Compare commits

..

100 Commits

Author SHA1 Message Date
Peter Steinberger
59d5ee8fce feat: add rainbow sessions background 2026-06-25 22:56:21 -04:00
Peter Steinberger
b48d2438d3 revert: restore default sessions background 2026-06-25 20:24:07 -04:00
Peter Steinberger
369288718e style(ui): use frog green sessions background 2026-06-25 20:19:00 -04:00
Peter Steinberger
bcd926cc4f feat(ui): use red sessions background 2026-06-25 20:02:36 -04:00
brokemac79
0da26499da fix: fallback on safe prompt timeouts (#96142) 2026-06-25 16:33:44 -07:00
brokemac79
1f941a026e fix: reload control ui on service worker update (#96141) 2026-06-25 16:33:27 -07:00
brokemac79
941e8f1ef2 fix: reset diff viewer controllers on rehydrate (#96138) 2026-06-25 16:19:30 -07:00
Renaud Cerrato
95b97e5b0b fix(exec): fail invalid explicit workdir before running (#94441)
* fix(exec): fail invalid explicit workdir before running

* test(exec): tighten invalid workdir regression

* fix(exec): clarify invalid workdir recovery

* refactor(exec): centralize workdir resolution

* test(exec): update invalid workdir assertion

* fix(exec): harden backend workdir contract

* fix(exec): map missing backend host workdirs

* fix(exec): reject control commands before workdir prep

* fix(exec): defer env hook until backend cwd validation

* chore(sdk): refresh plugin api baseline

* test(agents): drop redundant definition assertions

* test(exec): use real config workdirs

* test(exec): use tracked temp dirs

* test(openshell): keep temp setup local

* test: update temp-dir route fixture

---------

Co-authored-by: jesse-merhi <79823012+jesse-merhi@users.noreply.github.com>
2026-06-26 08:02:00 +10:00
VACInc
13ecca5408 fix(telegram): back off session init spool retries 2026-06-25 13:41:57 -07:00
Wynne668
c68484acc4 fix(gateway): report omitted chat-history messages in truncation log (#96788)
Summary:
- The PR moves Gateway `chat.history` omission accounting to a whole-pipeline reporter and adds focused helper plus real WebSocket request regression tests.
- PR surface: Source +35, Tests +219. Total +254 across 4 files.
- Reproducibility: yes. Current-main source shows the zero-count keep-last helper branch and the positive-coun ... he PR body includes a negative-control real WebSocket run where the same request test fails before the fix.

Automerge notes:
- PR branch already contained follow-up commit before automerge: fix(gateway): count unique omitted chat-history messages + prove diag…
- PR branch already contained follow-up commit before automerge: test(gateway): prove chat.history request emits omission diagnostic

Validation:
- ClawSweeper review passed for head 414f885880.
- Required merge gates passed before the squash merge.

Prepared head SHA: 414f885880
Review: https://github.com/openclaw/openclaw/pull/96788#issuecomment-4799553366

Co-authored-by: ZengWen-DT <ceng.wen@xydigit.com>
Approved-by: takhoffman
2026-06-25 20:17:27 +00:00
Ayaan Zaidi
d2da8c79d9 fix(auto-reply): serialize reply session initialization 2026-06-25 13:10:35 -07:00
NIO
1aa7cafc35 fix(github-copilot): bound model discovery and embeddings JSON response (#96499)
* fix(github-copilot): bound model discovery and embeddings JSON response reads

The GitHub Copilot embeddings plugin already bounds its error response
bodies via readResponseTextLimited, but the success JSON reads for both
model discovery and the embeddings call used unbounded response.json().
Route both through readProviderJsonResponse (16 MiB cap).

Update isCopilotSetupError to recognise the new error label prefix so
auto-selection still falls through on malformed discovery responses.
Update tests to use proper Response objects and the new error messages.

AI-assisted.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(github-copilot): use memory embedding response cap

Signed-off-by: sallyom <somalley@redhat.com>

---------

Signed-off-by: sallyom <somalley@redhat.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: sallyom <somalley@redhat.com>
2026-06-25 14:46:09 -04:00
NIO
66e2fcc6f8 fix(speech): bound TTS/STT voice-list and transcription JSON response reads (#96496)
Route success JSON reads through readProviderJsonResponse (16 MiB cap) in
azure-speech, elevenlabs, microsoft, minimax/tts, xai/stt, and
openrouter/media-understanding to prevent OOM from oversized or hostile
endpoint responses. Mirrors the response-limit campaign already applied to
other provider paths.

AI-assisted.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-25 14:32:53 -04:00
Ben Badejo
b3ac552c82 fix(codex): prefer desktop app-server for Computer Use on macOS (#96730)
* fix(codex): prefer desktop app-server for Computer Use on macOS

* fix(codex): fall back from stale desktop app-server

---------

Co-authored-by: Benjamin Badejo <ben@benbadejo.com>
2026-06-25 14:28:20 -04:00
mushuiyu886
5715b55000 fix(openrouter): bound video catalog JSON reads (#96505) 2026-06-25 14:17:01 -04:00
Radek Sienkiewicz
0247eab773 fix(cli): sync official plugins during update all (#96831)
Co-authored-by: ooiuuii <169449607+ooiuuii@users.noreply.github.com>
2026-06-25 20:13:37 +02:00
Alix-007
646e54ae35 fix(github-copilot): bound usage response (#96607)
The Copilot usage read in extensions/github-copilot/usage.ts parsed its
HTTP response with an unbounded await res.json(). A hostile or buggy
api.github.com proxy (the proxy endpoint is derived from a user-supplied
token) could stream an unbounded JSON body and drive the usage snapshot
into OOM.

Route the read through the shared readProviderJsonResponse (from
openclaw/plugin-sdk/provider-http), which enforces the 16 MiB byte cap,
cancels the stream on overflow, and wraps malformed JSON with the caller
label. Same no-helper-import-to-bounded-reader shape as the #96027 /
#96038 response-limit work.

Add a focused regression test: when the usage stream exceeds the JSON
byte cap, fetchCopilotUsage rejects with a bounded-overflow error and the
reader cancels the body mid-flight instead of buffering the full
advertised stream. Existing parse/HTTP-error cases keep passing.
2026-06-25 13:53:43 -04:00
Alix-007
d3620da3e0 fix(voyage): bound embedding-batch status, error, and non-OK responses (#96608)
The batch status read (fetchVoyageBatchStatus) parsed its response with an
unbounded await res.json(), and the batch error-file read (readVoyageBatchError)
buffered the whole body via await res.text(). On top of that, the non-OK
(4xx/5xx) diagnostic body was still read unbounded: assertVoyageResponseOk did
await res.text() before throwing, and the non-OK output-file branch in
runVoyageEmbeddingBatches did the same. Voyage base URLs are user-supplied and
reachable via SSRF, so a misbehaving or hostile endpoint could stream an
unbounded body into memory on any of these paths before parsing.

Route the status JSON through the shared readProviderJsonResponse, the error
file through readResponseWithLimit, and now the non-OK diagnostic body through
readResponseWithLimit as well, all under a single 16 MiB cap, cancelling the
stream on overflow before decode/parse. assertVoyageResponseOk preserves its
original "${context}: ${status} ${text}" diagnostic shape for under-cap bodies
and throws a bounded "(error body exceeds <N> bytes)" on overflow; the non-OK
output-file branch now reuses it instead of a duplicate unbounded read. The
existing error-file fail-soft handling (formatUnavailableBatchError) is
preserved, so a capped endpoint degrades gracefully. The submit path already
bounds its body via postJsonWithRetry/maxResponseBytes and is left untouched.

Symmetric counterpart to the #96027/#96038 response-limit campaign.
2026-06-25 13:52:36 -04:00
Alix-007
7b5ee739eb fix(byteplus): bound video-generation success response (#96606) 2026-06-25 13:47:07 -04:00
Alix-007
bfc33ac114 fix(google): bound video success response (#96605) 2026-06-25 13:41:35 -04:00
Alix-007
cc124d2921 fix(qwen): bound video success response (#96604) 2026-06-25 13:40:07 -04:00
Vincent Koc
7cce191b05 test(infra): isolate matrix outbound queue integration 2026-06-26 01:23:16 +08:00
Yzx
7fefc5ff58 fix: cron stream stalls fail over before job timeout (#96096)
* fix(agents): cap cron stream idle stalls

* fix(agents): preserve cron hostname timeout

* fix: bound cron idle timeout local exceptions

* fix: bound cron idle timeout local exceptions

---------

Co-authored-by: Radek Sienkiewicz <mail@velvetshark.com>
2026-06-25 19:04:15 +02:00
Yzx
19707cce1d fix(cron): avoid gateway restart on setup timeout (#96396)
* fix(cron): avoid gateway restart on setup timeout

* fix(cron): avoid gateway restart on setup timeout

---------

Co-authored-by: Radek Sienkiewicz <mail@velvetshark.com>
2026-06-25 18:11:33 +02:00
Ayaan Zaidi
a3b4e8102f test(telegram): fold draft preview surrogate clamp coverage 2026-06-25 09:10:07 -07:00
杨浩宇0668001029
4bd68aef65 fix(telegram): keep draft preview chunks surrogate-safe 2026-06-25 09:10:07 -07:00
Ayaan Zaidi
8bc069f76f fix(outbound): preserve narrowed delivery target type 2026-06-25 08:37:00 -07:00
Ayaan Zaidi
1adb119ba0 refactor(outbound): distill reserved target delivery cleanup 2026-06-25 08:37:00 -07:00
zhang-guiping
57c07d7f3b refactor: centralize reserved target error checks
Keep reserved-target detection behavior unchanged while routing callers through a shared helper so future changes stay localized.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-25 08:37:00 -07:00
张贵萍0668001030
3c8ff0d1c3 fix(outbound): require exact reserved directory matches 2026-06-25 08:37:00 -07:00
张贵萍0668001030
3a03d1e70b fix(cron): preserve reserved directory targets 2026-06-25 08:37:00 -07:00
张贵萍0668001030
9047b1cfa1 fix(outbound): preserve reserved directory target on route miss 2026-06-25 08:37:00 -07:00
张贵萍0668001030
ba004b3547 test(outbound): align Telegram resolver fixtures with chat capabilities 2026-06-25 08:37:00 -07:00
张贵萍0668001030
3092b4fd0d fix(outbound): fail closed heartbeat reserved Telegram misses 2026-06-25 08:37:00 -07:00
张贵萍0668001030
116758e69a fix(outbound): satisfy target resolver lint 2026-06-25 08:37:00 -07:00
张贵萍0668001030
cd3793185b fix(outbound): preserve reserved Telegram directory targets 2026-06-25 08:37:00 -07:00
zhang-guiping
5fccf06b5f fix(outbound): defer reserved-literal errors to async session-route resolver
In resolveAgentDeliveryPlanWithSessionRoute, reserved-literal errors from the
sync outbound target check are no longer treated as fatal. Instead, the path
proceeds to resolveOutboundSessionRoute which calls resolveMessagingTarget,
already fixed to do directory-first lookup before rejecting reserved literals.

This preserves configured Telegram directory entries named like reserved words
(current, self, this, me) through the explicit agent/gateway delivery path.

Update docs to reflect directory-first ordering.
2026-06-25 08:37:00 -07:00
zhang-guiping
bbf494955d fix(outbound): preserve configured directory entries before reserved-literal rejection in resolveMessagingTarget
Move the reserved-literal check from before directory lookup to after directory
miss, so configured Telegram groups/channels whose directory key is a reserved
word (current, self, this, me) still resolve through the directory before
failing closed. The reserved check now runs only after the directory returns no
match and before plugin fallback resolution.

Update the regression test to verify directory-first ordering: a configured
directory entry named current resolves successfully, and a directory miss with
a reserved literal fails with the descriptive error.
2026-06-25 08:37:00 -07:00
zhang-guiping
f12ade0082 fix(outbound): skip sync reserved-literal rejection for heartbeat mode
The sync reserved-literal check in resolveOutboundTargetWithPlugin was
suppressing heartbeat routes to directory entries whose names match
reserved literals (e.g., a Telegram group named "current"). Skip the
check for heartbeat mode so the async resolveChannelTarget →
resolveMessagingTarget path can do directory-first lookup before
deciding.
2026-06-25 08:37:00 -07:00
张贵萍
56baf9d079 fix(outbound): reject reserved Telegram targets 2026-06-25 08:37:00 -07:00
Ayaan Zaidi
dc12b998da fix(media): scope UUID filename restore to media store 2026-06-25 08:35:41 -07:00
Narahari Raghava
cf512f639b fix(media): strip internal UUID suffix from outbound media filenames
Closes #96538
2026-06-25 08:35:41 -07:00
Ayaan Zaidi
29670c13f6 refactor(status): reuse runtime authority decision 2026-06-25 08:31:54 -07:00
zhang-guiping
bead84f0ee fix(status): route usage to session-selected model 2026-06-25 08:31:54 -07:00
Vincent Koc
497d53d821 fix(sdk): tighten wildcard surface budget 2026-06-25 23:30:17 +08:00
yetval
446d98d601 fix(trajectory): export legacy v1 sessions without entry timestamps
readSessionBranch filtered out every entry lacking a string or number
timestamp. Sessions written before entry timestamps existed (version 1)
have ids and parentIds synthesized by the legacy migration but no entry
timestamp, so all entries were dropped and the exported bundle reported
transcriptEventCount 0. The transcript event builder already defaults a
missing timestamp via normalizeTimestamp, so the filter clause was both
wrong and redundant. Drop it; entry identity plus the canonical-entry
check is what the branch walk needs.
2026-06-25 08:29:25 -07:00
Gio Della-Libera
82a6a57330 Doctor: expose session artifact findings (#95976)
* feat(doctor): expose session artifact findings

* fix(doctor): make session artifact findings advisory
2026-06-25 08:27:25 -07:00
Ayaan Zaidi
01ce03c5b1 fix(gateway): preserve webchat send guard 2026-06-25 08:26:12 -07:00
黑承亮0668000844
5881dc8ac3 fix(gateway): use normalizeMessageChannel for send validation to support plugin channels
Fixes #92094
2026-06-25 08:26:12 -07:00
openclaw-clownfish[bot]
31a0f97dd9 fix(clownfish): repair validation for repair-94016-live-pr-inventory-20260617t082059-003-20260617a (2) 2026-06-25 08:25:09 -07:00
openclaw-clownfish[bot]
ace22feb3f fix(clownfish): address review for repair-94016-live-pr-inventory-20260617t082059-003-20260617a (1) 2026-06-25 08:25:09 -07:00
openclaw-clownfish[bot]
ecd29fe572 fix(gateway): resume channel after pending task recovery 2026-06-25 08:25:09 -07:00
openclaw-clownfish[bot]
6039da3ed6 fix(gateway): resume channel after pending task recovery 2026-06-25 08:25:09 -07:00
sheyanmin
8b4be2fdd4 fix: recover channel after stop timeout in health monitor
When a channel stop times out (e.g. during a Telegram API outage),
the channel enters recoveryStopTimedOut state. The health monitor's
subsequent start call would set restartPending and return without
actually starting the channel.

If the stuck stop never completes, the channel stays in limbo forever
with the health monitor retrying every cycle but never recovering.

Fix: when the health monitor retries recovery (recoveryStartRequested
already set), clean up the stuck task state and allow the channel to
start normally.

Closes #94008
2026-06-25 08:25:09 -07:00
Ayaan Zaidi
210ea659f7 fix(outbound): prevent partial-send recovery replay 2026-06-25 08:16:56 -07:00
rosenlo
c0a61f5351 test(outbound): add drain no-replay guard for unknown_after_send; clarify fallback intent
- Add integration test: after mid-batch failure with send evidence the
  resulting unknown_after_send entry is NOT replayed by reconnect drain
  when no adapter reconciliation is available ('refusing blind replay').
  Pins the drain contract so any regression that re-enables blind replay
  is caught end-to-end against a real SQLite queue.
- Add comment in deliver.ts fallback branch: failDelivery inside the
  markQueuedPlatformOutcomeUnknown catch is a last-resort DB-write-error
  path, not an indication that failDelivery is correct with send evidence.
2026-06-25 08:16:56 -07:00
rosenlo
7f2c04ce11 fix(test): remove unused import and unnecessary type assertions in queue integration test 2026-06-25 08:16:56 -07:00
rosenlo
f9e0dce731 test(outbound): add real-queue integration test for unknown_after_send on mid-batch failure
Complement the unit test (which mocks delivery-queue) with an integration
test that uses the real SQLite delivery queue (no mock of ./delivery-queue.js)
and the real deliverOutboundPayloads code path.

Verifies at the queue layer:
- mid-batch failure with send evidence (first payload succeeds, second
  throws, queuePolicy=required) -> queue entry recovery_state advances to
  unknown_after_send, retryCount stays 0, no lastError. This is the patch
  path: drain will route the entry through reconcileUnknownQueuedDelivery
  instead of leaving it in send_attempt_started for blind replay.
- no send evidence (sole payload fails immediately) -> failDelivery path:
  retryCount bumped, recovery_state stays send_attempt_started. Patch does
  not affect this path.

Negative control confirmed: with deliver.ts reverted to v2026.6.8 original
(no patch), the mid-batch test fails with recovery_state=send_attempt_started
(the root-cause state), while the no-evidence test still passes. This
reproduces the patch's code path and proves the fix at the real-queue layer.
2026-06-25 08:16:56 -07:00
rosenlo
71422a9a5a fix(outbound): advance queue entry to unknown_after_send on mid-batch failure with send evidence
When a required-mode batch send fails mid-batch after an earlier payload
already succeeded, the wrapper catch in deliverOutboundPayloadsWithQueueCleanup
called failDelivery. failDelivery only bumps retryCount/lastError; it does
not advance recoveryState, so the entry stayed in send_attempt_started (set
earlier by markDeliveryPlatformSendAttemptStarted via onPlatformSendStart).

On the next Telegram reconnect, drainQueuedEntry sees send_attempt_started
and calls reconcileUnknownQueuedDelivery. When adapter reconciliation
misreports not_sent (the message was actually sent, per the outbound send
ok / messageId evidence), the entry is replayed and the user receives a
duplicate.

Fix: when the error carries send evidence (OutboundDeliveryError with
sentBeforeError === true and platformSendStarted === true), call
markQueuedPlatformOutcomeUnknown instead of failDelivery. This advances the
entry to unknown_after_send, which drain already routes through
reconcileUnknownQueuedDelivery, preserving the entry for adapter
reconciliation rather than leaving it in send_attempt_started for replay.

When there is no send evidence (sentBeforeError === false), failDelivery
remains correct: nothing reached the channel, so retrying is safe.

This is a third duplicate path distinct from #89812 (mirror best-effort)
and #92274 (subagent-announce-delivery retry); it is the outbound/deliver
wrapper catch, which neither prior fix covers.

Tests:
- regression: two payloads, first succeeds, second throws; asserts
  markDeliveryPlatformOutcomeUnknown called, failDelivery/ackDelivery not.
- guard: no send evidence; failDelivery still called.
2026-06-25 08:16:56 -07:00
linhongkuan
2e6e17f7c5 fix(media-generation): preserve trimmed default model flag (#96430) 2026-06-25 20:46:47 +08:00
linhongkuan
1ba1fecaa6 fix(acp-core): clear stale active run lookups (#96427) 2026-06-25 20:44:07 +08:00
Shakker
4ecb45bf77 fix: narrow test config path 2026-06-25 10:40:38 +01:00
Shakker
0757cad597 fix: narrow cron path env cleanup 2026-06-25 10:07:09 +01:00
Shakker
21b21583cc test: isolate reply media state env 2026-06-25 10:02:41 +01:00
Shakker
c8c4490b17 fix: scope embedded image state env 2026-06-25 09:59:32 +01:00
Shakker
d693b70bfc test: preserve daemon coverage env scope 2026-06-25 09:56:16 +01:00
Shakker
2b8c089b76 fix: guard current turn state env 2026-06-25 09:53:17 +01:00
Shakker
1d1c2f4f72 test: stabilize allowlist config env 2026-06-25 09:50:50 +01:00
Shakker
3ce398712a fix: preserve exec env test cleanup 2026-06-25 09:48:15 +01:00
Shakker
3c2a3d9d2b test: centralize tool manager agent env 2026-06-25 09:45:37 +01:00
Shakker
33d7a2a3f7 fix: route session history config env 2026-06-25 09:42:46 +01:00
Vincent Koc
94ae918d8f perf(plugins): reuse installed manifest realpaths (#96710)
Co-authored-by: sheyanmin <she.yanmin@xydigit.com>
2026-06-25 16:41:26 +08:00
David
af906225fa fix(git-hooks): skip sequencer pre-commit formatting (#95842)
* fix(git-hooks): skip sequencer pre-commit formatting

* chore: rerun CI

* fix(git-hooks): skip revert sequencer formatting

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-06-25 16:40:12 +08:00
Shakker
08b7fddf80 test: centralize shell snapshot env 2026-06-25 09:37:55 +01:00
Wynne668
d7dff3cbf4 fix(document-extract): render PDF image fallback per page so multi-page scans don't starve later pages (#96390)
* fix(document-extract): render PDF image fallback per page so multi-page scans don't starve later pages

clawpdf's mode:"images" extract applies a single maxPixels budget across
every page, so the first page consumes it and later pages collapse to ~1x1
PNGs that vision OCR models reject. Render each selected page in its own
extract() call so the pixel budget resets per page and every page yields a
usable image.

* fix(document-extract): preserve aggregate PDF render budget

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-06-25 16:37:47 +08:00
Vincent Koc
42d0a1267e perf(gateway): cache transcript field regexes (#96707)
Co-authored-by: Yongan Zhang <374456248@qq.com>
2026-06-25 16:36:44 +08:00
David
99f56cd548 fix(discord): keep audio voice replies threaded (#95978)
* fix(discord): keep audio voice replies threaded

* chore: retrigger CI after base recovery
2026-06-25 16:36:23 +08:00
Shakker
e6a2f61e94 fix: route persisted result config env 2026-06-25 09:29:46 +01:00
linhongkuan
c030b305a4 fix(agent-core): preserve empty prompt arguments (#96405) 2026-06-25 16:25:52 +08:00
ly-wang19
770b19f496 fix(imessage): only strip standalone role-turn markers, not prose ending in a role word (#96392)
ROLE_TURN_MARKER_RE anchored only the end of the line (\b...:\s*$), so any
outbound line that merely ended with 'user:'/'system:'/'assistant:' was
truncated — e.g. 'Please send this reply to the user:' lost its last word.
Anchor the marker to the whole line so only a standalone leaked turn marker
(its own line) is stripped; standalone-marker behavior 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-25 16:05:36 +08:00
linhongkuan
793b604b23 fix(media-understanding): parse nested Gemini output JSON (#96432) 2026-06-25 15:50:39 +08:00
linhongkuan
31e941c3fc fix(context): count fullwidth chars in token estimates (#96442) 2026-06-25 14:36:04 +08:00
Vincent Koc
56d95b18f4 fix(sdk): refresh plugin sdk api baseline 2026-06-25 06:51:02 +02:00
Vincent Koc
e7f2b125f6 fix(test): isolate upgrade survivor artifacts 2026-06-25 05:49:46 +02:00
Vincent Koc
643410c1f3 test(qa): scope fanout marker proof to channel runtime 2026-06-25 10:20:51 +08:00
Vincent Koc
8d4e40d293 test(qa): extend fanout marker wait 2026-06-25 10:20:51 +08:00
Vincent Koc
068ae4eb4b test(qa): allow Codex fanout completion window 2026-06-25 10:20:51 +08:00
Vincent Koc
dad7168c2f fix(qa): align runtime parity evidence with Codex 2026-06-25 10:20:51 +08:00
Vincent Koc
31a65e0647 fix(agents): preserve absent embedded session keys 2026-06-25 10:20:51 +08:00
Vincent Koc
1a04b8eb98 test(plugins): review channel daemon spawn findings 2026-06-25 03:42:44 +02:00
clawsweeper[bot]
a21144d8a6 fix(cron): preserve enabled-with-defaults failure alert through store roundtrip (fixes #96589) (AI-assisted) (#96615)
Summary:
- The PR preserves `failure_alert_disabled === 0` as the enabled-with-defaults failure-alert state and adds focused codec roundtrip tests.
- PR surface: Source +2, Tests +54. Total +56 across 2 files.
- Reproducibility: yes. At source level, current main encodes `failureAlert: {}` with `failure_alert_disabled = 0`, then decodes it as `undefined` when all explicit alert option columns are null.

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

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

Prepared head SHA: bd9b2a1798
Review: https://github.com/openclaw/openclaw/pull/96615#issuecomment-4794949533

Co-authored-by: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com>
Co-authored-by: liuhao1024 <11816344+liuhao1024@users.noreply.github.com>
Approved-by: takhoffman
2026-06-25 01:17:31 +00:00
Dallin Romney
8a5cb85c31 ci: default maturity evidence to all profile (#96595) 2026-06-24 17:32:25 -07:00
Dallin Romney
61d4ff782e docs: clarify maturity scorecard scoring (#96594)
* docs: clarify maturity scorecard scoring

* chore: split qa profile workflow change

* docs: keep maturity coverage values stable

* test: keep maturity renderer fixture in core boundary
2026-06-24 17:32:08 -07:00
Vincent Koc
3ab7a72764 fix(plugin-sdk): update surface budget 2026-06-25 02:22:44 +02:00
Peter Lee
b4bdea0d02 fix(agent): emit model.usage diagnostic for HTTP ingress traffic (#96152)
* fix(agents): emit model.usage diagnostic for HTTP ingress traffic

* fix(agents): emit model.usage diagnostic for HTTP ingress traffic

* fix(agent): add regression tests and refactor ingress model.usage diagnostic emission

* fix(agent): resolve oxlint curly and no-useless-fallback-in-spread violations
2026-06-24 20:17:28 -04:00
Sarah Fortune
113d6f3c64 fix: surface provider authentication failures in channels (#96599)
* fix: surface provider authentication failures in channels

* fix: handle typed provider auth failures

---------

Co-authored-by: Sarah Fortune <sarah.fortune@gmail.com>
2026-06-24 17:16:30 -07:00
joshavant
0a14444924 Bound successful provider response reads 2026-06-24 19:08:22 -05:00
Peter Lee
0a042f68df fix(agent): replace self-wait with deferred release in retained-lock abort cleanup (#96100)
* fix(agent): wait for retained session write before releasing held lock on abort

* fix(agent): replace self-wait with deferred release in retained-lock abort cleanup

* fix(test): reject fallback acquire with SessionWriteLockTimeoutError in active-scope cleanup test

* fix(agent): trim retained-lock comments

Signed-off-by: sallyom <somalley@redhat.com>

---------

Signed-off-by: sallyom <somalley@redhat.com>
Co-authored-by: sallyom <somalley@redhat.com>
2026-06-24 19:50:58 -04:00
Vincent Koc
3ab8d6aa60 fix(e2e): extend Codex on-demand install timeout 2026-06-25 00:32:29 +02:00
Omar Shahine
f2af052cee perf(imessage): show typing sooner for slow replies (#95621)
Merged via squash.

Prepared head SHA: 65e9ad10fd
Co-authored-by: omarshahine <10343873+omarshahine@users.noreply.github.com>
Co-authored-by: omarshahine <10343873+omarshahine@users.noreply.github.com>
Reviewed-by: @omarshahine
2026-06-24 15:31:48 -07:00
240 changed files with 10512 additions and 1457 deletions

View File

@@ -134,7 +134,7 @@ jobs:
with:
ref: ${{ inputs.ref }}
expected_sha: ${{ needs.validate_selected_ref.outputs.selected_revision }}
qa_profile: release
qa_profile: all
secrets:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
@@ -238,8 +238,8 @@ jobs:
}
const evidence = JSON.parse(fs.readFileSync(evidencePath, "utf8"));
if (evidence.profile !== "release") {
throw new Error(`qa-evidence.json profile must be release, got ${JSON.stringify(evidence.profile)}`);
if (evidence.profile !== "all") {
throw new Error(`qa-evidence.json profile must be all, got ${JSON.stringify(evidence.profile)}`);
}
const artifactDir = path.dirname(evidencePath);
@@ -256,8 +256,8 @@ jobs:
const manifestPath = path.join(artifactDir, manifestNames[0]);
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
const manifestProfile = manifest.qaProfile ?? evidence.profile;
if (manifestProfile !== "release") {
throw new Error(`QA evidence manifest profile must be release, got ${JSON.stringify(manifestProfile)}`);
if (manifestProfile !== "all") {
throw new Error(`QA evidence manifest profile must be all, got ${JSON.stringify(manifestProfile)}`);
}
if (manifest.targetSha !== targetSha) {
throw new Error(`QA evidence manifest targetSha ${manifest.targetSha} does not match selected ref ${targetSha}`);
@@ -428,14 +428,14 @@ jobs:
cat > "$body_file" <<BODY
## Summary
- render maturity scorecard docs from \`qa/maturity-scores.yaml\` and release QA evidence
- render maturity scorecard docs from \`qa/maturity-scores.yaml\` and full taxonomy QA evidence
- maturity source ref: ${REF_INPUT}
- QA evidence run: ${evidence_run_id}
## Verification
- QA Lab maturity score validation passed
- Maturity scorecard workflow rendered docs from release profile qa-evidence.json artifacts with strict inputs
- Maturity scorecard workflow rendered docs from all profile qa-evidence.json artifacts with strict inputs
BODY
pr_url="$(gh pr list --head "$branch" --state open --json url --jq '.[0].url // ""')"

View File

@@ -18,7 +18,7 @@ on:
qa_profile:
description: Taxonomy QA profile id to run (for example release or all)
required: true
default: release
default: all
type: string
workflow_call:
inputs:

View File

@@ -1,2 +1,2 @@
6620d5a6100d60f98cf13b8a13e3c46e9631400d1a1d7c0c6a22c490da810813 plugin-sdk-api-baseline.json
961377a56fd0fb3307fb4be95dcb480610f14c717e1b82e4bf262dd5faaddcbc plugin-sdk-api-baseline.jsonl
abdff20b710c6b0fecb5af25603d7cfad7ade80600ca374ebe38f69d78933b50 plugin-sdk-api-baseline.json
630367961e4d14463020f588564c23308159ae2de6e4301418b2b0c471797e70 plugin-sdk-api-baseline.jsonl

View File

@@ -579,7 +579,7 @@ When `imsg launch` is running and `openclaw channels status --probe` reports `pr
</Accordion>
<Accordion title="Read receipts and typing">
When the private API bridge is up, accepted inbound chats are marked read before dispatch and a typing bubble is shown to the sender while the agent generates. Disable read-marking with:
When the private API bridge is up, accepted inbound chats are marked read and direct chats show a typing bubble as soon as the turn is accepted, while the agent prepares context and generates. Disable read-marking with:
```json5
{

View File

@@ -399,13 +399,17 @@ Updates apply to tracked plugin installs in the managed plugin index and tracked
<Accordion title="Resolving plugin id vs npm spec">
When you pass a plugin id, OpenClaw reuses the recorded install spec for that plugin. That means previously stored dist-tags such as `@beta` and exact pinned versions continue to be used on later `update <id>` runs.
That targeted-update rule is different from the bulk `openclaw plugins update --all` maintenance path. Bulk updates still respect ordinary tracked install specs, but trusted official OpenClaw plugin records can sync to the current official catalog target instead of staying on a stale exact official package. Use targeted `update <id>` when you intentionally want to keep an exact or tagged official spec untouched.
For npm installs, you can also pass an explicit npm package spec with a dist-tag or exact version. OpenClaw resolves that package name back to the tracked plugin record, updates that installed plugin, and records the new npm spec for future id-based updates.
Passing the npm package name without a version or tag also resolves back to the tracked plugin record. Use this when a plugin was pinned to an exact version and you want to move it back to the registry's default release line.
</Accordion>
<Accordion title="Beta channel updates">
`openclaw plugins update` reuses the tracked plugin spec unless you pass a new spec. `openclaw update` additionally knows the active OpenClaw update channel: on the beta channel, default-line npm and ClawHub plugin records try `@beta` first. They fall back to the recorded default/latest spec if no plugin beta release exists; npm plugins also fall back when the beta package exists but fails install validation. That fallback is reported as a warning and does not fail the core update. Exact versions and explicit tags stay pinned to that selector.
Targeted `openclaw plugins update <id-or-npm-spec>` reuses the tracked plugin spec unless you pass a new spec. Bulk `openclaw plugins update --all` uses the configured `update.channel` when it syncs trusted official plugin records to the official catalog target, so beta-channel installs can stay on the beta release line instead of being silently normalized to stable/latest.
`openclaw update` also knows the active OpenClaw update channel: on the beta channel, default-line npm and ClawHub plugin records try `@beta` first. They fall back to the recorded default/latest spec if no plugin beta release exists; npm plugins also fall back when the beta package exists but fails install validation. That fallback is reported as a warning and does not fail the core update. Exact versions and explicit tags stay pinned to that selector for targeted updates.
</Accordion>
<Accordion title="Version checks and integrity drift">

View File

@@ -167,7 +167,7 @@ surfaces, while Codex native hooks remain a separate lower-level Codex mechanism
- Agent runtime: `agents.defaults.timeoutSeconds` default 172800s (48 hours); enforced in `runEmbeddedAgent` abort timer.
- Cron runtime: isolated agent-turn `timeoutSeconds` is owned by cron. The scheduler starts that timer when execution begins, aborts the underlying run at the configured deadline, then runs bounded cleanup before recording the timeout so a stale child session cannot keep the lane stuck.
- Session liveness diagnostics: with diagnostics enabled, `diagnostics.stuckSessionWarnMs` classifies long `processing` sessions that have no observed reply, tool, status, block, or ACP progress. Active embedded runs, model calls, and tool calls report as `session.long_running`; owned silent model calls also stay `session.long_running` until `diagnostics.stuckSessionAbortMs` so slow or non-streaming providers are not reported as stalled too early. Active work with no recent progress reports as `session.stalled`; owned model calls switch to `session.stalled` at or after the abort threshold, and ownerless stale model/tool activity is not hidden as long-running. `session.stuck` is reserved for recoverable stale session bookkeeping, including idle queued sessions with stale ownerless model/tool activity. Stale session bookkeeping releases the affected session lane immediately after recovery gates pass; stalled embedded runs are abort-drained only after `diagnostics.stuckSessionAbortMs` (default: at least 5 minutes and 3x the warning threshold) so queued work can resume without cutting off merely slow runs. Recovery emits structured requested/completed outcomes, and diagnostic state is marked idle only if the same processing generation is still current. Repeated `session.stuck` diagnostics back off while the session remains unchanged.
- Model idle timeout: OpenClaw aborts a model request when no response chunks arrive before the idle window. `models.providers.<id>.timeoutSeconds` extends this idle watchdog for slow local/self-hosted providers, but it is still bounded by any lower `agents.defaults.timeoutSeconds` or run-specific timeout because those control the whole agent run. Otherwise OpenClaw uses `agents.defaults.timeoutSeconds` when configured, capped at 120s by default. Cron-triggered cloud model runs with no explicit model or agent timeout use the same default idle watchdog; cron-triggered local or self-hosted model runs disable the implicit watchdog unless an explicit timeout is configured, so slow local providers should set `models.providers.<id>.timeoutSeconds`.
- Model idle timeout: OpenClaw aborts a model request when no response chunks arrive before the idle window. `models.providers.<id>.timeoutSeconds` extends this idle watchdog for slow local/self-hosted providers, but it is still bounded by any lower `agents.defaults.timeoutSeconds` or run-specific timeout because those control the whole agent run. Otherwise OpenClaw uses `agents.defaults.timeoutSeconds` when configured, capped at 120s by default. Cron-triggered cloud model runs with no explicit model or agent timeout use the same default idle watchdog; with an explicit cron run timeout, cloud model stream stalls are capped at 60s so configured model fallbacks can run before the outer cron deadline. Cron-triggered local or self-hosted model runs disable the implicit watchdog unless an explicit timeout is configured, and explicit cron run timeouts remain the idle window for local/self-hosted providers, so slow local providers should set `models.providers.<id>.timeoutSeconds`.
- Provider HTTP request timeout: `models.providers.<id>.timeoutSeconds` applies to that provider's model HTTP fetches, including connect, headers, body, SDK request timeout, total guarded-fetch abort handling, and model stream idle watchdog. Use this for slow local/self-hosted providers such as Ollama before raising the whole agent runtime timeout, and keep the agent/runtime timeout at least as high when the model request needs to run longer.
## Where things can end early

View File

@@ -19,42 +19,23 @@ Use this page to answer one question: which OpenClaw surfaces are credible choic
## At a glance
<div className="maturity-summary-grid">
<div className="maturity-summary-item maturity-score-experimental">
<div className="maturity-summary-heading">
<span className="maturity-summary-value">4%</span>
<span>Coverage</span>
</div>
<div className="maturity-summary-bar" style={{ "--score": "4" }}><span /></div>
<div className="maturity-summary-meta">
<span className="maturity-level-pill maturity-level-experimental">Experimental</span>
<span>QA profile evidence</span>
</div>
</div>
<div className="maturity-summary-item maturity-score-alpha">
<div className="maturity-summary-heading">
<span className="maturity-summary-value">63%</span>
<span>Quality</span>
<span className="maturity-summary-value">67%</span>
<span>Maturity score</span>
</div>
<div className="maturity-summary-bar" style={{ "--score": "63" }}><span /></div>
<div className="maturity-summary-bar" style={{ "--score": "67" }}><span /></div>
<div className="maturity-summary-meta">
<span className="maturity-level-pill maturity-level-alpha">Alpha</span>
<span>Reliability and operator confidence</span>
</div>
</div>
<div className="maturity-summary-item maturity-score-beta">
<div className="maturity-summary-heading">
<span className="maturity-summary-value">70%</span>
<span>Completeness</span>
</div>
<div className="maturity-summary-bar" style={{ "--score": "70" }}><span /></div>
<div className="maturity-summary-meta">
<span className="maturity-level-pill maturity-level-beta">Beta</span>
<span>Expected workflow coverage</span>
<span>Quality + completeness</span>
<span>Coverage Experimental - 4%</span>
<span>Quality Alpha - 63%</span>
<span>Completeness Beta - 70%</span>
</div>
</div>
</div>
Coverage is deliberately evidence-led: an area does not become "ready" just because the implementation exists.
Coverage is deliberately evidence-led: an area does not become "ready" just because the implementation exists. It is not an input to the maturity score, but OpenClaw aims to keep end-to-end coverage above 90% for mature Stable-or-better features over time.
## Score bands

View File

@@ -57,34 +57,6 @@ 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,18 +114,7 @@ Example (in JS):
window.location.href = "openclaw://agent?message=Review%20this%20design";
```
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.
The app prompts for confirmation unless a valid key is provided.
## Security notes

View File

@@ -24,9 +24,6 @@ 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,10 +21,6 @@ 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,87 +1,228 @@
---
summary: "Install and use the OpenClaw macOS menu bar app"
summary: "OpenClaw macOS companion app (menu bar + gateway broker)"
read_when:
- Installing the macOS app
- Deciding between local and remote Gateway mode on macOS
- Looking for macOS app release downloads
- Implementing macOS app features
- Changing gateway lifecycle or node bridging on macOS
title: "macOS app"
---
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`.
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.
If you only need the CLI and Gateway, start with [Getting started](/start/getting-started).
## What it does
## Download
- 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 macOS app builds from the
[OpenClaw GitHub releases](https://github.com/openclaw/openclaw/releases).
When a release includes macOS app assets, look for:
## Local vs remote mode
- `OpenClaw-<version>.dmg` (preferred)
- `OpenClaw-<version>.zip`
- **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.
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).
## Launchd control
## First run
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)
1. Install and launch **OpenClaw.app**.
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.
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.
For the CLI/Gateway setup path, use [Getting started](/start/getting-started).
For permission recovery, use [macOS permissions](/platforms/mac/permissions).
## State dir placement (macOS)
## Choose a Gateway mode
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.
| 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) |
Prefer a local non-synced state path such as:
Local mode requires an installed `openclaw` CLI. The app can install it, or you
can follow [Gateway on macOS](/platforms/mac/bundled-gateway).
```bash
OPENCLAW_STATE_DIR=~/.openclaw
```
## What the app owns
If `openclaw doctor` detects state under:
- 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.
- `~/Library/Mobile Documents/com~apple~CloudDocs/...`
- `~/Library/CloudStorage/...`
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.
it will warn and recommend moving back to a local path.
## macOS detail pages
## Build and dev workflow (native)
| 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) |
- `cd apps/macos && swift build`
- `swift run OpenClaw` (or Xcode)
- Package app: `scripts/package-mac-app.sh`
## Related
## Debug gateway connectivity (macOS CLI)
- [Platforms](/platforms)
- [Getting started](/start/getting-started)
- [Gateway](/gateway)
- [Exec approvals](/tools/exec-approvals)
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)

View File

@@ -737,6 +737,10 @@ outbound host generic and use the messaging adapter surface for provider rules:
should be treated as `direct`, `group`, or `channel` before directory lookup.
- `messaging.targetResolver.looksLikeId(raw, normalized)` tells core whether an
input should skip straight to id-like resolution instead of directory search.
- `messaging.targetResolver.reservedLiterals` lists bare words that are
channel/session references for that provider. Resolution preserves configured
directory entries before rejecting reserved literals, then fails closed on a
directory miss.
- `messaging.targetResolver.resolveTarget(...)` is the plugin fallback when
core needs a final provider-owned resolution after normalization or after a
directory miss.

View File

@@ -115,6 +115,17 @@ before the thread starts.
After changing Computer Use config, use `/new` or `/reset` in the affected chat
before testing if an existing Codex thread has already started.
On macOS managed stdio startup, OpenClaw prefers the signed desktop Codex app
bundle at `/Applications/Codex.app/Contents/Resources/codex` when it exists.
That keeps Computer Use under the app bundle that owns the local desktop-control
permissions. If the desktop app is not installed, OpenClaw falls back to the
managed Codex binary installed beside the plugin. If an installed desktop app
initializes with an unsupported app-server version, OpenClaw closes that child
and retries the next managed binary candidate instead of letting a stale
desktop app shadow the plugin-local fallback. Explicit `appServer.command`
config or `OPENCLAW_CODEX_APP_SERVER_BIN` still overrides this managed
selection.
## Commands
Use the `/codex computer-use` commands from any chat surface where the `codex`
@@ -276,7 +287,13 @@ Codex app-server MCP status, or macOS permissions.
**Status or a probe times out on `computer-use.list_apps`.** The plugin and MCP
server are present, but the local Computer Use bridge did not answer. Quit or
restart Codex Computer Use, relaunch Codex Desktop if needed, then retry in a
fresh OpenClaw session.
fresh OpenClaw session. If the host previously ran Computer Use through an older
managed Codex app-server, refresh the installed plugin from the desktop bundled
marketplace:
```text
/codex computer-use install --source /Applications/Codex.app/Contents/Resources/plugins/openai-bundled
```
**A Computer Use tool says `Native hook relay unavailable`.** The Codex-native
tool hook could not reach an active OpenClaw relay through the local bridge or

View File

@@ -110,6 +110,13 @@ When you pass a plugin id, OpenClaw reuses the tracked install spec. Stored
dist-tags such as `@beta` and exact pinned versions continue to be used on
later `update <plugin-id>` runs.
`openclaw plugins update --all` is the bulk maintenance path. It still respects
ordinary tracked install specs, but trusted official OpenClaw plugin records can
sync to the current official catalog target instead of staying on a stale exact
official package. If `update.channel` is set to `beta`, that bulk official sync
uses the beta-channel context. Use a targeted `update <plugin-id>` when you
intentionally want to keep an exact or tagged official spec untouched.
For npm installs, you can pass an explicit package spec to switch the tracked
record:

View File

@@ -739,7 +739,7 @@ Write colocated tests in `src/channel.test.ts`:
describeMessageTool and action discovery
</Card>
<Card title="Target resolution" icon="crosshair" href="/plugins/architecture-internals#channel-target-resolution">
inferTargetChatType, looksLikeId, resolveTarget
inferTargetChatType, looksLikeId, reservedLiterals, resolveTarget
</Card>
<Card title="Runtime helpers" icon="settings" href="/plugins/sdk-runtime">
TTS, STT, media, subagent via api.runtime

View File

@@ -269,7 +269,7 @@ html.dark .nav-tabs-underline {
.maturity-summary-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
grid-template-columns: repeat(auto-fit, minmax(min(220px, 100%), 1fr));
margin: 14px 0 20px;
border-top: 1px solid color-mix(in oklab, rgb(var(--primary)) 18%, transparent);
border-bottom: 1px solid color-mix(in oklab, rgb(var(--primary)) 18%, transparent);

View File

@@ -2,7 +2,10 @@
* Azure Speech REST helpers. They normalize endpoints, build SSML, list voices,
* and synthesize speech with response-size and SSRF guards.
*/
import { assertOkOrThrowProviderError } from "openclaw/plugin-sdk/provider-http";
import {
assertOkOrThrowProviderError,
readProviderJsonResponse,
} from "openclaw/plugin-sdk/provider-http";
import { readResponseWithLimit } from "openclaw/plugin-sdk/response-limit-runtime";
import type { SpeechVoiceOption } from "openclaw/plugin-sdk/speech-core";
import { trimToUndefined } from "openclaw/plugin-sdk/speech-core";
@@ -160,7 +163,10 @@ export async function listAzureSpeechVoices(params: {
try {
await assertOkOrThrowProviderError(response, "Azure Speech voices API error");
const voices = (await response.json()) as AzureSpeechVoiceEntry[];
const voices = await readProviderJsonResponse<AzureSpeechVoiceEntry[]>(
response,
"azure-speech.voices",
);
return Array.isArray(voices)
? voices
.filter((voice) => !isDeprecatedVoice(voice))

View File

@@ -1,12 +1,70 @@
// Byteplus tests cover video generation provider plugin behavior.
import {
getProviderHttpMocks,
installProviderHttpMockCleanup,
} from "openclaw/plugin-sdk/provider-http-test-mocks";
import { expectExplicitVideoGenerationCapabilities } from "openclaw/plugin-sdk/provider-test-contracts";
import { beforeAll, describe, expect, it, vi } from "vitest";
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
const { postJsonRequestMock, fetchWithTimeoutMock } = getProviderHttpMocks();
// Submit/poll transport is mocked locally so each test can inject the BytePlus task JSON
// bodies, while readProviderJsonResponse is kept REAL (via importActual) so the byte-bounded
// reader actually streams and cancels oversized bodies under test instead of a stub.
const { postJsonRequestMock, fetchWithTimeoutMock, resolveApiKeyForProviderMock } = vi.hoisted(
() => ({
postJsonRequestMock: vi.fn(),
fetchWithTimeoutMock: vi.fn(),
resolveApiKeyForProviderMock: vi.fn(async () => ({ apiKey: "provider-key" })),
}),
);
vi.mock("openclaw/plugin-sdk/provider-auth-runtime", () => ({
resolveApiKeyForProvider: resolveApiKeyForProviderMock,
}));
vi.mock("openclaw/plugin-sdk/provider-http", async (importActual) => {
const actual = await importActual<typeof import("openclaw/plugin-sdk/provider-http")>();
const resolveTimeoutMs = (timeoutMs: unknown): number =>
typeof timeoutMs === "function" ? (timeoutMs() as number) : ((timeoutMs as number) ?? 60_000);
return {
// REAL byte-bounded JSON reader under test — not stubbed.
readProviderJsonResponse: actual.readProviderJsonResponse,
postJsonRequest: postJsonRequestMock,
fetchProviderOperationResponse: async (params: {
url: string;
init?: RequestInit;
timeoutMs?: unknown;
fetchFn: typeof fetch;
}) => fetchWithTimeoutMock(params.url, params.init ?? {}, resolveTimeoutMs(params.timeoutMs)),
fetchProviderDownloadResponse: async (params: {
url: string;
init?: RequestInit;
timeoutMs?: unknown;
fetchFn: typeof fetch;
}) => fetchWithTimeoutMock(params.url, params.init ?? {}, resolveTimeoutMs(params.timeoutMs)),
assertOkOrThrowHttpError: async () => {},
createProviderOperationDeadline: ({
label,
timeoutMs,
}: {
label: string;
timeoutMs?: number;
}) => ({ label, timeoutMs }),
createProviderOperationTimeoutResolver:
({ defaultTimeoutMs }: { defaultTimeoutMs: number }) =>
() =>
defaultTimeoutMs,
resolveProviderOperationTimeoutMs: ({ defaultTimeoutMs }: { defaultTimeoutMs: number }) =>
defaultTimeoutMs,
resolveProviderHttpRequestConfig: (params: {
baseUrl?: string;
defaultBaseUrl: string;
allowPrivateNetwork?: boolean;
defaultHeaders?: Record<string, string>;
}) => ({
baseUrl: params.baseUrl ?? params.defaultBaseUrl,
allowPrivateNetwork: params.allowPrivateNetwork === true,
headers: new Headers(params.defaultHeaders),
dispatcherPolicy: undefined,
}),
waitProviderOperationPollInterval: async () => {},
};
});
let buildBytePlusVideoGenerationProvider: typeof import("./video-generation-provider.js").buildBytePlusVideoGenerationProvider;
@@ -14,20 +72,22 @@ beforeAll(async () => {
({ buildBytePlusVideoGenerationProvider } = await import("./video-generation-provider.js"));
});
installProviderHttpMockCleanup();
afterEach(() => {
postJsonRequestMock.mockReset();
fetchWithTimeoutMock.mockReset();
resolveApiKeyForProviderMock.mockClear();
});
function mockSuccessfulBytePlusTask(params?: { model?: string }) {
postJsonRequestMock.mockResolvedValue({
response: {
json: async () => ({
id: "task_123",
}),
},
response: streamedJsonResponse({
id: "task_123",
}),
release: vi.fn(async () => {}),
});
fetchWithTimeoutMock
.mockResolvedValueOnce({
json: async () => ({
.mockResolvedValueOnce(
streamedJsonResponse({
id: "task_123",
status: "succeeded",
content: {
@@ -35,7 +95,7 @@ function mockSuccessfulBytePlusTask(params?: { model?: string }) {
},
model: params?.model ?? "seedance-1-0-lite-t2v-250428",
}),
})
)
.mockResolvedValueOnce({
headers: new Headers({ "content-type": "video/webm" }),
arrayBuffer: async () => Buffer.from("webm-bytes"),
@@ -77,6 +137,53 @@ function streamedVideoResponse(bytes: string): Response {
);
}
// BytePlus submit/poll task JSON is now read through the byte-bounded reader, so the
// mocked responses must expose a real readable body (not just a json() shortcut).
function streamedJsonResponse(payload: unknown): Response {
return new Response(
new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode(JSON.stringify(payload)));
controller.close();
},
}),
{ status: 200, headers: { "content-type": "application/json" } },
);
}
// Builds a JSON body larger than the shared 16 MiB readProviderJsonResponse cap so the
// bounded reader cancels the stream mid-flight; if the cap were removed the reader would
// buffer the whole advertised payload before parsing. Tracks how many bytes were pulled
// and whether the stream was canceled so callers can assert the body was not fully read.
function makeOversizedJsonStream(): {
body: ReadableStream<Uint8Array>;
maxBytes: number;
totalBytes: number;
state: { bytesPulled: number; canceled: boolean };
} {
const maxBytes = 16 * 1024 * 1024; // matches PROVIDER_JSON_RESPONSE_MAX_BYTES.
const ONE_MIB = 1024 * 1024;
const TOTAL_CHUNKS = 32; // 32 MiB advertised body, double the cap.
const chunk = new Uint8Array(ONE_MIB);
const state = { bytesPulled: 0, canceled: false };
let pulled = 0;
const body = new ReadableStream<Uint8Array>({
pull(controller) {
if (pulled >= TOTAL_CHUNKS) {
controller.close();
return;
}
pulled += 1;
state.bytesPulled += chunk.length;
controller.enqueue(chunk);
},
cancel() {
state.canceled = true;
},
});
return { body, maxBytes, totalBytes: TOTAL_CHUNKS * ONE_MIB, state };
}
describe("byteplus video generation provider", () => {
it("declares explicit mode capabilities", () => {
expectExplicitVideoGenerationCapabilities(buildBytePlusVideoGenerationProvider());
@@ -110,21 +217,19 @@ describe("byteplus video generation provider", () => {
it("rejects generated video downloads that exceed the configured media cap", async () => {
postJsonRequestMock.mockResolvedValue({
response: {
json: async () => ({ id: "task_too_large" }),
},
response: streamedJsonResponse({ id: "task_too_large" }),
release: vi.fn(async () => {}),
});
fetchWithTimeoutMock
.mockResolvedValueOnce({
json: async () => ({
.mockResolvedValueOnce(
streamedJsonResponse({
id: "task_too_large",
status: "succeeded",
content: {
video_url: "https://example.com/too-large.mp4",
},
}),
})
)
.mockResolvedValueOnce(streamedVideoResponse("too-large"));
const provider = buildBytePlusVideoGenerationProvider();
@@ -222,16 +327,14 @@ describe("byteplus video generation provider", () => {
it("drops malformed response duration metadata", async () => {
postJsonRequestMock.mockResolvedValue({
response: {
json: async () => ({
id: "task_123",
}),
},
response: streamedJsonResponse({
id: "task_123",
}),
release: vi.fn(async () => {}),
});
fetchWithTimeoutMock
.mockResolvedValueOnce({
json: async () => ({
.mockResolvedValueOnce(
streamedJsonResponse({
id: "task_123",
status: "succeeded",
content: {
@@ -239,7 +342,7 @@ describe("byteplus video generation provider", () => {
},
duration: 1.5,
}),
})
)
.mockResolvedValueOnce({
headers: new Headers({ "content-type": "video/mp4" }),
arrayBuffer: async () => Buffer.from("mp4-bytes"),
@@ -259,11 +362,15 @@ describe("byteplus video generation provider", () => {
it("reports malformed create JSON with a provider-owned error", async () => {
const release = vi.fn(async () => {});
postJsonRequestMock.mockResolvedValue({
response: {
json: async () => {
throw new SyntaxError("bad json");
},
},
response: new Response(
new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode("{ not valid json"));
controller.close();
},
}),
{ status: 200, headers: { "content-type": "application/json" } },
),
release,
});
@@ -281,19 +388,17 @@ describe("byteplus video generation provider", () => {
it("rejects status responses missing a task status", async () => {
postJsonRequestMock.mockResolvedValue({
response: {
json: async () => ({ id: "task_missing_status" }),
},
response: streamedJsonResponse({ id: "task_missing_status" }),
release: vi.fn(async () => {}),
});
fetchWithTimeoutMock.mockResolvedValueOnce({
json: async () => ({
fetchWithTimeoutMock.mockResolvedValueOnce(
streamedJsonResponse({
id: "task_missing_status",
content: {
video_url: "https://example.com/byteplus.mp4",
},
}),
});
);
const provider = buildBytePlusVideoGenerationProvider();
await expect(
@@ -308,18 +413,16 @@ describe("byteplus video generation provider", () => {
it("rejects malformed completed content", async () => {
postJsonRequestMock.mockResolvedValue({
response: {
json: async () => ({ id: "task_malformed_content" }),
},
response: streamedJsonResponse({ id: "task_malformed_content" }),
release: vi.fn(async () => {}),
});
fetchWithTimeoutMock.mockResolvedValueOnce({
json: async () => ({
fetchWithTimeoutMock.mockResolvedValueOnce(
streamedJsonResponse({
id: "task_malformed_content",
status: "succeeded",
content: ["https://example.com/byteplus.mp4"],
}),
});
);
const provider = buildBytePlusVideoGenerationProvider();
await expect(
@@ -331,4 +434,61 @@ describe("byteplus video generation provider", () => {
}),
).rejects.toThrow("BytePlus video generation completed with malformed content");
});
it("bounds the submit task JSON body and cancels an oversized stream", async () => {
const stream = makeOversizedJsonStream();
const release = vi.fn(async () => {});
postJsonRequestMock.mockResolvedValue({
response: new Response(stream.body, {
status: 200,
headers: { "content-type": "application/json" },
}),
release,
});
const provider = buildBytePlusVideoGenerationProvider();
await expect(
provider.generateVideo({
provider: "byteplus",
model: "seedance-1-0-lite-t2v-250428",
prompt: "oversized submit response",
cfg: {},
}),
).rejects.toThrow(
`BytePlus video generation failed: JSON response exceeds ${stream.maxBytes} bytes`,
);
expect(stream.state.canceled).toBe(true);
// Only the bounded prefix is pulled, never the full advertised stream.
expect(stream.state.bytesPulled).toBeLessThan(stream.totalBytes);
// The submit request must still be released even though the body overflowed.
expect(release).toHaveBeenCalledOnce();
});
it("bounds the poll status JSON body and cancels an oversized stream", async () => {
postJsonRequestMock.mockResolvedValue({
response: streamedJsonResponse({ id: "task_oversized_poll" }),
release: vi.fn(async () => {}),
});
const stream = makeOversizedJsonStream();
fetchWithTimeoutMock.mockResolvedValueOnce(
new Response(stream.body, {
status: 200,
headers: { "content-type": "application/json" },
}),
);
const provider = buildBytePlusVideoGenerationProvider();
await expect(
provider.generateVideo({
provider: "byteplus",
model: "seedance-1-0-lite-t2v-250428",
prompt: "oversized poll response",
cfg: {},
}),
).rejects.toThrow(
`BytePlus video status request failed: JSON response exceeds ${stream.maxBytes} bytes`,
);
expect(stream.state.canceled).toBe(true);
expect(stream.state.bytesPulled).toBeLessThan(stream.totalBytes);
});
});

View File

@@ -11,6 +11,7 @@ import {
fetchProviderDownloadResponse,
fetchProviderOperationResponse,
postJsonRequest,
readProviderJsonResponse,
resolveProviderOperationTimeoutMs,
resolveProviderHttpRequestConfig,
waitProviderOperationPollInterval,
@@ -55,16 +56,13 @@ type BytePlusTaskResponse = {
type BytePlusTaskStatus = "running" | "failed" | "queued" | "succeeded" | "cancelled";
async function readBytePlusJsonResponse<T>(
response: Pick<Response, "json">,
label: string,
): Promise<T> {
let payload: unknown;
try {
payload = await response.json();
} catch (cause) {
throw new Error(`${label}: malformed JSON response`, { cause });
}
async function readBytePlusJsonResponse<T>(response: Response, label: string): Promise<T> {
// BytePlus submit/poll task bodies are read through the shared byte-bounded reader
// (readResponseWithLimit, via readProviderJsonResponse) so a hostile or buggy endpoint
// that streams an unbounded JSON body cannot force the runtime to buffer the whole
// payload before parsing. Overflow cancels the stream and throws a bounded error;
// malformed JSON keeps the existing `${label}: malformed JSON response` wrapping.
const payload = await readProviderJsonResponse<unknown>(response, label);
if (!isRecord(payload)) {
throw new Error(`${label}: malformed JSON response`);
}

View File

@@ -639,6 +639,15 @@ function assertSupportedCodexAppServerVersion(response: CodexInitializeResponse)
return detectedVersion;
}
export function isUnsupportedCodexAppServerVersionError(error: unknown): boolean {
return (
error instanceof Error &&
error.message.startsWith(
`Codex app-server ${MIN_CODEX_APP_SERVER_VERSION} or newer is required`,
)
);
}
function buildCodexAppServerRuntimeIdentity(
response: CodexInitializeResponse,
serverVersion: string,

View File

@@ -167,6 +167,7 @@ export type CodexAppServerStartOptions = {
transport: CodexAppServerTransportMode;
command: string;
commandSource?: CodexAppServerCommandSource;
managedFallbackCommandPaths?: string[];
args: string[];
url?: string;
authToken?: string;
@@ -332,7 +333,9 @@ const codexAppServerNetworkProxySchema = z
baseProfile: z.enum(["read-only", "workspace"]).optional(),
mode: z.enum(["limited", "full"]).optional(),
domains: z.record(z.string(), codexAppServerNetworkProxyDomainPermissionSchema).optional(),
unixSockets: z.record(z.string(), codexAppServerNetworkProxyUnixSocketPermissionSchema).optional(),
unixSockets: z
.record(z.string(), codexAppServerNetworkProxyUnixSocketPermissionSchema)
.optional(),
proxyUrl: z.string().trim().min(1).optional(),
socksUrl: z.string().trim().min(1).optional(),
enableSocks5: z.boolean().optional(),
@@ -874,6 +877,7 @@ export function codexAppServerStartOptionsKey(
transport: options.transport,
command: options.command,
commandSource: options.commandSource ?? null,
managedFallbackCommandPaths: [...(options.managedFallbackCommandPaths ?? [])],
args: options.args,
url: options.url ?? null,
authToken: hashSecretForKey(options.authToken, "authToken"),

View File

@@ -27,6 +27,8 @@ function managedCommandPath(root: string, platform: NodeJS.Platform): string {
return pathApi.join(root, "node_modules", ".bin", platform === "win32" ? "codex.cmd" : "codex");
}
const MACOS_DESKTOP_CODEX_APP_SERVER_COMMAND = "/Applications/Codex.app/Contents/Resources/codex";
describe("managed Codex app-server binary", () => {
it("leaves explicit command overrides unchanged", async () => {
const explicitOptions = startOptions("config");
@@ -41,10 +43,14 @@ describe("managed Codex app-server binary", () => {
expect(pathExists).not.toHaveBeenCalled();
});
it("resolves the plugin-local bundled Codex binary", async () => {
it("prefers the macOS desktop app bundle when it exists", async () => {
const pluginRoot = path.join("/tmp", "openclaw", "extensions", "codex");
const paths = resolveManagedCodexAppServerPaths({ platform: "darwin", pluginRoot });
const pathExists = vi.fn(async (filePath: string) => filePath === paths.commandPath);
const pluginLocalCommand = managedCommandPath(pluginRoot, "darwin");
const pathExists = vi.fn(
async (filePath: string) =>
filePath === MACOS_DESKTOP_CODEX_APP_SERVER_COMMAND || filePath === pluginLocalCommand,
);
await expect(
resolveManagedCodexAppServerStartOptions(startOptions("managed"), {
@@ -54,10 +60,31 @@ describe("managed Codex app-server binary", () => {
}),
).resolves.toEqual({
...startOptions("managed"),
command: paths.commandPath,
command: MACOS_DESKTOP_CODEX_APP_SERVER_COMMAND,
commandSource: "resolved-managed",
managedFallbackCommandPaths: [pluginLocalCommand],
});
expect(paths.commandPath).toBe(MACOS_DESKTOP_CODEX_APP_SERVER_COMMAND);
expect(paths.candidateCommandPaths).toContain(pluginLocalCommand);
});
it("falls back to the plugin-local bundled Codex binary on macOS", async () => {
const pluginRoot = path.join("/tmp", "openclaw", "extensions", "codex");
const pluginLocalCommand = managedCommandPath(pluginRoot, "darwin");
const pathExists = vi.fn(async (filePath: string) => filePath === pluginLocalCommand);
await expect(
resolveManagedCodexAppServerStartOptions(startOptions("managed"), {
platform: "darwin",
pluginRoot,
pathExists,
}),
).resolves.toEqual({
...startOptions("managed"),
command: pluginLocalCommand,
commandSource: "resolved-managed",
});
expect(paths.commandPath).toBe(managedCommandPath(pluginRoot, "darwin"));
expect(pathExists).toHaveBeenCalledWith(MACOS_DESKTOP_CODEX_APP_SERVER_COMMAND, "darwin");
});
it("resolves Windows Codex command shims", () => {

View File

@@ -12,6 +12,7 @@ import { MANAGED_CODEX_APP_SERVER_PACKAGE } from "./version.js";
const CODEX_APP_SERVER_MODULE_DIR = path.dirname(fileURLToPath(import.meta.url));
const CODEX_PLUGIN_ROOT = resolveDefaultCodexPluginRoot(CODEX_APP_SERVER_MODULE_DIR);
const MACOS_DESKTOP_CODEX_APP_SERVER_COMMAND = "/Applications/Codex.app/Contents/Resources/codex";
type ManagedCodexAppServerPaths = {
commandPath: string;
@@ -39,16 +40,19 @@ export async function resolveManagedCodexAppServerStartOptions(
pluginRoot: options.pluginRoot,
});
const pathExists = options.pathExists ?? commandPathExists;
const commandPath = await findManagedCodexAppServerCommandPath({
const commandPaths = await findManagedCodexAppServerCommandPaths({
candidateCommandPaths: paths.candidateCommandPaths,
pathExists,
platform,
});
const commandPath = commandPaths[0];
const managedFallbackCommandPaths = commandPaths.slice(1);
return {
...startOptions,
command: commandPath,
commandSource: "resolved-managed",
...(managedFallbackCommandPaths.length > 0 ? { managedFallbackCommandPaths } : {}),
};
}
@@ -77,12 +81,17 @@ function resolveManagedCodexAppServerCommandCandidates(
const roots = resolveManagedCodexAppServerCandidateRoots(pluginRoot, platform);
return [
...new Set([
...resolveDesktopCodexAppServerCommandCandidates(platform),
...roots.map((root) => pathApi.join(root, "node_modules", ".bin", commandName)),
...resolveManagedCodexPackageBinCandidates(roots, platform),
]),
];
}
function resolveDesktopCodexAppServerCommandCandidates(platform: NodeJS.Platform): string[] {
return platform === "darwin" ? [MACOS_DESKTOP_CODEX_APP_SERVER_COMMAND] : [];
}
function resolveDefaultCodexPluginRoot(moduleDir: string): string {
const moduleBaseName = path.basename(moduleDir);
if (moduleBaseName === "dist" || moduleBaseName === "dist-runtime") {
@@ -195,16 +204,20 @@ function pathForPlatform(platform: NodeJS.Platform): typeof path {
return platform === "win32" ? path.win32 : path.posix;
}
async function findManagedCodexAppServerCommandPath(params: {
async function findManagedCodexAppServerCommandPaths(params: {
candidateCommandPaths: readonly string[];
pathExists: (filePath: string, platform: NodeJS.Platform) => Promise<boolean>;
platform: NodeJS.Platform;
}): Promise<string> {
}): Promise<string[]> {
const commandPaths: string[] = [];
for (const commandPath of params.candidateCommandPaths) {
if (await params.pathExists(commandPath, params.platform)) {
return commandPath;
commandPaths.push(commandPath);
}
}
if (commandPaths.length > 0) {
return commandPaths;
}
throw new Error(
[

View File

@@ -187,6 +187,41 @@ describe("shared Codex app-server client", () => {
startSpy.mockRestore();
});
it("falls back to the next managed app-server when desktop initialize is unsupported", async () => {
const desktop = createClientHarness();
const pluginLocal = createClientHarness();
const startSpy = vi
.spyOn(CodexAppServerClient, "start")
.mockReturnValueOnce(desktop.client)
.mockReturnValueOnce(pluginLocal.client);
mocks.resolveManagedCodexAppServerStartOptions.mockImplementationOnce(async (startOptions) => ({
...startOptions,
command: "/Applications/Codex.app/Contents/Resources/codex",
commandSource: "resolved-managed",
managedFallbackCommandPaths: ["/cache/openclaw/codex"],
}));
const listPromise = listCodexAppServerModels({ timeoutMs: 1000 });
await sendInitializeResult(desktop, "openclaw/0.124.9 (macOS; test)");
await sendInitializeResult(pluginLocal, "openclaw/0.125.0 (macOS; test)");
await sendEmptyModelList(pluginLocal);
await expect(listPromise).resolves.toEqual({ models: [] });
expect(desktop.process.stdin.destroyed).toBe(true);
expect(pluginLocal.process.stdin.destroyed).toBe(false);
expect(startSpy).toHaveBeenCalledTimes(2);
expect(startSpy.mock.calls[0]?.[0]).toMatchObject({
command: "/Applications/Codex.app/Contents/Resources/codex",
commandSource: "resolved-managed",
managedFallbackCommandPaths: ["/cache/openclaw/codex"],
});
expect(startSpy.mock.calls[1]?.[0]).toMatchObject({
command: "/cache/openclaw/codex",
commandSource: "resolved-managed",
});
expect(startSpy.mock.calls[1]?.[0]).not.toHaveProperty("managedFallbackCommandPaths");
});
it("closes and clears a shared app-server when initialize times out", async () => {
const first = createClientHarness();
const second = createClientHarness();

View File

@@ -11,7 +11,7 @@ import {
resolveCodexAppServerAuthProfileStore,
resolveCodexAppServerFallbackApiKeyCacheKey,
} from "./auth-bridge.js";
import { CodexAppServerClient } from "./client.js";
import { CodexAppServerClient, isUnsupportedCodexAppServerVersionError } from "./client.js";
import {
codexAppServerStartOptionsKey,
resolveCodexAppServerRuntimeOptions,
@@ -242,27 +242,23 @@ async function acquireSharedCodexAppServerClient(
const sharedPromise =
entry.promise ??
(entry.promise = (async () => {
const client = CodexAppServerClient.start(startOptions);
const client = await startInitializedCodexAppServerClient({
startOptions,
agentDir,
authProfileId: usesNativeAuth ? null : authProfileId,
config: options?.config,
onStartedClient: (startedClient) => {
entry.client = startedClient;
startedClient.setActiveSharedLeaseCountProviderForUnscopedNotifications(
() => entry.activeLeases,
);
options?.onStartedClient?.(startedClient);
},
});
entry.client = client;
options?.onStartedClient?.(client);
client.setActiveSharedLeaseCountProviderForUnscopedNotifications(() => entry.activeLeases);
client.addCloseHandler((closedClient) => clearSharedClientEntryIfCurrent(key, closedClient));
try {
await client.initialize();
await applyCodexAppServerAuthProfile({
client,
agentDir,
authProfileId: usesNativeAuth ? null : authProfileId,
startOptions,
config: options?.config,
});
return client;
} catch (error) {
// Startup failures happen before callers own the shared client, so close
// the child here instead of leaving a rejected daemon attached to stdio.
client.close();
throw error;
}
return client;
})());
try {
const client = await withTimeout(
@@ -291,39 +287,110 @@ export async function createIsolatedCodexAppServerClient(
): Promise<CodexAppServerClient> {
const { agentDir, usesNativeAuth, authProfileId, authProfileStore, startOptions } =
await resolveCodexAppServerClientStartContext(options);
const client = CodexAppServerClient.start(startOptions);
if (authProfileId) {
// Profile-backed Codex auth is ephemeral. Keep the host refresh callback
// available whether the profile came from a scoped store or persisted state.
client.addRequestHandler(async (request) => {
if (request.method !== "account/chatgptAuthTokens/refresh") {
return undefined;
return await startInitializedCodexAppServerClient({
startOptions,
agentDir,
authProfileId: usesNativeAuth ? null : authProfileId,
authProfileStore,
config: options?.config,
timeoutMs: options?.timeoutMs,
onStartedClient: options?.onStartedClient,
});
}
async function startInitializedCodexAppServerClient(params: {
startOptions: CodexAppServerStartOptions;
agentDir: string;
authProfileId: string | null | undefined;
authProfileStore?: AuthProfileStore;
config?: CodexAppServerClientOptions["config"];
timeoutMs?: number;
onStartedClient?: (client: CodexAppServerClient) => void;
}): Promise<CodexAppServerClient> {
const startOptionsCandidates = resolveManagedFallbackStartOptions(params.startOptions);
for (let index = 0; index < startOptionsCandidates.length; index += 1) {
const startOptions = startOptionsCandidates[index];
const client = CodexAppServerClient.start(startOptions);
params.onStartedClient?.(client);
const initialize = client.initialize();
try {
await withTimeout(initialize, params.timeoutMs ?? 0, "codex app-server initialize timed out");
} catch (error) {
client.close();
void initialize.catch(() => undefined);
if (shouldTryManagedFallbackStartOption(error, startOptions, index, startOptionsCandidates)) {
continue;
}
return await refreshCodexAppServerAuthTokens({
agentDir,
authProfileId,
...(authProfileStore ? { authProfileStore } : {}),
config: options?.config,
throw error;
}
if (params.authProfileId) {
// Profile-backed Codex auth is ephemeral. Keep the host refresh callback
// available whether the profile came from a scoped store or persisted state.
client.addRequestHandler(async (request) => {
if (request.method !== "account/chatgptAuthTokens/refresh") {
return undefined;
}
return await refreshCodexAppServerAuthTokens({
agentDir: params.agentDir,
authProfileId: params.authProfileId!,
...(params.authProfileStore ? { authProfileStore: params.authProfileStore } : {}),
config: params.config,
});
});
});
}
try {
await applyCodexAppServerAuthProfile({
client,
agentDir: params.agentDir,
authProfileId: params.authProfileId,
startOptions,
config: params.config,
...(params.authProfileStore ? { authProfileStore: params.authProfileStore } : {}),
});
return client;
} catch (error) {
client.close();
throw error;
}
}
const initialize = client.initialize();
try {
await withTimeout(initialize, options?.timeoutMs ?? 0, "codex app-server initialize timed out");
await applyCodexAppServerAuthProfile({
client,
agentDir,
authProfileId: usesNativeAuth ? null : authProfileId,
startOptions,
config: options?.config,
...(authProfileStore ? { authProfileStore } : {}),
});
return client;
} catch (error) {
client.close();
void initialize.catch(() => undefined);
throw error;
throw new Error("Managed Codex app-server fallback candidates were exhausted.");
}
function resolveManagedFallbackStartOptions(
startOptions: CodexAppServerStartOptions,
): CodexAppServerStartOptions[] {
const commands = [startOptions.command, ...(startOptions.managedFallbackCommandPaths ?? [])];
const candidates: CodexAppServerStartOptions[] = [];
for (let index = 0; index < commands.length; index += 1) {
const command = commands[index];
const managedFallbackCommandPaths = commands.slice(index + 1);
const candidate = {
...startOptions,
command,
};
if (managedFallbackCommandPaths.length === 0) {
delete candidate.managedFallbackCommandPaths;
} else {
candidate.managedFallbackCommandPaths = managedFallbackCommandPaths;
}
candidates.push(candidate);
}
return candidates;
}
function shouldTryManagedFallbackStartOption(
error: unknown,
startOptions: CodexAppServerStartOptions,
index: number,
startOptionsCandidates: readonly CodexAppServerStartOptions[],
): boolean {
return (
startOptions.commandSource === "resolved-managed" &&
index < startOptionsCandidates.length - 1 &&
isUnsupportedCodexAppServerVersionError(error)
);
}
/** Clears and closes all shared clients for deterministic tests. */

View File

@@ -172,6 +172,24 @@ describe("hydrateViewer", () => {
expect(document.documentElement.dataset.openclawDiffsError).toBeUndefined();
warn.mockRestore();
});
it("replaces stale controllers when hydrating the current cards again", async () => {
renderCard();
const { controllers, hydrateViewer } = await import("./viewer-client.js");
controllers.splice(0);
await hydrateViewer();
expect(controllers).toHaveLength(1);
const firstController = controllers[0];
document.body.innerHTML = "";
renderCard();
await hydrateViewer();
expect(controllers).toHaveLength(1);
expect(controllers[0]).not.toBe(firstController);
expect(fileDiffHydrateMock).toHaveBeenCalledTimes(2);
});
});
describe("viewerState initialization", () => {

View File

@@ -287,6 +287,9 @@ function syncAllControllers(): void {
}
export async function hydrateViewer(): Promise<void> {
// Rehydration replaces the current DOM card set; do not retain controllers
// from a previous render because they can keep stale DOM references alive.
controllers.length = 0;
const cards = await Promise.all(
getCards().map(async ({ host, payload }) => ({
host,

View File

@@ -345,7 +345,7 @@ describe("discordOutbound", () => {
2,
);
expect(messageOptions.accountId).toBe("default");
expect(messageOptions.replyTo).toBeUndefined();
expect(messageOptions.replyTo).toBe("reply-1");
const mediaCall = mockCall(hoisted.sendMessageDiscordMock, "sendMessageDiscord", 1);
expect(mediaCall[0]).toBe("channel:123456");
@@ -353,7 +353,7 @@ describe("discordOutbound", () => {
const mediaOptions = mockObjectArg(hoisted.sendMessageDiscordMock, "sendMessageDiscord", 1, 2);
expect(mediaOptions.accountId).toBe("default");
expect(mediaOptions.mediaUrl).toBe("https://example.com/extra.png");
expect(mediaOptions.replyTo).toBeUndefined();
expect(mediaOptions.replyTo).toBe("reply-1");
expect(result).toEqual({
channel: "discord",
messageId: "msg-1",
@@ -361,6 +361,31 @@ describe("discordOutbound", () => {
});
});
it("keeps captured replyTo on audioAsVoice sends when replyToMode is batched", async () => {
await discordOutbound.sendPayload?.({
cfg: {},
to: "channel:123456",
text: "",
payload: {
text: "voice note",
mediaUrls: ["https://example.com/voice.ogg", "https://example.com/extra.png"],
audioAsVoice: true,
},
accountId: "default",
replyToId: "reply-1",
replyToMode: "batched",
});
expect(
mockObjectArg(hoisted.sendVoiceMessageDiscordMock, "sendVoiceMessageDiscord", 0, 2).replyTo,
).toBe("reply-1");
expect(
hoisted.sendMessageDiscordMock.mock.calls.map(
(call) => (call[2] as { replyTo?: unknown } | undefined)?.replyTo,
),
).toEqual(["reply-1", "reply-1"]);
});
it("keeps replyToId on every internal audioAsVoice send when replyToMode is all", async () => {
await discordOutbound.sendPayload?.({
cfg: {},

View File

@@ -84,13 +84,15 @@ export async function sendDiscordOutboundPayload(params: {
const sendContext = await createDiscordPayloadSendContext(ctx);
if (payload.audioAsVoice && mediaUrls.length > 0) {
// audioAsVoice emits one logical Discord reply across voice/text/media sends.
// Capture before helper calls consume implicit single-use reply targets.
const voiceReplyTo = sendContext.resolveReplyTo();
let lastResult = await sendContext.withRetry(
async () =>
await sendContext.sendVoice(
sendContext.target,
mediaUrls[0],
resolveDiscordDeliveryOptions(ctx, sendContext),
),
await sendContext.sendVoice(sendContext.target, mediaUrls[0], {
...resolveDiscordDeliveryOptions(ctx, sendContext),
replyTo: voiceReplyTo,
}),
);
if (payload.text?.trim()) {
lastResult = await sendContext.withRetry(
@@ -98,6 +100,7 @@ export async function sendDiscordOutboundPayload(params: {
await sendContext.send(sendContext.target, payload.text, {
verbose: false,
...resolveDiscordFormattedDeliveryOptions(ctx, sendContext),
replyTo: voiceReplyTo,
}),
);
}
@@ -107,6 +110,7 @@ export async function sendDiscordOutboundPayload(params: {
await sendContext.send(sendContext.target, "", {
verbose: false,
...resolveDiscordMediaDeliveryOptions(ctx, sendContext, mediaUrl),
replyTo: voiceReplyTo,
}),
);
}

View File

@@ -55,20 +55,35 @@ describe("PDF document extractor", () => {
});
});
it("extracts text first and renders fallback images through clawpdf", async () => {
pdfDocument.extract.mockResolvedValueOnce({ text: "", images: [] }).mockResolvedValueOnce({
text: "",
images: [
{
type: "image",
bytes: Uint8Array.from(Buffer.from("png")),
mimeType: "image/png",
page: 1,
width: 10,
height: 10,
},
],
});
it("extracts text first and renders each fallback page with its own pixel budget", async () => {
pdfDocument.extract
.mockResolvedValueOnce({ text: "", images: [] })
.mockResolvedValueOnce({
text: "",
images: [
{
type: "image",
bytes: Uint8Array.from(Buffer.from("png1")),
mimeType: "image/png",
page: 1,
width: 5,
height: 10,
},
],
})
.mockResolvedValueOnce({
text: "",
images: [
{
type: "image",
bytes: Uint8Array.from(Buffer.from("png2")),
mimeType: "image/png",
page: 2,
width: 5,
height: 10,
},
],
});
const extractor = createPdfDocumentExtractor();
const result = await extractor.extract(request());
@@ -82,18 +97,24 @@ describe("PDF document extractor", () => {
maxPages: 2,
maxTextChars: 200_000,
});
// Each page renders in its own extract() call, with the aggregate pixel cap
// allocated across selected pages so later pages are not starved.
expect(pdfDocument.extract).toHaveBeenNthCalledWith(2, {
mode: "images",
maxPages: 2,
image: {
maxDimension: 10_000,
maxPixels: 100,
forms: true,
},
pages: [1],
image: { maxDimension: 10_000, maxPixels: 50, forms: true },
});
expect(pdfDocument.extract).toHaveBeenNthCalledWith(3, {
mode: "images",
pages: [2],
image: { maxDimension: 10_000, maxPixels: 50, forms: true },
});
expect(result).toEqual({
text: "",
images: [{ type: "image", data: "cG5n", mimeType: "image/png" }],
images: [
{ type: "image", data: "cG5nMQ==", mimeType: "image/png" },
{ type: "image", data: "cG5nMg==", mimeType: "image/png" },
],
});
expect(pdfDocument.destroy).toHaveBeenCalledTimes(1);
});
@@ -131,8 +152,9 @@ describe("PDF document extractor", () => {
expect(pdfDocument.destroy).not.toHaveBeenCalled();
});
it("filters selected pages before passing them to clawpdf", async () => {
it("filters selected pages and renders them one page per image call", async () => {
pdfDocument.extract
.mockResolvedValueOnce({ text: "", images: [] })
.mockResolvedValueOnce({ text: "", images: [] })
.mockResolvedValueOnce({ text: "", images: [] });
const extractor = createPdfDocumentExtractor();
@@ -141,11 +163,15 @@ describe("PDF document extractor", () => {
expect(pdfDocument.extract).toHaveBeenNthCalledWith(
1,
expect.objectContaining({ pages: [2, 1] }),
expect.objectContaining({ mode: "text", pages: [2, 1] }),
);
expect(pdfDocument.extract).toHaveBeenNthCalledWith(
2,
expect.objectContaining({ pages: [2, 1] }),
expect.objectContaining({ mode: "images", pages: [2] }),
);
expect(pdfDocument.extract).toHaveBeenNthCalledWith(
3,
expect.objectContaining({ mode: "images", pages: [1] }),
);
});

View File

@@ -83,17 +83,38 @@ async function extractPdfContent(
return { text, images: [] };
}
// clawpdf's image render budget (maxPixels) is shared across every page in one
// extract() call: the first page consumes it and later pages collapse to 1x1
// PNGs that vision models reject. Render each page separately, allocating the
// remaining aggregate budget across pages that still need rendering.
const imagePages =
pages ?? Array.from({ length: Math.min(pdf.pageCount, request.maxPages) }, (_, i) => i + 1);
try {
const imageResult = await pdf.extract({
mode: "images",
...pageSelection,
image: {
maxDimension: MAX_RENDER_DIMENSION,
maxPixels: request.maxPixels,
forms: true,
},
});
return { text, images: imageResult.images.map(toDocumentImage) };
const images: DocumentExtractedImage[] = [];
let remainingPixels = request.maxPixels;
for (let index = 0; index < imagePages.length; index += 1) {
if (remainingPixels <= 0) {
break;
}
const pagesRemaining = imagePages.length - index;
const maxPixelsPerPage = Math.max(1, Math.ceil(remainingPixels / pagesRemaining));
const pageNumber = imagePages[index];
const imageResult = await pdf.extract({
mode: "images",
pages: [pageNumber],
image: {
maxDimension: MAX_RENDER_DIMENSION,
maxPixels: maxPixelsPerPage,
forms: true,
},
});
for (const image of imageResult.images) {
images.push(toDocumentImage(image));
remainingPixels -= image.width * image.height;
}
}
return { text, images };
} catch (err) {
request.onImageExtractionError?.(err);
return { text, images: [] };

View File

@@ -1,5 +1,6 @@
// Duckduckgo plugin module implements ddg client behavior.
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { readProviderTextResponse } from "openclaw/plugin-sdk/provider-http";
import {
DEFAULT_CACHE_TTL_MINUTES,
DEFAULT_SEARCH_COUNT,
@@ -113,6 +114,10 @@ function isBotChallenge(html: string): boolean {
return /g-recaptcha|are you a human|id="challenge-form"|name="challenge"/i.test(html);
}
async function readDuckDuckGoHtmlResponse(response: Response): Promise<string> {
return await readProviderTextResponse(response, "DuckDuckGo search");
}
function parseDuckDuckGoHtml(html: string): DuckDuckGoResult[] {
const results: DuckDuckGoResult[] = [];
const resultRegex = /<a\b(?=[^>]*\bclass="[^"]*\bresult__a\b[^"]*")([^>]*)>([\s\S]*?)<\/a>/gi;
@@ -202,7 +207,7 @@ export async function runDuckDuckGoSearch(params: {
);
}
const html = await response.text();
const html = await readDuckDuckGoHtmlResponse(response);
if (isBotChallenge(html)) {
throw new Error("DuckDuckGo returned a bot-detection challenge.");
}
@@ -238,5 +243,6 @@ export const testing = {
decodeHtmlEntities,
isBotChallenge,
parseDuckDuckGoHtml,
readDuckDuckGoHtmlResponse,
};
export { testing as __testing };

View File

@@ -1,5 +1,6 @@
// Duckduckgo tests cover ddg search provider plugin behavior.
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { createStreamingResponse } from "../../test-support/streaming-error-response.js";
import { createDuckDuckGoWebSearchProvider as createDuckDuckGoWebSearchContractProvider } from "../web-search-contract-api.js";
import { DEFAULT_DDG_SAFE_SEARCH, resolveDdgRegion, resolveDdgSafeSearch } from "./config.js";
@@ -104,6 +105,24 @@ describe("duckduckgo web search provider", () => {
expect(runDuckDuckGoSearch).not.toHaveBeenCalled();
});
it("bounds successful DuckDuckGo HTML bodies without using response.text()", async () => {
const streamed = createStreamingResponse({
chunkCount: 32,
chunkSize: 1024 * 1024,
text: "x",
headers: { "Content-Type": "text/html" },
});
const textSpy = vi.spyOn(streamed.response, "text").mockRejectedValue(new Error("unbounded"));
await expect(ddgClientTesting.readDuckDuckGoHtmlResponse(streamed.response)).rejects.toThrow(
"DuckDuckGo search: text response exceeds 16777216 bytes",
);
expect(streamed.getReadCount()).toBeLessThan(32);
expect(streamed.wasCanceled()).toBe(true);
expect(textSpy).not.toHaveBeenCalled();
});
it("reads region from plugin config and normalizes empty values away", () => {
expect(
resolveDdgRegion({

View File

@@ -1,7 +1,10 @@
// Elevenlabs provider module implements model/runtime integration.
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { parseStrictFiniteNumber, parseStrictInteger } from "openclaw/plugin-sdk/number-runtime";
import { assertOkOrThrowProviderError } from "openclaw/plugin-sdk/provider-http";
import {
assertOkOrThrowProviderError,
readProviderJsonResponse,
} from "openclaw/plugin-sdk/provider-http";
import { normalizeResolvedSecretInputString } from "openclaw/plugin-sdk/secret-input";
import type {
SpeechDirectiveTokenParseContext,
@@ -367,14 +370,14 @@ async function listElevenLabsVoices(params: {
});
try {
await assertOkOrThrowProviderError(response, "ElevenLabs voices API error");
const json = (await response.json()) as {
const json = await readProviderJsonResponse<{
voices?: Array<{
voice_id?: string;
name?: string;
category?: string;
description?: string;
}>;
};
}>(response, "elevenlabs.voices");
return Array.isArray(json.voices)
? json.voices
.map((voice) => ({

View File

@@ -1,5 +1,6 @@
// Firecrawl plugin module implements firecrawl client behavior.
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { readProviderJsonResponse } from "openclaw/plugin-sdk/provider-http";
import {
DEFAULT_CACHE_TTL_MINUTES,
markdownToText,
@@ -41,6 +42,7 @@ const SCRAPE_CACHE = new Map<
>();
const DEFAULT_SEARCH_COUNT = 5;
const DEFAULT_SCRAPE_MAX_CHARS = 50_000;
const FIRECRAWL_SCRAPE_RESPONSE_MAX_BYTES = 64 * 1024 * 1024;
const ALLOWED_FIRECRAWL_HOSTS = new Set(["api.firecrawl.dev"]);
const FIRECRAWL_SELF_HOSTED_PRIVATE_ERROR =
"Firecrawl custom baseUrl must target a private or internal self-hosted endpoint.";
@@ -65,12 +67,9 @@ type FirecrawlSearchItem = {
async function readFirecrawlJsonResponse(
response: Response,
label: string,
opts?: { maxBytes?: number },
): Promise<Record<string, unknown>> {
try {
return (await response.json()) as Record<string, unknown>;
} catch (cause) {
throw new Error(`${label}: malformed JSON response`, { cause });
}
return await readProviderJsonResponse<Record<string, unknown>>(response, label, opts);
}
export type FirecrawlSearchParams = {
@@ -220,11 +219,9 @@ async function postFirecrawlJson<T>(
const readJsonPayload = async (): Promise<Record<string, unknown> | null> => {
const candidate = response as Response & { clone?: () => Response };
const jsonResponse = typeof candidate.clone === "function" ? candidate.clone() : response;
if (typeof jsonResponse.json !== "function") {
return null;
}
try {
const payload = await jsonResponse.json();
const body = await readResponseText(jsonResponse, { maxBytes: 64_000 });
const payload = JSON.parse(body.text) as unknown;
return payload && typeof payload === "object" && !Array.isArray(payload)
? (payload as Record<string, unknown>)
: null;
@@ -579,7 +576,10 @@ export async function runFirecrawlScrape(
},
},
async (response) => {
const payloadLocal = await readFirecrawlJsonResponse(response, "Firecrawl fetch failed");
const payloadLocal = await readFirecrawlJsonResponse(response, "Firecrawl fetch failed", {
// Scrape can legitimately return page bodies before maxChars truncates parsed output.
maxBytes: FIRECRAWL_SCRAPE_RESPONSE_MAX_BYTES,
});
if (payloadLocal.success === false) {
const detail =
typeof payloadLocal.error === "string"
@@ -613,6 +613,7 @@ export const testing = {
assertFirecrawlScrapeTargetAllowed,
parseFirecrawlScrapePayload,
postFirecrawlJson,
readFirecrawlJsonResponse,
resolveEndpoint,
validateFirecrawlBaseUrl,
resolveSearchItems,

View File

@@ -2,6 +2,7 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { mockPinnedHostnameResolution } from "openclaw/plugin-sdk/test-env";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { createStreamingResponse } from "../../test-support/streaming-error-response.js";
import {
DEFAULT_FIRECRAWL_BASE_URL,
DEFAULT_FIRECRAWL_MAX_AGE_MS,
@@ -966,6 +967,27 @@ describe("firecrawl tools", () => {
).rejects.toThrow("Firecrawl Search API error: malformed JSON response");
});
it("bounds successful Firecrawl JSON bodies before parsing", async () => {
const streamed = createStreamingResponse({
chunkCount: 32,
chunkSize: 1024 * 1024,
text: "x",
headers: { "content-type": "application/json" },
});
const jsonSpy = vi.spyOn(streamed.response, "json").mockRejectedValue(new Error("unbounded"));
await expect(
firecrawlClientTesting.readFirecrawlJsonResponse(
streamed.response,
"Firecrawl Search API error",
),
).rejects.toThrow("Firecrawl Search API error: JSON response exceeds 16777216 bytes");
expect(streamed.getReadCount()).toBeLessThan(32);
expect(streamed.wasCanceled()).toBe(true);
expect(jsonSpy).not.toHaveBeenCalled();
});
it("reports malformed Firecrawl scrape JSON with a stable provider error", async () => {
global.fetch = vi.fn(
async () =>

View File

@@ -75,13 +75,16 @@ function mockDiscoveryResponse(spec: {
json?: unknown;
text?: string;
}) {
const status = spec.status ?? (spec.ok ? 200 : 500);
const response =
spec.json !== undefined
? new Response(JSON.stringify(spec.json), {
status,
headers: { "Content-Type": "application/json" },
})
: new Response(spec.text ?? "", { status });
fetchWithSsrFGuardMock.mockImplementationOnce(async () => ({
response: {
ok: spec.ok,
status: spec.status ?? (spec.ok ? 200 : 500),
json: async () => spec.json,
text: async () => spec.text ?? "",
},
response,
release: vi.fn(async () => {}),
}));
}
@@ -228,20 +231,16 @@ describe("githubCopilotMemoryEmbeddingProviderAdapter", () => {
it("wraps invalid discovery JSON as a setup error", async () => {
fetchWithSsrFGuardMock.mockImplementationOnce(async () => ({
response: {
ok: true,
response: new Response("not-valid-json{{{", {
status: 200,
json: async () => {
throw new SyntaxError("bad json");
},
text: async () => "",
},
headers: { "Content-Type": "application/json" },
}),
release: vi.fn(async () => {}),
}));
await expect(
githubCopilotMemoryEmbeddingProviderAdapter.create(defaultCreateOptions()),
).rejects.toThrow("GitHub Copilot model discovery returned invalid JSON");
).rejects.toThrow("github-copilot.model-discovery: malformed JSON response");
});
it("bounds model discovery error bodies", async () => {
@@ -360,7 +359,7 @@ describe("githubCopilotMemoryEmbeddingProviderAdapter", () => {
).toBe(true);
expect(
shouldContinueAutoSelection(
new Error("GitHub Copilot model discovery returned invalid JSON"),
new Error("github-copilot.model-discovery: malformed JSON response"),
),
).toBe(true);
expect(shouldContinueAutoSelection(new Error("Network timeout"))).toBe(false);

View File

@@ -7,7 +7,10 @@ import {
type MemoryEmbeddingProviderAdapter,
} from "openclaw/plugin-sdk/memory-core-host-engine-embeddings";
import { buildCopilotIdeHeaders } from "openclaw/plugin-sdk/provider-auth";
import { readResponseTextLimited } from "openclaw/plugin-sdk/provider-http";
import {
readProviderJsonResponse,
readResponseTextLimited,
} from "openclaw/plugin-sdk/provider-http";
import { resolveConfiguredSecretInputString } from "openclaw/plugin-sdk/secret-input-runtime";
import { fetchWithSsrFGuard, type SsrFPolicy } from "openclaw/plugin-sdk/ssrf-runtime";
import { resolveFirstGithubToken } from "./auth.js";
@@ -29,6 +32,7 @@ const COPILOT_HEADERS_STATIC: Record<string, string> = {
...buildCopilotIdeHeaders(),
};
const COPILOT_ERROR_BODY_LIMIT_BYTES = 8 * 1024;
const COPILOT_EMBEDDINGS_RESPONSE_MAX_BYTES = 64 * 1024 * 1024;
function buildSsrfPolicy(baseUrl: string): SsrFPolicy | undefined {
try {
@@ -70,6 +74,7 @@ function isCopilotSetupError(err: unknown): boolean {
err.message.includes("Copilot token response") ||
err.message.includes("No embedding models available") ||
err.message.includes("GitHub Copilot model discovery") ||
err.message.includes("github-copilot.model-discovery") ||
err.message.includes("GitHub Copilot embedding model") ||
err.message.includes("Unexpected response from GitHub Copilot token endpoint")
);
@@ -100,12 +105,7 @@ async function discoverEmbeddingModels(params: {
const detail = await readResponseTextLimited(response, COPILOT_ERROR_BODY_LIMIT_BYTES);
throw new Error(`GitHub Copilot model discovery HTTP ${response.status}: ${detail}`);
}
let payload: unknown;
try {
payload = await response.json();
} catch {
throw new Error("GitHub Copilot model discovery returned invalid JSON");
}
const payload = await readProviderJsonResponse(response, "github-copilot.model-discovery");
const allModels = Array.isArray((payload as { data?: unknown })?.data)
? ((payload as { data: CopilotModelEntry[] }).data ?? [])
: [];
@@ -246,12 +246,9 @@ async function createGitHubCopilotEmbeddingProvider(
throw new Error(`GitHub Copilot embeddings HTTP ${response.status}: ${detail}`);
}
let payload: unknown;
try {
payload = await response.json();
} catch {
throw new Error("GitHub Copilot embeddings returned invalid JSON");
}
const payload = await readProviderJsonResponse(response, "github-copilot.embeddings", {
maxBytes: COPILOT_EMBEDDINGS_RESPONSE_MAX_BYTES,
});
return parseGitHubCopilotEmbeddingPayload(payload, input.length);
},
});

View File

@@ -267,6 +267,47 @@ describe("fetchCopilotUsage", () => {
plan: "free",
});
});
it("bounds the usage read and cancels the stream when the body exceeds the JSON byte cap", async () => {
// Larger than the shared 16 MiB readProviderJsonResponse cap so the bounded reader cancels the
// stream mid-flight; if the cap were removed the unbounded res.json() would buffer the whole body.
const ONE_MIB = 1024 * 1024;
const TOTAL_CHUNKS = 32; // 32 MiB advertised body, double the cap.
const chunk = new Uint8Array(ONE_MIB);
let bytesPulled = 0;
let canceled = false;
const makeOversizedJsonResponse = (): Response => {
let pulled = 0;
const body = new ReadableStream<Uint8Array>({
pull(controller) {
if (pulled >= TOTAL_CHUNKS) {
controller.close();
return;
}
pulled += 1;
bytesPulled += chunk.length;
controller.enqueue(chunk);
},
cancel() {
canceled = true;
},
});
return new Response(body, {
status: 200,
headers: { "Content-Type": "application/json" },
});
};
const mockFetch = createProviderUsageFetch(async () => makeOversizedJsonResponse());
await expect(fetchCopilotUsage("token", 5000, mockFetch)).rejects.toThrow(
/github-copilot-usage: JSON response exceeds/,
);
// The bounded reader cancels the body and never pulls the full advertised 32 MiB stream.
expect(canceled).toBe(true);
expect(bytesPulled).toBeLessThan(TOTAL_CHUNKS * ONE_MIB);
});
});
describe("github-copilot token", () => {

View File

@@ -1,5 +1,6 @@
// Github Copilot plugin module implements usage behavior.
import { buildCopilotIdeHeaders } from "openclaw/plugin-sdk/provider-auth";
import { readProviderJsonResponse } from "openclaw/plugin-sdk/provider-http";
import {
buildUsageHttpErrorSnapshot,
fetchJson,
@@ -41,7 +42,10 @@ export async function fetchCopilotUsage(
});
}
const data = (await res.json()) as CopilotUsageResponse;
const data = await readProviderJsonResponse<CopilotUsageResponse>(
res,
"github-copilot-usage",
);
const windows: UsageWindow[] = [];
if (data.quota_snapshots?.premium_interactions) {

View File

@@ -94,6 +94,39 @@ function fetchInputUrl(fetchMock: ReturnType<typeof vi.fn>, index: number): stri
return input.url;
}
function oversizedJsonResponse(params: { chunkCount: number; chunkSize: number }): {
response: Response;
getReadCount: () => number;
wasCanceled: () => boolean;
} {
const chunk = new Uint8Array(params.chunkSize);
let readCount = 0;
let canceled = false;
return {
response: new Response(
new ReadableStream<Uint8Array>({
pull(controller) {
if (readCount >= params.chunkCount) {
controller.close();
return;
}
readCount += 1;
controller.enqueue(chunk);
},
cancel() {
canceled = true;
},
}),
{
status: 200,
headers: { "Content-Type": "application/json" },
},
),
getReadCount: () => readCount,
wasCanceled: () => canceled,
};
}
let ssrfMock: { mockRestore: () => void } | undefined;
describe("google video generation provider", () => {
@@ -486,6 +519,33 @@ describe("google video generation provider", () => {
expect(result.videos[0]?.buffer).toEqual(Buffer.from("rest-video"));
});
it("bounds successful Google REST operation JSON bodies instead of buffering the whole response", async () => {
vi.spyOn(providerAuthRuntime, "resolveApiKeyForProvider").mockResolvedValue({
apiKey: "google-key",
source: "env",
mode: "api-key",
});
generateVideosMock.mockRejectedValue(Object.assign(new Error("sdk 404"), { status: 404 }));
const streamed = oversizedJsonResponse({ chunkCount: 64, chunkSize: 1024 * 1024 });
const fetchMock = vi.fn(async () => streamed.response);
vi.stubGlobal("fetch", fetchMock);
const provider = buildGoogleVideoGenerationProvider();
await expect(
provider.generateVideo({
provider: "google",
model: "veo-3.1-fast-generate-preview",
prompt: "A tiny robot watering a windowsill garden",
cfg: {},
durationSeconds: 3,
}),
).rejects.toThrow("Google video operation response exceeds 16777216 bytes");
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(streamed.getReadCount()).toBeLessThan(64);
expect(streamed.wasCanceled()).toBe(true);
});
it("retries transient Google REST poll failures with empty bodies", async () => {
vi.useFakeTimers();
vi.spyOn(providerAuthRuntime, "resolveApiKeyForProvider").mockResolvedValue({

View File

@@ -28,6 +28,7 @@ const DEFAULT_TIMEOUT_MS = 180_000;
const POLL_INTERVAL_MS = 10_000;
const MAX_POLL_ATTEMPTS = 120;
const DEFAULT_GENERATED_VIDEO_MAX_BYTES = 16 * 1024 * 1024;
const GOOGLE_VIDEO_OPERATION_RESPONSE_MAX_BYTES = 16 * 1024 * 1024;
const GOOGLE_VIDEO_EMPTY_RESULT_MESSAGE =
"Google video generation response missing generated videos";
@@ -349,7 +350,15 @@ async function requestGoogleVideoJson(params: {
signal: controller.signal,
});
try {
const text = await response.text();
const buffer = await readResponseWithLimit(
response,
GOOGLE_VIDEO_OPERATION_RESPONSE_MAX_BYTES,
{
onOverflow: ({ maxBytes }) =>
new Error(`Google video operation response exceeds ${maxBytes} bytes`),
},
);
const text = new TextDecoder().decode(buffer);
if (!response.ok) {
let detail: unknown = text;
if (text) {

View File

@@ -256,6 +256,183 @@ describe("iMessage monitor last-route updates", () => {
});
});
it("keeps direct progress options when imsg lacks native typing support", async () => {
setCachedIMessagePrivateApiStatus("imsg", {
available: true,
v2Ready: true,
selectors: {},
rpcMethods: ["watch.subscribe", "send", "read"],
});
dispatchInboundMessageMock.mockImplementationOnce(async (params) => {
expect(params.replyOptions?.suppressDefaultToolProgressMessages).toBe(true);
expect(params.replyOptions?.allowProgressCallbacksWhenSourceDeliverySuppressed).toBe(true);
expect(params.replyOptions?.onToolStart).toBeUndefined();
return { queuedFinal: false, counts: { tool: 0, block: 0, final: 0 } } as const;
});
let onNotification: ((message: { method: string; params: unknown }) => void) | undefined;
const client = {
request: vi.fn(async (method: string) => {
if (method === "watch.subscribe") {
return { subscription: 1 };
}
if (method === "typing") {
throw new Error("typing should not start without native typing support");
}
throw new Error(`unexpected imsg method ${method}`);
}),
waitForClose: vi.fn(async () => {
onNotification?.({
method: "message",
params: {
message: {
id: 13,
chat_id: 123,
sender: "+15550001111",
is_from_me: false,
text: "run a long script without native typing",
is_group: false,
created_at: new Date().toISOString(),
},
},
});
await Promise.resolve();
await Promise.resolve();
}),
stop: vi.fn(async () => {}),
};
createIMessageRpcClientMock.mockImplementation(async (params) => {
if (!params?.onNotification) {
throw new Error("expected iMessage notification handler");
}
onNotification = params.onNotification;
return client as never;
});
await monitorIMessageProvider({
config: {
channels: {
imessage: {
dmPolicy: "allowlist",
allowFrom: ["+15550001111"],
sendReadReceipts: false,
},
},
messages: { inbound: { debounceMs: 0 } },
session: { mainKey: "main" },
} as never,
runtime: { error: vi.fn(), exit: vi.fn(), log: vi.fn() },
});
await vi.waitFor(() => {
expect(dispatchInboundMessageMock).toHaveBeenCalledTimes(1);
});
expect(client.request).not.toHaveBeenCalledWith(
"typing",
expect.objectContaining({ typing: true }),
expect.anything(),
);
});
it("starts direct typing before dispatching the inbound turn", async () => {
setCachedIMessagePrivateApiStatus("imsg", {
available: true,
v2Ready: true,
selectors: {},
rpcMethods: ["watch.subscribe", "send", "typing"],
});
let onNotification: ((message: { method: string; params: unknown }) => void) | undefined;
const earlyTypingClient = {
request: vi.fn(async (method: string) => {
if (method === "typing") {
return { ok: true };
}
throw new Error(`unexpected imsg typing-client method ${method}`);
}),
stop: vi.fn(async () => {}),
};
const watchClient = {
request: vi.fn(async (method: string) => {
if (method === "watch.subscribe") {
return { subscription: 1 };
}
if (method === "typing") {
return { ok: true };
}
throw new Error(`unexpected imsg watch-client method ${method}`);
}),
waitForClose: vi.fn(async () => {
onNotification?.({
method: "message",
params: {
message: {
id: 12,
chat_id: 123,
sender: "+15550001111",
is_from_me: false,
text: "respond after a slow context build",
is_group: false,
created_at: new Date().toISOString(),
},
},
});
await vi.waitFor(() => {
expect(earlyTypingClient.request).toHaveBeenCalledWith(
"typing",
expect.objectContaining({ typing: true, to: "+15550001111" }),
expect.any(Object),
);
expect(dispatchInboundMessageMock).toHaveBeenCalledTimes(1);
});
}),
stop: vi.fn(async () => {}),
};
createIMessageRpcClientMock.mockImplementation(async (params) => {
if (params?.onNotification) {
onNotification = params.onNotification;
return watchClient as never;
}
return earlyTypingClient as never;
});
dispatchInboundMessageMock.mockImplementationOnce(async () => {
expect(earlyTypingClient.request).toHaveBeenCalledWith(
"typing",
expect.objectContaining({ typing: true, to: "+15550001111" }),
expect.any(Object),
);
return { queuedFinal: false, counts: { tool: 0, block: 0, final: 0 } } as const;
});
await monitorIMessageProvider({
config: {
channels: {
imessage: {
dmPolicy: "allowlist",
allowFrom: ["+15550001111"],
sendReadReceipts: false,
},
},
messages: { inbound: { debounceMs: 0 } },
session: { mainKey: "main" },
} as never,
runtime: { error: vi.fn(), exit: vi.fn(), log: vi.fn() },
});
expect(watchClient.request).not.toHaveBeenCalledWith(
"typing",
expect.objectContaining({ typing: true }),
expect.anything(),
);
await vi.waitFor(() => {
expect(earlyTypingClient.request).toHaveBeenCalledWith(
"typing",
expect.objectContaining({ typing: false, to: "+15550001111" }),
expect.any(Object),
);
});
});
it.each(["never", "message", "thinking"] as const)(
"does not start direct tool typing when typingMode is %s",
async (typingMode) => {
@@ -420,6 +597,87 @@ describe("iMessage monitor last-route updates", () => {
);
});
it("does not wait for read receipts before dispatching the inbound turn", async () => {
setCachedIMessagePrivateApiStatus("imsg", {
available: true,
v2Ready: true,
selectors: {},
rpcMethods: ["watch.subscribe", "read"],
});
let onNotification: ((message: { method: string; params: unknown }) => void) | undefined;
const readClient = {
request: vi.fn((method: string) => {
if (method === "read") {
return new Promise(() => {});
}
return Promise.reject(new Error(`unexpected imsg read-client method ${method}`));
}),
stop: vi.fn(async () => {}),
};
const watchClient = {
request: vi.fn((method: string) => {
if (method === "watch.subscribe") {
return Promise.resolve({ subscription: 1 });
}
return Promise.reject(new Error(`unexpected imsg watch-client method ${method}`));
}),
waitForClose: vi.fn(async () => {
onNotification?.({
method: "message",
params: {
message: {
id: 11,
chat_id: 123,
sender: "+15550001111",
is_from_me: false,
text: "respond without waiting for read receipt",
is_group: false,
created_at: new Date().toISOString(),
},
},
});
await vi.waitFor(() => {
expect(dispatchInboundMessageMock).toHaveBeenCalledTimes(1);
});
}),
stop: vi.fn(async () => {}),
};
createIMessageRpcClientMock.mockImplementation(async (params) => {
if (params?.onNotification) {
onNotification = params.onNotification;
return watchClient as never;
}
return readClient as never;
});
await monitorIMessageProvider({
config: {
channels: {
imessage: {
dmPolicy: "allowlist",
allowFrom: ["+15550001111"],
},
},
messages: { inbound: { debounceMs: 0 } },
session: { mainKey: "main" },
} as never,
runtime: { error: vi.fn(), exit: vi.fn(), log: vi.fn() },
});
expect(readClient.request).toHaveBeenCalledWith(
"read",
expect.objectContaining({ to: "+15550001111" }),
expect.any(Object),
);
expect(watchClient.request).not.toHaveBeenCalledWith(
"read",
expect.anything(),
expect.anything(),
);
expect(dispatchInboundMessageMock).toHaveBeenCalledTimes(1);
});
it.each([
{
label: "flat true",

View File

@@ -1087,7 +1087,7 @@ function buildIMessageEchoScope(params: {
return scopes;
}
function buildDirectIMessageReplyTarget(params: {
export function buildDirectIMessageReplyTarget(params: {
cfg: OpenClawConfig;
accountId?: string | null;
sender: string;

View File

@@ -94,6 +94,7 @@ import {
releaseIMessageInboundReplay,
} from "./inbound-dedupe.js";
import {
buildDirectIMessageReplyTarget,
buildIMessageInboundContext,
rememberIMessageSkippedFromMeForSelfChatDedupe,
resolveIMessageReactionContext,
@@ -1039,6 +1040,87 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P
const storePath = resolveStorePath(cfg.session?.store, {
agentId: decision.route.agentId,
});
const privateApiStatus = getCachedIMessagePrivateApiStatus(cliPath);
const supportsTyping = imessageRpcSupportsMethod(privateApiStatus, "typing");
const supportsRead = imessageRpcSupportsMethod(privateApiStatus, "read");
if (privateApiStatus?.available === true) {
// Surface a single warning per restart when the bridge is up but we
// had to gate off typing/read because the imsg build pre-dates the
// capability list. Otherwise the user sees no typing bubble / no
// "Read" receipt with no visible reason.
if (!supportsTyping || !supportsRead) {
warnIfImsgUpgradeNeeded.fireOnce(privateApiStatus.rpcMethods, runtime);
}
}
const configuredTypingMode = resolveConfiguredIMessageTypingMode(cfg);
const sendPolicy = resolveSendPolicy({
cfg,
entry: getSessionEntry({ storePath, sessionKey: decision.route.sessionKey }),
sessionKey: decision.route.sessionKey,
channel: "imessage",
chatType: decision.isGroup ? "group" : "direct",
});
const shouldUseDirectToolTypingOptions =
!decision.isGroup &&
sendPolicy !== "deny" &&
(configuredTypingMode === undefined || configuredTypingMode === "instant");
const shouldStartDirectTyping = supportsTyping && shouldUseDirectToolTypingOptions;
const earlyDirectTypingTarget = shouldStartDirectTyping
? buildDirectIMessageReplyTarget({
cfg,
accountId: decision.route.accountId,
sender: decision.sender,
})
: undefined;
let stopEarlyDirectTyping: (() => void) | undefined;
if (earlyDirectTypingTarget) {
// Start channel-native feedback before the expensive history/context/model
// path. Use a short-lived client so a slow typing RPC cannot block the
// monitor client's watch stream. Stop is sequenced after start so fast
// command replies cannot leave a late true after typing:false.
const earlyDirectTypingStarted = sendIMessageTyping(earlyDirectTypingTarget, true, {
cfg,
accountId: accountInfo.accountId,
}).then(
() => true,
(err: unknown) => {
logTypingFailure({
log: (msg) => logVerbose(msg),
channel: "imessage",
action: "start",
target: earlyDirectTypingTarget,
error: err,
});
return false;
},
);
let earlyTypingStopQueued = false;
stopEarlyDirectTyping = () => {
if (earlyTypingStopQueued) {
return;
}
earlyTypingStopQueued = true;
void earlyDirectTypingStarted
.then(async (started) => {
if (!started) {
return;
}
await sendIMessageTyping(earlyDirectTypingTarget, false, {
cfg,
accountId: accountInfo.accountId,
});
})
.catch((err: unknown) => {
logTypingFailure({
log: (msg) => logVerbose(msg),
channel: "imessage",
action: "stop",
target: earlyDirectTypingTarget,
error: err,
});
});
};
}
const stagedAttachments = remoteHost
? []
: await stageIMessageAttachments(validAttachments, {
@@ -1107,31 +1189,20 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P
);
}
const privateApiStatus = getCachedIMessagePrivateApiStatus(cliPath);
const supportsTyping = imessageRpcSupportsMethod(privateApiStatus, "typing");
const supportsRead = imessageRpcSupportsMethod(privateApiStatus, "read");
if (privateApiStatus?.available === true) {
// Surface a single warning per restart when the bridge is up but we
// had to gate off typing/read because the imsg build pre-dates the
// capability list. Otherwise the user sees no typing bubble / no
// "Read" receipt with no visible reason.
if (!supportsTyping || !supportsRead) {
warnIfImsgUpgradeNeeded.fireOnce(privateApiStatus.rpcMethods, runtime);
}
}
const sendReadReceipts = imessageCfg.sendReadReceipts !== false;
const typingTarget = ctxPayload.To;
if (supportsRead && sendReadReceipts && typingTarget) {
try {
await markIMessageChatRead(typingTarget, {
cfg,
accountId: accountInfo.accountId,
client: getActiveClient(),
});
} catch (err) {
// Read receipts are best-effort channel UI. Do not put them on the
// critical path before model dispatch; slow private-API reads otherwise
// make accepted iMessage turns feel stuck before the agent starts. Use
// a short-lived client so a stuck read cannot block monitor-client typing.
void markIMessageChatRead(typingTarget, {
cfg,
accountId: accountInfo.accountId,
}).catch((err: unknown) => {
runtime.error?.(`imessage: mark read failed: ${String(err)}`);
}
});
}
const { onModelSelected, ...replyPipeline } = createChannelMessageReplyPipeline({
@@ -1234,35 +1305,27 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P
},
});
let directTypingController: IMessageTypingController | undefined;
const configuredTypingMode = resolveConfiguredIMessageTypingMode(cfg);
const sendPolicy = resolveSendPolicy({
cfg,
entry: getSessionEntry({ storePath, sessionKey: decision.route.sessionKey }),
sessionKey: decision.route.sessionKey,
channel: "imessage",
chatType: decision.isGroup ? "group" : "direct",
});
const shouldStartToolTyping =
!decision.isGroup &&
sendPolicy !== "deny" &&
(configuredTypingMode === undefined || configuredTypingMode === "instant");
const directToolTypingOptions = shouldStartToolTyping
const directToolTypingOptions = shouldUseDirectToolTypingOptions
? ({
// iMessage's native typing bubble is channel-owned UI, not a
// visible tool-progress message. The suppress flag is what lets
// dispatch forward this callback even when verbose progress is off;
// allowProgress covers message_tool_only source delivery. Keep this on
// the direct instant/default path so configured typingMode values still
// decide when typing can begin.
// the direct instant/default path even when older imsg builds do not
// report native typing support.
suppressDefaultToolProgressMessages: true,
allowProgressCallbacksWhenSourceDeliverySuppressed: true,
onTypingController: (typing: IMessageTypingController) => {
directTypingController = typing;
typingReplyOptions.onTypingController?.(typing);
},
onToolStart: async () => {
await directTypingController?.startTypingLoop();
},
...(supportsTyping
? {
onToolStart: async () => {
await directTypingController?.startTypingLoop();
},
}
: {}),
} as const)
: {};
const configuredBlockStreaming = resolveChannelStreamingBlockEnabled(accountInfo.config);
@@ -1325,11 +1388,13 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P
historyMap: groupHistories,
limit: historyLimit,
},
onPreDispatchFailure: () =>
settleReplyDispatcher({
onPreDispatchFailure: () => {
stopEarlyDirectTyping?.();
void settleReplyDispatcher({
dispatcher,
onSettled: () => markDispatchIdle(),
}),
});
},
runDispatch: async () => {
try {
return await dispatchInboundMessage({
@@ -1348,6 +1413,7 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P
});
} finally {
markDispatchIdle();
stopEarlyDirectTyping?.();
}
},
}),

View File

@@ -49,6 +49,15 @@ describe("sanitizeOutboundText", () => {
expect(result).not.toMatch(/^assistant:$/m);
});
it("preserves prose lines that merely end with 'user:'/'system:'", () => {
expect(sanitizeOutboundText("Please send this reply to the user:")).toBe(
"Please send this reply to the user:",
);
expect(sanitizeOutboundText("Here is a note for the system:")).toBe(
"Here is a note for the system:",
);
});
it("collapses excessive blank lines after stripping", () => {
const text = "Hello\n\n\n\n\nWorld";
expect(sanitizeOutboundText(text)).toBe("Hello\n\nWorld");

View File

@@ -7,7 +7,9 @@ import { stripAssistantInternalScaffolding } from "openclaw/plugin-sdk/text-chun
*/
const INTERNAL_SEPARATOR_RE = /(?:#\+){2,}#?/g;
const ASSISTANT_ROLE_MARKER_RE = /\bassistant\s+to\s*=\s*\w+/gi;
const ROLE_TURN_MARKER_RE = /\b(?:user|system|assistant)\s*:\s*$/gm;
// Only a standalone role marker on its own line (a leaked turn boundary) — not
// any line that merely ends with the word "user/system/assistant:" in prose.
const ROLE_TURN_MARKER_RE = /^[ \t]*(?:user|system|assistant)\s*:\s*$/gm;
/**
* Strip all assistant-internal scaffolding from outbound text before delivery.

View File

@@ -7,7 +7,10 @@ import {
generateSecMsGecToken,
} from "node-edge-tts/dist/drm.js";
import { isVoiceCompatibleAudio } from "openclaw/plugin-sdk/media-runtime";
import { assertOkOrThrowProviderError } from "openclaw/plugin-sdk/provider-http";
import {
assertOkOrThrowProviderError,
readProviderJsonResponse,
} from "openclaw/plugin-sdk/provider-http";
import {
captureHttpExchange,
isDebugProxyGlobalFetchPatchInstalled,
@@ -166,7 +169,10 @@ export async function listMicrosoftVoices(): Promise<SpeechVoiceOption[]> {
});
}
await assertOkOrThrowProviderError(response, "Microsoft voices API error");
const voices = (await response.json()) as MicrosoftVoiceListEntry[];
const voices = await readProviderJsonResponse<MicrosoftVoiceListEntry[]>(
response,
"microsoft.speech-voices",
);
return Array.isArray(voices)
? voices
.map((voice) => ({

View File

@@ -1,6 +1,9 @@
// Minimax plugin module implements tts behavior.
import { resolveTimerTimeoutMs } from "openclaw/plugin-sdk/number-runtime";
import { assertOkOrThrowProviderError } from "openclaw/plugin-sdk/provider-http";
import {
assertOkOrThrowProviderError,
readProviderJsonResponse,
} from "openclaw/plugin-sdk/provider-http";
import {
fetchWithSsrFGuard,
ssrfPolicyFromHttpBaseUrlAllowedHostname,
@@ -105,10 +108,10 @@ export async function minimaxTTS(params: {
try {
await assertOkOrThrowProviderError(response, "MiniMax TTS API error");
const body = (await response.json()) as {
const body = await readProviderJsonResponse<{
data?: { audio?: string };
base_resp?: { status_code?: number; status_msg?: string };
};
}>(response, "minimax.tts");
// Check base_resp for envelope errors (HTTP 200 with non-zero status_code).
// Other MiniMax providers (image, video, music, web-search) already check this.
@@ -119,9 +122,7 @@ export async function minimaxTTS(params: {
body.base_resp.status_code !== 0
) {
const msg = body.base_resp.status_msg ?? "unknown error";
throw new Error(
`MiniMax TTS API error (${body.base_resp.status_code}): ${msg}`,
);
throw new Error(`MiniMax TTS API error (${body.base_resp.status_code}): ${msg}`);
}
const hexAudio = body?.data?.audio;

View File

@@ -1,6 +1,7 @@
// Ollama tests cover embedding provider plugin behavior.
import type { OpenClawConfig } from "openclaw/plugin-sdk/provider-auth";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { createStreamingResponse } from "../../test-support/streaming-error-response.js";
const { fetchConfiguredLocalOriginWithSsrFGuardMock } = vi.hoisted(() => ({
fetchConfiguredLocalOriginWithSsrFGuardMock: vi.fn(
@@ -412,10 +413,40 @@ describe("ollama embedding provider", () => {
});
await expect(provider.embedQuery("hello")).rejects.toThrow(
"Ollama embed response returned malformed JSON",
"Ollama embed response: malformed JSON response",
);
});
it("bounds successful embed JSON bodies before parsing", async () => {
const streamed = createStreamingResponse({
chunkCount: 32,
chunkSize: 1024 * 1024,
text: "x",
headers: { "content-type": "application/json" },
});
const jsonSpy = vi.spyOn(streamed.response, "json").mockRejectedValue(new Error("unbounded"));
vi.stubGlobal(
"fetch",
vi.fn(async () => streamed.response),
);
const { provider } = await createOllamaEmbeddingProvider({
config: {} as OpenClawConfig,
provider: "ollama",
model: "nomic-embed-text",
fallback: "none",
remote: { baseUrl: "http://127.0.0.1:11434" },
});
await expect(provider.embedQuery("hello")).rejects.toThrow(
"Ollama embed response: JSON response exceeds 16777216 bytes",
);
expect(streamed.getReadCount()).toBeLessThan(32);
expect(streamed.wasCanceled()).toBe(true);
expect(jsonSpy).not.toHaveBeenCalled();
});
it("rejects non-number embedding values instead of zeroing them", async () => {
vi.stubGlobal(
"fetch",

View File

@@ -6,7 +6,10 @@ import {
normalizeOptionalSecretInput,
} from "openclaw/plugin-sdk/provider-auth";
import { resolveEnvApiKey } from "openclaw/plugin-sdk/provider-auth-runtime";
import { readResponseTextLimited } from "openclaw/plugin-sdk/provider-http";
import {
readProviderJsonResponse,
readResponseTextLimited,
} from "openclaw/plugin-sdk/provider-http";
import { normalizeProviderId } from "openclaw/plugin-sdk/provider-model-shared";
import {
hasConfiguredSecretInput,
@@ -117,14 +120,9 @@ async function withRemoteHttpResponse<T>(params: {
}
async function readOllamaEmbeddingJsonResponse(
response: Pick<Response, "json">,
response: Response,
): Promise<{ embeddings?: unknown }> {
let payload: unknown;
try {
payload = await response.json();
} catch (cause) {
throw new Error("Ollama embed response returned malformed JSON", { cause });
}
const payload = await readProviderJsonResponse<unknown>(response, "Ollama embed response");
if (typeof payload !== "object" || payload === null || Array.isArray(payload)) {
throw new Error("Ollama embed response returned a non-object JSON payload");
}

View File

@@ -1,6 +1,7 @@
// Ollama tests cover web search provider plugin behavior.
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createStreamingResponse } from "../../test-support/streaming-error-response.js";
import { createOllamaWebSearchProvider as createContractOllamaWebSearchProvider } from "../web-search-contract-api.js";
import {
testing,
@@ -403,7 +404,32 @@ describe("ollama web search provider", () => {
config: createOllamaConfig(),
query: "openclaw",
}),
).rejects.toThrow("Ollama web search returned malformed JSON");
).rejects.toThrow("Ollama web search: malformed JSON response");
});
it("bounds successful Ollama web search JSON bodies before parsing", async () => {
const streamed = createStreamingResponse({
chunkCount: 32,
chunkSize: 1024 * 1024,
text: "x",
headers: { "content-type": "application/json" },
});
const jsonSpy = vi.spyOn(streamed.response, "json").mockRejectedValue(new Error("unbounded"));
fetchWithSsrFGuardMock.mockResolvedValueOnce({
response: streamed.response,
release: vi.fn(async () => {}),
});
await expect(
runOllamaWebSearch({
config: createOllamaConfig(),
query: "openclaw",
}),
).rejects.toThrow("Ollama web search: JSON response exceeds 16777216 bytes");
expect(streamed.getReadCount()).toBeLessThan(32);
expect(streamed.wasCanceled()).toBe(true);
expect(jsonSpy).not.toHaveBeenCalled();
});
it("warns when Ollama is not reachable during setup without cancelling", async () => {

View File

@@ -5,6 +5,7 @@ import {
normalizeOptionalSecretInput,
} from "openclaw/plugin-sdk/provider-auth";
import { resolveEnvApiKey } from "openclaw/plugin-sdk/provider-auth-runtime";
import { readProviderJsonResponse } from "openclaw/plugin-sdk/provider-http";
import {
enablePluginInConfig,
readPositiveIntegerParam,
@@ -67,11 +68,7 @@ type OllamaWebSearchAttempt = {
};
async function readOllamaWebSearchResponse(response: Response): Promise<OllamaWebSearchResponse> {
try {
return (await response.json()) as OllamaWebSearchResponse;
} catch (cause) {
throw new Error("Ollama web search returned malformed JSON", { cause });
}
return await readProviderJsonResponse<OllamaWebSearchResponse>(response, "Ollama web search");
}
function isOllamaCloudBaseUrl(baseUrl: string): boolean {

View File

@@ -24,6 +24,8 @@ const { assertOkOrThrowHttpErrorMock, postJsonRequestMock, resolveProviderHttpRe
vi.mock("openclaw/plugin-sdk/provider-http", () => ({
assertOkOrThrowHttpError: assertOkOrThrowHttpErrorMock,
postJsonRequest: postJsonRequestMock,
// Pass-through: bounded-reader enforcement is tested via bounded-reader unit tests.
readProviderJsonResponse: async (response: { json(): Promise<unknown> }) => response.json(),
requireTranscriptionText: (value: string | undefined, message: string) => {
const text = value?.trim();
if (!text) {

View File

@@ -10,6 +10,7 @@ import {
import {
assertOkOrThrowHttpError,
postJsonRequest,
readProviderJsonResponse,
requireTranscriptionText,
resolveProviderHttpRequestConfig,
} from "openclaw/plugin-sdk/provider-http";
@@ -148,7 +149,10 @@ export async function transcribeOpenRouterAudio(
try {
await assertOkOrThrowHttpError(response, "OpenRouter audio transcription failed");
const payload = (await response.json()) as OpenRouterSttResponse;
const payload = await readProviderJsonResponse<OpenRouterSttResponse>(
response,
"openrouter.stt",
);
return {
text: requireTranscriptionText(
payload.text,

View File

@@ -54,13 +54,34 @@ vi.mock("openclaw/plugin-sdk/provider-http", async () => {
function releasedJson(value: unknown) {
return {
response: {
json: async () => value,
},
response: new Response(JSON.stringify(value), {
status: 200,
headers: { "content-type": "application/json" },
}),
release: vi.fn(async () => {}),
};
}
function releasedOversizedJsonStream() {
let canceled = false;
const stream = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new Uint8Array(16 * 1024 * 1024 + 1));
},
cancel() {
canceled = true;
},
});
return {
response: new Response(stream, {
status: 200,
headers: { "content-type": "application/json" },
}),
release: vi.fn(async () => {}),
wasCanceled: () => canceled,
};
}
function releasedVideo(params: { contentType: string; bytes: string }) {
return {
response: new Response(Buffer.from(params.bytes), {
@@ -292,6 +313,40 @@ describe("openrouter video generation provider", () => {
});
});
it("cancels oversized OpenRouter video catalog success bodies", async () => {
const oversized = releasedOversizedJsonStream();
fetchWithTimeoutGuardedMock.mockResolvedValueOnce(oversized);
await expect(
listOpenRouterVideoModelCatalog({
config: {
models: {
providers: {
openrouter: {
baseUrl: "https://custom.openrouter.test/openrouter/api/v1",
},
},
},
} as never,
env: {},
resolveProviderApiKey: () => ({
apiKey: "OPENROUTER_API_KEY",
discoveryApiKey: "resolved-openrouter-key",
}),
resolveProviderAuth: () => ({
apiKey: "OPENROUTER_API_KEY",
discoveryApiKey: "resolved-openrouter-key",
mode: "api_key",
source: "env",
}),
}),
).rejects.toThrow(
"OpenRouter video models request failed: JSON response exceeds 16777216 bytes",
);
expect(oversized.wasCanceled()).toBe(true);
expect(oversized.release).toHaveBeenCalledOnce();
});
it("skips live OpenRouter video catalog discovery without an API key", async () => {
await expect(
listOpenRouterVideoModelCatalog({

View File

@@ -7,6 +7,7 @@ import { resolveApiKeyForProvider } from "openclaw/plugin-sdk/provider-auth-runt
import { getCachedLiveCatalogValue } from "openclaw/plugin-sdk/provider-catalog-shared";
import {
assertOkOrThrowHttpError,
readProviderJsonResponse,
resolveProviderHttpRequestConfig,
} from "openclaw/plugin-sdk/provider-http";
import {
@@ -234,7 +235,10 @@ async function fetchOpenRouterVideoModels(params: {
});
try {
await assertOkOrThrowHttpError(response, "OpenRouter video models request failed");
return (await response.json()) as OpenRouterVideoModelsResponse;
return await readProviderJsonResponse<OpenRouterVideoModelsResponse>(
response,
"OpenRouter video models request failed",
);
} finally {
await release();
}

View File

@@ -0,0 +1,167 @@
// Openshell tests cover backend-owned exec workdir validation behavior.
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import type { CreateSandboxBackendParams } from "openclaw/plugin-sdk/sandbox";
import {
createSandboxBrowserConfig,
createSandboxPruneConfig,
createSandboxSshConfig,
} from "openclaw/plugin-sdk/test-fixtures";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createOpenShellSandboxBackendFactory } from "./backend.js";
import { resolveOpenShellPluginConfig } from "./config.js";
const sdkMocks = vi.hoisted(() => ({
runSshSandboxCommand: vi.fn(),
disposeSshSandboxSession: vi.fn(),
}));
const cliMocks = vi.hoisted(() => ({
runOpenShellCli: vi.fn(),
createOpenShellSshSession: vi.fn(),
}));
vi.mock("openclaw/plugin-sdk/sandbox", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/sandbox")>();
return {
...actual,
runSshSandboxCommand: sdkMocks.runSshSandboxCommand,
disposeSshSandboxSession: sdkMocks.disposeSshSandboxSession,
};
});
vi.mock("./cli.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("./cli.js")>();
return {
...actual,
runOpenShellCli: cliMocks.runOpenShellCli,
createOpenShellSshSession: cliMocks.createOpenShellSshSession,
};
});
const tempDirs: string[] = [];
function createOpenShellBackendSandboxConfig(): CreateSandboxBackendParams["cfg"] {
return {
mode: "all",
backend: "openshell",
scope: "session",
workspaceAccess: "rw",
workspaceRoot: "/tmp/openclaw-sandboxes",
docker: {
image: "openclaw-sandbox:bookworm-slim",
containerPrefix: "openclaw-sbx-",
workdir: "/workspace",
readOnlyRoot: false,
tmpfs: [],
network: "none",
capDrop: [],
binds: [],
env: {},
},
ssh: createSandboxSshConfig("/tmp/openclaw-sandboxes"),
browser: createSandboxBrowserConfig(),
tools: { allow: ["*"], deny: [] },
prune: createSandboxPruneConfig(),
};
}
async function makeTempDir(prefix: string) {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), prefix));
tempDirs.push(dir);
return dir;
}
describe("openshell backend exec workdir validation", () => {
beforeEach(() => {
vi.clearAllMocks();
cliMocks.createOpenShellSshSession.mockResolvedValue({
command: "ssh",
configPath: "/tmp/openclaw-openshell-test-ssh-config",
host: "openshell-test",
});
cliMocks.runOpenShellCli.mockResolvedValue({
code: 0,
stdout: "",
stderr: "",
});
sdkMocks.runSshSandboxCommand.mockImplementation(async ({ remoteCommand }) => ({
stdout: String(remoteCommand).includes("openclaw-validate-workdir")
? Buffer.from("/workspace\n")
: Buffer.alloc(0),
stderr: Buffer.alloc(0),
code: 0,
}));
});
afterEach(async () => {
await Promise.all(
tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })),
);
});
it("reuses validation-time workspace preparation for the following exec", async () => {
const workspaceDir = await makeTempDir("openclaw-openshell-workspace-");
await fs.writeFile(path.join(workspaceDir, "seed.txt"), "seed", "utf8");
const backendFactory = createOpenShellSandboxBackendFactory({
pluginConfig: resolveOpenShellPluginConfig({
command: "openshell",
mode: "mirror",
}),
});
const backend = await backendFactory({
sessionKey: "agent:main:turn",
scopeKey: "agent:main",
workspaceDir,
agentWorkspaceDir: workspaceDir,
cfg: createOpenShellBackendSandboxConfig(),
});
await expect(backend.validateWorkdir?.("/workspace")).resolves.toBe("/workspace");
const execSpec = await backend.buildExecSpec({
command: "pwd",
workdir: "/workspace",
env: {},
usePty: false,
});
const uploadCalls = cliMocks.runOpenShellCli.mock.calls.filter(
([params]) => params.args[0] === "sandbox" && params.args[1] === "upload",
);
expect(uploadCalls).toHaveLength(1);
expect(execSpec.argv).toContain("openshell-test");
});
it("does not reuse validation-time workspace preparation after discard", async () => {
const workspaceDir = await makeTempDir("openclaw-openshell-workspace-");
await fs.writeFile(path.join(workspaceDir, "seed.txt"), "seed", "utf8");
const backendFactory = createOpenShellSandboxBackendFactory({
pluginConfig: resolveOpenShellPluginConfig({
command: "openshell",
mode: "mirror",
}),
});
const backend = await backendFactory({
sessionKey: "agent:main:turn",
scopeKey: "agent:main",
workspaceDir,
agentWorkspaceDir: workspaceDir,
cfg: createOpenShellBackendSandboxConfig(),
});
await expect(backend.validateWorkdir?.("/workspace")).resolves.toBe("/workspace");
backend.discardPreparedWorkdir?.("/workspace");
await backend.buildExecSpec({
command: "pwd",
workdir: "/workspace",
env: {},
usePty: false,
});
const uploadCalls = cliMocks.runOpenShellCli.mock.calls.filter(
([params]) => params.args[0] === "sandbox" && params.args[1] === "upload",
);
expect(uploadCalls).toHaveLength(2);
});
});

View File

@@ -22,6 +22,7 @@ import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coer
import type { OpenShellSandboxBackend } from "./backend.types.js";
import {
buildValidatedExecRemoteCommand,
buildRemoteWorkdirValidationCommand,
buildRemoteCommand,
createOpenShellSshSession,
runOpenShellCli,
@@ -280,6 +281,13 @@ async function createOpenShellSandboxBackend(params: {
mode: params.pluginConfig.mode,
configLabel: params.pluginConfig.from,
configLabelKind: "Source",
workdirValidation: "backend",
validateWorkdir: async (workdir) => await impl.validateWorkdir(workdir),
discardPreparedWorkdir: (workdir) => impl.discardPreparedWorkdir(workdir),
workdirRoots: [
params.pluginConfig.remoteWorkspaceDir,
params.pluginConfig.remoteAgentWorkspaceDir,
],
buildExecSpec: async ({ command, workdir, env, usePty }) => {
const pending = await impl.prepareExec({ command, workdir, env, usePty });
return {
@@ -318,6 +326,10 @@ async function createOpenShellSandboxBackend(params: {
class OpenShellSandboxBackendImpl {
private ensurePromise: Promise<void> | null = null;
private preparedRemoteWorkspaceForNextExec: {
workdir: string;
promise: Promise<void>;
} | null = null;
private remoteSeedPending = false;
constructor(
@@ -339,6 +351,10 @@ class OpenShellSandboxBackendImpl {
mode: this.params.execContext.config.mode,
configLabel: this.params.execContext.config.from,
configLabelKind: "Source",
workdirValidation: "backend",
validateWorkdir: async (workdir) => await this.validateWorkdir(workdir),
discardPreparedWorkdir: (workdir) => this.discardPreparedWorkdir(workdir),
workdirRoots: [this.params.remoteWorkspaceDir, this.params.remoteAgentWorkspaceDir],
remoteWorkspaceDir: this.params.remoteWorkspaceDir,
remoteAgentWorkspaceDir: this.params.remoteAgentWorkspaceDir,
buildExecSpec: async ({ command, workdir, env, usePty }) => {
@@ -382,20 +398,14 @@ class OpenShellSandboxBackendImpl {
env: Record<string, string>;
usePty: boolean;
}): Promise<{ argv: string[]; token: PendingExec }> {
const remoteWorkdir = params.workdir ?? this.params.remoteWorkspaceDir;
const preparedWorkspace = this.consumePreparedRemoteWorkspaceForNextExec(remoteWorkdir);
const remoteCommand = buildValidatedExecRemoteCommand({
command: params.command,
workdir: params.workdir ?? this.params.remoteWorkspaceDir,
workdir: remoteWorkdir,
env: params.env,
});
await this.ensureSandboxExists();
if (this.params.execContext.config.mode === "mirror") {
await this.syncWorkspaceToRemote();
} else {
const seeded = await this.maybeSeedRemoteWorkspace();
if (!seeded) {
await this.syncSkillsWorkspaceToRemote();
}
}
await (preparedWorkspace ?? this.prepareRemoteWorkspaceForExec());
const sshSession = await createOpenShellSshSession({
context: this.params.execContext,
});
@@ -414,6 +424,85 @@ class OpenShellSandboxBackendImpl {
};
}
async validateWorkdir(workdir: string): Promise<string | null> {
const preparedWorkspace = this.prepareRemoteWorkspaceForExec();
const reusablePreparation = { workdir, promise: preparedWorkspace };
this.preparedRemoteWorkspaceForNextExec = reusablePreparation;
try {
await preparedWorkspace;
const sshSession = await createOpenShellSshSession({
context: this.params.execContext,
});
try {
const result = await runSshSandboxCommand({
session: sshSession,
remoteCommand: buildRemoteWorkdirValidationCommand({
workdir,
root: this.resolveWorkdirValidationRoot(workdir),
}),
allowFailure: true,
});
const resolvedWorkdir = result.code === 0 ? result.stdout.toString("utf8").trim() : "";
if (this.preparedRemoteWorkspaceForNextExec === reusablePreparation) {
this.preparedRemoteWorkspaceForNextExec = resolvedWorkdir
? { workdir: resolvedWorkdir, promise: preparedWorkspace }
: null;
}
return resolvedWorkdir || null;
} finally {
await disposeSshSandboxSession(sshSession);
}
} catch (error) {
if (this.preparedRemoteWorkspaceForNextExec === reusablePreparation) {
this.preparedRemoteWorkspaceForNextExec = null;
}
throw error;
}
}
private resolveWorkdirValidationRoot(workdir: string): string {
try {
const normalized = normalizeRemotePath(workdir);
const roots = [
normalizeRemotePath(this.params.remoteAgentWorkspaceDir),
normalizeRemotePath(this.params.remoteWorkspaceDir),
].toSorted((a, b) => b.length - a.length);
return (
roots.find((root) => isRemotePathInside(root, normalized)) ?? this.params.remoteWorkspaceDir
);
} catch {
return this.params.remoteWorkspaceDir;
}
}
private consumePreparedRemoteWorkspaceForNextExec(workdir: string): Promise<void> | null {
const preparedWorkspace = this.preparedRemoteWorkspaceForNextExec;
if (!preparedWorkspace || preparedWorkspace.workdir !== workdir) {
this.preparedRemoteWorkspaceForNextExec = null;
return null;
}
this.preparedRemoteWorkspaceForNextExec = null;
return preparedWorkspace.promise;
}
discardPreparedWorkdir(workdir: string): void {
if (this.preparedRemoteWorkspaceForNextExec?.workdir === workdir) {
this.preparedRemoteWorkspaceForNextExec = null;
}
}
private async prepareRemoteWorkspaceForExec(): Promise<void> {
await this.ensureSandboxExists();
if (this.params.execContext.config.mode === "mirror") {
await this.syncWorkspaceToRemote();
return;
}
const seeded = await this.maybeSeedRemoteWorkspace();
if (!seeded) {
await this.syncSkillsWorkspaceToRemote();
}
}
async finalizeExec(token?: PendingExec): Promise<void> {
try {
if (this.params.execContext.config.mode === "mirror") {

View File

@@ -9,6 +9,7 @@ import type { ResolvedOpenShellPluginConfig } from "./config.js";
export {
buildExecRemoteCommand,
buildRemoteWorkdirValidationCommand,
buildValidatedExecRemoteCommand,
shellEscape,
} from "openclaw/plugin-sdk/sandbox";

View File

@@ -1,4 +1,5 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createStreamingResponse } from "../../test-support/streaming-error-response.js";
type EndpointCall = {
url: string;
@@ -311,4 +312,27 @@ describe("runParallelMcpSearch", () => {
expect(tracked.wasCanceled()).toBe(true);
expect(textSpy).not.toHaveBeenCalled();
});
it("bounds successful MCP bodies without using response.text()", async () => {
const streamed = createStreamingResponse({
chunkCount: 32,
chunkSize: 1024 * 1024,
text: "x",
headers: { "Content-Type": "application/json" },
});
const textSpy = vi.spyOn(streamed.response, "text").mockRejectedValue(new Error("unbounded"));
endpointMockState.responses.push(streamed.response);
const error = await runParallelMcpSearch({ searchQueries: ["x"], maxResults: 5 }).catch(
(cause: unknown) => cause,
);
expect(error).toBeInstanceOf(Error);
expect((error as Error).message).toContain(
"Parallel MCP: text response exceeds 16777216 bytes",
);
expect(streamed.getReadCount()).toBeLessThan(32);
expect(streamed.wasCanceled()).toBe(true);
expect(textSpy).not.toHaveBeenCalled();
});
});

View File

@@ -1,7 +1,10 @@
import { randomUUID } from "node:crypto";
import { createRequire } from "node:module";
import { readPluginPackageVersion } from "openclaw/plugin-sdk/extension-shared";
import { readResponseTextLimited } from "openclaw/plugin-sdk/provider-http";
import {
readProviderTextResponse,
readResponseTextLimited,
} from "openclaw/plugin-sdk/provider-http";
import { withTrustedWebSearchEndpoint } from "openclaw/plugin-sdk/provider-web-search";
// Free hosted Search MCP. This keyless transport is used only after the user
@@ -218,7 +221,7 @@ async function postMcp(params: {
status: response.status,
statusText: response.statusText,
text: response.ok
? await response.text()
? await readProviderTextResponse(response, "Parallel MCP")
: await readResponseTextLimited(response, PARALLEL_MCP_ERROR_BODY_LIMIT_BYTES),
sessionIdHeader: response.headers.get("mcp-session-id"),
}),

View File

@@ -1,4 +1,5 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createStreamingResponse } from "../../test-support/streaming-error-response.js";
type EndpointCall = {
url: string;
@@ -59,40 +60,6 @@ function cancelTrackedResponse(
};
}
function streamedJsonResponse(params: { chunkCount: number; chunkSize: number }): {
response: Response;
getReadCount: () => number;
wasCanceled: () => boolean;
} {
// Multi-chunk fixture: proves the bounded read stops pulling chunks before
// the whole (here syntactically broken / unbounded) body is buffered, and
// that the stream is cancelled on overflow.
let reads = 0;
let canceled = false;
const encoder = new TextEncoder();
const stream = new ReadableStream<Uint8Array>({
pull(controller) {
if (reads >= params.chunkCount) {
controller.close();
return;
}
reads += 1;
controller.enqueue(encoder.encode("a".repeat(params.chunkSize)));
},
cancel() {
canceled = true;
},
});
return {
response: new Response(stream, {
status: 200,
headers: { "Content-Type": "application/json" },
}),
getReadCount: () => reads,
wasCanceled: () => canceled,
};
}
import { testing } from "../test-api.js";
import { createParallelWebSearchProvider as createContractParallelWebSearchProvider } from "../web-search-contract-api.js";
import { createParallelWebSearchProvider } from "./parallel-web-search-provider.js";
@@ -621,7 +588,12 @@ describe("parallel web search provider", () => {
// 200-chunk x 1 MiB body (~200 MiB) caps at 16 MiB: the bounded reader must
// stop pulling chunks and cancel the stream well before draining it, then
// surface a bounded error rather than buffering the whole payload.
const streamed = streamedJsonResponse({ chunkCount: 200, chunkSize: 1024 * 1024 });
const streamed = createStreamingResponse({
chunkCount: 200,
chunkSize: 1024 * 1024,
text: "a",
headers: { "Content-Type": "application/json" },
});
endpointMockState.responses.push(streamed.response);
const provider = createParallelWebSearchProvider();
const tool = provider.createTool({

View File

@@ -1,3 +1,4 @@
import { readProviderJsonResponse } from "openclaw/plugin-sdk/provider-http";
// Perplexity provider module implements model/runtime integration.
import {
readPositiveIntegerParam,
@@ -142,11 +143,7 @@ function buildPerplexityRequestHeaders(apiKey: string, acceptJson = false): Reco
}
async function readPerplexityJsonResponse<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 await readProviderJsonResponse<T>(response, label);
}
function resolvePerplexityTransport(perplexity?: PerplexityConfig): {

View File

@@ -1,6 +1,7 @@
// Perplexity tests cover perplexity web search provider plugin behavior.
import { withEnv, withEnvAsync } from "openclaw/plugin-sdk/test-env";
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import { createStreamingResponse } from "../../test-support/streaming-error-response.js";
import { createPerplexityWebSearchProvider } from "./perplexity-web-search-provider.js";
import { testing } from "./perplexity-web-search-provider.runtime.js";
@@ -171,4 +172,22 @@ describe("perplexity web search provider", () => {
testing.readPerplexityJsonResponse(new Response("{ nope"), "Perplexity"),
).rejects.toThrow("Perplexity: malformed JSON response");
});
it("bounds successful Perplexity JSON bodies before parsing", async () => {
const streamed = createStreamingResponse({
chunkCount: 32,
chunkSize: 1024 * 1024,
text: "x",
headers: { "content-type": "application/json" },
});
const jsonSpy = vi.spyOn(streamed.response, "json").mockRejectedValue(new Error("unbounded"));
await expect(
testing.readPerplexityJsonResponse(streamed.response, "Perplexity Search"),
).rejects.toThrow("Perplexity Search: JSON response exceeds 16777216 bytes");
expect(streamed.getReadCount()).toBeLessThan(32);
expect(streamed.wasCanceled()).toBe(true);
expect(jsonSpy).not.toHaveBeenCalled();
});
});

View File

@@ -168,23 +168,42 @@ describe("runtime parity", () => {
const scoped = __testing.filterMockRequestsForParentPrompt(
[
{
prompt: "Fanout worker alpha: inspect the QA workspace and finish with exactly ALPHA-OK.",
allInputText:
"Delegate one bounded QA task to a subagent. Fanout worker alpha: inspect the QA workspace and finish with exactly ALPHA-OK.",
plannedToolName: "read",
},
{
prompt: "Delegate one bounded QA task to a subagent.",
allInputText: "Delegate one bounded QA task to a subagent.",
plannedToolName: "sessions_spawn",
},
{
prompt: "Continue the bounded QA task with the retained child result.",
allInputText:
"Delegate one bounded QA task to a subagent. Continue the bounded QA task with the retained child result.",
plannedToolName: "sessions_spawn",
},
{
allInputText: "Inspect the QA workspace and return one concise protocol note.",
plannedToolName: "read",
},
{
prompt: "Delegate one bounded QA task to a subagent.",
allInputText: "Delegate one bounded QA task to a subagent. Tool result: child accepted.",
toolOutput: "child accepted",
},
],
"Delegate one bounded QA task to a subagent.",
[
"Delegate one bounded QA task to a subagent.",
"Continue the bounded QA task with the retained child result.",
],
);
expect(scoped).toHaveLength(2);
expect(scoped).toHaveLength(3);
expect(scoped.map((request) => request.plannedToolName ?? "result")).toEqual([
"sessions_spawn",
"sessions_spawn",
"result",
]);

View File

@@ -120,6 +120,7 @@ type RuntimeParityTranscriptRecord = {
};
type RuntimeParityMockRequestSnapshot = {
prompt?: string;
allInputText?: string;
plannedToolName?: string;
plannedToolArgs?: unknown;
@@ -759,14 +760,22 @@ function resolveRuntimeParityToolCalls(params: {
function filterMockRequestsForParentPrompt(
requests: RuntimeParityMockRequestSnapshot[],
parentPrompt: string,
parentPrompts: readonly string[] = [parentPrompt],
) {
const normalizedParentPrompt = normalizeTextForParity(parentPrompt);
if (!normalizedParentPrompt) {
const normalizedParentPrompts = parentPrompts
.map(normalizeTextForParity)
.filter((prompt) => prompt.length > 0);
if (normalizedParentPrompts.length === 0) {
return requests;
}
const matching = requests.filter((request) =>
normalizeTextForParity(request.allInputText ?? "").includes(normalizedParentPrompt),
);
const matching = requests.filter((request) => {
const normalizedPrompt = normalizeTextForParity(request.prompt ?? "");
if (normalizedPrompt) {
return normalizedParentPrompts.some((prompt) => normalizedPrompt.includes(prompt));
}
const normalizedHistory = normalizeTextForParity(request.allInputText ?? "");
return normalizedParentPrompts.some((prompt) => normalizedHistory.includes(prompt));
});
return matching.length > 0 ? matching : requests;
}
@@ -966,6 +975,7 @@ async function loadRuntimeParityTranscripts(params: {
async function loadRuntimeParityMockToolCalls(
mockBaseUrl: string | undefined,
parentPrompt: string,
parentPrompts: readonly string[] = [parentPrompt],
): Promise<RuntimeParityToolCall[] | null> {
const normalizedBaseUrl = mockBaseUrl?.trim().replace(/\/+$/u, "");
if (!normalizedBaseUrl) {
@@ -991,6 +1001,7 @@ async function loadRuntimeParityMockToolCalls(
}
const requests = payload.filter(isMessageRecord).map(
(entry): RuntimeParityMockRequestSnapshot => ({
prompt: readNonEmptyString(entry.prompt),
allInputText: readNonEmptyString(entry.allInputText),
plannedToolName: readNonEmptyString(entry.plannedToolName),
plannedToolArgs: entry.plannedToolArgs ?? null,
@@ -998,7 +1009,7 @@ async function loadRuntimeParityMockToolCalls(
}),
);
return resolveToolCallOrderFromMockRequests(
filterMockRequestsForParentPrompt(requests, parentPrompt),
filterMockRequestsForParentPrompt(requests, parentPrompt, parentPrompts),
);
} catch {
return null;
@@ -1015,12 +1026,16 @@ export async function captureRuntimeParityCell(
});
const transcriptRecords = buildTranscriptRecords(transcriptBytes);
const transcriptToolCalls = resolveToolCallOrder(transcriptRecords);
const parentPrompt =
transcriptRecords
.filter((record) => record.role === "user" && !isToolResultLikeMessage(record.message))
.map((record) => extractAssistantText(record.message))
.find(Boolean) ?? "";
const mockToolCalls = await loadRuntimeParityMockToolCalls(params.mockBaseUrl, parentPrompt);
const parentPrompts = transcriptRecords
.filter((record) => record.role === "user")
.map((record) => extractAssistantText(record.message))
.filter((prompt) => prompt.length > 0);
const parentPrompt = parentPrompts[0] ?? "";
const mockToolCalls = await loadRuntimeParityMockToolCalls(
params.mockBaseUrl,
parentPrompt,
parentPrompts,
);
const gatewayLogs = params.gateway.logs?.();
const sentinelFindings = [
...scanGatewayLogSentinels(gatewayLogs),

View File

@@ -1,5 +1,6 @@
// Qqbot tests cover api-client plugin behavior.
import { afterEach, describe, expect, it, vi } from "vitest";
import { createStreamingResponse } from "../../../../test-support/streaming-error-response.js";
const fetchWithSsrFGuardMock = vi.hoisted(() => vi.fn());
@@ -88,4 +89,35 @@ describe("ApiClient", () => {
},
});
});
it("bounds successful response bodies without using response.text()", async () => {
const release = vi.fn(async () => {});
const streamed = createStreamingResponse({
chunkCount: 32,
chunkSize: 1024 * 1024,
text: "x",
headers: { "content-type": "application/json" },
});
const textSpy = vi.spyOn(streamed.response, "text").mockRejectedValue(new Error("unbounded"));
fetchWithSsrFGuardMock.mockResolvedValueOnce({
response: streamed.response,
release,
});
const client = new ApiClient({ baseUrl: "https://qqbot.test" });
let error: unknown;
try {
await client.request("token-1", "GET", "/v2/users/@me");
} catch (caught) {
error = caught;
}
expect(error).toBeInstanceOf(ApiError);
expect(String(error)).toContain("QQBot API response: text response exceeds 16777216 bytes");
expect(streamed.getReadCount()).toBeLessThan(32);
expect(streamed.wasCanceled()).toBe(true);
expect(textSpy).not.toHaveBeenCalled();
expect(release).toHaveBeenCalledTimes(1);
});
});

View File

@@ -9,7 +9,10 @@
* - `redactBodyKeys` replaces the hardcoded `file_data` redaction.
*/
import { readResponseTextLimited } from "openclaw/plugin-sdk/provider-http";
import {
readProviderTextResponse,
readResponseTextLimited,
} from "openclaw/plugin-sdk/provider-http";
import { fetchWithSsrFGuard, type SsrFPolicy } from "openclaw/plugin-sdk/ssrf-runtime";
import { ApiError, type ApiClientConfig, type EngineLogger } from "../types.js";
import { formatErrorMessage } from "../utils/format.js";
@@ -162,7 +165,7 @@ export class ApiClient {
const readBody = async (limitBytes?: number): Promise<string> => {
try {
return limitBytes === undefined
? await res.text()
? await readProviderTextResponse(res, "QQBot API response")
: await readResponseTextLimited(res, limitBytes);
} catch (err) {
throw new ApiError(

View File

@@ -1,5 +1,6 @@
// Qqbot tests cover channel-api tool behavior.
import { afterEach, describe, expect, it, vi } from "vitest";
import { createStreamingResponse } from "../../../../test-support/streaming-error-response.js";
const fetchWithSsrFGuardMock = vi.hoisted(() => vi.fn());
@@ -109,4 +110,33 @@ describe("executeChannelApi", () => {
expect(textSpy).not.toHaveBeenCalled();
expect(release).toHaveBeenCalledTimes(1);
});
it("bounds successful response bodies without using response.text()", async () => {
const release = vi.fn(async () => {});
const streamed = createStreamingResponse({
chunkCount: 32,
chunkSize: 1024 * 1024,
text: "x",
headers: { "content-type": "application/json" },
});
const textSpy = vi.spyOn(streamed.response, "text").mockRejectedValue(new Error("unbounded"));
fetchWithSsrFGuardMock.mockResolvedValueOnce({
response: streamed.response,
release,
});
const result = await executeChannelApi(
{ method: "GET", path: "/guilds/123/channels" },
{ accessToken: "token-1" },
);
expect(result.details).toMatchObject({
error: "QQ channel API response: text response exceeds 16777216 bytes",
path: "/guilds/123/channels",
});
expect(streamed.getReadCount()).toBeLessThan(32);
expect(streamed.wasCanceled()).toBe(true);
expect(textSpy).not.toHaveBeenCalled();
expect(release).toHaveBeenCalledTimes(1);
});
});

View File

@@ -8,7 +8,10 @@
* validation, fetch, and structured response formatting.
*/
import { readResponseTextLimited } from "openclaw/plugin-sdk/provider-http";
import {
readProviderTextResponse,
readResponseTextLimited,
} from "openclaw/plugin-sdk/provider-http";
import { fetchWithSsrFGuard, type SsrFPolicy } from "openclaw/plugin-sdk/ssrf-runtime";
import { formatErrorMessage } from "../utils/format.js";
import { debugLog, debugError } from "../utils/log.js";
@@ -216,7 +219,7 @@ export async function executeChannelApi(
debugLog(`[qqbot-channel-api] <<< Status: ${res.status} ${res.statusText}`);
const rawBody = res.ok
? await res.text()
? await readProviderTextResponse(res, "QQ channel API response")
: await readResponseTextLimited(res, CHANNEL_API_ERROR_BODY_LIMIT_BYTES);
if (!rawBody || rawBody.trim() === "") {
if (res.ok) {

View File

@@ -8,6 +8,39 @@ import { describeQwenVideo } from "./media-understanding-provider.js";
installPinnedHostnameTestHooks();
function oversizedJsonResponse(params: { chunkCount: number; chunkSize: number }): {
response: Response;
getReadCount: () => number;
wasCanceled: () => boolean;
} {
const chunk = new Uint8Array(params.chunkSize);
let readCount = 0;
let canceled = false;
return {
response: new Response(
new ReadableStream<Uint8Array>({
pull(controller) {
if (readCount >= params.chunkCount) {
controller.close();
return;
}
readCount += 1;
controller.enqueue(chunk);
},
cancel() {
canceled = true;
},
}),
{
status: 200,
headers: { "Content-Type": "application/json" },
},
),
getReadCount: () => readCount,
wasCanceled: () => canceled,
};
}
describe("describeQwenVideo", () => {
it("builds the expected OpenAI-compatible video payload", async () => {
const { fetchFn, getRequest } = createRequestCaptureJsonFetch({
@@ -74,4 +107,42 @@ describe("describeQwenVideo", () => {
`data:video/mp4;base64,${Buffer.from("video-bytes").toString("base64")}`,
);
});
it("bounds successful Qwen video JSON bodies instead of buffering the whole response", async () => {
const streamed = oversizedJsonResponse({ chunkCount: 64, chunkSize: 1024 * 1024 });
await expect(
describeQwenVideo({
buffer: Buffer.from("video-bytes"),
fileName: "clip.mp4",
mime: "video/mp4",
apiKey: "test-key",
timeoutMs: 1500,
baseUrl: "https://example.com/v1",
fetchFn: async () => streamed.response,
}),
).rejects.toThrow("Qwen video description failed: JSON response exceeds 16777216 bytes");
expect(streamed.getReadCount()).toBeLessThan(64);
expect(streamed.wasCanceled()).toBe(true);
});
it("reports malformed Qwen video JSON with a provider-owned error", async () => {
const response = new Response("not-json{", {
status: 200,
headers: { "Content-Type": "application/json" },
});
await expect(
describeQwenVideo({
buffer: Buffer.from("video-bytes"),
fileName: "clip.mp4",
mime: "video/mp4",
apiKey: "test-key",
timeoutMs: 1500,
baseUrl: "https://example.com/v1",
fetchFn: async () => response,
}),
).rejects.toThrow("Qwen video description failed: malformed JSON response");
});
});

View File

@@ -13,6 +13,7 @@ import {
import {
assertOkOrThrowHttpError,
postJsonRequest,
readProviderJsonResponse,
resolveProviderHttpRequestConfig,
} from "openclaw/plugin-sdk/provider-http";
import { QWEN_STANDARD_GLOBAL_BASE_URL } from "./models.js";
@@ -60,7 +61,14 @@ export async function describeQwenVideo(
try {
await assertOkOrThrowHttpError(res, "Qwen video description failed");
const payload = (await res.json()) as OpenAiCompatibleVideoPayload;
// Read the success body through the shared byte-bounded JSON reader (16 MiB cap +
// stream cancel on overflow) so a hostile or buggy endpoint cannot force the runtime
// to buffer an unbounded body. Malformed JSON keeps the
// `Qwen video description failed: malformed JSON response` wrapping.
const payload = await readProviderJsonResponse<OpenAiCompatibleVideoPayload>(
res,
"Qwen video description failed",
);
const text = coerceOpenAiCompatibleVideoText(payload);
if (!text) {
throw new Error("Qwen video description response missing content");

View File

@@ -2,23 +2,12 @@
import { spawn, type ChildProcess } from "node:child_process";
import { createHash, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
import type { EventEmitter } from "node:events";
import {
createServer,
type IncomingMessage,
type Server,
type ServerResponse,
} from "node:http";
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
import type { Socket } from "node:net";
import {
keepHttpServerTaskAlive,
waitUntilAbort,
} from "openclaw/plugin-sdk/channel-outbound";
import type { ChannelGatewayContext } from "openclaw/plugin-sdk/channel-contract";
import { keepHttpServerTaskAlive, waitUntilAbort } from "openclaw/plugin-sdk/channel-outbound";
import { KeyedAsyncQueue } from "openclaw/plugin-sdk/keyed-async-queue";
import {
createClaimableDedupe,
type ClaimableDedupe,
} from "openclaw/plugin-sdk/persistent-dedupe";
import { createClaimableDedupe, type ClaimableDedupe } from "openclaw/plugin-sdk/persistent-dedupe";
import { RAFT_CHANNEL_ID, type ResolvedRaftAccount } from "./accounts.js";
import { dispatchRaftWake } from "./inbound.js";
@@ -54,11 +43,7 @@ type RaftBridgeProcess = Pick<ChildProcess, "kill"> & Pick<EventEmitter, "once">
type RaftGatewayDeps = {
createToken?: () => string;
spawnBridge?: (params: {
profile: string;
endpoint: string;
token: string;
}) => RaftBridgeProcess;
spawnBridge?: (params: { profile: string; endpoint: string; token: string }) => RaftBridgeProcess;
wakeDedupe?: ClaimableDedupe;
};
@@ -80,6 +65,8 @@ function spawnRaftBridge(params: {
endpoint: string;
token: string;
}): RaftBridgeProcess {
// Raft owns the fixed bridge command. OpenClaw passes profile/loopback
// endpoint/token as separate argv/env fields; wake payloads never reach argv.
return spawn(
"raft",
[
@@ -247,7 +234,7 @@ export async function startRaftGatewayAccount(
onDiskError: (error) => {
ctx.log?.warn?.(`Raft wake dedupe storage failed: ${String(error)}`);
},
});
});
const token = (deps.createToken ?? createToken)();
const runtimeSession = randomUUID();
const sockets = new Set<Socket>();

View File

@@ -116,6 +116,8 @@ function buildDaemonArgs(opts: SignalDaemonOpts): string[] {
export function spawnSignalDaemon(opts: SignalDaemonOpts): SignalDaemonHandle {
const args = buildDaemonArgs(opts);
// The executable is operator-selected or setup-discovered signal-cli.
// Runtime message content only flows through the daemon HTTP API, not argv.
const child = spawn(opts.cliPath, args, {
stdio: ["ignore", "pipe", "pipe"],
});

View File

@@ -1,5 +1,6 @@
// Tavily tests cover tavily client plugin behavior.
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { createStreamingResponse } from "../../test-support/streaming-error-response.js";
// Capture every call to postTrustedWebToolsJson so we can assert on extraHeaders.
const postTrustedWebToolsJson = vi.fn();
@@ -61,6 +62,29 @@ describe("tavily client X-Client-Source header", () => {
);
});
it("bounds successful Tavily JSON bodies before parsing", async () => {
const streamed = createStreamingResponse({
chunkCount: 32,
chunkSize: 1024 * 1024,
text: "x",
headers: { "content-type": "application/json" },
});
const jsonSpy = vi.spyOn(streamed.response, "json").mockRejectedValue(new Error("unbounded"));
postTrustedWebToolsJson.mockImplementationOnce(
async (_params: unknown, parse: (r: Response) => Promise<unknown>) =>
parse(streamed.response),
);
await expect(runTavilySearch({ query: "test query" })).rejects.toThrow(
"Tavily Search: JSON response exceeds 16777216 bytes",
);
expect(streamed.getReadCount()).toBeLessThan(32);
expect(streamed.wasCanceled()).toBe(true);
expect(jsonSpy).not.toHaveBeenCalled();
});
it("runTavilyExtract sends X-Client-Source: openclaw", async () => {
await runTavilyExtract({ urls: ["https://example.com"] });

View File

@@ -1,5 +1,6 @@
// Tavily plugin module implements tavily client behavior.
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { readProviderJsonResponse } from "openclaw/plugin-sdk/provider-http";
import {
DEFAULT_CACHE_TTL_MINUTES,
normalizeCacheKey,
@@ -26,6 +27,7 @@ const EXTRACT_CACHE = new Map<
{ value: Record<string, unknown>; expiresAt: number; insertedAt: number }
>();
const DEFAULT_SEARCH_COUNT = 5;
const TAVILY_EXTRACT_RESPONSE_MAX_BYTES = 64 * 1024 * 1024;
export type TavilySearchParams = {
cfg?: OpenClawConfig;
@@ -73,6 +75,7 @@ async function postTavilyJson(params: {
apiKey: string;
body: Record<string, unknown>;
errorLabel: string;
responseMaxBytes?: number;
}): Promise<Record<string, unknown>> {
return postTrustedWebToolsJson(
{
@@ -83,19 +86,19 @@ async function postTavilyJson(params: {
errorLabel: params.errorLabel,
extraHeaders: { "X-Client-Source": "openclaw" },
},
async (response) => readTavilyJsonResponse(response, params.errorLabel),
async (response) =>
readTavilyJsonResponse(response, params.errorLabel, {
maxBytes: params.responseMaxBytes,
}),
);
}
async function readTavilyJsonResponse(
response: Response,
label: string,
opts?: { maxBytes?: number },
): Promise<Record<string, unknown>> {
try {
return (await response.json()) as Record<string, unknown>;
} catch (cause) {
throw new Error(`${label}: malformed JSON response`, { cause });
}
return await readProviderJsonResponse<Record<string, unknown>>(response, label, opts);
}
export async function runTavilySearch(
@@ -255,6 +258,8 @@ export async function runTavilyExtract(
apiKey,
body,
errorLabel: "Tavily Extract",
// Extract can include raw page content and image lists, unlike search metadata.
responseMaxBytes: TAVILY_EXTRACT_RESPONSE_MAX_BYTES,
});
const rawResults = Array.isArray(payload.results) ? payload.results : [];

View File

@@ -833,6 +833,7 @@ export const telegramPlugin = createChatChannelPlugin({
targetResolver: {
looksLikeId: looksLikeTelegramTargetId,
hint: "<chatId>",
reservedLiterals: ["current", "self", "this", "me"],
},
},
resolver: {

View File

@@ -807,16 +807,16 @@ describe("createTelegramDraftStream", () => {
expectNthPreviewSend(api, 2, "foo bar baz qux");
});
it("clamps a first oversized non-final preview", async () => {
it("clamps a first oversized non-final preview on a UTF-16 boundary", async () => {
const api = createMockDraftApi();
const stream = createDraftStream(api, { maxChars: 10 });
stream.update("1234567890ABCDEFGHIJ");
stream.update("123456789😀tail");
await stream.flush();
expect(api.sendMessage).toHaveBeenCalledTimes(1);
expectNthPreviewSend(api, 1, "1234567890");
expect(stream.lastDeliveredText?.()).toBe("1234567890");
expectNthPreviewSend(api, 1, "123456789");
expect(stream.lastDeliveredText?.()).toBe("123456789");
});
it("finalizes overflow that was hidden by a clamped non-final preview", async () => {

View File

@@ -5,6 +5,7 @@ import {
takeMessageIdAfterStop,
} from "openclaw/plugin-sdk/channel-outbound";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { sliceUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
import { buildTelegramThreadParams, type TelegramThreadSpec } from "./bot/helpers.js";
import { renderTelegramHtmlText, telegramHtmlToPlainTextFallback } from "./format.js";
import {
@@ -169,7 +170,7 @@ function findTelegramDraftChunkLength(
high = mid - 1;
}
}
return best;
return sliceUtf16Safe(text, 0, best).length;
}
export function createTelegramDraftStream(params: {

View File

@@ -418,7 +418,7 @@ type TestTelegramUpdate = {
update_id: number;
message: {
text: string;
chat: { id: number; type: "supergroup" };
chat: { id: number; type: "private" | "supergroup" };
message_thread_id?: number;
is_topic_message?: boolean;
};
@@ -436,6 +436,16 @@ function topicUpdate(updateId: number, threadId: number, text: string): TestTele
};
}
function directUpdate(updateId: number, chatId: number, text: string): TestTelegramUpdate {
return {
update_id: updateId,
message: {
text,
chat: { id: chatId, type: "private" },
},
};
}
async function waitForAbortSignal(signal: AbortSignal): Promise<void> {
if (signal.aborted) {
return;
@@ -1795,6 +1805,93 @@ describe("TelegramPollingSession", () => {
});
});
for (const scenario of [
{
name: "topic",
conflict: topicUpdate(42, 10, "retryable session init conflict"),
blocked: topicUpdate(43, 10, "same topic must wait behind retry backoff"),
other: topicUpdate(44, 11, "other topic can continue"),
conflictEvent: "topic10:conflict",
blockedEvent: "topic10:overtook",
otherEvent: "topic11",
error: "reply session initialization conflicted for agent:main:telegram:group:-100:topic:10",
},
{
name: "direct message",
conflict: directUpdate(42, 100, "retryable session init conflict"),
blocked: directUpdate(43, 100, "same DM must wait behind retry backoff"),
other: directUpdate(44, 101, "other DM can continue"),
conflictEvent: "dm100:conflict",
blockedEvent: "dm100:overtook",
otherEvent: "dm101",
error: "reply session initialization conflicted for agent:main:telegram:direct:100",
},
]) {
it(`backs off retryable reply session init conflicts for ${scenario.name} lanes`, async () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
try {
await withTempSpool(async (tempDir) => {
const abort = new AbortController();
const log = vi.fn();
let attempts = 0;
const events: string[] = [];
await writeSpooledTestUpdates(tempDir, [
scenario.conflict,
scenario.blocked,
scenario.other,
]);
const { runPromise, stopWorker } = startIsolatedIngressSession({
abort,
spoolDir: tempDir,
log,
drainIntervalMs: 100,
handleUpdate: async (update) => {
if (update.update_id === scenario.conflict.update_id) {
attempts += 1;
events.push(`${scenario.conflictEvent}:${attempts}`);
throw new Error(scenario.error);
}
if (update.update_id === scenario.blocked.update_id) {
events.push(scenario.blockedEvent);
return;
}
if (update.update_id === scenario.other.update_id) {
events.push(scenario.otherEvent);
}
},
});
await vi.waitFor(() => expect(attempts).toBe(1));
await vi.advanceTimersByTimeAsync(1_000);
expect(attempts).toBe(1);
await vi.waitFor(() =>
expect(events).toEqual([`${scenario.conflictEvent}:1`, scenario.otherEvent]),
);
expect(await pendingUpdateIds(tempDir, "all")).toEqual([
scenario.conflict.update_id,
scenario.blocked.update_id,
]);
expect(await failedUpdateIds(tempDir)).toEqual([]);
await vi.advanceTimersByTimeAsync(4_500);
await vi.waitFor(() => expect(attempts).toBe(2));
expect(events).not.toContain(scenario.blockedEvent);
expectLogIncludes(
log,
`spooled update ${scenario.conflict.update_id} failed; keeping for retry`,
);
abort.abort();
stopWorker();
await runPromise;
});
} finally {
vi.useRealTimers();
}
});
}
it("dead-letters wrapped missing harness failures", async () => {
await withTempSpool(async (tempDir) => {
const abort = new AbortController();

View File

@@ -131,11 +131,14 @@ const TELEGRAM_SPOOLED_HANDLER_ABORT_GRACE_MS = 5_000;
const TELEGRAM_SPOOLED_HANDLER_TIMEOUT_ENV = "OPENCLAW_TELEGRAM_SPOOLED_HANDLER_TIMEOUT_MS";
const TELEGRAM_SPOOLED_DRAIN_START_LIMIT = 100;
const TELEGRAM_SPOOLED_DRAIN_SCAN_LIMIT = TELEGRAM_SPOOLED_DRAIN_START_LIMIT * 10;
const TELEGRAM_SPOOLED_SESSION_INIT_CONFLICT_RETRY_BASE_MS = 5_000;
const TELEGRAM_SPOOLED_SESSION_INIT_CONFLICT_RETRY_MAX_MS = 60_000;
const TELEGRAM_POLLING_CLIENT_TIMEOUT_FLOOR_SECONDS = Math.ceil(
TELEGRAM_GET_UPDATES_REQUEST_TIMEOUT_MS / 1000,
);
const MISSING_AGENT_HARNESS_ERROR_NAME = "MissingAgentHarnessError";
const MISSING_AGENT_HARNESS_MESSAGE_RE = /Requested agent harness "[^"]+" is not registered\./u;
const REPLY_SESSION_INIT_CONFLICT_MESSAGE_RE = /reply session initialization conflicted for \S+/u;
function normalizeTelegramAccountId(accountId?: string | null): string {
return accountId?.trim() || "default";
@@ -169,6 +172,24 @@ function resolveNonRetryableSpooledUpdateFailure(
return null;
}
function resolveSpooledUpdateRetryDelayMs(update: TelegramSpooledUpdate, now = Date.now()): number {
const attempts = update.attempts ?? 0;
if (
!update.lastError ||
!REPLY_SESSION_INIT_CONFLICT_MESSAGE_RE.test(update.lastError) ||
update.lastAttemptAt === undefined ||
attempts <= 0
) {
return 0;
}
const exponent = Math.min(attempts - 1, 8);
const delayMs = Math.min(
TELEGRAM_SPOOLED_SESSION_INIT_CONFLICT_RETRY_MAX_MS,
TELEGRAM_SPOOLED_SESSION_INIT_CONFLICT_RETRY_BASE_MS * 2 ** exponent,
);
return Math.max(0, update.lastAttemptAt + delayMs - now);
}
type TelegramBot = ReturnType<typeof createTelegramBot>;
const waitForGracefulStop = async (stop: () => Promise<void>) => {
@@ -777,7 +798,9 @@ export class TelegramPollingSession {
}
}
try {
await releaseTelegramSpooledUpdateClaim(params.update);
await releaseTelegramSpooledUpdateClaim(params.update, {
lastError: formatErrorMessage(params.err),
});
} catch (releaseErr) {
this.opts.log(
`[telegram][diag] spooled update ${params.update.updateId} failed and could not be requeued: ${formatErrorMessage(releaseErr)}`,
@@ -865,6 +888,10 @@ export class TelegramPollingSession {
if (this.opts.abortSignal?.aborted) {
break;
}
if (resolveSpooledUpdateRetryDelayMs(update) > 0) {
claimedLaneKeys.add(laneKey);
continue;
}
const handlerKey = buildSpooledUpdateHandlerKey({ spoolDir: params.spoolDir, laneKey });
if (activeSpooledUpdateHandlersByLane.has(handlerKey)) {
blockedByLane.add(handlerKey);
@@ -1533,6 +1560,7 @@ export const testing = {
createTelegramRestartBackoffState,
resetTelegramRestartBackoffState,
resolveTelegramRestartDelayMs,
resolveSpooledUpdateRetryDelayMs,
resolveSpooledUpdateHandlerAbortGraceMs: (valueMs: unknown): number =>
resolvePositiveTimerTimeoutMs(valueMs, TELEGRAM_SPOOLED_HANDLER_ABORT_GRACE_MS),
};

View File

@@ -38,6 +38,9 @@ export type TelegramSpooledUpdate = {
path: string;
update: unknown;
receivedAt: number;
attempts?: number;
lastAttemptAt?: number;
lastError?: string;
claim?: TelegramSpooledUpdateClaimOwner;
};
@@ -166,6 +169,9 @@ function parseQueueRecord(
path: pendingPath(spoolDir, payload.updateId),
update: payload.update,
receivedAt: payload.receivedAt,
attempts: record.attempts,
...(record.lastAttemptAt === undefined ? {} : { lastAttemptAt: record.lastAttemptAt }),
...(record.lastError === undefined ? {} : { lastError: record.lastError }),
};
}
@@ -267,9 +273,11 @@ export async function claimTelegramSpooledUpdate(
export async function releaseTelegramSpooledUpdateClaim(
update: ClaimedTelegramSpooledUpdate,
options?: { lastError?: string; releasedAt?: number },
): Promise<void> {
await createTelegramIngressQueue(path.dirname(update.pendingPath)).release(
queueMutationTarget(update),
options,
);
}

View File

@@ -1,11 +1,21 @@
// Test Support plugin module implements streaming error response behavior.
export function createStreamingErrorResponse(params: {
status: number;
// Test Support plugin module implements streaming response fixtures.
export type StreamingResponseFixture = {
response: Response;
getReadCount: () => number;
wasCanceled: () => boolean;
};
export function createStreamingResponse(params: {
status?: number;
chunkCount: number;
chunkSize: number;
byte: number;
}): { response: Response; getReadCount: () => number } {
byte?: number;
text?: string;
headers?: HeadersInit;
}): StreamingResponseFixture {
let reads = 0;
let canceled = false;
const encoder = new TextEncoder();
const stream = new ReadableStream<Uint8Array>({
pull(controller) {
if (reads >= params.chunkCount) {
@@ -13,11 +23,28 @@ export function createStreamingErrorResponse(params: {
return;
}
reads += 1;
controller.enqueue(new Uint8Array(params.chunkSize).fill(params.byte));
const chunk =
params.text !== undefined
? encoder.encode(params.text.repeat(params.chunkSize))
: new Uint8Array(params.chunkSize).fill(params.byte ?? 120);
controller.enqueue(chunk);
},
cancel() {
canceled = true;
},
});
return {
response: new Response(stream, { status: params.status }),
response: new Response(stream, { status: params.status ?? 200, headers: params.headers }),
getReadCount: () => reads,
wasCanceled: () => canceled,
};
}
export function createStreamingErrorResponse(params: {
status: number;
chunkCount: number;
chunkSize: number;
byte: number;
}): StreamingResponseFixture {
return createStreamingResponse(params);
}

View File

@@ -0,0 +1,217 @@
// Voyage batch tests cover bounded status/error response reads.
import { describe, expect, it } from "vitest";
import type { VoyageEmbeddingClient } from "./embedding-provider.js";
import { testing } from "./embedding-batch.js";
const { fetchVoyageBatchStatus, readVoyageBatchError, VOYAGE_BATCH_RESPONSE_MAX_BYTES } = testing;
function buildClient(): VoyageEmbeddingClient {
return {
baseUrl: "https://api.voyageai.test/v1",
headers: { authorization: "Bearer test" },
model: "voyage-3",
};
}
/**
* Build deps whose withRemoteHttpResponse drives the real onResponse against a
* caller-provided Response, so the bounded readers run exactly as in production.
*/
function buildDeps(response: Response): Parameters<typeof fetchVoyageBatchStatus>[0]["deps"] {
return {
now: () => 0,
sleep: async () => {},
postJsonWithRetry: (async () => {
throw new Error("postJsonWithRetry should not be called in these tests");
}) as never,
uploadBatchJsonlFile: (async () => {
throw new Error("uploadBatchJsonlFile should not be called in these tests");
}) as never,
withRemoteHttpResponse: (async (params: { onResponse: (res: Response) => Promise<unknown> }) =>
await params.onResponse(response)) as never,
};
}
/**
* A streaming JSON-ish body that proves an oversized response stops being read
* before the whole advertised payload is buffered into memory. getReadCount
* reports how many chunks were pulled; cancel() flips wasCanceled.
*/
function streamingResponse(params: { chunkCount: number; chunkSize: number; status?: number }): {
response: Response;
getReadCount: () => number;
wasCanceled: () => boolean;
} {
let reads = 0;
let canceled = false;
const encoder = new TextEncoder();
const stream = new ReadableStream<Uint8Array>({
pull(controller) {
if (reads >= params.chunkCount) {
controller.close();
return;
}
reads += 1;
controller.enqueue(encoder.encode("a".repeat(params.chunkSize)));
},
cancel() {
canceled = true;
},
});
return {
response: new Response(stream, {
status: params.status ?? 200,
headers: { "content-type": "application/json" },
}),
getReadCount: () => reads,
wasCanceled: () => canceled,
};
}
describe("voyage batch bounded reads", () => {
it("uses a 16 MiB cap for batch status/error responses", () => {
expect(VOYAGE_BATCH_RESPONSE_MAX_BYTES).toBe(16 * 1024 * 1024);
});
it("parses a well-formed batch status response under the byte cap", async () => {
const response = new Response(JSON.stringify({ id: "batch_1", status: "completed" }), {
status: 200,
headers: { "content-type": "application/json" },
});
const status = await fetchVoyageBatchStatus({
client: buildClient(),
batchId: "batch_1",
deps: buildDeps(response),
});
expect(status).toEqual({ id: "batch_1", status: "completed" });
});
it("caps an oversized batch status stream instead of buffering the whole body", async () => {
const streamed = streamingResponse({ chunkCount: 64, chunkSize: 1024 });
await expect(
fetchVoyageBatchStatus({
client: buildClient(),
batchId: "batch_1",
deps: buildDeps(streamed.response),
maxResponseBytes: 4096,
}),
).rejects.toThrow(/voyage-batch-status: JSON response exceeds 4096 bytes/);
// Stream was cancelled mid-flight: fewer chunks read than the full payload.
expect(streamed.getReadCount()).toBeLessThan(64);
expect(streamed.wasCanceled()).toBe(true);
});
it("preserves the full NDJSON parse chain for an under-cap error file", async () => {
// Multi-line NDJSON with a blank line proves the bounded read does not
// disturb the original trim/split("\n")/JSON.parse/extractBatchErrorMessage
// pipeline: the first useful error message is still extracted byte-for-byte
// identically to the pre-change `await res.text()` path.
const body = [
JSON.stringify({ custom_id: "req-0", response: { status_code: 200 } }),
"",
JSON.stringify({ custom_id: "req-1", error: { message: "voyage upstream rejected" } }),
JSON.stringify({ custom_id: "req-2", error: { message: "second error ignored" } }),
"",
].join("\n");
const response = new Response(body, {
status: 200,
headers: { "content-type": "application/x-ndjson" },
});
const message = await readVoyageBatchError({
client: buildClient(),
errorFileId: "file_1",
deps: buildDeps(response),
});
// extractBatchErrorMessage returns the first line carrying a message, so the
// success line is skipped and the second error is not surfaced.
expect(message).toBe("voyage upstream rejected");
});
it("returns undefined for an empty error file via the original empty-body branch", async () => {
// Whitespace-only body must still hit the `!text.trim()` short-circuit after
// decoding the bounded buffer, returning undefined exactly as before.
const response = new Response(" \n", {
status: 200,
headers: { "content-type": "application/x-ndjson" },
});
const message = await readVoyageBatchError({
client: buildClient(),
errorFileId: "file_1",
deps: buildDeps(response),
});
expect(message).toBeUndefined();
});
it("fail-softs an oversized error file into formatUnavailableBatchError by design", async () => {
const streamed = streamingResponse({ chunkCount: 64, chunkSize: 1024 });
// Intended behavior: an over-cap error file must NOT throw out of
// readVoyageBatchError. An unbounded error body would otherwise OOM the
// worker, so the bounded overflow error is caught and degraded into a
// diagnostic string via formatUnavailableBatchError. We accept the lost
// detail; the overflow message names the cap so the truncation is visible.
const readError = async () =>
await readVoyageBatchError({
client: buildClient(),
errorFileId: "file_1",
deps: buildDeps(streamed.response),
maxResponseBytes: 4096,
});
await expect(readError()).resolves.toMatch(
/error file unavailable: voyage batch error file content exceeds 4096 bytes/,
);
// The bounded reader still cancels the stream mid-flight rather than
// buffering the whole advertised payload before failing soft.
expect(streamed.getReadCount()).toBeLessThan(64);
expect(streamed.wasCanceled()).toBe(true);
});
it("caps an oversized non-OK (error) diagnostic body instead of buffering it whole", async () => {
// Regression for the non-OK gap: `assertVoyageResponseOk` previously read the
// 4xx/5xx diagnostic body with an unbounded `await res.text()`. A hostile
// endpoint can return a 500 with a never-ending body, so that read must be
// bounded too. Drive a streaming 500 through the real status path and assert
// the bounded overflow error fires and the stream is cancelled mid-flight.
const streamed = streamingResponse({ chunkCount: 64, chunkSize: 1024, status: 500 });
await expect(
fetchVoyageBatchStatus({
client: buildClient(),
batchId: "batch_1",
deps: buildDeps(streamed.response),
maxResponseBytes: 4096,
}),
).rejects.toThrow(/voyage batch status failed: 500 \(error body exceeds 4096 bytes\)/);
// Stream was cancelled mid-flight rather than draining the whole body.
expect(streamed.getReadCount()).toBeLessThan(64);
expect(streamed.wasCanceled()).toBe(true);
});
it("preserves the diagnostic shape for a small non-OK (error) body", async () => {
// Under-cap non-OK body must still surface the original
// `${context}: ${status} ${text}` diagnostic byte-for-byte.
const response = new Response("voyage upstream is down", {
status: 503,
headers: { "content-type": "text/plain" },
});
await expect(
fetchVoyageBatchStatus({
client: buildClient(),
batchId: "batch_1",
deps: buildDeps(response),
}),
).rejects.toThrow(/voyage batch status failed: 503 voyage upstream is down/);
});
});

View File

@@ -21,6 +21,8 @@ import {
uploadBatchJsonlFile,
withRemoteHttpResponse,
} from "openclaw/plugin-sdk/memory-core-host-engine-embeddings";
import { readProviderJsonResponse } from "openclaw/plugin-sdk/provider-http";
import { readResponseWithLimit } from "openclaw/plugin-sdk/response-limit-runtime";
import { normalizeStringEntries } from "openclaw/plugin-sdk/string-coerce-runtime";
import type { VoyageEmbeddingClient } from "./embedding-provider.js";
@@ -41,6 +43,10 @@ type VoyageBatchOutputLine = ProviderBatchOutputLine;
const VOYAGE_BATCH_ENDPOINT = EMBEDDING_BATCH_ENDPOINT;
const VOYAGE_BATCH_COMPLETION_WINDOW = "12h";
const VOYAGE_BATCH_MAX_REQUESTS = 50000;
// Voyage batch status/error responses are untrusted external bodies. Cap them
// the same way other bundled providers do (16 MiB) so a misbehaving or hostile
// endpoint cannot stream an unbounded body into memory before we parse it.
const VOYAGE_BATCH_RESPONSE_MAX_BYTES = 16 * 1024 * 1024;
type VoyageBatchDeps = {
now: () => number;
@@ -65,9 +71,23 @@ function resolveVoyageBatchDeps(overrides: Partial<VoyageBatchDeps> | undefined)
};
}
async function assertVoyageResponseOk(res: Response, context: string): Promise<void> {
async function assertVoyageResponseOk(
res: Response,
context: string,
maxBytes: number = VOYAGE_BATCH_RESPONSE_MAX_BYTES,
): Promise<void> {
if (!res.ok) {
const text = await res.text();
// The non-OK diagnostic body is just as untrusted as the success body: a
// misbehaving or hostile endpoint can return a 4xx/5xx with an unbounded
// body, and the old `await res.text()` buffered it whole before we threw.
// Read it through the same bounded reader (16 MiB cap, stream cancelled on
// overflow) while preserving the original `${context}: ${status} ${text}`
// diagnostic shape for backward compatibility.
const bytes = await readResponseWithLimit(res, maxBytes, {
onOverflow: ({ maxBytes: maxBytesLocal }) =>
new Error(`${context}: ${res.status} (error body exceeds ${maxBytesLocal} bytes)`),
});
const text = new TextDecoder().decode(bytes);
throw new Error(`${context}: ${res.status} ${text}`);
}
}
@@ -127,14 +147,18 @@ async function fetchVoyageBatchStatus(params: {
client: VoyageEmbeddingClient;
batchId: string;
deps: VoyageBatchDeps;
maxResponseBytes?: number;
}): Promise<VoyageBatchStatus> {
const maxBytes = params.maxResponseBytes ?? VOYAGE_BATCH_RESPONSE_MAX_BYTES;
return await params.deps.withRemoteHttpResponse(
buildVoyageBatchRequest({
client: params.client,
path: `batches/${params.batchId}`,
onResponse: async (res) => {
await assertVoyageResponseOk(res, "voyage batch status failed");
return (await res.json()) as VoyageBatchStatus;
await assertVoyageResponseOk(res, "voyage batch status failed", maxBytes);
return await readProviderJsonResponse<VoyageBatchStatus>(res, "voyage-batch-status", {
maxBytes,
});
},
}),
);
@@ -144,15 +168,21 @@ async function readVoyageBatchError(params: {
client: VoyageEmbeddingClient;
errorFileId: string;
deps: VoyageBatchDeps;
maxResponseBytes?: number;
}): Promise<string | undefined> {
const maxBytes = params.maxResponseBytes ?? VOYAGE_BATCH_RESPONSE_MAX_BYTES;
try {
return await params.deps.withRemoteHttpResponse(
buildVoyageBatchRequest({
client: params.client,
path: `files/${params.errorFileId}/content`,
onResponse: async (res) => {
await assertVoyageResponseOk(res, "voyage batch error file content failed");
const text = await res.text();
await assertVoyageResponseOk(res, "voyage batch error file content failed", maxBytes);
const bytes = await readResponseWithLimit(res, maxBytes, {
onOverflow: ({ maxBytes: maxBytesLocal }) =>
new Error(`voyage batch error file content exceeds ${maxBytesLocal} bytes`),
});
const text = new TextDecoder().decode(bytes);
if (!text.trim()) {
return undefined;
}
@@ -280,10 +310,9 @@ export async function runVoyageEmbeddingBatches(
headers: buildBatchHeaders(params.client, { json: true }),
},
onResponse: async (contentRes) => {
if (!contentRes.ok) {
const text = await contentRes.text();
throw new Error(`voyage batch file content failed: ${contentRes.status} ${text}`);
}
// Same bounded non-OK diagnostic read as the status/error-file paths:
// the failure body is untrusted, so cap it instead of `await text()`.
await assertVoyageResponseOk(contentRes, "voyage batch file content failed");
if (!contentRes.body) {
return;
@@ -316,3 +345,9 @@ export async function runVoyageEmbeddingBatches(
},
});
}
export const testing = {
fetchVoyageBatchStatus,
readVoyageBatchError,
VOYAGE_BATCH_RESPONSE_MAX_BYTES,
} as const;

View File

@@ -8,8 +8,9 @@ import {
assertOkOrThrowHttpError,
buildAudioTranscriptionFormData,
postTranscriptionRequest,
resolveProviderHttpRequestConfig,
readProviderJsonResponse,
requireTranscriptionText,
resolveProviderHttpRequestConfig,
} from "openclaw/plugin-sdk/provider-http";
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
import { XAI_BASE_URL } from "./model-definitions.js";
@@ -68,7 +69,7 @@ export async function transcribeXaiAudio(
try {
await assertOkOrThrowHttpError(response, "xAI audio transcription failed");
const payload = (await response.json()) as XaiSttResponse;
const payload = await readProviderJsonResponse<XaiSttResponse>(response, "xai.stt");
return {
text: requireTranscriptionText(payload.text, "xAI transcription response missing text"),
...(model ? { model } : {}),

View File

@@ -16,6 +16,18 @@ if [[ ! -f "$FILTER_FILES" ]]; then
exit 1
fi
GIT_DIR="$(git rev-parse --git-dir 2>/dev/null || true)"
if [[ -n "$GIT_DIR" ]] && \
{ [[ -f "$GIT_DIR/MERGE_HEAD" ]] || \
[[ -f "$GIT_DIR/CHERRY_PICK_HEAD" ]] || \
[[ -f "$GIT_DIR/REVERT_HEAD" ]] || \
[[ -f "$GIT_DIR/REBASE_HEAD" ]] || \
[[ -d "$GIT_DIR/rebase-merge" ]] || \
[[ -d "$GIT_DIR/rebase-apply" ]]; }; then
# Sequencer commits stage the operation result, not just the user's local edits.
exit 0
fi
# Security: avoid option-injection from malicious file names (e.g. "--all", "--force").
# Robustness: NUL-delimited file list handles spaces/newlines safely.
# Compatibility: use read loops instead of `mapfile` so this runs on macOS Bash 3.x.

View File

@@ -34,6 +34,19 @@ describe("acp session manager", () => {
expect(store.getSessionByRunId("run-1")).toBeUndefined();
});
it("removes stale run lookup entries when rebinding an active run", () => {
const session = store.createSession({
sessionKey: "acp:rebind",
cwd: "/tmp",
});
store.setActiveRun(session.sessionId, "run-old", new AbortController());
store.setActiveRun(session.sessionId, "run-new", new AbortController());
expect(store.getSessionByRunId("run-old")).toBeUndefined();
expect(store.getSessionByRunId("run-new")?.sessionId).toBe(session.sessionId);
});
it("deletes sessions and aborts active runs on close", () => {
const session = store.createSession({
sessionId: "close-me",

View File

@@ -150,6 +150,9 @@ export function createInMemorySessionStore(options: AcpSessionStoreOptions = {})
if (!session) {
return;
}
if (session.activeRunId && session.activeRunId !== runId) {
runIdToSessionId.delete(session.activeRunId);
}
session.activeRunId = runId;
session.abortController = abortController;
runIdToSessionId.set(runId, sessionId);

View File

@@ -0,0 +1,11 @@
// Agent Core tests cover prompt template argument parsing behavior.
import { describe, expect, it } from "vitest";
import { parseCommandArgs, substituteArgs } from "./prompt-template-arguments.js";
describe("prompt template arguments", () => {
it("preserves quoted empty arguments so positional placeholders stay aligned", () => {
expect(parseCommandArgs('first "" third')).toEqual(["first", "", "third"]);
expect(parseCommandArgs("first '' third")).toEqual(["first", "", "third"]);
expect(substituteArgs("$1|$2|$3", parseCommandArgs('first "" third'))).toBe("first||third");
});
});

View File

@@ -5,26 +5,31 @@ export function parseCommandArgs(argsString: string): string[] {
const args: string[] = [];
let current = "";
let inQuote: string | null = null;
let hasToken = false;
for (const char of argsString) {
if (inQuote) {
if (char === inQuote) {
inQuote = null;
} else {
hasToken = true;
current += char;
}
} else if (char === '"' || char === "'") {
hasToken = true;
inQuote = char;
} else if (/\s/.test(char)) {
if (current) {
if (hasToken) {
args.push(current);
current = "";
hasToken = false;
}
} else {
hasToken = true;
current += char;
}
}
if (current) {
if (hasToken) {
args.push(current);
}
return args;

View File

@@ -55,4 +55,27 @@ describe("media-generation catalog", () => {
}),
).toEqual(["video-default", "video-pro"]);
});
it("marks a trimmed default model as the catalog default", () => {
expect(
synthesizeMediaGenerationCatalogEntries({
kind: "video_generation",
provider: {
id: "example",
defaultModel: " video-default ",
models: ["video-default"],
capabilities: {},
},
}),
).toEqual([
{
kind: "video_generation",
provider: "example",
model: "video-default",
source: "static",
default: true,
capabilities: {},
},
]);
});
});

View File

@@ -51,6 +51,7 @@ export function synthesizeMediaGenerationCatalogEntries<TCapabilities>(params: {
provider: MediaGenerationCatalogProvider<TCapabilities>;
modes?: readonly string[];
}): Array<MediaGenerationCatalogEntry<TCapabilities>> {
const defaultModel = uniqueTrimmedStrings([params.provider.defaultModel])[0];
return uniqueModels(params.provider).map((model) => {
const entry: MediaGenerationCatalogEntry<TCapabilities> = {
kind: params.kind,
@@ -62,7 +63,7 @@ export function synthesizeMediaGenerationCatalogEntries<TCapabilities>(params: {
if (params.provider.label) {
entry.label = params.provider.label;
}
if (model === params.provider.defaultModel) {
if (model === defaultModel) {
entry.default = true;
}
if (params.modes) {

View File

@@ -0,0 +1,100 @@
// Media Understanding Common tests cover provider output extraction behavior.
import { describe, expect, it } from "vitest";
import { extractGeminiResponse } from "./output-extract.js";
describe("extractGeminiResponse", () => {
it("extracts the response from noisy output with nested JSON objects", () => {
expect(
extractGeminiResponse(
[
"debug: invoking gemini",
JSON.stringify({
response: "a useful description",
usage: {
inputTokens: 12,
outputTokens: 4,
},
}),
].join("\n"),
),
).toBe("a useful description");
});
it("returns null for an incomplete JSON object", () => {
expect(extractGeminiResponse("{")).toBeNull();
});
it("ignores unmatched quotes in noisy output before the JSON object", () => {
expect(extractGeminiResponse('debug: model said "hello\n{"response":"ok"}')).toBe("ok");
});
it("ignores braces inside quoted noisy output", () => {
expect(extractGeminiResponse('debug: "hello { world" {"response":"ok"}')).toBe("ok");
});
it("ignores shell-quoted JSON-like noisy output", () => {
expect(extractGeminiResponse('debug: \'{"response":"fake"}\'')).toBeNull();
});
it("does not treat apostrophes inside noisy words as quote delimiters", () => {
expect(extractGeminiResponse('debug: it\'s done {"response":"ok"}')).toBe("ok");
});
it("resynchronizes after an unmatched brace in noisy output", () => {
expect(extractGeminiResponse('debug: generated {\n{"response":"ok"}')).toBe("ok");
});
it("preserves brace-heavy response text", () => {
const response = "{".repeat(33);
expect(extractGeminiResponse(JSON.stringify({ response }))).toBe(response);
});
it("extracts pretty-printed JSON output", () => {
expect(
extractGeminiResponse(
JSON.stringify(
{
response: "pretty response",
usage: { inputTokens: 12 },
},
null,
2,
),
),
).toBe("pretty response");
});
it("preserves pretty-printed object elements inside arrays", () => {
expect(
extractGeminiResponse(
JSON.stringify(
{
response: "array response",
items: [{ id: 1 }, { id: 2 }],
},
null,
2,
),
),
).toBe("array response");
});
it("does not accept an inner response from a malformed trailing object", () => {
expect(extractGeminiResponse('{"response":"good"} {"meta":{"response":"bad"} broken}')).toBe(
"good",
);
expect(extractGeminiResponse('{"response":"good"} {"meta":{"response":"bad"}')).toBe("good");
});
it("ignores a nested response inside an unfinished outer object", () => {
expect(extractGeminiResponse('noise {"meta":{"response":"bad"}')).toBeNull();
});
it("does not promote a child from a malformed outer object", () => {
expect(extractGeminiResponse('{"response":"good"} {"meta" {"response":"bad"}}')).toBe("good");
expect(extractGeminiResponse('noise {broken {"response":"bad"}}')).toBeNull();
expect(extractGeminiResponse('{"response":"good"}\nnoise {broken\n{"response":"bad"}}')).toBe(
"good",
);
});
});

View File

@@ -3,16 +3,119 @@
/** Parse the last JSON object in a noisy provider output string. */
function extractLastJsonObject(raw: string): unknown {
const trimmed = raw.trim();
const start = trimmed.lastIndexOf("{");
if (start === -1) {
return null;
const ranges: Array<{ end: number; start: number }> = [];
const starts: number[] = [];
let inString = false;
let escaped = false;
let preambleQuote: string | undefined;
let preambleEscaped = false;
let previousSignificant: string | undefined;
let lineHasNonWhitespace = false;
let arrayDepth = 0;
let candidateHasContent = false;
for (let index = 0; index < trimmed.length; index += 1) {
const character = trimmed[index];
if (inString) {
if (character === "\n" || character === "\r") {
starts.length = 0;
inString = false;
escaped = false;
} else if (escaped) {
escaped = false;
} else if (character === "\\") {
escaped = true;
} else if (character === '"') {
inString = false;
}
continue;
}
if (starts.length === 0) {
if (preambleQuote !== undefined) {
if (character === "\n" || character === "\r") {
preambleQuote = undefined;
preambleEscaped = false;
} else if (preambleEscaped) {
preambleEscaped = false;
} else if (character === "\\") {
preambleEscaped = true;
} else if (character === preambleQuote) {
preambleQuote = undefined;
}
continue;
}
if (character === '"' || character === "'" || character === "`") {
const previous = trimmed[index - 1];
if (previous === undefined || /[\s:([{]/.test(previous)) {
preambleQuote = character;
preambleEscaped = false;
continue;
}
}
if (character === "{") {
arrayDepth = 0;
candidateHasContent = false;
starts.push(index);
}
if (!/\s/.test(character)) {
previousSignificant = character;
lineHasNonWhitespace = true;
} else if (character === "\n" || character === "\r") {
lineHasNonWhitespace = false;
}
continue;
}
const hadCandidateContent = candidateHasContent;
if (character === '"') {
inString = true;
} else if (character === "{") {
if (
previousSignificant === ":" ||
previousSignificant === "[" ||
previousSignificant === '"' ||
(previousSignificant === "," && (lineHasNonWhitespace || arrayDepth > 0))
) {
starts.push(index);
} else if (!lineHasNonWhitespace && !hadCandidateContent) {
// Only resync at a clean record boundary; otherwise keep malformed
// outer objects from promoting diagnostic payloads as valid results.
starts.length = 1;
starts[0] = index;
arrayDepth = 0;
candidateHasContent = false;
}
} else if (character === "}" && starts.length > 0) {
const start = starts.pop();
if (start !== undefined && starts.length === 0) {
ranges.push({ start, end: index });
}
} else if (character === "[") {
arrayDepth += 1;
} else if (character === "]" && arrayDepth > 0) {
arrayDepth -= 1;
}
if (!/\s/.test(character)) {
candidateHasContent = true;
previousSignificant = character;
lineHasNonWhitespace = true;
} else if (character === "\n" || character === "\r") {
lineHasNonWhitespace = false;
}
}
const slice = trimmed.slice(start);
try {
return JSON.parse(slice);
} catch {
return null;
for (let index = ranges.length - 1; index >= 0; index -= 1) {
const range = ranges[index];
try {
return JSON.parse(trimmed.slice(range.start, range.end + 1));
} catch {
// Ignore malformed objects and try the previous completed range.
}
}
return null;
}
/** Extract Gemini CLI-style response text from the last JSON object in output. */

View File

@@ -108,7 +108,7 @@ flow:
- lambda:
params: [text]
expr: "config.expectedReplyGroups.every((group) => group.some((needle) => normalizeLowercaseStringOrEmpty(text).includes(needle)))"
- expr: "env.providerMode === 'mock-openai' ? 10000 : 30000"
- expr: "30000"
- expr: "env.providerMode === 'mock-openai' ? 100 : 250"
- if:
expr: "Boolean(env.mock)"
@@ -240,7 +240,11 @@ flow:
message:
expr: "lastError instanceof Error ? formatErrorMessage(lastError) : String(lastError ?? 'fanout retry exhausted')"
- if:
expr: "Boolean(env.mock)"
# Codex completes child sessions through its app-server path but
# does not relay the child marker back onto the parent QA channel.
# The shared assertions above already prove both child tool calls
# and child session rows; keep this transport-only proof OpenClaw-specific.
expr: "Boolean(env.mock) && env.gateway.runtimeEnv.OPENCLAW_QA_FORCE_RUNTIME !== 'codex'"
then:
- forEach:
items:
@@ -253,5 +257,5 @@ flow:
- lambda:
params: [candidate]
expr: "String(candidate.text ?? '').trim() === childCompletionMarker"
- 10000
- 30000
detailsExpr: "details"

View File

@@ -26,7 +26,9 @@ scenario:
config:
sessionKey: agent:qa:long-context-cache-stability
fixtureFile: large-cache-fixture.txt
cacheEvidenceNeedle: CACHE-FIXTURE-0550
cacheEvidenceNeedle: CACHE-FIXTURE-0050
cacheEvidenceLine: "CACHE-FIXTURE-0050: stable tool-result evidence for prompt-cache reuse across long sessions."
followupPromptNeedle: Using the already-read
warmupMarker: QA-LARGE-CACHE-WARMUP-OK
hitMarker: QA-LARGE-CACHE-HIT-OK
@@ -84,8 +86,17 @@ flow:
- set: debugRequests
value:
expr: "env.mock ? [...(await fetchJson(`${env.mock.baseUrl}/debug/requests`))] : []"
- set: cappedReadOutputIndex
value:
expr: "debugRequests.reduce((found, planned, index) => { if (found >= 0 || !planned.plannedToolCallId || planned.plannedToolName !== 'read' || planned.plannedToolArgs?.path !== config.fixtureFile) return found; const outputOffset = debugRequests.slice(index + 1).findIndex((candidate) => Boolean(candidate.toolOutputCallId) && candidate.toolOutputCallId === planned.plannedToolCallId); if (outputOffset < 0) return found; const output = debugRequests[index + 1 + outputOffset]; const evidence = [planned.allInputText, output.allInputText, output.toolOutput].filter((value) => typeof value === 'string').join('\\n'); const hasCodexFormattedTruncation = evidence.includes('Warning: truncated output') && (evidence.includes('chars truncated') || evidence.includes('tokens truncated')); return evidence.includes(config.cacheEvidenceLine) && (evidence.includes('[Read output capped at 50KB') || evidence.includes('...(OpenClaw truncated dynamic tool result') || evidence.includes('...(truncated)...') || hasCodexFormattedTruncation) ? index + 1 + outputOffset : found; }, -1)"
- set: hasCappedReadEvidence
value:
expr: "cappedReadOutputIndex >= 0"
- set: hasFollowupCacheEvidence
value:
expr: "cappedReadOutputIndex >= 0 && debugRequests.some((request, index) => index > cappedReadOutputIndex && String(request.prompt ?? '').includes(config.followupPromptNeedle) && String(request.allInputText ?? '').includes(config.cacheEvidenceLine))"
- assert:
expr: "!env.mock || debugRequests.some((request, index) => request.plannedToolName === 'read' && request.plannedToolArgs?.path === config.fixtureFile && typeof request.plannedToolCallId === 'string' && debugRequests.slice(index + 1).some((result, resultOffset) => result.toolOutputCallId === request.plannedToolCallId && String(result.toolOutput ?? '').includes(config.cacheEvidenceNeedle) && (String(result.toolOutput ?? '').includes('[Read output capped at 50KB') || (String(result.toolOutput ?? '').includes('...(truncated)...') && String(result.toolOutput ?? '').length <= 13000)) && debugRequests.slice(index + resultOffset + 2).some((followup) => followup.plannedToolName === 'read' && followup.plannedToolArgs?.path === config.fixtureFile && String(followup.allInputText ?? '').includes(config.cacheEvidenceNeedle) && (String(followup.allInputText ?? '').includes('[Read output capped at 50KB') || String(followup.allInputText ?? '').includes('...(truncated)...')))))"
expr: "!env.mock || (hasCappedReadEvidence && hasFollowupCacheEvidence)"
message:
expr: "`large capped read tool result was not observed: ${JSON.stringify(debugRequests.slice(-8).map((request) => ({ plannedToolName: request.plannedToolName ?? null, plannedToolArgs: request.plannedToolArgs ?? null, plannedToolCallId: request.plannedToolCallId ?? null, toolOutputCallId: request.toolOutputCallId ?? null, toolOutputLength: String(request.toolOutput ?? '').length, toolOutputHasNeedle: String(request.toolOutput ?? '').includes(config.cacheEvidenceNeedle), toolOutputHasReadCap: String(request.toolOutput ?? '').includes('[Read output capped at 50KB'), toolOutputHasCodexTruncation: String(request.toolOutput ?? '').includes('...(truncated)...'), inputHasNeedle: String(request.allInputText ?? '').includes(config.cacheEvidenceNeedle), inputHasReadCap: String(request.allInputText ?? '').includes('[Read output capped at 50KB'), inputHasCodexTruncation: String(request.allInputText ?? '').includes('...(truncated)...') })))}`"
expr: "`large capped read cache evidence was not observed: ${JSON.stringify({ hasCappedReadEvidence, hasFollowupCacheEvidence, requests: debugRequests.slice(-8).map((request) => ({ prompt: request.prompt ?? null, plannedToolName: request.plannedToolName ?? null, plannedToolArgs: request.plannedToolArgs ?? null, plannedToolCallId: request.plannedToolCallId ?? null, toolOutputCallId: request.toolOutputCallId ?? null, toolOutputLength: String(request.toolOutput ?? '').length, outputHasReadCap: String(request.toolOutput ?? '').includes('[Read output capped at 50KB'), outputHasCodexTruncation: String(request.toolOutput ?? '').includes('...(truncated)...'), inputHasEvidenceLine: String(request.allInputText ?? '').includes(config.cacheEvidenceLine) })) })}`"
detailsExpr: "outbound?.text ?? config.hitMarker"

View File

@@ -13,6 +13,11 @@ HOST_BUILD="${OPENCLAW_CODEX_ON_DEMAND_HOST_BUILD:-1}"
PACKAGE_TGZ="${OPENCLAW_CURRENT_PACKAGE_TGZ:-}"
run_log=""
# This lane installs the package and then exercises a managed npm install of Codex.
# Keep the package install budget above the shared default so slow npm hosts reach
# the Codex assertions instead of failing as a silent package-install timeout.
export OPENCLAW_E2E_NPM_INSTALL_TIMEOUT="${OPENCLAW_E2E_NPM_INSTALL_TIMEOUT:-1200s}"
cleanup() {
if [ -n "${PACKAGE_TGZ:-}" ]; then
docker_e2e_cleanup_package_tgz "$PACKAGE_TGZ"

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