Compare commits

..

346 Commits

Author SHA1 Message Date
Bek
ed45202b70 fix: route media understanding through codex auth 2026-05-27 02:18:02 -04:00
Vincent Koc
586a6ce03b fix(crabbox): use host-visible local work roots 2026-05-27 07:06:19 +02:00
Peter Steinberger
15c0dfa61b docs(changelog): refresh 2026.5.26 notes 2026-05-27 05:59:20 +01:00
Jesse Merhi
42f0822bfa fix(exec): hide unavailable durable approval actions (#86359)
* fix(macos): align ask always approval actions

* fix(macos): harden approval prompt decisions

* fix(ui): satisfy approval action lint

* fix(infra): settle jsonl sockets on close

* fix(ui): explain unavailable durable approvals

* test(macos): document legacy approval fallback
2026-05-27 14:58:11 +10:00
Alex Knight
2899560a6b fix(reply): derive explicit control command turns
Derive explicit source-reply command turns from authorized control-command bodies when legacy command source metadata is missing.

Preserve native/text structured command semantics, keep unauthorized native commands and structured normal command bodies on plugin-owned fallback paths, and pass bot username normalization through the derived detection.

Co-authored-by: Alex Knight <aknight@atlassian.com>
2026-05-27 05:57:04 +01:00
Vincent Koc
44c1cc8285 fix(e2e): check onboarding systemd noise 2026-05-27 06:48:27 +02:00
Andy Ye
2e3b4b58a1 test(agents): cover cold default model alias resolution
Adds regression coverage for provider-qualified default models with aliasless configured model entries.

Refs #86635

Co-authored-by: Andy Ye <35905412+TurboTheTurtle@users.noreply.github.com>
2026-05-27 05:47:14 +01:00
Peter Steinberger
5371b96af1 fix: prefer trusted argv runtime fallback roots 2026-05-27 05:46:51 +01:00
Peter Steinberger
e71b6f7e57 fix: expand startup argv runtime fallback hints 2026-05-27 05:46:51 +01:00
Peter Steinberger
2b9be22c0b fix: keep plugin runtime fallback on startup root 2026-05-27 05:46:51 +01:00
Peter Steinberger
78b2aeeae4 test: cover plugin runtime diagnostic context 2026-05-27 05:46:51 +01:00
Andy Ye
66a8262028 Fix runtime fallback startup argv default 2026-05-27 05:46:51 +01:00
Andy Ye
41fa603aa8 Fix plugin runtime module resolution diagnostics 2026-05-27 05:46:51 +01:00
Andy Tien
8246e91e92 fix(ui): show config open failure feedback (#87108)
Fixes #87020.

Summary:
- Surface config.openFile failures in the Control UI instead of silently doing nothing.
- Return actionable gateway errors for headless opener failures, including the config path.
- Add gateway and UI controller regression coverage for the failed-open path.

Verification:
- node scripts/run-vitest.mjs run --config test/vitest/vitest.gateway-methods.config.ts src/gateway/server-methods/config.test.ts --reporter=dot
- node scripts/run-vitest.mjs run --config test/vitest/vitest.ui.config.ts ui/src/ui/controllers/config.test.ts --reporter=dot
- pnpm check:changed via Blacksmith Testbox tbx_01ksktydqx6mk3n20yevcbkwtn
- autoreview --mode local

Thanks @Linux2010.

Co-authored-by: Linux2010 <35169750+Linux2010@users.noreply.github.com>
2026-05-27 05:45:45 +01:00
Vincent Koc
59818226a9 fix(e2e): bound Telegram RTT bot API calls 2026-05-27 06:44:14 +02:00
Gio Della-Libera
bf1a5c3303 fix(install): bound finalization probes (#86997)
Bounds nonessential installer finalization probes so npm prefix and daemon-status checks warn and fall back instead of hanging setup.

Thanks @giodl73-repo!
2026-05-27 05:39:05 +01:00
Agustin Rivera
119d2359f3 fix(memory): reject prompt-like memory stores (#87142)
* fix(memory): reject prompt-like memory stores

* fix(changelog): mention memory store rejection
2026-05-26 21:37:29 -07:00
Vincent Koc
6b68d05fdc fix(e2e): bound release user journey fixture probes 2026-05-27 06:33:08 +02:00
Vincent Koc
d88681662b fix(e2e): bound bundled runtime HTTP probes 2026-05-27 06:30:15 +02:00
Peter Steinberger
8fa4fad3a7 perf(gateway): skip duplicate turn session touch 2026-05-27 05:28:10 +01:00
Peter Steinberger
1c8a11265b test: avoid repeated module reloads in unit tests 2026-05-27 05:24:40 +01:00
zhang-guiping
608fa52c80 fix(media): keep explicit workspace roots scoped
Fixes MEDIA delivery for agent workspaces named `workspace-*` by carrying the explicit resolved workspace directory into scoped outbound media local roots. The unscoped default local media boundary remains closed for `workspace-*` sibling directories.

Proof:
- node scripts/run-vitest.mjs src/media/read-capability.test.ts src/media/local-media-access.test.ts
- pnpm exec oxfmt --write --threads=1 src/media/read-capability.ts src/media/read-capability.test.ts src/media/local-media-access.test.ts
- node scripts/run-vitest.mjs src/media/read-capability.test.ts src/media/local-media-access.test.ts src/auto-reply/reply/reply-media-paths.test.ts
- /Users/steipete/Projects/agent-scripts/skills/autoreview/scripts/autoreview --mode branch --base origin/main

Fixes #86879.
2026-05-27 05:24:07 +01:00
Vincent Koc
fca77dcb19 fix(e2e): bound bundled runtime smoke commands 2026-05-27 06:21:17 +02:00
Peter Steinberger
bbfcdea202 test: route more command tests through light suite 2026-05-27 05:20:51 +01:00
Vincent Koc
4b23b36f20 fix(scripts): short-circuit helper help 2026-05-27 06:20:39 +02:00
Peter Steinberger
10056c9346 test: harden docker smoke portability 2026-05-27 00:19:07 -04:00
Sebastien Tardif
4980c32846 fix(agents): recover failed subagent lifecycle completions
Recover failed subagent lifecycle completions through a shared retry/resume recovery path.

Proof:
- node scripts/run-vitest.mjs src/agents/subagent-registry.test.ts src/agents/subagent-registry.lifecycle-retry-grace.e2e.test.ts
- pnpm changed:lanes --json
- pnpm check:changed (Blacksmith Testbox tbx_01ksksytyrfxscxs78e8f3eegk)
- .agents/skills/autoreview/scripts/autoreview --mode local

Co-authored-by: Sebastien Tardif <sebtardif@ncf.ca>
2026-05-27 05:18:58 +01:00
Vincent Koc
dd44a47ba3 fix(e2e): hard kill timed out host commands 2026-05-27 06:16:02 +02:00
Peter Steinberger
2831d697ce test: move lightweight command tests to light suite 2026-05-27 05:13:11 +01:00
Vincent Koc
2cc6871553 fix(scripts): handle helper cli help 2026-05-27 06:11:57 +02:00
Vincent Koc
6d5c15a744 fix(gateway): bound loopback preflight calls 2026-05-27 06:11:19 +02:00
Agustin Rivera
e72621e566 fix(hooks): enforce default hook agent allowlist
Enforce hook allowedAgentIds against the effective default agent when hook payloads omit or blank agentId, while preserving omitted-agent dispatch semantics for default/global routing.

Also updates the affected generated hook config docs from the contributor change and fixes the current-main memory-core test mock after rebasing the PR branch.

Verification:
- pnpm format:check extensions/memory-core/src/dreaming.test.ts src/gateway/hooks.ts src/gateway/hooks.test.ts src/gateway/server/hooks-request-handler.ts src/gateway/server.hooks.test.ts && git diff --check
- node scripts/run-vitest.mjs run --config test/vitest/vitest.gateway-server.config.ts src/gateway/hooks.test.ts src/gateway/server.hooks.test.ts --reporter=dot --pool=forks --no-file-parallelism --testTimeout=120000
- node scripts/run-tsgo.mjs -p test/tsconfig/tsconfig.extensions.test.json --incremental --tsBuildInfoFile .artifacts/tsgo-cache/extensions-test-local-pr87124.tsbuildinfo
- pnpm check:test-types
- .agents/skills/autoreview/scripts/autoreview --mode branch --base origin/main
- GitHub PR merge state CLEAN; CodeQL Critical Quality rerun succeeded after first runner checkout wedged

Co-authored-by: Agustin Rivera <agustin@rivera-web.com>
2026-05-27 05:05:18 +01:00
Vincent Koc
2814ab66fd fix(e2e): handle docker helper cli help 2026-05-27 06:04:53 +02:00
Steady-ai
eb8f9b46da fix(codex): avoid native compaction on budget triggers (#86772)
* fix(codex): avoid native compaction on budget triggers

* fix(codex): require manual trigger for native compaction

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-05-27 05:04:35 +01:00
Peter Steinberger
05ff771010 test: speed up plugin test fixtures 2026-05-27 05:01:57 +01:00
Vincent Koc
de94217774 fix(e2e): bound openai chat tools client 2026-05-27 05:58:15 +02:00
Vincent Koc
981ae137f5 fix(e2e): bound upgrade survivor probes 2026-05-27 05:57:18 +02:00
Gio Della-Libera
371c4d621a fix(doctor): keep hooks model checks read-only (#86101)
Behavior addressed: doctor hooks model validation now loads the model catalog read-only, so lint/doctor can warn without writable catalog side effects.
Real environment tested: local temp merged tree on current origin/main.
Exact steps or command run after this patch: node scripts/run-vitest.mjs src/flows/doctor-core-checks.test.ts src/flows/doctor-health-contributions.test.ts --reporter=dot; ./node_modules/.bin/oxfmt --check --threads=1 src/flows/doctor-core-checks.ts src/flows/doctor-health-contributions.ts src/flows/doctor-core-checks.test.ts src/flows/doctor-health-contributions.test.ts; ./node_modules/.bin/oxlint src/flows/doctor-core-checks.ts src/flows/doctor-health-contributions.ts src/flows/doctor-core-checks.test.ts src/flows/doctor-health-contributions.test.ts; git diff --check origin/main <merged-tree>
Evidence after fix: 2 test files passed, 30 tests passed; oxfmt passed; oxlint passed; diff check passed.
Observed result after fix: hooks.gmail.model doctor paths call loadModelCatalog with readOnly true in both structured and legacy health surfaces.
What was not tested: GitHub Actions run details could not be refreshed because the Actions API was rate-limited; gh reported no required checks for the branch.

Thanks @giodl73-repo.

Co-authored-by: Gio Della-Libera <giodl73@gmail.com>
2026-05-27 04:55:39 +01:00
Vincent Koc
340f480a7b fix(installer): tighten nonroot smoke node preflight 2026-05-27 05:52:37 +02:00
Vincent Koc
d58f864e23 fix(e2e): bound HTTP readiness probes 2026-05-27 05:52:01 +02:00
Gio Della-Libera
a4e0b6ef47 fix(daemon): keep node tasks off gateway listener cleanup
Keep Windows node service stop/restart/status from treating the gateway listener port as node-owned runtime evidence. Node Scheduled Task and Startup fallback paths now match the installed node host command line before reporting or terminating a node runtime, so WSL2 gateway loopback connectivity is not disturbed by node lifecycle commands.

Fixes #85289.

Verification:
- node scripts/run-vitest.mjs src/daemon/schtasks.startup-fallback.test.ts src/daemon/schtasks.stop.test.ts
- git diff --check

Co-authored-by: Gio Della-Libera <giodl73@gmail.com>
2026-05-27 04:51:51 +01:00
Peter Steinberger
d2711c900d perf(gateway): reuse prepared auth stores 2026-05-27 04:51:43 +01:00
Peter Steinberger
1ce363743a test: speed up codex app server run attempts 2026-05-27 04:51:20 +01:00
Peter Steinberger
231a812276 build(codex): update Codex CLI to 0.134.0 2026-05-27 04:42:12 +01:00
Peter Steinberger
989a369112 docs(skills): omit advisory ids from changelog notes 2026-05-27 04:41:58 +01:00
Peter Steinberger
140892ce3d test: speed up test project routing 2026-05-27 04:41:30 +01:00
Jesse Merhi
5297eebe88 Fix stale approval prompts in Control UI (#86270)
* fix(ui): clear stale approval prompts

* fix(ui): keep approval prompt state current

* test: update approval controller mocks

* fix(ui): keep escape denying approvals

* refactor(ui): keep approval decisions in app
2026-05-27 13:38:52 +10:00
Vincent Koc
49d605ece7 fix(installer): reject stale cli node runtimes 2026-05-27 05:31:03 +02:00
Peter Steinberger
acbb06e266 test: harden e2e harness isolation 2026-05-26 23:20:42 -04:00
Peter Steinberger
96c576674d fix: keep approval runtime token local-only
Follow-up to #86771. Keep approval runtime authority source-based instead of loopback-host-based.\n\nProof: autoreview clean; Crabbox AWS run_5f28c413194d on cbx_ec9ef82cf95a passed 5 focused files / 68 tests plus formatter.\n\nCo-authored-by: fuller-stack-dev <263060202+fuller-stack-dev@users.noreply.github.com>
2026-05-27 04:20:38 +01:00
Peter Steinberger
145b57c734 perf(gateway): defer skipped-channel sidecars 2026-05-27 04:20:26 +01:00
Peter Steinberger
d606881807 docs(changelog): omit advisory id from release notes 2026-05-27 04:16:17 +01:00
Peter Steinberger
26c0c19352 docs(changelog): refresh 2026.5.26 notes 2026-05-27 04:15:52 +01:00
Peter Steinberger
c8d20aeb48 docs(skills): add release changelog update workflow 2026-05-27 04:14:48 +01:00
Vincent Koc
c965b3a1ae fix(e2e): bound upgrade survivor cli checks 2026-05-27 05:13:55 +02:00
Peter Steinberger
5177180376 test: speed up doctor config flow tests 2026-05-27 04:11:02 +01:00
Agustin Rivera
c1151ea899 fix(events): sanitize queued system markers (#87094)
* fix(events): sanitize queued system markers

* fix(changelog): record system event sanitization
2026-05-26 20:07:39 -07:00
Peter Steinberger
f393ebe54e fix(gateway): remove redundant unknown union 2026-05-26 23:06:26 -04:00
Peter Steinberger
e7f644c7b1 test: speed up model fallback tests 2026-05-27 04:06:03 +01:00
Andy Ye
ae52be9f32 fix(imessage): stage remote media before understanding
Stage remote iMessage attachments before media understanding so the image pipeline receives local remote-cache paths instead of raw macOS Messages paths.

Fixes #87089

Co-authored-by: Andy Ye <35905412+TurboTheTurtle@users.noreply.github.com>
2026-05-27 04:05:36 +01:00
Chunyue Wang
982e88821c fix(gateway): drop stale subagent announce history
Fix stale `subagent_announce` history hydration after `/new` by filtering pre-session-start announce/user reply pairs before `chat.history` projection.

Maintainer fixups added:
- require the adjacent assistant reply to carry a pre-session timestamp before dropping it
- preserve record timestamps for oversized transcript placeholders
- run the filter after Claude CLI history import and support imported timestamp/text fallback
- overread one local transcript message only as boundary context so limit-window edges do not leak stale assistant replies

Verification:
- `git diff --check`
- `node scripts/run-vitest.mjs src/gateway/server-methods/server-methods.test.ts src/gateway/session-utils.fs.test.ts src/gateway/session-history-state.test.ts src/gateway/cli-session-history.test.ts src/gateway/server.chat.gateway-server-chat-b.test.ts` -> 11 files, 463 tests passed
- `/Users/steipete/Projects/agent-scripts/skills/autoreview/scripts/autoreview --mode branch --base origin/main` -> clean, no accepted/actionable findings

Thanks @openperf.
2026-05-27 03:59:08 +01:00
Jason (Json)
13cfb77c10 fix: repair local approval resolution (#86771) 2026-05-26 19:56:30 -07:00
Vincent Koc
f89fcdd5b3 fix(e2e): bound codex media plugin setup 2026-05-27 04:55:21 +02:00
Val Alexander
b4f69286fd fix(gateway): stop chat timeout fallback cascade
Fix gateway/chat timeout abort propagation so timed-out runs do not cascade through fallbacks. Preserve provider timeout errors when the gateway abort signal did not fire, and keep timeout stop reasons in async gateway agent results. Includes regression coverage for chat, follow-up, memory flush, fallback classification, and gateway agent timeout results. Fixes #83962.
2026-05-27 03:54:44 +01:00
Peter Steinberger
b74cd69c6f perf(gateway): defer scheduled service imports 2026-05-27 03:52:15 +01:00
Peter Steinberger
0126aba57f test: speed up capability cli tests 2026-05-27 03:48:59 +01:00
Peter Steinberger
0ee4ccf02c perf(gateway): defer startup warning fallback imports 2026-05-27 03:45:42 +01:00
Vincent Koc
7014bd0ff1 fix(gateway): bound watch regression teardown 2026-05-27 04:45:11 +02:00
Peter Steinberger
a43cf2b5db test: type current plugin metadata snapshot mock 2026-05-27 03:44:27 +01:00
Peter Steinberger
1242931ba8 test: align WebChat delivery hint expectations 2026-05-27 03:44:27 +01:00
Peter Steinberger
0cfccdb0c7 fix(codex): keep WebChat delivery hints out of user requests
Land PR #87003 from @ragesaq with a maintainer fix for routed room events.

Co-authored-by: Forge <forge@psiclawops.dev>
2026-05-27 03:44:27 +01:00
Peter Steinberger
657f9d1422 test: speed up command secret gateway tests 2026-05-27 03:43:52 +01:00
Sarah Fortune
41962ed369 fix(status): show explicit fast mode state (#87115) 2026-05-26 19:43:14 -07:00
Josh Lehman
9119492f15 fix: preserve plugin LLM command auth (#85936)
Merged via squash.

Prepared head SHA: e61c724708
Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com>
Reviewed-by: @jalehman
2026-05-26 22:41:52 -04:00
Peter Steinberger
f7a39f487c test: align loopback prompt metadata 2026-05-26 22:38:22 -04:00
Peter Steinberger
166097e564 perf(gateway): reuse metadata for startup warnings 2026-05-27 03:36:00 +01:00
Peter Steinberger
53f36a8ee6 fix(plugin-sdk): stabilize diagnostic event root alias
Fixes #87082.

Co-authored-by: Kaspre <kaspre@gmail.com>
2026-05-27 03:34:54 +01:00
Neerav Makwana
6842d72a9c fix(tui): queue prompts submitted while busy (#86722)
* fix(tui): queue busy prompt submissions

* fix(tui): queue local busy sends

* fix(tui): keep gateway busy gate

* fix(tui): treat injected backends as local

* fix(tui): preserve stop interrupts

* fix(tui): satisfy queue readiness typing

* fix(tui): keep stop aborting active runs

* fix(tui): limit embedded stop shortcut

* fix(tui): stop active and queued runs

* fix(tui): block gateway busy slash sends

* fix(tui): let stop text pass busy gate

* fix(tui): allow queued stop text

* fix(tui): clear queued abort state

* fix(tui): let stop abort finishing local runs

* fix(tui): abort terminal local maintenance on stop

* fix(tui): emit aborted after stopped maintenance

* fix(tui): preserve stop fallback and queue order

* fix(tui): let idle local stop finish

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-05-27 03:34:36 +01:00
Peter Steinberger
f34a527f61 test: speed up tooling tests 2026-05-27 03:33:36 +01:00
Kaspre
b3f8a0edf3 fix(plugin-sdk): use Function.name to find onDiagnosticEvent export (#87084)
* fix(plugin-sdk): use Function.name to find onDiagnosticEvent export

normalizeDiagnosticEventsModule hardcodes `mod.r` as the fallback alias
for onDiagnosticEvent, but the bundler reassigns export aliases across
builds. On 2026.5.25-beta.1, `r` is emitFailoverEvent — calling it as
onDiagnosticEvent returns a non-function, so the combo unsubscribe
closure throws TypeError on every gateway stop.

Replace the hardcoded letter with Function.name introspection. JS
functions retain their original .name regardless of export aliasing,
so this survives bundler alias changes.

Fixes #87082

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

* test(plugin-sdk): cover diagnostic event alias shifts

* fix(plugin-sdk): harden diagnostic alias cleanup

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 19:31:41 -07:00
Sarah Fortune
df6ec2822f Suppress transient runner failures in channels (#87069) 2026-05-26 19:30:43 -07:00
Vincent Koc
698c40ef9d fix(e2e): bound telegram live hot path 2026-05-27 04:29:06 +02:00
Peter Steinberger
5aaad5f492 test: speed up crabbox wrapper tests 2026-05-27 03:26:50 +01:00
Peter Steinberger
df659d124d refactor(telegram): encode conversation binding mode 2026-05-27 03:26:31 +01:00
Fermin Quant
cecb07655a fix(agents): correlate pathless read diagnostics (#86977)
* fix(agents): correlate pathless read diagnostics

* fix(agents): trace embedded tool starts

* fix(agents): honor read aliases in trace diagnostics

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-05-27 03:23:55 +01:00
Peter Steinberger
cdfb1b4bf1 perf: trim gateway session cache churn 2026-05-27 03:23:26 +01:00
Peter Steinberger
90653775a9 test: speed up update cli tests 2026-05-27 03:16:21 +01:00
Peter Steinberger
27ad3d7eeb fix(doctor): map runtime tool schema health 2026-05-26 22:12:04 -04:00
Vincent Koc
8fa5ecb81d fix(e2e): bound update channel CLI checks 2026-05-27 04:11:31 +02:00
Peter Steinberger
c8364b43de test: speed up run-node tests 2026-05-27 03:11:21 +01:00
Agustin Rivera
06047005ef fix(browser): validate current tab before snapshots (#78526)
* fix(browser): validate current tab before snapshots

* fix(browser): reject snapshot selector before SSRF guard

* fix(test): stabilize plugin activation normalization

* fix(ci): fetch opengrep base history

* fix(snapshot): enforce snapshot ssrf policy

* docs(changelog): add unreleased entry for snapshot SSRF fix

* Revert "docs(changelog): add unreleased entry for snapshot SSRF fix"

This reverts commit 4f3031ff65.

* fix(changelog): record snapshot ssrf entry
2026-05-26 19:11:01 -07:00
Peter Steinberger
42d6cf66d3 fix(media): require staged sandbox media refs 2026-05-27 03:08:50 +01:00
Peter Steinberger
8d6b599737 perf: trim gateway startup planning 2026-05-27 03:04:15 +01:00
Vincent Koc
d7d037b46f fix(codex): quarantine unsupported dynamic tool schemas 2026-05-27 04:02:07 +02:00
Vincent Koc
d0cb7ba55b fix(e2e): bound package cli scenarios 2026-05-27 04:00:55 +02:00
Peter Steinberger
716d719d4c ci: prepare pnpm for crabbox hydrate 2026-05-26 21:58:49 -04:00
Vincent Koc
81d22e8f53 fix(e2e): bound kitchen sink gateway teardown 2026-05-27 03:58:14 +02:00
Peter Steinberger
97541170ca test: speed up test routing and parallels smoke tests 2026-05-27 02:56:47 +01:00
Gio Della-Libera
5304682593 fix(onboard): preserve configured default model (#87000)
Preserve user-configured default model settings when provider onboarding preset helpers merge provider models and aliases.

Fixes #75720.

Thanks @giodl73-repo.
2026-05-27 02:52:41 +01:00
kesslerio
b8ea6d2aee fix(telegram): route plugin-bound topic messages 2026-05-27 02:52:25 +01:00
Vincent Koc
1baab3bef5 fix(gateway): bound benchmark teardown waits 2026-05-27 03:49:41 +02:00
Samuel Soares da Silva
286964cd6a fix(diagnostics): recover orphaned session activity
Recover idle queued sessions whose diagnostic activity retained stale ownerless model or tool calls by classifying them as recoverable session.stuck after the usual recovery gates. Yield the event loop before stale session-lock process inspection so sync process lookup cannot monopolize lock contention paths.

Docs now describe the widened session.stuck telemetry contract for recoverable stale bookkeeping, including ownerless activity. Thanks @samuelsoaress.

Refs #84903.

Co-authored-by: samuelsoaress <samuelsoares177778@gmail.com>
2026-05-27 02:47:42 +01:00
Peter Steinberger
a67ee0f7a2 perf: avoid redundant runtime postbuild sync 2026-05-27 02:44:47 +01:00
Peter Steinberger
6290ed52ff fix(media): resolve inbound media refs consistently
Summary:
- Resolve inbound media references through the shared media-reference path before workspace-relative handling.
- Reuse the same sandbox rewrite for Pi native images and sandbox media bridge paths.
- Add regression coverage for managed inbound images, sandbox-staged media references, and invalid media IDs.
- Fix current lint by using non-mutating cpuprofile sorting.

Verification:
- node scripts/run-vitest.mjs src/media/media-reference.test.ts src/agents/sandbox-media-paths.test.ts src/agents/pi-embedded-runner/run/images.test.ts src/agents/tools/image-tool.test.ts src/media/web-media.test.ts src/agents/tools/pdf-tool.test.ts src/agents/tools/image-generate-tool.test.ts src/agents/tools/video-generate-tool.test.ts src/agents/tools/music-generate-tool.test.ts
- node scripts/run-oxlint-shards.mjs --threads=8
- git diff --check
- /Users/steipete/Projects/agent-skills/skills/autoreview/scripts/autoreview --mode branch --base origin/main
- GitHub CI rollup passed for eceea707a7

Fixes #87024.
Supersedes #87055; thanks @TurboTheTurtle for the report and initial fix direction.

Co-authored-by: Andy Ye <35905412+TurboTheTurtle@users.noreply.github.com>
2026-05-27 02:44:30 +01:00
Vincent Koc
b74984dd50 fix(e2e): bound logged onboard commands 2026-05-27 03:41:52 +02:00
Vincent Koc
dfadc7b704 fix(ollama): normalize greedy top_p (#87049) 2026-05-27 02:41:30 +01:00
Peter Steinberger
1d2bf82461 test: speed up crabbox config shim 2026-05-27 02:41:14 +01:00
Peter Steinberger
1954468efc test: speed up crabbox wrapper tests 2026-05-27 02:41:14 +01:00
Michael Appel
10546e57dd clickclack: enforce inbound sender allowlist [AI] (#83741)
* fix: enforce clickclack sender allowlist

* addressing codex review

* test(clickclack): drop removed senderIsOwner from inbound test fixture
2026-05-26 18:41:12 -07:00
Shakker
223655dfc4 fix: preserve provenance through user turn hooks 2026-05-27 02:38:58 +01:00
Shakker
2e8f1d439d fix: preserve user turn provenance metadata 2026-05-27 02:38:58 +01:00
Shakker
91cb04265b fix: keep user turn replay hooks idempotent 2026-05-27 02:38:58 +01:00
Shakker
e4c42ae786 fix: use selected user transcript text 2026-05-27 02:38:58 +01:00
Shakker
fafed256a6 fix: isolate chat transcript fallback failures 2026-05-27 02:38:58 +01:00
Shakker
b9c2590151 fix: use cleaned user turn transcript text 2026-05-27 02:38:58 +01:00
Shakker
c0f8224109 fix: resolve final codex mirror prompt 2026-05-27 02:38:58 +01:00
Shakker
2bd38da4b0 fix: mark final codex mirror user persistence 2026-05-27 02:38:58 +01:00
Shakker
ffb8350478 test: trim duplicate user turn persistence coverage 2026-05-27 02:38:58 +01:00
Shakker
00ab2f2cba test: wait for initial session task cleanup 2026-05-27 02:38:58 +01:00
Shakker
9263e3887e fix: preserve inline image routing with staged media 2026-05-27 02:38:58 +01:00
Shakker
c86214345f fix: keep user turn enrichment off dispatch 2026-05-27 02:38:58 +01:00
Shakker
696fb41c5b fix: restore user turn persistence checks 2026-05-27 02:38:58 +01:00
Shakker
848c38907d refactor: drop unused user turn update mode 2026-05-27 02:38:58 +01:00
Shakker
20d7bf7525 refactor: remove duplicate user turn handoff 2026-05-27 02:38:58 +01:00
Shakker
fe44ecd8f0 refactor: trim duplicated transcript tests 2026-05-27 02:38:58 +01:00
Shakker
8bbd4baa9a refactor: trim user turn transcript API 2026-05-27 02:38:58 +01:00
Shakker
d55fe4b6ae fix: persist cli user turns to admitted session target 2026-05-27 02:38:58 +01:00
Shakker
44bdc521f7 refactor: carry prepared user turns on recorder 2026-05-27 02:38:58 +01:00
Shakker
481f432e27 refactor: centralize prepared user turn merge 2026-05-27 02:38:58 +01:00
Shakker
0fd8c507bf test: cover cli recorder-owned user persistence 2026-05-27 02:38:58 +01:00
Shakker
33b24d6f2e refactor: reuse user turn recorder in cli persistence 2026-05-27 02:38:58 +01:00
Shakker
ce465d4422 refactor: let recorder track runtime persistence pending 2026-05-27 02:38:58 +01:00
Shakker
1679b2f14c refactor: drop unused inline user turn persistence wrappers 2026-05-27 02:38:58 +01:00
Shakker
d3465756f6 refactor: remove reply option user persistence callbacks 2026-05-27 02:38:58 +01:00
Shakker
1310c92be7 test: cover user turn transcript recorder lifecycle 2026-05-27 02:38:58 +01:00
Shakker
e9a2f10900 refactor: mark user turn persistence inside runtimes 2026-05-27 02:38:58 +01:00
Shakker
05001e102e refactor: carry user turn recorder into embedded runs 2026-05-27 02:38:58 +01:00
Shakker
e9d0ac2aba refactor: pass user turn recorder through reply options 2026-05-27 02:38:58 +01:00
Shakker
f3a43a90d3 refactor: route cli user turn persistence through recorder 2026-05-27 02:38:58 +01:00
Shakker
8a1b7710d7 refactor: add user turn transcript recorder 2026-05-27 02:38:58 +01:00
Shakker
00e68b195e perf: keep transcript idempotency scans explicit 2026-05-27 02:38:58 +01:00
Shakker
6510aecfb4 fix: infer later user turn media types 2026-05-27 02:38:58 +01:00
Shakker
662e5b67d5 fix: persist user turns after runtime mirror failures 2026-05-27 02:38:58 +01:00
Shakker
953fe4d6e1 fix: forward pending user turn persistence 2026-05-27 02:38:58 +01:00
Shakker
48034a5cc7 fix: preserve user turn idempotency after hooks 2026-05-27 02:38:58 +01:00
Shakker
51d3e363e3 fix: return persisted codex mirror user messages 2026-05-27 02:38:58 +01:00
Shakker
8caed9d66d fix: honor transcript hooks in user turn fallbacks 2026-05-27 02:38:58 +01:00
Shakker
8f2200777a fix: fail cli runs on user turn persistence errors 2026-05-27 02:38:58 +01:00
Shakker
b1b533c627 fix: prepare text chat send user turns 2026-05-27 02:38:58 +01:00
Shakker
d241a996de fix: keep exact assistant idempotency locked 2026-05-27 02:38:58 +01:00
Shakker
5d64ebe1de fix: resolve staged transcript media paths 2026-05-27 02:38:58 +01:00
Shakker
dc692aa6f6 perf: avoid duplicate transcript idempotency scans 2026-05-27 02:38:58 +01:00
Shakker
a9e51732db fix: keep chat send transcript text clean 2026-05-27 02:38:58 +01:00
Shakker
209eadcd2d fix: notify codex prompt mirror persistence 2026-05-27 02:38:58 +01:00
Shakker
7d3eabdee8 fix: harden chat send transcript fallback 2026-05-27 02:38:58 +01:00
Shakker
10f4096f11 fix: persist chat send user turns after hooked startup failures 2026-05-27 02:38:58 +01:00
Shakker
52b127b9fb test: avoid transcript filename assumptions 2026-05-27 02:38:58 +01:00
Shakker
0f5ce05753 fix: dedupe user turn transcript appends 2026-05-27 02:38:58 +01:00
Shakker
cf265732c7 fix: mirror prepared codex user turns 2026-05-27 02:38:58 +01:00
Shakker
98c01585b7 fix: isolate reply persistence notifications 2026-05-27 02:38:58 +01:00
Shakker
956a967047 fix: isolate cli persistence notifications 2026-05-27 02:38:58 +01:00
Shakker
8ad308d3e9 fix: keep pre-start chat send fallback persistence 2026-05-27 02:38:58 +01:00
Shakker
1c35ec6cd7 fix: preserve chat send user turns on started failures 2026-05-27 02:38:58 +01:00
Shakker
ce5adbd2c2 fix: keep gateway fallback tied to user persistence 2026-05-27 02:38:58 +01:00
Shakker
e1ff653ade fix: preserve queued media user turns for pi followups 2026-05-27 02:38:58 +01:00
Shakker
d9b5bdada1 refactor: persist cli user turns after hook approval 2026-05-27 02:38:58 +01:00
Shakker
1878662a91 refactor: add inline user turn append helper 2026-05-27 02:38:58 +01:00
Shakker
bf3dad63aa refactor: keep inline transcript error options separate 2026-05-27 02:38:58 +01:00
Shakker
38b0984d33 refactor: centralize inline user turn persistence 2026-05-27 02:38:58 +01:00
Shakker
41ad8f00eb refactor: persist followup cli user turns through sessions 2026-05-27 02:38:58 +01:00
Shakker
982c0aaa77 refactor: route chat send user transcripts through sessions 2026-05-27 02:38:58 +01:00
Shakker
5268bf900e refactor: persist cli user turns through sessions 2026-05-27 02:38:58 +01:00
Shakker
12adc30ac8 refactor: centralize user turn transcript persistence 2026-05-27 02:38:58 +01:00
Shakker
7b27c0495e test: cover text-only media followups 2026-05-27 02:38:58 +01:00
Shakker
840cea5d6e refactor: use shared user turn builder for command transcripts 2026-05-27 02:38:58 +01:00
Shakker
91aee9cd51 fix: keep media transcript text clean 2026-05-27 02:38:58 +01:00
Shakker
928a75a365 refactor: route chat send media through user turn input 2026-05-27 02:38:58 +01:00
Shakker
e5e65431fd refactor: prepare media user turns for replies 2026-05-27 02:38:58 +01:00
Shakker
833520b13a refactor: derive user turn media from fields 2026-05-27 02:38:58 +01:00
Shakker
56e461b76a refactor: thread prepared user turn through embedded runs 2026-05-27 02:38:58 +01:00
Shakker
b9f6c96d18 refactor: support prepared user turn persistence 2026-05-27 02:38:58 +01:00
Shakker
5c69853cd6 refactor: use shared user turn message for chat send updates 2026-05-27 02:38:58 +01:00
Shakker
cc4dca69eb refactor: build persisted user turn messages 2026-05-27 02:38:58 +01:00
Shakker
4a4ef7be5e fix: keep user turn media fields aligned 2026-05-27 02:38:58 +01:00
Shakker
f65fec27a2 refactor: add user turn media field builder 2026-05-27 02:38:58 +01:00
Peter Steinberger
47f7ec7631 perf: reduce session store clone churn 2026-05-27 02:35:53 +01:00
Peter Steinberger
b9ade75fec test(agents): deflake code mode guest error check 2026-05-27 02:34:17 +01:00
Peter Steinberger
0fe7479752 fix(agents): fence yield abort lock release 2026-05-27 02:32:51 +01:00
OpenClaw Assistant
a7eab7467f fix(agents): release yield abort session lock
Release the embedded attempt session lock before sessions_yield abort cleanup waits for session events and rewrites yielded-parent artifacts.

This keeps the existing bounded settle wait while preventing child completion callbacks from contending on the coarse parent transcript lock.

Adds focused session-lock lifecycle coverage.
2026-05-27 02:32:51 +01:00
Agustin Rivera
42b8898e8e fix(filefetch): wrap fetched text as external content (#87062)
* fix(filefetch): wrap fetched text as external content

* fix(release): add file transfer changelog entry
2026-05-26 18:29:48 -07:00
Peter Steinberger
ffe1213bf8 fix(ci): satisfy script oxlint sort rule 2026-05-27 02:27:33 +01:00
Peter Steinberger
a3e7473df2 ci: tolerate gateway status help probe hangs 2026-05-27 02:23:11 +01:00
Zee Zheng
e9823023f4 fix(memory-core): close providers created during shutdown
Refactor memory close provider draining so providers created during shutdown are closed through the same bounded retry path.

Co-authored-by: spacegeologist <zheng.zuo0@gmail.com>
2026-05-27 02:22:05 +01:00
Vincent Koc
6509da7555 fix(gateway): bound e2e HTTP helper responses 2026-05-27 03:21:03 +02:00
NVIDIAN
bba429831c fix(agents): honor per-agent thinking defaults for ingress runs (#86689)
Honor the selected session agent's thinkingDefault for ingress agent runs before global fallback.

Also keep session store cache object-clone writes parse-free while matching persisted JSON shape when cloning values.

Fixes #86669

Co-authored-by: ai-hpc <mail.speedy.hpc@hotmail.com>
2026-05-27 02:18:57 +01:00
Peter Steinberger
2035f38ab2 perf: trim gateway runtime hotspots 2026-05-27 02:17:29 +01:00
Peter Steinberger
f6599ede0d fix(sessions): avoid parsing object cache writes 2026-05-26 21:16:21 -04:00
Peter Steinberger
978cb6ac20 test(cli): allow mac startup memory overhead 2026-05-26 21:16:21 -04:00
Vincent Koc
d5b5eaccc2 fix(crabbox): show broker url in auth guard 2026-05-27 02:15:56 +01:00
Vincent Koc
7c432d2bd8 fix(crabbox): require broker auth for aws proof 2026-05-27 02:13:59 +01:00
Vincent Koc
d353dc128f fix(docker): bound kitchen sink plugin commands 2026-05-27 03:08:13 +02:00
Vincent Koc
2b5fba1519 fix(cli): bound startup memory probes 2026-05-27 03:06:46 +02:00
Peter Steinberger
049d6c9683 test: skip claude resume live proof without cli 2026-05-26 21:04:46 -04:00
Chunyue Wang
71d24f98a8 fix(agents): force SIGKILL for stuck MCP stdio children (#86739)
Guarantee MCP stdio child cleanup during Gateway shutdown by sending a synchronous SIGKILL when the child survives the existing stdin and SIGTERM waits. This prevents SIGTERM-ignoring local MCP processes from outliving the Gateway when killProcessTree's unref'd SIGKILL timer would otherwise lose the shutdown race.

Fixes #86412.

Verification:
- GitHub CI green on relevant agent/runtime, lint/type, CodeQL/security, OpenGrep, and Real behavior proof checks.
- Real behavior proof: https://github.com/openclaw/openclaw/actions/runs/26430512156/job/77802651894
- Maintainer manual review: no blocking findings.

Thanks @openperf.

Co-authored-by: openperf <16864032@qq.com>
2026-05-27 02:04:29 +01:00
Peter Steinberger
1dbd9a3154 fix(codex): avoid false queued terminal idle timeout (#87096) 2026-05-27 01:57:08 +01:00
Vincent Koc
bfddd45e25 fix(gateway): fail hot cpu scenario checks 2026-05-27 02:55:45 +02:00
Alix-007
c9ca7fc0d2 fix(cron): preview no-deliver message targets
Fix cron delivery previews for no-delivery jobs that still provide explicit message-tool targets.

- Reuse one cron delivery-plan explicit-target predicate across preview and isolated-agent runtime paths.
- Treat numeric threadId 0 as an explicit delivery target.
- Avoid fail-closed wording for unresolved message-tool-only targets.

Thanks @Alix-007 for the fix.

Co-authored-by: Alix-007 <267018309+Alix-007@users.noreply.github.com>
2026-05-27 01:53:11 +01:00
Peter Steinberger
a43da0c8c5 perf: reduce gateway cpu churn 2026-05-27 01:52:27 +01:00
Vincent Koc
80749b3bdf fix(gateway): harden runtime smoke checks 2026-05-27 02:49:22 +02:00
Vincent Koc
86ff2cf820 fix(docker): bound plugin sweep reads 2026-05-27 02:48:36 +02:00
Peter Steinberger
94cd364a00 test: make docker package timeout proof robust 2026-05-27 01:43:13 +01:00
JanusAsmussen
84e62824f6 fix(anthropic): pass system prompt on resumed claude-cli sessions
Summary:
- send Claude CLI system prompt files on resumed turns when backend policy is always
- set Claude CLI default systemPromptWhen to always
- add argv/unit coverage plus live ALPHA-to-BRAVO resume proof for #80374

Verification:
- pnpm test src/agents/cli-runner/helpers.system-prompt-resume.test.ts extensions/anthropic/cli-shared.test.ts src/agents/cli-backends.test.ts test/scripts/test-live-shard.test.ts -- --reporter=verbose
- OPENCLAW_LIVE_TEST=1 OPENCLAW_LIVE_USE_REAL_HOME=1 OPENCLAW_LIVE_CLI_BACKEND=true OPENCLAW_LIVE_CLI_BACKEND_MODEL=claude-cli/claude-haiku-4-5 node scripts/run-vitest.mjs run --config test/vitest/vitest.live.config.ts src/gateway/gateway-cli-backend.system-prompt-resume.live.test.ts --reporter=verbose
- /Users/steipete/Projects/agent-scripts/skills/autoreview/scripts/autoreview --mode local
- git diff --check
- gitcrawl gh pr checks 86433 --repo openclaw/openclaw --watch=false --required

Co-authored-by: JanusAsmussen <jjasmussen@outlook.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-05-27 01:31:13 +01:00
Enjou
d8f6d65525 fix(skills): sync plugin skills to sandbox workspaces
Copy plugin-provided skills from their validated real target into sandbox workspaces while keeping prompt-visible skill paths sandbox-local.

Adds regression coverage for symlinked plugin skills, multiple plugin skill roots, escaped symlink targets, and sandbox prompt paths that must not leak host plugin-skill locations.

Refs #86190
2026-05-27 01:27:10 +01:00
Peter Steinberger
8b8e088620 docs: show PR LOC in maintainer reviews 2026-05-27 01:26:56 +01:00
uday
0f18d52f16 fix(codex): raise dynamic tool timeout 2026-05-27 01:25:48 +01:00
Peter Steinberger
a1934e9d0e fix(cli): handle Bun launcher module misses
Fixes #86198.

Co-authored-by: Gio Della-Libera <giodl73@gmail.com>
2026-05-27 01:20:14 +01:00
Vincent Koc
e46b92cc58 fix(docker): bound plugin sweep commands 2026-05-27 02:19:55 +02:00
Peter Steinberger
ebfcddbaed docs: improve PR blame provenance 2026-05-27 01:17:54 +01:00
Jason (Json)
ee655f4d94 fix: scrub serialized tool-call text from replies (#86924)
* fix: scrub serialized tool-call text from replies

* fix: consume xmlish tool parameters
2026-05-27 01:16:58 +01:00
Peter Steinberger
eac918d69b test: fix CI type checks 2026-05-26 20:13:03 -04:00
Vincent Koc
b65411740e fix(e2e): resolve mac update smoke commands from PATH 2026-05-27 02:10:32 +02:00
Peter Steinberger
61fa2b285e test(docs): avoid URL default stringification 2026-05-26 20:04:33 -04:00
Peter Steinberger
9f7584c385 test: speed up plugin runtime tests 2026-05-27 01:02:46 +01:00
Peter Steinberger
69d84d775b fix(docs): use Cloudflare docs search API 2026-05-27 00:58:09 +01:00
Peter Steinberger
7e913c08f8 test: speed up run-node infra tests 2026-05-27 00:57:44 +01:00
Vincent Koc
6ef0cbb94f fix(docker): bound e2e image builds 2026-05-27 01:53:22 +02:00
Ted Li
030861e5d1 fix(agents): unwrap standalone message tool JSON (#86626)
* fix(agents): unwrap standalone message tool JSON

* fix(agents): guard message JSON unwrap

* fix(agents): gate message JSON recovery

* fix(agents): treat to as routed message JSON
2026-05-27 00:53:02 +01:00
Peter Steinberger
9cd1d27a89 fix(slack): fast-path wildcard open DM policy 2026-05-27 00:50:48 +01:00
Peter Steinberger
d122839eb7 ci: retry corepack pnpm activation 2026-05-27 00:49:26 +01:00
Peter Steinberger
dc1e6fb02b test: bound gateway live model discovery 2026-05-26 19:47:07 -04:00
Peter Steinberger
75fc0bce0f test: speed up plugin install suites 2026-05-27 00:46:44 +01:00
Steven
bf8be79b88 fix(irc): use channel routes for group inbound targets
Fix IRC group inbound metadata so `To` uses the same `channel:#name` route shape as `From` and `OriginatingTo`.

This keeps IRC group message context consistent for reply/session routing metadata.

Verification:
- `git diff --check origin/main...FETCH_HEAD`
- `git merge-tree origin/main FETCH_HEAD`
- `node scripts/run-vitest.mjs extensions/irc/src/inbound.behavior.test.ts --run` (1 file / 4 tests passed)
- `gh pr checks 86721 --repo openclaw/openclaw --json name,state,link,bucket,workflow` (pass/skip only; no required checks reported)
2026-05-27 00:44:12 +01:00
Jason (Json)
532494b12a Preserve xAI usage limit errors in local TUI (#86614)
* fix: preserve xai usage limit errors

* fix: classify actual xai credit errors

* fix: classify xai 429 billing exhaustion

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-05-27 00:43:55 +01:00
Peter Steinberger
fa384d4de0 fix: filter claude autoreview streaming 2026-05-27 00:41:34 +01:00
Abdel Gomez-Perez
474b1e0386 fix(cli-runner): scale Claude CLI reseed history automatically
Remove the proposed public `maxReseedHistoryChars` config surface and scale Claude CLI reseed history automatically from the resolved context tier instead.

Claude CLI 200K-context runs now keep a 64K-character reseed slice, 1M Opus/Sonnet runs use the bounded 256KiB cap, and non-Claude CLI backends keep the existing 12KiB default. This preserves the intended long-context behavior without adding another config option.

Verification:
- `node scripts/run-vitest.mjs src/agents/cli-runner/session-history.test.ts src/agents/cli-runner/prepare.test.ts`
- `node scripts/run-vitest.mjs src/agents/cli-runner/prepare.test.ts -t "automatic Claude CLI cap"`
- `node scripts/run-oxlint.mjs src/agents/cli-runner/prepare.ts src/agents/cli-runner/prepare.test.ts src/agents/cli-runner/session-history.ts src/agents/cli-runner/session-history.test.ts src/config/types.agent-defaults.ts src/config/zod-schema.core.ts`
- `pnpm check:changed` via Testbox `tbx_01kska2twjxb925xft9dj82hvb`
- GitHub PR checks green

Closes #83985
Co-authored-by: Abdel Gomez-Perez <nabdel07@icloud.com>
2026-05-27 00:41:01 +01:00
Peter Steinberger
8592352c24 test: speed up infra test hotspots 2026-05-27 00:39:27 +01:00
Vincent Koc
3e701449ff fix(e2e): keep mac smoke commands bounded without timeout 2026-05-27 01:37:57 +02:00
Peter Steinberger
693f06d811 fix(live): classify Z.ai plan denials as billing drift 2026-05-27 00:36:54 +01:00
Eric Milgram, PhD
678a0ee944 fix(config): render transform-backed config schema inputs (#67328)
Generate the public config JSON Schema from accepted input shapes so transform-backed fields remain renderable in the Control UI. Keep transform output schemas representable with explicit string pipes, align analyzer metadata handling, and cover the generated schema plus browser-safe UI render shapes.

Co-authored-by: Altay <altay@hey.com>

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-05-27 00:36:13 +01:00
Peter Steinberger
980d73dc5a perf: speed up test hotspots 2026-05-27 00:30:51 +01:00
Peter Steinberger
322ceb36ce feat: stream autoreview progress 2026-05-27 00:22:05 +01:00
Peter Steinberger
8f1fb675aa test: improve full-suite failure summaries 2026-05-27 00:21:12 +01:00
Vincent Koc
0028c2f793 fix(e2e): require bounded helper timeouts 2026-05-27 01:18:48 +02:00
Brian Potter
068d88c142 fix(ui): eliminate double scrollbar on Logs view
Keep the Logs page from rendering competing outer page and inner log-stream scrollbars. The Logs route now opts into an explicit content class for desktop fill-height layout, while mobile keeps the single-page scroll behavior with the capped log panel.

Also adds regression coverage for the route class and CSS ownership selectors.

Co-authored-by: Brian potter <brian@potterdigital.com>
2026-05-27 00:14:48 +01:00
Peter Steinberger
0f608bc497 test: speed up hot test fixtures 2026-05-27 00:11:23 +01:00
Alix-007
8ec2b2d09b fix(auto-reply): suppress repeated silent tokens (#86848)
* fix(auto-reply): suppress repeated silent tokens

* test(plugin-sdk): cover repeated silent token exports

* test(plugin-sdk): cover custom repeated silent token export

* fix(lint): drop redundant image registry casts

---------

Co-authored-by: Alix-007 <267018309+Alix-007@users.noreply.github.com>
2026-05-27 00:04:57 +01:00
Peter Steinberger
1313e15241 fix(commands): preserve async skill commands
Preserve native slash-command laziness while allowing `/skill` to load workspace skill commands asynchronously when needed. The loaded command list is reused for downstream native skill dispatch so valid `/skill <name>` calls do not get misclassified as unknown.

Verification:
- git diff --check
- fnm exec --using v24.15.0 -- pnpm changed:lanes --json
- .agents/skills/autoreview/scripts/autoreview --mode local
- GitHub CI rollup success for c0d778d512

Co-authored-by: Keshav's Bot <keshavbotagent@gmail.com>
2026-05-27 00:03:25 +01:00
Vincent Koc
130464e797 fix(docker): bound telegram npm installs 2026-05-27 00:53:53 +02:00
Vincent Koc
728b61a0a4 fix(mac): use corepack pnpm for app packaging 2026-05-27 00:53:09 +02:00
joshavant
1600bcd44d fix: mark ios watch app as watchkit app 2026-05-26 15:52:08 -07:00
fuller-stack-dev
40fa750b4f docs: explain bundled plugin npm override 2026-05-26 23:51:53 +01:00
fuller-stack-dev
669bfdd9b0 test: fix bundled install mock typing 2026-05-26 23:51:53 +01:00
fuller-stack-dev
771675e826 fix: keep bundled OpenClaw plugins image-owned 2026-05-26 23:51:53 +01:00
Peter Steinberger
84a33c743e fix: preserve whatsapp inbound batch order 2026-05-26 18:51:18 -04:00
Peter Steinberger
3f524a6423 perf: cache npm globalconfig lookups 2026-05-26 23:45:17 +01:00
狼哥
126a3363a3 fix(daemon): ignore recursive Windows gateway wrapper
Fixes #86007.

Release note: Windows gateway install/update now ignores a persisted OPENCLAW_WRAPPER when it points back at the generated gateway.cmd task script, preventing recursive gateway startup while keeping valid wrapper installs intact.

Credit: thanks @luoyanglang for the fix and proof.
2026-05-26 23:42:25 +01:00
Vincent Koc
eb15c443fc fix(docker): bound live setup commands 2026-05-27 00:38:17 +02:00
joshavant
1daef79f80 fix: restore ios build stability 2026-05-26 15:37:32 -07:00
Kevin Lin
7d6b7f434c feat(plugin-sdk): add reaction approval helpers (#86735)
* feat(plugin-sdk): add reaction approval helpers

* fix(signal): register target approval reactions

* Remove legacy WhatsApp approval reaction appender

* refactor(plugin-sdk): share native exec prompt suppression

* revert(discord): keep exec prompt suppression local

* refactor(plugin-sdk): share native approval fallback suppression

* fix(whatsapp): bind outbound approval reactions

* chore(plugin-sdk): refresh api baseline

* revert(imessage): defer reaction approval changes
2026-05-26 15:28:50 -07:00
Peter Steinberger
4f83cd6528 test(auto-reply): type manifest catalog harness mock
(cherry picked from commit 64e01ef97a)
2026-05-26 23:26:52 +01:00
Vincent Koc
96307ca9b4 fix(docker): bound live docker runs 2026-05-27 00:26:27 +02:00
Peter Steinberger
989d449404 test(auto-reply): mock manifest model catalog in trigger harness
(cherry picked from commit 7135e34520)
2026-05-26 23:22:46 +01:00
Vincent Koc
2f7bfdbd10 fix(crabbox): scope env-wrapped macOS bootstrap 2026-05-27 00:12:31 +02:00
Frederic David blum
1e1cf14da2 fix(gateway): reject RPCs from invalidated device-token clients durin… (#70707)
* fix(gateway): reject RPCs from invalidated device-token clients during rotation/revoke race

device.token.rotate, device.token.revoke and device.pair.remove all
respond 200 OK to the admin, then schedule disconnectClientsForDevice
via queueMicrotask so the response can flush before the socket close.
That microtask window plus the absence of a per-RPC re-check for
device-token auth (unlike shared-auth, which gets checked at
message-handler.ts:1444-1458) created a race: an attacker with RPCs
already pipelined in the WS socket buffer could land a few more
authenticated operations with the rotated/revoked token before the
socket actually closed.

Fix: add a cheap in-memory 'invalidated' flag on GatewayWsClient and
mark it synchronously *before* responding in the three handlers. Add
a mirror check at the start of the per-RPC dispatch that force-closes
the client if the flag is set, regardless of whether socket.close()
has taken effect yet. Disconnect still happens via queueMicrotask so
the admin's rotate/revoke response flushes normally.

Introduces context.invalidateClientsForDevice(deviceId, opts) as a
sync companion to the existing disconnectClientsForDevice. Also
defense-in-depth: disconnectClientsForDevice now sets the flag too,
so any other caller of the hard-disconnect path gets the per-RPC
gate for free.

* test(gateway): use vi.mocked instead of direct Mock casts in devices tests

check-test-types failed on the PR because direct 'as ReturnType<typeof vi.fn>' casts from RespondFn (or the optional context methods) don't structurally overlap with the Mock type — Mock has mockImplementation/mockReturnValue that RespondFn lacks, so strict tsgo rejects the conversion. vi.mocked() is the intended helper for reinterpreting an already-mocked function, and drops through to the Mock surface cleanly.

* test(gateway): align tests with upstream type/shape changes after rebase

After rebasing onto upstream main, two test surfaces drifted:

1. GatewayRequestContextParams gained two required fields upstream
   (getRuntimeConfig, broadcastVoiceWakeRoutingChanged). The
   makeContextParams test helper was missing them, so every consumer
   tripped tsgo with a missing-field error. Add both as vi.fn()
   stubs.

2. revokeDeviceToken's return shape changed upstream from a bare
   entry record to a discriminated union {ok: true, entry: ...} | {ok:
   false, reason}. The new device.token.revoke synchronous-invalidate
   test still mocked the old shape, so the production handler took the
   !revoked.ok branch and never reached the invalidateClientsForDevice
   call the test asserted. Update the mock to the new union shape.

Also fix three new Set([...] as never) sites in server-request-
context.test.ts that produced Set<unknown> rather than Set<never>.
Move the cast outside the Set constructor so the literal stays
inferred while the wrapper is type-erased to never, which is
assignable to the Partial<GatewayRequestContextParams> clients field.

* fix(gateway): export GatewayRequestContextParams for test access

* fix(ci): resolve check-test-types and lint failures from PR #70707 branch

- server-request-context.test.ts: hasConnectedMobileNode → hasConnectedTalkNode
  (field renamed in server-request-context.ts but test fixture not updated)
- status.summary.redaction.test.ts: add configuredModel/selectedModel/
  modelSelectionReason to createRecentSessionRow fixture
  (SessionStatus gained these fields in a13468320c; test was not updated)
- video-generation-providers.live.test.ts: replace empty {} fallbacks in
  conditional spreads with undefined (oxlint 1.65.0, 5 occurrences)
- music-generation-providers.live.test.ts: same fix for 4 occurrences

Remaining CI failures (FsSafeError/Python helper, media tests, Windows ACL,
session-memory hooks) are pre-existing infra failures unrelated to this PR.

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

* fix(ci): add missing GatewayRequestContextParams fields to test fixture

chatDeltaLastBroadcastText, agentDeltaSentAt, and bufferedAgentEvents are
required fields in GatewayRequestContextParams but were absent from the
makeContextParams fixture, causing TS2322 in check-test-types.

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

* fix(gateway): serialize credential invalidating RPCs

---------

Co-authored-by: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-05-26 23:09:56 +01:00
Peter Steinberger
6158742f80 fix(channel): handle plugin channel markdown fallback
(cherry picked from commit 8824a8de47)
2026-05-26 23:04:49 +01:00
Vincent Koc
3736d7b60b fix(docker): require bounded e2e docker commands 2026-05-27 00:03:00 +02:00
Thesaranshn8n
6729dea36f fix(codex): share native hook relay registry (#73950)
Co-authored-by: Sar Jeeves <sar-jeeves@example.com>
Co-authored-by: Kaspre <kaspre@gmail.com>
Co-authored-by: Dallin Romney <dallinromney@gmail.com>
2026-05-26 15:02:03 -07:00
Peter Steinberger
5a684c4553 fix(release): stabilize plugin prerelease tests
(cherry picked from commit ea42c1db8a)
2026-05-26 22:54:12 +01:00
Vincent Koc
c4b9f54b46 fix(diagnostics): flush OTel trace batches
Apply diagnostics.otel.flushIntervalMs to OpenTelemetry trace batching so short-lived Windows and QA runs do not lose late lifecycle/model spans. Also make the OTel QA smoke wait for required telemetry and print bounded failure diagnostics.
2026-05-26 22:46:39 +01:00
Peter Steinberger
d569e41c58 fix(memory): reject invalid CLI numeric options
Fixes memory CLI numeric parsing bugs found by clawpatch.

- memory CLI numeric options now reject non-finite values before command runtime.
- wiki apply `--confidence` now enforces the documented 0..1 range before metadata mutation.
- Commander parse-error UX is preserved without importing `commander` at bundled plugin runtime.

Proof:
- `node scripts/run-vitest.mjs extensions/memory-core/src/cli.test.ts extensions/memory-wiki/src/cli.test.ts`
- `pnpm exec oxfmt --check --threads=1 extensions/memory-core/src/cli.ts extensions/memory-core/src/cli.test.ts extensions/memory-wiki/src/cli.ts extensions/memory-wiki/src/cli.test.ts`
- `git diff --check`
- `.agents/skills/autoreview/scripts/autoreview --mode branch --base origin/main`
- Real CLI proof: invalid memory `--max-results` and wiki `--confidence` both fail with Commander parse errors before actions run.
- GitHub PR checks green: 67 success, 29 skipped, 1 neutral.
2026-05-26 22:42:48 +01:00
Peter Steinberger
5a7d5c6def fix(codex): bound app-server timeout fallout
Retire timed-out Codex app-server clients with lease-aware cleanup and keep harness-owned timeouts out of provider fallback.
2026-05-26 22:41:02 +01:00
Peter Steinberger
9fc71e9076 fix(agents): keep model browse normalization bounded
Keep model browse/list visibility consistent with runtime-normalized allowlist entries while keeping unrestricted default browse off plugin/runtime hydration. Add regression coverage for catalog visibility, `/models` browse data, and the replay sanitizer mock isolation that made the agents shard order-sensitive.

Verification:
- pnpm test src/agents/pi-embedded-runner.sanitize-session-history.test.ts src/agents/model-catalog-visibility.test.ts src/auto-reply/reply/commands-models.test.ts src/auto-reply/reply/model-selection.test.ts src/agents/model-selection.plugin-runtime.test.ts -- --reporter=verbose
- OPENCLAW_VITEST_MAX_WORKERS=2 pnpm exec node scripts/test-projects.mjs test/vitest/vitest.agents-core.config.ts
- .agents/skills/autoreview/scripts/autoreview --mode local
- GitHub Actions CI run 26476126784
2026-05-26 22:34:37 +01:00
Peter Steinberger
a818556dd9 fix: stabilize media-related tests 2026-05-26 17:30:34 -04:00
Vincent Koc
be2213e46e fix(ci): preserve docker pull retry failures 2026-05-26 23:30:07 +02:00
Peter Steinberger
bb48fcf36a ci: support windows node download fallback 2026-05-26 22:29:46 +01:00
Peter Steinberger
acd3ce00ea test(agents): pin native anthropic replay policy 2026-05-26 22:29:46 +01:00
Peter Steinberger
538b537cc5 fix(build): stabilize shrinkwrap generation 2026-05-26 22:29:46 +01:00
Peter Steinberger
17051894d0 fix(ui): ignore stale running session rows 2026-05-26 22:29:46 +01:00
Fermin Quant
0a085bf15e fix(status): surface systemd gateway hygiene (#86976) 2026-05-26 22:29:20 +01:00
Chengjie Wang
950007dd9c fix(ui): show failed tool results as errors (#85786)
Merged via squash.

Prepared head SHA: c0c4fb5917
Co-authored-by: chengjiew <75600865+chengjiew@users.noreply.github.com>
Co-authored-by: altaywtf <9790196+altaywtf@users.noreply.github.com>
Reviewed-by: @altaywtf
2026-05-27 00:27:57 +03:00
Peter Steinberger
1d972af69d ci: enforce Node 22 floor in setup helper 2026-05-26 22:26:08 +01:00
Peter Steinberger
ce4db4f9f3 ci: allow Windows Node 22 patch range 2026-05-26 22:26:08 +01:00
Andy Ye
f3e61580bd Fix status JSON plugin scan (#87001)
* fix status json plugin scan

* fix status json metadata imports

* fix channel metadata repair fallback

* fix runtime channel id normalization fallback

* fix status json env channel detection

Co-authored-by: Peter Steinberger <steipete@gmail.com>

* fix signed thinking legacy tool repair

* fix: preserve first signed replay turn

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-05-26 22:24:25 +01:00
吴杨帆
77505daa85 fix(telegram): preserve command slots for aliases (#85270)
* fix(telegram): preserve command slots for aliases

* fix: report Telegram alias command overflow

* fix: preserve Telegram alias menu order

* docs: drop release-owned changelog entry

---------

Co-authored-by: wuyangfan <yangfan.wu@succaiss.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-05-26 22:24:03 +01:00
Peter Steinberger
94fb547fe2 fix(agents): handle deferred maintenance drain
Ensure deferred context-engine maintenance rejects cleanly when the gateway command queue is draining, including coalesced active-run requests. This prevents budget compaction from treating an unscheduled deferred maintenance run as successful and leaving the context engine alive.

Verification:
- pnpm exec oxfmt --check --threads=1 src/process/command-queue.ts src/agents/pi-embedded-runner/compact.queued.ts src/agents/pi-embedded-runner/context-engine-maintenance.ts src/agents/pi-embedded-runner/context-engine-maintenance.test.ts
- pnpm test src/auto-reply/reply/agent-runner-memory.test.ts src/agents/pi-embedded-runner/compact.hooks.test.ts src/agents/pi-embedded-runner/context-engine-maintenance.test.ts src/tasks/task-flow-registry.store.test.ts src/auto-reply/reply/commands-compact.test.ts src/agents/pi-embedded-runner/compact-reasons.test.ts
- .agents/skills/autoreview/scripts/autoreview --mode branch --base origin/main
- GitHub Actions CI run 26475226442: relevant Node/Linux, lint, type, security, CodeQL, OpenGrep, Socket, Real behavior proof, and build jobs passed; Windows job failed before tests due current runner image Node 22.19.0 vs required 24.x, matching current main infra failure.
2026-05-26 22:17:19 +01:00
Peter Steinberger
72bc429f60 test: keep legacy tool-result error proof 2026-05-26 22:13:19 +01:00
Peter Steinberger
b546998b9b ci: fix post-merge Rastermill checks 2026-05-26 22:11:50 +01:00
Peter Steinberger
8523d3268a fix(agents): mark repaired legacy tool results errored 2026-05-26 17:01:12 -04:00
Peter Steinberger
b414020bef docs(changelog): note rastermill exif fix 2026-05-26 21:58:29 +01:00
Peter Steinberger
a6973ab9b4 docs(changelog): regroup 2026.5.26 release notes 2026-05-26 21:57:49 +01:00
Peter Steinberger
acb942f634 fix: keep EXIF normalization best-effort (#86923) 2026-05-26 21:55:57 +01:00
Peter Steinberger
cee8c8773b build: use rastermill 0.3.0 2026-05-26 21:55:57 +01:00
Peter Steinberger
e6edccad3a build: update rastermill dependency 2026-05-26 21:55:57 +01:00
Peter Steinberger
a3325c9fb4 refactor: use unified rastermill encode API 2026-05-26 21:55:57 +01:00
Peter Steinberger
03ae999a1a ci: normalize Windows toolcache paths 2026-05-26 21:55:57 +01:00
Peter Steinberger
16d06aa112 ci: satisfy opengrep git add guard 2026-05-26 21:55:57 +01:00
Peter Steinberger
4f728f8321 refactor: delegate image limits to Rastermill 2026-05-26 21:55:57 +01:00
Peter Steinberger
4e84229e82 fix: infer realtime smoke dev server type 2026-05-26 21:55:57 +01:00
Peter Steinberger
7d4d7512e4 build: update rastermill pin 2026-05-26 21:55:57 +01:00
Peter Steinberger
50b98a1878 refactor: delegate image processing to Rastermill 2026-05-26 21:55:57 +01:00
Peter Steinberger
4e45b11983 fix(agents): repair legacy tool results before replay 2026-05-26 16:53:32 -04:00
Josh Avant
3c16648ad7 fix(config): narrow profiled tool section doctor repair (#87030)
* fix(config): repair profiled tool section grants

* fix(config): narrow profiled tool section doctor repair

* fix(config): satisfy doctor warning lint

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-05-26 13:50:22 -07:00
Peter Steinberger
80655fe955 test: fix current suite drift 2026-05-26 16:40:08 -04:00
Alix-007
daa7b1d06b fix(lock): require owner identity proof before stale removal
Fixes #86814.

Reclaims stale plugin lock files only when the previous owner is provably gone or the recorded process start time proves PID reuse. Timestamp age alone now stays fail-closed for PID-owned locks, preserving mutual exclusion for long-running writers while still allowing pidless expired locks to expire.

Verification:
- pnpm test src/infra/stale-lock-file.test.ts src/plugin-sdk/file-lock.test.ts
- pnpm tool-display:check
- git diff --check
- autoreview --mode branch --base origin/main

Known CI note: check-guards failed in deps:shrinkwrap:check because npm resolved newer AWS transitive versions than pnpm-lock.yaml contains; no package or lock files are changed in this PR.

Co-authored-by: Alix-007 <267018309+Alix-007@users.noreply.github.com>
2026-05-26 21:38:35 +01:00
Peter Steinberger
d8a14e77c3 fix(deps): pin shrinkwrap patch drift to pnpm lock 2026-05-26 16:35:10 -04:00
joshavant
e09f89d37b revert: 60bec8c duplicate tool display guard 2026-05-26 13:32:50 -07:00
Vincent Koc
38edae7df7 fix(e2e): bound docker package preparation 2026-05-26 22:32:25 +02:00
Peter Steinberger
5e8f4981a5 fix(cli): add Windows stack-size respawn (#87031)
Add a Windows-only CLI respawn with `--stack-size=8192` so stack-heavy startup paths can run with a larger V8 stack.

The respawn path normalizes duplicated Windows `node.exe` launcher argv before handoff, preserves real non-launcher argv values containing `node.exe`, and treats both `--stack-size` and `--stack_size` as already configured.

Fixes #62055.
Supersedes #86307.
Thanks @giodl73-repo for the original fix.

Verification:
- `node --v8-options | rg -n "stack-size|stack_size"`
- `node --stack-size=8192 -e "console.log('ok')"`
- `node --stack_size=8192 -e "console.log('ok')"`
- `pnpm format:check src/cli/windows-argv.ts src/cli/windows-argv.test.ts src/entry.respawn.ts src/entry.respawn.test.ts`
- `node scripts/run-vitest.mjs src/entry.respawn.test.ts src/cli/windows-argv.test.ts`
- `.agents/skills/autoreview/scripts/autoreview --mode local`
- `pnpm check:changed` via Testbox `tbx_01ksjzf06pcgx29qrctjrn4rhr`, GitHub Actions run https://github.com/openclaw/openclaw/actions/runs/26473172664

Co-authored-by: Gio Della-Libera <giodl73@gmail.com>
2026-05-26 21:31:58 +01:00
martingarramon
ef86d8c95c fix(agents): preserve sessions_spawn transcript payloads (#82203)
Remove the transcript redaction path for sessions_spawn arguments and inline attachments. OpenClaw transcripts are local trusted-operator state, and streamTo/resumeSessionId are runtime routing fields that must not be rewritten before replay or dispatch.

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-05-26 21:30:01 +01:00
Josh Avant
60bec8c020 fix(agents): guard duplicate tool display metadata (#87025) 2026-05-26 13:28:38 -07:00
Peter Steinberger
f7e2d9bb47 ci(release): port 2026.5.25 release gate fixes 2026-05-26 21:19:51 +01:00
Peter Steinberger
ad71c427fa chore: update tool display snapshot 2026-05-26 16:17:51 -04:00
狼哥
4a85cd76f6 fix(web-search): keep runtime legacy merge out of validation (#86818)
Runtime-injected web_search provider config from plugins.entries.<plugin>.config.webSearch now stays available to provider execution without being validated as user-authored legacy tools.web.search.<provider> config.

Co-authored-by: luoyanglang <hanwanlonga@gmail.com>
2026-05-26 21:15:44 +01:00
Vincent Koc
3127808473 fix(cli): default logs to local timestamps (#85387) 2026-05-26 21:14:47 +01:00
Peter Steinberger
8788ae1a8e fix(agents): dedupe transcripts tool display config 2026-05-26 16:12:03 -04:00
Mark
e070519f43 fix(updater): exclude prerelease tags from stable git channel (#86559)
Preserve legacy numeric stable git tags while excluding named semver prerelease tags from stable git channel detection and status display.

Thanks @goldmar.
2026-05-26 21:11:38 +01:00
Chunyue Wang
c430fcde1c fix(agents): memoize session lock owner args
Memoize owner process argv lookups per PID during `cleanStaleLockFiles`, and yield between lock entries so startup cleanup does not monopolize the event loop while inspecting many session locks.

This keeps lock classification semantics unchanged while avoiding repeated synchronous process-args reads for lock clusters owned by the same PID, especially the Windows PowerShell path.

Fixes #86509.

Verification:
- `git diff --check origin/main...HEAD`
- focused TSX harness against the current-main merge result: `session-lock memo regression harness passed`

Thanks @openperf.

Co-authored-by: openperf <16864032@qq.com>
2026-05-26 21:10:19 +01:00
Shakker
0f49bbbeb2 fix: dedupe transcripts tool display metadata 2026-05-26 21:09:18 +01:00
Peter Steinberger
abb85ccc86 fix(cli): validate timeout and banner TTY state
Fixes two CLI edge cases found by clawpatch.

- `emitCliBanner` now honors injected TTY state before writing to stdout.
- Nodes RPC timeout handling now rejects malformed `--timeout` values with the existing timeout parser instead of forwarding `NaN` into gateway transport calls.

Proof:
- `node scripts/run-vitest.mjs src/cli/banner.test.ts src/cli/nodes-cli/register.invoke.approval-transport-timeout.test.ts`
- `pnpm exec oxfmt --check --threads=1 src/cli/banner.ts src/cli/banner.test.ts src/cli/nodes-cli/rpc.runtime.ts src/cli/nodes-cli/register.invoke.approval-transport-timeout.test.ts`
- `.agents/skills/autoreview/scripts/autoreview --mode branch --base origin/main`
- Real CLI proof: `pnpm openclaw nodes list --timeout nope --json` exits 1 with `Invalid --timeout`.
- Runtime banner proof: injected `isTty:false` with `stdout.isTTY=true` produced `writes=0`, `emitted=false`.
2026-05-26 21:08:11 +01:00
Andy Ye
bf0228b5c2 fix(codex): project newer history on app-server resume (#86677)
Project newer external OpenClaw chat history into resumed Codex app-server threads when the saved binding is older than user-visible transcript messages, while filtering Codex-owned mirror records on consecutive resumes.

Thanks @TurboTheTurtle!
2026-05-26 21:07:07 +01:00
pashpashpash
3a64dc7623 fix(codex): keep turn timeouts inside Codex (#86476)
Keep Codex app-server turn timeouts within the Codex runtime boundary so they interrupt the active turn without retiring the shared app-server client, poisoning auth-profile cooldowns, or falling through to generic provider/model fallback.

Preserve concrete non-timeout provider failures for auth-profile rotation and fallback, and add regression coverage for prompt-stage timeouts, assistant idle timeouts, auth-profile cooldowns, and app-server timeout handling.

Thanks @pashpashpash.
2026-05-26 21:06:19 +01:00
mjamiv
f22c3a518e fix(auto-reply): stage sandboxed workspace media
Fixes #74061.

Stages absolute final-reply MEDIA paths that already live under the agent workspace before sandbox path translation runs, so Telegram/local delivery can attach generated workspace media instead of dropping it as Media failed. Outside-workspace host-local paths remain blocked, and host-read HTML stays denied pending separate security-boundary review.

Verification:
- git diff --check origin/main...refs/remotes/pull/86531
- git merge-tree --write-tree origin/main refs/remotes/pull/86531
- reviewed src/auto-reply/reply/reply-media-paths.ts, src/media/web-media.ts, and focused tests

Co-authored-by: mjamiv <74088820+mjamiv@users.noreply.github.com>
2026-05-26 21:05:08 +01:00
Vincent Koc
2fcf990cee fix(e2e): support plain telegram install timeouts 2026-05-26 22:03:50 +02:00
Vincent Koc
639e7ff997 fix(mac): harden restart and dSYM packaging 2026-05-26 22:01:35 +02:00
Vincent Koc
4d6593642e fix(exec): avoid default approval store writes (#86964)
* fix(exec): avoid default approval store writes

* fix(exec): harden token approvals on default policy

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-05-26 20:59:24 +01:00
Vincent Koc
9b1b6d02fd fix(agents): restore current guard checks (#86934) 2026-05-26 20:59:03 +01:00
Peter Steinberger
983b33867e docs(changelog): prepare 2026.5.26 notes 2026-05-26 20:52:04 +01:00
Peter Steinberger
29a1dc2249 docs(changelog): note reply latency fixes 2026-05-26 20:51:00 +01:00
Keshav's Bot
699c047c7d fix(reply): reduce visible reply delivery latency 2026-05-26 20:51:00 +01:00
Keshav's Bot
ed3ae0da43 fix(reply): defer context compaction safely 2026-05-26 20:51:00 +01:00
Keshav's Bot
21c25bbb9d fix(codex): gate profiler timing and startup setup 2026-05-26 20:51:00 +01:00
Keshav's Bot
7951cc0c8a fix(agents): avoid runtime model hydration on hot paths 2026-05-26 20:51:00 +01:00
Keshav's Bot
c2b56ded61 fix(commands): keep slash handling off reply startup 2026-05-26 20:51:00 +01:00
Keshav's Bot
0afccc62ab fix(telegram): refine typing and progress drafts 2026-05-26 20:51:00 +01:00
Vincent Koc
5c1ecda0ca fix(e2e): support plain timeout wrappers 2026-05-26 21:49:04 +02:00
Pavel Ganson
e7500417c8 fix(channels): preserve direct native progress callbacks
Preserve native direct-message progress callbacks for quiet Telegram/Codex turns while keeping text tool summaries behind verbose visibility.

The fix keeps source-delivery suppression and sendPolicy denial intact, so quiet native progress is allowed only for direct chat progress callbacks and does not leak when delivery is denied.

Verification:
- node scripts/run-vitest.mjs run --config test/vitest/vitest.auto-reply-reply.config.ts src/auto-reply/reply/dispatch-from-config.test.ts -t "direct native progress callbacks|channel-owned group progress callbacks|delivers text-only tool summaries when verbose overrides preview suppression|delivers verbose tool summaries despite message-tool-only source suppression"
- node scripts/run-oxlint.mjs --tsconfig config/tsconfig/oxlint.core.json src/auto-reply/reply/dispatch-from-config.ts src/auto-reply/reply/dispatch-from-config.test.ts
- git diff --check
- /Users/steipete/Projects/agent-skills/skills/autoreview/scripts/autoreview --mode branch --base origin/main

Thanks @PashaGanson.
2026-05-26 20:41:38 +01:00
Peter Steinberger
174cd49f78 fix: tighten parser edge cases (#86999)
* fix: tighten parser edge cases

* fix: dedupe lsof listener records

* fix: recognize ipv6 wildcard model URLs
2026-05-26 20:40:13 +01:00
Vincent Koc
39682889f9 fix(e2e): clean stale docker lane containers 2026-05-26 21:25:16 +02:00
Vincent Koc
71cb60706b fix(e2e): bound docker lifecycle hangs 2026-05-26 21:22:01 +02:00
Peter Steinberger
0ea7871e53 fix(gateway): bound live agent model probes 2026-05-26 20:20:01 +01:00
Vincent Koc
b36fa1d8f1 fix(e2e): bound plugin binding docker smoke 2026-05-26 21:09:37 +02:00
Vincent Koc
c0641eb3ad fix(e2e): preserve docker run failure status 2026-05-26 20:55:51 +02:00
rendrag-git
e9dd1c43c4 feat(discord): bucket large model picker menus
Summary:
- Add alpha-bucket selects when the Discord provider/model picker exceeds select-menu limits.
- Split bucket/runtime lookup helpers and keep compact recents runtime decoding provider-scoped.

Verification:
- node scripts/run-vitest.mjs --config test/vitest/vitest.extension-discord.config.ts extensions/discord/src/monitor/model-picker.test.ts extensions/discord/src/monitor/native-command.model-picker.test.ts
- node scripts/run-tsgo.mjs
- git diff --check origin/main...HEAD
- autoreview --mode local: no accepted/actionable findings
- CI run 26468173320, OpenGrep run 26468171525, CodeQL Critical Quality run 26468171885

Co-authored-by: rendrag-git <253747599+rendrag-git@users.noreply.github.com>
Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-05-26 19:52:50 +01:00
alexph-dev
aa117ec4de fix(telegram): derive DM topics from bot capability
Remove the Telegram DM thread reply policy config and use Telegram bot capability as the single source of truth for DM topic session splitting.

DM messages with message_thread_id now split into thread-scoped sessions only when Telegram getMe reports has_topics_enabled for the bot. Doctor removes retired dm.threadReplies and direct.*.threadReplies keys, docs explain the upgrade behavior, and startup keeps cached bot info as a non-auth fallback when a fresh probe fails.

Refs #86513.
Thanks @alexph-dev.

Verification:
- pnpm docs:list
- pnpm exec oxfmt --check --threads=1 extensions/telegram/src/channel.ts extensions/telegram/src/channel.gateway.test.ts extensions/telegram/src/doctor-contract.ts extensions/telegram/src/doctor.test.ts
- git diff --check
- node scripts/run-vitest.mjs extensions/telegram/src/channel.gateway.test.ts extensions/telegram/src/doctor.test.ts extensions/telegram/src/bot/helpers.test.ts extensions/telegram/src/bot-message-context.dm-threads.test.ts extensions/telegram/src/config-schema.test.ts
- pnpm config:channels:check
- pnpm config:docs:check
- .agents/skills/autoreview/scripts/autoreview --mode local
- GitHub Actions: CI 26468039803, Workflow Sanity 26468040057, OpenGrep 26468039472, Real behavior proof 26468036483, CodeQL 26468039466, CodeQL Critical Quality 26468039473

Known CI caveat: checks-windows-node-test failed before tests because Windows runner setup left Node 22.19.0 active while the job requested Node 24.x; the same setup failure is present on current main CI run 26468063947.
2026-05-26 19:52:17 +01:00
Peter Steinberger
4007df7f60 fix: improve discord voice playback and wake replies 2026-05-26 19:40:12 +01:00
Vincent Koc
23aeb58eaa fix(e2e): kill timed kitchen rpc command groups 2026-05-26 20:39:44 +02:00
856 changed files with 41940 additions and 9480 deletions

View File

@@ -27,10 +27,11 @@ Use when:
- 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.
- Be patient with large bundles. Structured review can take up to 30 minutes while the model call is active, especially with Codex tools or web search.
- Treat heartbeat lines like `review still running: ... elapsed=... pid=...` as healthy progress, not a hang. Let the helper continue while heartbeats are advancing.
- Treat heartbeat lines like `review still running: ... elapsed=... pid=...` as healthy progress, not a hang. Let the helper continue while heartbeats are advancing. Pass `--stream-engine-output` when live engine text is useful; Codex and Claude filter tool/file chatter, other engines pass raw output through.
- Do not kill a review just because it has been quiet for 2-5 minutes, or because it is still running under the 30-minute window. Inspect the process only after missing multiple expected heartbeats, after 30 minutes, or after an obviously failed subprocess; prefer letting the same helper command finish.
- Tools are useful in review mode. The helper allows read-only inspection tools and web search by default so reviewers can check dependency contracts, upstream docs, and current behavior.
- Security perspective is always included, but it should not cripple legitimate functionality. Report security findings only when the change creates a concrete, actionable risk or removes an important safety check.
- For regression provenance, if no blamed PR is traceable, use the blamed commit as the provenance: commit SHA, date, and author username. Do not guess a merger or frame missing PR metadata as a separate finding.
- Do not invoke built-in `codex review`, nested reviewers, or reviewer panels from inside the review. The helper builds one bundle, calls one selected engine, validates one structured result, and stops.
- Stop as soon as the helper exits 0 with no accepted/actionable findings. Do not run an extra review just to get a nicer "clean" line, a second opinion, or clearer closeout wording.
- Treat the helper's successful exit plus absence of actionable findings as the clean review result, even if the underlying Codex CLI output is terse.
@@ -168,11 +169,12 @@ The helper:
- supports `--engine codex`, `claude`, `droid`, and `copilot`; default is `AUTOREVIEW_ENGINE` or `codex`; Codex should remain the default when nothing is set
- use `--mode commit --commit <ref>` for already-committed work, especially clean `main` after landing
- should be left in `--mode auto` or forced to `--mode branch` for PR/branch work; do not force `--mode local` after committing
- writes only to stdout unless `--output` or `--json-output` is set
- writes only to stdout unless `--output`, `--json-output`, or live streamed engine stderr is set
- supports `--dry-run`, `--parallel-tests`, `--prompt`, `--prompt-file`, `--dataset`, `--no-tools`, `--no-web-search`, and commit refs
- supports `--stream-engine-output` or `AUTOREVIEW_STREAM_ENGINE_OUTPUT=1` for live engine text while preserving structured validation; Codex and Claude hide tool/file event details, emit compact activity summaries, and report usage at turn completion
- supports opt-in review panels with `--panel` / `--reviewers`, plus per-engine `--model` and `--thinking`
- allows read-only tools and web search by default where the selected CLI supports them; forbids nested review in the prompt; Codex is run through `codex exec` with read-only sandbox and structured output
- prints `review still running: <engine> elapsed=<seconds>s pid=<pid>` to stderr at long-running intervals while waiting for the selected review engine
- prints `review still running: <engine> elapsed=<seconds>s pid=<pid>` to stderr at long-running intervals while waiting for the selected review engine, unless streamed output or compact Codex activity has been visible recently
- prints `autoreview clean: no accepted/actionable findings reported` when the selected review command exits 0
- exits nonzero when accepted/actionable findings are present

View File

@@ -6,13 +6,15 @@ import concurrent.futures
import copy
import json
import os
import queue
import subprocess
import sys
import tempfile
import textwrap
import threading
import time
from pathlib import Path
from typing import Any
from typing import Any, Callable
ENGINES = ("codex", "claude", "droid", "copilot")
@@ -100,7 +102,18 @@ def run_with_heartbeat(
input_text: str | None = None,
label: str,
heartbeat_seconds: int = 60,
stream_output: bool = False,
stream_display: Callable[[str, str], str | None] | None = None,
) -> subprocess.CompletedProcess[str]:
if stream_output:
return run_with_stream(
args,
cwd,
input_text=input_text,
label=label,
heartbeat_seconds=heartbeat_seconds,
stream_display=stream_display,
)
started = time.monotonic()
proc = subprocess.Popen(
args,
@@ -124,6 +137,82 @@ def run_with_heartbeat(
print(f"review still running: {label} elapsed={elapsed}s pid={proc.pid}", file=sys.stderr, flush=True)
def run_with_stream(
args: list[str],
cwd: Path,
*,
input_text: str | None,
label: str,
heartbeat_seconds: int,
stream_display: Callable[[str, str], str | None] | None,
) -> subprocess.CompletedProcess[str]:
started = time.monotonic()
proc = subprocess.Popen(
args,
cwd=cwd,
stdin=subprocess.PIPE if input_text is not None else None,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
bufsize=1,
)
events: queue.Queue[tuple[str, str | None]] = queue.Queue()
stdout_parts: list[str] = []
stderr_parts: list[str] = []
def read_stream(name: str, stream: Any) -> None:
try:
for line in iter(stream.readline, ""):
events.put((name, line))
finally:
events.put((name, None))
def write_stdin() -> None:
if proc.stdin is None or input_text is None:
return
try:
proc.stdin.write(input_text)
proc.stdin.close()
except BrokenPipeError:
return
threads = [
threading.Thread(target=read_stream, args=("stdout", proc.stdout), daemon=True),
threading.Thread(target=read_stream, args=("stderr", proc.stderr), daemon=True),
]
for thread in threads:
thread.start()
stdin_thread = threading.Thread(target=write_stdin, daemon=True)
stdin_thread.start()
open_streams = 2
while open_streams:
try:
name, line = events.get(timeout=heartbeat_seconds)
except queue.Empty:
elapsed = int(time.monotonic() - started)
print(f"review still running: {label} elapsed={elapsed}s pid={proc.pid}", file=sys.stderr, flush=True)
continue
if line is None:
open_streams -= 1
continue
if name == "stdout":
stdout_parts.append(line)
else:
stderr_parts.append(line)
display = stream_display(name, line) if stream_display else line
if display:
target = sys.stdout if name == "stdout" else sys.stderr
target.write(display)
target.flush()
for thread in threads:
thread.join()
stdin_thread.join(timeout=1)
returncode = proc.wait()
return subprocess.CompletedProcess(args, returncode, "".join(stdout_parts), "".join(stderr_parts))
def git(repo: Path, *args: str, check: bool = True) -> str:
return run(["git", *args], repo, check=check).stdout
@@ -336,9 +425,11 @@ def run_codex(args: argparse.Namespace, repo: Path, prompt: str) -> str:
cmd.extend(["--model", args.model])
if args.thinking:
cmd.extend(["-c", f'model_reasoning_effort="{args.thinking}"'])
cmd.append("exec")
if args.stream_engine_output:
cmd.append("--json")
cmd.extend(
[
"exec",
"--ephemeral",
"-C",
str(repo),
@@ -351,7 +442,14 @@ def run_codex(args: argparse.Namespace, repo: Path, prompt: str) -> str:
"-",
]
)
result = run_with_heartbeat(cmd, repo, input_text=prompt, label="codex")
result = run_with_heartbeat(
cmd,
repo,
input_text=prompt,
label="codex",
stream_output=args.stream_engine_output,
stream_display=CodexStreamDisplay() if args.stream_engine_output else None,
)
try:
output = output_path.read_text()
finally:
@@ -368,7 +466,7 @@ def run_claude(args: argparse.Namespace, repo: Path, prompt: str) -> str:
"--print",
"--no-session-persistence",
"--output-format",
"json",
"stream-json" if args.stream_engine_output else "json",
"--json-schema",
json.dumps(SCHEMA),
]
@@ -376,11 +474,20 @@ def run_claude(args: argparse.Namespace, repo: Path, prompt: str) -> str:
cmd.extend(["--allowedTools", claude_allowed_tools(args)])
else:
cmd.extend(["--tools", ""])
if args.stream_engine_output:
cmd.append("--verbose")
if args.model:
cmd.extend(["--model", args.model])
if args.thinking:
cmd.extend(["--effort", args.thinking])
result = run_with_heartbeat(cmd, repo, input_text=prompt, label="claude")
result = run_with_heartbeat(
cmd,
repo,
input_text=prompt,
label="claude",
stream_output=args.stream_engine_output,
stream_display=ClaudeStreamDisplay() if args.stream_engine_output else None,
)
if result.returncode != 0:
raise SystemExit(f"claude engine failed ({result.returncode})\n{result.stderr or result.stdout}")
return result.stdout
@@ -405,7 +512,7 @@ def run_droid(args: argparse.Namespace, repo: Path, prompt: str) -> str:
cmd.extend(["--model", args.model])
if not args.tools:
cmd.extend(["--disabled-tools", "*"])
result = run_with_heartbeat(cmd, repo, label="droid")
result = run_with_heartbeat(cmd, repo, label="droid", stream_output=args.stream_engine_output)
prompt_path.unlink(missing_ok=True)
if result.returncode != 0:
raise SystemExit(f"droid engine failed ({result.returncode})\n{result.stderr or result.stdout}")
@@ -430,7 +537,7 @@ def run_copilot(args: argparse.Namespace, repo: Path, prompt: str) -> str:
"--output-format",
"json",
"--stream",
"off",
"on" if args.stream_engine_output else "off",
"--no-ask-user",
"--disable-builtin-mcps",
]
@@ -447,12 +554,142 @@ def run_copilot(args: argparse.Namespace, repo: Path, prompt: str) -> str:
)
if args.web_search:
cmd.append("--allow-all-urls")
result = run_with_heartbeat(cmd, Path(tempdir), label="copilot")
result = run_with_heartbeat(cmd, Path(tempdir), label="copilot", stream_output=args.stream_engine_output)
if result.returncode != 0:
raise SystemExit(f"copilot engine failed ({result.returncode})\n{result.stderr or result.stdout}")
return result.stdout
class CodexStreamDisplay:
def __init__(self, *, activity_seconds: int = 20) -> None:
self.activity_seconds = activity_seconds
self.hidden_events = 0
self.last_visible = time.monotonic()
def __call__(self, name: str, line: str) -> str | None:
if name != "stdout":
return line
try:
event = json.loads(line)
except json.JSONDecodeError:
return self.visible(line)
event_type = event.get("type")
if event_type == "thread.started":
return self.visible(f"codex thread: {event.get('thread_id', '<unknown>')}\n")
if event_type == "turn.started":
return self.visible("codex turn started\n")
if event_type == "turn.completed":
usage = event.get("usage")
message = format_codex_usage(usage) + "\n" if isinstance(usage, dict) else "codex turn completed\n"
return self.visible(self.flush_hidden() + message)
item = event.get("item")
if isinstance(item, dict) and item.get("type") == "agent_message" and isinstance(item.get("text"), str):
return self.visible(self.flush_hidden() + item["text"].rstrip() + "\n")
return self.hidden_activity()
def hidden_activity(self) -> str | None:
self.hidden_events += 1
if time.monotonic() - self.last_visible < self.activity_seconds:
return None
return self.visible(self.flush_hidden())
def flush_hidden(self) -> str:
if not self.hidden_events:
return ""
count = self.hidden_events
self.hidden_events = 0
return f"codex activity: {count} hidden tool/status events\n"
def visible(self, text: str) -> str:
self.last_visible = time.monotonic()
return text
class ClaudeStreamDisplay:
def __init__(self, *, activity_seconds: int = 20) -> None:
self.activity_seconds = activity_seconds
self.hidden_events = 0
self.last_visible = time.monotonic()
self.started = False
def __call__(self, name: str, line: str) -> str | None:
if name != "stdout":
return line
try:
event = json.loads(line)
except json.JSONDecodeError:
return self.visible(line)
event_type = event.get("type")
if event_type == "system" and not self.started:
self.started = True
return self.visible("claude turn started\n")
if event_type == "assistant":
return self.assistant_message(event)
if event_type == "result":
return self.visible(self.flush_hidden() + self.result_summary(event))
return self.hidden_activity()
def assistant_message(self, event: dict[str, Any]) -> str | None:
message = event.get("message")
if not isinstance(message, dict):
return self.hidden_activity()
chunks: list[str] = []
for item in message.get("content", []):
if not isinstance(item, dict):
continue
if item.get("type") == "text" and isinstance(item.get("text"), str):
chunks.append(item["text"].rstrip())
if chunks:
return self.visible(self.flush_hidden() + "\n".join(chunks) + "\n")
return self.hidden_activity()
def result_summary(self, event: dict[str, Any]) -> str:
usage = event.get("usage")
fields: list[str] = []
if isinstance(usage, dict):
for key in (
"input_tokens",
"cache_read_input_tokens",
"cache_creation_input_tokens",
"output_tokens",
):
value = usage.get(key)
if isinstance(value, int):
fields.append(f"{key}={value}")
cost = event.get("total_cost_usd")
if isinstance(cost, (int, float)) and not isinstance(cost, bool):
fields.append(f"cost_usd={cost:.6f}")
return "claude usage: " + " ".join(fields) + "\n" if fields else "claude turn completed\n"
def hidden_activity(self) -> str | None:
self.hidden_events += 1
if time.monotonic() - self.last_visible < self.activity_seconds:
return None
return self.visible(self.flush_hidden())
def flush_hidden(self) -> str:
if not self.hidden_events:
return ""
count = self.hidden_events
self.hidden_events = 0
return f"claude activity: {count} hidden tool/status events\n"
def visible(self, text: str) -> str:
self.last_visible = time.monotonic()
return text
def format_codex_usage(usage: dict[str, Any]) -> str:
fields = [
"input_tokens",
"cached_input_tokens",
"output_tokens",
"reasoning_output_tokens",
]
parts = [f"{field}={usage[field]}" for field in fields if isinstance(usage.get(field), int)]
return "codex usage: " + " ".join(parts) if parts else "codex usage: unavailable"
def claude_allowed_tools(args: argparse.Namespace) -> str:
tools = [tool.strip() for tool in args.claude_allowed_tools.split(",") if tool.strip()]
if not args.web_search:
@@ -490,7 +727,7 @@ def extract_json(text: str) -> dict[str, Any]:
def extract_json_from_jsonl(text: str) -> dict[str, Any] | None:
candidates: list[str] = []
candidates: list[str | dict[str, Any]] = []
for line in text.splitlines():
line = line.strip()
if not line:
@@ -509,7 +746,13 @@ def extract_json_from_jsonl(text: str) -> dict[str, Any] | None:
candidates.append(data["content"])
if isinstance(event.get("result"), str):
candidates.append(event["result"])
if isinstance(event.get("structured_output"), dict):
candidates.append(event["structured_output"])
for candidate in reversed(candidates):
if isinstance(candidate, dict):
if "findings" in candidate:
return candidate
continue
parsed = parse_json_candidate(candidate)
if isinstance(parsed, dict) and "findings" in parsed:
return parsed
@@ -673,6 +916,12 @@ def parse_args() -> argparse.Namespace:
parser.add_argument("--dataset", action="append", help="Extra evidence file to include in the review bundle.")
parser.add_argument("--output", help="Write human output to a file as well as stdout.")
parser.add_argument("--json-output", help="Write validated structured review JSON.")
parser.add_argument(
"--stream-engine-output",
action="store_true",
default=os.environ.get("AUTOREVIEW_STREAM_ENGINE_OUTPUT") == "1",
help="Stream review engine output while preserving buffered output for validation. Codex output is filtered to hide tool/file chatter.",
)
parser.add_argument("--parallel-tests", help="Run a test command concurrently with review; failure fails the helper.")
parser.add_argument("--require-finding", action="append", default=[], help="Require finding text to contain this substring.")
parser.add_argument("--expect-findings", action="store_true", help="Treat findings as success; for harness acceptance tests.")

View File

@@ -160,9 +160,14 @@ pnpm crabbox:run -- \
--ttl 240m \
--timing-json \
--shell -- \
"pnpm test"
"pnpm verify"
```
Use `pnpm verify` when you need check plus full Vitest proof. It emits
`CRABBOX_PHASE:check` and `CRABBOX_PHASE:test`, making Crabbox summaries show
which stage failed. Use plain `pnpm test` only when check proof is already
covered or intentionally skipped.
Focused rerun:
```sh

View File

@@ -0,0 +1,87 @@
---
name: openclaw-changelog-update
description: Regenerate OpenClaw release changelog sections from git history before beta or stable releases.
---
# OpenClaw Changelog Update
Use this for release changelog rewrites and GitHub release-note source text.
Use it with `release-openclaw-maintainer`; this skill owns changelog content,
ordering, and audit discipline.
## Goal
Rewrite the target `CHANGELOG.md` version section from history, not from stale
draft notes. Produce user-facing release notes sorted by user interest while
preserving issue/PR refs and thanks.
## Inputs
- Target base version: `YYYY.M.D`, without beta suffix.
- Base tag: last reachable shipped release tag, usually the previous stable or
the previous beta train requested by the operator.
- Target ref: exact branch/SHA being released.
## Workflow
1. Start on `main` before branching when possible:
- `git fetch --tags origin`
- `git pull --ff-only`
- confirm clean `git status -sb`
2. Audit history, including direct commits:
- `git log --first-parent --date=iso-strict --pretty=format:'%h%x09%ad%x09%s' <base-tag>..<target-ref>`
- `git log --first-parent --grep='(#' --date=short --pretty=format:'%h%x09%ad%x09%s' <base-tag>..<target-ref>`
- also inspect `--since='24 hours ago'` when main moved during the release.
3. Read linked PRs/issues or diffs for ambiguous commits. Direct commits matter;
infer notes from subject, body, touched files, tests, and nearby commits.
4. Rewrite one stable-base section only:
- use `## YYYY.M.D`
- do not create beta-specific headings
- do not leave a stale `## Unreleased` section above the target release
- if `Unreleased` contains release-bound notes, fold them into the target
section instead of deleting them
5. Section shape:
- `### Highlights`: 5-8 bullets, broad user wins first
- `### Changes`: new capabilities and behavior changes
- `### Fixes`: user-facing fixes first, grouped by impact and surface
6. Preserve attribution:
- keep `#issue`, `(#PR)`, `Fixes #...`, and `Thanks @...`
- do not add GHSA references, advisory IDs, or security advisory slugs to
changelog entries or GitHub release-note text unless explicitly requested
- never thank bots, `@openclaw`, `@clawsweeper`, or `@steipete`
- if grouping multiple entries, carry all relevant refs and thanks into the
grouped bullet
7. Sorting preference:
- security/data-loss and content-boundary fixes
- transcript/replay/reply delivery correctness
- channels and mobile integrations
- providers/Codex/local model reliability
- install/update/release path reliability
- performance and observability
- docs and contributor-only/internal details last or omitted
8. Keep bullets single-line unless existing file style forces otherwise. Avoid
internal release-process noise unless it changes user install/update safety.
9. Check release-note side conditions:
- inspect `src/plugins/compat/registry.ts`
- inspect `src/commands/doctor/shared/deprecation-compat.ts`
- if any compatibility `removeAfter` is on/before release date, resolve it
or explicitly record the blocker before shipping
10. Validate and ship:
- `git diff --check`
- for docs/changelog-only changes, no broad tests are required
- commit with `scripts/committer "docs(changelog): refresh YYYY.M.D notes" CHANGELOG.md`
- push, pull/rebase if needed, then branch/rebase release from latest `main`
## Quota / API Outage Rule
If GitHub API quota is exhausted, do not idle. Continue work that does not need
GitHub API:
- local changelog rewrite and release-note extraction
- local pretag checks and package/build sanity
- git push/tag checks over git protocol
- npm registry `npm view` checks
- exact workflow-dispatch command preparation
Only GitHub Release creation, workflow dispatch, run polling, artifact download,
and issue/PR mutation need API quota.

View File

@@ -168,13 +168,22 @@ Output only qualifying candidates, with: ref, surface, proof, cause, fix sketch,
- Start every PR review with 1-3 plain sentences explaining what the change does and why it matters. Put this before `Findings`.
- Then list findings first. If none, say `No blocking findings` or `No findings`.
- Show size near the top as `LOC: +<additions>/-<deletions> (<changedFiles> files)`, using live PR stats or local diff stats.
- Always answer: bug/behavior being fixed, PR/issue URL and affected surface, provenance for regressions when traceable, and best-fix verdict.
- For bug/regression fixes, include a compact `Provenance:` line after cause/root-cause when a bounded history pass can identify it. Use `git log -S/-G`, `git blame`, linked PRs/issues, and tests.
- Provenance must separate roles when they differ: blamed code author username, blamed PR merger/committer username, current PR author username, PR number, and date. Do not collapse them into one "introduced by" actor.
- Provenance must separate roles when they differ: blamed code author username, blamed PR author username, blamed PR merger/committer username, automerge trigger when known, current PR author username, PR number, and date. Do not collapse them into one "introduced by" actor.
- If the blamed PR was merged by `clawsweeper[bot]` or another automation, identify the human trigger when practical. Check live PR timeline/comments first; if rate-limited, use gitcrawl/cache or public PR HTML. Look for maintainer command comments such as `@clawsweeper automerge`, `/landpr`, labels/events that armed automerge, and ClawSweeper status comments. Report `automerge triggered by @login`; if not found, say trigger unknown rather than naming the bot as the human decision-maker.
- For any confirmed bug, run `git blame` on the implicated line(s) after identifying the root cause. Report who broke it as the blamed PR merger/committer, and also name the blamed code author. Include the PR number. If no PR is traceable, use the blamed commit as the provenance: commit SHA, date, and author username. Do not guess a merger or frame missing PR metadata as a separate finding.
- Phrase provenance as `introduced by`, `made visible by`, or `carried forward by`, with confidence (`clear`, `likely`, `unknown`). If unclear, say what evidence is missing instead of guessing. For features, docs, and refactors, use `Provenance: N/A` or omit it when no broken behavior is being fixed.
- Keep summaries compact, but include enough proof that the verdict is auditable without rereading the PR.
LOC proof:
```bash
gh pr view <number> --json additions,deletions,changedFiles \
--jq '"LOC: +\(.additions)/-\(.deletions) (\(.changedFiles) files)"'
```
## Read beyond the diff
- Review the surrounding code path, not just changed lines. Open the caller, callee, data contracts, adjacent tests, and owner module.
@@ -194,7 +203,7 @@ Output only qualifying candidates, with: ref, surface, proof, cause, fix sketch,
- Before landing, require:
1. symptom evidence such as a repro, logs, or a failing test
2. a verified root cause in code with file/line
3. blame-backed provenance for regressions when traceable, including blamed PR merger and date, or commit SHA/date when no PR is traceable
3. blame-backed provenance for regressions when traceable, including blamed PR merger and automerge trigger when known, or commit SHA/date when no PR is traceable
4. a fix that touches the implicated code path
5. a regression test when feasible, or explicit manual verification plus a reason no test was added
- If the claim is unsubstantiated or likely wrong, request evidence or changes instead of merging.

View File

@@ -68,6 +68,7 @@ scripts/crabbox-wrapper.mjs` for Testbox, and `git commit --no-verify` only
pnpm changed:lanes --json
pnpm check:changed # changed typecheck/lint/guards; no Vitest
pnpm test:changed # cheap smart changed Vitest targets
pnpm verify # full check, then full Vitest
OPENCLAW_TEST_CHANGED_BROAD=1 pnpm test:changed
pnpm test <path-or-filter> -- --reporter=verbose
OPENCLAW_VITEST_MAX_WORKERS=1 pnpm test <path-or-filter>
@@ -89,6 +90,8 @@ status checks or install reconciliation in a linked worktree.
- `pnpm check` and `pnpm check:changed` do not run Vitest tests. They are for
typecheck, lint, and guard proof.
- `pnpm test` and `pnpm test:changed` run Vitest tests.
- `pnpm verify` runs `pnpm check`, then `pnpm test`, with Crabbox phase markers
so remote summaries show which half failed.
- `pnpm test:changed` is intentionally cheap by default: direct test edits,
sibling tests, explicit source mappings, and import-graph dependents.
- `OPENCLAW_TEST_CHANGED_BROAD=1 pnpm test:changed` is the explicit broad

View File

@@ -70,7 +70,8 @@ Use this skill for release and publish-time workflow. Load `$release-private` if
release blocker unless the operator waives it or the data clearly proves
infrastructure noise.
- Generate the changelog before version/tag preparation so the top changelog
section is deduped and ordered by user impact.
section is deduped and ordered by user impact. Use
`$openclaw-changelog-update` for the rewrite.
- Do not create beta-specific `CHANGELOG.md` headings. Beta releases use the
stable base version section, for example `v2026.4.20-beta.1` uses
`## 2026.4.20` release notes.

View File

@@ -55,7 +55,7 @@ runs:
shell: bash
run: |
set -euo pipefail
npm install -g bun@1.3.13
npm install -g bun@1.3.14
- name: Runtime versions
shell: bash

View File

@@ -62,6 +62,12 @@ runs:
;;
esac
corepack enable
for attempt in 1 2 3; do
if corepack prepare "$package_manager" --activate; then
exit 0
fi
sleep $((attempt * 5))
done
corepack prepare "$package_manager" --activate
- name: Resolve pnpm store path

View File

@@ -8,7 +8,10 @@ openclaw_node_version_matches() {
fi
case "$requested" in
*x)
[[ "${actual%%.*}" == "${requested%%.*}" ]]
[[ "${actual%%.*}" == "${requested%%.*}" ]] || return 1
if [[ "${requested%%.*}" == "22" ]]; then
openclaw_node_version_at_least "$actual" "22.19.0"
fi
;;
*.*.*)
[[ "$actual" == "$requested" ]]
@@ -22,6 +25,28 @@ openclaw_node_version_matches() {
esac
}
openclaw_node_version_at_least() {
local actual="$1"
local minimum="$2"
local actual_major actual_minor actual_patch minimum_major minimum_minor minimum_patch
IFS=. read -r actual_major actual_minor actual_patch <<< "$actual"
IFS=. read -r minimum_major minimum_minor minimum_patch <<< "$minimum"
actual_minor="${actual_minor:-0}"
actual_patch="${actual_patch:-0}"
minimum_minor="${minimum_minor:-0}"
minimum_patch="${minimum_patch:-0}"
if (( actual_major != minimum_major )); then
(( actual_major > minimum_major ))
return
fi
if (( actual_minor != minimum_minor )); then
(( actual_minor > minimum_minor ))
return
fi
(( actual_patch >= minimum_patch ))
}
openclaw_active_node_version() {
node -p 'process.versions.node' 2>/dev/null || true
}
@@ -57,6 +82,9 @@ openclaw_find_toolcache_node() {
"/Users/runner/hostedtoolcache" \
"/c/hostedtoolcache/windows"
do
if [[ ! -d "$root" && "$root" == *\\* ]] && command -v cygpath >/dev/null 2>&1; then
root="$(cygpath -u "$root" 2>/dev/null || printf '%s' "$root")"
fi
if [[ -d "$root/node" ]]; then
roots+=("$root/node")
elif [[ "$(basename "$root")" == "node" && -d "$root" ]]; then
@@ -108,6 +136,9 @@ openclaw_node_download_platform() {
Linux:aarch64 | Linux:arm64) printf 'linux-arm64\n' ;;
Darwin:x86_64) printf 'darwin-x64\n' ;;
Darwin:arm64) printf 'darwin-arm64\n' ;;
MINGW*:x86_64 | MSYS*:x86_64 | CYGWIN*:x86_64 | MINGW*:AMD64 | MSYS*:AMD64 | CYGWIN*:AMD64)
printf 'win-x64\n'
;;
*)
return 1
;;
@@ -120,8 +151,24 @@ openclaw_download_node() {
version="$(openclaw_resolve_node_download_version "$requested_node")"
platform="$(openclaw_node_download_platform)" || return 1
install_root="${RUNNER_TEMP:-/tmp}/openclaw-node-${version}-${platform}"
archive_url="https://nodejs.org/dist/${version}/node-${version}-${platform}.tar.xz"
mkdir -p "$install_root"
if [[ "$platform" == win-* ]]; then
local archive_path
archive_url="https://nodejs.org/dist/${version}/node-${version}-${platform}.zip"
archive_path="${RUNNER_TEMP:-/tmp}/node-${version}-${platform}.zip"
echo "Downloading Node ${version} from ${archive_url}"
curl -fsSL "$archive_url" -o "$archive_path"
if command -v powershell.exe >/dev/null 2>&1 && command -v cygpath >/dev/null 2>&1; then
powershell.exe -NoLogo -NoProfile -Command \
"Expand-Archive -LiteralPath '$(cygpath -w "$archive_path")' -DestinationPath '$(cygpath -w "$install_root")' -Force"
else
unzip -q "$archive_path" -d "$install_root"
fi
openclaw_prepend_node_bin "$install_root/node-${version}-${platform}"
return 0
fi
archive_url="https://nodejs.org/dist/${version}/node-${version}-${platform}.tar.xz"
echo "Downloading Node ${version} from ${archive_url}"
curl -fsSL "$archive_url" | tar -xJ -C "$install_root" --strip-components=1
openclaw_prepend_node_bin "$install_root/bin"

View File

@@ -202,6 +202,7 @@ jobs:
if (runNodeFull) {
checksFastCoreTasks.push(
{ check_name: "checks-fast-bundled-protocol", runtime: "node", task: "bundled-protocol" },
{ check_name: "checks-fast-bun-launcher", runtime: "bun", task: "bun-launcher" },
);
} else {
if (runNodeFastCiRouting) {
@@ -683,7 +684,7 @@ jobs:
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
install-bun: "false"
install-bun: ${{ matrix.task == 'bun-launcher' && 'true' || 'false' }}
- name: Run ${{ matrix.task }} (${{ matrix.runtime }})
env:
@@ -704,6 +705,9 @@ jobs:
ci-routing)
pnpm test src/commands/status.scan-result.test.ts src/scripts/ci-changed-scope.test.ts test/scripts/test-projects.test.ts
;;
bun-launcher)
OPENCLAW_TEST_BUN_LAUNCHER=1 pnpm test test/openclaw-launcher.e2e.test.ts
;;
*)
echo "Unsupported checks-fast task: $TASK" >&2
exit 1
@@ -1507,7 +1511,7 @@ jobs:
- name: Setup Node.js
env:
REQUESTED_NODE_VERSION: "24.x"
REQUESTED_NODE_VERSION: "22.x"
run: |
set -euo pipefail
source .github/actions/setup-pnpm-store-cache/ensure-node.sh
@@ -1516,7 +1520,7 @@ jobs:
- name: Setup pnpm
uses: ./.github/actions/setup-pnpm-store-cache
with:
node-version: 24.x
node-version: 22.x
- name: Runtime versions
run: |

View File

@@ -72,7 +72,24 @@ jobs:
echo "PNPM_HOME=$PNPM_HOME"
} >> "$GITHUB_ENV"
package_manager="$(node -e "const fs = require('node:fs'); const pkg = JSON.parse(fs.readFileSync('package.json', 'utf8')); process.stdout.write(pkg.packageManager || '')")"
case "$package_manager" in
pnpm@*) ;;
*)
echo "::error::Expected packageManager to pin pnpm, got '${package_manager:-<empty>}'"
exit 1
;;
esac
corepack enable --install-directory "$PNPM_HOME"
for attempt in 1 2 3; do
if corepack prepare "$package_manager" --activate; then
break
fi
if [ "$attempt" = 3 ]; then
corepack prepare "$package_manager" --activate
fi
sleep $((attempt * 5))
done
node_bin="$(dirname "$(node -p 'process.execPath')")"
echo "NODE_BIN=$node_bin" >> "$GITHUB_ENV"
echo "$node_bin" >> "$GITHUB_PATH"

View File

@@ -521,7 +521,7 @@ jobs:
set -euo pipefail
for attempt in 1 2; do
echo "live-cache attempt ${attempt}/2"
if timeout --kill-after=30s 8m pnpm test:live:cache; then
if timeout --foreground --kill-after=30s 8m pnpm test:live:cache; then
exit 0
fi
if [[ "$attempt" == "2" ]]; then
@@ -1434,7 +1434,7 @@ jobs:
fi
echo "Validating Docker E2E package tarball: $target"
started_at="$(date +%s)"
timeout --kill-after=30s 5m node scripts/check-openclaw-package-tarball.mjs "$target"
timeout --foreground 5m node scripts/check-openclaw-package-tarball.mjs "$target"
finished_at="$(date +%s)"
echo "Docker E2E package tarball validation finished in $((finished_at - started_at))s."
digest="$(sha256sum "$target" | awk '{print $1}')"
@@ -1778,7 +1778,7 @@ jobs:
- name: Run Docker live model sweep
if: contains(matrix.profiles, inputs.release_test_profile)
run: OPENCLAW_LIVE_DOCKER_REPO_ROOT="$GITHUB_WORKSPACE" timeout --kill-after=30s 35m bash .release-harness/scripts/test-live-models-docker.sh
run: OPENCLAW_LIVE_DOCKER_REPO_ROOT="$GITHUB_WORKSPACE" timeout --foreground --kill-after=30s 35m bash .release-harness/scripts/test-live-models-docker.sh
validate_live_models_docker_targeted:
name: Docker live models (selected providers)
@@ -1953,7 +1953,7 @@ jobs:
done
- name: Run Docker live model sweep
run: OPENCLAW_LIVE_DOCKER_REPO_ROOT="$GITHUB_WORKSPACE" timeout --kill-after=30s 35m bash .release-harness/scripts/test-live-models-docker.sh
run: OPENCLAW_LIVE_DOCKER_REPO_ROOT="$GITHUB_WORKSPACE" timeout --foreground --kill-after=30s 35m bash .release-harness/scripts/test-live-models-docker.sh
validate_live_provider_suites:
needs: validate_selected_ref
@@ -2289,32 +2289,32 @@ jobs:
include:
- suite_id: live-gateway-docker
label: Docker live gateway OpenAI
command: OPENCLAW_LIVE_GATEWAY_THINKING=low OPENCLAW_LIVE_GATEWAY_PROVIDERS=openai OPENCLAW_LIVE_GATEWAY_MODELS=openai/gpt-5.5 OPENCLAW_LIVE_GATEWAY_MAX_MODELS=1 OPENCLAW_LIVE_GATEWAY_STEP_TIMEOUT_MS=90000 OPENCLAW_LIVE_GATEWAY_MODEL_TIMEOUT_MS=600000 OPENCLAW_LIVE_DOCKER_REPO_ROOT="$GITHUB_WORKSPACE" timeout --kill-after=30s 35m bash .release-harness/scripts/test-live-gateway-models-docker.sh
command: OPENCLAW_LIVE_GATEWAY_THINKING=low OPENCLAW_LIVE_GATEWAY_PROVIDERS=openai OPENCLAW_LIVE_GATEWAY_MODELS=openai/gpt-5.5 OPENCLAW_LIVE_GATEWAY_MAX_MODELS=1 OPENCLAW_LIVE_GATEWAY_STEP_TIMEOUT_MS=90000 OPENCLAW_LIVE_GATEWAY_MODEL_TIMEOUT_MS=600000 OPENCLAW_LIVE_DOCKER_REPO_ROOT="$GITHUB_WORKSPACE" timeout --foreground --kill-after=30s 35m bash .release-harness/scripts/test-live-gateway-models-docker.sh
timeout_minutes: 40
profile_env_only: false
profiles: beta minimum stable full
- suite_id: live-gateway-anthropic-docker
label: Docker live gateway Anthropic
command: OPENCLAW_LIVE_GATEWAY_PROVIDERS=anthropic OPENCLAW_LIVE_GATEWAY_MODELS=anthropic/claude-sonnet-4-6 OPENCLAW_LIVE_GATEWAY_MAX_MODELS=1 OPENCLAW_LIVE_GATEWAY_STEP_TIMEOUT_MS=90000 OPENCLAW_LIVE_GATEWAY_MODEL_TIMEOUT_MS=180000 OPENCLAW_LIVE_DOCKER_REPO_ROOT="$GITHUB_WORKSPACE" timeout --kill-after=30s 35m bash .release-harness/scripts/test-live-gateway-models-docker.sh
command: OPENCLAW_LIVE_GATEWAY_PROVIDERS=anthropic OPENCLAW_LIVE_GATEWAY_MODELS=anthropic/claude-sonnet-4-6 OPENCLAW_LIVE_GATEWAY_MAX_MODELS=1 OPENCLAW_LIVE_GATEWAY_STEP_TIMEOUT_MS=90000 OPENCLAW_LIVE_GATEWAY_MODEL_TIMEOUT_MS=180000 OPENCLAW_LIVE_DOCKER_REPO_ROOT="$GITHUB_WORKSPACE" timeout --foreground --kill-after=30s 35m bash .release-harness/scripts/test-live-gateway-models-docker.sh
timeout_minutes: 40
profile_env_only: false
profiles: stable full
- suite_id: live-gateway-google-docker
label: Docker live gateway Google
command: OPENCLAW_LIVE_GATEWAY_PROVIDERS=google OPENCLAW_LIVE_GATEWAY_MODELS=google/gemini-3.1-pro-preview,google/gemini-3-flash-preview OPENCLAW_LIVE_GATEWAY_MAX_MODELS=2 OPENCLAW_LIVE_GATEWAY_STEP_TIMEOUT_MS=90000 OPENCLAW_LIVE_GATEWAY_MODEL_TIMEOUT_MS=180000 OPENCLAW_LIVE_DOCKER_REPO_ROOT="$GITHUB_WORKSPACE" timeout --kill-after=30s 35m bash .release-harness/scripts/test-live-gateway-models-docker.sh
command: OPENCLAW_LIVE_GATEWAY_PROVIDERS=google OPENCLAW_LIVE_GATEWAY_MODELS=google/gemini-3.1-pro-preview,google/gemini-3-flash-preview OPENCLAW_LIVE_GATEWAY_MAX_MODELS=2 OPENCLAW_LIVE_GATEWAY_STEP_TIMEOUT_MS=90000 OPENCLAW_LIVE_GATEWAY_MODEL_TIMEOUT_MS=180000 OPENCLAW_LIVE_DOCKER_REPO_ROOT="$GITHUB_WORKSPACE" timeout --foreground --kill-after=30s 35m bash .release-harness/scripts/test-live-gateway-models-docker.sh
timeout_minutes: 40
profile_env_only: false
profiles: stable full
- suite_id: live-gateway-minimax-docker
label: Docker live gateway MiniMax
command: OPENCLAW_LIVE_GATEWAY_PROVIDERS=minimax,minimax-portal OPENCLAW_LIVE_GATEWAY_MAX_MODELS=1 OPENCLAW_LIVE_GATEWAY_STEP_TIMEOUT_MS=90000 OPENCLAW_LIVE_GATEWAY_MODEL_TIMEOUT_MS=180000 OPENCLAW_LIVE_DOCKER_REPO_ROOT="$GITHUB_WORKSPACE" timeout --kill-after=30s 35m bash .release-harness/scripts/test-live-gateway-models-docker.sh
command: OPENCLAW_LIVE_GATEWAY_PROVIDERS=minimax,minimax-portal OPENCLAW_LIVE_GATEWAY_MAX_MODELS=1 OPENCLAW_LIVE_GATEWAY_STEP_TIMEOUT_MS=90000 OPENCLAW_LIVE_GATEWAY_MODEL_TIMEOUT_MS=180000 OPENCLAW_LIVE_DOCKER_REPO_ROOT="$GITHUB_WORKSPACE" timeout --foreground --kill-after=30s 35m bash .release-harness/scripts/test-live-gateway-models-docker.sh
timeout_minutes: 40
profile_env_only: false
profiles: stable full
- suite_id: live-gateway-advisory-docker-deepseek-fireworks
suite_group: live-gateway-advisory-docker
label: Docker live gateway advisory DeepSeek/Fireworks
command: OPENCLAW_LIVE_GATEWAY_PROVIDERS=deepseek,fireworks OPENCLAW_LIVE_GATEWAY_MAX_MODELS=2 OPENCLAW_LIVE_GATEWAY_STEP_TIMEOUT_MS=90000 OPENCLAW_LIVE_GATEWAY_MODEL_TIMEOUT_MS=180000 OPENCLAW_LIVE_DOCKER_REPO_ROOT="$GITHUB_WORKSPACE" timeout --kill-after=30s 35m bash .release-harness/scripts/test-live-gateway-models-docker.sh
command: OPENCLAW_LIVE_GATEWAY_PROVIDERS=deepseek,fireworks OPENCLAW_LIVE_GATEWAY_MAX_MODELS=2 OPENCLAW_LIVE_GATEWAY_STEP_TIMEOUT_MS=90000 OPENCLAW_LIVE_GATEWAY_MODEL_TIMEOUT_MS=180000 OPENCLAW_LIVE_DOCKER_REPO_ROOT="$GITHUB_WORKSPACE" timeout --foreground --kill-after=30s 35m bash .release-harness/scripts/test-live-gateway-models-docker.sh
timeout_minutes: 40
profile_env_only: false
advisory: true
@@ -2322,7 +2322,7 @@ jobs:
- suite_id: live-gateway-advisory-docker-opencode-openrouter
suite_group: live-gateway-advisory-docker
label: Docker live gateway advisory OpenCode/OpenRouter
command: OPENCLAW_LIVE_GATEWAY_PROVIDERS=opencode-go,openrouter OPENCLAW_LIVE_GATEWAY_MAX_MODELS=2 OPENCLAW_LIVE_GATEWAY_STEP_TIMEOUT_MS=90000 OPENCLAW_LIVE_GATEWAY_MODEL_TIMEOUT_MS=180000 OPENCLAW_LIVE_DOCKER_REPO_ROOT="$GITHUB_WORKSPACE" timeout --kill-after=30s 35m bash .release-harness/scripts/test-live-gateway-models-docker.sh
command: OPENCLAW_LIVE_GATEWAY_PROVIDERS=opencode-go,openrouter OPENCLAW_LIVE_GATEWAY_MAX_MODELS=2 OPENCLAW_LIVE_GATEWAY_STEP_TIMEOUT_MS=90000 OPENCLAW_LIVE_GATEWAY_MODEL_TIMEOUT_MS=180000 OPENCLAW_LIVE_DOCKER_REPO_ROOT="$GITHUB_WORKSPACE" timeout --foreground --kill-after=30s 35m bash .release-harness/scripts/test-live-gateway-models-docker.sh
timeout_minutes: 40
profile_env_only: false
advisory: true
@@ -2330,32 +2330,32 @@ jobs:
- suite_id: live-gateway-advisory-docker-xai-zai
suite_group: live-gateway-advisory-docker
label: Docker live gateway advisory xAI/Z.ai
command: OPENCLAW_LIVE_GATEWAY_PROVIDERS=xai,zai OPENCLAW_LIVE_GATEWAY_MAX_MODELS=2 OPENCLAW_LIVE_GATEWAY_STEP_TIMEOUT_MS=90000 OPENCLAW_LIVE_GATEWAY_MODEL_TIMEOUT_MS=180000 OPENCLAW_LIVE_DOCKER_REPO_ROOT="$GITHUB_WORKSPACE" timeout --kill-after=30s 35m bash .release-harness/scripts/test-live-gateway-models-docker.sh
command: OPENCLAW_LIVE_GATEWAY_PROVIDERS=xai,zai OPENCLAW_LIVE_GATEWAY_MAX_MODELS=2 OPENCLAW_LIVE_GATEWAY_STEP_TIMEOUT_MS=90000 OPENCLAW_LIVE_GATEWAY_MODEL_TIMEOUT_MS=180000 OPENCLAW_LIVE_DOCKER_REPO_ROOT="$GITHUB_WORKSPACE" timeout --foreground --kill-after=30s 35m bash .release-harness/scripts/test-live-gateway-models-docker.sh
timeout_minutes: 40
profile_env_only: false
advisory: true
profiles: full
- suite_id: live-cli-backend-docker
label: Docker live CLI backend
command: OPENCLAW_LIVE_DOCKER_REPO_ROOT="$GITHUB_WORKSPACE" timeout --kill-after=30s 45m bash .release-harness/scripts/test-live-cli-backend-docker.sh
command: OPENCLAW_LIVE_DOCKER_REPO_ROOT="$GITHUB_WORKSPACE" timeout --foreground --kill-after=30s 45m bash .release-harness/scripts/test-live-cli-backend-docker.sh
timeout_minutes: 50
profile_env_only: false
profiles: stable full
- suite_id: live-acp-bind-docker
label: Docker live ACP bind
command: OPENCLAW_LIVE_DOCKER_REPO_ROOT="$GITHUB_WORKSPACE" timeout --kill-after=30s 45m bash .release-harness/scripts/test-live-acp-bind-docker.sh
command: OPENCLAW_LIVE_DOCKER_REPO_ROOT="$GITHUB_WORKSPACE" timeout --foreground --kill-after=30s 45m bash .release-harness/scripts/test-live-acp-bind-docker.sh
timeout_minutes: 50
profile_env_only: false
profiles: stable full
- suite_id: live-codex-harness-docker
label: Docker live Codex harness
command: OPENCLAW_LIVE_DOCKER_REPO_ROOT="$GITHUB_WORKSPACE" timeout --kill-after=30s 35m bash .release-harness/scripts/test-live-codex-harness-docker.sh
command: OPENCLAW_LIVE_DOCKER_REPO_ROOT="$GITHUB_WORKSPACE" timeout --foreground --kill-after=30s 35m bash .release-harness/scripts/test-live-codex-harness-docker.sh
timeout_minutes: 40
profile_env_only: false
profiles: stable full
- suite_id: live-subagent-announce-docker
label: Docker live subagent announce
command: OPENCLAW_LIVE_DOCKER_REPO_ROOT="$GITHUB_WORKSPACE" timeout --kill-after=30s 20m bash .release-harness/scripts/test-live-subagent-announce-docker.sh
command: OPENCLAW_LIVE_DOCKER_REPO_ROOT="$GITHUB_WORKSPACE" timeout --foreground --kill-after=30s 20m bash .release-harness/scripts/test-live-subagent-announce-docker.sh
timeout_minutes: 25
profile_env_only: false
profiles: stable full

View File

@@ -44,7 +44,7 @@ jobs:
uses: actions/checkout@v6
with:
ref: ${{ github.sha }}
fetch-depth: 1
fetch-depth: 0
fetch-tags: false
persist-credentials: false
submodules: false

View File

@@ -146,6 +146,7 @@ Skills own workflows; root owns hard policy and routing.
- No `@ts-nocheck`. Lint suppressions only intentional + explained.
- External boundaries: prefer `zod` or existing schema helpers.
- Runtime branching: discriminated unions/closed codes over freeform strings. Avoid semantic sentinels (`?? 0`, empty object/string).
- Cross-function state: when valid combos matter, return a closed mode/result shape. Avoid parallel nullable fields or derived booleans that callers must keep in sync; make impossible states unrepresentable.
- Formatter-friendly shape: when oxfmt explodes an expression vertically, extract named booleans, payloads, or small helpers. Do not change width or use format-ignore for local compactness.
- Calls should be boring: complex decisions happen above; call args/object fields are names, literals, or simple property reads.
- Prefer early returns over nested condition pyramids. Split code into gather -> normalize -> decide -> act.

View File

@@ -2,10 +2,31 @@
Docs: https://docs.openclaw.ai
## Unreleased
## 2026.5.26
### Highlights
- Faster Gateway and replies: startup avoids repeated plugin, channel, session, usage-cost, warning, scheduled-service, and filesystem scans; visible replies separate user-facing sends from slower follow-up work; Gateway runtime/session caches churn less under load.
- Transcripts are core: transcript-backed meeting summaries, source-provider chunks, cleaned user turns, media provenance, Codex mirrors, WebChat replies, and CLI/TUI replay now use one more reliable transcript path.
- More channels are production-ready: Telegram keeps typing/progress context and forum topics, iMessage handles attachment roots, remote media staging, and duplicate local Messages sources, WhatsApp restores group/media behavior, Discord improves voice playback and model picking, and Signal/iMessage/WhatsApp get reaction approvals.
- Better voice and Talk: realtime Talk runs can be inspected, steered, cancelled, or followed up from Web UI and Discord voice; wake-name handling is more tolerant without letting ambient speech trigger agents.
- Safer content boundaries: Browser snapshot reads honor SSRF policy, system-event text cannot spoof nested prompt markers, fetched file text is wrapped as external content, ClickClack inbound sender allowlists run before agent dispatch, stale device tokens are rejected, and serialized tool-call text is scrubbed from replies.
- Providers, Codex, and local models are steadier: named auth profiles, OpenAI sampling params, Codex app-server resume/timeout/usage-limit recovery, dynamic tool-schema guards, xAI usage-limit surfacing, Ollama top-p normalization, and local approval resolution reduce provider-specific dead ends.
- More reliable install/update/release paths: Alpine installs, trusted runtime fallback roots, stable update channels, Docker/package timeouts, Windows/macOS proof lanes, Testbox/Crabbox delegation, plugin publish checks, and macOS runner bootstraps all got hardened.
- Better observability: Activity tab, gateway secret-prep traces, tool/model stream progress, explicit fast-mode status, systemd Gateway hygiene, OpenTelemetry LLM spans, release performance evidence, and richer telemetry signals make failures easier to inspect.
### Changes
- Transcripts: add core transcript capture and source-provider support for transcript-backed meeting summaries, including the renamed Transcripts docs, CLI surface, source-provider chunks, and cleaned user-turn persistence.
- Auth: add named model login profiles and supported credential migration for Hermes, OpenCode, and Codex auth profiles, with explicit opt-out and non-interactive controls. (#85667) Thanks @fuller-stack-dev.
- Diagnostics: trace gateway secret preparation, classify skill/tool usage, surface model stream progress, add OpenTelemetry LLM content spans, and expose alertable telemetry for blocked tools, failover, stale sessions, liveness, oversized payloads, and webhook ingress. (#83019, #80370, #86191)
- Channels: add Signal reaction approvals, iMessage thumb approval reactions, and WhatsApp thumb approval reaction support so mobile approval flows work without textual `/approve` commands. (#85894, #85952, #85477)
- Agents/API: forward OpenAI sampling params through the Gateway and expose estimated context-budget status for active agent runs. (#84094)
- TUI/status: queue prompts submitted while an agent is busy and show explicit fast-mode state plus richer systemd Gateway hygiene in status output. (#86722, #87115, #86976)
- Exec approvals: hide durable approval actions that are unavailable for the current prompt and keep approval runtime tokens local-only so stale prompts cannot offer misleading controls. (#86270, #86359)
- Plugin SDK: add reaction approval helpers and keep diagnostic event root exports discoverable across function-name and alias-bound module graphs. (#86735, #87084)
- Android/iOS: add the Android pair-new-gateway action and improve mobile Talk mode surfaces, including iOS realtime Talk mode and Android offline voice/gateway recovery. (#86798, #86355) Thanks @ngutman.
- Performance: cache plugin metadata snapshots, package realpaths, stable gateway metadata, model cost indexes, channel resolution, usage-cost indexes, and session/auth hot-path facts so common Gateway and reply paths do less rediscovery. (#84649, #85843, #86517, #86678)
- Voice: expose shared realtime turn-context tracking through the realtime voice SDK and reuse it for Discord speaker attribution and wake-name context recovery.
- Voice: reuse shared realtime output activity tracking in Google Meet command and node audio bridges, including recent-output checks for local barge-in detection.
- Voice: expose shared realtime output activity tracking through the realtime voice SDK and reuse it for Discord playback activity and barge-in decisions.
@@ -13,15 +34,43 @@ Docs: https://docs.openclaw.ai
- Voice: share activation-name matching and consult-transcript screening through the realtime voice SDK so Discord, browser voice, and meeting surfaces can reuse one implementation.
- Cron: default `cron.maxConcurrentRuns` to 8 so scheduled automations and their isolated agent turns can make progress in parallel without explicit configuration.
- QA-Lab: add `qa coverage --match <query>` so focused proof selection can discover matching scenarios from existing metadata before running live or remote lanes.
- Discord/model picker: surface an alpha-bucket select (e.g. `AG (12) · HN (18) · OZ (5)`) when the provider list or a provider's model list exceeds 25 items, so configs with `provider/*` wildcards stay one click from the right page instead of paginating through prev/next; falls back to numeric chunks when every item shares the same first letter.
- Control UI: add an ephemeral Activity tab for sanitized live tool activity summaries without persisting raw telemetry. Fixes #12831. Thanks @BunsDev.
- Build: include `ui:build` in the `full` and `ciArtifacts` profiles of `scripts/build-all.mjs` so `pnpm build` always rebuilds `dist/control-ui` after `tsdown` cleans `dist`, removing the second-command requirement and the missing-asset failure mode for source/runtime installs and CI artifact uploads. (#85206)
- Migrate: import supported Hermes, OpenCode, and Codex auth credentials into OpenClaw auth profiles when credential migration is selected, with explicit opt-out and non-interactive controls. (#85667) Thanks @fuller-stack-dev.
- iOS: improve Talk mode with direct realtime voice sessions, compact toolbar status, and responsive voice waveform feedback. (#86355) Thanks @ngutman.
- Media: replace the Sharp image backend with Photon for metadata, resizing, EXIF orientation, and PNG alpha-preserving optimization so OpenClaw no longer installs Sharp or the WhatsApp Jimp fallback for image processing. (#86437)
- Media: replace the Sharp image backend with Rastermill for metadata, resizing, EXIF orientation, and PNG alpha-preserving optimization so OpenClaw no longer installs Sharp or the WhatsApp Jimp fallback for image processing. (#86437)
- Codex: update the bundled Codex CLI to 0.134.0 and keep native compaction disabled for budget-triggered app-server turns so OpenClaw owns the recovery boundary. (#86772)
### Fixes
- Memory/security: reject prompt-like text submitted through the explicit `memory_store` tool before embedding or storage, matching the existing auto-capture prompt-injection filter. (#87142)
- Security/content boundaries: validate Browser snapshot tab URLs against SSRF policy before ChromeMCP or direct CDP reads, sanitize queued system-event text so untrusted plugin/channel labels cannot spoof nested prompt markers, wrap fetched file text and metadata as external content, apply ClickClack `allowFrom` sender allowlists before agent dispatch, reject RPCs from invalidated device-token clients during rotation, require staged sandbox media refs, and scrub serialized tool-call text from replies. (#78526, #87094, #87062, #83741, #70707, #86924) Thanks @zsxsoft, @ttzero25, and @mmaps.
- Transcripts/user turns: persist CLI, WebChat, media, follow-up, hook, and Codex-mirror user turns to the admitted session target; keep cleaned transcript text, inline image routing, provenance metadata, replay hooks, and fallback paths idempotent when runtimes fail or restart.
- TUI/status/onboarding/UI: queue busy TUI prompts instead of dropping them, preserve the configured default model during onboarding, show failed tool results as errors, show config-open failures in Control UI, keep status JSON plugin scans healthy, preserve xAI usage-limit errors locally, and expose explicit fast-mode/systemd state. (#86722, #87000, #85786, #87108, #87001, #86614, #87115, #86976)
- Plugin commands/SDK: preserve plugin LLM command auth, keep `onDiagnosticEvent` exports discoverable through `Function.name`, stabilize diagnostic event root aliases, correlate pathless read diagnostics, suppress transient runner failures in channel command paths, and repair local approval resolution. (#85936, #87084, #86977, #87069, #86771)
- Codex/providers: keep WebChat delivery hints out of user prompts, avoid false queued-terminal idle timeouts, share the native hook relay registry, quarantine unsupported dynamic tool schemas, preserve Claude resumed-session system prompts, normalize greedy Ollama `top_p`, preserve per-agent thinking defaults for ingress runs, and avoid native compaction takeover on budget-triggered Codex turns. (#87096, #73950, #87049, #86689, #86772)
- Gateway/perf/release: reuse startup-warning metadata and prepared auth stores, defer warning and scheduled-service fallback imports, trim Gateway session/startup/runtime CPU churn, skip duplicate turn session touches, stop chat timeout fallback cascades, drop stale subagent announce history, bound benchmark/watch/kitchen-sink teardown waits, bound macOS/package/onboarding/plugin smoke commands, bound install finalization probes, resolve Parallels npm-update commands from guest `PATH`, and bootstrap raw AWS macOS Node/pnpm commands through `/usr/bin/env`. (#86997)
- Reply/perf: reduce visible reply delivery latency by preserving Telegram typing/progress context, lazy-loading slash-command startup metadata, avoiding hot-path model hydration, flag-gating Codex profiler timing, deferring context compaction maintenance, and tracking delivery timing. (#86989, #86990, #86991, #86992, #86993, #86994) Thanks @keshavbotagent.
- Reply/source delivery: keep TUI, Control UI, media, TTS, transcript, and Codex source-reply finals live without duplicate terminal events or stale replay artifacts.
- Agents/replay: repair legacy tool results before replay, preserve `sessions_spawn` transcript payloads, restore current guard checks, stage sandboxed workspace media, and keep duplicate transcripts tool display metadata from reappearing. (#82203, #86934, #87025) Thanks @martingarramon, @vincentkoc, and @joshavant.
- Agents/hooks/subagents: enforce default hook agent allowlists, recover failed subagent lifecycle completions, and keep node task lifecycle cleanup from closing the Gateway listener. (#86101)
- Codex: project newer OpenClaw chat history into resumed app-server threads and keep Codex turn timeouts inside the Codex runtime boundary so timeouts do not poison shared app-server clients or fall through to unrelated provider fallback. (#86677, #86476) Thanks @TurboTheTurtle and @pashpashpash.
- Config/doctor/update: narrow profiled tool-section doctor repair, keep runtime-injected legacy web-search provider config out of user-authored config validation, and keep prerelease tags excluded from stable updater resolution. (#87030, #86818, #86559) Thanks @joshavant, @luoyanglang, and @stevenepalmer.
- CLI/Windows: add a Windows-only stack-size respawn for stack-heavy startup paths, default CLI logs to local timestamps, and validate timeout/banner TTY state more strictly. (#87031, #85387) Thanks @giodl73-repo and @vincentkoc.
- Locking/security: require owner identity proof before stale plugin lock removal, memoize session lock owner arguments, and avoid writing default exec approval stores unless policy state actually changed. (#86814, #86964) Thanks @Alix-007 and @vincentkoc.
- Install/release: bound Docker package build, inventory, pack, and tarball preparation with process-group timeouts; pin shrinkwrap patch drift to the pnpm lock; harden macOS restart and dSYM packaging; and run release Docker/live timeout wrappers in the foreground so child processes cannot wedge gates.
- Telegram/network: treat `ENETDOWN` as a transient pre-connect network failure so Telegram sends, gateway unhandled-rejection handling, and cron network retries follow the same recovery path as sibling network outages. (#86762) Thanks @TurboTheTurtle.
- Telegram: preserve inbound text entities, overlapping DM replies, account topic cache sidecars, outbound reply context, targeted bot-command mentions, durable group retry targets, forum topic names, and native progress callbacks. (#83873, #85361, #85555, #85656, #85709, #86299, #86553) Thanks @SebTardif, @luoyanglang, and @neeravmakwana.
- iMessage: read image attachments from local Messages attachment roots, dedupe duplicate local Messages-source accounts, seed direct DM history, fix image/group media attachment commands, advance catchup cursors after live handling, and keep slash-command acknowledgements in the source conversation. (#82642, #85475, #86569, #86705, #86706, #86770) Thanks @homer-byte, @TurboTheTurtle, @swang430, and @OmarShahine.
- WhatsApp/QQ/Twitch/IRC/Slack: restore WhatsApp ack identity and group-drop warnings, make QQ Bot media respect `OPENCLAW_HOME`, serialize Twitch auth disconnects, store IRC channel routes canonically, and keep Slack downloaded files out of reply media. (#83833, #85309, #85777, #85794, #85906, #86318, #86697) Thanks @sliverp, @neeravmakwana, and @Kailigithub.
- Discord/voice: improve voice playback and wake replies, bucket large model picker menus, merge media captions into one message, route metadata through configured proxies, restore numeric channel sends, suppress self-reply echoes, and tighten wake matching without breaking fuzzy wake phrases. (#80227, #86238, #86487, #86571, #86595, #86601)
- Codex: preserve native web-search metadata, keep oversized native thread reuse, bridge CLI API-key auth into the app server, preserve sandbox bootstrap path style, recover context-window prompt errors, honor yolo approval policy, disable native thread personality, and route compaction through Codex auth. (#85378, #85542, #85891, #85909, #86408)
- Agents/runtime: enforce session lock max-hold reclaim, release embedded-attempt locks on all exits, treat aborted subagent runs as terminal, avoid runtime model hydration on hot paths, disclose scoped session list counts, derive overflow budgets from provider errors, and keep fallback errors scoped to the active model candidate. (#70473, #85764, #86014, #86134, #86427, #86944) Thanks @openperf, @fuller-stack-dev, @zhangguiping-xydt, and @ferminquant.
- Config/update/doctor: retry config recovery after failed backup restore, skip shell env fallback on Windows, exclude prerelease tags from the stable git channel, support deep config edits, warn instead of aborting on unreadable cron stores, prune stale bundled plugin paths, and avoid duplicate restart prompts when the Gateway is already healthy. (#85739, #85787, #86060, #86260, #86384, #86533) Thanks @liaoyl830.
- Install/release: support Alpine CLI installs and runtime floors, prefer trusted startup argv runtime fallback roots, reject stale CLI node runtimes, avoid npm `min-release-age` installer failures, bound npm/package/Docker install phases, restore config parent ownership in Docker, seed Docker lockfile package tarballs before prune, and make release/plugin prerelease checks fail closed instead of hanging or false-greening. (#85491)
- Security: avoid printing Gateway tokens in Docker, validate plugin model-pattern regexes safely, escape transcript metadata field names, harden session allowlist glob matching, audit Claude permission overrides under YOLO, and require explicit allow for ACP auto approvals. (#85849, #85934, #86046, #86557)
- Media/images: replace Sharp with Rastermill, keep EXIF normalization best-effort, normalize HEIC/HEIF before image descriptions, route Codex image API keys through OpenAI, preserve image compression metadata, and auto-scale live tool result caps. (#85776, #86037, #86437, #86857, #86923)
- Memory: prevent semantic vector indexes from silently degrading when embeddings are unavailable, stop doctor OOMs on large session stores, preserve sidecar hooks/artifacts, write fallback dream diaries, use CJK-aware dreaming dedupe, and avoid per-file watcher FD fan-out. (#80613, #82928, #85060, #85704, #85967, #86701) Thanks @brokemac79, @openperf, and @yaaboo-gif.
- Agents/sessions: include visibility metadata on restricted `sessions_list` results so scoped counts are clearly reported without widening access or exposing hidden-session counts. (#86944) Thanks @ferminquant.
- Gateway/DNS: validate wide-area discovery domains before deriving zone paths or writing zone files, so invalid `discovery.wideArea.domain` and `dns setup --domain` values fail with a DNS-name diagnostic instead of falling through to unrelated configuration errors. Thanks @mmaps.
- Agents/BTW: route fallback side-question streams through the embedded stream resolver so Anthropic-compatible MiniMax requests use the same capped transport as normal chat. (#86312) Thanks @neeravmakwana.
@@ -143,95 +192,33 @@ Docs: https://docs.openclaw.ai
- Providers/Ollama: strip inline Kimi cloud reasoning prefixes from streamed and final visible replies while keeping ordinary Kimi answers append-only. (#86286) Thanks @jason-allen-oneal.
- Gateway: require Talk secret authority before setup-code handoff can include Talk secrets. (#85690) Thanks @ngutman.
- Agents: keep fallback error reporting scoped to the active model candidate so stale prior-provider quota/auth text is not reported for later fallback attempts. (#86134) thanks @zhangguiping-xydt.
- Agents: keep fallback error reporting scoped to the active model candidate so stale prior-provider quota/auth text is not reported for later fallback attempts. (#86134) Thanks @zhangguiping-xydt.
- iMessage: dedupe watcher startup when `channels.imessage.accounts` lists both `default` and a named account that point at the same local Messages source, so the gateway no longer spawns two `imsg rpc` processes or doubles inbound replies; the dedupe is scoped to watcher startup, leaving duplicate accounts addressable for outbound sends, status, and capability listings, and `openclaw doctor` flags the redundant account with a rebinding hint. Fixes #65141. (#86705) Thanks @swang430.
## 2026.5.25
### Fixes
- Installer: let the local-prefix CLI installer use Alpine's `apk` Node.js, npm, and Git packages on musl Linux instead of downloading glibc Node tarballs that fail `node:sqlite`.
- Checks: prefilter tracked conflict-marker scans so changed checks avoid reading every repository file on clean runs.
- Plugins: allow linked local plugin paths to probe TypeScript source entries without requiring compiled package output, restoring source-checkout plugin development on native Windows.
- CLI: route source-checkout build output to stderr before launching OpenClaw commands so stale local builds do not corrupt `--json` stdout.
- Installer: install Node.js through `apk` on Alpine Linux instead of falling through to the NodeSource package-manager path.
- Agents/perf: cache manifest-backed CLI provider descriptors and fallback provider resolution so model fallback retries avoid repeated bundled provider runtime scans while still invalidating across plugin reloads.
- Installer: detect musl Linux shells such as Alpine as Linux instead of rejecting them before npm install.
- Windows: run direct Node package scripts with env overrides through a cross-platform launcher so gateway, TUI, Docker-all, generated-module formatting, and optional Discord native opus installer entrypoints work on native Windows.
- Tests: run Vitest import timing entrypoints through a Node wrapper so native Windows package scripts can collect import diagnostics.
- Control UI: split large build-time runtime dependencies into stable chunks so Linux/Docker install and package builds stay below the app chunk warning threshold.
- Tests: run `test:max` and `test:changed:max` through a Node wrapper so high-worker Vitest entrypoints work on native Windows.
- Tests: retry transient loopback HTTP resets in the kitchen-sink RPC walk so native Windows readiness probes do not fail after the gateway is already ready.
- Tests: run `test:serial` through a Node wrapper so targeted serial Vitest commands work on native Windows.
- Tests: normalize Vitest config path assertions so the infra config suite runs on native Windows paths.
- Installer: avoid the incompatible generated `--before` install filter when raw npm `min-release-age` config is present. (#85491) Thanks @TurboTheTurtle.
- Agents/MCP: bound bundled MCP `tools/list` catalog discovery so hung MCP servers do not block session tool materialization. (#85063) Thanks @nxmxbbd.
- Channels/iMessage: recover malformed anchorless group watch payloads by GUID before debounce/routing, and drop unrecoverable payloads instead of replying to the sender DM. Fixes #84470. Refs #84503. Thanks @zhangguiping-xydt and @zqchris.
- Channels/iMessage: advance the startup catchup cursor from live-handled rows after a completed catchup pass, including rows received while catchup is still running, so restarts do not replay them. (#85475) Thanks @TurboTheTurtle.
- Tests: mount the shared Windows command helper into bare Docker E2E harness containers so published upgrade-survivor config walks can start on Linux.
- Tests: keep the plugin binding command escape Docker smoke focused on its intended Vitest cases and skip source-only install lifecycle scripts.
- Tests: let the generic plugin install E2E assertions use a configurable temp root and Windows home-relative install paths.
- Tests: keep kitchen-sink plugin assertion fixtures on a configurable temp root so native Windows runs no longer skip full-surface diagnostic coverage.
- Tests: fail Gateway startup benchmarks when a child startup never produces ready probes or process metrics instead of reporting all `n/a` samples as passing.
- Config/secrets: allow exec SecretRef ids to include `#` selectors so AWS-style `secret#json_key` ids validate consistently. (#80731) Thanks @TurboTheTurtle.
- Tests: keep the Telegram user credential helper on platform temp and path APIs so native Windows credential export and restore commands do not write through POSIX-only paths.
- Installer: include the optional verify phase in the progress counter so `--verify` shows `[4/4] Verifying installation` instead of `[4/3]`.
- Crabbox: let the wrapper find a sibling Crabbox checkout from linked Git worktrees so Codex worktrees can run remote gates without a PATH shim.
- CI: tolerate the standard `--` option separator in shared helper flag parsing so perf and test commands accept package-manager argument forwarding.
- Tests: preserve `--` passthrough arguments in live-media, live-shard, and extension batch harnesses so Vitest filters are not misread or silently ignored.
- Crabbox: default AWS macOS runner requests to on-demand capacity so EC2 Mac proof commands do not fail on the unsupported Spot market default.
- Tests: run upgrade-survivor config recipe commands through the Windows npm shim so native Windows package walks keep baseline config coverage.
- Image tool: use bundled Anthropic media limits when resolving image compression policy without provider-runtime hooks.
- Tests: fail the kitchen-sink RPC Docker walk when gateway RSS sampling is unavailable instead of silently disabling the per-process memory guard.
- Tests: suppress the current Rolldown plugin timing warning format in the Vitest wrapper so tiny focused runs do not drown useful stderr in repeated build-timing noise.
- Models/OpenRouter: use endpoint-specific OpenRouter context limits from `top_provider` metadata so provider-routed models no longer overstate available context. (#85949) Thanks @TurboTheTurtle.
- Crabbox: sync clean sparse-checkout remote changed gates from a temporary full checkout with local-only commits overlaid as worktree changes so git-backed script checks can seed the runner repository.
- Agents: avoid loading bundled channel plugins while resolving completion delivery policy and queue defaults on subagent handoff paths.
- Tests: allow split Vitest config shards through the explicit-target preflight so CI shard jobs run their intended projects.
- Tests: make startup memory and startup bench smoke scripts build CLI startup artifacts when run from a fresh source checkout.
- iMessage: mark authorized slash-command turns as text-sourced commands so `/status`, `/new`, and `/restart` acknowledgements return to the source conversation. (#82642) thanks @homer-byte.
- Crabbox: install Corepack shims into the writable hydration `PNPM_HOME` so local AWS runner hydration no longer tries to overwrite `/usr/local/bin/pnpm`.
- Live tests: fail Gateway live model sweeps when selected coverage is lost to timeouts or stale high-signal filters instead of reporting false missing-profile coverage, and pin Docker OpenAI gateway coverage to the current `gpt-5.5` lane.
- Tests: fail Docker resource-ceiling checks when stats samples or configured limits are invalid instead of silently reporting zero peaks.
- Auth/Codex: emit a one-shot actionable `log.warn` from the embedded legacy Codex OAuth sidecar loader when the only available seed lives in the macOS Keychain, naming `openclaw doctor --fix` and macOS Keychain instead of letting the credential silently fall through to a downstream `No API key found for provider "openai-codex"`. Thanks @romneyda.
- Agents: fail closed when provider-less session models match multiple provider-prefixed runtime policies so CLI runtime routing no longer depends on config order. (#85970) Thanks @potterdigital.
- Control UI/agents: keep collapsed tool rows readable without early ellipses, preserve raw expanded tool details, and make post-compaction AGENTS.md reinjection opt-in to avoid duplicated project context. Fixes #45649 and #45488. Thanks @BunsDev.
## 2026.5.24
## 2026.5.22
### Changes
- iMessage: support thumb-approval reactions — `👍` (Like tapback) resolves an approval as `allow-once` and `👎` resolves as `deny`, with the explicit-approver allowlist read from `channels.imessage.allowFrom`; `allow-always` stays on the manual `/approve <id> allow-always` text fallback. Mirrors the WhatsApp behavior from #85477.
- Gateway/perf: reuse process-stable channel catalog reads, avoid repeated bundled-channel boundary checks, and rotate gateway watch CPU profiles so benchmark runs do not accumulate unbounded artifacts.
- Gateway/perf: cache stable install-record, channel-catalog, bundled-channel, and Telegram session-store metadata during process-local hot paths to reduce repeated JSON and manifest reads.
- Gateway/perf: reuse immutable plugin metadata snapshots across startup, config, model, channel, setup, and secret metadata readers so hot paths avoid repeated plugin file stats and manifest registry reloads.
- Talk/realtime: let WebUI and Discord voice callers ask for active OpenClaw run status, cancel, steer, or queue follow-up work while a consult is still running. (#84231) Thanks @Solvely-Colin.
- Discord/voice: add realtime wake-name gating with agent-name defaults and raise profile bootstrap context budget for longer `USER.md`/`SOUL.md` files.
- Gateway/perf: lazy-load startup-idle plugin work, core gateway method handlers, and the embedded ACPX runtime so Gateway health and ready signals no longer wait on unused handler trees or ACPX probes.
- Gateway/perf: cache plugin SDK public-surface alias maps and skip irrelevant macOS Linuxbrew PATH probes so Gateway startup avoids repeated filesystem walks and slow missing-directory stats.
- Image tool: add adaptive model-aware image compression with an `agents.defaults.imageQuality` preference for choosing token-efficient, balanced, or high-detail media handling.
- Meeting Notes: add a source-only external meeting-notes plugin and SDK source-provider contract outside the core npm package, with auto-start capture config, manual transcript imports, read-only `openclaw meeting-notes` CLI access, and Discord voice as the first live source.
- Meeting Notes/Discord: release channel account startup before meeting-notes auto-capture, wait for the Discord voice manager during gateway boot, and stop plugin services before channel shutdown so voice capture state remains available during startup and cleanup.
- Transcripts: add the initial transcript capture and source-provider foundation, including auto-start capture config, manual transcript imports, read-only transcript access, and Discord voice as the first live source.
- Docs/channels/config: add Signal `configPath`, Telegram wildcard topic defaults, local-time backup archive names, Termux home fallback, include-path validation, secret-scanner-safe placeholder guidance, Gemini CLI/Antigravity media guidance, and macOS VM auto-login guidance. Thanks @NorseGaud, @yudistiraashadi, @huangqian8, @VibhorGautam, @maweibin, @tianxingleo, @IgnacioPro, and @xzcxzcyy-claw.
- Docs: clarify model-usage portability, Codex migration prerequisites, status bootstrap wording, thread-bound subagent limits, hook ownership, and config-preserving safety guidance. Thanks @aniruddhaadak80, @leno23, @TomDjerry, @matthewxmurphy, @vincentkoc, and @stablegenius49.
- Docs: clarify README onboarding and Gateway startup paths, WhatsApp QR/408 recovery, cron output language prompts, skill advanced features, gateway upstream 403 troubleshooting, and plugin fallback override guidance. Thanks @deepujain, @Zacxxx, @Jah-yee, @neyric, @usimic, @Renu-Cybe, @BigUncle, and @SeashoreShi.
- Docs: clarify context-pruning ratio bounds, local dashboard recovery, CLI env markers, remote onboarding token behavior, and Peekaboo Bridge permissions for subprocess agents. Thanks @ayesha-aziz123, @dishraters, @hougangdev, and @brandonlipman.
- Docs: clarify browser CDP diagnostics, Plugin SDK allowlist imports, status-reaction timing defaults, queue steering behavior, limited-tool troubleshooting, cron HEARTBEAT handling, Telegram multi-agent groups, Bitwarden SecretRef setup, and EasyRunner deployments. Thanks @Quratulain-bilal, @mbelinky, @Mickey-, @vancece, @xenouzik, @posigit, @surlymochan, @janaka, and @choiking.
- CLI/models: let `openclaw models auth login` store a single returned provider auth profile under a requested `--profile-id`, and document named Codex OAuth profile setup. (#49315) Thanks @DanielLSM.
- Crabbox/Testbox: run clean sparse-checkout Testbox syncs from a temporary full checkout and route remote changed gates through Corepack pnpm.
- Docs: clarify IPv4-only Gateway BYOH binding, trusted-proxy scope clearing, Android pairing approval, macOS Accessibility grants, Zalo profile env vars, password-store SecretRef setup, and Chinese memory navigation. Thanks @itskai-dev, @gwh7078, @longstoryscott, @MoeJaberr, and @yuaiccc.
- Docs: consolidate GLM under Z.AI, add the Upstash Box install guide and Gateway exposure runbook, clarify MEDIA directives, Copilot and Voyage setup, config path quoting, real behavior proof, and memory-file write guidance. Thanks @BobDu, @alitariksahin, @Jefsky, @musaabhasan, @OmerZeyveli, @leno23, @WuKongAI-CMU, @luoyanglang, and @majin1102.
- Docs: clarify media provider credentials, Codex/OpenClaw code-mode boundaries, Slack and Telegram ack reactions, Feishu dynamic agents, secrets plaintext boundaries, memory guidance, and Chinese glossary terms. Thanks @nielskaspers, @cosmopolitan033, @drclaw-iq, @alexgduarte, @zccyman, @chengoak, and @cassthebandit.
- Packaging: exclude documentation images and assets from the npm tarball, reducing published package size without affecting runtime docs search or CLI behavior. Thanks @SebTardif.
- Media understanding: stop auto-probing Gemini CLI and use Antigravity CLI only as a lower-priority image/video fallback after configured provider APIs.
- Diagnostics: emit sanitized `secrets.prepare` timeline spans for Gateway secret preparation so operators can distinguish secret startup latency without exposing provider names, secret ids, or secret values. (#83019) Thanks @samzong.
- Diagnostics: export bounded skill usage metrics/spans and tool source/owner labels for core, plugin, MCP, and channel tool execution without exposing raw paths or session identifiers. (#80370) Thanks @gauravprasadgp.
- Agents/subagents: limit default sub-agent bootstrap context to `AGENTS.md` and `TOOLS.md`, keeping persona, identity, user, memory, heartbeat, and setup files out of delegated workers by default. (#85283) Thanks @100yenadmin.
- Maintainer skills: require clean autoreview before surfacing bug-sweep PR URLs and treat changelog-only conflicts as routine busy-main churn.
- Maintainer skills: exclude plugin SDK/API boundary work from `openclaw-landable-bug-sweep` so bugbash sweeps stay focused on small paper-cut fixes.
- QA-Lab/diagnostics: extend the OpenTelemetry smoke harness to prove trace, metric, and log export, and add first-class Prometheus and observability smoke aliases.
- Plugin SDK: add a generic channel-message poll sender so channel plugins can expose poll delivery without depending on channel-specific SDK facades.
- Plugin SDK/cron delivery: route cron delivery through the modern target resolver and outbound session-route APIs, deprecate parser-backed target helpers and `plugin-sdk/messaging-targets`, and move bundled callers to `plugin-sdk/channel-targets`.
- Crabbox: keep the local wrapper's provider validation synced with the installed Crabbox binary while preserving supported aliases such as `docker` and `blacksmith`. (#85302) Thanks @hxy91819.
- Maintainer skills: add `openclaw-landable-bug-sweep` for producing five small, reviewed, CI-green OpenClaw bugfix PRs from issue/PR sweeps.
- Control UI/chat: add search and Load More pagination to the chat session picker, keeping initial session loads bounded while making older conversations reachable. (#85237) Thanks @amknight.
@@ -242,7 +229,6 @@ Docs: https://docs.openclaw.ai
- Gateway/plugins: reuse a compatible Gateway startup plugin registry during dispatch so safe plugin dispatches avoid redundant registry loading. (#84324) Thanks @ai-hpc.
- Plugins/SDK: add a general `embeddingProviders` capability contract and registration API so embeddings can become a reusable provider surface outside memory-specific adapters.
- Dependencies: refresh provider, plugin, UI, and tooling packages, update `protobufjs` to 8.4.0 to clear the current npm advisory, and carry the Claude ACP completion patch forward to `@agentclientprotocol/claude-agent-acp` 0.36.1.
- ACPX: bump the bundled ACP backend to `acpx` 0.10.0 for session export/import support.
- Agents/tools: remove the old sender-owner tool gating path so configured tools stay visible for trusted sessions while command and channel-action auth still carry real sender identity.
- QA-Lab: add curated mock JSONL replay fixtures and first-drift reporting for runtime-parity audits. (#80323, refs #80176) Thanks @100yenadmin.
- QA-Lab: add a QA bus tool-trace visibility scenario for sanitized tool-call assertions.
@@ -262,75 +248,21 @@ Docs: https://docs.openclaw.ai
### Fixes
- CLI/update: allow package-manager-managed hardlinked package roots during global update swaps while keeping generic plugin, hook, and dependency-free install moves fail-closed. (#85569) Thanks @ai-hpc.
- Gateway/update: avoid fetching unrelated tags during dev-channel git updates so moved release tags do not block branch-based updates. (#84737) Thanks @rubencu.
- CLI/update: suppress the expected future-config warning while an old update parent hands off to the freshly installed post-core process.
- MiniMax: store OAuth token expiry as an absolute millisecond timestamp so OAuth profiles no longer appear expired on every request. (#83480) Thanks @NianJiuZst.
- Agents/Anthropic: strip missing or blank thinking signatures for signed-thinking providers even when recovery supplies a narrow replay policy without signature preservation. Fixes #84430. (#84448) Thanks @NianJiuZst.
- Agents/channels: send a visible notice when an aborted main session cannot be resumed after restart, including Telegram group targets. (#85805) Thanks @pfrederiksen.
- Discord/voice: serialize overlapping voice joins, retry aborted startup readiness within the configured timeout, upgrade meeting-notes-only sessions to realtime when the normal follow join arrives, detach promoted meeting-notes ownership without leaving voice, and include `OpenClaw` in default realtime wake names.
- Gateway/restart: honor the configured restart drain budget for embedded runs and avoid spending the deferral timeout twice after forced restart timeouts. (#85708) Thanks @Kaspre.
- Gateway/boot: run `BOOT.md` startup checks in an isolated boot session so gateway restarts do not overwrite the agent's main session mapping. (#85479)
- Meeting Notes: include a speaker-labeled transcript section in generated summaries so Discord group voice captures show who said each captured utterance.
- Discord/voice: recover stale realtime playback state when Discord stream-close/player-idle events do not arrive, and keep generated runtime plugin aliases available after postbuild rewrites.
- Discord/voice: keep realtime playback running when meeting notes attaches to an existing voice session or a realtime consult starts, and route realtime user transcripts into meeting notes.
- Config/secrets: preflight active runtime SecretRefs before root and include config writes persist, and roll back unchanged file/env state when post-write refresh fails. Fixes #46531. (#84454) Thanks @samzong.
- CLI/models: preserve SecretRef-backed custom provider `apiKey` markers when `models status` regenerates `models.json`, avoiding resolved plaintext secrets on disk. Fixes #84632. (#84658) Thanks @NianJiuZst.
- WhatsApp/auto-reply: deliver deferred media replies through the foreground reply fence so overlapping no-reply turns no longer hide already visible responses. (#85517) Thanks @cavit99.
- Sessions/security: replace agent-to-agent wildcard allowlist regexes with a precompiled linear matcher so cross-agent access checks avoid backtracking-prone patterns. (#85849) Thanks @SebTardif.
- WebChat: keep the run-complete indicator in progress until deferred history replay renders the assistant reply, so Done no longer appears before response text. (#85374) Thanks @neeravmakwana.
- Agents/tools: give timed-out or cancelled process trees a bounded SIGTERM cleanup window before SIGKILL while preserving tree-aware cancellation. Fixes #66399. (#85865) Thanks @IWhatsskill.
- Agents/subagents: treat aborted subagent stop reasons as killed terminal failures so parent sessions get error announcements instead of silent success. Fixes #72293. (#85860) Thanks @IWhatsskill.
- Agents/providers: clamp proxy-like OpenAI Chat Completions output caps against the final request payload so strict local/API-compatible servers no longer reject prompts that already consume part of the context window. Fixes #83086. (#85889) Thanks @rendrag-git.
- Agents/compaction: skip agent-harness preflight for provider-owned CLI runtime sessions so over-threshold Claude CLI sessions continue through normal compaction instead of failing on a missing harness. Fixes #84857. (#84878) Thanks @zhangguiping-xydt.
- Codex/app-server: keep successful native hook relays available through a short post-turn grace window so late Codex hook subprocesses can finish policy enforcement without clearing a replacement relay. (#83987) Thanks @Kaspre.
- Control UI/config: save form-mode edits from the source config snapshot so runtime-only provider defaults like empty `models.providers.<id>.baseUrl` are not written back and rejected. Fixes #85831. Thanks @garyd9.
- Browser/existing-session: launch Chrome DevTools MCP with usage statistics disabled by default so its telemetry watchdog stays off unless an operator explicitly opts in. (#85886) Thanks @rohitjavvadi.
- Telegram: normalize legacy durable group retry targets before retry sends, polls, and pins so group retries keep using the real chat id. (#85656) Thanks @luoyanglang.
- Agents/PDF: route MiniMax PDF fallback policy through plugin metadata so MiniMax uses text extraction instead of VLM image fallback. (#85590, fixes #85575) Thanks @neeravmakwana.
- CLI/plugins: tighten timeout, numeric option, media payload, permission, profile/TLS, plugin metadata, JSON, and remote URL handling; prevent stuck progress/app-server/IRC/Synology/Twitch waits; and keep imported chat history ordering stable.
- Telegram/config: suppress the missing `accounts.default` warning when `channels.telegram.defaultAccount` names a configured account that also sorts first. Fixes #83948. Thanks @crypto86m.
- Telegram: serialize visible topic replies through core reply-lane admission so heartbeat and queued follow-up turns cannot continue ownerless or misroute responses. (#85709) Thanks @jalehman.
- CLI/node: print node status recovery hints on stdout consistently while keeping status errors on stderr. Fixes #83925. Thanks @davinci282828.
- WebChat: summarize internal message-tool source replies so tool cards no longer duplicate the visible reply body. (#84773) Thanks @jason-allen-oneal.
- Gateway/WebChat: hide duplicate `gateway-injected` assistant rows when Cursor ACP already persisted the same `acp-runtime` reply. Fixes #85741. Thanks @lxf-lxf.
- WebChat: scope the visible attachment button to its own composer file input so clicking Upload reliably opens the file picker. (#83952, fixes #47983) Thanks @jason-allen-oneal.
- Gateway: preserve deferred lifecycle-error cleanup across later non-terminal events so provider timeouts can persist failed session state instead of leaving sessions stuck running. (#85256, fixes #63819) Thanks @samzong.
- Gateway/update: stop treating inherited macOS `XPC_SERVICE_NAME` values as launchd supervision during update respawn, so GUI-spawned gateways use detached respawn instead of exiting for a missing LaunchAgent. Fixes #85224. Thanks @richardmqq.
- Gateway: stop sending duplicate message-phase `sessions.changed` websocket events after displayable `session.message` transcript updates. (#84834)
- Agents/subagents: report tool-only child progress during timeout summaries instead of showing no visible output.
- Telegram/ACP: preserve explicit `:topic:` conversation suffixes when inbound ACP targets do not carry a separate thread id.
- Browser/proxy: bypass the managed proxy for the exact local managed Chrome CDP readiness and DevTools WebSocket endpoints, so `openclaw browser start` works when the operator proxy blocks loopback egress. (#83255) Thanks @lightcap.
- Ollama: bypass the managed proxy for configured local embedding origins while keeping SSRF guardrails on unconfigured targets. Thanks @Kaspre.
- OpenAI/images: route Codex API-key image generation through the native OpenAI Images API instead of the Codex OAuth streaming backend, avoiding 401s from valid API keys.
- Agents/OpenAI completions: omit empty tool payload fields for proxy-like OpenAI-compatible endpoints so strict vLLM-style servers accept tool-free turns. (#85835) Thanks @rendrag-git.
- Sandbox: keep workspace skill mounts read-only for remote container-cwd file operations and reject symlinked skill roots before creating protected overlays. (#85591) Thanks @jason-allen-oneal.
- Scripts/Windows: route remaining QA, release, profile, and live-media `pnpm` launches through the managed runner so native Windows avoids brittle `.cmd` execution and shell-argv warnings.
- Release: align generated config/API baselines and the meeting-notes plugin version so release preflight stays green on native Windows.
- Install/Windows: run Git hook setup through a Node prepare helper so native Windows installs no longer print POSIX shell errors.
- Checks/Windows: chunk and serialize extension oxlint shards on native Windows so changed gates avoid Go-backed linter memory spikes.
- Release/Windows: run installed `openclaw.cmd` verification through explicit `cmd.exe` wrapping so npm prepublish/postpublish checks avoid Node shell-argv warnings.
- Release/Windows: run release-check npm pack/install/root probes through the shared npm runner so native Windows avoids bare `npm` lookup and `.cmd` shell-argv handling.
- Release/Windows: run cross-OS release check `.cmd` shims through explicit `cmd.exe` wrapping so native Windows install and gateway probes avoid Node shell-argv handling.
- Control UI/Windows: run i18n Pi, npm, and pnpm helper commands through explicit Windows runners so native Windows translation sync avoids brittle `.cmd` launches.
- Scripts/Windows: run the Z.AI fallback repro through the shared pnpm runner so native Windows avoids raw `.cmd` launches.
- Codex/Windows: run app-server protocol formatting through the shared pnpm runner so native Windows avoids raw `.cmd` launches.
- Plugins/Windows: run plugin npm package staging through the shared npm runner so native Windows release checks avoid bare `npm` lookup and `.cmd` shell-argv handling.
- Checks/Windows: route full `pnpm check` stage commands through the managed child runner so Windows avoids Node shell-argv deprecation warnings there too.
- Agents/fs: allow workspace-only host write/edit tools to write through in-workspace symlink directory parents while preserving outside-workspace symlink rejection. Fixes #84696. Thanks @garbagenetwork.
- Checks/Windows: run managed child commands through explicit `cmd.exe` wrapping instead of Node shell mode with argv, avoiding Node 24 subprocess deprecation warnings during changed checks.
- Gateway: omit internal stream-error placeholder entries from agent prompt history so failed assistant turns are not replayed as model-authored text. (#85652) Thanks @anyech.
- Sessions: enforce the session write-lock max-hold policy during lock acquisition so long-held locks can be reclaimed before the stale-lock window. (#85764) Thanks @njuboy11.
- Sessions/status: preserve user-facing model, fallback, usage, and cost attribution when internal subagent handoff runs use fallback models. (#85726, fixes #85082) Thanks @brokemac79.
- Install/update: honor `OPENCLAW_HOME` when deriving default dev checkout and installer onboarding paths, while keeping explicit `OPENCLAW_GIT_DIR` and `OPENCLAW_CONFIG_PATH` overrides authoritative. Fixes #54014. Thanks @robertPiro.
- Models: prune retired Groq, GitHub Copilot, OpenAI, xAI, and old Claude catalog entries, with doctor migration to upgrade existing configs to current provider refs.
- Plugins/Gateway: treat non-empty return values from plugin gateway method handlers as successful responses so `openclaw gateway call` no longer times out after completed plugin work. Fixes #59470. Thanks @HTMG23.
- Doctor/update: recognize junction-backed source checkouts as git installs by comparing canonical paths before showing package-manager update guidance. Fixes #82215. Thanks @igormf.
- Channels: honor `/verbose on` for tool/progress summaries across direct chats, groups, channels, and forum topics while preserving quiet default behavior. (#85488) Thanks @kurplunkin.
- Update: keep the detached gateway restart handoff best-effort when the restart script process cannot be spawned. (#83892) Thanks @davinci282828.
- Windows/config: skip POSIX login-shell env fallback on native Windows so startup no longer warns about missing `/bin/sh`. Fixes #84795. Thanks @JIRBOY.
- Telegram: persist the prompt-context message cache through plugin state and record bot-authored replies after sends and draft streaming so later turns can include prior assistant replies without relying on the JSON sidecar. (#85231) Thanks @keshavbotagent.
- Agents/subagents: keep Codex persona and user workspace files turn-scoped so native Codex subagents inherit only shared tool guidance by default. (#85811) Thanks @lastguru-net.
- CLI/skills: show an all-ready note with next-step commands when skill setup has no missing dependencies to install. (#85032) Thanks @aniruddhaadak80.
- Microsoft Foundry: route DeepSeek V4 Pro and Flash models through the Foundry Responses API while keeping older DeepSeek models on their existing path. (#85549) Thanks @roslinmahmud.
- Status/usage: show configured cost estimates for AWS SDK models in full usage output while keeping token-only usage replies cost-free. (#85619) Thanks @ItsOtherMauridian.
@@ -339,7 +271,6 @@ Docs: https://docs.openclaw.ai
- Telegram: send local `path`/`filePath` and structured attachment media from `sendMessage` actions instead of dropping them or sending text-only messages. (#85219) Thanks @keshavbotagent.
- Sessions/status: show the estimated context budget when fresh provider usage is unavailable and clear stale estimates across session resets and compaction boundaries. (#84830) Thanks @giodl73-repo.
- Gateway/config: pin relative `OPENCLAW_STATE_DIR` overrides to an absolute path at startup so later working-directory changes cannot retarget gateway state. (#52264) Thanks @PerfectPan.
- Checks/Parallels: make changed-lane scripts, shrinkwrap generation, and Parallels package smoke host commands run through native Windows-safe paths and `npm`/`pnpm` shims.
- Release/package: run npm release, prepublish, and postpublish verification through Windows-safe npm command shims so native Windows checks can execute `npm.cmd` instead of treating it as a binary.
- Agents/harness: pass CLI runtime aliases through harness selection so provider-owned CLI aliases no longer get rejected before reaching the right runtime. (#85631) Thanks @potterdigital.
- Secrets: show the irreversible apply warning after interactive `secrets configure` confirmation so confirmed migrations still get the final safety prompt. (#85638) Thanks @alkor2000.
@@ -351,13 +282,10 @@ Docs: https://docs.openclaw.ai
- Providers/Anthropic: migrate 1M context handling to GA-capable Claude 4.x models by sizing eligible models at 1M without the retired `context-1m-2025-08-07` beta, ignoring that retired beta in older configs, and preserving OAuth-required Anthropic beta headers. (#45613) Thanks @haoyu-haoyu.
- Cron/Telegram: parse forum-topic delivery targets through the Telegram plugin instead of cron core, including `:topic:` and `:topicId` forms for announce delivery. Thanks @etticat.
- Twitch: keep stale message-handler cleanup callbacks from removing newer handler registrations for the same account, preserving inbound message delivery after reconnects. Fixes #83888. (#85425) Thanks @alkor2000.
- Control UI/chat: keep light-mode model, thinking, config, and agents select arrows visible without tiling background icons. Fixes #85713. Thanks @Linux2010.
- Memory/LanceDB: expose public memory artifacts through the active memory provider bridge so memory-wiki imports durable memory files, daily notes, dream reports, and event logs without depending on memory-core internals. Fixes #83604. (#85060) Thanks @brokemac79.
- Crabbox: keep AWS hydration compatible with local Actions replay by inlining the hydrate workflow's Node/pnpm setup instead of invoking repo-local composite actions.
- Agents/subagents: simplify native sub-agent completion handoff so children report their latest visible assistant result to the requester without using `message`, while keeping parent-owned message-tool delivery policy intact. Fixes #85070. (#85089) Thanks @brokemac79.
- Docker setup: stop printing the Gateway bearer token in setup logs and printed follow-up commands.
- Gateway: defer channel account startup work until HTTP readiness and remove startup model prewarm, avoiding startup event-loop stalls and timer-delay warnings.
- Models/perf: reuse plugin metadata during models.json planning, keep bundled catalog augmentation manifest/static, and use static provider catalogs for metadata-only startup discovery so provider model normalization, auth discovery, and Gateway startup metadata do not reload broad plugin runtimes.
- Agents: let embedded compaction fallback retries proceed when PI-compatible candidates do not need agent harness plugin preparation.
- Agents/tools: honor configured custom provider API keys when deciding whether media, image-generation, video-generation, music-generation, and PDF tools are available. (#85570)
- StepFun: stop advertising stale generic API key auth choices so onboarding only offers runtime-backed Standard and Step Plan choices.
@@ -365,22 +293,16 @@ Docs: https://docs.openclaw.ai
- Windows installer: fail Git checkout installs when `pnpm install` or `pnpm build` fails instead of writing a wrapper to a missing CLI build.
- Sessions: surface previous-transcript archive failures during `/new` rotation so disk rename errors are logged instead of silently hiding stranded transcript files. Fixes #81984. (#85586, from #82081) Thanks @0xghost42.
- TUI/agents: mirror internal-ui message-tool replies into final chat output so message-tool-only agents remain visible in `openclaw tui`. Fixes #85538. Thanks @danpolasek.
- Gateway/TUI: preserve source-reply metadata through reply normalization and emit message-tool-only agent replies over the live chat stream so `openclaw tui` renders Codex replies without waiting for a history refresh. Thanks @shakkernerd.
- Codex/TUI: keep long source-reply runs alive after Codex reasoning completes so delayed visible `message` calls can still reach `openclaw tui`. Thanks @shakkernerd.
- TUI: keep quiet active runs busy after the response watchdog notice instead of reopening the prompt and encouraging duplicate submissions while the backend turn is still running. Thanks @shakkernerd.
- Agents: preserve the latest assistant thinking blocks while stripping invalid replay signatures from older turns, and retry Anthropic thinking failures without thinking replay. Fixes #85557. Thanks @bryanbaer.
- Agents: keep parallel OpenAI-compatible tool-call deltas in separate argument buffers so interleaved tool calls no longer corrupt streamed arguments. (#82263) Thanks @luna-system.
- Telegram: avoid false pairing prompts after transient pairing-store read failures while preserving configured `allowFrom` and per-DM pairing authorization. (#85555)
- Memory/doctor: report missing or unusable QMD workspace directories as workspace failures instead of generic binary failures. (#63167) Thanks @sercada.
- Debug proxy: record CONNECT client-socket errors and destroy the paired upstream socket so abrupt client disconnects no longer leak tunnel resources. (#82444) Thanks @SebTardif.
- Diffs: continue hydrating later diff cards when one card fails so a single broken card no longer blanks the whole diff viewer. (#84775) Thanks @cosmopolitan033.
- Mac app: use the native settings sidebar window chrome so the sidebar toggle stays on the left and content no longer clips under oversized titlebar padding.
- QA-Lab/Codex: bundle auth/plugin fixture imports for flow scenarios and let terminal async media tools end Codex app-server turns without timing out. (#80397, refs #80323) Thanks @100yenadmin.
- WhatsApp: persist inbound message delivery state through plugin state before dispatch and delay read receipts until handler completion, so retryable failures can redeliver without adding a plugin-local disk cache. Thanks @samzong.
- Gateway/agents: preserve fresh session overrides and metadata when stale cached agent-session entries race with store updates, so subagent model/provider overrides and routing policy survive concurrent writes. (#19328) Thanks @CodeReclaimers.
- Control UI/chat: keep chat session search inline with the session selector so the header no longer shows a duplicate standalone search row.
- Control UI/chat: collapse focused-mode header chrome and suppress hidden-header scroll updates so focus mode no longer jumps while scrolling. Thanks @amknight.
- Codex app-server: leave automatic compaction to native Codex, drop OpenClaw preflight/CLI/context-engine forced compaction for Codex runtime sessions, and still forward explicit `/compact` or plugin compaction requests into Codex while failing native compaction honestly. (#85500)
- Codex app-server: restart the native app-server and retry once when server-side compaction times out, so preflight compaction stalls recover instead of failing every dispatch. (#85500)
- Restore Control UI gateway token pairing [AI]. (#85459) Thanks @pgondhi987.
- OpenAI video: honor configured provider request private-network opt-in for local/custom video endpoints so explicitly trusted mock and self-hosted providers are not blocked. Thanks @shakkernerd.
- OpenAI video: send uploaded video edit requests to the documented `/videos/edits` endpoint with a `video` file instead of posting MP4 references to `/videos`. Thanks @shakkernerd.
@@ -389,11 +311,9 @@ Docs: https://docs.openclaw.ai
- CLI/agents: default new omitted-account bindings to all accounts when the channel has multiple configured accounts, and clarify account-scope docs. (#49769) Thanks @Gcaufy.
- Codex app-server: let authorized `/codex` control commands such as `/codex detach` escape plugin-owned conversation bindings while keeping unknown or unauthorized slash text routed to the bound plugin. Fixes #85157. (#85188) Thanks @TurboTheTurtle.
- Auto-reply/models: keep `/models` browse replies fast by sharing the bounded read-only catalog path with Gateway model listing. (#84735) Thanks @safrano9999.
- Browser/Doctor: read macOS Chrome app bundle versions from `Info.plist` before spawning Chrome and extend the fallback version probe timeout, avoiding false cold-cache warnings from Gatekeeper latency. Fixes #85418. Thanks @davidcittadini.
- Codex app-server: disable native Code Mode when the effective exec host is `node` and keep OpenClaw `exec`/`process` available, so `/exec host=node` routes shell commands through the selected node instead of the gateway. Fixes #85012. (#85090) Thanks @sahilsatralkar.
- Agents: bound embedded auto-compaction session write-lock watchdogs to the compaction timeout instead of the full run timeout, so stuck compaction cannot hold the live session lock for the whole run window. (#84949) Thanks @luoyanglang.
- Gateway/agents: return phase-aware `agent.wait` timeout attribution and only cool auth profiles on provider-started timeouts. Refs #65504. Thanks @100yenadmin.
- Gateway/systemd: launch managed update handoff helpers in a transient user scope so systemd-supervised Update Now flows survive the gateway unit restart. Fixes #84068.
- Gateway: defer provider auth-state prewarm until after startup readiness so early gateway tool/session requests are not blocked by provider auth discovery. (#85272) Thanks @dutifulbob.
- Gateway/models: coalesce provider auth-state rewarms after auth-profile failures and log event-loop delay for warm/rewarm work, so provider auth bursts no longer stack full auth sweeps behind channel replies.
- Gateway/models: stop cancelled provider auth-state prewarms from continuing full provider sweeps, so reload and auth-failure bursts no longer keep startup busy.
@@ -408,7 +328,6 @@ Docs: https://docs.openclaw.ai
- Control UI/logs: strip ANSI escape sequences from displayed Gateway log messages so color codes no longer appear as raw text. Fixes #64399. Thanks @guguangxin-eng.
- Docker: pre-create the workspace and auth-profile config mount points with `node` ownership so first-run named volumes do not start root-owned. Fixes #85076. Thanks @Noerr.
- Telegram: pass configured markdown table mode through outbound markdown chunking so chunked sends render tables consistently. Fixes #85085. Thanks @ShuaiHui.
- Diagnostics/OTel: drop snake_case diagnostic id attributes alongside camelCase ids so exported telemetry cannot leak run, session, message, chat, trace, or tool-call identifiers. (#72645) Thanks @Lion0710.
- CLI/update: preserve managed Gateway service environment during package cutovers so macOS LaunchAgent repair/restart reads the pre-update service state instead of caller shell state. (#83026)
- Agents/providers: honor per-model `api` and `baseUrl` overrides in custom provider auth hooks and transport selection. Fixes #80487. (#80488) Thanks @huveewomg.
- Gateway/restart: eager-load the lifecycle runtime before in-place upgrade signal handling so package replacement does not deadlock restart imports. (#84890) Thanks @myps6415.
@@ -419,11 +338,9 @@ Docs: https://docs.openclaw.ai
- Gateway chat: broadcast returned agent-run error payloads after an agent starts so ACP/WebChat clients receive terminal idle-timeout errors. Fixes #84945.
- Gateway chat display: preserve OpenAI-compatible `prompt_tokens`, `completion_tokens`, and `total_tokens` usage fields in sanitized chat history so llama.cpp sessions keep context counts. Fixes #77992. Thanks @MarTT79.
- Dashboard/CLI: allow macOS browser launching through `open` even when SSH environment variables are present, while preserving Linux SSH no-display protection. Fixes #67088. Thanks @theglove44.
- Codex app-server: keep native web search observations out of mirrored chat transcripts while preserving available action query metadata in tool progress telemetry. Fixes #85109. Thanks @ugitmebaby.
- Codex app-server: keep native web search observations out of mirrored chat transcripts while preserving tool progress telemetry. Fixes #85109. Thanks @ugitmebaby.
- OpenCode Go: strip unsupported Kimi reasoning replay fields before provider requests so repeated `kimi-k2.6` turns do not fail schema validation. Fixes #83812. Thanks @Sleeck.
- Browser/CDP: add a WSL2 portproxy self-loop hint when Chrome DevTools endpoints accept connections but return an empty HTTP reply. Fixes #59209. Thanks @Owlock.
- Agents/tools: add bounded tool-policy audit log entries that identify which allow/deny rule removed tools or blocked a sandboxed tool call. Fixes #55801. Thanks @justinjkline.
- CLI/logs: read implicit local Gateway logs through the passive backend client path so `openclaw logs --follow` does not register as a paired device, and use the active Linux systemd journal instead of stale configured-file fallbacks when live local RPC is unavailable. Fixes #83656 and #66841.
- Agents/OpenAI: preserve structured provider error code, type, and redacted body metadata on boundary-aware transport failures.
- Doctor/Codex: point native Codex asset warnings at the canonical `openclaw migrate plan codex` preview command. Fixes #84948. Thanks @markoa.
- CLI/models: make `capability model auth logout --agent` remove auth profiles from the selected non-default agent store. Fixes #85092. Thanks @islandpreneur007.
@@ -465,7 +382,7 @@ Docs: https://docs.openclaw.ai
- fix: constrain Windows task script names [AI]. (#85064) Thanks @pgondhi987.
- Control UI: keep the chat session picker from hiding older or cross-agent configured conversations while preserving the bounded configured-agent refresh. (#85211) Thanks @amknight.
- Agents/Anthropic: preserve unsafe integer tool-call input values in streamed Anthropic tool-use JSON, preventing Discord-style IDs from being rounded before dispatch. Fixes #47229. (#83063) Thanks @leno23.
- Agents: estimate tool-heavy prompt pressure at the LLM boundary before provider submission for non-Codex embedded runtimes, so persistent PI-style sessions compact before overflowing context windows. (#85541) Thanks @fuller-stack-dev and @joshavant.
- Agents/Codex: estimate tool-heavy prompt pressure at the LLM boundary before provider submission, so persistent sessions compact before overflowing context windows. (#85541) Thanks @fuller-stack-dev and @joshavant.
- Agents/hooks: wait for local one-shot CLI and Codex `agent_end` plugin hooks before process cleanup so terminal observability flushes reliably. (#85007)
- Providers/Google: preserve Gemini 3 cron `thinkingDefault: "low"` when stale catalog metadata says `reasoning:false`, so scheduled runs keep provider-supported thinking instead of downgrading to off. (#85185) Thanks @neeravmakwana.
- CLI/agents: allow `openclaw agent --session-key` to target explicit session keys, including agent-scoped legacy keys. (#85121) Thanks @Kaspre.
@@ -486,7 +403,6 @@ Docs: https://docs.openclaw.ai
- CLI/update: pre-pack GitHub/git package update targets before the staged npm install, restoring `openclaw update --tag main` for one-off package updates. (#81296) Thanks @fuller-stack-dev.
- Gateway: mirror successful same-source message-tool sends into session transcripts so delivered replies stay in later history/context. (#84837) Thanks @iFiras-Max1.
- Media generation: keep image, music, and video completion delivery from duplicating or losing task ownership when generated media finishes through active session replies. (#84006) Thanks @fuller-stack-dev.
- CLI/doctor: remove stale bundled plugin load paths from old versioned OpenClaw package roots after pnpm/npm upgrades. Fixes #58626. Thanks @solink7.
- Infra/json: retry transient `File changed during read` races while loading JSON state so config and state reads recover instead of failing the turn. (#84285)
- Plugins/providers: fail closed for workspace provider plugins during setup-mode discovery unless explicitly trusted, preventing untrusted workspace plugin code from running during provider setup. (#81069) Thanks @mmaps.
- Providers/Ollama: resolve configured Ollama Cloud `OLLAMA_API_KEY` markers to the real discovery key so cloud provider entries keep authenticated model catalog access. (#85037)
@@ -513,7 +429,6 @@ Docs: https://docs.openclaw.ai
- Update/doctor: prune stale local bundled plugin install records that point at old compiled bundled output so current bundled plugin schemas win after upgrade. (#84863) Thanks @fuller-stack-dev.
- Providers/Ollama: preserve native Ollama tool-call IDs across assistant replay so Gemini over Ollama Cloud can keep its hidden function-call thought-signature handle.
- Discord: keep session recovery and `/stop` abort ownership on the source dispatch lane while bound ACP turns continue routing to their target session, so stalled pre-run work and late replies are cleared instead of leaking after stop. Fixes #84477. (#85100) Thanks @joshavant.
- Discord/voice-call: keep forced realtime voice consult diagnostics in debug logs instead of agent prompts, so callers do not hear OpenClaw policy text when the provider misses `openclaw_agent_consult`. (#84411) Thanks @fuller-stack-dev.
- Codex app-server: mark missing turn completion after observed execution as replay-unsafe and release the session so follow-up turns can run. Fixes #84076. (#85107) Thanks @joshavant.
- Codex app-server: give visible `message` dynamic tool sends a longer timeout budget so slow channel delivery can return its own result or error instead of hitting the 30-second Codex wrapper. (#85216) Thanks @amknight.
- Codex app-server: add a dedicated post-tool raw assistant completion idle timeout config so trusted heavy turns can wait longer after tool handoff without weakening final assistant release.
@@ -522,7 +437,6 @@ Docs: https://docs.openclaw.ai
- PDF tool: time out idle remote PDF body reads after 120 seconds so stalled remote documents return an error instead of wedging the session. Fixes #68649. (#84768) Thanks @luoyanglang.
- Diagnostics/OpenTelemetry plugin: suppress handled OTLP exporter promise rejections so collector shutdowns no longer crash the Gateway. (#81085) Thanks @luoyanglang.
- Agents/exec: omit raw command text and env values from denied exec failure logs while keeping safe correlation metadata. Fixes #85049. (#85140) Thanks @joshavant.
- Media-understanding: restore the 4096-token default for image descriptions so reasoning-capable vision models no longer truncate before returning text, while preserving smaller model caps. (#84932) Thanks @scotthuang.
- Media/audio: skip empty structured sherpa-onnx transcripts instead of treating the raw JSON payload as spoken text. (#84667) Thanks @TurboTheTurtle.
- Agents/exec: preserve inherited XDG base-directory environment values for subprocesses while still rejecting agent-supplied XDG overrides. Fixes #84854. (#85139) Thanks @joshavant.
- Node/Linux: keep `OPENCLAW_GATEWAY_TOKEN` out of generated systemd unit files by writing node service token values to a node-specific env file. (#84408)

View File

@@ -24,7 +24,7 @@ private final class StreamFailureBox: @unchecked Sendable {
}
}
enum TalkGatewayPermissionState: Equatable, Sendable {
enum TalkGatewayPermissionState: Equatable {
case unknown
case ready
case missingScope(String)

View File

@@ -9,7 +9,7 @@ struct TalkRealtimeClientCreateParams: Encodable {
var voice: String?
}
struct TalkRealtimeClientSession: Decodable, Sendable {
struct TalkRealtimeClientSession: Decodable {
let provider: String
let transport: String
let clientSecret: String
@@ -24,12 +24,12 @@ struct TalkRealtimeClientSession: Decodable, Sendable {
}
}
struct TalkRealtimeToolCallResponse: Decodable, Sendable {
struct TalkRealtimeToolCallResponse: Decodable {
let runId: String?
let idempotencyKey: String?
}
struct TalkRealtimeServerEvent: Decodable, Sendable {
struct TalkRealtimeServerEvent: Decodable {
let type: String
let itemId: String?
let item: TalkRealtimeServerItem?
@@ -69,7 +69,7 @@ struct TalkRealtimeServerEvent: Decodable, Sendable {
}
}
struct TalkRealtimeServerItem: Decodable, Sendable {
struct TalkRealtimeServerItem: Decodable {
let id: String?
let type: String?
let callId: String?

View File

@@ -20,9 +20,9 @@
<string>$(OPENCLAW_MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(OPENCLAW_BUILD_VERSION)</string>
<key>WKApplication</key>
<true/>
<key>WKCompanionAppBundleIdentifier</key>
<string>$(OPENCLAW_APP_BUNDLE_ID)</string>
<key>WKWatchKitApp</key>
<true/>
</dict>
</plist>

View File

@@ -84,7 +84,7 @@ enum ExecAsk: String, CaseIterable, Codable, Identifiable {
}
}
enum ExecApprovalDecision: String, Codable {
enum ExecApprovalDecision: String, Codable, Equatable {
case allowOnce = "allow-once"
case allowAlways = "allow-always"
case deny

View File

@@ -70,7 +70,9 @@ final class ExecApprovalsGatewayPrompter {
timeoutMs: 10000)
return
}
let decision = ExecApprovalsPromptPresenter.prompt(request.request)
guard let decision = ExecApprovalsPromptPresenter.prompt(request.request) else {
return
}
try await GatewayConnection.shared.requestVoid(
method: .execApprovalResolve,
params: [

View File

@@ -14,6 +14,79 @@ struct ExecApprovalPromptRequest: Codable {
var agentId: String?
var resolvedPath: String?
var sessionKey: String?
var allowedDecisions: [ExecApprovalDecision]?
init(
command: String,
cwd: String? = nil,
host: String? = nil,
security: String? = nil,
ask: String? = nil,
agentId: String? = nil,
resolvedPath: String? = nil,
sessionKey: String? = nil,
allowedDecisions: [ExecApprovalDecision]? = nil)
{
self.command = command
self.cwd = cwd
self.host = host
self.security = security
self.ask = ask
self.agentId = agentId
self.resolvedPath = resolvedPath
self.sessionKey = sessionKey
self.allowedDecisions = allowedDecisions
}
private enum CodingKeys: String, CodingKey {
case command
case cwd
case host
case security
case ask
case agentId
case resolvedPath
case sessionKey
case allowedDecisions
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.command = try container.decode(String.self, forKey: .command)
self.cwd = try container.decodeIfPresent(String.self, forKey: .cwd)
self.host = try container.decodeIfPresent(String.self, forKey: .host)
self.security = try container.decodeIfPresent(String.self, forKey: .security)
self.ask = try container.decodeIfPresent(String.self, forKey: .ask)
self.agentId = try container.decodeIfPresent(String.self, forKey: .agentId)
self.resolvedPath = try container.decodeIfPresent(String.self, forKey: .resolvedPath)
self.sessionKey = try container.decodeIfPresent(String.self, forKey: .sessionKey)
let decodedDecisions = (try? container.decodeIfPresent(
[DecodedExecApprovalDecision].self,
forKey: .allowedDecisions)) ?? []
self.allowedDecisions = decodedDecisions.compactMap(\.decision)
}
static func allowedDecisions(forAsk ask: String?) -> [ExecApprovalDecision] {
// Older payloads did not carry ask/allowedDecisions. Preserve their durable
// approval option; explicit ask=always and allowedDecisions payloads are the
// policy-carrying shapes that remove it.
ask == ExecAsk.always.rawValue
? [.allowOnce, .deny]
: [.allowOnce, .allowAlways, .deny]
}
}
private struct DecodedExecApprovalDecision: Decodable {
var decision: ExecApprovalDecision?
init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
guard let raw = try? container.decode(String.self) else {
self.decision = nil
return
}
self.decision = ExecApprovalDecision(rawValue: raw)
}
}
private struct ExecApprovalSocketRequest: Codable {
@@ -227,7 +300,7 @@ final class ExecApprovalsPromptServer {
enum ExecApprovalsPromptPresenter {
@MainActor
static func prompt(_ request: ExecApprovalPromptRequest) -> ExecApprovalDecision {
static func prompt(_ request: ExecApprovalPromptRequest) -> ExecApprovalDecision? {
NSApp.activate(ignoringOtherApps: true)
let alert = NSAlert()
alert.alertStyle = .warning
@@ -235,20 +308,47 @@ enum ExecApprovalsPromptPresenter {
alert.informativeText = "Review the command details before allowing."
alert.accessoryView = self.buildAccessoryView(request)
alert.addButton(withTitle: "Allow Once")
alert.addButton(withTitle: "Always Allow")
alert.addButton(withTitle: "Don't Allow")
if #available(macOS 11.0, *), alert.buttons.indices.contains(2) {
alert.buttons[2].hasDestructiveAction = true
let decisions = self.allowedPromptDecisions(request)
for decision in decisions {
alert.addButton(withTitle: self.buttonTitle(for: decision))
}
if #available(macOS 11.0, *),
let denyIndex = decisions.firstIndex(of: .deny),
alert.buttons.indices.contains(denyIndex)
{
alert.buttons[denyIndex].hasDestructiveAction = true
}
switch alert.runModal() {
case .alertFirstButtonReturn:
return .allowOnce
case .alertSecondButtonReturn:
return .allowAlways
default:
return .deny
return self.decision(forModalResponse: alert.runModal(), decisions: decisions)
}
static func decision(
forModalResponse response: NSApplication.ModalResponse,
decisions: [ExecApprovalDecision]) -> ExecApprovalDecision?
{
let selectedIndex = response.rawValue
- NSApplication.ModalResponse.alertFirstButtonReturn.rawValue
if decisions.indices.contains(selectedIndex) {
return decisions[selectedIndex]
}
return decisions.contains(.deny) ? .deny : nil
}
static func allowedPromptDecisions(_ request: ExecApprovalPromptRequest) -> [ExecApprovalDecision] {
if let allowedDecisions = request.allowedDecisions, !allowedDecisions.isEmpty {
return allowedDecisions
}
return ExecApprovalPromptRequest.allowedDecisions(forAsk: request.ask)
}
private static func buttonTitle(for decision: ExecApprovalDecision) -> String {
switch decision {
case .allowOnce:
"Allow Once"
case .allowAlways:
"Always Allow"
case .deny:
"Don't Allow"
}
}
@@ -392,7 +492,7 @@ private enum ExecHostExecutor {
case .allow:
break
case .requiresPrompt:
let decision = ExecApprovalsPromptPresenter.prompt(
guard let decision = ExecApprovalsPromptPresenter.prompt(
ExecApprovalPromptRequest(
command: context.displayCommand,
cwd: request.cwd,
@@ -401,7 +501,15 @@ private enum ExecHostExecutor {
ask: context.ask.rawValue,
agentId: context.agentId,
resolvedPath: context.resolution?.resolvedPath,
sessionKey: request.sessionKey))
sessionKey: request.sessionKey,
allowedDecisions: ExecApprovalPromptRequest.allowedDecisions(
forAsk: context.ask.rawValue)))
else {
return self.errorResponse(
code: "UNAVAILABLE",
message: "SYSTEM_RUN_DENIED: approval prompt closed without decision",
reason: "approval-cancelled")
}
let followupDecision: ExecApprovalDecision
switch decision {
@@ -657,7 +765,7 @@ private final class ExecApprovalsSocketServer: @unchecked Sendable {
private let logger = Logger(subsystem: "ai.openclaw", category: "exec-approvals.socket")
private let socketPath: String
private let token: String
private let onPrompt: @Sendable (ExecApprovalPromptRequest) async -> ExecApprovalDecision
private let onPrompt: @Sendable (ExecApprovalPromptRequest) async -> ExecApprovalDecision?
private let onExec: @Sendable (ExecHostRequest) async -> ExecHostResponse
private var socketFD: Int32 = -1
private var acceptTask: Task<Void, Never>?
@@ -666,7 +774,7 @@ private final class ExecApprovalsSocketServer: @unchecked Sendable {
init(
socketPath: String,
token: String,
onPrompt: @escaping @Sendable (ExecApprovalPromptRequest) async -> ExecApprovalDecision,
onPrompt: @escaping @Sendable (ExecApprovalPromptRequest) async -> ExecApprovalDecision?,
onExec: @escaping @Sendable (ExecHostRequest) async -> ExecHostResponse)
{
self.socketPath = socketPath
@@ -808,7 +916,7 @@ private final class ExecApprovalsSocketServer: @unchecked Sendable {
try self.sendApprovalResponse(handle: handle, id: request.id, decision: .deny)
return
}
let decision = await self.onPrompt(request.request)
guard let decision = await self.onPrompt(request.request) else { return }
try self.sendApprovalResponse(handle: handle, id: request.id, decision: decision)
return
}

View File

@@ -754,7 +754,7 @@ actor MacNodeRuntime {
}
if requiresAsk, !approvedByAsk {
let decision = await MainActor.run {
let promptDecision = await MainActor.run {
ExecApprovalsPromptPresenter.prompt(
ExecApprovalPromptRequest(
command: context.displayCommand,
@@ -764,7 +764,26 @@ actor MacNodeRuntime {
ask: context.ask.rawValue,
agentId: context.agentId,
resolvedPath: context.resolution?.resolvedPath,
sessionKey: context.sessionKey))
sessionKey: context.sessionKey,
allowedDecisions: ExecApprovalPromptRequest.allowedDecisions(
forAsk: context.ask.rawValue)))
}
guard let decision = promptDecision else {
await self.emitExecEvent(
"exec.denied",
payload: ExecEventPayload(
sessionKey: context.sessionKey,
runId: context.runId,
host: "node",
command: context.displayCommand,
reason: "approval-cancelled"))
return ExecApprovalOutcome(
approvedByAsk: approvedByAsk,
persistAllowlist: persistAllowlist,
response: Self.errorResponse(
req,
code: .unavailable,
message: "SYSTEM_RUN_DENIED: approval prompt closed without decision"))
}
switch decision {
case .deny:

View File

@@ -5,6 +5,123 @@ import Testing
@Suite(.serialized)
@MainActor
struct ExecApprovalPromptLayoutTests {
@Test func `allowed decisions omit durable approval even when ask allows it`() {
let decisions = ExecApprovalsPromptPresenter.allowedPromptDecisions(
ExecApprovalPromptRequest(
command: "/bin/sh -lc pwd",
cwd: "/Users/example/projects/openclaw",
host: "node",
security: "full",
ask: "on-miss",
agentId: "main",
resolvedPath: "/bin/sh",
sessionKey: "session-1",
allowedDecisions: [.allowOnce, .deny]))
#expect(decisions == [.allowOnce, .deny])
}
@Test func `ask always prompts omit durable approval when decisions are omitted`() {
let decisions = ExecApprovalsPromptPresenter.allowedPromptDecisions(
ExecApprovalPromptRequest(
command: "/bin/sh -lc pwd",
cwd: "/Users/example/projects/openclaw",
host: "node",
security: "full",
ask: "always",
agentId: "main",
resolvedPath: "/bin/sh",
sessionKey: "session-1"))
#expect(decisions == [.allowOnce, .deny])
}
@Test func `ask on miss prompts keep durable approval when decisions are omitted`() {
let decisions = ExecApprovalsPromptPresenter.allowedPromptDecisions(
ExecApprovalPromptRequest(
command: "/bin/sh -lc pwd",
cwd: "/Users/example/projects/openclaw",
host: "node",
security: "full",
ask: "on-miss",
agentId: "main",
resolvedPath: "/bin/sh",
sessionKey: "session-1"))
#expect(decisions == [.allowOnce, .allowAlways, .deny])
}
@Test func `legacy prompts keep durable approval when policy fields are omitted`() {
let decisions = ExecApprovalsPromptPresenter.allowedPromptDecisions(
ExecApprovalPromptRequest(
command: "/bin/sh -lc pwd",
cwd: "/Users/example/projects/openclaw",
host: "node",
security: "full",
agentId: "main",
resolvedPath: "/bin/sh",
sessionKey: "session-1"))
#expect(decisions == [.allowOnce, .allowAlways, .deny])
}
@Test func `unknown ask prompts keep legacy durable approval when decisions are omitted`() {
let decisions = ExecApprovalsPromptPresenter.allowedPromptDecisions(
ExecApprovalPromptRequest(
command: "/bin/sh -lc pwd",
cwd: "/Users/example/projects/openclaw",
host: "node",
security: "full",
ask: "unexpected",
agentId: "main",
resolvedPath: "/bin/sh",
sessionKey: "session-1"))
#expect(decisions == [.allowOnce, .allowAlways, .deny])
}
@Test func `approval request decodes valid allowed decisions only`() throws {
let data = """
{
"command": "/bin/sh -lc pwd",
"ask": "on-miss",
"allowedDecisions": ["allow-once", "bad", "deny", 3]
}
""".data(using: .utf8)!
let request = try JSONDecoder().decode(ExecApprovalPromptRequest.self, from: data)
#expect(request.allowedDecisions == [.allowOnce, .deny])
}
@Test func `approval request falls back when allowed decisions has wrong shape`() throws {
let data = """
{
"command": "/bin/sh -lc pwd",
"ask": "always",
"allowedDecisions": "allow-once"
}
""".data(using: .utf8)!
let request = try JSONDecoder().decode(ExecApprovalPromptRequest.self, from: data)
#expect(ExecApprovalsPromptPresenter.allowedPromptDecisions(request) == [.allowOnce, .deny])
}
@Test func `modal close does not synthesize deny when deny is unavailable`() {
let closeResponse = NSApplication.ModalResponse(rawValue: 0)
let withoutDeny = ExecApprovalsPromptPresenter.decision(
forModalResponse: closeResponse,
decisions: [.allowOnce])
let withDeny = ExecApprovalsPromptPresenter.decision(
forModalResponse: closeResponse,
decisions: [.allowOnce, .deny])
#expect(withoutDeny == nil)
#expect(withDeny == .deny)
}
@Test func `accessory view reserves nonzero alert layout space`() {
let accessory = ExecApprovalsPromptPresenter.buildAccessoryView(
ExecApprovalPromptRequest(

View File

@@ -441,6 +441,47 @@
"includeTools"
]
},
"transcripts": {
"emoji": "🎙️",
"title": "Transcripts",
"actions": {
"start": {
"label": "start",
"detailKeys": [
"providerId",
"sessionId",
"title",
"meetingUrl",
"guildId",
"channelId"
]
},
"stop": {
"label": "stop",
"detailKeys": [
"sessionId"
]
},
"status": {
"label": "status"
},
"import": {
"label": "import",
"detailKeys": [
"providerId",
"sessionId",
"title",
"meetingUrl"
]
},
"summarize": {
"label": "summarize",
"detailKeys": [
"sessionId"
]
}
}
},
"sessions_spawn": {
"emoji": "🧑‍🔧",
"title": "Sub-agent",
@@ -499,45 +540,6 @@
"lines"
]
},
"transcripts": {
"emoji": "📝",
"title": "Transcripts",
"actions": {
"start": {
"label": "start",
"detailKeys": [
"providerId",
"accountId",
"guildId",
"channelId",
"title"
]
},
"stop": {
"label": "stop",
"detailKeys": [
"sessionId"
]
},
"status": {
"label": "status"
},
"import": {
"label": "import",
"detailKeys": [
"providerId",
"title",
"speakerLabel"
]
},
"summarize": {
"label": "summarize",
"detailKeys": [
"sessionId"
]
}
}
},
"web_search": {
"emoji": "🔎",
"title": "Web Search",

View File

@@ -1,4 +1,4 @@
1d2c2fa07a2d3c4d046d2defe2eb48b27011be25e75db205b19e3da37e9fd0a0 config-baseline.json
5b12e247f4375de2d454802d3af188a840851dd69e9d15a1a92a4815c7ef7d97 config-baseline.core.json
c766614db5c416910fb6cdd454efb0738779af80ddd58a4fb06d8b1ca6484ce2 config-baseline.channel.json
74441e331aabb3026784c148d4ee5ce3f489a15ed87ffd9b7ba0c5e2a7bc93be config-baseline.plugin.json
53b7621e99d75b98ecc8f4389d38900f84cf213f95dbcc877f36125d763c660d config-baseline.json
e92bbf45714e418383118098d4ff15d347fa8ffc7e7837b437b522d2b59ce9fe config-baseline.core.json
b901fb766edfd9df630690281476fc4032c64772f69d1d8f7b2e0e913a90f229 config-baseline.channel.json
5c214ab364011fd95735755f9fa4298aa4de8ad81144ae8dd08d969bb7ba318b config-baseline.plugin.json

View File

@@ -1,2 +1,2 @@
1f1824af61c8885360ff99dad3fe1d7b75565e540cdef57474e9f220f9876f78 plugin-sdk-api-baseline.json
4f29099d81398cb76331b618c39d298b3c9398efd84291dfb93c2098cb4ae443 plugin-sdk-api-baseline.jsonl
65cb96d0aa2888ddb7b014f810d7cd415f1f0ccdce7792fbf12b4aad11f146f8 plugin-sdk-api-baseline.json
662b37da529f199ee9b56482f2f6897bdd010dfb72be778d208b289adaca1298 plugin-sdk-api-baseline.jsonl

View File

@@ -306,7 +306,7 @@ Keep hook endpoints behind loopback, tailnet, or trusted reverse proxy.
- Use a dedicated hook token; do not reuse gateway auth tokens.
- Keep `hooks.path` on a dedicated subpath; `/` is rejected.
- Set `hooks.allowedAgentIds` to limit explicit `agentId` routing.
- Set `hooks.allowedAgentIds` to limit which effective agent a hook can target, including the default agent when `agentId` is omitted.
- Keep `hooks.allowRequestSessionKey=false` unless you require caller-selected sessions.
- If you enable `hooks.allowRequestSessionKey`, also set `hooks.allowedSessionKeyPrefixes` to constrain allowed session key shapes.
- Hook payloads are wrapped with safety boundaries by default.

View File

@@ -51,7 +51,7 @@ 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.
For direct chats and any other source event, use `messages.visibleReplies: "message_tool"` to apply the same tool-only visible-reply behavior globally. Some harnesses, including Codex, also default direct/source chats to message-tool delivery when this is unset. Set `messages.visibleReplies: "automatic"` to force the old automatic final-reply path. `messages.groupChat.visibleReplies` remains the more specific override for group/channel rooms.
For direct chats and any other source event, use `messages.visibleReplies: "message_tool"` to apply the same tool-only visible-reply behavior globally. Internal WebChat direct turns default to automatic final-reply delivery so Pi and Codex receive the same visible-reply contract. Set `messages.visibleReplies: "message_tool"` to intentionally require `message(action=send)` for visible output. `messages.groupChat.visibleReplies` remains the more specific override for group/channel rooms.
This replaces the old pattern of forcing the model to answer `NO_REPLY` for most lurk-mode turns. In tool-only mode, doing nothing visible simply means not calling the message tool.

View File

@@ -292,13 +292,17 @@ curl "https://api.telegram.org/bot<bot_token>/getUpdates"
- Routing is deterministic: Telegram inbound replies back to Telegram (the model does not pick channels).
- Inbound messages normalize into the shared channel envelope with reply metadata, media placeholders, and persisted reply-chain context for Telegram replies the gateway has observed.
- Group sessions are isolated by group ID. Forum topics append `:topic:<threadId>` to keep topics isolated.
- DM messages can carry `message_thread_id`; OpenClaw preserves the thread ID for replies but keeps DMs on the flat session by default. Configure `channels.telegram.dm.threadReplies: "inbound"`, `channels.telegram.direct.<chatId>.threadReplies: "inbound"`, `requireTopic: true`, or a matching topic config when you intentionally want DM topic session isolation.
- DM messages can carry `message_thread_id`; OpenClaw preserves it for replies. DM topic sessions split only when Telegram `getMe` reports `has_topics_enabled: true` for the bot; otherwise DMs stay on the flat session.
- Long polling uses grammY runner with per-chat/per-thread sequencing. Overall runner sink concurrency uses `agents.defaults.maxConcurrent`.
- Multi-account startup bounds concurrent Telegram `getMe` probes so large bot fleets do not fan out every account probe at once.
- Long polling is guarded inside each gateway process so only one active poller can use a bot token at a time. If you still see `getUpdates` 409 conflicts, another OpenClaw gateway, script, or external poller is likely using the same token.
- Long-polling watchdog restarts trigger after 120 seconds without completed `getUpdates` liveness by default. Increase `channels.telegram.pollingStallThresholdMs` only if your deployment still sees false polling-stall restarts during long-running work. The value is in milliseconds and is allowed from `30000` to `600000`; per-account overrides are supported.
- Telegram Bot API has no read-receipt support (`sendReadReceipts` does not apply).
<Note>
`channels.telegram.dm.threadReplies` and `channels.telegram.direct.<chatId>.threadReplies` were removed. Run `openclaw doctor --fix` after upgrading if your config still has those keys. DM topic routing now follows the bot capability from Telegram `getMe.has_topics_enabled`, which is controlled by BotFather threaded mode: topics-enabled bots use thread-scoped DM sessions when Telegram sends `message_thread_id`; other DMs stay on the flat session.
</Note>
## Feature reference
<AccordionGroup>
@@ -663,7 +667,8 @@ curl "https://api.telegram.org/bot<bot_token>/getUpdates"
**Thread-bound ACP spawn from chat**: `/acp spawn <agent> --thread here|auto` binds the current topic to a new ACP session; follow-ups route there directly. OpenClaw pins the spawn confirmation in-topic. Requires `channels.telegram.threadBindings.spawnSessions` to remain enabled (default: `true`).
Template context exposes `MessageThreadId` and `IsForum`. DM chats with `message_thread_id` keep DM routing and reply metadata on flat sessions by default; they only use thread-aware session keys when configured with `threadReplies: "inbound"`, `threadReplies: "always"`, `requireTopic: true`, or a matching topic config. Use top-level `channels.telegram.dm.threadReplies` for the account default, or `direct.<chatId>.threadReplies` for one DM.
Template context exposes `MessageThreadId` and `IsForum`. DM chats with `message_thread_id` keep reply metadata; they use thread-aware session keys only when Telegram `getMe` reports `has_topics_enabled: true` for the bot.
The former `dm.threadReplies` and `direct.*.threadReplies` overrides are intentionally retired; use BotFather threaded mode as the single source of truth and run `openclaw doctor --fix` to remove stale config keys.
</Accordion>
@@ -1078,7 +1083,7 @@ Primary reference: [Configuration reference - Telegram](/gateway/config-channels
- topic defaults: `groups.<chatId>.topics."*"` applies to unmatched forum topics; exact topic IDs override it
- exec approvals: `execApprovals`, `accounts.*.execApprovals`
- command/menu: `commands.native`, `commands.nativeSkills`, `customCommands`
- threading/replies: `replyToMode`, `dm.threadReplies`, `direct.*.threadReplies`
- threading/replies: `replyToMode`
- streaming: `streaming` (preview), `streaming.preview.toolProgress`, `blockStreaming`
- formatting/delivery: `textChunkLimit`, `chunkMode`, `linkPreview`, `responsePrefix`
- media/network: `mediaMaxMb`, `mediaGroupFlushMs`, `timeoutSeconds`, `pollingStallThresholdMs`, `retry`, `network.autoSelectFamily`, `network.dangerouslyAllowPrivateNetwork`, `proxy`

View File

@@ -376,7 +376,7 @@ The separate `Install Smoke` workflow reuses the same scope script through its o
`main` pushes (including merge commits) do not force the full path; when changed-scope logic would request full coverage on a push, the workflow keeps the fast Docker smoke and leaves the full install smoke to nightly or release validation.
The slow Bun global install image-provider smoke is separately gated by `run_bun_global_install_smoke`. It runs on the nightly schedule and from the release checks workflow, and manual `Install Smoke` dispatches can opt into it, but pull requests and `main` pushes do not. QR and installer Docker tests keep their own install-focused Dockerfiles.
The slow Bun global install image-provider smoke is separately gated by `run_bun_global_install_smoke`. It runs on the nightly schedule and from the release checks workflow, and manual `Install Smoke` dispatches can opt into it, but pull requests and `main` pushes do not. Normal PR CI still runs the fast Bun launcher regression lane for Node-relevant changes. QR and installer Docker tests keep their own install-focused Dockerfiles.
## Local Docker E2E

View File

@@ -2,13 +2,13 @@
summary: "CLI reference for `openclaw docs` (search the live docs index)"
read_when:
- You want to search the live OpenClaw docs from the terminal
- You need to know which helper binaries the docs CLI shells out to
- You need to know which hosted search API the docs CLI calls
title: "Docs"
---
# `openclaw docs`
Search the live OpenClaw docs index from the terminal. The command shells out to the public Mintlify-hosted docs MCP search endpoint at `https://docs.openclaw.ai/mcp.search_open_claw` and renders the results in your terminal.
Search the live OpenClaw docs index from the terminal. The command calls OpenClaw's Cloudflare-hosted docs search API and renders the results in your terminal.
## Usage
@@ -35,17 +35,7 @@ With no query, `openclaw docs` prints the docs entrypoint URL plus a sample sear
## How it works
`openclaw docs` invokes the `mcporter` CLI to call the docs search MCP tool, then parses the `Title: / Link: / Content:` blocks from the tool output into a list of results.
To resolve `mcporter`, OpenClaw checks in order:
1. `mcporter` on `PATH` (used directly if present).
2. `pnpm dlx mcporter ...` if `pnpm` is installed.
3. `npx -y mcporter ...` if `npx` is installed.
If none are available, the command fails with a hint to install `pnpm` (`npm install -g pnpm`).
The search call uses a fixed 30 second timeout. Result snippets are truncated to ~220 characters per entry.
`openclaw docs` calls `https://docs.openclaw.ai/api/search` and renders the JSON results. The search call uses a fixed 30 second timeout.
## Output
@@ -62,10 +52,10 @@ In non-rich output (piped, `--no-color`, scripts), the same data renders as Mark
## Exit codes
| Code | Meaning |
| ---- | --------------------------------------------------- |
| `0` | Search succeeded (including zero-result responses). |
| `1` | The MCP tool call failed; stderr is printed inline. |
| Code | Meaning |
| ---- | ----------------------------------------------------------------- |
| `0` | Search succeeded (including zero-result responses). |
| `1` | The hosted docs search API call failed; stderr is printed inline. |
## Related

View File

@@ -24,7 +24,8 @@ Related:
- `--json`: emit line-delimited JSON events
- `--plain`: plain text output without styled formatting
- `--no-color`: disable ANSI colors
- `--local-time`: render timestamps in your local timezone
- `--local-time`: render timestamps in your local timezone (default)
- `--utc`: render timestamps in UTC
## Shared Gateway RPC options
@@ -49,13 +50,14 @@ openclaw logs --plain
openclaw logs --no-color
openclaw logs --limit 500
openclaw logs --local-time
openclaw logs --utc
openclaw logs --follow --local-time
openclaw logs --url ws://127.0.0.1:18789 --token "$OPENCLAW_GATEWAY_TOKEN"
```
## Notes
- Use `--local-time` to render timestamps in your local timezone.
- Timestamps render in your local timezone by default. Use `--utc` for UTC output.
- If the implicit local loopback Gateway asks for pairing, closes during connect, or times out before `logs.tail` answers, `openclaw logs` falls back to the configured Gateway file log automatically. Explicit `--url` targets do not use this fallback.
- `openclaw logs --follow` does not follow configured-file fallbacks after implicit local Gateway RPC failures. On Linux, it uses the active user-systemd Gateway journal by PID when available and prints the selected log source; otherwise it keeps retrying the live Gateway instead of tailing a potentially stale side-by-side file.
- When using `--follow`, transient gateway disconnects (WebSocket close, timeout, connection drop) trigger automatic reconnection with exponential backoff (up to 8 retries, capped at 30 s between attempts). A warning is printed to stderr on each retry, and a `[logs] gateway reconnected` notice is printed once a poll succeeds. In `--json` mode both the retry warning and the reconnect transition are emitted as `{"type":"notice"}` records on stderr. Non-recoverable errors (auth failure, bad configuration) still exit immediately.

View File

@@ -103,7 +103,7 @@ rewriting files.
```bash
openclaw plugins search "calendar" # search ClawHub plugins
openclaw plugins install <package> # npm by default
openclaw plugins install <package> # source auto-detection
openclaw plugins install clawhub:<package> # ClawHub only
openclaw plugins install npm:<package> # npm only
openclaw plugins install npm-pack:<path.tgz> # local npm pack through npm install semantics
@@ -123,7 +123,7 @@ sources with guarded environment variables. See
[Plugin install overrides](/plugins/install-overrides).
<Warning>
Bare package names install from npm by default during the launch cutover. Use `clawhub:<package>` for ClawHub. Treat plugin installs like running code. Prefer pinned versions.
Bare package names install from npm by default during the launch cutover, unless they match an official plugin id. Raw `@openclaw/*` package specs that match bundled plugins use the bundled copy that shipped with the current OpenClaw build. Use `npm:<package>` when you deliberately want an external npm package instead. Use `clawhub:<package>` for ClawHub. Treat plugin installs like running code. Prefer pinned versions.
</Warning>
`plugins search` queries ClawHub for installable plugin packages and prints
@@ -171,7 +171,9 @@ is available, then fall back to `latest`.
Npm specs are **registry-only** (package name + optional **exact version** or **dist-tag**). Git/URL/file specs and semver ranges are rejected. Dependency installs run project-local with `--ignore-scripts` for safety, even when your shell has global npm install settings. Managed plugin npm roots inherit OpenClaw's package-level npm `overrides`, so host security pins apply to hoisted plugin dependencies too.
Use `npm:<package>` when you want to make npm resolution explicit. Bare package specs also install directly from npm during the launch cutover.
Use `npm:<package>` when you want to make npm resolution explicit. Bare package specs also install directly from npm during the launch cutover unless they match an official plugin id.
Raw `@openclaw/*` package specs that match bundled plugins resolve to the image-owned bundled copy before npm fallback. For example, `openclaw plugins install @openclaw/discord@2026.5.20 --pin` uses the bundled Discord plugin from the current OpenClaw build instead of creating a managed npm override. To force the external npm package, use `openclaw plugins install npm:@openclaw/discord@2026.5.20 --pin`.
Bare specs and `@latest` stay on the stable track. OpenClaw date-stamped correction versions such as `2026.5.3-1` are stable releases for this check. If npm resolves either of those to a prerelease, OpenClaw stops and asks you to opt in explicitly with a prerelease tag such as `@beta`/`@rc` or an exact prerelease version such as `@1.2.3-beta.4`.
@@ -207,7 +209,7 @@ openclaw plugins install clawhub:openclaw-codex-app-server
openclaw plugins install clawhub:openclaw-codex-app-server@1.2.3
```
Bare npm-safe plugin specs install from npm by default during the launch cutover:
Bare npm-safe plugin specs install from npm by default during the launch cutover unless they match an official plugin id:
```bash
openclaw plugins install openclaw-codex-app-server
@@ -217,6 +219,7 @@ Use `npm:` to make npm-only resolution explicit:
```bash
openclaw plugins install npm:openclaw-codex-app-server
openclaw plugins install npm:@openclaw/discord@2026.5.20
openclaw plugins install npm:@scope/plugin-name@1.0.1
```

View File

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

View File

@@ -126,7 +126,7 @@ keys.
- If commands seem stuck, enable verbose logs and look for "queued for ...ms" lines to confirm the queue is draining.
- If you need queue depth, enable verbose logs and watch for queue timing lines.
- Codex app-server runs that accept a turn and then stop emitting progress are interrupted by the Codex adapter so the active session lane can release instead of waiting for the outer run timeout.
- When diagnostics are enabled, sessions that remain in `processing` past `diagnostics.stuckSessionWarnMs` with no observed reply, tool, status, block, or ACP progress are classified by current activity. Active work logs as `session.long_running`; active work with no recent progress logs as `session.stalled`; `session.stuck` is reserved for stale session bookkeeping with no active work, and only that path can release the affected session lane so queued work drains. Repeated `session.stuck` diagnostics back off while the session remains unchanged.
- When diagnostics are enabled, sessions that remain in `processing` past `diagnostics.stuckSessionWarnMs` with no observed reply, tool, status, block, or ACP progress are classified by current activity. Active work logs as `session.long_running`; active work with no recent progress logs as `session.stalled`; `session.stuck` is reserved for recoverable stale session bookkeeping, including idle queued sessions with stale ownerless model/tool activity, and only that path can release the affected session lane so queued work drains. Repeated `session.stuck` diagnostics back off while the session remains unchanged.
## Related

View File

@@ -50,6 +50,50 @@ Disable all flags:
OPENCLAW_DIAGNOSTICS=0
```
`OPENCLAW_DIAGNOSTICS=0` is a process-level disable override: it disables
flags from both env and config for that process.
## Profiling flags
Profiler flags enable targeted timing spans without raising global logging
levels. They are disabled by default.
Enable all profiler-gated spans for one gateway run:
```bash
OPENCLAW_DIAGNOSTICS=profiler openclaw gateway run
```
Enable only reply-dispatch profiler spans:
```bash
OPENCLAW_DIAGNOSTICS=reply.profiler openclaw gateway run
```
Enable only Codex app-server startup/tool/thread profiler spans:
```bash
OPENCLAW_DIAGNOSTICS=codex.profiler openclaw gateway run
```
Enable profiler flags from config:
```json
{
"diagnostics": {
"flags": ["reply.profiler", "codex.profiler"]
}
}
```
Restart the gateway after changing config flags. To disable a profiler flag,
remove it from `diagnostics.flags` and restart. To temporarily disable every
diagnostics flag even when config enables profiler flags, start the process with:
```bash
OPENCLAW_DIAGNOSTICS=0 openclaw gateway run
```
## Timeline artifacts
The `timeline` flag writes structured startup and runtime timing events for

View File

@@ -399,6 +399,22 @@ minutes; set `0` to disable). One-shot embedded runs such as auth probes,
slug generation, and active-memory recall request cleanup at run end so stdio
children and Streamable HTTP/SSE streams do not outlive the run.
## Reseed history cap
When a fresh CLI session is seeded from a prior OpenClaw transcript (for
example after a `session_expired` retry), the rendered
`<conversation_history>` block is capped to keep reseed prompts from
exploding. The default is `12288` characters (about 3000 tokens).
Claude CLI backends automatically use a larger cap derived from the resolved
Claude context tier. Standard 200K-token Claude runs keep a larger transcript
slice, and 1M-token Claude runs keep a larger slice again, while other CLI
backends keep the conservative default.
- The cap only governs the reseed prompt's prior-history block. Live-session
output limits are tuned separately under `reliability.outputLimits`
(see [Sessions](#sessions)).
## Limitations
- **No direct OpenClaw tool calls.** OpenClaw does not inject tool calls into

View File

@@ -788,7 +788,7 @@ See the full channel index: [Channels](/channels).
Group messages default to **require mention** (metadata mention or safe regex patterns). Applies to WhatsApp, Telegram, Discord, Google Chat, and iMessage group chats.
Visible replies are controlled separately. Normal group and channel requests default to automatic final delivery: final assistant text posts through the legacy visible reply path. Some harnesses, including Codex, default direct/source chats to message-tool delivery so visible output only posts after the agent calls `message(action=send)`. If the model returns final text without calling the message tool, that final text stays private and the gateway verbose log records suppressed payload metadata.
Visible replies are controlled separately. Normal group, channel, and internal WebChat direct requests default to automatic final delivery: final assistant text posts through the legacy visible reply path. Opt into `messages.visibleReplies: "message_tool"` or `messages.groupChat.visibleReplies: "message_tool"` when visible output should only post after the agent calls `message(action=send)`. If the model returns final text without calling the message tool in an opted-in tool-only mode, that final text stays private and the gateway verbose log records suppressed payload metadata.
Tool-only visible replies require a model/runtime that reliably calls tools, and are recommended for shared ambient rooms on latest-generation models such as GPT 5.5. If
the session log shows assistant text with `didSendViaMessagingTool: false`, the
@@ -834,7 +834,7 @@ Fix: either pick a stronger tool-calling model, remove the explicit `"message_to
`messages.groupChat.unmentionedInbound: "room_event"` submits unmentioned always-on group/channel messages as quiet room context on supported channels. Mentioned messages, commands, and direct messages remain user requests. See [Ambient room events](/channels/ambient-room-events) for complete Discord, Slack, and Telegram examples.
`messages.visibleReplies` is the global source-event default; `messages.groupChat.visibleReplies` overrides it for group/channel source events. When `messages.visibleReplies` is unset, direct/source chats use the selected runtime or harness default. The Codex harness defaults direct/source chats to message-tool delivery; set `messages.visibleReplies: "automatic"` to use automatic final delivery. Channel allowlists and mention gating still decide whether an event is processed.
`messages.visibleReplies` is the global source-event default; `messages.groupChat.visibleReplies` overrides it for group/channel source events. When `messages.visibleReplies` is unset, direct/source chats use the selected runtime or harness default, but internal WebChat direct turns use automatic final delivery for Pi/Codex prompt parity. Set `messages.visibleReplies: "message_tool"` to intentionally require `message(action=send)` for visible output. Channel allowlists and mention gating still decide whether an event is processed.
#### DM history limits

View File

@@ -709,8 +709,8 @@ Validation and safety notes:
- `transform` can point to a JS/TS module returning a hook action.
- `transform.module` must be a relative path and stays within `hooks.transformsDir` (absolute paths and traversal are rejected).
- Keep `hooks.transformsDir` under `~/.openclaw/hooks/transforms`; workspace skill directories are rejected. If `openclaw doctor` reports this path as invalid, move the transform module into the hooks transforms directory or remove `hooks.transformsDir`.
- `agentId` routes to a specific agent; unknown IDs fall back to default.
- `allowedAgentIds`: restricts explicit routing (`*` or omitted = allow all, `[]` = deny all).
- `agentId` routes to a specific agent; unknown IDs fall back to the default agent.
- `allowedAgentIds`: restricts effective agent routing, including the default-agent path when `agentId` is omitted (`*` or omitted = allow all, `[]` = deny all).
- `defaultSessionKey`: optional fixed session key for hook agent runs without explicit `sessionKey`.
- `allowRequestSessionKey`: allow `/hooks/agent` callers and template-driven mapping session keys to set `sessionKey` (default: `false`).
- `allowedSessionKeyPrefixes`: optional prefix allowlist for explicit `sessionKey` values (request + mapping), e.g. `["hook:"]`. It becomes required when any mapping or preset uses a templated `sessionKey`.

View File

@@ -223,8 +223,8 @@ message bodies are also approved for export.
- `openclaw.queue.depth` (histogram, attrs: `openclaw.lane` or `openclaw.channel=heartbeat`)
- `openclaw.queue.wait_ms` (histogram, attrs: `openclaw.lane`)
- `openclaw.session.state` (counter, attrs: `openclaw.state`, `openclaw.reason`)
- `openclaw.session.stuck` (counter, attrs: `openclaw.state`; emitted only for stale session bookkeeping with no active work)
- `openclaw.session.stuck_age_ms` (histogram, attrs: `openclaw.state`; emitted only for stale session bookkeeping with no active work)
- `openclaw.session.stuck` (counter, attrs: `openclaw.state`; emitted for recoverable stale session bookkeeping)
- `openclaw.session.stuck_age_ms` (histogram, attrs: `openclaw.state`; emitted for recoverable stale session bookkeeping)
- `openclaw.session.turn.created` (counter, attrs: `openclaw.agent`, `openclaw.channel`, `openclaw.trigger`)
- `openclaw.session.recovery.requested` (counter, attrs: `openclaw.state`, `openclaw.action`, `openclaw.active_work_kind`, `openclaw.reason`)
- `openclaw.session.recovery.completed` (counter, attrs: `openclaw.state`, `openclaw.action`, `openclaw.status`, `openclaw.active_work_kind`, `openclaw.reason`)
@@ -249,8 +249,9 @@ OpenClaw classifies sessions by the work it can still observe:
turns behind the lane can resume. When unset, the abort threshold defaults to
the safer extended window of at least 5 minutes and 3x
`diagnostics.stuckSessionWarnMs`.
- `session.stuck`: stale session bookkeeping with no active work. This releases
the affected session lane immediately.
- `session.stuck`: stale session bookkeeping with no active work, or an idle
queued session with stale ownerless model/tool activity. This releases the
affected session lane immediately after recovery gates pass.
Recovery emits structured `session.recovery.requested` and
`session.recovery.completed` events. Diagnostic session state is marked idle

View File

@@ -37,7 +37,10 @@ install method:
- **`stable`** (package installs): updates via npm dist-tag `latest`.
- **`beta`** (package installs): prefers npm dist-tag `beta`, but falls back to
`latest` when `beta` is missing or older than the current stable tag.
- **`stable`** (git installs): checks out the latest stable git tag.
- **`stable`** (git installs): checks out the latest stable git tag, excluding
semver prerelease tags such as `-alpha.N`, `-beta.N`, `-rc.N`, `-dev.N`,
`-next.N`, `-preview.N`, `-canary.N`, `-nightly.N`, and other prerelease
suffixes.
- **`beta`** (git installs): prefers the latest beta git tag, but falls back to
the latest stable git tag when beta is missing or older.
- **`dev`**: ensures a git checkout (default `~/openclaw`, or
@@ -121,9 +124,11 @@ source (config, git tag, git branch, or default).
## Tagging best practices
- Tag releases you want git checkouts to land on (`vYYYY.M.D` for stable,
`vYYYY.M.D-beta.N` for beta).
`vYYYY.M.D-beta.N` for beta; named semver prerelease suffixes such as
`-alpha.N`, `-rc.N`, and `-next.N` are not stable targets).
- Legacy numeric stable tags such as `vYYYY.M.D-1` and `v1.0.1-1` are still
recognized as stable git tags for compatibility.
- `vYYYY.M.D.beta.N` is also recognized for compatibility, but prefer `-beta.N`.
- Legacy `vYYYY.M.D-<patch>` tags are still recognized as stable (non-beta).
- Keep tags immutable: never move or reuse a tag.
- npm dist-tags remain the source of truth for npm installs:
- `latest` -> stable

View File

@@ -310,7 +310,7 @@ available timeout in this order:
image-generation default.
- For the media-understanding `image` tool, `tools.media.image.timeoutSeconds`
converted to milliseconds, or the 60 second media default.
- The 30 second dynamic-tool default.
- The 90 second dynamic-tool default.
Dynamic tool budgets are capped at 600000 ms. On timeout, OpenClaw aborts the
tool signal where supported and returns a failed dynamic-tool response to Codex
@@ -353,7 +353,7 @@ If discovery fails or times out, OpenClaw uses a bundled fallback catalog for:
- GPT-5.4 mini
- GPT-5.2
The current bundled harness is `@openai/codex` `0.133.0`. A `model/list` probe
The current bundled harness is `@openai/codex` `0.134.0`. A `model/list` probe
against that bundled app-server returned:
| Model id | Default | Hidden | Input modalities | Reasoning efforts |

View File

@@ -49,11 +49,12 @@ newly selected model.
## Visible replies and heartbeats
When a direct/source chat turn runs through the Codex harness, visible replies
default to the message tool: final assistant text stays private unless the
agent calls `message(action="send")`. This matches GPT models well because they
can decide whether source-channel output is useful. Set
`messages.visibleReplies: "automatic"` to restore the old mode where final
assistant text posts automatically.
default to automatic final assistant delivery for internal WebChat surfaces.
This keeps Codex aligned with the Pi harness prompt contract: agents reply
normally, and OpenClaw posts the final text to the source conversation. Set
`messages.visibleReplies: "message_tool"` when a direct/source chat should
intentionally keep final assistant text private unless the agent calls
`message(action="send")`.
Codex heartbeat turns also get `heartbeat_respond` in the searchable OpenClaw
tool catalog by default, so the agent can record whether the wake should stay

View File

@@ -541,7 +541,7 @@ Supported `appServer` fields:
| `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. |
OpenClaw-owned dynamic tool calls are bounded independently from
`appServer.requestTimeoutMs`: Codex `item/tool/call` requests use a 30 second
`appServer.requestTimeoutMs`: Codex `item/tool/call` requests use a 90 second
OpenClaw watchdog by default. A positive per-call `timeoutMs` argument extends
or shortens that specific tool budget. The `image_generate` tool uses
`agents.defaults.imageGenerationModel.timeoutMs` when the tool call does not

View File

@@ -198,7 +198,8 @@ focused channel/runtime subpaths, `config-contracts`, `string-coerce-runtime`,
| `plugin-sdk/approval-gateway-runtime` | Shared approval gateway-resolution helper |
| `plugin-sdk/approval-handler-adapter-runtime` | Lightweight native approval adapter loading helpers for hot channel entrypoints |
| `plugin-sdk/approval-handler-runtime` | Broader approval handler runtime helpers; prefer the narrower adapter/gateway seams when they are enough |
| `plugin-sdk/approval-native-runtime` | Native approval target + account-binding helpers |
| `plugin-sdk/approval-native-runtime` | Native approval target + account-binding helpers and local native exec prompt suppression |
| `plugin-sdk/approval-reaction-runtime` | Hardcoded approval reaction bindings, reaction prompt payloads, reaction target stores, and compatibility export for local native exec prompt suppression |
| `plugin-sdk/approval-reply-runtime` | Exec/plugin approval reply payload helpers |
| `plugin-sdk/approval-runtime` | Exec/plugin approval payload helpers, native approval routing/runtime helpers, and structured approval display helpers such as `formatApprovalDisplayPath` |
| `plugin-sdk/reply-dedupe` | Narrow inbound reply dedupe reset helpers |
@@ -245,6 +246,7 @@ focused channel/runtime subpaths, `config-contracts`, `string-coerce-runtime`,
| `plugin-sdk/runtime-config-snapshot` | Current process config snapshot helpers such as `getRuntimeConfig`, `getRuntimeConfigSnapshot`, and test snapshot setters |
| `plugin-sdk/telegram-command-config` | Telegram command-name/description normalization and duplicate/conflict checks, even when the bundled Telegram contract surface is unavailable |
| `plugin-sdk/text-autolink-runtime` | File-reference autolink detection without the broad text barrel |
| `plugin-sdk/approval-reaction-runtime` | Hardcoded approval reaction bindings, reaction prompt payloads, reaction target stores, and compatibility export for local native exec prompt suppression |
| `plugin-sdk/approval-runtime` | Exec/plugin approval helpers, approval-capability builders, auth/profile helpers, native routing/runtime helpers, and structured approval display path formatting |
| `plugin-sdk/reply-runtime` | Shared inbound/reply runtime helpers, chunking, dispatch, heartbeat, reply planner |
| `plugin-sdk/reply-dispatch-runtime` | Narrow reply dispatch/finalize and conversation-label helpers |

View File

@@ -40,7 +40,9 @@ Before installing a plugin, make sure you have:
```
ClawHub is the primary discovery surface for community plugins. During the
launch cutover, ordinary bare package specs still install from npm. Use an
launch cutover, ordinary bare package specs still install from npm unless
they match an official plugin id. Raw `@openclaw/*` package specs that match
bundled plugins use the bundled copy from the current OpenClaw build. Use an
explicit prefix when you need one source.
</Step>
@@ -126,10 +128,13 @@ Before installing a plugin, make sure you have:
Bare package specs have special compatibility behavior. If the bare name matches
a bundled plugin id, OpenClaw uses that bundled source. If it matches an
official external plugin id, OpenClaw uses the official package catalog. Other
ordinary bare package specs install through npm during the launch cutover. Use
`clawhub:`, `npm:`, `git:`, or `npm-pack:` when you need deterministic source
selection. See [`openclaw plugins`](/cli/plugins#install) for the full command
contract.
ordinary bare package specs install through npm during the launch cutover. Raw
`@openclaw/*` package specs that match bundled plugins also resolve to the
bundled copy before npm fallback. Use `npm:@openclaw/<plugin>@<version>` when
you deliberately want the external npm package instead of the image-owned
bundled copy. Use `clawhub:`, `npm:`, `git:`, or `npm-pack:` when you need
deterministic source selection. See [`openclaw plugins`](/cli/plugins#install)
for the full command contract.
### Configure plugin policy

View File

@@ -122,23 +122,6 @@
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/client-bedrock-runtime/node_modules/@aws-sdk/token-providers": {
"version": "3.1053.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1053.0.tgz",
"integrity": "sha512-laSwHLYMMrXQRl2mFDXszF43m/F4pKWyGr7hCLfJmV8rn8c6CnI/hp/bf/Gn7gLcjz0SY4evd7SBpqtnIhzA/A==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.974.13",
"@aws-sdk/nested-clients": "^3.997.11",
"@aws-sdk/types": "^3.973.9",
"@smithy/core": "^3.24.3",
"@smithy/types": "^4.14.2",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/client-cognito-identity": {
"version": "3.1051.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/client-cognito-identity/-/client-cognito-identity-3.1051.0.tgz",
@@ -457,9 +440,9 @@
}
},
"node_modules/@aws-sdk/token-providers": {
"version": "3.1052.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1052.0.tgz",
"integrity": "sha512-QqZNB3so7UIDxZtroc85TQaLVxdZRFm0eWM1CSR2N+b06as9TOrilvrlTZuj3guYlxMs6yLOgGxnklJ5qMYtTw==",
"version": "3.1053.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1053.0.tgz",
"integrity": "sha512-laSwHLYMMrXQRl2mFDXszF43m/F4pKWyGr7hCLfJmV8rn8c6CnI/hp/bf/Gn7gLcjz0SY4evd7SBpqtnIhzA/A==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.974.13",
@@ -588,20 +571,6 @@
"node": ">=22.19.0"
}
},
"node_modules/@earendil-works/pi-ai/node_modules/@smithy/node-http-handler": {
"version": "4.7.3",
"resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.7.3.tgz",
"integrity": "sha512-/jPhevcTFPMVl6KNjbaI47iOg1zxC7IsnX4PQDGVZKMFceOXtB8IEYaB7a9VvkP/3oC60WzTeKocvSI7vLT0vA==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/core": "^3.24.3",
"@smithy/types": "^4.14.2",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@google/genai": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/@google/genai/-/genai-1.52.0.tgz",

View File

@@ -296,23 +296,6 @@
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/credential-provider-sso/node_modules/@aws-sdk/token-providers": {
"version": "3.1052.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1052.0.tgz",
"integrity": "sha512-QqZNB3so7UIDxZtroc85TQaLVxdZRFm0eWM1CSR2N+b06as9TOrilvrlTZuj3guYlxMs6yLOgGxnklJ5qMYtTw==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.974.13",
"@aws-sdk/nested-clients": "^3.997.11",
"@aws-sdk/types": "^3.973.9",
"@smithy/core": "^3.24.3",
"@smithy/types": "^4.14.2",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/credential-provider-web-identity": {
"version": "3.972.43",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.43.tgz",
@@ -514,20 +497,6 @@
"node": ">=22.19.0"
}
},
"node_modules/@earendil-works/pi-ai/node_modules/@smithy/node-http-handler": {
"version": "4.7.3",
"resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.7.3.tgz",
"integrity": "sha512-/jPhevcTFPMVl6KNjbaI47iOg1zxC7IsnX4PQDGVZKMFceOXtB8IEYaB7a9VvkP/3oC60WzTeKocvSI7vLT0vA==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/core": "^3.24.3",
"@smithy/types": "^4.14.2",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@google/genai": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/@google/genai/-/genai-1.52.0.tgz",

View File

@@ -282,23 +282,6 @@
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/credential-provider-sso/node_modules/@aws-sdk/token-providers": {
"version": "3.1052.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1052.0.tgz",
"integrity": "sha512-QqZNB3so7UIDxZtroc85TQaLVxdZRFm0eWM1CSR2N+b06as9TOrilvrlTZuj3guYlxMs6yLOgGxnklJ5qMYtTw==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.974.13",
"@aws-sdk/nested-clients": "^3.997.11",
"@aws-sdk/types": "^3.973.9",
"@smithy/core": "^3.24.3",
"@smithy/types": "^4.14.2",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/credential-provider-web-identity": {
"version": "3.972.43",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.43.tgz",
@@ -689,12 +672,12 @@
}
},
"node_modules/@smithy/node-http-handler": {
"version": "4.7.3",
"resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.7.3.tgz",
"integrity": "sha512-/jPhevcTFPMVl6KNjbaI47iOg1zxC7IsnX4PQDGVZKMFceOXtB8IEYaB7a9VvkP/3oC60WzTeKocvSI7vLT0vA==",
"version": "4.7.4",
"resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.7.4.tgz",
"integrity": "sha512-HIeF+1vrDGzPkkv39Hj2vlHSXHY3p958jd/8ZnePIY6+ZOsQX8coyEUKO5yQu4r0bQIVsbpotVIrXXwyycMStQ==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/core": "^3.24.3",
"@smithy/core": "^3.24.4",
"@smithy/types": "^4.14.2",
"tslib": "^2.6.2"
},

View File

@@ -67,7 +67,7 @@ export function buildAnthropicCliBackend(): CliBackendPlugin {
sessionIdFields: [...CLAUDE_CLI_SESSION_ID_FIELDS],
systemPromptFileArg: "--append-system-prompt-file",
systemPromptMode: "append",
systemPromptWhen: "first",
systemPromptWhen: "always",
clearEnv: [...CLAUDE_CLI_CLEAR_ENV],
reliability: {
watchdog: {

View File

@@ -270,6 +270,13 @@ describe("normalizeClaudeBackendConfig", () => {
expect(backend.config.resumeArgs).toContain("{sessionId}");
});
it("passes system prompt on every turn (issue #80374 — systemPromptWhen must be 'always')", () => {
// Before fix this was hardcoded to "first", which silently dropped
// systemPromptOverride on every resumed / compacted claude-cli session.
const backend = buildAnthropicCliBackend();
expect(backend.config.systemPromptWhen).toBe("always");
});
it("leaves claude cli subscription-managed, restricts setting sources, and clears inherited env overrides", () => {
const backend = buildAnthropicCliBackend();

View File

@@ -1,4 +1,5 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { EXISTING_SESSION_LIMITS } from "./existing-session-limits.js";
import {
createExistingSessionAgentSharedModule,
existingSessionRouteState,
@@ -215,13 +216,72 @@ describe("existing-session browser routes", () => {
it("checks existing-session snapshot URL when SSRF policy is configured", async () => {
const handler = getSnapshotGetHandler({ allowPrivateNetwork: false });
const response = createBrowserRouteResponse();
await handler?.({ params: {}, query: { format: "ai" } }, response.res);
expect(response.statusCode).toBe(200);
expect(navigationGuardMocks.assertBrowserNavigationAllowed).not.toHaveBeenCalled();
expect(navigationGuardMocks.assertBrowserNavigationResultAllowed).toHaveBeenCalledWith({
url: "https://example.com",
ssrfPolicy: { allowPrivateNetwork: false },
});
expect(chromeMcpMocks.takeChromeMcpSnapshot).toHaveBeenCalled();
});
it("allows existing-session snapshots under the default SSRF policy object", async () => {
const handler = getSnapshotGetHandler({});
const response = createBrowserRouteResponse();
await handler?.({ params: {}, query: { format: "ai" } }, response.res);
expect(response.statusCode).toBe(200);
expect(navigationGuardMocks.assertBrowserNavigationAllowed).not.toHaveBeenCalled();
expect(navigationGuardMocks.assertBrowserNavigationResultAllowed).toHaveBeenCalledWith({
url: "https://example.com",
ssrfPolicy: {},
});
expect(chromeMcpMocks.takeChromeMcpSnapshot).toHaveBeenCalled();
});
it("blocks existing-session snapshots when the current URL violates browser navigation policy", async () => {
routeState.profileCtx.ensureTabAvailable.mockResolvedValueOnce({
targetId: "7",
url: "http://127.0.0.1:8080/admin",
});
navigationGuardMocks.assertBrowserNavigationResultAllowed.mockRejectedValueOnce(
new Error("browser navigation blocked by policy"),
);
const handler = getSnapshotGetHandler({ allowPrivateNetwork: false });
const response = createBrowserRouteResponse();
await handler?.({ params: {}, query: { format: "ai" } }, response.res);
expect(response.statusCode).toBe(400);
expect(response.body).toEqual({ error: "browser navigation blocked by policy" });
expect(navigationGuardMocks.assertBrowserNavigationResultAllowed).toHaveBeenCalledWith({
url: "http://127.0.0.1:8080/admin",
ssrfPolicy: { allowPrivateNetwork: false },
});
expect(chromeMcpMocks.takeChromeMcpSnapshot).not.toHaveBeenCalled();
});
it("rejects existing-session snapshot selectors before checking the current URL", async () => {
routeState.profileCtx.ensureTabAvailable.mockResolvedValueOnce({
targetId: "7",
url: "http://127.0.0.1:8080/admin",
});
const handler = getSnapshotGetHandler({ allowPrivateNetwork: false });
const response = createBrowserRouteResponse();
await handler?.({ params: {}, query: { format: "ai", selector: "#admin" } }, response.res);
expect(response.statusCode).toBe(400);
expect(response.body).toEqual({
error: EXISTING_SESSION_LIMITS.snapshot.snapshotSelector,
});
expect(navigationGuardMocks.assertBrowserNavigationAllowed).not.toHaveBeenCalled();
expect(navigationGuardMocks.assertBrowserNavigationResultAllowed).not.toHaveBeenCalled();
expect(chromeMcpMocks.takeChromeMcpSnapshot).not.toHaveBeenCalled();
});
it("checks existing-session screenshot URL when SSRF policy is configured", async () => {

View File

@@ -0,0 +1,148 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createBrowserRouteApp, createBrowserRouteResponse } from "./test-helpers.js";
import type { BrowserRequest } from "./types.js";
const routeState = vi.hoisted(() => ({
profileCtx: {
profile: {
driver: "openclaw" as const,
name: "openclaw",
cdpUrl: "http://127.0.0.1:18800",
cdpIsLoopback: true,
},
ensureTabAvailable: vi.fn(async () => ({
targetId: "7",
url: "http://127.0.0.1:8080/admin",
wsUrl: "ws://127.0.0.1/devtools/page/7",
})),
},
}));
const cdpMocks = vi.hoisted(() => ({
snapshotAria: vi.fn(async () => ({
nodes: [{ ref: "1", role: "link", name: "private", depth: 0 }],
})),
snapshotRoleViaCdp: vi.fn(async () => ({
snapshot: '- link "private" [ref=e1]',
refs: { e1: { role: "link", name: "private" } },
stats: { lines: 1, chars: 25, refs: 1, interactive: 1 },
})),
}));
const navigationGuardMocks = vi.hoisted(() => ({
assertBrowserNavigationAllowed: vi.fn(async () => {}),
assertBrowserNavigationResultAllowed: vi.fn(async () => {
throw new Error("browser navigation blocked by policy");
}),
withBrowserNavigationPolicy: vi.fn((ssrfPolicy?: unknown) => (ssrfPolicy ? { ssrfPolicy } : {})),
}));
vi.mock("../cdp.js", () => ({
captureScreenshot: vi.fn(),
snapshotAria: cdpMocks.snapshotAria,
snapshotRoleViaCdp: cdpMocks.snapshotRoleViaCdp,
}));
vi.mock("../chrome-mcp.js", () => ({
evaluateChromeMcpScript: vi.fn(),
navigateChromeMcpPage: vi.fn(),
takeChromeMcpScreenshot: vi.fn(),
takeChromeMcpSnapshot: vi.fn(),
}));
vi.mock("../navigation-guard.js", () => ({
assertBrowserNavigationAllowed: navigationGuardMocks.assertBrowserNavigationAllowed,
assertBrowserNavigationResultAllowed: navigationGuardMocks.assertBrowserNavigationResultAllowed,
withBrowserNavigationPolicy: navigationGuardMocks.withBrowserNavigationPolicy,
}));
vi.mock("../screenshot.js", () => ({
DEFAULT_BROWSER_SCREENSHOT_MAX_BYTES: 128,
DEFAULT_BROWSER_SCREENSHOT_MAX_SIDE: 64,
normalizeBrowserScreenshot: vi.fn(async (buffer: Buffer) => ({
buffer,
contentType: "image/png",
})),
}));
vi.mock("../../media/store.js", () => ({
ensureMediaDir: vi.fn(async () => {}),
saveMediaBuffer: vi.fn(async () => ({ path: "/tmp/fake.png" })),
}));
vi.mock("./agent.shared.js", () => ({
getPwAiModule: vi.fn(async () => null),
handleRouteError: vi.fn(
(
_ctx: unknown,
res: { status: (code: number) => unknown; json: (body: unknown) => void },
err: unknown,
) => {
const message = err instanceof Error ? err.message : String(err);
res.status(400);
res.json({ error: message });
},
),
readBody: vi.fn((req: BrowserRequest) => req.body ?? {}),
requirePwAi: vi.fn(async () => null),
resolveProfileContext: vi.fn(() => routeState.profileCtx),
withPlaywrightRouteContext: vi.fn(),
withRouteTabContext: vi.fn(),
}));
const { registerBrowserAgentSnapshotRoutes } = await import("./agent.snapshot.js");
function getSnapshotGetHandler() {
const { app, getHandlers } = createBrowserRouteApp();
registerBrowserAgentSnapshotRoutes(app, {
state: () => ({
resolved: {
extraArgs: [],
ssrfPolicy: { dangerouslyAllowPrivateNetwork: false },
},
}),
} as never);
const handler = getHandlers.get("/snapshot");
expect(handler).toBeTypeOf("function");
return handler;
}
describe("local-managed browser snapshot routes", () => {
beforeEach(() => {
routeState.profileCtx.ensureTabAvailable.mockClear();
cdpMocks.snapshotAria.mockClear();
cdpMocks.snapshotRoleViaCdp.mockClear();
navigationGuardMocks.assertBrowserNavigationResultAllowed.mockClear();
navigationGuardMocks.withBrowserNavigationPolicy.mockClear();
});
it("blocks ARIA CDP snapshots when the current tab violates browser navigation policy", async () => {
const handler = getSnapshotGetHandler();
const response = createBrowserRouteResponse();
await handler?.({ params: {}, query: { format: "aria" } }, response.res);
expect(response.statusCode).toBe(400);
expect(response.body).toEqual({ error: "browser navigation blocked by policy" });
expect(navigationGuardMocks.assertBrowserNavigationResultAllowed).toHaveBeenCalledWith({
url: "http://127.0.0.1:8080/admin",
ssrfPolicy: { dangerouslyAllowPrivateNetwork: false },
});
expect(cdpMocks.snapshotAria).not.toHaveBeenCalled();
});
it("blocks AI CDP role snapshots when the current tab violates browser navigation policy", async () => {
const handler = getSnapshotGetHandler();
const response = createBrowserRouteResponse();
await handler?.({ params: {}, query: { format: "ai", interactive: "true" } }, response.res);
expect(response.statusCode).toBe(400);
expect(response.body).toEqual({ error: "browser navigation blocked by policy" });
expect(navigationGuardMocks.assertBrowserNavigationResultAllowed).toHaveBeenCalledWith({
url: "http://127.0.0.1:8080/admin",
ssrfPolicy: { dangerouslyAllowPrivateNetwork: false },
});
expect(cdpMocks.snapshotRoleViaCdp).not.toHaveBeenCalled();
});
});

View File

@@ -546,12 +546,20 @@ export function registerBrowserAgentSnapshotRoutes(
const tab = await profileCtx.ensureTabAvailable(targetId || undefined);
const usesChromeMcp = getBrowserProfileCapabilities(profileCtx.profile).usesChromeMcp;
const ssrfPolicyOpts = browserNavigationPolicyForProfile(ctx, profileCtx);
let observedBrowserState: unknown;
if (!usesChromeMcp && pwModule) {
if ((plan.labels || plan.mode === "efficient") && plan.format === "aria") {
return jsonError(res, 400, "labels/mode=efficient require format=ai");
}
if (usesChromeMcp && (plan.selectorValue || plan.frameSelectorValue)) {
return jsonError(res, 400, EXISTING_SESSION_LIMITS.snapshot.snapshotSelector);
}
if (ssrfPolicyOpts.ssrfPolicy) {
await assertBrowserNavigationResultAllowed({
url: tab.url,
...ssrfPolicyOpts,
});
}
let observedBrowserState: unknown;
if (!usesChromeMcp && pwModule) {
observedBrowserState = await pwModule
.getObservedBrowserStateViaPlaywright({
cdpUrl: profileCtx.profile.cdpUrl,
@@ -560,19 +568,7 @@ export function registerBrowserAgentSnapshotRoutes(
})
.catch(() => undefined);
}
if ((plan.labels || plan.mode === "efficient") && plan.format === "aria") {
return jsonError(res, 400, "labels/mode=efficient require format=ai");
}
if (usesChromeMcp) {
if (plan.selectorValue || plan.frameSelectorValue) {
return jsonError(res, 400, EXISTING_SESSION_LIMITS.snapshot.snapshotSelector);
}
if (ssrfPolicyOpts.ssrfPolicy) {
await assertBrowserNavigationResultAllowed({
url: tab.url,
...ssrfPolicyOpts,
});
}
const snapshot = await takeChromeMcpSnapshot({
profileName: profileCtx.profile.name,
profile: profileCtx.profile,

View File

@@ -4,7 +4,7 @@ import {
withBrowserNavigationPolicy,
} from "../navigation-guard.js";
import type { BrowserRouteContext } from "../server-context.js";
import type { BrowserRequest } from "./types.js";
import type { BrowserRequest, BrowserResponse } from "./types.js";
export const existingSessionRouteState = {
profileCtx: {
@@ -32,7 +32,11 @@ export const existingSessionRouteState = {
export function createExistingSessionAgentSharedModule() {
return {
getPwAiModule: vi.fn(async () => null),
handleRouteError: vi.fn(),
handleRouteError: vi.fn((_ctx: BrowserRouteContext, res: BrowserResponse, err: unknown) => {
const message = err instanceof Error ? err.message : String(err);
res.status(400);
res.json({ error: message });
}),
readBody: vi.fn((req: BrowserRequest) => req.body ?? {}),
requirePwAi: vi.fn(async () => {
throw new Error("Playwright should not be used for existing-session tests");

View File

@@ -5,22 +5,6 @@ import { describe, expect, it } from "vitest";
import { normalizeBrowserScreenshot } from "./screenshot.js";
describe("browser screenshot normalization", () => {
const unavailableImageBackend = process.platform === "win32" ? "sips" : "windows-native";
async function withUnavailableImageBackend<T>(fn: () => Promise<T>): Promise<T> {
const previousBackend = process.env.OPENCLAW_IMAGE_BACKEND;
process.env.OPENCLAW_IMAGE_BACKEND = unavailableImageBackend;
try {
return await fn();
} finally {
if (previousBackend === undefined) {
delete process.env.OPENCLAW_IMAGE_BACKEND;
} else {
process.env.OPENCLAW_IMAGE_BACKEND = previousBackend;
}
}
}
it("shrinks oversized images to <=2000x2000 and <=5MB", async () => {
const bigPng = createSolidPngBuffer(2100, 2100, { r: 12, g: 34, b: 56 });
@@ -47,18 +31,4 @@ describe("browser screenshot normalization", () => {
expect(normalized.buffer.equals(jpeg)).toBe(true);
});
it("rejects screenshots above max side when no image processor is available", async () => {
const png = createSolidPngBuffer(420, 120, { r: 12, g: 34, b: 56 });
expect(png.byteLength).toBeLessThan(5 * 1024 * 1024);
await withUnavailableImageBackend(async () => {
await expect(
normalizeBrowserScreenshot(png, {
maxSide: 120,
maxBytes: 5 * 1024 * 1024,
}),
).rejects.toThrow(/image processor unavailable/i);
});
});
});

View File

@@ -0,0 +1,75 @@
import {
resolveStableChannelMessageIngress,
type StableChannelIngressIdentityParams,
} from "openclaw/plugin-sdk/channel-ingress-runtime";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { getClickClackRuntime } from "./runtime.js";
import type { ClickClackMessage, CoreConfig, ResolvedClickClackAccount } from "./types.js";
const CHANNEL_ID = "clickclack" as const;
function normalizeClickClackUserId(value: string): string | null {
const trimmed = value.trim();
if (!trimmed) {
return null;
}
const withoutProvider = trimmed.replace(/^(clickclack|cc):/i, "").trim();
const directTarget = withoutProvider.match(/^dm:(.+)$/i);
return directTarget?.[1]?.trim() || withoutProvider || null;
}
const clickClackIngressIdentity = {
key: "user-id",
normalizeEntry: normalizeClickClackUserId,
normalizeSubject: normalizeClickClackUserId,
isWildcardEntry: (entry) => normalizeClickClackUserId(entry) === "*",
entryIdPrefix: "clickclack-user",
} satisfies StableChannelIngressIdentityParams;
export type ClickClackInboundAccess = {
shouldDispatch: boolean;
commandAuthorized: boolean;
};
export async function resolveClickClackInboundAccess(params: {
account: ResolvedClickClackAccount;
config: CoreConfig;
message: ClickClackMessage;
}): Promise<ClickClackInboundAccess> {
const runtime = getClickClackRuntime();
const isDirect = Boolean(params.message.direct_conversation_id);
const cfg = params.config as OpenClawConfig;
const shouldCheckCommand = runtime.channel.commands.shouldComputeCommandAuthorized(
params.message.body,
cfg,
);
const resolved = await resolveStableChannelMessageIngress({
channelId: CHANNEL_ID,
accountId: params.account.accountId,
identity: clickClackIngressIdentity,
cfg,
subject: { stableId: params.message.author_id },
conversation: {
kind: isDirect ? "direct" : "group",
id: isDirect
? (params.message.direct_conversation_id ?? params.message.author_id)
: (params.message.channel_id ?? params.message.thread_root_id),
},
allowFrom: params.account.allowFrom,
dmPolicy: "allowlist",
groupPolicy: "allowlist",
command: shouldCheckCommand
? {
cfg,
modeWhenAccessGroupsOff: "configured",
}
: false,
});
return {
shouldDispatch: resolved.ingress.admission === "dispatch",
commandAuthorized: resolved.commandAccess.requested
? resolved.commandAccess.authorized
: resolved.senderAccess.allowed,
};
}

View File

@@ -19,9 +19,14 @@ const mocks = vi.hoisted(() => ({
thread: vi.fn(),
},
handleClickClackInbound: vi.fn(),
resolveClickClackInboundAccess: vi.fn(),
resolveWorkspaceId: vi.fn(),
}));
vi.mock("./access.js", () => ({
resolveClickClackInboundAccess: mocks.resolveClickClackInboundAccess,
}));
vi.mock("./http-client.js", () => ({
createClickClackClient: vi.fn(() => mocks.client),
}));
@@ -76,6 +81,10 @@ describe("ClickClack gateway", () => {
created_at: "2026-01-01T00:00:00.000Z",
});
mocks.client.events.mockResolvedValue([]);
mocks.resolveClickClackInboundAccess.mockResolvedValue({
shouldDispatch: true,
commandAuthorized: true,
});
mocks.resolveWorkspaceId.mockResolvedValue("workspace-1");
mocks.client.channelMessages.mockResolvedValue([
{
@@ -135,8 +144,47 @@ describe("ClickClack gateway", () => {
);
await vi.waitFor(() => expect(mocks.handleClickClackInbound).toHaveBeenCalledTimes(1));
expect(mocks.handleClickClackInbound.mock.calls[0]?.[0].access).toEqual({
shouldDispatch: true,
commandAuthorized: true,
});
abort.abort();
await run;
expect(runError).toBeUndefined();
});
it("drops messages denied by ClickClack sender access before inbound handling", async () => {
const socket = new FakeSocket();
mocks.client.websocket.mockReturnValue(socket);
mocks.resolveClickClackInboundAccess.mockResolvedValue({
shouldDispatch: false,
commandAuthorized: false,
});
const abort = new AbortController();
const ctx = createGatewayContext(abort.signal);
const run = startClickClackGatewayAccount(ctx);
await vi.waitFor(() => expect(mocks.client.websocket).toHaveBeenCalledTimes(1));
socket.emit(
"message",
Buffer.from(
JSON.stringify({
id: "evt-1",
cursor: "cursor-1",
type: "message.created",
workspace_id: "workspace-1",
channel_id: "chan-1",
seq: 2,
created_at: "2026-01-01T00:00:00.000Z",
payload: { message_id: "msg-1", author_id: "human-1" },
}),
),
);
await vi.waitFor(() => expect(mocks.resolveClickClackInboundAccess).toHaveBeenCalledTimes(1));
expect(mocks.handleClickClackInbound).not.toHaveBeenCalled();
abort.abort();
await run;
});
});

View File

@@ -1,5 +1,6 @@
import type { ChannelGatewayContext } from "openclaw/plugin-sdk/channel-contract";
import type { RawData } from "ws";
import { resolveClickClackInboundAccess } from "./access.js";
import { resolveClickClackAccount } from "./accounts.js";
import { createClickClackClient } from "./http-client.js";
import { handleClickClackInbound } from "./inbound.js";
@@ -93,7 +94,20 @@ async function processEvent(params: {
if (message.author?.kind === "bot") {
return;
}
await handleClickClackInbound({ account: params.account, config: params.config, message });
const access = await resolveClickClackInboundAccess({
account: params.account,
config: params.config,
message,
});
if (!access.shouldDispatch) {
return;
}
await handleClickClackInbound({
account: params.account,
config: params.config,
message,
access,
});
}
export async function startClickClackGatewayAccount(

View File

@@ -3,7 +3,7 @@ import type { PluginRuntime } from "openclaw/plugin-sdk/core";
import { describe, expect, it, vi } from "vitest";
import { handleClickClackInbound } from "./inbound.js";
import { setClickClackRuntime } from "./runtime.js";
import type { CoreConfig, ResolvedClickClackAccount } from "./types.js";
import type { ClickClackMessage, CoreConfig, ResolvedClickClackAccount } from "./types.js";
const sendClickClackTextMock = vi.hoisted(() => vi.fn());
@@ -72,6 +72,58 @@ function createRuntime(): PluginRuntime {
} as unknown as PluginRuntime);
}
function createAgentAccount(
overrides: Partial<ResolvedClickClackAccount> = {},
): ResolvedClickClackAccount {
const base = {
accountId: "default",
enabled: true,
configured: true,
baseUrl: "http://127.0.0.1:8080",
token: "ccb_default",
workspace: "wsp_1",
replyMode: "agent",
toolsAllow: [],
defaultTo: "channel:general",
allowFrom: ["*"],
reconnectMs: 1_500,
config: {
allowFrom: ["*"],
},
} satisfies ResolvedClickClackAccount;
return {
...base,
...overrides,
config: {
...base.config,
...overrides.config,
},
};
}
function createMessage(overrides: Partial<ClickClackMessage> = {}): ClickClackMessage {
return {
id: "msg_1",
workspace_id: "wsp_1",
channel_id: "chn_1",
author_id: "usr_owner",
thread_root_id: "msg_1",
body: "/fast on",
body_format: "markdown",
created_at: "2026-05-09T12:00:00.000Z",
author: {
id: "usr_owner",
kind: "human",
display_name: "Peter",
handle: "steipete",
avatar_url: "",
created_at: "2026-05-09T12:00:00.000Z",
},
...overrides,
};
}
describe("handleClickClackInbound", () => {
it("runs model-mode bot accounts without tools and posts the bot reply", async () => {
sendClickClackTextMock.mockReset();
@@ -139,4 +191,95 @@ describe("handleClickClackInbound", () => {
expect(sendRequest?.text).toBe("service bot online");
expect(sendRequest?.replyToId).toBe("msg_1");
});
it("marks agent turns command-authorized for allowlisted senders", async () => {
const runtime = createRuntime();
vi.mocked(runtime.channel.commands.shouldComputeCommandAuthorized).mockReturnValue(true);
setClickClackRuntime(runtime);
const cfg = {
agents: {
defaults: {
model: "openai/gpt-5.4-mini",
},
},
} satisfies CoreConfig;
await handleClickClackInbound({
account: createAgentAccount({
allowFrom: ["usr_owner"],
config: { allowFrom: ["usr_owner"] },
}),
config: cfg,
message: createMessage(),
});
const runPrepared = vi.mocked(runtime.channel.turn.runPrepared);
expect(runPrepared).toHaveBeenCalledTimes(1);
expect(runPrepared.mock.calls[0]?.[0].ctxPayload.CommandAuthorized).toBe(true);
});
it("accepts ClickClack DM target syntax in allowFrom", async () => {
const runtime = createRuntime();
vi.mocked(runtime.channel.commands.shouldComputeCommandAuthorized).mockReturnValue(true);
setClickClackRuntime(runtime);
const cfg = {
agents: {
defaults: {
model: "openai/gpt-5.4-mini",
},
},
} satisfies CoreConfig;
await handleClickClackInbound({
account: createAgentAccount({
allowFrom: ["dm:usr_owner"],
config: { allowFrom: ["dm:usr_owner"] },
}),
config: cfg,
message: createMessage({
channel_id: undefined,
direct_conversation_id: "dcn_1",
}),
});
const runPrepared = vi.mocked(runtime.channel.turn.runPrepared);
expect(runPrepared).toHaveBeenCalledTimes(1);
expect(runPrepared.mock.calls[0]?.[0].ctxPayload.ChatType).toBe("direct");
expect(runPrepared.mock.calls[0]?.[0].ctxPayload.CommandAuthorized).toBe(true);
});
it("does not dispatch agent turns from senders outside allowFrom", async () => {
const runtime = createRuntime();
vi.mocked(runtime.channel.commands.shouldComputeCommandAuthorized).mockReturnValue(true);
setClickClackRuntime(runtime);
const cfg = {
agents: {
defaults: {
model: "openai/gpt-5.4-mini",
},
},
} satisfies CoreConfig;
await handleClickClackInbound({
account: createAgentAccount({
allowFrom: ["usr_owner"],
config: { allowFrom: ["usr_owner"] },
}),
config: cfg,
message: createMessage({
author_id: "usr_attacker",
author: {
id: "usr_attacker",
kind: "human",
display_name: "Attacker",
handle: "attacker",
avatar_url: "",
created_at: "2026-05-09T12:00:00.000Z",
},
}),
});
expect(runtime.channel.turn.runPrepared).not.toHaveBeenCalled();
expect(runtime.channel.reply.dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
});
});

View File

@@ -1,5 +1,6 @@
import { createChannelMessageReplyPipeline } from "openclaw/plugin-sdk/channel-message";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { resolveClickClackInboundAccess, type ClickClackInboundAccess } from "./access.js";
import { sendClickClackText } from "./outbound.js";
import { getClickClackRuntime } from "./runtime.js";
import { buildClickClackTarget } from "./target.js";
@@ -81,9 +82,20 @@ export async function handleClickClackInbound(params: {
account: ResolvedClickClackAccount;
config: CoreConfig;
message: ClickClackMessage;
access?: ClickClackInboundAccess;
}) {
const runtime = getClickClackRuntime();
const message = params.message;
const access =
params.access ??
(await resolveClickClackInboundAccess({
account: params.account,
config: params.config,
message,
}));
if (!access.shouldDispatch) {
return;
}
const isDirect = Boolean(message.direct_conversation_id);
const target = buildClickClackTarget(
isDirect
@@ -150,7 +162,7 @@ export async function handleClickClackInbound(params: {
Timestamp: message.created_at,
OriginatingChannel: CHANNEL_ID,
OriginatingTo: target,
CommandAuthorized: true,
CommandAuthorized: access.commandAuthorized,
});
const { onModelSelected, ...replyPipeline } = createChannelMessageReplyPipeline({
cfg: params.config as OpenClawConfig,

View File

@@ -9,7 +9,7 @@
"version": "2026.5.26",
"dependencies": {
"@earendil-works/pi-coding-agent": "0.75.5",
"@openai/codex": "0.133.0",
"@openai/codex": "0.134.0",
"typebox": "1.1.38",
"ws": "8.21.0",
"zod": "4.4.3"
@@ -274,23 +274,6 @@
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/credential-provider-sso/node_modules/@aws-sdk/token-providers": {
"version": "3.1052.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1052.0.tgz",
"integrity": "sha512-QqZNB3so7UIDxZtroc85TQaLVxdZRFm0eWM1CSR2N+b06as9TOrilvrlTZuj3guYlxMs6yLOgGxnklJ5qMYtTw==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.974.13",
"@aws-sdk/nested-clients": "^3.997.11",
"@aws-sdk/types": "^3.973.9",
"@smithy/core": "^3.24.3",
"@smithy/types": "^4.14.2",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/credential-provider-web-identity": {
"version": "3.972.43",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.43.tgz",
@@ -781,9 +764,9 @@
"license": "MIT"
},
"node_modules/@openai/codex": {
"version": "0.133.0",
"resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.133.0.tgz",
"integrity": "sha512-Gh42kLLBo/6gpnHmDzUWDVvyS57ekCB1+1Dz0RG2oIl3Lhk1uwrjSj/PwaJWWh4Rw/rUp1RqkwrMugFfFEOlqQ==",
"version": "0.134.0",
"resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.134.0.tgz",
"integrity": "sha512-N0vmdTXl/rglZjgd3PaMe9oRrqjO6zZ//uAvUhCDRnJNAUT3LrpYvCK3y9B/ev7QcChfXR43IGUh3ssqWRvMmA==",
"license": "Apache-2.0",
"bin": {
"codex": "bin/codex.js"
@@ -792,19 +775,19 @@
"node": ">=16"
},
"optionalDependencies": {
"@openai/codex-darwin-arm64": "npm:@openai/codex@0.133.0-darwin-arm64",
"@openai/codex-darwin-x64": "npm:@openai/codex@0.133.0-darwin-x64",
"@openai/codex-linux-arm64": "npm:@openai/codex@0.133.0-linux-arm64",
"@openai/codex-linux-x64": "npm:@openai/codex@0.133.0-linux-x64",
"@openai/codex-win32-arm64": "npm:@openai/codex@0.133.0-win32-arm64",
"@openai/codex-win32-x64": "npm:@openai/codex@0.133.0-win32-x64"
"@openai/codex-darwin-arm64": "npm:@openai/codex@0.134.0-darwin-arm64",
"@openai/codex-darwin-x64": "npm:@openai/codex@0.134.0-darwin-x64",
"@openai/codex-linux-arm64": "npm:@openai/codex@0.134.0-linux-arm64",
"@openai/codex-linux-x64": "npm:@openai/codex@0.134.0-linux-x64",
"@openai/codex-win32-arm64": "npm:@openai/codex@0.134.0-win32-arm64",
"@openai/codex-win32-x64": "npm:@openai/codex@0.134.0-win32-x64"
}
},
"node_modules/@openai/codex-darwin-arm64": {
"name": "@openai/codex",
"version": "0.133.0-darwin-arm64",
"resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.133.0-darwin-arm64.tgz",
"integrity": "sha512-W7f8+DckLujnqGlptKCzgJU+ooeHKMuk6KYgMFP6A9asn7YUsGUgJqjiBaX8oNcXO6w/pTbKGRARx1kCNS8lIg==",
"version": "0.134.0-darwin-arm64",
"resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.134.0-darwin-arm64.tgz",
"integrity": "sha512-pOxwQjb1HHtY6KG66+g/rX7uP4yBvchfCrQw22ddYy64s7fJqnD6UV/Ur60j6MWXt71jcaWLEkV1pJthQy9CFQ==",
"cpu": [
"arm64"
],
@@ -819,9 +802,9 @@
},
"node_modules/@openai/codex-darwin-x64": {
"name": "@openai/codex",
"version": "0.133.0-darwin-x64",
"resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.133.0-darwin-x64.tgz",
"integrity": "sha512-Ek8ikvLOiXZ8emcIJVBXxK6fm8ratBy0kaEt3JNisTNszxGshUHf/R4xxDxIyKNcUkYYXjW7A/rMwW3iu3OFlg==",
"version": "0.134.0-darwin-x64",
"resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.134.0-darwin-x64.tgz",
"integrity": "sha512-XjRtq8PB9dtpxQ5QU6TrzR/z8EVlLLk55oonsyRp3VkMOsKjJWXvLxAnmzUm1MuVZqz90Ua7CJbr+8BG+ZUWpA==",
"cpu": [
"x64"
],
@@ -836,9 +819,9 @@
},
"node_modules/@openai/codex-linux-arm64": {
"name": "@openai/codex",
"version": "0.133.0-linux-arm64",
"resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.133.0-linux-arm64.tgz",
"integrity": "sha512-uKXYYSJ3mY16sp4hcG/4BMNRjva/ZS4oARiI1+7k8+NiuoAhdCGWNe5u4KJ3sMuL3tp/IXcmc6B56EFX1+WDBQ==",
"version": "0.134.0-linux-arm64",
"resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.134.0-linux-arm64.tgz",
"integrity": "sha512-fqI8iClQGvrANFx/dJwZK8KNQlqlQKo7A/UB5G7IaeTAAJ+y/CG2R33Bbd9GboH/8ormY39ureNk27eqt++51g==",
"cpu": [
"arm64"
],
@@ -853,9 +836,9 @@
},
"node_modules/@openai/codex-linux-x64": {
"name": "@openai/codex",
"version": "0.133.0-linux-x64",
"resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.133.0-linux-x64.tgz",
"integrity": "sha512-9YfyqrfUj/UZ2+aXE4zBz47t6RXbVni95ZorGsNh857vxYK/asVpUtR2cymo9lB3JaI4mQaKFfV/t7IRItqkuA==",
"version": "0.134.0-linux-x64",
"resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.134.0-linux-x64.tgz",
"integrity": "sha512-d/o1AVAniQU2oSEq7ZV0hVwzmk6Dj2IWeNPLnX/KXyv1DfIMJbY+qEg/xhfRmuVW4VPhrhQLBITwrkAYviy1MA==",
"cpu": [
"x64"
],
@@ -870,9 +853,9 @@
},
"node_modules/@openai/codex-win32-arm64": {
"name": "@openai/codex",
"version": "0.133.0-win32-arm64",
"resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.133.0-win32-arm64.tgz",
"integrity": "sha512-mRzND0PSGHRoLk0X41GTSoc3tFjZSF4HgDlfjU5fiQcWVi0/kLb7Ku6/tPFT/X2hOLa3YdJkbIcHC0Hc9ni80g==",
"version": "0.134.0-win32-arm64",
"resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.134.0-win32-arm64.tgz",
"integrity": "sha512-8OdRmbCcyLLMF3Bg6945PW6INZ7bZVygYo2lusnC0Q2KZ3MRYrMnXRJ6mfvkDc8kPpFE+djMFnQ70gt/jBLVCA==",
"cpu": [
"arm64"
],
@@ -887,9 +870,9 @@
},
"node_modules/@openai/codex-win32-x64": {
"name": "@openai/codex",
"version": "0.133.0-win32-x64",
"resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.133.0-win32-x64.tgz",
"integrity": "sha512-u3ji78DIPZCGJeELuovsAnaZH+vK9gsA4F6M1y+Uy2s80Sz7/i1S0KL81qGReYji3urSjgBpkQuNP47GXOqxrQ==",
"version": "0.134.0-win32-x64",
"resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.134.0-win32-x64.tgz",
"integrity": "sha512-hW1omBcN1jKeVUVnTqWlpc42nF2qAwCEN6l1IFeKFJegYoZ39YrE7pdh56gAmaZTyT5Eexx7cgNpaEK3JElxvA==",
"cpu": [
"x64"
],
@@ -963,12 +946,12 @@
}
},
"node_modules/@smithy/node-http-handler": {
"version": "4.7.3",
"resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.7.3.tgz",
"integrity": "sha512-/jPhevcTFPMVl6KNjbaI47iOg1zxC7IsnX4PQDGVZKMFceOXtB8IEYaB7a9VvkP/3oC60WzTeKocvSI7vLT0vA==",
"version": "4.7.4",
"resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.7.4.tgz",
"integrity": "sha512-HIeF+1vrDGzPkkv39Hj2vlHSXHY3p958jd/8ZnePIY6+ZOsQX8coyEUKO5yQu4r0bQIVsbpotVIrXXwyycMStQ==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/core": "^3.24.3",
"@smithy/core": "^3.24.4",
"@smithy/types": "^4.14.2",
"tslib": "^2.6.2"
},

View File

@@ -9,7 +9,7 @@
"type": "module",
"dependencies": {
"@earendil-works/pi-coding-agent": "0.75.5",
"@openai/codex": "0.133.0",
"@openai/codex": "0.134.0",
"typebox": "1.1.38",
"ws": "8.21.0",
"zod": "4.4.3"

View File

@@ -311,6 +311,7 @@ describe("Codex app-server approval bridge", () => {
turnId: "turn-1",
nativeHookRelay: {
relayId: "relay-1",
generation: "generation-1",
allowedEvents: ["pre_tool_use"],
},
});
@@ -321,6 +322,7 @@ describe("Codex app-server approval bridge", () => {
expect(mockInvokeNativeHookRelay).toHaveBeenCalledWith({
provider: "codex",
relayId: "relay-1",
generation: "generation-1",
event: "pre_tool_use",
rawPayload: {
hook_event_name: "PreToolUse",
@@ -342,6 +344,7 @@ describe("Codex app-server approval bridge", () => {
cmd: "cat /tmp/private_key",
},
},
requireGeneration: true,
});
findApprovalEvent(params, {
status: "denied",
@@ -374,6 +377,7 @@ describe("Codex app-server approval bridge", () => {
turnId: "turn-1",
nativeHookRelay: {
relayId: "relay-1",
generation: "generation-1",
allowedEvents: ["pre_tool_use"],
},
});
@@ -416,6 +420,7 @@ describe("Codex app-server approval bridge", () => {
turnId: "turn-1",
nativeHookRelay: {
relayId: "relay-1",
generation: "generation-1",
allowedEvents: ["pre_tool_use"],
},
});
@@ -455,6 +460,7 @@ describe("Codex app-server approval bridge", () => {
turnId: "turn-1",
nativeHookRelay: {
relayId: "relay-1",
generation: "generation-1",
allowedEvents: ["pre_tool_use"],
},
});
@@ -498,6 +504,7 @@ describe("Codex app-server approval bridge", () => {
turnId: "turn-1",
nativeHookRelay: {
relayId: "relay-1",
generation: "generation-1",
allowedEvents: ["pre_tool_use"],
},
});
@@ -534,6 +541,7 @@ describe("Codex app-server approval bridge", () => {
turnId: "turn-1",
nativeHookRelay: {
relayId: "relay-1",
generation: "generation-1",
allowedEvents: ["pre_tool_use"],
},
});
@@ -565,6 +573,7 @@ describe("Codex app-server approval bridge", () => {
turnId: "turn-1",
nativeHookRelay: {
relayId: "relay-missing",
generation: "generation-1",
allowedEvents: ["pre_tool_use"],
},
});
@@ -589,6 +598,7 @@ describe("Codex app-server approval bridge", () => {
.mockResolvedValueOnce({ id: "plugin:permission-approval", decision: "deny" });
const nativeHookRelay = {
relayId: "relay-1",
generation: "generation-1",
allowedEvents: ["pre_tool_use" as const],
};

View File

@@ -59,7 +59,10 @@ export async function handleCodexAppServerApprovalRequest(params: {
paramsForRun: EmbeddedRunAttemptParams;
threadId: string;
turnId: string;
nativeHookRelay?: Pick<NativeHookRelayRegistrationHandle, "allowedEvents" | "relayId">;
nativeHookRelay?: Pick<
NativeHookRelayRegistrationHandle,
"allowedEvents" | "generation" | "relayId"
>;
autoApprove?: boolean;
signal?: AbortSignal;
}): Promise<JsonValue | undefined> {
@@ -316,7 +319,10 @@ async function runOpenClawToolPolicyForApprovalRequest(params: {
requestParams: JsonObject | undefined;
paramsForRun: EmbeddedRunAttemptParams;
context: ApprovalContext;
nativeHookRelay?: Pick<NativeHookRelayRegistrationHandle, "allowedEvents" | "relayId">;
nativeHookRelay?: Pick<
NativeHookRelayRegistrationHandle,
"allowedEvents" | "generation" | "relayId"
>;
signal?: AbortSignal;
}): Promise<ApprovalPolicyOutcome | undefined> {
const policyRequest = buildOpenClawToolPolicyRequest(params.method, params.requestParams);
@@ -379,7 +385,10 @@ async function runNativeRelayToolPolicyForApprovalRequest(params: {
requestParams: JsonObject | undefined;
context: ApprovalContext;
policyRequest: { toolName: string; params: JsonObject };
nativeHookRelay?: Pick<NativeHookRelayRegistrationHandle, "allowedEvents" | "relayId">;
nativeHookRelay?: Pick<
NativeHookRelayRegistrationHandle,
"allowedEvents" | "generation" | "relayId"
>;
cwd?: string;
}): Promise<
| {
@@ -423,8 +432,10 @@ async function runNativeRelayToolPolicyForApprovalRequest(params: {
const response = await invokeNativeHookRelay({
provider: "codex",
relayId: params.nativeHookRelay.relayId,
generation: params.nativeHookRelay.generation,
event: "pre_tool_use",
rawPayload: payload,
requireGeneration: true,
});
const decision = readNativeRelayPreToolUseDecision(response);
if (decision.blocked) {

View File

@@ -131,6 +131,16 @@ function resolveCodexAppServerAuthProfileStore(params: {
authProfileStore?: AuthProfileStore;
config?: AuthProfileOrderConfig;
}): AuthProfileStore {
if (params.authProfileStore) {
const providedProfileId = resolveCodexAppServerAuthProfileId({
authProfileId: params.authProfileId,
store: params.authProfileStore,
config: params.config,
});
if (providedProfileId && params.authProfileStore.profiles[providedProfileId]) {
return params.authProfileStore;
}
}
const overlaidStore = ensureCodexAppServerAuthProfileStore({
agentDir: params.agentDir,
authProfileId: params.authProfileId,

View File

@@ -22,3 +22,13 @@ export const defaultCodexAppServerClientFactory: CodexAppServerClientFactory = (
import("./shared-client.js").then(({ getSharedCodexAppServerClient }) =>
getSharedCodexAppServerClient({ startOptions, authProfileId, agentDir, config }),
);
export const defaultLeasedCodexAppServerClientFactory: CodexAppServerClientFactory = (
startOptions,
authProfileId,
agentDir,
config,
) =>
import("./shared-client.js").then(({ getLeasedSharedCodexAppServerClient }) =>
getLeasedSharedCodexAppServerClient({ startOptions, authProfileId, agentDir, config }),
);

View File

@@ -54,6 +54,7 @@ function startCompaction(sessionFile: string, options: { currentTokenCount?: num
sessionKey: "agent:main:session-1",
sessionFile,
workspaceDir: tempDir,
trigger: "manual",
...options,
});
}
@@ -64,6 +65,7 @@ function startSandboxedCompaction(sessionFile: string) {
sessionKey: "agent:main:session-1",
sessionFile,
workspaceDir: tempDir,
trigger: "manual",
config: { agents: { defaults: { sandbox: { mode: "all" } } } },
});
}
@@ -74,6 +76,7 @@ function startNodeExecCompaction(sessionFile: string) {
sessionKey: "agent:main:session-1",
sessionFile,
workspaceDir: tempDir,
trigger: "manual",
config: { tools: { exec: { host: "node", node: "worker-1" } } },
});
}
@@ -123,6 +126,63 @@ describe("maybeCompactCodexAppServerSession", () => {
expect(details.pending).toBe(true);
});
it("skips native app-server compaction for automatic budget triggers", async () => {
const fake = createFakeCodexClient();
setCodexAppServerClientFactoryForTest(async () => fake.client);
const sessionFile = await writeTestBinding();
const result = requireCompactResult(
await maybeCompactCodexAppServerSession({
sessionId: "session-1",
sessionKey: "agent:main:session-1",
sessionFile,
workspaceDir: tempDir,
trigger: "budget",
currentTokenCount: 456,
}),
);
expect(fake.request).not.toHaveBeenCalled();
expect(result.ok).toBe(true);
expect(result.compacted).toBe(false);
expect(result.reason).toBe("codex app-server owns automatic compaction");
expect(result.result?.tokensBefore).toBe(456);
expect(compactDetails(result)).toMatchObject({
backend: "codex-app-server",
skipped: true,
reason: "non_manual_trigger",
trigger: "budget",
});
});
it("skips native app-server compaction when trigger is omitted", async () => {
const fake = createFakeCodexClient();
setCodexAppServerClientFactoryForTest(async () => fake.client);
const sessionFile = await writeTestBinding();
const result = requireCompactResult(
await maybeCompactCodexAppServerSession({
sessionId: "session-1",
sessionKey: "agent:main:session-1",
sessionFile,
workspaceDir: tempDir,
currentTokenCount: 789,
}),
);
expect(fake.request).not.toHaveBeenCalled();
expect(result.ok).toBe(true);
expect(result.compacted).toBe(false);
expect(result.reason).toBe("codex app-server owns automatic compaction");
expect(result.result?.tokensBefore).toBe(789);
expect(compactDetails(result)).toMatchObject({
backend: "codex-app-server",
skipped: true,
reason: "non_manual_trigger",
trigger: "unknown",
});
});
it("blocks native app-server compaction when the current OpenClaw session is sandboxed", async () => {
const fake = createFakeCodexClient();
setCodexAppServerClientFactoryForTest(async () => fake.client);
@@ -278,6 +338,7 @@ describe("maybeCompactCodexAppServerSession", () => {
sessionKey: "agent:main:session-1",
sessionFile,
workspaceDir: tempDir,
trigger: "manual",
config: {
agents: {
defaults: {
@@ -313,6 +374,7 @@ describe("maybeCompactCodexAppServerSession", () => {
sessionKey: "agent:sara:session-1",
sessionFile,
workspaceDir: tempDir,
trigger: "manual",
config: {
agents: {
list: [
@@ -354,6 +416,7 @@ describe("maybeCompactCodexAppServerSession", () => {
sessionKey: "agent:nik:session-1",
sessionFile,
workspaceDir: tempDir,
trigger: "manual",
config: {
agents: {
defaults: {
@@ -402,6 +465,7 @@ describe("maybeCompactCodexAppServerSession", () => {
sessionKey: "agent:lossless:session-1",
sessionFile,
workspaceDir: tempDir,
trigger: "manual",
contextEngine,
config: {
plugins: {
@@ -455,6 +519,7 @@ describe("maybeCompactCodexAppServerSession", () => {
sessionKey: "agent:lossless-child:session-1",
sessionFile,
workspaceDir: tempDir,
trigger: "manual",
contextEngine,
config: {
plugins: {
@@ -511,6 +576,7 @@ describe("maybeCompactCodexAppServerSession", () => {
sessionKey: "agent:main:session-1",
sessionFile,
workspaceDir: tempDir,
trigger: "manual",
authProfileId: "openai-codex:runtime",
});

View File

@@ -122,6 +122,29 @@ async function compactCodexNativeThread(
params: CompactEmbeddedPiSessionParams,
options: { pluginConfig?: unknown; clientFactory?: CodexAppServerClientFactory } = {},
): Promise<EmbeddedPiCompactResult | undefined> {
if (params.trigger !== "manual") {
embeddedAgentLog.info("skipping codex app-server compaction for non-manual trigger", {
sessionId: params.sessionId,
sessionKey: params.sessionKey,
trigger: params.trigger,
});
return {
ok: true,
compacted: false,
reason: "codex app-server owns automatic compaction",
result: {
summary: "",
firstKeptEntryId: "",
tokensBefore: params.currentTokenCount ?? 0,
details: {
backend: "codex-app-server",
skipped: true,
reason: "non_manual_trigger",
trigger: params.trigger ?? "unknown",
},
},
};
}
const nativeExecutionBlock = resolveCodexNativeExecutionBlock({
config: params.config,
sessionKey: params.sandboxSessionKey ?? params.sessionKey,

View File

@@ -2,6 +2,7 @@ import type { AgentToolResult } from "@earendil-works/pi-agent-core";
import type { AnyAgentTool } from "openclaw/plugin-sdk/agent-harness";
import {
HEARTBEAT_RESPONSE_TOOL_NAME,
embeddedAgentLog,
wrapToolWithBeforeToolCallHook,
} from "openclaw/plugin-sdk/agent-harness-runtime";
import {
@@ -257,6 +258,90 @@ describe("createCodexDynamicToolBridge", () => {
expect(heartbeatExecute).not.toHaveBeenCalled();
});
it("keeps available and registered schemas paired with their tools", () => {
const bridge = createCodexDynamicToolBridge({
tools: [
createTool({
name: "message",
parameters: {
type: "object",
properties: { current: { type: "string" } },
},
}),
],
registeredTools: [
createTool({
name: "message",
parameters: {
type: "object",
properties: { durable: { type: "string" } },
},
}),
],
signal: new AbortController().signal,
});
expect(bridge.availableSpecs[0]?.inputSchema).toEqual({
type: "object",
properties: { current: { type: "string" } },
});
expect(bridge.specs[0]?.inputSchema).toEqual({
type: "object",
properties: { durable: { type: "string" } },
});
});
it("quarantines dynamic tools with unsupported input schemas", async () => {
const warn = vi.spyOn(embeddedAgentLog, "warn").mockImplementation(() => undefined);
const badExecute = vi.fn();
const bridge = createCodexDynamicToolBridge({
tools: [
createTool({ name: "message" }),
createTool({
name: "dofbot_move_angles",
parameters: { type: "array", items: { type: "number" } },
execute: badExecute,
}),
],
signal: new AbortController().signal,
});
expect(bridge.availableSpecs.map((tool) => tool.name)).toEqual(["message"]);
expect(bridge.specs.map((tool) => tool.name)).toEqual(["message"]);
expect(bridge.telemetry.quarantinedTools).toEqual([
{
tool: "dofbot_move_angles",
violations: ['dofbot_move_angles.inputSchema.type must be "object"'],
},
]);
expect(warn).toHaveBeenCalledWith(
expect.stringContaining("dofbot_move_angles"),
expect.objectContaining({
tools: [
{
tool: "dofbot_move_angles",
violations: ['dofbot_move_angles.inputSchema.type must be "object"'],
},
],
}),
);
const result = await bridge.handleToolCall({
threadId: "thread-1",
turnId: "turn-1",
callId: "call-1",
namespace: null,
tool: "dofbot_move_angles",
arguments: {},
});
expect(result).toEqual({
success: false,
contentItems: [{ type: "inputText", text: "Unknown OpenClaw tool: dofbot_move_angles" }],
});
expect(badExecute).not.toHaveBeenCalled();
});
it("can expose all dynamic tools directly for compatibility", () => {
const bridge = createCodexDynamicToolBridge({
tools: [createTool({ name: "web_search" }), createTool({ name: "message" })],

View File

@@ -6,11 +6,13 @@ import {
extractToolResultMediaArtifact,
filterToolResultMediaUrls,
HEARTBEAT_RESPONSE_TOOL_NAME,
embeddedAgentLog,
type EmbeddedRunAttemptParams,
isToolWrappedWithBeforeToolCallHook,
isMessagingTool,
isMessagingToolSendAction,
normalizeHeartbeatToolResponse,
projectRuntimeToolInputSchema,
runAgentHarnessAfterToolCallHook,
setBeforeToolCallDiagnosticsEnabled,
type AnyAgentTool,
@@ -46,6 +48,16 @@ type CodexDynamicToolHookContext = {
type CodexToolResultHookContext = Omit<CodexDynamicToolHookContext, "config">;
type ProjectedCodexDynamicTool = {
tool: AnyAgentTool;
inputSchema: JsonValue;
};
type CodexDynamicToolSchemaQuarantine = {
tool: string;
violations: readonly string[];
};
export type CodexDynamicToolBridge = {
availableSpecs: CodexDynamicToolSpec[];
specs: CodexDynamicToolSpec[];
@@ -63,6 +75,7 @@ export type CodexDynamicToolBridge = {
toolMediaUrls: string[];
toolAudioAsVoice: boolean;
successfulCronAdds?: number;
quarantinedTools: CodexDynamicToolSchemaQuarantine[];
};
};
@@ -83,16 +96,28 @@ export function createCodexDynamicToolBridge(params: {
}): CodexDynamicToolBridge {
const toolResultHookContext = toToolResultHookContext(params.hookContext);
const toolResultMaxChars = resolveCodexDynamicToolResultMaxChars(params.hookContext);
const tools = params.tools.map((tool) => {
const availableProjection = projectCodexDynamicTools(params.tools);
const registeredProjection = params.registeredTools
? projectCodexDynamicTools(params.registeredTools)
: availableProjection;
const availableTools = availableProjection.tools.map(({ tool, inputSchema }) => {
if (isToolWrappedWithBeforeToolCallHook(tool)) {
setBeforeToolCallDiagnosticsEnabled(tool, false);
return tool;
return { tool, inputSchema };
}
return wrapToolWithBeforeToolCallHook(tool, params.hookContext, { emitDiagnostics: false });
return {
tool: wrapToolWithBeforeToolCallHook(tool, params.hookContext, { emitDiagnostics: false }),
inputSchema,
};
});
const toolMap = new Map(tools.map((tool) => [tool.name, tool]));
const registeredTools = params.registeredTools ?? tools;
const toolMap = new Map(availableTools.map(({ tool }) => [tool.name, tool]));
const registeredTools = registeredProjection.tools.map(({ tool }) => tool);
const registeredToolNames = new Set(registeredTools.map((tool) => tool.name));
const quarantinedTools = dedupeQuarantinedDynamicTools([
...availableProjection.quarantinedTools,
...registeredProjection.quarantinedTools,
]);
warnQuarantinedDynamicTools(quarantinedTools);
const telemetry: CodexDynamicToolBridge["telemetry"] = {
didSendViaMessagingTool: false,
messagingToolSentTexts: [],
@@ -101,6 +126,7 @@ export function createCodexDynamicToolBridge(params: {
messagingToolSourceReplyPayloads: [],
toolMediaUrls: [],
toolAudioAsVoice: false,
quarantinedTools,
};
const middlewareRunner = createAgentToolResultMiddlewareRunner({
runtime: "codex",
@@ -114,16 +140,18 @@ export function createCodexDynamicToolBridge(params: {
]);
return {
availableSpecs: tools.map((tool) =>
availableSpecs: availableTools.map(({ tool, inputSchema }) =>
createCodexDynamicToolSpec({
tool,
inputSchema,
loading: params.loading ?? "searchable",
directToolNames,
}),
),
specs: registeredTools.map((tool) =>
specs: registeredProjection.tools.map(({ tool, inputSchema }) =>
createCodexDynamicToolSpec({
tool,
inputSchema,
loading: params.loading ?? "searchable",
directToolNames,
}),
@@ -257,13 +285,14 @@ export function createCodexDynamicToolBridge(params: {
function createCodexDynamicToolSpec(params: {
tool: AnyAgentTool;
inputSchema: JsonValue;
loading: CodexDynamicToolsLoading;
directToolNames: ReadonlySet<string>;
}): CodexDynamicToolSpec {
const base = {
name: params.tool.name,
description: params.tool.description,
inputSchema: toJsonValue(params.tool.parameters),
inputSchema: params.inputSchema,
};
if (params.loading === "direct" || params.directToolNames.has(params.tool.name)) {
return base;
@@ -274,6 +303,55 @@ function createCodexDynamicToolSpec(params: {
deferLoading: true,
};
}
function projectCodexDynamicTools(tools: readonly AnyAgentTool[]): {
tools: ProjectedCodexDynamicTool[];
quarantinedTools: CodexDynamicToolSchemaQuarantine[];
} {
const projectedTools: ProjectedCodexDynamicTool[] = [];
const quarantinedTools: CodexDynamicToolSchemaQuarantine[] = [];
for (const tool of tools) {
const projection = projectRuntimeToolInputSchema(tool.parameters, `${tool.name}.inputSchema`);
if (projection.violations.length > 0) {
quarantinedTools.push({ tool: tool.name, violations: projection.violations });
continue;
}
projectedTools.push({ tool, inputSchema: projection.schema as JsonValue });
}
return { tools: projectedTools, quarantinedTools };
}
function warnQuarantinedDynamicTools(tools: readonly CodexDynamicToolSchemaQuarantine[]): void {
if (tools.length === 0) {
return;
}
const unique = new Map<string, readonly string[]>();
for (const tool of tools) {
unique.set(tool.tool, tool.violations);
}
embeddedAgentLog.warn(
`codex app-server quarantined ${unique.size} dynamic ${unique.size === 1 ? "tool" : "tools"} with unsupported input schemas: ${[...unique.keys()].join(", ")}`,
{
tools: [...unique.entries()].map(([tool, violations]) => ({ tool, violations })),
},
);
}
function dedupeQuarantinedDynamicTools(
tools: readonly CodexDynamicToolSchemaQuarantine[],
): CodexDynamicToolSchemaQuarantine[] {
return [
...new Map(
tools.map((tool) => [
tool.tool,
{
tool: tool.tool,
violations: tool.violations,
},
]),
).values(),
];
}
function toToolResultHookContext(
ctx: CodexDynamicToolHookContext | undefined,
): CodexToolResultHookContext {
@@ -634,18 +712,6 @@ function convertToolContent(
];
}
function toJsonValue(value: unknown): JsonValue {
try {
const text = JSON.stringify(value);
if (!text) {
return {};
}
return JSON.parse(text) as JsonValue;
} catch {
return {};
}
}
function jsonObjectToRecord(value: JsonValue | undefined): Record<string, unknown> {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return {};

View File

@@ -6,6 +6,7 @@ const mocks = vi.hoisted(() => {
const authBridge = {
applyAuthProfile: vi.fn(async () => undefined),
authProfileId: vi.fn((params?: { authProfileId?: string }) => params?.authProfileId),
fallbackApiKeyCacheKey: vi.fn(() => undefined),
startOptions: vi.fn(async ({ startOptions }) => startOptions),
};
const managedBinary = {
@@ -20,6 +21,7 @@ const mocks = vi.hoisted(() => {
vi.mock("./auth-bridge.js", () => ({
applyCodexAppServerAuthProfile: mocks.authBridge.applyAuthProfile,
bridgeCodexAppServerStartOptions: mocks.authBridge.startOptions,
resolveCodexAppServerFallbackApiKeyCacheKey: mocks.authBridge.fallbackApiKeyCacheKey,
resolveCodexAppServerAuthProfileIdForAgent: mocks.authBridge.authProfileId,
}));
@@ -50,6 +52,8 @@ describe("listCodexAppServerModels", () => {
mocks.authBridge.authProfileId.mockImplementation(
(params?: { authProfileId?: string }) => params?.authProfileId,
);
mocks.authBridge.fallbackApiKeyCacheKey.mockClear();
mocks.authBridge.fallbackApiKeyCacheKey.mockReturnValue(undefined);
mocks.authBridge.startOptions.mockClear();
mocks.managedBinary.startOptions.mockClear();
mocks.managedBinary.startOptions.mockImplementation(async (startOptions) => startOptions);

View File

@@ -74,10 +74,13 @@ async function withCodexAppServerModelClient<T>(
): Promise<T> {
const timeoutMs = options.timeoutMs ?? 2500;
const useSharedClient = options.sharedClient !== false;
const { createIsolatedCodexAppServerClient, getSharedCodexAppServerClient } =
await import("./shared-client.js");
const {
createIsolatedCodexAppServerClient,
getLeasedSharedCodexAppServerClient,
releaseLeasedSharedCodexAppServerClient,
} = await import("./shared-client.js");
const client = useSharedClient
? await getSharedCodexAppServerClient({
? await getLeasedSharedCodexAppServerClient({
startOptions: options.startOptions,
timeoutMs,
authProfileId: options.authProfileId,
@@ -94,7 +97,9 @@ async function withCodexAppServerModelClient<T>(
try {
return await run({ client, timeoutMs });
} finally {
if (!useSharedClient) {
if (useSharedClient) {
releaseLeasedSharedCodexAppServerClient(client);
} else {
client.close();
}
}

View File

@@ -20,7 +20,7 @@ describe("Codex native hook relay config", () => {
{
type: "command",
command:
"openclaw hooks relay --provider codex --relay-id relay-1 --event pre_tool_use",
"openclaw hooks relay --provider codex --relay-id relay-1 --generation generation-1 --event pre_tool_use",
timeout: 7,
async: false,
statusMessage: "OpenClaw native hook relay",
@@ -34,7 +34,7 @@ describe("Codex native hook relay config", () => {
{
type: "command",
command:
"openclaw hooks relay --provider codex --relay-id relay-1 --event post_tool_use",
"openclaw hooks relay --provider codex --relay-id relay-1 --generation generation-1 --event post_tool_use",
timeout: 7,
async: false,
statusMessage: "OpenClaw native hook relay",
@@ -48,7 +48,7 @@ describe("Codex native hook relay config", () => {
{
type: "command",
command:
"openclaw hooks relay --provider codex --relay-id relay-1 --event permission_request",
"openclaw hooks relay --provider codex --relay-id relay-1 --generation generation-1 --event permission_request",
timeout: 7,
async: false,
statusMessage: "OpenClaw native hook relay",
@@ -62,7 +62,7 @@ describe("Codex native hook relay config", () => {
{
type: "command",
command:
"openclaw hooks relay --provider codex --relay-id relay-1 --event before_agent_finalize",
"openclaw hooks relay --provider codex --relay-id relay-1 --generation generation-1 --event before_agent_finalize",
timeout: 7,
async: false,
statusMessage: "OpenClaw native hook relay",
@@ -125,7 +125,7 @@ describe("Codex native hook relay config", () => {
{
type: "command",
command:
"openclaw hooks relay --provider codex --relay-id relay-1 --event permission_request",
"openclaw hooks relay --provider codex --relay-id relay-1 --generation generation-1 --event permission_request",
timeout: 5,
async: false,
statusMessage: "OpenClaw native hook relay",
@@ -160,7 +160,7 @@ describe("Codex native hook relay config", () => {
{
type: "command",
command:
"openclaw hooks relay --provider codex --relay-id relay-1 --event pre_tool_use",
"openclaw hooks relay --provider codex --relay-id relay-1 --generation generation-1 --event pre_tool_use",
timeout: 5,
async: false,
statusMessage: "OpenClaw native hook relay",
@@ -200,7 +200,7 @@ describe("Codex native hook relay config", () => {
{
type: "command",
command:
"openclaw hooks relay --provider codex --relay-id relay-1 --event permission_request",
"openclaw hooks relay --provider codex --relay-id relay-1 --generation generation-1 --event permission_request",
timeout: 5,
async: false,
statusMessage: "OpenClaw native hook relay",
@@ -260,6 +260,7 @@ function createRelay(options?: {
return {
relayId: "relay-1",
provider: "codex",
generation: "generation-1",
sessionId: "session-1",
sessionKey: "agent:main:session-1",
runId: "run-1",
@@ -267,7 +268,7 @@ function createRelay(options?: {
expiresAtMs: Date.now() + 1000,
shouldRelayEvent: (event) => !inactiveEvents.has(event),
commandForEvent: (event) =>
`openclaw hooks relay --provider codex --relay-id relay-1 --event ${event}`,
`openclaw hooks relay --provider codex --relay-id relay-1 --generation generation-1 --event ${event}`,
renew: () => undefined,
unregister: () => undefined,
};

View File

@@ -0,0 +1,30 @@
import { describe, expect, it } from "vitest";
import { isCodexAppServerProfilerEnabled } from "./profiler-flag.js";
describe("isCodexAppServerProfilerEnabled", () => {
it("is disabled by default", () => {
expect(isCodexAppServerProfilerEnabled(undefined, {} as NodeJS.ProcessEnv)).toBe(false);
});
it("matches global and Codex profiler flags", () => {
expect(
isCodexAppServerProfilerEnabled(
{ diagnostics: { flags: ["codex.profiler"] } },
{} as NodeJS.ProcessEnv,
),
).toBe(true);
expect(
isCodexAppServerProfilerEnabled(undefined, {
OPENCLAW_DIAGNOSTICS: "profiler",
} as NodeJS.ProcessEnv),
).toBe(true);
});
it("uses the documented diagnostics env disable override", () => {
expect(
isCodexAppServerProfilerEnabled({ diagnostics: { flags: ["codex.profiler"] } }, {
OPENCLAW_DIAGNOSTICS: "0",
} as NodeJS.ProcessEnv),
).toBe(false);
});
});

View File

@@ -0,0 +1,11 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { isDiagnosticFlagEnabled } from "openclaw/plugin-sdk/diagnostic-runtime";
const PROFILER_FLAGS = ["profiler", "codex.profiler"] as const;
export function isCodexAppServerProfilerEnabled(
config?: OpenClawConfig,
env: NodeJS.ProcessEnv = process.env,
): boolean {
return PROFILER_FLAGS.some((flag) => isDiagnosticFlagEnabled(flag, config, env));
}

View File

@@ -52,18 +52,6 @@
"modelProvider": {
"type": "string"
},
"permissionProfile": {
"description": "Full active permissions for this thread. `activePermissionProfile` carries display/provenance metadata for this runtime profile.",
"default": null,
"anyOf": [
{
"$ref": "#/definitions/PermissionProfile"
},
{
"type": "null"
}
]
},
"reasoningEffort": {
"anyOf": [
{
@@ -83,7 +71,7 @@
}
},
"sandbox": {
"description": "Legacy sandbox policy retained for compatibility. Experimental clients should prefer `permissionProfile` when they need exact runtime permissions.",
"description": "Legacy sandbox policy retained for compatibility. Experimental clients should prefer `activePermissionProfile` for profile provenance.",
"allOf": [
{
"$ref": "#/definitions/SandboxPolicy"
@@ -570,202 +558,6 @@
"failed"
]
},
"FileSystemAccessMode": {
"type": "string",
"enum": [
"read",
"write",
"none"
]
},
"FileSystemPath": {
"oneOf": [
{
"type": "object",
"required": [
"path",
"type"
],
"properties": {
"path": {
"$ref": "#/definitions/AbsolutePathBuf"
},
"type": {
"type": "string",
"enum": [
"path"
],
"title": "PathFileSystemPathType"
}
},
"title": "PathFileSystemPath"
},
{
"type": "object",
"required": [
"pattern",
"type"
],
"properties": {
"pattern": {
"type": "string"
},
"type": {
"type": "string",
"enum": [
"glob_pattern"
],
"title": "GlobPatternFileSystemPathType"
}
},
"title": "GlobPatternFileSystemPath"
},
{
"type": "object",
"required": [
"type",
"value"
],
"properties": {
"type": {
"type": "string",
"enum": [
"special"
],
"title": "SpecialFileSystemPathType"
},
"value": {
"$ref": "#/definitions/FileSystemSpecialPath"
}
},
"title": "SpecialFileSystemPath"
}
]
},
"FileSystemSandboxEntry": {
"type": "object",
"required": [
"access",
"path"
],
"properties": {
"access": {
"$ref": "#/definitions/FileSystemAccessMode"
},
"path": {
"$ref": "#/definitions/FileSystemPath"
}
}
},
"FileSystemSpecialPath": {
"oneOf": [
{
"type": "object",
"required": [
"kind"
],
"properties": {
"kind": {
"type": "string",
"enum": [
"root"
]
}
},
"title": "RootFileSystemSpecialPath"
},
{
"type": "object",
"required": [
"kind"
],
"properties": {
"kind": {
"type": "string",
"enum": [
"minimal"
]
}
},
"title": "MinimalFileSystemSpecialPath"
},
{
"type": "object",
"required": [
"kind"
],
"properties": {
"kind": {
"type": "string",
"enum": [
"project_roots"
]
},
"subpath": {
"type": [
"string",
"null"
]
}
},
"title": "KindFileSystemSpecialPath"
},
{
"type": "object",
"required": [
"kind"
],
"properties": {
"kind": {
"type": "string",
"enum": [
"tmpdir"
]
}
},
"title": "TmpdirFileSystemSpecialPath"
},
{
"type": "object",
"required": [
"kind"
],
"properties": {
"kind": {
"type": "string",
"enum": [
"slash_tmp"
]
}
},
"title": "SlashTmpFileSystemSpecialPath"
},
{
"type": "object",
"required": [
"kind",
"path"
],
"properties": {
"kind": {
"type": "string",
"enum": [
"unknown"
]
},
"path": {
"type": "string"
},
"subpath": {
"type": [
"string",
"null"
]
}
}
}
]
},
"FileUpdateChange": {
"type": "object",
"required": [
@@ -823,6 +615,13 @@
}
}
},
"ImageDetail": {
"type": "string",
"enum": [
"high",
"original"
]
},
"McpToolCallError": {
"type": "object",
"required": [
@@ -1004,135 +803,6 @@
}
]
},
"PermissionProfile": {
"oneOf": [
{
"description": "Codex owns sandbox construction for this profile.",
"type": "object",
"required": [
"fileSystem",
"network",
"type"
],
"properties": {
"fileSystem": {
"$ref": "#/definitions/PermissionProfileFileSystemPermissions"
},
"network": {
"$ref": "#/definitions/PermissionProfileNetworkPermissions"
},
"type": {
"type": "string",
"enum": [
"managed"
],
"title": "ManagedPermissionProfileType"
}
},
"title": "ManagedPermissionProfile"
},
{
"description": "Do not apply an outer sandbox.",
"type": "object",
"required": [
"type"
],
"properties": {
"type": {
"type": "string",
"enum": [
"disabled"
],
"title": "DisabledPermissionProfileType"
}
},
"title": "DisabledPermissionProfile"
},
{
"description": "Filesystem isolation is enforced by an external caller.",
"type": "object",
"required": [
"network",
"type"
],
"properties": {
"network": {
"$ref": "#/definitions/PermissionProfileNetworkPermissions"
},
"type": {
"type": "string",
"enum": [
"external"
],
"title": "ExternalPermissionProfileType"
}
},
"title": "ExternalPermissionProfile"
}
]
},
"PermissionProfileFileSystemPermissions": {
"oneOf": [
{
"type": "object",
"required": [
"entries",
"type"
],
"properties": {
"entries": {
"type": "array",
"items": {
"$ref": "#/definitions/FileSystemSandboxEntry"
}
},
"globScanMaxDepth": {
"type": [
"integer",
"null"
],
"format": "uint",
"minimum": 1
},
"type": {
"type": "string",
"enum": [
"restricted"
],
"title": "RestrictedPermissionProfileFileSystemPermissionsType"
}
},
"title": "RestrictedPermissionProfileFileSystemPermissions"
},
{
"type": "object",
"required": [
"type"
],
"properties": {
"type": {
"type": "string",
"enum": [
"unrestricted"
],
"title": "UnrestrictedPermissionProfileFileSystemPermissionsType"
}
},
"title": "UnrestrictedPermissionProfileFileSystemPermissions"
}
]
},
"PermissionProfileNetworkPermissions": {
"type": "object",
"required": [
"enabled"
],
"properties": {
"enabled": {
"type": "boolean"
}
}
},
"ReasoningEffort": {
"description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning",
"type": "string",
@@ -2442,6 +2112,17 @@
"url"
],
"properties": {
"detail": {
"default": null,
"anyOf": [
{
"$ref": "#/definitions/ImageDetail"
},
{
"type": "null"
}
]
},
"type": {
"type": "string",
"enum": [
@@ -2462,6 +2143,17 @@
"type"
],
"properties": {
"detail": {
"default": null,
"anyOf": [
{
"$ref": "#/definitions/ImageDetail"
},
{
"type": "null"
}
]
},
"path": {
"type": "string"
},

View File

@@ -52,18 +52,6 @@
"modelProvider": {
"type": "string"
},
"permissionProfile": {
"description": "Full active permissions for this thread. `activePermissionProfile` carries display/provenance metadata for this runtime profile.",
"default": null,
"anyOf": [
{
"$ref": "#/definitions/PermissionProfile"
},
{
"type": "null"
}
]
},
"reasoningEffort": {
"anyOf": [
{
@@ -83,7 +71,7 @@
}
},
"sandbox": {
"description": "Legacy sandbox policy retained for compatibility. Experimental clients should prefer `permissionProfile` when they need exact runtime permissions.",
"description": "Legacy sandbox policy retained for compatibility. Experimental clients should prefer `activePermissionProfile` for profile provenance.",
"allOf": [
{
"$ref": "#/definitions/SandboxPolicy"
@@ -570,202 +558,6 @@
"failed"
]
},
"FileSystemAccessMode": {
"type": "string",
"enum": [
"read",
"write",
"none"
]
},
"FileSystemPath": {
"oneOf": [
{
"type": "object",
"required": [
"path",
"type"
],
"properties": {
"path": {
"$ref": "#/definitions/AbsolutePathBuf"
},
"type": {
"type": "string",
"enum": [
"path"
],
"title": "PathFileSystemPathType"
}
},
"title": "PathFileSystemPath"
},
{
"type": "object",
"required": [
"pattern",
"type"
],
"properties": {
"pattern": {
"type": "string"
},
"type": {
"type": "string",
"enum": [
"glob_pattern"
],
"title": "GlobPatternFileSystemPathType"
}
},
"title": "GlobPatternFileSystemPath"
},
{
"type": "object",
"required": [
"type",
"value"
],
"properties": {
"type": {
"type": "string",
"enum": [
"special"
],
"title": "SpecialFileSystemPathType"
},
"value": {
"$ref": "#/definitions/FileSystemSpecialPath"
}
},
"title": "SpecialFileSystemPath"
}
]
},
"FileSystemSandboxEntry": {
"type": "object",
"required": [
"access",
"path"
],
"properties": {
"access": {
"$ref": "#/definitions/FileSystemAccessMode"
},
"path": {
"$ref": "#/definitions/FileSystemPath"
}
}
},
"FileSystemSpecialPath": {
"oneOf": [
{
"type": "object",
"required": [
"kind"
],
"properties": {
"kind": {
"type": "string",
"enum": [
"root"
]
}
},
"title": "RootFileSystemSpecialPath"
},
{
"type": "object",
"required": [
"kind"
],
"properties": {
"kind": {
"type": "string",
"enum": [
"minimal"
]
}
},
"title": "MinimalFileSystemSpecialPath"
},
{
"type": "object",
"required": [
"kind"
],
"properties": {
"kind": {
"type": "string",
"enum": [
"project_roots"
]
},
"subpath": {
"type": [
"string",
"null"
]
}
},
"title": "KindFileSystemSpecialPath"
},
{
"type": "object",
"required": [
"kind"
],
"properties": {
"kind": {
"type": "string",
"enum": [
"tmpdir"
]
}
},
"title": "TmpdirFileSystemSpecialPath"
},
{
"type": "object",
"required": [
"kind"
],
"properties": {
"kind": {
"type": "string",
"enum": [
"slash_tmp"
]
}
},
"title": "SlashTmpFileSystemSpecialPath"
},
{
"type": "object",
"required": [
"kind",
"path"
],
"properties": {
"kind": {
"type": "string",
"enum": [
"unknown"
]
},
"path": {
"type": "string"
},
"subpath": {
"type": [
"string",
"null"
]
}
}
}
]
},
"FileUpdateChange": {
"type": "object",
"required": [
@@ -823,6 +615,13 @@
}
}
},
"ImageDetail": {
"type": "string",
"enum": [
"high",
"original"
]
},
"McpToolCallError": {
"type": "object",
"required": [
@@ -1004,135 +803,6 @@
}
]
},
"PermissionProfile": {
"oneOf": [
{
"description": "Codex owns sandbox construction for this profile.",
"type": "object",
"required": [
"fileSystem",
"network",
"type"
],
"properties": {
"fileSystem": {
"$ref": "#/definitions/PermissionProfileFileSystemPermissions"
},
"network": {
"$ref": "#/definitions/PermissionProfileNetworkPermissions"
},
"type": {
"type": "string",
"enum": [
"managed"
],
"title": "ManagedPermissionProfileType"
}
},
"title": "ManagedPermissionProfile"
},
{
"description": "Do not apply an outer sandbox.",
"type": "object",
"required": [
"type"
],
"properties": {
"type": {
"type": "string",
"enum": [
"disabled"
],
"title": "DisabledPermissionProfileType"
}
},
"title": "DisabledPermissionProfile"
},
{
"description": "Filesystem isolation is enforced by an external caller.",
"type": "object",
"required": [
"network",
"type"
],
"properties": {
"network": {
"$ref": "#/definitions/PermissionProfileNetworkPermissions"
},
"type": {
"type": "string",
"enum": [
"external"
],
"title": "ExternalPermissionProfileType"
}
},
"title": "ExternalPermissionProfile"
}
]
},
"PermissionProfileFileSystemPermissions": {
"oneOf": [
{
"type": "object",
"required": [
"entries",
"type"
],
"properties": {
"entries": {
"type": "array",
"items": {
"$ref": "#/definitions/FileSystemSandboxEntry"
}
},
"globScanMaxDepth": {
"type": [
"integer",
"null"
],
"format": "uint",
"minimum": 1
},
"type": {
"type": "string",
"enum": [
"restricted"
],
"title": "RestrictedPermissionProfileFileSystemPermissionsType"
}
},
"title": "RestrictedPermissionProfileFileSystemPermissions"
},
{
"type": "object",
"required": [
"type"
],
"properties": {
"type": {
"type": "string",
"enum": [
"unrestricted"
],
"title": "UnrestrictedPermissionProfileFileSystemPermissionsType"
}
},
"title": "UnrestrictedPermissionProfileFileSystemPermissions"
}
]
},
"PermissionProfileNetworkPermissions": {
"type": "object",
"required": [
"enabled"
],
"properties": {
"enabled": {
"type": "boolean"
}
}
},
"ReasoningEffort": {
"description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning",
"type": "string",
@@ -2442,6 +2112,17 @@
"url"
],
"properties": {
"detail": {
"default": null,
"anyOf": [
{
"$ref": "#/definitions/ImageDetail"
},
{
"type": "null"
}
]
},
"type": {
"type": "string",
"enum": [
@@ -2462,6 +2143,17 @@
"type"
],
"properties": {
"detail": {
"default": null,
"anyOf": [
{
"$ref": "#/definitions/ImageDetail"
},
{
"type": "null"
}
]
},
"path": {
"type": "string"
},

View File

@@ -436,6 +436,13 @@
}
}
},
"ImageDetail": {
"type": "string",
"enum": [
"high",
"original"
]
},
"McpToolCallError": {
"type": "object",
"required": [
@@ -1471,6 +1478,17 @@
"url"
],
"properties": {
"detail": {
"default": null,
"anyOf": [
{
"$ref": "#/definitions/ImageDetail"
},
{
"type": "null"
}
]
},
"type": {
"type": "string",
"enum": [
@@ -1491,6 +1509,17 @@
"type"
],
"properties": {
"detail": {
"default": null,
"anyOf": [
{
"$ref": "#/definitions/ImageDetail"
},
{
"type": "null"
}
]
},
"path": {
"type": "string"
},

View File

@@ -432,6 +432,13 @@
}
}
},
"ImageDetail": {
"type": "string",
"enum": [
"high",
"original"
]
},
"McpToolCallError": {
"type": "object",
"required": [
@@ -1467,6 +1474,17 @@
"url"
],
"properties": {
"detail": {
"default": null,
"anyOf": [
{
"$ref": "#/definitions/ImageDetail"
},
{
"type": "null"
}
]
},
"type": {
"type": "string",
"enum": [
@@ -1487,6 +1505,17 @@
"type"
],
"properties": {
"detail": {
"default": null,
"anyOf": [
{
"$ref": "#/definitions/ImageDetail"
},
{
"type": "null"
}
]
},
"path": {
"type": "string"
},

View File

@@ -5,7 +5,11 @@ const sharedClientMocks = vi.hoisted(() => ({
getSharedCodexAppServerClient: vi.fn(),
}));
vi.mock("./shared-client.js", () => sharedClientMocks);
vi.mock("./shared-client.js", () => ({
...sharedClientMocks,
getLeasedSharedCodexAppServerClient: sharedClientMocks.getSharedCodexAppServerClient,
releaseLeasedSharedCodexAppServerClient: vi.fn(),
}));
const { requestCodexAppServerJson } = await import("./request.js");

View File

@@ -9,7 +9,8 @@ import type {
import { resolveCodexAppServerDirectSandboxBypassBlock } from "./sandbox-guard.js";
import {
createIsolatedCodexAppServerClient,
getSharedCodexAppServerClient,
getLeasedSharedCodexAppServerClient,
releaseLeasedSharedCodexAppServerClient,
} from "./shared-client.js";
import { withTimeout } from "./timeout.js";
@@ -63,7 +64,7 @@ export async function requestCodexAppServerJson<T = JsonValue | undefined>(param
return await withTimeout(
(async () => {
const client = await (
params.isolated ? createIsolatedCodexAppServerClient : getSharedCodexAppServerClient
params.isolated ? createIsolatedCodexAppServerClient : getLeasedSharedCodexAppServerClient
)({
startOptions: params.startOptions,
timeoutMs,
@@ -81,6 +82,8 @@ export async function requestCodexAppServerJson<T = JsonValue | undefined>(param
// underlying codex binary, so the unref'd close() path can leave
// the child running and keep the parent's event loop alive.
await client.closeAndWait({ exitTimeoutMs: 2_000, forceKillDelayMs: 250 });
} else {
releaseLeasedSharedCodexAppServerClient(client);
}
}
})(),

View File

@@ -887,6 +887,7 @@ describe("runCodexAppServerAttempt", () => {
await closeCodexSandboxExecServersForTests();
resetCodexAppServerClientFactoryForTest();
testing.resetOpenClawCodingToolsFactoryForTests();
testing.resetEnsuredCodexWorkspaceDirsForTests();
testing.clearPendingCodexNativeHookRelayUnregistersForTests();
resetCodexRateLimitCacheForTests();
nativeHookRelayTesting.clearNativeHookRelaysForTests();
@@ -903,6 +904,16 @@ describe("runCodexAppServerAttempt", () => {
await fs.rm(tempDir, { recursive: true, force: true });
});
it("recreates cached Codex workspace directories after cleanup removes them", async () => {
const workspaceDir = path.join(tempDir, "cached-workspace");
await testing.ensureCodexWorkspaceDirOnceForTests(workspaceDir);
await fs.rm(workspaceDir, { recursive: true, force: true });
await testing.ensureCodexWorkspaceDirOnceForTests(workspaceDir);
expect((await fs.stat(workspaceDir)).isDirectory()).toBe(true);
});
it("filters Codex-native dynamic tools from app-server tool exposure", () => {
const tools = [
"read",
@@ -2334,18 +2345,21 @@ describe("runCodexAppServerAttempt", () => {
testing.buildDeveloperInstructions(params, {
dynamicTools: [createMessageDynamicTool("Message test tool")],
}),
).toContain("To send a visible message, use the `message` tool.");
).toContain("Visible source replies are not automatically delivered for this run.");
const withoutMessageToolInstructions = testing.buildDeveloperInstructions(params, {
dynamicTools: [],
});
expect(withoutMessageToolInstructions).toContain("active Codex delivery path");
expect(withoutMessageToolInstructions).not.toContain("use the `message` tool");
expect(withoutMessageToolInstructions).toContain(
"reply normally in your final assistant message",
);
expect(withoutMessageToolInstructions).not.toContain("message(action=send)");
expect(withoutMessageToolInstructions).not.toContain("Use `message`");
params.sourceReplyDeliveryMode = "automatic";
const automaticInstructions = testing.buildDeveloperInstructions(params);
expect(automaticInstructions).toContain("active Codex delivery path");
expect(automaticInstructions).not.toContain("use the `message` tool");
expect(automaticInstructions).toContain("reply normally in your final assistant message");
expect(automaticInstructions).not.toContain("message(action=send)");
});
it("includes Codex app-server scoped plugin command guidance in developer instructions", () => {
@@ -2433,12 +2447,44 @@ describe("runCodexAppServerAttempt", () => {
]);
});
it("keeps leading delivery hints out of the Codex current user request", async () => {
const sessionFile = path.join(tempDir, "session-delivery-hint.jsonl");
const workspaceDir = path.join(tempDir, "workspace-delivery-hint");
const harness = createStartedThreadHarness();
const params = createParams(sessionFile, workspaceDir);
params.prompt = "Delivery: to send a message, use the `message` tool.\n\nhello";
params.skillsSnapshot = {
prompt: "<available_skills><skill><name>demo</name></skill></available_skills>",
skills: [],
};
const run = runCodexAppServerAttempt(params);
await harness.waitForMethod("turn/start");
await harness.completeTurn({ threadId: "thread-1", turnId: "turn-1" });
await run;
const turnStart = harness.requests.find((request) => request.method === "turn/start");
const turnStartParams = turnStart?.params as {
input?: Array<{ text?: string }>;
};
const inputText = turnStartParams.input?.[0]?.text ?? "";
expect(inputText).toContain("OpenClaw delivery metadata:");
expect(inputText).toContain(
"This delivery metadata is runtime routing guidance, not the user's request.",
);
expect(inputText).toContain("Delivery: to send a message, use the `message` tool.");
expect(inputText).toContain("Current user request:\nhello");
expect(inputText).not.toContain("Current user request:\nDelivery:");
});
it("mirrors the Codex prompt into the transcript when the turn starts", async () => {
const sessionFile = path.join(tempDir, "session-early-prompt.jsonl");
const workspaceDir = path.join(tempDir, "workspace-early-prompt");
const harness = createStartedThreadHarness();
const params = createParams(sessionFile, workspaceDir);
params.prompt = "external channel prompt";
const onUserMessagePersisted = vi.fn();
params.onUserMessagePersisted = onUserMessagePersisted;
const run = runCodexAppServerAttempt(params);
await harness.waitForMethod("turn/start");
@@ -2448,6 +2494,15 @@ describe("runCodexAppServerAttempt", () => {
expect(raw).toContain('"content":"external channel prompt"');
expect(raw).toContain('"idempotencyKey":"codex-app-server:thread-1:turn-1:prompt"');
});
await vi.waitFor(() => {
expect(onUserMessagePersisted).toHaveBeenCalledWith(
expect.objectContaining({
role: "user",
content: "external channel prompt",
idempotencyKey: "codex-app-server:thread-1:turn-1:prompt",
}),
);
});
const rawBeforeCompletion = await fs.readFile(sessionFile, "utf8");
expect(rawBeforeCompletion).not.toContain('"role":"assistant"');
@@ -2457,6 +2512,7 @@ describe("runCodexAppServerAttempt", () => {
const rawAfterCompletion = await fs.readFile(sessionFile, "utf8");
expect(rawAfterCompletion.match(/"role":"user"/gu)).toHaveLength(1);
expect(onUserMessagePersisted).toHaveBeenCalledTimes(1);
});
it("does not mirror the Codex prompt early when user message persistence is suppressed", async () => {
@@ -3078,6 +3134,22 @@ describe("runCodexAppServerAttempt", () => {
).toBe(testing.CODEX_DYNAMIC_MESSAGE_TOOL_TIMEOUT_MS);
});
it("uses a 90 second default for generic Codex dynamic tool calls", () => {
expect(
testing.resolveDynamicToolCallTimeoutMs({
call: {
threadId: "thread-1",
turnId: "turn-1",
callId: "call-session-status",
namespace: null,
tool: "session_status",
arguments: { sessionKey: "current" },
},
config: undefined,
}),
).toBe(90_000);
});
it("caps dynamic tool timeouts at the bridge maximum", () => {
expect(
testing.resolveDynamicToolCallTimeoutMs({
@@ -4149,7 +4221,7 @@ describe("runCodexAppServerAttempt", () => {
});
});
it("closes the app-server client when the active turn goes idle past the attempt timeout", async () => {
it("unsubscribes and closes the app-server client when the active turn goes idle past the attempt timeout", async () => {
const close = vi.fn();
const request = vi.fn(async (method: string) => {
if (method === "thread/start") {
@@ -4193,6 +4265,13 @@ describe("runCodexAppServerAttempt", () => {
},
{ timeoutMs: 5_000 },
);
expect(request).toHaveBeenCalledWith(
"thread/unsubscribe",
{
threadId: "thread-1",
},
{ timeoutMs: 5_000 },
);
expect(close).toHaveBeenCalledTimes(1);
expect(queueActiveRunMessageForTest("session-1", "after timeout")).toBe(false);
});
@@ -5508,6 +5587,7 @@ describe("runCodexAppServerAttempt", () => {
});
it("does not treat global rate-limit notifications as turn progress", async () => {
const warn = vi.spyOn(embeddedAgentLog, "warn").mockImplementation(() => undefined);
const harness = createStartedThreadHarness();
const params = createParams(
path.join(tempDir, "session.jsonl"),
@@ -5550,6 +5630,14 @@ describe("runCodexAppServerAttempt", () => {
),
{ interval: 1 },
);
expect(warn).toHaveBeenCalledWith(
"codex app-server client retired after timed-out turn",
expect.objectContaining({
reason: "turn_completion_idle_timeout",
threadId: "thread-1",
turnId: "turn-1",
}),
);
});
it("yields a macrotask before processing queued app-server notifications", async () => {
@@ -5575,6 +5663,70 @@ describe("runCodexAppServerAttempt", () => {
await expect(run).resolves.toMatchObject({ aborted: false, timedOut: false });
});
it("does not idle-timeout when terminal completion queues behind projection", async () => {
const harness = createStartedThreadHarness();
const params = createParams(
path.join(tempDir, "session.jsonl"),
path.join(tempDir, "workspace"),
);
params.timeoutMs = 120;
const turnStartProgressEvents: DiagnosticEventPayload[] = [];
const stopDiagnostics = onInternalDiagnosticEvent((event) => {
if (event.type === "run.progress" && event.reason === "codex_app_server:turn:start") {
turnStartProgressEvents.push(event);
}
});
let resolveReasoningStarted!: () => void;
const reasoningStarted = new Promise<void>((resolve) => {
resolveReasoningStarted = resolve;
});
let releaseProjection!: () => void;
const projectionGate = new Promise<void>((resolve) => {
releaseProjection = resolve;
});
params.onReasoningStream = async () => {
resolveReasoningStarted();
await projectionGate;
};
let settled = false;
const run = runCodexAppServerAttempt(params, {
turnCompletionIdleTimeoutMs: 5,
turnTerminalIdleTimeoutMs: 5,
}).finally(() => {
settled = true;
});
await harness.waitForMethod("turn/start");
await vi.waitFor(() => expect(turnStartProgressEvents).toHaveLength(2), { interval: 1 });
stopDiagnostics();
const blockedProjection = harness.notify({
method: "item/reasoning/textDelta",
params: {
threadId: "thread-1",
turnId: "turn-1",
itemId: "reasoning-1",
delta: "thinking",
},
});
void blockedProjection.catch(() => undefined);
await reasoningStarted;
const queuedTerminal = harness.completeTurn({ threadId: "thread-1", turnId: "turn-1" });
void queuedTerminal.catch(() => undefined);
await new Promise((resolve) => setTimeout(resolve, 30));
expect(settled).toBe(false);
expect(harness.request.mock.calls.some(([method]) => method === "turn/interrupt")).toBe(false);
releaseProjection();
await queuedTerminal;
const result = await run;
expect(result.aborted).toBe(false);
expect(result.timedOut).toBe(false);
expect(result.promptError).toBeNull();
});
it("releases the session when a completed agent message item goes quiet", async () => {
let notify: (notification: CodexServerNotification) => Promise<void> = async () => undefined;
const request = vi.fn(async (method: string) => {
@@ -6298,6 +6450,104 @@ describe("runCodexAppServerAttempt", () => {
expect(inputText).toContain("make the default webpage openclaw");
});
it("projects newer mirrored history when resuming an existing Codex thread binding", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
await writeExistingBinding(sessionFile, workspaceDir, { dynamicToolsFingerprint: "[]" });
const binding = await readCodexAppServerBinding(sessionFile);
const bindingUpdatedAt = Date.parse(binding?.updatedAt ?? "");
if (!Number.isFinite(bindingUpdatedAt)) {
throw new Error("expected valid Codex binding timestamp");
}
const sessionManager = SessionManager.open(sessionFile);
sessionManager.appendMessage(
userMessage("we were discussing the Sonnet leak screenshots", bindingUpdatedAt + 1_000),
);
sessionManager.appendMessage(
assistantMessage("David Ondrej was mentioned in that prior thread", bindingUpdatedAt + 2_000),
);
const harness = createResumeHarness();
const params = createParams(sessionFile, workspaceDir);
params.prompt = "is the previous message trustworthy?";
const run = runCodexAppServerAttempt(params);
await harness.waitForMethod("turn/start");
await new Promise<void>((resolve) => setImmediate(resolve));
await harness.completeTurn({ threadId: "thread-existing", turnId: "turn-1" });
await run;
expect(harness.requests.map((request) => request.method)).toContain("thread/resume");
const turnStart = harness.requests.find((request) => request.method === "turn/start");
const inputText =
(turnStart?.params as { input?: Array<{ text?: string }> } | undefined)?.input?.[0]?.text ??
"";
expect(inputText).toContain("OpenClaw assembled context for this turn:");
expect(inputText).toContain("we were discussing the Sonnet leak screenshots");
expect(inputText).toContain("David Ondrej was mentioned in that prior thread");
expect(inputText).toContain("Current user request:");
expect(inputText).toContain("is the previous message trustworthy?");
});
it("does not reproject Codex-owned mirrored messages on consecutive resumes", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
await writeExistingBinding(sessionFile, workspaceDir, { dynamicToolsFingerprint: "[]" });
const oldBindingUpdatedAt = Date.now() - 60_000;
const bindingPath = `${sessionFile}.codex-app-server.json`;
const bindingPayload = JSON.parse(await fs.readFile(bindingPath, "utf8")) as Record<
string,
unknown
>;
bindingPayload.updatedAt = new Date(oldBindingUpdatedAt).toISOString();
await fs.writeFile(bindingPath, `${JSON.stringify(bindingPayload, null, 2)}\n`);
const sessionManager = SessionManager.open(sessionFile);
sessionManager.appendMessage(
userMessage("we were discussing the Sonnet leak screenshots", oldBindingUpdatedAt + 1_000),
);
sessionManager.appendMessage(
assistantMessage(
"David Ondrej was mentioned in that prior thread",
oldBindingUpdatedAt + 2_000,
),
);
const firstHarness = createResumeHarness();
const firstParams = createParams(sessionFile, workspaceDir);
firstParams.prompt = "is the previous message trustworthy?";
const firstRun = runCodexAppServerAttempt(firstParams);
await firstHarness.waitForMethod("turn/start");
await firstHarness.completeTurn({ threadId: "thread-existing", turnId: "turn-1" });
await firstRun;
const firstTurnStart = firstHarness.requests.find((request) => request.method === "turn/start");
const firstInputText =
(firstTurnStart?.params as { input?: Array<{ text?: string }> } | undefined)?.input?.[0]
?.text ?? "";
expect(firstInputText).toContain("OpenClaw assembled context for this turn:");
expect(firstInputText).toContain("we were discussing the Sonnet leak screenshots");
expect(firstInputText).toContain("is the previous message trustworthy?");
const secondHarness = createResumeHarness();
const secondParams = createParams(sessionFile, workspaceDir);
secondParams.prompt = "continue from there";
const secondRun = runCodexAppServerAttempt(secondParams);
await secondHarness.waitForMethod("turn/start");
await secondHarness.completeTurn({ threadId: "thread-existing", turnId: "turn-1" });
await secondRun;
const secondTurnStart = secondHarness.requests.find(
(request) => request.method === "turn/start",
);
const secondInputText =
(secondTurnStart?.params as { input?: Array<{ text?: string }> } | undefined)?.input?.[0]
?.text ?? "";
expect(secondInputText).not.toContain("OpenClaw assembled context for this turn:");
expect(secondInputText).not.toContain("we were discussing the Sonnet leak screenshots");
expect(secondInputText).not.toContain("is the previous message trustworthy?");
expect(secondInputText).toContain("continue from there");
});
it("passes stable workspace files as Codex developer instructions and keeps MEMORY.md as turn context", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
@@ -8241,8 +8491,12 @@ describe("runCodexAppServerAttempt", () => {
);
await waitForMethod("turn/start");
expect(queueActiveRunMessageForTest("session-1", "first", { steeringMode: "all" })).toBe(true);
expect(queueActiveRunMessageForTest("session-1", "second", { steeringMode: "all" })).toBe(true);
expect(
queueActiveRunMessageForTest("session-1", "first", { debounceMs: 5, steeringMode: "all" }),
).toBe(true);
expect(
queueActiveRunMessageForTest("session-1", "second", { debounceMs: 5, steeringMode: "all" }),
).toBe(true);
await vi.waitFor(
() =>

File diff suppressed because it is too large Load Diff

View File

@@ -43,7 +43,12 @@ let clearSharedCodexAppServerClient: typeof import("./shared-client.js").clearSh
let clearSharedCodexAppServerClientIfCurrent: typeof import("./shared-client.js").clearSharedCodexAppServerClientIfCurrent;
let clearSharedCodexAppServerClientIfCurrentAndWait: typeof import("./shared-client.js").clearSharedCodexAppServerClientIfCurrentAndWait;
let createIsolatedCodexAppServerClient: typeof import("./shared-client.js").createIsolatedCodexAppServerClient;
let detachSharedCodexAppServerClientIfCurrent: typeof import("./shared-client.js").detachSharedCodexAppServerClientIfCurrent;
let getLeasedSharedCodexAppServerClient: typeof import("./shared-client.js").getLeasedSharedCodexAppServerClient;
let getSharedCodexAppServerClient: typeof import("./shared-client.js").getSharedCodexAppServerClient;
let retainSharedCodexAppServerClientIfCurrent: typeof import("./shared-client.js").retainSharedCodexAppServerClientIfCurrent;
let releaseLeasedSharedCodexAppServerClient: typeof import("./shared-client.js").releaseLeasedSharedCodexAppServerClient;
let retireSharedCodexAppServerClientIfCurrent: typeof import("./shared-client.js").retireSharedCodexAppServerClientIfCurrent;
let resetSharedCodexAppServerClientForTests: typeof import("./shared-client.js").resetSharedCodexAppServerClientForTests;
async function sendInitializeResult(
@@ -116,7 +121,12 @@ describe("shared Codex app-server client", () => {
clearSharedCodexAppServerClientIfCurrent,
clearSharedCodexAppServerClientIfCurrentAndWait,
createIsolatedCodexAppServerClient,
detachSharedCodexAppServerClientIfCurrent,
getLeasedSharedCodexAppServerClient,
getSharedCodexAppServerClient,
retainSharedCodexAppServerClientIfCurrent,
releaseLeasedSharedCodexAppServerClient,
retireSharedCodexAppServerClientIfCurrent,
resetSharedCodexAppServerClientForTests,
} = await import("./shared-client.js"));
});
@@ -316,6 +326,39 @@ describe("shared Codex app-server client", () => {
expect(startSpy).toHaveBeenCalledTimes(1);
});
it("preserves keyed shared-client state when adding lease metadata", async () => {
const legacy = createClientHarness();
const startOptions = {
transport: "websocket" as const,
command: "codex",
args: [],
url: "ws://127.0.0.1:39176",
authToken: "tok-keyed",
headers: {},
};
const key = codexAppServerStartOptionsKey(startOptions, {
agentDir: "/tmp/openclaw-agent",
});
const globalState = globalThis as typeof globalThis & {
[key: symbol]: unknown;
};
globalState[Symbol.for("openclaw.codexAppServerClientState")] = {
clients: new Map([[key, { client: legacy.client, promise: Promise.resolve(legacy.client) }]]),
};
await expect(getLeasedSharedCodexAppServerClient({ startOptions })).resolves.toBe(
legacy.client,
);
expect(retireSharedCodexAppServerClientIfCurrent(legacy.client)).toEqual({
activeLeases: 1,
closed: false,
});
expect(legacy.process.stdin.destroyed).toBe(false);
expect(releaseLeasedSharedCodexAppServerClient(legacy.client)).toBe(true);
expect(legacy.process.stdin.destroyed).toBe(true);
});
it("keeps an active shared client alive when another agent dir uses a different key", async () => {
const first = createClientHarness();
const second = createClientHarness();
@@ -508,6 +551,101 @@ describe("shared Codex app-server client", () => {
expect(second.process.stdin.destroyed).toBe(true);
});
it("can detach the current shared client without closing it", async () => {
const first = createClientHarness();
const second = createClientHarness();
vi.spyOn(CodexAppServerClient, "start")
.mockReturnValueOnce(first.client)
.mockReturnValueOnce(second.client);
const firstList = listCodexAppServerModels({ timeoutMs: 1000 });
await sendInitializeResult(first, "openclaw/0.125.0 (macOS; test)");
await sendEmptyModelList(first);
await expect(firstList).resolves.toEqual({ models: [] });
expect(detachSharedCodexAppServerClientIfCurrent(first.client)).toBe(true);
expect(first.process.stdin.destroyed).toBe(false);
const secondList = listCodexAppServerModels({ timeoutMs: 1000 });
await sendInitializeResult(second, "openclaw/0.125.0 (macOS; test)");
await sendEmptyModelList(second);
await expect(secondList).resolves.toEqual({ models: [] });
expect(detachSharedCodexAppServerClientIfCurrent(first.client)).toBe(false);
first.client.close();
expect(first.process.stdin.destroyed).toBe(true);
expect(second.process.kill).not.toHaveBeenCalled();
expect(detachSharedCodexAppServerClientIfCurrent(second.client)).toBe(true);
second.client.close();
expect(second.process.stdin.destroyed).toBe(true);
});
it("closes a retired shared app-server after all active leases release", async () => {
const first = createClientHarness();
const second = createClientHarness();
vi.spyOn(CodexAppServerClient, "start")
.mockReturnValueOnce(first.client)
.mockReturnValueOnce(second.client);
const firstList = listCodexAppServerModels({ timeoutMs: 1000 });
await sendInitializeResult(first, "openclaw/0.125.0 (macOS; test)");
await sendEmptyModelList(first);
await expect(firstList).resolves.toEqual({ models: [] });
const releaseFirst = retainSharedCodexAppServerClientIfCurrent(first.client);
const releaseSecond = retainSharedCodexAppServerClientIfCurrent(first.client);
expect(releaseFirst).toBeTypeOf("function");
expect(releaseSecond).toBeTypeOf("function");
expect(retireSharedCodexAppServerClientIfCurrent(first.client)).toEqual({
activeLeases: 2,
closed: false,
});
expect(first.process.stdin.destroyed).toBe(false);
const secondList = listCodexAppServerModels({ timeoutMs: 1000 });
await sendInitializeResult(second, "openclaw/0.125.0 (macOS; test)");
await sendEmptyModelList(second);
await expect(secondList).resolves.toEqual({ models: [] });
releaseFirst?.();
expect(first.process.stdin.destroyed).toBe(false);
releaseSecond?.();
expect(first.process.stdin.destroyed).toBe(true);
expect(second.process.kill).not.toHaveBeenCalled();
expect(retireSharedCodexAppServerClientIfCurrent(second.client)).toEqual({
activeLeases: 0,
closed: true,
});
expect(second.process.stdin.destroyed).toBe(true);
});
it("leases shared app-server clients before returning concurrent acquirers", async () => {
const first = createClientHarness();
vi.spyOn(CodexAppServerClient, "start").mockReturnValueOnce(first.client);
const firstLease = getLeasedSharedCodexAppServerClient({ timeoutMs: 1000 });
const secondLease = getLeasedSharedCodexAppServerClient({ timeoutMs: 1000 });
await sendInitializeResult(first, "openclaw/0.125.0 (macOS; test)");
await expect(firstLease).resolves.toBe(first.client);
await expect(secondLease).resolves.toBe(first.client);
expect(retireSharedCodexAppServerClientIfCurrent(first.client)).toEqual({
activeLeases: 2,
closed: false,
});
expect(retireSharedCodexAppServerClientIfCurrent(first.client)).toEqual({
activeLeases: 2,
closed: false,
});
expect(first.process.stdin.destroyed).toBe(false);
expect(releaseLeasedSharedCodexAppServerClient(first.client)).toBe(true);
expect(first.process.stdin.destroyed).toBe(false);
expect(releaseLeasedSharedCodexAppServerClient(first.client)).toBe(true);
expect(first.process.stdin.destroyed).toBe(true);
expect(releaseLeasedSharedCodexAppServerClient(first.client)).toBe(false);
});
it("waits only for the shared client that is still current", async () => {
const first = createClientHarness();
const second = createClientHarness();

View File

@@ -17,10 +17,13 @@ import { withTimeout } from "./timeout.js";
type SharedCodexAppServerClientEntry = {
client?: CodexAppServerClient;
promise?: Promise<CodexAppServerClient>;
activeLeases: number;
closeWhenIdle: boolean;
};
type SharedCodexAppServerClientState = {
clients: Map<string, SharedCodexAppServerClientEntry>;
leasedReleases: WeakMap<CodexAppServerClient, Array<() => void>>;
};
type LegacySharedCodexAppServerClientState = Partial<SharedCodexAppServerClientEntry> & {
@@ -28,6 +31,11 @@ type LegacySharedCodexAppServerClientState = Partial<SharedCodexAppServerClientE
clients?: unknown;
};
type KeyedSharedCodexAppServerClientState = {
clients: Map<string, Partial<SharedCodexAppServerClientEntry>>;
leasedReleases?: unknown;
};
const SHARED_CODEX_APP_SERVER_CLIENT_STATE = Symbol.for("openclaw.codexAppServerClientState");
function getSharedCodexAppServerClientState(): SharedCodexAppServerClientState {
@@ -35,31 +43,48 @@ function getSharedCodexAppServerClientState(): SharedCodexAppServerClientState {
[SHARED_CODEX_APP_SERVER_CLIENT_STATE]?: unknown;
};
const state = globalState[SHARED_CODEX_APP_SERVER_CLIENT_STATE];
if (isSharedCodexAppServerClientState(state)) {
return state;
const keyedState = readKeyedSharedCodexAppServerClientState(state);
if (keyedState) {
const clients = keyedState.clients as Map<string, SharedCodexAppServerClientEntry>;
for (const entry of clients.values()) {
entry.activeLeases ??= 0;
entry.closeWhenIdle ??= false;
}
const nextState: SharedCodexAppServerClientState = {
clients,
leasedReleases:
keyedState.leasedReleases instanceof WeakMap ? keyedState.leasedReleases : new WeakMap(),
};
globalState[SHARED_CODEX_APP_SERVER_CLIENT_STATE] = nextState;
return nextState;
}
const legacyState = readLegacySharedCodexAppServerClientState(state);
const clients = new Map<string, SharedCodexAppServerClientEntry>();
if (legacyState?.key && (legacyState.client || legacyState.promise)) {
const legacyKey = legacyState.key;
clients.set(legacyKey, { client: legacyState.client, promise: legacyState.promise });
clients.set(legacyKey, {
client: legacyState.client,
promise: legacyState.promise,
activeLeases: 0,
closeWhenIdle: false,
});
legacyState.client?.addCloseHandler((closedClient) =>
clearSharedClientEntryIfCurrent(legacyKey, closedClient),
);
}
const nextState: SharedCodexAppServerClientState = { clients };
const nextState: SharedCodexAppServerClientState = { clients, leasedReleases: new WeakMap() };
globalState[SHARED_CODEX_APP_SERVER_CLIENT_STATE] = nextState;
return nextState;
}
function isSharedCodexAppServerClientState(
function readKeyedSharedCodexAppServerClientState(
value: unknown,
): value is SharedCodexAppServerClientState {
return (
value !== null &&
): KeyedSharedCodexAppServerClientState | undefined {
return value !== null &&
typeof value === "object" &&
(value as { clients?: unknown }).clients instanceof Map
);
? (value as KeyedSharedCodexAppServerClientState)
: undefined;
}
function readLegacySharedCodexAppServerClientState(
@@ -71,13 +96,59 @@ function readLegacySharedCodexAppServerClientState(
return value as LegacySharedCodexAppServerClientState;
}
export async function getSharedCodexAppServerClient(options?: {
type SharedCodexAppServerClientOptions = {
startOptions?: CodexAppServerStartOptions;
timeoutMs?: number;
authProfileId?: string | null;
agentDir?: string;
config?: Parameters<typeof resolveCodexAppServerAuthProfileIdForAgent>[0]["config"];
}): Promise<CodexAppServerClient> {
};
export async function getSharedCodexAppServerClient(
options?: SharedCodexAppServerClientOptions,
): Promise<CodexAppServerClient> {
return (await acquireSharedCodexAppServerClient(options)).client;
}
export async function getLeasedSharedCodexAppServerClient(
options?: SharedCodexAppServerClientOptions,
): Promise<CodexAppServerClient> {
const acquired = await acquireSharedCodexAppServerClient(options, { leased: true });
const state = getSharedCodexAppServerClientState();
const releases = state.leasedReleases.get(acquired.client) ?? [];
releases.push(acquired.release);
state.leasedReleases.set(acquired.client, releases);
return acquired.client;
}
export function releaseLeasedSharedCodexAppServerClient(client: CodexAppServerClient): boolean {
const state = getSharedCodexAppServerClientState();
const releases = state.leasedReleases.get(client);
if (!releases) {
return false;
}
const release = releases.pop();
if (!release) {
return false;
}
if (releases.length === 0) {
state.leasedReleases.delete(client);
}
release();
return true;
}
async function acquireSharedCodexAppServerClient(
options?: SharedCodexAppServerClientOptions,
): Promise<{ client: CodexAppServerClient }>;
async function acquireSharedCodexAppServerClient(
options: SharedCodexAppServerClientOptions | undefined,
leaseOptions: { leased: true },
): Promise<{ client: CodexAppServerClient; release: () => void }>;
async function acquireSharedCodexAppServerClient(
options?: SharedCodexAppServerClientOptions,
leaseOptions?: { leased: true },
): Promise<{ client: CodexAppServerClient; release?: () => void }> {
const agentDir = options?.agentDir ?? resolveDefaultAgentDir(options?.config ?? {});
const usesNativeAuth = options?.authProfileId === null;
const requestedAuthProfileId =
@@ -132,11 +203,13 @@ export async function getSharedCodexAppServerClient(options?: {
}
})());
try {
return await withTimeout(
const client = await withTimeout(
sharedPromise,
options?.timeoutMs ?? 0,
"codex app-server initialize timed out",
);
const release = leaseOptions?.leased ? retainSharedClientEntry(entry) : undefined;
return release ? { client, release } : { client };
} catch (error) {
const currentEntry = state.clients.get(key);
if (currentEntry?.promise === sharedPromise) {
@@ -223,6 +296,59 @@ export function clearSharedCodexAppServerClientIfCurrent(
return false;
}
export function detachSharedCodexAppServerClientIfCurrent(
client: CodexAppServerClient | undefined,
): boolean {
if (!client) {
return false;
}
const state = getSharedCodexAppServerClientState();
for (const [key, entry] of state.clients) {
if (entry.client === client) {
state.clients.delete(key);
return true;
}
}
return false;
}
export function retainSharedCodexAppServerClientIfCurrent(
client: CodexAppServerClient | undefined,
): (() => void) | undefined {
if (!client) {
return undefined;
}
const state = getSharedCodexAppServerClientState();
for (const entry of state.clients.values()) {
if (entry.client === client) {
return retainSharedClientEntry(entry);
}
}
return undefined;
}
export function retireSharedCodexAppServerClientIfCurrent(
client: CodexAppServerClient | undefined,
): { activeLeases: number; closed: boolean } | undefined {
if (!client) {
return undefined;
}
const state = getSharedCodexAppServerClientState();
for (const [key, entry] of state.clients) {
if (entry.client === client) {
state.clients.delete(key);
entry.closeWhenIdle = true;
const closed = closeRetiredSharedClientEntryIfIdle(entry);
return { activeLeases: entry.activeLeases, closed };
}
}
const activeLeases = state.leasedReleases.get(client)?.length ?? 0;
if (activeLeases > 0) {
return { activeLeases, closed: false };
}
return undefined;
}
export async function clearSharedCodexAppServerClientIfCurrentAndWait(
client: CodexAppServerClient | undefined,
options?: {
@@ -260,7 +386,7 @@ function getOrCreateSharedClientEntry(
): SharedCodexAppServerClientEntry {
let entry = state.clients.get(key);
if (!entry) {
entry = {};
entry = { activeLeases: 0, closeWhenIdle: false };
state.clients.set(key, entry);
}
return entry;
@@ -283,6 +409,30 @@ function clearSharedClientEntryIfCurrent(key: string, client: CodexAppServerClie
}
}
function retainSharedClientEntry(entry: SharedCodexAppServerClientEntry): () => void {
let released = false;
entry.activeLeases += 1;
return () => {
if (released) {
return;
}
released = true;
entry.activeLeases = Math.max(0, entry.activeLeases - 1);
closeRetiredSharedClientEntryIfIdle(entry);
};
}
function closeRetiredSharedClientEntryIfIdle(entry: SharedCodexAppServerClientEntry): boolean {
if (!entry.closeWhenIdle || entry.activeLeases > 0 || !entry.client) {
return false;
}
const client = entry.client;
entry.closeWhenIdle = false;
entry.client = undefined;
client.close();
return true;
}
function collectSharedClients(state: SharedCodexAppServerClientState): CodexAppServerClient[] {
return [
...new Set(

View File

@@ -30,6 +30,9 @@ vi.mock("./session-binding.js", () => ({
vi.mock("./shared-client.js", () => ({
getSharedCodexAppServerClient: (...args: unknown[]) => getSharedCodexAppServerClientMock(...args),
getLeasedSharedCodexAppServerClient: (...args: unknown[]) =>
getSharedCodexAppServerClientMock(...args),
releaseLeasedSharedCodexAppServerClient: vi.fn(),
}));
vi.mock("./auth-bridge.js", () => ({
@@ -712,7 +715,13 @@ describe("runCodexAppServerSideQuestion", () => {
getSharedCodexAppServerClientMock.mockResolvedValue(client);
await expect(
runCodexAppServerSideQuestion(sideParams(), { nativeHookRelay: { enabled: true } }),
runCodexAppServerSideQuestion(
sideParams({
cfg: { tools: { loopDetection: { enabled: true } } } as never,
sessionKey: "agent:main:session-1",
}),
{ nativeHookRelay: { enabled: true } },
),
).rejects.toThrow("fork failed");
expect(relayIdDuringFork).toBeDefined();
@@ -738,7 +747,13 @@ describe("runCodexAppServerSideQuestion", () => {
getSharedCodexAppServerClientMock.mockResolvedValue(client);
await expect(
runCodexAppServerSideQuestion(sideParams(), { nativeHookRelay: { enabled: true } }),
runCodexAppServerSideQuestion(
sideParams({
cfg: { tools: { loopDetection: { enabled: true } } } as never,
sessionKey: "agent:main:session-1",
}),
{ nativeHookRelay: { enabled: true } },
),
).resolves.toEqual({ text: "Side answer." });
const forkParams = mockCall(client.request)[1] as Record<string, unknown> | undefined;
@@ -855,15 +870,21 @@ describe("runCodexAppServerSideQuestion", () => {
startedAtMs = Date.now();
await expect(
runCodexAppServerSideQuestion(sideParams(), {
pluginConfig: {
appServer: {
requestTimeoutMs,
turnCompletionIdleTimeoutMs: completionTimeoutMs,
runCodexAppServerSideQuestion(
sideParams({
cfg: { tools: { loopDetection: { enabled: true } } } as never,
sessionKey: "agent:main:session-1",
}),
{
pluginConfig: {
appServer: {
requestTimeoutMs,
turnCompletionIdleTimeoutMs: completionTimeoutMs,
},
},
nativeHookRelay: { enabled: true },
},
nativeHookRelay: { enabled: true },
}),
),
).resolves.toEqual({ text: "Side answer." });
expect(relayIdDuringFork).toBeDefined();
@@ -1157,6 +1178,21 @@ describe("runCodexAppServerSideQuestion", () => {
expect(timeoutMs).toBe(120_000);
});
it("uses a 90 second default for generic side-thread dynamic tool calls", () => {
const timeoutMs = testing.resolveSideDynamicToolCallTimeoutMs({
call: {
threadId: "side-thread",
turnId: "turn-1",
callId: "tool-1",
tool: "session_status",
arguments: { sessionKey: "current" },
},
config: {} as never,
});
expect(timeoutMs).toBe(90_000);
});
it("cleans up notification handlers when side tool setup fails", async () => {
const client = createFakeClient();
createOpenClawCodingToolsMock.mockImplementation(() => {

View File

@@ -66,7 +66,10 @@ import { rememberCodexRateLimits, readRecentCodexRateLimits } from "./rate-limit
import { formatCodexUsageLimitErrorMessage } from "./rate-limits.js";
import { resolveCodexNativeExecutionBlock } from "./sandbox-guard.js";
import { readCodexAppServerBinding } from "./session-binding.js";
import { getSharedCodexAppServerClient } from "./shared-client.js";
import {
getLeasedSharedCodexAppServerClient,
releaseLeasedSharedCodexAppServerClient,
} from "./shared-client.js";
import {
buildCodexRuntimeThreadConfig,
CODEX_NATIVE_PERSONALITY_NONE,
@@ -75,7 +78,7 @@ import {
} from "./thread-lifecycle.js";
import { filterToolsForVisionInputs } from "./vision-tools.js";
const CODEX_SIDE_DYNAMIC_TOOL_TIMEOUT_MS = 30_000;
const CODEX_SIDE_DYNAMIC_TOOL_TIMEOUT_MS = 90_000;
const CODEX_SIDE_DYNAMIC_TOOL_MAX_TIMEOUT_MS = 600_000;
const CODEX_SIDE_DYNAMIC_IMAGE_GENERATION_TOOL_TIMEOUT_MS = 120_000;
const CODEX_SIDE_DYNAMIC_IMAGE_TOOL_TIMEOUT_MS = 60_000;
@@ -145,7 +148,7 @@ export async function runCodexAppServerSideQuestion(
const pluginConfig = readCodexPluginConfig(options.pluginConfig);
const appServer = resolveCodexAppServerRuntimeOptions({ pluginConfig });
const authProfileId = params.authProfileId ?? binding.authProfileId;
const client = await getSharedCodexAppServerClient({
const client = await getLeasedSharedCodexAppServerClient({
startOptions: appServer.start,
timeoutMs: appServer.requestTimeoutMs,
authProfileId,
@@ -403,6 +406,7 @@ export async function runCodexAppServerSideQuestion(
timeoutMs: appServer.requestTimeoutMs,
});
} finally {
releaseLeasedSharedCodexAppServerClient(client);
nativeHookRelay?.unregister();
}
}

View File

@@ -19,6 +19,7 @@ import {
mergeCodexThreadConfigs,
type CodexPluginThreadConfig,
} from "./plugin-thread-config.js";
import { isCodexAppServerProfilerEnabled } from "./profiler-flag.js";
import {
assertCodexThreadResumeResponse,
assertCodexThreadStartResponse,
@@ -84,6 +85,113 @@ const CODEX_LIGHTWEIGHT_CONTEXT_THREAD_CONFIG: JsonObject = {
project_doc_max_bytes: 0,
};
type CodexThreadLifecycleTimingSpan = {
name: string;
durationMs: number;
elapsedMs: number;
};
type CodexThreadLifecycleTimingSummary = {
totalMs: number;
spans: CodexThreadLifecycleTimingSpan[];
};
const CODEX_THREAD_LIFECYCLE_TIMING_WARN_TOTAL_MS = 1_000;
const CODEX_THREAD_LIFECYCLE_TIMING_WARN_STAGE_MS = 500;
function createCodexThreadLifecycleTimingTracker(options: { enabled?: boolean } = {}): {
measure: <T>(name: string, run: () => Promise<T> | T) => Promise<T>;
measureSync: <T>(name: string, run: () => T) => T;
logIfSlow: (params: {
runId: string;
sessionId: string;
sessionKey?: string;
action: "started" | "resumed" | "rotated";
threadId?: string;
}) => void;
} {
if (!options.enabled) {
return {
async measure(_name, run) {
return await run();
},
measureSync(_name, run) {
return run();
},
logIfSlow() {},
};
}
const startedAt = Date.now();
let didLog = false;
const spans: CodexThreadLifecycleTimingSpan[] = [];
const toMs = (value: number) => Math.max(0, Math.round(value));
const record = (name: string, spanStartedAt: number) => {
spans.push({
name,
durationMs: toMs(Date.now() - spanStartedAt),
elapsedMs: toMs(Date.now() - startedAt),
});
};
const snapshot = (): CodexThreadLifecycleTimingSummary => ({
totalMs: toMs(Date.now() - startedAt),
spans: spans.slice(),
});
const shouldLog = (summary: CodexThreadLifecycleTimingSummary) =>
summary.totalMs >= CODEX_THREAD_LIFECYCLE_TIMING_WARN_TOTAL_MS ||
summary.spans.some((span) => span.durationMs >= CODEX_THREAD_LIFECYCLE_TIMING_WARN_STAGE_MS);
const formatSpans = (summary: CodexThreadLifecycleTimingSummary) =>
summary.spans.length > 0
? summary.spans
.map((span) => `${span.name}:${span.durationMs}ms@${span.elapsedMs}ms`)
.join(",")
: "none";
return {
async measure(name, run) {
const spanStartedAt = Date.now();
try {
return await run();
} finally {
record(name, spanStartedAt);
}
},
measureSync(name, run) {
const spanStartedAt = Date.now();
try {
return run();
} finally {
record(name, spanStartedAt);
}
},
logIfSlow(params) {
if (didLog) {
return;
}
const summary = snapshot();
if (!shouldLog(summary)) {
return;
}
didLog = true;
embeddedAgentLog.warn(
`codex app-server thread lifecycle timings runId=${params.runId} sessionId=${
params.sessionId
} sessionKey=${params.sessionKey ?? "unknown"} action=${params.action} totalMs=${
summary.totalMs
} stages=${formatSpans(summary)}`,
{
runId: params.runId,
sessionId: params.sessionId,
sessionKey: params.sessionKey,
action: params.action,
threadId: params.threadId,
totalMs: summary.totalMs,
spans: summary.spans,
},
);
},
};
}
export async function startOrResumeThread(params: {
client: CodexAppServerClient;
params: EmbeddedRunAttemptParams;
@@ -103,10 +211,16 @@ export async function startOrResumeThread(params: {
pluginThreadConfig?: CodexPluginThreadConfigProvider;
contextEngineProjection?: CodexContextEngineThreadBootstrapProjection;
}): Promise<CodexAppServerThreadLifecycleBinding> {
const dynamicToolsFingerprint = fingerprintDynamicTools(params.dynamicTools);
const contextEngineBinding = buildContextEngineBinding(
params.params,
params.contextEngineProjection,
// Thread lifecycle spans are useful when profiling startup churn, but normal
// turns should not pay Date.now/span-array overhead while resuming threads.
const lifecycleTiming = createCodexThreadLifecycleTimingTracker({
enabled: isCodexAppServerProfilerEnabled(params.params.config),
});
const dynamicToolsFingerprint = lifecycleTiming.measureSync("fingerprint_dynamic_tools", () =>
fingerprintDynamicTools(params.dynamicTools),
);
const contextEngineBinding = lifecycleTiming.measureSync("context_engine_binding", () =>
buildContextEngineBinding(params.params, params.contextEngineProjection),
);
const userMcpServersConfigPatch =
params.userMcpServersEnabled === false
@@ -118,11 +232,13 @@ export async function startOrResumeThread(params: {
const environmentSelectionFingerprint = fingerprintEnvironmentSelection(
params.environmentSelection,
);
let binding = await readCodexAppServerBinding(params.params.sessionFile, {
authProfileStore: params.params.authProfileStore,
agentDir: params.params.agentDir,
config: params.params.config,
});
let binding = await lifecycleTiming.measure("read_binding", () =>
readCodexAppServerBinding(params.params.sessionFile, {
authProfileStore: params.params.authProfileStore,
agentDir: params.params.agentDir,
config: params.params.config,
}),
);
let preserveExistingBinding = false;
let rotatedContextEngineBinding = false;
let prebuiltPluginThreadConfig: CodexPluginThreadConfig | undefined;
@@ -207,7 +323,9 @@ export async function startOrResumeThread(params: {
})
) {
try {
prebuiltPluginThreadConfig = await params.pluginThreadConfig?.build();
prebuiltPluginThreadConfig = await lifecycleTiming.measure("plugin_config_recovery", () =>
params.pluginThreadConfig?.build(),
);
pluginBindingStale =
prebuiltPluginThreadConfig?.fingerprint !== binding.pluginAppsFingerprint;
} catch (error) {
@@ -274,19 +392,21 @@ export async function startOrResumeThread(params: {
userMcpServersConfigPatch,
params.finalConfigPatch,
);
const resumeParams = lifecycleTiming.measureSync("thread_resume_params", () =>
buildThreadResumeParams(params.params, {
threadId: binding.threadId,
authProfileId,
appServer: params.appServer,
dynamicTools: params.dynamicTools,
developerInstructions: params.developerInstructions,
config: resumeConfig,
nativeCodeModeEnabled: params.nativeCodeModeEnabled,
nativeCodeModeOnlyEnabled: params.nativeCodeModeOnlyEnabled,
}),
);
const response = assertCodexThreadResumeResponse(
await params.client.request(
"thread/resume",
buildThreadResumeParams(params.params, {
threadId: binding.threadId,
authProfileId,
appServer: params.appServer,
dynamicTools: params.dynamicTools,
developerInstructions: params.developerInstructions,
config: resumeConfig,
nativeCodeModeEnabled: params.nativeCodeModeEnabled,
nativeCodeModeOnlyEnabled: params.nativeCodeModeOnlyEnabled,
}),
await lifecycleTiming.measure("thread_resume_request", () =>
params.client.request("thread/resume", resumeParams),
),
);
const boundAuthProfileId = authProfileId;
@@ -301,29 +421,31 @@ export async function startOrResumeThread(params: {
params.mcpServersFingerprintEvaluated === true
? params.mcpServersFingerprint
: binding.mcpServersFingerprint;
await writeCodexAppServerBinding(
params.params.sessionFile,
{
threadId: response.thread.id,
cwd: params.cwd,
authProfileId: boundAuthProfileId,
model: params.params.modelId,
modelProvider: response.modelProvider ?? fallbackModelProvider,
dynamicToolsFingerprint,
userMcpServersFingerprint,
mcpServersFingerprint: nextMcpServersFingerprint,
pluginAppsFingerprint: binding.pluginAppsFingerprint,
pluginAppsInputFingerprint: binding.pluginAppsInputFingerprint,
pluginAppPolicyContext: binding.pluginAppPolicyContext,
contextEngine: contextEngineBinding,
environmentSelectionFingerprint,
createdAt: binding.createdAt,
},
{
authProfileStore: params.params.authProfileStore,
agentDir: params.params.agentDir,
config: params.params.config,
},
await lifecycleTiming.measure("thread_resume_write_binding", () =>
writeCodexAppServerBinding(
params.params.sessionFile,
{
threadId: response.thread.id,
cwd: params.cwd,
authProfileId: boundAuthProfileId,
model: params.params.modelId,
modelProvider: response.modelProvider ?? fallbackModelProvider,
dynamicToolsFingerprint,
userMcpServersFingerprint,
mcpServersFingerprint: nextMcpServersFingerprint,
pluginAppsFingerprint: binding.pluginAppsFingerprint,
pluginAppsInputFingerprint: binding.pluginAppsInputFingerprint,
pluginAppPolicyContext: binding.pluginAppPolicyContext,
contextEngine: contextEngineBinding,
environmentSelectionFingerprint,
createdAt: binding.createdAt,
},
{
authProfileStore: params.params.authProfileStore,
agentDir: params.params.agentDir,
config: params.params.config,
},
),
);
if (contextEngineBinding) {
embeddedAgentLog.info("codex app-server wrote context-engine thread binding", {
@@ -336,6 +458,13 @@ export async function startOrResumeThread(params: {
action: "resumed",
});
}
lifecycleTiming.logIfSlow({
runId: params.params.runId,
sessionId: params.params.sessionId,
sessionKey: params.params.sessionKey,
threadId: response.thread.id,
action: "resumed",
});
return {
...binding,
threadId: response.thread.id,
@@ -366,27 +495,34 @@ export async function startOrResumeThread(params: {
}
const pluginThreadConfig = params.pluginThreadConfig?.enabled
? (prebuiltPluginThreadConfig ?? (await params.pluginThreadConfig.build()))
? (prebuiltPluginThreadConfig ??
(await lifecycleTiming.measure("plugin_config_build", () =>
params.pluginThreadConfig?.build(),
)))
: undefined;
const config = mergeCodexThreadConfigs(
params.config,
userMcpServersConfigPatch,
pluginThreadConfig?.configPatch,
params.finalConfigPatch,
const config = lifecycleTiming.measureSync("merge_thread_config", () =>
mergeCodexThreadConfigs(
params.config,
userMcpServersConfigPatch,
pluginThreadConfig?.configPatch,
params.finalConfigPatch,
),
);
const startParams = lifecycleTiming.measureSync("thread_start_params", () =>
buildThreadStartParams(params.params, {
cwd: params.cwd,
dynamicTools: params.dynamicTools,
appServer: params.appServer,
developerInstructions: params.developerInstructions,
config,
nativeCodeModeEnabled: params.nativeCodeModeEnabled,
nativeCodeModeOnlyEnabled: params.nativeCodeModeOnlyEnabled,
environmentSelection: params.environmentSelection,
}),
);
const response = assertCodexThreadStartResponse(
await params.client.request(
"thread/start",
buildThreadStartParams(params.params, {
cwd: params.cwd,
dynamicTools: params.dynamicTools,
appServer: params.appServer,
developerInstructions: params.developerInstructions,
config,
nativeCodeModeEnabled: params.nativeCodeModeEnabled,
nativeCodeModeOnlyEnabled: params.nativeCodeModeOnlyEnabled,
environmentSelection: params.environmentSelection,
}),
await lifecycleTiming.measure("thread_start_request", () =>
params.client.request("thread/start", startParams),
),
);
const modelProvider = resolveCodexAppServerModelProvider({
@@ -400,29 +536,31 @@ export async function startOrResumeThread(params: {
const nextMcpServersFingerprint =
params.mcpServersFingerprintEvaluated === true ? params.mcpServersFingerprint : undefined;
if (!preserveExistingBinding) {
await writeCodexAppServerBinding(
params.params.sessionFile,
{
threadId: response.thread.id,
cwd: params.cwd,
authProfileId: params.params.authProfileId,
model: response.model ?? params.params.modelId,
modelProvider: response.modelProvider ?? modelProvider,
dynamicToolsFingerprint,
userMcpServersFingerprint,
mcpServersFingerprint: nextMcpServersFingerprint,
pluginAppsFingerprint: pluginThreadConfig?.fingerprint,
pluginAppsInputFingerprint: pluginThreadConfig?.inputFingerprint,
pluginAppPolicyContext: pluginThreadConfig?.policyContext,
contextEngine: contextEngineBinding,
environmentSelectionFingerprint,
createdAt,
},
{
authProfileStore: params.params.authProfileStore,
agentDir: params.params.agentDir,
config: params.params.config,
},
await lifecycleTiming.measure("thread_start_write_binding", () =>
writeCodexAppServerBinding(
params.params.sessionFile,
{
threadId: response.thread.id,
cwd: params.cwd,
authProfileId: params.params.authProfileId,
model: response.model ?? params.params.modelId,
modelProvider: response.modelProvider ?? modelProvider,
dynamicToolsFingerprint,
userMcpServersFingerprint,
mcpServersFingerprint: nextMcpServersFingerprint,
pluginAppsFingerprint: pluginThreadConfig?.fingerprint,
pluginAppsInputFingerprint: pluginThreadConfig?.inputFingerprint,
pluginAppPolicyContext: pluginThreadConfig?.policyContext,
contextEngine: contextEngineBinding,
environmentSelectionFingerprint,
createdAt,
},
{
authProfileStore: params.params.authProfileStore,
agentDir: params.params.agentDir,
config: params.params.config,
},
),
);
if (contextEngineBinding) {
embeddedAgentLog.info("codex app-server wrote context-engine thread binding", {
@@ -436,6 +574,13 @@ export async function startOrResumeThread(params: {
});
}
}
lifecycleTiming.logIfSlow({
runId: params.params.runId,
sessionId: params.params.sessionId,
sessionKey: params.params.sessionKey,
threadId: response.thread.id,
action: rotatedContextEngineBinding ? "rotated" : "started",
});
return {
schemaVersion: 1,
threadId: response.thread.id,
@@ -973,9 +1118,12 @@ function buildVisibleReplyInstruction(
? dynamicTools.some((tool) => tool.name.trim() === "message")
: params.disableMessageTool !== true;
if (params.sourceReplyDeliveryMode === "message_tool_only" && messageToolAvailable) {
return "To send a visible message, use the `message` tool.";
return "Visible source replies are not automatically delivered for this run. Use `message(action=send)` for user-visible source-channel output. Do not repeat that visible content in your final answer.";
}
return "To send a visible reply, use the active Codex delivery path.";
if (messageToolAvailable) {
return "For the current source conversation, reply normally in your final assistant message; OpenClaw will deliver it through the active source conversation. Use `message` only for explicit out-of-band sends, media/file sends, or sends to a different target.";
}
return "For the current source conversation, reply normally in your final assistant message; OpenClaw will deliver it through the active source conversation.";
}
function buildUserInput(

View File

@@ -14,7 +14,11 @@ import {
makeAgentUserMessage,
} from "openclaw/plugin-sdk/test-fixtures";
import { afterEach, describe, expect, it, vi } from "vitest";
import { attachCodexMirrorIdentity, mirrorCodexAppServerTranscript } from "./transcript-mirror.js";
import {
attachCodexMirrorIdentity,
buildCodexUserPromptMessage,
mirrorCodexAppServerTranscript,
} from "./transcript-mirror.js";
const emitSessionTranscriptUpdateMock = vi.hoisted(() => vi.fn());
@@ -57,6 +61,37 @@ async function makeRoot(prefix: string): Promise<string> {
return root;
}
describe("buildCodexUserPromptMessage", () => {
it("uses the prepared user transcript message for app-server prompt mirrors", () => {
const message = buildCodexUserPromptMessage({
prompt: "[Mon 2026-05-25 19:14 GMT+1] What is in this image?",
messageChannel: "webchat",
userTurnTranscriptRecorder: {
message: {
role: "user",
content: "What is in this image?",
timestamp: 1779732875151,
MediaPath: "/tmp/image.png",
MediaPaths: ["/tmp/image.png"],
MediaType: "image/png",
MediaTypes: ["image/png"],
},
},
} as unknown as Parameters<typeof buildCodexUserPromptMessage>[0]);
expect(message).toMatchObject({
role: "user",
content: "What is in this image?",
timestamp: 1779732875151,
sourceChannel: "webchat",
MediaPath: "/tmp/image.png",
MediaPaths: ["/tmp/image.png"],
MediaType: "image/png",
MediaTypes: ["image/png"],
});
});
});
function parseJsonLines<T>(raw: string): T[] {
const records: T[] = [];
for (const line of raw.trim().split("\n")) {
@@ -126,13 +161,13 @@ describe("mirrorCodexAppServerTranscript", () => {
"turn-1:prompt",
);
await mirrorCodexAppServerTranscript({
const firstMirror = await mirrorCodexAppServerTranscript({
sessionFile,
sessionKey: "agent:main:main",
messages: [userMessage],
idempotencyScope: "codex-app-server:thread-1",
});
await mirrorCodexAppServerTranscript({
const secondMirror = await mirrorCodexAppServerTranscript({
sessionFile,
sessionKey: "agent:main:main",
messages: [userMessage],
@@ -152,6 +187,18 @@ describe("mirrorCodexAppServerTranscript", () => {
idempotencyKey: "codex-app-server:thread-1:turn-1:prompt",
});
expect(updates[0]?.messageSeq).toBe(1);
expect(firstMirror.userMessagesPresent).toHaveLength(1);
expect(firstMirror.userMessagesPresent[0]).toMatchObject({
role: "user",
content: [{ type: "text", text: "show me live" }],
idempotencyKey: "codex-app-server:thread-1:turn-1:prompt",
});
expect(secondMirror.userMessagesPresent).toHaveLength(1);
expect(secondMirror.userMessagesPresent[0]).toMatchObject({
role: "user",
content: [{ type: "text", text: "show me live" }],
idempotencyKey: "codex-app-server:thread-1:turn-1:prompt",
});
});
it("emits stable sequence numbers for multi-message mirror batches", async () => {
@@ -278,6 +325,52 @@ describe("mirrorCodexAppServerTranscript", () => {
);
});
it("returns the persisted user message for duplicate mirror hits", async () => {
initializeGlobalHookRunner(
createMockPluginRegistry([
{
hookName: "before_message_write",
handler: (event) => ({
message: castAgentMessage({
...((event as { message: unknown }).message as Record<string, unknown>),
content: [{ type: "text", text: "[redacted by hook]" }],
}),
}),
},
]),
);
const sessionFile = await createTempSessionFile();
const sourceMessage = makeAgentUserMessage({
content: [{ type: "text", text: "secret prompt" }],
timestamp: Date.now(),
});
const first = await mirrorCodexAppServerTranscript({
sessionFile,
sessionKey: "session-1",
messages: [sourceMessage],
idempotencyScope: "scope-1",
});
const second = await mirrorCodexAppServerTranscript({
sessionFile,
sessionKey: "session-1",
messages: [sourceMessage],
idempotencyScope: "scope-1",
});
expect(first.userMessagesPresent[0]?.content).toEqual([
{ type: "text", text: "[redacted by hook]" },
]);
expect(second.userMessagesPresent[0]?.content).toEqual([
{ type: "text", text: "[redacted by hook]" },
]);
expect(JSON.stringify(second.userMessagesPresent)).not.toContain("secret prompt");
const records = parseJsonLines<{ type?: string; message?: { role?: string } }>(
await fs.readFile(sessionFile, "utf8"),
);
expect(records.filter((record) => record.message?.role === "user")).toHaveLength(1);
});
it("preserves the computed idempotency key when hooks rewrite message keys", async () => {
initializeGlobalHookRunner(
createMockPluginRegistry([

View File

@@ -13,6 +13,11 @@ import {
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
type MirroredAgentMessage = Extract<AgentMessage, { role: "user" | "assistant" | "toolResult" }>;
type MirroredUserMessage = Extract<AgentMessage, { role: "user" }>;
export type CodexAppServerTranscriptMirrorResult = {
userMessagesPresent: MirroredUserMessage[];
};
const MIRROR_IDENTITY_META_KEY = "mirrorIdentity" as const;
@@ -32,7 +37,10 @@ function buildSenderLabel(params: {
return `${label} (${params.senderId})`;
}
export function buildCodexUserPromptMessage(params: EmbeddedRunAttemptParams): AgentMessage {
function buildCodexUserPromptMessageFromPrepared(
params: EmbeddedRunAttemptParams,
preparedUserMessage: MirroredUserMessage | undefined,
): AgentMessage {
const senderId = normalizeOptionalString(params.senderId);
const senderName = normalizeOptionalString(params.senderName);
const senderUsername = normalizeOptionalString(params.senderUsername);
@@ -41,6 +49,20 @@ export function buildCodexUserPromptMessage(params: EmbeddedRunAttemptParams): A
const sourceChannel = normalizeOptionalString(
params.inputProvenance?.sourceChannel ?? params.messageChannel ?? params.messageProvider,
);
if (preparedUserMessage) {
return {
role: "user",
timestamp: Date.now(),
...(params.inputProvenance ? { provenance: params.inputProvenance } : {}),
...(sourceChannel ? { sourceChannel } : {}),
...(senderId ? { senderId } : {}),
...(senderName ? { senderName } : {}),
...(senderUsername ? { senderUsername } : {}),
...(senderE164 ? { senderE164 } : {}),
...(senderLabel ? { senderLabel } : {}),
...(preparedUserMessage as unknown as Record<string, unknown>),
} as AgentMessage;
}
return {
role: "user",
content: params.prompt,
@@ -55,6 +77,23 @@ export function buildCodexUserPromptMessage(params: EmbeddedRunAttemptParams): A
} as AgentMessage;
}
export function buildCodexUserPromptMessage(params: EmbeddedRunAttemptParams): AgentMessage {
return buildCodexUserPromptMessageFromPrepared(
params,
params.userTurnTranscriptRecorder?.message,
);
}
export async function buildResolvedCodexUserPromptMessage(
params: EmbeddedRunAttemptParams,
): Promise<AgentMessage> {
const resolvedMessage = await params.userTurnTranscriptRecorder?.resolveMessage();
return buildCodexUserPromptMessageFromPrepared(
params,
resolvedMessage ?? params.userTurnTranscriptRecorder?.message,
);
}
/**
* Tag a message with a stable logical identity for mirror dedupe. Callers
* should use a value that is invariant for the same logical message across
@@ -113,13 +152,13 @@ export async function mirrorCodexAppServerTranscript(params: {
messages: AgentMessage[];
idempotencyScope?: string;
config?: SessionWriteLockAcquireTimeoutConfig;
}): Promise<void> {
}): Promise<CodexAppServerTranscriptMirrorResult> {
const messages = params.messages.filter(
(message): message is MirroredAgentMessage =>
message.role === "user" || message.role === "assistant" || message.role === "toolResult",
);
if (messages.length === 0) {
return;
return { userMessagesPresent: [] };
}
const lock = await acquireSessionWriteLock({
@@ -128,6 +167,7 @@ export async function mirrorCodexAppServerTranscript(params: {
});
const appendedUpdates: Array<{ messageId: string; message: AgentMessage; messageSeq: number }> =
[];
const userMessagesPresent: MirroredUserMessage[] = [];
try {
const mirrorState = await readTranscriptMirrorState(params.sessionFile);
let nextMessageSeq = mirrorState.messageCount;
@@ -136,13 +176,17 @@ export async function mirrorCodexAppServerTranscript(params: {
const idempotencyKey = params.idempotencyScope
? `${params.idempotencyScope}:${dedupeIdentity}`
: undefined;
if (idempotencyKey && mirrorState.idempotencyKeys.has(idempotencyKey)) {
continue;
}
const transcriptMessage = {
...message,
...(idempotencyKey ? { idempotencyKey } : {}),
} as AgentMessage;
if (idempotencyKey && mirrorState.idempotencyKeys.has(idempotencyKey)) {
const persistedUserMessage = mirrorState.userMessagesByIdempotencyKey.get(idempotencyKey);
if (persistedUserMessage) {
userMessagesPresent.push(persistedUserMessage);
}
continue;
}
const nextMessage = runAgentHarnessBeforeMessageWriteHook({
message: transcriptMessage,
agentId: params.agentId,
@@ -162,8 +206,15 @@ export async function mirrorCodexAppServerTranscript(params: {
const { messageId, message: appendedMessage } = await appendSessionTranscriptMessage({
transcriptPath: params.sessionFile,
message: messageToAppend,
idempotencyLookup: idempotencyKey ? "caller-checked" : "scan",
config: params.config,
});
if (appendedMessage.role === "user") {
userMessagesPresent.push(appendedMessage);
if (idempotencyKey) {
mirrorState.userMessagesByIdempotencyKey.set(idempotencyKey, appendedMessage);
}
}
nextMessageSeq += 1;
appendedUpdates.push({ messageId, message: appendedMessage, messageSeq: nextMessageSeq });
if (idempotencyKey) {
@@ -183,12 +234,17 @@ export async function mirrorCodexAppServerTranscript(params: {
messageSeq: update.messageSeq,
});
}
return { userMessagesPresent };
}
async function readTranscriptMirrorState(
sessionFile: string,
): Promise<{ idempotencyKeys: Set<string>; messageCount: number }> {
async function readTranscriptMirrorState(sessionFile: string): Promise<{
idempotencyKeys: Set<string>;
messageCount: number;
userMessagesByIdempotencyKey: Map<string, MirroredUserMessage>;
}> {
const idempotencyKeys = new Set<string>();
const userMessagesByIdempotencyKey = new Map<string, MirroredUserMessage>();
let messageCount = 0;
let raw: string;
try {
@@ -197,23 +253,26 @@ async function readTranscriptMirrorState(
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
throw error;
}
return { idempotencyKeys, messageCount };
return { idempotencyKeys, messageCount, userMessagesByIdempotencyKey };
}
for (const line of raw.split(/\r?\n/)) {
if (!line.trim()) {
continue;
}
try {
const parsed = JSON.parse(line) as { message?: { idempotencyKey?: unknown } };
const parsed = JSON.parse(line) as { message?: AgentMessage & { idempotencyKey?: unknown } };
if ((parsed as { type?: unknown }).type === "message") {
messageCount += 1;
}
if (typeof parsed.message?.idempotencyKey === "string") {
idempotencyKeys.add(parsed.message.idempotencyKey);
if (parsed.message.role === "user") {
userMessagesByIdempotencyKey.set(parsed.message.idempotencyKey, parsed.message);
}
}
} catch {
continue;
}
}
return { idempotencyKeys, messageCount };
return { idempotencyKeys, messageCount, userMessagesByIdempotencyKey };
}

View File

@@ -2,4 +2,4 @@ export const MIN_CODEX_APP_SERVER_VERSION = "0.125.0";
export const MIN_CODEX_SANDBOX_EXEC_SERVER_APP_SERVER_VERSION = "0.132.0";
export const MANAGED_CODEX_APP_SERVER_PACKAGE = "@openai/codex";
// Keep this in sync with the Codex CLI live-test package pin.
export const MANAGED_CODEX_APP_SERVER_PACKAGE_VERSION = "0.133.0";
export const MANAGED_CODEX_APP_SERVER_PACKAGE_VERSION = "0.134.0";

View File

@@ -18,7 +18,11 @@ const agentRuntimeMocks = vi.hoisted(() => ({
saveAuthProfileStore: vi.fn(),
}));
vi.mock("./app-server/shared-client.js", () => sharedClientMocks);
vi.mock("./app-server/shared-client.js", () => ({
...sharedClientMocks,
getLeasedSharedCodexAppServerClient: sharedClientMocks.getSharedCodexAppServerClient,
releaseLeasedSharedCodexAppServerClient: vi.fn(),
}));
vi.mock("openclaw/plugin-sdk/agent-runtime", () => agentRuntimeMocks);
import {

View File

@@ -33,7 +33,10 @@ import {
writeCodexAppServerBinding,
type CodexAppServerAuthProfileLookup,
} from "./app-server/session-binding.js";
import { getSharedCodexAppServerClient } from "./app-server/shared-client.js";
import {
getLeasedSharedCodexAppServerClient,
releaseLeasedSharedCodexAppServerClient,
} from "./app-server/shared-client.js";
import { CODEX_NATIVE_PERSONALITY_NONE } from "./app-server/thread-lifecycle.js";
import { formatCodexDisplayText } from "./command-formatters.js";
import {
@@ -270,52 +273,56 @@ async function attachExistingThread(params: {
modelProvider: params.modelProvider,
...agentLookup,
});
const client = await getSharedCodexAppServerClient({
const client = await getLeasedSharedCodexAppServerClient({
startOptions: runtime.start,
timeoutMs: runtime.requestTimeoutMs,
authProfileId: params.authProfileId,
...agentLookup,
});
const response: CodexThreadResumeResponse = await client.request(
CODEX_CONTROL_METHODS.resumeThread,
{
threadId: params.threadId,
...(params.model ? { model: params.model } : {}),
...(modelProvider ? { modelProvider } : {}),
personality: CODEX_NATIVE_PERSONALITY_NONE,
approvalPolicy: params.approvalPolicy ?? runtime.approvalPolicy,
approvalsReviewer: runtime.approvalsReviewer,
sandbox: params.sandbox ?? runtime.sandbox,
...((params.serviceTier ?? runtime.serviceTier)
? { serviceTier: params.serviceTier ?? runtime.serviceTier }
: {}),
persistExtendedHistory: true,
},
{ timeoutMs: runtime.requestTimeoutMs },
);
const thread = response.thread;
const runtimeApprovalPolicy =
typeof runtime.approvalPolicy === "string" ? runtime.approvalPolicy : undefined;
await writeCodexAppServerBinding(
params.sessionFile,
{
threadId: thread.id,
cwd: thread.cwd ?? params.workspaceDir,
authProfileId: params.authProfileId,
model: response.model ?? params.model,
modelProvider: normalizeCodexAppServerBindingModelProvider({
try {
const response: CodexThreadResumeResponse = await client.request(
CODEX_CONTROL_METHODS.resumeThread,
{
threadId: params.threadId,
...(params.model ? { model: params.model } : {}),
...(modelProvider ? { modelProvider } : {}),
personality: CODEX_NATIVE_PERSONALITY_NONE,
approvalPolicy: params.approvalPolicy ?? runtime.approvalPolicy,
approvalsReviewer: runtime.approvalsReviewer,
sandbox: params.sandbox ?? runtime.sandbox,
...((params.serviceTier ?? runtime.serviceTier)
? { serviceTier: params.serviceTier ?? runtime.serviceTier }
: {}),
persistExtendedHistory: true,
},
{ timeoutMs: runtime.requestTimeoutMs },
);
const thread = response.thread;
const runtimeApprovalPolicy =
typeof runtime.approvalPolicy === "string" ? runtime.approvalPolicy : undefined;
await writeCodexAppServerBinding(
params.sessionFile,
{
threadId: thread.id,
cwd: thread.cwd ?? params.workspaceDir,
authProfileId: params.authProfileId,
modelProvider: response.modelProvider ?? params.modelProvider,
model: response.model ?? params.model,
modelProvider: normalizeCodexAppServerBindingModelProvider({
authProfileId: params.authProfileId,
modelProvider: response.modelProvider ?? params.modelProvider,
...agentLookup,
}),
approvalPolicy: params.approvalPolicy ?? runtimeApprovalPolicy,
sandbox: params.sandbox ?? runtime.sandbox,
serviceTier: params.serviceTier ?? runtime.serviceTier,
},
{
...agentLookup,
}),
approvalPolicy: params.approvalPolicy ?? runtimeApprovalPolicy,
sandbox: params.sandbox ?? runtime.sandbox,
serviceTier: params.serviceTier ?? runtime.serviceTier,
},
{
...agentLookup,
},
);
},
);
} finally {
releaseLeasedSharedCodexAppServerClient(client);
}
}
async function createThread(params: {
@@ -340,54 +347,58 @@ async function createThread(params: {
modelProvider: params.modelProvider,
...agentLookup,
});
const client = await getSharedCodexAppServerClient({
const client = await getLeasedSharedCodexAppServerClient({
startOptions: runtime.start,
timeoutMs: runtime.requestTimeoutMs,
authProfileId: params.authProfileId,
...agentLookup,
});
const response: CodexThreadStartResponse = await client.request(
"thread/start",
{
cwd: params.workspaceDir,
...(params.model ? { model: params.model } : {}),
...(modelProvider ? { modelProvider } : {}),
personality: CODEX_NATIVE_PERSONALITY_NONE,
approvalPolicy: params.approvalPolicy ?? runtime.approvalPolicy,
approvalsReviewer: runtime.approvalsReviewer,
sandbox: params.sandbox ?? runtime.sandbox,
...((params.serviceTier ?? runtime.serviceTier)
? { serviceTier: params.serviceTier ?? runtime.serviceTier }
: {}),
developerInstructions:
"This Codex thread is bound to an OpenClaw conversation. Answer normally; OpenClaw will deliver your final response back to the conversation.",
experimentalRawEvents: true,
persistExtendedHistory: true,
},
{ timeoutMs: runtime.requestTimeoutMs },
);
const runtimeApprovalPolicy =
typeof runtime.approvalPolicy === "string" ? runtime.approvalPolicy : undefined;
await writeCodexAppServerBinding(
params.sessionFile,
{
threadId: response.thread.id,
cwd: response.thread.cwd ?? params.workspaceDir,
authProfileId: params.authProfileId,
model: response.model ?? params.model,
modelProvider: normalizeCodexAppServerBindingModelProvider({
try {
const response: CodexThreadStartResponse = await client.request(
"thread/start",
{
cwd: params.workspaceDir,
...(params.model ? { model: params.model } : {}),
...(modelProvider ? { modelProvider } : {}),
personality: CODEX_NATIVE_PERSONALITY_NONE,
approvalPolicy: params.approvalPolicy ?? runtime.approvalPolicy,
approvalsReviewer: runtime.approvalsReviewer,
sandbox: params.sandbox ?? runtime.sandbox,
...((params.serviceTier ?? runtime.serviceTier)
? { serviceTier: params.serviceTier ?? runtime.serviceTier }
: {}),
developerInstructions:
"This Codex thread is bound to an OpenClaw conversation. Answer normally; OpenClaw will deliver your final response back to the conversation.",
experimentalRawEvents: true,
persistExtendedHistory: true,
},
{ timeoutMs: runtime.requestTimeoutMs },
);
const runtimeApprovalPolicy =
typeof runtime.approvalPolicy === "string" ? runtime.approvalPolicy : undefined;
await writeCodexAppServerBinding(
params.sessionFile,
{
threadId: response.thread.id,
cwd: response.thread.cwd ?? params.workspaceDir,
authProfileId: params.authProfileId,
modelProvider: response.modelProvider ?? params.modelProvider,
model: response.model ?? params.model,
modelProvider: normalizeCodexAppServerBindingModelProvider({
authProfileId: params.authProfileId,
modelProvider: response.modelProvider ?? params.modelProvider,
...agentLookup,
}),
approvalPolicy: params.approvalPolicy ?? runtimeApprovalPolicy,
sandbox: params.sandbox ?? runtime.sandbox,
serviceTier: params.serviceTier ?? runtime.serviceTier,
},
{
...agentLookup,
}),
approvalPolicy: params.approvalPolicy ?? runtimeApprovalPolicy,
sandbox: params.sandbox ?? runtime.sandbox,
serviceTier: params.serviceTier ?? runtime.serviceTier,
},
{
...agentLookup,
},
);
},
);
} finally {
releaseLeasedSharedCodexAppServerClient(client);
}
}
async function runBoundTurn(params: {
@@ -407,7 +418,7 @@ async function runBoundTurn(params: {
throw new Error("bound Codex conversation has no thread binding");
}
const client = await getSharedCodexAppServerClient({
const client = await getLeasedSharedCodexAppServerClient({
startOptions: runtime.start,
timeoutMs: runtime.requestTimeoutMs,
authProfileId: binding.authProfileId,
@@ -498,6 +509,7 @@ async function runBoundTurn(params: {
} finally {
notificationCleanup();
requestCleanup();
releaseLeasedSharedCodexAppServerClient(client);
}
}

View File

@@ -20,7 +20,11 @@ const sharedClientMocks = vi.hoisted(() => ({
getSharedCodexAppServerClient: vi.fn(),
}));
vi.mock("./app-server/shared-client.js", () => sharedClientMocks);
vi.mock("./app-server/shared-client.js", () => ({
...sharedClientMocks,
getLeasedSharedCodexAppServerClient: sharedClientMocks.getSharedCodexAppServerClient,
releaseLeasedSharedCodexAppServerClient: vi.fn(),
}));
describe("codex conversation controls", () => {
beforeEach(async () => {

View File

@@ -10,7 +10,10 @@ import {
readCodexAppServerBinding,
writeCodexAppServerBinding,
} from "./app-server/session-binding.js";
import { getSharedCodexAppServerClient } from "./app-server/shared-client.js";
import {
getLeasedSharedCodexAppServerClient,
releaseLeasedSharedCodexAppServerClient,
} from "./app-server/shared-client.js";
import { formatCodexDisplayText } from "./command-formatters.js";
type ActiveTurn = {
@@ -61,20 +64,24 @@ export async function stopCodexConversationTurn(params: {
const runtime = resolveCodexAppServerRuntimeOptions({ pluginConfig: params.pluginConfig });
const lookup = buildBindingLookup(params);
const binding = await readCodexAppServerBinding(params.sessionFile, lookup);
const client = await getSharedCodexAppServerClient({
const client = await getLeasedSharedCodexAppServerClient({
startOptions: runtime.start,
timeoutMs: runtime.requestTimeoutMs,
authProfileId: binding?.authProfileId,
...lookup,
});
await client.request(
"turn/interrupt",
{
threadId: active.threadId,
turnId: active.turnId,
},
{ timeoutMs: runtime.requestTimeoutMs },
);
try {
await client.request(
"turn/interrupt",
{
threadId: active.threadId,
turnId: active.turnId,
},
{ timeoutMs: runtime.requestTimeoutMs },
);
} finally {
releaseLeasedSharedCodexAppServerClient(client);
}
return { stopped: true, message: "Codex stop requested." };
}
@@ -96,21 +103,25 @@ export async function steerCodexConversationTurn(params: {
const runtime = resolveCodexAppServerRuntimeOptions({ pluginConfig: params.pluginConfig });
const lookup = buildBindingLookup(params);
const binding = await readCodexAppServerBinding(params.sessionFile, lookup);
const client = await getSharedCodexAppServerClient({
const client = await getLeasedSharedCodexAppServerClient({
startOptions: runtime.start,
timeoutMs: runtime.requestTimeoutMs,
authProfileId: binding?.authProfileId,
...lookup,
});
await client.request(
"turn/steer",
{
threadId: active.threadId,
expectedTurnId: active.turnId,
input: [{ type: "text", text, text_elements: [] }],
},
{ timeoutMs: runtime.requestTimeoutMs },
);
try {
await client.request(
"turn/steer",
{
threadId: active.threadId,
expectedTurnId: active.turnId,
input: [{ type: "text", text, text_elements: [] }],
},
{ timeoutMs: runtime.requestTimeoutMs },
);
} finally {
releaseLeasedSharedCodexAppServerClient(client);
}
return { steered: true, message: "Sent steer message to Codex." };
}
@@ -261,25 +272,29 @@ async function resumeThreadWithOverrides(params: {
serviceTier?: CodexServiceTier;
}): Promise<CodexThreadResumeResponse> {
const runtime = resolveCodexAppServerRuntimeOptions({ pluginConfig: params.pluginConfig });
const client = await getSharedCodexAppServerClient({
const client = await getLeasedSharedCodexAppServerClient({
startOptions: runtime.start,
timeoutMs: runtime.requestTimeoutMs,
authProfileId: params.authProfileId,
...buildBindingLookup(params),
});
return await client.request(
CODEX_CONTROL_METHODS.resumeThread,
{
threadId: params.threadId,
...(params.model ? { model: params.model } : {}),
approvalPolicy: params.approvalPolicy ?? runtime.approvalPolicy,
sandbox: params.sandbox ?? runtime.sandbox,
approvalsReviewer: runtime.approvalsReviewer,
...(params.serviceTier ? { serviceTier: params.serviceTier } : {}),
persistExtendedHistory: true,
},
{ timeoutMs: runtime.requestTimeoutMs },
);
try {
return await client.request(
CODEX_CONTROL_METHODS.resumeThread,
{
threadId: params.threadId,
...(params.model ? { model: params.model } : {}),
approvalPolicy: params.approvalPolicy ?? runtime.approvalPolicy,
sandbox: params.sandbox ?? runtime.sandbox,
approvalsReviewer: runtime.approvalsReviewer,
...(params.serviceTier ? { serviceTier: params.serviceTier } : {}),
persistExtendedHistory: true,
},
{ timeoutMs: runtime.requestTimeoutMs },
);
} finally {
releaseLeasedSharedCodexAppServerClient(client);
}
}
function buildBindingLookup(params: {

View File

@@ -42,7 +42,8 @@ import type { v2 } from "../app-server/protocol.js";
import { requestCodexAppServerJson } from "../app-server/request.js";
import {
clearSharedCodexAppServerClientIfCurrentAndWait,
getSharedCodexAppServerClient,
getLeasedSharedCodexAppServerClient,
releaseLeasedSharedCodexAppServerClient,
} from "../app-server/shared-client.js";
import { applyCodexAuthItem, buildCodexAuthConfigPatchItems } from "./auth.js";
import { buildCodexMigrationPlan } from "./plan.js";
@@ -86,8 +87,8 @@ export function prepareTargetCodexAppServer(
): CodexMigrationTargetAppServerPreparation {
const appServer = resolveTargetCodexAppServer(ctx);
const targets = resolveCodexMigrationTargets(ctx);
let warmedClient: Awaited<ReturnType<typeof getSharedCodexAppServerClient>> | undefined;
const ready = getSharedCodexAppServerClient({
let warmedClient: Awaited<ReturnType<typeof getLeasedSharedCodexAppServerClient>> | undefined;
const ready = getLeasedSharedCodexAppServerClient({
startOptions: appServer.start,
timeoutMs: 60_000,
agentDir: targets.agentDir,
@@ -101,6 +102,9 @@ export function prepareTargetCodexAppServer(
return {
async dispose() {
await ready;
if (warmedClient) {
releaseLeasedSharedCodexAppServerClient(warmedClient);
}
await clearSharedCodexAppServerClientIfCurrentAndWait(warmedClient, {
exitTimeoutMs: 2_000,
forceKillDelayMs: 250,

View File

@@ -53,6 +53,7 @@ const logShutdown = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
const traceExporterCtor = vi.hoisted(() => vi.fn());
const metricExporterCtor = vi.hoisted(() => vi.fn());
const logExporterCtor = vi.hoisted(() => vi.fn());
const spanProcessorCtor = vi.hoisted(() => vi.fn());
const unhandledRejectionHandlerState = vi.hoisted(() => {
let handlers: Array<(reason: unknown) => boolean> = [];
return {
@@ -136,6 +137,9 @@ vi.mock("@opentelemetry/sdk-metrics", () => ({
}));
vi.mock("@opentelemetry/sdk-trace-base", () => ({
BatchSpanProcessor: function BatchSpanProcessor(exporter?: unknown, options?: unknown) {
spanProcessorCtor(exporter, options);
},
ParentBasedSampler: function ParentBasedSampler() {},
TraceIdRatioBasedSampler: function TraceIdRatioBasedSampler() {},
}));
@@ -261,6 +265,10 @@ function firstExporterOptions(mock: { mock: { calls: unknown[][] } }): { url?: s
return mockCallArg(mock, 0) as { url?: string };
}
function firstSpanProcessorOptions(): { scheduledDelayMillis?: number } {
return mockCallArg(spanProcessorCtor, 1) as { scheduledDelayMillis?: number };
}
function firstSetSpanContext(): Record<string, unknown> {
return mockCallArg(telemetryState.tracer.setSpanContext, 1) as Record<string, unknown>;
}
@@ -390,6 +398,7 @@ describe("diagnostics-otel service", () => {
traceExporterCtor.mockClear();
metricExporterCtor.mockClear();
logExporterCtor.mockClear();
spanProcessorCtor.mockClear();
unhandledRejectionHandlerState.reset();
unhandledRejectionHandlerState.register.mockClear();
delete process.env.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT;
@@ -1195,6 +1204,18 @@ describe("diagnostics-otel service", () => {
await service.stop?.(ctx);
});
test("applies flush interval to trace batching", async () => {
const service = createDiagnosticsOtelService();
const ctx = createTraceOnlyContext(OTEL_TEST_ENDPOINT);
ctx.config.diagnostics!.otel!.flushIntervalMs = 250;
await service.start(ctx);
expect(spanProcessorCtor).toHaveBeenCalledTimes(1);
expect(firstSpanProcessorOptions().scheduledDelayMillis).toBe(1000);
await service.stop?.(ctx);
});
test("uses signal-specific OTLP endpoints ahead of the shared endpoint", async () => {
const service = createDiagnosticsOtelService();
const ctx = createOtelContext(OTEL_TEST_ENDPOINT, {

View File

@@ -14,7 +14,11 @@ import { resourceFromAttributes } from "@opentelemetry/resources";
import { BatchLogRecordProcessor, LoggerProvider } from "@opentelemetry/sdk-logs";
import { PeriodicExportingMetricReader } from "@opentelemetry/sdk-metrics";
import { NodeSDK } from "@opentelemetry/sdk-node";
import { ParentBasedSampler, TraceIdRatioBasedSampler } from "@opentelemetry/sdk-trace-base";
import {
BatchSpanProcessor,
ParentBasedSampler,
TraceIdRatioBasedSampler,
} from "@opentelemetry/sdk-trace-base";
import { ATTR_SERVICE_NAME } from "@opentelemetry/semantic-conventions";
import {
ATTR_GEN_AI_INPUT_MESSAGES,
@@ -1167,6 +1171,14 @@ export function createDiagnosticsOtelService(): OpenClawPluginService {
...(headers ? { headers } : {}),
})
: undefined;
const spanProcessors =
traceExporter && typeof otel.flushIntervalMs === "number"
? [
new BatchSpanProcessor(traceExporter, {
scheduledDelayMillis: Math.max(1000, otel.flushIntervalMs),
}),
]
: undefined;
const metricExporter = metricsEnabled
? new OTLPMetricExporter({
@@ -1186,7 +1198,7 @@ export function createDiagnosticsOtelService(): OpenClawPluginService {
sdk = new NodeSDK({
resource,
...(traceExporter ? { traceExporter } : {}),
...(spanProcessors ? { spanProcessors } : traceExporter ? { traceExporter } : {}),
...(metricReader ? { metricReader } : {}),
...(sampleRate !== undefined
? {

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