Compare commits

..

202 Commits

Author SHA1 Message Date
Josh Lehman
cb3e2587c8 fix(status): gate plugin health subcommand 2026-06-14 08:09:24 -07:00
Josh Lehman
580a19f576 fix(state): require positive process incarnation for persisted health records 2026-06-14 08:09:24 -07:00
Josh Lehman
6e2d501eb5 fix(status): make tool quarantine recovery reachable and drop dead compact-status args 2026-06-14 08:09:24 -07:00
Josh Lehman
5c1ec80ce6 fix(status): reconcile plugin health records 2026-06-14 08:09:24 -07:00
Josh Lehman
7119f05bd6 refactor(status): simplify plugin health collection and unify quarantine stores 2026-06-14 08:09:24 -07:00
Josh Lehman
8dcda9b63e feat(plugins): tag channel-setup load failures with a closed diagnostic code 2026-06-14 08:09:24 -07:00
Josh Lehman
848b190336 fix(status): dedupe channel health failures 2026-06-14 08:09:23 -07:00
Josh Lehman
41a0eab391 fix(status): register status subcommands 2026-06-14 08:09:23 -07:00
Josh Lehman
40338244b9 fix(status): clean up plugin health edge cases 2026-06-14 08:09:23 -07:00
Josh Lehman
aac43fc35c fix(status): harden plugin health reporting 2026-06-14 08:09:23 -07:00
Josh Lehman
2b97969dd7 fix(status): surface plugin runtime health failures 2026-06-14 08:09:23 -07:00
Josh Lehman
1bba173b19 fix(status): scope context engine quarantine cleanup 2026-06-14 08:09:23 -07:00
Josh Lehman
bddf5d4f38 fix(status): preserve sibling context engine quarantines 2026-06-14 08:09:23 -07:00
Josh Lehman
814f093202 fix(status): surface runtime context engine quarantines 2026-06-14 08:09:23 -07:00
Josh Lehman
f066c2ced5 feat(status): surface plugin health 2026-06-14 08:09:22 -07:00
Vincent Koc
3826cda4d8 fix(gateway): run export helpers through cli entry 2026-06-14 23:08:19 +08:00
Vortex Openclaw
771881d189 fix(elevenlabs): use current TTS model ids (#92904)
* fix(elevenlabs): use current TTS model ids

* fix(elevenlabs): preserve served legacy model choices

---------

Co-authored-by: Ariel Bravy <ariel@vortexradar.com>
Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-06-14 23:01:27 +08:00
ZengWen-DT
6db496b04b fix(tui): keep spinner active when toggling tools (#92909)
* fix(tui): keep spinner active when toggling tools

* fix(tui): preserve finishing status when toggling tools

---------

Co-authored-by: zengwen <zeng_wen@foxmail.com>
Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-06-14 22:59:29 +08:00
iloveleon19
4892bbc10f fix(mattermost): merge progress preview lines by identity (#91331)
* fix(mattermost): merge progress preview lines by identity

* fix(mattermost): preserve progress across assistant boundaries

* fix(mattermost): compose reasoning with progress previews

* fix(channels): reset reasoning progress at block boundaries

* fix(channels): align tool progress line identities

* fix(channels): keep tool call identity mapping injective

---------

Co-authored-by: leon <leon@gmail.com>
Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-06-14 22:58:54 +08:00
Yuval Dinodia
7f6df80537 fix(google): route Gemini CLI OAuth through the env proxy (#46184) (#92815) 2026-06-14 22:52:18 +08:00
이민재
1a2e418500 fix: use passive periodic sqlite wal checkpoints
Use PASSIVE for periodic SQLite WAL checkpoints while keeping explicit checkpoint() and close() on TRUNCATE by default.

Preserve the old interval export as a compatibility alias, add the neutral interval export, and update the task storage docs contract.

Fixes #81715.
2026-06-14 22:46:15 +08:00
liuhao1024
a823cc3b1c docs(browser-control): document OPENCLAW_EAGER_BROWSER_CONTROL_SERVER requirement (#92845)
The standalone loopback HTTP API only starts when
OPENCLAW_EAGER_BROWSER_CONTROL_SERVER=1 is set in the gateway
service environment. Without it, browser control works via CLI and
agent tools but nothing listens on the loopback control port.

Fixes #92841
2026-06-14 22:43:26 +08:00
Colin Johnson
fd855c831f feat(webui): add session workspace rail (#92856)
* feat(webui): add session workspace rail

* fix(webui): address session workspace review

* fix(webui): secure session workspace previews

* fix(webui): handle nested session workspace paths

* fix(webui): update session file protocol models

* fix(webui): clear session rail lint
2026-06-14 22:39:51 +08:00
Vincent Koc
ccf5976d06 fix(gateway): async trajectory export approvals 2026-06-14 22:25:47 +08:00
abel-zer0
b5999bc6a0 fix(agents): drop incomplete reasoning replay turns (#88656)
Drop assistant replay turns that ended at the token limit with only incomplete hidden reasoning while preserving visible text, tool calls, empty turns, and unknown content shapes. Apply the same classification to embedded replay and public transport transforms, with focused regression, live OpenAI/Anthropic provider proof, docs, autoreview, Testbox, and green CI.

Co-authored-by: clawstation <abel@stationzero.ai>
2026-06-14 07:02:03 -07:00
Vincent Koc
fc6d448138 fix(ci): skip session accessor guard for older targets 2026-06-14 21:35:37 +08:00
Vincent Koc
2e745ba225 fix(webchat): route trajectory slash export before agent dispatch 2026-06-14 21:22:40 +08:00
Josh Lehman
ef47dd610c refactor: add session accessor seam with gateway consumer (#90463)
Merged via squash.

Prepared head SHA: 58aa59eaf8
Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com>
Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com>
Reviewed-by: @jalehman
2026-06-14 06:18:47 -07:00
mmyzwl
fbc3fa3876 fix(agents): recover genericized Anthropic thinking errors (#92916)
Recover invalid Anthropic thinking replays when provider details survive genericization in SDK, failover, cause-chain, or terminal stream error fields.

The recovery matcher now uses cycle-safe named error carriers, avoids scanning assistant content and tool arguments, and retains one retry per provider call. Focused regressions cover each carrier, cyclic causes, terminal errors, and false-positive payload text.

Addresses the recovery path in #92201. The separate root cause that creates or persists invalid signatures remains open for investigation.

Co-authored-by: wlzeng0668001202 <ceng.wenlong@xydigit.com>
2026-06-14 06:08:24 -07:00
Vincent Koc
d28691da97 fix(release): preserve child release check refs 2026-06-14 20:55:05 +08:00
Ayaan Zaidi
65e6d9c98c fix(auto-reply): strip delivery hints from leading metadata 2026-06-14 18:18:20 +05:30
Ayaan Zaidi
d498b1cce4 fix(plugin-sdk): expose delivery hints without utility imports 2026-06-14 18:18:20 +05:30
Ayaan Zaidi
210877a73e fix(auto-reply): share message-tool delivery hints 2026-06-14 18:18:20 +05:30
Ayaan Zaidi
d5b1d4529f test(auto-reply): trim duplicate progress assertion 2026-06-14 18:18:20 +05:30
Forge
36f6008842 test(auto-reply): assert allowed suppressed progress gating 2026-06-14 18:18:20 +05:30
Forge
cf88b4c024 fix(auto-reply): align message-tool progress gating 2026-06-14 18:18:20 +05:30
ragesaq
a15427d605 fix(auto-reply): deliver channel message-tool final replies
Clarify that interim assistant text remains visible under message_tool_only delivery while the final answer must use the message tool, and forward progress for channel message-tool turns once the message tool has delivered the final reply.

Co-authored-by: Forge <forge@psiclawops.dev>

Co-authored-by: Chisel <chisel@psiclawops.dev>
2026-06-14 18:18:20 +05:30
David
23d74dad12 fix(lmstudio): honor thinking off for binary reasoning models (#92002)
Scope disabled-thinking payload repair to LM Studio's lightweight provider stream hook. Preserve official OpenAI and Anthropic tool-calling paths.

Co-authored-by: David <32288+nxmxbbd@users.noreply.github.com>
2026-06-14 05:41:49 -07:00
Vincent Koc
ae8da992ce test(gateway): unblock trajectory export live release gate 2026-06-14 20:41:34 +08:00
Stellar鱼
4644e0c102 fix(memory-wiki): tolerate artifacts without agent ids
Fixes #92207.

Normalize public memory artifacts at the memory host boundary so providers that omit agentIds produce an empty list instead of throwing during artifact cloning, sorting, or memory-wiki bridge import. The bridge now renders those artifacts with unknown agents while downstream consumers still receive stable array-shaped metadata.

Verification:
- node scripts/run-vitest.mjs src/plugins/memory-state.test.ts extensions/memory-wiki/src/bridge.test.ts --maxWorkers=1
- .agents/skills/autoreview/scripts/autoreview --mode branch --base origin/main
- Crabbox run_2a30de5d0a00 / cbx_3684cb0b7ea5: OPENCLAW_CHECK_CHANGED_REMOTE_CHILD=1 OPENCLAW_CHANGED_LANES_RAW_SYNC=1 corepack pnpm check:changed
- GitHub PR checks clean on 19678ed60f
2026-06-14 20:19:24 +08:00
JC
fff193402e fix(gateway): build row metadata for single session lists
Refs #92057.

Build the request-scoped row metadata context for every non-empty sessions.list result, including limit=1, so single-row lists use the shared subagent metadata read index instead of direct per-row registry snapshot lookups. This keeps the existing single-row store child-session candidate optimization intact while removing the single-row metadata-cache gap.

Verification:
- node scripts/run-vitest.mjs src/gateway/session-utils.single-row-cache.test.ts --maxWorkers=1
- .agents/skills/autoreview/scripts/autoreview --mode branch --base origin/main
- Crabbox run_f89b56ffea83 / cbx_f1b1f5013225: OPENCLAW_CHECK_CHANGED_REMOTE_CHILD=1 OPENCLAW_CHANGED_LANES_RAW_SYNC=1 corepack pnpm check:changed
- GitHub PR checks clean on 1ba6619f2e
2026-06-14 19:50:30 +08:00
danbao
c78f9376d9 fix(gateway): degrade config watcher to polling
Fixes #92851.

When native filesystem watching exhausts its retry budget, the gateway config reloader now falls back to polling instead of disabling hot reload for the rest of the process. The watcher state tracks the effective Chokidar polling mode, including CHOKIDAR_USEPOLLING overrides, so forced polling avoids a redundant native phase and forced native mode reports an accurate native-mode disable.

Verification:
- node scripts/run-vitest.mjs src/gateway/config-reload.test.ts --maxWorkers=1
- .agents/skills/autoreview/scripts/autoreview --mode branch --base origin/main
- Testbox-through-Crabbox tbx_01kv2xvbqkv4dmvvvsswzm75hz: OPENCLAW_CHECK_CHANGED_REMOTE_CHILD=1 OPENCLAW_CHANGED_LANES_RAW_SYNC=1 corepack pnpm check:changed
- GitHub PR checks clean on c9762c5159
2026-06-14 19:29:29 +08:00
Peter Steinberger
d4a74b4993 fix(subagents): handle configured bare model provenance 2026-06-14 04:29:22 -07:00
Jasmine Zhang
aa04aef629 fix(subagents): preserve config-selected child model overrides 2026-06-14 04:29:22 -07:00
Vincent Koc
0716ebc1e5 test(openai): extend live STT fixture timeout 2026-06-14 19:21:25 +08:00
Chunyue Wang
cda040b4e5 fix(agents): clamp subagent spawn thinking overrides
Fixes #92412.

Subagent spawns that request an unsupported explicit thinking level now clamp through the existing provider/model thinking fallback instead of hard-failing after the orchestrator has already received an accepted ack. The exception is limited to trusted subagent spawn runs by requiring both the subagent lane and a subagent-shaped session key, so interactive and non-subagent explicit `--thinking` validation still fails loudly.

Verification:
- `node scripts/run-vitest.mjs src/agents/agent-command.live-model-switch.test.ts --maxWorkers=1`
- `.agents/skills/autoreview/scripts/autoreview --mode branch --base origin/main`
- Testbox-through-Crabbox `tbx_01kv2wt0nqavsmnvzzzy2antrc`: `OPENCLAW_CHECK_CHANGED_REMOTE_CHILD=1 OPENCLAW_CHANGED_LANES_RAW_SYNC=1 corepack pnpm check:changed`
- GitHub PR checks clean on `c71186863337d9dfb9a18e5349ebef634a7d5ccd`
2026-06-14 19:11:16 +08:00
Vincent Koc
00479b12d1 test(gateway): authorize trajectory export live command 2026-06-14 18:57:39 +08:00
Peter Steinberger
2a6eeceb40 fix(openai): recover invalid reasoning signatures (#92941) 2026-06-14 03:51:16 -07:00
Vincent Koc
30f5e6f639 test(installer): stabilize npm prefix probe test 2026-06-14 18:48:11 +08:00
Vincent Koc
dae9345407 test(gateway): isolate trajectory export live seed turn 2026-06-14 18:31:48 +08:00
Thomas Krohnfuß
a12942518e fix(openai): omit gpt-5.5 tool reasoning effort (#90574)
Fix GPT-5.5 Chat Completions tool requests by omitting the incompatible reasoning effort only on verified OpenAI and Azure routes. Preserve no-tool requests and nonblank custom OpenAI-compatible providers; add official regional endpoint metadata plus OpenAI and Anthropic live regression proof.

Co-authored-by: Thomas Krohnfuß <thomas.krohnfuss@stud.th-luebeck.de>
2026-06-14 03:28:54 -07:00
ghitafilali
b2c5e790b4 fix: restart gateway after isolated cron setup timeout
Restart the gateway after isolated cron setup timeouts and harden stale cron-task finalization around restart/reload boundaries.

Verification: focused cron/tasks/CLI regression suites, gateway filesystem/session regression suite, test typecheck, core lint shards, git diff --check, autoreview, Blacksmith Testbox changed gate tbx_01kv2srgbex71w9ce5rwv2wtr4, and clean GitHub PR checks on 13d06b5d6f.
2026-06-14 18:26:27 +08:00
Vincent Koc
37e3e895b0 test(release): unblock beta validation checks 2026-06-14 18:02:38 +08:00
Vincent Koc
2f34d06b42 fix(release): harden beta validation gates
(cherry picked from commit 91eeda0d708c2d8dac7c09c259b7cf390193f83f)
2026-06-14 17:33:48 +08:00
liuhao1024
db5e415888 fix(feishu): re-resolve route when dynamic agent binding already exists in runtime config (fixes #42837) (#92814)
* fix(feishu): re-resolve route when dynamic agent binding already exists in runtime config

When dynamicAgentCreation is enabled and a binding was previously written
to the config file (e.g. from a prior message), the in-memory cfg may be
stale and not contain the binding. Previously, maybeCreateDynamicAgent
returned { created: false, updatedCfg: cfg } with the stale cfg, and
bot.ts only re-resolved the route when created === true. This caused
subsequent messages to still route to agent:main.

Fix: check runtime.config.current() for the binding when it is missing
from the in-memory cfg. When found, return the runtime's current config
so the caller can re-resolve the route with up-to-date bindings.

Fixes #42837

* fix(feishu): serialize dynamic agent config updates

* fix(feishu): route with refreshed runtime config

* fix(feishu): use current dynamic-agent policy

* fix(feishu): reauthorize refreshed dynamic routes

* fix(feishu): authorize dynamic agent mutations

* fix(feishu): complete account-scoped dynamic routing

* fix(feishu): revalidate current direct routes

* fix(feishu): isolate named-account dynamic agents

* fix(feishu): bound named dynamic agent ids

* docs(feishu): explain legacy dynamic agent cap

* test(feishu): fix dynamic routing check types

---------

Co-authored-by: Vincent Koc <25068+vincentkoc@users.noreply.github.com>
2026-06-14 17:28:54 +08:00
openclaw-clownfish[bot]
b9e8e6d66e fix(sessions): restore reset archive fallback reads
Fall back to valid reset transcript archives when active async session transcripts are missing, while keeping active transcript priority and choosing the newest valid archive across roots.

Validation:

- node scripts/run-vitest.mjs src/gateway/session-utils.fs.test.ts src/gateway/sessions-history-http.test.ts src/gateway/sessions-history-http.revocation.test.ts src/gateway/session-history-state.test.ts src/gateway/server.chat.gateway-server-chat-b.test.ts src/gateway/managed-image-attachments.test.ts src/agents/tools/embedded-gateway-stub.test.ts src/tui/embedded-backend.test.ts

- node scripts/run-tsgo.mjs -p test/tsconfig/tsconfig.test.src.json --incremental --tsBuildInfoFile .artifacts/tsgo-cache/test-src-pr92879.tsbuildinfo

- git diff --check origin/main...HEAD && git diff --check

- autoreview --mode branch --base origin/main: clean

Direct-landed from #92879 because the source branch has maintainer edits disabled and the landed diff needed maintainer repair before merge.

Co-authored-by: Masato Hoshino <246810661+masatohoshino@users.noreply.github.com>

Co-authored-by: Hu Yitao <39733381+CadanHu@users.noreply.github.com>

Co-authored-by: Vincent Koc <25068+vincentkoc@users.noreply.github.com>
2026-06-14 17:24:17 +08:00
Peter Steinberger
a57b4c513f fix(openai): guard post-hook tool payloads (#92928)
Guard OpenAI post-hook tool inspection and code-mode filtering against unreadable accessors and asynchronous payload replacements. Preserve valid official `exec` and `wait` function tools across Responses and Chat Completions paths.

Supersedes #89703.

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-06-14 02:17:25 -07:00
openclaw-clownfish[bot]
333a93ce44 fix(cli): avoid false downgrade prompt for latest tag
Keep unresolved latest package targets moving while preserving downgrade confirmation for unresolved non-latest dist-tags.

Validation:

- node scripts/run-vitest.mjs src/cli/update-cli.test.ts

- node scripts/run-tsgo.mjs -p test/tsconfig/tsconfig.test.src.json --incremental --tsBuildInfoFile .artifacts/tsgo-cache/test-src-pr92911.tsbuildinfo

- git diff --check origin/main...HEAD && git diff --check

Direct-landed from #92911 because the source branch has maintainer edits disabled and ClawSweeper requested changelog removal plus behavior proof.

Co-authored-by: Andy Wu <31586206+Andy312432@users.noreply.github.com>

Co-authored-by: Vincent Koc <25068+vincentkoc@users.noreply.github.com>
2026-06-14 17:16:22 +08:00
Bek
46a5a5ee5f fix(media): route OAuth image defaults through Codex (#92824)
Route implicit OpenAI image understanding through the Codex app-server for eligible OpenAI OAuth profiles. Preserve scoped and persisted credential ownership plus the rotating-token refresh lifecycle for isolated clients.

Fixes #87168

Thanks @bek91.
2026-06-14 17:11:18 +08:00
Vincent Koc
af091174db test(gateway): allow trajectory export instruction latency 2026-06-14 17:05:24 +08:00
Dallin Romney
1affe4fcdf Fold Telegram RTT sampling into live QA evidence (#92550)
* refactor(qa): fold telegram rtt into live evidence

* test: default package telegram rtt samples

* refactor(qa-lab): fold telegram rtt into live evidence

* fix(qa-lab): keep package telegram rtt optional for focused runs

* fix(qa-lab): avoid stale rtt evidence on failed samples

* fix(qa-lab): pass telegram live env into credential leasing

* fix(qa-lab): update telegram canary remediation artifacts

* docs(qa): remove stale telegram observed artifact guidance

* fix(qa-lab): clarify telegram empty-reply remediation

* fix(qa-lab): honor telegram rtt timeout

* ci(qa): drop stale telegram capture env

* refactor: align telegram evidence coverage fields

* fix: ignore stale telegram observed artifacts

* fix: preserve telegram rtt coverage mapping

* fix: omit unused telegram rtt catch binding

* docs: document telegram rtt check selector
2026-06-14 17:02:33 +08:00
Peter Steinberger
439a9e97fd fix(openai): quarantine unreadable tool schemas (#92921)
Snapshot unreadable OpenAI tool descriptors and schemas before payload construction, preserve healthy siblings, and reconcile hard tool choices with the surviving function inventory.

Adds live-tested Responses and Chat Completions coverage, including allowed_tools, while keeping Anthropic regressions green.

Related: #89413, #89013, #89016, #89378, #89543, #90200, #90283, #90286, #90397

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-06-14 01:45:38 -07:00
Vincent Koc
1c86769cb8 test(gateway): keep trajectory export live proof correlated 2026-06-14 16:38:58 +08:00
Vincent Koc
b1caba5906 test(qa): align tool coverage CLI expectation 2026-06-14 16:11:00 +08:00
Vincent Koc
d2c0d3ac9b test(agents): skip anthropic billing drift in live tool checks 2026-06-14 15:56:29 +08:00
openclaw-clownfish[bot]
e99ab385cf fix(memory): preserve reindex rollback recovery (#92881)
* fix(memory): preserve reindex rollback recovery

Co-authored-by: Shiwen Han <46259514+TSHOGX@users.noreply.github.com>

* fix(clownfish): address review for gitcrawl-5644-autonomous-smoke (1)

Co-authored-by: Shiwen Han <46259514+TSHOGX@users.noreply.github.com>

* test: update memory reindex test routing expectation

* chore(memory): remove release changelog entry

* fix(memory): complete reindex retry recovery

---------

Co-authored-by: openclaw-clownfish[bot] <280122609+openclaw-clownfish[bot]@users.noreply.github.com>
Co-authored-by: Shiwen Han <46259514+TSHOGX@users.noreply.github.com>
Co-authored-by: Vincent Koc <25068+vincentkoc@users.noreply.github.com>
2026-06-14 15:31:51 +08:00
Peter Steinberger
19130e0dc2 fix(providers): quarantine unreadable Anthropic payload tools (#92908)
Quarantine unreadable and invalid Anthropic-family tool schemas before OpenAI-compatible serialization, keep tool choices aligned with surviving tools, and preserve provider metadata.

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-06-14 00:27:48 -07:00
openclaw-clownfish[bot]
8d08b90489 fix(memory-wiki): stop flagging raw source pages as malformed (#92876)
* fix(memory-wiki): stop flagging raw source pages as malformed

* fix(clownfish): address review for gitcrawl-11828-autonomous-smoke (1)

* fix(memory-wiki): skip freshness lint for raw source pages

* fix(memory-wiki): keep raw source ids linted

---------

Co-authored-by: openclaw-clownfish[bot] <280122609+openclaw-clownfish[bot]@users.noreply.github.com>
Co-authored-by: Vincent Koc <25068+vincentkoc@users.noreply.github.com>
2026-06-14 15:18:41 +08:00
Dallin Romney
a3e9dfee0e Simplify QA scorecard mapping shape (#92558)
* test(qa): simplify scorecard mapping shape

* test(qa): use typed scorecard evidence refs

* test(qa): map scorecard categories by coverage id

* feat: align qa coverage with taxonomy features

* refactor: keep qa coverage ids canonical

* refactor: minimize qa coverage id churn

* test: align qa coverage id assertions

* test: update qa evidence coverage expectations

* refactor qa taxonomy coverage ids

* style qa taxonomy coverage ids

* test qa coverage lint fix

* test qa coverage type fix
2026-06-14 00:16:33 -07:00
Vincent Koc
cf4000b47b fix(gateway): preserve slash command media replies 2026-06-14 14:54:18 +08:00
Vincent Koc
85ebbec117 docs(changelog): refresh 2026.6.8 notes
(cherry picked from commit 0b5cb00980c68a39b8fb1d77f6c04e9733bcbb09)
2026-06-14 14:54:18 +08:00
Vincent Koc
f85ef0d114 fix(gateway): deliver command block replies in webchat
(cherry picked from commit 33390ee88f5c8325efc60d1793c532a9489a5a72)
2026-06-14 14:54:18 +08:00
brokemac79
d1299658ac fix(active-memory): preserve verbose recall summaries (#90739)
* fix(active-memory): preserve verbose recall summaries

* fix(active-memory): require recall evidence for recovery

* fix(active-memory): recognize capped recall results

* fix(active-memory): preserve grounded recall state

* refactor(active-memory): limit recovery to completed recalls

* fix(active-memory): ground terminal recall recovery

* fix(active-memory): limit unavailable recovery to completed replies

* fix(active-memory): harden recall evidence recovery

* fix(active-memory): preserve timeout recovery contract

* fix(active-memory): preserve capped failure evidence

* fix(active-memory): reject content-only recall failures

* fix(active-memory): ground completed recall summaries

* fix(active-memory): separate hook and recall timeouts

* fix(active-memory): classify custom tool failures

* fix(active-memory): preserve harness tool evidence

* fix(active-memory): reject explicit empty results

* fix(active-memory): wait for fallback recall evidence

* fix(codex): report dynamic tool results

* fix(active-memory): separate preflight recall deadline

* fix(active-memory): normalize recall tool names

* fix(agents): classify unavailable approvals

* docs(active-memory): clarify hook timeout phases

* test(active-memory): stabilize timeout abort proof

* fix(agents): preserve successful cancellation outcomes

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-06-13 23:38:58 -07:00
Peter Steinberger
a02813164d fix(anthropic): quarantine invalid direct tool schemas (#92896)
Quarantine unreadable and structurally invalid direct/custom Anthropic tool schemas in both canonical request builders while preserving healthy siblings, forced-choice semantics, OAuth name mapping, and official OpenAI behavior.

Supersedes #89418, #89221, #90228, #89622, #89229, and #90278.

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-06-13 23:28:36 -07:00
Jefsky Wong
462c076a24 fix(anthropic): merge consecutive assistant replay turns (#87346)
Merge adjacent Anthropic assistant turns before dangling tool-use validation so signed tool calls remain immediately paired with their tool results. Preserve contributor credit. Fixes #87329.
2026-06-13 22:34:38 -07:00
Peter Steinberger
d68de3f77d docs(crabbox): warm Testbox in parallel 2026-06-14 01:20:32 -04:00
liuhao1024
2e20dd8dbf fix(anthropic): omit stale thinking from disabled requests (#92373)
Preserve signed thinking for active Anthropic tool-result continuation while omitting native thinking from completed history when the new request disables or omits thinking. Applies the same replay rule to the legacy SDK provider and managed Anthropic transport. Fixes #92360.
2026-06-13 22:17:02 -07:00
elfka toruviel
66880a5d73 fix(agents): preserve valid reasoning replay metadata (#90682)
Preserve validated provider reasoning ciphertext, signatures, and replay identifiers through transcript redaction without exempting malformed, nested, or credential-shaped values.

Fixes #90093.

Co-authored-by: Elfka Toruviel <aeb31988340aa87b@toruviel.online>
2026-06-13 21:47:38 -07:00
Song Luo
231b5a14d5 fix(cli): clarify --tz help text for offset-less --at values
Clarifies cron edit --at help after maintainer rebase and preserves the Gateway-host timezone wording for cron --tz help.

Validation:
- git diff --check
- node scripts/run-vitest.mjs src/cli/cron-cli.test.ts
- local Codex autoreview clean, no actionable findings

Co-authored-by: rrrrrredy <rrrrrredy@users.noreply.github.com>
2026-06-14 12:34:10 +08:00
openclaw-clownfish[bot]
c5db07eddc fix(lobster): surface workflow path errors
Surface missing bare Lobster workflow file paths instead of silently falling through to inline pipeline parsing.

The runner now treats plain workflow file inputs as file paths, keeps inline commands with file-like arguments as pipelines, and preserves existing workflow file paths that contain spaces. Regression coverage covers missing bare workflow paths, inline false positives, and spaced workflow filenames.

Fixes #68101.

Based on and credits #68106 by @vvitovec. This replacement branch carries the focused fix forward because #68106 is dirty against current main and could not be repaired on the fork branch with available bot permissions.

Validation:
- node scripts/run-vitest.mjs extensions/lobster/src/lobster-runner.test.ts
- autoreview clean: no accepted/actionable findings after the spaced-path fix
- GitHub checks: 127 pass, 0 fail, 0 pending

Co-authored-by: Viktor Vítovec <230458341+vvitovec@users.noreply.github.com>
2026-06-14 12:18:33 +08:00
openclaw-clownfish[bot]
808f677ab4 fix(feishu): target typing reaction on inbound message
Target Feishu Typing reactions at the inbound message id while preserving reply and thread routing to the topic root.

This keeps the fallback to replyToMessageId for flows without a separate inbound target, and adds regression coverage for topic/replyInThread behavior and synthetic Feishu turn sources.

Based on and credits #67783 by @huiwen01. This replacement branch carries the same user-visible fix forward because #67783 is dirty against main and earlier automation could not update the fork branch with available permissions. This intentionally does not reuse or expand #73958; root_id routing remains separate.

Validation:
- pnpm check:changed
- pnpm -s vitest run extensions/feishu/src/bot.test.ts extensions/feishu/src/reply-dispatcher.test.ts extensions/feishu/src/monitor.reaction.test.ts
- autoreview clean: no accepted/actionable findings
- GitHub checks: 127 pass, 0 fail, 0 pending

Co-authored-by: huiwen01 <89329207+huiwen01@users.noreply.github.com>
2026-06-14 12:11:49 +08:00
Jason (Json)
7259cb5c77 fix(update): continue after package doctor warnings (#91586)
* fix(update): continue after package doctor warnings

* fix(update): type advisory step rendering

* fix(update): preserve advisory doctor step state

* fix(update): share advisory doctor state

* fix(update): keep timed-out doctor failures blocking

* fix(update): require explicit doctor advisory result

* fix(update): reject malformed doctor advisory results

* fix(update): bound doctor advisory diagnostics

* fix(update): keep doctor advisory restart-neutral

* fix(update): protect doctor advisory IPC

* fix(update): scope doctor advisories to converging updater

* fix(update): scope doctor advisories to deferred repairs

* fix(update): secure doctor advisory IPC

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-06-13 21:03:57 -07:00
openclaw-clownfish[bot]
889bc52ba5 fix(ui): repair iOS Safari chat viewport handling
Repairs https://github.com/openclaw/openclaw/pull/63644.

Review proof: local structured autoreview on branch review/pr-92855 against origin/main exited clean with no accepted/actionable findings.

Co-authored-by: Xi Qi <1311124+macdao@users.noreply.github.com>
2026-06-14 11:36:42 +08:00
openclaw-clownfish[bot]
c8cf57a1a5 fix(hooks): reject slug-generator error payloads
Repairs https://github.com/openclaw/openclaw/pull/64181.

Co-authored-by: Cypher <28184436+Cypherm@users.noreply.github.com>
2026-06-14 11:31:01 +08:00
openclaw-clownfish[bot]
9a0bae06b7 fix(acp): accept MCP date protocolVersion in ACP server
Repairs https://github.com/openclaw/openclaw/pull/56176.
Fixes https://github.com/openclaw/openclaw/issues/56102.

Co-authored-by: Saurabh Mishra <2924124+bugkill3r@users.noreply.github.com>
2026-06-14 11:30:48 +08:00
Eduardo Piva
7f49f875de Add diagnostics OTEL capability contract tests (#92045) 2026-06-14 11:26:49 +08:00
openclaw-clownfish[bot]
bd10e1998b fix(tailscale): preserve parse errors for malformed JSON
Accept the fail-closed behavior for malformed `tailscale funnel status --json`: noisy valid JSON should parse, but malformed status output should not silently become “route absent”.

Source PR: https://github.com/openclaw/openclaw/pull/63321

Co-authored-by: Francisco Maestre Torreblanca <2027043+franciscomaestre@users.noreply.github.com>
2026-06-14 11:22:05 +08:00
zhang-guiping
650c5cac33 fix(qqbot): surface failed media sends (#92823)
* fix(qqbot): surface media send failures

* test(qqbot): cover text send failures

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-06-14 11:13:56 +08:00
Jason (Json)
965fa05df3 feat: add tool search directory mode
Add an experimental directory mode that keeps large authorized tool schemas deferred while exposing bounded discovery, exact deferred hydration, and normal OpenClaw policy/hook execution. Client tools remain directly visible; ambiguous hidden names fail closed.
2026-06-13 20:08:39 -07:00
openclaw-clownfish[bot]
9f32bea397 fix(feishu): await HTTP server shutdown during monitor cleanup
Source PR: https://github.com/openclaw/openclaw/pull/48588

Co-authored-by: alex sagit <267811734+alex-xuweilong@users.noreply.github.com>
2026-06-14 10:56:15 +08:00
zengLingbiao
7fb0d45b48 fix(agents): add usage guidance to sessions_spawn tool description (fixes #91814) (#91824)
* fix(agents): add usage guidance to sessions_spawn tool description (fixes #91814)

* fix(agents): tighten sessions spawn guidance

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-06-14 10:49:26 +08:00
Jason (Json)
8ae1adfdcc ci: gate stable releases on Windows companion assets (#92555)
* ci: gate stable releases on Windows companion assets

* fix(release): reject malformed Windows checksum manifests

* fix(release): make Windows recovery fail closed

* fix(release): tighten Windows asset identity checks

* fix(release): validate prepared candidate tarballs

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-06-13 19:33:33 -07:00
LowCode191
4001be54e4 fix(agents): catch malformed image blocks in sanitizeContentBlocksImages (#92792)
* fix(agents): catch malformed image blocks in sanitizeContentBlocksImages

* fix(agents): sanitize malformed-only image results

---------

Co-authored-by: Vincent Koc <25068+vincentkoc@users.noreply.github.com>
2026-06-14 10:24:48 +08:00
Jayesh Betala
e8b35a8280 fix(status): render sub-1000 token counts as plain integers (#89736)
* fix(status): render sub-1000 token counts as plain integers

formatKTokens always divided by 1000 and appended "k", so token counts
below 1000 rendered as misleading fractional k in `openclaw status`
output (e.g. 999 rounded up across the boundary to "1.0k", 420 -> "0.4k",
a 300-token cache write -> "write 0.3k").

Guard value < 1000 to render the plain rounded integer, matching the
canonical formatTokenCount convention (src/utils/usage-format.ts). The
>=1000 "k" behavior is unchanged. Adds focused regression tests for the
0/420/999/1000/12000 boundary and small-session/small-cache status lines.

Fixes #89735

* fix(status): reuse canonical token formatter

* refactor(status): extract lightweight token formatter

---------

Co-authored-by: Vincent Koc <25068+vincentkoc@users.noreply.github.com>
2026-06-14 10:24:42 +08:00
liuhao1024
077d3f1366 fix(stale): exempt ClawSweeper actionable labels from stale lifecycle (#92801)
Add clawsweeper:queueable-fix, clawsweeper:source-repro, and
clawsweeper:fix-shape-clear to exempt-issue-labels in all 4 stale
workflow steps and the backfill-closures script's issueExemptLabels
set.

Previously, issues classified by ClawSweeper as actionable fix
candidates could still be marked stale and auto-closed, creating
a conflict between the two automation systems (e.g. #78640,
#81078, #81122 had both 'stale' and 'clawsweeper:queueable-fix').

Fixes #89564
2026-06-14 10:24:20 +08:00
zhang-guiping
8afbc98018 fix(whatsapp): require durable auth before login success (#92095) 2026-06-14 10:24:14 +08:00
openclaw-clownfish[bot]
f58f8c86b9 fix(discord): raise thread title timeout and tokens
Source PR: https://github.com/openclaw/openclaw/pull/64734

Co-authored-by: Hana Chang <741302+hanamizuki@users.noreply.github.com>
2026-06-14 10:22:02 +08:00
clawsweeper[bot]
24e729fc4e feat(browser): extend --labels overlay to full-page and element captures (#92834)
Summary:
- The replacement PR extends Browser plugin labeled screenshots to honor Playwright full-page/ref/element scope, returns annotation bounding boxes, and updates docs, tests, and skill guidance.
- PR surface: Source +415, Tests +550, Docs +24. Total +989 across 12 files.
- Reproducibility: yes. Current main source shows the labeled Playwright helper ignores fullPage/ref/element and omits annotations, and the source PR supplies live before/after commands for the Browser plugin path.

Automerge notes:
- PR branch already contained follow-up commit before automerge: docs(browser): correct raw-CDP labels caveat in automation skill
- PR branch already contained follow-up commit before automerge: fix(browser): preserve labelsSkipped semantics for off-viewport refs
- PR branch already contained follow-up commit before automerge: docs(browser): scope labels docs by driver
- PR branch already contained follow-up commit before automerge: docs(browser): fix labels annotation indent and document scope fix
- PR branch already contained follow-up commit before automerge: docs(browser): indent annotations box schema under --labels bullet
- PR branch already contained follow-up commit before automerge: docs(browser): indent labels annotation schema

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

Prepared head SHA: 70aca6c506
Review: https://github.com/openclaw/openclaw/pull/92834#issuecomment-4700431344

Co-authored-by: FMLS <kfliuyang@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Mason Huang <masonxhuang@tencent.com>
Co-authored-by: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com>
Co-authored-by: clawsweeper[bot] <274271284+clawsweeper[bot]@users.noreply.github.com>
Approved-by: hxy91819
Co-authored-by: hxy91819 <8814856+hxy91819@users.noreply.github.com>
2026-06-14 02:21:23 +00:00
Peter Steinberger
aef670cf0c fix(copilot): strip replayed thinking blocks
Remove replayed thinking and redacted-thinking blocks from GitHub Copilot Claude history and final Anthropic payloads while preserving visible content, tool turns, and non-empty assistant structure.

Fixes #81520
Supersedes #87060 and #81534

Co-authored-by: Gio Della-Libera <giodl73@gmail.com>
2026-06-13 19:14:16 -07:00
clawsweeper[bot]
399f5bc993 fix: refresh model context metadata safely
Cap configured session context overrides by the selected model's known context window, refresh provider/model metadata consistently, and preserve the fixed Anthropic 1M context contract.

Fixes #39857

Co-authored-by: Kros Dai <7087+xdanger@users.noreply.github.com>
2026-06-13 18:48:43 -07:00
openclaw-clownfish[bot]
34678d8dfa fix(telegram): preserve command callbacks while prefixing generic callback data (#92825)
Fixes #54909.

Repair #54962 by preserving raw slash-command callbacks while routing generic callback data to agents as `callback_data: <value>`.

Validation:
- pnpm check:changed
- pnpm -s vitest run extensions/telegram/src/bot.create-telegram-bot.test.ts
- Codex /review
- Real behavior proof
- GitHub PR checks

Source PR credit: continues @hnshah's work from #54962 and preserves @timt80's report credit from #54909.

Co-authored-by: Hiten Shah <3155200+hnshah@users.noreply.github.com>
2026-06-14 09:47:16 +08:00
Vincent Koc
c60b424124 test(release): accept trajectory command session final
(cherry picked from commit 9458ffa7e8)
2026-06-14 09:42:13 +08:00
Vincent Koc
340c2456bb test(release): harden trajectory export live check
(cherry picked from commit b2d4cb7f86)
2026-06-14 09:25:18 +08:00
openclaw-clownfish[bot]
971542b7f6 UI: localize Logs tab labels (#92820)
Repair #61080 by routing Logs tab user-facing labels through Control UI locale keys while keeping export filename suffixes stable.

Validation:
- pnpm ui:i18n:check
- pnpm check:changed
- ui/src/ui/views/logs.test.ts
- Codex /review
- GitHub PR checks

Source PR credit: continues @rubensfox20's work from https://github.com/openclaw/openclaw/pull/61080.

Co-authored-by: rubensfox20 <111531429+rubensfox20@users.noreply.github.com>
2026-06-14 09:23:00 +08:00
scotthuang
b3dc274034 fix(gateway): preserve active runs during plugin finalization (#92746)
* fix(gateway): preserve active run during plugin finalization

* fix(ui): skip session.message history reload while gateway reports active run

* fix(ui): remove unused eslint-disable directive

* fix(ui): preserve active runs through finalization

---------

Co-authored-by: scotthuang <scotthuang@tencent.com>
Co-authored-by: Vincent Koc <25068+vincentkoc@users.noreply.github.com>
2026-06-14 08:58:27 +08:00
liuhao1024
1acca038b1 fix(gateway): use resolveNonNegativeNumber for totalTokens to display 0 instead of ? (fixes #43009) (#92795)
* fix(gateway): use resolveNonNegativeNumber for totalTokens to display 0 instead of ?

resolvePositiveNumber requires value > 0, filtering out the valid
totalTokens = 0 case (new session, no usage yet). This caused the TUI
to display 'tokens ?/200k' instead of 'tokens 0/200k (0%)'.

Use resolveNonNegativeNumber (>= 0) for the final totalTokens value
used in session display. The needsTranscriptTotalTokens check at line
2041 still correctly uses resolvePositiveNumber to decide whether to
fetch transcript data.

Fixes #43009

* fix(gateway): preserve fresh zero-token sessions

---------

Co-authored-by: Vincent Koc <25068+vincentkoc@users.noreply.github.com>
2026-06-14 08:56:49 +08:00
liuhao1024
fd4f5b3f59 fix(macos): defer isOverflowing mutation to break SwiftUI render loop (fixes #43480) (#92778)
* fix(macos): defer isOverflowing mutation to break SwiftUI render loop

measuredHeight() mutated model.isOverflowing synchronously during a SwiftUI
view update cycle. The onChange(of: attributed) handler triggered
updateWindowFrame → targetFrame → measuredHeight, which set isOverflowing,
invalidating the view and re-triggering onChange — an infinite render loop
causing 100% CPU pinwheel.

Fix: defer the isOverflowing mutation via DispatchQueue.main.async with an
equality guard to prevent redundant updates. The frame calculation itself
remains synchronous so the window size is correct immediately.

Fixes #43480

* fix(macos): preserve latest overflow measurement

---------

Co-authored-by: Vincent Koc <25068+vincentkoc@users.noreply.github.com>
2026-06-14 08:56:25 +08:00
Vincent Koc
ac3a98e55d test(gateway): capture trajectory export command events
(cherry picked from commit b656a51046)
2026-06-14 08:53:54 +08:00
liuhao1024
57e8c50d19 fix(telegram): skip IPv4 fallback when user explicitly configures non-ipv4first dnsResultOrder (fixes #41671) (#92806)
* fix(telegram): skip IPv4 fallback when user configures non-ipv4first dnsResultOrder

When the user explicitly configures channels.telegram.network.dnsResultOrder
to a non-ipv4first value (e.g. verbatim), the sticky IPv4 fallback dispatcher
should not be armed. Forcing autoSelectFamily=false + dnsResultOrder=ipv4first
overrides the user's explicit IPv6-friendly config, causing media downloads to
fail on hosts where IPv4 is broken but IPv6 works.

Fixes #41671

* fix(telegram): respect explicit DNS fallback policy

---------

Co-authored-by: Vincent Koc <25068+vincentkoc@users.noreply.github.com>
2026-06-14 08:51:26 +08:00
liuhao1024
274b7b1d9f fix(doctor): avoid false-positive legacy cron store warning when store was already migrated (fixes #92683) (#92690)
* fix(doctor): avoid false-positive legacy cron store warning when store was already migrated

When rawJobs.length > 0 and other issues exist (notifyCount, dreamingStaleCount)
but legacyStoreDetected is false (file already removed after migration), the doctor
unconditionally printed 'Legacy cron job storage detected at ...' — misleading users
into thinking the migration was incomplete.

Fix: conditionally use 'Cron store issues detected' heading when no legacy store file
exists, reserving 'Legacy cron job storage detected' for actual legacy store presence.

Fixes #92683

* test(doctor): add test for false-positive legacy cron store warning (#92683)
2026-06-14 08:49:07 +08:00
Vincent Koc
efca4b7e64 fix(memory): keep skipped recall diagnostics opt-in
Follow-up to #92745 after maintainer autoreview found that the skipped recall event widened the shipped MemoryHostEvent union and changed limited legacy reads.

Keep readMemoryHostEvents() source-compatible by filtering diagnostic records before applying limits, and expose skipped recall diagnostics through the opt-in MemoryHostEventRecord/readMemoryHostEventRecords path.

Original skipped-recall behavior landed in #92745 by @mushuiyu886.
2026-06-14 08:47:03 +08:00
Jason (Json)
65b460f234 fix(nodes): surface pending reapproval diagnostics (#92547)
* fix(nodes): surface pending reapproval diagnostics

* fix(nodes): harden reapproval diagnostics

* fix(nodes): scope pending diagnostics

* fix(nodes): request pairing diagnostics in cli

* fix(nodes): reuse stored auth for diagnostics

* fix(nodes): preserve selected diagnostics credentials

* fix(nodes): prefer approved diagnostics auth

* fix(nodes): narrow diagnostics fallbacks

* fix(nodes): recover from stale diagnostics auth

* fix(gateway): preserve connect error narrowing

* fix(nodes): isolate privileged diagnostics auth

* fix(nodes): constrain privileged diagnostics auth

* fix(nodes): close diagnostics review gaps

* fix(nodes): guard reapproval cleanup races

* fix(nodes): defer stale pairing cleanup

* fix(nodes): preserve reapproval on hello failure

* test(nodes): await post-handshake reapproval cleanup

* test(nodes): avoid unbound websocket send capture

* fix(nodes): allow local auth-none diagnostics

* fix(nodes): preserve overlapping reapproval

* fix(nodes): preserve pending node metadata

* fix(nodes): keep connection age with status

* fix(nodes): preserve reapproval during reconnect races

* fix(nodes): serialize reapproval cleanup

* fix(nodes): bound reapproval reconnect races

* test(nodes): satisfy cleanup claim lint

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-06-13 17:40:18 -07:00
liuhao1024
4c3c0ff5f9 fix(telegram): acknowledge callbacks before sequentialize
Fixes #42156.

Answer Telegram callback queries before per-chat/topic sequentialize can queue the handler behind an active turn, and carry the in-flight answer promise on the grammY context so the normal handler reuses it instead of double-answering.

Proof:
- node scripts/run-vitest.mjs extensions/telegram/src/bot.create-telegram-bot.test.ts -- -t "answers callback queries before same-chat sequentialize delays handlers|sequentializes updates by chat and thread|routes callback_query payloads as messages"
- node scripts/run-vitest.mjs extensions/telegram/src/bot.test.ts extensions/telegram/src/bot-handlers.runtime.test.ts
- node_modules/.bin/oxfmt --check extensions/telegram/src/bot-core.ts extensions/telegram/src/bot-handlers.runtime.ts extensions/telegram/src/callback-query-answer-state.ts extensions/telegram/src/bot.create-telegram-bot.test.ts
- git diff --check origin/main...HEAD
- .agents/skills/autoreview/scripts/autoreview --mode branch --base origin/main
- Azure Crabbox cbx_cba20c462ad5 / silver-barnacle: OPENCLAW_TESTBOX=1 node scripts/crabbox-wrapper.mjs run --provider azure --class Standard_D4ads_v6 --idle-timeout 90m --ttl 240m --timing-json -- env OPENCLAW_CHECK_CHANGED_REMOTE_CHILD=1 OPENCLAW_CHANGED_LANES_RAW_SYNC=1 corepack pnpm check:changed

Proof gap: live Telegram Desktop/burner-account proof was not run because openclaw-telegram-user-crabbox-proof is not installed in this shell.
2026-06-14 08:37:24 +08:00
Vincent Koc
9a27af9507 test(gateway): wait for trajectory export guidance
(cherry picked from commit 4675423788)
2026-06-14 08:26:47 +08:00
Peter Steinberger
1d9b9ef48f fix: reject unvalidated voice media streams
Reject voice media stream start frames when no acceptance validator is configured, preventing fail-open STT/TTS session creation. Verified locally, with autoreview, in a remote Linux dev box, and by green CI.
2026-06-13 17:26:19 -07:00
Ashish Patel
8f62ec6177 fix(status): avoid cumulative usage for context percent (#92604)
* fix(status): avoid cumulative usage for context percent

* fix(status): preserve legacy context totals
2026-06-14 08:07:08 +08:00
swide
b72634f56d fix(gateway): forward image-only input on /v1/responses (parity with chat completions) (#92488)
* fix(gateway): accept image-only input on /v1/responses

The OpenResponses endpoint rejected requests whose `input` contained only
an `input_image` (no `input_text`) with `400 Missing user message in
input.`, even though the image was parsed and collected into `images`.
The guard only checked `prompt.message` and ignored `images`, unlike the
equivalent /v1/chat/completions guard which uses
`!prompt.message && images.length === 0`.

Align the OpenResponses guard with Chat Completions so image-only turns
are forwarded to the agent. Empty input (no text and no image) still
returns 400.

Adds regression tests: image-only base64 input -> 200 with image reaching
the agent, and empty content -> 400.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(gateway): pass image-only /v1/responses turns to the agent

The one-line guard alone was insufficient: even after letting image-only
input past the `Missing user message` check, the downstream agent command
(`prepareAgentCommandExecution`) throws `Message (--message) is required`
for an empty message, so image-only `/v1/responses` returned 500.

Mirror the /v1/chat/completions prompt builder: substitute the shared
IMAGE_ONLY_USER_MESSAGE placeholder for the active image-only user turn so
the turn is not dropped and the real image is still attached via `images`.
Promote the placeholder constant to the shared gateway agent-prompt module
so both endpoints stay in sync, and revert the responses guard back to the
original `!prompt.message` check (responses images are not scoped to the
active turn, so the placeholder is the correct, single source of truth).

Co-authored-by: Cursor <cursoragent@cursor.com>

* chore: retrigger CI (flaky startup-core test timeout, unrelated to change)

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: songwendong <songwendong@shuidi-inc.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-14 08:06:20 +08:00
mushuiyu_xydt
99e7dad0e4 fix(memory): surface skipped short-term recall hits (#92745)
Record diagnostic events when memory_search returns durable memory hits that are intentionally excluded from short-term promotion, so users can distinguish eligibility decisions from recall tracking failures.
2026-06-14 08:05:52 +08:00
Vincent Koc
d626e99c31 test(gateway): preserve live Codex api-key auth
(cherry picked from commit 4aa50732f6)
2026-06-14 08:00:22 +08:00
openclaw-clownfish[bot]
c2754150c9 fix(heartbeat): route outbound mirror to isolated session key (#92807)
* fix(heartbeat): route outbound mirror to isolated session key

Co-authored-by: Merkava <263752781+agent-merkava@users.noreply.github.com>

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

Co-authored-by: Merkava <263752781+agent-merkava@users.noreply.github.com>

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

Co-authored-by: Merkava <263752781+agent-merkava@users.noreply.github.com>

---------

Co-authored-by: openclaw-clownfish[bot] <280122609+openclaw-clownfish[bot]@users.noreply.github.com>
Co-authored-by: Merkava <263752781+agent-merkava@users.noreply.github.com>
2026-06-14 07:51:29 +08:00
Chunyue Wang
5b21384ab6 fix(cron): isolate transient auth cooldowns
Keep cron-local transient auth failures from polluting shared cooldowns while preserving real auth/billing/rate-limit propagation. Verified with focused auth/cron tests, type proof, autoreview, and clean CI.
2026-06-14 07:45:20 +08:00
Zhao Shiqi
edd76238fe fix(daemon): keep duplicate Windows gateway tasks visible
Normalize Windows schtasks default gateway names without hiding similarly prefixed duplicate tasks. Verified with focused daemon tests, type proof, autoreview, and clean CI.
2026-06-14 07:45:16 +08:00
Darren Xu
d6b3950734 fix(tavily): keep web search contract executable
Keep the Tavily public artifact lightweight while lazily executing through the provider runtime. Verified with focused Tavily/provider artifact tests and clean CI.
2026-06-14 07:43:17 +08:00
Jayesh Betala
61145dc252 fix(ports): avoid stale cleanup for non-gateway SSH listeners
Limit SSH tunnel classification to actual queried-port forwards and keep SSH-like non-gateway listeners out of stale gateway cleanup. Verified with focused port/restart tests and clean CI.
2026-06-14 07:43:12 +08:00
Anurag Bheemappa Gnanamurthy
382db15e33 fix(configure): mask gateway token prompts
Mask gateway token/password prompts while preserving blank-token generation. Verified with focused configure wizard tests and clean CI.
2026-06-14 07:43:07 +08:00
Colin Johnson
1a8747620e fix: reflow composer beside workspace rail
Reflow the Control UI chat composer into the main chat column when the Workspace Files rail is expanded, so the composer stays beside the rail instead of extending underneath it.

Prepared head SHA: 7108d88cda
Co-authored-by: Colin Johnson <211764741+Solvely-Colin@users.noreply.github.com>
Co-authored-by: shakkernerd <165377636+shakkernerd@users.noreply.github.com>
Reviewed-by: @shakkernerd
2026-06-14 00:40:03 +01:00
Rohit
e55cebf4c2 fix(canvas): validate CLI numeric options
Validate Canvas CLI numeric arguments before node invocation.

- Reject malformed `--invoke-timeout` values through the shared Canvas invoke path before resolving a node.
- Keep snapshot `--quality` bounded to the existing Canvas tool schema range of 0..1.
- Add focused CLI regressions for timeout validation, quality bounds, and accepted boundary values.

Verification:
- `node scripts/run-vitest.mjs extensions/canvas/src/cli.test.ts --maxWorkers=1`
- `.agents/skills/autoreview/scripts/autoreview --mode branch --base origin/main ...`
- AWS Crabbox `cbx_b7838c58daba`, run `run_bbb58ca536f4`: `check:changed`
- Exact PR head `a970ce594ea6e7284ace519352fc258b1b81cb80`: GitHub checks green
2026-06-14 07:39:17 +08:00
Vincent Koc
2d4a9eb405 test(matrix): avoid racy inbound dedupe TTL
(cherry picked from commit 8ae3662b1c)
2026-06-14 07:35:14 +08:00
Vincent Koc
47759c3506 fix(qa): accept rich Telegram canary presence
(cherry picked from commit e86eb7567a)
2026-06-14 07:20:16 +08:00
Rohit
3429e33feb fix(matrix): validate CLI numeric option ranges
Validates Matrix CLI numeric option ranges before invoking setup or verification side effects.

`--initial-sync-limit` must now be non-negative, and `--timeout-ms` must now be positive.

Original PR by @rohitjavvadi.

Verification:
- `node scripts/run-vitest.mjs extensions/matrix/src/cli.test.ts --maxWorkers=1`
- autoreview clean
- Crabbox AWS `cbx_5c32f138ab3a` / `swift-lobster`, run `run_6e133b8b82e7`: `check:changed` passed
- exact PR head CI green: `d75f118299029b0516311646276cd2d6582379c5`
2026-06-14 07:16:39 +08:00
Vincent Koc
894f521aa5 test(macos): avoid real approvals migration in tests 2026-06-14 07:08:26 +08:00
Vincent Koc
97c5e6c235 test(macos): isolate exec approvals env 2026-06-14 07:04:53 +08:00
Stellar鱼
9974641d1e fix(agents): preserve compatible CLI session runtime pins
Preserves provider-compatible CLI runtime session pins across reply execution, follow-up execution, dispatch visibility, preflight compaction, and memory flush.

This keeps sessions pinned to compatible CLI runtimes such as `claude-cli` from leaking into embedded OpenClaw maintenance paths while still rejecting cross-provider runtime pins.

Original PR by @yu-xin-c; includes maintainer follow-up for the sibling memory paths.

Verification:
- `node scripts/run-vitest.mjs src/auto-reply/reply/agent-runner-execution.test.ts src/auto-reply/reply/agent-runner-memory.test.ts src/agents/model-runtime-aliases.test.ts --maxWorkers=1`
- autoreview clean
- Crabbox AWS `cbx_44400b494e97` / `coral-prawn`, run `run_69dd43475e39`: `check:changed` passed
- exact PR head CI green: `303b2f794f6c01fcf21b62b27c536b5f6eceb421`
2026-06-14 06:54:51 +08:00
Vincent Koc
924f4c1964 fix(qa): read rich Telegram replies in live checks
(cherry picked from commit 9c8b880353)
2026-06-14 06:35:59 +08:00
dependabot[bot]
2f57352eaa chore(deps): bump macOS Swift dependencies
Updates the macOS Swift package resolution for patch releases of Peekaboo, Sparkle, and swift-log.

Verification:
- `swift package describe --type json`
- `swift build --target OpenClawIPC`
- `swift build --target OpenClawDiscovery`
- upstream tag/revision checks for Peekaboo 3.4.1, Sparkle 2.9.3, and swift-log 1.13.2
- autoreview clean
- exact PR head CI green for macOS, dependency, and security checks
2026-06-14 06:26:41 +08:00
Vincent Koc
c11fcbcb6a fix(release): repair beta validation fixtures 2026-06-14 06:19:23 +08:00
lizeyu-xydt
5b6810211c fix(docker): remove stale nested openclaw package
Remove the stale nested openclaw package, its .bin shim, and the pnpm virtual-store copy from the runtime Docker image before final runtime assets are copied.

Run the package dist import-closure check after the cleanup so the check validates the final runtime-assets tree that the image ships.

Compatibility note: private Docker paths under /app/node_modules/openclaw and /app/node_modules/.bin/openclaw are removed; downstream images should use the documented /usr/local/bin/openclaw launcher or /app/openclaw.mjs.

Fixes #92551.
Thanks @lzyyzznl for the fix and @fxstein for the report.
2026-06-14 06:17:55 +08:00
Vincent Koc
e4313bac97 fix(ui): localize workspace file rail labels 2026-06-14 06:01:30 +08:00
Vincent Koc
6ebb303ef0 docs(changelog): refresh 2026.6.8 notes 2026-06-14 05:43:48 +08:00
Yuval Dinodia
ae68006a8f fix(sessions): enforce channel send policy for account-scoped DMs
Derive the channel from canonical account-scoped DM session keys when resolving session.sendPolicy, so channel-scoped allow/deny rules apply to per-account-channel-peer sessions.

Keep derivation limited to canonical channel peer key shapes and add malformed-key regressions so incomplete or non-channel keys do not accidentally match channel rules.

Compatibility note: existing channel-scoped send-policy rules can now block account-scoped DM sends that were previously allowed by this bug.

Thanks @yetval for the fix.
2026-06-14 05:38:42 +08:00
Peter Steinberger
735f59af73 feat(providers): add GLM-5.2 support (#92796)
* feat(providers): add GLM-5.2 support

* ci(live): add GLM-5.2 provider shard
2026-06-13 14:33:28 -07:00
mushuiyu_xydt
47112fc423 fix(memory): split header-too-large embedding batches
Route OpenAI/OpenAI-compatible request_headers_too_large embedding failures into the existing memory-core batch splitter instead of aborting bulk memory indexing.

Tighten the classifier to require header-too-large wording rather than a bare 431 status token, so unrelated provider errors do not fan out into recursive requests.

Fixes #92465.
Thanks @mushuiyu886 for the fix and @BrettHamlin for the report and proof.
2026-06-14 05:20:25 +08:00
Andy Ye
8549a203d4 Honor WhatsApp configured ACP bindings (#92513)
Merged via squash.

Prepared head SHA: 665080f482
Co-authored-by: TurboTheTurtle <35905412+TurboTheTurtle@users.noreply.github.com>
Co-authored-by: mcaxtr <7562095+mcaxtr@users.noreply.github.com>
Reviewed-by: @mcaxtr
2026-06-13 18:09:22 -03:00
liuhao1024
d912909230 docs(nodes): add node config example
Add a Nodes overview `openclaw.json` example for node pairing, command allow/deny policy, node exec routing, and per-agent node pinning.

Also clarifies exact `denyCommands` matching and links readers to the config reference for pairing and command-policy field details.

Fixes #92662.
Thanks @liuhao1024 for the fix and @ZengWen-DT for the parallel docs wording on exact node command policy.
2026-06-14 04:58:06 +08:00
Sally O'Malley
e6ffcf7362 docs: clarify before_install hook scope (#92766)
Signed-off-by: sallyom <somalley@redhat.com>
2026-06-13 16:54:04 -04:00
Ariel Smoliar
8047350445 docs(config): correct agent defaults concurrency comments
Correct the exported agent defaults type comments for `maxConcurrent` and `subagents.maxConcurrent` so they match the runtime defaults of 4 and 8.

No runtime behavior changes.
Thanks @ArielSmoliar for the fix.
2026-06-14 04:44:03 +08:00
Terrance Chen
15e4fbf593 fix(markdown-core): treat infinity chunk limit as unbounded
Fix render-aware markdown chunking so `Number.POSITIVE_INFINITY` is treated as an explicit unbounded chunk limit instead of falling back to `1`.

This preserves full Signal media captions and disabled Signal text chunking while keeping invalid non-finite limits on the existing fallback path.

Fixes #92734.
Thanks @yhterrance for the report and fix.
2026-06-14 04:29:12 +08:00
KRATOS
4e4ea1c16b fix(diagnostics): keep recovery scheduling out of the stuck-session warning backoff (#92752)
Summary:
- The branch changes diagnostic stuck/long-running warning backoff so recovery-eligible classifications are still returned during throttled warning ticks and updates the diagnostic tests.
- PR surface: Source +17, Tests +48. Total +65 across 2 files.
- Reproducibility: yes. Current main source shows logSessionAttention can return undefined during stuck or lon ... g backoff before the heartbeat reaches requestStuckSessionRecovery; I did not run a live QQ gateway replay.

Automerge notes:
- PR branch already contained follow-up commit before automerge: fix(diagnostics): keep recovery scheduling out of the stuck-session w…

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

Prepared head SHA: f61ec3a33f
Review: https://github.com/openclaw/openclaw/pull/92752#issuecomment-4699298908

Co-authored-by: Gnanam <gnanasekaran.sekareee@gmail.com>
Co-authored-by: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com>
Co-authored-by: clawsweeper[bot] <274271284+clawsweeper[bot]@users.noreply.github.com>
Approved-by: takhoffman
Co-authored-by: takhoffman <781889+takhoffman@users.noreply.github.com>
2026-06-13 20:05:33 +00:00
Narahari Raghava
b2da129e51 fix(tui): show resolved model ref in confirmation
Uses the canonical model ref returned by `sessions.patch` for the TUI `/model` confirmation so alias inputs report the model that was actually applied. The fallback still shows the raw input when a backend does not return `resolved`, and the display path uses `modelKey` so nested model ids keep the provider prefix without double-prefixing self-prefixed ids.

Proof: local focused TUI Vitest/format/lint; autoreview clean; Crabbox AWS focused TUI test run `run_7d7cc5b040e8`; exact-head GitHub CI green on `6db4acfb08f9d477ee1bdab429bd7189b78ffc92`.
2026-06-14 03:45:59 +08:00
Yzx
5b21a0337b fix(state): avoid sqlite wal on nfs state volumes
Detects NFS-backed SQLite database paths in the shared WAL helper and uses rollback journaling for those paths while preserving WAL/checkpoint maintenance on local filesystems. The NFS path now verifies SQLite's effective journal mode before disabling WAL maintenance, and core/memory/proxy-capture callers pass database path context into the centralized helper.

Fixes #90491.

Proof: local focused Vitest/format/lint; autoreview clean after fixing the journal-mode verification finding; Crabbox AWS focused test run `run_2ea7014350da`; Crabbox AWS changed gate `run_c828bbfe7d23`; exact-head GitHub CI green on `59674305ecd863d4815eec6098ccd3daab79ca4f`.
2026-06-14 03:32:13 +08:00
Shakker
dbf24fe35a fix: start workspace files collapsed 2026-06-13 20:19:34 +01:00
sallyom
d03932af18 feat: make workspace files panel collapsible
Signed-off-by: sallyom <somalley@redhat.com>
2026-06-13 20:19:34 +01:00
Zee Zheng
13a079b3f8 fix(telegram): expose thread create CLI remap
Exposes Telegram's thread-create CLI remap through the exported Telegram channel action adapter, preserving the existing plugin-owned mapping to topic-create before gateway dispatch.\n\nFixes #81581.\n\nProof: local focused format/lint/Vitest and dry-run; autoreview clean; Crabbox AWS run_07b98c939fce focused tests; Crabbox AWS run_1b7b35ce1de1 check:changed; exact-head GitHub CI green on 16f6afbdd7.
2026-06-14 02:37:31 +08:00
Colin Johnson
e58310b000 fix(ios): force stale foreground gateway reconnects (#92552) 2026-06-13 20:33:39 +02:00
狼哥
a1814586c6 fix(ui): preserve dashboard session parent lineage
Preserves the selected Control UI session as the parent when creating dashboard child sessions even if the session list is stale or filtered, while avoiding the synthetic unknown session as a parent.\n\nFixes #90623.\n\nProof: local focused format/lint/Vitest/browser test; autoreview clean; Crabbox AWS run_a2bfdcd2315a UI proof; Crabbox AWS run_ce60fdc546ff check:changed; exact-head GitHub CI green on 03d1c6f646.
2026-06-14 02:14:28 +08:00
Matt H
ca2410ab07 fix(parallel): send User-Agent on free MCP requests
Adds the OpenClaw Parallel User-Agent to free Parallel Search MCP requests so the zero-config web_search path is identifiable at the HTTP layer, matching the paid REST transport.\n\nProof: local focused format/lint/Vitest; live anonymous Parallel MCP handshake; autoreview clean; Crabbox AWS run_bf41ce86e862 focused regression; Crabbox AWS run_ee9b8954b081 check:changed; exact-head GitHub CI green on b7e45e3bfc.
2026-06-14 01:52:59 +08:00
ooiuuii
d20fdf3b38 fix(gateway): mark active main sessions before restart shutdown aborts (#91357)
* Mark active main sessions during restart shutdown

* Type restart marker mock in close tests

* fix(gateway): preserve active run ownership across restart

* fix(gateway): preserve active runs across restart

* fix(gateway): close restart recovery edge cases

* fix(cron): preserve lifecycle ownership across restart

* fix(gateway): release rejected run contexts

* fix(gateway): preserve restart lifecycle ownership

* fix(cron): retain overlapping run ownership

* fix(agents): preserve restart terminal precedence

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-06-13 10:49:17 -07:00
Josh Avant
689ebc815b feat: support /btw in CLI-backed sessions (#92669)
* feat: support CLI btw side questions

* test: fix CLI prepare test fixture types

* fix: lazy load local btw runner
2026-06-13 19:36:53 +02:00
bymle
22069bcc56 fix(google): strip provider prefix from Vertex model path
Summary:
- Strip the redundant `google/` provider prefix before embedding Google Vertex model ids under `/publishers/google/models/`.
- Keep bare Vertex model ids unchanged.
- Add regression coverage for the provider-qualified Vertex path.

Verification:
- `node_modules/.bin/oxfmt --check --threads=1 extensions/google/transport-stream.ts extensions/google/transport-stream.test.ts`
- `node scripts/run-oxlint.mjs extensions/google/transport-stream.ts extensions/google/transport-stream.test.ts`
- `node scripts/run-vitest.mjs extensions/google/transport-stream.test.ts --maxWorkers=1 -t 'strips redundant google provider prefixes from Google Vertex model paths'`
- Autoreview clean
- AWS Crabbox `run_649b209478d2` focused Node 24 regression proof
- AWS Crabbox `run_e193db2707ad` remote `check:changed`
- Exact-head CI green for `23aca6f46f596e220df37d939317b433f7044ec6`
- Contributor live Google Vertex proof recorded in the PR body
2026-06-14 01:13:43 +08:00
NianJiu
b01a54de6f fix(ui): restore sidebar session picker interactivity above desktop workbench (#92705)
* fix(ui): restore sidebar session picker interactivity above desktop workbench

The collapsed sidebar session picker was covered by the chat content
area when the workspace rail was visible at wider viewports. Two
issues caused this:

1. .sidebar-session-select--collapsed .chat-session-picker used
   var(--z-dropdown) which was never defined, creating an invalid
   z-index declaration (falls back to auto).

2. .shell-nav and .content--chat are grid siblings with equal
   z-index (auto), and .content--chat (later DOM) paints above
   .shell-nav, covering the session picker that extends from the
   nav column into the content column.

Fix: add position:relative + z-index:10 to .shell-nav so it stacks
above .content--chat; change overflow from hidden to visible so
the session picker extends beyond the nav rail; replace undefined
var(--z-dropdown) with z-index:100.

* fix(ui): keep sidebar picker z-index tokenized

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-06-14 01:13:29 +08:00
Ayaan Zaidi
45e36a241a fix(telegram): pass rich text prompts to cli backends 2026-06-13 21:45:22 +05:30
Ayaan Zaidi
5cb6f8aa9f fix(telegram): show rich text prompt for final replies 2026-06-13 21:45:22 +05:30
Ayaan Zaidi
b9ad8649d0 fix(telegram): allow rich tables in group prompts 2026-06-13 21:45:22 +05:30
Ayaan Zaidi
4e8a527542 test(telegram): align message flow fixture with rich drafts 2026-06-13 21:45:22 +05:30
Ayaan Zaidi
0eb92fa79c fix(telegram): clean rich message CI gates 2026-06-13 21:45:22 +05:30
Ayaan Zaidi
f1e303404c feat(telegram): nudge agents toward rich text 2026-06-13 21:45:22 +05:30
Ayaan Zaidi
80d2b40fac fix(telegram): keep rich text media-free 2026-06-13 21:45:22 +05:30
Ayaan Zaidi
a3bc0097c8 fix(telegram): migrate retired native draft config 2026-06-13 21:45:22 +05:30
Ayaan Zaidi
93318050e1 test(telegram): cover rich list and table limits 2026-06-13 21:45:22 +05:30
Ayaan Zaidi
18fbcef496 fix(chunking): preserve surrogate pairs 2026-06-13 21:45:22 +05:30
Ayaan Zaidi
e8b142feb1 refactor(telegram): remove native draft previews 2026-06-13 21:45:22 +05:30
Ayaan Zaidi
547cc0f109 feat(telegram): send text as rich messages 2026-06-13 21:45:22 +05:30
zhouhe-xydt
bb71f46251 fix(ui): preserve reset soft command args
Fixes #91316

Summary:
- Preserve `/reset soft ...` arguments when Control UI dispatches the local reset command.
- Reuse parsed slash-command semantics for reset confirmation detection.
- Keep non-soft reset tails on the destructive confirmation path across whitespace and colon separators.

Verification:
- `node_modules/.bin/oxfmt --check --threads=1 ui/src/ui/app-chat.ts ui/src/ui/app-chat.test.ts`
- `node scripts/run-oxlint.mjs ui/src/ui/app-chat.ts ui/src/ui/app-chat.test.ts`
- `node scripts/run-vitest.mjs ui/src/ui/app-chat.test.ts --maxWorkers=1 -t 'reset soft|reset softish|typed /reset command dispatch'`
- Autoreview clean
- AWS Crabbox `run_fbaf31b3fff8` focused Node 24 regression proof
- AWS Crabbox `run_eb3af5b92e42` remote `check:changed`
- Exact-head CI green for `5dee6f488fd393cb2127fe152f0d3fd53ccc13d2`
2026-06-13 23:55:59 +08:00
Vincent Koc
a6aa84f2d0 test(plugins): avoid brittle provider ref error text 2026-06-13 23:30:38 +08:00
Andy Ye
3b94949437 fix(agents): deliver generated media completions in webchat
Fixes #91003

Add explicit generated-media directives to completion handoff prompts and treat real attachment payloads as visible session-only delivery evidence for dashboard/webchat completions. Hardened maintainer follow-up keeps malformed attachment arrays from masking failed delivery and keeps generated MEDIA directive values single-line sanitized.

Proof: focused local format/lint/Vitest, clean final autoreview, Crabbox AWS focused proof run_32499eb46b33, Crabbox AWS check:changed run_af46879ffbd1, and exact-head GitHub CI green for f8e6f4a04e.
2026-06-13 23:21:08 +08:00
Vincent Koc
45056a463a fix(test): extend watchdog for gateway core shard 2026-06-13 23:01:11 +08:00
ZengWen-DT
c773d8cd8e fix(cron): de-duplicate main-session heartbeat events
Fixes #44922

Preserve heartbeat-owned cron reminders as a single model input during heartbeat runs while keeping normal-turn fallback delivery when a heartbeat is skipped.

Proof: focused local Vitest/oxlint/format, clean autoreview, Crabbox AWS run_67abc286250a, Crabbox AWS check:changed run_bddebf014d58, and exact-head GitHub CI green for 341e807d7a.
2026-06-13 22:49:48 +08:00
Vincent Koc
eb1b640854 test(config): contain shell env fallback in config write tests 2026-06-13 22:22:01 +08:00
Andy Ye
ddacb7ba39 fix(memory): keep memory_search in transient qmd mode (#92639)
Summary:
- Merged fix(memory): keep memory_search in transient qmd mode after ClawSweeper review.

Automerge notes:
- PR branch already contained follow-up commit before automerge: fix(memory): close transient search managers
- PR branch already contained follow-up commit before automerge: fix(memory): preserve default search managers
- PR branch already contained follow-up commit before automerge: fix(memory): preserve qmd cli boot freshness

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

Prepared head SHA: 64fe82c24c
Review: https://github.com/openclaw/openclaw/pull/92639#issuecomment-4698763950

Co-authored-by: Andy Ye <35905412+TurboTheTurtle@users.noreply.github.com>
Co-authored-by: clawsweeper[bot] <274271284+clawsweeper[bot]@users.noreply.github.com>
Approved-by: takhoffman
Co-authored-by: takhoffman <781889+takhoffman@users.noreply.github.com>
2026-06-13 14:14:54 +00:00
Song Zhenlin
762d8d8e64 fix(feishu): clear client cache on test SDK swap
Clear cached Feishu clients when the test runtime replaces the SDK, preventing stale clients from leaking across test setup. Adds regression coverage for the SDK swap path. Fixes #83911.
2026-06-13 22:01:15 +08:00
Anson_H
205ab8d4bd perf(terminal): reuse ANSI truncation scanner
Reuse one module-level ANSI/OSC scanner during visible-width truncation and reset scanner state between calls. Keeps styled, plain, and OSC-8 truncation behavior covered by regression tests.
2026-06-13 21:54:53 +08:00
Ayaan Zaidi
7994880864 fix(usage): suppress unknown total-only cost 2026-06-13 19:16:02 +05:30
Ayaan Zaidi
afe75b3387 fix(usage): warn on broken footer templates 2026-06-13 19:16:02 +05:30
Ayaan Zaidi
84cbaf1832 fix(usage): preserve partial footer counts 2026-06-13 19:16:02 +05:30
Ayaan Zaidi
5892dc8522 docs(usage): avoid unsupported duration template path 2026-06-13 19:16:02 +05:30
Ayaan Zaidi
a55accb4b6 fix(usage): reject empty footer templates 2026-06-13 19:16:02 +05:30
Ayaan Zaidi
cdd71103c9 test(usage): align full footer contract 2026-06-13 19:16:02 +05:30
Ayaan Zaidi
7328caba82 fix(usage): simplify default full footer 2026-06-13 19:16:02 +05:30
Peter Lindsey
3ec16bbad3 feat(usage): merge user footer templates over the default + ship full scale palette + docs
- messages.usageTemplate now layers OVER the built-in default (objects
  merge by key, arrays/scalars replace), like other openclaw config
  objects, so a user template only needs the delta it adds/changes.
- Default ships the full scale palette (braille/block/shade/moon/level/
  weather/plants/moons6); users add more by name.
- Document the template format end to end (the "default" sentinel, merge
  behavior, the contract paths, verb table, piece forms, a worked example)
  in docs/concepts/usage-tracking.md — previously unauthorable from docs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 19:16:02 +05:30
Peter Lindsey
cc831f8684 feat(usage): built-in "default" footer template (hidden)
Set messages.usageTemplate to the sentinel "default" to render a
good-looking built-in /usage full footer without supplying a template.
Intentionally undocumented in the config schema/help for now; a path or
inline object still overrides, and unset keeps the built-in line.

The default lives in source (default-template.ts) rather than a shipped
JSON so it stays in lockstep with the renderer. It keeps the 📚
context-window bar; it does not render limits/reset windows (the merged
PluginHookReplyUsageState carries no limits data).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 19:16:02 +05:30
Jayesh Betala
89cc175b2e fix(disk-space): promote rounded GiB boundary
Round MiB before selecting the display unit so low-disk warnings do not render boundary values as 1024 MiB. Adds regression coverage for the GiB boundary. Fixes #90245.
2026-06-13 21:45:51 +08:00
Ayaan Zaidi
3c02c239b4 test(openai): type storeless responses replay cases 2026-06-13 19:13:38 +05:30
Ayaan Zaidi
7359206b76 refactor(openai): simplify storeless replay gating 2026-06-13 19:13:38 +05:30
snowzlm
37d6fd2e81 test(OpenAI Responses): cover storeless replay compatibility 2026-06-13 19:13:38 +05:30
snowzlm
8ecf55b36a fix(OpenAI Responses): gate replay when store is stripped 2026-06-13 19:13:38 +05:30
Song Zhenlin
2e8a2d617d fix(browser): remove dead requireRef navigation import
Remove the unused requireRef import and void anchor from Browser navigation command registration while keeping navigate/resize registration covered by regression tests. Fixes #83878.
2026-06-13 21:39:51 +08:00
Vincent Koc
27e24ca683 fix(test): extend watchdog for slow vitest shards 2026-06-13 21:37:57 +08:00
huangjianxiong
68e234f9e2 fix(cli): preserve usage-error exits for lazy reparses
Reparse nested lazy commands from the Commander root so unknown options keep the original argv and exit non-zero. Adds nested lazy-command coverage for the root rawArgs path. Fixes #92069.
2026-06-13 21:33:30 +08:00
clawsweeper[bot]
5854e0c8f6 fix: split image setup and request timeout semantics (#92673)
Summary:
- The PR separates image media-understanding setup and provider request timeout handling, adds focused timeout regression tests, and updates gateway/Codex docs for the existing image timeout setting.
- PR surface: Source +39, Tests +67, Docs +8. Total +114 across 5 files.
- Reproducibility: yes. Source inspection shows current main subtracts setup elapsed time from the provider request timeout, and the PR adds a slow-setup regression test that exercises the failure path.

Automerge notes:
- PR branch already contained follow-up commit before automerge: docs: clarify image timeout phase semantics
- PR branch already contained follow-up commit before automerge: fix: bound image setup timeout separately
- PR branch already contained follow-up commit before automerge: Revert "fix: bound image setup timeout separately"
- PR branch already contained follow-up commit before automerge: fix: split image setup and request timeout semantics

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

Prepared head SHA: 001dee3fb0
Review: https://github.com/openclaw/openclaw/pull/92673#issuecomment-4698582136

Co-authored-by: Mason Huang <masonxhuang@tencent.com>
Co-authored-by: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com>
Co-authored-by: clawsweeper[bot] <274271284+clawsweeper[bot]@users.noreply.github.com>
Approved-by: hxy91819
Co-authored-by: hxy91819 <8814856+hxy91819@users.noreply.github.com>
2026-06-13 13:29:45 +00:00
Mason Huang
eaeedbf1f9 fix(docs): finalize i18n postprocess before skip (#92668)
Summary:
- Merged fix(docs): finalize i18n postprocess before skip after ClawSweeper review.

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

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

Prepared head SHA: ad79445835
Review: https://github.com/openclaw/openclaw/pull/92668#issuecomment-4698629026

Co-authored-by: Mason Huang <masonxhuang@tencent.com>
Co-authored-by: clawsweeper[bot] <274271284+clawsweeper[bot]@users.noreply.github.com>
Approved-by: hxy91819
Co-authored-by: hxy91819 <8814856+hxy91819@users.noreply.github.com>
2026-06-13 13:17:03 +00:00
Rishi Tamrakar
dc493bc9a2 fix(slack): emit message_sent on outbound replies (#89943)
Emit terminal Slack message_sent and message:sent hooks across normal, streaming, preview, fallback, slash, failure, and TTS reply paths with canonical session/target correlation and one outcome per logical payload.

Fixes #89942

Co-authored-by: Rishi Tamrakar <rishi.ktamrakar@gmail.com>
2026-06-13 06:10:51 -07:00
zhang-guiping
78c66742ab fix(agents): expose session identity in runtime prompts
Expose session key and stable session id in Runtime prompt metadata for embedded, CLI-backed, and command-generated agent prompts so agents do not infer session identity. Fixes #92453.
2026-06-13 21:03:38 +08:00
874 changed files with 64433 additions and 12723 deletions

View File

@@ -54,6 +54,13 @@ pnpm crabbox:run -- --help | sed -n '1,120p'
- For broad OpenClaw maintainer `pnpm` gates, prefer the repo wrapper with
`--provider blacksmith-testbox` or the repo Testbox helpers when the standing
Testbox policy applies.
- Cold Testbox acquisition and hydration often take tens of seconds. When broad
remote proof is likely, immediately start
`node scripts/crabbox-wrapper.mjs warmup --provider blacksmith-testbox --keep --timing-json`
in a background command session while inspecting, editing, and running
focused local tests. Poll later, reuse the returned `tbx_...` with
`--provider blacksmith-testbox --id <tbx_id>`, and stop it before handoff.
Do not warm speculatively when remote proof is unlikely.
- Always report the actual provider and id. `cbx_...` means AWS Crabbox;
`tbx_...` means Blacksmith Testbox through Crabbox. If the output only says
`blacksmith testbox list`, use `blacksmith testbox list --all` before

View File

@@ -150,9 +150,21 @@ Use this skill for release and publish-time workflow. Load `$release-private` if
- Stable Windows Hub release closeout requires the signed
`OpenClawCompanion-Setup-x64.exe`, `OpenClawCompanion-Setup-arm64.exe`, and
`OpenClawCompanion-SHA256SUMS.txt` assets on the canonical
`openclaw/openclaw` GitHub Release. Use the public `Windows Node Release`
workflow after the matching `openclaw/openclaw-windows-node` release exists;
it verifies Authenticode signatures on Windows before uploading assets.
`openclaw/openclaw` GitHub Release. Pass the exact signed
`openclaw/openclaw-windows-node` release tag as `windows_node_tag` to
`OpenClaw Release Publish`, together with the candidate-approved
`windows_node_installer_digests` map; it prevalidates the published source
release and required installers against that map before any publish child,
dispatches the public `Windows Node Release` workflow while the OpenClaw
release is still a draft, carries those pinned source asset digests
unchanged, verifies the expected OpenClaw Foundation Authenticode signer on
Windows, re-downloads and checksum-verifies the promoted asset contract, and
blocks publication until the canonical asset contract is present. Use direct
`Windows Node Release` dispatch only for recovery, always with an exact tag,
never `latest`, and the explicit `expected_installer_digests` JSON map from
the approved source release. Recovery rejects unexpected
`OpenClawCompanion-*` target asset names, then replaces the expected contract
assets with the pinned source bytes.
- Website Windows Hub download links should target exact canonical
`openclaw/openclaw/releases/download/vYYYY.M.PATCH/...` assets for the current
stable release, or `releases/latest/download/...` only after verifying the
@@ -675,19 +687,23 @@ node --import tsx scripts/openclaw-npm-postpublish-verify.ts <published-version>
where npm did not publish the beta version, delete/recreate the same beta
tag and any accidental draft/incomplete prerelease at the fixed commit
instead of skipping a prerelease number.
22. Start `.github/workflows/openclaw-npm-release.yml` from the same branch with
22. Start `.github/workflows/openclaw-release-publish.yml` from the same branch with
the same tag for the real publish, choose `npm_dist_tag` (`beta` default,
`latest` only when you intentionally want direct stable publish), keep it
the same as the preflight run, and pass the successful npm
`preflight_run_id`.
`preflight_run_id` plus the successful `full_release_validation_run_id`.
For stable publish, also pass the exact non-prerelease
`openclaw/openclaw-windows-node` tag as `windows_node_tag` and its
candidate-approved installer digest map as `windows_node_installer_digests`.
23. Wait for `npm-release` approval from `@openclaw/openclaw-release-managers`.
24. Wait for the real publish workflow to run postpublish verification,
create or update the GitHub release as a draft, upload dependency evidence,
promote and verify the required Windows Hub assets for stable releases,
append release verification proof, and only then undraft/publish it. If a
waited plugin publish fails after OpenClaw npm succeeds, the workflow keeps
the release draft with OpenClaw npm evidence and exits red; do not undraft
until the plugin publish gap is repaired. The standalone verifier command
remains the recovery probe:
waited plugin publish or Windows Hub promotion fails after OpenClaw npm
succeeds, the workflow keeps the release draft with OpenClaw npm evidence
and exits red; do not undraft until the gap is repaired. The standalone
verifier command remains the recovery probe:
`node --import tsx scripts/openclaw-npm-postpublish-verify.ts <published-version>`.
25. Run the post-published beta verification roster. First scan current `main`
for critical fixes that landed after the release branch cut; backport only

View File

@@ -1,61 +0,0 @@
name: openclaw-codeql-process-exec-boundary-critical-security
disable-default-queries: true
queries:
- uses: security-extended
query-filters:
- include:
precision:
- high
- very-high
tags contain: security
security-severity: /([7-9]|10)\.(\d)+/
paths:
- src/process
- src/tui/tui-local-shell.ts
- src/tui/tui.ts
- src/plugin-sdk/windows-spawn.ts
- packages/agent-core/src/harness/env
- packages/memory-host-sdk/src/host
- extensions/acpx/src
- extensions/bonjour/src/advertiser.ts
- extensions/browser/src/browser/chrome-mcp.ts
- extensions/browser/src/browser/chrome.executables.ts
- extensions/browser/src/browser/chrome.ts
- extensions/codex/src/app-server/sandbox-exec-server
- extensions/codex/src/app-server/transport-stdio.ts
- extensions/codex/src/node-cli-sessions.ts
- extensions/codex-supervisor/src/json-rpc-client.ts
- extensions/file-transfer/src
- extensions/google-meet/src
- extensions/imessage/src
- extensions/memory-core/src/memory/qmd-manager.ts
- extensions/memory-wiki/src/obsidian.ts
- extensions/microsoft-foundry/cli.ts
- extensions/ollama/src/wsl2-crash-loop-check.ts
- extensions/qa-lab/src
- extensions/signal/src/daemon.ts
- extensions/tts-local-cli/speech-provider.ts
- extensions/voice-call/src
- scripts
paths-ignore:
- "**/node_modules"
- "**/coverage"
- "**/*.generated.ts"
- "**/*.bundle.js"
- "**/*-runtime.js"
- "**/*.test.ts"
- "**/*.test.tsx"
- "**/*.spec.ts"
- "**/*.spec.tsx"
- "**/*.e2e.test.ts"
- "**/*.e2e.test.tsx"
- "**/*test-support*"
- "**/*test-helper*"
- "**/*mock*"
- "**/*fixture*"
- "**/*bench*"

View File

@@ -1358,6 +1358,8 @@ jobs:
- check_name: check-additional-boundaries-bcd
group: boundaries
boundary_shard: 2/4,3/4,4/4
- check_name: check-session-accessor-boundary
group: session-accessor-boundary
- check_name: check-additional-extension-channels
group: extension-channels
- check_name: check-additional-extension-bundled
@@ -1504,6 +1506,15 @@ jobs:
boundaries)
node scripts/run-additional-boundary-checks.mjs
;;
session-accessor-boundary)
if [ ! -f scripts/check-session-accessor-boundary.mjs ]; then
echo "[skip] session accessor boundary check is not present in this checkout"
elif ! node -e 'const pkg = require("./package.json"); process.exit(pkg.scripts?.["lint:tmp:session-accessor-boundary"] ? 0 : 1);'; then
echo "[skip] session accessor boundary script is not present in package.json"
else
run_check "lint:tmp:session-accessor-boundary" pnpm run lint:tmp:session-accessor-boundary
fi
;;
extension-channels)
run_check "lint:extensions:channels" pnpm run lint:extensions:channels
;;

View File

@@ -17,28 +17,7 @@ on:
- ".github/actions/**"
- ".github/codeql/**"
- ".github/workflows/**"
- "extensions/acpx/src/**"
- "extensions/bonjour/src/advertiser.ts"
- "extensions/browser/src/browser/chrome-mcp.ts"
- "extensions/browser/src/browser/chrome.executables.ts"
- "extensions/browser/src/browser/chrome.ts"
- "extensions/codex/src/app-server/sandbox-exec-server/**"
- "extensions/codex/src/app-server/transport-stdio.ts"
- "extensions/codex/src/node-cli-sessions.ts"
- "extensions/codex-supervisor/src/json-rpc-client.ts"
- "extensions/file-transfer/src/**"
- "extensions/google-meet/src/**"
- "extensions/imessage/src/**"
- "extensions/memory-core/src/memory/qmd-manager.ts"
- "extensions/memory-wiki/src/obsidian.ts"
- "extensions/microsoft-foundry/cli.ts"
- "extensions/ollama/src/wsl2-crash-loop-check.ts"
- "extensions/qa-lab/src/**"
- "extensions/signal/src/daemon.ts"
- "extensions/tts-local-cli/speech-provider.ts"
- "extensions/voice-call/src/**"
- "packages/**"
- "scripts/**"
- "src/**"
push:
branches:
@@ -47,28 +26,7 @@ on:
- ".github/actions/**"
- ".github/codeql/**"
- ".github/workflows/**"
- "extensions/acpx/src/**"
- "extensions/bonjour/src/advertiser.ts"
- "extensions/browser/src/browser/chrome-mcp.ts"
- "extensions/browser/src/browser/chrome.executables.ts"
- "extensions/browser/src/browser/chrome.ts"
- "extensions/codex/src/app-server/sandbox-exec-server/**"
- "extensions/codex/src/app-server/transport-stdio.ts"
- "extensions/codex/src/node-cli-sessions.ts"
- "extensions/codex-supervisor/src/json-rpc-client.ts"
- "extensions/file-transfer/src/**"
- "extensions/google-meet/src/**"
- "extensions/imessage/src/**"
- "extensions/memory-core/src/memory/qmd-manager.ts"
- "extensions/memory-wiki/src/obsidian.ts"
- "extensions/microsoft-foundry/cli.ts"
- "extensions/ollama/src/wsl2-crash-loop-check.ts"
- "extensions/qa-lab/src/**"
- "extensions/signal/src/daemon.ts"
- "extensions/tts-local-cli/speech-provider.ts"
- "extensions/voice-call/src/**"
- "packages/**"
- "scripts/**"
- "src/**"
schedule:
- cron: "0 6 * * *"
@@ -115,11 +73,6 @@ jobs:
runs_on: blacksmith-4vcpu-ubuntu-2404
timeout_minutes: 25
config_file: ./.github/codeql/codeql-mcp-process-tool-boundary-critical-security.yml
- language: javascript-typescript
category: process-exec-boundary
runs_on: blacksmith-4vcpu-ubuntu-2404
timeout_minutes: 25
config_file: ./.github/codeql/codeql-process-exec-boundary-critical-security.yml
- language: javascript-typescript
category: plugin-trust-boundary
runs_on: blacksmith-4vcpu-ubuntu-2404

View File

@@ -783,7 +783,7 @@ jobs:
fi
args=(
-f ref="$TARGET_SHA"
-f ref="$TARGET_REF"
-f expected_sha="$TARGET_SHA"
-f provider="$PROVIDER"
-f mode="$MODE"

View File

@@ -379,7 +379,6 @@ jobs:
OPENCLAW_QA_CONVEX_SECRET_CI: ${{ secrets.OPENCLAW_QA_CONVEX_SECRET_CI }}
OPENCLAW_QA_CREDENTIAL_ACQUIRE_TIMEOUT_MS: "1800000"
OPENCLAW_QA_REDACT_PUBLIC_METADATA: "1"
OPENCLAW_QA_TELEGRAM_CAPTURE_CONTENT: "1"
CRABBOX_COORDINATOR: ${{ secrets.CRABBOX_COORDINATOR }}
CRABBOX_COORDINATOR_TOKEN: ${{ secrets.CRABBOX_COORDINATOR_TOKEN }}
OPENCLAW_QA_MANTIS_CRABBOX_COORDINATOR: ${{ secrets.OPENCLAW_QA_MANTIS_CRABBOX_COORDINATOR }}

View File

@@ -220,7 +220,6 @@ jobs:
OPENCLAW_QA_CONVEX_SECRET_CI: ${{ secrets.OPENCLAW_QA_CONVEX_SECRET_CI }}
OPENCLAW_QA_CREDENTIAL_ACQUIRE_TIMEOUT_MS: "1800000"
OPENCLAW_QA_REDACT_PUBLIC_METADATA: "1"
OPENCLAW_QA_TELEGRAM_CAPTURE_CONTENT: "1"
INPUT_SCENARIO: ${{ inputs.scenario }}
PACKAGE_ARTIFACT_NAME: ${{ inputs.package_artifact_name || '' }}
run: |

View File

@@ -420,6 +420,7 @@ jobs:
add_suite live-cache
add_profile_suite native-live-src-agents "stable full"
add_profile_suite native-live-src-agents-zai-coding "stable full"
add_profile_suite native-live-src-gateway-core "beta minimum stable full"
add_profile_suite native-live-src-gateway-profiles-anthropic "stable full"
add_profile_suite native-live-src-gateway-profiles-anthropic-smoke "stable"
@@ -1956,6 +1957,12 @@ jobs:
timeout_minutes: 60
profile_env_only: false
profiles: stable full
- suite_id: native-live-src-agents-zai-coding
label: Native live Z.AI Coding Plan
command: ZAI_CODING_LIVE_TEST=1 node .release-harness/scripts/test-live-shard.mjs native-live-src-agents-zai-coding
timeout_minutes: 15
profile_env_only: false
profiles: stable full
- suite_id: native-live-src-gateway-core
label: Native live gateway core
command: OPENCLAW_LIVE_CODEX_HARNESS=1 OPENCLAW_LIVE_CODEX_HARNESS_AUTH=api-key node .release-harness/scripts/test-live-shard.mjs native-live-src-gateway-core

View File

@@ -1181,7 +1181,7 @@ jobs:
runtime_tool_coverage_release_checks:
name: Enforce QA Lab runtime tool coverage
needs: [resolve_target, qa_lab_runtime_parity_release_checks]
if: always() && contains(fromJSON('["all","qa","qa-parity"]'), needs.resolve_target.outputs.rerun_group)
if: contains(fromJSON('["all","qa","qa-parity"]'), needs.resolve_target.outputs.rerun_group)
runs-on: ubuntu-24.04
timeout-minutes: 15
permissions:
@@ -1204,13 +1204,35 @@ jobs:
node-version: ${{ env.NODE_VERSION }}
install-bun: "true"
- name: Download runtime parity status
uses: actions/download-artifact@v8
with:
name: release-check-status-qa-runtime-parity-${{ needs.resolve_target.outputs.revision }}
path: .artifacts/release-check-status/
- name: Verify runtime parity producer status
id: verify_runtime_parity_status
shell: bash
run: |
set -euo pipefail
status_path=".artifacts/release-check-status/qa_lab_runtime_parity_release_checks.env"
status="$(sed -n 's/^status=//p' "$status_path" | tail -n 1)"
if [[ "$status" != "success" ]]; then
echo "Runtime parity producer status is ${status:-missing}; skipping coverage artifact consumer."
echo "ready=false" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "ready=true" >> "$GITHUB_OUTPUT"
- name: Download runtime parity artifacts
if: steps.verify_runtime_parity_status.outputs.ready == 'true'
uses: actions/download-artifact@v8
with:
name: release-qa-runtime-parity-${{ needs.resolve_target.outputs.revision }}
path: .artifacts/qa-e2e/
- name: Enforce standard runtime tool coverage
if: steps.verify_runtime_parity_status.outputs.ready == 'true'
run: |
set -euo pipefail
pnpm openclaw qa coverage \
@@ -1412,7 +1434,6 @@ jobs:
OPENCLAW_QA_CONVEX_SECRET_CI: ${{ secrets.OPENCLAW_QA_CONVEX_SECRET_CI }}
OPENCLAW_QA_CREDENTIAL_ACQUIRE_TIMEOUT_MS: "1800000"
OPENCLAW_QA_REDACT_PUBLIC_METADATA: "1"
OPENCLAW_QA_TELEGRAM_CAPTURE_CONTENT: "1"
run: |
set -euo pipefail

View File

@@ -15,6 +15,14 @@ on:
description: Successful Full Release Validation run id for this tag/SHA, required when publish_openclaw_npm=true
required: false
type: string
windows_node_tag:
description: Exact openclaw-windows-node release tag, required for stable OpenClaw publish
required: false
type: string
windows_node_installer_digests:
description: Candidate-approved compact JSON map of Windows installer names to pinned sha256 digests
required: false
type: string
npm_telegram_run_id:
description: Optional successful NPM Telegram Beta E2E run id to include in final release evidence
required: false
@@ -81,12 +89,15 @@ jobs:
outputs:
sha: ${{ steps.manifest.outputs.sha || steps.ref.outputs.sha }}
preflight_artifact_name: ${{ steps.preflight_artifact.outputs.name }}
windows_node_installer_digests: ${{ steps.windows_source.outputs.installer_digests }}
steps:
- name: Validate inputs
env:
RELEASE_TAG: ${{ inputs.tag }}
PREFLIGHT_RUN_ID: ${{ inputs.preflight_run_id }}
FULL_RELEASE_VALIDATION_RUN_ID: ${{ inputs.full_release_validation_run_id }}
WINDOWS_NODE_TAG: ${{ inputs.windows_node_tag }}
WINDOWS_NODE_INSTALLER_DIGESTS: ${{ inputs.windows_node_installer_digests }}
PUBLISH_OPENCLAW_NPM: ${{ inputs.publish_openclaw_npm && 'true' || 'false' }}
PLUGIN_PUBLISH_SCOPE: ${{ inputs.plugin_publish_scope }}
PLUGINS: ${{ inputs.plugins }}
@@ -115,6 +126,22 @@ jobs:
echo "publish_openclaw_npm=true requires full_release_validation_run_id." >&2
exit 1
fi
stable_release=true
if [[ "${RELEASE_TAG}" == *"-alpha."* || "${RELEASE_TAG}" == *"-beta."* ]]; then
stable_release=false
fi
if [[ -n "${WINDOWS_NODE_TAG}" && ! "${WINDOWS_NODE_TAG}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+([-.][0-9A-Za-z]+([.-][0-9A-Za-z]+)*)?$ ]]; then
echo "windows_node_tag must be an explicit openclaw-windows-node release tag, not latest: ${WINDOWS_NODE_TAG}" >&2
exit 1
fi
if [[ "${PUBLISH_OPENCLAW_NPM}" == "true" && "${stable_release}" == "true" && -z "${WINDOWS_NODE_TAG}" ]]; then
echo "Stable OpenClaw publish requires an explicit windows_node_tag." >&2
exit 1
fi
if [[ "${PUBLISH_OPENCLAW_NPM}" == "true" && "${stable_release}" == "true" && -z "${WINDOWS_NODE_INSTALLER_DIGESTS}" ]]; then
echo "Stable OpenClaw publish requires candidate-approved windows_node_installer_digests." >&2
exit 1
fi
tideclaw_alpha_publish=false
if [[ "${RELEASE_TAG}" == *"-alpha."* && "${RELEASE_NPM_DIST_TAG}" == "alpha" && "${WORKFLOW_REF}" =~ ^refs/heads/tideclaw/alpha/[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{4}Z$ ]]; then
tideclaw_alpha_publish=true
@@ -143,6 +170,73 @@ jobs:
;;
esac
- name: Validate stable Windows source release
id: windows_source
if: ${{ inputs.publish_openclaw_npm }}
env:
GH_TOKEN: ${{ github.token }}
RELEASE_TAG: ${{ inputs.tag }}
WINDOWS_NODE_TAG: ${{ inputs.windows_node_tag }}
APPROVED_INSTALLER_DIGESTS: ${{ inputs.windows_node_installer_digests }}
run: |
set -euo pipefail
if [[ "${RELEASE_TAG}" == *"-alpha."* || "${RELEASE_TAG}" == *"-beta."* ]]; then
exit 0
fi
source_json="$(gh release view "${WINDOWS_NODE_TAG}" \
--repo openclaw/openclaw-windows-node \
--json tagName,isDraft,isPrerelease,assets,url)"
if [[ "$(printf '%s' "${source_json}" | jq -r '.tagName')" != "${WINDOWS_NODE_TAG}" ]]; then
echo "Windows source release tag does not match ${WINDOWS_NODE_TAG}." >&2
exit 1
fi
if [[ "$(printf '%s' "${source_json}" | jq -r '.isDraft')" == "true" ]]; then
echo "Stable OpenClaw publish requires a published Windows source release." >&2
exit 1
fi
if [[ "$(printf '%s' "${source_json}" | jq -r '.isPrerelease')" == "true" ]]; then
echo "Stable OpenClaw publish requires a non-prerelease Windows source release." >&2
exit 1
fi
required_assets=(
"OpenClawCompanion-Setup-x64.exe"
"OpenClawCompanion-Setup-arm64.exe"
)
required_assets_json="$(printf '%s\n' "${required_assets[@]}" | jq -R . | jq -sc .)"
if ! approved_installer_digests="$(printf '%s' "${APPROVED_INSTALLER_DIGESTS}" | jq -ce --argjson names "${required_assets_json}" '
if type == "object" and
(keys | sort) == ($names | sort) and
all(.[]; type == "string" and test("^sha256:[a-f0-9]{64}$"))
then .
else error("invalid candidate-approved Windows installer digest map")
end
')"; then
echo "windows_node_installer_digests must contain exactly the candidate-approved current installer asset contract." >&2
exit 1
fi
for asset_name in "${required_assets[@]}"; do
asset_matches="$(printf '%s' "${source_json}" | jq -c --arg name "${asset_name}" '[.assets[]? | select(.name == $name)]')"
asset_match_count="$(printf '%s' "${asset_matches}" | jq 'length')"
if [[ "${asset_match_count}" != "1" ]]; then
echo "Windows source release ${WINDOWS_NODE_TAG} must contain exactly one required asset ${asset_name}; found ${asset_match_count}." >&2
exit 1
fi
asset_digest="$(printf '%s' "${asset_matches}" | jq -r '.[0].digest // empty')"
if [[ ! "${asset_digest}" =~ ^sha256:[a-f0-9]{64}$ ]]; then
echo "Windows source release ${WINDOWS_NODE_TAG} asset ${asset_name} is missing its immutable SHA-256 digest." >&2
exit 1
fi
approved_digest="$(printf '%s' "${approved_installer_digests}" | jq -r --arg name "${asset_name}" '.[$name]')"
if [[ "${asset_digest}" != "${approved_digest}" ]]; then
echo "Windows source release ${WINDOWS_NODE_TAG} asset ${asset_name} no longer matches its candidate-approved digest." >&2
exit 1
fi
done
echo "installer_digests=${approved_installer_digests}" >> "$GITHUB_OUTPUT"
echo "- Windows Node source release: prevalidated \`${WINDOWS_NODE_TAG}\`" >> "$GITHUB_STEP_SUMMARY"
- name: Download OpenClaw npm preflight manifest
id: preflight_artifact
if: ${{ inputs.publish_openclaw_npm }}
@@ -337,6 +431,7 @@ jobs:
TARGET_SHA: ${{ steps.manifest.outputs.sha || steps.ref.outputs.sha }}
RELEASE_PROFILE: ${{ steps.full_manifest.outputs.release_profile || inputs.release_profile }}
FULL_RELEASE_VALIDATION_RUN_ID: ${{ inputs.full_release_validation_run_id }}
WINDOWS_NODE_TAG: ${{ inputs.windows_node_tag }}
run: |
{
echo "### Release target"
@@ -347,13 +442,16 @@ jobs:
if [[ -n "${FULL_RELEASE_VALIDATION_RUN_ID// }" ]]; then
echo "- Full release validation: \`${FULL_RELEASE_VALIDATION_RUN_ID}\`"
fi
if [[ -n "${WINDOWS_NODE_TAG// }" ]]; then
echo "- Windows Node source release: \`${WINDOWS_NODE_TAG}\`"
fi
} >> "$GITHUB_STEP_SUMMARY"
publish:
name: Publish plugins, then OpenClaw
needs: [resolve_release_target]
runs-on: ubuntu-latest
timeout-minutes: 60
timeout-minutes: 120
environment: npm-release
steps:
- name: Checkout release SHA
@@ -383,10 +481,16 @@ jobs:
WAIT_FOR_CLAWHUB: ${{ inputs.wait_for_clawhub && 'true' || 'false' }}
PREFLIGHT_ARTIFACT_NAME: ${{ needs.resolve_release_target.outputs.preflight_artifact_name }}
NPM_TELEGRAM_RUN_ID: ${{ inputs.npm_telegram_run_id }}
WINDOWS_NODE_TAG: ${{ inputs.windows_node_tag }}
WINDOWS_NODE_INSTALLER_DIGESTS: ${{ needs.resolve_release_target.outputs.windows_node_installer_digests }}
POSTPUBLISH_EVIDENCE_DIR: ${{ runner.temp }}/openclaw-release-postpublish-evidence
run: |
set -euo pipefail
is_stable_release() {
[[ "${RELEASE_TAG}" != *"-alpha."* && "${RELEASE_TAG}" != *"-beta."* ]]
}
dispatch_workflow_at_ref() {
local workflow_ref="$1"
shift
@@ -836,10 +940,105 @@ jobs:
}
publish_github_release() {
if is_stable_release; then
verify_windows_release_asset_contract
fi
gh release edit "${RELEASE_TAG}" --repo "$GITHUB_REPOSITORY" --draft=false
echo "- GitHub release: https://github.com/${GITHUB_REPOSITORY}/releases/tag/${RELEASE_TAG}" >> "$GITHUB_STEP_SUMMARY"
}
verify_windows_release_asset_contract() {
local actual_companion_assets actual_digest asset_name expected_companion_assets expected_digest expected_hash expected_installer_names manifest_dir manifest_json manifest_path release_json
# Add future promoted installer names, such as MSIX x64/ARM64, here.
local -a installer_assets=(
"OpenClawCompanion-Setup-x64.exe"
"OpenClawCompanion-Setup-arm64.exe"
)
local -a required_assets=(
"${installer_assets[@]}"
"OpenClawCompanion-SHA256SUMS.txt"
)
release_json="$(gh release view "${RELEASE_TAG}" --repo "$GITHUB_REPOSITORY" --json assets,url)"
expected_companion_assets="$(printf '%s\n' "${required_assets[@]}" | jq -R . | jq -sc 'sort')"
actual_companion_assets="$(printf '%s' "${release_json}" | jq -c '
[.assets[]? | select(.name | startswith("OpenClawCompanion-")) | .name] | sort
')"
if [[ "${actual_companion_assets}" != "${expected_companion_assets}" ]]; then
echo "Stable release OpenClawCompanion asset names do not exactly match the current contract." >&2
return 1
fi
for asset_name in "${required_assets[@]}"; do
if ! printf '%s' "${release_json}" | jq -e --arg name "${asset_name}" 'any(.assets[]?; .name == $name)' >/dev/null; then
echo "Stable release is missing required Windows asset ${asset_name}." >&2
return 1
fi
done
manifest_dir="${RUNNER_TEMP}/openclaw-windows-release-contract"
manifest_path="${manifest_dir}/OpenClawCompanion-SHA256SUMS.txt"
rm -rf "${manifest_dir}"
mkdir -p "${manifest_dir}"
gh release download "${RELEASE_TAG}" \
--repo "$GITHUB_REPOSITORY" \
--pattern "OpenClawCompanion-SHA256SUMS.txt" \
--dir "${manifest_dir}"
if ! manifest_json="$(jq -Rsc '
split("\n") as $lines |
(if $lines[-1] == "" then $lines[0:-1] else $lines end) |
map(sub("\r$"; "")) |
if all(.[]; test("^(?<hash>[a-f0-9]{64}) (?<name>[^/\\\\]+)$"))
then map(capture("^(?<hash>[a-f0-9]{64}) (?<name>[^/\\\\]+)$"))
else error("malformed Windows checksum manifest entry")
end
' "${manifest_path}")"; then
echo "Stable release Windows checksum manifest contains malformed entries." >&2
return 1
fi
expected_installer_names="$(printf '%s\n' "${installer_assets[@]}" | jq -R . | jq -sc 'sort')"
if ! printf '%s' "${manifest_json}" | jq -e --argjson expected "${expected_installer_names}" '
length == ($expected | length) and
([.[].name] | sort) == $expected and
([.[].name] | unique | length) == length
' >/dev/null; then
echo "Stable release Windows checksum manifest does not exactly match the installer asset contract." >&2
return 1
fi
for asset_name in "${installer_assets[@]}"; do
expected_digest="$(printf '%s' "${WINDOWS_NODE_INSTALLER_DIGESTS}" | jq -r --arg name "${asset_name}" '.[$name] // empty')"
actual_digest="$(printf '%s' "${release_json}" | jq -r --arg name "${asset_name}" '.assets[]? | select(.name == $name) | .digest // empty')"
if [[ -z "${expected_digest}" || "${actual_digest}" != "${expected_digest}" ]]; then
echo "Stable release Windows asset ${asset_name} does not match its pinned digest." >&2
return 1
fi
expected_hash="${expected_digest#sha256:}"
if ! printf '%s' "${manifest_json}" | jq -e --arg name "${asset_name}" --arg hash "${expected_hash}" '
any(.[]; .name == $name and .hash == $hash)
' >/dev/null; then
echo "Stable release Windows checksum manifest does not match pinned digest for ${asset_name}." >&2
return 1
fi
done
echo "- Windows Hub asset contract: verified" >> "$GITHUB_STEP_SUMMARY"
}
promote_windows_release_assets() {
if ! is_stable_release; then
return 0
fi
if [[ -z "${WINDOWS_NODE_INSTALLER_DIGESTS// }" ]]; then
echo "Stable release is missing prevalidated Windows installer digests." >&2
return 1
fi
windows_node_run_id="$(dispatch_workflow windows-node-release.yml \
-f tag="${RELEASE_TAG}" \
-f windows_node_tag="${WINDOWS_NODE_TAG}" \
-f expected_installer_digests="${WINDOWS_NODE_INSTALLER_DIGESTS}")"
echo "- Windows Node release run ID: \`${windows_node_run_id}\`" >> "$GITHUB_STEP_SUMMARY"
wait_for_run windows-node-release.yml "${windows_node_run_id}"
}
upload_dependency_evidence_release_asset() {
local release_version download_dir asset_path asset_name artifact_name
release_version="${RELEASE_TAG#v}"
@@ -913,7 +1112,7 @@ jobs:
}
append_release_proof_to_github_release() {
local release_version body_file notes_file tarball integrity telegram_line clawhub_line clawhub_bootstrap_line clawhub_runtime_state_path
local release_version body_file notes_file tarball integrity telegram_line clawhub_line clawhub_bootstrap_line clawhub_runtime_state_path windows_line
release_version="${RELEASE_TAG#v}"
body_file="${RUNNER_TEMP}/release-body.md"
@@ -931,6 +1130,10 @@ jobs:
write_clawhub_runtime_state false "${clawhub_runtime_state_path}"
clawhub_line="$(jq -r '.proofLines.normal' "${clawhub_runtime_state_path}")"
clawhub_bootstrap_line="$(jq -r '.proofLines.bootstrap' "${clawhub_runtime_state_path}")"
windows_line=""
if [[ -n "${windows_node_run_id// }" ]]; then
windows_line="- Windows Hub promotion: https://github.com/${GITHUB_REPOSITORY}/actions/runs/${windows_node_run_id} from openclaw/openclaw-windows-node@${WINDOWS_NODE_TAG}"
fi
RELEASE_BODY_FILE="${body_file}" \
RELEASE_NOTES_FILE="${notes_file}" \
@@ -948,6 +1151,7 @@ jobs:
CLAWHUB_LINE="${clawhub_line}" \
CLAWHUB_BOOTSTRAP_LINE="${clawhub_bootstrap_line}" \
TELEGRAM_LINE="${telegram_line}" \
WINDOWS_LINE="${windows_line}" \
node --input-type=module <<'NODE'
import { readFileSync, writeFileSync } from "node:fs";
@@ -974,6 +1178,7 @@ jobs:
process.env.CLAWHUB_BOOTSTRAP_LINE,
`- OpenClaw npm publish: https://github.com/${process.env.RELEASE_REPO}/actions/runs/${process.env.OPENCLAW_NPM_RUN_ID}`,
process.env.TELEGRAM_LINE,
...(process.env.WINDOWS_LINE ? [process.env.WINDOWS_LINE] : []),
].join("\n");
const withoutOldProof = body.replace(/\n?### Release verification\n[\s\S]*?(?=\n### |\n## |$)/, "");
@@ -998,6 +1203,9 @@ jobs:
else
echo "- OpenClaw npm publish: skipped by input"
fi
if is_stable_release && [[ "${PUBLISH_OPENCLAW_NPM}" == "true" ]]; then
echo "- Windows Hub promotion: required before the GitHub release can be published"
fi
if [[ "${WAIT_FOR_CLAWHUB}" == "true" ]]; then
echo "- Workflow completion waits for ClawHub"
else
@@ -1142,6 +1350,7 @@ jobs:
failed=0
openclaw_failed=0
windows_node_run_id=""
if [[ -n "${openclaw_pid}" ]] && ! wait "${openclaw_pid}"; then
failed=1
openclaw_failed=1
@@ -1172,6 +1381,9 @@ jobs:
fi
create_or_update_github_release
upload_dependency_evidence_release_asset
if ! promote_windows_release_assets; then
failed=1
fi
append_release_proof_to_github_release
if [[ "${failed}" == "0" ]]; then
publish_github_release

View File

@@ -532,7 +532,6 @@ jobs:
OPENCLAW_QA_CONVEX_SECRET_CI: ${{ secrets.OPENCLAW_QA_CONVEX_SECRET_CI }}
OPENCLAW_QA_CREDENTIAL_ACQUIRE_TIMEOUT_MS: "1800000"
OPENCLAW_QA_REDACT_PUBLIC_METADATA: "1"
OPENCLAW_QA_TELEGRAM_CAPTURE_CONTENT: "1"
INPUT_SCENARIO: ${{ github.event_name == 'workflow_dispatch' && inputs.scenario || '' }}
run: |
set -euo pipefail

View File

@@ -68,7 +68,7 @@ jobs:
days-before-pr-close: 7
stale-issue-label: stale
stale-pr-label: stale
exempt-issue-labels: enhancement,maintainer,pinned,security,no-stale,bad-barnacle
exempt-issue-labels: enhancement,maintainer,pinned,security,no-stale,bad-barnacle,clawsweeper:queueable-fix,clawsweeper:source-repro,clawsweeper:fix-shape-clear
exempt-pr-labels: maintainer,no-stale,bad-barnacle
operations-per-run: 2000
ascending: true
@@ -100,7 +100,7 @@ jobs:
days-before-pr-stale: -1
days-before-pr-close: -1
stale-issue-label: stale
exempt-issue-labels: enhancement,maintainer,pinned,security,no-stale,bad-barnacle
exempt-issue-labels: enhancement,maintainer,pinned,security,no-stale,bad-barnacle,clawsweeper:queueable-fix,clawsweeper:source-repro,clawsweeper:fix-shape-clear
operations-per-run: 2000
ascending: true
include-only-assigned: true
@@ -172,7 +172,7 @@ jobs:
days-before-pr-close: 7
stale-issue-label: stale
stale-pr-label: stale
exempt-issue-labels: enhancement,maintainer,pinned,security,no-stale,bad-barnacle
exempt-issue-labels: enhancement,maintainer,pinned,security,no-stale,bad-barnacle,clawsweeper:queueable-fix,clawsweeper:source-repro,clawsweeper:fix-shape-clear
exempt-pr-labels: maintainer,no-stale,bad-barnacle
operations-per-run: 2000
ascending: true
@@ -203,7 +203,7 @@ jobs:
days-before-pr-stale: -1
days-before-pr-close: -1
stale-issue-label: stale
exempt-issue-labels: enhancement,maintainer,pinned,security,no-stale,bad-barnacle
exempt-issue-labels: enhancement,maintainer,pinned,security,no-stale,bad-barnacle,clawsweeper:queueable-fix,clawsweeper:source-repro,clawsweeper:fix-shape-clear
operations-per-run: 2000
ascending: true
include-only-assigned: true
@@ -277,6 +277,9 @@ jobs:
"security",
"no-stale",
"bad-barnacle",
"clawsweeper:queueable-fix",
"clawsweeper:source-repro",
"clawsweeper:fix-shape-clear",
]);
const prExemptLabels = new Set(["maintainer", "no-stale", "bad-barnacle"]);
const maintainerAssociations = new Set(["OWNER", "MEMBER", "COLLABORATOR"]);

View File

@@ -8,9 +8,12 @@ on:
required: true
type: string
windows_node_tag:
description: openclaw-windows-node release tag to promote, or latest
description: Exact openclaw-windows-node release tag to promote, for example v0.6.3
required: true
type: string
expected_installer_digests:
description: Compact JSON map of installer asset names to pinned source sha256 digests
required: true
default: latest
type: string
permissions:
@@ -31,46 +34,129 @@ jobs:
env:
RELEASE_TAG: ${{ inputs.tag }}
WINDOWS_NODE_TAG: ${{ inputs.windows_node_tag }}
EXPECTED_INSTALLER_DIGESTS: ${{ inputs.expected_installer_digests }}
GH_TOKEN: ${{ github.token }}
run: |
if ($env:RELEASE_TAG -notmatch '^v[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*((-(alpha|beta)\.[1-9][0-9]*)|(-[1-9][0-9]*))?$') {
throw "Invalid OpenClaw release tag: $env:RELEASE_TAG"
}
if ($env:WINDOWS_NODE_TAG -ne "latest" -and $env:WINDOWS_NODE_TAG -notmatch '^v[0-9]+\.[0-9]+\.[0-9]+([-.][0-9A-Za-z.-]+)?$') {
throw "Invalid openclaw-windows-node release tag: $env:WINDOWS_NODE_TAG"
$stableRelease = -not (
$env:RELEASE_TAG.Contains("-alpha.") -or
$env:RELEASE_TAG.Contains("-beta.")
)
if ($env:WINDOWS_NODE_TAG -notmatch '^v[0-9]+\.[0-9]+\.[0-9]+([-.][0-9A-Za-z]+([.-][0-9A-Za-z]+)*)?$') {
throw "windows_node_tag must be an explicit openclaw-windows-node release tag, not latest: $env:WINDOWS_NODE_TAG"
}
try {
$expectedDigests = $env:EXPECTED_INSTALLER_DIGESTS | ConvertFrom-Json -AsHashtable
} catch {
throw "expected_installer_digests must be a JSON object: $_"
}
# Add future signed installer names, such as MSIX x64/ARM64, here.
$requiredInstallerNames = @(
"OpenClawCompanion-Setup-x64.exe",
"OpenClawCompanion-Setup-arm64.exe"
)
$allowedTargetCompanionAssetNames = @(
$requiredInstallerNames
"OpenClawCompanion-SHA256SUMS.txt"
)
if ($expectedDigests.Count -ne $requiredInstallerNames.Count) {
throw "expected_installer_digests must contain exactly the current installer asset contract."
}
foreach ($name in $requiredInstallerNames) {
$digest = [string]$expectedDigests[$name]
if ($digest -notmatch '^sha256:[A-Fa-f0-9]{64}$') {
throw "expected_installer_digests is missing a valid pinned digest for $name."
}
}
$targetRelease = gh release view $env:RELEASE_TAG --repo $env:GITHUB_REPOSITORY --json tagName,isDraft,isPrerelease,assets,url | ConvertFrom-Json
if ($targetRelease.tagName -ne $env:RELEASE_TAG) {
throw "OpenClaw release tag mismatch: expected $env:RELEASE_TAG, got $($targetRelease.tagName)"
}
$unexpectedTargetCompanionAssets = @(
$targetRelease.assets |
Where-Object {
$_.name.StartsWith("OpenClawCompanion-") -and
$_.name -notin $allowedTargetCompanionAssetNames
} |
ForEach-Object name |
Sort-Object
)
if ($unexpectedTargetCompanionAssets.Count -ne 0) {
throw "Target OpenClaw release contains unexpected OpenClawCompanion assets before upload: $($unexpectedTargetCompanionAssets -join ', ')"
}
$sourceRelease = gh release view $env:WINDOWS_NODE_TAG --repo openclaw/openclaw-windows-node --json tagName,isDraft,isPrerelease,assets,url | ConvertFrom-Json
if ($sourceRelease.tagName -ne $env:WINDOWS_NODE_TAG) {
throw "Windows source release tag mismatch: expected $env:WINDOWS_NODE_TAG, got $($sourceRelease.tagName)"
}
if ($sourceRelease.isDraft) {
throw "Windows source release must be published: $($sourceRelease.url)"
}
if ($stableRelease -and $sourceRelease.isPrerelease) {
throw "Stable OpenClaw releases require a non-prerelease Windows source release: $($sourceRelease.url)"
}
foreach ($name in $requiredInstallerNames) {
$sourceAssets = @($sourceRelease.assets | Where-Object name -eq $name)
if ($sourceAssets.Count -ne 1) {
throw "Windows source release must contain exactly one required asset $name; found $($sourceAssets.Count)."
}
if ([string]$sourceAssets[0].digest -ne [string]$expectedDigests[$name]) {
throw "Windows source release asset digest does not match the pinned digest: $name"
}
}
gh release view $env:RELEASE_TAG --repo $env:GITHUB_REPOSITORY | Out-Null
- name: Download Windows Hub release installers
shell: pwsh
env:
WINDOWS_NODE_TAG: ${{ inputs.windows_node_tag }}
EXPECTED_INSTALLER_DIGESTS: ${{ inputs.expected_installer_digests }}
GH_TOKEN: ${{ github.token }}
run: |
New-Item -ItemType Directory -Force -Path dist | Out-Null
$tagArgs = @()
if ($env:WINDOWS_NODE_TAG -ne "latest") {
$tagArgs += $env:WINDOWS_NODE_TAG
}
gh release download @tagArgs `
--repo openclaw/openclaw-windows-node `
--pattern "OpenClawCompanion-Setup-*.exe" `
--dir dist
$expected = @(
"dist/OpenClawCompanion-Setup-x64.exe",
"dist/OpenClawCompanion-Setup-arm64.exe"
# Add future signed installer patterns, such as MSIX x64/ARM64, here.
# Every matched installer is signature-checked, checksummed, and promoted.
$installerPatterns = @(
"OpenClawCompanion-Setup-x64.exe",
"OpenClawCompanion-Setup-arm64.exe"
)
foreach ($file in $expected) {
if (-not (Test-Path -LiteralPath $file)) {
throw "Missing expected Windows installer: $file"
$downloadArgs = @(
$env:WINDOWS_NODE_TAG,
"--repo", "openclaw/openclaw-windows-node",
"--dir", "dist"
)
foreach ($pattern in $installerPatterns) {
$downloadArgs += @("--pattern", $pattern)
}
gh release download @downloadArgs
if ($LASTEXITCODE -ne 0) {
throw "Failed to download Windows release assets from $env:WINDOWS_NODE_TAG."
}
foreach ($pattern in $installerPatterns) {
$patternMatches = @(Get-ChildItem -LiteralPath dist -File | Where-Object Name -Like $pattern)
if ($patternMatches.Count -ne 1) {
throw "Expected exactly one Windows installer matching '$pattern', found $($patternMatches.Count)."
}
}
$expectedDigests = $env:EXPECTED_INSTALLER_DIGESTS | ConvertFrom-Json -AsHashtable
foreach ($file in Get-ChildItem -LiteralPath dist -File) {
$expectedHash = ([string]$expectedDigests[$file.Name]) -replace '^sha256:', ''
$actualHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $file.FullName).Hash
if ($actualHash -ne $expectedHash) {
throw "Downloaded Windows source asset does not match pinned digest: $($file.Name)"
}
}
- name: Verify Authenticode signatures
shell: pwsh
run: |
Get-ChildItem -LiteralPath dist -Filter "OpenClawCompanion-Setup-*.exe" | ForEach-Object {
$expectedSignerSubject = "CN=OpenClaw Foundation, O=OpenClaw Foundation, L=Mill Valley, S=California, C=US"
Get-ChildItem -LiteralPath dist -File | ForEach-Object {
$signature = Get-AuthenticodeSignature -LiteralPath $_.FullName
if ($signature.Status -ne "Valid") {
throw "$($_.Name) Authenticode signature was $($signature.Status)."
@@ -78,6 +164,9 @@ jobs:
if (-not $signature.SignerCertificate) {
throw "$($_.Name) has no signer certificate."
}
if ($signature.SignerCertificate.Subject -ne $expectedSignerSubject) {
throw "$($_.Name) has unexpected signer subject $($signature.SignerCertificate.Subject)."
}
[pscustomobject]@{
File = $_.Name
Signer = $signature.SignerCertificate.Subject
@@ -88,7 +177,7 @@ jobs:
- name: Write SHA-256 manifest
shell: pwsh
run: |
Get-ChildItem -LiteralPath dist -Filter "OpenClawCompanion-Setup-*.exe" |
Get-ChildItem -LiteralPath dist -File |
Sort-Object Name |
ForEach-Object {
$hash = Get-FileHash -Algorithm SHA256 -LiteralPath $_.FullName
@@ -101,12 +190,81 @@ jobs:
RELEASE_TAG: ${{ inputs.tag }}
GH_TOKEN: ${{ github.token }}
run: |
gh release upload $env:RELEASE_TAG `
dist/OpenClawCompanion-Setup-x64.exe `
dist/OpenClawCompanion-Setup-arm64.exe `
dist/OpenClawCompanion-SHA256SUMS.txt `
--repo $env:GITHUB_REPOSITORY `
--clobber
$releaseAssets = @(Get-ChildItem -LiteralPath dist -File | Sort-Object Name | ForEach-Object FullName)
gh release upload $env:RELEASE_TAG @releaseAssets --repo $env:GITHUB_REPOSITORY --clobber
if ($LASTEXITCODE -ne 0) {
throw "Failed to upload Windows release assets to $env:RELEASE_TAG."
}
- name: Verify promoted release asset contract
shell: pwsh
env:
RELEASE_TAG: ${{ inputs.tag }}
GH_TOKEN: ${{ github.token }}
run: |
New-Item -ItemType Directory -Force -Path verified | Out-Null
$expectedAssets = @(Get-ChildItem -LiteralPath dist -File | Sort-Object Name)
$expectedCompanionAssetNames = @($expectedAssets | ForEach-Object Name | Sort-Object)
$targetRelease = gh release view $env:RELEASE_TAG --repo $env:GITHUB_REPOSITORY --json assets | ConvertFrom-Json
$actualCompanionAssetNames = @(
$targetRelease.assets |
Where-Object { $_.name.StartsWith("OpenClawCompanion-") } |
ForEach-Object name |
Sort-Object
)
$assetContractDiff = @(
Compare-Object `
-ReferenceObject $expectedCompanionAssetNames `
-DifferenceObject $actualCompanionAssetNames
)
if (
$actualCompanionAssetNames.Count -ne $expectedCompanionAssetNames.Count -or
$assetContractDiff.Count -ne 0
) {
throw "Promoted OpenClawCompanion asset names do not exactly match the current contract."
}
foreach ($asset in $expectedAssets) {
gh release download $env:RELEASE_TAG `
--repo $env:GITHUB_REPOSITORY `
--pattern $asset.Name `
--dir verified
if ($LASTEXITCODE -ne 0) {
throw "Failed to download promoted Windows release asset $($asset.Name)."
}
}
$manifestPath = "verified/OpenClawCompanion-SHA256SUMS.txt"
$manifestEntries = @(Get-Content -LiteralPath $manifestPath | ForEach-Object {
if ($_ -notmatch '^([A-Fa-f0-9]{64}) ([^\\/]+)$') {
throw "Invalid Windows SHA-256 manifest entry: $_"
}
[PSCustomObject]@{
Hash = $Matches[1]
Name = $Matches[2]
}
})
$expectedInstallerNames = @(
$expectedAssets |
Where-Object Name -ne "OpenClawCompanion-SHA256SUMS.txt" |
ForEach-Object Name
)
$manifestInstallerNames = @($manifestEntries | ForEach-Object Name | Sort-Object)
$contractDiff = @(
Compare-Object `
-ReferenceObject $expectedInstallerNames `
-DifferenceObject $manifestInstallerNames
)
if ($contractDiff.Count -ne 0) {
throw "Promoted Windows SHA-256 manifest does not match the installer asset contract."
}
foreach ($entry in $manifestEntries) {
$hash = (Get-FileHash -Algorithm SHA256 -LiteralPath "verified/$($entry.Name)").Hash
if ($hash -ne $entry.Hash) {
throw "Promoted Windows release asset checksum mismatch: $($entry.Name)"
}
}
- name: Summary
shell: pwsh
@@ -119,8 +277,9 @@ jobs:
OpenClaw release: $env:RELEASE_TAG
Source release: openclaw/openclaw-windows-node@$env:WINDOWS_NODE_TAG
- https://github.com/openclaw/openclaw/releases/download/$env:RELEASE_TAG/OpenClawCompanion-Setup-x64.exe
- https://github.com/openclaw/openclaw/releases/download/$env:RELEASE_TAG/OpenClawCompanion-Setup-arm64.exe
- https://github.com/openclaw/openclaw/releases/download/$env:RELEASE_TAG/OpenClawCompanion-SHA256SUMS.txt
"@ >> $env:GITHUB_STEP_SUMMARY
Get-ChildItem -LiteralPath dist -File |
Sort-Object Name |
ForEach-Object {
"- https://github.com/openclaw/openclaw/releases/download/$env:RELEASE_TAG/$($_.Name)"
} >> $env:GITHUB_STEP_SUMMARY

View File

@@ -2,6 +2,35 @@
Docs: https://docs.openclaw.ai
## 2026.6.8
### Highlights
- Telegram and WhatsApp channel delivery are richer and less brittle: Telegram can send structured rich text with tables, lists, expandable blockquotes, prompt-preserving CLI backend delivery, retired native draft migration, and safer rich-media boundaries, while WhatsApp now honors configured ACP bindings. (#92679, #84082, #89421, #92513) Thanks @obviyus, @jzakirov, @spacegeologist, and @TurboTheTurtle.
- Agent and Gateway recovery is sharper across account-scoped DM sends, generated media completions, restart shutdown aborts, yielded subagent pauses, yielded cron media, heartbeat dedupe, session identity prompts, and unknown OpenAI agent selector rejection. (#92788, #91246, #91357, #92631, #92146, #91287, #92468, #92510) Thanks @yetval, @TurboTheTurtle, @ooiuuii, @openperf, @IWhatsskill, @ZengWen-DT, and @zhangguiping-xydt.
- Provider/model handling expands and tightens with GLM-5.2, Claude Haiku 4.5 catalog rows, OpenRouter and Google Vertex provider-prefix normalization, managed SecretRef auth, bounded model browse discovery, storeless OpenAI Responses replay gating, and Claude 4.5 Copilot tool-streaming safety. (#92796, #90116, #92627, #91218, #90686, #92247, #90706, #75393) Thanks @arkyu2077, @liuhao1024, @bymle, @rohitjavvadi, @samson910022, @snowzlm, and @Kailigithub.
- `/usage` and reply payload hooks now have a native full footer renderer, default template, fixed-decimal formatting, credential-aware limits, better partial-count handling, and warnings for broken templates instead of silent bad output. (#92657, #89835, #89629) Thanks @Marvinthebored.
- UI and mobile flows are steadier: workspace files can collapse and start collapsed, WebChat backscroll survives streaming, the sidebar session picker remains interactive above the desktop workbench, reset soft args survive UI dispatch, stale dashboard session parent lineage is preserved, and iOS reconnects stale foreground gateways. (#92779, #92622, #92705, #91353, #90658, #92552) Thanks @shakkernerd, @TurboTheTurtle, @NianJiuZst, @zhouhe-xydt, @luoyanglang, and @Solvely-Colin.
- Memory, state, and diagnostics recover cleaner: oversized OpenAI embedding batches split before 431s, QMD memory search stays available in transient mode, SQLite avoids WAL on NFS state volumes, stuck-session recovery scheduling no longer resets warning backoff, and Infinity chunk limits stay genuinely unbounded. (#92650, #92618, #92639, #91247, #92752, #92735) Thanks @mushuiyu886, @TurboTheTurtle, @849261680, @gnanam1990, and @yhterrance.
### Changes
- Providers/models: add GLM-5.2 support and Claude Haiku 4.5 catalog entries while keeping provider-qualified model IDs normalized across OpenRouter and Google Vertex paths. (#92796, #90116, #92627, #91218) Thanks @arkyu2077, @liuhao1024, and @bymle.
- Channel plugins: ship Telegram rich-message delivery and WhatsApp ACP binding support, including rich prompt handoff to CLI backends and transport fixtures for richer drafts. (#92679, #92513) Thanks @obviyus and @TurboTheTurtle.
- Agent commands: support `/btw` in CLI-backed sessions and keep CLI usage-error exits classified as usage failures instead of successful runs. (#92669, #92162) Thanks @joshavant and @Pandah97.
- Usage hooks: add built-in full footer rendering, default footer templates, per-turn usage state, credential-aware limits, and fixed-decimal formatting for usage-bar templates. (#92657, #89835, #89629) Thanks @Marvinthebored.
- Docs and operator guidance: document node config examples, clarify before-install hook scope, correct agent default concurrency comments, refresh ZAI provider docs, and update channel/group docs for current Telegram and WhatsApp behavior. (#92677, #92766, #92695) Thanks @liuhao1024, @sallyom, and @ArielSmoliar.
### Fixes
- Channels and delivery: preserve account-scoped DM channel send policy, rich Telegram final replies, rich Telegram tables and lists, Telegram thread-create CLI remapping, Slack outbound `message_sent` hooks, contributed message-tool schema optionality, same-channel generated media completions, and channel chunking around surrogate pairs and Infinity limits. (#92788, #92679, #89421, #89943, #91137, #91246, #92735) Thanks @yetval, @obviyus, @spacegeologist, @rishitamrakar, @lundog, @TurboTheTurtle, and @yhterrance.
- Discord: give generated auto-thread titles a 60-second timeout and 4,096-token reasoning-model output budget, clamped to the selected model output cap. (#64734) Thanks @hanamizuki.
- Agent, cron, and Gateway runtime: mark active main sessions before restart shutdown aborts, pause yielded subagent runs whose terminal also signals abort, preserve yielded media completions, de-duplicate main-session heartbeat events, expose session identity in runtime prompts, reject unknown OpenAI agent selectors, keep generated media completions and slash-command block replies in WebChat, and require admin privileges for HTTP session/model override surfaces. (#91357, #92631, #92146, #91287, #92468, #92510, #91246, #92651, #92646) Thanks @ooiuuii, @openperf, @IWhatsskill, @ZengWen-DT, @zhangguiping-xydt, and @TurboTheTurtle.
- Providers and model replay: preserve storeless OpenAI Responses replay compatibility, avoid eager tool streaming for Claude 4.5 in Copilot, honor profile auth for SecretRef model entries, bound model browsing, strip provider prefixes where runtimes need bare IDs, and surface nested embedding fetch failures. (#90706, #75393, #90686, #92247, #92627, #91218, #92628) Thanks @snowzlm, @Kailigithub, @rohitjavvadi, @samson910022, @liuhao1024, @bymle, and @mushuiyu886.
- Memory, state, diagnostics, and config: split header-too-large embedding batches, keep QMD memory search enabled in transient mode, avoid SQLite WAL on NFS volumes, preserve recovery scheduling outside stuck-session warning backoff, and keep shell environment fallbacks contained in config write tests. (#92650, #92618, #92639, #91247, #92752) Thanks @mushuiyu886, @TurboTheTurtle, @849261680, and @gnanam1990.
- UI/mobile/TUI: preserve dashboard session parent lineage, WebChat backscroll, reset soft command args, sidebar session picker interactivity, collapsed workspace files, resolved `/model` confirmation refs, and stale foreground iOS Gateway reconnects. (#90658, #92622, #91353, #92705, #92779, #92773, #92552) Thanks @luoyanglang, @TurboTheTurtle, @zhouhe-xydt, @NianJiuZst, @shakkernerd, @NarahariRaghava, and @Solvely-Colin.
- Release and test reliability: extend slow Gateway/full-suite watchdogs, split local full-suite shards when throttled, stabilize plugin auth marker fixtures, avoid brittle provider-ref error text, and keep QA Lab bootstrap selection assertions aligned with flow-only scenarios. (#92652)
## 2026.6.6
### Highlights

View File

@@ -147,6 +147,10 @@ RUN --mount=type=cache,id=openclaw-pnpm-store,target=/root/.local/share/pnpm/sto
OPENCLAW_EXTENSIONS="$OPENCLAW_EXTENSIONS" OPENCLAW_BUNDLED_PLUGIN_DIR="$OPENCLAW_BUNDLED_PLUGIN_DIR" node scripts/prune-docker-plugin-dist.mjs && \
node scripts/postinstall-bundled-plugins.mjs && \
find dist -type f \( -name '*.d.ts' -o -name '*.d.mts' -o -name '*.d.cts' -o -name '*.map' \) -delete && \
rm -rf \
/app/node_modules/openclaw \
/app/node_modules/.bin/openclaw \
/app/node_modules/.pnpm/openclaw@*/node_modules/openclaw && \
node scripts/check-package-dist-imports.mjs /app
# ── Runtime base image ──────────────────────────────────────────

View File

@@ -188,6 +188,7 @@ final class NodeAppModel {
@ObservationIgnored private var backgroundGraceTaskTimer: Task<Void, Never>?
private var backgroundReconnectSuppressed = false
private var backgroundReconnectLeaseUntil: Date?
@ObservationIgnored private var foregroundGatewayResumeCheckInFlight = false
private var lastSignificantLocationWakeAt: Date?
@ObservationIgnored private let watchReplyCoordinator = WatchReplyCoordinator()
private var watchExecApprovalPromptsByID: [String: ExecApprovalPrompt] = [:]
@@ -214,6 +215,7 @@ final class NodeAppModel {
private static let watchExecApprovalBridgeStateKey = "watch.execApproval.bridge.state.v1"
private static let backgroundAliveLastSuccessAtMsKey = "gateway.backgroundAlive.lastSuccessAtMs"
private static let backgroundAliveLastTriggerKey = "gateway.backgroundAlive.lastTrigger"
private static let foregroundResumeHealthTimeoutSeconds = 1
var cameraHUDText: String?
var cameraHUDKind: CameraHUDKind?
@@ -417,9 +419,7 @@ final class NodeAppModel {
self.isBackgrounded = false
self.endBackgroundConnectionGracePeriod(reason: "scene_foreground")
self.clearBackgroundReconnectSuppression(reason: "scene_foreground")
if self.operatorConnected {
self.startGatewayHealthMonitor()
}
var shouldStartGatewayHealthMonitor = self.operatorConnected
if phase == .active {
self.voiceWake.resumeAfterExternalAudioCapture(wasSuspended: self.backgroundVoiceWakeSuspended)
self.backgroundVoiceWakeSuspended = false
@@ -444,6 +444,8 @@ final class NodeAppModel {
// iOS may suspend network sockets in background without a clean close.
// On foreground, force a fresh handshake to avoid "connected but dead" states.
if backgroundedFor >= 3.0 {
shouldStartGatewayHealthMonitor = false
self.foregroundGatewayResumeCheckInFlight = true
Task { [weak self] in
guard let self else { return }
let operatorWasConnected = await MainActor.run { self.operatorConnected }
@@ -452,31 +454,26 @@ final class NodeAppModel {
let healthy = await (try? self.operatorGateway.request(
method: "health",
paramsJSON: nil,
timeoutSeconds: 2)) != nil
timeoutSeconds: Self.foregroundResumeHealthTimeoutSeconds)) != nil
if healthy {
await MainActor.run { self.startGatewayHealthMonitor() }
await MainActor.run {
self.foregroundGatewayResumeCheckInFlight = false
self.startGatewayHealthMonitor()
}
return
}
}
await self.operatorGateway.disconnect()
await self.nodeGateway.disconnect()
await MainActor.run {
guard !self.isAppleReviewDemoModeEnabled else { return }
self.setOperatorConnected(false)
self.gatewayConnected = false
// Foreground recovery must actively restart the saved gateway config.
// Disconnecting stale sockets alone can leave us idle if the old
// reconnect tasks were suppressed or otherwise got stuck in background.
self.gatewayStatusText = "Reconnecting…"
self.talkMode.updateGatewayConnected(false)
if let cfg = self.activeGatewayConnectConfig {
self.applyGatewayConnectConfig(cfg)
}
self.foregroundGatewayResumeCheckInFlight = false
}
await self.restartGatewaySessionsAfterForegroundStaleConnection()
}
}
}
if shouldStartGatewayHealthMonitor {
self.startGatewayHealthMonitor()
}
@unknown default:
self.isBackgrounded = false
self.endBackgroundConnectionGracePeriod(reason: "scene_unknown")
@@ -786,6 +783,12 @@ final class NodeAppModel {
func refreshGatewayOverviewIfConnected() async {
guard await self.isOperatorConnected() else { return }
if self.foregroundGatewayResumeCheckInFlight {
GatewayDiagnostics.log("gateway overview refresh deferred reason=foreground_resume_check")
try? await Task.sleep(
nanoseconds: UInt64(Self.foregroundResumeHealthTimeoutSeconds) * 1_000_000_000)
guard await self.isOperatorConnected(), !self.foregroundGatewayResumeCheckInFlight else { return }
}
await self.refreshBrandingFromGateway()
await self.refreshAgentsFromGateway()
}
@@ -1986,12 +1989,33 @@ extension NodeAppModel {
}
func resetGatewaySessionsForForcedReconnect() async {
self.nodeGatewayTask?.cancel()
let nodeGatewayTask = self.nodeGatewayTask
let operatorGatewayTask = self.operatorGatewayTask
nodeGatewayTask?.cancel()
self.nodeGatewayTask = nil
self.operatorGatewayTask?.cancel()
operatorGatewayTask?.cancel()
self.operatorGatewayTask = nil
await self.operatorGateway.disconnect()
await self.nodeGateway.disconnect()
// Foreground recovery reuses the same config immediately after reset.
// Wait for canceled loops so their shutdown cleanup cannot clobber the new reconnect state.
if let operatorGatewayTask {
await operatorGatewayTask.value
}
if let nodeGatewayTask {
await nodeGatewayTask.value
}
}
private func restartGatewaySessionsAfterForegroundStaleConnection() async {
await self.resetGatewaySessionsForForcedReconnect()
guard !self.isAppleReviewDemoModeEnabled else { return }
self.setOperatorConnected(false)
self.gatewayConnected = false
self.gatewayStatusText = "Reconnecting…"
self.talkMode.updateGatewayConnected(false)
guard let cfg = self.activeGatewayConnectConfig else { return }
self.applyGatewayConnectConfig(cfg, forceReconnect: true)
}
func disconnectGateway() {
@@ -4826,6 +4850,10 @@ extension NodeAppModel {
(self.nodeGatewayTask != nil, self.operatorGatewayTask != nil)
}
func _test_restartGatewaySessionsAfterForegroundStaleConnection() async {
await self.restartGatewaySessionsAfterForegroundStaleConnection()
}
func _test_handleSuccessfulBootstrapGatewayOnboarding() async {
await self.handleSuccessfulBootstrapGatewayOnboarding(
url: URL(string: "wss://gateway.example")!,

View File

@@ -356,6 +356,20 @@ import UIKit
#expect(!appModel._test_hasGatewayLoopTasks().operator)
}
@Test @MainActor func foregroundStaleConnectionRestartReappliesActiveGatewayConfig() async {
let appModel = NodeAppModel()
defer { appModel.disconnectGateway() }
let config = Self.makeGatewayConnectConfig()
appModel.applyGatewayConnectConfig(config)
await appModel._test_restartGatewaySessionsAfterForegroundStaleConnection()
#expect(appModel.gatewayStatusText == "Reconnecting…")
#expect(appModel.activeGatewayConnectConfig?.hasSameConnectionInputs(as: config) == true)
#expect(appModel._test_hasGatewayLoopTasks().node)
#expect(appModel._test_hasGatewayLoopTasks().operator)
}
@Test @MainActor func loadLastConnectionReadsSavedValues() {
let prior = KeychainStore.loadString(service: "ai.openclaw.gateway", account: "lastConnection")
defer {

View File

@@ -1,5 +1,5 @@
{
"originHash" : "035a4fe955164c62c1628de75f6437a14443a947eea2a1b0176ba484d6fde6f8",
"originHash" : "ae9f37f50cff0d32d189e60948f61e2fa1704e997a6ef4ad5e37f6a11c165ea4",
"pins" : [
{
"identity" : "axorcist",
@@ -42,8 +42,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/steipete/Peekaboo.git",
"state" : {
"revision" : "3a56ed2aa769bfefb5a78722dfce3c34088cfba1",
"version" : "3.4.0"
"revision" : "ee0e3185431788dad533ffca77cd75315aa3d26f",
"version" : "3.4.1"
}
},
{
@@ -51,8 +51,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/sparkle-project/Sparkle",
"state" : {
"revision" : "6276ba2b404829d139c45ff98427cf90e2efc59b",
"version" : "2.9.2"
"revision" : "d46d456107feacc80711b21847b82b07bd9fb46e",
"version" : "2.9.3"
}
},
{
@@ -78,8 +78,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-log.git",
"state" : {
"revision" : "2aed77ae5ec9a86d8fe42c12275e4c2653a286ee",
"version" : "1.13.1"
"revision" : "92448c359f00ebe36ae97d3bd9086f13c7692b5a",
"version" : "1.13.2"
}
},
{

View File

@@ -19,7 +19,7 @@ let package = Package(
.package(url: "https://github.com/swiftlang/swift-subprocess.git", from: "0.4.0"),
.package(url: "https://github.com/apple/swift-log.git", from: "1.10.1"),
.package(url: "https://github.com/sparkle-project/Sparkle", from: "2.9.0"),
.package(url: "https://github.com/steipete/Peekaboo.git", exact: "3.4.0"),
.package(url: "https://github.com/steipete/Peekaboo.git", exact: "3.4.1"),
.package(path: "../shared/OpenClawKit"),
.package(path: "../swabble"),
],

View File

@@ -92,7 +92,13 @@ extension VoiceWakeOverlayController {
let contentHeight = ceil(used.height + (textInset.height * 2))
let total = contentHeight + self.verticalPadding * 2
self.model.isOverflowing = total > self.maxHeight
// Defer the overflow state mutation to break the SwiftUI onChange measuredHeight
// isOverflowing re-render onChange synchronous render loop (fixes #43480).
let overflowing = total > self.maxHeight
DispatchQueue.main.async { [weak self] in
guard let self, self.model.isOverflowing != overflowing else { return }
self.model.isOverflowing = overflowing
}
return max(self.minHeight, min(total, self.maxHeight))
}

View File

@@ -4,14 +4,64 @@ import Testing
@Suite(.serialized)
struct ExecApprovalsStoreRefactorTests {
private var realTemporaryDirectory: URL {
let path = FileManager().temporaryDirectory.path
if path.hasPrefix("/var/") {
return URL(fileURLWithPath: "/private\(path)", isDirectory: true)
}
return FileManager().temporaryDirectory.resolvingSymlinksInPath()
}
private func withLockedEnv(
_ values: [String: String?],
_ body: () async throws -> Void) async throws
{
func restoreEnv(_ values: [String: String?]) {
for (key, value) in values {
if let value {
setenv(key, value, 1)
} else {
unsetenv(key)
}
}
}
await TestIsolationLock.shared.acquire()
var previousEnv: [String: String?] = [:]
for (key, value) in values {
previousEnv[key] = getenv(key).map { String(cString: $0) }
if let value {
setenv(key, value, 1)
} else {
unsetenv(key)
}
}
do {
try await body()
restoreEnv(previousEnv)
await TestIsolationLock.shared.release()
} catch {
restoreEnv(previousEnv)
await TestIsolationLock.shared.release()
throw error
}
}
private func withTempStateDir(
_ body: @escaping @Sendable (URL) async throws -> Void) async throws
{
let stateDir = FileManager().temporaryDirectory
let root = self.realTemporaryDirectory
.appendingPathComponent("openclaw-state-\(UUID().uuidString)", isDirectory: true)
defer { try? FileManager().removeItem(at: stateDir) }
let home = root.appendingPathComponent("home", isDirectory: true)
let stateDir = root.appendingPathComponent("state", isDirectory: true)
defer { try? FileManager().removeItem(at: root) }
try Self.seedCurrentApprovalsFile(in: stateDir)
try await TestIsolation.withEnvValues(["OPENCLAW_STATE_DIR": stateDir.path]) {
try await self.withLockedEnv([
"OPENCLAW_HOME": home.path,
"OPENCLAW_STATE_DIR": stateDir.path,
]) {
try await body(stateDir)
}
}
@@ -19,13 +69,13 @@ struct ExecApprovalsStoreRefactorTests {
private func withTempHomeAndStateDir(
_ body: @escaping @Sendable (URL, URL) async throws -> Void) async throws
{
let root = FileManager().temporaryDirectory
let root = self.realTemporaryDirectory
.appendingPathComponent("openclaw-home-state-\(UUID().uuidString)", isDirectory: true)
let home = root.appendingPathComponent("home", isDirectory: true)
let stateDir = root.appendingPathComponent("state", isDirectory: true)
defer { try? FileManager().removeItem(at: root) }
try await TestIsolation.withEnvValues([
try await self.withLockedEnv([
"OPENCLAW_HOME": home.path,
"OPENCLAW_STATE_DIR": stateDir.path,
]) {
@@ -147,4 +197,19 @@ struct ExecApprovalsStoreRefactorTests {
}
return identifier
}
private static func seedCurrentApprovalsFile(in stateDir: URL) throws {
try FileManager().createDirectory(at: stateDir, withIntermediateDirectories: true)
let file = ExecApprovalsFile(
version: 1,
socket: ExecApprovalsSocketConfig(
path: stateDir.appendingPathComponent("exec-approvals.sock").path,
token: "test-token"),
defaults: nil,
agents: [:])
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
try encoder.encode(file)
.write(to: stateDir.appendingPathComponent("exec-approvals.json"))
}
}

View File

@@ -2074,6 +2074,204 @@ public struct SessionsCompactionRestoreResult: Codable, Sendable {
}
}
public struct SessionFileBrowserEntry: Codable, Sendable {
public let path: String
public let name: String
public let kind: AnyCodable
public let sessionkind: SessionFileRelevance?
public let size: Int?
public let updatedatms: Int?
public init(
path: String,
name: String,
kind: AnyCodable,
sessionkind: SessionFileRelevance?,
size: Int?,
updatedatms: Int?)
{
self.path = path
self.name = name
self.kind = kind
self.sessionkind = sessionkind
self.size = size
self.updatedatms = updatedatms
}
private enum CodingKeys: String, CodingKey {
case path
case name
case kind
case sessionkind = "sessionKind"
case size
case updatedatms = "updatedAtMs"
}
}
public struct SessionFileBrowserResult: Codable, Sendable {
public let path: String
public let parentpath: String?
public let search: String?
public let entries: [SessionFileBrowserEntry]
public let truncated: Bool?
public init(
path: String,
parentpath: String?,
search: String?,
entries: [SessionFileBrowserEntry],
truncated: Bool?)
{
self.path = path
self.parentpath = parentpath
self.search = search
self.entries = entries
self.truncated = truncated
}
private enum CodingKeys: String, CodingKey {
case path
case parentpath = "parentPath"
case search
case entries
case truncated
}
}
public struct SessionFileEntry: Codable, Sendable {
public let path: String
public let name: String
public let kind: SessionFileKind
public let missing: Bool
public let size: Int?
public let updatedatms: Int?
public let content: String?
public init(
path: String,
name: String,
kind: SessionFileKind,
missing: Bool,
size: Int?,
updatedatms: Int?,
content: String?)
{
self.path = path
self.name = name
self.kind = kind
self.missing = missing
self.size = size
self.updatedatms = updatedatms
self.content = content
}
private enum CodingKeys: String, CodingKey {
case path
case name
case kind
case missing
case size
case updatedatms = "updatedAtMs"
case content
}
}
public struct SessionsFilesListParams: Codable, Sendable {
public let sessionkey: String
public let agentid: String?
public let path: String?
public let search: String?
public init(
sessionkey: String,
agentid: String? = nil,
path: String?,
search: String?)
{
self.sessionkey = sessionkey
self.agentid = agentid
self.path = path
self.search = search
}
private enum CodingKeys: String, CodingKey {
case sessionkey = "sessionKey"
case agentid = "agentId"
case path
case search
}
}
public struct SessionsFilesListResult: Codable, Sendable {
public let sessionkey: String
public let root: String?
public let files: [SessionFileEntry]
public let browser: SessionFileBrowserResult?
public init(
sessionkey: String,
root: String?,
files: [SessionFileEntry],
browser: SessionFileBrowserResult?)
{
self.sessionkey = sessionkey
self.root = root
self.files = files
self.browser = browser
}
private enum CodingKeys: String, CodingKey {
case sessionkey = "sessionKey"
case root
case files
case browser
}
}
public struct SessionsFilesGetParams: Codable, Sendable {
public let sessionkey: String
public let path: String
public let agentid: String?
public init(
sessionkey: String,
path: String,
agentid: String? = nil)
{
self.sessionkey = sessionkey
self.path = path
self.agentid = agentid
}
private enum CodingKeys: String, CodingKey {
case sessionkey = "sessionKey"
case path
case agentid = "agentId"
}
}
public struct SessionsFilesGetResult: Codable, Sendable {
public let sessionkey: String
public let root: String?
public let file: SessionFileEntry
public init(
sessionkey: String,
root: String?,
file: SessionFileEntry)
{
self.sessionkey = sessionkey
self.root = root
self.file = file
}
private enum CodingKeys: String, CodingKey {
case sessionkey = "sessionKey"
case root
case file
}
}
public struct SessionsCreateParams: Codable, Sendable {
public let key: String?
public let agentid: String?

View File

@@ -1,4 +1,4 @@
37b56008790612b8293930b6a29d74490e98daa90f954fca9d133fcc28645c4c config-baseline.json
75b64c2ea081369ba4306493313a8a4cd48b784145f92fed995e6b77a5df350d config-baseline.core.json
17d64c9799dfa239a49493413f1100bdd9237e9b67aaeae331a4604dbc227023 config-baseline.channel.json
f9d1f50bfa8403891e76cd99dc1357cdece4a71e8ae18a39b190c2a14e6f97b0 config-baseline.plugin.json
0485ba902d2afd89d2c41cde7180d0cec2900b2db6804b9f97d42b7d85cd3af5 config-baseline.json
72bb80be618406f3337eaa2560d2559a35e49bd29576de8dd4a3aec1a6a94d92 config-baseline.core.json
1218f5555541b61bd5ddcac6441f15061b44789e2471d4ffecbe3059777c55c1 config-baseline.channel.json
a14ac4261e98403d1a7e047070e6f151938444e27382b860315bd0c74fda4861 config-baseline.plugin.json

View File

@@ -1,2 +1,2 @@
2c783beea6b3cda3d79060739a923f9f39e7e8b5942123dd6b08a09143a587ca plugin-sdk-api-baseline.json
0b33af2cffb42abb46682fb71c8f214da220793f13d10a34d332e75ff99e8ce9 plugin-sdk-api-baseline.jsonl
40b3c841849fbc29938a3bbb990e28a5db30142941c8ef0c081a94cee4c78331 plugin-sdk-api-baseline.json
40ee8e1bbf112e768d4944776443f90b2441b02e3e950726e4112015cd106108 plugin-sdk-api-baseline.jsonl

View File

@@ -311,7 +311,9 @@ $OPENCLAW_STATE_DIR/tasks/runs.sqlite
The registry loads into memory at gateway start and syncs writes to SQLite for durability across restarts.
The Gateway keeps the SQLite write-ahead log bounded by using SQLite's default
autocheckpoint threshold plus periodic and shutdown `TRUNCATE` checkpoints.
autocheckpoint threshold plus periodic `PASSIVE` checkpoints. Shutdown and
explicit maintenance checkpoints still use `TRUNCATE` so normal closes can
reclaim WAL space without making the background sweeper wait on active readers.
### Automatic maintenance

View File

@@ -161,17 +161,20 @@ Control how agents process messages:
<Step title="Incoming message arrives">
A WhatsApp group or DM message arrives.
</Step>
<Step title="Broadcast check">
System checks if peer ID is in `broadcast`.
<Step title="Route and admission">
OpenClaw applies channel allowlists, group activation rules, and configured ACP binding ownership.
</Step>
<Step title="If in broadcast list">
<Step title="Broadcast check">
If no configured ACP binding owns the route, OpenClaw checks whether the peer ID is in `broadcast`.
</Step>
<Step title="If broadcast applies">
- All listed agents process the message.
- Each agent has its own session key and isolated context.
- Agents process in parallel (default) or sequentially.
</Step>
<Step title="If not in broadcast list">
Normal routing applies (first matching binding).
<Step title="If broadcast does not apply">
OpenClaw dispatches the ordinary route or the configured ACP session route selected during routing.
</Step>
</Steps>
@@ -322,7 +325,7 @@ Broadcast groups work alongside existing routing:
- `GROUP_B`: agent1 AND agent2 respond (broadcast).
<Note>
**Precedence:** `broadcast` takes priority over `bindings`.
**Precedence:** `broadcast` takes priority over ordinary route bindings. Configured ACP bindings (`bindings[].type="acp"`) are exclusive: when one matches, OpenClaw dispatches to the configured ACP session instead of fan-out broadcast.
</Note>
## Troubleshooting
@@ -343,9 +346,9 @@ Broadcast groups work alongside existing routing:
</Accordion>
<Accordion title="Only one agent responding">
**Cause:** Peer ID might be in `bindings` but not `broadcast`.
**Cause:** Peer ID might be in ordinary route bindings but not `broadcast`, or it might match an exclusive configured ACP binding.
**Fix:** Add to broadcast config or remove from bindings.
**Fix:** Add ordinary route-bound peers to broadcast config, or remove/change the configured ACP binding if fan-out broadcast is desired.
</Accordion>
<Accordion title="Performance issues">

View File

@@ -416,7 +416,9 @@ Enable `dynamicAgentCreation` to automatically create **isolated agent instances
This is essential for public bots where you want each user to have their own private AI assistant experience.
<Note>
**Account limitation**: `dynamicAgentCreation` currently works with the **default Feishu account only**. Named/multi-account setups are not yet fully supported — dynamic bindings are created without `accountId`, so messages to named accounts may still route to `agent:main`. Track progress in [Issue #42837](https://github.com/openclaw/openclaw/issues/42837).
Dynamic bindings include the normalized Feishu `accountId`, so default and named accounts route each sender to the correct dynamic agent.
If a named account created an unscoped dynamic agent on an older release, that legacy agent still counts toward `maxAgents`. Confirm that it is not used by the default account before removing it, or temporarily increase `maxAgents`; OpenClaw cannot safely infer which account owns ambiguous legacy state.
</Note>
### Quick setup
@@ -447,7 +449,7 @@ This is essential for public bots where you want each user to have their own pri
When a new user sends their first DM:
1. The channel generates a unique `agentId` = `feishu-{user_open_id}`
1. The channel generates a unique `agentId`: `feishu-{user_open_id}` for the default account, or a bounded account-prefixed identity digest for a named account
2. Creates a new workspace at `workspaceTemplate` path
3. Registers the agent and creates a binding for this user
4. The workspace helper ensures bootstrap files (`AGENTS.md`, `SOUL.md`, `USER.md`, etc.) on first access
@@ -464,22 +466,23 @@ When a new user sends their first DM:
Template variables:
- `{agentId}` - the generated agent ID (e.g., `feishu-ou_xxxxxx`)
- `{agentId}` - the generated agent ID (e.g., `feishu-ou_xxxxxx` or `feishu-support-<identity_digest>`)
- `{userId}` - the sender's Feishu open_id (e.g., `ou_xxxxxx`)
### Session scope
`session.dmScope` controls how direct messages are mapped to agent sessions. This is a **global setting** that affects all channels.
| Value | Behavior | Best for |
| -------------------- | --------------------------------------------------------- | ------------------------------------------------------------------ |
| `"main"` | Each user's DM maps to their agent's main session | Single-user bots where you want `USER.md` / `SOUL.md` to auto-load |
| `"per-channel-peer"` | Each (channel + user) combination gets a separate session | Public multi-user bots needing stronger isolation |
| Value | Behavior | Best for |
| ---------------------------- | ------------------------------------------------------------------- | ------------------------------------------------------------------ |
| `"main"` | Each user's DM maps to their agent's main session | Single-user bots where you want `USER.md` / `SOUL.md` to auto-load |
| `"per-channel-peer"` | Each (channel + user) combination gets a separate session | Public multi-user bots needing stronger isolation |
| `"per-account-channel-peer"` | Each (account + channel + user) combination gets a separate session | Multi-account bots needing account-level session isolation |
**Tradeoff**: Using `"main"` enables automatic bootstrap file loading (`USER.md`, `SOUL.md`, `MEMORY.md`), but means all DMs across all channels share the same session key pattern. For public multi-user bots where isolation matters more than bootstrap auto-loading, consider `"per-channel-peer"` and manage bootstrap files manually.
<Note>
`"per-account-channel-peer"` is not recommended with `dynamicAgentCreation` because dynamic bindings are created without `accountId`. Use it only with manual bindings.
Use `"per-account-channel-peer"` when named Feishu accounts should keep separate sessions for the same sender. Dynamic bindings preserve the account scope.
</Note>
```json5

View File

@@ -586,7 +586,7 @@ Group inbound payloads set:
- `WasMentioned` (mention gating result)
- Telegram forum topics also include `MessageThreadId` and `IsForum`.
The agent system prompt includes a group intro on the first turn of a new group session. It reminds the model to respond like a human, avoid Markdown tables, minimize empty lines and follow normal chat spacing, and avoid typing literal `\n` sequences. Channel-sourced group names and participant labels are rendered as fenced untrusted metadata, not inline system instructions.
The agent system prompt includes a group intro on the first turn of a new group session. It reminds the model to respond like a human, minimize empty lines and follow normal chat spacing, and avoid typing literal `\n` sequences. Non-Telegram groups also discourage Markdown tables; Telegram rich-text guidance comes from the Telegram channel prompt. Channel-sourced group names and participant labels are rendered as fenced untrusted metadata, not inline system instructions.
## iMessage specifics

View File

@@ -311,7 +311,6 @@ curl "https://api.telegram.org/bot<bot_token>/getUpdates"
- direct chats: preview message + `editMessageText`
- groups/topics: preview message + `editMessageText`
- direct-chat tool progress: optional native `sendMessageDraft` status preview when enabled and supported
Requirement:
@@ -320,29 +319,10 @@ curl "https://api.telegram.org/bot<bot_token>/getUpdates"
- `streaming.preview.toolProgress` controls whether tool/progress updates reuse the same edited preview message (default: `true` when preview streaming is active)
- `streaming.preview.commandText` controls command/exec detail inside those tool-progress lines: `raw` (default, preserves released behavior) or `status` (tool label only)
- `streaming.progress.commentary` (default: `false`) opts into assistant commentary/preamble text in the temporary progress draft
- legacy `channels.telegram.streamMode` and boolean `streaming` values are detected; run `openclaw doctor --fix` to migrate them to `channels.telegram.streaming.mode`
- legacy `channels.telegram.streamMode`, boolean `streaming` values, and retired native draft preview keys are detected; run `openclaw doctor --fix` to migrate them to current streaming config
Tool-progress preview updates are the short status lines shown while tools run, for example command execution, file reads, planning updates, patch summaries, or Codex preamble/commentary text in Codex app-server mode. Telegram keeps these enabled by default to match released OpenClaw behavior from `v2026.4.22` and later.
Direct chats can use native Telegram drafts for these tool-progress lines without persisting tool chatter into chat history. Native drafts stop before answer text starts; final answers stay on the normal persistent delivery path. This lane is off by default and should be gated to trusted DM IDs first:
```json
{
"channels": {
"telegram": {
"streaming": {
"mode": "partial",
"preview": {
"toolProgress": true,
"nativeToolProgress": true,
"nativeToolProgressAllowFrom": ["123456789"]
}
}
}
}
}
```
To keep the edited preview for answer text but hide tool-progress lines, set:
```json
@@ -420,14 +400,16 @@ curl "https://api.telegram.org/bot<bot_token>/getUpdates"
</Accordion>
<Accordion title="Formatting and HTML fallback">
Outbound text uses Telegram `parse_mode: "HTML"`.
<Accordion title="Rich message formatting">
Outbound text uses Telegram rich messages.
- Markdown-ish text is rendered to Telegram-safe HTML.
- Supported Telegram HTML tags are preserved; unsupported HTML is escaped.
- If Telegram rejects parsed HTML, OpenClaw retries as plain text.
- Markdown text is sent as rich Markdown without converting it to HTML.
- Explicit HTML payloads are sent as rich HTML.
- Media captions still use Telegram HTML captions because rich messages do not replace captions.
Link previews are enabled by default and can be disabled with `channels.telegram.linkPreview: false`.
Long rich text is split automatically across Telegram's rich text and rich block limits. Tables over Telegram's column limit are sent as code blocks.
Link previews are enabled by default. `channels.telegram.linkPreview: false` skips automatic entity detection for rich text.
</Accordion>

View File

@@ -319,6 +319,40 @@ content and identifiers.
</Tab>
</Tabs>
## Configured ACP bindings
WhatsApp supports persistent ACP bindings with top-level `bindings[]` entries:
```json5
{
bindings: [
{
type: "acp",
agentId: "codex",
match: {
channel: "whatsapp",
accountId: "work",
peer: { kind: "direct", id: "+15555550123" },
},
},
{
type: "acp",
agentId: "codex",
match: {
channel: "whatsapp",
accountId: "work",
peer: { kind: "group", id: "120363424282127706@g.us" },
},
},
],
}
```
- Direct chats match E.164 numbers such as `+15555550123`.
- Groups match WhatsApp group JIDs such as `120363424282127706@g.us`.
- Group allowlists, sender policy, and mention or activation gating run before OpenClaw ensures the configured ACP session exists.
- A matched configured ACP binding owns the route. WhatsApp broadcast groups do not fan out that turn to ordinary WhatsApp sessions.
## Personal-number and self-chat behavior
When the linked self number is also present in `allowFrom`, WhatsApp self-chat safeguards activate:

View File

@@ -200,13 +200,19 @@ from `release/YYYY.M.PATCH` or `main` after the release tag exists and after the
OpenClaw npm preflight has succeeded. It verifies `pnpm plugins:sync:check`,
dispatches `Plugin NPM Release` for all publishable plugin packages, dispatches
`Plugin ClawHub Release` for the same release SHA, and only then dispatches
`OpenClaw NPM Release` with the saved `preflight_run_id`.
`OpenClaw NPM Release` with the saved `preflight_run_id`. Stable publish also
requires an exact `windows_node_tag`; the workflow verifies the Windows source
release and compares its x64/ARM64 installers with the candidate-approved
`windows_node_installer_digests` input before any publish child, then promotes
and verifies those same pinned installer digests plus the exact companion asset
and checksum contract before publishing the GitHub release draft.
```bash
gh workflow run openclaw-release-publish.yml \
--ref release/YYYY.M.PATCH \
-f tag=vYYYY.M.PATCH-beta.N \
-f preflight_run_id=<successful-openclaw-npm-preflight-run-id> \
-f full_release_validation_run_id=<successful-full-release-validation-run-id> \
-f npm_dist_tag=beta
```
@@ -452,7 +458,7 @@ For normal PRs, follow scoped CI/check evidence instead of treating parity as a
The `CodeQL` workflow is intentionally a narrow first-pass security scanner, not the full repository sweep. Daily, manual, and non-draft pull request guard runs scan Actions workflow code plus the highest-risk JavaScript/TypeScript surfaces with high-confidence security queries filtered to high/critical `security-severity`.
The pull request guard stays light: it only starts for changes under `.github/actions`, `.github/codeql`, `.github/workflows`, `packages`, `scripts`, `src`, or process-owning bundled plugin runtime paths, and it runs the same high-confidence security matrix as the scheduled workflow. Android and macOS CodeQL stay out of PR defaults.
The pull request guard stays light: it only starts for changes under `.github/actions`, `.github/codeql`, `.github/workflows`, `packages`, or `src`, and it runs the same high-confidence security matrix as the scheduled workflow. Android and macOS CodeQL stay out of PR defaults.
### Security categories
@@ -462,7 +468,6 @@ The pull request guard stays light: it only starts for changes under `.github/ac
| `/codeql-security-high/channel-runtime-boundary` | Core channel implementation contracts plus the channel plugin runtime, gateway, Plugin SDK, secrets, audit touchpoints |
| `/codeql-security-high/network-ssrf-boundary` | Core SSRF, IP parsing, network guard, web-fetch, and Plugin SDK SSRF policy surfaces |
| `/codeql-security-high/mcp-process-tool-boundary` | MCP servers, process execution helpers, outbound delivery, and agent tool-execution gates |
| `/codeql-security-high/process-exec-boundary` | Local shell, process spawn helpers, subprocess-owning bundled plugin runtimes, and workflow script glue |
| `/codeql-security-high/plugin-trust-boundary` | Plugin install, loader, manifest, registry, package-manager install, source-loading, and Plugin SDK package contract trust surfaces |
### Platform-specific security shards

View File

@@ -174,7 +174,22 @@ Notes:
or `--element`.
- `existing-session` / `user` profiles support page screenshots and `--ref`
screenshots from snapshot output, but not CSS `--element` screenshots.
- `--labels` overlays current snapshot refs on the screenshot.
- `--labels` overlays current snapshot refs on the screenshot. On
Playwright-backed profiles, it works with `--full-page` (full-page label
overlay), `--ref` (element-clip label overlay by ARIA ref), and `--element`
(element-clip label overlay by CSS selector); in element-clip modes, labels
are projected relative to the element. The response also includes an
`annotations` array with each ref's bounding box. Each item has `ref`,
`number`, `role`, optional `name`, and `box: {x, y, width, height}`;
coordinates are in the captured image's space (viewport / fullpage /
element-relative). The field is omitted when empty.
`existing-session` profiles render a chrome-mcp overlay on page screenshots
but do not use the Playwright projection helper and do not include
`annotations`; CSS `--element` screenshots are unsupported there. Without
Playwright or chrome-mcp, labeled screenshots are not available. Prior
releases ignored `--full-page`, `--ref`, and `--element` on labeled
Playwright screenshots and always returned a viewport capture; labeled
screenshots now honor those scopes.
- `snapshot --urls` appends discovered link destinations to AI snapshots so
agents can choose direct navigation targets instead of guessing from link
text alone.

View File

@@ -182,7 +182,10 @@ Interactive onboarding behavior with reference mode:
### Non-interactive Z.AI endpoint choices
<Note>
`--auth-choice zai-api-key` auto-detects the best Z.AI endpoint for your key (prefers the general API with `zai/glm-5.1`). If you specifically want the GLM Coding Plan endpoints, pick `zai-coding-global` or `zai-coding-cn`.
`--auth-choice zai-api-key` auto-detects the best Z.AI endpoint and model for
your key. Coding Plan endpoints prefer `zai/glm-5.2`; general API endpoints use
`zai/glm-5.1`. To force a Coding Plan endpoint, pick `zai-coding-global` or
`zai-coding-cn`.
</Note>
```bash

View File

@@ -159,7 +159,7 @@ is available, then fall back to `latest`.
<Accordion title="--dangerously-force-unsafe-install">
`--dangerously-force-unsafe-install` is deprecated and is now a no-op. OpenClaw no longer runs built-in install-time dangerous-code blocking for plugin installs.
Use the shared operator-owned `security.installPolicy` surface when host-specific install policy is required. Plugin `before_install` hooks and `security.installPolicy` can still block installs.
Use the shared operator-owned `security.installPolicy` surface when host-specific install policy is required. Plugin `before_install` hooks are plugin-runtime lifecycle hooks and are not the primary policy boundary for CLI installs.
If a plugin you published on ClawHub is hidden or blocked by a registry scan, use the publisher steps in [ClawHub publishing](/clawhub/publishing). `--dangerously-force-unsafe-install` does not ask ClawHub to rescan the plugin or make a blocked release public.
@@ -405,7 +405,7 @@ Updates apply to tracked plugin installs in the managed plugin index and tracked
</Accordion>
<Accordion title="--dangerously-force-unsafe-install on update">
`--dangerously-force-unsafe-install` is also accepted on `plugins update` for compatibility, but it is deprecated and no longer changes plugin update behavior. Operator `security.installPolicy` and plugin `before_install` hooks can still block updates.
`--dangerously-force-unsafe-install` is also accepted on `plugins update` for compatibility, but it is deprecated and no longer changes plugin update behavior. Operator `security.installPolicy` can still block updates; plugin `before_install` hooks only apply in processes where plugin hooks are loaded.
</Accordion>
</AccordionGroup>

View File

@@ -479,6 +479,9 @@ names that plugin registers. Active Memory lists those tools in the recall
prompt and passes the same list to the embedded sub-agent. If none of the
configured tools are available, or the memory sub-agent fails, Active Memory
skips recall for that turn and the main reply continues without memory context.
For custom recall tools, non-empty model-visible tool output counts as recall
evidence unless structured result fields explicitly report an empty result or
failure.
`toolsAllow` only accepts concrete memory tool names. Wildcards, `group:*`
entries, and core agent tools such as `read`, `exec`, `message`, and
`web_search` are ignored before the hidden memory sub-agent starts.
@@ -743,7 +746,11 @@ Before v2026.5.2 the plugin silently extended your configured `timeoutMs` by an
extra 30000 ms during cold-start so model warm-up, embedding-index load, and
the first recall could share one larger budget. v2026.5.2 moved that grace
behind an explicit `setupGraceTimeoutMs` config — your configured `timeoutMs`
is now the budget by default, unless you opt in.
is now the recall-work budget by default, unless you opt in. The blocking hook
uses two bounded phases around that budget: up to 1500 ms for session/config
preflight before recall starts, then a separate fixed 1500 ms for abort
settlement and transcript recovery after recall work stops. Neither allowance
extends model or tool execution.
If you upgraded from v2026.4.x and you set `timeoutMs` to a value tuned for the
old implicit-grace world (the recommended starter `timeoutMs: 15000` is one
@@ -765,14 +772,16 @@ outer watchdog budgets back to the pre-v5.2 effective values:
}
```
Per the v2026.5.2 changelog: _"use the configured recall timeout as the
blocking prompt-build hook budget by default and move cold-start setup grace
behind explicit `setupGraceTimeoutMs` config, so the plugin no longer silently
extends 15000 ms configs to 45000 ms on the main lane."_
The v2026.5.2 change removed the old implicit 30000 ms cold-start extension.
Beyond the configured recall-work budget, the hook can use up to 1500 ms for
preflight and another 1500 ms for post-recall completion. Its worst-case
blocking time is therefore `timeoutMs + setupGraceTimeoutMs + 3000` ms.
The embedded recall runner uses the same effective timeout budget, so
`setupGraceTimeoutMs` covers both the outer prompt-build watchdog and the inner
blocking recall run.
blocking recall run. The preflight cap covers session/config checks before that
budget begins. The post-recall allowance lets the outer hook settle abort
cleanup and read any final transcript state.
For resource-tight gateways where cold-start latency is a known trade-off,
lower values (500015000 ms) work too — the trade-off is a higher chance of

View File

@@ -97,7 +97,7 @@ These run inside the agent loop or gateway pipeline:
- **`agent_end`**: inspect the final message list and run metadata after completion.
- **`before_compaction` / `after_compaction`**: observe or annotate compaction cycles.
- **`before_tool_call` / `after_tool_call`**: intercept tool params/results.
- **`before_install`**: inspect install context and optionally block skill or plugin installs after operator install policy runs.
- **`before_install`**: inspect staged skill or plugin install material after operator install policy runs, when plugin hooks are loaded in the current OpenClaw process.
- **`tool_result_persist`**: synchronously transform tool results before they are written to an OpenClaw-owned session transcript.
- **`message_received` / `message_sending` / `message_sent`**: inbound + outbound message hooks.
- **`session_start` / `session_end`**: session lifecycle boundaries.
@@ -109,6 +109,7 @@ Hook decision rules for outbound/tool guards:
- `before_tool_call`: `{ block: false }` is a no-op and does not clear a prior block.
- `before_install`: `{ block: true }` is terminal and stops lower-priority handlers.
- `before_install`: `{ block: false }` is a no-op and does not clear a prior block.
- Use `security.installPolicy`, not `before_install`, for operator-owned install allow/block decisions that must cover CLI install and update paths.
- `message_sending`: `{ cancel: true }` is terminal and stops lower-priority handlers.
- `message_sending`: `{ cancel: false }` is a no-op and does not clear a prior cancel.

View File

@@ -247,12 +247,13 @@ of only a bot-to-bot Slack transcript.
evidence pipeline. It checks out the trusted candidate ref in a separate
worktree, runs `pnpm openclaw qa telegram --credential-source convex
--credential-role ci`, writes a `mantis-evidence.json` manifest from the
Telegram QA summary and observed-message artifact, renders the redacted
transcript HTML through a Crabbox desktop browser, generates a motion-trimmed GIF
with `crabbox media preview`, and posts the inline PR evidence comment when a PR
number is available. This lane is transcript-visual rather than logged-in
Telegram Web proof: the Telegram Bot API gives stable live message evidence, but
Telegram Web login state is not required for normal Mantis automation.
Telegram QA summary, `qa-evidence.json`, and report artifacts, renders the
redacted evidence HTML through a Crabbox desktop browser, generates a
motion-trimmed GIF with `crabbox media preview`, and posts the inline PR
evidence comment when a PR number is available. This lane is QA-evidence visual
rather than logged-in Telegram Web proof: the Telegram Bot API gives stable live
message evidence, but Telegram Web login state is not required for normal Mantis
automation.
`Mantis Telegram Desktop Proof` is the agentic native Telegram Desktop
before/after wrapper. A maintainer can trigger it from a PR comment with
@@ -494,8 +495,8 @@ zero:
- `pnpm openclaw qa discord` already runs a live Discord lane with driver and
SUT bots.
- The live transport runner already writes reports and observed-message
artifacts under `.artifacts/qa-e2e/`.
- The live transport runner already writes reports, QA evidence, and
transport-specific artifacts under `.artifacts/qa-e2e/`.
- Convex credential leases already provide exclusive access to shared live
transport credentials.
- The browser control service already supports screenshots, snapshots,

View File

@@ -264,7 +264,7 @@ Gemini CLI JSON replies are parsed from `response`; usage falls back to `stats`,
- Provider: `zai`
- Auth: `ZAI_API_KEY`
- Example model: `zai/glm-5.1`
- Example model: `zai/glm-5.2`
- CLI: `openclaw onboard --auth-choice zai-api-key`
- Model refs use the canonical `zai/*` provider ID.
- `zai-api-key` auto-detects the matching Z.AI endpoint; `zai-coding-global`, `zai-coding-cn`, `zai-global`, and `zai-cn` force a specific surface

View File

@@ -318,17 +318,17 @@ Matrix has a [dedicated page](/concepts/qa-matrix) because of its scenario count
These lanes register through `extensions/qa-lab/src/live-transports/shared/live-transport-cli.ts` and accept the same flags:
| Flag | Default | Description |
| ------------------------------------- | -------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- |
| `--scenario <id>` | - | Run only this scenario. Repeatable. |
| `--output-dir <path>` | `<repo>/.artifacts/qa-e2e/<transport>-<timestamp>` | Where reports/summary/observed messages and the output log are written. Relative paths resolve against `--repo-root`. |
| `--repo-root <path>` | `process.cwd()` | Repository root when invoking from a neutral cwd. |
| `--sut-account <id>` | `sut` | Temporary account id inside the QA gateway config. |
| `--provider-mode <mode>` | `live-frontier` | `mock-openai` or `live-frontier` (legacy `live-openai` still works). |
| `--model <ref>` / `--alt-model <ref>` | provider default | Primary/alternate model refs. |
| `--fast` | off | Provider fast mode where supported. |
| `--credential-source <env\|convex>` | `env` | See [Convex credential pool](#convex-credential-pool). |
| `--credential-role <maintainer\|ci>` | `ci` in CI, `maintainer` otherwise | Role used when `--credential-source convex`. |
| Flag | Default | Description |
| ------------------------------------- | -------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
| `--scenario <id>` | - | Run only this scenario. Repeatable. |
| `--output-dir <path>` | `<repo>/.artifacts/qa-e2e/<transport>-<timestamp>` | Where reports, summaries, evidence, transport-specific artifacts, and the output log are written. Relative paths resolve against `--repo-root`. |
| `--repo-root <path>` | `process.cwd()` | Repository root when invoking from a neutral cwd. |
| `--sut-account <id>` | `sut` | Temporary account id inside the QA gateway config. |
| `--provider-mode <mode>` | `live-frontier` | `mock-openai` or `live-frontier` (legacy `live-openai` still works). |
| `--model <ref>` / `--alt-model <ref>` | provider default | Primary/alternate model refs. |
| `--fast` | off | Provider fast mode where supported. |
| `--credential-source <env\|convex>` | `env` | See [Convex credential pool](#convex-credential-pool). |
| `--credential-role <maintainer\|ci>` | `ci` in CI, `maintainer` otherwise | Role used when `--credential-source convex`. |
Each lane exits non-zero on any failed scenario. `--allow-failures` writes artifacts without setting a failing exit code.
@@ -346,10 +346,6 @@ Required env when `--credential-source env`:
- `OPENCLAW_QA_TELEGRAM_DRIVER_BOT_TOKEN`
- `OPENCLAW_QA_TELEGRAM_SUT_BOT_TOKEN`
Optional:
- `OPENCLAW_QA_TELEGRAM_CAPTURE_CONTENT=1` keeps message bodies in observed-message artifacts (default redacts).
Scenarios (`extensions/qa-lab/src/live-transports/telegram/telegram-live.runtime.ts`):
- `telegram-canary`
@@ -375,26 +371,26 @@ Output artifacts:
- `telegram-qa-report.md`
- `qa-evidence.json` - evidence entries for the live transport checks, including profile, coverage, provider, channel, artifacts, result, and RTT fields.
- `telegram-qa-observed-messages.json` - bodies redacted unless `OPENCLAW_QA_TELEGRAM_CAPTURE_CONTENT=1`.
Package RTT comparison uses the same Telegram credential contract while keeping
its RTT sample controls on the RTT harness path:
Package Telegram runs use the same Telegram credential contract. Repeated RTT
measurement is part of the normal package Telegram live lane; the RTT
distribution is folded into `qa-evidence.json` under `result.timing` for the
selected RTT check.
```bash
pnpm rtt openclaw@beta \
--credential-source convex \
--credential-role maintainer \
--samples 20 \
--sample-timeout-ms 30000
OPENCLAW_QA_CREDENTIAL_SOURCE=convex \
pnpm test:docker:npm-telegram-live
```
When `--credential-source convex` is set, the RTT Docker wrapper leases a
`kind: "telegram"` credential, exports the leased group/driver/SUT bot env into
the installed-package run, heartbeats the lease, and releases it on shutdown.
`--samples` and `--sample-timeout-ms` still feed
`OPENCLAW_NPM_TELEGRAM_WARM_SAMPLES` and
`OPENCLAW_NPM_TELEGRAM_SAMPLE_TIMEOUT_MS`, so `result.json` remains comparable
across env-backed and Convex-backed RTT runs.
When `OPENCLAW_QA_CREDENTIAL_SOURCE=convex` is set, the package live wrapper
leases a `kind: "telegram"` credential, exports the leased group/driver/SUT bot
env into the installed-package run, heartbeats the lease, and releases it on
shutdown. The package wrapper defaults to 20 RTT checks of
`telegram-mentioned-message-reply`, a 30s RTT timeout, and Convex role
`maintainer` outside CI when Convex is selected. Override
`OPENCLAW_NPM_TELEGRAM_RTT_SAMPLES`, `OPENCLAW_NPM_TELEGRAM_RTT_TIMEOUT_MS`,
or `OPENCLAW_NPM_TELEGRAM_RTT_MAX_FAILURES` to tune RTT measurement without
creating a separate RTT command or Telegram-specific summary format.
### Discord QA

View File

@@ -32,8 +32,13 @@ title: "Usage tracking"
## Custom `/usage full` footer
Set `messages.usageTemplate` to customize the per-response `/usage full`
footer. The value can be an inline template object or a JSON file path:
`/usage full` shows a built-in compact footer with model, reasoning, fast/slow,
context window, turn tokens, cache, and cost when those fields are available. No
template file is required.
`messages.usageTemplate` is only for advanced custom layouts. The value is a
JSON file path (supports `~`) or an inline object, and it replaces the built-in
footer when valid:
```json
{
@@ -43,9 +48,182 @@ footer. The value can be an inline template object or a JSON file path:
}
```
Templates read the `openclaw.usageLine.v1` contract and can use `scales`,
`aliases`, and `output.surfaces` to render channel-specific footers. Missing,
unreadable, invalid, or empty templates fall back to the built-in usage line.
Missing or empty templates fall back to the built-in footer quietly. Unreadable
or invalid configured templates also fall back to the built-in footer and emit an
operator warning.
Start custom templates from the built-in shape, then edit the parts you want to
change:
```jsonc
{
"schema": "openclaw.usageBar.v1",
"scales": {
"braille": "⠐⡀⡄⡆⡇⣇⣧⣷⣿",
"block": "░▏▎▍▌▋▊▉█",
"shade": "░▒▓█",
"moon": "🌑🌘🌗🌖🌕",
"level": "▁▂▃▄▅▆▇█",
"weather": ["🥶", "☁️", "🌥", "⛅️", "🌤", "☀️"],
"plants": ["🪾", "🍂", "🌱", "☘️", "🍀", "🌿"],
"moons6": ["🌑", "🌚", "🌘", "🌗", "🌖", "🌝"],
},
"aliases": {
"models": {
"claude-opus-4-6": "opus46",
"claude-opus-4-8": "opus48",
"claude-sonnet-4-6": "sonnet46",
"claude-haiku-4-5": "haiku45",
"gpt-5.5": "gpt5.5",
},
"reasoning": {
"off": "🌑",
"minimal": "🌚",
"low": "🌘",
"medium": "🌗",
"high": "🌕",
"xhigh": "🌝",
},
},
"output": {
"sep": "",
"default": [
{ "text": "{model.provider}{identity.emoji|🤖} {model.display_name|alias:models}" },
{ "map": "model.is_fallback", "cases": { "true": " 🔄" } },
{ "map": "model.is_override", "cases": { "true": " 📌" } },
{ "when": "model.reasoning", "text": " {model.reasoning|alias:reasoning}" },
{ "map": "state.fast_mode", "cases": { "true": " ⚡", "false": " 🐌" } },
{
"when": "context.max_tokens",
"text": " | 📚 [{context.pct_used|meter:5:braille}]{context.max_tokens|num}",
},
{
"when": "usage.has_split_tokens",
"text": " ↕️ {usage.input_tokens|num|?}/{usage.output_tokens|num|?}",
},
{ "when": "usage.has_total_only_tokens", "text": " ↕️ {usage.total_tokens|num}" },
{ "when": "usage.cache_hit_pct", "text": " 🗄 {usage.cache_hit_pct|pct}" },
{ "when": "cost.turn_usd", "text": " 💰{cost.turn_usd|fixed:4}" },
],
"surfaces": {
"discord": [
{ "text": "-# -\n" },
{ "text": "-# {model.provider}{identity.emoji|🤖} {model.display_name|alias:models}" },
{ "map": "model.is_fallback", "cases": { "true": "🔄" } },
{ "map": "model.is_override", "cases": { "true": "📌" } },
{ "when": "model.reasoning", "text": " {model.reasoning|alias:reasoning}" },
{ "map": "state.fast_mode", "cases": { "true": " ⚡️", "false": " 🐌" } },
{
"when": "context.max_tokens",
"text": " | 📚 [{context.pct_used|meter:5:braille}]{context.max_tokens|num}",
},
{
"when": "usage.has_split_tokens",
"text": " ↕️ {usage.input_tokens|num|?}/{usage.output_tokens|num|?}",
},
{ "when": "usage.has_total_only_tokens", "text": " ↕️ {usage.total_tokens|num}" },
{ "when": "usage.cache_hit_pct", "text": " 🗄 {usage.cache_hit_pct|pct}" },
{ "when": "cost.turn_usd", "text": " 💰{cost.turn_usd|fixed:4}" },
],
},
},
}
```
### Shape
```jsonc
{
"schema": "openclaw.usageBar.v1",
"scales": { "<name>": "low-to-high glyphs" }, // string (1 glyph/char) or array
"aliases": { "<table>": { "<value>": "<label>" } },
"output": {
"sep": "", // joins surviving pieces
"default": [
/* pieces */
], // fallback for any surface
"surfaces": {
"discord": [
/* pieces */
],
"telegram": [
/* pieces */
],
},
},
}
```
Each surface is an ordered list of **pieces**; the engine renders each, drops
empties, and joins survivors with `sep`. A surface with no entry uses
`output.default`.
### Contract Paths
A piece reads values from the per-turn contract by dot-path. Absent values are
empty (so a `when` guard or a `|fallback` keeps the piece clean).
| Path | Meaning |
| ----------------------------------------------------------------------------------- | -------------------------------------- |
| `surface` | channel id (`discord`/`telegram`/etc.) |
| `model.provider` / `model.display_name` | provider id / model id |
| `model.reasoning` | effort (`off` through `xhigh`) |
| `model.is_fallback` / `model.is_override` | bool: fallback used / model pinned |
| `state.fast_mode` | bool: fast vs slow |
| `context.max_tokens` / `context.pct_used` | window budget / 0-100 used |
| `usage.input_tokens` / `usage.output_tokens` / `usage.total_tokens` | turn aggregate |
| `usage.has_split_tokens` / `usage.has_total_only_tokens` / `usage.cache_hit_pct` | token display guards and cache percent |
| `usage.last.input_tokens` / `usage.last.output_tokens` / `usage.last.cache_hit_pct` | final model call only |
| `cost.turn_usd` | estimated turn cost |
| `identity.name` / `identity.emoji` | agent name / chosen emoji |
(Provider rate-limit windows are **not** in this contract.)
### Verbs
Pipe a value through verbs left to right; a non-verb segment is the fallback.
| Verb | Effect | Example |
| --------------- | ------------------------------------- | --------------------------------- |
| `num` | compact count | `272000 -> 272k` |
| `fixed:N` | N decimals (default 2) | `0.0377` |
| `dur` | seconds to duration | `14820 -> 4h07m` |
| `pct` | append `%` | `96 -> 96%` |
| `inv` | `100 - x` | for used to remaining |
| `alias:TABLE` | lookup in `aliases`, echo if unlisted | `medium -> 🌗` |
| `meter:W:SCALE` | W-cell glyph bar over a 0-100 value | `[⣿⣿⠐⠐⠐]` (`meter:1` = one glyph) |
### Piece forms
- `{ "text": "📚 {context.max_tokens|num}" }`: literal + interpolation.
- `{ "when": "<path>", "text": "..." }`: render only if the path is truthy.
- `{ "map": "<path>", "cases": { "true": "⚡", "false": "🐌" } }`: value to glyph.
- `{ "each": "limits.windows", "item": "{label}" }`: iterate an array.
### Example
```jsonc
{
"schema": "openclaw.usageBar.v1",
"scales": { "braille": "⠐⡀⡄⡆⡇⣇⣧⣷⣿" },
"aliases": { "reasoning": { "medium": "🌗", "high": "🌕" } },
"output": {
"surfaces": {
"discord": [
{ "text": "{model.display_name}" },
{ "when": "model.reasoning", "text": " {model.reasoning|alias:reasoning}" },
{ "map": "state.fast_mode", "cases": { "true": " ⚡", "false": " 🐌" } },
{
"when": "context.max_tokens",
"text": " | 📚 [{context.pct_used|meter:5:braille}]{context.max_tokens|num}",
},
],
},
},
}
```
renders e.g. `claude-sonnet-4-6 🌗 🐌 | 📚 [⣿⣿⣿⣿⣧]272k`.
## Providers + credentials

View File

@@ -130,6 +130,8 @@ WhatsApp runs through the gateway's web channel (Baileys Web). It starts automat
}
```
- Top-level `bindings[]` entries with `type: "acp"` configure persistent ACP bindings for WhatsApp DMs and groups. Use an E.164 direct number or WhatsApp group JID in `match.peer.id`. Field semantics are shared in [ACP Agents](/tools/acp-agents#persistent-channel-bindings).
<Accordion title="Multi-account WhatsApp">
```json5

View File

@@ -339,7 +339,7 @@ Configures inbound media understanding (image/audio/video):
- `capabilities`: optional list (`image`, `audio`, `video`). Defaults: `openai`/`anthropic`/`minimax` → image, `google` → image+audio+video, `groq` → audio.
- `prompt`, `maxChars`, `maxBytes`, `timeoutSeconds`, `language`: per-entry overrides.
- `tools.media.image.timeoutSeconds` and matching image model `timeoutSeconds` entries also apply when the agent calls the explicit `image` tool.
- `tools.media.image.timeoutSeconds` and matching image model `timeoutSeconds` entries also apply when the agent calls the explicit `image` tool. For image understanding, this timeout applies to the request itself and is not reduced by earlier preparation work.
- Failures fall back to the next entry.
Provider auth follows standard order: `auth-profiles.json` → env vars → `models.providers.*.apiKey`.

View File

@@ -73,7 +73,7 @@ Live tests are split into two layers so we can isolate failures:
- `pnpm test:live` (or `OPENCLAW_LIVE_TEST=1` if invoking Vitest directly)
- Set `OPENCLAW_LIVE_MODELS=modern`, `small`, or `all` (alias for modern) to actually run this suite; otherwise it skips to keep `pnpm test:live` focused on gateway smoke
- How to select models:
- `OPENCLAW_LIVE_MODELS=modern` to run the modern allowlist (Opus/Sonnet 4.6+, GPT-5.2 + Codex, Gemini 3, DeepSeek V4, GLM 4.7, MiniMax M3, Grok 4.3)
- `OPENCLAW_LIVE_MODELS=modern` to run the modern allowlist (Opus/Sonnet 4.6+, GPT-5.2 + Codex, Gemini 3, DeepSeek V4, GLM 5.1, MiniMax M3, Grok 4.3)
- `OPENCLAW_LIVE_MODELS=small` to run the constrained small-model allowlist (Qwen 8B/9B local-compatible routes, Ollama Gemma, OpenRouter Qwen/GLM, and Z.AI GLM)
- `OPENCLAW_LIVE_MODELS=all` is an alias for the modern allowlist
- or `OPENCLAW_LIVE_MODELS="openai/gpt-5.5,anthropic/claude-opus-4-6,..."` (comma allowlist)
@@ -357,6 +357,9 @@ Narrow, explicit allowlists are fastest and least flaky:
- Tool calling across several providers:
- `OPENCLAW_LIVE_GATEWAY_MODELS="openai/gpt-5.5,anthropic/claude-opus-4-6,google/gemini-3-flash-preview,deepseek/deepseek-v4-flash,zai/glm-5.1,minimax/MiniMax-M3" pnpm test:live src/gateway/gateway-models.profiles.live.test.ts`
- Z.AI Coding Plan GLM-5.2 direct smoke:
- `ZAI_CODING_LIVE_TEST=1 pnpm test:live src/agents/zai.live.test.ts`
- Google focus (Gemini API key + Antigravity):
- Gemini (API key): `OPENCLAW_LIVE_GATEWAY_MODELS="google/gemini-3-flash-preview" pnpm test:live src/gateway/gateway-models.profiles.live.test.ts`
- Antigravity (OAuth): `OPENCLAW_LIVE_GATEWAY_MODELS="google-antigravity/claude-opus-4-6-thinking,google-antigravity/gemini-3-pro-high" pnpm test:live src/gateway/gateway-models.profiles.live.test.ts`
@@ -388,7 +391,7 @@ This is the "common models" run we expect to keep working:
- Google (Gemini API): `google/gemini-3.1-pro-preview` and `google/gemini-3-flash-preview` (avoid older Gemini 2.x models)
- Google (Antigravity): `google-antigravity/claude-opus-4-6-thinking` and `google-antigravity/gemini-3-flash`
- DeepSeek: `deepseek/deepseek-v4-flash` and `deepseek/deepseek-v4-pro`
- Z.AI (GLM): `zai/glm-5.1`
- Z.AI (GLM): `zai/glm-5.1` (general API) or `zai/glm-5.2` (Coding Plan)
- MiniMax: `minimax/MiniMax-M3`
Run gateway smoke with tools + image:
@@ -402,7 +405,7 @@ Pick at least one per provider family:
- Anthropic: `anthropic/claude-opus-4-6` (or `anthropic/claude-sonnet-4-6`)
- Google: `google/gemini-3-flash-preview` (or `google/gemini-3.1-pro-preview`)
- DeepSeek: `deepseek/deepseek-v4-flash`
- Z.AI (GLM): `zai/glm-5.1`
- Z.AI (GLM): `zai/glm-5.1` (general API) or `zai/glm-5.2` (Coding Plan)
- MiniMax: `minimax/MiniMax-M3`
Optional additional coverage (nice to have):

View File

@@ -218,17 +218,27 @@ inside every shard.
`OPENCLAW_NPM_TELEGRAM_PACKAGE_TGZ=/path/to/openclaw-current.tgz` or
`OPENCLAW_CURRENT_PACKAGE_TGZ` to test a resolved local tarball instead of
installing from the registry.
- Emits repeated RTT timing in `qa-evidence.json` by default with
`OPENCLAW_NPM_TELEGRAM_RTT_SAMPLES=20`. Override
`OPENCLAW_NPM_TELEGRAM_RTT_SAMPLES`,
`OPENCLAW_NPM_TELEGRAM_RTT_TIMEOUT_MS`, or
`OPENCLAW_NPM_TELEGRAM_RTT_MAX_FAILURES` to tune the RTT run.
`OPENCLAW_NPM_TELEGRAM_RTT_CHECKS` accepts a comma-separated list of
Telegram QA check IDs to sample; when unset, the default RTT-capable check
is `telegram-mentioned-message-reply`.
- Uses the same Telegram env credentials or Convex credential source as
`pnpm openclaw qa telegram`. For CI/release automation, set
`OPENCLAW_NPM_TELEGRAM_CREDENTIAL_SOURCE=convex` plus
`OPENCLAW_QA_CONVEX_SITE_URL` and the role secret. If
`OPENCLAW_QA_CONVEX_SITE_URL` and a role secret. If
`OPENCLAW_QA_CONVEX_SITE_URL` and a Convex role secret are present in CI,
the Docker wrapper selects Convex automatically.
- The wrapper validates Telegram or Convex credential env on the host before
Docker build/install work. Set `OPENCLAW_NPM_TELEGRAM_SKIP_CREDENTIAL_PREFLIGHT=1`
only when deliberately debugging pre-credential setup.
- `OPENCLAW_NPM_TELEGRAM_CREDENTIAL_ROLE=ci|maintainer` overrides the shared
`OPENCLAW_QA_CREDENTIAL_ROLE` for this lane only.
`OPENCLAW_QA_CREDENTIAL_ROLE` for this lane only. When Convex credentials
are selected and no role is set, the wrapper uses `ci` in CI and
`maintainer` outside CI.
- GitHub Actions exposes this lane as the manual maintainer workflow
`NPM Telegram Beta E2E`. It does not run on merge. The workflow uses the
`qa-live-shared` environment and Convex CI credential leases.
@@ -344,11 +354,11 @@ gh workflow run package-acceptance.yml --ref main \
want artifacts without a failing exit code.
- Requires two distinct bots in the same private group, with the SUT bot exposing a Telegram username.
- For stable bot-to-bot observation, enable Bot-to-Bot Communication Mode in `@BotFather` for both bots and ensure the driver bot can observe group bot traffic.
- Writes a Telegram QA report, summary, and observed-messages artifact under `.artifacts/qa-e2e/...`. Replying scenarios include RTT from driver send request to observed SUT reply.
- Writes a Telegram QA report, summary, and `qa-evidence.json` under `.artifacts/qa-e2e/...`. Replying scenarios include RTT from driver send request to observed SUT reply.
`Mantis Telegram Live` is the PR-evidence wrapper around this lane. It runs the
candidate ref with Convex-leased Telegram credentials, renders the redacted
observed-message transcript in a Crabbox desktop browser, records MP4 evidence,
candidate ref with Convex-leased Telegram credentials, renders the redacted QA
report/evidence bundle in a Crabbox desktop browser, records MP4 evidence,
generates a motion-trimmed GIF, uploads the artifact bundle, and posts inline PR
evidence through the Mantis GitHub App when `pr_number` is set. Maintainers can
start it from the Actions UI through `Mantis Scenario` (`scenario_id:

View File

@@ -214,6 +214,59 @@ permission boundary. Dangerous plugin node commands still require explicit
After a node changes its declared command list, reject the old device pairing
and approve the new request so the gateway stores the updated command snapshot.
## Config (`openclaw.json`)
Node-related settings live under `gateway.nodes` and `tools.exec`:
```json5
{
gateway: {
nodes: {
// Auto-approve first-time node pairing from trusted networks (CIDR list).
// Disabled when unset. Only applies to first-time role:node requests
// with no requested scopes; does not auto-approve upgrades.
pairing: {
autoApproveCidrs: ["192.168.1.0/24"],
},
// Opt into dangerous/privacy-heavy node commands (camera.snap, etc.).
allowCommands: ["camera.snap", "screen.record"],
// Block exact command names even if defaults or allowCommands include them.
denyCommands: ["camera.clip"],
},
},
tools: {
exec: {
// Default exec host: "node" routes all exec calls to a paired node.
host: "node",
// Security mode for node exec: allow only approved/allowlisted commands.
security: "allowlist",
// Pin exec to a specific node (id or name). Omit to allow any node.
node: "build-node",
},
},
}
```
Use exact node command names. `denyCommands` removes a command even when a
platform default or `allowCommands` entry would otherwise allow it. See
[Gateway configuration reference](/gateway/configuration-reference#gateway-field-details)
for gateway node pairing and command-policy field details.
Per-agent exec node override:
```json5
{
agents: {
list: [
{
id: "main",
tools: { exec: { node: "build-node" } },
},
],
},
}
```
## Screenshots (canvas snapshots)
If the node is showing the Canvas (WebView), `canvas.snapshot` returns `{ format, base64 }`.

View File

@@ -197,22 +197,30 @@ only for behavior that really belongs to the backend.
`CliBackendPlugin` can also define:
| Hook | Use |
| ---------------------------------- | ------------------------------------------------------ |
| `normalizeConfig(config, context)` | Rewrite legacy user config after merge |
| `resolveExecutionArgs(ctx)` | Add request-scoped flags such as thinking effort |
| `prepareExecution(ctx)` | Create temporary auth or config bridges before launch |
| `transformSystemPrompt(ctx)` | Apply a final CLI-specific system prompt transform |
| `textTransforms` | Bidirectional prompt/output replacements |
| `defaultAuthProfileId` | Prefer a specific OpenClaw auth profile |
| `authEpochMode` | Decide how auth changes invalidate stored CLI sessions |
| `nativeToolMode` | Declare whether the CLI has always-on native tools |
| `bundleMcp` / `bundleMcpMode` | Opt into OpenClaw's loopback MCP tool bridge |
| `ownsNativeCompaction` | Backend owns its own compaction - OpenClaw defers |
| Hook | Use |
| ---------------------------------- | --------------------------------------------------------------------------- |
| `normalizeConfig(config, context)` | Rewrite legacy user config after merge |
| `resolveExecutionArgs(ctx)` | Add request-scoped flags such as thinking effort or side-question isolation |
| `prepareExecution(ctx)` | Create temporary auth or config bridges before launch |
| `transformSystemPrompt(ctx)` | Apply a final CLI-specific system prompt transform |
| `textTransforms` | Bidirectional prompt/output replacements |
| `defaultAuthProfileId` | Prefer a specific OpenClaw auth profile |
| `authEpochMode` | Decide how auth changes invalidate stored CLI sessions |
| `nativeToolMode` | Declare whether the CLI has always-on native tools |
| `sideQuestionToolMode` | Declare disabled native tools for `/btw` side questions |
| `bundleMcp` / `bundleMcpMode` | Opt into OpenClaw's loopback MCP tool bridge |
| `ownsNativeCompaction` | Backend owns its own compaction - OpenClaw defers |
Keep these hooks provider-owned. Do not add CLI-specific branches to core when a
backend hook can express the behavior.
`ctx.executionMode` is `"agent"` for normal turns and `"side-question"` for
ephemeral `/btw` calls. Use it when the CLI needs different one-shot flags, such
as disabling native tools, session persistence, or resume behavior for BTW. If a
backend normally has `nativeToolMode: "always-on"` but its side-question argv
reliably disables those tools, also set `sideQuestionToolMode: "disabled"`;
otherwise OpenClaw fails closed when BTW requires a no-tools CLI run.
### `ownsNativeCompaction`: opting out of OpenClaw compaction
If your backend runs an agent that compacts its **own** transcript, set

View File

@@ -313,9 +313,13 @@ available timeout in this order:
- For `image_generate` without a configured timeout, the 120 second
image-generation default.
- For the media-understanding `image` tool, `tools.media.image.timeoutSeconds`
converted to milliseconds, or the 60 second media default.
converted to milliseconds, or the 60 second media default. For image
understanding, this applies to the request itself and is not reduced by
earlier preparation work.
- The 90 second dynamic-tool default.
This watchdog is the outer dynamic `item/tool/call` budget. Provider-specific
request timeouts run inside that call and keep their own timeout semantics.
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
so the turn can continue instead of leaving the session in `processing`.

View File

@@ -557,10 +557,14 @@ or shortens that specific tool budget. The `image_generate` tool uses
`agents.defaults.imageGenerationModel.timeoutMs` when the tool call does not
provide its own timeout, or a 120 second image-generation default otherwise.
The media-understanding `image` tool uses
`tools.media.image.timeoutSeconds` or its 60 second media default. Dynamic tool
budgets are capped at 600000 ms. On timeout, OpenClaw aborts the tool signal
`tools.media.image.timeoutSeconds` or its 60 second media default. For image
understanding, that timeout applies to the request itself and is not
reduced by earlier preparation work. 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 so the turn
can continue instead of leaving the session in `processing`.
This watchdog is the outer dynamic `item/tool/call` budget; provider-specific
request timeouts run inside that call and keep their own timeout semantics.
After Codex accepts a turn, and after OpenClaw responds to a turn-scoped
app-server request, the harness expects Codex to make current-turn progress and

View File

@@ -152,7 +152,8 @@ observation-only.
- `gateway_start` / `gateway_stop` - start or stop plugin-owned services with the Gateway
- `deactivate` - deprecated compatibility alias for `gateway_stop`; use `gateway_stop` in new plugins
- `cron_changed` - observe gateway-owned cron lifecycle changes (added, updated, removed, started, finished, scheduled)
- **`before_install`** - inspect skill or plugin install context and optionally block
- **`before_install`** - inspect staged skill or plugin install material from a loaded
plugin runtime
## Debug runtime hooks
@@ -462,11 +463,19 @@ Decision rules:
## Install hooks
`before_install` runs after the operator-owned `security.installPolicy` check
when one is configured. The `builtinScan` field remains in the event payload for
compatibility, but OpenClaw no longer runs built-in install-time dangerous-code
blocking, so it is an empty `ok` result. Return additional findings or
`{ block: true, blockReason }` to stop the install.
Use `security.installPolicy` for operator-owned allow/block decisions. That
policy runs from OpenClaw config, covers CLI install and update paths, and fails
closed when enabled but unavailable.
`before_install` is a plugin-runtime lifecycle hook. It runs after
`security.installPolicy` only in the OpenClaw process where plugin hooks have
already been loaded, such as Gateway-backed install flows. It is useful for
plugin-owned observations, warnings, and compatibility checks, but it is not the
primary enterprise or host security boundary for installs. The `builtinScan`
field remains in the event payload for compatibility, but OpenClaw no longer
runs built-in install-time dangerous-code blocking, so it is an empty `ok`
result. Return additional findings or `{ block: true, blockReason }` to stop the
install in that process.
`block: true` is terminal. `block: false` is treated as no decision.
Handler failures block the install fail-closed.

View File

@@ -378,7 +378,10 @@ AI CLI backend such as `claude-cli` or `my-cli`.
(for example normalizing old flag shapes).
- Use `resolveExecutionArgs` for request-scoped argv rewrites that belong to
the CLI dialect, such as mapping OpenClaw thinking levels to a native effort
flag.
flag. The hook receives `ctx.executionMode`; use `"side-question"` to add
backend-native isolation flags for ephemeral `/btw` calls. If those flags
reliably disable native tools for an otherwise always-on CLI, declare
`sideQuestionToolMode: "disabled"` too.
For an end-to-end authoring guide, see
[CLI backend plugins](/plugins/cli-backend-plugins).
@@ -428,6 +431,10 @@ semantics.
### Hook decision semantics
`before_install` is a plugin-runtime lifecycle hook, not the operator install
policy surface. Use `security.installPolicy` when an allow/block decision must
cover CLI and Gateway-backed install or update paths.
- `before_tool_call`: returning `{ block: true }` is terminal. Once any handler sets it, lower-priority handlers are skipped.
- `before_tool_call`: returning `{ block: false }` is treated as no decision (same as omitting `block`), not as an override.
- `before_install`: returning `{ block: true }` is terminal. Once any handler sets it, lower-priority handlers are skipped.

View File

@@ -515,6 +515,7 @@ API key auth, and dynamic model resolution.
- `openclaw/plugin-sdk/provider-model-shared` - `ProviderReplayFamily`, `buildProviderReplayFamilyHooks(...)`, and the raw replay builders (`buildOpenAICompatibleReplayPolicy`, `buildAnthropicReplayPolicyForModel`, `buildGoogleGeminiReplayPolicy`, `buildHybridAnthropicOrOpenAIReplayPolicy`). Also exports Gemini replay helpers (`sanitizeGoogleGeminiReplayHistory`, `resolveTaggedReasoningOutputMode`) and endpoint/model helpers (`resolveProviderEndpoint`, `normalizeProviderId`, `normalizeGooglePreviewModelId`).
- `openclaw/plugin-sdk/provider-stream` - `ProviderStreamFamily`, `buildProviderStreamFamilyHooks(...)`, `composeProviderStreamWrappers(...)`, plus the shared OpenAI/Codex wrappers (`createOpenAIAttributionHeadersWrapper`, `createOpenAIFastModeWrapper`, `createOpenAIServiceTierWrapper`, `createOpenAIResponsesContextManagementWrapper`, `createCodexNativeWebSearchWrapper`), DeepSeek V4 OpenAI-compatible wrapper (`createDeepSeekV4OpenAICompatibleThinkingWrapper`), Anthropic Messages thinking prefill cleanup (`createAnthropicThinkingPrefillPayloadWrapper`), plain-text tool-call compat (`createPlainTextToolCallCompatWrapper`), and shared proxy/provider wrappers (`createOpenRouterWrapper`, `createToolStreamWrapper`, `createMinimaxFastModeWrapper`).
- `openclaw/plugin-sdk/provider-stream-shared` - lightweight payload and event wrappers for hot provider paths, including `createOpenAICompatibleCompletionsThinkingOffWrapper`, `createPayloadPatchStreamWrapper`, and `createPlainTextToolCallCompatWrapper`.
- `openclaw/plugin-sdk/provider-tools` - `ProviderToolCompatFamily`, `buildProviderToolCompatFamilyHooks("deepseek" | "gemini" | "openai")`, and underlying provider schema helpers.
For Gemini-family providers, keep the reasoning-output mode aligned with

View File

@@ -164,7 +164,7 @@ and pairing-path families.
| `plugin-sdk/provider-tools` | `ProviderToolCompatFamily`, `buildProviderToolCompatFamilyHooks`, and DeepSeek/Gemini/OpenAI schema cleanup + diagnostics |
| `plugin-sdk/provider-usage` | Provider usage snapshot types, shared usage fetch helpers, and provider fetchers such as `fetchClaudeUsage` |
| `plugin-sdk/provider-stream` | `ProviderStreamFamily`, `buildProviderStreamFamilyHooks`, `composeProviderStreamWrappers`, stream wrapper types, plain-text tool-call compat, and shared Anthropic/Bedrock/DeepSeek V4/Google/Kilocode/Moonshot/OpenAI/OpenRouter/Z.A.I/MiniMax/Copilot wrapper helpers |
| `plugin-sdk/provider-stream-shared` | Public shared provider stream wrapper helpers including `composeProviderStreamWrappers`, `createPlainTextToolCallCompatWrapper`, `createPayloadPatchStreamWrapper`, `createToolStreamWrapper`, and Anthropic/DeepSeek/OpenAI-compatible stream utilities |
| `plugin-sdk/provider-stream-shared` | Public shared provider stream wrapper helpers including `composeProviderStreamWrappers`, `createOpenAICompatibleCompletionsThinkingOffWrapper`, `createPlainTextToolCallCompatWrapper`, `createPayloadPatchStreamWrapper`, `createToolStreamWrapper`, and Anthropic/DeepSeek/OpenAI-compatible stream utilities |
| `plugin-sdk/provider-transport-runtime` | Native provider transport helpers such as guarded fetch, transport message transforms, and writable transport event streams |
| `plugin-sdk/provider-onboard` | Onboarding config patch helpers |
| `plugin-sdk/global-singleton` | Process-local singleton/map/cache helpers |
@@ -236,6 +236,7 @@ usage endpoint failed or returned no usable usage data.
| `plugin-sdk/config-contracts` | Focused type-only config surface for plugin config shapes such as `OpenClawConfig` and channel/provider config types |
| `plugin-sdk/plugin-config-runtime` | Runtime plugin-config lookup helpers such as `requireRuntimeConfig`, `resolvePluginConfigObject`, and `resolveLivePluginConfigObject` |
| `plugin-sdk/config-mutation` | Transactional config mutation helpers such as `mutateConfigFile`, `replaceConfigFile`, and `logConfigUpdated` |
| `plugin-sdk/message-tool-delivery-hints` | Shared message-tool delivery metadata hint strings |
| `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 |

View File

@@ -19,7 +19,7 @@ OpenClaw uses the `zai` provider with a Z.AI API key.
## GLM models
GLM is a model family, not a separate provider. In OpenClaw, GLM models use
refs such as `zai/glm-5.1`: provider `zai`, model id `glm-5.1`.
refs such as `zai/glm-5.2`: provider `zai`, model id `glm-5.2`.
## Getting started
@@ -85,12 +85,12 @@ you want to force a specific Coding Plan or general API surface.
models: {
providers: {
zai: {
// Example value. Onboarding writes the matching baseUrl for your endpoint.
baseUrl: "https://api.z.ai/api/paas/v4",
// GLM-5.2 uses the Coding Plan endpoint.
baseUrl: "https://api.z.ai/api/coding/paas/v4",
},
},
},
agents: { defaults: { model: { primary: "zai/glm-5.1" } } },
agents: { defaults: { model: { primary: "zai/glm-5.2" } } },
}
```
@@ -105,28 +105,31 @@ openclaw models list --all --provider zai
The manifest-backed catalog currently includes:
| Model ref | Notes |
| -------------------- | ------------- |
| `zai/glm-5.1` | Default model |
| `zai/glm-5` | |
| `zai/glm-5-turbo` | |
| `zai/glm-5v-turbo` | |
| `zai/glm-4.7` | |
| `zai/glm-4.7-flash` | |
| `zai/glm-4.7-flashx` | |
| `zai/glm-4.6` | |
| `zai/glm-4.6v` | |
| `zai/glm-4.5` | |
| `zai/glm-4.5-air` | |
| `zai/glm-4.5-flash` | |
| `zai/glm-4.5v` | |
| Model ref | Notes |
| -------------------- | ------------------------------- |
| `zai/glm-5.2` | Coding Plan default; 1M context |
| `zai/glm-5.1` | General API default |
| `zai/glm-5` | |
| `zai/glm-5-turbo` | |
| `zai/glm-5v-turbo` | |
| `zai/glm-4.7` | |
| `zai/glm-4.7-flash` | |
| `zai/glm-4.7-flashx` | |
| `zai/glm-4.6` | |
| `zai/glm-4.6v` | |
| `zai/glm-4.5` | |
| `zai/glm-4.5-air` | |
| `zai/glm-4.5-flash` | |
| `zai/glm-4.5v` | |
<Tip>
GLM models are available as `zai/<model>` (example: `zai/glm-5`).
</Tip>
<Note>
The default bundled model ref is `zai/glm-5.1`. GLM versions and availability
Coding Plan setup defaults to `zai/glm-5.2`; general API setup keeps
`zai/glm-5.1`. Endpoint auto-detection falls back to `glm-5.1` or `glm-4.7`
when the selected plan does not expose GLM-5.2. GLM versions and availability
can change; run `openclaw models list --all --provider zai` to see the catalog
known to your installed version.
</Note>
@@ -173,7 +176,7 @@ known to your installed version.
agents: {
defaults: {
models: {
"zai/glm-5.1": {
"zai/glm-5.2": {
params: { preserveThinking: true },
},
},

View File

@@ -99,10 +99,14 @@ the maintainer-only release runbook.
file, lane, workflow job, package profile, provider, or model allowlist that
proves the fix. Rerun the full umbrella only when the changed surface makes
prior evidence stale.
9. For beta, tag `vYYYY.M.PATCH-beta.N`, then run `pnpm release:candidate -- --tag
vYYYY.M.PATCH-beta.N` from the matching `release/YYYY.M.PATCH` branch. The helper runs
the local generated-release checks, dispatches or verifies the full release
validation and npm preflight evidence, runs Parallels and Telegram package
9. For a tagged beta candidate, run
`pnpm release:candidate -- --tag vYYYY.M.PATCH-beta.N` from the matching
`release/YYYY.M.PATCH` branch. For stable, pass the required Windows source
release too:
`pnpm release:candidate -- --tag vYYYY.M.PATCH --windows-node-tag vX.Y.Z`.
The helper runs the local generated-release checks, dispatches or verifies
the full release validation and npm preflight evidence, runs Parallels
fresh/update proof against the exact prepared tarball plus Telegram package
proof, records plugin npm and ClawHub plans, and prints the exact
`OpenClaw Release Publish` command only after the evidence bundle is green.
`OpenClaw Release Publish` dispatches the selected or all-publishable plugin
@@ -142,9 +146,12 @@ vYYYY.M.PATCH-beta.N` from the matching `release/YYYY.M.PATCH` branch. The helpe
direct push, it opens or updates an appcast PR. Stable Windows Hub
readiness requires the signed `OpenClawCompanion-Setup-x64.exe`,
`OpenClawCompanion-Setup-arm64.exe`, and
`OpenClawCompanion-SHA256SUMS.txt` assets on the OpenClaw GitHub release;
promote them with the `Windows Node Release` workflow after the matching
`openclaw/openclaw-windows-node` release has passed its signing workflow.
`OpenClawCompanion-SHA256SUMS.txt` assets on the OpenClaw GitHub release.
Pass the exact signed `openclaw/openclaw-windows-node` release tag as
`windows_node_tag` and its candidate-approved installer digest map as
`windows_node_installer_digests`; `OpenClaw Release Publish` keeps the
release draft, dispatches `Windows Node Release`, and verifies all three
assets before publication.
11. After publish, run the npm post-publish verifier, optional standalone
published-npm Telegram E2E when you need post-publish channel proof,
dist-tag promotion when needed, verify the generated GitHub release page,
@@ -253,21 +260,36 @@ vYYYY.M.PATCH-beta.N` from the matching `release/YYYY.M.PATCH` branch. The helpe
to the GitHub release as `openclaw-<version>-dependency-evidence.zip`.
- Run `OpenClaw Release Publish` for the mutating publish sequence after the
tag exists. Dispatch it from `release/YYYY.M.PATCH` (or `main` when publishing a
main-reachable tag), pass the release tag and successful OpenClaw npm
`preflight_run_id`, and keep the default plugin publish scope
`all-publishable` unless you are deliberately running a focused repair. The
workflow serializes plugin npm publish, plugin ClawHub publish, and OpenClaw
npm publish so the core package is not published before its externalized
plugins.
- Run the manual `Windows Node Release` workflow for stable releases after the
matching `openclaw/openclaw-windows-node` release exists. It downloads the
signed Windows Hub installers from the companion repo, verifies their
Authenticode signatures on a Windows runner, writes a SHA-256 manifest, and
uploads the installers plus manifest onto the canonical OpenClaw GitHub
release. Website download links should target exact OpenClaw release asset
URLs for the current stable release, or `releases/latest/download/...` only
after verifying GitHub's latest redirect points at that same release; do not
link only to the companion repo release page.
main-reachable tag), pass the release tag, successful OpenClaw npm
`preflight_run_id`, and successful `full_release_validation_run_id`, and keep
the default plugin publish scope `all-publishable` unless you are deliberately
running a focused repair. The workflow serializes plugin npm publish, plugin
ClawHub publish, and OpenClaw npm publish so the core package is not published
before its externalized plugins.
- Stable `OpenClaw Release Publish` requires an exact `windows_node_tag` after
the matching non-prerelease `openclaw/openclaw-windows-node` release exists.
It also requires the candidate-approved `windows_node_installer_digests` map.
Before dispatching any publish child, it verifies that source release is
published, non-prerelease, contains the required x64/ARM64 installers, and
still matches that approved map. It then dispatches `Windows Node Release`
while the OpenClaw release is still a draft, carrying the pinned installer
digest map unchanged. The child
workflow downloads the signed Windows Hub installers from that exact tag,
matches them against the pinned digests, verifies their Authenticode
signatures use the expected OpenClaw Foundation signer on a Windows runner,
writes a SHA-256 manifest, and uploads the installers plus manifest onto the
canonical OpenClaw GitHub release, then re-downloads the promoted assets and
verifies the manifest membership and hashes. The parent verifies the current
x64, ARM64, and checksum asset contract before publication. Direct recovery
rejects unexpected `OpenClawCompanion-*` asset names before replacing the
expected contract assets with the pinned source bytes. Manually dispatch
`Windows Node Release` only for recovery, and always pass an exact tag, never
`latest`, plus the explicit `expected_installer_digests` JSON map from the
approved source release. Website download links should target exact OpenClaw
release asset URLs for the current stable release, or
`releases/latest/download/...` only after verifying GitHub's latest redirect
points at that same release; do not link only to the companion repo release
page.
- Release checks now run in a separate manual workflow:
`OpenClaw Release Checks`
- `OpenClaw Release Checks` also runs the QA Lab mock parity lane plus the fast
@@ -697,7 +719,12 @@ orchestrates the trusted-publisher workflows in the order the release needs:
`ref=<release-sha>`.
5. Dispatch `Plugin ClawHub Release` with the same scope and SHA.
6. Dispatch `OpenClaw NPM Release` with the release tag, npm dist-tag, and
saved `preflight_run_id`.
saved `preflight_run_id` after verifying the saved
`full_release_validation_run_id`.
7. For stable releases, create or update the GitHub release as a draft, dispatch
`Windows Node Release` with the explicit `windows_node_tag` and
candidate-approved `windows_node_installer_digests`, and verify the canonical
installer/checksum assets before publishing the draft.
Beta publish example:
@@ -706,6 +733,7 @@ gh workflow run openclaw-release-publish.yml \
--ref release/YYYY.M.PATCH \
-f tag=vYYYY.M.PATCH-beta.N \
-f preflight_run_id=<successful-openclaw-npm-preflight-run-id> \
-f full_release_validation_run_id=<successful-full-release-validation-run-id> \
-f npm_dist_tag=beta
```
@@ -715,7 +743,10 @@ Stable publish to the default beta dist-tag:
gh workflow run openclaw-release-publish.yml \
--ref release/YYYY.M.PATCH \
-f tag=vYYYY.M.PATCH \
-f windows_node_tag=vX.Y.Z \
-f windows_node_installer_digests='{"OpenClawCompanion-Setup-x64.exe":"sha256:<approved-x64-sha256>","OpenClawCompanion-Setup-arm64.exe":"sha256:<approved-arm64-sha256>"}' \
-f preflight_run_id=<successful-openclaw-npm-preflight-run-id> \
-f full_release_validation_run_id=<successful-full-release-validation-run-id> \
-f npm_dist_tag=beta
```
@@ -725,7 +756,10 @@ Stable promotion directly to `latest` is explicit:
gh workflow run openclaw-release-publish.yml \
--ref release/YYYY.M.PATCH \
-f tag=vYYYY.M.PATCH \
-f windows_node_tag=vX.Y.Z \
-f windows_node_installer_digests='{"OpenClawCompanion-Setup-x64.exe":"sha256:<approved-x64-sha256>","OpenClawCompanion-Setup-arm64.exe":"sha256:<approved-arm64-sha256>"}' \
-f preflight_run_id=<successful-openclaw-npm-preflight-run-id> \
-f full_release_validation_run_id=<successful-full-release-validation-run-id> \
-f npm_dist_tag=latest
```
@@ -755,6 +789,13 @@ package cannot ship without every publishable official plugin, including
- `tag`: required release tag; must already exist
- `preflight_run_id`: successful `OpenClaw NPM Release` preflight run id;
required when `publish_openclaw_npm=true`
- `full_release_validation_run_id`: successful `Full Release Validation` run
id; required when `publish_openclaw_npm=true`
- `windows_node_tag`: exact non-prerelease `openclaw/openclaw-windows-node`
release tag; required for stable OpenClaw publish
- `windows_node_installer_digests`: candidate-approved compact JSON map of the
current Windows installer names to their pinned `sha256:` digests; required
for stable OpenClaw publish
- `npm_dist_tag`: npm target tag for the OpenClaw package
- `plugin_publish_scope`: defaults to `all-publishable`; use `selected` only
for focused plugin-only repair work with `publish_openclaw_npm=false`
@@ -800,14 +841,21 @@ When cutting a stable npm release:
Matrix, and Telegram coverage from one manual workflow
4. If you intentionally only need the deterministic normal test graph, run the
manual `CI` workflow on the release ref instead
5. Save the successful `preflight_run_id`
6. Run `OpenClaw Release Publish` with the same `tag`, the same `npm_dist_tag`,
and the saved `preflight_run_id`; it publishes externalized plugins to npm
and ClawHub before promoting the OpenClaw npm package
7. If the release landed on `beta`, use the
5. Select the exact non-prerelease `openclaw/openclaw-windows-node` release tag
whose signed x64 and ARM64 installers should ship. Save it as
`windows_node_tag`, and save their validated digest map as
`windows_node_installer_digests`. The release-candidate helper records both
and includes them in its generated publish command.
6. Save the successful `preflight_run_id` and `full_release_validation_run_id`
7. Run `OpenClaw Release Publish` with the same `tag`, the same `npm_dist_tag`,
the selected `windows_node_tag`, its saved `windows_node_installer_digests`,
the saved `preflight_run_id`, and the saved `full_release_validation_run_id`;
it publishes externalized plugins to npm and ClawHub before promoting the
OpenClaw npm package
8. If the release landed on `beta`, use the
`openclaw/releases/.github/workflows/openclaw-npm-dist-tags.yml`
workflow to promote that stable version from `beta` to `latest`
8. If the release intentionally published directly to `latest` and `beta`
9. If the release intentionally published directly to `latest` and `beta`
should follow the same stable build immediately, use that same release
workflow to point both dist-tags at the stable version, or let its scheduled
self-healing sync move `beta` later

View File

@@ -20,6 +20,7 @@ Scope includes:
- Thinking signature cleanup
- Image payload sanitization
- Blank text-block cleanup before provider replay
- Incomplete reasoning-only length-turn cleanup before provider replay
- User-input provenance tagging (for inter-session routed prompts)
- Empty assistant error-turn repair for Bedrock Converse replay
@@ -91,6 +92,21 @@ Implementation:
---
## Global rule: incomplete reasoning-only turns
Assistant turns that hit the provider output limit with only thinking or
redacted-thinking content are omitted from the in-memory replay copy. Such turns
contain incomplete provider state and may carry a partial thinking signature.
Empty length turns remain unchanged, as do length turns with visible text, tool
calls, or unknown content blocks. Stored transcripts are not rewritten.
Implementation:
- `normalizeAssistantReplayContent` in `src/agents/embedded-agent-runner/replay-history.ts`
---
## Global rule: inter-session input provenance
When an agent sends a prompt into another session via `sessions_send` (including

View File

@@ -336,6 +336,7 @@ top-level `bindings[]` entries.
- **Discord channel/thread:** `match.channel="discord"` + `match.peer.id="<channelOrThreadId>"`
- **Slack channel/DM:** `match.channel="slack"` + `match.peer.id="<channelId|channel:<channelId>|#<channelId>|userId|user:<userId>|slack:<userId>|<@userId>>"`. Prefer stable Slack ids; channel bindings also match replies inside that channel's threads.
- **Telegram forum topic:** `match.channel="telegram"` + `match.peer.id="<chatId>:topic:<topicId>"`
- **WhatsApp DM/group:** `match.channel="whatsapp"` + `match.peer.id="<E.164|group JID>"`. Use E.164 numbers such as `+15555550123` for direct chats and WhatsApp group JIDs such as `120363424282127706@g.us` for groups.
- **iMessage DM/group:** `match.channel="imessage"` + `match.peer.id="<handle|chat_id:*|chat_guid:*|chat_identifier:*>"`. Prefer `chat_id:*` for stable group bindings.
</ParamField>
@@ -453,8 +454,9 @@ Use `agents.list[].runtime` to define ACP defaults once per agent:
### Behavior
- OpenClaw ensures the configured ACP session exists before use.
- Messages in that channel or topic route to the configured ACP session.
- OpenClaw ensures the configured ACP session exists after channel-specific admission and before use.
- Messages in that channel, topic, or chat route to the configured ACP session.
- Configured ACP bindings own their session route. Channel broadcast fan-out does not replace the configured ACP session for a matched binding.
- In bound conversations, `/new` and `/reset` reset the same ACP session key in place.
- Temporary runtime bindings (for example created by thread-focus flows) still apply where present.
- For cross-agent ACP spawns without an explicit `cwd`, OpenClaw inherits the target agent workspace from agent config.

View File

@@ -13,7 +13,12 @@ CLI, and scripting patterns (snapshots, refs, waits, debug flows).
## Control API (optional)
For local integrations only, the Gateway exposes a small loopback HTTP API:
For local integrations only, the Gateway exposes a small loopback HTTP API.
This standalone server is opt-in — set the environment variable
`OPENCLAW_EAGER_BROWSER_CONTROL_SERVER=1` in the gateway service environment
and restart the gateway before the HTTP endpoints become available. Without
this variable the browser control runtime still works through the CLI and
agent tools, but nothing listens on the loopback control port.
- Status/start/stop: `GET /`, `POST /start`, `POST /stop`
- Tabs: `GET /tabs`, `POST /tabs/open`, `POST /tabs/focus`, `DELETE /tabs/:targetId`
@@ -258,7 +263,14 @@ Snapshot flags at a glance:
- `--format aria`: accessibility tree with `axN` refs. When Playwright is available, OpenClaw binds refs with backend DOM ids to the live page so follow-up actions can use them; otherwise treat the output as inspection-only.
- `--efficient` (or `--mode efficient`): compact role snapshot preset. Set `browser.snapshotDefaults.mode: "efficient"` to make this the default (see [Gateway configuration](/gateway/configuration-reference#browser)).
- `--interactive`, `--compact`, `--depth`, `--selector` force a role snapshot with `ref=e12` refs. `--frame "<iframe>"` scopes role snapshots to an iframe.
- `--labels` adds a viewport-only screenshot with overlayed ref labels and prints the saved path.
- With Playwright, `--labels` adds a screenshot with overlayed ref labels
(prints `MEDIA:<path>`) plus an `annotations` array with each ref's bounding
box. On `screenshot`, Playwright-backed labels work with `--full-page`,
`--ref`, and `--element`; on `snapshot`, the accompanying screenshot remains
viewport-only. Existing-session/chrome-mcp profiles render overlay labels on
page screenshots but do not return `annotations` or use the Playwright
full-page/ref/element projection helper. Without Playwright or chrome-mcp,
labeled screenshots are not available.
- `--urls` appends discovered link destinations to AI snapshots.
## Snapshots and refs
@@ -274,7 +286,9 @@ OpenClaw supports two "snapshot" styles:
- Output: a role-based list/tree with `[ref=e12]` (and optional `[nth=1]`).
- Actions: `openclaw browser click e12`, `openclaw browser highlight e12`.
- Internally, the ref is resolved via `getByRole(...)` (plus `nth()` for duplicates).
- Add `--labels` to include a viewport screenshot with overlayed `e12` labels.
- Add `--labels` to include a screenshot with overlayed `e12` labels. On
Playwright-backed profiles this also returns per-ref bounding-box metadata
(`annotations[]`).
- Add `--urls` when link text is ambiguous and the agent needs concrete
navigation targets.

View File

@@ -42,8 +42,14 @@ app-server thread as an ephemeral side thread. That keeps Codex OAuth and native
thread behavior intact while still isolating the side answer from the parent
transcript. Like Codex `/side`, the side thread keeps the current Codex
permissions and native tool surface, with guardrails that tell the model not to
treat inherited parent-thread work as active instructions. Non-Codex runtimes
keep the older direct one-shot path.
treat inherited parent-thread work as active instructions.
For CLI runtime aliases, BTW uses the owning CLI backend in side-question mode
instead of falling back to a direct provider call. OpenClaw seeds sanitized
conversation context into a fresh one-shot CLI invocation, disables OpenClaw MCP
tool bundling and reusable CLI session state for that invocation, and lets the
backend add any CLI-native no-resume or no-tools flags it supports. Direct
non-CLI runtimes keep the direct one-shot path.
## What it does not do

View File

@@ -147,10 +147,12 @@ such as `@beta` stay pinned to the selected package and fail when incompatible.
Configure `security.installPolicy` to run a trusted local policy command before
plugin install or update proceeds. The policy receives metadata plus the staged
source path and can allow or block the install. It runs before plugin
`before_install` hooks. The deprecated `--dangerously-force-unsafe-install`
flag is accepted for compatibility but does not bypass install policy, hooks, or
OpenClaw's built-in plugin dependency denylist.
source path and can allow or block the install. It covers CLI and Gateway-backed
plugin install/update paths. Plugin `before_install` hooks run later only in
OpenClaw processes where plugin hooks are loaded, so use `security.installPolicy`
for operator-owned install decisions. The deprecated
`--dangerously-force-unsafe-install` flag is accepted for compatibility but does
not bypass install policy or OpenClaw's built-in plugin dependency denylist.
See [Skills config](/tools/skills-config#operator-install-policy-securityinstallpolicy)
for the shared `security.installPolicy` exec schema used by both skills and

View File

@@ -230,7 +230,8 @@ plugins.
| `/help` | Show the short help summary |
| `/commands` | Show the generated command catalog |
| `/tools [compact\|verbose]` | Show what the current agent can use right now |
| `/status` | Show execution/runtime status, Gateway and system uptime, plus provider usage/quota |
| `/status` | Show execution/runtime status, Gateway and system uptime, plugin health, plus provider usage/quota |
| `/status plugins` | Show detailed plugin health: load errors, quarantines, channel failures, dependency issues, compatibility notices |
| `/goal [status\|start\|pause\|resume\|complete\|block\|clear] ...` | Manage the current session's durable [goal](/tools/goal) |
| `/diagnostics [note]` | Owner-only support-report flow. Asks for exec approval every time |
| `/crestodian <request>` | Run the Crestodian setup and repair helper from an owner DM |

View File

@@ -16,9 +16,9 @@ search or dynamic-tools surface. Codex-native code mode, tool search, deferred
dynamic tools, and nested tool calls are stable Codex harness surfaces and do
not depend on `tools.toolSearch`.
When enabled for OpenClaw runs, the model receives one `tool_search_code` tool by default.
That tool runs a short JavaScript body in an isolated Node subprocess with an
`openclaw.tools` bridge:
When enabled for OpenClaw runs, the model receives one `tool_search_code` tool
by default. That tool runs a short JavaScript body in an isolated Node
subprocess with an `openclaw.tools` bridge:
```js
const hits = await openclaw.tools.search("create a GitHub issue");
@@ -49,8 +49,8 @@ run:
3. List eligible MCP tools through the session MCP runtime.
4. Add eligible client tools supplied for the current run.
5. Index compact descriptors for search.
6. Expose either the OpenClaw code bridge or the structured fallback tools to the
model.
6. Expose the OpenClaw code bridge, the structured fallback tools, or the
compact directory surface to the model.
At execution time every real tool call returns to OpenClaw. The isolated Node
runtime does not hold plugin implementations, MCP client objects, or secrets.
@@ -59,18 +59,26 @@ normal policy, approval, hook, logging, and result handling still apply.
## Modes
`tools.toolSearch` has two model-facing modes:
`tools.toolSearch` has three model-facing modes:
- `code`: exposes `tool_search_code`, the default compact JavaScript bridge.
- `tools`: exposes `tool_search`, `tool_describe`, and `tool_call` as plain
structured tools for providers that should not receive code.
- `directory`: exposes `tool_search`, `tool_describe`, and `tool_call` plus a
bounded prompt directory of available tool names and descriptions for
providers that should see tool names without every full schema. OpenClaw can
also expose a small bounded set of likely or required tool schemas directly
for the current turn.
Both modes use the same catalog and execution path. The only difference is the
shape the model sees. If the current runtime cannot launch the isolated Node
code-mode child process, the default `code` mode falls back to `tools` before
catalog compaction.
All modes use the same policy-filtered catalog and normal OpenClaw execution
path. If the current runtime cannot launch the isolated Node code-mode child
process, the default `code` mode falls back to `tools` before catalog
compaction. In `directory` mode, client-provided tools stay directly visible
for the current run while OpenClaw tools, plugin tools, and MCP tools can be
compacted behind the directory catalog. A direct call to an exact hidden
directory name is hydrated from that same authorized catalog before execution.
Both modes are experimental. Prefer direct tool exposure for small OpenClaw tool
All modes are experimental. Prefer direct tool exposure for small OpenClaw tool
catalogs, and prefer the Codex-native stable surfaces for Codex harness runs.
There is no separate source-selection config. When Tool Search is enabled, the
@@ -90,7 +98,10 @@ Tool Search changes the shape:
contract
- Tool Search tools mode: the model sees three compact structured fallback
tools
- during the turn: the model loads only the tool schemas it actually needs
- Tool Search directory mode: the model sees a bounded directory plus
search/describe/call controls and a small bounded set of likely or required
schemas
- during the turn: the model can load remaining schemas as needed
Direct tool exposure is still the right default for small catalogs. Tool Search
is best when one run can see many tools, especially from MCP servers or
@@ -132,6 +143,20 @@ The structured fallback mode exposes the same operations as tools:
- `tool_describe`
- `tool_call`
Directory mode exposes:
- `tool_search`
- `tool_describe`
- `tool_call`
It also keeps client-provided tools directly visible and may expose a small
bounded set of likely or required catalog tool schemas directly for the current
turn. If the bounded directory omits entries, use `tool_search` to find them. If
the model requests an exact hidden directory tool name directly, OpenClaw
hydrates it from the authorized catalog before normal execution.
Directory-mode client tool names must not collide with OpenClaw, plugin, or MCP
tool names because exact deferred dispatch uses those names.
## Runtime boundary
The code bridge runs in a short-lived Node subprocess. The subprocess starts
@@ -186,6 +211,18 @@ Use the structured fallback tools instead for OpenClaw runs:
}
```
Use the compact directory surface instead for OpenClaw runs:
```json5
{
tools: {
toolSearch: {
mode: "directory",
},
},
}
```
Tune code-mode timeout and search result limits:
```json5

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -123,11 +123,12 @@
"help": "Optional explicit denylist of chat/user IDs. Sessions whose resolved conversation id matches the list are skipped even when the chat type is allowed. Applied after allowedChatIds."
},
"timeoutMs": {
"label": "Timeout (ms)"
"label": "Timeout (ms)",
"help": "Recall work budget on the main lane. Before recall, the hook allows up to 1500 ms for session/config preflight. After recall starts, it reserves another fixed 1500 ms only for abort settlement and transcript recovery."
},
"setupGraceTimeoutMs": {
"label": "Setup Grace Timeout (ms)",
"help": "Advanced: extra blocking budget for cold embedded-run setup before the recall timeout is considered exhausted. Defaults to 0 so timeoutMs remains the main-lane hook budget unless you opt in."
"help": "Advanced: extra recall-work budget for cold embedded-run setup. Defaults to 0. The separate 1500 ms preflight cap and 1500 ms post-recall completion allowance still apply."
},
"queryMode": {
"label": "Query Mode",

View File

@@ -34,6 +34,7 @@ export function buildAnthropicCliBackend(): CliBackendPlugin {
bundleMcp: true,
bundleMcpMode: "claude-config-file",
nativeToolMode: "always-on",
sideQuestionToolMode: "disabled",
ownsNativeCompaction: true,
config: {
command: "claude",

View File

@@ -150,6 +150,61 @@ describe("resolveClaudeCliExecutionArgs", () => {
}),
).toEqual(["-p", "--effort", "max"]);
});
it("forces isolated no-tool one-shot args for side-question execution", () => {
expect(
resolveClaudeCliExecutionArgs({
workspaceDir: "/tmp",
provider: "claude-cli",
modelId: "claude-opus-4-7",
thinkingLevel: "max",
useResume: true,
executionMode: "side-question",
baseArgs: [
"-p",
"--output-format",
"stream-json",
"--allowedTools=mcp__openclaw__*",
"--allowedTools",
"Read",
"Grep",
"--permission-mode",
"bypassPermissions",
"--session-id=abc",
"--resume",
"old-session",
"--resume-session-at",
"old-message",
"--resume-session-at=old-message-equals",
"--mcp-config",
"/tmp/side-question-mcp.json",
"--bare",
"--safe-mode",
"--strict-mcp-config",
"--no-session-persistence",
"--max-turns",
"4",
"--effort",
"high",
],
}),
).toEqual([
"-p",
"--output-format",
"stream-json",
"--safe-mode",
"--tools",
"",
"--disallowedTools",
"mcp__*",
"--strict-mcp-config",
"--no-session-persistence",
"--max-turns",
"1",
"--permission-mode",
"default",
]);
});
});
describe("normalizeClaudeBackendConfig", () => {

View File

@@ -67,8 +67,26 @@ const CLAUDE_LEGACY_SKIP_PERMISSIONS_ARG = "--dangerously-skip-permissions";
const CLAUDE_PERMISSION_MODE_ARG = "--permission-mode";
const CLAUDE_SETTING_SOURCES_ARG = "--setting-sources";
const CLAUDE_EFFORT_ARG = "--effort";
const CLAUDE_BARE_ARG = "--bare";
const CLAUDE_SAFE_MODE_ARG = "--safe-mode";
const CLAUDE_TOOLS_ARG = "--tools";
const CLAUDE_DISALLOWED_TOOLS_ARG = "--disallowedTools";
const CLAUDE_MCP_CONFIG_ARG = "--mcp-config";
const CLAUDE_STRICT_MCP_CONFIG_ARG = "--strict-mcp-config";
const CLAUDE_NO_SESSION_PERSISTENCE_ARG = "--no-session-persistence";
const CLAUDE_MAX_TURNS_ARG = "--max-turns";
const CLAUDE_SESSION_ID_ARG = "--session-id";
const CLAUDE_RESUME_ARG = "--resume";
const CLAUDE_RESUME_SESSION_AT_ARG = "--resume-session-at";
const CLAUDE_RESUME_SHORT_ARG = "-r";
const CLAUDE_CONTINUE_ARG = "--continue";
const CLAUDE_CONTINUE_SHORT_ARG = "-c";
const CLAUDE_FORK_SESSION_ARG = "--fork-session";
const CLAUDE_SAFE_SETTING_SOURCES = "user";
const CLAUDE_BYPASS_PERMISSION_MODE = "bypassPermissions";
const CLAUDE_DEFAULT_PERMISSION_MODE = "default";
const CLAUDE_NO_TOOLS_VALUE = "";
const CLAUDE_DENY_MCP_TOOLS_VALUE = "mcp__*";
type ClaudeCliEffort = "low" | "medium" | "high" | "xhigh" | "max";
@@ -232,10 +250,89 @@ function stripClaudeEffortArgs(args: readonly string[]): string[] {
return normalized;
}
const CLAUDE_SIDE_QUESTION_VARIADIC_VALUE_ARGS = new Set([
"--allowedTools",
"--allowed-tools",
CLAUDE_DISALLOWED_TOOLS_ARG,
"--disallowed-tools",
CLAUDE_TOOLS_ARG,
CLAUDE_MCP_CONFIG_ARG,
]);
const CLAUDE_SIDE_QUESTION_VALUE_ARGS = new Set([
CLAUDE_PERMISSION_MODE_ARG,
CLAUDE_SESSION_ID_ARG,
CLAUDE_RESUME_ARG,
CLAUDE_RESUME_SESSION_AT_ARG,
CLAUDE_RESUME_SHORT_ARG,
CLAUDE_MAX_TURNS_ARG,
]);
const CLAUDE_SIDE_QUESTION_BARE_ARGS = new Set([
CLAUDE_CONTINUE_ARG,
CLAUDE_CONTINUE_SHORT_ARG,
CLAUDE_FORK_SESSION_ARG,
CLAUDE_BARE_ARG,
CLAUDE_SAFE_MODE_ARG,
CLAUDE_STRICT_MCP_CONFIG_ARG,
CLAUDE_NO_SESSION_PERSISTENCE_ARG,
]);
function stripClaudeSideQuestionConflictingArgs(args: readonly string[]): string[] {
const normalized: string[] = [];
for (let i = 0; i < args.length; i += 1) {
const arg = args[i] ?? "";
const equalsIndex = arg.indexOf("=");
const argName = equalsIndex > 0 ? arg.slice(0, equalsIndex) : arg;
if (CLAUDE_SIDE_QUESTION_BARE_ARGS.has(argName)) {
continue;
}
if (CLAUDE_SIDE_QUESTION_VARIADIC_VALUE_ARGS.has(argName)) {
if (equalsIndex < 0) {
while (typeof args[i + 1] === "string" && !args[i + 1]?.startsWith("-")) {
i += 1;
}
}
continue;
}
if (CLAUDE_SIDE_QUESTION_VALUE_ARGS.has(argName)) {
if (equalsIndex < 0) {
const maybeValue = args[i + 1];
if (typeof maybeValue === "string" && !maybeValue.startsWith("-")) {
i += 1;
}
}
continue;
}
normalized.push(arg);
}
return normalized;
}
function resolveClaudeCliSideQuestionExecutionArgs(baseArgs: readonly string[]): string[] {
return [
...stripClaudeSideQuestionConflictingArgs(stripClaudeEffortArgs(baseArgs)),
CLAUDE_SAFE_MODE_ARG,
CLAUDE_TOOLS_ARG,
CLAUDE_NO_TOOLS_VALUE,
CLAUDE_DISALLOWED_TOOLS_ARG,
CLAUDE_DENY_MCP_TOOLS_VALUE,
CLAUDE_STRICT_MCP_CONFIG_ARG,
CLAUDE_NO_SESSION_PERSISTENCE_ARG,
CLAUDE_MAX_TURNS_ARG,
"1",
CLAUDE_PERMISSION_MODE_ARG,
CLAUDE_DEFAULT_PERMISSION_MODE,
];
}
/** Resolve final Claude CLI execution args for one backend invocation. */
export function resolveClaudeCliExecutionArgs(
context: CliBackendResolveExecutionArgsContext,
): string[] {
if (context.executionMode === "side-question") {
return resolveClaudeCliSideQuestionExecutionArgs(context.baseArgs);
}
const effort = mapClaudeCliThinkingLevelToEffort(context.thinkingLevel);
if (!effort) {
return [...context.baseArgs];

View File

@@ -25,7 +25,7 @@ Use this skill when you need the `browser` tool for anything beyond a single pag
- Use the same `targetId` for follow-up actions so refs stay on the same tab.
- For durable Playwright refs, request `refs="aria"` when supported. If you receive `axN` refs from `snapshotFormat="aria"`, use them only after that same snapshot call; stale or unbound `axN` refs fail fast and need a fresh snapshot.
- Use `urls=true` when link text is ambiguous or a direct navigation target would avoid brittle clicks.
- Use `labels=true` on snapshot or screenshot when visual position matters.
- Use `labels=true` on snapshot or screenshot when visual position matters. On Playwright-backed profiles, the response includes an `annotations` array (`{ref, number, role, name?, box}`) with each ref's bounding box in the captured image's coordinate space, so you can reason about position without re-snapshotting; screenshot labels can also combine with `fullPage=true` (CLI: `--full-page`) to label the whole document, or `ref` / `element` to clip to one element. `profile="user"` and other existing-session (chrome-mcp) profiles render an overlay into page screenshots but do not attach `annotations` or use the Playwright full-page/ref/element projection helper, so read positions from the labeled image itself on those profiles. The raw-CDP fallback (no Playwright) does not support labeled screenshots at all and returns a 501, so only request `labels` when Playwright is available.
4. Act narrowly:
- Prefer `action="act"` with a ref from the latest snapshot.
- After navigation, modal changes, or form submission, snapshot again before the next action.

View File

@@ -486,6 +486,7 @@ export async function executeSnapshotAction(params: {
labels: snapshot.labels,
labelsCount: snapshot.labelsCount,
labelsSkipped: snapshot.labelsSkipped,
annotations: snapshot.annotations,
imagePath: snapshot.imagePath,
imageType: snapshot.imageType,
refsFallback,

View File

@@ -1,6 +1,8 @@
/**
* Shared result types for browser client action helpers.
*/
import type { AnnotationItem } from "./screenshot-annotate.js";
/** Generic success result for action endpoints. */
export type BrowserActionOk = { ok: true };
@@ -20,4 +22,10 @@ export type BrowserActionPathResult = {
labels?: boolean;
labelsCount?: number;
labelsSkipped?: number;
/**
* Per-ref bounding boxes when labels=true. Coordinates are in the
* captured image's space (viewport / fullpage / element-relative).
* Omitted when empty.
*/
annotations?: AnnotationItem[];
};

View File

@@ -18,6 +18,7 @@ import type {
} from "./client.types.js";
import { DEFAULT_BROWSER_SNAPSHOT_TIMEOUT_MS } from "./constants.js";
import type { BrowserDoctorReport } from "./doctor.js";
import type { AnnotationItem } from "./screenshot-annotate.js";
export type { BrowserStatus, BrowserTab, BrowserTransport } from "./client.types.js";
export type { BrowserDoctorCheck, BrowserDoctorReport } from "./doctor.js";
@@ -124,6 +125,11 @@ export type SnapshotResult =
labels?: boolean;
labelsCount?: number;
labelsSkipped?: number;
/**
* Per-ref bounding boxes when labels=true. Coordinates are in the
* captured image's space. Omitted when empty.
*/
annotations?: AnnotationItem[];
imagePath?: string;
imageType?: "png" | "jpeg";
blockedByDialog?: boolean;

View File

@@ -0,0 +1,205 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
installPwToolsCoreTestHooks,
setPwToolsCoreCurrentPage,
setPwToolsCoreCurrentRefLocator,
} from "./pw-tools-core.test-harness.js";
installPwToolsCoreTestHooks();
const mod = await import("./pw-tools-core.js");
type EvaluateArg = unknown;
function evaluateMockReturning(view: { x: number; y: number; width?: number; height?: number }) {
// Caller reads { x, y, width, height } in one evaluate; default to a normal
// desktop viewport so refs near the top stay in-viewport unless a test puts
// them out of range explicitly.
const result = { width: 1280, height: 720, ...view };
return vi.fn(async (arg: EvaluateArg) => {
if (typeof arg === "function") {
return result;
}
return true;
});
}
describe("screenshotWithLabelsViaPlaywright (viewport)", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("calls page.screenshot without fullPage and returns annotations", async () => {
const evaluate = evaluateMockReturning({ x: 0, y: 100 });
const screenshot = vi.fn(async () => Buffer.from("PNG"));
setPwToolsCoreCurrentPage({ evaluate, screenshot, url: () => "https://example.com" });
setPwToolsCoreCurrentRefLocator({
boundingBox: async () => ({ x: 10, y: 200, width: 50, height: 20 }),
});
const result = await mod.screenshotWithLabelsViaPlaywright({
cdpUrl: "http://127.0.0.1:18792",
targetId: "T1",
refs: { e1: { role: "button", name: "Submit" } },
type: "png",
});
expect(screenshot).toHaveBeenCalledWith(expect.objectContaining({ type: "png" }));
expect(screenshot).not.toHaveBeenCalledWith(expect.objectContaining({ fullPage: true }));
expect(result.annotations).toHaveLength(1);
expect(result.annotations[0]).toMatchObject({
ref: "e1",
number: 1,
role: "button",
name: "Submit",
});
// viewport-mode box = doc(box.x + scroll.x, box.y + scroll.y) - scroll = bbox
expect(result.annotations[0]?.box).toEqual({ x: 10, y: 200, width: 50, height: 20 });
expect(result.skipped).toBe(0);
});
it("runs the clear script even when screenshot throws", async () => {
const evaluate = evaluateMockReturning({ x: 0, y: 0 });
const screenshot = vi.fn(async () => {
throw new Error("boom");
});
setPwToolsCoreCurrentPage({ evaluate, screenshot });
setPwToolsCoreCurrentRefLocator({
boundingBox: async () => ({ x: 0, y: 0, width: 1, height: 1 }),
});
await expect(
mod.screenshotWithLabelsViaPlaywright({
cdpUrl: "http://127.0.0.1:18792",
targetId: "T1",
refs: { e1: { role: "button" } },
}),
).rejects.toThrow(/boom/);
// The clear script must have run (string evaluate calls include the overlay attr)
const clearCalls = evaluate.mock.calls.filter(
([arg]) => typeof arg === "string" && arg.includes("data-openclaw-labels"),
);
// inject + clear = at least 2 string evaluations
expect(clearCalls.length).toBeGreaterThanOrEqual(2);
});
it("counts off-viewport refs as skipped but still surfaces them in annotations", async () => {
const evaluate = evaluateMockReturning({ x: 0, y: 0, width: 1280, height: 720 });
const screenshot = vi.fn(async () => Buffer.from("PNG"));
setPwToolsCoreCurrentPage({ evaluate, screenshot });
// bbox is far below the viewport (y: 5000): not drawn, but still reported
// so callers keep the position and a non-zero skipped count.
setPwToolsCoreCurrentRefLocator({
boundingBox: async () => ({ x: 0, y: 5000, width: 50, height: 20 }),
});
const result = await mod.screenshotWithLabelsViaPlaywright({
cdpUrl: "http://127.0.0.1:18792",
targetId: "T1",
refs: { e1: { role: "button" } },
});
expect(result.skipped).toBe(1);
expect(result.labels).toBe(0);
expect(result.annotations).toHaveLength(1);
expect(result.annotations[0]?.ref).toBe("e1");
});
});
describe("screenshotWithLabelsViaPlaywright (fullpage)", () => {
beforeEach(() => vi.clearAllMocks());
it("forwards fullPage:true to page.screenshot and uses doc-space annotations", async () => {
const evaluate = evaluateMockReturning({ x: 0, y: 1000 });
const screenshot = vi.fn(async () => Buffer.from("FULL"));
setPwToolsCoreCurrentPage({ evaluate, screenshot });
setPwToolsCoreCurrentRefLocator({
boundingBox: async () => ({ x: 10, y: 200, width: 50, height: 20 }),
});
const result = await mod.screenshotWithLabelsViaPlaywright({
cdpUrl: "http://127.0.0.1:18792",
targetId: "T1",
refs: { e1: { role: "button" } },
fullPage: true,
});
expect(screenshot).toHaveBeenCalledWith(expect.objectContaining({ fullPage: true }));
// doc-space: scroll y=1000 + bbox y=200 = 1200
expect(result.annotations[0]?.box.y).toBe(1200);
expect(result.annotations[0]?.box.x).toBe(10);
});
});
describe("screenshotWithLabelsViaPlaywright (element/ref)", () => {
beforeEach(() => vi.clearAllMocks());
it("uses refLocator.screenshot for ref mode and projects relative to element", async () => {
const evaluate = evaluateMockReturning({ x: 0, y: 0 });
// First call resolves the element rect (container), second resolves e1 annotation bbox.
const boundingBox = vi
.fn<() => Promise<{ x: number; y: number; width: number; height: number } | null>>()
.mockResolvedValueOnce({ x: 50, y: 100, width: 200, height: 300 })
.mockResolvedValueOnce({ x: 60, y: 110, width: 30, height: 20 });
const elementScreenshot = vi.fn(async () => Buffer.from("ELEM"));
setPwToolsCoreCurrentPage({ evaluate, screenshot: vi.fn() });
setPwToolsCoreCurrentRefLocator({ boundingBox, screenshot: elementScreenshot });
const result = await mod.screenshotWithLabelsViaPlaywright({
cdpUrl: "http://127.0.0.1:18792",
targetId: "T1",
refs: { e1: { role: "button" } },
ref: "container",
});
expect(elementScreenshot).toHaveBeenCalledTimes(1);
// Element-relative: doc(60,110) - elementRect(50,100) = (10,10)
expect(result.annotations).toHaveLength(1);
expect(result.annotations[0]?.box).toEqual({ x: 10, y: 10, width: 30, height: 20 });
});
it("throws when ref/element cannot be resolved", async () => {
const evaluate = evaluateMockReturning({ x: 0, y: 0 });
setPwToolsCoreCurrentPage({ evaluate, screenshot: vi.fn() });
setPwToolsCoreCurrentRefLocator({
boundingBox: async () => null,
screenshot: vi.fn(),
});
await expect(
mod.screenshotWithLabelsViaPlaywright({
cdpUrl: "http://127.0.0.1:18792",
targetId: "T1",
refs: { e1: { role: "button" } },
ref: "missing",
}),
).rejects.toThrow(/element not found/i);
});
});
describe("screenshotWithLabelsViaPlaywright (skipped accounting)", () => {
beforeEach(() => vi.clearAllMocks());
it("counts refs whose boundingBox is null toward skipped", async () => {
const evaluate = evaluateMockReturning({ x: 0, y: 0 });
const screenshot = vi.fn(async () => Buffer.from("PNG"));
setPwToolsCoreCurrentPage({ evaluate, screenshot });
// Two refs: first returns a box, second returns null (e.g. element detached).
const boundingBox = vi
.fn<() => Promise<{ x: number; y: number; width: number; height: number } | null>>()
.mockResolvedValueOnce({ x: 10, y: 20, width: 30, height: 40 })
.mockResolvedValueOnce(null);
setPwToolsCoreCurrentRefLocator({ boundingBox });
const result = await mod.screenshotWithLabelsViaPlaywright({
cdpUrl: "http://127.0.0.1:18792",
targetId: "T1",
refs: { e1: { role: "button" }, e2: { role: "link" } },
});
expect(result.annotations).toHaveLength(1);
expect(result.annotations[0]?.ref).toBe("e1");
expect(result.skipped).toBe(1);
});
});

View File

@@ -41,6 +41,15 @@ import {
toAIFriendlyError,
} from "./pw-tools-core.shared.js";
import { closePageViaPlaywright, resizeViewportViaPlaywright } from "./pw-tools-core.snapshot.js";
import {
ANNOTATION_MAX_LABELS_DEFAULT,
type AnnotationItem,
buildOverlayClearScript,
buildOverlayInjectionScript,
type CoordinateSpace,
planAnnotations,
type RawAnnotationInput,
} from "./screenshot-annotate.js";
type TargetOpts = {
cdpUrl: string;
@@ -1287,7 +1296,15 @@ export async function screenshotWithLabelsViaPlaywright(opts: {
maxLabels?: number;
type?: "png" | "jpeg";
timeoutMs?: number;
}): Promise<{ buffer: Buffer; labels: number; skipped: number }> {
fullPage?: boolean;
ref?: string;
element?: string;
}): Promise<{
buffer: Buffer;
labels: number;
skipped: number;
annotations: AnnotationItem[];
}> {
const page = await getPageForTargetId(opts);
ensurePageState(page);
restoreRoleRefsForTarget({ cdpUrl: opts.cdpUrl, targetId: opts.targetId, page });
@@ -1295,119 +1312,151 @@ export async function screenshotWithLabelsViaPlaywright(opts: {
const maxLabels =
typeof opts.maxLabels === "number" && Number.isFinite(opts.maxLabels)
? Math.max(1, Math.floor(opts.maxLabels))
: 150;
: ANNOTATION_MAX_LABELS_DEFAULT;
const viewport = await page.evaluate(() => ({
scrollX: window.scrollX || 0,
scrollY: window.scrollY || 0,
const refKey = normalizeOptionalString(opts.ref) ?? undefined;
const elementSelector = normalizeOptionalString(opts.element) ?? undefined;
const space: CoordinateSpace = opts.fullPage
? "fullpage"
: refKey || elementSelector
? "element"
: "viewport";
// Read scroll + viewport size. Scroll converts Playwright's viewport-space
// boundingBoxes into document-space inputs; the viewport size lets the helper
// restore the shipped `labelsSkipped` semantics by counting off-viewport refs
// as skipped (in viewport capture mode).
const view = await page.evaluate(() => ({
x: window.scrollX || 0,
y: window.scrollY || 0,
width: window.innerWidth || 0,
height: window.innerHeight || 0,
}));
const scroll = { x: view.x, y: view.y };
const refs = Object.keys(opts.refs ?? {});
const boxes: Array<{ ref: string; x: number; y: number; w: number; h: number }> = [];
let skipped = 0;
let elementRect: { x: number; y: number; width: number; height: number } | undefined;
if (space === "element") {
const box = await resolveElementBoundingBoxForLabels(page, refKey, elementSelector);
if (!box) {
throw new Error(
`screenshotWithLabelsViaPlaywright: element not found for ${
refKey ? `ref="${refKey}"` : `selector="${elementSelector ?? ""}"`
}`,
);
}
// Convert viewport-space bbox to document space.
elementRect = {
x: box.x + scroll.x,
y: box.y + scroll.y,
width: box.width,
height: box.height,
};
}
for (const ref of refs) {
if (boxes.length >= maxLabels) {
skipped += 1;
const refKeys = Object.keys(opts.refs ?? {});
const inputs: RawAnnotationInput[] = [];
let bboxFailures = 0;
for (const ref of refKeys) {
const box = await refLocator(page, ref)
.boundingBox()
.catch(() => null);
if (!box) {
bboxFailures += 1;
continue;
}
try {
const box = await refLocator(page, ref).boundingBox();
if (!box) {
skipped += 1;
continue;
}
const x0 = box.x;
const y0 = box.y;
const x1 = box.x + box.width;
const y1 = box.y + box.height;
const vx0 = viewport.scrollX;
const vy0 = viewport.scrollY;
const vx1 = viewport.scrollX + viewport.width;
const vy1 = viewport.scrollY + viewport.height;
if (x1 < vx0 || x0 > vx1 || y1 < vy0 || y0 > vy1) {
skipped += 1;
continue;
}
boxes.push({
ref,
x: x0 - viewport.scrollX,
y: y0 - viewport.scrollY,
w: Math.max(1, box.width),
h: Math.max(1, box.height),
});
} catch {
skipped += 1;
}
inputs.push({
ref,
role: opts.refs[ref].role,
name: opts.refs[ref].name,
doc: {
x: box.x + scroll.x,
y: box.y + scroll.y,
width: box.width,
height: box.height,
},
});
}
const plan = planAnnotations({
inputs,
space,
scroll,
viewport: { width: view.width, height: view.height },
elementRect,
maxLabels,
});
try {
if (boxes.length > 0) {
await page.evaluate((labels) => {
const existing = document.querySelectorAll("[data-openclaw-labels]");
existing.forEach((el) => el.remove());
const root = document.createElement("div");
root.setAttribute("data-openclaw-labels", "1");
root.style.position = "fixed";
root.style.left = "0";
root.style.top = "0";
root.style.zIndex = "2147483647";
root.style.pointerEvents = "none";
root.style.fontFamily =
'"SF Mono","SFMono-Regular",Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace';
const clamp = (value: number, min: number, max: number) =>
Math.min(max, Math.max(min, value));
for (const label of labels) {
const box = document.createElement("div");
box.setAttribute("data-openclaw-labels", "1");
box.style.position = "absolute";
box.style.left = `${label.x}px`;
box.style.top = `${label.y}px`;
box.style.width = `${label.w}px`;
box.style.height = `${label.h}px`;
box.style.border = "2px solid #ffb020";
box.style.boxSizing = "border-box";
const tag = document.createElement("div");
tag.setAttribute("data-openclaw-labels", "1");
tag.textContent = label.ref;
tag.style.position = "absolute";
tag.style.left = `${label.x}px`;
tag.style.top = `${clamp(label.y - 18, 0, 20000)}px`;
tag.style.background = "#ffb020";
tag.style.color = "#1a1a1a";
tag.style.fontSize = "12px";
tag.style.lineHeight = "14px";
tag.style.padding = "1px 4px";
tag.style.borderRadius = "3px";
tag.style.boxShadow = "0 1px 2px rgba(0,0,0,0.35)";
tag.style.whiteSpace = "nowrap";
root.appendChild(box);
root.appendChild(tag);
}
document.documentElement.appendChild(root);
}, boxes);
if (plan.overlayItems.length > 0) {
const captureY = space === "element" ? elementRect?.y : space === "viewport" ? scroll.y : 0;
await page.evaluate(buildOverlayInjectionScript({ items: plan.overlayItems, captureY }));
}
const buffer = await page.screenshot({ type, timeout: opts.timeoutMs });
return { buffer, labels: boxes.length, skipped };
const buffer =
space === "element"
? await captureElementScreenshotForLabels(
page,
refKey,
elementSelector,
type,
opts.timeoutMs,
)
: await page.screenshot({
type,
fullPage: Boolean(opts.fullPage),
timeout: opts.timeoutMs,
});
return {
// `labels` reports overlay boxes actually drawn on the captured image
// (in-viewport, within budget); off-viewport refs are surfaced via
// `annotations` but not drawn, and are reflected in `skipped`.
buffer,
labels: plan.overlayItems.length,
skipped: plan.skipped + bboxFailures,
annotations: plan.annotations,
};
} finally {
await page
.evaluate(() => {
const existing = document.querySelectorAll("[data-openclaw-labels]");
existing.forEach((el) => el.remove());
})
.catch(() => {});
await page.evaluate(buildOverlayClearScript()).catch(() => {});
}
}
async function resolveElementBoundingBoxForLabels(
page: Page,
refKey: string | undefined,
cssSelector: string | undefined,
): Promise<{ x: number; y: number; width: number; height: number } | null> {
if (refKey) {
try {
return await refLocator(page, refKey).boundingBox();
} catch {
return null;
}
}
if (cssSelector) {
try {
return await page.locator(cssSelector).first().boundingBox();
} catch {
return null;
}
}
return null;
}
async function captureElementScreenshotForLabels(
page: Page,
refKey: string | undefined,
cssSelector: string | undefined,
type: "png" | "jpeg",
timeoutMs: number | undefined,
): Promise<Buffer> {
if (refKey) {
return await refLocator(page, refKey).screenshot({ type, timeout: timeoutMs });
}
if (cssSelector) {
return await page.locator(cssSelector).first().screenshot({ type, timeout: timeoutMs });
}
throw new Error("captureElementScreenshotForLabels: requires refKey or cssSelector");
}
/** Sets file inputs for a role ref or selector with strict existing-path checks. */
export async function setInputFilesViaPlaywright(opts: {
cdpUrl: string;

View File

@@ -5,6 +5,7 @@
* navigation policy checks, media storage, and screenshot normalization.
*/
import path from "node:path";
import { getImageMetadata } from "../../media/media-services.js";
import { ensureMediaDir, saveMediaBuffer } from "../../media/store.js";
import { captureScreenshot, snapshotAria, snapshotRoleViaCdp } from "../cdp.js";
import {
@@ -24,6 +25,8 @@ import {
assertBrowserNavigationResultAllowed,
} from "../navigation-guard.js";
import { getBrowserProfileCapabilities } from "../profile-capabilities.js";
import type { AnnotationItem } from "../screenshot-annotate.js";
import { scaleAnnotations } from "../screenshot-annotate.js";
import {
DEFAULT_BROWSER_SCREENSHOT_MAX_BYTES,
DEFAULT_BROWSER_SCREENSHOT_MAX_SIDE,
@@ -192,11 +195,24 @@ async function saveNormalizedScreenshotResponse(params: {
labels?: boolean;
labelsCount?: number;
labelsSkipped?: number;
annotations?: AnnotationItem[];
}) {
// Measure original dimensions BEFORE normalization so we can rescale
// annotation coordinates if the response pipeline shrinks the image
// (longest-side or byte-budget cap). Annotation boxes are in the captured
// image's pixel space, so they would otherwise drift from the saved media.
const originalMeta = params.annotations?.length
? ((await getImageMetadata(params.buffer)) ?? undefined)
: undefined;
const normalized = await normalizeBrowserScreenshot(params.buffer, {
maxSide: DEFAULT_BROWSER_SCREENSHOT_MAX_SIDE,
maxBytes: DEFAULT_BROWSER_SCREENSHOT_MAX_BYTES,
});
const annotations = await rescaleAnnotationsForNormalization({
annotations: params.annotations,
originalMeta,
normalizedBuffer: normalized.buffer,
});
await saveBrowserMediaResponse({
res: params.res,
buffer: normalized.buffer,
@@ -207,9 +223,39 @@ async function saveNormalizedScreenshotResponse(params: {
labels: params.labels,
labelsCount: params.labelsCount,
labelsSkipped: params.labelsSkipped,
annotations,
});
}
/**
* Keep annotation coordinates aligned with the saved media after
* normalizeBrowserScreenshot. Returns the original annotations unchanged
* when normalization did not change the image dimensions, or when image
* metadata is unavailable (best-effort: better to ship pre-resize coords
* than to drop the field entirely).
*/
async function rescaleAnnotationsForNormalization(params: {
annotations?: AnnotationItem[];
originalMeta?: { width?: number; height?: number };
normalizedBuffer: Buffer;
}): Promise<AnnotationItem[] | undefined> {
if (!params.annotations || params.annotations.length === 0) {
return params.annotations;
}
const orig = params.originalMeta;
if (!orig?.width || !orig?.height) {
return params.annotations;
}
const next = await getImageMetadata(params.normalizedBuffer);
if (!next?.width || !next?.height) {
return params.annotations;
}
if (next.width === orig.width && next.height === orig.height) {
return params.annotations;
}
return scaleAnnotations(params.annotations, next.width / orig.width, next.height / orig.height);
}
async function saveBrowserMediaResponse(params: {
res: BrowserResponse;
buffer: Buffer;
@@ -220,6 +266,7 @@ async function saveBrowserMediaResponse(params: {
labels?: boolean;
labelsCount?: number;
labelsSkipped?: number;
annotations?: AnnotationItem[];
}) {
await ensureMediaDir();
const saved = await saveMediaBuffer(
@@ -236,6 +283,9 @@ async function saveBrowserMediaResponse(params: {
...(params.labels ? { labels: true } : {}),
...(typeof params.labelsCount === "number" ? { labelsCount: params.labelsCount } : {}),
...(typeof params.labelsSkipped === "number" ? { labelsSkipped: params.labelsSkipped } : {}),
...(params.annotations && params.annotations.length > 0
? { annotations: params.annotations }
: {}),
});
}
@@ -478,6 +528,9 @@ export function registerBrowserAgentSnapshotRoutes(
refs: snap.refs,
type,
timeoutMs,
fullPage,
ref,
element,
});
await saveNormalizedScreenshotResponse({
res,
@@ -488,6 +541,7 @@ export function registerBrowserAgentSnapshotRoutes(
labels: true,
labelsCount: labeled.labels,
labelsSkipped: labeled.skipped,
annotations: labeled.annotations,
});
return;
}
@@ -743,10 +797,18 @@ export function registerBrowserAgentSnapshotRoutes(
type: "png",
timeoutMs: plan.timeoutMs,
});
const originalMeta = labeled.annotations.length
? ((await getImageMetadata(labeled.buffer)) ?? undefined)
: undefined;
const normalized = await normalizeBrowserScreenshot(labeled.buffer, {
maxSide: DEFAULT_BROWSER_SCREENSHOT_MAX_SIDE,
maxBytes: DEFAULT_BROWSER_SCREENSHOT_MAX_BYTES,
});
const scaledAnnotations = await rescaleAnnotationsForNormalization({
annotations: labeled.annotations,
originalMeta,
normalizedBuffer: normalized.buffer,
});
await ensureMediaDir();
const saved = await saveMediaBuffer(
normalized.buffer,
@@ -764,6 +826,9 @@ export function registerBrowserAgentSnapshotRoutes(
labels: true,
labelsCount: labeled.labels,
labelsSkipped: labeled.skipped,
...(scaledAnnotations && scaledAnnotations.length > 0
? { annotations: scaledAnnotations }
: {}),
imagePath: path.resolve(saved.path),
imageType,
...snap,

View File

@@ -0,0 +1,345 @@
import { describe, expect, it } from "vitest";
import {
ANNOTATION_OVERLAY_ATTR,
type AnnotationItem,
buildOverlayClearScript,
buildOverlayInjectionScript,
planAnnotations,
type RawAnnotationInput,
refToNumber,
scaleAnnotations,
} from "./screenshot-annotate.js";
const sampleInputs: RawAnnotationInput[] = [
{
ref: "e1",
role: "button",
name: "Submit",
doc: { x: 100, y: 200, width: 50, height: 20 },
},
{
ref: "e2",
role: "link",
doc: { x: 300, y: 1500, width: 80, height: 18 },
},
];
describe("refToNumber", () => {
it("extracts number from `e<N>` form", () => {
expect(refToNumber("e12")).toBe(12);
expect(refToNumber("e0")).toBe(0);
});
it("extracts number from `ax<N>` form", () => {
expect(refToNumber("ax12")).toBe(12);
});
it("extracts number from bare numeric form", () => {
expect(refToNumber("12")).toBe(12);
});
it("returns 0 for non-numeric refs", () => {
expect(refToNumber("foo")).toBe(0);
expect(refToNumber("")).toBe(0);
});
});
describe("planAnnotations - viewport mode", () => {
it("subtracts scroll from doc coords", () => {
const plan = planAnnotations({
inputs: sampleInputs,
space: "viewport",
scroll: { x: 0, y: 1000 },
});
expect(plan.annotations).toHaveLength(2);
expect(plan.annotations[0]).toEqual({
ref: "e1",
number: 1,
role: "button",
name: "Submit",
box: { x: 100, y: -800, width: 50, height: 20 },
});
expect(plan.annotations[1]).toEqual({
ref: "e2",
number: 2,
role: "link",
box: { x: 300, y: 500, width: 80, height: 18 },
});
expect(plan.skipped).toBe(0);
});
it("keeps overlay items in document space regardless of mode", () => {
const plan = planAnnotations({
inputs: sampleInputs,
space: "viewport",
scroll: { x: 0, y: 1000 },
});
expect(plan.overlayItems).toEqual([
{ ref: "e1", x: 100, y: 200, w: 50, h: 20 },
{ ref: "e2", x: 300, y: 1500, w: 80, h: 18 },
]);
});
it("omits empty name field", () => {
const plan = planAnnotations({
inputs: [{ ref: "e1", role: "button", name: "", doc: { x: 0, y: 0, width: 1, height: 1 } }],
space: "viewport",
scroll: { x: 0, y: 0 },
});
expect(plan.annotations[0]).not.toHaveProperty("name");
});
it("throws when scroll missing in viewport mode", () => {
expect(() => planAnnotations({ inputs: sampleInputs, space: "viewport" })).toThrow(/scroll/);
});
});
describe("planAnnotations - viewport off-screen accounting", () => {
it("counts off-viewport refs as skipped but keeps them in annotations when viewport size is given", () => {
const plan = planAnnotations({
inputs: [
{ ref: "e1", role: "button", doc: { x: 10, y: 50, width: 40, height: 20 } }, // in viewport
{ ref: "e2", role: "link", doc: { x: 10, y: 5000, width: 40, height: 20 } }, // below viewport
],
space: "viewport",
scroll: { x: 0, y: 0 },
viewport: { width: 1280, height: 720 },
});
// Only the in-viewport ref is drawn.
expect(plan.overlayItems.map((o) => o.ref)).toEqual(["e1"]);
// Both refs are surfaced for callers (off-viewport box can be out of image).
expect(plan.annotations.map((a) => a.ref)).toEqual(["e1", "e2"]);
// The off-viewport ref raises skipped, preserving the shipped contract.
expect(plan.skipped).toBe(1);
});
it("does not count off-viewport refs when viewport size is omitted", () => {
const plan = planAnnotations({
inputs: [{ ref: "e2", role: "link", doc: { x: 10, y: 5000, width: 40, height: 20 } }],
space: "viewport",
scroll: { x: 0, y: 0 },
});
expect(plan.skipped).toBe(0);
expect(plan.overlayItems).toHaveLength(1);
expect(plan.annotations).toHaveLength(1);
});
});
describe("planAnnotations - fullpage mode", () => {
it("returns box equal to doc (document coordinates)", () => {
const plan = planAnnotations({ inputs: sampleInputs, space: "fullpage" });
expect(plan.annotations[0].box).toEqual({ x: 100, y: 200, width: 50, height: 20 });
expect(plan.annotations[1].box).toEqual({ x: 300, y: 1500, width: 80, height: 18 });
});
it("does not require scroll", () => {
expect(() => planAnnotations({ inputs: sampleInputs, space: "fullpage" })).not.toThrow();
});
});
describe("planAnnotations - element mode", () => {
const elementRect = { x: 50, y: 100, width: 200, height: 300 };
it("projects box relative to element top-left", () => {
const plan = planAnnotations({
inputs: [{ ref: "e1", role: "button", doc: { x: 60, y: 110, width: 40, height: 20 } }],
space: "element",
elementRect,
});
expect(plan.annotations[0].box).toEqual({ x: 10, y: 10, width: 40, height: 20 });
});
it("filters out inputs that do not overlap element rect", () => {
const plan = planAnnotations({
inputs: [
{ ref: "e1", role: "button", doc: { x: 60, y: 110, width: 40, height: 20 } }, // inside
{ ref: "e2", role: "link", doc: { x: 500, y: 500, width: 40, height: 20 } }, // outside
],
space: "element",
elementRect,
});
expect(plan.annotations).toHaveLength(1);
expect(plan.annotations[0].ref).toBe("e1");
expect(plan.overlayItems).toHaveLength(1);
});
it("throws when elementRect missing", () => {
expect(() => planAnnotations({ inputs: [], space: "element" })).toThrow(/elementRect/);
});
});
describe("planAnnotations - maxLabels", () => {
it("truncates to maxLabels and reports skipped", () => {
const inputs = Array.from({ length: 5 }, (_, i) => ({
ref: `e${i + 1}`,
role: "button",
doc: { x: 0, y: i * 10, width: 5, height: 5 },
}));
const plan = planAnnotations({ inputs, space: "fullpage", maxLabels: 2 });
expect(plan.annotations).toHaveLength(2);
expect(plan.overlayItems).toHaveLength(2);
expect(plan.skipped).toBe(3);
});
it("uses ANNOTATION_MAX_LABELS_DEFAULT when not specified", () => {
const inputs = Array.from({ length: 200 }, (_, i) => ({
ref: `e${i + 1}`,
role: "button",
doc: { x: 0, y: i, width: 5, height: 5 },
}));
const plan = planAnnotations({ inputs, space: "fullpage" });
expect(plan.annotations).toHaveLength(150);
expect(plan.skipped).toBe(50);
});
});
describe("buildOverlayInjectionScript", () => {
it("returns a self-contained IIFE", () => {
const script = buildOverlayInjectionScript({
items: [{ ref: "e1", x: 100, y: 200, w: 50, h: 20 }],
});
expect(script).toMatch(/^\(\s*\(\s*\)\s*=>\s*\{/);
expect(script).toMatch(/\}\s*\)\s*\(\s*\)\s*;?\s*$/);
});
it("embeds the overlay attr", () => {
const script = buildOverlayInjectionScript({ items: [] });
expect(script).toContain(ANNOTATION_OVERLAY_ATTR);
});
it("embeds each item's ref text and coordinates", () => {
const script = buildOverlayInjectionScript({
items: [
{ ref: "e1", x: 100, y: 200, w: 50, h: 20 },
{ ref: "ax42", x: 999, y: 1500, w: 80, h: 18 },
],
});
expect(script).toMatch(/"ref":\s*"e1"/);
expect(script).toMatch(/"ref":\s*"ax42"/);
expect(script).toMatch(/"x":\s*100/);
expect(script).toMatch(/"x":\s*999/);
});
it("handles empty items without throwing", () => {
expect(() => buildOverlayInjectionScript({ items: [] })).not.toThrow();
});
it("rounds coordinates to integers", () => {
const script = buildOverlayInjectionScript({
items: [{ ref: "e1", x: 100.7, y: 200.4, w: 50.6, h: 20.1 }],
});
expect(script).toMatch(/"x":\s*101/); // 100.7 -> 101
expect(script).toMatch(/"y":\s*200/); // 200.4 -> 200
});
it("clamps zero/negative-size boxes to 1px so they remain visible", () => {
const script = buildOverlayInjectionScript({
items: [{ ref: "e1", x: 10, y: 10, w: 0, h: 0 }],
});
expect(script).toMatch(/"w":\s*1/);
expect(script).toMatch(/"h":\s*1/);
});
it("escapes hostile ref characters via JSON.stringify (no breakout)", () => {
const hostile = 'e1");alert(1);//';
const script = buildOverlayInjectionScript({
items: [{ ref: hostile, x: 0, y: 0, w: 1, h: 1 }],
});
// The hostile `"` MUST be escaped as `\"` inside the JSON literal.
expect(script).toContain('"e1\\");alert(1);//"');
// The unescaped breakout MUST NOT appear anywhere in the script as a
// bare statement that would terminate the JSON literal early.
expect(script).not.toContain('e1");alert(1);');
});
it("flips label below the box when y < 14 (no headroom)", () => {
const script = buildOverlayInjectionScript({
items: [{ ref: "e1", x: 0, y: 5, w: 10, h: 10 }],
});
// labelTop = relativeY < 14 ? it.y + 2 : it.y - 14
// The expression literal `relativeY < 14 ? (it.y + 2) : (it.y - 14)` is in the script.
expect(script).toContain("relativeY < 14 ? (it.y + 2) : (it.y - 14)");
});
it("uses capture-relative y when deciding whether to flip labels below boxes", () => {
const script = buildOverlayInjectionScript({
items: [{ ref: "e1", x: 0, y: 1005, w: 10, h: 10 }],
captureY: 1000,
});
expect(script).toContain("var captureY = 1000;");
expect(script).toContain("var relativeY = it.y - captureY;");
expect(script).toContain("relativeY < 14 ? (it.y + 2) : (it.y - 14)");
});
});
describe("buildOverlayClearScript", () => {
it("returns an IIFE selecting overlay attr", () => {
const script = buildOverlayClearScript();
expect(script).toContain(`[${ANNOTATION_OVERLAY_ATTR}]`);
expect(script).toMatch(/^\(\s*\(\s*\)\s*=>\s*\{/);
});
});
describe("scaleAnnotations", () => {
const sample: AnnotationItem[] = [
{
ref: "e1",
number: 1,
role: "button",
name: "Submit",
box: { x: 100, y: 200, width: 50, height: 20 },
},
];
it("returns identity (structural copy) when both factors are 1", () => {
const out = scaleAnnotations(sample, 1, 1);
expect(out[0]).toEqual(sample[0]);
expect(out[0]).not.toBe(sample[0]);
expect(out[0]?.box).not.toBe(sample[0]?.box);
});
it("scales box dimensions by independent x/y factors", () => {
const out = scaleAnnotations(sample, 0.5, 0.485);
expect(out[0]?.box).toEqual({
x: 50,
y: 97,
width: 25,
height: 10,
});
});
it("clamps width/height to a minimum of 1 to avoid disappearing labels", () => {
const tiny: AnnotationItem[] = [
{
ref: "e1",
number: 1,
role: "button",
box: { x: 0, y: 0, width: 1, height: 1 },
},
];
const out = scaleAnnotations(tiny, 0.1, 0.1);
expect(out[0]?.box.width).toBeGreaterThanOrEqual(1);
expect(out[0]?.box.height).toBeGreaterThanOrEqual(1);
});
it("returns identity (structural copy) for invalid factors", () => {
const out = scaleAnnotations(sample, Number.NaN, 0.5);
expect(out[0]?.box).toEqual(sample[0]?.box);
const out2 = scaleAnnotations(sample, 0, 0.5);
expect(out2[0]?.box).toEqual(sample[0]?.box);
const out3 = scaleAnnotations(sample, -1, 1);
expect(out3[0]?.box).toEqual(sample[0]?.box);
});
it("preserves ref/number/role/name fields verbatim", () => {
const out = scaleAnnotations(sample, 0.5, 0.5);
expect(out[0]?.ref).toBe("e1");
expect(out[0]?.number).toBe(1);
expect(out[0]?.role).toBe("button");
expect(out[0]?.name).toBe("Submit");
});
});

View File

@@ -0,0 +1,282 @@
// extensions/browser/src/browser/screenshot-annotate.ts
//
// Pure helper module for screenshot label annotations.
// Has no Playwright / CDP / page dependency: takes document-space inputs,
// returns coordinate-projected annotations + IIFE strings the caller can
// hand to page.evaluate / Runtime.evaluate.
//
// Used by:
// - pw-tools-core.interactions.ts (Playwright path, M1.2-a)
// - planned: raw-CDP path in M1.2-b
//
// chrome-mcp path keeps its own inline overlay (renderChromeMcpLabels) for now.
export const ANNOTATION_OVERLAY_ATTR = "data-openclaw-labels";
export const ANNOTATION_OVERLAY_ROOT_ID = "__openclaw-annotations__";
export const ANNOTATION_MAX_LABELS_DEFAULT = 150;
export type CoordinateSpace = "viewport" | "fullpage" | "element";
export interface RawAnnotationInput {
ref: string;
role: string;
name?: string;
/** Bounding box in document coordinates (viewport top-left + scroll). */
doc: { x: number; y: number; width: number; height: number };
}
export interface AnnotationBox {
x: number;
y: number;
width: number;
height: number;
}
export interface AnnotationItem {
ref: string;
number: number;
role: string;
name?: string;
box: AnnotationBox;
}
export interface OverlayItem {
ref: string;
x: number;
y: number;
w: number;
h: number;
}
export interface AnnotationPlan {
/** Always document-space items, fed to buildOverlayInjectionScript. */
overlayItems: OverlayItem[];
/** Items projected into the capture mode's image-space coordinates. */
annotations: AnnotationItem[];
/** Refs dropped because of maxLabels truncation. */
skipped: number;
}
export interface PlanAnnotationsParams {
inputs: RawAnnotationInput[];
space: CoordinateSpace;
/** Required when space === "viewport". */
scroll?: { x: number; y: number };
/**
* Viewport size (CSS px). Only meaningful when space === "viewport". When
* provided, refs whose document box falls outside the current viewport rect
* (`scroll` + this size) are counted as skipped instead of drawn, preserving
* the shipped `labelsSkipped` contract. Omit it to disable that accounting.
*/
viewport?: { width: number; height: number };
/** Required when space === "element". */
elementRect?: { x: number; y: number; width: number; height: number };
maxLabels?: number;
}
export function refToNumber(ref: string): number {
const match = ref.match(/(\d+)/);
if (!match) {
return 0;
}
const n = Number(match[1]);
return Number.isFinite(n) ? n : 0;
}
export function planAnnotations(params: PlanAnnotationsParams): AnnotationPlan {
const maxLabels = params.maxLabels ?? ANNOTATION_MAX_LABELS_DEFAULT;
if (params.space === "viewport" && !params.scroll) {
throw new Error("planAnnotations: scroll is required when space is 'viewport'");
}
if (params.space === "element" && !params.elementRect) {
throw new Error("planAnnotations: elementRect is required when space is 'element'");
}
// Element-mode filter: discard inputs that do not overlap the element rect.
let kept = params.inputs;
if (params.space === "element" && params.elementRect) {
const er = params.elementRect;
kept = params.inputs.filter((input) => rectsOverlap(input.doc, er));
}
// Viewport capture only shows refs inside the current viewport rect. An
// off-viewport ref is still surfaced in `annotations` (with its real,
// possibly out-of-image box) so callers can locate it, but it is not drawn
// and is counted as skipped. This keeps the shipped `labelsSkipped` meaning
// ("refs not present in the captured viewport image") instead of silently
// narrowing it. Only applied when the caller supplies the viewport size;
// without it we cannot decide off-screen state and skip nothing.
const viewportRect =
params.space === "viewport" && params.scroll && params.viewport
? {
x: params.scroll.x,
y: params.scroll.y,
width: params.viewport.width,
height: params.viewport.height,
}
: undefined;
const overlayItems: OverlayItem[] = [];
const annotations: AnnotationItem[] = [];
let skipped = 0;
for (const input of kept) {
if (viewportRect && !rectsOverlap(input.doc, viewportRect)) {
// Outside the captured viewport: count as skipped (compat) but still
// report the annotation; do not draw it or consume the label budget.
skipped += 1;
annotations.push(toAnnotation(input, params));
continue;
}
if (overlayItems.length >= maxLabels) {
skipped += 1;
continue;
}
overlayItems.push({
ref: input.ref,
x: input.doc.x,
y: input.doc.y,
w: input.doc.width,
h: input.doc.height,
});
annotations.push(toAnnotation(input, params));
}
return { overlayItems, annotations, skipped };
}
function toAnnotation(input: RawAnnotationInput, params: PlanAnnotationsParams): AnnotationItem {
return {
ref: input.ref,
number: refToNumber(input.ref),
role: input.role,
...(input.name ? { name: input.name } : {}),
box: projectBox(input.doc, params),
};
}
function projectBox(
doc: { x: number; y: number; width: number; height: number },
params: PlanAnnotationsParams,
): AnnotationBox {
if (params.space === "viewport") {
const scroll = params.scroll!;
return {
x: doc.x - scroll.x,
y: doc.y - scroll.y,
width: doc.width,
height: doc.height,
};
}
if (params.space === "element") {
const er = params.elementRect!;
// NOTE: width/height pass through unchanged even when the input rect
// partially extends past the element. The capture backend (e.g.
// locator.screenshot) is responsible for clipping; the box may have
// negative x/y or extend past elementRect width/height for partial overlaps.
return {
x: doc.x - er.x,
y: doc.y - er.y,
width: doc.width,
height: doc.height,
};
}
// fullpage: document coordinates as-is
return { x: doc.x, y: doc.y, width: doc.width, height: doc.height };
}
function rectsOverlap(
a: { x: number; y: number; width: number; height: number },
b: { x: number; y: number; width: number; height: number },
): boolean {
return a.x < b.x + b.width && a.x + a.width > b.x && a.y < b.y + b.height && a.y + a.height > b.y;
}
export function buildOverlayInjectionScript(params: {
items: OverlayItem[];
captureY?: number;
}): string {
const itemsJson = JSON.stringify(
params.items.map((it) => ({
ref: it.ref,
x: round(it.x),
y: round(it.y),
w: Math.max(1, round(it.w)),
h: Math.max(1, round(it.h)),
})),
);
const attr = ANNOTATION_OVERLAY_ATTR;
const rootId = ANNOTATION_OVERLAY_ROOT_ID;
const captureY = Number.isFinite(params.captureY) ? round(params.captureY ?? 0) : 0;
return `(() => {
var items = ${itemsJson};
var captureY = ${captureY};
var existing = document.querySelectorAll("[${attr}]");
for (var k = 0; k < existing.length; k++) existing[k].remove();
var root = document.createElement("div");
root.id = ${JSON.stringify(rootId)};
root.setAttribute("${attr}", "1");
root.style.cssText = "position:absolute;top:0;left:0;width:0;height:0;pointer-events:none;z-index:2147483647;font-family:'SF Mono','SFMono-Regular',Menlo,Monaco,Consolas,'Liberation Mono','Courier New',monospace;";
for (var i = 0; i < items.length; i++) {
var it = items[i];
var box = document.createElement("div");
box.setAttribute("${attr}", "1");
box.style.cssText = "position:absolute;left:" + it.x + "px;top:" + it.y + "px;width:" + it.w + "px;height:" + it.h + "px;border:2px solid #ffb020;box-sizing:border-box;pointer-events:none;";
var tag = document.createElement("div");
tag.setAttribute("${attr}", "1");
tag.textContent = String(it.ref);
var relativeY = it.y - captureY;
var labelTop = relativeY < 14 ? (it.y + 2) : (it.y - 14);
tag.style.cssText = "position:absolute;left:" + it.x + "px;top:" + labelTop + "px;background:#ffb020;color:#1a1a1a;font:bold 11px/14px monospace;padding:0 4px;border-radius:2px;white-space:nowrap;pointer-events:none;";
root.appendChild(box);
root.appendChild(tag);
}
document.documentElement.appendChild(root);
return true;
})();`;
}
export function buildOverlayClearScript(): string {
const attr = ANNOTATION_OVERLAY_ATTR;
return `(() => {
var existing = document.querySelectorAll("[${attr}]");
for (var k = 0; k < existing.length; k++) existing[k].remove();
return true;
})();`;
}
/**
* Scale annotation boxes by independent x/y factors. Used to keep annotation
* coordinates aligned with the saved image after the response pipeline
* resizes the screenshot (e.g. via normalizeBrowserScreenshot capping the
* longest side or the byte budget). Returns a new array; inputs are not
* mutated. When both factors are 1 the boxes are returned unchanged (modulo
* structural copy) so callers can share the same code path for resized and
* non-resized captures.
*/
export function scaleAnnotations(
items: AnnotationItem[],
scaleX: number,
scaleY: number,
): AnnotationItem[] {
if (!Number.isFinite(scaleX) || !Number.isFinite(scaleY) || scaleX <= 0 || scaleY <= 0) {
return items.map((it) => ({ ...it, box: { ...it.box } }));
}
if (scaleX === 1 && scaleY === 1) {
return items.map((it) => ({ ...it, box: { ...it.box } }));
}
return items.map((it) => ({
...it,
box: {
x: round(it.box.x * scaleX),
y: round(it.box.y * scaleY),
width: Math.max(1, round(it.box.width * scaleX)),
height: Math.max(1, round(it.box.height * scaleY)),
},
}));
}
function round(v: number): number {
return Math.round(v);
}

View File

@@ -61,4 +61,18 @@ describe("browser navigation commands", () => {
expect(capture.runtimeErrors.join("\n")).toContain("Invalid width: maximum is 8192");
expect(mocks.runBrowserResizeWithOutput).not.toHaveBeenCalled();
});
it("navigate and resize commands are registered after removing dead import (#83878)", async () => {
const program = createNavigationProgram();
const browserCmd = program.commands.find((c) => c.name() === "browser");
expect(browserCmd).toBeDefined();
const cmds = browserCmd!.commands.map((c) => c.name());
expect(cmds).toContain("resize");
expect(cmds).toContain("navigate");
// Verify the shared module still exports requireRef (used by other modules)
const shared = await import("./shared.js");
expect(typeof shared.requireRef).toBe("function");
});
});

View File

@@ -12,7 +12,7 @@ import {
type BrowserParentOpts,
} from "../browser-cli-shared.js";
import { danger, defaultRuntime } from "../core-api.js";
import { requireRef, resolveBrowserActionContext } from "./shared.js";
import { resolveBrowserActionContext } from "./shared.js";
/** Registers Browser navigate and resize commands. */
export function registerBrowserNavigationCommands(
@@ -94,7 +94,4 @@ export function registerBrowserNavigationCommands(
defaultRuntime.exit(1);
}
});
// Keep `requireRef` reachable; shared utilities are intended for other modules too.
void requireRef;
}

View File

@@ -51,7 +51,11 @@ export function registerBrowserInspectCommands(
.option("--full-page", "Capture full scrollable page", false)
.option("--ref <ref>", "ARIA ref from ai snapshot")
.option("--element <selector>", "CSS selector for element screenshot")
.option("--labels", "Overlay role refs on the screenshot", false)
.option(
"--labels",
"Overlay role refs on the screenshot (works with --full-page, --ref, and --element)",
false,
)
.option("--type <png|jpeg>", "Output type (default: png)", "png")
.action(async (targetId: string | undefined, opts, cmd) => {
const parent = parentOpts(cmd);
@@ -98,7 +102,7 @@ export function registerBrowserInspectCommands(
.option("--depth <n>", "Role snapshot: max depth")
.option("--selector <sel>", "Role snapshot: scope to CSS selector")
.option("--frame <sel>", "Role snapshot: scope to an iframe selector")
.option("--labels", "Include viewport label overlay screenshot", false)
.option("--labels", "Include label overlay screenshot with annotations", false)
.option("--urls", "Append discovered link URLs to AI snapshots", false)
.option("--out <path>", "Write snapshot to a file")
.action(async (opts, cmd) => {

View File

@@ -1,7 +1,11 @@
// Canvas tests cover cli plugin behavior.
import { Command } from "commander";
import { describe, expect, it, vi } from "vitest";
import { registerNodesCanvasCommands, type CanvasCliDependencies } from "./cli.js";
import {
createDefaultCanvasCliDependencies,
registerNodesCanvasCommands,
type CanvasCliDependencies,
} from "./cli.js";
function createCanvasCliDeps() {
const writtenFiles: Array<{ filePath: string; base64: string }> = [];
@@ -47,6 +51,26 @@ function createCanvasCliDeps() {
return { deps, runtime, writtenFiles };
}
function createCanvasCliDepsWithDefaultParsers() {
const baseDeps = createDefaultCanvasCliDependencies();
const harness = createCanvasCliDeps();
return {
...harness,
deps: {
...baseDeps,
defaultRuntime: harness.runtime,
nodesCallOpts: harness.deps.nodesCallOpts,
runNodesCommand: harness.deps.runNodesCommand,
getNodesTheme: harness.deps.getNodesTheme,
resolveNodeId: harness.deps.resolveNodeId,
buildNodeInvokeParams: harness.deps.buildNodeInvokeParams,
callGatewayCli: harness.deps.callGatewayCli,
writeBase64ToFile: harness.deps.writeBase64ToFile,
shortenHomePath: harness.deps.shortenHomePath,
},
};
}
describe("canvas CLI", () => {
it("registers under nodes and captures a snapshot media path", async () => {
const program = new Command();
@@ -135,6 +159,8 @@ describe("canvas CLI", () => {
it.each([
["--max-width", "640px", "--max-width must be a positive integer."],
["--quality", "0.8x", "--quality must be a number."],
["--quality", "-0.1", "--quality must be between 0 and 1."],
["--quality", "5", "--quality must be between 0 and 1."],
])("rejects partial numeric snapshot %s values", async (flag, value, message) => {
const program = new Command();
program.exitOverride();
@@ -151,6 +177,62 @@ describe("canvas CLI", () => {
expect(deps.callGatewayCli).not.toHaveBeenCalled();
});
it.each(["0", "1"])("accepts snapshot --quality boundary value %s", async (quality) => {
const program = new Command();
program.exitOverride();
const nodes = program.command("nodes");
const { deps } = createCanvasCliDeps();
registerNodesCanvasCommands(nodes, deps);
await program.parseAsync(
["nodes", "canvas", "snapshot", "--node", "ios-node", "--quality", quality],
{
from: "user",
},
);
expect(deps.callGatewayCli).toHaveBeenCalledWith(
"node.invoke",
expect.any(Object),
expect.objectContaining({
params: expect.objectContaining({
quality: Number(quality),
}),
}),
);
});
it.each([
["snapshot"],
["present"],
["hide"],
["navigate", "https://example.com"],
["eval", "1 + 1"],
["a2ui", "push", "--text", "hello"],
["a2ui", "reset"],
])("rejects invalid %s invoke timeouts before invoking the node", async (...args) => {
const program = new Command();
program.exitOverride();
const nodes = program.command("nodes");
const { deps } = createCanvasCliDepsWithDefaultParsers();
deps.resolveNodeId = vi.fn(async () => {
throw new Error("resolveNodeId should not be called");
});
registerNodesCanvasCommands(nodes, deps);
await expect(
program.parseAsync(
["nodes", "canvas", ...args, "--node", "ios-node", "--invoke-timeout", "20ms"],
{
from: "user",
},
),
).rejects.toThrow("--invoke-timeout must be a positive integer.");
expect(deps.resolveNodeId).not.toHaveBeenCalled();
expect(deps.callGatewayCli).not.toHaveBeenCalled();
});
it.each([
["--x", "1x"],
["--y", "2px"],

View File

@@ -97,7 +97,11 @@ function parseTimeoutMs(raw: unknown): number | undefined {
if (raw === undefined || raw === null) {
return undefined;
}
return parseStrictPositiveInteger(raw);
const parsed = parseStrictPositiveInteger(raw);
if (parsed === undefined) {
throw new Error("--invoke-timeout must be a positive integer.");
}
return parsed;
}
function parseCanvasPositiveIntOption(raw: string | undefined, flag: string): number | undefined {
@@ -122,6 +126,14 @@ function parseCanvasFiniteNumberOption(raw: string | undefined, flag: string): n
return parsed;
}
function parseCanvasSnapshotQualityOption(raw: string | undefined): number | undefined {
const parsed = parseCanvasFiniteNumberOption(raw, "--quality");
if (parsed !== undefined && (parsed < 0 || parsed > 1)) {
throw new Error("--quality must be between 0 and 1.");
}
return parsed;
}
function parseNodeCandidates(raw: unknown): CanvasNodeCandidate[] {
const payload =
raw && typeof raw === "object" ? (raw as { nodes?: unknown; paired?: unknown }) : {};
@@ -245,8 +257,8 @@ async function invokeCanvas(
command: string,
params?: Record<string, unknown>,
) {
const nodeId = await deps.resolveNodeId(opts, normalizeOptionalString(opts.node) ?? "");
const timeoutMs = deps.parseTimeoutMs(opts.invokeTimeout);
const nodeId = await deps.resolveNodeId(opts, normalizeOptionalString(opts.node) ?? "");
return await deps.callGatewayCli(
"node.invoke",
opts,
@@ -278,7 +290,7 @@ export function registerNodesCanvasCommands(nodes: Command, deps: CanvasCliDepen
await deps.runNodesCommand("canvas snapshot", async () => {
const format = parseCanvasSnapshotRequestFormat(opts.format);
const maxWidth = parseCanvasPositiveIntOption(opts.maxWidth, "--max-width");
const quality = parseCanvasFiniteNumberOption(opts.quality, "--quality");
const quality = parseCanvasSnapshotQualityOption(opts.quality);
const raw = await invokeCanvas(deps, opts, "canvas.snapshot", {
format,
maxWidth: Number.isFinite(maxWidth) ? maxWidth : undefined,

View File

@@ -5,6 +5,14 @@ import { buildCodexMediaUnderstandingProvider } from "./media-understanding-prov
import type { CodexAppServerClient } from "./src/app-server/client.js";
import type { CodexServerNotification, JsonValue } from "./src/app-server/protocol.js";
const sharedClientMocks = vi.hoisted(() => ({
createIsolatedCodexAppServerClient: vi.fn(),
}));
vi.mock("./src/app-server/shared-client.js", () => ({
createIsolatedCodexAppServerClient: sharedClientMocks.createIsolatedCodexAppServerClient,
}));
function codexModel(inputModalities: string[] = ["text", "image"]) {
return {
id: "gpt-5.4",
@@ -169,6 +177,7 @@ function createFakeClient(options?: {
requestHandlers.add(handler);
return () => requestHandlers.delete(handler);
},
close: vi.fn(),
} as unknown as CodexAppServerClient;
return { client, requests, approvalResponses };
@@ -178,13 +187,24 @@ describe("codex media understanding provider", () => {
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
sharedClientMocks.createIsolatedCodexAppServerClient.mockReset();
});
it("runs image understanding through a bounded Codex app-server turn", async () => {
const { client, requests } = createFakeClient();
const clientFactory = vi.fn(
async (_startOptions, _authProfileId, _agentDir, _config) => client,
);
const provider = buildCodexMediaUnderstandingProvider({
clientFactory: async () => client,
clientFactory,
});
const cfg = {
auth: {
order: {
openai: ["openai:work"],
},
},
};
const result = await provider.describeImage?.({
buffer: Buffer.from("image-bytes"),
@@ -194,7 +214,7 @@ describe("codex media understanding provider", () => {
model: "gpt-5.4",
prompt: "Describe briefly.",
timeoutMs: 30_000,
cfg: {},
cfg,
agentDir: "/tmp/openclaw-agent",
});
@@ -204,6 +224,12 @@ describe("codex media understanding provider", () => {
"thread/start",
"turn/start",
]);
expect(clientFactory).toHaveBeenCalledWith(
expect.any(Object),
undefined,
"/tmp/openclaw-agent",
cfg,
);
expect(requests[1]?.params).toEqual({
model: "gpt-5.4",
modelProvider: "openai",
@@ -236,6 +262,62 @@ describe("codex media understanding provider", () => {
});
});
it("treats a blank agent directory as absent when starting the app-server", async () => {
const { client, requests } = createFakeClient();
const clientFactory = vi.fn(async () => client);
const provider = buildCodexMediaUnderstandingProvider({ clientFactory });
const cfg = {};
await provider.describeImage?.({
buffer: Buffer.from("image-bytes"),
fileName: "image.png",
mime: "image/png",
provider: "codex",
model: "gpt-5.4",
timeoutMs: 30_000,
cfg,
agentDir: " ",
});
expect(clientFactory).toHaveBeenCalledWith(expect.any(Object), undefined, undefined, cfg);
expect(requests[1]?.params).toEqual(expect.objectContaining({ cwd: process.cwd() }));
expect(requests[2]?.params).toEqual(expect.objectContaining({ cwd: process.cwd() }));
});
it("passes the scoped auth store into isolated app-server startup", async () => {
const { client } = createFakeClient();
sharedClientMocks.createIsolatedCodexAppServerClient.mockResolvedValue(client);
const provider = buildCodexMediaUnderstandingProvider();
const authStore = {
version: 1,
profiles: {
"openai:scoped": {
type: "oauth" as const,
provider: "openai",
access: "scoped-access",
refresh: "scoped-refresh",
expires: Date.now() + 60_000,
},
},
};
await provider.describeImage?.({
buffer: Buffer.from("image-bytes"),
fileName: "image.png",
mime: "image/png",
provider: "codex",
model: "gpt-5.4",
timeoutMs: 30_000,
cfg: {},
authStore,
agentDir: "/tmp/openclaw-agent",
});
expect(sharedClientMocks.createIsolatedCodexAppServerClient).toHaveBeenCalledWith(
expect.objectContaining({ authProfileStore: authStore }),
);
});
it("clamps oversized image understanding turn timeouts", async () => {
const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout");
try {

View File

@@ -102,6 +102,8 @@ async function describeCodexImages(
profile: req.profile,
timeoutMs: req.timeoutMs,
agentDir: req.agentDir,
authStore: req.authStore,
cfg: req.cfg,
options,
taskLabel: "image understanding",
developerInstructions:
@@ -123,6 +125,8 @@ type BoundedCodexVisionTurnParams = {
profile?: string;
timeoutMs: number;
agentDir?: string;
authStore?: ImagesDescriptionRequest["authStore"];
cfg: ImagesDescriptionRequest["cfg"];
options: CodexMediaUnderstandingProviderOptions;
taskLabel: string;
developerInstructions: string;
@@ -135,17 +139,22 @@ async function runBoundedCodexVisionTurn(params: BoundedCodexVisionTurnParams):
pluginConfig: params.options.pluginConfig,
});
const timeoutMs = resolveTimerTimeoutMs(params.timeoutMs, 100, 100);
const agentDir = params.agentDir?.trim() || undefined;
const cwd = agentDir ?? process.cwd();
const ownsClient = !params.options.clientFactory;
// Tests inject a client factory; production creates an isolated app-server
// client so media tasks cannot reuse the interactive attempt session.
const client = params.options.clientFactory
? await params.options.clientFactory(appServer.start, params.profile)
? await params.options.clientFactory(appServer.start, params.profile, agentDir, params.cfg)
: await import("./src/app-server/shared-client.js").then(
({ createIsolatedCodexAppServerClient }) =>
createIsolatedCodexAppServerClient({
startOptions: appServer.start,
timeoutMs,
authProfileId: params.profile,
agentDir,
authProfileStore: params.authStore,
config: params.cfg,
}),
);
const abortController = new AbortController();
@@ -166,7 +175,7 @@ async function runBoundedCodexVisionTurn(params: BoundedCodexVisionTurnParams):
{
model: params.model,
modelProvider: "openai",
cwd: params.agentDir || process.cwd(),
cwd,
approvalPolicy: "on-request",
sandbox: "read-only",
serviceName: "OpenClaw",
@@ -193,7 +202,7 @@ async function runBoundedCodexVisionTurn(params: BoundedCodexVisionTurnParams):
{
threadId: thread.thread.id,
input: params.input,
cwd: params.agentDir || process.cwd(),
cwd,
approvalPolicy: "on-request",
model: params.model,
effort: "low",
@@ -242,6 +251,8 @@ async function extractCodexStructured(
profile: req.profile,
timeoutMs: req.timeoutMs,
agentDir: req.agentDir,
authStore: req.authStore,
cfg: req.cfg,
options,
taskLabel: "structured extraction",
developerInstructions:

View File

@@ -16,6 +16,7 @@ import {
} from "openclaw/plugin-sdk/agent-harness-runtime";
import { resolveAgentWorkspaceDir } from "openclaw/plugin-sdk/agent-runtime";
import { buildMemorySystemPromptAddition } from "openclaw/plugin-sdk/core";
import { MESSAGE_TOOL_DELIVERY_HINTS } from "openclaw/plugin-sdk/message-tool-delivery-hints";
import type { CodexDynamicToolSpec, JsonValue } from "./protocol.js";
import { isJsonObject } from "./protocol.js";
import type { CodexAppServerThreadBinding } from "./session-binding.js";
@@ -584,17 +585,12 @@ export function prependCodexOpenClawPromptContext(
return [context?.trim(), deliverySection, promptSection].filter(Boolean).join("\n\n");
}
const CODEX_DELIVERY_HINT_LINES = [
"Delivery: to send a message, use the `message` tool.",
"Delivery: Final assistant text is not automatically delivered in this run. Use the `message` tool to send user-visible output.",
] as const;
function splitLeadingCodexDeliveryHint(prompt: string): {
deliveryHint?: string;
prompt: string;
} {
const trimmedStart = prompt.trimStart();
const matchedHint = CODEX_DELIVERY_HINT_LINES.find((hint) => trimmedStart.startsWith(hint));
const matchedHint = MESSAGE_TOOL_DELIVERY_HINTS.find((hint) => trimmedStart.startsWith(hint));
if (!matchedHint) {
return { prompt };
}

View File

@@ -5,6 +5,7 @@ import path from "node:path";
import {
clearRuntimeAuthProfileStoreSnapshots,
loadAuthProfileStoreForSecretsRuntime,
replaceRuntimeAuthProfileStoreSnapshots,
} from "openclaw/plugin-sdk/agent-runtime";
import { upsertAuthProfile } from "openclaw/plugin-sdk/provider-auth";
import { afterEach, describe, expect, it, vi } from "vitest";
@@ -14,6 +15,7 @@ import {
refreshCodexAppServerAuthTokens,
resolveCodexAppServerAuthAccountCacheKey,
resolveCodexAppServerAuthProfileId,
resolveCodexAppServerAuthProfileStore,
resolveCodexAppServerFallbackApiKeyCacheKey,
resolveCodexAppServerHomeDir,
resolveCodexAppServerNativeHomeDir,
@@ -179,6 +181,39 @@ async function writeCodexCliApiKeyAuthFile(codexHome: string): Promise<void> {
}
describe("bridgeCodexAppServerStartOptions", () => {
it("preserves persisted provenance when preparing a supplied base store", async () => {
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-"));
const authProfileStore = { version: 1, profiles: {} };
try {
upsertAuthProfile({
agentDir,
profileId: "openai:work",
credential: {
type: "oauth",
provider: "openai",
access: "persisted-access",
refresh: "persisted-refresh",
expires: Date.now() + 60_000,
},
});
const prepared = resolveCodexAppServerAuthProfileStore({
agentDir,
authProfileId: "openai:work",
authProfileStore,
});
expect(prepared).not.toBe(authProfileStore);
expect(prepared.runtimePersistedProfileIds).toContain("openai:work");
expect(prepared.profiles["openai:work"]).toMatchObject({
access: "persisted-access",
refresh: "persisted-refresh",
});
} finally {
await fs.rm(agentDir, { recursive: true, force: true });
}
});
it("sets agent-owned CODEX_HOME without overriding HOME for local app-server launches", async () => {
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-"));
const startOptions = createStartOptions();
@@ -576,6 +611,603 @@ describe("bridgeCodexAppServerStartOptions", () => {
}
});
it("applies a supplied scoped OAuth profile instead of persisted credentials", async () => {
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-"));
const request = vi.fn(async () => ({ type: "chatgptAuthTokens" }));
try {
upsertAuthProfile({
agentDir,
profileId: "openai:work",
credential: {
type: "oauth",
provider: "openai",
access: "persisted-access",
refresh: "persisted-refresh",
expires: Date.now() + 24 * 60 * 60_000,
accountId: "persisted-account",
},
});
const authProfileStore: AuthProfileStore = {
version: 1,
profiles: {
"openai:work": {
type: "oauth",
provider: "openai",
access: "scoped-access",
refresh: "scoped-refresh",
expires: Date.now() + 24 * 60 * 60_000,
accountId: "scoped-account",
},
},
};
await applyCodexAppServerAuthProfile({
client: { request } as never,
agentDir,
authProfileId: "openai:work",
authProfileStore,
});
expect(request).toHaveBeenCalledWith("account/login/start", {
type: "chatgptAuthTokens",
accessToken: "scoped-access",
chatgptAccountId: "scoped-account",
chatgptPlanType: null,
});
} finally {
await fs.rm(agentDir, { recursive: true, force: true });
}
});
it.each([
{ name: "without persisted same-id credentials", persistSameId: false },
{ name: "with persisted same-id credentials", persistSameId: true },
])("refreshes an expired scoped OAuth profile $name", async ({ persistSameId }) => {
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-"));
const request = vi.fn(async () => ({ type: "chatgptAuthTokens" }));
oauthMocks.refreshOpenAICodexToken.mockResolvedValueOnce({
access: "scoped-refreshed-access",
refresh: "scoped-refreshed-refresh",
expires: Date.now() + 60_000,
accountId: "scoped-refreshed-account",
});
try {
if (persistSameId) {
upsertAuthProfile({
agentDir,
profileId: "openai:work",
credential: {
type: "oauth",
provider: "openai",
access: "persisted-access",
refresh: "persisted-refresh",
expires: Date.now() + 24 * 60 * 60_000,
accountId: "persisted-account",
},
});
}
const authProfileStore: AuthProfileStore = {
version: 1,
profiles: {
"openai:work": {
type: "oauth",
provider: "openai",
access: "scoped-expired-access",
refresh: "scoped-refresh",
expires: Date.now() - 60_000,
accountId: "scoped-account",
},
},
};
await applyCodexAppServerAuthProfile({
client: { request } as never,
agentDir,
authProfileId: "openai:work",
authProfileStore,
});
expect(oauthMocks.refreshOpenAICodexToken).toHaveBeenCalledWith("scoped-refresh");
expect(request).toHaveBeenCalledWith("account/login/start", {
type: "chatgptAuthTokens",
accessToken: "scoped-refreshed-access",
chatgptAccountId: "scoped-refreshed-account",
chatgptPlanType: null,
});
expect(authProfileStore.profiles["openai:work"]).toMatchObject({
access: "scoped-refreshed-access",
accountId: "scoped-refreshed-account",
});
if (persistSameId) {
expect(
loadAuthProfileStoreForSecretsRuntime(agentDir).profiles["openai:work"],
).toMatchObject({
access: "persisted-access",
accountId: "persisted-account",
});
}
} finally {
await fs.rm(agentDir, { recursive: true, force: true });
}
});
it("routes a supplied persisted OAuth clone through canonical refresh", async () => {
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-"));
const request = vi.fn(async () => ({ type: "chatgptAuthTokens" }));
oauthMocks.refreshOpenAICodexToken.mockResolvedValueOnce({
access: "persisted-refreshed-access",
refresh: "persisted-refreshed-refresh",
expires: Date.now() + 60_000,
accountId: "persisted-account",
});
try {
upsertAuthProfile({
agentDir,
profileId: "openai:work",
credential: {
type: "oauth",
provider: "openai",
access: "persisted-expired-access",
refresh: "persisted-refresh",
expires: Date.now() - 60_000,
accountId: "persisted-account",
},
});
const authProfileStore = loadAuthProfileStoreForSecretsRuntime(agentDir);
expect(authProfileStore.runtimePersistedProfileIds).toContain("openai:work");
await applyCodexAppServerAuthProfile({
client: { request } as never,
agentDir,
authProfileId: "openai:work",
authProfileStore,
});
expect(oauthMocks.refreshOpenAICodexToken).toHaveBeenCalledWith("persisted-refresh");
expect(request).toHaveBeenCalledWith("account/login/start", {
type: "chatgptAuthTokens",
accessToken: "persisted-refreshed-access",
chatgptAccountId: "persisted-account",
chatgptPlanType: null,
});
expect(loadAuthProfileStoreForSecretsRuntime(agentDir).profiles["openai:work"]).toMatchObject(
{
access: "persisted-refreshed-access",
refresh: "persisted-refreshed-refresh",
accountId: "persisted-account",
},
);
} finally {
await fs.rm(agentDir, { recursive: true, force: true });
}
});
it("keeps a prepared persisted store aligned across rotating refresh tokens", async () => {
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-"));
oauthMocks.refreshOpenAICodexToken
.mockResolvedValueOnce({
access: "first-rotated-access",
refresh: "first-rotated-refresh",
expires: Date.now() + 60_000,
})
.mockResolvedValueOnce({
access: "second-rotated-access",
refresh: "second-rotated-refresh",
expires: Date.now() + 60_000,
});
try {
upsertAuthProfile({
agentDir,
profileId: "openai:work",
credential: {
type: "oauth",
provider: "openai",
access: "initial-access",
refresh: "initial-refresh",
expires: Date.now() + 60_000,
},
});
const authProfileStore = resolveCodexAppServerAuthProfileStore({
agentDir,
authProfileId: "openai:work",
authProfileStore: { version: 1, profiles: {} },
});
await refreshCodexAppServerAuthTokens({
agentDir,
authProfileId: "openai:work",
authProfileStore,
});
await refreshCodexAppServerAuthTokens({
agentDir,
authProfileId: "openai:work",
authProfileStore,
});
expect(oauthMocks.refreshOpenAICodexToken.mock.calls).toEqual([
["initial-refresh"],
["first-rotated-refresh"],
]);
expect(authProfileStore.profiles["openai:work"]).toMatchObject({
access: "second-rotated-access",
refresh: "second-rotated-refresh",
});
} finally {
await fs.rm(agentDir, { recursive: true, force: true });
}
});
it("does not replace a prepared persisted store changed during refresh", async () => {
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-"));
let resolveRefresh:
| ((value: { access: string; refresh: string; expires: number }) => void)
| undefined;
oauthMocks.refreshOpenAICodexToken.mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveRefresh = resolve;
}),
);
try {
upsertAuthProfile({
agentDir,
profileId: "openai:work",
credential: {
type: "oauth",
provider: "openai",
access: "initial-access",
refresh: "initial-refresh",
expires: Date.now() + 60_000,
},
});
const authProfileStore = resolveCodexAppServerAuthProfileStore({
agentDir,
authProfileId: "openai:work",
authProfileStore: { version: 1, profiles: {} },
});
const refresh = refreshCodexAppServerAuthTokens({
agentDir,
authProfileId: "openai:work",
authProfileStore,
});
await vi.waitFor(() => expect(oauthMocks.refreshOpenAICodexToken).toHaveBeenCalledTimes(1));
authProfileStore.profiles["openai:work"] = {
type: "oauth",
provider: "openai",
access: "replacement-access",
refresh: "replacement-refresh",
expires: Date.now() + 60_000,
accountId: "replacement-account",
};
resolveRefresh?.({
access: "rotated-access",
refresh: "rotated-refresh",
expires: Date.now() + 60_000,
});
await refresh;
expect(authProfileStore.profiles["openai:work"]).toMatchObject({
access: "replacement-access",
refresh: "replacement-refresh",
accountId: "replacement-account",
});
} finally {
await fs.rm(agentDir, { recursive: true, force: true });
}
});
it("keeps a runtime-external same-account OAuth profile scoped", async () => {
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-"));
const request = vi.fn(async () => ({ type: "chatgptAuthTokens" }));
oauthMocks.refreshOpenAICodexToken.mockResolvedValueOnce({
access: "scoped-refreshed-access",
refresh: "scoped-refreshed-refresh",
expires: Date.now() + 60_000,
accountId: "shared-account",
});
try {
upsertAuthProfile({
agentDir,
profileId: "openai:work",
credential: {
type: "oauth",
provider: "openai",
access: "persisted-access",
refresh: "persisted-refresh",
expires: Date.now() + 24 * 60 * 60_000,
accountId: "shared-account",
},
});
const authProfileStore: AuthProfileStore = {
version: 1,
runtimeExternalProfileIds: ["openai:work"],
runtimeExternalProfileIdsAuthoritative: true,
profiles: {
"openai:work": {
type: "oauth",
provider: "openai",
access: "scoped-expired-access",
refresh: "scoped-refresh",
expires: Date.now() - 60_000,
accountId: "shared-account",
},
},
};
await applyCodexAppServerAuthProfile({
client: { request } as never,
agentDir,
authProfileId: "openai:work",
authProfileStore,
});
expect(oauthMocks.refreshOpenAICodexToken).toHaveBeenCalledWith("scoped-refresh");
expect(request).toHaveBeenCalledWith("account/login/start", {
type: "chatgptAuthTokens",
accessToken: "scoped-refreshed-access",
chatgptAccountId: "shared-account",
chatgptPlanType: null,
});
expect(loadAuthProfileStoreForSecretsRuntime(agentDir).profiles["openai:work"]).toMatchObject(
{
access: "persisted-access",
refresh: "persisted-refresh",
accountId: "shared-account",
},
);
} finally {
await fs.rm(agentDir, { recursive: true, force: true });
}
});
it("keeps an ambiguous supplied OAuth identity scoped", async () => {
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-"));
const request = vi.fn(async () => ({ type: "chatgptAuthTokens" }));
oauthMocks.refreshOpenAICodexToken.mockResolvedValueOnce({
access: "scoped-refreshed-access",
refresh: "scoped-refreshed-refresh",
expires: Date.now() + 60_000,
});
try {
upsertAuthProfile({
agentDir,
profileId: "openai:work",
credential: {
type: "oauth",
provider: "openai",
access: "persisted-access",
refresh: "persisted-refresh",
expires: Date.now() + 24 * 60 * 60_000,
accountId: "persisted-account",
},
});
const authProfileStore: AuthProfileStore = {
version: 1,
profiles: {
"openai:work": {
type: "oauth",
provider: "openai",
access: "scoped-expired-access",
refresh: "scoped-refresh",
expires: Date.now() - 60_000,
},
},
};
await applyCodexAppServerAuthProfile({
client: { request } as never,
agentDir,
authProfileId: "openai:work",
authProfileStore,
});
expect(oauthMocks.refreshOpenAICodexToken).toHaveBeenCalledWith("scoped-refresh");
expect(request).toHaveBeenCalledWith("account/login/start", {
type: "chatgptAuthTokens",
accessToken: "scoped-refreshed-access",
chatgptAccountId: "openai:work",
chatgptPlanType: null,
});
expect(loadAuthProfileStoreForSecretsRuntime(agentDir).profiles["openai:work"]).toMatchObject(
{
access: "persisted-access",
refresh: "persisted-refresh",
accountId: "persisted-account",
},
);
} finally {
await fs.rm(agentDir, { recursive: true, force: true });
}
});
it("routes a same-identity stale persisted clone through canonical persisted auth", async () => {
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-"));
const request = vi.fn(async () => ({ type: "chatgptAuthTokens" }));
try {
upsertAuthProfile({
agentDir,
profileId: "openai:work",
credential: {
type: "oauth",
provider: "openai",
access: "stale-access",
refresh: "stale-refresh",
expires: Date.now() - 60_000,
accountId: "persisted-account",
},
});
const authProfileStore = loadAuthProfileStoreForSecretsRuntime(agentDir);
expect(authProfileStore.runtimePersistedProfileIds).toContain("openai:work");
upsertAuthProfile({
agentDir,
profileId: "openai:work",
credential: {
type: "oauth",
provider: "openai",
access: "current-access",
refresh: "current-refresh",
expires: Date.now() + 24 * 60 * 60_000,
accountId: "persisted-account",
},
});
await applyCodexAppServerAuthProfile({
client: { request } as never,
agentDir,
authProfileId: "openai:work",
authProfileStore,
});
expect(oauthMocks.refreshOpenAICodexToken).not.toHaveBeenCalled();
expect(request).toHaveBeenCalledWith("account/login/start", {
type: "chatgptAuthTokens",
accessToken: "current-access",
chatgptAccountId: "persisted-account",
chatgptPlanType: null,
});
} finally {
await fs.rm(agentDir, { recursive: true, force: true });
}
});
it("keeps a changed-identity persisted clone scoped", async () => {
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-"));
const request = vi.fn(async () => ({ type: "chatgptAuthTokens" }));
oauthMocks.refreshOpenAICodexToken.mockResolvedValueOnce({
access: "account-a-refreshed-access",
refresh: "account-a-refreshed-refresh",
expires: Date.now() + 60_000,
accountId: "account-a",
});
try {
upsertAuthProfile({
agentDir,
profileId: "openai:work",
credential: {
type: "oauth",
provider: "openai",
access: "account-a-expired-access",
refresh: "account-a-refresh",
expires: Date.now() - 60_000,
accountId: "account-a",
},
});
const authProfileStore = loadAuthProfileStoreForSecretsRuntime(agentDir);
expect(authProfileStore.runtimePersistedProfileIds).toContain("openai:work");
upsertAuthProfile({
agentDir,
profileId: "openai:work",
credential: {
type: "oauth",
provider: "openai",
access: "account-b-access",
refresh: "account-b-refresh",
expires: Date.now() + 24 * 60 * 60_000,
accountId: "account-b",
},
});
replaceRuntimeAuthProfileStoreSnapshots([{ agentDir, store: authProfileStore }]);
await applyCodexAppServerAuthProfile({
client: { request } as never,
agentDir,
authProfileId: "openai:work",
authProfileStore,
});
expect(oauthMocks.refreshOpenAICodexToken).toHaveBeenCalledWith("account-a-refresh");
expect(request).toHaveBeenCalledWith("account/login/start", {
type: "chatgptAuthTokens",
accessToken: "account-a-refreshed-access",
chatgptAccountId: "account-a",
chatgptPlanType: null,
});
expect(loadAuthProfileStoreForSecretsRuntime(agentDir).profiles["openai:work"]).toMatchObject(
{
access: "account-b-access",
refresh: "account-b-refresh",
accountId: "account-b",
},
);
} finally {
await fs.rm(agentDir, { recursive: true, force: true });
}
});
it("serializes concurrent refreshes of the same scoped OAuth profile", async () => {
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-"));
const request = vi.fn(async () => ({ type: "chatgptAuthTokens" }));
let resolveRefresh:
| ((value: { access: string; refresh: string; expires: number; accountId: string }) => void)
| undefined;
oauthMocks.refreshOpenAICodexToken.mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveRefresh = resolve;
}),
);
const authProfileStore: AuthProfileStore = {
version: 1,
profiles: {
"openai:work": {
type: "oauth",
provider: "openai",
access: "scoped-expired-access",
refresh: "scoped-refresh",
expires: Date.now() - 60_000,
accountId: "scoped-account",
},
},
};
try {
const first = applyCodexAppServerAuthProfile({
client: { request } as never,
agentDir,
authProfileId: "openai:work",
authProfileStore,
});
const second = applyCodexAppServerAuthProfile({
client: { request } as never,
agentDir,
authProfileId: "openai:work",
authProfileStore,
});
await vi.waitFor(() => expect(oauthMocks.refreshOpenAICodexToken).toHaveBeenCalledTimes(1));
resolveRefresh?.({
access: "scoped-refreshed-access",
refresh: "scoped-refreshed-refresh",
expires: Date.now() + 60_000,
accountId: "scoped-refreshed-account",
});
await Promise.all([first, second]);
expect(oauthMocks.refreshOpenAICodexToken).toHaveBeenCalledTimes(1);
expect(request).toHaveBeenCalledTimes(2);
expect(request).toHaveBeenNthCalledWith(1, "account/login/start", {
type: "chatgptAuthTokens",
accessToken: "scoped-refreshed-access",
chatgptAccountId: "scoped-refreshed-account",
chatgptPlanType: null,
});
expect(request).toHaveBeenNthCalledWith(2, "account/login/start", {
type: "chatgptAuthTokens",
accessToken: "scoped-refreshed-access",
chatgptAccountId: "scoped-refreshed-account",
chatgptPlanType: null,
});
} finally {
resolveRefresh?.({
access: "cleanup-access",
refresh: "cleanup-refresh",
expires: Date.now() + 60_000,
accountId: "cleanup-account",
});
await fs.rm(agentDir, { recursive: true, force: true });
}
});
it("leaves native app-server auth untouched when auth bridging is disabled", async () => {
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-"));
const request = vi.fn(async () => ({ requiresOpenaiAuth: true }));

View File

@@ -4,9 +4,10 @@ import fsSync from "node:fs";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { isDeepStrictEqual } from "node:util";
import {
ensureAuthProfileStore,
ensureAuthProfileStoreWithoutExternalProfiles,
findPersistedAuthProfileCredential,
loadAuthProfileStoreForSecretsRuntime,
refreshOAuthCredentialForRuntime,
resolveAuthProfileOrder,
@@ -18,6 +19,7 @@ import {
type AuthProfileStore,
type OAuthCredential,
} from "openclaw/plugin-sdk/agent-runtime";
import { hasUsableOAuthCredential } from "openclaw/plugin-sdk/provider-auth";
import type { CodexAppServerClient } from "./client.js";
import type { CodexAppServerStartOptions } from "./config.js";
import type {
@@ -48,11 +50,16 @@ const CODEX_AUTH_JSON_FILENAME = "auth.json";
const CODEX_HOME_DIRNAME = ".codex";
type AuthProfileOrderConfig = Parameters<typeof resolveAuthProfileOrder>[0]["cfg"];
const scopedOAuthRefreshQueues = new WeakMap<
AuthProfileStore,
Map<string, Promise<OAuthCredential>>
>();
export async function bridgeCodexAppServerStartOptions(params: {
startOptions: CodexAppServerStartOptions;
agentDir: string;
authProfileId?: string | null;
authProfileStore?: AuthProfileStore;
config?: AuthProfileOrderConfig;
}): Promise<CodexAppServerStartOptions> {
if (params.startOptions.transport !== "stdio") {
@@ -65,9 +72,10 @@ export async function bridgeCodexAppServerStartOptions(params: {
if (params.authProfileId === null) {
return isolatedStartOptions;
}
const store = ensureCodexAppServerAuthProfileStore({
const store = resolveCodexAppServerAuthProfileStore({
agentDir: params.agentDir,
authProfileId: params.authProfileId,
authProfileStore: params.authProfileStore,
config: params.config,
});
const authProfileId = resolveCodexAppServerAuthProfileId({
@@ -103,13 +111,15 @@ export function resolveCodexAppServerAuthProfileId(params: {
export function resolveCodexAppServerAuthProfileIdForAgent(params: {
authProfileId?: string;
authProfileStore?: AuthProfileStore;
agentDir?: string;
config?: AuthProfileOrderConfig;
}): string | undefined {
const agentDir = params.agentDir?.trim() || resolveDefaultAgentDir(params.config ?? {});
const store = ensureCodexAppServerAuthProfileStore({
const store = resolveCodexAppServerAuthProfileStore({
agentDir,
authProfileId: params.authProfileId,
authProfileStore: params.authProfileStore,
config: params.config,
});
return resolveCodexAppServerAuthProfileId({
@@ -132,7 +142,7 @@ function ensureCodexAppServerAuthProfileStore(params: {
});
}
function resolveCodexAppServerAuthProfileStore(params: {
export function resolveCodexAppServerAuthProfileStore(params: {
agentDir?: string;
authProfileId?: string;
authProfileStore?: AuthProfileStore;
@@ -163,13 +173,41 @@ function resolveCodexAppServerAuthProfileStore(params: {
...params.authProfileStore.order,
}
: undefined;
const profiles = {
...overlaidStore.profiles,
...params.authProfileStore.profiles,
};
const suppliedProfileIds = new Set(Object.keys(params.authProfileStore.profiles));
const mergeRuntimeProfileIds = (overlaidIds?: string[], suppliedIds?: string[]) => [
...(overlaidIds ?? []).filter((profileId) => !suppliedProfileIds.has(profileId)),
...(suppliedIds ?? []),
];
const runtimePersistedProfileIds = mergeRuntimeProfileIds(
overlaidStore.runtimePersistedProfileIds,
params.authProfileStore.runtimePersistedProfileIds,
).filter((profileId) => profiles[profileId]);
const runtimeExternalProfileIds = mergeRuntimeProfileIds(
overlaidStore.runtimeExternalProfileIds,
params.authProfileStore.runtimeExternalProfileIds,
).filter((profileId) => profiles[profileId]);
const runtimeExternalProfileIdsAuthoritative =
overlaidStore.runtimeExternalProfileIdsAuthoritative === true ||
params.authProfileStore.runtimeExternalProfileIdsAuthoritative === true;
return {
...params.authProfileStore,
...(order ? { order } : {}),
profiles: {
...overlaidStore.profiles,
...params.authProfileStore.profiles,
},
profiles,
...(runtimePersistedProfileIds.length > 0
? { runtimePersistedProfileIds: [...new Set(runtimePersistedProfileIds)] }
: {}),
...(runtimeExternalProfileIds.length > 0 || runtimeExternalProfileIdsAuthoritative
? {
runtimeExternalProfileIds: [...new Set(runtimeExternalProfileIds)],
...(runtimeExternalProfileIdsAuthoritative
? { runtimeExternalProfileIdsAuthoritative: true }
: {}),
}
: {}),
};
}
@@ -339,6 +377,7 @@ export async function applyCodexAppServerAuthProfile(params: {
client: CodexAppServerClient;
agentDir: string;
authProfileId?: string | null;
authProfileStore?: AuthProfileStore;
startOptions?: CodexAppServerStartOptions;
config?: AuthProfileOrderConfig;
}): Promise<void> {
@@ -348,6 +387,7 @@ export async function applyCodexAppServerAuthProfile(params: {
const loginParams = await resolveCodexAppServerAuthProfileLoginParams({
agentDir: params.agentDir,
authProfileId: params.authProfileId,
authProfileStore: params.authProfileStore,
config: params.config,
});
if (!loginParams) {
@@ -371,6 +411,7 @@ export async function applyCodexAppServerAuthProfile(params: {
function resolveCodexAppServerAuthProfileLoginParams(params: {
agentDir: string;
authProfileId?: string;
authProfileStore?: AuthProfileStore;
config?: AuthProfileOrderConfig;
}): Promise<CodexLoginAccountParams | undefined> {
return resolveCodexAppServerAuthProfileLoginParamsInternal(params);
@@ -379,6 +420,7 @@ function resolveCodexAppServerAuthProfileLoginParams(params: {
export async function refreshCodexAppServerAuthTokens(params: {
agentDir: string;
authProfileId?: string;
authProfileStore?: AuthProfileStore;
config?: AuthProfileOrderConfig;
}): Promise<CodexChatgptAuthTokensRefreshResponse> {
const loginParams = await resolveCodexAppServerAuthProfileLoginParamsInternal({
@@ -398,12 +440,14 @@ export async function refreshCodexAppServerAuthTokens(params: {
async function resolveCodexAppServerAuthProfileLoginParamsInternal(params: {
agentDir: string;
authProfileId?: string;
authProfileStore?: AuthProfileStore;
forceOAuthRefresh?: boolean;
config?: AuthProfileOrderConfig;
}): Promise<CodexLoginAccountParams | undefined> {
const store = ensureCodexAppServerAuthProfileStore({
const store = resolveCodexAppServerAuthProfileStore({
agentDir: params.agentDir,
authProfileId: params.authProfileId,
authProfileStore: params.authProfileStore,
config: params.config,
});
const profileId = resolveCodexAppServerAuthProfileId({
@@ -425,6 +469,8 @@ async function resolveCodexAppServerAuthProfileLoginParamsInternal(params: {
}
const loginParams = await resolveLoginParamsForCredential(profileId, credential, {
agentDir: params.agentDir,
store,
preferStoreCredential: Boolean(params.authProfileStore?.profiles[profileId]),
forceOAuthRefresh: params.forceOAuthRefresh === true,
config: params.config,
});
@@ -509,14 +555,22 @@ function resolveCodexCliAuthFileApiKeyCacheKey(env: NodeJS.ProcessEnv): string |
async function resolveLoginParamsForCredential(
profileId: string,
credential: AuthProfileCredential,
params: { agentDir: string; forceOAuthRefresh: boolean; config?: AuthProfileOrderConfig },
params: {
agentDir: string;
store: AuthProfileStore;
preferStoreCredential: boolean;
forceOAuthRefresh: boolean;
config?: AuthProfileOrderConfig;
},
): Promise<CodexLoginAccountParams | undefined> {
// Runtime honors the persisted auth profile type. Shape-based remediation
// belongs at credential entry time so request handling does not preemptively
// reject opaque provider credentials.
if (credential.type === "api_key") {
const resolved = await resolveApiKeyForProfile({
store: ensureAuthProfileStore(params.agentDir, { allowKeychainPrompt: false }),
store: params.preferStoreCredential
? params.store
: ensureAuthProfileStore(params.agentDir, { allowKeychainPrompt: false }),
profileId,
agentDir: params.agentDir,
});
@@ -525,7 +579,9 @@ async function resolveLoginParamsForCredential(
}
if (credential.type === "token") {
const resolved = await resolveApiKeyForProfile({
store: ensureAuthProfileStore(params.agentDir, { allowKeychainPrompt: false }),
store: params.preferStoreCredential
? params.store
: ensureAuthProfileStore(params.agentDir, { allowKeychainPrompt: false }),
profileId,
agentDir: params.agentDir,
});
@@ -539,6 +595,8 @@ async function resolveLoginParamsForCredential(
}
const resolvedCredential = await resolveOAuthCredentialForCodexAppServer(profileId, credential, {
agentDir: params.agentDir,
store: params.store,
preferStoreCredential: params.preferStoreCredential,
forceRefresh: params.forceOAuthRefresh,
config: params.config,
});
@@ -551,22 +609,40 @@ async function resolveLoginParamsForCredential(
async function resolveOAuthCredentialForCodexAppServer(
profileId: string,
credential: OAuthCredential,
params: { agentDir: string; forceRefresh: boolean; config?: AuthProfileOrderConfig },
params: {
agentDir: string;
store: AuthProfileStore;
preferStoreCredential: boolean;
forceRefresh: boolean;
config?: AuthProfileOrderConfig;
},
): Promise<OAuthCredential> {
const ownerAgentDir = resolvePersistedAuthProfileOwnerAgentDir({
agentDir: params.agentDir,
profileId,
});
const store = ensureCodexAppServerAuthProfileStore({
const persistedCredential = findPersistedAuthProfileCredential({
agentDir: ownerAgentDir,
authProfileId: profileId,
config: params.config,
profileId,
});
const persistedStore = ensureAuthProfileStoreWithoutExternalProfiles(ownerAgentDir, {
allowKeychainPrompt: false,
});
const persistedCredential = persistedStore.profiles[profileId];
const useScopedCredential =
params.preferStoreCredential &&
shouldUseScopedOAuthCredential({
store: params.store,
profileId,
persistedCredential,
suppliedCredential: credential,
config: params.config,
});
const store = useScopedCredential
? params.store
: ensureCodexAppServerAuthProfileStore({
agentDir: ownerAgentDir,
authProfileId: profileId,
config: params.config,
});
const persistedOAuthCredential =
!useScopedCredential &&
persistedCredential?.type === "oauth" &&
isCodexAppServerAuthProvider(persistedCredential.provider, params.config)
? persistedCredential
@@ -577,6 +653,14 @@ async function resolveOAuthCredentialForCodexAppServer(
isCodexAppServerAuthProvider(ownerCredential.provider, params.config)
? ownerCredential
: undefined;
if (useScopedCredential && overlaidOAuthCredential) {
return await resolveScopedOAuthCredential({
store,
profileId,
credential: overlaidOAuthCredential,
forceRefresh: params.forceRefresh,
});
}
if (params.forceRefresh && !persistedOAuthCredential && overlaidOAuthCredential) {
const refreshedRuntimeCredential = await refreshOAuthCredentialForRuntime({
credential: overlaidOAuthCredential,
@@ -593,18 +677,111 @@ async function resolveOAuthCredentialForCodexAppServer(
agentDir: ownerAgentDir,
forceRefresh: params.forceRefresh && Boolean(persistedOAuthCredential),
});
const refreshed = loadAuthProfileStoreForSecretsRuntime(ownerAgentDir).profiles[profileId];
const storedCredential = store.profiles[profileId];
const candidate =
const refreshed = useScopedCredential
? undefined
: loadAuthProfileStoreForSecretsRuntime(ownerAgentDir).profiles[profileId];
const refreshedOAuthCredential =
refreshed?.type === "oauth" && isCodexAppServerAuthProvider(refreshed.provider, params.config)
? refreshed
: storedCredential?.type === "oauth" &&
isCodexAppServerAuthProvider(storedCredential.provider, params.config)
? storedCredential
: credential;
: undefined;
if (refreshedOAuthCredential && isDeepStrictEqual(params.store.profiles[profileId], credential)) {
// Persisted refreshes rotate refresh tokens. Keep an isolated prepared
// store aligned without reverting a concurrent caller-owned replacement.
params.store.profiles[profileId] = refreshedOAuthCredential;
}
const storedCredential = store.profiles[profileId];
const candidate = refreshedOAuthCredential
? refreshedOAuthCredential
: storedCredential?.type === "oauth" &&
isCodexAppServerAuthProvider(storedCredential.provider, params.config)
? storedCredential
: credential;
return resolved?.apiKey ? { ...candidate, access: resolved.apiKey } : candidate;
}
function shouldUseScopedOAuthCredential(params: {
store: AuthProfileStore;
profileId: string;
persistedCredential: AuthProfileCredential | undefined;
suppliedCredential: OAuthCredential;
config?: AuthProfileOrderConfig;
}): boolean {
if (!params.store.runtimePersistedProfileIds?.includes(params.profileId)) {
return true;
}
const persisted = params.persistedCredential;
if (persisted?.type !== "oauth") {
return true;
}
if (
resolveProviderIdForAuth(persisted.provider, { config: params.config }) !==
resolveProviderIdForAuth(params.suppliedCredential.provider, { config: params.config })
) {
return true;
}
return (
!isDeepStrictEqual(persisted, params.suppliedCredential) &&
!hasMatchingOAuthIdentity(persisted, params.suppliedCredential)
);
}
function hasMatchingOAuthIdentity(persisted: OAuthCredential, supplied: OAuthCredential): boolean {
const persistedAccountId = persisted.accountId?.trim();
const suppliedAccountId = supplied.accountId?.trim();
if (persistedAccountId && suppliedAccountId) {
return persistedAccountId === suppliedAccountId;
}
const persistedEmail = persisted.email?.trim().toLowerCase();
const suppliedEmail = supplied.email?.trim().toLowerCase();
return Boolean(persistedEmail && suppliedEmail && persistedEmail === suppliedEmail);
}
async function resolveScopedOAuthCredential(params: {
store: AuthProfileStore;
profileId: string;
credential: OAuthCredential;
forceRefresh: boolean;
}): Promise<OAuthCredential> {
const existingRefresh = scopedOAuthRefreshQueues.get(params.store)?.get(params.profileId);
if (existingRefresh) {
return await existingRefresh;
}
if (!params.forceRefresh && hasUsableOAuthCredential(params.credential)) {
return params.credential;
}
const storeRefreshes = scopedOAuthRefreshQueues.get(params.store) ?? new Map();
scopedOAuthRefreshQueues.set(params.store, storeRefreshes);
const refresh = (async () => {
const current = params.store.profiles[params.profileId];
const credential = current?.type === "oauth" ? current : params.credential;
if (!params.forceRefresh && hasUsableOAuthCredential(credential)) {
return credential;
}
const refreshed = await refreshOAuthCredentialForRuntime({ credential });
if (!refreshed?.access?.trim()) {
throw new Error(`Codex app-server auth profile "${params.profileId}" could not refresh.`);
}
if (!isDeepStrictEqual(params.store.profiles[params.profileId], credential)) {
throw new Error(
`Codex app-server auth profile "${params.profileId}" changed while refreshing.`,
);
}
params.store.profiles[params.profileId] = refreshed;
return refreshed;
})();
storeRefreshes.set(params.profileId, refresh);
try {
return await refresh;
} finally {
// Scoped stores are process-local; serialize their rotating refresh token
// and release the queue entry with the refresh that owns it.
if (storeRefreshes.get(params.profileId) === refresh) {
storeRefreshes.delete(params.profileId);
}
}
}
function isCodexAppServerAuthProvider(provider: string, config?: AuthProfileOrderConfig): boolean {
const resolvedProvider = resolveProviderIdForAuth(provider, { config });
return (

View File

@@ -183,6 +183,7 @@ describe("dynamic tool execution helpers", () => {
vi.useFakeTimers();
let capturedSignal: AbortSignal | undefined;
const onTimeout = vi.fn();
const onAgentToolResult = vi.fn();
const response = handleDynamicToolCallWithTimeout({
call: {
threadId: "thread-1",
@@ -200,6 +201,7 @@ describe("dynamic tool execution helpers", () => {
},
signal: new AbortController().signal,
timeoutMs: 1,
onAgentToolResult,
onTimeout,
});
@@ -216,6 +218,64 @@ describe("dynamic tool execution helpers", () => {
});
expect(capturedSignal?.aborted).toBe(true);
expect(onTimeout).toHaveBeenCalledTimes(1);
expect(onAgentToolResult).toHaveBeenCalledWith({
toolName: "message",
result: {
content: [
{
type: "text",
text: "OpenClaw dynamic tool call timed out after 1ms while running tool message.",
},
],
details: {
status: "failed",
error: "OpenClaw dynamic tool call timed out after 1ms while running tool message.",
},
},
isError: true,
});
});
it("reports pre-execution aborts to the private result observer", async () => {
const controller = new AbortController();
controller.abort(new Error("run cancelled"));
const onAgentToolResult = vi.fn();
const handleToolCall = vi.fn();
const result = await handleDynamicToolCallWithTimeout({
call: {
threadId: "thread-1",
turnId: "turn-1",
callId: "call-aborted",
namespace: null,
tool: "memory_search",
arguments: {},
},
toolBridge: { handleToolCall },
signal: controller.signal,
timeoutMs: 1_000,
onAgentToolResult,
});
expect(result).toEqual({
success: false,
contentItems: [
{ type: "inputText", text: "OpenClaw dynamic tool call aborted before execution." },
],
});
expect(handleToolCall).not.toHaveBeenCalled();
expect(onAgentToolResult).toHaveBeenCalledOnce();
expect(onAgentToolResult).toHaveBeenCalledWith({
toolName: "memory_search",
result: {
content: [{ type: "text", text: "OpenClaw dynamic tool call aborted before execution." }],
details: {
status: "failed",
error: "OpenClaw dynamic tool call aborted before execution.",
},
},
isError: true,
});
});
it("logs process poll timeout context separately from session idle", async () => {

View File

@@ -126,10 +126,41 @@ export async function handleDynamicToolCallWithTimeout(params: {
toolBridge: Pick<CodexDynamicToolBridge, "handleToolCall">;
signal: AbortSignal;
timeoutMs: number;
onAgentToolResult?: EmbeddedRunAttemptParams["onAgentToolResult"];
onTimeout?: () => void;
}): Promise<CodexDynamicToolCallResponse> {
// Timeout or run abort can win while a tool ignores cancellation. Keep the
// private observer terminal result exactly once across those competing paths.
let didNotifyAgentToolResult = false;
const notifyAgentToolResult = (
event: Parameters<NonNullable<EmbeddedRunAttemptParams["onAgentToolResult"]>>[0],
) => {
if (didNotifyAgentToolResult) {
return;
}
didNotifyAgentToolResult = true;
try {
params.onAgentToolResult?.(event);
} catch (error) {
embeddedAgentLog.warn(
`onAgentToolResult handler failed: tool=${params.call.tool} error=${String(error)}`,
);
}
};
const notifyFailedToolResult = (message: string) => {
notifyAgentToolResult({
toolName: params.call.tool,
result: {
content: [{ type: "text", text: message }],
details: { status: "failed", error: message },
},
isError: true,
});
};
if (params.signal.aborted) {
return failedDynamicToolResponse("OpenClaw dynamic tool call aborted before execution.");
const message = "OpenClaw dynamic tool call aborted before execution.";
notifyFailedToolResult(message);
return failedDynamicToolResponse(message);
}
const controller = new AbortController();
@@ -139,6 +170,7 @@ export async function handleDynamicToolCallWithTimeout(params: {
const abortFromRun = () => {
const message = "OpenClaw dynamic tool call aborted.";
controller.abort(params.signal.reason ?? new Error(message));
notifyFailedToolResult(message);
resolveAbort?.(failedDynamicToolResponse(message, { sideEffectEvidence: true }));
};
const abortPromise = new Promise<CodexDynamicToolCallResponse>((resolve) => {
@@ -155,6 +187,7 @@ export async function handleDynamicToolCallWithTimeout(params: {
...timeoutDetails.meta,
consoleMessage: timeoutDetails.consoleMessage,
});
notifyFailedToolResult(timeoutDetails.responseMessage);
resolve(
failedDynamicToolResponse(timeoutDetails.responseMessage, { sideEffectEvidence: true }),
);
@@ -167,13 +200,22 @@ export async function handleDynamicToolCallWithTimeout(params: {
if (params.signal.aborted) {
abortFromRun();
}
return await Promise.race([
params.toolBridge.handleToolCall(params.call, { signal: controller.signal }),
const response = await Promise.race([
params.toolBridge.handleToolCall(params.call, {
signal: controller.signal,
onAgentToolResult: notifyAgentToolResult,
}),
abortPromise,
timeoutPromise,
]);
if (!response.success && !didNotifyAgentToolResult) {
notifyFailedToolResult(readDynamicToolResponseText(response));
}
return response;
} catch (error) {
return failedDynamicToolResponse(error instanceof Error ? error.message : String(error), {
const message = error instanceof Error ? error.message : String(error);
notifyFailedToolResult(message);
return failedDynamicToolResponse(message, {
sideEffectEvidence: true,
});
} finally {
@@ -188,6 +230,16 @@ export async function handleDynamicToolCallWithTimeout(params: {
}
}
function readDynamicToolResponseText(response: CodexDynamicToolCallResponse): string {
const text = response.contentItems
.flatMap((item) =>
item.type === "inputText" && typeof item.text === "string" ? [item.text] : [],
)
.join("\n")
.trim();
return text || "OpenClaw dynamic tool call failed.";
}
function failedDynamicToolResponse(
message: string,
options?: { sideEffectEvidence?: boolean },

View File

@@ -222,6 +222,7 @@ describe("createCodexDynamicToolBridge", () => {
it("can register a durable tool schema while denying execution for the current turn", async () => {
const heartbeatExecute = vi.fn(async () => textToolResult("heartbeat recorded"));
const onAgentToolResult = vi.fn();
const bridge = createCodexDynamicToolBridge({
tools: [createTool({ name: "message" })],
registeredTools: [
@@ -237,14 +238,17 @@ describe("createCodexDynamicToolBridge", () => {
HEARTBEAT_RESPONSE_TOOL_NAME,
]);
const result = await bridge.handleToolCall({
threadId: "thread-1",
turnId: "turn-1",
callId: "call-1",
namespace: null,
tool: HEARTBEAT_RESPONSE_TOOL_NAME,
arguments: {},
});
const result = await bridge.handleToolCall(
{
threadId: "thread-1",
turnId: "turn-1",
callId: "call-1",
namespace: null,
tool: HEARTBEAT_RESPONSE_TOOL_NAME,
arguments: {},
},
{ onAgentToolResult },
);
expect(result).toEqual({
success: false,
@@ -256,6 +260,22 @@ describe("createCodexDynamicToolBridge", () => {
],
});
expect(heartbeatExecute).not.toHaveBeenCalled();
expect(onAgentToolResult).toHaveBeenCalledWith({
toolName: HEARTBEAT_RESPONSE_TOOL_NAME,
result: {
content: [
{
type: "text",
text: `OpenClaw tool is not available for this turn: ${HEARTBEAT_RESPONSE_TOOL_NAME}`,
},
],
details: {
status: "failed",
error: `OpenClaw tool is not available for this turn: ${HEARTBEAT_RESPONSE_TOOL_NAME}`,
},
},
isError: true,
});
});
it("keeps available and registered schemas paired with their tools", () => {
@@ -1027,6 +1047,152 @@ describe("createCodexDynamicToolBridge", () => {
expectContextFields(callArg(handler, 0, 1, "middleware context"), { runtime: "codex" });
});
it("keeps unrecognized non-success statuses fail-closed", async () => {
const onAgentToolResult = vi.fn();
const bridge = createCodexDynamicToolBridge({
tools: [
createTool({
name: "exec",
execute: vi.fn(async () =>
textToolResult("Approval is unavailable.", { status: "approval-unavailable" }),
),
}),
],
signal: new AbortController().signal,
});
const result = await bridge.handleToolCall(
{
threadId: "thread-1",
turnId: "turn-1",
callId: "call-1",
namespace: null,
tool: "exec",
arguments: { command: "pwd" },
},
{ onAgentToolResult },
);
expect(result).toMatchObject({ success: false });
expect(onAgentToolResult).toHaveBeenCalledWith({
toolName: "exec",
result: textToolResult("Approval is unavailable.", { status: "approval-unavailable" }),
isError: true,
});
});
it("preserves explicitly successful cancellation outcomes", async () => {
const onAgentToolResult = vi.fn();
const cancelledResult = textToolResult("Approval rejected.", {
ok: true,
status: "cancelled",
});
const bridge = createCodexDynamicToolBridge({
tools: [
createTool({
name: "lobster",
execute: vi.fn(async () => cancelledResult),
}),
],
signal: new AbortController().signal,
});
const result = await bridge.handleToolCall(
{
threadId: "thread-1",
turnId: "turn-1",
callId: "call-1",
namespace: null,
tool: "lobster",
arguments: {},
},
{ onAgentToolResult },
);
expect(result).toMatchObject({ success: true });
expect(onAgentToolResult).toHaveBeenCalledWith({
toolName: "lobster",
result: cancelledResult,
isError: false,
});
});
it("reports sanitized dynamic tool results to the private result observer", async () => {
const onAgentToolResult = vi.fn();
const bridge = createCodexDynamicToolBridge({
tools: [
createTool({
name: "memory_lookup_custom",
execute: vi.fn(async () =>
textToolResult("OPENROUTER_API_KEY=sk-or-v1-abcdef0123456789", {
status: "failed",
error: "backend unavailable",
}),
),
}),
],
signal: new AbortController().signal,
});
await bridge.handleToolCall(
{
threadId: "thread-1",
turnId: "turn-1",
callId: "call-1",
namespace: null,
tool: "memory_lookup_custom",
arguments: {},
},
{ onAgentToolResult },
);
expect(onAgentToolResult).toHaveBeenCalledOnce();
expect(onAgentToolResult).toHaveBeenCalledWith({
toolName: "memory_lookup_custom",
result: {
content: [{ type: "text", text: "OPENROUTER_API_KEY=sk-or-…6789" }],
details: { status: "failed", error: "backend unavailable" },
},
isError: true,
});
});
it("reports thrown dynamic tool failures to the private result observer", async () => {
const onAgentToolResult = vi.fn();
const bridge = createCodexDynamicToolBridge({
tools: [
createTool({
name: "memory_lookup_custom",
execute: vi.fn(async () => {
throw new Error("backend unavailable");
}),
}),
],
signal: new AbortController().signal,
});
await bridge.handleToolCall(
{
threadId: "thread-1",
turnId: "turn-1",
callId: "call-1",
namespace: null,
tool: "memory_lookup_custom",
arguments: {},
},
{ onAgentToolResult },
);
expect(onAgentToolResult).toHaveBeenCalledWith({
toolName: "memory_lookup_custom",
result: {
content: [{ type: "text", text: "backend unavailable" }],
details: { status: "failed", error: "backend unavailable" },
},
isError: true,
});
});
it("preserves terminal async tool results without marking them as errors", async () => {
const bridge = createBridgeWithToolResult("image_generate", {
content: [{ type: "text", text: "Background task started." }],

View File

@@ -12,11 +12,13 @@ import {
embeddedAgentLog,
type EmbeddedRunAttemptParams,
isToolWrappedWithBeforeToolCallHook,
isToolResultError,
isMessagingTool,
isMessagingToolSendAction,
normalizeHeartbeatToolResponse,
projectRuntimeToolInputSchema,
runAgentHarnessAfterToolCallHook,
sanitizeToolResult,
setBeforeToolCallDiagnosticsEnabled,
type AnyAgentTool,
type HeartbeatToolResponse,
@@ -71,7 +73,10 @@ export type CodexDynamicToolBridge = {
specs: CodexDynamicToolSpec[];
handleToolCall: (
params: CodexDynamicToolCallParams,
options?: { signal?: AbortSignal },
options?: {
signal?: AbortSignal;
onAgentToolResult?: EmbeddedRunAttemptParams["onAgentToolResult"];
},
) => Promise<CodexDynamicToolCallResponse>;
telemetry: {
didSendViaMessagingTool: boolean;
@@ -155,7 +160,6 @@ export function createCodexDynamicToolBridge(params: {
...ALWAYS_DIRECT_DYNAMIC_TOOL_NAMES,
...(params.directToolNames ?? []),
]);
return {
availableSpecs: availableTools.map((entry) =>
createCodexDynamicToolSpec({
@@ -175,19 +179,28 @@ export function createCodexDynamicToolBridge(params: {
handleToolCall: async (call, options) => {
const toolEntry = toolMap.get(call.tool);
if (!toolEntry) {
const message = registeredToolNames.has(call.tool)
? `OpenClaw tool is not available for this turn: ${call.tool}`
: `Unknown OpenClaw tool: ${call.tool}`;
notifyAgentToolResult(
options?.onAgentToolResult,
call.tool,
failedToolResult(message),
true,
);
if (registeredToolNames.has(call.tool)) {
return {
contentItems: [
{
type: "inputText",
text: `OpenClaw tool is not available for this turn: ${call.tool}`,
text: message,
},
],
success: false,
};
}
return {
contentItems: [{ type: "inputText", text: `Unknown OpenClaw tool: ${call.tool}` }],
contentItems: [{ type: "inputText", text: message }],
success: false,
};
}
@@ -202,7 +215,7 @@ export function createCodexDynamicToolBridge(params: {
const preparedArgs = tool.prepareArguments ? tool.prepareArguments(args) : args;
didStartExecution = true;
const rawResult = await tool.execute(call.callId, preparedArgs, signal);
const rawIsError = isToolResultError(rawResult);
const rawIsError = isCodexToolResultError(rawResult);
const middlewareResult = await middlewareRunner.applyToolResultMiddleware({
threadId: call.threadId,
turnId: call.turnId,
@@ -220,7 +233,8 @@ export function createCodexDynamicToolBridge(params: {
args,
result: middlewareResult,
});
const resultIsError = rawIsError || isToolResultError(result);
const resultIsError = rawIsError || isCodexToolResultError(result);
notifyAgentToolResult(options?.onAgentToolResult, toolName, result, resultIsError);
collectToolTelemetry({
toolName,
args,
@@ -262,6 +276,13 @@ export function createCodexDynamicToolBridge(params: {
);
return withSideEffectEvidence(response, terminalType !== "blocked");
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
notifyAgentToolResult(
options?.onAgentToolResult,
toolName,
failedToolResult(errorMessage),
true,
);
collectToolTelemetry({
toolName,
args,
@@ -278,7 +299,7 @@ export function createCodexDynamicToolBridge(params: {
sessionKey: toolResultHookContext.sessionKey,
channelId: toolResultHookContext.channelId,
startArgs: args,
error: error instanceof Error ? error.message : String(error),
error: errorMessage,
startedAt,
});
return withSideEffectEvidence(
@@ -287,7 +308,7 @@ export function createCodexDynamicToolBridge(params: {
contentItems: [
{
type: "inputText",
text: error instanceof Error ? error.message : String(error),
text: errorMessage,
},
],
success: false,
@@ -301,6 +322,32 @@ export function createCodexDynamicToolBridge(params: {
};
}
function notifyAgentToolResult(
observer: EmbeddedRunAttemptParams["onAgentToolResult"] | undefined,
toolName: string,
result: unknown,
isError: boolean,
) {
try {
observer?.({
toolName,
result: sanitizeToolResult(result),
isError,
});
} catch (error) {
embeddedAgentLog.warn(
`onAgentToolResult handler failed: tool=${toolName} error=${String(error)}`,
);
}
}
function failedToolResult(message: string): AgentToolResult<unknown> {
return {
content: [{ type: "text", text: message }],
details: { status: "failed", error: message },
};
}
function wrapProjectedCodexDynamicTools(
tools: readonly ProjectedCodexDynamicTool[],
hookContext: CodexDynamicToolHookContext | undefined,
@@ -688,11 +735,17 @@ function readPositiveInteger(value: unknown): number | undefined {
return Math.floor(value);
}
function isToolResultError(result: AgentToolResult<unknown>): boolean {
function isCodexToolResultError(result: AgentToolResult<unknown>): boolean {
if (isToolResultError(result)) {
return true;
}
const details = result.details;
if (!isRecord(details)) {
return false;
}
if (details.ok === true || details.success === true) {
return false;
}
if (details.timedOut === true) {
return true;
}

View File

@@ -13,6 +13,7 @@ import {
} from "openclaw/plugin-sdk/diagnostic-runtime";
import { initializeGlobalHookRunner, registerInternalHook } from "openclaw/plugin-sdk/hook-runtime";
import { registerMemoryCapability } from "openclaw/plugin-sdk/memory-core-host-runtime-core";
import { MESSAGE_TOOL_DELIVERY_HINTS } from "openclaw/plugin-sdk/message-tool-delivery-hints";
import { registerPluginCommand } from "openclaw/plugin-sdk/plugin-runtime";
import { createMockPluginRegistry } from "openclaw/plugin-sdk/plugin-test-runtime";
import { describe, expect, it, vi } from "vitest";
@@ -1290,33 +1291,35 @@ 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: [],
};
for (const [index, deliveryHint] of MESSAGE_TOOL_DELIVERY_HINTS.entries()) {
const sessionFile = path.join(tempDir, `session-delivery-hint-${index}.jsonl`);
const workspaceDir = path.join(tempDir, `workspace-delivery-hint-${index}`);
const harness = createStartedThreadHarness();
const params = createParams(sessionFile, workspaceDir);
params.prompt = `${deliveryHint}\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 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:");
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(deliveryHint);
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 () => {

View File

@@ -1846,6 +1846,7 @@ export async function runCodexAppServerAttempt(
toolBridge,
signal: runAbortController.signal,
timeoutMs: dynamicToolTimeoutMs,
onAgentToolResult: params.onAgentToolResult,
onTimeout: () => {
trajectoryRecorder?.recordEvent("tool.timeout", {
threadId: call.threadId,

View File

@@ -13,6 +13,14 @@ const mocks = vi.hoisted(() => ({
resolveCodexAppServerAuthProfileIdForAgent: vi.fn(
(params?: { authProfileId?: string }) => params?.authProfileId,
),
resolveCodexAppServerAuthProfileStore: vi.fn(
(params?: { authProfileStore?: unknown }) => params?.authProfileStore,
),
refreshCodexAppServerAuthTokens: vi.fn(async () => ({
accessToken: "refreshed-access",
chatgptAccountId: "refreshed-account",
chatgptPlanType: null,
})),
resolveCodexAppServerFallbackApiKeyCacheKey: vi.fn(() => undefined as string | undefined),
resolveManagedCodexAppServerStartOptions: vi.fn(async (startOptions) => startOptions),
embeddedAgentLog: { debug: vi.fn(), warn: vi.fn() },
@@ -23,6 +31,8 @@ vi.mock("./auth-bridge.js", () => ({
applyCodexAppServerAuthProfile: mocks.applyCodexAppServerAuthProfile,
bridgeCodexAppServerStartOptions: mocks.bridgeCodexAppServerStartOptions,
resolveCodexAppServerAuthProfileIdForAgent: mocks.resolveCodexAppServerAuthProfileIdForAgent,
resolveCodexAppServerAuthProfileStore: mocks.resolveCodexAppServerAuthProfileStore,
refreshCodexAppServerAuthTokens: mocks.refreshCodexAppServerAuthTokens,
resolveCodexAppServerFallbackApiKeyCacheKey: mocks.resolveCodexAppServerFallbackApiKeyCacheKey,
}));
@@ -79,6 +89,7 @@ function bridgeStartOptionsCall() {
return firstMockArg(mocks.bridgeCodexAppServerStartOptions, "bridge start options") as {
agentDir?: string;
authProfileId?: string;
authProfileStore?: unknown;
config?: unknown;
startOptions: { command?: string; commandSource?: string };
};
@@ -88,6 +99,7 @@ function applyAuthProfileCall() {
return firstMockArg(mocks.applyCodexAppServerAuthProfile, "apply auth profile") as {
agentDir?: string;
authProfileId?: string;
authProfileStore?: unknown;
config?: unknown;
};
}
@@ -96,6 +108,7 @@ function resolveAuthProfileCall() {
return firstMockArg(mocks.resolveCodexAppServerAuthProfileIdForAgent, "resolve auth profile") as {
agentDir?: string;
authProfileId?: string;
authProfileStore?: unknown;
config?: unknown;
};
}
@@ -142,6 +155,11 @@ describe("shared Codex app-server client", () => {
mocks.resolveCodexAppServerAuthProfileIdForAgent.mockImplementation(
(params?: { authProfileId?: string }) => params?.authProfileId,
);
mocks.resolveCodexAppServerAuthProfileStore.mockClear();
mocks.resolveCodexAppServerAuthProfileStore.mockImplementation(
(params?: { authProfileStore?: unknown }) => params?.authProfileStore,
);
mocks.refreshCodexAppServerAuthTokens.mockClear();
mocks.resolveCodexAppServerFallbackApiKeyCacheKey.mockClear();
mocks.resolveCodexAppServerFallbackApiKeyCacheKey.mockReturnValue(undefined);
mocks.resolveManagedCodexAppServerStartOptions.mockClear();
@@ -240,6 +258,95 @@ describe("shared Codex app-server client", () => {
expect(applyCall?.authProfileId).toBe("openai:work");
});
it("carries a scoped auth store through isolated app-server startup", async () => {
const harness = createClientHarness();
vi.spyOn(CodexAppServerClient, "start").mockReturnValue(harness.client);
const authProfileStore = { version: 1, profiles: {} };
const preparedAuthProfileStore = {
version: 1,
profiles: {
"openai:scoped": { type: "token", provider: "openai", token: "prepared-token" },
},
};
mocks.resolveCodexAppServerAuthProfileIdForAgent.mockReturnValue("openai:scoped");
mocks.resolveCodexAppServerAuthProfileStore.mockReturnValue(preparedAuthProfileStore);
const clientPromise = createIsolatedCodexAppServerClient({
timeoutMs: 1000,
authProfileStore,
});
await sendInitializeResult(harness, "openclaw/0.125.0 (macOS; test)");
await expect(clientPromise).resolves.toBe(harness.client);
expect(mocks.resolveCodexAppServerAuthProfileStore).toHaveBeenCalledWith({
agentDir: "/tmp/openclaw-agent",
authProfileId: undefined,
authProfileStore,
config: undefined,
});
expect(resolveAuthProfileCall().authProfileStore).toBe(preparedAuthProfileStore);
expect(bridgeStartOptionsCall().authProfileStore).toBe(preparedAuthProfileStore);
expect(applyAuthProfileCall().authProfileStore).toBe(preparedAuthProfileStore);
const priorWriteCount = harness.writes.length;
harness.send({
id: "refresh-1",
method: "account/chatgptAuthTokens/refresh",
params: { reason: "unauthorized", previousAccountId: "scoped-account" },
});
await vi.waitFor(() => expect(harness.writes.length).toBeGreaterThan(priorWriteCount));
expect(mocks.refreshCodexAppServerAuthTokens).toHaveBeenCalledWith({
agentDir: "/tmp/openclaw-agent",
authProfileId: "openai:scoped",
authProfileStore: preparedAuthProfileStore,
config: undefined,
});
expect(JSON.parse(harness.writes.at(-1) ?? "{}")).toEqual({
id: "refresh-1",
result: {
accessToken: "refreshed-access",
chatgptAccountId: "refreshed-account",
chatgptPlanType: null,
},
});
});
it("registers persisted profile refresh for isolated app-server startup", async () => {
const harness = createClientHarness();
vi.spyOn(CodexAppServerClient, "start").mockReturnValue(harness.client);
const clientPromise = createIsolatedCodexAppServerClient({
timeoutMs: 1000,
authProfileId: "openai:persisted",
agentDir: "/tmp/openclaw-persisted-agent",
});
await sendInitializeResult(harness, "openclaw/0.125.0 (macOS; test)");
await expect(clientPromise).resolves.toBe(harness.client);
const priorWriteCount = harness.writes.length;
harness.send({
id: "refresh-persisted",
method: "account/chatgptAuthTokens/refresh",
params: { reason: "unauthorized", previousAccountId: "persisted-account" },
});
await vi.waitFor(() => expect(harness.writes.length).toBeGreaterThan(priorWriteCount));
expect(mocks.refreshCodexAppServerAuthTokens).toHaveBeenCalledWith({
agentDir: "/tmp/openclaw-persisted-agent",
authProfileId: "openai:persisted",
config: undefined,
});
expect(JSON.parse(harness.writes.at(-1) ?? "{}")).toEqual({
id: "refresh-persisted",
result: {
accessToken: "refreshed-access",
chatgptAccountId: "refreshed-account",
chatgptPlanType: null,
},
});
});
it("skips target auth resolution when native source auth is requested", async () => {
const harness = createClientHarness();
vi.spyOn(CodexAppServerClient, "start").mockReturnValue(harness.client);

View File

@@ -2,11 +2,13 @@
* Owns shared and isolated Codex app-server client startup, auth application,
* lease tracking, and teardown.
*/
import { resolveDefaultAgentDir } from "openclaw/plugin-sdk/agent-runtime";
import { resolveDefaultAgentDir, type AuthProfileStore } from "openclaw/plugin-sdk/agent-runtime";
import {
applyCodexAppServerAuthProfile,
bridgeCodexAppServerStartOptions,
refreshCodexAppServerAuthTokens,
resolveCodexAppServerAuthProfileIdForAgent,
resolveCodexAppServerAuthProfileStore,
resolveCodexAppServerFallbackApiKeyCacheKey,
} from "./auth-bridge.js";
import { CodexAppServerClient } from "./client.js";
@@ -113,26 +115,41 @@ type CodexAppServerClientOptions = {
abandonSignal?: AbortSignal;
};
type IsolatedCodexAppServerClientOptions = CodexAppServerClientOptions & {
authProfileStore?: AuthProfileStore;
};
type ResolvedCodexAppServerClientStartContext = {
agentDir: string;
usesNativeAuth: boolean;
authProfileId: string | undefined;
authProfileStore: AuthProfileStore | undefined;
startOptions: CodexAppServerStartOptions;
};
async function resolveCodexAppServerClientStartContext(
options?: CodexAppServerClientOptions,
options?: IsolatedCodexAppServerClientOptions,
): Promise<ResolvedCodexAppServerClientStartContext> {
const agentDir = options?.agentDir ?? resolveDefaultAgentDir(options?.config ?? {});
const usesNativeAuth = options?.authProfileId === null;
const requestedAuthProfileId =
options?.authProfileId === null ? undefined : options?.authProfileId;
const authProfileStore =
!usesNativeAuth && options?.authProfileStore
? resolveCodexAppServerAuthProfileStore({
agentDir,
authProfileId: requestedAuthProfileId,
authProfileStore: options.authProfileStore,
config: options.config,
})
: options?.authProfileStore;
const authProfileId = usesNativeAuth
? undefined
: resolveCodexAppServerAuthProfileIdForAgent({
authProfileId: requestedAuthProfileId,
agentDir,
config: options?.config,
...(authProfileStore ? { authProfileStore } : {}),
});
const requestedStartOptions =
options?.startOptions ?? resolveCodexAppServerRuntimeOptions().start;
@@ -142,8 +159,9 @@ async function resolveCodexAppServerClientStartContext(
agentDir,
authProfileId: usesNativeAuth ? null : authProfileId,
config: options?.config,
...(authProfileStore ? { authProfileStore } : {}),
});
return { agentDir, usesNativeAuth, authProfileId, startOptions };
return { agentDir, usesNativeAuth, authProfileId, authProfileStore, startOptions };
}
/** Gets or starts a shared Codex app-server client without retaining a lease. */
@@ -269,11 +287,26 @@ async function acquireSharedCodexAppServerClient(
/** Starts a non-shared Codex app-server client owned entirely by the caller. */
export async function createIsolatedCodexAppServerClient(
options?: CodexAppServerClientOptions,
options?: IsolatedCodexAppServerClientOptions,
): Promise<CodexAppServerClient> {
const { agentDir, usesNativeAuth, authProfileId, startOptions } =
const { agentDir, usesNativeAuth, authProfileId, authProfileStore, startOptions } =
await resolveCodexAppServerClientStartContext(options);
const client = CodexAppServerClient.start(startOptions);
if (authProfileId) {
// Profile-backed Codex auth is ephemeral. Keep the host refresh callback
// available whether the profile came from a scoped store or persisted state.
client.addRequestHandler(async (request) => {
if (request.method !== "account/chatgptAuthTokens/refresh") {
return undefined;
}
return await refreshCodexAppServerAuthTokens({
agentDir,
authProfileId,
...(authProfileStore ? { authProfileStore } : {}),
config: options?.config,
});
});
}
const initialize = client.initialize();
try {
await withTimeout(initialize, options?.timeoutMs ?? 0, "codex app-server initialize timed out");
@@ -283,6 +316,7 @@ export async function createIsolatedCodexAppServerClient(
authProfileId: usesNativeAuth ? null : authProfileId,
startOptions,
config: options?.config,
...(authProfileStore ? { authProfileStore } : {}),
});
return client;
} catch (error) {

View File

@@ -1213,14 +1213,70 @@ describe("convertOpenClawToolToSdkTool", () => {
});
it("converts single text content to an exact textResultForLlm", async () => {
const sdkTool = convertOpenClawToolToSdkTool(
makeTool({}, { content: [{ text: "hello", type: "text" }], details: null }),
{},
);
const onAgentToolResult = vi.fn();
const sourceResult = {
content: [{ text: "hello", type: "text" }],
details: { results: [{ text: "hello" }] },
};
const sdkTool = convertOpenClawToolToSdkTool(makeTool({}, sourceResult), { onAgentToolResult });
const result = await runSdkTool(sdkTool, {});
expect(result).toEqual({ resultType: "success", textResultForLlm: "hello" });
expect(onAgentToolResult).toHaveBeenCalledWith({
toolName: "tool-a",
result: sourceResult,
isError: false,
});
});
it("reports thrown tool failures to the private result observer", async () => {
const error = new Error("backend unavailable");
const onAgentToolResult = vi.fn();
const sdkTool = convertOpenClawToolToSdkTool(
makeTool({
execute: vi.fn(async () => {
throw error;
}),
}),
{ onAgentToolResult },
);
await runSdkTool(sdkTool, {});
expect(onAgentToolResult).toHaveBeenCalledWith({
toolName: "tool-a",
result: {
content: [
{
type: "text",
text: "[copilot-tool-bridge] tool 'tool-a' failed: backend unavailable",
},
],
details: { status: "failed", error: "backend unavailable" },
},
isError: true,
});
});
it("reports returned OpenClaw error results as observer failures", async () => {
const onAgentToolResult = vi.fn();
const sourceResult = {
content: [{ text: '{"status":"error","error":"backend unavailable"}', type: "text" }],
details: { status: "error", error: "backend unavailable" },
};
const sdkTool = convertOpenClawToolToSdkTool(makeTool({}, sourceResult), {
onAgentToolResult,
});
const result = await runSdkTool(sdkTool, {});
expect(result).toMatchObject({ resultType: "success" });
expect(onAgentToolResult).toHaveBeenCalledWith({
toolName: "tool-a",
result: sourceResult,
isError: true,
});
});
it("joins multiple text blocks with newlines", async () => {
@@ -1276,16 +1332,12 @@ describe("convertOpenClawToolToSdkTool", () => {
});
it("returns a failure result for unsupported content shapes", async () => {
const sdkTool = convertOpenClawToolToSdkTool(
makeTool(
{},
{
content: [{ type: "resource" }],
details: null,
},
),
{},
);
const onAgentToolResult = vi.fn();
const sourceResult = {
content: [{ type: "resource" }],
details: null,
};
const sdkTool = convertOpenClawToolToSdkTool(makeTool({}, sourceResult), { onAgentToolResult });
const result = await runSdkTool(sdkTool, {});
@@ -1296,6 +1348,11 @@ describe("convertOpenClawToolToSdkTool", () => {
expect(getError(result as ToolResultObject)).toBe(
"[copilot-tool-bridge] unsupported AgentToolResult content shape: resource",
);
expect(onAgentToolResult).toHaveBeenCalledWith({
toolName: "tool-a",
result: sourceResult,
isError: true,
});
});
it("returns a failure result when execute throws and preserves the error", async () => {

View File

@@ -10,9 +10,11 @@ import {
buildEmbeddedAttemptToolRunContext,
getPluginToolMeta,
isSubagentSessionKey,
isToolResultError,
resolveAttemptSpawnWorkspaceDir,
resolveEmbeddedAttemptToolConstructionPlan,
resolveModelAuthMode,
sanitizeToolResult,
} from "openclaw/plugin-sdk/agent-harness-runtime";
type CreateOpenClawCodingTools =
@@ -205,6 +207,7 @@ export async function createCopilotToolBridge(
convertOpenClawToolToSdkTool(sourceTool, {
abortSignal: input.abortSignal,
beforeExecute: input.beforeExecute,
onAgentToolResult: input.attemptParams?.onAgentToolResult,
}),
),
sourceTools: filteredTools,
@@ -384,6 +387,7 @@ export function convertOpenClawToolToSdkTool(
ctx: {
abortSignal?: AbortSignal;
beforeExecute?: CopilotToolBridgeInput["beforeExecute"];
onAgentToolResult?: CopilotToolAttemptParams["onAgentToolResult"];
},
): SdkTool {
if (typeof sourceTool.name !== "string" || sourceTool.name.trim().length === 0) {
@@ -397,13 +401,30 @@ export function convertOpenClawToolToSdkTool(
}
let sequentialLock = Promise.resolve();
const notifyToolResult = (result: unknown, isError: boolean) => {
try {
ctx.onAgentToolResult?.({ toolName: sourceTool.name, result, isError });
} catch (error) {
console.warn("[copilot-tool-bridge] onAgentToolResult handler threw; continuing", error);
}
};
const failureResult = (message: string, error: unknown): ToolResultObject => {
notifyToolResult(
sanitizeToolResult({
content: [{ type: "text", text: message }],
details: { status: "failed", error: toError(error).message },
}),
true,
);
return createFailureResult(message, error);
};
const executeOnce = async (
args: unknown,
invocation: ToolInvocation,
): Promise<ToolResultObject> => {
if (ctx.abortSignal?.aborted) {
const error = new Error("[copilot-tool-bridge] aborted before execution");
return createFailureResult(error.message, error);
return failureResult(error.message, error);
}
try {
@@ -415,7 +436,7 @@ export function convertOpenClawToolToSdkTool(
toolName: sourceTool.name,
});
} catch (error: unknown) {
return createFailureResult(
return failureResult(
`[copilot-tool-bridge] beforeExecute failed for tool '${sourceTool.name}': ${toError(error).message}`,
error,
);
@@ -425,7 +446,7 @@ export function convertOpenClawToolToSdkTool(
try {
preparedArgs = sourceTool.prepareArguments ? sourceTool.prepareArguments(args) : args;
} catch (error: unknown) {
return createFailureResult(
return failureResult(
`[copilot-tool-bridge] prepareArguments failed for tool '${sourceTool.name}': ${toError(error).message}`,
error,
);
@@ -440,13 +461,19 @@ export function convertOpenClawToolToSdkTool(
undefined,
);
} catch (error: unknown) {
return createFailureResult(
return failureResult(
`[copilot-tool-bridge] tool '${sourceTool.name}' failed: ${toError(error).message}`,
error,
);
}
return agentToolResultToSdk(result);
const sdkResult = agentToolResultToSdk(result);
const sanitizedResult = sanitizeToolResult(result);
notifyToolResult(
sanitizedResult,
sdkResult.resultType === "failure" || isToolResultError(sanitizedResult),
);
return sdkResult;
};
const handler =

View File

@@ -91,11 +91,11 @@ describe("discord config schema", () => {
expect(cfg.accounts?.noisy?.suppressEmbeds).toBe(false);
});
it("rejects Telegram-only native tool-progress draft config", () => {
it("rejects unknown preview config keys", () => {
const issues = expectInvalidDiscordConfig({
streaming: {
preview: {
nativeToolProgress: true,
unknownPreviewFlag: true,
},
},
});

View File

@@ -41,6 +41,7 @@ beforeEach(() => {
model: {
provider: "anthropic",
id: "claude-sonnet-4-6",
maxTokens: 64_000,
},
auth: {
apiKey: "sk-test",
@@ -75,6 +76,7 @@ describe("generateThreadTitle", () => {
model: {
provider: "openrouter",
id: "anthropic/claude-sonnet-4-5",
maxTokens: 64_000,
},
auth: {
apiKey: "sk-openrouter",
@@ -158,6 +160,7 @@ describe("generateThreadTitle", () => {
it("builds contextual prompt and forwards completion options", async () => {
const now = 1_700_000_000_000;
const dateNowSpy = vi.spyOn(Date, "now").mockReturnValue(now);
const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout");
let result: string | null;
try {
result = await generateThreadTitle({
@@ -187,11 +190,40 @@ describe("generateThreadTitle", () => {
],
});
expect(completionArgs.options).toEqual({
maxTokens: 512,
maxTokens: 4_096,
signal: completionArgs.options?.signal,
});
expect(completionArgs.options?.signal).toBeInstanceOf(AbortSignal);
expect(completionArgs.options).not.toHaveProperty("temperature");
expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 60_000);
});
it("clamps completion budget to the selected model output cap", async () => {
prepareSimpleCompletionModelForAgentMock.mockResolvedValueOnce({
selection: {
provider: "anthropic",
modelId: "claude-haiku-4-5",
agentDir: "/tmp/openclaw-agent",
},
model: {
provider: "anthropic",
id: "claude-haiku-4-5",
maxTokens: 1_024,
},
auth: {
apiKey: "sk-test",
source: "env:TEST_API_KEY",
mode: "api-key",
},
} as Awaited<ReturnType<typeof agentRuntimeModule.prepareSimpleCompletionModelForAgent>>);
await generateThreadTitle({
cfg: EMPTY_DISCORD_TEST_CONFIG,
agentId: "main",
messageText: "Need a generated title.",
});
expect(firstCompletionArgs().options?.maxTokens).toBe(1_024);
});
it("returns null when completion throws", async () => {

View File

@@ -8,7 +8,7 @@ import {
} from "openclaw/plugin-sdk/simple-completion-runtime";
import { withAbortTimeout } from "./timeouts.js";
const DEFAULT_THREAD_TITLE_TIMEOUT_MS = 10_000;
const DEFAULT_THREAD_TITLE_TIMEOUT_MS = 60_000;
const MAX_THREAD_TITLE_SOURCE_CHARS = 600;
const MAX_THREAD_TITLE_CHANNEL_NAME_CHARS = 120;
const MAX_THREAD_TITLE_CHANNEL_DESCRIPTION_CHARS = 320;
@@ -17,7 +17,7 @@ const MAX_THREAD_TITLE_CHANNEL_DESCRIPTION_CHARS = 320;
// capacity: the entire budget is consumed by the thinking block before any
// text is emitted, so extractAssistantText returns empty and the rename is
// silently skipped.
const DISCORD_THREAD_TITLE_MAX_TOKENS = 512;
const DISCORD_THREAD_TITLE_MAX_TOKENS = 4_096;
const DISCORD_THREAD_TITLE_SYSTEM_PROMPT =
"Generate a concise Discord thread title (3-6 words). Return only the title. Use channel context when provided and avoid redundant channel-name words unless needed for clarity.";
@@ -77,6 +77,7 @@ async function completeThreadTitle(params: {
userMessage: string;
timeoutMs: number;
}) {
const maxTokens = Math.min(DISCORD_THREAD_TITLE_MAX_TOKENS, Math.floor(params.model.maxTokens));
return await withAbortTimeout({
timeoutMs: params.timeoutMs,
createTimeoutError: () => new Error(`thread-title timed out after ${params.timeoutMs}ms`),
@@ -95,7 +96,7 @@ async function completeThreadTitle(params: {
],
},
options: {
maxTokens: DISCORD_THREAD_TITLE_MAX_TOKENS,
maxTokens,
signal,
},
}),

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