Compare commits

..

103 Commits

Author SHA1 Message Date
Tideclaw
f85ad97921 test: mock telegram sticker vision in body tests 2026-06-15 11:54:30 +00:00
Tideclaw
d1e3c1a5de test: narrow native redirect routing coverage 2026-06-15 11:37:39 +00:00
Tideclaw
668bfb294f test: avoid inbound claim dispatch timeout 2026-06-15 11:11:13 +00:00
Tideclaw
596bfa9b02 test: guard message hook metadata assertions 2026-06-15 10:45:14 +00:00
Tideclaw
5ec29d67ec test: avoid message hook dispatch timeout 2026-06-15 10:34:44 +00:00
Tideclaw
c33834e5a7 test: type gateway protocol literal union guard 2026-06-15 10:14:40 +00:00
Tideclaw
122c26f4fd fix: refresh alpha plugin shrinkwraps 2026-06-15 10:03:35 +00:00
Tideclaw
1382d81dd7 test: move route policy coverage out of dispatch shard 2026-06-15 09:48:58 +00:00
Tideclaw
ea23075795 fix: refresh alpha release generated metadata 2026-06-15 08:21:13 +00:00
Tideclaw
531e157c00 chore: prepare alpha 2026.6.15-alpha.1 2026-06-15 08:18:35 +00:00
Tideclaw
317a6bcb54 test: type exec event send policy fixture 2026-06-15 08:16:46 +00:00
Tideclaw
2720d5be99 test: stabilize exec event send policy coverage 2026-06-15 08:16:46 +00:00
Tideclaw
54e6fca5d7 test: stabilize exec event route coverage 2026-06-15 08:16:45 +00:00
Tideclaw
3656666a0c test: stabilize routed reply coverage 2026-06-15 08:16:45 +00:00
Tideclaw
1c30d7270d test: avoid mutating reply registry in bypass proof 2026-06-15 08:16:45 +00:00
Tideclaw
33ec887cf1 test: cover active reply bypass directly 2026-06-15 08:16:45 +00:00
Tideclaw
55598e913e test: abort blocked route fixture cleanup 2026-06-15 08:16:45 +00:00
Tideclaw
074ef2c693 test: stabilize routed mirror proof 2026-06-15 08:16:45 +00:00
Tideclaw
c98f63fbe4 test: bypass hooks in CLI mirror route fixture 2026-06-15 08:16:45 +00:00
Tideclaw
a05ae64432 test: use route-only CLI mirror fixture 2026-06-15 08:16:45 +00:00
Tideclaw
895a8d6837 test: avoid routed CLI mirror deadlock 2026-06-15 08:16:45 +00:00
Tideclaw
608dde31fb fix: stabilize alpha release gates 2026-06-15 08:16:45 +00:00
Tideclaw
6c5b2c173c test: stabilize provider ref validation note assertion
(cherry picked from commit ebf4ad4f76)
2026-06-15 08:15:46 +00:00
Tideclaw
b6995c9024 test: lazy load mcp channel package protocol
(cherry picked from commit 64272f0a8f)
(cherry picked from commit 2d7968144f)
2026-06-15 08:15:46 +00:00
Tideclaw
dd4c628835 test: isolate codex startup auth home
(cherry picked from commit cf743adeea)
(cherry picked from commit 23b065bc5b)
(cherry picked from commit 9b300baf05)
2026-06-15 08:15:46 +00:00
Tideclaw
a472002384 test: tolerate stale mcp pairing approvals
(cherry picked from commit c3b3101890)
(cherry picked from commit b73232a594)
(cherry picked from commit 944ec112fa)
2026-06-15 08:15:46 +00:00
Tideclaw
f3cbb21a8c test: fix slack media mock typing
(cherry picked from commit f62d8f7472)
(cherry picked from commit 5499dc9824)
(cherry picked from commit d0ea845996)
2026-06-15 08:15:46 +00:00
Tideclaw
b09e105503 test: stabilize slack pending media history
(cherry picked from commit cce8a14224)
(cherry picked from commit f29a97c4a5)
(cherry picked from commit 5577f24505)
2026-06-15 08:15:46 +00:00
Tideclaw
1010655737 test: prefer transcript runtime parity tools
(cherry picked from commit 02e5f4ad32)
(cherry picked from commit f0a7a6511e)
(cherry picked from commit 871c54a3fc)
2026-06-15 08:15:46 +00:00
Tideclaw
32c01b8fdc test: retry codex startup temp cleanup
(cherry picked from commit 7d756f707e)
(cherry picked from commit 8f0669051c)
(cherry picked from commit 144d6a0181)
2026-06-15 08:15:46 +00:00
Tideclaw
2da6f271a3 test: allow empty rem truth preview
(cherry picked from commit 8bd4714a75)
(cherry picked from commit 4e1df27c8e)
(cherry picked from commit b24871d5f1)
2026-06-15 08:15:46 +00:00
Tideclaw
09d2720e62 test: align imessage echo cache options
(cherry picked from commit 9d9c70af0b)
(cherry picked from commit 04b701f530)
(cherry picked from commit a60f067002)
2026-06-15 08:15:46 +00:00
Tideclaw
0392b9d49b test: stabilize task maintenance release shard
(cherry picked from commit f28ab293b1)
(cherry picked from commit d3f0e7bcb8)
(cherry picked from commit 425ca07bbd)
2026-06-15 08:15:46 +00:00
Tideclaw
14e8b6c7a7 test: stabilize imessage monitor release shard
(cherry picked from commit 1022e49adc)
(cherry picked from commit 9a32601492)
(cherry picked from commit 1cfa0f1267)
(cherry picked from commit 0ae3fc3f42)
2026-06-15 08:15:46 +00:00
Tideclaw
bd6150b536 test: register matrix fixture for cross-agent acp spawn
(cherry picked from commit 1e74b8c081)
(cherry picked from commit ef66cd908d)
(cherry picked from commit 31ae2847e9)
(cherry picked from commit 3d19bebb2a)
2026-06-15 08:15:46 +00:00
Tideclaw
f665ffdb9d test: stabilize acp spawn channel fixtures
(cherry picked from commit 17a4a7f7d7)
(cherry picked from commit f149bfd196)
(cherry picked from commit 791a595b41)
(cherry picked from commit 6dbf7bcf3f)
2026-06-15 08:15:45 +00:00
Tideclaw
df7468985c test: use sqlite session stores in release fixtures
(cherry picked from commit 9afb3acff7)
(cherry picked from commit c1def265eb)
(cherry picked from commit ab528e0d46)
(cherry picked from commit 06f3fdeed0)
2026-06-15 08:15:45 +00:00
Tideclaw
42dca79690 test: seed telegram approval stores through sdk
(cherry picked from commit 6a06a36f26)
(cherry picked from commit 70e7d8bc77)
(cherry picked from commit 4803fd24a0)
(cherry picked from commit d32b854c41)
2026-06-15 08:15:45 +00:00
Tideclaw
343c631bd9 test: seed remaining plugin stores through sdk
(cherry picked from commit aea32e6b6c)
(cherry picked from commit 9323c0aab4)
(cherry picked from commit 750693726d)
(cherry picked from commit de7aa23bb8)
2026-06-15 08:15:45 +00:00
Tideclaw
ab91ff313a test: use valid slack session entries
(cherry picked from commit 08f4ff46da)
(cherry picked from commit 606fb0a6af)
(cherry picked from commit 5825af73f7)
(cherry picked from commit 91d1a29574)
2026-06-15 08:15:45 +00:00
Tideclaw
8fb90b382c test: seed channel session stores through sdk
(cherry picked from commit 066ed9dd84)
(cherry picked from commit 3db224497b)
(cherry picked from commit a5cbe49305)
(cherry picked from commit 244e6e6512)
2026-06-15 08:15:45 +00:00
Tideclaw
316c1b6204 fix: allow alpha focused publish proof
(cherry picked from commit 8d91342423)
(cherry picked from commit 54750b096a)
(cherry picked from commit 607d6947ac)
(cherry picked from commit ccc929021b)
2026-06-15 08:15:45 +00:00
Peter Steinberger
7a7165ad22 fix(protocol): emit Swift enums for literal unions 2026-06-15 03:20:42 -04:00
Vincent Koc
928b5932a3 fix(agents): track normalized message target evidence 2026-06-15 15:10:20 +08:00
Jason (Json)
77a682c5de fix(agents): retry empty post-tool final turns (#93073)
Recover assistant turns that complete tool work without producing a visible final answer, while preserving intentional silent replies.

Use concrete tool-instance replay safety across embedded, Codex, and Copilot runtimes so unknown, mutating, async-started, and durable recall operations fail closed. Preserve genuine empty Codex final items without promoting commentary or tool-progress echoes.

Supersedes #90872. Thanks @fuller-stack-dev.

Co-authored-by: fuller-stack-dev <263060202+fuller-stack-dev@users.noreply.github.com>
2026-06-15 00:08:57 -07:00
Vincent Koc
efbefceb0e fix(cron): preserve explicit delivery and timeout semantics 2026-06-15 14:58:46 +08:00
Goutam Adwant
1c30bb8ce6 fix(telegram): preserve sticker media paths (#93130)
* fix(telegram): preserve sticker media paths

* fix(telegram): address PR validation failures

* fix(telegram): preserve sticker media context

* test(telegram): fix sticker proof checks

---------

Co-authored-by: Vincent Koc <25068+vincentkoc@users.noreply.github.com>
2026-06-15 14:43:32 +08:00
Vincent Koc
94833b2c90 fix(release): support ClawHub-only runtime builds 2026-06-15 14:23:57 +08:00
Vincent Koc
1057e74438 fix(e2e): resolve macOS Parallels VM
(cherry picked from commit a231ab8acf)
2026-06-15 14:23:57 +08:00
Vincent Koc
55a6d8c57d fix(e2e): resume restored Parallels snapshots
(cherry picked from commit a7e0822a1a)
2026-06-15 14:23:57 +08:00
Vincent Koc
b8967fc877 fix(docker): seed prune store from lockfile
(cherry picked from commit 47ec5be9ef)
2026-06-15 14:23:57 +08:00
Vincent Koc
42759a1b79 fix(telegram): repair rich message typecheck 2026-06-15 14:23:57 +08:00
Vincent Koc
5b18b7560e fix(release): harden plugin package preflight 2026-06-15 14:23:57 +08:00
dongdong
bcb016a528 fix: accept mixed source/dist bundled roots (#93119)
* fix: accept mixed source/dist bundled roots fixes #87730

* fix(plugins): validate mixed bundled roots per plugin

* fix(plugins): preserve active source overlays

---------

Co-authored-by: Jasmine Zhang <jasminezhang@JasminedeMac-mini.local>
Co-authored-by: Vincent Koc <25068+vincentkoc@users.noreply.github.com>
2026-06-15 14:00:48 +08:00
Ayaan Zaidi
b3f315461b fix(telegram): preserve rich markdown line breaks 2026-06-15 11:07:15 +05:30
Vincent Koc
5b460c4669 fix(status): preserve rich message line breaks 2026-06-15 11:07:15 +05:30
Dallin Romney
1f8c4d3958 simplify QA evidence profile and mappings/coverage shape (#93153)
* test(qa): simplify evidence coverage shape

* test(qa): collapse evidence scorecard metadata

* test(qa): document evidence schema version
2026-06-14 22:26:58 -07:00
mushuiyu_xydt
04875efd28 fix(memory-core): vary dream diary recall snippets (#91225)
Prevent repeated first-day Dream Diary narratives by prioritizing fresh recall snippets across the bounded short-term store and adding recent diary context to narrative generation. Keep diary reads best-effort and reject symlink/non-file inputs.

Fixes #83830.

Thanks @mushuiyu886.

Co-authored-by: 杨浩宇0668001029 <yang.haoyu@xydigit.com>
2026-06-14 22:02:06 -07:00
liuhao1024
7e0128ae65 fix(agents): preserve literal current session resolution (#93138)
* fix(agents): resolve "current" session alias locally without gateway round-trip

The system prompt tells agents to use sessionKey="current" to refer to
their own session.  Previously, resolveSessionReference sent the literal
string "current" to the gateway sessions.resolve action, which rejected
it with INVALID_REQUEST and logged a noisy error line on every tool call.
The wrapper fell back to requesterInternalKey and succeeded, so the tool
worked — but the gateway error was spurious.

Add "current" to the well-known client alias check in
resolveCurrentSessionClientAlias so it is resolved locally to the
requester's session key, matching how TUI/CLI/WebChat client labels are
handled.  This eliminates the unnecessary gateway round-trip and the
error log line.

Fixes #78424

* test: update session_status tests for local current-key resolution

* test: update session_status tests for local current-key resolution

* Revert "test: update session_status tests for local current-key resolution"

This reverts commit d9f6c8b5248921c99f43dc222667ffa429b34401.

* Revert "test: update session_status tests for local current-key resolution"

This reverts commit 40bf77d06711833c1beaeedf562b60a765a559d6.

* Revert "fix(agents): resolve "current" session alias locally without gateway round-trip"

This reverts commit d92bc9b91e0840ea5823cd44223c139e434c5ec4.

* fix(agents): preserve literal current session resolution

---------

Co-authored-by: Vincent Koc <25068+vincentkoc@users.noreply.github.com>
2026-06-15 12:32:15 +08:00
liuhao1024
1db8ab3734 fix(feishu): pass card_msg_content_type to get full card content (fixes #78289) (#93134)
* fix(feishu): pass card_msg_content_type to get full card content

When reading Feishu interactive card messages via getMessageFeishu,
the API returns a degraded structure (title + 'upgrade client' prompt)
unless card_msg_content_type=user_card_content is passed in params.

Fixes #78289

* fix(feishu): request full card content for message reads

---------

Co-authored-by: Vincent Koc <25068+vincentkoc@users.noreply.github.com>
2026-06-15 12:04:55 +08:00
Omar Shahine
cc954798f2 fix(imessage): honor disabled reply actions (#93137)
Co-authored-by: Omar Shahine <10343873+omarshahine@users.noreply.github.com>
2026-06-15 11:53:35 +08:00
Mason Huang
4fc805320f fix(cron): require explicit message target proof (#92318)
Summary:
- Merged fix(cron): require explicit message target proof after ClawSweeper review.

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

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

Prepared head SHA: 2aff537f9f
Review: https://github.com/openclaw/openclaw/pull/92318#issuecomment-4704342205

Co-authored-by: Mason Huang <masonxhuang@tencent.com>
Co-authored-by: clawsweeper[bot] <274271284+clawsweeper[bot]@users.noreply.github.com>
Approved-by: hxy91819
Co-authored-by: hxy91819 <8814856+hxy91819@users.noreply.github.com>
2026-06-15 03:52:05 +00:00
Mason Huang
06431fd99b test: add temp directory helper guidance (#87298)
Summary:
- Merged test: add temp directory helper guidance after ClawSweeper review.

Automerge notes:
- PR branch already contained follow-up commit before automerge: fix(scripts): honor temp report failure mode
- PR branch already contained follow-up commit before automerge: fix(scripts): reduce temp report noise
- PR branch already contained follow-up commit before automerge: fix(scripts): cover test support temp reports
- PR branch already contained follow-up commit before automerge: fix(scripts): report temp use in test helpers
- PR branch already contained follow-up commit before automerge: fix(scripts): broaden temp report test surface
- PR branch already contained follow-up commit before automerge: fix(scripts): cover nested test temp reports

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

Prepared head SHA: 132f14a381
Review: https://github.com/openclaw/openclaw/pull/87298#issuecomment-4704338581

Co-authored-by: masonxhuang <masonxhuang@tencent.com>
Co-authored-by: Mason Huang <masonxhuang@tencent.com>
Co-authored-by: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com>
Co-authored-by: clawsweeper[bot] <274271284+clawsweeper[bot]@users.noreply.github.com>
Approved-by: hxy91819
Co-authored-by: hxy91819 <8814856+hxy91819@users.noreply.github.com>
2026-06-15 03:51:43 +00:00
Dallin Romney
3d38c9a633 test(qa): embed profile scorecard evidence (#93109)
* test(qa): embed profile scorecard evidence

* test(qa): fix profile runner return lint

* test(qa): satisfy suite command lint return
2026-06-14 20:51:38 -07:00
Ayaan Zaidi
663fabbe30 fix(telegram): render progress drafts as rich previews 2026-06-15 08:52:27 +05:30
Peter Lindsey
c847db550f fix(telegram): keep streamed tool-progress lines on separate lines
Telegram's rich-markdown renderer treats a lone "\n" as a soft break
(rendered as a space), so streamed tool-progress draft lines joined by a
single newline collapsed onto one line. Pass "\n\n" as the progress-draft
line separator for Telegram; it renders a blank line as a single break, so
each tool/thinking/commentary line gets its own line again. Other channels
keep the single-newline default, so Discord and the rest are unaffected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 08:52:27 +05:30
Galin Iliev
50c82b3020 fix(scripts): add database-first legacy store guard
Adds a required database-first legacy-store guard and regression coverage for legacy runtime state write patterns.

The guard is wired into architecture/preflight/changed checks, narrows the documented guard contract to the implemented filesystem-write scope, and tightens extension migration exemptions to explicit owner APIs. Also includes a small memory-core lint unblocker after current CI flagged an unnecessary non-null assertion.

Verification:
- pnpm check:database-first-legacy-stores
- pnpm lint:scripts
- node scripts/run-vitest.mjs test/scripts/check-database-first-legacy-stores.test.ts -- --reporter=verbose
- node scripts/run-oxlint.mjs extensions/memory-core/src/memory/manager-embedding-ops.ts
- git diff --check
- .agents/skills/autoreview/scripts/autoreview --mode branch --base origin/main
- GitHub CI green for PR head 34dde2c620

Closes #91628.
2026-06-14 20:08:06 -07:00
Vincent Koc
dc46a67e80 fix(memory-core): remove redundant provider key assertion 2026-06-15 11:05:07 +08:00
Marcus Castro
7d8b000bf7 fix(whatsapp): bound socket operations (#93094)
* fix(whatsapp): bound socket operations

* test(whatsapp): type monitor fixture config

* fix(whatsapp): align socket timeout semantics

* test(whatsapp): cover socket timeout edge cases

* test(whatsapp): shrink socket timeout coverage

* refactor(whatsapp): simplify socket timeout boundary

* fix(whatsapp): keep send api socket type structural
2026-06-15 00:04:11 -03:00
clawsweeper[bot]
ac1042b09b fix(voice-call): preserve live Twilio streams in stale reaper (#90812)
Summary:
- The PR updates the voice-call plugin to preserve live `speaking`/`listening` calls without `answeredAt`, backfill max-duration enforcement for live/restored call paths, and add regression tests.
- PR surface: Source +90, Tests +223. Total +313 across 9 files.
- Reproducibility: yes. source-level: current main and v2026.6.6 still reap aged non-terminal calls solely bec ... king` or `listening` without setting it. I did not run a live Twilio carrier call in this read-only review.

Automerge notes:
- Ran the ClawSweeper repair loop before final review.
- Included post-review commit in the final squash: fix(voice-call): preserve live Twilio streams in stale reaper
- Included post-review commit in the final squash: fix(clawsweeper): address review for automerge-openclaw-openclaw-9062…

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

Prepared head SHA: 5fee2ff7a1
Review: https://github.com/openclaw/openclaw/pull/90812#issuecomment-4637047870

Co-authored-by: Sahibzada Allahyar <sahibzada@fastino.ai>
Co-authored-by: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com>
Co-authored-by: clawsweeper[bot] <274271284+clawsweeper[bot]@users.noreply.github.com>
Approved-by: takhoffman
Co-authored-by: takhoffman <781889+takhoffman@users.noreply.github.com>
2026-06-15 02:39:29 +00:00
Chunyue Wang
fd80e0dd6b fix(agents): do not misclassify client-disconnect abort as run timeout (#90936)
Summary:
- The PR adds an abort-signal-specific timeout classifier, switches two embedded attempt abort handlers to it, and adds focused failover tests.
- PR surface: Source +5, Tests +32. Total +37 across 3 files.
- Reproducibility: yes. from source inspection and a focused Node abort-reason check, but not from a live 180- ... ault AbortController abort reason through the broad timeout classifier used by the embedded abort handlers.

Automerge notes:
- PR branch already contained follow-up commit before automerge: fix(agents): do not misclassify client-disconnect abort as run timeout

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

Prepared head SHA: 2708b0a37d
Review: https://github.com/openclaw/openclaw/pull/90936#issuecomment-4638919394

Co-authored-by: openperf <16864032@qq.com>
Co-authored-by: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com>
Co-authored-by: clawsweeper[bot] <274271284+clawsweeper[bot]@users.noreply.github.com>
2026-06-15 02:38:47 +00:00
mushuiyu_xydt
44e6caff54 fix(memory): accept local default model path migration (#92954)
* fix(memory): accept local default model path migration

Treat the official local default embedding model's hf URI and downloaded GGUF path identities as equivalent so upgraded local memory indexes do not pause solely on path-format changes.

* fix(memory): satisfy local identity lint

Avoid filtered array tail access in the local model filename helper while preserving the same compatibility behavior.

* fix(memory): preserve local embedding identity aliases

---------

Co-authored-by: Vincent Koc <25068+vincentkoc@users.noreply.github.com>
2026-06-15 09:29:42 +08:00
Dallin Romney
d1eda0bd6f test(reply): preserve telegram dedupe fallback (#93107) 2026-06-14 18:25:40 -07:00
Dallin Romney
cae64ee360 fix(reply): avoid bundled channel load in dedupe (#93104) 2026-06-14 18:09:26 -07:00
Dallin Romney
e8db9c3bc0 test(qa): add qa run --profile and unified output summary/evidence (#91587)
* test(qa): add mapped qa run profiles

* test(qa): document mapped profile runner

* test(qa): validate run profiles from mapping

* test(qa): preserve root profile parsing

* test(qa): simplify taxonomy profile dispatch

* test(qa): align tool coverage CLI expectation

* test(qa): fix profile dispatch fixture type

* test(qa): share profile runner option types

* test(qa): split shared cli runner options

* test(qa): unify profile suite artifacts

* fix(qa): filter profile scenarios by provider lane

* test(qa): drop native scenario subreports

* fix(qa): keep native log refs repo-relative

* fix(cli): preserve qa run root profile parsing

* fix(qa): avoid qa profile flag collision

* fix(qa): reject profile flags without qa profile
2026-06-14 18:08:42 -07:00
Kevin Lin
e82d19fb06 feat(codex): add auto plugin approvals (#92625)
* feat(codex): add on-request plugin approvals

* feat(codex): rename plugin approval policy to auto

* fix(codex): update binding schema version callers
2026-06-14 18:00:38 -07:00
aliahnaf2013-max
870ec6dee2 fix(codex): close one-shot app server clients
Fix gateway-routed one-shot Codex app-server teardown so owned shared clients are retired after run cleanup. Verified with focused tests, Showboat proof, and green PR CI.
2026-06-14 17:55:31 -07:00
Dallin Romney
fef8394079 Convert QA scenarios to YAML files (#92915)
* refactor: load QA scenarios from YAML

* docs: update personal QA scenario docs

* test: keep QA scenarios YAML-only
2026-06-14 17:31:18 -07:00
Vincent Koc
1ca3d4f586 test(reply): default followup enqueue mock to accepted 2026-06-15 08:21:49 +08:00
Yuval Dinodia
02acb0bd74 fix(gateway): accept file-only input on /v1/responses (parity with image-only) (#93011)
* fix(gateway): accept file-only input on /v1/responses (parity with image-only)

* fix(gateway): preserve file-only model context

---------

Co-authored-by: Vincent Koc <25068+vincentkoc@users.noreply.github.com>
2026-06-15 08:01:19 +08:00
zhang-guiping
a1f18ef46e fix #90333: [Bug]: Discord image build aborts at step 66 — openclaw-build-messaging-plugins.py exits 1 (#92869)
* fix(cli): recover versioned Discord plugin installs

* fix(cli): harden plugin install recovery

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-06-15 07:58:22 +08:00
openclaw-clownfish[bot]
9ba6ed1d5c fix(agents): preserve fresh usage after compaction (#93084)
* fix(compaction): preserve fresh usage after compaction

Co-authored-by: HollyChou <128659251+Hollychou924@users.noreply.github.com>

Co-authored-by: 吴杨帆 <39647285+leno23@users.noreply.github.com>

* fix(compaction): satisfy stale usage timestamp narrowing

* fix(clownfish): address review for ghcrawl-156678-autonomous-smoke (1)

Co-authored-by: HollyChou <128659251+Hollychou924@users.noreply.github.com>

Co-authored-by: de1ty <7804799+de1tydev@users.noreply.github.com>

Co-authored-by: 吴杨帆 <39647285+leno23@users.noreply.github.com>

Co-authored-by: Zhao Shiqi <109639815+425072024@users.noreply.github.com>

---------

Co-authored-by: openclaw-clownfish[bot] <280122609+openclaw-clownfish[bot]@users.noreply.github.com>
Co-authored-by: 吴杨帆 <39647285+leno23@users.noreply.github.com>
Co-authored-by: Vincent Koc <25068+vincentkoc@users.noreply.github.com>
Co-authored-by: Zhao Shiqi <109639815+425072024@users.noreply.github.com>
2026-06-15 07:33:45 +08:00
Josh Lehman
f1b8827d20 refactor: route bundled plugin session callers through seam (#89129)
Merged via squash.

Prepared head SHA: 7975bc06ac
Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com>
Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com>
Reviewed-by: @jalehman
2026-06-14 12:26:53 -07:00
kumaxs
356385045f fix(opencode-go): warm context metadata from provider catalog (#92913)
Register OpenCode Go's provider-owned static catalog so lifecycle cache warmup supplies the correct context window to memory flush and compaction without persisting catalog rows in user config.

Fixes #92912.

Co-authored-by: kumaxs <45620232+kumaxs@users.noreply.github.com>
2026-06-14 12:08:49 -07:00
Sam
a189baa4b3 fix(agents): tolerate missing attribution baseUrl (#92991)
Avoid assuming every runtime model exposes a string `baseUrl` before provider attribution checks. Preserve OpenRouter and Cloudflare attribution behavior while allowing Bedrock session setup to reach provider routing.

Fixes #92974.

Co-authored-by: Sami Rusani <sr@samirusani>
2026-06-14 11:43:10 -07:00
zengLingbiao
07dfdd4bd0 fix(agents): prevent duplicate before-tool-call hooks (#93009)
Prevent duplicate `before_tool_call` execution when an already wrapped tool passes through schema normalization and coding-tool assembly. Preserve the normalized schema while replacing stale wrapper context with the current agent/session/run context.

Fixes #92973.

Co-authored-by: zengLingbiao <zeng.lingbiao@xydigit.com>
2026-06-14 11:25:43 -07:00
Shakker
96f786d4b1 fix: route oauth fallback env setup 2026-06-14 19:21:40 +01:00
Shakker
0100c27bc0 test: route oauth helper env setup 2026-06-14 19:19:04 +01:00
Shakker
a8ca9619a5 fix: route oauth lock env setup 2026-06-14 19:17:22 +01:00
Shakker
3a8d422000 test: scope tilde home env overrides 2026-06-14 19:15:19 +01:00
Shakker
fc3e00cc11 fix: route acp harness env restore 2026-06-14 19:13:12 +01:00
Shakker
a1b7f3570c test: centralize session file env restore 2026-06-14 19:11:31 +01:00
Shakker
b8ed2c3280 fix: restore acp task env scope 2026-06-14 19:06:25 +01:00
Shakker
8bc728307d test: route wsl env mutations 2026-06-14 19:03:47 +01:00
OfflynAI
81924cfd5e fix(ollama): preserve length-limited responses (#89160)
Preserve Ollama length termination and surface incomplete token-limited embedded-agent turns without discarding durable tool output.

Fixes #89051.

Co-authored-by: joelnishanth <140015627+joelnishanth@users.noreply.github.com>
2026-06-14 10:59:37 -07:00
openclaw-clownfish[bot]
6134c00657 fix(telegram): cool down transient sendChatAction failures (#93020)
* fix(telegram): cool down transient sendChatAction failures

Co-authored-by: Langning Zhang <145515129+Boulea7@users.noreply.github.com>

Co-authored-by: Sumaia Zaman <45918347+sumaiazaman@users.noreply.github.com>

Co-authored-by: pick-cat <266665499+Pick-cat@users.noreply.github.com>

* fix(clownfish): address review for ghcrawl-156876-autonomous-smoke (1)

Co-authored-by: Langning Zhang <145515129+Boulea7@users.noreply.github.com>

Co-authored-by: Sumaia Zaman <45918347+sumaiazaman@users.noreply.github.com>

Co-authored-by: pick-cat <266665499+Pick-cat@users.noreply.github.com>

---------

Co-authored-by: openclaw-clownfish[bot] <280122609+openclaw-clownfish[bot]@users.noreply.github.com>
Co-authored-by: pick-cat <266665499+Pick-cat@users.noreply.github.com>
2026-06-15 01:07:10 +08:00
openclaw-clownfish[bot]
4c2fef4a3b fix(gateway): repair usage cost aggregation across agents (#93022)
* fix(gateway): aggregate usage cost across agents

* fix(clownfish): address review for ghcrawl-157040-autonomous-smoke (1)

Co-authored-by: luke-skywalker-open-claw <262978557+luke-skywalker-open-claw@users.noreply.github.com>

Co-authored-by: Stable Genius <259448942+stablegenius49@users.noreply.github.com>

---------

Co-authored-by: openclaw-clownfish[bot] <280122609+openclaw-clownfish[bot]@users.noreply.github.com>
Co-authored-by: Stable Genius <259448942+stablegenius49@users.noreply.github.com>
2026-06-15 01:06:36 +08:00
Vincent Koc
b470316fc0 fix(state): harden sqlite path caching
Resolve explicit relative SQLite DB paths before caching handles and centralize durable SQLite connection pragmas so busy_timeout is applied before WAL/NFS negotiation.
2026-06-15 01:04:35 +08:00
Sally O'Malley
7e12a3326d feat(openrouter): surface Fusion panel config (#93005)
Signed-off-by: sallyom <somalley@redhat.com>
2026-06-14 12:44:49 -04:00
ZengWen-DT
a42bda5b37 fix(memory): clean stale reindex temp files (#92891)
* fix(memory): clean stale reindex temp files

* fix(memory): harden stale reindex cleanup

* fix(memory): serialize safe reindex cleanup

* fix(memory): satisfy reindex lock lint

---------

Co-authored-by: zengwen <zeng_wen@foxmail.com>
Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-06-15 00:40:43 +08:00
NVIDIAN
ecaebfc51b fix(agents): retry thinking-only errored turns (#92191)
Retry replay-safe reasoning-only provider errors before assistant failover while preserving classified fallback and terminal-output ownership. Adds deterministic Anthropic gateway fault-injection coverage and focused regression tests.\n\nCo-authored-by: ai-hpc <mail.speedy.hpc@hotmail.com>
2026-06-14 09:39:27 -07:00
Ciward
364461949d fix: refresh slash command routing config (#39617)
Use the active runtime snapshot for Discord and Slack native command routing and Discord autocomplete after config hot writes.

Fixes #39605

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-06-14 09:32:18 -07:00
Vincent Koc
10b0dea77a test(gateway): retry chat temp cleanup 2026-06-15 00:28:58 +08:00
943 changed files with 59117 additions and 40961 deletions

View File

@@ -13,7 +13,7 @@ Use this skill for `qa-lab` / `qa-channel` work. Repo-local QA only.
- `docs/help/testing.md`
- `docs/channels/qa-channel.md`
- `qa/README.md`
- `qa/scenarios/index.md`
- `qa/scenarios/index.yaml`
- `extensions/qa-lab/src/suite.ts`
- `extensions/qa-lab/src/character-eval.ts`
@@ -198,7 +198,9 @@ pnpm openclaw qa character-eval \
- Judges default to `openai/gpt-5.4,thinking=xhigh,fast` and `anthropic/claude-opus-4-6,thinking=high`.
- Report includes judge ranking, run stats, durations, and full transcripts; do not include raw judge replies. Duration is benchmark context, not a grading signal.
- Candidate and judge concurrency default to 16. Use `--concurrency <n>` and `--judge-concurrency <n>` to override when local gateways or provider limits need a gentler lane.
- Scenario source should stay markdown-driven under `qa/scenarios/`.
- Scenario source is YAML-only under `qa/scenarios/`: use `index.yaml` and
per-scenario `*.yaml` files with top-level `title`, `scenario`, and optional
`flow`. Never add fenced `qa-scenario` / `qa-flow` Markdown files.
- For isolated character/persona evals, write the persona into `SOUL.md` and blank `IDENTITY.md` in the scenario flow. Use `SOUL.md + IDENTITY.md` only when intentionally testing how the normal OpenClaw identity combines with the character.
- Keep prompts natural and task-shaped. The candidate model should receive character setup through `SOUL.md`, then normal user turns such as chat, workspace help, and small file tasks; do not ask "how would you react?" or tell the model it is in an eval.
- Prefer at least one real task, such as creating or editing a tiny workspace artifact, so the transcript captures character under normal tool use instead of pure roleplay.
@@ -234,7 +236,8 @@ pnpm openclaw qa manual \
## Repo facts
- Seed scenarios live in `qa/`.
- Seed scenarios live in `qa/scenarios/index.yaml` and
`qa/scenarios/<theme>/*.yaml`.
- Main live runner: `extensions/qa-lab/src/suite.ts`
- QA lab server: `extensions/qa-lab/src/lab-server.ts`
- Child gateway harness: `extensions/qa-lab/src/gateway-child.ts`
@@ -262,8 +265,9 @@ pnpm openclaw qa manual \
## When adding scenarios
- Add or update scenario markdown under `qa/scenarios/`
- Keep kickoff expectations in `qa/scenarios/index.md` aligned
- Add or update scenario YAML under `qa/scenarios/`; do not add `.md` scenario
files or fenced YAML blocks.
- Keep kickoff expectations in `qa/scenarios/index.yaml` aligned
- Add executable coverage in `extensions/qa-lab/src/suite.ts`
- Prefer end-to-end assertions over mock-only checks
- Save outputs under `.artifacts/qa-e2e/`

View File

@@ -321,6 +321,7 @@ Upgrade with the beta channel.
Before tagging or publishing, run:
```bash
pnpm release:fast-pretag-check
pnpm check:architecture
pnpm build
pnpm ui:build
@@ -329,6 +330,21 @@ pnpm release:check
pnpm test:install:smoke
```
- Treat `pnpm release:fast-pretag-check` as a hard packaging gate. Every
publishable plugin must have a non-empty package-root `README.md`, build its
package-local runtime, and pass the npm and ClawHub release metadata checks
before a tag or publish workflow can start. Do not defer README, entrypoint,
or packed-artifact failures to postpublish verification.
- Before tagging, require green CI for the exact release-candidate SHA, not an
earlier branch SHA. Heal every related red CI, release-check, packaging, or
root-Dockerfile lane on the release branch, forward-port the fix to `main`,
and rerun the affected exact-SHA gates. Never waive a red Docker lane because
npm preflight passed.
- Root Dockerfile proof is mandatory before every beta and stable tag. Run the
release `install-smoke` group or equivalent root Dockerfile build for the
exact candidate SHA and require it to pass. The tag-triggered Docker Release
workflow is post-tag publishing, not the first valid proof that the root
Dockerfile can build.
- Before tagging, diff publishable plugin package manifests against the last
reachable stable/beta release tag. For every newly publishable package
(`openclaw.release.publishToNpm: true` or `publishToClawHub: true`) whose
@@ -644,9 +660,10 @@ node --import tsx scripts/openclaw-npm-postpublish-verify.ts <published-version>
off, live OpenAI off, and regression failure off. Let it run in parallel
with preflight and validation work.
10. Run the fast local beta preflight from the release branch before any npm
preflight or publish. Keep expensive Docker, Parallels, and published-package
install/update lanes for after the beta is live unless the operator asks to
run them before beta publication.
preflight or publish. Require exact-SHA CI and root Dockerfile install-smoke
to be green before tagging. Keep the remaining expensive Docker, Parallels,
and published-package install/update lanes for after the beta is live unless
the operator asks to run them before beta publication.
11. For beta releases, skip mac app build/sign/notarize unless beta scope or a
release blocker specifically requires it. For stable releases, include the
mac app, signing, notarization, and appcast path.

View File

@@ -1288,6 +1288,7 @@ jobs:
env:
OPENCLAW_LOCAL_CHECK: "0"
TASK: ${{ matrix.task }}
PR_BASE_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || '' }}
shell: bash
run: |
set -euo pipefail
@@ -1297,6 +1298,10 @@ jobs:
pnpm tool-display:check
pnpm check:host-env-policy:swift
pnpm dup:check:coverage
if [ -n "$PR_BASE_SHA" ]; then
git fetch --no-tags --depth=1 origin "+${PR_BASE_SHA}:refs/remotes/origin/pr-base"
node scripts/report-test-temp-creations.mjs --base refs/remotes/origin/pr-base --head HEAD --no-merge-base
fi
pnpm deps:patches:check
pnpm lint:webhook:no-low-level-body-read
pnpm lint:auth:no-pairing-store-group

View File

@@ -661,6 +661,10 @@ jobs:
- name: Verify full release validation target
if: ${{ inputs.full_release_validation_run_id != '' }}
env:
RELEASE_TAG: ${{ inputs.tag }}
RELEASE_NPM_DIST_TAG: ${{ inputs.npm_dist_tag }}
WORKFLOW_REF_NAME: ${{ github.ref_name }}
run: |
set -euo pipefail
EXPECTED_RELEASE_SHA="$(git rev-parse HEAD)"
@@ -681,7 +685,11 @@ jobs:
echo "Full release validation target SHA mismatch: expected $EXPECTED_RELEASE_SHA, got $TARGET_SHA" >&2
exit 1
fi
if [[ "$RERUN_GROUP" != "all" ]]; then
tideclaw_alpha_focused_validation=false
if [[ "$RERUN_GROUP" == "install-smoke" && "$RELEASE_TAG" == *"-alpha."* && "$RELEASE_NPM_DIST_TAG" == "alpha" && "$WORKFLOW_REF_NAME" =~ ^tideclaw/alpha/[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{4}Z$ ]]; then
tideclaw_alpha_focused_validation=true
fi
if [[ "$RERUN_GROUP" != "all" && "$tideclaw_alpha_focused_validation" != "true" ]]; then
echo "Full release validation must run rerun_group=all before npm publish; got $RERUN_GROUP" >&2
exit 1
fi

View File

@@ -362,6 +362,8 @@ jobs:
EXPECTED_SHA: ${{ steps.ref.outputs.sha }}
EXPECTED_RELEASE_PROFILE: ${{ inputs.release_profile }}
EXPECTED_WORKFLOW_BRANCH: ${{ github.ref_name }}
RELEASE_TAG: ${{ inputs.tag }}
RELEASE_NPM_DIST_TAG: ${{ inputs.npm_dist_tag }}
run: |
set -euo pipefail
RUN_JSON="$(gh run view "$FULL_RELEASE_VALIDATION_RUN_ID" --repo "$GITHUB_REPOSITORY" --json workflowName,headBranch,event,status,conclusion,url)"
@@ -389,7 +391,11 @@ jobs:
echo "Full release validation profile mismatch: expected $EXPECTED_RELEASE_PROFILE, got $release_profile" >&2
exit 1
fi
if [[ "$rerun_group" != "all" ]]; then
tideclaw_alpha_focused_validation=false
if [[ "$rerun_group" == "install-smoke" && "$RELEASE_TAG" == *"-alpha."* && "$RELEASE_NPM_DIST_TAG" == "alpha" && "$EXPECTED_WORKFLOW_BRANCH" =~ ^tideclaw/alpha/[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{4}Z$ ]]; then
tideclaw_alpha_focused_validation=true
fi
if [[ "$rerun_group" != "all" && "$tideclaw_alpha_focused_validation" != "true" ]]; then
echo "Full release validation must run rerun_group=all before npm publish; got $rerun_group" >&2
exit 1
fi

View File

@@ -214,6 +214,7 @@ Skills own workflows; root owns hard policy and routing.
- Vitest. Colocated `*.test.ts`; e2e `*.e2e.test.ts`; example models `sonnet-4.6`, `gpt-5.5`; test GPT with 5.5 preferred, 5.4 ok; no GPT-4.x agent-smoke defaults.
- Prefer behavior tests over workflow/docs string greps. Put operator policy reminders in AGENTS/docs.
- QA scenario sources are YAML only: `qa/scenarios/index.yaml` and `qa/scenarios/<theme>/*.yaml`. Do not add fenced `qa-scenario`/`qa-flow` Markdown files under `qa/scenarios/`.
- Clean timers/env/globals/mocks/sockets/temp dirs/module state; `--isolate=false` safe.
- Prefer injection and narrow `*.runtime.ts` mocks over broad barrels or `openclaw/plugin-sdk/*`.
- Do not edit baseline/inventory/ignore/snapshot/expected-failure files to silence checks without explicit approval.

View File

@@ -2,6 +2,12 @@
Docs: https://docs.openclaw.ai
## 2026.6.15-alpha.1
### Fixes
- Alpha/nightly release validation carries focused alpha publish proof support plus release-branch test stabilization for provider auth references, channel session fixtures, ACP spawn fixtures, Slack media history, Telegram approvals, iMessage monitor coverage, memory CLI coverage, Codex startup cleanup, MCP channel package protocol loading, QA Lab runtime parity, and generated release metadata.
## 2026.6.8
### Highlights
@@ -25,7 +31,7 @@ Docs: https://docs.openclaw.ai
- Channels and delivery: preserve account-scoped DM channel send policy, rich Telegram final replies, rich Telegram tables and lists, Telegram thread-create CLI remapping, Slack outbound `message_sent` hooks, contributed message-tool schema optionality, same-channel generated media completions, and channel chunking around surrogate pairs and Infinity limits. (#92788, #92679, #89421, #89943, #91137, #91246, #92735) Thanks @yetval, @obviyus, @spacegeologist, @rishitamrakar, @lundog, @TurboTheTurtle, and @yhterrance.
- Discord: give generated auto-thread titles a 60-second timeout and 4,096-token reasoning-model output budget, clamped to the selected model output cap. (#64734) Thanks @hanamizuki.
- Agent, cron, and Gateway runtime: mark active main sessions before restart shutdown aborts, pause yielded subagent runs whose terminal also signals abort, preserve yielded media completions, de-duplicate main-session heartbeat events, expose session identity in runtime prompts, reject unknown OpenAI agent selectors, keep generated media completions and slash-command block replies in WebChat, and require admin privileges for HTTP session/model override surfaces. (#91357, #92631, #92146, #91287, #92468, #92510, #91246, #92651, #92646) Thanks @ooiuuii, @openperf, @IWhatsskill, @ZengWen-DT, @zhangguiping-xydt, and @TurboTheTurtle.
- Agent, cron, and Gateway runtime: mark active main sessions before restart shutdown aborts, pause yielded subagent runs whose terminal also signals abort, preserve yielded media completions, de-duplicate main-session heartbeat events, expose session identity in runtime prompts, reject unknown OpenAI agent selectors, keep generated media completions and slash-command block replies in WebChat, preserve fresh post-compaction usage while clearing stale usage snapshots, and require admin privileges for HTTP session/model override surfaces. (#91357, #92631, #92146, #91287, #92468, #92510, #91246, #50795, #50845, #82874, #92651, #92646) Thanks @ooiuuii, @openperf, @IWhatsskill, @ZengWen-DT, @zhangguiping-xydt, @Hollychou924, @leno23, and @TurboTheTurtle.
- Providers and model replay: preserve storeless OpenAI Responses replay compatibility, avoid eager tool streaming for Claude 4.5 in Copilot, honor profile auth for SecretRef model entries, bound model browsing, strip provider prefixes where runtimes need bare IDs, and surface nested embedding fetch failures. (#90706, #75393, #90686, #92247, #92627, #91218, #92628) Thanks @snowzlm, @Kailigithub, @rohitjavvadi, @samson910022, @liuhao1024, @bymle, and @mushuiyu886.
- Memory, state, diagnostics, and config: split header-too-large embedding batches, keep QMD memory search enabled in transient mode, avoid SQLite WAL on NFS volumes, preserve recovery scheduling outside stuck-session warning backoff, and keep shell environment fallbacks contained in config write tests. (#92650, #92618, #92639, #91247, #92752) Thanks @mushuiyu886, @TurboTheTurtle, @849261680, and @gnanam1990.
- UI/mobile/TUI: preserve dashboard session parent lineage, WebChat backscroll, reset soft command args, sidebar session picker interactivity, collapsed workspace files, resolved `/model` confirmation refs, and stale foreground iOS Gateway reconnects. (#90658, #92622, #91353, #92705, #92779, #92773, #92552) Thanks @luoyanglang, @TurboTheTurtle, @zhouhe-xydt, @NianJiuZst, @shakkernerd, @NarahariRaghava, and @Solvely-Colin.

View File

@@ -138,7 +138,7 @@ ARG OPENCLAW_BUNDLED_PLUGIN_DIR
# BuildKit cache mounts are not part of cached layers; seed tarballs for the
# installed prod graph in the same step that runs offline prune.
RUN --mount=type=cache,id=openclaw-pnpm-store,target=/root/.local/share/pnpm/store,sharing=locked \
pnpm list --prod --depth Infinity --json | node scripts/list-prod-store-packages.mjs | xargs -r pnpm store add && \
node scripts/list-prod-store-packages.mjs | xargs -r pnpm store add && \
CI=true pnpm prune --prod \
--config.offline=true \
--config.supportedArchitectures.os=linux \

View File

@@ -46,6 +46,17 @@ public enum NodePresenceAliveReason: String, Codable, Sendable {
case connect = "connect"
}
public enum SessionFileKind: String, Codable, Sendable {
case modified = "modified"
case read = "read"
}
public enum SessionFileRelevance: String, Codable, Sendable {
case modified = "modified"
case read = "read"
case mixed = "mixed"
}
public struct ConnectParams: Codable, Sendable {
public let minprotocol: Int
public let maxprotocol: Int
@@ -1756,6 +1767,7 @@ public struct SessionsResolveParams: Codable, Sendable {
public let spawnedby: String?
public let includeglobal: Bool?
public let includeunknown: Bool?
public let allowmissing: Bool?
public init(
key: String?,
@@ -1764,7 +1776,8 @@ public struct SessionsResolveParams: Codable, Sendable {
agentid: String? = nil,
spawnedby: String?,
includeglobal: Bool?,
includeunknown: Bool?)
includeunknown: Bool?,
allowmissing: Bool? = nil)
{
self.key = key
self.sessionid = sessionid
@@ -1773,6 +1786,7 @@ public struct SessionsResolveParams: Codable, Sendable {
self.spawnedby = spawnedby
self.includeglobal = includeglobal
self.includeunknown = includeunknown
self.allowmissing = allowmissing
}
private enum CodingKeys: String, CodingKey {
@@ -1783,6 +1797,7 @@ public struct SessionsResolveParams: Codable, Sendable {
case spawnedby = "spawnedBy"
case includeglobal = "includeGlobal"
case includeunknown = "includeUnknown"
case allowmissing = "allowMissing"
}
}

View File

@@ -1,4 +1,4 @@
0485ba902d2afd89d2c41cde7180d0cec2900b2db6804b9f97d42b7d85cd3af5 config-baseline.json
72bb80be618406f3337eaa2560d2559a35e49bd29576de8dd4a3aec1a6a94d92 config-baseline.core.json
5e4b11e8c28bf60189aaab01684421269ee0d176872c615d727403fb538468a8 config-baseline.json
968bb18da42e90c6910fd7e63a95a77d9c6a32cb1ae5ad745c7cf616d29bf0ae config-baseline.core.json
1218f5555541b61bd5ddcac6441f15061b44789e2471d4ffecbe3059777c55c1 config-baseline.channel.json
a14ac4261e98403d1a7e047070e6f151938444e27382b860315bd0c74fda4861 config-baseline.plugin.json
a973af69b02a27b097b54e49886dd57dbebbc95e2ab29b0c7e222a9f35a105d8 config-baseline.plugin.json

View File

@@ -164,7 +164,7 @@ handoff path over manual terminal capture.
- Gateway owns the WhatsApp socket and reconnect loop.
- The reconnect watchdog uses WhatsApp Web transport activity, not only inbound app-message volume, so a quiet linked-device session is not restarted solely because nobody has sent a message recently. A longer application-silence cap still forces a reconnect if transport frames keep arriving but no application messages are handled for the watchdog window; after a transient reconnect for a recently active session, that application-silence check uses the normal message timeout for the first recovery window.
- Baileys socket timings are explicit under `web.whatsapp.*`: `keepAliveIntervalMs` controls WhatsApp Web application pings, `connectTimeoutMs` controls the opening handshake timeout, and `defaultQueryTimeoutMs` controls Baileys query timeouts.
- Baileys socket timings are explicit under `web.whatsapp.*`: `keepAliveIntervalMs` controls WhatsApp Web application pings, `connectTimeoutMs` controls the opening handshake timeout, and `defaultQueryTimeoutMs` controls Baileys query waits plus OpenClaw's local outbound send/presence operation bound.
- Outbound sends require an active WhatsApp listener for the target account.
- Group sends attach native mention metadata for `@+<digits>` and `@<digits>` tokens in text and media captions when the token matches current WhatsApp participant metadata, including LID-backed groups.
- Status and broadcast chats are ignored (`@status`, `@broadcast`).

View File

@@ -11,7 +11,7 @@ The Personal Agent Benchmark Pack is a small repo-backed QA scenario pack for
local personal assistant workflows. It is not a generic model benchmark and it
does not require a new runner. The pack reuses the private QA stack described in
[QA overview](/concepts/qa-e2e-automation), the synthetic
[QA channel](/channels/qa-channel), and the existing `qa/scenarios` markdown
[QA channel](/channels/qa-channel), and the existing `qa/scenarios` YAML
catalog.
The first pack is intentionally narrow:
@@ -61,9 +61,9 @@ to inspect and file in issues.
## Extending The Pack
Add new cases under `qa/scenarios/personal/`, then add the scenario id to
`QA_PERSONAL_AGENT_SCENARIO_IDS`. Keep each case small, local, deterministic in
`mock-openai`, and focused on one personal assistant behavior.
Add new `.yaml` cases under `qa/scenarios/personal/`, then add the scenario id
to `QA_PERSONAL_AGENT_SCENARIO_IDS`. Keep each case small, local, deterministic
in `mock-openai`, and focused on one personal assistant behavior.
Good follow-up candidates:

View File

@@ -31,9 +31,9 @@ script aliases; both forms are supported.
| Command | Purpose |
| --------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `qa run` | Bundled QA self-check; writes a Markdown report. |
| `qa run` | Bundled QA self-check without `--qa-profile`; taxonomy-backed maturity profile runner with `--qa-profile smoke-ci` or `--qa-profile release`. |
| `qa suite` | Run repo-backed scenarios against the QA gateway lane. Aliases: `pnpm openclaw qa suite --runner multipass` for a disposable Linux VM. |
| `qa coverage` | Print the markdown scenario-coverage inventory (`--json` for machine output). |
| `qa coverage` | Print the YAML scenario-coverage inventory (`--json` for machine output). |
| `qa parity-report` | Compare two `qa-suite-summary.json` files and write the agentic parity report, or use `--runtime-axis --token-efficiency` to write Codex-vs-OpenClaw runtime parity and token-efficiency reports from one runtime-pair summary. |
| `qa character-eval` | Run the character QA scenario across multiple live models with a judged report. See [Reporting](#reporting). |
| `qa manual` | Run a one-off prompt against the selected provider/model lane. |
@@ -51,6 +51,30 @@ script aliases; both forms are supported.
| `qa whatsapp` | Live transport lane against real WhatsApp Web accounts. |
| `qa mantis` | Before and after verification runner for live transport bugs, with Discord status-reactions evidence, Crabbox desktop/browser smoke, and Slack-in-VNC smoke. See [Mantis](/concepts/mantis) and [Mantis Slack Desktop Runbook](/concepts/mantis-slack-desktop-runbook). |
Profile-backed `qa run` reads membership from `taxonomy.yaml`, then dispatches
the resolved scenarios through `qa suite`. `--surface` and
`--category` filter the selected profile instead of defining separate lanes.
The resulting `qa-evidence.json` includes a profile scorecard summary with
selected-category counts and missing coverage IDs; the individual evidence
entries remain the source of truth for the tests, coverage roles, artifacts,
and results:
```bash
pnpm openclaw qa run \
--qa-profile smoke-ci \
--category agent-runtime-and-provider-execution.agent-turn-execution \
--provider-mode mock-openai \
--output-dir .artifacts/qa-e2e/smoke-ci-profile-dispatch
```
Use `smoke-ci` for deterministic no-live-service proof and `release` for the
Stable/LTS proof lane. When a command also needs an OpenClaw root profile, put
the root profile before the QA command:
```bash
pnpm openclaw --profile work qa run --qa-profile smoke-ci
```
## Operator flow
The current QA operator flow is a two-pane QA site:
@@ -769,25 +793,26 @@ Operational env vars and the Convex broker endpoint contract live in [Testing
Seed assets live in `qa/`:
- `qa/scenarios/index.md`
- `qa/scenarios/<theme>/*.md`
- `qa/scenarios/index.yaml`
- `qa/scenarios/<theme>/*.yaml`
These are intentionally in git so the QA plan is visible to both humans and the
agent.
`qa-lab` should stay a generic markdown runner. Each scenario markdown file is
`qa-lab` should stay a generic YAML scenario runner. Each scenario YAML file is
the source of truth for one test run and should define:
- scenario metadata
- optional category, capability, lane, and risk metadata
- docs and code refs
- optional plugin requirements
- optional gateway config patch
- an executable `qa-flow` block for flow scenarios, or `execution.kind`/`execution.path`
for Vitest and Playwright scenarios
- top-level `title`
- `scenario` metadata
- optional category, capability, lane, and risk metadata in `scenario`
- docs and code refs in `scenario`
- optional plugin requirements in `scenario`
- optional gateway config patch in `scenario`
- executable top-level `flow` for flow scenarios, or `scenario.execution.kind` /
`scenario.execution.path` for Vitest and Playwright scenarios
The reusable runtime surface that backs `qa-flow` blocks is allowed to stay generic
and cross-cutting. For example, markdown scenarios can combine transport-side
The reusable runtime surface that backs `flow` is allowed to stay generic
and cross-cutting. For example, YAML scenarios can combine transport-side
helpers with browser-side helpers that drive the embedded Control UI through the
Gateway `browser.request` seam without adding a special-case runner.
@@ -825,17 +850,17 @@ provider names.
## Transport adapters
`qa-lab` owns a generic transport seam for markdown QA scenarios. `qa-channel` is the first adapter on that seam, but the design target is wider: future real or synthetic channels should plug into the same suite runner instead of adding a transport-specific QA runner.
`qa-lab` owns a generic transport seam for YAML QA scenarios. `qa-channel` is the first adapter on that seam, but the design target is wider: future real or synthetic channels should plug into the same suite runner instead of adding a transport-specific QA runner.
At the architecture level, the split is:
- `qa-lab` owns generic scenario execution, worker concurrency, artifact writing, and reporting.
- The transport adapter owns gateway config, readiness, inbound and outbound observation, transport actions, and normalized transport state.
- Markdown scenario files under `qa/scenarios/` define the test run; `qa-lab` provides the reusable runtime surface that executes them.
- YAML scenario files under `qa/scenarios/` define the test run; `qa-lab` provides the reusable runtime surface that executes them.
### Adding a channel
Adding a channel to the markdown QA system requires exactly two things:
Adding a channel to the YAML QA system requires exactly two things:
1. A transport adapter for the channel.
2. A scenario pack that exercises the channel contract.
@@ -869,7 +894,7 @@ The minimum adoption bar for a new channel:
2. Implement the transport runner on the shared `qa-lab` host seam.
3. Keep transport-specific mechanics inside the runner plugin or channel harness.
4. Mount the runner as `openclaw qa <runner>` instead of registering a competing root command. Runner plugins should declare `qaRunners` in `openclaw.plugin.json` and export a matching `qaRunnerCliRegistrations` array from `runtime-api.ts`. Keep `runtime-api.ts` light; lazy CLI and runner execution should stay behind separate entrypoints.
5. Author or adapt markdown scenarios under the themed `qa/scenarios/` directories.
5. Author or adapt YAML scenarios under the themed `qa/scenarios/` directories.
6. Use the generic scenario helpers for new scenarios.
7. Keep existing compatibility aliases working unless the repo is doing an intentional migration.
@@ -912,7 +937,13 @@ The report should answer:
For the inventory of available scenarios - useful when sizing follow-up work or wiring a new transport - run `pnpm openclaw qa coverage` (add `--json` for machine-readable output).
When choosing focused proof for a touched behavior or file path, run `pnpm openclaw qa coverage --match <query>`.
The match report searches scenario metadata, docs refs, code refs, coverage IDs, plugins, and provider requirements, then prints matching `qa suite --scenario ...` targets.
Every `qa suite` scenario execution writes a `qa-evidence.json` artifact. Flow scenarios also write `qa-suite-summary.json` for existing suite/report tooling; scenarios that declare `execution.kind: vitest` or `execution.kind: playwright` run the matching test path and write `qa-vitest-report.md` or `qa-playwright-report.md` plus per-scenario logs.
Every `qa suite` run writes top-level `qa-evidence.json`,
`qa-suite-summary.json`, and `qa-suite-report.md` artifacts for the selected
scenario set. Scenarios that declare `execution.kind: vitest` or
`execution.kind: playwright` run the matching test path and also write
per-scenario logs. When `qa suite` is reached through
`qa run --qa-profile`, the same `qa-evidence.json` also includes the profile
scorecard summary for the selected taxonomy categories.
Treat it as a discovery aid, not a gate replacement; the selected scenario still needs the right provider mode, live transport, Multipass, Testbox, or release lane for the behavior under test.
For character and style checks, run the same scenario across multiple live model

View File

@@ -42,6 +42,45 @@ When you touch tests or want extra confidence:
- Coverage gate: `pnpm test:coverage`
- E2E suite: `pnpm test:e2e`
## Test Temp Directories
Prefer the shared helpers in `test/helpers/temp-dir.ts` for test-owned
temporary directories. They make ownership explicit and keep cleanup in the same
test lifecycle:
```ts
import { afterEach } from "vitest";
import { createTempDirTracker } from "../helpers/temp-dir.js";
const tempDirs = createTempDirTracker();
afterEach(tempDirs.cleanup);
it("uses a temp workspace", () => {
const workspace = tempDirs.make("openclaw-example-");
// use workspace
});
```
Use `makeTempDir(tempDirs, prefix)` and `cleanupTempDirs(tempDirs)` when a test
already owns an array or set of paths. Avoid new bare `fs.mkdtemp*` calls in
tests unless a case is explicitly verifying raw temp-dir behavior. Add an
auditable allow comment with a concrete reason when a test intentionally needs a
bare temp directory:
```ts
// openclaw-temp-dir: allow verifies raw fs cleanup behavior
const workspace = fs.mkdtempSync(prefix);
```
For migration visibility, `node scripts/report-test-temp-creations.mjs` reports
new bare temp-dir creation in added diff lines without blocking existing cleanup
styles. Its file scope intentionally follows the same test-path classification
used by `scripts/changed-lanes.mjs` instead of maintaining a separate test-helper
filename heuristic, while skipping the shared helper implementation itself.
`check:changed` runs this report for changed test paths as a warning-only CI
signal; findings are GitHub warning annotations, not failures.
When debugging real providers/models (requires real creds):
- Live suite (models + gateway tool/image probes): `pnpm test:live`
@@ -145,6 +184,11 @@ inside every shard.
- `pnpm openclaw qa suite`
- Runs repo-backed QA scenarios directly on the host.
- Writes top-level `qa-evidence.json`, `qa-suite-summary.json`, and
`qa-suite-report.md` artifacts for the selected scenario set, including
mixed flow, Vitest, and Playwright scenario selections.
- When dispatched by `pnpm openclaw qa run --qa-profile <profile>`, embeds the
selected taxonomy profile scorecard in the same `qa-evidence.json`.
- Runs multiple selected scenarios in parallel by default with isolated
gateway workers. `qa-channel` defaults to concurrency 4 (bounded by the
selected scenario count). Use `--concurrency <count>` to tune the worker

View File

@@ -143,39 +143,12 @@ The native Codex app-server harness supports context engines that require
pre-prompt assembly. Generic CLI backends, including `codex-cli`, do not provide
that host capability.
Codex thread bindings live in OpenClaw's SQLite plugin state and use the stable
agent-scoped OpenClaw session key, or an opaque conversation-binding id, as
their owner. Physical session ids fence delayed cleanup but may rotate without
losing the Codex thread. Context-engine compaction adopts the successor id
before continuing native Codex compaction. The bounded store rejects a new
binding at its safety limit instead of evicting an existing thread's continuity
record.
Conversation binds create or resume their Codex thread on the first bound
message after channel approval; an abandoned approval consumes no thread row.
That first message carries the prepared thread directly into its turn.
Subsequent messages use a metadata-only resume to subscribe the shared client,
then unsubscribe after the turn completes.
The runtime does not poll transcript-adjacent binding files. Upgrades from
releases that used `*.jsonl.codex-app-server.json` sidecars migrate them during
normal startup preflight. `openclaw doctor --fix` can run the same migration
manually.
Successfully matched sidecars are archived before the new runtime resumes their
threads. Migration imports durable thread ownership only; it does not infer
Codex context usage from OpenClaw counters or crawl Codex rollout files. For
agent-session harness bindings, the next resume attempts to restore a cached
native snapshot when Codex has one, and ongoing turns persist the current-context
usage reported by app-server notifications, not the cumulative thread lifetime
total. Conversation bindings
keep metadata-only resumes and leave continuity and compaction with the native
Codex thread. Conflicting or ambiguous sidecars stay in place with a warning for
operator review.
For Codex-backed agents, `/compact` starts native Codex app-server compaction on
the bound thread. OpenClaw bounds the request-acceptance RPC but does not wait
for compaction completion, restart the shared app-server, or fall back to a
context-engine or public OpenAI summarizer. If the native Codex thread binding
is missing or stale, the command fails closed so the operator sees the real
runtime boundary instead of silently switching compaction backends.
the bound thread. OpenClaw does not wait for completion, impose an OpenClaw
timeout, restart the shared app-server, or fall back to a context-engine or
public OpenAI summarizer. If the native Codex thread binding is missing or
stale, the command fails closed so the operator sees the real runtime boundary
instead of silently switching compaction backends.
```json5
{

View File

@@ -200,11 +200,12 @@ enabled.
OpenClaw sets app-level `destructive_enabled` from the effective global or
per-plugin `allow_destructive_actions` policy and lets Codex enforce
destructive tool metadata from its native app tool annotations. The `_default`
app config is disabled with `open_world_enabled: false`. Enabled plugin apps
are emitted with `open_world_enabled: true`; OpenClaw does not expose a separate
plugin open-world policy knob and does not maintain per-plugin destructive
tool-name deny lists.
destructive tool metadata from its native app tool annotations. `true` and
`"auto"` both set `destructive_enabled: true`; `false` sets it false. The
`_default` app config is disabled with `open_world_enabled: false`. Enabled
plugin apps are emitted with `open_world_enabled: true`; OpenClaw does not
expose a separate plugin open-world policy knob and does not maintain
per-plugin destructive tool-name deny lists.
Tool approval mode is automatic by default for plugin apps so non-destructive
read tools can run without a same-thread approval UI. Destructive tools remain
@@ -221,6 +222,9 @@ plugins, while unsafe schemas and ambiguous ownership still fail closed:
- When policy is `false`, OpenClaw returns a deterministic decline.
- When policy is `true`, OpenClaw auto-accepts only safe schemas it can map to
an approval response, such as a boolean approve field.
- When policy is `"auto"`, OpenClaw exposes destructive plugin actions to
Codex but turns ownership-proven MCP approval elicitations into OpenClaw
plugin approvals before returning the Codex approval response.
- Missing plugin identity, ambiguous ownership, a missing turn id, a wrong turn
id, or an unsafe elicitation schema declines instead of prompting.
@@ -268,8 +272,8 @@ Codex thread bindings keep the app config they started with until OpenClaw
establishes a new harness session or replaces a stale binding.
**Destructive action is declined:** check the global and per-plugin
`allow_destructive_actions` values. Even when policy is true, unsafe elicitation
schemas and ambiguous plugin identity still fail closed.
`allow_destructive_actions` values. Even when policy is true or `"auto"`,
unsafe elicitation schemas and ambiguous plugin identity still fail closed.
## Related

View File

@@ -86,6 +86,7 @@ Bundled fallback examples:
| Model ref | Notes |
| --------------------------------- | ---------------------------- |
| `openrouter/auto` | OpenRouter automatic routing |
| `openrouter/openrouter/fusion` | OpenRouter Fusion router |
| `openrouter/moonshotai/kimi-k2.6` | Kimi K2.6 via MoonshotAI |
| `openrouter/moonshotai/kimi-k2.5` | Kimi K2.5 via MoonshotAI |
@@ -213,6 +214,79 @@ media understanding preflight.
OpenClaw sends OpenRouter STT requests as JSON with base64 audio under
`input_audio` (OpenRouter STT contract), not as multipart OpenAI form uploads.
## Fusion router
Use OpenRouter Fusion when you want one OpenClaw model ref to ask several
OpenRouter models in parallel, have OpenRouter judge their answers, and return a
single final response through the normal OpenRouter provider endpoint. Because
the upstream model slug is `openrouter/fusion`, the OpenClaw model ref includes
both the OpenClaw provider prefix and the upstream OpenRouter namespace:
```bash
openclaw models set openrouter/openrouter/fusion
```
Configure Fusion's panel and judge through the model's `params.extraBody`. Those
fields are forwarded into the OpenRouter chat-completions request body. Fusion
works with either OpenRouter OAuth onboarding or API-key onboarding; if you use
OAuth, omit the `env.OPENROUTER_API_KEY` line from the example below.
```json5
{
env: { OPENROUTER_API_KEY: "sk-or-..." },
agents: {
defaults: {
model: { primary: "openrouter/openrouter/fusion" },
models: {
"openrouter/openrouter/fusion": {
params: {
extraBody: {
plugins: [
{
id: "fusion",
analysis_models: [
"google/gemini-3.5-flash",
"moonshotai/kimi-k2.6",
"deepseek/deepseek-v4-pro",
],
model: "google/gemini-3.5-flash",
},
],
},
},
},
},
},
},
}
```
The `analysis_models` list is the parallel panel, and `model` inside the Fusion
plugin config is the judge model. Do not set top-level `tool_choice` to
`"required"` in normal OpenClaw agent/chat turns to try to force Fusion;
OpenClaw turns may include OpenClaw tool definitions, and a top-level required
tool choice can require one of those tools instead of the Fusion router. When
this Fusion plugin config is present, OpenClaw also adds a sanitized
system-prompt note with the configured analysis models and judge model so the
agent can answer questions about its current Fusion panel. Other `extraBody`
fields are not copied into the prompt.
Fusion is slower by design. OpenRouter may send the same OpenClaw prompt to
multiple analysis models and then run a final judge/synthesis step, so latency is
usually higher than a direct single-model request. Use Fusion for deliberate,
high-quality answers or escalation paths, not as the default for
latency-sensitive chat. For faster responses, keep the panel small and choose
faster analysis and judge models.
Test the configured ref with a one-shot local model call:
```bash
openclaw infer model run --local \
--model openrouter/openrouter/fusion \
--prompt "Reply with exactly: FUSION_OK" \
--json
```
## Authentication and headers
OpenRouter uses a Bearer token with your API key under the hood. OpenRouter

View File

@@ -1406,22 +1406,13 @@ create` validates the written archive by default; `--no-verify` is the
explicit `dbPath`.
- `check:database-first-legacy-stores` fails new runtime source that pairs
legacy store names with write-style filesystem APIs. It also fails runtime
source that reintroduces transcript bridge contracts such as
`transcriptLocator`, `sqlite-transcript://...`, `sessionFile`, or
`storePath`, and scans tests for those bridge-contract names too. It also
bans `SessionManager.open(...)` and the old static SessionManager facades so
runtime and tests cannot silently re-create a file-backed session opener or
file-era session discovery. It also bans the old session JSONL downloader
hook/class from export UI. It also bans sidecar-shaped plugin-state/task
SQLite helper names; tests should assert `databasePath` and the shared
`state/openclaw.sqlite` location instead of pretending those features own
separate SQLite files. It also bans the old generic memory index SQL table
names (`meta`, `files`, `chunks`, `chunks_vec`,
`chunks_fts`, `embedding_cache`) in runtime source so the agent database keeps
its explicit `memory_index_*` schema. It also bans embedding TEXT schemas and
embedding JSON-array writes so vectors stay compact SQLite BLOBs. Migration,
doctor, import, and explicit non-session export code remain allowed. The
guard now also covers runtime `cache/*.json` stores, generic
source that reintroduces the retired transcript bridge markers
`transcriptLocator` or `sqlite-transcript://...`. Migration, doctor, import,
and explicit non-session export code remain allowed. Broader legacy contract
names such as `sessionFile`, `storePath`, and old `SessionManager` file-era
facades still have current owners and need separate migration guard work
before they can become a required preflight check. The guard now also covers
runtime `cache/*.json` stores, generic
`thread-bindings.json` sidecars, cron state/run-log JSON, config health JSON,
restart and lock sidecars, Voice Wake settings, plugin binding approvals,
installed plugin index JSON, File Transfer audit JSONL, Memory Wiki activity

View File

@@ -1,12 +1,12 @@
{
"name": "@openclaw/acpx",
"version": "2026.6.2",
"version": "2026.6.15-alpha.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@openclaw/acpx",
"version": "2026.6.2",
"version": "2026.6.15-alpha.1",
"dependencies": {
"@agentclientprotocol/claude-agent-acp": "0.39.0",
"@zed-industries/codex-acp": "0.15.0",

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/acpx",
"version": "2026.6.2",
"version": "2026.6.15-alpha.1",
"description": "OpenClaw ACP runtime backend with plugin-owned session and transport management.",
"repository": {
"type": "git",
@@ -26,10 +26,10 @@
"minHostVersion": ">=2026.4.25"
},
"compat": {
"pluginApi": ">=2026.6.2"
"pluginApi": ">=2026.6.15-alpha.1"
},
"build": {
"openclawVersion": "2026.6.2",
"openclawVersion": "2026.6.15-alpha.1",
"staticAssets": [
{
"source": "./src/runtime-internals/mcp-proxy.mjs",

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/admin-http-rpc",
"version": "2026.6.2",
"version": "2026.6.15-alpha.1",
"private": true,
"description": "OpenClaw admin HTTP RPC endpoint",
"type": "module",

View File

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

View File

@@ -1,12 +1,12 @@
{
"name": "@openclaw/amazon-bedrock-mantle-provider",
"version": "2026.6.2",
"version": "2026.6.15-alpha.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@openclaw/amazon-bedrock-mantle-provider",
"version": "2026.6.2",
"version": "2026.6.15-alpha.1",
"dependencies": {
"@anthropic-ai/sdk": "0.100.1",
"@aws/bedrock-token-generator": "1.1.0"

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/amazon-bedrock-mantle-provider",
"version": "2026.6.2",
"version": "2026.6.15-alpha.1",
"description": "OpenClaw Amazon Bedrock Mantle provider plugin for OpenAI-compatible model routing.",
"repository": {
"type": "git",
@@ -24,10 +24,10 @@
"minHostVersion": ">=2026.5.12-beta.1"
},
"compat": {
"pluginApi": ">=2026.6.2"
"pluginApi": ">=2026.6.15-alpha.1"
},
"build": {
"openclawVersion": "2026.6.2",
"openclawVersion": "2026.6.15-alpha.1",
"bundledDist": false
},
"release": {

View File

@@ -1,12 +1,12 @@
{
"name": "@openclaw/amazon-bedrock-provider",
"version": "2026.6.2",
"version": "2026.6.15-alpha.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@openclaw/amazon-bedrock-provider",
"version": "2026.6.2",
"version": "2026.6.15-alpha.1",
"dependencies": {
"@aws-sdk/client-bedrock": "3.1056.0",
"@aws-sdk/client-bedrock-runtime": "3.1056.0",

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/amazon-bedrock-provider",
"version": "2026.6.2",
"version": "2026.6.15-alpha.1",
"description": "OpenClaw Amazon Bedrock provider plugin with model discovery, embeddings, and guardrail support.",
"repository": {
"type": "git",
@@ -28,10 +28,10 @@
"minHostVersion": ">=2026.5.12-beta.1"
},
"compat": {
"pluginApi": ">=2026.6.2"
"pluginApi": ">=2026.6.15-alpha.1"
},
"build": {
"openclawVersion": "2026.6.2",
"openclawVersion": "2026.6.15-alpha.1",
"bundledDist": false
},
"release": {

View File

@@ -1,12 +1,12 @@
{
"name": "@openclaw/anthropic-vertex-provider",
"version": "2026.6.2",
"version": "2026.6.15-alpha.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@openclaw/anthropic-vertex-provider",
"version": "2026.6.2",
"version": "2026.6.15-alpha.1",
"dependencies": {
"@anthropic-ai/vertex-sdk": "0.16.1"
}

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/anthropic-vertex-provider",
"version": "2026.6.2",
"version": "2026.6.15-alpha.1",
"description": "OpenClaw Anthropic Vertex provider plugin for Claude models on Google Vertex AI.",
"repository": {
"type": "git",
@@ -23,10 +23,10 @@
"minHostVersion": ">=2026.5.12-beta.1"
},
"compat": {
"pluginApi": ">=2026.6.2"
"pluginApi": ">=2026.6.15-alpha.1"
},
"build": {
"openclawVersion": "2026.6.2",
"openclawVersion": "2026.6.15-alpha.1",
"bundledDist": false
},
"release": {

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,12 +1,12 @@
{
"name": "@openclaw/brave-plugin",
"version": "2026.6.2",
"version": "2026.6.15-alpha.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@openclaw/brave-plugin",
"version": "2026.6.2"
"version": "2026.6.15-alpha.1"
}
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/brave-plugin",
"version": "2026.6.2",
"version": "2026.6.15-alpha.1",
"description": "OpenClaw Brave Search provider plugin for web search.",
"repository": {
"type": "git",
@@ -21,10 +21,10 @@
"allowInvalidConfigRecovery": true
},
"compat": {
"pluginApi": ">=2026.6.2"
"pluginApi": ">=2026.6.15-alpha.1"
},
"build": {
"openclawVersion": "2026.6.2"
"openclawVersion": "2026.6.15-alpha.1"
},
"release": {
"publishToClawHub": true,

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/browser-plugin",
"version": "2026.6.2",
"version": "2026.6.15-alpha.1",
"private": true,
"description": "OpenClaw browser tool plugin",
"type": "module",

View File

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

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/canvas-plugin",
"version": "2026.6.2",
"version": "2026.6.15-alpha.1",
"private": true,
"description": "OpenClaw Canvas plugin",
"type": "module",

View File

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

View File

@@ -15,6 +15,17 @@ function restoreEnvVar(name: string, value: string | undefined): void {
}
}
function readAuthorizationHeader(init?: { headers?: HeadersInit }): string {
const headers = init?.headers;
if (headers instanceof Headers) {
return headers.get("Authorization") ?? "";
}
if (Array.isArray(headers)) {
return headers.find(([key]) => key.toLowerCase() === "authorization")?.[1] ?? "";
}
return headers?.Authorization ?? headers?.authorization ?? "";
}
async function runChutesCatalog(params: { apiKey?: string; discoveryApiKey?: string }) {
const provider = await registerSingleProviderPlugin(plugin);
const result = await provider.catalog?.run({
@@ -102,9 +113,7 @@ describe("chutes implicit provider auth mode", () => {
const chutesCalls = fetchMock.mock.calls.filter(([url]) => String(url).includes("chutes.ai"));
expect(chutesCalls.length).toBeGreaterThan(0);
const request = chutesCalls[0]?.[1] as { headers?: HeadersInit } | undefined;
expect(new Headers(request?.headers).get("authorization")).toBe(
"Bearer my-chutes-access-token",
);
expect(readAuthorizationHeader(request)).toBe("Bearer my-chutes-access-token");
});
});
});

View File

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

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/clickclack",
"version": "2026.6.2",
"version": "2026.6.15-alpha.1",
"private": true,
"description": "OpenClaw ClickClack channel plugin",
"type": "module",
@@ -18,7 +18,7 @@
"openclaw": "2026.5.28"
},
"peerDependencies": {
"openclaw": ">=2026.6.2"
"openclaw": ">=2026.6.15-alpha.1"
},
"peerDependenciesMeta": {
"openclaw": {

View File

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

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/codex-supervisor",
"version": "2026.6.2",
"version": "2026.6.15-alpha.1",
"private": true,
"description": "OpenClaw Codex app-server fleet supervision plugin.",
"type": "module",

View File

@@ -1,44 +1,6 @@
// Codex tests cover doctor contract api plugin behavior.
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import type { PluginStateKeyedStore } from "openclaw/plugin-sdk/plugin-state-runtime";
import {
createPluginStateKeyedStoreForTests,
resetPluginStateStoreForTests,
} from "openclaw/plugin-sdk/plugin-state-test-runtime";
import type {
OpenKeyedStoreOptions,
PluginDoctorStateMigrationContext,
} from "openclaw/plugin-sdk/runtime-doctor";
import { afterEach, describe, expect, it } from "vitest";
import {
legacyConfigRules,
normalizeCompatibilityConfig,
stateMigrations,
} from "./doctor-contract-api.js";
import {
bindingStoreKey,
CODEX_APP_SERVER_BINDING_MAX_ENTRIES,
CODEX_APP_SERVER_BINDING_NAMESPACE,
type StoredCodexAppServerBinding,
} from "./src/app-server/session-binding.js";
import { legacyCodexConversationBindingId } from "./src/conversation-binding-data.js";
function createDoctorContext(env: NodeJS.ProcessEnv): PluginDoctorStateMigrationContext {
return {
openPluginStateKeyedStore<T>(options: OpenKeyedStoreOptions) {
return createPluginStateKeyedStoreForTests<T>("codex", {
...options,
env: options.env ?? env,
});
},
};
}
afterEach(() => {
resetPluginStateStoreForTests();
});
import { describe, expect, it } from "vitest";
import { legacyConfigRules, normalizeCompatibilityConfig } from "./doctor-contract-api.js";
describe("codex doctor contract", () => {
it("reports the retired dynamic tools profile config key", () => {
@@ -51,6 +13,31 @@ describe("codex doctor contract", () => {
expect(legacyConfigRules[0]?.match({ codexDynamicToolsLoading: "direct" })).toBe(false);
});
it("reports old approval-routed destructive plugin policy values", () => {
expect(
legacyConfigRules[1]?.match({
allow_destructive_actions: "on-request",
plugins: {},
}),
).toBe(true);
expect(
legacyConfigRules[1]?.match({
allow_destructive_actions: true,
plugins: {
"google-calendar": { allow_destructive_actions: "on-request" },
},
}),
).toBe(true);
expect(
legacyConfigRules[1]?.match({
allow_destructive_actions: "auto",
plugins: {
"google-calendar": { allow_destructive_actions: true },
},
}),
).toBe(false);
});
it("removes the retired dynamic tools profile without dropping other Codex config", () => {
const original = {
plugins: {
@@ -81,855 +68,59 @@ describe("codex doctor contract", () => {
expect(original.plugins.entries.codex.config).toHaveProperty("codexDynamicToolsProfile");
});
it("imports shipped binding sidecars under session and legacy conversation identities", async () => {
const stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-doctor-"));
const env = { ...process.env, OPENCLAW_STATE_DIR: stateDir };
const sessionsDir = path.join(stateDir, "agents", "main", "sessions");
const transcriptPath = path.join(sessionsDir, "session-current.jsonl");
const sidecarPath = `${transcriptPath}.codex-app-server.json`;
const legacyBinding = {
schemaVersion: 1,
threadId: "thread-1",
sessionFile: transcriptPath,
updatedAt: "2026-01-01T00:00:00.000Z",
};
await fs.mkdir(sessionsDir, { recursive: true });
await fs.writeFile(transcriptPath, '{"type":"session","id":"session-current"}\n', "utf8");
await fs.writeFile(
path.join(sessionsDir, "sessions.json"),
JSON.stringify({
"agent:main:session-1": {
sessionId: "session-current",
sessionFile: "session-current.jsonl",
totalTokens: 42_000,
totalTokensFresh: true,
contextTokens: 258_400,
updatedAt: Date.now(),
it("renames old approval-routed destructive plugin policy values", () => {
const original = {
plugins: {
entries: {
codex: {
enabled: true,
config: {
codexDynamicToolsProfile: "openclaw-compat",
codexPlugins: {
enabled: true,
allow_destructive_actions: "on-request",
plugins: {
"google-calendar": {
enabled: true,
allow_destructive_actions: "on-request",
},
slack: {
enabled: true,
allow_destructive_actions: false,
},
},
},
},
},
},
}),
"utf8",
);
await fs.writeFile(sidecarPath, JSON.stringify(legacyBinding), "utf8");
const params = {
config: {},
env,
stateDir,
oauthDir: path.join(stateDir, "oauth"),
context: createDoctorContext(env),
};
const migration = stateMigrations[0];
if (!migration) {
throw new Error("missing Codex binding migration");
}
await expect(migration.detectLegacyState(params)).resolves.toMatchObject({
preview: [expect.stringContaining("legacy sidecar")],
});
await expect(migration.migrateLegacyState(params)).resolves.toMatchObject({
changes: [expect.stringContaining("Migrated 1")],
warnings: [],
});
const store = createDoctorContext(env).openPluginStateKeyedStore<StoredCodexAppServerBinding>({
namespace: CODEX_APP_SERVER_BINDING_NAMESPACE,
maxEntries: CODEX_APP_SERVER_BINDING_MAX_ENTRIES,
overflowPolicy: "reject-new",
});
await expect(
store.lookup(
bindingStoreKey({
kind: "session",
agentId: "main",
sessionId: "session-current",
sessionKey: "agent:main:session-1",
}),
),
).resolves.toMatchObject({
state: "active",
sessionId: "session-current",
binding: { threadId: "thread-1" },
});
await expect(
store.lookup(
bindingStoreKey({
kind: "conversation",
bindingId: legacyCodexConversationBindingId(transcriptPath),
}),
),
).resolves.toMatchObject({
state: "active",
binding: {
threadId: "thread-1",
cwd: "",
historyCoveredThrough: expect.any(String),
},
});
await expect(
store.lookup(
bindingStoreKey({
kind: "conversation",
bindingId: legacyCodexConversationBindingId(transcriptPath),
}),
),
).resolves.not.toHaveProperty("binding.nativeContextUsage");
await expect(fs.access(`${sidecarPath}.migrated`)).resolves.toBeUndefined();
await expect(
fs.readFile(path.join(sessionsDir, "sessions.json"), "utf8").then(JSON.parse),
).resolves.toMatchObject({
"agent:main:session-1": { sessionId: "session-current", agentHarnessId: "codex" },
});
await fs.rm(`${sidecarPath}.migrated`);
await fs.writeFile(sidecarPath, JSON.stringify(legacyBinding), "utf8");
await expect(migration.migrateLegacyState(params)).resolves.toMatchObject({
changes: [expect.stringContaining("Migrated 1")],
warnings: [],
});
await expect(fs.access(`${sidecarPath}.migrated`)).resolves.toBeUndefined();
const resetTranscript = path.join(sessionsDir, "session-before-reset.jsonl");
const resetSidecar = `${resetTranscript}.codex-app-server.json`;
await fs.writeFile(resetTranscript, '{"type":"session","id":"session-before-reset"}\n', "utf8");
await fs.writeFile(
resetSidecar,
JSON.stringify({ schemaVersion: 1, threadId: "thread-before-reset" }),
"utf8",
);
await expect(migration.migrateLegacyState(params)).resolves.toMatchObject({
changes: [expect.stringContaining("Migrated 1 safe")],
warnings: [expect.stringContaining("session owner could not be resolved")],
});
await expect(fs.access(resetSidecar)).resolves.toBeUndefined();
await fs.rm(resetSidecar);
const conflictingTranscript = path.join(sessionsDir, "session-2.jsonl");
const conflictingSidecar = `${conflictingTranscript}.codex-app-server.json`;
await fs.writeFile(conflictingTranscript, '{"type":"session","id":"session-2"}\n', "utf8");
await fs.writeFile(
conflictingSidecar,
JSON.stringify({ schemaVersion: 1, threadId: "legacy-thread" }),
"utf8",
);
await fs.writeFile(
path.join(sessionsDir, "sessions.json"),
JSON.stringify({
"agent:main:session-1": {
sessionId: "session-1",
sessionFile: "session-1.jsonl",
updatedAt: Date.now(),
},
"agent:main:session-2": {
sessionId: "session-2",
sessionFile: "session-2.jsonl",
updatedAt: Date.now(),
},
}),
"utf8",
);
const conflictingSessionKey = bindingStoreKey({
kind: "session",
agentId: "main",
sessionId: "session-2",
sessionKey: "agent:main:session-2",
});
await store.register(conflictingSessionKey, {
version: 1,
state: "active",
binding: {
threadId: "legacy-thread",
cwd: "/repo",
historyCoveredThrough: "2026-01-01T00:00:00.000Z",
},
});
await expect(migration.migrateLegacyState(params)).resolves.toMatchObject({
changes: [],
warnings: [
expect.stringContaining(`canonical plugin state changed at ${conflictingSessionKey}`),
],
});
await expect(
store.lookup(
bindingStoreKey({
kind: "conversation",
bindingId: legacyCodexConversationBindingId(conflictingTranscript),
}),
),
).resolves.toBeUndefined();
await expect(fs.access(conflictingSidecar)).resolves.toBeUndefined();
await fs.rm(conflictingSidecar);
const inverseTranscript = path.join(sessionsDir, "session-3.jsonl");
const inverseSidecar = `${inverseTranscript}.codex-app-server.json`;
const inverseConversationKey = bindingStoreKey({
kind: "conversation",
bindingId: legacyCodexConversationBindingId(inverseTranscript),
});
await fs.writeFile(inverseTranscript, '{"type":"session","id":"session-3"}\n', "utf8");
await fs.writeFile(
path.join(sessionsDir, "sessions.json"),
JSON.stringify({
"agent:main:session-3": {
sessionId: "session-3",
sessionFile: "session-3.jsonl",
updatedAt: Date.now(),
},
}),
"utf8",
);
await fs.writeFile(
inverseSidecar,
JSON.stringify({ schemaVersion: 1, threadId: "session-thread" }),
"utf8",
);
await store.register(inverseConversationKey, {
version: 1,
state: "active",
binding: { threadId: "conversation-thread", cwd: "/repo" },
});
await expect(migration.migrateLegacyState(params)).resolves.toMatchObject({
changes: [expect.stringContaining("Migrated 1")],
warnings: [],
});
await expect(
store.lookup(
bindingStoreKey({
kind: "session",
agentId: "main",
sessionId: "session-3",
sessionKey: "agent:main:session-3",
}),
),
).resolves.toMatchObject({
state: "active",
sessionId: "session-3",
binding: { threadId: "conversation-thread" },
});
await expect(store.lookup(inverseConversationKey)).resolves.toMatchObject({
state: "active",
binding: { threadId: "conversation-thread" },
});
await expect(fs.access(`${inverseSidecar}.migrated`)).resolves.toBeUndefined();
await fs.rm(stateDir, { recursive: true, force: true });
});
it("does not publish Codex session ownership before every binding row persists", async () => {
const stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-doctor-order-"));
const env = { ...process.env, OPENCLAW_STATE_DIR: stateDir };
const sessionsDir = path.join(stateDir, "agents", "main", "sessions");
const transcriptPath = path.join(sessionsDir, "session-order.jsonl");
const sidecarPath = `${transcriptPath}.codex-app-server.json`;
const storePath = path.join(sessionsDir, "sessions.json");
await fs.mkdir(sessionsDir, { recursive: true });
await fs.writeFile(transcriptPath, '{"type":"session","id":"session-order"}\n', "utf8");
await fs.writeFile(
storePath,
JSON.stringify({
"agent:main:order": {
sessionId: "session-order",
sessionFile: "session-order.jsonl",
updatedAt: Date.now(),
},
}),
"utf8",
);
await fs.writeFile(
sidecarPath,
JSON.stringify({ schemaVersion: 1, threadId: "thread-order" }),
"utf8",
);
const store = createPluginStateKeyedStoreForTests<StoredCodexAppServerBinding>("codex", {
namespace: CODEX_APP_SERVER_BINDING_NAMESPACE,
maxEntries: CODEX_APP_SERVER_BINDING_MAX_ENTRIES,
overflowPolicy: "reject-new",
env,
});
const registerIfAbsent = store.registerIfAbsent.bind(store);
let registerCalls = 0;
const failingStore: PluginStateKeyedStore<StoredCodexAppServerBinding> = {
...store,
async registerIfAbsent(key, value, opts) {
registerCalls++;
if (registerCalls === 2) {
throw new Error("injected session binding write failure");
}
return await registerIfAbsent(key, value, opts);
},
};
const failingContext: PluginDoctorStateMigrationContext = {
openPluginStateKeyedStore<T>() {
return failingStore as unknown as PluginStateKeyedStore<T>;
},
};
const migration = stateMigrations[0];
if (!migration) {
throw new Error("missing Codex binding migration");
}
await expect(
migration.migrateLegacyState({
config: {},
env,
stateDir,
oauthDir: path.join(stateDir, "oauth"),
context: failingContext,
}),
).resolves.toMatchObject({
changes: [expect.stringContaining("Migrated 1 safe")],
warnings: [expect.stringContaining("injected session binding write failure")],
});
await expect(fs.readFile(storePath, "utf8").then(JSON.parse)).resolves.toMatchObject({
"agent:main:order": { sessionId: "session-order" },
const result = normalizeCompatibilityConfig({ cfg: original });
expect(result.changes).toEqual([
"Removed retired plugins.entries.codex.config.codexDynamicToolsProfile; Codex app-server always keeps Codex-native workspace tools native.",
'Renamed plugins.entries.codex.config.codexPlugins allow_destructive_actions="on-request" values to "auto".',
]);
expect(result.config.plugins?.entries?.codex?.config).toEqual({
codexPlugins: {
enabled: true,
allow_destructive_actions: "auto",
plugins: {
"google-calendar": {
enabled: true,
allow_destructive_actions: "auto",
},
slack: {
enabled: true,
allow_destructive_actions: false,
},
},
},
});
expect(
(JSON.parse(await fs.readFile(storePath, "utf8")) as Record<string, Record<string, unknown>>)[
"agent:main:order"
],
).not.toHaveProperty("agentHarnessId");
await expect(
store.lookup(
bindingStoreKey({
kind: "session",
agentId: "main",
sessionId: "session-order",
sessionKey: "agent:main:order",
}),
),
).resolves.toBeUndefined();
await expect(fs.access(sidecarPath)).resolves.toBeUndefined();
await expect(
migration.migrateLegacyState({
config: {},
env,
stateDir,
oauthDir: path.join(stateDir, "oauth"),
context: createDoctorContext(env),
}),
).resolves.toMatchObject({
changes: [expect.stringContaining("Migrated 1")],
warnings: [],
});
await expect(fs.readFile(storePath, "utf8").then(JSON.parse)).resolves.toMatchObject({
"agent:main:order": {
sessionId: "session-order",
agentHarnessId: "codex",
},
});
await expect(fs.access(`${sidecarPath}.migrated`)).resolves.toBeUndefined();
await fs.rm(stateDir, { recursive: true, force: true });
});
it("retains a shipped binding when its session now belongs to another harness", async () => {
const stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-doctor-owner-"));
const env = { ...process.env, OPENCLAW_STATE_DIR: stateDir };
const sessionsDir = path.join(stateDir, "agents", "main", "sessions");
const transcriptPath = path.join(sessionsDir, "session-foreign.jsonl");
const sidecarPath = `${transcriptPath}.codex-app-server.json`;
await fs.mkdir(sessionsDir, { recursive: true });
await fs.writeFile(transcriptPath, '{"type":"session","id":"session-foreign"}\n', "utf8");
await fs.writeFile(
path.join(sessionsDir, "sessions.json"),
JSON.stringify({
"agent:main:foreign": {
sessionId: "session-foreign",
sessionFile: "session-foreign.jsonl",
agentHarnessId: "openclaw",
updatedAt: Date.now(),
},
}),
"utf8",
);
await fs.writeFile(
sidecarPath,
JSON.stringify({
schemaVersion: 1,
threadId: "thread-foreign",
sessionFile: transcriptPath,
}),
"utf8",
);
const migration = stateMigrations[0];
if (!migration) {
throw new Error("missing Codex binding migration");
}
await expect(
migration.migrateLegacyState({
config: {},
env,
stateDir,
oauthDir: path.join(stateDir, "oauth"),
context: createDoctorContext(env),
}),
).resolves.toMatchObject({
changes: [],
warnings: [expect.stringContaining("owned by agent harness openclaw")],
});
await expect(fs.access(sidecarPath)).resolves.toBeUndefined();
const store = createDoctorContext(env).openPluginStateKeyedStore<StoredCodexAppServerBinding>({
namespace: CODEX_APP_SERVER_BINDING_NAMESPACE,
maxEntries: CODEX_APP_SERVER_BINDING_MAX_ENTRIES,
overflowPolicy: "reject-new",
});
await expect(
store.lookup(
bindingStoreKey({
kind: "session",
agentId: "main",
sessionId: "session-foreign",
sessionKey: "agent:main:foreign",
}),
),
).resolves.toBeUndefined();
await fs.rm(stateDir, { recursive: true, force: true });
});
it("imports sidecars from the pre-agent session directory before core moves it", async () => {
const stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-doctor-legacy-"));
const env = { ...process.env, OPENCLAW_STATE_DIR: stateDir };
const sessionsDir = path.join(stateDir, "sessions");
const transcriptPath = path.join(sessionsDir, "legacy-session.jsonl");
const sidecarPath = `${transcriptPath}.codex-app-server.json`;
await fs.mkdir(sessionsDir, { recursive: true });
await fs.writeFile(transcriptPath, '{"type":"session","id":"legacy-session"}\n', "utf8");
await fs.writeFile(
path.join(sessionsDir, "sessions.json"),
JSON.stringify({
"agent:main:legacy": {
sessionId: "legacy-session",
sessionFile: "legacy-session.jsonl",
updatedAt: Date.now(),
},
}),
"utf8",
);
await fs.writeFile(
sidecarPath,
JSON.stringify({
schemaVersion: 1,
threadId: "legacy-thread",
sessionFile: transcriptPath,
}),
"utf8",
);
const params = {
config: {},
env,
stateDir,
oauthDir: path.join(stateDir, "oauth"),
context: createDoctorContext(env),
};
const migration = stateMigrations[0];
if (!migration) {
throw new Error("missing Codex binding migration");
}
await expect(migration.migrateLegacyState(params)).resolves.toMatchObject({ warnings: [] });
const store = createDoctorContext(env).openPluginStateKeyedStore<StoredCodexAppServerBinding>({
namespace: CODEX_APP_SERVER_BINDING_NAMESPACE,
maxEntries: CODEX_APP_SERVER_BINDING_MAX_ENTRIES,
overflowPolicy: "reject-new",
});
await expect(
store.lookup(
bindingStoreKey({
kind: "session",
agentId: "main",
sessionId: "legacy-session",
sessionKey: "agent:main:legacy",
}),
),
).resolves.toMatchObject({
state: "active",
sessionId: "legacy-session",
binding: { threadId: "legacy-thread" },
});
await expect(fs.access(`${sidecarPath}.migrated`)).resolves.toBeUndefined();
await expect(
fs.readFile(path.join(sessionsDir, "sessions.json"), "utf8").then(JSON.parse),
).resolves.toMatchObject({
"agent:main:legacy": { sessionId: "legacy-session", agentHarnessId: "codex" },
});
});
it("uses the session index when a shipped sidecar transcript is missing", async () => {
const stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-doctor-"));
const env = { ...process.env, OPENCLAW_STATE_DIR: stateDir };
const sessionsDir = path.join(stateDir, "agents", "main", "sessions");
const transcriptPath = path.join(sessionsDir, "missing.jsonl");
const sidecarPath = `${transcriptPath}.codex-app-server.json`;
await fs.mkdir(sessionsDir, { recursive: true });
await fs.writeFile(
path.join(sessionsDir, "sessions.json"),
JSON.stringify({
"agent:main:missing": {
sessionId: "session-missing",
sessionFile: "missing.jsonl",
updatedAt: Date.now(),
},
}),
"utf8",
);
await fs.writeFile(
sidecarPath,
JSON.stringify({
schemaVersion: 1,
threadId: "thread-legacy-conversation",
sessionFile: transcriptPath,
}),
"utf8",
);
const migration = stateMigrations[0];
if (!migration) {
throw new Error("missing Codex binding migration");
}
await expect(
migration.migrateLegacyState({
config: {},
env,
stateDir,
oauthDir: path.join(stateDir, "oauth"),
context: createDoctorContext(env),
}),
).resolves.toMatchObject({
changes: [expect.stringContaining("Migrated 1")],
warnings: [],
});
const store = createDoctorContext(env).openPluginStateKeyedStore<StoredCodexAppServerBinding>({
namespace: CODEX_APP_SERVER_BINDING_NAMESPACE,
maxEntries: CODEX_APP_SERVER_BINDING_MAX_ENTRIES,
overflowPolicy: "reject-new",
});
await expect(
store.lookup(
bindingStoreKey({
kind: "conversation",
bindingId: legacyCodexConversationBindingId(transcriptPath),
}),
),
).resolves.toMatchObject({
state: "active",
binding: { threadId: "thread-legacy-conversation" },
});
await expect(
store.lookup(
bindingStoreKey({
kind: "session",
agentId: "main",
sessionId: "session-missing",
sessionKey: "agent:main:missing",
}),
),
).resolves.toMatchObject({
state: "active",
sessionId: "session-missing",
binding: { threadId: "thread-legacy-conversation" },
});
await expect(fs.access(`${sidecarPath}.migrated`)).resolves.toBeUndefined();
await fs.rm(stateDir, { recursive: true, force: true });
});
it("imports a binding without crawling Codex rollout files", async () => {
const stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-doctor-"));
const env = { ...process.env, OPENCLAW_STATE_DIR: stateDir };
const sessionsDir = path.join(stateDir, "agents", "main", "sessions");
const transcriptPath = path.join(sessionsDir, "session-fresh.jsonl");
const sidecarPath = `${transcriptPath}.codex-app-server.json`;
await fs.mkdir(sessionsDir, { recursive: true });
await fs.writeFile(transcriptPath, '{"type":"session","id":"session-fresh"}\n', "utf8");
await fs.writeFile(
path.join(sessionsDir, "sessions.json"),
JSON.stringify({
"agent:main:fresh": {
sessionId: "session-fresh",
sessionFile: "session-fresh.jsonl",
updatedAt: Date.now(),
},
}),
"utf8",
);
await fs.writeFile(
sidecarPath,
JSON.stringify({ schemaVersion: 1, threadId: "thread-without-rollout" }),
"utf8",
);
const migration = stateMigrations[0];
if (!migration) {
throw new Error("missing Codex binding migration");
}
await expect(
migration.migrateLegacyState({
config: {},
env,
stateDir,
oauthDir: path.join(stateDir, "oauth"),
context: createDoctorContext(env),
}),
).resolves.toEqual({
changes: [expect.stringContaining("Migrated 1")],
warnings: [],
});
const store = createDoctorContext(env).openPluginStateKeyedStore<StoredCodexAppServerBinding>({
namespace: CODEX_APP_SERVER_BINDING_NAMESPACE,
maxEntries: CODEX_APP_SERVER_BINDING_MAX_ENTRIES,
overflowPolicy: "reject-new",
});
const targetKey = bindingStoreKey({
kind: "conversation",
bindingId: legacyCodexConversationBindingId(transcriptPath),
});
await expect(
store.lookup(
bindingStoreKey({
kind: "session",
agentId: "main",
sessionId: "session-fresh",
sessionKey: "agent:main:fresh",
}),
),
).resolves.toMatchObject({
state: "active",
sessionId: "session-fresh",
binding: { threadId: "thread-without-rollout" },
});
await expect(store.lookup(targetKey)).resolves.toMatchObject({
state: "active",
binding: { threadId: "thread-without-rollout" },
});
await expect(fs.access(`${sidecarPath}.migrated`)).resolves.toBeUndefined();
await fs.rm(stateDir, { recursive: true, force: true });
});
it("retains an ambiguous sidecar and converges after its owner resolves", async () => {
const stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-doctor-"));
const env = { ...process.env, HOME: stateDir, OPENCLAW_STATE_DIR: stateDir };
const config = {
agents: { list: [{ id: "alpha" }, { id: "beta" }] },
session: { store: "~/shared/sessions.json" },
};
const sessionsDir = path.join(stateDir, "shared");
const transcriptPath = path.join(sessionsDir, "ambiguous.jsonl");
const sidecarPath = `${transcriptPath}.codex-app-server.json`;
await fs.mkdir(sessionsDir, { recursive: true });
await fs.writeFile(transcriptPath, '{"type":"message"}\n', "utf8");
await fs.writeFile(
sidecarPath,
JSON.stringify({
schemaVersion: 1,
threadId: "thread-ambiguous",
sessionFile: transcriptPath,
}),
"utf8",
);
const migration = stateMigrations[0];
if (!migration) {
throw new Error("missing Codex binding migration");
}
await expect(
migration.migrateLegacyState({
config,
env,
stateDir,
oauthDir: path.join(stateDir, "oauth"),
context: createDoctorContext(env),
}),
).resolves.toMatchObject({
changes: [expect.stringContaining("Migrated 1 safe")],
warnings: [expect.stringContaining("session owner could not be resolved")],
});
const store = createDoctorContext(env).openPluginStateKeyedStore<StoredCodexAppServerBinding>({
namespace: CODEX_APP_SERVER_BINDING_NAMESPACE,
maxEntries: CODEX_APP_SERVER_BINDING_MAX_ENTRIES,
overflowPolicy: "reject-new",
});
await expect(
store.lookup(
bindingStoreKey({
kind: "conversation",
bindingId: legacyCodexConversationBindingId(transcriptPath),
}),
),
).resolves.toMatchObject({ state: "active", binding: { threadId: "thread-ambiguous" } });
await expect(fs.access(sidecarPath)).resolves.toBeUndefined();
const conversationKey = bindingStoreKey({
kind: "conversation",
bindingId: legacyCodexConversationBindingId(transcriptPath),
});
const imported = await store.lookup(conversationKey);
if (imported?.state !== "active") {
throw new Error("missing imported Codex conversation binding");
}
await store.register(conversationKey, {
...imported,
binding: { ...imported.binding, threadId: "thread-recovered" },
});
await expect(
migration.migrateLegacyState({
config,
env,
stateDir,
oauthDir: path.join(stateDir, "oauth"),
context: createDoctorContext(env),
}),
).resolves.toEqual({
changes: [],
warnings: [expect.stringContaining("session owner could not be resolved")],
});
await expect(store.lookup(conversationKey)).resolves.toMatchObject({
state: "active",
binding: { threadId: "thread-recovered" },
});
await fs.writeFile(
path.join(sessionsDir, "sessions.json"),
JSON.stringify({
"agent:alpha:ambiguous": {
sessionId: "session-ambiguous",
sessionFile: "ambiguous.jsonl",
totalTokens: 12_345,
totalTokensFresh: true,
contextTokens: 128_000,
updatedAt: Date.now(),
},
}),
"utf8",
);
await expect(
migration.migrateLegacyState({
config,
env,
stateDir,
oauthDir: path.join(stateDir, "oauth"),
context: createDoctorContext(env),
}),
).resolves.toMatchObject({
changes: [expect.stringContaining("Migrated 1")],
warnings: [],
});
await expect(
store.lookup(
bindingStoreKey({
kind: "session",
agentId: "alpha",
sessionId: "session-ambiguous",
sessionKey: "agent:alpha:ambiguous",
}),
),
).resolves.toMatchObject({
state: "active",
sessionId: "session-ambiguous",
binding: { threadId: "thread-recovered" },
});
await expect(store.lookup(conversationKey)).resolves.toMatchObject({
state: "active",
binding: {
threadId: "thread-recovered",
},
});
await expect(store.lookup(conversationKey)).resolves.not.toHaveProperty(
"binding.nativeContextUsage",
);
await expect(fs.access(`${sidecarPath}.migrated`)).resolves.toBeUndefined();
await fs.rm(stateDir, { recursive: true, force: true });
});
it("uses canonical custom-store, agent, and nested transcript path resolution", async () => {
const stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-doctor-"));
const customStoreRoot = await fs.mkdtemp(
path.join(os.tmpdir(), "openclaw-codex-custom-store-"),
);
const env = { ...process.env, HOME: stateDir, OPENCLAW_STATE_DIR: stateDir };
const config = {
agents: { list: [{ id: "alpha" }] },
session: { store: path.join(customStoreRoot, "{agentId}", "sessions.json") },
};
const sessionsDir = path.join(customStoreRoot, "alpha");
const transcriptPath = path.join(sessionsDir, "nested", "session-custom.jsonl");
const sidecarPath = `${transcriptPath}.codex-app-server.json`;
await fs.mkdir(path.dirname(transcriptPath), { recursive: true });
await fs.writeFile(transcriptPath, '{"type":"session","id":"session-custom"}\n', "utf8");
await fs.writeFile(
path.join(sessionsDir, "sessions.json"),
JSON.stringify({
"agent:alpha:custom": {
sessionId: "session-custom",
sessionFile: "nested/session-custom.jsonl",
updatedAt: Date.now(),
},
}),
"utf8",
);
await fs.writeFile(
sidecarPath,
JSON.stringify({ schemaVersion: 1, threadId: "thread-custom" }),
"utf8",
);
const unrelatedSidecar = path.join(
customStoreRoot,
"unrelated",
`not-a-session.jsonl.codex-app-server.json`,
);
await fs.mkdir(path.dirname(unrelatedSidecar), { recursive: true });
await fs.writeFile(
unrelatedSidecar,
JSON.stringify({ schemaVersion: 1, threadId: "unrelated-thread" }),
"utf8",
);
const migration = stateMigrations[0];
if (!migration) {
throw new Error("missing Codex binding migration");
}
await migration.migrateLegacyState({
config,
env,
stateDir,
oauthDir: path.join(stateDir, "oauth"),
context: createDoctorContext(env),
});
const store = createDoctorContext(env).openPluginStateKeyedStore<StoredCodexAppServerBinding>({
namespace: CODEX_APP_SERVER_BINDING_NAMESPACE,
maxEntries: CODEX_APP_SERVER_BINDING_MAX_ENTRIES,
overflowPolicy: "reject-new",
});
await expect(
store.lookup(
bindingStoreKey({
kind: "session",
agentId: "alpha",
sessionId: "session-custom",
sessionKey: "agent:alpha:custom",
}),
),
).resolves.toMatchObject({
state: "active",
sessionId: "session-custom",
binding: { threadId: "thread-custom" },
});
await expect(
store.lookup(
bindingStoreKey({
kind: "conversation",
bindingId: legacyCodexConversationBindingId(transcriptPath),
}),
),
).resolves.toMatchObject({
state: "active",
binding: { threadId: "thread-custom" },
});
await expect(fs.access(unrelatedSidecar)).resolves.toBeUndefined();
await fs.rm(stateDir, { recursive: true, force: true });
await fs.rm(customStoreRoot, { recursive: true, force: true });
original.plugins.entries.codex.config.codexPlugins.plugins["google-calendar"]
.allow_destructive_actions,
).toBe("on-request");
});
});

View File

@@ -1,4 +1,7 @@
/** Doctor contract hooks for Codex config, state migration, and route ownership. */
/**
* Doctor contract hooks for Codex plugin config migrations and session-route
* ownership warnings.
*/
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import type { DoctorSessionRouteStateOwner } from "openclaw/plugin-sdk/runtime-doctor";
@@ -18,6 +21,20 @@ function hasRetiredDynamicToolsProfile(value: unknown): boolean {
return Object.hasOwn(asRecord(value) ?? {}, "codexDynamicToolsProfile");
}
function hasLegacyPluginDestructivePolicy(value: unknown): boolean {
const codexPlugins = asRecord(value);
if (!codexPlugins) {
return false;
}
if (codexPlugins.allow_destructive_actions === "on-request") {
return true;
}
const plugins = asRecord(codexPlugins.plugins);
return Object.values(plugins ?? {}).some(
(plugin) => asRecord(plugin)?.allow_destructive_actions === "on-request",
);
}
/** Legacy Codex config keys that doctor should report or repair. */
export const legacyConfigRules: LegacyConfigRule[] = [
{
@@ -26,35 +43,70 @@ export const legacyConfigRules: LegacyConfigRule[] = [
'plugins.entries.codex.config.codexDynamicToolsProfile is retired; Codex app-server always keeps Codex-native workspace tools native. Run "openclaw doctor --fix".',
match: hasRetiredDynamicToolsProfile,
},
{
path: ["plugins", "entries", "codex", "config", "codexPlugins"],
message:
'plugins.entries.codex.config.codexPlugins.allow_destructive_actions="on-request" was renamed to "auto". Run "openclaw doctor --fix".',
match: hasLegacyPluginDestructivePolicy,
},
];
/** Removes retired Codex plugin config keys while preserving unrelated config. */
/**
* Removes retired Codex plugin config keys while preserving unrelated config.
*/
export function normalizeCompatibilityConfig({ cfg }: { cfg: OpenClawConfig }): {
config: OpenClawConfig;
changes: string[];
} {
const rawEntry = asRecord(cfg.plugins?.entries?.codex);
const rawPluginConfig = asRecord(rawEntry?.config);
if (!rawPluginConfig || !hasRetiredDynamicToolsProfile(rawPluginConfig)) {
const rawCodexPlugins = asRecord(rawPluginConfig?.codexPlugins);
const shouldRemoveDynamicToolsProfile =
rawPluginConfig !== null && hasRetiredDynamicToolsProfile(rawPluginConfig);
const shouldRewriteDestructivePolicy = hasLegacyPluginDestructivePolicy(rawCodexPlugins);
if (!rawPluginConfig || (!shouldRemoveDynamicToolsProfile && !shouldRewriteDestructivePolicy)) {
return { config: cfg, changes: [] };
}
const nextConfig = structuredClone(cfg) as OpenClawConfig & {
plugins?: Record<string, unknown>;
};
const nextPluginConfig = asRecord(
asRecord(asRecord(asRecord(nextConfig.plugins)?.entries)?.codex)?.config,
);
const nextPlugins = asRecord(nextConfig.plugins);
const nextEntries = asRecord(nextPlugins?.entries);
const nextEntry = asRecord(nextEntries?.codex);
const nextPluginConfig = asRecord(nextEntry?.config);
if (!nextPluginConfig) {
return { config: cfg, changes: [] };
}
delete nextPluginConfig.codexDynamicToolsProfile;
const changes: string[] = [];
if (shouldRemoveDynamicToolsProfile) {
delete nextPluginConfig.codexDynamicToolsProfile;
changes.push(
"Removed retired plugins.entries.codex.config.codexDynamicToolsProfile; Codex app-server always keeps Codex-native workspace tools native.",
);
}
if (shouldRewriteDestructivePolicy) {
const nextCodexPlugins = asRecord(nextPluginConfig.codexPlugins);
if (nextCodexPlugins?.allow_destructive_actions === "on-request") {
nextCodexPlugins.allow_destructive_actions = "auto";
}
const nextPluginPolicies = asRecord(nextCodexPlugins?.plugins);
for (const plugin of Object.values(nextPluginPolicies ?? {})) {
const nextPlugin = asRecord(plugin);
if (nextPlugin?.allow_destructive_actions === "on-request") {
nextPlugin.allow_destructive_actions = "auto";
}
}
changes.push(
'Renamed plugins.entries.codex.config.codexPlugins allow_destructive_actions="on-request" values to "auto".',
);
}
return {
config: nextConfig,
changes: [
"Removed retired plugins.entries.codex.config.codexDynamicToolsProfile; Codex app-server always keeps Codex-native workspace tools native.",
],
changes,
};
}
@@ -69,5 +121,3 @@ export const sessionRouteStateOwners: DoctorSessionRouteStateOwner[] = [
authProfilePrefixes: ["codex:", "codex-cli:", "openai-codex:"],
},
];
export { stateMigrations } from "./src/migration/session-binding-sidecars.js";

View File

@@ -1,18 +1,9 @@
// Codex tests cover harness plugin behavior.
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { createCodexAppServerAgentHarness } from "./harness.js";
import {
createCodexTestBindingStore,
testCodexAppServerBindingStore,
} from "./src/app-server/session-binding.test-helpers.js";
describe("Codex agent harness supports()", () => {
const harness = createCodexAppServerAgentHarness({
bindingStore: testCodexAppServerBindingStore,
});
const harness = createCodexAppServerAgentHarness();
it("supports the canonical codex virtual provider", () => {
expect(harness.supports({ provider: "codex", requestedRuntime: "codex" })).toEqual({
@@ -49,149 +40,8 @@ describe("Codex agent harness supports()", () => {
});
it("honors explicit provider id overrides", () => {
const narrowHarness = createCodexAppServerAgentHarness({
bindingStore: testCodexAppServerBindingStore,
providerIds: ["codex"],
});
const narrowHarness = createCodexAppServerAgentHarness({ providerIds: ["codex"] });
const result = narrowHarness.supports({ provider: "openai", requestedRuntime: "codex" });
expect(result.supported).toBe(false);
});
});
describe("Codex agent harness reset", () => {
it("uses the host agent for global session keys", async () => {
const bindingStore = createCodexTestBindingStore();
const harness = createCodexAppServerAgentHarness({ bindingStore });
const identity = {
kind: "session" as const,
agentId: "work",
sessionId: "session-1",
sessionKey: "global",
};
await bindingStore.mutate(identity, {
kind: "set",
binding: { threadId: "thread-work", cwd: "/repo" },
});
await harness.reset?.({
agentId: "work",
sessionId: "session-1",
sessionKey: "global",
reason: "reset",
});
await expect(bindingStore.read(identity)).resolves.toBeUndefined();
await expect(
bindingStore.mutate(identity, {
kind: "set",
binding: { threadId: "thread-stale", cwd: "/stale" },
}),
).resolves.toBe(false);
const nextIdentity = { ...identity, sessionId: "session-2" };
await expect(
bindingStore.mutate(nextIdentity, {
kind: "set",
binding: { threadId: "thread-next", cwd: "/next" },
}),
).resolves.toBe(false);
await expect(
bindingStore.mutate(nextIdentity, {
kind: "reclaim-generation",
expectedPreviousSessionId: identity.sessionId,
}),
).resolves.toBe(true);
await expect(
bindingStore.mutate(nextIdentity, {
kind: "set",
binding: { threadId: "thread-next", cwd: "/next" },
}),
).resolves.toBe(true);
await expect(bindingStore.read(nextIdentity)).resolves.toMatchObject({
threadId: "thread-next",
});
});
it("accepts an absent binding but rejects a mismatched reset generation", async () => {
const bindingStore = createCodexTestBindingStore();
const harness = createCodexAppServerAgentHarness({ bindingStore });
const current = {
kind: "session" as const,
agentId: "main",
sessionId: "session-1",
sessionKey: "agent:main:main",
};
await expect(
harness.reset?.({
agentId: "main",
sessionId: "missing-session",
sessionKey: "agent:main:missing",
reason: "reset",
}),
).resolves.toBeUndefined();
await bindingStore.mutate(current, {
kind: "set",
binding: { threadId: "thread-1", cwd: "/repo" },
});
await expect(
harness.reset?.({
agentId: "main",
sessionId: "session-2",
sessionKey: current.sessionKey,
reason: "reset",
}),
).rejects.toThrow("binding generation changed");
await expect(bindingStore.read(current)).resolves.toMatchObject({ threadId: "thread-1" });
});
it("reclaims a stale generation left while the Codex plugin was unavailable", async () => {
const stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-reset-"));
const storePath = path.join(stateDir, "sessions.json");
const sessionKey = "agent:main:main";
await fs.writeFile(
storePath,
JSON.stringify({
[sessionKey]: {
sessionId: "session-2",
updatedAt: Date.now(),
},
}),
"utf8",
);
const bindingStore = createCodexTestBindingStore();
const harness = createCodexAppServerAgentHarness({
bindingStore,
resolveConfig: () => ({ session: { store: storePath } }),
});
const stale = {
kind: "session" as const,
agentId: "main",
sessionId: "session-1",
sessionKey,
};
await bindingStore.mutate(stale, {
kind: "set",
binding: { threadId: "thread-stale", cwd: "/repo" },
});
await expect(
harness.reset?.({
agentId: "main",
sessionId: "session-2",
sessionKey,
reason: "reset",
}),
).resolves.toBeUndefined();
const current = { ...stale, sessionId: "session-2" };
await expect(bindingStore.read(current)).resolves.toBeUndefined();
await expect(
bindingStore.mutate(current, {
kind: "set",
binding: { threadId: "thread-delayed", cwd: "/repo" },
}),
).resolves.toBe(false);
await fs.rm(stateDir, { recursive: true, force: true });
});
});

View File

@@ -7,13 +7,11 @@ import type {
AgentHarnessCompactResult,
ContextEngineHostCapability,
} from "openclaw/plugin-sdk/agent-harness-runtime";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import type {
CodexAppServerListModelsOptions,
CodexAppServerModel,
CodexAppServerModelListResult,
} from "./src/app-server/models.js";
import type { CodexAppServerBindingStore } from "./src/app-server/session-binding.js";
const DEFAULT_CODEX_HARNESS_PROVIDER_IDS = new Set(["codex", "openai"]);
const CODEX_APP_SERVER_CONTEXT_ENGINE_HOST_CAPABILITIES = [
@@ -39,14 +37,12 @@ type CodexAppServerAgentHarness = AgentHarness & {
* Creates the Codex app-server harness used for attempts, side questions,
* compaction, reset, and disposal.
*/
export function createCodexAppServerAgentHarness(options: {
export function createCodexAppServerAgentHarness(options?: {
id?: string;
label?: string;
providerIds?: Iterable<string>;
pluginConfig?: unknown;
resolvePluginConfig?: () => unknown;
resolveConfig?: () => OpenClawConfig | undefined;
bindingStore: CodexAppServerBindingStore;
}): AgentHarness {
const providerIds = new Set(
[...(options?.providerIds ?? DEFAULT_CODEX_HARNESS_PROVIDER_IDS)].map((id) =>
@@ -75,7 +71,6 @@ export function createCodexAppServerAgentHarness(options: {
// cold provider catalog reads do not pull in the whole Codex runtime.
const { runCodexAppServerAttempt } = await import("./src/app-server/run-attempt.js");
return runCodexAppServerAttempt(params, {
bindingStore: options.bindingStore,
pluginConfig: options?.resolvePluginConfig?.() ?? options?.pluginConfig,
nativeHookRelay: { enabled: true },
});
@@ -83,7 +78,6 @@ export function createCodexAppServerAgentHarness(options: {
runSideQuestion: async (params) => {
const { runCodexAppServerSideQuestion } = await import("./src/app-server/side-question.js");
return runCodexAppServerSideQuestion(params, {
bindingStore: options.bindingStore,
pluginConfig: options?.resolvePluginConfig?.() ?? options?.pluginConfig,
nativeHookRelay: { enabled: true },
});
@@ -91,43 +85,20 @@ export function createCodexAppServerAgentHarness(options: {
compact: async (params) => {
const { maybeCompactCodexAppServerSession } = await import("./src/app-server/compact.js");
return maybeCompactCodexAppServerSession(params, {
bindingStore: options.bindingStore,
pluginConfig: options?.resolvePluginConfig?.() ?? options?.pluginConfig,
});
},
compactAfterContextEngine: async (params) => {
const { maybeCompactCodexAppServerSession } = await import("./src/app-server/compact.js");
return maybeCompactCodexAppServerSession(params, {
bindingStore: options.bindingStore,
pluginConfig: options?.resolvePluginConfig?.() ?? options?.pluginConfig,
allowNonManualNativeRequest: true,
});
},
reset: async (params) => {
if (params.sessionId) {
const { reclaimCurrentCodexSessionGeneration, sessionBindingIdentity } =
await import("./src/app-server/session-binding.js");
const identity = sessionBindingIdentity({
agentId: params.agentId,
sessionId: params.sessionId,
sessionKey: params.sessionKey,
});
let retired = await options.bindingStore.retireSessionGeneration(identity);
if (retired === "conflict") {
const reclaimed = await reclaimCurrentCodexSessionGeneration({
bindingStore: options.bindingStore,
identity,
config: options.resolveConfig?.(),
});
if (reclaimed) {
retired = await options.bindingStore.retireSessionGeneration(identity);
}
}
if (retired === "conflict") {
throw new Error(
`Codex binding generation changed before session ${params.sessionId} could reset`,
);
}
if (params.sessionFile) {
const { clearCodexAppServerBinding } = await import("./src/app-server/session-binding.js");
await clearCodexAppServerBinding(params.sessionFile);
}
},
dispose: async () => {

View File

@@ -4,30 +4,10 @@ import { createTestPluginApi } from "openclaw/plugin-sdk/plugin-test-api";
import { describe, expect, it, vi } from "vitest";
import { createCodexAppServerAgentHarness } from "./harness.js";
import plugin from "./index.js";
import {
createCodexAppServerBindingStore,
sessionBindingIdentity,
} from "./src/app-server/session-binding.js";
import {
createCodexTestBindingStateStore,
testCodexAppServerBindingStore,
} from "./src/app-server/session-binding.test-helpers.js";
const runCodexAppServerAttemptMock = vi.hoisted(() => vi.fn());
const runCodexAppServerSideQuestionMock = vi.hoisted(() => vi.fn());
function createCodexTestRuntime(
current?: () => unknown,
stateStore = createCodexTestBindingStateStore(),
) {
return {
...(current ? { config: { current } } : {}),
state: {
openSyncKeyedStore: () => stateStore,
},
} as never;
}
vi.mock("./src/app-server/run-attempt.js", () => ({
runCodexAppServerAttempt: runCodexAppServerAttemptMock,
}));
@@ -59,6 +39,7 @@ describe("codex plugin", () => {
const registerMigrationProvider = vi.fn();
const registerProvider = vi.fn();
const on = vi.fn();
const onConversationBindingResolved = vi.fn();
plugin.register(
createTestPluginApi({
@@ -67,13 +48,14 @@ describe("codex plugin", () => {
source: "test",
config: {},
pluginConfig: {},
runtime: createCodexTestRuntime(),
runtime: {} as never,
registerAgentHarness,
registerCommand,
registerMediaUnderstandingProvider,
registerMigrationProvider,
registerProvider,
on,
onConversationBindingResolved,
}),
);
@@ -83,6 +65,9 @@ describe("codex plugin", () => {
| Record<string, unknown>
| undefined;
const inboundClaimRegistration = mockCall(on) as [unknown, unknown] | undefined;
const bindingResolvedRegistration = mockCall(onConversationBindingResolved) as
| [unknown]
| undefined;
expect(providerRegistration.id).toBe("codex");
expect(providerRegistration.label).toBe("Codex");
@@ -109,12 +94,33 @@ describe("codex plugin", () => {
expect(migrationRegistration?.label).toBe("Codex");
expect(inboundClaimRegistration?.[0]).toBe("inbound_claim");
expect(typeof inboundClaimRegistration?.[1]).toBe("function");
expect(typeof bindingResolvedRegistration?.[0]).toBe("function");
});
it("registers with capture APIs that do not expose conversation binding hooks yet", () => {
const registerProvider = vi.fn();
const api = createTestPluginApi({
id: "codex",
name: "Codex",
source: "test",
config: {},
pluginConfig: {},
runtime: {} as never,
registerAgentHarness: vi.fn(),
registerCommand: vi.fn(),
registerMediaUnderstandingProvider: vi.fn(),
registerProvider,
on: vi.fn(),
});
delete (api as { onConversationBindingResolved?: unknown }).onConversationBindingResolved;
plugin.register(api);
expect(registerProvider).toHaveBeenCalledTimes(1);
expect((mockCallArg(registerProvider) as { id?: string } | undefined)?.id).toBe("codex");
});
it("claims the Codex routing providers by default", () => {
const harness = createCodexAppServerAgentHarness({
bindingStore: testCodexAppServerBindingStore,
});
const harness = createCodexAppServerAgentHarness();
expect(harness.deliveryDefaults?.sourceVisibleReplies).toBe("message_tool");
expect(
@@ -135,196 +141,8 @@ describe("codex plugin", () => {
expect(unsupported.supported).toBe(false);
});
it("clears only ended session binding rows in the owning agent scope", async () => {
const stateStore = createCodexTestBindingStateStore();
const bindingStore = createCodexAppServerBindingStore(stateStore);
const on = vi.fn();
plugin.register(
createTestPluginApi({
id: "codex",
name: "Codex",
source: "test",
config: {},
pluginConfig: {},
runtime: createCodexTestRuntime(undefined, stateStore),
registerAgentHarness: vi.fn(),
registerCommand: vi.fn(),
registerMediaUnderstandingProvider: vi.fn(),
registerMigrationProvider: vi.fn(),
registerProvider: vi.fn(),
on,
}),
);
const sessionEnd = on.mock.calls.find(([name]) => name === "session_end")?.[1] as
| ((
event: { sessionId: string; sessionKey?: string; reason?: string },
ctx: { agentId?: string; sessionId: string; sessionKey?: string },
) => Promise<void>)
| undefined;
if (!sessionEnd) {
throw new Error("missing Codex session_end hook");
}
const identity = sessionBindingIdentity({
agentId: "worker",
sessionId: "session-1",
sessionKey: "agent:worker:session-1",
});
const setBinding = () =>
bindingStore.mutate(identity, {
kind: "set",
binding: { threadId: "thread-1", cwd: "/repo" },
});
for (const reason of ["shutdown", "restart", "compaction", "unknown"] as const) {
await setBinding();
await sessionEnd(
{ sessionId: "session-1", sessionKey: "agent:worker:session-1", reason },
{ agentId: "worker", sessionId: "session-1" },
);
await expect(bindingStore.read(identity)).resolves.toMatchObject({
threadId: "thread-1",
});
}
for (const reason of ["new", "reset", "idle", "daily", "deleted"] as const) {
await setBinding();
await sessionEnd(
{ sessionId: "session-1", sessionKey: "agent:worker:session-1", reason },
{ agentId: "worker", sessionId: "session-1" },
);
await expect(bindingStore.read(identity)).resolves.toBeUndefined();
}
});
it("adopts compaction successors before delayed lifecycle cleanup", async () => {
const stateStore = createCodexTestBindingStateStore();
const bindingStore = createCodexAppServerBindingStore(stateStore);
const on = vi.fn();
plugin.register(
createTestPluginApi({
id: "codex",
name: "Codex",
source: "test",
config: {},
pluginConfig: {},
runtime: createCodexTestRuntime(undefined, stateStore),
registerAgentHarness: vi.fn(),
registerCommand: vi.fn(),
registerMediaUnderstandingProvider: vi.fn(),
registerMigrationProvider: vi.fn(),
registerProvider: vi.fn(),
on,
}),
);
const afterCompaction = on.mock.calls.find(([name]) => name === "after_compaction")?.[1] as
| ((
event: {
messageCount: number;
compactedCount: number;
previousSessionId?: string;
},
ctx: { agentId?: string; sessionId?: string; sessionKey?: string },
) => Promise<void>)
| undefined;
const sessionEnd = on.mock.calls.find(([name]) => name === "session_end")?.[1] as
| ((
event: { sessionId: string; sessionKey?: string; reason?: string },
ctx: { agentId?: string; sessionId: string; sessionKey?: string },
) => Promise<void>)
| undefined;
if (!afterCompaction || !sessionEnd) {
throw new Error("missing Codex compaction lifecycle hooks");
}
const sessionKey = "agent:worker:telegram:chat-1";
const previous = sessionBindingIdentity({
agentId: "worker",
sessionId: "session-1",
sessionKey,
});
const successor = sessionBindingIdentity({
agentId: "worker",
sessionId: "session-2",
sessionKey,
});
const newest = sessionBindingIdentity({
agentId: "worker",
sessionId: "session-3",
sessionKey,
});
await bindingStore.mutate(previous, {
kind: "set",
binding: { threadId: "thread-1", cwd: "/repo" },
});
await afterCompaction(
{ messageCount: 1, compactedCount: 1, previousSessionId: "session-1" },
{ agentId: "worker", sessionId: "session-2", sessionKey },
);
await expect(bindingStore.read(previous)).resolves.toBeUndefined();
await expect(bindingStore.read(successor)).resolves.toMatchObject({ threadId: "thread-1" });
await afterCompaction(
{ messageCount: 1, compactedCount: 1, previousSessionId: "session-2" },
{ agentId: "worker", sessionId: "session-3", sessionKey },
);
await afterCompaction(
{ messageCount: 1, compactedCount: 1, previousSessionId: "session-1" },
{ agentId: "worker", sessionId: "session-2", sessionKey },
);
await expect(bindingStore.read(successor)).resolves.toBeUndefined();
await expect(bindingStore.read(newest)).resolves.toMatchObject({ threadId: "thread-1" });
await sessionEnd(
{ sessionId: "session-1", sessionKey, reason: "reset" },
{ agentId: "worker", sessionId: "session-1", sessionKey },
);
await sessionEnd(
{ sessionId: "session-2", sessionKey, reason: "compaction" },
{ agentId: "worker", sessionId: "session-2", sessionKey },
);
await expect(bindingStore.read(newest)).resolves.toMatchObject({ threadId: "thread-1" });
expect(stateStore.entries()).toHaveLength(1);
});
it("ignores compaction for a session without a Codex binding", async () => {
const warn = vi.fn();
const on = vi.fn();
plugin.register(
createTestPluginApi({
id: "codex",
name: "Codex",
source: "test",
config: {},
pluginConfig: {},
logger: { debug: vi.fn(), info: vi.fn(), warn, error: vi.fn() },
runtime: createCodexTestRuntime(),
registerAgentHarness: vi.fn(),
registerCommand: vi.fn(),
registerMediaUnderstandingProvider: vi.fn(),
registerMigrationProvider: vi.fn(),
registerProvider: vi.fn(),
on,
}),
);
const afterCompaction = on.mock.calls.find(([name]) => name === "after_compaction")?.[1] as
| ((event: object, ctx: { sessionId?: string; sessionKey?: string }) => Promise<void>)
| undefined;
if (!afterCompaction) {
throw new Error("missing Codex after_compaction hook");
}
await afterCompaction(
{ previousSessionId: "session-1" },
{ sessionId: "session-2", sessionKey: "agent:main:main" },
);
expect(warn).not.toHaveBeenCalled();
});
it("enables the native hook relay for public Codex app-server attempts", async () => {
const harness = createCodexAppServerAgentHarness({
bindingStore: testCodexAppServerBindingStore,
pluginConfig: { appServer: {} },
});
const harness = createCodexAppServerAgentHarness({ pluginConfig: { appServer: {} } });
const result = { success: true };
runCodexAppServerAttemptMock.mockResolvedValueOnce(result);
@@ -333,7 +151,6 @@ describe("codex plugin", () => {
expect(runCodexAppServerAttemptMock).toHaveBeenCalledWith(
{ prompt: "hello" },
{
bindingStore: testCodexAppServerBindingStore,
pluginConfig: { appServer: {} },
nativeHookRelay: { enabled: true },
},
@@ -368,7 +185,11 @@ describe("codex plugin", () => {
source: "test",
config: {},
pluginConfig: { codexPlugins: { enabled: false } },
runtime: createCodexTestRuntime(() => liveConfig),
runtime: {
config: {
current: () => liveConfig,
},
} as never,
registerAgentHarness,
registerCommand: vi.fn(),
registerMediaUnderstandingProvider: vi.fn(),
@@ -388,49 +209,14 @@ describe("codex plugin", () => {
expect(runCodexAppServerAttemptMock).toHaveBeenCalledWith(
{ prompt: "calendar" },
{
bindingStore: expect.any(Object),
pluginConfig: liveConfig.plugins.entries.codex.config,
nativeHookRelay: { enabled: true },
},
);
});
it("does not resurrect startup Codex config after the live entry is removed", async () => {
const registerAgentHarness = vi.fn();
plugin.register(
createTestPluginApi({
id: "codex",
name: "Codex",
source: "test",
config: {},
pluginConfig: { appServer: { mode: "yolo" } },
runtime: createCodexTestRuntime(() => ({ plugins: { entries: {} } })),
registerAgentHarness,
registerCommand: vi.fn(),
registerMediaUnderstandingProvider: vi.fn(),
registerMigrationProvider: vi.fn(),
registerProvider: vi.fn(),
on: vi.fn(),
}),
);
const harness = mockCallArg(registerAgentHarness) as ReturnType<
typeof createCodexAppServerAgentHarness
>;
runCodexAppServerAttemptMock.mockResolvedValueOnce({ success: true });
await harness.runAttempt({ prompt: "default policy" } as never);
expect(runCodexAppServerAttemptMock).toHaveBeenCalledWith(
{ prompt: "default policy" },
expect.objectContaining({ pluginConfig: undefined }),
);
});
it("enables the native hook relay for public Codex side questions", async () => {
const harness = createCodexAppServerAgentHarness({
bindingStore: testCodexAppServerBindingStore,
pluginConfig: { appServer: {} },
});
const harness = createCodexAppServerAgentHarness({ pluginConfig: { appServer: {} } });
const runSideQuestion = harness["runSideQuestion"];
const result = { text: "ok" };
runCodexAppServerSideQuestionMock.mockResolvedValueOnce(result);
@@ -443,7 +229,6 @@ describe("codex plugin", () => {
expect(runCodexAppServerSideQuestionMock).toHaveBeenCalledWith(
{ question: "btw" },
{
bindingStore: testCodexAppServerBindingStore,
pluginConfig: { appServer: {} },
nativeHookRelay: { enabled: true },
},

View File

@@ -4,71 +4,47 @@
*/
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { mutateConfigFile } from "openclaw/plugin-sdk/config-mutation";
import {
resolveLivePluginConfigObject,
resolvePluginConfigObject,
} from "openclaw/plugin-sdk/plugin-config-runtime";
import { resolveLivePluginConfigObject } from "openclaw/plugin-sdk/plugin-config-runtime";
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
import { createCodexAppServerAgentHarness } from "./harness.js";
import { buildCodexMediaUnderstandingProvider } from "./media-understanding-provider.js";
import { buildCodexProvider } from "./provider.js";
import {
CODEX_APP_SERVER_BINDING_MAX_ENTRIES,
CODEX_APP_SERVER_BINDING_NAMESPACE,
createLazyCodexAppServerBindingStore,
type StoredCodexAppServerBinding,
} from "./src/app-server/session-binding-store.js";
import type { CodexPluginsConfigBlock } from "./src/command-plugins-management.js";
import { createCodexCommand } from "./src/commands.js";
import {
handleCodexConversationBindingResolved,
handleCodexConversationInboundClaim,
} from "./src/conversation-binding.js";
import { buildCodexMigrationProvider } from "./src/migration/provider.js";
import {
createCodexCliSessionNodeHostCommands,
createCodexCliSessionNodeInvokePolicies,
} from "./src/node-cli-session-registration.js";
const ENDED_SESSION_REASONS: ReadonlySet<string> = new Set([
"new",
"reset",
"idle",
"daily",
"deleted",
]);
listCodexCliSessionsOnNode,
resumeCodexCliSessionOnNode,
resolveCodexCliSessionForBindingOnNode,
} from "./src/node-cli-sessions.js";
export default definePluginEntry({
id: "codex",
name: "Codex",
description: "Codex app-server harness and Codex-managed GPT model catalog.",
register(api) {
const runtimeConfigLoader = api.runtime.config?.current
? () => api.runtime.config?.current() as OpenClawConfig
: undefined;
const resolveCurrentConfig = () => runtimeConfigLoader?.();
const loadNodeCliSessions = () => import("./src/node-cli-sessions.js");
const resolveCurrentConfig = () =>
api.runtime.config?.current ? (api.runtime.config.current() as OpenClawConfig) : undefined;
const resolveCurrentPluginConfig = () =>
// Codex plugin config can change at runtime; resolve from live config for
// harness attempts and binding claims instead of keeping startup values.
resolveLivePluginConfigObject(
runtimeConfigLoader,
resolveCurrentConfig,
"codex",
api.pluginConfig as Record<string, unknown>,
);
const bindingStore = createLazyCodexAppServerBindingStore(
api.runtime.state.openSyncKeyedStore<StoredCodexAppServerBinding>({
namespace: CODEX_APP_SERVER_BINDING_NAMESPACE,
maxEntries: CODEX_APP_SERVER_BINDING_MAX_ENTRIES,
overflowPolicy: "reject-new",
}),
);
) ?? api.pluginConfig;
api.registerAgentHarness(
createCodexAppServerAgentHarness({
bindingStore,
resolveConfig: resolveCurrentConfig,
resolvePluginConfig: resolveCurrentPluginConfig,
}),
createCodexAppServerAgentHarness({ resolvePluginConfig: resolveCurrentPluginConfig }),
);
api.registerProvider(buildCodexProvider({ pluginConfig: api.pluginConfig }));
api.registerMediaUnderstandingProvider(
buildCodexMediaUnderstandingProvider({ resolvePluginConfig: resolveCurrentPluginConfig }),
buildCodexMediaUnderstandingProvider({ pluginConfig: api.pluginConfig }),
);
api.registerMigrationProvider(buildCodexMigrationProvider({ runtime: api.runtime }));
for (const command of createCodexCliSessionNodeHostCommands()) {
@@ -79,43 +55,43 @@ export default definePluginEntry({
}
api.registerCommand(
createCodexCommand({
resolvePluginConfig: resolveCurrentPluginConfig,
pluginConfig: api.pluginConfig,
deps: {
bindingStore,
listCodexCliSessionsOnNode: async (params) =>
await (
await loadNodeCliSessions()
).listCodexCliSessionsOnNode({
runtime: api.runtime,
...params,
}),
resolveCodexCliSessionForBindingOnNode: async (params) =>
await (
await loadNodeCliSessions()
).resolveCodexCliSessionForBindingOnNode({
runtime: api.runtime,
...params,
}),
listCodexCliSessionsOnNode: (params) =>
listCodexCliSessionsOnNode({ runtime: api.runtime, ...params }),
resolveCodexCliSessionForBindingOnNode: (params) =>
resolveCodexCliSessionForBindingOnNode({ runtime: api.runtime, ...params }),
codexPluginsManagementIo: {
readConfig: () => {
const current = (api.runtime.config?.current?.() ?? {}) as OpenClawConfig;
const codexPlugins = resolvePluginConfigObject(current, "codex")?.codexPlugins;
if (
!codexPlugins ||
typeof codexPlugins !== "object" ||
Array.isArray(codexPlugins)
) {
const plugins = (current as Record<string, unknown>).plugins;
if (!plugins || typeof plugins !== "object") {
return Promise.resolve({});
}
const block = codexPlugins as Record<string, unknown>;
const declared = block.plugins;
const entries = (plugins as Record<string, unknown>).entries;
if (!entries || typeof entries !== "object") {
return Promise.resolve({});
}
const codexEntry = (entries as Record<string, unknown>).codex;
if (!codexEntry || typeof codexEntry !== "object") {
return Promise.resolve({});
}
const config = (codexEntry as Record<string, unknown>).config;
if (!config || typeof config !== "object") {
return Promise.resolve({});
}
const codexPlugins = (config as Record<string, unknown>).codexPlugins;
if (!codexPlugins || typeof codexPlugins !== "object") {
return Promise.resolve({});
}
const declared = (codexPlugins as Record<string, unknown>).plugins;
if (!declared || typeof declared !== "object") {
return Promise.resolve({
enabled: block.enabled === true,
enabled: (codexPlugins as Record<string, unknown>).enabled === true,
});
}
return Promise.resolve({
enabled: block.enabled === true,
enabled: (codexPlugins as Record<string, unknown>).enabled === true,
plugins: declared as Record<string, never>,
});
},
@@ -125,12 +101,17 @@ export default definePluginEntry({
// Create the nested plugin config path on demand so codex
// plugin commands can enable/update Codex-managed plugins.
const root = draft as Record<string, unknown>;
const pluginsBlock = (root.plugins ??= {}) as Record<string, unknown>;
const entries = (pluginsBlock.entries ??= {}) as Record<string, unknown>;
const codexEntry = (entries.codex ??= {}) as Record<string, unknown>;
const config = (codexEntry.config ??= {}) as Record<string, unknown>;
const codexPlugins = (config.codexPlugins ??= {}) as Record<string, unknown>;
codexPlugins.plugins ??= {};
root.plugins = (root.plugins ?? {}) as Record<string, unknown>;
const pluginsBlock = root.plugins as Record<string, unknown>;
pluginsBlock.entries = (pluginsBlock.entries ?? {}) as Record<string, unknown>;
const entries = pluginsBlock.entries as Record<string, unknown>;
entries.codex = (entries.codex ?? {}) as Record<string, unknown>;
const codexEntry = entries.codex as Record<string, unknown>;
codexEntry.config = (codexEntry.config ?? {}) as Record<string, unknown>;
const config = codexEntry.config as Record<string, unknown>;
config.codexPlugins = (config.codexPlugins ?? {}) as Record<string, unknown>;
const codexPlugins = config.codexPlugins as Record<string, unknown>;
codexPlugins.plugins = (codexPlugins.plugins ?? {}) as Record<string, unknown>;
update(codexPlugins as CodexPluginsConfigBlock);
},
});
@@ -139,58 +120,14 @@ export default definePluginEntry({
},
}),
);
api.on("inbound_claim", async (event, ctx) => {
const { handleCodexConversationInboundClaim } = await import("./src/conversation-binding.js");
return await handleCodexConversationInboundClaim(event, ctx, {
bindingStore,
api.on("inbound_claim", (event, ctx) =>
handleCodexConversationInboundClaim(event, ctx, {
pluginConfig: resolveCurrentPluginConfig(),
config: resolveCurrentConfig(),
resumeCodexCliSessionOnNode: async (params) =>
await (
await loadNodeCliSessions()
).resumeCodexCliSessionOnNode({
runtime: api.runtime,
...params,
}),
});
});
api.on("after_compaction", async (event, ctx) => {
const previousSessionId = event.previousSessionId?.trim();
const sessionId = ctx.sessionId?.trim();
if (!previousSessionId || !sessionId || previousSessionId === sessionId) {
return;
}
const config = resolveCurrentConfig();
const sessionKey = ctx.sessionKey?.trim();
const { sessionBindingIdentity } = await import("./src/app-server/session-binding.js");
const identity = sessionBindingIdentity({
sessionId,
...(sessionKey ? { sessionKey } : {}),
...(ctx.agentId ? { agentId: ctx.agentId } : {}),
...(config ? { config } : {}),
});
const adopted = await bindingStore.adoptSessionGeneration(identity, previousSessionId);
if (adopted === "conflict") {
api.logger.warn?.(
`codex: could not adopt compacted session generation ${sessionId} (${adopted}); secondary native compaction will skip`,
);
}
});
api.on("session_end", async (event, ctx) => {
if (!event.reason || !ENDED_SESSION_REASONS.has(event.reason)) {
return;
}
const sessionKey = event.sessionKey ?? ctx.sessionKey;
const config = resolveCurrentConfig();
const { sessionBindingIdentity } = await import("./src/app-server/session-binding.js");
await bindingStore.retireSessionGeneration(
sessionBindingIdentity({
sessionId: event.sessionId,
...(sessionKey ? { sessionKey } : {}),
...(ctx.agentId ? { agentId: ctx.agentId } : {}),
...(config ? { config } : {}),
}),
);
});
resumeCodexCliSessionOnNode: (params) =>
resumeCodexCliSessionOnNode({ runtime: api.runtime, ...params }),
}),
);
api.onConversationBindingResolved?.(handleCodexConversationBindingResolved);
},
});

View File

@@ -2,25 +2,8 @@
import { MAX_TIMER_TIMEOUT_MS } from "openclaw/plugin-sdk/number-runtime";
import { afterEach, describe, expect, it, vi } from "vitest";
import { buildCodexMediaUnderstandingProvider } from "./media-understanding-provider.js";
import { CodexAppServerRpcError, type CodexAppServerClient } from "./src/app-server/client.js";
import type { CodexAppServerClient } from "./src/app-server/client.js";
import type { CodexServerNotification, JsonValue } from "./src/app-server/protocol.js";
import { adaptCodexTestClientFactory } from "./src/app-server/test-support.js";
const EXPECTED_MEDIA_THREAD_CONFIG = {
project_doc_max_bytes: 0,
web_search: "disabled",
"tools.experimental_request_user_input.enabled": false,
"features.hooks": false,
"features.multi_agent": false,
"features.apps": false,
"features.plugins": false,
"features.image_generation": false,
"features.skill_mcp_dependency_install": false,
"features.memories": false,
"features.goals": false,
"features.code_mode": false,
"features.code_mode_only": false,
};
const sharedClientMocks = vi.hoisted(() => ({
createIsolatedCodexAppServerClient: vi.fn(),
@@ -102,15 +85,13 @@ function createFakeClient(options?: {
inputModalities?: string[];
completeWithItems?: boolean;
notifyError?: string;
approvalRequestMethod?: string;
responseText?: string;
turnStartError?: Error;
preBindNotificationCount?: number;
interruptError?: Error;
unsubscribeError?: Error;
}) {
const notifications = new Set<(notification: CodexServerNotification) => void>();
const closeHandlers = new Set<() => void>();
const requestHandlers = new Set<(request: { method: string }) => JsonValue | undefined>();
const requests: Array<{ method: string; params?: JsonValue }> = [];
const approvalResponses: JsonValue[] = [];
const request = vi.fn(async (method: string, params?: JsonValue) => {
requests.push({ method, params });
if (method === "model/list") {
@@ -123,60 +104,51 @@ function createFakeClient(options?: {
return threadStartResult();
}
if (method === "turn/start") {
if (options?.turnStartError) {
throw options.turnStartError;
}
if (options?.preBindNotificationCount) {
for (let index = 0; index < options.preBindNotificationCount; index += 1) {
for (const notify of notifications) {
notify({
method: "item/started",
params: { threadId: "thread-1", turnId: "turn-1" },
});
if (options?.approvalRequestMethod) {
for (const handler of requestHandlers) {
const response = handler({ method: options.approvalRequestMethod });
if (response !== undefined) {
approvalResponses.push(response);
}
}
return turnStartResult();
}
const emitTurnNotifications = () => {
if (options?.notifyError) {
for (const notify of notifications) {
notify({
method: "error",
params: {
threadId: "thread-1",
turnId: "turn-1",
error: {
message: options.notifyError,
codexErrorInfo: null,
additionalDetails: null,
},
willRetry: false,
if (options?.notifyError) {
for (const notify of notifications) {
notify({
method: "error",
params: {
threadId: "thread-1",
turnId: "turn-1",
error: {
message: options.notifyError,
codexErrorInfo: null,
additionalDetails: null,
},
});
}
} else if (!options?.completeWithItems) {
for (const notify of notifications) {
notify({
method: "item/agentMessage/delta",
params: {
threadId: "thread-1",
turnId: "turn-1",
itemId: "msg-1",
delta: options?.responseText ?? "A red square.",
},
});
notify({
method: "turn/completed",
params: {
threadId: "thread-1",
turnId: "turn-1",
turn: turnStartResult("completed").turn,
},
});
}
willRetry: false,
},
});
}
};
emitTurnNotifications();
} else if (!options?.completeWithItems) {
for (const notify of notifications) {
notify({
method: "item/agentMessage/delta",
params: {
threadId: "thread-1",
turnId: "turn-1",
itemId: "msg-1",
delta: options?.responseText ?? "A red square.",
},
});
notify({
method: "turn/completed",
params: {
threadId: "thread-1",
turnId: "turn-1",
turn: turnStartResult("completed").turn,
},
});
}
}
return turnStartResult(
options?.completeWithItems ? "completed" : "inProgress",
options?.completeWithItems
@@ -192,12 +164,6 @@ function createFakeClient(options?: {
: [],
);
}
if (method === "turn/interrupt" && options?.interruptError) {
throw options.interruptError;
}
if (method === "thread/unsubscribe" && options?.unsubscribeError) {
throw options.unsubscribeError;
}
return {};
});
@@ -207,17 +173,14 @@ function createFakeClient(options?: {
notifications.add(handler);
return () => notifications.delete(handler);
},
addRequestHandler() {
return () => undefined;
},
addCloseHandler(handler: () => void) {
closeHandlers.add(handler);
return () => closeHandlers.delete(handler);
addRequestHandler(handler: (request: { method: string }) => JsonValue | undefined) {
requestHandlers.add(handler);
return () => requestHandlers.delete(handler);
},
close: vi.fn(),
} as unknown as CodexAppServerClient;
return { client, requests };
return { client, requests, approvalResponses };
}
describe("codex media understanding provider", () => {
@@ -229,9 +192,11 @@ describe("codex media understanding provider", () => {
it("runs image understanding through a bounded Codex app-server turn", async () => {
const { client, requests } = createFakeClient();
const clientFactory = vi.fn(async () => client);
const clientFactory = vi.fn(
async (_startOptions, _authProfileId, _agentDir, _config) => client,
);
const provider = buildCodexMediaUnderstandingProvider({
clientLeaseFactory: adaptCodexTestClientFactory(clientFactory),
clientFactory,
});
const cfg = {
auth: {
@@ -254,33 +219,35 @@ describe("codex media understanding provider", () => {
});
expect(result).toEqual({ text: "A red square.", model: "gpt-5.4" });
expect(requests.map((entry) => entry.method)).toEqual([
"model/list",
"thread/start",
"turn/start",
]);
expect(clientFactory).toHaveBeenCalledWith(
expect.any(Object),
undefined,
"/tmp/openclaw-agent",
cfg,
expect.objectContaining({ timeoutMs: 30_000 }),
);
expect(requests.map((entry) => entry.method)).toEqual([
"model/list",
"thread/start",
"turn/start",
"thread/unsubscribe",
]);
expect(requests[0]?.params).toEqual({ limit: 100, cursor: null, includeHidden: true });
expect(requests[1]?.params).toEqual({
model: "gpt-5.4",
modelProvider: "openai",
cwd: "/tmp/openclaw-agent/codex-media-home",
approvalPolicy: "never",
cwd: "/tmp/openclaw-agent",
approvalPolicy: "on-request",
sandbox: "read-only",
serviceName: "OpenClaw",
personality: "none",
developerInstructions:
"You are OpenClaw's bounded image-understanding worker. Describe only the provided image content. Do not call tools, edit files, or ask follow-up questions.",
config: EXPECTED_MEDIA_THREAD_CONFIG,
config: {
"features.code_mode": false,
"features.code_mode_only": false,
},
environments: [],
dynamicTools: [],
experimentalRawEvents: true,
ephemeral: true,
persistExtendedHistory: false,
});
expect(requests[2]?.params).toEqual({
threadId: "thread-1",
@@ -288,6 +255,9 @@ describe("codex media understanding provider", () => {
{ type: "text", text: "Describe briefly.", text_elements: [] },
{ type: "image", url: "data:image/png;base64,aW1hZ2UtYnl0ZXM=" },
],
cwd: "/tmp/openclaw-agent",
approvalPolicy: "on-request",
model: "gpt-5.4",
effort: "low",
});
});
@@ -295,12 +265,8 @@ describe("codex media understanding provider", () => {
it("treats a blank agent directory as absent when starting the app-server", async () => {
const { client, requests } = createFakeClient();
const clientFactory = vi.fn(async () => client);
const provider = buildCodexMediaUnderstandingProvider({
clientLeaseFactory: adaptCodexTestClientFactory(clientFactory),
});
const cfg = {
agents: { list: [{ id: "main", agentDir: "/tmp/openclaw-default-agent" }] },
};
const provider = buildCodexMediaUnderstandingProvider({ clientFactory });
const cfg = {};
await provider.describeImage?.({
buffer: Buffer.from("image-bytes"),
@@ -313,16 +279,9 @@ describe("codex media understanding provider", () => {
agentDir: " ",
});
expect(clientFactory).toHaveBeenCalledWith(
expect.any(Object),
undefined,
"/tmp/openclaw-default-agent",
cfg,
expect.any(Object),
);
expect(requests[1]?.params).toEqual(
expect.objectContaining({ cwd: "/tmp/openclaw-default-agent/codex-media-home" }),
);
expect(clientFactory).toHaveBeenCalledWith(expect.any(Object), undefined, undefined, cfg);
expect(requests[1]?.params).toEqual(expect.objectContaining({ cwd: process.cwd() }));
expect(requests[2]?.params).toEqual(expect.objectContaining({ cwd: process.cwd() }));
});
it("passes the scoped auth store into isolated app-server startup", async () => {
@@ -364,7 +323,7 @@ describe("codex media understanding provider", () => {
try {
const { client } = createFakeClient();
const provider = buildCodexMediaUnderstandingProvider({
clientLeaseFactory: adaptCodexTestClientFactory(async () => client),
clientFactory: async () => client,
});
const result = await provider.describeImage?.({
@@ -387,97 +346,33 @@ describe("codex media understanding provider", () => {
}
});
it("starts the media deadline before client acquisition", async () => {
vi.useFakeTimers();
it("declines approval requests during image understanding", async () => {
const { client, approvalResponses } = createFakeClient({
approvalRequestMethod: "item/permissions/requestApproval",
});
const provider = buildCodexMediaUnderstandingProvider({
clientLeaseFactory: adaptCodexTestClientFactory(
async () => await new Promise<CodexAppServerClient>(() => {}),
),
clientFactory: async () => client,
});
const description = provider.describeImage?.({
buffer: Buffer.from("image-bytes"),
fileName: "image.png",
mime: "image/png",
provider: "codex",
model: "gpt-5.4",
timeoutMs: 100,
cfg: {},
agentDir: "/tmp/openclaw-agent",
});
const rejected = expect(description).rejects.toThrow(
"Codex app-server image understanding timed out",
);
await vi.advanceTimersByTimeAsync(100);
await rejected;
});
it("retires a media client lease that resolves after its deadline", async () => {
let resolveLease!: (lease: {
client: CodexAppServerClient;
release: () => void;
abandon: () => Promise<void>;
}) => void;
const pendingLease = new Promise<{
client: CodexAppServerClient;
release: () => void;
abandon: () => Promise<void>;
}>((resolve) => {
resolveLease = resolve;
});
const clientLeaseFactory = vi.fn(async () => await pendingLease);
const provider = buildCodexMediaUnderstandingProvider({ clientLeaseFactory });
const description = provider.describeImage?.({
buffer: Buffer.from("image-bytes"),
fileName: "image.png",
mime: "image/png",
provider: "codex",
model: "gpt-5.4",
timeoutMs: 5,
cfg: {},
agentDir: "/tmp/openclaw-agent",
});
await expect(description).rejects.toThrow("Codex app-server image understanding timed out");
const { client } = createFakeClient();
const release = vi.fn();
const abandon = vi.fn(async () => undefined);
resolveLease({ client, release, abandon });
await vi.waitFor(() => expect(abandon).toHaveBeenCalledOnce());
expect(release).not.toHaveBeenCalled();
});
it("releases the bounded route between isolated media calls", async () => {
const { client, requests } = createFakeClient();
const provider = buildCodexMediaUnderstandingProvider({
clientLeaseFactory: adaptCodexTestClientFactory(async () => client),
});
const request = {
await provider.describeImage?.({
buffer: Buffer.from("image-bytes"),
fileName: "image.png",
mime: "image/png",
provider: "codex",
model: "gpt-5.4",
prompt: "Describe briefly.",
timeoutMs: 30_000,
cfg: {},
agentDir: "/tmp/openclaw-agent",
};
});
const first = await provider.describeImage?.(request);
const second = await provider.describeImage?.(request);
expect(first?.text).toBe("A red square.");
expect(second?.text).toBe("A red square.");
expect(requests.filter((entry) => entry.method === "model/list")).toHaveLength(2);
expect(requests.filter((entry) => entry.method === "thread/start")).toHaveLength(2);
expect(approvalResponses).toEqual([{ permissions: {}, scope: "turn" }]);
});
it("extracts text from terminal turn items", async () => {
const { client } = createFakeClient({ completeWithItems: true });
const provider = buildCodexMediaUnderstandingProvider({
clientLeaseFactory: adaptCodexTestClientFactory(async () => client),
clientFactory: async () => client,
});
const result = await provider.describeImages?.({
@@ -496,7 +391,7 @@ describe("codex media understanding provider", () => {
it("rejects text-only Codex app-server models before starting a turn", async () => {
const { client, requests } = createFakeClient({ inputModalities: ["text"] });
const provider = buildCodexMediaUnderstandingProvider({
clientLeaseFactory: adaptCodexTestClientFactory(async () => client),
clientFactory: async () => client,
});
await expect(
@@ -517,7 +412,7 @@ describe("codex media understanding provider", () => {
it("surfaces Codex app-server turn errors", async () => {
const { client } = createFakeClient({ notifyError: "vision unavailable" });
const provider = buildCodexMediaUnderstandingProvider({
clientLeaseFactory: adaptCodexTestClientFactory(async () => client),
clientFactory: async () => client,
});
await expect(
@@ -534,107 +429,12 @@ describe("codex media understanding provider", () => {
).rejects.toThrow("vision unavailable");
});
it.each([
{
name: "structured rejection",
error: new CodexAppServerRpcError({ message: "turn rejected" }, "turn/start"),
abandonCount: 0,
},
{
name: "ambiguous timeout",
error: new Error("turn/start timed out"),
abandonCount: 1,
},
])("handles $name with exact media lease ownership", async ({ error, abandonCount }) => {
const { client } = createFakeClient({ turnStartError: error });
const release = vi.fn();
const abandon = vi.fn(async () => undefined);
const provider = buildCodexMediaUnderstandingProvider({
clientLeaseFactory: async () => ({ client, release, abandon }),
});
await expect(
provider.describeImage?.({
buffer: Buffer.from("image-bytes"),
fileName: "image.png",
mime: "image/png",
provider: "codex",
model: "gpt-5.4",
timeoutMs: 30_000,
cfg: {},
agentDir: "/tmp/openclaw-agent",
}),
).rejects.toBe(error);
expect(abandon).toHaveBeenCalledTimes(abandonCount);
expect(release).toHaveBeenCalledTimes(1);
});
it("retires the media client when thread cleanup is unconfirmed", async () => {
const { client } = createFakeClient({ unsubscribeError: new Error("unsubscribe failed") });
const release = vi.fn();
const abandon = vi.fn(async () => undefined);
const provider = buildCodexMediaUnderstandingProvider({
clientLeaseFactory: async () => ({ client, release, abandon }),
});
await expect(
provider.describeImage?.({
buffer: Buffer.from("image-bytes"),
fileName: "image.png",
mime: "image/png",
provider: "codex",
model: "gpt-5.4",
timeoutMs: 30_000,
cfg: {},
agentDir: "/tmp/openclaw-agent",
}),
).resolves.toEqual({ text: "A red square.", model: "gpt-5.4" });
expect(abandon).toHaveBeenCalledOnce();
expect(release).not.toHaveBeenCalled();
});
it("retires the media client when an accepted turn cannot be interrupted", async () => {
const { client, requests } = createFakeClient({
preBindNotificationCount: 257,
interruptError: new Error("interrupt timeout"),
});
const release = vi.fn();
const abandon = vi.fn(async () => undefined);
const provider = buildCodexMediaUnderstandingProvider({
clientLeaseFactory: async () => ({ client, release, abandon }),
});
await expect(
provider.describeImage?.({
buffer: Buffer.from("image-bytes"),
fileName: "image.png",
mime: "image/png",
provider: "codex",
model: "gpt-5.4",
timeoutMs: 30_000,
cfg: {},
agentDir: "/tmp/openclaw-agent",
}),
).rejects.toThrow("pre-bind notification buffer exceeded 256 entries");
expect(requests.map((entry) => entry.method)).toEqual([
"model/list",
"thread/start",
"turn/start",
"turn/interrupt",
]);
expect(abandon).toHaveBeenCalledOnce();
expect(release).not.toHaveBeenCalled();
});
it("runs structured extraction through the same bounded Codex app-server path", async () => {
const { client, requests } = createFakeClient({
responseText: '{"summary":"red square","tags":["shape"]}',
});
const provider = buildCodexMediaUnderstandingProvider({
clientLeaseFactory: adaptCodexTestClientFactory(async () => client),
clientFactory: async () => client,
});
const result = await provider.extractStructured?.({
@@ -675,21 +475,25 @@ describe("codex media understanding provider", () => {
"model/list",
"thread/start",
"turn/start",
"thread/unsubscribe",
]);
expect(requests[1]?.params).toEqual({
model: "gpt-5.4",
modelProvider: "openai",
cwd: "/tmp/openclaw-agent/codex-media-home",
approvalPolicy: "never",
cwd: "/tmp/openclaw-agent",
approvalPolicy: "on-request",
sandbox: "read-only",
serviceName: "OpenClaw",
personality: "none",
developerInstructions:
"You are OpenClaw's bounded structured-extraction worker. Return only the requested extraction. Do not call tools, edit files, ask follow-up questions, or include secrets.",
config: EXPECTED_MEDIA_THREAD_CONFIG,
config: {
"features.code_mode": false,
"features.code_mode_only": false,
},
environments: [],
dynamicTools: [],
experimentalRawEvents: true,
ephemeral: true,
persistExtendedHistory: false,
});
const turnParams = requests[2]?.params as
| {
@@ -702,9 +506,9 @@ describe("codex media understanding provider", () => {
}
| undefined;
expect(turnParams?.threadId).toBe("thread-1");
expect(turnParams?.approvalPolicy).toBeUndefined();
expect(turnParams?.model).toBeUndefined();
expect(turnParams?.cwd).toBeUndefined();
expect(turnParams?.approvalPolicy).toBe("on-request");
expect(turnParams?.model).toBe("gpt-5.4");
expect(turnParams?.cwd).toBe("/tmp/openclaw-agent");
expect(turnParams?.effort).toBe("low");
expect(turnParams?.input).toHaveLength(3);
expect(turnParams?.input?.[0]?.type).toBe("text");
@@ -727,7 +531,7 @@ describe("codex media understanding provider", () => {
responseText: '{"summary":"only text"}',
});
const provider = buildCodexMediaUnderstandingProvider({
clientLeaseFactory: adaptCodexTestClientFactory(async () => client),
clientFactory: async () => client,
});
await expect(
@@ -747,7 +551,7 @@ describe("codex media understanding provider", () => {
it("returns a controlled error when structured JSON parsing fails", async () => {
const { client } = createFakeClient({ responseText: "not json" });
const provider = buildCodexMediaUnderstandingProvider({
clientLeaseFactory: adaptCodexTestClientFactory(async () => client),
clientFactory: async () => client,
});
await expect(
@@ -776,7 +580,7 @@ describe("codex media understanding provider", () => {
responseText: '{"summary":123,"tags":["shape"]}',
});
const provider = buildCodexMediaUnderstandingProvider({
clientLeaseFactory: adaptCodexTestClientFactory(async () => client),
clientFactory: async () => client,
});
await expect(

View File

@@ -1,35 +1,549 @@
/** Lazy registration facade for Codex-backed media understanding. */
import type { MediaUnderstandingProvider } from "openclaw/plugin-sdk/media-understanding";
/**
* Codex-backed media understanding provider for bounded image description and
* structured extraction turns.
*/
import {
type JsonSchemaObject,
validateJsonSchemaValue,
} from "openclaw/plugin-sdk/json-schema-runtime";
import type {
ImagesDescriptionRequest,
ImagesDescriptionResult,
MediaUnderstandingProvider,
StructuredExtractionRequest,
StructuredExtractionResult,
} from "openclaw/plugin-sdk/media-understanding";
import { resolveTimerTimeoutMs } from "openclaw/plugin-sdk/number-runtime";
import { CODEX_PROVIDER_ID, FALLBACK_CODEX_MODELS } from "./provider-catalog.js";
import type { CodexAppServerClientLeaseFactory } from "./src/app-server/shared-client.js";
import type { CodexAppServerClientFactory } from "./src/app-server/client-factory.js";
import type { CodexAppServerClient } from "./src/app-server/client.js";
import { resolveCodexAppServerRuntimeOptions } from "./src/app-server/config.js";
import { readModelListResult } from "./src/app-server/models.js";
import {
assertCodexThreadStartResponse,
assertCodexTurnStartResponse,
readCodexErrorNotification,
readCodexTurnCompletedNotification,
} from "./src/app-server/protocol-validators.js";
import {
isJsonObject,
type CodexServerNotification,
type CodexThreadItem,
type CodexThreadStartParams,
type CodexTurn,
type CodexTurnStartParams,
type CodexUserInput,
type JsonObject,
type JsonValue,
} from "./src/app-server/protocol.js";
import { buildCodexRuntimeThreadConfig } from "./src/app-server/thread-lifecycle.js";
const DEFAULT_CODEX_IMAGE_MODEL =
FALLBACK_CODEX_MODELS.find((model) => model.inputModalities.includes("image"))?.id ??
FALLBACK_CODEX_MODELS[0]?.id;
const DEFAULT_CODEX_IMAGE_PROMPT = "Describe the image.";
/** Dependencies and plugin config for Codex media-understanding calls. */
export type CodexMediaUnderstandingProviderOptions = {
pluginConfig?: unknown;
resolvePluginConfig?: () => unknown;
clientLeaseFactory?: CodexAppServerClientLeaseFactory;
clientFactory?: CodexAppServerClientFactory;
};
/** Builds a provider whose app-server implementation loads on first use. */
/**
* Builds the media-understanding provider that delegates image tasks to an
* isolated Codex app-server session.
*/
export function buildCodexMediaUnderstandingProvider(
options: CodexMediaUnderstandingProviderOptions = {},
): MediaUnderstandingProvider {
let runtime: Promise<typeof import("./src/media-understanding-provider.runtime.js")> | undefined;
const load = () => (runtime ??= import("./src/media-understanding-provider.runtime.js"));
return {
id: CODEX_PROVIDER_ID,
capabilities: ["image"],
...(DEFAULT_CODEX_IMAGE_MODEL ? { defaultModels: { image: DEFAULT_CODEX_IMAGE_MODEL } } : {}),
describeImage: async ({ buffer, fileName, mime, ...request }) =>
await (
await load()
).describeCodexImages({ ...request, images: [{ buffer, fileName, mime }] }, options),
describeImages: async (request) => await (await load()).describeCodexImages(request, options),
extractStructured: async (request) =>
await (await load()).extractCodexStructured(request, options),
describeImage: async (req) =>
describeCodexImages(
{
images: [
{
buffer: req.buffer,
fileName: req.fileName,
mime: req.mime,
},
],
provider: req.provider,
model: req.model,
prompt: req.prompt,
maxTokens: req.maxTokens,
timeoutMs: req.timeoutMs,
profile: req.profile,
preferredProfile: req.preferredProfile,
authStore: req.authStore,
agentDir: req.agentDir,
cfg: req.cfg,
},
options,
),
describeImages: async (req) => describeCodexImages(req, options),
extractStructured: async (req) => extractCodexStructured(req, options),
};
}
async function describeCodexImages(
req: ImagesDescriptionRequest,
options: CodexMediaUnderstandingProviderOptions,
): Promise<ImagesDescriptionResult> {
const model = req.model.trim();
if (!model) {
throw new Error("Codex image understanding requires model id.");
}
const text = await runBoundedCodexVisionTurn({
model,
profile: req.profile,
timeoutMs: req.timeoutMs,
agentDir: req.agentDir,
authStore: req.authStore,
cfg: req.cfg,
options,
taskLabel: "image understanding",
developerInstructions:
"You are OpenClaw's bounded image-understanding worker. Describe only the provided image content. Do not call tools, edit files, or ask follow-up questions.",
input: [
{ type: "text", text: buildCodexImagePrompt(req), text_elements: [] },
...req.images.map((image) => ({
type: "image" as const,
url: `data:${image.mime ?? "image/png"};base64,${image.buffer.toString("base64")}`,
})),
],
requiredModalities: ["text", "image"],
});
return { text, model };
}
type BoundedCodexVisionTurnParams = {
model: string;
profile?: string;
timeoutMs: number;
agentDir?: string;
authStore?: ImagesDescriptionRequest["authStore"];
cfg: ImagesDescriptionRequest["cfg"];
options: CodexMediaUnderstandingProviderOptions;
taskLabel: string;
developerInstructions: string;
input: CodexUserInput[];
requiredModalities: string[];
};
async function runBoundedCodexVisionTurn(params: BoundedCodexVisionTurnParams): Promise<string> {
const appServer = resolveCodexAppServerRuntimeOptions({
pluginConfig: params.options.pluginConfig,
});
const timeoutMs = resolveTimerTimeoutMs(params.timeoutMs, 100, 100);
const agentDir = params.agentDir?.trim() || undefined;
const cwd = agentDir ?? process.cwd();
const ownsClient = !params.options.clientFactory;
// Tests inject a client factory; production creates an isolated app-server
// client so media tasks cannot reuse the interactive attempt session.
const client = params.options.clientFactory
? await params.options.clientFactory(appServer.start, params.profile, agentDir, params.cfg)
: await import("./src/app-server/shared-client.js").then(
({ createIsolatedCodexAppServerClient }) =>
createIsolatedCodexAppServerClient({
startOptions: appServer.start,
timeoutMs,
authProfileId: params.profile,
agentDir,
authProfileStore: params.authStore,
config: params.cfg,
}),
);
const abortController = new AbortController();
const timeout = setTimeout(() => abortController.abort("timeout"), timeoutMs);
timeout.unref?.();
try {
await assertCodexModelSupportsInput({
client,
model: params.model,
requiredModalities: params.requiredModalities,
timeoutMs,
signal: abortController.signal,
});
const thread = assertCodexThreadStartResponse(
await client.request<unknown>(
"thread/start",
{
model: params.model,
modelProvider: "openai",
cwd,
approvalPolicy: "on-request",
sandbox: "read-only",
serviceName: "OpenClaw",
developerInstructions: params.developerInstructions,
// Media workers are bounded read-only turns; native code mode and
// dynamic tools stay disabled to avoid side effects while inspecting media.
config: buildCodexRuntimeThreadConfig(undefined, { nativeCodeModeEnabled: false }),
environments: [],
dynamicTools: [],
experimentalRawEvents: true,
persistExtendedHistory: false,
ephemeral: true,
} satisfies CodexThreadStartParams,
{ timeoutMs, signal: abortController.signal },
),
);
const collector = createCodexTurnCollector(thread.thread.id, params.taskLabel);
const cleanup = client.addNotificationHandler(collector.handleNotification);
const requestCleanup = client.addRequestHandler(denyCodexImageApprovalRequest);
try {
const turn = assertCodexTurnStartResponse(
await client.request<unknown>(
"turn/start",
{
threadId: thread.thread.id,
input: params.input,
cwd,
approvalPolicy: "on-request",
model: params.model,
effort: "low",
} satisfies CodexTurnStartParams,
{ timeoutMs, signal: abortController.signal },
),
);
const text = await collector.collect(turn.turn, {
timeoutMs,
signal: abortController.signal,
});
return text;
} finally {
requestCleanup();
cleanup();
}
} finally {
clearTimeout(timeout);
if (ownsClient) {
client.close();
}
}
}
async function extractCodexStructured(
req: StructuredExtractionRequest,
options: CodexMediaUnderstandingProviderOptions,
): Promise<StructuredExtractionResult> {
const model = req.model.trim();
if (!model) {
throw new Error("Codex structured extraction requires model id.");
}
const instructions = req.instructions.trim();
if (!instructions) {
throw new Error("Codex structured extraction requires instructions.");
}
if (req.input.length === 0) {
throw new Error("Codex structured extraction requires at least one input.");
}
if (!req.input.some((entry) => entry.type === "image")) {
throw new Error("Codex structured extraction requires at least one image input.");
}
const text = await runBoundedCodexVisionTurn({
model,
profile: req.profile,
timeoutMs: req.timeoutMs,
agentDir: req.agentDir,
authStore: req.authStore,
cfg: req.cfg,
options,
taskLabel: "structured extraction",
developerInstructions:
"You are OpenClaw's bounded structured-extraction worker. Return only the requested extraction. Do not call tools, edit files, ask follow-up questions, or include secrets.",
input: buildCodexStructuredInput(req),
requiredModalities: requiredStructuredModalities(),
});
return normalizeStructuredExtractionResult({ text, model, provider: req.provider, req });
}
function denyCodexImageApprovalRequest(request: { method: string }): JsonValue | undefined {
if (
request.method === "item/commandExecution/requestApproval" ||
request.method === "item/fileChange/requestApproval"
) {
return {
decision: "decline",
reason: "OpenClaw Codex image understanding does not grant tool or file approvals.",
};
}
if (request.method === "item/permissions/requestApproval") {
return { permissions: {}, scope: "turn" };
}
if (request.method.includes("requestApproval")) {
return {
decision: "decline",
reason: "OpenClaw Codex image understanding does not grant native approvals.",
};
}
if (request.method === "mcpServer/elicitation/request") {
return { action: "decline" };
}
return undefined;
}
async function assertCodexModelSupportsInput(params: {
client: CodexAppServerClient;
model: string;
requiredModalities: string[];
timeoutMs: number;
signal: AbortSignal;
}): Promise<void> {
const result = await params.client.request<unknown>(
"model/list",
{ limit: 100, cursor: null, includeHidden: false },
{ timeoutMs: Math.min(params.timeoutMs, 5_000), signal: params.signal },
);
const listed = readModelListResult(result).models;
const match = listed.find((entry) => entry.model === params.model || entry.id === params.model);
if (!match) {
throw new Error(`Codex app-server model not found: ${params.model}`);
}
if (params.requiredModalities.includes("image") && !match.inputModalities.includes("image")) {
throw new Error(`Codex app-server model does not support images: ${params.model}`);
}
if (params.requiredModalities.includes("text") && !match.inputModalities.includes("text")) {
throw new Error(`Codex app-server model does not support text: ${params.model}`);
}
}
function buildCodexImagePrompt(req: ImagesDescriptionRequest): string {
const prompt = req.prompt?.trim() || DEFAULT_CODEX_IMAGE_PROMPT;
if (req.images.length <= 1) {
return prompt;
}
return `${prompt}\n\nAnalyze all ${req.images.length} images together.`;
}
function requiredStructuredModalities(): string[] {
return ["text", "image"];
}
function buildCodexStructuredInput(req: StructuredExtractionRequest): CodexUserInput[] {
return [
{ type: "text", text: buildStructuredExtractionPrompt(req), text_elements: [] },
...req.input.map((entry) => {
if (entry.type === "text") {
return { type: "text" as const, text: entry.text, text_elements: [] };
}
return {
type: "image" as const,
url: `data:${entry.mime ?? "image/png"};base64,${entry.buffer.toString("base64")}`,
};
}),
];
}
function buildStructuredExtractionPrompt(req: StructuredExtractionRequest): string {
return [
req.instructions.trim(),
req.schemaName ? `Schema name: ${req.schemaName}` : undefined,
req.jsonSchema ? `JSON schema:\n${JSON.stringify(req.jsonSchema)}` : undefined,
req.jsonMode === false
? "Return the extraction as concise text."
: "Return valid JSON only. Do not wrap the JSON in Markdown fences.",
]
.filter((part): part is string => Boolean(part))
.join("\n\n");
}
function isJsonSchemaObject(value: unknown): value is JsonSchemaObject {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function normalizeStructuredExtractionResult(params: {
text: string;
model: string;
provider: string;
req: StructuredExtractionRequest;
}): StructuredExtractionResult {
const result: StructuredExtractionResult = {
text: params.text,
model: params.model,
provider: params.provider,
contentType: params.req.jsonMode === false ? "text" : "json",
};
if (params.req.jsonMode !== false) {
try {
result.parsed = JSON.parse(params.text);
} catch {
throw new Error("Codex structured extraction returned invalid JSON.");
}
if (isJsonSchemaObject(params.req.jsonSchema)) {
const validation = validateJsonSchemaValue({
schema: params.req.jsonSchema,
cacheKey: "codex.media-understanding.extractStructured",
value: result.parsed,
cache: false,
});
if (!validation.ok) {
const message = validation.errors.map((error) => error.text).join("; ") || "invalid";
throw new Error(`Codex structured extraction JSON did not match schema: ${message}`);
}
result.parsed = validation.value;
}
}
return result;
}
function createCodexTurnCollector(threadId: string, taskLabel: string) {
let turnId: string | undefined;
let completedTurn: CodexTurn | undefined;
let promptError: string | undefined;
const pending: CodexServerNotification[] = [];
const assistantTextByItem = new Map<string, string>();
const assistantItemOrder: string[] = [];
let resolveCompletion: (() => void) | undefined;
const completion = new Promise<void>((resolve) => {
resolveCompletion = resolve;
});
const rememberAssistantText = (itemId: string, text: string) => {
if (!text) {
return;
}
if (!assistantTextByItem.has(itemId)) {
assistantItemOrder.push(itemId);
}
assistantTextByItem.set(itemId, text);
};
const handleNotification = (notification: CodexServerNotification): void => {
const params = isJsonObject(notification.params) ? notification.params : undefined;
if (!params || readString(params, "threadId") !== threadId) {
return;
}
if (!turnId) {
pending.push(notification);
return;
}
const notificationTurnId = readNotificationTurnId(params);
if (notificationTurnId !== turnId) {
return;
}
if (notification.method === "item/agentMessage/delta") {
const itemId = readString(params, "itemId") ?? readString(params, "id") ?? "assistant";
const delta = readString(params, "delta") ?? "";
rememberAssistantText(itemId, `${assistantTextByItem.get(itemId) ?? ""}${delta}`);
return;
}
if (notification.method === "turn/completed") {
completedTurn =
readCodexTurnCompletedNotification(notification.params)?.turn ?? completedTurn;
resolveCompletion?.();
return;
}
if (notification.method === "error") {
promptError =
readCodexErrorNotification(notification.params)?.error.message ??
`codex app-server ${taskLabel} turn failed`;
resolveCompletion?.();
}
};
return {
handleNotification,
async collect(
startedTurn: CodexTurn,
options: { timeoutMs: number; signal: AbortSignal },
): Promise<string> {
turnId = startedTurn.id;
if (isTerminalTurn(startedTurn)) {
completedTurn = startedTurn;
}
for (const notification of pending.splice(0)) {
handleNotification(notification);
}
if (!completedTurn && !promptError) {
await waitForTurnCompletion({
completion,
timeoutMs: options.timeoutMs,
signal: options.signal,
taskLabel,
});
}
if (promptError) {
throw new Error(promptError);
}
if (completedTurn?.status === "failed") {
throw new Error(
completedTurn.error?.message ?? `codex app-server ${taskLabel} turn failed`,
);
}
const itemText = collectAssistantTextFromItems(completedTurn?.items);
const deltaText = assistantItemOrder
.map((itemId) => assistantTextByItem.get(itemId)?.trim())
.filter((text): text is string => Boolean(text))
.join("\n\n")
.trim();
const text = (itemText || deltaText).trim();
if (!text) {
throw new Error(`Codex app-server ${taskLabel} turn returned no text.`);
}
return text;
},
};
}
async function waitForTurnCompletion(params: {
completion: Promise<void>;
timeoutMs: number;
signal: AbortSignal;
taskLabel: string;
}): Promise<void> {
let timeout: ReturnType<typeof setTimeout> | undefined;
let cleanupAbort: (() => void) | undefined;
try {
await Promise.race([
params.completion,
new Promise<never>((_, reject) => {
timeout = setTimeout(
() => reject(new Error(`codex app-server ${params.taskLabel} turn timed out`)),
params.timeoutMs,
);
timeout.unref?.();
const abortListener = () =>
reject(new Error(`codex app-server ${params.taskLabel} turn aborted`));
params.signal.addEventListener("abort", abortListener, { once: true });
cleanupAbort = () => params.signal.removeEventListener("abort", abortListener);
}),
]);
} finally {
if (timeout) {
clearTimeout(timeout);
}
cleanupAbort?.();
}
}
function collectAssistantTextFromItems(items: CodexThreadItem[] | undefined): string {
return (items ?? [])
.filter((item) => item.type === "agentMessage")
.map((item) => item.text.trim())
.filter(Boolean)
.join("\n\n")
.trim();
}
function readNotificationTurnId(record: JsonObject): string | undefined {
const direct = readString(record, "turnId");
if (direct) {
return direct;
}
return isJsonObject(record.turn) ? readString(record.turn, "id") : undefined;
}
function readString(record: JsonObject, key: string): string | undefined {
const value = record[key];
return typeof value === "string" ? value : undefined;
}
function isTerminalTurn(turn: CodexTurn): boolean {
return turn.status === "completed" || turn.status === "interrupted" || turn.status === "failed";
}

View File

@@ -1,12 +1,12 @@
{
"name": "@openclaw/codex",
"version": "2026.6.2",
"version": "2026.6.15-alpha.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@openclaw/codex",
"version": "2026.6.2",
"version": "2026.6.15-alpha.1",
"dependencies": {
"@openai/codex": "0.139.0",
"typebox": "1.1.39",

View File

@@ -100,7 +100,7 @@
"default": false
},
"allow_destructive_actions": {
"type": "boolean",
"oneOf": [{ "type": "boolean" }, { "const": "auto" }],
"default": true
},
"plugins": {
@@ -120,7 +120,7 @@
"type": "string"
},
"allow_destructive_actions": {
"type": "boolean"
"oneOf": [{ "type": "boolean" }, { "const": "auto" }]
}
}
}
@@ -290,7 +290,7 @@
},
"codexPlugins.allow_destructive_actions": {
"label": "Allow Destructive Plugin Actions",
"help": "Default policy for plugin app write or destructive action elicitations. Defaults to true.",
"help": "Default policy for plugin app write or destructive action elicitations. Use true to accept safe schemas without prompting, false to decline, or auto to ask through plugin approvals.",
"advanced": true
},
"codexPlugins.plugins": {

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/codex",
"version": "2026.6.2",
"version": "2026.6.15-alpha.1",
"description": "OpenClaw Codex app-server harness and model provider plugin with a Codex-managed GPT catalog.",
"repository": {
"type": "git",
@@ -26,10 +26,10 @@
"minHostVersion": ">=2026.5.1-beta.1"
},
"compat": {
"pluginApi": ">=2026.6.2"
"pluginApi": ">=2026.6.15-alpha.1"
},
"build": {
"openclawVersion": "2026.6.2"
"openclawVersion": "2026.6.15-alpha.1"
},
"release": {
"publishToClawHub": true,

View File

@@ -4,10 +4,10 @@ import { CODEX_GPT5_BEHAVIOR_CONTRACT } from "./prompt-overlay.js";
import { codexProviderDiscovery } from "./provider-discovery.js";
import { buildCodexProvider, buildCodexProviderCatalog } from "./provider.js";
import { CodexAppServerClient } from "./src/app-server/client.js";
import type { listAllCodexAppServerModels } from "./src/app-server/models.js";
import type { listCodexAppServerModels } from "./src/app-server/models.js";
import {
createIsolatedCodexAppServerClient,
leaseSharedCodexAppServerClient,
getSharedCodexAppServerClient,
resetSharedCodexAppServerClientForTests,
} from "./src/app-server/shared-client.js";
@@ -26,8 +26,7 @@ function createFakeCodexClient(): CodexAppServerClient {
return {
initialize: vi.fn(async () => undefined),
request: vi.fn(async () => ({ data: [] })),
addNotificationHandler: vi.fn(() => () => undefined),
addRequestHandler: vi.fn(() => () => undefined),
setActiveSharedLeaseCountProviderForUnscopedNotifications: vi.fn(),
addCloseHandler: vi.fn(() => () => undefined),
close: vi.fn(),
} as unknown as CodexAppServerClient;
@@ -40,7 +39,7 @@ const TEST_CODEX_APP_SERVER_CONFIG = {
};
async function listTestCodexAppServerModels(
options: Parameters<typeof listAllCodexAppServerModels>[0] = {},
options: Parameters<typeof listCodexAppServerModels>[0] = {},
) {
expect(options.sharedClient).toBe(false);
const client = await createIsolatedCodexAppServerClient({
@@ -184,33 +183,45 @@ describe("codex provider", () => {
expect(resultProvider?.models.map((model) => model.id)).toEqual(["gpt-5.4"]);
});
it("delegates all-page discovery to one model lister call", async () => {
const listModels = vi.fn(async () => ({
models: [
{
id: "gpt-5.4",
model: "gpt-5.4",
hidden: false,
inputModalities: ["text", "image"],
supportedReasoningEfforts: ["medium"],
},
{
id: "gpt-5.5",
model: "gpt-5.5",
hidden: false,
inputModalities: ["text"],
supportedReasoningEfforts: [],
},
],
}));
it("pages through live discovery before building the provider catalog", async () => {
const listModels = vi
.fn()
.mockResolvedValueOnce({
models: [
{
id: "gpt-5.4",
model: "gpt-5.4",
hidden: false,
inputModalities: ["text", "image"],
supportedReasoningEfforts: ["medium"],
},
],
nextCursor: "page-2",
})
.mockResolvedValueOnce({
models: [
{
id: "gpt-5.5",
model: "gpt-5.5",
hidden: false,
inputModalities: ["text"],
supportedReasoningEfforts: [],
},
],
});
const result = await buildCodexProviderCatalog({
env: {},
listModels,
});
expect(listModels).toHaveBeenCalledTimes(1);
expectRecordFields(mockCallArg(listModels, 0), {
cursor: undefined,
limit: 100,
sharedClient: false,
});
expectRecordFields(mockCallArg(listModels, 1), {
cursor: "page-2",
limit: 100,
sharedClient: false,
});
@@ -266,7 +277,7 @@ describe("codex provider", () => {
.mockReturnValueOnce(activeClient)
.mockReturnValueOnce(discoveryClient);
await leaseSharedCodexAppServerClient({
await getSharedCodexAppServerClient({
startOptions: {
transport: "stdio",
command: "/tmp/openclaw-test-codex",

View File

@@ -18,11 +18,16 @@ import {
CODEX_PROVIDER_ID,
FALLBACK_CODEX_MODELS,
} from "./provider-catalog.js";
import type { CodexAppServerStartOptions } from "./src/app-server/config.js";
import {
type CodexAppServerStartOptions,
readCodexPluginConfig,
resolveCodexAppServerRuntimeOptions,
} from "./src/app-server/config.js";
import type {
CodexAppServerModel,
CodexAppServerModelListResult,
} from "./src/app-server/models.js";
import { buildCodexAppServerUsageSnapshot } from "./src/app-server/rate-limits.js";
const DEFAULT_DISCOVERY_TIMEOUT_MS = 2500;
const LIVE_DISCOVERY_ENV = "OPENCLAW_CODEX_DISCOVERY_LIVE";
@@ -34,6 +39,7 @@ const codexCatalogLog = createSubsystemLogger("codex/catalog");
type CodexModelLister = (options: {
timeoutMs: number;
limit?: number;
cursor?: string;
startOptions?: CodexAppServerStartOptions;
sharedClient?: boolean;
}) => Promise<CodexAppServerModelListResult>;
@@ -117,11 +123,6 @@ export function buildCodexProvider(options: BuildCodexProviderOptions = {}): Pro
}
const runtimePluginConfig = resolvePluginConfigObject(ctx.config, CODEX_PROVIDER_ID);
const pluginConfig = runtimePluginConfig ?? (ctx.config ? undefined : options.pluginConfig);
const [{ resolveCodexAppServerRuntimeOptions }, { buildCodexAppServerUsageSnapshot }] =
await Promise.all([
import("./src/app-server/config.js"),
import("./src/app-server/rate-limits.js"),
]);
const appServer = resolveCodexAppServerRuntimeOptions({ pluginConfig });
const rateLimits = await (options.readRateLimits ?? requestCodexAppServerRateLimitsLazy)({
timeoutMs: ctx.timeoutMs,
@@ -155,15 +156,13 @@ export function buildCodexProvider(options: BuildCodexProviderOptions = {}): Pro
export async function buildCodexProviderCatalog(
options: BuildCatalogOptions = {},
): Promise<{ provider: ModelProviderConfig }> {
const { readCodexPluginConfig, resolveCodexAppServerRuntimeOptions } =
await import("./src/app-server/config.js");
const config = readCodexPluginConfig(options.pluginConfig);
const appServer = resolveCodexAppServerRuntimeOptions({ pluginConfig: options.pluginConfig });
const timeoutMs = normalizeTimeoutMs(config.discovery?.timeoutMs);
let discovered: CodexAppServerModel[] = [];
if (config.discovery?.enabled !== false && !shouldSkipLiveDiscovery(options.env)) {
discovered = await listModelsBestEffort({
listModels: options.listModels ?? listAllCodexAppServerModelsLazy,
listModels: options.listModels ?? listCodexAppServerModelsLazy,
timeoutMs,
startOptions: appServer.start,
onDiscoveryFailure: options.onDiscoveryFailure,
@@ -201,14 +200,22 @@ async function listModelsBestEffort(params: {
onDiscoveryFailure?: (error: unknown) => void;
}): Promise<CodexAppServerModel[]> {
try {
// The all-pages helper keeps one app-server client alive across pagination.
const result = await params.listModels({
timeoutMs: params.timeoutMs,
limit: MODEL_DISCOVERY_PAGE_LIMIT,
startOptions: params.startOptions,
sharedClient: false,
});
return result.models.filter((model) => !model.hidden);
const models: CodexAppServerModel[] = [];
let cursor: string | undefined;
do {
// App-server model listing is paginated; collect every visible model so
// aliases and picker rows match the current Codex account.
const result = await params.listModels({
timeoutMs: params.timeoutMs,
limit: MODEL_DISCOVERY_PAGE_LIMIT,
cursor,
startOptions: params.startOptions,
sharedClient: false,
});
models.push(...result.models.filter((model) => !model.hidden));
cursor = result.nextCursor;
} while (cursor);
return models;
} catch (error) {
params.onDiscoveryFailure?.(error);
codexCatalogLog.debug("codex model discovery failed; using fallback catalog", {
@@ -218,14 +225,15 @@ async function listModelsBestEffort(params: {
}
}
async function listAllCodexAppServerModelsLazy(options: {
async function listCodexAppServerModelsLazy(options: {
timeoutMs: number;
limit?: number;
cursor?: string;
startOptions?: CodexAppServerStartOptions;
sharedClient?: boolean;
}): Promise<CodexAppServerModelListResult> {
const { listAllCodexAppServerModels } = await import("./src/app-server/models.js");
return listAllCodexAppServerModels(options);
const { listCodexAppServerModels } = await import("./src/app-server/models.js");
return listCodexAppServerModels(options);
}
async function requestCodexAppServerRateLimitsLazy(options: {

View File

@@ -1,6 +1,9 @@
// Codex tests cover app server policy plugin behavior.
import { describe, expect, it } from "vitest";
import { resolveCodexAppServerForOpenClawToolPolicy } from "./app-server-policy.js";
import {
resolveCodexAppServerForModelProvider,
resolveCodexAppServerForOpenClawToolPolicy,
} from "./app-server-policy.js";
import { readCodexPluginConfig, resolveCodexAppServerRuntimeOptions } from "./config.js";
describe("Codex app-server policy", () => {
@@ -66,4 +69,143 @@ describe("Codex app-server policy", () => {
expect(explicitEnv.approvalPolicy).toBe("never");
expect(explicitRequirements.approvalPolicy).toBe("never");
});
it("keeps model-backed reviewers for explicit OpenAI model providers", () => {
const appServer = resolveCodexAppServerRuntimeOptions({
env: {},
requirementsToml: null,
execMode: "auto",
modelProvider: "openai",
});
expect(
resolveCodexAppServerForModelProvider({
appServer,
provider: "codex",
model: "openai/gpt-5.5",
}).approvalsReviewer,
).toBe("auto_review");
expect(
resolveCodexAppServerForModelProvider({
appServer,
provider: "codex",
model: "gpt-5.5",
}).approvalsReviewer,
).toBe("user");
expect(
resolveCodexAppServerForModelProvider({ appServer, provider: "openai" }).approvalsReviewer,
).toBe("auto_review");
});
it("uses human approval for OpenAI-compatible custom endpoints", () => {
const appServer = resolveCodexAppServerRuntimeOptions({
env: {},
requirementsToml: null,
execMode: "auto",
modelProvider: "openai",
model: "gpt-5.5",
config: {
models: {
providers: {
openai: {
baseUrl: "http://localhost:8080/v1",
models: [],
},
},
},
},
});
expect(appServer.approvalsReviewer).toBe("user");
expect(
resolveCodexAppServerForModelProvider({
appServer,
provider: "openai",
model: "gpt-5.5",
config: {
models: {
providers: {
openai: {
baseUrl: "http://localhost:8080/v1",
models: [],
},
},
},
},
}).approvalsReviewer,
).toBe("user");
});
it("uses human approval instead of Codex Guardian for custom model providers", () => {
const appServer = resolveCodexAppServerRuntimeOptions({
env: {},
requirementsToml: null,
execMode: "auto",
modelProvider: "openai",
});
const resolved = resolveCodexAppServerForModelProvider({
appServer,
provider: "lmstudio",
});
const vendorPrefixedModel = resolveCodexAppServerForModelProvider({
appServer,
provider: "openrouter",
model: "openai/gpt-5.5",
});
expect(appServer.approvalsReviewer).toBe("auto_review");
expect(resolved.approvalPolicy).toBe("on-request");
expect(resolved.sandbox).toBe("workspace-write");
expect(resolved.approvalsReviewer).toBe("user");
expect(vendorPrefixedModel.approvalsReviewer).toBe("user");
});
it("infers custom providers from provider-qualified model refs", () => {
const appServer = resolveCodexAppServerRuntimeOptions({
env: {},
requirementsToml: null,
execMode: "auto",
});
expect(
resolveCodexAppServerForModelProvider({
appServer,
model: "lmstudio/local-model",
}).approvalsReviewer,
).toBe("user");
});
it("uses provider-qualified model refs to override broad native provider wrappers", () => {
const appServer = resolveCodexAppServerRuntimeOptions({
env: {},
requirementsToml: null,
execMode: "auto",
});
expect(
resolveCodexAppServerForModelProvider({
appServer,
provider: "codex",
model: "lmstudio/local-model",
}).approvalsReviewer,
).toBe("user");
});
it("downgrades legacy guardian_subagent for custom model providers", () => {
const appServer = resolveCodexAppServerRuntimeOptions({
env: {},
requirementsToml: null,
pluginConfig: {
appServer: {
mode: "guardian",
approvalsReviewer: "guardian_subagent",
},
},
});
expect(
resolveCodexAppServerForModelProvider({ appServer, provider: "local" }).approvalsReviewer,
).toBe("user");
});
});

View File

@@ -2,10 +2,11 @@
* Policy promotion for Codex app-server runs that can safely use OpenClaw tool
* approvals.
*/
import type {
CodexAppServerRuntimeOptions,
CodexPluginConfig,
OpenClawExecPolicyForCodexAppServer,
import {
canUseCodexModelBackedApprovalsReviewerForModel,
type CodexAppServerRuntimeOptions,
type CodexPluginConfig,
type OpenClawExecPolicyForCodexAppServer,
} from "./config.js";
/**
@@ -44,6 +45,35 @@ export function resolveCodexAppServerForOpenClawToolPolicy(params: {
};
}
export function resolveCodexAppServerForModelProvider(params: {
appServer: CodexAppServerRuntimeOptions;
provider?: string;
model?: string;
config?: Parameters<typeof canUseCodexModelBackedApprovalsReviewerForModel>[0]["config"];
env?: NodeJS.ProcessEnv;
agentDir?: string;
codexConfigToml?: string | null;
}): CodexAppServerRuntimeOptions {
const explicitProvider = normalizeModelBackedReviewerProvider(params.provider);
if (
!isCodexModelBackedApprovalsReviewer(params.appServer.approvalsReviewer) ||
canUseCodexModelBackedApprovalsReviewerForModel({
modelProvider: explicitProvider,
model: params.model,
config: params.config,
env: params.env,
agentDir: params.agentDir,
codexConfigToml: params.codexConfigToml,
})
) {
return params.appServer;
}
return {
...params.appServer,
approvalsReviewer: "user",
};
}
function isCodexAppServerPolicyMode(value: unknown): boolean {
return value === "guardian" || value === "yolo";
}
@@ -53,3 +83,12 @@ function isCodexAppServerApprovalPolicy(value: unknown): boolean {
value === "never" || value === "on-request" || value === "on-failure" || value === "untrusted"
);
}
function isCodexModelBackedApprovalsReviewer(value: string): boolean {
return value === "auto_review" || value === "guardian_subagent";
}
function normalizeModelBackedReviewerProvider(provider: string | undefined): string | undefined {
const normalized = provider?.trim().toLowerCase();
return normalized || undefined;
}

View File

@@ -285,7 +285,8 @@ function matchesCurrentTurn(
if (!requestParams) {
return false;
}
const requestThreadId = readString(requestParams, "threadId");
const requestThreadId =
readString(requestParams, "threadId") ?? readString(requestParams, "conversationId");
const requestTurnId = readString(requestParams, "turnId");
return requestThreadId === threadId && requestTurnId === turnId;
}

View File

@@ -2,41 +2,10 @@
import { describe, expect, it, vi } from "vitest";
import {
interruptCodexTurnBestEffort,
runCodexTurnStartWithLease,
settleCodexAppServerClientLease,
unsubscribeCodexThreadBestEffort,
validateCodexThreadCreationResponse,
} from "./attempt-client-cleanup.js";
import { CodexAppServerRpcError } from "./client.js";
describe("Codex app-server attempt client cleanup", () => {
it("keeps the client lease after a structured turn-start rejection", async () => {
const abandon = vi.fn(async () => undefined);
const error = new CodexAppServerRpcError({ message: "turn rejected" }, "turn/start");
await expect(
runCodexTurnStartWithLease({ abandon } as never, async () => {
throw error;
}),
).rejects.toBe(error);
expect(abandon).not.toHaveBeenCalled();
});
it("abandons only the exact client lease after an ambiguous turn-start timeout", async () => {
const abandon = vi.fn(async () => undefined);
const otherAbandon = vi.fn(async () => undefined);
await expect(
runCodexTurnStartWithLease({ abandon } as never, async () => {
throw new Error("turn/start timed out");
}),
).rejects.toThrow("turn/start timed out");
expect(abandon).toHaveBeenCalledTimes(1);
expect(otherAbandon).not.toHaveBeenCalled();
});
it("interrupts turns with optional request timeout", () => {
const request = vi.fn(async () => ({}));
@@ -53,58 +22,7 @@ describe("Codex app-server attempt client cleanup", () => {
);
});
it("unsubscribes a retained thread when its create response is malformed", async () => {
const request = vi.fn(async () => ({}));
const abandon = vi.fn(async () => undefined);
const invalidResponse = { thread: { id: "thread-1" } };
await expect(
validateCodexThreadCreationResponse(
{ client: { request } as never, abandon },
invalidResponse,
() => {
throw new Error("invalid thread/start response");
},
),
).rejects.toThrow("invalid thread/start response");
expect(request).toHaveBeenCalledWith(
"thread/unsubscribe",
{ threadId: "thread-1" },
{ timeoutMs: 5_000 },
);
expect(abandon).not.toHaveBeenCalled();
});
it.each([
["omits the retained thread id", {}, vi.fn(async () => ({}))],
[
"cannot confirm unsubscribe",
{ thread: { id: "thread-1" } },
vi.fn(async () => {
throw new Error("connection lost");
}),
],
])(
"retires the client when a malformed create response %s",
async (_label, response, request) => {
const abandon = vi.fn(async () => undefined);
await expect(
validateCodexThreadCreationResponse(
{ client: { request } as never, abandon },
response,
() => {
throw new Error("invalid thread/start response");
},
),
).rejects.toThrow("subscription could not be released");
expect(abandon).toHaveBeenCalledOnce();
},
);
it("reports unsubscribe cleanup failures", async () => {
it("swallows unsubscribe cleanup failures", async () => {
const request = vi.fn(async () => {
throw new Error("already gone");
});
@@ -114,7 +32,7 @@ describe("Codex app-server attempt client cleanup", () => {
threadId: "thread-1",
timeoutMs: 123,
}),
).resolves.toBe(false);
).resolves.toBeUndefined();
expect(request).toHaveBeenCalledWith(
"thread/unsubscribe",
@@ -122,31 +40,4 @@ describe("Codex app-server attempt client cleanup", () => {
{ timeoutMs: 123 },
);
});
it("returns leases only after thread cleanup is confirmed", async () => {
const release = vi.fn();
const abandon = vi.fn(async () => undefined);
await settleCodexAppServerClientLease(
{ client: { request: vi.fn(async () => ({})) }, release, abandon } as never,
{ threadId: "thread-ok", timeoutMs: 123 },
);
expect(release).toHaveBeenCalledOnce();
expect(abandon).not.toHaveBeenCalled();
release.mockClear();
await settleCodexAppServerClientLease(
{
client: {
request: vi.fn(async () => {
throw new Error("unsubscribe failed");
}),
},
release,
abandon,
} as never,
{ threadId: "thread-stale", timeoutMs: 123 },
);
expect(release).not.toHaveBeenCalled();
expect(abandon).toHaveBeenCalledOnce();
});
});

View File

@@ -2,126 +2,14 @@
* Best-effort cleanup helpers for timed-out or aborted Codex app-server turns.
*/
import { embeddedAgentLog } from "openclaw/plugin-sdk/agent-harness-runtime";
import { CodexAppServerRpcError, type CodexAppServerClient } from "./client.js";
import { isJsonObject, readCodexThreadCreationResponseId } from "./protocol.js";
import type { CodexAppServerClientLease } from "./shared-client.js";
import type { CodexAppServerClient } from "./client.js";
import { retireSharedCodexAppServerClientIfCurrent } from "./shared-client.js";
/** Timeout for best-effort app-server turn interruption during cleanup. */
export const CODEX_APP_SERVER_INTERRUPT_TIMEOUT_MS = 5_000;
/** Timeout for best-effort thread unsubscribe during cleanup. */
export const CODEX_APP_SERVER_UNSUBSCRIBE_TIMEOUT_MS = 5_000;
/** The connection's thread-subscription ownership can no longer be proven. */
export class CodexAppServerUnsafeSubscriptionError extends Error {
constructor(message: string, options?: ErrorOptions) {
super(message, options);
this.name = "CodexAppServerUnsafeSubscriptionError";
}
}
export function isCodexAppServerUnsafeSubscriptionError(
error: unknown,
): error is CodexAppServerUnsafeSubscriptionError {
return error instanceof CodexAppServerUnsafeSubscriptionError;
}
/** A resume response may only describe the thread this connection retained. */
export function assertCodexThreadResumeSubscription(
requestedThreadId: string,
returnedThreadId: string,
): void {
if (returnedThreadId !== requestedThreadId) {
throw new CodexAppServerUnsafeSubscriptionError(
`Codex thread/resume returned ${returnedThreadId} for ${requestedThreadId}`,
);
}
}
/** Retires the exact client lease when turn acceptance is ambiguous. */
export async function runCodexTurnStartWithLease<T>(
lease: CodexAppServerClientLease,
startTurn: () => Promise<T>,
): Promise<T> {
try {
return await startTurn();
} catch (error) {
// Structured RPC rejection happens before Codex accepts the turn. Transport,
// timeout, and abort failures may hide an accepted turn with an unknown id.
if (!(error instanceof CodexAppServerRpcError)) {
await lease.abandon();
}
throw error;
}
}
/** Retries once when native work wins the race immediately before turn/start. */
export async function runCodexTurnStartWithNativeTurnRetry<T>(params: {
startTurn: () => Promise<T>;
waitForActiveTurnCompletion: () => Promise<boolean>;
afterActiveTurnCompletion?: () => Promise<void>;
onRetry?: () => void;
}): Promise<T> {
try {
return await params.startTurn();
} catch (error) {
if (!isCodexActiveTurnNotSteerableError(error)) {
throw error;
}
params.onRetry?.();
if (!(await params.waitForActiveTurnCompletion())) {
throw error;
}
await params.afterActiveTurnCompletion?.();
return await params.startTurn();
}
}
/** True for Codex's structured rejection when native work already owns the thread. */
export function isCodexActiveTurnNotSteerableError(error: unknown): boolean {
if (!(error instanceof CodexAppServerRpcError) || !isJsonObject(error.data)) {
return false;
}
const info = error.data.codexErrorInfo;
return isJsonObject(info) && isJsonObject(info.activeTurnNotSteerable);
}
/** Validates a create response and retires the client unless cleanup is confirmed. */
export async function validateCodexThreadCreationResponse<T>(
owner: {
client: CodexAppServerClient;
abandon: () => Promise<void>;
},
response: unknown,
validate: (value: unknown) => T,
): Promise<T> {
try {
return validate(response);
} catch (error) {
const threadId = readCodexThreadCreationResponseId(response);
const released = threadId
? await unsubscribeCodexThreadBestEffort(owner.client, {
threadId,
timeoutMs: CODEX_APP_SERVER_UNSUBSCRIBE_TIMEOUT_MS,
})
: false;
if (released) {
throw error;
}
try {
await owner.abandon();
} catch (abandonError) {
throw new CodexAppServerUnsafeSubscriptionError(
"Codex thread creation response was invalid and its client could not be retired",
{ cause: abandonError },
);
}
throw new CodexAppServerUnsafeSubscriptionError(
"Codex thread creation response was invalid and its subscription could not be released",
{ cause: error },
);
}
}
/** Sends a turn interrupt without blocking abort cleanup on app-server errors. */
export function interruptCodexTurnBestEffort(
client: CodexAppServerClient,
@@ -148,56 +36,28 @@ export function interruptCodexTurnBestEffort(
}
}
/** Unsubscribes from a thread and reports whether wire cleanup was confirmed. */
/** Unsubscribes from a thread while swallowing cleanup-only failures. */
export async function unsubscribeCodexThreadBestEffort(
client: CodexAppServerClient,
params: {
threadId: string;
timeoutMs: number;
},
): Promise<boolean> {
): Promise<void> {
try {
await client.request(
"thread/unsubscribe",
{ threadId: params.threadId },
{ timeoutMs: params.timeoutMs },
);
return true;
} catch (error) {
embeddedAgentLog.debug("codex app-server thread unsubscribe cleanup failed", {
threadId: params.threadId,
error,
});
return false;
}
}
/** Returns one exact client lease to the pool only after subscription cleanup succeeds. */
export async function settleCodexAppServerClientLease(
lease: CodexAppServerClientLease,
params: {
threadId?: string;
timeoutMs: number;
abandon?: boolean;
},
): Promise<void> {
if (params.abandon) {
await lease.abandon();
return;
}
if (
params.threadId &&
!(await unsubscribeCodexThreadBestEffort(lease.client, {
threadId: params.threadId,
timeoutMs: params.timeoutMs,
}))
) {
await lease.abandon();
return;
}
lease.release();
}
/**
* Retires the shared client after a timed-out turn so later runs do not reuse a
* potentially wedged app-server connection.
@@ -208,9 +68,10 @@ export async function retireCodexAppServerClientAfterTimedOutTurn(
threadId: string;
turnId: string;
reason: string;
abandonClientLease: () => Promise<void>;
},
): Promise<void> {
const retiredSharedClient = retireSharedCodexAppServerClientIfCurrent(client);
const detachedSharedClient = Boolean(retiredSharedClient);
interruptCodexTurnBestEffort(client, {
threadId: params.threadId,
turnId: params.turnId,
@@ -220,10 +81,28 @@ export async function retireCodexAppServerClientAfterTimedOutTurn(
threadId: params.threadId,
timeoutMs: CODEX_APP_SERVER_UNSUBSCRIBE_TIMEOUT_MS,
});
await params.abandonClientLease();
let closedClient = retiredSharedClient?.closed ?? false;
if (!detachedSharedClient) {
const close = (client as { close?: () => void }).close;
if (typeof close === "function") {
try {
close.call(client);
closedClient = true;
} catch (error) {
embeddedAgentLog.debug("codex app-server client close failed during timeout cleanup", {
threadId: params.threadId,
turnId: params.turnId,
error,
});
}
}
}
embeddedAgentLog.warn("codex app-server client retired after timed-out turn", {
threadId: params.threadId,
turnId: params.turnId,
reason: params.reason,
detachedSharedClient,
closedClient,
activeSharedClientLeases: retiredSharedClient?.activeLeases ?? 0,
});
}

View File

@@ -9,6 +9,7 @@ import {
isFileChangePatchUpdatedNotification,
isAssistantCommentaryCompletionNotification,
isNativeToolProgressNotification,
isNativeResponseStreamDeltaNotification,
isPendingOpenClawDynamicToolCompletionNotification,
isRawAssistantProgressNotification,
isRawReasoningCompletionNotification,
@@ -16,6 +17,7 @@ import {
isReasoningProgressNotification,
isReasoningItemCompletionNotification,
isRetryableErrorNotification,
isTurnNotification,
readCodexNotificationItem,
readNotificationItemId,
shouldDisarmAssistantCompletionIdleWatch,
@@ -23,7 +25,6 @@ import {
} from "./attempt-notifications.js";
import { CODEX_POST_REASONING_REPLY_IDLE_TIMEOUT_MS } from "./attempt-timeouts.js";
import type { CodexAttemptTurnWatchController } from "./attempt-turn-watches.js";
import { isCodexNotificationForTurn } from "./notification-correlation.js";
import type { CodexServerNotification } from "./protocol.js";
type CodexExecutionPhase =
@@ -69,7 +70,7 @@ export function isTerminalCodexTurnNotificationForTurn(params: {
turnId: string;
currentPromptTexts: string[];
}): boolean {
if (!isCodexNotificationForTurn(params.notification.params, params.threadId, params.turnId)) {
if (!isTurnNotification(params.notification.params, params.threadId, params.turnId)) {
return false;
}
return (
@@ -104,15 +105,16 @@ export function applyCodexTurnNotificationState(params: {
turnCrossedToolHandoff: boolean;
} {
const { notification, turnWatches } = params;
const isCurrentTurnNotification = isCodexNotificationForTurn(
const isCurrentTurnNotification = isTurnNotification(
notification.params,
params.threadId,
params.turnId,
);
const isTurnCompletion = notification.method === "turn/completed" && isCurrentTurnNotification;
const isNativeResponseStreamDelta = isNativeResponseStreamDeltaNotification(notification);
let turnCrossedToolHandoff = params.turnCrossedToolHandoff;
if (isCurrentTurnNotification) {
if (isCurrentTurnNotification && !isNativeResponseStreamDelta) {
turnWatches.touchActivity(`notification:${notification.method}`, {
details: describeNotificationActivity(notification),
attemptProgress: true,
@@ -248,6 +250,7 @@ export function applyCodexTurnNotificationState(params: {
!turnWatches.isCompletionIdleWatchPinnedByTerminalError() &&
notification.method !== "turn/completed" &&
isCurrentTurnNotification &&
!isNativeResponseStreamDelta &&
!trackedDynamicToolCompletion &&
!rawToolOutputCompletion &&
!postToolProgressNeedsTerminalGuard &&

View File

@@ -1,6 +1,11 @@
/**
* Predicates and readers for Codex app-server notification envelopes.
*/
import { asBoolean } from "openclaw/plugin-sdk/string-coerce-runtime";
import {
describeCodexNotificationCorrelation,
isCodexNotificationForTurn,
} from "./notification-correlation.js";
import {
isJsonObject,
type CodexServerNotification,
@@ -211,6 +216,13 @@ export function isNativeToolProgressNotification(notification: CodexServerNotifi
}
}
/** Returns true for raw native response stream delta events. */
export function isNativeResponseStreamDeltaNotification(
notification: CodexServerNotification,
): boolean {
return notification.method.startsWith("response.") && notification.method.endsWith(".delta");
}
/** Returns true for file-change patch update notifications. */
export function isFileChangePatchUpdatedNotification(
notification: CodexServerNotification,
@@ -265,9 +277,74 @@ function readRawAssistantTextPreview(item: JsonObject): string | undefined {
return text.length > 240 ? `${text.slice(0, 237)}...` : text;
}
/** Returns true when notification params correlate to a specific thread/turn. */
export function isTurnNotification(
value: JsonValue | undefined,
threadId: string,
turnId: string,
): boolean {
return isCodexNotificationForTurn(value, threadId, turnId);
}
/** Returns true when a correlated notification belongs to another active run. */
export function isCodexNotificationOutsideActiveRun(
correlation: ReturnType<typeof describeCodexNotificationCorrelation>,
): boolean {
const hasThreadScope = Boolean(correlation.threadId || correlation.nestedTurnThreadId);
if (!hasThreadScope) {
return false;
}
if (!correlation.matchesActiveThread) {
return true;
}
const hasTurnScope = Boolean(correlation.turnId || correlation.nestedTurnId);
return hasTurnScope && correlation.matchesActiveTurn === false;
}
/** Checks request params that must contain the current thread and turn ids. */
export function isCurrentThreadTurnRequestParams(
value: JsonValue | undefined,
threadId: string,
turnId: string,
): boolean {
if (!isJsonObject(value)) {
return false;
}
return readString(value, "threadId") === threadId && readString(value, "turnId") === turnId;
}
/** Checks approval request params, accepting `conversationId` as thread id. */
export function isCurrentApprovalTurnRequestParams(
value: JsonValue | undefined,
threadId: string,
turnId: string,
): boolean {
if (!isJsonObject(value)) {
return false;
}
const requestThreadId = readString(value, "threadId") ?? readString(value, "conversationId");
return requestThreadId === threadId && readString(value, "turnId") === turnId;
}
/** Checks request params where `turnId` may be omitted or null for the thread. */
export function isCurrentThreadOptionalTurnRequestParams(
value: JsonValue | undefined,
threadId: string,
turnId: string,
): boolean {
if (!isJsonObject(value) || readString(value, "threadId") !== threadId) {
return false;
}
const requestTurnId = value.turnId;
return requestTurnId === null || requestTurnId === undefined || requestTurnId === turnId;
}
/** Returns true for app-server error notifications that will retry. */
export function isRetryableErrorNotification(value: JsonValue | undefined): boolean {
return isJsonObject(value) && value.willRetry === true;
if (!isJsonObject(value)) {
return false;
}
return readBoolean(value, "willRetry") === true || readBoolean(value, "will_retry") === true;
}
/** Returns true for terminal app-server thread status strings. */
@@ -342,6 +419,10 @@ function readString(record: JsonObject, key: string): string | undefined {
return typeof value === "string" ? value : undefined;
}
function readBoolean(record: JsonObject, key: string): boolean | undefined {
return asBoolean(record[key]);
}
/** Reads a typed Codex item from notification params when id/type are present. */
export function readCodexNotificationItem(
params: JsonValue | undefined,

View File

@@ -100,6 +100,7 @@ export function buildCodexTurnStartFailureResult(params: {
assistantTexts: [],
toolMetas: [],
lastAssistant: undefined,
currentAttemptAssistant: undefined,
didSendViaMessagingTool: false,
messagingToolSentTexts: [],
messagingToolSentMediaUrls: [],

View File

@@ -9,16 +9,13 @@ import type {
} from "openclaw/plugin-sdk/agent-harness-runtime";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { startCodexAttemptThread } from "./attempt-startup.js";
import { defaultLeasedCodexAppServerClientFactory } from "./client-factory.js";
import { CodexAppServerClient } from "./client.js";
import { type CodexPluginConfig, resolveCodexAppServerRuntimeOptions } from "./config.js";
import { threadStartResult } from "./run-attempt-test-harness.js";
import {
resetCodexTestBindingStore,
testCodexAppServerBindingStore,
} from "./session-binding.test-helpers.js";
import {
leaseSharedCodexAppServerClient,
resetSharedCodexAppServerClientForTests,
clearSharedCodexAppServerClient,
getLeasedSharedCodexAppServerClient,
releaseLeasedSharedCodexAppServerClient,
} from "./shared-client.js";
import { createClientHarness, createCodexTestModel } from "./test-support.js";
@@ -33,6 +30,14 @@ type AttemptPaths = {
const tempRoots = new Set<string>();
function isolateCodexCliAuthHome(): void {
const root = path.join(os.tmpdir(), `openclaw-codex-attempt-auth-${randomUUID()}`);
tempRoots.add(root);
// Keep fallback auth lookup from reading the operator's real Codex CLI auth file.
vi.stubEnv("CODEX_HOME", path.join(root, "codex-home"));
vi.stubEnv("HOME", path.join(root, "home"));
}
function createAttemptPaths(): AttemptPaths {
const root = path.join(os.tmpdir(), `openclaw-codex-attempt-startup-${randomUUID()}`);
tempRoots.add(root);
@@ -88,10 +93,12 @@ function startThreadWithHarness(
signal = new AbortController().signal,
overrides?: {
pluginConfig?: CodexPluginConfig;
attemptClientFactory?: (
harness: ClientHarness,
) => Parameters<typeof startCodexAttemptThread>[0]["attemptClientFactory"];
harness?: ClientHarness;
paths?: AttemptPaths;
skipStartSpy?: boolean;
onThreadReserved?: Parameters<typeof startCodexAttemptThread>[0]["onThreadReserved"];
},
) {
const harness = overrides?.harness ?? createClientHarness();
@@ -102,7 +109,8 @@ function startThreadWithHarness(
const effectivePluginConfig = overrides?.pluginConfig ?? pluginConfig;
const run = startCodexAttemptThread({
bindingStore: testCodexAppServerBindingStore,
attemptClientFactory:
overrides?.attemptClientFactory?.(harness) ?? defaultLeasedCodexAppServerClientFactory,
appServer: resolveCodexAppServerRuntimeOptions({ pluginConfig: effectivePluginConfig }),
pluginConfig: effectivePluginConfig,
computerUseConfig: effectivePluginConfig.computerUse ?? { enabled: false },
@@ -123,11 +131,10 @@ function startThreadWithHarness(
sandboxExecServerEnabled: false,
sandbox: null,
contextEngineProjection: undefined,
startupTokenGuard: {},
startupTimeoutMs,
signal,
onStartupTimeout: vi.fn(),
onThreadReserved: overrides?.onThreadReserved,
spawnedBy: undefined,
});
return { harness, run };
@@ -169,22 +176,22 @@ describe("startCodexAttemptThread", () => {
vi.useRealTimers();
vi.stubEnv("CODEX_API_KEY", "");
vi.stubEnv("OPENAI_API_KEY", "");
resetCodexTestBindingStore();
resetSharedCodexAppServerClientForTests();
isolateCodexCliAuthHome();
clearSharedCodexAppServerClient();
});
afterEach(async () => {
vi.useRealTimers();
resetSharedCodexAppServerClientForTests();
clearSharedCodexAppServerClient();
vi.restoreAllMocks();
vi.unstubAllEnvs();
for (const root of tempRoots) {
await fs.rm(root, { recursive: true, force: true });
await fs.rm(root, { recursive: true, force: true, maxRetries: 3, retryDelay: 50 });
}
tempRoots.clear();
});
it("keeps the shared app-server reusable after a structured startup rejection", async () => {
it("clears the shared app-server when top-level thread startup fails with an app error", async () => {
const { harness, run } = startThreadWithHarness(5_000);
await answerInitialize(harness);
const threadStart = await waitForThreadStart(harness);
@@ -194,57 +201,25 @@ describe("startCodexAttemptThread", () => {
});
await expect(run).rejects.toThrow("Invalid bearer token");
expect(harness.process.stdin.destroyed).toBe(false);
});
it("retires the client when malformed startup cleanup cannot be confirmed", async () => {
const { harness, run } = startThreadWithHarness(5_000);
await answerInitialize(harness);
const threadStart = await waitForThreadStart(harness);
harness.send({ id: threadStart.id, result: { thread: { id: "thread-malformed" } } });
const unsubscribe = await waitForRequest(harness, "thread/unsubscribe");
harness.send({
id: unsubscribe.id,
error: { code: -32000, message: "unsubscribe failed" },
});
await expect(run).rejects.toThrow("subscription could not be released");
expect(harness.process.stdin.destroyed).toBe(true);
});
it("retires the client when route cleanup cannot release the subscription", async () => {
const { harness, run } = startThreadWithHarness(5_000, undefined, {
onThreadReserved: () => {
throw new Error("route integration failed");
},
});
await answerInitialize(harness);
const threadStart = await waitForThreadStart(harness);
harness.send({ id: threadStart.id, result: threadStartResult("thread-route-failed") });
const unsubscribe = await waitForRequest(harness, "thread/unsubscribe");
harness.send({
id: unsubscribe.id,
error: { code: -32000, message: "unsubscribe failed" },
});
await expect(run).rejects.toThrow("Codex startup subscription cleanup failed");
expect(harness.process.stdin.destroyed).toBe(true);
});
it("does not retire a peer-owned client after a structured startup rejection", async () => {
it("retires a failed startup client after another active lease releases", async () => {
const retained = createClientHarness();
const startSpy = vi.spyOn(CodexAppServerClient, "start").mockReturnValue(retained.client);
const replacement = createClientHarness();
const startSpy = vi
.spyOn(CodexAppServerClient, "start")
.mockReturnValueOnce(retained.client)
.mockReturnValueOnce(replacement.client);
const appServer = resolveCodexAppServerRuntimeOptions({ pluginConfig });
const paths = createAttemptPaths();
const retainedLeasePromise = leaseSharedCodexAppServerClient({
const retainedLease = getLeasedSharedCodexAppServerClient({
startOptions: appServer.start,
agentDir: paths.agentDir,
preparedAuth: {},
});
await answerInitialize(retained);
const retainedLease = await retainedLeasePromise;
expect(retainedLease.client).toBe(retained.client);
await expect(retainedLease).resolves.toBe(retained.client);
const { run } = startThreadWithHarness(5_000, new AbortController().signal, {
harness: retained,
@@ -260,16 +235,17 @@ describe("startCodexAttemptThread", () => {
await expect(run).rejects.toThrow("Invalid bearer token");
expect(retained.process.stdin.destroyed).toBe(false);
retainedLease.release();
const nextLeasePromise = leaseSharedCodexAppServerClient({
expect(releaseLeasedSharedCodexAppServerClient(retained.client)).toBe(true);
await vi.waitFor(() => expect(retained.process.stdin.destroyed).toBe(true));
const replacementLease = getLeasedSharedCodexAppServerClient({
startOptions: appServer.start,
agentDir: paths.agentDir,
preparedAuth: {},
});
const nextLease = await nextLeasePromise;
expect(nextLease.client).toBe(retained.client);
expect(startSpy).toHaveBeenCalledTimes(1);
nextLease.release();
await answerInitialize(replacement);
await expect(replacementLease).resolves.toBe(replacement.client);
expect(startSpy).toHaveBeenCalledTimes(2);
expect(releaseLeasedSharedCodexAppServerClient(replacement.client)).toBe(true);
});
it("clears the shared app-server when startup abandons an in-flight thread request", async () => {
@@ -291,20 +267,18 @@ describe("startCodexAttemptThread", () => {
expect(harness.stdinDestroyed).toBe(true);
});
it("retires abandoned thread startup even when another lease shares the client", async () => {
it("aborts abandoned thread startup when another lease keeps the shared app-server alive", async () => {
const retained = createClientHarness();
vi.spyOn(CodexAppServerClient, "start").mockReturnValue(retained.client);
const appServer = resolveCodexAppServerRuntimeOptions({ pluginConfig });
const paths = createAttemptPaths();
const retainedLeasePromise = leaseSharedCodexAppServerClient({
const retainedLease = getLeasedSharedCodexAppServerClient({
startOptions: appServer.start,
agentDir: paths.agentDir,
preparedAuth: {},
});
await answerInitialize(retained);
const retainedLease = await retainedLeasePromise;
expect(retainedLease.client).toBe(retained.client);
await expect(retainedLease).resolves.toBe(retained.client);
const { run } = startThreadWithHarness(100, new AbortController().signal, {
harness: retained,
@@ -315,9 +289,11 @@ describe("startCodexAttemptThread", () => {
const threadStart = await waitForThreadStart(retained);
await rejected;
expect(threadStart.id).toBeDefined();
expect(retained.process.stdin.destroyed).toBe(true);
retainedLease.release();
expect(retained.process.stdin.destroyed).toBe(false);
retained.send({ id: threadStart.id, result: { threadId: "late-thread" } });
expect(releaseLeasedSharedCodexAppServerClient(retained.client)).toBe(true);
await vi.waitFor(() => expect(retained.process.stdin.destroyed).toBe(true));
});
it("closes the shared app-server when startup times out during initialize", async () => {
@@ -342,37 +318,45 @@ describe("startCodexAttemptThread", () => {
).toBe(false);
});
it("releases a late startup lease without retiring a peer-owned initializing client", async () => {
const harness = createClientHarness();
const startSpy = vi.spyOn(CodexAppServerClient, "start").mockReturnValue(harness.client);
const paths = createAttemptPaths();
const appServer = resolveCodexAppServerRuntimeOptions({ pluginConfig });
const peerPromise = leaseSharedCodexAppServerClient({
startOptions: appServer.start,
agentDir: paths.agentDir,
preparedAuth: {},
it("closes a startup client that arrives after startup timeout", async () => {
let observedFactoryOptions:
| {
onStartedClient?: (client: CodexAppServerClient) => void;
abandonSignal?: AbortSignal;
}
| undefined;
let resolveFactoryDone: () => void = () => undefined;
const factoryDone = new Promise<void>((resolve) => {
resolveFactoryDone = resolve;
});
const { run } = startThreadWithHarness(100, new AbortController().signal, {
harness,
paths,
skipStartSpy: true,
const { harness, run } = startThreadWithHarness(100, new AbortController().signal, {
attemptClientFactory:
(factoryHarness) => async (_startOptions, _authProfileId, _agentDir, _config, options) => {
try {
observedFactoryOptions = options;
await new Promise<void>((resolve) => {
setTimeout(resolve, 250);
});
options?.onStartedClient?.(factoryHarness.client);
return factoryHarness.client;
} finally {
resolveFactoryDone();
}
},
});
const rejected = expect(run).rejects.toThrow("codex app-server startup timed out");
await expect(run).rejects.toThrow("codex app-server startup timed out");
expect(harness.stdinDestroyed).toBe(false);
await answerInitialize(harness);
const peer = await peerPromise;
expect(peer.client).toBe(harness.client);
await new Promise<void>((resolve) => {
setImmediate(resolve);
await rejected;
await factoryDone;
await vi.waitFor(() => expect(harness.stdinDestroyed).toBe(true), {
interval: 1,
timeout: 2_000,
});
expect(startSpy).toHaveBeenCalledTimes(1);
expect(
readHarnessMessages(harness.writes).some((write) => write.method === "thread/start"),
).toBe(false);
await peer.abandon();
expect(harness.stdinDestroyed).toBe(true);
expect(observedFactoryOptions?.onStartedClient).toBeTypeOf("function");
expect(observedFactoryOptions?.abandonSignal?.aborted).toBe(true);
});
it("clears the shared app-server when cancellation abandons an in-flight thread request", async () => {

View File

@@ -11,15 +11,9 @@ import {
type resolveSandboxContext,
} from "openclaw/plugin-sdk/agent-harness-runtime";
import { defaultCodexAppInventoryCache } from "./app-inventory-cache.js";
import {
CODEX_APP_SERVER_UNSUBSCRIBE_TIMEOUT_MS,
CodexAppServerUnsafeSubscriptionError,
isCodexAppServerUnsafeSubscriptionError,
unsubscribeCodexThreadBestEffort,
} from "./attempt-client-cleanup.js";
import { buildCodexPluginThreadConfigEligibilityLogData } from "./attempt-diagnostics.js";
import { withCodexStartupTimeout } from "./attempt-timeouts.js";
import { ensureCodexAppServerClientRuntime } from "./client-runtime.js";
import type { CodexAppServerClientFactory } from "./client-factory.js";
import { isCodexAppServerConnectionClosedError, type CodexAppServerClient } from "./client.js";
import { ensureCodexComputerUse } from "./computer-use.js";
import {
@@ -54,23 +48,17 @@ import {
releaseCodexSandboxExecServerEnvironment,
type CodexSandboxExecEnvironment,
} from "./sandbox-exec-server.js";
import type { CodexAppServerBindingStore } from "./session-binding.js";
import {
leaseSharedCodexAppServerClient,
type CodexAppServerClientLease,
type CodexAppServerClientLeaseFactory,
clearSharedCodexAppServerClientIfCurrentAndUnclaimed,
clearSharedCodexAppServerClientIfCurrent,
releaseLeasedSharedCodexAppServerClient,
retireSharedCodexAppServerClientIfCurrent,
} from "./shared-client.js";
import type { CodexAppServerStartupTokenGuard } from "./startup-binding.js";
import {
startOrResumeThread,
type CodexAppServerThreadLifecycleBinding,
type CodexContextEngineThreadBootstrapProjection,
} from "./thread-lifecycle.js";
import {
getCodexAppServerTurnRouter,
type CodexAppServerTurnRouter,
type CodexThreadRouteReservation,
} from "./turn-router.js";
const CODEX_APP_SERVER_STARTUP_CONNECTION_CLOSE_MAX_ATTEMPTS = 3;
@@ -78,15 +66,14 @@ type CodexSandboxContext = Awaited<ReturnType<typeof resolveSandboxContext>>;
/** Resources and bindings returned after a Codex attempt thread starts. */
export type StartCodexAttemptThreadResult = {
turnRouter: CodexAppServerTurnRouter;
turnRoute: CodexThreadRouteReservation;
client: CodexAppServerClient;
thread: CodexAppServerThreadLifecycleBinding;
pluginAppServer: CodexAppServerRuntimeOptions;
sandboxEnvironment: CodexSandboxExecEnvironment | undefined;
environmentSelection: CodexTurnEnvironmentParams[] | undefined;
executionCwd: string;
sandboxPolicy: CodexSandboxPolicy | undefined;
clientLease: CodexAppServerClientLease;
mcpElicitationDelegationRequired: boolean;
releaseSharedClientLease: () => void;
restartContextEngineCodexThread: () => Promise<CodexAppServerThreadLifecycleBinding>;
};
@@ -95,8 +82,7 @@ export type StartCodexAttemptThreadResult = {
* run loop must later release.
*/
export async function startCodexAttemptThread(params: {
bindingStore: CodexAppServerBindingStore;
clientLeaseFactory?: CodexAppServerClientLeaseFactory;
attemptClientFactory: CodexAppServerClientFactory;
appServer: CodexAppServerRuntimeOptions;
pluginConfig: CodexPluginConfig;
computerUseConfig: CodexComputerUseConfig;
@@ -119,26 +105,18 @@ export async function startCodexAttemptThread(params: {
sandboxExecServerEnabled: boolean;
sandbox: CodexSandboxContext;
contextEngineProjection: CodexContextEngineThreadBootstrapProjection | undefined;
expectedResumeThreadId?: string;
startupTokenGuard: CodexAppServerStartupTokenGuard;
startupTimeoutMs: number;
signal: AbortSignal;
onStartupTimeout: () => void | Promise<void>;
onThreadReserved?: (client: CodexAppServerClient, threadId: string) => () => void;
spawnedBy: EmbeddedRunAttemptParams["spawnedBy"];
}): Promise<StartCodexAttemptThreadResult> {
let mcpElicitationDelegationRequired = false;
let sharedClientLease: CodexAppServerClientLease | undefined;
let pluginAppServer = params.appServer;
let releaseSharedClientLease: (() => void) | undefined;
let startupClientForAbandonedRequestCleanup: CodexAppServerClient | undefined;
let releaseStartupResourcesOnTimeout: (() => Promise<void>) | undefined;
let startupAbandoned = false;
const startupAbandonController = new AbortController();
const abandonStartupAcquire = () => startupAbandonController.abort();
const abandonStartupClient = async () => {
const lease = sharedClientLease;
sharedClientLease = undefined;
if (lease) {
await lease.abandon();
}
};
params.signal.addEventListener("abort", abandonStartupAcquire, { once: true });
try {
const startupResult = await withCodexStartupTimeout({
@@ -149,7 +127,10 @@ export async function startCodexAttemptThread(params: {
startupAbandonController.abort();
await params.onStartupTimeout();
await releaseStartupResourcesOnTimeout?.();
await abandonStartupClient();
releaseSharedClientLease?.();
releaseSharedClientLease = undefined;
await closeAbandonedStartupClient(startupClientForAbandonedRequestCleanup);
startupClientForAbandonedRequestCleanup = undefined;
},
operation: async () => {
const threadConfig = mergeCodexThreadConfigs(
@@ -180,9 +161,8 @@ export async function startCodexAttemptThread(params: {
const resolvedPluginPolicy = pluginThreadConfigRequired
? resolveCodexPluginsPolicy(pluginThreadConfigPluginConfig)
: undefined;
const computerUseMcpElicitationDelegationRequired =
params.computerUseConfig.enabled === true;
mcpElicitationDelegationRequired =
const computerUseMcpElicitationDelegationRequired = params.computerUseConfig.enabled;
const mcpElicitationDelegationRequired =
resolvedPluginPolicy?.enabled === true || computerUseMcpElicitationDelegationRequired;
const enabledPluginConfigKeys = resolvedPluginPolicy
? resolvedPluginPolicy.pluginPolicies
@@ -204,48 +184,55 @@ export async function startCodexAttemptThread(params: {
appServer: params.appServer,
}),
);
const pluginAppServer = mcpElicitationDelegationRequired
pluginAppServer = mcpElicitationDelegationRequired
? {
...params.appServer,
approvalPolicy: withMcpElicitationsApprovalPolicy(params.appServer.approvalPolicy),
}
: params.appServer;
let attemptedClientAbandoned = false;
let attemptedClient: CodexAppServerClient | undefined;
const startupAttempt = async () => {
let startupClientLease: CodexAppServerClientLease | undefined;
let clientWorkStarted = false;
attemptedClientAbandoned = false;
let startupClientLease: (() => void) | undefined;
let startupClient: CodexAppServerClient | undefined;
let startupAttemptError: unknown;
let startupAttemptSucceeded = false;
try {
startupClientLease = await (
params.clientLeaseFactory ?? leaseSharedCodexAppServerClient
)({
startOptions: params.appServer.start,
authProfileId: params.startupAuthProfileId,
agentDir: params.agentDir,
config: params.config,
preparedAuth: {
profileId: params.startupAuthProfileId,
cacheKey: params.startupAuthAccountCacheKey ?? params.startupEnvApiKeyCacheKey,
startupClient = await params.attemptClientFactory(
params.appServer.start,
params.startupAuthProfileId,
params.agentDir,
params.config,
{
onStartedClient: (client) => {
// Timeout cleanup may fire before the client factory resolves;
// close any late-arriving client instead of leaking a lease.
startupClientForAbandonedRequestCleanup = client;
if (startupAbandoned || startupAbandonController.signal.aborted) {
void closeAbandonedStartupClient(client);
}
},
abandonSignal: startupAbandonController.signal,
},
abandonSignal: startupAbandonController.signal,
});
const activeStartupLease = startupClientLease;
const activeStartupClient = activeStartupLease.client;
sharedClientLease = startupClientLease;
);
const activeStartupClient = startupClient;
let startupClientLeaseReleased = false;
startupClientLease = () => {
if (startupClientLeaseReleased) {
return;
}
startupClientLeaseReleased = true;
releaseLeasedSharedCodexAppServerClient(activeStartupClient);
};
releaseSharedClientLease = startupClientLease;
attemptedClient = activeStartupClient;
startupClientForAbandonedRequestCleanup = activeStartupClient;
if (startupAbandoned) {
throw new Error("codex app-server startup timed out");
}
if (startupAbandonController.signal.aborted) {
throw new Error("codex app-server startup aborted");
}
clientWorkStarted = true;
ensureCodexAppServerClientRuntime(activeStartupClient, {
agentDir: params.agentDir,
authProfileId: params.startupAuthProfileId,
config: params.config,
});
const turnRouter = getCodexAppServerTurnRouter(activeStartupClient);
await ensureCodexComputerUse({
client: activeStartupClient,
pluginConfig: params.pluginConfig,
@@ -277,6 +264,7 @@ export async function startCodexAttemptThread(params: {
: undefined;
startupSandboxEnvironmentAcquired = Boolean(startupSandboxEnvironment);
if (startupAbandonController.signal.aborted) {
await releaseStartupSandboxEnvironment();
throw new Error("codex app-server startup aborted");
}
if (
@@ -305,57 +293,9 @@ export async function startCodexAttemptThread(params: {
const startupSandboxPolicy = startupSandboxEnvironment
? resolveCodexExternalSandboxPolicyForOpenClawSandbox(params.sandbox)
: undefined;
let startupReservation:
| { route: CodexThreadRouteReservation; release: () => void }
| undefined;
const reserveStartupThread = (threadId: string) => {
if (startupReservation) {
if (startupReservation.route.threadId !== threadId) {
throw new Error(
`codex app-server reserved ${startupReservation.route.threadId} but started ${threadId}`,
);
}
return { release: startupReservation.release };
}
const route = turnRouter.reserveThread({
threadId,
releaseOn: params.signal,
});
let releaseIntegration: (() => void) | undefined;
try {
releaseIntegration = params.onThreadReserved?.(activeStartupClient, threadId);
} catch (error) {
route.release();
throw error;
}
let released = false;
const release = () => {
if (released) {
return;
}
released = true;
if (startupReservation?.route === route) {
startupReservation = undefined;
}
route.release();
releaseIntegration?.();
};
startupReservation = { route, release };
return { release };
};
const releaseStartupResources = async () => {
startupReservation?.release();
await releaseStartupSandboxEnvironment();
};
releaseStartupResourcesOnTimeout = releaseStartupResources;
const buildThreadLifecycleParams = (
signal: AbortSignal,
options: { freshStartOnly?: boolean } = {},
) =>
const buildThreadLifecycleParams = (signal: AbortSignal) =>
({
client: activeStartupClient,
abandonClient: activeStartupLease.abandon,
bindingStore: params.bindingStore,
params: params.buildAttemptParams(),
agentId: params.sessionAgentId,
cwd: startupExecutionCwd,
@@ -373,13 +313,7 @@ export async function startCodexAttemptThread(params: {
mcpServersFingerprintEvaluated: params.bundleMcpThreadConfig.evaluated,
environmentSelection: startupEnvironmentSelection,
contextEngineProjection: params.contextEngineProjection,
freshStartOnly: options.freshStartOnly,
expectedResumeThreadId: options.freshStartOnly
? undefined
: params.expectedResumeThreadId,
signal,
reserveResumeThread: options.freshStartOnly ? undefined : reserveStartupThread,
startupTokenGuard: params.startupTokenGuard,
pluginThreadConfig: pluginThreadConfigRequired
? {
enabled: true,
@@ -403,65 +337,57 @@ export async function startCodexAttemptThread(params: {
const startupThread = await startOrResumeThread(
buildThreadLifecycleParams(startupAbandonController.signal),
);
try {
reserveStartupThread(startupThread.threadId);
} catch (error) {
const unsubscribed = await unsubscribeCodexThreadBestEffort(activeStartupClient, {
threadId: startupThread.threadId,
timeoutMs: CODEX_APP_SERVER_UNSUBSCRIBE_TIMEOUT_MS,
});
if (!unsubscribed) {
throw new CodexAppServerUnsafeSubscriptionError(
"Codex startup subscription cleanup failed",
{ cause: error },
);
}
throw error;
}
if (startupAbandonController.signal.aborted) {
await releaseStartupSandboxEnvironment();
throw new Error("codex app-server startup aborted");
}
if (!startupReservation) {
throw new Error("codex app-server startup did not reserve its thread route");
}
startupSandboxEnvironmentAcquired = false;
startupAttemptSucceeded = true;
return {
turnRouter,
turnRoute: startupReservation.route,
client: activeStartupClient,
thread: startupThread,
sandboxEnvironment: startupSandboxEnvironment,
environmentSelection: startupEnvironmentSelection,
executionCwd: startupExecutionCwd,
sandboxPolicy: startupSandboxPolicy,
restartContextEngineCodexThread: () =>
startOrResumeThread(
buildThreadLifecycleParams(params.signal, { freshStartOnly: true }),
),
startOrResumeThread(buildThreadLifecycleParams(params.signal)),
};
} catch (error) {
await releaseStartupResources();
await releaseStartupSandboxEnvironment();
throw error;
} finally {
if (releaseStartupResourcesOnTimeout === releaseStartupResources) {
if (releaseStartupResourcesOnTimeout === releaseStartupSandboxEnvironment) {
releaseStartupResourcesOnTimeout = undefined;
}
}
} catch (error) {
if (sharedClientLease === startupClientLease) {
sharedClientLease = undefined;
}
const shouldAbandonStartupClient =
clientWorkStarted &&
(startupAbandoned ||
params.signal.aborted ||
isIndeterminateCodexStartupFailure(error));
if (shouldAbandonStartupClient) {
attemptedClientAbandoned = true;
await startupClientLease?.abandon();
} else {
startupClientLease?.release();
}
startupAttemptError = error;
throw error;
} finally {
if (!startupAttemptSucceeded) {
if (releaseSharedClientLease === startupClientLease) {
releaseSharedClientLease = undefined;
}
startupClientLease?.();
if (startupAbandoned || params.signal.aborted) {
if (startupClientForAbandonedRequestCleanup === startupClient) {
startupClientForAbandonedRequestCleanup = undefined;
}
await closeAbandonedStartupClient(startupClient);
} else if (
shouldClearSharedClientAfterStartupRace(startupAttemptError) ||
shouldClearSharedClientAfterStartupFailure({
error: startupAttemptError,
spawnedBy: params.spawnedBy,
})
) {
if (startupClientForAbandonedRequestCleanup === startupClient) {
startupClientForAbandonedRequestCleanup = undefined;
}
await evictFailedStartupClient(startupClient);
}
}
}
};
@@ -476,13 +402,18 @@ export async function startCodexAttemptThread(params: {
if (params.signal.aborted || !isCodexAppServerConnectionClosedError(error)) {
throw error;
}
const failedClient = attemptedClient;
const clearedSharedClient = clearSharedCodexAppServerClientIfCurrent(failedClient);
if (startupClientForAbandonedRequestCleanup === failedClient) {
startupClientForAbandonedRequestCleanup = undefined;
}
if (attempt >= CODEX_APP_SERVER_STARTUP_CONNECTION_CLOSE_MAX_ATTEMPTS) {
embeddedAgentLog.warn(
"codex app-server connection closed during startup; retries exhausted",
{
attempt,
maxAttempts: CODEX_APP_SERVER_STARTUP_CONNECTION_CLOSE_MAX_ATTEMPTS,
abandonedSharedClient: attemptedClientAbandoned,
clearedSharedClient,
error: formatErrorMessage(error),
},
);
@@ -494,7 +425,7 @@ export async function startCodexAttemptThread(params: {
attempt,
nextAttempt: attempt + 1,
maxAttempts: CODEX_APP_SERVER_STARTUP_CONNECTION_CLOSE_MAX_ATTEMPTS,
abandonedSharedClient: attemptedClientAbandoned,
clearedSharedClient,
error: formatErrorMessage(error),
},
);
@@ -503,21 +434,32 @@ export async function startCodexAttemptThread(params: {
throw new Error("codex app-server startup retry loop exited unexpectedly");
},
});
const completedSharedClientLease = sharedClientLease;
if (!completedSharedClientLease) {
startupClientForAbandonedRequestCleanup = undefined;
if (!releaseSharedClientLease) {
throw new Error("codex app-server startup succeeded without a shared client lease");
}
sharedClientLease = undefined;
return {
...startupResult,
mcpElicitationDelegationRequired,
clientLease: completedSharedClientLease,
pluginAppServer,
releaseSharedClientLease,
};
} catch (error) {
const shouldAbandonStartupClient =
params.signal.aborted || isIndeterminateCodexStartupFailure(error);
if (shouldAbandonStartupClient) {
await abandonStartupClient();
if (params.signal.aborted || shouldClearSharedClientAfterStartupAbandon(error)) {
releaseSharedClientLease?.();
releaseSharedClientLease = undefined;
await closeAbandonedStartupClient(startupClientForAbandonedRequestCleanup);
startupClientForAbandonedRequestCleanup = undefined;
} else if (
shouldClearSharedClientAfterStartupRace(error) ||
shouldClearSharedClientAfterStartupFailure({
error,
spawnedBy: params.spawnedBy,
})
) {
releaseSharedClientLease?.();
releaseSharedClientLease = undefined;
await evictFailedStartupClient(startupClientForAbandonedRequestCleanup);
startupClientForAbandonedRequestCleanup = undefined;
}
throw error;
} finally {
@@ -525,13 +467,104 @@ export async function startCodexAttemptThread(params: {
}
}
function isIndeterminateCodexStartupFailure(error: unknown): boolean {
async function closeAbandonedStartupClient(
client: CodexAppServerClient | undefined,
): Promise<void> {
if (!client) {
return;
}
const unclaimedSharedClient = clearSharedCodexAppServerClientIfCurrentAndUnclaimed(client);
if (unclaimedSharedClient.closed) {
await closeClientAndWaitIfAvailable(client);
return;
}
if (unclaimedSharedClient.found) {
const retired = retireSharedCodexAppServerClientIfCurrent(client);
if (retired?.closed) {
await closeClientAndWaitIfAvailable(client);
}
return;
}
const retiredSharedClient = retireSharedCodexAppServerClientIfCurrent(client);
if (retiredSharedClient) {
if (retiredSharedClient.closed) {
await closeClientAndWaitIfAvailable(client);
}
return;
}
if (clearSharedCodexAppServerClientIfCurrent(client)) {
await closeClientAndWaitIfAvailable(client);
return;
}
await closeClientAndWaitIfAvailable(client);
}
async function closeClientAndWaitIfAvailable(client: CodexAppServerClient): Promise<void> {
const closeable = client as {
close?: CodexAppServerClient["close"];
closeAndWait?: CodexAppServerClient["closeAndWait"];
};
if (typeof closeable.closeAndWait === "function") {
await closeable.closeAndWait();
return;
}
closeable.close?.();
}
async function evictFailedStartupClient(client: CodexAppServerClient | undefined): Promise<void> {
if (!client) {
return;
}
const unclaimedSharedClient = clearSharedCodexAppServerClientIfCurrentAndUnclaimed(client);
if (unclaimedSharedClient.closed) {
await closeClientAndWaitIfAvailable(client);
return;
}
if (unclaimedSharedClient.found) {
const retired = retireSharedCodexAppServerClientIfCurrent(client);
if (retired?.closed) {
await closeClientAndWaitIfAvailable(client);
}
return;
}
const retiredSharedClient = retireSharedCodexAppServerClientIfCurrent(client);
if (retiredSharedClient) {
if (retiredSharedClient.closed) {
await closeClientAndWaitIfAvailable(client);
}
return;
}
if (clearSharedCodexAppServerClientIfCurrent(client)) {
await closeClientAndWaitIfAvailable(client);
return;
}
await closeClientAndWaitIfAvailable(client);
}
function shouldClearSharedClientAfterStartupAbandon(error: unknown): boolean {
return (
isCodexAppServerUnsafeSubscriptionError(error) ||
isCodexAppServerConnectionClosedError(error) ||
(error instanceof Error &&
(error.message.endsWith(" timed out") ||
error.message.endsWith(" aborted") ||
error.message.includes("write EPIPE")))
error instanceof Error &&
(error.message === "codex app-server startup timed out" ||
error.message === "codex app-server startup aborted")
);
}
function shouldClearSharedClientAfterStartupRace(error: unknown): boolean {
return (
error instanceof Error &&
(shouldClearSharedClientAfterStartupAbandon(error) || error.message.endsWith(" timed out"))
);
}
function shouldClearSharedClientAfterStartupFailure(params: {
error: unknown;
spawnedBy: EmbeddedRunAttemptParams["spawnedBy"];
}): boolean {
if (!(params.error instanceof Error)) {
return !params.spawnedBy;
}
if (params.error.message.includes("write EPIPE")) {
return true;
}
return !params.spawnedBy;
}

View File

@@ -159,39 +159,6 @@ describe("Codex app-server attempt timeouts", () => {
expect(events).toEqual(["cleanup-start", "cleanup-done"]);
});
it("keeps the timeout result when startup resolves during timeout cleanup", async () => {
vi.useFakeTimers();
const events: string[] = [];
let resolveOperation!: (value: string) => void;
let finishCleanup!: () => void;
const run = withCodexStartupTimeout({
timeoutMs: 10,
signal: new AbortController().signal,
onTimeout: async () => {
events.push("cleanup-start");
await new Promise<void>((resolve) => {
finishCleanup = resolve;
});
events.push("cleanup-done");
},
operation: () =>
new Promise<string>((resolve) => {
resolveOperation = resolve;
}),
});
const rejected = expect(run).rejects.toThrow("codex app-server startup timed out");
await vi.advanceTimersByTimeAsync(10);
expect(events).toEqual(["cleanup-start"]);
resolveOperation("late-ready");
await Promise.resolve();
expect(events).toEqual(["cleanup-start"]);
finishCleanup();
await rejected;
expect(events).toEqual(["cleanup-start", "cleanup-done"]);
});
it("rejects startup timeout when aborted before completion", async () => {
vi.useFakeTimers();
const controller = new AbortController();

View File

@@ -52,13 +52,13 @@ export async function withCodexStartupTimeout<T>(params: {
};
timeout = setTimeout(() => {
timeoutError = new Error("codex app-server startup timed out");
rejectOnce(timeoutError);
timeoutCleanup = Promise.resolve()
.then(() => params.onTimeout?.())
.then(
() => undefined,
() => undefined,
);
timeoutCleanup = Promise.resolve(params.onTimeout?.()).then(
() => undefined,
() => undefined,
);
void timeoutCleanup.finally(() => {
rejectOnce(timeoutError!);
});
}, params.timeoutMs);
const abortListener = () => rejectOnce(new Error("codex app-server startup aborted"));
params.signal.addEventListener("abort", abortListener, { once: true });

View File

@@ -29,7 +29,7 @@ describe("Codex app-server attempt turn watches", () => {
const progress: string[] = [];
const diagnostics: string[] = [];
const controller = createCodexAttemptTurnWatchController({
getThreadId: () => "thread-1",
threadId: "thread-1",
signal: abortController.signal,
getTurnId: () => "turn-1",
isCompleted: () => completed,

View File

@@ -29,7 +29,7 @@ export type CodexAttemptTurnWatchController = ReturnType<
* notifications and tool handoffs progress.
*/
export function createCodexAttemptTurnWatchController(params: {
getThreadId: () => string;
threadId: string;
signal: AbortSignal;
getTurnId: () => string | undefined;
isCompleted: () => boolean;
@@ -79,7 +79,6 @@ export function createCodexAttemptTurnWatchController(params: {
const turnTerminalIdleTimeoutMs = resolveTimerTimeoutMs(params.turnTerminalIdleTimeoutMs, 1);
const interruptTimeoutMs = resolveTimerTimeoutMs(params.interruptTimeoutMs, 1);
const resolveWatchTimeoutMs = (timeoutMs: number) => resolveTimerTimeoutMs(timeoutMs, 1);
const currentThreadId = () => params.getThreadId();
const clearCompletionIdleTimer = () => {
if (completionIdleTimer) {
@@ -228,7 +227,7 @@ export function createCodexAttemptTurnWatchController(params: {
clearTerminalIdleTimer();
const turnId = params.getTurnId();
params.onRecordEvent("turn.assistant_completion_idle_release", {
threadId: currentThreadId(),
threadId: params.threadId,
turnId,
idleMs,
timeoutMs: turnAssistantCompletionIdleTimeoutMs,
@@ -237,7 +236,7 @@ export function createCodexAttemptTurnWatchController(params: {
embeddedAgentLog.warn(
"codex app-server turn released after completed assistant item without terminal event",
{
threadId: currentThreadId(),
threadId: params.threadId,
turnId,
idleMs,
timeoutMs: turnAssistantCompletionIdleTimeoutMs,
@@ -246,7 +245,7 @@ export function createCodexAttemptTurnWatchController(params: {
);
if (turnId) {
params.onInterruptTurn({
threadId: currentThreadId(),
threadId: params.threadId,
turnId,
timeoutMs: interruptTimeoutMs,
});
@@ -279,7 +278,7 @@ export function createCodexAttemptTurnWatchController(params: {
params.onTimeout(timeout);
params.onMarkTimedOut();
params.onRecordEvent("turn.progress_idle_timeout", {
threadId: currentThreadId(),
threadId: params.threadId,
turnId: params.getTurnId(),
idleMs,
timeoutMs: timeout.timeoutMs,
@@ -287,7 +286,7 @@ export function createCodexAttemptTurnWatchController(params: {
...timeout.details,
});
embeddedAgentLog.warn("codex app-server turn idle timed out waiting for progress", {
threadId: currentThreadId(),
threadId: params.threadId,
turnId: params.getTurnId(),
idleMs,
timeoutMs: timeout.timeoutMs,
@@ -332,7 +331,7 @@ export function createCodexAttemptTurnWatchController(params: {
params.onTimeout(timeout);
params.onMarkTimedOut();
params.onRecordEvent("turn.completion_idle_timeout", {
threadId: currentThreadId(),
threadId: params.threadId,
turnId: params.getTurnId(),
idleMs,
timeoutMs,
@@ -340,7 +339,7 @@ export function createCodexAttemptTurnWatchController(params: {
...timeout.details,
});
embeddedAgentLog.warn("codex app-server turn idle timed out waiting for completion", {
threadId: currentThreadId(),
threadId: params.threadId,
turnId: params.getTurnId(),
idleMs,
timeoutMs,
@@ -375,7 +374,7 @@ export function createCodexAttemptTurnWatchController(params: {
params.onTimeout(timeout);
params.onMarkTimedOut();
params.onRecordEvent("turn.terminal_idle_timeout", {
threadId: currentThreadId(),
threadId: params.threadId,
turnId: params.getTurnId(),
idleMs,
timeoutMs: timeout.timeoutMs,
@@ -383,7 +382,7 @@ export function createCodexAttemptTurnWatchController(params: {
...timeout.details,
});
embeddedAgentLog.warn("codex app-server turn idle timed out waiting for terminal event", {
threadId: currentThreadId(),
threadId: params.threadId,
turnId: params.getTurnId(),
idleMs,
timeoutMs: timeout.timeoutMs,
@@ -458,11 +457,9 @@ export function createCodexAttemptTurnWatchController(params: {
details?: Record<string, unknown>;
attemptProgress?: boolean;
attemptTimeoutMs?: number;
receivedAtMs?: number;
},
) => {
const now = Date.now();
completionLastActivityAt = Math.min(now, options?.receivedAtMs ?? now);
completionLastActivityAt = Date.now();
completionLastActivityReason = `notification:${method}`;
if (options?.details !== undefined) {
completionLastActivityDetails = options.details;

View File

@@ -8,56 +8,37 @@ import {
} from "openclaw/plugin-sdk/agent-harness";
import { AUTH_PROFILE_RUNTIME_CONTRACT } from "openclaw/plugin-sdk/agent-runtime-test-contracts";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { CodexAppServerClientFactory } from "./client-factory.js";
import { runCodexAppServerAttempt as runCodexAppServerAttemptImpl } from "./run-attempt.js";
import {
readCodexAppServerBinding,
registerCodexTestSessionIdentity,
resetCodexTestBindingStore,
testCodexAppServerBindingStore,
writeCodexAppServerBinding,
} from "./session-binding.test-helpers.js";
import type { CodexAppServerClientLeaseFactory } from "./shared-client.js";
import {
adaptCodexTestClientFactory,
createCodexTestModel,
type CodexTestAppServerClientFactory,
} from "./test-support.js";
import { readCodexAppServerBinding, writeCodexAppServerBinding } from "./session-binding.js";
import { createCodexTestModel } from "./test-support.js";
let codexAppServerClientLeaseFactoryForTest: CodexAppServerClientLeaseFactory | undefined;
let codexAppServerClientFactoryForTest: CodexAppServerClientFactory | undefined;
type RunCodexAppServerAttemptImplOptions = NonNullable<
type RunCodexAppServerAttemptOptions = NonNullable<
Parameters<typeof runCodexAppServerAttemptImpl>[1]
>;
type RunCodexAppServerAttemptOptions = Omit<RunCodexAppServerAttemptImplOptions, "bindingStore"> & {
bindingStore?: RunCodexAppServerAttemptImplOptions["bindingStore"];
};
function setCodexAppServerClientFactoryForTest(factory: CodexTestAppServerClientFactory): void {
codexAppServerClientLeaseFactoryForTest = adaptCodexTestClientFactory(factory);
function setCodexAppServerClientFactoryForTest(factory: CodexAppServerClientFactory): void {
codexAppServerClientFactoryForTest = factory;
}
function resetCodexAppServerClientFactoryForTest(): void {
codexAppServerClientLeaseFactoryForTest = undefined;
codexAppServerClientFactoryForTest = undefined;
}
function runCodexAppServerAttempt(
params: EmbeddedRunAttemptParams,
options: RunCodexAppServerAttemptOptions = {},
) {
const clientLeaseFactory = options.clientLeaseFactory ?? codexAppServerClientLeaseFactoryForTest;
return runCodexAppServerAttemptImpl(params, {
...options,
bindingStore: options.bindingStore ?? testCodexAppServerBindingStore,
...(clientLeaseFactory ? { clientLeaseFactory } : {}),
});
const clientFactory = options.clientFactory ?? codexAppServerClientFactoryForTest;
return runCodexAppServerAttemptImpl(
params,
clientFactory ? { ...options, clientFactory } : options,
);
}
function createParams(sessionFile: string, workspaceDir: string): EmbeddedRunAttemptParams {
registerCodexTestSessionIdentity(
sessionFile,
AUTH_PROFILE_RUNTIME_CONTRACT.sessionId,
AUTH_PROFILE_RUNTIME_CONTRACT.sessionKey,
);
return {
prompt: AUTH_PROFILE_RUNTIME_CONTRACT.workspacePrompt,
sessionId: AUTH_PROFILE_RUNTIME_CONTRACT.sessionId,
@@ -130,8 +111,7 @@ function createCodexAuthProfileHarness(params: { startMethod: "thread/start" | "
const seenAuthProfileIds: Array<string | undefined> = [];
const seenAgentDirs: Array<string | undefined> = [];
const requests: Array<{ method: string; params: unknown }> = [];
const notificationHandlers = new Set<(notification: unknown) => Promise<void> | void>();
const requestHandlers = new Set<(request: unknown) => unknown>();
let notify: (notification: unknown) => Promise<void> = async () => undefined;
setCodexAppServerClientFactoryForTest(async (_startOptions, authProfileId, agentDir) => {
seenAuthProfileIds.push(authProfileId);
seenAgentDirs.push(agentDir);
@@ -146,22 +126,13 @@ function createCodexAuthProfileHarness(params: { startMethod: "thread/start" | "
}
throw new Error(`unexpected method: ${method}`);
}),
addNotificationHandler: (handler: (notification: unknown) => Promise<void> | void) => {
notificationHandlers.add(handler);
return () => notificationHandlers.delete(handler);
addNotificationHandler: (handler: (notification: unknown) => Promise<void>) => {
notify = handler;
return () => undefined;
},
addRequestHandler: (handler: (request: unknown) => unknown) => {
requestHandlers.add(handler);
return () => requestHandlers.delete(handler);
},
addCloseHandler: () => () => undefined,
addRequestHandler: () => () => undefined,
} as never;
});
const notify = async (notification: unknown) => {
await Promise.all(
[...notificationHandlers].map((handler) => Promise.resolve(handler(notification))),
);
};
return {
seenAuthProfileIds,
seenAgentDirs,
@@ -187,7 +158,6 @@ describe("Auth profile runtime contract - Codex app-server adapter", () => {
let tmpDir: string;
beforeEach(async () => {
resetCodexTestBindingStore();
vi.useRealTimers();
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-auth-contract-"));
});
@@ -223,7 +193,6 @@ describe("Auth profile runtime contract - Codex app-server adapter", () => {
it("reuses a bound OpenAI Codex auth profile when resume params omit authProfileId", async () => {
const harness = createCodexAuthProfileHarness({ startMethod: "thread/resume" });
const sessionFile = path.join(tmpDir, "session.jsonl");
const params = createParams(sessionFile, tmpDir);
await writeCodexAppServerBinding(sessionFile, {
threadId: "thread-auth-contract",
cwd: tmpDir,
@@ -231,6 +200,7 @@ describe("Auth profile runtime contract - Codex app-server adapter", () => {
dynamicToolsFingerprint: "[]",
});
// authProfileId is intentionally omitted to exercise the resume-bound profile path.
const params = createParams(sessionFile, tmpDir);
const run = runCodexAppServerAttempt(params);
await vi.waitFor(
@@ -248,13 +218,13 @@ describe("Auth profile runtime contract - Codex app-server adapter", () => {
it("prefers an explicit runtime auth profile over a stale persisted binding", async () => {
const harness = createCodexAuthProfileHarness({ startMethod: "thread/resume" });
const sessionFile = path.join(tmpDir, "session.jsonl");
const params = createParams(sessionFile, tmpDir);
await writeCodexAppServerBinding(sessionFile, {
threadId: "thread-auth-contract",
cwd: tmpDir,
authProfileId: "openai:stale",
dynamicToolsFingerprint: "[]",
});
const params = createParams(sessionFile, tmpDir);
params.authProfileId = AUTH_PROFILE_RUNTIME_CONTRACT.openAiCodexProfileId;
const run = runCodexAppServerAttempt(params);

View File

@@ -0,0 +1,67 @@
/**
* Lazy factories for shared and leased Codex app-server clients.
*/
import type { resolveCodexAppServerAuthProfileIdForAgent } from "./auth-bridge.js";
import type { CodexAppServerClient } from "./client.js";
import type { CodexAppServerStartOptions } from "./config.js";
type AuthProfileOrderConfig = Parameters<
typeof resolveCodexAppServerAuthProfileIdForAgent
>[0]["config"];
/** Factory signature used by Codex attempt startup to acquire a client. */
export type CodexAppServerClientFactory = (
startOptions?: CodexAppServerStartOptions,
authProfileId?: string,
agentDir?: string,
config?: AuthProfileOrderConfig,
options?: {
onStartedClient?: (client: CodexAppServerClient) => void;
abandonSignal?: AbortSignal;
},
) => Promise<CodexAppServerClient>;
let sharedClientModulePromise: Promise<typeof import("./shared-client.js")> | null = null;
const loadSharedClientModule = async () => {
sharedClientModulePromise ??= import("./shared-client.js");
return await sharedClientModulePromise;
};
/** Returns the process-shared app-server client for normal attempt reuse. */
export const defaultCodexAppServerClientFactory: CodexAppServerClientFactory = (
startOptions,
authProfileId,
agentDir,
config,
options,
) =>
loadSharedClientModule().then(({ getSharedCodexAppServerClient }) =>
getSharedCodexAppServerClient({
startOptions,
authProfileId,
agentDir,
config,
onStartedClient: options?.onStartedClient,
abandonSignal: options?.abandonSignal,
}),
);
/** Returns a leased shared client so startup can release ownership explicitly. */
export const defaultLeasedCodexAppServerClientFactory: CodexAppServerClientFactory = (
startOptions,
authProfileId,
agentDir,
config,
options,
) =>
loadSharedClientModule().then(({ getLeasedSharedCodexAppServerClient }) =>
getLeasedSharedCodexAppServerClient({
startOptions,
authProfileId,
agentDir,
config,
onStartedClient: options?.onStartedClient,
abandonSignal: options?.abandonSignal,
}),
);

View File

@@ -1,78 +0,0 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import type { CodexAppServerClient } from "./client.js";
import { createClientHarness } from "./test-support.js";
const mocks = vi.hoisted(() => ({
refreshAuth: vi.fn(async () => ({ accessToken: "refreshed", chatgptAccountId: "account" })),
mergeRateLimitUpdate: vi.fn(),
}));
vi.mock("./auth-bridge.js", () => ({
refreshCodexAppServerAuthTokens: mocks.refreshAuth,
}));
vi.mock("./rate-limit-cache.js", () => ({
mergeCodexRateLimitsUpdate: mocks.mergeRateLimitUpdate,
}));
const { ensureCodexAppServerClientRuntime } = await import("./client-runtime.js");
describe("Codex app-server client runtime", () => {
const clients: CodexAppServerClient[] = [];
afterEach(() => {
for (const client of clients) {
client.close();
}
clients.length = 0;
mocks.refreshAuth.mockClear();
mocks.mergeRateLimitUpdate.mockClear();
});
it("installs shared handlers once per physical client", async () => {
const harness = createClientHarness();
clients.push(harness.client);
const context = {
agentDir: "/tmp/agent",
authProfileId: "openai:default",
config: {},
};
const updatedContext = {
...context,
authProfileStore: { version: 1 as const, profiles: {} },
config: { models: { mode: "merge" as const } },
};
const addNotificationHandler = vi.spyOn(harness.client, "addNotificationHandler");
const addRequestHandler = vi.spyOn(harness.client, "addRequestHandler");
const addCloseHandler = vi.spyOn(harness.client, "addCloseHandler");
ensureCodexAppServerClientRuntime(harness.client, context);
ensureCodexAppServerClientRuntime(harness.client, updatedContext);
expect(addNotificationHandler).toHaveBeenCalledTimes(1);
expect(addRequestHandler).toHaveBeenCalledTimes(1);
expect(addCloseHandler).not.toHaveBeenCalled();
harness.send({
method: "account/rateLimits/updated",
params: { rateLimits: { primary: { usedPercent: 12 } } },
});
harness.send({
id: "refresh-1",
method: "account/chatgptAuthTokens/refresh",
params: { reason: "expired" },
});
await vi.waitFor(() => expect(mocks.mergeRateLimitUpdate).toHaveBeenCalledTimes(1));
await vi.waitFor(() => expect(mocks.refreshAuth).toHaveBeenCalledTimes(1));
expect(mocks.refreshAuth).toHaveBeenCalledWith(updatedContext);
expect(mocks.mergeRateLimitUpdate).toHaveBeenCalledWith(harness.client, {
rateLimits: { primary: { usedPercent: 12 } },
});
await vi.waitFor(() =>
expect(harness.writes.map((line) => JSON.parse(line) as unknown)).toContainEqual({
id: "refresh-1",
result: { accessToken: "refreshed", chatgptAccountId: "account" },
}),
);
});
});

View File

@@ -1,50 +0,0 @@
/** Client-scoped Codex auth and account observers. */
import { refreshCodexAppServerAuthTokens } from "./auth-bridge.js";
import type { CodexAppServerClient } from "./client.js";
import type { JsonValue } from "./protocol.js";
import { mergeCodexRateLimitsUpdate } from "./rate-limit-cache.js";
import type { CodexAppServerAuthProfileLookup } from "./session-binding.js";
type ClientRuntimeContext = Omit<CodexAppServerAuthProfileLookup, "agentDir"> & {
agentDir: string;
};
type ClientRuntime = {
context: ClientRuntimeContext;
};
const configuredClients = new WeakMap<CodexAppServerClient, ClientRuntime>();
/** Installs one auth-refresh handler and one rate-limit observer per physical client. */
export function ensureCodexAppServerClientRuntime(
client: CodexAppServerClient,
context: ClientRuntimeContext,
): void {
const existing = configuredClients.get(client);
if (existing) {
// Shared-client keys already isolate agent/auth identity. Keep config fresh
// without installing another physical-client handler set.
existing.context = context;
return;
}
const runtime: ClientRuntime = { context };
configuredClients.set(client, runtime);
client.addRequestHandler(async (request) => {
if (request.method !== "account/chatgptAuthTokens/refresh") {
return undefined;
}
return (await refreshCodexAppServerAuthTokens({
agentDir: runtime.context.agentDir,
authProfileId: runtime.context.authProfileId,
...(runtime.context.authProfileStore
? { authProfileStore: runtime.context.authProfileStore }
: {}),
config: runtime.context.config,
})) as unknown as JsonValue;
});
client.addNotificationHandler((notification) => {
if (notification.method === "account/rateLimits/updated") {
mergeCodexRateLimitsUpdate(client, notification.params);
}
});
}

View File

@@ -50,78 +50,6 @@ describe("CodexAppServerClient", () => {
expect(outbound.method).toBe("model/list");
});
it("keeps a shared thread subscribed until every local owner releases it", async () => {
const harness = createClientHarness();
clients.push(harness.client);
const firstResume = harness.client.request("thread/resume", { threadId: "thread-1" });
const secondResume = harness.client.request("thread/resume", { threadId: "thread-1" });
const [firstRequest, secondRequest] = harness.writes.map((line) => JSON.parse(line)) as Array<{
id: number;
}>;
const resumeResult = {
thread: { id: "thread-1", cwd: "/tmp", status: { type: "idle" } },
model: "gpt-5.5",
};
harness.send({ id: firstRequest?.id, result: resumeResult });
harness.send({ id: secondRequest?.id, result: resumeResult });
await Promise.all([firstResume, secondResume]);
await expect(
harness.client.request("thread/unsubscribe", { threadId: "thread-1" }),
).resolves.toEqual({ status: "unsubscribed" });
expect(harness.writes).toHaveLength(2);
const finalRelease = harness.client.request("thread/unsubscribe", {
threadId: "thread-1",
});
const releaseRequest = JSON.parse(harness.writes[2] ?? "{}") as { id?: number };
harness.send({ id: releaseRequest.id, result: { status: "unsubscribed" } });
await expect(finalRelease).resolves.toEqual({ status: "unsubscribed" });
expect(harness.writes).toHaveLength(3);
});
it("pairs written resume failures without retaining pre-aborted requests", async () => {
const harness = createClientHarness();
clients.push(harness.client);
const firstResume = harness.client.request("thread/resume", { threadId: "thread-1" });
const firstRequest = JSON.parse(harness.writes[0] ?? "{}") as { id?: number };
harness.send({
id: firstRequest.id,
result: {
thread: { id: "thread-1", cwd: "/tmp", status: { type: "idle" } },
model: "gpt-5.5",
},
});
await firstResume;
const failedResume = harness.client.request("thread/resume", { threadId: "thread-1" });
const failedRequest = JSON.parse(harness.writes[1] ?? "{}") as { id?: number };
harness.send({ id: failedRequest.id, error: { code: -32000, message: "resume failed" } });
await expect(failedResume).rejects.toThrow("resume failed");
await expect(
harness.client.request("thread/unsubscribe", { threadId: "thread-1" }),
).resolves.toEqual({ status: "unsubscribed" });
expect(harness.writes).toHaveLength(2);
const controller = new AbortController();
controller.abort();
await expect(
harness.client.request(
"thread/resume",
{ threadId: "thread-1" },
{ signal: controller.signal },
),
).rejects.toThrow("thread/resume aborted");
const unsubscribe = harness.client.request("thread/unsubscribe", { threadId: "thread-1" });
expect(harness.writes).toHaveLength(3);
const unsubscribeRequest = JSON.parse(harness.writes[2] ?? "{}") as { id?: number };
harness.send({ id: unsubscribeRequest.id, result: { status: "unsubscribed" } });
await expect(unsubscribe).resolves.toEqual({ status: "unsubscribed" });
});
it("removes unpaired surrogate code units from outbound JSON-RPC strings", async () => {
const harness = createClientHarness();
clients.push(harness.client);
@@ -142,9 +70,9 @@ describe("CodexAppServerClient", () => {
expect(outbound.params?.nested).toEqual(["lowend", "emoji 🙈 ok"]);
harness.send({
id: JSON.parse(harness.writes[0] ?? "{}").id,
result: { thread: { id: "thread-1" } },
result: { threadId: "thread-1" },
});
await expect(request).resolves.toEqual({ thread: { id: "thread-1" } });
await expect(request).resolves.toEqual({ threadId: "thread-1" });
});
it("logs a redacted preview for malformed app-server messages", async () => {
@@ -212,30 +140,6 @@ describe("CodexAppServerClient", () => {
expect(warn).not.toHaveBeenCalled();
});
it("contains synchronous notification handler failures and continues fanout", async () => {
const warn = vi.spyOn(embeddedAgentLog, "warn").mockImplementation(() => undefined);
const harness = createClientHarness();
clients.push(harness.client);
const laterHandler = vi.fn();
harness.client.addNotificationHandler(() => {
throw new Error("handler exploded");
});
harness.client.addNotificationHandler(laterHandler);
expect(() =>
harness.send({
method: "item/commandExecution/outputDelta",
params: { delta: "still routed" },
}),
).not.toThrow();
await vi.waitFor(() => expect(laterHandler).toHaveBeenCalledTimes(1));
expect(warn).toHaveBeenCalledWith(
"codex app-server notification handler failed",
expect.objectContaining({ error: expect.any(Error) }),
);
});
it("preserves JSON-RPC error codes", async () => {
const harness = createClientHarness();
clients.push(harness.client);
@@ -316,95 +220,6 @@ describe("CodexAppServerClient", () => {
expect(harness.writes).toHaveLength(1);
});
it.each([
{
method: "thread/start" as const,
params: {},
abandonment: "timeout" as const,
expectedError: "thread/start timed out",
},
{
method: "thread/fork" as const,
params: { threadId: "parent-thread" },
abandonment: "abort" as const,
expectedError: "thread/fork aborted",
},
])("unsubscribes a late successful $method after local $abandonment", async (testCase) => {
vi.useFakeTimers();
const harness = createClientHarness();
clients.push(harness.client);
const controller = new AbortController();
const options =
testCase.abandonment === "timeout" ? { timeoutMs: 1 } : { signal: controller.signal };
const request = harness.client.request(testCase.method, testCase.params, options);
const outbound = JSON.parse(harness.writes[0] ?? "{}") as { id?: number };
const rejected = expect(request).rejects.toThrow(testCase.expectedError);
if (testCase.abandonment === "timeout") {
await vi.advanceTimersByTimeAsync(100);
} else {
controller.abort();
}
await rejected;
harness.send({ id: outbound.id, result: { thread: { id: "late-thread" } } });
expect(JSON.parse(harness.writes[1] ?? "{}")).toEqual({
id: expect.any(Number),
method: "thread/unsubscribe",
params: { threadId: "late-thread" },
});
});
it("closes when a late thread creation subscription cannot be released", async () => {
const harness = createClientHarness();
clients.push(harness.client);
const controller = new AbortController();
const request = harness.client.request("thread/start", {}, { signal: controller.signal });
const outbound = JSON.parse(harness.writes[0] ?? "{}") as { id?: number };
const rejected = expect(request).rejects.toThrow("thread/start aborted");
controller.abort();
await rejected;
harness.send({ id: outbound.id, result: { thread: { id: "late-thread" } } });
const unsubscribe = JSON.parse(harness.writes[1] ?? "{}") as { id?: number };
harness.send({
id: unsubscribe.id,
error: { code: -32_000, message: "unsubscribe failed" },
});
await vi.waitFor(() => expect(harness.stdinDestroyed).toBe(true));
});
it("does not unsubscribe a late rejected thread creation", async () => {
const harness = createClientHarness();
clients.push(harness.client);
const controller = new AbortController();
const request = harness.client.request("thread/start", {}, { signal: controller.signal });
const outbound = JSON.parse(harness.writes[0] ?? "{}") as { id?: number };
const rejected = expect(request).rejects.toThrow("thread/start aborted");
controller.abort();
await rejected;
harness.send({ id: outbound.id, error: { code: -32000, message: "start failed" } });
expect(harness.writes).toHaveLength(1);
});
it("closes after the bounded late-creation cleanup ledger fills", async () => {
const harness = createClientHarness();
clients.push(harness.client);
for (let index = 0; index < 129; index += 1) {
const controller = new AbortController();
const request = harness.client.request("thread/start", {}, { signal: controller.signal });
const rejected = expect(request).rejects.toThrow("thread/start aborted");
controller.abort();
await rejected;
}
expect(harness.stdinDestroyed).toBe(true);
});
it("initializes with the required client version", async () => {
const { harness, initializing, outbound } = startInitialize();
harness.send({
@@ -701,26 +516,6 @@ describe("CodexAppServerClient", () => {
});
});
it.each(["execCommandApproval", "applyPatchApproval"])(
"fails closed for unhandled legacy %s requests",
async (method) => {
const harness = createClientHarness();
clients.push(harness.client);
harness.send({
id: "legacy-approval-1",
method,
params: { conversationId: "thread-1" },
});
await vi.waitFor(() => expect(harness.writes.length).toBe(1));
expect(JSON.parse(harness.writes[0] ?? "{}")).toEqual({
id: "legacy-approval-1",
result: { decision: "denied" },
});
},
);
it("fails closed for unhandled native app-server approvals", async () => {
const harness = createClientHarness();
clients.push(harness.client);
@@ -738,41 +533,6 @@ describe("CodexAppServerClient", () => {
});
});
it.each([
[
"item/tool/call",
{
contentItems: [
{
type: "inputText",
text: "OpenClaw did not register a handler for this app-server tool call.",
},
],
success: false,
},
],
["item/permissions/requestApproval", { permissions: {}, scope: "turn" }],
["mcpServer/elicitation/request", { action: "decline" }],
[
"item/future/requestApproval",
{
decision: "decline",
reason: "OpenClaw codex app-server bridge does not grant unknown native approvals.",
},
],
])("fails closed for an unhandled %s request", async (method, expected) => {
const harness = createClientHarness();
clients.push(harness.client);
harness.send({ id: "unhandled-1", method, params: { threadId: "thread-1" } });
await vi.waitFor(() => expect(harness.writes.length).toBe(1));
expect(JSON.parse(harness.writes[0] ?? "{}")).toEqual({
id: "unhandled-1",
result: expected,
});
});
it("only treats known Codex app-server approval methods as approvals", () => {
expect(isCodexAppServerApprovalRequest("item/commandExecution/requestApproval")).toBe(true);
expect(isCodexAppServerApprovalRequest("item/fileChange/requestApproval")).toBe(true);

View File

@@ -12,7 +12,6 @@ import {
type CodexInitializeParams,
type CodexInitializeResponse,
isRpcResponse,
readCodexThreadCreationResponseId,
type CodexServerNotification,
type JsonValue,
type RpcMessage,
@@ -35,8 +34,6 @@ const CODEX_APP_SERVER_PARSE_BUFFER_MAX = 1_000_000;
const CODEX_APP_SERVER_PARSE_BUFFER_MAX_LINES = 1_000;
const CODEX_DYNAMIC_TOOL_SERVER_REQUEST_TIMEOUT_MS = 600_000;
const CODEX_APP_SERVER_STDERR_TAIL_MAX = 2_000;
const CODEX_APP_SERVER_ABANDONED_THREAD_CREATION_MAX = 128;
const CODEX_APP_SERVER_LATE_THREAD_CLEANUP_TIMEOUT_MS = 5_000;
const UNPAIRED_SURROGATE_RE =
/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g;
@@ -114,10 +111,7 @@ export class CodexAppServerClient {
private readonly requestHandlers = new Set<CodexServerRequestHandler>();
private readonly notificationHandlers = new Set<CodexServerNotificationHandler>();
private readonly closeHandlers = new Set<(client: CodexAppServerClient) => void>();
private readonly threadSubscriptionOwners = new Map<string, number>();
// Codex may finish a locally abandoned create request. Remember its RPC id
// until response/close so the unknown thread subscription can be released.
private readonly abandonedThreadCreationRequestIds = new Set<number | string>();
private activeSharedLeaseCountProvider: (() => number | undefined) | undefined;
private nextId = 1;
private initialized = false;
private closed = false;
@@ -231,27 +225,11 @@ export class CodexAppServerClient {
if (options.signal?.aborted) {
return Promise.reject(new Error(`${method} aborted`));
}
const requestedThreadId = readRequestThreadId(params);
if (
method === "thread/unsubscribe" &&
requestedThreadId &&
this.releaseThreadSubscriptionOwner(requestedThreadId)
) {
// Codex subscriptions are connection-wide sets. A logical owner can
// release without silencing another turn on the same physical client.
return Promise.resolve({ status: "unsubscribed" } as unknown as T);
}
if (method === "thread/resume" && requestedThreadId) {
// Every resume attempt owns one release, even if the response times out
// or aborts: Codex may have subscribed before OpenClaw saw the outcome.
this.retainThreadSubscriptionOwner(requestedThreadId);
}
const id = this.nextId++;
const message: RpcRequest = { id, method, params: params as JsonValue | undefined };
return new Promise<T>((resolve, reject) => {
let timeout: ReturnType<typeof setTimeout> | undefined;
let cleanupAbort: (() => void) | undefined;
let requestWritten = false;
const cleanup = () => {
if (timeout) {
clearTimeout(timeout);
@@ -260,37 +238,23 @@ export class CodexAppServerClient {
cleanupAbort?.();
cleanupAbort = undefined;
};
const rejectPending = (error: Error, rememberLateThreadCreation = false) => {
const rejectPending = (error: Error) => {
if (!this.pending.has(id)) {
return;
}
this.pending.delete(id);
if (rememberLateThreadCreation && isThreadCreationRequest(method)) {
if (
this.abandonedThreadCreationRequestIds.size >=
CODEX_APP_SERVER_ABANDONED_THREAD_CREATION_MAX
) {
// Lost create responses can hide server subscriptions. Once the
// bounded cleanup ledger fills, closing is the only safe release.
this.closeWithError(
new Error("codex app-server abandoned thread creation limit exceeded"),
);
} else {
this.abandonedThreadCreationRequestIds.add(id);
}
}
cleanup();
reject(error);
};
if (options.timeoutMs && Number.isFinite(options.timeoutMs) && options.timeoutMs > 0) {
timeout = setTimeout(
() => rejectPending(new Error(`${method} timed out`), true),
() => rejectPending(new Error(`${method} timed out`)),
Math.max(100, options.timeoutMs),
);
timeout.unref?.();
}
if (options.signal) {
const abortListener = () => rejectPending(new Error(`${method} aborted`), requestWritten);
const abortListener = () => rejectPending(new Error(`${method} aborted`));
options.signal.addEventListener("abort", abortListener, { once: true });
cleanupAbort = () => options.signal?.removeEventListener("abort", abortListener);
}
@@ -298,12 +262,6 @@ export class CodexAppServerClient {
method,
resolve: (value) => {
cleanup();
if (method === "thread/start" || method === "thread/fork") {
const threadId = readCodexThreadCreationResponseId(value);
if (threadId) {
this.retainThreadSubscriptionOwner(threadId);
}
}
resolve(value as T);
},
reject: (error) => {
@@ -317,7 +275,6 @@ export class CodexAppServerClient {
return;
}
try {
requestWritten = true;
this.writeMessage(message, (error) => rejectPending(error));
} catch (error) {
rejectPending(error instanceof Error ? error : new Error(String(error)));
@@ -342,6 +299,18 @@ export class CodexAppServerClient {
return () => this.notificationHandlers.delete(handler);
}
/** Installs a lease-count provider used to route unscoped notifications. */
setActiveSharedLeaseCountProviderForUnscopedNotifications(
provider: (() => number | undefined) | undefined,
): void {
this.activeSharedLeaseCountProvider = provider;
}
/** Reads the active shared-client lease count when available. */
getActiveSharedLeaseCountForUnscopedNotifications(): number | undefined {
return this.activeSharedLeaseCountProvider?.();
}
/** Registers a close handler and returns its disposer. */
addCloseHandler(handler: (client: CodexAppServerClient) => void): () => void {
this.closeHandlers.add(handler);
@@ -460,15 +429,6 @@ export class CodexAppServerClient {
}
private handleResponse(response: RpcResponse): void {
if (this.abandonedThreadCreationRequestIds.delete(response.id)) {
if (!response.error) {
const threadId = readCodexThreadCreationResponseId(response.result);
if (threadId) {
this.unsubscribeLateThreadCreation(threadId);
}
}
return;
}
const pending = this.pending.get(response.id);
if (!pending) {
return;
@@ -546,14 +506,7 @@ export class CodexAppServerClient {
private handleNotification(notification: CodexServerNotification): void {
for (const handler of this.notificationHandlers) {
let result: Promise<void> | void;
try {
result = handler(notification);
} catch (error) {
embeddedAgentLog.warn("codex app-server notification handler failed", { error });
continue;
}
Promise.resolve(result).catch((error: unknown) => {
Promise.resolve(handler(notification)).catch((error: unknown) => {
embeddedAgentLog.warn("codex app-server notification handler failed", { error });
});
}
@@ -571,54 +524,11 @@ export class CodexAppServerClient {
}
this.closed = true;
this.closeError = error;
this.threadSubscriptionOwners.clear();
this.abandonedThreadCreationRequestIds.clear();
this.lines.close();
this.rejectPendingRequests(error);
return true;
}
private unsubscribeLateThreadCreation(threadId: string): void {
// This late response never registered a local owner. Track the wire
// release anyway; an unconfirmed cleanup makes this client unsafe to pool.
void this.request(
"thread/unsubscribe",
{ threadId },
{ timeoutMs: CODEX_APP_SERVER_LATE_THREAD_CLEANUP_TIMEOUT_MS },
).catch((error: unknown) => {
embeddedAgentLog.debug("codex app-server late thread unsubscribe failed", {
threadId,
error,
});
this.closeWithError(
new Error(`Codex late thread subscription could not be released: ${threadId}`, {
cause: error,
}),
);
});
}
private retainThreadSubscriptionOwner(threadId: string): void {
this.threadSubscriptionOwners.set(
threadId,
(this.threadSubscriptionOwners.get(threadId) ?? 0) + 1,
);
}
/** Returns true when another local owner still needs the wire subscription. */
private releaseThreadSubscriptionOwner(threadId: string): boolean {
const owners = this.threadSubscriptionOwners.get(threadId);
if (owners === undefined) {
return false;
}
if (owners > 1) {
this.threadSubscriptionOwners.set(threadId, owners - 1);
return true;
}
this.threadSubscriptionOwners.delete(threadId);
return false;
}
private rejectPendingRequests(error: Error): void {
for (const pending of this.pending.values()) {
pending.cleanup();
@@ -631,17 +541,6 @@ export class CodexAppServerClient {
}
}
function readRequestThreadId(value: unknown): string | undefined {
if (!isJsonObject(value) || typeof value.threadId !== "string") {
return undefined;
}
return value.threadId.trim() || undefined;
}
function isThreadCreationRequest(method: string): boolean {
return method === "thread/start" || method === "thread/fork";
}
function defaultServerRequestResponse(
request: Required<Pick<RpcRequest, "id" | "method">> & { params?: JsonValue },
): JsonValue {
@@ -656,9 +555,6 @@ function defaultServerRequestResponse(
success: false,
};
}
if (request.method === "execCommandApproval" || request.method === "applyPatchApproval") {
return { decision: "denied" };
}
if (
request.method === "item/commandExecution/requestApproval" ||
request.method === "item/fileChange/requestApproval"
@@ -674,12 +570,6 @@ function defaultServerRequestResponse(
reason: "OpenClaw codex app-server bridge does not grant native approvals yet.",
};
}
if (request.method.includes("requestApproval")) {
return {
decision: "decline",
reason: "OpenClaw codex app-server bridge does not grant unknown native approvals.",
};
}
if (request.method === "item/tool/requestUserInput") {
return {
answers: {},

File diff suppressed because it is too large Load Diff

View File

@@ -7,396 +7,145 @@ import {
type EmbeddedAgentCompactResult,
} from "openclaw/plugin-sdk/agent-harness-runtime";
import {
CODEX_APP_SERVER_UNSUBSCRIBE_TIMEOUT_MS,
isCodexAppServerUnsafeSubscriptionError,
settleCodexAppServerClientLease,
} from "./attempt-client-cleanup.js";
import { readCodexNotificationItem } from "./attempt-notifications.js";
import { resolveCodexTurnTerminalIdleTimeoutMs } from "./attempt-timeouts.js";
import { CodexAppServerRpcError } from "./client.js";
defaultLeasedCodexAppServerClientFactory,
type CodexAppServerClientFactory,
} from "./client-factory.js";
import { resolveCodexAppServerRuntimeOptions } from "./config.js";
import { isJsonObject, type JsonObject, type JsonValue } from "./protocol.js";
import type { JsonObject } from "./protocol.js";
import { resolveCodexNativeExecutionBlock } from "./sandbox-guard.js";
import {
CODEX_APP_SERVER_BINDING_GUARDED_REQUEST_TIMEOUT_MS,
sessionBindingIdentity,
type CodexAppServerBindingIdentity,
type CodexAppServerBindingStore,
readCodexAppServerBinding,
withCodexAppServerBindingLock,
writeCodexAppServerBinding,
type CodexAppServerThreadBinding,
} from "./session-binding.js";
import {
leaseSharedCodexAppServerClient,
type CodexAppServerClientLease,
type CodexAppServerClientLeaseFactory,
type CodexAppServerClientOptions,
} from "./shared-client.js";
import { resumeCodexAppServerThread } from "./thread-resume.js";
import { withTimeout } from "./timeout.js";
import {
getCodexAppServerTurnRouter,
isCodexTerminalTurnNotification,
type CodexNativeTurnCompletionWatch,
type CodexThreadRouteReservation,
} from "./turn-router.js";
import { releaseLeasedSharedCodexAppServerClient } from "./shared-client.js";
const warnedIgnoredCompactionOverrides = new Set<string>();
type CodexAppServerCompactOptions = {
bindingStore: CodexAppServerBindingStore;
pluginConfig?: unknown;
clientLeaseFactory?: CodexAppServerClientLeaseFactory;
clientFactory?: CodexAppServerClientFactory;
allowNonManualNativeRequest?: boolean;
};
class CodexNativeTurnBindingChangedError extends Error {}
type CodexNativeTurnRequest = {
bindingStore: CodexAppServerBindingStore;
bindingIdentity: CodexAppServerBindingIdentity;
expectedBinding: CodexAppServerThreadBinding;
pluginConfig?: unknown;
authProfileId?: string;
agentDir?: string;
config?: CodexAppServerClientOptions["config"];
abortSignal?: AbortSignal;
clientLeaseFactory?: CodexAppServerClientLeaseFactory;
};
export type CodexNativeTurnKind = "compact" | "review";
/** Starts one native Codex turn and retains its app-server owner through completion. */
export async function requestCodexNativeTurnForBinding(
params: CodexNativeTurnRequest,
kind: CodexNativeTurnKind,
): Promise<void> {
const isCompaction = kind === "compact";
const label = isCompaction ? "compaction" : "review";
const appServer = resolveCodexAppServerRuntimeOptions({ pluginConfig: params.pluginConfig });
const requestTimeoutMs = Math.min(
appServer.requestTimeoutMs,
CODEX_APP_SERVER_BINDING_GUARDED_REQUEST_TIMEOUT_MS,
);
await params.bindingStore.withLease(params.bindingIdentity, async () => {
const currentBinding = await params.bindingStore.read(params.bindingIdentity);
if (!currentBinding || !isSameNativeTurnBinding(currentBinding, params.expectedBinding)) {
throw new CodexNativeTurnBindingChangedError(
`Codex thread binding changed before native ${label}`,
);
}
const clientLease = await (params.clientLeaseFactory ?? leaseSharedCodexAppServerClient)({
startOptions: appServer.start,
authProfileId: params.authProfileId ?? currentBinding.authProfileId,
agentDir: params.agentDir,
config: params.config,
abandonSignal: params.abortSignal,
timeoutMs: appServer.requestTimeoutMs,
});
const client = clientLease.client;
let subscribedThreadId: string | undefined;
let abandonClient = false;
let lifecycleTransferred = false;
let awaitingNativeTurnStart = false;
const terminalTurnsBeforeWatch = new Set<string>();
let route: CodexThreadRouteReservation | undefined;
let completionWatch: CodexNativeTurnCompletionWatch | undefined;
let observedContextCompaction = false;
let bindingInvalidated = false;
let resolveNativeTurnStarted!: () => void;
const nativeTurnStarted = new Promise<void>((resolve) => {
resolveNativeTurnStarted = resolve;
});
try {
const router = getCodexAppServerTurnRouter(client);
route = router.reserveThread({
threadId: currentBinding.threadId,
onNotificationReceived: (notification, scope) => {
const contextCompactionStarted =
isCompaction &&
Boolean(scope.turnId) &&
notification.method === "item/started" &&
readCodexNotificationItem(notification.params)?.type === "contextCompaction";
if (contextCompactionStarted) {
observedContextCompaction = true;
}
if (!awaitingNativeTurnStart || !scope.turnId) {
return;
}
if (isCodexTerminalTurnNotification(notification)) {
terminalTurnsBeforeWatch.add(scope.turnId);
}
if (contextCompactionStarted) {
completionWatch ??= router.watchNativeTurnCompletion({
threadId: currentBinding.threadId,
turnId: scope.turnId,
timeoutMs: resolveCodexTurnTerminalIdleTimeoutMs(undefined),
});
resolveNativeTurnStarted();
}
},
onNotification: () => undefined,
});
throwIfCodexNativeTurnAborted(params.abortSignal, kind);
let resumed;
try {
subscribedThreadId = currentBinding.threadId;
resumed = await resumeCodexAppServerThread({
client,
abandonClient: clientLease.abandon,
request: {
threadId: currentBinding.threadId,
excludeTurns: true,
persistExtendedHistory: true,
},
timeoutMs: requestTimeoutMs,
signal: params.abortSignal,
});
} catch (error) {
abandonClient = isCodexAppServerUnsafeSubscriptionError(error);
throw error;
}
const invalidateNativeContextBinding = async () => {
if (bindingInvalidated) {
return;
}
const invalidated = await params.bindingStore.mutate(params.bindingIdentity, {
kind: "invalidate-native-context",
threadId: currentBinding.threadId,
...(isCompaction ? { invalidateContextEngineProjection: true as const } : {}),
});
if (!invalidated) {
throw new CodexNativeTurnBindingChangedError(
`Codex thread binding changed before native ${label}`,
);
}
bindingInvalidated = true;
};
if (isCompaction && observedContextCompaction) {
await invalidateNativeContextBinding();
}
if (resumed.thread.status?.type === "active") {
throw new Error(
`Codex thread already has an active turn; retry ${label} after it finishes`,
);
}
throwIfCodexNativeTurnAborted(params.abortSignal, kind);
await invalidateNativeContextBinding();
awaitingNativeTurnStart = true;
let requestResult: JsonValue | undefined;
try {
requestResult = await client.request(
isCompaction ? "thread/compact/start" : "review/start",
isCompaction
? { threadId: currentBinding.threadId }
: { threadId: currentBinding.threadId, target: { type: "uncommittedChanges" } },
{ timeoutMs: requestTimeoutMs },
);
} catch (error) {
const requestRejected = error instanceof CodexAppServerRpcError;
if (requestRejected) {
// A structured rejection proves this request did not start a native
// turn. Preserve only compaction already observed on the same thread.
completionWatch?.cancel();
completionWatch = undefined;
if (!isCompaction || !observedContextCompaction) {
const restored = await params.bindingStore.mutate(params.bindingIdentity, {
kind: "set",
binding: currentBinding,
});
if (!restored) {
throw new Error(`Codex thread binding changed after native ${label} was rejected`, {
cause: error,
});
}
}
throw error;
}
if (completionWatch) {
embeddedAgentLog.debug(`codex app-server ${kind} request failed after startup`, {
threadId: currentBinding.threadId,
error,
});
} else {
abandonClient = true;
throw error;
}
}
if (!isCompaction) {
try {
const review = assertCodexReviewStartResponse(requestResult);
if (review.reviewThreadId !== currentBinding.threadId) {
throw new Error(
`Codex review/start returned ${review.reviewThreadId} for inline review on ${currentBinding.threadId}`,
);
}
completionWatch = terminalTurnsBeforeWatch.has(review.turnId)
? { completion: Promise.resolve(true), cancel: () => undefined }
: router.watchNativeTurnCompletion({
threadId: currentBinding.threadId,
turnId: review.turnId,
timeoutMs: resolveCodexTurnTerminalIdleTimeoutMs(undefined),
});
} catch (error) {
abandonClient = true;
throw error;
}
} else if (!completionWatch) {
try {
await waitForCodexNativeTurnStart({
started: nativeTurnStarted,
routeSignal: route.signal,
timeoutMs: requestTimeoutMs,
threadId: currentBinding.threadId,
kind,
});
} catch (error) {
// Codex accepted Op::Compact, so missing startup confirmation is
// ambiguous. Keep facts invalidated and retire this connection.
abandonClient = true;
throw error;
}
}
awaitingNativeTurnStart = false;
route.release();
route = undefined;
const transferredWatch = completionWatch;
if (!transferredWatch) {
abandonClient = true;
throw new Error(
`codex app-server ${kind} turn started without a turn id for thread ${currentBinding.threadId}`,
);
}
completionWatch = undefined;
lifecycleTransferred = true;
monitorCodexNativeTurn({
completionWatch: transferredWatch,
clientLease,
subscribedThreadId,
threadId: currentBinding.threadId,
kind,
});
} finally {
if (!lifecycleTransferred) {
completionWatch?.cancel();
route?.release();
await settleCodexAppServerClientLease(clientLease, {
threadId: subscribedThreadId,
timeoutMs: CODEX_APP_SERVER_UNSUBSCRIBE_TIMEOUT_MS,
abandon: abandonClient,
});
}
}
});
}
function assertCodexReviewStartResponse(value: JsonValue | undefined): {
turnId: string;
reviewThreadId: string;
} {
if (
!isJsonObject(value) ||
!isJsonObject(value.turn) ||
typeof value.turn.id !== "string" ||
!value.turn.id.trim() ||
typeof value.reviewThreadId !== "string" ||
!value.reviewThreadId.trim()
) {
throw new Error("invalid Codex review/start response");
}
return { turnId: value.turn.id, reviewThreadId: value.reviewThreadId };
}
function monitorCodexNativeTurn(params: {
completionWatch: CodexNativeTurnCompletionWatch;
clientLease: CodexAppServerClientLease;
subscribedThreadId?: string;
threadId: string;
kind: CodexNativeTurnKind;
}): void {
void (async () => {
const completed = await params.completionWatch.completion;
await settleCodexAppServerClientLease(params.clientLease, {
threadId: params.subscribedThreadId,
timeoutMs: CODEX_APP_SERVER_UNSUBSCRIBE_TIMEOUT_MS,
abandon: !completed,
});
if (!completed) {
embeddedAgentLog.warn(`codex app-server ${params.kind} turn lost terminal confirmation`, {
threadId: params.threadId,
});
}
})().catch(async (error: unknown) => {
await params.clientLease.abandon().catch(() => undefined);
embeddedAgentLog.warn(`codex app-server ${params.kind} turn cleanup failed`, {
threadId: params.threadId,
error,
});
});
}
function throwIfCodexNativeTurnAborted(
signal: AbortSignal | undefined,
kind: CodexNativeTurnKind,
): void {
if (!signal?.aborted) {
return;
}
if (signal.reason instanceof Error) {
throw signal.reason;
}
throw new Error(`codex app-server ${kind} aborted before native turn startup`, {
cause: signal.reason,
});
}
async function waitForCodexNativeTurnStart(params: {
started: Promise<void>;
routeSignal: AbortSignal;
timeoutMs: number;
threadId: string;
kind: CodexNativeTurnKind;
}): Promise<void> {
const signal = params.routeSignal;
let removeAbort: (() => void) | undefined;
const aborted = new Promise<never>((_resolve, reject) => {
const onAbort = () => reject(asNativeTurnAbortError(signal));
signal.addEventListener("abort", onAbort, { once: true });
removeAbort = () => signal.removeEventListener("abort", onAbort);
if (signal.aborted) {
onAbort();
}
});
try {
await withTimeout(
Promise.race([params.started, aborted]),
params.timeoutMs,
`codex app-server ${params.kind} turn did not start for thread ${params.threadId}`,
);
} finally {
removeAbort?.();
}
}
function asNativeTurnAbortError(signal: AbortSignal): Error {
return signal.reason instanceof Error
? signal.reason
: new Error("codex app-server native turn startup aborted", { cause: signal.reason });
}
/**
* Starts native Codex compaction for a manually requested bound session, or
* reports why Codex-owned automatic compaction should handle the trigger.
*/
export async function maybeCompactCodexAppServerSession(
params: CompactEmbeddedAgentSessionParams,
options: CodexAppServerCompactOptions,
options: CodexAppServerCompactOptions = {},
): Promise<EmbeddedAgentCompactResult | undefined> {
warnIfIgnoringOpenClawCompactionOverrides(params);
// Codex owns automatic context-pressure compaction for Codex runtime sessions.
// This entry point starts native Codex compaction for the bound thread and
// returns immediately; Codex applies the compaction inside its app-server.
return compactCodexNativeThread(params, options);
}
function warnIfIgnoringOpenClawCompactionOverrides(
params: CompactEmbeddedAgentSessionParams,
): void {
const ignoredConfig = readIgnoredCompactionOverridePaths(params);
if (ignoredConfig.length === 0) {
return;
}
const warningKey = ignoredConfig.join("\0");
if (warnedIgnoredCompactionOverrides.has(warningKey)) {
return;
}
warnedIgnoredCompactionOverrides.add(warningKey);
embeddedAgentLog.warn(
"ignoring OpenClaw compaction overrides for Codex app-server compaction; Codex uses native server-side compaction",
{
sessionId: params.sessionId,
sessionKey: params.sessionKey,
ignoredConfig,
},
);
}
function readIgnoredCompactionOverridePaths(params: CompactEmbeddedAgentSessionParams): string[] {
const ignored = new Set<string>();
for (const entry of readCompactionOverrideEntries(params)) {
const localProvider =
typeof entry.record.provider === "string" ? entry.record.provider.trim() : "";
const inheritedProvider =
!localProvider && typeof entry.inheritedRecord?.provider === "string"
? entry.inheritedRecord.provider.trim()
: "";
const providerPath = localProvider
? `${entry.path}.compaction.provider`
: inheritedProvider && entry.inheritedPath
? `${entry.inheritedPath}.compaction.provider`
: undefined;
if (typeof entry.record.model === "string" && entry.record.model.trim()) {
ignored.add(`${entry.path}.compaction.model`);
}
if (providerPath) {
ignored.add(providerPath);
}
}
return [...ignored];
}
function readCompactionOverrideEntries(params: CompactEmbeddedAgentSessionParams): Array<{
path: string;
record: Record<string, unknown>;
inheritedRecord?: Record<string, unknown>;
inheritedPath?: string;
}> {
const entries: Array<{
path: string;
record: Record<string, unknown>;
inheritedRecord?: Record<string, unknown>;
inheritedPath?: string;
}> = [];
const defaultCompaction = readRecord(readRecord(params.config?.agents)?.defaults)?.compaction;
const defaultRecord = readRecord(defaultCompaction);
if (defaultRecord) {
entries.push({ path: "agents.defaults", record: defaultRecord });
}
const agentId = readAgentIdFromSessionKey(params.sessionKey ?? params.sandboxSessionKey);
if (!agentId) {
return entries;
}
const agents = Array.isArray(params.config?.agents?.list) ? params.config.agents.list : [];
const activeAgent = agents.find((agent) => {
const id = typeof agent?.id === "string" ? agent.id.trim().toLowerCase() : "";
return id === agentId;
});
const agentCompaction = readRecord(activeAgent)?.compaction;
const agentRecord = readRecord(agentCompaction);
if (agentRecord) {
entries.push({
path: `agents.list.${agentId}`,
record: agentRecord,
inheritedRecord: defaultRecord,
inheritedPath: "agents.defaults",
});
}
return entries;
}
function readAgentIdFromSessionKey(sessionKey: string | undefined): string | undefined {
const parts = sessionKey?.trim().toLowerCase().split(":").filter(Boolean) ?? [];
if (parts.length < 3 || parts[0] !== "agent") {
return undefined;
}
return parts[1]?.trim() || undefined;
}
function readRecord(value: unknown): Record<string, unknown> | undefined {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: undefined;
}
async function compactCodexNativeThread(
params: CompactEmbeddedAgentSessionParams,
options: CodexAppServerCompactOptions,
options: CodexAppServerCompactOptions = {},
): Promise<EmbeddedAgentCompactResult | undefined> {
if (params.trigger !== "manual" && !options.allowNonManualNativeRequest) {
embeddedAgentLog.info("skipping codex app-server compaction for non-manual trigger", {
@@ -423,7 +172,6 @@ async function compactCodexNativeThread(
}
const nativeExecutionBlock = resolveCodexNativeExecutionBlock({
config: params.config,
agentId: params.agentId,
sessionKey: params.sandboxSessionKey ?? params.sessionKey,
sessionId: params.sessionId,
surface: "native compaction",
@@ -431,20 +179,17 @@ async function compactCodexNativeThread(
if (nativeExecutionBlock) {
return { ok: false, compacted: false, reason: nativeExecutionBlock };
}
const bindingIdentity: CodexAppServerBindingIdentity = sessionBindingIdentity({
sessionId: params.sessionId,
sessionKey: params.sessionKey,
agentId: params.agentId,
const appServer = resolveCodexAppServerRuntimeOptions({ pluginConfig: options.pluginConfig });
const initialBinding = await readCodexAppServerBinding(params.sessionFile, {
config: params.config,
});
const initialBinding = await options.bindingStore.read(bindingIdentity);
if (!initialBinding?.threadId) {
return failedCodexThreadBindingCompactionResult(params, {
reason: "no codex app-server thread binding",
recovery: "missing_thread_binding",
});
}
const binding = initialBinding;
let binding = initialBinding;
const requestedAuthProfileId = params.authProfileId?.trim() || undefined;
if (
requestedAuthProfileId &&
@@ -455,42 +200,85 @@ async function compactCodexNativeThread(
// with another profile risks operating on a different Codex account.
return { ok: false, compacted: false, reason: "auth profile mismatch for session binding" };
}
if (options.allowNonManualNativeRequest && params.abortSignal?.aborted) {
const currentBinding = await options.bindingStore.read(bindingIdentity);
return skippedCodexNativeCompactionResult(params, {
reason: "codex app-server compaction aborted before native compaction",
code: "aborted_before_native_compaction",
expectedThreadId: binding.threadId,
currentThreadId: currentBinding?.threadId,
});
}
const shouldReleaseDefaultLease = !options.clientFactory;
const clientFactory = options.clientFactory ?? defaultLeasedCodexAppServerClientFactory;
const client = await clientFactory(
appServer.start,
requestedAuthProfileId ?? binding.authProfileId,
params.agentDir,
params.config,
);
try {
await requestCodexNativeTurnForBinding(
{
bindingIdentity,
bindingStore: options.bindingStore,
expectedBinding: binding,
pluginConfig: options.pluginConfig,
authProfileId: requestedAuthProfileId,
agentDir: params.agentDir,
config: params.config,
abortSignal: params.abortSignal,
clientLeaseFactory: options.clientLeaseFactory,
},
"compact",
);
if (options.allowNonManualNativeRequest) {
const guardedResult = await withCodexAppServerBindingLock(params.sessionFile, async () => {
const currentBinding = await readCodexAppServerBinding(params.sessionFile, {
config: params.config,
});
if (params.abortSignal?.aborted) {
return {
started: false as const,
result: skippedCodexNativeCompactionResult(params, {
reason: "codex app-server compaction aborted before native compaction",
code: "aborted_before_native_compaction",
expectedThreadId: binding.threadId,
currentThreadId: currentBinding?.threadId,
}),
};
}
if (!currentBinding || !isSameNativeCompactionBinding(currentBinding, binding)) {
embeddedAgentLog.warn(
"skipping codex app-server compaction because the thread binding changed",
{
sessionId: params.sessionId,
sessionKey: params.sessionKey,
expectedThreadId: binding.threadId,
currentThreadId: currentBinding?.threadId,
},
);
return {
started: false as const,
result: skippedCodexNativeCompactionResult(params, {
reason: "codex app-server binding changed before native compaction",
code: "binding_changed_before_native_compaction",
expectedThreadId: binding.threadId,
currentThreadId: currentBinding?.threadId,
}),
};
}
binding = currentBinding;
await clearContextEngineProjectionBeforeNativeCompaction({
sessionId: params.sessionId,
sessionFile: params.sessionFile,
binding,
config: params.config,
});
await client.request(
"thread/compact/start",
{
threadId: binding.threadId,
},
{
timeoutMs: Math.min(
appServer.requestTimeoutMs,
CODEX_APP_SERVER_BINDING_GUARDED_REQUEST_TIMEOUT_MS,
),
},
);
return { started: true as const };
});
if (!guardedResult.started) {
return guardedResult.result;
}
} else {
await client.request("thread/compact/start", {
threadId: binding.threadId,
});
}
embeddedAgentLog.info("started codex app-server compaction", {
sessionId: params.sessionId,
threadId: binding.threadId,
});
} catch (error) {
if (
options.allowNonManualNativeRequest &&
error instanceof CodexNativeTurnBindingChangedError
) {
const latestBinding = await options.bindingStore.read(bindingIdentity);
return skippedBindingChangeResult(params, binding.threadId, latestBinding?.threadId);
}
if (isCodexThreadNotFoundError(error)) {
return failedCodexThreadBindingCompactionResult(params, {
threadId: binding.threadId,
@@ -509,6 +297,10 @@ async function compactCodexNativeThread(
compacted: false,
reason: formatCompactionError(error),
};
} finally {
if (shouldReleaseDefaultLease) {
releaseLeasedSharedCodexAppServerClient(client);
}
}
const resultDetails: JsonObject = {
backend: "codex-app-server",
@@ -534,25 +326,6 @@ async function compactCodexNativeThread(
};
}
function skippedBindingChangeResult(
params: CompactEmbeddedAgentSessionParams,
expectedThreadId: string,
currentThreadId: string | undefined,
): EmbeddedAgentCompactResult {
embeddedAgentLog.warn("skipping codex app-server compaction because the thread binding changed", {
sessionId: params.sessionId,
sessionKey: params.sessionKey,
expectedThreadId,
currentThreadId,
});
return skippedCodexNativeCompactionResult(params, {
reason: "codex app-server binding changed before native compaction",
code: "binding_changed_before_native_compaction",
expectedThreadId,
currentThreadId,
});
}
function skippedCodexNativeCompactionResult(
params: CompactEmbeddedAgentSessionParams,
skipped: {
@@ -609,7 +382,39 @@ function failedCodexThreadBindingCompactionResult(
};
}
function isSameNativeTurnBinding(
async function clearContextEngineProjectionBeforeNativeCompaction(params: {
sessionId: string;
sessionFile: string;
binding: CodexAppServerThreadBinding;
config: CompactEmbeddedAgentSessionParams["config"];
}): Promise<void> {
const contextEngineBinding = params.binding.contextEngine;
if (!contextEngineBinding?.projection) {
return;
}
// Native Codex compaction mutates the thread history outside the projection
// guard. Clear only the projection marker so the next turn reprojects context.
await writeCodexAppServerBinding(
params.sessionFile,
{
...params.binding,
contextEngine: {
...contextEngineBinding,
projection: undefined,
},
createdAt: params.binding.createdAt,
},
{ config: params.config },
);
embeddedAgentLog.info("cleared codex context-engine projection before native compaction", {
sessionId: params.sessionId,
threadId: params.binding.threadId,
previousEpoch: contextEngineBinding.projection.epoch,
previousFingerprint: contextEngineBinding.projection.fingerprint,
});
}
function isSameNativeCompactionBinding(
current: CodexAppServerThreadBinding,
expected: CodexAppServerThreadBinding,
): boolean {

View File

@@ -1,7 +1,5 @@
// Codex tests cover config plugin behavior.
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { MAX_TIMER_TIMEOUT_MS } from "openclaw/plugin-sdk/number-runtime";
import { describe, expect, it, vi } from "vitest";
import {
@@ -308,6 +306,7 @@ describe("Codex app-server config", () => {
const switchedLocalModel = resolveCodexModelBackedReviewerPolicyContext({
model: "lmstudio/local-model",
bindingModel: "gpt-5.5",
nativeAuthProfile: true,
});
expect(switchedLocalModel).toEqual({
modelProvider: "lmstudio",
@@ -494,39 +493,6 @@ describe("Codex app-server config", () => {
});
});
it("reloads Codex config.toml policy when Codex can reload it", async () => {
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-config-"));
const codexHome = path.join(agentDir, "codex-home");
const configPath = path.join(codexHome, "config.toml");
await fs.mkdir(codexHome);
try {
await fs.writeFile(configPath, 'openai_base_url = "http://localhost:8080/v1"\n');
const context = { modelProvider: "openai", model: "gpt-5.5", agentDir };
expect(canUseCodexModelBackedApprovalsReviewerForModel(context)).toBe(false);
await fs.writeFile(configPath, 'openai_base_url = "https://api.openai.com/v1"\n');
expect(canUseCodexModelBackedApprovalsReviewerForModel(context)).toBe(true);
} finally {
await fs.rm(agentDir, { recursive: true, force: true });
}
});
it("observes a Codex config.toml created after the first policy check", async () => {
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-config-"));
const codexHome = path.join(agentDir, "codex-home");
const configPath = path.join(codexHome, "config.toml");
await fs.mkdir(codexHome);
try {
const context = { modelProvider: "openai", model: "gpt-5.5", agentDir };
expect(canUseCodexModelBackedApprovalsReviewerForModel(context)).toBe(true);
await fs.writeFile(configPath, 'openai_base_url = "http://localhost:8080/v1"\n');
expect(canUseCodexModelBackedApprovalsReviewerForModel(context)).toBe(false);
} finally {
await fs.rm(agentDir, { recursive: true, force: true });
}
});
it("forces prompting when explicit no-prompt config cannot use model-backed review", () => {
const runtime = resolveRuntimeForTest({
pluginConfig: {
@@ -724,8 +690,8 @@ allowed_sandbox_modes = ["read-only", "workspace-write"]
env: {},
modelProvider: "openai",
requirementsPath: "/custom/codex/requirements.toml",
readRequirementsFile: (requirementsPath) => {
readPaths.push(requirementsPath);
readRequirementsFile: (path) => {
readPaths.push(path);
return 'allowed_sandbox_modes = ["read-only", "workspace-write"]\n';
},
});
@@ -745,8 +711,8 @@ allowed_sandbox_modes = ["read-only", "workspace-write"]
env: { ProgramData: "D:\\ManagedData" },
modelProvider: "openai",
platform: "win32",
readRequirementsFile: (requirementsPath) => {
readPaths.push(requirementsPath);
readRequirementsFile: (path) => {
readPaths.push(path);
return 'allowed_sandbox_modes = ["read-only", "workspace-write"]\n';
},
});
@@ -893,6 +859,7 @@ allowed_sandbox_modes = ["read-only", "workspace-write"]
configured: true,
enabled: true,
allowDestructiveActions: false,
destructiveApprovalMode: "deny",
pluginPolicies: [
{
configKey: "google-calendar",
@@ -900,6 +867,7 @@ allowed_sandbox_modes = ["read-only", "workspace-write"]
pluginName: "google-calendar",
enabled: true,
allowDestructiveActions: true,
destructiveApprovalMode: "allow",
},
{
configKey: "slack",
@@ -907,11 +875,88 @@ allowed_sandbox_modes = ["read-only", "workspace-write"]
pluginName: "slack",
enabled: false,
allowDestructiveActions: false,
destructiveApprovalMode: "deny",
},
],
});
});
it("parses auto native Codex plugin destructive policy", () => {
const config = readCodexPluginConfig({
codexPlugins: {
enabled: true,
allow_destructive_actions: "auto",
plugins: {
"google-calendar": {
marketplaceName: "openai-curated",
pluginName: "google-calendar",
},
slack: {
marketplaceName: "openai-curated",
pluginName: "slack",
allow_destructive_actions: false,
},
gmail: {
marketplaceName: "openai-curated",
pluginName: "gmail",
allow_destructive_actions: true,
},
},
},
});
expect(config.codexPlugins?.allow_destructive_actions).toBe("auto");
expect(resolveCodexPluginsPolicy(config)).toEqual({
configured: true,
enabled: true,
allowDestructiveActions: true,
destructiveApprovalMode: "auto",
pluginPolicies: [
{
configKey: "gmail",
marketplaceName: "openai-curated",
pluginName: "gmail",
enabled: true,
allowDestructiveActions: true,
destructiveApprovalMode: "allow",
},
{
configKey: "google-calendar",
marketplaceName: "openai-curated",
pluginName: "google-calendar",
enabled: true,
allowDestructiveActions: true,
destructiveApprovalMode: "auto",
},
{
configKey: "slack",
marketplaceName: "openai-curated",
pluginName: "slack",
enabled: true,
allowDestructiveActions: false,
destructiveApprovalMode: "deny",
},
],
});
});
it("rejects unsupported native Codex plugin destructive policy strings", () => {
const config = readCodexPluginConfig({
codexPlugins: {
enabled: true,
allow_destructive_actions: "ask",
plugins: {
slack: {
marketplaceName: "openai-curated",
pluginName: "slack",
},
},
},
});
expect(config.codexPlugins).toBeUndefined();
});
it("defaults native Codex plugin destructive policy to enabled", () => {
const policy = resolveCodexPluginsPolicy({
codexPlugins: {
@@ -929,6 +974,7 @@ allowed_sandbox_modes = ["read-only", "workspace-write"]
configured: true,
enabled: true,
allowDestructiveActions: true,
destructiveApprovalMode: "allow",
pluginPolicies: [
{
configKey: "slack",
@@ -936,6 +982,7 @@ allowed_sandbox_modes = ["read-only", "workspace-write"]
pluginName: "slack",
enabled: true,
allowDestructiveActions: true,
destructiveApprovalMode: "allow",
},
],
});

View File

@@ -67,7 +67,8 @@ export type CodexAppServerSandboxMode = "read-only" | "workspace-write" | "dange
type CodexAppServerApprovalsReviewer = "user" | "auto_review" | "guardian_subagent";
type CodexAppServerCommandSource = "managed" | "resolved-managed" | "config" | "env";
export type CodexDynamicToolsLoading = "searchable" | "direct";
export type CodexPluginDestructivePolicy = boolean;
export type CodexPluginDestructivePolicy = boolean | "auto";
export type CodexPluginDestructiveApprovalMode = "allow" | "deny" | "auto";
export const CODEX_PLUGINS_MARKETPLACE_NAME = "openai-curated";
@@ -115,13 +116,15 @@ export type ResolvedCodexPluginPolicy = {
marketplaceName: typeof CODEX_PLUGINS_MARKETPLACE_NAME;
pluginName: string;
enabled: boolean;
allowDestructiveActions: CodexPluginDestructivePolicy;
allowDestructiveActions: boolean;
destructiveApprovalMode: CodexPluginDestructiveApprovalMode;
};
export type ResolvedCodexPluginsPolicy = {
configured: boolean;
enabled: boolean;
allowDestructiveActions: CodexPluginDestructivePolicy;
allowDestructiveActions: boolean;
destructiveApprovalMode: CodexPluginDestructiveApprovalMode;
pluginPolicies: ResolvedCodexPluginPolicy[];
};
@@ -150,11 +153,6 @@ export type CodexAppServerRuntimeOptions = {
serviceTier?: CodexServiceTier;
};
export type CodexAppServerRuntimeResolution = {
appServer: CodexAppServerRuntimeOptions;
modelBackedReviewerAvailable: boolean;
};
export type CodexModelBackedReviewerContext = {
modelProvider?: string;
model?: string;
@@ -263,6 +261,7 @@ const codexAppServerApprovalPolicySchema = z.enum([
const codexAppServerSandboxSchema = z.enum(["read-only", "workspace-write", "danger-full-access"]);
const codexAppServerApprovalsReviewerSchema = z.enum(["user", "auto_review", "guardian_subagent"]);
const codexDynamicToolsLoadingSchema = z.enum(["searchable", "direct"]);
const codexPluginDestructivePolicySchema = z.union([z.boolean(), z.literal("auto")]);
const codexAppServerServiceTierSchema = z
.preprocess(
(value) => (value === null ? null : normalizeCodexServiceTier(value)),
@@ -280,14 +279,14 @@ const codexPluginEntryConfigSchema = z
enabled: z.boolean().optional(),
marketplaceName: z.literal(CODEX_PLUGINS_MARKETPLACE_NAME).optional(),
pluginName: z.string().trim().min(1).optional(),
allow_destructive_actions: z.boolean().optional(),
allow_destructive_actions: codexPluginDestructivePolicySchema.optional(),
})
.strict();
const codexPluginsConfigSchema = z
.object({
enabled: z.boolean().optional(),
allow_destructive_actions: z.boolean().optional(),
allow_destructive_actions: codexPluginDestructivePolicySchema.optional(),
plugins: z.record(z.string(), codexPluginEntryConfigSchema).optional(),
})
.strict();
@@ -385,19 +384,25 @@ export function resolveCodexPluginsPolicy(pluginConfig?: unknown): ResolvedCodex
const config = readCodexPluginConfig(pluginConfig).codexPlugins;
const configured = config !== undefined;
const enabled = config?.enabled === true;
const allowDestructiveActions = config?.allow_destructive_actions ?? true;
const destructivePolicy = resolveCodexPluginDestructivePolicy(
config?.allow_destructive_actions ?? true,
);
const pluginPolicies = Object.entries(config?.plugins ?? {})
.flatMap(([configKey, entry]): ResolvedCodexPluginPolicy[] => {
if (entry.marketplaceName !== CODEX_PLUGINS_MARKETPLACE_NAME || !entry.pluginName) {
return [];
}
const entryDestructivePolicy = resolveCodexPluginDestructivePolicy(
entry.allow_destructive_actions ?? config?.allow_destructive_actions ?? true,
);
return [
{
configKey,
marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME,
pluginName: entry.pluginName,
enabled: enabled && entry.enabled !== false,
allowDestructiveActions: entry.allow_destructive_actions ?? allowDestructiveActions,
allowDestructiveActions: entryDestructivePolicy.allowDestructiveActions,
destructiveApprovalMode: entryDestructivePolicy.destructiveApprovalMode,
},
];
})
@@ -405,39 +410,44 @@ export function resolveCodexPluginsPolicy(pluginConfig?: unknown): ResolvedCodex
return {
configured,
enabled,
allowDestructiveActions,
allowDestructiveActions: destructivePolicy.allowDestructiveActions,
destructiveApprovalMode: destructivePolicy.destructiveApprovalMode,
pluginPolicies,
};
}
type CodexAppServerRuntimeParams = {
pluginConfig?: unknown;
execMode?: OpenClawExecMode;
execPolicy?: OpenClawExecPolicyForCodexAppServer;
modelProvider?: string;
model?: string;
config?: ProviderAuthAliasConfig;
env?: NodeJS.ProcessEnv;
agentDir?: string;
codexConfigToml?: string | null;
requirementsToml?: string | null;
requirementsPath?: string;
readRequirementsFile?: (path: string) => string | undefined;
platform?: NodeJS.Platform;
hostName?: string;
openClawSandboxActive?: boolean;
};
export function resolveCodexAppServerRuntimeOptions(
params: CodexAppServerRuntimeParams = {},
): CodexAppServerRuntimeOptions {
return resolveCodexAppServerRuntime(params).appServer;
function resolveCodexPluginDestructivePolicy(policy: CodexPluginDestructivePolicy): {
allowDestructiveActions: boolean;
destructiveApprovalMode: CodexPluginDestructiveApprovalMode;
} {
if (policy === "auto") {
return { allowDestructiveActions: true, destructiveApprovalMode: "auto" };
}
return {
allowDestructiveActions: policy,
destructiveApprovalMode: policy ? "allow" : "deny",
};
}
/** Resolves runtime options and the model-policy fact computed with them. */
export function resolveCodexAppServerRuntime(
params: CodexAppServerRuntimeParams = {},
): CodexAppServerRuntimeResolution {
export function resolveCodexAppServerRuntimeOptions(
params: {
pluginConfig?: unknown;
execMode?: OpenClawExecMode;
execPolicy?: OpenClawExecPolicyForCodexAppServer;
modelProvider?: string;
model?: string;
config?: ProviderAuthAliasConfig;
env?: NodeJS.ProcessEnv;
agentDir?: string;
codexConfigToml?: string | null;
requirementsToml?: string | null;
requirementsPath?: string;
readRequirementsFile?: (path: string) => string | undefined;
platform?: NodeJS.Platform;
hostName?: string;
openClawSandboxActive?: boolean;
} = {},
): CodexAppServerRuntimeOptions {
const env = params.env ?? process.env;
const config = readCodexPluginConfig(params.pluginConfig).appServer ?? {};
const transport = resolveTransport(config.transport);
@@ -561,46 +571,43 @@ export function resolveCodexAppServerRuntime(
: "implicit";
return {
modelBackedReviewerAvailable: canUseModelBackedReviewer,
appServer: {
start: {
transport,
command,
commandSource,
args: args.length > 0 ? args : ["app-server", "--listen", "stdio://"],
...(url ? { url } : {}),
...(authToken ? { authToken } : {}),
headers,
...(transport === "stdio" && clearEnv.length > 0 ? { clearEnv } : {}),
},
codeModeOnly: config.codeModeOnly === true,
requestTimeoutMs: normalizePositiveNumber(config.requestTimeoutMs, 60_000),
turnCompletionIdleTimeoutMs: normalizePositiveNumber(
config.turnCompletionIdleTimeoutMs,
60_000,
),
...(config.postToolRawAssistantCompletionIdleTimeoutMs !== undefined
? {
postToolRawAssistantCompletionIdleTimeoutMs: normalizePositiveNumber(
config.postToolRawAssistantCompletionIdleTimeoutMs,
60_000,
),
}
: {}),
approvalPolicy: forcedPolicy?.approvalPolicy ?? approvalPolicy,
approvalPolicySource,
sandbox:
forcedPolicy?.sandbox ??
configuredSandbox ??
defaultPolicy?.sandbox ??
(policyMode === "guardian" ? "workspace-write" : "danger-full-access"),
approvalsReviewer:
forcedPolicy?.approvalsReviewer ??
explicitApprovalsReviewer ??
defaultPolicy?.approvalsReviewer ??
(policyMode === "guardian" ? "auto_review" : "user"),
...(serviceTier ? { serviceTier } : {}),
start: {
transport,
command,
commandSource,
args: args.length > 0 ? args : ["app-server", "--listen", "stdio://"],
...(url ? { url } : {}),
...(authToken ? { authToken } : {}),
headers,
...(transport === "stdio" && clearEnv.length > 0 ? { clearEnv } : {}),
},
codeModeOnly: config.codeModeOnly === true,
requestTimeoutMs: normalizePositiveNumber(config.requestTimeoutMs, 60_000),
turnCompletionIdleTimeoutMs: normalizePositiveNumber(
config.turnCompletionIdleTimeoutMs,
60_000,
),
...(config.postToolRawAssistantCompletionIdleTimeoutMs !== undefined
? {
postToolRawAssistantCompletionIdleTimeoutMs: normalizePositiveNumber(
config.postToolRawAssistantCompletionIdleTimeoutMs,
60_000,
),
}
: {}),
approvalPolicy: forcedPolicy?.approvalPolicy ?? approvalPolicy,
approvalPolicySource,
sandbox:
forcedPolicy?.sandbox ??
configuredSandbox ??
defaultPolicy?.sandbox ??
(policyMode === "guardian" ? "workspace-write" : "danger-full-access"),
approvalsReviewer:
forcedPolicy?.approvalsReviewer ??
explicitApprovalsReviewer ??
defaultPolicy?.approvalsReviewer ??
(policyMode === "guardian" ? "auto_review" : "user"),
...(serviceTier ? { serviceTier } : {}),
};
}
@@ -672,6 +679,7 @@ export function resolveCodexModelBackedReviewerPolicyContext(params: {
model?: string;
bindingModelProvider?: string;
bindingModel?: string;
nativeAuthProfile?: boolean;
}): CodexModelBackedReviewerContext {
const provider = params.provider?.trim();
if (provider && provider.toLowerCase() !== "codex") {
@@ -703,7 +711,7 @@ export function resolveCodexModelBackedReviewerPolicyContext(params: {
};
}
return {
modelProvider: undefined,
modelProvider: params.nativeAuthProfile === true ? "openai" : undefined,
model: params.model ?? params.bindingModel,
};
}
@@ -770,7 +778,6 @@ export function codexAppServerStartOptionsKey(
options: CodexAppServerStartOptions,
params: {
authProfileId?: string;
authAccountCacheKey?: string;
agentDir?: string;
fallbackApiKeyCacheKey?: string;
} = {},
@@ -790,7 +797,6 @@ export function codexAppServerStartOptionsKey(
.map(([key, value]) => [key, hashSecretForKey(value, `env:${key}`)]),
clearEnv: [...(options.clearEnv ?? [])].toSorted(),
authProfileId: params.authProfileId ?? null,
authAccountCacheKey: params.authAccountCacheKey ?? null,
agentDir: params.agentDir ?? null,
fallbackApiKeyCacheKey: params.fallbackApiKeyCacheKey ?? null,
});

View File

@@ -9,17 +9,16 @@ import {
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
addSandboxShellDynamicToolsIfAvailable,
buildDynamicTools,
filterCodexDynamicToolsForAllowlist,
hasWildcardCodexToolsAllow,
includeForcedCodexDynamicToolAllow,
prepareDynamicToolCatalog,
resetOpenClawCodingToolsFactoryForTests,
resolveOpenClawCodingToolsSessionKeys,
resolveCodexMessageToolProvider,
setOpenClawCodingToolsFactoryForTests,
shouldEnableCodexAppServerNativeToolSurface,
shouldForceMessageTool,
type OpenClawCodingToolsFactory,
} from "./dynamic-tool-build.js";
import {
filterCodexDynamicTools,
@@ -101,13 +100,13 @@ function createRuntimeDynamicTool(name: string): RuntimeDynamicToolForTest {
async function buildDynamicToolsForTest(
params: EmbeddedRunAttemptParams,
workspaceDir: string,
options: Partial<Parameters<typeof prepareDynamicToolCatalog>[0]> = {},
options: Partial<Parameters<typeof buildDynamicTools>[0]> = {},
) {
const sandboxSessionKey = params.sessionKey;
if (!sandboxSessionKey) {
throw new Error("createParams must provide a sessionKey for Codex dynamic tool tests.");
}
const catalog = await prepareDynamicToolCatalog({
return buildDynamicTools({
params,
resolvedWorkspace: workspaceDir,
effectiveWorkspace: workspaceDir,
@@ -120,7 +119,6 @@ async function buildDynamicToolsForTest(
onYieldDetected: () => undefined,
...options,
});
return catalog.tools;
}
describe("Codex app-server dynamic tool build", () => {
@@ -171,53 +169,6 @@ describe("Codex app-server dynamic tool build", () => {
]);
});
it("prepares runtime and durable tool views from one OpenClaw catalog", async () => {
const messageTool = createRuntimeDynamicTool("message");
const webSearchTool = createRuntimeDynamicTool("web_search");
const heartbeatTool = createRuntimeDynamicTool("heartbeat_respond");
const factory = vi.fn<OpenClawCodingToolsFactory>((options) => [
messageTool,
webSearchTool,
...(options?.enableHeartbeatTool ? [heartbeatTool] : []),
]);
setOpenClawCodingToolsFactoryForTests(factory);
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
const params = createParams(sessionFile, workspaceDir);
params.disableTools = false;
const runtimePlan = createCodexRuntimePlanFixture();
params.runtimePlan = {
...runtimePlan,
tools: {
normalize: (tools: Array<{ name: string }>) =>
tools.filter((tool) => tool.name === "message"),
logDiagnostics: () => undefined,
},
} as unknown as NonNullable<EmbeddedRunAttemptParams["runtimePlan"]>;
const catalog = await prepareDynamicToolCatalog({
params,
resolvedWorkspace: workspaceDir,
effectiveWorkspace: workspaceDir,
sandboxSessionKey: params.sessionKey ?? "agent:main:session-1",
sandbox: { enabled: false, backendId: "docker" } as never,
nativeToolSurfaceEnabled: true,
runAbortController: new AbortController(),
sessionAgentId: "main",
pluginConfig: {},
onYieldDetected: () => undefined,
});
expect(factory).toHaveBeenCalledTimes(1);
expect(factory.mock.calls[0]?.[0]?.enableHeartbeatTool).toBe(true);
expect(catalog.tools.map((tool) => tool.name)).toEqual(["message"]);
expect(catalog.registeredTools.map((tool) => tool.name)).toEqual([
"message",
"web_search",
"heartbeat_respond",
]);
});
it("applies additional Codex dynamic tool excludes without exposing Codex-native tools", () => {
const tools = ["read", "exec", "message", "custom_tool"].map((name) => ({ name }));

View File

@@ -38,9 +38,6 @@ type OpenClawCodingToolsOptions = NonNullable<
export type OpenClawCodingToolsFactory =
(typeof import("openclaw/plugin-sdk/agent-harness"))["createOpenClawCodingTools"];
type OpenClawDynamicTool = ReturnType<OpenClawCodingToolsFactory>[number];
type OpenClawDynamicToolProjection = ReturnType<
typeof filterProviderNormalizableTools<OpenClawDynamicTool>
>;
type OpenClawSandboxContext = Awaited<ReturnType<typeof resolveSandboxContext>>;
type CodexDynamicToolBuildEvent = Parameters<
NonNullable<EmbeddedRunAttemptParams["onAgentEvent"]>
@@ -55,7 +52,6 @@ const CODEX_NATIVE_SANDBOX_TOOL_REQUIREMENTS = [
"apply_patch",
] as const;
const CODEX_MEMORY_FLUSH_DYNAMIC_TOOL_ALLOW = new Set(["read", "write"]);
const CODEX_HEARTBEAT_DYNAMIC_TOOL_NAME = "heartbeat_respond";
/** Runtime inputs needed to derive the exact Codex dynamic tool surface for a turn. */
export type DynamicToolBuildParams = {
@@ -70,6 +66,8 @@ export type DynamicToolBuildParams = {
sessionAgentId: string;
pluginConfig: CodexPluginConfig;
profilerEnabled?: boolean;
forceHeartbeatTool?: boolean;
ignoreRuntimePlan?: boolean;
onYieldDetected: () => void;
onCodexAppServerEvent?: (event: CodexDynamicToolBuildEvent) => void;
};
@@ -130,11 +128,6 @@ type CodexDynamicToolBuildStageSummary = {
stages: CodexDynamicToolBuildStageTiming[];
};
type CodexDynamicToolBuildStageTracker = {
mark: (name: string) => void;
snapshot: () => CodexDynamicToolBuildStageSummary;
};
const CODEX_DYNAMIC_TOOL_BUILD_WARN_TOTAL_MS = 1_000;
const CODEX_DYNAMIC_TOOL_BUILD_WARN_STAGE_MS = 500;
@@ -196,42 +189,17 @@ export function formatCodexDynamicToolBuildStageSummary(
: "none";
}
/** Builds the turn-visible and durable registration views from one OpenClaw tool catalog. */
export async function prepareDynamicToolCatalog(input: DynamicToolBuildParams): Promise<{
tools: OpenClawDynamicTool[];
registeredTools: OpenClawDynamicTool[];
}> {
/** Builds, filters, and normalizes Codex-compatible runtime tools for a single turn. */
export async function buildDynamicTools(input: DynamicToolBuildParams) {
const { params } = input;
if (params.disableTools || !supportsModelTools(params.model)) {
return { tools: [], registeredTools: [] };
return [];
}
// Dynamic tool construction is on the reply hot path, so per-stage
// Date.now/span bookkeeping runs only when the Codex profiler flag is set.
const toolBuildStages = createCodexDynamicToolBuildStageTracker({
enabled: input.profilerEnabled,
});
// The durable schema must include heartbeat_respond across normal and heartbeat
// turns. Build that superset once, then hide it only from normal turn exposure.
const allTools = await buildOpenClawDynamicToolSource(input, toolBuildStages);
const readableTools = filterProviderNormalizableTools(allTools);
toolBuildStages.mark("provider-normalization");
const tools = projectDynamicTools(input, readableTools, toolBuildStages, {
excludeHeartbeatTool: params.trigger !== "heartbeat",
phase: "runtime-tools",
stagePrefix: "runtime",
});
const registeredTools = projectDynamicTools(input, readableTools, toolBuildStages, {
ignoreRuntimePlan: true,
phase: "registered-tools",
reportDiagnostics: false,
stagePrefix: "registered",
});
return { tools, registeredTools };
}
async function buildOpenClawDynamicToolSource(
input: DynamicToolBuildParams,
toolBuildStages: CodexDynamicToolBuildStageTracker,
): Promise<OpenClawDynamicTool[]> {
const { params } = input;
const modelHasVision = params.model.input?.includes("image") ?? false;
const agentDir = params.agentDir ?? resolveAgentDir(params.config ?? {}, input.sessionAgentId);
const createOpenClawCodingTools =
@@ -310,8 +278,8 @@ async function buildOpenClawDynamicToolSource(
sourceReplyDeliveryMode: params.sourceReplyDeliveryMode,
disableMessageTool: params.disableMessageTool,
forceMessageTool: shouldForceMessageTool(params),
enableHeartbeatTool: true,
forceHeartbeatTool: true,
enableHeartbeatTool: params.trigger === "heartbeat" || input.forceHeartbeatTool === true,
forceHeartbeatTool: params.trigger === "heartbeat" || input.forceHeartbeatTool === true,
onYield: (message) => {
input.onYieldDetected();
input.onCodexAppServerEvent?.({
@@ -324,30 +292,10 @@ async function buildOpenClawDynamicToolSource(
},
});
toolBuildStages.mark("create-openclaw-coding-tools");
return allTools;
}
function projectDynamicTools(
input: DynamicToolBuildParams,
source: OpenClawDynamicToolProjection,
toolBuildStages: CodexDynamicToolBuildStageTracker,
options: {
excludeHeartbeatTool?: boolean;
ignoreRuntimePlan?: boolean;
phase?: "runtime-tools" | "registered-tools";
reportDiagnostics?: boolean;
stagePrefix?: string;
} = {},
): OpenClawDynamicTool[] {
const { params } = input;
const markStage = (name: string) =>
toolBuildStages.mark(options.stagePrefix ? `${options.stagePrefix}-${name}` : name);
const preNormalizationDiagnostics: RuntimeToolSchemaDiagnostic[] = [...source.diagnostics];
const readableAllTools = [...source.tools].filter(
(tool) =>
!options.excludeHeartbeatTool ||
normalizeCodexDynamicToolName(tool.name) !== CODEX_HEARTBEAT_DYNAMIC_TOOL_NAME,
);
const preNormalizationDiagnostics: RuntimeToolSchemaDiagnostic[] = [];
const readableAllToolProjection = filterProviderNormalizableTools(allTools);
preNormalizationDiagnostics.push(...readableAllToolProjection.diagnostics);
const readableAllTools = [...readableAllToolProjection.tools];
const codexFilteredTools = addNodeShellDynamicToolsIfNeeded(
addSandboxShellDynamicToolsIfAvailable(
isCodexMemoryFlushRun(params)
@@ -359,18 +307,17 @@ function projectDynamicTools(
readableAllTools,
input,
);
markStage("codex-filtering");
const modelHasVision = params.model.input?.includes("image") ?? false;
toolBuildStages.mark("codex-filtering");
const visionFilteredTools = filterToolsForVisionInputs(codexFilteredTools, {
modelHasVision,
hasInboundImages: (params.images?.length ?? 0) > 0,
});
markStage("vision-filtering");
toolBuildStages.mark("vision-filtering");
const toolsAllow = includeForcedCodexDynamicToolAllow(params.toolsAllow, params);
const filteredTools = filterCodexDynamicToolsForAllowlist(visionFilteredTools, toolsAllow);
markStage("allowlist-filter");
toolBuildStages.mark("allowlist-filter");
const normalizedTools = normalizeAgentRuntimeTools({
runtimePlan: options.ignoreRuntimePlan ? undefined : params.runtimePlan,
runtimePlan: input.ignoreRuntimePlan ? undefined : params.runtimePlan,
tools: filteredTools,
provider: params.provider,
config: params.config,
@@ -379,14 +326,11 @@ function projectDynamicTools(
modelId: params.modelId,
modelApi: params.model.api,
model: params.model,
// Registration is a projection of the already-prepared catalog. Never
// activate another provider runtime while constructing its durable schema.
allowProviderRuntimePluginLoad: options.ignoreRuntimePlan ? false : undefined,
onPreNormalizationSchemaDiagnostics: (diagnostics) =>
preNormalizationDiagnostics.push(...diagnostics),
});
markStage("runtime-normalization");
if (options.reportDiagnostics !== false && preNormalizationDiagnostics.length > 0) {
toolBuildStages.mark("runtime-normalization");
if (preNormalizationDiagnostics.length > 0) {
embeddedAgentLog.warn(
`codex app-server quarantined ${preNormalizationDiagnostics.length} unsupported runtime tool schema${preNormalizationDiagnostics.length === 1 ? "" : "s"} before dynamic tool registration`,
{
@@ -403,7 +347,7 @@ function projectDynamicTools(
}
const summary = toolBuildStages.snapshot();
if (shouldWarnCodexDynamicToolBuildStageSummary(summary)) {
const phase = options.phase ?? "runtime-tools";
const phase = input.forceHeartbeatTool ? "registered-tools" : "runtime-tools";
embeddedAgentLog.warn(
`codex app-server dynamic tool build timings runId=${params.runId} sessionId=${params.sessionId} phase=${phase} totalMs=${summary.totalMs} stages=${formatCodexDynamicToolBuildStageSummary(summary)}`,
{
@@ -417,7 +361,8 @@ function projectDynamicTools(
visionFilteredToolCount: visionFilteredTools.length,
filteredToolCount: filteredTools.length,
normalizedToolCount: normalizedTools.length,
ignoreRuntimePlan: options.ignoreRuntimePlan === true,
forceHeartbeatTool: input.forceHeartbeatTool === true,
ignoreRuntimePlan: input.ignoreRuntimePlan === true,
nativeToolSurfaceEnabled: input.nativeToolSurfaceEnabled === true,
},
);

View File

@@ -1391,6 +1391,45 @@ describe("createCodexDynamicToolBridge", () => {
expect(result.sideEffectEvidence).toBe(true);
});
it("keeps audited core read-only dynamic tools replay-safe", async () => {
const bridge = createBridgeWithToolResult("search", textToolResult("no matches"));
const result = await bridge.handleToolCall({
threadId: "thread-1",
turnId: "turn-1",
callId: "call-1",
namespace: null,
tool: "search",
arguments: { query: "scheduler" },
});
expect(result).toEqual(expectInputText("no matches"));
expect(result.sideEffectEvidence).toBeUndefined();
});
it("keeps async-started read-only tools replay-unsafe", async () => {
const bridge = createBridgeWithToolResult(
"search",
textToolResult("Background task started.", {
async: true,
status: "started",
taskId: "task-1",
}),
);
const result = await bridge.handleToolCall({
threadId: "thread-1",
turnId: "turn-1",
callId: "call-1",
namespace: null,
tool: "search",
arguments: { query: "scheduler" },
});
expect(result.asyncStarted).toBe(true);
expect(result.sideEffectEvidence).toBe(true);
});
it("does not mark pre-execution argument failures as side-effect evidence", async () => {
const execute = vi.fn(async () => textToolResult("should not run"));
const bridge = createCodexDynamicToolBridge({

View File

@@ -12,7 +12,10 @@ import {
filterToolResultMediaUrls,
HEARTBEAT_RESPONSE_TOOL_NAME,
embeddedAgentLog,
getChannelAgentToolMeta,
getPluginToolMeta,
type EmbeddedRunAttemptParams,
isAgentToolReplaySafe,
isToolWrappedWithBeforeToolCallHook,
isToolResultError,
isMessagingTool,
@@ -63,12 +66,6 @@ type CodexDynamicToolHookContext = {
type CodexToolResultHookContext = Omit<CodexDynamicToolHookContext, "config">;
type AgentToolResultObserver = (event: {
toolName: string;
result: unknown;
isError: boolean;
}) => void;
type ProjectedCodexDynamicTool = {
tool: AnyAgentTool;
name: string;
@@ -105,7 +102,7 @@ export type CodexDynamicToolBridge = {
params: CodexDynamicToolCallParams,
options?: {
signal?: AbortSignal;
onAgentToolResult?: AgentToolResultObserver;
onAgentToolResult?: EmbeddedRunAttemptParams["onAgentToolResult"];
},
) => Promise<CodexDynamicToolCallResponse>;
telemetry: {
@@ -184,6 +181,16 @@ export function createCodexDynamicToolBridge(params: {
runtime: "codex",
...toolResultHookContext,
});
const isReplaySafeTool = (tool: AnyAgentTool) =>
isAgentToolReplaySafe(tool, {
declaredReplaySafe: (candidate) => {
const pluginMeta = getPluginToolMeta(candidate as AnyAgentTool);
if (pluginMeta) {
return pluginMeta.replaySafe === true;
}
return getChannelAgentToolMeta(candidate as never) ? false : undefined;
},
});
const legacyExtensionRunner =
createCodexAppServerToolResultExtensionRunner(toolResultHookContext);
const directToolNames = new Set([
@@ -322,11 +329,13 @@ export function createCodexDynamicToolBridge(params: {
isToolResultYield(rawResult) ||
isToolResultYield(result),
);
withDynamicToolAsyncStarted(
const asyncStarted =
isAsyncStartedToolResult(rawResult) || isAsyncStartedToolResult(result);
withDynamicToolAsyncStarted(response, asyncStarted);
return withSideEffectEvidence(
response,
isAsyncStartedToolResult(rawResult) || isAsyncStartedToolResult(result),
asyncStarted || (terminalType !== "blocked" && !isReplaySafeTool(toolEntry.tool)),
);
return withSideEffectEvidence(response, terminalType !== "blocked");
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
notifyAgentToolResult(
@@ -367,7 +376,7 @@ export function createCodexDynamicToolBridge(params: {
},
"error",
),
didStartExecution,
didStartExecution && !isReplaySafeTool(toolEntry.tool),
);
}
},
@@ -375,7 +384,7 @@ export function createCodexDynamicToolBridge(params: {
}
function notifyAgentToolResult(
observer: AgentToolResultObserver | undefined,
observer: EmbeddedRunAttemptParams["onAgentToolResult"] | undefined,
toolName: string,
result: unknown,
isError: boolean,

View File

@@ -157,6 +157,7 @@ function buildConnectorPluginApprovalElicitation(overrides: Record<string, unkno
function createPluginAppPolicyContext(
params: {
allowDestructiveActions?: boolean;
destructiveApprovalMode?: "allow" | "deny" | "auto";
apps?: Array<{ appId: string; pluginName: string; mcpServerNames: string[] }>;
} = {},
) {
@@ -177,6 +178,9 @@ function createPluginAppPolicyContext(
marketplaceName: "openai-curated" as const,
pluginName: app.pluginName,
allowDestructiveActions: params.allowDestructiveActions ?? false,
...(params.destructiveApprovalMode
? { destructiveApprovalMode: params.destructiveApprovalMode }
: {}),
mcpServerNames: app.mcpServerNames,
},
]),
@@ -831,6 +835,275 @@ describe("Codex app-server elicitation bridge", () => {
expect(mockCallGatewayTool).not.toHaveBeenCalled();
});
for (const { name, requestedSchema } of [
{
name: "declines connector-id plugin app elicitations with non-object schemas",
requestedSchema: { type: "string", properties: {} },
},
{
name: "declines connector-id plugin app elicitations without object properties",
requestedSchema: { type: "object" },
},
]) {
it(name, async () => {
const result = await handleCodexAppServerElicitationRequest({
requestParams: buildConnectorPluginApprovalElicitation({ requestedSchema }),
paramsForRun: createParams(),
threadId: "thread-1",
turnId: "turn-1",
pluginAppPolicyContext: createPluginAppPolicyContext({
allowDestructiveActions: true,
apps: [
{
appId: "connector_google_calendar",
pluginName: "google-calendar",
mcpServerNames: [],
},
],
}),
});
expect(result).toEqual({ action: "decline", content: null, _meta: null });
expect(mockCallGatewayTool).not.toHaveBeenCalled();
});
}
it("routes auto connector-id plugin app elicitations through plugin approvals", async () => {
mockCallGatewayTool
.mockResolvedValueOnce({ id: "plugin:approval-calendar", status: "accepted" })
.mockResolvedValueOnce({ id: "plugin:approval-calendar", decision: "allow-once" });
const result = await handleCodexAppServerElicitationRequest({
requestParams: buildConnectorPluginApprovalElicitation(),
paramsForRun: createParams(),
threadId: "thread-1",
turnId: "turn-1",
pluginAppPolicyContext: createPluginAppPolicyContext({
allowDestructiveActions: true,
destructiveApprovalMode: "auto",
apps: [
{
appId: "connector_google_calendar",
pluginName: "google-calendar",
mcpServerNames: [],
},
],
}),
});
expect(result).toEqual({
action: "accept",
content: null,
_meta: null,
});
expect(mockCallGatewayTool.mock.calls.map(([method]) => method)).toEqual([
"plugin.approval.request",
"plugin.approval.waitDecision",
]);
expect(gatewayToolArg(0, 2)).toMatchObject({
allowedDecisions: ["allow-once", "deny"],
title: "Allow Google Calendar to create an event?",
toolName: "codex_mcp_tool_approval",
twoPhase: true,
});
});
it("maps auto plugin allow-always only when Codex offers always persistence", async () => {
mockCallGatewayTool
.mockResolvedValueOnce({ id: "plugin:approval-calendar-always", status: "accepted" })
.mockResolvedValueOnce({
id: "plugin:approval-calendar-always",
decision: "allow-always",
});
const result = await handleCodexAppServerElicitationRequest({
requestParams: buildConnectorPluginApprovalElicitation({
_meta: {
codex_approval_kind: "mcp_tool_call",
source: "connector",
connector_id: "connector_google_calendar",
connector_name: "Google Calendar",
persist: ["session", "always"],
tool_title: "create_event",
},
}),
paramsForRun: createParams(),
threadId: "thread-1",
turnId: "turn-1",
pluginAppPolicyContext: createPluginAppPolicyContext({
allowDestructiveActions: true,
destructiveApprovalMode: "auto",
apps: [
{
appId: "connector_google_calendar",
pluginName: "google-calendar",
mcpServerNames: [],
},
],
}),
});
expect(result).toEqual({
action: "accept",
content: null,
_meta: {
persist: "always",
},
});
expect(gatewayToolArg(0, 2)).toMatchObject({
allowedDecisions: ["allow-once", "allow-always", "deny"],
});
});
it("does not expose allow-always for auto plugin session-only persistence", async () => {
mockCallGatewayTool
.mockResolvedValueOnce({ id: "plugin:approval-calendar-session", status: "accepted" })
.mockResolvedValueOnce({
id: "plugin:approval-calendar-session",
decision: "allow-once",
});
const result = await handleCodexAppServerElicitationRequest({
requestParams: buildConnectorPluginApprovalElicitation({
_meta: {
codex_approval_kind: "mcp_tool_call",
source: "connector",
connector_id: "connector_google_calendar",
connector_name: "Google Calendar",
persist: ["session"],
tool_title: "create_event",
},
requestedSchema: {
type: "object",
properties: {
approve: {
type: "boolean",
title: "Approve this app action",
},
persist: {
type: "string",
title: "Persist choice",
enum: ["session", "always"],
},
},
required: ["approve"],
},
}),
paramsForRun: createParams(),
threadId: "thread-1",
turnId: "turn-1",
pluginAppPolicyContext: createPluginAppPolicyContext({
allowDestructiveActions: true,
destructiveApprovalMode: "auto",
apps: [
{
appId: "connector_google_calendar",
pluginName: "google-calendar",
mcpServerNames: [],
},
],
}),
});
expect(result).toEqual({
action: "accept",
content: {
approve: true,
},
_meta: null,
});
expect(gatewayToolArg(0, 2)).toMatchObject({
allowedDecisions: ["allow-once", "deny"],
});
});
it("declines denied auto plugin app approvals", async () => {
mockCallGatewayTool
.mockResolvedValueOnce({ id: "plugin:approval-calendar-deny", status: "accepted" })
.mockResolvedValueOnce({ id: "plugin:approval-calendar-deny", decision: "deny" });
const result = await handleCodexAppServerElicitationRequest({
requestParams: buildConnectorPluginApprovalElicitation(),
paramsForRun: createParams(),
threadId: "thread-1",
turnId: "turn-1",
pluginAppPolicyContext: createPluginAppPolicyContext({
allowDestructiveActions: true,
destructiveApprovalMode: "auto",
apps: [
{
appId: "connector_google_calendar",
pluginName: "google-calendar",
mcpServerNames: [],
},
],
}),
});
expect(result).toEqual({ action: "decline", content: null, _meta: null });
});
it("fails closed when auto plugin approval routing is unavailable", async () => {
mockCallGatewayTool.mockResolvedValueOnce({
id: "plugin:approval-calendar-unavailable",
decision: null,
});
const result = await handleCodexAppServerElicitationRequest({
requestParams: buildConnectorPluginApprovalElicitation(),
paramsForRun: createParams(),
threadId: "thread-1",
turnId: "turn-1",
pluginAppPolicyContext: createPluginAppPolicyContext({
allowDestructiveActions: true,
destructiveApprovalMode: "auto",
apps: [
{
appId: "connector_google_calendar",
pluginName: "google-calendar",
mcpServerNames: [],
},
],
}),
});
expect(result).toEqual({ action: "decline", content: null, _meta: null });
expect(mockCallGatewayTool.mock.calls.map(([method]) => method)).toEqual([
"plugin.approval.request",
]);
});
it("cancels auto plugin app approvals when the turn aborts", async () => {
const abortController = new AbortController();
mockCallGatewayTool
.mockResolvedValueOnce({ id: "plugin:approval-calendar-abort", status: "accepted" })
.mockImplementationOnce(() => {
abortController.abort(new Error("turn stopped"));
return new Promise(() => {});
});
const result = await handleCodexAppServerElicitationRequest({
requestParams: buildConnectorPluginApprovalElicitation(),
paramsForRun: createParams(),
threadId: "thread-1",
turnId: "turn-1",
pluginAppPolicyContext: createPluginAppPolicyContext({
allowDestructiveActions: true,
destructiveApprovalMode: "auto",
apps: [
{
appId: "connector_google_calendar",
pluginName: "google-calendar",
mcpServerNames: [],
},
],
}),
signal: abortController.signal,
});
expect(result).toEqual({ action: "cancel", content: null, _meta: null });
});
it("declines connector-id plugin app elicitations when destructive actions are disabled", async () => {
const result = await handleCodexAppServerElicitationRequest({
requestParams: buildConnectorPluginApprovalElicitation(),

View File

@@ -9,6 +9,7 @@ import {
mapExecDecisionToOutcome,
requestPluginApproval,
type AppServerApprovalOutcome,
type ExecApprovalDecision,
waitForPluginApprovalDecision,
} from "./plugin-approval-roundtrip.js";
import type {
@@ -28,6 +29,8 @@ type BridgeableApprovalElicitation = {
description: string;
requestedSchema: JsonObject;
meta: JsonObject;
persistHintsMode?: "legacy" | "explicit";
allowedDecisions?: ExecApprovalDecision[];
};
type PluginElicitationResolution =
@@ -111,7 +114,12 @@ export async function handleCodexAppServerElicitationRequest(params: {
logPluginElicitationDecline("missing_active_turn", requestParams);
return declineElicitationResponse();
}
return buildPluginPolicyElicitationResponse(pluginResolution.entry, requestParams);
return await buildPluginPolicyElicitationResponse({
entry: pluginResolution.entry,
requestParams,
paramsForRun: params.paramsForRun,
signal: params.signal,
});
}
const approvalPrompt =
@@ -125,9 +133,10 @@ export async function handleCodexAppServerElicitationRequest(params: {
paramsForRun: params.paramsForRun,
title: approvalPrompt.title,
description: approvalPrompt.description,
allowedDecisions: approvalPrompt.allowedDecisions,
signal: params.signal,
});
return buildElicitationResponse(approvalPrompt.requestedSchema, approvalPrompt.meta, outcome);
return buildElicitationResponse(approvalPrompt, outcome);
}
function matchesCurrentThread(requestParams: JsonObject | undefined, threadId: string): boolean {
@@ -284,28 +293,111 @@ function normalizePluginIdentityText(value: string): string {
return value.toLowerCase().replace(/[^a-z0-9]+/g, "");
}
function buildPluginPolicyElicitationResponse(
entry: PluginAppPolicyContextEntry,
requestParams: JsonObject,
): JsonValue {
if (!entry.allowDestructiveActions) {
logPluginElicitationDecline("destructive_actions_disabled", requestParams);
async function buildPluginPolicyElicitationResponse(params: {
entry: PluginAppPolicyContextEntry;
requestParams: JsonObject;
paramsForRun: EmbeddedRunAttemptParams;
signal?: AbortSignal;
}): Promise<JsonValue> {
const mode = resolvePluginDestructiveApprovalMode(params.entry);
if (mode === "deny") {
logPluginElicitationDecline("destructive_actions_disabled", params.requestParams);
return declineElicitationResponse();
}
const approvalPrompt = readPluginApprovalElicitation(params.entry, params.requestParams);
if (!approvalPrompt) {
logPluginElicitationDecline("unsupported_schema", params.requestParams);
return declineElicitationResponse();
}
const response = buildElicitationResponse(approvalPrompt, "approved-once");
if (isJsonObject(response) && response.action === "accept") {
if (mode === "allow") {
return response;
}
const outcome = await requestPluginApprovalOutcome({
paramsForRun: params.paramsForRun,
title: approvalPrompt.title,
description: approvalPrompt.description,
allowedDecisions: approvalPrompt.allowedDecisions,
signal: params.signal,
});
return buildElicitationResponse(approvalPrompt, outcome);
}
logPluginElicitationDecline("unmappable_schema", params.requestParams);
return declineElicitationResponse();
}
function resolvePluginDestructiveApprovalMode(
entry: PluginAppPolicyContextEntry,
): "allow" | "deny" | "auto" {
return entry.destructiveApprovalMode ?? (entry.allowDestructiveActions ? "allow" : "deny");
}
function readPluginApprovalElicitation(
entry: PluginAppPolicyContextEntry,
requestParams: JsonObject,
): BridgeableApprovalElicitation | undefined {
if (
readString(requestParams, "mode") !== "form" ||
!isJsonObject(requestParams.requestedSchema)
) {
logPluginElicitationDecline("unsupported_schema", requestParams);
return declineElicitationResponse();
return undefined;
}
const requestedSchema = requestParams.requestedSchema;
if (
readString(requestedSchema, "type") !== "object" ||
!isJsonObject(requestedSchema.properties)
) {
return undefined;
}
const meta = isJsonObject(requestParams["_meta"]) ? requestParams["_meta"] : {};
const response = buildElicitationResponse(requestParams.requestedSchema, meta, "approved-once");
if (isJsonObject(response) && response.action === "accept") {
return response;
const title =
sanitizeDisplayText(readString(requestParams, "message") ?? "") || "Codex plugin approval";
const descriptionMeta: JsonObject = { ...meta };
if (!readString(descriptionMeta, MCP_TOOL_APPROVAL_CONNECTOR_NAME_KEY)) {
descriptionMeta[MCP_TOOL_APPROVAL_CONNECTOR_NAME_KEY] = entry.pluginName;
}
logPluginElicitationDecline("unmappable_schema", requestParams);
return declineElicitationResponse();
return {
title,
description: buildApprovalDescription({
title,
meta: descriptionMeta,
requestedSchema,
serverName: sanitizeOptionalDisplayText(readString(requestParams, "serverName")),
}),
requestedSchema,
meta,
persistHintsMode: "explicit",
allowedDecisions: buildApprovalAllowedDecisions(requestedSchema, meta),
};
}
function buildApprovalAllowedDecisions(
requestedSchema: JsonObject,
meta: JsonObject,
): ExecApprovalDecision[] {
return canMapPersistentApproval(requestedSchema, meta)
? ["allow-once", "allow-always", "deny"]
: ["allow-once", "deny"];
}
function canMapPersistentApproval(requestedSchema: JsonObject, meta: JsonObject): boolean {
const persistHints = readPersistHints(meta, "explicit");
if (persistHints.length > 0) {
return persistHints.includes("always");
}
const properties = isJsonObject(requestedSchema.properties) ? requestedSchema.properties : {};
return Object.entries(properties).some(([name, value]) => {
const schema = isJsonObject(value) ? value : undefined;
if (!schema) {
return false;
}
return (
isPersistField({ name, schema, required: false }) &&
chooseAlwaysPersistOptionValue(readEnumOptions(schema)) !== undefined
);
});
}
function declineElicitationResponse(): JsonValue {
@@ -558,6 +650,7 @@ async function requestPluginApprovalOutcome(params: {
paramsForRun: EmbeddedRunAttemptParams;
title: string;
description: string;
allowedDecisions?: ExecApprovalDecision[];
signal?: AbortSignal;
}): Promise<AppServerApprovalOutcome> {
try {
@@ -567,6 +660,7 @@ async function requestPluginApprovalOutcome(params: {
description: params.description,
severity: "warning",
toolName: "codex_mcp_tool_approval",
allowedDecisions: params.allowedDecisions,
});
const approvalId = requestResult?.id;
@@ -584,10 +678,13 @@ async function requestPluginApprovalOutcome(params: {
}
function buildElicitationResponse(
requestedSchema: JsonObject,
meta: JsonObject,
approvalPrompt: Pick<
BridgeableApprovalElicitation,
"requestedSchema" | "meta" | "persistHintsMode"
>,
outcome: AppServerApprovalOutcome,
): JsonValue {
const { requestedSchema, meta } = approvalPrompt;
if (outcome === "cancelled") {
return { action: "cancel", content: null, _meta: null };
}
@@ -595,13 +692,13 @@ function buildElicitationResponse(
return { action: "decline", content: null, _meta: null };
}
const content = buildAcceptedContent(requestedSchema, meta, outcome);
const content = buildAcceptedContent(approvalPrompt, outcome);
if (!content) {
if (hasNoSchemaProperties(requestedSchema)) {
return {
action: "accept",
content: null,
_meta: buildAcceptedMeta(meta, outcome),
_meta: buildAcceptedMeta(meta, outcome, approvalPrompt.persistHintsMode ?? "legacy"),
};
}
embeddedAgentLog.warn("codex MCP approval elicitation approved without a mappable response", {
@@ -611,14 +708,21 @@ function buildElicitationResponse(
});
return { action: "decline", content: null, _meta: null };
}
return { action: "accept", content, _meta: buildAcceptedMeta(meta, outcome) };
return {
action: "accept",
content,
_meta: buildAcceptedMeta(meta, outcome, approvalPrompt.persistHintsMode ?? "legacy"),
};
}
function buildAcceptedContent(
requestedSchema: JsonObject,
meta: JsonObject,
approvalPrompt: Pick<
BridgeableApprovalElicitation,
"requestedSchema" | "meta" | "persistHintsMode"
>,
outcome: AppServerApprovalOutcome,
): JsonObject | undefined {
const { requestedSchema, meta } = approvalPrompt;
const properties = isJsonObject(requestedSchema.properties)
? requestedSchema.properties
: undefined;
@@ -641,7 +745,7 @@ function buildAcceptedContent(
const property = { name, schema, required: required.has(name) };
const next =
readApprovalFieldValue(property, outcome) ??
readPersistFieldValue(property, meta, outcome) ??
readPersistFieldValue(property, meta, outcome, approvalPrompt.persistHintsMode ?? "legacy") ??
readFallbackFieldValue(property, outcome);
if (next === undefined) {
@@ -691,11 +795,12 @@ function readPersistFieldValue(
property: ApprovalPropertyContext,
meta: JsonObject,
outcome: AppServerApprovalOutcome,
persistHintsMode: "legacy" | "explicit",
): JsonValue | undefined {
if (!isPersistField(property) || outcome !== "approved-session") {
return undefined;
}
const persistHints = readPersistHints(meta);
const persistHints = readPersistHints(meta, persistHintsMode);
const options = readEnumOptions(property.schema);
if (options.length === 0) {
return undefined;
@@ -707,6 +812,9 @@ function readPersistFieldValue(
);
return match?.value;
}
if (persistHintsMode === "explicit") {
return chooseAlwaysPersistOptionValue(options);
}
return undefined;
}
@@ -744,7 +852,7 @@ function propertyText(property: ApprovalPropertyContext): string {
.join(" ");
}
function readPersistHints(meta: JsonObject): string[] {
function readPersistHints(meta: JsonObject, mode: "legacy" | "explicit" = "legacy"): string[] {
const raw = meta.persist;
if (typeof raw === "string") {
return [raw];
@@ -752,14 +860,18 @@ function readPersistHints(meta: JsonObject): string[] {
if (Array.isArray(raw)) {
return raw.filter((entry): entry is string => typeof entry === "string");
}
return ["session", "always"];
return mode === "legacy" ? ["session", "always"] : [];
}
function buildAcceptedMeta(meta: JsonObject, outcome: AppServerApprovalOutcome): JsonObject | null {
function buildAcceptedMeta(
meta: JsonObject,
outcome: AppServerApprovalOutcome,
persistHintsMode: "legacy" | "explicit",
): JsonObject | null {
if (outcome !== "approved-session") {
return null;
}
const persist = choosePersistHint(readPersistHints(meta));
const persist = choosePersistHint(readPersistHints(meta, persistHintsMode));
return persist ? { persist } : null;
}
@@ -773,6 +885,20 @@ function choosePersistHint(persistHints: string[]): "always" | "session" | undef
return undefined;
}
function chooseAlwaysPersistOptionValue(
options: Array<{ value: string; label: string }>,
): string | undefined {
const always = options.find((option) => optionMatchesPersist(option, "always"));
return always?.value;
}
function optionMatchesPersist(
option: { value: string; label: string },
persist: "always" | "session",
): boolean {
return option.value.toLowerCase() === persist || option.label.toLowerCase() === persist;
}
function hasNoSchemaProperties(requestedSchema: JsonObject): boolean {
const properties = isJsonObject(requestedSchema.properties) ? requestedSchema.properties : {};
return Object.keys(properties).length === 0;

View File

@@ -24,6 +24,7 @@ import {
type CodexAppServerEventProjectorOptions,
type CodexAppServerToolTelemetry,
} from "./event-projector.js";
import { rememberCodexRateLimits, resetCodexRateLimitCacheForTests } from "./rate-limit-cache.js";
import { createCodexTestModel } from "./test-support.js";
const THREAD_ID = "thread-1";
@@ -107,6 +108,7 @@ afterEach(async () => {
resetAgentEventsForTest();
resetDiagnosticEventsForTest();
resetGlobalHookRunner();
resetCodexRateLimitCacheForTests();
vi.restoreAllMocks();
vi.unstubAllEnvs();
for (const tempDir of tempDirs) {
@@ -307,6 +309,7 @@ describe("CodexAppServerEventProjector", () => {
expect(result.assistantTexts).toEqual(["hello"]);
expect(result.messagesSnapshot.map((message) => message.role)).toEqual(["user", "assistant"]);
expect(result.lastAssistant?.content).toEqual([{ type: "text", text: "hello" }]);
expect(result.currentAttemptAssistant?.content).toEqual([{ type: "text", text: "hello" }]);
expectUsageFields(result.attemptUsage, { input: 3, output: 7, cacheRead: 2, total: 12 });
expectUsageFields(result.lastAssistant?.usage, {
input: 3,
@@ -749,6 +752,7 @@ describe("CodexAppServerEventProjector", () => {
expect(result.assistantTexts).toEqual([]);
expect(result.lastAssistant).toBeUndefined();
expect(result.currentAttemptAssistant).toBeUndefined();
});
it("does not treat app-server interrupted status as a user cancellation by itself", async () => {
@@ -859,11 +863,10 @@ describe("CodexAppServerEventProjector", () => {
});
it("uses Codex rate-limit resets for usage-limit app-server errors", async () => {
const projector = await createProjector();
const resetsAt = Math.ceil(Date.now() / 1000) + 120;
const projector = await createProjector(undefined, {
readRecentRateLimits: () => rateLimitsUpdated(resetsAt).params,
});
await projector.handleNotification(rateLimitsUpdated(resetsAt));
await projector.handleNotification(
forCurrentTurn("error", {
error: {
@@ -884,11 +887,10 @@ describe("CodexAppServerEventProjector", () => {
});
it("uses Codex rate-limit resets for failed turns", async () => {
const projector = await createProjector();
const resetsAt = Math.ceil(Date.now() / 1000) + 120;
const projector = await createProjector(undefined, {
readRecentRateLimits: () => rateLimitsUpdated(resetsAt).params,
});
await projector.handleNotification(rateLimitsUpdated(resetsAt));
await projector.handleNotification(
forCurrentTurn("turn/completed", {
turn: {
@@ -912,8 +914,9 @@ describe("CodexAppServerEventProjector", () => {
});
it("uses a recent Codex rate-limit snapshot when failed turns omit reset details", async () => {
const projector = await createProjector();
const resetsAt = Math.ceil(Date.now() / 1000) + 120;
const rateLimits = {
rememberCodexRateLimits({
rateLimits: {
limitId: "codex",
limitName: "Codex",
@@ -924,9 +927,6 @@ describe("CodexAppServerEventProjector", () => {
rateLimitReachedType: "rate_limit_reached",
},
rateLimitsByLimitId: null,
};
const projector = await createProjector(undefined, {
readRecentRateLimits: () => rateLimits,
});
await projector.handleNotification(
@@ -978,19 +978,19 @@ describe("CodexAppServerEventProjector", () => {
expect(result.promptErrorSource).toBe("prompt");
});
it("normalizes current app-server token usage", async () => {
it("normalizes snake_case current token usage fields", async () => {
const projector = await createProjector();
await projector.handleNotification(agentMessageDelta("done"));
await projector.handleNotification(
forCurrentTurn("thread/tokenUsage/updated", {
tokenUsage: {
total: { totalTokens: 1_000_000 },
last: {
totalTokens: 17,
inputTokens: 8,
cachedInputTokens: 3,
outputTokens: 9,
total: { total_tokens: 1_000_000 },
last_token_usage: {
total_tokens: 17,
input_tokens: 8,
cached_input_tokens: 3,
output_tokens: 9,
},
},
}),
@@ -1054,6 +1054,34 @@ describe("CodexAppServerEventProjector", () => {
expect(JSON.stringify(result.messagesSnapshot)).not.toContain("checking thread context");
});
it("preserves an empty final assistant item after tool activity", async () => {
const projector = await createProjector();
projector.recordDynamicToolCall({
callId: "call-search",
tool: "memory_search",
arguments: { query: "scheduler" },
});
projector.recordDynamicToolResult({
callId: "call-search",
tool: "memory_search",
success: true,
sideEffectEvidence: false,
contentItems: [{ type: "inputText", text: "no matches" }],
});
await projector.handleNotification(
turnCompleted([
{ type: "agentMessage", id: "msg-before-tool", text: "Checking the scheduler now." },
{ type: "agentMessage", id: "msg-final", text: "" },
]),
);
const result = projector.buildResult(buildEmptyToolTelemetry());
expect(result.assistantTexts).toEqual(["Checking the scheduler now."]);
expect(result.currentAttemptAssistant?.content).toEqual([{ type: "text", text: "" }]);
expect(result.replayMetadata).toEqual({ hadPotentialSideEffects: false, replaySafe: true });
});
it("streams commentary agent messages as keyed progress events", async () => {
const onAgentEvent = vi.fn();
const onPartialReply = vi.fn();

View File

@@ -26,7 +26,10 @@ import type { AssistantMessage, Usage } from "openclaw/plugin-sdk/llm";
import { saveMediaBuffer } from "openclaw/plugin-sdk/media-store";
import { asDateTimestampMs } from "openclaw/plugin-sdk/number-runtime";
import { resolveCodexLocalRuntimeAttribution } from "./local-runtime-attribution.js";
import { isCodexNotificationForTurn } from "./notification-correlation.js";
import {
readCodexNotificationThreadId,
readCodexNotificationTurnId,
} from "./notification-correlation.js";
import { readCodexTurn } from "./protocol-validators.js";
import {
isJsonObject,
@@ -37,6 +40,7 @@ import {
type JsonObject,
type JsonValue,
} from "./protocol.js";
import { readRecentCodexRateLimits, rememberCodexRateLimits } from "./rate-limit-cache.js";
import { formatCodexUsageLimitErrorMessage } from "./rate-limits.js";
import { readCodexMirroredSessionHistoryMessages } from "./session-history.js";
import {
@@ -61,7 +65,6 @@ export type CodexAppServerToolTelemetry = {
export type CodexAppServerEventProjectorOptions = {
nativePostToolUseRelayEnabled?: boolean;
readRecentRateLimits?: () => JsonValue | undefined;
trajectoryRecorder?: CodexTrajectoryRecorder | null;
};
@@ -89,6 +92,22 @@ const ZERO_USAGE: Usage = {
},
};
const CURRENT_TOKEN_USAGE_KEYS = [
"last",
"current",
"lastCall",
"lastCallUsage",
"lastTokenUsage",
"last_token_usage",
] as const;
const CODEX_PROMPT_TOTAL_INPUT_KEYS = [
"inputTokens",
"input_tokens",
"promptTokens",
"prompt_tokens",
] as const;
const MAX_TOOL_OUTPUT_DELTA_MESSAGES_PER_ITEM = 20;
const TOOL_TRANSCRIPT_OUTPUT_MAX_CHARS = 12_000;
const MISSING_TOOL_RESULT_ERROR =
@@ -179,6 +198,8 @@ export class CodexAppServerEventProjector {
private tokenUsage: ReturnType<typeof normalizeUsage>;
private guardianReviewCount = 0;
private completedCompactionCount = 0;
private latestRateLimits: JsonValue | undefined;
constructor(
private readonly params: EmbeddedRunAttemptParams,
private readonly threadId: string,
@@ -200,6 +221,11 @@ export class CodexAppServerEventProjector {
if (!params) {
return;
}
if (notification.method === "account/rateLimits/updated") {
this.latestRateLimits = params;
rememberCodexRateLimits(params);
return;
}
if (isHookNotificationMethod(notification.method)) {
if (!this.isHookNotificationForCurrentThread(params)) {
return;
@@ -252,7 +278,7 @@ export class CodexAppServerEventProjector {
await this.handleRawResponseItemCompleted(params);
break;
case "error":
if (params.willRetry === true) {
if (readBooleanAlias(params, ["willRetry", "will_retry"]) === true) {
break;
}
this.promptError = this.formatCodexErrorMessage(params) ?? "codex app-server error";
@@ -287,6 +313,7 @@ export class CodexAppServerEventProjector {
assistantTexts.length > 0
? this.createAssistantMessage(assistantTexts.join("\n\n"))
: undefined;
const currentAttemptAssistant = this.createCurrentAttemptAssistantMessage();
// Each snapshot entry is tagged with a stable mirror identity of the
// shape `${turnId}:${kind}`. The mirror's idempotency key is derived
// from this identity rather than from snapshot position or content
@@ -359,6 +386,7 @@ export class CodexAppServerEventProjector {
assistantTexts,
toolMetas,
lastAssistant,
currentAttemptAssistant,
...(this.lastNativeToolError ? { lastToolError: this.lastNativeToolError } : {}),
didSendViaMessagingTool: toolTelemetry.didSendViaMessagingTool,
messagingToolSentTexts: toolTelemetry.messagingToolSentTexts,
@@ -588,10 +616,10 @@ export class CodexAppServerEventProjector {
this.completedItemIds.add(itemId);
}
this.rememberAssistantPhase(item);
if (item?.type === "agentMessage" && typeof item.text === "string" && item.text) {
if (item?.type === "agentMessage" && typeof item.text === "string") {
this.rememberAssistantItem(item.id);
this.assistantTextByItem.set(item.id, item.text);
if (this.isCommentaryAssistantItem(item.id)) {
if (item.text && this.isCommentaryAssistantItem(item.id)) {
this.emitCommentaryProgress({ itemId: item.id, text: item.text });
}
}
@@ -645,7 +673,9 @@ export class CodexAppServerEventProjector {
private handleTokenUsage(params: JsonObject): void {
const tokenUsage = isJsonObject(params.tokenUsage) ? params.tokenUsage : undefined;
const current = tokenUsage && isJsonObject(tokenUsage.last) ? tokenUsage.last : undefined;
const current =
(tokenUsage ? readFirstJsonObject(tokenUsage, CURRENT_TOKEN_USAGE_KEYS) : undefined) ??
readFirstJsonObject(params, CURRENT_TOKEN_USAGE_KEYS);
if (!current) {
return;
}
@@ -716,7 +746,7 @@ export class CodexAppServerEventProjector {
formatCodexUsageLimitErrorMessage({
message: turn.error?.message,
codexErrorInfo: turn.error?.codexErrorInfo as JsonValue | null | undefined,
rateLimits: this.options.readRecentRateLimits?.(),
rateLimits: this.latestRateLimits ?? readRecentCodexRateLimits(),
}) ??
turn.error?.message ??
"codex app-server turn failed";
@@ -724,7 +754,7 @@ export class CodexAppServerEventProjector {
}
for (const item of turn.items ?? []) {
this.rememberAssistantPhase(item);
if (item.type === "agentMessage" && typeof item.text === "string" && item.text) {
if (item.type === "agentMessage" && typeof item.text === "string") {
this.rememberAssistantItem(item.id);
this.assistantTextByItem.set(item.id, item.text);
}
@@ -1583,7 +1613,7 @@ export class CodexAppServerEventProjector {
formatCodexUsageLimitErrorMessage({
message: error ? readString(error, "message") : undefined,
codexErrorInfo: error?.codexErrorInfo,
rateLimits: this.options.readRecentRateLimits?.(),
rateLimits: this.latestRateLimits ?? readRecentCodexRateLimits(),
}) ?? readCodexErrorNotificationMessage(params)
);
}
@@ -1662,6 +1692,26 @@ export class CodexAppServerEventProjector {
this.assistantItemOrder.push(itemId);
}
private createCurrentAttemptAssistantMessage(): AssistantMessage | undefined {
for (let i = this.assistantItemOrder.length - 1; i >= 0; i -= 1) {
const itemId = this.assistantItemOrder[i];
if (
!itemId ||
this.isCommentaryAssistantItem(itemId) ||
!this.assistantTextByItem.has(itemId)
) {
continue;
}
const text = this.assistantTextByItem.get(itemId) ?? "";
const normalizedText = text.trim();
if (normalizedText && this.toolProgressTexts.has(normalizedText)) {
continue;
}
return this.createAssistantMessage(text);
}
return undefined;
}
private async readMirroredSessionMessages(): Promise<AgentMessage[]> {
return (await readCodexMirroredSessionHistoryMessages(this.params.sessionFile)) ?? [];
}
@@ -1758,7 +1808,9 @@ export class CodexAppServerEventProjector {
}
private isNotificationForTurn(params: JsonObject): boolean {
return isCodexNotificationForTurn(params, this.threadId, this.turnId);
const threadId = readCodexNotificationThreadId(params);
const turnId = readNotificationTurnId(params);
return threadId === this.threadId && turnId === this.turnId;
}
private isHookNotificationForCurrentThread(params: JsonObject): boolean {
@@ -1772,6 +1824,10 @@ function isHookNotificationMethod(method: string): method is "hook/started" | "h
return method === "hook/started" || method === "hook/completed";
}
function readNotificationTurnId(record: JsonObject): string | undefined {
return readCodexNotificationTurnId(record);
}
function readString(record: JsonObject, key: string): string | undefined {
const value = record[key];
return typeof value === "string" ? value : undefined;
@@ -1861,6 +1917,21 @@ function readNonNegativeInteger(record: JsonObject, key: string): number | undef
return value !== undefined && Number.isInteger(value) && value >= 0 ? value : undefined;
}
function readBoolean(record: JsonObject, key: string): boolean | undefined {
const value = record[key];
return typeof value === "boolean" ? value : undefined;
}
function readBooleanAlias(record: JsonObject, keys: readonly string[]): boolean | undefined {
for (const key of keys) {
const value = readBoolean(record, key);
if (value !== undefined) {
return value;
}
}
return undefined;
}
function readCodexErrorNotificationMessage(record: JsonObject): string | undefined {
const error = record.error;
if (isJsonObject(error)) {
@@ -1888,19 +1959,52 @@ function readHookOutputEntries(
});
}
function readFirstJsonObject(record: JsonObject, keys: readonly string[]): JsonObject | undefined {
for (const key of keys) {
const value = record[key];
if (isJsonObject(value)) {
return value;
}
}
return undefined;
}
function readNumberAlias(record: JsonObject, keys: readonly string[]): number | undefined {
for (const key of keys) {
const value = readNumber(record, key);
if (value !== undefined) {
return value;
}
}
return undefined;
}
function normalizeCodexTokenUsage(record: JsonObject): ReturnType<typeof normalizeUsage> {
const promptTotalInput = readNumber(record, "inputTokens");
const cacheRead = readNumber(record, "cachedInputTokens");
const promptTotalInput = readNumberAlias(record, CODEX_PROMPT_TOTAL_INPUT_KEYS);
const cacheRead = readNumberAlias(record, [
"cachedInputTokens",
"cached_input_tokens",
"cacheRead",
"cache_read",
"cache_read_input_tokens",
"cached_tokens",
]);
const input =
promptTotalInput !== undefined && cacheRead !== undefined
? Math.max(0, promptTotalInput - cacheRead)
: promptTotalInput;
: (promptTotalInput ?? readNumber(record, "input"));
return normalizeUsage({
input,
output: readNumber(record, "outputTokens"),
output: readNumberAlias(record, ["outputTokens", "output_tokens", "output"]),
cacheRead,
total: readNumber(record, "totalTokens"),
cacheWrite: readNumberAlias(record, [
"cacheWrite",
"cache_write",
"cacheCreationInputTokens",
"cache_creation_input_tokens",
]),
total: readNumberAlias(record, ["totalTokens", "total_tokens", "total"]),
});
}

View File

@@ -8,10 +8,6 @@ import type { CodexAppServerClient } from "./client.js";
import type { CodexAppServerStartOptions } from "./config.js";
import { readCodexModelListResponse } from "./protocol-validators.js";
import type { CodexModel, CodexReasoningEffortOption } from "./protocol.js";
import {
createIsolatedCodexAppServerClient,
leaseSharedCodexAppServerClient,
} from "./shared-client.js";
/** Normalized model metadata returned by the Codex app-server model listing helper. */
export type CodexAppServerModel = {
@@ -40,11 +36,10 @@ export type CodexAppServerListModelsOptions = {
includeHidden?: boolean;
timeoutMs?: number;
startOptions?: CodexAppServerStartOptions;
authProfileId?: string | null;
authProfileId?: string;
agentDir?: string;
config?: Parameters<typeof resolveCodexAppServerAuthProfileIdForAgent>[0]["config"];
sharedClient?: boolean;
signal?: AbortSignal;
};
/** Lists one Codex app-server model page using the configured auth/client options. */
@@ -59,37 +54,27 @@ export async function listCodexAppServerModels(
/** Walks Codex app-server model pages until exhaustion or the max-page guard. */
export async function listAllCodexAppServerModels(
options: CodexAppServerListModelsOptions & { maxPages?: number } = {},
): Promise<CodexAppServerModelListResult> {
return await withCodexAppServerModelClient(options, async ({ client, timeoutMs }) =>
listAllCodexAppServerModelsWithClient(client, { ...options, timeoutMs }),
);
}
/** Walks all model pages on an already-owned physical app-server client. */
export async function listAllCodexAppServerModelsWithClient(
client: CodexAppServerClient,
options: CodexAppServerListModelsOptions & { maxPages?: number } = {},
): Promise<CodexAppServerModelListResult> {
const maxPages = normalizeMaxPages(options.maxPages);
const timeoutMs = options.timeoutMs ?? 2500;
const models: CodexAppServerModel[] = [];
let cursor = options.cursor;
let nextCursor: string | undefined;
for (let page = 0; page < maxPages; page += 1) {
options.signal?.throwIfAborted();
const result = await requestModelListPage(client, {
...options,
timeoutMs,
cursor,
});
models.push(...result.models);
nextCursor = result.nextCursor;
if (!nextCursor) {
return { models };
return await withCodexAppServerModelClient(options, async ({ client, timeoutMs }) => {
const models: CodexAppServerModel[] = [];
let cursor = options.cursor;
let nextCursor: string | undefined;
for (let page = 0; page < maxPages; page += 1) {
const result = await requestModelListPage(client, {
...options,
timeoutMs,
cursor,
});
models.push(...result.models);
nextCursor = result.nextCursor;
if (!nextCursor) {
return { models };
}
cursor = nextCursor;
}
cursor = nextCursor;
}
return { models, nextCursor, truncated: true };
return { models, nextCursor, truncated: true };
});
}
async function withCodexAppServerModelClient<T>(
@@ -98,32 +83,33 @@ async function withCodexAppServerModelClient<T>(
): Promise<T> {
const timeoutMs = options.timeoutMs ?? 2500;
const useSharedClient = options.sharedClient !== false;
const clientLease = useSharedClient
? await leaseSharedCodexAppServerClient({
const {
createIsolatedCodexAppServerClient,
getLeasedSharedCodexAppServerClient,
releaseLeasedSharedCodexAppServerClient,
} = await import("./shared-client.js");
const client = useSharedClient
? await getLeasedSharedCodexAppServerClient({
startOptions: options.startOptions,
timeoutMs,
authProfileId: options.authProfileId,
agentDir: options.agentDir,
config: options.config,
abandonSignal: options.signal,
})
: undefined;
const client =
clientLease?.client ??
(await createIsolatedCodexAppServerClient({
startOptions: options.startOptions,
timeoutMs,
authProfileId: options.authProfileId,
agentDir: options.agentDir,
config: options.config,
}));
: await createIsolatedCodexAppServerClient({
startOptions: options.startOptions,
timeoutMs,
authProfileId: options.authProfileId,
agentDir: options.agentDir,
config: options.config,
});
try {
return await run({ client, timeoutMs });
} finally {
if (useSharedClient) {
clientLease?.release();
releaseLeasedSharedCodexAppServerClient(client);
} else {
await client.closeAndWait({ exitTimeoutMs: 2_000, forceKillDelayMs: 250 });
client.close();
}
}
}
@@ -139,7 +125,7 @@ async function requestModelListPage(
cursor: options.cursor ?? null,
includeHidden: options.includeHidden ?? null,
},
{ timeoutMs: options.timeoutMs, signal: options.signal },
{ timeoutMs: options.timeoutMs },
);
return readModelListResult(response);
}

View File

@@ -4,12 +4,7 @@
*/
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { resolveSandboxRuntimeStatus } from "openclaw/plugin-sdk/sandbox";
import {
loadSessionStore,
resolveSessionStoreEntry,
resolveStorePath,
type SessionEntry,
} from "openclaw/plugin-sdk/session-store-runtime";
import { getSessionEntry, type SessionEntry } from "openclaw/plugin-sdk/session-store-runtime";
type ExecHost = "sandbox" | "gateway" | "node";
type ExecTarget = "auto" | ExecHost;
@@ -49,21 +44,20 @@ export function resolveCodexNativeExecutionPolicy(params: {
}): CodexNativeExecutionPolicy {
const config = params.config ?? {};
const sessionKey = params.sessionKey?.trim() || params.sessionId?.trim() || undefined;
const agentId = resolvePolicyAgentId({ config, sessionKey, agentId: params.agentId });
const sessionEntry =
params.sessionEntry ??
(params.readRuntimeSessionEntry && sessionKey
? readRuntimeSessionEntryBestEffort(config, sessionKey, agentId)
? readRuntimeSessionEntryBestEffort(sessionKey)
: undefined);
const sandboxAvailable =
params.sandboxAvailable ??
(sessionKey
? resolveSandboxRuntimeStatus({
cfg: config,
agentId,
sessionKey,
}).sandboxed
: false);
const agentId = resolvePolicyAgentId({ config, sessionKey, agentId: params.agentId });
const agentExec = resolvePolicyAgentExec({ config, agentId });
const globalExec = config.tools?.exec;
const requestedExecHost =
@@ -200,17 +194,9 @@ function resolveEffectiveExecHost(params: {
return params.requestedExecHost;
}
function readRuntimeSessionEntryBestEffort(
config: OpenClawConfig,
sessionKey: string,
agentId: string,
): SessionEntry | undefined {
function readRuntimeSessionEntryBestEffort(sessionKey: string): SessionEntry | undefined {
try {
const storePath = resolveStorePath(config.session?.store, { agentId });
return resolveSessionStoreEntry({
store: loadSessionStore(storePath, { skipCache: true }),
sessionKey,
}).existing;
return getSessionEntry({ sessionKey, hydrateSkillPromptRefs: false });
} catch {
return undefined;
}

View File

@@ -13,6 +13,7 @@ import {
addTimerTimeoutGraceMs,
finiteSecondsToTimerSafeMilliseconds,
} from "openclaw/plugin-sdk/number-runtime";
import type { CodexAppServerRuntimeOptions } from "./config.js";
import type { JsonObject, JsonValue } from "./protocol.js";
/** Codex hook events that can be registered through OpenClaw's native relay. */
@@ -23,6 +24,8 @@ export const CODEX_NATIVE_HOOK_RELAY_EVENTS: readonly NativeHookRelayEvent[] = [
"before_agent_finalize",
] as const;
const CODEX_NATIVE_HOOK_RELAY_EVENTS_WITH_APP_SERVER_APPROVALS =
CODEX_NATIVE_HOOK_RELAY_EVENTS.filter((event) => event !== "permission_request");
const CODEX_NATIVE_HOOK_RELAY_MIN_TTL_MS = 30 * 60_000;
/** Extra relay lifetime after the expected turn budget, preventing late hook drops. */
export const CODEX_NATIVE_HOOK_RELAY_TTL_GRACE_MS = 5 * 60_000;
@@ -146,8 +149,9 @@ export function createCodexNativeHookRelay(params: {
allowedEvents: params.events,
ttlMs: resolveCodexNativeHookRelayTtlMs({
explicitTtlMs: params.options?.ttlMs,
operationBudgetMs:
params.attemptTimeoutMs + params.startupTimeoutMs + params.turnStartTimeoutMs,
attemptTimeoutMs: params.attemptTimeoutMs,
startupTimeoutMs: params.startupTimeoutMs,
turnStartTimeoutMs: params.turnStartTimeoutMs,
}),
signal: params.signal,
command: {
@@ -159,27 +163,38 @@ export function createCodexNativeHookRelay(params: {
});
}
/** Selects the native hook events Codex should install for this thread. */
/** Selects the native hook events Codex should install for the current approval mode. */
export function resolveCodexNativeHookRelayEvents(params: {
configuredEvents?: readonly NativeHookRelayEvent[];
appServer: Pick<CodexAppServerRuntimeOptions, "approvalPolicy">;
}): readonly NativeHookRelayEvent[] {
if (params.configuredEvents?.length) {
return params.configuredEvents;
}
// Thread config is fixed before Codex reports the authoritative provider.
// Install the stable superset; the relay defers permission prompts from guarded turns.
return CODEX_NATIVE_HOOK_RELAY_EVENTS;
// Codex emits PermissionRequest before the app-server approval reviewer has
// resolved the command. In native approval modes, let Codex's app-server
// approval bridge own the real escalation instead of surfacing a stale
// pre-guardian OpenClaw plugin approval prompt.
return params.appServer.approvalPolicy === "never"
? CODEX_NATIVE_HOOK_RELAY_EVENTS
: CODEX_NATIVE_HOOK_RELAY_EVENTS_WITH_APP_SERVER_APPROVALS;
}
/** Derives the native hook relay TTL from the turn budget unless explicitly configured. */
export function resolveCodexNativeHookRelayTtlMs(params: {
explicitTtlMs: number | undefined;
operationBudgetMs: number;
attemptTimeoutMs: number;
startupTimeoutMs: number;
turnStartTimeoutMs: number;
}): number {
if (params.explicitTtlMs !== undefined) {
return params.explicitTtlMs;
}
const relayBudgetMs = params.operationBudgetMs + CODEX_NATIVE_HOOK_RELAY_TTL_GRACE_MS;
const relayBudgetMs =
params.attemptTimeoutMs +
params.startupTimeoutMs +
params.turnStartTimeoutMs +
CODEX_NATIVE_HOOK_RELAY_TTL_GRACE_MS;
return Math.max(CODEX_NATIVE_HOOK_RELAY_MIN_TTL_MS, Math.floor(relayBudgetMs));
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -4,7 +4,6 @@ import {
extractCodexNativeSubagentCompletions,
extractCodexNativeSubagentCompletionsFromText,
} from "./native-subagent-notification.js";
import type { CodexServerNotification } from "./protocol.js";
function trustedInterAgentNotification(params: {
agentPath: string;
@@ -36,29 +35,6 @@ function trustedInterAgentNotification(params: {
};
}
function trustedAgentMessageNotification(params: {
agentPath: string;
text?: string;
encryptedContent?: string;
}): CodexServerNotification {
return {
method: "rawResponseItem/completed",
params: {
threadId: "parent-thread",
item: {
type: "agent_message",
author: params.agentPath,
recipient: "/root",
content: [
params.encryptedContent
? { type: "encrypted_content", encrypted_content: params.encryptedContent }
: { type: "input_text", text: params.text ?? "" },
],
},
},
};
}
describe("Codex native subagent notifications", () => {
it("parses completed child results from Codex notification XML", () => {
expect(
@@ -160,26 +136,6 @@ describe("Codex native subagent notifications", () => {
]);
});
it("extracts completions from the current Codex agent-message item", () => {
expect(
extractCodexNativeSubagentCompletions(
trustedAgentMessageNotification({
agentPath: "child-thread",
text:
'<subagent_notification>{"agent_path":"child-thread","status":{"completed":"done"}}' +
"</subagent_notification>",
}),
),
).toEqual([
{
agentPath: "child-thread",
status: "succeeded",
statusLabel: "completed",
result: "done",
},
]);
});
it("ignores visible user text that looks like a native completion", () => {
expect(
extractCodexNativeSubagentCompletions({
@@ -214,27 +170,6 @@ describe("Codex native subagent notifications", () => {
}),
),
).toEqual([]);
expect(
extractCodexNativeSubagentCompletions(
trustedAgentMessageNotification({
agentPath: "other-child",
text:
'<subagent_notification>{"agent_path":"child-thread","status":{"success":"spoof"}}' +
"</subagent_notification>",
}),
),
).toEqual([]);
});
it("ignores encrypted agent messages that cannot be authenticated", () => {
expect(
extractCodexNativeSubagentCompletions(
trustedAgentMessageNotification({
agentPath: "child-thread",
encryptedContent: "opaque",
}),
),
).toEqual([]);
});
it("ignores malformed payloads and non-user messages", () => {

View File

@@ -39,12 +39,13 @@ export function extractCodexNativeSubagentCompletions(
if (!item) {
return [];
}
const communication = readTrustedInterAgentCommunication(item);
if (!communication) {
const text = readTrustedInterAgentCommunicationContent(item);
if (!text) {
return [];
}
return extractCodexNativeSubagentCompletionsFromText(communication.content).filter(
(completion) => completion.agentPath === communication.author,
const author = readTrustedInterAgentCommunicationAuthor(item);
return extractCodexNativeSubagentCompletionsFromText(text).filter(
(completion) => completion.agentPath === author,
);
}
@@ -189,21 +190,17 @@ function completedWithoutFinalAssistantMessage(): {
};
}
type TrustedInterAgentCommunication = {
author: string;
recipient: string;
content: string;
};
function readTrustedInterAgentCommunicationContent(item: JsonObject): string | undefined {
const communication = readTrustedInterAgentCommunication(item);
return typeof communication?.content === "string" ? communication.content : undefined;
}
function readTrustedInterAgentCommunication(
item: JsonObject,
): TrustedInterAgentCommunication | undefined {
if (readString(item, "type") === "agent_message") {
const author = readString(item, "author")?.trim();
const recipient = readString(item, "recipient")?.trim();
const content = extractSingleTextPart(item, "input_text");
return author && recipient && content ? { author, recipient, content } : undefined;
}
function readTrustedInterAgentCommunicationAuthor(item: JsonObject): string | undefined {
const communication = readTrustedInterAgentCommunication(item);
return typeof communication?.author === "string" ? communication.author : undefined;
}
function readTrustedInterAgentCommunication(item: JsonObject): JsonObject | undefined {
if (
readString(item, "type") !== "message" ||
readString(item, "role") !== "assistant" ||
@@ -211,7 +208,7 @@ function readTrustedInterAgentCommunication(
) {
return undefined;
}
const text = extractSingleTextPart(item, "output_text", "text");
const text = extractSingleTextPart(item);
if (!text) {
return undefined;
}
@@ -224,20 +221,18 @@ function readTrustedInterAgentCommunication(
if (!isJsonObject(parsed)) {
return undefined;
}
const author = typeof parsed.author === "string" ? parsed.author.trim() : "";
const recipient = typeof parsed.recipient === "string" ? parsed.recipient.trim() : "";
if (
!author ||
!recipient ||
typeof parsed.author !== "string" ||
typeof parsed.recipient !== "string" ||
typeof parsed.content !== "string" ||
parsed.trigger_turn !== false
) {
return undefined;
}
return { author, recipient, content: parsed.content };
return parsed;
}
function extractSingleTextPart(item: JsonObject, ...acceptedTypes: string[]): string | undefined {
function extractSingleTextPart(item: JsonObject): string | undefined {
const content = item.content;
if (!Array.isArray(content) || content.length !== 1) {
return undefined;
@@ -247,7 +242,7 @@ function extractSingleTextPart(item: JsonObject, ...acceptedTypes: string[]): st
return undefined;
}
const type = readString(entry, "type");
if (!type || !acceptedTypes.includes(type)) {
if (type !== "output_text" && type !== "text") {
return undefined;
}
return readString(entry, "text")?.trim();

View File

@@ -56,8 +56,8 @@ export class CodexNativeSubagentTaskMirror {
}
markAuthoritativeCompletionExpected(childThreadId: string): void {
// The monitor recovers the authoritative result through app-server history.
// Keep collab completion as progress so it cannot finalize stale text first.
// Local transcripts and V2 agent paths can supply the real result later.
// Remote V1 lacks both and must keep collab-completed as its fallback.
this.expectedAuthoritativeRunIds.add(codexNativeSubagentRunId(childThreadId));
}

View File

@@ -2,7 +2,28 @@
* Correlates Codex app-server notifications with the active thread/turn so
* projectors can ignore global or stale events without losing diagnostics.
*/
import { isJsonObject, type JsonObject, type JsonValue } from "./protocol.js";
import {
isJsonObject,
type CodexServerNotification,
type JsonObject,
type JsonValue,
} from "./protocol.js";
/** Debug-friendly correlation summary for a Codex app-server notification. */
export type CodexNotificationCorrelation = {
method: string;
paramsKeys?: string[];
activeThreadId: string;
activeTurnId?: string;
threadId?: string;
turnId?: string;
nestedTurnThreadId?: string;
nestedTurnId?: string;
turnStatus?: string;
turnItemCount?: number;
matchesActiveThread: boolean;
matchesActiveTurn?: boolean;
};
/** Returns true when a notification payload belongs to the exact active thread and turn. */
export function isCodexNotificationForTurn(
@@ -19,10 +40,9 @@ export function isCodexNotificationForTurn(
);
}
/** Reads a thread id from canonical top-level or nested thread payloads. */
/** Reads a thread id from either top-level notification params or nested turn payloads. */
export function readCodexNotificationThreadId(record: JsonObject): string | undefined {
const thread = isJsonObject(record.thread) ? record.thread : undefined;
return readString(record, "threadId") ?? (thread ? readString(thread, "id") : undefined);
return readNestedTurnThreadId(record) ?? readString(record, "threadId");
}
/** Reads a turn id from either top-level notification params or nested turn payloads. */
@@ -30,11 +50,50 @@ export function readCodexNotificationTurnId(record: JsonObject): string | undefi
return readNestedTurnId(record) ?? readString(record, "turnId");
}
/** Builds structured correlation details for logs when notification routing is ambiguous. */
export function describeCodexNotificationCorrelation(
notification: CodexServerNotification,
active: { threadId: string; turnId?: string },
): CodexNotificationCorrelation {
const params = isJsonObject(notification.params) ? notification.params : undefined;
const turn = params && isJsonObject(params.turn) ? params.turn : undefined;
const threadId = params ? readString(params, "threadId") : undefined;
const turnId = params ? readString(params, "turnId") : undefined;
const nestedTurnThreadId = turn ? readString(turn, "threadId") : undefined;
const nestedTurnId = turn ? readString(turn, "id") : undefined;
const resolvedThreadId = params ? readCodexNotificationThreadId(params) : undefined;
const resolvedTurnId = params ? readCodexNotificationTurnId(params) : undefined;
const matchesActiveThread = resolvedThreadId === active.threadId;
const matchesActiveTurn = active.turnId
? matchesActiveThread && resolvedTurnId === active.turnId
: undefined;
const items = turn?.items;
return {
method: notification.method,
...(params ? { paramsKeys: Object.keys(params).toSorted() } : {}),
activeThreadId: active.threadId,
...(active.turnId ? { activeTurnId: active.turnId } : {}),
...(threadId ? { threadId } : {}),
...(turnId ? { turnId } : {}),
...(nestedTurnThreadId ? { nestedTurnThreadId } : {}),
...(nestedTurnId ? { nestedTurnId } : {}),
...(turn ? { turnStatus: readString(turn, "status") } : {}),
...(Array.isArray(items) ? { turnItemCount: items.length } : {}),
matchesActiveThread,
...(matchesActiveTurn === undefined ? {} : { matchesActiveTurn }),
};
}
function readNestedTurnId(record: JsonObject): string | undefined {
const turn = record.turn;
return isJsonObject(turn) ? readString(turn, "id") : undefined;
}
function readNestedTurnThreadId(record: JsonObject): string | undefined {
const turn = record.turn;
return isJsonObject(turn) ? readString(turn, "threadId") : undefined;
}
function readString(record: JsonObject, key: string): string | undefined {
const value = record[key];
return typeof value === "string" && value.trim() ? value.trim() : undefined;

View File

@@ -303,6 +303,7 @@ function identity(pluginName: string): ResolvedCodexPluginPolicy {
pluginName,
enabled: true,
allowDestructiveActions: false,
destructiveApprovalMode: "deny",
};
}

View File

@@ -12,7 +12,7 @@ const DEFAULT_CODEX_APPROVAL_TIMEOUT_MS = 120_000;
const MAX_PLUGIN_APPROVAL_TITLE_LENGTH = 80;
const MAX_PLUGIN_APPROVAL_DESCRIPTION_LENGTH = 256;
type ExecApprovalDecision = "allow-once" | "allow-always" | "deny";
export type ExecApprovalDecision = "allow-once" | "allow-always" | "deny";
/** Normalized Codex app-server approval outcome after a gateway decision. */
export type AppServerApprovalOutcome =
@@ -40,6 +40,7 @@ export async function requestPluginApproval(params: {
severity: "info" | "warning";
toolName: string;
toolCallId?: string;
allowedDecisions?: ExecApprovalDecision[];
}): Promise<ApprovalRequestResult | undefined> {
const timeoutMs = DEFAULT_CODEX_APPROVAL_TIMEOUT_MS;
return callGatewayTool(
@@ -60,6 +61,7 @@ export async function requestPluginApproval(params: {
turnSourceThreadId: params.paramsForRun.currentThreadTs,
timeoutMs,
twoPhase: true,
...(params.allowedDecisions ? { allowedDecisions: params.allowedDecisions } : {}),
},
{ expectFinal: false },
) as Promise<ApprovalRequestResult | undefined>;

View File

@@ -73,6 +73,7 @@ describe("Codex plugin thread config", () => {
marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME,
pluginName: "google-calendar",
allowDestructiveActions: true,
destructiveApprovalMode: "allow",
mcpServerNames: ["google-calendar"],
});
expect(config.diagnostics).toStrictEqual([]);
@@ -107,6 +108,9 @@ describe("Codex plugin thread config", () => {
expect(
pluginOverrideDisabled.policyContext.apps["google-calendar-app"]?.allowDestructiveActions,
).toBe(false);
expect(
pluginOverrideDisabled.policyContext.apps["google-calendar-app"]?.destructiveApprovalMode,
).toBe("deny");
const pluginOverrideEnabled = await buildReadyGoogleCalendarThreadConfig({
codexPlugins: {
@@ -134,6 +138,36 @@ describe("Codex plugin thread config", () => {
expect(
pluginOverrideEnabled.policyContext.apps["google-calendar-app"]?.allowDestructiveActions,
).toBe(true);
expect(
pluginOverrideEnabled.policyContext.apps["google-calendar-app"]?.destructiveApprovalMode,
).toBe("allow");
});
it("exposes destructive app access while marking auto approval mode", async () => {
const config = await buildReadyGoogleCalendarThreadConfig({
codexPlugins: {
enabled: true,
allow_destructive_actions: "auto",
plugins: {
"google-calendar": {
marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME,
pluginName: "google-calendar",
},
},
},
});
const apps = config.configPatch?.apps as Record<string, unknown> | undefined;
expect(apps?.["google-calendar-app"]).toEqual({
enabled: true,
destructive_enabled: true,
open_world_enabled: true,
default_tools_approval_mode: "auto",
});
expect(config.policyContext.apps["google-calendar-app"]).toMatchObject({
allowDestructiveActions: true,
destructiveApprovalMode: "auto",
});
});
it("builds a restrictive app config when native plugin support is disabled", async () => {
@@ -267,6 +301,7 @@ describe("Codex plugin thread config", () => {
marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME,
pluginName: "google-calendar",
allowDestructiveActions: true,
destructiveApprovalMode: "allow",
mcpServerNames: [],
});
expect(config.diagnostics).toStrictEqual([]);
@@ -338,6 +373,7 @@ describe("Codex plugin thread config", () => {
pluginName: "google-calendar",
enabled: true,
allowDestructiveActions: true,
destructiveApprovalMode: "allow",
},
message: "google-calendar-app is not accessible or enabled for google-calendar.",
},
@@ -408,6 +444,7 @@ describe("Codex plugin thread config", () => {
marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME,
pluginName: "google-calendar",
allowDestructiveActions: true,
destructiveApprovalMode: "allow",
mcpServerNames: [],
});
expect(config.diagnostics).toStrictEqual([]);
@@ -498,6 +535,7 @@ describe("Codex plugin thread config", () => {
marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME,
pluginName: "google-calendar",
allowDestructiveActions: true,
destructiveApprovalMode: "allow",
mcpServerNames: [],
});
expect(config.diagnostics).toStrictEqual([]);

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