Compare commits

...

184 Commits

Author SHA1 Message Date
Peter Steinberger
55791151bf chore(release): bump beta 7 versions 2026-05-14 09:58:49 +01:00
Peter Steinberger
09d3b643a1 fix: carry transcript update sequence 2026-05-14 09:58:49 +01:00
samzong
cd0d3a1d84 fix(gateway): carry transcript update sequence
Signed-off-by: samzong <samzong.lu@gmail.com>
2026-05-14 09:58:49 +01:00
Peter Steinberger
f158cc77db chore(release): refresh generated baselines 2026-05-14 09:58:49 +01:00
Peter Steinberger
50969b50a3 test: align release check with external slack package 2026-05-14 09:58:49 +01:00
Peter Steinberger
0958ee1e94 fix(ci): restore main build 2026-05-14 09:58:49 +01:00
Peter Steinberger
177ca55589 build: keep external provider deps out of core dist 2026-05-14 09:58:49 +01:00
Vincent Koc
6d421be4f1 fix(slack): ignore malformed media redirects 2026-05-14 09:58:49 +01:00
Josh Avant
dfc5746b53 fix(gateway): suppress startup liveness warnings (#81699)
* fix(gateway): suppress startup liveness warnings

* docs(changelog): note diagnostic startup grace fix
2026-05-14 09:58:49 +01:00
Vincent Koc
3729ea5769 fix(process): normalize Windows child env keys 2026-05-14 09:58:49 +01:00
Peter Steinberger
c2ae792c32 test(wizard): include openshell optional plugin 2026-05-14 09:58:49 +01:00
Peter Steinberger
fab7ad8dc9 fix(build): keep slack dependencies extension-owned 2026-05-14 09:58:49 +01:00
Peter Steinberger
bb70396682 refactor(auth): use fs-safe stale lock recovery 2026-05-14 09:58:49 +01:00
Peter Steinberger
fe3460c895 fix(auth): reclaim zombie-owned stale locks 2026-05-14 09:58:49 +01:00
Peter Steinberger
3f156b30da fix(auth): preserve non-signalable lock owners 2026-05-14 09:58:49 +01:00
Peter Steinberger
885bdb8a4b fix(auth): fail closed on unreadable stale locks 2026-05-14 09:58:49 +01:00
Peter Steinberger
f2182ac3ba refactor(infra): centralize stale lock cleanup 2026-05-14 09:58:49 +01:00
Peter Steinberger
a79e4d81f3 fix(auth): reclaim stale file locks 2026-05-14 09:58:49 +01:00
Mariano Belinky
03d9f84d84 fix: reference codex watchdog changelog entry 2026-05-14 09:58:48 +01:00
Mariano Belinky
64b9eaacdb fix(codex): keep post-tool watchdog armed 2026-05-14 09:58:32 +01:00
Peter Steinberger
e07e2e6e16 test(release): align external plugin package checks 2026-05-14 09:57:59 +01:00
Peter Steinberger
79101b50ff build: externalize slack openshell vertex plugins 2026-05-14 09:57:59 +01:00
Peter Steinberger
89738fa9ef build(deps): consume fs-safe 0.2.3 2026-05-14 09:57:59 +01:00
Peter Steinberger
6e3b13e14c [codex] externalize amazon bedrock providers (#81687)
* build: externalize amazon bedrock providers

* build: skip external plugins in root dist graph

* test: update managed npm override expectation

* build: mark amazon providers external-only
2026-05-14 09:57:59 +01:00
Vincent Koc
4e0fd50012 fix(ci): skip locale refresh on invalid provider auth
(cherry picked from commit e064cc98f0)
2026-05-14 09:57:59 +01:00
Sarah Fortune
e0dfdb9184 fix(onboard): forward provider auth flags through wizard (#81669)
The wizard's applyAuthChoice call dropped provider-specific flag values
like --openai-api-key, only forwarding token/tokenProvider. As a result,
maybeApplyApiKeyFromOption could not honor the flag and onboarding still
prompted "Use existing OPENAI_API_KEY?" when the operator already
passed --openai-api-key alongside an existing env var (e.g. onboard-fast
harnesses that pre-seed --openai-api-key "$OPENAI_API_KEY").

Spread opts into the inner opts bag so provider-specific flag values
reach the provider auth method via ctx.opts. When no flag is passed the
env-confirm prompt still fires unchanged.

(cherry picked from commit 0a42afae3a)
2026-05-14 09:57:59 +01:00
pashpashpash
e91d682b22 fix(replies): preserve rich coalesced block replies (#81689)
(cherry picked from commit da1ccd3077)
2026-05-14 09:57:59 +01:00
Vincent Koc
c66a8bfa3b fix(ci): prefer valid locale refresh provider
(cherry picked from commit 3b7d01b63f)
2026-05-14 09:57:59 +01:00
Peter Steinberger
93ce22b632 fix(ci): restore control ui locale refresh
(cherry picked from commit 8f612787a8)

# Conflicts:
#	CHANGELOG.md
2026-05-14 09:57:59 +01:00
WhatsSkiLL
2f8db8ea03 fix(plugins): discover setup provider env vars (#81542)
Discover provider plugins from setup.providers[].envVars credentials during provider discovery while keeping the deprecated providerAuthEnvVars fallback.

Co-authored-by: JARVIS-Glasses <whatsskilll@gmail.com>
(cherry picked from commit eefa6ecea0)
2026-05-14 09:57:59 +01:00
Josh Lehman
59d7f03eaa fix(codex): preserve MCP servers in app-server harness (#81551)
* Plumb bundle MCP config into Codex app server

* fix: align codex mcp thread config with pi

* fix: rotate codex mcp threads when disabled

* fix: scope codex bundle mcp to bundled servers

* fix(codex): resend user MCP config on resume

---------

Co-authored-by: Josh Lehman <phaedrus@Mac.hsd1.ca.comcast.net>
(cherry picked from commit 1ee0d51e92)
2026-05-14 09:57:59 +01:00
Josh Lehman
833b9627b5 fix: route plugin LLM completions through Codex runtime (#81511)
* fix: route plugin LLM completions through Codex runtime

* fix: preserve OpenRouter completion model ids

* fix: allow registry config compat guards

(cherry picked from commit aac216d699)
2026-05-14 09:57:59 +01:00
Vincent Koc
f09d067a5e fix(update): allow update config size drops
(cherry picked from commit 3da9027770)
2026-05-14 09:57:59 +01:00
NVIDIAN
e714cacb87 fix: preserve pending subagent sessions during maintenance (#81498)
(cherry picked from commit 7c5222a195)
2026-05-14 09:57:59 +01:00
Jerry-Xin
9363bd2706 fix(update): preserve config during update repair
Preserve update-time config state by snapshotting before repair/restart writes, keeping plugin install records available for migration, and blocking unsafe update-time config size drops.

Also documents the Codex reserved SDK subpaths needed by the plugin contract guardrail.

Fixes #80077.

Thanks @Jerry-Xin and @vincentkoc.

Co-authored-by: Jerry-Xin <3401616+Jerry-Xin@users.noreply.github.com>
Co-authored-by: Vincent Koc <25068+vincentkoc@users.noreply.github.com>
(cherry picked from commit 61ae9b7193)
2026-05-14 09:57:59 +01:00
Jason
ddb205822e fix: use managed codex binary for source migration (#81582)
* fix: use managed codex binary for source migration

* docs: document codex reserved sdk subpaths

(cherry picked from commit 3fd64a281f)
2026-05-14 09:57:58 +01:00
Josh Avant
f9652c7b09 Fix Telegram polling ingress under event-loop stalls (#81746)
* fix telegram polling ingress under event-loop stalls

* add changelog for telegram ingress fix

(cherry picked from commit fd244fd76d)
2026-05-14 03:54:42 -05:00
Eduardo Buitrago
1d3eb9f1c7 fix(agents): forward explicit per-run timeout to LLM idle watchdog (#79426)
Merged via squash.

Prepared head SHA: 0e6cf9b4d5
Co-authored-by: legolaz8451 <18042830+legolaz8451@users.noreply.github.com>
Co-authored-by: joshavant <830519+joshavant@users.noreply.github.com>
Reviewed-by: @joshavant

(cherry picked from commit 336ba2a2b3)
2026-05-14 03:51:27 -05:00
Josh Avant
a18a8d5b72 fix(browser): request admin scope for CLI control (#81716)
* fix(browser): request admin scope for CLI control

* chore(changelog): note browser CLI scope fix

(cherry picked from commit 10d2f41c83)
2026-05-14 03:46:27 -05:00
Josh Avant
2b8ddd77b5 fix(gateway): suppress startup liveness warnings (#81699)
* fix(gateway): suppress startup liveness warnings

* docs(changelog): note diagnostic startup grace fix

(cherry picked from commit 4e1f59010e)
2026-05-14 03:42:16 -05:00
Vincent Koc
fbc8ed2da9 fix(plugin-sdk): classify memory core alias
(cherry picked from commit ce63b9ca46)
2026-05-14 14:47:11 +08:00
Vincent Koc
e15754c7a7 fix(plugin-sdk): restore memory core alias
(cherry picked from commit fe89243c3b)
2026-05-14 14:47:10 +08:00
Vincent Koc
f40326301a fix(plugins): prefer installed memory tool owners
(cherry picked from commit 25eef1203a)
2026-05-14 14:38:58 +08:00
Vincent Koc
91a8fdd079 fix(cli): keep plugin parent help lightweight 2026-05-14 14:12:01 +08:00
Vincent Koc
a03a6e3f13 fix(auth): backport Codex OAuth refresh spam fix
Backport Codex OAuth refresh spam handling and oauthRef-backed Codex OAuth runtime selection to release/2026.5.12.
2026-05-14 13:48:41 +08:00
Vincent Koc
0904505071 fix(cli): preserve multiline table colors 2026-05-14 13:38:39 +08:00
Vincent Koc
86885ccc24 fix(replies): preserve rich outbound content
# Conflicts:
#	CHANGELOG.md
2026-05-14 13:19:42 +08:00
Vincent Koc
015ec8baf2 test(matrix): guard externalized runtime deps 2026-05-14 12:38:39 +08:00
Vincent Koc
7e5943a62b fix(codex): backport app-server auth refresh failures
Backport Codex auth-refresh failure classification and app-server relogin detail handling to release/2026.5.12.
2026-05-14 12:12:58 +08:00
Vincent Koc
049573f276 docs: sync release sdk subpath docs 2026-05-14 12:08:30 +08:00
Vincent Koc
944b21e6b8 docs: backport reserved codex sdk subpaths 2026-05-14 12:06:59 +08:00
pashpashpash
2fea235f62 Backport Codex internal UI message replies to release 2026.5.12 (#81626)
* fix: route internal ui message tool replies

* docs: document reserved codex sdk helpers

* fix(agents): preserve rich internal source replies

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-05-14 12:05:55 +09:00
Vincent Koc
c70e4fea7b fix: sync codex cli package pin
(cherry picked from commit c95ccf43c1)
2026-05-14 10:42:47 +08:00
Vincent Koc
f561768a7c fix(agents): preserve reply metadata through tool media
(cherry picked from commit e774b25b2f)
2026-05-14 10:02:57 +08:00
pashpashpash
d1b5b243ae fix: load Codex for selectable OpenAI agent models
Treat selectable configured OpenAI agent models as Codex runtime requirements during plugin auto-enable, startup planning, and doctor install repair.

PR: https://github.com/openclaw/openclaw/pull/81591

(cherry picked from commit 3ce922437f)
2026-05-14 09:54:12 +08:00
Vincent Koc
89f641a005 chore(plugin-sdk): refresh release API baseline 2026-05-14 09:31:30 +08:00
Vincent Koc
5cd8ef8f91 test(agents): fix live profile lint
(cherry picked from commit 97ed9b2d82)
2026-05-14 09:30:16 +08:00
Vincent Koc
e0698db057 fix(plugin-sdk): export codex runtime helpers
(cherry picked from commit 5923d9e807)
2026-05-14 09:30:15 +08:00
Peter Steinberger
dc7d9a9168 test(agents): surface live OpenAI replay auth failures 2026-05-14 01:36:33 +01:00
Peter Steinberger
f730f969b1 test(plugins): isolate capability provider runtime mocks 2026-05-14 01:07:50 +01:00
Peter Steinberger
f5393b6be9 test(plugin-install): align npm peer scan expectations 2026-05-14 00:50:51 +01:00
Peter Steinberger
bde31cbb7b ci(release): fail full validation on child failure 2026-05-14 00:45:56 +01:00
pashpashpash
38a8068954 fix(codex): preserve user home for app-server launches
(cherry picked from commit 74860e93fd)
2026-05-13 16:39:19 -07:00
Peter Steinberger
a7fdf60d0d docs(clawhub): remove missing security route 2026-05-13 23:58:21 +01:00
Vincent Koc
b8bc73aea2 test(e2e): cover root-managed VPS upgrades 2026-05-14 06:55:27 +08:00
Peter Steinberger
f86e092e2d test(whatsapp): allow audio runtime dependency 2026-05-13 23:46:08 +01:00
Peter Steinberger
c4292e053d fix(plugins): scope install scanner to runtime graph
(cherry picked from commit cf571c1b58)
2026-05-13 21:30:05 +01:00
Peter Steinberger
e2c32243e9 fix(plugins): satisfy lance shim lint 2026-05-13 20:35:14 +01:00
Peter Steinberger
ab8b4eacce fix(plugins): allow benign LanceDB runtime shims
(cherry picked from commit 439e396262)
2026-05-13 20:26:12 +01:00
homer-byte
5f9249b059 fix(imessage): avoid visible media placeholder text (#81209)
Keep media-only iMessage sends from delivering visible <media:image> text while preserving a non-visible echo key for self-echo dedupe. Thanks @homer-byte.

(cherry picked from commit c3e5d85ce1)
2026-05-13 20:22:00 +01:00
homer-byte
485dbeb5ba fix(imessage): make inbound image attachments readable by agents (#78580)
Stage native iMessage inbound attachments into managed media and convert HEIC/HEIF images to JPEG before dispatch. Thanks @homer-byte.

(cherry picked from commit 1d6e5f7a3e)
2026-05-13 20:21:35 +01:00
Peter Steinberger
1f18e8864d fix(codex): project user MCP servers into app-server threads
Fixes #80814.

Co-authored-by: kinjitakabe <273844887+kinjitakabe@users.noreply.github.com>
(cherry picked from commit 8a406528b4)
2026-05-13 20:21:35 +01:00
Josh Lehman
bd840af600 fix(codex): rotate incompatible context-engine threads (#81223)
* fix(codex): rotate incompatible context-engine threads

* fix(codex): tighten context-engine sidecar policy

* fix: type context-engine binding policy config

---------

Co-authored-by: Josh Lehman <phaedrus@Mac.hsd1.ca.comcast.net>
(cherry picked from commit b55d9fa466)
2026-05-13 20:20:29 +01:00
Peter Steinberger
7470221401 chore(release): bump beta 6 2026-05-13 19:51:08 +01:00
Peter Steinberger
bde26a965c build(pnpm): restore source fixture release-age exemptions 2026-05-13 19:39:11 +01:00
Peter Steinberger
a44012c087 build(pnpm): exempt direct oxlint release age 2026-05-13 19:27:56 +01:00
Peter Steinberger
2cdd69a303 test(plugins): fix scoped config fixture types 2026-05-13 18:28:23 +01:00
Peter Steinberger
f084cf2335 fix(plugins): keep scoped config compat guard-clean 2026-05-13 18:21:25 +01:00
Peter Steinberger
b6c34ab055 fix(plugins): satisfy release lint 2026-05-13 18:12:39 +01:00
Peter Steinberger
95582f03de fix(agents): wire subagent announce gateway dependency 2026-05-13 18:03:37 +01:00
Peter Steinberger
d3950fbd64 chore(canvas): refresh a2ui bundle hash 2026-05-13 16:32:19 +01:00
Peter Steinberger
2906fb833f chore(release): refresh plugin sdk api baseline 2026-05-13 16:30:43 +01:00
Peter Steinberger
a6d878376b fix(tui): emit v4 embedded chat deltas 2026-05-13 16:27:38 +01:00
Peter Steinberger
38cb3d0041 docs(changelog): note v4 chat delta protocol 2026-05-13 16:22:41 +01:00
Peter Steinberger
bdbc224841 fix(gateway): avoid duplicate v4 deltas 2026-05-13 16:22:20 +01:00
Peter Steinberger
02ae92cade fix(gateway): require v4 chat deltas 2026-05-13 16:22:20 +01:00
samzong
9375a72d56 fix(sdk): preserve replayed chat snapshots 2026-05-13 16:22:20 +01:00
samzong
7e8b2d48ce fix(gateway): add incremental chat delta payloads 2026-05-13 16:22:20 +01:00
Vincent Koc
a56727d8fe fix(plugins): prune managed peers on uninstall
(cherry picked from commit 2a67a7f65e)
2026-05-13 22:54:31 +08:00
Peter Steinberger
4165893843 fix(plugins): attribute runtime config deprecations (#81425) (thanks @BKF-Gitty)
Co-authored-by: BKF-Gitty <bandark@mac.com>
2026-05-13 15:49:36 +01:00
Shakker
bf0a5fe5dc fix: preserve owned plugin dependencies during peer repair 2026-05-13 15:33:18 +01:00
Shakker
033d74ce2e fix: harden managed plugin peer recovery 2026-05-13 15:33:18 +01:00
Shakker
d0796a9b13 fix: avoid rescanning repaired plugin peers 2026-05-13 15:33:18 +01:00
Shakker
1b572f2fe2 fix: preserve managed plugin peer dependencies 2026-05-13 15:33:18 +01:00
Pavan Kumar Gondhi
1415c06fc4 fix(plugins): scan installed dependency runtime code [AI] (#81066)
* fix: scan installed plugin dependency code

* addressing review-skill

* addressing review-skill

* addressing codex review

* addressing codex review

* addressing codex review

* addressing codex review

* addressing codex review

* addressing codex review

* addressing codex review

* addressing codex review

* addressing ci

* addressing ci

* docs: add changelog entry for PR merge
2026-05-13 15:32:35 +01:00
Pavan Kumar Gondhi
8b840b28e5 fix: scan plugin runtime entries during install [AI] (#80998)
* fix: scan plugin runtime entries during install

* addressing review-skill

* addressing claude review

* docs: add changelog entry for PR merge
2026-05-13 15:31:34 +01:00
Peter Steinberger
c949a35534 test: keep release queue settings expectations 2026-05-13 14:45:50 +01:00
Peter Steinberger
159d6a610b test: fix queue settings session fixtures 2026-05-13 14:44:23 +01:00
Peter Steinberger
2d088b2c55 test: add live subagent steering proof 2026-05-13 14:43:42 +01:00
Peter Steinberger
e0c744869c fix: use in-process subagent announce handoff 2026-05-13 14:42:47 +01:00
brokemac79
d513271bb6 fix(channel): refresh wecom onboarding install 2026-05-13 14:25:15 +01:00
Peter Steinberger
0f24a78b24 fix(channel): refresh wecom onboarding install (#80390) (thanks @brokemac79) 2026-05-13 14:07:05 +01:00
Peter Steinberger
70bce0d9ed style(agents): format array schema normalization 2026-05-13 14:05:41 +01:00
Peter Steinberger
a50d65f63a fix(config): normalize gemini subagent model writes 2026-05-13 14:04:22 +01:00
Peter Steinberger
6a64f05bcc fix(cli): normalize Gemini config mutation refs 2026-05-13 14:04:17 +01:00
Peter Steinberger
32487b4906 fix(config): normalize per-agent Gemini preview refs 2026-05-13 14:04:11 +01:00
JARVIS-Glasses
798ba972ea fix(agents): normalize array tool schemas 2026-05-13 14:03:34 +01:00
Sarah Fortune
a8f03295c4 fix(plugins): raise default install scan file limit to 25k (#81361) 2026-05-13 14:00:29 +01:00
Jason
552c575a5c Allow pnpm source updates to build OpenClaw (#81294)
Merged via squash.

Prepared head SHA: 4815d5a8c9
Co-authored-by: fuller-stack-dev <263060202+fuller-stack-dev@users.noreply.github.com>
Co-authored-by: altaywtf <9790196+altaywtf@users.noreply.github.com>
Reviewed-by: @altaywtf
2026-05-13 13:59:54 +01:00
Peter Steinberger
f96e0ff23c fix(installer): honor git install versions 2026-05-13 13:59:25 +01:00
Gio Della-Libera
2873631873 fix(update): suppress handoff newer-config warning (#81235)
Merged via squash.

Prepared head SHA: 61a5c975bf
Co-authored-by: giodl73-repo <giodl@microsoft.com>
Co-authored-by: galiniliev <5711535+galiniliev@users.noreply.github.com>
Reviewed-by: @galiniliev
2026-05-13 13:48:17 +01:00
Vincent Koc
9ca2f075c4 revert(cli): remove global root refusal (#81370) 2026-05-13 11:37:33 +01:00
Peter Steinberger
6a8dac800d build(whatsapp): keep audio deps external 2026-05-13 11:27:59 +01:00
Peter Steinberger
e427846225 build(pnpm): restore exotic subdependency blocking 2026-05-13 11:27:52 +01:00
Peter Steinberger
a57b646af4 build(whatsapp): externalize whatsapp plugin 2026-05-13 11:27:28 +01:00
Peter Steinberger
0e324f6d2b build(pnpm): restore exotic subdependency blocking 2026-05-13 11:06:03 +01:00
Sarah Fortune
3c62ebddb6 chore(release): bump beta 5 2026-05-13 01:07:10 -07:00
Peter Steinberger
e88f8476c1 ci(release): extend npm telegram e2e timeout 2026-05-13 08:58:37 +01:00
Sarah Fortune
daff6d8797 cherry-pick #81219 onto release/2026.5.12 for beta.5 (#81327)
* feat(migrate): suppress plan log on embedding + add "Accept recommended" affordance (#81219)

Two related improvements to the interactive `openclaw migrate <provider>`
flow, both surfaced by the onboarding post-install migration prompt that
landed in #81192.

1. `suppressPlanLog?: boolean` on `MigrateCommonOptions`
   (`src/commands/migrate/types.ts`). When set, `migratePlanCommand`
   skips the up-front `runtime.log(formatMigrationPlan(plan))` dump.
   The interactive Codex selection picker and the "Apply this migration
   now?" confirm still run. Wired from the wizard helper at
   `src/wizard/setup.post-install-migration.ts` so that path no longer
   shows the plan dump after the user has already confirmed at the
   wizard prompt.

2. New "Accept recommended" sentinel row at the top of both Codex
   selection pickers, with "Toggle all on" and "Toggle all off" moved
   to the bottom. The cursor starts on "Accept recommended" so pressing
   Enter at the default position submits the picker's `initialValues`
   (the recommended set) — matching the visual state of the checkboxes.

   Implemented in `skill-selection-prompt.ts`:
   - Enter on the Accept sentinel sets `prompt.value` to
     `opts.initialValues` and lets clack submit.
   - Space on the Accept sentinel snaps `prompt.value` to
     `opts.initialValues` so the visible checkboxes flip to the
     recommended state. The user can then Enter to commit or continue
     toggling individual rows. The Accept row itself is never persisted
     in the submitted value list.

   The existing Enter handler for "Toggle all on" / "Toggle all off"
   stays unchanged.

3. Removed the "Skip for now" sentinel entirely. It was a single-
   keystroke trap: with the picker cursor wrapping from Accept to Skip
   via up-arrow (or via accidental down-arrows), Enter on Skip wiped
   `prompt.value` to `[MIGRATION_SELECTION_SKIP]` and abandoned the
   whole migration — including any items the user had already
   confirmed in the previous picker. To exit without migrating, users
   now navigate to "Toggle all off" (or use the `a` / `i` keyboard
   shortcuts) to clear the selection; the apply phase then sees no
   planned work and skips itself via the existing
   `shouldSkipCodexApplyAfterInteractiveSelection` path.

   Cleanup spans `migrate/selection.ts` (constants, `{ action: "skip" }`
   variant, and the reconcile/resolve SKIP branches),
   `migrate.ts` (the picker option rows and the
   `if (selection.action === "skip")` handler blocks in both pickers),
   and the corresponding tests.

4. Plugin selection hint relabelled from "Activate every recommended
   plugin" to "Migrate every recommended plugin" so it matches the
   skill hint and the prompt's own verb ("Migrate ... into this agent
   now?").

Tests:

- `src/commands/migrate/skill-selection-prompt.test.ts` — Accept
  sentinel cases (Enter and Space + Enter both submit initialValues);
  Skip-related test removed; Skip row dropped from the picker fixture.
- `src/commands/migrate/selection.test.ts` — Skip-related sub-
  assertions trimmed from the resolve/reconcile tests; the
  "skip + toggle-off precedence" test renamed to "toggle-off precedence
  over toggle-on" and Skip cases removed.
- `src/commands/migrate.test.ts` — four Skip-driven scenarios removed
  (plugin-only skip, both-pickers skip, skip-skills-continue-to-plugins,
  Codex subscription warning + skip).
- `src/wizard/setup.post-install-migration.test.ts` — call-args
  assertion expects the new `suppressPlanLog` option.

Verification:

- `pnpm lint` clean
- `pnpm tsgo:core` + `pnpm tsgo:core:test` clean
- Touched test suites green (migrate 32/32, selection 17/17,
  skill-selection-prompt 6/6, setup.post-install-migration 10/10).

(cherry picked from commit a197e31abb)

* test(migrate): reconcile beta selection tests
2026-05-13 00:52:36 -07:00
Peter Steinberger
6926481646 test(ci): update install smoke pull retry guard 2026-05-13 07:46:54 +01:00
Peter Steinberger
55b7d0c181 test(docker): stabilize update channel fixture install 2026-05-13 07:39:49 +01:00
Peter Steinberger
05b23f8fb8 ci(release): retry root smoke image pulls 2026-05-13 07:22:27 +01:00
Peter Steinberger
b27f0899e4 test(telegram): stabilize topic serialization test 2026-05-13 07:04:25 +01:00
Peter Steinberger
d1246255d0 chore(release): bump beta 4 2026-05-13 05:52:14 +01:00
Peter Steinberger
bdbdd17b90 build(canvas): refresh a2ui bundle hash 2026-05-13 05:52:10 +01:00
Peter Steinberger
4acf72cf03 fix(plugins): recognize managed codex runtime package 2026-05-13 05:44:39 +01:00
Peter Steinberger
e126e74a58 test(models): restore provider catalog discovery helper 2026-05-13 05:43:44 +01:00
rendrag-git
a2abb63f6e fix: resolve custom provider env markers
(cherry picked from commit 8831754f5c)
2026-05-13 05:42:42 +01:00
rendrag-git
a25650e044 fix: list self-hosted runtime wildcard models
(cherry picked from commit 2269ec727f)
2026-05-13 05:42:42 +01:00
rendrag-git
13c44a2432 fix: discover self-hosted provider wildcards
(cherry picked from commit 3b361cf51c)
2026-05-13 05:42:42 +01:00
pashpashpash
1ffb4126dc Trust installed Codex for its private task runtime (#81206)
* fix(codex): trust installed codex task runtime

* fix(codex): keep private runtime alias packaged

(cherry picked from commit 3688c47f1f)
2026-05-13 05:42:14 +01:00
Peter Steinberger
2b7e1ab1e4 fix: normalize per-agent gemini config refs
(cherry picked from commit 38bab38ce7)
2026-05-13 05:41:20 +01:00
Peter Steinberger
738a8f574e fix: normalize nested gemini preview config ids
(cherry picked from commit 9147a53274)
2026-05-13 05:41:20 +01:00
pashpashpash
410e009d54 Keep Codex media tools backed by auth profiles (#81059)
* fix(codex): pass auth profiles to dynamic tools

* fix: bump protobufjs past advisory range

(cherry picked from commit 36b9da5c91)
2026-05-13 05:40:50 +01:00
Peter Steinberger
80c9015b56 fix: canonicalize qualified gemini pro preview refs
(cherry picked from commit fad2484477)
2026-05-13 05:40:03 +01:00
Peter Steinberger
c612dd9529 fix: normalize oauth auth-result config patches
(cherry picked from commit 88714d6803)
2026-05-13 05:40:03 +01:00
Super Zheng
a97399a25c fix: enable native require fast path on Windows for plugin-sdk root alias (#80878)
Merged via squash.

Prepared head SHA: 87446445b6
Co-authored-by: medns <1575008+medns@users.noreply.github.com>
Co-authored-by: odysseus0 <8635094+odysseus0@users.noreply.github.com>
Reviewed-by: @odysseus0

(cherry picked from commit 4223dd2886)
2026-05-13 05:40:03 +01:00
pashpashpash
87d6b868b7 docs(changelog): note telegram html rendering backport 2026-05-12 20:57:43 -07:00
Ayaan Zaidi
e309a8732e fix(telegram): simplify context boundary plumbing
(cherry picked from commit 8c40481076)
2026-05-12 20:43:25 -07:00
VACInc
b28a70369d fix telegram context session start boundary
(cherry picked from commit 6648b20b65)
2026-05-12 20:43:25 -07:00
VACInc
c5140a09e1 fix telegram context reset boundary
(cherry picked from commit 991f89af04)
2026-05-12 20:43:25 -07:00
Ayaan Zaidi
3c3cef1785 fix(telegram): keep durable html unsanitized
(cherry picked from commit 78b57ae201)
2026-05-12 20:43:17 -07:00
Ayaan Zaidi
7c606f834c fix(telegram): preserve supported html replies
(cherry picked from commit 716a4cf412)
2026-05-12 20:43:17 -07:00
WhatsSkiLL
fec979cf96 fix(gateway): clarify invalid config recovery hints
Closes #40652.

Thanks @JARVIS-Glasses.

(cherry picked from commit e0f6f78b02)
2026-05-12 20:27:41 -07:00
Marcus Castro
b258035a8d fix(whatsapp): drain debounced inbound before close (#81246)
* fix(whatsapp): drain debounced inbound before close

* docs(changelog): note WhatsApp debounce close drain

(cherry picked from commit 81a3de1d9d)
2026-05-12 20:18:14 -07:00
Rubén Cuevas
f6c919cb9a fix(plugins): retry npm alias override installs (#80539)
* fix(plugins): retry npm alias override installs

* fix(onboarding): space install retry warning

* fix(onboarding): shorten retry progress label

* docs(changelog): note npm alias install retry

---------

Co-authored-by: pashpashpash <nik@vault77.ai>
(cherry picked from commit d4998d7b88)
2026-05-12 20:18:13 -07:00
Sarah Fortune
f810103509 feat(onboard): add --skip-hooks flag (#81220)
(cherry picked from commit e7c9e84a42)
2026-05-12 20:18:13 -07:00
Sarah Fortune
2aa4dc03c1 feat(onboard): offer codex migration after harness install (#81192)
Add a post-install seam so the wizard can prompt the user to import their
existing Codex CLI state (skills, archived config/hooks, advisory cached
plugins) through the existing `openclaw migrate codex` flow once the
harness plugin is in place. Fires on both fresh installs and repair runs;
the user can decline at any time.

Trigger sites, both routing through one helper:

- src/plugins/provider-auth-choice.ts: after
  `ensureCodexRuntimePluginForModelSelection` reports `installed: true`,
  dynamically import `offerPostInstallMigrations` and call it before the
  wizard moves on.
- src/commands/onboard-non-interactive/local/auth-choice.plugin-providers.ts:
  same call shape with `nonInteractive: true`, so the helper emits a hint
  line only and never mutates state.

Helper (src/wizard/setup.post-install-migration.ts) is generic, not
Codex-hardcoded — it resolves migration providers via the manifest
`migrationProviders` contract, filters to providers owned by plugins the
caller flags as installed in this onboarding step, runs `provider.detect`,
and on TTY hands accepted runs to `migrateDefaultCommand`. All detect,
prompt, and migrate failures are swallowed so onboarding never aborts on
this optional offer.

Also harden the Codex app-server subprocess lifecycle now that `detect()`
runs from a hotter onboarding path: isolate the plugin-install
`plugin/read` call (extensions/codex/src/migration/apply.ts) and have the
isolated request wait for child exit with a SIGKILL fallback
(extensions/codex/src/app-server/request.ts) so parents are not held open
by an orphaned codex binary.

Tests:

- src/wizard/setup.post-install-migration.test.ts (new, 10 cases)
- src/commands/onboard-non-interactive/local/auth-choice.plugin-providers.test.ts
  extended with hint-call assertions and a not-required-no-offer case.

(cherry picked from commit 48529f1a96)
2026-05-12 20:18:13 -07:00
Kevin Lin
a0a2eeefb9 fix: improve Codex migration selector enter
(cherry picked from commit bc44f3824f)
2026-05-12 20:18:13 -07:00
Sarah Fortune
4182fbaad0 fix(install): don't abort install.ps1 when git writes to stderr (#80834)
PowerShell 7+ honors $ErrorActionPreference=Stop for native commands,
so git's normal progress line ("From https://...") on stderr during
`git pull --rebase` would turn into a terminating error and abort the
installer immediately after a fresh clone — before pnpm install/build
ever runs. The existing `2>$null` redirects the display but the error
record is still generated.

Wrap the git status / pull calls in try/catch so the pull stays
best-effort and the rest of the installer can proceed. Reproduced on
Windows 11 ARM under PowerShell 7.x with -InstallMethod git.

(cherry picked from commit d06f0a0ee7)
2026-05-12 20:15:46 -07:00
Peter Steinberger
ab9893a4f5 fix(update): make pnpm preflight resolution deterministic 2026-05-13 03:01:05 +01:00
Peter Steinberger
d7472ab015 ci: avoid pnpm prompts in live docker tests 2026-05-13 02:35:10 +01:00
Peter Steinberger
95434cd497 test(gateway): tolerate google live tool nonce misses 2026-05-13 01:08:51 +01:00
Peter Steinberger
a90a5fc4d1 ci: track live provider workflow rename 2026-05-13 00:52:41 +01:00
Peter Steinberger
cc46ca9bee chore(release): bump beta 3 2026-05-13 00:04:20 +01:00
Peter Steinberger
9fd79d7b69 fix(plugins): keep codex runtime alias in packages 2026-05-12 23:48:21 +01:00
Peter Steinberger
b251a74b1c fix(plugins): alias codex native runtime for managed installs 2026-05-12 23:34:57 +01:00
Peter Steinberger
fdb6e92ff5 test(auth): type model check auth profile mocks 2026-05-12 22:44:11 +01:00
Peter Steinberger
7f0fc0bab4 build(canvas): refresh a2ui bundle hash 2026-05-12 22:37:43 +01:00
Peter Steinberger
8f212d0b6f chore(release): bump beta 2 2026-05-12 22:37:43 +01:00
y471823206
b86c387d6c Handle generic provider internal errors (#49401)
Merged via squash.

Prepared head SHA: 492caa49a9
Co-authored-by: y471823206 <2311651347@qq.com>
Co-authored-by: altaywtf <9790196+altaywtf@users.noreply.github.com>
Reviewed-by: @altaywtf
2026-05-12 22:37:43 +01:00
Galin Iliev
23dc2bfcd8 fix(azure):Drain split provider stream frames (#80927)
Merged via squash.

Prepared head SHA: 03a7e1fec3
Co-authored-by: galiniliev <5711535+galiniliev@users.noreply.github.com>
Co-authored-by: galiniliev <5711535+galiniliev@users.noreply.github.com>
Reviewed-by: @galiniliev
2026-05-12 22:37:42 +01:00
Bob
985bc40711 fix: surface silent model fallback failures (#80917)
Merged via squash.

Prepared head SHA: 59be6e2db5
Co-authored-by: dutifulbob <261991368+dutifulbob@users.noreply.github.com>
Co-authored-by: osolmaz <2453968+osolmaz@users.noreply.github.com>
Reviewed-by: @osolmaz
2026-05-12 22:37:42 +01:00
clawsweeper[bot]
eab66220f8 fix(gateway): wire max_completion_tokens/max_tokens through openai-http (#81013)
Summary:
- The branch adds Chat Completions token-cap fields to the Gateway request type, forwards them as agent stream parameters, and documents/tests the behavior.
- Reproducibility: yes. Source inspection gives a high-confidence current-main path: send `max_completion_toke ... tokens` to `/v1/chat/completions` and observe that the current handler never sets `streamParams.maxTokens`.

Automerge notes:
- PR branch already contained follow-up commit before automerge: fix(gateway): wire max_completion_tokens/max_tokens through openai-http

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

Prepared head SHA: a9c39f7d4a
Review: https://github.com/openclaw/openclaw/pull/81013#issuecomment-4430303959

Co-authored-by: Bingsen <dingheng.huang@urbanic.com>
Co-authored-by: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com>
2026-05-12 22:37:42 +01:00
pashpashpash
22a6717e11 Keep Codex media tools backed by auth profiles (#81059)
* fix(codex): pass auth profiles to dynamic tools

* fix: bump protobufjs past advisory range
2026-05-12 22:37:42 +01:00
Kevin Lin
a4743ad180 fix(codex): gate migration on app readiness (#80815)
* fix(codex): gate migration on app readiness

* fix(codex): preserve source auth during migration

* fix(codex): isolate migration source app probes

* docs(codex): align migration readiness reasons

* docs(codex): remove stale auth-required source reason

* fix(codex): narrow native auth profile resolver input

* fix: clarify codex migration subscription gating

* refactor: simplify codex migration subscription gate

* fix: make codex app verification optional

* docs: clarify codex app inventory cache

* test: avoid map spread in migration test
2026-05-12 22:37:42 +01:00
Ayaan Zaidi
1df4df6eed fix(docker): persist auth profile key mount 2026-05-12 22:37:42 +01:00
Rubén Cuevas
ca8bc5500d fix(onboard): short-circuit model auth check 2026-05-12 22:37:42 +01:00
Rubén Cuevas
930046df04 fix(onboard): accept Codex auth in model check 2026-05-12 22:37:42 +01:00
kinjitakabe
03e4b035f1 fix(matrix): stop runtime npm install from parent-derived cwd
`ensureMatrixSdkInstalled` previously derived an install `cwd` via fixed
two-segment traversal from `import.meta.url` and spawned `npm install`
(or `pnpm install`) when Matrix packages were missing. Under the
externalized plugin layout the derived path is a scope directory like
`<config>/npm/node_modules/@openclaw`, so npm walks up to the managed
project root and prunes undeclared siblings. Under the legacy bundled
layout it would target `<global-prefix>/lib/node_modules` and could
delete unrelated global CLIs.

Matrix is now a pure availability check: if any required package fails
to resolve, it throws an actionable error pointing the operator at the
supported repair commands (`openclaw plugins update matrix`,
`openclaw doctor --fix`). This matches extensions/AGENTS.md:
"Runtime never installs deps; install/update/doctor are repair points."

The exported signature stays backwards-compatible (all params optional;
`confirm` and `runtime` are accepted but ignored). `resolveMissingMatrixPackages`
gains an optional `resolveFn` seam for testability, mirroring the existing
`ensureMatrixCryptoRuntime` injection pattern.

Fixes #80758.
2026-05-12 22:37:42 +01:00
Peter Steinberger
6115eada6d docs: note baileys install policy 2026-05-12 22:37:42 +01:00
pashpashpash
84a2060a64 fix(codex): backport auth profiles to dynamic tools 2026-05-12 14:31:47 -07:00
Peter Steinberger
bc6090502c ci: allow focused media video live reruns 2026-05-12 21:09:16 +01:00
Peter Steinberger
d3a8a45119 ci: run advisory live wrappers with bash 2026-05-12 20:59:12 +01:00
Peter Steinberger
b12cd4358d ci: keep Docker CLI backend live test noninteractive 2026-05-12 19:47:54 +01:00
Peter Steinberger
1824464bf2 fix(docker): avoid runtime prune hang 2026-05-12 16:15:39 +01:00
Peter Steinberger
6eebba3920 test(openai): relax live tts latency budget 2026-05-12 15:28:55 +01:00
Peter Steinberger
7b544a7976 fix(release): unblock docker release validation 2026-05-12 14:34:32 +01:00
Peter Steinberger
441041f92d test(release): harden beta validation flakes 2026-05-12 13:25:18 +01:00
Peter Steinberger
7284608461 fix(release): unblock update validation gates 2026-05-12 12:50:02 +01:00
Peter Steinberger
56d96b3b8d fix(release): unblock beta docker validation 2026-05-12 12:07:28 +01:00
Peter Steinberger
41bf26ede3 test(release): refresh beta validation expectations 2026-05-12 11:33:20 +01:00
Peter Steinberger
6820d18160 test(docker): align runtime prune assertion 2026-05-12 11:14:58 +01:00
Peter Steinberger
e6fb7aa1a8 fix(docker): allow runtime prune to hydrate cache 2026-05-12 11:06:35 +01:00
562 changed files with 20294 additions and 2346 deletions

View File

@@ -137,8 +137,10 @@ jobs:
env:
OPENAI_API_KEY: ${{ secrets.OPENCLAW_DOCS_I18N_OPENAI_API_KEY || secrets.OPENAI_API_KEY }}
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
OPENCLAW_CONTROL_UI_I18N_MODEL: ${{ vars.OPENCLAW_CI_OPENAI_MODEL_BARE }}
OPENCLAW_CONTROL_UI_I18N_PROVIDER: ${{ secrets.ANTHROPIC_API_KEY != '' && 'anthropic' || 'openai' }}
OPENCLAW_CONTROL_UI_I18N_MODEL: ${{ secrets.ANTHROPIC_API_KEY != '' && 'claude-opus-4-6' || vars.OPENCLAW_CI_OPENAI_MODEL_BARE }}
OPENCLAW_CONTROL_UI_I18N_THINKING: low
OPENCLAW_CONTROL_UI_I18N_AUTH_OPTIONAL: "1"
LOCALE: ${{ matrix.locale }}
run: node --import tsx scripts/control-ui-i18n.ts sync --locale "${LOCALE}" --write

View File

@@ -297,6 +297,7 @@ jobs:
echo "conclusion=${conclusion}" >> "$GITHUB_OUTPUT"
if [[ "$conclusion" != "success" ]]; then
gh run view "$run_id" --json jobs --jq '.jobs[] | select(.conclusion != "success" and .conclusion != "skipped") | {name, conclusion, url}' || true
exit 1
fi
}
@@ -396,6 +397,7 @@ jobs:
echo "conclusion=${conclusion}" >> "$GITHUB_OUTPUT"
if [[ "$conclusion" != "success" ]]; then
gh run view "$run_id" --json jobs --jq '.jobs[] | select(.conclusion != "success" and .conclusion != "skipped") | {name, conclusion, url}' || true
exit 1
fi
}
@@ -504,6 +506,7 @@ jobs:
echo "conclusion=${conclusion}" >> "$GITHUB_OUTPUT"
if [[ "$conclusion" != "success" ]]; then
gh run view "$run_id" --json jobs --jq '.jobs[] | select(.conclusion != "success" and .conclusion != "skipped") | {name, conclusion, url}' || true
exit 1
fi
}
@@ -726,6 +729,7 @@ jobs:
echo "conclusion=${conclusion}" >> "$GITHUB_OUTPUT"
if [[ "$conclusion" != "success" ]]; then
gh run view "$run_id" --json jobs --jq '.jobs[] | select(.conclusion != "success" and .conclusion != "skipped") | {name, conclusion, url}' || true
exit 1
fi
summary:
@@ -735,62 +739,6 @@ jobs:
runs-on: ubuntu-24.04
timeout-minutes: 5
steps:
- name: Request private evidence update
env:
RELEASE_PRIVATE_DISPATCH_TOKEN: ${{ secrets.OPENCLAW_RELEASES_PRIVATE_DISPATCH_TOKEN }}
TARGET_REF: ${{ inputs.ref }}
PACKAGE_SPEC: ${{ inputs.evidence_package_spec || inputs.npm_telegram_package_spec }}
GITHUB_RUN_ID_VALUE: ${{ github.run_id }}
RELEASE_CHECKS_RESULT: ${{ needs.release_checks.result }}
run: |
set -euo pipefail
if [[ "$RELEASE_CHECKS_RESULT" == "skipped" ]]; then
echo "Release checks were skipped by rerun group; skipping automatic private evidence update."
exit 0
fi
if [[ -z "${RELEASE_PRIVATE_DISPATCH_TOKEN// }" ]]; then
echo "OPENCLAW_RELEASES_PRIVATE_DISPATCH_TOKEN is not configured; skipping automatic private evidence update."
exit 0
fi
release_id="${TARGET_REF#refs/tags/}"
release_id="${release_id#v}"
if [[ "$PACKAGE_SPEC" =~ ^openclaw@(.+)$ ]]; then
release_id="${BASH_REMATCH[1]}"
fi
release_id="$(printf '%s' "$release_id" | tr '/:@ ' '----' | tr -cd 'A-Za-z0-9._-')"
if [[ -z "$release_id" ]]; then
echo "::error::Could not derive release evidence id from target ref '${TARGET_REF}'."
exit 1
fi
payload="$(
jq -cn \
--arg full_validation_run_id "$GITHUB_RUN_ID_VALUE" \
--arg release_id "$release_id" \
--arg release_ref "$TARGET_REF" \
--arg package_spec "$PACKAGE_SPEC" \
--arg notes "Automatically requested by Full Release Validation ${GITHUB_RUN_ID_VALUE} after child workflows completed; the parent summary re-checks current child run conclusions." \
'{
event_type: "openclaw_full_release_validation_completed",
client_payload: {
full_validation_run_id: $full_validation_run_id,
release_id: $release_id,
release_ref: $release_ref,
package_spec: $package_spec,
notes: $notes
}
}'
)"
curl --fail-with-body \
-X POST \
-H "Accept: application/vnd.github+json" \
-H "Authorization: Bearer ${RELEASE_PRIVATE_DISPATCH_TOKEN}" \
-H "X-GitHub-Api-Version: 2022-11-28" \
https://api.github.com/repos/openclaw/releases-private/dispatches \
-d "$payload"
- name: Verify child workflow results
env:
GH_TOKEN: ${{ github.token }}
@@ -969,3 +917,61 @@ jobs:
summarize_child_timing "npm_telegram" "$NPM_TELEGRAM_RUN_ID"
exit "$failed"
- name: Request private evidence update
env:
RELEASE_PRIVATE_DISPATCH_TOKEN: ${{ secrets.OPENCLAW_RELEASES_PRIVATE_DISPATCH_TOKEN }}
TARGET_REF: ${{ inputs.ref }}
PACKAGE_SPEC: ${{ inputs.evidence_package_spec || inputs.npm_telegram_package_spec }}
GITHUB_RUN_ID_VALUE: ${{ github.run_id }}
RELEASE_CHECKS_RESULT: ${{ needs.release_checks.result }}
run: |
set -euo pipefail
if [[ "$RELEASE_CHECKS_RESULT" == "skipped" ]]; then
echo "Release checks were skipped by rerun group; skipping automatic private evidence update."
exit 0
fi
if [[ -z "${RELEASE_PRIVATE_DISPATCH_TOKEN// }" ]]; then
echo "OPENCLAW_RELEASES_PRIVATE_DISPATCH_TOKEN is not configured; skipping automatic private evidence update."
exit 0
fi
release_id="${TARGET_REF#refs/tags/}"
release_id="${release_id#v}"
if [[ "$PACKAGE_SPEC" =~ ^openclaw@(.+)$ ]]; then
release_id="${BASH_REMATCH[1]}"
fi
release_id="$(printf '%s' "$release_id" | tr '/:@ ' '----' | tr -cd 'A-Za-z0-9._-')"
if [[ -z "$release_id" ]]; then
echo "::warning::Could not derive release evidence id from target ref '${TARGET_REF}'; skipping automatic private evidence update."
exit 0
fi
payload="$(
jq -cn \
--arg full_validation_run_id "$GITHUB_RUN_ID_VALUE" \
--arg release_id "$release_id" \
--arg release_ref "$TARGET_REF" \
--arg package_spec "$PACKAGE_SPEC" \
--arg notes "Automatically requested by Full Release Validation ${GITHUB_RUN_ID_VALUE} after child workflows completed; the parent summary re-checks current child run conclusions." \
'{
event_type: "openclaw_full_release_validation_completed",
client_payload: {
full_validation_run_id: $full_validation_run_id,
release_id: $release_id,
release_ref: $release_ref,
package_spec: $package_spec,
notes: $notes
}
}'
)"
if ! curl --fail-with-body \
-X POST \
-H "Accept: application/vnd.github+json" \
-H "Authorization: Bearer ${RELEASE_PRIVATE_DISPATCH_TOKEN}" \
-H "X-GitHub-Api-Version: 2022-11-28" \
https://api.github.com/repos/openclaw/releases-private/dispatches \
-d "$payload"; then
echo "::warning::Automatic private release evidence dispatch failed; child workflow validation remains authoritative."
fi

View File

@@ -316,7 +316,19 @@ jobs:
- name: Pull root Dockerfile smoke image
env:
IMAGE_REF: ${{ needs.root_dockerfile_image.outputs.image_ref }}
run: timeout 600s docker pull "$IMAGE_REF"
run: |
set -euo pipefail
for attempt in 1 2; do
if timeout 1200s docker pull "$IMAGE_REF"; then
exit 0
fi
status=$?
if [ "$attempt" = "2" ]; then
exit "$status"
fi
docker image rm "$IMAGE_REF" >/dev/null 2>&1 || true
sleep 30
done
- name: Run root Dockerfile CLI smoke
env:
@@ -421,7 +433,19 @@ jobs:
- name: Pull root Dockerfile smoke image
env:
IMAGE_REF: ${{ needs.root_dockerfile_image.outputs.image_ref }}
run: timeout 600s docker pull "$IMAGE_REF"
run: |
set -euo pipefail
for attempt in 1 2; do
if timeout 1200s docker pull "$IMAGE_REF"; then
exit 0
fi
status=$?
if [ "$attempt" = "2" ]; then
exit "$status"
fi
docker image rm "$IMAGE_REF" >/dev/null 2>&1 || true
sleep 30
done
- name: Set up Blacksmith Docker Builder
uses: useblacksmith/setup-docker-builder@722e97d12b1d06a961800dd6c05d79d951ad3c80 # v1
@@ -488,7 +512,19 @@ jobs:
- name: Pull root Dockerfile smoke image
env:
IMAGE_REF: ${{ needs.root_dockerfile_image.outputs.image_ref }}
run: timeout 600s docker pull "$IMAGE_REF"
run: |
set -euo pipefail
for attempt in 1 2; do
if timeout 1200s docker pull "$IMAGE_REF"; then
exit 0
fi
status=$?
if [ "$attempt" = "2" ]; then
exit "$status"
fi
docker image rm "$IMAGE_REF" >/dev/null 2>&1 || true
sleep 30
done
- name: Setup Node environment for Bun smoke
uses: ./.github/actions/setup-node-env

View File

@@ -100,7 +100,7 @@ jobs:
run_package_telegram_e2e:
name: Run package Telegram E2E
runs-on: blacksmith-32vcpu-ubuntu-2404
timeout-minutes: 60
timeout-minutes: 120
environment: qa-live-shared
permissions:
actions: read

View File

@@ -433,6 +433,10 @@ jobs:
add_profile_suite native-live-extensions-media-music-google "full"
add_profile_suite native-live-extensions-media-music-minimax "full"
add_profile_suite native-live-extensions-media-video "full"
add_profile_suite native-live-extensions-media-video-a "full"
add_profile_suite native-live-extensions-media-video-b "full"
add_profile_suite native-live-extensions-media-video-c "full"
add_profile_suite native-live-extensions-media-video-d "full"
fi
fi
@@ -2198,6 +2202,7 @@ jobs:
- name: Run ${{ matrix.label }}
if: contains(matrix.profiles, inputs.release_test_profile) && (inputs.live_suite_filter == '' || inputs.live_suite_filter == matrix.suite_id || (inputs.live_suite_filter == 'native-live-src-gateway-profiles-anthropic' && startsWith(matrix.suite_id, 'native-live-src-gateway-profiles-anthropic-')) || (inputs.live_suite_filter == 'native-live-src-gateway-profiles-opencode-go' && startsWith(matrix.suite_id, 'native-live-src-gateway-profiles-opencode-go-')))
shell: bash
env:
OPENCLAW_LIVE_COMMAND: ${{ matrix.command }}
OPENCLAW_LIVE_SUITE_ADVISORY: ${{ matrix.advisory }}
@@ -2414,6 +2419,7 @@ jobs:
- name: Run ${{ matrix.label }}
if: contains(matrix.profiles, inputs.release_test_profile) && (inputs.live_suite_filter == '' || inputs.live_suite_filter == matrix.suite_id || (inputs.live_suite_filter == 'live-gateway-advisory-docker' && startsWith(matrix.suite_id, 'live-gateway-advisory-docker-')))
shell: bash
env:
OPENCLAW_LIVE_COMMAND: ${{ matrix.command }}
OPENCLAW_LIVE_SUITE_ADVISORY: ${{ matrix.advisory }}
@@ -2602,6 +2608,7 @@ jobs:
- name: Run ${{ matrix.label }}
if: contains(matrix.profiles, inputs.release_test_profile) && (inputs.live_suite_filter == '' || inputs.live_suite_filter == matrix.suite_id || (inputs.live_suite_filter == 'native-live-extensions-media-video' && startsWith(matrix.suite_id, 'native-live-extensions-media-video-')))
shell: bash
env:
OPENCLAW_LIVE_SUITE_ADVISORY: ${{ matrix.advisory }}
run: |

View File

@@ -626,7 +626,7 @@ jobs:
artifact_name: ${{ needs.prepare_release_package.outputs.artifact_name }}
package_sha256: ${{ (needs.resolve_target.outputs.package_acceptance_package_spec == '' && needs.resolve_target.outputs.release_package_spec == '') && needs.prepare_release_package.outputs.package_sha256 || '' }}
suite_profile: custom
docker_lanes: doctor-switch update-channel-switch skill-install update-corrupt-plugin upgrade-survivor published-upgrade-survivor update-restart-auth plugins-offline plugin-update
docker_lanes: doctor-switch update-channel-switch skill-install update-corrupt-plugin upgrade-survivor published-upgrade-survivor root-managed-vps-upgrade update-restart-auth plugins-offline plugin-update
published_upgrade_survivor_baselines: ${{ needs.resolve_target.outputs.run_release_soak == 'true' && 'last-stable-4 2026.4.23 2026.5.2 2026.4.15' || '' }}
published_upgrade_survivor_scenarios: ${{ needs.resolve_target.outputs.run_release_soak == 'true' && 'reported-issues' || '' }}
telegram_mode: mock-openai

View File

@@ -386,10 +386,10 @@ jobs:
docker_lanes="npm-onboard-channel-agent gateway-network config-reload"
;;
package)
docker_lanes="npm-onboard-channel-agent doctor-switch update-channel-switch skill-install update-corrupt-plugin upgrade-survivor published-upgrade-survivor update-restart-auth plugins-offline plugin-update"
docker_lanes="npm-onboard-channel-agent doctor-switch update-channel-switch skill-install update-corrupt-plugin upgrade-survivor published-upgrade-survivor root-managed-vps-upgrade update-restart-auth plugins-offline plugin-update"
;;
product)
docker_lanes="npm-onboard-channel-agent doctor-switch update-channel-switch skill-install update-corrupt-plugin upgrade-survivor published-upgrade-survivor update-restart-auth plugins plugin-update mcp-channels cron-mcp-cleanup openai-web-search-minimal openwebui"
docker_lanes="npm-onboard-channel-agent doctor-switch update-channel-switch skill-install update-corrupt-plugin upgrade-survivor published-upgrade-survivor root-managed-vps-upgrade update-restart-auth plugins plugin-update mcp-channels cron-mcp-cleanup openai-web-search-minimal openwebui"
include_openwebui=true
;;
full)

View File

@@ -31,6 +31,9 @@ Skills own workflows; root owns hard policy and routing.
- Core/tests: no deep plugin internals (`extensions/*/src/**`, `onboard.js`). Use public barrels, SDK facade, generic contracts.
- Owner boundary: owner-specific repair/detection/onboarding/auth/defaults/provider behavior lives in owner plugin. Shared/core gets generic seams only.
- Dependency ownership follows runtime ownership: plugin-only deps stay plugin-local; root deps only for core imports or intentionally internalized bundled plugin runtime.
- Internal bundled plugins ship in core dist; bundled-only facade loader ok only for them.
- External official plugins own package/deps and are excluded from core dist; core uses registry-aware `facade-runtime` or generic contracts.
- Moving external: update package excludes, official catalogs, docs, tests, and prove core runtime paths resolve installed plugin roots before root-dep removal.
- Legacy config repair belongs in `openclaw doctor --fix`, not startup/load-time core migrations. Runtime paths use canonical contracts.
- New seams: backward-compatible, documented, versioned. Third-party plugins exist.
- Channels are implementation under `src/channels/**`; plugin authors get SDK seams. Providers own auth/catalog/runtime hooks; core owns generic loop.

View File

@@ -4,19 +4,151 @@ Docs: https://docs.openclaw.ai
## Unreleased
### Changes
- Amazon Bedrock: externalize the Bedrock and Bedrock Mantle provider packages so core installs no longer pull AWS SDK dependencies unless those providers are installed.
- Plugins: externalize Slack, OpenShell sandbox, and Anthropic Vertex so their runtime dependency cones install only when those plugins are installed.
- Control UI/WebChat: add a persisted auto-scroll mode selector so users can keep the current near-bottom behavior, always follow streaming output, or turn automatic streaming scroll off and use the New messages button manually. Fixes #7648 and #81287. Thanks @BunsDev.
- ACP: add `acp.fallbacks` so ACP turns can try configured backup runtime backends when the primary backend is unavailable before any output is emitted. (#69542) Thanks @kaseonedge.
### Fixes
- Telegram: keep Bot API polling alive during main event-loop stalls by moving ingress to an isolated worker with a durable local spool. Fixes #81132. (#81746) Thanks @joshavant.
- CLI/plugins: keep bare plugin and parent-command help on the lightweight path, avoiding plugin registry discovery before rendering help.
- Gateway/session history: carry monotonic transcript message sequence through live updates and refresh SSE history when stale sequence input would otherwise append bad incremental state. (#81474) Thanks @samzong.
- Security/sandbox: include Windows `USERPROFILE` in the sandbox blocked home roots so credential-bearing binds (such as `.codex`, `.openclaw`, or `.ssh` under the Windows user profile) are denied even when `HOME` points at a different shell home. (#63074) Thanks @luoyanglang.
- Models config/auth: stop inferring provider env-var markers from broad `^[A-Z_][A-Z0-9_]*$` strings, and resolve config-backed provider `apiKey` values only through structured env SecretRefs (`secrets.providers[id]` / `secrets.defaults`), so unrelated env vars cannot accidentally become provider credentials. Thanks @sallyom.
- Media fetch: skip allocating and buffering the response body for bodyless media responses (HEAD probes and 204-style empty bodies), avoiding wasted heap on streams that carry no payload. Thanks @shakkernerd.
- CLI/onboarding: forward provider-specific auth flags (e.g. `--openai-api-key`) through the onboarding wizard so they reach provider auth methods via `ctx.opts`, letting `--openai-api-key "$OPENAI_API_KEY"` skip the redundant "use existing env var?" prompt in non-interactive harnesses. (#81669) Thanks @sjf.
- CLI/migrate: drop trailing periods from Codex migrate item messages and `REASON_CODE_MESSAGES` strings so plan/result rows read as labels instead of sentence fragments. (#81705) Thanks @sjf.
- Slack: treat malformed private-file redirect `Location` headers as unfollowable redirects instead of failing Slack media downloads.
- Plugins: discover provider plugins from `setup.providers[].envVars` credentials during provider discovery while keeping the deprecated `providerAuthEnvVars` fallback. (#81542) Thanks @JARVIS-Glasses.
- Docs/Codex harness: clarify that per-agent `CODEX_HOME` isolates `~/.codex` while inherited `HOME` intentionally keeps `.agents` discovery and subprocess user-home state available.
- Auth: reclaim dead-owner stale file locks before retrying locked writes, so crashed OAuth refreshes no longer wedge `auth-profiles.json` until manual cleanup.
- CLI tables: preserve muted/color styling on wrapped continuation lines after multiline cells, keeping `openclaw plugins list` descriptions readable.
- Process execution: collapse case-insensitive duplicate child environment keys on Windows so caller-provided overrides such as `PATH` cannot be shadowed by host `Path`.
- Gateway/diagnostics: suppress cold-start liveness warnings during the startup grace window while still sampling liveness metrics. Fixes #79915. (#81699) Thanks @joshavant.
- Codex harness: keep `oauthRef`-backed Codex OAuth profiles usable and stop high-confidence app-server OAuth refresh invalidation from retry-spamming raw token-refresh errors without turning entitlement or usage-limit payloads into re-auth prompts.
- Browser CLI: request the existing `operator.admin` gateway scope explicitly for browser control commands, avoiding unnecessary scope-upgrade approval loops. Fixes #81555. (#81716) Thanks @joshavant.
- Gateway/diagnostics: suppress cold-start liveness warnings during the startup grace window while still sampling liveness metrics. Fixes #79915. (#81699) Thanks @joshavant.
- Plugin SDK: restore the deprecated `openclaw/plugin-sdk/memory-core` package subpath as an alias of `memory-host-core`, so published memory companion plugins that still import it resolve on current hosts.
- Control UI/i18n: use the installed workspace pi runtime for locale refreshes, update the fallback package pin, prefer the Anthropic CI provider when available, and skip invalid provider credentials instead of failing main.
- Codex harness: classify native app-server token-refresh logout and relogin failures as authentication refresh errors, so users get re-authentication guidance instead of a raw runtime failure.
- Codex startup: treat selectable configured OpenAI agent models as Codex runtime requirements during plugin auto-enable, startup planning, and doctor install repair, so Anthropic-primary configs can still switch to OpenAI/Codex cleanly.
- Agents: preserve source-reply delivery metadata when merging tool-returned media into the final reply, keeping message-tool-only replies deliverable and mirrored. Thanks @pashpashpash and @vincentkoc.
- Replies: treat rich presentation, interactive controls, and channel-native payload data as outbound content across follow-up, heartbeat, cron, ACP, and block-streaming delivery paths, preventing card/button-only replies from being dropped as empty.
- WebChat/TUI: route Codex `tools.message` source replies to the active internal UI turn and mirror them to session history, so message-tool-only harness replies, including rich presentation and button-only replies, no longer disappear while WebChat and TUI remain non-targetable outbound channels. (#81586) Thanks @pashpashpash.
- Replies: deliver rich-only block replies even when block-streaming coalescing is enabled, keeping card and button payloads from being dropped by the text coalescer. Thanks @pashpashpash.
- macOS/companion: require system TLS trust before pinning a first-use direct `wss://` gateway certificate and honor `gateway.remote.tlsFingerprint` as the explicit pin for remote node-mode sessions, so fresh endpoints fail closed when macOS cannot trust the certificate unless configured out of band. Fixes #50642. Thanks @BunsDev.
- Update: snapshot config before update-time repair and restart writes, preserve plugin install records through doctor cleanup, and keep update-time config size drops from blocking the update while pointing users to the pre-update backup. Fixes #80077. (#80257) Thanks @Jerry-Xin and @vincentkoc.
- Sessions/status: classify ACP spawn-child sessions as `kind: "spawn-child"` instead of `"direct"` in `openclaw sessions` and status output; extract the duplicated session-kind classifier into a shared helper (`src/sessions/classify-session-kind.ts`) so both surfaces stay in sync. Fixes catalog #19. (#79544)
- Sessions/Gateway: report `agentRuntime.id: "acpx"` (or stored backend id) with `source: "session-key"` for ACP control-plane session rows in `openclaw sessions --json`, `openclaw status`, and Gateway session RPC responses instead of the incorrect `"auto"` / `"pi"` implicit fallback. Fixes catalog #18. (#79550)
- Telegram: delete tool-progress-only draft bubbles before rotating to the real answer, preventing orphaned progress messages in streamed replies.
- Codex app-server: keep per-agent `CODEX_HOME` isolation without rewriting `HOME` by default, so Codex-run subprocesses can still find normal user-home config, tokens, and CLI state unless the launch explicitly overrides `HOME`. Thanks @pashpashpash.
- iMessage: stop sending visible `<media:image>` placeholder text for media-only native image sends while preserving the internal echo key that prevents self-echo duplicate replies. (#81209) Thanks @homer-byte.
- Agents/sessions: create configured agent main sessions before first `sessions_send` or gateway send, so agent-to-agent messages no longer fail when the target agent has not started yet.
- gateway: pass Talk session scope to resolver [AI]. (#81379) Thanks @pgondhi987.
- Gateway protocol: require v4 clients and stream explicit chat `deltaText`/`replace` frames so SDK clients can consume assistant updates without local diffing. (#80725) Thanks @samzong.
- GitHub Copilot: exchange OAuth tokens for Copilot API tokens on image understanding requests and route Gemini image payloads through Chat Completions, fixing Copilot Gemini image descriptions. (#80393, #80442) Thanks @afunnyhy.
- Gateway: hide pending Node pairing commands, capabilities, and permissions until approval, and refresh the live approved surface when pairings change. (#80741) Thanks @samzong.
- Plugins/Feishu/WhatsApp/Line: enforce inbound media size caps while reading download streams, avoiding full buffering of oversized attachments. (#81044, #81050) Thanks @samzong.
- Plugins/install: limit install-time code safety scans to plugin-owned runtime entrypoints while keeping dependency manifest denylist checks, so trusted packages with large dependency trees no longer get blocked or warned on third-party runtime internals.
- Config: serialize and retry semantic config mutations centrally, so concurrent commands can rebase safe changes instead of clobbering or hand-rolling command-local retry loops. (#76601)
- Require approval for setup-code device pairing [AI]. (#81292) Thanks @pgondhi987.
- Plugins/install: preserve third-party peer dependencies in the managed npm root when later plugin installs or updates recalculate the shared dependency tree. Thanks @shakkernerd.
- Plugins/memory: prefer the npm-installed memory-lancedb plugin over the bundled fallback during duplicate resolution, keeping Active Memory's `memory_recall` tool visible after managed installs. Fixes #81193. Thanks @julio-arcila.
- Plugins/uninstall: prune managed third-party peer dependencies after their owning npm plugin is removed, without blocking plugin cleanup on peer-prune failures.
- Docker: pin setup-time container paths so stale host `.env` OpenClaw paths cannot leak into Linux containers. Fixes #80381. (#81105) Thanks @brokemac79.
- Channels/WeCom: refresh the official onboarding install to `@wecom/wecom-openclaw-plugin@2026.5.7` and update existing managed npm installs instead of failing on the package directory. Fixes #79884. (#80390) Thanks @brokemac79.
- Anthropic: reseed Claude CLI fresh-session retries from bounded OpenClaw transcript history after session rotation, preventing conversation amnesia. Fixes #80905. (#80934) Thanks @bitloi.
- Require explicit browser device pairing [AI]. (#81289) Thanks @pgondhi987.
- Require Control UI pairing before proxy-scoped access [AI]. (#81288) Thanks @pgondhi987.
- Installer: honor `--version` for git installs and install from the checked-in lockfile, preventing recent dependency pins from tripping pnpm's minimum-release-age gate during tag installs.
- Agents: deliver same-process subagent completion handoffs through the in-process agent dispatcher instead of opening a Gateway RPC loopback.
- Harden trusted-proxy source validation [AI]. (#81290) Thanks @pgondhi987.
- Agents: add permissive item schemas to array tool parameters before provider submission, preventing OpenAI-compatible schema validation from rejecting plugin tools that omit `items`. Fixes #81175. (#81217) Thanks @JARVIS-Glasses.
- Agents: escalate LLM idle watchdog timeouts through profile rotation and configured model fallback instead of leaving agent turns stuck after a silent model stream. Fixes #76877. (#80449) Thanks @jimdawdy-hub.
- Discord voice: treat OpenAI Realtime startup auth failures as fatal, suppress duplicate realtime error logs, and stop autoJoin from retrying the same broken voice channel until credentials are fixed.
- ACPX: stop forwarding unsupported timeout config options to Claude ACP while preserving OpenClaw's own turn timeout. (#80812) Thanks @sxxtony.
- Session transcripts: redact sensitive message content in the centralized JSONL append path so CLI turns, gateway transcript injection, transcript mirrors, and guarded tool results use the same configured redaction behavior. Fixes #73565. Refs #73563. (#79645) Thanks @Ziy1-Tan.
- Channels/iMessage: ignore Apple link-preview plugin payload attachments when users paste URLs, keeping the URL text while avoiding phantom media context. (#79374) Thanks @homer-byte.
- Telegram: detect polling stalls from `getUpdates` liveness only, so outbound API calls no longer mask dead inbound polling; log polling-cycle starts after transport rebuilds. Fixes #78473.
- fix: scan plugin runtime entries during install [AI]. (#80998) Thanks @pgondhi987.
- fix(plugins): scan installed dependency runtime code [AI]. (#81066) Thanks @pgondhi987.
- Inherit tool restrictions for delegated sessions [AI]. (#80979) Thanks @pgondhi987.
- Telegram: discard legacy long-poll update offsets that cannot be tied to the current bot token, so token rotation no longer leaves bots silently skipping new messages. (#80671) Thanks @sxxtony.
- browser: enforce navigation checks for act interactions [AI]. (#81070) Thanks @pgondhi987.
- Validate node exec event provenance [AI]. (#81071) Thanks @pgondhi987.
- Gateway: keep active reply runs visible to stuck-session diagnostics and clear no-active-work recovery state, preventing stale queued lanes after compaction or tool failures. Fixes #80677. (#81302)
- Codex app-server: rotate incompatible context-engine-managed native threads so Lossless-managed sessions do not resume stale hidden Codex history. (#81223) Thanks @jalehman.
- Codex cron: execute scheduled command-style automation payloads before workspace bootstrap or memory review, preserving existing isolated cron jobs after Codex harness migration. (#81510) Thanks @jalehman.
- Plugin LLM completions: honor Codex agent-runtime policy for canonical OpenAI model refs, so context-engine summarizers can use Codex OAuth instead of requiring direct `OPENAI_API_KEY` auth. (#81511) Thanks @jalehman.
- Gateway/OpenAI HTTP: return OpenAI-compatible 400 errors for invalid sampling params and provider validation failures instead of collapsing them to 500s. (#81275) Thanks @Lellansin.
- Telegram: publish plugin and skill command description localizations to native command menus while filtering unsupported locale codes and preserving Telegram command limits. (#81351) Thanks @jzakirov.
- Limit hook CLI tool authority [AI]. (#81065) Thanks @pgondhi987.
- Require admin scope for node device token management [AI]. (#81067) Thanks @pgondhi987.
- Restrict chat sender allowlist matching [AI]. (#80898) Thanks @pgondhi987.
- Update: suppress the false newer-config warning during restart health probing after an update handoff, while keeping future-version mutation guards intact. (#78652)
- Sessions: redact persisted tool result detail metadata before writing transcripts so diagnostic secrets do not survive tool output redaction. (#80444) Thanks @nimbleenigma.
- Codex runtime: allow the official installed `@openclaw/codex` package to use its private task-runtime and MCP projection SDK helpers, fixing `MODULE_NOT_FOUND` during migrated OpenAI/Codex beta runs.
- Codex migration: make Enter activate the highlighted checkbox row before continuing, so `Skip for now` and bulk-selection rows work even when planned items start preselected.
- Codex harness: keep auth-profile-backed media tools such as `image_generate` available when OpenAI auth lives in the agent's auth-profile store instead of environment variables.
- WhatsApp/install: allow Baileys' pinned libsignal git subdependency under pnpm 11 so source installs and local checks can complete.
- Require auth for sandbox browser CDP relay [AI]. (#81002) Thanks @pgondhi987.
- fix: detect carried exec command forms [AI]. (#81000) Thanks @pgondhi987.
- Reject truncated exec approval commands [AI]. (#81001) Thanks @pgondhi987.
- Enforce inline shell wrapper payload matching [AI]. (#80978) Thanks @pgondhi987.
- fix(node-pairing): replace changed pending requests [AI]. (#80894) Thanks @pgondhi987.
- Rate limit Google Chat webhook requests [AI]. (#80974) Thanks @pgondhi987.
- Docker: mount the auth-profile secret key directory so OAuth-backed auth profiles survive container rebuilds. (#80991)
- Onboarding: accept Codex auth profiles for canonical OpenAI model checks, avoiding false missing-auth warnings. (#80913) Thanks @rubencu.
- fix(feishu): normalize webhook rate-limit client keys [AI]. (#80975) Thanks @pgondhi987.
- fix(auth): prevent bootstrap pairing scope changes [AI]. (#80976) Thanks @pgondhi987.
- Validate Control UI loopback retry endpoints [AI]. (#80900) Thanks @pgondhi987.
- Harden exported markdown link rendering [AI]. (#80902) Thanks @pgondhi987.
- fix(gateway): honor minimal discovery mode for wide-area DNS-SD [AI]. (#80903) Thanks @pgondhi987.
- slack: enforce reaction notification policy [AI]. (#80907) Thanks @pgondhi987.
- Enforce gateway command scopes by caller context [AI]. (#80891) Thanks @pgondhi987.
- Telegram/groups: in single-account setups, treat an explicit empty `accounts.<id>.groups: {}` map the same as undefined so the root `channels.telegram.groups` allowlist still applies, instead of silently dropping every group update under the default `groupPolicy: "allowlist"`. Multi-account semantics are unchanged so per-account explicit-empty groups still scope-disable a single account without affecting siblings; the explicit way to block all groups for any account remains `groupPolicy: "disabled"`. Fixes #79427. (#81030) Thanks @kinjitakabe.
- Codex (app-server): project user-configured `mcp.servers` into new Codex thread configs, matching the codex-cli runtime's existing `-c mcp_servers=...` behavior so app-server-runtime agents see the same user MCP servers the CLI runtime already exposes. Plugin-curated apps remain attached via the separate `apps` config patch. Fixes #80814. Thanks @kinjitakabe.
- Enforce Slack plugin approval button authorization [AI]. (#80899) Thanks @pgondhi987.
- Recognize PowerShell -ec inline commands [AI]. (#80893) Thanks @pgondhi987.
- fix(qqbot): authorize approval button callbacks [AI]. (#80892) Thanks @pgondhi987.
- Telegram: render supported HTML tags in streamed and durable replies instead of showing literal markup. (#80977)
- Scrub streamable MCP redirect headers [AI]. (#80906) Thanks @pgondhi987.
- fix(memory-wiki): require admin scope for ingest [AI]. (#80897) Thanks @pgondhi987.
- memory-wiki: require write scope for Obsidian search [AI]. (#80904) Thanks @pgondhi987.
- WhatsApp/install: allow Baileys' pinned libsignal git subdependency under pnpm 11 so source installs and local checks can complete.
- WhatsApp: externalize the channel as a ClawHub/npm plugin outside the core npm runtime bundle, and bump Baileys to `7.0.0-rc11` so libsignal resolves from the registry instead of a GitHub tarball.
- WhatsApp: keep optional audio decoding dependencies local to the external plugin so the core npm install no longer pulls WhatsApp-only media helpers.
- Build: skip copied metadata for bundled plugins that are excluded from build entries, preventing update/status rebuilds from advertising missing QQ Bot runtime files. (#80925)
- Control UI/sessions: nest subagent sessions under their parent session in the session picker dropdown using a visual `└─ ` prefix, making the parent-child relationship clear. Fixes #77628. (#78623) Thanks @chinar-amrutkar.
- Auto-reply: surface a visible error when the configured model backend fails and fallback produces no visible reply, while preserving intentional silent turns and side-effect-only deliveries. (#80917) Thanks @dutifulbob.
- Agents/exec: skip redundant heartbeat wake-ups for subagent session exec completions, preventing spurious LLM invocations on parent sessions. Fixes #66748. (#66749) Thanks @ggzeng.
- Provider streams: keep OpenAI-compatible SSE and JSON fallback streams draining across split chunks and fail Azure Responses streams with a bounded first-event diagnostic instead of stalling. Refs #80926. (#80927) Thanks @galiniliev and @CaptainTimon.
- Agents: rewrite generic provider internal errors with support request IDs into user-friendly transient error copy. (#49401) Thanks @y471823206.
- WhatsApp: finish handling pending debounced inbound messages before closing the socket. (#81246) Thanks @mcaxtr.
- CLI/commitments: write `--json` output to stdout instead of diagnostic logs so automation can parse commitment list and dismiss results. (#81215) Thanks @giodl73-repo.
- Update: allow pnpm GitHub-source OpenClaw updates to approve the OpenClaw package build, so source installs complete their prepare/prepack lifecycle. (#81294) Thanks @fuller-stack-dev.
- Telegram: preserve supported HTML tags in visible replies and durable mirrors so formatted messages render correctly instead of degrading to escaped text. (#80977) Thanks @obviyus.
- Plugins/runtime: attribute deprecated runtime config load/write warnings to the plugin id and source that triggered them so logs and plugin doctor runs are actionable. Refs #81394. (#81425) Thanks @BKF-Gitty.
- Agents/cron: honor a cron payload's explicit `timeoutSeconds` for the LLM idle watchdog even when it numerically equals `agents.defaults.timeoutSeconds`, preserving explicit per-run timeout intent and preventing stalled streaming replies from being cut to the implicit 120s cap. (#79426) Thanks @legolaz8451.
- Codex app-server: keep the short post-tool completion watchdog armed across dynamic tool completion bookkeeping so embedded Codex runs fail fast and release their session lane when Codex goes quiet after a tool result. (#81697) Thanks @mbelinky.
### Changes
- Gateway/OpenAI HTTP: honor `max_completion_tokens` and `max_tokens` on inbound `/v1/chat/completions` requests so client-provided token caps reach the upstream provider via `streamParams.maxTokens`, with `max_completion_tokens` taking precedence when both are sent. Thanks @Lellansin.
- Models/OpenAI CLI auth: make `openclaw models auth login --provider openai` start the ChatGPT/Codex account login by default, while `--method api-key` remains the explicit OpenAI API-key setup path.
- Google/Gemini: normalize retired Gemini 3 Pro Preview ids inside explicit SDK OAuth auth-result config patches, so provider helpers emit `google/gemini-3.1-pro-preview` for Gemini 3.1 testing.
- Google/Gemini: normalize retired Gemini 3 Pro Preview ids inside SDK OAuth auth-result default config patches, so helper-built provider auth flows emit `google/gemini-3.1-pro-preview` for Gemini 3.1 testing.
- Google/Gemini: normalize retired Gemini 3 Pro Preview ids returned by direct `openclaw models auth login --set-default` provider auth flows before writing config, so Gemini testing targets `google/gemini-3.1-pro-preview`.
- Google/Gemini: normalize retired Gemini 3 Pro Preview ids in per-agent config defaults and auth patches, so agent-specific emitted config keeps targeting `google/gemini-3.1-pro-preview`.
- Google/Gemini: normalize retired Gemini 3 Pro Preview ids in provider catalog rows when API-key onboarding only reapplies the agent default, so emitted config keeps testing `google/gemini-3.1-pro-preview`.
- Google/Gemini: normalize retired Gemini 3 Pro Preview ids in `config set` mutation output for agent overrides and provider catalog rows, so current config emits `google/gemini-3.1-pro-preview`.
- Google/Gemini: canonicalize provider-qualified retired Gemini 3 Pro Preview refs during Google forward-compatible model resolution, so emitted config uses `google/gemini-3.1-pro-preview` for Gemini 3.1 testing.
- Google/Gemini: normalize proxy-prefixed retired Gemini 3 Pro Preview catalog rows, so emitted configs use `google/gemini-3.1-pro-preview` for Gemini 3.1 testing.
- Google/Gemini: normalize retired Gemini 3 Pro Preview ids inside per-agent model overrides before writing config, so agent-specific config emits `google/gemini-3.1-pro-preview` for Gemini 3.1 testing.
- Google/Gemini: normalize retired Gemini 3 Pro Preview ids in subagent, heartbeat, compaction, and subagent-tool model config during writes, so current config keeps emitting `google/gemini-3.1-pro-preview`.
- Docs/subagents: document `agents.defaults.subagents.announceTimeoutMs` in the sub-agent and configuration references. (#75509) Thanks @akrimm702.
- Cron: add direct `cron.get`, `openclaw cron get <id>`, and agent-tool `get` support for inspecting one stored cron job by id. (#75117) Thanks @samzong.
- Agents/tools: add per-sender tool policies with canonical channel-scoped sender keys, so operators can restrict dangerous tools by requester identity across global, agent, group, core, bundled, and plugin tool surfaces. (#66933) Thanks @JerranC.
@@ -85,6 +217,7 @@ Docs: https://docs.openclaw.ai
- Agents/compaction: read post-compaction AGENTS.md refresh context from the queued run workspace instead of the runner process cwd, so CLI-backed follow-up turns re-inject the correct workspace startup rules after compaction. Fixes #70541. (#75532) Thanks @vyctorbrzezowski.
- Agents/read tool: treat positive offsets beyond EOF as empty ranges instead of surfacing the upstream read error, so stale pagination cursors no longer crash tool calls while unrelated read failures still fail loud. Fixes #62466. (#75536) Thanks @vyctorbrzezowski.
- Google/Gemini: normalize retired Gemini 3 Pro Preview refs left in Google API-key onboarding model allowlists and fallbacks, so setup-emitted config keeps testing `google/gemini-3.1-pro-preview` instead of `google/gemini-3-pro-preview`.
- Telegram/context: bound selected topic context to the active session so messages from before `/new` or `/reset` are not replayed into later turns. (#80848) Thanks @VACInc.
- Google/Gemini: normalize retired nested Gemini 3 Pro Preview ids when resolving exact configured proxy-provider refs, so `kilocode/google/gemini-3-pro-preview` resolves to `kilocode/google/gemini-3.1-pro-preview` for Gemini 3.1 testing.
- CLI: strip generic OSC terminal escape payloads from sanitized output fields, preventing clipboard/title escape bodies from leaking into commitment tables and other terminal-safe text. Thanks @shakkernerd.
- Codex app-server: match connector-backed plugin approval elicitations by stable connector id so enabled destructive actions no longer fall through to display-name-only rejection.
@@ -221,6 +354,7 @@ Docs: https://docs.openclaw.ai
- Control UI/config: remove plugin allowlist entries that the form auto-added when a plugin enable toggle is reverted before saving, so reverting the visible toggle clears dirty state without persisting unintended allowlist changes. (#78329) Thanks @samzong.
- Gateway/mobile: reuse bootstrap-issued device-token scopes on handoff reconnects and surface device-token scope mismatches separately from token mismatches while preserving full shared-token dashboard/native sessions. Fixes #79292. Thanks @BunsDev.
- Media/host-read: allow buffer-verified gzip, tar, and 7z archives in the shared host-local media validator alongside ZIP and document attachments.
- Plugins/install: retry managed npm plugin installs without npm alias overrides after npm's `Invalid comparator: npm:` failure, so older npm versions can install official plugins instead of aborting. (#80539) Thanks @rubencu.
- Plugins/doctor: invalidate persisted plugin registry snapshots when plugin diagnostics point at deleted source paths, so `openclaw doctor` stops repeating stale warnings after a local extension is replaced by a managed npm plugin. Fixes #80087. (#80134) Thanks @hclsys.
- Doctor/OpenAI Codex: preserve Codex auth intent when auto-repairing legacy `openai-codex/*` model refs to canonical `openai/*` by adding provider/model-scoped Codex runtime policy, preventing repaired configs from falling through to direct OpenAI API-key auth. Fixes #78533 and #78570. Thanks @superck110 and @Azmodump.
- CLI/agents: surface durable message delivery status from `sendDurableMessageBatch` in `deliverAgentCommandResult` and `openclaw agent --json --deliver`, preserving suppressed hook outcomes as terminal no-retry results while exposing partial and failed sends for automation. Supersedes #53961 and #57755. Thanks @Kaspre.
@@ -322,6 +456,7 @@ Docs: https://docs.openclaw.ai
- Agents: abort generic repeated no-progress tool loops at the critical threshold when identical calls keep returning identical outcomes. (#80668) Thanks @frankekn.
- Exec approvals: omit generated command highlights for non-POSIX Windows and shell-wrapper approval commands until those command languages have native highlighting support. (#80566) Thanks @jesse-merhi.
- Telegram: keep verbose tool progress and result drafts separate from the final assistant answer so tool output no longer blends into the final Telegram message. (#80294) Thanks @jalehman.
- Plugin SDK/Windows: enable the native require fast path for root `openclaw/plugin-sdk` dist aliases instead of forcing Jiti transforms. (#80878) Thanks @medns.
## 2026.5.9
@@ -579,7 +714,7 @@ Docs: https://docs.openclaw.ai
- Control UI/chat: hide retired and non-public Google Gemini model IDs from chat model catalogs and route the bare `gemini-3-pro` alias to Gemini 3.1 Pro Preview instead of the shut-down Gemini 3 Pro Preview. Thanks @BunsDev.
- CLI/infer: canonicalize case-only catalog model refs in `infer model run --model` so mixed-case provider/model strings resolve to the canonical catalog entry instead of failing with `Unknown model`. (#78940) Thanks @ai-hpc.
- CLI/infer: allow explicit local `infer model run --model <provider/model>` probes to use exact bundled static catalog rows before the provider is written to config, surfacing missing credentials as auth errors instead of `Unknown model`.
- CLI/install: refuse state-mutating OpenClaw CLI runs as root by default, keep an explicit `OPENCLAW_ALLOW_ROOT=1` escape hatch for intentional root/container use, and update DigitalOcean setup guidance to run OpenClaw as a non-root user. Fixes #67478. Thanks @Jerry-Xin and @natechicago.
- CLI/install: revert the beta-only global root-refusal guard so existing root-managed VPS installs keep working; the DigitalOcean split-brain protection will move to a narrower image/install-specific path. Refs #67478 and #67509. Thanks @vincentkoc.
- Auto-reply/media: resolve `scp` from `PATH` when staging sandbox media so nonstandard OpenSSH installs can copy remote attachments.
- Agents/PI: route PI-native OpenAI-compatible default streams through OpenClaw boundary-aware transports so local-compatible model runs keep API-key injection and transport policy.
- Gateway/media: require authenticated owner or admin context for managed outgoing image bytes instead of trusting requester-session headers.
@@ -659,6 +794,7 @@ Docs: https://docs.openclaw.ai
- Discord/groups: tell Discord-channel agents to wrap bare URLs as `<https://example.com>` so link previews do not expand into uninvited embeds. (#78614)
- Agents/fallback: fail fast on session write-lock timeouts instead of trying fallback models for local file contention. Fixes #66646. Thanks @sallyom.
- Browser/SSRF: stop closing user-owned Chrome tabs when a read-only operation (snapshot/screenshot/interactions) is rejected by the SSRF guard — only OpenClaw-initiated navigations now close on policy denial. Thanks @scotthuang.
- iMessage: stage native inbound attachments into OpenClaw-managed media and convert HEIC/HEIF images to JPEG before dispatch, so image tools can read photos sent over native iMessage without requiring BlueBubbles.
- Agents/Gateway: throttle and cap live exec command-output events so noisy tool runs cannot flood Gateway WebSocket clients or starve RPC handling. (#78645) Thanks @joshavant.
- Memory Wiki: skip empty and whitespace-only source pages when refreshing generated Related blocks, preventing blank pages from being rewritten into Related-only stubs. Fixes #78121. Thanks @amknight.
- Telegram: keep duplicate message-tool-only Codex turns from posting generic silent-reply fallback text, so private finals stay private after inbound dedupe. Thanks @rubencu.

View File

@@ -116,20 +116,25 @@ ENV OPENCLAW_PREFER_PNPM=1
RUN pnpm_config_verify_deps_before_run=false pnpm ui:build
RUN pnpm_config_verify_deps_before_run=false pnpm qa:lab:build
# Prune dev dependencies and strip build-only metadata before copying
# Reinstall production dependencies and strip build-only metadata before copying
# runtime assets into the final image.
FROM build AS runtime-assets
ARG OPENCLAW_EXTENSIONS
ARG OPENCLAW_BUNDLED_PLUGIN_DIR
RUN --mount=type=cache,id=openclaw-pnpm-store,target=/root/.local/share/pnpm/store,sharing=locked \
CI=true pnpm prune --prod \
--config.offline=true \
RUN --mount=type=cache,id=openclaw-pnpm-runtime-store,target=/root/.local/share/pnpm/store,sharing=locked \
echo "==> runtime-assets: install prod dependencies" && \
rm -rf node_modules && \
NODE_OPTIONS=--max-old-space-size=2048 pnpm install --prod --frozen-lockfile --ignore-scripts \
--config.supportedArchitectures.os=linux \
--config.supportedArchitectures.cpu="$(node -p 'process.arch')" \
--config.supportedArchitectures.libc=glibc && \
echo "==> runtime-assets: refresh bundled plugin registry" && \
node scripts/postinstall-bundled-plugins.mjs && \
echo "==> runtime-assets: prune non-selected plugin dist" && \
OPENCLAW_EXTENSIONS="$OPENCLAW_EXTENSIONS" node scripts/prune-docker-plugin-dist.mjs && \
echo "==> runtime-assets: remove dist type and sourcemap files" && \
find dist -type f \( -name '*.d.ts' -o -name '*.d.mts' -o -name '*.d.cts' -o -name '*.map' \) -delete && \
echo "==> runtime-assets: check package dist imports" && \
node scripts/check-package-dist-imports.mjs /app
# ── Runtime base image ──────────────────────────────────────────

View File

@@ -1,4 +1,4 @@
package ai.openclaw.app.gateway
const val GATEWAY_PROTOCOL_VERSION = 4
const val GATEWAY_MIN_PROTOCOL_VERSION = 3
const val GATEWAY_MIN_PROTOCOL_VERSION = 4

View File

@@ -3,7 +3,7 @@
import Foundation
public let GATEWAY_PROTOCOL_VERSION = 4
public let GATEWAY_MIN_PROTOCOL_VERSION = 3
public let GATEWAY_MIN_PROTOCOL_VERSION = 4
private struct GatewayAnyCodingKey: CodingKey, Hashable {
let stringValue: String
@@ -6224,12 +6224,138 @@ public struct ChatInjectParams: Codable, Sendable {
}
}
public struct ChatEvent: Codable, Sendable {
public struct ChatDeltaEvent: Codable, Sendable {
public let runid: String
public let sessionkey: String
public let spawnedby: String?
public let seq: Int
public let state: AnyCodable
public let state: String
public let message: AnyCodable?
public let deltatext: String
public let replace: Bool?
public let usage: AnyCodable?
public init(
runid: String,
sessionkey: String,
spawnedby: String?,
seq: Int,
state: String,
message: AnyCodable?,
deltatext: String,
replace: Bool?,
usage: AnyCodable?)
{
self.runid = runid
self.sessionkey = sessionkey
self.spawnedby = spawnedby
self.seq = seq
self.state = state
self.message = message
self.deltatext = deltatext
self.replace = replace
self.usage = usage
}
private enum CodingKeys: String, CodingKey {
case runid = "runId"
case sessionkey = "sessionKey"
case spawnedby = "spawnedBy"
case seq
case state
case message
case deltatext = "deltaText"
case replace
case usage
}
}
public struct ChatFinalEvent: Codable, Sendable {
public let runid: String
public let sessionkey: String
public let spawnedby: String?
public let seq: Int
public let state: String
public let message: AnyCodable?
public let usage: AnyCodable?
public let stopreason: String?
public init(
runid: String,
sessionkey: String,
spawnedby: String?,
seq: Int,
state: String,
message: AnyCodable?,
usage: AnyCodable?,
stopreason: String?)
{
self.runid = runid
self.sessionkey = sessionkey
self.spawnedby = spawnedby
self.seq = seq
self.state = state
self.message = message
self.usage = usage
self.stopreason = stopreason
}
private enum CodingKeys: String, CodingKey {
case runid = "runId"
case sessionkey = "sessionKey"
case spawnedby = "spawnedBy"
case seq
case state
case message
case usage
case stopreason = "stopReason"
}
}
public struct ChatAbortedEvent: Codable, Sendable {
public let runid: String
public let sessionkey: String
public let spawnedby: String?
public let seq: Int
public let state: String
public let message: AnyCodable?
public let stopreason: String?
public init(
runid: String,
sessionkey: String,
spawnedby: String?,
seq: Int,
state: String,
message: AnyCodable?,
stopreason: String?)
{
self.runid = runid
self.sessionkey = sessionkey
self.spawnedby = spawnedby
self.seq = seq
self.state = state
self.message = message
self.stopreason = stopreason
}
private enum CodingKeys: String, CodingKey {
case runid = "runId"
case sessionkey = "sessionKey"
case spawnedby = "spawnedBy"
case seq
case state
case message
case stopreason = "stopReason"
}
}
public struct ChatErrorEvent: Codable, Sendable {
public let runid: String
public let sessionkey: String
public let spawnedby: String?
public let seq: Int
public let state: String
public let message: AnyCodable?
public let errormessage: String?
public let errorkind: AnyCodable?
@@ -6241,7 +6367,7 @@ public struct ChatEvent: Codable, Sendable {
sessionkey: String,
spawnedby: String?,
seq: Int,
state: AnyCodable,
state: String,
message: AnyCodable?,
errormessage: String?,
errorkind: AnyCodable?,
@@ -6373,6 +6499,43 @@ public enum PluginsSessionActionResult: Codable, Sendable {
}
}
public enum ChatEvent: Codable, Sendable {
case delta(ChatDeltaEvent)
case final(ChatFinalEvent)
case aborted(ChatAbortedEvent)
case error(ChatErrorEvent)
private enum CodingKeys: String, CodingKey {
case discriminator = "state"
}
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
let discriminator = try container.decode(String.self, forKey: .discriminator)
switch discriminator {
case "delta": self = try .delta(ChatDeltaEvent(from: decoder))
case "final": self = try .final(ChatFinalEvent(from: decoder))
case "aborted": self = try .aborted(ChatAbortedEvent(from: decoder))
case "error": self = try .error(ChatErrorEvent(from: decoder))
default:
throw DecodingError.dataCorruptedError(
forKey: .discriminator,
in: container,
debugDescription: "Unknown ChatEvent discriminator value"
)
}
}
public func encode(to encoder: Encoder) throws {
switch self {
case .delta(let value): try value.encode(to: encoder)
case .final(let value): try value.encode(to: encoder)
case .aborted(let value): try value.encode(to: encoder)
case .error(let value): try value.encode(to: encoder)
}
}
}
public enum GatewayFrame: Codable, Sendable {
case req(RequestFrame)
case res(ResponseFrame)

View File

@@ -66,7 +66,6 @@ const rootBundledPluginRuntimeDependencies = [
"@slack/bolt",
"@slack/types",
"@slack/web-api",
"audio-decode",
"grammy",
"linkedom",
"minimatch",

View File

@@ -38,6 +38,7 @@ services:
volumes:
- ${OPENCLAW_CONFIG_DIR:-${HOME:-/tmp}/.openclaw}:/home/node/.openclaw
- ${OPENCLAW_WORKSPACE_DIR:-${HOME:-/tmp}/.openclaw/workspace}:/home/node/.openclaw/workspace
- ${OPENCLAW_AUTH_PROFILE_SECRET_DIR:-${HOME:-/tmp}/.openclaw-auth-profile-secrets}:/home/node/.config/openclaw
## Uncomment the lines below to enable sandbox isolation
## (agents.defaults.sandbox). Requires Docker CLI in the image
## (build with --build-arg OPENCLAW_INSTALL_DOCKER_CLI=1) or use
@@ -112,6 +113,7 @@ services:
volumes:
- ${OPENCLAW_CONFIG_DIR:-${HOME:-/tmp}/.openclaw}:/home/node/.openclaw
- ${OPENCLAW_WORKSPACE_DIR:-${HOME:-/tmp}/.openclaw/workspace}:/home/node/.openclaw/workspace
- ${OPENCLAW_AUTH_PROFILE_SECRET_DIR:-${HOME:-/tmp}/.openclaw-auth-profile-secrets}:/home/node/.config/openclaw
stdin_open: true
tty: true
init: true

View File

@@ -1,4 +1,4 @@
f95819d93e9bec5d059440ab54fb4ccb487425cb91d647c8688cd18ef1d4d848 config-baseline.json
3325af3a6292959bb38166e9136c638dce5d2093d2339076742890848088a972 config-baseline.core.json
2b1f93d96441194f9501300666192df1409ccfd304499c9a9a4d5f96834372ce config-baseline.json
0b2ccb0cc75ab874b51e6abdccbf20addf27d1c1327d30c5de203cada645b7ae config-baseline.core.json
ad1d3cb596115d66c21e93de95e229c14c585f0dd4799b4ae3cc29b84761adc6 config-baseline.channel.json
0dac8944a0d51ae96f97e3809907f8a04d08413434a1a1190240f7e13bb11c4d config-baseline.plugin.json

View File

@@ -1,2 +1,2 @@
f26833e053032e3da94025c8a5a8cb62dcddd275797b527440a19be5886a4783 plugin-sdk-api-baseline.json
429fe1d6d119379b914bf84b15705233dc8d2d9e1a8131bb28ea19b19afbe6a0 plugin-sdk-api-baseline.jsonl
65c3bc31cec30b758d4cc560e30358bb80a730daf3002e2e095501b6a3d36ab6 plugin-sdk-api-baseline.json
58929aa8bc43d6be7eab3d56bb1c723a7c6a2de55b55203ae7d05355f94f6a7a plugin-sdk-api-baseline.jsonl

View File

@@ -16,8 +16,8 @@ Text is supported everywhere; media and reactions vary by channel.
- Slack multi-person DMs route as group chats, so group policy, mention
behavior, and group-session rules apply to MPIM conversations.
- WhatsApp setup is install-on-demand: onboarding can show the setup flow before
the plugin package is installed, and the Gateway loads the WhatsApp runtime
only when the channel is actually active.
the plugin package is installed, and the Gateway loads the external
ClawHub/npm plugin only when the channel is actually active.
## Supported channels

View File

@@ -41,6 +41,16 @@ Both transports are production-ready and reach feature parity for messaging, sla
**Pick HTTP Request URLs** when running multiple Gateway replicas behind a load balancer, when outbound WSS is blocked but inbound HTTPS is allowed, or when you already terminate Slack webhooks at a reverse proxy.
</Note>
## Install
Install Slack before configuring the channel:
```bash
openclaw plugins install @openclaw/slack
```
`plugins install` registers and enables the plugin. The plugin still does nothing until you configure the Slack app and channel settings below. See [Plugins](/tools/plugin) for general plugin behavior and install rules.
## Quick setup
<Tabs>
@@ -181,7 +191,7 @@ Both transports are production-ready and reach feature parity for messaging, sla
</CodeGroup>
<Note>
**Recommended** matches the bundled Slack plugin's full feature set: App Home, slash commands, files, reactions, pins, group DMs, and emoji/usergroup reads. Pick **Minimal** when workspace policy restricts scopes — it covers DMs, channel/group history, mentions, and slash commands but drops files, reactions, pins, group-DM (`mpim:*`), `emoji:read`, and `usergroups:read`. See [Manifest and scope checklist](#manifest-and-scope-checklist) for per-scope rationale and additive options like extra slash commands.
**Recommended** matches the Slack plugin's full feature set: App Home, slash commands, files, reactions, pins, group DMs, and emoji/usergroup reads. Pick **Minimal** when workspace policy restricts scopes — it covers DMs, channel/group history, mentions, and slash commands but drops files, reactions, pins, group-DM (`mpim:*`), `emoji:read`, and `usergroups:read`. See [Manifest and scope checklist](#manifest-and-scope-checklist) for per-scope rationale and additive options like extra slash commands.
</Note>
After Slack creates the app:
@@ -383,7 +393,7 @@ openclaw gateway
</CodeGroup>
<Note>
**Recommended** matches the bundled Slack plugin's full feature set; **Minimal** drops files, reactions, pins, group-DM (`mpim:*`), `emoji:read`, and `usergroups:read` for restrictive workspaces. See [Manifest and scope checklist](#manifest-and-scope-checklist) for per-scope rationale.
**Recommended** matches the Slack plugin's full feature set; **Minimal** drops files, reactions, pins, group-DM (`mpim:*`), `emoji:read`, and `usergroups:read` for restrictive workspaces. See [Manifest and scope checklist](#manifest-and-scope-checklist) for per-scope rationale.
</Note>
<Info>

View File

@@ -395,7 +395,7 @@ curl "https://api.telegram.org/bot<bot_token>/getUpdates"
Outbound text uses Telegram `parse_mode: "HTML"`.
- Markdown-ish text is rendered to Telegram-safe HTML.
- Raw model HTML is escaped to reduce Telegram parse failures.
- Supported Telegram HTML tags are preserved; unsupported HTML is escaped.
- If Telegram rejects parsed HTML, OpenClaw retries as plain text.
Link previews are enabled by default and can be disabled with `channels.telegram.linkPreview: false`.

View File

@@ -14,27 +14,19 @@ Status: production-ready via WhatsApp Web (Baileys). Gateway owns linked session
- `openclaw channels login --channel whatsapp` also offers the install flow when
the plugin is not present yet.
- Dev channel + git checkout: defaults to the local plugin path.
- Stable/Beta: uses the npm package `@openclaw/whatsapp` on the current official
release tag.
- Stable/Beta: installs the official `@openclaw/whatsapp` plugin from ClawHub
first, with npm as the fallback.
- The WhatsApp runtime is distributed outside the core OpenClaw npm package so
WhatsApp-specific runtime dependencies stay with the external plugin.
Manual install stays available:
```bash
openclaw plugins install @openclaw/whatsapp
openclaw plugins install clawhub:@openclaw/whatsapp
```
Use the bare package to follow the current official release tag. Pin an exact
version only when you need a reproducible install.
On Windows, the WhatsApp plugin needs Git on `PATH` during npm install because
one of its Baileys/libsignal dependencies is fetched from a git URL. Install
Git for Windows, then restart the shell and rerun the install:
```powershell
winget install --id Git.Git -e
```
Portable Git also works if its `bin` directory is on `PATH`.
Use the bare npm package (`@openclaw/whatsapp`) only when you need the registry
fallback. Pin an exact version only when you need a reproducible install.
<CardGroup cols={3}>
<Card title="Pairing" icon="link" href="/channels/pairing">

View File

@@ -22,6 +22,7 @@ openclaw migrate claude --dry-run
openclaw migrate codex --dry-run
openclaw migrate codex --skill gog-vault77-google-workspace
openclaw migrate codex --plugin google-calendar --dry-run
openclaw migrate codex --plugin google-calendar --verify-plugin-apps --dry-run
openclaw migrate hermes --dry-run
openclaw migrate hermes
openclaw migrate apply codex --yes --skill gog-vault77-google-workspace
@@ -59,6 +60,9 @@ openclaw onboard --import-from hermes --import-source ~/.hermes
<ParamField path="--plugin <name>" type="string">
Select one Codex plugin install item by plugin name or item id. Repeat the flag to migrate multiple Codex plugins. When omitted, interactive Codex migrations show a native Codex plugin checkbox selector and non-interactive migrations keep all planned plugins. This only applies to source-installed `openai-curated` Codex plugins discovered by the Codex app-server inventory.
</ParamField>
<ParamField path="--verify-plugin-apps" type="boolean">
Codex only. Force a fresh source Codex app-server `app/list` traversal before planning native plugin activation. Off by default to keep migration planning fast.
</ParamField>
<ParamField path="--no-backup" type="boolean">
Skip the pre-apply backup. Requires `--force` when local OpenClaw state exists.
</ParamField>
@@ -119,13 +123,15 @@ inventory a specific Codex home.
Use this provider when moving to the OpenClaw Codex harness and you want to
promote useful personal Codex CLI assets deliberately. Local Codex app-server
launches use per-agent `CODEX_HOME` and `HOME` directories, so they do not read
your personal Codex CLI state by default.
launches use a per-agent `CODEX_HOME`, so they do not read your personal Codex
CLI state by default, while subprocesses still inherit the normal process
`HOME` unless the app-server launch explicitly overrides it.
Running `openclaw migrate codex` in an interactive terminal previews the full
plan, then opens checkbox selectors before the final apply confirmation. Skill
copy items are prompted first. Use `Toggle all on` or `Toggle all off` for bulk
selection; planned skills start checked, conflict skills start unchecked, and
selection. Press Space to toggle rows, or press Enter to activate the highlighted
row and continue. Planned skills start checked, conflict skills start unchecked, and
`Skip for now` skips skill copies for this run while still continuing to plugin
selection. When source-installed curated Codex plugins are migratable and
`--plugin` was not supplied, migration then prompts for native Codex plugin
@@ -156,17 +162,36 @@ openclaw migrate apply codex --yes --plugin google-calendar
- Personal AgentSkills under `$HOME/.agents/skills`, copied into the current
OpenClaw agent workspace when you want per-agent ownership.
- Source-installed `openai-curated` Codex plugins discovered through Codex
app-server `plugin/list`. Apply calls app-server `plugin/install` for each
selected plugin, even if the target app-server already reports that plugin as
installed and enabled. Migrated Codex plugins are usable only in sessions that
select the native Codex harness; they are not exposed to Pi, normal OpenAI
provider runs, ACP conversation bindings, or other harnesses.
app-server `plugin/list`. Planning reads `plugin/read` for each enabled
installed plugin. App-backed plugins require the source Codex app-server
account response to be a ChatGPT subscription account; non-ChatGPT or missing
account responses are skipped with `codex_subscription_required`. By default,
migration does not call source `app/list`, so app-backed plugins that pass the
account gate are planned without source app accessibility verification, and
account lookup transport failures skip with `codex_account_unavailable`. Pass
`--verify-plugin-apps` when you want migration to force a fresh source
`app/list` snapshot and require every owned app to be present, enabled, and
accessible before planning native activation. In that mode, account lookup
transport failures fall through to source app inventory verification. The
source app inventory snapshot is kept in memory for the current process; it
is not written to migration output or target config. Disabled plugins,
unreadable plugin details, subscription-gated source accounts, and, when
verification is requested, missing apps, disabled apps, inaccessible apps, or
source app inventory failures become manual skipped items with typed reasons
instead of target config entries.
Apply calls app-server `plugin/install` for each selected eligible plugin,
even if the target app-server already reports that plugin as installed and
enabled. Migrated Codex plugins are usable only in sessions that select the
native Codex harness; they are not exposed to Pi, normal OpenAI provider runs,
ACP conversation bindings, or other harnesses.
### Manual-review Codex state
Codex `config.toml`, native `hooks/hooks.json`, non-curated marketplaces, and
cached plugin bundles that are not source-installed curated plugins are not
activated automatically. They are copied or reported in the migration report for
Codex `config.toml`, native `hooks/hooks.json`, non-curated marketplaces, cached
plugin bundles that are not source-installed curated plugins, and source-installed
plugins that fail the source subscription gate are not activated automatically.
When `--verify-plugin-apps` is set, plugins that fail the source app-inventory
gate are also skipped. They are copied or reported in the migration report for
manual review.
For migrated source-installed curated plugins, apply writes:
@@ -178,7 +203,13 @@ For migrated source-installed curated plugins, apply writes:
`pluginName` for each selected plugin
Migration never writes `plugins["*"]` and never stores local marketplace cache
paths. Auth-required installs are reported on the affected plugin item with
paths. Source-side subscription failures are reported on manual items with typed
reasons such as `codex_subscription_required`, `codex_account_unavailable`,
`plugin_disabled`, or `plugin_read_unavailable`. With `--verify-plugin-apps`,
source app-inventory failures can also appear as `app_inaccessible`,
`app_disabled`, `app_missing`, or `app_inventory_unavailable`. Skipped plugins
are not written to target config.
Target-side auth-required installs are reported on the affected plugin item with
`status: "skipped"`, `reason: "auth_required"`, and sanitized app identifiers.
Their explicit config entries are written disabled until you reauthorize and
enable them. Other install failures are item-scoped `error` results.

View File

@@ -133,7 +133,7 @@ is available, then fall back to `latest`.
This CLI flag applies to plugin install/update flows. Gateway-backed skill dependency installs use the matching `dangerouslyForceUnsafeInstall` request override, while `openclaw skills install` remains a separate ClawHub skill download/install flow.
If a plugin you published on ClawHub is blocked by a registry scan, use the publisher steps in [ClawHub](/clawhub/security).
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` only affects installs on your own machine; it does not ask ClawHub to rescan the plugin or make a blocked release public.
</Accordion>
<Accordion title="Hook packs and npm specs">

View File

@@ -1353,7 +1353,6 @@
"pages": [
"clawhub/api",
"clawhub/http-api",
"clawhub/security",
"clawhub/acceptable-usage"
]
}

View File

@@ -201,6 +201,10 @@ Set `stream: true` to receive Server-Sent Events (SSE):
- `tool_choice`: `"auto"`, `"none"`
- `messages[*].role: "tool"` follow-up turns
- `messages[*].tool_call_id` for binding tool results back to a prior tool call
- `max_completion_tokens`: number; per-call cap for total completion tokens (reasoning tokens included). Current OpenAI Chat Completions field name; preferred when both `max_completion_tokens` and `max_tokens` are sent.
- `max_tokens`: number; legacy alias accepted for backwards compatibility. Ignored when `max_completion_tokens` is also present.
When either field is set, the value is forwarded to the upstream provider via the agent stream-param channel. The actual wire field name sent to the upstream provider is chosen by the provider transport: `max_completion_tokens` for OpenAI-family endpoints, and `max_tokens` for providers that only accept the legacy name (such as Mistral and Chutes).
### Unsupported variants

View File

@@ -18,6 +18,7 @@ and an optional `mirror` workspace mode.
## Prerequisites
- OpenShell plugin installed (`openclaw plugins install @openclaw/openshell-sandbox`)
- The `openshell` CLI installed and on `PATH` (or set a custom path via
`plugins.entries.openshell.config.command`)
- An OpenShell account with sandbox access
@@ -25,7 +26,11 @@ and an optional `mirror` workspace mode.
## Quick start
1. Enable the plugin and set the sandbox backend:
1. Install and enable the plugin, then set the sandbox backend:
```bash
openclaw plugins install @openclaw/openshell-sandbox
```
```json5
{

View File

@@ -476,7 +476,9 @@ enumeration of `src/gateway/server-methods/*.ts`.
### Common event families
- `chat`: UI chat updates such as `chat.inject` and other transcript-only chat
events.
events. In protocol v4, delta payloads carry `deltaText`; `message` remains
the cumulative assistant snapshot. Non-prefix replacements set `replace=true`
and use `deltaText` as the replacement text.
- `session.message` and `session.tool`: transcript/event-stream updates for a
subscribed session.
- `sessions.changed`: session index or metadata changed.
@@ -632,8 +634,8 @@ terminal summary, and sanitized error text.
- `PROTOCOL_VERSION` lives in `src/gateway/protocol/version.ts`.
- Clients send `minProtocol` + `maxProtocol`; the server rejects ranges that
do not include its current protocol. Native clients use a v3 lower bound so
additive v4 clients can still reach v3 gateways.
do not include its current protocol. Current clients and servers require
protocol v4.
- Schemas + models are generated from TypeBox definitions:
- `pnpm protocol:gen`
- `pnpm protocol:gen:swift`
@@ -647,7 +649,7 @@ stable across protocol v4 and are the expected baseline for third-party clients.
| Constant | Default | Source |
| ----------------------------------------- | ----------------------------------------------------- | ------------------------------------------------------------------------------------------ |
| `PROTOCOL_VERSION` | `4` | `src/gateway/protocol/version.ts` |
| `MIN_CLIENT_PROTOCOL_VERSION` | `3` | `src/gateway/protocol/version.ts` |
| `MIN_CLIENT_PROTOCOL_VERSION` | `4` | `src/gateway/protocol/version.ts` |
| Request timeout (per RPC) | `30_000` ms | `src/gateway/client.ts` (`requestTimeoutMs`) |
| Preauth / connect-challenge timeout | `15_000` ms | `src/gateway/handshake-timeouts.ts` (config/env can raise the paired server/client budget) |
| Initial reconnect backoff | `1_000` ms | `src/gateway/client.ts` (`backoffMs`) |

View File

@@ -39,6 +39,13 @@ Preview migration from the source Codex home:
openclaw migrate codex --dry-run
```
Use strict source app verification when you want migration to check source app
accessibility before planning native plugin activation:
```bash
openclaw migrate codex --dry-run --verify-plugin-apps
```
Apply the migration when the plan looks right:
```bash
@@ -87,8 +94,19 @@ The integration has three separate states:
- Accessible: Codex app-server confirms the plugin's app entries are available
for the active account and can be mapped to the migrated plugin identity.
Migration is the durable install/eligibility step. Runtime app inventory is the
accessibility check. Codex harness session setup then computes a restrictive
Migration is the durable install/eligibility step. During planning, OpenClaw
reads source Codex `plugin/read` details and checks that the source Codex
app-server account response is a ChatGPT subscription account. Non-ChatGPT or
missing account responses skip app-backed plugins with
`codex_subscription_required`. By default, migration does not call source
`app/list`; app-backed source plugins that pass the account gate are planned
without source app accessibility verification, and account lookup transport
failures skip with `codex_account_unavailable`. With `--verify-plugin-apps`,
migration takes a fresh source `app/list` snapshot and requires every owned app
to be present, enabled, and accessible before planning native activation. In
that mode, account lookup transport failures fall through to the source
app-inventory gate. Runtime app inventory is the target-session accessibility
check after migration. Codex harness session setup then computes a restrictive
thread app config for the enabled and accessible plugin apps.
Thread app config is computed when OpenClaw establishes a Codex harness session
@@ -100,6 +118,12 @@ V1 is intentionally narrow:
- Only `openai-curated` plugins that were already installed in the source Codex
app-server inventory are migration-eligible.
- App-backed source plugins must pass the migration-time subscription gate.
`--verify-plugin-apps` adds the source app-inventory gate. Subscription-gated
accounts plus, in verification mode, inaccessible, disabled, missing source
apps or source app-inventory refresh failures are reported as skipped manual
items instead of enabled config entries. Unreadable plugin details are skipped
before the source app-inventory gate.
- Migration writes explicit plugin identities with `marketplaceName` and
`pluginName`; it does not write local `marketplacePath` cache paths.
- `codexPlugins.enabled` is the global enablement switch.
@@ -111,7 +135,18 @@ V1 is intentionally narrow:
## App inventory and ownership
OpenClaw reads Codex app inventory through app-server `app/list`, caches it for
one hour, and refreshes stale or missing entries asynchronously.
one hour, and refreshes stale or missing entries asynchronously. The cache is
in memory only; restarting the CLI or gateway drops it, and OpenClaw rebuilds it
from the next `app/list` read.
Migration and runtime use separate cache keys:
- Source migration verification uses the source Codex home and source app-server
start options. This runs only when `--verify-plugin-apps` is set, and it
forces a fresh source `app/list` traversal for that planning run.
- Target runtime setup uses the target agent's Codex app-server identity when it
builds the Codex thread app config. Plugin activation invalidates that target
cache key and then force-refreshes it after `plugin/install`.
A plugin app is exposed only when OpenClaw can map it back to the migrated
plugin through stable ownership:
@@ -161,6 +196,27 @@ plugins, while unsafe schemas and ambiguous ownership still fail closed:
needs authentication. The explicit plugin entry is written disabled until you
reauthorize and enable it.
**`app_inaccessible`, `app_disabled`, or `app_missing`:**
migration did not install the plugin because the source Codex app inventory did
not show all owned apps as present, enabled, and accessible while
`--verify-plugin-apps` was set. Reauthorize or enable the app in Codex, then
rerun migration with `--verify-plugin-apps`.
**`app_inventory_unavailable`:** migration did not install the plugin because
strict source app verification was requested and source Codex app inventory
refresh failed. Fix source Codex app-server access or retry without
`--verify-plugin-apps` if you accept the faster account-gated plan.
**`codex_subscription_required`:** migration did not install the app-backed
plugin because the source Codex app-server account was not logged in with a
ChatGPT subscription account. Log in to the Codex app with subscription auth,
then rerun migration.
**`codex_account_unavailable`:** migration did not install the app-backed plugin
because the source Codex app-server account could not be read. Fix source Codex
app-server auth or rerun with `--verify-plugin-apps` if you want source app
inventory to decide eligibility when account lookup fails.
**`marketplace_missing` or `plugin_missing`:** the target Codex app-server
cannot see the expected `openai-curated` marketplace or plugin. Rerun migration
against the target runtime or inspect Codex app-server plugin status.

View File

@@ -51,10 +51,7 @@ uninstall, and publishing commands.
| Plugin | Description | Distribution | Surface |
| ----------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| [alibaba](/plugins/reference/alibaba) | Adds video generation provider support. | `@openclaw/alibaba-provider`<br />included in OpenClaw | contracts: videoGenerationProviders |
| [amazon-bedrock](/plugins/reference/amazon-bedrock) | Adds Amazon Bedrock model provider support to OpenClaw. | `@openclaw/amazon-bedrock-provider`<br />included in OpenClaw | providers: amazon-bedrock; contracts: memoryEmbeddingProviders |
| [amazon-bedrock-mantle](/plugins/reference/amazon-bedrock-mantle) | Adds Amazon Bedrock Mantle model provider support to OpenClaw. | `@openclaw/amazon-bedrock-mantle-provider`<br />included in OpenClaw | providers: amazon-bedrock-mantle |
| [anthropic](/plugins/reference/anthropic) | Adds Anthropic model provider support to OpenClaw. | `@openclaw/anthropic-provider`<br />included in OpenClaw | providers: anthropic; contracts: mediaUnderstandingProviders |
| [anthropic-vertex](/plugins/reference/anthropic-vertex) | Adds Anthropic Vertex model provider support to OpenClaw. | `@openclaw/anthropic-vertex-provider`<br />included in OpenClaw | providers: anthropic-vertex |
| [arcee](/plugins/reference/arcee) | Adds Arcee model provider support to OpenClaw. | `@openclaw/arcee-provider`<br />included in OpenClaw | providers: arcee |
| [azure-speech](/plugins/reference/azure-speech) | Azure AI Speech text-to-speech (MP3, native Ogg/Opus voice notes, PCM telephony). | `@openclaw/azure-speech`<br />included in OpenClaw | contracts: speechProviders |
| [bonjour](/plugins/reference/bonjour) | Advertise the local OpenClaw gateway over Bonjour/mDNS. | `@openclaw/bonjour`<br />included in OpenClaw | plugin |
@@ -109,7 +106,6 @@ uninstall, and publishing commands.
| [opencode](/plugins/reference/opencode) | Adds OpenCode model provider support to OpenClaw. | `@openclaw/opencode-provider`<br />included in OpenClaw | providers: opencode; contracts: mediaUnderstandingProviders |
| [opencode-go](/plugins/reference/opencode-go) | Adds OpenCode Go model provider support to OpenClaw. | `@openclaw/opencode-go-provider`<br />included in OpenClaw | providers: opencode-go; contracts: mediaUnderstandingProviders |
| [openrouter](/plugins/reference/openrouter) | Adds OpenRouter model provider support to OpenClaw. | `@openclaw/openrouter-provider`<br />included in OpenClaw | providers: openrouter; contracts: imageGenerationProviders, mediaUnderstandingProviders, speechProviders, videoGenerationProviders |
| [openshell](/plugins/reference/openshell) | Sandbox backend powered by OpenShell with mirrored local workspaces and SSH-based command execution. | `@openclaw/openshell-sandbox`<br />included in OpenClaw | plugin |
| [perplexity](/plugins/reference/perplexity) | Adds web search provider support. | `@openclaw/perplexity-plugin`<br />included in OpenClaw | contracts: webSearchProviders |
| [qianfan](/plugins/reference/qianfan) | Adds Qianfan model provider support to OpenClaw. | `@openclaw/qianfan-provider`<br />included in OpenClaw | providers: qianfan |
| [qwen](/plugins/reference/qwen) | Adds Qwen, Qwen Cloud, Model Studio, DashScope model provider support to OpenClaw. | `@openclaw/qwen-provider`<br />included in OpenClaw | providers: qwen, qwencloud, modelstudio, dashscope; contracts: mediaUnderstandingProviders, videoGenerationProviders |
@@ -119,7 +115,6 @@ uninstall, and publishing commands.
| [sglang](/plugins/reference/sglang) | Adds SGLang model provider support to OpenClaw. | `@openclaw/sglang-provider`<br />included in OpenClaw | providers: sglang |
| [signal](/plugins/reference/signal) | Adds the Signal channel surface for sending and receiving OpenClaw messages. | `@openclaw/signal`<br />included in OpenClaw | channels: signal |
| [skill-workshop](/plugins/reference/skill-workshop) | Captures repeatable workflows as workspace skills, with pending review, safe writes, and skill prompt refresh. | `@openclaw/skill-workshop`<br />included in OpenClaw | contracts: tools |
| [slack](/plugins/reference/slack) | Adds the Slack channel surface for sending and receiving OpenClaw messages. | `@openclaw/slack`<br />included in OpenClaw | channels: slack |
| [stepfun](/plugins/reference/stepfun) | Adds StepFun, StepFun Plan model provider support to OpenClaw. | `@openclaw/stepfun-provider`<br />included in OpenClaw | providers: stepfun, stepfun-plan |
| [synthetic](/plugins/reference/synthetic) | Adds Synthetic model provider support to OpenClaw. | `@openclaw/synthetic-provider`<br />included in OpenClaw | providers: synthetic |
| [tavily](/plugins/reference/tavily) | Adds agent-callable tools. Adds web search provider support. | `@openclaw/tavily-plugin`<br />included in OpenClaw | contracts: tools, webSearchProviders; skills |
@@ -142,33 +137,38 @@ uninstall, and publishing commands.
## Official external packages
| Plugin | Description | Distribution | Surface |
| ------------------------------------------------------------------- | ------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------- |
| [acpx](/plugins/reference/acpx) | Embedded ACP runtime backend with plugin-owned session and transport management. | `@openclaw/acpx`<br />npm; ClawHub | skills |
| [brave](/plugins/reference/brave) | Adds web search provider support. | `@openclaw/brave-plugin`<br />npm; ClawHub | contracts: webSearchProviders |
| [codex](/plugins/reference/codex) | Codex app-server harness and Codex-managed GPT model catalog. | `@openclaw/codex`<br />npm; ClawHub | providers: codex; contracts: mediaUnderstandingProviders, migrationProviders |
| [diagnostics-otel](/plugins/reference/diagnostics-otel) | OpenClaw diagnostics OpenTelemetry exporter. | `@openclaw/diagnostics-otel`<br />npm; ClawHub: `clawhub:@openclaw/diagnostics-otel` | plugin |
| [diagnostics-prometheus](/plugins/reference/diagnostics-prometheus) | OpenClaw diagnostics Prometheus exporter. | `@openclaw/diagnostics-prometheus`<br />npm; ClawHub: `clawhub:@openclaw/diagnostics-prometheus` | plugin |
| [diffs](/plugins/reference/diffs) | Read-only diff viewer and file renderer for agents. | `@openclaw/diffs`<br />npm; ClawHub | contracts: tools; skills |
| [discord](/plugins/reference/discord) | Adds the Discord channel surface for sending and receiving OpenClaw messages. | `@openclaw/discord`<br />npm; ClawHub | channels: discord |
| [feishu](/plugins/reference/feishu) | Adds the Feishu channel surface for sending and receiving OpenClaw messages. | `@openclaw/feishu`<br />npm; ClawHub | channels: feishu; contracts: tools; skills |
| [google-meet](/plugins/reference/google-meet) | Join Google Meet calls through Chrome or Twilio transports. | `@openclaw/google-meet`<br />npm; ClawHub | contracts: tools |
| [googlechat](/plugins/reference/googlechat) | Adds the Google Chat channel surface for sending and receiving OpenClaw messages. | `@openclaw/googlechat`<br />npm; ClawHub | channels: googlechat |
| [line](/plugins/reference/line) | Adds the LINE channel surface for sending and receiving OpenClaw messages. | `@openclaw/line`<br />npm; ClawHub | channels: line |
| [lobster](/plugins/reference/lobster) | Typed workflow tool with resumable approvals. | `@openclaw/lobster`<br />npm; ClawHub | contracts: tools |
| [matrix](/plugins/reference/matrix) | Adds the Matrix channel surface for sending and receiving OpenClaw messages. | `@openclaw/matrix`<br />ClawHub: `clawhub:@openclaw/matrix`; npm | channels: matrix |
| [memory-lancedb](/plugins/reference/memory-lancedb) | Adds agent-callable tools. | `@openclaw/memory-lancedb`<br />npm; ClawHub | contracts: tools |
| [msteams](/plugins/reference/msteams) | Adds the Microsoft Teams channel surface for sending and receiving OpenClaw messages. | `@openclaw/msteams`<br />npm; ClawHub | channels: msteams |
| [nextcloud-talk](/plugins/reference/nextcloud-talk) | Adds the Nextcloud Talk channel surface for sending and receiving OpenClaw messages. | `@openclaw/nextcloud-talk`<br />npm; ClawHub | channels: nextcloud-talk |
| [nostr](/plugins/reference/nostr) | Adds the Nostr channel surface for sending and receiving OpenClaw messages. | `@openclaw/nostr`<br />npm; ClawHub | channels: nostr |
| [qqbot](/plugins/reference/qqbot) | Adds the QQ Bot channel surface for sending and receiving OpenClaw messages. | `@openclaw/qqbot`<br />npm; ClawHub | channels: qqbot; contracts: tools; skills |
| [synology-chat](/plugins/reference/synology-chat) | Adds the Synology Chat channel surface for sending and receiving OpenClaw messages. | `@openclaw/synology-chat`<br />npm; ClawHub | channels: synology-chat |
| [tlon](/plugins/reference/tlon) | Adds the Tlon channel surface for sending and receiving OpenClaw messages. | `@openclaw/tlon`<br />npm; ClawHub | channels: tlon; contracts: tools; skills |
| [twitch](/plugins/reference/twitch) | Adds the Twitch channel surface for sending and receiving OpenClaw messages. | `@openclaw/twitch`<br />npm; ClawHub | channels: twitch |
| [voice-call](/plugins/reference/voice-call) | Adds agent-callable tools. | `@openclaw/voice-call`<br />npm; ClawHub | contracts: tools |
| [whatsapp](/plugins/reference/whatsapp) | Adds the WhatsApp channel surface for sending and receiving OpenClaw messages. | `@openclaw/whatsapp`<br />npm; ClawHub | channels: whatsapp |
| [zalo](/plugins/reference/zalo) | Adds the Zalo channel surface for sending and receiving OpenClaw messages. | `@openclaw/zalo`<br />npm; ClawHub | channels: zalo |
| [zalouser](/plugins/reference/zalouser) | Adds the Zalo Personal channel surface for sending and receiving OpenClaw messages. | `@openclaw/zalouser`<br />npm; ClawHub | channels: zalouser; contracts: tools |
| Plugin | Description | Distribution | Surface |
| ------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------- |
| [acpx](/plugins/reference/acpx) | Embedded ACP runtime backend with plugin-owned session and transport management. | `@openclaw/acpx`<br />npm; ClawHub | skills |
| [amazon-bedrock](/plugins/reference/amazon-bedrock) | Adds Amazon Bedrock model provider support to OpenClaw. | `@openclaw/amazon-bedrock-provider`<br />npm | providers: amazon-bedrock; contracts: memoryEmbeddingProviders |
| [amazon-bedrock-mantle](/plugins/reference/amazon-bedrock-mantle) | Adds Amazon Bedrock Mantle model provider support to OpenClaw. | `@openclaw/amazon-bedrock-mantle-provider`<br />npm | providers: amazon-bedrock-mantle |
| [anthropic-vertex](/plugins/reference/anthropic-vertex) | Adds Anthropic Vertex model provider support to OpenClaw. | `@openclaw/anthropic-vertex-provider`<br />npm; ClawHub | providers: anthropic-vertex |
| [brave](/plugins/reference/brave) | Adds web search provider support. | `@openclaw/brave-plugin`<br />npm; ClawHub | contracts: webSearchProviders |
| [codex](/plugins/reference/codex) | Codex app-server harness and Codex-managed GPT model catalog. | `@openclaw/codex`<br />npm; ClawHub | providers: codex; contracts: mediaUnderstandingProviders, migrationProviders |
| [diagnostics-otel](/plugins/reference/diagnostics-otel) | OpenClaw diagnostics OpenTelemetry exporter. | `@openclaw/diagnostics-otel`<br />npm; ClawHub: `clawhub:@openclaw/diagnostics-otel` | plugin |
| [diagnostics-prometheus](/plugins/reference/diagnostics-prometheus) | OpenClaw diagnostics Prometheus exporter. | `@openclaw/diagnostics-prometheus`<br />npm; ClawHub: `clawhub:@openclaw/diagnostics-prometheus` | plugin |
| [diffs](/plugins/reference/diffs) | Read-only diff viewer and file renderer for agents. | `@openclaw/diffs`<br />npm; ClawHub | contracts: tools; skills |
| [discord](/plugins/reference/discord) | Adds the Discord channel surface for sending and receiving OpenClaw messages. | `@openclaw/discord`<br />npm; ClawHub | channels: discord |
| [feishu](/plugins/reference/feishu) | Adds the Feishu channel surface for sending and receiving OpenClaw messages. | `@openclaw/feishu`<br />npm; ClawHub | channels: feishu; contracts: tools; skills |
| [google-meet](/plugins/reference/google-meet) | Join Google Meet calls through Chrome or Twilio transports. | `@openclaw/google-meet`<br />npm; ClawHub | contracts: tools |
| [googlechat](/plugins/reference/googlechat) | Adds the Google Chat channel surface for sending and receiving OpenClaw messages. | `@openclaw/googlechat`<br />npm; ClawHub | channels: googlechat |
| [line](/plugins/reference/line) | Adds the LINE channel surface for sending and receiving OpenClaw messages. | `@openclaw/line`<br />npm; ClawHub | channels: line |
| [lobster](/plugins/reference/lobster) | Typed workflow tool with resumable approvals. | `@openclaw/lobster`<br />npm; ClawHub | contracts: tools |
| [matrix](/plugins/reference/matrix) | Adds the Matrix channel surface for sending and receiving OpenClaw messages. | `@openclaw/matrix`<br />ClawHub: `clawhub:@openclaw/matrix`; npm | channels: matrix |
| [memory-lancedb](/plugins/reference/memory-lancedb) | Adds agent-callable tools. | `@openclaw/memory-lancedb`<br />npm; ClawHub | contracts: tools |
| [msteams](/plugins/reference/msteams) | Adds the Microsoft Teams channel surface for sending and receiving OpenClaw messages. | `@openclaw/msteams`<br />npm; ClawHub | channels: msteams |
| [nextcloud-talk](/plugins/reference/nextcloud-talk) | Adds the Nextcloud Talk channel surface for sending and receiving OpenClaw messages. | `@openclaw/nextcloud-talk`<br />npm; ClawHub | channels: nextcloud-talk |
| [nostr](/plugins/reference/nostr) | Adds the Nostr channel surface for sending and receiving OpenClaw messages. | `@openclaw/nostr`<br />npm; ClawHub | channels: nostr |
| [openshell](/plugins/reference/openshell) | Sandbox backend powered by OpenShell with mirrored local workspaces and SSH-based command execution. | `@openclaw/openshell-sandbox`<br />npm; ClawHub | plugin |
| [qqbot](/plugins/reference/qqbot) | Adds the QQ Bot channel surface for sending and receiving OpenClaw messages. | `@openclaw/qqbot`<br />npm; ClawHub | channels: qqbot; contracts: tools; skills |
| [slack](/plugins/reference/slack) | Adds the Slack channel surface for sending and receiving OpenClaw messages. | `@openclaw/slack`<br />npm; ClawHub | channels: slack |
| [synology-chat](/plugins/reference/synology-chat) | Adds the Synology Chat channel surface for sending and receiving OpenClaw messages. | `@openclaw/synology-chat`<br />npm; ClawHub | channels: synology-chat |
| [tlon](/plugins/reference/tlon) | Adds the Tlon channel surface for sending and receiving OpenClaw messages. | `@openclaw/tlon`<br />npm; ClawHub | channels: tlon; contracts: tools; skills |
| [twitch](/plugins/reference/twitch) | Adds the Twitch channel surface for sending and receiving OpenClaw messages. | `@openclaw/twitch`<br />npm; ClawHub | channels: twitch |
| [voice-call](/plugins/reference/voice-call) | Adds agent-callable tools. | `@openclaw/voice-call`<br />npm; ClawHub | contracts: tools |
| [whatsapp](/plugins/reference/whatsapp) | Adds the WhatsApp channel surface for sending and receiving OpenClaw messages. | `@openclaw/whatsapp`<br />ClawHub: `clawhub:@openclaw/whatsapp`; npm | channels: whatsapp |
| [zalo](/plugins/reference/zalo) | Adds the Zalo channel surface for sending and receiving OpenClaw messages. | `@openclaw/zalo`<br />npm; ClawHub | channels: zalo |
| [zalouser](/plugins/reference/zalouser) | Adds the Zalo Personal channel surface for sending and receiving OpenClaw messages. | `@openclaw/zalouser`<br />npm; ClawHub | channels: zalouser; contracts: tools |
## Source checkout only

View File

@@ -19,10 +19,10 @@ pnpm plugins:inventory:gen
| ------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| [acpx](/plugins/reference/acpx) | Embedded ACP runtime backend with plugin-owned session and transport management. | `@openclaw/acpx`<br />npm; ClawHub | skills |
| [alibaba](/plugins/reference/alibaba) | Adds video generation provider support. | `@openclaw/alibaba-provider`<br />included in OpenClaw | contracts: videoGenerationProviders |
| [amazon-bedrock](/plugins/reference/amazon-bedrock) | Adds Amazon Bedrock model provider support to OpenClaw. | `@openclaw/amazon-bedrock-provider`<br />included in OpenClaw | providers: amazon-bedrock; contracts: memoryEmbeddingProviders |
| [amazon-bedrock-mantle](/plugins/reference/amazon-bedrock-mantle) | Adds Amazon Bedrock Mantle model provider support to OpenClaw. | `@openclaw/amazon-bedrock-mantle-provider`<br />included in OpenClaw | providers: amazon-bedrock-mantle |
| [amazon-bedrock](/plugins/reference/amazon-bedrock) | Adds Amazon Bedrock model provider support to OpenClaw. | `@openclaw/amazon-bedrock-provider`<br />npm | providers: amazon-bedrock; contracts: memoryEmbeddingProviders |
| [amazon-bedrock-mantle](/plugins/reference/amazon-bedrock-mantle) | Adds Amazon Bedrock Mantle model provider support to OpenClaw. | `@openclaw/amazon-bedrock-mantle-provider`<br />npm | providers: amazon-bedrock-mantle |
| [anthropic](/plugins/reference/anthropic) | Adds Anthropic model provider support to OpenClaw. | `@openclaw/anthropic-provider`<br />included in OpenClaw | providers: anthropic; contracts: mediaUnderstandingProviders |
| [anthropic-vertex](/plugins/reference/anthropic-vertex) | Adds Anthropic Vertex model provider support to OpenClaw. | `@openclaw/anthropic-vertex-provider`<br />included in OpenClaw | providers: anthropic-vertex |
| [anthropic-vertex](/plugins/reference/anthropic-vertex) | Adds Anthropic Vertex model provider support to OpenClaw. | `@openclaw/anthropic-vertex-provider`<br />npm; ClawHub | providers: anthropic-vertex |
| [arcee](/plugins/reference/arcee) | Adds Arcee model provider support to OpenClaw. | `@openclaw/arcee-provider`<br />included in OpenClaw | providers: arcee |
| [azure-speech](/plugins/reference/azure-speech) | Azure AI Speech text-to-speech (MP3, native Ogg/Opus voice notes, PCM telephony). | `@openclaw/azure-speech`<br />included in OpenClaw | contracts: speechProviders |
| [bonjour](/plugins/reference/bonjour) | Advertise the local OpenClaw gateway over Bonjour/mDNS. | `@openclaw/bonjour`<br />included in OpenClaw | plugin |
@@ -93,7 +93,7 @@ pnpm plugins:inventory:gen
| [opencode](/plugins/reference/opencode) | Adds OpenCode model provider support to OpenClaw. | `@openclaw/opencode-provider`<br />included in OpenClaw | providers: opencode; contracts: mediaUnderstandingProviders |
| [opencode-go](/plugins/reference/opencode-go) | Adds OpenCode Go model provider support to OpenClaw. | `@openclaw/opencode-go-provider`<br />included in OpenClaw | providers: opencode-go; contracts: mediaUnderstandingProviders |
| [openrouter](/plugins/reference/openrouter) | Adds OpenRouter model provider support to OpenClaw. | `@openclaw/openrouter-provider`<br />included in OpenClaw | providers: openrouter; contracts: imageGenerationProviders, mediaUnderstandingProviders, speechProviders, videoGenerationProviders |
| [openshell](/plugins/reference/openshell) | Sandbox backend powered by OpenShell with mirrored local workspaces and SSH-based command execution. | `@openclaw/openshell-sandbox`<br />included in OpenClaw | plugin |
| [openshell](/plugins/reference/openshell) | Sandbox backend powered by OpenShell with mirrored local workspaces and SSH-based command execution. | `@openclaw/openshell-sandbox`<br />npm; ClawHub | plugin |
| [perplexity](/plugins/reference/perplexity) | Adds web search provider support. | `@openclaw/perplexity-plugin`<br />included in OpenClaw | contracts: webSearchProviders |
| [qa-channel](/plugins/reference/qa-channel) | Adds the QA Channel surface for sending and receiving OpenClaw messages. | `@openclaw/qa-channel`<br />source checkout only | channels: qa-channel |
| [qa-lab](/plugins/reference/qa-lab) | OpenClaw QA lab plugin with private debugger UI and scenario runner. | `@openclaw/qa-lab`<br />source checkout only | plugin |
@@ -107,7 +107,7 @@ pnpm plugins:inventory:gen
| [sglang](/plugins/reference/sglang) | Adds SGLang model provider support to OpenClaw. | `@openclaw/sglang-provider`<br />included in OpenClaw | providers: sglang |
| [signal](/plugins/reference/signal) | Adds the Signal channel surface for sending and receiving OpenClaw messages. | `@openclaw/signal`<br />included in OpenClaw | channels: signal |
| [skill-workshop](/plugins/reference/skill-workshop) | Captures repeatable workflows as workspace skills, with pending review, safe writes, and skill prompt refresh. | `@openclaw/skill-workshop`<br />included in OpenClaw | contracts: tools |
| [slack](/plugins/reference/slack) | Adds the Slack channel surface for sending and receiving OpenClaw messages. | `@openclaw/slack`<br />included in OpenClaw | channels: slack |
| [slack](/plugins/reference/slack) | Adds the Slack channel surface for sending and receiving OpenClaw messages. | `@openclaw/slack`<br />npm; ClawHub | channels: slack |
| [stepfun](/plugins/reference/stepfun) | Adds StepFun, StepFun Plan model provider support to OpenClaw. | `@openclaw/stepfun-provider`<br />included in OpenClaw | providers: stepfun, stepfun-plan |
| [synology-chat](/plugins/reference/synology-chat) | Adds the Synology Chat channel surface for sending and receiving OpenClaw messages. | `@openclaw/synology-chat`<br />npm; ClawHub | channels: synology-chat |
| [synthetic](/plugins/reference/synthetic) | Adds Synthetic model provider support to OpenClaw. | `@openclaw/synthetic-provider`<br />included in OpenClaw | providers: synthetic |
@@ -128,7 +128,7 @@ pnpm plugins:inventory:gen
| [vydra](/plugins/reference/vydra) | Adds Vydra model provider support to OpenClaw. | `@openclaw/vydra-provider`<br />included in OpenClaw | providers: vydra; contracts: imageGenerationProviders, speechProviders, videoGenerationProviders |
| [web-readability](/plugins/reference/web-readability) | Extract readable article content from local HTML web fetch responses. | `@openclaw/web-readability-plugin`<br />included in OpenClaw | contracts: webContentExtractors |
| [webhooks](/plugins/reference/webhooks) | Authenticated inbound webhooks that bind external automation to OpenClaw TaskFlows. | `@openclaw/webhooks`<br />included in OpenClaw | plugin |
| [whatsapp](/plugins/reference/whatsapp) | Adds the WhatsApp channel surface for sending and receiving OpenClaw messages. | `@openclaw/whatsapp`<br />npm; ClawHub | channels: whatsapp |
| [whatsapp](/plugins/reference/whatsapp) | Adds the WhatsApp channel surface for sending and receiving OpenClaw messages. | `@openclaw/whatsapp`<br />ClawHub: `clawhub:@openclaw/whatsapp`; npm | channels: whatsapp |
| [xai](/plugins/reference/xai) | Adds xAI model provider support to OpenClaw. | `@openclaw/xai-plugin`<br />included in OpenClaw | providers: xai; contracts: imageGenerationProviders, mediaUnderstandingProviders, realtimeTranscriptionProviders, speechProviders, tools, videoGenerationProviders, webSearchProviders |
| [xiaomi](/plugins/reference/xiaomi) | Adds Xiaomi model provider support to OpenClaw. | `@openclaw/xiaomi-provider`<br />included in OpenClaw | providers: xiaomi; contracts: speechProviders |
| [zai](/plugins/reference/zai) | Adds Z.AI model provider support to OpenClaw. | `@openclaw/zai-provider`<br />included in OpenClaw | providers: zai; contracts: mediaUnderstandingProviders |

View File

@@ -12,7 +12,7 @@ Adds Amazon Bedrock Mantle model provider support to OpenClaw.
## Distribution
- Package: `@openclaw/amazon-bedrock-mantle-provider`
- Install route: included in OpenClaw
- Install route: npm
## Surface

View File

@@ -12,7 +12,7 @@ Adds Amazon Bedrock model provider support to OpenClaw.
## Distribution
- Package: `@openclaw/amazon-bedrock-provider`
- Install route: included in OpenClaw
- Install route: npm
## Surface

View File

@@ -12,7 +12,7 @@ Adds Anthropic Vertex model provider support to OpenClaw.
## Distribution
- Package: `@openclaw/anthropic-vertex-provider`
- Install route: included in OpenClaw
- Install route: npm; ClawHub
## Surface

View File

@@ -12,7 +12,7 @@ Sandbox backend powered by OpenShell with mirrored local workspaces and SSH-base
## Distribution
- Package: `@openclaw/openshell-sandbox`
- Install route: included in OpenClaw
- Install route: npm; ClawHub
## Surface

View File

@@ -12,7 +12,7 @@ Adds the Slack channel surface for sending and receiving OpenClaw messages.
## Distribution
- Package: `@openclaw/slack`
- Install route: included in OpenClaw
- Install route: npm; ClawHub
## Surface

View File

@@ -12,22 +12,12 @@ Adds the WhatsApp channel surface for sending and receiving OpenClaw messages.
## Distribution
- Package: `@openclaw/whatsapp`
- Install route: npm; ClawHub
- Install route: ClawHub: `clawhub:@openclaw/whatsapp`; npm
## Surface
channels: whatsapp
## Windows install note
On Windows, the WhatsApp plugin needs Git on `PATH` during npm install because one of its Baileys/libsignal dependencies is fetched from a git URL. Install Git for Windows, then restart the shell and rerun the install:
```powershell
winget install --id Git.Git -e
```
Portable Git also works if its `bin` directory is on `PATH`.
## Related docs
- [whatsapp](/channels/whatsapp)

View File

@@ -41,6 +41,13 @@ but new code should not add imports from them: `agent-runtime-test-contracts`,
`text-runtime`, and `zod`. Import `zod` directly from `zod` in new plugin code.
`plugin-test-runtime` is still an active focused test helper subpath.
### Reserved bundled plugin helper subpaths
These subpaths are plugin-owned compatibility surfaces reserved for their owning
bundled plugin, not general SDK APIs: `plugin-sdk/codex-mcp-projection` and
`plugin-sdk/codex-native-task-runtime`. Cross-owner extension imports are blocked
by package contract guardrails.
### Deprecated unused public subpaths
These public subpaths existed for at least one month and currently have no
@@ -215,6 +222,8 @@ focused channel/runtime subpaths, `config-contracts`, `string-coerce-runtime`,
| `plugin-sdk/runtime` | Broad runtime/logging/backup/plugin-install helpers |
| `plugin-sdk/runtime-env` | Narrow runtime env, logger, timeout, retry, and backoff helpers |
| `plugin-sdk/browser-config` | Supported browser config facade for normalized profile/defaults, CDP URL parsing, and browser-control auth helpers |
| `plugin-sdk/codex-mcp-projection` | Reserved bundled Codex helper for projecting user MCP server config into Codex thread config; not for third-party plugins |
| `plugin-sdk/codex-native-task-runtime` | Reserved bundled Codex helper for native task mirror/runtime wiring; not for third-party plugins |
| `plugin-sdk/channel-runtime-context` | Generic channel runtime-context registration and lookup helpers |
| `plugin-sdk/matrix` | Deprecated Matrix compatibility facade for older third-party channel packages; new plugins should import `plugin-sdk/run-command` directly |
| `plugin-sdk/mattermost` | Deprecated Mattermost compatibility facade for older third-party channel packages; new plugins should import generic SDK subpaths directly |
@@ -302,9 +311,9 @@ focused channel/runtime subpaths, `config-contracts`, `string-coerce-runtime`,
<Accordion title="Capability and testing subpaths">
| Subpath | Key exports |
| --- | --- |
| `plugin-sdk/media-runtime` | Shared media fetch/transform/store helpers, ffprobe-backed video dimension probing, and media payload builders |
| `plugin-sdk/media-runtime` | Shared media fetch/transform/store helpers including `saveRemoteMedia`, `saveResponseMedia`, `readRemoteMediaBuffer`, and deprecated `fetchRemoteMedia`; prefer store helpers before buffer reads when a URL should become OpenClaw media |
| `plugin-sdk/media-mime` | Narrow MIME normalization, file-extension mapping, MIME detection, and media-kind helpers |
| `plugin-sdk/media-store` | Narrow media store helpers such as `saveMediaBuffer` |
| `plugin-sdk/media-store` | Narrow media store helpers such as `saveMediaBuffer` and `saveMediaStream` |
| `plugin-sdk/media-generation-runtime` | Shared media-generation failover helpers, candidate selection, and missing-model messaging |
| `plugin-sdk/media-understanding` | Media understanding provider types plus provider-facing image/audio/structured-extraction helper exports |
| `plugin-sdk/text-chunking` | Text and markdown chunking/render helpers, markdown table conversion, directive-tag stripping, and safe-text utilities |
@@ -361,10 +370,18 @@ focused channel/runtime subpaths, `config-contracts`, `string-coerce-runtime`,
</Accordion>
<Accordion title="Reserved bundled-helper subpaths">
There are currently no reserved bundled-helper SDK subpaths. Owner-specific
helpers live inside the owning plugin package, while reusable host contracts
use generic SDK subpaths such as `plugin-sdk/gateway-runtime`,
`plugin-sdk/security-runtime`, and `plugin-sdk/plugin-config-runtime`.
Reserved bundled-helper SDK subpaths are narrow owner-specific surfaces for
bundled plugin code. They are tracked in the SDK inventory so package
builds and aliasing stay deterministic, but they are not general plugin
authoring APIs. New reusable host contracts should use generic SDK subpaths
such as `plugin-sdk/gateway-runtime`, `plugin-sdk/security-runtime`, and
`plugin-sdk/plugin-config-runtime`.
| Subpath | Owner and purpose |
| --- | --- |
| `plugin-sdk/codex-mcp-projection` | Bundled Codex plugin helper for projecting user MCP server config into Codex app-server thread config |
| `plugin-sdk/codex-native-task-runtime` | Bundled Codex plugin helper for mirroring Codex app-server native subagents into OpenClaw task state |
</Accordion>
</AccordionGroup>

View File

@@ -49,9 +49,9 @@ model as `provider/model`.
- [xAI](/providers/xai)
- [Z.AI](/providers/zai)
## Additional bundled provider variants
## Additional provider variants
- `anthropic-vertex` - implicit Anthropic on Google Vertex support when Vertex credentials are available; no separate onboarding auth choice
- `anthropic-vertex` - install `@openclaw/anthropic-vertex-provider` for implicit Anthropic on Google Vertex support when Vertex credentials are available; no separate onboarding auth choice
- `copilot-proxy` - local VS Code Copilot Proxy bridge; use `openclaw onboard --auth-choice copilot-proxy`
- `google-gemini-cli` - unofficial Gemini CLI OAuth flow; requires a local `gemini` install (`brew install gemini-cli` or `npm install -g @google/gemini-cli`); default model `google-gemini-cli/gemini-3-flash-preview`; use `openclaw onboard --auth-choice google-gemini-cli` or `openclaw models auth login --provider google-gemini-cli --set-default`

View File

@@ -20,7 +20,7 @@ SGLang serves open-weight models via an OpenAI-compatible HTTP API. OpenClaw con
| Streaming usage | Yes (`supportsStreamingUsage: true`) |
| Pricing | Marked external-free (`modelPricing.external: false`) |
OpenClaw also **auto-discovers** available models from SGLang when you opt in with `SGLANG_API_KEY` and you do not define an explicit `models.providers.sglang` entry — see [Model discovery (implicit provider)](#model-discovery-implicit-provider) below.
OpenClaw also **auto-discovers** available models from SGLang when you opt in with `SGLANG_API_KEY`. Use `sglang/*` in `agents.defaults.models` to keep discovery dynamic when you also configure a custom SGLang base URL. See [Model discovery (implicit provider)](#model-discovery-implicit-provider) below.
## Getting started
@@ -71,8 +71,10 @@ define `models.providers.sglang`, OpenClaw will query:
and convert the returned IDs into model entries.
<Note>
If you set `models.providers.sglang` explicitly, auto-discovery is skipped and
you must define models manually.
If you set `models.providers.sglang` explicitly, OpenClaw uses your declared
models by default. Add `"sglang/*": {}` to `agents.defaults.models` when you
want OpenClaw to query that configured provider's `/models` endpoint and include
all advertised SGLang models.
</Note>
## Explicit configuration (manual models)

View File

@@ -8,7 +8,7 @@ title: "vLLM"
vLLM can serve open-source (and some custom) models via an **OpenAI-compatible** HTTP API. OpenClaw connects to vLLM using the `openai-completions` API.
OpenClaw can also **auto-discover** available models from vLLM when you opt in with `VLLM_API_KEY` (any value works if your server does not enforce auth) and you do not define an explicit `models.providers.vllm` entry.
OpenClaw can also **auto-discover** available models from vLLM when you opt in with `VLLM_API_KEY` (any value works if your server does not enforce auth). Use `vllm/*` in `agents.defaults.models` to keep discovery dynamic when you also configure a custom vLLM base URL.
OpenClaw treats `vllm` as a local OpenAI-compatible provider that supports
streamed usage accounting, so status/context token counts can update from
@@ -72,7 +72,7 @@ GET http://127.0.0.1:8000/v1/models
and converts the returned IDs into model entries.
<Note>
If you set `models.providers.vllm` explicitly, auto-discovery is skipped and you must define models manually.
If you set `models.providers.vllm` explicitly, OpenClaw uses your declared models by default. Add `"vllm/*": {}` to `agents.defaults.models` when you want OpenClaw to query that configured provider's `/models` endpoint and include all advertised vLLM models.
</Note>
## Explicit configuration (manual models)
@@ -111,6 +111,21 @@ Use explicit config when:
}
```
To keep this provider dynamic without manually listing every model, add a provider
wildcard to the visible model catalog:
```json5
{
agents: {
defaults: {
models: {
"vllm/*": {},
},
},
},
}
```
## Advanced configuration
<AccordionGroup>
@@ -331,7 +346,7 @@ Use explicit config when:
</Accordion>
<Accordion title="No models discovered">
Auto-discovery requires `VLLM_API_KEY` to be set **and** no explicit `models.providers.vllm` config entry. If you have defined the provider manually, OpenClaw skips discovery and uses only your declared models.
Auto-discovery requires `VLLM_API_KEY` to be set. If you have defined `models.providers.vllm`, OpenClaw uses only your declared models unless `agents.defaults.models` includes `"vllm/*": {}`.
</Accordion>
<Accordion title="Tools render as raw text">

View File

@@ -77,7 +77,7 @@ Onboarding starts with **QuickStart** (defaults) vs **Advanced** (full control).
3. **Gateway** — Port, bind address, auth mode, Tailscale exposure.
In interactive token mode, choose default plaintext token storage or opt into SecretRef.
Non-interactive token SecretRef path: `--gateway-token-ref-env <ENV_VAR>`.
4. **Channels** — built-in and bundled chat channels such as iMessage, Discord, Feishu, Google Chat, Mattermost, Microsoft Teams, QQ Bot, Signal, Slack, Telegram, WhatsApp, and more.
4. **Channels** — built-in and official plugin chat channels such as iMessage, Discord, Feishu, Google Chat, Mattermost, Microsoft Teams, QQ Bot, Signal, Slack, Telegram, WhatsApp, and more.
5. **Daemon** — Installs a LaunchAgent (macOS), systemd user unit (Linux/WSL2), or native Windows Scheduled Task with per-user Startup-folder fallback.
If token auth requires a token and `gateway.auth.token` is SecretRef-managed, daemon install validates it but does not persist the resolved token into supervisor service environment metadata.
If token auth requires a token and the configured token SecretRef is unresolved, daemon install is blocked with actionable guidance.

View File

@@ -73,6 +73,7 @@ Notes:
## Sending + delivery
- Messages are sent to the Gateway; delivery to providers is off by default.
- The TUI is an internal source surface like WebChat, not a generic outbound channel. Harnesses that require `tools.message` for visible replies can satisfy the active TUI turn with a targetless `message.send`; explicit provider delivery still uses normal configured channels and never falls back to `lastChannel`.
- Turn delivery on:
- `/deliver on`
- or the Settings panel

View File

@@ -51,6 +51,7 @@ WebChat has two separate data paths:
- The session JSONL file is the durable model/runtime transcript. For normal agent runs, Pi persists model-visible `user`, `assistant`, and `toolResult` messages through its session manager. WebChat does not write arbitrary delivery, status, or helper text into that transcript.
- Gateway `ReplyPayload` events are the live delivery projection. They can be normalized for WebChat/channel display, block streaming, directive tags, media embedding, TTS/audio flags, and UI fallback behavior. They are not themselves the canonical session log.
- Harnesses that require visible replies through `tools.message` still use WebChat as a current-run internal source reply sink. A targetless `message.send` from that active WebChat run is projected into the same chat and mirrored to the session transcript; WebChat does not become a reusable outbound channel and never inherits `lastChannel`.
- WebChat injects assistant transcript entries only when the Gateway owns a displayed message outside a normal Pi assistant turn: `chat.inject`, non-agent command replies, aborted partial output, and WebChat-managed media transcript supplements.
- `chat.history` reads the stored session transcript and applies WebChat display projection. If live assistant text appears during a run but disappears after history reload, first check whether the raw JSONL contains the assistant text, then whether `chat.history` projection stripped it, then whether the Control UI optimistic-tail merge replaced local delivery state with the persisted snapshot.

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/acpx",
"version": "2026.5.12-beta.1",
"version": "2026.5.12-beta.7",
"description": "OpenClaw ACP runtime backend",
"repository": {
"type": "git",
@@ -26,10 +26,10 @@
"minHostVersion": ">=2026.4.25"
},
"compat": {
"pluginApi": ">=2026.5.12-beta.1"
"pluginApi": ">=2026.5.12-beta.7"
},
"build": {
"openclawVersion": "2026.5.12-beta.1",
"openclawVersion": "2026.5.12-beta.7",
"staticAssets": [
{
"source": "./src/runtime-internals/mcp-proxy.mjs",

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/alibaba-provider",
"version": "2026.5.12-beta.1",
"version": "2026.5.12-beta.7",
"private": true,
"description": "OpenClaw Alibaba Model Studio video provider plugin",
"type": "module",

View File

@@ -1,8 +1,11 @@
{
"name": "@openclaw/amazon-bedrock-mantle-provider",
"version": "2026.5.12-beta.1",
"private": true,
"version": "2026.5.12-beta.7",
"description": "OpenClaw Amazon Bedrock Mantle (OpenAI-compatible) provider plugin",
"repository": {
"type": "git",
"url": "https://github.com/openclaw/openclaw"
},
"type": "module",
"dependencies": {
"@anthropic-ai/sdk": "0.95.2",
@@ -15,6 +18,21 @@
"openclaw": {
"extensions": [
"./index.ts"
]
],
"install": {
"npmSpec": "@openclaw/amazon-bedrock-mantle-provider",
"defaultChoice": "npm",
"minHostVersion": ">=2026.5.12-beta.6"
},
"compat": {
"pluginApi": ">=2026.5.12-beta.7"
},
"build": {
"openclawVersion": "2026.5.12-beta.7",
"bundledDist": false
},
"release": {
"publishToNpm": true
}
}
}

View File

@@ -1,8 +1,11 @@
{
"name": "@openclaw/amazon-bedrock-provider",
"version": "2026.5.12-beta.1",
"private": true,
"version": "2026.5.12-beta.7",
"description": "OpenClaw Amazon Bedrock provider plugin",
"repository": {
"type": "git",
"url": "https://github.com/openclaw/openclaw"
},
"type": "module",
"dependencies": {
"@aws-sdk/client-bedrock": "3.1045.0",
@@ -17,6 +20,21 @@
"openclaw": {
"extensions": [
"./index.ts"
]
],
"install": {
"npmSpec": "@openclaw/amazon-bedrock-provider",
"defaultChoice": "npm",
"minHostVersion": ">=2026.5.12-beta.6"
},
"compat": {
"pluginApi": ">=2026.5.12-beta.7"
},
"build": {
"openclawVersion": "2026.5.12-beta.7",
"bundledDist": false
},
"release": {
"publishToNpm": true
}
}
}

View File

@@ -1,8 +1,11 @@
{
"name": "@openclaw/anthropic-vertex-provider",
"version": "2026.5.12-beta.1",
"private": true,
"version": "2026.5.12-beta.7",
"description": "OpenClaw Anthropic Vertex provider plugin",
"repository": {
"type": "git",
"url": "https://github.com/openclaw/openclaw"
},
"type": "module",
"dependencies": {
"@anthropic-ai/vertex-sdk": "0.16.0",
@@ -15,6 +18,22 @@
"openclaw": {
"extensions": [
"./index.ts"
]
],
"install": {
"npmSpec": "@openclaw/anthropic-vertex-provider",
"defaultChoice": "npm",
"minHostVersion": ">=2026.5.12-beta.6"
},
"compat": {
"pluginApi": ">=2026.5.12-beta.7"
},
"build": {
"openclawVersion": "2026.5.12-beta.7",
"bundledDist": false
},
"release": {
"publishToClawHub": true,
"publishToNpm": true
}
}
}

View File

@@ -13,7 +13,11 @@ import { resolveAnthropicVertexClientRegion, resolveAnthropicVertexProjectId } f
type AnthropicVertexEffort = NonNullable<AnthropicOptions["effort"]>;
type AnthropicVertexAdaptiveEffort = AnthropicVertexEffort | "xhigh";
type AnthropicVertexClientOptions = ConstructorParameters<typeof AnthropicVertexSdk>[0];
type AnthropicVertexClientOptions = {
baseURL?: string;
projectId?: string;
region: string;
};
export type AnthropicVertexStreamDeps = {
AnthropicVertex: new (options: AnthropicVertexClientOptions) => unknown;

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/anthropic-provider",
"version": "2026.5.12-beta.1",
"version": "2026.5.12-beta.7",
"private": true,
"description": "OpenClaw Anthropic provider plugin",
"type": "module",

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/arcee-provider",
"version": "2026.5.12-beta.1",
"version": "2026.5.12-beta.7",
"private": true,
"description": "OpenClaw Arcee provider plugin",
"type": "module",

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/azure-speech",
"version": "2026.5.12-beta.1",
"version": "2026.5.12-beta.7",
"private": true,
"description": "OpenClaw Azure Speech plugin",
"type": "module",

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/bonjour",
"version": "2026.5.12-beta.1",
"version": "2026.5.12-beta.7",
"description": "OpenClaw Bonjour/mDNS gateway discovery",
"type": "module",
"dependencies": {

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/brave-plugin",
"version": "2026.5.12-beta.1",
"version": "2026.5.12-beta.7",
"description": "OpenClaw Brave plugin",
"repository": {
"type": "git",
@@ -20,10 +20,10 @@
"minHostVersion": ">=2026.4.10"
},
"compat": {
"pluginApi": ">=2026.5.12-beta.1"
"pluginApi": ">=2026.5.12-beta.7"
},
"build": {
"openclawVersion": "2026.5.12-beta.1"
"openclawVersion": "2026.5.12-beta.7"
},
"release": {
"publishToClawHub": true,

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/browser-plugin",
"version": "2026.5.12-beta.1",
"version": "2026.5.12-beta.7",
"private": true,
"description": "OpenClaw browser tool plugin",
"type": "module",

View File

@@ -7,6 +7,10 @@ import type {
OpenClawPluginToolContext,
OpenClawPluginToolFactory,
} from "openclaw/plugin-sdk/plugin-entry";
import {
BROWSER_REQUEST_GATEWAY_METHOD,
BROWSER_REQUEST_GATEWAY_SCOPE,
} from "./src/browser-gateway-contract.js";
import { BrowserToolSchema } from "./src/browser-tool.schema.js";
const BROWSER_CLI_DESCRIPTOR = {
@@ -107,13 +111,13 @@ export function registerBrowserPlugin(api: OpenClawPluginApi) {
{ commands: ["browser"], descriptors: [BROWSER_CLI_DESCRIPTOR] },
);
api.registerGatewayMethod(
"browser.request",
BROWSER_REQUEST_GATEWAY_METHOD,
async (opts) => {
const { handleBrowserGatewayRequest } = await import("./register.runtime.js");
return await handleBrowserGatewayRequest(opts);
},
{
scope: "operator.admin",
scope: BROWSER_REQUEST_GATEWAY_SCOPE,
},
);
api.registerService(createLazyBrowserPluginService());

View File

@@ -0,0 +1,3 @@
export const BROWSER_REQUEST_GATEWAY_METHOD = "browser.request" as const;
export const BROWSER_REQUEST_GATEWAY_SCOPE = "operator.admin" as const;
export const BROWSER_REQUEST_GATEWAY_SCOPES = [BROWSER_REQUEST_GATEWAY_SCOPE] as const;

View File

@@ -0,0 +1,34 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { callGatewayFromCli } from "./core-api.js";
type CallGatewayFromCliArgs = Parameters<typeof callGatewayFromCli>;
const gatewayMocks = vi.hoisted(() => ({
callGatewayFromCli: vi.fn(async () => ({ ok: true })),
}));
vi.mock("./core-api.js", () => ({
callGatewayFromCli: gatewayMocks.callGatewayFromCli,
}));
const { callBrowserRequest } = await import("./browser-cli-shared.js");
describe("callBrowserRequest", () => {
beforeEach(() => {
gatewayMocks.callGatewayFromCli.mockClear();
});
it("requests the browser.request admin scope explicitly", async () => {
await callBrowserRequest(
{ json: true },
{ method: "GET", path: "/status", query: { profile: "openclaw" } },
{ progress: true },
);
const call = gatewayMocks.callGatewayFromCli.mock.calls[0] as unknown as
| CallGatewayFromCliArgs
| undefined;
const extra = call?.[3];
expect(extra).toEqual({ progress: true, scopes: ["operator.admin"] });
});
});

View File

@@ -1,4 +1,8 @@
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
import {
BROWSER_REQUEST_GATEWAY_METHOD,
BROWSER_REQUEST_GATEWAY_SCOPES,
} from "../browser-gateway-contract.js";
import { callGatewayFromCli, type GatewayRpcOpts } from "./core-api.js";
export type BrowserParentOpts = GatewayRpcOpts & {
@@ -44,7 +48,7 @@ export async function callBrowserRequest<T>(
: undefined;
const timeout = typeof resolvedTimeout === "number" ? String(resolvedTimeout) : opts.timeout;
const payload = await callGatewayFromCli(
"browser.request",
BROWSER_REQUEST_GATEWAY_METHOD,
{ ...opts, timeout },
{
method: params.method,
@@ -53,7 +57,7 @@ export async function callBrowserRequest<T>(
body: params.body,
timeoutMs: resolvedTimeout,
},
{ progress: extra?.progress },
{ progress: extra?.progress, scopes: [...BROWSER_REQUEST_GATEWAY_SCOPES] },
);
if (payload === undefined) {
throw new Error("Unexpected browser.request response");

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/byteplus-provider",
"version": "2026.5.12-beta.1",
"version": "2026.5.12-beta.7",
"private": true,
"description": "OpenClaw BytePlus provider plugin",
"type": "module",

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/canvas-plugin",
"version": "2026.5.12-beta.1",
"version": "2026.5.12-beta.7",
"private": true,
"description": "OpenClaw Canvas plugin",
"type": "module",

View File

@@ -1 +1 @@
6f42494c638de9f72ce783550b4a7c62912c26d47293641e588625afa06db370
2f0703377bc45c03973b81cd067fbfaa2ae8ad98ccd69f3ad47731edf5984fe0

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/cerebras-provider",
"version": "2026.5.12-beta.1",
"version": "2026.5.12-beta.7",
"private": true,
"description": "OpenClaw Cerebras provider plugin",
"type": "module",

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/chutes-provider",
"version": "2026.5.12-beta.1",
"version": "2026.5.12-beta.7",
"private": true,
"description": "OpenClaw Chutes.ai provider plugin",
"type": "module",

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/clickclack",
"version": "2026.5.12-beta.1",
"version": "2026.5.12-beta.7",
"private": true,
"description": "OpenClaw ClickClack channel plugin",
"type": "module",
@@ -18,7 +18,7 @@
"openclaw": "workspace:*"
},
"peerDependencies": {
"openclaw": ">=2026.5.12-beta.1"
"openclaw": ">=2026.5.12-beta.7"
},
"peerDependenciesMeta": {
"openclaw": {

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/cloudflare-ai-gateway-provider",
"version": "2026.5.12-beta.1",
"version": "2026.5.12-beta.7",
"private": true,
"description": "OpenClaw Cloudflare AI Gateway provider plugin",
"type": "module",

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/codex",
"version": "2026.5.12-beta.1",
"version": "2026.5.12-beta.7",
"description": "OpenClaw Codex harness and model provider plugin",
"repository": {
"type": "git",
@@ -27,10 +27,10 @@
"minHostVersion": ">=2026.5.1-beta.1"
},
"compat": {
"pluginApi": ">=2026.5.12-beta.1"
"pluginApi": ">=2026.5.12-beta.7"
},
"build": {
"openclawVersion": "2026.5.12-beta.1"
"openclawVersion": "2026.5.12-beta.7"
},
"release": {
"publishToClawHub": true,

View File

@@ -12,6 +12,7 @@ import {
bridgeCodexAppServerStartOptions,
refreshCodexAppServerAuthTokens,
resolveCodexAppServerAuthAccountCacheKey,
resolveCodexAppServerAuthProfileId,
resolveCodexAppServerHomeDir,
resolveCodexAppServerNativeHomeDir,
} from "./auth-bridge.js";
@@ -172,7 +173,7 @@ async function writeCodexCliAuthFile(codexHome: string): Promise<void> {
}
describe("bridgeCodexAppServerStartOptions", () => {
it("sets agent-owned CODEX_HOME and HOME for local app-server launches", async () => {
it("sets agent-owned CODEX_HOME without overriding HOME for local app-server launches", async () => {
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-"));
const startOptions = createStartOptions();
try {
@@ -188,17 +189,40 @@ describe("bridgeCodexAppServerStartOptions", () => {
...startOptions,
env: {
CODEX_HOME: codexHome,
HOME: nativeHome,
},
});
await expect(fs.access(codexHome)).resolves.toBeUndefined();
await expect(fs.access(nativeHome)).resolves.toBeUndefined();
await expectPathMissing(nativeHome);
expect(startOptions.env).toBeUndefined();
} finally {
await fs.rm(agentDir, { recursive: true, force: true });
}
});
it("preserves inherited HOME when clearEnv asks to clear app-server isolation vars", async () => {
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-"));
const startOptions = createStartOptions({
clearEnv: ["CODEX_HOME", "HOME", "FOO"],
});
try {
await expect(
bridgeCodexAppServerStartOptions({
startOptions,
agentDir,
}),
).resolves.toEqual({
...startOptions,
env: {
CODEX_HOME: resolveCodexAppServerHomeDir(agentDir),
},
clearEnv: ["FOO"],
});
expect(startOptions.clearEnv).toEqual(["CODEX_HOME", "HOME", "FOO"]);
} finally {
await fs.rm(agentDir, { recursive: true, force: true });
}
});
it("preserves explicit CODEX_HOME and HOME overrides", async () => {
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-"));
const codexHome = path.join(agentDir, "custom-codex-home");
@@ -260,7 +284,6 @@ describe("bridgeCodexAppServerStartOptions", () => {
env: {
EXISTING: "1",
CODEX_HOME: resolveCodexAppServerHomeDir(agentDir),
HOME: resolveCodexAppServerNativeHomeDir(agentDir),
},
clearEnv: ["FOO", "CODEX_API_KEY", "OPENAI_API_KEY"],
});
@@ -298,7 +321,6 @@ describe("bridgeCodexAppServerStartOptions", () => {
...startOptions,
env: {
CODEX_HOME: resolveCodexAppServerHomeDir(agentDir),
HOME: resolveCodexAppServerNativeHomeDir(agentDir),
},
clearEnv: ["FOO", "CODEX_API_KEY", "OPENAI_API_KEY"],
});
@@ -331,7 +353,6 @@ describe("bridgeCodexAppServerStartOptions", () => {
...startOptions,
env: {
CODEX_HOME: resolveCodexAppServerHomeDir(agentDir),
HOME: resolveCodexAppServerNativeHomeDir(agentDir),
},
clearEnv: ["FOO", "CODEX_API_KEY", "OPENAI_API_KEY"],
});
@@ -364,7 +385,6 @@ describe("bridgeCodexAppServerStartOptions", () => {
...startOptions,
env: {
CODEX_HOME: resolveCodexAppServerHomeDir(agentDir),
HOME: resolveCodexAppServerNativeHomeDir(agentDir),
},
});
} finally {
@@ -550,6 +570,25 @@ describe("bridgeCodexAppServerStartOptions", () => {
}
});
it("leaves native app-server auth untouched when auth bridging is disabled", async () => {
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-"));
const request = vi.fn(async () => ({ requiresOpenaiAuth: true }));
try {
vi.stubEnv("OPENAI_API_KEY", "env-api-key");
await applyCodexAppServerAuthProfile({
client: { request } as never,
agentDir,
authProfileId: null,
startOptions: createStartOptions(),
});
expect(request).not.toHaveBeenCalled();
} finally {
await fs.rm(agentDir, { recursive: true, force: true });
}
});
it("applies a normal OpenAI API-key profile as a Codex app-server backup", async () => {
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-"));
const request = vi.fn(async () => ({ type: "apiKey" }));
@@ -613,6 +652,65 @@ describe("bridgeCodexAppServerStartOptions", () => {
}
});
it("selects an oauthRef-backed Codex profile for app-server login", () => {
expect(
resolveCodexAppServerAuthProfileId({
store: {
version: 1,
profiles: {
"openai-codex:default": {
type: "oauth",
provider: "openai-codex",
access: "",
refresh: "",
expires: Date.now() + 60_000,
oauthRef: {
source: "openclaw-credentials",
provider: "openai-codex",
id: "0123456789abcdef0123456789abcdef",
},
},
},
},
}),
).toBe("openai-codex:default");
});
it("answers refresh requests from a discovered oauthRef-backed Codex profile", async () => {
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-"));
oauthMocks.refreshOpenAICodexToken.mockResolvedValueOnce({
access: "refreshed-ref-backed-access-token",
refresh: "refreshed-ref-backed-refresh-token",
expires: Date.now() + 60_000,
accountId: "account-ref-backed-refreshed",
});
try {
upsertAuthProfile({
agentDir,
profileId: "openai-codex:default",
credential: {
type: "oauth",
provider: "openai-codex",
access: "ref-backed-access-token",
refresh: "ref-backed-refresh-token",
expires: Date.now() + 60_000,
accountId: "account-ref-backed",
email: "codex@example.test",
},
});
await expect(refreshCodexAppServerAuthTokens({ agentDir })).resolves.toEqual({
accessToken: "refreshed-ref-backed-access-token",
chatgptAccountId: "account-ref-backed-refreshed",
chatgptPlanType: null,
});
expect(oauthMocks.refreshOpenAICodexToken).toHaveBeenCalledWith("ref-backed-refresh-token");
} finally {
await fs.rm(agentDir, { recursive: true, force: true });
}
});
it("applies native Codex CLI OAuth when no OpenClaw auth profile exists", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-"));
const agentDir = path.join(root, "agent");

View File

@@ -35,14 +35,14 @@ const CODEX_APP_SERVER_NATIVE_HOME_DIRNAME = "home";
const CODEX_API_KEY_ENV_VAR = "CODEX_API_KEY";
const OPENAI_API_KEY_ENV_VAR = "OPENAI_API_KEY";
const CODEX_APP_SERVER_API_KEY_ENV_VARS = [CODEX_API_KEY_ENV_VAR, OPENAI_API_KEY_ENV_VAR];
const CODEX_APP_SERVER_ISOLATION_ENV_VARS = [CODEX_HOME_ENV_VAR, HOME_ENV_VAR];
const CODEX_APP_SERVER_HOME_ENV_VARS = [CODEX_HOME_ENV_VAR, HOME_ENV_VAR];
type AuthProfileOrderConfig = Parameters<typeof resolveAuthProfileOrder>[0]["cfg"];
export async function bridgeCodexAppServerStartOptions(params: {
startOptions: CodexAppServerStartOptions;
agentDir: string;
authProfileId?: string;
authProfileId?: string | null;
config?: AuthProfileOrderConfig;
}): Promise<CodexAppServerStartOptions> {
if (params.startOptions.transport !== "stdio") {
@@ -52,6 +52,9 @@ export async function bridgeCodexAppServerStartOptions(params: {
params.startOptions,
params.agentDir,
);
if (params.authProfileId === null) {
return isolatedStartOptions;
}
const store = ensureCodexAppServerAuthProfileStore({
agentDir: params.agentDir,
authProfileId: params.authProfileId,
@@ -259,18 +262,20 @@ async function withAgentCodexHomeEnvironment(
: resolveCodexAppServerHomeDir(agentDir);
const nativeHome = startOptions.env?.[HOME_ENV_VAR]?.trim()
? startOptions.env[HOME_ENV_VAR]
: path.join(codexHome, CODEX_APP_SERVER_NATIVE_HOME_DIRNAME);
: undefined;
await fs.mkdir(codexHome, { recursive: true });
await fs.mkdir(nativeHome, { recursive: true });
if (nativeHome) {
await fs.mkdir(nativeHome, { recursive: true });
}
const nextStartOptions: CodexAppServerStartOptions = {
...startOptions,
env: {
...startOptions.env,
[CODEX_HOME_ENV_VAR]: codexHome,
[HOME_ENV_VAR]: nativeHome,
...(nativeHome ? { [HOME_ENV_VAR]: nativeHome } : {}),
},
};
const clearEnv = withoutClearedCodexIsolationEnv(startOptions.clearEnv);
const clearEnv = withoutClearedCodexHomeEnv(startOptions.clearEnv);
if (clearEnv) {
nextStartOptions.clearEnv = clearEnv;
} else {
@@ -279,11 +284,11 @@ async function withAgentCodexHomeEnvironment(
return nextStartOptions;
}
function withoutClearedCodexIsolationEnv(clearEnv: string[] | undefined): string[] | undefined {
function withoutClearedCodexHomeEnv(clearEnv: string[] | undefined): string[] | undefined {
if (!clearEnv) {
return undefined;
}
const reserved = new Set(CODEX_APP_SERVER_ISOLATION_ENV_VARS);
const reserved = new Set(CODEX_APP_SERVER_HOME_ENV_VARS);
const filtered = clearEnv.filter((envVar) => !reserved.has(envVar.trim().toUpperCase()));
return filtered.length === clearEnv.length ? clearEnv : filtered;
}
@@ -291,10 +296,13 @@ function withoutClearedCodexIsolationEnv(clearEnv: string[] | undefined): string
export async function applyCodexAppServerAuthProfile(params: {
client: CodexAppServerClient;
agentDir: string;
authProfileId?: string;
authProfileId?: string | null;
startOptions?: CodexAppServerStartOptions;
config?: AuthProfileOrderConfig;
}): Promise<void> {
if (params.authProfileId === null) {
return;
}
const loginParams = await resolveCodexAppServerAuthProfileLoginParams({
agentDir: params.agentDir,
authProfileId: params.authProfileId,

View File

@@ -126,6 +126,42 @@ describe("CodexAppServerClient", () => {
await expect(request).rejects.toHaveProperty("message", "Method not found");
});
it("surfaces relogin details from Codex app-server RPC errors", async () => {
const harness = createClientHarness();
clients.push(harness.client);
const request = harness.client.request("thread/start", {});
const outbound = JSON.parse(harness.writes[0] ?? "{}") as { id?: number };
harness.send({
id: outbound.id,
error: {
code: -32602,
message: "failed to load configuration",
data: {
reason: "cloudRequirements",
errorCode: "Auth",
action: "relogin",
statusCode: 401,
detail:
"Your authentication session could not be refreshed automatically. Please log out and sign in again.",
},
},
});
await expect(request).rejects.toHaveProperty(
"message",
"failed to load configuration: Your authentication session could not be refreshed automatically. Please log out and sign in again.",
);
await expect(request).rejects.toHaveProperty("data", {
reason: "cloudRequirements",
errorCode: "Auth",
action: "relogin",
statusCode: 401,
detail:
"Your authentication session could not be refreshed automatically. Please log out and sign in again.",
});
});
it("rejects timed-out requests and ignores late responses", async () => {
vi.useFakeTimers();
const harness = createClientHarness();

View File

@@ -42,13 +42,39 @@ export class CodexAppServerRpcError extends Error {
readonly data?: JsonValue;
constructor(error: { code?: number; message: string; data?: JsonValue }, method: string) {
super(error.message || `${method} failed`);
super(formatCodexAppServerRpcErrorMessage(error, method));
this.name = "CodexAppServerRpcError";
this.code = error.code;
this.data = error.data;
}
}
function formatCodexAppServerRpcErrorMessage(
error: { message: string; data?: JsonValue },
method: string,
): string {
const message = error.message || `${method} failed`;
const detail = readCodexAppServerRpcReloginDetail(error.data);
return detail && !message.includes(detail) ? `${message}: ${detail}` : message;
}
function readCodexAppServerRpcReloginDetail(data: JsonValue | undefined): string | undefined {
const record = isJsonObject(data) ? data : undefined;
const nested = isJsonObject(record?.error) ? record.error : record;
if (!nested) {
return undefined;
}
const isRelogin =
nested.action === "relogin" ||
(nested.reason === "cloudRequirements" && nested.errorCode === "Auth");
const detail = typeof nested.detail === "string" ? nested.detail.trim() : "";
return isRelogin && detail ? detail : undefined;
}
function isJsonObject(value: unknown): value is { [key: string]: JsonValue } {
return Boolean(value && typeof value === "object" && !Array.isArray(value));
}
export function isCodexAppServerConnectionClosedError(error: unknown): boolean {
if (!(error instanceof Error)) {
return false;

View File

@@ -320,6 +320,37 @@ describe("createCodexDynamicToolBridge", () => {
]);
});
it("records internal UI source replies separately from outbound messaging evidence", async () => {
const toolResult = textToolResult("Sent to current chat.", {
status: "ok",
deliveryStatus: "sent",
sourceReplySink: "internal-ui",
sourceReply: {
text: "visible reply",
mediaUrls: ["/tmp/reply.png"],
},
});
const bridge = createBridgeWithToolResult("message", toolResult);
const result = await handleMessageToolCall(bridge, {
action: "send",
message: "<think>private</think>visible reply",
});
expect(result).toEqual(expectInputText("Sent to current chat."));
expect(bridge.telemetry.didSendViaMessagingTool).toBe(true);
expect(bridge.telemetry.messagingToolSentTexts).toEqual([]);
expect(bridge.telemetry.messagingToolSentMediaUrls).toEqual([]);
expect(bridge.telemetry.messagingToolSentTargets).toEqual([]);
expect(bridge.telemetry.messagingToolSourceReplyPayloads).toEqual([
{
text: "visible reply",
mediaUrl: "/tmp/reply.png",
mediaUrls: ["/tmp/reply.png"],
},
]);
});
it("does not record messaging side effects when the send fails", async () => {
const tool = createTool({
name: "message",

View File

@@ -15,6 +15,7 @@ import {
type AnyAgentTool,
type HeartbeatToolResponse,
type MessagingToolSend,
type MessagingToolSourceReplyPayload,
wrapToolWithBeforeToolCallHook,
} from "openclaw/plugin-sdk/agent-harness-runtime";
import type { CodexDynamicToolsLoading } from "./config.js";
@@ -47,6 +48,7 @@ export type CodexDynamicToolBridge = {
messagingToolSentTexts: string[];
messagingToolSentMediaUrls: string[];
messagingToolSentTargets: MessagingToolSend[];
messagingToolSourceReplyPayloads: MessagingToolSourceReplyPayload[];
heartbeatToolResponse?: HeartbeatToolResponse;
toolMediaUrls: string[];
toolAudioAsVoice: boolean;
@@ -77,6 +79,7 @@ export function createCodexDynamicToolBridge(params: {
messagingToolSentTexts: [],
messagingToolSentMediaUrls: [],
messagingToolSentTargets: [],
messagingToolSourceReplyPayloads: [],
toolMediaUrls: [],
toolAudioAsVoice: false,
};
@@ -279,6 +282,11 @@ function collectToolTelemetry(params: {
return;
}
params.telemetry.didSendViaMessagingTool = true;
const sourceReplyPayload = extractInternalSourceReplyPayload(params.result?.details);
if (sourceReplyPayload) {
params.telemetry.messagingToolSourceReplyPayloads.push(sourceReplyPayload);
return;
}
const text = readFirstString(params.args, ["text", "message", "body", "content"]);
if (text) {
params.telemetry.messagingToolSentTexts.push(text);
@@ -296,6 +304,41 @@ function collectToolTelemetry(params: {
});
}
function extractInternalSourceReplyPayload(
details: unknown,
): MessagingToolSourceReplyPayload | undefined {
if (!isRecord(details) || details.sourceReplySink !== "internal-ui") {
return undefined;
}
const rawPayload = details.sourceReply;
if (!isRecord(rawPayload)) {
return undefined;
}
const text = readFirstString(rawPayload, ["text", "message"]);
const mediaUrls = collectMediaUrls(rawPayload);
const mediaUrl =
typeof rawPayload.mediaUrl === "string" && rawPayload.mediaUrl.trim()
? rawPayload.mediaUrl.trim()
: mediaUrls[0];
const payload: MessagingToolSourceReplyPayload = {
...(text ? { text } : {}),
...(mediaUrl ? { mediaUrl } : {}),
...(mediaUrls.length > 0 ? { mediaUrls } : {}),
...(rawPayload.audioAsVoice === true ? { audioAsVoice: true } : {}),
...(isRecord(rawPayload.presentation)
? { presentation: rawPayload.presentation as never }
: {}),
...(isRecord(rawPayload.interactive) ? { interactive: rawPayload.interactive as never } : {}),
...(isRecord(rawPayload.channelData) ? { channelData: rawPayload.channelData } : {}),
...(typeof details.idempotencyKey === "string" && details.idempotencyKey.trim()
? { idempotencyKey: details.idempotencyKey.trim() }
: {}),
};
return text || mediaUrls.length > 0 || payload.presentation || payload.interactive
? payload
: undefined;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}

View File

@@ -17,6 +17,7 @@ import {
type EmbeddedRunAttemptResult,
type HeartbeatToolResponse,
type MessagingToolSend,
type MessagingToolSourceReplyPayload,
type ToolProgressDetailMode,
} from "openclaw/plugin-sdk/agent-harness-runtime";
import { emitTrustedDiagnosticEvent } from "openclaw/plugin-sdk/diagnostic-runtime";
@@ -46,6 +47,7 @@ export type CodexAppServerToolTelemetry = {
messagingToolSentTexts: string[];
messagingToolSentMediaUrls: string[];
messagingToolSentTargets: MessagingToolSend[];
messagingToolSourceReplyPayloads?: MessagingToolSourceReplyPayload[];
heartbeatToolResponse?: HeartbeatToolResponse;
toolMediaUrls?: string[];
toolAudioAsVoice?: boolean;
@@ -320,6 +322,7 @@ export class CodexAppServerEventProjector {
messagingToolSentTexts: toolTelemetry.messagingToolSentTexts,
messagingToolSentMediaUrls: toolTelemetry.messagingToolSentMediaUrls,
messagingToolSentTargets: toolTelemetry.messagingToolSentTargets,
messagingToolSourceReplyPayloads: toolTelemetry.messagingToolSourceReplyPayloads ?? [],
heartbeatToolResponse: toolTelemetry.heartbeatToolResponse,
toolMediaUrls: this.buildToolMediaUrls(toolTelemetry),
toolAudioAsVoice: toolTelemetry.toolAudioAsVoice,

View File

@@ -0,0 +1,74 @@
import { createHash } from "node:crypto";
import {
buildCodexAppInventoryCacheKey,
type CodexAppInventoryCacheKeyInput,
} from "./app-inventory-cache.js";
import { resolveCodexAppServerHomeDir } from "./auth-bridge.js";
import type { CodexAppServerRuntimeOptions, CodexAppServerStartOptions } from "./config.js";
export type CodexPluginAppCacheKeyParams = Omit<
CodexAppInventoryCacheKeyInput,
"codexHome" | "endpoint"
> & {
appServer: Pick<CodexAppServerRuntimeOptions, "start">;
agentDir?: string;
};
export function buildCodexPluginAppCacheKey(params: CodexPluginAppCacheKeyParams): string {
return buildCodexAppInventoryCacheKey({
codexHome: resolveCodexPluginAppCacheCodexHome(params.appServer, params.agentDir),
endpoint: resolveCodexPluginAppCacheEndpoint(params.appServer),
authProfileId: params.authProfileId,
accountId: params.accountId,
envApiKeyFingerprint: params.envApiKeyFingerprint,
appServerVersion: params.appServerVersion,
});
}
export function resolveCodexPluginAppCacheEndpoint(
appServer: Pick<CodexAppServerRuntimeOptions, "start">,
): string {
return JSON.stringify({
transport: appServer.start.transport,
command: appServer.start.command,
args: appServer.start.args,
url: appServer.start.url ?? null,
credentialFingerprint: fingerprintCodexPluginAppCacheCredentials(appServer.start),
});
}
export function resolveCodexPluginAppCacheCodexHome(
appServer: Pick<CodexAppServerRuntimeOptions, "start">,
agentDir?: string,
): string | undefined {
const configuredCodexHome = appServer.start.env?.CODEX_HOME?.trim();
if (configuredCodexHome) {
return configuredCodexHome;
}
return appServer.start.transport === "stdio" && agentDir
? resolveCodexAppServerHomeDir(agentDir)
: undefined;
}
function fingerprintCodexPluginAppCacheCredentials(
startOptions: CodexAppServerStartOptions,
): string | null {
const authToken = startOptions.authToken ?? "";
const headers = Object.entries(startOptions.headers)
.map(([key, value]) => [key.toLowerCase(), value] as const)
.toSorted(([left], [right]) => left.localeCompare(right));
if (!authToken && headers.length === 0) {
return null;
}
const hash = createHash("sha256");
hash.update("openclaw:codex:plugin-app-cache-credentials:v1");
hash.update("\0");
hash.update(authToken);
for (const [key, value] of headers) {
hash.update("\0");
hash.update(key);
hash.update("\0");
hash.update(value);
}
return `sha256:${hash.digest("hex")}`;
}

View File

@@ -17,7 +17,8 @@ export async function requestCodexAppServerJson<M extends CodexAppServerRequestM
requestParams: CodexAppServerRequestParams<M>;
timeoutMs?: number;
startOptions?: CodexAppServerStartOptions;
authProfileId?: string;
authProfileId?: string | null;
agentDir?: string;
config?: Parameters<typeof resolveCodexAppServerAuthProfileIdForAgent>[0]["config"];
isolated?: boolean;
}): Promise<CodexAppServerRequestResult<M>>;
@@ -26,7 +27,8 @@ export async function requestCodexAppServerJson<T = JsonValue | undefined>(param
requestParams?: unknown;
timeoutMs?: number;
startOptions?: CodexAppServerStartOptions;
authProfileId?: string;
authProfileId?: string | null;
agentDir?: string;
config?: Parameters<typeof resolveCodexAppServerAuthProfileIdForAgent>[0]["config"];
isolated?: boolean;
}): Promise<T>;
@@ -35,7 +37,8 @@ export async function requestCodexAppServerJson<T = JsonValue | undefined>(param
requestParams?: unknown;
timeoutMs?: number;
startOptions?: CodexAppServerStartOptions;
authProfileId?: string;
authProfileId?: string | null;
agentDir?: string;
config?: Parameters<typeof resolveCodexAppServerAuthProfileIdForAgent>[0]["config"];
isolated?: boolean;
}): Promise<T> {
@@ -48,13 +51,19 @@ export async function requestCodexAppServerJson<T = JsonValue | undefined>(param
startOptions: params.startOptions,
timeoutMs,
authProfileId: params.authProfileId,
agentDir: params.agentDir,
config: params.config,
});
try {
return await client.request<T>(params.method, params.requestParams, { timeoutMs });
} finally {
if (params.isolated) {
client.close();
// Wait for the child to actually exit (with a SIGKILL fallback) so
// the parent process doesn't hang on an orphaned codex app-server.
// The stdio bin shim does not always propagate stdin EOF to the
// underlying codex binary, so the unref'd close() path can leave
// the child running and keep the parent's event loop alive.
await client.closeAndWait({ exitTimeoutMs: 2_000, forceKillDelayMs: 250 });
}
}
})(),

View File

@@ -11,6 +11,7 @@ import {
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { CodexServerNotification } from "./protocol.js";
import { runCodexAppServerAttempt, __testing } from "./run-attempt.js";
import { readCodexAppServerBinding, writeCodexAppServerBinding } from "./session-binding.js";
import { createCodexTestModel } from "./test-support.js";
let tempDir: string;
@@ -373,6 +374,70 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
await run;
});
it("retries a resumed context-engine thread on a fresh Codex thread after early context overflow", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
await writeCodexAppServerBinding(sessionFile, {
threadId: "thread-old",
cwd: workspaceDir,
dynamicToolsFingerprint: "[]",
contextEngine: {
schemaVersion: 1,
engineId: "lossless-claw",
policyFingerprint:
'{"schemaVersion":1,"engineId":"lossless-claw","ownsCompaction":true,"contextTokenBudget":400000,"projectionMaxChars":1000000}',
},
});
const contextEngine = createContextEngine();
const harness = createStartedThreadHarness(async (method, requestParams) => {
const request = requireRecord(requestParams, `${method} params`);
if (method === "thread/resume") {
return threadStartResult("thread-old");
}
if (method === "turn/start" && request.threadId === "thread-old") {
throw new Error("Codex ran out of room in the model's context window");
}
if (method === "thread/start") {
return threadStartResult("thread-fresh");
}
if (method === "turn/start" && request.threadId === "thread-fresh") {
return turnStartResult("turn-fresh");
}
return undefined;
});
const params = createParams(sessionFile, workspaceDir);
params.contextEngine = contextEngine;
params.contextTokenBudget = 400_000;
const run = runCodexAppServerAttempt(params);
await vi.waitFor(() =>
expect(harness.requests.map((request) => request.method)).toEqual([
"thread/resume",
"turn/start",
"thread/start",
"turn/start",
]),
);
await harness.notify({
method: "turn/completed",
params: {
threadId: "thread-fresh",
turnId: "turn-fresh",
turn: {
id: "turn-fresh",
status: "completed",
items: [{ type: "agentMessage", id: "msg-1", text: "fresh answer" }],
},
},
});
const result = await run;
expect(result.assistantTexts).toContain("fresh answer");
const savedBinding = await readCodexAppServerBinding(sessionFile);
expect(savedBinding?.threadId).toBe("thread-fresh");
expect(savedBinding?.contextEngine?.engineId).toBe("lossless-claw");
});
it("keeps current-turn context at the front of the Codex context-engine prompt", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");

View File

@@ -25,20 +25,18 @@ function queueActiveRunMessageForTest(
return queueAgentHarnessMessage(...args);
}
import { CODEX_GPT5_BEHAVIOR_CONTRACT } from "../../prompt-overlay.js";
import {
buildCodexAppInventoryCacheKey,
defaultCodexAppInventoryCache,
} from "./app-inventory-cache.js";
import {
resolveCodexAppServerEnvApiKeyCacheKey,
resolveCodexAppServerHomeDir,
} from "./auth-bridge.js";
import { defaultCodexAppInventoryCache } from "./app-inventory-cache.js";
import { resolveCodexAppServerEnvApiKeyCacheKey } from "./auth-bridge.js";
import { readCodexPluginConfig, resolveCodexAppServerRuntimeOptions } from "./config.js";
import {
CODEX_OPENCLAW_DYNAMIC_TOOL_NAMESPACE,
createCodexDynamicToolBridge,
} from "./dynamic-tools.js";
import * as elicitationBridge from "./elicitation-bridge.js";
import {
buildCodexPluginAppCacheKey,
resolveCodexPluginAppCacheEndpoint,
} from "./plugin-app-cache-key.js";
import type { CodexServerNotification } from "./protocol.js";
import { rememberCodexRateLimits, resetCodexRateLimitCacheForTests } from "./rate-limit-cache.js";
import { runCodexAppServerAttempt, __testing } from "./run-attempt.js";
@@ -645,6 +643,109 @@ describe("runCodexAppServerAttempt", () => {
}
});
it("passes MCP server config through to Codex thread/start", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
const request = vi.fn(async (method: string, _params: unknown) => {
if (method === "thread/start") {
return threadStartResult();
}
throw new Error(`unexpected method: ${method}`);
});
await startOrResumeThread({
client: { request } as never,
params: createParams(sessionFile, workspaceDir),
cwd: workspaceDir,
dynamicTools: [],
appServer: createThreadLifecycleAppServerOptions(),
config: {
mcp_servers: {
search: {
url: "https://mcp.example.com/mcp",
},
},
},
mcpServersFingerprint: "mcp-v1",
mcpServersFingerprintEvaluated: true,
});
const startRequest = request.mock.calls.find(([method]) => method === "thread/start");
expect((startRequest?.[1] as { config?: unknown } | undefined)?.config).toMatchObject({
mcp_servers: {
search: {
url: "https://mcp.example.com/mcp",
},
},
"features.code_mode": true,
"features.code_mode_only": true,
});
const binding = await readCodexAppServerBinding(sessionFile);
expect(binding?.mcpServersFingerprint).toBe("mcp-v1");
});
it("starts a new Codex thread when the MCP server fingerprint changes", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
await writeCodexAppServerBinding(sessionFile, {
threadId: "old-thread",
cwd: workspaceDir,
dynamicToolsFingerprint: JSON.stringify([]),
mcpServersFingerprint: "mcp-v1",
});
const request = vi.fn(async (method: string, _params: unknown) => {
if (method === "thread/start") {
return threadStartResult("new-thread");
}
throw new Error(`unexpected method: ${method}`);
});
const binding = await startOrResumeThread({
client: { request } as never,
params: createParams(sessionFile, workspaceDir),
cwd: workspaceDir,
dynamicTools: [],
appServer: createThreadLifecycleAppServerOptions(),
mcpServersFingerprint: "mcp-v2",
mcpServersFingerprintEvaluated: true,
});
expect(request.mock.calls.map(([method]) => method)).toEqual(["thread/start"]);
expect(binding.threadId).toBe("new-thread");
expect(binding.mcpServersFingerprint).toBe("mcp-v2");
});
it("starts a no-MCP Codex thread when MCP config is evaluated empty", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
await writeCodexAppServerBinding(sessionFile, {
threadId: "old-thread",
cwd: workspaceDir,
dynamicToolsFingerprint: JSON.stringify([]),
mcpServersFingerprint: "mcp-v1",
});
const request = vi.fn(async (method: string, _params: unknown) => {
if (method === "thread/start") {
return threadStartResult("new-thread");
}
throw new Error(`unexpected method: ${method}`);
});
const binding = await startOrResumeThread({
client: { request } as never,
params: createParams(sessionFile, workspaceDir),
cwd: workspaceDir,
dynamicTools: [],
appServer: createThreadLifecycleAppServerOptions(),
mcpServersFingerprintEvaluated: true,
});
expect(request.mock.calls.map(([method]) => method)).toEqual(["thread/start"]);
expect(binding.threadId).toBe("new-thread");
expect(binding.mcpServersFingerprint).toBeUndefined();
expect((await readCodexAppServerBinding(sessionFile))?.mcpServersFingerprint).toBeUndefined();
});
it("does not expose OpenClaw Tool Search controls through Codex dynamic tools", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
@@ -683,6 +784,47 @@ describe("runCodexAppServerAttempt", () => {
}
});
it("passes auth profiles into Codex dynamic tool construction", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
const params = createParams(sessionFile, workspaceDir);
const authProfileStore = {
version: 1,
profiles: {
"openai:api-key-backup": {
provider: "openai",
type: "api_key",
key: "not-a-real-key",
},
},
} satisfies EmbeddedRunAttemptParams["authProfileStore"];
params.disableTools = false;
params.authProfileStore = authProfileStore;
const factoryOptions: unknown[] = [];
__testing.setOpenClawCodingToolsFactoryForTests((options) => {
factoryOptions.push(options);
return [];
});
await __testing.buildDynamicTools({
params,
resolvedWorkspace: workspaceDir,
effectiveWorkspace: workspaceDir,
sandboxSessionKey: params.sessionKey!,
sandbox: null as never,
runAbortController: new AbortController(),
sessionAgentId: "main",
pluginConfig: {},
onYieldDetected: () => undefined,
});
expect(factoryOptions).toHaveLength(1);
expect(factoryOptions[0]).toMatchObject({
authProfileStore,
});
});
it("normalizes Codex dynamic toolsAllow entries before filtering", () => {
const tools = ["exec", "apply_patch", "read", "message"].map((name) => ({ name }));
@@ -1371,6 +1513,98 @@ describe("runCodexAppServerAttempt", () => {
expect(warnData?.lastActivityReason).toBe("request:item/tool/call:response");
});
it("keeps the post-tool completion watchdog armed across dynamic tool completion bookkeeping", async () => {
let notify: (notification: CodexServerNotification) => Promise<void> = async () => undefined;
let handleRequest:
| ((request: { id: string; method: string; params?: unknown }) => Promise<unknown>)
| undefined;
const warn = vi.spyOn(embeddedAgentLog, "warn").mockImplementation(() => undefined);
const request = vi.fn(async (method: string) => {
if (method === "thread/start") {
return threadStartResult("thread-1");
}
if (method === "turn/start") {
return turnStartResult("turn-1", "inProgress");
}
return {};
});
__testing.setCodexAppServerClientFactoryForTests(
async () =>
({
request,
addNotificationHandler: (handler: typeof notify) => {
notify = handler;
return () => undefined;
},
addRequestHandler: (
handler: (request: {
id: string;
method: string;
params?: unknown;
}) => Promise<unknown>,
) => {
handleRequest = handler;
return () => undefined;
},
}) as never,
);
const params = createParams(
path.join(tempDir, "session.jsonl"),
path.join(tempDir, "workspace"),
);
params.timeoutMs = 60_000;
const run = runCodexAppServerAttempt(params, {
turnCompletionIdleTimeoutMs: 5,
turnTerminalIdleTimeoutMs: 200,
});
await vi.waitFor(() => expect(handleRequest).toBeTypeOf("function"), { interval: 1 });
const toolResult = (await handleRequest?.({
id: "request-tool-1",
method: "item/tool/call",
params: {
threadId: "thread-1",
turnId: "turn-1",
callId: "call-1",
namespace: null,
tool: "message",
arguments: { action: "send", text: "already sent" },
},
})) as { success?: boolean };
expect(toolResult.success).toBe(false);
await notify({
method: "item/completed",
params: {
threadId: "thread-1",
turnId: "turn-1",
item: {
type: "dynamicToolCall",
id: "call-1",
tool: "message",
},
},
});
const result = await run;
expect(result.aborted).toBe(true);
expect(result.timedOut).toBe(true);
expect(result.promptError).toBe(
"codex app-server turn idle timed out waiting for turn/completed",
);
expect(
warn.mock.calls.some(
([message]) => message === "codex app-server turn idle timed out waiting for completion",
),
).toBe(true);
expect(
warn.mock.calls.some(
([message]) =>
message === "codex app-server turn idle timed out waiting for terminal event",
),
).toBe(false);
});
it("keeps waiting when Codex emits a raw assistant item after a dynamic tool response", async () => {
let notify: (notification: CodexServerNotification) => Promise<void> = async () => undefined;
let handleRequest:
@@ -3685,9 +3919,9 @@ describe("runCodexAppServerAttempt", () => {
});
defaultCodexAppInventoryCache.clear();
await defaultCodexAppInventoryCache.refreshNow({
key: buildCodexAppInventoryCacheKey({
codexHome: resolveCodexAppServerHomeDir(agentDir),
endpoint: __testing.resolveCodexPluginAppCacheEndpoint(appServer),
key: buildCodexPluginAppCacheKey({
appServer,
agentDir,
}),
request: async () => ({
data: [
@@ -3887,9 +4121,9 @@ describe("runCodexAppServerAttempt", () => {
});
defaultCodexAppInventoryCache.clear();
await defaultCodexAppInventoryCache.refreshNow({
key: buildCodexAppInventoryCacheKey({
codexHome: resolveCodexAppServerHomeDir(agentDir),
endpoint: __testing.resolveCodexPluginAppCacheEndpoint(appServer),
key: buildCodexPluginAppCacheKey({
appServer,
agentDir,
authProfileId,
accountId: "account-work",
}),
@@ -4028,9 +4262,9 @@ describe("runCodexAppServerAttempt", () => {
});
defaultCodexAppInventoryCache.clear();
await defaultCodexAppInventoryCache.refreshNow({
key: buildCodexAppInventoryCacheKey({
codexHome: resolveCodexAppServerHomeDir(agentDir),
endpoint: __testing.resolveCodexPluginAppCacheEndpoint(appServer),
key: buildCodexPluginAppCacheKey({
appServer,
agentDir,
envApiKeyFingerprint: resolveCodexAppServerEnvApiKeyCacheKey({
startOptions: appServer.start,
baseEnv: { CODEX_API_KEY: "old-codex-env-key" },
@@ -4331,6 +4565,180 @@ describe("runCodexAppServerAttempt", () => {
expect(request.mock.calls.map(([method]) => method)).toEqual(["thread/start", "thread/resume"]);
});
it("starts a fresh Codex thread for legacy context-engine sidecars without metadata", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
await writeExistingBinding(sessionFile, workspaceDir, { dynamicToolsFingerprint: "[]" });
const params = createParams(sessionFile, workspaceDir);
params.contextEngine = {
info: { id: "lossless-claw", name: "Lossless Claw", ownsCompaction: true },
assemble: vi.fn(),
compact: vi.fn(),
} as never;
params.contextTokenBudget = 400_000;
const appServer = createThreadLifecycleAppServerOptions();
const request = vi.fn(async (method: string) => {
if (method === "thread/start") {
return threadStartResult("thread-fresh");
}
throw new Error(`unexpected method: ${method}`);
});
const binding = await startOrResumeThread({
client: { request } as never,
params,
cwd: workspaceDir,
dynamicTools: [],
appServer,
});
expect(binding.threadId).toBe("thread-fresh");
expect(binding.lifecycle).toEqual({
action: "started",
rotatedContextEngineBinding: true,
});
expect(request.mock.calls.map(([method]) => method)).toEqual(["thread/start"]);
const savedBinding = await readCodexAppServerBinding(sessionFile);
expect(savedBinding?.contextEngine?.engineId).toBe("lossless-claw");
expect(savedBinding?.contextEngine?.policyFingerprint).toContain('"contextTokenBudget":400000');
});
it("resumes a Codex thread when context-engine sidecar metadata is compatible", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
const contextEngine = {
schemaVersion: 1 as const,
engineId: "lossless-claw",
policyFingerprint:
'{"schemaVersion":1,"engineId":"lossless-claw","ownsCompaction":true,"contextTokenBudget":400000,"projectionMaxChars":1000000}',
};
await writeExistingBinding(sessionFile, workspaceDir, {
dynamicToolsFingerprint: "[]",
contextEngine,
});
const params = createParams(sessionFile, workspaceDir);
params.contextEngine = {
info: { id: "lossless-claw", name: "Lossless Claw", ownsCompaction: true },
assemble: vi.fn(),
compact: vi.fn(),
} as never;
params.contextTokenBudget = 400_000;
const appServer = createThreadLifecycleAppServerOptions();
const request = vi.fn(async (method: string) => {
if (method === "thread/resume") {
return threadStartResult("thread-existing");
}
throw new Error(`unexpected method: ${method}`);
});
const binding = await startOrResumeThread({
client: { request } as never,
params,
cwd: workspaceDir,
dynamicTools: [],
appServer,
});
expect(binding.threadId).toBe("thread-existing");
expect(binding.lifecycle).toEqual({ action: "resumed" });
expect(request.mock.calls.map(([method]) => method)).toEqual(["thread/resume"]);
});
it("starts a fresh Codex thread when context-engine sidecar metadata is no longer active", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
await writeExistingBinding(sessionFile, workspaceDir, {
dynamicToolsFingerprint: "[]",
contextEngine: {
schemaVersion: 1,
engineId: "lossless-claw",
policyFingerprint:
'{"schemaVersion":1,"engineId":"lossless-claw","ownsCompaction":true,"contextTokenBudget":400000,"projectionMaxChars":1000000}',
},
});
const params = createParams(sessionFile, workspaceDir);
const appServer = createThreadLifecycleAppServerOptions();
const request = vi.fn(async (method: string) => {
if (method === "thread/start") {
return threadStartResult("thread-fresh");
}
throw new Error(`unexpected method: ${method}`);
});
const binding = await startOrResumeThread({
client: { request } as never,
params,
cwd: workspaceDir,
dynamicTools: [],
appServer,
});
expect(binding.threadId).toBe("thread-fresh");
expect(binding.lifecycle).toEqual({
action: "started",
rotatedContextEngineBinding: true,
});
expect(request.mock.calls.map(([method]) => method)).toEqual(["thread/start"]);
const savedBinding = await readCodexAppServerBinding(sessionFile);
expect(savedBinding?.contextEngine).toBeUndefined();
});
it("starts a fresh Codex thread when context-engine policy metadata changes", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
await writeExistingBinding(sessionFile, workspaceDir, {
dynamicToolsFingerprint: "[]",
contextEngine: {
schemaVersion: 1,
engineId: "lossless-claw",
policyFingerprint:
'{"schemaVersion":1,"engineId":"lossless-claw","engineVersion":"1.0.0","ownsCompaction":true,"turnMaintenanceMode":"foreground","citationsMode":"inline","contextTokenBudget":400000,"projectionMaxChars":1000000}',
},
});
const params = createParams(sessionFile, workspaceDir);
params.contextEngine = {
info: {
id: "lossless-claw",
name: "Lossless Claw",
version: "1.0.1",
ownsCompaction: true,
turnMaintenanceMode: "foreground",
},
assemble: vi.fn(),
compact: vi.fn(),
} as never;
params.config = { memory: { citations: "inline" } } as never;
params.contextTokenBudget = 400_000;
const appServer = createThreadLifecycleAppServerOptions();
const request = vi.fn(async (method: string) => {
if (method === "thread/start") {
return threadStartResult("thread-fresh");
}
throw new Error(`unexpected method: ${method}`);
});
const binding = await startOrResumeThread({
client: { request } as never,
params,
cwd: workspaceDir,
dynamicTools: [],
appServer,
});
expect(binding.threadId).toBe("thread-fresh");
expect(binding.lifecycle).toEqual({
action: "started",
rotatedContextEngineBinding: true,
});
expect(request.mock.calls.map(([method]) => method)).toEqual(["thread/start"]);
const savedBinding = await readCodexAppServerBinding(sessionFile);
expect(savedBinding?.contextEngine?.policyFingerprint).toContain('"engineVersion":"1.0.1"');
expect(savedBinding?.contextEngine?.policyFingerprint).toContain(
'"turnMaintenanceMode":"foreground"',
);
expect(savedBinding?.contextEngine?.policyFingerprint).toContain('"citationsMode":"inline"');
});
it("keeps the previous dynamic tool fingerprint for transient no-tool maintenance turns", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
@@ -5198,7 +5606,7 @@ describe("runCodexAppServerAttempt", () => {
});
it("keys plugin app inventory by websocket credentials without exposing them", () => {
const first = __testing.resolveCodexPluginAppCacheEndpoint({
const first = resolveCodexPluginAppCacheEndpoint({
start: {
transport: "websocket",
command: "codex",
@@ -5207,13 +5615,8 @@ describe("runCodexAppServerAttempt", () => {
authToken: "token-first",
headers: { Authorization: "Bearer first" },
},
requestTimeoutMs: 60_000,
turnCompletionIdleTimeoutMs: 5,
approvalPolicy: "never",
approvalsReviewer: "user",
sandbox: "workspace-write",
});
const second = __testing.resolveCodexPluginAppCacheEndpoint({
const second = resolveCodexPluginAppCacheEndpoint({
start: {
transport: "websocket",
command: "codex",
@@ -5222,11 +5625,6 @@ describe("runCodexAppServerAttempt", () => {
authToken: "token-second",
headers: { Authorization: "Bearer second" },
},
requestTimeoutMs: 60_000,
turnCompletionIdleTimeoutMs: 5,
approvalPolicy: "never",
approvalsReviewer: "user",
sandbox: "workspace-write",
});
expect(first).not.toEqual(second);

View File

@@ -15,6 +15,7 @@ import {
formatErrorMessage,
isActiveHarnessContextEngine,
isSubagentSessionKey,
loadCodexBundleMcpThreadConfig,
normalizeAgentRuntimeTools,
resolveAttemptSpawnWorkspaceDir,
resolveAgentHarnessBeforePromptBuildResult,
@@ -41,16 +42,12 @@ import {
import { markAuthProfileBlockedUntil, resolveAgentDir } from "openclaw/plugin-sdk/agent-runtime";
import { emitTrustedDiagnosticEvent } from "openclaw/plugin-sdk/diagnostic-runtime";
import { pathExists } from "openclaw/plugin-sdk/security-runtime";
import {
buildCodexAppInventoryCacheKey,
defaultCodexAppInventoryCache,
} from "./app-inventory-cache.js";
import { defaultCodexAppInventoryCache } from "./app-inventory-cache.js";
import { handleCodexAppServerApprovalRequest } from "./approval-bridge.js";
import {
refreshCodexAppServerAuthTokens,
resolveCodexAppServerAuthAccountCacheKey,
resolveCodexAppServerEnvApiKeyCacheKey,
resolveCodexAppServerHomeDir,
resolveCodexAppServerAuthProfileId,
resolveCodexAppServerAuthProfileIdForAgent,
} from "./auth-bridge.js";
@@ -87,9 +84,11 @@ import {
buildCodexNativeHookRelayConfig,
CODEX_NATIVE_HOOK_RELAY_EVENTS,
} from "./native-hook-relay.js";
import { buildCodexPluginAppCacheKey } from "./plugin-app-cache-key.js";
import {
buildCodexPluginThreadConfig,
buildCodexPluginThreadConfigInputFingerprint,
mergeCodexThreadConfigs,
shouldBuildCodexPluginThreadConfig,
} from "./plugin-thread-config.js";
import {
@@ -114,7 +113,11 @@ import {
resolveCodexUsageLimitResetAtMs,
shouldRefreshCodexRateLimitsForUsageLimitMessage,
} from "./rate-limits.js";
import { readCodexAppServerBinding, type CodexAppServerThreadBinding } from "./session-binding.js";
import {
clearCodexAppServerBinding,
readCodexAppServerBinding,
type CodexAppServerThreadBinding,
} from "./session-binding.js";
import { readCodexMirroredSessionHistoryMessages } from "./session-history.js";
import { clearSharedCodexAppServerClientIfCurrent } from "./shared-client.js";
import {
@@ -123,6 +126,7 @@ import {
buildTurnStartParams,
codexDynamicToolsFingerprint,
startOrResumeThread,
type CodexAppServerThreadLifecycleBinding,
} from "./thread-lifecycle.js";
import {
inferCodexDynamicToolMeta,
@@ -391,50 +395,6 @@ function toCodexTextInput(text: string): CodexUserInput {
return { type: "text", text, text_elements: [] };
}
function resolveCodexPluginAppCacheEndpoint(appServer: CodexAppServerRuntimeOptions): string {
return JSON.stringify({
transport: appServer.start.transport,
command: appServer.start.command,
args: appServer.start.args,
url: appServer.start.url ?? null,
credentialFingerprint: fingerprintCodexPluginAppCacheCredentials(appServer.start),
});
}
function fingerprintCodexPluginAppCacheCredentials(
startOptions: CodexAppServerRuntimeOptions["start"],
): string | null {
const authToken = startOptions.authToken ?? "";
const headers = Object.entries(startOptions.headers)
.map(([key, value]) => [key.toLowerCase(), value] as const)
.toSorted(([left], [right]) => left.localeCompare(right));
if (!authToken && headers.length === 0) {
return null;
}
const hash = createHash("sha256");
hash.update("openclaw:codex:plugin-app-cache-credentials:v1");
hash.update("\0");
hash.update(authToken);
for (const [key, value] of headers) {
hash.update("\0");
hash.update(key);
hash.update("\0");
hash.update(value);
}
return `sha256:${hash.digest("hex")}`;
}
function resolveCodexPluginAppCacheCodexHome(
appServer: CodexAppServerRuntimeOptions,
agentDir: string,
): string | undefined {
const configuredCodexHome = appServer.start.env?.CODEX_HOME?.trim();
if (configuredCodexHome) {
return configuredCodexHome;
}
return appServer.start.transport === "stdio" ? resolveCodexAppServerHomeDir(agentDir) : undefined;
}
function restrictCodexAppServerSandboxForOpenClawSandbox(
appServer: CodexAppServerRuntimeOptions,
sandbox: Awaited<ReturnType<typeof resolveSandboxContext>>,
@@ -539,6 +499,16 @@ export async function runCodexAppServerAttempt(
: resolveCodexAppServerEnvApiKeyCacheKey({
startOptions: appServer.start,
});
const bundleMcpThreadConfig = await loadCodexBundleMcpThreadConfig({
workspaceDir: effectiveWorkspace,
cfg: params.config,
toolsEnabled: supportsModelTools(params.model),
disableTools: params.disableTools,
toolsAllow: params.toolsAllow,
});
for (const diagnostic of bundleMcpThreadConfig.diagnostics) {
embeddedAgentLog.warn(`bundle-mcp: ${diagnostic.pluginId}: ${diagnostic.message}`);
}
const activeContextEngine = isActiveHarnessContextEngine(params.contextEngine)
? params.contextEngine
: undefined;
@@ -697,10 +667,13 @@ export async function runCodexAppServerAttempt(
tools: toolBridge.specs,
});
let client: CodexAppServerClient;
let thread: CodexAppServerThreadBinding;
let thread: CodexAppServerThreadLifecycleBinding;
let trajectoryEndRecorded = false;
let nativeHookRelay: NativeHookRelayRegistrationHandle | undefined;
let startupClientForCleanup: CodexAppServerClient | undefined;
let restartContextEngineCodexThread:
| (() => Promise<CodexAppServerThreadLifecycleBinding>)
| undefined;
const startupTimeoutMs = resolveCodexStartupTimeoutMs({
timeoutMs: params.timeoutMs,
timeoutFloorMs: options.startupTimeoutFloorMs,
@@ -732,11 +705,14 @@ export async function runCodexAppServerAttempt(
: options.nativeHookRelay?.enabled === false
? buildCodexNativeHookRelayDisabledConfig()
: undefined;
const threadConfig = nativeHookRelayConfig;
const threadConfig = mergeCodexThreadConfigs(
nativeHookRelayConfig,
bundleMcpThreadConfig?.configPatch as JsonObject | undefined,
);
const pluginThreadConfigEnabled = shouldBuildCodexPluginThreadConfig(pluginConfig);
const pluginAppCacheKey = buildCodexAppInventoryCacheKey({
codexHome: resolveCodexPluginAppCacheCodexHome(appServer, agentDir),
endpoint: resolveCodexPluginAppCacheEndpoint(appServer),
const pluginAppCacheKey = buildCodexPluginAppCacheKey({
appServer,
agentDir,
authProfileId: startupAuthProfileId,
accountId: startupAuthAccountCacheKey,
envApiKeyFingerprint: startupEnvApiKeyCacheKey,
@@ -783,7 +759,7 @@ export async function runCodexAppServerAttempt(
timeoutMs: appServer.requestTimeoutMs,
signal: runAbortController.signal,
});
const startupThread = await startOrResumeThread({
const threadLifecycleParams = {
client: startupClient,
params: runtimeParams,
cwd: effectiveWorkspace,
@@ -791,6 +767,8 @@ export async function runCodexAppServerAttempt(
appServer: pluginAppServer,
developerInstructions: promptBuild.developerInstructions,
config: threadConfig,
mcpServersFingerprint: bundleMcpThreadConfig.fingerprint,
mcpServersFingerprintEvaluated: bundleMcpThreadConfig.evaluated,
pluginThreadConfig: pluginThreadConfigEnabled
? {
enabled: true,
@@ -809,7 +787,9 @@ export async function runCodexAppServerAttempt(
}),
}
: undefined,
});
} satisfies Parameters<typeof startOrResumeThread>[0];
restartContextEngineCodexThread = () => startOrResumeThread(threadLifecycleParams);
const startupThread = await startOrResumeThread(threadLifecycleParams);
return { client: startupClient, thread: startupThread };
};
for (
@@ -923,6 +903,7 @@ export async function runCodexAppServerAttempt(
let turnCompletionLastActivityReason = "startup";
let turnCompletionLastActivityDetails: Record<string, unknown> | undefined;
let activeAppServerTurnRequests = 0;
const activeOpenClawDynamicToolCallIds = new Set<string>();
const activeTurnItemIds = new Set<string>();
const clearTurnCompletionIdleTimer = () => {
@@ -1268,12 +1249,16 @@ export async function runCodexAppServerAttempt(
turnCompletionIdleWatchArmed &&
!turnCompletionIdleWatchPinnedByTerminalError &&
notification.method !== "turn/completed" &&
isCurrentTurnNotification
isCurrentTurnNotification &&
!isTrackedOpenClawDynamicToolCompletionNotification(
notification,
activeOpenClawDynamicToolCallIds,
)
) {
// The short completion-idle watchdog only guards the blind gap after
// OpenClaw hands a turn-scoped request result back to Codex. Once Codex
// sends another current-turn notification, the app-server is alive again;
// the longer terminal watchdog remains the stuck-turn backstop.
// OpenClaw hands a turn-scoped request result back to Codex. Bookkeeping
// that closes the just-served OpenClaw dynamic tool item is still part of
// that handoff, so keep the short watchdog armed for that notification.
disarmTurnCompletionIdleWatch();
}
// Determine terminal-turn status before invoking the projector so a throw
@@ -1369,6 +1354,7 @@ export async function runCodexAppServerAttempt(
return undefined;
}
armCompletionWatchOnResponse = true;
activeOpenClawDynamicToolCallIds.add(call.callId);
trajectoryRecorder?.recordEvent("tool.call", {
threadId: call.threadId,
turnId: call.turnId,
@@ -1471,17 +1457,9 @@ export async function runCodexAppServerAttempt(
},
];
let turn: CodexTurnStartResponse;
try {
runAgentHarnessLlmInputHook({
event: llmInputEvent,
ctx: hookContext,
});
emitCodexAppServerEvent(params, {
stream: "codex_app_server.lifecycle",
data: { phase: "turn_starting", threadId: thread.threadId },
});
turn = assertCodexTurnStartResponse(
let turn: CodexTurnStartResponse | undefined;
const startCodexTurn = async (): Promise<CodexTurnStartResponse> =>
assertCodexTurnStartResponse(
await client.request(
"turn/start",
buildTurnStartParams(params, {
@@ -1493,78 +1471,121 @@ export async function runCodexAppServerAttempt(
{ timeoutMs: params.timeoutMs, signal: runAbortController.signal },
),
);
} catch (error) {
const usageLimitError = await formatCodexTurnStartUsageLimitError({
client,
error,
pendingNotifications,
timeoutMs: appServer.requestTimeoutMs,
signal: runAbortController.signal,
try {
runAgentHarnessLlmInputHook({
event: llmInputEvent,
ctx: hookContext,
});
const turnStartErrorMessage = usageLimitError?.message ?? formatErrorMessage(error);
emitCodexAppServerEvent(params, {
stream: "codex_app_server.lifecycle",
data: { phase: "turn_start_failed", error: turnStartErrorMessage },
data: { phase: "turn_starting", threadId: thread.threadId },
});
trajectoryRecorder?.recordEvent("session.ended", {
status: "error",
threadId: thread.threadId,
timedOut,
aborted: runAbortController.signal.aborted,
promptError: turnStartErrorMessage,
});
trajectoryEndRecorded = true;
runAgentHarnessLlmOutputHook({
event: {
turn = await startCodexTurn();
} catch (error) {
let turnStartError = error;
if (
shouldRetryContextEngineTurnOnFreshCodexThread({
error: turnStartError,
contextEngineActive: Boolean(activeContextEngine),
thread,
}) &&
restartContextEngineCodexThread
) {
embeddedAgentLog.warn(
"codex app-server context-engine turn overflowed on resume; retrying with fresh thread",
{
threadId: thread.threadId,
error: formatErrorMessage(turnStartError),
},
);
await clearCodexAppServerBinding(params.sessionFile);
thread = await restartContextEngineCodexThread();
emitCodexAppServerEvent(params, {
stream: "codex_app_server.lifecycle",
data: { phase: "thread_ready_retry", threadId: thread.threadId },
});
try {
turn = await startCodexTurn();
} catch (retryError) {
turnStartError = retryError;
}
}
if (turn === undefined) {
const usageLimitError = await formatCodexTurnStartUsageLimitError({
client,
error: turnStartError,
pendingNotifications,
timeoutMs: appServer.requestTimeoutMs,
signal: runAbortController.signal,
});
const turnStartErrorMessage = usageLimitError?.message ?? formatErrorMessage(turnStartError);
emitCodexAppServerEvent(params, {
stream: "codex_app_server.lifecycle",
data: { phase: "turn_start_failed", error: turnStartErrorMessage },
});
trajectoryRecorder?.recordEvent("session.ended", {
status: "error",
threadId: thread.threadId,
timedOut,
aborted: runAbortController.signal.aborted,
promptError: turnStartErrorMessage,
});
trajectoryEndRecorded = true;
runAgentHarnessLlmOutputHook({
event: {
runId: params.runId,
sessionId: params.sessionId,
provider: params.provider,
model: params.modelId,
resolvedRef:
params.runtimePlan?.observability.resolvedRef ?? `${params.provider}/${params.modelId}`,
...(params.runtimePlan?.observability.harnessId
? { harnessId: params.runtimePlan.observability.harnessId }
: {}),
assistantTexts: [],
},
ctx: hookContext,
});
runAgentHarnessAgentEndHook({
event: {
messages: turnStartFailureMessages,
success: false,
error: turnStartErrorMessage,
durationMs: Date.now() - attemptStartedAt,
},
ctx: hookContext,
});
notificationCleanup();
requestCleanup();
nativeHookRelay?.unregister();
await runAgentCleanupStep({
runId: params.runId,
sessionId: params.sessionId,
provider: params.provider,
model: params.modelId,
resolvedRef:
params.runtimePlan?.observability.resolvedRef ?? `${params.provider}/${params.modelId}`,
...(params.runtimePlan?.observability.harnessId
? { harnessId: params.runtimePlan.observability.harnessId }
: {}),
assistantTexts: [],
},
ctx: hookContext,
});
runAgentHarnessAgentEndHook({
event: {
messages: turnStartFailureMessages,
success: false,
error: turnStartErrorMessage,
durationMs: Date.now() - attemptStartedAt,
},
ctx: hookContext,
});
notificationCleanup();
requestCleanup();
nativeHookRelay?.unregister();
await runAgentCleanupStep({
runId: params.runId,
sessionId: params.sessionId,
step: "codex-trajectory-flush-startup-failure",
log: embeddedAgentLog,
cleanup: async () => {
await trajectoryRecorder?.flush();
},
});
params.abortSignal?.removeEventListener("abort", abortFromUpstream);
if (usageLimitError) {
await markCodexAuthProfileBlockedFromRateLimits({
params,
authProfileId: startupAuthProfileId,
rateLimits: usageLimitError.rateLimitsForProfile,
});
return buildCodexTurnStartFailureResult({
params,
message: usageLimitError.message,
messagesSnapshot: turnStartFailureMessages,
systemPromptReport,
step: "codex-trajectory-flush-startup-failure",
log: embeddedAgentLog,
cleanup: async () => {
await trajectoryRecorder?.flush();
},
});
params.abortSignal?.removeEventListener("abort", abortFromUpstream);
if (usageLimitError) {
await markCodexAuthProfileBlockedFromRateLimits({
params,
authProfileId: startupAuthProfileId,
rateLimits: usageLimitError.rateLimitsForProfile,
});
return buildCodexTurnStartFailureResult({
params,
message: usageLimitError.message,
messagesSnapshot: turnStartFailureMessages,
systemPromptReport,
});
}
throw turnStartError;
}
throw error;
}
if (!turn) {
throw new Error("codex app-server turn/start failed without an error");
}
turnId = turn.turn.id;
const activeTurnId = turn.turn.id;
@@ -1881,6 +1902,7 @@ function buildCodexTurnStartFailureResult(params: {
messagingToolSentTexts: [],
messagingToolSentMediaUrls: [],
messagingToolSentTargets: [],
messagingToolSourceReplyPayloads: [],
cloudCodeAssistFormatError: false,
replayMetadata: {
hadPotentialSideEffects: false,
@@ -2229,6 +2251,7 @@ async function buildDynamicTools(input: DynamicToolBuildParams) {
resolvedWorkspace: input.resolvedWorkspace,
}),
config: params.config,
authProfileStore: params.authProfileStore,
abortSignal: input.runAbortController.signal,
modelProvider: params.model.provider,
modelId: params.modelId,
@@ -2670,6 +2693,22 @@ function readNotificationItemId(notification: CodexServerNotification): string |
);
}
function isTrackedOpenClawDynamicToolCompletionNotification(
notification: CodexServerNotification,
activeOpenClawDynamicToolCallIds: ReadonlySet<string>,
): boolean {
if (notification.method !== "item/completed" || !isJsonObject(notification.params)) {
return false;
}
const itemId = readNotificationItemId(notification);
if (!itemId || !activeOpenClawDynamicToolCallIds.has(itemId)) {
return false;
}
const item = isJsonObject(notification.params.item) ? notification.params.item : undefined;
const itemType = item ? readString(item, "type") : undefined;
return itemType === undefined || itemType === "dynamicToolCall";
}
function readRawAssistantTextPreview(item: JsonObject): string | undefined {
if (readString(item, "role") !== "assistant" || !Array.isArray(item.content)) {
return undefined;
@@ -3082,6 +3121,28 @@ function isNonEmptyString(value: unknown): value is string {
return typeof value === "string" && value.length > 0;
}
function shouldRetryContextEngineTurnOnFreshCodexThread(params: {
error: unknown;
contextEngineActive: boolean;
thread: CodexAppServerThreadLifecycleBinding;
}): boolean {
if (!params.contextEngineActive || params.thread.lifecycle.action !== "resumed") {
return false;
}
return isCodexContextWindowError(params.error);
}
function isCodexContextWindowError(error: unknown): boolean {
const message = formatErrorMessage(error);
return (
/ran out of room in the model'?s context window/iu.test(message) ||
/context window/iu.test(message) ||
/context length/iu.test(message) ||
/maximum context/iu.test(message) ||
/too many tokens/iu.test(message)
);
}
function readCodexNotificationItem(params: JsonValue | undefined): CodexThreadItem | undefined {
if (!isJsonObject(params) || !isJsonObject(params.item)) {
return undefined;
@@ -3155,7 +3216,6 @@ export const __testing = {
filterToolsForVisionInputs,
handleDynamicToolCallWithTimeout,
resolveDynamicToolCallTimeoutMs,
resolveCodexPluginAppCacheEndpoint,
restrictCodexAppServerSandboxForOpenClawSandbox,
resolveOpenClawCodingToolsSessionKeys,
shouldForceMessageTool,

View File

@@ -59,6 +59,7 @@ describe("codex app-server session binding", () => {
model: "gpt-5.4-codex",
modelProvider: "openai",
dynamicToolsFingerprint: "tools-v1",
userMcpServersFingerprint: "user-mcp-v1",
});
const binding = await readCodexAppServerBinding(sessionFile);
@@ -70,6 +71,7 @@ describe("codex app-server session binding", () => {
expect(binding?.model).toBe("gpt-5.4-codex");
expect(binding?.modelProvider).toBe("openai");
expect(binding?.dynamicToolsFingerprint).toBe("tools-v1");
expect(binding?.userMcpServersFingerprint).toBe("user-mcp-v1");
const bindingStat = await fs.stat(resolveCodexAppServerBindingPath(sessionFile));
expect(bindingStat.isFile()).toBe(true);
});
@@ -102,6 +104,27 @@ describe("codex app-server session binding", () => {
expect(binding?.pluginAppPolicyContext).toEqual(pluginAppPolicyContext);
});
it("round-trips context-engine binding metadata", async () => {
const sessionFile = path.join(tempDir, "session.json");
await writeCodexAppServerBinding(sessionFile, {
threadId: "thread-123",
cwd: tempDir,
contextEngine: {
schemaVersion: 1,
engineId: "lossless-claw",
policyFingerprint: "lossless-policy-1",
},
});
const binding = await readCodexAppServerBinding(sessionFile);
expect(binding?.contextEngine).toEqual({
schemaVersion: 1,
engineId: "lossless-claw",
policyFingerprint: "lossless-policy-1",
});
});
it("rejects old plugin app policy entries that duplicate the app id", async () => {
const sessionFile = path.join(tempDir, "session.json");
await fs.writeFile(

View File

@@ -40,13 +40,22 @@ export type CodexAppServerThreadBinding = {
sandbox?: CodexAppServerSandboxMode;
serviceTier?: CodexServiceTier;
dynamicToolsFingerprint?: string;
userMcpServersFingerprint?: string;
mcpServersFingerprint?: string;
pluginAppsFingerprint?: string;
pluginAppsInputFingerprint?: string;
pluginAppPolicyContext?: PluginAppPolicyContext;
contextEngine?: CodexAppServerContextEngineBinding;
createdAt: string;
updatedAt: string;
};
export type CodexAppServerContextEngineBinding = {
schemaVersion: 1;
engineId: string;
policyFingerprint: string;
};
export function resolveCodexAppServerBindingPath(sessionFile: string): string {
return `${sessionFile}.codex-app-server.json`;
}
@@ -92,6 +101,12 @@ export async function readCodexAppServerBinding(
typeof parsed.dynamicToolsFingerprint === "string"
? parsed.dynamicToolsFingerprint
: undefined,
userMcpServersFingerprint:
typeof parsed.userMcpServersFingerprint === "string"
? parsed.userMcpServersFingerprint
: undefined,
mcpServersFingerprint:
typeof parsed.mcpServersFingerprint === "string" ? parsed.mcpServersFingerprint : undefined,
pluginAppsFingerprint:
typeof parsed.pluginAppsFingerprint === "string" ? parsed.pluginAppsFingerprint : undefined,
pluginAppsInputFingerprint:
@@ -99,6 +114,7 @@ export async function readCodexAppServerBinding(
? parsed.pluginAppsInputFingerprint
: undefined,
pluginAppPolicyContext: readPluginAppPolicyContext(parsed.pluginAppPolicyContext),
contextEngine: readContextEngineBinding(parsed.contextEngine),
createdAt: typeof parsed.createdAt === "string" ? parsed.createdAt : new Date().toISOString(),
updatedAt: typeof parsed.updatedAt === "string" ? parsed.updatedAt : new Date().toISOString(),
};
@@ -135,9 +151,12 @@ export async function writeCodexAppServerBinding(
sandbox: binding.sandbox,
serviceTier: binding.serviceTier,
dynamicToolsFingerprint: binding.dynamicToolsFingerprint,
userMcpServersFingerprint: binding.userMcpServersFingerprint,
mcpServersFingerprint: binding.mcpServersFingerprint,
pluginAppsFingerprint: binding.pluginAppsFingerprint,
pluginAppsInputFingerprint: binding.pluginAppsInputFingerprint,
pluginAppPolicyContext: binding.pluginAppPolicyContext,
contextEngine: binding.contextEngine,
createdAt: binding.createdAt ?? now,
updatedAt: now,
};
@@ -147,6 +166,25 @@ export async function writeCodexAppServerBinding(
);
}
function readContextEngineBinding(value: unknown): CodexAppServerContextEngineBinding | undefined {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return undefined;
}
const record = value as Record<string, unknown>;
if (
record.schemaVersion !== 1 ||
typeof record.engineId !== "string" ||
typeof record.policyFingerprint !== "string"
) {
return undefined;
}
return {
schemaVersion: 1,
engineId: record.engineId,
policyFingerprint: record.policyFingerprint,
};
}
function readPluginAppPolicyContext(value: unknown): PluginAppPolicyContext | undefined {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return undefined;

View File

@@ -39,6 +39,7 @@ let listCodexAppServerModels: typeof import("./models.js").listCodexAppServerMod
let clearSharedCodexAppServerClient: typeof import("./shared-client.js").clearSharedCodexAppServerClient;
let clearSharedCodexAppServerClientIfCurrent: typeof import("./shared-client.js").clearSharedCodexAppServerClientIfCurrent;
let createIsolatedCodexAppServerClient: typeof import("./shared-client.js").createIsolatedCodexAppServerClient;
let getSharedCodexAppServerClient: typeof import("./shared-client.js").getSharedCodexAppServerClient;
let resetSharedCodexAppServerClientForTests: typeof import("./shared-client.js").resetSharedCodexAppServerClientForTests;
async function sendInitializeResult(
@@ -63,6 +64,7 @@ describe("shared Codex app-server client", () => {
clearSharedCodexAppServerClient,
clearSharedCodexAppServerClientIfCurrent,
createIsolatedCodexAppServerClient,
getSharedCodexAppServerClient,
resetSharedCodexAppServerClientForTests,
} = await import("./shared-client.js"));
});
@@ -151,6 +153,39 @@ describe("shared Codex app-server client", () => {
expect(applyCall?.authProfileId).toBe("openai-codex:work");
});
it("skips target auth resolution when native source auth is requested", async () => {
const harness = createClientHarness();
vi.spyOn(CodexAppServerClient, "start").mockReturnValue(harness.client);
const config = { auth: { order: { "openai-codex": ["openai-codex:target"] } } };
const clientPromise = getSharedCodexAppServerClient({
timeoutMs: 1000,
authProfileId: null,
agentDir: "/tmp/openclaw-target-agent",
config,
});
await sendInitializeResult(harness, "openclaw/0.125.0 (macOS; test)");
await expect(clientPromise).resolves.toBe(harness.client);
expect(mocks.resolveCodexAppServerAuthProfileIdForAgent).not.toHaveBeenCalled();
const [bridgeCall] = mocks.bridgeCodexAppServerStartOptions.mock.calls[0] ?? [];
expect(bridgeCall).toEqual(
expect.objectContaining({
agentDir: "/tmp/openclaw-target-agent",
authProfileId: null,
config,
}),
);
const [applyCall] = mocks.applyCodexAppServerAuthProfile.mock.calls[0] ?? [];
expect(applyCall).toEqual(
expect.objectContaining({
agentDir: "/tmp/openclaw-target-agent",
authProfileId: null,
config,
}),
);
});
it("resolves the configured implicit auth profile before sharing a client", async () => {
const harness = createClientHarness();
vi.spyOn(CodexAppServerClient, "start").mockReturnValue(harness.client);

View File

@@ -32,29 +32,34 @@ function getSharedCodexAppServerClientState(): SharedCodexAppServerClientState {
export async function getSharedCodexAppServerClient(options?: {
startOptions?: CodexAppServerStartOptions;
timeoutMs?: number;
authProfileId?: string;
authProfileId?: string | null;
agentDir?: string;
config?: Parameters<typeof resolveCodexAppServerAuthProfileIdForAgent>[0]["config"];
}): Promise<CodexAppServerClient> {
const state = getSharedCodexAppServerClientState();
const agentDir = options?.agentDir ?? resolveDefaultAgentDir(options?.config ?? {});
const authProfileId = resolveCodexAppServerAuthProfileIdForAgent({
authProfileId: options?.authProfileId,
agentDir,
config: options?.config,
});
const usesNativeAuth = options?.authProfileId === null;
const requestedAuthProfileId =
options?.authProfileId === null ? undefined : options?.authProfileId;
const authProfileId = usesNativeAuth
? undefined
: resolveCodexAppServerAuthProfileIdForAgent({
authProfileId: requestedAuthProfileId,
agentDir,
config: options?.config,
});
const requestedStartOptions =
options?.startOptions ?? resolveCodexAppServerRuntimeOptions().start;
const managedStartOptions = await resolveManagedCodexAppServerStartOptions(requestedStartOptions);
const startOptions = await bridgeCodexAppServerStartOptions({
startOptions: managedStartOptions,
agentDir,
authProfileId,
authProfileId: usesNativeAuth ? null : authProfileId,
config: options?.config,
});
const key = codexAppServerStartOptionsKey(startOptions, {
authProfileId,
agentDir,
agentDir: usesNativeAuth ? undefined : agentDir,
});
if (state.key && state.key !== key) {
clearSharedCodexAppServerClient();
@@ -71,7 +76,7 @@ export async function getSharedCodexAppServerClient(options?: {
await applyCodexAppServerAuthProfile({
client,
agentDir,
authProfileId,
authProfileId: usesNativeAuth ? null : authProfileId,
startOptions,
config: options?.config,
});
@@ -100,23 +105,28 @@ export async function getSharedCodexAppServerClient(options?: {
export async function createIsolatedCodexAppServerClient(options?: {
startOptions?: CodexAppServerStartOptions;
timeoutMs?: number;
authProfileId?: string;
authProfileId?: string | null;
agentDir?: string;
config?: Parameters<typeof resolveCodexAppServerAuthProfileIdForAgent>[0]["config"];
}): Promise<CodexAppServerClient> {
const agentDir = options?.agentDir ?? resolveDefaultAgentDir(options?.config ?? {});
const authProfileId = resolveCodexAppServerAuthProfileIdForAgent({
authProfileId: options?.authProfileId,
agentDir,
config: options?.config,
});
const usesNativeAuth = options?.authProfileId === null;
const requestedAuthProfileId =
options?.authProfileId === null ? undefined : options?.authProfileId;
const authProfileId = usesNativeAuth
? undefined
: resolveCodexAppServerAuthProfileIdForAgent({
authProfileId: requestedAuthProfileId,
agentDir,
config: options?.config,
});
const requestedStartOptions =
options?.startOptions ?? resolveCodexAppServerRuntimeOptions().start;
const managedStartOptions = await resolveManagedCodexAppServerStartOptions(requestedStartOptions);
const startOptions = await bridgeCodexAppServerStartOptions({
startOptions: managedStartOptions,
agentDir,
authProfileId,
authProfileId: usesNativeAuth ? null : authProfileId,
config: options?.config,
});
const client = CodexAppServerClient.start(startOptions);
@@ -126,7 +136,7 @@ export async function createIsolatedCodexAppServerClient(options?: {
await applyCodexAppServerAuthProfile({
client,
agentDir,
authProfileId,
authProfileId: usesNativeAuth ? null : authProfileId,
startOptions,
config: options?.config,
});

View File

@@ -1,7 +1,9 @@
import {
embeddedAgentLog,
isActiveHarnessContextEngine,
type EmbeddedRunAttemptParams,
} from "openclaw/plugin-sdk/agent-harness-runtime";
import { buildCodexUserMcpServersThreadConfigPatch } from "openclaw/plugin-sdk/codex-mcp-projection";
import {
CODEX_GPT5_HEARTBEAT_PROMPT_OVERLAY,
renderCodexPromptOverlay,
@@ -9,6 +11,10 @@ import {
import { isModernCodexModel } from "../../provider.js";
import { isCodexAppServerConnectionClosedError, type CodexAppServerClient } from "./client.js";
import { codexSandboxPolicyForTurn, type CodexAppServerRuntimeOptions } from "./config.js";
import {
resolveCodexContextEngineProjectionMaxChars,
resolveCodexContextEngineProjectionReserveTokens,
} from "./context-engine-projection.js";
import {
isCodexPluginThreadBindingStale,
mergeCodexThreadConfigs,
@@ -34,9 +40,19 @@ import {
readCodexAppServerBinding,
writeCodexAppServerBinding,
type CodexAppServerAuthProfileLookup,
type CodexAppServerContextEngineBinding,
type CodexAppServerThreadBinding,
} from "./session-binding.js";
export type CodexAppServerThreadLifecycle = {
action: "started" | "resumed";
rotatedContextEngineBinding?: boolean;
};
export type CodexAppServerThreadLifecycleBinding = CodexAppServerThreadBinding & {
lifecycle: CodexAppServerThreadLifecycle;
};
export type CodexPluginThreadConfigProvider = {
enabled: boolean;
inputFingerprint?: string;
@@ -57,16 +73,58 @@ export async function startOrResumeThread(params: {
appServer: CodexAppServerRuntimeOptions;
developerInstructions?: string;
config?: JsonObject;
mcpServersFingerprint?: string;
mcpServersFingerprintEvaluated?: boolean;
pluginThreadConfig?: CodexPluginThreadConfigProvider;
}): Promise<CodexAppServerThreadBinding> {
}): Promise<CodexAppServerThreadLifecycleBinding> {
const dynamicToolsFingerprint = fingerprintDynamicTools(params.dynamicTools);
const contextEngineBinding = buildContextEngineBinding(params.params);
const userMcpServersConfigPatch = buildCodexUserMcpServersThreadConfigPatch(params.params.config);
const userMcpServersFingerprint = fingerprintUserMcpServersConfigPatch(userMcpServersConfigPatch);
let binding = await readCodexAppServerBinding(params.params.sessionFile, {
authProfileStore: params.params.authProfileStore,
agentDir: params.params.agentDir,
config: params.params.config,
});
let preserveExistingBinding = false;
let rotatedContextEngineBinding = false;
let prebuiltPluginThreadConfig: CodexPluginThreadConfig | undefined;
if (binding?.threadId && (binding.contextEngine || contextEngineBinding)) {
if (
!contextEngineBinding ||
!isContextEngineBindingCompatible(binding.contextEngine, contextEngineBinding)
) {
embeddedAgentLog.debug(
"codex app-server context-engine binding changed; starting a new thread",
{
threadId: binding.threadId,
engineId: contextEngineBinding?.engineId,
previousEngineId: binding.contextEngine?.engineId,
},
);
await clearCodexAppServerBinding(params.params.sessionFile);
binding = undefined;
rotatedContextEngineBinding = true;
}
}
if (binding?.threadId && binding.userMcpServersFingerprint !== userMcpServersFingerprint) {
embeddedAgentLog.debug("codex app-server user MCP config changed; starting a new thread", {
threadId: binding.threadId,
});
await clearCodexAppServerBinding(params.params.sessionFile);
binding = undefined;
}
if (
binding?.threadId &&
params.mcpServersFingerprintEvaluated === true &&
binding.mcpServersFingerprint !== params.mcpServersFingerprint
) {
embeddedAgentLog.debug("codex app-server MCP config changed; starting a new thread", {
threadId: binding.threadId,
});
await clearCodexAppServerBinding(params.params.sessionFile);
binding = undefined;
}
if (binding?.threadId) {
let pluginBindingStale = isCodexPluginThreadBindingStale({
codexPluginsEnabled: params.pluginThreadConfig?.enabled ?? false,
@@ -101,6 +159,17 @@ export async function startOrResumeThread(params: {
binding = undefined;
}
}
if (
binding?.threadId &&
params.mcpServersFingerprintEvaluated === true &&
binding.mcpServersFingerprint !== params.mcpServersFingerprint
) {
embeddedAgentLog.debug("codex app-server MCP config changed; starting a new thread", {
threadId: binding.threadId,
});
await clearCodexAppServerBinding(params.params.sessionFile);
binding = undefined;
}
if (binding?.threadId) {
// `/codex resume <thread>` writes a binding before the next turn can know
// the dynamic tool catalog, so only invalidate fingerprints we actually have.
@@ -134,6 +203,7 @@ export async function startOrResumeThread(params: {
} else {
try {
const authProfileId = params.params.authProfileId ?? binding.authProfileId;
const resumeConfig = mergeCodexThreadConfigs(params.config, userMcpServersConfigPatch);
const response = assertCodexThreadResumeResponse(
await params.client.request(
"thread/resume",
@@ -142,7 +212,7 @@ export async function startOrResumeThread(params: {
authProfileId,
appServer: params.appServer,
developerInstructions: params.developerInstructions,
config: params.config,
config: resumeConfig,
}),
),
);
@@ -154,6 +224,10 @@ export async function startOrResumeThread(params: {
agentDir: params.params.agentDir,
config: params.params.config,
});
const nextMcpServersFingerprint =
params.mcpServersFingerprintEvaluated === true
? params.mcpServersFingerprint
: binding.mcpServersFingerprint;
await writeCodexAppServerBinding(
params.params.sessionFile,
{
@@ -163,9 +237,12 @@ export async function startOrResumeThread(params: {
model: params.params.modelId,
modelProvider: response.modelProvider ?? fallbackModelProvider,
dynamicToolsFingerprint,
userMcpServersFingerprint,
mcpServersFingerprint: nextMcpServersFingerprint,
pluginAppsFingerprint: binding.pluginAppsFingerprint,
pluginAppsInputFingerprint: binding.pluginAppsInputFingerprint,
pluginAppPolicyContext: binding.pluginAppPolicyContext,
contextEngine: contextEngineBinding,
createdAt: binding.createdAt,
},
{
@@ -182,9 +259,13 @@ export async function startOrResumeThread(params: {
model: params.params.modelId,
modelProvider: response.modelProvider ?? fallbackModelProvider,
dynamicToolsFingerprint,
userMcpServersFingerprint,
mcpServersFingerprint: nextMcpServersFingerprint,
pluginAppsFingerprint: binding.pluginAppsFingerprint,
pluginAppsInputFingerprint: binding.pluginAppsInputFingerprint,
pluginAppPolicyContext: binding.pluginAppPolicyContext,
contextEngine: contextEngineBinding,
lifecycle: { action: "resumed" },
};
} catch (error) {
if (isCodexAppServerConnectionClosedError(error)) {
@@ -201,7 +282,11 @@ export async function startOrResumeThread(params: {
const pluginThreadConfig = params.pluginThreadConfig?.enabled
? (prebuiltPluginThreadConfig ?? (await params.pluginThreadConfig.build()))
: undefined;
const config = mergeCodexThreadConfigs(params.config, pluginThreadConfig?.configPatch);
const config = mergeCodexThreadConfigs(
params.config,
userMcpServersConfigPatch,
pluginThreadConfig?.configPatch,
);
const response = assertCodexThreadStartResponse(
await params.client.request(
"thread/start",
@@ -222,6 +307,8 @@ export async function startOrResumeThread(params: {
config: params.params.config,
});
const createdAt = new Date().toISOString();
const nextMcpServersFingerprint =
params.mcpServersFingerprintEvaluated === true ? params.mcpServersFingerprint : undefined;
if (!preserveExistingBinding) {
await writeCodexAppServerBinding(
params.params.sessionFile,
@@ -232,9 +319,12 @@ export async function startOrResumeThread(params: {
model: response.model ?? params.params.modelId,
modelProvider: response.modelProvider ?? modelProvider,
dynamicToolsFingerprint,
userMcpServersFingerprint,
mcpServersFingerprint: nextMcpServersFingerprint,
pluginAppsFingerprint: pluginThreadConfig?.fingerprint,
pluginAppsInputFingerprint: pluginThreadConfig?.inputFingerprint,
pluginAppPolicyContext: pluginThreadConfig?.policyContext,
contextEngine: contextEngineBinding,
createdAt,
},
{
@@ -253,14 +343,87 @@ export async function startOrResumeThread(params: {
model: response.model ?? params.params.modelId,
modelProvider: response.modelProvider ?? modelProvider,
dynamicToolsFingerprint,
userMcpServersFingerprint,
mcpServersFingerprint: nextMcpServersFingerprint,
pluginAppsFingerprint: pluginThreadConfig?.fingerprint,
pluginAppsInputFingerprint: pluginThreadConfig?.inputFingerprint,
pluginAppPolicyContext: pluginThreadConfig?.policyContext,
contextEngine: contextEngineBinding,
createdAt,
updatedAt: createdAt,
lifecycle: {
action: "started",
...(rotatedContextEngineBinding ? { rotatedContextEngineBinding } : {}),
},
};
}
function buildContextEngineBinding(
params: EmbeddedRunAttemptParams,
): CodexAppServerContextEngineBinding | undefined {
const contextEngine = isActiveHarnessContextEngine(params.contextEngine)
? params.contextEngine
: undefined;
const engineId = contextEngine?.info?.id?.trim();
if (!contextEngine || !engineId) {
return undefined;
}
return {
schemaVersion: 1,
engineId,
policyFingerprint: JSON.stringify({
schemaVersion: 1,
engineId,
engineVersion: contextEngine.info.version,
ownsCompaction: contextEngine.info.ownsCompaction === true,
turnMaintenanceMode: contextEngine.info.turnMaintenanceMode,
citationsMode: resolveContextEngineCitationsMode(params.config),
contextTokenBudget: params.contextTokenBudget,
projectionMaxChars: resolveCodexContextEngineProjectionMaxChars({
contextTokenBudget: params.contextTokenBudget,
reserveTokens: resolveCodexContextEngineProjectionReserveTokens({
config: params.config,
}),
}),
}),
};
}
function isContextEngineBindingCompatible(
previous: CodexAppServerContextEngineBinding | undefined,
next: CodexAppServerContextEngineBinding,
): boolean {
return (
previous?.schemaVersion === next.schemaVersion &&
previous.engineId === next.engineId &&
previous.policyFingerprint === next.policyFingerprint
);
}
function resolveContextEngineCitationsMode(config: unknown): JsonValue | undefined {
const rootConfig = isUnknownRecord(config) ? config : undefined;
const memoryConfig = isUnknownRecord(rootConfig?.memory) ? rootConfig.memory : undefined;
const citations = memoryConfig?.citations;
return isJsonConfigValue(citations) ? citations : undefined;
}
function isUnknownRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value && typeof value === "object" && !Array.isArray(value));
}
function isJsonConfigValue(value: unknown): value is JsonValue {
if (value === null || typeof value === "string" || typeof value === "boolean") {
return true;
}
if (typeof value === "number") {
return Number.isFinite(value);
}
if (Array.isArray(value)) {
return value.every(isJsonConfigValue);
}
return isUnknownRecord(value) && Object.values(value).every(isJsonConfigValue);
}
function shouldRecheckRecoverablePluginBinding(params: {
binding: CodexAppServerThreadBinding;
pluginThreadConfig?: CodexPluginThreadConfigProvider;
@@ -420,6 +583,12 @@ function fingerprintDynamicTools(dynamicTools: CodexDynamicToolSpec[]): string {
);
}
function fingerprintUserMcpServersConfigPatch(
configPatch: JsonObject | undefined,
): string | undefined {
return configPatch ? JSON.stringify(stabilizeJsonValue(configPatch)) : undefined;
}
function fingerprintDynamicToolSpec(tool: JsonValue): JsonValue {
if (!isJsonObject(tool)) {
return stabilizeJsonValue(tool);

View File

@@ -0,0 +1,259 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import type { EmbeddedRunAttemptParams } from "openclaw/plugin-sdk/agent-harness-runtime";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { CodexAppServerRuntimeOptions } from "./config.js";
import { writeCodexAppServerBinding } from "./session-binding.js";
import { startOrResumeThread } from "./thread-lifecycle.js";
function threadStartResult(threadId = "thread-1"): Record<string, unknown> {
return {
thread: {
id: threadId,
sessionId: "session-1",
forkedFromId: null,
preview: "",
ephemeral: false,
modelProvider: "openai",
createdAt: 1,
updatedAt: 1,
status: { type: "idle" },
path: null,
cwd: "/tmp",
cliVersion: "0.125.0",
source: "unknown",
agentNickname: null,
agentRole: null,
gitInfo: null,
name: null,
turns: [],
},
model: "gpt-5.4-codex",
modelProvider: "openai",
serviceTier: null,
cwd: "/tmp",
instructionSources: [],
approvalPolicy: "never",
approvalsReviewer: "user",
sandbox: { type: "dangerFullAccess" },
permissionProfile: null,
reasoningEffort: null,
};
}
function threadResumeResult(threadId = "thread-existing"): Record<string, unknown> {
return threadStartResult(threadId);
}
function createAppServerOptions(): CodexAppServerRuntimeOptions {
return {
start: {
transport: "stdio",
command: "codex",
args: ["app-server"],
headers: {},
},
requestTimeoutMs: 60_000,
turnCompletionIdleTimeoutMs: 60_000,
approvalPolicy: "never",
approvalsReviewer: "user",
sandbox: "workspace-write",
} as unknown as CodexAppServerRuntimeOptions;
}
function createParams(
sessionFile: string,
workspaceDir: string,
configOverrides?: EmbeddedRunAttemptParams["config"],
): EmbeddedRunAttemptParams {
return {
prompt: "hello",
sessionId: "session-1",
sessionKey: "agent:main:session-1",
sessionFile,
workspaceDir,
runId: "run-1",
provider: "codex",
modelId: "gpt-5.4-codex",
thinkLevel: "medium",
disableTools: true,
timeoutMs: 5_000,
authStorage: {} as never,
authProfileStore: { version: 1, profiles: {} },
modelRegistry: {} as never,
config: configOverrides,
} as unknown as EmbeddedRunAttemptParams;
}
describe("startOrResumeThread — user mcp.servers projection (regression: #80814)", () => {
let tempDir = "";
beforeEach(async () => {
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-80814-"));
});
afterEach(async () => {
if (tempDir) {
await fs.rm(tempDir, { recursive: true, force: true });
}
});
it("projects cfg.mcp.servers into the thread/start config patch under mcp_servers", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
const request = vi.fn(async (method: string, _params: unknown) => {
if (method === "thread/start") {
return threadStartResult();
}
throw new Error(`unexpected method: ${method}`);
});
await startOrResumeThread({
client: { request } as never,
params: createParams(sessionFile, workspaceDir, {
mcp: {
servers: {
outlook: {
transport: "stdio",
command: "node",
args: ["/opt/outlook-mcp/dist/index.js"],
},
},
},
} as unknown as EmbeddedRunAttemptParams["config"]),
cwd: workspaceDir,
dynamicTools: [],
appServer: createAppServerOptions(),
});
const startCall = request.mock.calls.find(([method]) => method === "thread/start");
const startParams = startCall?.[1] as { config?: { mcp_servers?: Record<string, unknown> } };
expect(startParams?.config?.mcp_servers).toBeDefined();
expect(startParams.config!.mcp_servers).toMatchObject({
outlook: { command: "node", args: ["/opt/outlook-mcp/dist/index.js"] },
});
});
it("omits mcp_servers from the start config when cfg has no user MCP servers", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
const request = vi.fn(async (method: string, _params: unknown) => {
if (method === "thread/start") {
return threadStartResult();
}
throw new Error(`unexpected method: ${method}`);
});
await startOrResumeThread({
client: { request } as never,
params: createParams(sessionFile, workspaceDir),
cwd: workspaceDir,
dynamicTools: [],
appServer: createAppServerOptions(),
});
const startCall = request.mock.calls.find(([method]) => method === "thread/start");
const startParams = startCall?.[1] as { config?: { mcp_servers?: Record<string, unknown> } };
expect(startParams?.config?.mcp_servers).toBeUndefined();
});
it("starts a new thread when an existing binding lacks the matching user MCP fingerprint", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
await writeCodexAppServerBinding(sessionFile, {
threadId: "thread-existing",
cwd: workspaceDir,
model: "gpt-5.4-codex",
modelProvider: "openai",
});
const request = vi.fn(async (method: string, _params: unknown) => {
if (method === "thread/start") {
return threadStartResult("thread-restarted");
}
throw new Error(`unexpected method: ${method}`);
});
await startOrResumeThread({
client: { request } as never,
params: createParams(sessionFile, workspaceDir, {
mcp: {
servers: {
notes: {
transport: "stdio",
command: "node",
args: ["/opt/notes-mcp/dist/index.js"],
},
},
},
} as unknown as EmbeddedRunAttemptParams["config"]),
cwd: workspaceDir,
dynamicTools: [],
appServer: createAppServerOptions(),
});
expect(request.mock.calls.some(([method]) => method === "thread/resume")).toBe(false);
const startCall = request.mock.calls.find(([method]) => method === "thread/start");
const startParams = startCall?.[1] as {
config?: { mcp_servers?: Record<string, unknown> };
};
expect(startParams?.config?.mcp_servers).toBeDefined();
expect(startParams.config!.mcp_servers).toMatchObject({
notes: { command: "node", args: ["/opt/notes-mcp/dist/index.js"] },
});
});
it("resends user MCP config when resuming a thread with the matching fingerprint", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
const config = {
mcp: {
servers: {
notes: {
transport: "stdio",
command: "node",
args: ["/opt/notes-mcp/dist/index.js"],
},
},
},
} as unknown as EmbeddedRunAttemptParams["config"];
const request = vi.fn(async (method: string, _params: unknown) => {
if (method === "thread/start") {
return threadStartResult("thread-with-user-mcp");
}
if (method === "thread/resume") {
return threadResumeResult("thread-with-user-mcp");
}
throw new Error(`unexpected method: ${method}`);
});
await startOrResumeThread({
client: { request } as never,
params: createParams(sessionFile, workspaceDir, config),
cwd: workspaceDir,
dynamicTools: [],
appServer: createAppServerOptions(),
});
request.mockClear();
await startOrResumeThread({
client: { request } as never,
params: createParams(sessionFile, workspaceDir, config),
cwd: workspaceDir,
dynamicTools: [],
appServer: createAppServerOptions(),
});
const resumeCall = request.mock.calls.find(([method]) => method === "thread/resume");
const resumeParams = resumeCall?.[1] as {
config?: { mcp_servers?: Record<string, unknown> };
};
expect(resumeCall).toBeDefined();
expect(resumeParams?.config?.mcp_servers).toMatchObject({
notes: { command: "node", args: ["/opt/notes-mcp/dist/index.js"] },
});
});
});

View File

@@ -1,3 +1,4 @@
export const MIN_CODEX_APP_SERVER_VERSION = "0.125.0";
export const MANAGED_CODEX_APP_SERVER_PACKAGE = "@openai/codex";
// Keep this in sync with the Codex CLI live-test package pin.
export const MANAGED_CODEX_APP_SERVER_PACKAGE_VERSION = "0.130.0";

View File

@@ -21,14 +21,22 @@ import type {
MigrationProviderContext,
} from "openclaw/plugin-sdk/plugin-entry";
import { defaultCodexAppInventoryCache } from "../app-server/app-inventory-cache.js";
import {
resolveCodexAppServerAuthAccountCacheKey,
resolveCodexAppServerAuthProfileIdForAgent,
resolveCodexAppServerEnvApiKeyCacheKey,
} from "../app-server/auth-bridge.js";
import {
CODEX_PLUGINS_MARKETPLACE_NAME,
readCodexPluginConfig,
resolveCodexAppServerRuntimeOptions,
type ResolvedCodexPluginPolicy,
} from "../app-server/config.js";
import {
ensureCodexPluginActivation,
type CodexPluginActivationResult,
} from "../app-server/plugin-activation.js";
import { buildCodexPluginAppCacheKey } from "../app-server/plugin-app-cache-key.js";
import type { v2 } from "../app-server/protocol.js";
import { requestCodexAppServerJson } from "../app-server/request.js";
import { buildCodexMigrationPlan } from "./plan.js";
@@ -40,6 +48,7 @@ import {
readCodexPluginMigrationConfigEntry,
type CodexPluginMigrationConfigEntry,
} from "./plan.js";
import { resolveCodexMigrationTargets } from "./targets.js";
const CODEX_PLUGIN_AUTH_REQUIRED_REASON = "auth_required";
const CODEX_PLUGIN_NOT_SELECTED_REASON = "not selected for migration";
@@ -104,6 +113,8 @@ async function applyCodexPluginInstallItem(
};
}
try {
const appCacheKey = await buildTargetCodexPluginAppCacheKey(ctx);
const appServer = resolveTargetCodexAppServer(ctx);
const result = await ensureCodexPluginActivation({
identity: policy,
installEvenIfActive: true,
@@ -112,10 +123,14 @@ async function applyCodexPluginInstallItem(
method,
requestParams,
timeoutMs: 60_000,
startOptions: appServer.start,
agentDir: resolveCodexMigrationTargets(ctx).agentDir,
config: ctx.config,
isolated: true,
}),
appCache: defaultCodexAppInventoryCache,
appCacheKey,
});
defaultCodexAppInventoryCache.clear();
const baseDetails = {
...item.details,
code: result.reason,
@@ -162,6 +177,38 @@ async function applyCodexPluginInstallItem(
}
}
function resolveTargetCodexAppServer(ctx: MigrationProviderContext) {
return resolveCodexAppServerRuntimeOptions({
pluginConfig: readCodexPluginConfig(ctx.config),
});
}
async function buildTargetCodexPluginAppCacheKey(ctx: MigrationProviderContext): Promise<string> {
const targets = resolveCodexMigrationTargets(ctx);
const appServer = resolveTargetCodexAppServer(ctx);
const authProfileId = resolveCodexAppServerAuthProfileIdForAgent({
agentDir: targets.agentDir,
config: ctx.config,
});
const accountId = await resolveCodexAppServerAuthAccountCacheKey({
authProfileId,
agentDir: targets.agentDir,
config: ctx.config,
});
const envApiKeyFingerprint = authProfileId
? undefined
: resolveCodexAppServerEnvApiKeyCacheKey({
startOptions: appServer.start,
});
return buildCodexPluginAppCacheKey({
appServer,
agentDir: targets.agentDir,
authProfileId,
accountId,
envApiKeyFingerprint,
});
}
async function applyCodexPluginConfigItem(
ctx: MigrationProviderContext,
item: MigrationItem,

View File

@@ -15,6 +15,7 @@ import type {
import { CODEX_PLUGINS_MARKETPLACE_NAME } from "../app-server/config.js";
import { exists, sanitizeName } from "./helpers.js";
import {
codexPluginMigrationSubscriptionWarning,
discoverCodexSource,
hasCodexSource,
type CodexPluginSource,
@@ -33,6 +34,7 @@ const CODEX_PLUGIN_NATIVE_CONFIG_PATH = [
"codexPlugins",
] as const;
const MIGRATION_REASON_PLUGIN_EXISTS = "plugin exists";
const CODEX_PLUGIN_SOURCE_APP_VERIFICATION_UNVERIFIED = "not_run";
export type CodexPluginMigrationConfigEntry = {
configKey: string;
@@ -40,6 +42,13 @@ export type CodexPluginMigrationConfigEntry = {
enabled: boolean;
};
type CodexPluginMigrationBlockSkipDetails = {
pluginName: string;
marketplaceName: typeof CODEX_PLUGINS_MARKETPLACE_NAME;
apps?: NonNullable<CodexPluginSource["migrationBlock"]>["apps"];
error?: string;
};
function uniqueSkillName(skill: CodexSkillSource, counts: Map<string, number>): string {
const base = sanitizeName(skill.name) || "codex-skill";
if ((counts.get(base) ?? 0) <= 1) {
@@ -173,6 +182,9 @@ function buildPluginItems(
pluginName: plugin.pluginName,
sourceInstalled: plugin.installed === true,
sourceEnabled: plugin.enabled === true,
...(plugin.apps && plugin.apps.length > 0 && !shouldVerifyPluginApps(ctx)
? { sourceAppVerification: CODEX_PLUGIN_SOURCE_APP_VERIFICATION_UNVERIFIED }
: {}),
},
}),
);
@@ -180,6 +192,29 @@ function buildPluginItems(
}
manualIndex += 1;
if (plugin.migrationBlock && plugin.pluginName) {
const details: CodexPluginMigrationBlockSkipDetails = {
pluginName: plugin.pluginName,
marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME,
...(plugin.migrationBlock.apps ? { apps: plugin.migrationBlock.apps } : {}),
...(plugin.migrationBlock.error ? { error: plugin.migrationBlock.error } : {}),
};
items.push(
createMigrationItem({
id: `plugin:${sanitizeName(plugin.name) || sanitizeName(path.basename(plugin.source))}:${manualIndex}`,
kind: "manual",
action: "manual",
source: plugin.source,
status: "skipped",
reason: plugin.migrationBlock.code,
message:
plugin.message ??
`Codex native plugin "${plugin.name}" was found but not activated automatically.`,
details: { ...details },
}),
);
continue;
}
items.push(
createMigrationManualItem({
id: `plugin:${sanitizeName(plugin.name) || sanitizeName(path.basename(plugin.source))}:${manualIndex}`,
@@ -195,6 +230,10 @@ function buildPluginItems(
return items;
}
function shouldVerifyPluginApps(ctx: MigrationProviderContext): boolean {
return ctx.providerOptions?.verifyPluginApps === true;
}
export function readCodexPluginMigrationConfigEntry(
item: MigrationItem,
enabled: boolean,
@@ -345,13 +384,17 @@ function buildPluginConfigItem(
export async function buildCodexMigrationPlan(
ctx: MigrationProviderContext,
): Promise<MigrationPlan> {
const source = await discoverCodexSource(ctx.source);
const targets = resolveCodexMigrationTargets(ctx);
const source = await discoverCodexSource({
input: ctx.source,
evaluatePluginMigrationEligibility: true,
verifyPluginApps: shouldVerifyPluginApps(ctx),
});
if (!hasCodexSource(source)) {
throw new Error(
`Codex state was not found at ${source.root}. Pass --from <path> if it lives elsewhere.`,
);
}
const targets = resolveCodexMigrationTargets(ctx);
const items: MigrationItem[] = [];
items.push(
...(await buildSkillItems({
@@ -386,16 +429,31 @@ export async function buildCodexMigrationPlan(
"Conflicts were found. Re-run with --overwrite to replace conflicting migration targets after item-level backups.",
]
: []),
...(source.plugins.length > 0
...(source.plugins.some((plugin) => plugin.migratable)
? [
"Codex source-installed openai-curated plugins are planned for native activation; cached plugin bundles remain manual-review only.",
]
: []),
...(source.plugins.some(
(plugin) => plugin.migratable && plugin.apps && plugin.apps.length > 0,
) && !shouldVerifyPluginApps(ctx)
? [
"Codex app-backed plugins were planned without source app accessibility verification. Re-run with --verify-plugin-apps to force a fresh source app/list check before planning native plugin activation.",
]
: []),
...(source.plugins.some((plugin) => plugin.sourceKind === "cache")
? ["Codex cached plugin bundles remain manual-review only."]
: []),
...(source.pluginDiscoveryError
? [
`Codex app-server plugin inventory discovery failed: ${source.pluginDiscoveryError}. Cached plugin bundles, if any, are advisory only.`,
]
: []),
...(source.plugins.some(
(plugin) => plugin.migrationBlock?.code === "codex_subscription_required",
)
? [codexPluginMigrationSubscriptionWarning()]
: []),
...(source.archivePaths.length > 0
? [
"Codex config and hook files are archive-only. They are preserved in the migration report, not loaded into OpenClaw automatically.",

View File

@@ -3,8 +3,10 @@ import os from "node:os";
import path from "node:path";
import type { MigrationProviderContext } from "openclaw/plugin-sdk/plugin-entry";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { defaultCodexAppInventoryCache } from "../app-server/app-inventory-cache.js";
import { CODEX_PLUGINS_MARKETPLACE_NAME } from "../app-server/config.js";
import type { v2 } from "../app-server/protocol.js";
import { buildCodexPluginAppCacheKey } from "../app-server/plugin-app-cache-key.js";
import type { CodexGetAccountResponse, v2 } from "../app-server/protocol.js";
import { buildCodexMigrationProvider } from "./provider.js";
const appServerRequest = vi.hoisted(() => vi.fn());
@@ -38,6 +40,7 @@ function makeContext(params: {
stateDir: string;
workspaceDir: string;
overwrite?: boolean;
verifyPluginApps?: boolean;
reportDir?: string;
config?: MigrationProviderContext["config"];
runtime?: MigrationProviderContext["runtime"];
@@ -56,6 +59,7 @@ function makeContext(params: {
source: params.source,
stateDir: params.stateDir,
overwrite: params.overwrite,
providerOptions: params.verifyPluginApps ? { verifyPluginApps: true } : undefined,
reportDir: params.reportDir,
logger,
};
@@ -69,6 +73,14 @@ function findItem(items: readonly { id?: string }[], id: string) {
return item as Record<string, unknown>;
}
function findItemByReason(items: readonly { reason?: string }[], reason: string) {
const item = items.find((entry) => entry.reason === reason);
if (!item) {
throw new Error(`Expected migration item reason ${reason}`);
}
return item as Record<string, unknown>;
}
function expectRecordFields(record: unknown, expected: Record<string, unknown>) {
if (!record || typeof record !== "object") {
throw new Error("Expected record");
@@ -122,9 +134,28 @@ async function createCodexFixture(): Promise<{
return { root, homeDir, codexHome, stateDir, workspaceDir };
}
function sourceAppCacheKey(fixture: { codexHome: string }): string {
return buildCodexPluginAppCacheKey({
appServer: {
start: {
transport: "stdio",
command: "codex",
commandSource: "managed",
args: ["app-server", "--listen", "stdio://"],
headers: {},
env: {
CODEX_HOME: fixture.codexHome,
HOME: path.dirname(fixture.codexHome),
},
},
},
});
}
afterEach(async () => {
vi.unstubAllEnvs();
appServerRequest.mockReset();
defaultCodexAppInventoryCache.clear();
for (const root of tempRoots) {
await fs.rm(root, { recursive: true, force: true });
}
@@ -145,6 +176,7 @@ describe("buildCodexMigrationProvider", () => {
source: fixture.codexHome,
stateDir: fixture.stateDir,
workspaceDir: fixture.workspaceDir,
verifyPluginApps: true,
}),
);
@@ -185,9 +217,15 @@ describe("buildCodexMigrationProvider", () => {
it("plans source-installed curated plugins without installing during dry-run", async () => {
const fixture = await createCodexFixture();
appServerRequest.mockResolvedValueOnce(
pluginList([pluginSummary("google-calendar", { installed: true, enabled: true })]),
);
appServerRequest.mockImplementation(async ({ method }: { method: string }) => {
if (method === "plugin/list") {
return pluginList([pluginSummary("google-calendar", { installed: true, enabled: true })]);
}
if (method === "plugin/read") {
return pluginRead("google-calendar");
}
throw new Error(`unexpected request ${method}`);
});
const provider = buildCodexMigrationProvider();
const plan = await provider.plan(
@@ -195,14 +233,23 @@ describe("buildCodexMigrationProvider", () => {
source: fixture.codexHome,
stateDir: fixture.stateDir,
workspaceDir: fixture.workspaceDir,
verifyPluginApps: true,
}),
);
expect(appServerRequest).toHaveBeenCalledTimes(1);
expect(appServerRequest).toHaveBeenCalledTimes(2);
expectRecordFields(mockCallArg(appServerRequest), {
method: "plugin/list",
requestParams: { cwds: [] },
});
expectRecordFields((mockCallArg(appServerRequest) as { startOptions?: unknown }).startOptions, {
command: "codex",
commandSource: "managed",
env: {
CODEX_HOME: fixture.codexHome,
HOME: path.dirname(fixture.codexHome),
},
});
expect(
appServerRequest.mock.calls.some(
([arg]) => (arg as { method?: string }).method === "plugin/install",
@@ -226,6 +273,588 @@ describe("buildCodexMigrationProvider", () => {
});
});
it("skips source-installed plugins whose owned apps are inaccessible", async () => {
const fixture = await createCodexFixture();
appServerRequest.mockImplementation(
async ({ method, requestParams }: { method: string; requestParams?: unknown }) => {
if (method === "plugin/list") {
return pluginList([pluginSummary("readwise", { installed: true, enabled: true })]);
}
if (method === "plugin/read") {
return pluginRead("readwise", [
pluginApp("asdk_app_readwise", { name: "Readwise", needsAuth: false }),
]);
}
if (method === "account/read") {
return chatGptAccount();
}
if (method === "app/list") {
expectRecordFields(requestParams, { forceRefetch: true });
return appsList([
appInfo("asdk_app_readwise", {
name: "Readwise",
isAccessible: false,
isEnabled: true,
}),
]);
}
throw new Error(`unexpected request ${method}`);
},
);
const provider = buildCodexMigrationProvider();
const plan = await provider.plan(
makeContext({
source: fixture.codexHome,
stateDir: fixture.stateDir,
workspaceDir: fixture.workspaceDir,
verifyPluginApps: true,
}),
);
expect(plan.items.some((item) => item.id === "plugin:readwise")).toBe(false);
expect(plan.items.some((item) => item.id === "config:codex-plugins")).toBe(false);
const manualItem = findItemByReason(plan.items, "app_inaccessible");
expectRecordFields(manualItem, {
kind: "manual",
action: "manual",
status: "skipped",
reason: "app_inaccessible",
});
const details = expectRecordFields(manualItem.details, {
pluginName: "readwise",
marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME,
});
expect(details).not.toHaveProperty("code");
expect(details.apps).toEqual([
{
id: "asdk_app_readwise",
name: "Readwise",
isAccessible: false,
isEnabled: true,
needsAuth: false,
},
]);
expect(appServerRequest.mock.calls.filter(([arg]) => arg.method === "app/list")).toHaveLength(
1,
);
});
it("plans app-backed plugins without source app/list by default", async () => {
const fixture = await createCodexFixture();
appServerRequest.mockImplementation(async ({ method }: { method: string }) => {
if (method === "plugin/list") {
return pluginList([pluginSummary("gmail", { installed: true, enabled: true })]);
}
if (method === "plugin/read") {
return pluginRead("gmail", [pluginApp("app-gmail", { name: "Gmail", needsAuth: true })]);
}
if (method === "account/read") {
return chatGptAccount();
}
throw new Error(`unexpected request ${method}`);
});
const provider = buildCodexMigrationProvider();
const plan = await provider.plan(
makeContext({
source: fixture.codexHome,
stateDir: fixture.stateDir,
workspaceDir: fixture.workspaceDir,
}),
);
expectRecordFields(findItem(plan.items, "plugin:gmail"), {
kind: "plugin",
action: "install",
status: "planned",
});
expectRecordFields(findItem(plan.items, "config:codex-plugins"), {
kind: "config",
action: "merge",
status: "planned",
});
expect(plan.warnings).toEqual(
expect.arrayContaining([
expect.stringContaining(
"Codex app-backed plugins were planned without source app accessibility verification",
),
]),
);
expect(appServerRequest.mock.calls.filter(([arg]) => arg.method === "app/list")).toHaveLength(
0,
);
});
it("warns and skips app-backed plugins when source Codex account is not ChatGPT subscription auth", async () => {
const fixture = await createCodexFixture();
appServerRequest.mockImplementation(async ({ method }: { method: string }) => {
if (method === "plugin/list") {
return pluginList([pluginSummary("gmail", { installed: true, enabled: true })]);
}
if (method === "plugin/read") {
return pluginRead("gmail", [pluginApp("app-gmail", { name: "Gmail", needsAuth: true })]);
}
if (method === "account/read") {
return {
account: { type: "apiKey" },
requiresOpenaiAuth: true,
} satisfies CodexGetAccountResponse;
}
throw new Error(`unexpected request ${method}`);
});
const provider = buildCodexMigrationProvider();
const plan = await provider.plan(
makeContext({
source: fixture.codexHome,
stateDir: fixture.stateDir,
workspaceDir: fixture.workspaceDir,
}),
);
expect(plan.items.some((item) => item.id === "plugin:gmail")).toBe(false);
expect(plan.items.some((item) => item.id === "config:codex-plugins")).toBe(false);
const manualItem = findItemByReason(plan.items, "codex_subscription_required");
expectRecordFields(manualItem, {
kind: "manual",
action: "manual",
status: "skipped",
reason: "codex_subscription_required",
});
const details = expectRecordFields(manualItem.details, {
pluginName: "gmail",
marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME,
});
expect(details).not.toHaveProperty("code");
expect(details.apps).toEqual([
{
id: "app-gmail",
name: "Gmail",
needsAuth: true,
},
]);
expect(plan.warnings).toEqual(
expect.arrayContaining([
expect.stringContaining(
"Codex app-backed plugin migration requires the Codex app-server source account",
),
]),
);
expect(plan.warnings).not.toEqual(
expect.arrayContaining([expect.stringContaining("planned for native activation")]),
);
expect(appServerRequest.mock.calls.filter(([arg]) => arg.method === "app/list")).toHaveLength(
0,
);
});
it("warns and skips app-backed plugins when source Codex account is missing", async () => {
const fixture = await createCodexFixture();
appServerRequest.mockImplementation(async ({ method }: { method: string }) => {
if (method === "plugin/list") {
return pluginList([pluginSummary("gmail", { installed: true, enabled: true })]);
}
if (method === "plugin/read") {
return pluginRead("gmail", [pluginApp("app-gmail", { name: "Gmail", needsAuth: true })]);
}
if (method === "account/read") {
return {
account: null,
requiresOpenaiAuth: true,
} satisfies CodexGetAccountResponse;
}
throw new Error(`unexpected request ${method}`);
});
const provider = buildCodexMigrationProvider();
const plan = await provider.plan(
makeContext({
source: fixture.codexHome,
stateDir: fixture.stateDir,
workspaceDir: fixture.workspaceDir,
}),
);
expect(plan.items.some((item) => item.id === "plugin:gmail")).toBe(false);
expect(plan.items.some((item) => item.id === "config:codex-plugins")).toBe(false);
expectRecordFields(findItemByReason(plan.items, "codex_subscription_required"), {
reason: "codex_subscription_required",
status: "skipped",
});
expect(appServerRequest.mock.calls.filter(([arg]) => arg.method === "app/list")).toHaveLength(
0,
);
});
it("falls through to app inventory when source account read fails and app verification is requested", async () => {
const fixture = await createCodexFixture();
appServerRequest.mockImplementation(async ({ method }: { method: string }) => {
if (method === "plugin/list") {
return pluginList([pluginSummary("gmail", { installed: true, enabled: true })]);
}
if (method === "plugin/read") {
return pluginRead("gmail", [pluginApp("app-gmail", { name: "Gmail", needsAuth: true })]);
}
if (method === "account/read") {
throw new Error("account unavailable");
}
if (method === "app/list") {
return appsList([appInfo("app-gmail")]);
}
throw new Error(`unexpected request ${method}`);
});
const provider = buildCodexMigrationProvider();
const plan = await provider.plan(
makeContext({
source: fixture.codexHome,
stateDir: fixture.stateDir,
workspaceDir: fixture.workspaceDir,
verifyPluginApps: true,
}),
);
expectRecordFields(findItem(plan.items, "plugin:gmail"), {
kind: "plugin",
action: "install",
status: "planned",
});
expect(appServerRequest.mock.calls.filter(([arg]) => arg.method === "app/list")).toHaveLength(
1,
);
});
it("skips app-backed plugins by default when source account read fails", async () => {
const fixture = await createCodexFixture();
appServerRequest.mockImplementation(async ({ method }: { method: string }) => {
if (method === "plugin/list") {
return pluginList([pluginSummary("gmail", { installed: true, enabled: true })]);
}
if (method === "plugin/read") {
return pluginRead("gmail", [pluginApp("app-gmail", { name: "Gmail", needsAuth: true })]);
}
if (method === "account/read") {
throw new Error("account unavailable");
}
throw new Error(`unexpected request ${method}`);
});
const provider = buildCodexMigrationProvider();
const plan = await provider.plan(
makeContext({
source: fixture.codexHome,
stateDir: fixture.stateDir,
workspaceDir: fixture.workspaceDir,
}),
);
expect(plan.items.some((item) => item.id === "plugin:gmail")).toBe(false);
expect(plan.items.some((item) => item.id === "config:codex-plugins")).toBe(false);
const manualItem = findItemByReason(plan.items, "codex_account_unavailable");
expectRecordFields(manualItem, {
kind: "manual",
action: "manual",
reason: "codex_account_unavailable",
status: "skipped",
});
expectRecordFields(manualItem.details, { error: "account unavailable" });
expect(appServerRequest.mock.calls.filter(([arg]) => arg.method === "app/list")).toHaveLength(
0,
);
});
it("reads source plugin readiness with native source auth instead of target agent auth", async () => {
const fixture = await createCodexFixture();
appServerRequest.mockImplementation(async ({ method }: { method: string }) => {
if (method === "plugin/list") {
return pluginList([pluginSummary("google-calendar", { installed: true, enabled: true })]);
}
if (method === "plugin/read") {
return pluginRead("google-calendar", [
pluginApp("app-google-calendar", { name: "Google Calendar", needsAuth: false }),
]);
}
if (method === "account/read") {
return chatGptAccount();
}
if (method === "app/list") {
return appsList([appInfo("app-google-calendar")]);
}
throw new Error(`unexpected request ${method}`);
});
const provider = buildCodexMigrationProvider();
await provider.plan(
makeContext({
source: fixture.codexHome,
stateDir: fixture.stateDir,
workspaceDir: fixture.workspaceDir,
verifyPluginApps: true,
config: {
agents: {
defaults: {
workspace: fixture.workspaceDir,
},
},
auth: {
order: {
"openai-codex": ["openai-codex:target"],
},
},
} as MigrationProviderContext["config"],
}),
);
expect(appServerRequest).toHaveBeenCalledTimes(4);
for (const [arg] of appServerRequest.mock.calls) {
expect(arg).toEqual(
expect.objectContaining({
authProfileId: null,
isolated: true,
startOptions: expect.objectContaining({
env: {
CODEX_HOME: fixture.codexHome,
HOME: path.dirname(fixture.codexHome),
},
}),
}),
);
expect(arg).not.toHaveProperty("agentDir");
expect(arg).not.toHaveProperty("config");
}
});
it("reports inaccessible before missing when multiple owned apps are blocked", async () => {
const fixture = await createCodexFixture();
appServerRequest.mockImplementation(async ({ method }: { method: string }) => {
if (method === "plugin/list") {
return pluginList([pluginSummary("readwise", { installed: true, enabled: true })]);
}
if (method === "plugin/read") {
return pluginRead("readwise", [
pluginApp("asdk_app_readwise", { name: "Readwise", needsAuth: false }),
pluginApp("asdk_app_reader", { name: "Reader", needsAuth: false }),
]);
}
if (method === "account/read") {
return chatGptAccount();
}
if (method === "app/list") {
return appsList([
appInfo("asdk_app_readwise", {
name: "Readwise",
isAccessible: false,
isEnabled: true,
}),
]);
}
throw new Error(`unexpected request ${method}`);
});
const provider = buildCodexMigrationProvider();
const plan = await provider.plan(
makeContext({
source: fixture.codexHome,
stateDir: fixture.stateDir,
workspaceDir: fixture.workspaceDir,
verifyPluginApps: true,
}),
);
const manualItem = findItemByReason(plan.items, "app_inaccessible");
expectRecordFields(manualItem, {
reason: "app_inaccessible",
status: "skipped",
});
const details = expectRecordFields(manualItem.details, {
pluginName: "readwise",
marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME,
});
expect(details).not.toHaveProperty("code");
expect(details.apps).toEqual([
{
id: "asdk_app_reader",
name: "Reader",
needsAuth: false,
},
{
id: "asdk_app_readwise",
name: "Readwise",
isAccessible: false,
isEnabled: true,
needsAuth: false,
},
]);
});
it("force-refreshes source app inventory once for app-backed plugins sharing a cache key", async () => {
const fixture = await createCodexFixture();
await defaultCodexAppInventoryCache.refreshNow({
key: sourceAppCacheKey(fixture),
request: async () => appsList([appInfo("app-google-calendar", { isAccessible: false })]),
});
appServerRequest.mockImplementation(
async ({ method, requestParams }: { method: string; requestParams?: unknown }) => {
if (method === "plugin/list") {
return pluginList([
pluginSummary("google-calendar", { installed: true, enabled: true }),
pluginSummary("gmail", { installed: true, enabled: true }),
]);
}
if (method === "plugin/read") {
const pluginName = (requestParams as v2.PluginReadParams).pluginName;
return pluginRead(pluginName, [pluginApp(`app-${pluginName}`)]);
}
if (method === "account/read") {
return chatGptAccount();
}
if (method === "app/list") {
expectRecordFields(requestParams, { forceRefetch: true });
return appsList([appInfo("app-google-calendar"), appInfo("app-gmail")]);
}
throw new Error(`unexpected request ${method}`);
},
);
const provider = buildCodexMigrationProvider();
const plan = await provider.plan(
makeContext({
source: fixture.codexHome,
stateDir: fixture.stateDir,
workspaceDir: fixture.workspaceDir,
verifyPluginApps: true,
}),
);
expectRecordFields(findItem(plan.items, "plugin:google-calendar"), { status: "planned" });
expectRecordFields(findItem(plan.items, "plugin:gmail"), { status: "planned" });
expect(appServerRequest.mock.calls.filter(([arg]) => arg.method === "app/list")).toHaveLength(
1,
);
});
it("fails closed for disabled plugins and plugin/read failures", async () => {
const fixture = await createCodexFixture();
appServerRequest.mockImplementation(
async ({ method, requestParams }: { method: string; requestParams?: unknown }) => {
if (method === "plugin/list") {
return pluginList([
pluginSummary("readwise", { installed: true, enabled: false }),
pluginSummary("gmail", { installed: true, enabled: true }),
]);
}
if (method === "plugin/read") {
expectRecordFields(requestParams, { pluginName: "gmail" });
throw new Error("detail unavailable");
}
throw new Error(`unexpected request ${method}`);
},
);
const provider = buildCodexMigrationProvider();
const plan = await provider.plan(
makeContext({
source: fixture.codexHome,
stateDir: fixture.stateDir,
workspaceDir: fixture.workspaceDir,
verifyPluginApps: true,
}),
);
expectRecordFields(findItemByReason(plan.items, "plugin_disabled"), {
reason: "plugin_disabled",
status: "skipped",
});
expectRecordFields(findItemByReason(plan.items, "plugin_read_unavailable"), {
reason: "plugin_read_unavailable",
status: "skipped",
});
expect(plan.items.some((item) => item.id === "config:codex-plugins")).toBe(false);
expect(appServerRequest.mock.calls.filter(([arg]) => arg.method === "app/list")).toHaveLength(
0,
);
});
it("fails closed when app inventory refresh fails for app-backed plugins", async () => {
const fixture = await createCodexFixture();
appServerRequest.mockImplementation(async ({ method }: { method: string }) => {
if (method === "plugin/list") {
return pluginList([pluginSummary("readwise", { installed: true, enabled: true })]);
}
if (method === "plugin/read") {
return pluginRead("readwise", [pluginApp("asdk_app_readwise", { name: "Readwise" })]);
}
if (method === "account/read") {
return chatGptAccount();
}
if (method === "app/list") {
throw new Error("app inventory unavailable");
}
throw new Error(`unexpected request ${method}`);
});
const provider = buildCodexMigrationProvider();
const plan = await provider.plan(
makeContext({
source: fixture.codexHome,
stateDir: fixture.stateDir,
workspaceDir: fixture.workspaceDir,
verifyPluginApps: true,
}),
);
expectRecordFields(findItemByReason(plan.items, "app_inventory_unavailable"), {
reason: "app_inventory_unavailable",
status: "skipped",
});
expect(plan.items.some((item) => item.id === "plugin:readwise")).toBe(false);
});
it("treats auth-required source apps as ready when app inventory says they are accessible", async () => {
const fixture = await createCodexFixture();
appServerRequest.mockImplementation(async ({ method }: { method: string }) => {
if (method === "plugin/list") {
return pluginList([pluginSummary("reader", { installed: true, enabled: true })]);
}
if (method === "plugin/read") {
return pluginRead("reader", [
pluginApp("ready-app", { name: "Ready App", needsAuth: false }),
pluginApp("auth-app", { name: "Auth App", needsAuth: true }),
]);
}
if (method === "account/read") {
return chatGptAccount();
}
if (method === "app/list") {
return appsList([appInfo("ready-app"), appInfo("auth-app")]);
}
throw new Error(`unexpected request ${method}`);
});
const provider = buildCodexMigrationProvider();
const plan = await provider.plan(
makeContext({
source: fixture.codexHome,
stateDir: fixture.stateDir,
workspaceDir: fixture.workspaceDir,
verifyPluginApps: true,
}),
);
const pluginItem = findItem(plan.items, "plugin:reader");
expectRecordFields(pluginItem, {
kind: "plugin",
action: "install",
status: "planned",
});
expectRecordFields(pluginItem.details, {
configKey: "reader",
pluginName: "reader",
marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME,
});
});
it("copies planned skills and archives native config during apply", async () => {
const fixture = await createCodexFixture();
const reportDir = path.join(fixture.root, "report");
@@ -275,6 +904,9 @@ describe("buildCodexMigrationProvider", () => {
if (method === "plugin/list") {
return pluginList([pluginSummary("google-calendar", { installed: true, enabled: true })]);
}
if (method === "plugin/read") {
return pluginRead("google-calendar");
}
if (method === "plugin/install") {
return { authPolicy: "ON_USE", appsNeedingAuth: [] } satisfies v2.PluginInstallResponse;
}
@@ -287,6 +919,9 @@ describe("buildCodexMigrationProvider", () => {
if (method === "config/mcpServer/reload") {
return {};
}
if (method === "app/list") {
return appsList([]);
}
throw new Error(`unexpected request ${method}`);
});
const provider = buildCodexMigrationProvider({
@@ -375,6 +1010,9 @@ describe("buildCodexMigrationProvider", () => {
pluginSummary("gmail", { installed: true, enabled: true }),
]);
}
if (method === "plugin/read") {
return pluginRead("google-calendar");
}
throw new Error(`unexpected request ${method}`);
});
const provider = buildCodexMigrationProvider();
@@ -415,6 +1053,9 @@ describe("buildCodexMigrationProvider", () => {
if (method === "plugin/list") {
return pluginList([pluginSummary("google-calendar", { installed: true, enabled: true })]);
}
if (method === "plugin/read") {
return pluginRead("google-calendar");
}
if (method === "plugin/install") {
return { authPolicy: "ON_USE", appsNeedingAuth: [] } satisfies v2.PluginInstallResponse;
}
@@ -427,6 +1068,9 @@ describe("buildCodexMigrationProvider", () => {
if (method === "config/mcpServer/reload") {
return {};
}
if (method === "app/list") {
return appsList([]);
}
throw new Error(`unexpected request ${method}`);
});
const provider = buildCodexMigrationProvider({
@@ -449,6 +1093,11 @@ describe("buildCodexMigrationProvider", () => {
it("merges migrated plugin config with existing Codex plugins when entries do not conflict", async () => {
const fixture = await createCodexFixture();
const sourceKey = sourceAppCacheKey(fixture);
await defaultCodexAppInventoryCache.refreshNow({
key: sourceKey,
request: async () => appsList([appInfo("source-only-app")]),
});
const configState: MigrationProviderContext["config"] = {
plugins: {
entries: {
@@ -476,6 +1125,9 @@ describe("buildCodexMigrationProvider", () => {
if (method === "plugin/list") {
return pluginList([pluginSummary("google-calendar", { installed: true, enabled: true })]);
}
if (method === "plugin/read") {
return pluginRead("google-calendar");
}
if (method === "plugin/install") {
return { authPolicy: "ON_USE", appsNeedingAuth: [] } satisfies v2.PluginInstallResponse;
}
@@ -488,6 +1140,9 @@ describe("buildCodexMigrationProvider", () => {
if (method === "config/mcpServer/reload") {
return {};
}
if (method === "app/list") {
return appsList([]);
}
throw new Error(`unexpected request ${method}`);
});
const provider = buildCodexMigrationProvider({
@@ -504,6 +1159,14 @@ describe("buildCodexMigrationProvider", () => {
);
expectRecordFields(findItem(result.items, "config:codex-plugins"), { status: "migrated" });
const sourceCacheRead = defaultCodexAppInventoryCache.read({
key: sourceKey,
request: async () => {
throw new Error("source app cache was cleared");
},
});
expect(sourceCacheRead.state).toBe("fresh");
expect(sourceCacheRead.snapshot?.apps.map((app) => app.id)).toEqual(["source-only-app"]);
expect(configState.plugins?.entries?.codex?.config?.codexPlugins).toEqual({
allow_destructive_actions: true,
plugins: {
@@ -545,6 +1208,9 @@ describe("buildCodexMigrationProvider", () => {
if (method === "plugin/list") {
return pluginList([pluginSummary("google-calendar", { installed: true, enabled: true })]);
}
if (method === "plugin/read") {
return pluginRead("google-calendar");
}
if (method === "plugin/install") {
return { authPolicy: "ON_USE", appsNeedingAuth: [] } satisfies v2.PluginInstallResponse;
}
@@ -557,6 +1223,9 @@ describe("buildCodexMigrationProvider", () => {
if (method === "config/mcpServer/reload") {
return {};
}
if (method === "app/list") {
return appsList([]);
}
throw new Error(`unexpected request ${method}`);
});
const provider = buildCodexMigrationProvider({
@@ -596,6 +1265,9 @@ describe("buildCodexMigrationProvider", () => {
if (method === "plugin/list") {
return pluginList([pluginSummary("google-calendar", { installed: true, enabled: true })]);
}
if (method === "plugin/read") {
return pluginRead("google-calendar");
}
if (method === "plugin/install") {
return {
authPolicy: "ON_USE",
@@ -619,6 +1291,9 @@ describe("buildCodexMigrationProvider", () => {
if (method === "config/mcpServer/reload") {
return {};
}
if (method === "app/list") {
return appsList([]);
}
throw new Error(`unexpected request ${method}`);
});
const provider = buildCodexMigrationProvider({
@@ -671,6 +1346,9 @@ describe("buildCodexMigrationProvider", () => {
if (method === "plugin/list") {
return pluginList([pluginSummary("google-calendar", { installed: true, enabled: true })]);
}
if (method === "plugin/read") {
return pluginRead("google-calendar");
}
if (method === "plugin/install") {
throw new Error("install failed");
}
@@ -777,6 +1455,61 @@ function pluginList(plugins: v2.PluginSummary[]): v2.PluginListResponse {
};
}
function pluginRead(pluginName: string, apps: v2.AppSummary[] = []): v2.PluginReadResponse {
return {
plugin: {
marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME,
marketplacePath: "/marketplaces/openai-curated",
summary: pluginSummary(pluginName, { installed: true, enabled: true }),
description: null,
skills: [],
apps,
mcpServers: [],
},
};
}
function pluginApp(id: string, overrides: Partial<v2.AppSummary> = {}): v2.AppSummary {
return {
id,
name: id,
description: null,
installUrl: null,
needsAuth: false,
...overrides,
};
}
function appInfo(id: string, overrides: Partial<v2.AppInfo> = {}): v2.AppInfo {
return {
id,
name: id,
description: null,
logoUrl: null,
logoUrlDark: null,
distributionChannel: null,
branding: null,
appMetadata: null,
labels: null,
installUrl: null,
isAccessible: true,
isEnabled: true,
pluginDisplayNames: [],
...overrides,
};
}
function appsList(apps: v2.AppInfo[]): v2.AppsListResponse {
return { data: apps, nextCursor: null };
}
function chatGptAccount(): CodexGetAccountResponse {
return {
account: { type: "chatgpt", email: "codex@example.test", planType: "plus" },
requiresOpenaiAuth: false,
};
}
function pluginSummary(id: string, overrides: Partial<v2.PluginSummary> = {}): v2.PluginSummary {
return {
id,

View File

@@ -18,7 +18,9 @@ export function buildCodexMigrationProvider(
description:
"Inventory and promote Codex CLI skills while keeping Codex native plugins and hooks explicit.",
async detect(ctx) {
const source = await discoverCodexSource(ctx.source);
const source = await discoverCodexSource({
input: ctx.source,
});
const found = hasCodexSource(source);
return {
found,

View File

@@ -1,8 +1,18 @@
import type { Dirent } from "node:fs";
import fs from "node:fs/promises";
import path from "node:path";
import {
defaultCodexAppInventoryCache,
type CodexAppInventoryRequest,
} from "../app-server/app-inventory-cache.js";
import { CODEX_PLUGINS_MARKETPLACE_NAME } from "../app-server/config.js";
import type { v2 } from "../app-server/protocol.js";
import type { CodexAppServerStartOptions } from "../app-server/config.js";
import { buildCodexPluginAppCacheKey } from "../app-server/plugin-app-cache-key.js";
import {
pluginReadParams,
type CodexPluginMarketplaceRef,
} from "../app-server/plugin-inventory.js";
import type { CodexGetAccountResponse, v2 } from "../app-server/protocol.js";
import { requestCodexAppServerJson } from "../app-server/request.js";
import {
exists,
@@ -32,9 +42,35 @@ export type CodexPluginSource = {
pluginName?: string;
installed?: boolean;
enabled?: boolean;
apps?: CodexPluginMigrationAppFact[];
migrationBlock?: CodexPluginMigrationBlock;
message?: string;
};
export type CodexPluginMigrationBlockCode =
| "plugin_disabled"
| "codex_subscription_required"
| "codex_account_unavailable"
| "plugin_read_unavailable"
| "app_inventory_unavailable"
| "app_inaccessible"
| "app_disabled"
| "app_missing";
export type CodexPluginMigrationAppFact = {
id: string;
name: string;
needsAuth?: boolean;
isAccessible?: boolean;
isEnabled?: boolean;
};
export type CodexPluginMigrationBlock = {
code: CodexPluginMigrationBlockCode;
apps?: CodexPluginMigrationAppFact[];
error?: string;
};
type CodexArchiveSource = {
id: string;
path: string;
@@ -56,6 +92,26 @@ type CodexSource = {
archivePaths: CodexArchiveSource[];
};
type CodexSourceDiscoveryOptions = {
input?: string;
evaluatePluginMigrationEligibility?: boolean;
verifyPluginApps?: boolean;
};
type SourceAppServerRequestOptions = {
startOptions: CodexAppServerStartOptions;
};
type PluginReadResult =
| {
ok: true;
detail: v2.PluginDetail;
}
| {
ok: false;
error: string;
};
function defaultCodexHome(): string {
return resolveHomePath(process.env.CODEX_HOME?.trim() || "~/.codex");
}
@@ -137,27 +193,19 @@ async function discoverPluginDirs(codexHome: string): Promise<CodexPluginSource[
return [...discovered.values()].toSorted((a, b) => a.source.localeCompare(b.source));
}
async function discoverInstalledCuratedPlugins(codexHome: string): Promise<{
async function discoverInstalledCuratedPlugins(
codexHome: string,
options: CodexSourceDiscoveryOptions = {},
): Promise<{
plugins: CodexPluginSource[];
error?: string;
}> {
const startOptions = sourceCodexAppServerStartOptions(codexHome);
const requestOptions = { startOptions };
try {
const response = await requestCodexAppServerJson<v2.PluginListResponse>({
const response = await requestSourceCodexAppServerJson<v2.PluginListResponse>(requestOptions, {
method: "plugin/list",
requestParams: { cwds: [] } satisfies v2.PluginListParams,
timeoutMs: 60_000,
isolated: true,
startOptions: {
transport: "stdio",
command: "codex",
commandSource: "config",
args: ["app-server", "--listen", "stdio://"],
headers: {},
env: {
CODEX_HOME: codexHome,
HOME: path.dirname(codexHome),
},
},
});
const marketplace = response.marketplaces.find(
(entry) => entry.name === CODEX_PLUGINS_MARKETPLACE_NAME,
@@ -170,25 +218,21 @@ async function discoverInstalledCuratedPlugins(codexHome: string): Promise<{
}
const plugins = marketplace.plugins
.filter((plugin) => plugin.installed)
.map((plugin): CodexPluginSource | undefined => {
const pluginName = pluginNameFromSummary(plugin);
if (!pluginName) {
return undefined;
}
return {
name: plugin.name,
pluginName,
marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME,
source: `${CODEX_PLUGINS_MARKETPLACE_NAME}/${pluginName}`,
sourceKind: "app-server",
migratable: true,
installed: plugin.installed,
enabled: plugin.enabled,
};
})
.filter((plugin): plugin is CodexPluginSource => plugin !== undefined)
.toSorted((a, b) => (a.pluginName ?? a.name).localeCompare(b.pluginName ?? b.name));
return { plugins };
.map((plugin) => buildInstalledPluginSource(plugin))
.filter((plugin): plugin is CodexPluginSource => plugin !== undefined);
const withEligibility =
options.evaluatePluginMigrationEligibility === true
? await withPluginMigrationEligibility({
plugins,
marketplace: marketplaceRef(marketplace),
requestOptions,
verifyPluginApps: options.verifyPluginApps === true,
})
: plugins;
const sorted = withEligibility.toSorted((a, b) =>
(a.pluginName ?? a.name).localeCompare(b.pluginName ?? b.name),
);
return { plugins: sorted };
} catch (error) {
return {
plugins: [],
@@ -197,6 +241,308 @@ async function discoverInstalledCuratedPlugins(codexHome: string): Promise<{
}
}
function sourceCodexAppServerStartOptions(codexHome: string): CodexAppServerStartOptions {
return {
transport: "stdio",
command: "codex",
commandSource: "managed",
args: ["app-server", "--listen", "stdio://"],
headers: {},
env: {
CODEX_HOME: codexHome,
HOME: path.dirname(codexHome),
},
};
}
async function requestSourceCodexAppServerJson<T>(
options: SourceAppServerRequestOptions,
params: {
method: string;
requestParams?: unknown;
},
): Promise<T> {
return await requestCodexAppServerJson<T>({
method: params.method,
requestParams: params.requestParams,
timeoutMs: 60_000,
startOptions: options.startOptions,
authProfileId: null,
isolated: true,
});
}
function buildInstalledPluginSource(plugin: v2.PluginSummary): CodexPluginSource | undefined {
const pluginName = pluginNameFromSummary(plugin);
if (!pluginName) {
return undefined;
}
return {
name: plugin.name,
pluginName,
marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME,
source: `${CODEX_PLUGINS_MARKETPLACE_NAME}/${pluginName}`,
sourceKind: "app-server",
migratable: true,
installed: plugin.installed,
enabled: plugin.enabled,
};
}
function marketplaceRef(marketplace: v2.PluginMarketplaceEntry): CodexPluginMarketplaceRef {
return {
name: CODEX_PLUGINS_MARKETPLACE_NAME,
...(marketplace.path ? { path: marketplace.path } : {}),
...(!marketplace.path ? { remoteMarketplaceName: marketplace.name } : {}),
};
}
async function withPluginMigrationEligibility(params: {
plugins: CodexPluginSource[];
marketplace: CodexPluginMarketplaceRef;
requestOptions: SourceAppServerRequestOptions;
verifyPluginApps: boolean;
}): Promise<CodexPluginSource[]> {
const pending: Array<{ plugin: CodexPluginSource; apps: CodexPluginMigrationAppFact[] }> = [];
const evaluated: CodexPluginSource[] = [];
for (const plugin of params.plugins) {
if (plugin.enabled !== true) {
evaluated.push({
...plugin,
migratable: false,
migrationBlock: { code: "plugin_disabled" },
message: `Codex plugin "${plugin.pluginName ?? plugin.name}" is installed in Codex but disabled; enable it in Codex before migrating it to OpenClaw.`,
});
continue;
}
const detail = await readPluginDetail(params.requestOptions, params.marketplace, plugin);
if (!detail.ok) {
evaluated.push({
...plugin,
migratable: false,
migrationBlock: { code: "plugin_read_unavailable", error: detail.error },
message: `Codex plugin "${plugin.pluginName ?? plugin.name}" detail could not be read: ${detail.error}`,
});
continue;
}
if (detail.detail.apps.length === 0) {
evaluated.push({
...plugin,
migratable: true,
});
continue;
}
const apps = detail.detail.apps
.map(sourcePluginAppFact)
.toSorted((left, right) => left.id.localeCompare(right.id));
pending.push({ plugin, apps });
}
if (pending.length === 0) {
return evaluated;
}
let sourceAccount: Awaited<ReturnType<typeof readSourceCodexAccount>> | undefined;
try {
sourceAccount = await readSourceCodexAccount(params.requestOptions);
} catch (error) {
if (!params.verifyPluginApps) {
const message = error instanceof Error ? error.message : String(error);
for (const { plugin, apps } of pending) {
evaluated.push({
...plugin,
migratable: false,
migrationBlock: { code: "codex_account_unavailable", apps, error: message },
message: `Codex plugin "${plugin.pluginName ?? plugin.name}" owns apps, but the source Codex app-server account could not be read: ${message}`,
});
}
return evaluated;
}
}
if (sourceAccount && sourceAccount !== "chatgpt") {
for (const { plugin, apps } of pending) {
evaluated.push({
...plugin,
migratable: false,
migrationBlock: { code: "codex_subscription_required", apps },
message: codexSubscriptionRequiredMessage(plugin),
});
}
return evaluated;
}
if (!params.verifyPluginApps) {
for (const { plugin, apps } of pending) {
evaluated.push({
...plugin,
apps,
migratable: true,
});
}
return evaluated;
}
const snapshot = await refreshSourceAppInventory(params.requestOptions).catch((error) => {
const message = error instanceof Error ? error.message : String(error);
for (const { plugin, apps } of pending) {
evaluated.push({
...plugin,
migratable: false,
migrationBlock: {
code: "app_inventory_unavailable",
apps,
error: message,
},
message: `Codex plugin "${plugin.pluginName ?? plugin.name}" owns apps, but source app inventory could not be read: ${message}`,
});
}
return undefined;
});
if (!snapshot) {
return evaluated;
}
const appInfoById = new Map(snapshot.apps.map((app) => [app.id, app] as const));
for (const { plugin, apps: declaredApps } of pending) {
const apps = declaredApps
.map((app) => sourcePluginAppFactWithInventory(app, appInfoById.get(app.id)))
.toSorted((left, right) => left.id.localeCompare(right.id));
const blockCode = migrationBlockCodeForApps(apps);
if (!blockCode) {
evaluated.push({ ...plugin, apps, migratable: true });
continue;
}
evaluated.push({
...plugin,
migratable: false,
migrationBlock: { code: blockCode, apps },
message: appInventoryBlockMessage(plugin, apps, blockCode),
});
}
return evaluated;
}
async function readSourceCodexAccount(
options: SourceAppServerRequestOptions,
): Promise<"chatgpt" | "non_chatgpt" | "missing"> {
const response = await requestSourceCodexAppServerJson<CodexGetAccountResponse>(options, {
method: "account/read",
requestParams: { refreshToken: false },
});
if (
!response.account ||
typeof response.account !== "object" ||
Array.isArray(response.account)
) {
return "missing";
}
const type = response.account.type;
return type === "chatgpt" ? "chatgpt" : "non_chatgpt";
}
async function readPluginDetail(
options: SourceAppServerRequestOptions,
marketplace: CodexPluginMarketplaceRef,
plugin: CodexPluginSource,
): Promise<PluginReadResult> {
try {
const response = await requestSourceCodexAppServerJson<v2.PluginReadResponse>(options, {
method: "plugin/read",
requestParams: pluginReadParams(marketplace, plugin.pluginName ?? plugin.name),
});
return { ok: true, detail: response.plugin };
} catch (error) {
return { ok: false, error: error instanceof Error ? error.message : String(error) };
}
}
async function refreshSourceAppInventory(
options: SourceAppServerRequestOptions,
): Promise<Awaited<ReturnType<typeof defaultCodexAppInventoryCache.refreshNow>>> {
const key = buildCodexPluginAppCacheKey({
appServer: { start: options.startOptions },
});
const request: CodexAppInventoryRequest = async (method, requestParams) =>
await requestSourceCodexAppServerJson<v2.AppsListResponse>(options, {
method,
requestParams,
});
return await defaultCodexAppInventoryCache.refreshNow({
key,
request,
forceRefetch: true,
});
}
function sourcePluginAppFact(app: v2.AppSummary): CodexPluginMigrationAppFact {
return {
id: app.id,
name: app.name,
needsAuth: app.needsAuth,
};
}
function sourcePluginAppFactWithInventory(
app: CodexPluginMigrationAppFact,
info: v2.AppInfo | undefined,
): CodexPluginMigrationAppFact {
if (!info) {
return app;
}
return {
...app,
isAccessible: info.isAccessible,
isEnabled: info.isEnabled,
};
}
function migrationBlockCodeForApps(
apps: readonly CodexPluginMigrationAppFact[],
): CodexPluginMigrationBlockCode | undefined {
if (apps.some((app) => app.isAccessible === false)) {
return "app_inaccessible";
}
if (apps.some((app) => app.isEnabled === false)) {
return "app_disabled";
}
if (apps.some((app) => app.isAccessible === undefined || app.isEnabled === undefined)) {
return "app_missing";
}
return undefined;
}
function appInventoryBlockMessage(
plugin: CodexPluginSource,
apps: readonly CodexPluginMigrationAppFact[],
code: CodexPluginMigrationBlockCode,
): string {
const status =
code === "app_inaccessible" ? "inaccessible" : code === "app_disabled" ? "disabled" : "missing";
const blocking =
apps.find((app) =>
code === "app_inaccessible"
? app.isAccessible === false
: code === "app_disabled"
? app.isEnabled === false
: app.isAccessible === undefined || app.isEnabled === undefined,
) ?? apps[0];
const appLabel = blocking ? ` app "${blocking.name}"` : " an owned app";
return `Codex plugin "${plugin.pluginName ?? plugin.name}" owns${appLabel} but the source app inventory reports it is ${status}; authenticate or enable the app in Codex before migrating it to OpenClaw.`;
}
export function codexPluginMigrationSubscriptionWarning(): string {
return "Codex app-backed plugin migration requires the Codex app-server source account to be logged in with a ChatGPT subscription account. Log in to the Codex app with subscription auth; OpenClaw auth or API-key auth does not satisfy Codex app connector access.";
}
function codexSubscriptionRequiredMessage(plugin: CodexPluginSource): string {
return `Codex plugin "${plugin.pluginName ?? plugin.name}" owns apps, but ${codexPluginMigrationSubscriptionWarning()}`;
}
function pluginNameFromSummary(summary: v2.PluginSummary): string | undefined {
const candidates = [summary.id, summary.name];
for (const candidate of candidates) {
@@ -216,8 +562,14 @@ function pluginNameFromSummary(summary: v2.PluginSummary): string | undefined {
return undefined;
}
export async function discoverCodexSource(input?: string): Promise<CodexSource> {
const codexHome = resolveHomePath(input?.trim() || defaultCodexHome());
export async function discoverCodexSource(
inputOrOptions?: string | CodexSourceDiscoveryOptions,
): Promise<CodexSource> {
const options =
typeof inputOrOptions === "string" || inputOrOptions === undefined
? { input: inputOrOptions }
: inputOrOptions;
const codexHome = resolveHomePath(options.input?.trim() || defaultCodexHome());
const codexSkillsDir = path.join(codexHome, "skills");
const agentsSkillsDir = personalAgentsSkillsDir();
const configPath = path.join(codexHome, "config.toml");
@@ -231,7 +583,7 @@ export async function discoverCodexSource(input?: string): Promise<CodexSource>
root: agentsSkillsDir,
sourceLabel: "personal AgentSkill",
});
const sourcePluginDiscovery = await discoverInstalledCuratedPlugins(codexHome);
const sourcePluginDiscovery = await discoverInstalledCuratedPlugins(codexHome, options);
const sourcePluginNames = new Set(
sourcePluginDiscovery.plugins.flatMap((plugin) =>
plugin.pluginName ? [plugin.pluginName] : [],

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/comfy-provider",
"version": "2026.5.12-beta.1",
"version": "2026.5.12-beta.7",
"private": true,
"description": "OpenClaw ComfyUI provider plugin",
"type": "module",

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/copilot-proxy",
"version": "2026.5.12-beta.1",
"version": "2026.5.12-beta.7",
"private": true,
"description": "OpenClaw Copilot Proxy provider plugin",
"type": "module",

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/deepgram-provider",
"version": "2026.5.12-beta.1",
"version": "2026.5.12-beta.7",
"private": true,
"description": "OpenClaw Deepgram media-understanding provider",
"type": "module",

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/deepinfra-provider",
"version": "2026.5.12-beta.1",
"version": "2026.5.12-beta.7",
"private": true,
"description": "OpenClaw DeepInfra provider plugin",
"type": "module",

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/deepseek-provider",
"version": "2026.5.12-beta.1",
"version": "2026.5.12-beta.7",
"private": true,
"description": "OpenClaw DeepSeek provider plugin",
"type": "module",

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/diagnostics-otel",
"version": "2026.5.12-beta.1",
"version": "2026.5.12-beta.7",
"description": "OpenClaw diagnostics OpenTelemetry exporter",
"repository": {
"type": "git",
@@ -34,10 +34,10 @@
"minHostVersion": ">=2026.4.25"
},
"compat": {
"pluginApi": ">=2026.5.12-beta.1"
"pluginApi": ">=2026.5.12-beta.7"
},
"build": {
"openclawVersion": "2026.5.12-beta.1"
"openclawVersion": "2026.5.12-beta.7"
},
"release": {
"publishToClawHub": true,

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/diagnostics-prometheus",
"version": "2026.5.12-beta.1",
"version": "2026.5.12-beta.7",
"description": "OpenClaw diagnostics Prometheus exporter",
"repository": {
"type": "git",
@@ -21,10 +21,10 @@
"minHostVersion": ">=2026.4.25"
},
"compat": {
"pluginApi": ">=2026.5.12-beta.1"
"pluginApi": ">=2026.5.12-beta.7"
},
"build": {
"openclawVersion": "2026.5.12-beta.1"
"openclawVersion": "2026.5.12-beta.7"
},
"release": {
"publishToClawHub": true,

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/diffs",
"version": "2026.5.12-beta.1",
"version": "2026.5.12-beta.7",
"description": "OpenClaw diff viewer plugin",
"repository": {
"type": "git",
@@ -31,10 +31,10 @@
"minHostVersion": ">=2026.4.30"
},
"compat": {
"pluginApi": ">=2026.5.12-beta.1"
"pluginApi": ">=2026.5.12-beta.7"
},
"build": {
"openclawVersion": "2026.5.12-beta.1",
"openclawVersion": "2026.5.12-beta.7",
"staticAssets": [
{
"source": "./assets/viewer-runtime.js",

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/discord",
"version": "2026.5.12-beta.1",
"version": "2026.5.12-beta.7",
"description": "OpenClaw Discord channel plugin",
"repository": {
"type": "git",
@@ -21,7 +21,7 @@
"openclaw": "workspace:*"
},
"peerDependencies": {
"openclaw": ">=2026.5.12-beta.1"
"openclaw": ">=2026.5.12-beta.7"
},
"peerDependenciesMeta": {
"openclaw": {
@@ -65,10 +65,10 @@
"allowInvalidConfigRecovery": true
},
"compat": {
"pluginApi": ">=2026.5.12-beta.1"
"pluginApi": ">=2026.5.12-beta.7"
},
"build": {
"openclawVersion": "2026.5.12-beta.1"
"openclawVersion": "2026.5.12-beta.7"
},
"release": {
"publishToClawHub": true,

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/document-extract-plugin",
"version": "2026.5.12-beta.1",
"version": "2026.5.12-beta.7",
"private": true,
"description": "OpenClaw local document extraction plugin",
"type": "module",

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