Compare commits

..

140 Commits

Author SHA1 Message Date
Vincent Koc
9850eb63fc feat(codex): support app-server network proxy profiles 2026-06-16 15:15:02 +08:00
Vincent Koc
63825369a2 fix(auto-reply): allow attachment sends in legacy group automatic replies (#93529)
Co-authored-by: openclaw-clownfish[bot] <280122609+openclaw-clownfish[bot]@users.noreply.github.com>
2026-06-16 14:48:16 +08:00
Vincent Koc
f2522a535d fix(e2e): wait for Ubuntu package maintenance 2026-06-16 14:45:24 +08:00
Vincent Koc
20964d3e3b fix(gateway): tolerate transient pre-hello clean closes (#93528)
* fix(gateway): tolerate transient pre-hello clean closes

Co-authored-by: RayRuan <43744645+ruanrrn@users.noreply.github.com>

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

Co-authored-by: RayRuan <43744645+ruanrrn@users.noreply.github.com>

---------

Co-authored-by: openclaw-clownfish[bot] <280122609+openclaw-clownfish[bot]@users.noreply.github.com>
Co-authored-by: RayRuan <43744645+ruanrrn@users.noreply.github.com>
2026-06-16 14:43:36 +08:00
JC
75141775db fix(openai): request SSE for native ChatGPT streams (#90487)
Co-authored-by: Vincent Koc <25068+vincentkoc@users.noreply.github.com>
2026-06-16 14:43:02 +08:00
Vincent Koc
999d44340f fix(cron): preserve model overrides for text payloads (#93527)
Co-authored-by: openclaw-clownfish[bot] <280122609+openclaw-clownfish[bot]@users.noreply.github.com>
Co-authored-by: Andi Liao <31417269+liaoandi@users.noreply.github.com>
2026-06-16 14:41:22 +08:00
Stellar鱼
ca1a53aca4 feat(cron): add compact list responses (#93395)
Merged via squash.

Prepared head SHA: 4965e7e630
Co-authored-by: yu-xin-c <175149126+yu-xin-c@users.noreply.github.com>
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Reviewed-by: @vincentkoc
2026-06-16 14:40:00 +08:00
Goutam Adwant
46c12b6c54 fix(mattermost): preserve Codex progress preview (#93476)
Merged via squash.

Prepared head SHA: f1dd666451
Co-authored-by: goutamadwant <8672451+goutamadwant@users.noreply.github.com>
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Reviewed-by: @vincentkoc
2026-06-16 14:39:19 +08:00
Stellar鱼
bbfea21a18 fix(security): audit open dm tool exposure (#92883)
* fix(security): audit open dm tool exposure

* fix(security): align open DM audit precedence

---------

Co-authored-by: Vincent Koc <25068+vincentkoc@users.noreply.github.com>
2026-06-16 14:38:39 +08:00
Jason (Json)
1e0062b44a feat: add Codex hosted web search (#93446)
Adds Codex as a selectable hosted web-search provider, routes native Codex search safely across model overrides, and isolates bounded hosted-search workers from configured tools.\n\nVerification: focused post-merge regression suite passed 202/202 tests on exact head 23824af49a.
2026-06-16 00:38:16 -06:00
Vincent Koc
23589d9e7c agents: notify chat exec empty-success completions (#93525)
Co-authored-by: openclaw-clownfish[bot] <280122609+openclaw-clownfish[bot]@users.noreply.github.com>
Co-authored-by: wenkang.xie <58462870+wenkang-xie@users.noreply.github.com>
2026-06-16 14:37:59 +08:00
Vincent Koc
15166e81ca fix(skills): trust verified ClawHub source provenance (#93506)
Merged via squash.

Prepared head SHA: a9ec22fa47
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Reviewed-by: @vincentkoc
2026-06-16 14:36:42 +08:00
zengLingbiao
4c9e7f6c61 fix(nodes): return screen snapshots as media (#93499)
Merged via squash.

Prepared head SHA: 6a69c5cdcc
Co-authored-by: zenglingbiao <290951975+zenglingbiao@users.noreply.github.com>
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Reviewed-by: @vincentkoc
2026-06-16 14:35:58 +08:00
Martin Kessler
840cfd69cd fix(telegram): bind bot mentions to assistant identity (#93088)
* fix(telegram): bind bot mentions to assistant identity

* fix(telegram): satisfy context payload mention typing

* refactor(telegram): carry mention facts as one context object

* test(telegram): use neutral bot handle fixture

* fix(ci): terminate heartbeat command groups

* fix(ci): preserve heartbeat shell functions

* fix(telegram): project effective mention facts

* fix(telegram): keep mention identity portable

* test(telegram): align mention facts mock

---------

Co-authored-by: Vincent Koc <25068+vincentkoc@users.noreply.github.com>
2026-06-16 14:35:14 +08:00
zhaoqj2016
b037280ea9 fix(ui): preserve CJK IME composition (#93498)
Merged via squash.

Prepared head SHA: c84ef0bdf5
Co-authored-by: Zhaoqj2016 <21196165+Zhaoqj2016@users.noreply.github.com>
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Reviewed-by: @vincentkoc
2026-06-16 14:34:40 +08:00
Andy Ye
6aff1e8f9e fix(memory): report skipped QMD embedding probe (#93473)
Merged via squash.

Prepared head SHA: eea1ba563b
Co-authored-by: TurboTheTurtle <35905412+TurboTheTurtle@users.noreply.github.com>
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Reviewed-by: @vincentkoc
2026-06-16 14:34:01 +08:00
Bhargav Chinta
e06f5f2edc fix(cron): preserve aborted isolated-run failure (#93471)
Merged via squash.

Prepared head SHA: dfbba9aa40
Co-authored-by: BhargavSatya <24696554+BhargavSatya@users.noreply.github.com>
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Reviewed-by: @vincentkoc
2026-06-16 14:33:25 +08:00
Harjoth Khara
d7cebdc215 fix(gateway): rotate already-stale generated transcript filename on /reset (#93496)
Merged via squash.

Prepared head SHA: 6ae356c34a
Co-authored-by: harjothkhara <48686985+harjothkhara@users.noreply.github.com>
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Reviewed-by: @vincentkoc
2026-06-16 14:32:38 +08:00
Vincent Koc
53da30dd98 fix(e2e): repair omitted Codex platform package 2026-06-16 14:31:23 +08:00
Vincent Koc
e46bcb834f fix(feishu): send post mentions as native at elements (#93522)
* fix(feishu): use native at elements for blue @mention rendering

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

Co-authored-by: gavin-ali <223589024+gavin-ali@users.noreply.github.com>

Co-authored-by: Yizuki_Ame <104178195+YizukiAme@users.noreply.github.com>

Co-authored-by: Pnant <73925474+Panniantong@users.noreply.github.com>

---------

Co-authored-by: openclaw-clownfish[bot] <280122609+openclaw-clownfish[bot]@users.noreply.github.com>
Co-authored-by: Pnant <73925474+Panniantong@users.noreply.github.com>
2026-06-16 14:30:40 +08:00
Vincent Koc
d2439d2f7d fix(onboard): skip Homebrew prompt on unsupported platforms (#93521)
Co-authored-by: openclaw-clownfish[bot] <280122609+openclaw-clownfish[bot]@users.noreply.github.com>
2026-06-16 14:30:27 +08:00
Vincent Koc
52280351bb fix(workspace): store setup state outside workspace dot-dir (#93520)
Co-authored-by: openclaw-clownfish[bot] <280122609+openclaw-clownfish[bot]@users.noreply.github.com>
Co-authored-by: Lai Quang Huy <64073540+1qh@users.noreply.github.com>
2026-06-16 14:30:01 +08:00
openclaw-clownfish[bot]
e1d3f12d7f fix(memory): use per-keyword FTS search in hybrid mode #39484 (#73976)
Co-authored-by: openclaw-clownfish[bot] <280122609+openclaw-clownfish[bot]@users.noreply.github.com>
2026-06-16 14:25:02 +08:00
Vincent Koc
ce6fd93279 fix(skills): quote skill-creator template description (#93517)
Co-authored-by: openclaw-clownfish[bot] <280122609+openclaw-clownfish[bot]@users.noreply.github.com>
Co-authored-by: parubets <1392109+parubets@users.noreply.github.com>
2026-06-16 14:24:19 +08:00
Vincent Koc
1884cedd35 fix(skills): refresh persisted snapshots after restart (#93513)
* fix(skills): refresh persisted snapshots after restart

Co-authored-by: Vincent Koc <25068+vincentkoc@users.noreply.github.com>

Co-authored-by: Oleksandr Zakotyanskyi <28755978+fif911@users.noreply.github.com>

Co-authored-by: Stephan Kadauke <10904538+skadauke@users.noreply.github.com>

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

Co-authored-by: Vincent Koc <25068+vincentkoc@users.noreply.github.com>

Co-authored-by: Oleksandr Zakotyanskyi <28755978+fif911@users.noreply.github.com>

Co-authored-by: Stephan Kadauke <10904538+skadauke@users.noreply.github.com>

---------

Co-authored-by: openclaw-clownfish[bot] <280122609+openclaw-clownfish[bot]@users.noreply.github.com>
Co-authored-by: Stephan Kadauke <10904538+skadauke@users.noreply.github.com>
2026-06-16 14:20:47 +08:00
zhang-guiping
610c76087b [Bug]: ollama-cloud runtime fails DNS lookup for ai.ollama.com, while ollama/<model>:cloud works (#92594)
* fix(ollama): repair retired cloud provider endpoint

Route configured Ollama Cloud provider ids through plugin doctor compatibility migrations so doctor --fix can rewrite the retired ai.ollama.com endpoint before runtime reads persisted config.

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

* test(doctor): align provider fixture with typed config

Ensure the doctor registry provider-scoped migration test uses a fully typed provider fixture so the test type-check shard validates the intended behavior.

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

* test(ollama): align doctor fixture with typed config

Use fully typed provider and model fixtures in the Ollama doctor contract tests so the extension test type-check shard validates the migration behavior.

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

* fix(ollama): preserve custom cloud provider base url

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

* fix(ollama): avoid logging retired endpoint secrets

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: Vincent Koc <25068+vincentkoc@users.noreply.github.com>
2026-06-16 14:20:24 +08:00
Vincent Koc
a664c44375 fix(scripts): create extension memory report dirs 2026-06-16 08:19:39 +02:00
Vincent Koc
add00d747b build(docs): finish PowerShell-safe docs formatting (#93512)
Co-authored-by: openclaw-clownfish[bot] <280122609+openclaw-clownfish[bot]@users.noreply.github.com>
Co-authored-by: yil337 <220073147+yil337@users.noreply.github.com>
2026-06-16 14:19:01 +08:00
zhang-guiping
bb164384c2 [Bug]: ollama-cloud runtime fails DNS lookup for ai.ollama.com, while ollama/<model>:cloud works (#92594)
* fix(ollama): repair retired cloud provider endpoint

Route configured Ollama Cloud provider ids through plugin doctor compatibility migrations so doctor --fix can rewrite the retired ai.ollama.com endpoint before runtime reads persisted config.

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

* test(doctor): align provider fixture with typed config

Ensure the doctor registry provider-scoped migration test uses a fully typed provider fixture so the test type-check shard validates the intended behavior.

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

* test(ollama): align doctor fixture with typed config

Use fully typed provider and model fixtures in the Ollama doctor contract tests so the extension test type-check shard validates the migration behavior.

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

* fix(ollama): preserve custom cloud provider base url

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

* fix(ollama): avoid logging retired endpoint secrets

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: Vincent Koc <25068+vincentkoc@users.noreply.github.com>
2026-06-16 14:17:57 +08:00
Vincent Koc
4a0e376d1f fix(imessage): normalize leading NUL echo-cache prefixes (#93511)
Co-authored-by: openclaw-clownfish[bot] <280122609+openclaw-clownfish[bot]@users.noreply.github.com>
Co-authored-by: jason <3031622+drvoss@users.noreply.github.com>
2026-06-16 14:17:42 +08:00
zhang-guiping
2196ea2930 fix #85871: [Bug]: Heartbeat scheduler silently fails to fire on 5.20 and all 5.x versions (regression from 4.23) (#88970)
* fix heartbeat deferral during active embedded runs

* fix heartbeat admission busy retry

* fix(heartbeat): bind retry to local admission

---------

Co-authored-by: Vincent Koc <25068+vincentkoc@users.noreply.github.com>
2026-06-16 14:17:37 +08:00
Vincent Koc
6aa83374d9 fix(scripts): pin Docker preflight platform 2026-06-16 08:06:29 +02:00
openclaw-clownfish[bot]
59950f7b52 fix(ui): preserve gateway token during safe websocket url edits (#73923)
* fix(ui): preserve gateway token during safe websocket url edits

* fix(ui): preserve gateway token during safe websocket url edits

---------

Co-authored-by: openclaw-clownfish[bot] <280122609+openclaw-clownfish[bot]@users.noreply.github.com>
2026-06-16 14:03:13 +08:00
Vincent Koc
ccf83ace38 fix(plugins): repair missing required platform packages 2026-06-16 14:00:11 +08:00
xydigit-sj
2b752ac0d1 fix(doctor): repair null agents.list[].workspace values (#93105)
A literal null `workspace` field in an agent entry failed schema validation at
startup, producing a crash loop that `openclaw doctor --fix` could not recover
from because the compatibility pipeline never normalized the malformed field.

Add a narrow doctor migration that removes null `workspace` values from
`agents.list` entries and relies on the existing fallback path (defaults or
stateDir-derived workspace) at runtime.

Fixes #77718.
2026-06-16 13:59:36 +08:00
Alix-007
01d3505d7c fix(auto-reply): redact secrets in /debug show and /debug set output (#93333)
PR #88496 routed /config show and /config set chat output through the
shared schema-aware redaction path, but the sibling /debug commands in
the same handler were left untouched. /debug show JSON-stringified the
full runtime override tree verbatim and /debug set echoed the raw value,
so a secret-shaped override (e.g. gateway.auth.token, channels.*.botToken)
set via /debug set was rendered in plaintext to chat-visible output.

Apply redactConfigObject(overrides, schema.uiHints) to the override tree
before rendering /debug show, and reuse formatConfigSetValueLabel for the
/debug set acknowledgement, matching the existing /config redaction
contract. Non-secret fields and env placeholders are preserved.
2026-06-16 13:59:28 +08:00
Agent外设王东旭
37636ac8e2 Fix Matrix bracketed display-name mentions (#83156)
Co-authored-by: dxw <wdx@me.com>
2026-06-16 13:57:56 +08:00
Harjoth Khara
5a9396ef6d fix(ui): restore provider usage pill in desktop chat composer [AI] (#93055)
* fix(ui): restore provider usage pill in desktop chat composer (#93041)

Composer refactors dropped the quota pill from renderChatControls and left the
desktop renderChatSessionSelect wrapper orphaned, so it rendered nowhere on
desktop. Re-attach the existing pill, add modelAuthStatusResult to the guarded
controls dep list so it updates when usage windows arrive async, and hide it on
the 2-col mobile composer grid.

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

* test(ui): add real-browser e2e proof for chat quota pill (#93041)

Playwright/Chromium test that mocks models.authStatus usage windows and asserts
the restored provider usage pill renders in the desktop chat composer (and is
absent without usage). Skips gracefully when Chromium is unavailable.

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

* test(ui): write quota-pill e2e screenshots to ignored .artifacts path (#93041)

Match the control-ui-e2e convention (.artifacts/control-ui-e2e/...) so the proof
run does not leave untracked root-level files. Addresses ClawSweeper review.

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 13:57:40 +08:00
Vincent Koc
e934e1cad7 fix(scripts): share Docker E2E artifact bounds 2026-06-16 07:39:44 +02:00
lizeyu-xydt
5afddf547e fix(discord): apply tool status emojis immediately to avoid override by thinking reactions (#93488)
* fix(discord): apply tool status emojis immediately to avoid override by thinking reactions

Tool emoji reactions (🛠️, 🌐, 🔎, etc.) during Discord tool/skill execution
were not appearing because setTool() used a 700ms debounce shared with
setThinking(). Rapid onReasoningStream calls from overlapping reasoning
would repeatedly overwrite the pending tool emoji with 🧠, so the tool
emoji never reached Discord.

Fix by making setTool() apply emojis immediately (skip debounce). Tool
transitions are user-facing state changes that should be visible without
delay, and the terminal done/error transitions already flush any pending
state.

Fixes #92715.

* fix(discord): forward quiet tool lifecycle status

* fix(slack): preserve tool status reactions

* test(channels): type quiet tool lifecycle options

---------

Co-authored-by: Vincent Koc <25068+vincentkoc@users.noreply.github.com>
2026-06-16 13:33:32 +08:00
Vincent Koc
9a0aefb73f fix(scripts): bound Docker E2E JSON helpers 2026-06-16 07:31:17 +02:00
Goutam Adwant
325d0208d0 fix(ui): add agent selector to skills page (#93487)
* fix(ui): add agent selector to skills page

* test(ui): stabilize skills agent selector checks

* fix(skills): preserve agent-scoped state

* fix(skills): refresh current scope after config updates

---------

Co-authored-by: Vincent Koc <25068+vincentkoc@users.noreply.github.com>
2026-06-16 13:30:25 +08:00
Vincent Koc
983e0f2ba0 docs: refresh generated API baselines 2026-06-16 07:26:19 +02:00
Jason (Json)
37c1dacac9 docs: point PR landing at maintainer workflow (#93494)
* docs: point PR landing at maintainer workflow

* docs: name PR landing scripts

* docs: specify PR landing commands

* docs: point PR landing at canonical wrapper

* docs: document canonical PR wrapper flow

* docs: scope PR wrapper flow to main

---------

Co-authored-by: Vincent Koc <25068+vincentkoc@users.noreply.github.com>
2026-06-16 13:24:51 +08:00
ly-wang19
ca5c3e677a fix(cron): clear delivery routing fields from cron edit (#93495)
* fix(cron): clear delivery routing fields from cron edit

cron edit could set delivery channel/to/thread-id/account but could not unset them: an empty value (e.g. --to "") builds delivery.X = undefined, which is omitted from the JSON-RPC patch, so mergeCronDelivery never sees the key and the field is silently kept. The gateway RPC already accepts an explicit null to clear each field (CronDeliveryPatchSchema + mergeCronDelivery via normalizeOptionalString); the CLI just never sent it.

Add --clear-channel/--clear-to/--clear-thread-id/--clear-account, each emitting null (mirroring the existing --clear-model), with mutual-exclusion guards against the matching set flag and against --webhook.

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

* fix(cron): preserve delivery defaults when clearing routes

* fix(cron): validate cleared prefixed routes

---------

Co-authored-by: ly-wang19 <ly-wang19@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Vincent Koc <25068+vincentkoc@users.noreply.github.com>
2026-06-16 13:24:33 +08:00
Vincent Koc
2fec8b12d5 test(feishu): make fallback dispatch assertion observable 2026-06-16 13:23:44 +08:00
Vincent Koc
a89c9937c2 fix(ci): normalize Windows WSL probe output 2026-06-16 07:21:33 +02:00
Vincent Koc
9bdf89598e fix(e2e): retry macOS Parallels npm install 2026-06-16 13:19:02 +08:00
Vincent Koc
df17e01cac test(feishu): prove partial runtime fallback dispatches 2026-06-16 13:16:19 +08:00
ooiuuii
9d1dec4678 fix(cli): honor route-first log level (#93460) 2026-06-16 13:15:52 +08:00
Vincent Koc
350f06362b fix(e2e): budget macOS Parallels onboarding 2026-06-16 13:15:43 +08:00
xydt-tanshanshan
a2bc7ab269 [AI] fix(feishu): guard against missing inbound in channelRuntime fallback (#93466)
* [AI] fix(feishu): guard against missing inbound in channelRuntime fallback

When channelRuntime from gateway context is truthy but lacks the inbound
property, the ?? operator still selects it over getFeishuRuntime().channel,
causing TypeError at core.channel.inbound.run().

The ChannelGatewayContext types channelRuntime as ChannelRuntimeSurface
(only guarantees runtimeContexts), but channel.ts casts it to
PluginRuntimeChannel via type assertion. If a partial runtime object
without inbound is provided, the type lie becomes a runtime crash.

Fix: check channelRuntime?.inbound before using it; fall back to
getFeishuRuntime().channel when inbound is absent.

Related to #93453

* [AI] test(feishu): add regression for partial channelRuntime lacking inbound

When channelRuntime has runtimeContexts but no inbound, the guard in
bot.ts should fall back to getFeishuRuntime().channel. Add a test that
passes a partial channelRuntime and verifies dispatch does not crash.

Refs #93453
2026-06-16 13:15:08 +08:00
Vincent Koc
7ac2bbaaf0 fix(qa): install gauntlet plugin requirements 2026-06-16 06:59:58 +02:00
Vincent Koc
96404a7bd5 fix(scripts): bound gauntlet QA summaries 2026-06-16 06:59:15 +02:00
Vincent Koc
484ee14273 fix(scripts): bound plugin install index artifacts 2026-06-16 06:43:01 +02:00
Vincent Koc
88c9e4d644 fix(e2e): clear restored macOS npm cache 2026-06-16 12:41:00 +08:00
Patrick Erichsen
99a398a4b1 docs: add ClawHub content rights to sidebar (#93489) 2026-06-15 21:35:24 -07:00
Edward Abrams
ef3e5f5e31 perf(plugins): thread prepared manifest plugins through runtime normalization (#85254)
Carry prepared manifest model-id normalization records through the runtime bridge so hot callers reuse existing metadata instead of consulting the snapshot fallback.

The final change preserves the existing no-prepared-record behavior, adds focused forwarding coverage, and removes the one-off proof script before landing.

Thanks @zeroaltitude.

Verification:
- 224 focused tests
- full CI run 27594070734
- real behavior proof run 27594081022
- final whole-branch autoreview clean

Co-authored-by: zeroaltitude <zeroaltitude@gmail.com>
2026-06-16 06:31:36 +02:00
Vincent Koc
6f53f84af3 fix(gateway): normalize paired access lists 2026-06-16 12:31:24 +08:00
wangmiao0668000666
d6eefa191f fix(device-pairing): guard mergeRoles/mergeScopes against non-string entries (#90654) 2026-06-16 12:31:24 +08:00
wangmiao0668000666
481652d78a fix(gateway): guard formatAuditList against non-string items to prevent handshake trim crash (#90654) 2026-06-16 12:31:24 +08:00
Vincent Koc
9a86a2b30b fix(qa): ignore setup gauntlet observations 2026-06-16 06:30:11 +02:00
Agustin Rivera
300794520b fix(discord): block cross-provider guild admin actions (#93354)
* fix(discord): block cross-provider guild admin actions

* fix(discord): reject cross-provider moderation actions

* fix(discord): preserve manual admin action trust

* fix(discord): align admin action trust guard
2026-06-15 21:28:53 -07:00
Vincent Koc
f9376b16d4 fix(scripts): bound npm onboard status artifacts 2026-06-16 06:19:32 +02:00
Agustin Rivera
ee81082f57 fix(openshell): pin mirror remote mutations (#93361)
* fix(openshell): pin mirror remote mutations

* fix(openshell): pin mirror remote mutations

* fix(openshell): keep mirror state pinned

* fix(openshell): preserve pinned remove failures
2026-06-15 21:16:59 -07:00
Marvinthebored
395a082348 fix(codex): dedupe commentary raw response echoes (#93343)
Suppress each raw commentary echo paired with a typed Codex item completion by protocol order, while preserving later raw-only notes and contributor-rewritten completion text.

Fixes #93296.
Thanks @Marvinthebored.

Verification:
- 95 focused projector tests
- full CI run 27593515603
- real behavior proof run 27593522821
- local and whole-branch autoreview clean

Co-authored-by: Peter Lindsey <peter@lindsey.jp>
2026-06-16 06:13:47 +02:00
Onur Solmaz
8c108c294d fix(agents): honor disabled envelope timestamps at model boundary (#93238)
Merged via squash.

Prepared head SHA: 53f7117a4b
Co-authored-by: osolmaz <2453968+osolmaz@users.noreply.github.com>
Reviewed-by: @osolmaz
2026-06-16 12:13:24 +08:00
Vincent Koc
48d96cd8a1 fix(update): retry launchd handoff recovery 2026-06-16 12:09:01 +08:00
yetval
d1f6ca20a1 fix(update): keep CLI plugin post-update failure behavior unchanged 2026-06-16 12:09:01 +08:00
yetval
c9418b8afd fix(update): restart managed gateway when update handoff fails after stop 2026-06-16 12:09:01 +08:00
Vincent Koc
0c657190ec fix(qa): fail runtime parity on cell failures 2026-06-16 05:53:19 +02:00
Vincent Koc
6ffa0fb348 test(plugins): narrow session extension registry coverage 2026-06-16 11:36:43 +08:00
杨浩宇0668001029
0fb0c2cb8e fix(plugins): keep empty session extension pins authoritative
Pinned session-extension registries now remain the owner even when empty, preventing later active registry churn from leaking agent-owned extensions into the gateway surface.
2026-06-16 11:36:43 +08:00
杨浩宇0668001029
0e71ce1174 test(plugins): cover session extension registry lifecycle
Exercise pinned startup session extensions through WebSocket patching, release cleanup, standalone loading, and active-registry churn.
2026-06-16 11:36:43 +08:00
杨浩宇0668001029
ffa736f713 fix(plugins): satisfy session extension lint 2026-06-16 11:36:43 +08:00
杨浩宇0668001029
b85ae9fb1b fix(plugins): pin session extension registry 2026-06-16 11:36:43 +08:00
Alex Knight
67c80e941e fix(gateway): fall back to managed path when inbound PDF sandbox staging fails (#90097) 2026-06-16 13:19:32 +10:00
Alex Knight
e850750754 fix(media): extract large managed inbound PDFs via media-understanding (#90096, #90097)
Inbound PDF/document text already flows to agents through the canonical
media-understanding pipeline (applyMediaUnderstanding -> extractFileBlocks),
but it inherited the OpenResponses input_file limits (5MB / 4 pages), so large
managed PDFs from channels/Control UI were skipped and locked-down agents saw
only an attachment marker.

- Size inbound file extraction from agents.defaults.mediaMaxMb (default 20MB,
  cap 25MB) and pdfMaxPages (default 20, cap 150) via a new
  resolveFileExtractionLimits; explicit gateway responses.files config still
  wins per-field. (#90096)
- chat.send: let oversized (>5MB) managed inbound PDFs pass through sandbox
  staging with their managed media path instead of a 4xx, so host-side
  extraction reaches sandboxed agents without copying the file into every
  sandbox; non-PDF oversize files are still rejected. (#90097)

Reuses the existing extraction/injection path; no parallel module or extra
prompt-injection sites.
2026-06-16 13:19:32 +10:00
Vincent Koc
7c6ad2327c fix(infra): narrow inherited gateway pid protection 2026-06-16 11:11:25 +08:00
amittell
d88f1bf217 fix(infra): preserve inherited gateway PID across reparent during cleanStaleGatewayProcessesSync
When a child openclaw process is spawned via a backgrounded subshell that
exits before the new process reaches the stale-pid sweep, the new process
is reparented to the supervisor (PID 1 / launchd) and the ancestor walk
in getSelfAndAncestorPidsSync can no longer see the running gateway. The
running gateway then shows up on lsof as an unrelated sibling on the
port and gets SIGKILL'd by cleanStaleGatewayProcessesSync, recreating
the issue #68451 supervisor restart loop across a reparent boundary.

Real-world trigger: a user ~/.zshrc auto-start block
  if ! pgrep -x openclaw-gateway >/dev/null; then
    (openclaw gateway >/dev/null 2>&1 &)
  fi
combined with codex per-turn `zsh -c "set -e; . shell_snapshot"` invocations
caused every chat turn on rh-bot to SIGKILL its launchd-managed gateway,
producing HTTP 000 errors and ~33 kill events captured by a forensic
launchd unified-log tracker before the zshrc was patched.

Fix: gateway-cli captures OPENCLAW_GATEWAY_SERVICE_PID from inherited env
BEFORE overwriting it with process.pid, then threads the captured PID
through cleanStaleGatewayProcessesSync into getSelfAndAncestorPidsSync's
exclusion set. The protection is opt-in per call site so existing
maintainer paths (openclaw update / openclaw doctor restart helpers) keep
their ability to terminate a running gateway intentionally.

The inherited-PID parser is strict positive-integer only: a malformed
inherited env value (`"123abc"`, `"123.4"`, `"0x7b"`, etc.) is rejected
rather than silently protecting PID 123 from cleanup and leaving the
stale listener alive. New focused unit tests cover the parser
contract.

Existing regression tests cover the reparent suicide-kill scenario and
the defensive ignore-non-positive-PID contract on the cleanup side.
2026-06-16 11:11:25 +08:00
Vincent Koc
6da2d6ac5a fix(crabbox): bootstrap absolute macOS env pnpm 2026-06-16 04:59:05 +02:00
Vincent Koc
2b05bd7b0d fix(cli): preserve sessions_yield over MCP 2026-06-16 10:53:58 +08:00
张贵萍0668001030
eea350f2ff fix: preserve yielded CLI lifecycle state 2026-06-16 10:53:58 +08:00
张贵萍0668001030
c8c94e15ad fix(gateway): refresh loopback yield cache lifecycle 2026-06-16 10:53:58 +08:00
张贵萍0668001030
d7a09b13e6 fix(gateway): satisfy lint for MCP yield context 2026-06-16 10:53:58 +08:00
张贵萍0668001030
88e4a0f0d5 fix(gateway): propagate MCP yield session context 2026-06-16 10:53:58 +08:00
Vincent Koc
db194a6887 fix(crabbox): detect direct env changed gates 2026-06-16 04:40:09 +02:00
Vincent Koc
00160ea6ee fix(workboard): refuse unsafe SSHFS SQLite storage
Preserve rollback journaling for NFS and SMB-backed stores, refuse SSHFS after symlink-aware mount classification, and close Workboard database handles when filesystem policy rejects initialization.
2026-06-16 04:34:14 +02:00
Vincent Koc
ffb67d2d2e fix(qa): suppress empty WhatsApp debug artifacts
Suppress empty WhatsApp gateway-debug artifact publication and keep the public QA run view redacted and consistent across report/evidence output.

Verification:
- Testbox focused WhatsApp QA runtime format/lint/test run passed: https://github.com/openclaw/openclaw/actions/runs/27589031659
- Testbox changed gate passed: https://github.com/openclaw/openclaw/actions/runs/27589128132
- PR CI passed on final head: https://github.com/openclaw/openclaw/actions/runs/27589903708
- git diff --check passed locally
2026-06-16 10:32:53 +08:00
Vincent Koc
d89ab2c014 fix(e2e): wait for Parallels update cleanup 2026-06-16 04:19:54 +02:00
Aniruddha Adak
11a0ad10e9 test: make install-safe-path symlink tests compatible with Windows 2026-06-16 10:12:47 +08:00
Vincent Koc
9b6bed7a75 fix(memory): release reindex lock after failed init 2026-06-16 04:04:19 +02:00
Vincent Koc
f87d194b8b fix(memory): prevent peer-write loss during reindex 2026-06-16 04:04:19 +02:00
Vincent Koc
386b0e6c74 fix(backup): snapshot all live SQLite state
Use transactionally consistent VACUUM INTO snapshots for every state-root SQLite database and exclude original journal sidecars so verified backups cannot restore torn plugin or memory state.
2026-06-16 04:00:43 +02:00
Vincent Koc
ee495abda1 fix(release): satisfy retry delay lint 2026-06-16 09:58:25 +08:00
Vincent Koc
147e979713 fix(postinstall): bound packaged dist scans 2026-06-16 03:57:44 +02:00
Vincent Koc
1ee788189a fix(release): accept trusted main Telegram evidence 2026-06-16 09:54:56 +08:00
Vincent Koc
e71cf0ffcb fix(release): tolerate npm propagation after publish 2026-06-16 09:51:47 +08:00
Vincent Koc
3c65127827 fix(qa): preserve WhatsApp live failure diagnostics 2026-06-16 03:42:26 +02:00
Vincent Koc
a4e7d9a0db test(plugin-sdk): ratchet SQLite helper surface 2026-06-16 09:36:32 +08:00
Vincent Koc
ac8a3f367c fix(sqlite): disable WAL on network filesystems 2026-06-16 09:36:32 +08:00
openclaw-clownfish[bot]
8694fe7e81 fix(gateway): block internal HTTP session overrides
Reject HTTP session-key overrides that target reserved internal session namespaces while preserving normal explicit session keys.

Co-authored-by: RichardCao <4612401+RichardCao@users.noreply.github.com>
2026-06-16 09:30:27 +08:00
openclaw-clownfish[bot]
073343e2e2 fix(outbound): ignore schema-padded poll metadata on send
Ignore schema-padded poll metadata on plain send actions unless content-bearing poll fields are present.

Co-authored-by: 鄧 偉程 <148790968+weichengdeng@users.noreply.github.com>
2026-06-16 09:29:16 +08:00
Vincent Koc
aa0d710085 fix(release): bound artifact package scans 2026-06-16 03:24:57 +02:00
Vincent Koc
c70b9849d9 fix(agents): handle string assistant message content 2026-06-16 09:23:40 +08:00
Vincent Koc
919c5b7c7b fix(plugin-sdk): calibrate callable surface budget 2026-06-16 03:19:24 +02:00
Vincent Koc
5296dc378f fix(release): bound postpublish dist scans 2026-06-16 03:15:52 +02:00
Shakker
a447f9a43d fix: guard session message cache payloads 2026-06-16 02:08:24 +01:00
Vincent Koc
04b7e192af fix(release): require full validation child run urls 2026-06-16 03:06:01 +02:00
Dallin Romney
450060d7a2 test(qa): expand smoke-ci and release categories and coverage (#93175)
* test(qa): add smoke ci primary coverage evidence

* test(qa): remove overstated primary coverage claims

* test(qa): make release profile include smoke ci

* test(qa): trim taxonomy formatting churn

* test(qa): avoid hardcoded profile names in coverage test

* test(qa): make release profile cover taxonomy

* test(qa): type profile fixture all category flag

* test(qa): include channel delivery in smoke ci profile
2026-06-15 18:05:52 -07:00
Vincent Koc
6bc57ca73a fix(migrate-hermes): snapshot live SQLite archive 2026-06-16 03:01:37 +02:00
Vincent Koc
ea346f4361 fix(sqlite): close databases after failed initialization 2026-06-16 03:00:23 +02:00
Vincent Koc
d5c9e7ea99 test(plugin-sdk): ratchet surface budget checks 2026-06-16 02:56:41 +02:00
Vincent Koc
9eed9c5758 fix(e2e): derive lifecycle proc units 2026-06-16 02:56:41 +02:00
Vincent Koc
1c2363def6 fix(plugin-sdk): refresh QA self-check API baseline 2026-06-16 02:56:41 +02:00
Vincent Koc
b7d53800d6 fix(release): require beta smoke run url 2026-06-16 02:55:19 +02:00
Vincent Koc
6326395c0a fix(state): make SQLite sidecar archives retriable
Archive the canonical legacy database before SQLite sidecars, then detect and finish pending sidecar cleanup on retry without reopening the migrated database.
2026-06-16 02:52:29 +02:00
Vincent Koc
568f2d5631 fix(sessions): guard unsafe transcript serialization cache 2026-06-16 02:43:58 +02:00
Vincent Koc
e94b666e45 fix(mac): isolate dmg image cleanup 2026-06-16 02:43:16 +02:00
Dirk
ee3b7eb7c0 fix(telegram): forward Bot API 10.1 rich_message content to agent (#93418)
* fix(telegram): surface unsupported inbound rich messages

* fix(telegram): isolate rich message placeholders

* fix(telegram): accept typed rich message inputs

* fix(telegram): preserve rich message cache marker

---------

Co-authored-by: Vincent Koc <25068+vincentkoc@users.noreply.github.com>
2026-06-16 08:42:48 +08:00
Alex Knight
2365a137d8 fix(mattermost): keep message tool replies in threads (#93424)
* fix(mattermost): keep message tool replies in threads

* fix(outbound): preserve one-root reply threading

* fix(outbound): preserve explicit reply target precedence

* fix(mattermost): mirror inherited replies to root session

* test(outbound): align reply transport contract

* fix(mattermost): align mirrored thread root

---------

Co-authored-by: Alex Knight <15041791+amknight@users.noreply.github.com>
Co-authored-by: Vincent Koc <25068+vincentkoc@users.noreply.github.com>
2026-06-16 08:36:59 +08:00
Alex Knight
dc09d148bb fix(guards): allow auth profile sqlite reader (#93448)
Allow the auth-profile read-only SQLite bootstrap path through the Kysely guardrail. The runtime already wraps reads with Kysely; the raw DatabaseSync boundary is the short-lived read-only bootstrap.

Co-authored-by: Alex Knight <15041791+amknight@users.noreply.github.com>
2026-06-16 10:34:13 +10:00
Gio Della-Libera
55263b3dfa feat(policy): cover exec approvals artifact (#90003)
Add exec approvals artifact evidence to Policy.

- add the execApprovals policy namespace and check IDs for required artifact presence, default/per-agent security posture, autoAllowSkills, and allowlist drift
- read the active exec-approvals.json artifact only when execApprovals policy rules are configured, honoring OPENCLAW_STATE_DIR before the default ~/.openclaw path
- emit redacted posture evidence and stable oc:// references without socket tokens, command text, resolved paths, timestamps, or approval-session details
- document the public policy surface and add focused scanner, doctor, conformance, and CLI coverage

Validation:
- GitHub Actions for head b82eefe492 are green, including Real behavior proof.
- ClawSweeper re-review completed for the same head with proof: sufficient and status: ready for maintainer look.
- Maintainer artifact-boundary acceptance is recorded in the PR discussion and body.

Co-authored-by: Gio Della-Libera <235387111+giodl73-repo@users.noreply.github.com>
2026-06-15 17:30:48 -07:00
ZengWen-DT
01acb34bdb fix(tui): show activity indicator for system-injected runs (#93427)
* fix(tui): show activity indicator for system-injected runs

System-injected runs (bridge-notify, webhook, cron) never go through the
TUI submit path, so no active/pending run id exists when their lifecycle
"start" event arrives. handleAgentEvent dropped events for untracked runs,
leaving the status bar idle until the response landed.

Adopt an untracked lifecycle "start" for the current session (lifecycle
events always carry sessionKey) so the activity indicator shows work is
happening, mirroring how chat deltas adopt runs in handleChatEvent. Local
side-question (btw) runs never claim the active slot.

Closes #51825

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

* fix(tui): preserve concurrent injected run activity

---------

Co-authored-by: zengwen <zeng_wen@foxmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Vincent Koc <25068+vincentkoc@users.noreply.github.com>
2026-06-16 08:27:59 +08:00
zengLingbiao
03e3ef86af fix(agents): resolve configured default model in runEmbeddedAgent (fixes #93419) (#93428)
* fix(agents): honor configured default model in embedded runs

* fix(agents): resolve embedded defaults from runtime config

* fix(agents): preserve embedded model routing semantics

* test(agents): model current embedded attempts explicitly

---------

Co-authored-by: Vincent Koc <25068+vincentkoc@users.noreply.github.com>
2026-06-16 08:27:19 +08:00
Harjoth Khara
eac3e08cfd fix(line): cap carousel column text at 60 chars when a title or image is set (#93429)
* fix(line): cap carousel column text at 60 chars with title or image

LINE limits a carousel column's text to 60 characters when the column has
a title or thumbnail image, and 120 characters otherwise. createCarouselColumn
always truncated to 120, so a column with a title/image and 61-120 char text
exceeded the limit and made LINE reject the entire carousel reply (HTTP 400).
Apply the conditional limit (mirroring the buttons template) and drop the now
redundant slice in createProductCarousel.

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

* fix(line): apply conditional text limits across templates

* fix(line): truncate template text by code point

* fix(line): preserve grapheme clusters when truncating

* fix(line): apply compact limit for default actions

* fix(line): follow title and thumbnail text limits

* fix(line): truncate template text within UTF-16 limits

* fix(line): preserve required text within template limits

* fix(line): preserve carousel product prices

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Vincent Koc <25068+vincentkoc@users.noreply.github.com>
2026-06-16 08:23:05 +08:00
NianJiu
a375d6c849 fix(telegram): gate rich messages behind opt-in (#93279)
Restore readable standard Telegram text delivery by default after Bot API 10.1 rich messages rendered as unsupported in current clients. Keep native rich tables and structured messages available through the account-level richMessages opt-in, with account-aware capability advertising and documented structural limits.

Fixes #93263.
2026-06-15 17:22:41 -07:00
Vincent Koc
9dbf8f718f fix(sessions): guard append cache after extension serialization 2026-06-16 08:13:38 +08:00
Vincent Koc
fd806ada64 fix(agents): bound autoreview scope (#93435) 2026-06-16 07:58:07 +08:00
Vincent Koc
4ca8bf086c fix(proxy): close cached SQLite stores by path
Track debug proxy capture stores per database path so replacing or concurrently leasing capture paths cannot orphan SQLite handles and WAL checkpoint timers.
2026-06-16 01:54:09 +02:00
Vincent Koc
b41c0b6746 fix(cli): preserve gateway request errors in json mode 2026-06-16 01:52:23 +02:00
Marcus Castro
52d9d16e1b fix(whatsapp): preserve auth on terminal disconnects (#93076)
* fix(whatsapp): preserve auth on passive terminal stops

* fix(whatsapp): recover stale web auth during relink

* fix(gateway): defer channel stop until qr takeover
2026-06-15 20:50:22 -03:00
Vincent Koc
0ef8620746 fix(auth): wait through SQLite read contention
Apply the canonical SQLite busy timeout to short-lived read-only auth profile reads so a brief rollback-journal exclusive lock cannot make valid persisted credentials appear missing.
2026-06-16 01:41:05 +02:00
Vincent Koc
74c6f175c7 fix(ci): skip transcript guard for older release targets 2026-06-16 07:40:47 +08:00
Alix-007
0d50ec77de fix(memory): swap rollback-journal sidecar during atomic reindex (#93295)
The atomic reindex file ops hardcoded the WAL sidecar pair (-wal/-shm)
when moving, removing, and backing up index files. NFS-backed memory
stores run SQLite under journal_mode=DELETE, which produces a
rollback-journal (-journal) sidecar instead. As a result an index swap
left the previous targets stale -journal next to the freshly published
2026-06-16 07:37:17 +08:00
Dr Rushindra Sinha
eccfacb02c fix(whatsapp): stop markdownToWhatsApp dropping code spans followed by a digit (#93409)
The inline-code/fence restore step matched the placeholder index with a
greedy `(\d+)`, so a digit in user text immediately after a code span
(e.g. `code`5) was absorbed into the index, resolved to undefined, and
`?? ""` deleted both the code span and the digit. Terminate the
placeholder index with the existing NUL marker so the index boundary is
unambiguous.

Co-authored-by: Dr Rushindra Sinha <5796457+rushindrasinha@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 07:35:28 +08:00
Matt Gunnin
f08b24e63c fix(discord): suppress tool progress for message-tool replies (#93412)
* fix(discord): suppress tool progress for message-tool replies

* fix(discord): preserve explicit status reactions for tool-only replies

* fix(discord): keep explicit status reactions private

---------

Co-authored-by: Vincent Koc <25068+vincentkoc@users.noreply.github.com>
2026-06-16 07:35:15 +08:00
Vincent Koc
c32ba171db fix(qa): fail unsuccessful self-checks 2026-06-16 01:32:47 +02:00
Shakker
e64379dddb fix: stabilize transcript cache and CLI env isolation 2026-06-16 00:27:53 +01:00
Josh Lehman
127e174c9e refactor: route auto-reply sessions through session seam (#89124)
* clawdbot-6f0: route agent runtime session writes through seam

* clawdbot-6f0: route command entry persistence through seam

* test: ratchet auto-reply session accessor writes

* refactor: scope embedded attempt quota reads

* test: ratchet session store save writer
2026-06-15 15:50:38 -07:00
513 changed files with 26532 additions and 4752 deletions

View File

@@ -24,7 +24,7 @@ Use when:
- Prefer small fixes at the right ownership boundary; no refactor unless it clearly improves the bug class.
- When an accepted finding shows a bug class or repeated pattern, inspect the current PR scope for sibling instances before fixing.
- Fix the scoped bug class at once when practical; stop at touched surfaces, owner boundaries, and clear follow-up territory.
- Keep going until structured review returns no accepted/actionable findings.
- Keep going until structured review returns no accepted/actionable findings only while the work remains inside the original task scope.
- If a review-triggered fix changes code, rerun focused tests and rerun the structured review helper.
- For security-audit suppression changes, verify accepted findings remain auditable: suppressed findings stay in structured output, active output keeps an unsuppressible suppression notice, and aggregate findings cannot hide unrelated active risk.
- Never switch or override the requested review engine/model. If the review hits model capacity, retry the same command a few times with the same engine/model.
@@ -43,6 +43,42 @@ Use when:
- If Gitcrawl reports a portable manifest mismatch, source/runtime DB health error, or stale portable-store checkout, run `gitcrawl doctor --json` and inspect `source_db_health`, `runtime_db_health`, and `portable_store_status` before falling back to live GitHub.
- Do not push just to review. Push only when the user requested push/ship/PR update.
## Scope Governor
Autoreview is a closeout gate, not permission to rewrite the task.
Before the first review, freeze a scope baseline: original request or issue, target branch, intended behavior, owner boundary, changed files, and non-test LOC. For inherited or already-bloated branches, use the intended PR diff as the baseline rather than accepting all existing branch drift.
Before patching a finding, classify it:
- **In-scope blocker**: the finding is introduced by the current diff, affects the same owner boundary, and can be fixed without changing the task's contract.
- **Follow-up**: the finding is real but belongs to an adjacent bug class, sibling surface, cleanup, or broader hardening track.
- **Stop-and-escalate**: the finding requires a new protocol/config/storage/public API contract, a different owner boundary, a release-process change, or a design choice outside the original request.
Stop patching and report the scope break instead of continuing when:
- a narrow PR turns into an architecture change, protocol change, migration, or release-process change;
- the diff grows past 2x the original files or non-test LOC without explicit approval to expand scope;
- two review-triggered patch cycles have not converged; pause and reclassify every remaining finding before another edit;
- the best fix is "define the canonical contract first" rather than another local inference layer;
- fixing the accepted finding would make the PR no longer describe the same behavior, issue, or owner boundary.
After the two-cycle pause, continue only when every remaining accepted finding is still an in-scope blocker. Otherwise preserve the useful analysis, identify the smallest safe landed subset if one exists, and open or request a follow-up for the larger fix. Do not keep committing speculative fixes just to satisfy the reviewer.
Do not stack or push review-triggered fix commits while scope classification or focused proof is unresolved. Keep exploratory edits local until the cycle is proven in scope; if scope breaks, remove them from the landing lane instead of preserving them as branch history.
Critical exceptions must be explicit: active data loss, crash, broken install/upgrade, release blocker, or concrete security exposure. If the exception is not one of those, it is not critical enough to blow up scope.
## Release Branches And Release Process
On release, beta, stable, hotfix, signing, notarization, appcast, package-publish, or release-check work, use freeze discipline even when the branch name is not release-like:
- Fix only release blockers, failed release infrastructure, exact backports, install/upgrade breakage, data loss, crashes, or concrete security exposure.
- Treat non-blocking autoreview findings as follow-ups for `main`, not reasons to broaden the release branch.
- Do not introduce new product behavior, config surface, protocol shape, migration, plugin ownership, docs narrative, or process policy unless it directly unblocks the release.
- Keep proof tied to the release target: exact branch/ref, failing check or shipped-risk reason, smallest command/proof, and whether the fix must also forward-port to `main`.
- If review discovers a real but non-critical design problem during release closeout, stop with a follow-up issue/PR plan; do not use the release branch as the refactor lane.
## Pick Target
Dirty local work:

View File

@@ -440,8 +440,36 @@ def load_datasets(args: argparse.Namespace) -> str:
return "\n\n".join(chunks)
def review_scope_policy() -> str:
return textwrap.dedent(
"""
Review scope discipline:
- This helper is a closeout gate. Do not turn a narrow patch into a broad
redesign request.
- Report a finding only when this diff introduces or exposes a concrete
defect that must be fixed before this target can land.
- If the best fix requires a new protocol, config, storage, public API,
release process, migration, owner-boundary move, or canonical contract,
say that directly in the finding and keep the finding tied to the
smallest changed line that proves the current patch is not landable.
- Do not ask for sibling-surface hardening, cleanup, refactors, or
follow-up architecture work unless the current diff is incorrect
without that work.
- Prefer the smallest correct pre-merge fix. A broader ideal design is
not an actionable finding unless the current patch cannot safely land.
- If this is release-branch or release-process work, apply freeze
discipline. Report only release blockers, exact backport regressions,
install/upgrade breakage, crashes, data loss, concrete security
exposure, or release-infrastructure failures. Non-blocking design,
cleanup, and hardening concerns belong on main as follow-ups.
"""
).strip()
def build_prompt(repo: Path, target: str, target_ref: str | None, bundle: str, extra_prompt: str, datasets: str) -> str:
target_line = f"{target} {target_ref}" if target_ref else target
branch = current_branch(repo)
scope_policy = review_scope_policy()
return textwrap.dedent(
f"""
You are a senior code reviewer. Review the provided git change bundle only.
@@ -463,8 +491,11 @@ def build_prompt(repo: Path, target: str, target_ref: str | None, bundle: str, e
- If there are no actionable findings, return an empty findings array and mark the patch correct.
Review target: {target_line}
Current branch: {branch}
Repository: {repo}
{scope_policy}
{extra_prompt}
{datasets}

View File

@@ -3,6 +3,7 @@ from __future__ import annotations
import argparse
import os
import runpy
import shutil
import stat
import subprocess
@@ -145,8 +146,23 @@ def create_fixture_repo(repo: Path, fixture: str) -> None:
write_fixture_file(repo, MALICIOUS_CHANGED if fixture == "malicious" else BENIGN_CHANGED)
def validate_prompt_policy(repo: Path, autoreview: Path) -> None:
namespace = runpy.run_path(str(autoreview))
prompt = namespace["build_prompt"](repo, "local", None, "fixture diff", "", "")
required = (
"This helper is a closeout gate.",
"Do not turn a narrow patch into a broad",
"If this is release-branch or release-process work",
"Non-blocking design,",
)
missing = [needle for needle in required if needle not in prompt]
if missing:
raise RuntimeError(f"autoreview prompt missing scope policy: {missing}")
def run_reviews(repo: Path, script_dir: Path, fixture: str, engines: list[str]) -> None:
autoreview = script_dir / "autoreview"
validate_prompt_policy(repo, autoreview)
for engine in engines:
print(f"== {engine} ==", flush=True)
command = [

View File

@@ -284,7 +284,7 @@ gh search issues --repo openclaw/openclaw --match title,body --limit 50 \
- If bot review conversations exist on your PR, address them and resolve them yourself once fixed.
- Leave a review conversation unresolved only when reviewer or maintainer judgment is still needed.
- Before landing any PR with non-trivial code changes, run `$autoreview` until no accepted/actionable findings remain, unless equivalent manual review already covered it, the change is trivial/docs-only, or the user opts out.
- When landing or merging any PR, follow the global `/landpr` process.
- When an agent is landing or merging a PR targeting `main`, use only the repo-native `scripts/pr` wrapper: run `scripts/pr review-init <PR>`, follow its emitted checkout/guard guidance, initialize and complete review artifacts with `scripts/pr review-artifacts-init <PR>`, validate them with `scripts/pr review-validate-artifacts <PR>`, then run `scripts/pr prepare-run <PR>` and `scripts/pr merge-run <PR>`.
- Use `scripts/committer "<msg>" <file...>` for scoped commits instead of manual `git add` and `git commit`.
- Keep commit messages concise and action-oriented.
- Group related changes; avoid bundling unrelated refactors.

View File

@@ -65,6 +65,13 @@ gh workflow run openclaw-performance.yml \
Prefer the trusted workflow on `main`, target the exact release SHA:
- Keep trusted-workflow checks compatible with frozen release targets. If
`main` adds a target-owned guard script or package command after the release
branch cut, make the trusted workflow skip only when that target surface is
absent. Heal the trusted workflow before rerunning validation; do not port an
unrelated runtime refactor or mutate the release candidate just to satisfy a
newer `main`-only check.
```bash
gh workflow run full-release-validation.yml \
--repo openclaw/openclaw \

View File

@@ -552,6 +552,16 @@ node --import tsx scripts/openclaw-npm-postpublish-verify.ts <published-version>
- `preflight_only=true` on the npm workflow is also the right way to validate an
existing tag after publish; it should keep running the build checks even when
the npm version is already published.
- npm registry metadata is eventually consistent immediately after trusted
publishing. Keep postpublish `npm view` checks on bounded `--prefer-online`
retries, and carry that verified tarball/integrity metadata into later proof
steps instead of reading the registry again. If the OpenClaw npm child
succeeded but the parent publish workflow failed on an immediate exact-version
`E404`, verify the exact version with a cache-bypassed registry read, run the
standalone postpublish verifier and the full beta verifier with the original
successful child run IDs, then finalize the draft, dependency evidence asset,
and release proof manually. Never rerun the publish workflow for that
already-published version.
- npm validation-only preflight may still be dispatched from ordinary branches
when testing workflow changes before merge. Release checks and real publish
use only `main` or `release/YYYY.M.PATCH`.
@@ -720,8 +730,13 @@ node --import tsx scripts/openclaw-npm-postpublish-verify.ts <published-version>
waited plugin publish or Windows Hub promotion fails after OpenClaw npm
succeeds, the workflow keeps the release draft with OpenClaw npm evidence
and exits red; do not undraft until the gap is repaired. The standalone
verifier command remains the recovery probe:
verifier command remains the first recovery probe:
`node --import tsx scripts/openclaw-npm-postpublish-verify.ts <published-version>`.
For a failed postpublish parent after successful publish children, also run
`pnpm release:verify-beta -- <published-version> ... --skip-github-release`
with the original child run IDs and an evidence output path before manually
recreating the workflow's draft, dependency evidence asset, proof section,
and publish step.
25. Run the post-published beta verification roster. First scan current `main`
for critical fixes that landed after the release branch cut; backport only
important low-risk fixes before starting expensive lanes, or increment to

View File

@@ -1523,7 +1523,13 @@ jobs:
fi
;;
session-transcript-reader-boundary)
run_check "lint:tmp:session-transcript-reader-boundary" pnpm run lint:tmp:session-transcript-reader-boundary
if [ ! -f scripts/check-session-transcript-reader-boundary.mjs ]; then
echo "[skip] session transcript reader boundary check is not present in this checkout"
elif ! node -e 'const pkg = require("./package.json"); process.exit(pkg.scripts?.["lint:tmp:session-transcript-reader-boundary"] ? 0 : 1);'; then
echo "[skip] session transcript reader boundary script is not present in package.json"
else
run_check "lint:tmp:session-transcript-reader-boundary" pnpm run lint:tmp:session-transcript-reader-boundary
fi
;;
extension-channels)
run_check "lint:extensions:channels" pnpm run lint:extensions:channels

View File

@@ -275,7 +275,7 @@ jobs:
local workflow="$1"
shift
local before_json dispatch_output run_id status conclusion url poll_count
local dispatch_output run_id status conclusion url poll_count
gh_with_retry() {
local output status attempt
for attempt in 1 2 3 4 5 6; do
@@ -298,8 +298,6 @@ jobs:
printf '%s\n' "$output" >&2
return "$status"
}
before_json="$(gh_with_retry run list --workflow "$workflow" --event workflow_dispatch --limit 100 --json databaseId --jq '[.[].databaseId]')"
dispatch_output="$(gh_with_retry workflow run "$workflow" --ref "$CHILD_WORKFLOW_REF" "$@")"
printf '%s\n' "$dispatch_output"
run_id="$(
@@ -309,20 +307,7 @@ jobs:
)"
if [[ -z "$run_id" ]]; then
for _ in $(seq 1 60); do
run_id="$(
BEFORE_IDS="$before_json" gh_with_retry run list --workflow "$workflow" --event workflow_dispatch --limit 50 --json databaseId,createdAt \
--jq 'map(select(.databaseId as $id | (env.BEFORE_IDS | fromjson | index($id) | not))) | sort_by(.createdAt) | reverse | .[0].databaseId // empty'
)"
if [[ -n "$run_id" ]]; then
break
fi
sleep 5
done
fi
if [[ -z "${run_id:-}" ]]; then
echo "Could not find dispatched run for ${workflow}." >&2
echo "::error::gh workflow run ${workflow} did not return an Actions run URL; refusing to guess from recent workflow_dispatch runs." >&2
exit 1
fi
@@ -423,7 +408,7 @@ jobs:
local workflow="$1"
shift
local before_json dispatch_output run_id status conclusion url poll_count
local dispatch_output run_id status conclusion url poll_count
gh_with_retry() {
local output status attempt
for attempt in 1 2 3 4 5 6; do
@@ -446,8 +431,6 @@ jobs:
printf '%s\n' "$output" >&2
return "$status"
}
before_json="$(gh_with_retry run list --workflow "$workflow" --event workflow_dispatch --limit 100 --json databaseId --jq '[.[].databaseId]')"
dispatch_output="$(gh_with_retry workflow run "$workflow" --ref "$CHILD_WORKFLOW_REF" "$@")"
printf '%s\n' "$dispatch_output"
run_id="$(
@@ -457,20 +440,7 @@ jobs:
)"
if [[ -z "$run_id" ]]; then
for _ in $(seq 1 60); do
run_id="$(
BEFORE_IDS="$before_json" gh_with_retry run list --workflow "$workflow" --event workflow_dispatch --limit 50 --json databaseId,createdAt \
--jq 'map(select(.databaseId as $id | (env.BEFORE_IDS | fromjson | index($id) | not))) | sort_by(.createdAt) | reverse | .[0].databaseId // empty'
)"
if [[ -n "$run_id" ]]; then
break
fi
sleep 5
done
fi
if [[ -z "${run_id:-}" ]]; then
echo "Could not find dispatched run for ${workflow}." >&2
echo "::error::gh workflow run ${workflow} did not return an Actions run URL; refusing to guess from recent workflow_dispatch runs." >&2
exit 1
fi
@@ -581,7 +551,7 @@ jobs:
local workflow="$1"
shift
local before_json dispatch_output run_id status conclusion url poll_count run_json
local dispatch_output run_id status conclusion url poll_count run_json
gh_with_retry() {
local output status attempt
for attempt in 1 2 3 4 5 6; do
@@ -604,8 +574,6 @@ jobs:
printf '%s\n' "$output" >&2
return "$status"
}
before_json="$(gh_with_retry run list --workflow "$workflow" --event workflow_dispatch --limit 100 --json databaseId --jq '[.[].databaseId]')"
dispatch_output="$(gh_with_retry workflow run "$workflow" --ref "$CHILD_WORKFLOW_REF" "$@")"
printf '%s\n' "$dispatch_output"
run_id="$(
@@ -615,20 +583,7 @@ jobs:
)"
if [[ -z "$run_id" ]]; then
for _ in $(seq 1 60); do
run_id="$(
BEFORE_IDS="$before_json" gh_with_retry run list --workflow "$workflow" --event workflow_dispatch --limit 50 --json databaseId,createdAt \
--jq 'map(select(.databaseId as $id | (env.BEFORE_IDS | fromjson | index($id) | not))) | sort_by(.createdAt) | reverse | .[0].databaseId // empty'
)"
if [[ -n "$run_id" ]]; then
break
fi
sleep 5
done
fi
if [[ -z "${run_id:-}" ]]; then
echo "Could not find dispatched run for ${workflow}." >&2
echo "::error::gh workflow run ${workflow} did not return an Actions run URL; refusing to guess from recent workflow_dispatch runs." >&2
exit 1
fi
@@ -928,8 +883,6 @@ jobs:
return "$status"
}
before_json="$(gh_with_retry run list --workflow npm-telegram-beta-e2e.yml --event workflow_dispatch --limit 100 --json databaseId --jq '[.[].databaseId]')"
args=(-f package_spec="${PACKAGE_SPEC:-openclaw@beta}" -f harness_ref="$TARGET_SHA" -f provider_mode="$PROVIDER_MODE")
if [[ -z "${PACKAGE_SPEC// }" ]]; then
if [[ "$PREPARE_PACKAGE_RESULT" != "success" || -z "${PACKAGE_ARTIFACT_NAME// }" ]]; then
@@ -946,22 +899,16 @@ jobs:
args+=(-f scenario="$SCENARIO")
fi
gh_with_retry workflow run npm-telegram-beta-e2e.yml --ref "$CHILD_WORKFLOW_REF" "${args[@]}"
run_id=""
for _ in $(seq 1 60); do
run_id="$(
BEFORE_IDS="$before_json" gh_with_retry run list --workflow npm-telegram-beta-e2e.yml --event workflow_dispatch --limit 50 --json databaseId,createdAt \
--jq 'map(select(.databaseId as $id | (env.BEFORE_IDS | fromjson | index($id) | not))) | sort_by(.createdAt) | reverse | .[0].databaseId // empty'
)"
if [[ -n "$run_id" ]]; then
break
fi
sleep 5
done
dispatch_output="$(gh_with_retry workflow run npm-telegram-beta-e2e.yml --ref "$CHILD_WORKFLOW_REF" "${args[@]}")"
printf '%s\n' "$dispatch_output"
run_id="$(
printf '%s\n' "$dispatch_output" |
sed -nE 's#.*actions/runs/([0-9]+).*#\1#p' |
tail -n 1
)"
if [[ -z "$run_id" ]]; then
echo "Could not find dispatched run for npm-telegram-beta-e2e.yml." >&2
echo "::error::gh workflow run npm-telegram-beta-e2e.yml did not return an Actions run URL; refusing to guess from recent workflow_dispatch runs." >&2
exit 1
fi
@@ -1073,31 +1020,23 @@ jobs:
echo "- Release impact: advisory"
} >> "$GITHUB_STEP_SUMMARY"
before_json="$(gh_with_retry run list --workflow openclaw-performance.yml --event workflow_dispatch --limit 100 --json databaseId --jq '[.[].databaseId]')"
gh_with_retry workflow run openclaw-performance.yml \
dispatch_output="$(gh_with_retry workflow run openclaw-performance.yml \
--ref "$CHILD_WORKFLOW_REF" \
-f target_ref="$TARGET_SHA" \
-f profile=release \
-f repeat=3 \
-f deep_profile=false \
-f live_openai_candidate=false \
-f fail_on_regression=false
run_id=""
for _ in $(seq 1 60); do
run_id="$(
BEFORE_IDS="$before_json" gh_with_retry run list --workflow openclaw-performance.yml --event workflow_dispatch --limit 50 --json databaseId,createdAt \
--jq 'map(select(.databaseId as $id | (env.BEFORE_IDS | fromjson | index($id) | not))) | sort_by(.createdAt) | reverse | .[0].databaseId // empty'
)"
if [[ -n "$run_id" ]]; then
break
fi
sleep 5
done
-f fail_on_regression=false)"
printf '%s\n' "$dispatch_output"
run_id="$(
printf '%s\n' "$dispatch_output" |
sed -nE 's#.*actions/runs/([0-9]+).*#\1#p' |
tail -n 1
)"
if [[ -z "$run_id" ]]; then
echo "::warning::Could not find dispatched run for openclaw-performance.yml."
echo "::warning::gh workflow run openclaw-performance.yml did not return an Actions run URL; refusing to guess from recent workflow_dispatch runs."
exit 0
fi

View File

@@ -1112,13 +1112,14 @@ jobs:
}
append_release_proof_to_github_release() {
local release_version body_file notes_file tarball integrity telegram_line clawhub_line clawhub_bootstrap_line clawhub_runtime_state_path windows_line
local release_version body_file notes_file evidence_path tarball integrity telegram_line clawhub_line clawhub_bootstrap_line clawhub_runtime_state_path windows_line
release_version="${RELEASE_TAG#v}"
body_file="${RUNNER_TEMP}/release-body.md"
notes_file="${RUNNER_TEMP}/release-notes-with-proof.md"
tarball="$(npm view "openclaw@${release_version}" dist.tarball --json | jq -r '.')"
integrity="$(npm view "openclaw@${release_version}" dist.integrity --json | jq -r '.')"
evidence_path="${POSTPUBLISH_EVIDENCE_DIR}/release-postpublish-evidence.json"
tarball="$(jq -er '.openclawNpmTarball | select(type == "string" and length > 0)' "${evidence_path}")"
integrity="$(jq -er '.openclawNpmIntegrity | select(type == "string" and length > 0)' "${evidence_path}")"
gh release view "${RELEASE_TAG}" --repo "$GITHUB_REPOSITORY" --json body --jq .body > "${body_file}"
if [[ -n "${NPM_TELEGRAM_RUN_ID// }" ]]; then

View File

@@ -133,8 +133,9 @@ jobs:
$rootfs = "C:\wsl\ubuntu-noble-wsl.rootfs.tar.gz"
New-Item -ItemType Directory -Force -Path @((Split-Path -Parent $rootfs), $wslRoot) | Out-Null
Invoke-WebRequest -Uri $env:UBUNTU_WSL_ROOTFS_URL -OutFile $rootfs -UseBasicParsing
wsl.exe --import UbuntuProbe $wslRoot $rootfs --version 2
Write-Host "wsl_import_exit=$LASTEXITCODE"
$import = Invoke-WslText -Arguments @("--import", "UbuntuProbe", $wslRoot, $rootfs, "--version", "2")
Write-Host $import.Text
Write-Host "wsl_import_exit=$($import.Code)"
$list = Invoke-WslText -Arguments @("--list", "--verbose")
Write-Host $list.Text
Write-Host "wsl_list_after_import_exit=$($list.Code)"
@@ -144,14 +145,15 @@ jobs:
if ($distros.Count -gt 0) {
$distro = $distros[0]
Write-Host "wsl_probe_distro=$distro"
wsl.exe -d $distro --exec bash -lc 'set -euo pipefail; uname -a; if [ -f /etc/os-release ]; then sed -n "1,8p" /etc/os-release; fi'
$exec = Invoke-WslText -Arguments @("-d", $distro, "--exec", "bash", "-lc", 'set -euo pipefail; uname -a; if [ -f /etc/os-release ]; then sed -n "1,8p" /etc/os-release; fi')
} else {
wsl.exe --exec bash -lc 'set -euo pipefail; uname -a; if [ -f /etc/os-release ]; then sed -n "1,8p" /etc/os-release; fi'
$exec = Invoke-WslText -Arguments @("--exec", "bash", "-lc", 'set -euo pipefail; uname -a; if [ -f /etc/os-release ]; then sed -n "1,8p" /etc/os-release; fi')
}
if ($LASTEXITCODE -eq 0) {
Write-Host $exec.Text
if ($exec.Code -eq 0) {
$ok = $true
}
Write-Host "wsl_exec_exit=$LASTEXITCODE"
Write-Host "wsl_exec_exit=$($exec.Code)"
}
if ($ok) {

View File

@@ -251,3 +251,6 @@ jobs:
- name: Check plugin SDK API baseline drift
run: pnpm plugin-sdk:api:check
- name: Check plugin SDK surface budget
run: pnpm plugin-sdk:surface:check

View File

@@ -172,7 +172,7 @@ Skills own workflows; root owns hard policy and routing.
- PR artifacts/screenshots: attach to PR/comment/external artifact store. Never push screenshots, videos, proof images, or proof assets to OpenClaw or any product repo branch, including temp artifact branches. Use Crabbox artifact publishing plus the manifest URL. Do not commit `.github/pr-assets`.
- CI polling: exact SHA, relevant checks only, minimal fields. Skip routine noise (`Auto response`, `Labeler`, docs agents, performance/stale). Logs only after failure/completion or concrete need.
- OpenClaw write-access maintainers may skip `Real behavior proof` when local tests or Crabbox verified behavior; record proof in PR verification.
- `/landpr`: use `~/.codex/prompts/landpr.md`; do not idle on `auto-response` or `check-docs`.
- Agent PR landing to `main`: use only the repo-native `scripts/pr` wrapper: run `scripts/pr review-init <PR>`, follow its emitted checkout/guard guidance, initialize and complete review artifacts with `scripts/pr review-artifacts-init <PR>`, validate them with `scripts/pr review-validate-artifacts <PR>`, then run `scripts/pr prepare-run <PR>` and `scripts/pr merge-run <PR>`; do not idle on `auto-response` or `check-docs`.
## Code

View File

@@ -23,15 +23,23 @@ Docs: https://docs.openclaw.ai
### Fixes
- Onboarding/skills: show the Homebrew install recommendation only on macOS and Linux, so FreeBSD and other unsupported platforms no longer get a misleading brew prompt. Fixes #68893; carries forward #68894, #68910, #68941, #68943, #69002, and #69545. Thanks @yurivict, @Sanjays2402, @Eruditi, @JustInCache, @nnish16, and @Mlightsnow.
- 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.
- Auto-reply/groups: keep ordinary group text replies on automatic final-reply delivery while allowing `message(action=send)` for files, images, and other attachments to the same group or topic. Carries forward #43276; refs #48004. Thanks @NayukiChiba and @ShakaRover.
- iMessage: normalize leading NUL sent-message echo prefixes while preserving interior NUL bytes and the leading attributedBody marker handling from #73942. Carries forward #63581. Thanks @drvoss.
- 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, 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.
- Agents/exec: default empty-success background completion notices on only for real chat channels, preserving explicit opt-outs and keeping generic providers silent while carrying forward the narrow UX intent from #39726 and #46926. Thanks @Sapientropic and @wenkang-xie.
- 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.
- Workspace setup state: store setup completion outside the workspace dot directory using an OpenClaw-named root file, migrate valid legacy state forward, and avoid clobbering generic root `workspace-state.json` files for TigerFS-style dot-path compatibility. This Clownfish replacement carries forward the focused #53326 fix idea because the original branch was closed and uneditable. (#53326, #44783, #39446) Thanks @1qh.
- 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.
- Control UI: preserve Gateway Access tokens during same-normalized WebSocket URL edits and reload gateway-scoped tokens when switching endpoints. Fixes #41545; repairs #42001 with additional source PRs #41546, #41552, and #41718. Thanks @wsyjh8, @llagy0020, @llagy007, @pingfanfan, and @zheliu2.
- Gateway CLI: tolerate a single transient clean WebSocket close before `hello-ok` so one-shot RPC calls reconnect instead of failing noisily, while repeated clean pre-hello closes still surface. Carries forward source PRs #54475 and #54774; #85253 covered adjacent connect assembly diagnostics. Thanks @ruanrrn.
- Release and test reliability: extend slow Gateway/full-suite watchdogs, split local full-suite shards when throttled, stabilize plugin auth marker fixtures, avoid brittle provider-ref error text, and keep QA Lab bootstrap selection assertions aligned with flow-only scenarios. (#92652)
- macOS Peekaboo bridge: update the embedded Peekaboo package to 3.5.2 and route bundled-skill CLI commands through the OpenClaw app bridge so they inherit its Screen Recording and Accessibility grants.
- Agent routing: route subagent RPC callbacks addressed to an agent-shaped `--to` target to the correct session key instead of falling back to the main session, so WeChat (and other channel) session-key callbacks reach the intended subagent session. (#90231) Thanks @zhangguiping-xydt.
- Cron: preserve model, fallback, thinking, timeout, light-context, unsafe-content, and tool allow-list overrides on implicit text payloads by promoting them to agent turns, while explicit system events still prune those fields. Fixes #28905; carries forward #64060 and #73946. Thanks @liaoandi.
- QQBot delivery: keep markdown table chunks self-contained across message boundaries by preserving table state across block deliveries, flushing unfinished table-row fragments as plain text, and detecting short pipe-terminated rows by column count so split rows are not sent as malformed markdown. (#92428) Thanks @sliverp.
## 2026.6.6

View File

@@ -306,6 +306,15 @@
"fps",
"screenIndex"
]
},
"screen_snapshot": {
"label": "screen snapshot",
"detailKeys": [
"node",
"nodeId",
"screenIndex",
"maxWidth"
]
}
}
},

View File

@@ -6156,6 +6156,7 @@ public struct CronListParams: Codable, Sendable {
public let sortby: AnyCodable?
public let sortdir: AnyCodable?
public let agentid: String?
public let compact: Bool?
public init(
includedisabled: Bool?,
@@ -6167,7 +6168,8 @@ public struct CronListParams: Codable, Sendable {
lastrunstatus: AnyCodable?,
sortby: AnyCodable?,
sortdir: AnyCodable?,
agentid: String? = nil)
agentid: String? = nil,
compact: Bool? = nil)
{
self.includedisabled = includedisabled
self.limit = limit
@@ -6179,6 +6181,7 @@ public struct CronListParams: Codable, Sendable {
self.sortby = sortby
self.sortdir = sortdir
self.agentid = agentid
self.compact = compact
}
private enum CodingKeys: String, CodingKey {
@@ -6192,6 +6195,7 @@ public struct CronListParams: Codable, Sendable {
case sortby = "sortBy"
case sortdir = "sortDir"
case agentid = "agentId"
case compact
}
}

View File

@@ -1,4 +1,4 @@
0485ba902d2afd89d2c41cde7180d0cec2900b2db6804b9f97d42b7d85cd3af5 config-baseline.json
72bb80be618406f3337eaa2560d2559a35e49bd29576de8dd4a3aec1a6a94d92 config-baseline.core.json
1218f5555541b61bd5ddcac6441f15061b44789e2471d4ffecbe3059777c55c1 config-baseline.channel.json
a14ac4261e98403d1a7e047070e6f151938444e27382b860315bd0c74fda4861 config-baseline.plugin.json
64c09563ce090b8dda1e9794f021af3068db0ff4a59cf471e5ad094d2ae978b8 config-baseline.json
bb9f42e7f1d6713af46d693dba5c43efd603c222d7d0adbd0f0d0e95cdec6790 config-baseline.core.json
2d735389858305509528e74329b6f8c65d311e1471c3b4e91dc17aaab8e63a80 config-baseline.channel.json
a973af69b02a27b097b54e49886dd57dbebbc95e2ab29b0c7e222a9f35a105d8 config-baseline.plugin.json

View File

@@ -1,2 +1,2 @@
303312830e2d7275bfe5abcdbdb3b47fd8648067a7b51ca043503a78bb18d275 plugin-sdk-api-baseline.json
71e94e1de9f1b03aa44da55ec63d16146ab279740c44854d5998bc0f04d6ae0d plugin-sdk-api-baseline.jsonl
ea92ef67bc01141e1f3b64edd025471c9c3439da50de3cecb208e7eca797f947 plugin-sdk-api-baseline.json
28ed6df6ba46abfd252cd760b7f88a93c598b4256e6ea8dfc2c9005c327300fb plugin-sdk-api-baseline.jsonl

View File

@@ -465,7 +465,9 @@ openclaw cron edit <jobId> --clear-agent
`openclaw cron run <jobId>` returns after enqueueing the manual run. Use `--wait` for shutdown hooks, maintenance scripts, or other automation that must block until the queued run finishes. Wait mode polls the exact returned `runId`; it exits `0` for status `ok` and non-zero for `error`, `skipped`, or a wait timeout.
`openclaw cron create` is an alias for `openclaw cron add`, and new jobs can use a positional schedule (`"0 9 * * 1"`, `"every 1h"`, `"20m"`, or an ISO timestamp) followed by a positional agent prompt. Use `--webhook <url>` on `cron add|create` or `cron edit` to POST the finished run payload to an HTTP endpoint. Webhook delivery cannot be combined with chat delivery flags such as `--announce`, `--channel`, `--to`, `--thread-id`, or `--account`.
The agent `cron` tool returns compact job summaries (`id`, `name`, `enabled`, `nextRunAtMs`, `scheduleKind`, `lastRunStatus`) from `cron(action: "list")`; use `cron(action: "get", jobId: "...")` for one full job definition. Direct Gateway callers can pass `compact: true` to `cron.list`; omitting it preserves the existing full response with delivery previews.
`openclaw cron create` is an alias for `openclaw cron add`, and new jobs can use a positional schedule (`"0 9 * * 1"`, `"every 1h"`, `"20m"`, or an ISO timestamp) followed by a positional agent prompt. Use `--webhook <url>` on `cron add|create` or `cron edit` to POST the finished run payload to an HTTP endpoint. Webhook delivery cannot be combined with chat delivery flags such as `--announce`, `--channel`, `--to`, `--thread-id`, or `--account`. On `cron edit`, `--clear-channel`, `--clear-to`, `--clear-thread-id`, and `--clear-account` unset those routing fields individually (each rejected alongside its matching set flag), which is distinct from `--no-deliver` disabling runner fallback delivery.
<Note>
Model override note:

View File

@@ -50,6 +50,8 @@ Use `messages.groupChat.visibleReplies: "message_tool"` when a shared room shoul
Use `"automatic"` for weaker models or runtimes that do not reliably understand tool-only delivery. In automatic mode, the agent's final assistant text is the visible source reply path, so a model that cannot consistently call `message(action=send)` can still answer normally.
In automatic mode, normal text final replies are posted directly to the room. If the visible reply needs files, images, or other attachments, the agent may still use `message(action=send)` for that attachment instead of trying to force it through the final text reply.
If the message tool is unavailable under the active tool policy, OpenClaw falls
back to automatic visible replies instead of silently suppressing the response.
`openclaw doctor` warns about this mismatch.

View File

@@ -111,6 +111,10 @@ After a successful startup, OpenClaw caches the bot identity in the state direct
## Access control and activation
### Group bot identity
In Telegram groups and forum topics, an explicit mention of the configured bot handle (for example `@my_bot`) is treated as addressing the selected OpenClaw agent, even when the agent persona name differs from the Telegram username. The group silence policy still applies to unrelated group traffic, but the bot handle itself is not considered "someone else."
<Tabs>
<Tab title="DM policy">
`channels.telegram.dmPolicy` controls direct message access:
@@ -418,7 +422,19 @@ curl "https://api.telegram.org/bot<bot_token>/getUpdates"
</Accordion>
<Accordion title="Rich message formatting">
Outbound text uses Telegram rich messages.
Outbound text uses standard Telegram HTML messages by default so replies remain readable across current Telegram clients.
Set `channels.telegram.richMessages: true` to opt into Bot API 10.1 rich messages:
```json5
{
channels: {
telegram: {
richMessages: true,
},
},
}
```
- Markdown text is rendered through OpenClaw's Markdown IR and sent as Telegram rich HTML.
- Explicit rich HTML payloads preserve supported Bot API 10.1 tags such as headings, tables, details, rich media, and formulas.
@@ -426,6 +442,8 @@ curl "https://api.telegram.org/bot<bot_token>/getUpdates"
This keeps model text away from Telegram Rich Markdown sigils, so currency like `$400-600K` is not parsed as math. Long rich text is split automatically across Telegram's rich text and rich block limits. Tables over Telegram's column limit are sent as code blocks.
Rich messages require compatible Telegram clients. Some current Desktop, Web, Android, and third-party clients display accepted rich messages as unsupported, so keep this option disabled unless every client used with the bot can render them.
Link previews are enabled by default. `channels.telegram.linkPreview: false` skips automatic entity detection for rich text.
</Accordion>
@@ -1081,7 +1099,7 @@ Primary reference: [Configuration reference - Telegram](/gateway/config-channels
- command/menu: `commands.native`, `commands.nativeSkills`, `customCommands`
- threading/replies: `replyToMode`
- streaming: `streaming` (preview), `streaming.preview.toolProgress`, `blockStreaming`
- formatting/delivery: `textChunkLimit`, `chunkMode`, `linkPreview`, `responsePrefix`
- formatting/delivery: `textChunkLimit`, `chunkMode`, `richMessages`, `linkPreview`, `responsePrefix`
- media/network: `mediaMaxMb`, `mediaGroupFlushMs`, `timeoutSeconds`, `pollingStallThresholdMs`, `retry`, `network.autoSelectFamily`, `network.dangerouslyAllowPrivateNetwork`, `proxy`
- custom API root: `apiRoot` (Bot API root only; do not include `/bot<TOKEN>`)
- webhook: `webhookUrl`, `webhookSecret`, `webhookPath`, `webhookHost`

View File

@@ -93,6 +93,8 @@ Isolated cron chat delivery is shared between the agent and the runner:
Use `cron add|create --webhook <url>` or `cron edit <job-id> --webhook <url>` to set webhook delivery. Do not combine `--webhook` with chat delivery flags such as `--announce`, `--no-deliver`, `--channel`, `--to`, `--thread-id`, or `--account`.
`cron edit <job-id>` can unset individual delivery routing fields with `--clear-channel`, `--clear-to`, `--clear-thread-id`, and `--clear-account` (each is rejected when combined with its matching set flag). Unlike `--no-deliver`, which only disables runner fallback delivery, these remove the stored field so the job resolves that part of its route from defaults again.
`--announce` is runner fallback delivery for the final reply. `--no-deliver` disables that fallback but does not remove the agent's `message` tool when a chat route is available.
Reminders created from an active chat preserve the live chat delivery target for fallback announce delivery. Internal session keys may be lowercase; do not use them as a source of truth for case-sensitive provider IDs such as Matrix room IDs.

View File

@@ -54,7 +54,8 @@ doctor can report the missing artifact.
Policy is authored, not generated from the user's current settings. A minimal
policy for channels, MCP servers, model providers, network posture, ingress/channel access, Gateway
exposure, agent workspace posture, configured sandbox runtime posture, OpenClaw
data-handling posture, config secret provider/auth profile posture, and tool metadata looks like this:
data-handling posture, config secret provider/auth profile posture, exec approval
file posture, and tool metadata looks like this:
```jsonc
{
@@ -145,6 +146,15 @@ data-handling posture, config secret provider/auth profile posture, and tool met
"allowModes": ["api_key", "token"],
},
},
"execApprovals": {
"requireFile": true,
"defaults": { "allowSecurity": ["deny"] },
"agents": {
"allowSecurity": ["deny", "allowlist"],
"allowAutoAllowSkills": false,
"allowlist": { "expected": ["deploy", "status"] },
},
},
"tools": {
"requireMetadata": ["risk", "sensitivity", "owner"],
"profiles": {
@@ -187,9 +197,11 @@ and `group:runtime` covers shell/process tools. Tool posture policy observes
`tools.profile`, `tools.allow`, `tools.alsoAllow`, `tools.deny`,
`tools.fs.workspaceOnly`, `tools.exec.security`, `tools.exec.ask`,
`tools.exec.host`, `tools.elevated.enabled`, and the same per-agent
`agents.list[].tools.*` overrides. It does not read runtime/operator approval
state such as exec-approvals.json, and it does not enforce tool calls at
runtime. Secret evidence records
`agents.list[].tools.*` overrides. Exec approval policy reads the named
`exec-approvals.json` product artifact only when an `execApprovals` rule is
present; evidence records defaults, per-agent posture, and allowlist patterns
without socket tokens or last-used command text. Policy does not enforce tool
calls at runtime. Secret evidence records
provider/source posture and SecretRef metadata, never raw secret values. Policy
does not read or attest per-agent credential stores such as `auth-profiles.json`;
those stores remain owned by the existing auth and credential flows.
@@ -218,8 +230,8 @@ its own finding against the same observed config.
Use `scopes.<scopeName>` when one set of agents or channels needs stricter
policy than the top-level baseline. Agent-scoped sections use `agentIds`, which
supports `tools.*`, `agents.workspace.*`, `sandbox.*`, and
`dataHandling.memory.*`. Channel-scoped
supports `tools.*`, `agents.workspace.*`, `sandbox.*`, `dataHandling.memory.*`,
and `execApprovals.*`. Channel-scoped
ingress uses `channelIds`, which supports `ingress.channels.*`. Unsupported
sections are rejected instead of being ignored. If an `agentIds` entry is not
present in `agents.list[]`, OpenClaw evaluates the scoped rule against inherited
@@ -304,10 +316,10 @@ groups where those fields cannot be observed.
Top-level `ingress.session.requireDmScope` remains global because
`session.dmScope` is not channel-attributable evidence.
| Selector | Supported sections | Use when |
| ------------ | ----------------------------------------------------------------- | ------------------------------------------------- |
| `agentIds` | `tools`, `agents.workspace`, `sandbox`, and `dataHandling.memory` | One or more runtime agents need stricter rules. |
| `channelIds` | `ingress.channels` | One or more channels need stricter ingress rules. |
| Selector | Supported sections | Use when |
| ------------ | ---------------------------------------------------------------------------------- | ------------------------------------------------- |
| `agentIds` | `tools`, `agents.workspace`, `sandbox`, `dataHandling.memory`, and `execApprovals` | One or more runtime agents need stricter rules. |
| `channelIds` | `ingress.channels` | One or more channels need stricter ingress rules. |
Every scope present in `policy.jsonc` must be valid and enforceable.
@@ -401,6 +413,69 @@ allowlist such as `["all"]`.
| `secrets.denySources` | Secret provider sources and SecretRef sources | Deny sources such as `exec`, `file`, or another configured source name. |
| `secrets.allowInsecureProviders` | Insecure secret-provider posture flags | Set to `false` to reject providers that opt into insecure posture. |
#### Exec approvals
Exec approvals policy observes the active runtime `exec-approvals.json`
artifact. By default this is `~/.openclaw/exec-approvals.json`; when
`OPENCLAW_STATE_DIR` is set, Policy reads
`$OPENCLAW_STATE_DIR/exec-approvals.json`. Actual posture rules such as
`execApprovals.defaults.*` or `execApprovals.agents.*` require readable artifact
evidence; a missing or invalid artifact is reported as unobservable evidence
instead of becoming a best-effort pass against synthetic runtime defaults. Once
the artifact is readable, omitted approval fields inherit runtime defaults: missing
`defaults.security` is `full`, and missing agent security inherits that
default. Evidence includes `defaults`, `agents.*`, and
`agents.*.allowlist[].pattern` plus optional `argPattern`, effective
`autoAllowSkills` posture, and entry source. It does not include socket
path/token, `commandText`, `lastUsedCommand`, resolved paths, or timestamps.
| Policy field | Observed state | Use when |
| ------------------------------------------- | -------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------- |
| `execApprovals.requireFile` | Active runtime `exec-approvals.json` path | Set to `true` to require the approvals artifact to exist and parse. |
| `execApprovals.defaults.allowSecurity` | `defaults.security`, defaulting to `full` | Allow only approved default approval security modes. |
| `execApprovals.agents.allowSecurity` | `agents.*.security`, inheriting defaults | Allow only approved per-agent effective approval security modes. |
| `execApprovals.agents.allowAutoAllowSkills` | `defaults.autoAllowSkills` and `agents.*.autoAllowSkills`, inheriting runtime defaults | Set to `false` to require strict manual allowlists without implicit skill CLI approval. |
| `execApprovals.agents.allowlist.expected` | Aggregate `agents.*.allowlist[]` pattern and optional argPattern entries | Require the approvals allowlist to match the reviewed pattern set. |
For example, require the approvals artifact, deny permissive defaults, and
allow only reviewed exec approval posture for selected agents:
```jsonc
{
"execApprovals": {
"requireFile": true,
"defaults": {
// Security modes: "deny", "allowlist", or "full".
// This default permits only the locked-down deny posture.
"allowSecurity": ["deny"],
},
},
"scopes": {
"restricted-shell": {
"agentIds": ["family-agent", "groups-agent"],
"execApprovals": {
"agents": {
// Selected agents may use reviewed allowlist posture, but not "full".
"allowSecurity": ["allowlist"],
// false means skill CLIs must appear in the reviewed allowlist instead of
// being implicitly approved by autoAllowSkills.
"allowAutoAllowSkills": false,
"allowlist": {
"expected": [
// Simple entry: exact reviewed executable pattern with no argPattern.
"travel-hub",
// Constrained entry: pattern plus reviewed argument regex.
{ "pattern": "calendar-cli", "argPattern": "^sync\\b" },
"/bin/date",
],
},
},
},
},
},
}
```
#### Auth profiles
| Policy field | Observed state | Use when |
@@ -769,6 +844,13 @@ Policy currently verifies:
| `policy/secrets-insecure-provider` | A secret provider opts into insecure posture when policy denies it. |
| `policy/auth-profile-invalid-metadata` | A config auth profile is missing valid provider or mode metadata. |
| `policy/auth-profile-unapproved-mode` | A config auth profile mode is outside the policy allowlist. |
| `policy/exec-approvals-missing` | Policy requires `exec-approvals.json`, but the artifact is missing. |
| `policy/exec-approvals-invalid` | The configured exec approvals artifact cannot be parsed. |
| `policy/exec-approvals-default-security-unapproved` | Exec approval defaults use a security mode outside the policy allowlist. |
| `policy/exec-approvals-agent-security-unapproved` | A per-agent effective exec approval security mode is outside the allowlist. |
| `policy/exec-approvals-auto-allow-skills-enabled` | An exec approval agent implicitly auto-allows skill CLIs when policy denies it. |
| `policy/exec-approvals-allowlist-missing` | The approvals allowlist is missing a pattern required by policy. |
| `policy/exec-approvals-allowlist-unexpected` | The approvals allowlist includes a pattern not expected by policy. |
| `policy/tools-missing-risk-level` | A governed tool declaration is missing risk metadata. |
| `policy/tools-unknown-risk-level` | A governed tool declaration uses an unknown risk value. |
| `policy/tools-missing-sensitivity-token` | A governed tool declaration is missing sensitivity metadata. |

View File

@@ -44,7 +44,7 @@ For webhook ingress, startup logs a non-fatal security warning and audit flags `
If Gateway password auth is supplied only at startup, pass the same value to `openclaw security audit --auth password --password <password>` so the audit can check it against `hooks.token`.
Run `openclaw doctor --fix` to rotate a persisted reused `hooks.token`, then update external hook senders to use the new hook token.
It also warns when sandbox Docker settings are configured while sandbox mode is off, when `gateway.nodes.denyCommands` uses ineffective pattern-like/unknown entries (exact node command-name matching only, not shell-text filtering), when `gateway.nodes.allowCommands` explicitly enables dangerous node commands, when global `tools.profile="minimal"` is overridden by agent tool profiles, when write/edit tools are disabled but `exec` is still available without a constraining sandbox filesystem boundary, when open groups expose runtime/filesystem tools without sandbox/workspace guards, and when installed plugin tools may be reachable under permissive tool policy.
It also warns when sandbox Docker settings are configured while sandbox mode is off, when `gateway.nodes.denyCommands` uses ineffective pattern-like/unknown entries (exact node command-name matching only, not shell-text filtering), when `gateway.nodes.allowCommands` explicitly enables dangerous node commands, when global `tools.profile="minimal"` is overridden by agent tool profiles, when write/edit tools are disabled but `exec` is still available without a constraining sandbox filesystem boundary, when open DMs or groups expose runtime/filesystem tools without sandbox/workspace guards, and when installed plugin tools may be reachable under permissive tool policy.
It also flags `gateway.allowRealIpFallback=true` (header-spoofing risk if proxies are misconfigured) and `discovery.mdns.mode="full"` (metadata leakage via mDNS TXT records).
It also warns when sandbox browser uses Docker `bridge` network without `sandbox.browser.cdpSourceRange`.
It also flags dangerous sandbox Docker network modes (including `host` and `container:*` namespace joins).

View File

@@ -36,7 +36,7 @@ If `userTimezone` is unset, OpenClaw resolves the host timezone at runtime (no c
- **Use UTC envelopes** (`envelopeTimezone: "utc"`) when you want stable timestamps across hosts in different regions, or when you want UTC-aligned logs to match diagnostics output.
- **Use a fixed IANA zone** (e.g. `"Europe/Vienna"`) when the gateway host is in one zone but the user is in another and you want envelopes to read in the user's zone regardless of host migration.
- **Set `envelopeTimestamp: "off"`** for low-token envelopes when timestamp context is not useful for the conversation.
- **Set `envelopeTimestamp: "off"`** when timestamp context is not useful for the conversation. This removes absolute timestamps from envelopes, direct agent prompt prefixes, and embedded model-input prefixes.
For the full behavior reference, examples per provider, and elapsed-time formatting, see [Date & Time](/date-time).

View File

@@ -37,7 +37,7 @@ You can override this behavior:
- `envelopeTimezone: "local"` uses the host timezone.
- `envelopeTimezone: "user"` uses `agents.defaults.userTimezone` (falls back to host timezone).
- Use an explicit IANA timezone (e.g., `"America/Chicago"`) for a fixed zone.
- `envelopeTimestamp: "off"` removes absolute timestamps from envelope headers.
- `envelopeTimestamp: "off"` removes absolute timestamps from envelope headers, direct agent prompt prefixes, and embedded model-input prefixes.
- `envelopeElapsed: "off"` removes elapsed time suffixes (the `+2m` style).
### Examples

View File

@@ -1385,7 +1385,8 @@
"pages": [
"clawhub/api",
"clawhub/http-api",
"clawhub/acceptable-usage"
"clawhub/acceptable-usage",
"clawhub/content-rights"
]
}
]

View File

@@ -99,7 +99,7 @@ Optional request headers:
- `x-openclaw-model: <provider/model-or-bare-id>` overrides the backend model for the selected agent. Shared-secret bearer callers can use this header. Identity-bearing callers, such as trusted-proxy or private no-auth ingress requests with `x-openclaw-scopes`, need `operator.admin`; write-only callers get `403 missing scope: operator.admin`.
- `x-openclaw-agent-id: <agentId>` remains supported as a compatibility override.
- `x-openclaw-session-key: <sessionKey>` fully controls session routing.
- `x-openclaw-session-key: <sessionKey>` explicitly controls session routing. The value must not use reserved internal session namespaces such as `subagent:`, `cron:`, or `acp:`; those requests are rejected with `400 invalid_request_error`.
- `x-openclaw-message-channel: <channel>` sets the synthetic ingress channel context for channel-aware prompts and policies.
Compatibility aliases still accepted:
@@ -145,7 +145,7 @@ By default the endpoint is **stateless per request** (a new session key is gener
If the request includes an OpenAI `user` string, the Gateway derives a stable session key from it, so repeated calls can share an agent session.
For custom apps, the safest default is to reuse the same `user` value per conversation thread. Avoid account-level identifiers unless you explicitly want multiple conversations or devices to share one OpenClaw session. Use `x-openclaw-session-key` when you need explicit routing control across multiple clients or threads.
For custom apps, the safest default is to reuse the same `user` value per conversation thread. Avoid account-level identifiers unless you explicitly want multiple conversations or devices to share one OpenClaw session. Use `x-openclaw-session-key` only when you need explicit routing control across multiple clients or threads, and choose application-owned keys that do not start with reserved internal namespaces such as `subagent:`, `cron:`, or `acp:`.
## Why this surface matters

View File

@@ -110,8 +110,8 @@ exhaustive):
| `skills.code_safety` | warn/critical | Skill installer metadata/code contains suspicious or dangerous patterns | skill install source | no |
| `skills.code_safety.scan_failed` | warn | Skill code scan could not complete | skill scan environment | no |
| `security.exposure.open_channels_with_exec` | warn/critical | Shared/public rooms can reach exec-enabled agents | `channels.*.dmPolicy`, `channels.*.groupPolicy`, `tools.exec.*`, `agents.list[].tools.exec.*` | no |
| `security.exposure.open_groups_with_elevated` | critical | Open groups + elevated tools create high-impact prompt-injection paths | `channels.*.groupPolicy`, `tools.elevated.*` | no |
| `security.exposure.open_groups_with_runtime_or_fs` | critical/warn | Open groups can reach command/file tools without sandbox/workspace guards | `channels.*.groupPolicy`, `tools.profile/deny`, `tools.fs.workspaceOnly`, `agents.*.sandbox.mode` | no |
| `security.exposure.open_groups_with_elevated` | critical | Open DMs/groups + elevated tools create high-impact prompt-injection paths | top-level or nested DM policy paths, account overrides, `channels.*.groupPolicy` | no |
| `security.exposure.open_groups_with_runtime_or_fs` | critical/warn | Open DMs/groups can reach command/file tools without sandbox/workspace guards | DM/group policy paths, `tools.profile/deny`, `tools.fs.workspaceOnly`, `agents.*.sandbox.mode` | no |
| `security.trust_model.multi_user_heuristic` | warn | Config looks multi-user while gateway trust model is personal-assistant | split trust boundaries, or shared-user hardening (`sandbox.mode`, tool deny/workspace scoping`) | no |
| `tools.profile_minimal_overridden` | warn | Agent overrides bypass global minimal profile | `agents.list[].tools.profile` | no |
| `plugins.tools_reachable_permissive_policy` | warn | Extension tools reachable in permissive contexts | `tools.profile` + tool allow/deny | no |

View File

@@ -103,8 +103,45 @@ Supported `appServer` fields:
| `approvalsReviewer` | `"user"` or an allowed guardian reviewer | Use `"auto_review"` to let Codex review native approval prompts when allowed. |
| `defaultWorkspaceDir` | current process directory | Workspace used by `/codex bind` when `--cwd` is omitted. |
| `serviceTier` | unset | Optional Codex app-server service tier. `"priority"` enables fast-mode routing, `"flex"` requests flex processing, and `null` clears the override. Legacy `"fast"` is accepted as `"priority"`. |
| `networkProxy` | disabled | Opt into Codex permissions-profile networking for app-server commands. OpenClaw defines the selected `permissions.<profile>.network` config and selects that profile on thread start or resume instead of sending `sandbox`. |
| `experimental.sandboxExecServer` | `false` | Preview opt-in that registers an OpenClaw sandbox-backed Codex environment with Codex app-server 0.132.0 or newer so native Codex execution can run inside the active OpenClaw sandbox. |
`appServer.networkProxy` is explicit because it changes the Codex sandbox
contract. When enabled, OpenClaw also sets `features.network_proxy.enabled` in
the Codex thread config so the generated permission profile can start Codex
managed networking. The default generated profile is `openclaw-network`; use
`profileName` to choose another local name.
```js
export default {
plugins: {
entries: {
codex: {
config: {
appServer: {
sandbox: "workspace-write",
networkProxy: {
enabled: true,
domains: {
"api.openai.com": "allow",
"blocked.example.com": "deny",
},
allowUpstreamProxy: true,
proxyUrl: "http://127.0.0.1:3128",
},
},
},
},
},
},
};
```
If the normal app-server runtime would be `danger-full-access`, enabling
`networkProxy` uses workspace-style filesystem access for the generated
permission profile. Codex managed network enforcement is sandboxed networking,
so a full-access profile would not protect outbound traffic.
The plugin blocks older or unversioned app-server handshakes. Codex app-server
must report stable version `0.125.0` or newer.

View File

@@ -505,9 +505,22 @@ Codex dynamic tools default to `searchable` loading. OpenClaw does not expose
dynamic tools that duplicate Codex-native workspace operations: `read`, `write`,
`edit`, `apply_patch`, `exec`, `process`, and `update_plan`. Most remaining
OpenClaw integration tools such as messaging, media, cron, browser, nodes,
gateway, `heartbeat_respond`, and `web_search` are available through Codex tool
search under the `openclaw` namespace, keeping the initial model context
smaller.
gateway, and `heartbeat_respond` are available through Codex tool search under
the `openclaw` namespace, keeping the initial model context smaller. Web search
uses Codex's hosted `web_search` tool by default when search is enabled and no
managed provider is selected. Native hosted search and OpenClaw's managed
`web_search` dynamic tool are mutually exclusive so managed search cannot bypass
native domain restrictions. OpenClaw uses the managed tool when hosted search is
unavailable, explicitly disabled, or replaced by a selected managed provider.
OpenClaw keeps Codex's standalone `web.run` extension disabled because
production app-server traffic rejects its user-defined `web` namespace.
`tools.web.search.enabled: false` disables both paths, as do tool-disabled
LLM-only runs. Codex treats `"cached"` as a preference and resolves it to live
external access for unrestricted app-server turns. Automatic managed fallback
fails closed when native `allowedDomains` are set so the allowlist cannot be
bypassed. Persistent effective search-policy changes rotate the bound Codex
thread before the next turn. Transient per-turn restrictions use a temporary
restricted thread and preserve the existing binding for later resume.
`sessions_yield` and message-tool-only source replies stay direct because
those are turn-control contracts. `sessions_spawn` stays searchable so Codex's
native `spawn_agent` remains the primary Codex subagent surface, while explicit
@@ -548,8 +561,45 @@ Supported `appServer` fields:
| `sandbox` | `"danger-full-access"` or an allowed guardian sandbox | Native Codex sandbox mode sent to thread start/resume. Guardian defaults prefer `"workspace-write"` when allowed, otherwise `"read-only"`. When an OpenClaw sandbox is active, `danger-full-access` turns use Codex `workspace-write` with network access derived from the OpenClaw sandbox egress setting. |
| `approvalsReviewer` | `"user"` or an allowed guardian reviewer | Use `"auto_review"` to let Codex review native approval prompts when allowed, otherwise `guardian_subagent` or `user`. `guardian_subagent` remains a legacy alias. |
| `serviceTier` | unset | Optional Codex app-server service tier. `"priority"` enables fast-mode routing, `"flex"` requests flex processing, `null` clears the override, and legacy `"fast"` is accepted as `"priority"`. |
| `networkProxy` | disabled | Opt into Codex permissions-profile networking for app-server commands. OpenClaw defines the selected `permissions.<profile>.network` config and selects that profile on thread start or resume instead of sending `sandbox`. |
| `experimental.sandboxExecServer` | `false` | Preview opt-in that registers an OpenClaw sandbox-backed Codex environment with Codex app-server 0.132.0 or newer so native Codex execution can run inside the active OpenClaw sandbox. |
`appServer.networkProxy` is explicit because it changes the Codex sandbox
contract. When enabled, OpenClaw also sets `features.network_proxy.enabled` in
the Codex thread config so the generated permission profile can start Codex
managed networking. The default generated profile is `openclaw-network`; use
`profileName` to choose another local name.
```js
export default {
plugins: {
entries: {
codex: {
config: {
appServer: {
sandbox: "workspace-write",
networkProxy: {
enabled: true,
domains: {
"api.openai.com": "allow",
"blocked.example.com": "deny",
},
allowUpstreamProxy: true,
proxyUrl: "http://127.0.0.1:3128",
},
},
},
},
},
},
};
```
If the normal app-server runtime would be `danger-full-access`, enabling
`networkProxy` uses workspace-style filesystem access for the generated
permission profile. Codex managed network enforcement is sandboxed networking,
so a full-access profile would not protect outbound traffic.
OpenClaw-owned dynamic tool calls are bounded independently from
`appServer.requestTimeoutMs`: Codex `item/tool/call` requests use a 90 second
OpenClaw watchdog by default. A positive per-call `timeoutMs` argument extends

View File

@@ -1278,6 +1278,7 @@ Important examples:
| `openclaw.compat.pluginApi` | Minimum OpenClaw plugin API range required by this package, using a semver floor like `>=2026.5.27`. |
| `openclaw.install.expectedIntegrity` | Expected npm dist integrity string such as `sha512-...`; install and update flows verify the fetched artifact against it. |
| `openclaw.install.allowInvalidConfigRecovery` | Allows a narrow bundled-plugin reinstall recovery path when config is invalid. |
| `openclaw.install.requiredPlatformPackages` | npm package aliases that must materialize when their lockfile platform constraints match the current host. |
| `openclaw.startup.deferConfiguredChannelFullLoadUntilAfterListen` | Lets setup-runtime channel surfaces load before listen, then defers the full configured channel plugin until post-listen activation. |
Manifest metadata decides which provider/channel/setup choices appear in
@@ -1290,6 +1291,13 @@ registry loading for non-bundled plugin sources. Invalid values are rejected;
newer-but-valid values skip external plugins on older hosts. Bundled source
plugins are assumed to be co-versioned with the host checkout.
`openclaw.install.requiredPlatformPackages` is for npm packages that expose
required native binaries through optional, platform-specific aliases. List the
bare npm package name for every supported platform alias. During npm install,
OpenClaw verifies only the declared alias whose lockfile constraints match the
current host. If npm reports success but omits that alias, OpenClaw retries once
with a fresh cache and rolls back the install if the alias is still missing.
`openclaw.compat.pluginApi` is enforced during package install for non-bundled
plugin sources. Use it for the OpenClaw plugin SDK/runtime API floor that the
package was built against. It can be stricter than `minHostVersion` when a

View File

@@ -163,6 +163,7 @@ Example:
| `minHostVersion` | `string` | Minimum supported OpenClaw version in the form `>=x.y.z` or `>=x.y.z-prerelease`. |
| `expectedIntegrity` | `string` | Expected npm dist integrity string, usually `sha512-...`, for pinned installs. |
| `allowInvalidConfigRecovery` | `boolean` | Lets bundled-plugin reinstall flows recover from specific stale-config failures. |
| `requiredPlatformPackages` | `string[]` | Required platform-specific npm aliases verified during npm install. |
<AccordionGroup>
<Accordion title="Onboarding behavior">

View File

@@ -250,7 +250,7 @@ usage endpoint failed or returned no usable usage data.
| `plugin-sdk/session-store-runtime` | Session workflow helpers (`getSessionEntry`, `listSessionEntries`, `patchSessionEntry`, `upsertSessionEntry`), legacy session store path/session-key helpers, updated-at reads, and deprecated whole-store mutation helpers |
| `plugin-sdk/cron-store-runtime` | Cron store path/load/save helpers |
| `plugin-sdk/state-paths` | State/OAuth dir path helpers |
| `plugin-sdk/plugin-state-runtime` | Plugin sidecar SQLite keyed-state types |
| `plugin-sdk/plugin-state-runtime` | Plugin sidecar SQLite keyed-state types plus centralized connection pragma and WAL maintenance setup for plugin-owned databases |
| `plugin-sdk/routing` | Route/session-key/account binding helpers such as `resolveAgentRoute`, `buildAgentSessionKey`, and `resolveDefaultAgentBoundAccountId` |
| `plugin-sdk/status-helpers` | Shared channel/account status summary helpers, runtime-state defaults, and issue metadata helpers |
| `plugin-sdk/target-resolver-runtime` | Shared target resolver helpers |

View File

@@ -60,6 +60,9 @@ local while `web_search` and `x_search` can use xAI Responses under the hood.
<Card title="Brave Search" icon="shield" href="/tools/brave-search">
Structured results with snippets. Supports `llm-context` mode, country/language filters. Free tier available.
</Card>
<Card title="Codex Hosted Search" icon="search" href="/plugins/codex-harness">
AI-synthesized grounded answers through your Codex app-server account.
</Card>
<Card title="DuckDuckGo" icon="bird" href="/tools/duckduckgo-search">
Key-free fallback. No API key needed. Unofficial HTML-based integration.
</Card>
@@ -106,6 +109,7 @@ local while `web_search` and `x_search` can use xAI Responses under the hood.
| Provider | Result style | Filters | API key |
| ------------------------------------------------ | -------------------------------------------------------------- | ------------------------------------------------ | --------------------------------------------------------------------------------------- |
| [Brave](/tools/brave-search) | Structured snippets | Country, language, time, `llm-context` mode | `BRAVE_API_KEY` |
| [Codex Hosted Search](/plugins/codex-harness) | AI-synthesized + source URLs | Domains, context size, user location | None; uses Codex/OpenAI sign-in |
| [DuckDuckGo](/tools/duckduckgo-search) | Structured snippets | -- | None (key-free) |
| [Exa](/tools/exa-search) | Structured + extracted | Neural/keyword mode, date, content extraction | `EXA_API_KEY` |
| [Firecrawl](/tools/firecrawl) | Structured snippets | Via `firecrawl_search` tool | `FIRECRAWL_API_KEY` |
@@ -128,20 +132,52 @@ Direct OpenAI Responses models use OpenAI's hosted `web_search` tool automatical
## Native Codex web search
Codex-capable models can optionally use the provider-native Responses `web_search` tool instead of OpenClaw's managed `web_search` function.
The Codex app-server runtime uses Codex's hosted `web_search` tool automatically
when web search is enabled and no managed provider is selected. Native hosted
search and OpenClaw's managed `web_search` dynamic tool are mutually exclusive,
so managed search cannot bypass native domain restrictions. OpenClaw uses the
managed tool when hosted search is unavailable, explicitly disabled, or
replaced by a selected managed provider. OpenClaw keeps Codex's standalone
`web.run` extension disabled because production app-server traffic rejects its
user-defined `web` namespace.
- Configure it under `tools.web.search.openaiCodex`
- It only activates for Codex-capable OpenAI models (`openai/*` models using `api: "openai-chatgpt-responses"`)
- Managed `web_search` still applies to non-Codex models
- `mode: "cached"` is the default and recommended setting
- Configure native search under `tools.web.search.openaiCodex`
- Set `tools.web.search.provider: "codex"` to provision Codex Hosted Search as
the managed `web_search` provider for any parent model. Each call runs a
bounded ephemeral Codex app-server turn and fails if Codex does not emit a
hosted `webSearch` item.
- `mode: "cached"` is the default preference, but Codex resolves it to live
external access for unrestricted app-server turns; set `"live"` to request
live access explicitly
- Set `tools.web.search.provider` to a managed provider such as `brave` to use
OpenClaw's managed `web_search` instead
- Set `tools.web.search.openaiCodex.enabled: false` to opt out of Codex-hosted
search; other managed providers remain available
- Restricting the Codex native tool surface also keeps managed `web_search`
available
- When `allowedDomains` is set, automatic managed fallback fails closed if
hosted search is unavailable so the native allowlist cannot be bypassed
- Tool-disabled LLM-only runs disable both native and managed search
- `tools.web.search.enabled: false` disables both managed and native search
Persistent effective Codex search-policy changes start a fresh bound thread so
an already loaded app-server thread cannot keep stale hosted-search access.
Transient per-turn restrictions use a temporary restricted thread and preserve
the existing binding for later resume.
Direct OpenAI ChatGPT Responses traffic can also use OpenAI's hosted
`web_search` tool. That separate path remains opt-in through
`tools.web.search.openaiCodex.enabled: true` and only applies to eligible
`openai/*` models using `api: "openai-chatgpt-responses"`.
```json5
{
tools: {
web: {
search: {
enabled: true,
// Optional: use Codex Hosted Search from non-Codex parent models too.
provider: "codex",
openaiCodex: {
enabled: true,
mode: "cached",
@@ -159,14 +195,25 @@ Codex-capable models can optionally use the provider-native Responses `web_searc
}
```
If native Codex search is enabled but the current model is not Codex-capable, OpenClaw keeps the normal managed `web_search` behavior.
For runtimes and providers that do not support native Codex search, Codex can
use the managed `web_search` fallback through OpenClaw's dynamic tool namespace.
Use an explicit managed provider when you need OpenClaw's provider-specific
network controls instead of Codex-hosted search.
Selecting `provider: "codex"` enables the bundled `codex` plugin and uses the
same `tools.web.search.openaiCodex` restrictions shown above. Authenticate the
Codex app-server first with `openclaw models auth login --provider openai`.
The parent agent can use any model or runtime; only the bounded search worker
runs through Codex.
## Network safety
Managed `web_search` provider calls use OpenClaw's guarded fetch path. For
Managed HTTP `web_search` provider calls use OpenClaw's guarded fetch path. For
trusted provider API hosts, OpenClaw allows Surge, Clash, and sing-box fake-IP
DNS answers in `198.18.0.0/15` and `fc00::/7` only for that provider hostname.
Other private, loopback, link-local, and metadata destinations remain blocked.
Codex Hosted Search is the exception: its bounded worker delegates network
access to Codex app-server's hosted `web_search` tool.
This automatic allowance does not apply to arbitrary `web_fetch` URLs. For
`web_fetch`, enable `tools.web.fetch.ssrfPolicy.allowRfc2544BenchmarkRange` and
@@ -200,6 +247,7 @@ Key-free fallbacks after that:
12. **DuckDuckGo** -- key-free HTML fallback with no account or API key (order 100)
13. **Ollama Web Search** -- key-free fallback via your configured local Ollama host when it is reachable and signed in with `ollama signin`; can reuse Ollama provider bearer auth when the host needs it, and can call direct `https://ollama.com` search when configured with `OLLAMA_API_KEY` (order 110)
14. **SearXNG** -- `SEARXNG_BASE_URL` or `plugins.entries.searxng.config.webSearch.baseUrl` (order 200)
15. **Codex Hosted Search** -- key-free provider contract that uses the active Codex/OpenAI sign-in (order 900)
When no API-backed provider is configured, OpenClaw defaults to **Parallel
Search (Free)**, so `web_search` works without an API key.

View File

@@ -32,12 +32,13 @@ describe("codex plugin", () => {
expect(manifest.enabledByDefault).toBeUndefined();
});
it("registers the codex provider and agent harness", () => {
it("registers the codex provider, agent harness, and hosted web search", () => {
const registerAgentHarness = vi.fn();
const registerCommand = vi.fn();
const registerMediaUnderstandingProvider = vi.fn();
const registerMigrationProvider = vi.fn();
const registerProvider = vi.fn();
const registerWebSearchProvider = vi.fn();
const on = vi.fn();
const onConversationBindingResolved = vi.fn();
@@ -54,6 +55,7 @@ describe("codex plugin", () => {
registerMediaUnderstandingProvider,
registerMigrationProvider,
registerProvider,
registerWebSearchProvider,
on,
onConversationBindingResolved,
}),
@@ -82,6 +84,13 @@ describe("codex plugin", () => {
expect(mediaProviderRegistration?.defaultModels).toEqual({ image: "gpt-5.5" });
expect(typeof mediaProviderRegistration?.describeImage).toBe("function");
expect(typeof mediaProviderRegistration?.describeImages).toBe("function");
const webSearchRegistration = mockCallArg(registerWebSearchProvider) as
| Record<string, unknown>
| undefined;
expect(webSearchRegistration?.id).toBe("codex");
expect(webSearchRegistration?.label).toBe("Codex Hosted Search");
expect(webSearchRegistration?.requiresCredential).toBe(false);
expect(typeof webSearchRegistration?.createTool).toBe("function");
const commandRegistration = mockCallArg(registerCommand) as Record<string, unknown> | undefined;
expect(commandRegistration?.name).toBe("codex");
expect(commandRegistration?.description).toBe(

View File

@@ -23,6 +23,7 @@ import {
resumeCodexCliSessionOnNode,
resolveCodexCliSessionForBindingOnNode,
} from "./src/node-cli-sessions.js";
import { createCodexWebSearchProvider } from "./src/web-search-provider.js";
export default definePluginEntry({
id: "codex",
@@ -46,6 +47,9 @@ export default definePluginEntry({
api.registerMediaUnderstandingProvider(
buildCodexMediaUnderstandingProvider({ pluginConfig: api.pluginConfig }),
);
api.registerWebSearchProvider(
createCodexWebSearchProvider({ resolvePluginConfig: resolveCurrentPluginConfig }),
);
api.registerMigrationProvider(buildCodexMigrationProvider({ runtime: api.runtime }));
for (const command of createCodexCliSessionNodeHostCommands()) {
api.registerNodeHostCommand(command);

View File

@@ -229,6 +229,7 @@ describe("codex media understanding provider", () => {
undefined,
"/tmp/openclaw-agent",
cfg,
{ timeoutMs: 30_000 },
);
expect(requests[1]?.params).toEqual({
model: "gpt-5.4",
@@ -240,8 +241,14 @@ describe("codex media understanding provider", () => {
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: {
"features.apps": false,
"features.code_mode": false,
"features.code_mode_only": false,
"features.image_generation": false,
"features.multi_agent": false,
"features.plugins": false,
"features.standalone_web_search": false,
web_search: "disabled",
},
environments: [],
dynamicTools: [],
@@ -279,11 +286,51 @@ describe("codex media understanding provider", () => {
agentDir: " ",
});
expect(clientFactory).toHaveBeenCalledWith(expect.any(Object), undefined, undefined, cfg);
expect(clientFactory).toHaveBeenCalledWith(expect.any(Object), undefined, undefined, cfg, {
timeoutMs: 30_000,
});
expect(requests[1]?.params).toEqual(expect.objectContaining({ cwd: process.cwd() }));
expect(requests[2]?.params).toEqual(expect.objectContaining({ cwd: process.cwd() }));
});
it("preserves configured WebSocket transport for media turns", async () => {
const { client, requests } = createFakeClient();
const clientFactory = vi.fn(async () => client);
const provider = buildCodexMediaUnderstandingProvider({
pluginConfig: {
appServer: {
transport: "websocket",
url: "ws://127.0.0.1:4501",
},
},
clientFactory,
});
await 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",
});
expect(clientFactory).toHaveBeenCalledWith(
expect.objectContaining({
transport: "websocket",
url: "ws://127.0.0.1:4501",
}),
undefined,
"/tmp/openclaw-agent",
{},
{ timeoutMs: 30_000 },
);
expect(requests[1]?.params).toEqual(expect.objectContaining({ cwd: "/tmp/openclaw-agent" }));
expect(requests[2]?.params).toEqual(expect.objectContaining({ cwd: "/tmp/openclaw-agent" }));
});
it("passes the scoped auth store into isolated app-server startup", async () => {
const { client } = createFakeClient();
sharedClientMocks.createIsolatedCodexAppServerClient.mockResolvedValue(client);
@@ -486,8 +533,14 @@ describe("codex media understanding provider", () => {
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: {
"features.apps": false,
"features.code_mode": false,
"features.code_mode_only": false,
"features.image_generation": false,
"features.multi_agent": false,
"features.plugins": false,
"features.standalone_web_search": false,
web_search: "disabled",
},
environments: [],
dynamicTools: [],

View File

@@ -13,41 +13,19 @@ import type {
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 { 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";
runBoundedCodexAppServerTurn,
type CodexBoundedTurnOptions,
} from "./src/app-server/bounded-turn.js";
import type { CodexUserInput } from "./src/app-server/protocol.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;
clientFactory?: CodexAppServerClientFactory;
};
export type CodexMediaUnderstandingProviderOptions = CodexBoundedTurnOptions;
/**
* Builds the media-understanding provider that delegates image tasks to an
@@ -97,13 +75,13 @@ async function describeCodexImages(
throw new Error("Codex image understanding requires model id.");
}
const text = await runBoundedCodexVisionTurn({
model,
const { text } = await runBoundedCodexAppServerTurn({
config: req.cfg,
model: { mode: "required", id: model },
profile: req.profile,
timeoutMs: req.timeoutMs,
agentDir: req.agentDir,
authStore: req.authStore,
cfg: req.cfg,
authProfileStore: req.authStore,
options,
taskLabel: "image understanding",
developerInstructions:
@@ -116,117 +94,11 @@ async function describeCodexImages(
})),
],
requiredModalities: ["text", "image"],
isolation: "configured-transport",
});
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,
@@ -246,73 +118,24 @@ async function extractCodexStructured(
throw new Error("Codex structured extraction requires at least one image input.");
}
const text = await runBoundedCodexVisionTurn({
model,
const { text } = await runBoundedCodexAppServerTurn({
config: req.cfg,
model: { mode: "required", id: model },
profile: req.profile,
timeoutMs: req.timeoutMs,
agentDir: req.agentDir,
authStore: req.authStore,
cfg: req.cfg,
authProfileStore: req.authStore,
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(),
isolation: "configured-transport",
});
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) {
@@ -391,159 +214,3 @@ function normalizeStructuredExtractionResult(params: {
}
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

@@ -5,7 +5,8 @@
"providers": ["codex"],
"contracts": {
"mediaUnderstandingProviders": ["codex"],
"migrationProviders": ["codex"]
"migrationProviders": ["codex"],
"webSearchProviders": ["codex"]
},
"mediaUnderstandingProviderMetadata": {
"codex": {
@@ -192,6 +193,47 @@
"enum": ["user", "auto_review", "guardian_subagent"]
},
"serviceTier": { "type": ["string", "null"] },
"networkProxy": {
"type": "object",
"additionalProperties": false,
"properties": {
"enabled": {
"type": "boolean",
"default": false
},
"profileName": { "type": "string" },
"baseProfile": {
"type": "string",
"enum": ["read-only", "workspace"]
},
"mode": {
"type": "string",
"enum": ["limited", "full"]
},
"domains": {
"type": "object",
"additionalProperties": {
"type": "string",
"enum": ["allow", "deny"]
}
},
"unixSockets": {
"type": "object",
"additionalProperties": {
"type": "string",
"enum": ["allow", "deny"]
}
},
"proxyUrl": { "type": "string" },
"socksUrl": { "type": "string" },
"enableSocks5": { "type": "boolean" },
"enableSocks5Udp": { "type": "boolean" },
"allowUpstreamProxy": { "type": "boolean" },
"allowLocalBinding": { "type": "boolean" },
"dangerouslyAllowNonLoopbackProxy": { "type": "boolean" },
"dangerouslyAllowAllUnixSockets": { "type": "boolean" }
}
},
"defaultWorkspaceDir": {
"type": "string"
},
@@ -384,6 +426,81 @@
"help": "Optional Codex app-server service tier. Use priority, flex, or null. Legacy fast is accepted as priority.",
"advanced": true
},
"appServer.networkProxy": {
"label": "Network Proxy",
"help": "Enable Codex permissions-profile networking for app-server commands.",
"advanced": true
},
"appServer.networkProxy.enabled": {
"label": "Network Proxy Enabled",
"help": "When enabled, OpenClaw defines a Codex permissions profile and selects it on thread start or resume instead of sandbox fields.",
"advanced": true
},
"appServer.networkProxy.profileName": {
"label": "Network Proxy Profile",
"help": "Codex permissions profile name generated for app-server network access.",
"advanced": true
},
"appServer.networkProxy.baseProfile": {
"label": "Network Proxy Base",
"help": "Filesystem access used by the generated profile. Defaults to read-only for read-only sandboxes and workspace otherwise.",
"advanced": true
},
"appServer.networkProxy.domains": {
"label": "Network Domains",
"help": "Domain allow and deny rules for Codex sandboxed networking.",
"advanced": true
},
"appServer.networkProxy.unixSockets": {
"label": "Unix Sockets",
"help": "Unix socket allow and deny rules for Codex sandboxed networking.",
"advanced": true
},
"appServer.networkProxy.proxyUrl": {
"label": "HTTP Proxy URL",
"help": "HTTP listener URL used by Codex sandboxed networking.",
"advanced": true
},
"appServer.networkProxy.socksUrl": {
"label": "SOCKS Proxy URL",
"help": "SOCKS listener URL used by Codex sandboxed networking.",
"advanced": true
},
"appServer.networkProxy.enableSocks5": {
"label": "Enable SOCKS5",
"help": "Expose SOCKS5 support for the generated Codex permissions profile.",
"advanced": true
},
"appServer.networkProxy.enableSocks5Udp": {
"label": "Enable SOCKS5 UDP",
"help": "Allow UDP over the SOCKS5 listener when SOCKS5 is enabled.",
"advanced": true
},
"appServer.networkProxy.allowUpstreamProxy": {
"label": "Allow Upstream Proxy",
"help": "Allow Codex sandboxed networking to chain through inherited HTTP(S)_PROXY or ALL_PROXY settings.",
"advanced": true
},
"appServer.networkProxy.allowLocalBinding": {
"label": "Allow Local Binding",
"help": "Permit broader local and private-network access through Codex sandboxed networking.",
"advanced": true
},
"appServer.networkProxy.mode": {
"label": "Network Mode",
"help": "Codex sandboxed networking mode for subprocess traffic.",
"advanced": true
},
"appServer.networkProxy.dangerouslyAllowNonLoopbackProxy": {
"label": "Allow Non-Loopback Proxy",
"help": "Permit non-loopback bind addresses for Codex sandboxed networking listeners.",
"advanced": true
},
"appServer.networkProxy.dangerouslyAllowAllUnixSockets": {
"label": "Allow All Unix Sockets",
"help": "Bypass Codex's Unix socket allowlist for tightly controlled environments.",
"advanced": true
},
"appServer.defaultWorkspaceDir": {
"label": "Default Workspace",
"help": "Workspace used by /codex bind when --cwd is omitted.",

View File

@@ -23,7 +23,15 @@
"install": {
"npmSpec": "@openclaw/codex",
"defaultChoice": "npm",
"minHostVersion": ">=2026.5.1-beta.1"
"minHostVersion": ">=2026.5.1-beta.1",
"requiredPlatformPackages": [
"@openai/codex-linux-x64",
"@openai/codex-linux-arm64",
"@openai/codex-darwin-x64",
"@openai/codex-darwin-arm64",
"@openai/codex-win32-x64",
"@openai/codex-win32-arm64"
]
},
"compat": {
"pluginApi": ">=2026.6.2"

View File

@@ -116,10 +116,12 @@ function startThreadWithHarness(
effectiveWorkspace: paths.workspaceDir,
effectiveCwd: paths.cwd,
dynamicTools: [],
webSearchAllowed: false,
developerInstructions: undefined,
finalConfigPatch: undefined,
bundleMcpThreadConfig,
nativeToolSurfaceEnabled: true,
nativeProviderWebSearchSupport: "supported",
sandboxExecServerEnabled: false,
sandbox: null,
contextEngineProjection: undefined,

View File

@@ -59,6 +59,7 @@ import {
type CodexAppServerThreadLifecycleBinding,
type CodexContextEngineThreadBootstrapProjection,
} from "./thread-lifecycle.js";
import type { CodexNativeWebSearchSupport } from "./web-search.js";
const CODEX_APP_SERVER_STARTUP_CONNECTION_CLOSE_MAX_ATTEMPTS = 3;
@@ -96,12 +97,15 @@ export async function startCodexAttemptThread(params: {
effectiveWorkspace: string;
effectiveCwd: string;
dynamicTools: CodexDynamicToolSpec[];
persistentWebSearchAllowed?: boolean;
webSearchAllowed: boolean;
developerInstructions: string | undefined;
finalConfigPatch?: Parameters<typeof startOrResumeThread>[0]["finalConfigPatch"];
buildFinalConfigPatch?: Parameters<typeof startOrResumeThread>[0]["buildFinalConfigPatch"];
nativeHookRelayGeneration?: string;
bundleMcpThreadConfig: CodexBundleMcpThreadConfig;
nativeToolSurfaceEnabled: boolean;
nativeProviderWebSearchSupport: CodexNativeWebSearchSupport;
sandboxExecServerEnabled: boolean;
sandbox: CodexSandboxContext;
contextEngineProjection: CodexContextEngineThreadBootstrapProjection | undefined;
@@ -300,6 +304,8 @@ export async function startCodexAttemptThread(params: {
agentId: params.sessionAgentId,
cwd: startupExecutionCwd,
dynamicTools: params.dynamicTools,
persistentWebSearchAllowed: params.persistentWebSearchAllowed,
webSearchAllowed: params.webSearchAllowed,
appServer: pluginAppServer,
developerInstructions: params.developerInstructions,
config: threadConfig,
@@ -307,6 +313,7 @@ export async function startCodexAttemptThread(params: {
buildFinalConfigPatch: params.buildFinalConfigPatch,
nativeHookRelayGeneration: params.nativeHookRelayGeneration,
nativeCodeModeEnabled: params.nativeToolSurfaceEnabled,
nativeProviderWebSearchSupport: params.nativeProviderWebSearchSupport,
nativeCodeModeOnlyEnabled: params.appServer.codeModeOnly,
userMcpServersEnabled: params.nativeToolSurfaceEnabled,
mcpServersFingerprint: params.bundleMcpThreadConfig.fingerprint,

View File

@@ -0,0 +1,505 @@
import fs from "node:fs/promises";
import path from "node:path";
import type { AuthProfileStore } from "openclaw/plugin-sdk/agent-runtime";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { resolveTimerTimeoutMs } from "openclaw/plugin-sdk/number-runtime";
import { resolvePreferredOpenClawTmpDir, withTempWorkspace } from "openclaw/plugin-sdk/temp-path";
import { readCodexNotificationItem } from "./attempt-notifications.js";
import type { CodexAppServerClientFactory } from "./client-factory.js";
import type { CodexAppServerClient } from "./client.js";
import { resolveCodexAppServerRuntimeOptions } from "./config.js";
import { readModelListResult } from "./models.js";
import { mergeCodexThreadConfigs } from "./plugin-thread-config.js";
import {
assertCodexThreadStartResponse,
assertCodexTurnStartResponse,
readCodexErrorNotification,
readCodexTurnCompletedNotification,
} from "./protocol-validators.js";
import {
isJsonObject,
type CodexServerNotification,
type CodexThreadItem,
type CodexThreadStartParams,
type CodexTurn,
type CodexTurnStartParams,
type CodexUserInput,
type JsonObject,
type JsonValue,
} from "./protocol.js";
import { buildCodexRuntimeThreadConfig } from "./thread-lifecycle.js";
const CODEX_PRIVATE_STDIO_ARGS = ["app-server", "--listen", "stdio://"];
const OPENCLAW_CODEX_APP_SERVER_ARGS_ENV_VAR = "OPENCLAW_CODEX_APP_SERVER_ARGS";
const CODEX_BOUNDED_THREAD_CONFIG: JsonObject = {
"features.multi_agent": false,
"features.apps": false,
"features.plugins": false,
"features.image_generation": false,
"features.standalone_web_search": false,
web_search: "disabled",
};
const CODEX_PRIVATE_BOUNDED_THREAD_CONFIG: JsonObject = {
"features.hooks": false,
notify: [],
};
export type CodexBoundedTurnOptions = {
pluginConfig?: unknown;
clientFactory?: CodexAppServerClientFactory;
};
export type CodexBoundedTurnResult = {
text: string;
items: CodexThreadItem[];
model: string;
};
type CodexBoundedTurnModelSelection = { mode: "required"; id: string } | { mode: "live-default" };
type CodexBoundedTurnParams = {
config?: OpenClawConfig;
model: CodexBoundedTurnModelSelection;
profile?: string;
timeoutMs: number;
signal?: AbortSignal;
agentDir?: string;
authProfileStore?: AuthProfileStore;
options: CodexBoundedTurnOptions;
taskLabel: string;
developerInstructions: string;
input: CodexUserInput[];
requiredModalities: string[];
isolation: "configured-transport" | "private-stdio";
threadConfig?: JsonObject;
};
export async function runBoundedCodexAppServerTurn(
params: CodexBoundedTurnParams,
): Promise<CodexBoundedTurnResult> {
const appServer = resolveCodexAppServerRuntimeOptions({
pluginConfig: params.options.pluginConfig,
});
if (params.isolation === "configured-transport") {
return await runBoundedCodexAppServerTurnInWorkspace(params, appServer, {
cwd: params.agentDir?.trim() || process.cwd(),
});
}
if (appServer.start.transport !== "stdio") {
throw new Error("Bounded Codex turns require stdio transport so native tools can be isolated.");
}
return await withTempWorkspace(
{
rootDir: resolvePreferredOpenClawTmpDir(),
prefix: "codex-bounded-turn-",
},
async (workspace) => {
const codexHome = path.join(workspace.dir, "codex-home");
const cwd = path.join(workspace.dir, "workspace");
await Promise.all([
fs.mkdir(codexHome, { recursive: true }),
fs.mkdir(cwd, { recursive: true }),
]);
return await runBoundedCodexAppServerTurnInWorkspace(params, appServer, { codexHome, cwd });
},
);
}
async function runBoundedCodexAppServerTurnInWorkspace(
params: CodexBoundedTurnParams,
appServer: ReturnType<typeof resolveCodexAppServerRuntimeOptions>,
workspace: { codexHome?: string; cwd: string },
): Promise<CodexBoundedTurnResult> {
const timeoutMs = resolveTimerTimeoutMs(params.timeoutMs, 100, 100);
const agentDir = params.agentDir?.trim() || undefined;
// Hosted search needs a private Codex home and cwd so inherited native tools
// cannot escape the bounded turn. Media calls retain configured transport
// compatibility while still using an isolated ephemeral thread.
const startOptions = workspace.codexHome
? buildPrivateCodexAppServerStartOptions(appServer.start, workspace.codexHome)
: appServer.start;
const ownsClient = !params.options.clientFactory;
const client = params.options.clientFactory
? await params.options.clientFactory(startOptions, params.profile, agentDir, params.config, {
timeoutMs,
})
: await import("./shared-client.js").then(({ createIsolatedCodexAppServerClient }) =>
createIsolatedCodexAppServerClient({
startOptions,
timeoutMs,
authProfileId: params.profile,
agentDir,
authProfileStore: params.authProfileStore,
config: params.config,
}),
);
const abortController = new AbortController();
const abortFromCaller = () => abortController.abort(params.signal?.reason ?? "aborted");
if (params.signal?.aborted) {
abortFromCaller();
} else {
params.signal?.addEventListener("abort", abortFromCaller, { once: true });
}
const timeout = setTimeout(() => abortController.abort("timeout"), timeoutMs);
timeout.unref?.();
try {
const model = await resolveCodexBoundedTurnModel({
client,
selection: params.model,
requiredModalities: params.requiredModalities,
timeoutMs,
signal: abortController.signal,
});
const thread = assertCodexThreadStartResponse(
await client.request<unknown>(
"thread/start",
{
model,
modelProvider: "openai",
cwd: workspace.cwd,
approvalPolicy: "on-request",
sandbox: "read-only",
serviceName: "OpenClaw",
developerInstructions: params.developerInstructions,
config: buildCodexRuntimeThreadConfig(resolveBoundedThreadConfig(params, workspace), {
nativeCodeModeEnabled: false,
}),
environments: [],
dynamicTools: [],
experimentalRawEvents: true,
persistExtendedHistory: false,
ephemeral: true,
} satisfies CodexThreadStartParams,
{ timeoutMs, signal: abortController.signal },
),
);
const collector = createCodexBoundedTurnCollector(thread.thread.id, params.taskLabel);
const cleanup = client.addNotificationHandler(collector.handleNotification);
const requestCleanup = client.addRequestHandler(
createCodexBoundedApprovalHandler(params.taskLabel),
);
try {
const turn = assertCodexTurnStartResponse(
await client.request<unknown>(
"turn/start",
{
threadId: thread.thread.id,
input: params.input,
cwd: workspace.cwd,
approvalPolicy: "on-request",
model,
effort: "low",
} satisfies CodexTurnStartParams,
{ timeoutMs, signal: abortController.signal },
),
);
return {
...(await collector.collect(turn.turn, {
timeoutMs,
signal: abortController.signal,
})),
model,
};
} finally {
requestCleanup();
cleanup();
}
} finally {
clearTimeout(timeout);
params.signal?.removeEventListener("abort", abortFromCaller);
if (ownsClient) {
client.close();
}
}
}
function resolveBoundedThreadConfig(
params: CodexBoundedTurnParams,
workspace: { codexHome?: string },
): JsonObject {
const boundedConfig = mergeCodexThreadConfigs(
CODEX_BOUNDED_THREAD_CONFIG,
params.threadConfig,
) ?? { ...CODEX_BOUNDED_THREAD_CONFIG };
if (!workspace.codexHome) {
return boundedConfig;
}
return mergeCodexThreadConfigs(boundedConfig, CODEX_PRIVATE_BOUNDED_THREAD_CONFIG) ?? {
...boundedConfig,
...CODEX_PRIVATE_BOUNDED_THREAD_CONFIG,
};
}
function buildPrivateCodexAppServerStartOptions(
start: ReturnType<typeof resolveCodexAppServerRuntimeOptions>["start"],
codexHome: string,
): ReturnType<typeof resolveCodexAppServerRuntimeOptions>["start"] {
const privateEnv = Object.fromEntries(
Object.entries(start.env ?? {}).filter(
([name]) => name.trim().toUpperCase() !== OPENCLAW_CODEX_APP_SERVER_ARGS_ENV_VAR,
),
);
const clearEnv = (start.clearEnv ?? []).filter((name) => {
const normalized = name.trim().toUpperCase();
return normalized !== "CODEX_HOME" && normalized !== OPENCLAW_CODEX_APP_SERVER_ARGS_ENV_VAR;
});
return {
...start,
args: [...CODEX_PRIVATE_STDIO_ARGS],
env: {
...privateEnv,
CODEX_HOME: codexHome,
},
clearEnv: [...clearEnv, OPENCLAW_CODEX_APP_SERVER_ARGS_ENV_VAR],
};
}
function createCodexBoundedApprovalHandler(taskLabel: string) {
return (request: { method: string }): JsonValue | undefined => {
if (
request.method === "item/commandExecution/requestApproval" ||
request.method === "item/fileChange/requestApproval"
) {
return {
decision: "decline",
reason: `OpenClaw Codex ${taskLabel} 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 ${taskLabel} does not grant native approvals.`,
};
}
if (request.method === "mcpServer/elicitation/request") {
return { action: "decline" };
}
return undefined;
};
}
async function resolveCodexBoundedTurnModel(params: {
client: CodexAppServerClient;
selection: CodexBoundedTurnModelSelection;
requiredModalities: string[];
timeoutMs: number;
signal: AbortSignal;
}): Promise<string> {
const result = await params.client.request<unknown>(
"model/list",
{ limit: null, cursor: null, includeHidden: false },
{ timeoutMs: Math.min(params.timeoutMs, 5_000), signal: params.signal },
);
const listed = readModelListResult(result).models;
if (params.selection.mode === "live-default") {
const supported = listed.filter((entry) =>
params.requiredModalities.every((modality) => entry.inputModalities.includes(modality)),
);
const selected = supported.find((entry) => entry.isDefault) ?? supported[0];
if (!selected) {
throw new Error(
`Codex app-server has no model supporting ${params.requiredModalities.join(" and ")} input.`,
);
}
return selected.model;
}
const model = params.selection.id;
const match = listed.find((entry) => entry.model === model || entry.id === model);
if (!match) {
throw new Error(`Codex app-server model not found: ${model}`);
}
if (params.requiredModalities.includes("image") && !match.inputModalities.includes("image")) {
throw new Error(`Codex app-server model does not support images: ${model}`);
}
if (params.requiredModalities.includes("text") && !match.inputModalities.includes("text")) {
throw new Error(`Codex app-server model does not support text: ${model}`);
}
return model;
}
function createCodexBoundedTurnCollector(threadId: string, taskLabel: string) {
let turnId: string | undefined;
let completedTurn: CodexTurn | undefined;
let promptError: string | undefined;
const pending: CodexServerNotification[] = [];
const completedItems = new Map<string, CodexThreadItem>();
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/completed") {
const item = readCodexNotificationItem(notification.params);
if (item) {
completedItems.set(item.id, item);
if (item.type === "agentMessage" && typeof item.text === "string") {
rememberAssistantText(item.id, item.text);
}
}
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<Omit<CodexBoundedTurnResult, "model">> {
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 items = collectCompletedItems(completedTurn?.items, completedItems);
const itemText = collectAssistantTextFromItems(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, items };
},
};
}
function collectCompletedItems(
turnItems: CodexThreadItem[] | undefined,
notificationItems: Map<string, CodexThreadItem>,
): CodexThreadItem[] {
const items = new Map(notificationItems);
for (const item of turnItems ?? []) {
items.set(item.id, item);
}
return [...items.values()];
}
async function waitForTurnCompletion(params: {
completion: Promise<void>;
timeoutMs: number;
signal: AbortSignal;
taskLabel: string;
}): Promise<void> {
if (params.signal.aborted) {
throw new Error(`codex app-server ${params.taskLabel} turn aborted`);
}
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

@@ -18,6 +18,7 @@ export type CodexAppServerClientFactory = (
options?: {
onStartedClient?: (client: CodexAppServerClient) => void;
abandonSignal?: AbortSignal;
timeoutMs?: number;
},
) => Promise<CodexAppServerClient>;
@@ -44,6 +45,7 @@ export const defaultCodexAppServerClientFactory: CodexAppServerClientFactory = (
config,
onStartedClient: options?.onStartedClient,
abandonSignal: options?.abandonSignal,
timeoutMs: options?.timeoutMs,
}),
);
@@ -63,5 +65,6 @@ export const defaultLeasedCodexAppServerClientFactory: CodexAppServerClientFacto
config,
onStartedClient: options?.onStartedClient,
abandonSignal: options?.abandonSignal,
timeoutMs: options?.timeoutMs,
}),
);

View File

@@ -125,6 +125,89 @@ describe("Codex app-server config", () => {
});
});
it("builds Codex permissions-profile config for app-server network proxy", () => {
const runtime = resolveRuntimeForTest({
pluginConfig: {
appServer: {
sandbox: "workspace-write",
networkProxy: {
enabled: true,
profileName: "mock-proxy",
mode: "limited",
domains: {
" api.openai.com ": "allow",
"blocked.example.com": "deny",
},
unixSockets: {
" /tmp/mock-proxy.sock ": "allow",
},
proxyUrl: "http://127.0.0.1:3128",
socksUrl: "socks5h://127.0.0.1:8081",
enableSocks5: true,
enableSocks5Udp: false,
allowUpstreamProxy: true,
allowLocalBinding: false,
},
},
},
});
expect(runtime.networkProxy).toEqual({
profileName: "mock-proxy",
configPatch: {
"features.network_proxy.enabled": true,
permissions: {
"mock-proxy": {
filesystem: {
":minimal": "read",
":workspace_roots": {
".": "write",
},
},
network: {
enabled: true,
mode: "limited",
domains: {
"api.openai.com": "allow",
"blocked.example.com": "deny",
},
unix_sockets: {
"/tmp/mock-proxy.sock": "allow",
},
proxy_url: "http://127.0.0.1:3128",
socks_url: "socks5h://127.0.0.1:8081",
enable_socks5: true,
enable_socks5_udp: false,
allow_upstream_proxy: true,
allow_local_binding: false,
},
},
},
},
});
});
it("uses read-only filesystem rules for read-only network proxy profiles", () => {
const runtime = resolveRuntimeForTest({
pluginConfig: {
appServer: {
sandbox: "read-only",
networkProxy: {
enabled: true,
domains: { "example.com": "allow" },
},
},
},
});
const permissions = runtime.networkProxy?.configPatch.permissions as Record<
string,
{ filesystem: { ":workspace_roots": { ".": string } } }
>;
expect(runtime.networkProxy?.profileName).toBe("openclaw-network");
expect(permissions["openclaw-network"]?.filesystem[":workspace_roots"]["."]).toBe("read");
});
it("clamps oversized app-server timer config", () => {
const runtime = resolveRuntimeForTest({
pluginConfig: {

View File

@@ -16,7 +16,7 @@ import { normalizeAgentId } from "openclaw/plugin-sdk/routing";
import { normalizeTrimmedStringList } from "openclaw/plugin-sdk/string-coerce-runtime";
import { detectWindowsSpawnCommandInlineArgs } from "openclaw/plugin-sdk/windows-spawn";
import { z } from "zod";
import type { CodexSandboxPolicy, CodexServiceTier } from "./protocol.js";
import type { CodexSandboxPolicy, CodexServiceTier, JsonObject, JsonValue } from "./protocol.js";
const START_OPTIONS_KEY_SECRET_SYMBOL = Symbol.for("openclaw.codexAppServerStartOptionsKeySecret");
const START_OPTIONS_KEY_SECRET = getStartOptionsKeySecret();
@@ -111,6 +111,32 @@ export type CodexAppServerExperimentalConfig = {
sandboxExecServer?: boolean;
};
export type CodexAppServerNetworkProxyPermission = "allow" | "deny";
export type CodexAppServerNetworkProxyBaseProfile = "read-only" | "workspace";
export type CodexAppServerNetworkProxyMode = "limited" | "full";
export type CodexAppServerNetworkProxyConfig = {
enabled?: boolean;
profileName?: string;
baseProfile?: CodexAppServerNetworkProxyBaseProfile;
mode?: CodexAppServerNetworkProxyMode;
domains?: Record<string, CodexAppServerNetworkProxyPermission>;
unixSockets?: Record<string, CodexAppServerNetworkProxyPermission>;
proxyUrl?: string;
socksUrl?: string;
enableSocks5?: boolean;
enableSocks5Udp?: boolean;
allowUpstreamProxy?: boolean;
allowLocalBinding?: boolean;
dangerouslyAllowNonLoopbackProxy?: boolean;
dangerouslyAllowAllUnixSockets?: boolean;
};
export type ResolvedCodexAppServerNetworkProxyConfig = {
profileName: string;
configPatch: JsonObject;
};
export type ResolvedCodexPluginPolicy = {
configKey: string;
marketplaceName: typeof CODEX_PLUGINS_MARKETPLACE_NAME;
@@ -151,6 +177,7 @@ export type CodexAppServerRuntimeOptions = {
sandbox: CodexAppServerSandboxMode;
approvalsReviewer: CodexAppServerApprovalsReviewer;
serviceTier?: CodexServiceTier;
networkProxy?: ResolvedCodexAppServerNetworkProxyConfig;
};
export type CodexModelBackedReviewerContext = {
@@ -188,6 +215,7 @@ export type CodexPluginConfig = {
sandbox?: CodexAppServerSandboxMode;
approvalsReviewer?: CodexAppServerApprovalsReviewer;
serviceTier?: CodexServiceTier | null;
networkProxy?: CodexAppServerNetworkProxyConfig;
defaultWorkspaceDir?: string;
experimental?: CodexAppServerExperimentalConfig;
};
@@ -216,6 +244,7 @@ export const CODEX_APP_SERVER_CONFIG_KEYS = [
"sandbox",
"approvalsReviewer",
"serviceTier",
"networkProxy",
"defaultWorkspaceDir",
"experimental",
] as const;
@@ -249,6 +278,7 @@ export const CODEX_PLUGIN_ENTRY_CONFIG_KEYS = [
const DEFAULT_CODEX_COMPUTER_USE_PLUGIN_NAME = "computer-use";
const DEFAULT_CODEX_COMPUTER_USE_MCP_SERVER_NAME = "computer-use";
const DEFAULT_CODEX_COMPUTER_USE_MARKETPLACE_DISCOVERY_TIMEOUT_MS = 60_000;
const DEFAULT_CODEX_APP_SERVER_NETWORK_PROXY_PROFILE = "openclaw-network";
const codexAppServerTransportSchema = z.enum(["stdio", "websocket"]);
const codexAppServerPolicyModeSchema = z.enum(["yolo", "guardian"]);
@@ -273,6 +303,25 @@ const codexAppServerExperimentalSchema = z
sandboxExecServer: z.boolean().optional(),
})
.strict();
const codexAppServerNetworkProxyPermissionSchema = z.enum(["allow", "deny"]);
const codexAppServerNetworkProxySchema = z
.object({
enabled: z.boolean().optional(),
profileName: z.string().trim().min(1).optional(),
baseProfile: z.enum(["read-only", "workspace"]).optional(),
mode: z.enum(["limited", "full"]).optional(),
domains: z.record(z.string(), codexAppServerNetworkProxyPermissionSchema).optional(),
unixSockets: z.record(z.string(), codexAppServerNetworkProxyPermissionSchema).optional(),
proxyUrl: z.string().trim().min(1).optional(),
socksUrl: z.string().trim().min(1).optional(),
enableSocks5: z.boolean().optional(),
enableSocks5Udp: z.boolean().optional(),
allowUpstreamProxy: z.boolean().optional(),
allowLocalBinding: z.boolean().optional(),
dangerouslyAllowNonLoopbackProxy: z.boolean().optional(),
dangerouslyAllowAllUnixSockets: z.boolean().optional(),
})
.strict();
const codexPluginEntryConfigSchema = z
.object({
@@ -334,6 +383,7 @@ const codexPluginConfigSchema = z
sandbox: codexAppServerSandboxSchema.optional(),
approvalsReviewer: codexAppServerApprovalsReviewerSchema.optional(),
serviceTier: codexAppServerServiceTierSchema,
networkProxy: codexAppServerNetworkProxySchema.optional(),
defaultWorkspaceDir: z.string().optional(),
experimental: codexAppServerExperimentalSchema.optional(),
})
@@ -549,6 +599,11 @@ export function resolveCodexAppServerRuntimeOptions(
? normalizedPolicyMode
: (explicitPolicyMode ?? normalizedPolicyMode ?? defaultPolicy?.mode ?? "yolo");
const serviceTier = normalizeCodexServiceTier(config.serviceTier);
const resolvedSandbox =
forcedPolicy?.sandbox ??
configuredSandbox ??
defaultPolicy?.sandbox ??
(policyMode === "guardian" ? "workspace-write" : "danger-full-access");
if (transport === "websocket" && !url) {
throw new Error(
"plugins.entries.codex.config.appServer.url is required when appServer.transport is websocket",
@@ -597,17 +652,14 @@ export function resolveCodexAppServerRuntimeOptions(
: {}),
approvalPolicy: forcedPolicy?.approvalPolicy ?? approvalPolicy,
approvalPolicySource,
sandbox:
forcedPolicy?.sandbox ??
configuredSandbox ??
defaultPolicy?.sandbox ??
(policyMode === "guardian" ? "workspace-write" : "danger-full-access"),
sandbox: resolvedSandbox,
approvalsReviewer:
forcedPolicy?.approvalsReviewer ??
explicitApprovalsReviewer ??
defaultPolicy?.approvalsReviewer ??
(policyMode === "guardian" ? "auto_review" : "user"),
...(serviceTier ? { serviceTier } : {}),
...resolveCodexAppServerNetworkProxy(config.networkProxy, resolvedSandbox),
};
}
@@ -821,6 +873,69 @@ export function codexSandboxPolicyForTurn(
};
}
function resolveCodexAppServerNetworkProxy(
config: CodexAppServerNetworkProxyConfig | undefined,
sandbox: CodexAppServerSandboxMode,
): { networkProxy?: ResolvedCodexAppServerNetworkProxyConfig } {
if (config?.enabled !== true) {
return {};
}
const profileName =
readNonEmptyString(config.profileName) ?? DEFAULT_CODEX_APP_SERVER_NETWORK_PROXY_PROFILE;
const fileSystemMode =
config.baseProfile === "read-only" || (!config.baseProfile && sandbox === "read-only")
? "read"
: "write";
const networkConfig = removeUndefinedJsonFields({
enabled: true,
mode: config.mode,
domains: normalizeNetworkProxyPermissionMap(config.domains),
unix_sockets: normalizeNetworkProxyPermissionMap(config.unixSockets),
proxy_url: readNonEmptyString(config.proxyUrl),
socks_url: readNonEmptyString(config.socksUrl),
enable_socks5: config.enableSocks5,
enable_socks5_udp: config.enableSocks5Udp,
allow_upstream_proxy: config.allowUpstreamProxy,
allow_local_binding: config.allowLocalBinding,
dangerously_allow_non_loopback_proxy: config.dangerouslyAllowNonLoopbackProxy,
dangerously_allow_all_unix_sockets: config.dangerouslyAllowAllUnixSockets,
});
return {
networkProxy: {
profileName,
configPatch: {
"features.network_proxy.enabled": true,
permissions: {
[profileName]: {
filesystem: {
":minimal": "read",
":workspace_roots": {
".": fileSystemMode,
},
},
network: networkConfig,
},
},
},
},
};
}
function normalizeNetworkProxyPermissionMap(
value: Record<string, CodexAppServerNetworkProxyPermission> | undefined,
): Record<string, CodexAppServerNetworkProxyPermission> | undefined {
const entries = Object.entries(value ?? {})
.map(([key, permission]) => [key.trim(), permission] as const)
.filter(([key]) => key.length > 0);
return entries.length > 0 ? Object.fromEntries(entries) : undefined;
}
function removeUndefinedJsonFields(value: Record<string, JsonValue | undefined>): JsonObject {
return Object.fromEntries(
Object.entries(value).filter((entry): entry is [string, JsonValue] => entry[1] !== undefined),
);
}
export function withMcpElicitationsApprovalPolicy(
policy: CodexAppServerEffectiveApprovalPolicy,
): CodexAppServerEffectiveApprovalPolicy {

View File

@@ -171,6 +171,199 @@ describe("Codex app-server dynamic tool build", () => {
]);
});
it("removes managed web_search when domain-restricted Codex hosted search is active", async () => {
const workspaceDir = path.join(tempDir, "workspace");
const params = createParams(path.join(tempDir, "session.jsonl"), workspaceDir);
params.disableTools = false;
params.runtimePlan = createCodexRuntimePlanFixture();
params.config = {
tools: {
web: {
search: { openaiCodex: { allowedDomains: ["example.com"] } },
},
},
} as never;
setOpenClawCodingToolsFactoryForTests(() => [
createRuntimeDynamicTool("web_search"),
createRuntimeDynamicTool("message"),
]);
let webSearchAllowed = false;
const tools = await buildDynamicToolsForTest(params, workspaceDir, {
onWebSearchPolicyResolved: (allowed) => {
webSearchAllowed = allowed;
},
});
expect(tools.map((tool) => tool.name)).toEqual(["message"]);
expect(webSearchAllowed).toBe(true);
});
it("reports hosted search denied when effective tool policy removes web_search", async () => {
const workspaceDir = path.join(tempDir, "workspace");
const params = createParams(path.join(tempDir, "session.jsonl"), workspaceDir);
params.disableTools = false;
params.runtimePlan = createCodexRuntimePlanFixture();
setOpenClawCodingToolsFactoryForTests(() => [createRuntimeDynamicTool("message")]);
let webSearchAllowed = true;
const tools = await buildDynamicToolsForTest(params, workspaceDir, {
onWebSearchPolicyResolved: (allowed) => {
webSearchAllowed = allowed;
},
});
expect(tools.map((tool) => tool.name)).toEqual(["message"]);
expect(webSearchAllowed).toBe(false);
});
it("separates persistent search policy from a runtime toolsAllow restriction", async () => {
const workspaceDir = path.join(tempDir, "workspace");
const params = createParams(path.join(tempDir, "session.jsonl"), workspaceDir);
params.disableTools = false;
params.runtimePlan = createCodexRuntimePlanFixture();
params.toolsAllow = ["message"];
setOpenClawCodingToolsFactoryForTests(() => [
createRuntimeDynamicTool("web_search"),
createRuntimeDynamicTool("message"),
]);
let persistentWebSearchAllowed = false;
let webSearchAllowed = true;
const tools = await buildDynamicToolsForTest(params, workspaceDir, {
onPersistentWebSearchPolicyResolved: (allowed) => {
persistentWebSearchAllowed = allowed;
},
onWebSearchPolicyResolved: (allowed) => {
webSearchAllowed = allowed;
},
});
expect(tools.map((tool) => tool.name)).toEqual(["message"]);
expect(persistentWebSearchAllowed).toBe(true);
expect(webSearchAllowed).toBe(false);
});
it("keeps persistent search denied when runtime toolsAllow also excludes it", async () => {
const workspaceDir = path.join(tempDir, "workspace");
const params = createParams(path.join(tempDir, "session.jsonl"), workspaceDir);
params.disableTools = false;
params.runtimePlan = createCodexRuntimePlanFixture();
params.toolsAllow = ["message"];
setOpenClawCodingToolsFactoryForTests(() => [createRuntimeDynamicTool("message")]);
let persistentWebSearchAllowed = true;
let webSearchAllowed = true;
const tools = await buildDynamicToolsForTest(params, workspaceDir, {
onPersistentWebSearchPolicyResolved: (allowed) => {
persistentWebSearchAllowed = allowed;
},
onWebSearchPolicyResolved: (allowed) => {
webSearchAllowed = allowed;
},
});
expect(tools.map((tool) => tool.name)).toEqual(["message"]);
expect(persistentWebSearchAllowed).toBe(false);
expect(webSearchAllowed).toBe(false);
});
it("treats sender-scoped web_search denial as transient", async () => {
const workspaceDir = path.join(tempDir, "workspace");
const params = createParams(path.join(tempDir, "session.jsonl"), workspaceDir);
params.disableTools = false;
params.runtimePlan = createCodexRuntimePlanFixture();
params.senderId = "restricted-sender";
params.config = {
tools: {
toolsBySender: {
"id:restricted-sender": { deny: ["web_search"] },
},
},
} as never;
setOpenClawCodingToolsFactoryForTests(() => [createRuntimeDynamicTool("message")]);
let persistentWebSearchAllowed = false;
let webSearchAllowed = true;
const tools = await buildDynamicToolsForTest(params, workspaceDir, {
onPersistentWebSearchPolicyResolved: (allowed) => {
persistentWebSearchAllowed = allowed;
},
onWebSearchPolicyResolved: (allowed) => {
webSearchAllowed = allowed;
},
});
expect(tools.map((tool) => tool.name)).toEqual(["message"]);
expect(persistentWebSearchAllowed).toBe(true);
expect(webSearchAllowed).toBe(false);
});
it("keeps persistent search denied when global and sender policy both deny it", async () => {
const workspaceDir = path.join(tempDir, "workspace");
const params = createParams(path.join(tempDir, "session.jsonl"), workspaceDir);
params.disableTools = false;
params.runtimePlan = createCodexRuntimePlanFixture();
params.senderId = "restricted-sender";
params.config = {
tools: {
deny: ["web_search"],
toolsBySender: {
"id:restricted-sender": { deny: ["web_search"] },
},
},
} as never;
setOpenClawCodingToolsFactoryForTests(() => [createRuntimeDynamicTool("message")]);
let persistentWebSearchAllowed = true;
await buildDynamicToolsForTest(params, workspaceDir, {
onPersistentWebSearchPolicyResolved: (allowed) => {
persistentWebSearchAllowed = allowed;
},
});
expect(persistentWebSearchAllowed).toBe(false);
});
it("keeps managed web_search when a managed provider is explicitly selected", async () => {
const workspaceDir = path.join(tempDir, "workspace");
const params = createParams(path.join(tempDir, "session.jsonl"), workspaceDir);
params.disableTools = false;
params.runtimePlan = createCodexRuntimePlanFixture();
params.config = {
tools: {
web: {
search: { provider: "brave" },
},
},
} as never;
setOpenClawCodingToolsFactoryForTests(() => [
createRuntimeDynamicTool("web_search"),
createRuntimeDynamicTool("message"),
]);
const tools = await buildDynamicToolsForTest(params, workspaceDir);
expect(tools.map((tool) => tool.name)).toEqual(["web_search", "message"]);
});
it("keeps managed web_search when the active Codex provider lacks hosted search", async () => {
const workspaceDir = path.join(tempDir, "workspace");
const params = createParams(path.join(tempDir, "session.jsonl"), workspaceDir);
params.disableTools = false;
params.runtimePlan = createCodexRuntimePlanFixture();
setOpenClawCodingToolsFactoryForTests(() => [
createRuntimeDynamicTool("web_search"),
createRuntimeDynamicTool("message"),
]);
const tools = await buildDynamicToolsForTest(params, workspaceDir, {
nativeProviderWebSearchSupport: "unsupported",
});
expect(tools.map((tool) => tool.name)).toEqual(["web_search", "message"]);
});
it("applies additional Codex dynamic tool excludes without exposing Codex-native tools", () => {
const tools = ["read", "exec", "message", "custom_tool"].map((name) => ({ name }));
@@ -282,6 +475,7 @@ describe("Codex app-server dynamic tool build", () => {
createRuntimeDynamicTool("process"),
createRuntimeDynamicTool("apply_patch"),
createRuntimeDynamicTool("message"),
createRuntimeDynamicTool("web_search"),
];
});
const sessionFile = path.join(tempDir, "session.jsonl");
@@ -292,11 +486,19 @@ describe("Codex app-server dynamic tool build", () => {
params.trigger = "memory";
params.memoryFlushWritePath = "memory/2026-05-22.md";
const sandbox = { enabled: true, backendId: "docker" } as never;
let persistentWebSearchAllowed = false;
let webSearchAllowed = true;
const nativeToolSurfaceEnabled = shouldEnableCodexAppServerNativeToolSurface(params, sandbox);
const tools = await buildDynamicToolsForTest(params, workspaceDir, {
sandbox,
nativeToolSurfaceEnabled,
onPersistentWebSearchPolicyResolved: (allowed) => {
persistentWebSearchAllowed = allowed;
},
onWebSearchPolicyResolved: (allowed) => {
webSearchAllowed = allowed;
},
});
expect(nativeToolSurfaceEnabled).toBe(false);
@@ -306,6 +508,33 @@ describe("Codex app-server dynamic tool build", () => {
memoryFlushWritePath: "memory/2026-05-22.md",
});
expect(tools.map((tool) => tool.name)).toEqual(["read", "write"]);
expect(persistentWebSearchAllowed).toBe(true);
expect(webSearchAllowed).toBe(false);
});
it("keeps persistent search disabled during a memory flush when config disables it", async () => {
setOpenClawCodingToolsFactoryForTests(() => [
createRuntimeDynamicTool("read"),
createRuntimeDynamicTool("write"),
createRuntimeDynamicTool("web_search"),
]);
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
const params = createParams(sessionFile, workspaceDir);
params.disableTools = false;
params.runtimePlan = createCodexRuntimePlanFixture();
params.trigger = "memory";
params.memoryFlushWritePath = "memory/2026-05-22.md";
params.config = { tools: { web: { search: { enabled: false } } } };
let persistentWebSearchAllowed = true;
await buildDynamicToolsForTest(params, workspaceDir, {
onPersistentWebSearchPolicyResolved: (allowed) => {
persistentWebSearchAllowed = allowed;
},
});
expect(persistentWebSearchAllowed).toBe(false);
});
it("exposes OpenClaw sandbox shell tools under distinct names for non-Docker sandbox backends", async () => {

View File

@@ -29,6 +29,7 @@ import { resolveCodexNativeExecutionPolicy } from "./native-execution-policy.js"
import type { CodexSandboxPolicy, CodexTurnEnvironmentParams } from "./protocol.js";
import type { CodexSandboxExecEnvironment } from "./sandbox-exec-server.js";
import { filterToolsForVisionInputs } from "./vision-tools.js";
import { resolveCodexWebSearchPlan, type CodexNativeWebSearchSupport } from "./web-search.js";
type OpenClawCodingToolsOptions = NonNullable<
Parameters<(typeof import("openclaw/plugin-sdk/agent-harness"))["createOpenClawCodingTools"]>[0]
@@ -62,6 +63,7 @@ export type DynamicToolBuildParams = {
sandboxSessionKey: string;
sandbox: OpenClawSandboxContext;
nativeToolSurfaceEnabled?: boolean;
nativeProviderWebSearchSupport?: CodexNativeWebSearchSupport;
runAbortController: AbortController;
sessionAgentId: string;
pluginConfig: CodexPluginConfig;
@@ -70,6 +72,8 @@ export type DynamicToolBuildParams = {
ignoreRuntimePlan?: boolean;
onYieldDetected: () => void;
onCodexAppServerEvent?: (event: CodexDynamicToolBuildEvent) => void;
onPersistentWebSearchPolicyResolved?: (allowed: boolean) => void;
onWebSearchPolicyResolved?: (allowed: boolean) => void;
};
let openClawCodingToolsFactoryForTests: OpenClawCodingToolsFactory | undefined;
@@ -192,7 +196,13 @@ export function formatCodexDynamicToolBuildStageSummary(
/** 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)) {
if (params.disableTools) {
input.onWebSearchPolicyResolved?.(false);
return [];
}
if (!supportsModelTools(params.model)) {
input.onPersistentWebSearchPolicyResolved?.(false);
input.onWebSearchPolicyResolved?.(false);
return [];
}
// Dynamic tool construction is on the reply hot path, so per-stage
@@ -202,9 +212,9 @@ export async function buildDynamicTools(input: DynamicToolBuildParams) {
});
const modelHasVision = params.model.input?.includes("image") ?? false;
const agentDir = params.agentDir ?? resolveAgentDir(params.config ?? {}, input.sessionAgentId);
const agentHarness = await import("openclaw/plugin-sdk/agent-harness");
const createOpenClawCodingTools =
openClawCodingToolsFactoryForTests ??
(await import("openclaw/plugin-sdk/agent-harness")).createOpenClawCodingTools;
openClawCodingToolsFactoryForTests ?? agentHarness.createOpenClawCodingTools;
toolBuildStages.mark("load-agent-harness-tools");
const sessionKeys = resolveOpenClawCodingToolsSessionKeys(params, input.sandboxSessionKey);
const allTools = createOpenClawCodingTools({
@@ -297,6 +307,12 @@ export async function buildDynamicTools(input: DynamicToolBuildParams) {
const preNormalizationDiagnostics: RuntimeToolSchemaDiagnostic[] = [];
const readableAllToolProjection = filterProviderNormalizableTools(allTools);
preNormalizationDiagnostics.push(...readableAllToolProjection.diagnostics);
const webSearchPlan = resolveCodexWebSearchPlan({
config: params.config,
disableTools: params.disableTools,
nativeToolSurfaceEnabled: input.nativeToolSurfaceEnabled,
nativeProviderWebSearchSupport: input.nativeProviderWebSearchSupport,
});
const readableAllTools = [...readableAllToolProjection.tools];
const codexFilteredTools = addNodeShellDynamicToolsIfNeeded(
addSandboxShellDynamicToolsIfAvailable(
@@ -315,6 +331,40 @@ export async function buildDynamicTools(input: DynamicToolBuildParams) {
hasInboundImages: (params.images?.length ?? 0) > 0,
});
toolBuildStages.mark("vision-filtering");
const webSearchPresent = visionFilteredTools.some((tool) => tool.name === "web_search");
const webSearchPolicy = agentHarness.resolveWebSearchToolPolicy({
config: params.config,
modelProvider: params.model.provider,
modelId: params.modelId,
agentId: input.sessionAgentId,
sessionKey: input.sandboxSessionKey,
sandboxToolPolicy: input.sandbox?.tools,
messageProvider: resolveCodexMessageToolProvider(params),
agentAccountId: params.agentAccountId,
groupId: params.groupId,
groupChannel: params.groupChannel,
groupSpace: params.groupSpace,
spawnedBy: params.spawnedBy,
senderId: params.senderId,
senderName: params.senderName,
senderUsername: params.senderUsername,
senderE164: params.senderE164,
});
const senderScopedWebSearchRestriction =
!webSearchPolicy.allowed && webSearchPolicy.persistentAllowed;
const transientWebSearchRestriction =
senderScopedWebSearchRestriction || isCodexMemoryFlushRun(params);
const persistentCodexWebSearchSurface =
params.config?.tools?.web?.search?.enabled !== false &&
!(input.pluginConfig.codexDynamicToolsExclude ?? []).some(
(name) => normalizeCodexDynamicToolName(name) === "web_search",
);
input.onPersistentWebSearchPolicyResolved?.(
webSearchPresent ||
(persistentCodexWebSearchSurface &&
transientWebSearchRestriction &&
webSearchPolicy.persistentAllowed),
);
const toolsAllow = includeForcedCodexDynamicToolAllow(params.toolsAllow, params);
const filteredTools = filterCodexDynamicToolsForAllowlist(visionFilteredTools, toolsAllow);
toolBuildStages.mark("allowlist-filter");
@@ -332,6 +382,12 @@ export async function buildDynamicTools(input: DynamicToolBuildParams) {
preNormalizationDiagnostics.push(...diagnostics),
});
toolBuildStages.mark("runtime-normalization");
// Resolve policy before hiding the managed tool. Hosted search follows the
// same effective policy, while only one search implementation is exposed.
input.onWebSearchPolicyResolved?.(normalizedTools.some((tool) => tool.name === "web_search"));
const exposedTools = webSearchPlan.suppressManagedWebSearch
? normalizedTools.filter((tool) => tool.name !== "web_search")
: normalizedTools;
if (preNormalizationDiagnostics.length > 0) {
embeddedAgentLog.warn(
`codex app-server quarantined ${preNormalizationDiagnostics.length} unsupported runtime tool schema${preNormalizationDiagnostics.length === 1 ? "" : "s"} before dynamic tool registration`,
@@ -362,14 +418,14 @@ export async function buildDynamicTools(input: DynamicToolBuildParams) {
codexFilteredToolCount: codexFilteredTools.length,
visionFilteredToolCount: visionFilteredTools.length,
filteredToolCount: filteredTools.length,
normalizedToolCount: normalizedTools.length,
normalizedToolCount: exposedTools.length,
forceHeartbeatTool: input.forceHeartbeatTool === true,
ignoreRuntimePlan: input.ignoreRuntimePlan === true,
nativeToolSurfaceEnabled: input.nativeToolSurfaceEnabled === true,
},
);
}
return normalizedTools;
return exposedTools;
}
/** Preserves delivery-critical tools when a narrow allowlist would otherwise hide them. */

View File

@@ -1150,6 +1150,222 @@ describe("CodexAppServerEventProjector", () => {
expect(result.assistantTexts).toEqual(["final answer"]);
});
it("does not double-deliver a commentary note echoed on the raw response lane", async () => {
const onAgentEvent = vi.fn();
const projector = await createProjector({
...(await createParams()),
onAgentEvent,
});
// Typed agentMessage lane streams the note, keyed by the thread item id.
await projector.handleNotification(
forCurrentTurn("item/started", {
item: { type: "agentMessage", id: "msg-commentary", phase: "commentary", text: "" },
}),
);
await projector.handleNotification(
agentMessageDelta("Checking the workspace", "msg-commentary"),
);
await projector.handleNotification(
forCurrentTurn("item/completed", {
item: {
type: "agentMessage",
id: "msg-commentary",
phase: "commentary",
text: "Checking the workspace",
},
}),
);
// Raw response lane echoes the same note. Codex omits the message id on the
// wire (ResponseItem::Message.id is skip_serializing), so the projector
// synthesizes a `raw-assistant-*` id that never matches the thread item id.
await projector.handleNotification(
forCurrentTurn("rawResponseItem/completed", {
item: {
type: "message",
role: "assistant",
phase: "commentary",
content: [{ type: "output_text", text: "Checking the workspace" }],
},
}),
);
const preambles = onAgentEvent.mock.calls
.map((call) => call[0])
.filter((event) => event.stream === "item" && event.data.kind === "preamble");
expect(preambles.map((event) => event.data.progressText)).toEqual(["Checking the workspace"]);
expect(preambles.every((event) => event.data.itemId === "msg-commentary")).toBe(true);
});
it("delivers distinct same-text commentary notes from the same lane within a turn", async () => {
const onAgentEvent = vi.fn();
const projector = await createProjector({
...(await createParams()),
onAgentEvent,
});
// Two separate notes that happen to share text must each be delivered.
for (const id of ["msg-1", "msg-2"]) {
await projector.handleNotification(
forCurrentTurn("item/started", {
item: { type: "agentMessage", id, phase: "commentary", text: "" },
}),
);
await projector.handleNotification(agentMessageDelta("Checking the workspace", id));
}
const preambles = onAgentEvent.mock.calls
.map((call) => call[0])
.filter((event) => event.stream === "item" && event.data.kind === "preamble");
expect(preambles.map((event) => event.data.itemId)).toEqual(["msg-1", "msg-2"]);
expect(preambles.map((event) => event.data.progressText)).toEqual([
"Checking the workspace",
"Checking the workspace",
]);
});
it("delivers a later raw-only commentary note after consuming a same-text typed echo", async () => {
const onAgentEvent = vi.fn();
const projector = await createProjector({
...(await createParams()),
onAgentEvent,
});
const rawCommentary = () =>
forCurrentTurn("rawResponseItem/completed", {
item: {
type: "message",
role: "assistant",
phase: "commentary",
content: [{ type: "output_text", text: "Checking the workspace" }],
},
});
await projector.handleNotification(
forCurrentTurn("item/started", {
item: { type: "agentMessage", id: "msg-commentary", phase: "commentary", text: "" },
}),
);
await projector.handleNotification(
agentMessageDelta("Checking the workspace", "msg-commentary"),
);
await projector.handleNotification(
forCurrentTurn("item/completed", {
item: {
type: "agentMessage",
id: "msg-commentary",
phase: "commentary",
text: "Checking the workspace",
},
}),
);
await projector.handleNotification(rawCommentary());
await projector.handleNotification(rawCommentary());
const preambles = onAgentEvent.mock.calls
.map((call) => call[0])
.filter((event) => event.stream === "item" && event.data.kind === "preamble");
expect(preambles.map((event) => event.data.itemId)).toEqual([
"msg-commentary",
"raw-assistant-2",
]);
});
it("pairs a raw commentary echo after a rewritten typed completion", async () => {
const onAgentEvent = vi.fn();
const projector = await createProjector({
...(await createParams()),
onAgentEvent,
});
await projector.handleNotification(
forCurrentTurn("item/started", {
item: { type: "agentMessage", id: "msg-commentary", phase: "commentary", text: "" },
}),
);
await projector.handleNotification(
forCurrentTurn("item/completed", {
item: {
type: "agentMessage",
id: "msg-commentary",
phase: "commentary",
text: "Contributor-rewritten note",
},
}),
);
await projector.handleNotification(
forCurrentTurn("rawResponseItem/completed", {
item: {
type: "message",
role: "assistant",
phase: "commentary",
content: [{ type: "output_text", text: "Original model note" }],
},
}),
);
const preambles = onAgentEvent.mock.calls
.map((call) => call[0])
.filter((event) => event.stream === "item" && event.data.kind === "preamble");
expect(preambles.map((event) => event.data.progressText)).toEqual([
"Contributor-rewritten note",
]);
expect(preambles.every((event) => event.data.itemId === "msg-commentary")).toBe(true);
});
it("clears a pending commentary echo when the raw envelope has no text", async () => {
const onAgentEvent = vi.fn();
const projector = await createProjector({
...(await createParams()),
onAgentEvent,
});
await projector.handleNotification(
forCurrentTurn("item/started", {
item: { type: "agentMessage", id: "msg-commentary", phase: "commentary", text: "" },
}),
);
await projector.handleNotification(
forCurrentTurn("item/completed", {
item: {
type: "agentMessage",
id: "msg-commentary",
phase: "commentary",
text: " ",
},
}),
);
await projector.handleNotification(
forCurrentTurn("rawResponseItem/completed", {
item: {
type: "message",
role: "assistant",
phase: "commentary",
content: [],
},
}),
);
await projector.handleNotification(
forCurrentTurn("rawResponseItem/completed", {
item: {
type: "message",
role: "assistant",
phase: "commentary",
content: [{ type: "output_text", text: "Later raw-only note" }],
},
}),
);
const preambles = onAgentEvent.mock.calls
.map((call) => call[0])
.filter((event) => event.stream === "item" && event.data.kind === "preamble");
expect(preambles.map((event) => event.data.progressText)).toEqual(["Later raw-only note"]);
});
it("does not resolve commentary-phase assistant text as the final reply", async () => {
const projector = await createProjector();

View File

@@ -149,6 +149,9 @@ export class CodexAppServerEventProjector {
private readonly assistantItemOrder: string[] = [];
private readonly assistantPhaseByItem = new Map<string, string>();
private readonly lastCommentaryProgressTextByItem = new Map<string, string>();
// Codex emits each typed item completion before its matching raw response item.
// Pair by protocol order because contributors may rewrite only the typed text.
private pendingRawCommentaryEchoes = 0;
private readonly reasoningTextByGroup = new Map<string, ReasoningTextGroup>();
private readonly reasoningItemOrder = new Map<string, number>();
private readonly planTextByItem = new Map<string, string>();
@@ -653,6 +656,7 @@ export class CodexAppServerEventProjector {
this.assistantTextByItem.set(item.id, item.text);
if (item.text && this.isCommentaryAssistantItem(item.id)) {
this.emitCommentaryProgress({ itemId: item.id, text: item.text });
this.pendingRawCommentaryEchoes += 1;
}
}
this.recordNativeGeneratedMedia(item);
@@ -914,12 +918,16 @@ export class CodexAppServerEventProjector {
if (readString(item, "role") !== "assistant") {
return;
}
const phase = readString(item, "phase");
if (phase === "commentary" && this.pendingRawCommentaryEchoes > 0) {
this.pendingRawCommentaryEchoes -= 1;
return;
}
const text = extractRawAssistantText(item);
if (!text) {
return;
}
const itemId = readString(item, "id") ?? `raw-assistant-${this.assistantItemOrder.length + 1}`;
const phase = readString(item, "phase");
if (phase) {
this.assistantPhaseByItem.set(itemId, phase);
}

View File

@@ -318,7 +318,7 @@ describe("OpenClaw-owned tool runtime contract — Codex app-server adapter", ()
it("records successful Codex messaging text, media, and target telemetry", async () => {
const hooks = installOpenClawOwnedToolHooks();
const execute = vi.fn(async () => textToolResult("Sent."));
const execute = vi.fn(async () => textToolResult("Sent.", { messageId: "message-1" }));
const bridge = createCodexDynamicToolBridge({
tools: [createContractTool({ name: "message", execute })],
signal: new AbortController().signal,

View File

@@ -76,6 +76,12 @@ export type CodexTurnEnvironmentParams = JsonObject & {
cwd: string;
};
export type CodexPermissionProfileSelection = JsonObject & {
type: "profile";
id: string;
modifications?: JsonValue[] | null;
};
export type CodexThreadStartParams = JsonObject & {
input?: CodexUserInput[];
cwd?: string;
@@ -85,6 +91,7 @@ export type CodexThreadStartParams = JsonObject & {
approvalPolicy?: string | JsonObject;
approvalsReviewer?: string | null;
sandbox?: string;
permissions?: CodexPermissionProfileSelection;
serviceTier?: CodexServiceTier | null;
dynamicTools?: CodexDynamicToolSpec[] | null;
developerInstructions?: string;
@@ -102,6 +109,7 @@ export type CodexThreadResumeParams = JsonObject & {
approvalPolicy?: string | JsonObject;
approvalsReviewer?: string | null;
sandbox?: string;
permissions?: CodexPermissionProfileSelection;
serviceTier?: CodexServiceTier | null;
config?: JsonObject;
developerInstructions?: string;
@@ -153,6 +161,7 @@ export type CodexTurnStartParams = JsonObject & {
approvalPolicy?: string | JsonObject;
approvalsReviewer?: string | null;
sandboxPolicy?: CodexSandboxPolicy;
permissions?: CodexPermissionProfileSelection;
serviceTier?: CodexServiceTier | null;
effort?: string | null;
personality?: string | null;
@@ -337,6 +346,12 @@ export type CodexGetAccountResponse = {
requiresOpenaiAuth?: boolean;
};
export type CodexModelProviderCapabilitiesReadResponse = {
namespaceTools: boolean;
imageGeneration: boolean;
webSearch: boolean;
};
export type CodexChatgptAuthTokensRefreshResponse = {
accessToken: string;
chatgptAccountId: string;
@@ -542,6 +557,7 @@ type CodexAppServerRequestResultMap = {
"marketplace/add": JsonValue;
"mcpServerStatus/list": CodexListMcpServerStatusResponse;
"model/list": CodexModelListResponse;
"modelProvider/capabilities/read": CodexModelProviderCapabilitiesReadResponse;
"plugin/install": CodexPluginInstallResponse;
"plugin/list": CodexPluginListResponse;
"plugin/read": CodexPluginReadResponse;

View File

@@ -0,0 +1,90 @@
import { describe, expect, it, vi } from "vitest";
import type { CodexAppServerClientFactory } from "./client-factory.js";
import type { CodexAppServerClient } from "./client.js";
import type { CodexAppServerRuntimeOptions } from "./config.js";
import { resolveCodexProviderWebSearchSupport } from "./provider-capabilities.js";
const appServer = {
start: {},
requestTimeoutMs: 1_000,
} as CodexAppServerRuntimeOptions;
function createClientFactory(webSearch: boolean | boolean[]) {
const values = Array.isArray(webSearch) ? [...webSearch] : [webSearch];
const request = vi.fn(async () => ({ webSearch: values.shift() ?? false }));
const client = { request } as unknown as CodexAppServerClient;
const clientFactory = vi.fn(async () => client) as unknown as CodexAppServerClientFactory;
return { clientFactory, request };
}
function resolveSupport(
clientFactory: CodexAppServerClientFactory,
modelProviderOverride?: string,
) {
return resolveCodexProviderWebSearchSupport({
clientFactory,
appServer,
authProfileId: undefined,
agentDir: "/tmp/agent",
config: undefined,
modelProviderOverride,
signal: new AbortController().signal,
});
}
describe("resolveCodexProviderWebSearchSupport", () => {
it("reads the latest configured provider capability for each attempt", async () => {
const { clientFactory, request } = createClientFactory([true, false]);
await expect(resolveSupport(clientFactory)).resolves.toBe("supported");
await expect(resolveSupport(clientFactory)).resolves.toBe("unsupported");
expect(request).toHaveBeenCalledTimes(2);
expect(request).toHaveBeenCalledWith(
"modelProvider/capabilities/read",
{},
expect.objectContaining({ timeoutMs: 1_000 }),
);
});
it("reports unknown support when app-server startup fails", async () => {
const clientFactory = vi.fn(async () => {
throw new Error("old app-server");
}) as unknown as CodexAppServerClientFactory;
await expect(resolveSupport(clientFactory)).resolves.toBe("unknown");
});
it("reports unknown support when the capability read fails", async () => {
const request = vi.fn(async () => {
throw new Error("transient rpc failure");
});
const client = { request } as unknown as CodexAppServerClient;
const clientFactory = vi.fn(async () => client) as unknown as CodexAppServerClientFactory;
await expect(resolveSupport(clientFactory)).resolves.toBe("unknown");
expect(request).toHaveBeenCalledOnce();
});
it("keeps managed search when the configured provider reports no hosted support", async () => {
const { clientFactory, request } = createClientFactory(false);
await expect(resolveSupport(clientFactory)).resolves.toBe("unsupported");
expect(request).toHaveBeenCalledOnce();
});
it("uses hosted search for the built-in OpenAI provider override", async () => {
const { clientFactory, request } = createClientFactory(false);
await expect(resolveSupport(clientFactory, " OpenAI ")).resolves.toBe("supported");
expect(request).not.toHaveBeenCalled();
});
it("keeps managed search for provider overrides the capability RPC cannot target", async () => {
const { clientFactory, request } = createClientFactory(true);
await expect(resolveSupport(clientFactory, "amazon-bedrock")).resolves.toBe("unsupported");
await expect(resolveSupport(clientFactory, "custom-provider")).resolves.toBe("unsupported");
expect(request).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,78 @@
import type { EmbeddedRunAttemptParams } from "openclaw/plugin-sdk/agent-harness-runtime";
import type { CodexAppServerClientFactory } from "./client-factory.js";
import type { CodexAppServerClient } from "./client.js";
import type { CodexAppServerRuntimeOptions } from "./config.js";
import { releaseLeasedSharedCodexAppServerClient } from "./shared-client.js";
import type { CodexNativeWebSearchSupport } from "./web-search.js";
async function readConfiguredProviderWebSearchSupport(params: {
client: CodexAppServerClient;
timeoutMs: number;
signal: AbortSignal;
}): Promise<CodexNativeWebSearchSupport> {
const response = await params.client.request(
"modelProvider/capabilities/read",
{},
{
timeoutMs: params.timeoutMs,
signal: params.signal,
},
);
return response.webSearch ? "supported" : "unsupported";
}
export async function resolveCodexProviderWebSearchSupportForClient(params: {
client: CodexAppServerClient;
timeoutMs: number;
modelProviderOverride: string | undefined;
signal: AbortSignal;
}): Promise<CodexNativeWebSearchSupport> {
const modelProviderOverride = params.modelProviderOverride?.trim().toLowerCase();
if (modelProviderOverride === "openai") {
return "supported";
}
if (modelProviderOverride) {
// Codex's capability RPC only reports the configured provider, not a
// thread-scoped override. Keep managed search for overrides whose hosted
// capability cannot be proven from the configured-provider response.
return "unsupported";
}
try {
return await readConfiguredProviderWebSearchSupport(params);
} catch {
return "unknown";
}
}
export async function resolveCodexProviderWebSearchSupport(params: {
clientFactory: CodexAppServerClientFactory;
appServer: CodexAppServerRuntimeOptions;
authProfileId: string | undefined;
agentDir: string;
config: EmbeddedRunAttemptParams["config"] | undefined;
modelProviderOverride: string | undefined;
signal: AbortSignal;
}): Promise<CodexNativeWebSearchSupport> {
let client: CodexAppServerClient | undefined;
try {
client = await params.clientFactory(
params.appServer.start,
params.authProfileId,
params.agentDir,
params.config,
{ timeoutMs: params.appServer.requestTimeoutMs },
);
return await resolveCodexProviderWebSearchSupportForClient({
client,
timeoutMs: params.appServer.requestTimeoutMs,
modelProviderOverride: params.modelProviderOverride,
signal: params.signal,
});
} catch {
return "unknown";
} finally {
if (client) {
releaseLeasedSharedCodexAppServerClient(client);
}
}
}

View File

@@ -5281,6 +5281,41 @@ describe("runCodexAppServerAttempt", () => {
expect(turnRequestParams?.approvalsReviewer).toBe("user");
});
it("keeps managed web_search for provider-qualified Codex model overrides", async () => {
testing.setOpenClawCodingToolsFactoryForTests(() => [createRuntimeDynamicTool("web_search")]);
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
const { requests, waitForMethod, completeTurn } = createStartedThreadHarness(async (method) => {
if (method === "modelProvider/capabilities/read") {
return { webSearch: true };
}
return undefined;
});
const params = createParams(sessionFile, workspaceDir);
params.disableTools = false;
params.runtimePlan = createCodexRuntimePlanFixture();
params.modelId = "lmstudio/local-model";
const run = runCodexAppServerAttempt(params);
await waitForMethod("turn/start");
await completeTurn({ threadId: "thread-1", turnId: "turn-1" });
await run;
expect(requests.map((request) => request.method)).not.toContain(
"modelProvider/capabilities/read",
);
const startRequest = requests.find((request) => request.method === "thread/start");
const startRequestParams = startRequest?.params as Record<string, unknown> | undefined;
const startConfig = startRequestParams?.config as Record<string, unknown> | undefined;
const dynamicToolNames = (
startRequestParams?.dynamicTools as Array<{ name?: string }> | undefined
)?.map((tool) => tool.name);
expect(startRequestParams?.model).toBe("local-model");
expect(startRequestParams?.modelProvider).toBe("lmstudio");
expect(startConfig?.web_search).toBe("disabled");
expect(dynamicToolNames).toContain("web_search");
});
it("uses bound local model providers when disabling Guardian on resumed threads", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");

View File

@@ -217,6 +217,7 @@ import {
type JsonObject,
type JsonValue,
} from "./protocol.js";
import { resolveCodexProviderWebSearchSupport } from "./provider-capabilities.js";
import { releaseCodexSandboxExecServerEnvironment } from "./sandbox-exec-server.js";
import {
clearCodexAppServerBinding,
@@ -234,6 +235,7 @@ import {
buildTurnCollaborationMode,
buildTurnStartParams,
codexDynamicToolsFingerprint,
resolveCodexAppServerThreadModelSelection,
type CodexAppServerThreadLifecycleBinding,
type CodexContextEngineThreadBootstrapProjection,
} from "./thread-lifecycle.js";
@@ -261,6 +263,7 @@ import {
refreshCodexUsageLimitPromptError,
} from "./usage-limit-error.js";
import { createCodexUserInputBridge } from "./user-input-bridge.js";
import { resolveCodexWebSearchPlan } from "./web-search.js";
const CODEX_NATIVE_HOOK_RELAY_RENEW_INTERVAL_MS = 60_000;
const CODEX_APP_SERVER_PROJECTED_CHARS_PER_TOKEN = 4;
@@ -650,6 +653,31 @@ export async function runCodexAppServerAttempt(
sandboxExecServerEnabled,
});
preDynamicStartupStages.mark("native-tool-surface");
const nativeProviderWebSearchSupport =
resolveCodexWebSearchPlan({
config: params.config,
disableTools: params.disableTools,
nativeToolSurfaceEnabled,
}).kind === "native-hosted"
? await resolveCodexProviderWebSearchSupport({
clientFactory: attemptClientFactory,
appServer,
authProfileId: startupAuthProfileId,
agentDir,
config: params.config,
modelProviderOverride: resolveCodexAppServerThreadModelSelection({
provider: params.provider,
model: params.modelId,
binding: startupBinding,
authProfileId: startupAuthProfileId,
authProfileStore: params.authProfileStore,
agentDir,
config: params.config,
}).modelProvider,
signal: runAbortController.signal,
})
: "unsupported";
preDynamicStartupStages.mark("provider-capabilities");
for (const diagnostic of bundleMcpThreadConfig.diagnostics) {
embeddedAgentLog.warn(`bundle-mcp: ${diagnostic.pluginId}: ${diagnostic.message}`);
}
@@ -716,6 +744,8 @@ export async function runCodexAppServerAttempt(
...(onCodexToolOutcome ? { onToolOutcome: onCodexToolOutcome } : {}),
}
: params;
let persistentWebSearchAllowed: boolean | undefined;
let webSearchAllowed = false;
const tools = await buildDynamicTools({
params: dynamicToolParams,
resolvedWorkspace,
@@ -724,6 +754,7 @@ export async function runCodexAppServerAttempt(
sandboxSessionKey,
sandbox,
nativeToolSurfaceEnabled,
nativeProviderWebSearchSupport,
runAbortController,
sessionAgentId,
pluginConfig,
@@ -732,6 +763,12 @@ export async function runCodexAppServerAttempt(
yieldDetected = true;
},
onCodexAppServerEvent: (event) => emitCodexAppServerEvent(params, event),
onPersistentWebSearchPolicyResolved: (allowed) => {
persistentWebSearchAllowed = allowed;
},
onWebSearchPolicyResolved: (allowed) => {
webSearchAllowed = allowed;
},
});
const registeredTools = await buildDynamicTools({
params: dynamicToolParams,
@@ -741,6 +778,7 @@ export async function runCodexAppServerAttempt(
sandboxSessionKey,
sandbox,
nativeToolSurfaceEnabled,
nativeProviderWebSearchSupport,
runAbortController,
sessionAgentId,
pluginConfig,
@@ -1269,10 +1307,13 @@ export async function runCodexAppServerAttempt(
effectiveWorkspace,
effectiveCwd,
dynamicTools: toolBridge.specs,
persistentWebSearchAllowed,
webSearchAllowed,
developerInstructions: promptBuild.developerInstructions,
buildFinalConfigPatch: buildNativeHookRelayFinalConfigPatch,
bundleMcpThreadConfig,
nativeToolSurfaceEnabled,
nativeProviderWebSearchSupport,
sandboxExecServerEnabled,
sandbox,
contextEngineProjection,

View File

@@ -61,6 +61,7 @@ describe("codex app-server session binding", () => {
model: "gpt-5.4-codex",
modelProvider: "openai",
dynamicToolsFingerprint: "tools-v1",
webSearchThreadConfigFingerprint: "web-search-v1",
userMcpServersFingerprint: "user-mcp-v1",
nativeHookRelayGeneration: "generation-v1",
});
@@ -74,6 +75,7 @@ describe("codex app-server session binding", () => {
expect(binding?.model).toBe("gpt-5.4-codex");
expect(binding?.modelProvider).toBe("openai");
expect(binding?.dynamicToolsFingerprint).toBe("tools-v1");
expect(binding?.webSearchThreadConfigFingerprint).toBe("web-search-v1");
expect(binding?.userMcpServersFingerprint).toBe("user-mcp-v1");
expect(binding?.nativeHookRelayGeneration).toBe("generation-v1");
const bindingStat = await fs.stat(resolveCodexAppServerBindingPath(sessionFile));

View File

@@ -66,8 +66,10 @@ export type CodexAppServerThreadBinding = {
approvalPolicy?: CodexAppServerApprovalPolicy;
sandbox?: CodexAppServerSandboxMode;
serviceTier?: CodexServiceTier;
networkProxyProfileName?: string;
dynamicToolsFingerprint?: string;
dynamicToolsContainDeferred?: boolean;
webSearchThreadConfigFingerprint?: string;
userMcpServersFingerprint?: string;
mcpServersFingerprint?: string;
nativeHookRelayGeneration?: string;
@@ -180,6 +182,10 @@ export async function readCodexAppServerBinding(
approvalPolicy: readApprovalPolicy(parsed.approvalPolicy),
sandbox: readSandboxMode(parsed.sandbox),
serviceTier: readServiceTier(parsed.serviceTier),
networkProxyProfileName:
typeof parsed.networkProxyProfileName === "string"
? parsed.networkProxyProfileName
: undefined,
dynamicToolsFingerprint:
typeof parsed.dynamicToolsFingerprint === "string"
? parsed.dynamicToolsFingerprint
@@ -188,6 +194,10 @@ export async function readCodexAppServerBinding(
typeof parsed.dynamicToolsContainDeferred === "boolean"
? parsed.dynamicToolsContainDeferred
: undefined,
webSearchThreadConfigFingerprint:
typeof parsed.webSearchThreadConfigFingerprint === "string"
? parsed.webSearchThreadConfigFingerprint
: undefined,
userMcpServersFingerprint:
typeof parsed.userMcpServersFingerprint === "string"
? parsed.userMcpServersFingerprint
@@ -251,8 +261,10 @@ export async function writeCodexAppServerBinding(
approvalPolicy: binding.approvalPolicy,
sandbox: binding.sandbox,
serviceTier: binding.serviceTier,
networkProxyProfileName: binding.networkProxyProfileName,
dynamicToolsFingerprint: binding.dynamicToolsFingerprint,
dynamicToolsContainDeferred: binding.dynamicToolsContainDeferred,
webSearchThreadConfigFingerprint: binding.webSearchThreadConfigFingerprint,
userMcpServersFingerprint: binding.userMcpServersFingerprint,
mcpServersFingerprint: binding.mcpServersFingerprint,
nativeHookRelayGeneration: binding.nativeHookRelayGeneration,

View File

@@ -20,6 +20,7 @@ const refreshCodexAppServerAuthTokensMock = vi.fn();
const createOpenClawCodingToolsMock = vi.fn();
const toolExecuteMock = vi.fn();
const handleCodexAppServerApprovalRequestMock = vi.fn();
const resolveCodexProviderWebSearchSupportForClientMock = vi.fn();
vi.mock("./session-binding.js", () => ({
clearCodexAppServerBinding: vi.fn(),
@@ -46,6 +47,11 @@ vi.mock("./approval-bridge.js", () => ({
handleCodexAppServerApprovalRequestMock(...args),
}));
vi.mock("./provider-capabilities.js", () => ({
resolveCodexProviderWebSearchSupportForClient: (...args: unknown[]) =>
resolveCodexProviderWebSearchSupportForClientMock(...args),
}));
vi.mock("openclaw/plugin-sdk/agent-harness", () => ({
createOpenClawCodingTools: (...args: unknown[]) => createOpenClawCodingToolsMock(...args),
}));
@@ -322,6 +328,61 @@ function sideParams(overrides: Partial<Parameters<typeof runCodexAppServerSideQu
} satisfies Parameters<typeof runCodexAppServerSideQuestion>[0];
}
async function runSideQuestionWithManagedWebSearchCall(
params: Parameters<typeof runCodexAppServerSideQuestion>[0] = sideParams(),
options: { preserveToolFactory?: boolean } = {},
) {
const client = createFakeClient();
let toolResponse: unknown;
if (!options.preserveToolFactory) {
createOpenClawCodingToolsMock.mockReturnValue([
{
name: "web_search",
description: "Search the web",
parameters: { type: "object", properties: {} },
execute: toolExecuteMock,
},
]);
}
client.request.mockImplementation(async (method: string) => {
if (method === "thread/fork") {
return threadResult("side-thread");
}
if (method === "thread/inject_items") {
return {};
}
if (method === "turn/start") {
setTimeout(() => {
void (async () => {
toolResponse = await client.handleRequest({
id: 42,
method: "item/tool/call",
params: {
threadId: "side-thread",
turnId: "turn-1",
callId: "tool-1",
tool: "web_search",
arguments: { query: "service providers" },
},
});
client.emit(turnCompleted("side-thread", "turn-1", "Search answer."));
})();
}, 0);
return turnStartResult("turn-1");
}
if (method === "thread/unsubscribe" || method === "turn/interrupt") {
return {};
}
throw new Error(`unexpected request: ${method}`);
});
getSharedCodexAppServerClientMock.mockResolvedValue(client);
const result = await runCodexAppServerSideQuestion(params);
const forkCall = client.request.mock.calls.find(([method]) => method === "thread/fork");
const forkConfig = (forkCall?.[1] as { config?: Record<string, unknown> } | undefined)?.config;
return { forkConfig, result, toolResponse };
}
describe("runCodexAppServerSideQuestion", () => {
beforeEach(() => {
nativeHookRelayTesting.clearNativeHookRelaysForTests();
@@ -332,6 +393,8 @@ describe("runCodexAppServerSideQuestion", () => {
createOpenClawCodingToolsMock.mockReset();
toolExecuteMock.mockReset();
handleCodexAppServerApprovalRequestMock.mockReset();
resolveCodexProviderWebSearchSupportForClientMock.mockReset();
resolveCodexProviderWebSearchSupportForClientMock.mockResolvedValue("supported");
toolExecuteMock.mockResolvedValue({
content: [{ type: "text", text: "tool output" }],
@@ -343,6 +406,12 @@ describe("runCodexAppServerSideQuestion", () => {
parameters: { type: "object", properties: {} },
execute: toolExecuteMock,
},
{
name: "web_search",
description: "Search the web",
parameters: { type: "object", properties: {} },
execute: toolExecuteMock,
},
]);
readCodexAppServerBindingMock.mockResolvedValue({
@@ -380,7 +449,21 @@ describe("runCodexAppServerSideQuestion", () => {
sideParams({
messageChannel: "discord",
messageProvider: "discord-voice",
sessionKey: "agent:main:conversation",
sandboxSessionKey: "agent:main:runtime-policy",
currentChannelId: "voice-room",
agentAccountId: "account-1",
messageTo: "channel-1",
messageThreadId: "thread-1",
groupId: "group-1",
groupChannel: "#ops",
groupSpace: "workspace-1",
spawnedBy: "agent:main:parent",
senderId: "sender-1",
senderName: "Rosita",
senderUsername: "rosita",
senderE164: "+15550001",
senderIsOwner: true,
}),
);
@@ -414,6 +497,8 @@ describe("runCodexAppServerSideQuestion", () => {
"features.code_mode": true,
"features.code_mode_only": false,
"features.apply_patch_streaming_events": true,
"features.standalone_web_search": false,
web_search: "cached",
});
expect(forkParams?.developerInstructions).toContain("You are in a side conversation");
expect(forkParams?.developerInstructions).toContain(
@@ -481,9 +566,211 @@ describe("runCodexAppServerSideQuestion", () => {
expect(toolOptions).toHaveProperty("messageProvider", "discord");
expect(toolOptions).toHaveProperty("toolPolicyMessageProvider", "discord-voice");
expect(toolOptions).toHaveProperty("currentChannelId", "voice-room");
expect(toolOptions).toMatchObject({
agentAccountId: "account-1",
sessionKey: "agent:main:runtime-policy",
runSessionKey: "agent:main:conversation",
messageTo: "channel-1",
messageThreadId: "thread-1",
groupId: "group-1",
groupChannel: "#ops",
groupSpace: "workspace-1",
spawnedBy: "agent:main:parent",
senderId: "sender-1",
senderName: "Rosita",
senderUsername: "rosita",
senderE164: "+15550001",
senderIsOwner: true,
});
expect(toolOptions).toHaveProperty("requireExplicitMessageTarget", true);
});
it("disables hosted search when side-question sender policy removes managed web_search", async () => {
createOpenClawCodingToolsMock.mockImplementation((options: { senderId?: string }) =>
options.senderId === "restricted-sender"
? []
: [
{
name: "web_search",
description: "Search the web",
parameters: { type: "object", properties: {} },
execute: toolExecuteMock,
},
],
);
const { forkConfig } = await runSideQuestionWithManagedWebSearchCall(
sideParams({ senderId: "restricted-sender" }),
{ preserveToolFactory: true },
);
expect(forkConfig).toMatchObject({
"features.standalone_web_search": false,
web_search: "disabled",
});
});
it.each([
{ name: "deny all", toolsAllow: [] },
{ name: "narrow allowlist", toolsAllow: ["message"] },
])("rejects /btw before forking when effective toolsAllow is $name", async ({ toolsAllow }) => {
await expect(
runCodexAppServerSideQuestion(
sideParams({
messageChannel: "telegram",
messageProvider: "telegram",
senderId: "restricted-sender",
toolsAllow,
}),
),
).rejects.toThrow(
"Codex-native /btw side-question mode is unavailable because the effective tool policy restricts Codex native tools for this session.",
);
expect(getSharedCodexAppServerClientMock).not.toHaveBeenCalled();
expect(resolveCodexProviderWebSearchSupportForClientMock).not.toHaveBeenCalled();
});
it("applies native search restrictions to side forks and suppresses managed search", async () => {
const { forkConfig, result, toolResponse } = await runSideQuestionWithManagedWebSearchCall(
sideParams({
cfg: {
tools: {
web: {
search: {
openaiCodex: {
allowedDomains: ["example.com"],
},
},
},
},
} as never,
}),
);
expect(result).toEqual({ text: "Search answer." });
expect(forkConfig).toMatchObject({
"features.standalone_web_search": false,
web_search: "cached",
"tools.web_search.allowed_domains": ["example.com"],
});
expect(toolResponse).toEqual({
success: false,
contentItems: [{ type: "inputText", text: "Unknown OpenClaw tool: web_search" }],
});
expect(toolExecuteMock).not.toHaveBeenCalled();
});
it("preserves managed web_search while planning hosted search for Responses side questions", async () => {
createOpenClawCodingToolsMock.mockImplementation(
(options: { suppressManagedWebSearch?: boolean }) =>
options.suppressManagedWebSearch === false
? [
{
name: "web_search",
description: "Search the web",
parameters: { type: "object", properties: {} },
execute: toolExecuteMock,
},
]
: [],
);
const { forkConfig, toolResponse } = await runSideQuestionWithManagedWebSearchCall(
sideParams({
runtimeModel: {
id: "gpt-5.5",
provider: "openai",
api: "openai-chatgpt-responses",
} as never,
}),
{ preserveToolFactory: true },
);
expect(forkConfig).toMatchObject({
"features.standalone_web_search": false,
web_search: "cached",
});
expect(toolResponse).toEqual({
success: false,
contentItems: [{ type: "inputText", text: "Unknown OpenClaw tool: web_search" }],
});
expect(toolExecuteMock).not.toHaveBeenCalled();
});
it("disables search for side forks when the configured provider lacks hosted search", async () => {
resolveCodexProviderWebSearchSupportForClientMock.mockResolvedValue("unsupported");
const { forkConfig, result, toolResponse } = await runSideQuestionWithManagedWebSearchCall();
expect(result).toEqual({ text: "Search answer." });
expect(forkConfig).toMatchObject({
"features.standalone_web_search": false,
web_search: "disabled",
});
expect(toolResponse).toEqual({
success: false,
contentItems: [{ type: "inputText", text: "Unknown OpenClaw tool: web_search" }],
});
expect(toolExecuteMock).not.toHaveBeenCalled();
});
it("disables search for side forks when a managed provider is selected", async () => {
const { forkConfig, result, toolResponse } = await runSideQuestionWithManagedWebSearchCall(
sideParams({
cfg: {
tools: {
web: {
search: {
provider: "brave",
},
},
},
} as never,
}),
);
expect(result).toEqual({ text: "Search answer." });
expect(forkConfig).toMatchObject({
"features.standalone_web_search": false,
web_search: "disabled",
});
expect(toolResponse).toEqual({
success: false,
contentItems: [{ type: "inputText", text: "Unknown OpenClaw tool: web_search" }],
});
expect(toolExecuteMock).not.toHaveBeenCalled();
expect(resolveCodexProviderWebSearchSupportForClientMock).not.toHaveBeenCalled();
});
it("disables both search surfaces for side forks when web search is disabled", async () => {
const { forkConfig, result, toolResponse } = await runSideQuestionWithManagedWebSearchCall(
sideParams({
cfg: {
tools: {
web: {
search: {
enabled: false,
},
},
},
} as never,
}),
);
expect(result).toEqual({ text: "Search answer." });
expect(forkConfig).toMatchObject({
"features.standalone_web_search": false,
web_search: "disabled",
});
expect(toolResponse).toEqual({
success: false,
contentItems: [{ type: "inputText", text: "Unknown OpenClaw tool: web_search" }],
});
expect(toolExecuteMock).not.toHaveBeenCalled();
expect(resolveCodexProviderWebSearchSupportForClientMock).not.toHaveBeenCalled();
});
it("returns side-thread completions scoped by nested turn thread id", async () => {
const client = createFakeClient();
client.request.mockImplementation(async (method: string) => {
@@ -526,6 +813,27 @@ describe("runCodexAppServerSideQuestion", () => {
expect(getSharedCodexAppServerClientMock).not.toHaveBeenCalled();
});
it("checks /btw native execution against the runtime-policy session", async () => {
await expect(
runCodexAppServerSideQuestion(
sideParams({
cfg: {
agents: {
defaults: { sandbox: { mode: "non-main", scope: "agent" } },
list: [{ id: "main" }],
},
} as never,
sessionKey: "agent:main:main",
sandboxSessionKey: "agent:main:whatsapp:personal:direct:15555550123",
}),
),
).rejects.toThrow(
"Codex-native /btw side-question mode is unavailable because OpenClaw sandboxing is active for this session.",
);
expect(getSharedCodexAppServerClientMock).not.toHaveBeenCalled();
});
it("rejects /btw before forking when exec host=node is active", async () => {
await expect(
runCodexAppServerSideQuestion(
@@ -883,6 +1191,11 @@ describe("runCodexAppServerSideQuestion", () => {
expect(forkParams?.approvalPolicy).toBe("on-request");
expect(forkParams?.sandbox).toBe("workspace-write");
expect(forkParams?.approvalsReviewer).toBe("user");
expect(resolveCodexProviderWebSearchSupportForClientMock).toHaveBeenCalledWith(
expect.objectContaining({
modelProviderOverride: "lmstudio",
}),
);
expect(config?.["features.code_mode"]).toBe(true);
expect(config?.["features.code_mode_only"]).toBe(true);
});

View File

@@ -31,7 +31,10 @@ import {
shouldAutoApproveCodexAppServerApprovals,
type CodexAppServerRuntimeOptions,
} from "./config.js";
import { resolveCodexMessageToolProvider } from "./dynamic-tool-build.js";
import {
resolveCodexMessageToolProvider,
shouldEnableCodexAppServerNativeToolSurface,
} from "./dynamic-tool-build.js";
import {
emitDynamicToolErrorDiagnostic,
emitDynamicToolStartedDiagnostic,
@@ -69,6 +72,7 @@ import {
type JsonObject,
type JsonValue,
} from "./protocol.js";
import { resolveCodexProviderWebSearchSupportForClient } from "./provider-capabilities.js";
import { rememberCodexRateLimits, readRecentCodexRateLimits } from "./rate-limit-cache.js";
import { formatCodexUsageLimitErrorMessage } from "./rate-limits.js";
import { resolveCodexNativeExecutionBlock } from "./sandbox-guard.js";
@@ -86,6 +90,11 @@ import {
resolveReasoningEffort,
} from "./thread-lifecycle.js";
import { filterToolsForVisionInputs } from "./vision-tools.js";
import {
resolveCodexWebSearchPlan,
type CodexNativeWebSearchSupport,
type CodexWebSearchPlan,
} from "./web-search.js";
const CODEX_SIDE_DYNAMIC_TOOL_TIMEOUT_MS = 90_000;
const CODEX_SIDE_DYNAMIC_TOOL_MAX_TIMEOUT_MS = 600_000;
@@ -144,16 +153,6 @@ export async function runCodexAppServerSideQuestion(
"Codex /btw needs an active Codex thread. Send a normal message first, then try /btw again.",
);
}
const nativeExecutionBlock = resolveCodexNativeExecutionBlock({
config: params.cfg,
sessionKey: params.sessionKey,
sessionId: params.sessionId,
surface: "/btw side-question mode",
});
if (nativeExecutionBlock) {
throw new Error(nativeExecutionBlock);
}
const pluginConfig = readCodexPluginConfig(options.pluginConfig);
const { sessionAgentId } = resolveSessionAgentIds({
sessionKey: params.sessionKey,
@@ -205,6 +204,23 @@ export async function runCodexAppServerSideQuestion(
config: params.cfg,
agentDir: params.agentDir,
});
const cwd = binding.cwd || params.workspaceDir || process.cwd();
const sideRunParams = buildSideRunAttemptParams(params, { cwd, authProfileId });
const nativeExecutionBlock = resolveCodexNativeExecutionBlock({
config: sideRunParams.config,
sessionKey: sideRunParams.sandboxSessionKey?.trim() || sideRunParams.sessionKey,
sessionId: sideRunParams.sessionId,
surface: "/btw side-question mode",
});
if (nativeExecutionBlock) {
throw new Error(nativeExecutionBlock);
}
const nativeToolSurfaceEnabled = shouldEnableCodexAppServerNativeToolSurface(sideRunParams);
if (!nativeToolSurfaceEnabled) {
throw new Error(
"Codex-native /btw side-question mode is unavailable because the effective tool policy restricts Codex native tools for this session.",
);
}
const client = await getLeasedSharedCodexAppServerClient({
startOptions: appServer.start,
timeoutMs: appServer.requestTimeoutMs,
@@ -230,8 +246,6 @@ export async function runCodexAppServerSideQuestion(
let nativeHookRelay: NativeHookRelayRegistrationHandle | undefined;
try {
const cwd = binding.cwd || params.workspaceDir || process.cwd();
const sideRunParams = buildSideRunAttemptParams(params, { cwd, authProfileId });
const modelScopedAppServer = resolveCodexAppServerForModelProvider({
appServer,
provider: reviewerPolicyContext.modelProvider,
@@ -253,11 +267,25 @@ export async function runCodexAppServerSideQuestion(
const sandbox = useModelScopedPolicy
? modelScopedAppServer.sandbox
: (binding.sandbox ?? modelScopedAppServer.sandbox);
const toolBridge = await createCodexSideToolBridge({
const nativeProviderWebSearchSupport =
resolveCodexWebSearchPlan({
config: params.cfg,
nativeToolSurfaceEnabled,
}).kind === "native-hosted"
? await resolveCodexProviderWebSearchSupportForClient({
client,
timeoutMs: appServer.requestTimeoutMs,
modelProviderOverride: modelSelection.modelProvider,
signal: runAbortController.signal,
})
: "unsupported";
const { toolBridge, webSearchPlan } = await createCodexSideToolBridge({
params,
cwd,
pluginConfig,
sessionAgentId,
nativeToolSurfaceEnabled,
nativeProviderWebSearchSupport,
signal: runAbortController.signal,
});
removeRequestHandler = client.addRequestHandler(async (request) => {
@@ -383,7 +411,8 @@ export async function runCodexAppServerSideQuestion(
: options.nativeHookRelay?.enabled === false
? buildCodexNativeHookRelayDisabledConfig()
: undefined;
const runtimeThreadConfig = buildCodexRuntimeThreadConfig(undefined, {
const runtimeThreadConfig = buildCodexRuntimeThreadConfig(webSearchPlan.threadConfig, {
nativeCodeModeEnabled: nativeToolSurfaceEnabled,
nativeCodeModeOnlyEnabled: appServer.codeModeOnly,
});
const threadConfig =
@@ -562,10 +591,25 @@ function buildSideRunAttemptParams(
sessionId: params.sessionId,
sessionFile: params.sessionFile,
sessionKey: params.sessionKey,
...(params.sandboxSessionKey ? { sandboxSessionKey: params.sandboxSessionKey } : {}),
agentId: params.agentId,
...(params.messageChannel ? { messageChannel: params.messageChannel } : {}),
...(params.messageProvider ? { messageProvider: params.messageProvider } : {}),
...(params.agentAccountId ? { agentAccountId: params.agentAccountId } : {}),
...(params.messageTo ? { messageTo: params.messageTo } : {}),
...(params.messageThreadId !== undefined ? { messageThreadId: params.messageThreadId } : {}),
...(params.groupId !== undefined ? { groupId: params.groupId } : {}),
...(params.groupChannel !== undefined ? { groupChannel: params.groupChannel } : {}),
...(params.groupSpace !== undefined ? { groupSpace: params.groupSpace } : {}),
...(params.memberRoleIds ? { memberRoleIds: params.memberRoleIds } : {}),
...(params.spawnedBy !== undefined ? { spawnedBy: params.spawnedBy } : {}),
...(params.senderId !== undefined ? { senderId: params.senderId } : {}),
...(params.senderName !== undefined ? { senderName: params.senderName } : {}),
...(params.senderUsername !== undefined ? { senderUsername: params.senderUsername } : {}),
...(params.senderE164 !== undefined ? { senderE164: params.senderE164 } : {}),
...(params.senderIsOwner !== undefined ? { senderIsOwner: params.senderIsOwner } : {}),
...(params.currentChannelId ? { currentChannelId: params.currentChannelId } : {}),
...(params.toolsAllow ? { toolsAllow: params.toolsAllow } : {}),
workspaceDir: options.cwd,
authProfileId: options.authProfileId,
authProfileIdSource: params.authProfileIdSource,
@@ -592,8 +636,10 @@ async function createCodexSideToolBridge(input: {
cwd: string;
pluginConfig: ReturnType<typeof readCodexPluginConfig>;
sessionAgentId: string;
nativeToolSurfaceEnabled: boolean;
nativeProviderWebSearchSupport: CodexNativeWebSearchSupport;
signal: AbortSignal;
}): Promise<CodexDynamicToolBridge> {
}): Promise<{ toolBridge: CodexDynamicToolBridge; webSearchPlan: CodexWebSearchPlan }> {
const runtimeModel =
input.params.runtimeModel ??
({ id: input.params.model, provider: input.params.provider } as never);
@@ -603,7 +649,10 @@ async function createCodexSideToolBridge(input: {
const createOpenClawCodingTools = (await import("openclaw/plugin-sdk/agent-harness"))
.createOpenClawCodingTools;
const sandboxSessionKey =
input.params.sessionKey?.trim() || input.params.sessionId || input.sessionAgentId;
input.params.sandboxSessionKey?.trim() ||
input.params.sessionKey?.trim() ||
input.params.sessionId ||
input.sessionAgentId;
const sandbox = await resolveSandboxContext({
config: input.params.cfg,
sessionKey: sandboxSessionKey,
@@ -638,12 +687,34 @@ async function createCodexSideToolBridge(input: {
modelAuthMode: resolveModelAuthMode(runtimeModel.provider, input.params.cfg, undefined, {
workspaceDir: input.cwd,
}),
suppressManagedWebSearch: false,
...(input.params.messageProvider || input.params.messageChannel
? {
messageProvider: messageToolProvider,
toolPolicyMessageProvider: input.params.messageProvider ?? input.params.messageChannel,
}
: {}),
...(input.params.agentAccountId ? { agentAccountId: input.params.agentAccountId } : {}),
...(input.params.messageTo ? { messageTo: input.params.messageTo } : {}),
...(input.params.messageThreadId !== undefined
? { messageThreadId: input.params.messageThreadId }
: {}),
...(input.params.groupId !== undefined ? { groupId: input.params.groupId } : {}),
...(input.params.groupChannel !== undefined
? { groupChannel: input.params.groupChannel }
: {}),
...(input.params.groupSpace !== undefined ? { groupSpace: input.params.groupSpace } : {}),
...(input.params.memberRoleIds ? { memberRoleIds: input.params.memberRoleIds } : {}),
...(input.params.spawnedBy !== undefined ? { spawnedBy: input.params.spawnedBy } : {}),
...(input.params.senderId !== undefined ? { senderId: input.params.senderId } : {}),
...(input.params.senderName !== undefined ? { senderName: input.params.senderName } : {}),
...(input.params.senderUsername !== undefined
? { senderUsername: input.params.senderUsername }
: {}),
...(input.params.senderE164 !== undefined ? { senderE164: input.params.senderE164 } : {}),
...(input.params.senderIsOwner !== undefined
? { senderIsOwner: input.params.senderIsOwner }
: {}),
...(input.params.currentChannelId ? { currentChannelId: input.params.currentChannelId } : {}),
hookChannelId: buildAgentHookContextChannelFields({
sessionKey: input.params.sessionKey,
@@ -662,26 +733,45 @@ async function createCodexSideToolBridge(input: {
hasInboundImages: false,
});
}
const requestedWebSearchPlan = resolveCodexWebSearchPlan({
config: input.params.cfg,
nativeToolSurfaceEnabled: input.nativeToolSurfaceEnabled,
nativeProviderWebSearchSupport: input.nativeProviderWebSearchSupport,
webSearchAllowed: tools.some((tool) => tool.name === "web_search"),
});
// Codex forks do not accept dynamicTools, so managed web_search cannot be
// registered on a side thread. Keep it only as the native-search policy signal.
const webSearchPlan =
requestedWebSearchPlan.kind === "managed"
? resolveCodexWebSearchPlan({
config: input.params.cfg,
webSearchAllowed: false,
})
: requestedWebSearchPlan;
const exposedTools = tools.filter((tool) => tool.name !== "web_search");
const hookChannelFields = buildAgentHookContextChannelFields({
sessionKey: input.params.sessionKey,
messageChannel: input.params.messageChannel,
messageProvider: input.params.messageProvider,
currentChannelId: input.params.currentChannelId,
});
return createCodexDynamicToolBridge({
tools,
signal: input.signal,
loading: resolveCodexDynamicToolsLoading(input.pluginConfig),
hookContext: {
agentId: input.sessionAgentId,
config: input.params.cfg,
sessionId: input.params.sessionId,
sessionKey: input.params.sessionKey,
runId: input.params.opts?.runId ?? `codex-btw:${input.params.sessionId}`,
currentChannelProvider: messageToolProvider,
...hookChannelFields,
},
});
return {
toolBridge: createCodexDynamicToolBridge({
tools: exposedTools,
signal: input.signal,
loading: resolveCodexDynamicToolsLoading(input.pluginConfig),
hookContext: {
agentId: input.sessionAgentId,
config: input.params.cfg,
sessionId: input.params.sessionId,
sessionKey: input.params.sessionKey,
runId: input.params.opts?.runId ?? `codex-btw:${input.params.sessionId}`,
currentChannelProvider: messageToolProvider,
...hookChannelFields,
},
}),
webSearchPlan,
};
}
async function handleSideDynamicToolCallWithTimeout(params: {

View File

@@ -2,12 +2,15 @@
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import {
createParams,
createParams as createRunAttemptParams,
setupRunAttemptTestHooks,
tempDir,
threadStartResult,
} from "./run-attempt-test-harness.js";
import { readCodexAppServerBinding, writeCodexAppServerBinding } from "./session-binding.js";
import {
readCodexAppServerBinding,
writeCodexAppServerBinding as writeRawCodexAppServerBinding,
} from "./session-binding.js";
import { startOrResumeThread } from "./thread-lifecycle.js";
function createThreadLifecycleAppServerOptions(): Parameters<
@@ -29,6 +32,37 @@ function createThreadLifecycleAppServerOptions(): Parameters<
};
}
function createParams(sessionFile: string, workspaceDir: string) {
const params = createRunAttemptParams(sessionFile, workspaceDir);
params.disableTools = false;
return params;
}
const DEFAULT_CODEX_RUNTIME_THREAD_CONFIG = {
"features.code_mode": true,
"features.code_mode_only": false,
"features.apply_patch_streaming_events": true,
"features.standalone_web_search": false,
web_search: "cached",
} as const;
const DEFAULT_CODEX_WEB_SEARCH_THREAD_CONFIG_FINGERPRINT = JSON.stringify({
"features.standalone_web_search": false,
web_search: "cached",
});
function writeCodexAppServerBinding(...args: Parameters<typeof writeRawCodexAppServerBinding>) {
const [sessionFile, binding, lookup] = args;
return writeRawCodexAppServerBinding(
sessionFile,
{
webSearchThreadConfigFingerprint: DEFAULT_CODEX_WEB_SEARCH_THREAD_CONFIG_FINGERPRINT,
...binding,
},
lookup,
);
}
function createMessageDynamicTool(
description: string,
actions: string[] = ["send"],
@@ -388,6 +422,540 @@ describe("Codex app-server thread lifecycle bindings", () => {
expect(binding.modelProvider).toBe("lmstudio");
});
it("starts a fresh Codex thread when web search switches to a managed provider", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
const params = createParams(sessionFile, workspaceDir);
params.disableTools = false;
const appServer = createThreadLifecycleAppServerOptions();
let starts = 0;
const request = vi.fn(async (method: string, _params?: unknown) => {
if (method === "thread/start") {
starts += 1;
return threadStartResult(`thread-${starts}`);
}
if (method === "thread/resume") {
return threadStartResult("thread-existing");
}
throw new Error(`unexpected method: ${method}`);
});
await startOrResumeThread({
client: { request } as never,
params,
cwd: workspaceDir,
dynamicTools: [createDeferredNamedDynamicTool("web_search")],
appServer,
});
params.config = {
tools: {
web: {
search: { provider: "brave" },
},
},
};
const binding = await startOrResumeThread({
client: { request } as never,
params,
cwd: workspaceDir,
dynamicTools: [createDeferredNamedDynamicTool("web_search")],
appServer,
});
expect(binding.threadId).toBe("thread-2");
expect(request.mock.calls.map(([method]) => method)).toEqual(["thread/start", "thread/start"]);
expect(request.mock.calls[0]?.[1]).toMatchObject({
config: { web_search: "cached" },
});
expect(request.mock.calls[1]?.[1]).toMatchObject({
config: { web_search: "disabled" },
});
});
it("uses a transient Codex thread when runtime toolsAllow denies web_search", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
const params = createParams(sessionFile, workspaceDir);
params.disableTools = false;
const appServer = createThreadLifecycleAppServerOptions();
let starts = 0;
const request = vi.fn(async (method: string, _params?: unknown) => {
if (method === "thread/start") {
starts += 1;
return threadStartResult(`thread-${starts}`);
}
if (method === "thread/resume") {
return threadStartResult("thread-1");
}
throw new Error(`unexpected method: ${method}`);
});
await startOrResumeThread({
client: { request } as never,
params,
cwd: workspaceDir,
dynamicTools: [createDeferredNamedDynamicTool("web_search")],
webSearchAllowed: true,
appServer,
});
params.toolsAllow = ["message"];
const restrictedBinding = await startOrResumeThread({
client: { request } as never,
params,
cwd: workspaceDir,
dynamicTools: [createDeferredNamedDynamicTool("web_search")],
webSearchAllowed: false,
appServer,
});
const savedAfterRestriction = await readCodexAppServerBinding(sessionFile);
params.toolsAllow = undefined;
const resumedBinding = await startOrResumeThread({
client: { request } as never,
params,
cwd: workspaceDir,
dynamicTools: [createDeferredNamedDynamicTool("web_search")],
webSearchAllowed: true,
appServer,
});
expect(restrictedBinding.threadId).toBe("thread-2");
expect(savedAfterRestriction?.threadId).toBe("thread-1");
expect(resumedBinding.threadId).toBe("thread-1");
expect(request.mock.calls.map(([method]) => method)).toEqual([
"thread/start",
"thread/start",
"thread/resume",
]);
expect(request.mock.calls[0]?.[1]).toMatchObject({
config: { web_search: "cached" },
});
expect(request.mock.calls[1]?.[1]).toMatchObject({
config: { web_search: "disabled" },
});
});
it("preserves the native-search binding when provider capability support is unknown", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
const params = createParams(sessionFile, workspaceDir);
const appServer = createThreadLifecycleAppServerOptions();
let starts = 0;
const request = vi.fn(async (method: string, requestParams?: unknown) => {
if (method === "thread/start") {
starts += 1;
return threadStartResult(`thread-${starts}`);
}
if (method === "thread/resume") {
return threadStartResult((requestParams as { threadId: string }).threadId);
}
throw new Error(`unexpected method: ${method}`);
});
await startOrResumeThread({
client: { request } as never,
params,
cwd: workspaceDir,
dynamicTools: [],
nativeProviderWebSearchSupport: "supported",
webSearchAllowed: true,
appServer,
});
const transientBinding = await startOrResumeThread({
client: { request } as never,
params,
cwd: workspaceDir,
dynamicTools: [],
nativeProviderWebSearchSupport: "unknown",
webSearchAllowed: true,
appServer,
});
const savedAfterUnknownSupport = await readCodexAppServerBinding(sessionFile);
const resumedBinding = await startOrResumeThread({
client: { request } as never,
params,
cwd: workspaceDir,
dynamicTools: [],
nativeProviderWebSearchSupport: "supported",
webSearchAllowed: true,
appServer,
});
expect(transientBinding.threadId).toBe("thread-2");
expect(savedAfterUnknownSupport?.threadId).toBe("thread-1");
expect(resumedBinding.threadId).toBe("thread-1");
expect(request.mock.calls.map(([method]) => method)).toEqual([
"thread/start",
"thread/start",
"thread/resume",
]);
expect(request.mock.calls[0]?.[1]).toMatchObject({
config: { web_search: "cached" },
});
expect(request.mock.calls[1]?.[1]).toMatchObject({
config: { web_search: "disabled" },
});
});
it("does not persist a first-turn managed fallback when provider capability support is unknown", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
const request = vi.fn(async (method: string, _requestParams?: unknown) => {
if (method === "thread/start") {
return threadStartResult("thread-transient");
}
throw new Error(`unexpected method: ${method}`);
});
const binding = await startOrResumeThread({
client: { request } as never,
params: createParams(sessionFile, workspaceDir),
cwd: workspaceDir,
dynamicTools: [],
nativeProviderWebSearchSupport: "unknown",
webSearchAllowed: true,
appServer: createThreadLifecycleAppServerOptions(),
});
expect(binding.threadId).toBe("thread-transient");
expect(await readCodexAppServerBinding(sessionFile)).toBeUndefined();
expect(request.mock.calls[0]?.[1]).toMatchObject({
config: { web_search: "disabled" },
});
});
it("persists a restricted Codex thread when effective config policy denies web_search", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
const params = createParams(sessionFile, workspaceDir);
const appServer = createThreadLifecycleAppServerOptions();
let starts = 0;
const request = vi.fn(async (method: string, requestParams?: unknown) => {
if (method === "thread/start") {
starts += 1;
return threadStartResult(`thread-${starts}`);
}
if (method === "thread/resume") {
return threadStartResult((requestParams as { threadId: string }).threadId);
}
throw new Error(`unexpected method: ${method}`);
});
await startOrResumeThread({
client: { request } as never,
params,
cwd: workspaceDir,
dynamicTools: [createDeferredNamedDynamicTool("web_search")],
webSearchAllowed: true,
appServer,
});
params.config = { tools: { deny: ["web_search"] } };
params.toolsAllow = [];
const restrictedBinding = await startOrResumeThread({
client: { request } as never,
params,
cwd: workspaceDir,
dynamicTools: [],
persistentWebSearchAllowed: false,
webSearchAllowed: false,
appServer,
});
const resumedRestrictedBinding = await startOrResumeThread({
client: { request } as never,
params,
cwd: workspaceDir,
dynamicTools: [],
persistentWebSearchAllowed: false,
webSearchAllowed: false,
appServer,
});
expect(restrictedBinding.threadId).toBe("thread-2");
expect(resumedRestrictedBinding.threadId).toBe("thread-2");
expect((await readCodexAppServerBinding(sessionFile))?.threadId).toBe("thread-2");
expect(request.mock.calls.map(([method]) => method)).toEqual([
"thread/start",
"thread/start",
"thread/resume",
]);
});
it("persists config-denied search when runtime toolsAllow also excludes web_search", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
const params = createParams(sessionFile, workspaceDir);
const appServer = createThreadLifecycleAppServerOptions();
let starts = 0;
const request = vi.fn(async (method: string, requestParams?: unknown) => {
if (method === "thread/start") {
starts += 1;
return threadStartResult(`thread-${starts}`);
}
if (method === "thread/resume") {
return threadStartResult((requestParams as { threadId: string }).threadId);
}
throw new Error(`unexpected method: ${method}`);
});
await startOrResumeThread({
client: { request } as never,
params,
cwd: workspaceDir,
dynamicTools: [createDeferredNamedDynamicTool("web_search")],
persistentWebSearchAllowed: true,
webSearchAllowed: true,
appServer,
});
params.config = { tools: { deny: ["web_search"] } };
params.toolsAllow = ["message"];
const restrictedBinding = await startOrResumeThread({
client: { request } as never,
params,
cwd: workspaceDir,
dynamicTools: [],
nativeCodeModeEnabled: false,
persistentWebSearchAllowed: false,
webSearchAllowed: false,
appServer,
});
const resumedRestrictedBinding = await startOrResumeThread({
client: { request } as never,
params,
cwd: workspaceDir,
dynamicTools: [],
nativeCodeModeEnabled: false,
persistentWebSearchAllowed: false,
webSearchAllowed: false,
appServer,
});
expect(restrictedBinding.threadId).toBe("thread-2");
expect(resumedRestrictedBinding.threadId).toBe("thread-2");
expect((await readCodexAppServerBinding(sessionFile))?.threadId).toBe("thread-2");
expect(request.mock.calls.map(([method]) => method)).toEqual([
"thread/start",
"thread/start",
"thread/resume",
]);
});
it("replaces the Codex binding when web search is persistently disabled", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
const params = createParams(sessionFile, workspaceDir);
const appServer = createThreadLifecycleAppServerOptions();
let starts = 0;
const request = vi.fn(async (method: string, _params?: unknown) => {
if (method === "thread/start") {
starts += 1;
return threadStartResult(`thread-${starts}`);
}
throw new Error(`unexpected method: ${method}`);
});
await startOrResumeThread({
client: { request } as never,
params,
cwd: workspaceDir,
dynamicTools: [createDeferredNamedDynamicTool("web_search")],
appServer,
});
params.config = {
tools: {
web: {
search: { enabled: false },
},
},
};
const binding = await startOrResumeThread({
client: { request } as never,
params,
cwd: workspaceDir,
dynamicTools: [],
webSearchAllowed: false,
appServer,
});
expect(binding.threadId).toBe("thread-2");
expect((await readCodexAppServerBinding(sessionFile))?.threadId).toBe("thread-2");
expect(request.mock.calls.map(([method]) => method)).toEqual(["thread/start", "thread/start"]);
});
it("starts a fresh Codex thread for default hosted search on a legacy binding", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
await writeRawCodexAppServerBinding(sessionFile, {
threadId: "thread-legacy",
cwd: workspaceDir,
model: "gpt-5.5",
modelProvider: "openai",
dynamicToolsFingerprint: "[]",
});
const request = vi.fn(async (method: string, _params?: unknown) => {
if (method === "thread/start") {
return threadStartResult("thread-fresh");
}
throw new Error(`unexpected method: ${method}`);
});
const binding = await startOrResumeThread({
client: { request } as never,
params: createParams(sessionFile, workspaceDir),
cwd: workspaceDir,
dynamicTools: [],
appServer: createThreadLifecycleAppServerOptions(),
});
expect(binding.threadId).toBe("thread-fresh");
expect(request.mock.calls.map(([method]) => method)).toEqual(["thread/start"]);
expect(request.mock.calls[0]?.[1]).toMatchObject({
config: {
"features.standalone_web_search": false,
web_search: "cached",
},
});
});
it("starts a fresh Codex thread for a restrictive web search policy on a legacy binding", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
await writeRawCodexAppServerBinding(sessionFile, {
threadId: "thread-legacy",
cwd: workspaceDir,
model: "gpt-5.5",
modelProvider: "openai",
dynamicToolsFingerprint: "[]",
});
const params = createParams(sessionFile, workspaceDir);
params.disableTools = false;
params.config = {
tools: {
web: {
search: { openaiCodex: { enabled: false } },
},
},
};
const request = vi.fn(async (method: string, _params?: unknown) => {
if (method === "thread/start") {
return threadStartResult("thread-fresh");
}
throw new Error(`unexpected method: ${method}`);
});
const binding = await startOrResumeThread({
client: { request } as never,
params,
cwd: workspaceDir,
dynamicTools: [],
appServer: createThreadLifecycleAppServerOptions(),
});
expect(binding.threadId).toBe("thread-fresh");
expect(request.mock.calls.map(([method]) => method)).toEqual(["thread/start"]);
expect(request.mock.calls[0]?.[1]).toMatchObject({
config: { web_search: "disabled" },
});
});
it("starts a fresh Codex thread for hosted search restrictions on a legacy binding", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
await writeRawCodexAppServerBinding(sessionFile, {
threadId: "thread-legacy",
cwd: workspaceDir,
model: "gpt-5.5",
modelProvider: "openai",
dynamicToolsFingerprint: "[]",
});
const params = createParams(sessionFile, workspaceDir);
params.disableTools = false;
params.config = {
tools: {
web: {
search: { openaiCodex: { allowedDomains: ["example.com"] } },
},
},
};
const request = vi.fn(async (method: string, _params?: unknown) => {
if (method === "thread/start") {
return threadStartResult("thread-fresh");
}
throw new Error(`unexpected method: ${method}`);
});
const binding = await startOrResumeThread({
client: { request } as never,
params,
cwd: workspaceDir,
dynamicTools: [],
appServer: createThreadLifecycleAppServerOptions(),
});
expect(binding.threadId).toBe("thread-fresh");
expect(request.mock.calls.map(([method]) => method)).toEqual(["thread/start"]);
expect(request.mock.calls[0]?.[1]).toMatchObject({
config: {
web_search: "cached",
"tools.web_search.allowed_domains": ["example.com"],
},
});
});
it("starts a fresh Codex thread when an existing session enters tool-disabled mode", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
const params = createParams(sessionFile, workspaceDir);
params.disableTools = false;
const appServer = createThreadLifecycleAppServerOptions();
let starts = 0;
const request = vi.fn(async (method: string, _params?: unknown) => {
if (method === "thread/start") {
starts += 1;
return threadStartResult(`thread-${starts}`);
}
if (method === "thread/resume") {
return threadStartResult("thread-existing");
}
throw new Error(`unexpected method: ${method}`);
});
await startOrResumeThread({
client: { request } as never,
params,
cwd: workspaceDir,
dynamicTools: [],
appServer,
});
params.disableTools = true;
const restrictedBinding = await startOrResumeThread({
client: { request } as never,
params,
cwd: workspaceDir,
dynamicTools: [],
appServer,
});
const savedAfterRestriction = await readCodexAppServerBinding(sessionFile);
params.disableTools = false;
const resumedBinding = await startOrResumeThread({
client: { request } as never,
params,
cwd: workspaceDir,
dynamicTools: [],
appServer,
});
expect(restrictedBinding.threadId).toBe("thread-2");
expect(savedAfterRestriction?.threadId).toBe("thread-1");
expect(resumedBinding.threadId).toBe("thread-existing");
expect(request.mock.calls.map(([method]) => method)).toEqual([
"thread/start",
"thread/start",
"thread/resume",
]);
expect(request.mock.calls[1]?.[1]).toMatchObject({
config: { web_search: "disabled" },
});
});
it("starts a fresh Codex thread when dynamic tools switch from deferred to direct", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
@@ -856,9 +1424,7 @@ describe("Codex app-server thread lifecycle bindings", () => {
};
const expectedConfig = {
...config,
"features.code_mode": true,
"features.code_mode_only": false,
"features.apply_patch_streaming_events": true,
...DEFAULT_CODEX_RUNTIME_THREAD_CONFIG,
};
await startOrResumeThread({
@@ -925,9 +1491,7 @@ describe("Codex app-server thread lifecycle bindings", () => {
expect(requestCalls.map(([method]) => method)).toEqual(["thread/start"]);
expect(requestCalls[0]?.[1].config).toEqual({
"features.hooks": true,
"features.code_mode": true,
"features.code_mode_only": false,
"features.apply_patch_streaming_events": true,
...DEFAULT_CODEX_RUNTIME_THREAD_CONFIG,
hooks: { PreToolUse: [] },
...createPluginAppConfigPatch(),
});
@@ -1004,17 +1568,13 @@ describe("Codex app-server thread lifecycle bindings", () => {
expect(requestCalls.map(([method]) => method)).toEqual(["thread/start", "thread/resume"]);
expect(requestCalls[0]?.[1].config).toMatchObject({
"features.hooks": true,
"features.code_mode": true,
"features.code_mode_only": false,
"features.apply_patch_streaming_events": true,
...DEFAULT_CODEX_RUNTIME_THREAD_CONFIG,
"hooks.PreToolUse": finalConfigPatch["hooks.PreToolUse"],
...createPluginAppConfigPatch(),
});
expect(requestCalls[1]?.[1].config).toMatchObject({
"features.hooks": true,
"features.code_mode": true,
"features.code_mode_only": false,
"features.apply_patch_streaming_events": true,
...DEFAULT_CODEX_RUNTIME_THREAD_CONFIG,
"hooks.PreToolUse": finalConfigPatch["hooks.PreToolUse"],
});
});
@@ -1074,16 +1634,12 @@ describe("Codex app-server thread lifecycle bindings", () => {
expect(requestCalls.map(([method]) => method)).toEqual(["thread/start", "thread/resume"]);
expect(requestCalls[0]?.[1].config).toEqual({
"features.hooks": true,
"features.code_mode": true,
"features.code_mode_only": false,
"features.apply_patch_streaming_events": true,
...DEFAULT_CODEX_RUNTIME_THREAD_CONFIG,
...createPluginAppConfigPatch(),
});
expect(requestCalls[1]?.[1].config).toEqual({
"features.hooks": true,
"features.code_mode": true,
"features.code_mode_only": false,
"features.apply_patch_streaming_events": true,
...DEFAULT_CODEX_RUNTIME_THREAD_CONFIG,
});
});
@@ -1144,9 +1700,7 @@ describe("Codex app-server thread lifecycle bindings", () => {
const requestCalls = request.mock.calls as unknown as Array<[string, { config?: unknown }]>;
expect(requestCalls.map(([method]) => method)).toEqual(["thread/start"]);
expect(requestCalls[0]?.[1].config).toEqual({
"features.code_mode": true,
"features.code_mode_only": false,
"features.apply_patch_streaming_events": true,
...DEFAULT_CODEX_RUNTIME_THREAD_CONFIG,
apps: {
_default: {
enabled: false,
@@ -1203,9 +1757,7 @@ describe("Codex app-server thread lifecycle bindings", () => {
const requestCalls = request.mock.calls as unknown as Array<[string, { config?: unknown }]>;
expect(requestCalls.map(([method]) => method)).toEqual(["thread/resume"]);
expect(requestCalls[0]?.[1].config).toEqual({
"features.code_mode": true,
"features.code_mode_only": false,
"features.apply_patch_streaming_events": true,
...DEFAULT_CODEX_RUNTIME_THREAD_CONFIG,
});
const binding = await readCodexAppServerBinding(sessionFile);
expect(binding?.threadId).toBe("thread-existing");
@@ -1263,9 +1815,7 @@ describe("Codex app-server thread lifecycle bindings", () => {
expect(requestCalls.map(([method]) => method)).toEqual(["thread/start"]);
expect(requestCalls[0]?.[1].config).toEqual({
...createPluginAppConfigPatch(),
"features.code_mode": true,
"features.code_mode_only": false,
"features.apply_patch_streaming_events": true,
...DEFAULT_CODEX_RUNTIME_THREAD_CONFIG,
});
const binding = await readCodexAppServerBinding(sessionFile);
expect(binding?.threadId).toBe("thread-recovered");
@@ -1329,9 +1879,7 @@ describe("Codex app-server thread lifecycle bindings", () => {
const requestCalls = request.mock.calls as unknown as Array<[string, { config?: unknown }]>;
expect(requestCalls.map(([method]) => method)).toEqual(["thread/resume"]);
expect(requestCalls[0]?.[1].config).toEqual({
"features.code_mode": true,
"features.code_mode_only": false,
"features.apply_patch_streaming_events": true,
...DEFAULT_CODEX_RUNTIME_THREAD_CONFIG,
});
});
@@ -1385,9 +1933,7 @@ describe("Codex app-server thread lifecycle bindings", () => {
expect(requestCalls.map(([method]) => method)).toEqual(["thread/start"]);
expect(requestCalls[0]?.[1].config).toEqual({
...createTwoPluginAppConfigPatch(),
"features.code_mode": true,
"features.code_mode_only": false,
"features.apply_patch_streaming_events": true,
...DEFAULT_CODEX_RUNTIME_THREAD_CONFIG,
});
const binding = await readCodexAppServerBinding(sessionFile);
expect(binding?.threadId).toBe("thread-recovered");
@@ -1450,9 +1996,7 @@ describe("Codex app-server thread lifecycle bindings", () => {
expect(requestCalls.map(([method]) => method)).toEqual(["thread/start"]);
expect(requestCalls[0]?.[1].config).toEqual({
...createTwoCalendarAppConfigPatch(),
"features.code_mode": true,
"features.code_mode_only": false,
"features.apply_patch_streaming_events": true,
...DEFAULT_CODEX_RUNTIME_THREAD_CONFIG,
});
const binding = await readCodexAppServerBinding(sessionFile);
expect(binding?.threadId).toBe("thread-recovered");
@@ -1504,9 +2048,7 @@ describe("Codex app-server thread lifecycle bindings", () => {
expect(requestCalls.map(([method]) => method)).toEqual(["thread/start"]);
expect(requestCalls[0]?.[1].config).toEqual({
...createPluginAppConfigPatch(),
"features.code_mode": true,
"features.code_mode_only": false,
"features.apply_patch_streaming_events": true,
...DEFAULT_CODEX_RUNTIME_THREAD_CONFIG,
});
const binding = await readCodexAppServerBinding(sessionFile);
expect(binding?.threadId).toBe("thread-plugins");

View File

@@ -14,6 +14,7 @@ import {
buildThreadStartParams,
codexDynamicToolsFingerprint,
formatCodexThreadLifecycleTimingSummary,
resolveCodexAppServerThreadModelSelection,
resolveReasoningEffort,
shouldWarnCodexThreadLifecycleTimingSummary,
startOrResumeThread,
@@ -85,6 +86,36 @@ function createAppServerOptions() {
} as const;
}
function createNetworkProxyAppServerOptions() {
return {
...createAppServerOptions(),
networkProxy: {
profileName: "mock-proxy",
configPatch: {
"features.network_proxy.enabled": true,
permissions: {
"mock-proxy": {
filesystem: {
":minimal": "read",
":workspace_roots": {
".": "write",
},
},
network: {
enabled: true,
domains: {
"api.openai.com": "allow",
},
allow_upstream_proxy: true,
proxy_url: "http://127.0.0.1:3128",
},
},
},
},
},
} as const;
}
function createThreadLifecycleParams(
sessionFile: string,
workspaceDir: string,
@@ -318,10 +349,133 @@ describe("Codex app-server native code mode config", () => {
"features.code_mode": true,
"features.code_mode_only": false,
"features.apply_patch_streaming_events": true,
"features.standalone_web_search": false,
web_search: "cached",
});
expect(request.personality).toBe("none");
});
it("enables hosted Codex web search on thread/start by default", () => {
const request = buildThreadStartParams(createAttemptParams({ provider: "codex" }), {
cwd: "/repo",
dynamicTools: [],
appServer: createAppServerOptions() as never,
developerInstructions: "test instructions",
});
expect(request.config).toMatchObject({
"features.standalone_web_search": false,
web_search: "cached",
});
});
it("disables hosted Codex web search for tool-disabled runs", () => {
const params = createAttemptParams({ provider: "codex" });
params.disableTools = true;
const request = buildThreadStartParams(params, {
cwd: "/repo",
dynamicTools: [],
appServer: createAppServerOptions() as never,
developerInstructions: "test instructions",
});
expect(request.config).toMatchObject({
"features.standalone_web_search": false,
web_search: "disabled",
});
});
it("disables hosted Codex web search when effective tool policy denies web_search", () => {
const request = buildThreadStartParams(createAttemptParams({ provider: "codex" }), {
cwd: "/repo",
dynamicTools: [],
webSearchAllowed: false,
appServer: createAppServerOptions() as never,
developerInstructions: "test instructions",
});
expect(request.config).toMatchObject({
"features.standalone_web_search": false,
web_search: "disabled",
});
});
it("disables native Codex search when runtime policy disables native tools", () => {
const request = buildThreadResumeParams(createAttemptParams({ provider: "codex" }), {
threadId: "thread-1",
appServer: createAppServerOptions() as never,
developerInstructions: "test instructions",
nativeCodeModeEnabled: false,
});
expect(request.config).toMatchObject({
"features.standalone_web_search": false,
web_search: "disabled",
});
});
it("disables hosted Codex web search when the active provider lacks support", () => {
const request = buildThreadStartParams(createAttemptParams({ provider: "codex" }), {
cwd: "/repo",
dynamicTools: [],
appServer: createAppServerOptions() as never,
developerInstructions: "test instructions",
nativeProviderWebSearchSupport: "unsupported",
});
expect(request.config).toMatchObject({
"features.standalone_web_search": false,
web_search: "disabled",
});
});
it("uses a Codex permissions profile for network-proxy thread/start requests", () => {
const request = buildThreadStartParams(createAttemptParams({ provider: "openai" }), {
cwd: "/repo",
dynamicTools: [],
appServer: createNetworkProxyAppServerOptions() as never,
developerInstructions: "test instructions",
});
expect(request.permissions).toEqual({ type: "profile", id: "mock-proxy" });
expect(request).not.toHaveProperty("sandbox");
expect(request.config).toMatchObject({
"features.network_proxy.enabled": true,
permissions: {
"mock-proxy": {
network: {
enabled: true,
allow_upstream_proxy: true,
proxy_url: "http://127.0.0.1:3128",
},
},
},
});
});
it("uses a Codex permissions profile for network-proxy thread/resume requests", () => {
const request = buildThreadResumeParams(createAttemptParams({ provider: "openai" }), {
threadId: "thread-1",
appServer: createNetworkProxyAppServerOptions() as never,
developerInstructions: "test instructions",
});
expect(request.permissions).toEqual({ type: "profile", id: "mock-proxy" });
expect(request).not.toHaveProperty("sandbox");
expect(request.config).toMatchObject({
"features.network_proxy.enabled": true,
permissions: {
"mock-proxy": {
network: {
domains: {
"api.openai.com": "allow",
},
},
},
},
});
});
it("disables Codex tool-search features for nano models", () => {
const request = buildThreadStartParams(
createAttemptParams({ provider: "openai", modelId: "gpt-5.4-nano" }),
@@ -338,6 +492,8 @@ describe("Codex app-server native code mode config", () => {
"features.code_mode_only": false,
"features.apply_patch_streaming_events": true,
"features.multi_agent": false,
"features.standalone_web_search": false,
web_search: "cached",
});
});
@@ -376,6 +532,8 @@ describe("Codex app-server native code mode config", () => {
"features.code_mode": true,
"features.code_mode_only": true,
"features.apply_patch_streaming_events": true,
"features.standalone_web_search": false,
web_search: "cached",
});
});
@@ -395,6 +553,8 @@ describe("Codex app-server native code mode config", () => {
"features.code_mode": true,
"features.code_mode_only": true,
"features.apply_patch_streaming_events": true,
"features.standalone_web_search": false,
web_search: "cached",
});
});
@@ -409,6 +569,8 @@ describe("Codex app-server native code mode config", () => {
"features.code_mode": true,
"features.code_mode_only": false,
"features.apply_patch_streaming_events": true,
"features.standalone_web_search": false,
web_search: "cached",
});
});
@@ -430,6 +592,8 @@ describe("Codex app-server native code mode config", () => {
expect(request.config).toEqual({
"features.code_mode": false,
"features.code_mode_only": false,
"features.standalone_web_search": false,
web_search: "disabled",
});
});
@@ -447,6 +611,8 @@ describe("Codex app-server native code mode config", () => {
expect(request.config).toEqual({
"features.code_mode": false,
"features.code_mode_only": false,
"features.standalone_web_search": false,
web_search: "disabled",
});
});
@@ -475,6 +641,8 @@ describe("Codex app-server native code mode config", () => {
"features.code_mode": true,
"features.code_mode_only": false,
"features.apply_patch_streaming_events": true,
"features.standalone_web_search": false,
web_search: "cached",
});
});
@@ -496,6 +664,8 @@ describe("Codex app-server native code mode config", () => {
"features.code_mode": true,
"features.code_mode_only": false,
"features.apply_patch_streaming_events": true,
"features.standalone_web_search": false,
web_search: "cached",
});
});
});
@@ -524,6 +694,35 @@ describe("Codex app-server turn input image sanitizing", () => {
});
});
it("uses Codex permissions for network-proxy turn/start requests", () => {
const request = buildTurnStartParams(createAttemptParams({ provider: "openai" }), {
threadId: "thread-1",
cwd: "/repo",
appServer: createNetworkProxyAppServerOptions() as never,
});
expect(request).not.toHaveProperty("permissions");
expect(request).not.toHaveProperty("sandboxPolicy");
});
it("keeps explicit sandbox policy overrides ahead of network-proxy turn permissions", () => {
const request = buildTurnStartParams(createAttemptParams({ provider: "openai" }), {
threadId: "thread-1",
cwd: "/repo",
appServer: createNetworkProxyAppServerOptions() as never,
sandboxPolicy: {
type: "externalSandbox",
networkAccess: "enabled",
},
});
expect(request).not.toHaveProperty("permissions");
expect(request.sandboxPolicy).toEqual({
type: "externalSandbox",
networkAccess: "enabled",
});
});
it("attaches turn-scoped developer instructions without changing thread config", () => {
const request = buildTurnStartParams(createAttemptParams({ provider: "openai" }), {
threadId: "thread-1",
@@ -614,6 +813,8 @@ describe("Codex app-server turn params", () => {
"features.code_mode": true,
"features.code_mode_only": false,
"features.apply_patch_streaming_events": true,
"features.standalone_web_search": false,
web_search: "cached",
},
sandbox: "danger-full-access",
serviceTier: "flex",
@@ -815,6 +1016,52 @@ describe("Codex app-server model provider selection", () => {
expect(request.modelProvider).toBe("lmstudio");
});
it("uses provider-qualified model refs for thread capability selection", () => {
expect(
resolveCodexAppServerThreadModelSelection({
provider: "codex",
model: "amazon-bedrock/local-model",
}),
).toEqual({
model: "local-model",
modelProvider: "amazon-bedrock",
});
});
it("uses a matching bound provider for thread capability selection", () => {
expect(
resolveCodexAppServerThreadModelSelection({
provider: "codex",
model: "local-model",
binding: {
threadId: "thread-1",
model: "local-model",
modelProvider: "amazon-bedrock",
},
}),
).toEqual({
model: "local-model",
modelProvider: "amazon-bedrock",
});
});
it("prefers provider-qualified models over bound providers for thread capability selection", () => {
expect(
resolveCodexAppServerThreadModelSelection({
provider: "codex",
model: "openai/gpt-5.5",
binding: {
threadId: "thread-1",
model: "local-model",
modelProvider: "amazon-bedrock",
},
}),
).toEqual({
model: "gpt-5.5",
modelProvider: "openai",
});
});
it("normalizes provider-qualified model refs for turn/start metadata", () => {
const request = buildTurnStartParams(
createAttemptParams({ provider: "codex", modelId: "lmstudio/local-model" }),

View File

@@ -21,7 +21,10 @@ import {
resolveCodexContextEngineProjectionMaxChars,
resolveCodexContextEngineProjectionReserveTokens,
} from "./context-engine-projection.js";
import { shouldDisableCodexToolSearchForModel } from "./dynamic-tool-profile.js";
import {
normalizeCodexDynamicToolName,
shouldDisableCodexToolSearchForModel,
} from "./dynamic-tool-profile.js";
import { invalidInlineImageText, sanitizeInlineImageDataUrl } from "./image-payload-sanitizer.js";
import {
isCodexPluginThreadBindingStale,
@@ -36,6 +39,7 @@ import {
import {
isJsonObject,
type CodexDynamicToolSpec,
type CodexPermissionProfileSelection,
type CodexSandboxPolicy,
type CodexThreadResumeParams,
type CodexThreadStartParams,
@@ -55,6 +59,7 @@ import {
type CodexAppServerContextEngineProjectionBinding,
type CodexAppServerThreadBinding,
} from "./session-binding.js";
import { resolveCodexWebSearchPlan, type CodexNativeWebSearchSupport } from "./web-search.js";
export type CodexAppServerThreadLifecycle = {
action: "started" | "resumed";
@@ -287,6 +292,8 @@ export async function startOrResumeThread(params: {
agentId?: string;
cwd: string;
dynamicTools: CodexDynamicToolSpec[];
persistentWebSearchAllowed?: boolean;
webSearchAllowed?: boolean;
appServer: CodexAppServerRuntimeOptions;
developerInstructions?: string;
config?: JsonObject;
@@ -296,6 +303,7 @@ export async function startOrResumeThread(params: {
) => CodexThreadFinalConfigPatchResult;
nativeHookRelayGeneration?: string;
nativeCodeModeEnabled?: boolean;
nativeProviderWebSearchSupport?: CodexNativeWebSearchSupport;
nativeCodeModeOnlyEnabled?: boolean;
userMcpServersEnabled?: boolean;
mcpServersFingerprint?: string;
@@ -318,6 +326,16 @@ export async function startOrResumeThread(params: {
const dynamicToolsContainDeferred = params.dynamicTools.some(
(tool) => tool.deferLoading === true,
);
const webSearchPlan = lifecycleTiming.measureSync("web-search-plan", () =>
resolveCodexWebSearchPlan({
config: params.params.config,
disableTools: params.params.disableTools,
nativeToolSurfaceEnabled: params.nativeCodeModeEnabled,
nativeProviderWebSearchSupport: params.nativeProviderWebSearchSupport,
webSearchAllowed: params.webSearchAllowed,
}),
);
const webSearchThreadConfigFingerprint = fingerprintJsonObject(webSearchPlan.threadConfig);
const contextEngineBinding = lifecycleTiming.measureSync("context-engine-binding", () =>
buildContextEngineBinding(params.params, params.contextEngineProjection),
);
@@ -338,25 +356,20 @@ export async function startOrResumeThread(params: {
config: params.params.config,
}),
);
let startModelProvider: string | undefined;
if (binding?.threadId) {
const authProfileId = params.params.authProfileId ?? binding.authProfileId;
startModelProvider =
resolveCodexAppServerModelProvider({
provider: params.params.provider,
authProfileId,
authProfileStore: params.params.authProfileStore,
agentDir: params.params.agentDir,
config: params.params.config,
}) ??
resolveCodexBindingModelProviderFallback({
provider: params.params.provider,
currentModel: params.params.modelId,
bindingModel: binding.model,
bindingModelProvider: binding.modelProvider,
});
}
let preserveExistingBinding = false;
const startModelSelection = resolveCodexAppServerThreadModelSelection({
provider: params.params.provider,
model: params.params.modelId,
binding,
authProfileId: params.params.authProfileId,
authProfileStore: params.params.authProfileStore,
agentDir: params.params.agentDir,
config: params.params.config,
});
const startModelProvider = startModelSelection.modelProvider;
// Capability read failures use managed search for this turn but must not
// create a binding that later looks like a confirmed provider-policy change.
let preserveExistingBinding =
params.nativeProviderWebSearchSupport === "unknown" && !binding?.threadId;
let rotatedContextEngineBinding = false;
let prebuiltPluginThreadConfig: CodexPluginThreadConfig | undefined;
const throwIfAborted = () => {
@@ -375,7 +388,47 @@ export async function startOrResumeThread(params: {
error.name = "AbortError";
throw error;
};
if (binding?.threadId && params.nativeCodeModeEnabled === false) {
const webSearchBindingChanged =
binding?.threadId &&
binding.webSearchThreadConfigFingerprint !== webSearchThreadConfigFingerprint;
const persistentWebSearchRestriction =
params.webSearchAllowed === false && params.persistentWebSearchAllowed === false;
// A transient native-tool restriction must not replace a legacy binding just
// because that binding predates search fingerprints. Explicit persistent
// search denial still rotates first so the restricted thread can persist.
const deferLegacyWebSearchRotationToTransientNativeSurface =
params.nativeCodeModeEnabled === false &&
binding?.webSearchThreadConfigFingerprint === undefined &&
!persistentWebSearchRestriction;
if (
binding?.threadId &&
webSearchBindingChanged &&
!deferLegacyWebSearchRotationToTransientNativeSurface
) {
const transientWebSearchRestriction = isTransientWebSearchRestriction(params);
if (transientWebSearchRestriction) {
embeddedAgentLog.debug(
"codex app-server web search restricted for turn; starting transient thread",
{
threadId: binding.threadId,
},
);
preserveExistingBinding = true;
} else {
// Codex can ignore resume overrides for a loaded thread, so persistent
// search-policy changes and legacy bindings without metadata rotate first.
embeddedAgentLog.debug("codex app-server web search config changed; starting a new thread", {
threadId: binding.threadId,
});
await clearCodexAppServerBinding(params.params.sessionFile);
}
binding = undefined;
}
if (
binding?.threadId &&
params.nativeCodeModeEnabled === false &&
!persistentWebSearchRestriction
) {
embeddedAgentLog.debug(
"codex app-server native tool surface disabled for turn; starting transient thread",
{
@@ -553,13 +606,16 @@ export async function startOrResumeThread(params: {
buildThreadResumeParams(params.params, {
threadId: binding.threadId,
authProfileId,
model: startModelSelection.model,
modelProvider: startModelProvider,
appServer: params.appServer,
dynamicTools: params.dynamicTools,
developerInstructions: params.developerInstructions,
config: resumeConfig,
nativeCodeModeEnabled: params.nativeCodeModeEnabled,
nativeProviderWebSearchSupport: params.nativeProviderWebSearchSupport,
nativeCodeModeOnlyEnabled: params.nativeCodeModeOnlyEnabled,
webSearchAllowed: params.webSearchAllowed,
}),
);
const requestModelProvider =
@@ -588,8 +644,10 @@ export async function startOrResumeThread(params: {
modelProvider: response.modelProvider ?? requestModelProvider ?? startModelProvider,
dynamicToolsFingerprint,
dynamicToolsContainDeferred,
webSearchThreadConfigFingerprint,
userMcpServersFingerprint,
mcpServersFingerprint: nextMcpServersFingerprint,
networkProxyProfileName: params.appServer.networkProxy?.profileName,
nativeHookRelayGeneration:
finalConfigPatch.nativeHookRelayGeneration ?? binding.nativeHookRelayGeneration,
pluginAppsFingerprint: binding.pluginAppsFingerprint,
@@ -635,8 +693,10 @@ export async function startOrResumeThread(params: {
modelProvider: response.modelProvider ?? requestModelProvider ?? startModelProvider,
dynamicToolsFingerprint,
dynamicToolsContainDeferred,
webSearchThreadConfigFingerprint,
userMcpServersFingerprint,
mcpServersFingerprint: nextMcpServersFingerprint,
networkProxyProfileName: params.appServer.networkProxy?.profileName,
nativeHookRelayGeneration:
finalConfigPatch.nativeHookRelayGeneration ?? binding.nativeHookRelayGeneration,
pluginAppsFingerprint: binding.pluginAppsFingerprint,
@@ -687,8 +747,11 @@ export async function startOrResumeThread(params: {
developerInstructions: params.developerInstructions,
config,
nativeCodeModeEnabled: params.nativeCodeModeEnabled,
nativeProviderWebSearchSupport: params.nativeProviderWebSearchSupport,
nativeCodeModeOnlyEnabled: params.nativeCodeModeOnlyEnabled,
webSearchAllowed: params.webSearchAllowed,
environmentSelection: params.environmentSelection,
model: startModelSelection.model,
modelProvider: startModelProvider,
}),
);
@@ -731,8 +794,10 @@ export async function startOrResumeThread(params: {
response.modelProvider ?? requestModelProvider ?? startModelProvider ?? modelProvider,
dynamicToolsFingerprint,
dynamicToolsContainDeferred,
webSearchThreadConfigFingerprint,
userMcpServersFingerprint,
mcpServersFingerprint: nextMcpServersFingerprint,
networkProxyProfileName: params.appServer.networkProxy?.profileName,
nativeHookRelayGeneration: finalConfigPatch.nativeHookRelayGeneration,
pluginAppsFingerprint: pluginThreadConfig?.fingerprint,
pluginAppsInputFingerprint: pluginThreadConfig?.inputFingerprint,
@@ -781,6 +846,7 @@ export async function startOrResumeThread(params: {
dynamicToolsContainDeferred,
userMcpServersFingerprint,
mcpServersFingerprint: nextMcpServersFingerprint,
networkProxyProfileName: params.appServer.networkProxy?.profileName,
nativeHookRelayGeneration: finalConfigPatch.nativeHookRelayGeneration,
pluginAppsFingerprint: pluginThreadConfig?.fingerprint,
pluginAppsInputFingerprint: pluginThreadConfig?.inputFingerprint,
@@ -796,6 +862,45 @@ export async function startOrResumeThread(params: {
};
}
function isTransientWebSearchRestriction(
params: Pick<
Parameters<typeof startOrResumeThread>[0],
| "params"
| "nativeCodeModeEnabled"
| "nativeProviderWebSearchSupport"
| "persistentWebSearchAllowed"
| "webSearchAllowed"
>,
): boolean {
if (params.nativeProviderWebSearchSupport === "unknown") {
return true;
}
if (params.params.config?.tools?.web?.search?.enabled === false) {
return false;
}
if (params.params.disableTools === true) {
return true;
}
const persistentWebSearchRestriction =
params.webSearchAllowed === false && params.persistentWebSearchAllowed === false;
if (params.nativeCodeModeEnabled === false && !persistentWebSearchRestriction) {
return true;
}
if (params.webSearchAllowed !== false) {
return false;
}
if (params.persistentWebSearchAllowed !== undefined) {
return params.persistentWebSearchAllowed;
}
if (params.params.toolsAllow === undefined) {
return false;
}
return !params.params.toolsAllow.some((name) => {
const normalized = normalizeCodexDynamicToolName(name);
return normalized === "*" || normalized === "web_search";
});
}
export function buildContextEngineBinding(
params: EmbeddedRunAttemptParams,
projection?: CodexContextEngineThreadBootstrapProjection,
@@ -922,8 +1027,11 @@ export function buildThreadStartParams(
developerInstructions?: string;
config?: JsonObject;
nativeCodeModeEnabled?: boolean;
nativeProviderWebSearchSupport?: CodexNativeWebSearchSupport;
nativeCodeModeOnlyEnabled?: boolean;
webSearchAllowed?: boolean;
environmentSelection?: CodexTurnEnvironmentParams[];
model?: string | null;
modelProvider?: string | null;
},
): CodexThreadStartParams {
@@ -935,7 +1043,7 @@ export function buildThreadStartParams(
config: params.config,
});
const modelSelection = resolveCodexAppServerRequestModelSelection({
model: params.modelId,
model: options.model ?? params.modelId,
modelProvider: options.modelProvider ?? resolvedModelProvider,
authProfileId: params.authProfileId,
authProfileStore: params.authProfileStore,
@@ -948,13 +1056,16 @@ export function buildThreadStartParams(
cwd: options.cwd,
approvalPolicy: options.appServer.approvalPolicy,
approvalsReviewer: options.appServer.approvalsReviewer,
sandbox: options.appServer.sandbox,
...codexThreadSandboxOrPermissions(options.appServer),
...(options.appServer.serviceTier ? { serviceTier: options.appServer.serviceTier } : {}),
personality: CODEX_NATIVE_PERSONALITY_NONE,
serviceName: "OpenClaw",
config: buildCodexRuntimeThreadConfigForRun(params, options.config, {
nativeCodeModeEnabled: options.nativeCodeModeEnabled,
nativeProviderWebSearchSupport: options.nativeProviderWebSearchSupport,
nativeCodeModeOnlyEnabled: options.nativeCodeModeOnlyEnabled,
webSearchAllowed: options.webSearchAllowed,
appServer: options.appServer,
}),
...resolveCodexThreadEnvironmentSelection(options),
developerInstructions:
@@ -977,7 +1088,10 @@ export function buildThreadResumeParams(
developerInstructions?: string;
config?: JsonObject;
nativeCodeModeEnabled?: boolean;
nativeProviderWebSearchSupport?: CodexNativeWebSearchSupport;
nativeCodeModeOnlyEnabled?: boolean;
webSearchAllowed?: boolean;
model?: string | null;
},
): CodexThreadResumeParams {
const resolvedModelProvider = resolveCodexAppServerModelProvider({
@@ -988,7 +1102,7 @@ export function buildThreadResumeParams(
config: params.config,
});
const modelSelection = resolveCodexAppServerRequestModelSelection({
model: params.modelId,
model: options.model ?? params.modelId,
modelProvider: options.modelProvider ?? resolvedModelProvider,
authProfileId: options.authProfileId ?? params.authProfileId,
authProfileStore: params.authProfileStore,
@@ -1001,12 +1115,15 @@ export function buildThreadResumeParams(
...(modelSelection.modelProvider ? { modelProvider: modelSelection.modelProvider } : {}),
approvalPolicy: options.appServer.approvalPolicy,
approvalsReviewer: options.appServer.approvalsReviewer,
sandbox: options.appServer.sandbox,
...codexThreadSandboxOrPermissions(options.appServer),
...(options.appServer.serviceTier ? { serviceTier: options.appServer.serviceTier } : {}),
personality: CODEX_NATIVE_PERSONALITY_NONE,
config: buildCodexRuntimeThreadConfigForRun(params, options.config, {
nativeCodeModeEnabled: options.nativeCodeModeEnabled,
nativeProviderWebSearchSupport: options.nativeProviderWebSearchSupport,
nativeCodeModeOnlyEnabled: options.nativeCodeModeOnlyEnabled,
webSearchAllowed: options.webSearchAllowed,
appServer: options.appServer,
}),
developerInstructions:
options.developerInstructions ??
@@ -1038,6 +1155,44 @@ export function resolveCodexBindingModelProviderFallback(params: {
return hasProviderQualifiedModelRef(currentModel) ? undefined : params.bindingModelProvider;
}
export function resolveCodexAppServerThreadModelSelection(params: {
provider: string;
model: string;
binding?: Pick<
CodexAppServerThreadBinding,
"threadId" | "authProfileId" | "model" | "modelProvider"
>;
authProfileId?: string;
authProfileStore?: CodexAppServerAuthProfileLookup["authProfileStore"];
agentDir?: string;
config?: CodexAppServerAuthProfileLookup["config"];
}): { model: string; modelProvider?: string } {
const authProfileId = params.authProfileId ?? params.binding?.authProfileId;
const explicitModelProvider = resolveCodexAppServerModelProvider({
provider: params.provider,
authProfileId,
authProfileStore: params.authProfileStore,
agentDir: params.agentDir,
config: params.config,
});
const bindingModelProvider = params.binding?.threadId
? resolveCodexBindingModelProviderFallback({
provider: params.provider,
currentModel: params.model,
bindingModel: params.binding.model,
bindingModelProvider: params.binding.modelProvider,
})
: undefined;
return resolveCodexAppServerRequestModelSelection({
model: params.model,
modelProvider: explicitModelProvider ?? bindingModelProvider,
authProfileId,
authProfileStore: params.authProfileStore,
agentDir: params.agentDir,
config: params.config,
});
}
export function resolveCodexAppServerRequestModelSelection(params: {
model: string;
modelProvider?: string | null;
@@ -1117,12 +1272,29 @@ export function buildCodexRuntimeThreadConfig(
function buildCodexRuntimeThreadConfigForRun(
params: EmbeddedRunAttemptParams,
config: JsonObject | undefined,
options: { nativeCodeModeEnabled?: boolean; nativeCodeModeOnlyEnabled?: boolean } = {},
options: {
nativeCodeModeEnabled?: boolean;
nativeProviderWebSearchSupport?: CodexNativeWebSearchSupport;
nativeCodeModeOnlyEnabled?: boolean;
webSearchAllowed?: boolean;
appServer?: Pick<CodexAppServerRuntimeOptions, "networkProxy">;
} = {},
): JsonObject {
const baseConfig = buildCodexRuntimeThreadConfig(config, options);
const webSearchConfig = resolveCodexWebSearchPlan({
config: params.config,
disableTools: params.disableTools,
nativeToolSurfaceEnabled: options.nativeCodeModeEnabled,
nativeProviderWebSearchSupport: options.nativeProviderWebSearchSupport,
webSearchAllowed: options.webSearchAllowed,
}).threadConfig;
const baseConfig = buildCodexRuntimeThreadConfig(
mergeCodexThreadConfigs(config, webSearchConfig),
options,
);
const runtimeConfig =
mergeCodexThreadConfigs(
baseConfig,
options.appServer?.networkProxy?.configPatch,
shouldDisableCodexToolSearchForModel(params.modelId)
? CODEX_TOOL_SEARCH_UNSUPPORTED_THREAD_CONFIG
: undefined,
@@ -1163,14 +1335,20 @@ export function buildTurnStartParams(
agentDir: params.agentDir,
config: params.config,
});
const useThreadPermissionProfile = options.appServer.networkProxy && !options.sandboxPolicy;
return {
threadId: options.threadId,
input: buildUserInput(params, options.promptText),
cwd: options.cwd,
approvalPolicy: options.appServer.approvalPolicy,
approvalsReviewer: options.appServer.approvalsReviewer,
sandboxPolicy:
options.sandboxPolicy ?? codexSandboxPolicyForTurn(options.appServer.sandbox, options.cwd),
...(useThreadPermissionProfile
? {}
: {
sandboxPolicy:
options.sandboxPolicy ??
codexSandboxPolicyForTurn(options.appServer.sandbox, options.cwd),
}),
model: modelSelection.model,
personality: CODEX_NATIVE_PERSONALITY_NONE,
...(options.appServer.serviceTier ? { serviceTier: options.appServer.serviceTier } : {}),
@@ -1186,6 +1364,20 @@ export function buildTurnStartParams(
};
}
function codexThreadSandboxOrPermissions(
appServer: Pick<CodexAppServerRuntimeOptions, "networkProxy" | "sandbox">,
): Pick<CodexThreadStartParams, "permissions" | "sandbox"> {
const permissionProfile = appServer.networkProxy?.profileName;
if (permissionProfile) {
return { permissions: codexPermissionProfileSelection(permissionProfile) };
}
return { sandbox: appServer.sandbox };
}
function codexPermissionProfileSelection(profileName: string): CodexPermissionProfileSelection {
return { type: "profile", id: profileName };
}
function resolveCodexThreadEnvironmentSelection(options: {
nativeCodeModeEnabled?: boolean;
environmentSelection?: CodexTurnEnvironmentParams[];
@@ -1315,6 +1507,10 @@ function fingerprintUserMcpServersConfigPatch(
return configPatch ? JSON.stringify(stabilizeJsonValue(configPatch)) : undefined;
}
function fingerprintJsonObject(value: JsonObject): string {
return JSON.stringify(stabilizeJsonValue(value));
}
function fingerprintEnvironmentSelection(
environments: CodexTurnEnvironmentParams[] | undefined,
): string | undefined {

View File

@@ -0,0 +1,195 @@
import { describe, expect, it } from "vitest";
import { resolveCodexWebSearchPlan } from "./web-search.js";
describe("resolveCodexWebSearchPlan", () => {
it("uses Codex hosted web search by default when no managed provider is selected", () => {
expect(resolveCodexWebSearchPlan({})).toEqual({
kind: "native-hosted",
suppressManagedWebSearch: true,
threadConfig: {
"features.standalone_web_search": false,
web_search: "cached",
},
});
});
it("projects Codex native web search tuning into thread config", () => {
const plan = resolveCodexWebSearchPlan({
config: {
tools: {
web: {
search: {
openaiCodex: {
enabled: true,
mode: "live",
allowedDomains: [" example.com ", "example.com", ""],
contextSize: "high",
userLocation: {
country: " CA ",
region: " Alberta ",
city: " Edmonton ",
timezone: "America/Edmonton",
},
},
},
},
},
},
});
expect(plan).toEqual({
kind: "native-hosted",
suppressManagedWebSearch: true,
threadConfig: {
"features.standalone_web_search": false,
web_search: "live",
"tools.web_search.allowed_domains": ["example.com"],
"tools.web_search.context_size": "high",
"tools.web_search.location.country": "CA",
"tools.web_search.location.region": "Alberta",
"tools.web_search.location.city": "Edmonton",
"tools.web_search.location.timezone": "America/Edmonton",
},
});
});
it("keeps managed web_search when an explicit managed provider is selected", () => {
expect(
resolveCodexWebSearchPlan({
config: {
tools: {
web: {
search: { provider: "brave" },
},
},
},
}),
).toEqual({
kind: "managed",
suppressManagedWebSearch: false,
threadConfig: {
"features.standalone_web_search": false,
web_search: "disabled",
},
});
});
it("keeps managed web_search for an explicit Codex native search opt-out", () => {
expect(
resolveCodexWebSearchPlan({
config: {
tools: {
web: {
search: { openaiCodex: { enabled: false } },
},
},
},
}),
).toEqual({
kind: "managed",
suppressManagedWebSearch: false,
threadConfig: {
"features.standalone_web_search": false,
web_search: "disabled",
},
});
});
it("keeps managed web_search when runtime policy disables Codex native tools", () => {
expect(resolveCodexWebSearchPlan({ nativeToolSurfaceEnabled: false })).toEqual({
kind: "managed",
suppressManagedWebSearch: false,
threadConfig: {
"features.standalone_web_search": false,
web_search: "disabled",
},
});
});
it("keeps managed web_search when the active Codex provider lacks hosted search", () => {
expect(resolveCodexWebSearchPlan({ nativeProviderWebSearchSupport: "unsupported" })).toEqual({
kind: "managed",
suppressManagedWebSearch: false,
threadConfig: {
"features.standalone_web_search": false,
web_search: "disabled",
},
});
});
it("keeps managed web_search when active provider support is unknown", () => {
expect(resolveCodexWebSearchPlan({ nativeProviderWebSearchSupport: "unknown" })).toEqual({
kind: "managed",
suppressManagedWebSearch: false,
threadConfig: {
"features.standalone_web_search": false,
web_search: "disabled",
},
});
});
it("fails closed instead of bypassing native domain restrictions through managed fallback", () => {
expect(
resolveCodexWebSearchPlan({
config: {
tools: {
web: {
search: { openaiCodex: { allowedDomains: ["example.com"] } },
},
},
},
nativeProviderWebSearchSupport: "unsupported",
}),
).toEqual({
kind: "disabled",
suppressManagedWebSearch: true,
threadConfig: {
"features.standalone_web_search": false,
web_search: "disabled",
},
});
});
it("disables native and managed search for tool-disabled runs", () => {
expect(resolveCodexWebSearchPlan({ disableTools: true })).toEqual({
kind: "disabled",
suppressManagedWebSearch: true,
threadConfig: {
"features.standalone_web_search": false,
web_search: "disabled",
},
});
});
it("disables native and managed search when effective tool policy denies web_search", () => {
expect(resolveCodexWebSearchPlan({ webSearchAllowed: false })).toEqual({
kind: "disabled",
suppressManagedWebSearch: true,
threadConfig: {
"features.standalone_web_search": false,
web_search: "disabled",
},
});
});
it("disables both native and managed search when OpenClaw web search is disabled", () => {
expect(
resolveCodexWebSearchPlan({
config: {
tools: {
web: {
search: { enabled: false },
},
},
},
}),
).toEqual({
kind: "disabled",
suppressManagedWebSearch: true,
threadConfig: {
"features.standalone_web_search": false,
web_search: "disabled",
},
});
});
});

View File

@@ -0,0 +1,132 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import type { JsonObject } from "./protocol.js";
export type CodexWebSearchPlan = {
kind: "native-hosted" | "managed" | "disabled";
suppressManagedWebSearch: boolean;
threadConfig: JsonObject;
};
export type CodexNativeWebSearchSupport = "supported" | "unsupported" | "unknown";
const CODEX_NATIVE_WEB_SEARCH_DISABLED_CONFIG: JsonObject = {
"features.standalone_web_search": false,
web_search: "disabled",
};
function normalizeOptionalString(value: unknown): string | undefined {
return typeof value === "string" ? value.trim() || undefined : undefined;
}
function normalizeUniqueStrings(value: unknown): string[] | undefined {
if (!Array.isArray(value)) {
return undefined;
}
const normalized = [
...new Set(
value.map(normalizeOptionalString).filter((entry): entry is string => Boolean(entry)),
),
];
return normalized.length > 0 ? normalized : undefined;
}
function hasManagedSearchProvider(config: OpenClawConfig | undefined): boolean {
return normalizeOptionalString(config?.tools?.web?.search?.provider) !== undefined;
}
function hasNativeDomainRestrictions(config: OpenClawConfig | undefined): boolean {
return (
normalizeUniqueStrings(config?.tools?.web?.search?.openaiCodex?.allowedDomains) !== undefined
);
}
export function buildCodexNativeWebSearchThreadConfig(
config: OpenClawConfig | undefined,
): JsonObject {
const nativeConfig = config?.tools?.web?.search?.openaiCodex;
const threadConfig: JsonObject = {
// Production app-server traffic rejects standalone web.run's user-defined
// `web` namespace. Hosted web_search emits the same native search items.
"features.standalone_web_search": false,
// Codex treats cached as a preference and resolves it to live for
// unrestricted permission profiles.
web_search: nativeConfig?.mode === "live" ? "live" : "cached",
};
const allowedDomains = normalizeUniqueStrings(nativeConfig?.allowedDomains);
if (allowedDomains) {
threadConfig["tools.web_search.allowed_domains"] = allowedDomains;
}
if (nativeConfig?.contextSize) {
threadConfig["tools.web_search.context_size"] = nativeConfig.contextSize;
}
const location = nativeConfig?.userLocation;
const country = normalizeOptionalString(location?.country);
const region = normalizeOptionalString(location?.region);
const city = normalizeOptionalString(location?.city);
const timezone = normalizeOptionalString(location?.timezone);
if (country) {
threadConfig["tools.web_search.location.country"] = country;
}
if (region) {
threadConfig["tools.web_search.location.region"] = region;
}
if (city) {
threadConfig["tools.web_search.location.city"] = city;
}
if (timezone) {
threadConfig["tools.web_search.location.timezone"] = timezone;
}
return threadConfig;
}
export function resolveCodexWebSearchPlan(params: {
config?: OpenClawConfig;
disableTools?: boolean;
nativeToolSurfaceEnabled?: boolean;
nativeProviderWebSearchSupport?: CodexNativeWebSearchSupport;
webSearchAllowed?: boolean;
}): CodexWebSearchPlan {
if (
params.disableTools === true ||
params.webSearchAllowed === false ||
params.config?.tools?.web?.search?.enabled === false
) {
return {
kind: "disabled",
suppressManagedWebSearch: true,
threadConfig: CODEX_NATIVE_WEB_SEARCH_DISABLED_CONFIG,
};
}
const nativeConfig = params.config?.tools?.web?.search?.openaiCodex;
const managedSearchExplicit =
hasManagedSearchProvider(params.config) || nativeConfig?.enabled === false;
const nativeProviderSupportsSearch =
params.nativeProviderWebSearchSupport === undefined ||
params.nativeProviderWebSearchSupport === "supported";
const nativeSearchEnabled =
params.nativeToolSurfaceEnabled !== false &&
nativeProviderSupportsSearch &&
nativeConfig?.enabled !== false &&
!hasManagedSearchProvider(params.config);
if (!nativeSearchEnabled) {
if (!managedSearchExplicit && hasNativeDomainRestrictions(params.config)) {
return {
kind: "disabled",
suppressManagedWebSearch: true,
threadConfig: CODEX_NATIVE_WEB_SEARCH_DISABLED_CONFIG,
};
}
return {
kind: "managed",
suppressManagedWebSearch: false,
threadConfig: CODEX_NATIVE_WEB_SEARCH_DISABLED_CONFIG,
};
}
return {
kind: "native-hosted",
// Native and managed search must stay mutually exclusive. In particular,
// exposing managed web_search here could bypass native allowed_domains.
suppressManagedWebSearch: true,
threadConfig: buildCodexNativeWebSearchThreadConfig(params.config),
};
}

View File

@@ -180,6 +180,54 @@ describe("codex conversation binding", () => {
);
});
it("uses Codex permissions for network-proxy app-server bind threads", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const requests: Array<{ method: string; params: Record<string, unknown> }> = [];
sharedClientMocks.getSharedCodexAppServerClient.mockResolvedValue({
request: vi.fn(async (method: string, requestParams: Record<string, unknown>) => {
requests.push({ method, params: requestParams });
return {
thread: { id: "thread-new", sessionId: "session-1", cwd: tempDir },
model: "gpt-5.4-mini",
};
}),
});
await startCodexConversationThread({
pluginConfig: {
appServer: {
networkProxy: {
enabled: true,
domains: { "api.openai.com": "allow" },
allowUpstreamProxy: true,
proxyUrl: "http://127.0.0.1:3128",
},
},
},
sessionFile,
workspaceDir: tempDir,
model: "gpt-5.4-mini",
modelProvider: "openai",
});
expect(requests).toHaveLength(1);
expect(requests[0]?.method).toBe("thread/start");
expect(requests[0]?.params.permissions).toEqual({ type: "profile", id: "openclaw-network" });
expect(requests[0]?.params).not.toHaveProperty("sandbox");
expect(requests[0]?.params.config).toMatchObject({
"features.network_proxy.enabled": true,
permissions: {
"openclaw-network": {
network: {
domains: { "api.openai.com": "allow" },
allow_upstream_proxy: true,
proxy_url: "http://127.0.0.1:3128",
},
},
},
});
});
it("preserves Codex auth and omits the public OpenAI provider for native bind threads", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
agentRuntimeMocks.ensureAuthProfileStore.mockReturnValue({
@@ -937,7 +985,7 @@ describe("codex conversation binding", () => {
await fs.writeFile(
`${sessionFile}.codex-app-server.json`,
JSON.stringify({
schemaVersion: 1,
schemaVersion: 2,
threadId: "thread-1",
cwd: tempDir,
approvalPolicy: "never",
@@ -1126,6 +1174,7 @@ describe("codex conversation binding", () => {
schemaVersion: 1,
threadId: "thread-1",
cwd: tempDir,
networkProxyProfileName: "openclaw-network",
}),
);
let notificationHandler: ((notification: unknown) => void) | undefined;
@@ -1203,6 +1252,92 @@ describe("codex conversation binding", () => {
});
});
it("uses Codex permissions for network-proxy bound app-server turns", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
await fs.writeFile(
`${sessionFile}.codex-app-server.json`,
JSON.stringify({
schemaVersion: 2,
threadId: "thread-1",
cwd: tempDir,
networkProxyProfileName: "openclaw-network",
}),
);
let notificationHandler: ((notification: unknown) => void) | undefined;
const turnStartParams: Record<string, unknown>[] = [];
sharedClientMocks.getSharedCodexAppServerClient.mockResolvedValue({
request: vi.fn(async (method: string, requestParams: Record<string, unknown>) => {
if (method === "turn/start") {
turnStartParams.push(requestParams);
setImmediate(() =>
notificationHandler?.({
method: "turn/completed",
params: {
threadId: "thread-1",
turn: {
id: "turn-1",
status: "completed",
items: [{ type: "agentMessage", id: "item-1", text: "done" }],
},
},
}),
);
return { turn: { id: "turn-1" } };
}
throw new Error(`unexpected method: ${method}`);
}),
addNotificationHandler: vi.fn((handler: (notification: unknown) => void) => {
notificationHandler = handler;
return () => undefined;
}),
addRequestHandler: vi.fn(() => () => undefined),
});
const result = await handleCodexConversationInboundClaim(
{
content: "hello",
channel: "telegram",
isGroup: false,
commandAuthorized: true,
},
{
channelId: "telegram",
pluginBinding: {
bindingId: "binding-1",
pluginId: "codex",
pluginRoot: tempDir,
channel: "telegram",
accountId: "default",
conversationId: "5185575566",
boundAt: Date.now(),
data: {
kind: "codex-app-server-session",
version: 1,
sessionFile,
workspaceDir: tempDir,
},
},
},
{
pluginConfig: {
appServer: {
networkProxy: {
enabled: true,
domains: { "api.openai.com": "allow" },
allowUpstreamProxy: true,
proxyUrl: "http://127.0.0.1:3128",
},
},
},
timeoutMs: 50,
},
);
expect(result).toEqual({ handled: true, reply: { text: "done" } });
expect(turnStartParams[0]).not.toHaveProperty("permissions");
expect(turnStartParams[0]).not.toHaveProperty("sandboxPolicy");
});
it("blocks Guardian-mode bound turns with stale no-approval policy on custom model providers", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
await fs.writeFile(

View File

@@ -30,9 +30,11 @@ import {
} from "./app-server/config.js";
import type {
CodexServiceTier,
CodexPermissionProfileSelection,
CodexThreadResumeResponse,
CodexThreadStartResponse,
CodexTurnStartResponse,
JsonObject,
JsonValue,
} from "./app-server/protocol.js";
import {
@@ -415,22 +417,43 @@ function buildThreadRequestRuntimeOptions(
): {
approvalPolicy: ConversationAppServerRuntime["runtime"]["approvalPolicy"];
approvalsReviewer: ConversationAppServerRuntime["runtime"]["approvalsReviewer"];
sandbox: ConversationAppServerRuntime["runtime"]["sandbox"];
sandbox?: ConversationAppServerRuntime["runtime"]["sandbox"];
serviceTier?: CodexServiceTier;
permissions?: CodexPermissionProfileSelection;
config?: JsonObject;
} {
const serviceTier = params.serviceTier ?? resolved.runtime.serviceTier;
const sandbox = resolved.execPolicy?.touched
? resolved.runtime.sandbox
: (params.sandbox ?? resolved.runtime.sandbox);
return {
approvalPolicy: resolved.execPolicy?.touched
? resolved.runtime.approvalPolicy
: (params.approvalPolicy ?? resolved.runtime.approvalPolicy),
approvalsReviewer: resolved.runtime.approvalsReviewer,
sandbox: resolved.execPolicy?.touched
? resolved.runtime.sandbox
: (params.sandbox ?? resolved.runtime.sandbox),
...codexConversationSandboxOrPermissions(resolved.runtime, sandbox),
...(serviceTier ? { serviceTier } : {}),
};
}
function codexConversationSandboxOrPermissions(
runtime: Pick<ConversationAppServerRuntime["runtime"], "networkProxy">,
sandbox: ConversationAppServerRuntime["runtime"]["sandbox"],
): {
sandbox?: ConversationAppServerRuntime["runtime"]["sandbox"];
permissions?: CodexPermissionProfileSelection;
config?: JsonObject;
} {
const networkProxy = runtime.networkProxy;
if (networkProxy) {
return {
permissions: { type: "profile", id: networkProxy.profileName },
config: networkProxy.configPatch,
};
}
return { sandbox };
}
async function writeThreadBindingFromResponse(
params: CodexThreadBindingParams,
resolved: CodexThreadBindingRuntime,
@@ -459,6 +482,7 @@ async function writeThreadBindingFromResponse(
? resolved.runtime.sandbox
: (params.sandbox ?? resolved.runtime.sandbox),
serviceTier: params.serviceTier ?? resolved.runtime.serviceTier,
networkProxyProfileName: resolved.runtime.networkProxy?.profileName,
},
{
...resolved.agentLookup,
@@ -568,6 +592,9 @@ async function runBoundTurn(params: {
const sandbox = useModelScopedPolicy
? modelScopedRuntime.sandbox
: (binding.sandbox ?? modelScopedRuntime.sandbox);
const permissionProfile = modelScopedRuntime.networkProxy?.profileName;
const useStickyNetworkProfile =
permissionProfile !== undefined && binding.networkProxyProfileName === permissionProfile;
assertNativeConversationApprovalPolicySupported({
execPolicy,
approvalPolicy,
@@ -641,7 +668,9 @@ async function runBoundTurn(params: {
cwd: workspaceDir,
approvalPolicy,
approvalsReviewer: modelScopedRuntime.approvalsReviewer,
sandboxPolicy: codexSandboxPolicyForTurn(sandbox, workspaceDir),
...(useStickyNetworkProfile
? {}
: { sandboxPolicy: codexSandboxPolicyForTurn(sandbox, workspaceDir) }),
...(modelSelection?.model ? { model: modelSelection.model } : {}),
personality: CODEX_NATIVE_PERSONALITY_NONE,
...((binding.serviceTier ?? runtime.serviceTier)

View File

@@ -6,6 +6,11 @@ import { MANAGED_CODEX_APP_SERVER_PACKAGE_VERSION } from "./app-server/version.j
type CodexPackageManifest = {
dependencies?: Record<string, string>;
devDependencies?: Record<string, string>;
openclaw?: {
install?: {
requiredPlatformPackages?: string[];
};
};
};
describe("codex package manifest", () => {
@@ -18,5 +23,13 @@ describe("codex package manifest", () => {
expect(packageJson.dependencies?.["@openai/codex"]).toBe(
MANAGED_CODEX_APP_SERVER_PACKAGE_VERSION,
);
expect(packageJson.openclaw?.install?.requiredPlatformPackages).toEqual([
"@openai/codex-linux-x64",
"@openai/codex-linux-arm64",
"@openai/codex-darwin-x64",
"@openai/codex-darwin-arm64",
"@openai/codex-win32-x64",
"@openai/codex-win32-arm64",
]);
});
});

View File

@@ -0,0 +1,99 @@
import {
readStringParam,
resolveSearchTimeoutSeconds,
type SearchConfigRecord,
type WebSearchProviderToolExecutionContext,
wrapWebContent,
} from "openclaw/plugin-sdk/provider-web-search";
import type { WebSearchProviderPlugin } from "openclaw/plugin-sdk/provider-web-search-contract";
import {
runBoundedCodexAppServerTurn,
type CodexBoundedTurnOptions,
} from "./app-server/bounded-turn.js";
import { isJsonObject, type CodexThreadItem, type JsonObject } from "./app-server/protocol.js";
import { buildCodexNativeWebSearchThreadConfig } from "./app-server/web-search.js";
type WebSearchProviderContext = Parameters<WebSearchProviderPlugin["createTool"]>[0];
export async function executeCodexWebSearchProviderTool(
ctx: WebSearchProviderContext,
args: Record<string, unknown>,
executionContext: WebSearchProviderToolExecutionContext | undefined,
options: CodexBoundedTurnOptions,
): Promise<Record<string, unknown>> {
const query = readStringParam(args, "query", { required: true });
const start = Date.now();
const result = await runBoundedCodexAppServerTurn({
config: ctx.config,
model: { mode: "live-default" },
timeoutMs: resolveSearchTimeoutSeconds(ctx.searchConfig as SearchConfigRecord) * 1_000,
signal: executionContext?.signal,
agentDir: ctx.agentDir,
options,
taskLabel: "hosted search",
developerInstructions:
"You are OpenClaw's bounded web-search worker. You must use Codex hosted web_search to answer the user's search query. Return a concise grounded answer with source URLs. Do not call other tools, edit files, or ask follow-up questions.",
input: [{ type: "text", text: query, text_elements: [] }],
requiredModalities: ["text"],
isolation: "private-stdio",
threadConfig: buildCodexNativeWebSearchThreadConfig(ctx.config),
});
const searches = result.items
.filter((item) => item.type === "webSearch")
.map(summarizeCodexWebSearchItem);
if (searches.length === 0) {
throw new Error("Codex hosted search completed without invoking web search.");
}
return {
query,
provider: "codex",
model: result.model,
tookMs: Date.now() - start,
externalContent: {
untrusted: true,
source: "web_search",
provider: "codex",
wrapped: true,
},
content: wrapWebContent(result.text, "web_search"),
searches,
};
}
function summarizeCodexWebSearchItem(item: CodexThreadItem): Record<string, unknown> {
const action = isJsonObject(item.action) ? item.action : undefined;
const actionType = readNonEmptyString(action, "type");
const queries = actionType === "search" ? readNonEmptyStringArray(action, "queries") : [];
const query =
normalizeNonEmptyString(item.query) ??
(actionType === "search" ? readNonEmptyString(action, "query") : undefined) ??
queries[0];
const url = readNonEmptyString(action, "url");
const pattern = readNonEmptyString(action, "pattern");
return {
...(query ? { query } : {}),
...(queries.length > 0 ? { queries } : {}),
...(actionType && actionType !== "search" ? { action: actionType } : {}),
...(url ? { url } : {}),
...(pattern ? { pattern } : {}),
};
}
function readNonEmptyString(record: JsonObject | undefined, key: string): string | undefined {
return record ? normalizeNonEmptyString(record[key]) : undefined;
}
function readNonEmptyStringArray(record: JsonObject | undefined, key: string): string[] {
const value = record?.[key];
if (!Array.isArray(value)) {
return [];
}
return value.flatMap((entry) => {
const normalized = normalizeNonEmptyString(entry);
return normalized ? [normalized] : [];
});
}
function normalizeNonEmptyString(value: unknown): string | undefined {
return typeof value === "string" ? value.trim() || undefined : undefined;
}

View File

@@ -0,0 +1,36 @@
import {
createWebSearchProviderContractFields,
type WebSearchProviderPlugin,
} from "openclaw/plugin-sdk/provider-web-search-contract";
export function createCodexWebSearchProviderBase(): Omit<WebSearchProviderPlugin, "createTool"> {
return {
id: "codex",
label: "Codex Hosted Search",
hint: "Grounded answers through your Codex app-server account",
onboardingScopes: ["text-inference"],
requiresCredential: false,
envVars: [],
placeholder: "(uses Codex sign-in)",
signupUrl: "https://chatgpt.com/codex",
docsUrl: "https://docs.openclaw.ai/tools/web",
autoDetectOrder: 900,
credentialPath: "",
...createWebSearchProviderContractFields({
credentialPath: "",
searchCredential: { type: "none" },
selectionPluginId: "codex",
}),
runSetup: async (ctx) => {
await ctx.prompter.note(
[
"Codex Hosted Search uses the bundled Codex app-server and your Codex/OpenAI sign-in.",
"If needed, sign in with: openclaw models auth login --provider openai",
"Verify the app-server account with /codex status.",
].join("\n"),
"Codex Hosted Search",
);
return ctx.config;
},
};
}

View File

@@ -0,0 +1,384 @@
import path from "node:path";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { describe, expect, it, vi } from "vitest";
import { createCodexWebSearchProvider as createContractCodexWebSearchProvider } from "../web-search-contract-api.js";
import type { CodexAppServerClient } from "./app-server/client.js";
import type { CodexAppServerStartOptions } from "./app-server/config.js";
import type { CodexServerNotification, JsonValue } from "./app-server/protocol.js";
import { createCodexWebSearchProvider } from "./web-search-provider.js";
function codexModel(
options: {
id?: string;
model?: string;
inputModalities?: string[];
isDefault?: boolean;
} = {},
) {
const id = options.id ?? "gpt-5.5";
return {
id,
model: options.model ?? id,
upgrade: null,
upgradeInfo: null,
availabilityNux: null,
displayName: "gpt-5.5",
description: "GPT-5.5",
hidden: false,
supportedReasoningEfforts: [{ reasoningEffort: "low", description: "fast" }],
defaultReasoningEffort: "low",
inputModalities: options.inputModalities ?? ["text", "image"],
supportsPersonality: false,
additionalSpeedTiers: [],
isDefault: options.isDefault ?? true,
};
}
function threadStartResult() {
return {
thread: {
id: "thread-1",
sessionId: "session-1",
forkedFromId: null,
preview: "",
ephemeral: true,
modelProvider: "openai",
createdAt: 1,
updatedAt: 1,
status: { type: "idle" },
path: null,
cwd: "/tmp/openclaw-agent",
cliVersion: "0.125.0",
source: "unknown",
agentNickname: null,
agentRole: null,
gitInfo: null,
name: null,
turns: [],
},
model: "gpt-5.5",
modelProvider: "openai",
serviceTier: null,
cwd: "/tmp/openclaw-agent",
instructionSources: [],
approvalPolicy: "on-request",
approvalsReviewer: "user",
sandbox: { type: "dangerFullAccess" },
permissionProfile: null,
reasoningEffort: null,
};
}
function turnStartResult(status = "inProgress") {
return {
turn: {
id: "turn-1",
status,
items: [],
error: null,
startedAt: null,
completedAt: null,
durationMs: null,
},
};
}
function createFakeClient(options?: {
emitWebSearch?: boolean;
models?: ReturnType<typeof codexModel>[];
}) {
const notifications = new Set<(notification: CodexServerNotification) => void>();
const requests: Array<{ method: string; params?: JsonValue }> = [];
const request = vi.fn(async (method: string, params?: JsonValue) => {
requests.push({ method, params });
if (method === "model/list") {
return { data: options?.models ?? [codexModel()], nextCursor: null };
}
if (method === "thread/start") {
return threadStartResult();
}
if (method === "turn/start") {
for (const notify of notifications) {
if (options?.emitWebSearch !== false) {
notify({
method: "item/completed",
params: {
threadId: "thread-1",
turnId: "turn-1",
item: {
id: "search-1",
type: "webSearch",
query: "plumbers in Edmonton Alberta",
action: {
type: "search",
query: "plumbers in Edmonton Alberta",
queries: ["plumbers in Edmonton Alberta"],
},
},
},
});
}
notify({
method: "item/agentMessage/delta",
params: {
threadId: "thread-1",
turnId: "turn-1",
itemId: "msg-1",
delta: "Two current providers: Example One and Example Two.",
},
});
notify({
method: "turn/completed",
params: {
threadId: "thread-1",
turnId: "turn-1",
turn: turnStartResult("completed").turn,
},
});
}
return turnStartResult();
}
return {};
});
const client = {
request,
addNotificationHandler(handler: (notification: CodexServerNotification) => void) {
notifications.add(handler);
return () => notifications.delete(handler);
},
addRequestHandler() {
return () => {};
},
} as unknown as CodexAppServerClient;
return { client, requests };
}
function createConfig(): OpenClawConfig {
return {
tools: {
web: {
search: {
provider: "codex",
timeoutSeconds: 30,
openaiCodex: {
enabled: true,
mode: "live",
allowedDomains: ["example.com"],
contextSize: "high",
userLocation: {
country: "CA",
region: "Alberta",
city: "Edmonton",
timezone: "America/Edmonton",
},
},
},
},
},
};
}
describe("codex web search provider", () => {
it("registers a selectable keyless provider contract", () => {
const provider = createContractCodexWebSearchProvider();
expect(provider.id).toBe("codex");
expect(provider.label).toBe("Codex Hosted Search");
expect(provider.requiresCredential).toBe(false);
expect(provider.envVars).toEqual([]);
expect(provider.autoDetectOrder).toBe(900);
expect(provider.applySelectionConfig?.({}).plugins?.entries?.codex?.enabled).toBe(true);
});
it("honors the explicit Codex hosted-search opt-out", () => {
const provider = createCodexWebSearchProvider();
expect(
provider.createTool({
searchConfig: { provider: "codex", openaiCodex: { enabled: false } },
}),
).toBeNull();
});
it("fails closed when configured app-server transport cannot be isolated", async () => {
const { client } = createFakeClient();
const provider = createCodexWebSearchProvider({
resolvePluginConfig: () => ({
appServer: {
transport: "websocket",
url: "ws://127.0.0.1:4501",
},
}),
clientFactory: async () => client,
});
const config = createConfig();
const tool = provider.createTool({
config,
searchConfig: config.tools?.web?.search,
agentDir: "/tmp/openclaw-agent",
});
await expect(tool?.execute({ query: "plumbers in Edmonton Alberta" })).rejects.toThrow(
"Bounded Codex turns require stdio transport so native tools can be isolated.",
);
});
it("runs an isolated grounded Codex search with configured restrictions", async () => {
const { client, requests } = createFakeClient();
let isolatedStartOptions: CodexAppServerStartOptions | undefined;
const provider = createCodexWebSearchProvider({
resolvePluginConfig: () => ({
appServer: {
args: [
"app-server",
"--listen",
"stdio://",
"-c",
"mcp_servers.external.command='unsafe'",
],
clearEnv: ["CODEX_HOME", "KEEP_CLEARED"],
},
}),
clientFactory: async (startOptions) => {
isolatedStartOptions = startOptions;
return client;
},
});
const config = createConfig();
const tool = provider.createTool({
config,
searchConfig: config.tools?.web?.search,
agentDir: "/tmp/openclaw-agent",
});
const result = await tool?.execute({ query: "plumbers in Edmonton Alberta" });
expect(result).toMatchObject({
query: "plumbers in Edmonton Alberta",
provider: "codex",
model: "gpt-5.5",
externalContent: {
untrusted: true,
source: "web_search",
provider: "codex",
wrapped: true,
},
searches: [
{
query: "plumbers in Edmonton Alberta",
queries: ["plumbers in Edmonton Alberta"],
},
],
});
expect(result?.content).toContain("Two current providers");
expect(requests.map((entry) => entry.method)).toEqual([
"model/list",
"thread/start",
"turn/start",
]);
expect(requests[1]?.params).toMatchObject({
model: "gpt-5.5",
modelProvider: "openai",
cwd: expect.any(String),
approvalPolicy: "on-request",
sandbox: "read-only",
environments: [],
dynamicTools: [],
ephemeral: true,
config: {
"features.code_mode": false,
"features.code_mode_only": false,
"features.hooks": false,
"features.standalone_web_search": false,
notify: [],
web_search: "live",
"tools.web_search.allowed_domains": ["example.com"],
"tools.web_search.context_size": "high",
"tools.web_search.location.country": "CA",
"tools.web_search.location.region": "Alberta",
"tools.web_search.location.city": "Edmonton",
"tools.web_search.location.timezone": "America/Edmonton",
},
});
const threadStartCwd = (requests[1]?.params as { cwd?: string } | undefined)?.cwd;
const isolatedCodexHome = isolatedStartOptions?.env?.CODEX_HOME;
expect(threadStartCwd).not.toBe("/tmp/openclaw-agent");
expect(isolatedStartOptions?.args).toEqual(["app-server", "--listen", "stdio://"]);
expect(isolatedStartOptions?.clearEnv).toEqual([
"KEEP_CLEARED",
"OPENCLAW_CODEX_APP_SERVER_ARGS",
]);
expect(isolatedCodexHome).toEqual(expect.any(String));
if (!threadStartCwd || !isolatedCodexHome) {
throw new Error("expected isolated Codex home and workspace");
}
expect(path.dirname(threadStartCwd)).toBe(path.dirname(isolatedCodexHome));
});
it("selects the live default text-capable model", async () => {
const { client, requests } = createFakeClient({
models: [
codexModel({ id: "available-first", isDefault: false }),
codexModel({ id: "available-default", model: "available-default-wire" }),
],
});
const provider = createCodexWebSearchProvider({
clientFactory: async () => client,
});
const config = createConfig();
const tool = provider.createTool({
config,
searchConfig: config.tools?.web?.search,
agentDir: "/tmp/openclaw-agent",
});
const result = await tool?.execute({ query: "plumbers in Edmonton Alberta" });
expect(result?.model).toBe("available-default-wire");
expect(requests[1]?.params).toEqual(
expect.objectContaining({ model: "available-default-wire" }),
);
expect(requests[2]?.params).toEqual(
expect.objectContaining({ model: "available-default-wire" }),
);
});
it("fails closed when the live catalog has no text-capable model", async () => {
const { client, requests } = createFakeClient({
models: [codexModel({ id: "image-only", inputModalities: ["image"] })],
});
const provider = createCodexWebSearchProvider({
clientFactory: async () => client,
});
const config = createConfig();
const tool = provider.createTool({
config,
searchConfig: config.tools?.web?.search,
agentDir: "/tmp/openclaw-agent",
});
await expect(tool?.execute({ query: "plumbers in Edmonton Alberta" })).rejects.toThrow(
"Codex app-server has no model supporting text input.",
);
expect(requests.map((entry) => entry.method)).toEqual(["model/list"]);
});
it("fails closed when Codex returns an ungrounded answer", async () => {
const { client } = createFakeClient({ emitWebSearch: false });
const provider = createCodexWebSearchProvider({
clientFactory: async () => client,
});
const config = createConfig();
const tool = provider.createTool({
config,
searchConfig: config.tools?.web?.search,
agentDir: "/tmp/openclaw-agent",
});
await expect(tool?.execute({ query: "plumbers in Edmonton Alberta" })).rejects.toThrow(
"Codex hosted search completed without invoking web search.",
);
});
});

View File

@@ -0,0 +1,62 @@
import { resolvePluginConfigObject } from "openclaw/plugin-sdk/plugin-config-runtime";
import type { WebSearchProviderPlugin } from "openclaw/plugin-sdk/provider-web-search-contract";
import type { CodexAppServerClientFactory } from "./app-server/client-factory.js";
import { createCodexWebSearchProviderBase } from "./web-search-provider.shared.js";
type CodexWebSearchRuntime = typeof import("./web-search-provider.runtime.js");
let codexWebSearchRuntimePromise: Promise<CodexWebSearchRuntime> | undefined;
function loadCodexWebSearchRuntime(): Promise<CodexWebSearchRuntime> {
codexWebSearchRuntimePromise ??= import("./web-search-provider.runtime.js");
return codexWebSearchRuntimePromise;
}
const CodexWebSearchSchema = {
type: "object",
properties: {
query: {
type: "string",
description: "Search query. Include the desired region, time range, and constraints.",
},
},
required: ["query"],
additionalProperties: false,
} satisfies Record<string, unknown>;
export type CodexWebSearchProviderOptions = {
resolvePluginConfig?: () => unknown;
clientFactory?: CodexAppServerClientFactory;
};
export function createCodexWebSearchProvider(
options: CodexWebSearchProviderOptions = {},
): WebSearchProviderPlugin {
return {
...createCodexWebSearchProviderBase(),
createTool: (ctx) => {
const nativeConfig = ctx.searchConfig?.openaiCodex;
if (
nativeConfig &&
typeof nativeConfig === "object" &&
!Array.isArray(nativeConfig) &&
(nativeConfig as { enabled?: unknown }).enabled === false
) {
return null;
}
return {
description:
"Search the current web through Codex hosted search and return a grounded answer with source URLs.",
parameters: CodexWebSearchSchema,
execute: async (args, executionContext) => {
const { executeCodexWebSearchProviderTool } = await loadCodexWebSearchRuntime();
return await executeCodexWebSearchProviderTool(ctx, args, executionContext, {
pluginConfig:
options.resolvePluginConfig?.() ?? resolvePluginConfigObject(ctx.config, "codex"),
clientFactory: options.clientFactory,
});
},
};
},
};
}

View File

@@ -0,0 +1,9 @@
import type { WebSearchProviderPlugin } from "openclaw/plugin-sdk/provider-web-search-contract";
import { createCodexWebSearchProviderBase } from "./src/web-search-provider.shared.js";
export function createCodexWebSearchProvider(): WebSearchProviderPlugin {
return {
...createCodexWebSearchProviderBase(),
createTool: () => null,
};
}

View File

@@ -9,6 +9,7 @@ import {
import type { ChannelMessageActionContext } from "openclaw/plugin-sdk/channel-contract";
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
import { handleDiscordAction } from "../../action-runtime-api.js";
import { isTrustedRequesterGuildAdminAction } from "../trusted-requester-actions.js";
import {
isDiscordModerationAction,
readDiscordModerationCommand,
@@ -26,15 +27,24 @@ type Ctx = Pick<
| "cfg"
| "accountId"
| "requesterSenderId"
| "senderIsOwner"
| "toolContext"
| "mediaLocalRoots"
| "mediaReadFile"
>;
function readDiscordRequesterSenderId(ctx: Ctx): string | undefined {
return ctx.toolContext?.currentChannelProvider?.trim().toLowerCase() === "discord"
? normalizeOptionalString(ctx.requesterSenderId)
: undefined;
const currentProvider = normalizeOptionalString(ctx.toolContext?.currentChannelProvider);
if (currentProvider?.toLowerCase() === "discord") {
return normalizeOptionalString(ctx.requesterSenderId);
}
if (
isTrustedRequesterGuildAdminAction(ctx.action) &&
(currentProvider || ctx.senderIsOwner !== true)
) {
throw new Error("Discord guild admin actions require a trusted Discord sender identity.");
}
return undefined;
}
function senderParam(senderUserId: string | undefined) {
@@ -356,7 +366,6 @@ export async function tryHandleDiscordMessageActionGuildAdmin(params: {
message: "deleteDays must be an integer from 0 to 7",
}),
});
const senderUserIdLocal = normalizeOptionalString(ctx.requesterSenderId);
return await handleDiscordAction(
{
action: moderation.action,
@@ -367,7 +376,7 @@ export async function tryHandleDiscordMessageActionGuildAdmin(params: {
until: moderation.until,
reason: moderation.reason,
deleteMessageDays: moderation.deleteMessageDays,
senderUserId: senderUserIdLocal,
senderUserId,
},
cfg,
);

View File

@@ -122,7 +122,24 @@ describe("handleDiscordMessageAction", () => {
});
});
it("does not treat non-Discord requester ids as Discord guild admin sender ids", async () => {
it("rejects non-Discord requester ids for Discord guild admin actions", async () => {
const cfg = discordConfig({ channels: true });
await expect(
handleDiscordMessageAction({
action: "channel-delete",
params: {
channelId: "channel-1",
},
cfg,
requesterSenderId: "telegram-user-id",
toolContext: { currentChannelProvider: "telegram" },
}),
).rejects.toThrow("trusted Discord sender identity");
expect(handleDiscordActionMock).not.toHaveBeenCalled();
});
it("keeps no-context Discord guild admin actions on the manual runtime path", async () => {
const cfg = discordConfig({ channels: true });
await handleDiscordMessageAction({
action: "channel-delete",
@@ -130,16 +147,67 @@ describe("handleDiscordMessageAction", () => {
channelId: "channel-1",
},
cfg,
senderIsOwner: true,
});
expectDiscordActionCall({
payload: {
action: "channelDelete",
accountId: undefined,
channelId: "channel-1",
},
cfg,
});
});
it("rejects no-context Discord guild admin actions without owner trust", async () => {
const cfg = discordConfig({ channels: true });
await expect(
handleDiscordMessageAction({
action: "channel-delete",
params: {
channelId: "channel-1",
},
cfg,
}),
).rejects.toThrow("trusted Discord sender identity");
expect(handleDiscordActionMock).not.toHaveBeenCalled();
});
it("rejects non-Discord requester ids for Discord moderation actions", async () => {
const cfg = discordConfig({ moderation: true });
await expect(
handleDiscordMessageAction({
action: "timeout",
params: {
guildId: "guild-1",
userId: "user-2",
durationMin: 5,
},
cfg,
requesterSenderId: "telegram-user-id",
toolContext: { currentChannelProvider: "telegram" },
}),
).rejects.toThrow("trusted Discord sender identity");
expect(handleDiscordActionMock).not.toHaveBeenCalled();
});
it("keeps read-only guild lookups available from non-Discord requesters", async () => {
const cfg = discordConfig({ channelInfo: true });
await handleDiscordMessageAction({
action: "channel-info",
params: {
channelId: "channel-1",
},
cfg,
requesterSenderId: "telegram-user-id",
toolContext: { currentChannelProvider: "telegram" },
});
expectDiscordActionCall({
payload: {
action: "channelDelete",
accountId: undefined,
channelId: "channel-1",
},
payload: { action: "channelInfo", accountId: undefined, channelId: "channel-1" },
cfg,
});
});

View File

@@ -43,6 +43,7 @@ export async function handleDiscordMessageAction(
| "cfg"
| "accountId"
| "requesterSenderId"
| "senderIsOwner"
| "toolContext"
| "mediaAccess"
| "mediaLocalRoots"

View File

@@ -140,7 +140,7 @@ describe("discordMessageActions", () => {
]);
});
it("requires trusted requester sender for privileged guild admin actions only from Discord turns", () => {
it("requires trusted requester sender for privileged guild admin actions from tool contexts", () => {
for (const action of ["channel-delete", "timeout", "kick", "ban"] as const) {
expect(
discordMessageActions.requiresTrustedRequesterSender?.({
@@ -148,13 +148,18 @@ describe("discordMessageActions", () => {
toolContext: { currentChannelProvider: "discord" },
}),
).toBe(true);
expect(
discordMessageActions.requiresTrustedRequesterSender?.({
action,
}),
).toBe(false);
}
expect(
discordMessageActions.requiresTrustedRequesterSender?.({
action: "channel-delete",
toolContext: { currentChannelProvider: "telegram" },
}),
).toBe(false);
).toBe(true);
expect(
discordMessageActions.requiresTrustedRequesterSender?.({
action: "read",
@@ -513,6 +518,7 @@ describe("discordMessageActions", () => {
cfg,
accountId: "ops",
requesterSenderId: "user-1",
senderIsOwner: true,
toolContext,
mediaAccess,
mediaLocalRoots,
@@ -525,6 +531,7 @@ describe("discordMessageActions", () => {
cfg,
accountId: "ops",
requesterSenderId: "user-1",
senderIsOwner: true,
toolContext,
mediaAccess,
mediaLocalRoots,

View File

@@ -12,24 +12,7 @@ import { inspectDiscordAccount } from "./account-inspect.js";
import { createDiscordActionGate, listDiscordAccountIds } from "./accounts.js";
import { readDiscordComponentSpec } from "./components.js";
import { withDiscordInboundEventDeliveryMetadata } from "./inbound-event-delivery.js";
const trustedRequesterGuildAdminActions = new Set<ChannelMessageActionName>([
"emoji-upload",
"sticker-upload",
"role-add",
"role-remove",
"channel-create",
"channel-edit",
"channel-delete",
"channel-move",
"category-create",
"category-edit",
"category-delete",
"event-create",
"timeout",
"kick",
"ban",
]);
import { isTrustedRequesterGuildAdminAction } from "./trusted-requester-actions.js";
const localExecutionActions = new Set<ChannelMessageActionName>([
"send",
@@ -202,8 +185,7 @@ export const discordMessageActions: ChannelMessageActionAdapter = {
resolveExecutionMode: resolveDiscordActionExecutionMode,
describeMessageTool: describeDiscordMessageTool,
requiresTrustedRequesterSender: ({ action, toolContext }) =>
normalizeOptionalString(toolContext?.currentChannelProvider)?.toLowerCase() === "discord" &&
trustedRequesterGuildAdminActions.has(action),
Boolean(toolContext) && isTrustedRequesterGuildAdminAction(action),
extractToolSend: ({ args }) => {
const action = normalizeOptionalString(args.action) ?? "";
if (action === "sendMessage") {
@@ -266,6 +248,7 @@ export const discordMessageActions: ChannelMessageActionAdapter = {
cfg,
accountId,
requesterSenderId,
senderIsOwner,
toolContext,
mediaAccess,
mediaLocalRoots,
@@ -281,6 +264,7 @@ export const discordMessageActions: ChannelMessageActionAdapter = {
cfg,
accountId,
requesterSenderId,
senderIsOwner,
toolContext,
mediaAccess,
mediaLocalRoots,

View File

@@ -44,10 +44,8 @@ export function createDiscordDraftPreviewController(params: {
const accountBlockStreamingEnabled =
resolveChannelStreamingBlockEnabled(params.discordConfig) ??
params.cfg.agents?.defaults?.blockStreamingDefault === "on";
const canStreamProgressDraftForToolOnlySource =
params.sourceRepliesAreToolOnly && discordStreamMode === "progress";
const canStreamDraft =
(!params.sourceRepliesAreToolOnly || canStreamProgressDraftForToolOnlySource) &&
!params.sourceRepliesAreToolOnly &&
discordStreamMode !== "off" &&
!accountBlockStreamingEnabled;
const draftStream = canStreamDraft

View File

@@ -184,6 +184,7 @@ type DispatchInboundParams = {
onPartialReply?: (payload: { text?: string }) => Promise<void> | void;
onAssistantMessageStart?: () => Promise<void> | void;
allowProgressCallbacksWhenSourceDeliverySuppressed?: boolean;
allowToolLifecycleWhenProgressHidden?: boolean;
onTypingCleanup?: () => Promise<void> | void;
};
};
@@ -984,6 +985,7 @@ describe("processDiscordMessage ack reactions", () => {
await runProcessDiscordMessage(ctx);
expect(getLastDispatchReplyOptions()?.allowToolLifecycleWhenProgressHidden).toBe(true);
const emojis = getReactionEmojis();
expect(emojis).toContain("👀");
expect(emojis).toContain(DEFAULT_EMOJIS.done);
@@ -1152,6 +1154,7 @@ describe("processDiscordMessage ack reactions", () => {
await runProcessDiscordMessage(ctx);
expect(getLastDispatchReplyOptions()?.allowToolLifecycleWhenProgressHidden).toBeUndefined();
expect(getReactionEmojis()).toEqual(["👀"]);
});
@@ -2154,12 +2157,12 @@ describe("processDiscordMessage draft streaming", () => {
expect(deliverDiscordReply).not.toHaveBeenCalled();
});
it("streams Discord tool progress for coding-profile message-tool-only guild replies", async () => {
const draftStream = createMockDraftStreamForTest();
it("keeps Discord tool progress private for coding-profile message-tool-only guild replies", async () => {
dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => {
expect(params?.replyOptions?.sourceReplyDeliveryMode).toBe("message_tool_only");
expect(params?.replyOptions?.allowProgressCallbacksWhenSourceDeliverySuppressed).toBe(true);
expect(
params?.replyOptions?.allowProgressCallbacksWhenSourceDeliverySuppressed,
).toBeUndefined();
await params?.replyOptions?.onToolStart?.({ name: "exec", phase: "start" });
await params?.replyOptions?.onItemEvent?.({ progressText: "exec done" });
return createNoQueuedDispatchResult();
@@ -2179,7 +2182,36 @@ describe("processDiscordMessage draft streaming", () => {
await runProcessDiscordMessage(ctx);
expect(getLastDispatchReplyOptions()?.sourceReplyDeliveryMode).toBe("message_tool_only");
expect(draftStream.update).toHaveBeenCalledWith("Pinching\n\n🛠 Exec\n• exec done");
expect(createDiscordDraftStream).not.toHaveBeenCalled();
expect(deliverDiscordReply).not.toHaveBeenCalled();
});
it("preserves explicitly enabled status reactions without exposing tool progress drafts", async () => {
dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => {
expect(params?.replyOptions?.sourceReplyDeliveryMode).toBe("message_tool_only");
expect(params?.replyOptions?.allowProgressCallbacksWhenSourceDeliverySuppressed).toBe(true);
expect(params?.replyOptions?.suppressDefaultToolProgressMessages).toBe(true);
await params?.replyOptions?.onToolStart?.({ name: "exec", phase: "start" });
return createNoQueuedDispatchResult();
});
const ctx = await createBaseContext({
cfg: {
tools: { profile: "coding" },
messages: {
ackReaction: "👀",
groupChat: { visibleReplies: "message_tool" },
statusReactions: { enabled: true, timing: { debounceMs: 0 } },
},
session: { store: "/tmp/openclaw-discord-process-test-sessions.json" },
},
route: BASE_CHANNEL_ROUTE,
});
await runProcessDiscordMessage(ctx);
expect(getReactionEmojis()).toContain(DEFAULT_EMOJIS.done);
expect(createDiscordDraftStream).not.toHaveBeenCalled();
expect(deliverDiscordReply).not.toHaveBeenCalled();
});

View File

@@ -981,9 +981,7 @@ async function processDiscordMessageInner(
queuedDeliveryCorrelations: isRoomEvent ? [{ begin: beginDeliveryCorrelation }] : undefined,
suppressTyping: isRoomEvent ? true : undefined,
allowProgressCallbacksWhenSourceDeliverySuppressed:
sourceRepliesAreToolOnly && draftPreview.draftStream && draftPreview.isProgressMode
? true
: undefined,
sourceRepliesAreToolOnly && statusReactionsExplicitlyEnabled ? true : undefined,
disableBlockStreaming: sourceRepliesAreToolOnly
? true
: (draftPreview.disableBlockStreamingForDraft ??
@@ -1001,9 +999,12 @@ async function processDiscordMessageInner(
? () => draftPreview.handleAssistantMessageBoundary()
: undefined,
onModelSelected,
suppressDefaultToolProgressMessages: draftPreview.suppressDefaultToolProgressMessages
? true
: undefined,
suppressDefaultToolProgressMessages:
(sourceRepliesAreToolOnly && statusReactionsExplicitlyEnabled) ||
draftPreview.suppressDefaultToolProgressMessages
? true
: undefined,
allowToolLifecycleWhenProgressHidden: statusReactionsEnabled ? true : undefined,
commentaryProgressEnabled: draftPreview.isProgressMode
? draftPreview.commentaryProgressEnabled
: undefined,

View File

@@ -0,0 +1,24 @@
// Discord guild-admin actions need a Discord sender identity for permission checks.
import type { ChannelMessageActionName } from "openclaw/plugin-sdk/channel-contract";
const trustedRequesterGuildAdminActions = new Set<ChannelMessageActionName>([
"emoji-upload",
"sticker-upload",
"role-add",
"role-remove",
"channel-create",
"channel-edit",
"channel-delete",
"channel-move",
"category-create",
"category-edit",
"category-delete",
"event-create",
"timeout",
"kick",
"ban",
]);
export function isTrustedRequesterGuildAdminAction(action: ChannelMessageActionName): boolean {
return trustedRequesterGuildAdminActions.has(action);
}

View File

@@ -236,7 +236,7 @@ const readSessionUpdatedAtMock: PluginRuntime["channel"]["session"]["readSession
const resolveStorePathMock: PluginRuntime["channel"]["session"]["resolveStorePath"] = (params) =>
mockResolveStorePath(params);
const resolveEnvelopeFormatOptionsMock = () => ({});
const finalizeInboundContextMock = (ctx: Record<string, unknown>) => ctx;
const finalizeInboundContextMock = vi.fn((ctx: Record<string, unknown>) => ctx);
const withReplyDispatcherMock = async ({
run,
}: Parameters<PluginRuntime["channel"]["reply"]["withReplyDispatcher"]>[0]) => await run();
@@ -422,6 +422,7 @@ async function dispatchMessage(params: {
cfg: ClawdbotConfig;
currentCfg?: ClawdbotConfig;
event: FeishuMessageEvent;
channelRuntime?: PluginRuntime["channel"];
}) {
const runtime = createRuntimeEnv();
const feishuConfig = params.cfg.channels?.feishu;
@@ -443,6 +444,7 @@ async function dispatchMessage(params: {
cfg,
event: params.event,
runtime,
channelRuntime: params.channelRuntime,
});
return runtime;
}
@@ -960,6 +962,32 @@ describe("handleFeishuMessage ACP routing", () => {
);
expect(dispatcherOptions.allowReasoningPreview).toBe(true);
});
it("falls back to full runtime channel when partial channelRuntime lacks inbound", async () => {
const partialChannelRuntime = {
runtimeContexts: {} as PluginRuntime["channel"]["runtimeContexts"],
} as PluginRuntime["channel"];
await dispatchMessage({
cfg: {
session: { mainKey: "main", scope: "per-sender" },
channels: { feishu: { enabled: true, allowFrom: ["ou_sender_1"], dmPolicy: "open" } },
},
event: {
sender: { sender_id: { open_id: "ou_sender_1" } },
message: {
message_id: "msg-partial-runtime",
chat_id: "oc_dm",
chat_type: "p2p",
message_type: "text",
content: JSON.stringify({ text: "hello" }),
},
},
channelRuntime: partialChannelRuntime,
});
expect(finalizeInboundContextMock).toHaveBeenCalledTimes(1);
});
});
describe("handleFeishuMessage command authorization", () => {

View File

@@ -734,7 +734,7 @@ export async function handleFeishuMessage(params: {
try {
const core = {
channel: channelRuntime ?? getFeishuRuntime().channel,
channel: channelRuntime?.inbound ? channelRuntime : getFeishuRuntime().channel,
} as ReturnType<typeof getFeishuRuntime>;
const pairing = createChannelPairingController({
core,
@@ -1602,6 +1602,7 @@ export async function handleFeishuMessage(params: {
threadReply,
accountId: account.accountId,
identity,
mentionTargets: ctx.mentionTargets,
messageCreateTimeMs,
sessionKey: agentSessionKey,
});
@@ -1779,6 +1780,7 @@ export async function handleFeishuMessage(params: {
threadReply,
accountId: account.accountId,
identity,
mentionTargets: ctx.mentionTargets,
messageCreateTimeMs,
sessionKey: route.sessionKey,
});

View File

@@ -549,18 +549,23 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
expect(sendMarkdownCardFeishuMock).not.toHaveBeenCalled();
});
it("does not attach automatic mentions to non-streaming plain text replies", async () => {
it("passes mention-forward targets to non-streaming plain text replies without rewriting body text", async () => {
useNonStreamingAutoAccount();
const { options } = createDispatcherHarness({
replyToMessageId: "om_msg",
mentionTargets: [{ openId: "ou_target", name: "Target User", key: "@_user_1" }],
});
await options.deliver({ text: "plain text" }, { kind: "final" });
await options.deliver(
{ text: 'plain text <at user_id="ou_body">Body User</at>' },
{ kind: "final" },
);
expect(sendMessageFeishuMock).toHaveBeenCalledTimes(1);
expect(firstMockArg(sendMessageFeishuMock, "send message params")).not.toHaveProperty(
"mentions",
);
expectMockArgFields(sendMessageFeishuMock, "message send params", {
text: 'plain text <at user_id="ou_body">Body User</at>',
mentions: [{ openId: "ou_target", name: "Target User", key: "@_user_1" }],
});
});
it("does not attach automatic mentions to card replies", async () => {

View File

@@ -15,6 +15,7 @@ import { stripReasoningTagsFromText } from "openclaw/plugin-sdk/text-chunking";
import { resolveFeishuRuntimeAccount } from "./accounts.js";
import { createFeishuClient } from "./client.js";
import { sendMediaFeishu, shouldSuppressFeishuTextForVoiceMedia } from "./media.js";
import type { MentionTarget } from "./mention-target.types.js";
import {
createReplyPrefixContext,
type ClawdbotConfig,
@@ -129,6 +130,7 @@ type CreateFeishuReplyDispatcherParams = {
rootId?: string;
accountId?: string;
identity?: OutboundIdentity;
mentionTargets?: MentionTarget[];
/** Epoch ms when the inbound message was created. Used to suppress typing
* indicators on old/replayed messages after context compaction (#30418). */
messageCreateTimeMs?: number;
@@ -149,6 +151,7 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
rootId,
accountId,
identity,
mentionTargets,
} = params;
const sendReplyToMessageId = skipReplyToInMessages ? undefined : replyToMessageId;
const typingTargetMessageId = explicitTypingTargetMessageId?.trim() || replyToMessageId;
@@ -743,7 +746,7 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
text,
useCard: false,
infoKind: info?.kind,
sendChunk: async ({ chunk }) => {
sendChunk: async ({ chunk, isFirst }) => {
await sendMessageFeishu({
cfg,
to: chatId,
@@ -752,6 +755,9 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
replyInThread: effectiveReplyInThread,
allowTopLevelReplyFallback,
accountId,
...(info?.kind === "final" && isFirst && mentionTargets?.length
? { mentions: mentionTargets }
: {}),
});
},
});

View File

@@ -1,7 +1,7 @@
// Feishu tests cover send plugin behavior.
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import type { ClawdbotConfig } from "../runtime-api.js";
import { buildMarkdownCard } from "./send.js";
import { buildFeishuPostMessagePayload, buildMarkdownCard } from "./send.js";
const {
mockConvertMarkdownTables,
@@ -64,6 +64,49 @@ let listFeishuThreadMessages: typeof import("./send.js").listFeishuThreadMessage
let resolveFeishuCardTemplate: typeof import("./send.js").resolveFeishuCardTemplate;
let sendMessageFeishu: typeof import("./send.js").sendMessageFeishu;
describe("buildFeishuPostMessagePayload", () => {
it("prepends structured mention targets as native post at elements", () => {
const payload = buildFeishuPostMessagePayload({
messageText: "hello **world**",
mentions: [
{ openId: "ou_alice", name: "Alice", key: "@_user_1" },
{ openId: " ou_bob ", name: " Bob ", key: "@_user_2" },
],
});
expect(payload.msgType).toBe("post");
expect(JSON.parse(payload.content)).toEqual({
zh_cn: {
content: [
[
{ tag: "at", user_id: "ou_alice", user_name: "Alice" },
{ tag: "at", user_id: "ou_bob", user_name: "Bob" },
{ tag: "md", text: "hello **world**" },
],
],
},
});
});
it("leaves body-supplied at tags literal in the markdown element", () => {
const payload = buildFeishuPostMessagePayload({
messageText: 'please keep <at user_id="ou_body">Body User</at> literal',
mentions: [{ openId: "ou_target", name: "Target User", key: "@_user_1" }],
});
expect(JSON.parse(payload.content)).toEqual({
zh_cn: {
content: [
[
{ tag: "at", user_id: "ou_target", user_name: "Target User" },
{ tag: "md", text: 'please keep <at user_id="ou_body">Body User</at> literal' },
],
],
},
});
});
});
describe("getMessageFeishu", () => {
beforeAll(async () => {
({
@@ -173,6 +216,51 @@ describe("getMessageFeishu", () => {
});
});
it("sends automatic mentions as native post elements without rewriting body text", async () => {
const create = vi.fn().mockResolvedValue({ code: 0, data: { message_id: "om_mentions" } });
mockCreateFeishuClient.mockReturnValue({
im: {
message: {
create,
reply: vi.fn(),
get: mockClientGet,
list: mockClientList,
patch: mockClientPatch,
},
},
});
const result = await sendMessageFeishu({
cfg: {} as ClawdbotConfig,
to: "oc_send",
text: 'body <at user_id="ou_body">Body User</at>',
mentions: [{ openId: "ou_target", name: "Target User", key: "@_user_1" }],
});
expect(mockConvertMarkdownTables).toHaveBeenCalledWith(
'body <at user_id="ou_body">Body User</at>',
"preserve",
);
expect(create).toHaveBeenCalledWith({
params: { receive_id_type: "chat_id" },
data: {
receive_id: "oc_send",
msg_type: "post",
content: JSON.stringify({
zh_cn: {
content: [
[
{ tag: "at", user_id: "ou_target", user_name: "Target User" },
{ tag: "md", text: 'body <at user_id="ou_body">Body User</at>' },
],
],
},
}),
},
});
expect(result).toEqual({ messageId: "om_mentions", chatId: "oc_send" });
});
it("extracts text content from interactive card elements", async () => {
mockClientGet.mockResolvedValueOnce({
code: 0,

View File

@@ -12,7 +12,7 @@ import { resolveFeishuRuntimeAccount } from "./accounts.js";
import { createFeishuClient } from "./client.js";
import { requestFeishuApi } from "./comment-shared.js";
import type { MentionTarget } from "./mention-target.types.js";
import { buildMentionedCardContent, buildMentionedMessage } from "./mention.js";
import { buildMentionedCardContent } from "./mention.js";
import { parsePostContent } from "./post.js";
import {
assertFeishuMessageApiSuccess,
@@ -546,22 +546,50 @@ export type SendFeishuMessageParams = {
accountId?: string;
};
export function buildFeishuPostMessagePayload(params: { messageText: string }): {
type FeishuPostMessageElement =
| { tag: "at"; user_id: string; user_name?: string }
| { tag: "md"; text: string };
function buildFeishuPostMentionElements(mentions?: MentionTarget[]): FeishuPostMessageElement[] {
if (!mentions?.length) {
return [];
}
const elements: FeishuPostMessageElement[] = [];
for (const mention of mentions) {
const userId = mention.openId.trim();
if (!userId) {
continue;
}
const userName = mention.name.trim();
elements.push({
tag: "at",
user_id: userId,
...(userName ? { user_name: userName } : {}),
});
}
return elements;
}
export function buildFeishuPostMessagePayload(params: {
messageText: string;
mentions?: MentionTarget[];
}): {
content: string;
msgType: string;
} {
const { messageText } = params;
const { messageText, mentions } = params;
const content: FeishuPostMessageElement[] = [
...buildFeishuPostMentionElements(mentions),
{
tag: "md",
text: messageText,
},
];
return {
content: JSON.stringify({
zh_cn: {
content: [
[
{
tag: "md",
text: messageText,
},
],
],
content: [content],
},
}),
msgType: "post",
@@ -587,14 +615,9 @@ export async function sendMessageFeishu(
channel: "feishu",
});
// Build message content (with @mention support)
let rawText = text ?? "";
if (mentions && mentions.length > 0) {
rawText = buildMentionedMessage(mentions, rawText);
}
const messageText = convertMarkdownTables(rawText, tableMode);
const messageText = convertMarkdownTables(text ?? "", tableMode);
const { content, msgType } = buildFeishuPostMessagePayload({ messageText });
const { content, msgType } = buildFeishuPostMessagePayload({ messageText, mentions });
const directParams = { receiveId, receiveIdType, content, msgType };
return sendReplyOrFallbackDirect(client, {

View File

@@ -34,17 +34,28 @@ export type SentMessageCache = {
// duplicate delivery (noisy but not lossy) — never message loss.
const SENT_MESSAGE_TEXT_TTL_MS = 4_000;
const SENT_MESSAGE_ID_TTL_MS = 60_000;
const LEADING_ATTRIBUTED_BODY_CORRUPTION_MARKERS = /^[\uFEFF\uFFFD\uFFFE\uFFFF]+/u;
function isLeadingEchoTextCorruptionMarker(code: number): boolean {
return (
code === 0x0000 || code === 0xfeff || code === 0xfffd || code === 0xfffe || code === 0xffff
);
}
function stripLeadingEchoTextCorruptionMarkers(text: string): string {
let offset = 0;
while (offset < text.length && isLeadingEchoTextCorruptionMarker(text.charCodeAt(offset))) {
offset += 1;
}
return offset === 0 ? text : text.slice(offset);
}
function normalizeEchoTextKey(text: string | undefined): string | null {
if (!text) {
return null;
}
const normalized = text
.replace(/\r\n?/g, "\n")
.trim()
.replace(LEADING_ATTRIBUTED_BODY_CORRUPTION_MARKERS, "")
.trim();
const normalized = stripLeadingEchoTextCorruptionMarkers(
text.replace(/\r\n?/g, "\n").trim(),
).trim();
return normalized ? normalized : null;
}

View File

@@ -51,6 +51,20 @@ describe("iMessage sent-message echo cache", () => {
).toBe(true);
});
it("matches delayed reflected echoes with leading NUL corruption markers", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-02-25T00:00:00Z"));
const cache = createSentMessageCache();
cache.remember("acct:imessage:+1555", { text: "Delayed echo reply" });
expect(
cache.has("acct:imessage:+1555", {
text: "\u0000\u0000Delayed echo reply",
}),
).toBe(true);
});
it("keeps attributedBody corruption cleanup leading-only", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-02-25T00:00:00Z"));
@@ -67,6 +81,16 @@ describe("iMessage sent-message echo cache", () => {
expect(cache.has("acct:imessage:+1555", { text: "Delayed\necho reply" })).toBe(false);
});
it("keeps NUL corruption cleanup leading-only", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-02-25T00:00:00Z"));
const cache = createSentMessageCache();
cache.remember("acct:imessage:+1555", { text: "Delayed echo reply" });
expect(cache.has("acct:imessage:+1555", { text: "Delayed\u0000echo reply" })).toBe(false);
});
it("matches by outbound message id and ignores placeholder ids", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-02-25T00:00:00Z"));

View File

@@ -51,13 +51,13 @@ describe("createButtonTemplate", () => {
expect((template.template as { text: string }).text.length).toBe(60);
});
it("keeps longer text when thumbnail is provided", () => {
it("truncates text to 60 chars when title and thumbnail are provided", () => {
const longText = "x".repeat(100);
const template = createButtonTemplate("Title", longText, [messageAction("OK")], {
thumbnailImageUrl: "https://example.com/thumb.jpg",
});
expect((template.template as { text: string }).text.length).toBe(100);
expect((template.template as { text: string }).text.length).toBe(60);
});
});
@@ -77,12 +77,67 @@ describe("createCarouselColumn", () => {
expect(column.actions.length).toBe(3);
});
it("truncates text to 120 characters", () => {
it("truncates text to 120 characters when no title or image is set", () => {
const longText = "x".repeat(150);
const column = createCarouselColumn({ text: longText, actions: [messageAction("OK")] });
expect(column.text.length).toBe(120);
});
it("truncates text to 60 characters when a title is set", () => {
const longText = "x".repeat(150);
const column = createCarouselColumn({
title: "Title",
text: longText,
actions: [messageAction("OK")],
});
expect(column.text.length).toBe(60);
});
it("does not split an emoji grapheme at the 60-code-unit boundary", () => {
const text = `${"x".repeat(59)}👨👩👧👦after`;
const column = createCarouselColumn({
title: "Title",
text,
actions: [messageAction("OK")],
});
expect(column.text).toBe("x".repeat(59));
});
it("keeps required text when the first grapheme exceeds the limit", () => {
const text = `😀${"\u0301".repeat(59)}`;
const column = createCarouselColumn({
title: "Title",
text,
actions: [messageAction("OK")],
});
expect(column.text.length).toBe(60);
expect(column.text.startsWith("😀")).toBe(true);
});
it("uses the compact limit when a whitespace-only title is present", () => {
const column = createCarouselColumn({
title: " ",
text: "x".repeat(150),
actions: [messageAction("OK")],
});
expect(column.text).toBe("x".repeat(60));
});
it("truncates text to 60 characters when a thumbnail image is set", () => {
const longText = "x".repeat(150);
const column = createCarouselColumn({
text: longText,
thumbnailImageUrl: "https://example.com/thumb.jpg",
actions: [messageAction("OK")],
});
expect(column.text.length).toBe(60);
});
});
describe("carousel column limits", () => {
@@ -131,6 +186,20 @@ describe("createProductCarousel", () => {
.columns;
expect(columns[0].actions[0].type).toBe(expectedType);
});
it("preserves the complete price when truncating a long description", () => {
const template = createProductCarousel([
{
title: "Product",
description: "x".repeat(59),
price: "$12.99",
},
]);
const columns = (template.template as { columns: Array<{ text: string }> }).columns;
expect(columns[0].text).toBe(`${"x".repeat(53)}\n$12.99`);
expect(columns[0].text.length).toBe(60);
});
});
describe("flex cards", () => {

View File

@@ -13,6 +13,9 @@ type CarouselColumn = messagingApi.CarouselColumn;
type ImageCarouselTemplate = messagingApi.ImageCarouselTemplate;
type ImageCarouselColumn = messagingApi.ImageCarouselColumn;
const COMPACT_TEMPLATE_TEXT_LIMIT = 60;
const graphemeSegmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
type TemplatePayloadAction = {
type?: "uri" | "postback" | "message";
uri?: string;
@@ -30,6 +33,48 @@ function buildTemplatePayloadAction(action: TemplatePayloadAction): Action {
return messageAction(action.label, action.data ?? action.label);
}
function resolveTemplateTextLimit(params: {
title?: string;
thumbnailImageUrl?: string;
textOnlyLimit: number;
}): number {
return params.title !== undefined || params.thumbnailImageUrl !== undefined
? COMPACT_TEMPLATE_TEXT_LIMIT
: params.textOnlyLimit;
}
function truncateTemplateText(text: string, limit: number): string {
let result = "";
for (const { segment } of graphemeSegmenter.segment(text)) {
if (result.length + segment.length > limit) {
// A pathological grapheme can exceed LINE's whole field limit. Preserve
// graphemes normally, but keep required text non-empty without splitting
// a surrogate pair when the first grapheme alone cannot fit.
if (!result) {
for (const codePoint of segment) {
if (result.length + codePoint.length > limit) {
break;
}
result += codePoint;
}
}
break;
}
result += segment;
}
return result;
}
function formatProductCarouselText(description: string, price?: string): string {
if (!price) {
return description;
}
const priceText = truncateTemplateText(price, COMPACT_TEMPLATE_TEXT_LIMIT);
const descriptionLimit = Math.max(0, COMPACT_TEMPLATE_TEXT_LIMIT - priceText.length - 1);
const descriptionText = truncateTemplateText(description, descriptionLimit);
return descriptionText ? `${descriptionText}\n${priceText}` : priceText;
}
/**
* Create a confirm template (yes/no style dialog)
*/
@@ -68,12 +113,15 @@ export function createButtonTemplate(
altText?: string;
},
): TemplateMessage {
const hasThumbnail = Boolean(options?.thumbnailImageUrl?.trim());
const textLimit = hasThumbnail ? 160 : 60;
const textLimit = resolveTemplateTextLimit({
title,
thumbnailImageUrl: options?.thumbnailImageUrl,
textOnlyLimit: 160,
});
const template: ButtonsTemplate = {
type: "buttons",
title: title.slice(0, 40), // LINE limit
text: text.slice(0, textLimit), // LINE limit (60 if no thumbnail, 160 with thumbnail)
text: truncateTemplateText(text, textLimit),
actions: actions.slice(0, 4), // LINE limit: max 4 actions
thumbnailImageUrl: options?.thumbnailImageUrl,
imageAspectRatio: options?.imageAspectRatio ?? "rectangle",
@@ -125,9 +173,14 @@ export function createCarouselColumn(params: {
imageBackgroundColor?: string;
defaultAction?: Action;
}): CarouselColumn {
// LINE caps a carousel column's text at 60 chars when the column carries a
// title or thumbnail image, and 120 chars otherwise. Sending an over-length
// text makes LINE reject the whole carousel, so mirror the conditional limit
// the buttons template already applies above.
const textLimit = resolveTemplateTextLimit({ ...params, textOnlyLimit: 120 });
return {
title: params.title?.slice(0, 40),
text: params.text.slice(0, 120), // LINE limit
text: truncateTemplateText(params.text, textLimit),
actions: params.actions.slice(0, 3), // LINE limit: max 3 actions per column
thumbnailImageUrl: params.thumbnailImageUrl,
imageBackgroundColor: params.imageBackgroundColor,
@@ -256,9 +309,7 @@ export function createProductCarousel(
return createCarouselColumn({
title: product.title,
text: product.price
? `${product.description}\n${product.price}`.slice(0, 120)
: product.description,
text: formatProductCarouselText(product.description, product.price),
thumbnailImageUrl: product.imageUrl,
actions,
});

View File

@@ -40,6 +40,15 @@ describe("stripMatrixMentionPrefix", () => {
expect(result).toBe("/model");
});
it("strips bracketed @display name syntax", () => {
const result = stripMatrixMentionPrefix({
text: "@[OpenClaw Bot] /model",
displayName: "OpenClaw Bot",
mentionRegexes: [],
});
expect(result).toBe("/model");
});
it("returns original text when text is empty", () => {
const result = stripMatrixMentionPrefix({
text: "",

View File

@@ -913,6 +913,31 @@ describe("matrix monitor handler pairing account scope", () => {
expect(recordInboundSession).toHaveBeenCalled();
});
it("processes room messages mentioned via bracketed @displayName in formatted_body", async () => {
const recordInboundSession = vi.fn(async () => {});
const { handler } = createMatrixHandlerTestHarness({
isDirectMessage: false,
getMemberDisplayName: async () => "Display Name",
recordInboundSession,
});
await handler(
"!room:example.org",
createMatrixRoomMessageEvent({
eventId: "$bracketed-display-name-mention",
content: {
msgtype: "m.text",
body: "@[Display Name] please reply",
formatted_body:
'<a href="https://matrix.to/#/@bot:example.org">@[Display Name]</a> please reply',
"m.mentions": { user_ids: ["@bot:example.org"] },
},
}),
);
expect(recordInboundSession).toHaveBeenCalled();
});
it("does not fetch self displayName for plain-text room mentions", async () => {
const getMemberDisplayName = vi.fn(async () => "Tom Servo");
const { handler, recordInboundSession } = createMatrixHandlerTestHarness({

View File

@@ -228,6 +228,24 @@ describe("resolveMentions", () => {
expect(result.hasExplicitMention).toBe(true);
});
it("detects mention when the visible label is bracketed @displayName text", () => {
const result = resolveMentions({
content: {
msgtype: "m.text",
body: "@[Display Name] please reply",
formatted_body:
'<a href="https://matrix.to/#/@bot:matrix.org">@[Display Name]</a> please reply',
"m.mentions": { user_ids: ["@bot:matrix.org"] },
},
userId,
displayName: "Display Name",
text: "@[Display Name] please reply",
mentionRegexes: [],
});
expect(result.wasMentioned).toBe(true);
expect(result.hasExplicitMention).toBe(true);
});
it("ignores out-of-range hexadecimal HTML entities in visible labels", () => {
expect(
resolveMentions({

View File

@@ -89,6 +89,7 @@ function resolveMatrixMentionPrefixCandidates(params: {
append(localpart ? `@${localpart}` : null);
append(params.displayName);
append(params.displayName ? `@${params.displayName}` : null);
append(params.displayName ? `@[${params.displayName}]` : null);
return candidates;
}
@@ -158,6 +159,7 @@ function isVisibleMentionLabel(params: {
localpart ? extractVisibleMentionText(`@${localpart}`) : null,
params.displayName ? extractVisibleMentionText(params.displayName) : null,
params.displayName ? extractVisibleMentionText(`@${params.displayName}`) : null,
params.displayName ? extractVisibleMentionText(`@[${params.displayName}]`) : null,
].filter((value): value is string => Boolean(value));
return candidates.includes(cleaned);
}

View File

@@ -74,6 +74,14 @@ function requireMattermostReplyToModeResolver() {
return resolveReplyToMode;
}
function requireMattermostThreadTargetMatcher() {
const matchesToolContextTarget = mattermostPlugin.threading?.matchesToolContextTarget;
if (!matchesToolContextTarget) {
throw new Error("mattermost threading.matchesToolContextTarget missing");
}
return matchesToolContextTarget;
}
function requireMattermostSendText() {
const sendText = mattermostPlugin.outbound?.sendText;
if (!sendText) {
@@ -236,6 +244,27 @@ describe("mattermostPlugin", () => {
},
);
it("matches bare Mattermost channel ids against the active channel target", () => {
const matchesToolContextTarget = requireMattermostThreadTargetMatcher();
expect(
matchesToolContextTarget({
target: "tqfek9psh7fw8mpa5berwyytqw",
toolContext: {
currentChannelId: "channel:tqfek9psh7fw8mpa5berwyytqw",
},
}),
).toBe(true);
expect(
matchesToolContextTarget({
target: "tqfek9psh7fw8mpa5berwyytqw",
toolContext: {
currentChannelId: "channel:kqfek9psh7fw8mpa5berwyytqw",
},
}),
).toBe(false);
});
it("exposes the effective reply root as the transport thread", () => {
const resolveReplyTransport = mattermostPlugin.threading?.resolveReplyTransport;
if (!resolveReplyTransport) {
@@ -249,8 +278,30 @@ describe("mattermostPlugin", () => {
threadId: "other-thread",
}),
).toEqual({
replyToId: "post-parent",
threadId: "post-parent",
replyToId: "other-thread",
threadId: "other-thread",
});
expect(
resolveReplyTransport({
cfg: {},
replyToId: "child-post",
replyToIsExplicit: true,
threadId: "root-post",
}),
).toEqual({
replyToId: "root-post",
threadId: "root-post",
});
expect(
resolveReplyTransport({
cfg: {},
replyToId: "child-post",
replyToIsExplicit: false,
threadId: "root-post",
}),
).toEqual({
replyToId: "root-post",
threadId: "root-post",
});
expect(
resolveReplyTransport({
@@ -402,6 +453,17 @@ describe("mattermostPlugin", () => {
},
}),
).toBeUndefined();
expect(
resolveAutoThreadId({
cfg: {},
to: "tqfek9psh7fw8mpa5berwyytqw",
toolContext: {
currentChannelId: "channel:tqfek9psh7fw8mpa5berwyytqw",
currentThreadTs: "root-1",
replyToMode: "all",
},
}),
).toBe("root-1");
expect(
resolveAutoThreadId({
cfg: {},
@@ -714,7 +776,7 @@ describe("mattermostPlugin", () => {
expect(options.replyToId).toBe("post-root");
});
it("keeps explicit reply precedence when threadId is also provided", async () => {
it("uses threadId as the Mattermost root when generic replyTo names a child post", async () => {
const cfg = createMattermostTestConfig();
await mattermostPlugin.actions?.handleAction?.(
@@ -732,7 +794,29 @@ describe("mattermostPlugin", () => {
);
const options = expectSingleMattermostSend("channel:CHAN1", "hello");
expect(options.replyToId).toBe("child-post");
expect(options.replyToId).toBe("post-root");
});
it("keeps explicit replyToId precedence when threadId is also provided", async () => {
const cfg = createMattermostTestConfig();
await mattermostPlugin.actions?.handleAction?.(
createMattermostActionContext({
action: "send",
params: {
to: "channel:CHAN1",
message: "hello",
replyToId: "explicit-root",
threadId: "post-root",
replyTo: "child-post",
},
cfg,
accountId: "default",
}),
);
const options = expectSingleMattermostSend("channel:CHAN1", "hello");
expect(options.replyToId).toBe("explicit-root");
});
it("routes filePath send actions through Mattermost media upload options", async () => {

View File

@@ -258,10 +258,8 @@ function resolveMattermostAutoThreadId(params: {
typeof context?.currentMessageId === "number"
? String(context.currentMessageId)
: normalizeOptionalString(context?.currentMessageId);
const currentTarget = context?.currentChannelId
? normalizeMattermostMessagingTarget(context.currentChannelId)
: undefined;
if (currentThreadId && currentTarget === normalizeMattermostMessagingTarget(params.to)) {
const currentTarget = normalizeMattermostThreadTarget(context?.currentChannelId);
if (currentThreadId && currentTarget === normalizeMattermostThreadTarget(params.to)) {
if (replyToId === currentMessageId) {
return currentThreadId;
}
@@ -276,6 +274,28 @@ function resolveMattermostAutoThreadId(params: {
return replyToId;
}
function normalizeMattermostThreadTarget(raw: string | undefined): string | undefined {
const normalized = raw ? normalizeMattermostMessagingTarget(raw) : undefined;
if (normalized) {
return normalized;
}
const trimmed = normalizeOptionalString(raw);
return trimmed && /^[a-z0-9]{26}$/i.test(trimmed) ? `channel:${trimmed}` : undefined;
}
function matchesMattermostToolContextTarget(params: {
target: string;
toolContext: ChannelThreadingToolContext;
}): boolean {
const target = normalizeMattermostThreadTarget(params.target);
if (!target) {
return false;
}
return [params.toolContext.currentChannelId, params.toolContext.currentMessagingTarget].some(
(currentTarget) => normalizeMattermostThreadTarget(currentTarget) === target,
);
}
function normalizeMattermostThreadId(value: string | number | undefined): string | undefined {
return typeof value === "number" ? String(value) : normalizeOptionalString(value);
}
@@ -420,12 +440,13 @@ const mattermostMessageActions: ChannelMessageActionAdapter = {
: typeof params.message === "string"
? params.message
: "";
// Match the shared runner semantics: trim empty reply IDs away before
// falling back from replyToId to replyTo on direct plugin calls.
// Mattermost post root_id is the thread root. A generic replyTo can name
// the current child post, so prefer threadId unless the caller supplied the
// Mattermost-specific replyToId root directly.
const replyToId =
normalizeOptionalString(params.replyToId) ??
normalizeOptionalString(params.replyTo) ??
normalizeOptionalString(params.threadId);
normalizeOptionalString(params.threadId) ??
normalizeOptionalString(params.replyTo);
const resolvedAccountId = accountId || undefined;
const attachmentMedia = collectMattermostAttachmentMedia(params);
@@ -896,16 +917,18 @@ export const mattermostPlugin: ChannelPlugin<ResolvedMattermostAccount> = create
},
resolveAutoThreadId: ({ to, replyToId, toolContext }) =>
resolveMattermostAutoThreadId({ to, replyToId, toolContext }),
matchesToolContextTarget: ({ target, toolContext }) =>
matchesMattermostToolContextTarget({ target, toolContext }),
resolveReplyTransport: ({ threadId, replyToId, replyToIsExplicit, replyDelivery }) => {
const ambientThreadId = threadId != null ? String(threadId) : undefined;
const resolvedThreadId =
replyDelivery?.chatType === "direct"
? undefined
: replyToIsExplicit
? (replyToId ?? ambientThreadId)
: replyDelivery
? (ambientThreadId ?? replyToId ?? undefined)
: (replyToId ?? ambientThreadId);
: replyDelivery
? replyToIsExplicit
? (replyToId ?? ambientThreadId)
: (ambientThreadId ?? replyToId ?? undefined)
: (ambientThreadId ?? replyToId);
return {
replyToId: replyDelivery?.chatType === "direct" ? null : resolvedThreadId,
threadId: resolvedThreadId ?? null,

View File

@@ -445,13 +445,14 @@ describe("mattermost inbound user posts", () => {
expect(ctx?.Provider).toBe("mattermost");
});
it("merges Mattermost progress preview updates by line identity", async () => {
it("merges Mattermost progress preview updates and clears after message-tool delivery", async () => {
const socket = new FakeWebSocket();
const abortController = new AbortController();
mockState.abortController = abortController;
const draftStream = {
update: vi.fn(),
flush: vi.fn(async () => {}),
clear: vi.fn(async () => {}),
stop: vi.fn(async () => {}),
};
mockState.createMattermostDraftStream.mockReturnValue(draftStream);
@@ -505,6 +506,7 @@ describe("mattermost inbound user posts", () => {
status: "completed",
progressText: "done",
});
await params.replyOptions?.onObservedReplyDelivery?.();
abortController.abort();
});
@@ -543,6 +545,9 @@ describe("mattermost inbound user posts", () => {
socket.emitClose(1000);
await monitor;
const replyOptions = mockState.dispatchReplyFromConfig.mock.calls.at(0)?.[0].replyOptions;
expect(replyOptions?.allowProgressCallbacksWhenSourceDeliverySuppressed).toBe(true);
expect(draftStream.clear).toHaveBeenCalledTimes(1);
const updates = draftStream.update.mock.calls.map((call) => String(call[0]));
expect(updates.at(-1)).toContain("Read");
expect(updates.at(-1)).toContain("Exec");

View File

@@ -1922,6 +1922,11 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
dispatcher,
replyOptions: {
...replyOptions,
allowProgressCallbacksWhenSourceDeliverySuppressed:
draftToolProgressEnabled ? true : undefined,
onObservedReplyDelivery: draftToolProgressEnabled
? () => draftStream.clear()
: undefined,
disableBlockStreaming: true,
...(suppressDefaultToolProgressMessages
? { suppressDefaultToolProgressMessages: true }

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