Compare commits

..

77 Commits

Author SHA1 Message Date
snowzlmbot
8d168c836a fix(agents): preserve structured tool result visible text (#97268)
* fix(agents): preserve structured tool result visible text

* fix(agents): address structured tool result review blockers

* fix(agents): cover real structured tool result shapes

* fix(agents): preserve canonical tool error statuses

* fix(agents): harden structured result rendering

* fix(agents): preserve typed structured results

* fix(agents): bound provider result media

---------

Co-authored-by: snowzlmbot <293528334+snowzlmbot@users.noreply.github.com>
Co-authored-by: Peter Steinberger <steipete@golden-gate.local>
2026-06-28 02:38:43 -07:00
xydt-tanshanshan
d17b970bb5 [AI] fix(plugins): recognize document-extractors as a capability kind… (#91597)
* [AI] fix(plugins): recognize document-extractors as a capability kind in inspect-shape

PluginCapabilityKind did not include "document-extractors", causing
plugins that declare contracts.documentExtractors (like document-extract)
to show capabilityCount=0 and shape="non-capability" in plugins inspect.

Add "document-extractors" to PluginCapabilityKind and read from
plugin.contracts.documentExtractors in buildPluginCapabilityEntries().

Related to #91539

* [AI] test(plugins): add document-extractors shape contract coverage

Add a test case verifying that plugins declaring
contracts.documentExtractors are classified as
plain-capability shape with capabilityCount=1
and capabilities including the document-extractors kind.

Addresses ClawSweeper P2 review finding on PR #91597.

* [AI] chore: rebase on main to refresh CI

* test(plugins): fold extractor into shape matrix

---------

Co-authored-by: Peter Steinberger <steipete@golden-gate.local>
2026-06-28 02:06:10 -07:00
NIO
38ddcef78f Fix/zalo bound api json response reads (#97277)
* fix(zalo): bound Bot API JSON response reads via readProviderJsonResponse

* test(zalo): keep API proof in focused coverage

---------

Co-authored-by: NIO <nocodet@mail.com>
Co-authored-by: Peter Steinberger <steipete@golden-gate.local>
2026-06-28 02:04:22 -07:00
NIO
7ba9212665 Fix/discord bound probe getme json reads (#97278)
* fix(discord): bound probe getMe JSON response reads

* test(discord): add oversized probe getMe JSON regression

* test(discord): add loopback proof for bounded probe getMe reads

* fix(scripts): satisfy oxlint in discord probe proof script

* test(discord): keep probe proof in focused coverage

---------

Co-authored-by: NIO <nocodet@mail.com>
Co-authored-by: Peter Steinberger <steipete@golden-gate.local>
2026-06-28 01:57:20 -07:00
Peter Lee
6c7a6ff1c4 fix(cron): propagate cleanupCliLiveSessionOnRunEnd to isolated cron CLI branch (#97227)
* fix(cron): propagate cleanupCliLiveSessionOnRunEnd to isolated cron CLI branch

* test(cron): add CLI interim retry coverage for isolated cron cleanup flag

Verify cleanupCliLiveSessionOnRunEnd is passed on both the initial and
retry CLI runs during isolated cron interim-ack retry loops. Proves the
inner boundary is safe: each runCliAgent call creates a fresh context,
so cleanup cannot affect the retry's live session.

* fix(cron): remove unused variable in interim retry test

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(cron): trim redundant cleanup retry coverage

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Peter Steinberger <steipete@golden-gate.local>
2026-06-28 01:55:56 -07:00
Bek
9c95abd49d fix: seed Slack thread context after reset (#97100) 2026-06-28 02:36:53 -04:00
Dallin Romney
119dc4bd82 test: promote OpenAI HTTP QA coverage (#97369) 2026-06-27 22:28:31 -07:00
Dallin Romney
69af58ba26 ci: log macOS Swift build phases (#97151) 2026-06-27 22:06:56 -07:00
Galin Iliev
c6ade83a5c fix: avoid stale dashboard child context budgets (#97332)
* fix(gateway): avoid stale child session context tokens

* fix(gateway): avoid stale child session context tokens

---------

Co-authored-by: Galin Iliev <Galin.Iliev@microsoft.com>
2026-06-27 21:31:18 -07:00
Josh Avant
07b934901a fix: scanned PDF pages reach chat vision models (#97354)
* fix: forward scanned PDF page images to chat models

* fix: remove stale reply option cast
2026-06-27 23:01:08 -05:00
Gio Della-Libera
1b0766080a Add hosted catalog source profile validation (#95969)
Merged via squash.

Prepared head SHA: 6dc6ca154c
Co-authored-by: giodl73-repo <235387111+giodl73-repo@users.noreply.github.com>
Co-authored-by: giodl73-repo <235387111+giodl73-repo@users.noreply.github.com>
Reviewed-by: @giodl73-repo
2026-06-27 20:56:54 -07:00
Andy Ye
830467bc93 fix(imessage): stage remote media for plugin claims (#91803) 2026-06-27 19:36:21 -07:00
Gio Della-Libera
d1b917120a Persist hosted catalog snapshots in state (#95964)
Merged via squash.

Prepared head SHA: 372ec63c99
Co-authored-by: giodl73-repo <235387111+giodl73-repo@users.noreply.github.com>
Co-authored-by: giodl73-repo <235387111+giodl73-repo@users.noreply.github.com>
Reviewed-by: @giodl73-repo
2026-06-27 19:01:22 -07:00
Dallin Romney
78f7de01c1 test: link local e2e qa coverage wrappers (#97150) 2026-06-27 18:34:36 -07:00
Gio Della-Libera
3630d502eb Doctor: expose gateway runtime findings (#97075)
* feat(doctor): expose gateway runtime findings

* fix(doctor): redact gateway health targets
2026-06-27 17:17:49 -07:00
Dallin Romney
7bbd09047b docs: simplify macOS app overview (#97120)
* docs: simplify macOS app docs

* docs: preserve macOS app detail links

* docs: address macOS review feedback
2026-06-27 16:04:30 -07:00
Shakker
c53dbcaf4d fix: surface delegated Testbox proof status 2026-06-27 23:53:53 +01:00
Shakker
461e551e85 test: clarify delegated Testbox proof status 2026-06-27 23:53:52 +01:00
Galin Iliev
dc575d148a fix: page sessions_history beyond truncated tails (#97101)
* Add sessions history offset pagination

* Fix sessions_history pagination after tool caps

* Fix sessions_history PR CI blockers

* Update sessions_history prompt snapshots

* Fix offset history projection windows

---------

Co-authored-by: Galin Iliev <5711535+galiniliev@users.noreply.github.com>
2026-06-27 15:30:49 -07:00
Gio Della-Libera
12685ee6b7 Add hosted catalog snapshot fallback (#95877)
Merged via squash.

Prepared head SHA: 5f71635bba
Co-authored-by: giodl73-repo <235387111+giodl73-repo@users.noreply.github.com>
Co-authored-by: giodl73-repo <235387111+giodl73-repo@users.noreply.github.com>
Reviewed-by: @giodl73-repo
2026-06-27 15:26:49 -07:00
Gio Della-Libera
36722014ef Doctor: expose configured plugin install findings (#96171)
* feat(doctor): expose configured plugin install findings

* fix(doctor): satisfy configured plugin mapper lint
2026-06-27 14:18:50 -07:00
zengLingbiao
1b8b8500ce fix(auto-reply): truncate user-facing text on UTF-16 boundary (#97299)
Summary:
- The PR imports `truncateUtf16Safe` and uses it for the Codex usage-limit preview and verbose working-label truncation paths in auto-reply.
- PR surface: Source +2. Total +2 across 2 files.
- Reproducibility: yes. Current main uses raw `slice(0, N)` at both reported user-facing truncation sites, and ... ludes terminal before/after output showing dangling surrogates before the fix and safe truncation after it.

Automerge notes:
- No ClawSweeper repair was needed after automerge opt-in.

Validation:
- ClawSweeper review passed for head 74a0a32ed9.
- Required merge gates passed before the squash merge.

Prepared head SHA: 74a0a32ed9
Review: https://github.com/openclaw/openclaw/pull/97299#issuecomment-4820038635

Co-authored-by: zenglingbiao <zeng.lingbiao@xydigit.com>
Approved-by: takhoffman
2026-06-27 20:39:32 +00:00
Gio Della-Libera
c29e1fe764 Add hosted external catalog feed loader (#95868)
Merged via squash.

Prepared head SHA: 76da9328af
Co-authored-by: giodl73-repo <235387111+giodl73-repo@users.noreply.github.com>
Co-authored-by: giodl73-repo <235387111+giodl73-repo@users.noreply.github.com>
Reviewed-by: @giodl73-repo
2026-06-27 13:20:05 -07:00
Ayaan Zaidi
c52adf7505 test(telegram): cover retained preview chunk gaps 2026-06-27 12:30:57 -07:00
Ayaan Zaidi
199700de26 fix(telegram): replay retained preview gaps 2026-06-27 12:30:57 -07:00
Hannes Rudolph
b14a95b3fd docs: revert v2026.6.9 changelog update (#97306)
This reverts commit ebf1ba70d5.
2026-06-27 13:04:51 -06:00
Hannes Rudolph
ebf1ba70d5 docs: update changelog for v2026.6.9 (#97124) 2026-06-27 12:37:53 -06:00
Hannes Rudolph
78d70230b6 docs: align v2026.6.10 changelog heading (#97297) 2026-06-27 12:37:24 -06:00
shengting
98ed83f848 fix(model-fallback): don't rethrow provider-side AbortErrors as user cancellations (#90908)
* fix(model-fallback): don't rethrow provider-side AbortErrors as user cancellations

When the LLM API closes the connection mid-stream, the fetch layer
surfaces AbortError("This operation was aborted") with no external
abort signal triggered. The old guard `shouldRethrowAbort()` returned
false for these errors (because isTimeoutError matched the message),
so they fell through to the fallback loop but were never retried —
the error propagated up and produced SILENT_REPLY_TOKEN in group
sessions, permanently silencing the topic.

Replace the guard with a direct check: only rethrow AbortError when
the external abort signal is actually set (user/gateway cancellation).
Provider-side AbortErrors without an external signal now fall through
to the next fallback candidate, giving the system a chance to recover.

* fix(cron): forward abort signal into runWithModelFallback

Thread the cron executor's abort signal into the shared
runWithModelFallback call so that cron timeouts and cancellations
stop the fallback chain instead of retrying with the next candidate.

Previously, the run callback checked params.abortSignal?.aborted and
threw, but runWithModelFallback itself had no signal — so the new
guard in model-fallback.ts could not distinguish a caller abort from
a provider-side AbortError and would retry silently.

Also adds a focused regression test verifying the signal is forwarded.

---------

Co-authored-by: Shengting Xie <shengting@openclaw.ai>
Co-authored-by: yayu <yayu@yayuMacStudio.local>
2026-06-27 20:53:16 +03:00
Gio Della-Libera
1bdde66950 Doctor: expose plugin registry findings (#96169)
Merged via squash.

Prepared head SHA: cf4399955e
Co-authored-by: giodl73-repo <235387111+giodl73-repo@users.noreply.github.com>
Co-authored-by: giodl73-repo <235387111+giodl73-repo@users.noreply.github.com>
Reviewed-by: @giodl73-repo
2026-06-27 10:00:50 -07:00
llagy009
2720ac06b7 fix(duckduckgo): guard out-of-range numeric HTML entities (#96583)
decodeHtmlEntities decoded numeric entities with String.fromCodePoint(parseInt(...)) without a range check, so an out-of-range entity such as &#99999999; or &#x110000; threw RangeError and made the whole results page fail to parse. Validate the code point is within 0..0x10FFFF and keep the original entity text otherwise. Add a regression covering decimal and hex out-of-range entities plus a valid astral entity.
2026-06-27 09:37:58 -07:00
miorbnli
ce15f348bb fix(telegram): use idempotent retry context for delete/reaction (#96612)
reactMessageTelegram and deleteMessageTelegram passed context: "send" to
isRecoverableTelegramNetworkError, which disables message-snippet matching
(allowMessageMatch defaults to false only for "send"). Both operations are
idempotent (setMessageReaction / deleteMessage are safe to repeat), yet a
transient snippet-only network error (e.g. "socket hang up", "undici network
error" with no error code) was not retried — stricter than polling/webhook/
unknown, which all default allowMessageMatch to true. Users saw spurious
reaction/delete failures on transient network errors.

Add delete | react to TelegramNetworkErrorContext (additive) and use them at
the two callers. The helper default (context !== "send") is unchanged, so
delete/react now match polling/webhook/unknown. sendMessage keeps "send".

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-27 09:31:52 -07:00
llagy009
e5c3c59c67 fix(synology-chat): truncate sanitized input on UTF-16 boundary (#96574)
sanitizeInput truncated long messages with String.slice(0, 4000), which
can cut through an astral character's surrogate pair (e.g. an emoji at
the 4000-char boundary), leaving a lone surrogate in the sanitized text
passed downstream.

Use truncateUtf16Safe so truncation never splits a surrogate pair,
keeping the existing 4000-char budget and '... [truncated]' suffix.

Adds tests asserting the truncated output stays UTF-16 well formed and
that a supplementary-plane character is preserved when it fits.
2026-06-27 09:31:33 -07:00
llagy009
2e881ab1c6 fix(googlechat): truncate approval card text on UTF-16 boundary (#96573)
truncateText sliced the approval card text paragraph with String.slice,
which can cut through an astral character's surrogate pair (e.g. an emoji
straddling the 1797-char limit), leaving a lone surrogate in the card
text sent to Google Chat.

Use truncateUtf16Safe from the plugin SDK so truncation never splits a
surrogate pair, keeping the '...' suffix and the existing length budget.

Adds tests asserting the truncated Command card text stays UTF-16 well
formed and that an astral character is preserved when it fits.
2026-06-27 09:31:26 -07:00
llagy009
90c20d15c2 fix(slack): truncate approval mrkdwn on UTF-16 boundary (#96576)
truncateSlackMrkdwn cut approval Block Kit mrkdwn (Command/Request/
plugin description) with String.slice(0, maxChars - 1), which can split
an astral character's surrogate pair at the 2600-char preview limit,
leaving a lone surrogate in the chat.postMessage/chat.update payload.

Slice with sliceUtf16Safe so truncation never splits a surrogate pair,
keeping the existing ellipsis suffix and length budget.

Adds tests asserting exec command and plugin request mrkdwn stay free of
lone surrogates, plus a BMP regression keeping the existing limit.
2026-06-27 09:31:20 -07:00
llagy009
cb8bc71ff8 fix(whatsapp): elide auto-reply text on UTF-16 boundary (#96580)
elide truncated text with String.slice(0, limit) on a UTF-16 code-unit
index, so an astral character straddling the limit was cut into a lone
surrogate; the truncated-char count was also computed from the fixed
limit rather than the actual kept length.

Truncate with truncateUtf16Safe so a surrogate pair is never split, and
derive the truncated-char count from the kept length so the annotation
stays accurate.

Adds tests asserting no lone surrogate when the limit lands inside an
emoji and that a complete astral character is kept when it fits.
2026-06-27 09:31:16 -07:00
llagy009
b5c662f4f5 fix(codex): keep CLI session preview text on code-point boundaries (#96582)
truncateText shortened the cached lastMessage preview with value.slice(0, max - 3), which can cut a surrogate pair in half and emit a lone surrogate into the codex CLI session list JSON. Use the shared truncateUtf16Safe helper so truncation falls back to a whole code-point boundary. Add regressions for both the history.jsonl and sessions/**/*.jsonl preview paths.
2026-06-27 09:31:00 -07:00
llagy009
d693ed4af3 fix(qqbot): truncate reminder job name on code-point boundary (#96575)
generateJobName truncated reminder content with String.slice(0, 20) on
a UTF-16 code-unit index, so an astral character (e.g. an emoji) landing
on the boundary was cut into a lone surrogate, producing a malformed
cron job name.

Truncate with the shared truncateUtf16Safe helper so a surrogate pair is
never split, keeping the existing 20-unit budget and ellipsis suffix.

Adds a test asserting the truncated job name contains no lone surrogate.
2026-06-27 09:30:52 -07:00
llagy009
6c5a9fde9f fix(msteams): truncate reflection prompt on UTF-16 boundary (#96578)
buildReflectionPrompt truncated the thumbed-down response with
String.slice(0, 500) on a UTF-16 code-unit index, so an astral
character straddling the 500-char cap was cut into a lone surrogate in
the reflection prompt built for the LLM.

Use truncateUtf16Safe so truncation never splits a surrogate pair,
keeping the existing 500-char budget and '...' suffix.

Adds tests asserting the prompt stays UTF-16 well formed when truncating
and that a boundary emoji is preserved when it fits.
2026-06-27 09:30:26 -07:00
Vincent Koc
b8e3de1160 fix(telegram): recover stalled ingress spool claims (#97118)
* fix(telegram): drain ingress with native queue claims

* fix(telegram): bound native claim drain snapshots

* fix(telegram): recover pid-reused ingress claims

* fix(channels): block claimed candidate lanes

* fix(telegram): recover stalled ingress spool claims

* test(telegram): cover native claimNext drain stalls

---------

Co-authored-by: Dallin Romney <dallinromney@gmail.com>
2026-06-27 09:14:14 -07:00
mikasa
b9c64142e2 fix(agents): keep missing tool results on current model (#95543) 2026-06-27 19:06:56 +03:00
Milosz Jankiewicz
84bcd500c9 feat(xai): route OAuth login through device-code flow (#97249)
Route xAI OAuth through device-code sign-in so remote and headless hosts do not need a localhost callback. Preserve the legacy manual `xai-device-code` auth choice/method as a compatibility alias to the same device-code flow.

Also migrate stale xAI token endpoints on refresh and fail fast on structured refresh errors while keeping retries scoped to detected HTML/Cloudflare challenge responses.

Verification:
- `node scripts/run-vitest.mjs extensions/xai/index.test.ts extensions/xai/xai-oauth.test.ts`
- `node scripts/run-vitest.mjs src/cli/models-cli.test.ts -t 'maps --device-code'`
- `node scripts/run-vitest.mjs src/commands/auth-choice.test.ts -t 'removed provider auth choice'`
- Crabbox local-container live smoke on exact head `fef3cb24afb01cd1f69cf04ef67ed11d71dfadb3`: xAI discovery and device authorization returned 200.
- `$autoreview` after the live smoke: clean.

Co-authored-by: Jaaneek <Jaaneek@users.noreply.github.com>
Co-authored-by: fuller-stack-dev <263060202+fuller-stack-dev@users.noreply.github.com>
2026-06-27 10:02:57 -06:00
ly-wang19
f857e8d66e fix(terminal): wrap long wide-char words by visible width, not code-point count (#96746)
wrapNoteMessage measures every fit decision in visible columns, but its
splitLongWord fallback (for a single word longer than the line budget) sliced
the word into groups of maxLen code points. maxLen is a visible-column budget,
so a run of wide characters (CJK / fullwidth / emoji, 2 columns each) produced
lines up to twice the budget — e.g. a 24-char CJK word at maxWidth 20 emitted a
40-column line.

Accumulate grapheme visible width (the same unit visibleWidth uses) and start a
new segment when the next grapheme would exceed maxLen, so every wrapped segment
fits the budget; a single grapheme wider than the budget still emits, preserving
progress. ASCII wrapping is unchanged.

Co-authored-by: ly-wang19 <ly-wang19@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 08:50:45 -07:00
clawSean
a048aeae16 fix(qa-lab): treat claude-cli as Anthropic-family for live turn timeouts (#96567)
The live-frontier turn-timeout resolution only matched `anthropic/` models,
so `claude-cli/*` routes fell into the 120s fallback bucket instead of the
180s Anthropic floor, and claude-cli Opus missed the 240s Opus floor — even
though claude-cli serves the same Anthropic Claude models.

Recognize claude-cli as Anthropic-family via a small `isAnthropicFamilyModel`
helper, mirroring the existing `provider === "anthropic" || provider === "claude-cli"`
precedent in the aimock and mock-openai servers.

Co-authored-by: clawSean <260045960+clawSean@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 08:47:33 -07:00
llagy009
4b9e01813e fix(msteams): keep truncated parent context text well-formed (#96569)
summarizeParentMessage truncated the parent body with text.slice(0, PARENT_TEXT_MAX_CHARS - 1), which can cut a surrogate pair in half and emit a lone surrogate into the injected "Replying to @sender: …" system event. Use the shared truncateUtf16Safe helper so truncation falls back to a whole code-point boundary. Add a regression asserting the summary stays isWellFormed() when the limit lands inside an emoji.
2026-06-27 08:47:27 -07:00
llagy009
7830faa5fe fix(whatsapp): convert GFM bold-italic without leaving literal asterisks (#96570)
markdownToWhatsApp only handled **bold** and __bold__, so combined GFM
strong+emphasis such as ***bi***, __*y*__ or **_x_** was reduced by the
plain bold rule first and left a literal ** around the inner emphasis
(e.g. ***bi*** -> **bi**), which WhatsApp renders as plain characters.

Handle the combined strong+emphasis variants before the plain strong
rules and emit WhatsApp bold+italic (*_text_*). Plain bold, italic,
strikethrough and code-span handling are unchanged.

Adds it.each cases for the GFM bold-italic variants and a regression
case ensuring bold-italic markers inside inline code are preserved.
2026-06-27 08:46:45 -07:00
Masato Hoshino
ddedf13190 fix(irc): sanitize internal tool-trace lines from outbound text (#97214)
* fix(irc): sanitize internal tool-trace lines from outbound text

* fix(irc): sanitize internal tool-trace lines from outbound text

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-06-27 08:34:38 -07:00
Gio Della-Libera
cb4244fe15 Doctor: expose state integrity findings (#95979)
Merged via squash.

Prepared head SHA: eb3bd1adad
Co-authored-by: giodl73-repo <235387111+giodl73-repo@users.noreply.github.com>
Co-authored-by: giodl73-repo <235387111+giodl73-repo@users.noreply.github.com>
Reviewed-by: @giodl73-repo
2026-06-27 08:31:56 -07:00
zw-xysk
361869e434 fix(validation): preserve null in anyOf unions instead of coercing to empty string (fixes #96716) (#97212)
* fix(validation): preserve null in anyOf unions instead of coercing to empty string

Fixes #96716

* fix(validation): preserve null in anyOf unions instead of coercing to empty string

* fix(validation): preserve null in anyOf unions instead of coercing to empty string

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-06-27 08:25:42 -07:00
Gio Della-Libera
4010b81a77 Refactor external plugin catalog toward feeds (#95846)
Merged via squash.

Prepared head SHA: 82d05bdc46
Co-authored-by: giodl73-repo <235387111+giodl73-repo@users.noreply.github.com>
Co-authored-by: giodl73-repo <235387111+giodl73-repo@users.noreply.github.com>
Reviewed-by: @giodl73-repo
2026-06-27 07:41:40 -07:00
xingzhou
8fa24325b5 fix(nostr): bound seen tracker timer options (#97133) 2026-06-27 07:40:36 -07:00
mushuiyu886
f4fa10c2c5 fix(clickclack): bound REST success JSON response reads (#96970)
* fix(clickclack): bound REST success JSON response reads

* test(clickclack): harden response cap proof

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-06-27 07:30:32 -07:00
cornna
2100ee7cc8 fix(telegram): avoid duplicate dm chat window context (#89855)
Co-authored-by: Cornna <96944678+ymylive@users.noreply.github.com>
2026-06-27 07:29:57 -07:00
mushuiyu886
6e8f30c0e2 fix(qqbot): bound STT transcription JSON response (#96968) 2026-06-27 07:25:29 -07:00
ooiuuii
9d800b71c0 fix(scripts): route i18n formatter through pnpm runner (#95534) 2026-06-27 07:13:04 -07:00
mushuiyu886
5ccfc97b31 fix(google): bound TTS success JSON response reads (#96984) 2026-06-27 07:00:36 -07:00
mushuiyu886
a7bfc06f45 fix(google-media): bound JSON response reads (#96920)
* fix(google-media): bound JSON response reads

* test(google): relax media response cap assertion

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-06-27 06:46:58 -07:00
Kevin Lin
c5d34c8376 feat(codex): add always plugin approval mode (#97123)
* feat(codex): add always plugin approval mode

* fix(codex): normalize plugin approval decisions

* fix(codex): fail closed on layered approval overrides
2026-06-27 01:19:00 -07:00
joshavant
fbfadbd806 test: relax local TUI PTY startup wait 2026-06-26 23:08:14 -05:00
Josh Avant
6f1076351c fix: defer active implicit session rollover (#97164) 2026-06-26 22:42:45 -05:00
joshavant
898ca9741c test(trajectory): cover truncated usage preservation 2026-06-26 22:34:55 -05:00
lin-hongkuan
67118d5ab9 fix(trajectory): preserve codex completion usage 2026-06-26 22:34:55 -05:00
lin-hongkuan
bf2a8ecfdb fix(trajectory): preserve usage in truncated events 2026-06-26 22:34:55 -05:00
Josh Avant
cee2aca409 Scope agent cron operations to the calling agent (#96883)
* Scope agent cron operations to caller

* Scope OpenClaw tools MCP cron by session

* Address cron scope review feedback

* Preserve unscoped cron update retargeting

* Move cron caller identity into gateway context

* Clarify Gateway restart guidance

* Add cron caller identity regression proof
2026-06-26 21:41:14 -05:00
Peter Steinberger
56259606d1 fix(agent-core): ignore truncated tool calls (#97140)
* fix(agent-core): ignore truncated tool calls

Co-authored-by: Galin Iliev <5711535+galiniliev@users.noreply.github.com>

* fix(agent-core): require explicit tool-call terminals

---------

Co-authored-by: Galin Iliev <5711535+galiniliev@users.noreply.github.com>
2026-06-27 03:31:42 +01:00
weiqinl
552ec2b49d fix(opencode-go): re-arm idle timer on block-boundary events to prevent false stalled-stream abort (#97128)
* fix(opencode-go): re-arm idle timer on block-boundary events to prevent false stalled-stream abort

When the opencode-go model finalizes a tool call and deliberates before
the next one, the provider emits real block-boundary SSE events
(text_end, thinking_end, toolcall_start, toolcall_end) that prove the
socket is alive, but the watchdog's isProviderProgressEvent only
returned true for token deltas (text_delta, thinking_delta,
toolcall_delta). This caused the idle timer to fire and falsely abort a
live stream, replacing a completed answer with a stalled error and
dropping the provider's real done event.

Fix: include block-boundary events in isProviderProgressEvent so the
idle timer is re-armed on any forward-progress provider event.
text_start and thinking_start are intentionally excluded because they
are synthetic preamble events that should not shorten the first-event
window.

Closes #96518

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(opencode-go): satisfy lint in stream regression

* test(opencode-go): satisfy lint in stream regression

* test(opencode-go): satisfy lint in stream regression

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-06-27 09:48:42 +08:00
Gio Della-Libera
4d0f19a968 test(policy): add config coverage report (#87081)
Merged via squash.

Prepared head SHA: 689734541b
Co-authored-by: giodl73-repo <235387111+giodl73-repo@users.noreply.github.com>
Co-authored-by: giodl73-repo <235387111+giodl73-repo@users.noreply.github.com>
Reviewed-by: @giodl73-repo
2026-06-26 18:28:35 -07:00
Peter Steinberger
072d3ed7b5 fix(telegram): retain socket failure context (#97130)
Co-authored-by: zhang-guiping <zhang.guiping@xydigit.com>
2026-06-27 02:11:36 +01:00
wangmiao0668000666
1bccd29304 fix(provider-transport-fetch): bound SSE buffer to prevent OOM (#96989)
* fix(provider-transport-fetch): bound SSE buffer to prevent OOM

* fix(provider-transport-fetch): appease oxlint curly rule in test

* fix(provider-transport-fetch): drain events before cap + cancel reader on overflow

* fix(provider-transport-fetch): remove unused encoder from coalesced chunk test

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(transport): tighten SSE buffer guards

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-06-27 08:54:09 +08:00
pick-cat
498567190d fix(agents): guard delivery-evidence attachment recursion against cycles (#97041)
* fix(agents): guard delivery-evidence attachment recursion against cycles

* fix(agents): guard delivery-evidence attachment recursion against cycles

* fix(agents): guard delivery-evidence attachment recursion against cycles

---------

Co-authored-by: Pick-cat <266665499+Pick-cat@users.noreply.github.com>
Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-06-27 08:52:46 +08:00
wangmiao0668000666
5880e0afc4 fix(openai-chatgpt-responses): bound streaming success-body SSE reads at 16 MiB (#96762)
* fix(openai-chatgpt-responses): bound streaming success-body SSE reads at 16 MiB

Apply createSseByteGuard to src/llm/providers/openai-chatgpt-responses.ts
(parseSSE) so the ChatGPT Responses SSE parser cannot be exhausted by a
hostile or malfunctioning endpoint that streams an unbounded SSE body.
The 16 MiB cap matches the non-streaming readProviderJsonResponse cap.

What changed:
- src/llm/providers/openai-chatgpt-responses.ts: wrap body.getReader() in
  createSseByteGuard before the existing parseSSE loop. The function is
  also re-exported as parseSSEForTest for direct test access.
- Inline test added to openai-chatgpt-responses.test.ts: hostile 1 MiB
  pull stream, asserts canonical overflow message, cancel(reason) was an
  Error instance, pullCount bounded to 17-20.

Reuses the createSseByteGuard helper from src/agents/streaming-byte-guard.ts
(introduced in #96701 for the anthropic-transport-stream path; same
helper, same 16 MiB cap, same overflow-error shape). This PR is the
third of the planned per-surface rescue series for the previously closed
PR #96666, after PR #3b (#96723).

No SDK surface change. No repro script committed (proof in this body).

* fix(openai): bound ChatGPT error body reads

* fix(openai): bound chatgpt error parsing

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-06-27 08:35:10 +08:00
mushuiyu886
65fec9d787 fix(voice-call): emit canonical session keys (#89884)
* fix(voice-call): scope generated session keys by agent

* docs(voice-call): document session key canonicalization

* test(voice-call): prove legacy session migration

* fix(voice-call): preserve canonical session ownership

* fix(sessions): isolate opaque nested identities

* fix(voice-call): preserve routing ownership

* fix(voice-call): enforce inbound route direction

* fix(sessions): preserve migration and policy boundaries

* fix(sessions): normalize ambiguous main aliases

* fix(sessions): preserve canonical peer and warning paths

* fix(sessions): exclude mixed-case scoped legacy keys

* fix(sessions): cover first-run plugin migration gaps

* fix(sessions): compare aliased store identities

* fix(sessions): coalesce aliased store ownership

* fix(sessions): defer ambiguous aliased migrations

* fix(sessions): preserve shared migration boundaries

* fix(sessions): preserve opaque peer ownership

* fix(sessions): reject ambiguous ownership shapes

* fix(sessions): preserve transcript rewrite keys

* fix(sessions): close routing and migration ambiguities

* fix(sessions): preserve plugin-owned ACP aliases

* fix(sessions): retain physical store ownership

* fix(sessions): restore configured store owners

* fix(sessions): reject malformed store owners

* fix(sessions): validate ACP store ownership

* fix(sessions): include canonical store owners

* fix(sessions): preserve final store symlinks

* fix(sessions): retain shared row owners

* fix(sessions): close legacy policy gaps

* fix(sessions): preserve aliases across migrations

* fix(sessions): resolve first-run store ownership

* fix(sessions): preserve hostile legacy keys

* fix(sessions): inspect unlisted store owners

* test(doctor): refresh migration harness

* fix(sessions): preserve opaque route segments

* fix(sessions): retain metadata during migration

* fix(sessions): fail closed on store alias uncertainty

* fix(sessions): defer aliased store rewrites

* fix(sessions): retain legacy row owners

* test(sessions): harden migration proof

* fix(sessions): migrate opaque agent keys

* chore(plugin-sdk): refresh API baseline

* fix(voice-call): reuse public routing parser

* fix(sessions): retain readable alias warnings

* fix(sessions): reject opaque nested routes

* fix(sessions): share strict delivery parsing

* test(voice-call): preserve malformed Matrix case

* fix(sessions): reject legacy peer overlap

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-06-27 01:29:58 +01:00
Bartok
4d9cd7d227 fix(agents): truncate exec command detail on code-point boundaries (#96963)
* fix(agents): truncate exec command detail on code-point boundaries

compactRawCommand in src/agents/tool-display-exec.ts middle-truncated the
one-line command with raw String.prototype.slice. When the head or tail
boundary fell between the two UTF-16 code units of a surrogate pair (e.g. an
emoji like U+1F600), the slice kept a lone surrogate, which renders as the
replacement character in the tool-call summary shown in chat/transcripts.

Use the existing sliceUtf16Safe helper for both ends so the boundary falls on
a code-point boundary, dropping the whole emoji instead of half of it. This is
behavior-preserving for non-surrogate input.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor: move surrogate-safe slice helpers to browser-safe module

ClawSweeper flagged that importing sliceUtf16Safe from src/utils.ts into
tool-display-exec.ts pulls node:fs/os/path into the Control UI browser-shared
bundle (ui/vite.config.ts treats tool-display-exec.ts as browser-shared).

Move sliceUtf16Safe/truncateUtf16Safe into a self-contained, dependency-free
module src/shared/text/surrogate-safe-slice.ts. src/utils.ts re-exports them
(zero churn for existing Node-side callers), and tool-display-exec.ts now
imports directly from the node-free module so no Node built-ins can leak into
the browser bundle.

* fix(agents): use shared utf16 helper in exec display

---------

Co-authored-by: ly-wang19 <ly-wang19@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-06-27 08:24:14 +08:00
linhongkuan
12ea61a08d chore(catalog): bump Weixin plugin to 2.4.6 (#96801)
* chore(catalog): bump Weixin plugin to 2.4.6

* chore(catalog): align Weixin host floor

* chore: retrigger PR checks

---------

Co-authored-by: lin-hongkuan <lin-hongkuan@users.noreply.github.com>
2026-06-27 08:12:12 +08:00
Wynne668
4932366b92 fix(cli): keep built-in nodes commands off the plugin load path (#96702)
registerNodesCli unconditionally registered plugin CLI commands, so
lightweight built-in commands like `nodes status`/`nodes list` paid the
full plugin CLI/runtime load cost. Only resolve plugin-provided node
subcommands (e.g. `nodes canvas`) when the invoked subcommand is not
already a built-in, keeping the built-in nodes path fast.

Fixes #96697
2026-06-27 08:11:31 +08:00
wangmiao0668000666
4f3d81b918 fix(googlechat): replace unbounded response.json() with readProviderJsonResponse (#96772)
* fix(googlechat): replace unbounded response.json() with readProviderJsonResponse

Replace the local readGoogleChatJsonResponse and
readGoogleChatCertsResponse wrappers with the existing SDK helper
readProviderJsonResponse (from openclaw/plugin-sdk/provider-http) so the
Google Chat API JSON responses are bounded at 16 MiB, matching the
non-streaming cap already used by 15+ other extensions.

What changed:
- extensions/googlechat/src/api.ts: readGoogleChatJsonResponse now
  delegates to readProviderJsonResponse. Removed the local try/catch
  wrapper.
- extensions/googlechat/src/auth.ts: readGoogleChatCertsResponse now
  delegates to readProviderJsonResponse. Error message preserved.
  Removed the local try/catch wrapper.

This PR applies the same pattern as Alix-007's #96042, #96038 (lmstudio,
provider JSON reads). No SDK promotion needed — readProviderJsonResponse
is already available in openclaw/plugin-sdk/provider-http.

* fix(googlechat): add inline bounded-read regression tests

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(googlechat): remove unused variable flagged by oxlint

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(googlechat): bound api error body reads

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-06-27 08:11:10 +08:00
Bartok
e09b9dfc1b fix(agents): truncate tool-display detail on code-point boundaries (#96958)
* fix(agents): truncate tool-display detail on code-point boundaries

coerceDisplayValue truncated a long first-line detail value with raw
UTF-16 slice() at half = floor((maxStringChars-1)/2). When an emoji
(surrogate pair) straddles the cut boundary, the head kept a lone high
surrogate and the tail could begin on a lone low surrogate, rendering as
the replacement character. Use the existing sliceUtf16Safe helper so the
whole code point is dropped at the boundary, matching the UTF-16-safe
truncation used elsewhere in the repo. Behavior-preserving for
non-surrogate input.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(build): import surrogate-safe slice from node-free leaf module

tool-display-common.ts is in the UI browser bundle graph; importing
sliceUtf16Safe from utils.js dragged node:fs (via infra/fs-safe) into the
bundle and broke build-artifacts/QA Smoke. Extract sliceUtf16Safe/truncateUtf16Safe
into src/shared/utf16-slice.ts (dependency-free) and re-export from utils.js to
preserve the existing import surface.

* fix(build): import surrogate-safe slice from node-free leaf module

* fix(build): import surrogate-safe slice from node-free leaf module

* fix(build): import surrogate-safe slice from node-free leaf module

---------

Co-authored-by: ly-wang19 <ly-wang19@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-06-27 08:10:06 +08:00
358 changed files with 21667 additions and 20048 deletions

View File

@@ -848,28 +848,6 @@ jobs:
path: .local/gateway-watch-regression/
retention-days: 7
native-i18n:
permissions:
contents: read
needs: [preflight]
if: ${{ !cancelled() && always() && (needs.preflight.outputs.run_macos == 'true' || needs.preflight.outputs.run_android == 'true' || needs.preflight.outputs.run_node == 'true') }}
runs-on: ${{ github.repository == 'openclaw/openclaw' && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-24.04' }}
timeout-minutes: 10
steps:
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
ref: ${{ needs.preflight.outputs.checkout_revision }}
persist-credentials: false
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
install-bun: "false"
- name: Check native app i18n inventory
run: pnpm native:i18n:check
checks-fast-core:
permissions:
contents: read

View File

@@ -81,7 +81,7 @@ Automatic fast mode starts short conversations quickly, then returns longer or f
- Prevents [Docker](https://docs.openclaw.ai/install/docker) and [Podman](https://docs.openclaw.ai/install/podman) setup from running unbounded on hosts where GNU timeout is installed as `gtimeout`, so image pulls, builds, and detached startup receive the intended guard. [62b2e9e](https://github.com/openclaw/openclaw/commit/62b2e9ef14b4be6fd396621c8e5e248331f08695).
### Plugins, Packaging, and QA
### Plugins and Packaging
#### Codex service-tier clearing
@@ -96,7 +96,6 @@ Automatic fast mode starts short conversations quickly, then returns longer or f
#### Doctor check ordering
- Keeps core [`openclaw doctor`](https://docs.openclaw.ai/gateway/doctor) diagnostics in their normal order before extension checks, making lint and repair output easier to follow. [PR #86627](https://github.com/openclaw/openclaw/pull/86627). Thanks @giodl73-repo.
## 2026.6.9
### Highlights

File diff suppressed because it is too large Load Diff

View File

@@ -7187,17 +7187,20 @@ public struct ChatHistoryParams: Codable, Sendable {
public let sessionkey: String
public let agentid: String?
public let limit: Int?
public let offset: Int?
public let maxchars: Int?
public init(
sessionkey: String,
agentid: String? = nil,
limit: Int?,
offset: Int? = nil,
maxchars: Int?)
{
self.sessionkey = sessionkey
self.agentid = agentid
self.limit = limit
self.offset = offset
self.maxchars = maxchars
}
@@ -7205,6 +7208,7 @@ public struct ChatHistoryParams: Codable, Sendable {
case sessionkey = "sessionKey"
case agentid = "agentId"
case limit
case offset
case maxchars = "maxChars"
}
}

View File

@@ -1,2 +1,2 @@
760812c17f7e48d7ceafeebbbe348dad13916ccb9ecaf41b3abc9a09b1e690c1 plugin-sdk-api-baseline.json
4d9b76016b2f845e101949a3d2ac92437f49783906d1c263d65f3534bb333de5 plugin-sdk-api-baseline.jsonl
ad74d16da51b0d6da0b1fad75b4903c5554e02ac41c00ee5dae89fa63ceef802 plugin-sdk-api-baseline.json
d1a9c33c40c039cda1f5d1a7d29ed20fb88d073b6a84899faca0a44fbc5a5092 plugin-sdk-api-baseline.jsonl

View File

@@ -651,7 +651,16 @@ pnpm crabbox:run -- --provider blacksmith-testbox \
"corepack pnpm test"
```
Read the final JSON summary. The useful fields are `provider`, `leaseId`, `syncDelegated`, `exitCode`, `commandMs`, and `totalMs`. One-shot Blacksmith-backed Crabbox runs should stop the Testbox automatically; if a run is interrupted or cleanup is unclear, inspect live boxes and stop only the boxes you created:
Read the final JSON summary. The useful fields are `provider`, `leaseId`,
`syncDelegated`, `exitCode`, `commandMs`, and `totalMs`. For delegated
Blacksmith Testbox runs, the Crabbox wrapper exit code and JSON summary are the
command result. The linked GitHub Actions run owns hydration and keepalive; it
can finish as `cancelled` when the Testbox is stopped externally after the SSH
command has already returned. Treat that as a cleanup/status artifact unless
the wrapper `exitCode` is non-zero or the command output shows a failed test.
One-shot Blacksmith-backed Crabbox runs should stop the Testbox automatically;
if a run is interrupted or cleanup is unclear, inspect live boxes and stop only
the boxes you created:
```bash
blacksmith testbox list --all

View File

@@ -297,7 +297,8 @@ tool-call XML payloads (including `<tool_call>...</tool_call>`,
downgraded tool-call scaffolding / leaked ASCII/full-width model control
tokens / malformed MiniMax tool-call XML from assistant recall, and can
replace oversized rows with `[sessions_history omitted: message too large]`
instead of returning a raw transcript dump.
instead of returning a raw transcript dump. Use `nextOffset` when present to
page backward through older transcript windows.
## Scaling pattern

View File

@@ -58,6 +58,11 @@ results may be scope-limited.
`sessions_history` fetches the conversation transcript for a specific session.
By default, tool results are excluded -- pass `includeTools: true` to see them.
Use `limit` for the newest bounded tail. Pass `offset: 0` when you need
pagination metadata, then pass returned `nextOffset` values to page backward
through older OpenClaw transcript windows without reading raw transcript files.
Explicit offset pages do not merge external CLI fallback imports; use the
default newest-tail view when you need that merged display history.
The returned view is intentionally bounded and safety-filtered:
- assistant text is normalized before recall:
@@ -78,7 +83,7 @@ The returned view is intentionally bounded and safety-filtered:
- very large histories can drop older rows or replace an oversized row with
`[sessions_history omitted: message too large]`
- the tool reports summary flags such as `truncated`, `droppedMessages`,
`contentTruncated`, `contentRedacted`, and `bytes`
`contentTruncated`, `contentRedacted`, `bytes`, and pagination metadata
Both tools accept either a **session key** (like `"main"`) or a **session ID**
from a previous list call.

View File

@@ -316,6 +316,11 @@ conversation bindings, or any non-Codex harness.
plugin/app support for the Codex harness. Default: `false`.
- `plugins.entries.codex.config.codexPlugins.allow_destructive_actions`:
default destructive-action policy for migrated plugin app elicitations.
Use `true` to accept safe Codex approval schemas without prompting, `false`
to decline them, `"auto"` to route Codex-required approvals through OpenClaw
plugin approvals, or `"always"` to ask for every plugin write/destructive
action without durable approval. The `"always"` mode clears durable Codex
per-tool approval overrides for the affected app before starting the thread.
Default: `true`.
- `plugins.entries.codex.config.codexPlugins.plugins.<key>.enabled`: enables a
migrated plugin entry when global `codexPlugins.enabled` is also true.
@@ -326,7 +331,8 @@ conversation bindings, or any non-Codex harness.
Codex plugin identity from migration, for example `"google-calendar"`.
- `plugins.entries.codex.config.codexPlugins.plugins.<key>.allow_destructive_actions`:
per-plugin destructive-action override. When omitted, the global
`allow_destructive_actions` value is used.
`allow_destructive_actions` value is used. The per-plugin value accepts the
same `true`, `false`, `"auto"`, or `"always"` policies.
`codexPlugins.enabled` is the global enablement directive. Explicit plugin
entries written by migration are the durable install and repair eligibility set.

View File

@@ -321,7 +321,7 @@ When `mode: "all"`, outputs are labeled `[Image 1/2]`, `[Audio 2/2]`, etc.
- The injected block uses explicit boundary markers like `<<<EXTERNAL_UNTRUSTED_CONTENT id="...">>>` / `<<<END_EXTERNAL_UNTRUSTED_CONTENT id="...">>>` and includes a `Source: External` metadata line.
- This attachment-extraction path intentionally omits the long `SECURITY NOTICE:` banner to avoid bloating the media prompt; the boundary markers and metadata still remain.
- If a file has no extractable text, OpenClaw injects `[No extractable text]`.
- If a PDF falls back to rendered page images in this path, the media prompt keeps the placeholder `[PDF content rendered to images; images not forwarded to model]` because this attachment-extraction step forwards text blocks, not the rendered PDF images.
- If a PDF falls back to rendered page images in this path, OpenClaw forwards those page images to vision-capable reply models and keeps the placeholder `[PDF content rendered to images]` in the file block.
</Accordion>
</AccordionGroup>

View File

@@ -57,6 +57,34 @@ Logging:
The macOS app checks the gateway version against its own version. If they're
incompatible, update the global CLI to match the app version.
## State directory on macOS
Keep OpenClaw state on a local, non-synced disk. Avoid iCloud Drive and other
cloud-synced folders because sync latency and file locks can affect sessions,
credentials, and Gateway state.
Set `OPENCLAW_STATE_DIR` to a local path only when you need an override.
`openclaw doctor` warns about common cloud-synced state paths and recommends
moving back to local storage. See
[environment variables](/help/environment#path-related-env-vars) and
[Doctor](/gateway/doctor).
## Debug app connectivity
Use the macOS debug CLI from a source checkout to exercise the same Gateway
WebSocket handshake and discovery logic the app uses:
```bash
cd apps/macos
swift run openclaw-mac connect --json
swift run openclaw-mac discover --timeout 3000 --json
```
`connect` accepts `--url`, `--token`, `--timeout`, and `--json`. `discover`
accepts `--timeout`, `--json`, and `--include-local`. Compare discovery output
with `openclaw gateway discover --json` when you need to separate CLI discovery
from app-side connection issues.
## Smoke check
```bash

View File

@@ -114,7 +114,18 @@ Example (in JS):
window.location.href = "openclaw://agent?message=Review%20this%20design";
```
The app prompts for confirmation unless a valid key is provided.
Supported query parameters:
- `message`: prefilled agent prompt.
- `sessionKey`: stable session identifier.
- `thinking`: optional thinking profile.
- `deliver`, `to`, or `channel`: delivery target.
- `timeoutSeconds`: optional run timeout.
- `key`: app-generated safety token for trusted local callers.
The app prompts for confirmation unless a valid key is provided. Unkeyed links
show the message and URL before approval, and ignore delivery routing fields;
keyed links use the normal Gateway run path.
## Security notes

View File

@@ -24,6 +24,9 @@ In SSH tunnel mode, discovered LAN/tailnet hostnames are saved as
`gateway.remote.sshTarget`. The app keeps `gateway.remote.url` on the local
tunnel endpoint, for example `ws://127.0.0.1:18789`, so CLI, Web Chat, and
the local node-host service all use the same safe loopback transport.
When discovery returns both raw Tailnet IPs and stable hostnames, the app
prefers Tailscale MagicDNS or LAN names so remote connections survive address
changes better.
If the local tunnel port differs from the remote gateway port, set
`gateway.remote.remotePort` to the port on the remote host.

View File

@@ -21,6 +21,10 @@ title: "macOS IPC"
- The app runs the Gateway (local mode) and connects to it as a node.
- Agent actions are performed via `node.invoke` (e.g. `system.run`, `system.notify`, `canvas.*`).
- Common Mac node commands include `canvas.*`, `camera.snap`, `camera.clip`,
`screen.snapshot`, `screen.record`, `system.run`, and `system.notify`.
- The node reports a `permissions` map so agents can see whether screen,
camera, microphone, speech, automation, or accessibility access is available.
### Node service + app IPC

View File

@@ -1,228 +1,87 @@
---
summary: "OpenClaw macOS companion app (menu bar + gateway broker)"
summary: "Install and use the OpenClaw macOS menu bar app"
read_when:
- Implementing macOS app features
- Changing gateway lifecycle or node bridging on macOS
- Installing the macOS app
- Deciding between local and remote Gateway mode on macOS
- Looking for macOS app release downloads
title: "macOS app"
---
The macOS app is the **menu-bar companion** for OpenClaw. It owns permissions,
manages/attaches to the Gateway locally (launchd or manual), and exposes macOS
capabilities to the agent as a node.
The macOS app is the OpenClaw **menu bar companion**. Use it when you want a
native tray UI, macOS permission prompts, notifications, WebChat, voice input,
Canvas, or Mac-hosted node tools such as `system.run`.
## What it does
If you only need the CLI and Gateway, start with [Getting started](/start/getting-started).
- Shows native notifications and status in the menu bar.
- Owns TCC prompts (Notifications, Accessibility, Screen Recording, Microphone,
Speech Recognition, Automation/AppleScript).
- Runs or connects to the Gateway (local or remote).
- Exposes macOS-only tools (Canvas, Camera, Screen Recording, `system.run`).
- Starts the local node host service in **remote** mode (launchd), and stops it in **local** mode.
- Optionally hosts **PeekabooBridge** for UI automation.
- Installs the global CLI (`openclaw`) on request via npm, pnpm, or bun (the app prefers npm, then pnpm, then bun; Node remains the recommended Gateway runtime).
## Download
## Local vs remote mode
Download macOS app builds from the
[OpenClaw GitHub releases](https://github.com/openclaw/openclaw/releases).
When a release includes macOS app assets, look for:
- **Local** (default): the app attaches to a running local Gateway if present;
otherwise it enables the launchd service via `openclaw gateway install`.
- **Remote**: the app connects to a Gateway over SSH/Tailscale and never starts
a local process.
The app starts the local **node host service** so the remote Gateway can reach this Mac.
The app does not spawn the Gateway as a child process.
Gateway discovery now prefers Tailscale MagicDNS names over raw tailnet IPs,
so the Mac app recovers more reliably when tailnet IPs change.
- `OpenClaw-<version>.dmg` (preferred)
- `OpenClaw-<version>.zip`
## Launchd control
Some releases only include CLI, evidence, or Windows assets. If the newest
release has no macOS app asset, use the newest release that does, or build the
app from source with [macOS dev setup](/platforms/mac/dev-setup).
The app manages a per-user LaunchAgent labeled `ai.openclaw.gateway`
(or `ai.openclaw.<profile>` when using `--profile`/`OPENCLAW_PROFILE`; legacy `com.openclaw.*` still unloads).
```bash
launchctl kickstart -k gui/$UID/ai.openclaw.gateway
launchctl bootout gui/$UID/ai.openclaw.gateway
```
Replace the label with `ai.openclaw.<profile>` when running a named profile.
If the LaunchAgent isn't installed, enable it from the app or run
`openclaw gateway install`.
If the gateway repeatedly disappears for minutes to hours and only resumes when you touch the Control UI or SSH into the host, see the troubleshooting note for macOS Maintenance Sleep / `ENETDOWN` crashes and launchd's respawn-protection gate in [Gateway troubleshooting](/gateway/troubleshooting#macos-gateway-silently-stops-responding-then-resumes-when-you-touch-the-dashboard).
## Node capabilities (mac)
The macOS app presents itself as a node. Common commands:
- Canvas: `canvas.present`, `canvas.navigate`, `canvas.eval`, `canvas.snapshot`, `canvas.a2ui.*`
- Camera: `camera.snap`, `camera.clip`
- Screen: `screen.snapshot`, `screen.record`
- System: `system.run`, `system.notify`
The node reports a `permissions` map so agents can decide what's allowed.
Node service + app IPC:
- When the headless node host service is running (remote mode), it connects to the Gateway WS as a node.
- `system.run` executes in the macOS app (UI/TCC context) over a local Unix socket; prompts + output stay in-app.
Diagram (SCI):
```
Gateway -> Node Service (WS)
| IPC (UDS + token + HMAC + TTL)
v
Mac App (UI + TCC + system.run)
```
## Exec approvals (system.run)
`system.run` is controlled by **Exec approvals** in the macOS app (Settings → Exec approvals).
Security + ask + allowlist are stored locally on the Mac in:
```
~/.openclaw/exec-approvals.json
```
Example:
```json
{
"version": 1,
"defaults": {
"security": "deny",
"ask": "on-miss"
},
"agents": {
"main": {
"security": "allowlist",
"ask": "on-miss",
"allowlist": [{ "pattern": "/opt/homebrew/bin/rg" }]
}
}
}
```
Notes:
- `allowlist` entries are glob patterns for resolved binary paths, or bare command names for PATH-invoked commands.
- Raw shell command text that contains shell control or expansion syntax (`&&`, `||`, `;`, `|`, `` ` ``, `$`, `<`, `>`, `(`, `)`) is treated as an allowlist miss and requires explicit approval (or allowlisting the shell binary).
- Choosing "Always Allow" in the prompt adds that command to the allowlist.
- `system.run` environment overrides are filtered (drops `PATH`, `DYLD_*`, `LD_*`, `BASHOPTS`, `FPATH`, `KSH_ENV`, `NODE_OPTIONS`, `NODE_REDIRECT_WARNINGS`, `NODE_REPL_EXTERNAL_MODULE`, `NODE_REPL_HISTORY`, `NODE_V8_COVERAGE`, `PYTHON*`, `PERL*`, `RUBYOPT`, `SHELLOPTS`, `PS4`, `TCLLIBPATH`) and then merged with the app's environment.
- For shell wrappers (`bash|sh|zsh ... -c/-lc`), request-scoped environment overrides are reduced to a small explicit allowlist (`TERM`, `LANG`, `LC_*`, `COLORTERM`, `NO_COLOR`, `FORCE_COLOR`).
- For allow-always decisions in allowlist mode, known dispatch wrappers (`env`, `flock`, `nice`, `nohup`, `stdbuf`, `timeout`) persist inner executable paths instead of wrapper paths. If unwrapping is not safe, no allowlist entry is persisted automatically.
## Deep links
The app registers the `openclaw://` URL scheme for local actions.
### `openclaw://agent`
Triggers a Gateway `agent` request.
```bash
open 'openclaw://agent?message=Hello%20from%20deep%20link'
```
Query parameters:
- `message` (required)
- `sessionKey` (optional)
- `thinking` (optional)
- `deliver` / `to` / `channel` (optional)
- `timeoutSeconds` (optional)
- `key` (optional unattended mode key)
Safety:
- Without `key`, the app prompts for confirmation.
- Without `key`, the app enforces a short message limit for the confirmation prompt and ignores `deliver` / `to` / `channel`.
- With a valid `key`, the run is unattended (intended for personal automations).
## Onboarding flow (typical)
## First run
1. Install and launch **OpenClaw.app**.
2. Complete the permissions checklist (TCC prompts).
3. Ensure **Local** mode is active and the Gateway is running.
4. Install the CLI if you want terminal access.
2. Complete the macOS permission checklist.
3. Pick **Local** or **Remote** mode.
4. Install the `openclaw` CLI if the app asks for it.
5. Open WebChat from the menu bar and send a test message.
## State dir placement (macOS)
For the CLI/Gateway setup path, use [Getting started](/start/getting-started).
For permission recovery, use [macOS permissions](/platforms/mac/permissions).
Avoid putting your OpenClaw state dir in iCloud or other cloud-synced folders.
Sync-backed paths can add latency and occasionally cause file-lock/sync races for
sessions and credentials.
## Choose a Gateway mode
Prefer a local non-synced state path such as:
| Mode | Use it when | Detail page |
| ------ | --------------------------------------------------------------------------------------- | -------------------------------------------------- |
| Local | This Mac should run the Gateway and keep it alive with launchd. | [Gateway on macOS](/platforms/mac/bundled-gateway) |
| Remote | Another host runs the Gateway and this Mac should control it over SSH, LAN, or Tailnet. | [Remote control](/platforms/mac/remote) |
```bash
OPENCLAW_STATE_DIR=~/.openclaw
```
Local mode requires an installed `openclaw` CLI. The app can install it, or you
can follow [Gateway on macOS](/platforms/mac/bundled-gateway).
If `openclaw doctor` detects state under:
## What the app owns
- `~/Library/Mobile Documents/com~apple~CloudDocs/...`
- `~/Library/CloudStorage/...`
- Menu bar status, notifications, health, and WebChat.
- macOS permission prompts for screen, microphone, speech, automation, and accessibility.
- Local node tools such as Canvas, camera/screen capture, notifications, and `system.run`.
- Exec approval prompts for Mac-hosted commands.
- Remote-mode SSH tunnels or direct Gateway connections.
it will warn and recommend moving back to a local path.
The app does **not** replace the OpenClaw Gateway or general CLI docs. Core
Gateway configuration, providers, plugins, channels, tools, and security live in
their own docs.
## Build and dev workflow (native)
## macOS detail pages
- `cd apps/macos && swift build`
- `swift run OpenClaw` (or Xcode)
- Package app: `scripts/package-mac-app.sh`
| Task | Read |
| ---------------------------------------- | ------------------------------------------------------------------------------------------- |
| Install or debug the CLI/Gateway service | [Gateway on macOS](/platforms/mac/bundled-gateway) |
| Keep state out of cloud-synced folders | [Gateway on macOS](/platforms/mac/bundled-gateway#state-directory-on-macos) |
| Debug app discovery and connectivity | [Gateway on macOS](/platforms/mac/bundled-gateway#debug-app-connectivity) |
| Understand launchd behavior | [Gateway lifecycle](/platforms/mac/child-process) |
| Fix permissions or signing/TCC issues | [macOS permissions](/platforms/mac/permissions) |
| Connect to a remote Gateway | [Remote control](/platforms/mac/remote) |
| Read menu bar status and health checks | [Menu bar](/platforms/mac/menu-bar), [Health checks](/platforms/mac/health) |
| Use the embedded chat UI | [WebChat](/platforms/mac/webchat) |
| Use voice wake or push-to-talk | [Voice wake](/platforms/mac/voicewake) |
| Use Canvas and Canvas deep links | [Canvas](/platforms/mac/canvas) |
| Host PeekabooBridge for UI automation | [Peekaboo bridge](/platforms/mac/peekaboo) |
| Configure command approvals | [Exec approvals](/tools/exec-approvals), [advanced details](/tools/exec-approvals-advanced) |
| Inspect Mac node commands and app IPC | [macOS IPC](/platforms/mac/xpc) |
| Capture logs | [macOS logging](/platforms/mac/logging) |
| Build from source | [macOS dev setup](/platforms/mac/dev-setup) |
## Debug gateway connectivity (macOS CLI)
## Related
Use the debug CLI to exercise the same Gateway WebSocket handshake and discovery
logic that the macOS app uses, without launching the app.
```bash
cd apps/macos
swift run openclaw-mac connect --json
swift run openclaw-mac discover --timeout 3000 --json
```
Connect options:
- `--url <ws://host:port>`: override config
- `--mode <local|remote>`: resolve from config (default: config or local)
- `--probe`: force a fresh health probe
- `--timeout <ms>`: request timeout (default: `15000`)
- `--json`: structured output for diffing
Discovery options:
- `--include-local`: include gateways that would be filtered as "local"
- `--timeout <ms>`: overall discovery window (default: `2000`)
- `--json`: structured output for diffing
<Tip>
Compare against `openclaw gateway discover --json` to see whether the macOS app's discovery pipeline (`local.` plus the configured wide-area domain, with wide-area and Tailscale Serve fallbacks) differs from the Node CLI's `dns-sd` based discovery.
</Tip>
## Remote connection plumbing (SSH tunnels)
When the macOS app runs in **Remote** mode, it opens an SSH tunnel so local UI
components can talk to a remote Gateway as if it were on localhost.
### Control tunnel (Gateway WebSocket port)
- **Purpose:** health checks, status, Web Chat, config, and other control-plane calls.
- **Local port:** the Gateway port (default `18789`), always stable.
- **Remote port:** the same Gateway port on the remote host.
- **Behavior:** no random local port; the app reuses an existing healthy tunnel
or restarts it if needed.
- **SSH shape:** `ssh -N -L <local>:127.0.0.1:<remote>` with BatchMode +
ExitOnForwardFailure + keepalive options.
- **IP reporting:** the SSH tunnel uses loopback, so the gateway will see the node
IP as `127.0.0.1`. Use **Direct (ws/wss)** transport if you want the real client
IP to appear (see [macOS remote access](/platforms/mac/remote)).
For setup steps, see [macOS remote access](/platforms/mac/remote). For protocol
details, see [Gateway protocol](/gateway/protocol).
## Related docs
- [Gateway runbook](/gateway)
- [Gateway (macOS)](/platforms/mac/bundled-gateway)
- [macOS permissions](/platforms/mac/permissions)
- [Canvas](/platforms/mac/canvas)
- [Platforms](/platforms)
- [Getting started](/start/getting-started)
- [Gateway](/gateway)
- [Exec approvals](/tools/exec-approvals)

View File

@@ -200,11 +200,11 @@ enabled.
OpenClaw sets app-level `destructive_enabled` from the effective global or
per-plugin `allow_destructive_actions` policy and lets Codex enforce
destructive tool metadata from its native app tool annotations. `true` and
`"auto"` both set `destructive_enabled: true`; `false` sets it false. The
`_default` app config is disabled with `open_world_enabled: false`. Enabled
plugin apps are emitted with `open_world_enabled: true`; OpenClaw does not
expose a separate plugin open-world policy knob and does not maintain
destructive tool metadata from its native app tool annotations. `true`,
`"auto"`, and `"always"` set `destructive_enabled: true`; `false` sets it
false. The `_default` app config is disabled with `open_world_enabled: false`.
Enabled plugin apps are emitted with `open_world_enabled: true`; OpenClaw does
not expose a separate plugin open-world policy knob and does not maintain
per-plugin destructive tool-name deny lists.
Tool approval mode is automatic by default for plugin apps so non-destructive
@@ -225,6 +225,10 @@ plugins, while unsafe schemas and ambiguous ownership still fail closed:
- When policy is `"auto"`, OpenClaw exposes destructive plugin actions to
Codex but turns ownership-proven MCP approval elicitations into OpenClaw
plugin approvals before returning the Codex approval response.
- When policy is `"always"`, OpenClaw uses the same Codex write/destructive
gating as `"auto"`, clears durable Codex per-tool approval overrides for the
app before the thread starts, and only offers one-shot approval or denial so
durable approvals cannot suppress later write-action prompts.
- Missing plugin identity, ambiguous ownership, a missing turn id, a wrong turn
id, or an unsafe elicitation schema declines instead of prompting.
@@ -272,8 +276,9 @@ Codex thread bindings keep the app config they started with until OpenClaw
establishes a new harness session or replaces a stale binding.
**Destructive action is declined:** check the global and per-plugin
`allow_destructive_actions` values. Even when policy is true or `"auto"`,
unsafe elicitation schemas and ambiguous plugin identity still fail closed.
`allow_destructive_actions` values. Even when policy is true, `"auto"`, or
`"always"`, unsafe elicitation schemas and ambiguous plugin identity still fail
closed.
## Related

View File

@@ -102,6 +102,7 @@ import { mockNodeBuiltinModule } from "openclaw/plugin-sdk/test-node-mocks";
| `registerProviderPlugins` | Capture provider registrations across multiple plugins. Import from `plugin-sdk/plugin-test-runtime` |
| `requireRegisteredProvider` | Assert that a provider collection contains an id. Import from `plugin-sdk/plugin-test-runtime` |
| `createRuntimeEnv` | Build a mocked CLI/plugin runtime environment. Import from `plugin-sdk/plugin-test-runtime` |
| `createPluginRuntimeMock` | Build a mocked plugin runtime surface. Import from `plugin-sdk/plugin-test-runtime` |
| `createPluginSetupWizardStatus` | Build setup status helpers for channel plugins. Import from `plugin-sdk/plugin-test-runtime` |
| `describeOpenAIProviderRuntimeContract` | Install provider-family runtime contract checks. Import from `plugin-sdk/provider-test-contracts` |
| `expectPassthroughReplayPolicy` | Assert provider replay policies pass through provider-owned tools and metadata. Import from `plugin-sdk/provider-test-contracts` |
@@ -213,11 +214,10 @@ entry to declare `kind: "memory"`.
### Testing runtime config access
Prefer the shared plugin runtime mock from `openclaw/plugin-sdk/channel-test-helpers`
when testing bundled channel plugins. Its deprecated `runtime.config.loadConfig()` and
`runtime.config.writeConfigFile(...)` mocks throw by default so tests catch new
usage of compatibility APIs. Override those mocks only when the test is
explicitly covering legacy compatibility behavior.
Prefer the shared plugin runtime mock from `openclaw/plugin-sdk/plugin-test-runtime`.
Its deprecated `runtime.config.loadConfig()` and `runtime.config.writeConfigFile(...)`
mocks throw by default so tests catch new usage of compatibility APIs. Override
those mocks only when the test is explicitly covering legacy compatibility behavior.
### Unit testing a channel plugin

View File

@@ -211,6 +211,18 @@ each carrier call should start with fresh context, for example reception,
booking, IVR, or Google Meet bridge flows where the same phone number may
represent different meetings.
Voice Call stores generated session keys under the configured agent namespace
(`agent:<agentId>:voice:*`) so call memory survives Gateway session-key
canonicalization after restarts. Raw explicit integration keys use the same
agent namespace. A canonical `agent:<configuredAgentId>:*` key keeps that owner,
and its main aliases honor core `session.mainKey` and global scope. Foreign or
malformed `agent:*` input is scoped as an opaque key under the configured agent;
`global` and `unknown` remain global sentinels. Gateway startup promotes older
raw keys in default or `{agentId}`-templated stores where the path proves one
owner. In fixed custom stores, ambiguous legacy rows remain untouched because
they do not contain enough information to choose an owner; new calls use
canonical agent-scoped history.
## Realtime voice conversations
`realtime` selects a full-duplex realtime voice provider for live call

View File

@@ -29,10 +29,11 @@ Use the path that matches your OpenClaw install state:
openclaw onboard --install-daemon
```
On a VPS or over SSH, use device-code during onboarding:
On a VPS or over SSH, select xAI OAuth directly; OpenClaw uses device-code
verification and does not require a localhost callback:
```bash
openclaw onboard --install-daemon --auth-choice xai-device-code
openclaw onboard --install-daemon --auth-choice xai-oauth
```
OAuth does not require an xAI API key. OpenClaw does not require the Grok
@@ -48,13 +49,6 @@ Use the path that matches your OpenClaw install state:
openclaw models auth login --provider xai --method oauth
```
Use the device-code flow instead when the Gateway runs over SSH, Docker, or
a VPS and a localhost browser callback is awkward:
```bash
openclaw models auth login --provider xai --device-code
```
To make Grok the default model after signing in, apply it separately:
```bash
@@ -86,8 +80,7 @@ Use the path that matches your OpenClaw install state:
<Note>
OpenClaw uses the xAI Responses API as the bundled xAI transport. The same
credential from `openclaw models auth login --provider xai --method oauth`,
`openclaw models auth login --provider xai --device-code`, or
credential from `openclaw models auth login --provider xai --method oauth` or
`openclaw models auth login --provider xai --method api-key` can also power first-class
`web_search`, `x_search`, remote `code_execution`, and xAI image/video generation.
Speech and transcription currently require `XAI_API_KEY` or provider config.
@@ -102,8 +95,9 @@ and, by default, `x_search` through an operator xAI Responses proxy.
## OAuth troubleshooting
- If browser OAuth cannot reach `127.0.0.1:56121`, use
`openclaw models auth login --provider xai --device-code`.
- For SSH, Docker, VPS, or other remote setups, use
`openclaw models auth login --provider xai --method oauth`; xAI OAuth uses
device-code verification instead of a localhost callback.
- If sign-in succeeds but Grok is not the default model, run
`openclaw models set xai/grok-4.3`.
- To inspect saved xAI auth profiles, run:
@@ -117,9 +111,9 @@ and, by default, `x_search` through an operator xAI Responses proxy.
eligible, try the API-key path or check the subscription on xAI's side.
<Tip>
Use `xai-device-code` when signing in from SSH, Docker, or a VPS. OpenClaw
prints an xAI URL and short code; finish sign-in in any local browser while the
remote process polls xAI for the completed token exchange.
Use `xai-oauth` when signing in from SSH, Docker, or a VPS. OpenClaw prints an
xAI URL and short code; finish sign-in in any local browser while the remote
process polls xAI for the completed token exchange.
</Tip>
## Built-in catalog
@@ -498,12 +492,10 @@ Legacy aliases still normalize to the canonical bundled ids:
<Accordion title="Known limits">
- xAI auth can use an API key, environment variable, plugin config fallback,
browser OAuth, or device-code OAuth with an eligible xAI account. Browser
OAuth uses a local callback on `127.0.0.1:56121`; for remote hosts, use
`xai-device-code` unless you want to forward that port before opening the
sign-in URL. xAI decides which accounts can receive OAuth API tokens, and
the consent page may show Grok Build even though OpenClaw does not require
the Grok Build app.
or OAuth with an eligible xAI account. OAuth uses device-code verification
without a localhost callback. xAI decides which accounts can receive OAuth
API tokens, and the consent page may show Grok Build even though OpenClaw
does not require the Grok Build app.
- OpenClaw does not currently expose the xAI multi-agent model family. xAI
serves these models through the Responses API, but they do not accept the
client-side or custom tools used by OpenClaw's shared agent loop. See the

View File

@@ -20,6 +20,7 @@ title: "Tests"
- `pnpm changed:lanes`: shows the architectural lanes triggered by the diff against `origin/main`.
- `pnpm check:changed`: delegates to Crabbox/Testbox by default outside CI, then runs the smart changed check gate for the diff against `origin/main` inside the remote child. It runs typecheck, lint, and guard commands for the affected architectural lanes, but does not run Vitest tests. Use `pnpm test:changed` or explicit `pnpm test <target>` for test proof.
- Codex worktrees and linked/sparse checkouts: avoid direct local `pnpm test*`, `pnpm check*`, and `pnpm crabbox:run` unless you have verified pnpm will not reconcile dependencies. For tiny explicit-file proof use `node scripts/run-vitest.mjs <path-or-filter>`; for changed gates or broad proof use `node scripts/crabbox-wrapper.mjs run --provider blacksmith-testbox ... -- env OPENCLAW_CHECK_CHANGED_REMOTE_CHILD=1 OPENCLAW_CHANGED_LANES_RAW_SYNC=1 corepack pnpm check:changed` so pnpm runs inside Testbox.
- Testbox-through-Crabbox proof: use the wrapper's final `exitCode` and timing JSON as the command result. The delegated Blacksmith GitHub Actions run may show `cancelled` after a successful SSH command because the Testbox is stopped from outside the keepalive action; verify the wrapper summary and command output before treating that as a test failure.
- `OPENCLAW_HEAVY_CHECK_LOCK_SCOPE=worktree <local-heavy-check command>`: keeps heavy-check serialization inside the current worktree instead of the Git common dir for commands such as `pnpm check:changed` and targeted `pnpm test ...`. Use it only on high-capacity local hosts when you intentionally run independent checks across linked worktrees.
- `pnpm test`: routes explicit file/directory targets through scoped Vitest lanes. Untargeted runs are full-suite proof: they use fixed shard groups, expand to leaf configs for local parallel execution, and print the expected local shard fanout before starting. The extension group always expands to the per-extension shard configs instead of one giant root-project process.
- Test wrapper runs end with a short `[test] passed|failed|skipped ... in ...` summary. Vitest's own duration line stays the per-shard detail.

View File

@@ -38,13 +38,13 @@ Do **not** use it when you need local files, your shell, your repo, or paired de
<Steps>
<Step title="Provide xAI credentials">
Sign in with Grok OAuth using an eligible SuperGrok or X Premium subscription,
use the remote-friendly device-code flow, or store an API key. OAuth works
for `code_execution` and `x_search`; `XAI_API_KEY` or plugin web-search
config can also power Grok `web_search`.
or store an API key. xAI OAuth uses device-code verification, so it works
from remote hosts without a localhost callback. OAuth works for
`code_execution` and `x_search`; `XAI_API_KEY` or plugin web-search config
can also power Grok `web_search`.
```bash
openclaw models auth login --provider xai --method oauth
openclaw models auth login --provider xai --device-code
```
During a fresh install, the same auth choices are available inside
@@ -52,7 +52,7 @@ Do **not** use it when you need local files, your shell, your repo, or paired de
```bash
openclaw onboard --install-daemon
openclaw onboard --install-daemon --auth-choice xai-device-code
openclaw onboard --install-daemon --auth-choice xai-oauth
```
Or use an API key:

View File

@@ -523,6 +523,7 @@ should be rewritten in normal assistant voice.
- Credential/token-like text is redacted.
- Long blocks can be truncated.
- Very large histories can drop older rows or replace an oversized row with `[sessions_history omitted: message too large]`.
- Use `nextOffset` when present to page backward through older transcript windows.
- Raw on-disk transcript inspection is the fallback when you need the full byte-for-byte transcript.
## Tool policy

View File

@@ -192,6 +192,109 @@ describe("AcpxRuntime fresh reset wrapper", () => {
);
});
it("adds the OpenClaw session key to the managed OpenClaw tools MCP bridge", () => {
const baseStore: TestSessionStore = {
load: vi.fn(async () => undefined),
save: vi.fn(async () => {}),
};
const { runtime } = makeRuntime(baseStore, {
openclawToolsMcpBridgeEnabled: true,
mcpServers: [
{
name: "openclaw-tools",
command: "node",
args: ["dist/mcp/openclaw-tools-serve.js"],
env: [],
},
],
});
const readScopedMcpEnv = (sessionKey: string) => {
const delegate = (
runtime as unknown as {
resolveOpenClawToolsDelegateForSession(sessionKey: string): unknown;
}
).resolveOpenClawToolsDelegateForSession(sessionKey) as {
options: {
mcpServers?: Array<{
env?: Array<{ name: string; value: string }>;
name: string;
}>;
};
};
return delegate.options.mcpServers?.find((server) => server.name === "openclaw-tools")?.env;
};
expect(readScopedMcpEnv("agent:worker:main")).toContainEqual({
name: "OPENCLAW_TOOLS_MCP_AGENT_SESSION_KEY",
value: "agent:worker:main",
});
expect(readScopedMcpEnv("agent:research:main")).toContainEqual({
name: "OPENCLAW_TOOLS_MCP_AGENT_SESSION_KEY",
value: "agent:research:main",
});
});
it("keeps managed OpenClaw tools MCP delegates reachable for fresh sessions", async () => {
const baseStore: TestSessionStore = {
load: vi.fn(async () => undefined),
save: vi.fn(async () => {}),
};
const { runtime } = makeRuntime(baseStore, {
openclawToolsMcpBridgeEnabled: true,
mcpServers: [
{
name: "openclaw-tools",
command: "node",
args: ["dist/mcp/openclaw-tools-serve.js"],
env: [],
},
],
});
const exposedRuntime = runtime as unknown as {
openclawToolsSessionDelegates: Map<string, unknown>;
resolveOpenClawToolsDelegateForSession(sessionKey: string): unknown;
};
const firstDelegate =
exposedRuntime.resolveOpenClawToolsDelegateForSession("agent:worker:main");
expect(exposedRuntime.openclawToolsSessionDelegates.has("agent:worker:main")).toBe(true);
await runtime.prepareFreshSession({ sessionKey: "agent:worker:main" });
expect(exposedRuntime.openclawToolsSessionDelegates.has("agent:worker:main")).toBe(true);
expect(exposedRuntime.resolveOpenClawToolsDelegateForSession("agent:worker:main")).toBe(
firstDelegate,
);
});
it("uses the no-MCP delegate for startup probes when the OpenClaw tools bridge is enabled", async () => {
const baseStore: TestSessionStore = {
load: vi.fn(async () => undefined),
save: vi.fn(async () => {}),
};
const { runtime, delegate, bridgeSafeDelegate } = makeRuntime(baseStore, {
openclawToolsMcpBridgeEnabled: true,
mcpServers: [
{
name: "openclaw-tools",
command: "node",
args: ["dist/mcp/openclaw-tools-serve.js"],
env: [],
},
],
});
const defaultProbe = vi.spyOn(delegate, "probeAvailability").mockResolvedValue(undefined);
const safeProbe = vi
.spyOn(bridgeSafeDelegate, "probeAvailability")
.mockResolvedValue(undefined);
await runtime.probeAvailability();
expect(safeProbe).toHaveBeenCalledTimes(1);
expect(defaultProbe).not.toHaveBeenCalled();
});
it("normalizes OpenClaw Codex model ids for ACP startup", async () => {
const baseStore: TestSessionStore = {
load: vi.fn(async () => undefined),
@@ -1163,6 +1266,46 @@ describe("AcpxRuntime fresh reset wrapper", () => {
expect(baseStore["load"]).toHaveBeenCalledOnce();
});
it("releases managed OpenClaw tools MCP delegates after close", async () => {
const baseStore: TestSessionStore = {
load: vi.fn(async () => undefined),
save: vi.fn(async () => {}),
};
const { runtime } = makeRuntime(baseStore, {
openclawToolsMcpBridgeEnabled: true,
mcpServers: [
{
name: "openclaw-tools",
command: "node",
args: ["dist/mcp/openclaw-tools-serve.js"],
env: [],
},
],
});
const exposedRuntime = runtime as unknown as {
openclawToolsSessionDelegates: Map<string, { close: AcpRuntime["close"] }>;
resolveOpenClawToolsDelegateForSession(sessionKey: string): {
close: AcpRuntime["close"];
};
};
const scopedDelegate =
exposedRuntime.resolveOpenClawToolsDelegateForSession("agent:codex:main");
const close = vi.spyOn(scopedDelegate, "close").mockResolvedValue(undefined);
await runtime.close({
handle: {
sessionKey: "agent:codex:main",
backend: "acpx",
runtimeSessionName: "agent:codex:main",
},
reason: "closed",
});
expect(close).toHaveBeenCalledOnce();
expect(exposedRuntime.openclawToolsSessionDelegates.has("agent:codex:main")).toBe(false);
});
it("cleans up OpenClaw-owned ACPX process trees after close", async () => {
const baseStore: TestSessionStore = {
load: vi.fn(async () => ({

View File

@@ -50,6 +50,7 @@ type OpenClawAcpxRuntimeOptions = AcpRuntimeOptions & {
openclawWrapperRoot?: string;
openclawGatewayInstanceId?: string;
openclawProcessLeaseStore?: AcpxProcessLeaseStore;
openclawToolsMcpBridgeEnabled?: boolean;
};
type AcpxRuntimeTestOptions = Record<string, unknown> & {
openclawProcessCleanup?: AcpxProcessCleanupDeps;
@@ -57,6 +58,10 @@ type AcpxRuntimeTestOptions = Record<string, unknown> & {
type OpenClawRuntimeTurnInput = Parameters<NonNullable<AcpRuntime["startTurn"]>>[0];
type OpenClawRuntimeEnsureInput = Parameters<AcpRuntime["ensureSession"]>[0];
type AcpxDelegateEnsureInput = Parameters<BaseAcpxRuntime["ensureSession"]>[0];
type AcpxMcpServer = NonNullable<AcpRuntimeOptions["mcpServers"]>[number];
const ACPX_OPENCLAW_TOOLS_MCP_SERVER_NAME = "openclaw-tools";
const OPENCLAW_TOOLS_MCP_AGENT_SESSION_KEY_ENV = "OPENCLAW_TOOLS_MCP_AGENT_SESSION_KEY";
type ResetAwareSessionStore = AcpSessionStore & {
markFresh: (sessionKey: string) => void;
@@ -682,6 +687,33 @@ function shouldUseDistinctBridgeDelegate(options: AcpRuntimeOptions): boolean {
return Array.isArray(mcpServers) && mcpServers.length > 0;
}
function withOpenClawToolsMcpSessionEnv(params: {
enabled: boolean | undefined;
mcpServers: AcpRuntimeOptions["mcpServers"];
sessionKey: string;
}): AcpRuntimeOptions["mcpServers"] {
const sessionKey = params.sessionKey.trim();
if (!params.enabled || !sessionKey || !params.mcpServers?.length) {
return params.mcpServers;
}
let changed = false;
const nextServers = params.mcpServers.map((server): AcpxMcpServer => {
if (server.name !== ACPX_OPENCLAW_TOOLS_MCP_SERVER_NAME || !("command" in server)) {
return server;
}
changed = true;
const env = [
...server.env.filter((entry) => entry.name !== OPENCLAW_TOOLS_MCP_AGENT_SESSION_KEY_ENV),
{
name: OPENCLAW_TOOLS_MCP_AGENT_SESSION_KEY_ENV,
value: sessionKey,
},
];
return { ...server, env };
});
return changed ? nextServers : params.mcpServers;
}
/** OpenClaw-managed ACP runtime implementation backed by the upstream acpx runtime. */
export class AcpxRuntime implements AcpRuntime {
private readonly sessionStore: ResetAwareSessionStore;
@@ -693,6 +725,10 @@ export class AcpxRuntime implements AcpRuntime {
private readonly delegate: BaseAcpxRuntime;
private readonly bridgeSafeDelegate: BaseAcpxRuntime;
private readonly probeDelegate: BaseAcpxRuntime;
private readonly delegateOptions: AcpRuntimeOptions;
private readonly delegateTestOptions: BaseAcpxRuntimeTestOptions;
private readonly openclawToolsMcpBridgeEnabled: boolean;
private readonly openclawToolsSessionDelegates = new Map<string, BaseAcpxRuntime>();
private readonly processCleanupDeps: AcpxProcessCleanupDeps | undefined;
private readonly wrapperRoot: string | undefined;
private readonly gatewayInstanceId: string | undefined;
@@ -706,6 +742,7 @@ export class AcpxRuntime implements AcpRuntime {
this.wrapperRoot = options.openclawWrapperRoot;
this.gatewayInstanceId = options.openclawGatewayInstanceId;
this.processLeaseStore = options.openclawProcessLeaseStore;
this.openclawToolsMcpBridgeEnabled = options.openclawToolsMcpBridgeEnabled === true;
this.cwd = options.cwd;
this.sessionStore = createResetAwareSessionStore(options.sessionStore, {
gatewayInstanceId: this.gatewayInstanceId,
@@ -723,20 +760,21 @@ export class AcpxRuntime implements AcpRuntime {
sessionStore: this.sessionStore,
agentRegistry: this.scopedAgentRegistry,
};
this.delegate = new BaseAcpxRuntime(
sharedOptions,
delegateTestOptions as BaseAcpxRuntimeTestOptions,
);
this.delegateOptions = sharedOptions;
this.delegateTestOptions = delegateTestOptions as BaseAcpxRuntimeTestOptions;
this.delegate = new BaseAcpxRuntime(sharedOptions, this.delegateTestOptions);
this.bridgeSafeDelegate = shouldUseDistinctBridgeDelegate(options)
? new BaseAcpxRuntime(
{
...sharedOptions,
mcpServers: [],
},
delegateTestOptions as BaseAcpxRuntimeTestOptions,
this.delegateTestOptions,
)
: this.delegate;
this.probeDelegate = this.resolveDelegateForAgent(resolveProbeAgentName(options));
this.probeDelegate = this.openclawToolsMcpBridgeEnabled
? this.bridgeSafeDelegate
: this.resolveDelegateForAgent(resolveProbeAgentName(options));
}
private resolveDelegateForAgent(agentName: string | undefined): BaseAcpxRuntime {
@@ -751,6 +789,57 @@ export class AcpxRuntime implements AcpRuntime {
return shouldUseBridgeSafeDelegateForCommand(command) ? this.bridgeSafeDelegate : this.delegate;
}
private resolveDelegateForSession(params: {
command: string | undefined;
sessionKey: string;
}): BaseAcpxRuntime {
if (shouldUseBridgeSafeDelegateForCommand(params.command)) {
return this.bridgeSafeDelegate;
}
return this.resolveOpenClawToolsDelegateForSession(params.sessionKey);
}
private resolveOpenClawToolsDelegateForSession(sessionKey: string): BaseAcpxRuntime {
if (!this.openclawToolsMcpBridgeEnabled) {
return this.delegate;
}
const normalizedSessionKey = sessionKey.trim();
if (!normalizedSessionKey) {
return this.delegate;
}
const cached = this.openclawToolsSessionDelegates.get(normalizedSessionKey);
if (cached) {
return cached;
}
// Upstream acpx captures mcpServers at runtime construction. The managed
// OpenClaw tools bridge needs per-session identity, so cache one delegate
// per session with the scoped MCP env already embedded.
const delegate = new BaseAcpxRuntime(
{
...this.delegateOptions,
mcpServers: withOpenClawToolsMcpSessionEnv({
enabled: this.openclawToolsMcpBridgeEnabled,
mcpServers: this.delegateOptions.mcpServers,
sessionKey: normalizedSessionKey,
}),
},
this.delegateTestOptions,
);
this.openclawToolsSessionDelegates.set(normalizedSessionKey, delegate);
return delegate;
}
private releaseOpenClawToolsDelegateForSession(sessionKey: string): void {
if (!this.openclawToolsMcpBridgeEnabled) {
return;
}
const normalizedSessionKey = sessionKey.trim();
if (!normalizedSessionKey) {
return;
}
this.openclawToolsSessionDelegates.delete(normalizedSessionKey);
}
private async resolveDelegateForHandle(handle: AcpRuntimeHandle): Promise<BaseAcpxRuntime> {
const record = await this.sessionStore.load(handle.acpxRecordId ?? handle.sessionKey);
return this.resolveDelegateForLoadedRecord(handle, record);
@@ -762,9 +851,17 @@ export class AcpxRuntime implements AcpRuntime {
): BaseAcpxRuntime {
const recordCommand = readAgentCommandFromRecord(record);
if (recordCommand) {
return this.resolveDelegateForCommand(recordCommand);
return this.resolveDelegateForSession({
command: recordCommand,
sessionKey: handle.sessionKey,
});
}
return this.resolveDelegateForAgent(readAgentFromHandle(handle));
const agentName = readAgentFromHandle(handle);
const command = resolveAgentCommandForName({
agentName,
agentRegistry: this.agentRegistry,
});
return this.resolveDelegateForSession({ command, sessionKey: handle.sessionKey });
}
private async resolveCommandForHandle(handle: AcpRuntimeHandle): Promise<string | undefined> {
@@ -980,7 +1077,7 @@ export class AcpxRuntime implements AcpRuntime {
agentName: input.agent,
agentRegistry: this.agentRegistry,
});
const delegate = this.resolveDelegateForCommand(command);
const delegate = this.resolveDelegateForSession({ command, sessionKey: input.sessionKey });
const claudeModelOverride = isClaudeAcpCommand(command)
? normalizeClaudeAcpModelOverride(input.model)
: undefined;
@@ -1264,6 +1361,9 @@ export class AcpxRuntime implements AcpRuntime {
}
async prepareFreshSession(input: { sessionKey: string }): Promise<void> {
// Fresh reset has no ACP handle to close the delegate's upstream client.
// Keep the scoped delegate reachable so the next ensure can replace it;
// close() owns cache release when the session lifecycle ends.
this.sessionStore.markFresh(input.sessionKey);
}
@@ -1272,8 +1372,9 @@ export class AcpxRuntime implements AcpRuntime {
input.handle.acpxRecordId ?? input.handle.sessionKey,
);
let closeSucceeded;
const delegate = this.resolveDelegateForLoadedRecord(input.handle, record);
try {
await this.resolveDelegateForLoadedRecord(input.handle, record).close({
await delegate.close({
handle: input.handle,
reason: input.reason,
discardPersistentState: input.discardPersistentState,
@@ -1282,6 +1383,9 @@ export class AcpxRuntime implements AcpRuntime {
} finally {
await this.cleanupProcessTreeForRecord(input.handle, record);
}
if (closeSucceeded) {
this.releaseOpenClawToolsDelegateForSession(input.handle.sessionKey);
}
if (closeSucceeded && input.discardPersistentState) {
this.sessionStore.markFresh(input.handle.sessionKey);
}

View File

@@ -111,6 +111,7 @@ function createLazyDefaultRuntime(params: AcpxRuntimeFactoryParams): AcpxRuntime
}),
probeAgent: params.pluginConfig.probeAgent,
mcpServers: toAcpMcpServers(params.pluginConfig.mcpServers),
openclawToolsMcpBridgeEnabled: params.pluginConfig.openClawToolsMcpBridge,
permissionMode: params.pluginConfig.permissionMode,
nonInteractivePermissions: params.pluginConfig.nonInteractivePermissions,
timeoutMs: resolveAcpxTimerTimeoutMs(params.pluginConfig.timeoutSeconds),

View File

@@ -1,6 +1,81 @@
import { createServer, type Server } from "node:http";
import { describe, expect, it, vi } from "vitest";
import { createClickClackClient } from "./http-client.js";
const LOOPBACK_RESPONSE_BYTES = 18 * 1024 * 1024;
async function listenLoopbackServer(server: Server): Promise<number> {
return await new Promise((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", () => {
server.off("error", reject);
const address = server.address();
if (!address || typeof address === "string") {
reject(new Error("expected loopback TCP address"));
return;
}
resolve(address.port);
});
});
}
function createOversizedJsonServer(): { server: Server; closed: Promise<number> } {
let resolveClosed: (sentBytes: number) => void = () => {};
const closed = new Promise<number>((resolve) => {
resolveClosed = resolve;
});
const server = createServer((req, res) => {
let sentBytes = 0;
let stopped = false;
let prefixSent = false;
const prefixChunk = Buffer.from('{"user":{"id":"');
const bodyChunk = Buffer.alloc(64 * 1024, 0x61);
const suffixChunk = Buffer.from('"}}');
const writeBuffer = (buffer: Buffer) => {
sentBytes += buffer.length;
if (!res.write(buffer)) {
res.once("drain", writeChunks);
return false;
}
return true;
};
const writeChunks = () => {
if (!prefixSent) {
prefixSent = true;
if (!writeBuffer(prefixChunk)) {
return;
}
}
while (true) {
if (stopped) {
return;
}
if (sentBytes + bodyChunk.length + suffixChunk.length >= LOOPBACK_RESPONSE_BYTES) {
break;
}
if (!writeBuffer(bodyChunk)) {
return;
}
}
if (!stopped) {
sentBytes += suffixChunk.length;
res.end(suffixChunk);
}
};
res.writeHead(200, { connection: "close", "content-type": "application/json" });
res.on("close", () => {
stopped = true;
resolveClosed(sentBytes);
});
req.on("aborted", () => {
stopped = true;
res.destroy();
});
writeChunks();
});
return { server, closed };
}
function streamedErrorResponse(body: string, limit: number) {
const encoded = new TextEncoder().encode(body);
let readCount = 0;
@@ -39,6 +114,25 @@ function streamedErrorResponse(body: string, limit: number) {
}
describe("ClickClack HTTP client", () => {
it("bounds oversized success JSON responses and closes the stream early", async () => {
const { server, closed } = createOversizedJsonServer();
const port = await listenLoopbackServer(server);
const client = createClickClackClient({
baseUrl: `http://127.0.0.1:${port}`,
token: "test-token",
});
try {
await expect(client.me()).rejects.toThrow(
"ClickClack response: JSON response exceeds 16777216 bytes",
);
const sentBytes = await closed;
expect(sentBytes).toBeLessThan(LOOPBACK_RESPONSE_BYTES);
} finally {
server.close();
}
});
it("bounds error response bodies without using raw response.text()", async () => {
const streamed = streamedErrorResponse("x".repeat(9000), 8 * 1024);
const fetchMock = vi.fn(async () => streamed.response);

View File

@@ -2,7 +2,10 @@
* Thin ClickClack REST/websocket client used by gateway, resolver, and outbound
* delivery code.
*/
import { readResponseTextLimited } from "openclaw/plugin-sdk/provider-http";
import {
readProviderJsonResponse,
readResponseTextLimited,
} from "openclaw/plugin-sdk/provider-http";
import { WebSocket } from "ws";
import type {
ClickClackChannel,
@@ -44,7 +47,7 @@ export function createClickClackClient(options: ClientOptions) {
const detail = await readResponseTextLimited(response, CLICKCLACK_ERROR_BODY_LIMIT_BYTES);
throw new Error(`ClickClack ${response.status}: ${detail}`);
}
return (await response.json()) as T;
return await readProviderJsonResponse<T>(response, "ClickClack response");
}
return {

View File

@@ -36,6 +36,14 @@ describe("codex doctor contract", () => {
},
}),
).toBe(false);
expect(
legacyConfigRules[1]?.match({
allow_destructive_actions: "always",
plugins: {
"google-calendar": { allow_destructive_actions: "always" },
},
}),
).toBe(false);
});
it("removes the retired dynamic tools profile without dropping other Codex config", () => {

View File

@@ -101,7 +101,7 @@
"default": false
},
"allow_destructive_actions": {
"oneOf": [{ "type": "boolean" }, { "const": "auto" }],
"oneOf": [{ "type": "boolean" }, { "const": "auto" }, { "const": "always" }],
"default": true
},
"plugins": {
@@ -121,7 +121,7 @@
"type": "string"
},
"allow_destructive_actions": {
"oneOf": [{ "type": "boolean" }, { "const": "auto" }]
"oneOf": [{ "type": "boolean" }, { "const": "auto" }, { "const": "always" }]
}
}
}
@@ -343,7 +343,7 @@
},
"codexPlugins.allow_destructive_actions": {
"label": "Allow Destructive Plugin Actions",
"help": "Default policy for plugin app write or destructive action elicitations. Use true to accept safe schemas without prompting, false to decline, or auto to ask through plugin approvals.",
"help": "Default policy for plugin app write or destructive action elicitations. Use true to accept safe schemas without prompting, false to decline, auto to ask through plugin approvals when Codex requires approval, or always to ask for every write/destructive action without durable approval.",
"advanced": true
},
"codexPlugins.plugins": {

View File

@@ -346,6 +346,7 @@ export async function startCodexAttemptThread(params: {
timeoutMs: params.appServer.requestTimeoutMs,
signal,
}),
configCwd: startupExecutionCwd,
appCache: defaultCodexAppInventoryCache,
appCacheKey: pluginAppCacheKey,
}),

View File

@@ -1192,6 +1192,52 @@ allowed_sandbox_modes = ["read-only", "workspace-write"]
});
});
it("parses always native Codex plugin destructive policy", () => {
const config = readCodexPluginConfig({
codexPlugins: {
enabled: true,
allow_destructive_actions: "always",
plugins: {
"google-calendar": {
marketplaceName: "openai-curated",
pluginName: "google-calendar",
},
slack: {
marketplaceName: "openai-curated",
pluginName: "slack",
allow_destructive_actions: "auto",
},
},
},
});
expect(config.codexPlugins?.allow_destructive_actions).toBe("always");
expect(resolveCodexPluginsPolicy(config)).toEqual({
configured: true,
enabled: true,
allowDestructiveActions: true,
destructiveApprovalMode: "always",
pluginPolicies: [
{
configKey: "google-calendar",
marketplaceName: "openai-curated",
pluginName: "google-calendar",
enabled: true,
allowDestructiveActions: true,
destructiveApprovalMode: "always",
},
{
configKey: "slack",
marketplaceName: "openai-curated",
pluginName: "slack",
enabled: true,
allowDestructiveActions: true,
destructiveApprovalMode: "auto",
},
],
});
});
it("rejects unsupported native Codex plugin destructive policy strings", () => {
const config = readCodexPluginConfig({
codexPlugins: {

View File

@@ -74,8 +74,8 @@ export type CodexAppServerSandboxMode = "read-only" | "workspace-write" | "dange
type CodexAppServerApprovalsReviewer = "user" | "auto_review" | "guardian_subagent";
type CodexAppServerCommandSource = "managed" | "resolved-managed" | "config" | "env";
export type CodexDynamicToolsLoading = "searchable" | "direct";
export type CodexPluginDestructivePolicy = boolean | "auto";
export type CodexPluginDestructiveApprovalMode = "allow" | "deny" | "auto";
export type CodexPluginDestructivePolicy = boolean | "auto" | "always";
export type CodexPluginDestructiveApprovalMode = "allow" | "deny" | "auto" | "always";
export const CODEX_PLUGINS_MARKETPLACE_NAME = "openai-curated";
@@ -311,7 +311,11 @@ const codexAppServerApprovalPolicySchema = z.enum([
const codexAppServerSandboxSchema = z.enum(["read-only", "workspace-write", "danger-full-access"]);
const codexAppServerApprovalsReviewerSchema = z.enum(["user", "auto_review", "guardian_subagent"]);
const codexDynamicToolsLoadingSchema = z.enum(["searchable", "direct"]);
const codexPluginDestructivePolicySchema = z.union([z.boolean(), z.literal("auto")]);
const codexPluginDestructivePolicySchema = z.union([
z.boolean(),
z.literal("auto"),
z.literal("always"),
]);
const codexAppServerServiceTierSchema = z
.preprocess(
(value) => (value === null ? null : normalizeCodexServiceTier(value)),
@@ -495,8 +499,8 @@ function resolveCodexPluginDestructivePolicy(policy: CodexPluginDestructivePolic
allowDestructiveActions: boolean;
destructiveApprovalMode: CodexPluginDestructiveApprovalMode;
} {
if (policy === "auto") {
return { allowDestructiveActions: true, destructiveApprovalMode: "auto" };
if (policy === "auto" || policy === "always") {
return { allowDestructiveActions: true, destructiveApprovalMode: policy };
}
return {
allowDestructiveActions: policy,

View File

@@ -157,7 +157,7 @@ function buildConnectorPluginApprovalElicitation(overrides: Record<string, unkno
function createPluginAppPolicyContext(
params: {
allowDestructiveActions?: boolean;
destructiveApprovalMode?: "allow" | "deny" | "auto";
destructiveApprovalMode?: "allow" | "deny" | "auto" | "always";
apps?: Array<{ appId: string; pluginName: string; mcpServerNames: string[] }>;
} = {},
) {
@@ -1017,6 +1017,96 @@ describe("Codex app-server elicitation bridge", () => {
});
});
it("does not expose allow-always for always plugin policy", async () => {
mockCallGatewayTool
.mockResolvedValueOnce({ id: "plugin:approval-calendar-always-policy", status: "accepted" })
.mockResolvedValueOnce({
id: "plugin:approval-calendar-always-policy",
decision: "allow-once",
});
const result = await handleCodexAppServerElicitationRequest({
requestParams: buildConnectorPluginApprovalElicitation({
_meta: {
codex_approval_kind: "mcp_tool_call",
source: "connector",
connector_id: "connector_google_calendar",
connector_name: "Google Calendar",
persist: ["session", "always"],
tool_title: "create_event",
},
}),
paramsForRun: createParams(),
threadId: "thread-1",
turnId: "turn-1",
pluginAppPolicyContext: createPluginAppPolicyContext({
allowDestructiveActions: true,
destructiveApprovalMode: "always",
apps: [
{
appId: "connector_google_calendar",
pluginName: "google-calendar",
mcpServerNames: [],
},
],
}),
});
expect(result).toEqual({
action: "accept",
content: null,
_meta: null,
});
expect(gatewayToolArg(0, 2)).toMatchObject({
allowedDecisions: ["allow-once", "deny"],
});
});
it("maps unexpected allow-always decisions to one-shot for always plugin policy", async () => {
mockCallGatewayTool
.mockResolvedValueOnce({
id: "plugin:approval-calendar-unexpected-always",
status: "accepted",
})
.mockResolvedValueOnce({
id: "plugin:approval-calendar-unexpected-always",
decision: "allow-always",
});
const result = await handleCodexAppServerElicitationRequest({
requestParams: buildConnectorPluginApprovalElicitation({
_meta: {
codex_approval_kind: "mcp_tool_call",
source: "connector",
connector_id: "connector_google_calendar",
connector_name: "Google Calendar",
persist: ["session", "always"],
tool_title: "create_event",
},
}),
paramsForRun: createParams(),
threadId: "thread-1",
turnId: "turn-1",
pluginAppPolicyContext: createPluginAppPolicyContext({
allowDestructiveActions: true,
destructiveApprovalMode: "always",
apps: [
{
appId: "connector_google_calendar",
pluginName: "google-calendar",
mcpServerNames: [],
},
],
}),
});
expect(result).toEqual({
action: "accept",
content: null,
_meta: null,
});
});
it("declines denied auto plugin app approvals", async () => {
mockCallGatewayTool
.mockResolvedValueOnce({ id: "plugin:approval-calendar-deny", status: "accepted" })

View File

@@ -318,10 +318,13 @@ async function buildPluginPolicyElicitationResponse(params: {
paramsForRun: params.paramsForRun,
title: approvalPrompt.title,
description: approvalPrompt.description,
allowedDecisions: approvalPrompt.allowedDecisions,
allowedDecisions: allowedPluginPolicyApprovalDecisions(mode, approvalPrompt),
signal: params.signal,
});
return buildElicitationResponse(approvalPrompt, outcome);
return buildElicitationResponse(
approvalPrompt,
oneShotPluginPolicyApprovalOutcome(mode, outcome),
);
}
logPluginElicitationDecline("unmappable_schema", params.requestParams);
return declineElicitationResponse();
@@ -329,10 +332,28 @@ async function buildPluginPolicyElicitationResponse(params: {
function resolvePluginDestructiveApprovalMode(
entry: PluginAppPolicyContextEntry,
): "allow" | "deny" | "auto" {
): "allow" | "deny" | "auto" | "always" {
return entry.destructiveApprovalMode ?? (entry.allowDestructiveActions ? "allow" : "deny");
}
function allowedPluginPolicyApprovalDecisions(
mode: "allow" | "deny" | "auto" | "always",
approvalPrompt: BridgeableApprovalElicitation,
): ExecApprovalDecision[] {
const allowedDecisions = approvalPrompt.allowedDecisions ?? ["allow-once", "deny"];
if (mode !== "always") {
return allowedDecisions;
}
return allowedDecisions.filter((decision) => decision !== "allow-always");
}
function oneShotPluginPolicyApprovalOutcome(
mode: "allow" | "deny" | "auto" | "always",
outcome: AppServerApprovalOutcome,
): AppServerApprovalOutcome {
return mode === "always" && outcome === "approved-session" ? "approved-once" : outcome;
}
function readPluginApprovalElicitation(
entry: PluginAppPolicyContextEntry,
requestParams: JsonObject,

View File

@@ -170,6 +170,379 @@ describe("Codex plugin thread config", () => {
});
});
it("exposes destructive app access while clearing only durable approval overrides for always mode", async () => {
const appCache = new CodexAppInventoryCache();
await appCache.refreshNow({
key: "runtime",
nowMs: 0,
request: async () => ({
data: [appInfo("google-calendar-app", true)],
nextCursor: null,
}),
});
let configReadCount = 0;
const request = vi.fn(async (method: string) => {
if (method === "plugin/list") {
return pluginList([pluginSummary("google-calendar", { installed: true, enabled: true })]);
}
if (method === "plugin/read") {
return pluginDetail(
"google-calendar",
[appSummary("google-calendar-app")],
["google-calendar"],
);
}
if (method === "config/read") {
configReadCount += 1;
if (configReadCount > 1) {
return {
config: {
apps: {
"google-calendar-app": {
tools: {
"calendar/read": {
enabled: false,
},
},
},
},
},
};
}
return {
config: {
apps: {
"google-calendar-app": {
tools: {
"calendar/create": {
approval_mode: "approve",
enabled: false,
},
"calendar/read": {
enabled: false,
},
"calendar/update": {
approvalMode: "approve",
},
},
},
},
},
};
}
if (method === "config/value/write") {
return {};
}
throw new Error(`unexpected request ${method}`);
});
const config = await buildCodexPluginThreadConfig({
pluginConfig: {
codexPlugins: {
enabled: true,
allow_destructive_actions: "always",
plugins: {
"google-calendar": {
marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME,
pluginName: "google-calendar",
},
},
},
},
appCache,
appCacheKey: "runtime",
nowMs: 1,
request,
});
const apps = config.configPatch?.apps as Record<string, unknown> | undefined;
expect(apps?.["google-calendar-app"]).toEqual({
enabled: true,
destructive_enabled: true,
open_world_enabled: true,
default_tools_approval_mode: "auto",
});
expect(config.policyContext.apps["google-calendar-app"]).toMatchObject({
allowDestructiveActions: true,
destructiveApprovalMode: "always",
});
expect(request).toHaveBeenCalledWith("config/read", { includeLayers: false });
expect(request.mock.calls.filter(([method]) => method === "config/read")).toHaveLength(2);
expect(request).toHaveBeenCalledWith("config/value/write", {
keyPath: 'apps."google-calendar-app".tools."calendar/create".approval_mode',
value: null,
mergeStrategy: "replace",
});
expect(request).toHaveBeenCalledWith("config/value/write", {
keyPath: 'apps."google-calendar-app".tools."calendar/update".approval_mode',
value: null,
mergeStrategy: "replace",
});
expect(request).not.toHaveBeenCalledWith("config/value/write", {
keyPath: 'apps."google-calendar-app".tools',
value: null,
mergeStrategy: "replace",
});
});
it("omits always policy apps when cwd effective approval overrides remain after cleanup", async () => {
const appCache = new CodexAppInventoryCache();
await appCache.refreshNow({
key: "runtime",
nowMs: 0,
request: async () => ({
data: [appInfo("google-calendar-app", true)],
nextCursor: null,
}),
});
let configReadCount = 0;
const request = vi.fn(async (method: string) => {
if (method === "plugin/list") {
return pluginList([pluginSummary("google-calendar", { installed: true, enabled: true })]);
}
if (method === "plugin/read") {
return pluginDetail(
"google-calendar",
[appSummary("google-calendar-app")],
["google-calendar"],
);
}
if (method === "config/read") {
configReadCount += 1;
return {
config: {
apps: {
"google-calendar-app": {
tools: {
"calendar/create": {
approval_mode: "approve",
source: configReadCount === 1 ? "user" : "project",
},
},
},
},
},
};
}
if (method === "config/value/write") {
return { status: "ok" };
}
throw new Error(`unexpected request ${method}`);
});
const config = await buildCodexPluginThreadConfig({
pluginConfig: {
codexPlugins: {
enabled: true,
allow_destructive_actions: "always",
plugins: {
"google-calendar": {
marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME,
pluginName: "google-calendar",
},
},
},
},
appCache,
appCacheKey: "runtime",
configCwd: "/repo/project",
nowMs: 1,
request,
});
expect(config.configPatch).toEqual({
apps: {
_default: {
enabled: false,
destructive_enabled: false,
open_world_enabled: false,
},
},
});
expect(config.policyContext.apps).toStrictEqual({});
expect(request).toHaveBeenCalledWith("config/read", {
includeLayers: false,
cwd: "/repo/project",
});
expect(request.mock.calls.filter(([method]) => method === "config/read")).toHaveLength(2);
expect(config.diagnostics).toStrictEqual([
{
code: "approval_overrides_clear_failed",
plugin: {
configKey: "google-calendar",
marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME,
pluginName: "google-calendar",
enabled: true,
allowDestructiveActions: true,
destructiveApprovalMode: "always",
},
message:
"Could not clear durable Codex app approval overrides for google-calendar-app: effective approval overrides remain for calendar/create",
},
]);
});
it("omits always policy apps when approval override writes are overridden", async () => {
const appCache = new CodexAppInventoryCache();
await appCache.refreshNow({
key: "runtime",
nowMs: 0,
request: async () => ({
data: [appInfo("google-calendar-app", true)],
nextCursor: null,
}),
});
const request = vi.fn(async (method: string) => {
if (method === "plugin/list") {
return pluginList([pluginSummary("google-calendar", { installed: true, enabled: true })]);
}
if (method === "plugin/read") {
return pluginDetail(
"google-calendar",
[appSummary("google-calendar-app")],
["google-calendar"],
);
}
if (method === "config/read") {
return {
config: {
apps: {
"google-calendar-app": {
tools: {
"calendar/create": {
approval_mode: "approve",
},
},
},
},
},
};
}
if (method === "config/value/write") {
return { status: "okOverridden" };
}
throw new Error(`unexpected request ${method}`);
});
const config = await buildCodexPluginThreadConfig({
pluginConfig: {
codexPlugins: {
enabled: true,
allow_destructive_actions: "always",
plugins: {
"google-calendar": {
marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME,
pluginName: "google-calendar",
},
},
},
},
appCache,
appCacheKey: "runtime",
configCwd: "/repo/project",
nowMs: 1,
request,
});
expect(config.configPatch).toEqual({
apps: {
_default: {
enabled: false,
destructive_enabled: false,
open_world_enabled: false,
},
},
});
expect(config.policyContext.apps).toStrictEqual({});
expect(config.diagnostics).toStrictEqual([
{
code: "approval_overrides_clear_failed",
plugin: {
configKey: "google-calendar",
marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME,
pluginName: "google-calendar",
enabled: true,
allowDestructiveActions: true,
destructiveApprovalMode: "always",
},
message:
"Could not clear durable Codex app approval overrides for google-calendar-app: approval override for calendar/create is controlled by another config layer",
},
]);
});
it("omits always policy apps when durable approval override cleanup fails", async () => {
const appCache = new CodexAppInventoryCache();
await appCache.refreshNow({
key: "runtime",
nowMs: 0,
request: async () => ({
data: [appInfo("google-calendar-app", true)],
nextCursor: null,
}),
});
const config = await buildCodexPluginThreadConfig({
pluginConfig: {
codexPlugins: {
enabled: true,
allow_destructive_actions: "always",
plugins: {
"google-calendar": {
marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME,
pluginName: "google-calendar",
},
},
},
},
appCache,
appCacheKey: "runtime",
nowMs: 1,
request: async (method) => {
if (method === "plugin/list") {
return pluginList([pluginSummary("google-calendar", { installed: true, enabled: true })]);
}
if (method === "plugin/read") {
return pluginDetail(
"google-calendar",
[appSummary("google-calendar-app")],
["google-calendar"],
);
}
if (method === "config/read") {
throw new Error("readonly config");
}
throw new Error(`unexpected request ${method}`);
},
});
expect(config.configPatch).toEqual({
apps: {
_default: {
enabled: false,
destructive_enabled: false,
open_world_enabled: false,
},
},
});
expect(config.policyContext.apps).toStrictEqual({});
expect(config.diagnostics).toStrictEqual([
{
code: "approval_overrides_clear_failed",
plugin: {
configKey: "google-calendar",
marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME,
pluginName: "google-calendar",
enabled: true,
allowDestructiveActions: true,
destructiveApprovalMode: "always",
},
message:
"Could not clear durable Codex app approval overrides for google-calendar-app: readonly config",
},
]);
});
it("builds a restrictive app config when native plugin support is disabled", async () => {
expect(
shouldBuildCodexPluginThreadConfig({

View File

@@ -29,7 +29,7 @@ import {
type CodexPluginOwnedApp,
type CodexPluginRuntimeRequest,
} from "./plugin-inventory.js";
import type { JsonObject, JsonValue } from "./protocol.js";
import { isJsonObject, type JsonObject, type JsonValue } from "./protocol.js";
/** Policy context for one app id exposed by a configured Codex plugin. */
export type PluginAppPolicyContextEntry = {
@@ -52,7 +52,7 @@ export type PluginAppPolicyContext = {
export type CodexPluginThreadConfigDiagnostic =
| CodexPluginInventoryDiagnostic
| {
code: "plugin_activation_failed" | "app_not_ready";
code: "plugin_activation_failed" | "app_not_ready" | "approval_overrides_clear_failed";
plugin?: ResolvedCodexPluginPolicy;
message: string;
};
@@ -72,6 +72,7 @@ export type CodexPluginThreadConfig = {
export type BuildCodexPluginThreadConfigParams = {
pluginConfig?: unknown;
request: CodexPluginRuntimeRequest;
configCwd?: string;
appCache?: CodexAppInventoryCache;
appCacheKey: string;
nowMs?: number;
@@ -250,6 +251,18 @@ export async function buildCodexPluginThreadConfig(
});
continue;
}
if (
record.policy.destructiveApprovalMode === "always" &&
!(await clearPersistedAppToolApprovalOverrides({
request: params.request,
configCwd: params.configCwd,
plugin: record.policy,
app,
diagnostics,
}))
) {
continue;
}
const appConfig: JsonObject = {
enabled: true,
destructive_enabled: record.policy.allowDestructiveActions,
@@ -367,6 +380,86 @@ function buildPluginAppPolicyContext(
};
}
async function clearPersistedAppToolApprovalOverrides(params: {
request: CodexPluginRuntimeRequest;
configCwd?: string;
plugin: ResolvedCodexPluginPolicy;
app: CodexPluginOwnedApp;
diagnostics: CodexPluginThreadConfigDiagnostic[];
}): Promise<boolean> {
try {
const overrideNames = await readPersistedAppToolApprovalOverrideNames(params);
for (const toolName of overrideNames) {
const response = await params.request("config/value/write", {
keyPath: `apps.${quoteConfigKeyPathSegment(params.app.id)}.tools.${quoteConfigKeyPathSegment(
toolName,
)}.approval_mode`,
value: null,
mergeStrategy: "replace",
});
if (isOverriddenConfigWriteResponse(response)) {
throw new Error(`approval override for ${toolName} is controlled by another config layer`);
}
}
const remainingOverrideNames = await readPersistedAppToolApprovalOverrideNames(params);
if (remainingOverrideNames.length > 0) {
throw new Error(
`effective approval overrides remain for ${remainingOverrideNames.join(", ")}`,
);
}
return true;
} catch (error) {
params.diagnostics.push({
code: "approval_overrides_clear_failed",
plugin: params.plugin,
message: `Could not clear durable Codex app approval overrides for ${params.app.id}: ${
error instanceof Error ? error.message : String(error)
}`,
});
return false;
}
}
async function readPersistedAppToolApprovalOverrideNames(params: {
request: CodexPluginRuntimeRequest;
configCwd?: string;
app: CodexPluginOwnedApp;
}): Promise<string[]> {
const response = await params.request("config/read", {
includeLayers: false,
...(params.configCwd ? { cwd: params.configCwd } : {}),
});
const config = isJsonObject(response) ? response.config : undefined;
const appsRoot = isJsonObject(config) ? config.apps : undefined;
const nestedApps = isJsonObject(appsRoot) ? appsRoot.apps : undefined;
const appConfig = isJsonObject(appsRoot)
? (appsRoot[params.app.id] ??
(isJsonObject(nestedApps) ? nestedApps[params.app.id] : undefined))
: undefined;
const tools = isJsonObject(appConfig) ? appConfig.tools : undefined;
if (!isJsonObject(tools)) {
return [];
}
return Object.entries(tools)
.filter(([, value]) => hasPersistedToolApprovalOverride(value))
.map(([toolName]) => toolName)
.toSorted();
}
function hasPersistedToolApprovalOverride(value: JsonValue): boolean {
return (
isJsonObject(value) && (value.approval_mode !== undefined || value.approvalMode !== undefined)
);
}
function isOverriddenConfigWriteResponse(response: unknown): boolean {
return isJsonObject(response) && response.status === "okOverridden";
}
function quoteConfigKeyPathSegment(segment: string): string {
return `"${segment.replace(/["\\]/g, (char) => `\\${char}`)}"`;
}
function shouldWaitForInitialAppInventory(
params: BuildCodexPluginThreadConfigParams,
policy: ResolvedCodexPluginsPolicy,

View File

@@ -575,6 +575,8 @@ type CodexAppServerRequestResultMap = {
"account/read": CodexGetAccountResponse;
"app/list": CodexAppsListResponse;
"config/mcpServer/reload": JsonValue;
"config/read": JsonValue;
"config/value/write": JsonValue;
"environment/add": JsonValue;
"experimentalFeature/enablement/set": JsonValue;
"feedback/upload": JsonValue;

View File

@@ -112,6 +112,44 @@ describe("requestCodexAppServerJson sandbox guard", () => {
expect(request).toHaveBeenCalledWith("thread/list", { limit: 10 }, { timeoutMs: 60_000 });
});
it("allows config value writes in sandboxed sessions", async () => {
const request = vi.fn(async () => ({ ok: true }));
sharedClientMocks.getSharedCodexAppServerClient.mockResolvedValue({ request });
const params = {
keyPath: 'apps."google-calendar-app".tools',
value: null,
mergeStrategy: "replace",
};
await expect(
requestCodexAppServerJson({
method: "config/value/write",
requestParams: params,
config: { agents: { defaults: { sandbox: { mode: "all" } } } },
sessionKey: "sandboxed-session",
}),
).resolves.toEqual({ ok: true });
expect(request).toHaveBeenCalledWith("config/value/write", params, { timeoutMs: 60_000 });
});
it("allows config reads in sandboxed sessions", async () => {
const request = vi.fn(async () => ({ config: { apps: { apps: {} } } }));
sharedClientMocks.getSharedCodexAppServerClient.mockResolvedValue({ request });
const params = { includeLayers: false };
await expect(
requestCodexAppServerJson({
method: "config/read",
requestParams: params,
config: { agents: { defaults: { sandbox: { mode: "all" } } } },
sessionKey: "sandboxed-session",
}),
).resolves.toEqual({ config: { apps: { apps: {} } } });
expect(request).toHaveBeenCalledWith("config/read", params, { timeoutMs: 60_000 });
});
it("allows sandbox-pinned thread starts in sandboxed sessions", async () => {
const request = vi.fn(async () => ({ thread: { id: "thread-1" }, model: "gpt-5.5" }));
sharedClientMocks.getSharedCodexAppServerClient.mockResolvedValue({ request });

View File

@@ -19,6 +19,8 @@ const DIRECT_METHOD_POLICIES = new Map<string, DirectMethodPolicy>([
["account/read", "allowed-control-plane"],
["app/list", "allowed-control-plane"],
["config/mcpServer/reload", "allowed-control-plane"],
["config/read", "allowed-control-plane"],
["config/value/write", "allowed-control-plane"],
["environment/add", "allowed-control-plane"],
["experimentalFeature/enablement/set", "allowed-control-plane"],
["feedback/upload", "allowed-control-plane"],

View File

@@ -145,6 +145,35 @@ describe("codex app-server session binding", () => {
expect(binding?.pluginAppPolicyContext).toEqual(pluginAppPolicyContext);
});
it("round-trips always plugin app policy context destructive approval mode", async () => {
const sessionFile = path.join(tempDir, "session.json");
const pluginAppPolicyContext = {
fingerprint: "plugin-policy-always",
apps: {
"google-calendar-app": {
configKey: "google-calendar",
marketplaceName: "openai-curated" as const,
pluginName: "google-calendar",
allowDestructiveActions: true,
destructiveApprovalMode: "always" as const,
mcpServerNames: ["google-calendar"],
},
},
pluginAppIds: {
"google-calendar": ["google-calendar-app"],
},
};
await writeCodexAppServerBinding(sessionFile, {
threadId: "thread-123",
cwd: tempDir,
pluginAppPolicyContext,
});
const binding = await readCodexAppServerBinding(sessionFile);
expect(binding?.pluginAppPolicyContext).toEqual(pluginAppPolicyContext);
});
it("normalizes v1 plugin app policy context destructive approval modes", async () => {
const sessionFile = path.join(tempDir, "session.json");
await fs.writeFile(

View File

@@ -421,6 +421,9 @@ function readDestructiveApprovalMode(
if (value === "auto") {
return bindingSchemaVersion === 1 ? "allow" : "auto";
}
if (value === "always" && bindingSchemaVersion === 2) {
return "always";
}
if (value === "on-request" && bindingSchemaVersion === 1) {
return "auto";
}

View File

@@ -5,6 +5,7 @@ import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
createCodexTrajectoryRecorder,
recordCodexTrajectoryCompletion,
recordCodexTrajectoryContext,
resolveCodexTrajectoryAppendFlags,
resolveCodexTrajectoryPointerFlags,
@@ -80,7 +81,9 @@ describe("Codex trajectory recorder", () => {
expect(content).not.toContain("secret");
expect(content).not.toContain("sk-test-secret-token");
expect(content).not.toContain("sk-other-secret-token");
expect(fs.statSync(filePath).mode & 0o777).toBe(0o600);
if (process.platform !== "win32") {
expect(fs.statSync(filePath).mode & 0o777).toBe(0o600);
}
expect(fs.existsSync(path.join(tmpDir, "session.trajectory-path.json"))).toBe(true);
});
@@ -253,4 +256,235 @@ describe("Codex trajectory recorder", () => {
expect(parsed.data?.truncated).toBe(true);
expect(parsed.data?.reason).toBe("trajectory-event-size-limit");
});
it("preserves usage when truncating oversized model completion events", async () => {
const tmpDir = makeTempDir();
const sessionFile = path.join(tmpDir, "session.jsonl");
const attempt = {
sessionFile,
sessionId: "session-1",
sessionKey: "agent:main:session-1",
runId: "run-1",
provider: "codex",
modelId: "gpt-5.4",
model: { api: "responses" },
} as never;
const usage = {
input: 384_954,
output: 5_624,
cacheRead: 333_824,
reasoningTokens: 2_038,
total: 724_402,
};
const recorder = createCodexTrajectoryRecorder({
cwd: tmpDir,
attempt,
env: {},
});
const trajectoryRecorder = expectTrajectoryRecorder(recorder);
recordCodexTrajectoryCompletion(trajectoryRecorder, {
attempt,
threadId: "thread-1",
turnId: "turn-1",
timedOut: false,
result: {
aborted: false,
attemptUsage: usage,
assistantTexts: ["done"],
messagesSnapshot: Array.from({ length: 20 }, (_value, index) => ({
role: index % 2 === 0 ? "user" : "assistant",
content: `message-${index} ${"x".repeat(32_000)}`,
})),
} as never,
});
await trajectoryRecorder.flush();
const parsed = JSON.parse(
fs.readFileSync(path.join(tmpDir, "session.trajectory.jsonl"), "utf8"),
);
expect(parsed.type).toBe("model.completed");
expect(parsed.data).toMatchObject({
truncated: true,
reason: "trajectory-event-size-limit",
usage,
});
expect(parsed.data.messagesSnapshot).toBeUndefined();
expect(parsed.data.droppedFields).toContain("messagesSnapshot");
expect(Buffer.byteLength(JSON.stringify(parsed), "utf8")).toBeLessThanOrEqual(256 * 1024);
});
it("drops oversized preserved fields when needed to keep completion events bounded", async () => {
const tmpDir = makeTempDir();
const sessionFile = path.join(tmpDir, "session.jsonl");
const attempt = {
sessionFile,
sessionId: "session-1",
sessionKey: "agent:main:session-1",
runId: "run-1",
provider: "codex",
modelId: "gpt-5.4",
model: { api: "responses" },
} as never;
const oversizedUsage = Object.fromEntries(
Array.from({ length: 100 }, (_value, index) => [`field-${index}`, "x".repeat(5_000)]),
);
const recorder = createCodexTrajectoryRecorder({
cwd: tmpDir,
attempt,
env: {},
});
const trajectoryRecorder = expectTrajectoryRecorder(recorder);
recordCodexTrajectoryCompletion(trajectoryRecorder, {
attempt,
threadId: "thread-1",
turnId: "turn-1",
timedOut: false,
result: {
aborted: false,
attemptUsage: oversizedUsage,
assistantTexts: ["x".repeat(32_000)],
messagesSnapshot: [{ role: "assistant", content: "x".repeat(32_000) }],
} as never,
});
await trajectoryRecorder.flush();
const parsed = JSON.parse(
fs.readFileSync(path.join(tmpDir, "session.trajectory.jsonl"), "utf8"),
);
expect(parsed.data).toMatchObject({
truncated: true,
reason: "trajectory-event-size-limit",
});
expect(parsed.data.usage).toBeUndefined();
expect(parsed.data.droppedFields).toEqual(
expect.arrayContaining(["usage", "assistantTexts", "messagesSnapshot"]),
);
expect(Buffer.byteLength(JSON.stringify(parsed), "utf8")).toBeLessThanOrEqual(256 * 1024);
});
it("preserves usage on non-final oversized model completion events", async () => {
const tmpDir = makeTempDir();
const sessionFile = path.join(tmpDir, "session.jsonl");
const attempt = {
sessionFile,
sessionId: "session-1",
sessionKey: "agent:main:session-1",
runId: "run-1",
provider: "codex",
modelId: "gpt-5.4",
model: { api: "responses" },
} as never;
const firstUsage = {
input: 384_954,
output: 5_624,
cacheRead: 333_824,
reasoningTokens: 2_038,
total: 724_402,
};
const secondUsage = { input: 12, output: 3, total: 15 };
const recorder = createCodexTrajectoryRecorder({
cwd: tmpDir,
attempt,
env: {},
});
const trajectoryRecorder = expectTrajectoryRecorder(recorder);
recordCodexTrajectoryCompletion(trajectoryRecorder, {
attempt,
threadId: "thread-1",
turnId: "turn-1",
timedOut: false,
result: {
aborted: false,
attemptUsage: firstUsage,
assistantTexts: ["first"],
messagesSnapshot: Array.from({ length: 20 }, (_value, index) => ({
role: index % 2 === 0 ? "user" : "assistant",
content: `message-${index} ${"x".repeat(32_000)}`,
})),
} as never,
});
recordCodexTrajectoryCompletion(trajectoryRecorder, {
attempt,
threadId: "thread-1",
turnId: "turn-2",
timedOut: false,
result: {
aborted: false,
attemptUsage: secondUsage,
assistantTexts: ["final answer"],
messagesSnapshot: [{ role: "assistant", content: "final answer" }],
} as never,
});
await trajectoryRecorder.flush();
const events = fs
.readFileSync(path.join(tmpDir, "session.trajectory.jsonl"), "utf8")
.trim()
.split(/\r?\n/u)
.map((line) => JSON.parse(line));
expect(events).toHaveLength(2);
expect(events[0].data).toMatchObject({
truncated: true,
usage: firstUsage,
});
expect(events[1].data).toMatchObject({
turnId: "turn-2",
usage: secondUsage,
assistantTexts: ["final answer"],
});
expect(events[1].data.truncated).toBeUndefined();
});
it("redacts secrets before preserving usage in truncated completion events", async () => {
const tmpDir = makeTempDir();
const sessionFile = path.join(tmpDir, "session.jsonl");
const attempt = {
sessionFile,
sessionId: "session-1",
sessionKey: "agent:main:session-1",
runId: "run-1",
provider: "codex",
modelId: "gpt-5.4",
model: { api: "responses" },
} as never;
const recorder = createCodexTrajectoryRecorder({
cwd: tmpDir,
attempt,
env: {},
});
const trajectoryRecorder = expectTrajectoryRecorder(recorder);
recordCodexTrajectoryCompletion(trajectoryRecorder, {
attempt,
threadId: "thread-1",
turnId: "turn-1",
timedOut: false,
result: {
aborted: false,
attemptUsage: {
total: 1,
apiKey: "sk-test-secret-token",
authorization: "Bearer sk-other-secret-token",
},
assistantTexts: ["done"],
messagesSnapshot: Array.from({ length: 20 }, (_value, index) => ({
role: index % 2 === 0 ? "user" : "assistant",
content: `message-${index} ${"x".repeat(32_000)}`,
})),
} as never,
});
await trajectoryRecorder.flush();
const parsed = JSON.parse(
fs.readFileSync(path.join(tmpDir, "session.trajectory.jsonl"), "utf8"),
);
const preservedUsage = JSON.stringify(parsed.data.usage);
expect(parsed.data.truncated).toBe(true);
expect(preservedUsage).toContain("redacted");
expect(preservedUsage).not.toContain("sk-test-secret-token");
expect(preservedUsage).not.toContain("sk-other-secret-token");
});
});

View File

@@ -40,6 +40,7 @@ const JWT_VALUE_RE = /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]
const COOKIE_PAIR_RE = /\b([A-Za-z][A-Za-z0-9_.-]{1,64})=([A-Za-z0-9+/._~%=-]{16,})(?=;|\s|$)/gu;
const TRAJECTORY_RUNTIME_FILE_MAX_BYTES = 50 * 1024 * 1024;
const TRAJECTORY_RUNTIME_EVENT_MAX_BYTES = 256 * 1024;
const TRAJECTORY_RUNTIME_OVERSIZE_PRESERVED_DATA_KEYS = ["usage", "promptCache"] as const;
type CodexTrajectoryOpenFlagConstants = Pick<
typeof nodeFs.constants,
@@ -82,19 +83,57 @@ function boundedTrajectoryLine(event: Record<string, unknown>): string | undefin
if (bytes <= TRAJECTORY_RUNTIME_EVENT_MAX_BYTES) {
return `${line}\n`;
}
const truncated = JSON.stringify({
...event,
data: {
truncated: true,
originalBytes: bytes,
limitBytes: TRAJECTORY_RUNTIME_EVENT_MAX_BYTES,
reason: "trajectory-event-size-limit",
},
});
if (Buffer.byteLength(truncated, "utf8") <= TRAJECTORY_RUNTIME_EVENT_MAX_BYTES) {
return `${truncated}\n`;
const originalData =
event.data && typeof event.data === "object" && !Array.isArray(event.data)
? (event.data as Record<string, unknown>)
: {};
const originalDataKeys = Object.keys(originalData);
const preservedDataKeys = new Set<string>();
const baseData = {
truncated: true,
originalBytes: bytes,
limitBytes: TRAJECTORY_RUNTIME_EVENT_MAX_BYTES,
reason: "trajectory-event-size-limit",
};
const buildTruncatedLine = (includeDroppedFields: boolean): string | undefined => {
const data: Record<string, unknown> = { ...baseData };
for (const key of TRAJECTORY_RUNTIME_OVERSIZE_PRESERVED_DATA_KEYS) {
if (preservedDataKeys.has(key)) {
data[key] = originalData[key];
}
}
if (includeDroppedFields) {
const droppedFields = originalDataKeys.filter((key) => !preservedDataKeys.has(key));
if (droppedFields.length > 0) {
data.droppedFields = droppedFields;
}
}
const truncated = JSON.stringify({ ...event, data });
if (Buffer.byteLength(truncated, "utf8") <= TRAJECTORY_RUNTIME_EVENT_MAX_BYTES) {
return `${truncated}\n`;
}
return undefined;
};
let best = buildTruncatedLine(true) ?? buildTruncatedLine(false);
if (!best) {
return undefined;
}
return undefined;
for (const key of TRAJECTORY_RUNTIME_OVERSIZE_PRESERVED_DATA_KEYS) {
if (!Object.hasOwn(originalData, key)) {
continue;
}
preservedDataKeys.add(key);
const next = buildTruncatedLine(true) ?? buildTruncatedLine(false);
if (next) {
best = next;
continue;
}
preservedDataKeys.delete(key);
}
return best;
}
function resolveTrajectoryPointerFilePath(sessionFile: string): string {

View File

@@ -23,7 +23,7 @@ export type CodexPluginConfigEntry = {
enabled?: boolean;
marketplaceName?: string;
pluginName?: string;
allow_destructive_actions?: boolean | "auto";
allow_destructive_actions?: boolean | "auto" | "always";
};
export type CodexPluginsConfigBlock = {

View File

@@ -24,6 +24,31 @@ describe("codex conversation turn input", () => {
]);
});
it("uses staged remote-cache paths for remote iMessage image attachments", () => {
const rawPath = "/Users/demo/Library/Messages/Attachments/ab/cd/photo.jpg";
const stagedPath = "/tmp/openclaw-proof/.openclaw/media/remote-cache/imessage/photo.jpg";
const input = buildCodexConversationTurnInput({
prompt: "what is this?",
event: {
content: "what is this?",
channel: "imessage",
isGroup: false,
metadata: {
mediaPaths: [stagedPath],
mediaTypes: ["image/jpeg"],
originalMediaPaths: [rawPath],
},
},
});
expect(input).toEqual([
{ type: "text", text: "what is this?", text_elements: [] },
{ type: "localImage", path: stagedPath },
]);
expect(input).not.toContainEqual({ type: "localImage", path: rawPath });
});
it("uses remote image urls when no local path is available", () => {
expect(
buildCodexConversationTurnInput({

View File

@@ -43,7 +43,7 @@ export type CodexPluginMigrationConfigEntry = {
configKey: string;
pluginName: string;
enabled: boolean;
allowDestructiveActions?: "auto";
allowDestructiveActions?: "auto" | "always";
};
type CodexPluginMigrationBlockSkipDetails = {
@@ -168,15 +168,18 @@ function isLegacyDestructivePolicyRepair(
);
}
function isLegacyDestructivePolicyConfigEntryRepair(
function readExistingPluginAllowDestructiveActions(
existing: unknown,
pluginName: string,
): boolean {
): "auto" | "always" | undefined {
const existingEntry = isRecord(existing) ? existing : undefined;
return (
existingEntry?.allow_destructive_actions === "on-request" &&
existingEntry.pluginName === pluginName
if (existingEntry?.pluginName !== pluginName) {
return undefined;
}
const normalized = normalizeExistingAllowDestructiveActions(
existingEntry.allow_destructive_actions,
);
return normalized === "auto" || normalized === "always" ? normalized : undefined;
}
function buildPluginItems(
@@ -203,12 +206,15 @@ function buildPluginItems(
enabled: true,
marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME,
pluginName: plugin.pluginName,
...(isLegacyDestructivePolicyConfigEntryRepair(
existingPluginEntries[configKey],
plugin.pluginName,
)
? { allow_destructive_actions: "auto" }
: {}),
...(() => {
const allowDestructiveActions = readExistingPluginAllowDestructiveActions(
existingPluginEntries[configKey],
plugin.pluginName,
);
return allowDestructiveActions
? { allow_destructive_actions: allowDestructiveActions }
: {};
})(),
};
const conflict =
!ctx.overwrite &&
@@ -234,8 +240,9 @@ function buildPluginItems(
pluginName: plugin.pluginName,
sourceInstalled: plugin.installed === true,
sourceEnabled: plugin.enabled === true,
...(plannedEntry.allow_destructive_actions === "auto"
? { allowDestructiveActions: "auto" }
...(plannedEntry.allow_destructive_actions === "auto" ||
plannedEntry.allow_destructive_actions === "always"
? { allowDestructiveActions: plannedEntry.allow_destructive_actions }
: {}),
...(plugin.apps && plugin.apps.length > 0 && !shouldVerifyPluginApps(ctx)
? { sourceAppVerification: CODEX_PLUGIN_SOURCE_APP_VERIFICATION_UNVERIFIED }
@@ -310,13 +317,15 @@ export function readCodexPluginMigrationConfigEntry(
configKey,
pluginName,
enabled,
...(allowDestructiveActions === "auto" ? { allowDestructiveActions: "auto" } : {}),
...(allowDestructiveActions === "auto" || allowDestructiveActions === "always"
? { allowDestructiveActions }
: {}),
};
}
function readExistingAllowDestructiveActions(
config: MigrationProviderContext["config"],
): boolean | "auto" | undefined {
): boolean | "auto" | "always" | undefined {
const value = readMigrationConfigPath(config as Record<string, unknown>, [
...CODEX_PLUGIN_NATIVE_CONFIG_PATH,
"allow_destructive_actions",
@@ -324,8 +333,16 @@ function readExistingAllowDestructiveActions(
return normalizeExistingAllowDestructiveActions(value);
}
function normalizeExistingAllowDestructiveActions(value: unknown): boolean | "auto" | undefined {
return value === "auto" || value === "on-request" ? "auto" : asBoolean(value);
function normalizeExistingAllowDestructiveActions(
value: unknown,
): boolean | "auto" | "always" | undefined {
if (value === "auto" || value === "on-request") {
return "auto";
}
if (value === "always") {
return "always";
}
return asBoolean(value);
}
function readExistingPluginPolicyRepairs(

View File

@@ -2108,6 +2108,76 @@ describe("buildCodexMigrationProvider", () => {
});
});
it("preserves global always destructive plugin policy during migration", async () => {
const fixture = await createCodexFixture();
const configState: MigrationProviderContext["config"] = {
plugins: {
entries: {
codex: {
enabled: true,
config: {
codexPlugins: {
enabled: true,
allow_destructive_actions: "always",
plugins: {},
},
},
},
},
},
agents: { defaults: { workspace: fixture.workspaceDir } },
} as MigrationProviderContext["config"];
appServerRequest.mockImplementation(async ({ method }: { method: string }) => {
if (method === "plugin/list") {
return pluginList([pluginSummary("google-calendar", { installed: true, enabled: true })]);
}
if (method === "plugin/read") {
return pluginRead("google-calendar");
}
if (method === "plugin/install") {
return { authPolicy: "ON_USE", appsNeedingAuth: [] } satisfies v2.PluginInstallResponse;
}
if (method === "skills/list") {
return { data: [] } satisfies v2.SkillsListResponse;
}
if (method === "hooks/list") {
return { data: [] } satisfies v2.HooksListResponse;
}
if (method === "config/mcpServer/reload") {
return {};
}
if (method === "app/list") {
return appsList([]);
}
throw new Error(`unexpected request ${method}`);
});
const provider = buildCodexMigrationProvider({
runtime: createConfigRuntime(configState),
});
const result = await provider.apply(
makeContext({
source: fixture.codexHome,
stateDir: fixture.stateDir,
workspaceDir: fixture.workspaceDir,
config: configState,
}),
);
expectRecordFields(findItem(result.items, "config:codex-plugins"), { status: "migrated" });
expect(configState.plugins?.entries?.codex?.config?.codexPlugins).toEqual({
enabled: true,
allow_destructive_actions: "always",
plugins: {
"google-calendar": {
enabled: true,
marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME,
pluginName: "google-calendar",
},
},
});
});
it("records auth-required plugin installs as disabled explicit config entries", async () => {
const fixture = await createCodexFixture();
const configState: MigrationProviderContext["config"] = {

View File

@@ -207,4 +207,65 @@ describe("codex cli node sessions", () => {
}),
).rejects.toThrow("Codex CLI node command returned malformed payloadJSON.");
});
it("keeps Codex history session previews on UTF-16 code point boundaries", async () => {
const sessionId = "019e2007-1f7e-7eb1-a42b-8c01f4b9b5ce";
const text = `${"a".repeat(136)}🤖tail`;
await fs.writeFile(
path.join(tempDir, "history.jsonl"),
JSON.stringify({ session_id: sessionId, ts: 1778678322, text }),
);
const command = createCodexCliSessionNodeHostCommands().find(
(entry) => entry.command === CODEX_CLI_SESSIONS_LIST_COMMAND,
);
const raw = await command?.handle(JSON.stringify({ filter: "", limit: 5 }));
const parsed = JSON.parse(raw ?? "{}") as {
sessions?: Array<{ lastMessage?: string }>;
};
expect(parsed.sessions?.[0]?.lastMessage).toBe(`${"a".repeat(136)}...`);
expect(parsed.sessions?.[0]?.lastMessage).not.toContain("\ud83e");
expect(parsed.sessions?.[0]?.lastMessage).not.toContain("\udd16");
});
it("keeps Codex session-file previews on UTF-16 code point boundaries", async () => {
const sessionId = "019e23d1-f33d-78e3-959e-0f56f30a5248";
const sessionDir = path.join(tempDir, "sessions", "2026", "05", "14");
const sessionFile = path.join(sessionDir, `rollout-2026-05-14T00-10-22-${sessionId}.jsonl`);
const text = `${"b".repeat(136)}🤖tail`;
await fs.mkdir(sessionDir, { recursive: true });
await fs.writeFile(
sessionFile,
[
JSON.stringify({
timestamp: "2026-05-14T00:10:23.618Z",
type: "session_meta",
payload: { id: sessionId, cwd: "/tmp/codex-work" },
}),
JSON.stringify({
timestamp: "2026-05-14T00:10:23.619Z",
type: "response_item",
payload: {
type: "message",
role: "user",
content: [{ type: "input_text", text }],
},
}),
].join("\n"),
);
const command = createCodexCliSessionNodeHostCommands().find(
(entry) => entry.command === CODEX_CLI_SESSIONS_LIST_COMMAND,
);
const raw = await command?.handle(JSON.stringify({ filter: "", limit: 5 }));
const parsed = JSON.parse(raw ?? "{}") as {
sessions?: Array<{ lastMessage?: string }>;
};
expect(parsed.sessions?.[0]?.lastMessage).toBe(`${"b".repeat(136)}...`);
expect(parsed.sessions?.[0]?.lastMessage).not.toContain("\ud83e");
expect(parsed.sessions?.[0]?.lastMessage).not.toContain("\udd16");
});
});

View File

@@ -12,6 +12,7 @@ import type {
import type { PluginRuntime } from "openclaw/plugin-sdk/plugin-runtime";
import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime";
import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path";
import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
import {
materializeWindowsSpawnProgram,
resolveWindowsSpawnProgram,
@@ -691,7 +692,10 @@ function normalizeTimeoutMs(value: unknown): number {
}
function truncateText(value: string, max: number): string {
return value.length > max ? `${value.slice(0, max - 3)}...` : value;
if (value.length <= max) {
return value;
}
return `${truncateUtf16Safe(value, Math.max(0, max - 3))}...`;
}
function compareOptionalStringsDesc(a?: string, b?: string): number {

View File

@@ -9,6 +9,28 @@ import {
} from "./probe.js";
import { jsonResponse } from "./test-http-helpers.js";
const DISCORD_PROBE_JSON_CAP_BYTES = 16 * 1024 * 1024;
function oversizedDiscordProbeJsonResponse(onCancel: () => void): Response {
const response = new Response(
new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new Uint8Array(DISCORD_PROBE_JSON_CAP_BYTES + 1));
},
cancel() {
onCancel();
},
}),
{ headers: { "content-type": "application/json" }, status: 200 },
);
Object.defineProperty(response, "json", {
value: async () => {
throw new Error("unbounded json reader was used");
},
});
return response;
}
describe("resolveDiscordPrivilegedIntentsFromFlags", () => {
beforeEach(() => {
vi.useRealTimers();
@@ -104,6 +126,21 @@ describe("resolveDiscordPrivilegedIntentsFromFlags", () => {
expect(cancel).toHaveBeenCalledTimes(1);
});
it("bounds oversized getMe probe JSON responses and cancels the stream", async () => {
let cancelCount = 0;
const fetcher = withFetchPreconnect(async () =>
oversizedDiscordProbeJsonResponse(() => {
cancelCount += 1;
}),
);
await expect(probeDiscord("MTIz.abc.def", 1_000, { fetcher })).resolves.toMatchObject({
ok: false,
error: expect.stringContaining("discord.probe.getMe: JSON response exceeds 16777216 bytes"),
});
expect(cancelCount).toBe(1);
});
it("derives application id from parseable tokens before probing REST", async () => {
let calls = 0;
const fetcher = withFetchPreconnect(async () => {

View File

@@ -2,6 +2,7 @@
import type { BaseProbeResult } from "openclaw/plugin-sdk/channel-contract";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { resolveFetch } from "openclaw/plugin-sdk/fetch-runtime";
import { readProviderJsonResponse } from "openclaw/plugin-sdk/provider-http";
import { fetchWithTimeout } from "openclaw/plugin-sdk/text-utility-runtime";
import { DiscordApiError, fetchDiscord } from "./api.js";
import { normalizeDiscordToken } from "./token.js";
@@ -155,7 +156,10 @@ export async function probeDiscord(
result.error = `getMe failed (${res.status})`;
return { ...result, elapsedMs: Date.now() - started };
}
const json = (await res.json()) as { id?: string; username?: string };
const json = await readProviderJsonResponse<{ id?: string; username?: string }>(
res,
"discord.probe.getMe",
);
result.ok = true;
result.bot = {
id: json.id ?? null,

View File

@@ -36,6 +36,10 @@ type DuckDuckGoResult = {
snippet: string;
};
function isDecodableCodePoint(cp: number): boolean {
return Number.isInteger(cp) && cp >= 0 && cp <= 0x10ffff && (cp < 0xd800 || cp > 0xdfff);
}
function decodeHtmlEntities(text: string): string {
return text.replace(
/&(?:lt|gt|quot|apos|#39|#x27|#x2F|nbsp|ndash|mdash|hellip|amp|#\d+|#x[0-9a-f]+);/gi,
@@ -72,10 +76,12 @@ function decodeHtmlEntities(text: string): string {
return "&";
}
if (normalized.startsWith("&#x")) {
return String.fromCodePoint(Number.parseInt(normalized.slice(3, -1), 16));
const codePoint = Number.parseInt(normalized.slice(3, -1), 16);
return isDecodableCodePoint(codePoint) ? String.fromCodePoint(codePoint) : entity;
}
if (normalized.startsWith("&#")) {
return String.fromCodePoint(Number.parseInt(normalized.slice(2, -1), 10));
const codePoint = Number.parseInt(normalized.slice(2, -1), 10);
return isDecodableCodePoint(codePoint) ? String.fromCodePoint(codePoint) : entity;
}
return entity;
},

View File

@@ -205,6 +205,20 @@ describe("duckduckgo web search provider", () => {
);
});
it("leaves out-of-range numeric html entities intact instead of throwing", () => {
expect(() => ddgClientTesting.decodeHtmlEntities("Result &#99999999; end")).not.toThrow();
expect(ddgClientTesting.decodeHtmlEntities("Result &#99999999; end")).toBe(
"Result &#99999999; end",
);
expect(ddgClientTesting.decodeHtmlEntities("Hex &#x110000; tail")).toBe("Hex &#x110000; tail");
// Surrogate-range entities would decode to lone UTF-16 surrogates; keep them intact.
expect(ddgClientTesting.decodeHtmlEntities("Bad &#55296; end")).toBe("Bad &#55296; end");
expect(ddgClientTesting.decodeHtmlEntities("Bad &#xD800; end")).toBe("Bad &#xD800; end");
expect(ddgClientTesting.decodeHtmlEntities("Bad &#xDFFF; end")).toBe("Bad &#xDFFF; end");
// A valid supplementary-plane entity still decodes.
expect(ddgClientTesting.decodeHtmlEntities("Smile &#128512;")).toBe("Smile 😀");
});
it("does not double-decode escaped entities (decodes &amp; last)", () => {
// A result whose text literally shows "&lt;" arrives double-encoded as
// "&amp;lt;". Decoding &amp; first would re-decode it into "<", corrupting

View File

@@ -11,6 +11,7 @@ import {
import {
assertOkOrThrowProviderError,
postJsonRequest,
readProviderJsonResponse,
type ProviderRequestTransportOverrides,
} from "openclaw/plugin-sdk/provider-http";
import {
@@ -97,11 +98,11 @@ async function generateGeminiInlineDataText(params: {
try {
await assertOkOrThrowProviderError(res, params.httpErrorLabel);
const payload = (await res.json()) as {
const payload = await readProviderJsonResponse<{
candidates?: Array<{
content?: { parts?: Array<{ text?: string }> };
}>;
};
}>(res, params.httpErrorLabel);
const parts = payload.candidates?.[0]?.content?.parts ?? [];
const text = parts
.map((part) => part?.text?.trim())

View File

@@ -1,4 +1,5 @@
// Google tests cover media understanding provider.video plugin behavior.
import { createServer, type Server } from "node:http";
import {
createRequestCaptureJsonFetch,
installPinnedHostnameTestHooks,
@@ -10,6 +11,49 @@ import { resolveGoogleGenerativeAiHttpRequestConfig } from "./runtime-api.js";
installPinnedHostnameTestHooks();
const LOOPBACK_RESPONSE_BYTES = 18 * 1024 * 1024;
async function listenLoopbackServer(server: Server): Promise<number> {
return await new Promise((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", () => {
server.off("error", reject);
const address = server.address();
if (!address || typeof address === "string") {
reject(new Error("expected loopback TCP address"));
return;
}
resolve(address.port);
});
});
}
function createOversizedJsonServer(): { server: Server; closed: Promise<number> } {
let resolveClosed: (sentBytes: number) => void = () => {};
const closed = new Promise<number>((resolve) => {
resolveClosed = resolve;
});
const server = createServer((_req, res) => {
let sentBytes = 0;
const chunk = Buffer.alloc(64 * 1024, 0x20);
res.writeHead(200, { "content-type": "application/json" });
const timer = setInterval(() => {
if (sentBytes >= LOOPBACK_RESPONSE_BYTES) {
clearInterval(timer);
res.end();
return;
}
sentBytes += chunk.length;
res.write(chunk);
}, 1);
res.on("close", () => {
clearInterval(timer);
resolveClosed(sentBytes);
});
});
return { server, closed };
}
describe("describeGeminiVideo", () => {
it("respects case-insensitive x-goog-api-key overrides", async () => {
let seenKey: string | null = null;
@@ -114,6 +158,29 @@ describe("describeGeminiVideo", () => {
);
});
it("bounds oversized video JSON responses and closes the stream early", async () => {
const { server, closed } = createOversizedJsonServer();
const port = await listenLoopbackServer(server);
const fetchFn = withFetchPreconnect(async () =>
fetch(`http://127.0.0.1:${port}/google-video-json`),
);
try {
await expect(
describeGeminiVideo({
buffer: Buffer.from("video-bytes"),
fileName: "clip.mp4",
apiKey: "test-key",
timeoutMs: 1500,
fetchFn,
}),
).rejects.toThrow(/JSON response exceeds 16777216 bytes/u);
await expect(closed).resolves.toBeLessThan(LOOPBACK_RESPONSE_BYTES);
} finally {
server.close();
}
});
it("rejects non-Google video base URLs before sending authenticated requests", async () => {
await expect(
describeGeminiVideo({

View File

@@ -20,6 +20,8 @@ const {
let buildGoogleSpeechProvider: typeof import("./speech-provider.js").buildGoogleSpeechProvider;
let testing: typeof import("./speech-provider.js").testing;
const GOOGLE_TTS_JSON_CAP_BYTES = 16 * 1024 * 1024;
beforeAll(async () => {
({ buildGoogleSpeechProvider, testing } = await import("./speech-provider.js"));
});
@@ -56,6 +58,26 @@ function installGoogleTtsRequestMock(pcm = Buffer.from([1, 0, 2, 0])) {
return postJsonRequestMock;
}
function oversizedGoogleTtsJsonResponse(onCancel: () => void): Response {
const response = new Response(
new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new Uint8Array(GOOGLE_TTS_JSON_CAP_BYTES + 1));
},
cancel() {
onCancel();
},
}),
{ headers: { "content-type": "application/json" }, status: 200 },
);
Object.defineProperty(response, "json", {
value: async () => {
throw new Error("unbounded json reader was used");
},
});
return response;
}
function expectRecordFields(value: unknown, expected: Record<string, unknown>) {
if (!value || typeof value !== "object") {
throw new Error("Expected record");
@@ -149,6 +171,39 @@ describe("Google speech provider", () => {
expect(transcodeAudioBufferToOpusMock).not.toHaveBeenCalled();
});
it("bounds oversized Gemini TTS success JSON responses and cancels the stream", async () => {
let cancelCount = 0;
const release = vi.fn(async () => {});
postJsonRequestMock
.mockResolvedValueOnce({
response: oversizedGoogleTtsJsonResponse(() => {
cancelCount += 1;
}),
release,
})
.mockResolvedValueOnce({
response: oversizedGoogleTtsJsonResponse(() => {
cancelCount += 1;
}),
release,
});
const provider = buildGoogleSpeechProvider();
await expect(
provider.synthesize({
text: "oversized tts response",
cfg: {},
providerConfig: {
apiKey: "google-test-key",
},
target: "audio-file",
timeoutMs: 12_000,
}),
).rejects.toThrow("Google TTS response: JSON response exceeds 16777216 bytes");
expect(cancelCount).toBe(2);
expect(release).toHaveBeenCalledTimes(2);
});
it("transcodes Gemini PCM to Opus for voice-note targets", async () => {
installGoogleTtsRequestMock(Buffer.from([5, 0, 6, 0]));
transcodeAudioBufferToOpusMock.mockResolvedValueOnce(Buffer.from("google-opus"));

View File

@@ -3,6 +3,7 @@ import { transcodeAudioBufferToOpus } from "openclaw/plugin-sdk/media-runtime";
import {
assertOkOrThrowProviderError,
postJsonRequest,
readProviderJsonResponse,
sanitizeConfiguredModelProviderRequest,
} from "openclaw/plugin-sdk/provider-http";
import type { OpenClawConfig } from "openclaw/plugin-sdk/provider-onboard";
@@ -503,7 +504,11 @@ async function synthesizeGoogleTtsPcmOnce(params: {
}
}
try {
return extractGoogleSpeechPcm((await res.json()) as GoogleGenerateSpeechResponse);
const payload = await readProviderJsonResponse<GoogleGenerateSpeechResponse>(
res,
"Google TTS response",
);
return extractGoogleSpeechPcm(payload);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
throw new GoogleTtsRetryableError(message);

View File

@@ -476,6 +476,45 @@ describe("google transport stream", () => {
expect(result.content[2]).toHaveProperty("thoughtSignature", "Y2FsbF9zaWdfMQ==");
});
it("preserves MAX_TOKENS when the partial response contains a function call", async () => {
guardedFetchMock.mockResolvedValueOnce(
buildSseResponse([
{
candidates: [
{
content: {
parts: [{ functionCall: { name: "lookup", args: { q: "hello" } } }],
},
finishReason: "MAX_TOKENS",
},
],
},
]),
);
const streamFn = createGoogleGenerativeAiTransportStreamFn();
const stream = await Promise.resolve(
streamFn(
buildGeminiModel(),
{
messages: [{ role: "user", content: "hello", timestamp: 0 }],
tools: [
{
name: "lookup",
description: "Look up a value",
parameters: { type: "object" },
},
],
} as Parameters<typeof streamFn>[1],
{ apiKey: "gemini-api-key" } as Parameters<typeof streamFn>[2],
),
);
const result = await stream.result();
expect(result.stopReason).toBe("length");
expect(result.content).toEqual([expect.objectContaining({ type: "toolCall", name: "lookup" })]);
});
it("strips redundant google provider prefixes from Gemini API model paths", async () => {
guardedFetchMock.mockResolvedValueOnce(buildSseResponse([]));

View File

@@ -1404,7 +1404,12 @@ function createGoogleTransportStreamFn(kind: CanonicalGoogleTransportApi): Strea
}
if (typeof candidate?.finishReason === "string") {
output.stopReason = mapStopReasonString(candidate.finishReason);
if (output.content.some((block) => block.type === "toolCall")) {
// MAX_TOKENS can leave a complete-looking partial call. Only a normal
// Google stop may promote parsed calls into an executable tool-use turn.
if (
output.stopReason === "stop" &&
output.content.some((block) => block.type === "toolCall")
) {
output.stopReason = "toolUse";
}
}

View File

@@ -2,6 +2,10 @@
import crypto from "node:crypto";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { parseMediaContentLength } from "openclaw/plugin-sdk/media-runtime";
import {
readProviderJsonResponse,
readResponseTextLimited,
} from "openclaw/plugin-sdk/provider-http";
import { readResponseWithLimit } from "openclaw/plugin-sdk/response-limit-runtime";
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
import type { ResolvedGoogleChatAccount } from "./accounts.js";
@@ -13,11 +17,7 @@ const CHAT_API_BASE = "https://chat.googleapis.com/v1";
const CHAT_UPLOAD_BASE = "https://chat.googleapis.com/upload/v1";
async function readGoogleChatJsonResponse<T>(response: Response, label: string): Promise<T> {
try {
return (await response.json()) as T;
} catch (cause) {
throw new Error(`${label}: malformed JSON response`, { cause });
}
return readProviderJsonResponse<T>(response, label);
}
const headersToObject = (headers?: HeadersInit): Record<string, string> =>
@@ -57,7 +57,7 @@ async function withGoogleChatResponse<T>(params: {
});
try {
if (!response.ok) {
const text = await response.text().catch(() => "");
const text = await readResponseTextLimited(response).catch(() => "");
throw new Error(`${errorPrefix} ${response.status}: ${text || response.statusText}`);
}
return await handleResponse(response);

View File

@@ -110,6 +110,45 @@ function createDeferred<T>(): {
return { promise, reject, resolve };
}
type CardPayloadWithTextWidgets = {
cardsV2: Array<{
card: {
sections?: Array<{
header?: string;
widgets?: Array<{ textParagraph?: { text: string } }>;
}>;
};
}>;
};
function getTextParagraphText(payload: unknown, header: string): string {
const text = (payload as CardPayloadWithTextWidgets).cardsV2[0]?.card.sections?.find(
(section) => section.header === header,
)?.widgets?.[0]?.textParagraph?.text;
if (typeof text !== "string") {
throw new Error(`Expected ${header} text paragraph`);
}
return text;
}
function isUtf16WellFormed(value: string): boolean {
for (let index = 0; index < value.length; index += 1) {
const codeUnit = value.charCodeAt(index);
if (codeUnit >= 0xd800 && codeUnit <= 0xdbff) {
const nextCodeUnit = index + 1 < value.length ? value.charCodeAt(index + 1) : -1;
if (nextCodeUnit < 0xdc00 || nextCodeUnit > 0xdfff) {
return false;
}
index += 1;
continue;
}
if (codeUnit >= 0xdc00 && codeUnit <= 0xdfff) {
return false;
}
}
return true;
}
describe("googleChatApprovalNativeRuntime", () => {
async function preparePendingDelivery(view = createPendingView()) {
const nowMs = Date.now();
@@ -149,6 +188,31 @@ describe("googleChatApprovalNativeRuntime", () => {
return { pendingPayload, plannedTarget, prepared, request, view };
}
it("keeps truncated pending command card text UTF-16 well formed", async () => {
const view = createPendingView();
view.commandText = `${"a".repeat(1796)}😀${"b".repeat(100)}`;
const { pendingPayload } = await preparePendingDelivery(view);
const commandText = getTextParagraphText(pendingPayload, "Command");
expect(commandText.length).toBeLessThanOrEqual(1800);
expect(commandText.endsWith("...")).toBe(true);
expect(isUtf16WellFormed(commandText)).toBe(true);
expect(JSON.stringify(pendingPayload.cardsV2)).not.toContain("\\ud83d");
});
it("preserves a complete astral character when it fits before the truncation suffix", async () => {
const view = createPendingView();
view.commandText = `${"a".repeat(1795)}😀${"b".repeat(100)}`;
const { pendingPayload } = await preparePendingDelivery(view);
const commandText = getTextParagraphText(pendingPayload, "Command");
expect(commandText).toBe(`${"a".repeat(1795)}😀...`);
expect(commandText.length).toBe(1800);
expect(isUtf16WellFormed(commandText)).toBe(true);
});
it("sends pending cards and updates the delivered message without buttons", async () => {
sendGoogleChatMessage.mockResolvedValue({ messageName: "spaces/AAA/messages/msg-1" });
updateGoogleChatMessage.mockResolvedValue({ messageName: "spaces/AAA/messages/msg-1" });

View File

@@ -9,6 +9,7 @@ import { buildChannelApprovalNativeTargetKey } from "openclaw/plugin-sdk/approva
import type { ExecApprovalDecision } from "openclaw/plugin-sdk/approval-runtime";
import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env";
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
import { resolveGoogleChatAccount, type ResolvedGoogleChatAccount } from "./accounts.js";
import { sendGoogleChatMessage, updateGoogleChatMessage } from "./api.js";
import {
@@ -87,7 +88,7 @@ function escapeGoogleChatText(text: string): string {
}
function truncateText(text: string, maxChars = MAX_TEXT_PARAGRAPH_CHARS): string {
return text.length <= maxChars ? text : `${text.slice(0, maxChars - 3)}...`;
return text.length <= maxChars ? text : `${truncateUtf16Safe(text, maxChars - 3)}...`;
}
function buildMetadataText(metadata: readonly { label: string; value: string }[]): string {

View File

@@ -1,4 +1,5 @@
// Googlechat plugin module implements auth behavior.
import { readProviderJsonResponse } from "openclaw/plugin-sdk/provider-http";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
import { fetchWithSsrFGuard } from "../runtime-api.js";
import type { ResolvedGoogleChatAccount } from "./accounts.js";
@@ -17,11 +18,10 @@ const CHAT_CERTS_URL =
"https://www.googleapis.com/service_accounts/v1/metadata/x509/chat@system.gserviceaccount.com";
async function readGoogleChatCertsResponse(response: Response): Promise<Record<string, string>> {
try {
return (await response.json()) as Record<string, string>;
} catch (cause) {
throw new Error("Google Chat cert fetch failed: malformed JSON response", { cause });
}
return readProviderJsonResponse<Record<string, string>>(
response,
"Google Chat cert fetch failed",
);
}
// Size-capped to prevent unbounded growth in long-running deployments (#4948)

View File

@@ -568,4 +568,137 @@ describe("verifyGoogleChatRequest", () => {
});
expect(release).toHaveBeenCalledOnce();
});
describe("bounded JSON read (readProviderJsonResponse delegation)", () => {
afterEach(() => {
authTesting.resetGoogleChatAuthForTests();
mocks.fetchWithSsrFGuard.mockClear();
vi.unstubAllGlobals();
});
it("cancels oversized cert fetch JSON body via the 16 MiB provider cap", async () => {
const ONE_MIB = 1024 * 1024;
const TOTAL_CHUNKS = 32;
const chunk = new Uint8Array(ONE_MIB);
let bytesPulled = 0;
let canceled = false;
const oversizedJson = new Response(
new ReadableStream<Uint8Array>({
pull(controller) {
if (bytesPulled >= TOTAL_CHUNKS * ONE_MIB) {
controller.close();
return;
}
bytesPulled += chunk.length;
controller.enqueue(chunk);
},
cancel() {
canceled = true;
},
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
const release = vi.fn(async () => {});
mocks.fetchWithSsrFGuard.mockResolvedValueOnce({
response: oversizedJson,
release,
});
const result = await verifyGoogleChatRequest({
bearer: "token",
audienceType: "project-number",
audience: "123456789",
});
expect(result.ok).toBe(false);
expect(result.reason).toMatch(/JSON response exceeds 16777216 bytes/);
expect(canceled).toBe(true);
expect(bytesPulled).toBeLessThan(TOTAL_CHUNKS * ONE_MIB);
expect(release).toHaveBeenCalledOnce();
});
it("rejects oversized sendMessage JSON body via the 16 MiB provider cap", async () => {
const ONE_MIB = 1024 * 1024;
const TOTAL_CHUNKS = 32;
const chunk = new Uint8Array(ONE_MIB);
let bytesPulled = 0;
let canceled = false;
const oversizedJson = new Response(
new ReadableStream<Uint8Array>({
pull(controller) {
if (bytesPulled >= TOTAL_CHUNKS * ONE_MIB) {
controller.close();
return;
}
bytesPulled += chunk.length;
controller.enqueue(chunk);
},
cancel() {
canceled = true;
},
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
const release = vi.fn(async () => {});
mocks.fetchWithSsrFGuard.mockResolvedValueOnce({
response: oversizedJson,
release,
});
await expect(
sendGoogleChatMessage({
account,
space: "spaces/AAA",
text: "hello",
}),
).rejects.toThrow(/Google Chat API request failed: JSON response exceeds 16777216 bytes/);
expect(canceled).toBe(true);
expect(bytesPulled).toBeLessThan(TOTAL_CHUNKS * ONE_MIB);
});
it("caps non-OK sendMessage error bodies before formatting the API error", async () => {
const ONE_MIB = 1024 * 1024;
const TOTAL_CHUNKS = 32;
const chunk = new TextEncoder().encode("x".repeat(ONE_MIB));
let bytesPulled = 0;
let canceled = false;
const oversizedError = new Response(
new ReadableStream<Uint8Array>({
pull(controller) {
if (bytesPulled >= TOTAL_CHUNKS * ONE_MIB) {
controller.close();
return;
}
bytesPulled += chunk.length;
controller.enqueue(chunk);
},
cancel() {
canceled = true;
},
}),
{ status: 500, statusText: "Internal Server Error" },
);
const release = vi.fn(async () => {});
mocks.fetchWithSsrFGuard.mockResolvedValueOnce({
response: oversizedError,
release,
});
await expect(
sendGoogleChatMessage({
account,
space: "spaces/AAA",
text: "hello",
}),
).rejects.toThrow(/^Google Chat API 500: x+/);
expect(canceled).toBe(true);
expect(bytesPulled).toBeLessThan(TOTAL_CHUNKS * ONE_MIB);
expect(release).toHaveBeenCalledOnce();
});
});
});

View File

@@ -15,3 +15,21 @@ describe("irc outbound chunking", () => {
expect(ircOutboundBaseAdapter.textChunkLimit).toBe(350);
});
});
describe("irc outbound sanitizeText", () => {
afterEach(() => {
clearIrcRuntime();
});
it("strips internal tool-trace banners before outbound delivery", () => {
const text = "Done.\n⚠ 🛠️ `search repos (agent)` failed";
expect(ircOutboundBaseAdapter.sanitizeText({ text })).toBe("Done.");
});
it("preserves ordinary assistant prose while sanitizing", () => {
const text = "The pipeline has 3 open deals.";
expect(ircOutboundBaseAdapter.sanitizeText({ text })).toBe(text);
});
});

View File

@@ -1,5 +1,6 @@
// Irc plugin module implements outbound base behavior.
import { sanitizeForPlainText } from "openclaw/plugin-sdk/channel-outbound";
import { sanitizeAssistantVisibleText } from "openclaw/plugin-sdk/text-chunking";
import { chunkTextForOutbound } from "./channel-api.js";
export const ircOutboundBaseAdapter = {
@@ -7,5 +8,9 @@ export const ircOutboundBaseAdapter = {
chunker: chunkTextForOutbound,
chunkerMode: "markdown" as const,
textChunkLimit: 350,
sanitizeText: ({ text }: { text: string }) => sanitizeForPlainText(text),
// IRC's plain-text pass does not remove assistant scaffolding. Run the
// canonical delivery sanitizer first so internal tool traces are dropped
// before channel formatting.
sanitizeText: ({ text }: { text: string }) =>
sanitizeForPlainText(sanitizeAssistantVisibleText(text)),
};

View File

@@ -1,5 +1,6 @@
// Msteams plugin module implements feedback reflection prompt behavior.
import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/string-coerce-runtime";
import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
/** Max chars of the thumbed-down response to include in the reflection prompt. */
const MAX_RESPONSE_CHARS = 500;
@@ -19,7 +20,7 @@ export function buildReflectionPrompt(params: {
if (params.thumbedDownResponse) {
const truncated =
params.thumbedDownResponse.length > MAX_RESPONSE_CHARS
? `${params.thumbedDownResponse.slice(0, MAX_RESPONSE_CHARS)}...`
? `${truncateUtf16Safe(params.thumbedDownResponse, MAX_RESPONSE_CHARS)}...`
: params.thumbedDownResponse;
parts.push(`\nYour response was:\n> ${truncated}`);
}

View File

@@ -19,6 +19,11 @@ import { msteamsRuntimeStub } from "./test-support/runtime.js";
const previousStateDir = process.env.OPENCLAW_STATE_DIR;
// Matches an unpaired UTF-16 surrogate (lone high or lone low), without relying
// on the ES2024 String.prototype.isWellFormed() runtime API.
const UNPAIRED_SURROGATE_RE =
/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/;
describe("buildFeedbackEvent", () => {
it("builds a well-formed custom event", () => {
const event = buildFeedbackEvent({
@@ -73,6 +78,26 @@ describe("buildReflectionPrompt", () => {
expect(prompt.length).toBeLessThan(longResponse.length + 500);
});
it("does not split UTF-16 surrogate pairs when truncating a thumbed-down response", () => {
const thumbedDownResponse = `${"a".repeat(499)}🦞${"b".repeat(20)}`;
const prompt = buildReflectionPrompt({ thumbedDownResponse });
expect(prompt).not.toMatch(UNPAIRED_SURROGATE_RE);
expect(prompt).toContain(`${"a".repeat(499)}...`);
expect(prompt).not.toContain("\ud83e");
expect(prompt).not.toContain("\udd9e");
});
it("keeps a boundary emoji when it fully fits before the truncation cap", () => {
const thumbedDownResponse = `${"a".repeat(498)}🦞${"b".repeat(20)}`;
const prompt = buildReflectionPrompt({ thumbedDownResponse });
expect(prompt).not.toMatch(UNPAIRED_SURROGATE_RE);
expect(prompt).toContain(`${"a".repeat(498)}🦞...`);
});
it("includes user comment when provided", () => {
const prompt = buildReflectionPrompt({
thumbedDownResponse: "Some response",

View File

@@ -10,6 +10,11 @@ import {
summarizeParentMessage,
} from "./thread-parent-context.js";
// Matches an unpaired UTF-16 surrogate (lone high or lone low), without relying
// on the ES2024 String.prototype.isWellFormed() runtime API.
const UNPAIRED_SURROGATE_RE =
/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/;
describe("summarizeParentMessage", () => {
it("returns undefined for missing message", () => {
expect(summarizeParentMessage(undefined)).toBeUndefined();
@@ -81,6 +86,20 @@ describe("summarizeParentMessage", () => {
expect(summary?.text.length).toBeLessThanOrEqual(400);
expect(summary?.text.endsWith("…")).toBe(true);
});
it("keeps truncated parent text well-formed when truncating surrogate pairs", () => {
const msg: GraphThreadMessage = {
id: "p1",
from: { user: { displayName: "Dana" } },
body: { content: `${"a".repeat(398)}🦞${"b".repeat(50)}`, contentType: "text" },
};
const summary = summarizeParentMessage(msg);
expect(summary?.text).not.toMatch(UNPAIRED_SURROGATE_RE);
expect(summary?.text).toBe(`${"a".repeat(398)}`);
expect(summary?.text.endsWith("\ud83e…")).toBe(false);
});
});
describe("formatParentContextEvent", () => {

View File

@@ -17,6 +17,7 @@ import {
asDateTimestampMs,
resolveExpiresAtMsFromDurationMs,
} from "openclaw/plugin-sdk/number-runtime";
import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
import { fetchChannelMessage, stripHtmlFromTeamsMessage } from "./graph-thread.js";
import type { GraphThreadMessage } from "./graph-thread.js";
@@ -138,7 +139,9 @@ export function summarizeParentMessage(
return {
sender,
text:
text.length > PARENT_TEXT_MAX_CHARS ? `${text.slice(0, PARENT_TEXT_MAX_CHARS - 1)}` : text,
text.length > PARENT_TEXT_MAX_CHARS
? `${truncateUtf16Safe(text, PARENT_TEXT_MAX_CHARS - 1)}`
: text,
};
}

View File

@@ -232,6 +232,55 @@ describe("SeenTracker", () => {
tracker.stop();
vi.useRealTimers();
});
it.each([-1, 0])("falls back to default TTL for non-positive ttlMs %s", (ttlMs) => {
vi.useFakeTimers();
const tracker = createTracker({ ttlMs, pruneIntervalMs: 10 * 60 * 1000 });
try {
tracker.add("id1");
vi.advanceTimersByTime(1);
expect(tracker.peek("id1")).toBe(true);
} finally {
tracker.stop();
vi.useRealTimers();
}
});
it("falls back to default TTL for infinite ttlMs", () => {
vi.useFakeTimers();
const tracker = createTracker({
ttlMs: Number.POSITIVE_INFINITY,
pruneIntervalMs: 10 * 60 * 1000,
});
try {
tracker.add("id1");
vi.advanceTimersByTime(60 * 60 * 1000 + 1);
expect(tracker.peek("id1")).toBe(false);
} finally {
tracker.stop();
vi.useRealTimers();
}
});
it.each([-1, 0, Number.POSITIVE_INFINITY])(
"uses the default prune interval for unsafe pruneIntervalMs %s",
(pruneIntervalMs) => {
vi.useFakeTimers();
const setIntervalSpy = vi.spyOn(globalThis, "setInterval");
const tracker = createTracker({ pruneIntervalMs });
try {
expect(setIntervalSpy).toHaveBeenCalledTimes(1);
expect(setIntervalSpy.mock.calls[0]?.[1]).toBe(10 * 60 * 1000);
} finally {
tracker.stop();
setIntervalSpy.mockRestore();
vi.useRealTimers();
}
},
);
});
});

View File

@@ -3,7 +3,10 @@
* Prevents unbounded memory growth under high load or abuse.
*/
import { resolveIntegerOption } from "openclaw/plugin-sdk/number-runtime";
import {
resolveIntegerOption,
resolvePositiveTimerTimeoutMs,
} from "openclaw/plugin-sdk/number-runtime";
interface SeenTrackerOptions {
/** Maximum number of entries to track (default: 100,000) */
@@ -45,8 +48,8 @@ interface Entry {
*/
export function createSeenTracker(options?: SeenTrackerOptions): SeenTracker {
const maxEntries = resolveIntegerOption(options?.maxEntries, 100_000, { min: 1 });
const ttlMs = options?.ttlMs ?? 60 * 60 * 1000; // 1 hour
const pruneIntervalMs = options?.pruneIntervalMs ?? 10 * 60 * 1000; // 10 minutes
const ttlMs = resolvePositiveTimerTimeoutMs(options?.ttlMs, 60 * 60 * 1000);
const pruneIntervalMs = resolvePositiveTimerTimeoutMs(options?.pruneIntervalMs, 10 * 60 * 1000);
// Main storage
const entries = new Map<string, Entry>();

View File

@@ -98,13 +98,13 @@ describe("buildAssistantMessage", () => {
expect(msg.stopReason).toBe("length");
});
it("keeps tool use authoritative over a length stop", () => {
it("keeps a length stop authoritative over complete-looking tool calls", () => {
const response = makeOllamaResponse({
done_reason: "length",
tool_calls: [{ function: { name: "read", arguments: { path: "README.md" } } }],
});
const msg = buildAssistantMessage(response, MODEL_INFO);
expect(msg.stopReason).toBe("toolUse");
expect(msg.stopReason).toBe("length");
});
});
@@ -282,6 +282,32 @@ describe("createOllamaStreamFn thinking events", () => {
expect(done.message?.stopReason).toBe("length");
});
it("preserves a native length stop when the partial response contains tool calls", async () => {
const events = await streamOllamaEvents(
[
makeOllamaResponse({
done_reason: "length",
tool_calls: [{ function: { name: "read", arguments: { path: "README.md" } } }],
}),
],
{},
{
messages: [{ role: "user", content: "test" }],
tools: [{ name: "read", description: "Read files", parameters: { type: "object" } }],
} as never,
);
const done = events.find((event) => event.type === "done") as {
reason?: string;
message?: { content?: Array<Record<string, unknown>>; stopReason?: string };
};
expect(done.reason).toBe("length");
expect(done.message?.stopReason).toBe("length");
expect(done.message?.content).toEqual([
expect.objectContaining({ type: "toolCall", name: "read" }),
]);
});
it("uses generic stream timeout for Ollama request timeout", async () => {
await streamOllamaEvents([makeOllamaResponse({ content: "ok" })], { timeoutMs: 2500 });

View File

@@ -656,10 +656,15 @@ function estimateTokensFromChars(chars: number): number {
}
function resolveOllamaStopReason(response: OllamaChatResponse) {
// Ollama's length terminal means generation hit its token limit, even when
// the partial response already contains a complete-looking tool call.
if (response.done_reason === "length") {
return "length" as const;
}
if (response.message.tool_calls?.length) {
return "toolUse" as const;
}
return response.done_reason === "length" ? ("length" as const) : ("stop" as const);
return "stop" as const;
}
function estimateOllamaPromptTokens(params: {

View File

@@ -713,4 +713,100 @@ describe("createOpencodeGoStalledStreamWrapper", () => {
controller.end();
await consumer;
});
it("must NOT abort a live stream that keeps emitting block-boundary events between deltas", async () => {
// Regression for https://github.com/openclaw/openclaw/issues/96518:
// the idle timer must re-arm on block-boundary events (text_end,
// thinking_end, toolcall_start, toolcall_end), not only on token
// deltas. A stream that keeps producing boundary events between
// deltas is demonstrably alive and must not be aborted.
const { stream: baseStream, controller } = createFakeBaseStream();
let abortCalled = false;
const underlying = vi.fn((_model, _context, options) => {
if (options?.signal) {
options.signal.addEventListener("abort", () => {
abortCalled = true;
});
}
return baseStream;
});
const idleTimeoutMs = 5_000;
const wrapper = createOpencodeGoStalledStreamWrapper(underlying as any, {
provider: "opencode-go",
idleTimeoutMs,
});
const downstream = await Promise.resolve(
wrapper({ provider: "opencode-go", id: "glm-4.6" } as any, {} as any, {} as any),
);
expect(downstream).toBeDefined();
if (!downstream) {
return;
}
const received: AnyEvent[] = [];
const consumer = (async () => {
for await (const event of downstream) {
received.push(event);
}
})();
const partial = { role: "assistant", content: [{ type: "text", text: "x" }] };
// Provider starts producing a tool-call turn. The last *delta* arms the idle timer.
controller.emit({ type: "start", partial } as any);
controller.emit({
type: "toolcall_delta",
contentIndex: 0,
delta: "{",
partial,
} as any);
await vi.advanceTimersByTimeAsync(0);
// The model finalizes the tool call and deliberates on the next one,
// emitting real block-boundary events that prove the SSE socket is alive.
// Each gap is < idleTimeoutMs, so a liveness-aware watchdog must stay armed.
await vi.advanceTimersByTimeAsync(3_000);
controller.emit({
type: "toolcall_end",
contentIndex: 0,
toolCall: { name: "f", arguments: "{}" },
partial,
} as any);
await vi.advanceTimersByTimeAsync(3_000);
controller.emit({
type: "toolcall_start",
contentIndex: 1,
partial,
} as any);
// Advance to 5s after the last delta, but only 2s after the last
// boundary event. The idle timer should have been re-armed by the
// boundary events, so it must NOT fire yet.
await vi.advanceTimersByTimeAsync(1_000);
// The provider's completed answer arrives right after.
controller.emit({
type: "done",
reason: "stop",
message: {
...partial,
content: [{ type: "text", text: "final answer" }],
stopReason: "stop",
},
} as any);
controller.end();
await vi.advanceTimersByTimeAsync(0);
await consumer;
const hasDone = received.some((e) => e.type === "done");
const hasStalledError = received.some(
(e) => e.type === "error" && (e as any).error?.stopReason === "error",
);
expect(abortCalled).toBe(false);
expect(hasDone).toBe(true);
expect(hasStalledError).toBe(false);
});
});

View File

@@ -55,7 +55,11 @@ function isProviderProgressEvent(event: AssistantMessageEvent): boolean {
return (
event.type === "text_delta" ||
event.type === "thinking_delta" ||
event.type === "toolcall_delta"
event.type === "toolcall_delta" ||
event.type === "text_end" ||
event.type === "thinking_end" ||
event.type === "toolcall_start" ||
event.type === "toolcall_end"
);
}

View File

@@ -68,4 +68,30 @@ describe("qa live timeout policy", () => {
),
).toBe(240_000);
});
it("uses the anthropic floor for claude-cli sonnet turns", () => {
expect(
resolveQaLiveTurnTimeoutMs(
{
providerMode: "live-frontier",
primaryModel: "claude-cli/claude-sonnet-4-6",
alternateModel: "claude-cli/claude-opus-4-8",
},
30_000,
),
).toBe(180_000);
});
it("uses the opus floor for claude-cli opus turns", () => {
expect(
resolveQaLiveTurnTimeoutMs(
{
providerMode: "live-frontier",
primaryModel: "claude-cli/claude-opus-4-8",
alternateModel: "claude-cli/claude-opus-4-8",
},
30_000,
),
).toBe(240_000);
});
});

View File

@@ -9,6 +9,13 @@ function isAnthropicModel(modelRef: string) {
return modelRef.startsWith("anthropic/");
}
// claude-cli is an Anthropic-backed Claude runtime, so it shares the Anthropic
// turn-timeout floors; mirror the claude-cli==anthropic precedent in the aimock
// and mock-openai servers.
function isAnthropicFamilyModel(modelRef: string) {
return isAnthropicModel(modelRef) || modelRef.startsWith("claude-cli/");
}
function isQaFastModeModelRef(modelRef: string) {
return isOpenAiModel(modelRef);
}
@@ -18,7 +25,7 @@ function isGptFiveModel(modelRef: string) {
}
function isClaudeOpusModel(modelRef: string) {
return isAnthropicModel(modelRef) && modelRef.includes("claude-opus");
return isAnthropicFamilyModel(modelRef) && modelRef.includes("claude-opus");
}
export const liveFrontierProviderDefinition: QaProviderDefinition = {
@@ -39,7 +46,7 @@ export const liveFrontierProviderDefinition: QaProviderDefinition = {
if (isClaudeOpusModel(modelRef)) {
return Math.max(fallbackMs, 240_000);
}
if (isAnthropicModel(modelRef)) {
if (isAnthropicFamilyModel(modelRef)) {
return Math.max(fallbackMs, 180_000);
}
if (isGptFiveModel(modelRef)) {

View File

@@ -235,6 +235,7 @@ describe("qa scenario catalog", () => {
it("loads folded HTTP API script scenarios with primary taxonomy coverage", () => {
expect(readQaScenarioById("openai-compatible-chat-tools").coverage?.primary).toStrictEqual([
"gateway.openai-compatible-apis",
"runtime.hosted-tool-use",
]);
expect(readQaScenarioById("openai-web-search-minimal").coverage?.primary).toStrictEqual([
"runtime.reasoning-and-cache-controls",
@@ -244,6 +245,7 @@ describe("qa scenario catalog", () => {
).toStrictEqual(["web-search.openai-native-web-search", "plugins.web-search-and-fetch"]);
expect(readQaScenarioById("openwebui-openai-compatible").coverage?.primary).toStrictEqual([
"gateway.openai-compatible-apis",
"runtime.hosted-provider-turns",
]);
});

View File

@@ -97,11 +97,45 @@ describe("engine/tools/remind-logic", () => {
expect(generateJobName("drink water")).toBe("Reminder: drink water");
});
it("truncates long content", () => {
const long = "a very long reminder content that exceeds twenty characters";
const name = generateJobName(long);
expect(name.length).toBeLessThan(40);
expect(name).toContain("…");
it("truncates long content to a 20 UTF-16-unit budget with an ellipsis", () => {
expect(generateJobName("a very long reminder content")).toBe(
"Reminder: a very long reminder…",
);
});
it("keeps an exactly-fitting all-emoji content unchanged", () => {
// 10 emoji = 20 UTF-16 units, exactly at the budget, so no truncation.
expect(generateJobName("😀".repeat(10))).toBe(`Reminder: ${"😀".repeat(10)}`);
});
it("does not split surrogate pairs when truncating", () => {
const hasLoneSurrogate = (value: string): boolean => {
for (let index = 0; index < value.length; index++) {
const code = value.charCodeAt(index);
if (code >= 0xd800 && code <= 0xdbff) {
const next = value.charCodeAt(index + 1);
if (!(next >= 0xdc00 && next <= 0xdfff)) {
return true;
}
index++;
} else if (code >= 0xdc00 && code <= 0xdfff) {
return true;
}
}
return false;
};
// 11 emoji = 22 UTF-16 units > 20; the 11th emoji straddles the cap and is
// dropped whole rather than split into a lone surrogate.
const allEmoji = generateJobName("😀".repeat(11));
expect(allEmoji).toBe(`Reminder: ${"😀".repeat(10)}`);
expect(hasLoneSurrogate(allEmoji)).toBe(false);
// 19 ASCII + emoji: the emoji's high surrogate would land at unit 20, so the
// whole pair is dropped to stay within the 20-unit budget.
const name = generateJobName(`${"x".repeat(19)}😀tail`);
expect(name).toBe(`Reminder: ${"x".repeat(19)}`);
expect(hasLoneSurrogate(name)).toBe(false);
});
});

View File

@@ -1,5 +1,6 @@
// Qqbot plugin module implements remind logic behavior.
import { resolveExpiresAtMsFromDurationMs } from "openclaw/plugin-sdk/number-runtime";
import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
/**
* QQBot reminder tool core logic.
@@ -171,7 +172,7 @@ export function isCronExpression(timeStr: string): boolean {
*/
export function generateJobName(content: string): string {
const trimmed = content.trim();
const short = trimmed.length > 20 ? `${trimmed.slice(0, 20)}` : trimmed;
const short = trimmed.length > 20 ? `${truncateUtf16Safe(trimmed, 20)}` : trimmed;
return `Reminder: ${short}`;
}

View File

@@ -1,8 +1,8 @@
// Qqbot tests cover stt plugin behavior.
import * as fs from "node:fs";
import * as path from "node:path";
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { withTempDir } from "openclaw/plugin-sdk/test-env";
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const ssrfRuntimeMocks = vi.hoisted(() => ({
fetchWithSsrFGuard: vi.fn(),
@@ -41,6 +41,36 @@ function cancelTrackedResponse(
};
}
function largeTranscriptionJsonResponse(params: { chunkCount: number; chunkSize: number }): {
response: Response;
getReadCount: () => number;
} {
let chunkIndex = 0;
const encoder = new TextEncoder();
const chunks = [
'{"text":"',
...Array.from({ length: params.chunkCount }, () => "a".repeat(params.chunkSize)),
'"}',
];
const stream = new ReadableStream<Uint8Array>({
pull(controller) {
if (chunkIndex >= chunks.length) {
controller.close();
return;
}
controller.enqueue(encoder.encode(chunks[chunkIndex]));
chunkIndex += 1;
},
});
return {
response: new Response(stream, {
status: 200,
headers: { "content-type": "application/json" },
}),
getReadCount: () => chunkIndex,
};
}
function requireFirstSsrfRequest(): {
url?: unknown;
auditContext?: unknown;
@@ -177,6 +207,44 @@ describe("engine/utils/stt", () => {
});
});
it("bounds successful STT JSON responses before parsing", async () => {
await withTempDir("openclaw-qqbot-stt-success-limit-", async (tmpDir) => {
const audioPath = path.join(tmpDir, "voice.wav");
fs.writeFileSync(audioPath, Buffer.from([1, 2, 3, 4]));
const release = vi.fn(async () => {});
const streamed = largeTranscriptionJsonResponse({
chunkCount: 18,
chunkSize: 1024 * 1024,
});
ssrfRuntimeMocks.fetchWithSsrFGuard.mockResolvedValueOnce({
response: streamed.response,
release,
});
let error: unknown;
try {
await transcribeAudio(audioPath, {
channels: {
qqbot: {
stt: {
baseUrl: "https://api.example.test/v1/",
apiKey: "secret",
model: "whisper-1",
},
},
},
});
} catch (caught) {
error = caught;
}
expect(String(error)).toContain("qqbot.stt: JSON response exceeds 16777216 bytes");
expect(streamed.getReadCount()).toBeLessThan(20);
expect(release).toHaveBeenCalledTimes(1);
});
});
it("bounds STT error bodies without using response.text()", async () => {
await withTempDir("openclaw-qqbot-stt-error-", async (tmpDir) => {
const audioPath = path.join(tmpDir, "voice.wav");

View File

@@ -8,7 +8,10 @@
import * as fs from "node:fs";
import path from "node:path";
import { mimeTypeFromFilePath } from "openclaw/plugin-sdk/media-mime";
import { readResponseTextLimited } from "openclaw/plugin-sdk/provider-http";
import {
readProviderJsonResponse,
readResponseTextLimited,
} from "openclaw/plugin-sdk/provider-http";
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
import {
normalizeOptionalString,
@@ -100,7 +103,7 @@ export async function transcribeAudio(
throw new Error(`STT failed (HTTP ${resp.status}): ${detail.slice(0, 300)}`);
}
const result = (await resp.json()) as { text?: string };
const result = await readProviderJsonResponse<{ text?: string }>(resp, "qqbot.stt");
return normalizeOptionalString(result.text) ?? null;
} finally {
await release();

View File

@@ -33,11 +33,190 @@ function readChatUpdatePayload(
return payload as ChatUpdatePayload;
}
const UNPAIRED_SURROGATE_RE =
/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/;
function readMrkdwnTexts(blocks: unknown): string[] {
if (!Array.isArray(blocks)) {
return [];
}
const texts: string[] = [];
for (const block of blocks) {
if (!block || typeof block !== "object") {
continue;
}
const text = (block as { text?: unknown }).text;
if (
text &&
typeof text === "object" &&
(text as { type?: unknown }).type === "mrkdwn" &&
typeof (text as { text?: unknown }).text === "string"
) {
texts.push((text as { text: string }).text);
}
const elements = (block as { elements?: unknown }).elements;
if (!Array.isArray(elements)) {
continue;
}
for (const element of elements) {
if (
element &&
typeof element === "object" &&
(element as { type?: unknown }).type === "mrkdwn" &&
typeof (element as { text?: unknown }).text === "string"
) {
texts.push((element as { text: string }).text);
}
}
}
return texts;
}
function findApprovalMrkdwn(payload: SlackPayload, prefix: string): string {
const text = readMrkdwnTexts(payload.blocks).find((entry) => entry.startsWith(prefix));
if (!text) {
throw new Error(`Expected Slack mrkdwn block starting with ${prefix}`);
}
return text;
}
describe("slackApprovalNativeRuntime", () => {
it("subscribes to plugin approval events", () => {
expect(slackApprovalNativeRuntime.eventKinds).toEqual(["exec", "plugin"]);
});
it("does not leave dangling surrogates when truncating exec approval command mrkdwn", async () => {
const commandText = `${"a".repeat(2598)}😀tail`;
const payload = (await slackApprovalNativeRuntime.presentation.buildPendingPayload({
cfg: {} as never,
accountId: "default",
context: {
app: {} as never,
config: {} as never,
},
request: {
id: "req-surrogate",
request: {
command: commandText,
},
createdAtMs: 0,
expiresAtMs: 60_000,
},
approvalKind: "exec",
nowMs: 0,
view: {
approvalKind: "exec",
approvalId: "req-surrogate",
commandText,
metadata: [],
actions: [
{
decision: "allow-once",
label: "Allow Once",
command: "/approve req-surrogate allow-once",
style: "success",
},
],
} as never,
})) as SlackPayload;
const commandMrkdwn = findApprovalMrkdwn(payload, "*Command*");
expect(commandMrkdwn).toMatch(/…\n```$/);
expect(UNPAIRED_SURROGATE_RE.test(commandMrkdwn)).toBe(false);
});
it("does not leave dangling surrogates when truncating plugin approval request mrkdwn", async () => {
const title = `${"a".repeat(2598)}😀tail`;
const payload = (await slackApprovalNativeRuntime.presentation.buildPendingPayload({
cfg: {} as never,
accountId: "default",
context: {
app: {} as never,
config: {} as never,
},
request: {
id: "plugin:req-surrogate",
request: {
title,
description: "Needs approval.",
},
createdAtMs: 0,
expiresAtMs: 60_000,
},
approvalKind: "plugin",
nowMs: 0,
view: {
approvalKind: "plugin",
phase: "pending",
approvalId: "plugin:req-surrogate",
title,
description: "Needs approval.",
severity: "warning",
pluginId: "test-plugin",
toolName: "test-tool",
metadata: [],
actions: [
{
decision: "deny",
label: "Deny",
command: "/approve plugin:req-surrogate deny",
style: "danger",
},
],
expiresAtMs: 60_000,
} as never,
})) as SlackPayload;
const requestMrkdwn = findApprovalMrkdwn(payload, "*Request*");
expect(requestMrkdwn).toMatch(/…$/);
expect(UNPAIRED_SURROGATE_RE.test(requestMrkdwn)).toBe(false);
});
it("still truncates plain BMP approval mrkdwn at the Slack approval preview limit", async () => {
const commandText = "b".repeat(2700);
const payload = (await slackApprovalNativeRuntime.presentation.buildPendingPayload({
cfg: {} as never,
accountId: "default",
context: {
app: {} as never,
config: {} as never,
},
request: {
id: "req-bmp",
request: {
command: commandText,
},
createdAtMs: 0,
expiresAtMs: 60_000,
},
approvalKind: "exec",
nowMs: 0,
view: {
approvalKind: "exec",
approvalId: "req-bmp",
commandText,
metadata: [],
actions: [
{
decision: "allow-once",
label: "Allow Once",
command: "/approve req-bmp allow-once",
style: "success",
},
],
} as never,
})) as SlackPayload;
const commandMrkdwn = findApprovalMrkdwn(payload, "*Command*");
expect(commandMrkdwn).toMatch(/…\n```$/);
expect(commandMrkdwn).toContain(`${"b".repeat(2599)}`);
expect(UNPAIRED_SURROGATE_RE.test(commandMrkdwn)).toBe(false);
});
it("renders only the allowed pending actions", async () => {
const payload = (await slackApprovalNativeRuntime.presentation.buildPendingPayload({
cfg: {} as never,

View File

@@ -19,6 +19,7 @@ import { buildApprovalPresentationFromActionDescriptors } from "openclaw/plugin-
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { logError } from "openclaw/plugin-sdk/logging-core";
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
import {
isSlackAnyNativeApprovalClientEnabled,
resolveSlackApprovalKind,
@@ -73,7 +74,14 @@ function resolveHandlerContext(params: ChannelApprovalCapabilityHandlerContext):
}
function truncateSlackMrkdwn(text: string, maxChars: number): string {
return text.length <= maxChars ? text : `${text.slice(0, maxChars - 1)}`;
const limit = Math.max(0, Math.floor(maxChars));
if (text.length <= limit) {
return text;
}
if (limit <= 1) {
return truncateUtf16Safe(text, limit);
}
return `${truncateUtf16Safe(text, limit - 1)}`;
}
function buildSlackCodeBlock(text: string): string {

View File

@@ -3,6 +3,7 @@ export { getRuntimeConfig } from "openclaw/plugin-sdk/runtime-config-snapshot";
export { isDangerousNameMatchingEnabled } from "openclaw/plugin-sdk/dangerous-name-runtime";
export {
readSessionUpdatedAt,
resolveChannelResetConfig,
resolveSessionKey,
resolveStorePath,
updateLastRoute,

View File

@@ -2,6 +2,7 @@
import type { App } from "@slack/bolt";
import { resolveDefaultAgentId } from "openclaw/plugin-sdk/agent-runtime";
import { formatAllowlistMatchMeta } from "openclaw/plugin-sdk/allow-from";
import type { ChannelRuntimeSurface } from "openclaw/plugin-sdk/channel-contract";
import type {
OpenClawConfig,
SlackReactionNotificationMode,
@@ -97,6 +98,7 @@ export type SlackMonitorContext = {
botToken: string;
app: App;
runtime: RuntimeEnv;
channelRuntime?: ChannelRuntimeSurface;
botUserId: string;
botId?: string;
@@ -184,6 +186,7 @@ export function createSlackMonitorContext(params: {
botToken: string;
app: App;
runtime: RuntimeEnv;
channelRuntime?: ChannelRuntimeSurface;
botUserId: string;
botId?: string;
@@ -601,6 +604,7 @@ export function createSlackMonitorContext(params: {
botToken: params.botToken,
app: params.app,
runtime: params.runtime,
channelRuntime: params.channelRuntime,
botUserId: params.botUserId,
botId: params.botId,
teamId: params.teamId,

View File

@@ -1,7 +1,7 @@
// Slack plugin module implements prepare thread context behavior.
import { formatInboundEnvelope } from "openclaw/plugin-sdk/channel-inbound";
import { runTasksWithConcurrency } from "openclaw/plugin-sdk/concurrency-runtime";
import type { ContextVisibilityMode } from "openclaw/plugin-sdk/config-contracts";
import type { ContextVisibilityMode, OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
import {
filterSupplementalContextItems,
@@ -10,7 +10,7 @@ import {
import type { ResolvedSlackAccount } from "../../accounts.js";
import type { SlackMessageEvent } from "../../types.js";
import { resolveSlackAllowListMatch } from "../allow-list.js";
import { readSessionUpdatedAt } from "../config.runtime.js";
import { readSessionUpdatedAt, resolveChannelResetConfig } from "../config.runtime.js";
import type { SlackMonitorContext } from "../context.js";
import type { SlackMediaResult } from "../media-types.js";
import { resolveSlackThreadHistory, type SlackThreadStarter } from "../thread.js";
@@ -35,13 +35,49 @@ function loadSlackMediaModule(): Promise<SlackMediaModule> {
type SlackThreadContextData = {
threadStarterBody: string | undefined;
threadHistoryBody: string | undefined;
threadSessionPreviousTimestamp: number | undefined;
shouldSeedInitialThreadContext: boolean;
threadLabel: string | undefined;
threadStarterMedia: SlackMediaResult[] | null;
};
const SLACK_THREAD_CONTEXT_USER_LOOKUP_CONCURRENCY = 4;
type SlackSessionResetFreshness = {
state: "missing" | "fresh" | "stale";
};
type SlackSessionFreshnessRuntime = {
session?: {
resolveEntryResetFreshness?: (params: {
storePath?: string;
sessionKey: string;
sessionCfg?: OpenClawConfig["session"];
resetType: "thread";
resetOverride?: ReturnType<typeof resolveChannelResetConfig>;
}) => SlackSessionResetFreshness;
};
};
function resolveSlackThreadSessionFreshness(params: {
ctx: SlackMonitorContext;
storePath: string;
sessionKey: string;
}): SlackSessionResetFreshness | undefined {
// Gateway startup supplies the full channel runtime, but the public surface
// intentionally keeps non-context helpers untyped for external plugins.
const runtime = params.ctx.channelRuntime as SlackSessionFreshnessRuntime | undefined;
return runtime?.session?.resolveEntryResetFreshness?.({
storePath: params.storePath,
sessionKey: params.sessionKey,
sessionCfg: params.ctx.cfg.session,
resetType: "thread",
resetOverride: resolveChannelResetConfig({
sessionCfg: params.ctx.cfg.session,
channel: "slack",
}),
});
}
function isSlackThreadContextSenderAllowed(params: {
allowFromLower: string[];
allowNameMatching: boolean;
@@ -125,19 +161,36 @@ export async function resolveSlackThreadContextData(params: {
let threadHistoryBody: string | undefined;
let threadLabel: string | undefined;
let threadStarterMedia: SlackMediaResult[] | null = null;
const threadSessionPreviousTimestamp =
const threadSessionFreshness =
params.isThreadReply && params.threadTs
? resolveSlackThreadSessionFreshness({
ctx: params.ctx,
storePath: params.storePath,
sessionKey: params.sessionKey,
})
: undefined;
const threadSessionPreviousTimestamp =
params.isThreadReply && params.threadTs && !threadSessionFreshness
? readSessionUpdatedAt({
storePath: params.storePath,
sessionKey: params.sessionKey,
})
: undefined;
const shouldSeedInitialThreadContext = Boolean(
params.isThreadReply &&
params.threadTs &&
(threadSessionFreshness
? threadSessionFreshness.state !== "fresh"
: threadSessionPreviousTimestamp === undefined),
);
const shouldLoadInitialThreadHistory =
shouldSeedInitialThreadContext || params.forceInitialHistory === true;
if (!params.isThreadReply || !params.threadTs) {
return {
threadStarterBody,
threadHistoryBody,
threadSessionPreviousTimestamp,
shouldSeedInitialThreadContext,
threadLabel,
threadStarterMedia,
};
@@ -195,10 +248,9 @@ export async function resolveSlackThreadContextData(params: {
threadLabel = `Slack thread ${params.roomLabel}`;
}
const isNewThreadSession = !threadSessionPreviousTimestamp;
const includeBotStarterAsRootContext = shouldIncludeBotThreadStarterContext({
starterIsCurrentBot,
isNewThreadSession,
isNewThreadSession: shouldSeedInitialThreadContext,
hasStarterText: Boolean(starter?.text),
});
@@ -218,10 +270,7 @@ export async function resolveSlackThreadContextData(params: {
const threadInitialHistoryLimit = params.account.config?.thread?.initialHistoryLimit ?? 20;
if (
threadInitialHistoryLimit > 0 &&
(!threadSessionPreviousTimestamp || params.forceInitialHistory)
) {
if (threadInitialHistoryLimit > 0 && shouldLoadInitialThreadHistory) {
const currentBotRootTs = starter?.ts ?? params.threadTs;
const threadHistory = await resolveSlackThreadHistory({
channelId: params.message.channel,
@@ -333,7 +382,7 @@ export async function resolveSlackThreadContextData(params: {
return {
threadStarterBody,
threadHistoryBody,
threadSessionPreviousTimestamp,
shouldSeedInitialThreadContext,
threadLabel,
threadStarterMedia,
};

View File

@@ -2,7 +2,9 @@
import fs from "node:fs";
import path from "node:path";
import type { App } from "@slack/bolt";
import type { ChannelRuntimeSurface } from "openclaw/plugin-sdk/channel-contract";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { createPluginRuntimeMock } from "openclaw/plugin-sdk/plugin-test-runtime";
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path";
import type { ResolvedSlackAccount } from "../../accounts.js";
@@ -17,6 +19,7 @@ export function createInboundSlackTestContext(params: {
channelsConfig?: SlackChannelConfigEntries;
threadRequireExplicitMention?: boolean;
dmHistoryLimit?: number;
channelRuntime?: ChannelRuntimeSurface;
}) {
return createSlackMonitorContext({
cfg: params.cfg,
@@ -24,6 +27,7 @@ export function createInboundSlackTestContext(params: {
botToken: "token",
app: { client: params.appClient ?? {} } as App,
runtime: {} as RuntimeEnv,
channelRuntime: params.channelRuntime ?? createPluginRuntimeMock().channel,
botUserId: "B1",
botId: "B1",
teamId: "T1",

View File

@@ -1870,12 +1870,15 @@ Second paragraph should still reach the agent after Slack's preview cutoff.`;
baseSessionKey: route.sessionKey,
threadId: "200.000",
});
const now = Date.now();
await saveSessionStore(
storePath,
{
[threadKeys.sessionKey]: {
sessionId: "existing-thread-session",
updatedAt: Date.now(),
updatedAt: now,
sessionStartedAt: now,
lastInteractionAt: now,
},
},
{ skipMaintenance: true },
@@ -1905,6 +1908,260 @@ Second paragraph should still reach the agent after Slack's preview cutoff.`;
expect(replies).toHaveBeenCalledTimes(1);
});
it("preserves existing thread fallback when channel runtime is omitted", async () => {
const { storePath } = storeFixture.makeTmpStorePath();
const cfg = {
session: { store: storePath },
channels: { slack: { enabled: true, replyToMode: "all", groupPolicy: "open" } },
} as OpenClawConfig;
const route = resolveAgentRoute({
cfg,
channel: "slack",
accountId: "default",
teamId: "T1",
peer: { kind: "channel", id: "C123" },
});
const threadKeys = resolveThreadSessionKeys({
baseSessionKey: route.sessionKey,
threadId: "250.000",
});
const now = Date.now();
await saveSessionStore(
storePath,
{
[threadKeys.sessionKey]: {
sessionId: "direct-monitor-existing-thread-session",
updatedAt: now - 2 * 24 * 60 * 60 * 1000,
sessionStartedAt: now - 2 * 24 * 60 * 60 * 1000,
lastInteractionAt: now - 2 * 24 * 60 * 60 * 1000,
},
},
{ skipMaintenance: true },
);
const replies = vi.fn().mockResolvedValueOnce({
messages: [{ text: "starter", user: "U2", ts: "250.000" }],
});
const slackCtx = createThreadSlackCtx({ cfg, replies });
slackCtx.channelRuntime = undefined;
slackCtx.resolveUserName = async () => ({ name: "Alice" });
slackCtx.resolveChannelName = async () => ({ name: "general", type: "channel" });
const prepared = await prepareThreadMessage(slackCtx, {
text: "direct monitor reply in old thread",
ts: "251.000",
thread_ts: "250.000",
});
assertPrepared(prepared);
expect(prepared.ctxPayload.IsFirstThreadTurn).toBeUndefined();
expect(prepared.ctxPayload.ThreadHistoryBody).toBeUndefined();
expect(prepared.ctxPayload.ThreadStarterBody).toBeUndefined();
expect(replies).toHaveBeenCalledTimes(1);
});
it("loads bounded thread history for existing thread sessions stale under reset policy", async () => {
const { storePath } = storeFixture.makeTmpStorePath();
const now = Date.now();
const cfg = {
session: { store: storePath },
channels: { slack: { enabled: true, replyToMode: "all", groupPolicy: "open" } },
} as OpenClawConfig;
const route = resolveAgentRoute({
cfg,
channel: "slack",
accountId: "default",
teamId: "T1",
peer: { kind: "channel", id: "C123" },
});
const threadKeys = resolveThreadSessionKeys({
baseSessionKey: route.sessionKey,
threadId: "300.000",
});
await saveSessionStore(
storePath,
{
[threadKeys.sessionKey]: {
sessionId: "stale-thread-session",
updatedAt: now,
sessionStartedAt: now - 2 * 24 * 60 * 60 * 1000,
lastInteractionAt: now - 2 * 24 * 60 * 60 * 1000,
},
},
{ skipMaintenance: true },
);
const replies = vi
.fn()
.mockResolvedValueOnce({
messages: [{ text: "starter", user: "U2", ts: "300.000" }],
})
.mockResolvedValueOnce({
messages: [
{ text: "starter", user: "U2", ts: "300.000" },
{ text: "assistant prior output", bot_id: "B1", ts: "300.500" },
{ text: "prior human context", user: "U1", ts: "300.800" },
{ text: "current post-reset message", user: "U1", ts: "301.000" },
],
response_metadata: { next_cursor: "" },
});
const slackCtx = createThreadSlackCtx({ cfg, replies });
slackCtx.threadInheritParent = true;
slackCtx.resolveUserName = async (id: string) => ({
name: id === "U1" ? "Alice" : "Bob",
});
slackCtx.resolveChannelName = async () => ({ name: "general", type: "channel" });
const prepared = await prepareMessageWith(
slackCtx,
createSlackAccount({
replyToMode: "all",
thread: { initialHistoryLimit: 10, inheritParent: true },
}),
createThreadReplyMessage({
text: "current post-reset message",
ts: "301.000",
thread_ts: "300.000",
}),
);
assertPrepared(prepared);
expect(prepared.ctxPayload.SessionKey).toBe(threadKeys.sessionKey);
expect(prepared.ctxPayload.IsFirstThreadTurn).toBe(true);
expect(prepared.ctxPayload.ThreadStarterBody).toBe("starter");
expect(prepared.ctxPayload.ThreadHistoryBody).toContain("prior human context");
expect(prepared.ctxPayload.ThreadHistoryBody).not.toContain("assistant prior output");
expect(prepared.ctxPayload.ThreadHistoryBody).not.toContain("current post-reset message");
expect(prepared.ctxPayload.ParentSessionKey).toBe(route.sessionKey);
expect(replies).toHaveBeenCalledTimes(2);
expect(replies).toHaveBeenLastCalledWith({
channel: "C123",
ts: "300.000",
limit: 200,
inclusive: true,
});
});
it("keeps provider-owned thread sessions existing when reset policy is implicit", async () => {
const { storePath } = storeFixture.makeTmpStorePath();
const now = Date.now();
const cfg = {
session: { store: storePath },
channels: { slack: { enabled: true, replyToMode: "all", groupPolicy: "open" } },
} as OpenClawConfig;
const route = resolveAgentRoute({
cfg,
channel: "slack",
accountId: "default",
teamId: "T1",
peer: { kind: "channel", id: "C123" },
});
const threadKeys = resolveThreadSessionKeys({
baseSessionKey: route.sessionKey,
threadId: "350.000",
});
await saveSessionStore(
storePath,
{
[threadKeys.sessionKey]: {
sessionId: "provider-owned-thread-session",
updatedAt: now,
sessionStartedAt: now - 2 * 24 * 60 * 60 * 1000,
lastInteractionAt: now - 2 * 24 * 60 * 60 * 1000,
providerOverride: "claude-cli",
cliSessionBindings: {
"claude-cli": { sessionId: "claude-cli-thread-session" },
},
},
},
{ skipMaintenance: true },
);
const replies = vi.fn().mockResolvedValueOnce({
messages: [{ text: "starter", user: "U2", ts: "350.000" }],
});
const slackCtx = createThreadSlackCtx({ cfg, replies });
slackCtx.resolveUserName = async () => ({ name: "Alice" });
slackCtx.resolveChannelName = async () => ({ name: "general", type: "channel" });
const prepared = await prepareMessageWith(
slackCtx,
createSlackAccount({
replyToMode: "all",
thread: { initialHistoryLimit: 10 },
}),
createThreadReplyMessage({
text: "reply after implicit reset boundary",
ts: "351.000",
thread_ts: "350.000",
}),
);
assertPrepared(prepared);
expect(prepared.ctxPayload.IsFirstThreadTurn).toBeUndefined();
expect(prepared.ctxPayload.ThreadStarterBody).toBeUndefined();
expect(prepared.ctxPayload.ThreadHistoryBody).toBeUndefined();
expect(replies).toHaveBeenCalledTimes(1);
});
it("keeps initialHistoryLimit zero as a hard disable for stale thread sessions", async () => {
const { storePath } = storeFixture.makeTmpStorePath();
const now = Date.now();
const cfg = {
session: { store: storePath },
channels: { slack: { enabled: true, replyToMode: "all", groupPolicy: "open" } },
} as OpenClawConfig;
const route = resolveAgentRoute({
cfg,
channel: "slack",
accountId: "default",
teamId: "T1",
peer: { kind: "channel", id: "C123" },
});
const threadKeys = resolveThreadSessionKeys({
baseSessionKey: route.sessionKey,
threadId: "400.000",
});
await saveSessionStore(
storePath,
{
[threadKeys.sessionKey]: {
sessionId: "stale-zero-history-thread-session",
updatedAt: now,
sessionStartedAt: now - 2 * 24 * 60 * 60 * 1000,
lastInteractionAt: now - 2 * 24 * 60 * 60 * 1000,
},
},
{ skipMaintenance: true },
);
const replies = vi.fn().mockResolvedValueOnce({
messages: [{ text: "starter", user: "U2", ts: "400.000" }],
});
const slackCtx = createThreadSlackCtx({ cfg, replies });
slackCtx.resolveUserName = async () => ({ name: "Alice" });
slackCtx.resolveChannelName = async () => ({ name: "general", type: "channel" });
const prepared = await prepareMessageWith(
slackCtx,
createSlackAccount({
replyToMode: "all",
thread: { initialHistoryLimit: 0 },
}),
createThreadReplyMessage({
text: "current post-reset message",
ts: "401.000",
thread_ts: "400.000",
}),
);
assertPrepared(prepared);
expect(prepared.ctxPayload.IsFirstThreadTurn).toBe(true);
expect(prepared.ctxPayload.ThreadStarterBody).toBe("starter");
expect(prepared.ctxPayload.ThreadHistoryBody).toBeUndefined();
expect(replies).toHaveBeenCalledTimes(1);
});
it("drops ambiguous thread replies instead of treating them as root messages", async () => {
const { storePath } = storeFixture.makeTmpStorePath();
const cfg = {

View File

@@ -1225,7 +1225,7 @@ export async function prepareSlackMessage(params: {
const {
threadStarterBody,
threadHistoryBody,
threadSessionPreviousTimestamp,
shouldSeedInitialThreadContext,
threadLabel,
threadStarterMedia,
} = await resolveSlackThreadContextData({
@@ -1320,7 +1320,7 @@ export async function prepareSlackMessage(params: {
thread: {
// Only include thread starter body for NEW sessions (existing sessions already have it in their transcript)
starterBody:
!directThreadRoutedToDmSession && !threadSessionPreviousTimestamp
!directThreadRoutedToDmSession && shouldSeedInitialThreadContext
? threadStarterBody
: undefined,
historyBody: supplementalThreadHistoryBody,
@@ -1340,7 +1340,7 @@ export async function prepareSlackMessage(params: {
isThreadReply &&
threadTs &&
!directThreadRoutedToDmSession &&
!threadSessionPreviousTimestamp
shouldSeedInitialThreadContext
? true
: undefined,
...buildSlackMentionContextPayload({

View File

@@ -378,6 +378,7 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
botToken,
app,
runtime,
channelRuntime: opts.channelRuntime,
botUserId,
botId,
teamId,

View File

@@ -433,6 +433,31 @@ describe("synology-chat security helpers", () => {
expect(result).toContain("[truncated]");
});
it("truncates long inputs without splitting a surrogate pair", () => {
const loneSurrogatePattern =
/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?:^|[^\uD800-\uDBFF])[\uDC00-\uDFFF]/u;
const input = "a".repeat(3999) + "\u{1F600}" + "b".repeat(2000);
const result = sanitizeInput(input);
expect(result).toContain("[truncated]");
expect(result).not.toMatch(loneSurrogatePattern);
expect(result).toBe(`${"a".repeat(3999)}... [truncated]`);
});
it("keeps complete supplementary-plane characters that fit before truncation", () => {
const loneSurrogatePattern =
/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?:^|[^\uD800-\uDBFF])[\uDC00-\uDFFF]/u;
const emoji = "\u{1F600}";
const input = "a".repeat(3998) + emoji + "b".repeat(2000);
const result = sanitizeInput(input);
expect(result).toContain("[truncated]");
expect(result.startsWith(`${"a".repeat(3998)}${emoji}`)).toBe(true);
expect(result).not.toMatch(loneSurrogatePattern);
});
it("rate limits per user and caps tracked state", () => {
const limiter = new RateLimiter(3, 60);
expect(limiter.check("user1")).toBe(true);

View File

@@ -5,6 +5,7 @@
import { resolveStableChannelMessageIngress } from "openclaw/plugin-sdk/channel-ingress-runtime";
import { finiteSecondsToTimerSafeMilliseconds } from "openclaw/plugin-sdk/number-runtime";
import { safeEqualSecret } from "openclaw/plugin-sdk/security-runtime";
import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
import {
createFixedWindowRateLimiter,
type FixedWindowRateLimiter,
@@ -64,7 +65,7 @@ export function sanitizeInput(text: string): string {
const maxLength = 4000;
if (sanitized.length > maxLength) {
sanitized = sanitized.slice(0, maxLength) + "... [truncated]";
sanitized = truncateUtf16Safe(sanitized, maxLength) + "... [truncated]";
}
return sanitized;

View File

@@ -6,6 +6,7 @@
import type { IncomingMessage, ServerResponse } from "node:http";
import * as querystring from "node:querystring";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
import {
beginWebhookRequestPipelineOrReject,
createWebhookInFlightLimiter,
@@ -503,7 +504,7 @@ async function parseAndAuthorizeSynologyWebhook(params: {
respondNoContent(params.res);
return { ok: false };
}
const preview = cleanText.length > 100 ? `${cleanText.slice(0, 100)}...` : cleanText;
const preview = cleanText.length > 100 ? `${truncateUtf16Safe(cleanText, 100)}...` : cleanText;
return {
ok: true,
message: {
@@ -574,7 +575,7 @@ async function processAuthorizedSynologyWebhook(params: {
deliveryUserId,
params.account.allowInsecureSsl,
);
const replyPreview = reply.length > 100 ? `${reply.slice(0, 100)}...` : reply;
const replyPreview = reply.length > 100 ? `${truncateUtf16Safe(reply, 100)}...` : reply;
params.log?.info?.(
`Reply sent to ${params.message.payload.username} (${deliveryUserId}): ${replyPreview}`,
);

View File

@@ -0,0 +1,78 @@
import { describe, expect, it } from "vitest";
import { buildTelegramMessageContextForTest } from "./bot-message-context.test-harness.js";
import type { TelegramPromptContextEntry } from "./bot-message-context.types.js";
const telegramChatWindowContext: TelegramPromptContextEntry = {
label: "Conversation context",
source: "telegram",
type: "chat_window",
payload: {
order: "chronological",
relation: "selected_for_current_message",
messages: [
{
message_id: "10",
sender: "Pat",
timestamp_ms: 1_700_000_000_000,
body: "Earlier DM turn already in the transcript",
},
],
},
};
describe("buildTelegramMessageContext prompt context", () => {
it("omits Telegram chat-window context for existing unthreaded private DM sessions", async () => {
const ctx = await buildTelegramMessageContextForTest({
message: {
chat: { id: 1234, type: "private", first_name: "Pat" },
from: { id: 1234, first_name: "Pat" },
text: "continue",
},
promptContext: [telegramChatWindowContext],
sessionRuntime: {
readSessionUpdatedAt: ({ sessionKey }) =>
sessionKey === "agent:main:main" ? 1_700_000_000_000 : undefined,
},
});
expect(ctx?.ctxPayload.SessionKey).toBe("agent:main:main");
expect(ctx?.ctxPayload.UntrustedStructuredContext).toBeUndefined();
});
it("keeps Telegram chat-window context for fresh private DM sessions", async () => {
const ctx = await buildTelegramMessageContextForTest({
message: {
chat: { id: 1234, type: "private", first_name: "Pat" },
from: { id: 1234, first_name: "Pat" },
text: "start",
},
promptContext: [telegramChatWindowContext],
});
expect(ctx?.ctxPayload.UntrustedStructuredContext).toEqual([telegramChatWindowContext]);
});
it("keeps Telegram chat-window context for existing private DM replies", async () => {
const ctx = await buildTelegramMessageContextForTest({
message: {
chat: { id: 1234, type: "private", first_name: "Pat" },
from: { id: 1234, first_name: "Pat" },
text: "replying with context",
reply_to_message: {
chat: { id: 1234, type: "private", first_name: "Pat" },
from: { id: 1234, first_name: "Pat" },
text: "older referenced turn",
date: 1_700_000_000,
message_id: 10,
},
},
promptContext: [telegramChatWindowContext],
sessionRuntime: {
readSessionUpdatedAt: ({ sessionKey }) =>
sessionKey === "agent:main:main" ? 1_700_000_000_000 : undefined,
},
});
expect(ctx?.ctxPayload.UntrustedStructuredContext).toEqual([telegramChatWindowContext]);
});
});

View File

@@ -113,6 +113,10 @@ export async function resolveTelegramMessageContextStorePath(params: {
});
}
function isTelegramChatWindowPromptContext(entry: TelegramPromptContextEntry): boolean {
return entry.source === "telegram" && entry.type === "chat_window";
}
function replyTargetToChainEntry(replyTarget: TelegramReplyTarget): TelegramReplyChainEntry {
return {
...(replyTarget.id ? { messageId: replyTarget.id } : {}),
@@ -378,6 +382,17 @@ export async function buildTelegramInboundContextPayload(params: {
storePath,
sessionKey: route.sessionKey,
});
const shouldSuppressPersistedDmChatWindowContext =
!isGroup &&
previousTimestamp !== undefined &&
dmThreadId == null &&
visibleReplyChain.length === 0 &&
!visibleReplyTarget;
// Existing plain DMs already carry their history through the persistent
// transcript. Keep chat windows for fresh DMs, topics, replies, and groups.
const visiblePromptContext = shouldSuppressPersistedDmChatWindowContext
? promptContext.filter((entry) => !isTelegramChatWindowPromptContext(entry))
: promptContext;
const body = formatInboundEnvelope({
channel: "Telegram",
from: conversationLabel,
@@ -559,7 +574,7 @@ export async function buildTelegramInboundContextPayload(params: {
}
: undefined,
groupSystemPrompt: isGroup || (!isGroup && groupConfig) ? groupSystemPrompt : undefined,
untrustedContext: promptContext.length > 0 ? promptContext : undefined,
untrustedContext: visiblePromptContext.length > 0 ? visiblePromptContext : undefined,
},
contextVisibility: contextVisibilityMode,
extra: {

View File

@@ -23,6 +23,7 @@ type BuildTelegramMessageContextForTestParams = {
message: Record<string, unknown>;
me?: Record<string, unknown>;
allMedia?: TelegramMediaRef[];
promptContext?: BuildTelegramMessageContextParams["promptContext"];
options?: BuildTelegramMessageContextParams["options"];
cfg?: Record<string, unknown>;
accountId?: string;
@@ -112,6 +113,7 @@ export async function buildTelegramMessageContextForTest(
me: { id: 7, username: "bot", ...params.me },
} as never,
allMedia: params.allMedia ?? [],
promptContext: params.promptContext ?? [],
storeAllowFrom: [],
options: params.options ?? {},
bot: {

View File

@@ -957,7 +957,7 @@ describe("resolveTelegramFetch", () => {
expect(eighthDispatcher).toBe(firstDispatcher);
expect(ninthDispatcher).toBe(firstDispatcher);
expectPinnedFallbackIpDispatcher(3);
expectLoggerMessageContaining(loggerWarn, "fetch fallback: DNS-resolved IP unreachable");
expectLoggerMessageContaining(loggerWarn, "fetch fallback: primary connection path failed");
expectLoggerMessageContaining(
loggerDebug,
"fetch fallback: recovered from attempt 2 to attempt 0",
@@ -1193,6 +1193,31 @@ describe("resolveTelegramFetch", () => {
expect(undiciFetch).toHaveBeenCalledTimes(1);
});
it("does not automatically retry structured EADDRNOTAVAIL fetch failures", async () => {
const fetchError = buildFetchFallbackError("EADDRNOTAVAIL");
undiciFetch.mockRejectedValue(fetchError);
const resolved = resolveTelegramFetchOrThrow(undefined, STICKY_IPV4_FALLBACK_NETWORK);
await expect(resolved("https://api.telegram.org/botx/sendMessage")).rejects.toThrow(
"fetch failed",
);
expect(undiciFetch).toHaveBeenCalledTimes(1);
});
it("preserves EADDRNOTAVAIL in forced fallback diagnostics", () => {
const transport = resolveTelegramTransport(undefined, STICKY_IPV4_FALLBACK_NETWORK);
const fetchError = buildFetchFallbackError("EADDRNOTAVAIL");
expect(transport.forceFallback?.("probe timeout/network error", fetchError)).toBe(true);
expect(transport.forceFallback?.("probe timeout/network error", fetchError)).toBe(true);
expectLoggerMessageContaining(loggerWarn, "primary connection path failed");
expectLoggerMessageContaining(loggerWarn, "codes=EADDRNOTAVAIL");
expectNoLoggerMessageContaining(loggerWarn, "DNS-resolved IP unreachable");
});
it("retries sticky fallback when the local network is down during connect", async () => {
undiciFetch
.mockRejectedValueOnce(buildFetchFallbackError("ENETDOWN"))

View File

@@ -488,9 +488,10 @@ export type TelegramTransport = {
dispatcherAttempts?: TelegramDispatcherAttempt[];
/**
* Promote this transport to its next fallback dispatcher before the next
* request. Returns false when no fallback path exists.
* request. The original error, when available, is retained in diagnostics.
* Returns false when no fallback path exists.
*/
forceFallback?: (reason: string) => boolean;
forceFallback?: (reason: string, err?: unknown) => boolean;
/**
* Release all dispatchers owned by this transport and the TCP sockets they
* hold. Safe to call multiple times; subsequent calls resolve immediately.
@@ -563,7 +564,8 @@ function createTelegramTransportAttempts(params: {
},
exportAttempt: { dispatcherPolicy: fallbackIpPolicy },
logLevel: "warn",
logMessage: "fetch fallback: DNS-resolved IP unreachable; trying alternative Telegram API IP",
logMessage:
"fetch fallback: primary connection path failed; trying alternative Telegram API IP",
});
return attempts;
@@ -864,8 +866,8 @@ export function resolveTelegramTransport(
fetch: resolvedFetch,
sourceFetch,
dispatcherAttempts: transportAttempts.map((attempt) => attempt.exportAttempt),
forceFallback: (reason: string) =>
promoteStickyAttempt(stickyAttemptIndex + 1, new Error("forced fallback"), reason),
forceFallback: (reason: string, err?: unknown) =>
promoteStickyAttempt(stickyAttemptIndex + 1, err ?? new Error("forced fallback"), reason),
close,
};
}

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