Compare commits

..

179 Commits

Author SHA1 Message Date
Vincent Koc
cef282cdac style(apple): format localized share extension 2026-06-26 18:04:36 -07:00
Vincent Koc
620479c230 feat(apple): localize native app surfaces 2026-06-26 18:04:36 -07:00
Vincent Koc
fe66f9cac6 feat(apple): add Russian and Hindi app catalogs 2026-06-26 18:04:36 -07:00
Vincent Koc
afbbd2ab16 fix(i18n): restrict native UI extraction 2026-06-26 18:04:06 -07:00
Vincent Koc
9580fad305 fix(i18n): filter non-translatable native literals 2026-06-26 17:58:34 -07:00
Vincent Koc
9df3467360 fix(i18n): cover all native source roots 2026-06-26 17:54:21 -07:00
Vincent Koc
ac70e9ddda fix(i18n): inventory conditional native labels 2026-06-26 17:49:14 -07:00
Vincent Koc
bfca9b2447 fix(i18n): align native scan scope and build exclusions 2026-06-26 17:44:07 -07:00
Vincent Koc
3d06c4bc24 feat(i18n): inventory native resources and wrappers 2026-06-26 17:39:57 -07:00
Vincent Koc
8f9aca8aaa fix(i18n): parse native interpolation expressions 2026-06-26 17:31:58 -07:00
Vincent Koc
6f0d8c2097 fix(i18n): preserve Kotlin native placeholders 2026-06-26 17:26:37 -07:00
Vincent Koc
c5884957ff ci(i18n): run native checks for tooling changes 2026-06-26 17:21:49 -07:00
Vincent Koc
22d0780a89 fix(i18n): preserve native placeholders and whitespace 2026-06-26 17:16:33 -07:00
Vincent Koc
126fc2f0b4 fix(i18n): skip non-runtime native source literals 2026-06-26 17:11:47 -07:00
Vincent Koc
67cf97ef55 fix(i18n): guard native inventory in CI 2026-06-26 17:07:02 -07:00
Vincent Koc
8cbd6c78c8 fix(i18n): keep native refresh inventory clean 2026-06-26 17:03:11 -07:00
Vincent Koc
1545198f8b feat(i18n): define native locale matrix 2026-06-26 16:55:51 -07:00
Vincent Koc
20f5648a2e feat(i18n): inventory native app UI strings 2026-06-26 16:55:51 -07:00
joshavant
ff35f3bb2c Track mobile release SHAs with refs 2026-06-26 18:54:45 -05:00
Vincent Koc
ff18374293 Merge branch 'main' of https://github.com/openclaw/openclaw
* 'main' of https://github.com/openclaw/openclaw:
  fix(codex): wait for native tool completion (#96818)
2026-06-26 16:52:48 -07:00
Vincent Koc
fa78cfbfb7 Reapply "docs: document agent issue and PR routing (#96714)"
This reverts commit c691872b9e.
2026-06-26 16:52:45 -07:00
xingzhou
8252fc009f fix(codex): wait for native tool completion (#96818)
* fix(codex): wait for native tool completion

* fix(codex): track native execution lifecycles

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-06-27 00:52:31 +01:00
Vincent Koc
c691872b9e Revert "docs: document agent issue and PR routing (#96714)"
This reverts commit 43dd34262e.
2026-06-26 16:52:17 -07:00
openclaw-release-bot
e0932e0bc4 chore(release): update appcast for 2026.6.10 2026-06-26 23:46:53 +00:00
Patrick Erichsen
808c227edb feat: scaffold provider plugins from init (#94352)
* feat: scaffold provider plugins from init

* fix: satisfy plugin init scaffold CI guards

* fix: preserve plugin init id argument
2026-06-26 16:43:51 -07:00
mushuiyu886
deb0ffdcdf fix #94040: [Bug]: nodes approve failed: GatewayClientRequestError: unknown requestId (#94452)
* fix(nodes): explain unknown approval request ids

* fix(nodes): keep stale request handling CI-clean

* fix(nodes): point stale approve hint at pending command

* fix(nodes): explain stale approval request ids

* fix(nodes): make stale approval guidance reliable

* fix(nodes): preserve stale approval error context

---------

Co-authored-by: openclaw-clownfish[bot] <280122609+openclaw-clownfish[bot]@users.noreply.github.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-06-27 00:40:29 +01:00
mushuiyu886
a846b879ec fix(mcp): include image source for screenshot results (#90902)
* fix(mcp): emit image content with base64 source

* fix(mcp): keep plugin tool images in SDK schema

* test(mcp): exercise image bridge end to end

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-06-27 00:39:40 +01:00
Eva
43dd34262e docs: document agent issue and PR routing (#96714)
* docs: document agent issue and pr routing

* docs: link contribution routes from readme

* docs: point routing docs at github codeowners

* docs(contributing): keep routing guidance concise

---------

Co-authored-by: Eva <eva@100yen.org>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-06-27 00:34:58 +01:00
Galin Iliev
6883c6c070 fix: wake yielded parent after subagents finish (#97090)
* fix: wake yielded subagent parents after descendants settle

* fix: wake yielded subagent parents after descendants settle

---------

Co-authored-by: Galin Iliev <Galin.Iliev@microsoft.com>
2026-06-26 16:31:20 -07:00
xingzhou
91726e9624 fix(cron): reject invalid no-output timeout (#96516)
* fix(cron): reject invalid no-output timeout

* fix(cron): validate command output limits

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-06-27 00:28:45 +01:00
Bartok
289865b392 fix(matrix): truncate thread starter body on code-point boundaries (#97121)
Matrix thread-starter previews truncated long bodies by raw UTF-16
slice, which could cut an astral character (e.g. emoji) and leave a lone
surrogate, rendering mojibake in the agent's thread context.

Reuse the existing sliceUtf16Safe helper so the cut backs up to a valid
surrogate boundary, preserving the 500-code-unit limit and '...' suffix.
Adds a regression test that fails against the raw-slice implementation.

Salvages the original fix from #96407 (auto-closed by the active-PR
queue cap). Preserves @ly-wang19's authorship; rebased clean onto main
by @Bartok9.

Co-authored-by: ly-wang19 <ly-wang19@users.noreply.github.com>
2026-06-27 07:27:38 +08:00
pick-cat
6db4624f43 fix(auth): suppress recovery hint for format failures (#95779)
* fix(auth): suppress recovery hint for format failures

* test(auth): cover format failure recovery copy

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-06-27 00:27:25 +01:00
ly-wang19
b7a9d3005c perf(ui): use sets for usage selection filters (#96945)
Co-authored-by: ly-wang19 <ly-wang19@users.noreply.github.com>
2026-06-27 07:23:59 +08:00
Dallin Romney
bdd365a348 docs: clarify macOS release workflow refs (#97116)
* docs: clarify macOS release workflow refs

* docs: clarify macOS release repo roles

* docs: document macOS release approval gate

* docs: align maintainer macOS release repo refs
2026-06-26 16:08:08 -07:00
Vincent Koc
a82902c725 ci: right-size runner registration caps (#97119) 2026-06-27 07:01:26 +08:00
ly-wang19
aca905cce5 perf(sessions): find matching checkpoints without sorting (#96964)
Co-authored-by: ly-wang19 <ly-wang19@users.noreply.github.com>
2026-06-27 06:53:48 +08:00
zw-xysk
ab966c214b fix(tools): treat no-op writes and edits as terminal tool-loop failures (fixes #96983) (#97044)
* fix(tools): treat no-op writes and edits as terminal tool-loop failures

Fixes #96983

* fix(tools): treat no-op writes and edits as terminal tool-loop failures

Fixes #96983

* fix(tools): preserve valid sibling edits in mixed no-op batches

Fixes #96983

* fix(tools): terminate apply_patch no-ops safely

* fix(tools): validate no-op edits independently

* fix(tools): preserve no-op edit overlap checks

* fix(tools): preserve no-op patch file formatting

* fix(tools): preserve move no-op formatting

* fix(tools): narrow same-path move no-op typing

* fix(tools): distinguish edit no-op errors

* fix(tools): keep edit previews aligned with execution

* fix(tools): align no-op validation and formatting

* fix(tools): preserve empty patch and preview no-ops

* fix(tools): preview fuzzy edit no-ops cleanly

* fix(tools): isolate fuzzy-equivalent edit no-ops

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-06-27 06:41:26 +08:00
ly-wang19
41c00a65d6 perf(status): select recent sessions without full sort (#96955)
Co-authored-by: ly-wang19 <ly-wang19@users.noreply.github.com>
2026-06-27 06:33:00 +08:00
Vincent Koc
eba1ca683f test(i18n): include Hindi and Russian registry locales 2026-06-26 15:23:10 -07:00
github-actions[bot]
b3eee03740 chore(ui): refresh fa control ui locale 2026-06-26 22:08:51 +00:00
github-actions[bot]
6109420e5c chore(ui): refresh nl control ui locale 2026-06-26 22:08:49 +00:00
github-actions[bot]
44e522cf6b chore(ui): refresh vi control ui locale 2026-06-26 22:08:46 +00:00
github-actions[bot]
ab8cd3dac9 chore(ui): refresh th control ui locale 2026-06-26 22:08:17 +00:00
github-actions[bot]
816c2cf1f8 chore(ui): refresh pl control ui locale 2026-06-26 22:08:08 +00:00
github-actions[bot]
9cc10a8382 chore(ui): refresh id control ui locale 2026-06-26 22:08:02 +00:00
github-actions[bot]
c6757d7a75 chore(ui): refresh uk control ui locale 2026-06-26 22:07:51 +00:00
github-actions[bot]
a70e7ce24b chore(ui): refresh tr control ui locale 2026-06-26 22:07:28 +00:00
github-actions[bot]
6b98d179b6 chore(ui): refresh ar control ui locale 2026-06-26 22:07:23 +00:00
github-actions[bot]
cb4e9e4118 chore(ui): refresh it control ui locale 2026-06-26 22:07:14 +00:00
github-actions[bot]
0023cc816a chore(ui): refresh fr control ui locale 2026-06-26 22:07:01 +00:00
github-actions[bot]
6d62dae215 chore(ui): refresh ko control ui locale 2026-06-26 22:06:43 +00:00
github-actions[bot]
8d61631b40 chore(ui): refresh ja-JP control ui locale 2026-06-26 22:06:35 +00:00
github-actions[bot]
68bed5e902 chore(ui): refresh es control ui locale 2026-06-26 22:06:32 +00:00
github-actions[bot]
7b549a26e8 chore(ui): refresh de control ui locale 2026-06-26 22:06:08 +00:00
github-actions[bot]
57f62a5fd9 chore(ui): refresh pt-BR control ui locale 2026-06-26 22:06:03 +00:00
github-actions[bot]
ba70d365ac chore(ui): refresh zh-TW control ui locale 2026-06-26 22:05:56 +00:00
github-actions[bot]
ce88d65779 chore(ui): refresh zh-CN control ui locale 2026-06-26 22:05:52 +00:00
Vincent Koc
689baa5c1e feat(i18n): add Hindi and Russian docs and Control UI locales 2026-06-26 15:01:15 -07:00
ly-wang19
4c4396c4c2 perf(memory): copy only requested embedding dimensions (#96952)
* perf(memory): copy only requested embedding dimensions

* perf(memory): copy only requested embedding dimensions

---------

Co-authored-by: ly-wang19 <ly-wang19@users.noreply.github.com>
Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-06-27 05:56:02 +08:00
ly-wang19
c1336b6b41 perf(plugins): classify cached tool candidates once (#96948)
Co-authored-by: ly-wang19 <ly-wang19@users.noreply.github.com>
2026-06-27 05:49:35 +08:00
ly-wang19
d4a01e48bc fix(link-understanding): strip markdown links whose label contains brackets (#96476)
Merged via squash.

Prepared head SHA: 2d69ed259f
Co-authored-by: ly-wang19 <94427531+ly-wang19@users.noreply.github.com>
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Reviewed-by: @vincentkoc
2026-06-27 05:43:11 +08:00
ly-wang19
a0e9ca1e95 perf(update): reuse missing plugin payload id set (#96950)
* perf(update): reuse missing plugin payload id set

* perf(update): reuse missing plugin payload id set

---------

Co-authored-by: ly-wang19 <ly-wang19@users.noreply.github.com>
Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-06-27 05:32:38 +08:00
ly-wang19
1b6557dfa2 fix(markdown): a fenced-code line with trailing text is content, not a closing fence (#96745)
* fix(markdown): a fenced-code line with trailing text is content, not a closing fence

scanFenceSpans accepted any line starting with >=3 matching fence markers as a
closing fence, ignoring trailing text after the marker. Per CommonMark a closing
fence may be followed only by whitespace, so a code-content line such as
"``` not a close" was wrongly treated as a close: the block ended early, the
following lines were reported as outside any fence, and the trailing marker line
became a new unclosed opener.

That made isSafeFenceBreak() return true for offsets inside the real code block
and findFenceSpanAt() return undefined, so chunkers (chunkMarkdownText, the
embedded-agent block chunker) could split inside a fenced code block — the exact
thing this module exists to prevent.

Require the closing fence's trailing text to be whitespace-only. Opening info
strings, bare closes, and longer same-marker closes are unaffected.

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

* fix(markdown): honor fence suffix whitespace rules

Co-authored-by: ly-wang19 <ly-wang19@users.noreply.github.com>

---------

Co-authored-by: ly-wang19 <ly-wang19@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-06-27 05:32:10 +08:00
Bartok
2968004680 fix(chunk): keep surrogate pairs whole when hard-splitting an over-long line (#96951)
chunkByNewline length-splits a single over-long line that has no usable break
point. The first head cut used a raw UTF-16 slice (lineValue.slice(0,
firstLimit)), so when firstLimit landed inside a surrogate pair it emitted a
chunk ending in a lone high surrogate and a next chunk starting with a lone low
surrogate, which render as U+FFFD on delivery. The recursive chunkText that
handles the remainder is already surrogate-safe; only this first cut was raw.

Route the head cut through avoidTrailingHighSurrogateBreak (the same helper
chunkText and chunkMarkdownText already use) so the cut backs off to a
code-point boundary. ASCII and non-surrogate cuts are unaffected. Reproduces at
the production-default 4096 limit for emoji-dense lines; chunkByNewline is the
published plugin-SDK channel.text helper, reachable with arbitrary outbound text.

Co-authored-by: ly-wang19 <ly-wang19@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 05:21:45 +08:00
Bek
9636bea901 perf(memory): add QMD search diagnostics and runtime cache (#96655) 2026-06-26 16:16:12 -04:00
NianJiu
1089253ca9 fix feishu login qr rendering (#97087)
Co-authored-by: NianJiuZst <180004567+users.noreply.github.com>
2026-06-26 12:09:38 -07:00
Hannes Rudolph
e5123e44b0 docs: update changelog for v2026.6.10 (#97079)
* docs: update changelog for v2026.6.10

* docs: update changelog for v2026.6.10
2026-06-26 12:22:40 -06:00
Shakker
1cd6f81a46 fix: close memory test failure paths 2026-06-26 14:05:07 +01:00
Shakker
80c754ddf4 test: normalize feishu env fixtures 2026-06-26 13:19:33 +01:00
maweibin
512f0f1bf7 fix(webchat): make model selector width adaptive to prevent long name… (#96990)
Summary:
- The PR updates `ui/src/styles/chat/layout.css` so the WebChat composer model selector can size to long model/thinking labels and also changes the base inline-select menu width rule.
- PR surface: Source +1. Total +1 across 1 file.
- Reproducibility: yes. from source inspection: current main and `v2026.6.10` combine model and thinking text  ... ro in this read-only review, but the PR's inspected before screenshot demonstrates the reported truncation.

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

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

Prepared head SHA: 0cf1a65852
Review: https://github.com/openclaw/openclaw/pull/96990#issuecomment-4807967550

Co-authored-by: 0668000787 <ma.weibin@xydigit.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Approved-by: takhoffman
2026-06-26 12:18:28 +00:00
Shakker
338e119533 fix: preserve watcher test state 2026-06-26 13:05:44 +01:00
Shakker
e4f63577d0 test: scope manager reindex state 2026-06-26 12:56:21 +01:00
Shakker
94d93d4c85 fix: retain qmd test environment state 2026-06-26 12:48:24 +01:00
Shakker
7718e25b2a test: preserve memory startup env state 2026-06-26 12:40:55 +01:00
Shakker
8079aa62a2 fix: isolate memory index test state 2026-06-26 12:32:39 +01:00
Shakker
6f162f321a test: scope narrative env cleanup 2026-06-26 12:23:32 +01:00
NIO
527f8f0cbb fix(image-gen): bound image generation provider JSON response reads (#96495)
* fix(image-gen): bound image generation provider JSON response reads

Route success JSON reads through readProviderJsonResponse (16 MiB cap)
in openrouter, google, fal, minimax, openai, and vydra image generation
providers 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>

* fix(image-gen): size bounded JSON caps for inline image payloads

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-26 07:08:30 -04:00
Shakker
c05d0d5bbf fix: restore dreaming env after setup 2026-06-26 11:12:52 +01:00
Shakker
535af4452b test: scope dreaming env batch one 2026-06-26 11:12:04 +01:00
Jesse Merhi
ec737ee74d fix: rebase clawhub install trust (#81364) 2026-06-26 18:33:19 +10:00
Liu Wenyu
9a735bea03 fix(outbound): ignore empty delivery receipts (#79811) 2026-06-26 09:40:46 +02:00
Peter Steinberger
81e53202f2 fix(scripts): bypass gh wrapper shims 2026-06-26 07:51:07 +01:00
xingzhou
e9f9a68d68 fix(weixin): startAccount preserves session routing (#93686)
* fix(channels): resolve manifest account config by normalized id

* fix(routing): ignore blocked keys during normalized account lookup

* fix(routing): block normalized unsafe account keys

* test(routing): type normalized account lookup case

* trigger CI

* fix account lookup invalid key fallback

* fix(weixin): startAccount preserves session routing (#93686) (thanks @zhangguiping-xydt)

---------

Co-authored-by: sliverp <870080352@qq.com>
2026-06-26 14:36:49 +08:00
Josh Avant
db255b1154 Fix Telegram spooled claim refresh (#96962) 2026-06-26 01:30:57 -05:00
Gio Della-Libera
4fc504d321 Doctor: add lint --all (#96471)
* fix(doctor): keep audit scrub lint opt-in

* fix(doctor): keep audit lint defaults internal

* feat(doctor): add lint profiles
2026-06-25 22:26:42 -07:00
Dallin Romney
751a6c23f0 fix(signal): avoid duplicate cli missing note (#96932) 2026-06-25 21:30:44 -07:00
Dallin Romney
899f65097b ci: park timing summary collection (#96930) 2026-06-25 21:20:44 -07:00
Kevin Lin
a6a4652c70 fix(codex): expose plugin apps after delayed inventory load (#96872)
* fix(codex): refresh missing plugin app inventory

* fix(codex): honor OpenClaw app enablement overrides
2026-06-25 21:10:02 -07:00
Dallin Romney
3b292ba9d4 fix(signal): use brew for macos signal-cli install (#96909) 2026-06-25 21:08:54 -07:00
Jesse Merhi
0fdfc9f65f fix(exec): harden backend sandbox exec cleanup (#96926) 2026-06-26 13:51:52 +10:00
Josh Avant
448b7c75b6 Stabilize Google Meet chrome-node launch config (#96908) 2026-06-25 22:11:57 -05:00
joshavant
6830aa39ea fix(signal): bind approval reactions from structured deliveries 2026-06-25 20:15:58 -05:00
brokemac79
a0b397748f fix(status): restore Codex synthetic usage in status 2026-06-25 17:37:30 -07:00
brokemac79
dd0e4f6e61 fix: avoid plugin update range fallback after metadata failure (#96143)
Co-authored-by: Dallin Romney <dallinromney@gmail.com>
2026-06-25 17:15:50 -07: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
508 changed files with 56640 additions and 3693 deletions

View File

@@ -13,12 +13,13 @@ registration edge limit.
- The scarce resource is Blacksmith runner registrations, not Blacksmith vCPU
capacity.
- GitHub runner registrations are capped at 1,500 per 5 minutes per repository,
organization, or enterprise. The `openclaw` organization shares one bucket.
- GitHub runner registrations for `openclaw` are currently capped at 3,000 per
5 minutes per repository, organization, or enterprise. The `openclaw`
organization shares one bucket.
- Core REST quota does not draw down this bucket. Check
`actions_runner_registration` separately; core quota can be healthy while
runner registration is throttled.
- Use 1,000 registrations per 5 minutes as the operating target. Leave the last
- Use 2,000 registrations per 5 minutes as the operating target. Leave the last
third for other repos, retries, and burst overlap.
- Jobs that route, notify, summarize, choose shards, or run short CodeQL quality
scans should stay on GitHub-hosted runners unless measured evidence says
@@ -87,7 +88,7 @@ admission. The debounce only suppresses pushes that arrive while
registrations are spent even if a later push cancels the run. If timing is
uncertain, count every sequential push in the window.
Reject a change unless the org-level worst case stays below 1,000 registrations
Reject a change unless the org-level worst case stays below 2,000 registrations
per 5 minutes with headroom for ClawSweeper, ClawHub, Clownfish, OpenClaw RTT,
and Clawbench.
@@ -127,8 +128,8 @@ These are intentionally guarded by `test/scripts/ci-workflow-guards.test.ts`:
- `runner-admission` on `ubuntu-24.04` with
`OPENCLAW_MAIN_CI_DEBOUNCE_SECONDS=90`.
- `preflight` and `security-fast` needing `runner-admission`.
- CI matrix caps: fast/check lanes at 8, compact Node PR plan at current caps,
Windows and Android at 2.
- CI matrix caps: fast/check lanes at 12, Node test shards at 24, Windows and
Android at 2.
- `build-artifacts` on `blacksmith-16vcpu-ubuntu-2404`.
- lower-weight Node/check shards on `blacksmith-4vcpu-ubuntu-2404`.
- heavy retained Linux/Android shards on `blacksmith-8vcpu-ubuntu-2404`.

View File

@@ -5,7 +5,7 @@ description: "Run or recover OpenClaw macOS release signing, notarization, appca
# OpenClaw Mac Release
Use with `$release-openclaw-maintainer`, `$release-openclaw-ci`, `$one-password`, and `$release-private` if it exists when stable macOS assets, private mac preflight, notarization, appcast promotion, or mac release recovery is involved.
Use with `$release-openclaw-maintainer`, `$release-openclaw-ci`, `$one-password`, and `$release-private` if it exists when stable macOS assets, release-ops mac preflight, notarization, appcast promotion, or mac release recovery is involved.
## Credentials
@@ -23,7 +23,7 @@ Use with `$release-openclaw-maintainer`, `$release-openclaw-ci`, `$one-password`
## GitHub Secrets
Target private repo environment: `openclaw/releases-private`, env `mac-release`.
Target release-ops repo environment: `openclaw/releases`, env `mac-release`.
Set only after local notary auth validation:
@@ -35,12 +35,24 @@ Do not update these from mixed sources. All three ASC fields must come from the
## Workflow Shape
- `openclaw/openclaw` is the public product repo. Its GitHub Releases page is
where macOS assets are ultimately attached.
- `openclaw/openclaw` `macos-release.yml` is public handoff validation only.
It never signs, notarizes, or uploads macOS assets, regardless of
`preflight_only`.
- `openclaw/releases` is the restricted release-ops repo. Its macOS workflows
sign, notarize, validate, and promote assets onto the
`openclaw/openclaw` GitHub release.
- Public release branch may carry mac-only packaging fixes after the stable tag/npm are already live.
- Use `source_ref=release/YYYY.M.PATCH` for private mac preflight/validation when building that branch variation.
- Use `source_ref=release/YYYY.M.PATCH` for release-ops mac preflight/validation when building that branch variation.
- Keep `tag=vYYYY.M.PATCH` pointing at the original stable release commit.
- Real mac publish must reuse:
- a successful private mac preflight run for the same tag/source SHA
- a successful private mac validation run for the same tag/source SHA
- a successful release-ops mac preflight run for the same tag/source SHA
- a successful release-ops mac validation run for the same tag/source SHA
- Release-ops preflight and real publish enter the protected `mac-release`
environment in the `build_sign_and_package` job. Operators may be able to
trigger the workflow while Vincent or another environment reviewer approves
the paused deployment before signing/notarization/promotion proceeds.
- If preflight source SHA differs from tag SHA, validation must also use the same `source_ref`; promotion rejects mismatched proof.
## Notarization
@@ -52,10 +64,25 @@ Do not update these from mixed sources. All three ASC fields must come from the
## Dispatch
Private preflight:
Public handoff validation:
```bash
gh workflow run openclaw-macos-publish.yml --repo openclaw/releases-private --ref main \
gh workflow run macos-release.yml --repo openclaw/openclaw \
--ref release/YYYY.M.PATCH \
-f tag=vYYYY.M.PATCH \
-f preflight_only=true \
-f public_release_branch=release/YYYY.M.PATCH
```
- Use the public release branch as the workflow ref so the Actions list displays
`release/YYYY.M.PATCH`, matching prior stable macOS handoff runs.
- Do not use `--ref main` or `--ref vYYYY.M.PATCH` for this public handoff
validation. The workflow checks out the tag from the `tag` input internally.
Release-ops preflight:
```bash
gh workflow run openclaw-macos-publish.yml --repo openclaw/releases --ref main \
-f tag=vYYYY.M.PATCH \
-f source_ref=release/YYYY.M.PATCH \
-f preflight_only=true \
@@ -64,18 +91,24 @@ gh workflow run openclaw-macos-publish.yml --repo openclaw/releases-private --re
-f public_release_branch=release/YYYY.M.PATCH
```
Private validation for a branch-variation preflight:
Wait for the run to reach the `mac-release` environment approval if GitHub
pauses it, then get approval from Vincent or another configured environment
reviewer. Record the successful preflight run id.
Release-ops validation for a branch-variation preflight:
```bash
gh workflow run openclaw-macos-validate.yml --repo openclaw/releases-private --ref main \
gh workflow run openclaw-macos-validate.yml --repo openclaw/releases --ref main \
-f tag=vYYYY.M.PATCH \
-f source_ref=release/YYYY.M.PATCH
```
Record the successful validation run id.
Real publish:
```bash
gh workflow run openclaw-macos-publish.yml --repo openclaw/releases-private --ref main \
gh workflow run openclaw-macos-publish.yml --repo openclaw/releases --ref main \
-f tag=vYYYY.M.PATCH \
-f preflight_only=false \
-f smoke_test_only=false \
@@ -85,6 +118,14 @@ gh workflow run openclaw-macos-publish.yml --repo openclaw/releases-private --re
-f public_release_branch=release/YYYY.M.PATCH
```
Wait for the `mac-release` environment approval again if GitHub pauses the real
publish run before it promotes assets.
- Release-ops `openclaw/releases` publish/validate workflows run from their own
trusted `main` workflow ref. Real publish has a guard that rejects any other
workflow ref. That displayed `main` ref is expected; the public OpenClaw
source is selected by `tag` and optional `source_ref`.
## Verify
- `gh release view vYYYY.M.PATCH --repo openclaw/openclaw` shows zip, dmg, dSYM zip, not draft, not prerelease.

View File

@@ -203,8 +203,9 @@ Stable publication is not complete until `main` carries the actual shipped relea
validation-only release machinery. If mac packaging needs release-branch-only
fixes after the stable npm package or GitHub tag is already published, do not
create a `vYYYY.M.PATCH-N` correction tag just to change the workflow source.
Dispatch the private mac workflows for the original `tag=vYYYY.M.PATCH` with
`source_ref=release/YYYY.M.PATCH` and `public_release_branch=release/YYYY.M.PATCH`;
Dispatch the release-ops mac workflows for the original `tag=vYYYY.M.PATCH`
with `source_ref=release/YYYY.M.PATCH` and
`public_release_branch=release/YYYY.M.PATCH`;
provenance checks must prove the source SHA descends from the tag and
validation/preflight use the same source. Reserve `vYYYY.M.PATCH-N` correction
tags for emergency hotfixes that must publish a new npm package/release
@@ -579,8 +580,8 @@ node --import tsx scripts/openclaw-npm-postpublish-verify.ts <published-version>
- Actual npm install/update phases are capped at 5 minutes. If `npm install -g`, installer package install, or `openclaw update` takes longer than 300s in release e2e, stop treating the run as healthy progress and debug the installer/updater or harness.
- Serialize host build/package mutations ahead of VM lanes. Finish `pnpm build`, `pnpm ui:build`, `pnpm release:check`, install smoke, and any Docker/package-prep lanes before starting Parallels `npm pack` lanes; otherwise `dist` can disappear during VM pack prep and produce false failures.
- Include mac release readiness in preflight by running the public validation
workflow in `openclaw/openclaw` and the real mac preflight in
`openclaw/releases-private` for every release.
workflow in `openclaw/openclaw` and the release-ops mac preflight in
`openclaw/releases` for every release.
- Treat the `appcast.xml` update on `main` as part of mac release readiness, not an optional follow-up.
- The workflows remain tag-based. The agent is responsible for making sure
preflight runs complete successfully before any publish run starts.
@@ -608,16 +609,16 @@ node --import tsx scripts/openclaw-npm-postpublish-verify.ts <published-version>
## Use the right auth flow
- OpenClaw publish uses GitHub trusted publishing.
- Stable npm promotion from `beta` to `latest` uses the private
`openclaw/releases-private/.github/workflows/openclaw-npm-dist-tags.yml`
workflow because `npm dist-tag` management needs `NPM_TOKEN`, while the
public npm release workflow stays OIDC-only.
- Prefer fixing the private workflow token path over any local 1Password
fallback. The desired setup is a granular npm token stored as the private
- Stable npm promotion from `beta` to `latest` uses the restricted release-ops
`openclaw/releases/.github/workflows/openclaw-npm-dist-tags.yml` workflow
because `npm dist-tag` management needs `NPM_TOKEN`, while the public npm
release workflow stays OIDC-only.
- Prefer fixing the release-ops workflow token path over any local 1Password
fallback. The desired setup is a granular npm token stored as the release-ops
repo's `NPM_TOKEN` secret, scoped to the `openclaw` package with read/write
and 2FA bypass for automation.
- If the private dist-tag workflow cannot promote because `NPM_TOKEN` is absent
or stale, use the local tmux + 1Password fallback:
- If the release-ops dist-tag workflow cannot promote because `NPM_TOKEN` is
absent or stale, use the local tmux + 1Password fallback:
- Start or reuse a tmux session so interactive `npm login` and OTP prompts
are observable and recoverable.
- Hard rule: never run `op` directly in the main agent shell during release
@@ -635,21 +636,21 @@ node --import tsx scripts/openclaw-npm-postpublish-verify.ts <published-version>
- Verify with a cache-bypassed registry read, for example:
`npm view openclaw dist-tags --json --prefer-online --cache /tmp/openclaw-npm-cache-verify-$$`
and `npm view openclaw@latest version dist.tarball --json --prefer-online`.
- Direct stable publishes can also use that private dist-tag workflow to point
`beta` at the already-published `latest` version when the operator wants both
tags aligned immediately.
- Direct stable publishes can also use that release-ops dist-tag workflow to
point `beta` at the already-published `latest` version when the operator wants
both tags aligned immediately.
- The publish run must be started manually with `workflow_dispatch`.
- The npm workflow and the private mac publish workflow accept
- The npm workflow and the release-ops mac publish workflow accept
`preflight_only=true` to run validation/build/package steps without uploading
public release assets.
- Real npm publish requires a prior successful npm preflight run id and the
successful Full Release Validation run id for the same tag/SHA so the publish
job promotes the prepared tarball instead of rebuilding it and attaches the
correct release evidence.
- Real private mac publish requires a prior successful private mac preflight
run id so the publish job promotes the prepared artifacts instead of
- Real release-ops mac publish requires a prior successful release-ops mac
preflight run id so the publish job promotes the prepared artifacts instead of
rebuilding or renotarizing them again.
- The private mac workflow also accepts `smoke_test_only=true` for branch-safe
- The release-ops mac workflow also accepts `smoke_test_only=true` for branch-safe
workflow smoke tests that use ad-hoc signing, skip notarization, skip shared
appcast generation, and do not prove release readiness.
- `preflight_only=true` on the npm workflow is also the right way to validate an
@@ -670,27 +671,27 @@ node --import tsx scripts/openclaw-npm-postpublish-verify.ts <published-version>
use only `main` or `release/YYYY.M.PATCH`.
- `.github/workflows/macos-release.yml` in `openclaw/openclaw` is now a
public validation-only handoff. It validates the tag/release state and points
operators to the private repo. It still rebuilds the JS outputs needed for
operators to the release-ops repo. It still rebuilds the JS outputs needed for
release validation, but it does not sign, notarize, or publish macOS
artifacts.
- `openclaw/releases-private/.github/workflows/openclaw-macos-validate.yml`
is the required private mac validation lane for `swift test`; keep it green
- `openclaw/releases/.github/workflows/openclaw-macos-validate.yml` is the
required release-ops mac validation lane for `swift test`; keep it green
before any real stable mac publish run starts.
- Real mac preflight and real mac publish both use
`openclaw/releases-private/.github/workflows/openclaw-macos-publish.yml`.
- The private mac validation lane runs on GitHub's standard macOS runner.
- The private mac preflight path runs on GitHub's xlarge macOS runner and uses
`openclaw/releases/.github/workflows/openclaw-macos-publish.yml`.
- The release-ops mac validation lane runs on GitHub's standard macOS runner.
- The release-ops mac preflight path runs on GitHub's xlarge macOS runner and uses
a SwiftPM cache because the build/sign/notarize/package path is CPU-heavy.
- Private mac preflight uploads notarized build artifacts as workflow artifacts
instead of uploading public GitHub release assets.
- Private smoke-test runs upload ad-hoc, non-notarized build artifacts as
- Release-ops mac preflight uploads notarized build artifacts as workflow
artifacts instead of uploading public GitHub release assets.
- Release-ops smoke-test runs upload ad-hoc, non-notarized build artifacts as
workflow artifacts and intentionally skip stable `appcast.xml` generation.
- For stable releases, npm preflight, Full Release Validation, public mac
validation, private mac validation, and private mac preflight must all pass
before any real publish run starts. For beta releases, npm preflight and Full
Release Validation must pass before npm publish unless the operator explicitly
waives the full gate; mac beta validation is still only required when
requested.
validation, release-ops mac validation, and release-ops mac preflight must all
pass before any real publish run starts. For beta releases, npm preflight and
Full Release Validation must pass before npm publish unless the operator
explicitly waives the full gate; mac beta validation is still only required
when requested.
- Real publish runs may be dispatched from `main` or from a
`release/YYYY.M.PATCH` branch. For release-branch runs, the tag must be contained
in that release branch, and the real publish must reuse a successful preflight
@@ -699,21 +700,21 @@ node --import tsx scripts/openclaw-npm-postpublish-verify.ts <published-version>
rather than workflow-level SHA pinning.
- The `npm-release` environment must be approved by `@openclaw/openclaw-release-managers` before publish continues.
- Mac publish uses
`openclaw/releases-private/.github/workflows/openclaw-macos-publish.yml` for
private mac preflight artifact preparation and real publish artifact
`openclaw/releases/.github/workflows/openclaw-macos-publish.yml` for
release-ops mac preflight artifact preparation and real publish artifact
promotion.
- Real private mac publish uploads the packaged `.zip`, `.dmg`, and
- Real release-ops mac publish uploads the packaged `.zip`, `.dmg`, and
`.dSYM.zip` assets to the existing GitHub release in `openclaw/openclaw`
automatically when `OPENCLAW_PUBLIC_REPO_RELEASE_TOKEN` is present in the
private repo `mac-release` environment.
release-ops repo `mac-release` environment.
- For stable releases, the agent must also download the signed
`macos-appcast-<tag>` artifact from the successful private mac workflow and
then update `appcast.xml` on `main`.
`macos-appcast-<tag>` artifact from the successful release-ops mac workflow
and then update `appcast.xml` on `main`.
- For beta mac releases, do not update the shared production `appcast.xml`
unless a separate beta Sparkle feed exists.
- The private repo targets a dedicated `mac-release` environment. If the GitHub
plan does not yet support required reviewers there, do not assume the
environment alone is the approval boundary; rely on private repo access and
- The release-ops repo targets a dedicated `mac-release` environment. If the
GitHub plan does not yet support required reviewers there, do not assume the
environment alone is the approval boundary; rely on restricted repo access and
CODEOWNERS until those settings can be enabled.
- Do not use `NPM_TOKEN` or the plugin OTP flow for the OpenClaw package
publish path; package publishing uses trusted publishing.
@@ -800,12 +801,12 @@ node --import tsx scripts/openclaw-npm-postpublish-verify.ts <published-version>
18. For stable releases, start `.github/workflows/macos-release.yml` in
`openclaw/openclaw` and wait for the public validation-only run to pass.
19. For stable releases, start
`openclaw/releases-private/.github/workflows/openclaw-macos-validate.yml`
with the same tag and wait for the private mac validation lane to pass.
`openclaw/releases/.github/workflows/openclaw-macos-validate.yml` with the
same tag and wait for the release-ops mac validation lane to pass.
20. For stable releases, start
`openclaw/releases-private/.github/workflows/openclaw-macos-publish.yml`
with `preflight_only=true` and wait for it to pass. Save that run id because
the real publish requires it to reuse the notarized mac artifacts.
`openclaw/releases/.github/workflows/openclaw-macos-publish.yml` with
`preflight_only=true` and wait for it to pass. Save that run id because the
real publish requires it to reuse the notarized mac artifacts.
21. If any preflight or validation run fails, fix the issue on a new commit,
delete the tag and any accidental draft/incomplete GitHub release, recreate
the tag from the fixed commit, and rerun all relevant preflights from
@@ -861,22 +862,23 @@ node --import tsx scripts/openclaw-npm-postpublish-verify.ts <published-version>
promotion roster when the matching beta already carried the full confidence
pass: published npm postpublish verify, Docker install/update smoke,
macOS-only Parallels install/update smoke, and required QA signal.
Then start the private
`openclaw/releases-private/.github/workflows/openclaw-npm-dist-tags.yml`
workflow to promote that stable version from `beta` to `latest`, then
verify `latest` now points at that version.
Then start the restricted release-ops
`openclaw/releases/.github/workflows/openclaw-npm-dist-tags.yml` workflow
to promote that stable version from `beta` to `latest`, then verify
`latest` now points at that version.
29. If the stable release was published directly to `latest` and `beta` should
follow it, start that same private dist-tag workflow to point `beta` at the
stable version, then verify both `latest` and `beta` point at that version.
follow it, start that same release-ops dist-tag workflow to point `beta` at
the stable version, then verify both `latest` and `beta` point at that
version.
30. For stable releases, start
`openclaw/releases-private/.github/workflows/openclaw-macos-publish.yml`
for the real publish with the successful private mac `preflight_run_id` and
wait for success.
31. Verify the successful real private mac run uploaded the `.zip`, `.dmg`,
`openclaw/releases/.github/workflows/openclaw-macos-publish.yml` for the
real publish with the successful release-ops mac `preflight_run_id` and wait
for success.
31. Verify the successful real release-ops mac run uploaded the `.zip`, `.dmg`,
and `.dSYM.zip` artifacts to the existing GitHub release in
`openclaw/openclaw`.
32. For stable releases, download `macos-appcast-<tag>` from the successful
private mac run, update `appcast.xml` on `main`, verify the feed, then
release-ops mac run, update `appcast.xml` on `main`, verify the feed, then
complete the **Close stable releases on main** gate.
33. For beta releases, publish the mac assets only when intentionally requested;
expect no shared production

View File

@@ -848,6 +848,32 @@ jobs:
path: .local/gateway-watch-regression/
retention-days: 7
native-i18n:
permissions:
contents: read
needs: [preflight]
if: ${{ !cancelled() && always() && (needs.preflight.outputs.run_macos == 'true' || needs.preflight.outputs.run_android == 'true' || needs.preflight.outputs.run_node == 'true') }}
runs-on: ${{ github.repository == 'openclaw/openclaw' && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-24.04' }}
timeout-minutes: 10
steps:
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
ref: ${{ needs.preflight.outputs.checkout_revision }}
persist-credentials: false
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
install-bun: "false"
- name: Check native app i18n inventory
run: pnpm native:i18n:check
- name: Check Apple app i18n catalogs
if: needs.preflight.outputs.run_macos == 'true'
run: pnpm apple:i18n:check
checks-fast-core:
permissions:
contents: read
@@ -858,7 +884,7 @@ jobs:
timeout-minutes: 60
strategy:
fail-fast: false
max-parallel: 8
max-parallel: 12
matrix: ${{ fromJson(needs.preflight.outputs.checks_fast_core_matrix) }}
steps:
- name: Checkout
@@ -977,7 +1003,7 @@ jobs:
timeout-minutes: 60
strategy:
fail-fast: false
max-parallel: 8
max-parallel: 12
matrix: ${{ fromJson(needs.preflight.outputs.plugin_contracts_matrix) }}
steps:
- name: Checkout
@@ -1058,7 +1084,7 @@ jobs:
timeout-minutes: 60
strategy:
fail-fast: false
max-parallel: 8
max-parallel: 12
matrix: ${{ fromJson(needs.preflight.outputs.channel_contracts_matrix) }}
steps:
- name: Checkout
@@ -1212,8 +1238,8 @@ jobs:
strategy:
fail-fast: false
# The canonical main path waits for the admission debounce above, so
# modestly widen this large matrix without recreating registration bursts.
max-parallel: 16
# widen this large matrix within the current runner-registration budget.
max-parallel: 24
matrix: ${{ fromJson(needs.preflight.outputs.checks_node_core_nondist_matrix) }}
steps:
- name: Checkout
@@ -1351,7 +1377,7 @@ jobs:
timeout-minutes: 20
strategy:
fail-fast: false
max-parallel: 8
max-parallel: 12
matrix:
include:
- check_name: check-guards
@@ -1493,7 +1519,7 @@ jobs:
timeout-minutes: 20
strategy:
fail-fast: false
max-parallel: 8
max-parallel: 12
matrix:
include:
- check_name: check-additional-boundaries-a
@@ -2419,7 +2445,8 @@ jobs:
- macos-swift
- ios-build
- android
if: ${{ !cancelled() && always() && github.event_name != 'push' && (github.event_name != 'pull_request' || !github.event.pull_request.draft) }}
# Re-enable this job when we want to collect CI timing data for timing optimization.
if: ${{ false && !cancelled() && always() && github.event_name != 'push' && (github.event_name != 'pull_request' || !github.event.pull_request.draft) }}
runs-on: ubuntu-24.04
timeout-minutes: 5
steps:

View File

@@ -0,0 +1,51 @@
name: Plugin Init Scaffold Validation
on:
workflow_dispatch:
push:
branches: [main]
paths:
- ".github/workflows/plugin-init-scaffold-validation.yml"
- "package.json"
- "pnpm-lock.yaml"
- "scripts/validate-plugin-init-provider-scaffold.ts"
- "src/cli/plugins-authoring-command.ts"
- "src/cli/plugins-authoring-command.test.ts"
- "src/cli/plugins-cli.ts"
- "src/plugin-sdk/**"
pull_request:
types: [opened, reopened, synchronize, ready_for_review]
paths:
- ".github/workflows/plugin-init-scaffold-validation.yml"
- "package.json"
- "pnpm-lock.yaml"
- "scripts/validate-plugin-init-provider-scaffold.ts"
- "src/cli/plugins-authoring-command.ts"
- "src/cli/plugins-authoring-command.test.ts"
- "src/cli/plugins-cli.ts"
- "src/plugin-sdk/**"
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.event_name == 'pull_request' && github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
validate-provider-scaffold:
name: Validate provider scaffold
if: github.event_name != 'pull_request' || !github.event.pull_request.draft
runs-on: ${{ github.repository == 'openclaw/openclaw' && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-24.04' }}
timeout-minutes: 30
steps:
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
install-bun: "false"
- name: Generate and validate provider scaffold
run: pnpm test:plugins:init-provider-scaffold

View File

@@ -143,6 +143,9 @@ Skills own workflows; root owns hard policy and routing.
## GitHub / PRs
- Fresh GitHub items: read `CONTRIBUTING.md`, the issue chooser/form, PR template, and `.github/CODEOWNERS`; blank issues are disabled; preserve templates and evidence requirements.
- Agent-authored/non-trivial work: create or reuse the issue first; tiny fixes may go direct. PRs use the template, link context, and keep durable problem/impact/evidence sections.
- Route support to Discord and security through `SECURITY.md`. Use listed maintainer areas/`CODEOWNERS`; never guess mentions.
- Use `$openclaw-pr-maintainer` immediately for maintainer-side OpenClaw issue/PR review, triage, duplicates, labels, comments, close, land, or evidence. Contributor PR creation/refresh follows the requested contributor workflow; linked refs alone do not require maintainer archive tooling.
- Issue/PR start: `git status -sb`; if clean, `git pull --ff-only`; if dirty, yell before pull/rebase.
- PR refs: `gh pr view/diff` or `gh api`, not web search. Prefer `gitcrawl` for maintainer discovery; missing/stale `gitcrawl` falls through to live `gh`, not contributor setup. Verify live with `gh` before mutation.

View File

@@ -2,44 +2,100 @@
Docs: https://docs.openclaw.ai
## 2026.6.10
### Highlights
- **Automatic fast mode for talks:** OpenClaw can enable fast mode for short conversational turns, then return to normal mode for longer runs with bounded fallback and delivery behavior. (#85104) Thanks @alexph-dev and @vincentkoc.
- **More reliable model routing:** Zai model synthesis, GLM overload failover, and native reasoning-level selection now follow the active model catalog more consistently. (#94461, #93241, #94067, #94136) Thanks @Pandah97, @chrysb, @0xghost42, @zhengli0922, @openperf, @civiltox, and @BorClaw.
- **Safer session and channel state:** channel switches reset stale origin fields, and cron delivery awareness stays attached to the target session. (#95328, #93580) Thanks @ZengWen-DT, @jalehman, @gorkem2020, and @scotthuang.
- **Trusted policies survive hook composition:** composed hook registries keep the trusted tool policies required by approval-sensitive flows. (#94545) Thanks @jesse-merhi.
### Changes
- **Agent and channel runtime:** fast-mode state now survives retries, fallback transitions, progress events, and embedded/CLI/ACP normalization; session and channel routing retain the current target and delivery context. (#85104, #93580, #95328) Thanks @alexph-dev, @vincentkoc, @scotthuang, @ZengWen-DT, @jalehman, and @gorkem2020.
- **Provider behavior:** model catalogs now supply the correct Zai base URL, overload classification, and native reasoning controls for live-discovered models. (#94461, #93241, #94067, #94136) Thanks @Pandah97, @chrysb, @0xghost42, @zhengli0922, @openperf, @civiltox, and @BorClaw.
## Unreleased
### Fixes
- **Fast-mode and policy correctness:** fallback cutoffs and reset notices are bounded, repeated progress events remain visible, Codex service-tier state is normalized, and trusted policies are not lost when hook registries are composed. (#85104, #94545) Thanks @alexph-dev, @vincentkoc, and @jesse-merhi.
- **Model and delivery edge cases:** Zai and GLM failover paths use the right runtime metadata, while stale channel-origin state no longer leaks across session changes. (#94461, #93241, #95328) Thanks @Pandah97, @chrysb, @0xghost42, @zhengli0922, @ZengWen-DT, @jalehman, and @gorkem2020.
- **Provider plugin onboarding:** setup refreshes provider plugin registry metadata after installing setup-selected provider plugins, so auth continuation uses the newly installed provider instead of stale registry state. (#95792) Thanks @snowzlmbot.
- **WeChat account routing:** `startAccount` preserves session routing by resolving manifest channel account config from raw account keys with opaque provider ids, while still ignoring manifest account keys that normalize to blocked object keys. (#93686) Thanks @zhangguiping-xydt.
### Complete contribution record
## 2026.6.10
This audited record covers the complete v2026.6.9..HEAD history: 12 merged PRs. The generation manifest also supplies direct commits as editorial input; the grouped notes above prioritize user impact.
Automatic fast mode starts short conversations quickly, then returns longer or fallback work to normal mode without losing visible state. Provider routing, channel progress, session identity, and trusted tool policies are more reliable, with smaller improvements spanning provider setup, diagnostics, and transcript tooling.
#### Pull requests
### Highlights
- **PR #86627** Keep core doctor health in contribution order. Thanks @giodl73-repo.
- **PR #93580** fix: preserve cron delivery awareness for target sessions. Thanks @scotthuang and @jalehman.
- **PR #95030** refactor: add SDK transcript identity target API. Thanks @jalehman.
- **PR #94838** refactor(copilot): complete harness lifecycle parity. Thanks @vincentkoc.
- **PR #95328** fix(sessions): reset stale per-channel origin fields on channel switch. Related #95325. Thanks @ZengWen-DT and @jalehman and @gorkem2020.
- **PR #94461** fix(zai): fall back to manifest baseUrl for synthesized GLM-5 models. Related #94269. Thanks @Pandah97 and @chrysb.
- **PR #93241** fix(agents): classify Zhipu GLM overload as overloaded for failover. Related #93211. Thanks @0xghost42 and @zhengli0922.
- **PR #94067** fix(channels): resolve native /think menu levels via runtime catalog for live-discovered models. Related #93835. Thanks @openperf and @civiltox.
- **PR #94136** fix(zai): expose GLM-5.2 reasoning levels [AI-assisted]. Thanks @BorClaw.
- **PR #85104** feat: fast talks auto mode. Related #85087. Thanks @alexph-dev.
- **PR #94545** fix: keep trusted policies with hook registry. Thanks @jesse-merhi.
- **PR #95792** fix(onboard): refresh provider plugin registry after setup installs. Related #95765. Thanks @snowzlmbot.
#### Automatic fast mode
- Adds [`/fast auto`](https://docs.openclaw.ai/tools/thinking) so short conversational calls can start quickly, while longer or fallback work returns to normal mode with the effective state still visible. [PR #85104](https://github.com/openclaw/openclaw/pull/85104), [Issue #85087](https://github.com/openclaw/openclaw/issues/85087). Thanks @alexph-dev and @vincentkoc.
- Shows the effective automatic fast-mode state in status instead of reducing it to on/off, and avoids carrying a cleared Codex service-tier choice into later runs. [8845f2f](https://github.com/openclaw/openclaw/commit/8845f2fd6143becc37110ab5021dd5e1517f0cdc). Thanks @vincentkoc.
- Keeps automatic fast-mode timing consistent when a turn switches to a fallback model. [075091d](https://github.com/openclaw/openclaw/commit/075091d0cab94053ff094268efc0acb225d514f4). Thanks @vincentkoc.
- Keeps the original fast-mode timing and progress behavior when a live model switch retries a turn. [d1e190f](https://github.com/openclaw/openclaw/commit/d1e190fbe822ad6ae4e660ce376b60ec9fdb0fba). Thanks @vincentkoc.
- Keeps automatic fast-mode progress and reset behavior distinct from explicit fast mode after a run switches modes. [20aec98](https://github.com/openclaw/openclaw/commit/20aec985545db7a24ea066e5bff1c47b789cbded). Thanks @vincentkoc.
- Shows the effective fast-mode value in connected-agent sessions instead of the configured value, so status reflects what the session is actually using. [9509aa0](https://github.com/openclaw/openclaw/commit/9509aa063c0ef3e32be1516fcb0c23606b6d5c7b). Thanks @vincentkoc.
- Keeps the effective automatic fast-mode setting visible through fallback transitions in connected-agent sessions. [7f5423c](https://github.com/openclaw/openclaw/commit/7f5423ca97174a3f16c211db54a6c96e5b3a6089). Thanks @vincentkoc.
- Keeps automatic fast-mode timing and progress consistent when reply and [scheduled-agent runs](https://docs.openclaw.ai/automation/cron-jobs) retry or switch models. [6c29f88](https://github.com/openclaw/openclaw/commit/6c29f88913796bfe05696556cd82246670b126f0). Thanks @vincentkoc.
- Keeps fast-mode cleanup and status consistent when a run switches between fallback models. [c4694f8](https://github.com/openclaw/openclaw/commit/c4694f84ffd52064f89609098cc4f8570fb72e1b). Thanks @vincentkoc.
- Shows the automatic fast-mode reset only when fallback work is finished, so status messages match the end of the transition. [f4d93c8](https://github.com/openclaw/openclaw/commit/f4d93c855bff6930f5e5d739b95e0c2612ec4899). Thanks @vincentkoc.
- Shows reset and delivery progress at the right time when auto-reply or other follow-up runs retry or leave automatic fast mode. [684e440](https://github.com/openclaw/openclaw/commit/684e44013778bd47d159e64b2595e4d09a92ebea). Thanks @vincentkoc.
### Channels and Messaging
#### Channel delivery and progress updates
- Prevents the next turn after a [scheduled message](https://docs.openclaw.ai/automation/cron-jobs) from losing what was delivered or whether delivery failed, so replies can use that context without exposing cron details in the channel. [PR #93580](https://github.com/openclaw/openclaw/pull/93580). Thanks @jalehman and @scotthuang.
- Prevents streamed channel progress from dropping a repeated status that represents a separate step, so each meaningful step remains visible in the draft. [2d42e52](https://github.com/openclaw/openclaw/commit/2d42e52ac5513e0bd824b8a0e069db83e04bc056). Thanks @vincentkoc.
- Prevents keyed streamed progress from staying on an older status, so viewers see the latest state instead of stale text. [8bb6472](https://github.com/openclaw/openclaw/commit/8bb6472c4de2eea06f1ba31d6ed679e2ac4581b0). Thanks @vincentkoc.
### Providers and Models
#### Provider model catalogs and reasoning controls
- Treats Zhipu/GLM overload responses as overloads, so a configured fallback is selected for the right reason instead of following the wrong failover path. [PR #93241](https://github.com/openclaw/openclaw/pull/93241), [Issue #93211](https://github.com/openclaw/openclaw/issues/93211). Thanks @0xghost42 and @zhengli0922.
- Prevents Telegram, Slack, and Discord `/think` menus for live Ollama models from hiding supported levels, so users can choose valid reasoning settings without guessing. [PR #94067](https://github.com/openclaw/openclaw/pull/94067), [Issue #93835](https://github.com/openclaw/openclaw/issues/93835). Thanks @civiltox and @openperf.
- Expands [`zai/glm-5.2` thinking choices](https://docs.openclaw.ai/tools/thinking) beyond binary on/off and sends high or max requests as the intended Z.AI reasoning effort. [PR #94136](https://github.com/openclaw/openclaw/pull/94136). Thanks @borclaw.
- Prevents bundled [Z.ai GLM-5 models](https://docs.openclaw.ai/providers/zai) from falling through to OpenAI and producing misleading API-key errors, so they use Z.AI by default. [PR #94461](https://github.com/openclaw/openclaw/pull/94461), [Issue #94269](https://github.com/openclaw/openclaw/issues/94269). Thanks @chrysb and @pandah97.
- Adds GLM-5.2 and Kimi K2.7 Code to the [OpenCode Go catalog](https://docs.openclaw.ai/providers/opencode-go) with current limits, so users can select the models from OpenClaw. [66f84a9](https://github.com/openclaw/openclaw/commit/66f84a9bf1082de26f92b2b3741cc2f34aba34fa). Thanks @samson1357924.
- Corrects `kimi-k2.7-code` capability listings so OpenCode Go users are not offered unsupported video prompts when the model accepts text and images. [715dc71](https://github.com/openclaw/openclaw/commit/715dc718fc5a2a5d6f7e9ec16e0269382b726e83).
#### Provider plugin onboarding
- Prevents first-run setup from skipping the selected provider's credential prompt after plugin installation, so onboarding continues with that provider instead of falling back to OpenAI. [PR #95792](https://github.com/openclaw/openclaw/pull/95792), [Issue #95765](https://github.com/openclaw/openclaw/issues/95765). Thanks @snowzlmbot.
### Memory, Sessions, and State
#### Session transcript SDK helpers
- Adds a durable [session-transcript SDK contract](https://docs.openclaw.ai/plugins/sdk-runtime) so plugins can read, append, publish, and lock the intended transcript without treating [legacy file paths](https://docs.openclaw.ai/plugins/sdk-subpaths) as identity. [PR #95030](https://github.com/openclaw/openclaw/pull/95030). Thanks @jalehman.
#### Cross-channel session identity
- Prevents a shared direct-message [session](https://docs.openclaw.ai/concepts/session) from carrying the previous [channel's identity](https://docs.openclaw.ai/channels/channel-routing) after a switch, so status, reactions, threads, and message references target the current channel. [PR #95328](https://github.com/openclaw/openclaw/pull/95328), [Issue #95325](https://github.com/openclaw/openclaw/issues/95325). Thanks @gorkem2020, @jalehman, and @zengwen-dt.
### Gateway, Security, and Trust
#### Prompt context boundaries
- Keeps empty prompts separate from hook-added context during compaction or session reuse in [Copilot and Codex sessions](https://docs.openclaw.ai/plugins/copilot), so prompt boundaries remain consistent. [PR #94838](https://github.com/openclaw/openclaw/pull/94838). Thanks @vincentkoc.
#### Trusted tool policy enforcement
- Keeps [approval-sensitive Gateway and plugin tools](https://docs.openclaw.ai/plugins/hooks) protected when connected extensions change, so configured safeguards continue to apply. [PR #94545](https://github.com/openclaw/openclaw/pull/94545). Thanks @jesse-merhi.
#### Trusted package redirects
- Prevents authenticated package-source tokens from being sent to an allowed redirect on another origin, while the valid redirected download still completes. [b0df6dc](https://github.com/openclaw/openclaw/commit/b0df6dc10eb5b9e9fdca93063a16316f8589954e).
### Clients and Interfaces
#### Docker and Podman setup timeouts
- Prevents [Docker](https://docs.openclaw.ai/install/docker) and [Podman](https://docs.openclaw.ai/install/podman) setup from running unbounded on hosts where GNU timeout is installed as `gtimeout`, so image pulls, builds, and detached startup receive the intended guard. [62b2e9e](https://github.com/openclaw/openclaw/commit/62b2e9ef14b4be6fd396621c8e5e248331f08695).
### Plugins, Packaging, and QA
#### Codex service-tier clearing
- Prevents cleared [Codex service tiers](https://docs.openclaw.ai/tools/thinking) from being persisted as explicit stale state, so resumed or switched conversations use the normal default instead. [cd32d9f](https://github.com/openclaw/openclaw/commit/cd32d9ff91caf84c0ead38796ef096cdc5bea06e). Thanks @vincentkoc.
#### StepFun provider installation
- Restores [ClawHub discovery](https://docs.openclaw.ai/plugins/reference/stepfun) for the [StepFun provider](https://docs.openclaw.ai/providers/stepfun) plugin, so operators can install it through either ClawHub or npm. [ecb82f1](https://github.com/openclaw/openclaw/commit/ecb82f1be93024be23c1b191ebea92c63230b6c0). Thanks @vincentkoc.
### Docs and Operator Workflows
#### Doctor check ordering
- Keeps core [`openclaw doctor`](https://docs.openclaw.ai/gateway/doctor) diagnostics in their normal order before extension checks, making lint and repair output easier to follow. [PR #86627](https://github.com/openclaw/openclaw/pull/86627). Thanks @giodl73-repo.
## 2026.6.9

View File

@@ -97,6 +97,23 @@ Welcome to the lobster tank! 🦞
4. **Test/CI-only PRs for known `main` failures** → Don't open a PR. The Maintainer team is already tracking those failures, and PRs that only tweak tests or CI to chase them will be closed unless they are required to validate a new fix.
5. **Questions** → Discord [#help](https://discord.com/channels/1456350064065904867/1459642797895319552) / [#users-helping-users](https://discord.com/channels/1456350064065904867/1459007081603403828)
## Issue, PR, and Contact Routing
Start from this routing map before creating GitHub items:
| Situation | Use | Required evidence |
| -------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------- |
| Product bug, regression, crash, or behavior defect | [Bug report](https://github.com/openclaw/openclaw/issues/new?template=bug_report.yml) | Repro steps, expected vs actual behavior, version, OS, model/provider route when relevant, logs/screenshots, impact |
| Documentation bug or missing/contradictory docs | [Docs bug report](https://github.com/openclaw/openclaw/issues/new?template=docs_bug_report.yml) | Affected docs path or URL, verification steps, expected docs content, actual docs content, impact, evidence |
| New feature, architecture change, or product improvement | [Feature request](https://github.com/openclaw/openclaw/issues/new?template=feature_request.yml) or Discord first | Problem, proposed solution, alternatives, impact, examples or prior art |
| Onboarding, setup help, or general support question | Discord [#help](https://discord.com/channels/1456350064065904867/1459642797895319552) / [#users-helping-users](https://discord.com/channels/1456350064065904867/1459007081603403828) | Do not open a GitHub issue unless there is a concrete product defect or docs gap |
| Security vulnerability | See [Report a Vulnerability](#report-a-vulnerability) below | Do not file public issues for private security reports |
| PR for an existing or newly filed issue | Use the [PR template](.github/pull_request_template.md) | Visible `Closes #<issue>` or `Related: #<issue>`, problem, shipped solution, user impact, validation evidence |
For agent-authored or otherwise non-trivial work, create or reuse the issue first, then open the PR against it. Bugs and very small fixes may go straight to PR, but still link existing context when it exists and fill out the PR template.
Do not guess who to tag. Let issue forms, labels/automation, `.github/CODEOWNERS`, and the maintainer areas above route the work. Mention a maintainer only when their listed area or owned path is directly relevant and you need a decision; otherwise rely on normal review. For coordinated change sets, ask in **#clawtributors** before opening more than the PR limit.
## PR Limits
We cap at **20 open PRs per author**. If you exceed this, the `r: too-many-prs` label is added and your PR is auto-closed. This is a hard limit.

View File

@@ -304,6 +304,9 @@ by Peter Steinberger and the community.
## Community
See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines, maintainers, and how to submit PRs.
Use the [issue chooser](https://github.com/openclaw/openclaw/issues/new/choose) for bugs, docs bugs, and feature requests;
ask setup/support questions in [Discord](https://discord.gg/clawd); and report vulnerabilities through [SECURITY.md](SECURITY.md).
PRs should link the relevant issue when possible and follow the [PR template](.github/pull_request_template.md) with problem, impact, and evidence.
AI/vibe-coded PRs welcome! 🤖
Special thanks to [Mario Zechner](https://mariozechner.at/) for his support and for

View File

@@ -2,6 +2,53 @@
<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0">
<channel>
<title>OpenClaw</title>
<item>
<title>2026.6.10</title>
<pubDate>Fri, 26 Jun 2026 23:37:36 +0000</pubDate>
<link>https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml</link>
<sparkle:version>2606001090</sparkle:version>
<sparkle:shortVersionString>2026.6.10</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
<description><![CDATA[<h2>OpenClaw 2026.6.10</h2>
<h3>Highlights</h3>
<ul>
<li><strong>Automatic fast mode for talks:</strong> OpenClaw can enable fast mode for short conversational turns, then return to normal mode for longer runs with bounded fallback and delivery behavior. (#85104) Thanks @alexph-dev and @vincentkoc.</li>
<li><strong>More reliable model routing:</strong> Zai model synthesis, GLM overload failover, and native reasoning-level selection now follow the active model catalog more consistently. (#94461, #93241, #94067, #94136) Thanks @Pandah97, @chrysb, @0xghost42, @zhengli0922, @openperf, @civiltox, and @BorClaw.</li>
<li><strong>Safer session and channel state:</strong> channel switches reset stale origin fields, and cron delivery awareness stays attached to the target session. (#95328, #93580) Thanks @ZengWen-DT, @jalehman, @gorkem2020, and @scotthuang.</li>
<li><strong>Trusted policies survive hook composition:</strong> composed hook registries keep the trusted tool policies required by approval-sensitive flows. (#94545) Thanks @jesse-merhi.</li>
</ul>
<h3>Changes</h3>
<ul>
<li><strong>Agent and channel runtime:</strong> fast-mode state now survives retries, fallback transitions, progress events, and embedded/CLI/ACP normalization; session and channel routing retain the current target and delivery context. (#85104, #93580, #95328) Thanks @alexph-dev, @vincentkoc, @scotthuang, @ZengWen-DT, @jalehman, and @gorkem2020.</li>
<li><strong>Provider behavior:</strong> model catalogs now supply the correct Zai base URL, overload classification, and native reasoning controls for live-discovered models. (#94461, #93241, #94067, #94136) Thanks @Pandah97, @chrysb, @0xghost42, @zhengli0922, @openperf, @civiltox, and @BorClaw.</li>
</ul>
<h3>Fixes</h3>
<ul>
<li><strong>Fast-mode and policy correctness:</strong> fallback cutoffs and reset notices are bounded, repeated progress events remain visible, Codex service-tier state is normalized, and trusted policies are not lost when hook registries are composed. (#85104, #94545) Thanks @alexph-dev, @vincentkoc, and @jesse-merhi.</li>
<li><strong>Model and delivery edge cases:</strong> Zai and GLM failover paths use the right runtime metadata, while stale channel-origin state no longer leaks across session changes. (#94461, #93241, #95328) Thanks @Pandah97, @chrysb, @0xghost42, @zhengli0922, @ZengWen-DT, @jalehman, and @gorkem2020.</li>
<li><strong>Provider plugin onboarding:</strong> setup refreshes provider plugin registry metadata after installing setup-selected provider plugins, so auth continuation uses the newly installed provider instead of stale registry state. (#95792) Thanks @snowzlmbot.</li>
</ul>
<h3>Complete contribution record</h3>
This audited record covers the complete v2026.6.9..HEAD history: 12 merged PRs. The generation manifest also supplies direct commits as editorial input; the grouped notes above prioritize user impact.
<h4>Pull requests</h4>
<ul>
<li><strong>PR #86627</strong> Keep core doctor health in contribution order. Thanks @giodl73-repo.</li>
<li><strong>PR #93580</strong> fix: preserve cron delivery awareness for target sessions. Thanks @scotthuang and @jalehman.</li>
<li><strong>PR #95030</strong> refactor: add SDK transcript identity target API. Thanks @jalehman.</li>
<li><strong>PR #94838</strong> refactor(copilot): complete harness lifecycle parity. Thanks @vincentkoc.</li>
<li><strong>PR #95328</strong> fix(sessions): reset stale per-channel origin fields on channel switch. Related #95325. Thanks @ZengWen-DT and @jalehman and @gorkem2020.</li>
<li><strong>PR #94461</strong> fix(zai): fall back to manifest baseUrl for synthesized GLM-5 models. Related #94269. Thanks @Pandah97 and @chrysb.</li>
<li><strong>PR #93241</strong> fix(agents): classify Zhipu GLM overload as overloaded for failover. Related #93211. Thanks @0xghost42 and @zhengli0922.</li>
<li><strong>PR #94067</strong> fix(channels): resolve native /think menu levels via runtime catalog for live-discovered models. Related #93835. Thanks @openperf and @civiltox.</li>
<li><strong>PR #94136</strong> fix(zai): expose GLM-5.2 reasoning levels [AI-assisted]. Thanks @BorClaw.</li>
<li><strong>PR #85104</strong> feat: fast talks auto mode. Related #85087. Thanks @alexph-dev.</li>
<li><strong>PR #94545</strong> fix: keep trusted policies with hook registry. Thanks @jesse-merhi.</li>
<li><strong>PR #95792</strong> fix(onboard): refresh provider plugin registry after setup installs. Related #95765. Thanks @snowzlmbot.</li>
</ul>
<p><a href="https://github.com/openclaw/openclaw/blob/main/CHANGELOG.md">View full changelog</a></p>
]]></description>
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.6.10/OpenClaw-2026.6.10.zip" length="56115790" type="application/octet-stream" sparkle:edSignature="MEeGG8+WePhUg9uDShznmdhhAgy/WWe7bAwr4XRTauNdrM441iziQYIlwhfNrtHDHX+uE1/tkRtIMcELfuekAg=="/>
</item>
<item>
<title>2026.6.8</title>
<pubDate>Tue, 16 Jun 2026 17:17:20 +0000</pubDate>
@@ -124,132 +171,5 @@
]]></description>
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.6.5/OpenClaw-2026.6.5.zip" length="55725877" type="application/octet-stream" sparkle:edSignature="EKr7gCfpEVStis9HSADJk1CWYbmH2MHMqSgNfZvLbBFCBWmk3pjBJS6K2qkxkq5lIbTj4H+Lo7Iri6ip/xTGDA=="/>
</item>
<item>
<title>2026.6.1</title>
<pubDate>Wed, 03 Jun 2026 21:26:22 +0000</pubDate>
<link>https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml</link>
<sparkle:version>2026060190</sparkle:version>
<sparkle:shortVersionString>2026.6.1</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
<description><![CDATA[<h2>OpenClaw 2026.6.1</h2>
<h3>Highlights</h3>
<ul>
<li>Agents and CLI-backed runtimes recover more cleanly from interrupted tool calls, stale session bindings, compaction handoffs, and media delivery retries. (#88129, #88136, #88141, #88162, #88182)</li>
<li>Channels and mobile delivery are steadier across Telegram, WhatsApp, iMessage, Slack, Discord, Microsoft Teams, Google Chat, Google Meet, and iOS realtime Talk. (#88096, #88105, #88183, #88231)</li>
<li>Provider and plugin requests now bound more timers, retries, OAuth/device-code lifetimes, media downloads, local service probes, and generated-content polling paths before they can hang a run.</li>
<li>Skills, session metadata, gateway runtime state, plugin metadata, memory watchers, and store writes do less repeated work on hot paths while keeping config, dispatch, and Linux file-watch behavior stable. (#89185, #89188, #85351) Thanks @RomneyDa and @NianJiuZst.</li>
<li>Skills and plugin loading now handle stale disabled snapshots and loader failures more clearly, so channel turns avoid disabled SecretRefs and operators get better recovery guidance. (#79072, #79173) Thanks @zeus1959.</li>
<li>Workboard, SecretRef plugin manifests, hosted iOS push relay, and external Copilot/Tokenjuice packaging add broader orchestration, integration, and plugin delivery surfaces. (#82326, #87469, #87796, #88107, #88117)</li>
<li>Skill Workshop now has a fuller Control UI flow with proposal lists, today actions, revision handoff, searchable file previews, review states, locale coverage, and reusable session routing.</li>
<li>Chat and Control UI startup paths keep sends alive through history loading, stream deltas incrementally, skip markdown work while streaming, keep drafts local while typing, clear the composer after sends, trace first-output latency, prioritize first connect, and expose calmer composer controls. (#88772, #88825, #88998, #89030, #89106) Thanks @vincentkoc and @sallyom.</li>
<li>Provider coverage and model metadata now include MiniMax M3, account OAuth endpoints, Google/Vertex catalog fixes, OpenRouter SQLite model caching, Copilot Claude 1M capabilities, Foundry reasoning alignment, and OpenAI response replay guards. (#88480, #88512, #88851, #88860)</li>
<li>iMessage monitor state, inbound queues, and plugin install ledgers moved toward SQLite-backed state so restarts and local monitors recover with less duplicate filesystem scanning. (#88794, #88797)</li>
<li>Release, CI, Docker, E2E, plugin install, and diagnostics lanes now cap more logs, response bodies, readiness probes, artifact checks, status polling, child workflow waits, docker package cleanup, quiet test stalls, and rollback snapshots so failures report bounded proof instead of stalling. (#88966) Thanks @RomneyDa.</li>
</ul>
<h3>Changes</h3>
<ul>
<li>Docs: add a dedicated Skill Workshop guide covering governed skill creation, reviewable proposals, CLI, Gateway, agent tool behavior, approval policy, support files, and recovery, and refresh the ClawHub showcase cards. (#88734) Thanks @shakkernerd and @vyctorbrzezowski.</li>
<li>Skills: let the <code>skill_workshop</code> agent tool apply, reject, and quarantine explicit proposals through the guarded review flow. Thanks @shakkernerd.</li>
<li>Skills: let proposals carry approved support files under standard skill folders, with scanner, hash, and rollback safeguards. Thanks @shakkernerd.</li>
<li>Skills: let pending proposals be revised in place with versioned, dated proposal frontmatter before approval. Thanks @shakkernerd.</li>
<li>Skills: add Skill Workshop with pending proposals, CLI/Gateway review actions, rollback metadata, and the <code>skill_workshop</code> agent tool. Thanks @shakkernerd.</li>
<li>Skill Workshop: add the Control UI navigation, styled dashboard, proposal today view, revision dialog, file preview modal, searchable preview files, reusable session handoff, and localized strings.</li>
<li>Plugins: externalize Tokenjuice as the official <code>@openclaw/tokenjuice</code> plugin with npm and ClawHub publish metadata.</li>
<li>Plugins: externalize the GitHub Copilot agent runtime as the official <code>@openclaw/copilot</code> plugin with npm and ClawHub publish metadata.</li>
<li>iOS: add hosted push relay defaults, realtime Talk playback, and a guarded WebSocket ping path for more reliable mobile sessions. (#88096, #88105, #88231)</li>
<li>iOS: support native iPad display layouts.</li>
<li>Workboard: add orchestration primitives and agent coordination tools for multi-agent planning and run tracking. (#87469)</li>
<li>Workboard: wire task-backed board runs and show task comments in the edit modal.</li>
<li>Code mode: add internal namespaces for scoped agent/global sessions and exact namespace tool dispatch. (#88043)</li>
<li>Code mode: add MCP API files and docs for code-mode integrations.</li>
<li>Control UI: add a Dreaming-tab agent selector and propagate the selected agent through Dreaming status, diary, and diary actions. (#78748) Thanks @stevenepalmer.</li>
<li>Control UI: add calmer chat composer controls, local draft typing state, and first-output latency instrumentation for active chat entry. (#88772, #88998) Thanks @vincentkoc.</li>
<li>Plugins: add a SecretRef provider integration manifest contract and extract shared LLM core packages for provider/plugin reuse. (#82326, #88117)</li>
<li>Plugins: persist the plugin install index in SQLite so installed package lookup survives reloads with less filesystem scanning. (#88794)</li>
<li>Providers: add MiniMax M3 model support. (#88860)</li>
<li>Doctor: add disk space health checks and stabilize post-upgrade JSON probes.</li>
<li>Channels: store inbound queues in SQLite and migrate iMessage monitor state to SQLite-backed tracking. (#88797)</li>
<li>Skills: add the core skills index and centralize skills runtime loading, status, filtering, and prompt formatting.</li>
</ul>
<h3>Fixes</h3>
<ul>
<li>Release/CI/E2E: fail early when Crabbox sparse-sync full checkouts do not have enough local disk, with guidance for moving the sync root.</li>
<li>Build: render independent CLI startup metadata help snapshots concurrently to cut cold build-all metadata time.</li>
<li>Plugins: stop timed-out package-boundary prep steps by process group so descendant TypeScript/helper processes do not survive local check cleanup.</li>
<li>Control UI: serve static assets asynchronously after safe-open checks so large UI files do not block Gateway request handling.</li>
<li>Scripts/UI: forward direct wrapper SIGHUP shutdown to child processes so terminal hangups do not leave wrapped dev commands running.</li>
<li>Gateway: return the post-expiration pending-work revision from node drains so reconnecting nodes do not observe stale queue revisions after expired items are pruned.</li>
<li>Release/CI/E2E: keep temporary full-sync checkouts alive while slow Crabbox leases boot, so sparse worktree runs do not lose their sync source before file-list generation.</li>
<li>Release/CI/E2E: normalize inherited Linux <code>C.UTF-8</code> locale settings before raw AWS macOS Crabbox bootstrap commands, avoiding macOS locale warnings during package-manager hydration.</li>
<li>Release/CI/E2E: keep gateway watch regression checks from copying large static plugin assets inside the measured idle window.</li>
<li>Update: keep core updates nonblocking when a missing external plugin repair download stalls, while still blocking installed active plugin payload smoke failures.</li>
<li>Agents/providers: keep streaming tool-call argument parsing record-shaped when providers emit valid non-object JSON such as <code>null</code> or arrays.</li>
<li>Release/CI/E2E: reset incremental log readers when watched log files rotate without shrinking, so same-size replacements do not hide new readiness or RPC lines.</li>
<li>Talk: preserve explicit <code>null</code> payloads on controller-created turn and output-audio lifecycle events.</li>
<li>Agents/TUI: keep local custom provider runs from loading plugin runtime and auth alias metadata when plugins are disabled.</li>
<li>Agents/TUI: restore in-flight TUI run switch-back behavior, keep no-policy native hook fallback available, guard vanished workspaces, and keep lightweight isolated subagents lightweight.</li>
<li>Agents/media: keep async image, music, and video generation starts from ending the Codex turn, so mixed requests can continue with summaries or other work while media renders in the background.</li>
<li>Agents/Codex: keep public OpenAI API-key profiles from being treated as native Codex app-server auth while preserving persisted Codex OAuth sessions.</li>
<li>Agents/Codex: stream Codex app-server final-answer partials to live reply previews, preserve ACP metadata in SQLite, prefer real tool results over synthetic repair output, prevent aborted app-server turn handles from lingering, migrate legacy OpenAI Codex <code>lastGood</code> auth state, and preserve workspace/session metadata through ACP runtime refactors. (#88405, #88724, #88730) Thanks @vincentkoc.</li>
<li>Control UI: keep collapsed tool cards labeled with the tool name and action instead of generic output text. Thanks @shakkernerd.</li>
<li>Agents/Codex: surface Skill Workshop guidance in Codex app-server prompts when <code>skill_workshop</code> is available. Thanks @shakkernerd.</li>
<li>Skill Workshop: restore and localize the Control UI board/today view switcher so review workflows keep their intended layout toggle across locales. Thanks @shakkernerd.</li>
<li>Agents/auth: write auth profiles atomically, dispatch auth failures by type, add force re-login recovery, preserve workspaces during state-only uninstall, and compact before oversized turns so recovery paths avoid partial state. (#89181) Thanks @RomneyDa.</li>
<li>Skills: skip disabled skill env overrides from stale persisted snapshots so disabled skill <code>apiKey</code> SecretRefs cannot abort embedded or channel turns. (#79072, #79173) Thanks @zeus1959.</li>
<li>Skill Workshop: render the Control UI tab from filtered navigation state and keep filtered fallback routing stable.</li>
<li>CLI: avoid live catalog validation during <code>openclaw agents add</code>, so adding a secondary agent no longer depends on provider catalog availability. (#76284, #88314) Thanks @zhangguiping-xydt.</li>
<li>CLI: keep <code>plugins list --json</code> on the snapshot-only path so plugin sweeps avoid loading the full runtime status graph.</li>
<li>CLI/desktop: bridge WSL clipboard operations through the shell, recognize manual-update launchd jobs, and keep machine-readable startup output parseable during progress setup. (#88764, #88689) Thanks @alexzhu0.</li>
<li>Plugins: make PixVerse external-plugin ClawHub metadata explicit and keep it out of bundled dist builds.</li>
<li>Plugins: clarify plugin loader failure guidance so missing or incompatible plugin packages point operators at the right repair path.</li>
<li>Plugins: preserve npm plugin roots after blocked installs, skip plugin-local <code>openclaw</code> peer symlinks during rollback snapshots, relink those peers after restore, isolate cached tool runtime siblings, and isolate web-provider factory failures so one bad plugin does not poison sibling runtime paths. (#77237, #88807)</li>
<li>Cron: keep SQLite cron migrations compatible with legacy run-log tables, archived job stores, diagnostic cron names, and legacy one-shot delete-after-run behavior. (#88285)</li>
<li>Cron: keep update delivery validation scoped, harden restart state, and retire MCP runtimes on isolated cron cleanup.</li>
<li>Memory: serialize QMD update/embed writes per store, reduce Linux watcher fan-out, retry transient FileProvider-backed reads, preserve phase signals on read errors, harden envelope metadata sanitization, reattach Linux native watchers when directories are recreated, and rewrite generated transcript paths on rollover so memory/search state survives concurrent gateway and CLI activity. (#66339, #85931, #89185, #89188, #85351) Thanks @openperf, @amittell, @RomneyDa, and @NianJiuZst.</li>
<li>Memory: keep vector-disabled FTS indexes from resolving embedding providers during sync and search.</li>
<li>Providers: bound generated media downloads from OpenAI, Runway, xAI, MiniMax, BytePlus, DashScope-compatible, FAL, OpenRouter, Google, Vydra, and Comfy providers.</li>
<li>Providers: resolve Google defaults to <code>google-generative-ai</code>, register Vertex static catalog rows, align Foundry reasoning metadata, skip DeepSeek V4 thinking params on Foundry fallback, use MiniMax account OAuth endpoints, preserve Copilot Claude 1M capabilities, suppress disabled Ollama reasoning output, forward Gemini stop sequences, strip Kimi-incompatible Anthropic cache markers, keep OpenAI stop-finished tool calls, and avoid replay ids when the Responses store is disabled. (#88480, #88512, #76612) Thanks @coder999999999, @BryanTegomoh, and @vliuyt.</li>
<li>Providers: cap GitHub Copilot OAuth request timeouts before creating abort signals.</li>
<li>Cron: retry recurring jobs after transient model rate limits before waiting for the next scheduled slot.</li>
<li>Agents/Codex: keep live session locks during cleanup, recover interrupted CLI tool transcripts, preserve Codex auth and compaction session identity, clear orphan tool state, cap app-server idle timers, and keep media completion delivery retryable. (#88129, #88136, #88141, #88162, #88182)</li>
<li>Chat/UI: show Gateway chat failures as visible assistant messages in the Control UI instead of only setting an invisible error state.</li>
<li>Channels: cap Telegram, Discord, WhatsApp, Signal, Feishu, Google Chat, Microsoft Teams, QQBot, Nostr, Zalo, Zalouser, and Nextcloud-style request/retry timers; preserve SMS approval reply routes; and retry WhatsApp QR login 408 timeouts. (#88183)</li>
<li>Security/config parsing: reject unsafe OAuth/token lifetimes, retry-after delays, inbound timestamps, response body sizes, command timeout config, sandbox observer token TTLs, and gateway WebSocket calls after close.</li>
<li>Providers/media: cap local service, model, usage, queue, generated media, TTS, music, workflow polling, and provider OAuth request timers across hosted and local providers.</li>
<li>Release/CI/E2E: bound release candidate reads, beta smoke REST calls, plugin npm verification commands, changelog restore, cross-OS process groups, kitchen-sink and bundled plugin readiness probes, secret-provider probes, Telegram credential timeouts, Control UI i18n and CLI startup metadata generation, Vitest routing, dependency guard admin approvals, child workflow failure detection, quiet Node test shard stalls, docker package cleanup, and mainline test flakes. (#88127, #88137, #88155, #88160, #88966) Thanks @RomneyDa.</li>
<li>Release/CI/E2E: keep Kitchen Sink live plugin MCP probes resolving source-checkout workspace packages and align the live gauntlet with current Kitchen Sink diagnostics.</li>
<li>Release/CI/E2E: run the secret-provider integration proof through the repo pnpm runner so native macOS and Windows validation use the hydrated package-manager shim.</li>
<li>Release/CI/E2E: run the Telegram desktop proof gateway through the repo pnpm runner so native macOS proof uses the hydrated package-manager shim.</li>
<li>Docs/CI: run Mintlify anchor checks through the repo pnpm runner so docs link validation works when pnpm is only available through the hydrated package-manager shim.</li>
<li>Agents: keep configured fallback model metadata typed so provider params, context-token caps, and media input limits do not break changed-gate typechecks.</li>
<li>Agents: accept hidden <code>sessions_send</code> body aliases before validation while keeping the model-facing <code>message</code> schema canonical. (#88229) Thanks @zhangguiping-xydt.</li>
<li>Chat/UI: preserve startup chat sends during history loading, unblock the initial Control UI chat send, stream chat deltas incrementally, skip markdown parsing while streaming, keep drafts local while typing, guard composer rerenders, honor Chromium executable overrides, and detect system Chromium for E2E. (#88998) Thanks @vincentkoc.</li>
<li>Channels: stop schema-padded poll modifiers from turning normal <code>send</code> actions into invalid poll sends. (#89601) Thanks @codezz.</li>
<li>Channels: preserve long Feishu streaming replies, send visible fallbacks when accepted Feishu turns produce no final reply, tolerate iMessage self-chat timestamp skew, preserve colon-prefixed slash commands in mention parsing, decode Nostr <code>npub</code> allowlists correctly, and suppress raw provider errors during channel delivery. (#87896)</li>
<li>Config/status/doctor: skip unresolved shell references in state-dir dotenv files, resolve gateway auth secrets during deep status audits, respect explicit PI runtime policy, report runtime tool-schema errors, and keep post-upgrade JSON stable. (#88288)</li>
<li>Gateway/session state: list commands from the Gateway plugin registry, harden MCP loopback tool schemas, hide phantom agent-store rows from <code>sessions.list</code>, make task persistence failures explicit, and carry session UUIDs on interactive dispatch events.</li>
<li>Gateway/plugins: narrow plugin lookup memoization to the stable plugin/runtime inputs, avoiding repeated lookup work without mixing disabled or filtered plugin state.</li>
<li>OpenAI/TTS: handle speed directives for OpenAI TTS voices. (#74089)</li>
<li>CI/Crabbox: keep default runner capacity on the Azure credit-backed on-demand D4 lane with the Azure SSH port and a Git-independent full check job, so broad validation avoids low-priority spot quota stalls, hydrate port mismatches, non-Git hydrated workspaces, and stale AWS region hints.</li>
<li>CI/Crabbox: route Crabbox wrapper and Testbox workflow edits to their regression tests so changed-test gates do not silently run zero specs.</li>
<li>CI/workflows: route workflow sanity helper edits to their guard tests and cover composite-action input interpolation checks.</li>
<li>CI/tooling: route CI scope, dependency, changelog, and docs helper edits to their owner tests instead of silently skipping changed-test coverage.</li>
<li>CI/tooling: route package, release, and install helper edits to their owner tests so changed-test gates cover publish and installer script changes.</li>
<li>CI/tooling: route shared script library edits through their owner tests so lock, process, safety, and scan helpers do not skip changed-test coverage.</li>
<li>CI/tooling: skip expensive import-graph scans once a changed diff already requires broad fallback, keeping local changed-test planning fast while still collecting explicit owner tests.</li>
<li>CI/tooling: route script edits through conventional owner tests when matching <code>test/scripts</code> or <code>src/scripts</code> coverage already exists.</li>
<li>CI/tooling: honor option terminators in the memory FD repro script so follow-on arguments are not reparsed.</li>
<li>Release/CI/E2E: assert plugin lifecycle runtime inspect output instead of only capturing it.</li>
<li>Release/CI/E2E: make gateway-network prove the advertised health RPC and retry early WebSocket closes without burning full open timeouts.</li>
<li>Release/CI/E2E: honor option terminators across release, Parallels smoke, plugin gauntlet, and extension-memory scripts.</li>
<li>Release/CI/E2E: fail plugin gateway gauntlet QA chunks when the requested suite summary is missing or invalid.</li>
<li>Performance: prebuild QA runtime probes with generated plugin assets but without CLI startup metadata.</li>
<li>Performance: skip declaration bundling for runtime-only CLI startup and gateway watch build profiles.</li>
<li>Performance: reuse prepared provider handles, strict tool schemas, gateway runtime metadata, session maintenance config, plugin metadata, bundled skill allowlists, package-local plugin artifacts, single-entry store writes, and validated/serialized session prompt blobs.</li>
</ul>
<p><a href="https://github.com/openclaw/openclaw/blob/main/CHANGELOG.md">View full changelog</a></p>
]]></description>
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.6.1/OpenClaw-2026.6.1.zip" length="55062100" type="application/octet-stream" sparkle:edSignature="PVp8E2HBCvikB/0LCr36lFEyHPAzoFA2ScT6LW27FlzvP+m4r1AEuVN2UrtgWlpkGSsn4Eav0kPJe32u4ObNBw=="/>
</item>
</channel>
</rss>

17421
apps/.i18n/native-source.json Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -56,6 +56,38 @@ Recommended workflow:
The third-party flavor is archived as a signed APK for non-Play distribution. It is not uploaded by the Play release lane.
## Release SHA tracking
Successful Play build uploads create a non-tag Git ref that records the source
commit for the uploaded store build:
```text
refs/openclaw/mobile-releases/android/<versionName>-<versionCode>
```
Example:
```text
refs/openclaw/mobile-releases/android/2026.6.10-2026061008
```
These refs are intentionally outside `refs/tags/*` and `refs/heads/*`. They do
not appear on GitHub release or tag pages, and they do not participate in the
core OpenClaw release machinery.
`pnpm android:release:upload` checks the ref before uploading the Play build and
records it only after `upload_to_play_store` succeeds. Existing refs are
immutable: the same ref at the same SHA is accepted, while the same ref at a
different SHA fails. `GOOGLE_PLAY_VALIDATE_ONLY=1` still checks the ref but does
not record it because no Play build is published.
Useful direct commands:
```bash
pnpm mobile:release:preflight -- --platform android --version 2026.6.10 --version-code 2026061008
pnpm mobile:release:resolve -- --platform android --version 2026.6.10 --version-code 2026061008
```
## Signing model
`apps/android/Config/ReleaseSigning.json` pins the Android signing assets in the shared private `apps-signing` repo. The Android pipeline uses the same `MATCH_PASSWORD` release-owner secret as iOS, but the Android files are managed by `scripts/android-release-signing.mjs` instead of Fastlane `match`.

View File

@@ -198,6 +198,58 @@ def capture_android_screenshots!
sh(shell_join(["bash", File.join(repo_root, "scripts", "android-screenshots.sh")]))
end
def mobile_release_ref_script
File.join(repo_root, "scripts", "mobile-release-ref.ts")
end
def release_git_sha
stdout, stderr, status = Open3.capture3("git", "rev-parse", "HEAD", chdir: repo_root)
UI.user_error!("Unable to resolve release Git SHA: #{stderr.strip}") unless status.success?
stdout.strip
end
def mobile_release_ref_command(command, platform:, version:, build: nil, version_code: nil, sha: nil)
args = [
"node",
"--import",
"tsx",
mobile_release_ref_script,
command,
"--platform",
platform,
"--version",
version,
"--root",
repo_root,
]
args.push("--build", build.to_s) if build
args.push("--version-code", version_code.to_s) if version_code
args.push("--sha", sha.to_s) if sha
sh(shell_join(args))
end
def ensure_mobile_release_ref_available!(platform:, version:, build: nil, version_code: nil, sha: nil)
mobile_release_ref_command(
"preflight",
platform: platform,
version: version,
build: build,
version_code: version_code,
sha: sha
)
end
def record_mobile_release_ref!(platform:, version:, build: nil, version_code: nil, sha: nil)
mobile_release_ref_command(
"record",
platform: platform,
version: version,
build: build,
version_code: version_code,
sha: sha
)
end
def read_android_release_signing_properties!(path)
UI.user_error!("Missing materialized Android release signing properties at #{path}.") unless File.exist?(path)
@@ -282,6 +334,13 @@ def upload_play_store_metadata!(version_metadata)
end
def upload_play_store_build!(version_metadata, upload_metadata: false, upload_images: false, upload_screenshots: false)
release_sha = release_git_sha
ensure_mobile_release_ref_available!(
platform: "android",
version: version_metadata.fetch(:version),
version_code: version_metadata.fetch(:version_code),
sha: release_sha
)
ENV["SUPPLY_UPLOAD_SCREENSHOTS"] = "1" if upload_screenshots
validate_android_screenshots!
sync_android_changelog!(version_metadata.fetch(:version_code))
@@ -302,6 +361,15 @@ def upload_play_store_build!(version_metadata, upload_metadata: false, upload_im
skip_upload_screenshots: !upload_screenshots,
validate_only: play_validate_only?
)
unless play_validate_only?
record_mobile_release_ref!(
platform: "android",
version: version_metadata.fetch(:version),
version_code: version_metadata.fetch(:version_code),
sha: release_sha
)
end
end
load_env_file(File.join(ANDROID_FASTLANE_ROOT, ".env"))

File diff suppressed because it is too large Load Diff

View File

@@ -49,13 +49,15 @@ final class ShareViewController: UIViewController {
self.draftTextView.textContainerInset = UIEdgeInsets(top: 12, left: 10, bottom: 12, right: 10)
self.sendButton.translatesAutoresizingMaskIntoConstraints = false
self.sendButton.setTitle("Send to OpenClaw", for: .normal)
self.sendButton.setTitle(
NSLocalizedString("Send to OpenClaw", comment: "Share extension send action"),
for: .normal)
self.sendButton.titleLabel?.font = .preferredFont(forTextStyle: .headline)
self.sendButton.addTarget(self, action: #selector(self.handleSendTap), for: .touchUpInside)
self.sendButton.isEnabled = false
self.cancelButton.translatesAutoresizingMaskIntoConstraints = false
self.cancelButton.setTitle("Cancel", for: .normal)
self.cancelButton.setTitle(NSLocalizedString("Cancel", comment: "Share extension cancel action"), for: .normal)
self.cancelButton.addTarget(self, action: #selector(self.handleCancelTap), for: .touchUpInside)
let buttons = UIStackView(arrangedSubviews: [self.cancelButton, self.sendButton])
@@ -84,7 +86,7 @@ final class ShareViewController: UIViewController {
private func prepareDraft() async {
let traceId = UUID().uuidString
ShareGatewayRelaySettings.saveLastEvent("Share opened.")
self.showStatus("Preparing share…")
self.showStatus(NSLocalizedString("Preparing share…", comment: "Share extension preparation status"))
self.logger.info("share begin trace=\(traceId, privacy: .public)")
let extracted = await self.extractSharedContent()
let payload = extracted.payload
@@ -102,10 +104,12 @@ final class ShareViewController: UIViewController {
}
if message.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
ShareGatewayRelaySettings.saveLastEvent("Share ready: waiting for message input.")
self.showStatus("Add a message, then tap Send.")
self.showStatus(NSLocalizedString(
"Add a message, then tap Send.",
comment: "Share extension empty draft guidance"))
} else {
ShareGatewayRelaySettings.saveLastEvent("Share ready: draft prepared.")
self.showStatus("Edit text, then tap Send.")
self.showStatus(NSLocalizedString("Edit text, then tap Send.", comment: "Share extension draft guidance"))
}
}
@@ -125,7 +129,7 @@ final class ShareViewController: UIViewController {
let trimmed = message.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else {
ShareGatewayRelaySettings.saveLastEvent("Share blocked: message is empty.")
self.showStatus("Message is empty.")
self.showStatus(NSLocalizedString("Message is empty.", comment: "Share extension empty message status"))
return
}
@@ -134,20 +138,23 @@ final class ShareViewController: UIViewController {
self.sendButton.isEnabled = false
self.cancelButton.isEnabled = false
}
self.showStatus("Sending to OpenClaw gateway…")
self.showStatus(NSLocalizedString("Sending to OpenClaw gateway…", comment: "Share extension sending status"))
ShareGatewayRelaySettings.saveLastEvent("Sending to gateway…")
do {
try await self.sendMessageToGateway(trimmed, attachments: self.pendingAttachments)
ShareGatewayRelaySettings.saveLastEvent(
"Sent to gateway (\(trimmed.count) chars, \(self.pendingAttachments.count) attachment(s)).")
self.showStatus("Sent to OpenClaw.")
self.showStatus(NSLocalizedString("Sent to OpenClaw.", comment: "Share extension success status"))
DispatchQueue.main.asyncAfter(deadline: .now() + 0.45) {
self.extensionContext?.completeRequest(returningItems: nil)
}
} catch {
self.logger.error("share send failed reason=\(error.localizedDescription, privacy: .public)")
ShareGatewayRelaySettings.saveLastEvent("Send failed: \(error.localizedDescription)")
self.showStatus("Send failed: \(error.localizedDescription)")
self.showStatus(
String(
format: NSLocalizedString("Send failed: %@", comment: "Share extension failure status"),
error.localizedDescription))
await MainActor.run {
self.isSending = false
self.sendButton.isEnabled = true
@@ -161,13 +168,21 @@ final class ShareViewController: UIViewController {
throw NSError(
domain: "OpenClawShare",
code: 10,
userInfo: [NSLocalizedDescriptionKey: "OpenClaw is not connected to a gateway yet."])
userInfo: [
NSLocalizedDescriptionKey: NSLocalizedString(
"OpenClaw is not connected to a gateway yet.",
comment: "Share extension missing gateway error"),
])
}
guard let url = URL(string: config.gatewayURLString) else {
throw NSError(
domain: "OpenClawShare",
code: 11,
userInfo: [NSLocalizedDescriptionKey: "Invalid saved gateway URL."])
userInfo: [
NSLocalizedDescriptionKey: NSLocalizedString(
"Invalid saved gateway URL.",
comment: "Share extension invalid gateway error"),
])
}
let gateway = GatewayNodeSession()

View File

@@ -129,6 +129,37 @@ pnpm ios:version:pin -- --version 2026.4.10
This keeps the TestFlight version stable while review is in flight.
## Release SHA tracking
Successful App Store Connect uploads create a non-tag Git ref that records the
source commit for the uploaded store build:
```text
refs/openclaw/mobile-releases/ios/<CFBundleShortVersionString>-<CFBundleVersion>
```
Example:
```text
refs/openclaw/mobile-releases/ios/2026.6.10-8
```
These refs are intentionally outside `refs/tags/*` and `refs/heads/*`. They do
not appear on GitHub release or tag pages, and they do not participate in the
core OpenClaw release machinery.
`pnpm ios:release:upload` checks the ref before archive/upload work and records
it only after `upload_to_testflight` succeeds. Existing refs are immutable: the
same ref at the same SHA is accepted, while the same ref at a different SHA
fails.
Useful direct commands:
```bash
pnpm mobile:release:preflight -- --platform ios --version 2026.6.10 --build 8
pnpm mobile:release:resolve -- --platform ios --version 2026.6.10 --build 8
```
## New release promotion workflow
When you want the next production iOS release to align with the current gateway release:

View File

@@ -1128,6 +1128,58 @@ def prepare_app_store_release!(version:, build_number:)
release_xcconfig
end
def mobile_release_ref_script
File.join(repo_root, "scripts", "mobile-release-ref.ts")
end
def release_git_sha
stdout, stderr, status = Open3.capture3("git", "rev-parse", "HEAD", chdir: repo_root)
UI.user_error!("Unable to resolve release Git SHA: #{stderr.strip}") unless status.success?
stdout.strip
end
def mobile_release_ref_command(command, platform:, version:, build: nil, version_code: nil, sha: nil)
args = [
"node",
"--import",
"tsx",
mobile_release_ref_script,
command,
"--platform",
platform,
"--version",
version,
"--root",
repo_root,
]
args.push("--build", build.to_s) if build
args.push("--version-code", version_code.to_s) if version_code
args.push("--sha", sha.to_s) if sha
sh(shell_join(args))
end
def ensure_mobile_release_ref_available!(platform:, version:, build: nil, version_code: nil, sha: nil)
mobile_release_ref_command(
"preflight",
platform: platform,
version: version,
build: build,
version_code: version_code,
sha: sha
)
end
def record_mobile_release_ref!(platform:, version:, build: nil, version_code: nil, sha: nil)
mobile_release_ref_command(
"record",
platform: platform,
version: version,
build: build,
version_code: version_code,
sha: sha
)
end
def validate_app_store_ipa!(ipa_path)
script_path = File.join(repo_root, "scripts", "ios-validate-app-store-ipa.sh")
sh(shell_join(["bash", script_path, "--ipa", ipa_path]))
@@ -1309,15 +1361,22 @@ platform :ios do
UI.user_error!("Use `pnpm ios:release:upload`; direct Fastlane TestFlight upload is disabled.")
end
release_sha = release_git_sha
release_signing_check!
preserve_local_signing do
screenshots
end
context = prepare_app_store_context(require_api_key: true)
ensure_mobile_release_ref_available!(
platform: "ios",
version: context[:short_version],
build: context[:build_number],
sha: release_sha
)
ENV["DELIVER_SCREENSHOTS"] = "1"
ENV["DELIVER_RELEASE_NOTES"] = "1"
metadata
context = prepare_app_store_context(require_api_key: true)
build = build_app_store_release(context)
upload_to_testflight(
@@ -1326,6 +1385,12 @@ platform :ios do
skip_waiting_for_build_processing: true,
uses_non_exempt_encryption: false
)
record_mobile_release_ref!(
platform: "ios",
version: build[:short_version],
build: build[:build_number],
sha: release_sha
)
UI.success("Uploaded iOS App Store build: version=#{build[:version]} short=#{build[:short_version]} build=#{build[:build_number]}")
UI.important("App Review submission remains manual in App Store Connect.")

View File

@@ -60,6 +60,8 @@ targets:
Release: Signing.xcconfig
sources:
- path: Sources
resources:
- path: Resources/Localizable.xcstrings
dependencies:
- target: OpenClawShareExtension
embed: true
@@ -185,6 +187,8 @@ targets:
Release: Signing.xcconfig
sources:
- path: ShareExtension
resources:
- path: Resources/Localizable.xcstrings
dependencies:
- package: OpenClawKit
- sdk: AppIntents.framework
@@ -227,6 +231,8 @@ targets:
sources:
- path: ActivityWidget
- path: Sources/LiveActivity/OpenClawActivityAttributes.swift
resources:
- path: Resources/Localizable.xcstrings
dependencies:
- sdk: WidgetKit.framework
- sdk: ActivityKit.framework
@@ -259,6 +265,8 @@ targets:
- path: WatchApp
excludes:
- Info.plist
resources:
- path: Resources/Localizable.xcstrings
dependencies:
- sdk: AppIntents.framework
- sdk: WatchConnectivity.framework

View File

@@ -63,6 +63,7 @@ let package = Package(
resources: [
.copy("Resources/OpenClaw.icns"),
.copy("Resources/DeviceModels"),
.process("Resources/Localizable.xcstrings"),
],
swiftSettings: [
.enableUpcomingFeature("StrictConcurrency"),

View File

@@ -0,0 +1,526 @@
{
"sourceLanguage": "en",
"strings": {
"Logout": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Logout"
}
},
"zh-CN": {
"stringUnit": {
"state": "translated",
"value": "退出登录"
}
},
"zh-TW": {
"stringUnit": {
"state": "translated",
"value": "登出"
}
},
"pt-BR": {
"stringUnit": {
"state": "translated",
"value": "Sair"
}
},
"de": {
"stringUnit": {
"state": "translated",
"value": "Abmelden"
}
},
"es": {
"stringUnit": {
"state": "translated",
"value": "Cerrar sesión"
}
},
"ja-JP": {
"stringUnit": {
"state": "translated",
"value": "ログアウト"
}
},
"ko": {
"stringUnit": {
"state": "translated",
"value": "로그아웃"
}
},
"fr": {
"stringUnit": {
"state": "translated",
"value": "Déconnexion"
}
},
"hi": {
"stringUnit": {
"state": "translated",
"value": "लॉग आउट"
}
},
"ar": {
"stringUnit": {
"state": "translated",
"value": "تسجيل الخروج"
}
},
"it": {
"stringUnit": {
"state": "translated",
"value": "Esci"
}
},
"tr": {
"stringUnit": {
"state": "translated",
"value": "Çıkış yap"
}
},
"uk": {
"stringUnit": {
"state": "translated",
"value": "Вийти"
}
},
"id": {
"stringUnit": {
"state": "translated",
"value": "Keluar"
}
},
"pl": {
"stringUnit": {
"state": "translated",
"value": "Wyloguj"
}
},
"th": {
"stringUnit": {
"state": "translated",
"value": "ออกจากระบบ"
}
},
"vi": {
"stringUnit": {
"state": "translated",
"value": "Đăng xuất"
}
},
"nl": {
"stringUnit": {
"state": "translated",
"value": "Uitloggen"
}
},
"fa": {
"stringUnit": {
"state": "translated",
"value": "خروج"
}
},
"ru": {
"stringUnit": {
"state": "translated",
"value": "Выйти"
}
}
}
},
"Refresh": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Refresh"
}
},
"zh-CN": {
"stringUnit": {
"state": "translated",
"value": "刷新"
}
},
"zh-TW": {
"stringUnit": {
"state": "translated",
"value": "重新整理"
}
},
"pt-BR": {
"stringUnit": {
"state": "translated",
"value": "Atualizar"
}
},
"de": {
"stringUnit": {
"state": "translated",
"value": "Aktualisieren"
}
},
"es": {
"stringUnit": {
"state": "translated",
"value": "Actualizar"
}
},
"ja-JP": {
"stringUnit": {
"state": "translated",
"value": "更新"
}
},
"ko": {
"stringUnit": {
"state": "translated",
"value": "새로 고침"
}
},
"fr": {
"stringUnit": {
"state": "translated",
"value": "Actualiser"
}
},
"hi": {
"stringUnit": {
"state": "translated",
"value": "रीफ़्रेश"
}
},
"ar": {
"stringUnit": {
"state": "translated",
"value": "تحديث"
}
},
"it": {
"stringUnit": {
"state": "translated",
"value": "Aggiorna"
}
},
"tr": {
"stringUnit": {
"state": "translated",
"value": "Yenile"
}
},
"uk": {
"stringUnit": {
"state": "translated",
"value": "Оновити"
}
},
"id": {
"stringUnit": {
"state": "translated",
"value": "Segarkan"
}
},
"pl": {
"stringUnit": {
"state": "translated",
"value": "Odśwież"
}
},
"th": {
"stringUnit": {
"state": "translated",
"value": "รีเฟรช"
}
},
"vi": {
"stringUnit": {
"state": "translated",
"value": "Làm mới"
}
},
"nl": {
"stringUnit": {
"state": "translated",
"value": "Vernieuwen"
}
},
"fa": {
"stringUnit": {
"state": "translated",
"value": "بازخوانی"
}
},
"ru": {
"stringUnit": {
"state": "translated",
"value": "Обновить"
}
}
}
},
"Run now": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Run now"
}
},
"zh-CN": {
"stringUnit": {
"state": "translated",
"value": "立即运行"
}
},
"zh-TW": {
"stringUnit": {
"state": "translated",
"value": "立即執行"
}
},
"pt-BR": {
"stringUnit": {
"state": "translated",
"value": "Executar agora"
}
},
"de": {
"stringUnit": {
"state": "translated",
"value": "Jetzt ausführen"
}
},
"es": {
"stringUnit": {
"state": "translated",
"value": "Ejecutar ahora"
}
},
"ja-JP": {
"stringUnit": {
"state": "translated",
"value": "今すぐ実行"
}
},
"ko": {
"stringUnit": {
"state": "translated",
"value": "지금 실행"
}
},
"fr": {
"stringUnit": {
"state": "translated",
"value": "Exécuter maintenant"
}
},
"hi": {
"stringUnit": {
"state": "translated",
"value": "अभी चलाएँ"
}
},
"ar": {
"stringUnit": {
"state": "translated",
"value": "تشغيل الآن"
}
},
"it": {
"stringUnit": {
"state": "translated",
"value": "Esegui ora"
}
},
"tr": {
"stringUnit": {
"state": "translated",
"value": "Şimdi çalıştır"
}
},
"uk": {
"stringUnit": {
"state": "translated",
"value": "Запустити зараз"
}
},
"id": {
"stringUnit": {
"state": "translated",
"value": "Jalankan sekarang"
}
},
"pl": {
"stringUnit": {
"state": "translated",
"value": "Uruchom teraz"
}
},
"th": {
"stringUnit": {
"state": "translated",
"value": "เรียกใช้ตอนนี้"
}
},
"vi": {
"stringUnit": {
"state": "translated",
"value": "Chạy ngay"
}
},
"nl": {
"stringUnit": {
"state": "translated",
"value": "Nu uitvoeren"
}
},
"fa": {
"stringUnit": {
"state": "translated",
"value": "اکنون اجرا شود"
}
},
"ru": {
"stringUnit": {
"state": "translated",
"value": "Запустить сейчас"
}
}
}
},
"Save": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Save"
}
},
"zh-CN": {
"stringUnit": {
"state": "translated",
"value": "保存"
}
},
"zh-TW": {
"stringUnit": {
"state": "translated",
"value": "儲存"
}
},
"pt-BR": {
"stringUnit": {
"state": "translated",
"value": "Salvar"
}
},
"de": {
"stringUnit": {
"state": "translated",
"value": "Speichern"
}
},
"es": {
"stringUnit": {
"state": "translated",
"value": "Guardar"
}
},
"ja-JP": {
"stringUnit": {
"state": "translated",
"value": "保存"
}
},
"ko": {
"stringUnit": {
"state": "translated",
"value": "저장"
}
},
"fr": {
"stringUnit": {
"state": "translated",
"value": "Enregistrer"
}
},
"hi": {
"stringUnit": {
"state": "translated",
"value": "सहेजें"
}
},
"ar": {
"stringUnit": {
"state": "translated",
"value": "حفظ"
}
},
"it": {
"stringUnit": {
"state": "translated",
"value": "Salva"
}
},
"tr": {
"stringUnit": {
"state": "translated",
"value": "Kaydet"
}
},
"uk": {
"stringUnit": {
"state": "translated",
"value": "Зберегти"
}
},
"id": {
"stringUnit": {
"state": "translated",
"value": "Simpan"
}
},
"pl": {
"stringUnit": {
"state": "translated",
"value": "Zapisz"
}
},
"th": {
"stringUnit": {
"state": "translated",
"value": "บันทึก"
}
},
"vi": {
"stringUnit": {
"state": "translated",
"value": "Lưu"
}
},
"nl": {
"stringUnit": {
"state": "translated",
"value": "Opslaan"
}
},
"fa": {
"stringUnit": {
"state": "translated",
"value": "ذخیره"
}
},
"ru": {
"stringUnit": {
"state": "translated",
"value": "Сохранить"
}
}
}
}
},
"version": "1.0"
}

View File

@@ -1,2 +1,2 @@
35b314075ff47453c5d57788861ca0c0e65d6a988b549ab2a2e1757b7590d140 plugin-sdk-api-baseline.json
0dc8abcefccfe7d19280bde5fb2c0c69cf73b782d47e3759e2984baf904fe07c plugin-sdk-api-baseline.jsonl
760812c17f7e48d7ceafeebbbe348dad13916ccb9ecaf41b3abc9a09b1e690c1 plugin-sdk-api-baseline.json
4d9b76016b2f845e101949a3d2ac92437f49783906d1c263d65f3534bb333de5 plugin-sdk-api-baseline.jsonl

View File

@@ -11,7 +11,7 @@ Generated locale trees and live translation memory now live in the publish repo:
- English docs are authored in `openclaw/openclaw`.
- The source docs tree lives under `docs/`.
- The source repo no longer keeps committed generated locale trees such as `docs/zh-CN/**`, `docs/zh-TW/**`, `docs/ja-JP/**`, `docs/es/**`, `docs/pt-BR/**`, `docs/ko/**`, `docs/de/**`, `docs/fr/**`, `docs/ar/**`, `docs/it/**`, `docs/vi/**`, `docs/nl/**`, `docs/fa/**`, `docs/tr/**`, `docs/uk/**`, `docs/id/**`, `docs/pl/**`, or `docs/th/**`.
- The source repo no longer keeps committed generated locale trees such as `docs/zh-CN/**`, `docs/zh-TW/**`, `docs/ja-JP/**`, `docs/es/**`, `docs/pt-BR/**`, `docs/ko/**`, `docs/de/**`, `docs/fr/**`, `docs/hi/**`, `docs/ar/**`, `docs/it/**`, `docs/vi/**`, `docs/nl/**`, `docs/fa/**`, `docs/ru/**`, `docs/tr/**`, `docs/uk/**`, `docs/id/**`, `docs/pl/**`, or `docs/th/**`.
## End-to-end flow
@@ -32,10 +32,10 @@ Generated locale trees and live translation memory now live in the publish repo:
## Locale visibility
- Control UI supports `en`, `zh-CN`, `zh-TW`, `pt-BR`, `de`, `es`, `ja-JP`, `ko`, `fr`, `ar`, `it`, `tr`, `uk`, `id`, `pl`, `th`, `vi`, `nl`, and `fa`.
- Control UI supports `en`, `zh-CN`, `zh-TW`, `pt-BR`, `de`, `es`, `ja-JP`, `ko`, `fr`, `hi`, `ar`, `it`, `vi`, `nl`, `fa`, `ru`, `tr`, `uk`, `id`, `pl`, and `th`.
- Docs translation workflows generate the same non-English locale set in `openclaw/docs`.
- The Mintlify docs language picker can expose only the locales accepted by Mintlify `navigation.languages`; today that includes Vietnamese (`vi`) and Dutch (`nl`), but not Thai (`th`) or Persian (`fa`).
- Do not treat missing `th` or `fa` entries in generated `docs/docs.json` as a pipeline failure. Verify their generated folders in `openclaw/docs` instead.
- The Mintlify docs language picker can expose only the locales accepted by Mintlify `navigation.languages`; Russian (`ru`) and Hindi (`hi`) are now included in the publish configuration.
- Do not treat locale visibility in generated `docs/docs.json` as proof that translation artifacts exist. Verify each generated locale folder and its translation memory in `openclaw/docs`.
## Files in this folder

View File

@@ -0,0 +1,82 @@
[
{
"source": "ACP",
"target": "ACP"
},
{
"source": "Active Memory",
"target": "Active Memory"
},
{
"source": "ClawHub",
"target": "ClawHub"
},
{
"source": "CLI",
"target": "CLI"
},
{
"source": "Compaction",
"target": "Compaction"
},
{
"source": "Cron",
"target": "Cron"
},
{
"source": "Dreaming",
"target": "Dreaming"
},
{
"source": "Gateway",
"target": "Gateway"
},
{
"source": "Heartbeat",
"target": "Heartbeat"
},
{
"source": "LINE",
"target": "LINE"
},
{
"source": "Mintlify",
"target": "Mintlify"
},
{
"source": "Node",
"target": "Node"
},
{
"source": "OpenClaw",
"target": "OpenClaw"
},
{
"source": "Pi",
"target": "Pi"
},
{
"source": "Plugin",
"target": "Plugin"
},
{
"source": "Skills",
"target": "Skills"
},
{
"source": "Tailscale",
"target": "Tailscale"
},
{
"source": "TaskFlow",
"target": "TaskFlow"
},
{
"source": "TUI",
"target": "TUI"
},
{
"source": "Webhook",
"target": "Webhook"
}
]

View File

@@ -0,0 +1,82 @@
[
{
"source": "ACP",
"target": "ACP"
},
{
"source": "Active Memory",
"target": "Active Memory"
},
{
"source": "ClawHub",
"target": "ClawHub"
},
{
"source": "CLI",
"target": "CLI"
},
{
"source": "Compaction",
"target": "Compaction"
},
{
"source": "Cron",
"target": "Cron"
},
{
"source": "Dreaming",
"target": "Dreaming"
},
{
"source": "Gateway",
"target": "Gateway"
},
{
"source": "Heartbeat",
"target": "Heartbeat"
},
{
"source": "LINE",
"target": "LINE"
},
{
"source": "Mintlify",
"target": "Mintlify"
},
{
"source": "Node",
"target": "Node"
},
{
"source": "OpenClaw",
"target": "OpenClaw"
},
{
"source": "Pi",
"target": "Pi"
},
{
"source": "Plugin",
"target": "Plugin"
},
{
"source": "Skills",
"target": "Skills"
},
{
"source": "Tailscale",
"target": "Tailscale"
},
{
"source": "TaskFlow",
"target": "TaskFlow"
},
{
"source": "TUI",
"target": "TUI"
},
{
"source": "Webhook",
"target": "Webhook"
}
]

View File

@@ -90,9 +90,9 @@ Scope logic lives in `scripts/ci-changed-scope.mjs` and is covered by unit tests
The slowest Node test families are split or balanced so each job stays small without over-reserving runners: plugin contracts and channel contracts each run as two weighted Blacksmith-backed shards with the standard GitHub runner fallback, core unit fast/support lanes run separately, core runtime infra is split between state, process/config, shared, and three cron domain shards, auto-reply runs as balanced workers (with the reply subtree split into agent-runner, dispatch, and commands/state-routing shards), and agentic gateway/server configs are split across chat/auth/model/http-plugin/runtime/startup lanes instead of waiting on built artifacts. Normal CI then packs only isolated infra include-pattern shards into deterministic bundles of at most 64 test files, reducing the Node matrix without merging non-isolated command/cron, stateful agents-core, or gateway/server suites; heavy fixed suites stay on 8 vCPU while the bundled and lower-weight lanes use 4 vCPU. Pull requests on the canonical repository use an additional compact admission plan: the same per-config groups run in isolated subprocesses inside the current 34-job Linux Node plan, so a single PR does not register the full 70-plus-job Node matrix. `main` pushes, manual dispatches, and release gates retain the full matrix. Broad browser, QA, media, and miscellaneous plugin tests use their dedicated Vitest configs instead of the shared plugin catch-all. Include-pattern shards record timing entries using the CI shard name, so `.artifacts/vitest-shard-timings.json` can distinguish a whole config from a filtered shard. `check-additional-*` keeps package-boundary compile/canary work together and separates runtime topology architecture from gateway watch coverage; the boundary guard list is striped into one prompt-heavy shard and one combined shard for the remaining guard stripes, each running selected independent guards concurrently and printing per-check timings. The expensive Codex happy-path prompt snapshot drift check runs as its own additional job for manual CI and for prompt-affecting changes only, so normal unrelated Node changes do not wait behind cold prompt snapshot generation and the boundary shards stay balanced while prompt drift is still pinned to the PR that caused it; the same flag skips prompt snapshot Vitest generation inside the built-artifact core support-boundary shard. Gateway watch, channel tests, and the core support-boundary shard run concurrently inside `build-artifacts` after `dist/` and `dist-runtime/` are already built.
Once admitted, canonical Linux CI permits up to 12 concurrent Node jobs and 8 for
the smaller fast/check lanes; Windows and Android stay at two because those
runner pools are narrower.
Once admitted, canonical Linux CI permits up to 24 concurrent Node test jobs and
12 for the smaller fast/check lanes; Windows and Android stay at two because
those runner pools are narrower.
The compact PR plan emits 18 Node jobs for the current suite: whole-config
groups are batched in isolated subprocesses with a 120-minute batch timeout,
@@ -145,17 +145,17 @@ gh workflow run full-release-validation.yml --ref main -f ref=<branch-or-sha>
## Runner registration budget
GitHub caps self-hosted runner registrations at 1,500 runners per 5 minutes per
repository, organization, or enterprise. The limit is shared by all Blacksmith
runner registrations in the `openclaw` organization, so adding another
Blacksmith installation does not add a new bucket.
OpenClaw's current GitHub runner-registration bucket allows 3,000 self-hosted
runner registrations per 5 minutes. The limit is shared by all Blacksmith runner
registrations in the `openclaw` organization, so adding another Blacksmith
installation does not add a new bucket.
Treat Blacksmith labels as the scarce resource for burst control. Jobs that
only route, notify, summarize, select shards, or run short CodeQL scans should
stay on GitHub-hosted runners unless they have measured Blacksmith-specific
needs. Any new Blacksmith matrix, larger `max-parallel`, or high-frequency
workflow must show its worst-case registration count and keep the org-level
target below 1,000 registrations per 5 minutes, leaving headroom for concurrent
target below 2,000 registrations per 5 minutes, leaving headroom for concurrent
repositories and retried jobs.
Canonical-repo CI keeps Blacksmith as the default runner path for normal push and pull-request runs. `workflow_dispatch` and non-canonical repository runs use GitHub-hosted runners, but normal canonical runs do not currently probe Blacksmith queue health or automatically fall back to GitHub-hosted labels when Blacksmith is unavailable.

View File

@@ -24,17 +24,31 @@ OpenClaw agent or Gateway.
```bash
openclaw skills search "calendar"
openclaw skills install @owner/<slug>
openclaw skills install @owner/<slug> --acknowledge-clawhub-risk
openclaw skills update @owner/<slug>
openclaw skills update @owner/<slug> --acknowledge-clawhub-risk
openclaw skills verify @owner/<slug>
openclaw plugins search "calendar"
openclaw plugins install clawhub:<package>
openclaw plugins install clawhub:<package> --acknowledge-clawhub-risk
openclaw plugins update <id-or-npm-spec>
```
Skill installs target the active workspace `skills/` directory by default. Add
`--global` to install into the shared managed skills directory.
OpenClaw checks the selected community ClawHub skill or plugin trust state
before downloading it. Versioned community skill and plugin releases use
exact-release trust metadata; resolver-backed GitHub skills rely on ClawHub's
install resolver to enforce scan and force-install policy before it returns a
pinned commit. Malicious or blocked community releases are refused. Risky
community releases require review and `--acknowledge-clawhub-risk` when a
non-interactive command should continue after that review.
Official ClawHub publishers/packages and bundled OpenClaw sources bypass this
release-trust prompt and security-verdict fetch during install and update.
Plugin installs use the `clawhub:` prefix when you want ClawHub resolution
instead of npm or another install source.

View File

@@ -40,6 +40,7 @@ openclaw doctor
openclaw doctor --lint
openclaw doctor --lint --json
openclaw doctor --lint --severity-min warning
openclaw doctor --lint --all
openclaw doctor --lint --allow-exec
openclaw doctor --deep
openclaw doctor --fix
@@ -73,6 +74,7 @@ The targeted Discord capabilities probe reports the bot's effective channel perm
- `--post-upgrade`: run post-upgrade plugin compatibility probes; emits findings to stdout; exits with code 1 if any error-level findings are present
- `--json`: with `--lint`, emit JSON findings instead of human output; with `--post-upgrade`, emit a machine-readable JSON envelope (`{ probesRun, findings }`)
- `--severity-min <level>`: with `--lint`, drop findings below `info`, `warning`, or `error`
- `--all`: with `--lint`, run all registered checks, including opt-in checks excluded from the default automation set
- `--skip <id>`: with `--lint`, skip a check id; repeat to skip more than one
- `--only <id>`: with `--lint`, run only a check id; repeat to run a small selected set
@@ -82,13 +84,14 @@ The targeted Discord capabilities probe reports the bot's effective channel perm
It uses the structured health-check path, does not prompt, and does not repair
or rewrite config/state. Use it in CI, preflight scripts, and review workflows
when you want machine-readable findings instead of guided repair prompts.
Lint-output options such as `--json`, `--severity-min`, `--only`, and `--skip`
Lint-output options such as `--json`, `--severity-min`, `--all`, `--only`, and `--skip`
are only accepted with `--lint`.
```bash
openclaw doctor --lint
openclaw doctor --lint --severity-min warning
openclaw doctor --lint --json
openclaw doctor --lint --all
openclaw doctor --lint --allow-exec
openclaw doctor --lint --only core/doctor/gateway-config --json
```
@@ -130,6 +133,13 @@ Exit behavior:
example, `openclaw doctor --lint --severity-min error` can print no findings and
exit `0` even when lower-severity `info` or `warning` findings exist.
`--all` controls which checks are selected before severity filtering. The
default lint run is the stable automation gate and excludes checks that are
intentionally opt-in because they are deep, historical, or more likely to
surface repairable legacy residue. Use `--all` when you want the complete lint
inventory without listing each check id. `--only <id>` remains the most precise
selector and can run any registered check by id.
## Structured Health Checks
Modern doctor checks use a small structured contract:
@@ -186,6 +196,7 @@ Use `--only` and `--skip` when a workflow wants a focused gate:
```bash
openclaw doctor --lint --only core/doctor/gateway-config --json
openclaw doctor --lint --skip core/doctor/skills-readiness
openclaw doctor --lint --all --skip core/doctor/session-locks
```
`--only` and `--skip` accept full check ids and may be repeated. If an `--only`

View File

@@ -54,8 +54,9 @@ openclaw plugins update <id-or-npm-spec>
openclaw plugins update --all
openclaw plugins marketplace list <marketplace>
openclaw plugins marketplace list <marketplace> --json
openclaw plugins init <id>
openclaw plugins init <id> --directory ./my-plugin --name "My Plugin"
openclaw plugins init my-tool --name "My Tool"
openclaw plugins init my-provider --name "My Provider" --type provider
openclaw plugins init my-provider --name "My Provider" --type provider --directory ./my-provider
openclaw plugins build --entry ./dist/index.js
openclaw plugins build --entry ./dist/index.js --check
openclaw plugins validate --entry ./dist/index.js
@@ -86,12 +87,15 @@ npm run plugin:build
npm run plugin:validate
```
`plugins init` creates a minimal TypeScript tool plugin that uses
`defineToolPlugin`. `plugins build` imports that entry, reads its static tool
metadata, writes `openclaw.plugin.json`, and keeps `package.json`
`openclaw.extensions` aligned. `plugins validate` checks that the generated
manifest, package metadata, and current entry export still agree. See
[Tool Plugins](/plugins/tool-plugins) for the full authoring workflow.
`plugins init` creates a minimal TypeScript tool plugin by default. The first
argument is the plugin id; pass `--name` for the display name. OpenClaw uses the
id for the default output directory and package naming. Tool scaffolds use
`defineToolPlugin`.
`plugins build` imports the built entry, reads its static tool metadata, writes
`openclaw.plugin.json`, and keeps `package.json` `openclaw.extensions` aligned.
`plugins validate` checks that the generated manifest, package metadata, and
current entry export still agree. See [Tool Plugins](/plugins/tool-plugins) for
the full tool-authoring workflow.
The scaffold writes TypeScript source but generates metadata from the built
`./dist/index.js` entry so the workflow also works with the published CLI. Use
@@ -99,6 +103,29 @@ The scaffold writes TypeScript source but generates metadata from the built
`plugins build --check` in CI to fail when generated metadata is stale without
rewriting files.
### Provider Scaffold
```bash
openclaw plugins init acme-models --name "Acme Models" --type provider
cd acme-models
npm install
npm run build
npm test
npm run validate
```
Provider scaffolds create a generic text/model provider plugin with OpenAI-compatible
API-key plumbing, a built-in `npm run validate` script for `clawhub package
validate`, ClawHub package metadata, and a manually dispatched GitHub workflow
for future trusted publishing through GitHub Actions OIDC. Provider scaffolds do
not generate skills and do not use `openclaw plugins build` or
`openclaw plugins validate`; those commands are for the tool scaffold's
generated metadata path.
Before publishing, replace the placeholder API base URL, model catalog, docs
route, credential text, and README copy with real provider details. Use the
generated README for first-time ClawHub publishing and trusted publisher setup.
### Install
```bash
@@ -111,6 +138,7 @@ openclaw plugins install git:github.com/<owner>/<repo> # git repo
openclaw plugins install git:github.com/<owner>/<repo>@<ref>
openclaw plugins install <package> --force # overwrite existing install
openclaw plugins install <package> --pin # pin version
openclaw plugins install clawhub:<package> --acknowledge-clawhub-risk
openclaw plugins install <package> --dangerously-force-unsafe-install
openclaw plugins install <path> # local path
openclaw plugins install <plugin>@<marketplace> # marketplace
@@ -163,6 +191,12 @@ is available, then fall back to `latest`.
If a plugin you published on ClawHub is hidden or blocked by a registry scan, use the publisher steps in [ClawHub publishing](/clawhub/publishing). `--dangerously-force-unsafe-install` does not ask ClawHub to rescan the plugin or make a blocked release public.
</Accordion>
<Accordion title="--acknowledge-clawhub-risk">
Community ClawHub installs check the selected release trust record before downloading the package. If ClawHub disables download for the release, reports malicious scan findings, or puts the release in a blocking moderation state such as quarantine, OpenClaw refuses the release. For non-blocking risky scan statuses, risky moderation states, or registry reasons, OpenClaw shows the trust details and asks for confirmation before continuing.
Use `--acknowledge-clawhub-risk` only after reviewing the ClawHub warning and deciding to continue without an interactive prompt. Pending or stale clean trust records warn but do not require acknowledgement. Official ClawHub packages and bundled OpenClaw plugin sources bypass this release-trust prompt.
</Accordion>
<Accordion title="Hook packs and npm specs">
`plugins install` is also the install surface for hook packs that expose `openclaw.hooks` in `package.json`. Use `openclaw hooks` for filtered hook visibility and per-hook enablement, not package installation.
@@ -390,6 +424,7 @@ openclaw plugins update <id-or-npm-spec>
openclaw plugins update --all
openclaw plugins update <id-or-npm-spec> --dry-run
openclaw plugins update @openclaw/voice-call
openclaw plugins update openclaw-codex-app-server --acknowledge-clawhub-risk
openclaw plugins update openclaw-codex-app-server --dangerously-force-unsafe-install
```
@@ -399,13 +434,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">
@@ -417,6 +456,9 @@ Updates apply to tracked plugin installs in the managed plugin index and tracked
<Accordion title="--dangerously-force-unsafe-install on update">
`--dangerously-force-unsafe-install` is also accepted on `plugins update` for compatibility, but it is deprecated and no longer changes plugin update behavior. Operator `security.installPolicy` can still block updates; plugin `before_install` hooks only apply in processes where plugin hooks are loaded.
</Accordion>
<Accordion title="--acknowledge-clawhub-risk on update">
Community ClawHub-backed plugin updates run the same exact-release trust check as installs before downloading the replacement package. Use `--acknowledge-clawhub-risk` for reviewed automation that should continue when the selected ClawHub release has a risky trust warning. Official ClawHub packages and bundled OpenClaw plugin sources bypass this release-trust prompt.
</Accordion>
</AccordionGroup>
### Inspect

View File

@@ -31,9 +31,11 @@ openclaw skills install git:owner/repo
openclaw skills install git:owner/repo@main
openclaw skills install ./path/to/skill --as custom-name
openclaw skills install @owner/<slug> --force
openclaw skills install @owner/<slug> --acknowledge-clawhub-risk
openclaw skills install @owner/<slug> --agent <id>
openclaw skills install @owner/<slug> --global
openclaw skills update @owner/<slug>
openclaw skills update @owner/<slug> --acknowledge-clawhub-risk
openclaw skills update @owner/<slug> --global
openclaw skills update --all
openclaw skills update --all --agent <id>
@@ -97,6 +99,14 @@ Notes:
- `install --version <version>` applies only to ClawHub skill refs.
- `install --force` overwrites an existing workspace skill folder for the same
slug.
- Community ClawHub skill installs and updates check trust before downloading.
Versioned community archive releases use exact-release trust metadata.
Resolver-backed GitHub skills rely on ClawHub's install resolver to enforce
scan and force-install policy before it returns a pinned commit. Malicious or
blocked community releases are refused. Risky community releases require
review and `--acknowledge-clawhub-risk` when a non-interactive command should
continue after that review. Official ClawHub skill publishers and bundled
OpenClaw skill sources bypass this release-trust prompt.
- `--global` targets the shared managed skills directory and cannot be combined
with `--agent <id>`.
- `--agent <id>` targets one configured agent workspace and overrides current

View File

@@ -28,6 +28,7 @@ openclaw update --tag main
openclaw update --dry-run
openclaw update --no-restart
openclaw update --yes
openclaw update --acknowledge-clawhub-risk
openclaw update --json
openclaw --update
```
@@ -45,6 +46,11 @@ openclaw --update
when npm plugin artifact drift is detected during post-update plugin sync.
- `--timeout <seconds>`: per-step timeout (default is 1800s).
- `--yes`: skip confirmation prompts (for example downgrade confirmation).
- `--acknowledge-clawhub-risk`: after reviewing community ClawHub trust
warnings, allow post-update plugin sync to continue without an interactive
prompt. Without this, risky community ClawHub plugin releases are skipped and
left unchanged when OpenClaw cannot prompt. Official ClawHub packages and
bundled OpenClaw plugin sources bypass this release-trust prompt.
`openclaw update` does not have a `--verbose` flag. Use `--dry-run` to preview
the planned channel/tag/install/restart actions, `--json` for machine-readable
@@ -88,6 +94,7 @@ converge.
```bash
openclaw update repair
openclaw update repair --channel beta
openclaw update repair --acknowledge-clawhub-risk
openclaw update repair --json
```
@@ -98,6 +105,10 @@ Options:
- `--json`: print machine-readable finalization JSON.
- `--timeout <seconds>`: timeout for repair steps (default `1800`).
- `--yes`: skip confirmation prompts.
- `--acknowledge-clawhub-risk`: after reviewing community ClawHub trust
warnings, allow repair-time plugin convergence to continue without an
interactive prompt. Official ClawHub packages and bundled OpenClaw plugin
sources bypass this release-trust prompt.
- `--no-restart`: accepted for update command parity; repair never restarts the
Gateway.

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

@@ -104,6 +104,7 @@ Examples:
openclaw doctor --lint
openclaw doctor --lint --severity-min warning
openclaw doctor --lint --json
openclaw doctor --lint --all
openclaw doctor --lint --only core/doctor/gateway-config --json
```
@@ -111,7 +112,7 @@ JSON output includes:
- `ok`: whether any visible finding met the selected severity threshold
- `checksRun`: number of health checks executed
- `checksSkipped`: checks skipped by `--only` or `--skip`
- `checksSkipped`: checks skipped by the selected profile, `--only`, or `--skip`
- `findings`: structured diagnostics with `checkId`, `severity`, `message`, and
optional `path`, `line`, `column`, `ocPath`, and `fixHint`
@@ -122,11 +123,13 @@ Exit codes:
- `2`: command/runtime failure before lint findings could be emitted
Use `--severity-min info|warning|error` to control both what is printed and what
causes a non-zero lint exit. Use `--only <id>` for narrow preflight gates and
causes a non-zero lint exit. Use `--all` to run the complete lint inventory,
including deeper opt-in checks excluded from the default automation set. Use `--only <id>` for narrow preflight gates and
`--skip <id>` to temporarily exclude a noisy check while keeping the rest of the
lint run active.
Lint-output options such as `--json`, `--severity-min`, `--only`, and `--skip`
must be paired with `--lint`; regular doctor and repair runs reject them.
Lint-output options such as `--json`, `--severity-min`, `--all`, `--only`, and
`--skip` must be paired with `--lint`; regular doctor and repair runs reject
them.
## What it does (summary)

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

@@ -155,9 +155,13 @@ shorthand before OpenClaw builds app-server start options, and unresolved
structured SecretRefs fail before any token or header is sent. When native Codex
plugins are configured, OpenClaw uses the connected app-server's plugin control
plane to install or refresh those plugins and then refreshes app inventory so
plugin-owned apps are visible to the Codex thread. Only connect OpenClaw to
remote app-servers that are trusted to accept OpenClaw-managed plugin installs
and app inventory refreshes.
plugin-owned apps are visible to the Codex thread. `app/list` is still the
authoritative inventory and metadata source, but OpenClaw policy decides whether
`thread/start` sends `config.apps[appId].enabled = true` for a listed accessible
app even if Codex currently marks it disabled. Unknown or missing app ids remain
fail-closed; this path only activates marketplace plugins via `plugin/install`
and refreshes inventory. Only connect OpenClaw to remote app-servers that are
trusted to accept OpenClaw-managed plugin installs and app inventory refreshes.
## Approval and sandbox modes

View File

@@ -465,7 +465,13 @@ do not receive Gateway env API-key fallback; use an explicit auth profile or the
remote app-server's own account.
When native Codex plugins are configured, OpenClaw installs or refreshes those
plugins through the connected app-server before exposing plugin-owned apps to
the Codex thread.
the Codex thread. `app/list` remains the source of truth for app ids,
accessibility, and metadata, but OpenClaw owns the per-thread enablement
decision: if policy allows a listed accessible app, OpenClaw sends
`thread/start.config.apps[appId].enabled = true` even when `app/list` currently
reports that app disabled. This path does not invent app installation for
unknown ids; OpenClaw only activates marketplace plugins with `plugin/install`
and then refreshes inventory.
If a subscription profile hits a Codex usage limit, OpenClaw records the reset
time when Codex reports one and tries the next ordered auth profile for the same

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

@@ -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

@@ -21,6 +21,7 @@ import {
readCodexNotificationItem,
readNotificationItemId,
shouldDisarmAssistantCompletionIdleWatch,
updateActiveCompletionBlockerItemIds,
updateActiveTurnItemIds,
} from "./attempt-notifications.js";
import { CODEX_POST_REASONING_REPLY_IDLE_TIMEOUT_MS } from "./attempt-timeouts.js";
@@ -92,6 +93,7 @@ export function applyCodexTurnNotificationState(params: {
currentPromptTexts: string[];
turnWatches: CodexAttemptTurnWatchController;
activeTurnItemIds: Set<string>;
activeCompletionBlockerItemIds: Set<string>;
activeAppServerTurnRequests: number;
pendingOpenClawDynamicToolCompletionIds: Set<string>;
turnCrossedToolHandoff: boolean;
@@ -121,6 +123,7 @@ export function applyCodexTurnNotificationState(params: {
});
params.onReportExecutionNotification(notification);
updateActiveTurnItemIds(notification, params.activeTurnItemIds);
updateActiveCompletionBlockerItemIds(notification, params.activeCompletionBlockerItemIds);
if (notification.method === "item/completed" && params.activeTurnItemIds.size === 0) {
params.onScheduleTerminalDynamicToolReleaseCheck();
}

View File

@@ -63,6 +63,45 @@ export function updateActiveTurnItemIds(
activeItemIds.delete(itemId);
}
export function updateActiveCompletionBlockerItemIds(
notification: CodexServerNotification,
activeItemIds: Set<string>,
): void {
if (notification.method !== "item/started" && notification.method !== "item/completed") {
return;
}
const itemId = readNotificationItemId(notification);
if (!itemId) {
return;
}
if (notification.method === "item/completed") {
activeItemIds.delete(itemId);
return;
}
const item = readCodexNotificationItem(notification.params);
if (item && isCompletionBlockingItem(item)) {
activeItemIds.add(itemId);
}
}
function isCompletionBlockingItem(item: CodexThreadItem): boolean {
// Codex emits paired item/started and item/completed notifications for these
// execution items. Completion must not time out while any pair is still open.
switch (item.type) {
case "collabAgentToolCall":
case "commandExecution":
case "dynamicToolCall":
case "fileChange":
case "imageGeneration":
case "imageView":
case "mcpToolCall":
case "webSearch":
return true;
default:
return false;
}
}
function isCompletedAssistantNotification(notification: CodexServerNotification): boolean {
if (!isJsonObject(notification.params)) {
return false;

View File

@@ -1,6 +1,7 @@
// Codex tests cover attempt turn watches plugin behavior.
import { MAX_TIMER_TIMEOUT_MS } from "openclaw/plugin-sdk/number-runtime";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { updateActiveCompletionBlockerItemIds } from "./attempt-notifications.js";
import { createCodexAttemptTurnWatchController } from "./attempt-turn-watches.js";
describe("Codex app-server attempt turn watches", () => {
@@ -23,6 +24,7 @@ describe("Codex app-server attempt turn watches", () => {
let terminalQueued = false;
let activeRequests = 0;
let activeItems = 0;
let activeCompletionBlockers = 0;
const interrupts: Array<Record<string, unknown>> = [];
const timeouts: Array<Record<string, unknown>> = [];
const events: Array<{ name: string; fields: Record<string, unknown> }> = [];
@@ -36,6 +38,7 @@ describe("Codex app-server attempt turn watches", () => {
isTerminalTurnNotificationQueued: () => terminalQueued,
getActiveAppServerTurnRequests: () => activeRequests,
getActiveTurnItemCount: () => activeItems,
getActiveCompletionBlockerItemCount: () => activeCompletionBlockers,
turnCompletionIdleTimeoutMs: 10,
turnAssistantCompletionIdleTimeoutMs: 10,
turnAttemptIdleTimeoutMs: 10,
@@ -69,6 +72,9 @@ describe("Codex app-server attempt turn watches", () => {
set activeItems(value: number) {
activeItems = value;
},
set activeCompletionBlockers(value: number) {
activeCompletionBlockers = value;
},
interrupts,
timeouts,
events,
@@ -155,6 +161,32 @@ describe("Codex app-server attempt turn watches", () => {
expect(harness.abortController.signal.aborted).toBe(false);
});
it("waits for active completion blocker items before firing completion idle timeout", () => {
const harness = createController();
harness.activeCompletionBlockers = 1;
harness.controller.touchActivity("request:mcpServer/elicitation/request:response", {
arm: true,
});
vi.advanceTimersByTime(10);
expect(harness.timeouts).toEqual([]);
expect(harness.abortController.signal.aborted).toBe(false);
harness.activeCompletionBlockers = 0;
harness.controller.touchActivity("notification:item/completed");
vi.advanceTimersByTime(10);
expect(harness.timeouts).toMatchObject([
{
kind: "completion",
idleMs: 10,
timeoutMs: 10,
lastActivityReason: "notification:item/completed",
},
]);
});
it("releases a completed assistant item after the assistant idle guard expires", () => {
const harness = createController();
@@ -214,3 +246,41 @@ describe("Codex app-server attempt turn watches", () => {
expect(harness.abortController.signal.reason).toBe("turn_progress_idle_timeout");
});
});
describe("Codex completion blocker item tracking", () => {
it.each([
"collabAgentToolCall",
"commandExecution",
"dynamicToolCall",
"fileChange",
"imageGeneration",
"imageView",
"mcpToolCall",
"webSearch",
])("tracks the %s lifecycle", (type) => {
const activeItemIds = new Set<string>();
updateActiveCompletionBlockerItemIds(
{ method: "item/started", params: { item: { id: "item-1", type } } },
activeItemIds,
);
expect(activeItemIds).toEqual(new Set(["item-1"]));
updateActiveCompletionBlockerItemIds(
{ method: "item/completed", params: { item: { id: "item-1", type } } },
activeItemIds,
);
expect(activeItemIds).toEqual(new Set());
});
it.each(["agentMessage", "contextCompaction", "plan", "reasoning", "subAgentActivity"])(
"does not track the %s lifecycle",
(type) => {
const activeItemIds = new Set<string>();
updateActiveCompletionBlockerItemIds(
{ method: "item/started", params: { item: { id: "item-1", type } } },
activeItemIds,
);
expect(activeItemIds).toEqual(new Set());
},
);
});

View File

@@ -36,6 +36,7 @@ export function createCodexAttemptTurnWatchController(params: {
isTerminalTurnNotificationQueued: () => boolean;
getActiveAppServerTurnRequests: () => number;
getActiveTurnItemCount: () => number;
getActiveCompletionBlockerItemCount: () => number;
turnCompletionIdleTimeoutMs: number;
turnAssistantCompletionIdleTimeoutMs: number;
turnAttemptIdleTimeoutMs: number;
@@ -121,7 +122,8 @@ export function createCodexAttemptTurnWatchController(params: {
params.isCompleted() ||
params.signal.aborted ||
!completionIdleWatchArmed ||
params.getActiveAppServerTurnRequests() > 0
params.getActiveAppServerTurnRequests() > 0 ||
params.getActiveCompletionBlockerItemCount() > 0
) {
return;
}
@@ -183,7 +185,8 @@ export function createCodexAttemptTurnWatchController(params: {
params.isTerminalTurnNotificationQueued() ||
params.signal.aborted ||
!completionIdleWatchArmed ||
params.getActiveAppServerTurnRequests() > 0
params.getActiveAppServerTurnRequests() > 0 ||
params.getActiveCompletionBlockerItemCount() > 0
) {
return false;
}
@@ -302,7 +305,8 @@ export function createCodexAttemptTurnWatchController(params: {
params.isTerminalTurnNotificationQueued() ||
params.signal.aborted ||
!completionIdleWatchArmed ||
params.getActiveAppServerTurnRequests() > 0
params.getActiveAppServerTurnRequests() > 0 ||
params.getActiveCompletionBlockerItemCount() > 0
) {
return;
}

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

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

View File

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

View File

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

View File

@@ -53,7 +53,6 @@ import { attachCodexMirrorIdentity, buildCodexUserPromptMessage } from "./transc
export type CodexAppServerToolTelemetry = {
didSendViaMessagingTool: boolean;
didDeliverSourceReplyViaMessageTool?: boolean;
messagingToolSentTexts: string[];
messagingToolSentMediaUrls: string[];
messagingToolSentTargets: MessagingToolSend[];
@@ -412,8 +411,6 @@ export class CodexAppServerEventProjector {
currentAttemptAssistant,
...(this.lastNativeToolError ? { lastToolError: this.lastNativeToolError } : {}),
didSendViaMessagingTool: toolTelemetry.didSendViaMessagingTool,
didDeliverSourceReplyViaMessageTool:
toolTelemetry.didDeliverSourceReplyViaMessageTool === true,
messagingToolSentTexts: toolTelemetry.messagingToolSentTexts,
messagingToolSentMediaUrls: toolTelemetry.messagingToolSentMediaUrls,
messagingToolSentTargets: toolTelemetry.messagingToolSentTargets,

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

@@ -254,7 +254,7 @@ describe("Codex plugin thread config", () => {
const request = vi.fn(async (method: string, params?: unknown) => {
if (method === "app/list") {
appListParams.push(params as v2.AppsListParams);
return { data: [appInfo("google-calendar-app", true)], nextCursor: null };
return { data: [appInfo("google-calendar-app", true, false)], nextCursor: null };
}
if (method === "plugin/list") {
return pluginList([pluginSummary("google-calendar", { installed: true, enabled: true })]);
@@ -317,6 +317,117 @@ describe("Codex plugin thread config", () => {
]);
});
it("re-enables an OpenClaw-allowed app even when app/list reports it disabled", async () => {
const appCache = new CodexAppInventoryCache();
await appCache.refreshNow({
key: "runtime",
nowMs: 0,
request: async () => ({
data: [appInfo("google-calendar-app", true, false)],
nextCursor: null,
}),
});
const config = await buildCodexPluginThreadConfig({
pluginConfig: {
codexPlugins: {
enabled: true,
plugins: {
"google-calendar": {
marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME,
pluginName: "google-calendar",
},
},
},
},
appCache,
appCacheKey: "runtime",
nowMs: 1,
request: async (method) => {
if (method === "plugin/list") {
return pluginList([pluginSummary("google-calendar", { installed: true, enabled: true })]);
}
if (method === "plugin/read") {
return pluginDetail("google-calendar", [appSummary("google-calendar-app")]);
}
throw new Error(`unexpected request ${method}`);
},
});
expect(config.inventory?.records[0]?.apps).toStrictEqual([
{
id: "google-calendar-app",
name: "google-calendar-app",
accessible: true,
enabled: false,
needsAuth: false,
},
]);
expect(config.configPatch?.apps).toMatchObject({
"google-calendar-app": {
enabled: true,
},
});
expect(config.diagnostics).toStrictEqual([]);
});
it("refreshes missing app inventory when plugin activation becomes unnecessary", async () => {
const appCache = new CodexAppInventoryCache();
const appListParams: v2.AppsListParams[] = [];
let pluginListCalls = 0;
const request = vi.fn(async (method: string, params?: unknown) => {
if (method === "plugin/list") {
pluginListCalls += 1;
const active = pluginListCalls > 1;
return pluginList([
pluginSummary("google-calendar", { installed: active, enabled: active }),
]);
}
if (method === "plugin/read") {
return pluginDetail("google-calendar", [appSummary("google-calendar-app")]);
}
if (method === "app/list") {
appListParams.push(params as v2.AppsListParams);
return {
data: [appInfo("google-calendar-app", true)],
nextCursor: null,
} satisfies v2.AppsListResponse;
}
throw new Error(`unexpected request ${method}`);
});
const config = await buildCodexPluginThreadConfig({
pluginConfig: {
codexPlugins: {
enabled: true,
plugins: {
"google-calendar": {
marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME,
pluginName: "google-calendar",
},
},
},
},
appCache,
appCacheKey: "runtime",
request,
});
expect(config.configPatch?.apps).toMatchObject({
"google-calendar-app": {
enabled: true,
},
});
expect(request.mock.calls.map(([method]) => method)).not.toContain("plugin/install");
expect(appListParams).toEqual([
{
cursor: undefined,
limit: 100,
forceRefetch: true,
},
]);
});
it("does not expose plugin apps missing from the app inventory snapshot", async () => {
const appCache = new CodexAppInventoryCache();
await appCache.refreshNow({
@@ -375,11 +486,59 @@ describe("Codex plugin thread config", () => {
allowDestructiveActions: true,
destructiveApprovalMode: "allow",
},
message: "google-calendar-app is not accessible or enabled for google-calendar.",
message: "google-calendar-app is not accessible for google-calendar.",
},
]);
});
it("does not expose apps for plugins that OpenClaw policy leaves disabled", async () => {
const appCache = new CodexAppInventoryCache();
await appCache.refreshNow({
key: "runtime",
nowMs: 0,
request: async () => ({
data: [appInfo("google-calendar-app", true)],
nextCursor: null,
}),
});
const config = await buildCodexPluginThreadConfig({
pluginConfig: {
codexPlugins: {
enabled: true,
plugins: {
"google-calendar": {
enabled: false,
marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME,
pluginName: "google-calendar",
},
},
},
},
appCache,
appCacheKey: "runtime",
nowMs: 1,
request: async (method) => {
if (method === "plugin/list") {
return pluginList([pluginSummary("google-calendar", { installed: true, enabled: true })]);
}
throw new Error(`unexpected request ${method}`);
},
});
expect(config.configPatch).toEqual({
apps: {
_default: {
enabled: false,
destructive_enabled: false,
open_world_enabled: false,
},
},
});
expect(config.policyContext.apps).toStrictEqual({});
expect(config.diagnostics).toStrictEqual([]);
});
it("force-refreshes app inventory when proven plugin apps are not ready", async () => {
const appCache = new CodexAppInventoryCache();
await appCache.refreshNow({
@@ -572,9 +731,7 @@ describe("Codex plugin thread config", () => {
let installed = false;
const request = vi.fn(async (method: string, params?: unknown) => {
if (method === "plugin/list") {
return pluginList([
pluginSummary("google-calendar", { installed, enabled: installed }),
]);
return pluginList([pluginSummary("google-calendar", { installed, enabled: installed })]);
}
if (method === "plugin/read") {
return pluginDetail("google-calendar", [appSummary("google-calendar-app")]);
@@ -738,6 +895,70 @@ describe("Codex plugin thread config", () => {
]);
});
it("fails closed when app inventory entries are malformed", async () => {
const appCache = new CodexAppInventoryCache();
await appCache.refreshNow({
key: "runtime",
nowMs: 0,
request: async () =>
({
data: [{ ...appInfo("google-calendar-app", true), id: "" }] as unknown as v2.AppInfo[],
nextCursor: null,
}) satisfies v2.AppsListResponse,
});
const config = await buildCodexPluginThreadConfig({
pluginConfig: {
codexPlugins: {
enabled: true,
plugins: {
"google-calendar": {
marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME,
pluginName: "google-calendar",
},
},
},
},
appCache,
appCacheKey: "runtime",
nowMs: 1,
request: async (method) => {
if (method === "plugin/list") {
return pluginList([pluginSummary("google-calendar", { installed: true, enabled: true })]);
}
if (method === "plugin/read") {
return pluginDetail("google-calendar", [appSummary("google-calendar-app")]);
}
throw new Error(`unexpected request ${method}`);
},
});
expect(config.configPatch).toEqual({
apps: {
_default: {
enabled: false,
destructive_enabled: false,
open_world_enabled: false,
},
},
});
expect(config.policyContext.apps).toStrictEqual({});
expect(config.diagnostics).toStrictEqual([
{
code: "app_not_ready",
plugin: {
configKey: "google-calendar",
marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME,
pluginName: "google-calendar",
enabled: true,
allowDestructiveActions: true,
destructiveApprovalMode: "allow",
},
message: "google-calendar-app is not accessible for google-calendar.",
},
]);
});
it("uses durable policy and app cache key in the cheap input fingerprint", async () => {
const appCache = new CodexAppInventoryCache();
const first = buildCodexPluginThreadConfigInputFingerprint({

View File

@@ -125,6 +125,9 @@ export async function buildCodexPluginThreadConfig(
nowMs: params.nowMs,
suppressAppInventoryRefresh: true,
});
const appInventoryRefreshDeferredForActivation =
inventory.records.some((record) => record.activationRequired) &&
shouldRefreshMissingAppInventory(params, policy, inventory);
if (shouldWaitForInitialAppInventory(params, policy, inventory)) {
await refreshAppInventoryNow(params, appCache, {
forceRefetch: true,
@@ -166,10 +169,19 @@ export async function buildCodexPluginThreadConfig(
});
}
}
if (activationResults.some((activation) => activation.ok && activation.installAttempted)) {
const postInstallRefreshRequired = activationResults.some(
(activation) => activation.ok && activation.installAttempted,
);
// Activation can become unnecessary or fail before it refreshes apps. Rebuild the
// deferred missing snapshot so unrelated active plugin apps are not silently erased.
const deferredMissingRefreshRequired =
appInventoryRefreshDeferredForActivation &&
!postInstallRefreshRequired &&
shouldRefreshMissingAppInventory(params, policy, inventory);
if (postInstallRefreshRequired || deferredMissingRefreshRequired) {
await refreshAppInventoryNow(params, appCache, {
forceRefetch: true,
reason: "post_install",
reason: postInstallRefreshRequired ? "post_install" : "deferred_missing",
targetAppIds: collectInventoryOwnedAppIds(inventory),
});
inventory = await readCodexPluginInventory({
@@ -219,24 +231,22 @@ export async function buildCodexPluginThreadConfig(
const policyApps: Record<string, PluginAppPolicyContextEntry> = {};
const pluginAppIds: Record<string, string[]> = {};
for (const record of inventory.records) {
if (record.activationRequired) {
const activation = activationResults.find(
(item) => item.identity.configKey === record.policy.configKey,
);
if (!activation?.ok) {
continue;
}
const activation = activationResults.find(
(item) => item.identity.configKey === record.policy.configKey,
);
if (activation?.ok === false || (record.activationRequired && !activation?.ok)) {
continue;
}
if (record.appOwnership !== "proven") {
continue;
}
pluginAppIds[record.policy.configKey] = [...record.ownedAppIds].toSorted();
for (const app of resolveThreadConfigAppsForRecord({ record, inventory })) {
if (!app.accessible || !app.enabled) {
if (!isPluginAppReadyForThreadStart(app)) {
diagnostics.push({
code: "app_not_ready",
plugin: record.policy,
message: `${app.id} is not accessible or enabled for ${record.policy.pluginName}.`,
message: `${app.id} is not accessible for ${record.policy.pluginName}.`,
});
continue;
}
@@ -362,9 +372,18 @@ function shouldWaitForInitialAppInventory(
policy: ResolvedCodexPluginsPolicy,
inventory: CodexPluginInventory,
): boolean {
// Install/enable first so the initial app/list can observe newly activated plugin apps.
if (inventory.records.some((record) => record.activationRequired)) {
return false;
}
return shouldRefreshMissingAppInventory(params, policy, inventory);
}
function shouldRefreshMissingAppInventory(
params: BuildCodexPluginThreadConfigParams,
policy: ResolvedCodexPluginsPolicy,
inventory: CodexPluginInventory,
): boolean {
return Boolean(
params.appCacheKey &&
policy.pluginPolicies.some((plugin) => plugin.enabled) &&
@@ -419,6 +438,13 @@ function resolveThreadConfigAppsForRecord(params: {
return params.record.apps;
}
function isPluginAppReadyForThreadStart(app: CodexPluginOwnedApp): boolean {
// `app/list` is the source of truth for inventory and access posture, but
// OpenClaw owns the per-thread enablement decision. A listed app that is
// accessible can be re-enabled for this thread via `config.apps[app.id]`.
return app.accessible;
}
function shouldForceRefreshForNotReadyPluginApps(
params: BuildCodexPluginThreadConfigParams,
policy: ResolvedCodexPluginsPolicy,
@@ -434,7 +460,7 @@ function shouldForceRefreshForNotReadyPluginApps(
(record) =>
record.appOwnership === "proven" &&
record.ownedAppIds.length > 0 &&
(record.apps.length === 0 || record.apps.some((app) => !app.accessible || !app.enabled)),
(record.apps.length === 0 || record.apps.some((app) => !app.accessible)),
);
}

View File

@@ -4416,6 +4416,131 @@ describe("runCodexAppServerAttempt", () => {
expect(requests.map((entry) => entry.method)).not.toContain("app/list");
});
it("sends a thread/start app enable override when app/list cached the app as disabled", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
const agentDir = path.join(tempDir, "agent");
const pluginConfig = {
codexPlugins: {
enabled: true,
plugins: {
"google-calendar": {
marketplaceName: "openai-curated",
pluginName: "google-calendar",
},
},
},
};
const appServer = resolveCodexAppServerRuntimeOptions({
pluginConfig: readCodexPluginConfig(pluginConfig),
});
defaultCodexAppInventoryCache.clear();
await defaultCodexAppInventoryCache.refreshNow({
key: buildCodexPluginAppCacheKey({
appServer,
agentDir,
runtimeIdentity: getMockRuntimeIdentity(),
}),
request: async () => ({
data: [
{
id: "google-calendar-app",
name: "Google Calendar",
description: null,
logoUrl: null,
logoUrlDark: null,
distributionChannel: null,
branding: null,
appMetadata: null,
labels: null,
installUrl: null,
isAccessible: true,
isEnabled: false,
pluginDisplayNames: [],
},
],
nextCursor: null,
}),
});
const { requests, waitForMethod, completeTurn } = createStartedThreadHarness(async (method) => {
if (method === "plugin/list") {
return {
marketplaces: [
{
name: "openai-curated",
path: "/marketplaces/openai-curated",
interface: null,
plugins: [
{
id: "google-calendar",
name: "google-calendar",
source: { type: "remote" },
installed: true,
enabled: true,
installPolicy: "AVAILABLE",
authPolicy: "ON_USE",
availability: "AVAILABLE",
interface: null,
},
],
},
],
marketplaceLoadErrors: [],
featuredPluginIds: [],
};
}
if (method === "plugin/read") {
return {
plugin: {
marketplaceName: "openai-curated",
marketplacePath: "/marketplaces/openai-curated",
summary: {
id: "google-calendar",
name: "google-calendar",
source: { type: "remote" },
installed: true,
enabled: true,
installPolicy: "AVAILABLE",
authPolicy: "ON_USE",
availability: "AVAILABLE",
interface: null,
},
description: null,
skills: [],
apps: [
{
id: "google-calendar-app",
name: "Google Calendar",
description: null,
installUrl: null,
needsAuth: false,
},
],
mcpServers: ["google-calendar"],
},
};
}
if (method === "app/list") {
throw new Error("app/list should use the cached inventory entry");
}
return undefined;
});
const params = createParams(sessionFile, workspaceDir);
params.agentDir = agentDir;
const run = runCodexAppServerAttempt(params, { pluginConfig });
await waitForMethod("turn/start");
await completeTurn({ threadId: "thread-1", turnId: "turn-1" });
await run;
const threadStart = requests.find((entry) => entry.method === "thread/start");
const threadStartParams = threadStart?.params as
| { config?: { apps?: Record<string, { enabled?: boolean }> } }
| undefined;
expect(threadStartParams?.config?.apps?.["google-calendar-app"]?.enabled).toBe(true);
expect(requests.map((entry) => entry.method)).not.toContain("app/list");
});
it("keys plugin app inventory by inherited API key fallback credentials", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");

View File

@@ -841,11 +841,9 @@ export async function runCodexAppServerAttempt(
currentChannelProvider: resolveCodexMessageToolProvider(params),
currentChannelId: params.currentChannelId,
currentMessagingTarget: params.currentMessagingTarget,
currentMessageId: params.currentMessageId,
currentThreadId: params.currentThreadTs,
replyToMode: params.replyToMode,
hasRepliedRef: params.hasRepliedRef,
sourceReplyDeliveryMode: params.sourceReplyDeliveryMode,
onToolOutcome: onCodexToolOutcome,
allocateToolOutcomeOrdinal: allocateCodexToolOutcomeOrdinal,
},
@@ -1580,6 +1578,7 @@ export async function runCodexAppServerAttempt(
let activeAppServerTurnRequests = 0;
const pendingOpenClawDynamicToolCompletionIds = new Set<string>();
const activeTurnItemIds = new Set<string>();
const activeCompletionBlockerItemIds = new Set<string>();
let turnCrossedToolHandoff = false;
let pendingTerminalDynamicToolRelease:
| {
@@ -1629,6 +1628,7 @@ export async function runCodexAppServerAttempt(
isTerminalTurnNotificationQueued: () => terminalTurnNotificationQueued,
getActiveAppServerTurnRequests: () => activeAppServerTurnRequests,
getActiveTurnItemCount: () => activeTurnItemIds.size,
getActiveCompletionBlockerItemCount: () => activeCompletionBlockerItemIds.size,
turnCompletionIdleTimeoutMs,
turnAssistantCompletionIdleTimeoutMs,
turnAttemptIdleTimeoutMs,
@@ -1901,6 +1901,7 @@ export async function runCodexAppServerAttempt(
currentPromptTexts: [codexTurnPromptText],
turnWatches,
activeTurnItemIds,
activeCompletionBlockerItemIds,
activeAppServerTurnRequests,
pendingOpenClawDynamicToolCompletionIds,
turnCrossedToolHandoff,

View File

@@ -49,9 +49,7 @@ const DISABLED_CODEX_WEB_SEARCH_THREAD_CONFIG_FINGERPRINT = JSON.stringify({
web_search: "disabled",
});
function writeCodexAppServerBinding(
...args: Parameters<typeof writeRawCodexAppServerBinding>
) {
function writeCodexAppServerBinding(...args: Parameters<typeof writeRawCodexAppServerBinding>) {
const [sessionFile, binding, lookup] = args;
return writeRawCodexAppServerBinding(
sessionFile,
@@ -78,6 +76,7 @@ describe("createCodexAttemptTurnWatchController", () => {
isTerminalTurnNotificationQueued: () => false,
getActiveAppServerTurnRequests: () => 0,
getActiveTurnItemCount: () => 0,
getActiveCompletionBlockerItemCount: () => 0,
turnCompletionIdleTimeoutMs: 500,
turnAssistantCompletionIdleTimeoutMs: 500,
turnAttemptIdleTimeoutMs: 200,
@@ -807,6 +806,93 @@ describe("runCodexAppServerAttempt turn watches", () => {
expect(result.promptError).toBeNull();
});
it("keeps an eliciting MCP tool active past the completion timeout", async () => {
const harness = createStartedThreadHarness();
const bridgedResponse = {
action: "accept",
content: null,
_meta: null,
} as const;
vi.spyOn(elicitationBridge, "handleCodexAppServerElicitationRequest").mockResolvedValue(
bridgedResponse,
);
const params = createParams(
path.join(tempDir, "session-mcp-elicitation.jsonl"),
path.join(tempDir, "workspace-mcp-elicitation"),
);
params.timeoutMs = 500;
let settled = false;
const run = runCodexAppServerAttempt(params, {
turnCompletionIdleTimeoutMs: 15,
turnAssistantCompletionIdleTimeoutMs: 1_000,
turnTerminalIdleTimeoutMs: 1_000,
}).finally(() => {
settled = true;
});
await harness.waitForMethod("turn/start");
await harness.notify({
method: "item/started",
params: {
threadId: "thread-1",
turnId: "turn-1",
item: {
id: "mcp-1",
type: "mcpToolCall",
server: "computer-use",
tool: "computer",
status: "inProgress",
arguments: {},
},
},
});
await expect(
harness.handleServerRequest({
id: "request-mcp-elicitation",
method: "mcpServer/elicitation/request",
params: {
threadId: "thread-1",
turnId: "turn-1",
mode: "form",
message: "Approve?",
requestedSchema: { type: "object", properties: {} },
serverName: "computer-use",
_meta: null,
},
}),
).resolves.toEqual(bridgedResponse);
await new Promise((resolve) => {
setTimeout(resolve, 40);
});
expect(settled).toBe(false);
expect(harness.request.mock.calls.some(([method]) => method === "turn/interrupt")).toBe(false);
await harness.notify({
method: "item/completed",
params: {
threadId: "thread-1",
turnId: "turn-1",
item: {
id: "mcp-1",
type: "mcpToolCall",
server: "computer-use",
tool: "computer",
status: "completed",
arguments: {},
result: { content: [] },
},
},
});
await harness.completeTurn({ threadId: "thread-1", turnId: "turn-1" });
const result = await run;
expect(result.aborted).toBe(false);
expect(result.timedOut).toBe(false);
expect(result.promptError).toBeNull();
});
it("counts pending user input requests as turn attempt progress", async () => {
const harness = createStartedThreadHarness();
const params = createParams(

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

@@ -31,15 +31,21 @@ vi.mock("openclaw/plugin-sdk/provider-auth-runtime", () => ({
resolveApiKeyForProvider: resolveApiKeyForProviderMock,
}));
vi.mock("openclaw/plugin-sdk/provider-http", () => ({
assertOkOrThrowHttpError: assertOkOrThrowHttpErrorMock,
createProviderOperationDeadline: createProviderOperationDeadlineMock,
postJsonRequest: postJsonRequestMock,
postMultipartRequest: postMultipartRequestMock,
resolveProviderHttpRequestConfig: resolveProviderHttpRequestConfigMock,
resolveProviderOperationTimeoutMs: resolveProviderOperationTimeoutMsMock,
sanitizeConfiguredModelProviderRequest: vi.fn((request) => request),
}));
vi.mock("openclaw/plugin-sdk/provider-http", async () => {
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/provider-http")>(
"openclaw/plugin-sdk/provider-http",
);
return {
assertOkOrThrowHttpError: assertOkOrThrowHttpErrorMock,
createProviderOperationDeadline: createProviderOperationDeadlineMock,
postJsonRequest: postJsonRequestMock,
postMultipartRequest: postMultipartRequestMock,
readProviderJsonResponse: actual.readProviderJsonResponse,
resolveProviderHttpRequestConfig: resolveProviderHttpRequestConfigMock,
resolveProviderOperationTimeoutMs: resolveProviderOperationTimeoutMsMock,
sanitizeConfiguredModelProviderRequest: vi.fn((request) => request),
};
});
afterAll(() => {
vi.doUnmock("openclaw/plugin-sdk/provider-auth-runtime");
@@ -63,6 +69,13 @@ function requireFirstMockObjectArg(mock: ReturnType<typeof vi.fn>, label: string
return value;
}
function jsonResponse(payload: unknown): Response {
return new Response(JSON.stringify(payload), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
describe("deepinfra image generation provider", () => {
afterEach(() => {
assertOkOrThrowHttpErrorMock.mockClear();
@@ -86,11 +99,9 @@ describe("deepinfra image generation provider", () => {
const release = vi.fn(async () => {});
const jpegBytes = Buffer.from([0xff, 0xd8, 0xff, 0x00]);
postJsonRequestMock.mockResolvedValue({
response: {
json: async () => ({
data: [{ b64_json: jpegBytes.toString("base64"), revised_prompt: "red square" }],
}),
},
response: jsonResponse({
data: [{ b64_json: jpegBytes.toString("base64"), revised_prompt: "red square" }],
}),
release,
});
@@ -168,17 +179,15 @@ describe("deepinfra image generation provider", () => {
it("sends image edits as multipart OpenAI-compatible requests", async () => {
postMultipartRequestMock.mockResolvedValue({
response: {
json: async () => ({
data: [
{
b64_json: Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]).toString(
"base64",
),
},
],
}),
},
response: jsonResponse({
data: [
{
b64_json: Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]).toString(
"base64",
),
},
],
}),
release: vi.fn(async () => {}),
});

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,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

@@ -12,6 +12,7 @@ import { isProviderApiKeyConfigured } from "openclaw/plugin-sdk/provider-auth";
import {
assertOkOrThrowHttpError,
assertOkOrThrowProviderError,
readProviderJsonResponse,
} from "openclaw/plugin-sdk/provider-http";
import { readResponseWithLimit } from "openclaw/plugin-sdk/response-limit-runtime";
import {
@@ -645,7 +646,9 @@ export function buildFalImageGenerationProvider(): ImageGenerationProvider {
try {
await assertOkOrThrowHttpError(response, "fal image generation failed");
const payload = parseFalImageGenerationResponse(await response.json());
const payload = parseFalImageGenerationResponse(
await readProviderJsonResponse(response, "fal.image-generation"),
);
const images: GeneratedImageAsset[] = [];
let imageIndex = 0;
for (const entry of payload.images) {

View File

@@ -29,21 +29,30 @@ function expectExplicitDefaultAccountSelection(
expect(account.appId).toBe(appId);
}
function withEnvVar(key: string, value: string | undefined, run: () => void) {
function setTestEnvValue(key: string, value: string | undefined): () => void {
const prev = process.env[key];
if (value === undefined) {
delete process.env[key];
Reflect.deleteProperty(process.env, key);
} else {
process.env[key] = value;
Reflect.set(process.env, key, value);
}
return () => restoreTestEnvValue(key, prev);
}
function restoreTestEnvValue(key: string, value: string | undefined): void {
if (value === undefined) {
Reflect.deleteProperty(process.env, key);
} else {
Reflect.set(process.env, key, value);
}
}
function withEnvVar(key: string, value: string | undefined, run: () => void): void {
const restore = setTestEnvValue(key, value);
try {
run();
} finally {
if (prev === undefined) {
delete process.env[key];
} else {
process.env[key] = prev;
}
restore();
}
}
@@ -214,8 +223,7 @@ describe("resolveFeishuCredentials", () => {
it("resolves env SecretRef objects when unresolved refs are allowed", () => {
const key = "FEISHU_APP_SECRET_TEST";
const prev = process.env[key];
process.env[key] = " secret_from_env ";
const restore = setTestEnvValue(key, " secret_from_env ");
try {
const creds = resolveFeishuCredentials(
@@ -234,18 +242,13 @@ describe("resolveFeishuCredentials", () => {
domain: "feishu",
});
} finally {
if (prev === undefined) {
delete process.env[key];
} else {
process.env[key] = prev;
}
restore();
}
});
it("resolves env SecretRef with custom provider alias when unresolved refs are allowed", () => {
const key = "FEISHU_APP_SECRET_CUSTOM_PROVIDER_TEST";
const prev = process.env[key];
process.env[key] = " secret_from_env_alias ";
const restore = setTestEnvValue(key, " secret_from_env_alias ");
try {
const creds = resolveFeishuCredentials(
@@ -258,11 +261,7 @@ describe("resolveFeishuCredentials", () => {
expect(creds?.appSecret).toBe("secret_from_env_alias");
} finally {
if (prev === undefined) {
delete process.env[key];
} else {
process.env[key] = prev;
}
restore();
}
});

View File

@@ -1,16 +1,21 @@
// Feishu tests cover app registration plugin behavior.
import { MAX_TIMER_TIMEOUT_MS } from "openclaw/plugin-sdk/number-runtime";
import { afterEach, describe, expect, it, vi } from "vitest";
import { beginAppRegistration, pollAppRegistration } from "./app-registration.js";
import { beginAppRegistration, pollAppRegistration, printQrCode } from "./app-registration.js";
const { fetchWithSsrFGuardMock } = vi.hoisted(() => ({
const { fetchWithSsrFGuardMock, renderQrTerminalMock } = vi.hoisted(() => ({
fetchWithSsrFGuardMock: vi.fn(),
renderQrTerminalMock: vi.fn(async () => "terminal-qr"),
}));
vi.mock("openclaw/plugin-sdk/ssrf-runtime", () => ({
fetchWithSsrFGuard: fetchWithSsrFGuardMock,
}));
vi.mock("./qr-terminal.js", () => ({
renderQrTerminal: renderQrTerminalMock,
}));
function mockFeishuJson(payload: unknown) {
fetchWithSsrFGuardMock.mockResolvedValueOnce({
response: new Response(JSON.stringify(payload), { status: 200 }),
@@ -23,6 +28,7 @@ describe("Feishu app registration", () => {
vi.useRealTimers();
vi.restoreAllMocks();
fetchWithSsrFGuardMock.mockReset();
renderQrTerminalMock.mockClear();
});
it("defaults unsafe begin polling lifetimes from provider responses", async () => {
@@ -59,4 +65,16 @@ describe("Feishu app registration", () => {
await vi.runOnlyPendingTimersAsync();
await expect(poll).resolves.toEqual({ status: "timeout" });
});
it("prints scan-to-create QR codes with compact terminal rendering", async () => {
const writeSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
await printQrCode("https://accounts.feishu.cn/verify?device_code=long-device-code");
expect(renderQrTerminalMock).toHaveBeenCalledWith(
"https://accounts.feishu.cn/verify?device_code=long-device-code",
{ small: true },
);
expect(writeSpy).toHaveBeenCalledWith("terminal-qr\n");
});
});

View File

@@ -266,7 +266,7 @@ export async function pollAppRegistration(params: {
* otherwise the pattern is corrupted and cannot be scanned.
*/
export async function printQrCode(url: string): Promise<void> {
const output = await renderQrTerminal(url);
const output = await renderQrTerminal(url, { small: true });
process.stdout.write(output.endsWith("\n") ? output : `${output}\n`);
}

View File

@@ -83,6 +83,14 @@ let FEISHU_USER_AGENT: string;
let priorProxyEnv: Partial<Record<ProxyEnvKey, string | undefined>> = {};
let priorFeishuTimeoutEnv: string | undefined;
function setFeishuTestEnvValue(key: string, value: string | undefined): void {
if (value === undefined) {
Reflect.deleteProperty(process.env, key);
} else {
Reflect.set(process.env, key, value);
}
}
vi.mock("./channel.js", () => ({
feishuPlugin: feishuPluginMock,
}));
@@ -213,10 +221,10 @@ beforeAll(async () => {
beforeEach(() => {
priorProxyEnv = {};
priorFeishuTimeoutEnv = process.env[FEISHU_HTTP_TIMEOUT_ENV_VAR];
delete process.env[FEISHU_HTTP_TIMEOUT_ENV_VAR];
setFeishuTestEnvValue(FEISHU_HTTP_TIMEOUT_ENV_VAR, undefined);
for (const key of proxyEnvKeys) {
priorProxyEnv[key] = process.env[key];
delete process.env[key];
setFeishuTestEnvValue(key, undefined);
}
vi.clearAllMocks();
clearClientCache();
@@ -238,18 +246,9 @@ beforeEach(() => {
afterEach(() => {
for (const key of proxyEnvKeys) {
const value = priorProxyEnv[key];
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
if (priorFeishuTimeoutEnv === undefined) {
delete process.env[FEISHU_HTTP_TIMEOUT_ENV_VAR];
} else {
process.env[FEISHU_HTTP_TIMEOUT_ENV_VAR] = priorFeishuTimeoutEnv;
setFeishuTestEnvValue(key, priorProxyEnv[key]);
}
setFeishuTestEnvValue(FEISHU_HTTP_TIMEOUT_ENV_VAR, priorFeishuTimeoutEnv);
setFeishuClientRuntimeForTest();
});
@@ -359,7 +358,7 @@ describe("createFeishuClient HTTP timeout", () => {
});
it("uses env timeout override when provided and no direct timeout is set", async () => {
process.env[FEISHU_HTTP_TIMEOUT_ENV_VAR] = "60000";
setFeishuTestEnvValue(FEISHU_HTTP_TIMEOUT_ENV_VAR, "60000");
createFeishuClient({
appId: "app_8",
@@ -373,7 +372,7 @@ describe("createFeishuClient HTTP timeout", () => {
it("ignores non-decimal env timeout overrides", async () => {
for (const value of ["0x10", "1e3", "10.5"]) {
process.env[FEISHU_HTTP_TIMEOUT_ENV_VAR] = value;
setFeishuTestEnvValue(FEISHU_HTTP_TIMEOUT_ENV_VAR, value);
createFeishuClient({
appId: `app-${value}`,
@@ -387,7 +386,7 @@ describe("createFeishuClient HTTP timeout", () => {
});
it("prefers direct timeout over env override", async () => {
process.env[FEISHU_HTTP_TIMEOUT_ENV_VAR] = "60000";
setFeishuTestEnvValue(FEISHU_HTTP_TIMEOUT_ENV_VAR, "60000");
createFeishuClient({
appId: "app_10",
@@ -401,7 +400,10 @@ describe("createFeishuClient HTTP timeout", () => {
});
it("clamps env timeout override to max bound", async () => {
process.env[FEISHU_HTTP_TIMEOUT_ENV_VAR] = String(FEISHU_HTTP_TIMEOUT_MAX_MS + 123_456);
setFeishuTestEnvValue(
FEISHU_HTTP_TIMEOUT_ENV_VAR,
String(FEISHU_HTTP_TIMEOUT_MAX_MS + 123_456),
);
createFeishuClient({
appId: "app_9",
@@ -505,7 +507,7 @@ describe("createFeishuWSClient proxy handling", () => {
});
it("creates a ws proxy agent when lowercase https_proxy is set", async () => {
process.env.https_proxy = "http://lower-https:8001";
setFeishuTestEnvValue("https_proxy", "http://lower-https:8001");
await createFeishuWSClient(baseAccount);
@@ -515,7 +517,7 @@ describe("createFeishuWSClient proxy handling", () => {
});
it("creates a ws proxy agent when uppercase HTTPS_PROXY is set", async () => {
process.env.HTTPS_PROXY = "http://upper-https:8002";
setFeishuTestEnvValue("HTTPS_PROXY", "http://upper-https:8002");
await createFeishuWSClient(baseAccount);
@@ -525,7 +527,7 @@ describe("createFeishuWSClient proxy handling", () => {
});
it("falls back to HTTP_PROXY for ws proxy agent creation", async () => {
process.env.HTTP_PROXY = "http://upper-http:8999";
setFeishuTestEnvValue("HTTP_PROXY", "http://upper-http:8999");
await createFeishuWSClient(baseAccount);

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

@@ -855,7 +855,7 @@ describe("google-meet plugin", () => {
});
it("registers the node-host command used by chrome-node transport", () => {
const { nodeHostCommands } = setup();
const { nodeHostCommands, nodeInvokePolicies } = setup();
const command = nodeHostCommands.find(
(entry): entry is Record<string, unknown> =>
@@ -865,7 +865,13 @@ describe("google-meet plugin", () => {
throw new Error("expected googlemeet.chrome node host command");
}
expect(command.cap).toBe("google-meet");
expect(command.dangerous).toBe(true);
expect(typeof command.handle).toBe("function");
expect(nodeInvokePolicies).toHaveLength(1);
expect(nodeInvokePolicies[0]).toMatchObject({
commands: ["googlemeet.chrome"],
dangerous: true,
});
});
it("keeps the agent tool visible on non-macOS hosts but blocks local Chrome talk-back joins", async () => {
@@ -2239,6 +2245,9 @@ describe("google-meet plugin", () => {
try {
const { methods, runCommandWithTimeout } = setup({
defaultMode: "transcribe",
chrome: {
browserProfile: "meet-devtools",
},
});
const callGatewayFromCli = mockLocalMeetBrowserRequest({
inCall: true,
@@ -3428,7 +3437,12 @@ describe("google-meet plugin", () => {
},
);
chromeTransportTesting.setDepsForTest({ callGatewayFromCli });
const { tools, nodesInvoke } = setup({ defaultTransport: "chrome" });
const { tools, nodesInvoke } = setup({
defaultTransport: "chrome",
chrome: {
browserProfile: "meet-devtools",
},
});
const tool = tools[0] as {
execute: (
id: string,
@@ -3458,6 +3472,7 @@ describe("google-meet plugin", () => {
expect(focusCall[0]).toBe("browser.request");
expect(requireRecord(focusCall[2], "focus request").method).toBe("POST");
expect(requireRecord(focusCall[2], "focus request").path).toBe("/tabs/focus");
expect(requireRecord(focusCall[2], "focus request").query).toBeUndefined();
expect(focusCall[3]).toEqual({ progress: false });
expect(nodesInvoke).not.toHaveBeenCalled();
});

View File

@@ -35,6 +35,10 @@ import {
fetchGoogleMeetSpace,
} from "./src/meet.js";
import { handleGoogleMeetNodeHostCommand } from "./src/node-host.js";
import {
createGoogleMeetChromeNodeInvokePolicy,
GOOGLE_MEET_CHROME_NODE_COMMAND,
} from "./src/node-invoke-policy.js";
import { GoogleMeetRuntime } from "./src/runtime.js";
import { isGoogleMeetBrowserManualActionError } from "./src/transports/chrome-create.js";
@@ -1196,10 +1200,12 @@ export default definePluginEntry({
);
api.registerNodeHostCommand({
command: "googlemeet.chrome",
command: GOOGLE_MEET_CHROME_NODE_COMMAND,
cap: "google-meet",
dangerous: true,
handle: handleGoogleMeetNodeHostCommand,
});
api.registerNodeInvokePolicy(createGoogleMeetChromeNodeInvokePolicy(config));
api.registerCli(
async ({ program }) => {

View File

@@ -91,6 +91,41 @@ describe("google-meet node host bridge sessions", () => {
}
});
it("passes the Meet URL before Chrome profile args when launching a profiled browser", async () => {
const originalPlatform = process.platform;
children.length = 0;
vi.mocked(spawnSync).mockClear();
Object.defineProperty(process, "platform", { configurable: true, value: "darwin" });
try {
const start = JSON.parse(
await handleGoogleMeetNodeHostCommand(
JSON.stringify({
action: "start",
url: "https://meet.google.com/xyz-abcd-uvw",
mode: "transcribe",
browserProfile: "Profile 2",
}),
),
);
expect(start.launched).toBe(true);
expect(spawnSync).toHaveBeenCalledWith(
"open",
[
"-a",
"Google Chrome",
"https://meet.google.com/xyz-abcd-uvw",
"--args",
"--profile-directory=Profile 2",
],
expect.objectContaining({ encoding: "utf8" }),
);
} finally {
Object.defineProperty(process, "platform", { configurable: true, value: originalPlatform });
}
});
it("clears output playback without closing the active bridge when the old output exits", async () => {
const originalPlatform = process.platform;
children.length = 0;

View File

@@ -332,12 +332,11 @@ function startChrome(params: Record<string, unknown>) {
}
if (params.launch !== false) {
const argv = ["open", "-a", "Google Chrome"];
const argv = ["open", "-a", "Google Chrome", url];
const browserProfile = readString(params.browserProfile);
if (browserProfile) {
argv.push("--args", `--profile-directory=${browserProfile}`);
}
argv.push(url);
const result = runCommandWithTimeout(argv, timeoutMs);
if (result.code !== 0) {
if (bridgeId) {

View File

@@ -0,0 +1,134 @@
// Google Meet node.invoke policy tests cover caller-controlled command sanitization.
import type { OpenClawPluginNodeInvokePolicyContext } from "openclaw/plugin-sdk/plugin-entry";
import { describe, expect, it, vi } from "vitest";
import { resolveGoogleMeetConfig } from "./config.js";
import {
createGoogleMeetChromeNodeInvokePolicy,
GOOGLE_MEET_CHROME_NODE_COMMAND,
} from "./node-invoke-policy.js";
function createContext(params: unknown, pluginConfig: Record<string, unknown> = {}) {
const invokeNode = vi.fn<OpenClawPluginNodeInvokePolicyContext["invokeNode"]>(async () => ({
ok: true,
payload: { ok: true },
}));
const ctx: OpenClawPluginNodeInvokePolicyContext = {
nodeId: "node-1",
command: GOOGLE_MEET_CHROME_NODE_COMMAND,
params,
config: {} as never,
pluginConfig,
invokeNode,
};
return { ctx, invokeNode };
}
describe("Google Meet node invoke policy", () => {
it("rewrites start executable fields from trusted config", async () => {
const policy = createGoogleMeetChromeNodeInvokePolicy(
resolveGoogleMeetConfig({
chrome: {
launch: false,
browserProfile: "Trusted Profile",
joinTimeoutMs: 45_000,
audioInputCommand: ["trusted-capture", "--raw"],
audioOutputCommand: ["trusted-play", "--raw"],
},
}),
);
const { ctx, invokeNode } = createContext({
action: "start",
url: "https://meet.google.com/abc-defg-hij",
mode: "bidi",
launch: true,
browserProfile: "Attacker Profile",
joinTimeoutMs: 1,
audioBridgeCommand: ["node", "-e", "process.exit(99)"],
audioBridgeHealthCommand: ["node", "-e", "process.exit(98)"],
audioInputCommand: ["malicious-capture"],
audioOutputCommand: ["malicious-play"],
});
await expect(policy.handle(ctx)).resolves.toEqual({ ok: true, payload: { ok: true } });
expect(invokeNode).toHaveBeenCalledTimes(1);
expect(invokeNode).toHaveBeenCalledWith({
params: {
action: "start",
url: "https://meet.google.com/abc-defg-hij",
mode: "bidi",
launch: false,
browserProfile: "Trusted Profile",
joinTimeoutMs: 45_000,
audioInputCommand: ["trusted-capture", "--raw"],
audioOutputCommand: ["trusted-play", "--raw"],
},
});
});
it("uses trusted configured external bridge commands for start", async () => {
const policy = createGoogleMeetChromeNodeInvokePolicy(
resolveGoogleMeetConfig({
chrome: {
audioBridgeHealthCommand: ["trusted-bridge", "status"],
audioBridgeCommand: ["trusted-bridge", "start"],
},
}),
);
const { ctx, invokeNode } = createContext({
action: "start",
url: "https://meet.google.com/abc-defg-hij",
mode: "bidi",
audioBridgeHealthCommand: ["node", "-e", "process.exit(98)"],
audioBridgeCommand: ["node", "-e", "process.exit(99)"],
});
await policy.handle(ctx);
const call = invokeNode.mock.calls[0]?.[0];
expect(call?.params).toMatchObject({
action: "start",
audioBridgeHealthCommand: ["trusted-bridge", "status"],
audioBridgeCommand: ["trusted-bridge", "start"],
});
});
it("rejects direct start for non-Meet URLs before node dispatch", async () => {
const policy = createGoogleMeetChromeNodeInvokePolicy(resolveGoogleMeetConfig({}));
const { ctx, invokeNode } = createContext({
action: "start",
url: "https://example.com/private",
mode: "bidi",
});
await expect(policy.handle(ctx)).resolves.toMatchObject({
ok: false,
code: "GOOGLE_MEET_NODE_POLICY_DENIED",
message: "url must be an explicit https://meet.google.com/... URL",
});
expect(invokeNode).not.toHaveBeenCalled();
});
it("keeps direct setup diagnostics but strips extra fields", async () => {
const policy = createGoogleMeetChromeNodeInvokePolicy(resolveGoogleMeetConfig({}));
const { ctx, invokeNode } = createContext({
action: "setup",
audioBridgeCommand: ["node", "-e", "process.exit(99)"],
});
await policy.handle(ctx);
expect(invokeNode).toHaveBeenCalledWith({ params: { action: "setup" } });
});
it("rejects unsupported googlemeet.chrome actions before node dispatch", async () => {
const policy = createGoogleMeetChromeNodeInvokePolicy(resolveGoogleMeetConfig({}));
const { ctx, invokeNode } = createContext({ action: "exec", command: ["id"] });
await expect(policy.handle(ctx)).resolves.toMatchObject({
ok: false,
code: "GOOGLE_MEET_NODE_POLICY_DENIED",
});
expect(invokeNode).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,192 @@
import type {
OpenClawPluginNodeInvokePolicy,
OpenClawPluginNodeInvokePolicyContext,
OpenClawPluginNodeInvokePolicyResult,
} from "openclaw/plugin-sdk/plugin-entry";
import type { GoogleMeetConfig } from "./config.js";
import { normalizeMeetUrl } from "./runtime.js";
export const GOOGLE_MEET_CHROME_NODE_COMMAND = "googlemeet.chrome";
const START_MODES = new Set(["agent", "bidi", "realtime", "transcribe"]);
type PolicyDecision =
| { approved: true; params: Record<string, unknown> }
| { approved: false; result: OpenClawPluginNodeInvokePolicyResult };
function asRecord(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
}
function readString(value: unknown): string | undefined {
return typeof value === "string" && value.length > 0 ? value : undefined;
}
function readPositiveNumber(value: unknown): number | undefined {
return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : undefined;
}
function copyCommand(command: string[] | undefined): string[] | undefined {
return command && command.length > 0 ? [...command] : undefined;
}
function denied(message: string, code = "GOOGLE_MEET_NODE_POLICY_DENIED") {
return { ok: false as const, code, message };
}
function approved(params: Record<string, unknown>): PolicyDecision {
return { approved: true, params };
}
function buildStartParams(
params: Record<string, unknown>,
config: GoogleMeetConfig,
): PolicyDecision {
let url: string;
try {
url = normalizeMeetUrl(params.url);
} catch (error) {
return {
approved: false,
result: denied(
error instanceof Error ? error.message : "googlemeet.chrome start requires url",
),
};
}
const mode = readString(params.mode);
if (mode && !START_MODES.has(mode)) {
return {
approved: false,
result: denied(`googlemeet.chrome start mode is unsupported: ${mode}`),
};
}
const startParams: Record<string, unknown> = {
action: "start",
url,
launch: params.launch === false ? false : config.chrome.launch,
browserProfile: config.chrome.browserProfile,
joinTimeoutMs: config.chrome.joinTimeoutMs,
};
if (mode) {
startParams.mode = mode;
}
const audioInputCommand = copyCommand(config.chrome.audioInputCommand);
if (audioInputCommand) {
startParams.audioInputCommand = audioInputCommand;
}
const audioOutputCommand = copyCommand(config.chrome.audioOutputCommand);
if (audioOutputCommand) {
startParams.audioOutputCommand = audioOutputCommand;
}
const audioBridgeCommand = copyCommand(config.chrome.audioBridgeCommand);
if (audioBridgeCommand) {
startParams.audioBridgeCommand = audioBridgeCommand;
}
const audioBridgeHealthCommand = copyCommand(config.chrome.audioBridgeHealthCommand);
if (audioBridgeHealthCommand) {
startParams.audioBridgeHealthCommand = audioBridgeHealthCommand;
}
return approved(startParams);
}
function buildForwardParams(params: Record<string, unknown>): Record<string, unknown> | null {
const action = readString(params.action);
switch (action) {
case "setup":
return { action };
case "status": {
const bridgeId = readString(params.bridgeId);
return bridgeId ? { action, bridgeId } : { action };
}
case "list": {
const forwarded: Record<string, unknown> = { action };
const url = readString(params.url);
const mode = readString(params.mode);
if (url) {
forwarded.url = url;
}
if (mode) {
forwarded.mode = mode;
}
return forwarded;
}
case "stopByUrl": {
const forwarded: Record<string, unknown> = { action };
const url = readString(params.url);
const mode = readString(params.mode);
const exceptBridgeId = readString(params.exceptBridgeId);
if (url) {
forwarded.url = url;
}
if (mode) {
forwarded.mode = mode;
}
if (exceptBridgeId) {
forwarded.exceptBridgeId = exceptBridgeId;
}
return forwarded;
}
case "pullAudio": {
const forwarded: Record<string, unknown> = { action };
const bridgeId = readString(params.bridgeId);
const timeoutMs = readPositiveNumber(params.timeoutMs);
if (bridgeId) {
forwarded.bridgeId = bridgeId;
}
if (timeoutMs) {
forwarded.timeoutMs = timeoutMs;
}
return forwarded;
}
case "pushAudio": {
const forwarded: Record<string, unknown> = { action };
const bridgeId = readString(params.bridgeId);
const base64 = readString(params.base64);
if (bridgeId) {
forwarded.bridgeId = bridgeId;
}
if (base64) {
forwarded.base64 = base64;
}
return forwarded;
}
case "clearAudio":
case "stop": {
const bridgeId = readString(params.bridgeId);
return bridgeId ? { action, bridgeId } : { action };
}
default:
return null;
}
}
export function createGoogleMeetChromeNodeInvokePolicy(
config: GoogleMeetConfig,
): OpenClawPluginNodeInvokePolicy {
return {
commands: [GOOGLE_MEET_CHROME_NODE_COMMAND],
dangerous: true,
async handle(ctx: OpenClawPluginNodeInvokePolicyContext) {
if (ctx.command !== GOOGLE_MEET_CHROME_NODE_COMMAND) {
return denied(`unsupported Google Meet node command: ${ctx.command}`);
}
const params = asRecord(ctx.params);
const action = readString(params.action);
let decision: PolicyDecision;
if (action === "start") {
decision = buildStartParams(params, config);
} else {
const forwardParams = buildForwardParams(params);
decision = forwardParams
? approved(forwardParams)
: { approved: false, result: denied("unsupported googlemeet.chrome action") };
}
if (!decision.approved) {
return decision.result;
}
return await ctx.invokeNode({ params: decision.params });
},
};
}

View File

@@ -69,6 +69,7 @@ export function setupGoogleMeetPlugin(
const tools: unknown[] = [];
const cliRegistrations: unknown[] = [];
const nodeHostCommands: unknown[] = [];
const nodeInvokePolicies: unknown[] = [];
const nodesList = vi.fn(
async () =>
options.nodesListResult ?? {
@@ -165,6 +166,7 @@ export function setupGoogleMeetPlugin(
},
registerCli: (_registrar: unknown, opts: unknown) => cliRegistrations.push(opts),
registerNodeHostCommand: (command: unknown) => nodeHostCommands.push(command),
registerNodeInvokePolicy: (policy: unknown) => nodeInvokePolicies.push(policy),
});
const originalPlatform = process.platform;
Object.defineProperty(process, "platform", {
@@ -184,6 +186,7 @@ export function setupGoogleMeetPlugin(
nodesList,
nodesInvoke,
nodeHostCommands,
nodeInvokePolicies,
};
}

View File

@@ -8,6 +8,13 @@ import { testing as geminiWebSearchTesting } from "./src/gemini-web-search-provi
let ssrfMock: { mockRestore: () => void } | undefined;
function jsonResponse(payload: unknown): Response {
return new Response(JSON.stringify(payload), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
function mockGoogleApiKeyAuth() {
vi.spyOn(providerAuthRuntime, "resolveApiKeyForProvider").mockResolvedValue({
apiKey: "google-test-key",
@@ -24,9 +31,8 @@ function installGoogleFetchMock(params?: {
const mimeType = params?.mimeType ?? "image/png";
const data = params?.data ?? "png-data";
const inlineDataKey = params?.inlineDataKey ?? "inlineData";
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
const fetchMock = vi.fn().mockResolvedValue(
jsonResponse({
candidates: [
{
content: {
@@ -42,7 +48,7 @@ function installGoogleFetchMock(params?: {
},
],
}),
});
);
vi.stubGlobal("fetch", fetchMock);
return fetchMock;
}
@@ -100,9 +106,8 @@ describe("Google image-generation provider", () => {
source: "env",
mode: "api-key",
});
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
const fetchMock = vi.fn().mockResolvedValue(
jsonResponse({
candidates: [
{
content: {
@@ -119,7 +124,7 @@ describe("Google image-generation provider", () => {
},
],
}),
});
);
vi.stubGlobal("fetch", fetchMock);
const provider = buildGoogleImageGenerationProvider();
@@ -208,10 +213,7 @@ describe("Google image-generation provider", () => {
mockGoogleApiKeyAuth();
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ candidates: { content: { parts: [] } } }),
}),
vi.fn().mockResolvedValue(jsonResponse({ candidates: { content: { parts: [] } } })),
);
const provider = buildGoogleImageGenerationProvider();
@@ -229,9 +231,8 @@ describe("Google image-generation provider", () => {
mockGoogleApiKeyAuth();
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
vi.fn().mockResolvedValue(
jsonResponse({
candidates: [
{
content: {
@@ -240,7 +241,7 @@ describe("Google image-generation provider", () => {
},
],
}),
}),
),
);
const provider = buildGoogleImageGenerationProvider();
@@ -260,9 +261,8 @@ describe("Google image-generation provider", () => {
source: "profile",
mode: "token",
});
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
const fetchMock = vi.fn().mockResolvedValue(
jsonResponse({
candidates: [
{
content: {
@@ -278,7 +278,7 @@ describe("Google image-generation provider", () => {
},
],
}),
});
);
vi.stubGlobal("fetch", fetchMock);
const provider = buildGoogleImageGenerationProvider();
@@ -305,6 +305,74 @@ describe("Google image-generation provider", () => {
});
});
it("accepts valid multi-image inline JSON responses above the generic provider JSON cap", async () => {
mockGoogleApiKeyAuth();
const imageBytes = Buffer.alloc(6 * 1024 * 1024, 1);
const imagePayload = imageBytes.toString("base64");
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue(
jsonResponse({
candidates: [
{
content: {
parts: Array.from({ length: 3 }, () => ({
inlineData: {
mimeType: "image/png",
data: imagePayload,
},
})),
},
},
],
}),
),
);
const provider = buildGoogleImageGenerationProvider();
const result = await provider.generateImage({
provider: "google",
model: "gemini-3.1-flash-image-preview",
prompt: "draw a cat",
cfg: {},
});
expect(result.images).toHaveLength(3);
expect(result.images.map((image) => image.buffer.byteLength)).toEqual([
imageBytes.byteLength,
imageBytes.byteLength,
imageBytes.byteLength,
]);
});
it("still rejects oversized Google image JSON responses", async () => {
mockGoogleApiKeyAuth();
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue(
jsonResponse({
candidates: [
{
content: {
parts: [{ text: "x".repeat(35 * 1024 * 1024) }],
},
},
],
}),
),
);
const provider = buildGoogleImageGenerationProvider();
await expect(
provider.generateImage({
provider: "google",
model: "gemini-3.1-flash-image-preview",
prompt: "draw a cat",
cfg: {},
}),
).rejects.toThrow("google.image-generation: JSON response exceeds");
});
it("sends reference images and explicit resolution for edit flows", async () => {
mockGoogleApiKeyAuth();
const fetchMock = installGoogleFetchMock();

View File

@@ -1,15 +1,18 @@
// Google provider module implements model/runtime integration.
import {
generatedImageAssetFromBase64,
resolveInlineImageJsonResponseMaxBytes,
type GeneratedImageAsset,
type ImageGenerationProvider,
} from "openclaw/plugin-sdk/image-generation";
import { MAX_IMAGE_BYTES } from "openclaw/plugin-sdk/media-runtime";
import { parseStrictPositiveInteger } from "openclaw/plugin-sdk/number-runtime";
import { isProviderApiKeyConfigured } from "openclaw/plugin-sdk/provider-auth";
import { resolveApiKeyForProvider } from "openclaw/plugin-sdk/provider-auth-runtime";
import {
assertOkOrThrowHttpError,
postJsonRequest,
readProviderJsonResponse,
sanitizeConfiguredModelProviderRequest,
} from "openclaw/plugin-sdk/provider-http";
import {
@@ -22,6 +25,8 @@ import { normalizeGoogleModelId, resolveGoogleGenerativeAiHttpRequestConfig } fr
const DEFAULT_GOOGLE_IMAGE_MODEL = "gemini-3.1-flash-image-preview";
const DEFAULT_IMAGE_TIMEOUT_MS = 180_000;
const DEFAULT_OUTPUT_MIME = "image/png";
const GOOGLE_MAX_IMAGE_RESULTS = 4;
const MB = 1024 * 1024;
const GOOGLE_SUPPORTED_SIZES = [
"1024x1024",
"1024x1536",
@@ -49,6 +54,16 @@ function normalizeGoogleImageModel(model: string | undefined): string {
return normalizeGoogleModelId(trimmed || DEFAULT_GOOGLE_IMAGE_MODEL);
}
function resolveGeneratedImageMaxBytes(req: {
cfg: { agents?: { defaults?: { mediaMaxMb?: number } } };
}): number {
const configured = req.cfg.agents?.defaults?.mediaMaxMb;
if (typeof configured === "number" && Number.isFinite(configured) && configured > 0) {
return Math.floor(configured * MB);
}
return MAX_IMAGE_BYTES;
}
function mapSizeToImageConfig(
size: string | undefined,
): { aspectRatio?: string; imageSize?: "2K" | "4K" } | undefined {
@@ -149,14 +164,14 @@ export function buildGoogleImageGenerationProvider(): ImageGenerationProvider {
}),
capabilities: {
generate: {
maxCount: 4,
maxCount: GOOGLE_MAX_IMAGE_RESULTS,
supportsSize: true,
supportsAspectRatio: true,
supportsResolution: true,
},
edit: {
enabled: true,
maxCount: 4,
maxCount: GOOGLE_MAX_IMAGE_RESULTS,
maxInputImages: 5,
supportsSize: true,
supportsAspectRatio: true,
@@ -231,7 +246,12 @@ export function buildGoogleImageGenerationProvider(): ImageGenerationProvider {
try {
await assertOkOrThrowHttpError(res, "Google image generation failed");
const payload = await res.json();
const payload = await readProviderJsonResponse(res, "google.image-generation", {
maxBytes: resolveInlineImageJsonResponseMaxBytes(
GOOGLE_MAX_IMAGE_RESULTS,
resolveGeneratedImageMaxBytes(req),
),
});
let imageIndex = 0;
const images: GeneratedImageAsset[] = [];
for (const part of googleResponseParts(payload)) {

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

@@ -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

@@ -33,15 +33,21 @@ vi.mock("openclaw/plugin-sdk/provider-auth-runtime", () => ({
resolveApiKeyForProvider: resolveApiKeyForProviderMock,
}));
vi.mock("openclaw/plugin-sdk/provider-http", () => ({
assertOkOrThrowHttpError: assertOkOrThrowHttpErrorMock,
createProviderOperationDeadline: createProviderOperationDeadlineMock,
postJsonRequest: postJsonRequestMock,
postMultipartRequest: postMultipartRequestMock,
resolveProviderHttpRequestConfig: resolveProviderHttpRequestConfigMock,
resolveProviderOperationTimeoutMs: resolveProviderOperationTimeoutMsMock,
sanitizeConfiguredModelProviderRequest: sanitizeConfiguredModelProviderRequestMock,
}));
vi.mock("openclaw/plugin-sdk/provider-http", async () => {
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/provider-http")>(
"openclaw/plugin-sdk/provider-http",
);
return {
assertOkOrThrowHttpError: assertOkOrThrowHttpErrorMock,
createProviderOperationDeadline: createProviderOperationDeadlineMock,
postJsonRequest: postJsonRequestMock,
postMultipartRequest: postMultipartRequestMock,
readProviderJsonResponse: actual.readProviderJsonResponse,
resolveProviderHttpRequestConfig: resolveProviderHttpRequestConfigMock,
resolveProviderOperationTimeoutMs: resolveProviderOperationTimeoutMsMock,
sanitizeConfiguredModelProviderRequest: sanitizeConfiguredModelProviderRequestMock,
};
});
afterAll(() => {
vi.doUnmock("openclaw/plugin-sdk/provider-auth-runtime");
@@ -49,13 +55,18 @@ afterAll(() => {
vi.resetModules();
});
function jsonResponse(payload: unknown): Response {
return new Response(JSON.stringify(payload), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
function mockGeneratedPngResponse() {
postJsonRequestMock.mockResolvedValue({
response: {
json: async () => ({
data: [{ b64_json: Buffer.from("png-bytes").toString("base64") }],
}),
},
response: jsonResponse({
data: [{ b64_json: Buffer.from("png-bytes").toString("base64") }],
}),
release: vi.fn(async () => {}),
});
}

View File

@@ -23,6 +23,23 @@ describe("matrix thread context", () => {
).toBe("Thread starter body");
});
it("truncates long thread starter bodies on code-point boundaries", () => {
const summary = summarizeMatrixThreadStarterEvent({
event_id: "$root",
sender: "@alice:example.org",
type: "m.room.message",
origin_server_ts: Date.now(),
content: {
msgtype: "m.text",
// 496 "a" + astral emoji (surrogate pair at units 496-497) + tail.
// A raw slice(0, 497) would cut the pair and leave a lone high surrogate.
body: `${"a".repeat(496)}\u{1F600}bcd`,
},
} as MatrixRawEvent);
expect(summary).toBe(`${"a".repeat(496)}...`);
expect(summary && /[\uD800-\uDFFF]/.test(summary)).toBe(false);
});
it("marks media-only thread starter events instead of returning bare filenames", () => {
expect(
summarizeMatrixThreadStarterEvent({

View File

@@ -1,4 +1,5 @@
// Matrix plugin module implements thread context behavior.
import { sliceUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
import type { MatrixClient } from "../sdk.js";
import { summarizeMatrixMessageContextEvent, trimMatrixMaybeString } from "./context-summary.js";
import type { MatrixRawEvent } from "./types.js";
@@ -17,7 +18,7 @@ function truncateThreadStarterBody(value: string): string {
if (value.length <= MAX_THREAD_STARTER_BODY_LENGTH) {
return value;
}
return `${value.slice(0, MAX_THREAD_STARTER_BODY_LENGTH - 3)}...`;
return `${sliceUtf16Safe(value, 0, MAX_THREAD_STARTER_BODY_LENGTH - 3)}...`;
}
export function summarizeMatrixThreadStarterEvent(event: MatrixRawEvent): string | undefined {

View File

@@ -2342,7 +2342,7 @@ describe("memory cli", () => {
lastRecalledAt: "<now>",
queryHashes: ["<hash>"],
recallDays: ["<today>"],
conceptTags: ["backup", "backups", "glacier"],
conceptTags: ["backup", "backups", "glacier", "s3"],
});
expect(close).toHaveBeenCalled();
});

View File

@@ -35,6 +35,19 @@ const NARRATIVE_SESSION_LOCKS_KEY = Symbol.for(
"openclaw.memoryCore.dreamingNarrative.sessionLocks",
);
const EXPECTS_POSIX_PRIVATE_FILE_MODE = process.platform !== "win32";
const originalNarrativeStateDir = process.env.OPENCLAW_STATE_DIR;
function setNarrativeTestEnv(stateDir: string): void {
Reflect.set(process.env, "OPENCLAW_STATE_DIR", stateDir);
}
function restoreNarrativeTestEnv(): void {
if (originalNarrativeStateDir === undefined) {
Reflect.deleteProperty(process.env, "OPENCLAW_STATE_DIR");
} else {
Reflect.set(process.env, "OPENCLAW_STATE_DIR", originalNarrativeStateDir);
}
}
type MockCallSource = { mock: { calls: Array<Array<unknown>> } };
@@ -89,7 +102,7 @@ async function expectPathMissing(targetPath: string): Promise<void> {
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllEnvs();
restoreNarrativeTestEnv();
resolveGlobalMap<string, unknown>(DREAMS_FILE_LOCKS_KEY).clear();
resolveGlobalMap<string, unknown>(NARRATIVE_SESSION_LOCKS_KEY).clear();
});
@@ -1228,7 +1241,7 @@ describe("generateAndAppendDreamNarrative", () => {
vi.spyOn(runtimeConfigSnapshotModule, "getRuntimeConfig").mockReturnValue({
session: {},
} as never);
vi.stubEnv("OPENCLAW_STATE_DIR", stateDir);
setNarrativeTestEnv(stateDir);
vi.spyOn(memoryCoreHostRuntimeCoreModule, "resolveStateDir").mockReturnValue(stateDir);
const subagent = createMockSubagent("The repository whispered of forgotten endpoints.");
@@ -1297,7 +1310,7 @@ describe("generateAndAppendDreamNarrative", () => {
vi.spyOn(runtimeConfigSnapshotModule, "getRuntimeConfig").mockReturnValue({
session: {},
} as never);
vi.stubEnv("OPENCLAW_STATE_DIR", stateDir);
setNarrativeTestEnv(stateDir);
vi.spyOn(memoryCoreHostRuntimeCoreModule, "resolveStateDir").mockReturnValue(stateDir);
const subagent = createMockSubagent("A forgotten endpoint hummed in the dark.");

View File

@@ -11,7 +11,7 @@ import {
resolveMemoryRemDreamingConfig,
} from "openclaw/plugin-sdk/memory-core-host-status";
import { saveSessionStore } from "openclaw/plugin-sdk/session-store-runtime";
import { describe, expect, it, vi } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
testing,
filterRecallEntriesWithinLookback,
@@ -30,6 +30,8 @@ import { createMemoryCoreTestHarness } from "./test-helpers.js";
const { createTempWorkspace } = createMemoryCoreTestHarness();
const DREAMING_TEST_BASE_TIME = new Date("2026-04-05T10:00:00.000Z");
const DREAMING_TEST_DAY = "2026-04-05";
const originalDreamingTestFast = process.env.OPENCLAW_TEST_FAST;
const originalDreamingStateDir = process.env.OPENCLAW_STATE_DIR;
const EMPTY_SESSION_CONTENT_HASH =
"75a11da44c802486bc6f65640aa48a730f0f684c5c07a42ba3cd1735eb3fb070";
const LIGHT_DREAMING_TEST_CONFIG: OpenClawConfig = {
@@ -59,6 +61,28 @@ const LIGHT_DREAMING_TEST_CONFIG: OpenClawConfig = {
},
};
function setDreamingTestEnv(stateDir: string): void {
Reflect.set(process.env, "OPENCLAW_TEST_FAST", "1");
Reflect.set(process.env, "OPENCLAW_STATE_DIR", stateDir);
}
function restoreDreamingTestEnv(): void {
if (originalDreamingTestFast === undefined) {
Reflect.deleteProperty(process.env, "OPENCLAW_TEST_FAST");
} else {
Reflect.set(process.env, "OPENCLAW_TEST_FAST", originalDreamingTestFast);
}
if (originalDreamingStateDir === undefined) {
Reflect.deleteProperty(process.env, "OPENCLAW_STATE_DIR");
} else {
Reflect.set(process.env, "OPENCLAW_STATE_DIR", originalDreamingStateDir);
}
}
afterEach(() => {
restoreDreamingTestEnv();
});
function requireCandidateByKey<T extends { key: string }>(candidates: T[], key: string): T {
const candidate = candidates.find((entry) => entry.key === key);
if (!candidate) {
@@ -947,8 +971,7 @@ describe("memory-core dreaming phases", () => {
it("checkpoints session transcript ingestion and skips unchanged transcripts", async () => {
const workspaceDir = await createDreamingWorkspace();
vi.stubEnv("OPENCLAW_TEST_FAST", "1");
vi.stubEnv("OPENCLAW_STATE_DIR", path.join(workspaceDir, ".state"));
setDreamingTestEnv(path.join(workspaceDir, ".state"));
const sessionsDir = resolveSessionTranscriptsDirForAgent("main");
await fs.mkdir(sessionsDir, { recursive: true });
const transcriptPath = path.join(sessionsDir, "dreaming-main.jsonl");
@@ -1022,7 +1045,7 @@ describe("memory-core dreaming phases", () => {
([target]) => typeof target === "string" && target === transcriptPath,
).length;
readSpy.mockRestore();
vi.unstubAllEnvs();
restoreDreamingTestEnv();
}
expect(transcriptReadCount).toBeLessThanOrEqual(1);
@@ -1051,8 +1074,7 @@ describe("memory-core dreaming phases", () => {
it("keeps primary session transcripts out of configured subagent workspaces", async () => {
const workspaceDir = await createDreamingWorkspace();
const subagentWorkspaceDir = await createDreamingWorkspace();
vi.stubEnv("OPENCLAW_TEST_FAST", "1");
vi.stubEnv("OPENCLAW_STATE_DIR", path.join(workspaceDir, ".state"));
setDreamingTestEnv(path.join(workspaceDir, ".state"));
const mainSessionsDir = resolveSessionTranscriptsDirForAgent("main");
const subagentSessionsDir = resolveSessionTranscriptsDirForAgent("agi-ceo");
@@ -1122,7 +1144,7 @@ describe("memory-core dreaming phases", () => {
await triggerLightDreaming(beforeAgentReply, workspaceDir, 5);
});
} finally {
vi.unstubAllEnvs();
restoreDreamingTestEnv();
}
const mainCorpus = await fs.readFile(
@@ -1141,8 +1163,7 @@ describe("memory-core dreaming phases", () => {
it("redacts sensitive session content before writing session corpus", async () => {
const workspaceDir = await createDreamingWorkspace();
vi.stubEnv("OPENCLAW_TEST_FAST", "1");
vi.stubEnv("OPENCLAW_STATE_DIR", path.join(workspaceDir, ".state"));
setDreamingTestEnv(path.join(workspaceDir, ".state"));
const sessionsDir = resolveSessionTranscriptsDirForAgent("main");
await fs.mkdir(sessionsDir, { recursive: true });
const transcriptPath = path.join(sessionsDir, "dreaming-main.jsonl");
@@ -1198,7 +1219,7 @@ describe("memory-core dreaming phases", () => {
await triggerLightDreaming(beforeAgentReply, workspaceDir, 5);
});
} finally {
vi.unstubAllEnvs();
restoreDreamingTestEnv();
}
const corpusPath = path.join(
@@ -1215,8 +1236,7 @@ describe("memory-core dreaming phases", () => {
it("skips dreaming-generated narrative transcripts during session ingestion", async () => {
const workspaceDir = await createDreamingWorkspace();
vi.stubEnv("OPENCLAW_TEST_FAST", "1");
vi.stubEnv("OPENCLAW_STATE_DIR", path.join(workspaceDir, ".state"));
setDreamingTestEnv(path.join(workspaceDir, ".state"));
const sessionsDir = resolveSessionTranscriptsDirForAgent("main");
await fs.mkdir(sessionsDir, { recursive: true });
const transcriptPath = path.join(sessionsDir, "dreaming-narrative.jsonl");
@@ -1291,7 +1311,7 @@ describe("memory-core dreaming phases", () => {
{ trigger: "heartbeat", workspaceDir },
);
} finally {
vi.unstubAllEnvs();
restoreDreamingTestEnv();
}
await expectPathMissing(
@@ -1308,8 +1328,7 @@ describe("memory-core dreaming phases", () => {
it("skips dreaming transcripts when the session store identifies them before bootstrap lands", async () => {
const workspaceDir = await createDreamingWorkspace();
vi.stubEnv("OPENCLAW_TEST_FAST", "1");
vi.stubEnv("OPENCLAW_STATE_DIR", path.join(workspaceDir, ".state"));
setDreamingTestEnv(path.join(workspaceDir, ".state"));
const sessionsDir = resolveSessionTranscriptsDirForAgent("main");
await fs.mkdir(sessionsDir, { recursive: true });
const transcriptPath = path.join(sessionsDir, "dreaming-narrative.jsonl");
@@ -1387,7 +1406,7 @@ describe("memory-core dreaming phases", () => {
{ trigger: "heartbeat", workspaceDir },
);
} finally {
vi.unstubAllEnvs();
restoreDreamingTestEnv();
}
await expectPathMissing(
@@ -1404,8 +1423,7 @@ describe("memory-core dreaming phases", () => {
it("skips isolated cron run transcripts during session ingestion", async () => {
const workspaceDir = await createDreamingWorkspace();
vi.stubEnv("OPENCLAW_TEST_FAST", "1");
vi.stubEnv("OPENCLAW_STATE_DIR", path.join(workspaceDir, ".state"));
setDreamingTestEnv(path.join(workspaceDir, ".state"));
const sessionsDir = resolveSessionTranscriptsDirForAgent("main");
await fs.mkdir(sessionsDir, { recursive: true });
const transcriptPath = path.join(sessionsDir, "cron-run.jsonl");
@@ -1480,7 +1498,7 @@ describe("memory-core dreaming phases", () => {
{ trigger: "heartbeat", workspaceDir },
);
} finally {
vi.unstubAllEnvs();
restoreDreamingTestEnv();
}
await expectPathMissing(
@@ -1496,8 +1514,7 @@ describe("memory-core dreaming phases", () => {
it("drops generated system wrapper text without suppressing paired assistant replies", async () => {
const workspaceDir = await createDreamingWorkspace();
vi.stubEnv("OPENCLAW_TEST_FAST", "1");
vi.stubEnv("OPENCLAW_STATE_DIR", path.join(workspaceDir, ".state"));
setDreamingTestEnv(path.join(workspaceDir, ".state"));
const sessionsDir = resolveSessionTranscriptsDirForAgent("main");
await fs.mkdir(sessionsDir, { recursive: true });
const transcriptPath = path.join(sessionsDir, "ordinary-session.jsonl");
@@ -1580,7 +1597,7 @@ describe("memory-core dreaming phases", () => {
);
} finally {
vi.useRealTimers();
vi.unstubAllEnvs();
restoreDreamingTestEnv();
}
const corpus = await fs.readFile(
@@ -1595,8 +1612,7 @@ describe("memory-core dreaming phases", () => {
it("drops archive, cron, and heartbeat chatter from fresh session corpus output", async () => {
const workspaceDir = await createDreamingWorkspace();
vi.stubEnv("OPENCLAW_TEST_FAST", "1");
vi.stubEnv("OPENCLAW_STATE_DIR", path.join(workspaceDir, ".state"));
setDreamingTestEnv(path.join(workspaceDir, ".state"));
const sessionsDir = resolveSessionTranscriptsDirForAgent("main");
await fs.mkdir(sessionsDir, { recursive: true });
@@ -1729,7 +1745,7 @@ describe("memory-core dreaming phases", () => {
);
} finally {
vi.useRealTimers();
vi.unstubAllEnvs();
restoreDreamingTestEnv();
}
const corpus = await fs.readFile(
@@ -1781,8 +1797,7 @@ describe("memory-core dreaming phases", () => {
it("does not reread unchanged dreaming-generated transcripts after checkpointing skip state", async () => {
const workspaceDir = await createDreamingWorkspace();
vi.stubEnv("OPENCLAW_TEST_FAST", "1");
vi.stubEnv("OPENCLAW_STATE_DIR", path.join(workspaceDir, ".state"));
setDreamingTestEnv(path.join(workspaceDir, ".state"));
const sessionsDir = resolveSessionTranscriptsDirForAgent("main");
await fs.mkdir(sessionsDir, { recursive: true });
const transcriptPath = path.join(sessionsDir, "dreaming-narrative.jsonl");
@@ -1859,14 +1874,13 @@ describe("memory-core dreaming phases", () => {
readFileSpy.mockRestore();
} finally {
vi.restoreAllMocks();
vi.unstubAllEnvs();
restoreDreamingTestEnv();
}
});
it("dedupes reset/deleted session archives instead of double-ingesting", async () => {
const workspaceDir = await createDreamingWorkspace();
vi.stubEnv("OPENCLAW_TEST_FAST", "1");
vi.stubEnv("OPENCLAW_STATE_DIR", path.join(workspaceDir, ".state"));
setDreamingTestEnv(path.join(workspaceDir, ".state"));
const sessionsDir = resolveSessionTranscriptsDirForAgent("main");
await fs.mkdir(sessionsDir, { recursive: true });
const transcriptPath = path.join(sessionsDir, "dreaming-main.jsonl");
@@ -1958,7 +1972,7 @@ describe("memory-core dreaming phases", () => {
await triggerLightDreaming(beforeAgentReply, workspaceDir, 910);
});
} finally {
vi.unstubAllEnvs();
restoreDreamingTestEnv();
}
const ranked = await rankShortTermPromotionCandidates({
@@ -1989,8 +2003,7 @@ describe("memory-core dreaming phases", () => {
it("skips reset/deleted archive artifacts without active transcripts during session ingestion", async () => {
const workspaceDir = await createDreamingWorkspace();
vi.stubEnv("OPENCLAW_TEST_FAST", "1");
vi.stubEnv("OPENCLAW_STATE_DIR", path.join(workspaceDir, ".state"));
setDreamingTestEnv(path.join(workspaceDir, ".state"));
const sessionsDir = resolveSessionTranscriptsDirForAgent("main");
await fs.mkdir(sessionsDir, { recursive: true });
const archivePath = path.join(
@@ -2048,7 +2061,7 @@ describe("memory-core dreaming phases", () => {
await triggerLightDreaming(beforeAgentReply, workspaceDir, 5);
});
} finally {
vi.unstubAllEnvs();
restoreDreamingTestEnv();
}
await expectPathMissing(
@@ -2061,8 +2074,7 @@ describe("memory-core dreaming phases", () => {
it("buckets session snippets by per-message day rather than file mtime", async () => {
const workspaceDir = await createDreamingWorkspace();
vi.stubEnv("OPENCLAW_TEST_FAST", "1");
vi.stubEnv("OPENCLAW_STATE_DIR", path.join(workspaceDir, ".state"));
setDreamingTestEnv(path.join(workspaceDir, ".state"));
const sessionsDir = resolveSessionTranscriptsDirForAgent("main");
await fs.mkdir(sessionsDir, { recursive: true });
const transcriptPath = path.join(sessionsDir, "dreaming-main.jsonl");
@@ -2127,7 +2139,7 @@ describe("memory-core dreaming phases", () => {
await triggerLightDreaming(beforeAgentReply, workspaceDir, 5);
});
} finally {
vi.unstubAllEnvs();
restoreDreamingTestEnv();
}
const corpusDir = path.join(workspaceDir, "memory", ".dreams", "session-corpus");
@@ -2142,8 +2154,7 @@ describe("memory-core dreaming phases", () => {
it("drains >80 unseen transcript messages across multiple unchanged sweeps", async () => {
const workspaceDir = await createDreamingWorkspace();
vi.stubEnv("OPENCLAW_TEST_FAST", "1");
vi.stubEnv("OPENCLAW_STATE_DIR", path.join(workspaceDir, ".state"));
setDreamingTestEnv(path.join(workspaceDir, ".state"));
const sessionsDir = resolveSessionTranscriptsDirForAgent("main");
await fs.mkdir(sessionsDir, { recursive: true });
const transcriptPath = path.join(sessionsDir, "dreaming-main.jsonl");
@@ -2200,7 +2211,7 @@ describe("memory-core dreaming phases", () => {
await triggerLightDreaming(beforeAgentReply, workspaceDir, 7);
});
} finally {
vi.unstubAllEnvs();
restoreDreamingTestEnv();
}
const corpusPath = path.join(
@@ -2222,8 +2233,7 @@ describe("memory-core dreaming phases", () => {
it("re-ingests rewritten session transcripts after truncate/reset", async () => {
const workspaceDir = await createDreamingWorkspace();
vi.stubEnv("OPENCLAW_TEST_FAST", "1");
vi.stubEnv("OPENCLAW_STATE_DIR", path.join(workspaceDir, ".state"));
setDreamingTestEnv(path.join(workspaceDir, ".state"));
const sessionsDir = resolveSessionTranscriptsDirForAgent("main");
await fs.mkdir(sessionsDir, { recursive: true });
const transcriptPath = path.join(sessionsDir, "dreaming-main.jsonl");
@@ -2300,7 +2310,7 @@ describe("memory-core dreaming phases", () => {
await triggerLightDreaming(beforeAgentReply, workspaceDir, 910);
});
} finally {
vi.unstubAllEnvs();
restoreDreamingTestEnv();
}
const ranked = await rankShortTermPromotionCandidates({
@@ -2317,8 +2327,7 @@ describe("memory-core dreaming phases", () => {
it("ingests sessions when dreaming is enabled even if memorySearch is disabled", async () => {
const workspaceDir = await createDreamingWorkspace();
vi.stubEnv("OPENCLAW_TEST_FAST", "1");
vi.stubEnv("OPENCLAW_STATE_DIR", path.join(workspaceDir, ".state"));
setDreamingTestEnv(path.join(workspaceDir, ".state"));
const sessionsDir = resolveSessionTranscriptsDirForAgent("main");
await fs.mkdir(sessionsDir, { recursive: true });
const transcriptPath = path.join(sessionsDir, "dreaming-main.jsonl");
@@ -2376,7 +2385,7 @@ describe("memory-core dreaming phases", () => {
await triggerLightDreaming(beforeAgentReply, workspaceDir, 5);
});
} finally {
vi.unstubAllEnvs();
restoreDreamingTestEnv();
}
const ranked = await rankShortTermPromotionCandidates({

View File

@@ -43,6 +43,7 @@ let providerCloseGate: Promise<void> | null = null;
let providerInitGate: Promise<void> | null = null;
let providerCalls: Array<{ provider?: string; model?: string; outputDimensionality?: number }> = [];
let forceNoProvider = false;
const originalMemoryIndexStateDir = process.env.OPENCLAW_STATE_DIR;
const identityAliasFixture = vi.hoisted(() => ({
provider: "identity-alias-test",
@@ -58,6 +59,18 @@ function createLocalWorkerExitError(): Error {
});
}
function setMemoryIndexStateDir(stateDir: string): void {
Reflect.set(process.env, "OPENCLAW_STATE_DIR", stateDir);
}
function restoreMemoryIndexStateDir(): void {
if (originalMemoryIndexStateDir === undefined) {
Reflect.deleteProperty(process.env, "OPENCLAW_STATE_DIR");
} else {
Reflect.set(process.env, "OPENCLAW_STATE_DIR", originalMemoryIndexStateDir);
}
}
vi.mock("./embeddings.js", () => {
const embedText = (text: string) => {
const lower = text.toLowerCase();
@@ -276,7 +289,7 @@ describe("memory index", () => {
closeOpenClawStateDatabaseForTest();
clearRegistry();
managersForCleanup.clear();
vi.unstubAllEnvs();
restoreMemoryIndexStateDir();
});
beforeEach(async () => {
@@ -298,7 +311,7 @@ describe("memory index", () => {
rmSync(workspaceDir, { recursive: true, force: true });
mkdirSync(memoryDir, { recursive: true });
vi.stubEnv("OPENCLAW_STATE_DIR", path.join(workspaceDir, ".state-memory-index"));
setMemoryIndexStateDir(path.join(workspaceDir, ".state-memory-index"));
await fs.writeFile(
path.join(memoryDir, "2026-01-12.md"),
"# Log\nAlpha memory line.\nZebra memory line.",
@@ -488,7 +501,7 @@ describe("memory index", () => {
stateDirName: string;
}): Promise<MemoryIndexManager | null> {
forceNoProvider = true;
vi.stubEnv("OPENCLAW_STATE_DIR", path.join(workspaceDir, params.stateDirName));
setMemoryIndexStateDir(path.join(workspaceDir, params.stateDirName));
const cfg = createCfg({
sources: ["memory", "sessions"],
sessionMemory: true,
@@ -573,7 +586,7 @@ describe("memory index", () => {
it("reindexes memory tables in place without deleting unrelated agent rows", async () => {
const stateDir = path.join(workspaceDir, "managed-memory-state");
vi.stubEnv("OPENCLAW_STATE_DIR", stateDir);
setMemoryIndexStateDir(stateDir);
const agentDbPath = resolveOpenClawAgentSqlitePath({ agentId: "main" });
const agentDb = openOpenClawAgentDatabase({ agentId: "main" });
agentDb.db
@@ -1117,7 +1130,7 @@ describe("memory index", () => {
it("clears dirty after sessions-only identity reindex", async () => {
try {
vi.stubEnv("OPENCLAW_STATE_DIR", path.join(workspaceDir, ".state-sessions-only-reindex"));
setMemoryIndexStateDir(path.join(workspaceDir, ".state-sessions-only-reindex"));
const sessionsDir = resolveSessionTranscriptsDirForAgent("main");
await fs.mkdir(sessionsDir, { recursive: true });
await fs.writeFile(
@@ -1167,13 +1180,13 @@ describe("memory index", () => {
await nextManager.close?.();
}
} finally {
vi.unstubAllEnvs();
restoreMemoryIndexStateDir();
}
});
it("marks sessions-only indexes dirty when metadata is missing but chunks exist", async () => {
try {
vi.stubEnv("OPENCLAW_STATE_DIR", path.join(workspaceDir, ".state-sessions-missing-meta"));
setMemoryIndexStateDir(path.join(workspaceDir, ".state-sessions-missing-meta"));
const sessionsDir = resolveSessionTranscriptsDirForAgent("main");
await fs.mkdir(sessionsDir, { recursive: true });
await fs.writeFile(
@@ -1223,13 +1236,13 @@ describe("memory index", () => {
await nextManager.close?.();
}
} finally {
vi.unstubAllEnvs();
restoreMemoryIndexStateDir();
}
});
it("keeps provider cutover vector search paused during targeted session sync", async () => {
try {
vi.stubEnv("OPENCLAW_STATE_DIR", path.join(workspaceDir, ".state-targeted-cutover"));
setMemoryIndexStateDir(path.join(workspaceDir, ".state-targeted-cutover"));
const sessionsDir = resolveSessionTranscriptsDirForAgent("main");
await fs.mkdir(sessionsDir, { recursive: true });
const sessionFile = path.join(sessionsDir, "session-targeted-cutover.jsonl");
@@ -1287,13 +1300,13 @@ describe("memory index", () => {
await nextManager.close?.();
}
} finally {
vi.unstubAllEnvs();
restoreMemoryIndexStateDir();
}
});
it("preserves memory dirty events raised during session identity reindex", async () => {
try {
vi.stubEnv("OPENCLAW_STATE_DIR", path.join(workspaceDir, ".state-dirty-during-session"));
setMemoryIndexStateDir(path.join(workspaceDir, ".state-dirty-during-session"));
const sessionsDir = resolveSessionTranscriptsDirForAgent("main");
await fs.mkdir(sessionsDir, { recursive: true });
await fs.writeFile(
@@ -1351,7 +1364,7 @@ describe("memory index", () => {
await nextManager.close?.();
}
} finally {
vi.unstubAllEnvs();
restoreMemoryIndexStateDir();
}
});
@@ -2230,7 +2243,7 @@ describe("memory index", () => {
expect(results[0]?.source).toBe("sessions");
expect(results[0]?.snippet).toContain("ORBIT-10");
} finally {
vi.unstubAllEnvs();
restoreMemoryIndexStateDir();
}
});
@@ -2274,7 +2287,7 @@ describe("memory index", () => {
expect(results[0]?.source).toBe("sessions");
expect(results[0]?.snippet).toContain("ORBIT-10");
} finally {
vi.unstubAllEnvs();
restoreMemoryIndexStateDir();
}
});
});

View File

@@ -56,9 +56,32 @@ type MemoryTranscriptUpdateSubscriber = (
const MEMORY_CORE_TRANSCRIPT_UPDATE_SUBSCRIBER_KEY = Symbol.for(
"openclaw.memoryCore.sessionTranscriptUpdateSubscriber",
);
const originalStartupStateDir = process.env.OPENCLAW_STATE_DIR;
const originalStartupConfigPath = process.env.OPENCLAW_CONFIG_PATH;
type SourceStateRow = { path: string; hash: string; mtime: number; size: number };
function setStartupStateDir(stateDir: string): void {
Reflect.set(process.env, "OPENCLAW_STATE_DIR", stateDir);
}
function setStartupConfigPath(configPath: string): void {
Reflect.set(process.env, "OPENCLAW_CONFIG_PATH", configPath);
}
function restoreStartupEnv(): void {
if (originalStartupStateDir === undefined) {
Reflect.deleteProperty(process.env, "OPENCLAW_STATE_DIR");
} else {
Reflect.set(process.env, "OPENCLAW_STATE_DIR", originalStartupStateDir);
}
if (originalStartupConfigPath === undefined) {
Reflect.deleteProperty(process.env, "OPENCLAW_CONFIG_PATH");
} else {
Reflect.set(process.env, "OPENCLAW_CONFIG_PATH", originalStartupConfigPath);
}
}
class SessionStartupCatchupHarness extends MemoryManagerSyncOps {
protected readonly cfg = {} as OpenClawConfig;
protected readonly agentId = "main";
@@ -230,13 +253,13 @@ describe("session startup catch-up", () => {
beforeEach(async () => {
stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-session-startup-"));
vi.stubEnv("OPENCLAW_STATE_DIR", stateDir);
setStartupStateDir(stateDir);
});
afterEach(async () => {
vi.clearAllTimers();
vi.useRealTimers();
vi.unstubAllEnvs();
restoreStartupEnv();
clearRuntimeConfigSnapshot();
clearConfigCache();
await fs.rm(stateDir, { recursive: true, force: true });
@@ -458,7 +481,7 @@ describe("session startup catch-up", () => {
"utf-8",
);
await fs.writeFile(configPath, JSON.stringify({ session: { store: storePath } }), "utf-8");
vi.stubEnv("OPENCLAW_CONFIG_PATH", configPath);
setStartupConfigPath(configPath);
clearRuntimeConfigSnapshot();
clearConfigCache();
const harness = new SessionStartupCatchupHarness([]);
@@ -505,7 +528,7 @@ describe("session startup catch-up", () => {
"utf-8",
);
await fs.writeFile(configPath, JSON.stringify({ session: { store: storePath } }), "utf-8");
vi.stubEnv("OPENCLAW_CONFIG_PATH", configPath);
setStartupConfigPath(configPath);
clearRuntimeConfigSnapshot();
clearConfigCache();
const harness = new SessionStartupCatchupHarness([]);
@@ -553,7 +576,7 @@ describe("session startup catch-up", () => {
"utf-8",
);
await fs.writeFile(configPath, JSON.stringify({ session: { store: storePath } }), "utf-8");
vi.stubEnv("OPENCLAW_CONFIG_PATH", configPath);
setStartupConfigPath(configPath);
clearRuntimeConfigSnapshot();
clearConfigCache();
const harness = new SessionStartupCatchupHarness([]);
@@ -660,7 +683,7 @@ describe("session startup catch-up", () => {
"utf-8",
);
await fs.writeFile(configPath, JSON.stringify({ session: { store: storePath } }), "utf-8");
vi.stubEnv("OPENCLAW_CONFIG_PATH", configPath);
setStartupConfigPath(configPath);
clearRuntimeConfigSnapshot();
clearConfigCache();
const harness = new SessionStartupCatchupHarness([]);

View File

@@ -13,6 +13,23 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const { buildSessionEntryMock } = vi.hoisted(() => ({
buildSessionEntryMock: vi.fn(),
}));
const originalSyncYieldStateDir = process.env.OPENCLAW_STATE_DIR;
function setSyncYieldStateDir(): void {
Reflect.set(
process.env,
"OPENCLAW_STATE_DIR",
path.join(os.tmpdir(), "openclaw-session-sync-yield"),
);
}
function restoreSyncYieldStateDir(): void {
if (originalSyncYieldStateDir === undefined) {
Reflect.deleteProperty(process.env, "OPENCLAW_STATE_DIR");
} else {
Reflect.set(process.env, "OPENCLAW_STATE_DIR", originalSyncYieldStateDir);
}
}
vi.mock("undici", async () => {
const actual = await vi.importActual<typeof import("undici")>("undici");
@@ -162,7 +179,7 @@ class SessionSyncYieldHarness extends MemoryManagerSyncOps {
describe("session sync responsiveness", () => {
beforeEach(() => {
vi.stubEnv("OPENCLAW_STATE_DIR", path.join(os.tmpdir(), "openclaw-session-sync-yield"));
setSyncYieldStateDir();
buildSessionEntryMock.mockImplementation(async (absPath: string) => {
const name = path.basename(absPath);
return {
@@ -177,7 +194,7 @@ describe("session sync responsiveness", () => {
});
afterEach(() => {
vi.unstubAllEnvs();
restoreSyncYieldStateDir();
vi.clearAllMocks();
});

View File

@@ -18,6 +18,19 @@ const createEmbeddingProviderMock = vi.hoisted(() =>
providerUnavailableReason: "No embeddings provider available.",
})),
);
const originalFtsOnlyStateDir = process.env.OPENCLAW_STATE_DIR;
function setFtsOnlyStateDir(stateDir: string): void {
Reflect.set(process.env, "OPENCLAW_STATE_DIR", stateDir);
}
function restoreFtsOnlyStateDir(): void {
if (originalFtsOnlyStateDir === undefined) {
Reflect.deleteProperty(process.env, "OPENCLAW_STATE_DIR");
} else {
Reflect.set(process.env, "OPENCLAW_STATE_DIR", originalFtsOnlyStateDir);
}
}
vi.mock("./embeddings.js", () => ({
createEmbeddingProvider: createEmbeddingProviderMock,
@@ -44,7 +57,7 @@ describe("memory manager FTS-only reindex", () => {
workspaceDir = path.join(fixtureRoot, `case-${caseId++}`);
await fs.mkdir(path.join(workspaceDir, "memory"), { recursive: true });
await fs.writeFile(path.join(workspaceDir, "MEMORY.md"), "Alpha topic\n\nKeep this note.");
vi.stubEnv("OPENCLAW_STATE_DIR", path.join(workspaceDir, "state"));
setFtsOnlyStateDir(path.join(workspaceDir, "state"));
indexPath = resolveOpenClawAgentSqlitePath({ agentId: "main" });
});
@@ -54,7 +67,7 @@ describe("memory manager FTS-only reindex", () => {
manager = null;
}
await closeAllMemorySearchManagers();
vi.unstubAllEnvs();
restoreFtsOnlyStateDir();
});
afterAll(async () => {

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