Compare commits

..

75 Commits

Author SHA1 Message Date
Peter Steinberger 323493fa1b test: refresh release verification baselines
Docker Release / validate_manual_backfill (push) Has been cancelled
Docker Release / approve_manual_backfill (push) Has been cancelled
Docker Release / build-amd64 (push) Has been cancelled
Docker Release / build-arm64 (push) Has been cancelled
Docker Release / create-manifest (push) Has been cancelled
2026-04-14 13:42:03 +01:00
Peter Steinberger 64d237dd02 build: refresh a2ui bundle hash 2026-04-14 13:42:03 +01:00
Peter Steinberger 62f9cf53c9 chore: prepare 2026.4.14 release 2026-04-14 13:42:03 +01:00
Peter Steinberger d2240a9476 test: harden qa-lab concurrent web scenarios 2026-04-14 13:42:02 +01:00
Vincent Koc 10dbb21380 fix(models): normalize google-vertex flash-lite ids 2026-04-14 13:24:46 +01:00
Peter Steinberger 3329824eed test: harden video live provider release gate 2026-04-14 12:53:33 +01:00
Peter Steinberger d7cc6f7643 docs: prepare changelog for 2026.4.14 2026-04-14 11:23:25 +01:00
Vincent Koc 6c0bff111c fix(google): strip Gemini compat base suffixes (#66445)
* fix(google): cover Gemini image /openai base URLs

* fix(google): strip Gemini compat base suffixes

* fix(google): scope Gemini /openai normalization

* fix(google): harden base URL normalization

* fix(google): restrict Gemini auth base URLs

* Update CHANGELOG.md

* Update CHANGELOG.md
2026-04-14 11:19:41 +01:00
Vincent Koc 3587e0ef95 fix(codex): keep auth read diagnostics off stdout (#66451)
* fix(codex): keep auth read diagnostics off stdout

* docs(changelog): fix codex auth entry

* fix(codex): sanitize auth read diagnostics

* Update CHANGELOG.md
2026-04-14 11:13:57 +01:00
Vincent Koc 82364e901a test(codex): cover exact gpt-5.4 registry upgrades (#66454) 2026-04-14 11:05:45 +01:00
Vincent Koc 5a5ca6d62c feat(codex): add gpt-5.4-pro forward compat (#66453)
* feat(openai-codex): add gpt-5.4-pro forward-compat #63404

* feat(openai-codex): add gpt-5.4-pro forward-compat #63404

* openai-codex: use patch.cost when forward-compat falls back to normalizeModelCompat

* feat(codex): add gpt-5.4-pro forward compat

* fix(codex): reuse gpt-5.4 fallback for gpt-5.4-pro

---------

Co-authored-by: jepson-liu <jepsonliu@gmail.com>
2026-04-14 11:05:24 +01:00
Vincent Koc 8820a43818 fix(memory): preserve embedding proxy provider prefixes (#66452)
* fix(memory): preserve embedding proxy provider prefixes

* docs(changelog): fix embeddings entry

* Update CHANGELOG.md
2026-04-14 11:05:07 +01:00
Vincent Koc e9f5619716 fix(onboard): cap compat probe max_tokens (#66450)
* fix(onboard): cap compat probe max_tokens

* docs(changelog): fix onboarding entry

* Update CHANGELOG.md
2026-04-14 11:03:44 +01:00
Vincent Koc f4372613d8 fix(media): remap AAC uploads to M4A (#66446)
* fix(media): remap AAC uploads to M4A

* fix(media): remap AAC uploads to M4A
2026-04-14 11:00:28 +01:00
Vincent Koc e58d50b7a8 fix(telegram): trust explicit proxy DNS for media downloads (#66461) 2026-04-14 10:42:33 +01:00
Vincent Koc 6ee8e194c0 fix(media-understanding): auto-upgrade provider HTTP helper to trusted env proxy mode (#66458)
* fix(media-understanding): auto-upgrade provider HTTP helper to trusted env proxy mode

* Update CHANGELOG.md
2026-04-14 10:29:09 +01:00
Vincent Koc dfed74b254 fix(hooks): honor configured ollama slug timeout (#66455) 2026-04-14 10:28:09 +01:00
Vincent Koc 072e8cfe62 test(discord): avoid status fast path in allow-from fixture 2026-04-14 10:04:47 +01:00
Vincent Koc a70fdc88e0 fix(github-copilot): enable xhigh for gpt-5.4 (#66437)
* fix(github-copilot): enable xhigh for gpt-5.4

* Update CHANGELOG.md
2026-04-14 09:58:19 +01:00
Vincent Koc 4f15d77ecc fix(ollama): enable streaming usage for openai-compat (#66439)
* fix(ollama): enable streaming usage for openai-compat

* Update CHANGELOG.md
2026-04-14 09:57:42 +01:00
Vincent Koc b90d4ea3d7 fix(codex): canonicalize the gpt-5.4-codex alias (#66438)
* fix(codex): canonicalize the gpt-5.4-codex alias

* Update CHANGELOG.md
2026-04-14 09:56:58 +01:00
Vincent Koc 381a8e860a fix(discord): return native status replies directly (#66434) 2026-04-14 09:55:02 +01:00
Vincent Koc 56625a189b fix(gateway): scope reset hook assertion 2026-04-14 09:44:53 +01:00
Bikkies fecd4fcc55 fix(agents) context-engine: per-iteration ingest and assemble for compaction (#63555)
Merged via squash.

Prepared head SHA: 0d815fc190f5b2fd976fea3075e64fef043b1e55
Co-authored-by: Bikkies <29473797+Bikkies@users.noreply.github.com>
Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com>
Reviewed-by: @jalehman
2026-04-14 01:42:14 -07:00
Vincent Koc 33a698fe10 fix(qa-lab): correct scenario catalog type 2026-04-14 09:39:20 +01:00
Mason Huang 7eecfa411d fix(browser): unblock loopback CDP readiness under strict SSRF defaults (#66354)
Merged via squash.

Prepared head SHA: d9030ff2f05e4def509128af46171612e450fc43
Co-authored-by: hxy91819 <8814856+hxy91819@users.noreply.github.com>
Co-authored-by: hxy91819 <8814856+hxy91819@users.noreply.github.com>
Reviewed-by: @hxy91819
2026-04-14 16:30:43 +08:00
Vincent Koc e59f5ecac3 fix(tools): normalize media model lookups (#66422)
* fix(tools): normalize media model lookups

* Update CHANGELOG.md
2026-04-14 09:23:52 +01:00
Ayaan Zaidi aa0dc118f1 fix: preserve subagent registry runtime import path across source and dist (#66420)
* fix(build): correct subagent registry runtime import path

* fix: correct subagent registry runtime import path (#66420)

* fix: preserve subagent registry runtime import path across source and dist (#66420)
2026-04-14 13:52:24 +05:30
Vincent Koc 38de896419 fix(agents): honor embedded ollama timeouts (#66418) 2026-04-14 09:21:22 +01:00
Vincent Koc 900681751d test(qa-lab): seed broken-turn recovery scenarios (#66416) 2026-04-14 09:03:49 +01:00
Vincent Koc 37f449d7e1 fix(memory): restore ollama embedding adapter (#66269)
* fix(memory): restore ollama embedding adapter

* Update CHANGELOG.md
2026-04-14 09:02:31 +01:00
yongqiang li 6a5ff83b24 fix(build): include subagent-registry.runtime.js in dist output (#66205)
* fix: ensure subagent-registry.runtime.js is included in dist output

* fix(build): ship subagent registry runtime

* Update CHANGELOG.md

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-04-14 09:00:40 +01:00
VACInc 26e80cc6cc [codex] Telegram: unblock status commands behind busy turns (#66226)
* Telegram: unblock status commands behind busy turns

* fix(telegram): keep export-session on topic lane

* Update CHANGELOG.md

---------

Co-authored-by: VACInc <3279061+VACInc@users.noreply.github.com>
Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-04-14 08:57:07 +01:00
Ayaan Zaidi 213c36cf51 fix(browser): preserve legacy strict SSRF alias 2026-04-14 12:50:02 +05:30
Ayaan Zaidi 024f4614a1 fix: add browser SSRF follow-up changelog entry (#66386) 2026-04-14 12:42:59 +05:30
Ayaan Zaidi 1dabfef28d fix(browser): preserve explicit strict SSRF config 2026-04-14 12:42:59 +05:30
Ayaan Zaidi 1b76966f05 fix(browser): use loopback policy for json-new fallback 2026-04-14 12:42:59 +05:30
Ayaan Zaidi bf1d49093a fix(browser): relax default hostname SSRF guard 2026-04-14 12:42:59 +05:30
Neerav Makwana a743b30b8b fix: honor configured store limits (#66240) (thanks @neeravmakwana)
* fix(media): honor configured store limits

* fix(media): report effective source limits

* refactor(media): distill configured limit wiring

---------

Co-authored-by: Ayaan Zaidi <hi@obviy.us>
2026-04-14 11:53:20 +05:30
Neerav Makwana 0381852c26 fix: harden approvals get timeout handling (#66239) (thanks @neeravmakwana)
* fix(cli): harden approvals get timeout handling

* fix(cli): sanitize approvals timeout notes

* fix(cli): distill approvals timeout note

---------

Co-authored-by: Ayaan Zaidi <hi@obviy.us>
2026-04-14 11:29:59 +05:30
Luke 0abe64a4ff Agents: clarify local model context preflight (#66236)
Merged via squash.

Prepared head SHA: 11bfaf15f62734097f761e0ae4a2f8a219951a08
Co-authored-by: ImLukeF <92253590+ImLukeF@users.noreply.github.com>
Co-authored-by: ImLukeF <92253590+ImLukeF@users.noreply.github.com>
Reviewed-by: @ImLukeF
2026-04-14 15:38:10 +10:00
拐爷&&老拐瘦 852484965f fix: cache external plugin catalog lookups in auto-enable (#66246) (thanks @yfge)
* fix: cache external plugin catalog lookups in auto-enable

Fixes openclaw/openclaw#66159

* test: restore readFileSync spy in plugin auto-enable test

* refactor: distill plugin auto-enable cache path

* fix: cache external plugin catalog lookups in auto-enable (#66246) (thanks @yfge)

---------

Co-authored-by: Ayaan Zaidi <hi@obviy.us>
2026-04-14 09:41:02 +05:30
Peter Steinberger 55604a9a91 test: keep telegram cache boundary compatible 2026-04-13 20:50:11 -07:00
Peter Steinberger 73d3cf9920 test: bound docker fs bridge probes 2026-04-13 20:49:39 -07:00
Peter Steinberger 3deea5a426 fix: mirror baileys root dependency 2026-04-13 20:49:39 -07:00
Peter Steinberger df84225504 test: align post-rebase full-suite drift 2026-04-13 20:49:39 -07:00
Peter Steinberger 67ffb6f6c2 fix: keep baileys plugin-local 2026-04-13 20:49:39 -07:00
Peter Steinberger 1277294293 test: drop removed agent scope suppression 2026-04-13 20:49:39 -07:00
Peter Steinberger 296471b692 test: align cron model error expectations 2026-04-13 20:49:39 -07:00
Peter Steinberger 4b127adc9d test: align agent session resolver mocks 2026-04-13 20:49:39 -07:00
Peter Steinberger 0d6643e244 test: align cron runtime seams 2026-04-13 20:49:39 -07:00
Peter Steinberger 311bc842b8 fix: remove agent config lint suppression 2026-04-13 20:49:39 -07:00
Peter Steinberger d4f556a052 fix: align latest main type drift 2026-04-13 20:49:39 -07:00
Peter Steinberger 5b24009271 test: mock model fallback source check 2026-04-13 20:49:39 -07:00
Peter Steinberger cf3d27ab94 test: use cron embedded runtime mock 2026-04-13 20:49:39 -07:00
Peter Steinberger 00415e2010 test: refresh cron and mcp typed fixtures 2026-04-13 20:49:39 -07:00
Peter Steinberger ff8605f3c2 test: update model fallback auth store mock 2026-04-13 20:49:39 -07:00
Peter Steinberger 1e11b36d80 test: align feishu replay helper typing 2026-04-13 20:49:39 -07:00
Peter Steinberger c09031f15a fix: tighten inbound replay typing 2026-04-13 20:49:39 -07:00
Peter Steinberger 63965dc70b test: stabilize gateway wake gating regression 2026-04-13 20:49:39 -07:00
Peter Steinberger ca9f969831 test: cover gateway wake startup gating 2026-04-13 20:49:39 -07:00
tmimmanuel a2ab9e6a8e fix: avoid inline dotenv secrets in systemd unit during service repair (#66249) (thanks @tmimmanuel)
* fix(daemon): avoid inline dotenv secrets in systemd unit during service repair

* fix(daemon): sanitize systemd envfile and dedupe state-dir resolution

* fix(daemon): fail on multiline dotenv values for systemd envfile

* test(daemon): cover systemd envfile staging

* fix: keep systemd envfile overrides intact (#66249) (thanks @tmimmanuel)

---------

Co-authored-by: Ayaan Zaidi <hi@obviy.us>
2026-04-14 09:06:10 +05:30
Ayaan Zaidi 3c501d3554 test: remove timer dependency from telegram topic cache tests 2026-04-14 08:58:07 +05:30
Ayaan Zaidi 556905a3f4 fix: restore pnpm check 2026-04-14 08:48:15 +05:30
Peter Steinberger 0eebb49fef test: enforce npm pack budget in install smoke 2026-04-14 04:05:12 +01:00
Ayaan Zaidi 8a9d5e37be fix: move telegram topic-cache changelog to unreleased (#66107) 2026-04-14 08:32:27 +05:30
Ayaan Zaidi c91d3d4537 fix(telegram): persist topic cache via default runtime 2026-04-14 08:32:27 +05:30
Ayaan Zaidi 4f92b1fbb0 fix(telegram): allow topic cache without session runtime 2026-04-14 08:32:27 +05:30
Ayaan Zaidi 6eafb5f844 docs(changelog): note telegram topic-name persistence 2026-04-14 08:32:27 +05:30
Ayaan Zaidi 59afcf9922 test(telegram): cover topic-name cache reload 2026-04-14 08:32:27 +05:30
Ayaan Zaidi ad181b2361 fix(telegram): persist topic-name cache 2026-04-14 08:32:27 +05:30
Agustin Rivera 29f206243b Guard dangerous gateway config mutations (#62006)
* fix(gateway): guard dangerous config alias

* fix(gateway): ignore reordered dangerous flags

* fix(gateway): use id-based mapping identity and honor legacy alias baseline

* fix(gateway): tighten dangerous config matching

* fix(gateway): strip IPv6 brackets in isRemoteGatewayTarget hostname check

* fix(gateway): detect tunneled remote targets

* fix(gateway): match id-less hook mappings by fingerprint, not index

* fix(gateway): detect env-selected remote targets

* fix(gateway): resolve remote-target guard from live config, not captured opts

* fix(gateway): resolve remote-target guard from live config, not captured opts

* fix(gateway): treat loopback OPENCLAW_GATEWAY_URL as local when mode is not remote

* fix(gateway): preserve legacy dangerous hook edits

* fix(gateway): block dangerous plugin reactivation

* fix(gateway): handle dotted plugin IDs in dangerous-flag checks

* fix(gateway): honor plugin policy activation

* fix(gateway): block remote plugin activation changes via allow/deny/enabled

* fix(gateway): broaden loopback url detection

* fix(gateway): resolve plugin IDs by longest-prefix match

* fix(gateway): block remote slot activation

* fix(gateway): preserve legacy mapping identity during id+field transitions

* fix(gateway): block remote load-path and channel activation changes

* test(gateway): fix remote config mock typing

* fix(gateway): guard auto-enabled dangerous plugins

* fix(gateway): address P1 review comments on remote gateway mutation guards

- Treat all OPENCLAW_GATEWAY_URL targets as remote for mutation guards to prevent SSH tunnel bypasses
- Always load config fresh in isRemoteGatewayTargetForAgentTools to detect session changes
- Expand remote activation guard to cover auto-enable paths (auth.profiles, models.providers, agents.defaults, agents.list, tools.web.fetch.provider)
- Respect plugins.deny in manifest-missing fallback to prevent false negatives
- Fix hook mapping identity matching to properly handle id-less mappings by fingerprint
- Update tests to reflect new secure behavior for env-sourced gateway URLs

* fix(gateway): prevent hook mapping swap attacks via fingerprint-only matching

When both current and next tokens have fingerprints, match ONLY by fingerprint.
This prevents replacing one dangerous hook mapping with a different one at the
same array index from being incorrectly treated as 'already present'.

The previous fallback to index-based matching allowed bypasses where an attacker
could swap dangerous mappings at the same index without triggering the guard.

* fix(gateway): honor allowlist in fallback guard

* fix(gateway): treat empty plugin allowlist as unrestricted in manifest-missing fallback

* docs: update USER.md worklog for empty-allowlist fix

* fix(gateway): resolve review comments — type safety, auto-enable resilience, remote hardening edits

* docs: update USER.md worklog for review comment resolution

* fix(gateway): block remaining remote setup auto-enable paths

* fix(gateway): simplify dangerous config mutation guard to set-diff approach

Replace 400+ lines of hook fingerprinting, remote gateway detection,
plugin activation tracking, and auto-enable enumeration with a simple
set-diff against collectEnabledInsecureOrDangerousFlags — the same
enumeration openclaw security audit already uses.

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

* chore: remove USER.md audit log from PR

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

* changelog: note gateway-tool dangerous config mutation guard (#62006)

---------

Co-authored-by: Devin Robison <drobison@nvidia.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 20:59:39 -06:00
Agustin Rivera df192c514c fix(media): fail closed on attachment canonicalization (#66022)
* fix(media): fail closed on attachment canonicalization

* fix(media): clarify attachment skip failures

* fix(media): preserve attachment URL fallback

* fix(media): preserve getPath URL fallback on blocked local paths

* changelog: note media attachment canonicalization fail-closed (#66022)

---------

Co-authored-by: Devin Robison <drobison@nvidia.com>
2026-04-13 20:46:20 -06:00
Agustin Rivera 1c35795fce fix(slack): align interaction auth with allowlists (#66028)
* fix(slack): align interaction auth with allowlists

* fix(slack): address review followups

* fix(slack): preserve explicit owners with wildcard

* chore: append Claude comments resolution worklog

* fix(slack): harden interaction auth with default-deny, mandatory actor binding, and channel type validation

- Add interactiveEvent flag to authorizeSlackSystemEventSender for stricter
  interactive control authorization
- Default-deny when no allowFrom or channel users are configured for
  interactive events (block actions, modals)
- Require expectedSenderId for all interactive event types; block actions
  pass Slack-verified userId, modals pass metadata-embedded userId
- Reject ambiguous channel types for interactive events to prevent DM
  authorization bypass via channel-type fallback
- Add comprehensive test coverage for all new behaviors

* fix(slack): scope interactive owner/allowFrom enforcement to interactive paths only

* fix(slack): preserve no-channel interactive default

* Update context-engine-maintenance test

* chore: remove USER.md worklog artifact

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

* changelog: note Slack interactive auth allowlist alignment (#66028)

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Devin Robison <drobison@nvidia.com>
2026-04-13 20:38:11 -06:00
Eva H 49d99c7500 fix: include apiKey in codex provider catalog to unblock models.json loading (#66180)
Merged via squash.

Prepared head SHA: ce61934ac9dd0de8702546bc63d9ec7f303b7a4f
Co-authored-by: hoyyeva <63033505+hoyyeva@users.noreply.github.com>
Co-authored-by: BruceMacD <5853428+BruceMacD@users.noreply.github.com>
Reviewed-by: @BruceMacD
2026-04-13 19:22:09 -07:00
180 changed files with 6994 additions and 874 deletions
@@ -86,6 +86,10 @@ node --import tsx scripts/openclaw-npm-postpublish-verify.ts <published-version>
- For stable correction releases like `YYYY.M.D-N`, it also verifies the
upgrade path from `YYYY.M.D` to `YYYY.M.D-N` so a correction publish cannot
silently leave existing global installs on the old base stable payload.
- Treat install smoke as a pack-budget gate too. `pnpm test:install:smoke`
now fails the candidate update tarball when npm reports an oversized
`unpackedSize`, so release-time e2e cannot miss pack bloat that would risk
low-memory install/startup failures.
## Check all relevant release builds
+35 -1
View File
@@ -8,14 +8,42 @@ Docs: https://docs.openclaw.ai
### Fixes
## 2026.4.14-beta.1
## 2026.4.14
### Changes
- OpenAI Codex/models: add forward-compat support for `gpt-5.4-pro`, including Codex pricing/limits and list/status visibility before the upstream catalog catches up. (#66453) Thanks @jepson-liu.
- Telegram/forum topics: surface human topic names in agent context, prompt metadata, and plugin hook metadata by learning names from Telegram forum service messages. (#65973) Thanks @ptahdunbar.
### Fixes
- Agents/Ollama: forward the configured embedded-run timeout into the global undici stream timeout tuning so slow local Ollama runs no longer inherit the default stream cutoff instead of the operator-set run timeout. (#63175) Thanks @mindcraftreader and @vincentkoc.
- Models/Codex: include `apiKey` in the codex provider catalog output so the Pi ModelRegistry validator no longer rejects the entry and silently drops all custom models from every provider in `models.json`. (#66180) Thanks @hoyyeva.
- Tools/image+pdf: normalize configured provider/model refs before media-tool registry lookup so image and PDF tool runs stop rejecting valid Ollama vision models as unknown just because the tool path skipped the usual model-ref normalization step. (#59943) Thanks @yqli2420 and @vincentkoc.
- Slack/interactions: apply the configured global `allowFrom` owner allowlist to channel block-action and modal interactive events, require an expected sender id for cross-verification, and reject ambiguous channel types so interactive triggers can no longer bypass the documented allowlist intent in channels without a `users` list. Open-by-default behavior is preserved when no allowlists are configured. (#66028) Thanks @eleqtrizit.
- Media-understanding/attachments: fail closed when a local attachment path cannot be canonically resolved via `realpath`, so a `realpath` error can no longer downgrade the canonical-roots allowlist check to a non-canonical comparison; attachments that also have a URL still fall back to the network fetch path. (#66022) Thanks @eleqtrizit.
- Agents/gateway-tool: reject `config.patch` and `config.apply` calls from the model-facing gateway tool when they would newly enable any flag enumerated by `openclaw security audit` (for example `dangerouslyDisableDeviceAuth`, `allowInsecureAuth`, `dangerouslyAllowHostHeaderOriginFallback`, `hooks.gmail.allowUnsafeExternalContent`, `tools.exec.applyPatch.workspaceOnly: false`); already-enabled flags pass through unchanged so non-dangerous edits in the same patch still apply, and direct authenticated operator RPC behavior is unchanged. (#62006) Thanks @eleqtrizit.
- Google image generation: strip a trailing `/openai` suffix from configured Google base URLs only when calling the native Gemini image API so Gemini image requests stop 404ing without breaking explicit OpenAI-compatible Google endpoints. (#66445) Thanks @dapzthelegend.
- Telegram/forum topics: persist learned topic names to the Telegram session sidecar store so agent context can keep using human topic names after a restart instead of relearning from future service metadata. (#66107) Thanks @obviyus.
- Doctor/systemd: keep `openclaw doctor --repair` and service reinstall from re-embedding dotenv-backed secrets in user systemd units, while preserving newer inline overrides over stale state-dir `.env` values. (#66249) Thanks @tmimmanuel.
- Ollama/OpenAI-compat: send `stream_options.include_usage` for Ollama streaming completions so local Ollama runs report real usage instead of falling back to bogus prompt-token counts that trigger premature compaction. (#64568) Thanks @xchunzhao and @vincentkoc.
- Doctor/plugins: cache external `preferOver` catalog lookups within each plugin auto-enable pass so large `agents.list` configs no longer peg CPU and repeatedly reread plugin catalogs during doctor/plugins resolution. (#66246) Thanks @yfge.
- GitHub Copilot/thinking: allow `github-copilot/gpt-5.4` to use `xhigh` reasoning so Copilot GPT-5.4 matches the rest of the GPT-5.4 family. (#50168) Thanks @jakepresent and @vincentkoc.
- Memory/embeddings: preserve non-OpenAI provider prefixes when normalizing OpenAI-compatible embedding model refs so proxy-backed memory providers stop failing with `Unknown memory embedding provider`. (#66452) Thanks @jlapenna.
- Agents/local models: clarify low-context preflight hints for self-hosted models, point config-backed caps at the relevant OpenClaw setting, and stop suggesting larger models when `agents.defaults.contextTokens` is the real limit. (#66236) Thanks @ImLukeF.
- Browser/SSRF: restore hostname navigation under the default browser SSRF policy while keeping explicit strict mode reachable from config, and keep managed loopback CDP `/json/new` fallback requests on the local CDP control policy so browser follow-up fixes stop regressing normal navigation or self-blocking local CDP control. (#66386) Thanks @obviyus.
- Models/Codex: canonicalize the legacy `openai-codex/gpt-5.4-codex` runtime alias to `openai-codex/gpt-5.4` while still honoring alias-specific and canonical per-model overrides. (#43060) Thanks @Sapientropic and @vincentkoc.
- Browser/SSRF: preserve explicit strict browser navigation mode for legacy `browser.ssrfPolicy.allowPrivateNetwork: false` configs by normalizing the legacy alias to the canonical strict marker instead of silently widening those installs to the default non-strict hostname-navigation path.
- Onboarding/custom providers: use `max_tokens=16` for OpenAI-compatible verification probes so stricter custom endpoints stop rejecting onboarding checks that only need a tiny completion. (#66450) Thanks @WuKongAI-CMU.
- Agents/subagents: emit the subagent registry lazy-runtime stub on the stable dist path that both source and bundled runtime imports resolve, so the follow-up dist fix no longer still fails with `ERR_MODULE_NOT_FOUND` at runtime. (#66420) Thanks @obviyus.
- Media-understanding/proxy env: auto-upgrade provider HTTP helper requests to trusted env-proxy mode only when `HTTP_PROXY`/`HTTPS_PROXY` is active and the target is not bypassed by `NO_PROXY`, so remote media-understanding and transcription requests stop failing local DNS pre-resolution in proxy-only environments without widening SSRF bypasses. (#52162) Thanks @mjamiv and @vincentkoc.
- Telegram/media downloads: let Telegram media fetches trust an operator-configured explicit proxy for target DNS resolution after hostname-policy checks, so proxy-backed installs stop failing `could not download media` on Bot API file downloads after the DNS-pinning regression. (#66245) Thanks @dawei41468 and @vincentkoc.
- Browser: keep loopback CDP readiness checks reachable under strict SSRF defaults so OpenClaw can reconnect to locally started managed Chrome. (#66354) Thanks @hxy91819.
- Agents/context engine: compact engine-owned sessions from the first tool-loop delta and preserve ingest fallback when `afterTurn` is absent, so long-running tool loops can stay bounded without dropping engine state. (#63555) Thanks @Bikkies.
- OpenAI Codex/auth: keep malformed Codex CLI auth-file diagnostics on the debug logger instead of stdout so interactive command output stays clean while auth read failures remain traceable. (#66451) Thanks @SimbaKingjoe.
- Discord/native commands: return the real status card for native `/status` interactions instead of falling through to the synthetic `✅ Done.` ack when the generic dispatcher produces no visible reply. (#54629) Thanks @tkozzer and @vincentkoc.
- Hooks/Ollama: let LLM-backed session-memory slug generation honor an explicit `agents.defaults.timeoutSeconds` override instead of always aborting after 15 seconds, so slow local Ollama runs stop silently dropping back to generic filenames. (#66237) Thanks @dmak and @vincentkoc.
- Media/transcription: remap `.aac` filenames to `.m4a` for OpenAI-compatible audio uploads so AAC voice notes stop failing MIME-sensitive transcription endpoints. (#66446) Thanks @ben-z.
- UI/chat: replace marked.js with markdown-it so maliciously crafted markdown can no longer freeze the Control UI via ReDoS. (#46707) Thanks @zhangfnf.
- Auto-reply/send policy: keep `sendPolicy: "deny"` from blocking inbound message processing, so the agent still runs its turn while all outbound delivery is suppressed for observer-style setups. (#65461, #53328) Thanks @omarshahine.
- BlueBubbles: lazy-refresh the Private API server-info cache on send when reply threading or message effects are requested but status is unknown, so sends no longer silently degrade to plain messages when the 10-minute cache expires. (#65447, #43764) Thanks @omarshahine.
@@ -35,21 +63,26 @@ Docs: https://docs.openclaw.ai
- Cron/scheduler: stop inventing short retries when cron next-run calculation returns no valid future slot, and keep a maintenance wake armed so enabled unscheduled jobs recover without entering a refire loop. (#66019, #66083) Thanks @mbelinky.
- Cron/scheduler: preserve the active error-backoff floor when maintenance repair recomputes a missing cron next-run, so recurring errored jobs do not resume early after a transient next-run resolution failure. (#66019, #66083, #66113) Thanks @mbelinky.
- Outbound/delivery-queue: persist the originating outbound `session` context on queued delivery entries and replay it during recovery, so write-ahead-queued sends keep their original outbound media policy context after restart instead of evaluating against a missing session. (#66025) Thanks @eleqtrizit.
- Memory/Ollama: restore the built-in `ollama` embedding adapter in memory-core so explicit `memorySearch.provider: "ollama"` works again, and include endpoint-aware cache keys so different Ollama hosts do not reuse each other's embeddings. (#63429, #66078, #66163) Thanks @nnish16 and @vincentkoc.
- Auto-reply/queue: split collect-mode followup drains into contiguous groups by per-message authorization context (sender id, owner status, exec/bash-elevated overrides), so queued items from different senders or exec configs no longer execute under the last queued run's owner-only and exec-approval context. (#66024) Thanks @eleqtrizit.
- Dreaming/memory-core: require a live queued Dreaming cron event before the heartbeat hook runs the sweep, so managed Dreaming no longer replays on later heartbeats after the scheduled run was already consumed. (#66139) Thanks @mbelinky.
- Control UI/Dreaming: stop Imported Insights and Memory Palace from calling optional `memory-wiki` gateway methods when the plugin is off, and refresh config before wiki reloads so the Dreaming tab stops showing misleading unknown-method failures. (#66140) Thanks @mbelinky.
- Agents/tools: only mark streamed unknown-tool retries as counted when a streamed message actually classifies an unavailable tool, and keep incomplete streamed tool names from resetting the retry streak before the final assistant message arrives. (#66145) Thanks @dutifulbob.
- Memory/active-memory: move recalled memory onto the hidden untrusted prompt-prefix path instead of system prompt injection, label the visible Active Memory status line fields, and include the resolved recall provider/model in gateway debug logs so trace/debug output matches what the model actually saw. (#66144) Thanks @Takhoffman.
- Memory/QMD: stop treating legacy lowercase `memory.md` as a second default root collection, so QMD recall no longer searches phantom `memory-alt-*` collections and builtin/QMD root-memory fallback stays aligned. (#66141) Thanks @mbelinky.
- Agents/subagents: ship `dist/agents/subagent-registry.runtime.js` in npm builds so `runtime: "subagent"` runs stop stalling in `queued` after the registry import fails. (#66189) Thanks @yqli2420 and @vincentkoc.
- Agents/OpenAI: map `minimal` thinking to OpenAI's supported `low` reasoning effort for GPT-5.4 requests, so embedded runs stop failing request validation. Thanks @steipete.
- Voice-call/media-stream: resolve the source IP from trusted forwarding headers for per-IP pending-connection limits when `webhookSecurity.trustForwardingHeaders` and `trustedProxyIPs` are configured, and reserve `maxConnections` capacity for in-flight WebSocket upgrades so concurrent handshakes can no longer momentarily exceed the operator-set cap. (#66027) Thanks @eleqtrizit.
- Feishu/allowlist: canonicalize allowlist entries by explicit `user`/`chat` kind, strip repeated `feishu:`/`lark:` provider prefixes, and stop folding opaque Feishu IDs to lowercase, so allowlist matching no longer crosses user/chat namespaces or widens to case-insensitive ID matches the operator did not intend. (#66021) Thanks @eleqtrizit.
- Telegram/status commands: let read-only status slash commands bypass busy topic turns, while keeping `/export-session` on the normal lane so it cannot interleave with an in-flight session mutation. (#66226) Thanks @VACInc and @vincentkoc.
- TTS/reply media: persist OpenClaw temp voice outputs into managed outbound media and allow them through reply-media normalization, so voice-note replies stop silently dropping. (#63511) Thanks @jetd1.
- Agents/tools: treat Windows drive-letter paths (`C:\\...`) as absolute when resolving sandbox and read-tool paths so workspace root is not prepended under POSIX path rules. (#54039) Thanks @ly85206559 and @vincentkoc.
- Agents/OpenAI: recover embedded GPT-style runs when reasoning-only or empty turns need bounded continuation, with replay-safe retry gating and incomplete-turn fallback when no visible answer arrives. (#66167) thanks @jalehman
- Outbound/relay-status: suppress internal relay-status placeholder payloads (`No channel reply.`, `Replied in-thread.`, `Replied in #...`, wiki-update status variants ending in `No channel reply.`) before channel delivery so internal housekeeping text does not leak to users.
- Slack/doctor: add a dedicated doctor-contract sidecar so config warmup paths such as `openclaw cron` no longer fall back to Slack's broader contract surface, which could trigger Slack-related config-read crashes on affected setups. (#63192) Thanks @shhtheonlyperson.
- Hooks/session-memory: pass the resolved agent workspace into gateway `/new` and `/reset` session-memory hooks so reset snapshots stay scoped to the right agent workspace instead of leaking into the default workspace. (#64735) Thanks @suboss87 and @vincentkoc.
- CLI/approvals: raise the default `openclaw approvals get` gateway timeout and report config-load timeouts explicitly, so slow hosts stop showing a misleading `Config unavailable.` note when the approvals snapshot succeeds but the follow-up config RPC needs more time. (#66239) Thanks @neeravmakwana.
- Media/store: honor configured agent media limits when saving generated media and persisting outbound reply media, so the store no longer hard-stops those flows at 5 MB before the configured limit applies. (#66229) Thanks @neeravmakwana and @vincentkoc.
## 2026.4.12
@@ -310,6 +343,7 @@ Docs: https://docs.openclaw.ai
- Agents/inbound metadata: strip NUL bytes from serialized inbound context blocks before they reach backend spawn args, so malformed message metadata cannot crash agent spawn with `ERR_INVALID_ARG_VALUE`. (#65389) Thanks @adminfedres and @vincentkoc.
- iMessage: retry transient `watch.subscribe` startup failures before tearing down the monitor, so brief local transport stalls do not immediately bounce the channel. (#65393) Thanks @vincentkoc.
- Status/session_status: move shared session status text into a neutral internal status module and keep the tool importing a local runtime shim, so built `session_status` no longer depends on reply command internals or a bundler-opaque runtime import. (#65807) Thanks @dutifulbob.
- QQBot/security: replace raw `fetch()` in the image-size probe with SSRF-guarded `fetchRemoteMedia`, fix `resolveRepoRoot()` to walk up to `.git` instead of hardcoding two parent levels, and refresh the raw-fetch allowlist to match the corrected scan. (#63495) Thanks @dims.
## 2026.4.9
+1 -1
View File
@@ -66,7 +66,7 @@ android {
minSdk = 31
targetSdk = 36
versionCode = 2026041401
versionName = "2026.4.14-beta.1"
versionName = "2026.4.14"
ndk {
// Support all major ABIs — native libs are tiny (~47 KB per ABI)
abiFilters += listOf("armeabi-v7a", "arm64-v8a", "x86", "x86_64")
@@ -15,7 +15,7 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>2026.4.14-beta.1</string>
<string>2026.4.14</string>
<key>CFBundleVersion</key>
<string>2026041401</string>
<key>CFBundleIconFile</key>
@@ -1,2 +1,2 @@
7003e0d0ba1cddb7eb388204825ac892206209a4a9c795e76c4e34b5fc7b50f0 plugin-sdk-api-baseline.json
14e39520459abc7db7993a700a4f07adfa0855d9233d123c4725477b91f1cb13 plugin-sdk-api-baseline.jsonl
7b121e2b694f80433fa91ce9037527ca58be546a7f18798470a4ade66593e5e1 plugin-sdk-api-baseline.json
7b802cc04f0eac0b498b50711e39a7afe93bbb6b682a2013d2c303583fb73f40 plugin-sdk-api-baseline.jsonl
+14
View File
@@ -33,6 +33,20 @@ openclaw browser --browser-profile openclaw open https://example.com
openclaw browser --browser-profile openclaw snapshot
```
## Quick troubleshooting
If `start` fails with `not reachable after start`, troubleshoot CDP readiness first. If `start` and `tabs` succeed but `open` or `navigate` fails, the browser control plane is healthy and the failure is usually navigation SSRF policy.
Minimal sequence:
```bash
openclaw browser --browser-profile openclaw start
openclaw browser --browser-profile openclaw tabs
openclaw browser --browser-profile openclaw open https://example.com
```
Detailed guidance: [Browser troubleshooting](/tools/browser#cdp-startup-failure-vs-navigation-ssrf-block)
## Lifecycle
```bash
+1
View File
@@ -174,6 +174,7 @@ Compatibility notes for stricter OpenAI-compatible backends:
- Gateway can reach the proxy? `curl http://127.0.0.1:1234/v1/models`.
- LM Studio model unloaded? Reload; cold start is a common “hanging” cause.
- OpenClaw warns when the detected context window is below **32k** and blocks below **16k**. If you hit that preflight, raise the server/model context limit or choose a larger model.
- Context errors? Lower `contextWindow` or raise your server limit.
- OpenAI-compatible server returns `messages[].content ... expected a string`?
Add `compat.requiresStringContent: true` on that model entry.
+3
View File
@@ -90,6 +90,9 @@ OpenClaw has three public release lanes:
- npm release preflight fails closed unless the tarball includes both
`dist/control-ui/index.html` and a non-empty `dist/control-ui/assets/` payload
so we do not ship an empty browser dashboard again
- `pnpm test:install:smoke` also enforces the npm pack `unpackedSize` budget on
the candidate update tarball, so installer e2e catches accidental pack bloat
before the release publish path
- If the release work touched CI planning, extension timing manifests, or
extension test matrices, regenerate and review the planner-owned
`checks-node-extensions` workflow matrix outputs from `.github/workflows/ci.yml`
+57
View File
@@ -884,6 +884,63 @@ For Linux-specific issues (especially snap Chromium), see
For WSL2 Gateway + Windows Chrome split-host setups, see
[WSL2 + Windows + remote Chrome CDP troubleshooting](/tools/browser-wsl2-windows-remote-cdp-troubleshooting).
### CDP startup failure vs navigation SSRF block
These are different failure classes and they point to different code paths.
- **CDP startup or readiness failure** means OpenClaw cannot confirm that the browser control plane is healthy.
- **Navigation SSRF block** means the browser control plane is healthy, but a page navigation target is rejected by policy.
Common examples:
- CDP startup or readiness failure:
- `Chrome CDP websocket for profile "openclaw" is not reachable after start`
- `Remote CDP for profile "<name>" is not reachable at <cdpUrl>`
- Navigation SSRF block:
- `open`, `navigate`, snapshot, or tab-opening flows fail with a browser/network policy error while `start` and `tabs` still work
Use this minimal sequence to separate the two:
```bash
openclaw browser --browser-profile openclaw start
openclaw browser --browser-profile openclaw tabs
openclaw browser --browser-profile openclaw open https://example.com
```
How to read the results:
- If `start` fails with `not reachable after start`, troubleshoot CDP readiness first.
- If `start` succeeds but `tabs` fails, the control plane is still unhealthy. Treat this as a CDP reachability problem, not a page-navigation problem.
- If `start` and `tabs` succeed but `open` or `navigate` fails, the browser control plane is up and the failure is in navigation policy or the target page.
- If `start`, `tabs`, and `open` all succeed, the basic managed-browser control path is healthy.
Important behavior details:
- Browser config defaults to a fail-closed SSRF policy object even when you do not configure `browser.ssrfPolicy`.
- For the local loopback `openclaw` managed profile, CDP health checks intentionally skip browser SSRF reachability enforcement for OpenClaw's own local control plane.
- Navigation protection is separate. A successful `start` or `tabs` result does not mean a later `open` or `navigate` target is allowed.
Security guidance:
- Do **not** relax browser SSRF policy by default.
- Prefer narrow host exceptions such as `hostnameAllowlist` or `allowedHostnames` over broad private-network access.
- Use `dangerouslyAllowPrivateNetwork: true` only in intentionally trusted environments where private-network browser access is required and reviewed.
Example: navigation blocked, control plane healthy
- `start` succeeds
- `tabs` succeeds
- `open http://internal.example` fails
That usually means browser startup is fine and the navigation target needs policy review.
Example: startup blocked before navigation matters
- `start` fails with `not reachable after start`
- `tabs` also fails or cannot run
That points to browser launch or CDP reachability, not a page URL allowlist problem.
## Agent tools + how control works
The agent gets **one tool** for browser automation:
@@ -10,7 +10,7 @@ vi.mock("openclaw/plugin-sdk/ssrf-runtime", async (importOriginal) => {
};
});
import { fetchJson, fetchOk } from "./cdp.helpers.js";
import { assertCdpEndpointAllowed, fetchJson, fetchOk } from "./cdp.helpers.js";
describe("cdp helpers", () => {
afterEach(() => {
@@ -43,6 +43,23 @@ describe("cdp helpers", () => {
expect(release).toHaveBeenCalledTimes(1);
});
it("allows loopback CDP endpoints in strict SSRF mode", async () => {
await expect(
assertCdpEndpointAllowed("http://127.0.0.1:9222/json/version", {
dangerouslyAllowPrivateNetwork: false,
}),
).resolves.toBeUndefined();
});
it("still enforces hostname allowlist for loopback CDP endpoints", async () => {
await expect(
assertCdpEndpointAllowed("http://127.0.0.1:9222/json/version", {
dangerouslyAllowPrivateNetwork: false,
hostnameAllowlist: ["*.corp.example"],
}),
).rejects.toThrow("browser endpoint blocked by policy");
});
it("releases guarded CDP fetches for bodyless requests", async () => {
const release = vi.fn(async () => {});
fetchWithSsrFGuardMock.mockResolvedValueOnce({
@@ -62,4 +79,62 @@ describe("cdp helpers", () => {
expect(release).toHaveBeenCalledTimes(1);
});
it("uses an exact loopback allowlist for guarded loopback CDP fetches", async () => {
const release = vi.fn(async () => {});
fetchWithSsrFGuardMock.mockResolvedValueOnce({
response: {
ok: true,
status: 200,
},
release,
});
await expect(
fetchOk("http://127.0.0.1:9222/json/version", 250, undefined, {
dangerouslyAllowPrivateNetwork: false,
}),
).resolves.toBeUndefined();
expect(fetchWithSsrFGuardMock).toHaveBeenCalledWith(
expect.objectContaining({
url: "http://127.0.0.1:9222/json/version",
policy: {
dangerouslyAllowPrivateNetwork: false,
allowedHostnames: ["127.0.0.1"],
},
}),
);
expect(release).toHaveBeenCalledTimes(1);
});
it("preserves hostname allowlist while allowing exact loopback CDP fetches", async () => {
const release = vi.fn(async () => {});
fetchWithSsrFGuardMock.mockResolvedValueOnce({
response: {
ok: true,
status: 200,
},
release,
});
await expect(
fetchOk("http://127.0.0.1:9222/json/version", 250, undefined, {
dangerouslyAllowPrivateNetwork: false,
hostnameAllowlist: ["*.corp.example"],
}),
).resolves.toBeUndefined();
expect(fetchWithSsrFGuardMock).toHaveBeenCalledWith(
expect.objectContaining({
url: "http://127.0.0.1:9222/json/version",
policy: {
dangerouslyAllowPrivateNetwork: false,
hostnameAllowlist: ["*.corp.example"],
allowedHostnames: ["127.0.0.1"],
},
}),
);
expect(release).toHaveBeenCalledTimes(1);
});
});
+19 -2
View File
@@ -69,8 +69,16 @@ export async function assertCdpEndpointAllowed(
throw new Error(`Invalid CDP URL protocol: ${parsed.protocol.replace(":", "")}`);
}
try {
const policy = isLoopbackHost(parsed.hostname)
? {
...ssrfPolicy,
allowedHostnames: Array.from(
new Set([...(ssrfPolicy?.allowedHostnames ?? []), parsed.hostname]),
),
}
: ssrfPolicy;
await resolvePinnedHostnameWithPolicy(parsed.hostname, {
policy: ssrfPolicy,
policy,
});
} catch (error) {
throw new BrowserCdpEndpointBlockedError({ cause: error });
@@ -263,11 +271,20 @@ export async function fetchCdpChecked(
try {
const headers = getHeadersWithAuth(url, (init?.headers as Record<string, string>) || {});
const res = await withNoProxyForCdpUrl(url, async () => {
const parsedUrl = new URL(url);
const policy = isLoopbackHost(parsedUrl.hostname)
? {
...ssrfPolicy,
allowedHostnames: Array.from(
new Set([...(ssrfPolicy?.allowedHostnames ?? []), parsedUrl.hostname]),
),
}
: (ssrfPolicy ?? { allowPrivateNetwork: true });
const guarded = await fetchWithSsrFGuard({
url,
init: { ...init, headers },
signal: ctrl.signal,
policy: ssrfPolicy ?? { allowPrivateNetwork: true },
policy,
auditContext: "browser-cdp",
});
guardedRelease = guarded.release;
@@ -0,0 +1,70 @@
import { createServer, type Server } from "node:http";
import type { AddressInfo } from "node:net";
import { afterEach, describe, expect, it } from "vitest";
import { getChromeWebSocketUrl, isChromeReachable } from "./chrome.js";
type RunningServer = {
server: Server;
baseUrl: string;
};
const runningServers: Server[] = [];
async function startLoopbackCdpServer(): Promise<RunningServer> {
const server = createServer((req, res) => {
if (req.url !== "/json/version") {
res.statusCode = 404;
res.end("not found");
return;
}
const address = server.address() as AddressInfo;
res.setHeader("content-type", "application/json");
res.end(
JSON.stringify({
Browser: "Chrome/999.0.0.0",
webSocketDebuggerUrl: `ws://127.0.0.1:${address.port}/devtools/browser/TEST`,
}),
);
});
await new Promise<void>((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", () => resolve());
});
runningServers.push(server);
const address = server.address() as AddressInfo;
return {
server,
baseUrl: `http://127.0.0.1:${address.port}`,
};
}
afterEach(async () => {
await Promise.all(
runningServers
.splice(0)
.map(
(server) =>
new Promise<void>((resolve, reject) =>
server.close((err) => (err ? reject(err) : resolve())),
),
),
);
});
describe("chrome loopback SSRF integration", () => {
it("keeps loopback CDP HTTP reachability working under strict default SSRF policy", async () => {
const { baseUrl } = await startLoopbackCdpServer();
await expect(isChromeReachable(baseUrl, 500, {})).resolves.toBe(true);
});
it("returns the loopback websocket URL under strict default SSRF policy", async () => {
const { baseUrl } = await startLoopbackCdpServer();
await expect(getChromeWebSocketUrl(baseUrl, 500, {})).resolves.toMatch(
/\/devtools\/browser\/TEST$/,
);
});
});
+11 -5
View File
@@ -312,22 +312,28 @@ describe("browser chrome helpers", () => {
await expect(isChromeReachable("http://127.0.0.1:12345", 50)).resolves.toBe(false);
});
it("blocks private CDP probes when strict SSRF policy is enabled", async () => {
const fetchSpy = vi.fn().mockRejectedValue(new Error("should not be called"));
it("allows loopback CDP probes while still blocking non-loopback private targets in strict SSRF mode", async () => {
const fetchSpy = vi
.fn()
.mockResolvedValueOnce({
ok: true,
json: async () => ({ webSocketDebuggerUrl: "ws://127.0.0.1/devtools" }),
} as unknown as Response)
.mockRejectedValue(new Error("should not be called"));
vi.stubGlobal("fetch", fetchSpy);
await expect(
isChromeReachable("http://127.0.0.1:12345", 50, {
dangerouslyAllowPrivateNetwork: false,
}),
).resolves.toBe(false);
).resolves.toBe(true);
await expect(
isChromeReachable("ws://127.0.0.1:19999", 50, {
isChromeReachable("http://169.254.169.254:12345", 50, {
dangerouslyAllowPrivateNetwork: false,
}),
).resolves.toBe(false);
expect(fetchSpy).not.toHaveBeenCalled();
expect(fetchSpy).toHaveBeenCalledTimes(1);
});
it("blocks cross-host websocket pivots returned by /json/version in strict SSRF mode", async () => {
+10 -1
View File
@@ -318,7 +318,16 @@ describe("browser config", () => {
dangerouslyAllowPrivateNetwork: false,
},
});
expect(resolved.ssrfPolicy).toEqual({});
expect(resolved.ssrfPolicy).toEqual({ dangerouslyAllowPrivateNetwork: false });
});
it("preserves legacy explicit strict mode from allowPrivateNetwork=false", () => {
const resolved = resolveBrowserConfig({
ssrfPolicy: {
allowPrivateNetwork: false,
},
} as unknown as BrowserConfig);
expect(resolved.ssrfPolicy).toEqual({ dangerouslyAllowPrivateNetwork: false });
});
it("keeps allowlist-only browser SSRF policy strict by default", () => {
+5 -1
View File
@@ -149,7 +149,11 @@ function resolveBrowserSsrFPolicy(cfg: BrowserConfig | undefined): SsrFPolicy |
}
return {
...(resolvedAllowPrivateNetwork ? { dangerouslyAllowPrivateNetwork: true } : {}),
...(resolvedAllowPrivateNetwork ||
dangerouslyAllowPrivateNetwork === false ||
allowPrivateNetwork === false
? { dangerouslyAllowPrivateNetwork: resolvedAllowPrivateNetwork }
: {}),
...(allowedHostnames ? { allowedHostnames } : {}),
...(hostnameAllowlist ? { hostnameAllowlist } : {}),
};
@@ -128,6 +128,18 @@ describe("browser navigation guard", () => {
expect(lookupFn).not.toHaveBeenCalled();
});
it("allows hostname navigation when the default strict policy object is present", async () => {
const lookupFn = createLookupFn("93.184.216.34");
await expect(
assertBrowserNavigationAllowed({
url: "https://example.com",
lookupFn,
ssrfPolicy: {},
}),
).resolves.toBeUndefined();
expect(lookupFn).toHaveBeenCalledWith("example.com", { all: true });
});
it("allows explicitly allowed hostnames in strict mode", async () => {
const lookupFn = createLookupFn("93.184.216.34");
await expect(
@@ -300,8 +312,11 @@ describe("browser navigation guard", () => {
).resolves.toBeUndefined();
});
it("treats default browser SSRF mode as requiring redirect-hop inspection", () => {
expect(requiresInspectableBrowserNavigationRedirects()).toBe(true);
it("requires redirect-hop inspection only in explicit strict mode", () => {
expect(requiresInspectableBrowserNavigationRedirects()).toBe(false);
expect(
requiresInspectableBrowserNavigationRedirects({ dangerouslyAllowPrivateNetwork: false }),
).toBe(true);
expect(requiresInspectableBrowserNavigationRedirects({ allowPrivateNetwork: true })).toBe(
false,
);
@@ -43,7 +43,7 @@ export function withBrowserNavigationPolicy(
}
export function requiresInspectableBrowserNavigationRedirects(ssrfPolicy?: SsrFPolicy): boolean {
return !isPrivateNetworkAllowedByPolicy(ssrfPolicy);
return ssrfPolicy?.dangerouslyAllowPrivateNetwork === false;
}
export function requiresInspectableBrowserNavigationRedirectsForUrl(
@@ -122,6 +122,7 @@ export async function assertBrowserNavigationAllowed(
// the same address that passed policy checks.
if (
opts.ssrfPolicy &&
opts.ssrfPolicy.dangerouslyAllowPrivateNetwork === false &&
!isPrivateNetworkAllowedByPolicy(opts.ssrfPolicy) &&
!isIpLiteralHostname(parsed.hostname) &&
!isExplicitlyAllowedBrowserHostname(parsed.hostname, opts.ssrfPolicy)
@@ -42,7 +42,7 @@ describe("browser tab routes attachOnly loopback profiles", () => {
{
id: "PAGE-1",
title: "WordPress",
url: "https://example.test/wp-login.php",
url: "https://example.com/wp-login.php",
webSocketDebuggerUrl: "ws://127.0.0.1:9222/devtools/page/PAGE-1",
type: "page",
},
@@ -73,7 +73,7 @@ describe("browser tab routes attachOnly loopback profiles", () => {
{
targetId: "PAGE-1",
title: "WordPress",
url: "https://example.test/wp-login.php",
url: "https://example.com/wp-login.php",
wsUrl: "ws://127.0.0.1:9222/devtools/page/PAGE-1",
type: "page",
},
@@ -71,7 +71,6 @@ export function createProfileAvailability({
const getCdpReachabilityPolicy = () =>
resolveCdpReachabilityPolicy(profile, state().resolved.ssrfPolicy);
const isReachable = async (timeoutMs?: number) => {
if (capabilities.usesChromeMcp) {
// listChromeMcpTabs creates the session if needed — no separate ensureChromeMcpAvailable call required
@@ -80,7 +80,7 @@ describe("browser remote profile fallback and attachOnly behavior", () => {
it("fails closed for remote tab opens in strict mode without Playwright", async () => {
vi.spyOn(deps.pwAiModule, "getPwAiModule").mockResolvedValue(null);
const { state, remote, fetchMock } = deps.createRemoteRouteHarness();
state.resolved.ssrfPolicy = {};
state.resolved.ssrfPolicy = { dangerouslyAllowPrivateNetwork: false };
await expect(remote.openTab("https://example.com")).rejects.toBeInstanceOf(
deps.InvalidBrowserNavigationUrlError,
@@ -230,14 +230,14 @@ export function createProfileTabOps({
{
method: "PUT",
},
ssrfPolicyOpts.ssrfPolicy,
getCdpControlPolicy(),
).catch(async (err) => {
if (String(err).includes("HTTP 405")) {
return await fetchJson<CdpTarget>(
endpoint,
CDP_JSON_NEW_TIMEOUT_MS,
undefined,
ssrfPolicyOpts.ssrfPolicy,
getCdpControlPolicy(),
);
}
throw err;
@@ -6,6 +6,7 @@ vi.hoisted(() => {
});
import "./server-context.chrome-test-harness.js";
import * as cdpHelpersModule from "./cdp.helpers.js";
import * as cdpModule from "./cdp.js";
import { InvalidBrowserNavigationUrlError } from "./navigation-guard.js";
import { createBrowserRouteContext } from "./server-context.js";
@@ -296,4 +297,38 @@ describe("browser server-context tab selection state", () => {
);
expect(fetchMock).not.toHaveBeenCalled();
});
it("uses the loopback CDP control policy for /json/new fallback requests", async () => {
vi.spyOn(cdpModule, "createTargetViaCdp").mockRejectedValue(new Error("cdp unavailable"));
const fetchJson = vi.spyOn(cdpHelpersModule, "fetchJson");
fetchJson.mockRejectedValueOnce(new Error("HTTP 405")).mockResolvedValueOnce({
id: "NEW",
title: "New Tab",
url: "https://example.com",
webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/NEW",
type: "page",
});
const state = makeState("openclaw");
state.resolved.ssrfPolicy = {};
const ctx = createBrowserRouteContext({ getState: () => state });
const openclaw = ctx.forProfile("openclaw");
const opened = await openclaw.openTab("https://example.com");
expect(opened.targetId).toBe("NEW");
expect(fetchJson).toHaveBeenNthCalledWith(
1,
expect.stringContaining("/json/new"),
expect.any(Number),
{ method: "PUT" },
undefined,
);
expect(fetchJson).toHaveBeenNthCalledWith(
2,
expect.stringContaining("/json/new"),
expect.any(Number),
undefined,
undefined,
);
});
});
+1
View File
@@ -114,6 +114,7 @@ export async function buildCodexProviderCatalog(
return {
provider: {
baseUrl: CODEX_BASE_URL,
apiKey: "codex-app-server",
auth: "token",
api: "openai-codex-responses",
models,
@@ -52,8 +52,8 @@ function createConfig(): OpenClawConfig {
function createCommand(cfg: OpenClawConfig, discordConfig?: DiscordAccountConfig) {
const commandSpec: NativeCommandSpec = {
name: "status",
description: "Status",
name: "ping",
description: "Ping",
acceptsArgs: false,
};
return createDiscordNativeCommand({
@@ -19,6 +19,7 @@ const runtimeModuleMocks = vi.hoisted(() => ({
matchPluginCommand: vi.fn(),
executePluginCommand: vi.fn(),
dispatchReplyWithDispatcher: vi.fn(),
resolveDirectStatusReplyForSession: vi.fn(),
}));
vi.mock("openclaw/plugin-sdk/plugin-runtime", async () => {
@@ -43,6 +44,11 @@ vi.mock("openclaw/plugin-sdk/reply-runtime", async () => {
};
});
vi.mock("openclaw/plugin-sdk/command-status-runtime", () => ({
resolveDirectStatusReplyForSession: (...args: unknown[]) =>
runtimeModuleMocks.resolveDirectStatusReplyForSession(...args),
}));
function createInteraction(params?: {
channelType?: ChannelType;
channelId?: string;
@@ -306,35 +312,24 @@ function createDispatchSpy() {
} as never);
}
function expectBoundSessionDispatch(
dispatchSpy: ReturnType<typeof createDispatchSpy>,
expectedPattern: RegExp,
) {
expect(dispatchSpy).toHaveBeenCalledTimes(1);
const dispatchCall = dispatchSpy.mock.calls[0]?.[0] as {
ctx?: { SessionKey?: string; CommandTargetSessionKey?: string };
};
if (!dispatchCall.ctx?.SessionKey || !dispatchCall.ctx.CommandTargetSessionKey) {
throw new Error("native command dispatch did not include bound session context");
}
expect(dispatchCall.ctx.SessionKey).toMatch(expectedPattern);
expect(dispatchCall.ctx.CommandTargetSessionKey).toMatch(expectedPattern);
}
async function expectBoundStatusCommandDispatch(params: {
async function expectBoundStatusCommandDirectReply(params: {
cfg: OpenClawConfig;
interaction: MockCommandInteraction;
expectedPattern: RegExp;
}) {
runtimeModuleMocks.matchPluginCommand.mockReturnValue(null);
const dispatchSpy = createDispatchSpy();
const dispatchSpy = runtimeModuleMocks.dispatchReplyWithDispatcher;
const statusSpy = runtimeModuleMocks.resolveDirectStatusReplyForSession;
const command = await createStatusCommand(params.cfg);
await (command as { run: (interaction: unknown) => Promise<void> }).run(
params.interaction as unknown,
);
expectBoundSessionDispatch(dispatchSpy, params.expectedPattern);
expect(dispatchSpy).not.toHaveBeenCalled();
expect(statusSpy).toHaveBeenCalledTimes(1);
const statusCall = statusSpy.mock.calls[0]?.[0] as { sessionKey?: string };
expect(statusCall.sessionKey).toMatch(params.expectedPattern);
}
describe("Discord native plugin command dispatch", () => {
@@ -366,6 +361,10 @@ describe("Discord native plugin command dispatch", () => {
tool: 0,
},
} as never);
runtimeModuleMocks.resolveDirectStatusReplyForSession.mockReset();
runtimeModuleMocks.resolveDirectStatusReplyForSession.mockResolvedValue({
text: "status reply",
});
discordNativeCommandTesting.setMatchPluginCommand(
runtimeModuleMocks.matchPluginCommand as typeof import("openclaw/plugin-sdk/plugin-runtime").matchPluginCommand,
);
@@ -632,7 +631,7 @@ describe("Discord native plugin command dispatch", () => {
}),
);
await expectBoundStatusCommandDispatch({
await expectBoundStatusCommandDirectReply({
cfg,
interaction,
expectedPattern: /^agent:codex:acp:binding:discord:default:/,
@@ -683,7 +682,8 @@ describe("Discord native plugin command dispatch", () => {
}),
);
runtimeModuleMocks.matchPluginCommand.mockReturnValue(null);
const dispatchSpy = createDispatchSpy();
const dispatchSpy = runtimeModuleMocks.dispatchReplyWithDispatcher;
const statusSpy = runtimeModuleMocks.resolveDirectStatusReplyForSession;
const command = await createStatusCommand(cfg);
discordNativeCommandTesting.setResolveDiscordNativeInteractionRouteState(async () => ({
route: {
@@ -712,14 +712,10 @@ describe("Discord native plugin command dispatch", () => {
await (command as { run: (interaction: unknown) => Promise<void> }).run(interaction as unknown);
expect(dispatchSpy).toHaveBeenCalledTimes(1);
const dispatchCall = dispatchSpy.mock.calls[0]?.[0] as {
ctx?: { SessionKey?: string; CommandTargetSessionKey?: string };
};
expect(dispatchCall.ctx?.SessionKey).toBe("agent:qwen:discord:slash:owner");
expect(dispatchCall.ctx?.CommandTargetSessionKey).toBe(
"agent:qwen:discord:channel:1478836151241412759",
);
expect(dispatchSpy).not.toHaveBeenCalled();
expect(statusSpy).toHaveBeenCalledTimes(1);
const statusCall = statusSpy.mock.calls[0]?.[0] as { sessionKey?: string };
expect(statusCall.sessionKey).toBe("agent:qwen:discord:channel:1478836151241412759");
});
it("routes Discord DM native slash commands through configured ACP bindings", async () => {
@@ -735,7 +731,7 @@ describe("Discord native plugin command dispatch", () => {
}),
);
await expectBoundStatusCommandDispatch({
await expectBoundStatusCommandDirectReply({
cfg,
interaction,
expectedPattern: /^agent:codex:acp:binding:discord:default:/,
@@ -0,0 +1,200 @@
import { ChannelType } from "discord-api-types/v10";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import {
createMockCommandInteraction,
type MockCommandInteraction,
} from "./native-command.test-helpers.js";
import { createNoopThreadBindingManager } from "./thread-bindings.js";
const runtimeModuleMocks = vi.hoisted(() => ({
dispatchReplyWithDispatcher: vi.fn(),
resolveDirectStatusReplyForSession: vi.fn(),
}));
vi.mock("openclaw/plugin-sdk/reply-dispatch-runtime", async () => {
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/reply-dispatch-runtime")>(
"openclaw/plugin-sdk/reply-dispatch-runtime",
);
return {
...actual,
dispatchReplyWithDispatcher: (...args: unknown[]) =>
runtimeModuleMocks.dispatchReplyWithDispatcher(...args),
};
});
vi.mock("openclaw/plugin-sdk/command-status-runtime", () => ({
resolveDirectStatusReplyForSession: (...args: unknown[]) =>
runtimeModuleMocks.resolveDirectStatusReplyForSession(...args),
}));
let createDiscordNativeCommand: typeof import("./native-command.js").createDiscordNativeCommand;
let discordNativeCommandTesting: typeof import("./native-command.js").__testing;
function createInteraction(params?: {
channelType?: ChannelType;
channelId?: string;
threadParentId?: string | null;
guildId?: string | null;
guildName?: string;
}): MockCommandInteraction {
return createMockCommandInteraction({
userId: "owner",
username: "tester",
globalName: "Tester",
channelType: params?.channelType ?? ChannelType.DM,
channelId: params?.channelId ?? "dm-1",
threadParentId: params?.threadParentId,
guildId: params?.guildId ?? null,
guildName: params?.guildName,
interactionId: "interaction-1",
});
}
function createConfig(params?: { requireMention?: boolean }): OpenClawConfig {
return {
commands: {
useAccessGroups: false,
},
channels: {
discord: {
dm: { enabled: true, policy: "open" },
guilds: {
guild1: {
requireMention: true,
channels: {
chan1: {
allow: true,
requireMention: params?.requireMention ?? true,
},
},
},
},
},
},
} as OpenClawConfig;
}
async function createStatusCommand(cfg: OpenClawConfig) {
return createDiscordNativeCommand({
command: {
name: "status",
description: "Status",
acceptsArgs: false,
},
cfg,
discordConfig: cfg.channels?.discord ?? {},
accountId: "default",
sessionPrefix: "discord:slash",
ephemeralDefault: true,
threadBindings: createNoopThreadBindingManager("default"),
});
}
function setDefaultRouteState() {
discordNativeCommandTesting.setResolveDiscordNativeInteractionRouteState(async (params) => ({
route: {
agentId: "main",
channel: "discord",
accountId: params.accountId ?? "default",
sessionKey: "agent:main:main",
mainSessionKey: "agent:main:main",
lastRoutePolicy: "session",
matchedBy: "default",
},
effectiveRoute: {
agentId: "main",
channel: "discord",
accountId: params.accountId ?? "default",
sessionKey: "agent:main:main",
mainSessionKey: "agent:main:main",
lastRoutePolicy: "session",
matchedBy: "default",
},
boundSessionKey: undefined,
configuredRoute: null,
configuredBinding: null,
bindingReadiness: null,
}));
}
function firstStatusCall(): {
cfg: OpenClawConfig;
sessionKey: string;
channel: string;
isGroup: boolean;
defaultGroupActivation: () => "always" | "mention";
} {
const call = runtimeModuleMocks.resolveDirectStatusReplyForSession.mock.calls[0]?.[0];
if (!call) {
throw new Error("expected resolveDirectStatusReplyForSession to be called");
}
return call as {
cfg: OpenClawConfig;
sessionKey: string;
channel: string;
isGroup: boolean;
defaultGroupActivation: () => "always" | "mention";
};
}
describe("discord native /status", () => {
beforeAll(async () => {
({ createDiscordNativeCommand, __testing: discordNativeCommandTesting } =
await import("./native-command.js"));
});
beforeEach(() => {
vi.clearAllMocks();
runtimeModuleMocks.dispatchReplyWithDispatcher.mockResolvedValue({
counts: {
final: 0,
block: 0,
tool: 0,
},
queuedFinal: false,
} as never);
runtimeModuleMocks.resolveDirectStatusReplyForSession.mockResolvedValue({
text: "status reply",
});
discordNativeCommandTesting.setDispatchReplyWithDispatcher(
runtimeModuleMocks.dispatchReplyWithDispatcher as typeof import("openclaw/plugin-sdk/reply-dispatch-runtime").dispatchReplyWithDispatcher,
);
setDefaultRouteState();
});
it("returns a direct status reply without falling through the generic dispatcher", async () => {
const cfg = createConfig();
const command = await createStatusCommand(cfg);
const interaction = createInteraction();
await (command as { run: (interaction: unknown) => Promise<void> }).run(interaction as unknown);
expect(runtimeModuleMocks.resolveDirectStatusReplyForSession).toHaveBeenCalledTimes(1);
expect(runtimeModuleMocks.dispatchReplyWithDispatcher).not.toHaveBeenCalled();
expect(interaction.followUp).toHaveBeenCalledWith(
expect.objectContaining({
content: "status reply",
}),
);
expect(interaction.reply).not.toHaveBeenCalled();
});
it("passes through the effective guild activation when requireMention is disabled", async () => {
const cfg = createConfig({ requireMention: false });
const command = await createStatusCommand(cfg);
const interaction = createInteraction({
channelType: ChannelType.GuildText,
channelId: "chan1",
guildId: "guild1",
guildName: "Guild One",
});
await (command as { run: (interaction: unknown) => Promise<void> }).run(interaction as unknown);
const statusCall = firstStatusCall();
expect(statusCall.channel).toBe("discord");
expect(statusCall.isGroup).toBe(true);
expect(statusCall.defaultGroupActivation()).toBe("always");
});
});
@@ -145,7 +145,9 @@ describe("discord native /think autocomplete", () => {
providerThinkingMocks.resolveProviderBinaryThinking.mockReturnValue(undefined);
providerThinkingMocks.resolveProviderDefaultThinkingLevel.mockReturnValue(undefined);
providerThinkingMocks.resolveProviderXHighThinking.mockImplementation(({ provider, context }) =>
provider === "openai-codex" && context.modelId === "gpt-5.4" ? true : undefined,
provider === "openai-codex" && ["gpt-5.4", "gpt-5.4-pro"].includes(context.modelId)
? true
: undefined,
);
buildModelsProviderDataMock.mockResolvedValue({
byProvider: new Map<string, Set<string>>(),
@@ -172,7 +174,9 @@ describe("discord native /think autocomplete", () => {
providerThinkingMocks.resolveProviderDefaultThinkingLevel.mockReturnValue(undefined);
providerThinkingMocks.resolveProviderXHighThinking.mockReset();
providerThinkingMocks.resolveProviderXHighThinking.mockImplementation(({ provider, context }) =>
provider === "openai-codex" && context.modelId === "gpt-5.4" ? true : undefined,
provider === "openai-codex" && ["gpt-5.4", "gpt-5.4-pro"].includes(context.modelId)
? true
: undefined,
);
fs.mkdirSync(path.dirname(STORE_PATH), { recursive: true });
fs.writeFileSync(
@@ -18,6 +18,7 @@ import {
resolveCommandAuthorizedFromAuthorizers,
resolveNativeCommandSessionTargets,
} from "openclaw/plugin-sdk/command-auth-native";
import { resolveDirectStatusReplyForSession } from "openclaw/plugin-sdk/command-status-runtime";
import type { OpenClawConfig, loadConfig } from "openclaw/plugin-sdk/config-runtime";
import { buildPairingReply } from "openclaw/plugin-sdk/conversation-runtime";
import { isDangerousNameMatchingEnabled } from "openclaw/plugin-sdk/dangerous-name-runtime";
@@ -755,6 +756,7 @@ async function dispatchDiscordCommandInteraction(params: {
threadBindings,
suppressReplies,
} = params;
const commandName = command.nativeName ?? command.key;
const respond = async (content: string, options?: { ephemeral?: boolean }) => {
const payload = {
content,
@@ -869,15 +871,10 @@ async function dispatchDiscordCommandInteraction(params: {
conversationId: rawChannelId || "unknown",
parentConversationId: threadParentId,
threadBinding: isThreadChannel ? threadBindings.getByThreadId(rawChannelId) : undefined,
enforceConfiguredBindingReadiness: !shouldBypassConfiguredAcpEnsure(
command.nativeName ?? command.key,
),
enforceConfiguredBindingReadiness: !shouldBypassConfiguredAcpEnsure(commandName),
}));
const canBypassConfiguredAcpGuildGuards = async () => {
if (
!interaction.guild ||
!shouldBypassConfiguredAcpGuildGuards(command.nativeName ?? command.key)
) {
if (!interaction.guild || !shouldBypassConfiguredAcpGuildGuards(commandName)) {
return false;
}
const routeState = await getNativeRouteState();
@@ -1131,6 +1128,36 @@ async function dispatchDiscordCommandInteraction(params: {
targetSessionKey: effectiveRoute.sessionKey,
boundSessionKey,
});
const mediaLocalRoots = getAgentScopedMediaLocalRoots(cfg, effectiveRoute.agentId);
if (!suppressReplies && commandName === "status") {
const statusReply = await resolveDirectStatusReplyForSession({
cfg,
sessionKey: commandTargetSessionKey?.trim() || sessionKey,
channel: "discord",
senderId: sender.id,
senderIsOwner: ownerOk,
isAuthorizedSender: commandAuthorized,
isGroup: isGuild || isGroupDm,
defaultGroupActivation: () =>
!isGuild ? "always" : channelConfig?.requireMention === false ? "always" : "mention",
});
if (statusReply && hasRenderableReplyPayload(statusReply)) {
await deliverDiscordInteractionReply({
interaction,
payload: statusReply,
mediaLocalRoots,
textLimit: resolveTextChunkLimit(cfg, "discord", accountId, {
fallbackLimit: 2000,
}),
maxLinesPerMessage: resolveDiscordMaxLinesPerMessage({ cfg, discordConfig, accountId }),
preferFollowUp,
chunkMode: resolveChunkMode(cfg, "discord", accountId),
});
return;
}
await respond("Status unavailable.");
return;
}
const ctxPayload = buildDiscordNativeCommandContext({
prompt,
commandArgs: commandArgs ?? {},
@@ -1164,7 +1191,6 @@ async function dispatchDiscordCommandInteraction(params: {
channel: "discord",
accountId: effectiveRoute.accountId,
});
const mediaLocalRoots = getAgentScopedMediaLocalRoots(cfg, effectiveRoute.agentId);
const blockStreamingEnabled = resolveChannelStreamingBlockEnabled(discordConfig);
let didReply = false;
@@ -386,7 +386,7 @@ export async function expectFeishuReplyPipelineDedupedAfterPostSendFailure(param
expect(params.dispatchReplyFromConfigMock).toHaveBeenCalledTimes(1);
expect(params.runtimeErrorMock).toHaveBeenCalledTimes(1);
},
{ timeout: waitTimeoutMs },
waitTimeoutMs == null ? undefined : { timeout: waitTimeoutMs },
),
waitForSecond: () =>
vi.waitFor(
@@ -394,7 +394,7 @@ export async function expectFeishuReplyPipelineDedupedAfterPostSendFailure(param
expect(params.dispatchReplyFromConfigMock).toHaveBeenCalledTimes(1);
expect(params.runtimeErrorMock).toHaveBeenCalledTimes(1);
},
{ timeout: waitTimeoutMs },
waitTimeoutMs == null ? undefined : { timeout: waitTimeoutMs },
),
});
}
+1 -1
View File
@@ -10,7 +10,7 @@ import { buildGithubCopilotReplayPolicy } from "./replay-policy.js";
import { wrapCopilotProviderStream } from "./stream.js";
const COPILOT_ENV_VARS = ["COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"];
const COPILOT_XHIGH_MODEL_IDS = ["gpt-5.2", "gpt-5.2-codex"] as const;
const COPILOT_XHIGH_MODEL_IDS = ["gpt-5.4", "gpt-5.2", "gpt-5.2-codex"] as const;
type GithubCopilotPluginConfig = {
discovery?: {
+102 -1
View File
@@ -1,7 +1,10 @@
import type { ProviderRequestTransportOverrides } from "openclaw/plugin-sdk/provider-http";
import { describe, expect, it } from "vitest";
import {
isGoogleGenerativeAiApi,
normalizeGoogleApiBaseUrl,
normalizeGoogleGenerativeAiBaseUrl,
normalizeGoogleProviderConfig,
parseGeminiAuth,
resolveGoogleGenerativeAiHttpRequestConfig,
resolveGoogleGenerativeAiApiOrigin,
@@ -38,6 +41,20 @@ describe("google generative ai helpers", () => {
expect(normalizeGoogleGenerativeAiBaseUrl()).toBeUndefined();
});
it("keeps /openai on generic Google base URL normalization and strips it only for native Gemini callers", () => {
expect(
normalizeGoogleApiBaseUrl("https://generativelanguage.googleapis.com/v1beta/openai"),
).toBe("https://generativelanguage.googleapis.com/v1beta/openai");
expect(
normalizeGoogleGenerativeAiBaseUrl("https://generativelanguage.googleapis.com/v1beta/openai"),
).toBe("https://generativelanguage.googleapis.com/v1beta");
expect(
normalizeGoogleGenerativeAiBaseUrl(
"https://generativelanguage.googleapis.com/v1alpha/openai/",
),
).toBe("https://generativelanguage.googleapis.com/v1alpha");
});
it("normalizes Google provider configs by provider key, provider api, or model api", () => {
expect(
shouldNormalizeGoogleGenerativeAiProviderConfig("google", {
@@ -61,6 +78,12 @@ describe("google generative ai helpers", () => {
models: [{ api: "openai-completions" }],
}),
).toBe(false);
expect(
shouldNormalizeGoogleGenerativeAiProviderConfig("google", {
api: "openai-completions",
models: [{ api: "openai-completions" }],
}),
).toBe(false);
});
it("normalizes transport baseUrls only for Google Generative AI", () => {
@@ -84,6 +107,36 @@ describe("google generative ai helpers", () => {
});
});
it("normalizes google-vertex model ids without rewriting the OpenAI-compatible baseUrl", () => {
expect(
normalizeGoogleProviderConfig("google-vertex", {
api: "openai-completions",
baseUrl:
"https://aiplatform.googleapis.com/v1/projects/test/locations/us-central1/endpoints/openapi",
models: [
{
id: "gemini-3.1-flash-lite",
name: "Gemini Flash Lite",
input: ["text"],
reasoning: false,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 1,
maxTokens: 1,
},
],
}),
).toMatchObject({
api: "openai-completions",
baseUrl:
"https://aiplatform.googleapis.com/v1/projects/test/locations/us-central1/endpoints/openapi",
models: [
expect.objectContaining({
id: "gemini-3.1-flash-lite-preview",
}),
],
});
});
it("derives the Gemini API origin without duplicating /v1beta", () => {
expect(resolveGoogleGenerativeAiApiOrigin()).toBe("https://generativelanguage.googleapis.com");
expect(resolveGoogleGenerativeAiApiOrigin("https://generativelanguage.googleapis.com")).toBe(
@@ -123,7 +176,7 @@ describe("google generative ai helpers", () => {
});
expect(oauthConfig).toMatchObject({
baseUrl: "https://generativelanguage.googleapis.com/v1beta",
allowPrivateNetwork: true,
allowPrivateNetwork: false,
});
expect(Object.fromEntries(new Headers(oauthConfig.headers).entries())).toEqual({
authorization: "Bearer oauth-token",
@@ -144,4 +197,52 @@ describe("google generative ai helpers", () => {
"x-goog-api-key": "api-key-123",
});
});
it("preserves explicit OpenAI-compatible Google endpoints during provider normalization", () => {
expect(
resolveGoogleGenerativeAiTransport({
api: "openai-completions",
baseUrl: "https://generativelanguage.googleapis.com/v1beta/openai",
}),
).toEqual({
api: "openai-completions",
baseUrl: "https://generativelanguage.googleapis.com/v1beta/openai",
});
});
it("strips URL credentials during Google base URL normalization", () => {
const normalized = normalizeGoogleApiBaseUrl(
"https://user:secret@generativelanguage.googleapis.com/v1beta/openai?x=1#frag",
);
expect(normalized).toBe("https://generativelanguage.googleapis.com/v1beta/openai");
});
it("rejects non-Google Gemini base URLs and ignores smuggled private-network flags", () => {
expect(() =>
resolveGoogleGenerativeAiHttpRequestConfig({
apiKey: "api-key-123",
baseUrl: "https://proxy.example.com/v1beta",
capability: "image",
transport: "http",
}),
).toThrow("Google Generative AI baseUrl must use https://generativelanguage.googleapis.com");
expect(() =>
resolveGoogleGenerativeAiHttpRequestConfig({
apiKey: "api-key-123",
baseUrl: "http://generativelanguage.googleapis.com/v1beta",
capability: "image",
transport: "http",
}),
).toThrow("Google Generative AI baseUrl must use https://generativelanguage.googleapis.com");
const config = resolveGoogleGenerativeAiHttpRequestConfig({
apiKey: "api-key-123",
baseUrl: "https://generativelanguage.googleapis.com/v1beta",
capability: "image",
transport: "http",
request: { allowPrivateNetwork: true } as unknown as ProviderRequestTransportOverrides,
});
expect(config.allowPrivateNetwork).toBe(false);
});
});
+30 -3
View File
@@ -7,7 +7,11 @@ import {
type OpenClawConfig,
} from "openclaw/plugin-sdk/provider-onboard";
import { parseGoogleOauthApiKey } from "./oauth-token-shared.js";
import { DEFAULT_GOOGLE_API_BASE_URL, normalizeGoogleApiBaseUrl } from "./provider-policy.js";
import {
DEFAULT_GOOGLE_API_BASE_URL,
normalizeGoogleApiBaseUrl,
normalizeGoogleGenerativeAiBaseUrl,
} from "./provider-policy.js";
export { normalizeAntigravityModelId, normalizeGoogleModelId } from "./model-id.js";
export {
DEFAULT_GOOGLE_API_BASE_URL,
@@ -40,6 +44,29 @@ export function parseGeminiAuth(apiKey: string): { headers: Record<string, strin
};
}
function resolveTrustedGoogleGenerativeAiBaseUrl(baseUrl?: string): string {
const normalized =
normalizeGoogleGenerativeAiBaseUrl(baseUrl ?? DEFAULT_GOOGLE_API_BASE_URL) ??
DEFAULT_GOOGLE_API_BASE_URL;
let url: URL;
try {
url = new URL(normalized);
} catch {
throw new Error(
"Google Generative AI baseUrl must be a valid https URL on generativelanguage.googleapis.com",
);
}
if (
url.protocol !== "https:" ||
url.hostname.toLowerCase() !== "generativelanguage.googleapis.com"
) {
throw new Error(
"Google Generative AI baseUrl must use https://generativelanguage.googleapis.com",
);
}
return normalized;
}
export function resolveGoogleGenerativeAiHttpRequestConfig(params: {
apiKey: string;
baseUrl?: string;
@@ -49,9 +76,9 @@ export function resolveGoogleGenerativeAiHttpRequestConfig(params: {
transport: "http" | "media-understanding";
}) {
return resolveProviderHttpRequestConfig({
baseUrl: normalizeGoogleApiBaseUrl(params.baseUrl ?? DEFAULT_GOOGLE_API_BASE_URL),
baseUrl: resolveTrustedGoogleGenerativeAiBaseUrl(params.baseUrl),
defaultBaseUrl: DEFAULT_GOOGLE_API_BASE_URL,
allowPrivateNetwork: Boolean(params.baseUrl?.trim()),
allowPrivateNetwork: false,
headers: params.headers,
request: params.request,
defaultHeaders: parseGeminiAuth(params.apiKey).headers,
@@ -305,6 +305,33 @@ describe("Google image-generation provider", () => {
);
});
it("strips a configured /openai suffix before calling the native Gemini image API", async () => {
mockGoogleApiKeyAuth();
const fetchMock = installGoogleFetchMock();
const provider = buildGoogleImageGenerationProvider();
await provider.generateImage({
provider: "google",
model: "gemini-3-pro-image-preview",
prompt: "draw a fox",
cfg: {
models: {
providers: {
google: {
baseUrl: "https://generativelanguage.googleapis.com/v1beta/openai",
models: [],
},
},
},
},
});
expect(fetchMock).toHaveBeenCalledWith(
"https://generativelanguage.googleapis.com/v1beta/models/gemini-3-pro-image-preview:generateContent",
expect.any(Object),
);
});
it("prefers scoped configured Gemini API keys over environment fallbacks", () => {
expect(
geminiWebSearchTesting.resolveGeminiApiKey({
@@ -79,7 +79,7 @@ describe("describeGeminiVideo", () => {
fileName: "clip.mp4",
apiKey: "test-key",
timeoutMs: 1500,
baseUrl: "https://example.com/v1beta/",
baseUrl: "https://generativelanguage.googleapis.com/v1beta/",
model: "gemini-3-pro",
headers: { "X-Other": "1" },
fetchFn,
@@ -88,7 +88,9 @@ describe("describeGeminiVideo", () => {
expect(result.model).toBe("gemini-3-pro-preview");
expect(result.text).toBe("first\nsecond");
expect(seenUrl).toBe("https://example.com/v1beta/models/gemini-3-pro-preview:generateContent");
expect(seenUrl).toBe(
"https://generativelanguage.googleapis.com/v1beta/models/gemini-3-pro-preview:generateContent",
);
expect(seenInit?.method).toBe("POST");
expect(seenInit?.signal).toBeInstanceOf(AbortSignal);
@@ -110,4 +112,21 @@ describe("describeGeminiVideo", () => {
Buffer.from("video-bytes").toString("base64"),
);
});
it("rejects non-Google video base URLs before sending authenticated requests", async () => {
await expect(
describeGeminiVideo({
buffer: Buffer.from("video-bytes"),
fileName: "clip.mp4",
apiKey: "test-key",
timeoutMs: 1500,
baseUrl: "https://example.com/v1beta/",
fetchFn: async () => {
throw new Error("fetch should not run");
},
}),
).rejects.toThrow(
"Google Generative AI baseUrl must use https://generativelanguage.googleapis.com",
);
});
});
@@ -28,4 +28,20 @@ describe("google provider policy public artifact", () => {
models: [{ id: "gemini-3-pro-preview" }],
});
});
it("preserves explicit OpenAI-compatible Google endpoints during normalization", () => {
expect(
normalizeConfig({
provider: "google",
providerConfig: {
baseUrl: "https://generativelanguage.googleapis.com/v1beta/openai",
api: "openai-completions",
models: [],
},
}),
).toMatchObject({
baseUrl: "https://generativelanguage.googleapis.com/v1beta/openai",
api: "openai-completions",
});
});
});
+52 -14
View File
@@ -29,14 +29,21 @@ function isGoogleGenerativeAiUrl(url: URL): boolean {
);
}
function stripUrlUserInfo(url: URL): void {
url.username = "";
url.password = "";
}
export function normalizeGoogleApiBaseUrl(baseUrl?: string): string {
const raw = trimTrailingSlashes(normalizeOptionalString(baseUrl) || DEFAULT_GOOGLE_API_BASE_URL);
try {
const url = new URL(raw);
url.hash = "";
url.search = "";
if (isGoogleGenerativeAiUrl(url) && trimTrailingSlashes(url.pathname || "") === "") {
url.pathname = "/v1beta";
stripUrlUserInfo(url);
if (isGoogleGenerativeAiUrl(url)) {
const normalizedPath = trimTrailingSlashes(url.pathname || "");
url.pathname = normalizedPath || "/v1beta";
}
return trimTrailingSlashes(url.toString());
} catch {
@@ -52,7 +59,23 @@ export function isGoogleGenerativeAiApi(api?: string | null): boolean {
}
export function normalizeGoogleGenerativeAiBaseUrl(baseUrl?: string): string | undefined {
return baseUrl ? normalizeGoogleApiBaseUrl(baseUrl) : baseUrl;
if (!baseUrl) {
return baseUrl;
}
const normalized = normalizeGoogleApiBaseUrl(baseUrl);
try {
const url = new URL(normalized);
stripUrlUserInfo(url);
if (isGoogleGenerativeAiUrl(url)) {
url.pathname = trimTrailingSlashes(url.pathname || "").replace(/\/openai$/i, "") || "/v1beta";
return trimTrailingSlashes(url.toString());
}
} catch {
// `normalizeGoogleApiBaseUrl` already returned the best-effort input form.
}
return normalized;
}
export function resolveGoogleGenerativeAiTransport<TApi extends string | null | undefined>(params: {
@@ -68,20 +91,28 @@ export function resolveGoogleGenerativeAiTransport<TApi extends string | null |
}
export function resolveGoogleGenerativeAiApiOrigin(baseUrl?: string): string {
return normalizeGoogleApiBaseUrl(baseUrl).replace(/\/v1beta$/i, "");
return (
normalizeGoogleGenerativeAiBaseUrl(baseUrl) ?? normalizeGoogleApiBaseUrl(baseUrl)
).replace(/\/v1beta$/i, "");
}
export function shouldNormalizeGoogleGenerativeAiProviderConfig(
providerKey: string,
provider: GoogleProviderConfigLike,
): boolean {
if (providerKey === "google" || providerKey === "google-vertex") {
return true;
}
if (isGoogleGenerativeAiApi(provider.api)) {
return true;
}
return provider.models?.some((model) => isGoogleGenerativeAiApi(model?.api)) ?? false;
const hasGoogleGenerativeAiModelApi =
provider.models?.some((model) => isGoogleGenerativeAiApi(model?.api)) ?? false;
if (hasGoogleGenerativeAiModelApi) {
return true;
}
if (providerKey !== "google" && providerKey !== "google-vertex") {
return false;
}
const hasExplicitNonGoogleApi = normalizeOptionalString(provider.api) !== undefined;
return !hasExplicitNonGoogleApi;
}
export function shouldNormalizeGoogleProviderConfig(
@@ -121,14 +152,21 @@ export function normalizeGoogleProviderConfig(
provider: ModelProviderConfig,
): ModelProviderConfig {
let nextProvider = provider;
const shouldNormalizeModelIds =
providerKey === "google-vertex" ||
shouldNormalizeGoogleGenerativeAiProviderConfig(providerKey, nextProvider);
if (shouldNormalizeGoogleGenerativeAiProviderConfig(providerKey, nextProvider)) {
if (shouldNormalizeModelIds) {
const modelNormalized = normalizeProviderModels(nextProvider, normalizeGoogleModelId);
const normalizedBaseUrl = normalizeGoogleGenerativeAiBaseUrl(modelNormalized.baseUrl);
nextProvider =
normalizedBaseUrl !== modelNormalized.baseUrl
? { ...modelNormalized, baseUrl: normalizedBaseUrl ?? modelNormalized.baseUrl }
: modelNormalized;
if (shouldNormalizeGoogleGenerativeAiProviderConfig(providerKey, modelNormalized)) {
const normalizedBaseUrl = normalizeGoogleGenerativeAiBaseUrl(modelNormalized.baseUrl);
nextProvider =
normalizedBaseUrl !== modelNormalized.baseUrl
? { ...modelNormalized, baseUrl: normalizedBaseUrl ?? modelNormalized.baseUrl }
: modelNormalized;
} else {
nextProvider = modelNormalized;
}
}
if (providerKey === "google-antigravity") {
@@ -6,12 +6,16 @@ import { resolveSessionTranscriptsDirForAgent } from "openclaw/plugin-sdk/memory
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import {
clearMemoryEmbeddingProviders as clearRegistry,
listMemoryEmbeddingProviders as listRegisteredAdapters,
registerMemoryEmbeddingProvider as registerAdapter,
} from "../../../../src/plugins/memory-embedding-providers.js";
import "./test-runtime-mocks.js";
import type { MemoryIndexManager } from "./index.js";
import { getMemorySearchManager, closeAllMemorySearchManagers } from "./index.js";
import { registerBuiltInMemoryEmbeddingProviders } from "./provider-adapters.js";
import {
DEFAULT_OLLAMA_EMBEDDING_MODEL,
registerBuiltInMemoryEmbeddingProviders,
} from "./provider-adapters.js";
let embedBatchCalls = 0;
let embedBatchInputCalls = 0;
@@ -108,6 +112,18 @@ vi.mock("./embeddings.js", () => {
});
describe("memory index", () => {
it("registers the builtin ollama embedding provider", () => {
const adapter = listRegisteredAdapters().find((entry) => entry.id === "ollama");
expect(adapter).toBeDefined();
expect(adapter).toEqual(
expect.objectContaining({
id: "ollama",
defaultModel: DEFAULT_OLLAMA_EMBEDDING_MODEL,
}),
);
});
let fixtureRoot = "";
let workspaceDir = "";
let memoryDir = "";
@@ -4,6 +4,7 @@ import {
DEFAULT_LMSTUDIO_EMBEDDING_MODEL,
DEFAULT_LOCAL_MODEL,
DEFAULT_MISTRAL_EMBEDDING_MODEL,
DEFAULT_OLLAMA_EMBEDDING_MODEL,
DEFAULT_OPENAI_EMBEDDING_MODEL,
DEFAULT_VOYAGE_EMBEDDING_MODEL,
OPENAI_BATCH_ENDPOINT,
@@ -12,6 +13,7 @@ import {
createLmstudioEmbeddingProvider,
createLocalEmbeddingProvider,
createMistralEmbeddingProvider,
createOllamaEmbeddingProvider,
createOpenAiEmbeddingProvider,
createVoyageEmbeddingProvider,
hasNonTextEmbeddingParts,
@@ -290,6 +292,31 @@ const mistralAdapter: MemoryEmbeddingProviderAdapter = {
},
};
const ollamaAdapter: MemoryEmbeddingProviderAdapter = {
id: "ollama",
defaultModel: DEFAULT_OLLAMA_EMBEDDING_MODEL,
transport: "remote",
create: async (options) => {
const { provider, client } = await createOllamaEmbeddingProvider({
...options,
provider: "ollama",
fallback: "none",
});
return {
provider,
runtime: {
id: "ollama",
cacheKeyData: {
provider: "ollama",
baseUrl: client.baseUrl,
model: client.model,
headers: sanitizeHeaders(client.headers, ["authorization"]),
},
},
};
},
};
const lmstudioAdapter: MemoryEmbeddingProviderAdapter = {
id: "lmstudio",
defaultModel: DEFAULT_LMSTUDIO_EMBEDDING_MODEL,
@@ -347,6 +374,7 @@ export const builtinMemoryEmbeddingProviderAdapters = [
geminiAdapter,
voyageAdapter,
mistralAdapter,
ollamaAdapter,
lmstudioAdapter,
] as const;
@@ -409,6 +437,7 @@ export {
DEFAULT_LMSTUDIO_EMBEDDING_MODEL,
DEFAULT_LOCAL_MODEL,
DEFAULT_MISTRAL_EMBEDDING_MODEL,
DEFAULT_OLLAMA_EMBEDDING_MODEL,
DEFAULT_OPENAI_EMBEDDING_MODEL,
DEFAULT_VOYAGE_EMBEDDING_MODEL,
canAutoSelectLocal,
@@ -99,13 +99,14 @@ describe("createNextcloudTalkWebhookServer replay handling", () => {
stateDir: params.stateDir,
});
return async (message: NextcloudTalkInboundMessage) =>
return async (message: NextcloudTalkInboundMessage): Promise<void> => {
await processNextcloudTalkReplayGuardedMessage({
replayGuard,
accountId: params.accountId ?? "acct",
message,
handleMessage: () => params.handleMessage(message),
});
};
}
it("acknowledges replayed requests and skips onMessage side effects", async () => {
+9 -3
View File
@@ -22,9 +22,15 @@ const runtimeMocks = vi.hoisted(() => ({
refreshOpenAICodexToken: vi.fn(),
}));
vi.mock("openclaw/plugin-sdk/runtime-env", () => ({
ensureGlobalUndiciEnvProxyDispatcher: runtimeMocks.ensureGlobalUndiciEnvProxyDispatcher,
}));
vi.mock("openclaw/plugin-sdk/runtime-env", async () => {
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/runtime-env")>(
"openclaw/plugin-sdk/runtime-env",
);
return {
...actual,
ensureGlobalUndiciEnvProxyDispatcher: runtimeMocks.ensureGlobalUndiciEnvProxyDispatcher,
};
});
vi.mock("@mariozechner/pi-ai/oauth", async () => {
const actual = await vi.importActual<typeof import("@mariozechner/pi-ai/oauth")>(
@@ -1,5 +1,16 @@
import fs from "node:fs";
import { afterEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const runtimeMocks = vi.hoisted(() => ({
debug: vi.fn(),
}));
vi.mock("openclaw/plugin-sdk/runtime-env", () => ({
createSubsystemLogger: () => ({
debug: runtimeMocks.debug,
}),
}));
import {
OPENAI_CODEX_DEFAULT_PROFILE_ID,
readOpenAICodexCliOAuthProfile,
@@ -12,6 +23,10 @@ function buildJwt(payload: Record<string, unknown>) {
}
describe("readOpenAICodexCliOAuthProfile", () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
@@ -80,4 +95,54 @@ describe("readOpenAICodexCliOAuthProfile", () => {
expect(parsed).toBeNull();
});
it("returns null without logging when the Codex CLI auth file is missing", () => {
const error = Object.assign(new Error("missing"), {
code: "ENOENT",
});
vi.spyOn(fs, "readFileSync").mockImplementation(() => {
throw error;
});
const parsed = readOpenAICodexCliOAuthProfile({
store: { version: 1, profiles: {} },
});
expect(parsed).toBeNull();
expect(runtimeMocks.debug).not.toHaveBeenCalled();
});
it("logs a sanitized code for invalid auth JSON", () => {
vi.spyOn(fs, "readFileSync").mockReturnValue("{");
const parsed = readOpenAICodexCliOAuthProfile({
store: { version: 1, profiles: {} },
});
expect(parsed).toBeNull();
expect(runtimeMocks.debug).toHaveBeenCalledWith(
"Failed to read Codex CLI auth file (code=INVALID_JSON)",
);
});
it("does not leak auth file paths in debug logs for filesystem failures", () => {
const error = Object.assign(
new Error("EACCES: permission denied, open '/Users/alice/.codex/auth.json'"),
{
code: "EACCES",
},
);
vi.spyOn(fs, "readFileSync").mockImplementation(() => {
throw error;
});
const parsed = readOpenAICodexCliOAuthProfile({
store: { version: 1, profiles: {} },
});
expect(parsed).toBeNull();
expect(runtimeMocks.debug).toHaveBeenCalledWith(
"Failed to read Codex CLI auth file (code=EACCES)",
);
});
});
+15 -1
View File
@@ -2,6 +2,7 @@ import fs from "node:fs";
import path from "node:path";
import type { AuthProfileStore, OAuthCredential } from "openclaw/plugin-sdk/provider-auth";
import { resolveRequiredHomeDir } from "openclaw/plugin-sdk/provider-auth";
import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env";
import {
resolveCodexAccessTokenExpiry,
resolveCodexAuthIdentity,
@@ -9,6 +10,7 @@ import {
import { trimNonEmptyString } from "./openai-codex-shared.js";
const PROVIDER_ID = "openai-codex";
const log = createSubsystemLogger("openai/codex-cli-auth");
export const CODEX_CLI_PROFILE_ID = `${PROVIDER_ID}:codex-cli`;
export const OPENAI_CODEX_DEFAULT_PROFILE_ID = `${PROVIDER_ID}:default`;
@@ -42,7 +44,19 @@ function readCodexCliAuthFile(env: NodeJS.ProcessEnv): CodexCliAuthFile | null {
const raw = fs.readFileSync(authPath, "utf8");
const parsed = JSON.parse(raw);
return parsed && typeof parsed === "object" ? (parsed as CodexCliAuthFile) : null;
} catch {
} catch (error) {
const code =
error instanceof SyntaxError
? "INVALID_JSON"
: error instanceof Error && "code" in error
? (error as NodeJS.ErrnoException).code
: undefined;
if (code === "ENOENT") {
return null;
}
log.debug(
`Failed to read Codex CLI auth file (code=${typeof code === "string" ? code : "UNKNOWN"})`,
);
return null;
}
}
@@ -134,6 +134,117 @@ describe("openai codex provider", () => {
});
});
it("resolves gpt-5.4-pro with pro pricing and codex-sized limits", () => {
const provider = buildOpenAICodexProviderPlugin();
const model = provider.resolveDynamicModel?.({
provider: "openai-codex",
modelId: "gpt-5.4-pro",
modelRegistry: {
find: (providerId: string, modelId: string) => {
if (providerId === "openai-codex" && modelId === "gpt-5.3-codex") {
return {
id: "gpt-5.3-codex",
name: "gpt-5.3-codex",
provider: "openai-codex",
api: "openai-codex-responses",
baseUrl: "https://chatgpt.com/backend-api",
reasoning: true,
input: ["text", "image"] as const,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 272_000,
maxTokens: 128_000,
};
}
return undefined;
},
} as never,
});
expect(model).toMatchObject({
id: "gpt-5.4-pro",
contextWindow: 1_050_000,
contextTokens: 272_000,
maxTokens: 128_000,
cost: { input: 30, output: 180, cacheRead: 0, cacheWrite: 0 },
});
});
it("resolves gpt-5.4-pro from a gpt-5.4 runtime template when legacy codex rows are absent", () => {
const provider = buildOpenAICodexProviderPlugin();
const model = provider.resolveDynamicModel?.({
provider: "openai-codex",
modelId: "gpt-5.4-pro",
modelRegistry: {
find: (providerId: string, modelId: string) => {
if (providerId === "openai-codex" && modelId === "gpt-5.4") {
return {
id: "gpt-5.4",
name: "gpt-5.4",
provider: "openai-codex",
api: "openai-codex-responses",
baseUrl: "https://chatgpt.com/backend-api",
reasoning: true,
input: ["text", "image"] as const,
cost: { input: 2.5, output: 15, cacheRead: 0.25, cacheWrite: 0 },
contextWindow: 1_050_000,
contextTokens: 272_000,
maxTokens: 128_000,
};
}
return undefined;
},
} as never,
});
expect(model).toMatchObject({
id: "gpt-5.4-pro",
api: "openai-codex-responses",
baseUrl: "https://chatgpt.com/backend-api",
contextWindow: 1_050_000,
contextTokens: 272_000,
maxTokens: 128_000,
cost: { input: 30, output: 180, cacheRead: 0, cacheWrite: 0 },
});
});
it("resolves the legacy gpt-5.4-codex alias to canonical gpt-5.4", () => {
const provider = buildOpenAICodexProviderPlugin();
const model = provider.resolveDynamicModel?.({
provider: "openai-codex",
modelId: "gpt-5.4-codex",
modelRegistry: {
find: (providerId: string, modelId: string) => {
if (providerId === "openai-codex" && modelId === "gpt-5.3-codex") {
return {
id: "gpt-5.3-codex",
name: "gpt-5.3-codex",
provider: "openai-codex",
api: "openai-codex-responses",
baseUrl: "https://chatgpt.com/backend-api",
reasoning: true,
input: ["text", "image"] as const,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 272_000,
maxTokens: 128_000,
};
}
return undefined;
},
} as never,
});
expect(model).toMatchObject({
id: "gpt-5.4",
name: "gpt-5.4",
contextWindow: 1_050_000,
contextTokens: 272_000,
maxTokens: 128_000,
});
});
it("resolves gpt-5.4-mini from codex templates with codex-sized limits", () => {
const provider = buildOpenAICodexProviderPlugin();
@@ -192,13 +303,76 @@ describe("openai codex provider", () => {
id: "gpt-5.4",
contextWindow: 1_050_000,
contextTokens: 272_000,
cost: { input: 2.5, output: 15, cacheRead: 0.25, cacheWrite: 0 },
}),
);
expect(entries).toContainEqual(
expect.objectContaining({
id: "gpt-5.4-pro",
contextWindow: 1_050_000,
contextTokens: 272_000,
cost: { input: 30, output: 180, cacheRead: 0, cacheWrite: 0 },
}),
);
expect(entries).toContainEqual(
expect.objectContaining({
id: "gpt-5.4-mini",
contextWindow: 272_000,
cost: { input: 0.75, output: 4.5, cacheRead: 0.075, cacheWrite: 0 },
}),
);
});
it("augments gpt-5.4-pro from catalog gpt-5.4 when legacy codex rows are absent", () => {
const provider = buildOpenAICodexProviderPlugin();
const entries = provider.augmentModelCatalog?.({
env: process.env,
entries: [
{
id: "gpt-5.4",
name: "gpt-5.4",
provider: "openai-codex",
reasoning: true,
input: ["text", "image"],
contextWindow: 272_000,
},
],
} as never);
expect(entries).toContainEqual(
expect.objectContaining({
id: "gpt-5.4-pro",
contextWindow: 1_050_000,
contextTokens: 272_000,
cost: { input: 30, output: 180, cacheRead: 0, cacheWrite: 0 },
}),
);
});
it("canonicalizes legacy gpt-5.4-codex models during resolved-model normalization", () => {
const provider = buildOpenAICodexProviderPlugin();
const model = provider.normalizeResolvedModel?.({
provider: "openai-codex",
model: {
id: "gpt-5.4-codex",
name: "gpt-5.4-codex",
provider: "openai-codex",
api: "openai-codex-responses",
baseUrl: "https://chatgpt.com/backend-api",
reasoning: true,
input: ["text", "image"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 1_050_000,
contextTokens: 272_000,
maxTokens: 128_000,
},
} as never);
expect(model).toMatchObject({
id: "gpt-5.4",
name: "gpt-5.4",
});
});
});
+72 -14
View File
@@ -41,6 +41,8 @@ import {
const PROVIDER_ID = "openai-codex";
const OPENAI_CODEX_BASE_URL = "https://chatgpt.com/backend-api";
const OPENAI_CODEX_GPT_54_MODEL_ID = "gpt-5.4";
const OPENAI_CODEX_GPT_54_LEGACY_MODEL_ID = "gpt-5.4-codex";
const OPENAI_CODEX_GPT_54_PRO_MODEL_ID = "gpt-5.4-pro";
const OPENAI_CODEX_GPT_54_MINI_MODEL_ID = "gpt-5.4-mini";
const OPENAI_CODEX_GPT_54_NATIVE_CONTEXT_TOKENS = 1_050_000;
const OPENAI_CODEX_GPT_54_DEFAULT_CONTEXT_TOKENS = 272_000;
@@ -52,6 +54,12 @@ const OPENAI_CODEX_GPT_54_COST = {
cacheRead: 0.25,
cacheWrite: 0,
} as const;
const OPENAI_CODEX_GPT_54_PRO_COST = {
input: 30,
output: 180,
cacheRead: 0,
cacheWrite: 0,
} as const;
const OPENAI_CODEX_GPT_54_MINI_COST = {
input: 0.75,
output: 4.5,
@@ -59,6 +67,11 @@ const OPENAI_CODEX_GPT_54_MINI_COST = {
cacheWrite: 0,
} as const;
const OPENAI_CODEX_GPT_54_TEMPLATE_MODEL_IDS = ["gpt-5.3-codex", "gpt-5.2-codex"] as const;
/** Legacy codex rows first; fall back to catalog `gpt-5.4` when the API omits 5.3/5.2. */
const OPENAI_CODEX_GPT_54_CATALOG_SYNTH_TEMPLATE_MODEL_IDS = [
...OPENAI_CODEX_GPT_54_TEMPLATE_MODEL_IDS,
OPENAI_CODEX_GPT_54_MODEL_ID,
] as const;
const OPENAI_CODEX_GPT_54_MINI_TEMPLATE_MODEL_IDS = [
OPENAI_CODEX_GPT_54_MODEL_ID,
"gpt-5.1-codex-mini",
@@ -71,6 +84,7 @@ const OPENAI_CODEX_GPT_53_SPARK_MAX_TOKENS = 128_000;
const OPENAI_CODEX_TEMPLATE_MODEL_IDS = ["gpt-5.2-codex"] as const;
const OPENAI_CODEX_XHIGH_MODEL_IDS = [
OPENAI_CODEX_GPT_54_MODEL_ID,
OPENAI_CODEX_GPT_54_PRO_MODEL_ID,
OPENAI_CODEX_GPT_54_MINI_MODEL_ID,
OPENAI_CODEX_GPT_53_MODEL_ID,
OPENAI_CODEX_GPT_53_SPARK_MODEL_ID,
@@ -79,6 +93,7 @@ const OPENAI_CODEX_XHIGH_MODEL_IDS = [
] as const;
const OPENAI_CODEX_MODERN_MODEL_IDS = [
OPENAI_CODEX_GPT_54_MODEL_ID,
OPENAI_CODEX_GPT_54_PRO_MODEL_ID,
OPENAI_CODEX_GPT_54_MINI_MODEL_ID,
"gpt-5.2",
"gpt-5.2-codex",
@@ -88,6 +103,13 @@ const OPENAI_CODEX_MODERN_MODEL_IDS = [
const OPENAI_RESPONSES_STREAM_HOOKS = buildProviderStreamFamilyHooks("openai-responses-defaults");
function normalizeCodexTransport(model: ProviderRuntimeModel): ProviderRuntimeModel {
const lowerModelId = normalizeLowercaseStringOrEmpty(model.id);
const canonicalModelId =
lowerModelId === OPENAI_CODEX_GPT_54_LEGACY_MODEL_ID ? OPENAI_CODEX_GPT_54_MODEL_ID : model.id;
const canonicalName =
normalizeLowercaseStringOrEmpty(model.name) === OPENAI_CODEX_GPT_54_LEGACY_MODEL_ID
? OPENAI_CODEX_GPT_54_MODEL_ID
: model.name;
const useCodexTransport =
!model.baseUrl || isOpenAIApiBaseUrl(model.baseUrl) || isOpenAICodexBaseUrl(model.baseUrl);
const api =
@@ -96,32 +118,45 @@ function normalizeCodexTransport(model: ProviderRuntimeModel): ProviderRuntimeMo
api === "openai-codex-responses" && (!model.baseUrl || isOpenAIApiBaseUrl(model.baseUrl))
? OPENAI_CODEX_BASE_URL
: model.baseUrl;
if (api === model.api && baseUrl === model.baseUrl) {
if (
api === model.api &&
baseUrl === model.baseUrl &&
canonicalModelId === model.id &&
canonicalName === model.name
) {
return model;
}
return {
...model,
id: canonicalModelId,
name: canonicalName,
api,
baseUrl,
};
}
function resolveCodexForwardCompatModel(
ctx: ProviderResolveDynamicModelContext,
): ProviderRuntimeModel | undefined {
function resolveCodexForwardCompatModel(ctx: ProviderResolveDynamicModelContext) {
const trimmedModelId = ctx.modelId.trim();
const lower = normalizeLowercaseStringOrEmpty(trimmedModelId);
let templateIds: readonly string[];
let patch: Partial<ProviderRuntimeModel> | undefined;
if (lower === OPENAI_CODEX_GPT_54_MODEL_ID) {
templateIds = OPENAI_CODEX_GPT_54_TEMPLATE_MODEL_IDS;
let patch: Parameters<typeof cloneFirstTemplateModel>[0]["patch"];
if (lower === OPENAI_CODEX_GPT_54_MODEL_ID || lower === OPENAI_CODEX_GPT_54_LEGACY_MODEL_ID) {
templateIds = OPENAI_CODEX_GPT_54_CATALOG_SYNTH_TEMPLATE_MODEL_IDS;
patch = {
contextWindow: OPENAI_CODEX_GPT_54_NATIVE_CONTEXT_TOKENS,
contextTokens: OPENAI_CODEX_GPT_54_DEFAULT_CONTEXT_TOKENS,
maxTokens: OPENAI_CODEX_GPT_54_MAX_TOKENS,
cost: OPENAI_CODEX_GPT_54_COST,
};
} else if (lower === OPENAI_CODEX_GPT_54_PRO_MODEL_ID) {
templateIds = OPENAI_CODEX_GPT_54_CATALOG_SYNTH_TEMPLATE_MODEL_IDS;
patch = {
contextWindow: OPENAI_CODEX_GPT_54_NATIVE_CONTEXT_TOKENS,
contextTokens: OPENAI_CODEX_GPT_54_DEFAULT_CONTEXT_TOKENS,
maxTokens: OPENAI_CODEX_GPT_54_MAX_TOKENS,
cost: OPENAI_CODEX_GPT_54_PRO_COST,
};
} else if (lower === OPENAI_CODEX_GPT_54_MINI_MODEL_ID) {
templateIds = OPENAI_CODEX_GPT_54_MINI_TEMPLATE_MODEL_IDS;
patch = {
@@ -150,14 +185,23 @@ function resolveCodexForwardCompatModel(
return (
cloneFirstTemplateModel({
providerId: PROVIDER_ID,
modelId: trimmedModelId,
modelId:
lower === OPENAI_CODEX_GPT_54_LEGACY_MODEL_ID
? OPENAI_CODEX_GPT_54_MODEL_ID
: trimmedModelId,
templateIds,
ctx,
patch,
}) ??
normalizeModelCompat({
id: trimmedModelId,
name: trimmedModelId,
id:
lower === OPENAI_CODEX_GPT_54_LEGACY_MODEL_ID
? OPENAI_CODEX_GPT_54_MODEL_ID
: trimmedModelId,
name:
lower === OPENAI_CODEX_GPT_54_LEGACY_MODEL_ID
? OPENAI_CODEX_GPT_54_MODEL_ID
: trimmedModelId,
api: "openai-codex-responses",
provider: PROVIDER_ID,
baseUrl: OPENAI_CODEX_BASE_URL,
@@ -284,9 +328,13 @@ export function buildOpenAICodexProviderPlugin(): ProviderPlugin {
supportsXHighThinking: ({ modelId }) =>
matchesExactOrPrefix(modelId, OPENAI_CODEX_XHIGH_MODEL_IDS),
isModernModelRef: ({ modelId }) => matchesExactOrPrefix(modelId, OPENAI_CODEX_MODERN_MODEL_IDS),
preferRuntimeResolvedModel: (ctx) =>
normalizeProviderId(ctx.provider) === PROVIDER_ID &&
ctx.modelId.trim().toLowerCase() === OPENAI_CODEX_GPT_54_MODEL_ID,
preferRuntimeResolvedModel: (ctx) => {
if (normalizeProviderId(ctx.provider) !== PROVIDER_ID) {
return false;
}
const id = ctx.modelId.trim().toLowerCase();
return id === OPENAI_CODEX_GPT_54_MODEL_ID || id === OPENAI_CODEX_GPT_54_PRO_MODEL_ID;
},
buildReplayPolicy: buildOpenAIReplayPolicy,
prepareExtraParams: (ctx) => {
const transport = ctx.extraParams?.transport;
@@ -316,7 +364,7 @@ export function buildOpenAICodexProviderPlugin(): ProviderPlugin {
const gpt54Template = findCatalogTemplate({
entries: ctx.entries,
providerId: PROVIDER_ID,
templateIds: OPENAI_CODEX_GPT_54_TEMPLATE_MODEL_IDS,
templateIds: OPENAI_CODEX_GPT_54_CATALOG_SYNTH_TEMPLATE_MODEL_IDS,
});
const gpt54MiniTemplate = findCatalogTemplate({
entries: ctx.entries,
@@ -335,12 +383,22 @@ export function buildOpenAICodexProviderPlugin(): ProviderPlugin {
input: ["text", "image"],
contextWindow: OPENAI_CODEX_GPT_54_NATIVE_CONTEXT_TOKENS,
contextTokens: OPENAI_CODEX_GPT_54_DEFAULT_CONTEXT_TOKENS,
cost: OPENAI_CODEX_GPT_54_COST,
}),
buildOpenAISyntheticCatalogEntry(gpt54Template, {
id: OPENAI_CODEX_GPT_54_PRO_MODEL_ID,
reasoning: true,
input: ["text", "image"],
contextWindow: OPENAI_CODEX_GPT_54_NATIVE_CONTEXT_TOKENS,
contextTokens: OPENAI_CODEX_GPT_54_DEFAULT_CONTEXT_TOKENS,
cost: OPENAI_CODEX_GPT_54_PRO_COST,
}),
buildOpenAISyntheticCatalogEntry(gpt54MiniTemplate, {
id: OPENAI_CODEX_GPT_54_MINI_MODEL_ID,
reasoning: true,
input: ["text", "image"],
contextWindow: OPENAI_CODEX_GPT_54_MINI_CONTEXT_TOKENS,
cost: OPENAI_CODEX_GPT_54_MINI_COST,
}),
buildOpenAISyntheticCatalogEntry(sparkTemplate, {
id: OPENAI_CODEX_GPT_53_SPARK_MODEL_ID,
+10
View File
@@ -6,6 +6,13 @@ import {
} from "openclaw/plugin-sdk/provider-model-shared";
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
type SyntheticOpenAIModelCatalogCost = {
input: number;
output: number;
cacheRead: number;
cacheWrite: number;
};
type SyntheticOpenAIModelCatalogEntry = {
provider: string;
id: string;
@@ -14,6 +21,7 @@ type SyntheticOpenAIModelCatalogEntry = {
input?: ("text" | "image")[];
contextWindow?: number;
contextTokens?: number;
cost?: SyntheticOpenAIModelCatalogCost;
};
export const OPENAI_API_BASE_URL = "https://api.openai.com/v1";
@@ -50,6 +58,7 @@ export function buildOpenAISyntheticCatalogEntry(
input: readonly ("text" | "image")[];
contextWindow: number;
contextTokens?: number;
cost?: SyntheticOpenAIModelCatalogCost;
},
): SyntheticOpenAIModelCatalogEntry | undefined {
if (!template) {
@@ -63,6 +72,7 @@ export function buildOpenAISyntheticCatalogEntry(
input: [...entry.input],
contextWindow: entry.contextWindow,
...(entry.contextTokens === undefined ? {} : { contextTokens: entry.contextTokens }),
...(entry.cost === undefined ? {} : { cost: entry.cost }),
};
}
@@ -118,6 +118,30 @@ describe("qa scenario catalog", () => {
);
});
it("includes the seeded mock-only broken-turn scenarios in the markdown pack", () => {
const scenarioIds = [
"reasoning-only-recovery-replay-safe-read",
"reasoning-only-no-auto-retry-after-write",
"empty-response-recovery-replay-safe-read",
"empty-response-retry-budget-exhausted",
];
for (const scenarioId of scenarioIds) {
const scenario = readQaScenarioById(scenarioId);
const config = readQaScenarioExecutionConfig(scenarioId) as
| {
requiredProvider?: string;
prompt?: string;
}
| undefined;
expect(scenario.sourcePath).toBe(`qa/scenarios/${scenarioId}.md`);
expect(config?.requiredProvider).toBe("mock-openai");
expect(config?.prompt).toContain("check");
expect(scenario.execution.flow?.steps.length).toBeGreaterThan(0);
}
});
it("keeps mock-only image debug assertions guarded in live-frontier runs", () => {
const scenario = readQaScenarioPack().scenarios.find(
(candidate) => candidate.id === "image-understanding-attachment",
+1 -1
View File
@@ -350,7 +350,7 @@ export function readQaBootstrapScenarioCatalog(): QaBootstrapScenarioCatalog {
};
}
export function readQaScenarioById(id: string): QaSeedScenario {
export function readQaScenarioById(id: string): QaSeedScenarioWithSource {
const scenario = readQaScenarioPack().scenarios.find((candidate) => candidate.id === id);
if (!scenario) {
throw new Error(`unknown qa scenario: ${id}`);
+10
View File
@@ -214,6 +214,16 @@ describe("qa suite failure reply handling", () => {
});
});
it("enables Control UI only for Control UI scenario workers", () => {
expect(
qaSuiteTesting.scenarioRequiresControlUi({
...makeScenario("control-ui"),
surface: "control-ui",
}),
).toBe(true);
expect(qaSuiteTesting.scenarioRequiresControlUi(makeScenario("plain"))).toBe(false);
});
it("filters provider-specific scenarios from an implicit live lane", () => {
const scenarios = [
makeScenario("generic"),
+20 -7
View File
@@ -68,7 +68,7 @@ import { readQaBootstrapScenarioCatalog } from "./scenario-catalog.js";
import { runScenarioFlow } from "./scenario-flow-runner.js";
import { createQaScenarioRuntimeApi } from "./scenario-runtime-api.js";
import {
closeAllQaWebSessions,
closeQaWebSessions,
qaWebEvaluate,
qaWebOpenPage,
qaWebSnapshot,
@@ -98,6 +98,7 @@ type QaSuiteEnvironment = {
providerMode: "mock-openai" | "live-frontier";
primaryModel: string;
alternateModel: string;
webSessionIds: Set<string>;
};
export type QaSuiteStartLabFn = (params?: QaLabServerStartParams) => Promise<QaLabServerHandle>;
@@ -340,6 +341,12 @@ function collectQaSuiteGatewayRuntimeOptions(
return forwardHostHome ? { forwardHostHome: true } : undefined;
}
function scenarioRequiresControlUi(
scenario: ReturnType<typeof readQaBootstrapScenarioCatalog>["scenarios"][number],
) {
return normalizeLowercaseStringOrEmpty(scenario.surface) === "control-ui";
}
function liveTurnTimeoutMs(env: QaSuiteEnvironment, fallbackMs: number) {
return resolveQaLiveTurnTimeoutMs(env, fallbackMs);
}
@@ -1268,7 +1275,11 @@ function createScenarioFlowApi(
browserOpenTab: qaBrowserOpenTab,
browserSnapshot: qaBrowserSnapshot,
browserAct: qaBrowserAct,
webOpenPage: qaWebOpenPage,
webOpenPage: async (params: Parameters<typeof qaWebOpenPage>[0]) => {
const opened = await qaWebOpenPage(params);
env.webSessionIds.add(opened.pageId);
return opened;
},
webWait: qaWebWait,
webType: qaWebType,
webSnapshot: qaWebSnapshot,
@@ -1330,6 +1341,7 @@ export const qaSuiteTesting = {
mapQaSuiteWithConcurrency,
normalizeQaSuiteConcurrency,
scenarioMatchesLiveLane,
scenarioRequiresControlUi,
selectQaSuiteScenarios,
readTransportTranscript,
formatTransportTranscript,
@@ -1575,10 +1587,10 @@ export async function runQaSuite(params?: QaSuiteRunParams): Promise<QaSuiteResu
scenarioIds: [scenario.id],
concurrency: 1,
startLab,
// Isolated workers do not need their own Control UI proxy. The
// outer lab already owns the watch surface, so skip per-worker
// Control UI asset resolution and startup overhead.
controlUiEnabled: false,
// Most isolated workers do not need their own Control UI proxy.
// Control UI scenarios do, because they open the worker's
// gateway-backed app directly.
controlUiEnabled: scenarioRequiresControlUi(scenario),
});
const scenarioResult: QaSuiteScenarioResult =
result.scenarios[0] ??
@@ -1740,6 +1752,7 @@ export async function runQaSuite(params?: QaSuiteRunParams): Promise<QaSuiteResu
providerMode,
primaryModel,
alternateModel,
webSessionIds: new Set(),
};
let preserveGatewayRuntimeDir: string | undefined;
@@ -1849,7 +1862,7 @@ export async function runQaSuite(params?: QaSuiteRunParams): Promise<QaSuiteResu
preserveGatewayRuntimeDir = path.join(outputDir, "artifacts", "gateway-runtime");
throw error;
} finally {
await closeAllQaWebSessions();
await closeQaWebSessions(env.webSessionIds);
const keepTemp = process.env.OPENCLAW_QA_KEEP_TEMP === "1" || false;
await gateway.stop({
keepTemp,
+16
View File
@@ -44,6 +44,7 @@ vi.mock("playwright-core", () => ({
import {
closeAllQaWebSessions,
closeQaWebSessions,
qaWebEvaluate,
qaWebOpenPage,
qaWebSnapshot,
@@ -114,4 +115,19 @@ describe("qa web runtime", () => {
expect(contextClose).toHaveBeenCalledTimes(1);
expect(browserClose).toHaveBeenCalledTimes(1);
});
it("can close only selected page sessions", async () => {
const first = await qaWebOpenPage({ url: "http://127.0.0.1:3000/one" });
const second = await qaWebOpenPage({ url: "http://127.0.0.1:3000/two" });
await closeQaWebSessions([first.pageId]);
await expect(qaWebSnapshot({ pageId: first.pageId })).rejects.toThrow(
`unknown web session: ${first.pageId}`,
);
await expect(qaWebSnapshot({ pageId: second.pageId })).resolves.toMatchObject({
text: "hello from body",
});
await closeAllQaWebSessions();
});
});
+15 -3
View File
@@ -144,11 +144,23 @@ export async function qaWebEvaluate<T = unknown>(params: QaWebEvaluateParams): P
])) as T;
}
export async function closeAllQaWebSessions(): Promise<void> {
const active = [...sessions.values()];
sessions.clear();
export async function closeQaWebSessions(pageIds?: Iterable<string>): Promise<void> {
const active = pageIds
? [...pageIds].flatMap((pageId) => {
const session = sessions.get(pageId);
sessions.delete(pageId);
return session ? [session] : [];
})
: [...sessions.values()];
if (!pageIds) {
sessions.clear();
}
for (const session of active) {
await session.context.close().catch(() => {});
await session.browser.close().catch(() => {});
}
}
export async function closeAllQaWebSessions(): Promise<void> {
await closeQaWebSessions();
}
+2 -2
View File
@@ -93,7 +93,7 @@ describe("qqbot local media path remapping", () => {
it("allows structured payload files inside the QQ Bot media directory", () => {
const { mediaFile } = createQqbotMediaFile("allowed.png");
expect(resolveQQBotPayloadLocalFilePath(mediaFile)).toBe(mediaFile);
expect(resolveQQBotPayloadLocalFilePath(mediaFile)).toBe(fs.realpathSync(mediaFile));
});
it("blocks structured payload files inside the QQ Bot data directory", () => {
@@ -127,6 +127,6 @@ describe("qqbot local media path remapping", () => {
"legacy.png",
);
expect(resolveQQBotPayloadLocalFilePath(missingWorkspacePath)).toBe(mediaFile);
expect(resolveQQBotPayloadLocalFilePath(missingWorkspacePath)).toBe(fs.realpathSync(mediaFile));
});
});
+460 -2
View File
@@ -2,6 +2,7 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import type { SlackMonitorContext } from "./context.js";
const readStoreAllowFromForDmPolicyMock = vi.hoisted(() => vi.fn());
let authorizeSlackSystemEventSender: typeof import("./auth.js").authorizeSlackSystemEventSender;
let clearSlackAllowFromCacheForTest: typeof import("./auth.js").clearSlackAllowFromCacheForTest;
let resolveSlackEffectiveAllowFrom: typeof import("./auth.js").resolveSlackEffectiveAllowFrom;
@@ -24,12 +25,42 @@ function makeSlackCtx(allowFrom: string[]): SlackMonitorContext {
} as unknown as SlackMonitorContext;
}
function makeAuthorizeCtx(params?: {
allowFrom?: string[];
channelsConfig?: Record<string, { users?: string[] }>;
resolveUserName?: (userId: string) => Promise<{ name?: string }>;
resolveChannelName?: (
channelId: string,
) => Promise<{ name?: string; type?: "im" | "mpim" | "channel" | "group" }>;
}) {
return {
allowFrom: params?.allowFrom ?? [],
accountId: "main",
dmPolicy: "open",
dmEnabled: true,
allowNameMatching: false,
channelsConfig: params?.channelsConfig ?? {},
channelsConfigKeys: Object.keys(params?.channelsConfig ?? {}),
defaultRequireMention: true,
isChannelAllowed: vi.fn(() => true),
resolveUserName: vi.fn(
params?.resolveUserName ?? ((_) => Promise.resolve({ name: undefined })),
),
resolveChannelName: vi.fn(
params?.resolveChannelName ?? ((_) => Promise.resolve({ name: "general", type: "channel" })),
),
} as unknown as SlackMonitorContext;
}
describe("resolveSlackEffectiveAllowFrom", () => {
const prevTtl = process.env.OPENCLAW_SLACK_PAIRING_ALLOWFROM_CACHE_TTL_MS;
beforeAll(async () => {
({ clearSlackAllowFromCacheForTest, resolveSlackEffectiveAllowFrom } =
await import("./auth.js"));
({
authorizeSlackSystemEventSender,
clearSlackAllowFromCacheForTest,
resolveSlackEffectiveAllowFrom,
} = await import("./auth.js"));
});
beforeEach(() => {
@@ -83,3 +114,430 @@ describe("resolveSlackEffectiveAllowFrom", () => {
expect(readStoreAllowFromForDmPolicyMock).toHaveBeenCalledTimes(2);
});
});
describe("authorizeSlackSystemEventSender", () => {
beforeAll(async () => {
({ authorizeSlackSystemEventSender, clearSlackAllowFromCacheForTest } =
await import("./auth.js"));
});
beforeEach(() => {
clearSlackAllowFromCacheForTest();
});
it("keeps non-interactive channel senders open when only global allowFrom is configured", async () => {
const result = await authorizeSlackSystemEventSender({
ctx: makeAuthorizeCtx({ allowFrom: ["U_OWNER"] }),
senderId: "U_ATTACKER",
channelId: "C1",
});
expect(result).toEqual({
allowed: true,
channelType: "channel",
channelName: "general",
});
});
it("keeps channel users as the non-interactive gate even when global allowFrom is configured", async () => {
const result = await authorizeSlackSystemEventSender({
ctx: makeAuthorizeCtx({
allowFrom: ["U_OWNER"],
channelsConfig: {
C1: { users: ["U_ALLOWED"] },
},
}),
senderId: "U_OWNER",
channelId: "C1",
});
expect(result).toEqual({
allowed: false,
reason: "sender-not-channel-allowed",
channelType: "channel",
channelName: "general",
});
});
it("uses the channel denial reason for non-interactive senders who miss a channel users allowlist", async () => {
const result = await authorizeSlackSystemEventSender({
ctx: makeAuthorizeCtx({
allowFrom: ["U_OWNER"],
channelsConfig: {
C1: { users: ["U_ALLOWED"] },
},
}),
senderId: "U_ATTACKER",
channelId: "C1",
});
expect(result).toEqual({
allowed: false,
reason: "sender-not-channel-allowed",
channelType: "channel",
channelName: "general",
});
});
it("allows channel senders authorized by channel users even when not in global allowFrom", async () => {
const result = await authorizeSlackSystemEventSender({
ctx: makeAuthorizeCtx({
allowFrom: ["U_OWNER"],
channelsConfig: {
C1: { users: ["U_ALLOWED"] },
},
}),
senderId: "U_ALLOWED",
channelId: "C1",
});
expect(result).toEqual({
allowed: true,
channelType: "channel",
channelName: "general",
});
});
it("keeps channel interactions open when no global or channel allowlists are configured", async () => {
const result = await authorizeSlackSystemEventSender({
ctx: makeAuthorizeCtx(),
senderId: "U_ANYONE",
channelId: "C1",
});
expect(result).toEqual({
allowed: true,
channelType: "channel",
channelName: "general",
});
});
it("does not let a wildcard global allowFrom bypass non-interactive channel users restrictions", async () => {
const result = await authorizeSlackSystemEventSender({
ctx: makeAuthorizeCtx({
allowFrom: ["*"],
channelsConfig: {
C1: { users: ["U_ALLOWED"] },
},
}),
senderId: "U_ATTACKER",
channelId: "C1",
});
expect(result).toEqual({
allowed: false,
reason: "sender-not-channel-allowed",
channelType: "channel",
channelName: "general",
});
});
it("still allows a channel user when the global allowFrom is wildcard", async () => {
const result = await authorizeSlackSystemEventSender({
ctx: makeAuthorizeCtx({
allowFrom: ["*"],
channelsConfig: {
C1: { users: ["U_ALLOWED"] },
},
}),
senderId: "U_ALLOWED",
channelId: "C1",
});
expect(result).toEqual({
allowed: true,
channelType: "channel",
channelName: "general",
});
});
it("does not give non-interactive owner bypass when channel users are configured, even if explicit owners are also listed", async () => {
const result = await authorizeSlackSystemEventSender({
ctx: makeAuthorizeCtx({
allowFrom: ["U_OWNER", "*"],
channelsConfig: {
C1: { users: ["U_ALLOWED"] },
},
}),
senderId: "U_ATTACKER",
channelId: "C1",
});
expect(result).toEqual({
allowed: false,
reason: "sender-not-channel-allowed",
channelType: "channel",
channelName: "general",
});
});
it("keeps explicit owners behind the non-interactive channel users gate when allowFrom also contains wildcard", async () => {
const result = await authorizeSlackSystemEventSender({
ctx: makeAuthorizeCtx({
allowFrom: ["U_OWNER", "*"],
channelsConfig: {
C1: { users: ["U_ALLOWED"] },
},
}),
senderId: "U_OWNER",
channelId: "C1",
});
expect(result).toEqual({
allowed: false,
reason: "sender-not-channel-allowed",
channelType: "channel",
channelName: "general",
});
});
it("allows senders without channel context when no allowFrom is configured", async () => {
const result = await authorizeSlackSystemEventSender({
ctx: makeAuthorizeCtx(),
senderId: "U_ANYONE",
});
expect(result).toEqual({
allowed: true,
channelType: "channel",
channelName: undefined,
});
});
});
describe("authorizeSlackSystemEventSender interactiveEvent", () => {
beforeAll(async () => {
({ authorizeSlackSystemEventSender, clearSlackAllowFromCacheForTest } =
await import("./auth.js"));
});
beforeEach(() => {
clearSlackAllowFromCacheForTest();
});
it("rejects interactive events when expectedSenderId is not provided", async () => {
const result = await authorizeSlackSystemEventSender({
ctx: makeAuthorizeCtx({ allowFrom: ["U_OWNER"] }),
senderId: "U_OWNER",
channelId: "C1",
interactiveEvent: true,
});
expect(result).toEqual({
allowed: false,
reason: "missing-expected-sender",
});
});
it("allows interactive events when expectedSenderId matches senderId", async () => {
const result = await authorizeSlackSystemEventSender({
ctx: makeAuthorizeCtx({ allowFrom: ["U_OWNER"] }),
senderId: "U_OWNER",
channelId: "C1",
expectedSenderId: "U_OWNER",
interactiveEvent: true,
});
expect(result).toEqual({
allowed: true,
channelType: "channel",
channelName: "general",
});
});
it("allows interactive channel senders who match the global allowFrom even when channel users are configured", async () => {
const result = await authorizeSlackSystemEventSender({
ctx: makeAuthorizeCtx({
allowFrom: ["U_OWNER"],
channelsConfig: {
C1: { users: ["U_ALLOWED"] },
},
}),
senderId: "U_OWNER",
channelId: "C1",
expectedSenderId: "U_OWNER",
interactiveEvent: true,
});
expect(result).toEqual({
allowed: true,
channelType: "channel",
channelName: "general",
});
});
it("uses a combined denial reason when an interactive sender matches neither global nor channel allowlists", async () => {
const result = await authorizeSlackSystemEventSender({
ctx: makeAuthorizeCtx({
allowFrom: ["U_OWNER"],
channelsConfig: {
C1: { users: ["U_ALLOWED"] },
},
}),
senderId: "U_ATTACKER",
channelId: "C1",
expectedSenderId: "U_ATTACKER",
interactiveEvent: true,
});
expect(result).toEqual({
allowed: false,
reason: "sender-not-authorized",
channelType: "channel",
channelName: "general",
});
});
it("keeps interactive channel events open when no allowlists are configured", async () => {
const result = await authorizeSlackSystemEventSender({
ctx: makeAuthorizeCtx(),
senderId: "U_ANYONE",
channelId: "C1",
expectedSenderId: "U_ANYONE",
interactiveEvent: true,
});
expect(result).toEqual({
allowed: true,
channelType: "channel",
channelName: "general",
});
});
it("preserves explicit owner access for interactive events when allowFrom also contains wildcard", async () => {
const result = await authorizeSlackSystemEventSender({
ctx: makeAuthorizeCtx({
allowFrom: ["U_OWNER", "*"],
channelsConfig: {
C1: { users: ["U_ALLOWED"] },
},
}),
senderId: "U_OWNER",
channelId: "C1",
expectedSenderId: "U_OWNER",
interactiveEvent: true,
});
expect(result).toEqual({
allowed: true,
channelType: "channel",
channelName: "general",
});
});
it("keeps interactive no-channel events open when no allowFrom is configured", async () => {
const result = await authorizeSlackSystemEventSender({
ctx: makeAuthorizeCtx(),
senderId: "U_ANYONE",
expectedSenderId: "U_ANYONE",
interactiveEvent: true,
});
expect(result).toEqual({
allowed: true,
channelType: "channel",
channelName: undefined,
});
});
it("denies interactive no-channel events when sender is not in allowFrom", async () => {
const result = await authorizeSlackSystemEventSender({
ctx: makeAuthorizeCtx({ allowFrom: ["U_OWNER"] }),
senderId: "U_ATTACKER",
expectedSenderId: "U_ATTACKER",
interactiveEvent: true,
});
expect(result).toEqual({
allowed: false,
reason: "sender-not-allowlisted",
});
});
it("allows interactive no-channel events when sender is in allowFrom", async () => {
const result = await authorizeSlackSystemEventSender({
ctx: makeAuthorizeCtx({ allowFrom: ["U_OWNER"] }),
senderId: "U_OWNER",
expectedSenderId: "U_OWNER",
interactiveEvent: true,
});
expect(result).toEqual({
allowed: true,
channelType: "channel",
channelName: undefined,
});
});
it("rejects interactive events with ambiguous channel type", async () => {
// Channel ID "X1" has no recognized prefix (D, C, G) so the type is ambiguous
const result = await authorizeSlackSystemEventSender({
ctx: makeAuthorizeCtx({
allowFrom: ["U_OWNER"],
resolveChannelName: () => Promise.resolve({ name: "mystery" }),
}),
senderId: "U_OWNER",
channelId: "X1",
expectedSenderId: "U_OWNER",
interactiveEvent: true,
});
expect(result).toEqual({
allowed: false,
reason: "ambiguous-channel-type",
channelType: "channel",
channelName: "mystery",
});
});
it("allows interactive events when channel type is known from ID prefix", async () => {
const result = await authorizeSlackSystemEventSender({
ctx: makeAuthorizeCtx({ allowFrom: ["U_OWNER"] }),
senderId: "U_OWNER",
channelId: "C1",
expectedSenderId: "U_OWNER",
interactiveEvent: true,
});
expect(result).toEqual({
allowed: true,
channelType: "channel",
channelName: "general",
});
});
it("allows interactive events when channel type is known from explicit type", async () => {
const result = await authorizeSlackSystemEventSender({
ctx: makeAuthorizeCtx({
allowFrom: ["U_OWNER"],
resolveChannelName: () => Promise.resolve({ name: "mystery", type: "group" }),
}),
senderId: "U_OWNER",
channelId: "X1",
channelType: "group",
expectedSenderId: "U_OWNER",
interactiveEvent: true,
});
expect(result).toEqual({
allowed: true,
channelType: "group",
channelName: "mystery",
});
});
it("does not apply interactiveEvent restrictions to non-interactive events", async () => {
// Same scenario as the denying test above, but without interactiveEvent flag
const result = await authorizeSlackSystemEventSender({
ctx: makeAuthorizeCtx(),
senderId: "U_ANYONE",
channelId: "C1",
});
expect(result).toEqual({
allowed: true,
channelType: "channel",
channelName: "general",
});
});
});
+90 -7
View File
@@ -3,9 +3,11 @@ import {
allowListMatches,
normalizeAllowList,
normalizeAllowListLower,
resolveSlackAllowListMatch,
resolveSlackUserAllowed,
} from "./allow-list.js";
import { resolveSlackChannelConfig } from "./channel-config.js";
import { inferSlackChannelType } from "./channel-type.js";
import { normalizeSlackChannelType, type SlackMonitorContext } from "./context.js";
type ResolvedAllowFromLists = {
@@ -153,11 +155,14 @@ export type SlackSystemEventAuthResult = {
allowed: boolean;
reason?:
| "missing-sender"
| "missing-expected-sender"
| "sender-mismatch"
| "channel-not-allowed"
| "ambiguous-channel-type"
| "dm-disabled"
| "sender-not-allowlisted"
| "sender-not-channel-allowed";
| "sender-not-channel-allowed"
| "sender-not-authorized";
channelType?: "im" | "mpim" | "channel" | "group";
channelName?: string;
};
@@ -168,6 +173,10 @@ export async function authorizeSlackSystemEventSender(params: {
channelId?: string;
channelType?: string | null;
expectedSenderId?: string;
/** When true, requires expectedSenderId, rejects ambiguous channel types,
* and applies interactive-only owner allowFrom checks without changing the
* open-by-default channel behavior when no allowlists are configured. */
interactiveEvent?: boolean;
}): Promise<SlackSystemEventAuthResult> {
const senderId = params.senderId?.trim();
if (!senderId) {
@@ -179,6 +188,11 @@ export async function authorizeSlackSystemEventSender(params: {
return { allowed: false, reason: "sender-mismatch" };
}
// Interactive events require an expected sender to cross-verify the actor.
if (params.interactiveEvent && !expectedSenderId) {
return { allowed: false, reason: "missing-expected-sender" };
}
const channelId = params.channelId?.trim();
let channelType = normalizeSlackChannelType(params.channelType, channelId);
let channelName: string | undefined;
@@ -188,7 +202,8 @@ export async function authorizeSlackSystemEventSender(params: {
type?: "im" | "mpim" | "channel" | "group";
} = await params.ctx.resolveChannelName(channelId).catch(() => ({}));
channelName = info.name;
channelType = normalizeSlackChannelType(params.channelType ?? info.type, channelId);
const resolvedTypeSource = params.channelType ?? info.type;
channelType = normalizeSlackChannelType(resolvedTypeSource, channelId);
if (
!params.ctx.isChannelAllowed({
channelId,
@@ -203,6 +218,31 @@ export async function authorizeSlackSystemEventSender(params: {
channelName,
};
}
// For interactive events, reject when channel type could not be positively
// determined from either the explicit type or the channel ID prefix. This
// prevents a DM from being misclassified as "channel" and skipping
// DM-specific authorization.
if (params.interactiveEvent) {
const inferredFromId = inferSlackChannelType(channelId);
const sourceNormalized =
typeof resolvedTypeSource === "string"
? resolvedTypeSource.toLowerCase().trim()
: undefined;
const sourceIsKnownType =
sourceNormalized === "im" ||
sourceNormalized === "mpim" ||
sourceNormalized === "channel" ||
sourceNormalized === "group";
if (inferredFromId === undefined && !sourceIsKnownType) {
return {
allowed: false,
reason: "ambiguous-channel-type",
channelType,
channelName,
};
}
}
}
const senderInfo: { name?: string } = await params.ctx
@@ -235,8 +275,8 @@ export async function authorizeSlackSystemEventSender(params: {
}
}
} else if (!channelId) {
// No channel context. Apply allowFrom if configured so we fail closed
// for privileged interactive events when owner allowlist is present.
// No channel context. Preserve the existing open default unless a global
// allowFrom list is configured.
const allowFromLower = await resolveAllowFromLower(false);
if (allowFromLower.length > 0) {
const senderAllowListed = isSlackSenderAllowListed({
@@ -250,6 +290,9 @@ export async function authorizeSlackSystemEventSender(params: {
}
}
} else {
const allowFromLower = await resolveAllowFromLower(false);
const ownerAllowlistConfigured = allowFromLower.length > 0;
const allowFromLowerWithoutWildcard = allowFromLower.filter((entry) => entry !== "*");
const channelConfig = resolveSlackChannelConfig({
channelId,
channelName,
@@ -260,6 +303,23 @@ export async function authorizeSlackSystemEventSender(params: {
});
const channelUsersAllowlistConfigured =
Array.isArray(channelConfig?.users) && channelConfig.users.length > 0;
const ownerMatch = ownerAllowlistConfigured
? resolveSlackAllowListMatch({
allowList: allowFromLower,
id: senderId,
name: senderName,
allowNameMatching: params.ctx.allowNameMatching,
})
: { allowed: false };
const ownerAllowed = ownerMatch.allowed;
const ownerExplicitlyAllowed =
allowFromLowerWithoutWildcard.length > 0 &&
resolveSlackAllowListMatch({
allowList: allowFromLowerWithoutWildcard,
id: senderId,
name: senderName,
allowNameMatching: params.ctx.allowNameMatching,
}).allowed;
if (channelUsersAllowlistConfigured) {
const channelUserAllowed = resolveSlackUserAllowed({
allowList: channelConfig?.users,
@@ -267,14 +327,37 @@ export async function authorizeSlackSystemEventSender(params: {
userName: senderName,
allowNameMatching: params.ctx.allowNameMatching,
});
if (!channelUserAllowed) {
if (channelUserAllowed || (params.interactiveEvent && ownerExplicitlyAllowed)) {
return {
allowed: false,
reason: "sender-not-channel-allowed",
allowed: true,
channelType,
channelName,
};
}
return {
allowed: false,
reason:
params.interactiveEvent && ownerAllowlistConfigured
? "sender-not-authorized"
: "sender-not-channel-allowed",
channelType,
channelName,
};
}
if (params.interactiveEvent && ownerAllowed) {
return {
allowed: true,
channelType,
channelName,
};
}
if (params.interactiveEvent && ownerAllowlistConfigured) {
return {
allowed: false,
reason: "sender-not-allowlisted",
channelType,
channelName,
};
}
}
@@ -472,6 +472,11 @@ async function authorizeSlackBlockAction(params: {
ctx: params.ctx,
senderId: params.parsed.userId,
channelId: params.parsed.channelId,
// Block action sender identity is verified by Slack's request signing.
// Pass the Slack-verified userId as expectedSenderId to satisfy the
// mandatory actor-binding requirement for interactive events.
expectedSenderId: params.parsed.userId,
interactiveEvent: true,
});
if (auth.allowed) {
return auth;
@@ -219,6 +219,7 @@ export async function emitSlackModalLifecycleEvent(params: {
channelId: sessionRouting.channelId,
channelType: sessionRouting.channelType,
expectedSenderId: expectedUserId,
interactiveEvent: true,
});
if (!auth.allowed) {
params.ctx.runtime.log?.(
@@ -138,9 +138,10 @@ function createContext(overrides?: {
runtime: { log: runtimeLog },
dmEnabled: overrides?.dmEnabled ?? true,
dmPolicy: overrides?.dmPolicy ?? ("open" as const),
allowFrom: overrides?.allowFrom ?? [],
allowFrom: overrides?.allowFrom ?? ["*"],
allowNameMatching: overrides?.allowNameMatching ?? false,
channelsConfig: overrides?.channelsConfig ?? {},
channelsConfigKeys: Object.keys(overrides?.channelsConfig ?? {}),
defaultRequireMention: true,
shouldDropMismatchedSlackEvent: (body: unknown) =>
overrides?.shouldDropMismatchedSlackEvent?.(body) ?? false,
@@ -771,6 +772,156 @@ describe("registerSlackInteractionEvents", () => {
});
});
it("blocks channel block actions when sender is outside configured global allowFrom", async () => {
enqueueSystemEventMock.mockClear();
const { ctx, app, getHandler } = createContext({
allowFrom: ["U_OWNER"],
});
registerSlackInteractionEvents({ ctx: ctx as never });
const handler = getHandler();
expect(handler).toBeTruthy();
const ack = vi.fn().mockResolvedValue(undefined);
const respond = vi.fn().mockResolvedValue(undefined);
await handler!({
ack,
respond,
body: {
user: { id: "U_ATTACKER" },
channel: { id: "C1" },
message: {
ts: "250.251",
blocks: [{ type: "actions", block_id: "verify_block", elements: [] }],
},
},
action: {
type: "button",
action_id: "openclaw:verify",
block_id: "verify_block",
},
});
expect(ack).toHaveBeenCalled();
expect(enqueueSystemEventMock).not.toHaveBeenCalled();
expect(app.client.chat.update).not.toHaveBeenCalled();
expect(respond).toHaveBeenCalledWith({
text: "You are not authorized to use this control.",
response_type: "ephemeral",
});
});
it("allows channel block actions when channel users allowlist authorizes the sender", async () => {
enqueueSystemEventMock.mockClear();
const { ctx, app, getHandler } = createContext({
allowFrom: ["U_OWNER"],
channelsConfig: {
C1: { users: ["U_ALLOWED"] },
},
});
registerSlackInteractionEvents({ ctx: ctx as never });
const handler = getHandler();
expect(handler).toBeTruthy();
const ack = vi.fn().mockResolvedValue(undefined);
const respond = vi.fn().mockResolvedValue(undefined);
await handler!({
ack,
respond,
body: {
user: { id: "U_ALLOWED" },
channel: { id: "C1" },
message: {
ts: "260.261",
blocks: [{ type: "actions", block_id: "verify_block", elements: [] }],
},
},
action: {
type: "button",
action_id: "openclaw:verify",
block_id: "verify_block",
},
});
expect(ack).toHaveBeenCalled();
expect(enqueueSystemEventMock).toHaveBeenCalledTimes(1);
expect(app.client.chat.update).toHaveBeenCalledTimes(1);
expect(respond).not.toHaveBeenCalled();
});
it("blocks wildcard global allowFrom from bypassing configured channel users", async () => {
enqueueSystemEventMock.mockClear();
const { ctx, app, getHandler } = createContext({
allowFrom: ["*"],
channelsConfig: {
C1: { users: ["U_ALLOWED"] },
},
});
registerSlackInteractionEvents({ ctx: ctx as never });
const handler = getHandler();
expect(handler).toBeTruthy();
const ack = vi.fn().mockResolvedValue(undefined);
const respond = vi.fn().mockResolvedValue(undefined);
await handler!({
ack,
respond,
body: {
user: { id: "U_ATTACKER" },
channel: { id: "C1" },
message: {
ts: "270.271",
blocks: [{ type: "actions", block_id: "verify_block", elements: [] }],
},
},
action: {
type: "button",
action_id: "openclaw:verify",
block_id: "verify_block",
},
});
expect(ack).toHaveBeenCalled();
expect(enqueueSystemEventMock).not.toHaveBeenCalled();
expect(app.client.chat.update).not.toHaveBeenCalled();
expect(respond).toHaveBeenCalledWith({
text: "You are not authorized to use this control.",
response_type: "ephemeral",
});
});
it("keeps channel block actions open when no allowlists are configured", async () => {
enqueueSystemEventMock.mockClear();
const { ctx, app, getHandler } = createContext({ allowFrom: [] });
registerSlackInteractionEvents({ ctx: ctx as never });
const handler = getHandler();
expect(handler).toBeTruthy();
const ack = vi.fn().mockResolvedValue(undefined);
const respond = vi.fn().mockResolvedValue(undefined);
await handler!({
ack,
respond,
body: {
user: { id: "U_ANYONE" },
channel: { id: "C1" },
message: {
ts: "305.306",
blocks: [{ type: "actions", block_id: "verify_block", elements: [] }],
},
},
action: {
type: "button",
action_id: "openclaw:verify",
block_id: "verify_block",
},
});
expect(ack).toHaveBeenCalled();
expect(enqueueSystemEventMock).toHaveBeenCalledTimes(1);
expect(app.client.chat.update).toHaveBeenCalledTimes(1);
expect(respond).not.toHaveBeenCalled();
});
it("blocks DM block actions when sender is not in allowFrom", async () => {
enqueueSystemEventMock.mockClear();
const { ctx, app, getHandler } = createContext({
@@ -1412,6 +1563,33 @@ describe("registerSlackInteractionEvents", () => {
expect(enqueueSystemEventMock).not.toHaveBeenCalled();
});
it("keeps no-channel modal events open when allowFrom is unset", async () => {
enqueueSystemEventMock.mockClear();
const { ctx, getViewHandler } = createContext({ allowFrom: [] });
registerSlackInteractionEvents({ ctx: ctx as never });
const viewHandler = getViewHandler();
expect(viewHandler).toBeTruthy();
const ack = vi.fn().mockResolvedValue(undefined);
await viewHandler!({
ack,
body: {
user: { id: "U444" },
view: {
id: "V444",
callback_id: "openclaw:routing_form",
private_metadata: JSON.stringify({ userId: "U444" }),
state: {
values: {},
},
},
},
} as never);
expect(ack).toHaveBeenCalled();
expect(enqueueSystemEventMock).toHaveBeenCalledTimes(1);
});
it("captures modal input labels and picker values across block types", async () => {
enqueueSystemEventMock.mockClear();
const { ctx, getViewHandler } = createContext();
@@ -1,6 +1,16 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const { recordInboundSessionMock } = vi.hoisted(() => ({
recordInboundSessionMock: vi.fn().mockResolvedValue(undefined),
import { resetTopicNameCacheForTest } from "./topic-name-cache.js";
type SessionRuntimeModule = typeof import("./bot-message-context.session.runtime.js");
type RecordInboundSessionFn = SessionRuntimeModule["recordInboundSession"];
type ResolveStorePathFn = SessionRuntimeModule["resolveStorePath"];
const { recordInboundSessionMock, resolveStorePathMock } = vi.hoisted(() => ({
recordInboundSessionMock: vi.fn<RecordInboundSessionFn>(async () => undefined),
resolveStorePathMock: vi.fn<ResolveStorePathFn>(() => "/tmp/openclaw-session-store.json"),
}));
vi.mock("./bot-message-context.session.runtime.js", async () => {
@@ -9,7 +19,10 @@ vi.mock("./bot-message-context.session.runtime.js", async () => {
);
return {
...actual,
recordInboundSession: (...args: unknown[]) => recordInboundSessionMock(...args),
recordInboundSession: (...args: Parameters<typeof actual.recordInboundSession>) =>
recordInboundSessionMock(...args),
resolveStorePath: (...args: Parameters<typeof actual.resolveStorePath>) =>
resolveStorePathMock(...args),
};
});
@@ -34,11 +47,15 @@ const { clearRuntimeConfigSnapshot, setRuntimeConfigSnapshot } =
beforeEach(() => {
clearRuntimeConfigSnapshot();
resetTopicNameCacheForTest();
});
afterEach(() => {
clearRuntimeConfigSnapshot();
resetTopicNameCacheForTest();
recordInboundSessionMock.mockClear();
resolveStorePathMock.mockReset();
resolveStorePathMock.mockReturnValue("/tmp/openclaw-session-store.json");
});
describe("buildTelegramMessageContext dm thread sessions", () => {
@@ -161,6 +178,123 @@ describe("buildTelegramMessageContext group sessions without forum", () => {
expect(ctx).not.toBeNull();
expect(ctx?.ctxPayload?.TopicName).toBe("Deployments");
});
it("handles forum messages without session runtime overrides", async () => {
const ctx = await buildTelegramMessageContextForTest({
message: {
message_id: 3,
chat: { id: -1001234567890, type: "supergroup", title: "Test Forum", is_forum: true },
date: 1700000002,
text: "@bot hello",
message_thread_id: 99,
from: { id: 42, first_name: "Alice" },
reply_to_message: {
message_id: 2,
forum_topic_created: { name: "Deployments", icon_color: 0x6fb9f0 },
},
},
options: { forceWasMentioned: true },
resolveGroupActivation: () => true,
sessionRuntime: null,
});
expect(ctx).not.toBeNull();
expect(ctx?.ctxPayload?.TopicName).toBe("Deployments");
});
it("reloads topic name from disk after cache reset", async () => {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-telegram-topic-name-"));
const sessionStorePath = path.join(tempDir, "sessions.json");
const buildPersistedContext = async (message: Record<string, unknown>) =>
await buildTelegramMessageContextForTest({
message,
options: { forceWasMentioned: true },
resolveGroupActivation: () => true,
sessionRuntime: {
resolveStorePath: () => sessionStorePath,
},
});
try {
await buildPersistedContext({
message_id: 4,
chat: { id: -1001234567890, type: "supergroup", title: "Test Forum", is_forum: true },
date: 1700000003,
text: "@bot hello",
message_thread_id: 99,
from: { id: 42, first_name: "Alice" },
reply_to_message: {
message_id: 3,
forum_topic_created: { name: "Deployments", icon_color: 0x6fb9f0 },
},
});
resetTopicNameCacheForTest();
const ctx = await buildPersistedContext({
message_id: 5,
chat: { id: -1001234567890, type: "supergroup", title: "Test Forum", is_forum: true },
date: 1700000004,
text: "@bot again",
message_thread_id: 99,
from: { id: 42, first_name: "Alice" },
});
expect(ctx).not.toBeNull();
expect(ctx?.ctxPayload?.TopicName).toBe("Deployments");
} finally {
await fs.rm(tempDir, { recursive: true, force: true });
resetTopicNameCacheForTest();
}
});
it("persists topic names through the default session runtime path", async () => {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-telegram-topic-name-"));
const sessionStorePath = path.join(tempDir, "sessions.json");
resolveStorePathMock.mockReturnValue(sessionStorePath);
try {
await buildTelegramMessageContextForTest({
message: {
message_id: 6,
chat: { id: -1001234567890, type: "supergroup", title: "Test Forum", is_forum: true },
date: 1700000005,
text: "@bot hello",
message_thread_id: 99,
from: { id: 42, first_name: "Alice" },
reply_to_message: {
message_id: 5,
forum_topic_created: { name: "Deployments", icon_color: 0x6fb9f0 },
},
},
options: { forceWasMentioned: true },
resolveGroupActivation: () => true,
sessionRuntime: null,
});
resetTopicNameCacheForTest();
const ctx = await buildTelegramMessageContextForTest({
message: {
message_id: 7,
chat: { id: -1001234567890, type: "supergroup", title: "Test Forum", is_forum: true },
date: 1700000006,
text: "@bot again",
message_thread_id: 99,
from: { id: 42, first_name: "Alice" },
},
options: { forceWasMentioned: true },
resolveGroupActivation: () => true,
sessionRuntime: null,
});
expect(ctx).not.toBeNull();
expect(ctx?.ctxPayload?.TopicName).toBe("Deployments");
} finally {
await fs.rm(tempDir, { recursive: true, force: true });
resetTopicNameCacheForTest();
}
});
});
describe("buildTelegramMessageContext direct peer routing", () => {
@@ -76,6 +76,17 @@ async function loadTelegramMessageContextSessionRuntime(
};
}
export async function resolveTelegramMessageContextStorePath(params: {
cfg: OpenClawConfig;
agentId: string;
sessionRuntime?: TelegramMessageContextSessionRuntimeOverrides;
}): Promise<string> {
const sessionRuntime = await loadTelegramMessageContextSessionRuntime(params.sessionRuntime);
return sessionRuntime.resolveStorePath(params.cfg.session?.store, {
agentId: params.agentId,
});
}
export async function buildTelegramInboundContextPayload(params: {
cfg: OpenClawConfig;
primaryCtx: TelegramContext;
@@ -232,8 +243,10 @@ export async function buildTelegramInboundContextPayload(params: {
? (groupLabel ?? `group:${chatId}`)
: buildSenderLabel(msg, senderId || chatId);
const sessionRuntime = await loadTelegramMessageContextSessionRuntime(sessionRuntimeOverride);
const storePath = sessionRuntime.resolveStorePath(cfg.session?.store, {
const storePath = await resolveTelegramMessageContextStorePath({
cfg,
agentId: route.agentId,
sessionRuntime: sessionRuntimeOverride,
});
const envelopeOptions = resolveEnvelopeFormatOptions(cfg);
const previousTimestamp = sessionRuntime.readSessionUpdatedAt({
@@ -36,7 +36,7 @@ type BuildTelegramMessageContextForTestParams = {
cfg?: Record<string, unknown>;
accountId?: string;
runtime?: BuildTelegramMessageContextParams["runtime"];
sessionRuntime?: BuildTelegramMessageContextParams["sessionRuntime"];
sessionRuntime?: BuildTelegramMessageContextParams["sessionRuntime"] | null;
resolveGroupActivation?: BuildTelegramMessageContextParams["resolveGroupActivation"];
resolveGroupRequireMention?: BuildTelegramMessageContextParams["resolveGroupRequireMention"];
resolveTelegramGroupConfig?: BuildTelegramMessageContextParams["resolveTelegramGroupConfig"];
@@ -59,6 +59,13 @@ export async function buildTelegramMessageContextForTest(
> {
const { vi } = await loadVitestModule();
const buildTelegramMessageContext = await loadBuildTelegramMessageContext();
const sessionRuntime =
params.sessionRuntime === null
? undefined
: {
...telegramMessageContextSessionRuntimeForTest,
...params.sessionRuntime,
};
return await buildTelegramMessageContext({
primaryCtx: {
message: {
@@ -85,10 +92,7 @@ export async function buildTelegramMessageContextForTest(
recordChannelActivity: () => undefined,
...params.runtime,
},
sessionRuntime: {
...telegramMessageContextSessionRuntimeForTest,
...params.sessionRuntime,
},
sessionRuntime,
account: { accountId: params.accountId ?? "default" } as never,
historyLimit: 0,
groupHistories: new Map(),
+42 -24
View File
@@ -11,7 +11,10 @@ import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
import { withTelegramApiErrorLogging } from "./api-logging.js";
import { firstDefined, normalizeAllowFrom, normalizeDmAllowFromWithStore } from "./bot-access.js";
import { resolveTelegramInboundBody } from "./bot-message-context.body.js";
import { buildTelegramInboundContextPayload } from "./bot-message-context.session.js";
import {
buildTelegramInboundContextPayload,
resolveTelegramMessageContextStorePath,
} from "./bot-message-context.session.js";
import type { BuildTelegramMessageContextParams } from "./bot-message-context.types.js";
import {
buildTypingThreadParams,
@@ -34,7 +37,7 @@ import {
resolveTelegramReactionVariant,
resolveTelegramStatusReactionEmojis,
} from "./status-reaction-variants.js";
import { getTopicName, updateTopicName } from "./topic-name-cache.js";
import { getTopicName, resolveTopicNameCachePath, updateTopicName } from "./topic-name-cache.js";
export type {
BuildTelegramMessageContextParams,
@@ -149,40 +152,55 @@ export const buildTelegramMessageContext = async ({
const resolvedThreadId = threadSpec.scope === "forum" ? threadSpec.id : undefined;
const replyThreadId = threadSpec.id;
const dmThreadId = threadSpec.scope === "dm" ? threadSpec.id : undefined;
const topicNameCachePath = resolveTopicNameCachePath(
await resolveTelegramMessageContextStorePath({
cfg,
agentId: account.accountId,
sessionRuntime,
}),
);
let topicName: string | undefined;
if (isForum && resolvedThreadId != null) {
const ftCreated = msg.forum_topic_created;
const ftEdited = msg.forum_topic_edited;
const ftClosed = msg.forum_topic_closed;
const ftReopened = msg.forum_topic_reopened;
const topicPatch = ftCreated?.name
? {
name: ftCreated.name,
iconColor: ftCreated.icon_color,
iconCustomEmojiId: ftCreated.icon_custom_emoji_id,
closed: false,
}
: ftEdited?.name
? {
name: ftEdited.name,
iconCustomEmojiId: ftEdited.icon_custom_emoji_id,
}
: ftClosed
? { closed: true }
: ftReopened
? { closed: false }
: undefined;
if (ftCreated?.name) {
updateTopicName(chatId, resolvedThreadId, {
name: ftCreated.name,
iconColor: ftCreated.icon_color,
iconCustomEmojiId: ftCreated.icon_custom_emoji_id,
closed: false,
});
} else if (ftEdited?.name) {
updateTopicName(chatId, resolvedThreadId, {
name: ftEdited.name,
iconCustomEmojiId: ftEdited.icon_custom_emoji_id,
});
} else if (ftClosed) {
updateTopicName(chatId, resolvedThreadId, { closed: true });
} else if (ftReopened) {
updateTopicName(chatId, resolvedThreadId, { closed: false });
if (topicPatch) {
updateTopicName(chatId, resolvedThreadId, topicPatch, topicNameCachePath);
}
topicName = getTopicName(chatId, resolvedThreadId);
topicName = getTopicName(chatId, resolvedThreadId, topicNameCachePath);
if (!topicName) {
const replyFtCreated = msg.reply_to_message?.forum_topic_created;
if (replyFtCreated?.name) {
updateTopicName(chatId, resolvedThreadId, {
name: replyFtCreated.name,
iconColor: replyFtCreated.icon_color,
iconCustomEmojiId: replyFtCreated.icon_custom_emoji_id,
});
updateTopicName(
chatId,
resolvedThreadId,
{
name: replyFtCreated.name,
iconColor: replyFtCreated.icon_color,
iconCustomEmojiId: replyFtCreated.icon_custom_emoji_id,
},
topicNameCachePath,
);
topicName = replyFtCreated.name;
}
}
@@ -811,7 +811,10 @@ export const dispatchTelegramMessage = async ({
: undefined,
onToolStart: statusReactionController
? async (payload) => {
await Promise.resolve(statusReactionController.setTool(payload.name ?? "tool"));
const toolName = payload.name?.trim();
if (toolName) {
await statusReactionController.setTool(toolName);
}
}
: undefined,
onCompactionStart: statusReactionController
@@ -62,6 +62,63 @@ const TELEGRAM_TEST_TIMINGS = {
textFragmentGapMs: 30,
} as const;
type TelegramMiddlewareTestContext = Record<string, unknown>;
type TelegramMiddleware = (
ctx: TelegramMiddlewareTestContext,
next: () => Promise<void>,
) => Promise<void> | void;
function getRegisteredTelegramMiddlewares(): TelegramMiddleware[] {
return middlewareUseSpy.mock.calls
.map((call) => call[0])
.filter((fn): fn is TelegramMiddleware => typeof fn === "function");
}
async function runTelegramMiddlewareChain(params: {
ctx: TelegramMiddlewareTestContext;
finalHandler: (ctx: TelegramMiddlewareTestContext) => Promise<void>;
}): Promise<void> {
const middlewares = getRegisteredTelegramMiddlewares();
let idx = -1;
const dispatch = async (i: number): Promise<void> => {
if (i <= idx) {
throw new Error("middleware dispatch called multiple times");
}
idx = i;
const fn = middlewares[i];
if (!fn) {
await params.finalHandler(params.ctx);
return;
}
await fn(params.ctx, async () => dispatch(i + 1));
};
await dispatch(0);
}
function installPerKeySequentializer(): void {
sequentializeSpy.mockImplementationOnce(() => {
const lanes = new Map<string, Promise<void>>();
return async (ctx: TelegramMiddlewareTestContext, next: () => Promise<void>) => {
const key = harness.sequentializeKey?.(ctx) ?? "default";
const previous = lanes.get(key) ?? Promise.resolve();
const current = previous.then(async () => {
await next();
});
lanes.set(
key,
current.catch(() => undefined),
);
try {
await current;
} finally {
if (lanes.get(key) === current) {
lanes.delete(key);
}
}
};
});
}
describe("createTelegramBot", () => {
beforeAll(() => {
process.env.TZ = "UTC";
@@ -146,6 +203,182 @@ describe("createTelegramBot", () => {
expect(harness.sequentializeKey).toBe(getTelegramSequentialKey);
});
it("lets /status bypass a busy Telegram topic lane", async () => {
installPerKeySequentializer();
loadConfig.mockReturnValue({
commands: { native: true },
channels: {
telegram: {
dmPolicy: "open",
allowFrom: ["*"],
groups: { "*": { requireMention: false } },
},
},
});
const startedBodies: string[] = [];
let releaseConversationTurn!: () => void;
const conversationGate = new Promise<void>((resolve) => {
releaseConversationTurn = resolve;
});
replySpy.mockImplementation(async (ctx: MsgContext, opts?: GetReplyOptions) => {
await opts?.onReplyStart?.();
const body = String(ctx.CommandBody ?? ctx.Body ?? "");
startedBodies.push(body);
if (body.includes("hello there")) {
await conversationGate;
}
return { text: `reply:${body}` };
});
createTelegramBot({ token: "tok" });
const messageHandler = getOnHandler("message") as (
ctx: TelegramMiddlewareTestContext,
) => Promise<void>;
const statusHandler = commandSpy.mock.calls.find((call) => call[0] === "status")?.[1] as
| ((ctx: TelegramMiddlewareTestContext) => Promise<void>)
| undefined;
expect(statusHandler).toBeDefined();
if (!statusHandler) {
return;
}
const busyCtx = {
...makeForumGroupMessageCtx({ threadId: 99, text: "hello there" }),
message: {
...makeForumGroupMessageCtx({ threadId: 99, text: "hello there" }).message,
message_id: 101,
},
update: { update_id: 101 },
};
const statusCtx = {
...makeForumGroupMessageCtx({ threadId: 99, text: "/status" }),
message: {
...makeForumGroupMessageCtx({ threadId: 99, text: "/status" }).message,
message_id: 102,
},
update: { update_id: 102 },
match: "",
};
const busyPromise = runTelegramMiddlewareChain({
ctx: busyCtx,
finalHandler: messageHandler,
});
await vi.waitFor(() => {
expect(startedBodies).toHaveLength(1);
expect(startedBodies[0]).toContain("hello there");
});
const statusPromise = runTelegramMiddlewareChain({
ctx: statusCtx,
finalHandler: statusHandler,
});
await vi.waitFor(() => {
expect(startedBodies).toHaveLength(2);
expect(startedBodies[0]).toContain("hello there");
expect(startedBodies[1]).toBe("/status");
expect(sendMessageSpy).toHaveBeenCalledTimes(1);
expect(sendMessageSpy.mock.calls[0]?.[1]).toContain("reply:/status");
});
await statusPromise;
releaseConversationTurn();
await busyPromise;
await vi.waitFor(() => {
expect(sendMessageSpy).toHaveBeenCalledTimes(2);
});
const sentBodies = sendMessageSpy.mock.calls.map((call) => String(call[1]));
expect(sentBodies[0]).toContain("reply:/status");
expect(sentBodies[1]).toContain("hello there");
});
it("keeps ordinary Telegram messages serialized within the same topic", async () => {
installPerKeySequentializer();
loadConfig.mockReturnValue({
channels: {
telegram: {
dmPolicy: "open",
allowFrom: ["*"],
groups: { "*": { requireMention: false } },
},
},
});
const startedBodies: string[] = [];
let releaseFirstTurn!: () => void;
const firstTurnGate = new Promise<void>((resolve) => {
releaseFirstTurn = resolve;
});
replySpy.mockImplementation(async (ctx: MsgContext, opts?: GetReplyOptions) => {
await opts?.onReplyStart?.();
const body = String(ctx.Body ?? "");
startedBodies.push(body);
if (body.includes("first message")) {
await firstTurnGate;
}
return { text: `reply:${body}` };
});
createTelegramBot({ token: "tok" });
const messageHandler = getOnHandler("message") as (
ctx: TelegramMiddlewareTestContext,
) => Promise<void>;
const firstCtx = {
...makeForumGroupMessageCtx({ threadId: 99, text: "first message" }),
message: {
...makeForumGroupMessageCtx({ threadId: 99, text: "first message" }).message,
message_id: 201,
},
update: { update_id: 201 },
};
const secondCtx = {
...makeForumGroupMessageCtx({ threadId: 99, text: "second message" }),
message: {
...makeForumGroupMessageCtx({ threadId: 99, text: "second message" }).message,
message_id: 202,
},
update: { update_id: 202 },
};
const firstPromise = runTelegramMiddlewareChain({
ctx: firstCtx,
finalHandler: messageHandler,
});
await vi.waitFor(() => {
expect(startedBodies).toHaveLength(1);
expect(startedBodies[0]).toContain("first message");
});
const secondPromise = runTelegramMiddlewareChain({
ctx: secondCtx,
finalHandler: messageHandler,
});
await Promise.resolve();
expect(startedBodies).toHaveLength(1);
expect(startedBodies[0]).toContain("first message");
expect(sendMessageSpy).not.toHaveBeenCalled();
releaseFirstTurn();
await Promise.all([firstPromise, secondPromise]);
expect(startedBodies).toHaveLength(2);
expect(startedBodies[0]).toContain("first message");
expect(startedBodies[1]).toContain("second message");
const sentBodies = sendMessageSpy.mock.calls.map((call) => String(call[1]));
expect(sentBodies[0]).toContain("first message");
expect(sentBodies[1]).toContain("second message");
});
it("preserves same-chat reply order when a debounced run is still active", async () => {
const DEBOUNCE_MS = 4321;
loadConfig.mockReturnValue({
@@ -332,7 +332,15 @@ describe("resolveMedia getFile retry", () => {
it("uses caller-provided fetch impl for file downloads", async () => {
const getFile = vi.fn().mockResolvedValue({ file_path: "documents/file_42.pdf" });
const callerFetch = vi.fn() as unknown as typeof fetch;
const dispatcherAttempts = [{ dispatcherPolicy: { mode: "direct" as const } }];
const dispatcherAttempts = [
{
dispatcherPolicy: {
mode: "explicit-proxy" as const,
proxyUrl: "http://localhost:6152",
allowPrivateProxy: true,
},
},
];
const callerTransport = {
fetch: callerFetch,
sourceFetch: callerFetch,
@@ -357,6 +365,7 @@ describe("resolveMedia getFile retry", () => {
expect.objectContaining({
fetchImpl: callerFetch,
dispatcherAttempts,
trustExplicitProxyDns: true,
shouldRetryFetchError: expect.any(Function),
readIdleTimeoutMs: 30_000,
ssrfPolicy: {
@@ -157,6 +157,14 @@ function resolveRequiredTelegramTransport(transport?: TelegramTransport): Telegr
/** Default idle timeout for Telegram media downloads (30 seconds). */
const TELEGRAM_DOWNLOAD_IDLE_TIMEOUT_MS = 30_000;
function usesTrustedTelegramExplicitProxy(transport: TelegramTransport): boolean {
return (
transport.dispatcherAttempts?.some(
(attempt) => attempt.dispatcherPolicy?.mode === "explicit-proxy",
) ?? false
);
}
function resolveTrustedLocalTelegramRoot(
filePath: string,
trustedLocalFileRoots?: readonly string[],
@@ -225,6 +233,7 @@ async function downloadAndSaveTelegramFile(params: {
url,
fetchImpl: transport.sourceFetch,
dispatcherAttempts: transport.dispatcherAttempts,
trustExplicitProxyDns: usesTrustedTelegramExplicitProxy(transport),
shouldRetryFetchError: shouldRetryTelegramTransportFallback,
filePathHint: params.filePath,
maxBytes: params.maxBytes,
+33 -1
View File
@@ -59,7 +59,39 @@ describe("getTelegramSequentialKey", () => {
{ message: mockMessage({ chat: mockChat({ id: 123 }), text: "/stop" }) },
"telegram:123:control",
],
[{ message: mockMessage({ chat: mockChat({ id: 123 }), text: "/status" }) }, "telegram:123"],
[
{ message: mockMessage({ chat: mockChat({ id: 123 }), text: "/status" }) },
"telegram:123:control",
],
[
{ message: mockMessage({ chat: mockChat({ id: 123 }), text: "/commands" }) },
"telegram:123:control",
],
[
{ message: mockMessage({ chat: mockChat({ id: 123 }), text: "/help" }) },
"telegram:123:control",
],
[
{ message: mockMessage({ chat: mockChat({ id: 123 }), text: "/tools" }) },
"telegram:123:control",
],
[
{ message: mockMessage({ chat: mockChat({ id: 123 }), text: "/tasks" }) },
"telegram:123:control",
],
[
{ message: mockMessage({ chat: mockChat({ id: 123 }), text: "/context" }) },
"telegram:123:control",
],
[
{ message: mockMessage({ chat: mockChat({ id: 123 }), text: "/whoami" }) },
"telegram:123:control",
],
[
{ message: mockMessage({ chat: mockChat({ id: 123 }), text: "/export-session" }) },
"telegram:123",
],
[{ message: mockMessage({ chat: mockChat({ id: 123 }), text: "/export" }) }, "telegram:123"],
[
{ message: mockMessage({ chat: mockChat({ id: 123 }), text: "/btw what is the time?" }) },
"telegram:123:btw:1",
+32
View File
@@ -1,4 +1,9 @@
import { type Message, type UserFromGetMe } from "@grammyjs/types";
import {
listChatCommands,
maybeResolveTextAlias,
normalizeCommandBody,
} from "openclaw/plugin-sdk/command-auth";
import { parseExecApprovalCommandText } from "openclaw/plugin-sdk/infra-runtime";
import { isAbortRequestText } from "openclaw/plugin-sdk/reply-runtime";
import { isBtwRequestText } from "openclaw/plugin-sdk/reply-runtime";
@@ -20,6 +25,27 @@ export type TelegramSequentialKeyContext = {
};
};
function resolveStatusCommandControlLane(params: {
rawText?: string;
botUsername?: string;
}): boolean {
// Only read-only status commands should bypass the per-topic lane. Commands
// like /export-session stay on the normal lane because they materialize
// session state to disk and should not interleave with an active turn.
const normalizedBody = normalizeCommandBody(
params.rawText?.trim() ?? "",
params.botUsername ? { botUsername: params.botUsername } : undefined,
);
const alias = maybeResolveTextAlias(normalizedBody);
if (!alias) {
return false;
}
const command = listChatCommands().find((entry) =>
entry.textAliases.some((candidate) => candidate.trim().toLowerCase() === alias),
);
return command?.category === "status" && command.key !== "export-session";
}
export function getTelegramSequentialKey(ctx: TelegramSequentialKeyContext): string {
const reaction = ctx.update?.message_reaction;
if (reaction?.chat?.id) {
@@ -43,6 +69,12 @@ export function getTelegramSequentialKey(ctx: TelegramSequentialKeyContext): str
}
return "telegram:control";
}
if (resolveStatusCommandControlLane({ rawText, botUsername })) {
if (typeof chatId === "number") {
return `telegram:${chatId}:control`;
}
return "telegram:control";
}
if (isBtwRequestText(rawText, botUsername ? { botUsername } : undefined)) {
const messageId = msg?.message_id;
if (typeof chatId === "number" && typeof messageId === "number") {
@@ -1,8 +1,13 @@
import syncFs from "node:fs";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
clearTopicNameCache,
getTopicEntry,
getTopicName,
resetTopicNameCacheForTest,
topicNameCacheSize,
updateTopicName,
} from "./topic-name-cache.js";
@@ -11,6 +16,7 @@ describe("topic-name-cache", () => {
beforeEach(() => {
vi.useRealTimers();
clearTopicNameCache();
resetTopicNameCacheForTest();
});
afterEach(() => {
@@ -64,7 +70,6 @@ describe("topic-name-cache", () => {
it("updates timestamps on write", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-04-13T22:00:00.000Z"));
updateTopicName(-100123, 42, { name: "A" });
const t1 = getTopicEntry(-100123, 42)?.updatedAt ?? 0;
await vi.advanceTimersByTimeAsync(10);
@@ -89,7 +94,6 @@ describe("topic-name-cache", () => {
it("refreshes recency on read so active topics survive eviction", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-04-13T22:00:00.000Z"));
updateTopicName(-100000, 1, { name: "Active" });
await vi.advanceTimersByTimeAsync(10);
for (let i = 2; i <= 2048; i++) {
@@ -100,4 +104,37 @@ describe("topic-name-cache", () => {
expect(getTopicName(-100000, 1)).toBe("Active");
expect(topicNameCacheSize()).toBe(2048);
});
it("reloads persisted entries from disk", async () => {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-topic-cache-"));
const persistedPath = path.join(tempDir, "topic-names.json");
try {
updateTopicName(-100123, 42, { name: "Deployments" }, persistedPath);
resetTopicNameCacheForTest();
expect(getTopicName(-100123, 42, persistedPath)).toBe("Deployments");
} finally {
await fs.rm(tempDir, { recursive: true, force: true });
resetTopicNameCacheForTest();
}
});
it("keeps separate in-memory stores for separate persisted paths", async () => {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-topic-cache-"));
const firstPath = path.join(tempDir, "first-topic-names.json");
const secondPath = path.join(tempDir, "second-topic-names.json");
try {
updateTopicName(-100123, 42, { name: "Deployments" }, firstPath);
updateTopicName(-200456, 84, { name: "Incidents" }, secondPath);
const readFileSpy = vi.spyOn(syncFs, "readFileSync");
expect(getTopicName(-100123, 42, firstPath)).toBe("Deployments");
expect(getTopicName(-200456, 84, secondPath)).toBe("Incidents");
expect(readFileSpy).not.toHaveBeenCalled();
} finally {
vi.restoreAllMocks();
await fs.rm(tempDir, { recursive: true, force: true });
resetTopicNameCacheForTest();
}
});
});
+162 -24
View File
@@ -1,4 +1,10 @@
import fs from "node:fs";
import path from "node:path";
import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
const MAX_ENTRIES = 2_048;
const TOPIC_NAME_CACHE_STATE_KEY = Symbol.for("openclaw.telegramTopicNameCacheState");
const DEFAULT_TOPIC_NAME_CACHE_KEY = "__default__";
export type TopicEntry = {
name: string;
@@ -8,27 +14,151 @@ export type TopicEntry = {
updatedAt: number;
};
const cache = new Map<string, TopicEntry>();
type TopicNameStore = Map<string, TopicEntry>;
type TopicNameStoreState = {
lastUpdatedAt: number;
store: TopicNameStore;
};
type TopicNameCacheState = {
stores: Map<string, TopicNameStoreState>;
};
function createTopicNameStore(): TopicNameStore {
return new Map<string, TopicEntry>();
}
function createTopicNameStoreState(): TopicNameStoreState {
return {
lastUpdatedAt: 0,
store: createTopicNameStore(),
};
}
function getTopicNameCacheState(): TopicNameCacheState {
const globalStore = globalThis as Record<PropertyKey, unknown>;
const existing = globalStore[TOPIC_NAME_CACHE_STATE_KEY] as TopicNameCacheState | undefined;
if (existing) {
return existing;
}
const state: TopicNameCacheState = { stores: new Map() };
globalStore[TOPIC_NAME_CACHE_STATE_KEY] = state;
return state;
}
function cacheKey(chatId: number | string, threadId: number | string): string {
return `${chatId}:${threadId}`;
}
function evictOldest(): void {
while (cache.size > MAX_ENTRIES) {
const oldestKey = cache.keys().next().value;
if (!oldestKey) {
return;
}
cache.delete(oldestKey);
export function resolveTopicNameCachePath(storePath: string): string {
return `${storePath}.telegram-topic-names.json`;
}
function evictOldest(store: TopicNameStore): void {
if (store.size <= MAX_ENTRIES) {
return;
}
let oldestKey: string | undefined;
let oldestTime = Infinity;
for (const [key, entry] of store) {
if (entry.updatedAt < oldestTime) {
oldestTime = entry.updatedAt;
oldestKey = key;
}
}
if (oldestKey) {
store.delete(oldestKey);
}
}
function isTopicEntry(value: unknown): value is TopicEntry {
if (!value || typeof value !== "object") {
return false;
}
const entry = value as Partial<TopicEntry>;
return (
typeof entry.name === "string" &&
entry.name.length > 0 &&
typeof entry.updatedAt === "number" &&
Number.isFinite(entry.updatedAt)
);
}
function readPersistedTopicNames(persistedPath: string): TopicNameStore {
if (!fs.existsSync(persistedPath)) {
return createTopicNameStore();
}
try {
const raw = fs.readFileSync(persistedPath, "utf-8");
const parsed = JSON.parse(raw) as Record<string, unknown>;
const entries = Object.entries(parsed)
.filter((entry): entry is [string, TopicEntry] => isTopicEntry(entry[1]))
.toSorted(([, left], [, right]) => right.updatedAt - left.updatedAt)
.slice(0, MAX_ENTRIES);
return new Map(entries);
} catch (error) {
logVerbose(`telegram: failed to read topic-name cache: ${String(error)}`);
return createTopicNameStore();
}
}
function getTopicStoreState(persistedPath?: string): TopicNameStoreState {
const state = getTopicNameCacheState();
const stateKey = persistedPath ?? DEFAULT_TOPIC_NAME_CACHE_KEY;
const existing = state.stores.get(stateKey);
if (existing) {
return existing;
}
const next = persistedPath
? {
lastUpdatedAt: 0,
store: readPersistedTopicNames(persistedPath),
}
: createTopicNameStoreState();
next.lastUpdatedAt = Math.max(0, ...Array.from(next.store.values(), (entry) => entry.updatedAt));
state.stores.set(stateKey, next);
return next;
}
function getTopicStore(persistedPath?: string): TopicNameStore {
return getTopicStoreState(persistedPath).store;
}
function nextUpdatedAt(persistedPath?: string): number {
const state = getTopicStoreState(persistedPath);
const now = Date.now();
state.lastUpdatedAt = now > state.lastUpdatedAt ? now : state.lastUpdatedAt + 1;
return state.lastUpdatedAt;
}
function removeTopicStore(persistedPath?: string): void {
const state = getTopicNameCacheState();
const stateKey = persistedPath ?? DEFAULT_TOPIC_NAME_CACHE_KEY;
if (persistedPath) {
fs.rmSync(persistedPath, { force: true });
}
state.stores.delete(stateKey);
}
function persistTopicStore(persistedPath: string, store: TopicNameStore): void {
if (store.size === 0) {
fs.rmSync(persistedPath, { force: true });
return;
}
fs.mkdirSync(path.dirname(persistedPath), { recursive: true });
const tempPath = `${persistedPath}.${process.pid}.tmp`;
fs.writeFileSync(tempPath, JSON.stringify(Object.fromEntries(store)), "utf-8");
fs.renameSync(tempPath, persistedPath);
}
export function updateTopicName(
chatId: number | string,
threadId: number | string,
patch: Partial<Omit<TopicEntry, "updatedAt">>,
persistedPath?: string,
): void {
const cache = getTopicStore(persistedPath);
const key = cacheKey(chatId, threadId);
const existing = cache.get(key);
const merged: TopicEntry = {
@@ -36,45 +166,53 @@ export function updateTopicName(
iconColor: patch.iconColor ?? existing?.iconColor,
iconCustomEmojiId: patch.iconCustomEmojiId ?? existing?.iconCustomEmojiId,
closed: patch.closed ?? existing?.closed,
updatedAt: Date.now(),
updatedAt: nextUpdatedAt(persistedPath),
};
if (!merged.name) {
return;
}
cache.delete(key);
cache.set(key, merged);
evictOldest();
evictOldest(cache);
if (persistedPath) {
try {
persistTopicStore(persistedPath, cache);
} catch (error) {
logVerbose(`telegram: failed to persist topic-name cache: ${String(error)}`);
}
}
}
export function getTopicName(
chatId: number | string,
threadId: number | string,
persistedPath?: string,
): string | undefined {
const key = cacheKey(chatId, threadId);
const entry = cache.get(key);
const entry = getTopicStore(persistedPath).get(cacheKey(chatId, threadId));
if (entry) {
const refreshedEntry: TopicEntry = {
...entry,
updatedAt: Date.now(),
};
cache.delete(key);
cache.set(key, refreshedEntry);
return refreshedEntry.name;
entry.updatedAt = nextUpdatedAt(persistedPath);
}
return undefined;
return entry?.name;
}
export function getTopicEntry(
chatId: number | string,
threadId: number | string,
persistedPath?: string,
): TopicEntry | undefined {
return cache.get(cacheKey(chatId, threadId));
return getTopicStore(persistedPath).get(cacheKey(chatId, threadId));
}
export function clearTopicNameCache(): void {
cache.clear();
const state = getTopicNameCacheState();
for (const stateKey of state.stores.keys()) {
removeTopicStore(stateKey === DEFAULT_TOPIC_NAME_CACHE_KEY ? undefined : stateKey);
}
}
export function topicNameCacheSize(): number {
return cache.size;
return getTopicStore().size;
}
export function resetTopicNameCacheForTest(): void {
getTopicNameCacheState().stores.clear();
}
+251 -204
View File
@@ -1,8 +1,16 @@
import { describe, expect, it } from "vitest";
import { resolveOpenClawAgentDir } from "../src/agents/agent-paths.js";
import { collectProviderApiKeys } from "../src/agents/live-auth-keys.js";
import { isModelNotFoundErrorMessage } from "../src/agents/live-model-errors.js";
import { isLiveProfileKeyModeEnabled, isLiveTestEnabled } from "../src/agents/live-test-helpers.js";
import { resolveApiKeyForProvider } from "../src/agents/model-auth.js";
import {
isAuthErrorMessage,
isBillingErrorMessage,
isOverloadedErrorMessage,
isServerErrorMessage,
isTimeoutErrorMessage,
} from "../src/agents/pi-embedded-helpers/failover-matches.js";
import { loadConfig, type OpenClawConfig } from "../src/config/config.js";
import { isTruthyEnvValue } from "../src/infra/env.js";
import { getShellEnvAppliedKeys, loadShellEnvFallback } from "../src/infra/shell-env.js";
@@ -149,6 +157,29 @@ function maybeLoadShellEnvForVideoProviders(providerIds: string[]): void {
});
}
function resolveLiveVideoSkipReason(message: string): string | null {
if (isAuthErrorMessage(message)) {
return "auth drift";
}
if (isModelNotFoundErrorMessage(message)) {
return "model drift";
}
if (isBillingErrorMessage(message)) {
return "billing drift";
}
if (
isTimeoutErrorMessage(message) ||
/did not finish in time/i.test(message) ||
/last status:\s*in_progress/i.test(message)
) {
return "provider timeout";
}
if (isOverloadedErrorMessage(message) || isServerErrorMessage(message)) {
return "provider outage";
}
return null;
}
function expectBufferedVideo(
video: { buffer?: Buffer; mimeType: string; fileName?: string } | undefined,
): { buffer: Buffer; mimeType: string; fileName?: string } {
@@ -162,225 +193,241 @@ function expectBufferedVideo(
return { buffer, mimeType, fileName };
}
describeLive("video generation provider live", () => {
it(
"covers declared video-generation modes with shell/profile auth",
async () => {
const cfg = withPluginsEnabled(loadConfig());
const configuredModels = resolveConfiguredLiveVideoModels(cfg);
const agentDir = resolveOpenClawAgentDir();
const attempted: string[] = [];
const skipped: string[] = [];
const failures: string[] = [];
async function runLiveVideoProviderCase(testCase: LiveProviderCase): Promise<void> {
const cfg = withPluginsEnabled(loadConfig());
const configuredModels = resolveConfiguredLiveVideoModels(cfg);
const agentDir = resolveOpenClawAgentDir();
const attempted: string[] = [];
const skipped: string[] = [];
const failures: string[] = [];
maybeLoadShellEnvForVideoProviders(CASES.map((entry) => entry.providerId));
maybeLoadShellEnvForVideoProviders([testCase.providerId]);
for (const testCase of CASES) {
const modelRef =
envModelMap.get(testCase.providerId) ??
configuredModels.get(testCase.providerId) ??
DEFAULT_LIVE_VIDEO_MODELS[testCase.providerId];
if (!modelRef) {
skipped.push(`${testCase.providerId}: no model configured`);
continue;
}
const modelRef =
envModelMap.get(testCase.providerId) ??
configuredModels.get(testCase.providerId) ??
DEFAULT_LIVE_VIDEO_MODELS[testCase.providerId];
if (!modelRef) {
skipped.push(`${testCase.providerId}: no model configured`);
console.log(
`[live:video-generation] provider=${testCase.providerId} attempted=none skipped=${skipped.join(", ")} failures=none shellEnv=${getShellEnvAppliedKeys().join(", ") || "none"}`,
);
return;
}
const hasLiveKeys = collectProviderApiKeys(testCase.providerId).length > 0;
const authStore = resolveLiveVideoAuthStore({
requireProfileKeys: REQUIRE_PROFILE_KEYS,
hasLiveKeys,
const hasLiveKeys = collectProviderApiKeys(testCase.providerId).length > 0;
const authStore = resolveLiveVideoAuthStore({
requireProfileKeys: REQUIRE_PROFILE_KEYS,
hasLiveKeys,
});
let authLabel = "unresolved";
try {
const auth = await resolveApiKeyForProvider({
provider: testCase.providerId,
cfg,
agentDir,
store: authStore,
});
authLabel = `${auth.source} ${redactLiveApiKey(auth.apiKey)}`;
} catch {
skipped.push(`${testCase.providerId}: no usable auth`);
console.log(
`[live:video-generation] provider=${testCase.providerId} attempted=none skipped=${skipped.join(", ")} failures=none shellEnv=${getShellEnvAppliedKeys().join(", ") || "none"}`,
);
return;
}
const { videoProviders } = await registerProviderPlugin({
plugin: testCase.plugin,
id: testCase.pluginId,
name: testCase.pluginName,
});
const provider = requireRegisteredProvider(videoProviders, testCase.providerId, "video provider");
const providerModel = resolveProviderModelForLiveTest(testCase.providerId, modelRef);
const generateCaps = provider.capabilities.generate;
const imageToVideoCaps = provider.capabilities.imageToVideo;
const videoToVideoCaps = provider.capabilities.videoToVideo;
const durationSeconds = Math.min(generateCaps?.maxDurationSeconds ?? 3, 3);
const liveResolution = resolveLiveVideoResolution({
providerId: testCase.providerId,
modelRef,
});
const liveSize = testCase.providerId === "openai" ? "1280x720" : undefined;
const logPrefix = `[live:video-generation] provider=${testCase.providerId} model=${providerModel}`;
let generatedVideo = null as {
buffer: Buffer;
mimeType: string;
fileName?: string;
} | null;
try {
const startedAt = Date.now();
console.error(`${logPrefix} mode=generate start auth=${authLabel}`);
const result = await provider.generateVideo({
provider: testCase.providerId,
model: providerModel,
prompt: "A tiny paper diorama city at sunrise with slow cinematic camera motion and no text.",
cfg,
agentDir,
authStore,
durationSeconds,
...(generateCaps?.supportsSize && liveSize ? { size: liveSize } : {}),
...(generateCaps?.supportsAspectRatio ? { aspectRatio: "16:9" } : {}),
...(generateCaps?.supportsResolution ? { resolution: liveResolution } : {}),
...(generateCaps?.supportsAudio ? { audio: false } : {}),
...(generateCaps?.supportsWatermark ? { watermark: false } : {}),
});
expect(result.videos.length).toBeGreaterThan(0);
generatedVideo = expectBufferedVideo(result.videos[0]);
attempted.push(`${testCase.providerId}:generate:${providerModel} (${authLabel})`);
console.error(
`${logPrefix} mode=generate done ms=${Date.now() - startedAt} videos=${result.videos.length}`,
);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
const skipReason = resolveLiveVideoSkipReason(message);
if (skipReason) {
skipped.push(`${testCase.providerId}:generate (${authLabel}): ${skipReason}`);
console.error(`${logPrefix} mode=generate skip (${skipReason}) error=${message}`);
} else {
failures.push(`${testCase.providerId}:generate (${authLabel}): ${message}`);
console.error(`${logPrefix} mode=generate failed error=${message}`);
}
console.log(
`[live:video-generation] provider=${testCase.providerId} attempted=${attempted.join(", ") || "none"} skipped=${skipped.join(", ") || "none"} failures=${failures.join(" | ") || "none"} shellEnv=${getShellEnvAppliedKeys().join(", ") || "none"}`,
);
expect(failures).toEqual([]);
return;
}
if (imageToVideoCaps?.enabled) {
if (
!canRunBufferBackedImageToVideoLiveLane({
providerId: testCase.providerId,
modelRef,
})
) {
skipped.push(
`${testCase.providerId}:imageToVideo requires remote URL or model-specific input`,
);
} else {
try {
const startedAt = Date.now();
console.error(`${logPrefix} mode=imageToVideo start auth=${authLabel}`);
const referenceImage =
testCase.providerId === "openai"
? createEditReferencePng({ width: 1280, height: 720 })
: createEditReferencePng();
const result = await provider.generateVideo({
provider: testCase.providerId,
model: providerModel,
prompt:
"Animate the reference art with subtle parallax motion and drifting camera movement.",
cfg,
agentDir,
authStore,
durationSeconds,
...(imageToVideoCaps.supportsSize && liveSize ? { size: liveSize } : {}),
inputImages: [
{
buffer: referenceImage,
mimeType: "image/png",
fileName: "reference.png",
},
],
...(imageToVideoCaps.supportsAspectRatio ? { aspectRatio: "16:9" } : {}),
...(imageToVideoCaps.supportsResolution ? { resolution: liveResolution } : {}),
...(imageToVideoCaps.supportsAudio ? { audio: false } : {}),
...(imageToVideoCaps.supportsWatermark ? { watermark: false } : {}),
});
let authLabel = "unresolved";
try {
const auth = await resolveApiKeyForProvider({
provider: testCase.providerId,
cfg,
agentDir,
store: authStore,
});
authLabel = `${auth.source} ${redactLiveApiKey(auth.apiKey)}`;
} catch {
skipped.push(`${testCase.providerId}: no usable auth`);
continue;
}
const { videoProviders } = await registerProviderPlugin({
plugin: testCase.plugin,
id: testCase.pluginId,
name: testCase.pluginName,
});
const provider = requireRegisteredProvider(
videoProviders,
testCase.providerId,
"video provider",
expect(result.videos.length).toBeGreaterThan(0);
expectBufferedVideo(result.videos[0]);
attempted.push(`${testCase.providerId}:imageToVideo:${providerModel} (${authLabel})`);
console.error(
`${logPrefix} mode=imageToVideo done ms=${Date.now() - startedAt} videos=${result.videos.length}`,
);
const providerModel = resolveProviderModelForLiveTest(testCase.providerId, modelRef);
const generateCaps = provider.capabilities.generate;
const imageToVideoCaps = provider.capabilities.imageToVideo;
const videoToVideoCaps = provider.capabilities.videoToVideo;
const durationSeconds = Math.min(generateCaps?.maxDurationSeconds ?? 3, 3);
const liveResolution = resolveLiveVideoResolution({
providerId: testCase.providerId,
modelRef,
});
const liveSize = testCase.providerId === "openai" ? "1280x720" : undefined;
const logPrefix = `[live:video-generation] provider=${testCase.providerId} model=${providerModel}`;
let generatedVideo = null as {
buffer: Buffer;
mimeType: string;
fileName?: string;
} | null;
try {
const startedAt = Date.now();
console.error(`${logPrefix} mode=generate start auth=${authLabel}`);
const result = await provider.generateVideo({
provider: testCase.providerId,
model: providerModel,
prompt:
"A tiny paper diorama city at sunrise with slow cinematic camera motion and no text.",
cfg,
agentDir,
authStore,
durationSeconds,
...(generateCaps?.supportsSize && liveSize ? { size: liveSize } : {}),
...(generateCaps?.supportsAspectRatio ? { aspectRatio: "16:9" } : {}),
...(generateCaps?.supportsResolution ? { resolution: liveResolution } : {}),
...(generateCaps?.supportsAudio ? { audio: false } : {}),
...(generateCaps?.supportsWatermark ? { watermark: false } : {}),
});
expect(result.videos.length).toBeGreaterThan(0);
generatedVideo = expectBufferedVideo(result.videos[0]);
attempted.push(`${testCase.providerId}:generate:${providerModel} (${authLabel})`);
console.error(
`${logPrefix} mode=generate done ms=${Date.now() - startedAt} videos=${result.videos.length}`,
);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
failures.push(`${testCase.providerId}:generate (${authLabel}): ${message}`);
console.error(`${logPrefix} mode=generate failed error=${message}`);
continue;
}
if (!imageToVideoCaps?.enabled) {
continue;
}
if (
!canRunBufferBackedImageToVideoLiveLane({
providerId: testCase.providerId,
modelRef,
})
) {
skipped.push(
`${testCase.providerId}:imageToVideo requires remote URL or model-specific input`,
);
continue;
}
try {
const startedAt = Date.now();
console.error(`${logPrefix} mode=imageToVideo start auth=${authLabel}`);
const referenceImage =
testCase.providerId === "openai"
? createEditReferencePng({ width: 1280, height: 720 })
: createEditReferencePng();
const result = await provider.generateVideo({
provider: testCase.providerId,
model: providerModel,
prompt:
"Animate the reference art with subtle parallax motion and drifting camera movement.",
cfg,
agentDir,
authStore,
durationSeconds,
...(imageToVideoCaps.supportsSize && liveSize ? { size: liveSize } : {}),
inputImages: [
{
buffer: referenceImage,
mimeType: "image/png",
fileName: "reference.png",
},
],
...(imageToVideoCaps.supportsAspectRatio ? { aspectRatio: "16:9" } : {}),
...(imageToVideoCaps.supportsResolution ? { resolution: liveResolution } : {}),
...(imageToVideoCaps.supportsAudio ? { audio: false } : {}),
...(imageToVideoCaps.supportsWatermark ? { watermark: false } : {}),
});
expect(result.videos.length).toBeGreaterThan(0);
expectBufferedVideo(result.videos[0]);
attempted.push(`${testCase.providerId}:imageToVideo:${providerModel} (${authLabel})`);
console.error(
`${logPrefix} mode=imageToVideo done ms=${Date.now() - startedAt} videos=${result.videos.length}`,
);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
const skipReason = resolveLiveVideoSkipReason(message);
if (skipReason) {
skipped.push(`${testCase.providerId}:imageToVideo (${authLabel}): ${skipReason}`);
console.error(`${logPrefix} mode=imageToVideo skip (${skipReason}) error=${message}`);
} else {
failures.push(`${testCase.providerId}:imageToVideo (${authLabel}): ${message}`);
console.error(`${logPrefix} mode=imageToVideo failed error=${message}`);
}
}
}
}
if (!videoToVideoCaps?.enabled) {
continue;
}
if (
!canRunBufferBackedVideoToVideoLiveLane({
providerId: testCase.providerId,
modelRef,
})
) {
skipped.push(
`${testCase.providerId}:videoToVideo requires remote URL or model-specific input`,
);
continue;
}
if (!generatedVideo?.buffer) {
skipped.push(`${testCase.providerId}:videoToVideo missing generated seed video`);
continue;
}
if (videoToVideoCaps?.enabled) {
if (
!canRunBufferBackedVideoToVideoLiveLane({
providerId: testCase.providerId,
modelRef,
})
) {
skipped.push(
`${testCase.providerId}:videoToVideo requires remote URL or model-specific input`,
);
} else if (!generatedVideo?.buffer) {
skipped.push(`${testCase.providerId}:videoToVideo missing generated seed video`);
} else {
try {
const startedAt = Date.now();
console.error(`${logPrefix} mode=videoToVideo start auth=${authLabel}`);
const result = await provider.generateVideo({
provider: testCase.providerId,
model: providerModel,
prompt: "Rework the reference clip into a brighter, steadier cinematic continuation.",
cfg,
agentDir,
authStore,
durationSeconds: Math.min(videoToVideoCaps.maxDurationSeconds ?? durationSeconds, 3),
inputVideos: [generatedVideo],
...(videoToVideoCaps.supportsAspectRatio ? { aspectRatio: "16:9" } : {}),
...(videoToVideoCaps.supportsResolution ? { resolution: liveResolution } : {}),
...(videoToVideoCaps.supportsAudio ? { audio: false } : {}),
...(videoToVideoCaps.supportsWatermark ? { watermark: false } : {}),
});
try {
const startedAt = Date.now();
console.error(`${logPrefix} mode=videoToVideo start auth=${authLabel}`);
const result = await provider.generateVideo({
provider: testCase.providerId,
model: providerModel,
prompt: "Rework the reference clip into a brighter, steadier cinematic continuation.",
cfg,
agentDir,
authStore,
durationSeconds: Math.min(videoToVideoCaps.maxDurationSeconds ?? durationSeconds, 3),
inputVideos: [generatedVideo],
...(videoToVideoCaps.supportsAspectRatio ? { aspectRatio: "16:9" } : {}),
...(videoToVideoCaps.supportsResolution ? { resolution: liveResolution } : {}),
...(videoToVideoCaps.supportsAudio ? { audio: false } : {}),
...(videoToVideoCaps.supportsWatermark ? { watermark: false } : {}),
});
expect(result.videos.length).toBeGreaterThan(0);
expectBufferedVideo(result.videos[0]);
attempted.push(`${testCase.providerId}:videoToVideo:${providerModel} (${authLabel})`);
console.error(
`${logPrefix} mode=videoToVideo done ms=${Date.now() - startedAt} videos=${result.videos.length}`,
);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
expect(result.videos.length).toBeGreaterThan(0);
expectBufferedVideo(result.videos[0]);
attempted.push(`${testCase.providerId}:videoToVideo:${providerModel} (${authLabel})`);
console.error(
`${logPrefix} mode=videoToVideo done ms=${Date.now() - startedAt} videos=${result.videos.length}`,
);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
const skipReason = resolveLiveVideoSkipReason(message);
if (skipReason) {
skipped.push(`${testCase.providerId}:videoToVideo (${authLabel}): ${skipReason}`);
console.error(`${logPrefix} mode=videoToVideo skip (${skipReason}) error=${message}`);
} else {
failures.push(`${testCase.providerId}:videoToVideo (${authLabel}): ${message}`);
console.error(`${logPrefix} mode=videoToVideo failed error=${message}`);
}
}
}
}
console.log(
`[live:video-generation] attempted=${attempted.join(", ") || "none"} skipped=${skipped.join(", ") || "none"} failures=${failures.join(" | ") || "none"} shellEnv=${getShellEnvAppliedKeys().join(", ") || "none"}`,
);
if (attempted.length === 0) {
expect(failures).toEqual([]);
console.warn("[live:video-generation] no provider had usable auth; skipping assertions");
return;
}
expect(failures).toEqual([]);
},
15 * 60_000,
console.log(
`[live:video-generation] provider=${testCase.providerId} attempted=${attempted.join(", ") || "none"} skipped=${skipped.join(", ") || "none"} failures=${failures.join(" | ") || "none"} shellEnv=${getShellEnvAppliedKeys().join(", ") || "none"}`,
);
expect(failures).toEqual([]);
}
describeLive("video generation provider live", () => {
for (const testCase of CASES) {
// One provider per test keeps cumulative suite runtime from tripping a single timeout cap.
it(
`covers declared video-generation modes with shell/profile auth (${testCase.providerId})`,
async () => {
await runLiveVideoProviderCase(testCase);
},
15 * 60_000,
);
}
});
+5 -5
View File
@@ -46,6 +46,10 @@ function shouldClearSocketRefAfterSendFailure(err: unknown): boolean {
return /closed|reset|disconnect|no active socket/i.test(formatError(err));
}
function isNonEmptyString(value: string | undefined): value is string {
return Boolean(value);
}
export type MonitorWebInboxOptions = {
verbose: boolean;
accountId: string;
@@ -132,11 +136,7 @@ export async function attachWebInboxToSocket(
error?: unknown,
): Promise<void> => {
const dedupeKeys = [
...new Set(
entries
.map((entry) => entry.dedupeKey)
.filter((dedupeKey): dedupeKey is string => Boolean(dedupeKey)),
),
...new Set(entries.map((entry) => entry.dedupeKey).filter(isNonEmptyString)),
];
if (dedupeKeys.length === 0) {
return;
+5 -1
View File
@@ -1,6 +1,6 @@
{
"name": "openclaw",
"version": "2026.4.14-beta.1",
"version": "2026.4.14",
"description": "Multi-channel AI gateway with extensible messaging integrations",
"keywords": [],
"homepage": "https://github.com/openclaw/openclaw#readme",
@@ -482,6 +482,10 @@
"types": "./dist/plugin-sdk/command-status.d.ts",
"default": "./dist/plugin-sdk/command-status.js"
},
"./plugin-sdk/command-status-runtime": {
"types": "./dist/plugin-sdk/command-status-runtime.d.ts",
"default": "./dist/plugin-sdk/command-status-runtime.js"
},
"./plugin-sdk/command-detection": {
"types": "./dist/plugin-sdk/command-detection.d.ts",
"default": "./dist/plugin-sdk/command-detection.js"
@@ -207,7 +207,7 @@ steps:
args:
- lambda:
async: true
expr: "await (async () => { const store = await readRawQaSessionStore(env); const entry = store[activeSessionKey]; if (!entry || !Array.isArray(entry.pluginDebugEntries)) return undefined; return entry.pluginDebugEntries.some((pluginEntry) => pluginEntry?.pluginId === 'active-memory' && Array.isArray(pluginEntry.lines) && pluginEntry.lines.some((line) => line.includes('Active Memory: ok'))) ? entry : undefined; })()"
expr: "await (async () => { const store = await readRawQaSessionStore(env); const entry = store[activeSessionKey]; if (!entry || !Array.isArray(entry.pluginDebugEntries)) return undefined; return entry.pluginDebugEntries.some((pluginEntry) => pluginEntry?.pluginId === 'active-memory' && Array.isArray(pluginEntry.lines) && pluginEntry.lines.some((line) => line.includes('Active Memory: status=ok'))) ? entry : undefined; })()"
- 10000
- if:
expr: "Boolean(env.mock)"
@@ -0,0 +1,81 @@
# Empty-response recovery after replay-safe read
```yaml qa-scenario
id: empty-response-recovery-replay-safe-read
title: Empty-response recovery after replay-safe read
surface: runtime
objective: Verify an empty visible GPT turn after a replay-safe read auto-continues into a visible answer.
successCriteria:
- Scenario is mock-openai only so live lanes do not pick it up implicitly.
- The agent performs a replay-safe read before the empty response.
- The runtime injects the visible-answer continuation instruction after the empty turn.
- The final visible reply contains the exact recovery marker.
docsRefs:
- docs/help/testing.md
codeRefs:
- extensions/qa-lab/src/mock-openai-server.ts
- src/agents/pi-embedded-runner/run/incomplete-turn.ts
execution:
kind: flow
summary: Verify empty OpenAI turns recover after a replay-safe read.
config:
requiredProvider: mock-openai
promptSnippet: Empty response continuation QA check
prompt: "Empty response continuation QA check: read QA_KICKOFF_TASK.md, then answer with exactly EMPTY-RECOVERED-OK."
expectedReply: EMPTY-RECOVERED-OK
retryNeedle: The previous attempt did not produce a user-visible answer.
```
```yaml qa-flow
steps:
- name: retries an empty replay-safe read into a visible answer
actions:
- assert:
expr: "env.providerMode === 'mock-openai'"
message: this seeded scenario is mock-openai only
- call: waitForGatewayHealthy
args:
- ref: env
- 60000
- call: reset
- set: requestCountBefore
value:
expr: "env.mock ? (await fetchJson(`${env.mock.baseUrl}/debug/requests`)).length : 0"
- set: sessionKey
value:
expr: "`agent:qa:empty-response-recovery:${randomUUID().slice(0, 8)}`"
- call: runAgentPrompt
args:
- ref: env
- sessionKey:
ref: sessionKey
message:
expr: config.prompt
timeoutMs:
expr: liveTurnTimeoutMs(env, 45000)
- call: waitForOutboundMessage
saveAs: outbound
args:
- ref: state
- lambda:
params: [candidate]
expr: "candidate.conversation.id === 'qa-operator' && candidate.text.includes(config.expectedReply)"
- expr: liveTurnTimeoutMs(env, 30000)
- assert:
expr: "outbound.text.includes(config.expectedReply)"
message:
expr: "`missing empty-response recovery marker: ${outbound.text}`"
- if:
expr: "Boolean(env.mock)"
then:
- set: scenarioRequests
value:
expr: "(await fetchJson(`${env.mock.baseUrl}/debug/requests`)).slice(requestCountBefore)"
- assert:
expr: "scenarioRequests.some((request) => String(request.allInputText ?? '').includes(config.promptSnippet) && request.plannedToolName === 'read')"
message: expected replay-safe read request in mock trace
- assert:
expr: "scenarioRequests.some((request) => String(request.allInputText ?? '').includes(config.retryNeedle))"
message: expected empty-response retry instruction in mock trace
detailsExpr: "env.mock ? `${outbound.text}\\nrequests=${String(scenarioRequests?.length ?? 0)}` : outbound.text"
```
@@ -0,0 +1,75 @@
# Empty-response retry budget exhausted
```yaml qa-scenario
id: empty-response-retry-budget-exhausted
title: Empty-response retry budget exhausted
surface: runtime
objective: Verify repeated empty GPT turns exhaust the retry budget after one continuation attempt.
successCriteria:
- Scenario is mock-openai only so live lanes do not pick it up implicitly.
- The agent performs the replay-safe read that makes retrying allowed.
- Mock trace shows the run reaches a terminal post-read turn without ever producing the requested success marker.
docsRefs:
- docs/help/testing.md
codeRefs:
- extensions/qa-lab/src/mock-openai-server.ts
- src/agents/pi-embedded-runner/run/incomplete-turn.ts
execution:
kind: flow
summary: Verify empty-response retry exhaustion still surfaces a visible failure.
config:
requiredProvider: mock-openai
promptSnippet: Empty response exhaustion QA check
prompt: "Empty response exhaustion QA check: read QA_KICKOFF_TASK.md, then answer with exactly EMPTY-EXHAUSTED-OK."
retryNeedle: The previous attempt did not produce a user-visible answer.
```
```yaml qa-flow
steps:
- name: surfaces a retry error after empty-response exhaustion
actions:
- assert:
expr: "env.providerMode === 'mock-openai'"
message: this seeded scenario is mock-openai only
- call: waitForGatewayHealthy
args:
- ref: env
- 60000
- call: reset
- set: requestCountBefore
value:
expr: "env.mock ? (await fetchJson(`${env.mock.baseUrl}/debug/requests`)).length : 0"
- set: sessionKey
value:
expr: "`agent:qa:empty-response-exhausted:${randomUUID().slice(0, 8)}`"
- call: startAgentRun
saveAs: started
args:
- ref: env
- sessionKey:
ref: sessionKey
message:
expr: config.prompt
timeoutMs:
expr: liveTurnTimeoutMs(env, 45000)
- set: waited
value:
expr: "await env.gateway.call('agent.wait', { runId: started.runId, timeoutMs: liveTurnTimeoutMs(env, 45000) }, { timeoutMs: liveTurnTimeoutMs(env, 50000) })"
- assert:
expr: "waited?.status === 'ok'"
message:
expr: "`agent.wait returned ${String(waited?.status ?? 'unknown')}: ${String(waited?.error ?? '')}`"
- if:
expr: "Boolean(env.mock)"
then:
- set: scenarioRequests
value:
expr: "(await fetchJson(`${env.mock.baseUrl}/debug/requests`)).slice(requestCountBefore)"
- assert:
expr: "scenarioRequests.some((request) => String(request.allInputText ?? '').includes(config.promptSnippet) && request.plannedToolName === 'read')"
message: expected replay-safe read request in mock trace
- assert:
expr: "scenarioRequests.length >= 2"
message: expected at least the replay-safe read request and one terminal post-read turn
detailsExpr: "env.mock ? `requests=${String(scenarioRequests?.length ?? 0)}` : String(waited?.status ?? '')"
```
@@ -0,0 +1,90 @@
# Reasoning-only no-auto-retry after write
```yaml qa-scenario
id: reasoning-only-no-auto-retry-after-write
title: Reasoning-only no-auto-retry after write
surface: runtime
objective: Verify a GPT-style reasoning-only turn after a mutating write stays replay-unsafe and does not auto-retry.
successCriteria:
- Scenario is mock-openai only so live lanes do not pick it up implicitly.
- The agent performs the seeded mutating write.
- Mock trace does not include an automatic reasoning-only retry instruction.
- Mock trace stops after the write-side reasoning-only terminal turn instead of attempting a continuation.
docsRefs:
- docs/help/testing.md
- docs/help/gpt54-codex-agentic-parity.md
codeRefs:
- extensions/qa-lab/src/mock-openai-server.ts
- src/agents/pi-embedded-runner/run/incomplete-turn.ts
execution:
kind: flow
summary: Verify reasoning-only turns after a write do not auto-retry.
config:
requiredProvider: mock-openai
promptSnippet: Reasoning-only after write safety check
prompt: "Reasoning-only after write safety check: write reasoning-only-side-effect.txt, then answer with exactly SIDE-EFFECT-GUARD-OK."
retryNeedle: recorded reasoning but did not produce a user-visible answer
outputFile: reasoning-only-side-effect.txt
```
```yaml qa-flow
steps:
- name: keeps replay-unsafety explicit after a mutating write
actions:
- assert:
expr: "env.providerMode === 'mock-openai'"
message: this seeded scenario is mock-openai only
- call: waitForGatewayHealthy
args:
- ref: env
- 60000
- call: reset
- set: requestCountBefore
value:
expr: "env.mock ? (await fetchJson(`${env.mock.baseUrl}/debug/requests`)).length : 0"
- set: sessionKey
value:
expr: "`agent:qa:reasoning-only-write:${randomUUID().slice(0, 8)}`"
- call: startAgentRun
saveAs: started
args:
- ref: env
- sessionKey:
ref: sessionKey
message:
expr: config.prompt
timeoutMs:
expr: liveTurnTimeoutMs(env, 45000)
- set: waited
value:
expr: "await env.gateway.call('agent.wait', { runId: started.runId, timeoutMs: liveTurnTimeoutMs(env, 45000) }, { timeoutMs: liveTurnTimeoutMs(env, 50000) })"
- assert:
expr: "waited?.status === 'ok'"
message:
expr: "`agent.wait returned ${String(waited?.status ?? 'unknown')}: ${String(waited?.error ?? '')}`"
- call: fs.readFile
saveAs: sideEffect
args:
- expr: "path.join(env.gateway.workspaceDir, config.outputFile)"
- utf8
- assert:
expr: "sideEffect.includes('side effects already happened')"
message:
expr: "`side-effect file missing expected contents: ${sideEffect}`"
- if:
expr: "Boolean(env.mock)"
then:
- set: scenarioRequests
value:
expr: "(await fetchJson(`${env.mock.baseUrl}/debug/requests`)).slice(requestCountBefore)"
- assert:
expr: "scenarioRequests.some((request) => String(request.allInputText ?? '').includes(config.promptSnippet) && request.plannedToolName === 'write')"
message: expected mutating write request in mock trace
- assert:
expr: "!scenarioRequests.some((request) => String(request.allInputText ?? '').includes(config.retryNeedle))"
message: reasoning-only retry instruction should not be injected after a write
- assert:
expr: "scenarioRequests.filter((request) => String(request.allInputText ?? '').includes(config.promptSnippet)).length === 2"
message: expected exactly the write request plus the reasoning-only terminal request
detailsExpr: "env.mock ? `requests=${String(scenarioRequests?.length ?? 0)} sideEffect=${sideEffect.trim()}` : sideEffect"
```
@@ -0,0 +1,81 @@
# Reasoning-only recovery after replay-safe read
```yaml qa-scenario
id: reasoning-only-recovery-replay-safe-read
title: Reasoning-only recovery after replay-safe read
surface: runtime
objective: Verify a GPT-style reasoning-only turn after a replay-safe read auto-continues into a visible answer.
successCriteria:
- Scenario is mock-openai only so live lanes do not pick it up implicitly.
- The agent performs a replay-safe read before the reasoning-only turn.
- The runtime injects the visible-answer continuation instruction after the reasoning-only turn.
- The final visible reply contains the exact recovery marker.
docsRefs:
- docs/help/testing.md
codeRefs:
- extensions/qa-lab/src/mock-openai-server.ts
- src/agents/pi-embedded-runner/run/incomplete-turn.ts
execution:
kind: flow
summary: Verify reasoning-only OpenAI turns recover after a replay-safe read.
config:
requiredProvider: mock-openai
promptSnippet: Reasoning-only continuation QA check
prompt: "Reasoning-only continuation QA check: read QA_KICKOFF_TASK.md, then answer with exactly REASONING-RECOVERED-OK."
expectedReply: REASONING-RECOVERED-OK
retryNeedle: recorded reasoning but did not produce a user-visible answer
```
```yaml qa-flow
steps:
- name: retries a replay-safe read into a visible answer
actions:
- assert:
expr: "env.providerMode === 'mock-openai'"
message: this seeded scenario is mock-openai only
- call: waitForGatewayHealthy
args:
- ref: env
- 60000
- call: reset
- set: requestCountBefore
value:
expr: "env.mock ? (await fetchJson(`${env.mock.baseUrl}/debug/requests`)).length : 0"
- set: sessionKey
value:
expr: "`agent:qa:reasoning-only-recovery:${randomUUID().slice(0, 8)}`"
- call: runAgentPrompt
args:
- ref: env
- sessionKey:
ref: sessionKey
message:
expr: config.prompt
timeoutMs:
expr: liveTurnTimeoutMs(env, 45000)
- call: waitForOutboundMessage
saveAs: outbound
args:
- ref: state
- lambda:
params: [candidate]
expr: "candidate.conversation.id === 'qa-operator' && candidate.text.includes(config.expectedReply)"
- expr: liveTurnTimeoutMs(env, 30000)
- assert:
expr: "outbound.text.includes(config.expectedReply)"
message:
expr: "`missing recovery marker: ${outbound.text}`"
- if:
expr: "Boolean(env.mock)"
then:
- set: scenarioRequests
value:
expr: "(await fetchJson(`${env.mock.baseUrl}/debug/requests`)).slice(requestCountBefore)"
- assert:
expr: "scenarioRequests.some((request) => String(request.allInputText ?? '').includes(config.promptSnippet) && request.plannedToolName === 'read')"
message: expected replay-safe read request in mock trace
- assert:
expr: "scenarioRequests.some((request) => String(request.allInputText ?? '').includes(config.retryNeedle))"
message: expected reasoning-only retry instruction in mock trace
detailsExpr: "env.mock ? `${outbound.text}\\nrequests=${String(scenarioRequests?.length ?? 0)}` : outbound.text"
```
+22
View File
@@ -0,0 +1,22 @@
export type NpmPackBudgetResult = {
filename?: string;
unpackedSize?: number;
};
export declare const NPM_PACK_UNPACKED_SIZE_BUDGET_BYTES: number;
export declare function formatMiB(bytes: number): string;
export declare function formatPackUnpackedSizeBudgetError(params: {
budgetBytes?: number;
label: string;
unpackedSize: number;
}): string;
export declare function collectPackUnpackedSizeErrors(
results: Iterable<NpmPackBudgetResult>,
options?: {
budgetBytes?: number;
missingDataMessage?: string;
},
): string[];
+55
View File
@@ -0,0 +1,55 @@
// 2026.3.12 ballooned to ~213.6 MiB unpacked and correlated with low-memory
// startup/doctor OOM reports. 2026.4.12 intentionally stages Matrix runtime
// dependencies, including crypto wasm, so packaged installs do not miss Docker
// and gateway runtime dependencies. Keep the budget below the 2026.3.12 bloat
// level while allowing that mirrored runtime surface.
export const NPM_PACK_UNPACKED_SIZE_BUDGET_BYTES = 202 * 1024 * 1024;
export function formatMiB(bytes) {
return `${(bytes / (1024 * 1024)).toFixed(1)} MiB`;
}
function resolvePackResultLabel(entry, index) {
return entry.filename?.trim() || `pack result #${index + 1}`;
}
export function formatPackUnpackedSizeBudgetError(params) {
const budgetBytes = params.budgetBytes ?? NPM_PACK_UNPACKED_SIZE_BUDGET_BYTES;
return [
`${params.label} unpackedSize ${params.unpackedSize} bytes (${formatMiB(params.unpackedSize)}) exceeds budget ${budgetBytes} bytes (${formatMiB(budgetBytes)}).`,
"Investigate duplicate channel shims, copied extension trees, or other accidental pack bloat before release.",
].join(" ");
}
export function collectPackUnpackedSizeErrors(results, options = {}) {
const entries = Array.from(results);
const errors = [];
const budgetBytes = options.budgetBytes ?? NPM_PACK_UNPACKED_SIZE_BUDGET_BYTES;
let checkedCount = 0;
for (const [index, entry] of entries.entries()) {
if (typeof entry.unpackedSize !== "number" || !Number.isFinite(entry.unpackedSize)) {
continue;
}
checkedCount += 1;
if (entry.unpackedSize <= budgetBytes) {
continue;
}
errors.push(
formatPackUnpackedSizeBudgetError({
budgetBytes,
label: resolvePackResultLabel(entry, index),
unpackedSize: entry.unpackedSize,
}),
);
}
if (entries.length > 0 && checkedCount === 0) {
errors.push(
options.missingDataMessage ??
"npm pack --dry-run produced no unpackedSize data; pack size budget was not verified.",
);
}
return errors;
}
+3
View File
@@ -65,6 +65,9 @@ export const pluginSdkDocMetadata = {
"command-status": {
category: "channel",
},
"command-status-runtime": {
category: "runtime",
},
"secret-input": {
category: "channel",
},
+1
View File
@@ -108,6 +108,7 @@
"command-auth",
"command-auth-native",
"command-status",
"command-status-runtime",
"command-detection",
"command-surface",
"collection-runtime",
+3 -50
View File
@@ -17,6 +17,7 @@ import {
collectBundledPluginRuntimeDependencySpecs,
collectRootDistBundledRuntimeMirrors,
} from "./lib/bundled-plugin-root-runtime-mirrors.mjs";
import { collectPackUnpackedSizeErrors as collectNpmPackUnpackedSizeErrors } from "./lib/npm-pack-budget.mjs";
import { listPluginSdkDistArtifacts } from "./lib/plugin-sdk-entries.mjs";
import { listStaticExtensionAssetOutputs } from "./runtime-postbuild.mjs";
import { sparkleBuildFloorsFromShortVersion, type SparkleBuildFloors } from "./sparkle-build.ts";
@@ -53,12 +54,6 @@ const forbiddenPrefixes = [
"dist/plugin-sdk/.tsbuildinfo",
"docs/.generated/",
];
// 2026.3.12 ballooned to ~213.6 MiB unpacked and correlated with low-memory
// startup/doctor OOM reports. 2026.4.12 intentionally stages Matrix runtime
// dependencies, including crypto wasm, so packaged installs do not miss Docker
// and gateway runtime dependencies. Keep the budget below the 2026.3.12 bloat
// level while allowing that mirrored runtime surface.
const npmPackUnpackedSizeBudgetBytes = 202 * 1024 * 1024;
const appcastPath = resolve("appcast.xml");
const laneBuildMin = 1_000_000_000;
const laneFloorAdoptionDateKey = 20260227;
@@ -269,49 +264,7 @@ export function collectForbiddenPackPaths(paths: Iterable<string>): string[] {
.toSorted((left, right) => left.localeCompare(right));
}
function formatMiB(bytes: number): string {
return `${(bytes / (1024 * 1024)).toFixed(1)} MiB`;
}
function resolvePackResultLabel(entry: PackResult, index: number): string {
return entry.filename?.trim() || `pack result #${index + 1}`;
}
function formatPackUnpackedSizeBudgetError(params: {
label: string;
unpackedSize: number;
}): string {
return [
`${params.label} unpackedSize ${params.unpackedSize} bytes (${formatMiB(params.unpackedSize)}) exceeds budget ${npmPackUnpackedSizeBudgetBytes} bytes (${formatMiB(npmPackUnpackedSizeBudgetBytes)}).`,
"Investigate duplicate channel shims, copied extension trees, or other accidental pack bloat before release.",
].join(" ");
}
export function collectPackUnpackedSizeErrors(results: Iterable<PackResult>): string[] {
const entries = Array.from(results);
const errors: string[] = [];
let checkedCount = 0;
for (const [index, entry] of entries.entries()) {
if (typeof entry.unpackedSize !== "number" || !Number.isFinite(entry.unpackedSize)) {
continue;
}
checkedCount += 1;
if (entry.unpackedSize <= npmPackUnpackedSizeBudgetBytes) {
continue;
}
const label = resolvePackResultLabel(entry, index);
errors.push(formatPackUnpackedSizeBudgetError({ label, unpackedSize: entry.unpackedSize }));
}
if (entries.length > 0 && checkedCount === 0) {
errors.push(
"npm pack --dry-run produced no unpackedSize data; pack size budget was not verified.",
);
}
return errors;
}
export { collectPackUnpackedSizeErrors } from "./lib/npm-pack-budget.mjs";
function extractTag(item: string, tag: string): string | null {
const escapedTag = tag.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
@@ -486,7 +439,7 @@ async function main() {
})
.toSorted((left, right) => left.localeCompare(right));
const forbidden = collectForbiddenPackPaths(paths);
const sizeErrors = collectPackUnpackedSizeErrors(results);
const sizeErrors = collectNpmPackUnpackedSizeErrors(results);
if (missing.length > 0 || forbidden.length > 0 || sizeErrors.length > 0) {
if (missing.length > 0) {
+34
View File
@@ -58,6 +58,39 @@ console.log(
' "$label" "$pack_json_file"
}
assert_pack_unpacked_size_budget() {
local label="$1"
local pack_json_file="$2"
node --input-type=module - "$label" "$pack_json_file" <<'NODE'
import { readFileSync } from "node:fs";
import { collectPackUnpackedSizeErrors } from "./scripts/lib/npm-pack-budget.mjs";
const label = process.argv[2];
const packJsonFile = process.argv[3];
const raw = readFileSync(packJsonFile, "utf8") || "[]";
const parsed = JSON.parse(raw);
const budgetOverride = process.env.OPENCLAW_INSTALL_SMOKE_PACK_UNPACKED_BUDGET_BYTES;
const budgetBytes = budgetOverride ? Number(budgetOverride) : undefined;
if (budgetOverride && !Number.isFinite(budgetBytes)) {
throw new Error(
`OPENCLAW_INSTALL_SMOKE_PACK_UNPACKED_BUDGET_BYTES must be numeric, got ${JSON.stringify(
budgetOverride,
)}`,
);
}
const errors = collectPackUnpackedSizeErrors(parsed, {
budgetBytes,
missingDataMessage: `${label} npm pack output did not include unpackedSize; install smoke cannot verify pack budget.`,
});
for (const error of errors) {
console.error(`ERROR: ${error}`);
}
if (errors.length > 0) {
process.exit(1);
}
NODE
}
print_pack_delta_audit() {
local baseline_pack_json_file="$1"
local update_pack_json_file="$2"
@@ -191,6 +224,7 @@ process.stdout.write(last.filename);
' "$pack_json_file"
)"
print_pack_audit "update" "$pack_json_file"
assert_pack_unpacked_size_budget "update" "$pack_json_file"
packed_update_version="$(
node -e '
const raw = require("node:fs").readFileSync(process.argv[1], "utf8") || "[]";
+1 -2
View File
@@ -42,8 +42,7 @@ function getLog(): ReturnType<typeof createSubsystemLogger> {
/** Strip null bytes from paths to prevent ENOTDIR errors. */
function stripNullBytes(s: string): string {
// eslint-disable-next-line no-control-regex
return s.replace(/\0/g, "");
return s.replaceAll("\0", "");
}
export function listAgentEntries(cfg: OpenClawConfig): AgentEntry[] {
+85
View File
@@ -4,6 +4,8 @@ import {
CONTEXT_WINDOW_HARD_MIN_TOKENS,
CONTEXT_WINDOW_WARN_BELOW_TOKENS,
evaluateContextWindowGuard,
formatContextWindowBlockMessage,
formatContextWindowWarningMessage,
resolveContextWindowInfo,
} from "./context-window-guard.js";
@@ -222,4 +224,87 @@ describe("context-window-guard", () => {
expect(CONTEXT_WINDOW_HARD_MIN_TOKENS).toBe(16_000);
expect(CONTEXT_WINDOW_WARN_BELOW_TOKENS).toBe(32_000);
});
it("adds a local-model hint to warning messages for localhost endpoints", () => {
const guard = evaluateContextWindowGuard({
info: { tokens: 24_000, source: "model" },
});
expect(
formatContextWindowWarningMessage({
provider: "lmstudio",
modelId: "qwen3",
guard,
runtimeBaseUrl: "http://127.0.0.1:1234/v1",
}),
).toContain("local/self-hosted runs work best at 32000+ tokens");
});
it("does not add local-model hints for generic custom endpoints", () => {
const guard = evaluateContextWindowGuard({
info: { tokens: 24_000, source: "model" },
});
expect(
formatContextWindowWarningMessage({
provider: "custom",
modelId: "hosted-proxy-model",
guard,
runtimeBaseUrl: "https://models.example.com/v1",
}),
).toBe("low context window: custom/hosted-proxy-model ctx=24000 (warn<32000) source=model");
});
it("adds a local-model hint to block messages for localhost endpoints", () => {
const guard = evaluateContextWindowGuard({
info: { tokens: 8_000, source: "model" },
});
expect(
formatContextWindowBlockMessage({
guard,
runtimeBaseUrl: "http://127.0.0.1:11434/v1",
}),
).toContain("This looks like a local model endpoint.");
});
it("points config-backed block remediation at agents.defaults.contextTokens", () => {
const guard = evaluateContextWindowGuard({
info: { tokens: 8_000, source: "agentContextTokens" },
});
const message = formatContextWindowBlockMessage({
guard,
runtimeBaseUrl: "http://127.0.0.1:11434/v1",
});
expect(message).toContain("OpenClaw is capped by agents.defaults.contextTokens.");
expect(message).not.toContain("choose a larger model");
});
it("points model config block remediation at contextWindow/contextTokens", () => {
const guard = evaluateContextWindowGuard({
info: { tokens: 8_000, source: "modelsConfig" },
});
expect(
formatContextWindowBlockMessage({
guard,
runtimeBaseUrl: "http://127.0.0.1:11434/v1",
}),
).toContain("Raise contextWindow/contextTokens or choose a larger model.");
});
it("keeps block messages concise for public providers", () => {
const guard = evaluateContextWindowGuard({
info: { tokens: 8_000, source: "model" },
});
expect(
formatContextWindowBlockMessage({
guard,
runtimeBaseUrl: "https://api.openai.com/v1",
}),
).toBe(`Model context window too small (8000 tokens; source=model). Minimum is 16000.`);
});
});
+72
View File
@@ -1,4 +1,5 @@
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { resolveProviderEndpoint } from "./provider-attribution.js";
import { findNormalizedProviderValue } from "./provider-id.js";
export const CONTEXT_WINDOW_HARD_MIN_TOKENS = 16_000;
@@ -61,6 +62,77 @@ export type ContextWindowGuardResult = ContextWindowInfo & {
shouldBlock: boolean;
};
export type ContextWindowGuardHint = {
endpointClass: ReturnType<typeof resolveProviderEndpoint>["endpointClass"];
likelySelfHosted: boolean;
};
export function resolveContextWindowGuardHint(params: {
runtimeBaseUrl?: string | null;
}): ContextWindowGuardHint {
const endpoint = resolveProviderEndpoint(params.runtimeBaseUrl ?? undefined);
return {
endpointClass: endpoint.endpointClass,
likelySelfHosted: endpoint.endpointClass === "local",
};
}
export function formatContextWindowWarningMessage(params: {
provider: string;
modelId: string;
guard: ContextWindowGuardResult;
runtimeBaseUrl?: string | null;
}): string {
const base = `low context window: ${params.provider}/${params.modelId} ctx=${params.guard.tokens} (warn<${CONTEXT_WINDOW_WARN_BELOW_TOKENS}) source=${params.guard.source}`;
const hint = resolveContextWindowGuardHint({ runtimeBaseUrl: params.runtimeBaseUrl });
if (!hint.likelySelfHosted) {
return base;
}
if (params.guard.source === "agentContextTokens") {
return (
`${base}; OpenClaw is capped by agents.defaults.contextTokens, so raise that cap ` +
`if you want to use more of the model context window`
);
}
if (params.guard.source === "modelsConfig") {
return (
`${base}; OpenClaw is using the configured model context limit for this model, ` +
`so raise contextWindow/contextTokens if it is set too low`
);
}
return (
`${base}; local/self-hosted runs work best at ` +
`${CONTEXT_WINDOW_WARN_BELOW_TOKENS}+ tokens and may show weaker tool use or more compaction until the server/model context limit is raised`
);
}
export function formatContextWindowBlockMessage(params: {
guard: ContextWindowGuardResult;
runtimeBaseUrl?: string | null;
}): string {
const base =
`Model context window too small (${params.guard.tokens} tokens; ` +
`source=${params.guard.source}). Minimum is ${CONTEXT_WINDOW_HARD_MIN_TOKENS}.`;
const hint = resolveContextWindowGuardHint({ runtimeBaseUrl: params.runtimeBaseUrl });
if (!hint.likelySelfHosted) {
return base;
}
if (params.guard.source === "agentContextTokens") {
return `${base} OpenClaw is capped by agents.defaults.contextTokens. Raise that cap.`;
}
if (params.guard.source === "modelsConfig") {
return (
`${base} OpenClaw is using the configured model context limit for this model. ` +
`Raise contextWindow/contextTokens or choose a larger model.`
);
}
return (
`${base} This looks like a local model endpoint. ` +
`Raise the server/model context limit or choose a larger model. ` +
`OpenClaw local/self-hosted runs work best at ${CONTEXT_WINDOW_WARN_BELOW_TOKENS}+ tokens.`
);
}
export function evaluateContextWindowGuard(params: {
info: ContextWindowInfo;
warnBelowTokens?: number;
+10
View File
@@ -21,6 +21,16 @@ describe("resolveEffectiveExecutionContract", () => {
).toBe("strict-agentic");
});
it("auto-activates on the mock-openai qa lane", () => {
expect(
resolveEffectiveExecutionContract({
config: emptyConfig,
provider: "mock-openai",
modelId: "mock-openai/gpt-5.4",
}),
).toBe("strict-agentic");
});
it("auto-activates on gpt-5o and variants without a separator", () => {
for (const modelId of ["gpt-5", "gpt-5o", "gpt-5o-mini"]) {
expect(
+4 -2
View File
@@ -39,14 +39,16 @@ const STRICT_AGENTIC_MODEL_ID_PATTERN = /^gpt-5(?:[.o-]|$)/i;
* Supported provider + model combinations where strict-agentic is the intended
* runtime contract. Kept as a narrow helper so both the execution-contract
* resolver and the `update_plan` auto-enable gate converge on the same
* definition of "GPT-5-family openai/openai-codex run".
* definition of "GPT-5-family openai/openai-codex run". The embedded
* `mock-openai` QA lane intentionally piggybacks on that contract so repo QA
* can exercise the same incomplete-turn recovery rules end to end.
*/
export function isStrictAgenticSupportedProviderModel(params: {
provider?: string | null;
modelId?: string | null;
}): boolean {
const provider = normalizeLowercaseStringOrEmpty(params.provider ?? "");
if (provider !== "openai" && provider !== "openai-codex") {
if (provider !== "openai" && provider !== "openai-codex" && provider !== "mock-openai") {
return false;
}
const modelId = typeof params.modelId === "string" ? params.modelId : "";
+3 -1
View File
@@ -401,7 +401,8 @@ describe("isModernModelRef", () => {
provider === "openai" &&
["gpt-5.4", "gpt-5.4-pro", "gpt-5.4-mini", "gpt-5.4-nano"].includes(context.modelId)
? true
: provider === "openai-codex" && ["gpt-5.4", "gpt-5.4-mini"].includes(context.modelId)
: provider === "openai-codex" &&
["gpt-5.4", "gpt-5.4-pro", "gpt-5.4-mini"].includes(context.modelId)
? true
: provider === "opencode" && ["claude-opus-4-6", "gemini-3-pro"].includes(context.modelId)
? true
@@ -415,6 +416,7 @@ describe("isModernModelRef", () => {
expect(isModernModelRef({ provider: "openai", id: "gpt-5.4-mini" })).toBe(true);
expect(isModernModelRef({ provider: "openai", id: "gpt-5.4-nano" })).toBe(true);
expect(isModernModelRef({ provider: "openai-codex", id: "gpt-5.4" })).toBe(true);
expect(isModernModelRef({ provider: "openai-codex", id: "gpt-5.4-pro" })).toBe(true);
expect(isModernModelRef({ provider: "openai-codex", id: "gpt-5.4-mini" })).toBe(true);
expect(isModernModelRef({ provider: "opencode", id: "claude-opus-4-6" })).toBe(true);
expect(isModernModelRef({ provider: "opencode", id: "gemini-3-pro" })).toBe(true);
+13
View File
@@ -11,6 +11,10 @@ vi.mock("./auth-profiles/store.js", () => ({
loadAuthProfileStoreForRuntime: vi.fn(),
}));
vi.mock("./auth-profiles/source-check.js", () => ({
hasAnyAuthProfileStoreSource: vi.fn(),
}));
vi.mock("./auth-profiles/usage.js", () => ({
getSoonestCooldownExpiry: vi.fn(),
isProfileInCooldown: vi.fn(),
@@ -26,6 +30,7 @@ vi.mock("./auth-profiles/source-check.js", () => ({
}));
type AuthProfilesStoreModule = typeof import("./auth-profiles/store.js");
type AuthProfilesSourceCheckModule = typeof import("./auth-profiles/source-check.js");
type AuthProfilesUsageModule = typeof import("./auth-profiles/usage.js");
type AuthProfilesOrderModule = typeof import("./auth-profiles/order.js");
type ModelFallbackModule = typeof import("./model-fallback.js");
@@ -34,6 +39,9 @@ type LoggerModule = typeof import("../logging/logger.js");
let mockedEnsureAuthProfileStore: ReturnType<
typeof vi.mocked<AuthProfilesStoreModule["ensureAuthProfileStore"]>
>;
let mockedHasAnyAuthProfileStoreSource: ReturnType<
typeof vi.mocked<AuthProfilesSourceCheckModule["hasAnyAuthProfileStoreSource"]>
>;
let mockedGetSoonestCooldownExpiry: ReturnType<
typeof vi.mocked<AuthProfilesUsageModule["getSoonestCooldownExpiry"]>
>;
@@ -57,11 +65,15 @@ let unregisterLogTransport: (() => void) | undefined;
async function loadModelFallbackProbeModules() {
const authProfilesStoreModule = await import("./auth-profiles/store.js");
const authProfilesSourceCheckModule = await import("./auth-profiles/source-check.js");
const authProfilesUsageModule = await import("./auth-profiles/usage.js");
const authProfilesOrderModule = await import("./auth-profiles/order.js");
const loggerModule = await import("../logging/logger.js");
const modelFallbackModule = await import("./model-fallback.js");
mockedEnsureAuthProfileStore = vi.mocked(authProfilesStoreModule.ensureAuthProfileStore);
mockedHasAnyAuthProfileStoreSource = vi.mocked(
authProfilesSourceCheckModule.hasAnyAuthProfileStoreSource,
);
mockedGetSoonestCooldownExpiry = vi.mocked(authProfilesUsageModule.getSoonestCooldownExpiry);
mockedIsProfileInCooldown = vi.mocked(authProfilesUsageModule.isProfileInCooldown);
mockedResolveProfilesUnavailableReason = vi.mocked(
@@ -187,6 +199,7 @@ describe("runWithModelFallback probe logic", () => {
version: 1,
profiles: {},
};
mockedHasAnyAuthProfileStoreSource.mockReturnValue(true);
mockedEnsureAuthProfileStore.mockReturnValue(fakeStore);
// Default: resolveAuthProfileOrder returns profiles only for "openai" provider
@@ -17,12 +17,16 @@ function buildModel(id: string): NonNullable<ProviderConfig["models"]>[number] {
};
}
function buildProvider(modelIds: string[]): ProviderConfig {
function buildProvider(
modelIds: string[],
overrides: Partial<ProviderConfig> = {},
): ProviderConfig {
return {
baseUrl: "https://example.invalid/v1",
api: "openai-completions",
apiKey: "EXAMPLE_KEY", // pragma: allowlist secret
models: modelIds.map((id) => buildModel(id)),
...overrides,
};
}
@@ -69,7 +73,9 @@ describe("google-vertex provider normalization", () => {
it("normalizes gemini flash-lite IDs for google-vertex providers", () => {
const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-"));
const providers = {
"google-vertex": buildProvider(["gemini-3.1-flash-lite", "gemini-3-flash-preview"]),
"google-vertex": buildProvider(["gemini-3.1-flash-lite", "gemini-3-flash-preview"], {
api: undefined,
}),
openai: buildProvider(["gpt-5"]),
};
@@ -86,7 +92,9 @@ describe("google-vertex provider normalization", () => {
it("returns original providers object when no google-vertex IDs need normalization", () => {
const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-"));
const providers = {
"google-vertex": buildProvider(["gemini-3.1-flash-lite-preview", "gemini-3-flash-preview"]),
"google-vertex": buildProvider(["gemini-3.1-flash-lite-preview", "gemini-3-flash-preview"], {
api: undefined,
}),
};
const normalized = normalizeProviders({ providers, agentDir });
@@ -0,0 +1,34 @@
import { describe, expect, it } from "vitest";
import { resolveOpenAICompletionsCompatDefaults } from "./openai-completions-compat.js";
describe("resolveOpenAICompletionsCompatDefaults", () => {
it("enables streaming usage for local ollama OpenAI-compat endpoints", () => {
expect(
resolveOpenAICompletionsCompatDefaults({
provider: "ollama",
endpointClass: "local",
knownProviderFamily: "ollama",
}).supportsUsageInStreaming,
).toBe(true);
});
it("keeps streaming usage enabled for custom ollama OpenAI-compat endpoints", () => {
expect(
resolveOpenAICompletionsCompatDefaults({
provider: "ollama",
endpointClass: "custom",
knownProviderFamily: "ollama",
}).supportsUsageInStreaming,
).toBe(true);
});
it("does not broaden streaming usage for generic custom providers", () => {
expect(
resolveOpenAICompletionsCompatDefaults({
provider: "custom-cpa",
endpointClass: "custom",
knownProviderFamily: "custom-cpa",
}).supportsUsageInStreaming,
).toBe(false);
});
});
+5 -2
View File
@@ -33,6 +33,7 @@ export function resolveOpenAICompletionsCompatDefaults(
input: OpenAICompletionsCompatDefaultsInput,
): OpenAICompletionsCompatDefaults {
const {
provider,
endpointClass,
knownProviderFamily,
supportsNativeStreamingUsageCompat = false,
@@ -64,7 +65,8 @@ export function resolveOpenAICompletionsCompatDefaults(
endpointClass === "chutes-native" ||
endpointClass === "mistral-public" ||
knownProviderFamily === "mistral" ||
(isDefaultRoute && isDefaultRouteProvider(input.provider, "chutes"));
(isDefaultRoute && isDefaultRouteProvider(provider, "chutes"));
const isOllamaCompatProvider = provider === "ollama";
return {
supportsStore:
@@ -76,7 +78,8 @@ export function resolveOpenAICompletionsCompatDefaults(
endpointClass !== "xai-native" &&
!usesExplicitProxyLikeEndpoint,
supportsUsageInStreaming:
!isNonStandard && (!usesConfiguredNonOpenAIEndpoint || supportsNativeStreamingUsageCompat),
isOllamaCompatProvider ||
(!isNonStandard && (!usesConfiguredNonOpenAIEndpoint || supportsNativeStreamingUsageCompat)),
maxTokensField: usesMaxTokens ? "max_tokens" : "max_completion_tokens",
thinkingFormat: isZai ? "zai" : isOpenRouterLike ? "openrouter" : "openai",
supportsStrictMode: !isZai && !usesConfiguredNonOpenAIEndpoint,
@@ -1196,6 +1196,33 @@ describe("openai transport stream", () => {
expect(params.stream_options).toMatchObject({ include_usage: true });
});
it("enables streaming usage compat for Ollama OpenAI-compat endpoints", () => {
const params = buildOpenAICompletionsParams(
{
id: "qwen2.5:7b",
name: "Qwen 2.5 7B",
api: "openai-completions",
provider: "ollama",
baseUrl: "http://127.0.0.1:11434/v1",
reasoning: true,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 32768,
maxTokens: 8192,
} satisfies Model<"openai-completions">,
{
systemPrompt: "system",
messages: [],
tools: [],
} as never,
undefined,
) as {
stream_options?: { include_usage?: boolean };
};
expect(params.stream_options).toMatchObject({ include_usage: true });
});
it("disables developer-role-only compat defaults for configured custom proxy completions providers", () => {
const params = buildOpenAICompletionsParams(
{
+143
View File
@@ -397,6 +397,149 @@ describe("gateway tool", () => {
);
});
it("rejects config.patch that enables dangerouslyDisableDeviceAuth", async () => {
const tool = requireGatewayTool();
await expect(
tool.execute("call-dangerous-device-auth", {
action: "config.patch",
raw: "{ gateway: { controlUi: { dangerouslyDisableDeviceAuth: true } } }",
}),
).rejects.toThrow("cannot enable dangerous config flags");
expect(callGatewayTool).not.toHaveBeenCalledWith(
"config.patch",
expect.any(Object),
expect.anything(),
);
});
it("rejects config.patch that enables allowUnsafeExternalContent on gmail hooks", async () => {
const tool = requireGatewayTool();
await expect(
tool.execute("call-dangerous-gmail", {
action: "config.patch",
raw: "{ hooks: { gmail: { allowUnsafeExternalContent: true } } }",
}),
).rejects.toThrow("cannot enable dangerous config flags");
expect(callGatewayTool).not.toHaveBeenCalledWith(
"config.patch",
expect.any(Object),
expect.anything(),
);
});
it("rejects config.patch that weakens applyPatch.workspaceOnly", async () => {
const tool = requireGatewayTool();
await expect(
tool.execute("call-dangerous-workspace", {
action: "config.patch",
raw: "{ tools: { exec: { applyPatch: { workspaceOnly: false } } } }",
}),
).rejects.toThrow("cannot enable dangerous config flags");
expect(callGatewayTool).not.toHaveBeenCalledWith(
"config.patch",
expect.any(Object),
expect.anything(),
);
});
it("rejects config.patch that enables allowInsecureAuth on control UI", async () => {
const tool = requireGatewayTool();
await expect(
tool.execute("call-dangerous-insecure-auth", {
action: "config.patch",
raw: "{ gateway: { controlUi: { allowInsecureAuth: true } } }",
}),
).rejects.toThrow("cannot enable dangerous config flags");
expect(callGatewayTool).not.toHaveBeenCalledWith(
"config.patch",
expect.any(Object),
expect.anything(),
);
});
it("rejects config.patch that enables dangerouslyAllowHostHeaderOriginFallback", async () => {
const tool = requireGatewayTool();
await expect(
tool.execute("call-dangerous-origin-fallback", {
action: "config.patch",
raw: "{ gateway: { controlUi: { dangerouslyAllowHostHeaderOriginFallback: true } } }",
}),
).rejects.toThrow("cannot enable dangerous config flags");
expect(callGatewayTool).not.toHaveBeenCalledWith(
"config.patch",
expect.any(Object),
expect.anything(),
);
});
it("allows config.patch that does not enable any dangerous flag", async () => {
const sessionKey = "agent:main:whatsapp:dm:+15555550123";
const tool = requireGatewayTool(sessionKey);
const raw = '{ channels: { telegram: { groups: { "*": { requireMention: false } } } } }';
await tool.execute("call-safe-patch", {
action: "config.patch",
raw,
});
expect(callGatewayTool).toHaveBeenCalledWith(
"config.patch",
expect.any(Object),
expect.objectContaining({ raw: raw.trim() }),
);
});
it("allows config.patch when a dangerous flag is already enabled and stays enabled", async () => {
vi.mocked(callGatewayTool).mockImplementationOnce(async (method: string) => {
if (method === "config.get") {
return {
hash: "hash-1",
config: {
tools: { exec: { ask: "on-miss", security: "allowlist" } },
hooks: { gmail: { allowUnsafeExternalContent: true } },
},
};
}
return { ok: true };
});
const sessionKey = "agent:main:whatsapp:dm:+15555550123";
const tool = requireGatewayTool(sessionKey);
const raw =
'{ hooks: { gmail: { allowUnsafeExternalContent: true } }, agents: { defaults: { workspace: "~/test" } } }';
await tool.execute("call-keep-dangerous", {
action: "config.patch",
raw,
});
expect(callGatewayTool).toHaveBeenCalledWith(
"config.patch",
expect.any(Object),
expect.objectContaining({ raw: raw.trim() }),
);
});
it("rejects config.apply that introduces a dangerous flag", async () => {
const tool = requireGatewayTool();
await expect(
tool.execute("call-dangerous-apply", {
action: "config.apply",
raw: '{ tools: { exec: { ask: "on-miss", security: "allowlist", applyPatch: { workspaceOnly: false } } } }',
}),
).rejects.toThrow("cannot enable dangerous config flags");
expect(callGatewayTool).not.toHaveBeenCalledWith(
"config.apply",
expect.any(Object),
expect.anything(),
);
});
it("passes update.run through gateway call", async () => {
const sessionKey = "agent:main:whatsapp:dm:+15555550123";
const tool = requireGatewayTool(sessionKey);
@@ -171,14 +171,16 @@ describe("buildContextEngineMaintenanceRuntimeContext", () => {
});
await Promise.resolve();
rewriteTranscriptEntriesInSessionFileMock.mockImplementationOnce(async (_params?: unknown) => {
events.push("rewrite");
return {
changed: true,
bytesFreed: 123,
rewrittenEntries: 2,
};
});
rewriteTranscriptEntriesInSessionFileMock.mockImplementationOnce(
async (_params?: unknown) => {
events.push("rewrite");
return {
changed: true,
bytesFreed: 123,
rewrittenEntries: 2,
};
},
);
const runtimeContext = buildContextEngineMaintenanceRuntimeContext({
sessionId: "session-rewrite-handoff",
@@ -836,19 +838,20 @@ describe("runContextEngineMaintenance", () => {
allowRewrite = resolve;
});
events.push("maintenance-before-rewrite");
await (params as { runtimeContext?: ContextEngineRuntimeContext }).runtimeContext
?.rewriteTranscriptEntries?.({
replacements: [
{
entryId: "entry-1",
message: castAgentMessage({
role: "assistant",
content: [{ type: "text", text: "done" }],
timestamp: 2,
}),
},
],
});
await (
params as { runtimeContext?: ContextEngineRuntimeContext }
).runtimeContext?.rewriteTranscriptEntries?.({
replacements: [
{
entryId: "entry-1",
message: castAgentMessage({
role: "assistant",
content: [{ type: "text", text: "done" }],
timestamp: 2,
}),
},
],
});
events.push("maintenance-after-rewrite");
return {
changed: false,
@@ -857,14 +860,16 @@ describe("runContextEngineMaintenance", () => {
};
});
rewriteTranscriptEntriesInSessionFileMock.mockImplementationOnce(async (_params?: unknown) => {
events.push("rewrite");
return {
changed: true,
bytesFreed: 123,
rewrittenEntries: 2,
};
});
rewriteTranscriptEntriesInSessionFileMock.mockImplementationOnce(
async (_params?: unknown) => {
events.push("rewrite");
return {
changed: true,
bytesFreed: 123,
rewrittenEntries: 2,
};
},
);
const backgroundEngine = {
info: {
@@ -182,18 +182,19 @@ function buildDynamicModel(
};
}
case "openai-codex": {
const isLegacyGpt54Alias = lower === "gpt-5.4-codex";
const template =
lower === "gpt-5.4"
? findTemplate(params, "openai-codex", ["gpt-5.4", "gpt-5.4"])
lower === "gpt-5.4" || isLegacyGpt54Alias || lower === "gpt-5.4-pro"
? findTemplate(params, "openai-codex", ["gpt-5.4", "gpt-5.3-codex", "gpt-5.2-codex"])
: lower === "gpt-5.4-mini"
? findTemplate(params, "openai-codex", [
"gpt-5.4",
"gpt-5.1-codex-mini",
"gpt-5.3-codex",
"gpt-5.4",
"gpt-5.2-codex",
])
: lower === "gpt-5.3-codex-spark"
? findTemplate(params, "openai-codex", ["gpt-5.4", "gpt-5.4"])
? findTemplate(params, "openai-codex", ["gpt-5.4", "gpt-5.3-codex", "gpt-5.2-codex"])
: findTemplate(params, "openai-codex", ["gpt-5.4"]);
const fallback = {
provider: "openai-codex",
@@ -205,7 +206,23 @@ function buildDynamicModel(
contextWindow: DEFAULT_CONTEXT_WINDOW,
maxTokens: DEFAULT_CONTEXT_WINDOW,
};
if (lower === "gpt-5.4") {
if (lower === "gpt-5.4" || isLegacyGpt54Alias) {
return cloneTemplate(
template,
"gpt-5.4",
{
provider: "openai-codex",
api: "openai-codex-responses",
baseUrl: OPENAI_CODEX_BASE_URL,
cost: { input: 2.5, output: 15, cacheRead: 0.25, cacheWrite: 0 },
contextWindow: 1_050_000,
contextTokens: 272_000,
maxTokens: 128_000,
},
fallback,
);
}
if (lower === "gpt-5.4-pro") {
return cloneTemplate(
template,
modelId,
@@ -213,7 +230,7 @@ function buildDynamicModel(
provider: "openai-codex",
api: "openai-codex-responses",
baseUrl: OPENAI_CODEX_BASE_URL,
cost: { input: 2.5, output: 15, cacheRead: 0.25, cacheWrite: 0 },
cost: { input: 30, output: 180, cacheRead: 0, cacheWrite: 0 },
contextWindow: 1_050_000,
contextTokens: 272_000,
maxTokens: 128_000,
+151
View File
@@ -43,6 +43,7 @@ import {
buildOpenAICodexForwardCompatExpectation,
makeModel,
mockDiscoveredModel,
OPENAI_CODEX_TEMPLATE_MODEL,
mockOpenAICodexTemplateModel,
resetMockDiscoverModels,
} from "./model.test-harness.js";
@@ -827,6 +828,156 @@ describe("resolveModel", () => {
expect(result.model).toMatchObject(buildOpenAICodexForwardCompatExpectation("gpt-5.4"));
});
it("upgrades stale exact openai-codex gpt-5.4 registry metadata via forward-compat", () => {
vi.mocked(discoverModels).mockReturnValue({
find: vi.fn((provider: string, modelId: string) => {
if (provider !== "openai-codex") {
return null;
}
if (modelId === "gpt-5.4") {
return {
...OPENAI_CODEX_TEMPLATE_MODEL,
id: "gpt-5.4",
name: "GPT-5.4",
contextWindow: 272000,
};
}
if (modelId === "gpt-5.3-codex") {
return {
...OPENAI_CODEX_TEMPLATE_MODEL,
id: "gpt-5.3-codex",
name: "GPT-5.3 Codex",
};
}
return null;
}),
} as unknown as ReturnType<typeof discoverModels>);
const result = resolveModelForTest("openai-codex", "gpt-5.4", "/tmp/agent");
expect(result.error).toBeUndefined();
expect(result.model).toMatchObject({
provider: "openai-codex",
id: "gpt-5.4",
contextWindow: 1_050_000,
maxTokens: 128000,
});
});
it("does not downgrade exact openai-codex gpt-5.3-codex registry metadata", () => {
vi.mocked(discoverModels).mockReturnValue({
find: vi.fn((provider: string, modelId: string) => {
if (provider !== "openai-codex") {
return null;
}
if (modelId === "gpt-5.3-codex") {
return {
...OPENAI_CODEX_TEMPLATE_MODEL,
id: "gpt-5.3-codex",
name: "GPT-5.3 Codex",
contextWindow: 272000,
};
}
return null;
}),
} as unknown as ReturnType<typeof discoverModels>);
const result = resolveModelForTest("openai-codex", "gpt-5.3-codex", "/tmp/agent");
expect(result.error).toBeUndefined();
expect(result.model).toMatchObject({
provider: "openai-codex",
id: "gpt-5.3-codex",
contextWindow: 272000,
maxTokens: 128000,
});
});
it("canonicalizes the legacy openai-codex gpt-5.4-codex alias at runtime", () => {
mockOpenAICodexTemplateModel(discoverModels);
const result = resolveModelForTest("openai-codex", "gpt-5.4-codex", "/tmp/agent");
expect(result.error).toBeUndefined();
expect(result.model).toMatchObject(buildOpenAICodexForwardCompatExpectation("gpt-5.4"));
expect(result.model?.id).toBe("gpt-5.4");
expect(result.model?.name).toBe("gpt-5.4");
});
it("applies canonical openai-codex overrides when resolving the gpt-5.4-codex alias", () => {
mockOpenAICodexTemplateModel(discoverModels);
const cfg = {
models: {
providers: {
"openai-codex": {
baseUrl: "https://proxy.example.com/backend-api",
api: "openai-codex-responses",
models: [
{
...makeModel("gpt-5.4"),
contextWindow: 123456,
contextTokens: 65432,
maxTokens: 7777,
reasoning: false,
},
],
},
},
},
} as unknown as OpenClawConfig;
const result = resolveModelForTest("openai-codex", "gpt-5.4-codex", "/tmp/agent", cfg);
expect(result.error).toBeUndefined();
expect(result.model).toMatchObject({
provider: "openai-codex",
id: "gpt-5.4",
api: "openai-codex-responses",
baseUrl: "https://proxy.example.com/backend-api",
contextWindow: 123456,
contextTokens: 65432,
maxTokens: 7777,
reasoning: false,
});
});
it("prefers alias-specific overrides over canonical ones for gpt-5.4-codex", () => {
mockOpenAICodexTemplateModel(discoverModels);
const cfg = {
models: {
providers: {
"openai-codex": {
api: "openai-codex-responses",
models: [
{
...makeModel("gpt-5.4"),
contextWindow: 222222,
maxTokens: 22222,
},
{
...makeModel("gpt-5.4-codex"),
contextWindow: 111111,
maxTokens: 11111,
},
],
},
},
},
} as unknown as OpenClawConfig;
const result = resolveModelForTest("openai-codex", "gpt-5.4-codex", "/tmp/agent", cfg);
expect(result.error).toBeUndefined();
expect(result.model).toMatchObject({
provider: "openai-codex",
id: "gpt-5.4",
contextWindow: 111111,
maxTokens: 11111,
});
});
it("builds an openai-codex fallback for gpt-5.4-mini", () => {
mockOpenAICodexTemplateModel(discoverModels);
+29 -4
View File
@@ -96,6 +96,24 @@ function resolveRuntimeHooks(params?: {
return params?.runtimeHooks ?? DEFAULT_PROVIDER_RUNTIME_HOOKS;
}
function canonicalizeLegacyResolvedModel(params: {
provider: string;
model: Model<Api>;
}): Model<Api> {
if (
normalizeProviderId(params.provider) !== "openai-codex" ||
params.model.id.trim().toLowerCase() !== "gpt-5.4-codex"
) {
return params.model;
}
return {
...params.model,
id: "gpt-5.4",
name:
params.model.name.trim().toLowerCase() === "gpt-5.4-codex" ? "gpt-5.4" : params.model.name,
};
}
function applyResolvedTransportFallback(params: {
provider: string;
cfg?: OpenClawConfig;
@@ -184,10 +202,13 @@ function normalizeResolvedModel(params: {
runtimeHooks,
model: compatNormalized ?? pluginNormalized ?? normalizedInputModel,
});
return normalizeResolvedProviderModel({
return canonicalizeLegacyResolvedModel({
provider: params.provider,
model:
fallbackTransportNormalized ?? compatNormalized ?? pluginNormalized ?? normalizedInputModel,
model: normalizeResolvedProviderModel({
provider: params.provider,
model:
fallbackTransportNormalized ?? compatNormalized ?? pluginNormalized ?? normalizedInputModel,
}),
});
}
@@ -270,7 +291,11 @@ function applyConfiguredProviderOverrides(params: {
headers: sanitizeModelHeaders(discoveredModel.headers, { stripSecretRefMarkers: true }),
};
}
const configuredModel = providerConfig.models?.find((candidate) => candidate.id === modelId);
const configuredModel =
providerConfig.models?.find((candidate) => candidate.id === modelId) ??
(discoveredModel.id !== modelId
? providerConfig.models?.find((candidate) => candidate.id === discoveredModel.id)
: undefined);
const discoveredHeaders = sanitizeModelHeaders(discoveredModel.headers, {
stripSecretRefMarkers: true,
});
@@ -169,6 +169,14 @@ export const mockedResolveContextWindowInfo = vi.fn(() => ({
tokens: 200000,
source: "model",
}));
export const mockedFormatContextWindowWarningMessage = vi.fn(
(params: { provider: string; modelId: string; guard: { tokens: number; source: string } }) =>
`low context window: ${params.provider}/${params.modelId} ctx=${params.guard.tokens} source=${params.guard.source}`,
);
export const mockedFormatContextWindowBlockMessage = vi.fn(
(params: { guard: { tokens: number; source: string } }) =>
`Model context window too small (${params.guard.tokens} tokens; source=${params.guard.source}). Minimum is 1000.`,
);
export const mockedGetApiKeyForModel = vi.fn(
async ({ profileId }: { profileId?: string } = {}) => ({
apiKey: "test-key",
@@ -300,6 +308,16 @@ export function resetRunOverflowCompactionHarnessMocks(): void {
tokens: 200000,
source: "model",
});
mockedFormatContextWindowWarningMessage.mockReset();
mockedFormatContextWindowWarningMessage.mockImplementation(
(params: { provider: string; modelId: string; guard: { tokens: number; source: string } }) =>
`low context window: ${params.provider}/${params.modelId} ctx=${params.guard.tokens} source=${params.guard.source}`,
);
mockedFormatContextWindowBlockMessage.mockReset();
mockedFormatContextWindowBlockMessage.mockImplementation(
(params: { guard: { tokens: number; source: string } }) =>
`Model context window too small (${params.guard.tokens} tokens; source=${params.guard.source}). Minimum is 1000.`,
);
mockedGetApiKeyForModel.mockReset();
mockedGetApiKeyForModel.mockImplementation(
async ({ profileId }: { profileId?: string } = {}) => ({
@@ -443,6 +461,8 @@ export async function loadRunOverflowCompactionHarness(): Promise<{
CONTEXT_WINDOW_HARD_MIN_TOKENS: 1000,
CONTEXT_WINDOW_WARN_BELOW_TOKENS: 5000,
evaluateContextWindowGuard: mockedEvaluateContextWindowGuard,
formatContextWindowBlockMessage: mockedFormatContextWindowBlockMessage,
formatContextWindowWarningMessage: mockedFormatContextWindowWarningMessage,
resolveContextWindowInfo: mockedResolveContextWindowInfo,
}));

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