diff --git a/.github/actions/setup-node-env/action.yml b/.github/actions/setup-node-env/action.yml
index c46387517e4..5ea0373ff76 100644
--- a/.github/actions/setup-node-env/action.yml
+++ b/.github/actions/setup-node-env/action.yml
@@ -1,12 +1,16 @@
name: Setup Node environment
description: >
- Initialize submodules with retry, install Node 22, pnpm, optionally Bun,
+ Initialize submodules with retry, install Node 24 by default, pnpm, optionally Bun,
and optionally run pnpm install. Requires actions/checkout to run first.
inputs:
node-version:
description: Node.js version to install.
required: false
- default: "22.x"
+ default: "24.x"
+ cache-key-suffix:
+ description: Suffix appended to the pnpm store cache key.
+ required: false
+ default: "node24"
pnpm-version:
description: pnpm version for corepack.
required: false
@@ -16,7 +20,7 @@ inputs:
required: false
default: "true"
use-sticky-disk:
- description: Use Blacksmith sticky disks for pnpm store caching.
+ description: Request Blacksmith sticky-disk pnpm caching on trusted runs; pull_request runs fall back to actions/cache.
required: false
default: "false"
install-deps:
@@ -54,7 +58,7 @@ runs:
uses: ./.github/actions/setup-pnpm-store-cache
with:
pnpm-version: ${{ inputs.pnpm-version }}
- cache-key-suffix: "node22"
+ cache-key-suffix: ${{ inputs.cache-key-suffix }}
use-sticky-disk: ${{ inputs.use-sticky-disk }}
- name: Setup Bun
diff --git a/.github/actions/setup-pnpm-store-cache/action.yml b/.github/actions/setup-pnpm-store-cache/action.yml
index e1e5a34abda..249544d49ac 100644
--- a/.github/actions/setup-pnpm-store-cache/action.yml
+++ b/.github/actions/setup-pnpm-store-cache/action.yml
@@ -8,9 +8,9 @@ inputs:
cache-key-suffix:
description: Suffix appended to the cache key.
required: false
- default: "node22"
+ default: "node24"
use-sticky-disk:
- description: Use Blacksmith sticky disks instead of actions/cache for pnpm store.
+ description: Use Blacksmith sticky disks instead of actions/cache for pnpm store on trusted runs; pull_request runs fall back to actions/cache.
required: false
default: "false"
use-restore-keys:
@@ -18,7 +18,7 @@ inputs:
required: false
default: "true"
use-actions-cache:
- description: Whether to restore/save pnpm store with actions/cache.
+ description: Whether to restore/save pnpm store with actions/cache, including pull_request fallback when sticky disks are disabled.
required: false
default: "true"
runs:
@@ -51,21 +51,23 @@ runs:
run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
- name: Mount pnpm store sticky disk
- if: inputs.use-sticky-disk == 'true'
+ # Keep persistent sticky-disk state off untrusted PR runs.
+ if: inputs.use-sticky-disk == 'true' && github.event_name != 'pull_request'
uses: useblacksmith/stickydisk@v1
with:
- key: ${{ github.repository }}-pnpm-store-${{ runner.os }}-${{ inputs.cache-key-suffix }}
+ key: ${{ github.repository }}-pnpm-store-${{ runner.os }}-${{ github.ref_name }}-${{ inputs.cache-key-suffix }}-${{ hashFiles('pnpm-lock.yaml') }}
path: ${{ steps.pnpm-store.outputs.path }}
- name: Restore pnpm store cache (exact key only)
- if: inputs.use-actions-cache == 'true' && inputs.use-sticky-disk != 'true' && inputs.use-restore-keys != 'true'
+ # PRs that request sticky disks still need a safe cache restore path.
+ if: inputs.use-actions-cache == 'true' && (inputs.use-sticky-disk != 'true' || github.event_name == 'pull_request') && inputs.use-restore-keys != 'true'
uses: actions/cache@v4
with:
path: ${{ steps.pnpm-store.outputs.path }}
key: ${{ runner.os }}-pnpm-store-${{ inputs.cache-key-suffix }}-${{ hashFiles('pnpm-lock.yaml') }}
- name: Restore pnpm store cache (with fallback keys)
- if: inputs.use-actions-cache == 'true' && inputs.use-sticky-disk != 'true' && inputs.use-restore-keys == 'true'
+ if: inputs.use-actions-cache == 'true' && (inputs.use-sticky-disk != 'true' || github.event_name == 'pull_request') && inputs.use-restore-keys == 'true'
uses: actions/cache@v4
with:
path: ${{ steps.pnpm-store.outputs.path }}
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 2562d84d223..9038096a488 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -233,6 +233,40 @@ jobs:
- name: Check docs
run: pnpm check:docs
+ compat-node22:
+ name: "compat-node22"
+ needs: [docs-scope, changed-scope]
+ if: needs.docs-scope.outputs.docs_only != 'true' && (github.event_name == 'push' || needs.changed-scope.outputs.run_node == 'true')
+ runs-on: blacksmith-16vcpu-ubuntu-2404
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+ with:
+ submodules: false
+
+ - name: Setup Node 22 compatibility environment
+ uses: ./.github/actions/setup-node-env
+ with:
+ node-version: "22.x"
+ cache-key-suffix: "node22"
+ install-bun: "false"
+ use-sticky-disk: "true"
+
+ - name: Configure Node 22 test resources
+ run: |
+ # Keep the compatibility lane aligned with the default Node test lane.
+ echo "OPENCLAW_TEST_WORKERS=2" >> "$GITHUB_ENV"
+ echo "OPENCLAW_TEST_MAX_OLD_SPACE_SIZE_MB=6144" >> "$GITHUB_ENV"
+
+ - name: Build under Node 22
+ run: pnpm build
+
+ - name: Run tests under Node 22
+ run: pnpm test
+
+ - name: Verify npm pack under Node 22
+ run: pnpm release:check
+
skills-python:
needs: [docs-scope, changed-scope]
if: needs.docs-scope.outputs.docs_only != 'true' && (github.event_name == 'push' || needs.changed-scope.outputs.run_node == 'true' || needs.changed-scope.outputs.run_skills_python == 'true')
@@ -401,14 +435,14 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
- node-version: 22.x
+ node-version: 24.x
check-latest: false
- name: Setup pnpm + cache store
uses: ./.github/actions/setup-pnpm-store-cache
with:
pnpm-version: "10.23.0"
- cache-key-suffix: "node22"
+ cache-key-suffix: "node24"
# Sticky disk mount currently retries/fails on every shard and adds ~50s
# before install while still yielding zero pnpm store reuse.
# Try exact-key actions/cache restores instead to recover store reuse
diff --git a/.github/workflows/docker-release.yml b/.github/workflows/docker-release.yml
index 2cc29748c91..3ad4b539311 100644
--- a/.github/workflows/docker-release.yml
+++ b/.github/workflows/docker-release.yml
@@ -36,7 +36,7 @@ jobs:
uses: actions/checkout@v4
- name: Set up Docker Builder
- uses: useblacksmith/setup-docker-builder@v1
+ uses: docker/setup-buildx-action@v3
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
@@ -137,7 +137,7 @@ jobs:
uses: actions/checkout@v4
- name: Set up Docker Builder
- uses: useblacksmith/setup-docker-builder@v1
+ uses: docker/setup-buildx-action@v3
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
diff --git a/.github/workflows/install-smoke.yml b/.github/workflows/install-smoke.yml
index f18ba38a091..ca04748f9bf 100644
--- a/.github/workflows/install-smoke.yml
+++ b/.github/workflows/install-smoke.yml
@@ -41,7 +41,7 @@ jobs:
uses: actions/checkout@v4
- name: Set up Docker Builder
- uses: useblacksmith/setup-docker-builder@v1
+ uses: docker/setup-buildx-action@v3
# Blacksmith can fall back to the local docker driver, which rejects gha
# cache export/import. Keep smoke builds driver-agnostic.
diff --git a/.github/workflows/openclaw-npm-release.yml b/.github/workflows/openclaw-npm-release.yml
index 09126ed6ad2..f3783045820 100644
--- a/.github/workflows/openclaw-npm-release.yml
+++ b/.github/workflows/openclaw-npm-release.yml
@@ -10,7 +10,7 @@ concurrency:
cancel-in-progress: false
env:
- NODE_VERSION: "22.x"
+ NODE_VERSION: "24.x"
PNPM_VERSION: "10.23.0"
jobs:
diff --git a/.github/workflows/sandbox-common-smoke.yml b/.github/workflows/sandbox-common-smoke.yml
index 13688bd0f25..8ece9010a20 100644
--- a/.github/workflows/sandbox-common-smoke.yml
+++ b/.github/workflows/sandbox-common-smoke.yml
@@ -27,7 +27,7 @@ jobs:
submodules: false
- name: Set up Docker Builder
- uses: useblacksmith/setup-docker-builder@v1
+ uses: docker/setup-buildx-action@v3
- name: Build minimal sandbox base (USER sandbox)
shell: bash
diff --git a/.gitignore b/.gitignore
index 4defa8acb33..4f8abcaa94f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -123,3 +123,11 @@ dist/protocol.schema.json
# Synthing
**/.stfolder/
.dev-state
+docs/superpowers/plans/2026-03-10-collapsed-side-nav.md
+docs/superpowers/specs/2026-03-10-collapsed-side-nav-design.md
+.gitignore
+test/config-form.analyze.telegram.test.ts
+ui/src/ui/theme-variants.browser.test.ts
+ui/src/ui/__screenshots__/navigation.browser.test.ts/control-UI-routing-auto-scrolls-chat-history-to-the-latest-message-1.png
+ui/src/ui/__screenshots__/navigation.browser.test.ts/control-UI-routing-auto-scrolls-chat-history-to-the-latest-message-1.png
+ui/src/ui/__screenshots__/navigation.browser.test.ts/control-UI-routing-auto-scrolls-chat-history-to-the-latest-message-1.png
diff --git a/.npmignore b/.npmignore
new file mode 100644
index 00000000000..7cd53fdbc08
--- /dev/null
+++ b/.npmignore
@@ -0,0 +1 @@
+**/node_modules/
diff --git a/.secrets.baseline b/.secrets.baseline
index 5a0c639b9e3..056b2dd8778 100644
--- a/.secrets.baseline
+++ b/.secrets.baseline
@@ -12991,7 +12991,7 @@
"filename": "ui/src/i18n/locales/en.ts",
"hashed_secret": "de0ff6b974d6910aca8d6b830e1b761f076d8fe6",
"is_verified": false,
- "line_number": 61
+ "line_number": 74
}
],
"ui/src/i18n/locales/pt-BR.ts": [
@@ -13000,7 +13000,7 @@
"filename": "ui/src/i18n/locales/pt-BR.ts",
"hashed_secret": "ef7b6f95faca2d7d3a5aa5a6434c89530c6dd243",
"is_verified": false,
- "line_number": 61
+ "line_number": 73
}
],
"vendor/a2ui/README.md": [
diff --git a/AGENTS.md b/AGENTS.md
index 69b0df68faa..45eed9ec2ad 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -118,6 +118,7 @@
- Keep files concise; extract helpers instead of “V2” copies. Use existing patterns for CLI options and dependency injection via `createDefaultDeps`.
- Aim to keep files under ~700 LOC; guideline only (not a hard guardrail). Split/refactor when it improves clarity or testability.
- Naming: use **OpenClaw** for product/app/docs headings; use `openclaw` for CLI command, package/binary, paths, and config keys.
+- Written English: use American spelling and grammar in code, comments, docs, and UI strings (e.g. "color" not "colour", "behavior" not "behaviour", "analyze" not "analyse").
## Release Channels (Naming)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 93fedbb94de..f6b385e8133 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,10 +4,88 @@ Docs: https://docs.openclaw.ai
## Unreleased
+## 2026.3.12
+
### Changes
+- Control UI/dashboard-v2: refresh the gateway dashboard with modular overview, chat, config, agent, and session views, plus a command palette, mobile bottom tabs, and richer chat tools like slash commands, search, export, and pinned messages. (#41503) Thanks @BunsDev.
+- OpenAI/GPT-5.4 fast mode: add configurable session-level fast toggles across `/fast`, TUI, Control UI, and ACP, with per-model config defaults and OpenAI/Codex request shaping.
+- Anthropic/Claude fast mode: map the shared `/fast` toggle and `params.fastMode` to direct Anthropic API-key `service_tier` requests, with live verification for both Anthropic and OpenAI fast-mode tiers.
+- Models/plugins: move Ollama, vLLM, and SGLang onto the provider-plugin architecture, with provider-owned onboarding, discovery, model-picker setup, and post-selection hooks so core provider wiring is more modular.
+- Docs/Kubernetes: Add a starter K8s install path with raw manifests, Kind setup, and deployment docs. Thanks @sallyom @dzianisv @egkristi
+- Agents/subagents: add `sessions_yield` so orchestrators can end the current turn immediately, skip queued tool work, and carry a hidden follow-up payload into the next session turn. (#36537) thanks @jriff
+- Slack/agent replies: support `channelData.slack.blocks` in the shared reply delivery path so agents can send Block Kit messages through standard Slack outbound delivery. (#44592) Thanks @vincentkoc.
+
### Fixes
+- Security/device pairing: switch `/pair` and `openclaw qr` setup codes to short-lived bootstrap tokens so the next release no longer embeds shared gateway credentials in chat or QR pairing payloads. Thanks @lintsinghua.
+- Security/plugins: disable implicit workspace plugin auto-load so cloned repositories cannot execute workspace plugin code without an explicit trust decision. (`GHSA-99qw-6mr3-36qr`)(#44174) Thanks @lintsinghua and @vincentkoc.
+- Models/Kimi Coding: send `anthropic-messages` tools in native Anthropic format again so `kimi-coding` stops degrading tool calls into XML/plain-text pseudo invocations instead of real `tool_use` blocks. (#38669, #39907, #40552) Thanks @opriz.
+- TUI/chat log: reuse the active assistant message component for the same streaming run so `openclaw tui` no longer renders duplicate assistant replies. (#35364) Thanks @lisitan.
+- Telegram/model picker: make inline model button selections persist the chosen session model correctly, clear overrides when selecting the configured default, and include effective fallback models in `/models` button validation. (#40105) Thanks @avirweb.
+- Cron/proactive delivery: keep isolated direct cron sends out of the write-ahead resend queue so transient-send retries do not replay duplicate proactive messages after restart. (#40646) Thanks @openperf and @vincentkoc.
+- Models/Kimi Coding: send the built-in `User-Agent: claude-code/0.1.0` header by default for `kimi-coding` while still allowing explicit provider headers to override it, so Kimi Code subscription auth can work without a local header-injection proxy. (#30099) Thanks @Amineelfarssi and @vincentkoc.
+- Models/OpenAI Codex Spark: keep `gpt-5.3-codex-spark` working on the `openai-codex/*` path via resolver fallbacks and clearer Codex-only handling, while continuing to suppress the stale direct `openai/*` Spark row that OpenAI rejects live.
+- Ollama/Kimi Cloud: apply the Moonshot Kimi payload compatibility wrapper to Ollama-hosted Kimi models like `kimi-k2.5:cloud`, so tool routing no longer breaks when thinking is enabled. (#41519) Thanks @vincentkoc.
+- Moonshot CN API: respect explicit `baseUrl` (api.moonshot.cn) in implicit provider resolution so platform.moonshot.cn API keys authenticate correctly instead of returning HTTP 401. (#33637) Thanks @chengzhichao-xydt.
+- Kimi Coding/provider config: respect explicit `models.providers["kimi-coding"].baseUrl` when resolving the implicit provider so custom Kimi Coding endpoints no longer get overwritten by the built-in default. (#36353) Thanks @2233admin.
+- Gateway/main-session routing: keep TUI and other `mode:UI` main-session sends on the internal surface when `deliver` is enabled, so replies no longer inherit the session's persisted Telegram/WhatsApp route. (#43918) Thanks @obviyus.
+- BlueBubbles/self-chat echo dedupe: drop reflected duplicate webhook copies only when a matching `fromMe` event was just seen for the same chat, body, and timestamp, preventing self-chat loops without broad webhook suppression. Related to #32166. (#38442) Thanks @vincentkoc.
+- iMessage/self-chat echo dedupe: drop reflected duplicate copies only when a matching `is_from_me` event was just seen for the same chat, text, and `created_at`, preventing self-chat loops without broad text-only suppression. Related to #32166. (#38440) Thanks @vincentkoc.
+- Subagents/completion announce retries: raise the default announce timeout to 90 seconds and stop retrying gateway-timeout failures for externally delivered completion announces, preventing duplicate user-facing completion messages after slow gateway responses. Fixes #41235. Thanks @vasujain00 and @vincentkoc.
+- Mattermost/block streaming: fix duplicate message delivery (one threaded, one top-level) when block streaming is active by excluding `replyToId` from the block reply dedup key and adding an explicit `threading` dock to the Mattermost plugin. (#41362) Thanks @mathiasnagler and @vincentkoc.
+- Mattermost/reply media delivery: pass agent-scoped `mediaLocalRoots` through shared reply delivery so allowed local files upload correctly from button, slash-command, and model-picker replies. (#44021) Thanks @LyleLiu666.
+- macOS/Reminders: add the missing `NSRemindersUsageDescription` to the bundled app so `apple-reminders` can trigger the system permission prompt from OpenClaw.app. (#8559) Thanks @dinakars777.
+- Gateway/session discovery: discover disk-only and retired ACP session stores under custom templated `session.store` roots so ACP reconciliation, session-id/session-label targeting, and run-id fallback keep working after restart. (#44176) thanks @gumadeiras.
+- Plugins/env-scoped roots: fix plugin discovery/load caches and provenance tracking so same-process `HOME`/`OPENCLAW_HOME` changes no longer reuse stale plugin state or misreport `~/...` plugins as untracked. (#44046) thanks @gumadeiras.
+- Models/OpenRouter native ids: canonicalize native OpenRouter model keys across config writes, runtime lookups, fallback management, and `models list --plain`, and migrate legacy duplicated `openrouter/openrouter/...` config entries forward on write.
+- Windows/native update: make package installs use the npm update path instead of the git path, carry portable Git into native Windows updates, and mirror the installer's Windows npm env so `openclaw update` no longer dies early on missing `git` or `node-llama-cpp` download setup.
+- Sandbox/write: preserve pinned mutation-helper payload stdin so sandboxed `write` no longer reports success while creating empty files. (#43876) Thanks @glitch418x.
+- Security/exec approvals: escape invisible Unicode format characters in approval prompts so zero-width command text renders as visible `\u{...}` escapes instead of spoofing the reviewed command. (`GHSA-pcqg-f7rg-xfvv`)(#43687) Thanks @EkiXu and @vincentkoc.
+- Hooks/loader: fail closed when workspace hook paths cannot be resolved with `realpath`, so unreadable or broken internal hook paths are skipped instead of falling back to unresolved imports. (#44437) Thanks @vincentkoc.
+- Hooks/agent deliveries: dedupe repeated hook requests by optional idempotency key so webhook retries can reuse the first run instead of launching duplicate agent executions. (#44438) Thanks @vincentkoc.
+- Security/exec detection: normalize compatibility Unicode and strip invisible formatting code points before obfuscation checks so zero-width and fullwidth command tricks no longer suppress heuristic detection. (`GHSA-9r3v-37xh-2cf6`)(#44091) Thanks @wooluo and @vincentkoc.
+- Security/exec allowlist: preserve POSIX case sensitivity and keep `?` within a single path segment so exact-looking allowlist patterns no longer overmatch executables across case or directory boundaries. (`GHSA-f8r2-vg7x-gh8m`)(#43798) Thanks @zpbrent and @vincentkoc.
+- Security/commands: require sender ownership for `/config` and `/debug` so authorized non-owner senders can no longer reach owner-only config and runtime debug surfaces. (`GHSA-r7vr-gr74-94p8`)(#44305) Thanks @tdjackey and @vincentkoc.
+- Security/gateway auth: clear unbound client-declared scopes on shared-token WebSocket connects so device-less shared-token operators cannot self-declare elevated scopes. (`GHSA-rqpp-rjj8-7wv8`)(#44306) Thanks @LUOYEcode and @vincentkoc.
+- Security/browser.request: block persistent browser profile create/delete routes from write-scoped `browser.request` so callers can no longer persist admin-only browser profile changes through the browser control surface. (`GHSA-vmhq-cqm9-6p7q`)(#43800) Thanks @tdjackey and @vincentkoc.
+- Security/agent: reject public spawned-run lineage fields and keep workspace inheritance on the internal spawned-session path so external `agent` callers can no longer override the gateway workspace boundary. (`GHSA-2rqg-gjgv-84jm`)(#43801) Thanks @tdjackey and @vincentkoc.
+- Security/session_status: enforce sandbox session-tree visibility and shared agent-to-agent access guards before reading or mutating target session state, so sandboxed subagents can no longer inspect parent session metadata or write parent model overrides via `session_status`. (`GHSA-wcxr-59v9-rxr8`)(#43754) Thanks @tdjackey and @vincentkoc.
+- Security/agent tools: mark `nodes` as explicitly owner-only and document/test that `canvas` remains a shared trusted-operator surface unless a real boundary bypass exists.
+- Security/exec approvals: fail closed for Ruby approval flows that use `-r`, `--require`, or `-I` so approval-backed commands no longer bind only the main script while extra local code-loading flags remain outside the reviewed file snapshot.
+- Security/device pairing: cap issued and verified device-token scopes to each paired device's approved scope baseline so stale or overbroad tokens cannot exceed approved access. (`GHSA-2pwv-x786-56f8`)(#43686) Thanks @tdjackey and @vincentkoc.
+- Docs/onboarding: align the legacy wizard reference and `openclaw onboard` command docs with the Ollama onboarding flow so all onboarding reference paths now document `--auth-choice ollama`, Cloud + Local mode, and non-interactive usage. (#43473) Thanks @BruceMacD.
+- Models/secrets: enforce source-managed SecretRef markers in generated `models.json` so runtime-resolved provider secrets are not persisted when runtime projection is skipped. (#43759) Thanks @joshavant.
+- Security/WebSocket preauth: shorten unauthenticated handshake retention and reject oversized pre-auth frames before application-layer parsing to reduce pre-pairing exposure on unsupported public deployments. (`GHSA-jv4g-m82p-2j93`)(#44089) (`GHSA-xwx2-ppv2-wx98`)(#44089) Thanks @ez-lbz and @vincentkoc.
+- Security/proxy attachments: restore the shared media-store size cap for persisted browser proxy files so oversized payloads are rejected instead of overriding the intended 5 MB limit. (`GHSA-6rph-mmhp-h7h9`)(#43684) Thanks @tdjackey and @vincentkoc.
+- Security/host env: block inherited `GIT_EXEC_PATH` from sanitized host exec environments so Git helper resolution cannot be steered by host environment state. (`GHSA-jf5v-pqgw-gm5m`)(#43685) Thanks @zpbrent and @vincentkoc.
+- Security/Feishu webhook: require `encryptKey` alongside `verificationToken` in webhook mode so unsigned forged events are rejected instead of being processed with token-only configuration. (`GHSA-g353-mgv3-8pcj`)(#44087) Thanks @lintsinghua and @vincentkoc.
+- Security/Feishu reactions: preserve looked-up group chat typing and fail closed on ambiguous reaction context so group authorization and mention gating cannot be bypassed through synthetic `p2p` reactions. (`GHSA-m69h-jm2f-2pv8`)(#44088) Thanks @zpbrent and @vincentkoc.
+- Security/LINE webhook: require signatures for empty-event POST probes too so unsigned requests no longer confirm webhook reachability with a `200` response. (`GHSA-mhxh-9pjm-w7q5`)(#44090) Thanks @TerminalsandCoffee and @vincentkoc.
+- Security/Zalo webhook: rate limit invalid secret guesses before auth so weak webhook secrets cannot be brute-forced through unauthenticated churned requests without pre-auth `429` responses. (`GHSA-5m9r-p9g7-679c`)(#44173) Thanks @zpbrent and @vincentkoc.
+- Security/Zalouser groups: require stable group IDs for allowlist auth by default and gate mutable group-name matching behind `channels.zalouser.dangerouslyAllowNameMatching`. Thanks @zpbrent.
+- Security/Slack and Teams routing: require stable channel and team IDs for allowlist routing by default, with mutable name matching only via each channel's `dangerouslyAllowNameMatching` break-glass flag.
+- Security/exec approvals: fail closed for ambiguous inline loader and shell-payload script execution, bind the real script after POSIX shell value-taking flags, and unwrap `pnpm`/`npm exec`/`npx` script runners before approval binding. (`GHSA-57jw-9722-6rf2`)(`GHSA-jvqh-rfmh-jh27`)(`GHSA-x7pp-23xv-mmr4`)(`GHSA-jc5j-vg4r-j5jx`)(#44247) Thanks @tdjackey and @vincentkoc.
+- Doctor/gateway service audit: canonicalize service entrypoint paths before comparing them so symlink-vs-realpath installs no longer trigger false "entrypoint does not match the current install" repair prompts. (#43882) Thanks @ngutman.
+- Doctor/gateway service audit: earlier groundwork for this fix landed in the superseded #28338 branch. Thanks @realriphub.
+- Gateway/session stores: regenerate the Swift push-test protocol models and align Windows native session-store realpath handling so protocol checks and sync session discovery stop drifting on Windows. (#44266) thanks @jalehman.
+- Context engine/session routing: forward optional `sessionKey` through context-engine lifecycle calls so plugins can see structured routing metadata during bootstrap, assembly, post-turn ingestion, and compaction. (#44157) thanks @jalehman.
+- Agents/failover: classify z.ai `network_error` stop reasons as retryable timeouts so provider connectivity failures trigger fallback instead of surfacing raw unhandled-stop-reason errors. (#43884) Thanks @hougangdev.
+- Memory/session sync: add mode-aware post-compaction session reindexing with `agents.defaults.compaction.postIndexSync` plus `agents.defaults.memorySearch.sync.sessions.postCompactionForce`, so compacted session memory can refresh immediately without forcing every deployment into synchronous reindexing. (#25561) thanks @rodrigouroz.
+- Telegram/model picker: make inline model button selections persist the chosen session model correctly, clear overrides when selecting the configured default, and include effective fallback models in `/models` button validation. (#40105) Thanks @avirweb.
+- Telegram/native command sync: suppress expected `BOT_COMMANDS_TOO_MUCH` retry error noise, add a final fallback summary log, and document the difference between command-menu overflow and real Telegram network failures.
+- Mattermost/reply media delivery: pass agent-scoped `mediaLocalRoots` through shared reply delivery so allowed local files upload correctly from button, slash-command, and model-picker replies. (#44021) Thanks @LyleLiu666.
+- Plugins/env-scoped roots: fix plugin discovery/load caches and provenance tracking so same-process `HOME`/`OPENCLAW_HOME` changes no longer reuse stale plugin state or misreport `~/...` plugins as untracked. (#44046) thanks @gumadeiras.
+- Gateway/session discovery: discover disk-only and retired ACP session stores under custom templated `session.store` roots so ACP reconciliation, session-id/session-label targeting, and run-id fallback keep working after restart. (#44176) thanks @gumadeiras.
+- Models/OpenRouter native ids: canonicalize native OpenRouter model keys across config writes, runtime lookups, fallback management, and `models list --plain`, and migrate legacy duplicated `openrouter/openrouter/...` config entries forward on write.
+- Gateway/hooks: bucket hook auth failures by forwarded client IP behind trusted proxies and warn when `hooks.allowedAgentIds` leaves hook routing unrestricted.
+- Agents/compaction: skip the post-compaction `cache-ttl` marker write when a compaction completed in the same attempt, preventing the next turn from immediately triggering a second tiny compaction. (#28548) thanks @MoerAI.
+- Native chat/macOS: add `/new`, `/reset`, and `/clear` reset triggers, keep shared main-session aliases aligned, and ignore stale model-selection completions so native chat state stays in sync across reset and fast model changes. (#10898) Thanks @Nachx639.
+- Agents/compaction safeguard: route missing-model and missing-API-key cancellation warnings through the shared subsystem logger so they land in structured and file logs. (#9974) Thanks @dinakars777.
+- Cron/doctor: stop flagging canonical `agentTurn` and `systemEvent` payload kinds as legacy cron storage, while still normalizing whitespace-padded and non-canonical variants. (#44012) Thanks @shuicici.
+- ACP/client final-message delivery: preserve terminal assistant text snapshots before resolving `end_turn`, so ACP clients no longer drop the last visible reply when the gateway sends the final message body on the terminal chat event. (#17615) Thanks @pjeby.
+- Telegram/Discord status reactions: show a temporary compacting reaction during auto-compaction pauses and restore thinking afterward so the bot no longer appears frozen while context is being compacted. (#35474) thanks @Cypherm.
+
## 2026.3.11
### Security
@@ -31,6 +109,9 @@ Docs: https://docs.openclaw.ai
- Gateway/node pending work: add narrow in-memory pending-work queue primitives (`node.pending.enqueue` / `node.pending.drain`) and wake-helper reuse as a foundation for dormant-node work delivery. (#41409) Thanks @mbelinky.
- Git/runtime state: ignore the gateway-generated `.dev-state` file so local runtime state does not show up as untracked repo noise. (#41848) Thanks @smysle.
- Exec/child commands: mark child command environments with `OPENCLAW_CLI` so subprocesses can detect when they were launched from the OpenClaw CLI. (#41411) Thanks @vincentkoc.
+- LLM Task/Lobster: add an optional `thinking` override so workflow calls can explicitly set embedded reasoning level with shared validation for invalid values and unsupported `xhigh` modes. (#15606) Thanks @xadenryan and @ImLukeF.
+- Mattermost/reply threading: add `channels.mattermost.replyToMode` for channel and group messages so top-level posts can start thread-scoped sessions without the manual reply-then-thread workaround. (#29587) Thanks @teconomix.
+- iOS/push relay: add relay-backed official-build push delivery with App Attest + receipt verification, gateway-bound send delegation, and config-based relay URL setup on the gateway. (#43369) Thanks @ngutman.
### Breaking
@@ -38,6 +119,11 @@ Docs: https://docs.openclaw.ai
### Fixes
+- Windows/install: stop auto-installing `node-llama-cpp` during normal npm CLI installs so `openclaw@latest` no longer fails on Windows while building optional local-embedding dependencies.
+- Windows/update: mirror the native installer environment during global npm updates, including portable Git fallback and Windows-safe npm shell settings, so `openclaw update` works again on native Windows installs.
+- Gateway/status: expose `runtimeVersion` in gateway status output so install/update smoke tests can verify the running version before and after updates.
+- Windows/onboarding: explain when non-interactive local onboarding is waiting for an already-running gateway, and surface native Scheduled Task admin requirements more clearly instead of failing with an opaque gateway timeout.
+- Windows/gateway install: fall back from denied Scheduled Task creation to a per-user Startup-folder login item, so native `openclaw gateway install` and `--install-daemon` keep working without an elevated PowerShell shell.
- Agents/text sanitization: strip leaked model control tokens (`<|...|>` and full-width `<|...|>` variants) from user-facing assistant text, preventing GLM-5 and DeepSeek internal delimiters from reaching end users. (#42173) Thanks @imwyvern.
- iOS/gateway foreground recovery: reconnect immediately on foreground return after stale background sockets are torn down, so the app no longer stays disconnected until a later wake path happens. (#41384) Thanks @mbelinky.
- Gateway/Control UI: keep dashboard auth tokens in session-scoped browser storage so same-tab refreshes preserve remote token auth without restoring long-lived localStorage token persistence, while scoping tokens to the selected gateway URL and fragment-only bootstrap flow. (#40892) thanks @velvet-shark.
@@ -50,6 +136,7 @@ Docs: https://docs.openclaw.ai
- Telegram/final preview delivery: split active preview lifecycle from cleanup retention so missing archived preview edits avoid duplicate fallback sends without clearing the live preview or blocking later in-place finalization. (#41662) thanks @hougangdev.
- Telegram/final preview delivery followup: keep ambiguous missing-`message_id` finals only when a preview was already visible, while first-preview/no-id cases still fall back so Telegram users do not lose the final reply. (#41932) thanks @hougangdev.
- Telegram/final preview cleanup follow-up: clear stale cleanup-retain state only for transient preview finals so archived-preview retains no longer leave a stale partial bubble beside a later fallback-sent final. (#41763) Thanks @obviyus.
+- Telegram/poll restarts: scope process-level polling restarts to real Telegram `getUpdates` failures so unrelated network errors, such as Slack DNS misses, no longer bounce Telegram polling. (#43799) Thanks @obviyus.
- Gateway/auth: allow one trusted device-token retry on shared-token mismatch with recovery hints to prevent reconnect churn during token drift. (#42507) Thanks @joshavant.
- Gateway/config errors: surface up to three validation issues in top-level `config.set`, `config.patch`, and `config.apply` error messages while preserving structured issue details. (#42664) Thanks @huntharo.
- Agents/Azure OpenAI Responses: include the `azure-openai` provider in the Responses API store override so Azure OpenAI multi-turn cron jobs and embedded agent runs no longer fail with HTTP 400 "store is set to false". (#42934, fixes #42800) Thanks @ademczuk.
@@ -92,8 +179,8 @@ Docs: https://docs.openclaw.ai
- Security/system.run: fail closed for approval-backed interpreter/runtime commands when OpenClaw cannot bind exactly one concrete local file operand, while extending best-effort direct-file binding to additional runtime forms. Thanks @tdjackey for reporting.
- Gateway/session reset auth: split conversation `/new` and `/reset` handling away from the admin-only `sessions.reset` control-plane RPC so write-scoped gateway callers can no longer reach the privileged reset path through `agent`. Thanks @tdjackey for reporting.
- Security/plugin runtime: stop unauthenticated plugin HTTP routes from inheriting synthetic admin gateway scopes when they call `runtime.subagent.*`, so admin-only methods like `sessions.delete` stay blocked without gateway auth.
-- Security/session_status: enforce sandbox session-tree visibility and shared agent-to-agent access guards before reading or mutating target session state, so sandboxed subagents can no longer inspect parent session metadata or write parent model overrides via `session_status`.
- Security/nodes: treat the `nodes` agent tool as owner-only fallback policy so non-owner senders cannot reach paired-node approval or invoke paths through the shared tool set.
+- Sandbox/sessions_spawn: restore real workspace handoff for read-only sandboxed sessions so spawned subagents mount the configured workspace at `/agent` instead of inheriting the sandbox copy. Related #40582.
- Security/external content: treat whitespace-delimited `EXTERNAL UNTRUSTED CONTENT` boundary markers like underscore-delimited variants so prompt wrappers cannot bypass marker sanitization. (#35983) Thanks @urianpaul94.
- Telegram/exec approvals: reject `/approve` commands aimed at other bots, keep deterministic approval prompts visible when tool-result delivery fails, and stop resolved exact IDs from matching other pending approvals by prefix. (#37233) Thanks @huntharo.
- Subagents/authority: persist leaf vs orchestrator control scope at spawn time and route tool plus slash-command control through shared ownership checks, so leaf sessions cannot regain orchestration privileges after restore or flat-key lookups. Thanks @tdjackey.
@@ -130,6 +217,14 @@ Docs: https://docs.openclaw.ai
- Telegram/direct delivery: bridge direct delivery sends to internal `message:sent` hooks so internal hook listeners observe successful Telegram deliveries. (#40185) Thanks @vincentkoc.
- Dependencies: refresh workspace dependencies except the pinned Carbon package, and harden ACP session-config writes against non-string SDK values so newer ACP clients fail fast instead of tripping type/runtime mismatches.
- Telegram/polling restarts: clear bounded cleanup timeout handles after `runner.stop()` and `bot.stop()` settle so stall recovery no longer leaves stray 15-second timers behind on clean shutdown. (#43188) thanks @kyohwang.
+- Gateway/config errors: surface up to three validation issues in top-level `config.set`, `config.patch`, and `config.apply` error messages while preserving structured issue details. (#42664) Thanks @huntharo.
+- Hooks/plugin context parity followup: pass `trigger` and `channelId` through embedded `llm_input`, `agent_end`, and `llm_output` hook contexts so plugins receive the same agent metadata across hook phases. (#42362) Thanks @zhoulf1006.
+- Status/context windows: normalize provider-qualified override cache keys so `/status` resolves the active provider's configured context window even when `models.providers` keys use mixed case or surrounding whitespace. (#36389) Thanks @haoruilee.
+- ACP/main session aliases: canonicalize `main` before ACP session lookup so restarted ACP main sessions rehydrate instead of failing closed with `Session is not ACP-enabled: main`. (#43285, fixes #25692)
+- Agents/embedded runner: recover canonical allowlisted tool names from malformed `toolCallId` and malformed non-blank tool-name variants before dispatch, while failing closed on ambiguous matches. (#34485) thanks @yuweuii.
+- Agents/failover: classify ZenMux quota-refresh `402` responses as `rate_limit` so model fallback retries continue instead of stopping on a temporary subscription window. (#43917) thanks @bwjoke.
+- Agents/failover: classify HTTP 422 malformed-request responses as `format` and recognize OpenRouter "requires more credits" billing errors so provider fallback triggers instead of surfacing raw errors. (#43823) thanks @jnMetaCode.
+- Memory/QMD Windows: fail closed when `qmd.cmd` or `mcporter.cmd` wrappers cannot be resolved to a direct entrypoint, so memory search no longer falls back to shell execution on Windows.
## 2026.3.8
@@ -204,6 +299,10 @@ Docs: https://docs.openclaw.ai
- macOS/browser proxy: serialize non-GET browser proxy request bodies through `AnyCodable.foundationValue` so nested JSON bodies no longer crash the macOS app with `Invalid type in JSON write (__SwiftValue)`. (#43069) Thanks @Effet.
- CLI/skills tables: keep terminal table borders aligned for wide graphemes, use full reported terminal width, and switch a few ambiguous skill icons to Terminal-safe emoji so `openclaw skills` renders more consistently in Terminal.app and iTerm. Thanks @vincentkoc.
- Memory/Gemini: normalize returned Gemini embeddings across direct query, direct batch, and async batch paths so memory search uses consistent vector handling for Gemini too. (#43409) Thanks @gumadeiras.
+- Agents/failover: recognize additional serialized network errno strings plus `EHOSTDOWN` and `EPIPE` structured codes so transient transport failures trigger timeout failover more reliably. (#42830) Thanks @jnMetaCode.
+- Telegram/model picker: make inline model button selections persist the chosen session model correctly, clear overrides when selecting the configured default, and include effective fallback models in `/models` button validation. (#40105) Thanks @avirweb.
+- Agents/embedded runner: carry provider-observed overflow token counts into compaction so overflow retries and diagnostics use the rejected live prompt size instead of only transcript estimates. (#40357) thanks @rabsef-bicrym.
+- Agents/compaction transcript updates: emit a transcript-update event immediately after successful embedded compaction so downstream listeners observe the post-compact transcript without waiting for a later write. (#25558) thanks @rodrigouroz.
## 2026.3.7
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index c7808db9cf8..a4bb0e17361 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -92,6 +92,7 @@ Welcome to the lobster tank! 🦞
- Describe what & why
- Reply to or resolve bot review conversations you addressed before asking for review again
- **Include screenshots** — one showing the problem/before, one showing the fix/after (for UI or visual changes)
+- Use American English spelling and grammar in code, comments, docs, and UI strings
## Review Conversations Are Author-Owned
diff --git a/Dockerfile b/Dockerfile
index d6923365b4b..72c413ebe7b 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -14,14 +14,14 @@
# Slim (bookworm-slim): docker build --build-arg OPENCLAW_VARIANT=slim .
ARG OPENCLAW_EXTENSIONS=""
ARG OPENCLAW_VARIANT=default
-ARG OPENCLAW_NODE_BOOKWORM_IMAGE="node:22-bookworm@sha256:b501c082306a4f528bc4038cbf2fbb58095d583d0419a259b2114b5ac53d12e9"
-ARG OPENCLAW_NODE_BOOKWORM_DIGEST="sha256:b501c082306a4f528bc4038cbf2fbb58095d583d0419a259b2114b5ac53d12e9"
-ARG OPENCLAW_NODE_BOOKWORM_SLIM_IMAGE="node:22-bookworm-slim@sha256:9c2c405e3ff9b9afb2873232d24bb06367d649aa3e6259cbe314da59578e81e9"
-ARG OPENCLAW_NODE_BOOKWORM_SLIM_DIGEST="sha256:9c2c405e3ff9b9afb2873232d24bb06367d649aa3e6259cbe314da59578e81e9"
+ARG OPENCLAW_NODE_BOOKWORM_IMAGE="node:24-bookworm@sha256:3a09aa6354567619221ef6c45a5051b671f953f0a1924d1f819ffb236e520e6b"
+ARG OPENCLAW_NODE_BOOKWORM_DIGEST="sha256:3a09aa6354567619221ef6c45a5051b671f953f0a1924d1f819ffb236e520e6b"
+ARG OPENCLAW_NODE_BOOKWORM_SLIM_IMAGE="node:24-bookworm-slim@sha256:e8e2e91b1378f83c5b2dd15f0247f34110e2fe895f6ca7719dbb780f929368eb"
+ARG OPENCLAW_NODE_BOOKWORM_SLIM_DIGEST="sha256:e8e2e91b1378f83c5b2dd15f0247f34110e2fe895f6ca7719dbb780f929368eb"
# Base images are pinned to SHA256 digests for reproducible builds.
# Trade-off: digests must be updated manually when upstream tags move.
-# To update, run: docker manifest inspect node:22-bookworm (or podman)
+# To update, run: docker buildx imagetools inspect node:24-bookworm (or podman)
# and replace the digest below with the current multi-arch manifest list entry.
FROM ${OPENCLAW_NODE_BOOKWORM_IMAGE} AS ext-deps
@@ -39,8 +39,18 @@ RUN mkdir -p /out && \
# ── Stage 2: Build ──────────────────────────────────────────────
FROM ${OPENCLAW_NODE_BOOKWORM_IMAGE} AS build
-# Install Bun (required for build scripts)
-RUN curl -fsSL https://bun.sh/install | bash
+# Install Bun (required for build scripts). Retry the whole bootstrap flow to
+# tolerate transient 5xx failures from bun.sh/GitHub during CI image builds.
+RUN set -eux; \
+ for attempt in 1 2 3 4 5; do \
+ if curl --retry 5 --retry-all-errors --retry-delay 2 -fsSL https://bun.sh/install | bash; then \
+ break; \
+ fi; \
+ if [ "$attempt" -eq 5 ]; then \
+ exit 1; \
+ fi; \
+ sleep $((attempt * 2)); \
+ done
ENV PATH="/root/.bun/bin:${PATH}"
RUN corepack enable
@@ -92,12 +102,12 @@ RUN CI=true pnpm prune --prod && \
# ── Runtime base images ─────────────────────────────────────────
FROM ${OPENCLAW_NODE_BOOKWORM_IMAGE} AS base-default
ARG OPENCLAW_NODE_BOOKWORM_DIGEST
-LABEL org.opencontainers.image.base.name="docker.io/library/node:22-bookworm" \
+LABEL org.opencontainers.image.base.name="docker.io/library/node:24-bookworm" \
org.opencontainers.image.base.digest="${OPENCLAW_NODE_BOOKWORM_DIGEST}"
FROM ${OPENCLAW_NODE_BOOKWORM_SLIM_IMAGE} AS base-slim
ARG OPENCLAW_NODE_BOOKWORM_SLIM_DIGEST
-LABEL org.opencontainers.image.base.name="docker.io/library/node:22-bookworm-slim" \
+LABEL org.opencontainers.image.base.name="docker.io/library/node:24-bookworm-slim" \
org.opencontainers.image.base.digest="${OPENCLAW_NODE_BOOKWORM_SLIM_DIGEST}"
# ── Stage 3: Runtime ────────────────────────────────────────────
@@ -141,7 +151,15 @@ COPY --from=runtime-assets --chown=node:node /app/docs ./docs
ENV COREPACK_HOME=/usr/local/share/corepack
RUN install -d -m 0755 "$COREPACK_HOME" && \
corepack enable && \
- corepack prepare "$(node -p "require('./package.json').packageManager")" --activate && \
+ for attempt in 1 2 3 4 5; do \
+ if corepack prepare "$(node -p "require('./package.json').packageManager")" --activate; then \
+ break; \
+ fi; \
+ if [ "$attempt" -eq 5 ]; then \
+ exit 1; \
+ fi; \
+ sleep $((attempt * 2)); \
+ done && \
chmod -R a+rX "$COREPACK_HOME"
# Install additional system packages needed by your skills or extensions.
@@ -209,7 +227,7 @@ RUN ln -sf /app/openclaw.mjs /usr/local/bin/openclaw \
ENV NODE_ENV=production
# Security hardening: Run as non-root user
-# The node:22-bookworm image includes a 'node' user (uid 1000)
+# The node:24-bookworm image includes a 'node' user (uid 1000)
# This reduces the attack surface by preventing container escape via root privileges
USER node
diff --git a/SECURITY.md b/SECURITY.md
index 204dadbf36d..bef814525a5 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -37,6 +37,7 @@ For fastest triage, include all of the following:
- Exact vulnerable path (`file`, function, and line range) on a current revision.
- Tested version details (OpenClaw version and/or commit SHA).
- Reproducible PoC against latest `main` or latest released version.
+- If the claim targets a released version, evidence from the shipped tag and published artifact/package for that exact version (not only `main`).
- Demonstrated impact tied to OpenClaw's documented trust boundaries.
- For exposed-secret reports: proof the credential is OpenClaw-owned (or grants access to OpenClaw-operated infrastructure/services).
- Explicit statement that the report does not rely on adversarial operators sharing one gateway host/config.
@@ -55,6 +56,7 @@ These are frequently reported but are typically closed with no code change:
- Authorized user-triggered local actions presented as privilege escalation. Example: an allowlisted/owner sender running `/export-session /absolute/path.html` to write on the host. In this trust model, authorized user actions are trusted host actions unless you demonstrate an auth/sandbox/boundary bypass.
- Reports that only show a malicious plugin executing privileged actions after a trusted operator installs/enables it.
- Reports that assume per-user multi-tenant authorization on a shared gateway host/config.
+- Reports that treat the Gateway HTTP compatibility endpoints (`POST /v1/chat/completions`, `POST /v1/responses`) as if they implemented scoped operator auth (`operator.write` vs `operator.admin`). These endpoints authenticate the shared Gateway bearer secret/password and are documented full operator-access surfaces, not per-user/per-scope boundaries.
- Reports that only show differences in heuristic detection/parity (for example obfuscation-pattern detection on one exec path but not another, such as `node.invoke -> system.run` parity gaps) without demonstrating bypass of auth, approvals, allowlist enforcement, sandboxing, or other documented trust boundaries.
- ReDoS/DoS claims that require trusted operator configuration input (for example catastrophic regex in `sessionFilter` or `logging.redactPatterns`) without a trust-boundary bypass.
- Archive/install extraction claims that require pre-existing local filesystem priming in trusted state (for example planting symlink/hardlink aliases under destination directories such as skills/tools paths) without showing an untrusted path that can create/control that primitive.
@@ -65,6 +67,7 @@ These are frequently reported but are typically closed with no code change:
- Discord inbound webhook signature findings for paths not used by this repo's Discord integration.
- Claims that Microsoft Teams `fileConsent/invoke` `uploadInfo.uploadUrl` is attacker-controlled without demonstrating one of: auth boundary bypass, a real authenticated Teams/Bot Framework event carrying attacker-chosen URL, or compromise of the Microsoft/Bot trust path.
- Scanner-only claims against stale/nonexistent paths, or claims without a working repro.
+- Reports that restate an already-fixed issue against later released versions without showing the vulnerable path still exists in the shipped tag or published artifact for that later version.
### Duplicate Report Handling
@@ -90,6 +93,7 @@ When patching a GHSA via `gh api`, include `X-GitHub-Api-Version: 2022-11-28` (o
OpenClaw does **not** model one gateway as a multi-tenant, adversarial user boundary.
- Authenticated Gateway callers are treated as trusted operators for that gateway instance.
+- The HTTP compatibility endpoints (`POST /v1/chat/completions`, `POST /v1/responses`) are in that same trusted-operator bucket. Passing Gateway bearer auth there is equivalent to operator access for that gateway; they do not implement a narrower `operator.write` vs `operator.admin` trust split.
- Session identifiers (`sessionKey`, session IDs, labels) are routing controls, not per-user authorization boundaries.
- If one operator can view data from another operator on the same gateway, that is expected in this trust model.
- OpenClaw can technically run multiple gateway instances on one machine, but recommended operations are clean separation by trust boundary.
@@ -145,6 +149,7 @@ OpenClaw security guidance assumes:
OpenClaw's security model is "personal assistant" (one trusted operator, potentially many agents), not "shared multi-tenant bus."
- If multiple people can message the same tool-enabled agent (for example a shared Slack workspace), they can all steer that agent within its granted permissions.
+- Non-owner sender status only affects owner-only tools/commands. If a non-owner can still access a non-owner-only tool on that same agent (for example `canvas`), that is within the granted tool boundary unless the report demonstrates an auth, policy, allowlist, approval, or sandbox bypass.
- Session or memory scoping reduces context bleed, but does **not** create per-user host authorization boundaries.
- For mixed-trust or adversarial users, isolate by OS user/host/gateway and use separate credentials per boundary.
- A company-shared agent can be a valid setup when users are in the same trust boundary and the agent is strictly business-only.
diff --git a/appcast.xml b/appcast.xml
index 4bceb205614..69632c08b97 100644
--- a/appcast.xml
+++ b/appcast.xml
@@ -2,6 +2,98 @@
OpenClaw
+ -
+
2026.3.12
+ Fri, 13 Mar 2026 04:25:50 +0000
+ https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml
+ 2026031290
+ 2026.3.12
+ 15.0
+ OpenClaw 2026.3.12
+Changes
+
+Control UI/dashboard-v2: refresh the gateway dashboard with modular overview, chat, config, agent, and session views, plus a command palette, mobile bottom tabs, and richer chat tools like slash commands, search, export, and pinned messages. (#41503) Thanks @BunsDev.
+OpenAI/GPT-5.4 fast mode: add configurable session-level fast toggles across /fast, TUI, Control UI, and ACP, with per-model config defaults and OpenAI/Codex request shaping.
+Anthropic/Claude fast mode: map the shared /fast toggle and params.fastMode to direct Anthropic API-key service_tier requests, with live verification for both Anthropic and OpenAI fast-mode tiers.
+Models/plugins: move Ollama, vLLM, and SGLang onto the provider-plugin architecture, with provider-owned onboarding, discovery, model-picker setup, and post-selection hooks so core provider wiring is more modular.
+Docs/Kubernetes: Add a starter K8s install path with raw manifests, Kind setup, and deployment docs. Thanks @sallyom @dzianisv @egkristi
+Agents/subagents: add sessions_yield so orchestrators can end the current turn immediately, skip queued tool work, and carry a hidden follow-up payload into the next session turn. (#36537) thanks @jriff
+Slack/agent replies: support channelData.slack.blocks in the shared reply delivery path so agents can send Block Kit messages through standard Slack outbound delivery. (#44592) Thanks @vincentkoc.
+
+Fixes
+
+Security/device pairing: switch /pair and openclaw qr setup codes to short-lived bootstrap tokens so the next release no longer embeds shared gateway credentials in chat or QR pairing payloads. Thanks @lintsinghua.
+Security/plugins: disable implicit workspace plugin auto-load so cloned repositories cannot execute workspace plugin code without an explicit trust decision. (GHSA-99qw-6mr3-36qr)(#44174) Thanks @lintsinghua and @vincentkoc.
+Models/Kimi Coding: send anthropic-messages tools in native Anthropic format again so kimi-coding stops degrading tool calls into XML/plain-text pseudo invocations instead of real tool_use blocks. (#38669, #39907, #40552) Thanks @opriz.
+TUI/chat log: reuse the active assistant message component for the same streaming run so openclaw tui no longer renders duplicate assistant replies. (#35364) Thanks @lisitan.
+Telegram/model picker: make inline model button selections persist the chosen session model correctly, clear overrides when selecting the configured default, and include effective fallback models in /models button validation. (#40105) Thanks @avirweb.
+Cron/proactive delivery: keep isolated direct cron sends out of the write-ahead resend queue so transient-send retries do not replay duplicate proactive messages after restart. (#40646) Thanks @openperf and @vincentkoc.
+Models/Kimi Coding: send the built-in User-Agent: claude-code/0.1.0 header by default for kimi-coding while still allowing explicit provider headers to override it, so Kimi Code subscription auth can work without a local header-injection proxy. (#30099) Thanks @Amineelfarssi and @vincentkoc.
+Models/OpenAI Codex Spark: keep gpt-5.3-codex-spark working on the openai-codex/* path via resolver fallbacks and clearer Codex-only handling, while continuing to suppress the stale direct openai/* Spark row that OpenAI rejects live.
+Ollama/Kimi Cloud: apply the Moonshot Kimi payload compatibility wrapper to Ollama-hosted Kimi models like kimi-k2.5:cloud, so tool routing no longer breaks when thinking is enabled. (#41519) Thanks @vincentkoc.
+Moonshot CN API: respect explicit baseUrl (api.moonshot.cn) in implicit provider resolution so platform.moonshot.cn API keys authenticate correctly instead of returning HTTP 401. (#33637) Thanks @chengzhichao-xydt.
+Kimi Coding/provider config: respect explicit models.providers["kimi-coding"].baseUrl when resolving the implicit provider so custom Kimi Coding endpoints no longer get overwritten by the built-in default. (#36353) Thanks @2233admin.
+Gateway/main-session routing: keep TUI and other mode:UI main-session sends on the internal surface when deliver is enabled, so replies no longer inherit the session's persisted Telegram/WhatsApp route. (#43918) Thanks @obviyus.
+BlueBubbles/self-chat echo dedupe: drop reflected duplicate webhook copies only when a matching fromMe event was just seen for the same chat, body, and timestamp, preventing self-chat loops without broad webhook suppression. Related to #32166. (#38442) Thanks @vincentkoc.
+iMessage/self-chat echo dedupe: drop reflected duplicate copies only when a matching is_from_me event was just seen for the same chat, text, and created_at, preventing self-chat loops without broad text-only suppression. Related to #32166. (#38440) Thanks @vincentkoc.
+Subagents/completion announce retries: raise the default announce timeout to 90 seconds and stop retrying gateway-timeout failures for externally delivered completion announces, preventing duplicate user-facing completion messages after slow gateway responses. Fixes #41235. Thanks @vasujain00 and @vincentkoc.
+Mattermost/block streaming: fix duplicate message delivery (one threaded, one top-level) when block streaming is active by excluding replyToId from the block reply dedup key and adding an explicit threading dock to the Mattermost plugin. (#41362) Thanks @mathiasnagler and @vincentkoc.
+Mattermost/reply media delivery: pass agent-scoped mediaLocalRoots through shared reply delivery so allowed local files upload correctly from button, slash-command, and model-picker replies. (#44021) Thanks @LyleLiu666.
+macOS/Reminders: add the missing NSRemindersUsageDescription to the bundled app so apple-reminders can trigger the system permission prompt from OpenClaw.app. (#8559) Thanks @dinakars777.
+Gateway/session discovery: discover disk-only and retired ACP session stores under custom templated session.store roots so ACP reconciliation, session-id/session-label targeting, and run-id fallback keep working after restart. (#44176) thanks @gumadeiras.
+Plugins/env-scoped roots: fix plugin discovery/load caches and provenance tracking so same-process HOME/OPENCLAW_HOME changes no longer reuse stale plugin state or misreport ~/... plugins as untracked. (#44046) thanks @gumadeiras.
+Models/OpenRouter native ids: canonicalize native OpenRouter model keys across config writes, runtime lookups, fallback management, and models list --plain, and migrate legacy duplicated openrouter/openrouter/... config entries forward on write.
+Windows/native update: make package installs use the npm update path instead of the git path, carry portable Git into native Windows updates, and mirror the installer's Windows npm env so openclaw update no longer dies early on missing git or node-llama-cpp download setup.
+Sandbox/write: preserve pinned mutation-helper payload stdin so sandboxed write no longer reports success while creating empty files. (#43876) Thanks @glitch418x.
+Security/exec approvals: escape invisible Unicode format characters in approval prompts so zero-width command text renders as visible \u{...} escapes instead of spoofing the reviewed command. (GHSA-pcqg-f7rg-xfvv)(#43687) Thanks @EkiXu and @vincentkoc.
+Hooks/loader: fail closed when workspace hook paths cannot be resolved with realpath, so unreadable or broken internal hook paths are skipped instead of falling back to unresolved imports. (#44437) Thanks @vincentkoc.
+Hooks/agent deliveries: dedupe repeated hook requests by optional idempotency key so webhook retries can reuse the first run instead of launching duplicate agent executions. (#44438) Thanks @vincentkoc.
+Security/exec detection: normalize compatibility Unicode and strip invisible formatting code points before obfuscation checks so zero-width and fullwidth command tricks no longer suppress heuristic detection. (GHSA-9r3v-37xh-2cf6)(#44091) Thanks @wooluo and @vincentkoc.
+Security/exec allowlist: preserve POSIX case sensitivity and keep ? within a single path segment so exact-looking allowlist patterns no longer overmatch executables across case or directory boundaries. (GHSA-f8r2-vg7x-gh8m)(#43798) Thanks @zpbrent and @vincentkoc.
+Security/commands: require sender ownership for /config and /debug so authorized non-owner senders can no longer reach owner-only config and runtime debug surfaces. (GHSA-r7vr-gr74-94p8)(#44305) Thanks @tdjackey and @vincentkoc.
+Security/gateway auth: clear unbound client-declared scopes on shared-token WebSocket connects so device-less shared-token operators cannot self-declare elevated scopes. (GHSA-rqpp-rjj8-7wv8)(#44306) Thanks @LUOYEcode and @vincentkoc.
+Security/browser.request: block persistent browser profile create/delete routes from write-scoped browser.request so callers can no longer persist admin-only browser profile changes through the browser control surface. (GHSA-vmhq-cqm9-6p7q)(#43800) Thanks @tdjackey and @vincentkoc.
+Security/agent: reject public spawned-run lineage fields and keep workspace inheritance on the internal spawned-session path so external agent callers can no longer override the gateway workspace boundary. (GHSA-2rqg-gjgv-84jm)(#43801) Thanks @tdjackey and @vincentkoc.
+Security/session_status: enforce sandbox session-tree visibility and shared agent-to-agent access guards before reading or mutating target session state, so sandboxed subagents can no longer inspect parent session metadata or write parent model overrides via session_status. (GHSA-wcxr-59v9-rxr8)(#43754) Thanks @tdjackey and @vincentkoc.
+Security/agent tools: mark nodes as explicitly owner-only and document/test that canvas remains a shared trusted-operator surface unless a real boundary bypass exists.
+Security/exec approvals: fail closed for Ruby approval flows that use -r, --require, or -I so approval-backed commands no longer bind only the main script while extra local code-loading flags remain outside the reviewed file snapshot.
+Security/device pairing: cap issued and verified device-token scopes to each paired device's approved scope baseline so stale or overbroad tokens cannot exceed approved access. (GHSA-2pwv-x786-56f8)(#43686) Thanks @tdjackey and @vincentkoc.
+Docs/onboarding: align the legacy wizard reference and openclaw onboard command docs with the Ollama onboarding flow so all onboarding reference paths now document --auth-choice ollama, Cloud + Local mode, and non-interactive usage. (#43473) Thanks @BruceMacD.
+Models/secrets: enforce source-managed SecretRef markers in generated models.json so runtime-resolved provider secrets are not persisted when runtime projection is skipped. (#43759) Thanks @joshavant.
+Security/WebSocket preauth: shorten unauthenticated handshake retention and reject oversized pre-auth frames before application-layer parsing to reduce pre-pairing exposure on unsupported public deployments. (GHSA-jv4g-m82p-2j93)(#44089) (GHSA-xwx2-ppv2-wx98)(#44089) Thanks @ez-lbz and @vincentkoc.
+Security/proxy attachments: restore the shared media-store size cap for persisted browser proxy files so oversized payloads are rejected instead of overriding the intended 5 MB limit. (GHSA-6rph-mmhp-h7h9)(#43684) Thanks @tdjackey and @vincentkoc.
+Security/host env: block inherited GIT_EXEC_PATH from sanitized host exec environments so Git helper resolution cannot be steered by host environment state. (GHSA-jf5v-pqgw-gm5m)(#43685) Thanks @zpbrent and @vincentkoc.
+Security/Feishu webhook: require encryptKey alongside verificationToken in webhook mode so unsigned forged events are rejected instead of being processed with token-only configuration. (GHSA-g353-mgv3-8pcj)(#44087) Thanks @lintsinghua and @vincentkoc.
+Security/Feishu reactions: preserve looked-up group chat typing and fail closed on ambiguous reaction context so group authorization and mention gating cannot be bypassed through synthetic p2p reactions. (GHSA-m69h-jm2f-2pv8)(#44088) Thanks @zpbrent and @vincentkoc.
+Security/LINE webhook: require signatures for empty-event POST probes too so unsigned requests no longer confirm webhook reachability with a 200 response. (GHSA-mhxh-9pjm-w7q5)(#44090) Thanks @TerminalsandCoffee and @vincentkoc.
+Security/Zalo webhook: rate limit invalid secret guesses before auth so weak webhook secrets cannot be brute-forced through unauthenticated churned requests without pre-auth 429 responses. (GHSA-5m9r-p9g7-679c)(#44173) Thanks @zpbrent and @vincentkoc.
+Security/Zalouser groups: require stable group IDs for allowlist auth by default and gate mutable group-name matching behind channels.zalouser.dangerouslyAllowNameMatching. Thanks @zpbrent.
+Security/Slack and Teams routing: require stable channel and team IDs for allowlist routing by default, with mutable name matching only via each channel's dangerouslyAllowNameMatching break-glass flag.
+Security/exec approvals: fail closed for ambiguous inline loader and shell-payload script execution, bind the real script after POSIX shell value-taking flags, and unwrap pnpm/npm exec/npx script runners before approval binding. (GHSA-57jw-9722-6rf2)(GHSA-jvqh-rfmh-jh27)(GHSA-x7pp-23xv-mmr4)(GHSA-jc5j-vg4r-j5jx)(#44247) Thanks @tdjackey and @vincentkoc.
+Doctor/gateway service audit: canonicalize service entrypoint paths before comparing them so symlink-vs-realpath installs no longer trigger false "entrypoint does not match the current install" repair prompts. (#43882) Thanks @ngutman.
+Doctor/gateway service audit: earlier groundwork for this fix landed in the superseded #28338 branch. Thanks @realriphub.
+Gateway/session stores: regenerate the Swift push-test protocol models and align Windows native session-store realpath handling so protocol checks and sync session discovery stop drifting on Windows. (#44266) thanks @jalehman.
+Context engine/session routing: forward optional sessionKey through context-engine lifecycle calls so plugins can see structured routing metadata during bootstrap, assembly, post-turn ingestion, and compaction. (#44157) thanks @jalehman.
+Agents/failover: classify z.ai network_error stop reasons as retryable timeouts so provider connectivity failures trigger fallback instead of surfacing raw unhandled-stop-reason errors. (#43884) Thanks @hougangdev.
+Memory/session sync: add mode-aware post-compaction session reindexing with agents.defaults.compaction.postIndexSync plus agents.defaults.memorySearch.sync.sessions.postCompactionForce, so compacted session memory can refresh immediately without forcing every deployment into synchronous reindexing. (#25561) thanks @rodrigouroz.
+Telegram/model picker: make inline model button selections persist the chosen session model correctly, clear overrides when selecting the configured default, and include effective fallback models in /models button validation. (#40105) Thanks @avirweb.
+Telegram/native command sync: suppress expected BOT_COMMANDS_TOO_MUCH retry error noise, add a final fallback summary log, and document the difference between command-menu overflow and real Telegram network failures.
+Mattermost/reply media delivery: pass agent-scoped mediaLocalRoots through shared reply delivery so allowed local files upload correctly from button, slash-command, and model-picker replies. (#44021) Thanks @LyleLiu666.
+Plugins/env-scoped roots: fix plugin discovery/load caches and provenance tracking so same-process HOME/OPENCLAW_HOME changes no longer reuse stale plugin state or misreport ~/... plugins as untracked. (#44046) thanks @gumadeiras.
+Gateway/session discovery: discover disk-only and retired ACP session stores under custom templated session.store roots so ACP reconciliation, session-id/session-label targeting, and run-id fallback keep working after restart. (#44176) thanks @gumadeiras.
+Models/OpenRouter native ids: canonicalize native OpenRouter model keys across config writes, runtime lookups, fallback management, and models list --plain, and migrate legacy duplicated openrouter/openrouter/... config entries forward on write.
+Gateway/hooks: bucket hook auth failures by forwarded client IP behind trusted proxies and warn when hooks.allowedAgentIds leaves hook routing unrestricted.
+Agents/compaction: skip the post-compaction cache-ttl marker write when a compaction completed in the same attempt, preventing the next turn from immediately triggering a second tiny compaction. (#28548) thanks @MoerAI.
+Native chat/macOS: add /new, /reset, and /clear reset triggers, keep shared main-session aliases aligned, and ignore stale model-selection completions so native chat state stays in sync across reset and fast model changes. (#10898) Thanks @Nachx639.
+Agents/compaction safeguard: route missing-model and missing-API-key cancellation warnings through the shared subsystem logger so they land in structured and file logs. (#9974) Thanks @dinakars777.
+Cron/doctor: stop flagging canonical agentTurn and systemEvent payload kinds as legacy cron storage, while still normalizing whitespace-padded and non-canonical variants. (#44012) Thanks @shuicici.
+ACP/client final-message delivery: preserve terminal assistant text snapshots before resolving end_turn, so ACP clients no longer drop the last visible reply when the gateway sends the final message body on the terminal chat event. (#17615) Thanks @pjeby.
+Telegram/Discord status reactions: show a temporary compacting reaction during auto-compaction pauses and restore thinking afterward so the bot no longer appears frozen while context is being compacted. (#35474) thanks @Cypherm.
+
+View full changelog
+]]>
+
+
-
2026.3.8-beta.1
Mon, 09 Mar 2026 07:19:57 +0000
@@ -438,225 +530,5 @@
]]>
- -
-
2026.3.2
- Tue, 03 Mar 2026 04:30:29 +0000
- https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml
- 2026030290
- 2026.3.2
- 15.0
- OpenClaw 2026.3.2
-Changes
-
-Secrets/SecretRef coverage: expand SecretRef support across the full supported user-supplied credential surface (64 targets total), including runtime collectors, openclaw secrets planning/apply/audit flows, onboarding SecretInput UX, and related docs; unresolved refs now fail fast on active surfaces while inactive surfaces report non-blocking diagnostics. (#29580) Thanks @joshavant.
-Tools/PDF analysis: add a first-class pdf tool with native Anthropic and Google PDF provider support, extraction fallback for non-native models, configurable defaults (agents.defaults.pdfModel, pdfMaxBytesMb, pdfMaxPages), and docs/tests covering routing, validation, and registration. (#31319) Thanks @tyler6204.
-Outbound adapters/plugins: add shared sendPayload support across direct-text-media, Discord, Slack, WhatsApp, Zalo, and Zalouser with multi-media iteration and chunk-aware text fallback. (#30144) Thanks @nohat.
-Models/MiniMax: add first-class MiniMax-M2.5-highspeed support across built-in provider catalogs, onboarding flows, and MiniMax OAuth plugin defaults, while keeping legacy MiniMax-M2.5-Lightning compatibility for existing configs.
-Sessions/Attachments: add inline file attachment support for sessions_spawn (subagent runtime only) with base64/utf8 encoding, transcript content redaction, lifecycle cleanup, and configurable limits via tools.sessions_spawn.attachments. (#16761) Thanks @napetrov.
-Telegram/Streaming defaults: default channels.telegram.streaming to partial (from off) so new Telegram setups get live preview streaming out of the box, with runtime fallback to message-edit preview when native drafts are unavailable.
-Telegram/DM streaming: use sendMessageDraft for private preview streaming, keep reasoning/answer preview lanes separated in DM reasoning-stream mode. (#31824) Thanks @obviyus.
-Telegram/voice mention gating: add optional disableAudioPreflight on group/topic config to skip mention-detection preflight transcription for inbound voice notes where operators want text-only mention checks. (#23067) Thanks @yangnim21029.
-CLI/Config validation: add openclaw config validate (with --json) to validate config files before gateway startup, and include detailed invalid-key paths in startup invalid-config errors. (#31220) thanks @Sid-Qin.
-Tools/Diffs: add PDF file output support and rendering quality customization controls (fileQuality, fileScale, fileMaxWidth) for generated diff artifacts, and document PDF as the preferred option when messaging channels compress images. (#31342) Thanks @gumadeiras.
-Memory/Ollama embeddings: add memorySearch.provider = "ollama" and memorySearch.fallback = "ollama" support, honor models.providers.ollama settings for memory embedding requests, and document Ollama embedding usage. (#26349) Thanks @nico-hoff.
-Zalo Personal plugin (@openclaw/zalouser): rebuilt channel runtime to use native zca-js integration in-process, removing external CLI transport usage and keeping QR/login + send/listen flows fully inside OpenClaw.
-Plugin SDK/channel extensibility: expose channelRuntime on ChannelGatewayContext so external channel plugins can access shared runtime helpers (reply/routing/session/text/media/commands) without internal imports. (#25462) Thanks @guxiaobo.
-Plugin runtime/STT: add api.runtime.stt.transcribeAudioFile(...) so extensions can transcribe local audio files through OpenClaw's configured media-understanding audio providers. (#22402) Thanks @benthecarman.
-Plugin hooks/session lifecycle: include sessionKey in session_start/session_end hook events and contexts so plugins can correlate lifecycle callbacks with routing identity. (#26394) Thanks @tempeste.
-Hooks/message lifecycle: add internal hook events message:transcribed and message:preprocessed, plus richer outbound message:sent context (isGroup, groupId) for group-conversation correlation and post-transcription automations. (#9859) Thanks @Drickon.
-Media understanding/audio echo: add optional tools.media.audio.echoTranscript + echoFormat to send a pre-agent transcript confirmation message to the originating chat, with echo disabled by default. (#32150) Thanks @AytuncYildizli.
-Plugin runtime/system: expose runtime.system.requestHeartbeatNow(...) so extensions can wake targeted sessions immediately after enqueueing system events. (#19464) Thanks @AustinEral.
-Plugin runtime/events: expose runtime.events.onAgentEvent and runtime.events.onSessionTranscriptUpdate for extension-side subscriptions, and isolate transcript-listener failures so one faulty listener cannot break the entire update fanout. (#16044) Thanks @scifantastic.
-CLI/Banner taglines: add cli.banner.taglineMode (random | default | off) to control funny tagline behavior in startup output, with docs + FAQ guidance and regression tests for config override behavior.
-
-Breaking
-
-BREAKING: Onboarding now defaults tools.profile to messaging for new local installs (interactive + non-interactive). New setups no longer start with broad coding/system tools unless explicitly configured.
-BREAKING: ACP dispatch now defaults to enabled unless explicitly disabled (acp.dispatch.enabled=false). If you need to pause ACP turn routing while keeping /acp controls, set acp.dispatch.enabled=false. Docs: https://docs.openclaw.ai/tools/acp-agents
-BREAKING: Plugin SDK removed api.registerHttpHandler(...). Plugins must register explicit HTTP routes via api.registerHttpRoute({ path, auth, match, handler }), and dynamic webhook lifecycles should use registerPluginHttpRoute(...).
-BREAKING: Zalo Personal plugin (@openclaw/zalouser) no longer depends on external zca-compatible CLI binaries (openzca, zca-cli) for runtime send/listen/login; operators should use openclaw channels login --channel zalouser after upgrade to refresh sessions in the new JS-native path.
-
-Fixes
-
-Plugin command/runtime hardening: validate and normalize plugin command name/description at registration boundaries, and guard Telegram native menu normalization paths so malformed plugin command specs cannot crash startup (trim on undefined). (#31997) Fixes #31944. Thanks @liuxiaopai-ai.
-Telegram: guard duplicate-token checks and gateway startup token normalization when account tokens are missing, preventing token.trim() crashes during status/start flows. (#31973) Thanks @ningding97.
-Discord/lifecycle startup status: push an immediate connected status snapshot when the gateway is already connected before lifecycle debug listeners attach, with abort-guarding to avoid contradictory status flips during pre-aborted startup. (#32336) Thanks @mitchmcalister.
-Feishu/LINE group system prompts: forward per-group systemPrompt config into inbound context GroupSystemPrompt for Feishu and LINE group/room events so configured group-specific behavior actually applies at dispatch time. (#31713) Thanks @whiskyboy.
-Mentions/Slack formatting hardening: add null-safe guards for runtime text normalization paths so malformed/undefined text payloads do not crash mention stripping or mrkdwn conversion. (#31865) Thanks @stone-jin.
-Feishu/Plugin sdk compatibility: add safe webhook default fallbacks when loading Feishu monitor state so mixed-version installs no longer crash if older openclaw/plugin-sdk builds omit webhook default constants. (#31606)
-Feishu/group broadcast dispatch: add configurable multi-agent group broadcast dispatch with observer-session isolation, cross-account dedupe safeguards, and non-mention history buffering rules that avoid duplicate replay in broadcast/topic workflows. (#29575) Thanks @ohmyskyhigh.
-Gateway/Subagent TLS pairing: allow authenticated local gateway-client backend self-connections to skip device pairing while still requiring pairing for non-local/direct-host paths, restoring sessions_spawn with gateway.tls.enabled=true in Docker/LAN setups. Fixes #30740. Thanks @Sid-Qin and @vincentkoc.
-Browser/CDP startup diagnostics: include Chrome stderr output and a Linux no-sandbox hint in startup timeout errors so failed launches are easier to diagnose. (#29312) Thanks @veast.
-Synology Chat/webhook ingress hardening: enforce bounded body reads (size + timeout) via shared request-body guards to prevent unauthenticated slow-body hangs before token validation. (#25831) Thanks @bmendonca3.
-Feishu/Dedup restart resilience: warm persistent dedup state into memory on monitor startup so retry events after gateway restart stay suppressed without requiring initial on-disk probe misses. (#31605)
-Voice-call/runtime lifecycle: prevent EADDRINUSE loops by resetting failed runtime promises, making webhook start() idempotent with the actual bound port, and fully cleaning up webhook/tunnel/tailscale resources after startup failures. (#32395) Thanks @scoootscooob.
-Gateway/Security hardening: tie loopback-origin dev allowance to actual local socket clients (not Host header claims), add explicit warnings/metrics when gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback accepts websocket origins, harden safe-regex detection for quantified ambiguous alternation patterns (for example (a|aa)+), and bound large regex-evaluation inputs for session-filter and log-redaction paths.
-Gateway/Plugin HTTP hardening: require explicit auth for plugin route registration, add route ownership guards for duplicate path+match registrations, centralize plugin path matching/auth logic into dedicated modules, and share webhook target-route lifecycle wiring across channel monitors to avoid stale or conflicting registrations. Thanks @tdjackey for reporting.
-Browser/Profile defaults: prefer openclaw profile over chrome in headless/no-sandbox environments unless an explicit defaultProfile is configured. (#14944) Thanks @BenediktSchackenberg.
-Gateway/WS security: keep plaintext ws:// loopback-only by default, with explicit break-glass private-network opt-in via OPENCLAW_ALLOW_INSECURE_PRIVATE_WS=1; align onboarding/client/call validation and tests to this strict-default policy. (#28670) Thanks @dashed, @vincentkoc.
-OpenAI Codex OAuth/TLS prerequisites: add an OAuth TLS cert-chain preflight with actionable remediation for cert trust failures, and gate doctor TLS prerequisite probing to OpenAI Codex OAuth-configured installs (or explicit doctor --deep) to avoid unconditional outbound probe latency. (#32051) Thanks @alexfilatov.
-Security/Webhook request hardening: enforce auth-before-body parsing for BlueBubbles and Google Chat webhook handlers, add strict pre-auth body/time budgets for webhook auth paths (including LINE signature verification), and add shared in-flight/request guardrails plus regression tests/lint checks to prevent reintroducing unauthenticated slow-body DoS patterns. Thanks @GCXWLP for reporting.
-CLI/Config validation and routing hardening: dedupe openclaw config validate failures to a single authoritative report, expose allowed-values metadata/hints across core Zod and plugin AJV validation (including --json fields), sanitize terminal-rendered validation text, and make command-path parsing root-option-aware across preaction/route/lazy registration (including routed config get/unset with split root options). Thanks @gumadeiras.
-Browser/Extension relay reconnect tolerance: keep /json/version and /cdp reachable during short MV3 worker disconnects when attached targets still exist, and retain clients across reconnect grace windows. (#30232) Thanks @Sid-Qin.
-CLI/Browser start timeout: honor openclaw browser --timeout start and stop by removing the fixed 15000ms override so slower Chrome startups can use caller-provided timeouts. (#22412, #23427) Thanks @vincentkoc.
-Synology Chat/gateway lifecycle: keep startAccount pending until abort for inactive and active account paths to prevent webhook route restart loops under gateway supervision. (#23074) Thanks @druide67.
-Exec approvals/allowlist matching: escape regex metacharacters in path-pattern literals (while preserving glob wildcards), preventing crashes on allowlisted executables like /usr/bin/g++ and correctly matching mixed wildcard/literal token paths. (#32162) Thanks @stakeswky.
-Synology Chat/webhook compatibility: accept JSON and alias payload fields, allow token resolution from body/query/header sources, and ACK webhook requests with 204 to avoid persistent Processing... states in Synology Chat clients. (#26635) Thanks @memphislee09-source.
-Voice-call/Twilio signature verification: retry signature validation across deterministic URL port variants (with/without port) to handle mixed Twilio signing behavior behind reverse proxies and non-standard ports. (#25140) Thanks @drvoss.
-Slack/Bolt startup compatibility: remove invalid message.channels and message.groups event registrations so Slack providers no longer crash on startup with Bolt 4.6+; channel/group traffic continues through the unified message handler (channel_type). (#32033) Thanks @mahopan.
-Slack/socket auth failure handling: fail fast on non-recoverable auth errors (account_inactive, invalid_auth, etc.) during startup and reconnect instead of retry-looping indefinitely, including unable_to_socket_mode_start error payload propagation. (#32377) Thanks @scoootscooob.
-Gateway/macOS LaunchAgent hardening: write Umask=077 in generated gateway LaunchAgent plists so npm upgrades preserve owner-only default file permissions for gateway-created state files. (#31919) Fixes #31905. Thanks @liuxiaopai-ai.
-macOS/LaunchAgent security defaults: write Umask=63 (octal 077) into generated gateway launchd plists so post-update service reinstalls keep owner-only file permissions by default instead of falling back to system 022. (#32022) Fixes #31905. Thanks @liuxiaopai-ai.
-Media understanding/provider HTTP proxy routing: pass a proxy-aware fetch function from HTTPS_PROXY/HTTP_PROXY env vars into audio/video provider calls (with graceful malformed-proxy fallback) so transcription/video requests honor configured outbound proxies. (#27093) Thanks @mcaxtr.
-Sandbox/workspace mount permissions: make primary /workspace bind mounts read-only whenever workspaceAccess is not rw (including none) across both core sandbox container and sandbox browser create flows. (#32227) Thanks @guanyu-zhang.
-Tools/fsPolicy propagation: honor tools.fs.workspaceOnly for image/pdf local-root allowlists so non-sandbox media paths outside workspace are rejected when workspace-only mode is enabled. (#31882) Thanks @justinhuangcode.
-Daemon/Homebrew runtime pinning: resolve Homebrew Cellar Node paths to stable Homebrew-managed symlinks (including versioned formulas like node@22) so gateway installs keep the intended runtime across brew upgrades. (#32185) Thanks @scoootscooob.
-Browser/Security output boundary hardening: replace check-then-rename output commits with root-bound fd-verified writes, unify install/skills canonical path-boundary checks, and add regression coverage for symlink-rebind race paths across browser output and shared fs-safe write flows. Thanks @tdjackey for reporting.
-Gateway/Security canonicalization hardening: decode plugin route path variants to canonical fixpoint (with bounded depth), fail closed on canonicalization anomalies, and enforce gateway auth for deeply encoded /api/channels/* variants to prevent alternate-path auth bypass through plugin handlers. Thanks @tdjackey for reporting.
-Browser/Gateway hardening: preserve env credentials for OPENCLAW_GATEWAY_URL / CLAWDBOT_GATEWAY_URL while treating explicit --url as override-only auth, and make container browser hardening flags optional with safer defaults for Docker/LXC stability. (#31504) Thanks @vincentkoc.
-Gateway/Control UI basePath webhook passthrough: let non-read methods under configured controlUiBasePath fall through to plugin routes (instead of returning Control UI 405), restoring webhook handlers behind basePath mounts. (#32311) Thanks @ademczuk.
-Control UI/Legacy browser compatibility: replace toSorted-dependent cron suggestion sorting in app-render with a compatibility helper so older browsers without Array.prototype.toSorted no longer white-screen. (#31775) Thanks @liuxiaopai-ai.
-macOS/PeekabooBridge: add compatibility socket symlinks for legacy clawdbot, clawdis, and moltbot Application Support socket paths so pre-rename clients can still connect. (#6033) Thanks @lumpinif and @vincentkoc.
-Gateway/message tool reliability: avoid false Unknown channel failures when message.* actions receive platform-specific channel ids by falling back to toolContext.currentChannelProvider, and prevent health-monitor restart thrash for channels that just (re)started by adding a per-channel startup-connect grace window. (from #32367) Thanks @MunemHashmi.
-Windows/Spawn canonicalization: unify non-core Windows spawn handling across ACP client, QMD/mcporter memory paths, and sandbox Docker execution using the shared wrapper-resolution policy, with targeted regression coverage for .cmd shim unwrapping and shell fallback behavior. (#31750) Thanks @Takhoffman.
-Security/ACP sandbox inheritance: enforce fail-closed runtime guardrails for sessions_spawn with runtime="acp" by rejecting ACP spawns from sandboxed requester sessions and rejecting sandbox="require" for ACP runtime, preventing sandbox-boundary bypass via host-side ACP initialization. (#32254) Thanks @tdjackey for reporting, and @dutifulbob for the fix.
-Security/Web tools SSRF guard: keep DNS pinning for untrusted web_fetch and citation-redirect URL checks when proxy env vars are set, and require explicit dangerous opt-in before env-proxy routing can bypass pinned dispatch for trusted/operator-controlled endpoints. Thanks @tdjackey for reporting.
-Gemini schema sanitization: coerce malformed JSON Schema properties values (null, arrays, primitives) to {} before provider validation, preventing downstream strict-validator crashes on invalid plugin/tool schemas. (#32332) Thanks @webdevtodayjason.
-Media understanding/malformed attachment guards: harden attachment selection and decision summary formatting against non-array or malformed attachment payloads to prevent runtime crashes on invalid inbound metadata shapes. (#28024) Thanks @claw9267.
-Browser/Extension navigation reattach: preserve debugger re-attachment when relay is temporarily disconnected by deferring relay attach events until reconnect/re-announce, reducing post-navigation tab loss. (#28725) Thanks @stone-jin.
-Browser/Extension relay stale tabs: evict stale cached targets from /json/list when extension targets are destroyed/crashed or commands fail with missing target/session errors. (#6175) Thanks @vincentkoc.
-Browser/CDP startup readiness: wait for CDP websocket readiness after launching Chrome and cleanly stop/reset when readiness never arrives, reducing follow-up PortInUseError races after browser start/open. (#29538) Thanks @AaronWander.
-OpenAI/Responses WebSocket tool-call id hygiene: normalize blank/whitespace streamed tool-call ids before persistence, and block empty function_call_output.call_id payloads in the WS conversion path to avoid OpenAI 400 errors (Invalid 'input[n].call_id': empty string), with regression coverage for both inbound stream normalization and outbound payload guards.
-Security/Nodes camera URL downloads: bind node camera.snap/camera.clip URL payload downloads to the resolved node host, enforce fail-closed behavior when node remoteIp is unavailable, and use SSRF-guarded fetch with redirect host/protocol checks to prevent off-node fetch pivots. Thanks @tdjackey for reporting.
-Config/backups hardening: enforce owner-only (0600) permissions on rotated config backups and clean orphan .bak.* files outside the managed backup ring, reducing credential leakage risk from stale or permissive backup artifacts. (#31718) Thanks @YUJIE2002.
-Telegram/inbound media filenames: preserve original file_name metadata for document/audio/video/animation downloads (with fetch/path fallbacks), so saved inbound attachments keep sender-provided names instead of opaque Telegram file paths. (#31837) Thanks @Kay-051.
-Gateway/OpenAI chat completions: honor x-openclaw-message-channel when building agentCommand input for /v1/chat/completions, preserving caller channel identity instead of forcing webchat. (#30462) Thanks @bmendonca3.
-Plugin SDK/runtime hardening: add package export verification in CI/release checks to catch missing runtime exports before publish-time regressions. (#28575) Thanks @Glucksberg.
-Media/MIME normalization: normalize parameterized/case-variant MIME strings in kindFromMime (for example Audio/Ogg; codecs=opus) so WhatsApp voice notes are classified as audio and routed through transcription correctly. (#32280) Thanks @Lucenx9.
-Discord/audio preflight mentions: detect audio attachments via Discord content_type and gate preflight transcription on typed text (not media placeholders), so guild voice-note mentions are transcribed and matched correctly. (#32136) Thanks @jnMetaCode.
-Feishu/topic session routing: use thread_id as topic session scope fallback when root_id is absent, keep first-turn topic keys stable across thread creation, and force thread replies when inbound events already carry topic/thread context. (#29788) Thanks @songyaolun.
-Gateway/Webchat NO_REPLY streaming: suppress assistant lead-fragment deltas that are prefixes of NO_REPLY and keep final-message buffering in sync, preventing partial NO leaks on silent-response runs while preserving legitimate short replies. (#32073) Thanks @liuxiaopai-ai.
-Telegram/models picker callbacks: keep long model buttons selectable by falling back to compact callback payloads and resolving provider ids on selection (with provider re-prompt on ambiguity), avoiding Telegram 64-byte callback truncation failures. (#31857) Thanks @bmendonca3.
-Context-window metadata warmup: add exponential config-load retry backoff (1s -> 2s -> 4s, capped at 60s) so transient startup failures recover automatically without hot-loop retries.
-Voice-call/Twilio external outbound: auto-register webhook-first outbound-api calls (initiated outside OpenClaw) so media streams are accepted and call direction metadata stays accurate. (#31181) Thanks @scoootscooob.
-Feishu/topic root replies: prefer root_id as outbound replyTargetMessageId when present, and parse millisecond message_create_time values correctly so topic replies anchor to the root message in grouped thread flows. (#29968) Thanks @bmendonca3.
-Feishu/DM pairing reply target: send pairing challenge replies to chat: instead of user: so Lark/Feishu private chats with user-id-only sender payloads receive pairing messages reliably. (#31403) Thanks @stakeswky.
-Feishu/Lark private DM routing: treat inbound chat_type: "private" as direct-message context for pairing/mention-forward/reaction synthetic handling so Lark private chats behave like Feishu p2p DMs. (#31400) Thanks @stakeswky.
-Signal/message actions: allow react to fall back to toolContext.currentMessageId when messageId is omitted, matching Telegram behavior and unblocking agent-initiated reactions on inbound turns. (#32217) Thanks @dunamismax.
-Discord/message actions: allow react to fall back to toolContext.currentMessageId when messageId is omitted, matching Telegram/Signal reaction ergonomics in inbound turns.
-Synology Chat/reply delivery: resolve webhook usernames to Chat API user_id values for outbound chatbot replies, avoiding mismatches between webhook user IDs and method=chatbot recipient IDs in multi-account setups. (#23709) Thanks @druide67.
-Slack/thread context payloads: only inject thread starter/history text on first thread turn for new sessions while preserving thread metadata, reducing repeated context-token bloat on long-lived thread sessions. (#32133) Thanks @sourman.
-Slack/session routing: keep top-level channel messages in one shared session when replyToMode=off, while preserving thread-scoped keys for true thread replies and non-off modes. (#32193) Thanks @bmendonca3.
-Voice-call/webhook routing: require exact webhook path matches (instead of prefix matches) so lookalike paths cannot reach provider verification/dispatch logic. (#31930) Thanks @afurm.
-Zalo/Pairing auth tests: add webhook regression coverage asserting DM pairing-store reads/writes remain account-scoped, preventing cross-account authorization bleed in multi-account setups. (#26121) Thanks @bmendonca3.
-Zalouser/Pairing auth tests: add account-scoped DM pairing-store regression coverage (monitor.account-scope.test.ts) to prevent cross-account allowlist bleed in multi-account setups. (#26672) Thanks @bmendonca3.
-Feishu/Send target prefixes: normalize explicit group:/dm: send targets and preserve explicit receive-id routing hints when resolving outbound Feishu targets. (#31594) Thanks @liuxiaopai-ai.
-Webchat/Feishu session continuation: preserve routable OriginatingChannel/OriginatingTo metadata from session delivery context in chat.send, and prefer provider-normalized channel when deciding cross-channel route dispatch so Webchat replies continue on the selected Feishu session instead of falling back to main/internal session routing. (#31573)
-Telegram/implicit mention forum handling: exclude Telegram forum system service messages (forum_topic_*, general_forum_topic_*) from reply-chain implicit mention detection so requireMention does not get bypassed inside bot-created topic lifecycle events. (#32262) Thanks @scoootscooob.
-Slack/inbound debounce routing: isolate top-level non-DM message debounce keys by message timestamp to avoid cross-thread collisions, preserve DM batching, and flush pending top-level buffers before immediate non-debounce follow-ups to keep ordering stable. (#31951) Thanks @scoootscooob.
-Feishu/Duplicate replies: suppress same-target reply dispatch when message-tool sends use generic provider metadata (provider: "message") and normalize lark/feishu provider aliases during duplicate-target checks, preventing double-delivery in Feishu sessions. (#31526)
-Webchat/silent token leak: filter assistant NO_REPLY-only transcript entries from chat.history responses and add client-side defense-in-depth guards in the chat controller so internal silent tokens never render as visible chat bubbles. (#32015) Consolidates overlap from #32183, #32082, #32045, #32052, #32172, and #32112. Thanks @ademczuk, @liuxiaopai-ai, @ningding97, @bmendonca3, and @x4v13r1120.
-Doctor/local memory provider checks: stop false-positive local-provider warnings when provider=local and no explicit modelPath is set by honoring default local model fallback while still warning when gateway probe reports local embeddings not ready. (#32014) Fixes #31998. Thanks @adhishthite.
-Media understanding/parakeet CLI output parsing: read parakeet-mlx transcripts from --output-dir/.txt when txt output is requested (or default), with stdout fallback for non-txt formats. (#9177) Thanks @mac-110.
-Media understanding/audio transcription guard: skip tiny/empty audio files (<1024 bytes) before provider/CLI transcription to avoid noisy invalid-audio failures and preserve clean fallback behavior. (#8388) Thanks @Glucksberg.
-Gateway/Plugin HTTP route precedence: run explicit plugin HTTP routes before the Control UI SPA catch-all so registered plugin webhook/custom paths remain reachable, while unmatched paths still fall through to Control UI handling. (#31885) Thanks @Sid-Qin.
-Gateway/Node browser proxy routing: honor profile from browser.request JSON body when query params omit it, while preserving query-profile precedence when both are present. (#28852) Thanks @Sid-Qin.
-Gateway/Control UI basePath POST handling: return 405 for POST on exact basePath routes (for example /openclaw) instead of redirecting, and add end-to-end regression coverage that root-mounted webhook POST paths still pass through to plugin handlers. (#31349) Thanks @Sid-Qin.
-Browser/default profile selection: default browser.defaultProfile behavior now prefers openclaw (managed standalone CDP) when no explicit default is configured, while still auto-provisioning the chrome relay profile for explicit opt-in use. (#32031) Fixes #31907. Thanks @liuxiaopai-ai.
-Sandbox/mkdirp boundary checks: allow existing in-boundary directories to pass mkdirp boundary validation when directory open probes return platform-specific I/O errors, with regression coverage for directory-safe fallback behavior. (#31547) Thanks @stakeswky.
-Models/config env propagation: apply config.env.vars before implicit provider discovery in models bootstrap so config-scoped credentials are visible to implicit provider resolution paths. (#32295) Thanks @hsiaoa.
-Models/Codex usage labels: infer weekly secondary usage windows from reset cadence when API window seconds are ambiguously reported as 24h, so openclaw models status no longer mislabels weekly limits as daily. (#31938) Thanks @bmendonca3.
-Gateway/Heartbeat model reload: treat models.* and agents.defaults.model config updates as heartbeat hot-reload triggers so heartbeat picks up model changes without a full gateway restart. (#32046) Thanks @stakeswky.
-Memory/LanceDB embeddings: forward configured embedding.dimensions into OpenAI embeddings requests so vector size and API output dimensions stay aligned when dimensions are explicitly configured. (#32036) Thanks @scotthuang.
-Gateway/Control UI method guard: allow POST requests to non-UI routes to fall through when no base path is configured, and add POST regression coverage for fallthrough and base-path 405 behavior. (#23970) Thanks @tyler6204.
-Browser/CDP status accuracy: require a successful Browser.getVersion response over the CDP websocket (not just socket-open) before reporting cdpReady, so stale idle command channels are surfaced as unhealthy. (#23427) Thanks @vincentkoc.
-Daemon/systemd checks in containers: treat missing systemctl invocations (including spawn systemctl ENOENT/EACCES) as unavailable service state during is-enabled checks, preventing container flows from failing with Gateway service check failed before install/status handling can continue. (#26089) Thanks @sahilsatralkar and @vincentkoc.
-Security/Node exec approvals: revalidate approval-bound cwd identity immediately before execution/forwarding and fail closed with an explicit denial when cwd drifts after approval hardening.
-Security audit/skills workspace hardening: add skills.workspace.symlink_escape warning in openclaw security audit when workspace skills/**/SKILL.md resolves outside the workspace root (for example symlink-chain drift), plus docs coverage in the security glossary.
-Security/Node exec approvals: preserve shell/dispatch-wrapper argv semantics during approval hardening so approved wrapper commands (for example env sh -c ...) cannot drift into a different runtime command shape, and add regression coverage for both approval-plan generation and approved runtime execution paths. Thanks @tdjackey for reporting.
-Security/fs-safe write hardening: make writeFileWithinRoot use same-directory temp writes plus atomic rename, add post-write inode/hardlink revalidation with security warnings on boundary drift, and avoid truncating existing targets when final rename fails.
-Security/Skills archive extraction: unify tar extraction safety checks across tar.gz and tar.bz2 install flows, enforce tar compressed-size limits, and fail closed if tar.bz2 archives change between preflight and extraction to prevent bypasses of entry-type/size guardrails. Thanks @GCXWLP for reporting.
-Security/Prompt spoofing hardening: stop injecting queued runtime events into user-role prompt text, route them through trusted system-prompt context, and neutralize inbound spoof markers like [System Message] and line-leading System: in untrusted message content. (#30448)
-Sandbox/Docker setup command parsing: accept agents.*.sandbox.docker.setupCommand as either a string or a string array, and normalize arrays to newline-delimited shell scripts so multi-step setup commands no longer concatenate without separators. (#31953) Thanks @liuxiaopai-ai.
-Sandbox/Bootstrap context boundary hardening: reject symlink/hardlink alias bootstrap seed files that resolve outside the source workspace and switch post-compaction AGENTS.md context reads to boundary-verified file opens, preventing host file content from being injected via workspace aliasing. Thanks @tdjackey for reporting.
-Agents/Sandbox workdir mapping: map container workdir paths (for example /workspace) back to the host workspace before sandbox path validation so exec requests keep the intended directory in containerized runs instead of falling back to an unavailable host path. (#31841) Thanks @liuxiaopai-ai.
-Docker/Sandbox bootstrap hardening: make OPENCLAW_SANDBOX opt-in parsing explicit (1|true|yes|on), support custom Docker socket paths via OPENCLAW_DOCKER_SOCKET, defer docker.sock exposure until sandbox prerequisites pass, and reset/roll back persisted sandbox mode to off when setup is skipped or partially fails to avoid stale broken sandbox state. (#29974) Thanks @jamtujest and @vincentkoc.
-Hooks/webhook ACK compatibility: return 200 (instead of 202) for successful /hooks/agent requests so providers that require 200 (for example Forward Email) accept dispatched agent hook deliveries. (#28204) Thanks @Glucksberg.
-Feishu/Run channel fallback: prefer Provider over Surface when inferring queued run messageProvider fallback (when OriginatingChannel is missing), preventing Feishu turns from being mislabeled as webchat in mixed relay metadata contexts. (#31880) Fixes #31859. Thanks @liuxiaopai-ai.
-Skills/sherpa-onnx-tts: run the sherpa-onnx-tts bin under ESM (replace CommonJS require imports) and add regression coverage to prevent require is not defined in ES module scope startup crashes. (#31965) Thanks @bmendonca3.
-Inbound metadata/direct relay context: restore direct-channel conversation metadata blocks for external channels (for example WhatsApp) while preserving webchat-direct suppression, so relay agents recover sender/message identifiers without reintroducing internal webchat metadata noise. (#31969) Fixes #29972. Thanks @Lucenx9.
-Slack/Channel message subscriptions: register explicit message.channels and message.groups monitor handlers (alongside generic message) so channel/group event subscriptions are consumed even when Slack dispatches typed message event names. Fixes #31674.
-Hooks/session-scoped memory context: expose ephemeral sessionId in embedded plugin tool contexts and before_tool_call/after_tool_call hook contexts (including compaction and client-tool wiring) so plugins can isolate per-conversation state across /new and /reset. Related #31253 and #31304. Thanks @Sid-Qin and @Servo-AIpex.
-Voice-call/Twilio inbound greeting: run answered-call initial notify greeting for Twilio instead of skipping the manager speak path, with regression coverage for both Twilio and Plivo notify flows. (#29121) Thanks @xinhuagu.
-Voice-call/stale call hydration: verify active calls with the provider before loading persisted in-progress calls so stale locally persisted records do not block or misroute new call handling after restarts. (#4325) Thanks @garnetlyx.
-Feishu/File upload filenames: percent-encode non-ASCII/special-character file_name values in Feishu multipart uploads so Chinese/symbol-heavy filenames are sent as proper attachments instead of plain text links. (#31179) Thanks @Kay-051.
-Media/MIME channel parity: route Telegram/Signal/iMessage media-kind checks through normalized kindFromMime so mixed-case/parameterized MIME values classify consistently across message channels.
-WhatsApp/inbound self-message context: propagate inbound fromMe through the web inbox pipeline and annotate direct self messages as (self) in envelopes so agents can distinguish owner-authored turns from contact turns. (#32167) Thanks @scoootscooob.
-Webchat/stream finalization: persist streamed assistant text when final events omit message, while keeping final payload precedence and skipping empty stream buffers to prevent disappearing replies after tool turns. (#31920) Thanks @Sid-Qin.
-Feishu/Inbound ordering: serialize message handling per chat while preserving cross-chat concurrency to avoid same-chat race drops under bursty inbound traffic. (#31807)
-Feishu/Typing notification suppression: skip typing keepalive reaction re-adds when the indicator is already active, preventing duplicate notification pings from repeated identical emoji adds. (#31580)
-Feishu/Probe failure backoff: cache API and timeout probe failures for one minute per account key while preserving abort-aware probe timeouts, reducing repeated health-check retries during transient credential/network outages. (#29970)
-Feishu/Streaming block fallback: preserve markdown block stream text as final streaming-card content when final payload text is missing, while still suppressing non-card internal block chunk delivery. (#30663)
-Feishu/Bitable API errors: unify Feishu Bitable tool error handling with structured LarkApiError responses and consistent API/context attribution across wiki/base metadata, field, and record operations. (#31450)
-Feishu/Missing-scope grant URL fix: rewrite known invalid scope aliases (contact:contact.base:readonly) to valid scope names in permission grant links, so remediation URLs open with correct Feishu consent scopes. (#31943)
-BlueBubbles/Message metadata: harden send response ID extraction, include sender identity in DM context, and normalize inbound message_id selection to avoid duplicate ID metadata. (#23970) Thanks @tyler6204.
-WebChat/markdown tables: ensure GitHub-flavored markdown table parsing is explicitly enabled at render time and add horizontal overflow handling for wide tables, with regression coverage for table-only and mixed text+table content. (#32365) Thanks @BlueBirdBack.
-Feishu/default account resolution: always honor explicit channels.feishu.defaultAccount during outbound account selection (including top-level-credential setups where the preferred id is not present in accounts), instead of silently falling back to another account id. (#32253) Thanks @bmendonca3.
-Feishu/Sender lookup permissions: suppress user-facing grant prompts for stale non-existent scope errors (contact:contact.base:readonly) during best-effort sender-name resolution so inbound messages continue without repeated false permission notices. (#31761)
-Discord/dispatch + Slack formatting: restore parallel outbound dispatch across Discord channels with per-channel queues while preserving in-channel ordering, and run Slack preview/stream update text through mrkdwn normalization for consistent formatting. (#31927) Thanks @Sid-Qin.
-Feishu/Inbound debounce: debounce rapid same-chat sender bursts into one ordered dispatch turn, skip already-processed retries when composing merged text, and preserve bot-mention intent across merged entries to reduce duplicate or late inbound handling. (#31548)
-Tests/Sandbox + archive portability: use junction-compatible directory-link setup on Windows and explicit file-symlink platform guards in symlink escape tests where unprivileged file symlinks are unavailable, reducing false Windows CI failures while preserving traversal checks on supported paths. (#28747) Thanks @arosstale.
-Browser/Extension re-announce reliability: keep relay state in connecting when re-announce forwarding fails and extend debugger re-attach retries after navigation to reduce false attached states and post-nav disconnect loops. (#27630) Thanks @markmusson.
-Browser/Act request compatibility: accept legacy flattened action="act" params (kind/ref/text/...) in addition to request={...} so browser act calls no longer fail with request required. (#15120) Thanks @vincentkoc.
-OpenRouter/x-ai compatibility: skip reasoning.effort injection for x-ai/* models (for example Grok) so OpenRouter requests no longer fail with invalid-arguments errors on unsupported reasoning params. (#32054) Thanks @scoootscooob.
-Models/openai-completions developer-role compatibility: force supportsDeveloperRole=false for non-native endpoints, treat unparseable baseUrl values as non-native, and add regression coverage for empty/malformed baseUrl plus explicit-true override behavior. (#29479) thanks @akramcodez.
-Browser/Profile attach-only override: support browser.profiles..attachOnly (fallback to global browser.attachOnly) so loopback proxy profiles can skip local launch/port-ownership checks without forcing attach-only mode for every profile. (#20595) Thanks @unblockedgamesstudio and @vincentkoc.
-Sessions/Lock recovery: detect recycled Linux PIDs by comparing lock-file starttime with /proc//stat starttime, so stale .jsonl.lock files are reclaimed immediately in containerized PID-reuse scenarios while preserving compatibility for older lock files. (#26443) Fixes #27252. Thanks @HirokiKobayashi-R and @vincentkoc.
-Cron/isolated delivery target fallback: remove early unresolved-target return so cron delivery can flow through shared outbound target resolution (including per-channel resolveDefaultTo fallback) when delivery.to is omitted. (#32364) Thanks @hclsys.
-OpenAI media capabilities: include audio in the OpenAI provider capability list so audio transcription models are eligible in media-understanding provider selection. (#12717) Thanks @openjay.
-Browser/Managed tab cap: limit loopback managed openclaw page tabs to 8 via best-effort cleanup after tab opens to reduce long-running renderer buildup while preserving attach-only and remote profile behavior. (#29724) Thanks @pandego.
-Docker/Image health checks: add Dockerfile HEALTHCHECK that probes gateway GET /healthz so container runtimes can mark unhealthy instances without requiring auth credentials in the probe command. (#11478) Thanks @U-C4N and @vincentkoc.
-Gateway/Node dangerous-command parity: include sms.send in default onboarding node denyCommands, share onboarding deny defaults with the gateway dangerous-command source of truth, and include sms.send in phone-control /phone arm writes handling so SMS follows the same break-glass flow as other dangerous node commands. Thanks @zpbrent.
-Pairing/AllowFrom account fallback: handle omitted accountId values in readChannelAllowFromStore and readChannelAllowFromStoreSync as default, while preserving legacy unscoped allowFrom merges for default-account flows. Thanks @Sid-Qin and @vincentkoc.
-Browser/Remote CDP ownership checks: skip local-process ownership errors for non-loopback remote CDP profiles when HTTP is reachable but the websocket handshake fails, and surface the remote websocket attach/retry path instead. (#15582) Landed from contributor (#28780) Thanks @stubbi, @bsormagec, @unblockedgamesstudio and @vincentkoc.
-Browser/CDP proxy bypass: force direct loopback agent paths and scoped NO_PROXY expansion for localhost CDP HTTP/WS connections when proxy env vars are set, so browser relay/control still works behind global proxy settings. (#31469) Thanks @widingmarcus-cyber.
-Sessions/idle reset correctness: preserve existing updatedAt during inbound metadata-only writes so idle-reset boundaries are not unintentionally refreshed before actual user turns. (#32379) Thanks @romeodiaz.
-Sessions/lock recovery: reclaim orphan legacy same-PID lock files missing starttime when no in-process lock ownership exists, avoiding false lock timeouts after PID reuse while preserving active lock safety checks. (#32081) Thanks @bmendonca3.
-Sessions/store cache invalidation: reload cached session stores when file size changes within the same mtime tick by keying cache validation on a single file-stat snapshot (mtimeMs + sizeBytes), with regression coverage for same-tick rewrites. (#32191) Thanks @jalehman.
-Agents/Subagents sessions_spawn: reject malformed agentId inputs before normalization (for example error-message/path-like strings) to prevent unintended synthetic agent IDs and ghost workspace/session paths; includes strict validation regression coverage. (#31381) Thanks @openperf.
-CLI/installer Node preflight: enforce Node.js v22.12+ consistently in both openclaw.mjs runtime bootstrap and installer active-shell checks, with actionable nvm recovery guidance for mismatched shell PATH/defaults. (#32356) Thanks @jasonhargrove.
-Web UI/config form: support SecretInput string-or-secret-ref unions in map additionalProperties, so provider API key fields stay editable instead of being marked unsupported. (#31866) Thanks @ningding97.
-Auto-reply/inline command cleanup: preserve newline structure when stripping inline /status and extracting inline slash commands by collapsing only horizontal whitespace, preventing paragraph flattening in multi-line replies. (#32224) Thanks @scoootscooob.
-Config/raw redaction safety: preserve non-sensitive literals during raw redaction round-trips, scope SecretRef redaction to secret IDs (not structural fields like source/provider), and fall back to structured raw redaction when text replacement cannot restore the original config shape. (#32174) Thanks @bmendonca3.
-Hooks/runtime stability: keep the internal hook handler registry on a globalThis singleton so hook registration/dispatch remains consistent when bundling emits duplicate module copies. (#32292) Thanks @Drickon.
-Hooks/after_tool_call: include embedded session context (sessionKey, agentId) and fire the hook exactly once per tool execution by removing duplicate adapter-path dispatch in embedded runs. (#32201) Thanks @jbeno, @scoootscooob, @vincentkoc.
-Hooks/tool-call correlation: include runId and toolCallId in plugin tool hook payloads/context and scope tool start/adjusted-param tracking by run to prevent cross-run collisions in before_tool_call and after_tool_call. (#32360) Thanks @vincentkoc.
-Plugins/install diagnostics: reject legacy plugin package shapes without openclaw.extensions and return an explicit upgrade hint with troubleshooting docs for repackaging. (#32055) Thanks @liuxiaopai-ai.
-Hooks/plugin context parity: ensure llm_input hooks in embedded attempts receive the same trigger and channelId-aware hookCtx used by the other hook phases, preserving channel/trigger-scoped plugin behavior. (#28623) Thanks @davidrudduck and @vincentkoc.
-Plugins/hardlink install compatibility: allow bundled plugin manifests and entry files to load when installed via hardlink-based package managers (pnpm, bun) while keeping hardlink rejection enabled for non-bundled plugin sources. (#32119) Fixes #28175, #28404, #29455. Thanks @markfietje.
-Cron/session reaper reliability: move cron session reaper sweeps into onTimer finally and keep pruning active even when timer ticks fail early (for example cron store parse failures), preventing stale isolated run sessions from accumulating indefinitely. (#31996) Fixes #31946. Thanks @scoootscooob.
-Cron/HEARTBEAT_OK summary leak: suppress fallback main-session enqueue for heartbeat/internal ack summaries in isolated announce mode so HEARTBEAT_OK noise never appears in user chat while real summaries still forward. (#32093) Thanks @scoootscooob.
-Authentication: classify permission_error as auth_permanent for profile fallback. (#31324) Thanks @Sid-Qin.
-Agents/host edit reliability: treat host edit-tool throws as success only when on-disk post-check confirms replacement likely happened (newText present and oldText absent), preventing false failure reports while avoiding pre-write false positives. (#32383) Thanks @polooooo.
-Plugins/install fallback safety: resolve bare install specs to bundled plugin ids before npm lookup (for example diffs -> bundled @openclaw/diffs), keep npm fallback limited to true package-not-found errors, and continue rejecting non-plugin npm packages that fail manifest validation. (#32096) Thanks @scoootscooob.
-Web UI/inline code copy fidelity: disable forced mid-token wraps on inline spans so copied UUID/hash/token strings preserve exact content instead of inserting line-break spaces. (#32346) Thanks @hclsys.
-Restart sentinel formatting: avoid duplicate Reason: lines when restart message text already matches stats.reason, keeping restart notifications concise for users and downstream parsers. (#32083) Thanks @velamints2.
-Auto-reply/followup queue: avoid stale callback reuse across idle-window restarts by caching the followup runner only when a drain actually starts, preserving enqueue ordering after empty-finalize paths. (#31902) Thanks @Lanfei.
-Agents/tool-result guard: always clear pending tool-call state on interruptions even when synthetic tool results are disabled, preventing orphaned tool-use transcripts that cause follow-up provider request failures. (#32120) Thanks @jnMetaCode.
-Failover/error classification: treat HTTP 529 (provider overloaded, common with Anthropic-compatible APIs) as rate_limit so model failover can engage instead of misclassifying the error path. (#31854) Thanks @bugkill3r.
-Logging: use local time for logged timestamps instead of UTC, aligning log output with documented local timezone behavior and avoiding confusion during local diagnostics. (#28434) Thanks @liuy.
-Agents/Subagent announce cleanup: keep completion-message runs pending while descendants settle, add a 30 minute hard-expiry backstop to avoid indefinite pending state, and keep retry bookkeeping resumable across deferred wakes. (#23970) Thanks @tyler6204.
-Secrets/exec resolver timeout defaults: use provider timeoutMs as the default inactivity (noOutputTimeoutMs) watchdog for exec secret providers, preventing premature no-output kills for resolvers that start producing output after 2s. (#32235) Thanks @bmendonca3.
-Auto-reply/reminder guard note suppression: when a turn makes reminder-like commitments but schedules no new cron jobs, suppress the unscheduled-reminder warning note only if an enabled cron already exists for the same session; keep warnings for unrelated sessions, disabled jobs, or unreadable cron store paths. (#32255) Thanks @scoootscooob.
-Cron/isolated announce heartbeat suppression: treat multi-payload runs as skippable when any payload is a heartbeat ack token and no payload has media, preventing internal narration + trailing HEARTBEAT_OK from being delivered to users. (#32131) Thanks @adhishthite.
-Cron/store migration: normalize legacy cron jobs with string schedule and top-level command/timeout fields into canonical schedule/payload/session-target shape on load, preventing schedule-error loops on old persisted stores. (#31926) Thanks @bmendonca3.
-Tests/Windows backup rotation: skip chmod-only backup permission assertions on Windows while retaining compose/rotation/prune coverage across platforms to avoid false CI failures from Windows non-POSIX mode semantics. (#32286) Thanks @jalehman.
-Tests/Subagent announce: set OPENCLAW_TEST_FAST=1 before importing subagent-announce format suites so module-level fast-mode constants are captured deterministically on Windows CI, preventing timeout flakes in nested completion announce coverage. (#31370) Thanks @zwffff.
-
-View full changelog
-]]>
-
-
-
\ No newline at end of file
diff --git a/apps/android/app/build.gradle.kts b/apps/android/app/build.gradle.kts
index 32306780c72..a7ffff29062 100644
--- a/apps/android/app/build.gradle.kts
+++ b/apps/android/app/build.gradle.kts
@@ -63,8 +63,8 @@ android {
applicationId = "ai.openclaw.app"
minSdk = 31
targetSdk = 36
- versionCode = 202603110
- versionName = "2026.3.11"
+ versionCode = 202603120
+ versionName = "2026.3.12"
ndk {
// Support all major ABIs — native libs are tiny (~47 KB per ABI)
abiFilters += listOf("armeabi-v7a", "arm64-v8a", "x86", "x86_64")
diff --git a/apps/android/app/src/main/java/ai/openclaw/app/MainViewModel.kt b/apps/android/app/src/main/java/ai/openclaw/app/MainViewModel.kt
index a1b6ba3d353..128527144ef 100644
--- a/apps/android/app/src/main/java/ai/openclaw/app/MainViewModel.kt
+++ b/apps/android/app/src/main/java/ai/openclaw/app/MainViewModel.kt
@@ -116,6 +116,10 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
runtime.setGatewayToken(value)
}
+ fun setGatewayBootstrapToken(value: String) {
+ runtime.setGatewayBootstrapToken(value)
+ }
+
fun setGatewayPassword(value: String) {
runtime.setGatewayPassword(value)
}
diff --git a/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt b/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt
index c4e5f6a5b1d..bd94edef93c 100644
--- a/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt
+++ b/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt
@@ -503,6 +503,7 @@ class NodeRuntime(context: Context) {
val gatewayToken: StateFlow = prefs.gatewayToken
val onboardingCompleted: StateFlow = prefs.onboardingCompleted
fun setGatewayToken(value: String) = prefs.setGatewayToken(value)
+ fun setGatewayBootstrapToken(value: String) = prefs.setGatewayBootstrapToken(value)
fun setGatewayPassword(value: String) = prefs.setGatewayPassword(value)
fun setOnboardingCompleted(value: Boolean) = prefs.setOnboardingCompleted(value)
val lastDiscoveredStableId: StateFlow = prefs.lastDiscoveredStableId
@@ -698,10 +699,25 @@ class NodeRuntime(context: Context) {
operatorStatusText = "Connecting…"
updateStatus()
val token = prefs.loadGatewayToken()
+ val bootstrapToken = prefs.loadGatewayBootstrapToken()
val password = prefs.loadGatewayPassword()
val tls = connectionManager.resolveTlsParams(endpoint)
- operatorSession.connect(endpoint, token, password, connectionManager.buildOperatorConnectOptions(), tls)
- nodeSession.connect(endpoint, token, password, connectionManager.buildNodeConnectOptions(), tls)
+ operatorSession.connect(
+ endpoint,
+ token,
+ bootstrapToken,
+ password,
+ connectionManager.buildOperatorConnectOptions(),
+ tls,
+ )
+ nodeSession.connect(
+ endpoint,
+ token,
+ bootstrapToken,
+ password,
+ connectionManager.buildNodeConnectOptions(),
+ tls,
+ )
operatorSession.reconnect()
nodeSession.reconnect()
}
@@ -726,9 +742,24 @@ class NodeRuntime(context: Context) {
nodeStatusText = "Connecting…"
updateStatus()
val token = prefs.loadGatewayToken()
+ val bootstrapToken = prefs.loadGatewayBootstrapToken()
val password = prefs.loadGatewayPassword()
- operatorSession.connect(endpoint, token, password, connectionManager.buildOperatorConnectOptions(), tls)
- nodeSession.connect(endpoint, token, password, connectionManager.buildNodeConnectOptions(), tls)
+ operatorSession.connect(
+ endpoint,
+ token,
+ bootstrapToken,
+ password,
+ connectionManager.buildOperatorConnectOptions(),
+ tls,
+ )
+ nodeSession.connect(
+ endpoint,
+ token,
+ bootstrapToken,
+ password,
+ connectionManager.buildNodeConnectOptions(),
+ tls,
+ )
}
fun acceptGatewayTrustPrompt() {
diff --git a/apps/android/app/src/main/java/ai/openclaw/app/SecurePrefs.kt b/apps/android/app/src/main/java/ai/openclaw/app/SecurePrefs.kt
index b7e72ee4126..a1aabeb1b3c 100644
--- a/apps/android/app/src/main/java/ai/openclaw/app/SecurePrefs.kt
+++ b/apps/android/app/src/main/java/ai/openclaw/app/SecurePrefs.kt
@@ -15,7 +15,10 @@ import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonPrimitive
import java.util.UUID
-class SecurePrefs(context: Context) {
+class SecurePrefs(
+ context: Context,
+ private val securePrefsOverride: SharedPreferences? = null,
+) {
companion object {
val defaultWakeWords: List = listOf("openclaw", "claude")
private const val displayNameKey = "node.displayName"
@@ -35,7 +38,7 @@ class SecurePrefs(context: Context) {
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
}
- private val securePrefs: SharedPreferences by lazy { createSecurePrefs(appContext, securePrefsName) }
+ private val securePrefs: SharedPreferences by lazy { securePrefsOverride ?: createSecurePrefs(appContext, securePrefsName) }
private val _instanceId = MutableStateFlow(loadOrCreateInstanceId())
val instanceId: StateFlow = _instanceId
@@ -76,6 +79,9 @@ class SecurePrefs(context: Context) {
private val _gatewayToken = MutableStateFlow("")
val gatewayToken: StateFlow = _gatewayToken
+ private val _gatewayBootstrapToken = MutableStateFlow("")
+ val gatewayBootstrapToken: StateFlow = _gatewayBootstrapToken
+
private val _onboardingCompleted =
MutableStateFlow(plainPrefs.getBoolean("onboarding.completed", false))
val onboardingCompleted: StateFlow = _onboardingCompleted
@@ -165,6 +171,10 @@ class SecurePrefs(context: Context) {
saveGatewayPassword(value)
}
+ fun setGatewayBootstrapToken(value: String) {
+ saveGatewayBootstrapToken(value)
+ }
+
fun setOnboardingCompleted(value: Boolean) {
plainPrefs.edit { putBoolean("onboarding.completed", value) }
_onboardingCompleted.value = value
@@ -193,6 +203,26 @@ class SecurePrefs(context: Context) {
securePrefs.edit { putString(key, token.trim()) }
}
+ fun loadGatewayBootstrapToken(): String? {
+ val key = "gateway.bootstrapToken.${_instanceId.value}"
+ val stored =
+ _gatewayBootstrapToken.value.trim().ifEmpty {
+ val persisted = securePrefs.getString(key, null)?.trim().orEmpty()
+ if (persisted.isNotEmpty()) {
+ _gatewayBootstrapToken.value = persisted
+ }
+ persisted
+ }
+ return stored.takeIf { it.isNotEmpty() }
+ }
+
+ fun saveGatewayBootstrapToken(token: String) {
+ val key = "gateway.bootstrapToken.${_instanceId.value}"
+ val trimmed = token.trim()
+ securePrefs.edit { putString(key, trimmed) }
+ _gatewayBootstrapToken.value = trimmed
+ }
+
fun loadGatewayPassword(): String? {
val key = "gateway.password.${_instanceId.value}"
val stored = securePrefs.getString(key, null)?.trim()
diff --git a/apps/android/app/src/main/java/ai/openclaw/app/gateway/DeviceAuthStore.kt b/apps/android/app/src/main/java/ai/openclaw/app/gateway/DeviceAuthStore.kt
index d1ac63a90ff..202ea4820e1 100644
--- a/apps/android/app/src/main/java/ai/openclaw/app/gateway/DeviceAuthStore.kt
+++ b/apps/android/app/src/main/java/ai/openclaw/app/gateway/DeviceAuthStore.kt
@@ -5,6 +5,7 @@ import ai.openclaw.app.SecurePrefs
interface DeviceAuthTokenStore {
fun loadToken(deviceId: String, role: String): String?
fun saveToken(deviceId: String, role: String, token: String)
+ fun clearToken(deviceId: String, role: String)
}
class DeviceAuthStore(private val prefs: SecurePrefs) : DeviceAuthTokenStore {
@@ -18,7 +19,7 @@ class DeviceAuthStore(private val prefs: SecurePrefs) : DeviceAuthTokenStore {
prefs.putString(key, token.trim())
}
- fun clearToken(deviceId: String, role: String) {
+ override fun clearToken(deviceId: String, role: String) {
val key = tokenKey(deviceId, role)
prefs.remove(key)
}
diff --git a/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewaySession.kt b/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewaySession.kt
index aee47eaada8..55e371a57c7 100644
--- a/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewaySession.kt
+++ b/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewaySession.kt
@@ -52,6 +52,33 @@ data class GatewayConnectOptions(
val userAgent: String? = null,
)
+private enum class GatewayConnectAuthSource {
+ DEVICE_TOKEN,
+ SHARED_TOKEN,
+ BOOTSTRAP_TOKEN,
+ PASSWORD,
+ NONE,
+}
+
+data class GatewayConnectErrorDetails(
+ val code: String?,
+ val canRetryWithDeviceToken: Boolean,
+ val recommendedNextStep: String?,
+)
+
+private data class SelectedConnectAuth(
+ val authToken: String?,
+ val authBootstrapToken: String?,
+ val authDeviceToken: String?,
+ val authPassword: String?,
+ val signatureToken: String?,
+ val authSource: GatewayConnectAuthSource,
+ val attemptedDeviceTokenRetry: Boolean,
+)
+
+private class GatewayConnectFailure(val gatewayError: GatewaySession.ErrorShape) :
+ IllegalStateException(gatewayError.message)
+
class GatewaySession(
private val scope: CoroutineScope,
private val identityStore: DeviceIdentityStore,
@@ -83,7 +110,11 @@ class GatewaySession(
}
}
- data class ErrorShape(val code: String, val message: String)
+ data class ErrorShape(
+ val code: String,
+ val message: String,
+ val details: GatewayConnectErrorDetails? = null,
+ )
private val json = Json { ignoreUnknownKeys = true }
private val writeLock = Mutex()
@@ -95,6 +126,7 @@ class GatewaySession(
private data class DesiredConnection(
val endpoint: GatewayEndpoint,
val token: String?,
+ val bootstrapToken: String?,
val password: String?,
val options: GatewayConnectOptions,
val tls: GatewayTlsParams?,
@@ -103,15 +135,22 @@ class GatewaySession(
private var desired: DesiredConnection? = null
private var job: Job? = null
@Volatile private var currentConnection: Connection? = null
+ @Volatile private var pendingDeviceTokenRetry = false
+ @Volatile private var deviceTokenRetryBudgetUsed = false
+ @Volatile private var reconnectPausedForAuthFailure = false
fun connect(
endpoint: GatewayEndpoint,
token: String?,
+ bootstrapToken: String?,
password: String?,
options: GatewayConnectOptions,
tls: GatewayTlsParams? = null,
) {
- desired = DesiredConnection(endpoint, token, password, options, tls)
+ desired = DesiredConnection(endpoint, token, bootstrapToken, password, options, tls)
+ pendingDeviceTokenRetry = false
+ deviceTokenRetryBudgetUsed = false
+ reconnectPausedForAuthFailure = false
if (job == null) {
job = scope.launch(Dispatchers.IO) { runLoop() }
}
@@ -119,6 +158,9 @@ class GatewaySession(
fun disconnect() {
desired = null
+ pendingDeviceTokenRetry = false
+ deviceTokenRetryBudgetUsed = false
+ reconnectPausedForAuthFailure = false
currentConnection?.closeQuietly()
scope.launch(Dispatchers.IO) {
job?.cancelAndJoin()
@@ -130,6 +172,7 @@ class GatewaySession(
}
fun reconnect() {
+ reconnectPausedForAuthFailure = false
currentConnection?.closeQuietly()
}
@@ -219,6 +262,7 @@ class GatewaySession(
private inner class Connection(
private val endpoint: GatewayEndpoint,
private val token: String?,
+ private val bootstrapToken: String?,
private val password: String?,
private val options: GatewayConnectOptions,
private val tls: GatewayTlsParams?,
@@ -344,15 +388,48 @@ class GatewaySession(
private suspend fun sendConnect(connectNonce: String) {
val identity = identityStore.loadOrCreate()
- val storedToken = deviceAuthStore.loadToken(identity.deviceId, options.role)
- val trimmedToken = token?.trim().orEmpty()
- // QR/setup/manual shared token must take precedence; stale role tokens can survive re-onboarding.
- val authToken = if (trimmedToken.isNotBlank()) trimmedToken else storedToken.orEmpty()
- val payload = buildConnectParams(identity, connectNonce, authToken, password?.trim())
+ val storedToken = deviceAuthStore.loadToken(identity.deviceId, options.role)?.trim()
+ val selectedAuth =
+ selectConnectAuth(
+ endpoint = endpoint,
+ tls = tls,
+ role = options.role,
+ explicitGatewayToken = token?.trim()?.takeIf { it.isNotEmpty() },
+ explicitBootstrapToken = bootstrapToken?.trim()?.takeIf { it.isNotEmpty() },
+ explicitPassword = password?.trim()?.takeIf { it.isNotEmpty() },
+ storedToken = storedToken?.takeIf { it.isNotEmpty() },
+ )
+ if (selectedAuth.attemptedDeviceTokenRetry) {
+ pendingDeviceTokenRetry = false
+ }
+ val payload =
+ buildConnectParams(
+ identity = identity,
+ connectNonce = connectNonce,
+ selectedAuth = selectedAuth,
+ )
val res = request("connect", payload, timeoutMs = CONNECT_RPC_TIMEOUT_MS)
if (!res.ok) {
- val msg = res.error?.message ?: "connect failed"
- throw IllegalStateException(msg)
+ val error = res.error ?: ErrorShape("UNAVAILABLE", "connect failed")
+ val shouldRetryWithDeviceToken =
+ shouldRetryWithStoredDeviceToken(
+ error = error,
+ explicitGatewayToken = token?.trim()?.takeIf { it.isNotEmpty() },
+ storedToken = storedToken?.takeIf { it.isNotEmpty() },
+ attemptedDeviceTokenRetry = selectedAuth.attemptedDeviceTokenRetry,
+ endpoint = endpoint,
+ tls = tls,
+ )
+ if (shouldRetryWithDeviceToken) {
+ pendingDeviceTokenRetry = true
+ deviceTokenRetryBudgetUsed = true
+ } else if (
+ selectedAuth.attemptedDeviceTokenRetry &&
+ shouldClearStoredDeviceTokenAfterRetry(error)
+ ) {
+ deviceAuthStore.clearToken(identity.deviceId, options.role)
+ }
+ throw GatewayConnectFailure(error)
}
handleConnectSuccess(res, identity.deviceId)
connectDeferred.complete(Unit)
@@ -361,6 +438,9 @@ class GatewaySession(
private fun handleConnectSuccess(res: RpcResponse, deviceId: String) {
val payloadJson = res.payloadJson ?: throw IllegalStateException("connect failed: missing payload")
val obj = json.parseToJsonElement(payloadJson).asObjectOrNull() ?: throw IllegalStateException("connect failed")
+ pendingDeviceTokenRetry = false
+ deviceTokenRetryBudgetUsed = false
+ reconnectPausedForAuthFailure = false
val serverName = obj["server"].asObjectOrNull()?.get("host").asStringOrNull()
val authObj = obj["auth"].asObjectOrNull()
val deviceToken = authObj?.get("deviceToken").asStringOrNull()
@@ -380,8 +460,7 @@ class GatewaySession(
private fun buildConnectParams(
identity: DeviceIdentity,
connectNonce: String,
- authToken: String,
- authPassword: String?,
+ selectedAuth: SelectedConnectAuth,
): JsonObject {
val client = options.client
val locale = Locale.getDefault().toLanguageTag()
@@ -397,16 +476,20 @@ class GatewaySession(
client.modelIdentifier?.let { put("modelIdentifier", JsonPrimitive(it)) }
}
- val password = authPassword?.trim().orEmpty()
val authJson =
when {
- authToken.isNotEmpty() ->
+ selectedAuth.authToken != null ->
buildJsonObject {
- put("token", JsonPrimitive(authToken))
+ put("token", JsonPrimitive(selectedAuth.authToken))
+ selectedAuth.authDeviceToken?.let { put("deviceToken", JsonPrimitive(it)) }
}
- password.isNotEmpty() ->
+ selectedAuth.authBootstrapToken != null ->
buildJsonObject {
- put("password", JsonPrimitive(password))
+ put("bootstrapToken", JsonPrimitive(selectedAuth.authBootstrapToken))
+ }
+ selectedAuth.authPassword != null ->
+ buildJsonObject {
+ put("password", JsonPrimitive(selectedAuth.authPassword))
}
else -> null
}
@@ -420,7 +503,7 @@ class GatewaySession(
role = options.role,
scopes = options.scopes,
signedAtMs = signedAtMs,
- token = if (authToken.isNotEmpty()) authToken else null,
+ token = selectedAuth.signatureToken,
nonce = connectNonce,
platform = client.platform,
deviceFamily = client.deviceFamily,
@@ -483,7 +566,16 @@ class GatewaySession(
frame["error"]?.asObjectOrNull()?.let { obj ->
val code = obj["code"].asStringOrNull() ?: "UNAVAILABLE"
val msg = obj["message"].asStringOrNull() ?: "request failed"
- ErrorShape(code, msg)
+ val detailObj = obj["details"].asObjectOrNull()
+ val details =
+ detailObj?.let {
+ GatewayConnectErrorDetails(
+ code = it["code"].asStringOrNull(),
+ canRetryWithDeviceToken = it["canRetryWithDeviceToken"].asBooleanOrNull() == true,
+ recommendedNextStep = it["recommendedNextStep"].asStringOrNull(),
+ )
+ }
+ ErrorShape(code, msg, details)
}
pending.remove(id)?.complete(RpcResponse(id, ok, payloadJson, error))
}
@@ -607,6 +699,10 @@ class GatewaySession(
delay(250)
continue
}
+ if (reconnectPausedForAuthFailure) {
+ delay(250)
+ continue
+ }
try {
onDisconnected(if (attempt == 0) "Connecting…" else "Reconnecting…")
@@ -615,6 +711,13 @@ class GatewaySession(
} catch (err: Throwable) {
attempt += 1
onDisconnected("Gateway error: ${err.message ?: err::class.java.simpleName}")
+ if (
+ err is GatewayConnectFailure &&
+ shouldPauseReconnectAfterAuthFailure(err.gatewayError)
+ ) {
+ reconnectPausedForAuthFailure = true
+ continue
+ }
val sleepMs = minOf(8_000L, (350.0 * Math.pow(1.7, attempt.toDouble())).toLong())
delay(sleepMs)
}
@@ -622,7 +725,15 @@ class GatewaySession(
}
private suspend fun connectOnce(target: DesiredConnection) = withContext(Dispatchers.IO) {
- val conn = Connection(target.endpoint, target.token, target.password, target.options, target.tls)
+ val conn =
+ Connection(
+ target.endpoint,
+ target.token,
+ target.bootstrapToken,
+ target.password,
+ target.options,
+ target.tls,
+ )
currentConnection = conn
try {
conn.connect()
@@ -698,6 +809,100 @@ class GatewaySession(
if (host == "0.0.0.0" || host == "::") return true
return host.startsWith("127.")
}
+
+ private fun selectConnectAuth(
+ endpoint: GatewayEndpoint,
+ tls: GatewayTlsParams?,
+ role: String,
+ explicitGatewayToken: String?,
+ explicitBootstrapToken: String?,
+ explicitPassword: String?,
+ storedToken: String?,
+ ): SelectedConnectAuth {
+ val shouldUseDeviceRetryToken =
+ pendingDeviceTokenRetry &&
+ explicitGatewayToken != null &&
+ storedToken != null &&
+ isTrustedDeviceRetryEndpoint(endpoint, tls)
+ val authToken =
+ explicitGatewayToken
+ ?: if (
+ explicitPassword == null &&
+ (explicitBootstrapToken == null || storedToken != null)
+ ) {
+ storedToken
+ } else {
+ null
+ }
+ val authDeviceToken = if (shouldUseDeviceRetryToken) storedToken else null
+ val authBootstrapToken = if (authToken == null) explicitBootstrapToken else null
+ val authSource =
+ when {
+ authDeviceToken != null || (explicitGatewayToken == null && authToken != null) ->
+ GatewayConnectAuthSource.DEVICE_TOKEN
+ authToken != null -> GatewayConnectAuthSource.SHARED_TOKEN
+ authBootstrapToken != null -> GatewayConnectAuthSource.BOOTSTRAP_TOKEN
+ explicitPassword != null -> GatewayConnectAuthSource.PASSWORD
+ else -> GatewayConnectAuthSource.NONE
+ }
+ return SelectedConnectAuth(
+ authToken = authToken,
+ authBootstrapToken = authBootstrapToken,
+ authDeviceToken = authDeviceToken,
+ authPassword = explicitPassword,
+ signatureToken = authToken ?: authBootstrapToken,
+ authSource = authSource,
+ attemptedDeviceTokenRetry = shouldUseDeviceRetryToken,
+ )
+ }
+
+ private fun shouldRetryWithStoredDeviceToken(
+ error: ErrorShape,
+ explicitGatewayToken: String?,
+ storedToken: String?,
+ attemptedDeviceTokenRetry: Boolean,
+ endpoint: GatewayEndpoint,
+ tls: GatewayTlsParams?,
+ ): Boolean {
+ if (deviceTokenRetryBudgetUsed) return false
+ if (attemptedDeviceTokenRetry) return false
+ if (explicitGatewayToken == null || storedToken == null) return false
+ if (!isTrustedDeviceRetryEndpoint(endpoint, tls)) return false
+ val detailCode = error.details?.code
+ val recommendedNextStep = error.details?.recommendedNextStep
+ return error.details?.canRetryWithDeviceToken == true ||
+ recommendedNextStep == "retry_with_device_token" ||
+ detailCode == "AUTH_TOKEN_MISMATCH"
+ }
+
+ private fun shouldPauseReconnectAfterAuthFailure(error: ErrorShape): Boolean {
+ return when (error.details?.code) {
+ "AUTH_TOKEN_MISSING",
+ "AUTH_BOOTSTRAP_TOKEN_INVALID",
+ "AUTH_PASSWORD_MISSING",
+ "AUTH_PASSWORD_MISMATCH",
+ "AUTH_RATE_LIMITED",
+ "PAIRING_REQUIRED",
+ "CONTROL_UI_DEVICE_IDENTITY_REQUIRED",
+ "DEVICE_IDENTITY_REQUIRED" -> true
+ "AUTH_TOKEN_MISMATCH" -> deviceTokenRetryBudgetUsed && !pendingDeviceTokenRetry
+ else -> false
+ }
+ }
+
+ private fun shouldClearStoredDeviceTokenAfterRetry(error: ErrorShape): Boolean {
+ return error.details?.code == "AUTH_DEVICE_TOKEN_MISMATCH"
+ }
+
+ private fun isTrustedDeviceRetryEndpoint(
+ endpoint: GatewayEndpoint,
+ tls: GatewayTlsParams?,
+ ): Boolean {
+ if (isLoopbackHost(endpoint.host)) {
+ return true
+ }
+ return tls?.expectedFingerprint?.trim()?.isNotEmpty() == true
+ }
}
private fun JsonElement?.asObjectOrNull(): JsonObject? = this as? JsonObject
diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/ConnectTabScreen.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/ConnectTabScreen.kt
index 4b8ac2c8e5d..5391ff78fe7 100644
--- a/apps/android/app/src/main/java/ai/openclaw/app/ui/ConnectTabScreen.kt
+++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/ConnectTabScreen.kt
@@ -200,8 +200,11 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
viewModel.setManualHost(config.host)
viewModel.setManualPort(config.port)
viewModel.setManualTls(config.tls)
+ viewModel.setGatewayBootstrapToken(config.bootstrapToken)
if (config.token.isNotBlank()) {
viewModel.setGatewayToken(config.token)
+ } else if (config.bootstrapToken.isNotBlank()) {
+ viewModel.setGatewayToken("")
}
viewModel.setGatewayPassword(config.password)
viewModel.connectManual()
diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/GatewayConfigResolver.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/GatewayConfigResolver.kt
index 93b4fc1bb60..9ca5687e594 100644
--- a/apps/android/app/src/main/java/ai/openclaw/app/ui/GatewayConfigResolver.kt
+++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/GatewayConfigResolver.kt
@@ -1,8 +1,8 @@
package ai.openclaw.app.ui
-import androidx.core.net.toUri
import java.util.Base64
import java.util.Locale
+import java.net.URI
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
@@ -18,6 +18,7 @@ internal data class GatewayEndpointConfig(
internal data class GatewaySetupCode(
val url: String,
+ val bootstrapToken: String?,
val token: String?,
val password: String?,
)
@@ -26,6 +27,7 @@ internal data class GatewayConnectConfig(
val host: String,
val port: Int,
val tls: Boolean,
+ val bootstrapToken: String,
val token: String,
val password: String,
)
@@ -44,12 +46,26 @@ internal fun resolveGatewayConnectConfig(
if (useSetupCode) {
val setup = decodeGatewaySetupCode(setupCode) ?: return null
val parsed = parseGatewayEndpoint(setup.url) ?: return null
+ val setupBootstrapToken = setup.bootstrapToken?.trim().orEmpty()
+ val sharedToken =
+ when {
+ !setup.token.isNullOrBlank() -> setup.token.trim()
+ setupBootstrapToken.isNotEmpty() -> ""
+ else -> fallbackToken.trim()
+ }
+ val sharedPassword =
+ when {
+ !setup.password.isNullOrBlank() -> setup.password.trim()
+ setupBootstrapToken.isNotEmpty() -> ""
+ else -> fallbackPassword.trim()
+ }
return GatewayConnectConfig(
host = parsed.host,
port = parsed.port,
tls = parsed.tls,
- token = setup.token ?: fallbackToken.trim(),
- password = setup.password ?: fallbackPassword.trim(),
+ bootstrapToken = setupBootstrapToken,
+ token = sharedToken,
+ password = sharedPassword,
)
}
@@ -59,6 +75,7 @@ internal fun resolveGatewayConnectConfig(
host = parsed.host,
port = parsed.port,
tls = parsed.tls,
+ bootstrapToken = "",
token = fallbackToken.trim(),
password = fallbackPassword.trim(),
)
@@ -69,7 +86,7 @@ internal fun parseGatewayEndpoint(rawInput: String): GatewayEndpointConfig? {
if (raw.isEmpty()) return null
val normalized = if (raw.contains("://")) raw else "https://$raw"
- val uri = normalized.toUri()
+ val uri = runCatching { URI(normalized) }.getOrNull() ?: return null
val host = uri.host?.trim().orEmpty()
if (host.isEmpty()) return null
@@ -104,9 +121,10 @@ internal fun decodeGatewaySetupCode(rawInput: String): GatewaySetupCode? {
val obj = parseJsonObject(decoded) ?: return null
val url = jsonField(obj, "url").orEmpty()
if (url.isEmpty()) return null
+ val bootstrapToken = jsonField(obj, "bootstrapToken")
val token = jsonField(obj, "token")
val password = jsonField(obj, "password")
- GatewaySetupCode(url = url, token = token, password = password)
+ GatewaySetupCode(url = url, bootstrapToken = bootstrapToken, token = token, password = password)
} catch (_: IllegalArgumentException) {
null
}
diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt
index 8810ea93fcb..dc33bdb6836 100644
--- a/apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt
+++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt
@@ -772,8 +772,18 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
return@Button
}
gatewayUrl = parsedSetup.url
- parsedSetup.token?.let { viewModel.setGatewayToken(it) }
- gatewayPassword = parsedSetup.password.orEmpty()
+ viewModel.setGatewayBootstrapToken(parsedSetup.bootstrapToken.orEmpty())
+ val sharedToken = parsedSetup.token.orEmpty().trim()
+ val password = parsedSetup.password.orEmpty().trim()
+ if (sharedToken.isNotEmpty()) {
+ viewModel.setGatewayToken(sharedToken)
+ } else if (!parsedSetup.bootstrapToken.isNullOrBlank()) {
+ viewModel.setGatewayToken("")
+ }
+ gatewayPassword = password
+ if (password.isEmpty() && !parsedSetup.bootstrapToken.isNullOrBlank()) {
+ viewModel.setGatewayPassword("")
+ }
} else {
val manualUrl = composeGatewayManualUrl(manualHost, manualPort, manualTls)
val parsedGateway = manualUrl?.let(::parseGatewayEndpoint)
@@ -782,6 +792,7 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
return@Button
}
gatewayUrl = parsedGateway.displayUrl
+ viewModel.setGatewayBootstrapToken("")
}
step = OnboardingStep.Permissions
},
@@ -850,8 +861,13 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
viewModel.setManualHost(parsed.host)
viewModel.setManualPort(parsed.port)
viewModel.setManualTls(parsed.tls)
+ if (gatewayInputMode == GatewayInputMode.Manual) {
+ viewModel.setGatewayBootstrapToken("")
+ }
if (token.isNotEmpty()) {
viewModel.setGatewayToken(token)
+ } else {
+ viewModel.setGatewayToken("")
}
viewModel.setGatewayPassword(password)
viewModel.connectManual()
diff --git a/apps/android/app/src/test/java/ai/openclaw/app/SecurePrefsTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/SecurePrefsTest.kt
index cd72bf75dff..1ef860e29b4 100644
--- a/apps/android/app/src/test/java/ai/openclaw/app/SecurePrefsTest.kt
+++ b/apps/android/app/src/test/java/ai/openclaw/app/SecurePrefsTest.kt
@@ -20,4 +20,19 @@ class SecurePrefsTest {
assertEquals(LocationMode.WhileUsing, prefs.locationMode.value)
assertEquals("whileUsing", plainPrefs.getString("location.enabledMode", null))
}
+
+ @Test
+ fun saveGatewayBootstrapToken_persistsSeparatelyFromSharedToken() {
+ val context = RuntimeEnvironment.getApplication()
+ val securePrefs = context.getSharedPreferences("openclaw.node.secure.test", Context.MODE_PRIVATE)
+ securePrefs.edit().clear().commit()
+ val prefs = SecurePrefs(context, securePrefsOverride = securePrefs)
+
+ prefs.setGatewayToken("shared-token")
+ prefs.setGatewayBootstrapToken("bootstrap-token")
+
+ assertEquals("shared-token", prefs.loadGatewayToken())
+ assertEquals("bootstrap-token", prefs.loadGatewayBootstrapToken())
+ assertEquals("bootstrap-token", prefs.gatewayBootstrapToken.value)
+ }
}
diff --git a/apps/android/app/src/test/java/ai/openclaw/app/gateway/GatewaySessionInvokeTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/gateway/GatewaySessionInvokeTest.kt
index a3f301498c8..2cfa1be4866 100644
--- a/apps/android/app/src/test/java/ai/openclaw/app/gateway/GatewaySessionInvokeTest.kt
+++ b/apps/android/app/src/test/java/ai/openclaw/app/gateway/GatewaySessionInvokeTest.kt
@@ -27,6 +27,7 @@ import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.annotation.Config
+import java.util.concurrent.atomic.AtomicInteger
import java.util.concurrent.atomic.AtomicReference
private const val TEST_TIMEOUT_MS = 8_000L
@@ -41,11 +42,16 @@ private class InMemoryDeviceAuthStore : DeviceAuthTokenStore {
override fun saveToken(deviceId: String, role: String, token: String) {
tokens["${deviceId.trim()}|${role.trim()}"] = token.trim()
}
+
+ override fun clearToken(deviceId: String, role: String) {
+ tokens.remove("${deviceId.trim()}|${role.trim()}")
+ }
}
private data class NodeHarness(
val session: GatewaySession,
val sessionJob: Job,
+ val deviceAuthStore: InMemoryDeviceAuthStore,
)
private data class InvokeScenarioResult(
@@ -56,6 +62,157 @@ private data class InvokeScenarioResult(
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34])
class GatewaySessionInvokeTest {
+ @Test
+ fun connect_usesBootstrapTokenWhenSharedAndDeviceTokensAreAbsent() = runBlocking {
+ val json = testJson()
+ val connected = CompletableDeferred()
+ val connectAuth = CompletableDeferred()
+ val lastDisconnect = AtomicReference("")
+ val server =
+ startGatewayServer(json) { webSocket, id, method, frame ->
+ when (method) {
+ "connect" -> {
+ if (!connectAuth.isCompleted) {
+ connectAuth.complete(frame["params"]?.jsonObject?.get("auth")?.jsonObject)
+ }
+ webSocket.send(connectResponseFrame(id))
+ webSocket.close(1000, "done")
+ }
+ }
+ }
+
+ val harness =
+ createNodeHarness(
+ connected = connected,
+ lastDisconnect = lastDisconnect,
+ ) { GatewaySession.InvokeResult.ok("""{"handled":true}""") }
+
+ try {
+ connectNodeSession(
+ session = harness.session,
+ port = server.port,
+ token = null,
+ bootstrapToken = "bootstrap-token",
+ )
+ awaitConnectedOrThrow(connected, lastDisconnect, server)
+
+ val auth = withTimeout(TEST_TIMEOUT_MS) { connectAuth.await() }
+ assertEquals("bootstrap-token", auth?.get("bootstrapToken")?.jsonPrimitive?.content)
+ assertNull(auth?.get("token"))
+ } finally {
+ shutdownHarness(harness, server)
+ }
+ }
+
+ @Test
+ fun connect_prefersStoredDeviceTokenOverBootstrapToken() = runBlocking {
+ val json = testJson()
+ val connected = CompletableDeferred()
+ val connectAuth = CompletableDeferred()
+ val lastDisconnect = AtomicReference("")
+ val server =
+ startGatewayServer(json) { webSocket, id, method, frame ->
+ when (method) {
+ "connect" -> {
+ if (!connectAuth.isCompleted) {
+ connectAuth.complete(frame["params"]?.jsonObject?.get("auth")?.jsonObject)
+ }
+ webSocket.send(connectResponseFrame(id))
+ webSocket.close(1000, "done")
+ }
+ }
+ }
+
+ val harness =
+ createNodeHarness(
+ connected = connected,
+ lastDisconnect = lastDisconnect,
+ ) { GatewaySession.InvokeResult.ok("""{"handled":true}""") }
+
+ try {
+ val deviceId = DeviceIdentityStore(RuntimeEnvironment.getApplication()).loadOrCreate().deviceId
+ harness.deviceAuthStore.saveToken(deviceId, "node", "device-token")
+
+ connectNodeSession(
+ session = harness.session,
+ port = server.port,
+ token = null,
+ bootstrapToken = "bootstrap-token",
+ )
+ awaitConnectedOrThrow(connected, lastDisconnect, server)
+
+ val auth = withTimeout(TEST_TIMEOUT_MS) { connectAuth.await() }
+ assertEquals("device-token", auth?.get("token")?.jsonPrimitive?.content)
+ assertNull(auth?.get("bootstrapToken"))
+ } finally {
+ shutdownHarness(harness, server)
+ }
+ }
+
+ @Test
+ fun connect_retriesWithStoredDeviceTokenAfterSharedTokenMismatch() = runBlocking {
+ val json = testJson()
+ val connected = CompletableDeferred()
+ val firstConnectAuth = CompletableDeferred()
+ val secondConnectAuth = CompletableDeferred()
+ val connectAttempts = AtomicInteger(0)
+ val lastDisconnect = AtomicReference("")
+ val server =
+ startGatewayServer(json) { webSocket, id, method, frame ->
+ when (method) {
+ "connect" -> {
+ val auth = frame["params"]?.jsonObject?.get("auth")?.jsonObject
+ when (connectAttempts.incrementAndGet()) {
+ 1 -> {
+ if (!firstConnectAuth.isCompleted) {
+ firstConnectAuth.complete(auth)
+ }
+ webSocket.send(
+ """{"type":"res","id":"$id","ok":false,"error":{"code":"INVALID_REQUEST","message":"unauthorized","details":{"code":"AUTH_TOKEN_MISMATCH","canRetryWithDeviceToken":true,"recommendedNextStep":"retry_with_device_token"}}}""",
+ )
+ webSocket.close(1000, "retry")
+ }
+ else -> {
+ if (!secondConnectAuth.isCompleted) {
+ secondConnectAuth.complete(auth)
+ }
+ webSocket.send(connectResponseFrame(id))
+ webSocket.close(1000, "done")
+ }
+ }
+ }
+ }
+ }
+
+ val harness =
+ createNodeHarness(
+ connected = connected,
+ lastDisconnect = lastDisconnect,
+ ) { GatewaySession.InvokeResult.ok("""{"handled":true}""") }
+
+ try {
+ val deviceId = DeviceIdentityStore(RuntimeEnvironment.getApplication()).loadOrCreate().deviceId
+ harness.deviceAuthStore.saveToken(deviceId, "node", "stored-device-token")
+
+ connectNodeSession(
+ session = harness.session,
+ port = server.port,
+ token = "shared-auth-token",
+ bootstrapToken = null,
+ )
+ awaitConnectedOrThrow(connected, lastDisconnect, server)
+
+ val firstAuth = withTimeout(TEST_TIMEOUT_MS) { firstConnectAuth.await() }
+ val secondAuth = withTimeout(TEST_TIMEOUT_MS) { secondConnectAuth.await() }
+ assertEquals("shared-auth-token", firstAuth?.get("token")?.jsonPrimitive?.content)
+ assertNull(firstAuth?.get("deviceToken"))
+ assertEquals("shared-auth-token", secondAuth?.get("token")?.jsonPrimitive?.content)
+ assertEquals("stored-device-token", secondAuth?.get("deviceToken")?.jsonPrimitive?.content)
+ } finally {
+ shutdownHarness(harness, server)
+ }
+ }
+
@Test
fun nodeInvokeRequest_roundTripsInvokeResult() = runBlocking {
val handshakeOrigin = AtomicReference(null)
@@ -182,11 +339,12 @@ class GatewaySessionInvokeTest {
): NodeHarness {
val app = RuntimeEnvironment.getApplication()
val sessionJob = SupervisorJob()
+ val deviceAuthStore = InMemoryDeviceAuthStore()
val session =
GatewaySession(
scope = CoroutineScope(sessionJob + Dispatchers.Default),
identityStore = DeviceIdentityStore(app),
- deviceAuthStore = InMemoryDeviceAuthStore(),
+ deviceAuthStore = deviceAuthStore,
onConnected = { _, _, _ ->
if (!connected.isCompleted) connected.complete(Unit)
},
@@ -197,10 +355,15 @@ class GatewaySessionInvokeTest {
onInvoke = onInvoke,
)
- return NodeHarness(session = session, sessionJob = sessionJob)
+ return NodeHarness(session = session, sessionJob = sessionJob, deviceAuthStore = deviceAuthStore)
}
- private suspend fun connectNodeSession(session: GatewaySession, port: Int) {
+ private suspend fun connectNodeSession(
+ session: GatewaySession,
+ port: Int,
+ token: String? = "test-token",
+ bootstrapToken: String? = null,
+ ) {
session.connect(
endpoint =
GatewayEndpoint(
@@ -210,7 +373,8 @@ class GatewaySessionInvokeTest {
port = port,
tlsEnabled = false,
),
- token = "test-token",
+ token = token,
+ bootstrapToken = bootstrapToken,
password = null,
options =
GatewayConnectOptions(
diff --git a/apps/android/app/src/test/java/ai/openclaw/app/ui/GatewayConfigResolverTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/ui/GatewayConfigResolverTest.kt
index 72738843ff0..a4eef3b9b09 100644
--- a/apps/android/app/src/test/java/ai/openclaw/app/ui/GatewayConfigResolverTest.kt
+++ b/apps/android/app/src/test/java/ai/openclaw/app/ui/GatewayConfigResolverTest.kt
@@ -8,7 +8,8 @@ import org.junit.Test
class GatewayConfigResolverTest {
@Test
fun resolveScannedSetupCodeAcceptsRawSetupCode() {
- val setupCode = encodeSetupCode("""{"url":"wss://gateway.example:18789","token":"token-1"}""")
+ val setupCode =
+ encodeSetupCode("""{"url":"wss://gateway.example:18789","bootstrapToken":"bootstrap-1"}""")
val resolved = resolveScannedSetupCode(setupCode)
@@ -17,7 +18,8 @@ class GatewayConfigResolverTest {
@Test
fun resolveScannedSetupCodeAcceptsQrJsonPayload() {
- val setupCode = encodeSetupCode("""{"url":"wss://gateway.example:18789","password":"pw-1"}""")
+ val setupCode =
+ encodeSetupCode("""{"url":"wss://gateway.example:18789","bootstrapToken":"bootstrap-1"}""")
val qrJson =
"""
{
@@ -53,6 +55,43 @@ class GatewayConfigResolverTest {
assertNull(resolved)
}
+ @Test
+ fun decodeGatewaySetupCodeParsesBootstrapToken() {
+ val setupCode =
+ encodeSetupCode("""{"url":"wss://gateway.example:18789","bootstrapToken":"bootstrap-1"}""")
+
+ val decoded = decodeGatewaySetupCode(setupCode)
+
+ assertEquals("wss://gateway.example:18789", decoded?.url)
+ assertEquals("bootstrap-1", decoded?.bootstrapToken)
+ assertNull(decoded?.token)
+ assertNull(decoded?.password)
+ }
+
+ @Test
+ fun resolveGatewayConnectConfigPrefersBootstrapTokenFromSetupCode() {
+ val setupCode =
+ encodeSetupCode("""{"url":"wss://gateway.example:18789","bootstrapToken":"bootstrap-1"}""")
+
+ val resolved =
+ resolveGatewayConnectConfig(
+ useSetupCode = true,
+ setupCode = setupCode,
+ manualHost = "",
+ manualPort = "",
+ manualTls = true,
+ fallbackToken = "shared-token",
+ fallbackPassword = "shared-password",
+ )
+
+ assertEquals("gateway.example", resolved?.host)
+ assertEquals(18789, resolved?.port)
+ assertEquals(true, resolved?.tls)
+ assertEquals("bootstrap-1", resolved?.bootstrapToken)
+ assertNull(resolved?.token?.takeIf { it.isNotEmpty() })
+ assertNull(resolved?.password?.takeIf { it.isNotEmpty() })
+ }
+
private fun encodeSetupCode(payloadJson: String): String {
return Base64.getUrlEncoder().withoutPadding().encodeToString(payloadJson.toByteArray(Charsets.UTF_8))
}
diff --git a/apps/ios/ActivityWidget/OpenClawLiveActivity.swift b/apps/ios/ActivityWidget/OpenClawLiveActivity.swift
index 836803f403f..497fbd45a08 100644
--- a/apps/ios/ActivityWidget/OpenClawLiveActivity.swift
+++ b/apps/ios/ActivityWidget/OpenClawLiveActivity.swift
@@ -47,6 +47,7 @@ struct OpenClawLiveActivity: Widget {
Spacer()
trailingView(state: context.state)
}
+ .padding(.horizontal, 12)
.padding(.vertical, 4)
}
diff --git a/apps/ios/README.md b/apps/ios/README.md
index 6eb35a1d639..0e78d8cf0d9 100644
--- a/apps/ios/README.md
+++ b/apps/ios/README.md
@@ -62,11 +62,17 @@ Release behavior:
- Local development keeps using unique per-developer bundle IDs from `scripts/ios-configure-signing.sh`.
- Beta release uses canonical `ai.openclaw.client*` bundle IDs through a temporary generated xcconfig in `apps/ios/build/BetaRelease.xcconfig`.
+- Beta release also switches the app to `OpenClawPushTransport=relay`, `OpenClawPushDistribution=official`, and `OpenClawPushAPNsEnvironment=production`.
- The beta flow does not modify `apps/ios/.local-signing.xcconfig` or `apps/ios/LocalSigning.xcconfig`.
- Root `package.json.version` is the only version source for iOS.
-- A root version like `2026.3.11-beta.1` becomes:
- - `CFBundleShortVersionString = 2026.3.11`
- - `CFBundleVersion = next TestFlight build number for 2026.3.11`
+- A root version like `2026.3.12-beta.1` becomes:
+ - `CFBundleShortVersionString = 2026.3.12`
+ - `CFBundleVersion = next TestFlight build number for 2026.3.12`
+
+Required env for beta builds:
+
+- `OPENCLAW_PUSH_RELAY_BASE_URL=https://relay.example.com`
+ This must be a plain `https://host[:port][/path]` base URL without whitespace, query params, fragments, or xcconfig metacharacters.
Archive without upload:
@@ -91,9 +97,43 @@ pnpm ios:beta -- --build-number 7
- The app calls `registerForRemoteNotifications()` at launch.
- `apps/ios/Sources/OpenClaw.entitlements` sets `aps-environment` to `development`.
- APNs token registration to gateway happens only after gateway connection (`push.apns.register`).
+- Local/manual builds default to `OpenClawPushTransport=direct` and `OpenClawPushDistribution=local`.
- Your selected team/profile must support Push Notifications for the app bundle ID you are signing.
- If push capability or provisioning is wrong, APNs registration fails at runtime (check Xcode logs for `APNs registration failed`).
-- Debug builds register as APNs sandbox; Release builds use production.
+- Debug builds default to `OpenClawPushAPNsEnvironment=sandbox`; Release builds default to `production`.
+
+## APNs Expectations For Official Builds
+
+- Official/TestFlight builds register with the external push relay before they publish `push.apns.register` to the gateway.
+- The gateway registration for relay mode contains an opaque relay handle, a registration-scoped send grant, relay origin metadata, and installation metadata instead of the raw APNs token.
+- The relay registration is bound to the gateway identity fetched from `gateway.identity.get`, so another gateway cannot reuse that stored registration.
+- The app persists the relay handle metadata locally so reconnects can republish the gateway registration without re-registering on every connect.
+- If the relay base URL changes in a later build, the app refreshes the relay registration instead of reusing the old relay origin.
+- Relay mode requires a reachable relay base URL and uses App Attest plus the app receipt during registration.
+- Gateway-side relay sending is configured through `gateway.push.apns.relay.baseUrl` in `openclaw.json`. `OPENCLAW_APNS_RELAY_BASE_URL` remains a temporary env override only.
+
+## Official Build Relay Trust Model
+
+- `iOS -> gateway`
+ - The app must pair with the gateway and establish both node and operator sessions.
+ - The operator session is used to fetch `gateway.identity.get`.
+- `iOS -> relay`
+ - The app registers with the relay over HTTPS using App Attest plus the app receipt.
+ - The relay requires the official production/TestFlight distribution path, which is why local
+ Xcode/dev installs cannot use the hosted relay.
+- `gateway delegation`
+ - The app includes the gateway identity in relay registration.
+ - The relay returns a relay handle and registration-scoped send grant delegated to that gateway.
+- `gateway -> relay`
+ - The gateway signs relay send requests with its own device identity.
+ - The relay verifies both the delegated send grant and the gateway signature before it sends to
+ APNs.
+- `relay -> APNs`
+ - Production APNs credentials and raw official-build APNs tokens stay in the relay deployment,
+ not on the gateway.
+
+This exists to keep the hosted relay limited to genuine OpenClaw official builds and to ensure a
+gateway can only send pushes for iOS devices that paired with that gateway.
## What Works Now (Concrete)
diff --git a/apps/ios/Sources/Chat/IOSGatewayChatTransport.swift b/apps/ios/Sources/Chat/IOSGatewayChatTransport.swift
index 67f01138803..297811d3ee7 100644
--- a/apps/ios/Sources/Chat/IOSGatewayChatTransport.swift
+++ b/apps/ios/Sources/Chat/IOSGatewayChatTransport.swift
@@ -39,6 +39,13 @@ struct IOSGatewayChatTransport: OpenClawChatTransport, Sendable {
// (chat.subscribe is a node event, not an operator RPC method.)
}
+ func resetSession(sessionKey: String) async throws {
+ struct Params: Codable { var key: String }
+ let data = try JSONEncoder().encode(Params(key: sessionKey))
+ let json = String(data: data, encoding: .utf8)
+ _ = try await self.gateway.request(method: "sessions.reset", paramsJSON: json, timeoutSeconds: 10)
+ }
+
func requestHistory(sessionKey: String) async throws -> OpenClawChatHistoryPayload {
struct Params: Codable { var sessionKey: String }
let data = try JSONEncoder().encode(Params(sessionKey: sessionKey))
diff --git a/apps/ios/Sources/Gateway/GatewayConnectConfig.swift b/apps/ios/Sources/Gateway/GatewayConnectConfig.swift
index 7f4e93380b0..0abea0e312c 100644
--- a/apps/ios/Sources/Gateway/GatewayConnectConfig.swift
+++ b/apps/ios/Sources/Gateway/GatewayConnectConfig.swift
@@ -14,6 +14,7 @@ struct GatewayConnectConfig: Sendable {
let stableID: String
let tls: GatewayTLSParams?
let token: String?
+ let bootstrapToken: String?
let password: String?
let nodeOptions: GatewayConnectOptions
diff --git a/apps/ios/Sources/Gateway/GatewayConnectionController.swift b/apps/ios/Sources/Gateway/GatewayConnectionController.swift
index 259768a4df1..dc94f3d0797 100644
--- a/apps/ios/Sources/Gateway/GatewayConnectionController.swift
+++ b/apps/ios/Sources/Gateway/GatewayConnectionController.swift
@@ -101,6 +101,7 @@ final class GatewayConnectionController {
return "Missing instanceId (node.instanceId). Try restarting the app."
}
let token = GatewaySettingsStore.loadGatewayToken(instanceId: instanceId)
+ let bootstrapToken = GatewaySettingsStore.loadGatewayBootstrapToken(instanceId: instanceId)
let password = GatewaySettingsStore.loadGatewayPassword(instanceId: instanceId)
// Resolve the service endpoint (SRV/A/AAAA). TXT is unauthenticated; do not route via TXT.
@@ -151,6 +152,7 @@ final class GatewayConnectionController {
gatewayStableID: stableID,
tls: tlsParams,
token: token,
+ bootstrapToken: bootstrapToken,
password: password)
return nil
}
@@ -163,6 +165,7 @@ final class GatewayConnectionController {
let instanceId = UserDefaults.standard.string(forKey: "node.instanceId")?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let token = GatewaySettingsStore.loadGatewayToken(instanceId: instanceId)
+ let bootstrapToken = GatewaySettingsStore.loadGatewayBootstrapToken(instanceId: instanceId)
let password = GatewaySettingsStore.loadGatewayPassword(instanceId: instanceId)
let resolvedUseTLS = self.resolveManualUseTLS(host: host, useTLS: useTLS)
guard let resolvedPort = self.resolveManualPort(host: host, port: port, useTLS: resolvedUseTLS)
@@ -203,6 +206,7 @@ final class GatewayConnectionController {
gatewayStableID: stableID,
tls: tlsParams,
token: token,
+ bootstrapToken: bootstrapToken,
password: password)
}
@@ -229,6 +233,7 @@ final class GatewayConnectionController {
stableID: cfg.stableID,
tls: cfg.tls,
token: cfg.token,
+ bootstrapToken: cfg.bootstrapToken,
password: cfg.password,
nodeOptions: self.makeConnectOptions(stableID: cfg.stableID))
appModel.applyGatewayConnectConfig(refreshedConfig)
@@ -261,6 +266,7 @@ final class GatewayConnectionController {
let instanceId = UserDefaults.standard.string(forKey: "node.instanceId")?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let token = GatewaySettingsStore.loadGatewayToken(instanceId: instanceId)
+ let bootstrapToken = GatewaySettingsStore.loadGatewayBootstrapToken(instanceId: instanceId)
let password = GatewaySettingsStore.loadGatewayPassword(instanceId: instanceId)
let tlsParams = GatewayTLSParams(
required: true,
@@ -274,6 +280,7 @@ final class GatewayConnectionController {
gatewayStableID: pending.stableID,
tls: tlsParams,
token: token,
+ bootstrapToken: bootstrapToken,
password: password)
}
@@ -319,6 +326,7 @@ final class GatewayConnectionController {
guard !instanceId.isEmpty else { return }
let token = GatewaySettingsStore.loadGatewayToken(instanceId: instanceId)
+ let bootstrapToken = GatewaySettingsStore.loadGatewayBootstrapToken(instanceId: instanceId)
let password = GatewaySettingsStore.loadGatewayPassword(instanceId: instanceId)
if manualEnabled {
@@ -353,6 +361,7 @@ final class GatewayConnectionController {
gatewayStableID: stableID,
tls: tlsParams,
token: token,
+ bootstrapToken: bootstrapToken,
password: password)
return
}
@@ -379,6 +388,7 @@ final class GatewayConnectionController {
gatewayStableID: stableID,
tls: tlsParams,
token: token,
+ bootstrapToken: bootstrapToken,
password: password)
return
}
@@ -448,6 +458,7 @@ final class GatewayConnectionController {
gatewayStableID: String,
tls: GatewayTLSParams?,
token: String?,
+ bootstrapToken: String?,
password: String?)
{
guard let appModel else { return }
@@ -463,6 +474,7 @@ final class GatewayConnectionController {
stableID: gatewayStableID,
tls: tls,
token: token,
+ bootstrapToken: bootstrapToken,
password: password,
nodeOptions: connectOptions)
appModel.applyGatewayConnectConfig(cfg)
diff --git a/apps/ios/Sources/Gateway/GatewaySettingsStore.swift b/apps/ios/Sources/Gateway/GatewaySettingsStore.swift
index 37c039d69d1..92dc71259e5 100644
--- a/apps/ios/Sources/Gateway/GatewaySettingsStore.swift
+++ b/apps/ios/Sources/Gateway/GatewaySettingsStore.swift
@@ -104,6 +104,21 @@ enum GatewaySettingsStore {
account: self.gatewayTokenAccount(instanceId: instanceId))
}
+ static func loadGatewayBootstrapToken(instanceId: String) -> String? {
+ let account = self.gatewayBootstrapTokenAccount(instanceId: instanceId)
+ let token = KeychainStore.loadString(service: self.gatewayService, account: account)?
+ .trimmingCharacters(in: .whitespacesAndNewlines)
+ if token?.isEmpty == false { return token }
+ return nil
+ }
+
+ static func saveGatewayBootstrapToken(_ token: String, instanceId: String) {
+ _ = KeychainStore.saveString(
+ token,
+ service: self.gatewayService,
+ account: self.gatewayBootstrapTokenAccount(instanceId: instanceId))
+ }
+
static func loadGatewayPassword(instanceId: String) -> String? {
KeychainStore.loadString(
service: self.gatewayService,
@@ -278,6 +293,9 @@ enum GatewaySettingsStore {
_ = KeychainStore.delete(
service: self.gatewayService,
account: self.gatewayTokenAccount(instanceId: trimmed))
+ _ = KeychainStore.delete(
+ service: self.gatewayService,
+ account: self.gatewayBootstrapTokenAccount(instanceId: trimmed))
_ = KeychainStore.delete(
service: self.gatewayService,
account: self.gatewayPasswordAccount(instanceId: trimmed))
@@ -331,6 +349,10 @@ enum GatewaySettingsStore {
"gateway-token.\(instanceId)"
}
+ private static func gatewayBootstrapTokenAccount(instanceId: String) -> String {
+ "gateway-bootstrap-token.\(instanceId)"
+ }
+
private static func gatewayPasswordAccount(instanceId: String) -> String {
"gateway-password.\(instanceId)"
}
diff --git a/apps/ios/Sources/Gateway/GatewaySetupCode.swift b/apps/ios/Sources/Gateway/GatewaySetupCode.swift
index 8ccbab42da7..d52ca023563 100644
--- a/apps/ios/Sources/Gateway/GatewaySetupCode.swift
+++ b/apps/ios/Sources/Gateway/GatewaySetupCode.swift
@@ -5,6 +5,7 @@ struct GatewaySetupPayload: Codable {
var host: String?
var port: Int?
var tls: Bool?
+ var bootstrapToken: String?
var token: String?
var password: String?
}
@@ -39,4 +40,3 @@ enum GatewaySetupCode {
return String(data: data, encoding: .utf8)
}
}
-
diff --git a/apps/ios/Sources/Info.plist b/apps/ios/Sources/Info.plist
index 892d53e7ae9..5908021fad3 100644
--- a/apps/ios/Sources/Info.plist
+++ b/apps/ios/Sources/Info.plist
@@ -66,6 +66,14 @@
OpenClaw uses on-device speech recognition for voice wake.
NSSupportsLiveActivities
+ OpenClawPushAPNsEnvironment
+ $(OPENCLAW_PUSH_APNS_ENVIRONMENT)
+ OpenClawPushDistribution
+ $(OPENCLAW_PUSH_DISTRIBUTION)
+ OpenClawPushRelayBaseURL
+ $(OPENCLAW_PUSH_RELAY_BASE_URL)
+ OpenClawPushTransport
+ $(OPENCLAW_PUSH_TRANSPORT)
UIApplicationSceneManifest
UIApplicationSupportsMultipleScenes
diff --git a/apps/ios/Sources/Model/NodeAppModel.swift b/apps/ios/Sources/Model/NodeAppModel.swift
index 685b30f0887..4c0ab81f1a1 100644
--- a/apps/ios/Sources/Model/NodeAppModel.swift
+++ b/apps/ios/Sources/Model/NodeAppModel.swift
@@ -12,6 +12,12 @@ import UserNotifications
private struct NotificationCallError: Error, Sendable {
let message: String
}
+
+private struct GatewayRelayIdentityResponse: Decodable {
+ let deviceId: String
+ let publicKey: String
+}
+
// Ensures notification requests return promptly even if the system prompt blocks.
private final class NotificationInvokeLatch: @unchecked Sendable {
private let lock = NSLock()
@@ -140,6 +146,7 @@ final class NodeAppModel {
private var shareDeliveryTo: String?
private var apnsDeviceTokenHex: String?
private var apnsLastRegisteredTokenHex: String?
+ @ObservationIgnored private let pushRegistrationManager = PushRegistrationManager()
var gatewaySession: GatewayNodeSession { self.nodeGateway }
var operatorSession: GatewayNodeSession { self.operatorGateway }
private(set) var activeGatewayConnectConfig: GatewayConnectConfig?
@@ -528,13 +535,6 @@ final class NodeAppModel {
private static let apnsDeviceTokenUserDefaultsKey = "push.apns.deviceTokenHex"
private static let deepLinkKeyUserDefaultsKey = "deeplink.agent.key"
private static let canvasUnattendedDeepLinkKey: String = NodeAppModel.generateDeepLinkKey()
- private static var apnsEnvironment: String {
-#if DEBUG
- "sandbox"
-#else
- "production"
-#endif
- }
private func refreshBrandingFromGateway() async {
do {
@@ -1189,7 +1189,15 @@ final class NodeAppModel {
_ = try await notificationCenter.requestAuthorization(options: [.alert, .sound, .badge])
}
- return await self.notificationAuthorizationStatus()
+ let updatedStatus = await self.notificationAuthorizationStatus()
+ if Self.isNotificationAuthorizationAllowed(updatedStatus) {
+ // Refresh APNs registration immediately after the first permission grant so the
+ // gateway can receive a push registration without requiring an app relaunch.
+ await MainActor.run {
+ UIApplication.shared.registerForRemoteNotifications()
+ }
+ }
+ return updatedStatus
}
private func notificationAuthorizationStatus() async -> NotificationAuthorizationStatus {
@@ -1204,6 +1212,17 @@ final class NodeAppModel {
}
}
+ private static func isNotificationAuthorizationAllowed(
+ _ status: NotificationAuthorizationStatus
+ ) -> Bool {
+ switch status {
+ case .authorized, .provisional, .ephemeral:
+ true
+ case .denied, .notDetermined:
+ false
+ }
+ }
+
private func runNotificationCall(
timeoutSeconds: Double,
operation: @escaping @Sendable () async throws -> T
@@ -1661,6 +1680,7 @@ extension NodeAppModel {
gatewayStableID: String,
tls: GatewayTLSParams?,
token: String?,
+ bootstrapToken: String?,
password: String?,
connectOptions: GatewayConnectOptions)
{
@@ -1673,6 +1693,7 @@ extension NodeAppModel {
stableID: stableID,
tls: tls,
token: token,
+ bootstrapToken: bootstrapToken,
password: password,
nodeOptions: connectOptions)
self.prepareForGatewayConnect(url: url, stableID: effectiveStableID)
@@ -1680,6 +1701,7 @@ extension NodeAppModel {
url: url,
stableID: effectiveStableID,
token: token,
+ bootstrapToken: bootstrapToken,
password: password,
nodeOptions: connectOptions,
sessionBox: sessionBox)
@@ -1687,6 +1709,7 @@ extension NodeAppModel {
url: url,
stableID: effectiveStableID,
token: token,
+ bootstrapToken: bootstrapToken,
password: password,
nodeOptions: connectOptions,
sessionBox: sessionBox)
@@ -1702,6 +1725,7 @@ extension NodeAppModel {
gatewayStableID: cfg.stableID,
tls: cfg.tls,
token: cfg.token,
+ bootstrapToken: cfg.bootstrapToken,
password: cfg.password,
connectOptions: cfg.nodeOptions)
}
@@ -1782,6 +1806,7 @@ private extension NodeAppModel {
url: URL,
stableID: String,
token: String?,
+ bootstrapToken: String?,
password: String?,
nodeOptions: GatewayConnectOptions,
sessionBox: WebSocketSessionBox?)
@@ -1819,6 +1844,7 @@ private extension NodeAppModel {
try await self.operatorGateway.connect(
url: url,
token: token,
+ bootstrapToken: bootstrapToken,
password: password,
connectOptions: operatorOptions,
sessionBox: sessionBox,
@@ -1834,6 +1860,7 @@ private extension NodeAppModel {
await self.refreshBrandingFromGateway()
await self.refreshAgentsFromGateway()
await self.refreshShareRouteFromGateway()
+ await self.registerAPNsTokenIfNeeded()
await self.startVoiceWakeSync()
await MainActor.run { LiveActivityManager.shared.handleReconnect() }
await MainActor.run { self.startGatewayHealthMonitor() }
@@ -1876,6 +1903,7 @@ private extension NodeAppModel {
url: URL,
stableID: String,
token: String?,
+ bootstrapToken: String?,
password: String?,
nodeOptions: GatewayConnectOptions,
sessionBox: WebSocketSessionBox?)
@@ -1924,6 +1952,7 @@ private extension NodeAppModel {
try await self.nodeGateway.connect(
url: url,
token: token,
+ bootstrapToken: bootstrapToken,
password: password,
connectOptions: currentOptions,
sessionBox: sessionBox,
@@ -2479,7 +2508,8 @@ extension NodeAppModel {
else {
return
}
- if token == self.apnsLastRegisteredTokenHex {
+ let usesRelayTransport = await self.pushRegistrationManager.usesRelayTransport
+ if !usesRelayTransport && token == self.apnsLastRegisteredTokenHex {
return
}
guard let topic = Bundle.main.bundleIdentifier?.trimmingCharacters(in: .whitespacesAndNewlines),
@@ -2488,25 +2518,40 @@ extension NodeAppModel {
return
}
- struct PushRegistrationPayload: Codable {
- var token: String
- var topic: String
- var environment: String
- }
-
- let payload = PushRegistrationPayload(
- token: token,
- topic: topic,
- environment: Self.apnsEnvironment)
do {
- let json = try Self.encodePayload(payload)
- await self.nodeGateway.sendEvent(event: "push.apns.register", payloadJSON: json)
+ let gatewayIdentity: PushRelayGatewayIdentity?
+ if usesRelayTransport {
+ guard self.operatorConnected else { return }
+ gatewayIdentity = try await self.fetchPushRelayGatewayIdentity()
+ } else {
+ gatewayIdentity = nil
+ }
+ let payloadJSON = try await self.pushRegistrationManager.makeGatewayRegistrationPayload(
+ apnsTokenHex: token,
+ topic: topic,
+ gatewayIdentity: gatewayIdentity)
+ await self.nodeGateway.sendEvent(event: "push.apns.register", payloadJSON: payloadJSON)
self.apnsLastRegisteredTokenHex = token
} catch {
- // Best-effort only.
+ self.pushWakeLogger.error(
+ "APNs registration publish failed: \(error.localizedDescription, privacy: .public)")
}
}
+ private func fetchPushRelayGatewayIdentity() async throws -> PushRelayGatewayIdentity {
+ let response = try await self.operatorGateway.request(
+ method: "gateway.identity.get",
+ paramsJSON: "{}",
+ timeoutSeconds: 8)
+ let decoded = try JSONDecoder().decode(GatewayRelayIdentityResponse.self, from: response)
+ let deviceId = decoded.deviceId.trimmingCharacters(in: .whitespacesAndNewlines)
+ let publicKey = decoded.publicKey.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !deviceId.isEmpty, !publicKey.isEmpty else {
+ throw PushRelayError.relayMisconfigured("Gateway identity response missing required fields")
+ }
+ return PushRelayGatewayIdentity(deviceId: deviceId, publicKey: publicKey)
+ }
+
private static func isSilentPushPayload(_ userInfo: [AnyHashable: Any]) -> Bool {
guard let apsAny = userInfo["aps"] else { return false }
if let aps = apsAny as? [AnyHashable: Any] {
diff --git a/apps/ios/Sources/Onboarding/GatewayOnboardingView.swift b/apps/ios/Sources/Onboarding/GatewayOnboardingView.swift
index b8b6e267755..f160b37d798 100644
--- a/apps/ios/Sources/Onboarding/GatewayOnboardingView.swift
+++ b/apps/ios/Sources/Onboarding/GatewayOnboardingView.swift
@@ -275,9 +275,21 @@ private struct ManualEntryStep: View {
if let token = payload.token, !token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
self.manualToken = token.trimmingCharacters(in: .whitespacesAndNewlines)
+ } else if payload.bootstrapToken?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false {
+ self.manualToken = ""
}
if let password = payload.password, !password.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
self.manualPassword = password.trimmingCharacters(in: .whitespacesAndNewlines)
+ } else if payload.bootstrapToken?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false {
+ self.manualPassword = ""
+ }
+
+ let trimmedInstanceId = UserDefaults.standard.string(forKey: "node.instanceId")?
+ .trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
+ if !trimmedInstanceId.isEmpty {
+ let trimmedBootstrapToken =
+ payload.bootstrapToken?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
+ GatewaySettingsStore.saveGatewayBootstrapToken(trimmedBootstrapToken, instanceId: trimmedInstanceId)
}
self.setupStatusText = "Setup code applied."
diff --git a/apps/ios/Sources/Onboarding/OnboardingWizardView.swift b/apps/ios/Sources/Onboarding/OnboardingWizardView.swift
index 4cefeb77e74..060b398eba4 100644
--- a/apps/ios/Sources/Onboarding/OnboardingWizardView.swift
+++ b/apps/ios/Sources/Onboarding/OnboardingWizardView.swift
@@ -642,11 +642,17 @@ struct OnboardingWizardView: View {
self.manualHost = link.host
self.manualPort = link.port
self.manualTLS = link.tls
- if let token = link.token {
+ let trimmedBootstrapToken = link.bootstrapToken?.trimmingCharacters(in: .whitespacesAndNewlines)
+ self.saveGatewayBootstrapToken(trimmedBootstrapToken)
+ if let token = link.token?.trimmingCharacters(in: .whitespacesAndNewlines), !token.isEmpty {
self.gatewayToken = token
+ } else if trimmedBootstrapToken?.isEmpty == false {
+ self.gatewayToken = ""
}
- if let password = link.password {
+ if let password = link.password?.trimmingCharacters(in: .whitespacesAndNewlines), !password.isEmpty {
self.gatewayPassword = password
+ } else if trimmedBootstrapToken?.isEmpty == false {
+ self.gatewayPassword = ""
}
self.saveGatewayCredentials(token: self.gatewayToken, password: self.gatewayPassword)
self.showQRScanner = false
@@ -794,6 +800,13 @@ struct OnboardingWizardView: View {
GatewaySettingsStore.saveGatewayPassword(trimmedPassword, instanceId: trimmedInstanceId)
}
+ private func saveGatewayBootstrapToken(_ token: String?) {
+ let trimmedInstanceId = self.instanceId.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !trimmedInstanceId.isEmpty else { return }
+ let trimmedToken = token?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
+ GatewaySettingsStore.saveGatewayBootstrapToken(trimmedToken, instanceId: trimmedInstanceId)
+ }
+
private func connectDiscoveredGateway(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) async {
self.connectingGatewayID = gateway.id
self.issue = .none
diff --git a/apps/ios/Sources/OpenClawApp.swift b/apps/ios/Sources/OpenClawApp.swift
index c94b1209f8d..ae980b0216a 100644
--- a/apps/ios/Sources/OpenClawApp.swift
+++ b/apps/ios/Sources/OpenClawApp.swift
@@ -407,6 +407,13 @@ enum WatchPromptNotificationBridge {
let granted = (try? await center.requestAuthorization(options: [.alert, .sound, .badge])) ?? false
if !granted { return false }
let updatedStatus = await self.notificationAuthorizationStatus(center: center)
+ if self.isAuthorizationStatusAllowed(updatedStatus) {
+ // Refresh APNs registration immediately after the first permission grant so the
+ // gateway can receive a push registration without requiring an app relaunch.
+ await MainActor.run {
+ UIApplication.shared.registerForRemoteNotifications()
+ }
+ }
return self.isAuthorizationStatusAllowed(updatedStatus)
case .denied:
return false
diff --git a/apps/ios/Sources/Push/PushBuildConfig.swift b/apps/ios/Sources/Push/PushBuildConfig.swift
new file mode 100644
index 00000000000..d1665921552
--- /dev/null
+++ b/apps/ios/Sources/Push/PushBuildConfig.swift
@@ -0,0 +1,75 @@
+import Foundation
+
+enum PushTransportMode: String {
+ case direct
+ case relay
+}
+
+enum PushDistributionMode: String {
+ case local
+ case official
+}
+
+enum PushAPNsEnvironment: String {
+ case sandbox
+ case production
+}
+
+struct PushBuildConfig {
+ let transport: PushTransportMode
+ let distribution: PushDistributionMode
+ let relayBaseURL: URL?
+ let apnsEnvironment: PushAPNsEnvironment
+
+ static let current = PushBuildConfig()
+
+ init(bundle: Bundle = .main) {
+ self.transport = Self.readEnum(
+ bundle: bundle,
+ key: "OpenClawPushTransport",
+ fallback: .direct)
+ self.distribution = Self.readEnum(
+ bundle: bundle,
+ key: "OpenClawPushDistribution",
+ fallback: .local)
+ self.apnsEnvironment = Self.readEnum(
+ bundle: bundle,
+ key: "OpenClawPushAPNsEnvironment",
+ fallback: Self.defaultAPNsEnvironment)
+ self.relayBaseURL = Self.readURL(bundle: bundle, key: "OpenClawPushRelayBaseURL")
+ }
+
+ var usesRelay: Bool {
+ self.transport == .relay
+ }
+
+ private static func readURL(bundle: Bundle, key: String) -> URL? {
+ guard let raw = bundle.object(forInfoDictionaryKey: key) as? String else { return nil }
+ let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !trimmed.isEmpty else { return nil }
+ guard let components = URLComponents(string: trimmed),
+ components.scheme?.lowercased() == "https",
+ let host = components.host,
+ !host.isEmpty,
+ components.user == nil,
+ components.password == nil,
+ components.query == nil,
+ components.fragment == nil
+ else {
+ return nil
+ }
+ return components.url
+ }
+
+ private static func readEnum(
+ bundle: Bundle,
+ key: String,
+ fallback: T)
+ -> T where T.RawValue == String {
+ guard let raw = bundle.object(forInfoDictionaryKey: key) as? String else { return fallback }
+ let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
+ return T(rawValue: trimmed) ?? fallback
+ }
+
+ private static let defaultAPNsEnvironment: PushAPNsEnvironment = .sandbox
+}
diff --git a/apps/ios/Sources/Push/PushRegistrationManager.swift b/apps/ios/Sources/Push/PushRegistrationManager.swift
new file mode 100644
index 00000000000..77f54f8d108
--- /dev/null
+++ b/apps/ios/Sources/Push/PushRegistrationManager.swift
@@ -0,0 +1,169 @@
+import CryptoKit
+import Foundation
+
+private struct DirectGatewayPushRegistrationPayload: Encodable {
+ var transport: String = PushTransportMode.direct.rawValue
+ var token: String
+ var topic: String
+ var environment: String
+}
+
+private struct RelayGatewayPushRegistrationPayload: Encodable {
+ var transport: String = PushTransportMode.relay.rawValue
+ var relayHandle: String
+ var sendGrant: String
+ var gatewayDeviceId: String
+ var installationId: String
+ var topic: String
+ var environment: String
+ var distribution: String
+ var tokenDebugSuffix: String?
+}
+
+struct PushRelayGatewayIdentity: Codable {
+ var deviceId: String
+ var publicKey: String
+}
+
+actor PushRegistrationManager {
+ private let buildConfig: PushBuildConfig
+ private let relayClient: PushRelayClient?
+
+ var usesRelayTransport: Bool {
+ self.buildConfig.transport == .relay
+ }
+
+ init(buildConfig: PushBuildConfig = .current) {
+ self.buildConfig = buildConfig
+ self.relayClient = buildConfig.relayBaseURL.map { PushRelayClient(baseURL: $0) }
+ }
+
+ func makeGatewayRegistrationPayload(
+ apnsTokenHex: String,
+ topic: String,
+ gatewayIdentity: PushRelayGatewayIdentity?)
+ async throws -> String {
+ switch self.buildConfig.transport {
+ case .direct:
+ return try Self.encodePayload(
+ DirectGatewayPushRegistrationPayload(
+ token: apnsTokenHex,
+ topic: topic,
+ environment: self.buildConfig.apnsEnvironment.rawValue))
+ case .relay:
+ guard let gatewayIdentity else {
+ throw PushRelayError.relayMisconfigured("Missing gateway identity for relay registration")
+ }
+ return try await self.makeRelayPayload(
+ apnsTokenHex: apnsTokenHex,
+ topic: topic,
+ gatewayIdentity: gatewayIdentity)
+ }
+ }
+
+ private func makeRelayPayload(
+ apnsTokenHex: String,
+ topic: String,
+ gatewayIdentity: PushRelayGatewayIdentity)
+ async throws -> String {
+ guard self.buildConfig.distribution == .official else {
+ throw PushRelayError.relayMisconfigured(
+ "Relay transport requires OpenClawPushDistribution=official")
+ }
+ guard self.buildConfig.apnsEnvironment == .production else {
+ throw PushRelayError.relayMisconfigured(
+ "Relay transport requires OpenClawPushAPNsEnvironment=production")
+ }
+ guard let relayClient = self.relayClient else {
+ throw PushRelayError.relayBaseURLMissing
+ }
+ guard let bundleId = Bundle.main.bundleIdentifier?.trimmingCharacters(in: .whitespacesAndNewlines),
+ !bundleId.isEmpty
+ else {
+ throw PushRelayError.relayMisconfigured("Missing bundle identifier for relay registration")
+ }
+ guard let installationId = GatewaySettingsStore.loadStableInstanceID()?
+ .trimmingCharacters(in: .whitespacesAndNewlines),
+ !installationId.isEmpty
+ else {
+ throw PushRelayError.relayMisconfigured("Missing stable installation ID for relay registration")
+ }
+
+ let tokenHashHex = Self.sha256Hex(apnsTokenHex)
+ let relayOrigin = relayClient.normalizedBaseURLString
+ if let stored = PushRelayRegistrationStore.loadRegistrationState(),
+ stored.installationId == installationId,
+ stored.gatewayDeviceId == gatewayIdentity.deviceId,
+ stored.relayOrigin == relayOrigin,
+ stored.lastAPNsTokenHashHex == tokenHashHex,
+ !Self.isExpired(stored.relayHandleExpiresAtMs)
+ {
+ return try Self.encodePayload(
+ RelayGatewayPushRegistrationPayload(
+ relayHandle: stored.relayHandle,
+ sendGrant: stored.sendGrant,
+ gatewayDeviceId: gatewayIdentity.deviceId,
+ installationId: installationId,
+ topic: topic,
+ environment: self.buildConfig.apnsEnvironment.rawValue,
+ distribution: self.buildConfig.distribution.rawValue,
+ tokenDebugSuffix: stored.tokenDebugSuffix))
+ }
+
+ let response = try await relayClient.register(
+ installationId: installationId,
+ bundleId: bundleId,
+ appVersion: DeviceInfoHelper.appVersion(),
+ environment: self.buildConfig.apnsEnvironment,
+ distribution: self.buildConfig.distribution,
+ apnsTokenHex: apnsTokenHex,
+ gatewayIdentity: gatewayIdentity)
+ let registrationState = PushRelayRegistrationStore.RegistrationState(
+ relayHandle: response.relayHandle,
+ sendGrant: response.sendGrant,
+ relayOrigin: relayOrigin,
+ gatewayDeviceId: gatewayIdentity.deviceId,
+ relayHandleExpiresAtMs: response.expiresAtMs,
+ tokenDebugSuffix: Self.normalizeTokenSuffix(response.tokenSuffix),
+ lastAPNsTokenHashHex: tokenHashHex,
+ installationId: installationId,
+ lastTransport: self.buildConfig.transport.rawValue)
+ _ = PushRelayRegistrationStore.saveRegistrationState(registrationState)
+ return try Self.encodePayload(
+ RelayGatewayPushRegistrationPayload(
+ relayHandle: response.relayHandle,
+ sendGrant: response.sendGrant,
+ gatewayDeviceId: gatewayIdentity.deviceId,
+ installationId: installationId,
+ topic: topic,
+ environment: self.buildConfig.apnsEnvironment.rawValue,
+ distribution: self.buildConfig.distribution.rawValue,
+ tokenDebugSuffix: registrationState.tokenDebugSuffix))
+ }
+
+ private static func isExpired(_ expiresAtMs: Int64?) -> Bool {
+ guard let expiresAtMs else { return true }
+ let nowMs = Int64(Date().timeIntervalSince1970 * 1000)
+ // Refresh shortly before expiry so reconnect-path republishes a live handle.
+ return expiresAtMs <= nowMs + 60_000
+ }
+
+ private static func sha256Hex(_ value: String) -> String {
+ let digest = SHA256.hash(data: Data(value.utf8))
+ return digest.map { String(format: "%02x", $0) }.joined()
+ }
+
+ private static func normalizeTokenSuffix(_ value: String?) -> String? {
+ guard let value else { return nil }
+ let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
+ return trimmed.isEmpty ? nil : trimmed
+ }
+
+ private static func encodePayload(_ payload: some Encodable) throws -> String {
+ let data = try JSONEncoder().encode(payload)
+ guard let json = String(data: data, encoding: .utf8) else {
+ throw PushRelayError.relayMisconfigured("Failed to encode push registration payload as UTF-8")
+ }
+ return json
+ }
+}
diff --git a/apps/ios/Sources/Push/PushRelayClient.swift b/apps/ios/Sources/Push/PushRelayClient.swift
new file mode 100644
index 00000000000..07bb5caa3b7
--- /dev/null
+++ b/apps/ios/Sources/Push/PushRelayClient.swift
@@ -0,0 +1,349 @@
+import CryptoKit
+import DeviceCheck
+import Foundation
+import StoreKit
+
+enum PushRelayError: LocalizedError {
+ case relayBaseURLMissing
+ case relayMisconfigured(String)
+ case invalidResponse(String)
+ case requestFailed(status: Int, message: String)
+ case unsupportedAppAttest
+ case missingReceipt
+
+ var errorDescription: String? {
+ switch self {
+ case .relayBaseURLMissing:
+ "Push relay base URL missing"
+ case let .relayMisconfigured(message):
+ message
+ case let .invalidResponse(message):
+ message
+ case let .requestFailed(status, message):
+ "Push relay request failed (\(status)): \(message)"
+ case .unsupportedAppAttest:
+ "App Attest unavailable on this device"
+ case .missingReceipt:
+ "App Store receipt missing after refresh"
+ }
+ }
+}
+
+private struct PushRelayChallengeResponse: Decodable {
+ var challengeId: String
+ var challenge: String
+ var expiresAtMs: Int64
+}
+
+private struct PushRelayRegisterSignedPayload: Encodable {
+ var challengeId: String
+ var installationId: String
+ var bundleId: String
+ var environment: String
+ var distribution: String
+ var gateway: PushRelayGatewayIdentity
+ var appVersion: String
+ var apnsToken: String
+}
+
+private struct PushRelayAppAttestPayload: Encodable {
+ var keyId: String
+ var attestationObject: String?
+ var assertion: String
+ var clientDataHash: String
+ var signedPayloadBase64: String
+}
+
+private struct PushRelayReceiptPayload: Encodable {
+ var base64: String
+}
+
+private struct PushRelayRegisterRequest: Encodable {
+ var challengeId: String
+ var installationId: String
+ var bundleId: String
+ var environment: String
+ var distribution: String
+ var gateway: PushRelayGatewayIdentity
+ var appVersion: String
+ var apnsToken: String
+ var appAttest: PushRelayAppAttestPayload
+ var receipt: PushRelayReceiptPayload
+}
+
+struct PushRelayRegisterResponse: Decodable {
+ var relayHandle: String
+ var sendGrant: String
+ var expiresAtMs: Int64?
+ var tokenSuffix: String?
+ var status: String
+}
+
+private struct RelayErrorResponse: Decodable {
+ var error: String?
+ var message: String?
+ var reason: String?
+}
+
+private final class PushRelayReceiptRefreshCoordinator: NSObject, SKRequestDelegate {
+ private var continuation: CheckedContinuation?
+ private var activeRequest: SKReceiptRefreshRequest?
+
+ func refresh() async throws {
+ try await withCheckedThrowingContinuation { continuation in
+ self.continuation = continuation
+ let request = SKReceiptRefreshRequest()
+ self.activeRequest = request
+ request.delegate = self
+ request.start()
+ }
+ }
+
+ func requestDidFinish(_ request: SKRequest) {
+ self.continuation?.resume(returning: ())
+ self.continuation = nil
+ self.activeRequest = nil
+ }
+
+ func request(_ request: SKRequest, didFailWithError error: Error) {
+ self.continuation?.resume(throwing: error)
+ self.continuation = nil
+ self.activeRequest = nil
+ }
+}
+
+private struct PushRelayAppAttestProof {
+ var keyId: String
+ var attestationObject: String?
+ var assertion: String
+ var clientDataHash: String
+ var signedPayloadBase64: String
+}
+
+private final class PushRelayAppAttestService {
+ func createProof(challenge: String, signedPayload: Data) async throws -> PushRelayAppAttestProof {
+ let service = DCAppAttestService.shared
+ guard service.isSupported else {
+ throw PushRelayError.unsupportedAppAttest
+ }
+
+ let keyID = try await self.loadOrCreateKeyID(using: service)
+ let attestationObject = try await self.attestKeyIfNeeded(
+ service: service,
+ keyID: keyID,
+ challenge: challenge)
+ let signedPayloadHash = Data(SHA256.hash(data: signedPayload))
+ let assertion = try await self.generateAssertion(
+ service: service,
+ keyID: keyID,
+ signedPayloadHash: signedPayloadHash)
+
+ return PushRelayAppAttestProof(
+ keyId: keyID,
+ attestationObject: attestationObject,
+ assertion: assertion.base64EncodedString(),
+ clientDataHash: Self.base64URL(signedPayloadHash),
+ signedPayloadBase64: signedPayload.base64EncodedString())
+ }
+
+ private func loadOrCreateKeyID(using service: DCAppAttestService) async throws -> String {
+ if let existing = PushRelayRegistrationStore.loadAppAttestKeyID(), !existing.isEmpty {
+ return existing
+ }
+ let keyID = try await service.generateKey()
+ _ = PushRelayRegistrationStore.saveAppAttestKeyID(keyID)
+ return keyID
+ }
+
+ private func attestKeyIfNeeded(
+ service: DCAppAttestService,
+ keyID: String,
+ challenge: String)
+ async throws -> String? {
+ if PushRelayRegistrationStore.loadAttestedKeyID() == keyID {
+ return nil
+ }
+ let challengeData = Data(challenge.utf8)
+ let clientDataHash = Data(SHA256.hash(data: challengeData))
+ let attestation = try await service.attestKey(keyID, clientDataHash: clientDataHash)
+ // Apple treats App Attest key attestation as a one-time operation. Save the
+ // attested marker immediately so later receipt/network failures do not cause a
+ // permanently broken re-attestation loop on the same key.
+ _ = PushRelayRegistrationStore.saveAttestedKeyID(keyID)
+ return attestation.base64EncodedString()
+ }
+
+ private func generateAssertion(
+ service: DCAppAttestService,
+ keyID: String,
+ signedPayloadHash: Data)
+ async throws -> Data {
+ do {
+ return try await service.generateAssertion(keyID, clientDataHash: signedPayloadHash)
+ } catch {
+ _ = PushRelayRegistrationStore.clearAppAttestKeyID()
+ _ = PushRelayRegistrationStore.clearAttestedKeyID()
+ throw error
+ }
+ }
+
+ private static func base64URL(_ data: Data) -> String {
+ data.base64EncodedString()
+ .replacingOccurrences(of: "+", with: "-")
+ .replacingOccurrences(of: "/", with: "_")
+ .replacingOccurrences(of: "=", with: "")
+ }
+}
+
+private final class PushRelayReceiptProvider {
+ func loadReceiptBase64() async throws -> String {
+ if let receipt = self.readReceiptData() {
+ return receipt.base64EncodedString()
+ }
+ let refreshCoordinator = PushRelayReceiptRefreshCoordinator()
+ try await refreshCoordinator.refresh()
+ if let refreshed = self.readReceiptData() {
+ return refreshed.base64EncodedString()
+ }
+ throw PushRelayError.missingReceipt
+ }
+
+ private func readReceiptData() -> Data? {
+ guard let url = Bundle.main.appStoreReceiptURL else { return nil }
+ guard let data = try? Data(contentsOf: url), !data.isEmpty else { return nil }
+ return data
+ }
+}
+
+// The client is constructed once and used behind PushRegistrationManager actor isolation.
+final class PushRelayClient: @unchecked Sendable {
+ private let baseURL: URL
+ private let session: URLSession
+ private let jsonDecoder = JSONDecoder()
+ private let jsonEncoder = JSONEncoder()
+ private let appAttest = PushRelayAppAttestService()
+ private let receiptProvider = PushRelayReceiptProvider()
+
+ init(baseURL: URL, session: URLSession = .shared) {
+ self.baseURL = baseURL
+ self.session = session
+ }
+
+ var normalizedBaseURLString: String {
+ Self.normalizeBaseURLString(self.baseURL)
+ }
+
+ func register(
+ installationId: String,
+ bundleId: String,
+ appVersion: String,
+ environment: PushAPNsEnvironment,
+ distribution: PushDistributionMode,
+ apnsTokenHex: String,
+ gatewayIdentity: PushRelayGatewayIdentity)
+ async throws -> PushRelayRegisterResponse {
+ let challenge = try await self.fetchChallenge()
+ let signedPayload = PushRelayRegisterSignedPayload(
+ challengeId: challenge.challengeId,
+ installationId: installationId,
+ bundleId: bundleId,
+ environment: environment.rawValue,
+ distribution: distribution.rawValue,
+ gateway: gatewayIdentity,
+ appVersion: appVersion,
+ apnsToken: apnsTokenHex)
+ let signedPayloadData = try self.jsonEncoder.encode(signedPayload)
+ let appAttest = try await self.appAttest.createProof(
+ challenge: challenge.challenge,
+ signedPayload: signedPayloadData)
+ let receiptBase64 = try await self.receiptProvider.loadReceiptBase64()
+ let requestBody = PushRelayRegisterRequest(
+ challengeId: signedPayload.challengeId,
+ installationId: signedPayload.installationId,
+ bundleId: signedPayload.bundleId,
+ environment: signedPayload.environment,
+ distribution: signedPayload.distribution,
+ gateway: signedPayload.gateway,
+ appVersion: signedPayload.appVersion,
+ apnsToken: signedPayload.apnsToken,
+ appAttest: PushRelayAppAttestPayload(
+ keyId: appAttest.keyId,
+ attestationObject: appAttest.attestationObject,
+ assertion: appAttest.assertion,
+ clientDataHash: appAttest.clientDataHash,
+ signedPayloadBase64: appAttest.signedPayloadBase64),
+ receipt: PushRelayReceiptPayload(base64: receiptBase64))
+
+ let endpoint = self.baseURL.appending(path: "v1/push/register")
+ var request = URLRequest(url: endpoint)
+ request.httpMethod = "POST"
+ request.timeoutInterval = 20
+ request.setValue("application/json", forHTTPHeaderField: "Content-Type")
+ request.httpBody = try self.jsonEncoder.encode(requestBody)
+
+ let (data, response) = try await self.session.data(for: request)
+ let status = Self.statusCode(from: response)
+ guard (200..<300).contains(status) else {
+ if status == 401 {
+ // If the relay rejects registration, drop local App Attest state so the next
+ // attempt re-attests instead of getting stuck without an attestation object.
+ _ = PushRelayRegistrationStore.clearAppAttestKeyID()
+ _ = PushRelayRegistrationStore.clearAttestedKeyID()
+ }
+ throw PushRelayError.requestFailed(
+ status: status,
+ message: Self.decodeErrorMessage(data: data))
+ }
+ let decoded = try self.decode(PushRelayRegisterResponse.self, from: data)
+ return decoded
+ }
+
+ private func fetchChallenge() async throws -> PushRelayChallengeResponse {
+ let endpoint = self.baseURL.appending(path: "v1/push/challenge")
+ var request = URLRequest(url: endpoint)
+ request.httpMethod = "POST"
+ request.timeoutInterval = 10
+ request.setValue("application/json", forHTTPHeaderField: "Content-Type")
+ request.httpBody = Data("{}".utf8)
+
+ let (data, response) = try await self.session.data(for: request)
+ let status = Self.statusCode(from: response)
+ guard (200..<300).contains(status) else {
+ throw PushRelayError.requestFailed(
+ status: status,
+ message: Self.decodeErrorMessage(data: data))
+ }
+ return try self.decode(PushRelayChallengeResponse.self, from: data)
+ }
+
+ private func decode(_ type: T.Type, from data: Data) throws -> T {
+ do {
+ return try self.jsonDecoder.decode(type, from: data)
+ } catch {
+ throw PushRelayError.invalidResponse(error.localizedDescription)
+ }
+ }
+
+ private static func statusCode(from response: URLResponse) -> Int {
+ (response as? HTTPURLResponse)?.statusCode ?? 0
+ }
+
+ private static func normalizeBaseURLString(_ url: URL) -> String {
+ var absolute = url.absoluteString
+ while absolute.hasSuffix("/") {
+ absolute.removeLast()
+ }
+ return absolute
+ }
+
+ private static func decodeErrorMessage(data: Data) -> String {
+ if let decoded = try? JSONDecoder().decode(RelayErrorResponse.self, from: data) {
+ let message = decoded.message ?? decoded.reason ?? decoded.error ?? ""
+ if !message.isEmpty {
+ return message
+ }
+ }
+ let raw = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
+ return raw.isEmpty ? "unknown relay error" : raw
+ }
+}
diff --git a/apps/ios/Sources/Push/PushRelayKeychainStore.swift b/apps/ios/Sources/Push/PushRelayKeychainStore.swift
new file mode 100644
index 00000000000..4d7df09cd14
--- /dev/null
+++ b/apps/ios/Sources/Push/PushRelayKeychainStore.swift
@@ -0,0 +1,112 @@
+import Foundation
+
+private struct StoredPushRelayRegistrationState: Codable {
+ var relayHandle: String
+ var sendGrant: String
+ var relayOrigin: String?
+ var gatewayDeviceId: String
+ var relayHandleExpiresAtMs: Int64?
+ var tokenDebugSuffix: String?
+ var lastAPNsTokenHashHex: String
+ var installationId: String
+ var lastTransport: String
+}
+
+enum PushRelayRegistrationStore {
+ private static let service = "ai.openclaw.pushrelay"
+ private static let registrationStateAccount = "registration-state"
+ private static let appAttestKeyIDAccount = "app-attest-key-id"
+ private static let appAttestedKeyIDAccount = "app-attested-key-id"
+
+ struct RegistrationState: Codable {
+ var relayHandle: String
+ var sendGrant: String
+ var relayOrigin: String?
+ var gatewayDeviceId: String
+ var relayHandleExpiresAtMs: Int64?
+ var tokenDebugSuffix: String?
+ var lastAPNsTokenHashHex: String
+ var installationId: String
+ var lastTransport: String
+ }
+
+ static func loadRegistrationState() -> RegistrationState? {
+ guard let raw = KeychainStore.loadString(
+ service: self.service,
+ account: self.registrationStateAccount),
+ let data = raw.data(using: .utf8),
+ let decoded = try? JSONDecoder().decode(StoredPushRelayRegistrationState.self, from: data)
+ else {
+ return nil
+ }
+ return RegistrationState(
+ relayHandle: decoded.relayHandle,
+ sendGrant: decoded.sendGrant,
+ relayOrigin: decoded.relayOrigin,
+ gatewayDeviceId: decoded.gatewayDeviceId,
+ relayHandleExpiresAtMs: decoded.relayHandleExpiresAtMs,
+ tokenDebugSuffix: decoded.tokenDebugSuffix,
+ lastAPNsTokenHashHex: decoded.lastAPNsTokenHashHex,
+ installationId: decoded.installationId,
+ lastTransport: decoded.lastTransport)
+ }
+
+ @discardableResult
+ static func saveRegistrationState(_ state: RegistrationState) -> Bool {
+ let stored = StoredPushRelayRegistrationState(
+ relayHandle: state.relayHandle,
+ sendGrant: state.sendGrant,
+ relayOrigin: state.relayOrigin,
+ gatewayDeviceId: state.gatewayDeviceId,
+ relayHandleExpiresAtMs: state.relayHandleExpiresAtMs,
+ tokenDebugSuffix: state.tokenDebugSuffix,
+ lastAPNsTokenHashHex: state.lastAPNsTokenHashHex,
+ installationId: state.installationId,
+ lastTransport: state.lastTransport)
+ guard let data = try? JSONEncoder().encode(stored),
+ let raw = String(data: data, encoding: .utf8)
+ else {
+ return false
+ }
+ return KeychainStore.saveString(raw, service: self.service, account: self.registrationStateAccount)
+ }
+
+ @discardableResult
+ static func clearRegistrationState() -> Bool {
+ KeychainStore.delete(service: self.service, account: self.registrationStateAccount)
+ }
+
+ static func loadAppAttestKeyID() -> String? {
+ let value = KeychainStore.loadString(service: self.service, account: self.appAttestKeyIDAccount)?
+ .trimmingCharacters(in: .whitespacesAndNewlines)
+ if value?.isEmpty == false { return value }
+ return nil
+ }
+
+ @discardableResult
+ static func saveAppAttestKeyID(_ keyID: String) -> Bool {
+ KeychainStore.saveString(keyID, service: self.service, account: self.appAttestKeyIDAccount)
+ }
+
+ @discardableResult
+ static func clearAppAttestKeyID() -> Bool {
+ KeychainStore.delete(service: self.service, account: self.appAttestKeyIDAccount)
+ }
+
+ static func loadAttestedKeyID() -> String? {
+ let value = KeychainStore.loadString(service: self.service, account: self.appAttestedKeyIDAccount)?
+ .trimmingCharacters(in: .whitespacesAndNewlines)
+ if value?.isEmpty == false { return value }
+ return nil
+ }
+
+ @discardableResult
+ static func saveAttestedKeyID(_ keyID: String) -> Bool {
+ KeychainStore.saveString(keyID, service: self.service, account: self.appAttestedKeyIDAccount)
+ }
+
+ @discardableResult
+ static func clearAttestedKeyID() -> Bool {
+ KeychainStore.delete(service: self.service, account: self.appAttestedKeyIDAccount)
+ }
+}
diff --git a/apps/ios/Sources/Settings/SettingsTab.swift b/apps/ios/Sources/Settings/SettingsTab.swift
index 7aa79fa24ca..3dec2fa779b 100644
--- a/apps/ios/Sources/Settings/SettingsTab.swift
+++ b/apps/ios/Sources/Settings/SettingsTab.swift
@@ -767,12 +767,22 @@ struct SettingsTab: View {
}
let trimmedInstanceId = self.instanceId.trimmingCharacters(in: .whitespacesAndNewlines)
+ let trimmedBootstrapToken =
+ payload.bootstrapToken?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
+ if !trimmedInstanceId.isEmpty {
+ GatewaySettingsStore.saveGatewayBootstrapToken(trimmedBootstrapToken, instanceId: trimmedInstanceId)
+ }
if let token = payload.token, !token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
let trimmedToken = token.trimmingCharacters(in: .whitespacesAndNewlines)
self.gatewayToken = trimmedToken
if !trimmedInstanceId.isEmpty {
GatewaySettingsStore.saveGatewayToken(trimmedToken, instanceId: trimmedInstanceId)
}
+ } else if !trimmedBootstrapToken.isEmpty {
+ self.gatewayToken = ""
+ if !trimmedInstanceId.isEmpty {
+ GatewaySettingsStore.saveGatewayToken("", instanceId: trimmedInstanceId)
+ }
}
if let password = payload.password, !password.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
let trimmedPassword = password.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -780,6 +790,11 @@ struct SettingsTab: View {
if !trimmedInstanceId.isEmpty {
GatewaySettingsStore.saveGatewayPassword(trimmedPassword, instanceId: trimmedInstanceId)
}
+ } else if !trimmedBootstrapToken.isEmpty {
+ self.gatewayPassword = ""
+ if !trimmedInstanceId.isEmpty {
+ GatewaySettingsStore.saveGatewayPassword("", instanceId: trimmedInstanceId)
+ }
}
return true
diff --git a/apps/ios/Tests/DeepLinkParserTests.swift b/apps/ios/Tests/DeepLinkParserTests.swift
index 7f24aa3e34e..bac3288add1 100644
--- a/apps/ios/Tests/DeepLinkParserTests.swift
+++ b/apps/ios/Tests/DeepLinkParserTests.swift
@@ -86,7 +86,13 @@ private func agentAction(
string: "openclaw://gateway?host=openclaw.local&port=18789&tls=1&token=abc&password=def")!
#expect(
DeepLinkParser.parse(url) == .gateway(
- .init(host: "openclaw.local", port: 18789, tls: true, token: "abc", password: "def")))
+ .init(
+ host: "openclaw.local",
+ port: 18789,
+ tls: true,
+ bootstrapToken: nil,
+ token: "abc",
+ password: "def")))
}
@Test func parseGatewayLinkRejectsInsecureNonLoopbackWs() {
@@ -102,14 +108,15 @@ private func agentAction(
}
@Test func parseGatewaySetupCodeParsesBase64UrlPayload() {
- let payload = #"{"url":"wss://gateway.example.com:443","token":"tok","password":"pw"}"#
+ let payload = #"{"url":"wss://gateway.example.com:443","bootstrapToken":"tok","password":"pw"}"#
let link = GatewayConnectDeepLink.fromSetupCode(setupCode(from: payload))
#expect(link == .init(
host: "gateway.example.com",
port: 443,
tls: true,
- token: "tok",
+ bootstrapToken: "tok",
+ token: nil,
password: "pw"))
}
@@ -118,38 +125,40 @@ private func agentAction(
}
@Test func parseGatewaySetupCodeDefaultsTo443ForWssWithoutPort() {
- let payload = #"{"url":"wss://gateway.example.com","token":"tok"}"#
+ let payload = #"{"url":"wss://gateway.example.com","bootstrapToken":"tok"}"#
let link = GatewayConnectDeepLink.fromSetupCode(setupCode(from: payload))
#expect(link == .init(
host: "gateway.example.com",
port: 443,
tls: true,
- token: "tok",
+ bootstrapToken: "tok",
+ token: nil,
password: nil))
}
@Test func parseGatewaySetupCodeRejectsInsecureNonLoopbackWs() {
- let payload = #"{"url":"ws://attacker.example:18789","token":"tok"}"#
+ let payload = #"{"url":"ws://attacker.example:18789","bootstrapToken":"tok"}"#
let link = GatewayConnectDeepLink.fromSetupCode(setupCode(from: payload))
#expect(link == nil)
}
@Test func parseGatewaySetupCodeRejectsInsecurePrefixBypassHost() {
- let payload = #"{"url":"ws://127.attacker.example:18789","token":"tok"}"#
+ let payload = #"{"url":"ws://127.attacker.example:18789","bootstrapToken":"tok"}"#
let link = GatewayConnectDeepLink.fromSetupCode(setupCode(from: payload))
#expect(link == nil)
}
@Test func parseGatewaySetupCodeAllowsLoopbackWs() {
- let payload = #"{"url":"ws://127.0.0.1:18789","token":"tok"}"#
+ let payload = #"{"url":"ws://127.0.0.1:18789","bootstrapToken":"tok"}"#
let link = GatewayConnectDeepLink.fromSetupCode(setupCode(from: payload))
#expect(link == .init(
host: "127.0.0.1",
port: 18789,
tls: false,
- token: "tok",
+ bootstrapToken: "tok",
+ token: nil,
password: nil))
}
}
diff --git a/apps/ios/Tests/IOSGatewayChatTransportTests.swift b/apps/ios/Tests/IOSGatewayChatTransportTests.swift
index f49f242ff24..42526dd21c4 100644
--- a/apps/ios/Tests/IOSGatewayChatTransportTests.swift
+++ b/apps/ios/Tests/IOSGatewayChatTransportTests.swift
@@ -26,5 +26,10 @@ import Testing
_ = try await transport.requestHealth(timeoutMs: 250)
Issue.record("Expected requestHealth to throw when gateway not connected")
} catch {}
+
+ do {
+ try await transport.resetSession(sessionKey: "node-test")
+ Issue.record("Expected resetSession to throw when gateway not connected")
+ } catch {}
}
}
diff --git a/apps/ios/fastlane/Fastfile b/apps/ios/fastlane/Fastfile
index e7b286b4dd5..fb32b1e907b 100644
--- a/apps/ios/fastlane/Fastfile
+++ b/apps/ios/fastlane/Fastfile
@@ -99,7 +99,7 @@ def normalize_release_version(raw_value)
version = raw_value.to_s.strip.sub(/\Av/, "")
UI.user_error!("Missing root package.json version.") unless env_present?(version)
unless version.match?(/\A\d+\.\d+\.\d+(?:[.-]?beta[.-]\d+)?\z/i)
- UI.user_error!("Invalid package.json version '#{raw_value}'. Expected 2026.3.11 or 2026.3.11-beta.1.")
+ UI.user_error!("Invalid package.json version '#{raw_value}'. Expected 2026.3.12 or 2026.3.12-beta.1.")
end
version
diff --git a/apps/ios/project.yml b/apps/ios/project.yml
index 91b2a8e46d1..53e6489a25b 100644
--- a/apps/ios/project.yml
+++ b/apps/ios/project.yml
@@ -98,6 +98,17 @@ targets:
SUPPORTS_LIVE_ACTIVITIES: YES
ENABLE_APPINTENTS_METADATA: NO
ENABLE_APP_INTENTS_METADATA_GENERATION: NO
+ configs:
+ Debug:
+ OPENCLAW_PUSH_TRANSPORT: direct
+ OPENCLAW_PUSH_DISTRIBUTION: local
+ OPENCLAW_PUSH_RELAY_BASE_URL: ""
+ OPENCLAW_PUSH_APNS_ENVIRONMENT: sandbox
+ Release:
+ OPENCLAW_PUSH_TRANSPORT: direct
+ OPENCLAW_PUSH_DISTRIBUTION: local
+ OPENCLAW_PUSH_RELAY_BASE_URL: ""
+ OPENCLAW_PUSH_APNS_ENVIRONMENT: production
info:
path: Sources/Info.plist
properties:
@@ -131,6 +142,10 @@ targets:
NSSpeechRecognitionUsageDescription: OpenClaw uses on-device speech recognition for voice wake.
NSSupportsLiveActivities: true
ITSAppUsesNonExemptEncryption: false
+ OpenClawPushTransport: "$(OPENCLAW_PUSH_TRANSPORT)"
+ OpenClawPushDistribution: "$(OPENCLAW_PUSH_DISTRIBUTION)"
+ OpenClawPushRelayBaseURL: "$(OPENCLAW_PUSH_RELAY_BASE_URL)"
+ OpenClawPushAPNsEnvironment: "$(OPENCLAW_PUSH_APNS_ENVIRONMENT)"
UISupportedInterfaceOrientations:
- UIInterfaceOrientationPortrait
- UIInterfaceOrientationPortraitUpsideDown
diff --git a/apps/macos/Sources/OpenClaw/ControlChannel.swift b/apps/macos/Sources/OpenClaw/ControlChannel.swift
index c4472f8f452..607aab47940 100644
--- a/apps/macos/Sources/OpenClaw/ControlChannel.swift
+++ b/apps/macos/Sources/OpenClaw/ControlChannel.swift
@@ -324,6 +324,8 @@ final class ControlChannel {
switch source {
case .deviceToken:
return "Auth: device token (paired device)"
+ case .bootstrapToken:
+ return "Auth: bootstrap token (setup code)"
case .sharedToken:
return "Auth: shared token (\(isRemote ? "gateway.remote.token" : "gateway.auth.token"))"
case .password:
diff --git a/apps/macos/Sources/OpenClaw/HostEnvSecurityPolicy.generated.swift b/apps/macos/Sources/OpenClaw/HostEnvSecurityPolicy.generated.swift
index 2981a60bbf7..932c9fc5e61 100644
--- a/apps/macos/Sources/OpenClaw/HostEnvSecurityPolicy.generated.swift
+++ b/apps/macos/Sources/OpenClaw/HostEnvSecurityPolicy.generated.swift
@@ -17,6 +17,7 @@ enum HostEnvSecurityPolicy {
"BASH_ENV",
"ENV",
"GIT_EXTERNAL_DIFF",
+ "GIT_EXEC_PATH",
"SHELL",
"SHELLOPTS",
"PS4",
diff --git a/apps/macos/Sources/OpenClaw/NodeMode/MacNodeModeCoordinator.swift b/apps/macos/Sources/OpenClaw/NodeMode/MacNodeModeCoordinator.swift
index fa216d09c5f..5e093c49e24 100644
--- a/apps/macos/Sources/OpenClaw/NodeMode/MacNodeModeCoordinator.swift
+++ b/apps/macos/Sources/OpenClaw/NodeMode/MacNodeModeCoordinator.swift
@@ -77,6 +77,7 @@ final class MacNodeModeCoordinator {
try await self.session.connect(
url: config.url,
token: config.token,
+ bootstrapToken: nil,
password: config.password,
connectOptions: connectOptions,
sessionBox: sessionBox,
diff --git a/apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift b/apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift
index 0beeb2bdc27..f35e4e4c4ec 100644
--- a/apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift
+++ b/apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift
@@ -508,6 +508,8 @@ extension OnboardingView {
return ("exclamationmark.triangle.fill", .orange)
case .gatewayTokenNotConfigured:
return ("wrench.and.screwdriver.fill", .orange)
+ case .setupCodeExpired:
+ return ("qrcode.viewfinder", .orange)
case .passwordRequired:
return ("lock.slash.fill", .orange)
case .pairingRequired:
diff --git a/apps/macos/Sources/OpenClaw/RemoteGatewayProbe.swift b/apps/macos/Sources/OpenClaw/RemoteGatewayProbe.swift
index f878d0f5e28..7073ad81de7 100644
--- a/apps/macos/Sources/OpenClaw/RemoteGatewayProbe.swift
+++ b/apps/macos/Sources/OpenClaw/RemoteGatewayProbe.swift
@@ -6,6 +6,7 @@ enum RemoteGatewayAuthIssue: Equatable {
case tokenRequired
case tokenMismatch
case gatewayTokenNotConfigured
+ case setupCodeExpired
case passwordRequired
case pairingRequired
@@ -20,6 +21,8 @@ enum RemoteGatewayAuthIssue: Equatable {
self = .tokenMismatch
case .authTokenNotConfigured:
self = .gatewayTokenNotConfigured
+ case .authBootstrapTokenInvalid:
+ self = .setupCodeExpired
case .authPasswordMissing, .authPasswordMismatch, .authPasswordNotConfigured:
self = .passwordRequired
case .pairingRequired:
@@ -33,7 +36,7 @@ enum RemoteGatewayAuthIssue: Equatable {
switch self {
case .tokenRequired, .tokenMismatch:
true
- case .gatewayTokenNotConfigured, .passwordRequired, .pairingRequired:
+ case .gatewayTokenNotConfigured, .setupCodeExpired, .passwordRequired, .pairingRequired:
false
}
}
@@ -46,6 +49,8 @@ enum RemoteGatewayAuthIssue: Equatable {
"That token did not match the gateway"
case .gatewayTokenNotConfigured:
"This gateway host needs token setup"
+ case .setupCodeExpired:
+ "This setup code is no longer valid"
case .passwordRequired:
"This gateway is using unsupported auth"
case .pairingRequired:
@@ -61,6 +66,8 @@ enum RemoteGatewayAuthIssue: Equatable {
"Check `gateway.auth.token` or `OPENCLAW_GATEWAY_TOKEN` on the gateway host and try again."
case .gatewayTokenNotConfigured:
"This gateway is set to token auth, but no `gateway.auth.token` is configured on the gateway host. If the gateway uses an environment variable instead, set `OPENCLAW_GATEWAY_TOKEN` before starting the gateway."
+ case .setupCodeExpired:
+ "Scan or paste a fresh setup code from an already-paired OpenClaw client, then try again."
case .passwordRequired:
"This onboarding flow does not support password auth yet. Reconfigure the gateway to use token auth, then retry."
case .pairingRequired:
@@ -72,6 +79,8 @@ enum RemoteGatewayAuthIssue: Equatable {
switch self {
case .tokenRequired, .gatewayTokenNotConfigured:
"No token yet? Generate one on the gateway host with `openclaw doctor --generate-gateway-token`, then set it as `gateway.auth.token`."
+ case .setupCodeExpired:
+ nil
case .pairingRequired:
"If you do not have another paired OpenClaw client yet, approve the pending request on the gateway host with `openclaw devices approve`."
case .tokenMismatch, .passwordRequired:
@@ -87,6 +96,8 @@ enum RemoteGatewayAuthIssue: Equatable {
"Gateway token mismatch. Check gateway.auth.token or OPENCLAW_GATEWAY_TOKEN on the gateway host."
case .gatewayTokenNotConfigured:
"This gateway has token auth enabled, but no gateway.auth.token is configured on the host."
+ case .setupCodeExpired:
+ "Setup code expired or already used. Scan a fresh setup code, then try again."
case .passwordRequired:
"This gateway uses password auth. Remote onboarding on macOS cannot collect gateway passwords yet."
case .pairingRequired:
@@ -108,6 +119,8 @@ struct RemoteGatewayProbeSuccess: Equatable {
switch self.authSource {
case .some(.deviceToken):
"Connected via paired device"
+ case .some(.bootstrapToken):
+ "Connected with setup code"
case .some(.sharedToken):
"Connected with gateway token"
case .some(.password):
@@ -121,6 +134,8 @@ struct RemoteGatewayProbeSuccess: Equatable {
switch self.authSource {
case .some(.deviceToken):
"This Mac used a stored device token. New or unpaired devices may still need the gateway token."
+ case .some(.bootstrapToken):
+ "This Mac is still using the temporary setup code. Approve pairing to finish provisioning device-scoped auth."
case .some(.sharedToken), .some(.password), .some(GatewayAuthSource.none), nil:
nil
}
diff --git a/apps/macos/Sources/OpenClaw/Resources/Info.plist b/apps/macos/Sources/OpenClaw/Resources/Info.plist
index 4a6f9003f75..6c9398474ca 100644
--- a/apps/macos/Sources/OpenClaw/Resources/Info.plist
+++ b/apps/macos/Sources/OpenClaw/Resources/Info.plist
@@ -15,9 +15,9 @@
CFBundlePackageType
APPL
CFBundleShortVersionString
- 2026.3.11
+ 2026.3.12
CFBundleVersion
- 202603110
+ 202603120
CFBundleIconFile
OpenClaw
CFBundleURLTypes
@@ -59,6 +59,8 @@
OpenClaw uses speech recognition to detect your Voice Wake trigger phrase.
NSAppleEventsUsageDescription
OpenClaw needs Automation (AppleScript) permission to drive Terminal and other apps for agent actions.
+ NSRemindersUsageDescription
+ OpenClaw can access Reminders when requested by the agent for the apple-reminders skill.
NSAppTransportSecurity
diff --git a/apps/macos/Sources/OpenClaw/WebChatSwiftUI.swift b/apps/macos/Sources/OpenClaw/WebChatSwiftUI.swift
index 9110ce59faf..86c225f9ef0 100644
--- a/apps/macos/Sources/OpenClaw/WebChatSwiftUI.swift
+++ b/apps/macos/Sources/OpenClaw/WebChatSwiftUI.swift
@@ -59,7 +59,23 @@ struct MacGatewayChatTransport: OpenClawChatTransport {
method: "sessions.list",
params: params,
timeoutMs: 15000)
- return try JSONDecoder().decode(OpenClawChatSessionsListResponse.self, from: data)
+ let decoded = try JSONDecoder().decode(OpenClawChatSessionsListResponse.self, from: data)
+ let mainSessionKey = await GatewayConnection.shared.cachedMainSessionKey()
+ let defaults = decoded.defaults.map {
+ OpenClawChatSessionsDefaults(
+ model: $0.model,
+ contextTokens: $0.contextTokens,
+ mainSessionKey: mainSessionKey)
+ } ?? OpenClawChatSessionsDefaults(
+ model: nil,
+ contextTokens: nil,
+ mainSessionKey: mainSessionKey)
+ return OpenClawChatSessionsListResponse(
+ ts: decoded.ts,
+ path: decoded.path,
+ count: decoded.count,
+ defaults: defaults,
+ sessions: decoded.sessions)
}
func setSessionModel(sessionKey: String, model: String?) async throws {
@@ -103,6 +119,13 @@ struct MacGatewayChatTransport: OpenClawChatTransport {
try await GatewayConnection.shared.healthOK(timeoutMs: timeoutMs)
}
+ func resetSession(sessionKey: String) async throws {
+ _ = try await GatewayConnection.shared.request(
+ method: "sessions.reset",
+ params: ["key": AnyCodable(sessionKey)],
+ timeoutMs: 10000)
+ }
+
func events() -> AsyncStream {
AsyncStream { continuation in
let task = Task {
diff --git a/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift b/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift
index ea85e6c1511..3003ae79f7b 100644
--- a/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift
+++ b/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift
@@ -538,8 +538,6 @@ public struct AgentParams: Codable, Sendable {
public let inputprovenance: [String: AnyCodable]?
public let idempotencykey: String
public let label: String?
- public let spawnedby: String?
- public let workspacedir: String?
public init(
message: String,
@@ -566,9 +564,7 @@ public struct AgentParams: Codable, Sendable {
internalevents: [[String: AnyCodable]]?,
inputprovenance: [String: AnyCodable]?,
idempotencykey: String,
- label: String?,
- spawnedby: String?,
- workspacedir: String?)
+ label: String?)
{
self.message = message
self.agentid = agentid
@@ -595,8 +591,6 @@ public struct AgentParams: Codable, Sendable {
self.inputprovenance = inputprovenance
self.idempotencykey = idempotencykey
self.label = label
- self.spawnedby = spawnedby
- self.workspacedir = workspacedir
}
private enum CodingKeys: String, CodingKey {
@@ -625,8 +619,6 @@ public struct AgentParams: Codable, Sendable {
case inputprovenance = "inputProvenance"
case idempotencykey = "idempotencyKey"
case label
- case spawnedby = "spawnedBy"
- case workspacedir = "workspaceDir"
}
}
@@ -1114,6 +1106,7 @@ public struct PushTestResult: Codable, Sendable {
public let tokensuffix: String
public let topic: String
public let environment: String
+ public let transport: String
public init(
ok: Bool,
@@ -1122,7 +1115,8 @@ public struct PushTestResult: Codable, Sendable {
reason: String?,
tokensuffix: String,
topic: String,
- environment: String)
+ environment: String,
+ transport: String)
{
self.ok = ok
self.status = status
@@ -1131,6 +1125,7 @@ public struct PushTestResult: Codable, Sendable {
self.tokensuffix = tokensuffix
self.topic = topic
self.environment = environment
+ self.transport = transport
}
private enum CodingKeys: String, CodingKey {
@@ -1141,6 +1136,7 @@ public struct PushTestResult: Codable, Sendable {
case tokensuffix = "tokenSuffix"
case topic
case environment
+ case transport
}
}
@@ -1326,6 +1322,7 @@ public struct SessionsPatchParams: Codable, Sendable {
public let key: String
public let label: AnyCodable?
public let thinkinglevel: AnyCodable?
+ public let fastmode: AnyCodable?
public let verboselevel: AnyCodable?
public let reasoninglevel: AnyCodable?
public let responseusage: AnyCodable?
@@ -1336,6 +1333,7 @@ public struct SessionsPatchParams: Codable, Sendable {
public let execnode: AnyCodable?
public let model: AnyCodable?
public let spawnedby: AnyCodable?
+ public let spawnedworkspacedir: AnyCodable?
public let spawndepth: AnyCodable?
public let subagentrole: AnyCodable?
public let subagentcontrolscope: AnyCodable?
@@ -1346,6 +1344,7 @@ public struct SessionsPatchParams: Codable, Sendable {
key: String,
label: AnyCodable?,
thinkinglevel: AnyCodable?,
+ fastmode: AnyCodable?,
verboselevel: AnyCodable?,
reasoninglevel: AnyCodable?,
responseusage: AnyCodable?,
@@ -1356,6 +1355,7 @@ public struct SessionsPatchParams: Codable, Sendable {
execnode: AnyCodable?,
model: AnyCodable?,
spawnedby: AnyCodable?,
+ spawnedworkspacedir: AnyCodable?,
spawndepth: AnyCodable?,
subagentrole: AnyCodable?,
subagentcontrolscope: AnyCodable?,
@@ -1365,6 +1365,7 @@ public struct SessionsPatchParams: Codable, Sendable {
self.key = key
self.label = label
self.thinkinglevel = thinkinglevel
+ self.fastmode = fastmode
self.verboselevel = verboselevel
self.reasoninglevel = reasoninglevel
self.responseusage = responseusage
@@ -1375,6 +1376,7 @@ public struct SessionsPatchParams: Codable, Sendable {
self.execnode = execnode
self.model = model
self.spawnedby = spawnedby
+ self.spawnedworkspacedir = spawnedworkspacedir
self.spawndepth = spawndepth
self.subagentrole = subagentrole
self.subagentcontrolscope = subagentcontrolscope
@@ -1386,6 +1388,7 @@ public struct SessionsPatchParams: Codable, Sendable {
case key
case label
case thinkinglevel = "thinkingLevel"
+ case fastmode = "fastMode"
case verboselevel = "verboseLevel"
case reasoninglevel = "reasoningLevel"
case responseusage = "responseUsage"
@@ -1396,6 +1399,7 @@ public struct SessionsPatchParams: Codable, Sendable {
case execnode = "execNode"
case model
case spawnedby = "spawnedBy"
+ case spawnedworkspacedir = "spawnedWorkspaceDir"
case spawndepth = "spawnDepth"
case subagentrole = "subagentRole"
case subagentcontrolscope = "subagentControlScope"
diff --git a/apps/macos/Tests/OpenClawIPCTests/OnboardingRemoteAuthPromptTests.swift b/apps/macos/Tests/OpenClawIPCTests/OnboardingRemoteAuthPromptTests.swift
index d33cff562f9..00f3e704708 100644
--- a/apps/macos/Tests/OpenClawIPCTests/OnboardingRemoteAuthPromptTests.swift
+++ b/apps/macos/Tests/OpenClawIPCTests/OnboardingRemoteAuthPromptTests.swift
@@ -17,6 +17,10 @@ struct OnboardingRemoteAuthPromptTests {
message: "token not configured",
detailCode: GatewayConnectAuthDetailCode.authTokenNotConfigured.rawValue,
canRetryWithDeviceToken: false)
+ let bootstrapInvalid = GatewayConnectAuthError(
+ message: "setup code expired",
+ detailCode: GatewayConnectAuthDetailCode.authBootstrapTokenInvalid.rawValue,
+ canRetryWithDeviceToken: false)
let passwordMissing = GatewayConnectAuthError(
message: "password missing",
detailCode: GatewayConnectAuthDetailCode.authPasswordMissing.rawValue,
@@ -33,6 +37,7 @@ struct OnboardingRemoteAuthPromptTests {
#expect(RemoteGatewayAuthIssue(error: tokenMissing) == .tokenRequired)
#expect(RemoteGatewayAuthIssue(error: tokenMismatch) == .tokenMismatch)
#expect(RemoteGatewayAuthIssue(error: tokenNotConfigured) == .gatewayTokenNotConfigured)
+ #expect(RemoteGatewayAuthIssue(error: bootstrapInvalid) == .setupCodeExpired)
#expect(RemoteGatewayAuthIssue(error: passwordMissing) == .passwordRequired)
#expect(RemoteGatewayAuthIssue(error: pairingRequired) == .pairingRequired)
#expect(RemoteGatewayAuthIssue(error: unknown) == nil)
@@ -88,6 +93,11 @@ struct OnboardingRemoteAuthPromptTests {
remoteToken: "",
remoteTokenUnsupported: false,
authIssue: .gatewayTokenNotConfigured) == false)
+ #expect(OnboardingView.shouldShowRemoteTokenField(
+ showAdvancedConnection: false,
+ remoteToken: "",
+ remoteTokenUnsupported: false,
+ authIssue: .setupCodeExpired) == false)
#expect(OnboardingView.shouldShowRemoteTokenField(
showAdvancedConnection: false,
remoteToken: "",
@@ -106,11 +116,14 @@ struct OnboardingRemoteAuthPromptTests {
@Test func `paired device success copy explains auth source`() {
let pairedDevice = RemoteGatewayProbeSuccess(authSource: .deviceToken)
+ let bootstrap = RemoteGatewayProbeSuccess(authSource: .bootstrapToken)
let sharedToken = RemoteGatewayProbeSuccess(authSource: .sharedToken)
let noAuth = RemoteGatewayProbeSuccess(authSource: GatewayAuthSource.none)
#expect(pairedDevice.title == "Connected via paired device")
#expect(pairedDevice.detail == "This Mac used a stored device token. New or unpaired devices may still need the gateway token.")
+ #expect(bootstrap.title == "Connected with setup code")
+ #expect(bootstrap.detail == "This Mac is still using the temporary setup code. Approve pairing to finish provisioning device-scoped auth.")
#expect(sharedToken.title == "Connected with gateway token")
#expect(sharedToken.detail == nil)
#expect(noAuth.title == "Remote gateway ready")
diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatSessions.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatSessions.swift
index 48f01e09c6a..c5a74c9a9aa 100644
--- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatSessions.swift
+++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatSessions.swift
@@ -34,6 +34,13 @@ public struct OpenClawChatModelChoice: Identifiable, Codable, Sendable, Hashable
public struct OpenClawChatSessionsDefaults: Codable, Sendable {
public let model: String?
public let contextTokens: Int?
+ public let mainSessionKey: String?
+
+ public init(model: String?, contextTokens: Int?, mainSessionKey: String? = nil) {
+ self.model = model
+ self.contextTokens = contextTokens
+ self.mainSessionKey = mainSessionKey
+ }
}
public struct OpenClawChatSessionEntry: Codable, Identifiable, Sendable, Hashable {
@@ -69,4 +76,18 @@ public struct OpenClawChatSessionsListResponse: Codable, Sendable {
public let count: Int?
public let defaults: OpenClawChatSessionsDefaults?
public let sessions: [OpenClawChatSessionEntry]
+
+ public init(
+ ts: Double?,
+ path: String?,
+ count: Int?,
+ defaults: OpenClawChatSessionsDefaults?,
+ sessions: [OpenClawChatSessionEntry])
+ {
+ self.ts = ts
+ self.path = path
+ self.count = count
+ self.defaults = defaults
+ self.sessions = sessions
+ }
}
diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatTransport.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatTransport.swift
index bfbd33bfda3..49bd91db372 100644
--- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatTransport.swift
+++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatTransport.swift
@@ -27,11 +27,19 @@ public protocol OpenClawChatTransport: Sendable {
func events() -> AsyncStream
func setActiveSessionKey(_ sessionKey: String) async throws
+ func resetSession(sessionKey: String) async throws
}
extension OpenClawChatTransport {
public func setActiveSessionKey(_: String) async throws {}
+ public func resetSession(sessionKey _: String) async throws {
+ throw NSError(
+ domain: "OpenClawChatTransport",
+ code: 0,
+ userInfo: [NSLocalizedDescriptionKey: "sessions.reset not supported by this transport"])
+ }
+
public func abortRun(sessionKey _: String, runId _: String) async throws {
throw NSError(
domain: "OpenClawChatTransport",
diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel.swift
index a136469fbd8..92413aefe64 100644
--- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel.swift
+++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel.swift
@@ -138,21 +138,23 @@ public final class OpenClawChatViewModel {
let now = Date().timeIntervalSince1970 * 1000
let cutoff = now - (24 * 60 * 60 * 1000)
let sorted = self.sessions.sorted { ($0.updatedAt ?? 0) > ($1.updatedAt ?? 0) }
+ let mainSessionKey = self.resolvedMainSessionKey
var result: [OpenClawChatSessionEntry] = []
var included = Set()
- // Always show the main session first, even if it hasn't been updated recently.
- if let main = sorted.first(where: { $0.key == "main" }) {
+ // Always show the resolved main session first, even if it hasn't been updated recently.
+ if let main = sorted.first(where: { $0.key == mainSessionKey }) {
result.append(main)
included.insert(main.key)
} else {
- result.append(self.placeholderSession(key: "main"))
- included.insert("main")
+ result.append(self.placeholderSession(key: mainSessionKey))
+ included.insert(mainSessionKey)
}
for entry in sorted {
guard !included.contains(entry.key) else { continue }
+ guard entry.key == self.sessionKey || !Self.isHiddenInternalSession(entry.key) else { continue }
guard (entry.updatedAt ?? 0) >= cutoff else { continue }
result.append(entry)
included.insert(entry.key)
@@ -169,6 +171,18 @@ public final class OpenClawChatViewModel {
return result
}
+ private var resolvedMainSessionKey: String {
+ let trimmed = self.sessionDefaults?.mainSessionKey?
+ .trimmingCharacters(in: .whitespacesAndNewlines)
+ return (trimmed?.isEmpty == false ? trimmed : nil) ?? "main"
+ }
+
+ private static func isHiddenInternalSession(_ key: String) -> Bool {
+ let trimmed = key.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !trimmed.isEmpty else { return false }
+ return trimmed == "onboarding" || trimmed.hasSuffix(":onboarding")
+ }
+
public var showsModelPicker: Bool {
!self.modelChoices.isEmpty
}
@@ -365,10 +379,19 @@ public final class OpenClawChatViewModel {
return "\(message.role)|\(timestamp)|\(text)"
}
+ private static let resetTriggers: Set = ["/new", "/reset", "/clear"]
+
private func performSend() async {
guard !self.isSending else { return }
let trimmed = self.input.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty || !self.attachments.isEmpty else { return }
+
+ if Self.resetTriggers.contains(trimmed.lowercased()) {
+ self.input = ""
+ await self.performReset()
+ return
+ }
+
let sessionKey = self.sessionKey
guard self.healthOK else {
@@ -499,6 +522,22 @@ public final class OpenClawChatViewModel {
await self.bootstrap()
}
+ private func performReset() async {
+ self.isLoading = true
+ self.errorText = nil
+ defer { self.isLoading = false }
+
+ do {
+ try await self.transport.resetSession(sessionKey: self.sessionKey)
+ } catch {
+ self.errorText = error.localizedDescription
+ chatUILogger.error("session reset failed \(error.localizedDescription, privacy: .public)")
+ return
+ }
+
+ await self.bootstrap()
+ }
+
private func performSelectThinkingLevel(_ level: String) async {
let next = Self.normalizedThinkingLevel(level) ?? "off"
guard next != self.thinkingLevel else { return }
@@ -549,7 +588,9 @@ public final class OpenClawChatViewModel {
sessionKey: sessionKey,
model: nextModelRef)
guard requestID == self.latestModelSelectionRequestIDsBySession[sessionKey] else {
- self.applySuccessfulModelSelection(next, sessionKey: sessionKey, syncSelection: false)
+ // Keep older successful patches as rollback state, but do not replay
+ // stale UI/session state over a newer in-flight or completed selection.
+ self.lastSuccessfulModelSelectionIDsBySession[sessionKey] = next
return
}
self.applySuccessfulModelSelection(next, sessionKey: sessionKey, syncSelection: true)
diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/DeepLinks.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/DeepLinks.swift
index 20b3761668b..5f1440ccb1a 100644
--- a/apps/shared/OpenClawKit/Sources/OpenClawKit/DeepLinks.swift
+++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/DeepLinks.swift
@@ -9,13 +9,15 @@ public struct GatewayConnectDeepLink: Codable, Sendable, Equatable {
public let host: String
public let port: Int
public let tls: Bool
+ public let bootstrapToken: String?
public let token: String?
public let password: String?
- public init(host: String, port: Int, tls: Bool, token: String?, password: String?) {
+ public init(host: String, port: Int, tls: Bool, bootstrapToken: String?, token: String?, password: String?) {
self.host = host
self.port = port
self.tls = tls
+ self.bootstrapToken = bootstrapToken
self.token = token
self.password = password
}
@@ -25,7 +27,7 @@ public struct GatewayConnectDeepLink: Codable, Sendable, Equatable {
return URL(string: "\(scheme)://\(self.host):\(self.port)")
}
- /// Parse a device-pair setup code (base64url-encoded JSON: `{url, token?, password?}`).
+ /// Parse a device-pair setup code (base64url-encoded JSON: `{url, bootstrapToken?, token?, password?}`).
public static func fromSetupCode(_ code: String) -> GatewayConnectDeepLink? {
guard let data = Self.decodeBase64Url(code) else { return nil }
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return nil }
@@ -41,9 +43,16 @@ public struct GatewayConnectDeepLink: Codable, Sendable, Equatable {
return nil
}
let port = parsed.port ?? (tls ? 443 : 18789)
+ let bootstrapToken = json["bootstrapToken"] as? String
let token = json["token"] as? String
let password = json["password"] as? String
- return GatewayConnectDeepLink(host: hostname, port: port, tls: tls, token: token, password: password)
+ return GatewayConnectDeepLink(
+ host: hostname,
+ port: port,
+ tls: tls,
+ bootstrapToken: bootstrapToken,
+ token: token,
+ password: password)
}
private static func decodeBase64Url(_ input: String) -> Data? {
@@ -140,6 +149,7 @@ public enum DeepLinkParser {
host: hostParam,
port: port,
tls: tls,
+ bootstrapToken: nil,
token: query["token"],
password: query["password"]))
diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift
index 4848043980b..2c3da84af68 100644
--- a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift
+++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift
@@ -112,6 +112,7 @@ public struct GatewayConnectOptions: Sendable {
public enum GatewayAuthSource: String, Sendable {
case deviceToken = "device-token"
case sharedToken = "shared-token"
+ case bootstrapToken = "bootstrap-token"
case password = "password"
case none = "none"
}
@@ -131,6 +132,22 @@ private let defaultOperatorConnectScopes: [String] = [
"operator.pairing",
]
+private extension String {
+ var nilIfEmpty: String? {
+ self.isEmpty ? nil : self
+ }
+}
+
+private struct SelectedConnectAuth: Sendable {
+ let authToken: String?
+ let authBootstrapToken: String?
+ let authDeviceToken: String?
+ let authPassword: String?
+ let signatureToken: String?
+ let storedToken: String?
+ let authSource: GatewayAuthSource
+}
+
private enum GatewayConnectErrorCodes {
static let authTokenMismatch = GatewayConnectAuthDetailCode.authTokenMismatch.rawValue
static let authDeviceTokenMismatch = GatewayConnectAuthDetailCode.authDeviceTokenMismatch.rawValue
@@ -154,6 +171,7 @@ public actor GatewayChannelActor {
private var connectWaiters: [CheckedContinuation] = []
private var url: URL
private var token: String?
+ private var bootstrapToken: String?
private var password: String?
private let session: WebSocketSessioning
private var backoffMs: Double = 500
@@ -185,6 +203,7 @@ public actor GatewayChannelActor {
public init(
url: URL,
token: String?,
+ bootstrapToken: String? = nil,
password: String? = nil,
session: WebSocketSessionBox? = nil,
pushHandler: (@Sendable (GatewayPush) async -> Void)? = nil,
@@ -193,6 +212,7 @@ public actor GatewayChannelActor {
{
self.url = url
self.token = token
+ self.bootstrapToken = bootstrapToken
self.password = password
self.session = session?.session ?? URLSession(configuration: .default)
self.pushHandler = pushHandler
@@ -398,39 +418,24 @@ public actor GatewayChannelActor {
}
let includeDeviceIdentity = options.includeDeviceIdentity
let identity = includeDeviceIdentity ? DeviceIdentityStore.loadOrCreate() : nil
- let storedToken =
- (includeDeviceIdentity && identity != nil)
- ? DeviceAuthStore.loadToken(deviceId: identity!.deviceId, role: role)?.token
- : nil
- let shouldUseDeviceRetryToken =
- includeDeviceIdentity && self.pendingDeviceTokenRetry &&
- storedToken != nil && self.token != nil && self.isTrustedDeviceRetryEndpoint()
- if shouldUseDeviceRetryToken {
+ let selectedAuth = self.selectConnectAuth(
+ role: role,
+ includeDeviceIdentity: includeDeviceIdentity,
+ deviceId: identity?.deviceId)
+ if selectedAuth.authDeviceToken != nil && self.pendingDeviceTokenRetry {
self.pendingDeviceTokenRetry = false
}
- // Keep shared credentials explicit when provided. Device token retry is attached
- // only on a bounded second attempt after token mismatch.
- let authToken = self.token ?? (includeDeviceIdentity ? storedToken : nil)
- let authDeviceToken = shouldUseDeviceRetryToken ? storedToken : nil
- let authSource: GatewayAuthSource
- if authDeviceToken != nil || (self.token == nil && storedToken != nil) {
- authSource = .deviceToken
- } else if authToken != nil {
- authSource = .sharedToken
- } else if self.password != nil {
- authSource = .password
- } else {
- authSource = .none
- }
- self.lastAuthSource = authSource
- self.logger.info("gateway connect auth=\(authSource.rawValue, privacy: .public)")
- if let authToken {
+ self.lastAuthSource = selectedAuth.authSource
+ self.logger.info("gateway connect auth=\(selectedAuth.authSource.rawValue, privacy: .public)")
+ if let authToken = selectedAuth.authToken {
var auth: [String: ProtoAnyCodable] = ["token": ProtoAnyCodable(authToken)]
- if let authDeviceToken {
+ if let authDeviceToken = selectedAuth.authDeviceToken {
auth["deviceToken"] = ProtoAnyCodable(authDeviceToken)
}
params["auth"] = ProtoAnyCodable(auth)
- } else if let password = self.password {
+ } else if let authBootstrapToken = selectedAuth.authBootstrapToken {
+ params["auth"] = ProtoAnyCodable(["bootstrapToken": ProtoAnyCodable(authBootstrapToken)])
+ } else if let password = selectedAuth.authPassword {
params["auth"] = ProtoAnyCodable(["password": ProtoAnyCodable(password)])
}
let signedAtMs = Int(Date().timeIntervalSince1970 * 1000)
@@ -443,7 +448,7 @@ public actor GatewayChannelActor {
role: role,
scopes: scopes,
signedAtMs: signedAtMs,
- token: authToken,
+ token: selectedAuth.signatureToken,
nonce: connectNonce,
platform: platform,
deviceFamily: InstanceIdentity.deviceFamily)
@@ -472,14 +477,14 @@ public actor GatewayChannelActor {
} catch {
let shouldRetryWithDeviceToken = self.shouldRetryWithStoredDeviceToken(
error: error,
- explicitGatewayToken: self.token,
- storedToken: storedToken,
- attemptedDeviceTokenRetry: authDeviceToken != nil)
+ explicitGatewayToken: self.token?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty,
+ storedToken: selectedAuth.storedToken,
+ attemptedDeviceTokenRetry: selectedAuth.authDeviceToken != nil)
if shouldRetryWithDeviceToken {
self.pendingDeviceTokenRetry = true
self.deviceTokenRetryBudgetUsed = true
self.backoffMs = min(self.backoffMs, 250)
- } else if authDeviceToken != nil,
+ } else if selectedAuth.authDeviceToken != nil,
let identity,
self.shouldClearStoredDeviceTokenAfterRetry(error)
{
@@ -490,6 +495,50 @@ public actor GatewayChannelActor {
}
}
+ private func selectConnectAuth(
+ role: String,
+ includeDeviceIdentity: Bool,
+ deviceId: String?
+ ) -> SelectedConnectAuth {
+ let explicitToken = self.token?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty
+ let explicitBootstrapToken =
+ self.bootstrapToken?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty
+ let explicitPassword = self.password?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty
+ let storedToken =
+ (includeDeviceIdentity && deviceId != nil)
+ ? DeviceAuthStore.loadToken(deviceId: deviceId!, role: role)?.token
+ : nil
+ let shouldUseDeviceRetryToken =
+ includeDeviceIdentity && self.pendingDeviceTokenRetry &&
+ storedToken != nil && explicitToken != nil && self.isTrustedDeviceRetryEndpoint()
+ let authToken =
+ explicitToken ??
+ (includeDeviceIdentity && explicitPassword == nil &&
+ (explicitBootstrapToken == nil || storedToken != nil) ? storedToken : nil)
+ let authBootstrapToken = authToken == nil ? explicitBootstrapToken : nil
+ let authDeviceToken = shouldUseDeviceRetryToken ? storedToken : nil
+ let authSource: GatewayAuthSource
+ if authDeviceToken != nil || (explicitToken == nil && authToken != nil) {
+ authSource = .deviceToken
+ } else if authToken != nil {
+ authSource = .sharedToken
+ } else if authBootstrapToken != nil {
+ authSource = .bootstrapToken
+ } else if explicitPassword != nil {
+ authSource = .password
+ } else {
+ authSource = .none
+ }
+ return SelectedConnectAuth(
+ authToken: authToken,
+ authBootstrapToken: authBootstrapToken,
+ authDeviceToken: authDeviceToken,
+ authPassword: explicitPassword,
+ signatureToken: authToken ?? authBootstrapToken,
+ storedToken: storedToken,
+ authSource: authSource)
+ }
+
private func handleConnectResponse(
_ res: ResponseFrame,
identity: DeviceIdentity?,
@@ -892,7 +941,8 @@ public actor GatewayChannelActor {
return (id: id, data: data)
} catch {
self.logger.error(
- "gateway \(kind) encode failed \(method, privacy: .public) error=\(error.localizedDescription, privacy: .public)")
+ "gateway \(kind) encode failed \(method, privacy: .public) error=\(error.localizedDescription, privacy: .public)"
+ )
throw error
}
}
diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayErrors.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayErrors.swift
index 3b1d97059a3..7ef7f466476 100644
--- a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayErrors.swift
+++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayErrors.swift
@@ -5,6 +5,7 @@ public enum GatewayConnectAuthDetailCode: String, Sendable {
case authRequired = "AUTH_REQUIRED"
case authUnauthorized = "AUTH_UNAUTHORIZED"
case authTokenMismatch = "AUTH_TOKEN_MISMATCH"
+ case authBootstrapTokenInvalid = "AUTH_BOOTSTRAP_TOKEN_INVALID"
case authDeviceTokenMismatch = "AUTH_DEVICE_TOKEN_MISMATCH"
case authTokenMissing = "AUTH_TOKEN_MISSING"
case authTokenNotConfigured = "AUTH_TOKEN_NOT_CONFIGURED"
@@ -92,6 +93,7 @@ public struct GatewayConnectAuthError: LocalizedError, Sendable {
public var isNonRecoverable: Bool {
switch self.detail {
case .authTokenMissing,
+ .authBootstrapTokenInvalid,
.authTokenNotConfigured,
.authPasswordMissing,
.authPasswordMismatch,
diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayNodeSession.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayNodeSession.swift
index 378ad10e365..945e482bbbf 100644
--- a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayNodeSession.swift
+++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayNodeSession.swift
@@ -64,6 +64,7 @@ public actor GatewayNodeSession {
private var channel: GatewayChannelActor?
private var activeURL: URL?
private var activeToken: String?
+ private var activeBootstrapToken: String?
private var activePassword: String?
private var activeConnectOptionsKey: String?
private var connectOptions: GatewayConnectOptions?
@@ -194,6 +195,7 @@ public actor GatewayNodeSession {
public func connect(
url: URL,
token: String?,
+ bootstrapToken: String?,
password: String?,
connectOptions: GatewayConnectOptions,
sessionBox: WebSocketSessionBox?,
@@ -204,6 +206,7 @@ public actor GatewayNodeSession {
let nextOptionsKey = self.connectOptionsKey(connectOptions)
let shouldReconnect = self.activeURL != url ||
self.activeToken != token ||
+ self.activeBootstrapToken != bootstrapToken ||
self.activePassword != password ||
self.activeConnectOptionsKey != nextOptionsKey ||
self.channel == nil
@@ -221,6 +224,7 @@ public actor GatewayNodeSession {
let channel = GatewayChannelActor(
url: url,
token: token,
+ bootstrapToken: bootstrapToken,
password: password,
session: sessionBox,
pushHandler: { [weak self] push in
@@ -233,6 +237,7 @@ public actor GatewayNodeSession {
self.channel = channel
self.activeURL = url
self.activeToken = token
+ self.activeBootstrapToken = bootstrapToken
self.activePassword = password
self.activeConnectOptionsKey = nextOptionsKey
}
@@ -257,6 +262,7 @@ public actor GatewayNodeSession {
self.channel = nil
self.activeURL = nil
self.activeToken = nil
+ self.activeBootstrapToken = nil
self.activePassword = nil
self.activeConnectOptionsKey = nil
self.hasEverConnected = false
diff --git a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift
index ea85e6c1511..3003ae79f7b 100644
--- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift
+++ b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift
@@ -538,8 +538,6 @@ public struct AgentParams: Codable, Sendable {
public let inputprovenance: [String: AnyCodable]?
public let idempotencykey: String
public let label: String?
- public let spawnedby: String?
- public let workspacedir: String?
public init(
message: String,
@@ -566,9 +564,7 @@ public struct AgentParams: Codable, Sendable {
internalevents: [[String: AnyCodable]]?,
inputprovenance: [String: AnyCodable]?,
idempotencykey: String,
- label: String?,
- spawnedby: String?,
- workspacedir: String?)
+ label: String?)
{
self.message = message
self.agentid = agentid
@@ -595,8 +591,6 @@ public struct AgentParams: Codable, Sendable {
self.inputprovenance = inputprovenance
self.idempotencykey = idempotencykey
self.label = label
- self.spawnedby = spawnedby
- self.workspacedir = workspacedir
}
private enum CodingKeys: String, CodingKey {
@@ -625,8 +619,6 @@ public struct AgentParams: Codable, Sendable {
case inputprovenance = "inputProvenance"
case idempotencykey = "idempotencyKey"
case label
- case spawnedby = "spawnedBy"
- case workspacedir = "workspaceDir"
}
}
@@ -1114,6 +1106,7 @@ public struct PushTestResult: Codable, Sendable {
public let tokensuffix: String
public let topic: String
public let environment: String
+ public let transport: String
public init(
ok: Bool,
@@ -1122,7 +1115,8 @@ public struct PushTestResult: Codable, Sendable {
reason: String?,
tokensuffix: String,
topic: String,
- environment: String)
+ environment: String,
+ transport: String)
{
self.ok = ok
self.status = status
@@ -1131,6 +1125,7 @@ public struct PushTestResult: Codable, Sendable {
self.tokensuffix = tokensuffix
self.topic = topic
self.environment = environment
+ self.transport = transport
}
private enum CodingKeys: String, CodingKey {
@@ -1141,6 +1136,7 @@ public struct PushTestResult: Codable, Sendable {
case tokensuffix = "tokenSuffix"
case topic
case environment
+ case transport
}
}
@@ -1326,6 +1322,7 @@ public struct SessionsPatchParams: Codable, Sendable {
public let key: String
public let label: AnyCodable?
public let thinkinglevel: AnyCodable?
+ public let fastmode: AnyCodable?
public let verboselevel: AnyCodable?
public let reasoninglevel: AnyCodable?
public let responseusage: AnyCodable?
@@ -1336,6 +1333,7 @@ public struct SessionsPatchParams: Codable, Sendable {
public let execnode: AnyCodable?
public let model: AnyCodable?
public let spawnedby: AnyCodable?
+ public let spawnedworkspacedir: AnyCodable?
public let spawndepth: AnyCodable?
public let subagentrole: AnyCodable?
public let subagentcontrolscope: AnyCodable?
@@ -1346,6 +1344,7 @@ public struct SessionsPatchParams: Codable, Sendable {
key: String,
label: AnyCodable?,
thinkinglevel: AnyCodable?,
+ fastmode: AnyCodable?,
verboselevel: AnyCodable?,
reasoninglevel: AnyCodable?,
responseusage: AnyCodable?,
@@ -1356,6 +1355,7 @@ public struct SessionsPatchParams: Codable, Sendable {
execnode: AnyCodable?,
model: AnyCodable?,
spawnedby: AnyCodable?,
+ spawnedworkspacedir: AnyCodable?,
spawndepth: AnyCodable?,
subagentrole: AnyCodable?,
subagentcontrolscope: AnyCodable?,
@@ -1365,6 +1365,7 @@ public struct SessionsPatchParams: Codable, Sendable {
self.key = key
self.label = label
self.thinkinglevel = thinkinglevel
+ self.fastmode = fastmode
self.verboselevel = verboselevel
self.reasoninglevel = reasoninglevel
self.responseusage = responseusage
@@ -1375,6 +1376,7 @@ public struct SessionsPatchParams: Codable, Sendable {
self.execnode = execnode
self.model = model
self.spawnedby = spawnedby
+ self.spawnedworkspacedir = spawnedworkspacedir
self.spawndepth = spawndepth
self.subagentrole = subagentrole
self.subagentcontrolscope = subagentcontrolscope
@@ -1386,6 +1388,7 @@ public struct SessionsPatchParams: Codable, Sendable {
case key
case label
case thinkinglevel = "thinkingLevel"
+ case fastmode = "fastMode"
case verboselevel = "verboseLevel"
case reasoninglevel = "reasoningLevel"
case responseusage = "responseUsage"
@@ -1396,6 +1399,7 @@ public struct SessionsPatchParams: Codable, Sendable {
case execnode = "execNode"
case model
case spawnedby = "spawnedBy"
+ case spawnedworkspacedir = "spawnedWorkspaceDir"
case spawndepth = "spawnDepth"
case subagentrole = "subagentRole"
case subagentcontrolscope = "subagentControlScope"
diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatViewModelTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatViewModelTests.swift
index abfd267a66c..6d1fa88e569 100644
--- a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatViewModelTests.swift
+++ b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatViewModelTests.swift
@@ -83,6 +83,7 @@ private func makeViewModel(
historyResponses: [OpenClawChatHistoryPayload],
sessionsResponses: [OpenClawChatSessionsListResponse] = [],
modelResponses: [[OpenClawChatModelChoice]] = [],
+ resetSessionHook: (@Sendable (String) async throws -> Void)? = nil,
setSessionModelHook: (@Sendable (String?) async throws -> Void)? = nil,
setSessionThinkingHook: (@Sendable (String) async throws -> Void)? = nil,
initialThinkingLevel: String? = nil,
@@ -93,6 +94,7 @@ private func makeViewModel(
historyResponses: historyResponses,
sessionsResponses: sessionsResponses,
modelResponses: modelResponses,
+ resetSessionHook: resetSessionHook,
setSessionModelHook: setSessionModelHook,
setSessionThinkingHook: setSessionThinkingHook)
let vm = await MainActor.run {
@@ -199,6 +201,7 @@ private actor TestChatTransportState {
var historyCallCount: Int = 0
var sessionsCallCount: Int = 0
var modelsCallCount: Int = 0
+ var resetSessionKeys: [String] = []
var sentRunIds: [String] = []
var sentThinkingLevels: [String] = []
var abortedRunIds: [String] = []
@@ -211,6 +214,7 @@ private final class TestChatTransport: @unchecked Sendable, OpenClawChatTranspor
private let historyResponses: [OpenClawChatHistoryPayload]
private let sessionsResponses: [OpenClawChatSessionsListResponse]
private let modelResponses: [[OpenClawChatModelChoice]]
+ private let resetSessionHook: (@Sendable (String) async throws -> Void)?
private let setSessionModelHook: (@Sendable (String?) async throws -> Void)?
private let setSessionThinkingHook: (@Sendable (String) async throws -> Void)?
@@ -221,12 +225,14 @@ private final class TestChatTransport: @unchecked Sendable, OpenClawChatTranspor
historyResponses: [OpenClawChatHistoryPayload],
sessionsResponses: [OpenClawChatSessionsListResponse] = [],
modelResponses: [[OpenClawChatModelChoice]] = [],
+ resetSessionHook: (@Sendable (String) async throws -> Void)? = nil,
setSessionModelHook: (@Sendable (String?) async throws -> Void)? = nil,
setSessionThinkingHook: (@Sendable (String) async throws -> Void)? = nil)
{
self.historyResponses = historyResponses
self.sessionsResponses = sessionsResponses
self.modelResponses = modelResponses
+ self.resetSessionHook = resetSessionHook
self.setSessionModelHook = setSessionModelHook
self.setSessionThinkingHook = setSessionThinkingHook
var cont: AsyncStream.Continuation!
@@ -301,6 +307,13 @@ private final class TestChatTransport: @unchecked Sendable, OpenClawChatTranspor
}
}
+ func resetSession(sessionKey: String) async throws {
+ await self.state.resetSessionKeysAppend(sessionKey)
+ if let resetSessionHook = self.resetSessionHook {
+ try await resetSessionHook(sessionKey)
+ }
+ }
+
func setSessionThinking(sessionKey _: String, thinkingLevel: String) async throws {
await self.state.patchedThinkingLevelsAppend(thinkingLevel)
if let setSessionThinkingHook = self.setSessionThinkingHook {
@@ -336,6 +349,10 @@ private final class TestChatTransport: @unchecked Sendable, OpenClawChatTranspor
func patchedThinkingLevels() async -> [String] {
await self.state.patchedThinkingLevels
}
+
+ func resetSessionKeys() async -> [String] {
+ await self.state.resetSessionKeys
+ }
}
extension TestChatTransportState {
@@ -370,6 +387,10 @@ extension TestChatTransportState {
fileprivate func patchedThinkingLevelsAppend(_ v: String) {
self.patchedThinkingLevels.append(v)
}
+
+ fileprivate func resetSessionKeysAppend(_ v: String) {
+ self.resetSessionKeys.append(v)
+ }
}
@Suite struct ChatViewModelTests {
@@ -592,6 +613,151 @@ extension TestChatTransportState {
#expect(keys == ["main", "custom"])
}
+ @Test func sessionChoicesUseResolvedMainSessionKeyInsteadOfLiteralMain() async throws {
+ let now = Date().timeIntervalSince1970 * 1000
+ let recent = now - (30 * 60 * 1000)
+ let recentOlder = now - (90 * 60 * 1000)
+ let history = historyPayload(sessionKey: "Luke’s MacBook Pro", sessionId: "sess-main")
+ let sessions = OpenClawChatSessionsListResponse(
+ ts: now,
+ path: nil,
+ count: 2,
+ defaults: OpenClawChatSessionsDefaults(
+ model: nil,
+ contextTokens: nil,
+ mainSessionKey: "Luke’s MacBook Pro"),
+ sessions: [
+ OpenClawChatSessionEntry(
+ key: "Luke’s MacBook Pro",
+ kind: nil,
+ displayName: "Luke’s MacBook Pro",
+ surface: nil,
+ subject: nil,
+ room: nil,
+ space: nil,
+ updatedAt: recent,
+ sessionId: nil,
+ systemSent: nil,
+ abortedLastRun: nil,
+ thinkingLevel: nil,
+ verboseLevel: nil,
+ inputTokens: nil,
+ outputTokens: nil,
+ totalTokens: nil,
+ modelProvider: nil,
+ model: nil,
+ contextTokens: nil),
+ sessionEntry(key: "recent-1", updatedAt: recentOlder),
+ ])
+
+ let (_, vm) = await makeViewModel(
+ sessionKey: "Luke’s MacBook Pro",
+ historyResponses: [history],
+ sessionsResponses: [sessions])
+ await MainActor.run { vm.load() }
+ try await waitUntil("sessions loaded") { await MainActor.run { !vm.sessions.isEmpty } }
+
+ let keys = await MainActor.run { vm.sessionChoices.map(\.key) }
+ #expect(keys == ["Luke’s MacBook Pro", "recent-1"])
+ }
+
+ @Test func sessionChoicesHideInternalOnboardingSession() async throws {
+ let now = Date().timeIntervalSince1970 * 1000
+ let recent = now - (2 * 60 * 1000)
+ let recentOlder = now - (5 * 60 * 1000)
+ let history = historyPayload(sessionKey: "agent:main:main", sessionId: "sess-main")
+ let sessions = OpenClawChatSessionsListResponse(
+ ts: now,
+ path: nil,
+ count: 2,
+ defaults: OpenClawChatSessionsDefaults(
+ model: nil,
+ contextTokens: nil,
+ mainSessionKey: "agent:main:main"),
+ sessions: [
+ OpenClawChatSessionEntry(
+ key: "agent:main:onboarding",
+ kind: nil,
+ displayName: "Luke’s MacBook Pro",
+ surface: nil,
+ subject: nil,
+ room: nil,
+ space: nil,
+ updatedAt: recent,
+ sessionId: nil,
+ systemSent: nil,
+ abortedLastRun: nil,
+ thinkingLevel: nil,
+ verboseLevel: nil,
+ inputTokens: nil,
+ outputTokens: nil,
+ totalTokens: nil,
+ modelProvider: nil,
+ model: nil,
+ contextTokens: nil),
+ OpenClawChatSessionEntry(
+ key: "agent:main:main",
+ kind: nil,
+ displayName: "Luke’s MacBook Pro",
+ surface: nil,
+ subject: nil,
+ room: nil,
+ space: nil,
+ updatedAt: recentOlder,
+ sessionId: nil,
+ systemSent: nil,
+ abortedLastRun: nil,
+ thinkingLevel: nil,
+ verboseLevel: nil,
+ inputTokens: nil,
+ outputTokens: nil,
+ totalTokens: nil,
+ modelProvider: nil,
+ model: nil,
+ contextTokens: nil),
+ ])
+
+ let (_, vm) = await makeViewModel(
+ sessionKey: "agent:main:main",
+ historyResponses: [history],
+ sessionsResponses: [sessions])
+ await MainActor.run { vm.load() }
+ try await waitUntil("sessions loaded") { await MainActor.run { !vm.sessions.isEmpty } }
+
+ let keys = await MainActor.run { vm.sessionChoices.map(\.key) }
+ #expect(keys == ["agent:main:main"])
+ }
+
+ @Test func resetTriggerResetsSessionAndReloadsHistory() async throws {
+ let before = historyPayload(
+ messages: [
+ chatTextMessage(role: "assistant", text: "before reset", timestamp: 1),
+ ])
+ let after = historyPayload(
+ messages: [
+ chatTextMessage(role: "assistant", text: "after reset", timestamp: 2),
+ ])
+
+ let (transport, vm) = await makeViewModel(historyResponses: [before, after])
+ try await loadAndWaitBootstrap(vm: vm)
+ try await waitUntil("initial history loaded") {
+ await MainActor.run { vm.messages.first?.content.first?.text == "before reset" }
+ }
+
+ await MainActor.run {
+ vm.input = "/new"
+ vm.send()
+ }
+
+ try await waitUntil("reset called") {
+ await transport.resetSessionKeys() == ["main"]
+ }
+ try await waitUntil("history reloaded") {
+ await MainActor.run { vm.messages.first?.content.first?.text == "after reset" }
+ }
+ #expect(await transport.lastSentRunId() == nil)
+ }
+
@Test func bootstrapsModelSelectionFromSessionAndDefaults() async throws {
let now = Date().timeIntervalSince1970 * 1000
let history = historyPayload()
@@ -758,7 +924,8 @@ extension TestChatTransportState {
}
#expect(await MainActor.run { vm.modelSelectionID } == "openai/gpt-5.4-pro")
- #expect(await MainActor.run { vm.sessions.first(where: { $0.key == "main" })?.model } == "openai/gpt-5.4-pro")
+ #expect(await MainActor.run { vm.sessions.first(where: { $0.key == "main" })?.model } == "gpt-5.4-pro")
+ #expect(await MainActor.run { vm.sessions.first(where: { $0.key == "main" })?.modelProvider } == "openai")
}
@Test func sendWaitsForInFlightModelPatchToFinish() async throws {
@@ -852,11 +1019,15 @@ extension TestChatTransportState {
}
try await waitUntil("older model completion wins after latest failure") {
- await MainActor.run { vm.sessions.first(where: { $0.key == "main" })?.model == "openai/gpt-5.4" }
+ await MainActor.run {
+ vm.sessions.first(where: { $0.key == "main" })?.model == "gpt-5.4" &&
+ vm.sessions.first(where: { $0.key == "main" })?.modelProvider == "openai"
+ }
}
#expect(await MainActor.run { vm.modelSelectionID } == "openai/gpt-5.4")
- #expect(await MainActor.run { vm.sessions.first(where: { $0.key == "main" })?.model } == "openai/gpt-5.4")
+ #expect(await MainActor.run { vm.sessions.first(where: { $0.key == "main" })?.model } == "gpt-5.4")
+ #expect(await MainActor.run { vm.sessions.first(where: { $0.key == "main" })?.modelProvider } == "openai")
#expect(await transport.patchedModels() == ["openai/gpt-5.4", "openai/gpt-5.4-pro"])
}
@@ -1012,12 +1183,17 @@ extension TestChatTransportState {
}
try await waitUntil("late model completion updates only the original session") {
- await MainActor.run { vm.sessions.first(where: { $0.key == "main" })?.model == "openai/gpt-5.4" }
+ await MainActor.run {
+ vm.sessions.first(where: { $0.key == "main" })?.model == "gpt-5.4" &&
+ vm.sessions.first(where: { $0.key == "main" })?.modelProvider == "openai"
+ }
}
#expect(await MainActor.run { vm.modelSelectionID } == "openai/gpt-5.4")
- #expect(await MainActor.run { vm.sessions.first(where: { $0.key == "main" })?.model } == "openai/gpt-5.4")
+ #expect(await MainActor.run { vm.sessions.first(where: { $0.key == "main" })?.model } == "gpt-5.4")
+ #expect(await MainActor.run { vm.sessions.first(where: { $0.key == "main" })?.modelProvider } == "openai")
#expect(await MainActor.run { vm.sessions.first(where: { $0.key == "other" })?.model } == "openai/gpt-5.4-pro")
+ #expect(await MainActor.run { vm.sessions.first(where: { $0.key == "other" })?.modelProvider } == nil)
#expect(await transport.patchedModels() == ["openai/gpt-5.4", "openai/gpt-5.4-pro"])
}
diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/DeepLinksSecurityTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/DeepLinksSecurityTests.swift
index 8bbf4f8a650..79613b310ff 100644
--- a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/DeepLinksSecurityTests.swift
+++ b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/DeepLinksSecurityTests.swift
@@ -20,11 +20,17 @@ import Testing
string: "openclaw://gateway?host=127.0.0.1&port=18789&tls=0&token=abc")!
#expect(
DeepLinkParser.parse(url) == .gateway(
- .init(host: "127.0.0.1", port: 18789, tls: false, token: "abc", password: nil)))
+ .init(
+ host: "127.0.0.1",
+ port: 18789,
+ tls: false,
+ bootstrapToken: nil,
+ token: "abc",
+ password: nil)))
}
@Test func setupCodeRejectsInsecureNonLoopbackWs() {
- let payload = #"{"url":"ws://attacker.example:18789","token":"tok"}"#
+ let payload = #"{"url":"ws://attacker.example:18789","bootstrapToken":"tok"}"#
let encoded = Data(payload.utf8)
.base64EncodedString()
.replacingOccurrences(of: "+", with: "-")
@@ -34,7 +40,7 @@ import Testing
}
@Test func setupCodeRejectsInsecurePrefixBypassHost() {
- let payload = #"{"url":"ws://127.attacker.example:18789","token":"tok"}"#
+ let payload = #"{"url":"ws://127.attacker.example:18789","bootstrapToken":"tok"}"#
let encoded = Data(payload.utf8)
.base64EncodedString()
.replacingOccurrences(of: "+", with: "-")
@@ -44,7 +50,7 @@ import Testing
}
@Test func setupCodeAllowsLoopbackWs() {
- let payload = #"{"url":"ws://127.0.0.1:18789","token":"tok"}"#
+ let payload = #"{"url":"ws://127.0.0.1:18789","bootstrapToken":"tok"}"#
let encoded = Data(payload.utf8)
.base64EncodedString()
.replacingOccurrences(of: "+", with: "-")
@@ -55,7 +61,8 @@ import Testing
host: "127.0.0.1",
port: 18789,
tls: false,
- token: "tok",
+ bootstrapToken: "tok",
+ token: nil,
password: nil))
}
}
diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayErrorsTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayErrorsTests.swift
new file mode 100644
index 00000000000..92d3e1292de
--- /dev/null
+++ b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayErrorsTests.swift
@@ -0,0 +1,14 @@
+import OpenClawKit
+import Testing
+
+@Suite struct GatewayErrorsTests {
+ @Test func bootstrapTokenInvalidIsNonRecoverable() {
+ let error = GatewayConnectAuthError(
+ message: "setup code expired",
+ detailCode: GatewayConnectAuthDetailCode.authBootstrapTokenInvalid.rawValue,
+ canRetryWithDeviceToken: false)
+
+ #expect(error.isNonRecoverable)
+ #expect(error.detail == .authBootstrapTokenInvalid)
+ }
+}
diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayNodeSessionTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayNodeSessionTests.swift
index a48015e1100..183fc385d8c 100644
--- a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayNodeSessionTests.swift
+++ b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayNodeSessionTests.swift
@@ -266,6 +266,7 @@ struct GatewayNodeSessionTests {
try await gateway.connect(
url: URL(string: "ws://example.invalid")!,
token: nil,
+ bootstrapToken: nil,
password: nil,
connectOptions: options,
sessionBox: WebSocketSessionBox(session: session),
diff --git a/changelog/fragments/toolcall-id-malformed-name-inference.md b/changelog/fragments/toolcall-id-malformed-name-inference.md
new file mode 100644
index 00000000000..6af2b986f34
--- /dev/null
+++ b/changelog/fragments/toolcall-id-malformed-name-inference.md
@@ -0,0 +1 @@
+- runner: infer canonical tool names from malformed `toolCallId` variants (e.g. `functionsread3`, `functionswrite4`) when allowlist is present, preventing `Tool not found` regressions in strict routers.
diff --git a/docs/channels/channel-routing.md b/docs/channels/channel-routing.md
index 2d824359311..63c5806ebae 100644
--- a/docs/channels/channel-routing.md
+++ b/docs/channels/channel-routing.md
@@ -118,6 +118,11 @@ Session stores live under the state directory (default `~/.openclaw`):
You can override the store path via `session.store` and `{agentId}` templating.
+Gateway and ACP session discovery also scans disk-backed agent stores under the
+default `agents/` root and under templated `session.store` roots. Discovered
+stores must stay inside that resolved agent root and use a regular
+`sessions.json` file. Symlinks and out-of-root paths are ignored.
+
## WebChat behavior
WebChat attaches to the **selected agent** and defaults to the agent’s main
diff --git a/docs/channels/feishu.md b/docs/channels/feishu.md
index 67e4fd60379..467fc57c0fe 100644
--- a/docs/channels/feishu.md
+++ b/docs/channels/feishu.md
@@ -193,16 +193,18 @@ Edit `~/.openclaw/openclaw.json`:
}
```
-If you use `connectionMode: "webhook"`, set `verificationToken`. The Feishu webhook server binds to `127.0.0.1` by default; set `webhookHost` only if you intentionally need a different bind address.
+If you use `connectionMode: "webhook"`, set both `verificationToken` and `encryptKey`. The Feishu webhook server binds to `127.0.0.1` by default; set `webhookHost` only if you intentionally need a different bind address.
-#### Verification Token (webhook mode)
+#### Verification Token and Encrypt Key (webhook mode)
-When using webhook mode, set `channels.feishu.verificationToken` in your config. To get the value:
+When using webhook mode, set both `channels.feishu.verificationToken` and `channels.feishu.encryptKey` in your config. To get the values:
1. In Feishu Open Platform, open your app
2. Go to **Development** → **Events & Callbacks** (开发配置 → 事件与回调)
3. Open the **Encryption** tab (加密策略)
-4. Copy **Verification Token**
+4. Copy **Verification Token** and **Encrypt Key**
+
+The screenshot below shows where to find the **Verification Token**. The **Encrypt Key** is listed in the same **Encryption** section.

@@ -600,6 +602,7 @@ Key options:
| `channels.feishu.connectionMode` | Event transport mode | `websocket` |
| `channels.feishu.defaultAccount` | Default account ID for outbound routing | `default` |
| `channels.feishu.verificationToken` | Required for webhook mode | - |
+| `channels.feishu.encryptKey` | Required for webhook mode | - |
| `channels.feishu.webhookPath` | Webhook route path | `/feishu/events` |
| `channels.feishu.webhookHost` | Webhook bind host | `127.0.0.1` |
| `channels.feishu.webhookPort` | Webhook bind port | `3000` |
diff --git a/docs/channels/mattermost.md b/docs/channels/mattermost.md
index 6a7ee8bb472..1e3e3f4bad2 100644
--- a/docs/channels/mattermost.md
+++ b/docs/channels/mattermost.md
@@ -129,6 +129,35 @@ Notes:
- `onchar` still responds to explicit @mentions.
- `channels.mattermost.requireMention` is honored for legacy configs but `chatmode` is preferred.
+## Threading and sessions
+
+Use `channels.mattermost.replyToMode` to control whether channel and group replies stay in the
+main channel or start a thread under the triggering post.
+
+- `off` (default): only reply in a thread when the inbound post is already in one.
+- `first`: for top-level channel/group posts, start a thread under that post and route the
+ conversation to a thread-scoped session.
+- `all`: same behavior as `first` for Mattermost today.
+- Direct messages ignore this setting and stay non-threaded.
+
+Config example:
+
+```json5
+{
+ channels: {
+ mattermost: {
+ replyToMode: "all",
+ },
+ },
+}
+```
+
+Notes:
+
+- Thread-scoped sessions use the triggering post id as the thread root.
+- `first` and `all` are currently equivalent because once Mattermost has a thread root,
+ follow-up chunks and media continue in that same thread.
+
## Access control (DMs)
- Default: `channels.mattermost.dmPolicy = "pairing"` (unknown senders get a pairing code).
diff --git a/docs/channels/msteams.md b/docs/channels/msteams.md
index 9c4a583e1b5..a24f20c69df 100644
--- a/docs/channels/msteams.md
+++ b/docs/channels/msteams.md
@@ -114,11 +114,11 @@ Example:
**Teams + channel allowlist**
- Scope group/channel replies by listing teams and channels under `channels.msteams.teams`.
-- Keys can be team IDs or names; channel keys can be conversation IDs or names.
+- Keys should use stable team IDs and channel conversation IDs.
- When `groupPolicy="allowlist"` and a teams allowlist is present, only listed teams/channels are accepted (mention‑gated).
- The configure wizard accepts `Team/Channel` entries and stores them for you.
- On startup, OpenClaw resolves team/channel and user allowlist names to IDs (when Graph permissions allow)
- and logs the mapping; unresolved entries are kept as typed.
+ and logs the mapping; unresolved team/channel names are kept as typed but ignored for routing by default unless `channels.msteams.dangerouslyAllowNameMatching: true` is enabled.
Example:
@@ -457,7 +457,7 @@ Key settings (see `/gateway/configuration` for shared channel patterns):
- `channels.msteams.webhook.path` (default `/api/messages`)
- `channels.msteams.dmPolicy`: `pairing | allowlist | open | disabled` (default: pairing)
- `channels.msteams.allowFrom`: DM allowlist (AAD object IDs recommended). The wizard resolves names to IDs during setup when Graph access is available.
-- `channels.msteams.dangerouslyAllowNameMatching`: break-glass toggle to re-enable mutable UPN/display-name matching.
+- `channels.msteams.dangerouslyAllowNameMatching`: break-glass toggle to re-enable mutable UPN/display-name matching and direct team/channel name routing.
- `channels.msteams.textChunkLimit`: outbound text chunk size.
- `channels.msteams.chunkMode`: `length` (default) or `newline` to split on blank lines (paragraph boundaries) before length chunking.
- `channels.msteams.mediaAllowHosts`: allowlist for inbound attachment hosts (defaults to Microsoft/Teams domains).
diff --git a/docs/channels/pairing.md b/docs/channels/pairing.md
index d402de16662..1ba3c6c92f2 100644
--- a/docs/channels/pairing.md
+++ b/docs/channels/pairing.md
@@ -72,7 +72,7 @@ If you use the `device-pair` plugin, you can do first-time device pairing entire
The setup code is a base64-encoded JSON payload that contains:
- `url`: the Gateway WebSocket URL (`ws://...` or `wss://...`)
-- `token`: a short-lived pairing token
+- `bootstrapToken`: a short-lived single-device bootstrap token used for the initial pairing handshake
Treat the setup code like a password while it is valid.
diff --git a/docs/channels/slack.md b/docs/channels/slack.md
index c099120c699..7fe44cc611b 100644
--- a/docs/channels/slack.md
+++ b/docs/channels/slack.md
@@ -169,15 +169,15 @@ For actions/directory reads, user token can be preferred when configured. For wr
- `allowlist`
- `disabled`
- Channel allowlist lives under `channels.slack.channels`.
+ Channel allowlist lives under `channels.slack.channels` and should use stable channel IDs.
Runtime note: if `channels.slack` is completely missing (env-only setup), runtime falls back to `groupPolicy="allowlist"` and logs a warning (even if `channels.defaults.groupPolicy` is set).
Name/ID resolution:
- channel allowlist entries and DM allowlist entries are resolved at startup when token access allows
- - unresolved entries are kept as configured
- - inbound authorization matching is ID-first by default; direct username/slug matching requires `channels.slack.dangerouslyAllowNameMatching: true`
+ - unresolved channel-name entries are kept as configured but ignored for routing by default
+ - inbound authorization and channel routing are ID-first by default; direct username/slug matching requires `channels.slack.dangerouslyAllowNameMatching: true`
@@ -190,7 +190,7 @@ For actions/directory reads, user token can be preferred when configured. For wr
- mention regex patterns (`agents.list[].groupChat.mentionPatterns`, fallback `messages.groupChat.mentionPatterns`)
- implicit reply-to-bot thread behavior
- Per-channel controls (`channels.slack.channels.`):
+ Per-channel controls (`channels.slack.channels.`; names only via startup resolution or `dangerouslyAllowNameMatching`):
- `requireMention`
- `users` (allowlist)
diff --git a/docs/channels/telegram.md b/docs/channels/telegram.md
index f2467d12b0a..a0c679988d3 100644
--- a/docs/channels/telegram.md
+++ b/docs/channels/telegram.md
@@ -335,9 +335,10 @@ curl "https://api.telegram.org/bot/getUpdates"
If native commands are disabled, built-ins are removed. Custom/plugin commands may still register if configured.
- Common setup failure:
+ Common setup failures:
- - `setMyCommands failed` usually means outbound DNS/HTTPS to `api.telegram.org` is blocked.
+ - `setMyCommands failed` with `BOT_COMMANDS_TOO_MUCH` means the Telegram menu still overflowed after trimming; reduce plugin/skill/custom commands or disable `channels.telegram.commands.native`.
+ - `setMyCommands failed` with network/fetch errors usually means outbound DNS/HTTPS to `api.telegram.org` is blocked.
### Device pairing commands (`device-pair` plugin)
@@ -843,7 +844,8 @@ openclaw message poll --channel telegram --target -1001234567890:topic:42 \
- authorize your sender identity (pairing and/or numeric `allowFrom`)
- command authorization still applies even when group policy is `open`
- - `setMyCommands failed` usually indicates DNS/HTTPS reachability issues to `api.telegram.org`
+ - `setMyCommands failed` with `BOT_COMMANDS_TOO_MUCH` means the native menu has too many entries; reduce plugin/skill/custom commands or disable native menus
+ - `setMyCommands failed` with network/fetch errors usually indicates DNS/HTTPS reachability issues to `api.telegram.org`
diff --git a/docs/channels/troubleshooting.md b/docs/channels/troubleshooting.md
index 2848947c479..a7850801948 100644
--- a/docs/channels/troubleshooting.md
+++ b/docs/channels/troubleshooting.md
@@ -44,12 +44,13 @@ Full troubleshooting: [/channels/whatsapp#troubleshooting-quick](/channels/whats
### Telegram failure signatures
-| Symptom | Fastest check | Fix |
-| --------------------------------- | ----------------------------------------------- | --------------------------------------------------------------------------- |
-| `/start` but no usable reply flow | `openclaw pairing list telegram` | Approve pairing or change DM policy. |
-| Bot online but group stays silent | Verify mention requirement and bot privacy mode | Disable privacy mode for group visibility or mention bot. |
-| Send failures with network errors | Inspect logs for Telegram API call failures | Fix DNS/IPv6/proxy routing to `api.telegram.org`. |
-| Upgraded and allowlist blocks you | `openclaw security audit` and config allowlists | Run `openclaw doctor --fix` or replace `@username` with numeric sender IDs. |
+| Symptom | Fastest check | Fix |
+| ----------------------------------- | ----------------------------------------------- | --------------------------------------------------------------------------- |
+| `/start` but no usable reply flow | `openclaw pairing list telegram` | Approve pairing or change DM policy. |
+| Bot online but group stays silent | Verify mention requirement and bot privacy mode | Disable privacy mode for group visibility or mention bot. |
+| Send failures with network errors | Inspect logs for Telegram API call failures | Fix DNS/IPv6/proxy routing to `api.telegram.org`. |
+| `setMyCommands` rejected at startup | Inspect logs for `BOT_COMMANDS_TOO_MUCH` | Reduce plugin/skill/custom Telegram commands or disable native menus. |
+| Upgraded and allowlist blocks you | `openclaw security audit` and config allowlists | Run `openclaw doctor --fix` or replace `@username` with numeric sender IDs. |
Full troubleshooting: [/channels/telegram#troubleshooting](/channels/telegram#troubleshooting)
diff --git a/docs/channels/zalouser.md b/docs/channels/zalouser.md
index 9b62244e234..58bd2a43923 100644
--- a/docs/channels/zalouser.md
+++ b/docs/channels/zalouser.md
@@ -86,11 +86,13 @@ Approve via:
- Default: `channels.zalouser.groupPolicy = "open"` (groups allowed). Use `channels.defaults.groupPolicy` to override the default when unset.
- Restrict to an allowlist with:
- `channels.zalouser.groupPolicy = "allowlist"`
- - `channels.zalouser.groups` (keys are group IDs or names; controls which groups are allowed)
+ - `channels.zalouser.groups` (keys should be stable group IDs; names are resolved to IDs on startup when possible)
- `channels.zalouser.groupAllowFrom` (controls which senders in allowed groups can trigger the bot)
- Block all groups: `channels.zalouser.groupPolicy = "disabled"`.
- The configure wizard can prompt for group allowlists.
-- On startup, OpenClaw resolves group/user names in allowlists to IDs and logs the mapping; unresolved entries are kept as typed.
+- On startup, OpenClaw resolves group/user names in allowlists to IDs and logs the mapping.
+- Group allowlist matching is ID-only by default. Unresolved names are ignored for auth unless `channels.zalouser.dangerouslyAllowNameMatching: true` is enabled.
+- `channels.zalouser.dangerouslyAllowNameMatching: true` is a break-glass compatibility mode that re-enables mutable group-name matching.
- If `groupAllowFrom` is unset, runtime falls back to `allowFrom` for group sender checks.
- Sender checks apply to both normal group messages and control commands (for example `/new`, `/reset`).
diff --git a/docs/cli/agent.md b/docs/cli/agent.md
index 93c8d04b41a..430bdf50743 100644
--- a/docs/cli/agent.md
+++ b/docs/cli/agent.md
@@ -25,4 +25,5 @@ openclaw agent --agent ops --message "Generate report" --deliver --reply-channel
## Notes
-- When this command triggers `models.json` regeneration, SecretRef-managed provider credentials are persisted as non-secret markers (for example env var names or `secretref-managed`), not resolved secret plaintext.
+- When this command triggers `models.json` regeneration, SecretRef-managed provider credentials are persisted as non-secret markers (for example env var names, `secretref-env:ENV_VAR_NAME`, or `secretref-managed`), not resolved secret plaintext.
+- Marker writes are source-authoritative: OpenClaw persists markers from the active source config snapshot, not from resolved runtime secret values.
diff --git a/docs/cli/index.md b/docs/cli/index.md
index cbcd5bff0b5..2796e7927d2 100644
--- a/docs/cli/index.md
+++ b/docs/cli/index.md
@@ -337,7 +337,7 @@ Options:
- `--non-interactive`
- `--mode `
- `--flow ` (manual is an alias for advanced)
-- `--auth-choice `
+- `--auth-choice `
- `--token-provider ` (non-interactive; used with `--auth-choice token`)
- `--token ` (non-interactive; used with `--auth-choice token`)
- `--token-profile-id ` (non-interactive; default: `:manual`)
@@ -355,8 +355,8 @@ Options:
- `--minimax-api-key `
- `--opencode-zen-api-key `
- `--opencode-go-api-key `
-- `--custom-base-url ` (non-interactive; used with `--auth-choice custom-api-key`)
-- `--custom-model-id ` (non-interactive; used with `--auth-choice custom-api-key`)
+- `--custom-base-url ` (non-interactive; used with `--auth-choice custom-api-key` or `--auth-choice ollama`)
+- `--custom-model-id ` (non-interactive; used with `--auth-choice custom-api-key` or `--auth-choice ollama`)
- `--custom-api-key ` (non-interactive; optional; used with `--auth-choice custom-api-key`; falls back to `CUSTOM_API_KEY` when omitted)
- `--custom-provider-id ` (non-interactive; optional custom provider id)
- `--custom-compatibility ` (non-interactive; optional; default `openai`)
diff --git a/docs/cli/onboard.md b/docs/cli/onboard.md
index 36629a3bb8d..4b30e0d52b3 100644
--- a/docs/cli/onboard.md
+++ b/docs/cli/onboard.md
@@ -43,6 +43,18 @@ openclaw onboard --non-interactive \
`--custom-api-key` is optional in non-interactive mode. If omitted, onboarding checks `CUSTOM_API_KEY`.
+Non-interactive Ollama:
+
+```bash
+openclaw onboard --non-interactive \
+ --auth-choice ollama \
+ --custom-base-url "http://ollama-host:11434" \
+ --custom-model-id "qwen3.5:27b" \
+ --accept-risk
+```
+
+`--custom-base-url` defaults to `http://127.0.0.1:11434`. `--custom-model-id` is optional; if omitted, onboarding uses Ollama's suggested defaults. Cloud model IDs such as `kimi-k2.5:cloud` also work here.
+
Store provider keys as refs instead of plaintext:
```bash
@@ -83,6 +95,13 @@ openclaw onboard --non-interactive \
--accept-risk
```
+Non-interactive local gateway health:
+
+- Unless you pass `--skip-health`, onboarding waits for a reachable local gateway before it exits successfully.
+- `--install-daemon` starts the managed gateway install path first. Without it, you must already have a local gateway running, for example `openclaw gateway run`.
+- If you only want config/workspace/bootstrap writes in automation, use `--skip-health`.
+- On native Windows, `--install-daemon` tries Scheduled Tasks first and falls back to a per-user Startup-folder login item if task creation is denied.
+
Interactive onboarding behavior with reference mode:
- Choose **Use secret reference** when prompted.
diff --git a/docs/cli/qr.md b/docs/cli/qr.md
index 2fc070ca1bd..1575b16d029 100644
--- a/docs/cli/qr.md
+++ b/docs/cli/qr.md
@@ -17,7 +17,7 @@ openclaw qr
openclaw qr --setup-code-only
openclaw qr --json
openclaw qr --remote
-openclaw qr --url wss://gateway.example/ws --token ''
+openclaw qr --url wss://gateway.example/ws
```
## Options
@@ -25,8 +25,8 @@ openclaw qr --url wss://gateway.example/ws --token ''
- `--remote`: use `gateway.remote.url` plus remote token/password from config
- `--url `: override gateway URL used in payload
- `--public-url `: override public URL used in payload
-- `--token `: override gateway token for payload
-- `--password `: override gateway password for payload
+- `--token `: override which gateway token the bootstrap flow authenticates against
+- `--password `: override which gateway password the bootstrap flow authenticates against
- `--setup-code-only`: print only setup code
- `--no-ascii`: skip ASCII QR rendering
- `--json`: emit JSON (`setupCode`, `gatewayUrl`, `auth`, `urlSource`)
@@ -34,6 +34,7 @@ openclaw qr --url wss://gateway.example/ws --token ''
## Notes
- `--token` and `--password` are mutually exclusive.
+- The setup code itself now carries an opaque short-lived `bootstrapToken`, not the shared gateway token/password.
- With `--remote`, if effectively active remote credentials are configured as SecretRefs and you do not pass `--token` or `--password`, the command resolves them from the active gateway snapshot. If gateway is unavailable, the command fails fast.
- Without `--remote`, local gateway auth SecretRefs are resolved when no CLI auth override is passed:
- `gateway.auth.token` resolves when token auth can win (explicit `gateway.auth.mode="token"` or inferred mode where no password source wins).
diff --git a/docs/cli/sessions.md b/docs/cli/sessions.md
index 4ed5ace54ee..b8c1ebfac6f 100644
--- a/docs/cli/sessions.md
+++ b/docs/cli/sessions.md
@@ -24,6 +24,12 @@ Scope selection:
- `--all-agents`: aggregate all configured agent stores
- `--store `: explicit store path (cannot be combined with `--agent` or `--all-agents`)
+`openclaw sessions --all-agents` reads configured agent stores. Gateway and ACP
+session discovery are broader: they also include disk-only stores found under
+the default `agents/` root or a templated `session.store` root. Those
+discovered stores must resolve to regular `sessions.json` files inside the
+agent root; symlinks and out-of-root paths are skipped.
+
JSON examples:
`openclaw sessions --all-agents --json`:
diff --git a/docs/concepts/model-providers.md b/docs/concepts/model-providers.md
index 549875c77b4..a502240226e 100644
--- a/docs/concepts/model-providers.md
+++ b/docs/concepts/model-providers.md
@@ -47,6 +47,8 @@ OpenClaw ships with the pi‑ai catalog. These providers require **no**
- Override per model via `agents.defaults.models["openai/"].params.transport` (`"sse"`, `"websocket"`, or `"auto"`)
- OpenAI Responses WebSocket warm-up defaults to enabled via `params.openaiWsWarmup` (`true`/`false`)
- OpenAI priority processing can be enabled via `agents.defaults.models["openai/"].params.serviceTier`
+- OpenAI fast mode can be enabled per model via `agents.defaults.models["/"].params.fastMode`
+- `openai/gpt-5.3-codex-spark` is intentionally suppressed in OpenClaw because the live OpenAI API rejects it; Spark is treated as Codex-only
```json5
{
@@ -61,6 +63,7 @@ OpenClaw ships with the pi‑ai catalog. These providers require **no**
- Optional rotation: `ANTHROPIC_API_KEYS`, `ANTHROPIC_API_KEY_1`, `ANTHROPIC_API_KEY_2`, plus `OPENCLAW_LIVE_ANTHROPIC_KEY` (single override)
- Example model: `anthropic/claude-opus-4-6`
- CLI: `openclaw onboard --auth-choice token` (paste setup-token) or `openclaw models auth paste-token --provider anthropic`
+- Direct API-key models support the shared `/fast` toggle and `params.fastMode`; OpenClaw maps that to Anthropic `service_tier` (`auto` vs `standard_only`)
- Policy note: setup-token support is technical compatibility; Anthropic has blocked some subscription usage outside Claude Code in the past. Verify current Anthropic terms and decide based on your risk tolerance.
- Recommendation: Anthropic API key auth is the safer, recommended path over subscription setup-token auth.
@@ -78,6 +81,8 @@ OpenClaw ships with the pi‑ai catalog. These providers require **no**
- CLI: `openclaw onboard --auth-choice openai-codex` or `openclaw models auth login --provider openai-codex`
- Default transport is `auto` (WebSocket-first, SSE fallback)
- Override per model via `agents.defaults.models["openai-codex/"].params.transport` (`"sse"`, `"websocket"`, or `"auto"`)
+- Shares the same `/fast` toggle and `params.fastMode` config as direct `openai/*`
+- `openai-codex/gpt-5.3-codex-spark` remains available when the Codex OAuth catalog exposes it; entitlement-dependent
- Policy note: OpenAI Codex OAuth is explicitly supported for external tools/workflows like OpenClaw.
```json5
@@ -352,7 +357,7 @@ See [/providers/minimax](/providers/minimax) for setup details, model options, a
### Ollama
-Ollama is a local LLM runtime that provides an OpenAI-compatible API:
+Ollama ships as a bundled provider plugin and uses Ollama's native API:
- Provider: `ollama`
- Auth: None required (local server)
@@ -372,11 +377,15 @@ ollama pull llama3.3
}
```
-Ollama is detected locally at `http://127.0.0.1:11434` when you opt in with `OLLAMA_API_KEY`, and `openclaw onboard` can configure it directly as a first-class provider. See [/providers/ollama](/providers/ollama) for onboarding, cloud/local mode, and custom configuration.
+Ollama is detected locally at `http://127.0.0.1:11434` when you opt in with
+`OLLAMA_API_KEY`, and the bundled provider plugin adds Ollama directly to
+`openclaw onboard` and the model picker. See [/providers/ollama](/providers/ollama)
+for onboarding, cloud/local mode, and custom configuration.
### vLLM
-vLLM is a local (or self-hosted) OpenAI-compatible server:
+vLLM ships as a bundled provider plugin for local/self-hosted OpenAI-compatible
+servers:
- Provider: `vllm`
- Auth: Optional (depends on your server)
@@ -400,6 +409,34 @@ Then set a model (replace with one of the IDs returned by `/v1/models`):
See [/providers/vllm](/providers/vllm) for details.
+### SGLang
+
+SGLang ships as a bundled provider plugin for fast self-hosted
+OpenAI-compatible servers:
+
+- Provider: `sglang`
+- Auth: Optional (depends on your server)
+- Default base URL: `http://127.0.0.1:30000/v1`
+
+To opt in to auto-discovery locally (any value works if your server does not
+enforce auth):
+
+```bash
+export SGLANG_API_KEY="sglang-local"
+```
+
+Then set a model (replace with one of the IDs returned by `/v1/models`):
+
+```json5
+{
+ agents: {
+ defaults: { model: { primary: "sglang/your-model-id" } },
+ },
+}
+```
+
+See [/providers/sglang](/providers/sglang) for details.
+
### Local proxies (LM Studio, vLLM, LiteLLM, etc.)
Example (OpenAI‑compatible):
diff --git a/docs/concepts/models.md b/docs/concepts/models.md
index f87eead821c..6323feef04e 100644
--- a/docs/concepts/models.md
+++ b/docs/concepts/models.md
@@ -207,7 +207,7 @@ mode, pass `--yes` to accept defaults.
## Models registry (`models.json`)
Custom providers in `models.providers` are written into `models.json` under the
-agent directory (default `~/.openclaw/agents//models.json`). This file
+agent directory (default `~/.openclaw/agents//agent/models.json`). This file
is merged by default unless `models.mode` is set to `replace`.
Merge mode precedence for matching provider IDs:
@@ -215,7 +215,9 @@ Merge mode precedence for matching provider IDs:
- Non-empty `baseUrl` already present in the agent `models.json` wins.
- Non-empty `apiKey` in the agent `models.json` wins only when that provider is not SecretRef-managed in current config/auth-profile context.
- SecretRef-managed provider `apiKey` values are refreshed from source markers (`ENV_VAR_NAME` for env refs, `secretref-managed` for file/exec refs) instead of persisting resolved secrets.
+- SecretRef-managed provider header values are refreshed from source markers (`secretref-env:ENV_VAR_NAME` for env refs, `secretref-managed` for file/exec refs).
- Empty or missing agent `apiKey`/`baseUrl` fall back to config `models.providers`.
- Other provider fields are refreshed from config and normalized catalog data.
-This marker-based persistence applies whenever OpenClaw regenerates `models.json`, including command-driven paths like `openclaw agent`.
+Marker persistence is source-authoritative: OpenClaw writes markers from the active source config snapshot (pre-resolution), not from resolved runtime secret values.
+This applies whenever OpenClaw regenerates `models.json`, including command-driven paths like `openclaw agent`.
diff --git a/docs/concepts/session.md b/docs/concepts/session.md
index 6c9010d2c11..5c60655858e 100644
--- a/docs/concepts/session.md
+++ b/docs/concepts/session.md
@@ -281,7 +281,7 @@ Runtime override (owner only):
- `openclaw status` — shows store path and recent sessions.
- `openclaw sessions --json` — dumps every entry (filter with `--active `).
- `openclaw gateway call sessions.list --params '{}'` — fetch sessions from the running gateway (use `--url`/`--token` for remote gateway access).
-- Send `/status` as a standalone message in chat to see whether the agent is reachable, how much of the session context is used, current thinking/verbose toggles, and when your WhatsApp web creds were last refreshed (helps spot relink needs).
+- Send `/status` as a standalone message in chat to see whether the agent is reachable, how much of the session context is used, current thinking/fast/verbose toggles, and when your WhatsApp web creds were last refreshed (helps spot relink needs).
- Send `/context list` or `/context detail` to see what’s in the system prompt and injected workspace files (and the biggest context contributors).
- Send `/stop` (or standalone abort phrases like `stop`, `stop action`, `stop run`, `stop openclaw`) to abort the current run, clear queued followups for that session, and stop any sub-agent runs spawned from it (the reply includes the stopped count).
- Send `/compact` (optional instructions) as a standalone message to summarize older context and free up window space. See [/concepts/compaction](/concepts/compaction).
diff --git a/docs/docs.json b/docs/docs.json
index e6cf5ba382b..402d56aa380 100644
--- a/docs/docs.json
+++ b/docs/docs.json
@@ -876,6 +876,7 @@
"group": "Hosting and deployment",
"pages": [
"vps",
+ "install/kubernetes",
"install/fly",
"install/hetzner",
"install/gcp",
diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md
index 1e48f69d6f8..b4a697d5a5a 100644
--- a/docs/gateway/configuration-reference.md
+++ b/docs/gateway/configuration-reference.md
@@ -2014,9 +2014,11 @@ OpenClaw uses the pi-coding-agent model catalog. Add custom providers via `model
- Non-empty agent `models.json` `baseUrl` values win.
- Non-empty agent `apiKey` values win only when that provider is not SecretRef-managed in current config/auth-profile context.
- SecretRef-managed provider `apiKey` values are refreshed from source markers (`ENV_VAR_NAME` for env refs, `secretref-managed` for file/exec refs) instead of persisting resolved secrets.
+ - SecretRef-managed provider header values are refreshed from source markers (`secretref-env:ENV_VAR_NAME` for env refs, `secretref-managed` for file/exec refs).
- Empty or missing agent `apiKey`/`baseUrl` fall back to `models.providers` in config.
- Matching model `contextWindow`/`maxTokens` use the higher value between explicit config and implicit catalog values.
- Use `models.mode: "replace"` when you want config to fully rewrite `models.json`.
+ - Marker persistence is source-authoritative: markers are written from the active source config snapshot (pre-resolution), not from resolved runtime secret values.
### Provider field details
@@ -2196,7 +2198,7 @@ Anthropic-compatible, built-in provider. Shortcut: `openclaw onboard --auth-choi
{
id: "hf:MiniMaxAI/MiniMax-M2.5",
name: "MiniMax M2.5",
- reasoning: false,
+ reasoning: true,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 192000,
@@ -2236,7 +2238,7 @@ Base URL should omit `/v1` (Anthropic client appends it). Shortcut: `openclaw on
{
id: "MiniMax-M2.5",
name: "MiniMax M2.5",
- reasoning: false,
+ reasoning: true,
input: ["text"],
cost: { input: 15, output: 60, cacheRead: 2, cacheWrite: 10 },
contextWindow: 200000,
@@ -2445,6 +2447,14 @@ See [Plugins](/tools/plugin).
// Remove tools from the default HTTP deny list
allow: ["gateway"],
},
+ push: {
+ apns: {
+ relay: {
+ baseUrl: "https://relay.example.com",
+ timeoutMs: 10000,
+ },
+ },
+ },
},
}
```
@@ -2470,6 +2480,11 @@ See [Plugins](/tools/plugin).
- `remote.transport`: `ssh` (default) or `direct` (ws/wss). For `direct`, `remote.url` must be `ws://` or `wss://`.
- `OPENCLAW_ALLOW_INSECURE_PRIVATE_WS=1`: client-side break-glass override that allows plaintext `ws://` to trusted private-network IPs; default remains loopback-only for plaintext.
- `gateway.remote.token` / `.password` are remote-client credential fields. They do not configure gateway auth by themselves.
+- `gateway.push.apns.relay.baseUrl`: base HTTPS URL for the external APNs relay used by official/TestFlight iOS builds after they publish relay-backed registrations to the gateway. This URL must match the relay URL compiled into the iOS build.
+- `gateway.push.apns.relay.timeoutMs`: gateway-to-relay send timeout in milliseconds. Defaults to `10000`.
+- Relay-backed registrations are delegated to a specific gateway identity. The paired iOS app fetches `gateway.identity.get`, includes that identity in the relay registration, and forwards a registration-scoped send grant to the gateway. Another gateway cannot reuse that stored registration.
+- `OPENCLAW_APNS_RELAY_BASE_URL` / `OPENCLAW_APNS_RELAY_TIMEOUT_MS`: temporary env overrides for the relay config above.
+- `OPENCLAW_APNS_RELAY_ALLOW_HTTP=true`: development-only escape hatch for loopback HTTP relay URLs. Production relay URLs should stay on HTTPS.
- Local gateway call paths can use `gateway.remote.*` as fallback only when `gateway.auth.*` is unset.
- If `gateway.auth.token` / `gateway.auth.password` is explicitly configured via SecretRef and unresolved, resolution fails closed (no remote fallback masking).
- `trustedProxies`: reverse proxy IPs that terminate TLS. Only list proxies you control.
diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md
index ece612d101d..d7e5f5c25d3 100644
--- a/docs/gateway/configuration.md
+++ b/docs/gateway/configuration.md
@@ -225,6 +225,63 @@ When validation fails:
+
+ Relay-backed push is configured in `openclaw.json`.
+
+ Set this in gateway config:
+
+ ```json5
+ {
+ gateway: {
+ push: {
+ apns: {
+ relay: {
+ baseUrl: "https://relay.example.com",
+ // Optional. Default: 10000
+ timeoutMs: 10000,
+ },
+ },
+ },
+ },
+ }
+ ```
+
+ CLI equivalent:
+
+ ```bash
+ openclaw config set gateway.push.apns.relay.baseUrl https://relay.example.com
+ ```
+
+ What this does:
+
+ - Lets the gateway send `push.test`, wake nudges, and reconnect wakes through the external relay.
+ - Uses a registration-scoped send grant forwarded by the paired iOS app. The gateway does not need a deployment-wide relay token.
+ - Binds each relay-backed registration to the gateway identity that the iOS app paired with, so another gateway cannot reuse the stored registration.
+ - Keeps local/manual iOS builds on direct APNs. Relay-backed sends apply only to official distributed builds that registered through the relay.
+ - Must match the relay base URL baked into the official/TestFlight iOS build, so registration and send traffic reach the same relay deployment.
+
+ End-to-end flow:
+
+ 1. Install an official/TestFlight iOS build that was compiled with the same relay base URL.
+ 2. Configure `gateway.push.apns.relay.baseUrl` on the gateway.
+ 3. Pair the iOS app to the gateway and let both node and operator sessions connect.
+ 4. The iOS app fetches the gateway identity, registers with the relay using App Attest plus the app receipt, and then publishes the relay-backed `push.apns.register` payload to the paired gateway.
+ 5. The gateway stores the relay handle and send grant, then uses them for `push.test`, wake nudges, and reconnect wakes.
+
+ Operational notes:
+
+ - If you switch the iOS app to a different gateway, reconnect the app so it can publish a new relay registration bound to that gateway.
+ - If you ship a new iOS build that points at a different relay deployment, the app refreshes its cached relay registration instead of reusing the old relay origin.
+
+ Compatibility note:
+
+ - `OPENCLAW_APNS_RELAY_BASE_URL` and `OPENCLAW_APNS_RELAY_TIMEOUT_MS` still work as temporary env overrides.
+ - `OPENCLAW_APNS_RELAY_ALLOW_HTTP=true` remains a loopback-only development escape hatch; do not persist HTTP relay URLs in config.
+
+ See [iOS App](/platforms/ios#relay-backed-push-for-official-builds) for the end-to-end flow and [Authentication and trust flow](/platforms/ios#authentication-and-trust-flow) for the relay security model.
+
+
+
```json5
{
diff --git a/docs/gateway/security/index.md b/docs/gateway/security/index.md
index 3084adf82ad..f7f6583d794 100644
--- a/docs/gateway/security/index.md
+++ b/docs/gateway/security/index.md
@@ -304,6 +304,7 @@ schema:
- `channels.googlechat.dangerouslyAllowNameMatching`
- `channels.googlechat.accounts..dangerouslyAllowNameMatching`
- `channels.msteams.dangerouslyAllowNameMatching`
+- `channels.zalouser.dangerouslyAllowNameMatching` (extension channel)
- `channels.irc.dangerouslyAllowNameMatching` (extension channel)
- `channels.irc.accounts..dangerouslyAllowNameMatching` (extension channel)
- `channels.mattermost.dangerouslyAllowNameMatching` (extension channel)
diff --git a/docs/help/faq.md b/docs/help/faq.md
index 453688c1c5f..37f5f96c815 100644
--- a/docs/help/faq.md
+++ b/docs/help/faq.md
@@ -179,7 +179,7 @@ Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS,
- [I closed my terminal on Windows - how do I restart OpenClaw?](#i-closed-my-terminal-on-windows-how-do-i-restart-openclaw)
- [The Gateway is up but replies never arrive. What should I check?](#the-gateway-is-up-but-replies-never-arrive-what-should-i-check)
- ["Disconnected from gateway: no reason" - what now?](#disconnected-from-gateway-no-reason-what-now)
- - [Telegram setMyCommands fails with network errors. What should I check?](#telegram-setmycommands-fails-with-network-errors-what-should-i-check)
+ - [Telegram setMyCommands fails. What should I check?](#telegram-setmycommands-fails-what-should-i-check)
- [TUI shows no output. What should I check?](#tui-shows-no-output-what-should-i-check)
- [How do I completely stop then start the Gateway?](#how-do-i-completely-stop-then-start-the-gateway)
- [ELI5: `openclaw gateway restart` vs `openclaw gateway`](#eli5-openclaw-gateway-restart-vs-openclaw-gateway)
@@ -2710,7 +2710,7 @@ openclaw logs --follow
Docs: [Dashboard](/web/dashboard), [Remote access](/gateway/remote), [Troubleshooting](/gateway/troubleshooting).
-### Telegram setMyCommands fails with network errors What should I check
+### Telegram setMyCommands fails What should I check
Start with logs and channel status:
@@ -2719,7 +2719,11 @@ openclaw channels status
openclaw channels logs --channel telegram
```
-If you are on a VPS or behind a proxy, confirm outbound HTTPS is allowed and DNS works.
+Then match the error:
+
+- `BOT_COMMANDS_TOO_MUCH`: the Telegram menu has too many entries. OpenClaw already trims to the Telegram limit and retries with fewer commands, but some menu entries still need to be dropped. Reduce plugin/skill/custom commands, or disable `channels.telegram.commands.native` if you do not need the menu.
+- `TypeError: fetch failed`, `Network request for 'setMyCommands' failed!`, or similar network errors: if you are on a VPS or behind a proxy, confirm outbound HTTPS is allowed and DNS works for `api.telegram.org`.
+
If the Gateway is remote, make sure you are looking at logs on the Gateway host.
Docs: [Telegram](/channels/telegram), [Channel troubleshooting](/channels/troubleshooting).
diff --git a/docs/index.md b/docs/index.md
index f838ebf4cab..7c69600f55d 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -54,7 +54,7 @@ OpenClaw is a **self-hosted gateway** that connects your favorite chat apps —
- **Agent-native**: built for coding agents with tool use, sessions, memory, and multi-agent routing
- **Open source**: MIT licensed, community-driven
-**What do you need?** Node 22+, an API key from your chosen provider, and 5 minutes. For best quality and security, use the strongest latest-generation model available.
+**What do you need?** Node 24 (recommended), or Node 22 LTS (`22.16+`) for compatibility, an API key from your chosen provider, and 5 minutes. For best quality and security, use the strongest latest-generation model available.
## How it works
diff --git a/docs/install/ansible.md b/docs/install/ansible.md
index be91aedaadd..63c18bec237 100644
--- a/docs/install/ansible.md
+++ b/docs/install/ansible.md
@@ -46,7 +46,7 @@ The Ansible playbook installs and configures:
1. **Tailscale** (mesh VPN for secure remote access)
2. **UFW firewall** (SSH + Tailscale ports only)
3. **Docker CE + Compose V2** (for agent sandboxes)
-4. **Node.js 22.x + pnpm** (runtime dependencies)
+4. **Node.js 24 + pnpm** (runtime dependencies; Node 22 LTS, currently `22.16+`, remains supported for compatibility)
5. **OpenClaw** (host-based, not containerized)
6. **Systemd service** (auto-start with security hardening)
diff --git a/docs/install/bun.md b/docs/install/bun.md
index 9b3dcb2c224..5cbe76ce3ac 100644
--- a/docs/install/bun.md
+++ b/docs/install/bun.md
@@ -45,7 +45,7 @@ bun run vitest run
Bun may block dependency lifecycle scripts unless explicitly trusted (`bun pm untrusted` / `bun pm trust`).
For this repo, the commonly blocked scripts are not required:
-- `@whiskeysockets/baileys` `preinstall`: checks Node major >= 20 (we run Node 22+).
+- `@whiskeysockets/baileys` `preinstall`: checks Node major >= 20 (OpenClaw defaults to Node 24 and still supports Node 22 LTS, currently `22.16+`).
- `protobufjs` `postinstall`: emits warnings about incompatible version schemes (no build artifacts).
If you hit a real runtime issue that requires these scripts, trust them explicitly:
diff --git a/docs/install/docker.md b/docs/install/docker.md
index c6337c3db48..a68066dcd57 100644
--- a/docs/install/docker.md
+++ b/docs/install/docker.md
@@ -165,13 +165,13 @@ Common tags:
The main Docker image currently uses:
-- `node:22-bookworm`
+- `node:24-bookworm`
The docker image now publishes OCI base-image annotations (sha256 is an example,
and points at the pinned multi-arch manifest list for that tag):
-- `org.opencontainers.image.base.name=docker.io/library/node:22-bookworm`
-- `org.opencontainers.image.base.digest=sha256:b501c082306a4f528bc4038cbf2fbb58095d583d0419a259b2114b5ac53d12e9`
+- `org.opencontainers.image.base.name=docker.io/library/node:24-bookworm`
+- `org.opencontainers.image.base.digest=sha256:3a09aa6354567619221ef6c45a5051b671f953f0a1924d1f819ffb236e520e6b`
- `org.opencontainers.image.source=https://github.com/openclaw/openclaw`
- `org.opencontainers.image.url=https://openclaw.ai`
- `org.opencontainers.image.documentation=https://docs.openclaw.ai/install/docker`
@@ -408,7 +408,7 @@ To speed up rebuilds, order your Dockerfile so dependency layers are cached.
This avoids re-running `pnpm install` unless lockfiles change:
```dockerfile
-FROM node:22-bookworm
+FROM node:24-bookworm
# Install Bun (required for build scripts)
RUN curl -fsSL https://bun.sh/install | bash
diff --git a/docs/install/gcp.md b/docs/install/gcp.md
index 2c6bdd8ac1f..dfedfe4ba38 100644
--- a/docs/install/gcp.md
+++ b/docs/install/gcp.md
@@ -306,7 +306,7 @@ If you add new skills later that depend on additional binaries, you must:
**Example Dockerfile**
```dockerfile
-FROM node:22-bookworm
+FROM node:24-bookworm
RUN apt-get update && apt-get install -y socat && rm -rf /var/lib/apt/lists/*
diff --git a/docs/install/hetzner.md b/docs/install/hetzner.md
index 9baf90278b8..4c27840cee0 100644
--- a/docs/install/hetzner.md
+++ b/docs/install/hetzner.md
@@ -227,7 +227,7 @@ If you add new skills later that depend on additional binaries, you must:
**Example Dockerfile**
```dockerfile
-FROM node:22-bookworm
+FROM node:24-bookworm
RUN apt-get update && apt-get install -y socat && rm -rf /var/lib/apt/lists/*
diff --git a/docs/install/index.md b/docs/install/index.md
index 285324ed6b7..d0f847838d0 100644
--- a/docs/install/index.md
+++ b/docs/install/index.md
@@ -13,7 +13,7 @@ Already followed [Getting Started](/start/getting-started)? You're all set — t
## System requirements
-- **[Node 22+](/install/node)** (the [installer script](#install-methods) will install it if missing)
+- **[Node 24 (recommended)](/install/node)** (Node 22 LTS, currently `22.16+`, is still supported for compatibility; the [installer script](#install-methods) will install Node 24 if missing)
- macOS, Linux, or Windows
- `pnpm` only if you build from source
@@ -70,7 +70,7 @@ For VPS/cloud hosts, avoid third-party "1-click" marketplace images when possibl
- If you already have Node 22+ and prefer to manage the install yourself:
+ If you already manage Node yourself, we recommend Node 24. OpenClaw still supports Node 22 LTS, currently `22.16+`, for compatibility:
diff --git a/docs/install/installer.md b/docs/install/installer.md
index 78334681ad4..6317e8e06cc 100644
--- a/docs/install/installer.md
+++ b/docs/install/installer.md
@@ -70,8 +70,8 @@ Recommended for most interactive installs on macOS/Linux/WSL.
Supports macOS and Linux (including WSL). If macOS is detected, installs Homebrew if missing.
-
- Checks Node version and installs Node 22 if needed (Homebrew on macOS, NodeSource setup scripts on Linux apt/dnf/yum).
+
+ Checks Node version and installs Node 24 if needed (Homebrew on macOS, NodeSource setup scripts on Linux apt/dnf/yum). OpenClaw still supports Node 22 LTS, currently `22.16+`, for compatibility.
Installs Git if missing.
@@ -175,7 +175,7 @@ Designed for environments where you want everything under a local prefix (defaul
- Downloads Node tarball (default `22.22.0`) to `/tools/node-v` and verifies SHA-256.
+ Downloads a pinned supported Node tarball (currently default `22.22.0`) to `/tools/node-v` and verifies SHA-256.
If Git is missing, attempts install via apt/dnf/yum on Linux or Homebrew on macOS.
@@ -251,8 +251,8 @@ Designed for environments where you want everything under a local prefix (defaul
Requires PowerShell 5+.
-
- If missing, attempts install via winget, then Chocolatey, then Scoop.
+
+ If missing, attempts install via winget, then Chocolatey, then Scoop. Node 22 LTS, currently `22.16+`, remains supported for compatibility.
- `npm` method (default): global npm install using selected `-Tag`
diff --git a/docs/install/kubernetes.md b/docs/install/kubernetes.md
new file mode 100644
index 00000000000..577ff9d2df5
--- /dev/null
+++ b/docs/install/kubernetes.md
@@ -0,0 +1,191 @@
+---
+summary: "Deploy OpenClaw Gateway to a Kubernetes cluster with Kustomize"
+read_when:
+ - You want to run OpenClaw on a Kubernetes cluster
+ - You want to test OpenClaw in a Kubernetes environment
+title: "Kubernetes"
+---
+
+# OpenClaw on Kubernetes
+
+A minimal starting point for running OpenClaw on Kubernetes — not a production-ready deployment. It covers the core resources and is meant to be adapted to your environment.
+
+## Why not Helm?
+
+OpenClaw is a single container with some config files. The interesting customization is in agent content (markdown files, skills, config overrides), not infrastructure templating. Kustomize handles overlays without the overhead of a Helm chart. If your deployment grows more complex, a Helm chart can be layered on top of these manifests.
+
+## What you need
+
+- A running Kubernetes cluster (AKS, EKS, GKE, k3s, kind, OpenShift, etc.)
+- `kubectl` connected to your cluster
+- An API key for at least one model provider
+
+## Quick start
+
+```bash
+# Replace with your provider: ANTHROPIC, GEMINI, OPENAI, or OPENROUTER
+export _API_KEY="..."
+./scripts/k8s/deploy.sh
+
+kubectl port-forward svc/openclaw 18789:18789 -n openclaw
+open http://localhost:18789
+```
+
+Retrieve the gateway token and paste it into the Control UI:
+
+```bash
+kubectl get secret openclaw-secrets -n openclaw -o jsonpath='{.data.OPENCLAW_GATEWAY_TOKEN}' | base64 -d
+```
+
+For local debugging, `./scripts/k8s/deploy.sh --show-token` prints the token after deploy.
+
+## Local testing with Kind
+
+If you don't have a cluster, create one locally with [Kind](https://kind.sigs.k8s.io/):
+
+```bash
+./scripts/k8s/create-kind.sh # auto-detects docker or podman
+./scripts/k8s/create-kind.sh --delete # tear down
+```
+
+Then deploy as usual with `./scripts/k8s/deploy.sh`.
+
+## Step by step
+
+### 1) Deploy
+
+**Option A** — API key in environment (one step):
+
+```bash
+# Replace with your provider: ANTHROPIC, GEMINI, OPENAI, or OPENROUTER
+export _API_KEY="..."
+./scripts/k8s/deploy.sh
+```
+
+The script creates a Kubernetes Secret with the API key and an auto-generated gateway token, then deploys. If the Secret already exists, it preserves the current gateway token and any provider keys not being changed.
+
+**Option B** — create the secret separately:
+
+```bash
+export _API_KEY="..."
+./scripts/k8s/deploy.sh --create-secret
+./scripts/k8s/deploy.sh
+```
+
+Use `--show-token` with either command if you want the token printed to stdout for local testing.
+
+### 2) Access the gateway
+
+```bash
+kubectl port-forward svc/openclaw 18789:18789 -n openclaw
+open http://localhost:18789
+```
+
+## What gets deployed
+
+```
+Namespace: openclaw (configurable via OPENCLAW_NAMESPACE)
+├── Deployment/openclaw # Single pod, init container + gateway
+├── Service/openclaw # ClusterIP on port 18789
+├── PersistentVolumeClaim # 10Gi for agent state and config
+├── ConfigMap/openclaw-config # openclaw.json + AGENTS.md
+└── Secret/openclaw-secrets # Gateway token + API keys
+```
+
+## Customization
+
+### Agent instructions
+
+Edit the `AGENTS.md` in `scripts/k8s/manifests/configmap.yaml` and redeploy:
+
+```bash
+./scripts/k8s/deploy.sh
+```
+
+### Gateway config
+
+Edit `openclaw.json` in `scripts/k8s/manifests/configmap.yaml`. See [Gateway configuration](/gateway/configuration) for the full reference.
+
+### Add providers
+
+Re-run with additional keys exported:
+
+```bash
+export ANTHROPIC_API_KEY="..."
+export OPENAI_API_KEY="..."
+./scripts/k8s/deploy.sh --create-secret
+./scripts/k8s/deploy.sh
+```
+
+Existing provider keys stay in the Secret unless you overwrite them.
+
+Or patch the Secret directly:
+
+```bash
+kubectl patch secret openclaw-secrets -n openclaw \
+ -p '{"stringData":{"_API_KEY":"..."}}'
+kubectl rollout restart deployment/openclaw -n openclaw
+```
+
+### Custom namespace
+
+```bash
+OPENCLAW_NAMESPACE=my-namespace ./scripts/k8s/deploy.sh
+```
+
+### Custom image
+
+Edit the `image` field in `scripts/k8s/manifests/deployment.yaml`:
+
+```yaml
+image: ghcr.io/openclaw/openclaw:2026.3.1
+```
+
+### Expose beyond port-forward
+
+The default manifests bind the gateway to loopback inside the pod. That works with `kubectl port-forward`, but it does not work with a Kubernetes `Service` or Ingress path that needs to reach the pod IP.
+
+If you want to expose the gateway through an Ingress or load balancer:
+
+- Change the gateway bind in `scripts/k8s/manifests/configmap.yaml` from `loopback` to a non-loopback bind that matches your deployment model
+- Keep gateway auth enabled and use a proper TLS-terminated entrypoint
+- Configure the Control UI for remote access using the supported web security model (for example HTTPS/Tailscale Serve and explicit allowed origins when needed)
+
+## Re-deploy
+
+```bash
+./scripts/k8s/deploy.sh
+```
+
+This applies all manifests and restarts the pod to pick up any config or secret changes.
+
+## Teardown
+
+```bash
+./scripts/k8s/deploy.sh --delete
+```
+
+This deletes the namespace and all resources in it, including the PVC.
+
+## Architecture notes
+
+- The gateway binds to loopback inside the pod by default, so the included setup is for `kubectl port-forward`
+- No cluster-scoped resources — everything lives in a single namespace
+- Security: `readOnlyRootFilesystem`, `drop: ALL` capabilities, non-root user (UID 1000)
+- The default config keeps the Control UI on the safer local-access path: loopback bind plus `kubectl port-forward` to `http://127.0.0.1:18789`
+- If you move beyond localhost access, use the supported remote model: HTTPS/Tailscale plus the appropriate gateway bind and Control UI origin settings
+- Secrets are generated in a temp directory and applied directly to the cluster — no secret material is written to the repo checkout
+
+## File structure
+
+```
+scripts/k8s/
+├── deploy.sh # Creates namespace + secret, deploys via kustomize
+├── create-kind.sh # Local Kind cluster (auto-detects docker/podman)
+└── manifests/
+ ├── kustomization.yaml # Kustomize base
+ ├── configmap.yaml # openclaw.json + AGENTS.md
+ ├── deployment.yaml # Pod spec with security hardening
+ ├── pvc.yaml # 10Gi persistent storage
+ └── service.yaml # ClusterIP on 18789
+```
diff --git a/docs/install/node.md b/docs/install/node.md
index 8c57fde4f72..9cf2f59ec77 100644
--- a/docs/install/node.md
+++ b/docs/install/node.md
@@ -9,7 +9,7 @@ read_when:
# Node.js
-OpenClaw requires **Node 22 or newer**. The [installer script](/install#install-methods) will detect and install Node automatically — this page is for when you want to set up Node yourself and make sure everything is wired up correctly (versions, PATH, global installs).
+OpenClaw requires **Node 22.16 or newer**. **Node 24 is the default and recommended runtime** for installs, CI, and release workflows. Node 22 remains supported via the active LTS line. The [installer script](/install#install-methods) will detect and install Node automatically — this page is for when you want to set up Node yourself and make sure everything is wired up correctly (versions, PATH, global installs).
## Check your version
@@ -17,7 +17,7 @@ OpenClaw requires **Node 22 or newer**. The [installer script](/install#install-
node -v
```
-If this prints `v22.x.x` or higher, you're good. If Node isn't installed or the version is too old, pick an install method below.
+If this prints `v24.x.x` or higher, you're on the recommended default. If it prints `v22.16.x` or higher, you're on the supported Node 22 LTS path, but we still recommend upgrading to Node 24 when convenient. If Node isn't installed or the version is too old, pick an install method below.
## Install Node
@@ -36,7 +36,7 @@ If this prints `v22.x.x` or higher, you're good. If Node isn't installed or the
**Ubuntu / Debian:**
```bash
- curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
+ curl -fsSL https://deb.nodesource.com/setup_24.x | sudo -E bash -
sudo apt-get install -y nodejs
```
@@ -77,8 +77,8 @@ If this prints `v22.x.x` or higher, you're good. If Node isn't installed or the
Example with fnm:
```bash
-fnm install 22
-fnm use 22
+fnm install 24
+fnm use 24
```
diff --git a/docs/platforms/digitalocean.md b/docs/platforms/digitalocean.md
index bddc63b9d1f..cd05587ae76 100644
--- a/docs/platforms/digitalocean.md
+++ b/docs/platforms/digitalocean.md
@@ -66,8 +66,8 @@ ssh root@YOUR_DROPLET_IP
# Update system
apt update && apt upgrade -y
-# Install Node.js 22
-curl -fsSL https://deb.nodesource.com/setup_22.x | bash -
+# Install Node.js 24
+curl -fsSL https://deb.nodesource.com/setup_24.x | bash -
apt install -y nodejs
# Install OpenClaw
diff --git a/docs/platforms/ios.md b/docs/platforms/ios.md
index 0a2eb5abae5..f64eba3fed0 100644
--- a/docs/platforms/ios.md
+++ b/docs/platforms/ios.md
@@ -49,6 +49,114 @@ openclaw nodes status
openclaw gateway call node.list --params "{}"
```
+## Relay-backed push for official builds
+
+Official distributed iOS builds use the external push relay instead of publishing the raw APNs
+token to the gateway.
+
+Gateway-side requirement:
+
+```json5
+{
+ gateway: {
+ push: {
+ apns: {
+ relay: {
+ baseUrl: "https://relay.example.com",
+ },
+ },
+ },
+ },
+}
+```
+
+How the flow works:
+
+- The iOS app registers with the relay using App Attest and the app receipt.
+- The relay returns an opaque relay handle plus a registration-scoped send grant.
+- The iOS app fetches the paired gateway identity and includes it in relay registration, so the relay-backed registration is delegated to that specific gateway.
+- The app forwards that relay-backed registration to the paired gateway with `push.apns.register`.
+- The gateway uses that stored relay handle for `push.test`, background wakes, and wake nudges.
+- The gateway relay base URL must match the relay URL baked into the official/TestFlight iOS build.
+- If the app later connects to a different gateway or a build with a different relay base URL, it refreshes the relay registration instead of reusing the old binding.
+
+What the gateway does **not** need for this path:
+
+- No deployment-wide relay token.
+- No direct APNs key for official/TestFlight relay-backed sends.
+
+Expected operator flow:
+
+1. Install the official/TestFlight iOS build.
+2. Set `gateway.push.apns.relay.baseUrl` on the gateway.
+3. Pair the app to the gateway and let it finish connecting.
+4. The app publishes `push.apns.register` automatically after it has an APNs token, the operator session is connected, and relay registration succeeds.
+5. After that, `push.test`, reconnect wakes, and wake nudges can use the stored relay-backed registration.
+
+Compatibility note:
+
+- `OPENCLAW_APNS_RELAY_BASE_URL` still works as a temporary env override for the gateway.
+
+## Authentication and trust flow
+
+The relay exists to enforce two constraints that direct APNs-on-gateway cannot provide for
+official iOS builds:
+
+- Only genuine OpenClaw iOS builds distributed through Apple can use the hosted relay.
+- A gateway can send relay-backed pushes only for iOS devices that paired with that specific
+ gateway.
+
+Hop by hop:
+
+1. `iOS app -> gateway`
+ - The app first pairs with the gateway through the normal Gateway auth flow.
+ - That gives the app an authenticated node session plus an authenticated operator session.
+ - The operator session is used to call `gateway.identity.get`.
+
+2. `iOS app -> relay`
+ - The app calls the relay registration endpoints over HTTPS.
+ - Registration includes App Attest proof plus the app receipt.
+ - The relay validates the bundle ID, App Attest proof, and Apple receipt, and requires the
+ official/production distribution path.
+ - This is what blocks local Xcode/dev builds from using the hosted relay. A local build may be
+ signed, but it does not satisfy the official Apple distribution proof the relay expects.
+
+3. `gateway identity delegation`
+ - Before relay registration, the app fetches the paired gateway identity from
+ `gateway.identity.get`.
+ - The app includes that gateway identity in the relay registration payload.
+ - The relay returns a relay handle and a registration-scoped send grant that are delegated to
+ that gateway identity.
+
+4. `gateway -> relay`
+ - The gateway stores the relay handle and send grant from `push.apns.register`.
+ - On `push.test`, reconnect wakes, and wake nudges, the gateway signs the send request with its
+ own device identity.
+ - The relay verifies both the stored send grant and the gateway signature against the delegated
+ gateway identity from registration.
+ - Another gateway cannot reuse that stored registration, even if it somehow obtains the handle.
+
+5. `relay -> APNs`
+ - The relay owns the production APNs credentials and the raw APNs token for the official build.
+ - The gateway never stores the raw APNs token for relay-backed official builds.
+ - The relay sends the final push to APNs on behalf of the paired gateway.
+
+Why this design was created:
+
+- To keep production APNs credentials out of user gateways.
+- To avoid storing raw official-build APNs tokens on the gateway.
+- To allow hosted relay usage only for official/TestFlight OpenClaw builds.
+- To prevent one gateway from sending wake pushes to iOS devices owned by a different gateway.
+
+Local/manual builds remain on direct APNs. If you are testing those builds without the relay, the
+gateway still needs direct APNs credentials:
+
+```bash
+export OPENCLAW_APNS_TEAM_ID="TEAMID"
+export OPENCLAW_APNS_KEY_ID="KEYID"
+export OPENCLAW_APNS_PRIVATE_KEY_P8="$(cat /path/to/AuthKey_KEYID.p8)"
+```
+
## Discovery paths
### Bonjour (LAN)
diff --git a/docs/platforms/linux.md b/docs/platforms/linux.md
index 0cce3a54e75..c03dba6f795 100644
--- a/docs/platforms/linux.md
+++ b/docs/platforms/linux.md
@@ -15,7 +15,7 @@ Native Linux companion apps are planned. Contributions are welcome if you want t
## Beginner quick path (VPS)
-1. Install Node 22+
+1. Install Node 24 (recommended; Node 22 LTS, currently `22.16+`, still works for compatibility)
2. `npm i -g openclaw@latest`
3. `openclaw onboard --install-daemon`
4. From your laptop: `ssh -N -L 18789:127.0.0.1:18789 @`
diff --git a/docs/platforms/mac/bundled-gateway.md b/docs/platforms/mac/bundled-gateway.md
index 6cb878015fb..e6e57cc1809 100644
--- a/docs/platforms/mac/bundled-gateway.md
+++ b/docs/platforms/mac/bundled-gateway.md
@@ -16,7 +16,7 @@ running (or attaches to an existing local Gateway if one is already running).
## Install the CLI (required for local mode)
-You need Node 22+ on the Mac, then install `openclaw` globally:
+Node 24 is the default runtime on the Mac. Node 22 LTS, currently `22.16+`, still works for compatibility. Then install `openclaw` globally:
```bash
npm install -g openclaw@
diff --git a/docs/platforms/mac/dev-setup.md b/docs/platforms/mac/dev-setup.md
index e50a850086a..982f687049c 100644
--- a/docs/platforms/mac/dev-setup.md
+++ b/docs/platforms/mac/dev-setup.md
@@ -14,7 +14,7 @@ This guide covers the necessary steps to build and run the OpenClaw macOS applic
Before building the app, ensure you have the following installed:
1. **Xcode 26.2+**: Required for Swift development.
-2. **Node.js 22+ & pnpm**: Required for the gateway, CLI, and packaging scripts.
+2. **Node.js 24 & pnpm**: Recommended for the gateway, CLI, and packaging scripts. Node 22 LTS, currently `22.16+`, remains supported for compatibility.
## 1. Install Dependencies
diff --git a/docs/platforms/mac/release.md b/docs/platforms/mac/release.md
index cd4052ac9dc..d1266c24830 100644
--- a/docs/platforms/mac/release.md
+++ b/docs/platforms/mac/release.md
@@ -39,7 +39,7 @@ Notes:
# Default is auto-derived from APP_VERSION when omitted.
SKIP_NOTARIZE=1 \
BUNDLE_ID=ai.openclaw.mac \
-APP_VERSION=2026.3.11 \
+APP_VERSION=2026.3.12 \
BUILD_CONFIG=release \
SIGN_IDENTITY="Developer ID Application: ()" \
scripts/package-mac-dist.sh
@@ -47,10 +47,10 @@ scripts/package-mac-dist.sh
# `package-mac-dist.sh` already creates the zip + DMG.
# If you used `package-mac-app.sh` directly instead, create them manually:
# If you want notarization/stapling in this step, use the NOTARIZE command below.
-ditto -c -k --sequesterRsrc --keepParent dist/OpenClaw.app dist/OpenClaw-2026.3.11.zip
+ditto -c -k --sequesterRsrc --keepParent dist/OpenClaw.app dist/OpenClaw-2026.3.12.zip
# Optional: build a styled DMG for humans (drag to /Applications)
-scripts/create-dmg.sh dist/OpenClaw.app dist/OpenClaw-2026.3.11.dmg
+scripts/create-dmg.sh dist/OpenClaw.app dist/OpenClaw-2026.3.12.dmg
# Recommended: build + notarize/staple zip + DMG
# First, create a keychain profile once:
@@ -58,13 +58,13 @@ scripts/create-dmg.sh dist/OpenClaw.app dist/OpenClaw-2026.3.11.dmg
# --apple-id "" --team-id "" --password ""
NOTARIZE=1 NOTARYTOOL_PROFILE=openclaw-notary \
BUNDLE_ID=ai.openclaw.mac \
-APP_VERSION=2026.3.11 \
+APP_VERSION=2026.3.12 \
BUILD_CONFIG=release \
SIGN_IDENTITY="Developer ID Application: ()" \
scripts/package-mac-dist.sh
# Optional: ship dSYM alongside the release
-ditto -c -k --keepParent apps/macos/.build/release/OpenClaw.app.dSYM dist/OpenClaw-2026.3.11.dSYM.zip
+ditto -c -k --keepParent apps/macos/.build/release/OpenClaw.app.dSYM dist/OpenClaw-2026.3.12.dSYM.zip
```
## Appcast entry
@@ -72,7 +72,7 @@ ditto -c -k --keepParent apps/macos/.build/release/OpenClaw.app.dSYM dist/OpenCl
Use the release note generator so Sparkle renders formatted HTML notes:
```bash
-SPARKLE_PRIVATE_KEY_FILE=/path/to/ed25519-private-key scripts/make_appcast.sh dist/OpenClaw-2026.3.11.zip https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml
+SPARKLE_PRIVATE_KEY_FILE=/path/to/ed25519-private-key scripts/make_appcast.sh dist/OpenClaw-2026.3.12.zip https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml
```
Generates HTML release notes from `CHANGELOG.md` (via [`scripts/changelog-to-html.sh`](https://github.com/openclaw/openclaw/blob/main/scripts/changelog-to-html.sh)) and embeds them in the appcast entry.
@@ -80,7 +80,7 @@ Commit the updated `appcast.xml` alongside the release assets (zip + dSYM) when
## Publish & verify
-- Upload `OpenClaw-2026.3.11.zip` (and `OpenClaw-2026.3.11.dSYM.zip`) to the GitHub release for tag `v2026.3.11`.
+- Upload `OpenClaw-2026.3.12.zip` (and `OpenClaw-2026.3.12.dSYM.zip`) to the GitHub release for tag `v2026.3.12`.
- Ensure the raw appcast URL matches the baked feed: `https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml`.
- Sanity checks:
- `curl -I https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml` returns 200.
diff --git a/docs/platforms/mac/signing.md b/docs/platforms/mac/signing.md
index 9927ca5f82b..0feac8cd281 100644
--- a/docs/platforms/mac/signing.md
+++ b/docs/platforms/mac/signing.md
@@ -14,7 +14,7 @@ This app is usually built from [`scripts/package-mac-app.sh`](https://github.com
- calls [`scripts/codesign-mac-app.sh`](https://github.com/openclaw/openclaw/blob/main/scripts/codesign-mac-app.sh) to sign the main binary and app bundle so macOS treats each rebuild as the same signed bundle and keeps TCC permissions (notifications, accessibility, screen recording, mic, speech). For stable permissions, use a real signing identity; ad-hoc is opt-in and fragile (see [macOS permissions](/platforms/mac/permissions)).
- uses `CODESIGN_TIMESTAMP=auto` by default; it enables trusted timestamps for Developer ID signatures. Set `CODESIGN_TIMESTAMP=off` to skip timestamping (offline debug builds).
- inject build metadata into Info.plist: `OpenClawBuildTimestamp` (UTC) and `OpenClawGitCommit` (short hash) so the About pane can show build, git, and debug/release channel.
-- **Packaging requires Node 22+**: the script runs TS builds and the Control UI build.
+- **Packaging defaults to Node 24**: the script runs TS builds and the Control UI build. Node 22 LTS, currently `22.16+`, remains supported for compatibility.
- reads `SIGN_IDENTITY` from the environment. Add `export SIGN_IDENTITY="Apple Development: Your Name (TEAMID)"` (or your Developer ID Application cert) to your shell rc to always sign with your cert. Ad-hoc signing requires explicit opt-in via `ALLOW_ADHOC_SIGNING=1` or `SIGN_IDENTITY="-"` (not recommended for permission testing).
- runs a Team ID audit after signing and fails if any Mach-O inside the app bundle is signed by a different Team ID. Set `SKIP_TEAM_ID_CHECK=1` to bypass.
diff --git a/docs/platforms/raspberry-pi.md b/docs/platforms/raspberry-pi.md
index 247bf757b91..5e7e35c9544 100644
--- a/docs/platforms/raspberry-pi.md
+++ b/docs/platforms/raspberry-pi.md
@@ -76,15 +76,15 @@ sudo apt install -y git curl build-essential
sudo timedatectl set-timezone America/Chicago # Change to your timezone
```
-## 4) Install Node.js 22 (ARM64)
+## 4) Install Node.js 24 (ARM64)
```bash
# Install Node.js via NodeSource
-curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
+curl -fsSL https://deb.nodesource.com/setup_24.x | sudo -E bash -
sudo apt install -y nodejs
# Verify
-node --version # Should show v22.x.x
+node --version # Should show v24.x.x
npm --version
```
diff --git a/docs/platforms/windows.md b/docs/platforms/windows.md
index 3ab668ea01e..e755a241375 100644
--- a/docs/platforms/windows.md
+++ b/docs/platforms/windows.md
@@ -22,6 +22,43 @@ Native Windows companion apps are planned.
- [Install & updates](/install/updating)
- Official WSL2 guide (Microsoft): [https://learn.microsoft.com/windows/wsl/install](https://learn.microsoft.com/windows/wsl/install)
+## Native Windows status
+
+Native Windows CLI flows are improving, but WSL2 is still the recommended path.
+
+What works well on native Windows today:
+
+- website installer via `install.ps1`
+- local CLI use such as `openclaw --version`, `openclaw doctor`, and `openclaw plugins list --json`
+- embedded local-agent/provider smoke such as:
+
+```powershell
+openclaw agent --local --agent main --thinking low -m "Reply with exactly WINDOWS-HATCH-OK."
+```
+
+Current caveats:
+
+- `openclaw onboard --non-interactive` still expects a reachable local gateway unless you pass `--skip-health`
+- `openclaw onboard --non-interactive --install-daemon` and `openclaw gateway install` try Windows Scheduled Tasks first
+- if Scheduled Task creation is denied, OpenClaw falls back to a per-user Startup-folder login item and starts the gateway immediately
+- Scheduled Tasks are still preferred when available because they provide better supervisor status
+
+If you want the native CLI only, without gateway service install, use one of these:
+
+```powershell
+openclaw onboard --non-interactive --skip-health
+openclaw gateway run
+```
+
+If you do want managed startup on native Windows:
+
+```powershell
+openclaw gateway install
+openclaw gateway status --json
+```
+
+If Scheduled Task creation is blocked, the fallback service mode still auto-starts after login through the current user's Startup folder.
+
## Gateway
- [Gateway runbook](/gateway)
diff --git a/docs/providers/anthropic.md b/docs/providers/anthropic.md
index de974315273..8974bb2dd61 100644
--- a/docs/providers/anthropic.md
+++ b/docs/providers/anthropic.md
@@ -44,6 +44,34 @@ openclaw onboard --anthropic-api-key "$ANTHROPIC_API_KEY"
- [Adaptive thinking](https://platform.claude.com/docs/en/build-with-claude/adaptive-thinking)
- [Extended thinking](https://platform.claude.com/docs/en/build-with-claude/extended-thinking)
+## Fast mode (Anthropic API)
+
+OpenClaw's shared `/fast` toggle also supports direct Anthropic API-key traffic.
+
+- `/fast on` maps to `service_tier: "auto"`
+- `/fast off` maps to `service_tier: "standard_only"`
+- Config default:
+
+```json5
+{
+ agents: {
+ defaults: {
+ models: {
+ "anthropic/claude-sonnet-4-5": {
+ params: { fastMode: true },
+ },
+ },
+ },
+ },
+}
+```
+
+Important limits:
+
+- This is **API-key only**. Anthropic setup-token / OAuth auth does not honor OpenClaw fast-mode tier injection.
+- OpenClaw only injects Anthropic service tiers for direct `api.anthropic.com` requests. If you route `anthropic/*` through a proxy or gateway, `/fast` leaves `service_tier` untouched.
+- Anthropic reports the effective tier on the response under `usage.service_tier`. On accounts without Priority Tier capacity, `service_tier: "auto"` may still resolve to `standard`.
+
## Prompt caching (Anthropic API)
OpenClaw supports Anthropic's prompt caching feature. This is **API-only**; subscription auth does not honor cache settings.
diff --git a/docs/providers/index.md b/docs/providers/index.md
index 50e45c6559b..f68cd0e0b53 100644
--- a/docs/providers/index.md
+++ b/docs/providers/index.md
@@ -37,7 +37,7 @@ Looking for chat channel docs (WhatsApp/Telegram/Discord/Slack/Mattermost (plugi
- [Mistral](/providers/mistral)
- [Moonshot AI (Kimi + Kimi Coding)](/providers/moonshot)
- [NVIDIA](/providers/nvidia)
-- [Ollama (local models)](/providers/ollama)
+- [Ollama (cloud + local models)](/providers/ollama)
- [OpenAI (API + Codex)](/providers/openai)
- [OpenCode (Zen + Go)](/providers/opencode)
- [OpenRouter](/providers/openrouter)
diff --git a/docs/providers/minimax.md b/docs/providers/minimax.md
index f060c637de8..8cdc5b028f6 100644
--- a/docs/providers/minimax.md
+++ b/docs/providers/minimax.md
@@ -151,7 +151,7 @@ Configure manually via `openclaw.json`:
{
id: "minimax-m2.5-gs32",
name: "MiniMax M2.5 GS32",
- reasoning: false,
+ reasoning: true,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 196608,
diff --git a/docs/providers/ollama.md b/docs/providers/ollama.md
index abc41361ed0..c4604a8e350 100644
--- a/docs/providers/ollama.md
+++ b/docs/providers/ollama.md
@@ -1,7 +1,7 @@
---
-summary: "Run OpenClaw with Ollama (local LLM runtime)"
+summary: "Run OpenClaw with Ollama (cloud and local models)"
read_when:
- - You want to run OpenClaw with local models via Ollama
+ - You want to run OpenClaw with cloud or local models via Ollama
- You need Ollama setup and configuration guidance
title: "Ollama"
---
@@ -16,6 +16,42 @@ Ollama is a local LLM runtime that makes it easy to run open-source models on yo
## Quick start
+### Onboarding wizard (recommended)
+
+The fastest way to set up Ollama is through the onboarding wizard:
+
+```bash
+openclaw onboard
+```
+
+Select **Ollama** from the provider list. The wizard will:
+
+1. Ask for the Ollama base URL where your instance can be reached (default `http://127.0.0.1:11434`).
+2. Let you choose **Cloud + Local** (cloud models and local models) or **Local** (local models only).
+3. Open a browser sign-in flow if you choose **Cloud + Local** and are not signed in to ollama.com.
+4. Discover available models and suggest defaults.
+5. Auto-pull the selected model if it is not available locally.
+
+Non-interactive mode is also supported:
+
+```bash
+openclaw onboard --non-interactive \
+ --auth-choice ollama \
+ --accept-risk
+```
+
+Optionally specify a custom base URL or model:
+
+```bash
+openclaw onboard --non-interactive \
+ --auth-choice ollama \
+ --custom-base-url "http://ollama-host:11434" \
+ --custom-model-id "qwen3.5:27b" \
+ --accept-risk
+```
+
+### Manual setup
+
1. Install Ollama: [https://ollama.com/download](https://ollama.com/download)
2. Pull a local model if you want local inference:
@@ -28,7 +64,7 @@ ollama pull gpt-oss:20b
ollama pull llama3.3
```
-3. If you want Ollama Cloud models too, sign in:
+3. If you want cloud models too, sign in:
```bash
ollama signin
@@ -41,7 +77,7 @@ openclaw onboard
```
- `Local`: local models only
-- `Cloud + Local`: local models plus Ollama Cloud models
+- `Cloud + Local`: local models plus cloud models
- Cloud models such as `kimi-k2.5:cloud`, `minimax-m2.5:cloud`, and `glm-5:cloud` do **not** require a local `ollama pull`
OpenClaw currently suggests:
@@ -191,6 +227,14 @@ Once configured, all your Ollama models are available:
}
```
+## Cloud models
+
+Cloud models let you run cloud-hosted models (for example `kimi-k2.5:cloud`, `minimax-m2.5:cloud`, `glm-5:cloud`) alongside your local models.
+
+To use cloud models, select **Cloud + Local** mode during onboarding. The wizard checks whether you are signed in and opens a browser sign-in flow when needed. If authentication cannot be verified, the wizard falls back to local model defaults.
+
+You can also sign in directly at [ollama.com/signin](https://ollama.com/signin).
+
## Advanced
### Reasoning models
diff --git a/docs/providers/openai.md b/docs/providers/openai.md
index 4683f061546..a6a60f8f2ea 100644
--- a/docs/providers/openai.md
+++ b/docs/providers/openai.md
@@ -36,6 +36,12 @@ openclaw onboard --openai-api-key "$OPENAI_API_KEY"
OpenAI's current API model docs list `gpt-5.4` and `gpt-5.4-pro` for direct
OpenAI API usage. OpenClaw forwards both through the `openai/*` Responses path.
+OpenClaw intentionally suppresses the stale `openai/gpt-5.3-codex-spark` row,
+because direct OpenAI API calls reject it in live traffic.
+
+OpenClaw does **not** expose `openai/gpt-5.3-codex-spark` on the direct OpenAI
+API path. `pi-ai` still ships a built-in row for that model, but live OpenAI API
+requests currently reject it. Spark is treated as Codex-only in OpenClaw.
## Option B: OpenAI Code (Codex) subscription
@@ -63,6 +69,18 @@ openclaw models auth login --provider openai-codex
OpenAI's current Codex docs list `gpt-5.4` as the current Codex model. OpenClaw
maps that to `openai-codex/gpt-5.4` for ChatGPT/Codex OAuth usage.
+If your Codex account is entitled to Codex Spark, OpenClaw also supports:
+
+- `openai-codex/gpt-5.3-codex-spark`
+
+OpenClaw treats Codex Spark as Codex-only. It does not expose a direct
+`openai/gpt-5.3-codex-spark` API-key path.
+
+OpenClaw also preserves `openai-codex/gpt-5.3-codex-spark` when `pi-ai`
+discovers it. Treat it as entitlement-dependent and experimental: Codex Spark is
+separate from GPT-5.4 `/fast`, and availability depends on the signed-in Codex /
+ChatGPT account.
+
### Transport default
OpenClaw uses `pi-ai` for model streaming. For both `openai/*` and
@@ -165,6 +183,46 @@ pass that field through on direct `openai/*` Responses requests.
Supported values are `auto`, `default`, `flex`, and `priority`.
+### OpenAI fast mode
+
+OpenClaw exposes a shared fast-mode toggle for both `openai/*` and
+`openai-codex/*` sessions:
+
+- Chat/UI: `/fast status|on|off`
+- Config: `agents.defaults.models["/"].params.fastMode`
+
+When fast mode is enabled, OpenClaw applies a low-latency OpenAI profile:
+
+- `reasoning.effort = "low"` when the payload does not already specify reasoning
+- `text.verbosity = "low"` when the payload does not already specify verbosity
+- `service_tier = "priority"` for direct `openai/*` Responses calls to `api.openai.com`
+
+Example:
+
+```json5
+{
+ agents: {
+ defaults: {
+ models: {
+ "openai/gpt-5.4": {
+ params: {
+ fastMode: true,
+ },
+ },
+ "openai-codex/gpt-5.4": {
+ params: {
+ fastMode: true,
+ },
+ },
+ },
+ },
+ },
+}
+```
+
+Session overrides win over config. Clearing the session override in the Sessions UI
+returns the session to the configured default.
+
### OpenAI Responses server-side compaction
For direct OpenAI Responses models (`openai/*` using `api: "openai-responses"` with
diff --git a/docs/providers/sglang.md b/docs/providers/sglang.md
new file mode 100644
index 00000000000..ce66950c0c3
--- /dev/null
+++ b/docs/providers/sglang.md
@@ -0,0 +1,104 @@
+---
+summary: "Run OpenClaw with SGLang (OpenAI-compatible self-hosted server)"
+read_when:
+ - You want to run OpenClaw against a local SGLang server
+ - You want OpenAI-compatible /v1 endpoints with your own models
+title: "SGLang"
+---
+
+# SGLang
+
+SGLang can serve open-source models via an **OpenAI-compatible** HTTP API.
+OpenClaw can connect to SGLang using the `openai-completions` API.
+
+OpenClaw can also **auto-discover** available models from SGLang when you opt
+in with `SGLANG_API_KEY` (any value works if your server does not enforce auth)
+and you do not define an explicit `models.providers.sglang` entry.
+
+## Quick start
+
+1. Start SGLang with an OpenAI-compatible server.
+
+Your base URL should expose `/v1` endpoints (for example `/v1/models`,
+`/v1/chat/completions`). SGLang commonly runs on:
+
+- `http://127.0.0.1:30000/v1`
+
+2. Opt in (any value works if no auth is configured):
+
+```bash
+export SGLANG_API_KEY="sglang-local"
+```
+
+3. Run onboarding and choose `SGLang`, or set a model directly:
+
+```bash
+openclaw onboard
+```
+
+```json5
+{
+ agents: {
+ defaults: {
+ model: { primary: "sglang/your-model-id" },
+ },
+ },
+}
+```
+
+## Model discovery (implicit provider)
+
+When `SGLANG_API_KEY` is set (or an auth profile exists) and you **do not**
+define `models.providers.sglang`, OpenClaw will query:
+
+- `GET http://127.0.0.1:30000/v1/models`
+
+and convert the returned IDs into model entries.
+
+If you set `models.providers.sglang` explicitly, auto-discovery is skipped and
+you must define models manually.
+
+## Explicit configuration (manual models)
+
+Use explicit config when:
+
+- SGLang runs on a different host/port.
+- You want to pin `contextWindow`/`maxTokens` values.
+- Your server requires a real API key (or you want to control headers).
+
+```json5
+{
+ models: {
+ providers: {
+ sglang: {
+ baseUrl: "http://127.0.0.1:30000/v1",
+ apiKey: "${SGLANG_API_KEY}",
+ api: "openai-completions",
+ models: [
+ {
+ id: "your-model-id",
+ name: "Local SGLang Model",
+ reasoning: false,
+ input: ["text"],
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
+ contextWindow: 128000,
+ maxTokens: 8192,
+ },
+ ],
+ },
+ },
+ },
+}
+```
+
+## Troubleshooting
+
+- Check the server is reachable:
+
+```bash
+curl http://127.0.0.1:30000/v1/models
+```
+
+- If requests fail with auth errors, set a real `SGLANG_API_KEY` that matches
+ your server configuration, or configure the provider explicitly under
+ `models.providers.sglang`.
diff --git a/docs/reference/RELEASING.md b/docs/reference/RELEASING.md
index b13803e69f3..f929d16e5f7 100644
--- a/docs/reference/RELEASING.md
+++ b/docs/reference/RELEASING.md
@@ -9,7 +9,7 @@ read_when:
# Release Checklist (npm + macOS)
-Use `pnpm` (Node 22+) from the repo root. Keep the working tree clean before tagging/publishing.
+Use `pnpm` from the repo root with Node 24 by default. Node 22 LTS, currently `22.16+`, remains supported for compatibility. Keep the working tree clean before tagging/publishing.
## Operator trigger
diff --git a/docs/reference/secretref-credential-surface.md b/docs/reference/secretref-credential-surface.md
index 2a5fc5a66ac..9f73c7d0112 100644
--- a/docs/reference/secretref-credential-surface.md
+++ b/docs/reference/secretref-credential-surface.md
@@ -69,8 +69,10 @@ Scope intent:
- `channels.bluebubbles.password`
- `channels.bluebubbles.accounts.*.password`
- `channels.feishu.appSecret`
+- `channels.feishu.encryptKey`
- `channels.feishu.verificationToken`
- `channels.feishu.accounts.*.appSecret`
+- `channels.feishu.accounts.*.encryptKey`
- `channels.feishu.accounts.*.verificationToken`
- `channels.msteams.appPassword`
- `channels.mattermost.botToken`
@@ -101,6 +103,7 @@ Notes:
- Plan entries target `profiles.*.key` / `profiles.*.token` and write sibling refs (`keyRef` / `tokenRef`).
- Auth-profile refs are included in runtime resolution and audit coverage.
- For SecretRef-managed model providers, generated `agents/*/agent/models.json` entries persist non-secret markers (not resolved secret values) for `apiKey`/header surfaces.
+- Marker persistence is source-authoritative: OpenClaw writes markers from the active source config snapshot (pre-resolution), not from resolved runtime secret values.
- For web search:
- In explicit provider mode (`tools.web.search.provider` set), only the selected provider key is active.
- In auto mode (`tools.web.search.provider` unset), only the first provider key that resolves by precedence is active.
diff --git a/docs/reference/secretref-user-supplied-credentials-matrix.json b/docs/reference/secretref-user-supplied-credentials-matrix.json
index 6d4b05d2822..f72729dbadc 100644
--- a/docs/reference/secretref-user-supplied-credentials-matrix.json
+++ b/docs/reference/secretref-user-supplied-credentials-matrix.json
@@ -128,6 +128,13 @@
"secretShape": "secret_input",
"optIn": true
},
+ {
+ "id": "channels.feishu.accounts.*.encryptKey",
+ "configFile": "openclaw.json",
+ "path": "channels.feishu.accounts.*.encryptKey",
+ "secretShape": "secret_input",
+ "optIn": true
+ },
{
"id": "channels.feishu.accounts.*.verificationToken",
"configFile": "openclaw.json",
@@ -142,6 +149,13 @@
"secretShape": "secret_input",
"optIn": true
},
+ {
+ "id": "channels.feishu.encryptKey",
+ "configFile": "openclaw.json",
+ "path": "channels.feishu.encryptKey",
+ "secretShape": "secret_input",
+ "optIn": true
+ },
{
"id": "channels.feishu.verificationToken",
"configFile": "openclaw.json",
diff --git a/docs/reference/test.md b/docs/reference/test.md
index 8d99e674c3f..6d5c5535a83 100644
--- a/docs/reference/test.md
+++ b/docs/reference/test.md
@@ -81,7 +81,7 @@ This script drives the interactive wizard via a pseudo-tty, verifies config/work
## QR import smoke (Docker)
-Ensures `qrcode-terminal` loads under Node 22+ in Docker:
+Ensures `qrcode-terminal` loads under the supported Docker Node runtimes (Node 24 default, Node 22 compatible):
```bash
pnpm test:docker:qr
diff --git a/docs/reference/wizard.md b/docs/reference/wizard.md
index d58ab96c83a..60e88fe4226 100644
--- a/docs/reference/wizard.md
+++ b/docs/reference/wizard.md
@@ -39,6 +39,8 @@ For a high-level overview, see [Onboarding Wizard](/start/wizard).
- **OpenAI API key**: uses `OPENAI_API_KEY` if present or prompts for a key, then stores it in auth profiles.
- **xAI (Grok) API key**: prompts for `XAI_API_KEY` and configures xAI as a model provider.
- **OpenCode**: prompts for `OPENCODE_API_KEY` (or `OPENCODE_ZEN_API_KEY`, get it at https://opencode.ai/auth) and lets you pick the Zen or Go catalog.
+ - **Ollama**: prompts for the Ollama base URL, offers **Cloud + Local** or **Local** mode, discovers available models, and auto-pulls the selected local model when needed.
+ - More detail: [Ollama](/providers/ollama)
- **API key**: stores the key for you.
- **Vercel AI Gateway (multi-model proxy)**: prompts for `AI_GATEWAY_API_KEY`.
- More detail: [Vercel AI Gateway](/providers/vercel-ai-gateway)
@@ -239,6 +241,18 @@ openclaw onboard --non-interactive \
```
Swap to `--auth-choice opencode-go --opencode-go-api-key "$OPENCODE_API_KEY"` for the Go catalog.
+
+ ```bash
+ openclaw onboard --non-interactive \
+ --mode local \
+ --auth-choice ollama \
+ --custom-model-id "qwen3.5:27b" \
+ --accept-risk \
+ --gateway-port 18789 \
+ --gateway-bind loopback
+ ```
+ Add `--custom-base-url "http://ollama-host:11434"` to target a remote Ollama instance.
+
### Add agent (non-interactive)
diff --git a/docs/start/getting-started.md b/docs/start/getting-started.md
index c4bed93d33f..26b54b63f6f 100644
--- a/docs/start/getting-started.md
+++ b/docs/start/getting-started.md
@@ -19,7 +19,7 @@ Docs: [Dashboard](/web/dashboard) and [Control UI](/web/control-ui).
## Prereqs
-- Node 22 or newer
+- Node 24 recommended (Node 22 LTS, currently `22.16+`, still supported for compatibility)
Check your Node version with `node --version` if you are unsure.
diff --git a/docs/start/wizard-cli-automation.md b/docs/start/wizard-cli-automation.md
index 8547f60ac19..cd00787c5c7 100644
--- a/docs/start/wizard-cli-automation.md
+++ b/docs/start/wizard-cli-automation.md
@@ -134,6 +134,17 @@ openclaw onboard --non-interactive \
```
Swap to `--auth-choice opencode-go --opencode-go-api-key "$OPENCODE_API_KEY"` for the Go catalog.
+
+ ```bash
+ openclaw onboard --non-interactive \
+ --mode local \
+ --auth-choice ollama \
+ --custom-model-id "qwen3.5:27b" \
+ --accept-risk \
+ --gateway-port 18789 \
+ --gateway-bind loopback
+ ```
+
```bash
openclaw onboard --non-interactive \
diff --git a/docs/start/wizard-cli-reference.md b/docs/start/wizard-cli-reference.md
index 20f99accd8d..5d3e6be6e72 100644
--- a/docs/start/wizard-cli-reference.md
+++ b/docs/start/wizard-cli-reference.md
@@ -16,7 +16,7 @@ For the short guide, see [Onboarding Wizard (CLI)](/start/wizard).
Local mode (default) walks you through:
-- Model and auth setup (OpenAI Code subscription OAuth, Anthropic API key or setup token, plus MiniMax, GLM, Moonshot, and AI Gateway options)
+- Model and auth setup (OpenAI Code subscription OAuth, Anthropic API key or setup token, plus MiniMax, GLM, Ollama, Moonshot, and AI Gateway options)
- Workspace location and bootstrap files
- Gateway settings (port, bind, auth, tailscale)
- Channels and providers (Telegram, WhatsApp, Discord, Google Chat, Mattermost plugin, Signal)
@@ -178,6 +178,11 @@ What you set:
Prompts for `SYNTHETIC_API_KEY`.
More detail: [Synthetic](/providers/synthetic).
+
+ Prompts for base URL (default `http://127.0.0.1:11434`), then offers Cloud + Local or Local mode.
+ Discovers available models and suggests defaults.
+ More detail: [Ollama](/providers/ollama).
+
Moonshot (Kimi K2) and Kimi Coding configs are auto-written.
More detail: [Moonshot AI (Kimi + Kimi Coding)](/providers/moonshot).
diff --git a/docs/start/wizard.md b/docs/start/wizard.md
index ef1fc52b31a..05c09ed53fd 100644
--- a/docs/start/wizard.md
+++ b/docs/start/wizard.md
@@ -111,8 +111,10 @@ Notes:
## Full reference
-For detailed step-by-step breakdowns, non-interactive scripting, Signal setup,
-RPC API, and a full list of config fields the wizard writes, see the
+For detailed step-by-step breakdowns and config outputs, see
+[CLI Onboarding Reference](/start/wizard-cli-reference).
+For non-interactive examples, see [CLI Automation](/start/wizard-cli-automation).
+For the deeper technical reference, including RPC details, see
[Wizard Reference](/reference/wizard).
## Related docs
diff --git a/docs/tools/acp-agents.md b/docs/tools/acp-agents.md
index 65a320f1c52..d8ac5b5f7d3 100644
--- a/docs/tools/acp-agents.md
+++ b/docs/tools/acp-agents.md
@@ -421,6 +421,8 @@ Some controls depend on backend capabilities. If a backend does not support a co
| `/acp doctor` | Backend health, capabilities, actionable fixes. | `/acp doctor` |
| `/acp install` | Print deterministic install and enable steps. | `/acp install` |
+`/acp sessions` reads the store for the current bound or requester session. Commands that accept `session-key`, `session-id`, or `session-label` tokens resolve targets through gateway session discovery, including custom per-agent `session.store` roots.
+
## Runtime options mapping
`/acp` has convenience commands and a generic setter.
diff --git a/docs/tools/llm-task.md b/docs/tools/llm-task.md
index e6f574d078e..2626d3237e4 100644
--- a/docs/tools/llm-task.md
+++ b/docs/tools/llm-task.md
@@ -75,11 +75,14 @@ outside the list is rejected.
- `schema` (object, optional JSON Schema)
- `provider` (string, optional)
- `model` (string, optional)
+- `thinking` (string, optional)
- `authProfileId` (string, optional)
- `temperature` (number, optional)
- `maxTokens` (number, optional)
- `timeoutMs` (number, optional)
+`thinking` accepts the standard OpenClaw reasoning presets, such as `low` or `medium`.
+
## Output
Returns `details.json` containing the parsed JSON (and validates against
@@ -90,6 +93,7 @@ Returns `details.json` containing the parsed JSON (and validates against
```lobster
openclaw.invoke --tool llm-task --action json --args-json '{
"prompt": "Given the input email, return intent and draft.",
+ "thinking": "low",
"input": {
"subject": "Hello",
"body": "Can you help?"
diff --git a/docs/tools/lobster.md b/docs/tools/lobster.md
index 65ff4f56dfb..5c8a47e4d62 100644
--- a/docs/tools/lobster.md
+++ b/docs/tools/lobster.md
@@ -106,6 +106,7 @@ Use it in a pipeline:
```lobster
openclaw.invoke --tool llm-task --action json --args-json '{
"prompt": "Given the input email, return intent and draft.",
+ "thinking": "low",
"input": { "subject": "Hello", "body": "Can you help?" },
"schema": {
"type": "object",
diff --git a/docs/tools/plugin.md b/docs/tools/plugin.md
index a257d8b7a45..7dd6a045c15 100644
--- a/docs/tools/plugin.md
+++ b/docs/tools/plugin.md
@@ -43,6 +43,48 @@ prerelease tag such as `@beta`/`@rc` or an exact prerelease version.
See [Voice Call](/plugins/voice-call) for a concrete example plugin.
Looking for third-party listings? See [Community plugins](/plugins/community).
+## Architecture
+
+OpenClaw's plugin system has four layers:
+
+1. **Manifest + discovery**
+ OpenClaw finds candidate plugins from configured paths, workspace roots,
+ global extension roots, and bundled extensions. Discovery reads
+ `openclaw.plugin.json` plus package metadata first.
+2. **Enablement + validation**
+ Core decides whether a discovered plugin is enabled, disabled, blocked, or
+ selected for an exclusive slot such as memory.
+3. **Runtime loading**
+ Enabled plugins are loaded in-process via jiti and register capabilities into
+ a central registry.
+4. **Surface consumption**
+ The rest of OpenClaw reads the registry to expose tools, channels, provider
+ setup, hooks, HTTP routes, CLI commands, and services.
+
+The important design boundary:
+
+- discovery + config validation should work from **manifest/schema metadata**
+ without executing plugin code
+- runtime behavior comes from the plugin module's `register(api)` path
+
+That split lets OpenClaw validate config, explain missing/disabled plugins, and
+build UI/schema hints before the full runtime is active.
+
+## Execution model
+
+Plugins run **in-process** with the Gateway. They are not sandboxed. A loaded
+plugin has the same process-level trust boundary as core code.
+
+Implications:
+
+- a plugin can register tools, network handlers, hooks, and services
+- a plugin bug can crash or destabilize the gateway
+- a malicious plugin is equivalent to arbitrary code execution inside the
+ OpenClaw process
+
+Use allowlists and explicit install/load paths for non-bundled plugins. Treat
+workspace plugins as development-time code, not production defaults.
+
## Available plugins (official)
- Microsoft Teams is plugin-only as of 2026.1.15; install `@openclaw/msteams` if you use Teams.
@@ -78,6 +120,48 @@ Plugins can register:
Plugins run **in‑process** with the Gateway, so treat them as trusted code.
Tool authoring guide: [Plugin agent tools](/plugins/agent-tools).
+## Load pipeline
+
+At startup, OpenClaw does roughly this:
+
+1. discover candidate plugin roots
+2. read `openclaw.plugin.json` and package metadata
+3. reject unsafe candidates
+4. normalize plugin config (`plugins.enabled`, `allow`, `deny`, `entries`,
+ `slots`, `load.paths`)
+5. decide enablement for each candidate
+6. load enabled modules via jiti
+7. call `register(api)` and collect registrations into the plugin registry
+8. expose the registry to commands/runtime surfaces
+
+The safety gates happen **before** runtime execution. Candidates are blocked
+when the entry escapes the plugin root, the path is world-writable, or path
+ownership looks suspicious for non-bundled plugins.
+
+### Manifest-first behavior
+
+The manifest is the control-plane source of truth. OpenClaw uses it to:
+
+- identify the plugin
+- discover declared channels/skills/config schema
+- validate `plugins.entries..config`
+- augment Control UI labels/placeholders
+- show install/catalog metadata
+
+The runtime module is the data-plane part. It registers actual behavior such as
+hooks, tools, commands, or provider flows.
+
+### What the loader caches
+
+OpenClaw keeps short in-process caches for:
+
+- discovery results
+- manifest registry data
+- loaded plugin registries
+
+These caches reduce bursty startup and repeated command overhead. They are safe
+to think of as short-lived performance caches, not persistence.
+
## Runtime helpers
Plugins can access selected core helpers via `api.runtime`. For telephony TTS:
@@ -259,6 +343,10 @@ Default-on bundled plugin exceptions:
Installed plugins are enabled by default, but can be disabled the same way.
+Workspace plugins are **disabled by default** unless you explicitly enable them
+or allowlist them. This is intentional: a checked-out repo should not silently
+become production gateway code.
+
Hardening notes:
- If `plugins.allow` is empty and non-bundled plugins are discoverable, OpenClaw logs a startup warning with plugin ids and sources.
@@ -275,6 +363,25 @@ manifest.
If multiple plugins resolve to the same id, the first match in the order above
wins and lower-precedence copies are ignored.
+### Enablement rules
+
+Enablement is resolved after discovery:
+
+- `plugins.enabled: false` disables all plugins
+- `plugins.deny` always wins
+- `plugins.entries..enabled: false` disables that plugin
+- workspace-origin plugins are disabled by default
+- allowlists restrict the active set when `plugins.allow` is non-empty
+- bundled plugins are disabled by default unless:
+ - the bundled id is in the built-in default-on set, or
+ - you explicitly enable it, or
+ - channel config implicitly enables the bundled channel plugin
+- exclusive slots can force-enable the selected plugin for that slot
+
+In current core, bundled default-on ids include local/provider helpers such as
+`ollama`, `sglang`, `vllm`, plus `device-pair`, `phone-control`, and
+`talk-voice`.
+
### Package packs
A plugin directory may include a `package.json` with `openclaw.extensions`:
@@ -354,6 +461,34 @@ Default plugin ids:
If a plugin exports `id`, OpenClaw uses it but warns when it doesn’t match the
configured id.
+## Registry model
+
+Loaded plugins do not directly mutate random core globals. They register into a
+central plugin registry.
+
+The registry tracks:
+
+- plugin records (identity, source, origin, status, diagnostics)
+- tools
+- legacy hooks and typed hooks
+- channels
+- providers
+- gateway RPC handlers
+- HTTP routes
+- CLI registrars
+- background services
+- plugin-owned commands
+
+Core features then read from that registry instead of talking to plugin modules
+directly. This keeps loading one-way:
+
+- plugin module -> registry registration
+- core runtime -> registry consumption
+
+That separation matters for maintainability. It means most core surfaces only
+need one integration point: "read the registry", not "special-case every plugin
+module".
+
## Config
```json5
@@ -390,6 +525,17 @@ Validation rules (strict):
`openclaw.plugin.json` (`configSchema`).
- If a plugin is disabled, its config is preserved and a **warning** is emitted.
+### Disabled vs missing vs invalid
+
+These states are intentionally different:
+
+- **disabled**: plugin exists, but enablement rules turned it off
+- **missing**: config references a plugin id that discovery did not find
+- **invalid**: plugin exists, but its config does not match the declared schema
+
+OpenClaw preserves config for disabled plugins so toggling them back on is not
+destructive.
+
## Plugin slots (exclusive categories)
Some plugin categories are **exclusive** (only one active at a time). Use
@@ -488,6 +634,19 @@ Plugins export either:
- A function: `(api) => { ... }`
- An object: `{ id, name, configSchema, register(api) { ... } }`
+`register(api)` is where plugins attach behavior. Common registrations include:
+
+- `registerTool`
+- `registerHook`
+- `on(...)` for typed lifecycle hooks
+- `registerChannel`
+- `registerProvider`
+- `registerHttpRoute`
+- `registerCommand`
+- `registerCli`
+- `registerContextEngine`
+- `registerService`
+
Context engine plugins can also register a runtime-owned context manager:
```ts
@@ -603,13 +762,188 @@ Migration guidance:
## Provider plugins (model auth)
-Plugins can register **model provider auth** flows so users can run OAuth or
-API-key setup inside OpenClaw (no external scripts needed).
+Plugins can register **model providers** so users can run OAuth or API-key
+setup inside OpenClaw, surface provider setup in onboarding/model-pickers, and
+contribute implicit provider discovery.
+
+Provider plugins are the modular extension seam for model-provider setup. They
+are not just "OAuth helpers" anymore.
+
+### Provider plugin lifecycle
+
+A provider plugin can participate in five distinct phases:
+
+1. **Auth**
+ `auth[].run(ctx)` performs OAuth, API-key capture, device code, or custom
+ setup and returns auth profiles plus optional config patches.
+2. **Non-interactive setup**
+ `auth[].runNonInteractive(ctx)` handles `openclaw onboard --non-interactive`
+ without prompts. Use this when the provider needs custom headless setup
+ beyond the built-in simple API-key paths.
+3. **Wizard integration**
+ `wizard.onboarding` adds an entry to `openclaw onboard`.
+ `wizard.modelPicker` adds a setup entry to the model picker.
+4. **Implicit discovery**
+ `discovery.run(ctx)` can contribute provider config automatically during
+ model resolution/listing.
+5. **Post-selection follow-up**
+ `onModelSelected(ctx)` runs after a model is chosen. Use this for provider-
+ specific work such as downloading a local model.
+
+This is the recommended split because these phases have different lifecycle
+requirements:
+
+- auth is interactive and writes credentials/config
+- non-interactive setup is flag/env-driven and must not prompt
+- wizard metadata is static and UI-facing
+- discovery should be safe, quick, and failure-tolerant
+- post-select hooks are side effects tied to the chosen model
+
+### Provider auth contract
+
+`auth[].run(ctx)` returns:
+
+- `profiles`: auth profiles to write
+- `configPatch`: optional `openclaw.json` changes
+- `defaultModel`: optional `provider/model` ref
+- `notes`: optional user-facing notes
+
+Core then:
+
+1. writes the returned auth profiles
+2. applies auth-profile config wiring
+3. merges the config patch
+4. optionally applies the default model
+5. runs the provider's `onModelSelected` hook when appropriate
+
+That means a provider plugin owns the provider-specific setup logic, while core
+owns the generic persistence and config-merge path.
+
+### Provider non-interactive contract
+
+`auth[].runNonInteractive(ctx)` is optional. Implement it when the provider
+needs headless setup that cannot be expressed through the built-in generic
+API-key flows.
+
+The non-interactive context includes:
+
+- the current and base config
+- parsed onboarding CLI options
+- runtime logging/error helpers
+- agent/workspace dirs
+- `resolveApiKey(...)` to read provider keys from flags, env, or existing auth
+ profiles while honoring `--secret-input-mode`
+- `toApiKeyCredential(...)` to convert a resolved key into an auth-profile
+ credential with the right plaintext vs secret-ref storage
+
+Use this surface for providers such as:
+
+- self-hosted OpenAI-compatible runtimes that need `--custom-base-url` +
+ `--custom-model-id`
+- provider-specific non-interactive verification or config synthesis
+
+Do not prompt from `runNonInteractive`. Reject missing inputs with actionable
+errors instead.
+
+### Provider wizard metadata
+
+`wizard.onboarding` controls how the provider appears in grouped onboarding:
+
+- `choiceId`: auth-choice value
+- `choiceLabel`: option label
+- `choiceHint`: short hint
+- `groupId`: group bucket id
+- `groupLabel`: group label
+- `groupHint`: group hint
+- `methodId`: auth method to run
+
+`wizard.modelPicker` controls how a provider appears as a "set this up now"
+entry in model selection:
+
+- `label`
+- `hint`
+- `methodId`
+
+When a provider has multiple auth methods, the wizard can either point at one
+explicit method or let OpenClaw synthesize per-method choices.
+
+OpenClaw validates provider wizard metadata when the plugin registers:
+
+- duplicate or blank auth-method ids are rejected
+- wizard metadata is ignored when the provider has no auth methods
+- invalid `methodId` bindings are downgraded to warnings and fall back to the
+ provider's remaining auth methods
+
+### Provider discovery contract
+
+`discovery.run(ctx)` returns one of:
+
+- `{ provider }`
+- `{ providers }`
+- `null`
+
+Use `{ provider }` for the common case where the plugin owns one provider id.
+Use `{ providers }` when a plugin discovers multiple provider entries.
+
+The discovery context includes:
+
+- the current config
+- agent/workspace dirs
+- process env
+- a helper to resolve the provider API key and a discovery-safe API key value
+
+Discovery should be:
+
+- fast
+- best-effort
+- safe to skip on failure
+- careful about side effects
+
+It should not depend on prompts or long-running setup.
+
+### Discovery ordering
+
+Provider discovery runs in ordered phases:
+
+- `simple`
+- `profile`
+- `paired`
+- `late`
+
+Use:
+
+- `simple` for cheap environment-only discovery
+- `profile` when discovery depends on auth profiles
+- `paired` for providers that need to coordinate with another discovery step
+- `late` for expensive or local-network probing
+
+Most self-hosted providers should use `late`.
+
+### Good provider-plugin boundaries
+
+Good fit for provider plugins:
+
+- local/self-hosted providers with custom setup flows
+- provider-specific OAuth/device-code login
+- implicit discovery of local model servers
+- post-selection side effects such as model pulls
+
+Less compelling fit:
+
+- trivial API-key-only providers that differ only by env var, base URL, and one
+ default model
+
+Those can still become plugins, but the main modularity payoff comes from
+extracting behavior-rich providers first.
Register a provider via `api.registerProvider(...)`. Each provider exposes one
-or more auth methods (OAuth, API key, device code, etc.). These methods power:
+or more auth methods (OAuth, API key, device code, etc.). Those methods can
+power:
- `openclaw models auth login --provider [--method ]`
+- `openclaw onboard`
+- model-picker “custom provider” setup entries
+- implicit provider discovery during model resolution/listing
Example:
@@ -642,6 +976,31 @@ api.registerProvider({
},
},
],
+ wizard: {
+ onboarding: {
+ choiceId: "acme",
+ choiceLabel: "AcmeAI",
+ groupId: "acme",
+ groupLabel: "AcmeAI",
+ methodId: "oauth",
+ },
+ modelPicker: {
+ label: "AcmeAI (custom)",
+ hint: "Connect a self-hosted AcmeAI endpoint",
+ methodId: "oauth",
+ },
+ },
+ discovery: {
+ order: "late",
+ run: async () => ({
+ provider: {
+ baseUrl: "https://acme.example/v1",
+ api: "openai-completions",
+ apiKey: "${ACME_API_KEY}",
+ models: [],
+ },
+ }),
+ },
});
```
@@ -649,8 +1008,19 @@ Notes:
- `run` receives a `ProviderAuthContext` with `prompter`, `runtime`,
`openUrl`, and `oauth.createVpsAwareHandlers` helpers.
+- `runNonInteractive` receives a `ProviderAuthMethodNonInteractiveContext`
+ with `opts`, `resolveApiKey`, and `toApiKeyCredential` helpers for
+ headless onboarding.
- Return `configPatch` when you need to add default models or provider config.
- Return `defaultModel` so `--set-default` can update agent defaults.
+- `wizard.onboarding` adds a provider choice to `openclaw onboard`.
+- `wizard.modelPicker` adds a “setup this provider” entry to the model picker.
+- `discovery.run` returns either `{ provider }` for the plugin’s own provider id
+ or `{ providers }` for multi-provider discovery.
+- `discovery.order` controls when the provider runs relative to built-in
+ discovery phases: `simple`, `profile`, `paired`, or `late`.
+- `onModelSelected` is the post-selection hook for provider-specific follow-up
+ work such as pulling a local model.
### Register a messaging channel
diff --git a/docs/tools/slash-commands.md b/docs/tools/slash-commands.md
index d792398f1fa..e0a9f1aa365 100644
--- a/docs/tools/slash-commands.md
+++ b/docs/tools/slash-commands.md
@@ -14,7 +14,7 @@ The host-only bash chat command uses `! ` (with `/bash ` as an alias).
There are two related systems:
- **Commands**: standalone `/...` messages.
-- **Directives**: `/think`, `/verbose`, `/reasoning`, `/elevated`, `/exec`, `/model`, `/queue`.
+- **Directives**: `/think`, `/fast`, `/verbose`, `/reasoning`, `/elevated`, `/exec`, `/model`, `/queue`.
- Directives are stripped from the message before the model sees it.
- In normal chat messages (not directive-only), they are treated as “inline hints” and do **not** persist session settings.
- In directive-only messages (the message contains only directives), they persist to the session and reply with an acknowledgement.
@@ -102,6 +102,7 @@ Text + native (when enabled):
- `/send on|off|inherit` (owner-only)
- `/reset` or `/new [model]` (optional model hint; remainder is passed through)
- `/think ` (dynamic choices by model/provider; aliases: `/thinking`, `/t`)
+- `/fast status|on|off` (omitting the arg shows the current effective fast-mode state)
- `/verbose on|full|off` (alias: `/v`)
- `/reasoning on|off|stream` (alias: `/reason`; when on, sends a separate message prefixed `Reasoning:`; `stream` = Telegram draft only)
- `/elevated on|off|ask|full` (alias: `/elev`; `full` skips exec approvals)
@@ -130,6 +131,7 @@ Notes:
- Discord thread-binding commands (`/focus`, `/unfocus`, `/agents`, `/session idle`, `/session max-age`) require effective thread bindings to be enabled (`session.threadBindings.enabled` and/or `channels.discord.threadBindings.enabled`).
- ACP command reference and runtime behavior: [ACP Agents](/tools/acp-agents).
- `/verbose` is meant for debugging and extra visibility; keep it **off** in normal use.
+- `/fast on|off` persists a session override. Use the Sessions UI `inherit` option to clear it and fall back to config defaults.
- Tool failure summaries are still shown when relevant, but detailed failure text is only included when `/verbose` is `on` or `full`.
- `/reasoning` (and `/verbose`) are risky in group settings: they may reveal internal reasoning or tool output you did not intend to expose. Prefer leaving them off, especially in group chats.
- **Fast path:** command-only messages from allowlisted senders are handled immediately (bypass queue + model).
diff --git a/docs/tools/thinking.md b/docs/tools/thinking.md
index 9a2fdc87ea6..045911c92b2 100644
--- a/docs/tools/thinking.md
+++ b/docs/tools/thinking.md
@@ -1,7 +1,7 @@
---
-summary: "Directive syntax for /think + /verbose and how they affect model reasoning"
+summary: "Directive syntax for /think, /fast, /verbose, and reasoning visibility"
read_when:
- - Adjusting thinking or verbose directive parsing or defaults
+ - Adjusting thinking, fast-mode, or verbose directive parsing or defaults
title: "Thinking Levels"
---
@@ -42,6 +42,21 @@ title: "Thinking Levels"
- **Embedded Pi**: the resolved level is passed to the in-process Pi agent runtime.
+## Fast mode (/fast)
+
+- Levels: `on|off`.
+- Directive-only message toggles a session fast-mode override and replies `Fast mode enabled.` / `Fast mode disabled.`.
+- Send `/fast` (or `/fast status`) with no mode to see the current effective fast-mode state.
+- OpenClaw resolves fast mode in this order:
+ 1. Inline/directive-only `/fast on|off`
+ 2. Session override
+ 3. Per-model config: `agents.defaults.models["/"].params.fastMode`
+ 4. Fallback: `off`
+- For `openai/*`, fast mode applies the OpenAI fast profile: `service_tier=priority` when supported, plus low reasoning effort and low text verbosity.
+- For `openai-codex/*`, fast mode applies the same low-latency profile on Codex Responses. OpenClaw keeps one shared `/fast` toggle across both auth paths.
+- For direct `anthropic/*` API-key requests, fast mode maps to Anthropic service tiers: `/fast on` sets `service_tier=auto`, `/fast off` sets `service_tier=standard_only`.
+- Anthropic fast mode is API-key only. OpenClaw skips Anthropic service-tier injection for Claude setup-token / OAuth auth and for non-Anthropic proxy base URLs.
+
## Verbose directives (/verbose or /v)
- Levels: `on` (minimal) | `full` | `off` (default).
diff --git a/docs/web/control-ui.md b/docs/web/control-ui.md
index 59e9c0c226b..73487cc0eae 100644
--- a/docs/web/control-ui.md
+++ b/docs/web/control-ui.md
@@ -75,7 +75,7 @@ The Control UI can localize itself on first load based on your browser locale, a
- Stream tool calls + live tool output cards in Chat (agent events)
- Channels: WhatsApp/Telegram/Discord/Slack + plugin channels (Mattermost, etc.) status + QR login + per-channel config (`channels.status`, `web.login.*`, `config.patch`)
- Instances: presence list + refresh (`system-presence`)
-- Sessions: list + per-session thinking/verbose overrides (`sessions.list`, `sessions.patch`)
+- Sessions: list + per-session thinking/fast/verbose/reasoning overrides (`sessions.list`, `sessions.patch`)
- Cron jobs: list/add/edit/run/enable/disable + run history (`cron.*`)
- Skills: status, enable/disable, install, API key updates (`skills.*`)
- Nodes: list + caps (`node.list`)
diff --git a/docs/web/tui.md b/docs/web/tui.md
index 0c09cb1f877..d1869821d68 100644
--- a/docs/web/tui.md
+++ b/docs/web/tui.md
@@ -37,7 +37,7 @@ Use `--password` if your Gateway uses password auth.
- Header: connection URL, current agent, current session.
- Chat log: user messages, assistant replies, system notices, tool cards.
- Status line: connection/run state (connecting, running, streaming, idle, error).
-- Footer: connection state + agent + session + model + think/verbose/reasoning + token counts + deliver.
+- Footer: connection state + agent + session + model + think/fast/verbose/reasoning + token counts + deliver.
- Input: text editor with autocomplete.
## Mental model: agents + sessions
@@ -92,6 +92,7 @@ Core:
Session controls:
- `/think `
+- `/fast `
- `/verbose `
- `/reasoning `
- `/usage `
diff --git a/extensions/.npmignore b/extensions/.npmignore
new file mode 100644
index 00000000000..7cd53fdbc08
--- /dev/null
+++ b/extensions/.npmignore
@@ -0,0 +1 @@
+**/node_modules/
diff --git a/extensions/acpx/package.json b/extensions/acpx/package.json
index ae4f7e695ef..95d39a46a49 100644
--- a/extensions/acpx/package.json
+++ b/extensions/acpx/package.json
@@ -1,10 +1,10 @@
{
"name": "@openclaw/acpx",
- "version": "2026.3.11",
+ "version": "2026.3.12",
"description": "OpenClaw ACP runtime backend via acpx",
"type": "module",
"dependencies": {
- "acpx": "0.2.0"
+ "acpx": "0.3.0"
},
"openclaw": {
"extensions": [
diff --git a/extensions/bluebubbles/package.json b/extensions/bluebubbles/package.json
index 4918e9d3c02..e60dc2ea639 100644
--- a/extensions/bluebubbles/package.json
+++ b/extensions/bluebubbles/package.json
@@ -1,6 +1,6 @@
{
"name": "@openclaw/bluebubbles",
- "version": "2026.3.11",
+ "version": "2026.3.12",
"description": "OpenClaw BlueBubbles channel plugin",
"type": "module",
"dependencies": {
diff --git a/extensions/bluebubbles/src/monitor-normalize.test.ts b/extensions/bluebubbles/src/monitor-normalize.test.ts
index 3986909c259..3e06302593c 100644
--- a/extensions/bluebubbles/src/monitor-normalize.test.ts
+++ b/extensions/bluebubbles/src/monitor-normalize.test.ts
@@ -17,9 +17,28 @@ describe("normalizeWebhookMessage", () => {
expect(result).not.toBeNull();
expect(result?.senderId).toBe("+15551234567");
+ expect(result?.senderIdExplicit).toBe(false);
expect(result?.chatGuid).toBe("iMessage;-;+15551234567");
});
+ it("marks explicit sender handles as explicit identity", () => {
+ const result = normalizeWebhookMessage({
+ type: "new-message",
+ data: {
+ guid: "msg-explicit-1",
+ text: "hello",
+ isGroup: false,
+ isFromMe: true,
+ handle: { address: "+15551234567" },
+ chatGuid: "iMessage;-;+15551234567",
+ },
+ });
+
+ expect(result).not.toBeNull();
+ expect(result?.senderId).toBe("+15551234567");
+ expect(result?.senderIdExplicit).toBe(true);
+ });
+
it("does not infer sender from group chatGuid when sender handle is missing", () => {
const result = normalizeWebhookMessage({
type: "new-message",
@@ -72,6 +91,7 @@ describe("normalizeWebhookReaction", () => {
expect(result).not.toBeNull();
expect(result?.senderId).toBe("+15551234567");
+ expect(result?.senderIdExplicit).toBe(false);
expect(result?.messageId).toBe("p:0/msg-1");
expect(result?.action).toBe("added");
});
diff --git a/extensions/bluebubbles/src/monitor-normalize.ts b/extensions/bluebubbles/src/monitor-normalize.ts
index 173ea9c24a6..83454602d4c 100644
--- a/extensions/bluebubbles/src/monitor-normalize.ts
+++ b/extensions/bluebubbles/src/monitor-normalize.ts
@@ -191,12 +191,13 @@ function readFirstChatRecord(message: Record): Record): {
senderId: string;
+ senderIdExplicit: boolean;
senderName?: string;
} {
const handleValue = message.handle ?? message.sender;
const handle =
asRecord(handleValue) ?? (typeof handleValue === "string" ? { address: handleValue } : null);
- const senderId =
+ const senderIdRaw =
readString(handle, "address") ??
readString(handle, "handle") ??
readString(handle, "id") ??
@@ -204,13 +205,18 @@ function extractSenderInfo(message: Record): {
readString(message, "sender") ??
readString(message, "from") ??
"";
+ const senderId = senderIdRaw.trim();
const senderName =
readString(handle, "displayName") ??
readString(handle, "name") ??
readString(message, "senderName") ??
undefined;
- return { senderId, senderName };
+ return {
+ senderId,
+ senderIdExplicit: Boolean(senderId),
+ senderName,
+ };
}
function extractChatContext(message: Record): {
@@ -441,6 +447,7 @@ export type BlueBubblesParticipant = {
export type NormalizedWebhookMessage = {
text: string;
senderId: string;
+ senderIdExplicit: boolean;
senderName?: string;
messageId?: string;
timestamp?: number;
@@ -466,6 +473,7 @@ export type NormalizedWebhookReaction = {
action: "added" | "removed";
emoji: string;
senderId: string;
+ senderIdExplicit: boolean;
senderName?: string;
messageId: string;
timestamp?: number;
@@ -672,7 +680,7 @@ export function normalizeWebhookMessage(
readString(message, "subject") ??
"";
- const { senderId, senderName } = extractSenderInfo(message);
+ const { senderId, senderIdExplicit, senderName } = extractSenderInfo(message);
const { chatGuid, chatIdentifier, chatId, chatName, isGroup, participants } =
extractChatContext(message);
const normalizedParticipants = normalizeParticipantList(participants);
@@ -717,7 +725,7 @@ export function normalizeWebhookMessage(
// BlueBubbles may omit `handle` in webhook payloads; for DM chat GUIDs we can still infer sender.
const senderFallbackFromChatGuid =
- !senderId && !isGroup && chatGuid ? extractHandleFromChatGuid(chatGuid) : null;
+ !senderIdExplicit && !isGroup && chatGuid ? extractHandleFromChatGuid(chatGuid) : null;
const normalizedSender = normalizeBlueBubblesHandle(senderId || senderFallbackFromChatGuid || "");
if (!normalizedSender) {
return null;
@@ -727,6 +735,7 @@ export function normalizeWebhookMessage(
return {
text,
senderId: normalizedSender,
+ senderIdExplicit,
senderName,
messageId,
timestamp,
@@ -777,7 +786,7 @@ export function normalizeWebhookReaction(
const emoji = (associatedEmoji?.trim() || mapping?.emoji) ?? `reaction:${associatedType}`;
const action = mapping?.action ?? resolveTapbackActionHint(associatedType) ?? "added";
- const { senderId, senderName } = extractSenderInfo(message);
+ const { senderId, senderIdExplicit, senderName } = extractSenderInfo(message);
const { chatGuid, chatIdentifier, chatId, chatName, isGroup } = extractChatContext(message);
const fromMe = readBoolean(message, "isFromMe") ?? readBoolean(message, "is_from_me");
@@ -793,7 +802,7 @@ export function normalizeWebhookReaction(
: undefined;
const senderFallbackFromChatGuid =
- !senderId && !isGroup && chatGuid ? extractHandleFromChatGuid(chatGuid) : null;
+ !senderIdExplicit && !isGroup && chatGuid ? extractHandleFromChatGuid(chatGuid) : null;
const normalizedSender = normalizeBlueBubblesHandle(senderId || senderFallbackFromChatGuid || "");
if (!normalizedSender) {
return null;
@@ -803,6 +812,7 @@ export function normalizeWebhookReaction(
action,
emoji,
senderId: normalizedSender,
+ senderIdExplicit,
senderName,
messageId: associatedGuid,
timestamp,
diff --git a/extensions/bluebubbles/src/monitor-processing.ts b/extensions/bluebubbles/src/monitor-processing.ts
index 6eb2ab08bc0..9cf72ea1efd 100644
--- a/extensions/bluebubbles/src/monitor-processing.ts
+++ b/extensions/bluebubbles/src/monitor-processing.ts
@@ -38,6 +38,10 @@ import {
resolveBlueBubblesMessageId,
resolveReplyContextFromCache,
} from "./monitor-reply-cache.js";
+import {
+ hasBlueBubblesSelfChatCopy,
+ rememberBlueBubblesSelfChatCopy,
+} from "./monitor-self-chat-cache.js";
import type {
BlueBubblesCoreRuntime,
BlueBubblesRuntimeEnv,
@@ -47,7 +51,12 @@ import { isBlueBubblesPrivateApiEnabled } from "./probe.js";
import { normalizeBlueBubblesReactionInput, sendBlueBubblesReaction } from "./reactions.js";
import { normalizeSecretInputString } from "./secret-input.js";
import { resolveChatGuidForTarget, sendMessageBlueBubbles } from "./send.js";
-import { formatBlueBubblesChatTarget, isAllowedBlueBubblesSender } from "./targets.js";
+import {
+ extractHandleFromChatGuid,
+ formatBlueBubblesChatTarget,
+ isAllowedBlueBubblesSender,
+ normalizeBlueBubblesHandle,
+} from "./targets.js";
const DEFAULT_TEXT_LIMIT = 4000;
const invalidAckReactions = new Set();
@@ -80,6 +89,19 @@ function normalizeSnippet(value: string): string {
return stripMarkdown(value).replace(/\s+/g, " ").trim().toLowerCase();
}
+function isBlueBubblesSelfChatMessage(
+ message: NormalizedWebhookMessage,
+ isGroup: boolean,
+): boolean {
+ if (isGroup || !message.senderIdExplicit) {
+ return false;
+ }
+ const chatHandle =
+ (message.chatGuid ? extractHandleFromChatGuid(message.chatGuid) : null) ??
+ normalizeBlueBubblesHandle(message.chatIdentifier ?? "");
+ return Boolean(chatHandle) && chatHandle === message.senderId;
+}
+
function prunePendingOutboundMessageIds(now = Date.now()): void {
const cutoff = now - PENDING_OUTBOUND_MESSAGE_ID_TTL_MS;
for (let i = pendingOutboundMessageIds.length - 1; i >= 0; i--) {
@@ -453,8 +475,27 @@ export async function processMessage(
? `removed ${tapbackParsed.emoji} reaction`
: `reacted with ${tapbackParsed.emoji}`
: text || placeholder;
+ const isSelfChatMessage = isBlueBubblesSelfChatMessage(message, isGroup);
+ const selfChatLookup = {
+ accountId: account.accountId,
+ chatGuid: message.chatGuid,
+ chatIdentifier: message.chatIdentifier,
+ chatId: message.chatId,
+ senderId: message.senderId,
+ body: rawBody,
+ timestamp: message.timestamp,
+ };
const cacheMessageId = message.messageId?.trim();
+ const confirmedOutboundCacheEntry = cacheMessageId
+ ? resolveReplyContextFromCache({
+ accountId: account.accountId,
+ replyToId: cacheMessageId,
+ chatGuid: message.chatGuid,
+ chatIdentifier: message.chatIdentifier,
+ chatId: message.chatId,
+ })
+ : null;
let messageShortId: string | undefined;
const cacheInboundMessage = () => {
if (!cacheMessageId) {
@@ -476,6 +517,12 @@ export async function processMessage(
if (message.fromMe) {
// Cache from-me messages so reply context can resolve sender/body.
cacheInboundMessage();
+ const confirmedAssistantOutbound =
+ confirmedOutboundCacheEntry?.senderLabel === "me" &&
+ normalizeSnippet(confirmedOutboundCacheEntry.body ?? "") === normalizeSnippet(rawBody);
+ if (isSelfChatMessage && confirmedAssistantOutbound) {
+ rememberBlueBubblesSelfChatCopy(selfChatLookup);
+ }
if (cacheMessageId) {
const pending = consumePendingOutboundMessageId({
accountId: account.accountId,
@@ -499,6 +546,11 @@ export async function processMessage(
return;
}
+ if (isSelfChatMessage && hasBlueBubblesSelfChatCopy(selfChatLookup)) {
+ logVerbose(core, runtime, `drop: reflected self-chat duplicate sender=${message.senderId}`);
+ return;
+ }
+
if (!rawBody) {
logVerbose(core, runtime, `drop: empty text sender=${message.senderId}`);
return;
diff --git a/extensions/bluebubbles/src/monitor-self-chat-cache.test.ts b/extensions/bluebubbles/src/monitor-self-chat-cache.test.ts
new file mode 100644
index 00000000000..3e843f6943d
--- /dev/null
+++ b/extensions/bluebubbles/src/monitor-self-chat-cache.test.ts
@@ -0,0 +1,190 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+import {
+ hasBlueBubblesSelfChatCopy,
+ rememberBlueBubblesSelfChatCopy,
+ resetBlueBubblesSelfChatCache,
+} from "./monitor-self-chat-cache.js";
+
+describe("BlueBubbles self-chat cache", () => {
+ const directLookup = {
+ accountId: "default",
+ chatGuid: "iMessage;-;+15551234567",
+ senderId: "+15551234567",
+ } as const;
+
+ afterEach(() => {
+ resetBlueBubblesSelfChatCache();
+ vi.useRealTimers();
+ });
+
+ it("matches repeated lookups for the same scope, timestamp, and text", () => {
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date("2026-03-07T00:00:00Z"));
+
+ rememberBlueBubblesSelfChatCopy({
+ ...directLookup,
+ body: " hello\r\nworld ",
+ timestamp: 123,
+ });
+
+ expect(
+ hasBlueBubblesSelfChatCopy({
+ ...directLookup,
+ body: "hello\nworld",
+ timestamp: 123,
+ }),
+ ).toBe(true);
+ });
+
+ it("canonicalizes DM scope across chatIdentifier and chatGuid", () => {
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date("2026-03-07T00:00:00Z"));
+
+ rememberBlueBubblesSelfChatCopy({
+ accountId: "default",
+ chatIdentifier: "+15551234567",
+ senderId: "+15551234567",
+ body: "hello",
+ timestamp: 123,
+ });
+
+ expect(
+ hasBlueBubblesSelfChatCopy({
+ accountId: "default",
+ chatGuid: "iMessage;-;+15551234567",
+ senderId: "+15551234567",
+ body: "hello",
+ timestamp: 123,
+ }),
+ ).toBe(true);
+
+ resetBlueBubblesSelfChatCache();
+
+ rememberBlueBubblesSelfChatCopy({
+ accountId: "default",
+ chatGuid: "iMessage;-;+15551234567",
+ senderId: "+15551234567",
+ body: "hello",
+ timestamp: 123,
+ });
+
+ expect(
+ hasBlueBubblesSelfChatCopy({
+ accountId: "default",
+ chatIdentifier: "+15551234567",
+ senderId: "+15551234567",
+ body: "hello",
+ timestamp: 123,
+ }),
+ ).toBe(true);
+ });
+
+ it("expires entries after the ttl window", () => {
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date("2026-03-07T00:00:00Z"));
+
+ rememberBlueBubblesSelfChatCopy({
+ ...directLookup,
+ body: "hello",
+ timestamp: 123,
+ });
+
+ vi.advanceTimersByTime(11_001);
+
+ expect(
+ hasBlueBubblesSelfChatCopy({
+ ...directLookup,
+ body: "hello",
+ timestamp: 123,
+ }),
+ ).toBe(false);
+ });
+
+ it("evicts older entries when the cache exceeds its cap", () => {
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date("2026-03-07T00:00:00Z"));
+
+ for (let i = 0; i < 513; i += 1) {
+ rememberBlueBubblesSelfChatCopy({
+ ...directLookup,
+ body: `message-${i}`,
+ timestamp: i,
+ });
+ vi.advanceTimersByTime(1_001);
+ }
+
+ expect(
+ hasBlueBubblesSelfChatCopy({
+ ...directLookup,
+ body: "message-0",
+ timestamp: 0,
+ }),
+ ).toBe(false);
+ expect(
+ hasBlueBubblesSelfChatCopy({
+ ...directLookup,
+ body: "message-512",
+ timestamp: 512,
+ }),
+ ).toBe(true);
+ });
+
+ it("enforces the cache cap even when cleanup is throttled", () => {
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date("2026-03-07T00:00:00Z"));
+
+ for (let i = 0; i < 513; i += 1) {
+ rememberBlueBubblesSelfChatCopy({
+ ...directLookup,
+ body: `burst-${i}`,
+ timestamp: i,
+ });
+ }
+
+ expect(
+ hasBlueBubblesSelfChatCopy({
+ ...directLookup,
+ body: "burst-0",
+ timestamp: 0,
+ }),
+ ).toBe(false);
+ expect(
+ hasBlueBubblesSelfChatCopy({
+ ...directLookup,
+ body: "burst-512",
+ timestamp: 512,
+ }),
+ ).toBe(true);
+ });
+
+ it("does not collide long texts that differ only in the middle", () => {
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date("2026-03-07T00:00:00Z"));
+
+ const prefix = "a".repeat(256);
+ const suffix = "b".repeat(256);
+ const longBodyA = `${prefix}${"x".repeat(300)}${suffix}`;
+ const longBodyB = `${prefix}${"y".repeat(300)}${suffix}`;
+
+ rememberBlueBubblesSelfChatCopy({
+ ...directLookup,
+ body: longBodyA,
+ timestamp: 123,
+ });
+
+ expect(
+ hasBlueBubblesSelfChatCopy({
+ ...directLookup,
+ body: longBodyA,
+ timestamp: 123,
+ }),
+ ).toBe(true);
+ expect(
+ hasBlueBubblesSelfChatCopy({
+ ...directLookup,
+ body: longBodyB,
+ timestamp: 123,
+ }),
+ ).toBe(false);
+ });
+});
diff --git a/extensions/bluebubbles/src/monitor-self-chat-cache.ts b/extensions/bluebubbles/src/monitor-self-chat-cache.ts
new file mode 100644
index 00000000000..09d7167d769
--- /dev/null
+++ b/extensions/bluebubbles/src/monitor-self-chat-cache.ts
@@ -0,0 +1,127 @@
+import { createHash } from "node:crypto";
+import { extractHandleFromChatGuid, normalizeBlueBubblesHandle } from "./targets.js";
+
+type SelfChatCacheKeyParts = {
+ accountId: string;
+ chatGuid?: string;
+ chatIdentifier?: string;
+ chatId?: number;
+ senderId: string;
+};
+
+type SelfChatLookup = SelfChatCacheKeyParts & {
+ body?: string;
+ timestamp?: number;
+};
+
+const SELF_CHAT_TTL_MS = 10_000;
+const MAX_SELF_CHAT_CACHE_ENTRIES = 512;
+const CLEANUP_MIN_INTERVAL_MS = 1_000;
+const MAX_SELF_CHAT_BODY_CHARS = 32_768;
+const cache = new Map();
+let lastCleanupAt = 0;
+
+function normalizeBody(body: string | undefined): string | null {
+ if (!body) {
+ return null;
+ }
+ const bounded =
+ body.length > MAX_SELF_CHAT_BODY_CHARS ? body.slice(0, MAX_SELF_CHAT_BODY_CHARS) : body;
+ const normalized = bounded.replace(/\r\n?/g, "\n").trim();
+ return normalized ? normalized : null;
+}
+
+function isUsableTimestamp(timestamp: number | undefined): timestamp is number {
+ return typeof timestamp === "number" && Number.isFinite(timestamp);
+}
+
+function digestText(text: string): string {
+ return createHash("sha256").update(text).digest("base64url");
+}
+
+function trimOrUndefined(value?: string | null): string | undefined {
+ const trimmed = value?.trim();
+ return trimmed ? trimmed : undefined;
+}
+
+function resolveCanonicalChatTarget(parts: SelfChatCacheKeyParts): string | null {
+ const handleFromGuid = parts.chatGuid ? extractHandleFromChatGuid(parts.chatGuid) : null;
+ if (handleFromGuid) {
+ return handleFromGuid;
+ }
+
+ const normalizedIdentifier = normalizeBlueBubblesHandle(parts.chatIdentifier ?? "");
+ if (normalizedIdentifier) {
+ return normalizedIdentifier;
+ }
+
+ return (
+ trimOrUndefined(parts.chatGuid) ??
+ trimOrUndefined(parts.chatIdentifier) ??
+ (typeof parts.chatId === "number" ? String(parts.chatId) : null)
+ );
+}
+
+function buildScope(parts: SelfChatCacheKeyParts): string {
+ const target = resolveCanonicalChatTarget(parts) ?? parts.senderId;
+ return `${parts.accountId}:${target}`;
+}
+
+function cleanupExpired(now = Date.now()): void {
+ if (
+ lastCleanupAt !== 0 &&
+ now >= lastCleanupAt &&
+ now - lastCleanupAt < CLEANUP_MIN_INTERVAL_MS
+ ) {
+ return;
+ }
+ lastCleanupAt = now;
+ for (const [key, seenAt] of cache.entries()) {
+ if (now - seenAt > SELF_CHAT_TTL_MS) {
+ cache.delete(key);
+ }
+ }
+}
+
+function enforceSizeCap(): void {
+ while (cache.size > MAX_SELF_CHAT_CACHE_ENTRIES) {
+ const oldestKey = cache.keys().next().value;
+ if (typeof oldestKey !== "string") {
+ break;
+ }
+ cache.delete(oldestKey);
+ }
+}
+
+function buildKey(lookup: SelfChatLookup): string | null {
+ const body = normalizeBody(lookup.body);
+ if (!body || !isUsableTimestamp(lookup.timestamp)) {
+ return null;
+ }
+ return `${buildScope(lookup)}:${lookup.timestamp}:${digestText(body)}`;
+}
+
+export function rememberBlueBubblesSelfChatCopy(lookup: SelfChatLookup): void {
+ cleanupExpired();
+ const key = buildKey(lookup);
+ if (!key) {
+ return;
+ }
+ cache.set(key, Date.now());
+ enforceSizeCap();
+}
+
+export function hasBlueBubblesSelfChatCopy(lookup: SelfChatLookup): boolean {
+ cleanupExpired();
+ const key = buildKey(lookup);
+ if (!key) {
+ return false;
+ }
+ const seenAt = cache.get(key);
+ return typeof seenAt === "number" && Date.now() - seenAt <= SELF_CHAT_TTL_MS;
+}
+
+export function resetBlueBubblesSelfChatCache(): void {
+ cache.clear();
+ lastCleanupAt = 0;
+}
diff --git a/extensions/bluebubbles/src/monitor.test.ts b/extensions/bluebubbles/src/monitor.test.ts
index b02019058b8..1ba2e27f0b6 100644
--- a/extensions/bluebubbles/src/monitor.test.ts
+++ b/extensions/bluebubbles/src/monitor.test.ts
@@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createPluginRuntimeMock } from "../../test-utils/plugin-runtime-mock.js";
import type { ResolvedBlueBubblesAccount } from "./accounts.js";
import { fetchBlueBubblesHistory } from "./history.js";
+import { resetBlueBubblesSelfChatCache } from "./monitor-self-chat-cache.js";
import {
handleBlueBubblesWebhookRequest,
registerBlueBubblesWebhookTarget,
@@ -246,6 +247,7 @@ describe("BlueBubbles webhook monitor", () => {
vi.clearAllMocks();
// Reset short ID state between tests for predictable behavior
_resetBlueBubblesShortIdState();
+ resetBlueBubblesSelfChatCache();
mockFetchBlueBubblesHistory.mockResolvedValue({ entries: [], resolved: true });
mockReadAllowFromStore.mockResolvedValue([]);
mockUpsertPairingRequest.mockResolvedValue({ code: "TESTCODE", created: true });
@@ -259,6 +261,7 @@ describe("BlueBubbles webhook monitor", () => {
afterEach(() => {
unregister?.();
+ vi.useRealTimers();
});
describe("DM pairing behavior vs allowFrom", () => {
@@ -2676,5 +2679,449 @@ describe("BlueBubbles webhook monitor", () => {
expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
});
+
+ it("drops reflected self-chat duplicates after a confirmed assistant outbound", async () => {
+ const account = createMockAccount({ dmPolicy: "open" });
+ const config: OpenClawConfig = {};
+ const core = createMockRuntime();
+ setBlueBubblesRuntime(core);
+
+ const { sendMessageBlueBubbles } = await import("./send.js");
+ vi.mocked(sendMessageBlueBubbles).mockResolvedValueOnce({ messageId: "msg-self-1" });
+
+ mockDispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce(async (params) => {
+ await params.dispatcherOptions.deliver({ text: "replying now" }, { kind: "final" });
+ return EMPTY_DISPATCH_RESULT;
+ });
+
+ unregister = registerBlueBubblesWebhookTarget({
+ account,
+ config,
+ runtime: { log: vi.fn(), error: vi.fn() },
+ core,
+ path: "/bluebubbles-webhook",
+ });
+
+ const timestamp = Date.now();
+ const inboundPayload = {
+ type: "new-message",
+ data: {
+ text: "hello",
+ handle: { address: "+15551234567" },
+ isGroup: false,
+ isFromMe: false,
+ guid: "msg-self-0",
+ chatGuid: "iMessage;-;+15551234567",
+ date: timestamp,
+ },
+ };
+
+ await handleBlueBubblesWebhookRequest(
+ createMockRequest("POST", "/bluebubbles-webhook", inboundPayload),
+ createMockResponse(),
+ );
+ await flushAsync();
+
+ expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledTimes(1);
+ mockDispatchReplyWithBufferedBlockDispatcher.mockClear();
+
+ const fromMePayload = {
+ type: "new-message",
+ data: {
+ text: "replying now",
+ handle: { address: "+15551234567" },
+ isGroup: false,
+ isFromMe: true,
+ guid: "msg-self-1",
+ chatGuid: "iMessage;-;+15551234567",
+ date: timestamp,
+ },
+ };
+
+ await handleBlueBubblesWebhookRequest(
+ createMockRequest("POST", "/bluebubbles-webhook", fromMePayload),
+ createMockResponse(),
+ );
+ await flushAsync();
+
+ const reflectedPayload = {
+ type: "new-message",
+ data: {
+ text: "replying now",
+ handle: { address: "+15551234567" },
+ isGroup: false,
+ isFromMe: false,
+ guid: "msg-self-2",
+ chatGuid: "iMessage;-;+15551234567",
+ date: timestamp,
+ },
+ };
+
+ await handleBlueBubblesWebhookRequest(
+ createMockRequest("POST", "/bluebubbles-webhook", reflectedPayload),
+ createMockResponse(),
+ );
+ await flushAsync();
+
+ expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
+ });
+
+ it("does not drop inbound messages when no fromMe self-chat copy was seen", async () => {
+ const account = createMockAccount({ dmPolicy: "open" });
+ const config: OpenClawConfig = {};
+ const core = createMockRuntime();
+ setBlueBubblesRuntime(core);
+
+ unregister = registerBlueBubblesWebhookTarget({
+ account,
+ config,
+ runtime: { log: vi.fn(), error: vi.fn() },
+ core,
+ path: "/bluebubbles-webhook",
+ });
+
+ const inboundPayload = {
+ type: "new-message",
+ data: {
+ text: "genuinely new message",
+ handle: { address: "+15551234567" },
+ isGroup: false,
+ isFromMe: false,
+ guid: "msg-inbound-1",
+ chatGuid: "iMessage;-;+15551234567",
+ date: Date.now(),
+ },
+ };
+
+ await handleBlueBubblesWebhookRequest(
+ createMockRequest("POST", "/bluebubbles-webhook", inboundPayload),
+ createMockResponse(),
+ );
+ await flushAsync();
+
+ expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
+ });
+
+ it("does not drop reflected copies after the self-chat cache TTL expires", async () => {
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date("2026-03-07T00:00:00Z"));
+
+ const account = createMockAccount({ dmPolicy: "open" });
+ const config: OpenClawConfig = {};
+ const core = createMockRuntime();
+ setBlueBubblesRuntime(core);
+
+ unregister = registerBlueBubblesWebhookTarget({
+ account,
+ config,
+ runtime: { log: vi.fn(), error: vi.fn() },
+ core,
+ path: "/bluebubbles-webhook",
+ });
+
+ const timestamp = Date.now();
+ const fromMePayload = {
+ type: "new-message",
+ data: {
+ text: "ttl me",
+ handle: { address: "+15551234567" },
+ isGroup: false,
+ isFromMe: true,
+ guid: "msg-self-ttl-1",
+ chatGuid: "iMessage;-;+15551234567",
+ date: timestamp,
+ },
+ };
+
+ await handleBlueBubblesWebhookRequest(
+ createMockRequest("POST", "/bluebubbles-webhook", fromMePayload),
+ createMockResponse(),
+ );
+ await vi.runAllTimersAsync();
+
+ mockDispatchReplyWithBufferedBlockDispatcher.mockClear();
+ vi.advanceTimersByTime(10_001);
+
+ const reflectedPayload = {
+ type: "new-message",
+ data: {
+ text: "ttl me",
+ handle: { address: "+15551234567" },
+ isGroup: false,
+ isFromMe: false,
+ guid: "msg-self-ttl-2",
+ chatGuid: "iMessage;-;+15551234567",
+ date: timestamp,
+ },
+ };
+
+ await handleBlueBubblesWebhookRequest(
+ createMockRequest("POST", "/bluebubbles-webhook", reflectedPayload),
+ createMockResponse(),
+ );
+ await vi.runAllTimersAsync();
+
+ expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
+ });
+
+ it("does not cache regular fromMe DMs as self-chat reflections", async () => {
+ const account = createMockAccount({ dmPolicy: "open" });
+ const config: OpenClawConfig = {};
+ const core = createMockRuntime();
+ setBlueBubblesRuntime(core);
+
+ unregister = registerBlueBubblesWebhookTarget({
+ account,
+ config,
+ runtime: { log: vi.fn(), error: vi.fn() },
+ core,
+ path: "/bluebubbles-webhook",
+ });
+
+ const timestamp = Date.now();
+ const fromMePayload = {
+ type: "new-message",
+ data: {
+ text: "shared text",
+ handle: { address: "+15557654321" },
+ isGroup: false,
+ isFromMe: true,
+ guid: "msg-normal-fromme",
+ chatGuid: "iMessage;-;+15551234567",
+ date: timestamp,
+ },
+ };
+
+ await handleBlueBubblesWebhookRequest(
+ createMockRequest("POST", "/bluebubbles-webhook", fromMePayload),
+ createMockResponse(),
+ );
+ await flushAsync();
+
+ mockDispatchReplyWithBufferedBlockDispatcher.mockClear();
+
+ const inboundPayload = {
+ type: "new-message",
+ data: {
+ text: "shared text",
+ handle: { address: "+15551234567" },
+ isGroup: false,
+ isFromMe: false,
+ guid: "msg-normal-inbound",
+ chatGuid: "iMessage;-;+15551234567",
+ date: timestamp,
+ },
+ };
+
+ await handleBlueBubblesWebhookRequest(
+ createMockRequest("POST", "/bluebubbles-webhook", inboundPayload),
+ createMockResponse(),
+ );
+ await flushAsync();
+
+ expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
+ });
+
+ it("does not drop user-authored self-chat prompts without a confirmed assistant outbound", async () => {
+ const account = createMockAccount({ dmPolicy: "open" });
+ const config: OpenClawConfig = {};
+ const core = createMockRuntime();
+ setBlueBubblesRuntime(core);
+
+ unregister = registerBlueBubblesWebhookTarget({
+ account,
+ config,
+ runtime: { log: vi.fn(), error: vi.fn() },
+ core,
+ path: "/bluebubbles-webhook",
+ });
+
+ const timestamp = Date.now();
+ const fromMePayload = {
+ type: "new-message",
+ data: {
+ text: "user-authored self prompt",
+ handle: { address: "+15551234567" },
+ isGroup: false,
+ isFromMe: true,
+ guid: "msg-self-user-1",
+ chatGuid: "iMessage;-;+15551234567",
+ date: timestamp,
+ },
+ };
+
+ await handleBlueBubblesWebhookRequest(
+ createMockRequest("POST", "/bluebubbles-webhook", fromMePayload),
+ createMockResponse(),
+ );
+ await flushAsync();
+
+ mockDispatchReplyWithBufferedBlockDispatcher.mockClear();
+
+ const reflectedPayload = {
+ type: "new-message",
+ data: {
+ text: "user-authored self prompt",
+ handle: { address: "+15551234567" },
+ isGroup: false,
+ isFromMe: false,
+ guid: "msg-self-user-2",
+ chatGuid: "iMessage;-;+15551234567",
+ date: timestamp,
+ },
+ };
+
+ await handleBlueBubblesWebhookRequest(
+ createMockRequest("POST", "/bluebubbles-webhook", reflectedPayload),
+ createMockResponse(),
+ );
+ await flushAsync();
+
+ expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
+ });
+
+ it("does not treat a pending text-only match as confirmed assistant outbound", async () => {
+ const account = createMockAccount({ dmPolicy: "open" });
+ const config: OpenClawConfig = {};
+ const core = createMockRuntime();
+ setBlueBubblesRuntime(core);
+
+ const { sendMessageBlueBubbles } = await import("./send.js");
+ vi.mocked(sendMessageBlueBubbles).mockResolvedValueOnce({ messageId: "ok" });
+
+ mockDispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce(async (params) => {
+ await params.dispatcherOptions.deliver({ text: "same text" }, { kind: "final" });
+ return EMPTY_DISPATCH_RESULT;
+ });
+
+ unregister = registerBlueBubblesWebhookTarget({
+ account,
+ config,
+ runtime: { log: vi.fn(), error: vi.fn() },
+ core,
+ path: "/bluebubbles-webhook",
+ });
+
+ const timestamp = Date.now();
+ const inboundPayload = {
+ type: "new-message",
+ data: {
+ text: "hello",
+ handle: { address: "+15551234567" },
+ isGroup: false,
+ isFromMe: false,
+ guid: "msg-self-race-0",
+ chatGuid: "iMessage;-;+15551234567",
+ date: timestamp,
+ },
+ };
+
+ await handleBlueBubblesWebhookRequest(
+ createMockRequest("POST", "/bluebubbles-webhook", inboundPayload),
+ createMockResponse(),
+ );
+ await flushAsync();
+
+ expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledTimes(1);
+ mockDispatchReplyWithBufferedBlockDispatcher.mockClear();
+
+ const fromMePayload = {
+ type: "new-message",
+ data: {
+ text: "same text",
+ handle: { address: "+15551234567" },
+ isGroup: false,
+ isFromMe: true,
+ guid: "msg-self-race-1",
+ chatGuid: "iMessage;-;+15551234567",
+ date: timestamp,
+ },
+ };
+
+ await handleBlueBubblesWebhookRequest(
+ createMockRequest("POST", "/bluebubbles-webhook", fromMePayload),
+ createMockResponse(),
+ );
+ await flushAsync();
+
+ const reflectedPayload = {
+ type: "new-message",
+ data: {
+ text: "same text",
+ handle: { address: "+15551234567" },
+ isGroup: false,
+ isFromMe: false,
+ guid: "msg-self-race-2",
+ chatGuid: "iMessage;-;+15551234567",
+ date: timestamp,
+ },
+ };
+
+ await handleBlueBubblesWebhookRequest(
+ createMockRequest("POST", "/bluebubbles-webhook", reflectedPayload),
+ createMockResponse(),
+ );
+ await flushAsync();
+
+ expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
+ });
+
+ it("does not treat chatGuid-inferred sender ids as self-chat evidence", async () => {
+ const account = createMockAccount({ dmPolicy: "open" });
+ const config: OpenClawConfig = {};
+ const core = createMockRuntime();
+ setBlueBubblesRuntime(core);
+
+ unregister = registerBlueBubblesWebhookTarget({
+ account,
+ config,
+ runtime: { log: vi.fn(), error: vi.fn() },
+ core,
+ path: "/bluebubbles-webhook",
+ });
+
+ const timestamp = Date.now();
+ const fromMePayload = {
+ type: "new-message",
+ data: {
+ text: "shared inferred text",
+ handle: null,
+ isGroup: false,
+ isFromMe: true,
+ guid: "msg-inferred-fromme",
+ chatGuid: "iMessage;-;+15551234567",
+ date: timestamp,
+ },
+ };
+
+ await handleBlueBubblesWebhookRequest(
+ createMockRequest("POST", "/bluebubbles-webhook", fromMePayload),
+ createMockResponse(),
+ );
+ await flushAsync();
+
+ mockDispatchReplyWithBufferedBlockDispatcher.mockClear();
+
+ const inboundPayload = {
+ type: "new-message",
+ data: {
+ text: "shared inferred text",
+ handle: { address: "+15551234567" },
+ isGroup: false,
+ isFromMe: false,
+ guid: "msg-inferred-inbound",
+ chatGuid: "iMessage;-;+15551234567",
+ date: timestamp,
+ },
+ };
+
+ await handleBlueBubblesWebhookRequest(
+ createMockRequest("POST", "/bluebubbles-webhook", inboundPayload),
+ createMockResponse(),
+ );
+ await flushAsync();
+
+ expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
+ });
});
});
diff --git a/extensions/copilot-proxy/package.json b/extensions/copilot-proxy/package.json
index 56f6c1085ee..2b902e216db 100644
--- a/extensions/copilot-proxy/package.json
+++ b/extensions/copilot-proxy/package.json
@@ -1,6 +1,6 @@
{
"name": "@openclaw/copilot-proxy",
- "version": "2026.3.11",
+ "version": "2026.3.12",
"private": true,
"description": "OpenClaw Copilot Proxy provider plugin",
"type": "module",
diff --git a/extensions/device-pair/index.ts b/extensions/device-pair/index.ts
index 7590703a32b..825d1668ac0 100644
--- a/extensions/device-pair/index.ts
+++ b/extensions/device-pair/index.ts
@@ -2,6 +2,7 @@ import os from "node:os";
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/device-pair";
import {
approveDevicePairing,
+ issueDeviceBootstrapToken,
listDevicePairing,
resolveGatewayBindUrl,
runPluginCommandWithTimeout,
@@ -31,8 +32,7 @@ type DevicePairPluginConfig = {
type SetupPayload = {
url: string;
- token?: string;
- password?: string;
+ bootstrapToken: string;
};
type ResolveUrlResult = {
@@ -41,10 +41,8 @@ type ResolveUrlResult = {
error?: string;
};
-type ResolveAuthResult = {
- token?: string;
- password?: string;
- label?: string;
+type ResolveAuthLabelResult = {
+ label?: "token" | "password";
error?: string;
};
@@ -187,7 +185,7 @@ async function resolveTailnetHost(): Promise {
);
}
-function resolveAuth(cfg: OpenClawPluginApi["config"]): ResolveAuthResult {
+function resolveAuthLabel(cfg: OpenClawPluginApi["config"]): ResolveAuthLabelResult {
const mode = cfg.gateway?.auth?.mode;
const token =
pickFirstDefined([
@@ -203,13 +201,13 @@ function resolveAuth(cfg: OpenClawPluginApi["config"]): ResolveAuthResult {
]) ?? undefined;
if (mode === "token" || mode === "password") {
- return resolveRequiredAuth(mode, { token, password });
+ return resolveRequiredAuthLabel(mode, { token, password });
}
if (token) {
- return { token, label: "token" };
+ return { label: "token" };
}
if (password) {
- return { password, label: "password" };
+ return { label: "password" };
}
return { error: "Gateway auth is not configured (no token or password)." };
}
@@ -227,17 +225,17 @@ function pickFirstDefined(candidates: Array): string | null {
return null;
}
-function resolveRequiredAuth(
+function resolveRequiredAuthLabel(
mode: "token" | "password",
values: { token?: string; password?: string },
-): ResolveAuthResult {
+): ResolveAuthLabelResult {
if (mode === "token") {
return values.token
- ? { token: values.token, label: "token" }
+ ? { label: "token" }
: { error: "Gateway auth is set to token, but no token is configured." };
}
return values.password
- ? { password: values.password, label: "password" }
+ ? { label: "password" }
: { error: "Gateway auth is set to password, but no password is configured." };
}
@@ -393,9 +391,9 @@ export default function register(api: OpenClawPluginApi) {
return { text: `✅ Paired ${label}${platformLabel}.` };
}
- const auth = resolveAuth(api.config);
- if (auth.error) {
- return { text: `Error: ${auth.error}` };
+ const authLabelResult = resolveAuthLabel(api.config);
+ if (authLabelResult.error) {
+ return { text: `Error: ${authLabelResult.error}` };
}
const urlResult = await resolveGatewayUrl(api);
@@ -405,14 +403,13 @@ export default function register(api: OpenClawPluginApi) {
const payload: SetupPayload = {
url: urlResult.url,
- token: auth.token,
- password: auth.password,
+ bootstrapToken: (await issueDeviceBootstrapToken()).token,
};
if (action === "qr") {
const setupCode = encodeSetupCode(payload);
const qrAscii = await renderQrAscii(setupCode);
- const authLabel = auth.label ?? "auth";
+ const authLabel = authLabelResult.label ?? "auth";
const channel = ctx.channel;
const target = ctx.senderId?.trim() || ctx.from?.trim() || ctx.to?.trim() || "";
@@ -503,7 +500,7 @@ export default function register(api: OpenClawPluginApi) {
const channel = ctx.channel;
const target = ctx.senderId?.trim() || ctx.from?.trim() || ctx.to?.trim() || "";
- const authLabel = auth.label ?? "auth";
+ const authLabel = authLabelResult.label ?? "auth";
if (channel === "telegram" && target) {
try {
diff --git a/extensions/diagnostics-otel/package.json b/extensions/diagnostics-otel/package.json
index 91aea1e9256..ed34f16faf9 100644
--- a/extensions/diagnostics-otel/package.json
+++ b/extensions/diagnostics-otel/package.json
@@ -1,6 +1,6 @@
{
"name": "@openclaw/diagnostics-otel",
- "version": "2026.3.11",
+ "version": "2026.3.12",
"description": "OpenClaw diagnostics OpenTelemetry exporter",
"type": "module",
"dependencies": {
diff --git a/extensions/diffs/package.json b/extensions/diffs/package.json
index c9e30cee333..8e84cfb45c3 100644
--- a/extensions/diffs/package.json
+++ b/extensions/diffs/package.json
@@ -1,6 +1,6 @@
{
"name": "@openclaw/diffs",
- "version": "2026.3.11",
+ "version": "2026.3.12",
"private": true,
"description": "OpenClaw diff viewer plugin",
"type": "module",
diff --git a/extensions/discord/package.json b/extensions/discord/package.json
index 7f291bd1c7a..0f8c0635e9c 100644
--- a/extensions/discord/package.json
+++ b/extensions/discord/package.json
@@ -1,6 +1,6 @@
{
"name": "@openclaw/discord",
- "version": "2026.3.11",
+ "version": "2026.3.12",
"description": "OpenClaw Discord channel plugin",
"type": "module",
"openclaw": {
diff --git a/extensions/feishu/package.json b/extensions/feishu/package.json
index 116f15f08d2..3c31e647553 100644
--- a/extensions/feishu/package.json
+++ b/extensions/feishu/package.json
@@ -1,6 +1,6 @@
{
"name": "@openclaw/feishu",
- "version": "2026.3.11",
+ "version": "2026.3.12",
"description": "OpenClaw Feishu/Lark channel plugin (community maintained by @m1heng)",
"type": "module",
"dependencies": {
diff --git a/extensions/feishu/src/accounts.test.ts b/extensions/feishu/src/accounts.test.ts
index 979f2fa3791..56783bbd29d 100644
--- a/extensions/feishu/src/accounts.test.ts
+++ b/extensions/feishu/src/accounts.test.ts
@@ -241,6 +241,25 @@ describe("resolveFeishuCredentials", () => {
domain: "feishu",
});
});
+
+ it("does not resolve encryptKey SecretRefs outside webhook mode", () => {
+ const creds = resolveFeishuCredentials(
+ asConfig({
+ connectionMode: "websocket",
+ appId: "cli_123",
+ appSecret: "secret_456",
+ encryptKey: { source: "file", provider: "default", id: "path/to/secret" } as never,
+ }),
+ );
+
+ expect(creds).toEqual({
+ appId: "cli_123",
+ appSecret: "secret_456", // pragma: allowlist secret
+ encryptKey: undefined,
+ verificationToken: undefined,
+ domain: "feishu",
+ });
+ });
});
describe("resolveFeishuAccount", () => {
diff --git a/extensions/feishu/src/accounts.ts b/extensions/feishu/src/accounts.ts
index 016bc997458..b528f6ae0e5 100644
--- a/extensions/feishu/src/accounts.ts
+++ b/extensions/feishu/src/accounts.ts
@@ -169,10 +169,14 @@ export function resolveFeishuCredentials(
if (!appId || !appSecret) {
return null;
}
+ const connectionMode = cfg?.connectionMode ?? "websocket";
return {
appId,
appSecret,
- encryptKey: normalizeString(cfg?.encryptKey),
+ encryptKey:
+ connectionMode === "webhook"
+ ? resolveSecretLike(cfg?.encryptKey, "channels.feishu.encryptKey")
+ : normalizeString(cfg?.encryptKey),
verificationToken: resolveSecretLike(
cfg?.verificationToken,
"channels.feishu.verificationToken",
diff --git a/extensions/feishu/src/channel.ts b/extensions/feishu/src/channel.ts
index 7c90136e70f..856941c4b21 100644
--- a/extensions/feishu/src/channel.ts
+++ b/extensions/feishu/src/channel.ts
@@ -129,7 +129,7 @@ export const feishuPlugin: ChannelPlugin = {
defaultAccount: { type: "string" },
appId: { type: "string" },
appSecret: secretInputJsonSchema,
- encryptKey: { type: "string" },
+ encryptKey: secretInputJsonSchema,
verificationToken: secretInputJsonSchema,
domain: {
oneOf: [
@@ -170,7 +170,7 @@ export const feishuPlugin: ChannelPlugin = {
name: { type: "string" },
appId: { type: "string" },
appSecret: secretInputJsonSchema,
- encryptKey: { type: "string" },
+ encryptKey: secretInputJsonSchema,
verificationToken: secretInputJsonSchema,
domain: { type: "string", enum: ["feishu", "lark"] },
connectionMode: { type: "string", enum: ["websocket", "webhook"] },
diff --git a/extensions/feishu/src/config-schema.test.ts b/extensions/feishu/src/config-schema.test.ts
index cdd4724d3fb..0e0881c849f 100644
--- a/extensions/feishu/src/config-schema.test.ts
+++ b/extensions/feishu/src/config-schema.test.ts
@@ -47,7 +47,7 @@ describe("FeishuConfigSchema webhook validation", () => {
}
});
- it("accepts top-level webhook mode with verificationToken", () => {
+ it("rejects top-level webhook mode without encryptKey", () => {
const result = FeishuConfigSchema.safeParse({
connectionMode: "webhook",
verificationToken: "token_top",
@@ -55,6 +55,21 @@ describe("FeishuConfigSchema webhook validation", () => {
appSecret: "secret_top", // pragma: allowlist secret
});
+ expect(result.success).toBe(false);
+ if (!result.success) {
+ expect(result.error.issues.some((issue) => issue.path.join(".") === "encryptKey")).toBe(true);
+ }
+ });
+
+ it("accepts top-level webhook mode with verificationToken and encryptKey", () => {
+ const result = FeishuConfigSchema.safeParse({
+ connectionMode: "webhook",
+ verificationToken: "token_top",
+ encryptKey: "encrypt_top",
+ appId: "cli_top",
+ appSecret: "secret_top", // pragma: allowlist secret
+ });
+
expect(result.success).toBe(true);
});
@@ -79,9 +94,30 @@ describe("FeishuConfigSchema webhook validation", () => {
}
});
- it("accepts account webhook mode inheriting top-level verificationToken", () => {
+ it("rejects account webhook mode without encryptKey", () => {
+ const result = FeishuConfigSchema.safeParse({
+ accounts: {
+ main: {
+ connectionMode: "webhook",
+ verificationToken: "token_main",
+ appId: "cli_main",
+ appSecret: "secret_main", // pragma: allowlist secret
+ },
+ },
+ });
+
+ expect(result.success).toBe(false);
+ if (!result.success) {
+ expect(
+ result.error.issues.some((issue) => issue.path.join(".") === "accounts.main.encryptKey"),
+ ).toBe(true);
+ }
+ });
+
+ it("accepts account webhook mode inheriting top-level verificationToken and encryptKey", () => {
const result = FeishuConfigSchema.safeParse({
verificationToken: "token_top",
+ encryptKey: "encrypt_top",
accounts: {
main: {
connectionMode: "webhook",
@@ -102,6 +138,31 @@ describe("FeishuConfigSchema webhook validation", () => {
provider: "default",
id: "FEISHU_VERIFICATION_TOKEN",
},
+ encryptKey: "encrypt_top",
+ appId: "cli_top",
+ appSecret: {
+ source: "env",
+ provider: "default",
+ id: "FEISHU_APP_SECRET",
+ },
+ });
+
+ expect(result.success).toBe(true);
+ });
+
+ it("accepts SecretRef encryptKey in webhook mode", () => {
+ const result = FeishuConfigSchema.safeParse({
+ connectionMode: "webhook",
+ verificationToken: {
+ source: "env",
+ provider: "default",
+ id: "FEISHU_VERIFICATION_TOKEN",
+ },
+ encryptKey: {
+ source: "env",
+ provider: "default",
+ id: "FEISHU_ENCRYPT_KEY",
+ },
appId: "cli_top",
appSecret: {
source: "env",
diff --git a/extensions/feishu/src/config-schema.ts b/extensions/feishu/src/config-schema.ts
index 4060e6e2cbb..b78404de6f8 100644
--- a/extensions/feishu/src/config-schema.ts
+++ b/extensions/feishu/src/config-schema.ts
@@ -186,7 +186,7 @@ export const FeishuAccountConfigSchema = z
name: z.string().optional(), // Display name for this account
appId: z.string().optional(),
appSecret: buildSecretInputSchema().optional(),
- encryptKey: z.string().optional(),
+ encryptKey: buildSecretInputSchema().optional(),
verificationToken: buildSecretInputSchema().optional(),
domain: FeishuDomainSchema.optional(),
connectionMode: FeishuConnectionModeSchema.optional(),
@@ -204,7 +204,7 @@ export const FeishuConfigSchema = z
// Top-level credentials (backward compatible for single-account mode)
appId: z.string().optional(),
appSecret: buildSecretInputSchema().optional(),
- encryptKey: z.string().optional(),
+ encryptKey: buildSecretInputSchema().optional(),
verificationToken: buildSecretInputSchema().optional(),
domain: FeishuDomainSchema.optional().default("feishu"),
connectionMode: FeishuConnectionModeSchema.optional().default("websocket"),
@@ -240,13 +240,23 @@ export const FeishuConfigSchema = z
const defaultConnectionMode = value.connectionMode ?? "websocket";
const defaultVerificationTokenConfigured = hasConfiguredSecretInput(value.verificationToken);
- if (defaultConnectionMode === "webhook" && !defaultVerificationTokenConfigured) {
- ctx.addIssue({
- code: z.ZodIssueCode.custom,
- path: ["verificationToken"],
- message:
- 'channels.feishu.connectionMode="webhook" requires channels.feishu.verificationToken',
- });
+ const defaultEncryptKeyConfigured = hasConfiguredSecretInput(value.encryptKey);
+ if (defaultConnectionMode === "webhook") {
+ if (!defaultVerificationTokenConfigured) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ path: ["verificationToken"],
+ message:
+ 'channels.feishu.connectionMode="webhook" requires channels.feishu.verificationToken',
+ });
+ }
+ if (!defaultEncryptKeyConfigured) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ path: ["encryptKey"],
+ message: 'channels.feishu.connectionMode="webhook" requires channels.feishu.encryptKey',
+ });
+ }
}
for (const [accountId, account] of Object.entries(value.accounts ?? {})) {
@@ -259,6 +269,8 @@ export const FeishuConfigSchema = z
}
const accountVerificationTokenConfigured =
hasConfiguredSecretInput(account.verificationToken) || defaultVerificationTokenConfigured;
+ const accountEncryptKeyConfigured =
+ hasConfiguredSecretInput(account.encryptKey) || defaultEncryptKeyConfigured;
if (!accountVerificationTokenConfigured) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
@@ -268,6 +280,15 @@ export const FeishuConfigSchema = z
"a verificationToken (account-level or top-level)",
});
}
+ if (!accountEncryptKeyConfigured) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ path: ["accounts", accountId, "encryptKey"],
+ message:
+ `channels.feishu.accounts.${accountId}.connectionMode="webhook" requires ` +
+ "an encryptKey (account-level or top-level)",
+ });
+ }
}
if (value.dmPolicy === "open") {
diff --git a/extensions/feishu/src/monitor.account.ts b/extensions/feishu/src/monitor.account.ts
index 601f78f0843..f7d40d8e280 100644
--- a/extensions/feishu/src/monitor.account.ts
+++ b/extensions/feishu/src/monitor.account.ts
@@ -24,14 +24,14 @@ import { botNames, botOpenIds } from "./monitor.state.js";
import { monitorWebhook, monitorWebSocket } from "./monitor.transport.js";
import { getFeishuRuntime } from "./runtime.js";
import { getMessageFeishu } from "./send.js";
-import type { ResolvedFeishuAccount } from "./types.js";
+import type { FeishuChatType, ResolvedFeishuAccount } from "./types.js";
const FEISHU_REACTION_VERIFY_TIMEOUT_MS = 1_500;
export type FeishuReactionCreatedEvent = {
message_id: string;
chat_id?: string;
- chat_type?: "p2p" | "group" | "private";
+ chat_type?: string;
reaction_type?: { emoji_type?: string };
operator_type?: string;
user_id?: { open_id?: string };
@@ -105,10 +105,19 @@ export async function resolveReactionSyntheticEvent(
return null;
}
+ const fallbackChatType = reactedMsg.chatType;
+ const normalizedEventChatType = normalizeFeishuChatType(event.chat_type);
+ const resolvedChatType = normalizedEventChatType ?? fallbackChatType;
+ if (!resolvedChatType) {
+ logger?.(
+ `feishu[${accountId}]: skipping reaction ${emoji} on ${messageId} without chat type context`,
+ );
+ return null;
+ }
+
const syntheticChatIdRaw = event.chat_id ?? reactedMsg.chatId;
const syntheticChatId = syntheticChatIdRaw?.trim() ? syntheticChatIdRaw : `p2p:${senderId}`;
- const syntheticChatType: "p2p" | "group" | "private" =
- event.chat_type === "group" ? "group" : "p2p";
+ const syntheticChatType: FeishuChatType = resolvedChatType;
return {
sender: {
sender_id: { open_id: senderId },
@@ -126,6 +135,10 @@ export async function resolveReactionSyntheticEvent(
};
}
+function normalizeFeishuChatType(value: unknown): FeishuChatType | undefined {
+ return value === "group" || value === "private" || value === "p2p" ? value : undefined;
+}
+
type RegisterEventHandlersContext = {
cfg: ClawdbotConfig;
accountId: string;
@@ -521,6 +534,9 @@ export async function monitorSingleAccount(params: MonitorSingleAccountParams):
if (connectionMode === "webhook" && !account.verificationToken?.trim()) {
throw new Error(`Feishu account "${accountId}" webhook mode requires verificationToken`);
}
+ if (connectionMode === "webhook" && !account.encryptKey?.trim()) {
+ throw new Error(`Feishu account "${accountId}" webhook mode requires encryptKey`);
+ }
const warmupCount = await warmupDedupFromDisk(accountId, log);
if (warmupCount > 0) {
diff --git a/extensions/feishu/src/monitor.reaction.test.ts b/extensions/feishu/src/monitor.reaction.test.ts
index 5537af6b214..e17859d0531 100644
--- a/extensions/feishu/src/monitor.reaction.test.ts
+++ b/extensions/feishu/src/monitor.reaction.test.ts
@@ -51,10 +51,11 @@ function makeReactionEvent(
};
}
-function createFetchedReactionMessage(chatId: string) {
+function createFetchedReactionMessage(chatId: string, chatType?: "p2p" | "group" | "private") {
return {
messageId: "om_msg1",
chatId,
+ chatType,
senderOpenId: "ou_bot",
content: "hello",
contentType: "text",
@@ -64,13 +65,15 @@ function createFetchedReactionMessage(chatId: string) {
async function resolveReactionWithLookup(params: {
event?: FeishuReactionCreatedEvent;
lookupChatId: string;
+ lookupChatType?: "p2p" | "group" | "private";
}) {
return await resolveReactionSyntheticEvent({
cfg,
accountId: "default",
event: params.event ?? makeReactionEvent(),
botOpenId: "ou_bot",
- fetchMessage: async () => createFetchedReactionMessage(params.lookupChatId),
+ fetchMessage: async () =>
+ createFetchedReactionMessage(params.lookupChatId, params.lookupChatType),
uuid: () => "fixed-uuid",
});
}
@@ -268,6 +271,7 @@ describe("resolveReactionSyntheticEvent", () => {
fetchMessage: async () => ({
messageId: "om_msg1",
chatId: "oc_group",
+ chatType: "group",
senderOpenId: "ou_other",
senderType: "user",
content: "hello",
@@ -293,6 +297,7 @@ describe("resolveReactionSyntheticEvent", () => {
fetchMessage: async () => ({
messageId: "om_msg1",
chatId: "oc_group",
+ chatType: "group",
senderOpenId: "ou_other",
senderType: "user",
content: "hello",
@@ -348,21 +353,43 @@ describe("resolveReactionSyntheticEvent", () => {
it("falls back to reacted message chat_id when event chat_id is absent", async () => {
const result = await resolveReactionWithLookup({
lookupChatId: "oc_group_from_lookup",
+ lookupChatType: "group",
});
expect(result?.message.chat_id).toBe("oc_group_from_lookup");
- expect(result?.message.chat_type).toBe("p2p");
+ expect(result?.message.chat_type).toBe("group");
});
it("falls back to sender p2p chat when lookup returns empty chat_id", async () => {
const result = await resolveReactionWithLookup({
lookupChatId: "",
+ lookupChatType: "p2p",
});
expect(result?.message.chat_id).toBe("p2p:ou_user1");
expect(result?.message.chat_type).toBe("p2p");
});
+ it("drops reactions without chat context when lookup does not provide chat_type", async () => {
+ const result = await resolveReactionWithLookup({
+ lookupChatId: "oc_group_from_lookup",
+ });
+
+ expect(result).toBeNull();
+ });
+
+ it("drops reactions when event chat_type is invalid and lookup cannot recover it", async () => {
+ const result = await resolveReactionWithLookup({
+ event: makeReactionEvent({
+ chat_id: "oc_group_from_event",
+ chat_type: "bogus" as "group",
+ }),
+ lookupChatId: "oc_group_from_lookup",
+ });
+
+ expect(result).toBeNull();
+ });
+
it("logs and drops reactions when lookup throws", async () => {
const log = vi.fn();
const event = makeReactionEvent();
diff --git a/extensions/feishu/src/monitor.transport.ts b/extensions/feishu/src/monitor.transport.ts
index 49a9130bb61..d619f3cddb3 100644
--- a/extensions/feishu/src/monitor.transport.ts
+++ b/extensions/feishu/src/monitor.transport.ts
@@ -1,7 +1,9 @@
import * as http from "http";
+import crypto from "node:crypto";
import * as Lark from "@larksuiteoapi/node-sdk";
import {
applyBasicWebhookRequestGuards,
+ readJsonBodyWithLimit,
type RuntimeEnv,
installRequestBodyLimitGuard,
} from "openclaw/plugin-sdk/feishu";
@@ -26,6 +28,50 @@ export type MonitorTransportParams = {
eventDispatcher: Lark.EventDispatcher;
};
+function isFeishuWebhookPayload(value: unknown): value is Record {
+ return !!value && typeof value === "object" && !Array.isArray(value);
+}
+
+function buildFeishuWebhookEnvelope(
+ req: http.IncomingMessage,
+ payload: Record,
+): Record {
+ return Object.assign(Object.create({ headers: req.headers }), payload) as Record;
+}
+
+function isFeishuWebhookSignatureValid(params: {
+ headers: http.IncomingHttpHeaders;
+ payload: Record;
+ encryptKey?: string;
+}): boolean {
+ const encryptKey = params.encryptKey?.trim();
+ if (!encryptKey) {
+ return true;
+ }
+
+ const timestampHeader = params.headers["x-lark-request-timestamp"];
+ const nonceHeader = params.headers["x-lark-request-nonce"];
+ const signatureHeader = params.headers["x-lark-signature"];
+ const timestamp = Array.isArray(timestampHeader) ? timestampHeader[0] : timestampHeader;
+ const nonce = Array.isArray(nonceHeader) ? nonceHeader[0] : nonceHeader;
+ const signature = Array.isArray(signatureHeader) ? signatureHeader[0] : signatureHeader;
+ if (!timestamp || !nonce || !signature) {
+ return false;
+ }
+
+ const computedSignature = crypto
+ .createHash("sha256")
+ .update(timestamp + nonce + encryptKey + JSON.stringify(params.payload))
+ .digest("hex");
+ return computedSignature === signature;
+}
+
+function respondText(res: http.ServerResponse, statusCode: number, body: string): void {
+ res.statusCode = statusCode;
+ res.setHeader("Content-Type", "text/plain; charset=utf-8");
+ res.end(body);
+}
+
export async function monitorWebSocket({
account,
accountId,
@@ -88,7 +134,6 @@ export async function monitorWebhook({
log(`feishu[${accountId}]: starting Webhook server on ${host}:${port}, path ${path}...`);
const server = http.createServer();
- const webhookHandler = Lark.adaptDefault(path, eventDispatcher, { autoChallenge: true });
server.on("request", (req, res) => {
res.on("finish", () => {
@@ -118,15 +163,68 @@ export async function monitorWebhook({
return;
}
- void Promise.resolve(webhookHandler(req, res))
- .catch((err) => {
+ void (async () => {
+ try {
+ const bodyResult = await readJsonBodyWithLimit(req, {
+ maxBytes: FEISHU_WEBHOOK_MAX_BODY_BYTES,
+ timeoutMs: FEISHU_WEBHOOK_BODY_TIMEOUT_MS,
+ });
+ if (guard.isTripped() || res.writableEnded) {
+ return;
+ }
+ if (!bodyResult.ok) {
+ if (bodyResult.code === "INVALID_JSON") {
+ respondText(res, 400, "Invalid JSON");
+ }
+ return;
+ }
+ if (!isFeishuWebhookPayload(bodyResult.value)) {
+ respondText(res, 400, "Invalid JSON");
+ return;
+ }
+
+ // Lark's default adapter drops invalid signatures as an empty 200. Reject here instead.
+ if (
+ !isFeishuWebhookSignatureValid({
+ headers: req.headers,
+ payload: bodyResult.value,
+ encryptKey: account.encryptKey,
+ })
+ ) {
+ respondText(res, 401, "Invalid signature");
+ return;
+ }
+
+ const { isChallenge, challenge } = Lark.generateChallenge(bodyResult.value, {
+ encryptKey: account.encryptKey ?? "",
+ });
+ if (isChallenge) {
+ res.statusCode = 200;
+ res.setHeader("Content-Type", "application/json; charset=utf-8");
+ res.end(JSON.stringify(challenge));
+ return;
+ }
+
+ const value = await eventDispatcher.invoke(
+ buildFeishuWebhookEnvelope(req, bodyResult.value),
+ { needCheck: false },
+ );
+ if (!res.headersSent) {
+ res.statusCode = 200;
+ res.setHeader("Content-Type", "application/json; charset=utf-8");
+ res.end(JSON.stringify(value));
+ }
+ } catch (err) {
if (!guard.isTripped()) {
error(`feishu[${accountId}]: webhook handler error: ${String(err)}`);
+ if (!res.headersSent) {
+ respondText(res, 500, "Internal Server Error");
+ }
}
- })
- .finally(() => {
+ } finally {
guard.dispose();
- });
+ }
+ })();
});
httpServers.set(accountId, server);
diff --git a/extensions/feishu/src/monitor.webhook-e2e.test.ts b/extensions/feishu/src/monitor.webhook-e2e.test.ts
new file mode 100644
index 00000000000..2e73f973408
--- /dev/null
+++ b/extensions/feishu/src/monitor.webhook-e2e.test.ts
@@ -0,0 +1,306 @@
+import crypto from "node:crypto";
+import { createServer } from "node:http";
+import type { AddressInfo } from "node:net";
+import type { ClawdbotConfig } from "openclaw/plugin-sdk/feishu";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { createFeishuRuntimeMockModule } from "./monitor.test-mocks.js";
+
+const probeFeishuMock = vi.hoisted(() => vi.fn());
+
+vi.mock("./probe.js", () => ({
+ probeFeishu: probeFeishuMock,
+}));
+
+vi.mock("./client.js", async () => {
+ const actual = await vi.importActual("./client.js");
+ return {
+ ...actual,
+ createFeishuWSClient: vi.fn(() => ({ start: vi.fn() })),
+ };
+});
+
+vi.mock("./runtime.js", () => createFeishuRuntimeMockModule());
+
+import { monitorFeishuProvider, stopFeishuMonitor } from "./monitor.js";
+
+async function getFreePort(): Promise {
+ const server = createServer();
+ await new Promise((resolve) => server.listen(0, "127.0.0.1", () => resolve()));
+ const address = server.address() as AddressInfo | null;
+ if (!address) {
+ throw new Error("missing server address");
+ }
+ await new Promise((resolve) => server.close(() => resolve()));
+ return address.port;
+}
+
+async function waitUntilServerReady(url: string): Promise {
+ for (let i = 0; i < 50; i += 1) {
+ try {
+ const response = await fetch(url, { method: "GET" });
+ if (response.status >= 200 && response.status < 500) {
+ return;
+ }
+ } catch {
+ // retry
+ }
+ await new Promise((resolve) => setTimeout(resolve, 20));
+ }
+ throw new Error(`server did not start: ${url}`);
+}
+
+function buildConfig(params: {
+ accountId: string;
+ path: string;
+ port: number;
+ verificationToken?: string;
+ encryptKey?: string;
+}): ClawdbotConfig {
+ return {
+ channels: {
+ feishu: {
+ enabled: true,
+ accounts: {
+ [params.accountId]: {
+ enabled: true,
+ appId: "cli_test",
+ appSecret: "secret_test", // pragma: allowlist secret
+ connectionMode: "webhook",
+ webhookHost: "127.0.0.1",
+ webhookPort: params.port,
+ webhookPath: params.path,
+ encryptKey: params.encryptKey,
+ verificationToken: params.verificationToken,
+ },
+ },
+ },
+ },
+ } as ClawdbotConfig;
+}
+
+function signFeishuPayload(params: {
+ encryptKey: string;
+ payload: Record;
+ timestamp?: string;
+ nonce?: string;
+}): Record {
+ const timestamp = params.timestamp ?? "1711111111";
+ const nonce = params.nonce ?? "nonce-test";
+ const signature = crypto
+ .createHash("sha256")
+ .update(timestamp + nonce + params.encryptKey + JSON.stringify(params.payload))
+ .digest("hex");
+ return {
+ "content-type": "application/json",
+ "x-lark-request-timestamp": timestamp,
+ "x-lark-request-nonce": nonce,
+ "x-lark-signature": signature,
+ };
+}
+
+function encryptFeishuPayload(encryptKey: string, payload: Record): string {
+ const iv = crypto.randomBytes(16);
+ const key = crypto.createHash("sha256").update(encryptKey).digest();
+ const cipher = crypto.createCipheriv("aes-256-cbc", key, iv);
+ const plaintext = Buffer.from(JSON.stringify(payload), "utf8");
+ const encrypted = Buffer.concat([cipher.update(plaintext), cipher.final()]);
+ return Buffer.concat([iv, encrypted]).toString("base64");
+}
+
+async function withRunningWebhookMonitor(
+ params: {
+ accountId: string;
+ path: string;
+ verificationToken: string;
+ encryptKey: string;
+ },
+ run: (url: string) => Promise,
+) {
+ const port = await getFreePort();
+ const cfg = buildConfig({
+ accountId: params.accountId,
+ path: params.path,
+ port,
+ encryptKey: params.encryptKey,
+ verificationToken: params.verificationToken,
+ });
+
+ const abortController = new AbortController();
+ const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() };
+ const monitorPromise = monitorFeishuProvider({
+ config: cfg,
+ runtime,
+ abortSignal: abortController.signal,
+ });
+
+ const url = `http://127.0.0.1:${port}${params.path}`;
+ await waitUntilServerReady(url);
+
+ try {
+ await run(url);
+ } finally {
+ abortController.abort();
+ await monitorPromise;
+ }
+}
+
+afterEach(() => {
+ stopFeishuMonitor();
+});
+
+describe("Feishu webhook signed-request e2e", () => {
+ it("rejects invalid signatures with 401 instead of empty 200", async () => {
+ probeFeishuMock.mockResolvedValue({ ok: true, botOpenId: "bot_open_id" });
+
+ await withRunningWebhookMonitor(
+ {
+ accountId: "invalid-signature",
+ path: "/hook-e2e-invalid-signature",
+ verificationToken: "verify_token",
+ encryptKey: "encrypt_key",
+ },
+ async (url) => {
+ const payload = { type: "url_verification", challenge: "challenge-token" };
+ const response = await fetch(url, {
+ method: "POST",
+ headers: {
+ ...signFeishuPayload({ encryptKey: "wrong_key", payload }),
+ },
+ body: JSON.stringify(payload),
+ });
+
+ expect(response.status).toBe(401);
+ expect(await response.text()).toBe("Invalid signature");
+ },
+ );
+ });
+
+ it("rejects missing signature headers with 401", async () => {
+ probeFeishuMock.mockResolvedValue({ ok: true, botOpenId: "bot_open_id" });
+
+ await withRunningWebhookMonitor(
+ {
+ accountId: "missing-signature",
+ path: "/hook-e2e-missing-signature",
+ verificationToken: "verify_token",
+ encryptKey: "encrypt_key",
+ },
+ async (url) => {
+ const response = await fetch(url, {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ type: "url_verification", challenge: "challenge-token" }),
+ });
+
+ expect(response.status).toBe(401);
+ expect(await response.text()).toBe("Invalid signature");
+ },
+ );
+ });
+
+ it("returns 400 for invalid json before invoking the sdk", async () => {
+ probeFeishuMock.mockResolvedValue({ ok: true, botOpenId: "bot_open_id" });
+
+ await withRunningWebhookMonitor(
+ {
+ accountId: "invalid-json",
+ path: "/hook-e2e-invalid-json",
+ verificationToken: "verify_token",
+ encryptKey: "encrypt_key",
+ },
+ async (url) => {
+ const response = await fetch(url, {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: "{not-json",
+ });
+
+ expect(response.status).toBe(400);
+ expect(await response.text()).toBe("Invalid JSON");
+ },
+ );
+ });
+
+ it("accepts signed plaintext url_verification challenges end-to-end", async () => {
+ probeFeishuMock.mockResolvedValue({ ok: true, botOpenId: "bot_open_id" });
+
+ await withRunningWebhookMonitor(
+ {
+ accountId: "signed-challenge",
+ path: "/hook-e2e-signed-challenge",
+ verificationToken: "verify_token",
+ encryptKey: "encrypt_key",
+ },
+ async (url) => {
+ const payload = { type: "url_verification", challenge: "challenge-token" };
+ const response = await fetch(url, {
+ method: "POST",
+ headers: signFeishuPayload({ encryptKey: "encrypt_key", payload }),
+ body: JSON.stringify(payload),
+ });
+
+ expect(response.status).toBe(200);
+ await expect(response.json()).resolves.toEqual({ challenge: "challenge-token" });
+ },
+ );
+ });
+
+ it("accepts signed non-challenge events and reaches the dispatcher", async () => {
+ probeFeishuMock.mockResolvedValue({ ok: true, botOpenId: "bot_open_id" });
+
+ await withRunningWebhookMonitor(
+ {
+ accountId: "signed-dispatch",
+ path: "/hook-e2e-signed-dispatch",
+ verificationToken: "verify_token",
+ encryptKey: "encrypt_key",
+ },
+ async (url) => {
+ const payload = {
+ schema: "2.0",
+ header: { event_type: "unknown.event" },
+ event: {},
+ };
+ const response = await fetch(url, {
+ method: "POST",
+ headers: signFeishuPayload({ encryptKey: "encrypt_key", payload }),
+ body: JSON.stringify(payload),
+ });
+
+ expect(response.status).toBe(200);
+ expect(await response.text()).toContain("no unknown.event event handle");
+ },
+ );
+ });
+
+ it("accepts signed encrypted url_verification challenges end-to-end", async () => {
+ probeFeishuMock.mockResolvedValue({ ok: true, botOpenId: "bot_open_id" });
+
+ await withRunningWebhookMonitor(
+ {
+ accountId: "encrypted-challenge",
+ path: "/hook-e2e-encrypted-challenge",
+ verificationToken: "verify_token",
+ encryptKey: "encrypt_key",
+ },
+ async (url) => {
+ const payload = {
+ encrypt: encryptFeishuPayload("encrypt_key", {
+ type: "url_verification",
+ challenge: "encrypted-challenge-token",
+ }),
+ };
+ const response = await fetch(url, {
+ method: "POST",
+ headers: signFeishuPayload({ encryptKey: "encrypt_key", payload }),
+ body: JSON.stringify(payload),
+ });
+
+ expect(response.status).toBe(200);
+ await expect(response.json()).resolves.toEqual({
+ challenge: "encrypted-challenge-token",
+ });
+ },
+ );
+ });
+});
diff --git a/extensions/feishu/src/monitor.webhook-security.test.ts b/extensions/feishu/src/monitor.webhook-security.test.ts
index 466b9a4201a..e9bfa8bf008 100644
--- a/extensions/feishu/src/monitor.webhook-security.test.ts
+++ b/extensions/feishu/src/monitor.webhook-security.test.ts
@@ -64,6 +64,7 @@ function buildConfig(params: {
path: string;
port: number;
verificationToken?: string;
+ encryptKey?: string;
}): ClawdbotConfig {
return {
channels: {
@@ -78,6 +79,7 @@ function buildConfig(params: {
webhookHost: "127.0.0.1",
webhookPort: params.port,
webhookPath: params.path,
+ encryptKey: params.encryptKey,
verificationToken: params.verificationToken,
},
},
@@ -91,6 +93,7 @@ async function withRunningWebhookMonitor(
accountId: string;
path: string;
verificationToken: string;
+ encryptKey: string;
},
run: (url: string) => Promise,
) {
@@ -99,6 +102,7 @@ async function withRunningWebhookMonitor(
accountId: params.accountId,
path: params.path,
port,
+ encryptKey: params.encryptKey,
verificationToken: params.verificationToken,
});
@@ -141,6 +145,19 @@ describe("Feishu webhook security hardening", () => {
);
});
+ it("rejects webhook mode without encryptKey", async () => {
+ probeFeishuMock.mockResolvedValue({ ok: true, botOpenId: "bot_open_id" });
+
+ const cfg = buildConfig({
+ accountId: "missing-encrypt-key",
+ path: "/hook-missing-encrypt",
+ port: await getFreePort(),
+ verificationToken: "verify_token",
+ });
+
+ await expect(monitorFeishuProvider({ config: cfg })).rejects.toThrow(/requires encryptKey/i);
+ });
+
it("returns 415 for POST requests without json content type", async () => {
probeFeishuMock.mockResolvedValue({ ok: true, botOpenId: "bot_open_id" });
await withRunningWebhookMonitor(
@@ -148,6 +165,7 @@ describe("Feishu webhook security hardening", () => {
accountId: "content-type",
path: "/hook-content-type",
verificationToken: "verify_token",
+ encryptKey: "encrypt_key",
},
async (url) => {
const response = await fetch(url, {
@@ -169,6 +187,7 @@ describe("Feishu webhook security hardening", () => {
accountId: "rate-limit",
path: "/hook-rate-limit",
verificationToken: "verify_token",
+ encryptKey: "encrypt_key",
},
async (url) => {
let saw429 = false;
diff --git a/extensions/feishu/src/onboarding.ts b/extensions/feishu/src/onboarding.ts
index 46ad40d7681..24d3bbcc413 100644
--- a/extensions/feishu/src/onboarding.ts
+++ b/extensions/feishu/src/onboarding.ts
@@ -370,6 +370,37 @@ export const feishuOnboardingAdapter: ChannelOnboardingAdapter = {
},
};
}
+ const currentEncryptKey = (next.channels?.feishu as FeishuConfig | undefined)?.encryptKey;
+ const encryptKeyPromptState = buildSingleChannelSecretPromptState({
+ accountConfigured: hasConfiguredSecretInput(currentEncryptKey),
+ hasConfigToken: hasConfiguredSecretInput(currentEncryptKey),
+ allowEnv: false,
+ });
+ const encryptKeyResult = await promptSingleChannelSecretInput({
+ cfg: next,
+ prompter,
+ providerHint: "feishu-webhook",
+ credentialLabel: "encrypt key",
+ accountConfigured: encryptKeyPromptState.accountConfigured,
+ canUseEnv: encryptKeyPromptState.canUseEnv,
+ hasConfigToken: encryptKeyPromptState.hasConfigToken,
+ envPrompt: "",
+ keepPrompt: "Feishu encrypt key already configured. Keep it?",
+ inputPrompt: "Enter Feishu encrypt key",
+ preferredEnvVar: "FEISHU_ENCRYPT_KEY",
+ });
+ if (encryptKeyResult.action === "set") {
+ next = {
+ ...next,
+ channels: {
+ ...next.channels,
+ feishu: {
+ ...next.channels?.feishu,
+ encryptKey: encryptKeyResult.value,
+ },
+ },
+ };
+ }
const currentWebhookPath = (next.channels?.feishu as FeishuConfig | undefined)?.webhookPath;
const webhookPath = String(
await prompter.text({
diff --git a/extensions/feishu/src/send.ts b/extensions/feishu/src/send.ts
index 928ef07f949..0f4fd7e7758 100644
--- a/extensions/feishu/src/send.ts
+++ b/extensions/feishu/src/send.ts
@@ -7,7 +7,7 @@ import { parsePostContent } from "./post.js";
import { getFeishuRuntime } from "./runtime.js";
import { assertFeishuMessageApiSuccess, toFeishuSendResult } from "./send-result.js";
import { resolveFeishuSendTarget } from "./send-target.js";
-import type { FeishuSendResult } from "./types.js";
+import type { FeishuChatType, FeishuMessageInfo, FeishuSendResult } from "./types.js";
const WITHDRAWN_REPLY_ERROR_CODES = new Set([230011, 231003]);
@@ -74,17 +74,6 @@ async function sendFallbackDirect(
return toFeishuSendResult(response, params.receiveId);
}
-export type FeishuMessageInfo = {
- messageId: string;
- chatId: string;
- senderId?: string;
- senderOpenId?: string;
- senderType?: string;
- content: string;
- contentType: string;
- createTime?: number;
-};
-
function parseInteractiveCardContent(parsed: unknown): string {
if (!parsed || typeof parsed !== "object") {
return "[Interactive Card]";
@@ -184,6 +173,7 @@ export async function getMessageFeishu(params: {
items?: Array<{
message_id?: string;
chat_id?: string;
+ chat_type?: FeishuChatType;
msg_type?: string;
body?: { content?: string };
sender?: {
@@ -195,6 +185,7 @@ export async function getMessageFeishu(params: {
}>;
message_id?: string;
chat_id?: string;
+ chat_type?: FeishuChatType;
msg_type?: string;
body?: { content?: string };
sender?: {
@@ -228,6 +219,10 @@ export async function getMessageFeishu(params: {
return {
messageId: item.message_id ?? messageId,
chatId: item.chat_id ?? "",
+ chatType:
+ item.chat_type === "group" || item.chat_type === "private" || item.chat_type === "p2p"
+ ? item.chat_type
+ : undefined,
senderId: item.sender?.id,
senderOpenId: item.sender?.id_type === "open_id" ? item.sender?.id : undefined,
senderType: item.sender?.sender_type,
diff --git a/extensions/feishu/src/types.ts b/extensions/feishu/src/types.ts
index 2160ae05c25..c28398fca65 100644
--- a/extensions/feishu/src/types.ts
+++ b/extensions/feishu/src/types.ts
@@ -60,6 +60,20 @@ export type FeishuSendResult = {
chatId: string;
};
+export type FeishuChatType = "p2p" | "group" | "private";
+
+export type FeishuMessageInfo = {
+ messageId: string;
+ chatId: string;
+ chatType?: FeishuChatType;
+ senderId?: string;
+ senderOpenId?: string;
+ senderType?: string;
+ content: string;
+ contentType: string;
+ createTime?: number;
+};
+
export type FeishuProbeResult = BaseProbeResult & {
appId?: string;
botName?: string;
diff --git a/extensions/google-gemini-cli-auth/package.json b/extensions/google-gemini-cli-auth/package.json
index 7a84f58020a..b41be0f6712 100644
--- a/extensions/google-gemini-cli-auth/package.json
+++ b/extensions/google-gemini-cli-auth/package.json
@@ -1,6 +1,6 @@
{
"name": "@openclaw/google-gemini-cli-auth",
- "version": "2026.3.11",
+ "version": "2026.3.12",
"private": true,
"description": "OpenClaw Gemini CLI OAuth provider plugin",
"type": "module",
diff --git a/extensions/googlechat/package.json b/extensions/googlechat/package.json
index 2b9eee3932e..5791c77aff2 100644
--- a/extensions/googlechat/package.json
+++ b/extensions/googlechat/package.json
@@ -1,6 +1,6 @@
{
"name": "@openclaw/googlechat",
- "version": "2026.3.11",
+ "version": "2026.3.12",
"private": true,
"description": "OpenClaw Google Chat channel plugin",
"type": "module",
@@ -8,7 +8,7 @@
"google-auth-library": "^10.6.1"
},
"peerDependencies": {
- "openclaw": ">=2026.3.7"
+ "openclaw": ">=2026.3.11"
},
"peerDependenciesMeta": {
"openclaw": {
diff --git a/extensions/imessage/package.json b/extensions/imessage/package.json
index 8add26a2fe7..7c0eb02180c 100644
--- a/extensions/imessage/package.json
+++ b/extensions/imessage/package.json
@@ -1,6 +1,6 @@
{
"name": "@openclaw/imessage",
- "version": "2026.3.11",
+ "version": "2026.3.12",
"private": true,
"description": "OpenClaw iMessage channel plugin",
"type": "module",
diff --git a/extensions/irc/package.json b/extensions/irc/package.json
index e6e9bdfe6b4..f9a4f8fcccd 100644
--- a/extensions/irc/package.json
+++ b/extensions/irc/package.json
@@ -1,6 +1,6 @@
{
"name": "@openclaw/irc",
- "version": "2026.3.11",
+ "version": "2026.3.12",
"description": "OpenClaw IRC channel plugin",
"type": "module",
"dependencies": {
diff --git a/extensions/line/package.json b/extensions/line/package.json
index 4f98b21c7a2..d4b7236f316 100644
--- a/extensions/line/package.json
+++ b/extensions/line/package.json
@@ -1,6 +1,6 @@
{
"name": "@openclaw/line",
- "version": "2026.3.11",
+ "version": "2026.3.12",
"private": true,
"description": "OpenClaw LINE channel plugin",
"type": "module",
diff --git a/extensions/llm-task/README.md b/extensions/llm-task/README.md
index d8e5dadc6fb..738208f3d60 100644
--- a/extensions/llm-task/README.md
+++ b/extensions/llm-task/README.md
@@ -69,6 +69,7 @@ outside the list is rejected.
- `schema` (object, optional JSON Schema)
- `provider` (string, optional)
- `model` (string, optional)
+- `thinking` (string, optional)
- `authProfileId` (string, optional)
- `temperature` (number, optional)
- `maxTokens` (number, optional)
diff --git a/extensions/llm-task/package.json b/extensions/llm-task/package.json
index bf63c9b28fc..577f6676b6d 100644
--- a/extensions/llm-task/package.json
+++ b/extensions/llm-task/package.json
@@ -1,6 +1,6 @@
{
"name": "@openclaw/llm-task",
- "version": "2026.3.11",
+ "version": "2026.3.12",
"private": true,
"description": "OpenClaw JSON-only LLM task plugin",
"type": "module",
diff --git a/extensions/llm-task/src/llm-task-tool.test.ts b/extensions/llm-task/src/llm-task-tool.test.ts
index fea135e8be5..fc9f0e07215 100644
--- a/extensions/llm-task/src/llm-task-tool.test.ts
+++ b/extensions/llm-task/src/llm-task-tool.test.ts
@@ -109,6 +109,59 @@ describe("llm-task tool (json-only)", () => {
expect(call.model).toBe("claude-4-sonnet");
});
+ it("passes thinking override to embedded runner", async () => {
+ // oxlint-disable-next-line typescript/no-explicit-any
+ (runEmbeddedPiAgent as any).mockResolvedValueOnce({
+ meta: {},
+ payloads: [{ text: JSON.stringify({ ok: true }) }],
+ });
+ const tool = createLlmTaskTool(fakeApi());
+ await tool.execute("id", { prompt: "x", thinking: "high" });
+ // oxlint-disable-next-line typescript/no-explicit-any
+ const call = (runEmbeddedPiAgent as any).mock.calls[0]?.[0];
+ expect(call.thinkLevel).toBe("high");
+ });
+
+ it("normalizes thinking aliases", async () => {
+ // oxlint-disable-next-line typescript/no-explicit-any
+ (runEmbeddedPiAgent as any).mockResolvedValueOnce({
+ meta: {},
+ payloads: [{ text: JSON.stringify({ ok: true }) }],
+ });
+ const tool = createLlmTaskTool(fakeApi());
+ await tool.execute("id", { prompt: "x", thinking: "on" });
+ // oxlint-disable-next-line typescript/no-explicit-any
+ const call = (runEmbeddedPiAgent as any).mock.calls[0]?.[0];
+ expect(call.thinkLevel).toBe("low");
+ });
+
+ it("throws on invalid thinking level", async () => {
+ const tool = createLlmTaskTool(fakeApi());
+ await expect(tool.execute("id", { prompt: "x", thinking: "banana" })).rejects.toThrow(
+ /invalid thinking level/i,
+ );
+ });
+
+ it("throws on unsupported xhigh thinking level", async () => {
+ const tool = createLlmTaskTool(fakeApi());
+ await expect(tool.execute("id", { prompt: "x", thinking: "xhigh" })).rejects.toThrow(
+ /only supported/i,
+ );
+ });
+
+ it("does not pass thinkLevel when thinking is omitted", async () => {
+ // oxlint-disable-next-line typescript/no-explicit-any
+ (runEmbeddedPiAgent as any).mockResolvedValueOnce({
+ meta: {},
+ payloads: [{ text: JSON.stringify({ ok: true }) }],
+ });
+ const tool = createLlmTaskTool(fakeApi());
+ await tool.execute("id", { prompt: "x" });
+ // oxlint-disable-next-line typescript/no-explicit-any
+ const call = (runEmbeddedPiAgent as any).mock.calls[0]?.[0];
+ expect(call.thinkLevel).toBeUndefined();
+ });
+
it("enforces allowedModels", async () => {
// oxlint-disable-next-line typescript/no-explicit-any
(runEmbeddedPiAgent as any).mockResolvedValueOnce({
diff --git a/extensions/llm-task/src/llm-task-tool.ts b/extensions/llm-task/src/llm-task-tool.ts
index 3a2e42c7223..ff2037e534a 100644
--- a/extensions/llm-task/src/llm-task-tool.ts
+++ b/extensions/llm-task/src/llm-task-tool.ts
@@ -2,7 +2,13 @@ import fs from "node:fs/promises";
import path from "node:path";
import { Type } from "@sinclair/typebox";
import Ajv from "ajv";
-import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/llm-task";
+import {
+ formatThinkingLevels,
+ formatXHighModelHint,
+ normalizeThinkLevel,
+ resolvePreferredOpenClawTmpDir,
+ supportsXHighThinking,
+} from "openclaw/plugin-sdk/llm-task";
// NOTE: This extension is intended to be bundled with OpenClaw.
// When running from source (tests/dev), OpenClaw internals live under src/.
// When running from a built install, internals live under dist/ (no src/ tree).
@@ -86,6 +92,7 @@ export function createLlmTaskTool(api: OpenClawPluginApi) {
Type.String({ description: "Provider override (e.g. openai-codex, anthropic)." }),
),
model: Type.Optional(Type.String({ description: "Model id override." })),
+ thinking: Type.Optional(Type.String({ description: "Thinking level override." })),
authProfileId: Type.Optional(Type.String({ description: "Auth profile override." })),
temperature: Type.Optional(Type.Number({ description: "Best-effort temperature override." })),
maxTokens: Type.Optional(Type.Number({ description: "Best-effort maxTokens override." })),
@@ -144,6 +151,18 @@ export function createLlmTaskTool(api: OpenClawPluginApi) {
);
}
+ const thinkingRaw =
+ typeof params.thinking === "string" && params.thinking.trim() ? params.thinking : undefined;
+ const thinkLevel = thinkingRaw ? normalizeThinkLevel(thinkingRaw) : undefined;
+ if (thinkingRaw && !thinkLevel) {
+ throw new Error(
+ `Invalid thinking level "${thinkingRaw}". Use one of: ${formatThinkingLevels(provider, model)}.`,
+ );
+ }
+ if (thinkLevel === "xhigh" && !supportsXHighThinking(provider, model)) {
+ throw new Error(`Thinking level "xhigh" is only supported for ${formatXHighModelHint()}.`);
+ }
+
const timeoutMs =
(typeof params.timeoutMs === "number" && params.timeoutMs > 0
? params.timeoutMs
@@ -204,6 +223,7 @@ export function createLlmTaskTool(api: OpenClawPluginApi) {
model,
authProfileId,
authProfileIdSource: authProfileId ? "user" : "auto",
+ thinkLevel,
streamParams,
disableTools: true,
});
diff --git a/extensions/lobster/package.json b/extensions/lobster/package.json
index c0c243b28c0..16ee2e3be03 100644
--- a/extensions/lobster/package.json
+++ b/extensions/lobster/package.json
@@ -1,6 +1,6 @@
{
"name": "@openclaw/lobster",
- "version": "2026.3.11",
+ "version": "2026.3.12",
"description": "Lobster workflow tool plugin (typed pipelines + resumable approvals)",
"type": "module",
"dependencies": {
diff --git a/extensions/matrix/CHANGELOG.md b/extensions/matrix/CHANGELOG.md
index 65f31b8445e..b991025a500 100644
--- a/extensions/matrix/CHANGELOG.md
+++ b/extensions/matrix/CHANGELOG.md
@@ -1,5 +1,11 @@
# Changelog
+## 2026.3.12
+
+### Changes
+
+- Version alignment with core OpenClaw release numbers.
+
## 2026.3.11
### Changes
diff --git a/extensions/matrix/package.json b/extensions/matrix/package.json
index 8a132a9edf5..1db00fbdea3 100644
--- a/extensions/matrix/package.json
+++ b/extensions/matrix/package.json
@@ -1,6 +1,6 @@
{
"name": "@openclaw/matrix",
- "version": "2026.3.11",
+ "version": "2026.3.12",
"description": "OpenClaw Matrix channel plugin",
"type": "module",
"dependencies": {
@@ -8,7 +8,7 @@
"@matrix-org/matrix-sdk-crypto-nodejs": "^0.4.0",
"@vector-im/matrix-bot-sdk": "0.8.0-element.3",
"markdown-it": "14.1.1",
- "music-metadata": "^11.12.1",
+ "music-metadata": "^11.12.3",
"zod": "^4.3.6"
},
"openclaw": {
diff --git a/extensions/mattermost/package.json b/extensions/mattermost/package.json
index e16e158545e..3d1222d3f43 100644
--- a/extensions/mattermost/package.json
+++ b/extensions/mattermost/package.json
@@ -1,6 +1,6 @@
{
"name": "@openclaw/mattermost",
- "version": "2026.3.11",
+ "version": "2026.3.12",
"description": "OpenClaw Mattermost channel plugin",
"type": "module",
"dependencies": {
diff --git a/extensions/mattermost/src/channel.test.ts b/extensions/mattermost/src/channel.test.ts
index c3ff193896f..c188a8e6719 100644
--- a/extensions/mattermost/src/channel.test.ts
+++ b/extensions/mattermost/src/channel.test.ts
@@ -65,6 +65,38 @@ describe("mattermostPlugin", () => {
});
});
+ describe("threading", () => {
+ it("uses replyToMode for channel messages and keeps direct messages off", () => {
+ const resolveReplyToMode = mattermostPlugin.threading?.resolveReplyToMode;
+ if (!resolveReplyToMode) {
+ return;
+ }
+
+ const cfg: OpenClawConfig = {
+ channels: {
+ mattermost: {
+ replyToMode: "all",
+ },
+ },
+ };
+
+ expect(
+ resolveReplyToMode({
+ cfg,
+ accountId: "default",
+ chatType: "channel",
+ }),
+ ).toBe("all");
+ expect(
+ resolveReplyToMode({
+ cfg,
+ accountId: "default",
+ chatType: "direct",
+ }),
+ ).toBe("off");
+ });
+ });
+
describe("messageActions", () => {
beforeEach(() => {
resetMattermostReactionBotUserCacheForTests();
diff --git a/extensions/mattermost/src/channel.ts b/extensions/mattermost/src/channel.ts
index 2dffaa6f3cf..f8116e127b3 100644
--- a/extensions/mattermost/src/channel.ts
+++ b/extensions/mattermost/src/channel.ts
@@ -14,6 +14,8 @@ import {
deleteAccountFromConfigSection,
migrateBaseNameToDefaultAccount,
normalizeAccountId,
+ resolveAllowlistProviderRuntimeGroupPolicy,
+ resolveDefaultGroupPolicy,
setAccountEnabledInConfigSection,
type ChannelMessageActionAdapter,
type ChannelMessageActionName,
@@ -25,6 +27,7 @@ import {
listMattermostAccountIds,
resolveDefaultMattermostAccountId,
resolveMattermostAccount,
+ resolveMattermostReplyToMode,
type ResolvedMattermostAccount,
} from "./mattermost/accounts.js";
import { normalizeMattermostBaseUrl } from "./mattermost/client.js";
@@ -270,6 +273,16 @@ export const mattermostPlugin: ChannelPlugin = {
streaming: {
blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 },
},
+ threading: {
+ resolveReplyToMode: ({ cfg, accountId, chatType }) => {
+ const account = resolveMattermostAccount({ cfg, accountId: accountId ?? "default" });
+ const kind =
+ chatType === "direct" || chatType === "group" || chatType === "channel"
+ ? chatType
+ : "channel";
+ return resolveMattermostReplyToMode(account, kind);
+ },
+ },
reload: { configPrefixes: ["channels.mattermost"] },
configSchema: buildChannelConfigSchema(MattermostConfigSchema),
config: {
diff --git a/extensions/mattermost/src/config-schema.test.ts b/extensions/mattermost/src/config-schema.test.ts
index c744a6a5e0f..aa8db0f5d02 100644
--- a/extensions/mattermost/src/config-schema.test.ts
+++ b/extensions/mattermost/src/config-schema.test.ts
@@ -1,7 +1,7 @@
import { describe, expect, it } from "vitest";
import { MattermostConfigSchema } from "./config-schema.js";
-describe("MattermostConfigSchema SecretInput", () => {
+describe("MattermostConfigSchema", () => {
it("accepts SecretRef botToken at top-level", () => {
const result = MattermostConfigSchema.safeParse({
botToken: { source: "env", provider: "default", id: "MATTERMOST_BOT_TOKEN" },
@@ -21,4 +21,29 @@ describe("MattermostConfigSchema SecretInput", () => {
});
expect(result.success).toBe(true);
});
+
+ it("accepts replyToMode", () => {
+ const result = MattermostConfigSchema.safeParse({
+ replyToMode: "all",
+ });
+ expect(result.success).toBe(true);
+ });
+
+ it("rejects unsupported direct-message reply threading config", () => {
+ const result = MattermostConfigSchema.safeParse({
+ dm: {
+ replyToMode: "all",
+ },
+ });
+ expect(result.success).toBe(false);
+ });
+
+ it("rejects unsupported per-chat-type reply threading config", () => {
+ const result = MattermostConfigSchema.safeParse({
+ replyToModeByChatType: {
+ direct: "all",
+ },
+ });
+ expect(result.success).toBe(false);
+ });
});
diff --git a/extensions/mattermost/src/config-schema.ts b/extensions/mattermost/src/config-schema.ts
index 51d9bdbe33a..43dd7ede8d2 100644
--- a/extensions/mattermost/src/config-schema.ts
+++ b/extensions/mattermost/src/config-schema.ts
@@ -43,6 +43,7 @@ const MattermostAccountSchemaBase = z
chunkMode: z.enum(["length", "newline"]).optional(),
blockStreaming: z.boolean().optional(),
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
+ replyToMode: z.enum(["off", "first", "all"]).optional(),
responsePrefix: z.string().optional(),
actions: z
.object({
diff --git a/extensions/mattermost/src/mattermost/accounts.test.ts b/extensions/mattermost/src/mattermost/accounts.test.ts
index b3ad8d49e04..0e01d362520 100644
--- a/extensions/mattermost/src/mattermost/accounts.test.ts
+++ b/extensions/mattermost/src/mattermost/accounts.test.ts
@@ -1,6 +1,10 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/mattermost";
import { describe, expect, it } from "vitest";
-import { resolveDefaultMattermostAccountId } from "./accounts.js";
+import {
+ resolveDefaultMattermostAccountId,
+ resolveMattermostAccount,
+ resolveMattermostReplyToMode,
+} from "./accounts.js";
describe("resolveDefaultMattermostAccountId", () => {
it("prefers channels.mattermost.defaultAccount when it matches a configured account", () => {
@@ -50,3 +54,37 @@ describe("resolveDefaultMattermostAccountId", () => {
expect(resolveDefaultMattermostAccountId(cfg)).toBe("default");
});
});
+
+describe("resolveMattermostReplyToMode", () => {
+ it("uses the configured mode for channel and group messages", () => {
+ const cfg: OpenClawConfig = {
+ channels: {
+ mattermost: {
+ replyToMode: "all",
+ },
+ },
+ };
+
+ const account = resolveMattermostAccount({ cfg, accountId: "default" });
+ expect(resolveMattermostReplyToMode(account, "channel")).toBe("all");
+ expect(resolveMattermostReplyToMode(account, "group")).toBe("all");
+ });
+
+ it("keeps direct messages off even when replyToMode is enabled", () => {
+ const cfg: OpenClawConfig = {
+ channels: {
+ mattermost: {
+ replyToMode: "all",
+ },
+ },
+ };
+
+ const account = resolveMattermostAccount({ cfg, accountId: "default" });
+ expect(resolveMattermostReplyToMode(account, "direct")).toBe("off");
+ });
+
+ it("defaults to off when replyToMode is unset", () => {
+ const account = resolveMattermostAccount({ cfg: {}, accountId: "default" });
+ expect(resolveMattermostReplyToMode(account, "channel")).toBe("off");
+ });
+});
diff --git a/extensions/mattermost/src/mattermost/accounts.ts b/extensions/mattermost/src/mattermost/accounts.ts
index 1de9a09bca8..ae154ba8923 100644
--- a/extensions/mattermost/src/mattermost/accounts.ts
+++ b/extensions/mattermost/src/mattermost/accounts.ts
@@ -1,7 +1,12 @@
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
import { createAccountListHelpers, type OpenClawConfig } from "openclaw/plugin-sdk/mattermost";
import { normalizeResolvedSecretInputString, normalizeSecretInputString } from "../secret-input.js";
-import type { MattermostAccountConfig, MattermostChatMode } from "../types.js";
+import type {
+ MattermostAccountConfig,
+ MattermostChatMode,
+ MattermostChatTypeKey,
+ MattermostReplyToMode,
+} from "../types.js";
import { normalizeMattermostBaseUrl } from "./client.js";
export type MattermostTokenSource = "env" | "config" | "none";
@@ -130,6 +135,20 @@ export function resolveMattermostAccount(params: {
};
}
+/**
+ * Resolve the effective replyToMode for a given chat type.
+ * Mattermost auto-threading only applies to channel and group messages.
+ */
+export function resolveMattermostReplyToMode(
+ account: ResolvedMattermostAccount,
+ kind: MattermostChatTypeKey,
+): MattermostReplyToMode {
+ if (kind === "direct") {
+ return "off";
+ }
+ return account.config.replyToMode ?? "off";
+}
+
export function listEnabledMattermostAccounts(cfg: OpenClawConfig): ResolvedMattermostAccount[] {
return listMattermostAccountIds(cfg)
.map((accountId) => resolveMattermostAccount({ cfg, accountId }))
diff --git a/extensions/mattermost/src/mattermost/interactions.test.ts b/extensions/mattermost/src/mattermost/interactions.test.ts
index a6379a52664..3f52982cc52 100644
--- a/extensions/mattermost/src/mattermost/interactions.test.ts
+++ b/extensions/mattermost/src/mattermost/interactions.test.ts
@@ -2,7 +2,7 @@ import { type IncomingMessage, type ServerResponse } from "node:http";
import { describe, expect, it, beforeEach, afterEach, vi } from "vitest";
import { setMattermostRuntime } from "../runtime.js";
import { resolveMattermostAccount } from "./accounts.js";
-import type { MattermostClient } from "./client.js";
+import type { MattermostClient, MattermostPost } from "./client.js";
import {
buildButtonAttachments,
computeInteractionCallbackUrl,
@@ -738,6 +738,70 @@ describe("createMattermostInteractionHandler", () => {
]);
});
+ it("forwards fetched post threading metadata to session and button callbacks", async () => {
+ const enqueueSystemEvent = vi.fn();
+ setMattermostRuntime({
+ system: {
+ enqueueSystemEvent,
+ },
+ } as unknown as Parameters[0]);
+ const context = { action_id: "approve", __openclaw_channel_id: "chan-1" };
+ const token = generateInteractionToken(context, "acct");
+ const resolveSessionKey = vi.fn().mockResolvedValue("session:thread:root-9");
+ const dispatchButtonClick = vi.fn();
+ const fetchedPost: MattermostPost = {
+ id: "post-1",
+ channel_id: "chan-1",
+ root_id: "root-9",
+ message: "Choose",
+ props: {
+ attachments: [{ actions: [{ id: "approve", name: "Approve" }] }],
+ },
+ };
+ const handler = createMattermostInteractionHandler({
+ client: {
+ request: async (_path: string, init?: { method?: string }) =>
+ init?.method === "PUT" ? { id: "post-1" } : fetchedPost,
+ } as unknown as MattermostClient,
+ botUserId: "bot",
+ accountId: "acct",
+ resolveSessionKey,
+ dispatchButtonClick,
+ });
+
+ const req = createReq({
+ body: {
+ user_id: "user-1",
+ user_name: "alice",
+ channel_id: "chan-1",
+ post_id: "post-1",
+ context: { ...context, _token: token },
+ },
+ });
+ const res = createRes();
+
+ await handler(req, res);
+
+ expect(res.statusCode).toBe(200);
+ expect(resolveSessionKey).toHaveBeenCalledWith({
+ channelId: "chan-1",
+ userId: "user-1",
+ post: fetchedPost,
+ });
+ expect(enqueueSystemEvent).toHaveBeenCalledWith(
+ expect.stringContaining('Mattermost button click: action="approve"'),
+ expect.objectContaining({ sessionKey: "session:thread:root-9" }),
+ );
+ expect(dispatchButtonClick).toHaveBeenCalledWith(
+ expect.objectContaining({
+ channelId: "chan-1",
+ userId: "user-1",
+ postId: "post-1",
+ post: fetchedPost,
+ }),
+ );
+ });
+
it("lets a custom interaction handler short-circuit generic completion updates", async () => {
const context = { action_id: "mdlprov", __openclaw_channel_id: "chan-1" };
const token = generateInteractionToken(context, "acct");
@@ -751,6 +815,7 @@ describe("createMattermostInteractionHandler", () => {
request: async (path: string, init?: { method?: string }) => {
requestLog.push({ path, method: init?.method });
return {
+ id: "post-1",
channel_id: "chan-1",
message: "Choose",
props: {
@@ -790,6 +855,7 @@ describe("createMattermostInteractionHandler", () => {
actionId: "mdlprov",
actionName: "Browse providers",
originalMessage: "Choose",
+ post: expect.objectContaining({ id: "post-1" }),
userName: "alice",
}),
);
diff --git a/extensions/mattermost/src/mattermost/interactions.ts b/extensions/mattermost/src/mattermost/interactions.ts
index 9e888d658cb..f99d0b5d3ac 100644
--- a/extensions/mattermost/src/mattermost/interactions.ts
+++ b/extensions/mattermost/src/mattermost/interactions.ts
@@ -6,7 +6,7 @@ import {
type OpenClawConfig,
} from "openclaw/plugin-sdk/mattermost";
import { getMattermostRuntime } from "../runtime.js";
-import { updateMattermostPost, type MattermostClient } from "./client.js";
+import { updateMattermostPost, type MattermostClient, type MattermostPost } from "./client.js";
const INTERACTION_MAX_BODY_BYTES = 64 * 1024;
const INTERACTION_BODY_TIMEOUT_MS = 10_000;
@@ -390,7 +390,11 @@ export function createMattermostInteractionHandler(params: {
allowedSourceIps?: string[];
trustedProxies?: string[];
allowRealIpFallback?: boolean;
- resolveSessionKey?: (channelId: string, userId: string) => Promise;
+ resolveSessionKey?: (params: {
+ channelId: string;
+ userId: string;
+ post: MattermostPost;
+ }) => Promise;
handleInteraction?: (opts: {
payload: MattermostInteractionPayload;
userName: string;
@@ -398,6 +402,7 @@ export function createMattermostInteractionHandler(params: {
actionName: string;
originalMessage: string;
context: Record;
+ post: MattermostPost;
}) => Promise;
dispatchButtonClick?: (opts: {
channelId: string;
@@ -406,6 +411,7 @@ export function createMattermostInteractionHandler(params: {
actionId: string;
actionName: string;
postId: string;
+ post: MattermostPost;
}) => Promise;
log?: (message: string) => void;
}): (req: IncomingMessage, res: ServerResponse) => Promise {
@@ -503,13 +509,10 @@ export function createMattermostInteractionHandler(params: {
const userName = payload.user_name ?? payload.user_id;
let originalMessage = "";
+ let originalPost: MattermostPost | null = null;
let clickedButtonName: string | null = null;
try {
- const originalPost = await client.request<{
- channel_id?: string | null;
- message?: string;
- props?: Record;
- }>(`/posts/${payload.post_id}`);
+ originalPost = await client.request(`/posts/${payload.post_id}`);
const postChannelId = originalPost.channel_id?.trim();
if (!postChannelId || postChannelId !== payload.channel_id) {
log?.(
@@ -550,6 +553,14 @@ export function createMattermostInteractionHandler(params: {
return;
}
+ if (!originalPost) {
+ log?.(`mattermost interaction: missing fetched post ${payload.post_id}`);
+ res.statusCode = 500;
+ res.setHeader("Content-Type", "application/json");
+ res.end(JSON.stringify({ error: "Failed to load interaction post" }));
+ return;
+ }
+
log?.(
`mattermost interaction: action=${actionId} user=${payload.user_name ?? payload.user_id} ` +
`post=${payload.post_id} channel=${payload.channel_id}`,
@@ -564,6 +575,7 @@ export function createMattermostInteractionHandler(params: {
actionName: clickedButtonName,
originalMessage,
context: contextWithoutToken,
+ post: originalPost,
});
if (response !== null) {
res.statusCode = 200;
@@ -590,7 +602,11 @@ export function createMattermostInteractionHandler(params: {
`in channel ${payload.channel_id}`;
const sessionKey = params.resolveSessionKey
- ? await params.resolveSessionKey(payload.channel_id, payload.user_id)
+ ? await params.resolveSessionKey({
+ channelId: payload.channel_id,
+ userId: payload.user_id,
+ post: originalPost,
+ })
: `agent:main:mattermost:${accountId}:${payload.channel_id}`;
core.system.enqueueSystemEvent(eventLabel, {
@@ -632,6 +648,7 @@ export function createMattermostInteractionHandler(params: {
actionId,
actionName: clickedButtonName,
postId: payload.post_id,
+ post: originalPost,
});
} catch (err) {
log?.(`mattermost interaction: dispatchButtonClick failed: ${String(err)}`);
diff --git a/extensions/mattermost/src/mattermost/monitor.test.ts b/extensions/mattermost/src/mattermost/monitor.test.ts
index 1bd871714c4..ab993dbb2af 100644
--- a/extensions/mattermost/src/mattermost/monitor.test.ts
+++ b/extensions/mattermost/src/mattermost/monitor.test.ts
@@ -3,7 +3,9 @@ import { describe, expect, it, vi } from "vitest";
import { resolveMattermostAccount } from "./accounts.js";
import {
evaluateMattermostMentionGate,
+ resolveMattermostEffectiveReplyToId,
resolveMattermostReplyRootId,
+ resolveMattermostThreadSessionContext,
type MattermostMentionGateInput,
type MattermostRequireMentionResolverInput,
} from "./monitor.js";
@@ -109,6 +111,29 @@ describe("mattermost mention gating", () => {
});
});
+describe("resolveMattermostReplyRootId with block streaming payloads", () => {
+ it("uses threadRootId for block-streamed payloads with replyToId", () => {
+ // When block streaming sends a payload with replyToId from the threading
+ // mode, the deliver callback should still use the existing threadRootId.
+ expect(
+ resolveMattermostReplyRootId({
+ threadRootId: "thread-root-1",
+ replyToId: "streamed-reply-id",
+ }),
+ ).toBe("thread-root-1");
+ });
+
+ it("falls back to payload replyToId when no threadRootId in block streaming", () => {
+ // Top-level channel message: no threadRootId, payload carries the
+ // inbound post id as replyToId from the "all" threading mode.
+ expect(
+ resolveMattermostReplyRootId({
+ replyToId: "inbound-post-for-threading",
+ }),
+ ).toBe("inbound-post-for-threading");
+ });
+});
+
describe("resolveMattermostReplyRootId", () => {
it("uses replyToId for top-level replies", () => {
expect(
@@ -131,3 +156,94 @@ describe("resolveMattermostReplyRootId", () => {
expect(resolveMattermostReplyRootId({})).toBeUndefined();
});
});
+
+describe("resolveMattermostEffectiveReplyToId", () => {
+ it("keeps an existing thread root", () => {
+ expect(
+ resolveMattermostEffectiveReplyToId({
+ kind: "channel",
+ postId: "post-123",
+ replyToMode: "all",
+ threadRootId: "thread-root-456",
+ }),
+ ).toBe("thread-root-456");
+ });
+
+ it("starts a thread for top-level channel messages when replyToMode is all", () => {
+ expect(
+ resolveMattermostEffectiveReplyToId({
+ kind: "channel",
+ postId: "post-123",
+ replyToMode: "all",
+ }),
+ ).toBe("post-123");
+ });
+
+ it("starts a thread for top-level group messages when replyToMode is first", () => {
+ expect(
+ resolveMattermostEffectiveReplyToId({
+ kind: "group",
+ postId: "post-123",
+ replyToMode: "first",
+ }),
+ ).toBe("post-123");
+ });
+
+ it("keeps direct messages non-threaded", () => {
+ expect(
+ resolveMattermostEffectiveReplyToId({
+ kind: "direct",
+ postId: "post-123",
+ replyToMode: "all",
+ }),
+ ).toBeUndefined();
+ });
+});
+
+describe("resolveMattermostThreadSessionContext", () => {
+ it("forks channel sessions by top-level post when replyToMode is all", () => {
+ expect(
+ resolveMattermostThreadSessionContext({
+ baseSessionKey: "agent:main:mattermost:default:chan-1",
+ kind: "channel",
+ postId: "post-123",
+ replyToMode: "all",
+ }),
+ ).toEqual({
+ effectiveReplyToId: "post-123",
+ sessionKey: "agent:main:mattermost:default:chan-1:thread:post-123",
+ parentSessionKey: "agent:main:mattermost:default:chan-1",
+ });
+ });
+
+ it("keeps existing thread roots for threaded follow-ups", () => {
+ expect(
+ resolveMattermostThreadSessionContext({
+ baseSessionKey: "agent:main:mattermost:default:chan-1",
+ kind: "group",
+ postId: "post-123",
+ replyToMode: "first",
+ threadRootId: "root-456",
+ }),
+ ).toEqual({
+ effectiveReplyToId: "root-456",
+ sessionKey: "agent:main:mattermost:default:chan-1:thread:root-456",
+ parentSessionKey: "agent:main:mattermost:default:chan-1",
+ });
+ });
+
+ it("keeps direct-message sessions linear", () => {
+ expect(
+ resolveMattermostThreadSessionContext({
+ baseSessionKey: "agent:main:mattermost:default:user-1",
+ kind: "direct",
+ postId: "post-123",
+ replyToMode: "all",
+ }),
+ ).toEqual({
+ effectiveReplyToId: undefined,
+ sessionKey: "agent:main:mattermost:default:user-1",
+ parentSessionKey: undefined,
+ });
+ });
+});
diff --git a/extensions/mattermost/src/mattermost/monitor.ts b/extensions/mattermost/src/mattermost/monitor.ts
index 59bc6b39aee..16e3bd6434a 100644
--- a/extensions/mattermost/src/mattermost/monitor.ts
+++ b/extensions/mattermost/src/mattermost/monitor.ts
@@ -32,7 +32,7 @@ import {
type HistoryEntry,
} from "openclaw/plugin-sdk/mattermost";
import { getMattermostRuntime } from "../runtime.js";
-import { resolveMattermostAccount } from "./accounts.js";
+import { resolveMattermostAccount, resolveMattermostReplyToMode } from "./accounts.js";
import {
createMattermostClient,
fetchMattermostChannel,
@@ -80,6 +80,7 @@ import {
type MattermostWebSocketFactory,
} from "./monitor-websocket.js";
import { runWithReconnect } from "./reconnect.js";
+import { deliverMattermostReplyPayload } from "./reply-delivery.js";
import { sendMessageMattermost } from "./send.js";
import {
DEFAULT_COMMAND_SPECS,
@@ -274,6 +275,51 @@ export function resolveMattermostReplyRootId(params: {
}
return params.replyToId?.trim() || undefined;
}
+
+export function resolveMattermostEffectiveReplyToId(params: {
+ kind: ChatType;
+ postId?: string | null;
+ replyToMode: "off" | "first" | "all";
+ threadRootId?: string | null;
+}): string | undefined {
+ const threadRootId = params.threadRootId?.trim();
+ if (threadRootId) {
+ return threadRootId;
+ }
+ if (params.kind === "direct") {
+ return undefined;
+ }
+ const postId = params.postId?.trim();
+ if (!postId) {
+ return undefined;
+ }
+ return params.replyToMode === "all" || params.replyToMode === "first" ? postId : undefined;
+}
+
+export function resolveMattermostThreadSessionContext(params: {
+ baseSessionKey: string;
+ kind: ChatType;
+ postId?: string | null;
+ replyToMode: "off" | "first" | "all";
+ threadRootId?: string | null;
+}): { effectiveReplyToId?: string; sessionKey: string; parentSessionKey?: string } {
+ const effectiveReplyToId = resolveMattermostEffectiveReplyToId({
+ kind: params.kind,
+ postId: params.postId,
+ replyToMode: params.replyToMode,
+ threadRootId: params.threadRootId,
+ });
+ const threadKeys = resolveThreadSessionKeys({
+ baseSessionKey: params.baseSessionKey,
+ threadId: effectiveReplyToId,
+ parentSessionKey: effectiveReplyToId ? params.baseSessionKey : undefined,
+ });
+ return {
+ effectiveReplyToId,
+ sessionKey: threadKeys.sessionKey,
+ parentSessionKey: threadKeys.parentSessionKey,
+ };
+}
type MattermostMediaInfo = {
path: string;
contentType?: string;
@@ -521,7 +567,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
trustedProxies: cfg.gateway?.trustedProxies,
allowRealIpFallback: cfg.gateway?.allowRealIpFallback === true,
handleInteraction: handleModelPickerInteraction,
- resolveSessionKey: async (channelId: string, userId: string) => {
+ resolveSessionKey: async ({ channelId, userId, post }) => {
const channelInfo = await resolveChannelInfo(channelId);
const kind = mapMattermostChannelTypeToChatType(channelInfo?.type);
const teamId = channelInfo?.team_id ?? undefined;
@@ -535,7 +581,14 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
id: kind === "direct" ? userId : channelId,
},
});
- return route.sessionKey;
+ const replyToMode = resolveMattermostReplyToMode(account, kind);
+ return resolveMattermostThreadSessionContext({
+ baseSessionKey: route.sessionKey,
+ kind,
+ postId: post.id || undefined,
+ replyToMode,
+ threadRootId: post.root_id,
+ }).sessionKey;
},
dispatchButtonClick: async (opts) => {
const channelInfo = await resolveChannelInfo(opts.channelId);
@@ -554,6 +607,14 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
id: kind === "direct" ? opts.userId : opts.channelId,
},
});
+ const replyToMode = resolveMattermostReplyToMode(account, kind);
+ const threadContext = resolveMattermostThreadSessionContext({
+ baseSessionKey: route.sessionKey,
+ kind,
+ postId: opts.post.id || opts.postId,
+ replyToMode,
+ threadRootId: opts.post.root_id,
+ });
const to = kind === "direct" ? `user:${opts.userId}` : `channel:${opts.channelId}`;
const bodyText = `[Button click: user @${opts.userName} selected "${opts.actionName}"]`;
const ctxPayload = core.channel.reply.finalizeInboundContext({
@@ -568,7 +629,8 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
? `mattermost:group:${opts.channelId}`
: `mattermost:channel:${opts.channelId}`,
To: to,
- SessionKey: route.sessionKey,
+ SessionKey: threadContext.sessionKey,
+ ParentSessionKey: threadContext.parentSessionKey,
AccountId: route.accountId,
ChatType: chatType,
ConversationLabel: `mattermost:${opts.userName}`,
@@ -580,6 +642,8 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
Provider: "mattermost" as const,
Surface: "mattermost" as const,
MessageSid: `interaction:${opts.postId}:${opts.actionId}`,
+ ReplyToId: threadContext.effectiveReplyToId,
+ MessageThreadId: threadContext.effectiveReplyToId,
WasMentioned: true,
CommandAuthorized: false,
OriginatingChannel: "mattermost" as const,
@@ -604,7 +668,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
accountId: account.accountId,
});
const typingCallbacks = createTypingCallbacks({
- start: () => sendTypingIndicator(opts.channelId),
+ start: () => sendTypingIndicator(opts.channelId, threadContext.effectiveReplyToId),
onStartError: (err) => {
logTypingFailure({
log: (message) => logger.debug?.(message),
@@ -619,36 +683,21 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
...prefixOptions,
humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, route.agentId),
deliver: async (payload: ReplyPayload) => {
- const mediaUrls = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
- const text = core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode);
- if (mediaUrls.length === 0) {
- const chunkMode = core.channel.text.resolveChunkMode(
- cfg,
- "mattermost",
- account.accountId,
- );
- const chunks = core.channel.text.chunkMarkdownTextWithMode(
- text,
- textLimit,
- chunkMode,
- );
- for (const chunk of chunks.length > 0 ? chunks : [text]) {
- if (!chunk) continue;
- await sendMessageMattermost(to, chunk, {
- accountId: account.accountId,
- });
- }
- } else {
- let first = true;
- for (const mediaUrl of mediaUrls) {
- const caption = first ? text : "";
- first = false;
- await sendMessageMattermost(to, caption, {
- accountId: account.accountId,
- mediaUrl,
- });
- }
- }
+ await deliverMattermostReplyPayload({
+ core,
+ cfg,
+ payload,
+ to,
+ accountId: account.accountId,
+ agentId: route.agentId,
+ replyToId: resolveMattermostReplyRootId({
+ threadRootId: threadContext.effectiveReplyToId,
+ replyToId: payload.replyToId,
+ }),
+ textLimit,
+ tableMode,
+ sendMessage: sendMessageMattermost,
+ });
runtime.log?.(`delivered button-click reply to ${to}`);
},
onError: (err, info) => {
@@ -834,6 +883,8 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
commandText: string;
commandAuthorized: boolean;
route: ReturnType;
+ sessionKey: string;
+ parentSessionKey?: string;
channelId: string;
senderId: string;
senderName: string;
@@ -844,6 +895,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
roomLabel: string;
teamId?: string;
postId: string;
+ effectiveReplyToId?: string;
deliverReplies?: boolean;
}): Promise => {
const to = params.kind === "direct" ? `user:${params.senderId}` : `channel:${params.channelId}`;
@@ -863,7 +915,8 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
? `mattermost:group:${params.channelId}`
: `mattermost:channel:${params.channelId}`,
To: to,
- SessionKey: params.route.sessionKey,
+ SessionKey: params.sessionKey,
+ ParentSessionKey: params.parentSessionKey,
AccountId: params.route.accountId,
ChatType: params.chatType,
ConversationLabel: fromLabel,
@@ -876,6 +929,8 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
Provider: "mattermost" as const,
Surface: "mattermost" as const,
MessageSid: `interaction:${params.postId}:${Date.now()}`,
+ ReplyToId: params.effectiveReplyToId,
+ MessageThreadId: params.effectiveReplyToId,
Timestamp: Date.now(),
WasMentioned: true,
CommandAuthorized: params.commandAuthorized,
@@ -907,7 +962,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
const capturedTexts: string[] = [];
const typingCallbacks = shouldDeliverReplies
? createTypingCallbacks({
- start: () => sendTypingIndicator(params.channelId),
+ start: () => sendTypingIndicator(params.channelId, params.effectiveReplyToId),
onStartError: (err) => {
logTypingFailure({
log: (message) => logger.debug?.(message),
@@ -923,45 +978,34 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
...prefixOptions,
// Picker-triggered confirmations should stay immediate.
deliver: async (payload: ReplyPayload) => {
- const mediaUrls = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
- const text = core.channel.text
- .convertMarkdownTables(payload.text ?? "", tableMode)
- .trim();
+ const trimmedPayload = {
+ ...payload,
+ text: core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode).trim(),
+ };
if (!shouldDeliverReplies) {
- if (text) {
- capturedTexts.push(text);
+ if (trimmedPayload.text) {
+ capturedTexts.push(trimmedPayload.text);
}
return;
}
- if (mediaUrls.length === 0) {
- const chunkMode = core.channel.text.resolveChunkMode(
- cfg,
- "mattermost",
- account.accountId,
- );
- const chunks = core.channel.text.chunkMarkdownTextWithMode(text, textLimit, chunkMode);
- for (const chunk of chunks.length > 0 ? chunks : [text]) {
- if (!chunk) {
- continue;
- }
- await sendMessageMattermost(to, chunk, {
- accountId: account.accountId,
- });
- }
- return;
- }
-
- let first = true;
- for (const mediaUrl of mediaUrls) {
- const caption = first ? text : "";
- first = false;
- await sendMessageMattermost(to, caption, {
- accountId: account.accountId,
- mediaUrl,
- });
- }
+ await deliverMattermostReplyPayload({
+ core,
+ cfg,
+ payload: trimmedPayload,
+ to,
+ accountId: account.accountId,
+ agentId: params.route.agentId,
+ replyToId: resolveMattermostReplyRootId({
+ threadRootId: params.effectiveReplyToId,
+ replyToId: trimmedPayload.replyToId,
+ }),
+ textLimit,
+ // The picker path already converts and trims text before capture/delivery.
+ tableMode: "off",
+ sendMessage: sendMessageMattermost,
+ });
},
onError: (err, info) => {
runtime.error?.(`mattermost model picker ${info.kind} reply failed: ${String(err)}`);
@@ -1000,6 +1044,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
};
userName: string;
context: Record;
+ post: MattermostPost;
}): Promise {
const pickerState = parseMattermostModelPickerContext(params.context);
if (!pickerState) {
@@ -1088,6 +1133,18 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
id: kind === "direct" ? params.payload.user_id : params.payload.channel_id,
},
});
+ const replyToMode = resolveMattermostReplyToMode(account, kind);
+ const threadContext = resolveMattermostThreadSessionContext({
+ baseSessionKey: route.sessionKey,
+ kind,
+ postId: params.post.id || params.payload.post_id,
+ replyToMode,
+ threadRootId: params.post.root_id,
+ });
+ const modelSessionRoute = {
+ agentId: route.agentId,
+ sessionKey: threadContext.sessionKey,
+ };
const data = await buildModelsProviderData(cfg, route.agentId);
if (data.providers.length === 0) {
@@ -1101,7 +1158,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
if (pickerState.action === "providers" || pickerState.action === "back") {
const currentModel = resolveMattermostModelPickerCurrentModel({
cfg,
- route,
+ route: modelSessionRoute,
data,
});
const view = renderMattermostProviderPickerView({
@@ -1120,7 +1177,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
if (pickerState.action === "list") {
const currentModel = resolveMattermostModelPickerCurrentModel({
cfg,
- route,
+ route: modelSessionRoute,
data,
});
const view = renderMattermostModelsPickerView({
@@ -1151,6 +1208,8 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
commandText: `/model ${targetModelRef}`,
commandAuthorized: auth.commandAuthorized,
route,
+ sessionKey: threadContext.sessionKey,
+ parentSessionKey: threadContext.parentSessionKey,
channelId: params.payload.channel_id,
senderId: params.payload.user_id,
senderName: params.userName,
@@ -1161,11 +1220,12 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
roomLabel,
teamId,
postId: params.payload.post_id,
+ effectiveReplyToId: threadContext.effectiveReplyToId,
deliverReplies: true,
});
const updatedModel = resolveMattermostModelPickerCurrentModel({
cfg,
- route,
+ route: modelSessionRoute,
data,
skipCache: true,
});
@@ -1385,12 +1445,15 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
const baseSessionKey = route.sessionKey;
const threadRootId = post.root_id?.trim() || undefined;
- const threadKeys = resolveThreadSessionKeys({
+ const replyToMode = resolveMattermostReplyToMode(account, kind);
+ const threadContext = resolveMattermostThreadSessionContext({
baseSessionKey,
- threadId: threadRootId,
- parentSessionKey: threadRootId ? baseSessionKey : undefined,
+ kind,
+ postId: post.id,
+ replyToMode,
+ threadRootId,
});
- const sessionKey = threadKeys.sessionKey;
+ const { effectiveReplyToId, sessionKey, parentSessionKey } = threadContext;
const historyKey = kind === "direct" ? null : sessionKey;
const mentionRegexes = core.channel.mentions.buildMentionRegexes(cfg, route.agentId);
@@ -1554,7 +1617,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
: `mattermost:channel:${channelId}`,
To: to,
SessionKey: sessionKey,
- ParentSessionKey: threadKeys.parentSessionKey,
+ ParentSessionKey: parentSessionKey,
AccountId: route.accountId,
ChatType: chatType,
ConversationLabel: fromLabel,
@@ -1570,8 +1633,8 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
MessageSidFirst: allMessageIds.length > 1 ? allMessageIds[0] : undefined,
MessageSidLast:
allMessageIds.length > 1 ? allMessageIds[allMessageIds.length - 1] : undefined,
- ReplyToId: threadRootId,
- MessageThreadId: threadRootId,
+ ReplyToId: effectiveReplyToId,
+ MessageThreadId: effectiveReplyToId,
Timestamp: typeof post.create_at === "number" ? post.create_at : undefined,
WasMentioned: kind !== "direct" ? mentionDecision.effectiveWasMentioned : undefined,
CommandAuthorized: commandAuthorized,
@@ -1623,7 +1686,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
});
const typingCallbacks = createTypingCallbacks({
- start: () => sendTypingIndicator(channelId, threadRootId),
+ start: () => sendTypingIndicator(channelId, effectiveReplyToId),
onStartError: (err) => {
logTypingFailure({
log: (message) => logger.debug?.(message),
@@ -1639,42 +1702,21 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, route.agentId),
typingCallbacks,
deliver: async (payload: ReplyPayload) => {
- const mediaUrls = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
- const text = core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode);
- if (mediaUrls.length === 0) {
- const chunkMode = core.channel.text.resolveChunkMode(
- cfg,
- "mattermost",
- account.accountId,
- );
- const chunks = core.channel.text.chunkMarkdownTextWithMode(text, textLimit, chunkMode);
- for (const chunk of chunks.length > 0 ? chunks : [text]) {
- if (!chunk) {
- continue;
- }
- await sendMessageMattermost(to, chunk, {
- accountId: account.accountId,
- replyToId: resolveMattermostReplyRootId({
- threadRootId,
- replyToId: payload.replyToId,
- }),
- });
- }
- } else {
- let first = true;
- for (const mediaUrl of mediaUrls) {
- const caption = first ? text : "";
- first = false;
- await sendMessageMattermost(to, caption, {
- accountId: account.accountId,
- mediaUrl,
- replyToId: resolveMattermostReplyRootId({
- threadRootId,
- replyToId: payload.replyToId,
- }),
- });
- }
- }
+ await deliverMattermostReplyPayload({
+ core,
+ cfg,
+ payload,
+ to,
+ accountId: account.accountId,
+ agentId: route.agentId,
+ replyToId: resolveMattermostReplyRootId({
+ threadRootId: effectiveReplyToId,
+ replyToId: payload.replyToId,
+ }),
+ textLimit,
+ tableMode,
+ sendMessage: sendMessageMattermost,
+ });
runtime.log?.(`delivered reply to ${to}`);
},
onError: (err, info) => {
diff --git a/extensions/mattermost/src/mattermost/reply-delivery.test.ts b/extensions/mattermost/src/mattermost/reply-delivery.test.ts
new file mode 100644
index 00000000000..7d48e5fcfc0
--- /dev/null
+++ b/extensions/mattermost/src/mattermost/reply-delivery.test.ts
@@ -0,0 +1,95 @@
+import fs from "node:fs/promises";
+import os from "node:os";
+import path from "node:path";
+import type { OpenClawConfig } from "openclaw/plugin-sdk/mattermost";
+import { describe, expect, it, vi } from "vitest";
+import { deliverMattermostReplyPayload } from "./reply-delivery.js";
+
+describe("deliverMattermostReplyPayload", () => {
+ it("passes agent-scoped mediaLocalRoots when sending media paths", async () => {
+ const previousStateDir = process.env.OPENCLAW_STATE_DIR;
+ const stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-mm-state-"));
+ process.env.OPENCLAW_STATE_DIR = stateDir;
+
+ try {
+ const sendMessage = vi.fn(async () => undefined);
+ const core = {
+ channel: {
+ text: {
+ convertMarkdownTables: vi.fn((text: string) => text),
+ resolveChunkMode: vi.fn(() => "length"),
+ chunkMarkdownTextWithMode: vi.fn((text: string) => [text]),
+ },
+ },
+ } as any;
+
+ const agentId = "agent-1";
+ const mediaUrl = `file://${path.join(stateDir, `workspace-${agentId}`, "photo.png")}`;
+ const cfg = {} satisfies OpenClawConfig;
+
+ await deliverMattermostReplyPayload({
+ core,
+ cfg,
+ payload: { text: "caption", mediaUrl },
+ to: "channel:town-square",
+ accountId: "default",
+ agentId,
+ replyToId: "root-post",
+ textLimit: 4000,
+ tableMode: "off",
+ sendMessage,
+ });
+
+ expect(sendMessage).toHaveBeenCalledTimes(1);
+ expect(sendMessage).toHaveBeenCalledWith(
+ "channel:town-square",
+ "caption",
+ expect.objectContaining({
+ accountId: "default",
+ mediaUrl,
+ replyToId: "root-post",
+ mediaLocalRoots: expect.arrayContaining([path.join(stateDir, `workspace-${agentId}`)]),
+ }),
+ );
+ } finally {
+ if (previousStateDir === undefined) {
+ delete process.env.OPENCLAW_STATE_DIR;
+ } else {
+ process.env.OPENCLAW_STATE_DIR = previousStateDir;
+ }
+ await fs.rm(stateDir, { recursive: true, force: true });
+ }
+ });
+
+ it("forwards replyToId for text-only chunked replies", async () => {
+ const sendMessage = vi.fn(async () => undefined);
+ const core = {
+ channel: {
+ text: {
+ convertMarkdownTables: vi.fn((text: string) => text),
+ resolveChunkMode: vi.fn(() => "length"),
+ chunkMarkdownTextWithMode: vi.fn(() => ["hello"]),
+ },
+ },
+ } as any;
+
+ await deliverMattermostReplyPayload({
+ core,
+ cfg: {} satisfies OpenClawConfig,
+ payload: { text: "hello" },
+ to: "channel:town-square",
+ accountId: "default",
+ agentId: "agent-1",
+ replyToId: "root-post",
+ textLimit: 4000,
+ tableMode: "off",
+ sendMessage,
+ });
+
+ expect(sendMessage).toHaveBeenCalledTimes(1);
+ expect(sendMessage).toHaveBeenCalledWith("channel:town-square", "hello", {
+ accountId: "default",
+ replyToId: "root-post",
+ });
+ });
+});
diff --git a/extensions/mattermost/src/mattermost/reply-delivery.ts b/extensions/mattermost/src/mattermost/reply-delivery.ts
new file mode 100644
index 00000000000..5c94e51934b
--- /dev/null
+++ b/extensions/mattermost/src/mattermost/reply-delivery.ts
@@ -0,0 +1,71 @@
+import type { OpenClawConfig, PluginRuntime, ReplyPayload } from "openclaw/plugin-sdk/mattermost";
+import { getAgentScopedMediaLocalRoots } from "openclaw/plugin-sdk/mattermost";
+
+type MarkdownTableMode = Parameters[1];
+
+type SendMattermostMessage = (
+ to: string,
+ text: string,
+ opts: {
+ accountId?: string;
+ mediaUrl?: string;
+ mediaLocalRoots?: readonly string[];
+ replyToId?: string;
+ },
+) => Promise;
+
+export async function deliverMattermostReplyPayload(params: {
+ core: PluginRuntime;
+ cfg: OpenClawConfig;
+ payload: ReplyPayload;
+ to: string;
+ accountId: string;
+ agentId?: string;
+ replyToId?: string;
+ textLimit: number;
+ tableMode: MarkdownTableMode;
+ sendMessage: SendMattermostMessage;
+}): Promise {
+ const mediaUrls =
+ params.payload.mediaUrls ?? (params.payload.mediaUrl ? [params.payload.mediaUrl] : []);
+ const text = params.core.channel.text.convertMarkdownTables(
+ params.payload.text ?? "",
+ params.tableMode,
+ );
+
+ if (mediaUrls.length === 0) {
+ const chunkMode = params.core.channel.text.resolveChunkMode(
+ params.cfg,
+ "mattermost",
+ params.accountId,
+ );
+ const chunks = params.core.channel.text.chunkMarkdownTextWithMode(
+ text,
+ params.textLimit,
+ chunkMode,
+ );
+ for (const chunk of chunks.length > 0 ? chunks : [text]) {
+ if (!chunk) {
+ continue;
+ }
+ await params.sendMessage(params.to, chunk, {
+ accountId: params.accountId,
+ replyToId: params.replyToId,
+ });
+ }
+ return;
+ }
+
+ const mediaLocalRoots = getAgentScopedMediaLocalRoots(params.cfg, params.agentId);
+ let first = true;
+ for (const mediaUrl of mediaUrls) {
+ const caption = first ? text : "";
+ first = false;
+ await params.sendMessage(params.to, caption, {
+ accountId: params.accountId,
+ mediaUrl,
+ mediaLocalRoots,
+ replyToId: params.replyToId,
+ });
+ }
+}
diff --git a/extensions/mattermost/src/mattermost/slash-http.ts b/extensions/mattermost/src/mattermost/slash-http.ts
index 3c64b083d3a..36a5643e3fd 100644
--- a/extensions/mattermost/src/mattermost/slash-http.ts
+++ b/extensions/mattermost/src/mattermost/slash-http.ts
@@ -35,6 +35,7 @@ import {
authorizeMattermostCommandInvocation,
normalizeMattermostAllowList,
} from "./monitor-auth.js";
+import { deliverMattermostReplyPayload } from "./reply-delivery.js";
import { sendMessageMattermost } from "./send.js";
import {
parseSlashCommandPayload,
@@ -492,32 +493,17 @@ async function handleSlashCommandAsync(params: {
...prefixOptions,
humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, route.agentId),
deliver: async (payload: ReplyPayload) => {
- const mediaUrls = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
- const text = core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode);
- if (mediaUrls.length === 0) {
- const chunkMode = core.channel.text.resolveChunkMode(
- cfg,
- "mattermost",
- account.accountId,
- );
- const chunks = core.channel.text.chunkMarkdownTextWithMode(text, textLimit, chunkMode);
- for (const chunk of chunks.length > 0 ? chunks : [text]) {
- if (!chunk) continue;
- await sendMessageMattermost(to, chunk, {
- accountId: account.accountId,
- });
- }
- } else {
- let first = true;
- for (const mediaUrl of mediaUrls) {
- const caption = first ? text : "";
- first = false;
- await sendMessageMattermost(to, caption, {
- accountId: account.accountId,
- mediaUrl,
- });
- }
- }
+ await deliverMattermostReplyPayload({
+ core,
+ cfg,
+ payload,
+ to,
+ accountId: account.accountId,
+ agentId: route.agentId,
+ textLimit,
+ tableMode,
+ sendMessage: sendMessageMattermost,
+ });
runtime.log?.(`delivered slash reply to ${to}`);
},
onError: (err, info) => {
diff --git a/extensions/mattermost/src/types.ts b/extensions/mattermost/src/types.ts
index ba664baa894..f4038ac6920 100644
--- a/extensions/mattermost/src/types.ts
+++ b/extensions/mattermost/src/types.ts
@@ -5,6 +5,9 @@ import type {
SecretInput,
} from "openclaw/plugin-sdk/mattermost";
+export type MattermostReplyToMode = "off" | "first" | "all";
+export type MattermostChatTypeKey = "direct" | "channel" | "group";
+
export type MattermostChatMode = "oncall" | "onmessage" | "onchar";
export type MattermostAccountConfig = {
@@ -54,6 +57,14 @@ export type MattermostAccountConfig = {
blockStreamingCoalesce?: BlockStreamingCoalesceConfig;
/** Outbound response prefix override for this channel/account. */
responsePrefix?: string;
+ /**
+ * Controls whether channel and group replies are sent as thread replies.
+ * - "off" (default): only thread-reply when incoming message is already a thread reply
+ * - "first": reply in a thread under the triggering message
+ * - "all": always reply in a thread; uses existing thread root or starts a new thread under the message
+ * Direct messages always behave as "off".
+ */
+ replyToMode?: MattermostReplyToMode;
/** Action toggles for this account. */
actions?: {
/** Enable message reaction actions. Default: true. */
diff --git a/extensions/memory-core/package.json b/extensions/memory-core/package.json
index 640c81e1539..b7c451515bb 100644
--- a/extensions/memory-core/package.json
+++ b/extensions/memory-core/package.json
@@ -1,11 +1,11 @@
{
"name": "@openclaw/memory-core",
- "version": "2026.3.11",
+ "version": "2026.3.12",
"private": true,
"description": "OpenClaw core memory search plugin",
"type": "module",
"peerDependencies": {
- "openclaw": ">=2026.3.7"
+ "openclaw": ">=2026.3.11"
},
"peerDependenciesMeta": {
"openclaw": {
diff --git a/extensions/memory-lancedb/package.json b/extensions/memory-lancedb/package.json
index 2a1b2a9994b..db5bf2c35f7 100644
--- a/extensions/memory-lancedb/package.json
+++ b/extensions/memory-lancedb/package.json
@@ -1,6 +1,6 @@
{
"name": "@openclaw/memory-lancedb",
- "version": "2026.3.11",
+ "version": "2026.3.12",
"private": true,
"description": "OpenClaw LanceDB-backed long-term memory plugin with auto-recall/capture",
"type": "module",
diff --git a/extensions/minimax-portal-auth/package.json b/extensions/minimax-portal-auth/package.json
index 6e11b99212f..9126a463441 100644
--- a/extensions/minimax-portal-auth/package.json
+++ b/extensions/minimax-portal-auth/package.json
@@ -1,6 +1,6 @@
{
"name": "@openclaw/minimax-portal-auth",
- "version": "2026.3.11",
+ "version": "2026.3.12",
"private": true,
"description": "OpenClaw MiniMax Portal OAuth provider plugin",
"type": "module",
diff --git a/extensions/msteams/CHANGELOG.md b/extensions/msteams/CHANGELOG.md
index bf82200cf59..2ad54faec97 100644
--- a/extensions/msteams/CHANGELOG.md
+++ b/extensions/msteams/CHANGELOG.md
@@ -1,5 +1,11 @@
# Changelog
+## 2026.3.12
+
+### Changes
+
+- Version alignment with core OpenClaw release numbers.
+
## 2026.3.11
### Changes
diff --git a/extensions/msteams/package.json b/extensions/msteams/package.json
index c159d091977..740ef4e8cdf 100644
--- a/extensions/msteams/package.json
+++ b/extensions/msteams/package.json
@@ -1,6 +1,6 @@
{
"name": "@openclaw/msteams",
- "version": "2026.3.11",
+ "version": "2026.3.12",
"description": "OpenClaw Microsoft Teams channel plugin",
"type": "module",
"dependencies": {
diff --git a/extensions/msteams/src/monitor-handler/message-handler.ts b/extensions/msteams/src/monitor-handler/message-handler.ts
index 6fe227537d3..fff243fb70c 100644
--- a/extensions/msteams/src/monitor-handler/message-handler.ts
+++ b/extensions/msteams/src/monitor-handler/message-handler.ts
@@ -175,6 +175,7 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
teamName,
conversationId,
channelName,
+ allowNameMatching: isDangerousNameMatchingEnabled(msteamsCfg),
});
const senderGroupPolicy = resolveSenderScopedGroupPolicy({
groupPolicy,
diff --git a/extensions/msteams/src/policy.test.ts b/extensions/msteams/src/policy.test.ts
index 02d59a99723..091e22d1fd8 100644
--- a/extensions/msteams/src/policy.test.ts
+++ b/extensions/msteams/src/policy.test.ts
@@ -50,7 +50,7 @@ describe("msteams policy", () => {
expect(res.allowed).toBe(false);
});
- it("matches team and channel by name", () => {
+ it("blocks team and channel name matches by default", () => {
const cfg: MSTeamsConfig = {
teams: {
"My Team": {
@@ -69,6 +69,31 @@ describe("msteams policy", () => {
conversationId: "ignored",
});
+ expect(res.teamConfig).toBeUndefined();
+ expect(res.channelConfig).toBeUndefined();
+ expect(res.allowed).toBe(false);
+ });
+
+ it("matches team and channel by name when dangerous name matching is enabled", () => {
+ const cfg: MSTeamsConfig = {
+ teams: {
+ "My Team": {
+ requireMention: true,
+ channels: {
+ "General Chat": { requireMention: false },
+ },
+ },
+ },
+ };
+
+ const res = resolveMSTeamsRouteConfig({
+ cfg,
+ teamName: "My Team",
+ channelName: "General Chat",
+ conversationId: "ignored",
+ allowNameMatching: true,
+ });
+
expect(res.teamConfig?.requireMention).toBe(true);
expect(res.channelConfig?.requireMention).toBe(false);
expect(res.allowed).toBe(true);
diff --git a/extensions/msteams/src/policy.ts b/extensions/msteams/src/policy.ts
index 3d405f94c9e..c6317184d89 100644
--- a/extensions/msteams/src/policy.ts
+++ b/extensions/msteams/src/policy.ts
@@ -16,6 +16,7 @@ import {
resolveToolsBySender,
resolveChannelEntryMatchWithFallback,
resolveNestedAllowlistDecision,
+ isDangerousNameMatchingEnabled,
} from "openclaw/plugin-sdk/msteams";
export type MSTeamsResolvedRouteConfig = {
@@ -35,6 +36,7 @@ export function resolveMSTeamsRouteConfig(params: {
teamName?: string | null | undefined;
conversationId?: string | null | undefined;
channelName?: string | null | undefined;
+ allowNameMatching?: boolean;
}): MSTeamsResolvedRouteConfig {
const teamId = params.teamId?.trim();
const teamName = params.teamName?.trim();
@@ -44,8 +46,8 @@ export function resolveMSTeamsRouteConfig(params: {
const allowlistConfigured = Object.keys(teams).length > 0;
const teamCandidates = buildChannelKeyCandidates(
teamId,
- teamName,
- teamName ? normalizeChannelSlug(teamName) : undefined,
+ params.allowNameMatching ? teamName : undefined,
+ params.allowNameMatching && teamName ? normalizeChannelSlug(teamName) : undefined,
);
const teamMatch = resolveChannelEntryMatchWithFallback({
entries: teams,
@@ -58,8 +60,8 @@ export function resolveMSTeamsRouteConfig(params: {
const channelAllowlistConfigured = Object.keys(channels).length > 0;
const channelCandidates = buildChannelKeyCandidates(
conversationId,
- channelName,
- channelName ? normalizeChannelSlug(channelName) : undefined,
+ params.allowNameMatching ? channelName : undefined,
+ params.allowNameMatching && channelName ? normalizeChannelSlug(channelName) : undefined,
);
const channelMatch = resolveChannelEntryMatchWithFallback({
entries: channels,
@@ -101,6 +103,7 @@ export function resolveMSTeamsGroupToolPolicy(
const groupId = params.groupId?.trim();
const groupChannel = params.groupChannel?.trim();
const groupSpace = params.groupSpace?.trim();
+ const allowNameMatching = isDangerousNameMatchingEnabled(cfg);
const resolved = resolveMSTeamsRouteConfig({
cfg,
@@ -108,6 +111,7 @@ export function resolveMSTeamsGroupToolPolicy(
teamName: groupSpace,
conversationId: groupId,
channelName: groupChannel,
+ allowNameMatching,
});
if (resolved.channelConfig) {
@@ -158,8 +162,8 @@ export function resolveMSTeamsGroupToolPolicy(
const channelCandidates = buildChannelKeyCandidates(
groupId,
- groupChannel,
- groupChannel ? normalizeChannelSlug(groupChannel) : undefined,
+ allowNameMatching ? groupChannel : undefined,
+ allowNameMatching && groupChannel ? normalizeChannelSlug(groupChannel) : undefined,
);
for (const teamConfig of Object.values(cfg.teams ?? {})) {
const match = resolveChannelEntryMatchWithFallback({
diff --git a/extensions/nextcloud-talk/package.json b/extensions/nextcloud-talk/package.json
index 9ef0a1daf09..24231f71cea 100644
--- a/extensions/nextcloud-talk/package.json
+++ b/extensions/nextcloud-talk/package.json
@@ -1,6 +1,6 @@
{
"name": "@openclaw/nextcloud-talk",
- "version": "2026.3.11",
+ "version": "2026.3.12",
"description": "OpenClaw Nextcloud Talk channel plugin",
"type": "module",
"dependencies": {
diff --git a/extensions/nostr/CHANGELOG.md b/extensions/nostr/CHANGELOG.md
index dcb4c18fdfa..697a4423f96 100644
--- a/extensions/nostr/CHANGELOG.md
+++ b/extensions/nostr/CHANGELOG.md
@@ -1,5 +1,11 @@
# Changelog
+## 2026.3.12
+
+### Changes
+
+- Version alignment with core OpenClaw release numbers.
+
## 2026.3.11
### Changes
diff --git a/extensions/nostr/package.json b/extensions/nostr/package.json
index f02b67b6837..bffacd76e07 100644
--- a/extensions/nostr/package.json
+++ b/extensions/nostr/package.json
@@ -1,6 +1,6 @@
{
"name": "@openclaw/nostr",
- "version": "2026.3.11",
+ "version": "2026.3.12",
"description": "OpenClaw Nostr channel plugin for NIP-04 encrypted DMs",
"type": "module",
"dependencies": {
diff --git a/extensions/ollama/README.md b/extensions/ollama/README.md
new file mode 100644
index 00000000000..3a331c08e4b
--- /dev/null
+++ b/extensions/ollama/README.md
@@ -0,0 +1,3 @@
+# Ollama Provider
+
+Bundled provider plugin for Ollama discovery and setup.
diff --git a/extensions/ollama/index.ts b/extensions/ollama/index.ts
new file mode 100644
index 00000000000..6ba28a3af7c
--- /dev/null
+++ b/extensions/ollama/index.ts
@@ -0,0 +1,123 @@
+import {
+ buildOllamaProvider,
+ emptyPluginConfigSchema,
+ ensureOllamaModelPulled,
+ OLLAMA_DEFAULT_BASE_URL,
+ promptAndConfigureOllama,
+ configureOllamaNonInteractive,
+ type OpenClawPluginApi,
+ type ProviderAuthContext,
+ type ProviderAuthMethodNonInteractiveContext,
+ type ProviderAuthResult,
+ type ProviderDiscoveryContext,
+} from "openclaw/plugin-sdk/core";
+
+const PROVIDER_ID = "ollama";
+const DEFAULT_API_KEY = "ollama-local";
+
+const ollamaPlugin = {
+ id: "ollama",
+ name: "Ollama Provider",
+ description: "Bundled Ollama provider plugin",
+ configSchema: emptyPluginConfigSchema(),
+ register(api: OpenClawPluginApi) {
+ api.registerProvider({
+ id: PROVIDER_ID,
+ label: "Ollama",
+ docsPath: "/providers/ollama",
+ envVars: ["OLLAMA_API_KEY"],
+ auth: [
+ {
+ id: "local",
+ label: "Ollama",
+ hint: "Cloud and local open models",
+ kind: "custom",
+ run: async (ctx: ProviderAuthContext): Promise => {
+ const result = await promptAndConfigureOllama({
+ cfg: ctx.config,
+ prompter: ctx.prompter,
+ });
+ return {
+ profiles: [
+ {
+ profileId: "ollama:default",
+ credential: {
+ type: "api_key",
+ provider: PROVIDER_ID,
+ key: DEFAULT_API_KEY,
+ },
+ },
+ ],
+ configPatch: result.config,
+ defaultModel: `ollama/${result.defaultModelId}`,
+ };
+ },
+ runNonInteractive: async (ctx: ProviderAuthMethodNonInteractiveContext) =>
+ configureOllamaNonInteractive({
+ nextConfig: ctx.config,
+ opts: ctx.opts,
+ runtime: ctx.runtime,
+ }),
+ },
+ ],
+ discovery: {
+ order: "late",
+ run: async (ctx: ProviderDiscoveryContext) => {
+ const explicit = ctx.config.models?.providers?.ollama;
+ const hasExplicitModels = Array.isArray(explicit?.models) && explicit.models.length > 0;
+ const ollamaKey = ctx.resolveProviderApiKey(PROVIDER_ID).apiKey;
+ if (hasExplicitModels && explicit) {
+ return {
+ provider: {
+ ...explicit,
+ baseUrl:
+ typeof explicit.baseUrl === "string" && explicit.baseUrl.trim()
+ ? explicit.baseUrl.trim().replace(/\/+$/, "")
+ : OLLAMA_DEFAULT_BASE_URL,
+ api: explicit.api ?? "ollama",
+ apiKey: ollamaKey ?? explicit.apiKey ?? DEFAULT_API_KEY,
+ },
+ };
+ }
+
+ const provider = await buildOllamaProvider(explicit?.baseUrl, {
+ quiet: !ollamaKey && !explicit,
+ });
+ if (provider.models.length === 0 && !ollamaKey && !explicit?.apiKey) {
+ return null;
+ }
+ return {
+ provider: {
+ ...provider,
+ apiKey: ollamaKey ?? explicit?.apiKey ?? DEFAULT_API_KEY,
+ },
+ };
+ },
+ },
+ wizard: {
+ onboarding: {
+ choiceId: "ollama",
+ choiceLabel: "Ollama",
+ choiceHint: "Cloud and local open models",
+ groupId: "ollama",
+ groupLabel: "Ollama",
+ groupHint: "Cloud and local open models",
+ methodId: "local",
+ },
+ modelPicker: {
+ label: "Ollama (custom)",
+ hint: "Detect models from a local or remote Ollama instance",
+ methodId: "local",
+ },
+ },
+ onModelSelected: async ({ config, model, prompter }) => {
+ if (!model.startsWith("ollama/")) {
+ return;
+ }
+ await ensureOllamaModelPulled({ config, prompter });
+ },
+ });
+ },
+};
+
+export default ollamaPlugin;
diff --git a/extensions/ollama/openclaw.plugin.json b/extensions/ollama/openclaw.plugin.json
new file mode 100644
index 00000000000..3df1002d1ac
--- /dev/null
+++ b/extensions/ollama/openclaw.plugin.json
@@ -0,0 +1,9 @@
+{
+ "id": "ollama",
+ "providers": ["ollama"],
+ "configSchema": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {}
+ }
+}
diff --git a/extensions/ollama/package.json b/extensions/ollama/package.json
new file mode 100644
index 00000000000..766687aa1e5
--- /dev/null
+++ b/extensions/ollama/package.json
@@ -0,0 +1,12 @@
+{
+ "name": "@openclaw/ollama-provider",
+ "version": "2026.3.12",
+ "private": true,
+ "description": "OpenClaw Ollama provider plugin",
+ "type": "module",
+ "openclaw": {
+ "extensions": [
+ "./index.ts"
+ ]
+ }
+}
diff --git a/extensions/open-prose/package.json b/extensions/open-prose/package.json
index de86909f961..a1570f96f66 100644
--- a/extensions/open-prose/package.json
+++ b/extensions/open-prose/package.json
@@ -1,6 +1,6 @@
{
"name": "@openclaw/open-prose",
- "version": "2026.3.11",
+ "version": "2026.3.12",
"private": true,
"description": "OpenProse VM skill pack plugin (slash command + telemetry).",
"type": "module",
diff --git a/extensions/sglang/README.md b/extensions/sglang/README.md
new file mode 100644
index 00000000000..4a16a882c2e
--- /dev/null
+++ b/extensions/sglang/README.md
@@ -0,0 +1,3 @@
+# SGLang Provider
+
+Bundled provider plugin for SGLang discovery and setup.
diff --git a/extensions/sglang/index.ts b/extensions/sglang/index.ts
new file mode 100644
index 00000000000..4c9102caebc
--- /dev/null
+++ b/extensions/sglang/index.ts
@@ -0,0 +1,103 @@
+import {
+ buildSglangProvider,
+ configureOpenAICompatibleSelfHostedProviderNonInteractive,
+ emptyPluginConfigSchema,
+ promptAndConfigureOpenAICompatibleSelfHostedProvider,
+ type OpenClawPluginApi,
+ type ProviderAuthContext,
+ type ProviderAuthMethodNonInteractiveContext,
+ type ProviderAuthResult,
+ type ProviderDiscoveryContext,
+} from "openclaw/plugin-sdk/core";
+
+const PROVIDER_ID = "sglang";
+const DEFAULT_BASE_URL = "http://127.0.0.1:30000/v1";
+
+const sglangPlugin = {
+ id: "sglang",
+ name: "SGLang Provider",
+ description: "Bundled SGLang provider plugin",
+ configSchema: emptyPluginConfigSchema(),
+ register(api: OpenClawPluginApi) {
+ api.registerProvider({
+ id: PROVIDER_ID,
+ label: "SGLang",
+ docsPath: "/providers/sglang",
+ envVars: ["SGLANG_API_KEY"],
+ auth: [
+ {
+ id: "custom",
+ label: "SGLang",
+ hint: "Fast self-hosted OpenAI-compatible server",
+ kind: "custom",
+ run: async (ctx: ProviderAuthContext): Promise => {
+ const result = await promptAndConfigureOpenAICompatibleSelfHostedProvider({
+ cfg: ctx.config,
+ prompter: ctx.prompter,
+ providerId: PROVIDER_ID,
+ providerLabel: "SGLang",
+ defaultBaseUrl: DEFAULT_BASE_URL,
+ defaultApiKeyEnvVar: "SGLANG_API_KEY",
+ modelPlaceholder: "Qwen/Qwen3-8B",
+ });
+ return {
+ profiles: [
+ {
+ profileId: result.profileId,
+ credential: result.credential,
+ },
+ ],
+ configPatch: result.config,
+ defaultModel: result.modelRef,
+ };
+ },
+ runNonInteractive: async (ctx: ProviderAuthMethodNonInteractiveContext) =>
+ configureOpenAICompatibleSelfHostedProviderNonInteractive({
+ ctx,
+ providerId: PROVIDER_ID,
+ providerLabel: "SGLang",
+ defaultBaseUrl: DEFAULT_BASE_URL,
+ defaultApiKeyEnvVar: "SGLANG_API_KEY",
+ modelPlaceholder: "Qwen/Qwen3-8B",
+ }),
+ },
+ ],
+ discovery: {
+ order: "late",
+ run: async (ctx: ProviderDiscoveryContext) => {
+ if (ctx.config.models?.providers?.sglang) {
+ return null;
+ }
+ const { apiKey, discoveryApiKey } = ctx.resolveProviderApiKey(PROVIDER_ID);
+ if (!apiKey) {
+ return null;
+ }
+ return {
+ provider: {
+ ...(await buildSglangProvider({ apiKey: discoveryApiKey })),
+ apiKey,
+ },
+ };
+ },
+ },
+ wizard: {
+ onboarding: {
+ choiceId: "sglang",
+ choiceLabel: "SGLang",
+ choiceHint: "Fast self-hosted OpenAI-compatible server",
+ groupId: "sglang",
+ groupLabel: "SGLang",
+ groupHint: "Fast self-hosted server",
+ methodId: "custom",
+ },
+ modelPicker: {
+ label: "SGLang (custom)",
+ hint: "Enter SGLang URL + API key + model",
+ methodId: "custom",
+ },
+ },
+ });
+ },
+};
+
+export default sglangPlugin;
diff --git a/extensions/sglang/openclaw.plugin.json b/extensions/sglang/openclaw.plugin.json
new file mode 100644
index 00000000000..161ea4c635a
--- /dev/null
+++ b/extensions/sglang/openclaw.plugin.json
@@ -0,0 +1,9 @@
+{
+ "id": "sglang",
+ "providers": ["sglang"],
+ "configSchema": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {}
+ }
+}
diff --git a/extensions/sglang/package.json b/extensions/sglang/package.json
new file mode 100644
index 00000000000..6ee92946db7
--- /dev/null
+++ b/extensions/sglang/package.json
@@ -0,0 +1,12 @@
+{
+ "name": "@openclaw/sglang-provider",
+ "version": "2026.3.12",
+ "private": true,
+ "description": "OpenClaw SGLang provider plugin",
+ "type": "module",
+ "openclaw": {
+ "extensions": [
+ "./index.ts"
+ ]
+ }
+}
diff --git a/extensions/signal/package.json b/extensions/signal/package.json
index 6fd516cfd42..ecb862d4364 100644
--- a/extensions/signal/package.json
+++ b/extensions/signal/package.json
@@ -1,6 +1,6 @@
{
"name": "@openclaw/signal",
- "version": "2026.3.11",
+ "version": "2026.3.12",
"private": true,
"description": "OpenClaw Signal channel plugin",
"type": "module",
diff --git a/extensions/slack/package.json b/extensions/slack/package.json
index dbc4a4483c4..a166c432a36 100644
--- a/extensions/slack/package.json
+++ b/extensions/slack/package.json
@@ -1,6 +1,6 @@
{
"name": "@openclaw/slack",
- "version": "2026.3.11",
+ "version": "2026.3.12",
"private": true,
"description": "OpenClaw Slack channel plugin",
"type": "module",
diff --git a/extensions/synology-chat/package.json b/extensions/synology-chat/package.json
index 0e7b4847494..bf2653078ec 100644
--- a/extensions/synology-chat/package.json
+++ b/extensions/synology-chat/package.json
@@ -1,6 +1,6 @@
{
"name": "@openclaw/synology-chat",
- "version": "2026.3.11",
+ "version": "2026.3.12",
"description": "Synology Chat channel plugin for OpenClaw",
"type": "module",
"dependencies": {
diff --git a/extensions/telegram/package.json b/extensions/telegram/package.json
index 8ffa3acf603..bc00f6c016c 100644
--- a/extensions/telegram/package.json
+++ b/extensions/telegram/package.json
@@ -1,6 +1,6 @@
{
"name": "@openclaw/telegram",
- "version": "2026.3.11",
+ "version": "2026.3.12",
"private": true,
"description": "OpenClaw Telegram channel plugin",
"type": "module",
diff --git a/extensions/tlon/package.json b/extensions/tlon/package.json
index 154e1dd6dbd..abf1d2745c6 100644
--- a/extensions/tlon/package.json
+++ b/extensions/tlon/package.json
@@ -1,6 +1,6 @@
{
"name": "@openclaw/tlon",
- "version": "2026.3.11",
+ "version": "2026.3.12",
"description": "OpenClaw Tlon/Urbit channel plugin",
"type": "module",
"dependencies": {
diff --git a/extensions/twitch/CHANGELOG.md b/extensions/twitch/CHANGELOG.md
index 844ef13dc6c..547069ba8ba 100644
--- a/extensions/twitch/CHANGELOG.md
+++ b/extensions/twitch/CHANGELOG.md
@@ -1,5 +1,11 @@
# Changelog
+## 2026.3.12
+
+### Changes
+
+- Version alignment with core OpenClaw release numbers.
+
## 2026.3.11
### Changes
diff --git a/extensions/twitch/package.json b/extensions/twitch/package.json
index 3bcdf9fe847..a39b7b6e3d0 100644
--- a/extensions/twitch/package.json
+++ b/extensions/twitch/package.json
@@ -1,6 +1,6 @@
{
"name": "@openclaw/twitch",
- "version": "2026.3.11",
+ "version": "2026.3.12",
"description": "OpenClaw Twitch channel plugin",
"type": "module",
"dependencies": {
diff --git a/extensions/vllm/README.md b/extensions/vllm/README.md
new file mode 100644
index 00000000000..ce0990a8698
--- /dev/null
+++ b/extensions/vllm/README.md
@@ -0,0 +1,3 @@
+# vLLM Provider
+
+Bundled provider plugin for vLLM discovery and setup.
diff --git a/extensions/vllm/index.ts b/extensions/vllm/index.ts
new file mode 100644
index 00000000000..fd0a5e18914
--- /dev/null
+++ b/extensions/vllm/index.ts
@@ -0,0 +1,103 @@
+import {
+ buildVllmProvider,
+ configureOpenAICompatibleSelfHostedProviderNonInteractive,
+ emptyPluginConfigSchema,
+ promptAndConfigureOpenAICompatibleSelfHostedProvider,
+ type OpenClawPluginApi,
+ type ProviderAuthContext,
+ type ProviderAuthMethodNonInteractiveContext,
+ type ProviderAuthResult,
+ type ProviderDiscoveryContext,
+} from "openclaw/plugin-sdk/core";
+
+const PROVIDER_ID = "vllm";
+const DEFAULT_BASE_URL = "http://127.0.0.1:8000/v1";
+
+const vllmPlugin = {
+ id: "vllm",
+ name: "vLLM Provider",
+ description: "Bundled vLLM provider plugin",
+ configSchema: emptyPluginConfigSchema(),
+ register(api: OpenClawPluginApi) {
+ api.registerProvider({
+ id: PROVIDER_ID,
+ label: "vLLM",
+ docsPath: "/providers/vllm",
+ envVars: ["VLLM_API_KEY"],
+ auth: [
+ {
+ id: "custom",
+ label: "vLLM",
+ hint: "Local/self-hosted OpenAI-compatible server",
+ kind: "custom",
+ run: async (ctx: ProviderAuthContext): Promise => {
+ const result = await promptAndConfigureOpenAICompatibleSelfHostedProvider({
+ cfg: ctx.config,
+ prompter: ctx.prompter,
+ providerId: PROVIDER_ID,
+ providerLabel: "vLLM",
+ defaultBaseUrl: DEFAULT_BASE_URL,
+ defaultApiKeyEnvVar: "VLLM_API_KEY",
+ modelPlaceholder: "meta-llama/Meta-Llama-3-8B-Instruct",
+ });
+ return {
+ profiles: [
+ {
+ profileId: result.profileId,
+ credential: result.credential,
+ },
+ ],
+ configPatch: result.config,
+ defaultModel: result.modelRef,
+ };
+ },
+ runNonInteractive: async (ctx: ProviderAuthMethodNonInteractiveContext) =>
+ configureOpenAICompatibleSelfHostedProviderNonInteractive({
+ ctx,
+ providerId: PROVIDER_ID,
+ providerLabel: "vLLM",
+ defaultBaseUrl: DEFAULT_BASE_URL,
+ defaultApiKeyEnvVar: "VLLM_API_KEY",
+ modelPlaceholder: "meta-llama/Meta-Llama-3-8B-Instruct",
+ }),
+ },
+ ],
+ discovery: {
+ order: "late",
+ run: async (ctx: ProviderDiscoveryContext) => {
+ if (ctx.config.models?.providers?.vllm) {
+ return null;
+ }
+ const { apiKey, discoveryApiKey } = ctx.resolveProviderApiKey(PROVIDER_ID);
+ if (!apiKey) {
+ return null;
+ }
+ return {
+ provider: {
+ ...(await buildVllmProvider({ apiKey: discoveryApiKey })),
+ apiKey,
+ },
+ };
+ },
+ },
+ wizard: {
+ onboarding: {
+ choiceId: "vllm",
+ choiceLabel: "vLLM",
+ choiceHint: "Local/self-hosted OpenAI-compatible server",
+ groupId: "vllm",
+ groupLabel: "vLLM",
+ groupHint: "Local/self-hosted OpenAI-compatible",
+ methodId: "custom",
+ },
+ modelPicker: {
+ label: "vLLM (custom)",
+ hint: "Enter vLLM URL + API key + model",
+ methodId: "custom",
+ },
+ },
+ });
+ },
+};
+
+export default vllmPlugin;
diff --git a/extensions/vllm/openclaw.plugin.json b/extensions/vllm/openclaw.plugin.json
new file mode 100644
index 00000000000..5a9f9a778ee
--- /dev/null
+++ b/extensions/vllm/openclaw.plugin.json
@@ -0,0 +1,9 @@
+{
+ "id": "vllm",
+ "providers": ["vllm"],
+ "configSchema": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {}
+ }
+}
diff --git a/extensions/vllm/package.json b/extensions/vllm/package.json
new file mode 100644
index 00000000000..493486551ea
--- /dev/null
+++ b/extensions/vllm/package.json
@@ -0,0 +1,12 @@
+{
+ "name": "@openclaw/vllm-provider",
+ "version": "2026.3.12",
+ "private": true,
+ "description": "OpenClaw vLLM provider plugin",
+ "type": "module",
+ "openclaw": {
+ "extensions": [
+ "./index.ts"
+ ]
+ }
+}
diff --git a/extensions/voice-call/CHANGELOG.md b/extensions/voice-call/CHANGELOG.md
index 93aba26c868..82a8e02a623 100644
--- a/extensions/voice-call/CHANGELOG.md
+++ b/extensions/voice-call/CHANGELOG.md
@@ -1,5 +1,11 @@
# Changelog
+## 2026.3.12
+
+### Changes
+
+- Version alignment with core OpenClaw release numbers.
+
## 2026.3.11
### Changes
diff --git a/extensions/voice-call/package.json b/extensions/voice-call/package.json
index 9bdadd3b226..65012d94a66 100644
--- a/extensions/voice-call/package.json
+++ b/extensions/voice-call/package.json
@@ -1,6 +1,6 @@
{
"name": "@openclaw/voice-call",
- "version": "2026.3.11",
+ "version": "2026.3.12",
"description": "OpenClaw voice-call plugin",
"type": "module",
"dependencies": {
diff --git a/extensions/whatsapp/package.json b/extensions/whatsapp/package.json
index 1a21be8eba9..98e4b646852 100644
--- a/extensions/whatsapp/package.json
+++ b/extensions/whatsapp/package.json
@@ -1,6 +1,6 @@
{
"name": "@openclaw/whatsapp",
- "version": "2026.3.11",
+ "version": "2026.3.12",
"private": true,
"description": "OpenClaw WhatsApp channel plugin",
"type": "module",
diff --git a/extensions/zalo/CHANGELOG.md b/extensions/zalo/CHANGELOG.md
index 178f993e825..14de72d85f3 100644
--- a/extensions/zalo/CHANGELOG.md
+++ b/extensions/zalo/CHANGELOG.md
@@ -1,5 +1,11 @@
# Changelog
+## 2026.3.12
+
+### Changes
+
+- Version alignment with core OpenClaw release numbers.
+
## 2026.3.11
### Changes
diff --git a/extensions/zalo/package.json b/extensions/zalo/package.json
index 463887c68fe..285246486fb 100644
--- a/extensions/zalo/package.json
+++ b/extensions/zalo/package.json
@@ -1,10 +1,10 @@
{
"name": "@openclaw/zalo",
- "version": "2026.3.11",
+ "version": "2026.3.12",
"description": "OpenClaw Zalo channel plugin",
"type": "module",
"dependencies": {
- "undici": "7.22.0",
+ "undici": "7.24.0",
"zod": "^4.3.6"
},
"openclaw": {
diff --git a/extensions/zalo/src/monitor.webhook.test.ts b/extensions/zalo/src/monitor.webhook.test.ts
index 297d8249d3a..57b5f43202e 100644
--- a/extensions/zalo/src/monitor.webhook.test.ts
+++ b/extensions/zalo/src/monitor.webhook.test.ts
@@ -283,6 +283,7 @@ describe("handleZaloWebhookRequest", () => {
try {
await withServer(webhookRequestHandler, async (baseUrl) => {
+ let saw429 = false;
for (let i = 0; i < 200; i += 1) {
const response = await fetch(`${baseUrl}/hook-query-status?nonce=${i}`, {
method: "POST",
@@ -292,10 +293,15 @@ describe("handleZaloWebhookRequest", () => {
},
body: "{}",
});
- expect(response.status).toBe(401);
+ expect([401, 429]).toContain(response.status);
+ if (response.status === 429) {
+ saw429 = true;
+ break;
+ }
}
- expect(getZaloWebhookStatusCounterSizeForTest()).toBe(1);
+ expect(saw429).toBe(true);
+ expect(getZaloWebhookStatusCounterSizeForTest()).toBe(2);
});
} finally {
unregister();
@@ -322,6 +328,91 @@ describe("handleZaloWebhookRequest", () => {
}
});
+ it("rate limits unauthorized secret guesses before authentication succeeds", async () => {
+ const unregister = registerTarget({ path: "/hook-preauth-rate" });
+
+ try {
+ await withServer(webhookRequestHandler, async (baseUrl) => {
+ const saw429 = await postUntilRateLimited({
+ baseUrl,
+ path: "/hook-preauth-rate",
+ secret: "invalid-token", // pragma: allowlist secret
+ withNonceQuery: true,
+ });
+
+ expect(saw429).toBe(true);
+ expect(getZaloWebhookRateLimitStateSizeForTest()).toBe(1);
+ });
+ } finally {
+ unregister();
+ }
+ });
+
+ it("does not let unauthorized floods rate-limit authenticated traffic from a different trusted forwarded client IP", async () => {
+ const unregister = registerTarget({
+ path: "/hook-preauth-split",
+ config: {
+ gateway: {
+ trustedProxies: ["127.0.0.1"],
+ },
+ } as OpenClawConfig,
+ });
+
+ try {
+ await withServer(webhookRequestHandler, async (baseUrl) => {
+ for (let i = 0; i < 130; i += 1) {
+ const response = await fetch(`${baseUrl}/hook-preauth-split?nonce=${i}`, {
+ method: "POST",
+ headers: {
+ "x-bot-api-secret-token": "invalid-token", // pragma: allowlist secret
+ "content-type": "application/json",
+ "x-forwarded-for": "203.0.113.10",
+ },
+ body: "{}",
+ });
+ if (response.status === 429) {
+ break;
+ }
+ }
+
+ const validResponse = await fetch(`${baseUrl}/hook-preauth-split`, {
+ method: "POST",
+ headers: {
+ "x-bot-api-secret-token": "secret",
+ "content-type": "application/json",
+ "x-forwarded-for": "198.51.100.20",
+ },
+ body: JSON.stringify({ event_name: "message.unsupported.received" }),
+ });
+
+ expect(validResponse.status).toBe(200);
+ });
+ } finally {
+ unregister();
+ }
+ });
+
+ it("still returns 401 before 415 when both secret and content-type are invalid", async () => {
+ const unregister = registerTarget({ path: "/hook-auth-before-type" });
+
+ try {
+ await withServer(webhookRequestHandler, async (baseUrl) => {
+ const response = await fetch(`${baseUrl}/hook-auth-before-type`, {
+ method: "POST",
+ headers: {
+ "x-bot-api-secret-token": "invalid-token", // pragma: allowlist secret
+ "content-type": "text/plain",
+ },
+ body: "not-json",
+ });
+
+ expect(response.status).toBe(401);
+ });
+ } finally {
+ unregister();
+ }
+ });
+
it("scopes DM pairing store reads and writes to accountId", async () => {
const { core, readAllowFromStore, upsertPairingRequest } = createPairingAuthCore({
pairingCreated: false,
diff --git a/extensions/zalo/src/monitor.webhook.ts b/extensions/zalo/src/monitor.webhook.ts
index 8fad827fddc..ef10d3a9a0e 100644
--- a/extensions/zalo/src/monitor.webhook.ts
+++ b/extensions/zalo/src/monitor.webhook.ts
@@ -16,6 +16,7 @@ import {
WEBHOOK_ANOMALY_COUNTER_DEFAULTS,
WEBHOOK_RATE_LIMIT_DEFAULTS,
} from "openclaw/plugin-sdk/zalo";
+import { resolveClientIp } from "../../../src/gateway/net.js";
import type { ResolvedZaloAccount } from "./accounts.js";
import type { ZaloFetch, ZaloUpdate } from "./api.js";
import type { ZaloRuntimeEnv } from "./monitor.js";
@@ -109,6 +110,10 @@ function recordWebhookStatus(
});
}
+function headerValue(value: string | string[] | undefined): string | undefined {
+ return Array.isArray(value) ? value[0] : value;
+}
+
export function registerZaloWebhookTarget(
target: ZaloWebhookTarget,
opts?: {
@@ -140,6 +145,33 @@ export async function handleZaloWebhookRequest(
targetsByPath: webhookTargets,
allowMethods: ["POST"],
handle: async ({ targets, path }) => {
+ const trustedProxies = targets[0]?.config.gateway?.trustedProxies;
+ const allowRealIpFallback = targets[0]?.config.gateway?.allowRealIpFallback === true;
+ const clientIp =
+ resolveClientIp({
+ remoteAddr: req.socket.remoteAddress,
+ forwardedFor: headerValue(req.headers["x-forwarded-for"]),
+ realIp: headerValue(req.headers["x-real-ip"]),
+ trustedProxies,
+ allowRealIpFallback,
+ }) ??
+ req.socket.remoteAddress ??
+ "unknown";
+ const rateLimitKey = `${path}:${clientIp}`;
+ const nowMs = Date.now();
+ if (
+ !applyBasicWebhookRequestGuards({
+ req,
+ res,
+ rateLimiter: webhookRateLimiter,
+ rateLimitKey,
+ nowMs,
+ })
+ ) {
+ recordWebhookStatus(targets[0]?.runtime, path, res.statusCode);
+ return true;
+ }
+
const headerToken = String(req.headers["x-bot-api-secret-token"] ?? "");
const target = resolveWebhookTargetWithAuthOrRejectSync({
targets,
@@ -150,16 +182,12 @@ export async function handleZaloWebhookRequest(
recordWebhookStatus(targets[0]?.runtime, path, res.statusCode);
return true;
}
- const rateLimitKey = `${path}:${req.socket.remoteAddress ?? "unknown"}`;
- const nowMs = Date.now();
-
+ // Preserve the historical 401-before-415 ordering for invalid secrets while still
+ // consuming rate-limit budget on unauthenticated guesses.
if (
!applyBasicWebhookRequestGuards({
req,
res,
- rateLimiter: webhookRateLimiter,
- rateLimitKey,
- nowMs,
requireJsonContentType: true,
})
) {
diff --git a/extensions/zalouser/CHANGELOG.md b/extensions/zalouser/CHANGELOG.md
index b5a0fbb6f57..b503c283a39 100644
--- a/extensions/zalouser/CHANGELOG.md
+++ b/extensions/zalouser/CHANGELOG.md
@@ -1,5 +1,11 @@
# Changelog
+## 2026.3.12
+
+### Changes
+
+- Version alignment with core OpenClaw release numbers.
+
## 2026.3.11
### Changes
diff --git a/extensions/zalouser/package.json b/extensions/zalouser/package.json
index 2b803b0b150..5046deabca0 100644
--- a/extensions/zalouser/package.json
+++ b/extensions/zalouser/package.json
@@ -1,6 +1,6 @@
{
"name": "@openclaw/zalouser",
- "version": "2026.3.11",
+ "version": "2026.3.12",
"description": "OpenClaw Zalo Personal Account plugin via native zca-js integration",
"type": "module",
"dependencies": {
diff --git a/extensions/zalouser/src/channel.sendpayload.test.ts b/extensions/zalouser/src/channel.sendpayload.test.ts
index 0cef65f8c05..d388773e2e6 100644
--- a/extensions/zalouser/src/channel.sendpayload.test.ts
+++ b/extensions/zalouser/src/channel.sendpayload.test.ts
@@ -5,6 +5,7 @@ import {
primeSendMock,
} from "../../../src/test-utils/send-payload-contract.js";
import { zalouserPlugin } from "./channel.js";
+import { setZalouserRuntime } from "./runtime.js";
vi.mock("./send.js", () => ({
sendMessageZalouser: vi.fn().mockResolvedValue({ ok: true, messageId: "zlu-1" }),
@@ -38,6 +39,14 @@ describe("zalouserPlugin outbound sendPayload", () => {
let mockedSend: ReturnType>;
beforeEach(async () => {
+ setZalouserRuntime({
+ channel: {
+ text: {
+ resolveChunkMode: vi.fn(() => "length"),
+ resolveTextChunkLimit: vi.fn(() => 1200),
+ },
+ },
+ } as never);
const mod = await import("./send.js");
mockedSend = vi.mocked(mod.sendMessageZalouser);
mockedSend.mockClear();
@@ -55,7 +64,7 @@ describe("zalouserPlugin outbound sendPayload", () => {
expect(mockedSend).toHaveBeenCalledWith(
"1471383327500481391",
"hello group",
- expect.objectContaining({ isGroup: true }),
+ expect.objectContaining({ isGroup: true, textMode: "markdown" }),
);
expect(result).toMatchObject({ channel: "zalouser", messageId: "zlu-g1" });
});
@@ -71,7 +80,7 @@ describe("zalouserPlugin outbound sendPayload", () => {
expect(mockedSend).toHaveBeenCalledWith(
"987654321",
"hello",
- expect.objectContaining({ isGroup: false }),
+ expect.objectContaining({ isGroup: false, textMode: "markdown" }),
);
expect(result).toMatchObject({ channel: "zalouser", messageId: "zlu-d1" });
});
@@ -87,14 +96,37 @@ describe("zalouserPlugin outbound sendPayload", () => {
expect(mockedSend).toHaveBeenCalledWith(
"g-1471383327500481391",
"hello native group",
- expect.objectContaining({ isGroup: true }),
+ expect.objectContaining({ isGroup: true, textMode: "markdown" }),
);
expect(result).toMatchObject({ channel: "zalouser", messageId: "zlu-g-native" });
});
+ it("passes long markdown through once so formatting happens before chunking", async () => {
+ const text = `**${"a".repeat(2501)}**`;
+ mockedSend.mockResolvedValue({ ok: true, messageId: "zlu-code" });
+
+ const result = await zalouserPlugin.outbound!.sendPayload!({
+ ...baseCtx({ text }),
+ to: "987654321",
+ });
+
+ expect(mockedSend).toHaveBeenCalledTimes(1);
+ expect(mockedSend).toHaveBeenCalledWith(
+ "987654321",
+ text,
+ expect.objectContaining({
+ isGroup: false,
+ textMode: "markdown",
+ textChunkMode: "length",
+ textChunkLimit: 1200,
+ }),
+ );
+ expect(result).toMatchObject({ channel: "zalouser", messageId: "zlu-code" });
+ });
+
installSendPayloadContractSuite({
channel: "zalouser",
- chunking: { mode: "split", longTextLength: 3000, maxChunkLength: 2000 },
+ chunking: { mode: "passthrough", longTextLength: 3000 },
createHarness: ({ payload, sendResults }) => {
primeSendMock(mockedSend, { ok: true, messageId: "zlu-1" }, sendResults);
return {
diff --git a/extensions/zalouser/src/channel.test.ts b/extensions/zalouser/src/channel.test.ts
index 231bcc8b2d3..f54539ed809 100644
--- a/extensions/zalouser/src/channel.test.ts
+++ b/extensions/zalouser/src/channel.test.ts
@@ -1,30 +1,65 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { zalouserPlugin } from "./channel.js";
-import { sendReactionZalouser } from "./send.js";
+import { setZalouserRuntime } from "./runtime.js";
+import { sendMessageZalouser, sendReactionZalouser } from "./send.js";
vi.mock("./send.js", async (importOriginal) => {
const actual = (await importOriginal()) as Record;
return {
...actual,
+ sendMessageZalouser: vi.fn(async () => ({ ok: true, messageId: "mid-1" })),
sendReactionZalouser: vi.fn(async () => ({ ok: true })),
};
});
+const mockSendMessage = vi.mocked(sendMessageZalouser);
const mockSendReaction = vi.mocked(sendReactionZalouser);
-describe("zalouser outbound chunker", () => {
- it("chunks without empty strings and respects limit", () => {
- const chunker = zalouserPlugin.outbound?.chunker;
- expect(chunker).toBeTypeOf("function");
- if (!chunker) {
+describe("zalouser outbound", () => {
+ beforeEach(() => {
+ mockSendMessage.mockClear();
+ setZalouserRuntime({
+ channel: {
+ text: {
+ resolveChunkMode: vi.fn(() => "newline"),
+ resolveTextChunkLimit: vi.fn(() => 10),
+ },
+ },
+ } as never);
+ });
+
+ it("passes markdown chunk settings through sendText", async () => {
+ const sendText = zalouserPlugin.outbound?.sendText;
+ expect(sendText).toBeTypeOf("function");
+ if (!sendText) {
return;
}
- const limit = 10;
- const chunks = chunker("hello world\nthis is a test", limit);
- expect(chunks.length).toBeGreaterThan(1);
- expect(chunks.every((c) => c.length > 0)).toBe(true);
- expect(chunks.every((c) => c.length <= limit)).toBe(true);
+ const result = await sendText({
+ cfg: { channels: { zalouser: { enabled: true } } } as never,
+ to: "group:123456",
+ text: "hello world\nthis is a test",
+ accountId: "default",
+ } as never);
+
+ expect(mockSendMessage).toHaveBeenCalledWith(
+ "123456",
+ "hello world\nthis is a test",
+ expect.objectContaining({
+ profile: "default",
+ isGroup: true,
+ textMode: "markdown",
+ textChunkMode: "newline",
+ textChunkLimit: 10,
+ }),
+ );
+ expect(result).toEqual(
+ expect.objectContaining({
+ channel: "zalouser",
+ messageId: "mid-1",
+ ok: true,
+ }),
+ );
});
});
diff --git a/extensions/zalouser/src/channel.ts b/extensions/zalouser/src/channel.ts
index 2091124be6e..d2f7a714537 100644
--- a/extensions/zalouser/src/channel.ts
+++ b/extensions/zalouser/src/channel.ts
@@ -20,9 +20,9 @@ import {
buildBaseAccountStatusSnapshot,
buildChannelConfigSchema,
DEFAULT_ACCOUNT_ID,
- chunkTextForOutbound,
deleteAccountFromConfigSection,
formatAllowFromLowercase,
+ isDangerousNameMatchingEnabled,
isNumericTargetId,
migrateBaseNameToDefaultAccount,
normalizeAccountId,
@@ -43,6 +43,7 @@ import { resolveZalouserReactionMessageIds } from "./message-sid.js";
import { zalouserOnboardingAdapter } from "./onboarding.js";
import { probeZalouser } from "./probe.js";
import { writeQrDataUrlToTempFile } from "./qr-temp-file.js";
+import { getZalouserRuntime } from "./runtime.js";
import { sendMessageZalouser, sendReactionZalouser } from "./send.js";
import { collectZalouserStatusIssues } from "./status-issues.js";
import {
@@ -166,6 +167,16 @@ function resolveZalouserQrProfile(accountId?: string | null): string {
return normalized;
}
+function resolveZalouserOutboundChunkMode(cfg: OpenClawConfig, accountId?: string) {
+ return getZalouserRuntime().channel.text.resolveChunkMode(cfg, "zalouser", accountId);
+}
+
+function resolveZalouserOutboundTextChunkLimit(cfg: OpenClawConfig, accountId?: string) {
+ return getZalouserRuntime().channel.text.resolveTextChunkLimit(cfg, "zalouser", accountId, {
+ fallbackLimit: zalouserDock.outbound?.textChunkLimit ?? 2000,
+ });
+}
+
function mapUser(params: {
id: string;
name?: string | null;
@@ -206,6 +217,7 @@ function resolveZalouserGroupPolicyEntry(params: ChannelGroupContext) {
groupId: params.groupId,
groupChannel: params.groupChannel,
includeWildcard: true,
+ allowNameMatching: isDangerousNameMatchingEnabled(account.config),
}),
);
}
@@ -595,14 +607,11 @@ export const zalouserPlugin: ChannelPlugin = {
},
outbound: {
deliveryMode: "direct",
- chunker: chunkTextForOutbound,
- chunkerMode: "text",
- textChunkLimit: 2000,
+ chunker: (text, limit) => getZalouserRuntime().channel.text.chunkMarkdownText(text, limit),
+ chunkerMode: "markdown",
sendPayload: async (ctx) =>
await sendPayloadWithChunkedTextAndMedia({
ctx,
- textChunkLimit: zalouserPlugin.outbound!.textChunkLimit,
- chunker: zalouserPlugin.outbound!.chunker,
sendText: (nextCtx) => zalouserPlugin.outbound!.sendText!(nextCtx),
sendMedia: (nextCtx) => zalouserPlugin.outbound!.sendMedia!(nextCtx),
emptyResult: { channel: "zalouser", messageId: "" },
@@ -613,6 +622,9 @@ export const zalouserPlugin: ChannelPlugin = {
const result = await sendMessageZalouser(target.threadId, text, {
profile: account.profile,
isGroup: target.isGroup,
+ textMode: "markdown",
+ textChunkMode: resolveZalouserOutboundChunkMode(cfg, account.accountId),
+ textChunkLimit: resolveZalouserOutboundTextChunkLimit(cfg, account.accountId),
});
return buildChannelSendResult("zalouser", result);
},
@@ -624,6 +636,9 @@ export const zalouserPlugin: ChannelPlugin = {
isGroup: target.isGroup,
mediaUrl,
mediaLocalRoots,
+ textMode: "markdown",
+ textChunkMode: resolveZalouserOutboundChunkMode(cfg, account.accountId),
+ textChunkLimit: resolveZalouserOutboundTextChunkLimit(cfg, account.accountId),
});
return buildChannelSendResult("zalouser", result);
},
diff --git a/extensions/zalouser/src/config-schema.ts b/extensions/zalouser/src/config-schema.ts
index 4879a2d46cd..1ff115876c4 100644
--- a/extensions/zalouser/src/config-schema.ts
+++ b/extensions/zalouser/src/config-schema.ts
@@ -19,6 +19,7 @@ const zalouserAccountSchema = z.object({
enabled: z.boolean().optional(),
markdown: MarkdownConfigSchema,
profile: z.string().optional(),
+ dangerouslyAllowNameMatching: z.boolean().optional(),
dmPolicy: DmPolicySchema.optional(),
allowFrom: AllowFromListSchema,
historyLimit: z.number().int().min(0).optional(),
diff --git a/extensions/zalouser/src/group-policy.test.ts b/extensions/zalouser/src/group-policy.test.ts
index 0ab0e01d763..adbeffbe86f 100644
--- a/extensions/zalouser/src/group-policy.test.ts
+++ b/extensions/zalouser/src/group-policy.test.ts
@@ -23,6 +23,18 @@ describe("zalouser group policy helpers", () => {
).toEqual(["123", "group:123", "chan-1", "Team Alpha", "team-alpha", "*"]);
});
+ it("builds id-only candidates when name matching is disabled", () => {
+ expect(
+ buildZalouserGroupCandidates({
+ groupId: "123",
+ groupChannel: "chan-1",
+ groupName: "Team Alpha",
+ includeGroupIdAlias: true,
+ allowNameMatching: false,
+ }),
+ ).toEqual(["123", "group:123", "*"]);
+ });
+
it("finds the first matching group entry", () => {
const groups = {
"group:123": { allow: true },
diff --git a/extensions/zalouser/src/group-policy.ts b/extensions/zalouser/src/group-policy.ts
index 1b6ca8e200e..4d116f15bf2 100644
--- a/extensions/zalouser/src/group-policy.ts
+++ b/extensions/zalouser/src/group-policy.ts
@@ -23,6 +23,7 @@ export function buildZalouserGroupCandidates(params: {
groupName?: string | null;
includeGroupIdAlias?: boolean;
includeWildcard?: boolean;
+ allowNameMatching?: boolean;
}): string[] {
const seen = new Set();
const out: string[] = [];
@@ -43,10 +44,12 @@ export function buildZalouserGroupCandidates(params: {
if (params.includeGroupIdAlias === true && groupId) {
push(`group:${groupId}`);
}
- push(groupChannel);
- push(groupName);
- if (groupName) {
- push(normalizeZalouserGroupSlug(groupName));
+ if (params.allowNameMatching !== false) {
+ push(groupChannel);
+ push(groupName);
+ if (groupName) {
+ push(normalizeZalouserGroupSlug(groupName));
+ }
}
if (params.includeWildcard !== false) {
push("*");
diff --git a/extensions/zalouser/src/monitor.group-gating.test.ts b/extensions/zalouser/src/monitor.group-gating.test.ts
index b3e38efecd6..f6723cad3d7 100644
--- a/extensions/zalouser/src/monitor.group-gating.test.ts
+++ b/extensions/zalouser/src/monitor.group-gating.test.ts
@@ -51,6 +51,7 @@ function createRuntimeEnv(): RuntimeEnv {
function installRuntime(params: {
commandAuthorized?: boolean;
+ replyPayload?: { text?: string; mediaUrl?: string; mediaUrls?: string[] };
resolveCommandAuthorizedFromAuthorizers?: (params: {
useAccessGroups: boolean;
authorizers: Array<{ configured: boolean; allowed: boolean }>;
@@ -58,6 +59,9 @@ function installRuntime(params: {
}) {
const dispatchReplyWithBufferedBlockDispatcher = vi.fn(async ({ dispatcherOptions, ctx }) => {
await dispatcherOptions.typingCallbacks?.onReplyStart?.();
+ if (params.replyPayload) {
+ await dispatcherOptions.deliver(params.replyPayload);
+ }
return { queuedFinal: false, counts: { tool: 0, block: 0, final: 0 }, ctx };
});
const resolveCommandAuthorizedFromAuthorizers = vi.fn(
@@ -166,7 +170,8 @@ function installRuntime(params: {
text: {
resolveMarkdownTableMode: vi.fn(() => "code"),
convertMarkdownTables: vi.fn((text: string) => text),
- resolveChunkMode: vi.fn(() => "line"),
+ resolveChunkMode: vi.fn(() => "length"),
+ resolveTextChunkLimit: vi.fn(() => 1200),
chunkMarkdownTextWithMode: vi.fn((text: string) => [text]),
},
},
@@ -304,6 +309,42 @@ describe("zalouser monitor group mention gating", () => {
expect(callArg?.ctx?.WasMentioned).toBe(true);
});
+ it("passes long markdown replies through once so formatting happens before chunking", async () => {
+ const replyText = `**${"a".repeat(2501)}**`;
+ installRuntime({
+ commandAuthorized: false,
+ replyPayload: { text: replyText },
+ });
+
+ await __testing.processMessage({
+ message: createDmMessage({
+ content: "hello",
+ }),
+ account: {
+ ...createAccount(),
+ config: {
+ ...createAccount().config,
+ dmPolicy: "open",
+ },
+ },
+ config: createConfig(),
+ runtime: createRuntimeEnv(),
+ });
+
+ expect(sendMessageZalouserMock).toHaveBeenCalledTimes(1);
+ expect(sendMessageZalouserMock).toHaveBeenCalledWith(
+ "u-1",
+ replyText,
+ expect.objectContaining({
+ isGroup: false,
+ profile: "default",
+ textMode: "markdown",
+ textChunkMode: "length",
+ textChunkLimit: 1200,
+ }),
+ );
+ });
+
it("uses commandContent for mention-prefixed control commands", async () => {
const { dispatchReplyWithBufferedBlockDispatcher } = installRuntime({
commandAuthorized: true,
@@ -383,6 +424,73 @@ describe("zalouser monitor group mention gating", () => {
expect(dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
});
+ it("does not accept a different group id by matching only the mutable group name by default", async () => {
+ const { dispatchReplyWithBufferedBlockDispatcher } = installRuntime({
+ commandAuthorized: false,
+ });
+ await __testing.processMessage({
+ message: createGroupMessage({
+ threadId: "g-attacker-001",
+ groupName: "Trusted Team",
+ senderId: "666",
+ hasAnyMention: true,
+ wasExplicitlyMentioned: true,
+ content: "ping @bot",
+ }),
+ account: {
+ ...createAccount(),
+ config: {
+ ...createAccount().config,
+ groupPolicy: "allowlist",
+ groupAllowFrom: ["*"],
+ groups: {
+ "group:g-trusted-001": { allow: true },
+ "Trusted Team": { allow: true },
+ },
+ },
+ },
+ config: createConfig(),
+ runtime: createRuntimeEnv(),
+ });
+
+ expect(dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
+ });
+
+ it("accepts mutable group-name matches only when dangerouslyAllowNameMatching is enabled", async () => {
+ const { dispatchReplyWithBufferedBlockDispatcher } = installRuntime({
+ commandAuthorized: false,
+ });
+ await __testing.processMessage({
+ message: createGroupMessage({
+ threadId: "g-attacker-001",
+ groupName: "Trusted Team",
+ senderId: "666",
+ hasAnyMention: true,
+ wasExplicitlyMentioned: true,
+ content: "ping @bot",
+ }),
+ account: {
+ ...createAccount(),
+ config: {
+ ...createAccount().config,
+ dangerouslyAllowNameMatching: true,
+ groupPolicy: "allowlist",
+ groupAllowFrom: ["*"],
+ groups: {
+ "group:g-trusted-001": { allow: true },
+ "Trusted Team": { allow: true },
+ },
+ },
+ },
+ config: createConfig(),
+ runtime: createRuntimeEnv(),
+ });
+
+ expect(dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledTimes(1);
+ const callArg = dispatchReplyWithBufferedBlockDispatcher.mock.calls[0]?.[0];
+ expect(callArg?.ctx?.To).toBe("zalouser:group:g-attacker-001");
+ });
+
it("allows group control commands when sender is in groupAllowFrom", async () => {
const { dispatchReplyWithBufferedBlockDispatcher, resolveCommandAuthorizedFromAuthorizers } =
installRuntime({
diff --git a/extensions/zalouser/src/monitor.ts b/extensions/zalouser/src/monitor.ts
index 6590082e830..3ba7e80d2b9 100644
--- a/extensions/zalouser/src/monitor.ts
+++ b/extensions/zalouser/src/monitor.ts
@@ -19,6 +19,7 @@ import {
createScopedPairingAccess,
createReplyPrefixOptions,
evaluateGroupRouteAccessForPolicy,
+ isDangerousNameMatchingEnabled,
issuePairingChallenge,
resolveOutboundMediaUrls,
mergeAllowlist,
@@ -212,6 +213,7 @@ function resolveGroupRequireMention(params: {
groupId: string;
groupName?: string | null;
groups: Record;
+ allowNameMatching?: boolean;
}): boolean {
const entry = findZalouserGroupEntry(
params.groups ?? {},
@@ -220,6 +222,7 @@ function resolveGroupRequireMention(params: {
groupName: params.groupName,
includeGroupIdAlias: true,
includeWildcard: true,
+ allowNameMatching: params.allowNameMatching,
}),
);
if (typeof entry?.requireMention === "boolean") {
@@ -316,6 +319,7 @@ async function processMessage(
});
const groups = account.config.groups ?? {};
+ const allowNameMatching = isDangerousNameMatchingEnabled(account.config);
if (isGroup) {
const groupEntry = findZalouserGroupEntry(
groups,
@@ -324,6 +328,7 @@ async function processMessage(
groupName,
includeGroupIdAlias: true,
includeWildcard: true,
+ allowNameMatching,
}),
);
const routeAccess = evaluateGroupRouteAccessForPolicy({
@@ -466,6 +471,7 @@ async function processMessage(
groupId: chatId,
groupName,
groups,
+ allowNameMatching,
})
: false;
const mentionRegexes = core.channel.mentions.buildMentionRegexes(config, route.agentId);
@@ -703,6 +709,10 @@ async function deliverZalouserReply(params: {
params;
const tableMode = params.tableMode ?? "code";
const text = core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode);
+ const chunkMode = core.channel.text.resolveChunkMode(config, "zalouser", accountId);
+ const textChunkLimit = core.channel.text.resolveTextChunkLimit(config, "zalouser", accountId, {
+ fallbackLimit: ZALOUSER_TEXT_LIMIT,
+ });
const sentMedia = await sendMediaWithLeadingCaption({
mediaUrls: resolveOutboundMediaUrls(payload),
@@ -713,6 +723,9 @@ async function deliverZalouserReply(params: {
profile,
mediaUrl,
isGroup,
+ textMode: "markdown",
+ textChunkMode: chunkMode,
+ textChunkLimit,
});
statusSink?.({ lastOutboundAt: Date.now() });
},
@@ -725,20 +738,17 @@ async function deliverZalouserReply(params: {
}
if (text) {
- const chunkMode = core.channel.text.resolveChunkMode(config, "zalouser", accountId);
- const chunks = core.channel.text.chunkMarkdownTextWithMode(
- text,
- ZALOUSER_TEXT_LIMIT,
- chunkMode,
- );
- logVerbose(core, runtime, `Sending ${chunks.length} text chunk(s) to ${chatId}`);
- for (const chunk of chunks) {
- try {
- await sendMessageZalouser(chatId, chunk, { profile, isGroup });
- statusSink?.({ lastOutboundAt: Date.now() });
- } catch (err) {
- runtime.error(`Zalouser message send failed: ${String(err)}`);
- }
+ try {
+ await sendMessageZalouser(chatId, text, {
+ profile,
+ isGroup,
+ textMode: "markdown",
+ textChunkMode: chunkMode,
+ textChunkLimit,
+ });
+ statusSink?.({ lastOutboundAt: Date.now() });
+ } catch (err) {
+ runtime.error(`Zalouser message send failed: ${String(err)}`);
}
}
}
diff --git a/extensions/zalouser/src/send.test.ts b/extensions/zalouser/src/send.test.ts
index 92b3cec25f2..cc920e6be7e 100644
--- a/extensions/zalouser/src/send.test.ts
+++ b/extensions/zalouser/src/send.test.ts
@@ -8,6 +8,7 @@ import {
sendSeenZalouser,
sendTypingZalouser,
} from "./send.js";
+import { parseZalouserTextStyles } from "./text-styles.js";
import {
sendZaloDeliveredEvent,
sendZaloLink,
@@ -16,6 +17,7 @@ import {
sendZaloTextMessage,
sendZaloTypingEvent,
} from "./zalo-js.js";
+import { TextStyle } from "./zca-client.js";
vi.mock("./zalo-js.js", () => ({
sendZaloTextMessage: vi.fn(),
@@ -43,36 +45,272 @@ describe("zalouser send helpers", () => {
mockSendSeen.mockReset();
});
- it("delegates text send to JS transport", async () => {
+ it("keeps plain text literal by default", async () => {
mockSendText.mockResolvedValueOnce({ ok: true, messageId: "mid-1" });
- const result = await sendMessageZalouser("thread-1", "hello", {
+ const result = await sendMessageZalouser("thread-1", "**hello**", {
profile: "default",
isGroup: true,
});
- expect(mockSendText).toHaveBeenCalledWith("thread-1", "hello", {
- profile: "default",
- isGroup: true,
- });
+ expect(mockSendText).toHaveBeenCalledWith(
+ "thread-1",
+ "**hello**",
+ expect.objectContaining({
+ profile: "default",
+ isGroup: true,
+ }),
+ );
expect(result).toEqual({ ok: true, messageId: "mid-1" });
});
- it("maps image helper to media send", async () => {
+ it("formats markdown text when markdown mode is enabled", async () => {
+ mockSendText.mockResolvedValueOnce({ ok: true, messageId: "mid-1b" });
+
+ await sendMessageZalouser("thread-1", "**hello**", {
+ profile: "default",
+ isGroup: true,
+ textMode: "markdown",
+ });
+
+ expect(mockSendText).toHaveBeenCalledWith(
+ "thread-1",
+ "hello",
+ expect.objectContaining({
+ profile: "default",
+ isGroup: true,
+ textMode: "markdown",
+ textStyles: [{ start: 0, len: 5, st: TextStyle.Bold }],
+ }),
+ );
+ });
+
+ it("formats image captions in markdown mode", async () => {
mockSendText.mockResolvedValueOnce({ ok: true, messageId: "mid-2" });
await sendImageZalouser("thread-2", "https://example.com/a.png", {
profile: "p2",
- caption: "cap",
+ caption: "_cap_",
isGroup: false,
+ textMode: "markdown",
});
- expect(mockSendText).toHaveBeenCalledWith("thread-2", "cap", {
+ expect(mockSendText).toHaveBeenCalledWith(
+ "thread-2",
+ "cap",
+ expect.objectContaining({
+ profile: "p2",
+ caption: undefined,
+ isGroup: false,
+ mediaUrl: "https://example.com/a.png",
+ textMode: "markdown",
+ textStyles: [{ start: 0, len: 3, st: TextStyle.Italic }],
+ }),
+ );
+ });
+
+ it("does not keep the raw markdown caption as a media fallback after formatting", async () => {
+ mockSendText.mockResolvedValueOnce({ ok: true, messageId: "mid-2b" });
+
+ await sendImageZalouser("thread-2", "https://example.com/a.png", {
profile: "p2",
- caption: "cap",
+ caption: "```\n```",
isGroup: false,
- mediaUrl: "https://example.com/a.png",
+ textMode: "markdown",
});
+
+ expect(mockSendText).toHaveBeenCalledWith(
+ "thread-2",
+ "",
+ expect.objectContaining({
+ profile: "p2",
+ caption: undefined,
+ isGroup: false,
+ mediaUrl: "https://example.com/a.png",
+ textMode: "markdown",
+ textStyles: undefined,
+ }),
+ );
+ });
+
+ it("rechunks normalized markdown text before sending to avoid transport truncation", async () => {
+ const text = "\t".repeat(500) + "a".repeat(1500);
+ const formatted = parseZalouserTextStyles(text);
+ mockSendText
+ .mockResolvedValueOnce({ ok: true, messageId: "mid-2c-1" })
+ .mockResolvedValueOnce({ ok: true, messageId: "mid-2c-2" });
+
+ const result = await sendMessageZalouser("thread-2c", text, {
+ profile: "p2c",
+ isGroup: false,
+ textMode: "markdown",
+ });
+
+ expect(formatted.text.length).toBeGreaterThan(2000);
+ expect(mockSendText).toHaveBeenCalledTimes(2);
+ expect(mockSendText.mock.calls.map((call) => call[1]).join("")).toBe(formatted.text);
+ expect(mockSendText.mock.calls.every((call) => (call[1] as string).length <= 2000)).toBe(true);
+ expect(result).toEqual({ ok: true, messageId: "mid-2c-2" });
+ });
+
+ it("preserves text styles when splitting long formatted markdown", async () => {
+ const text = `**${"a".repeat(2501)}**`;
+ mockSendText
+ .mockResolvedValueOnce({ ok: true, messageId: "mid-2d-1" })
+ .mockResolvedValueOnce({ ok: true, messageId: "mid-2d-2" });
+
+ const result = await sendMessageZalouser("thread-2d", text, {
+ profile: "p2d",
+ isGroup: false,
+ textMode: "markdown",
+ });
+
+ expect(mockSendText).toHaveBeenNthCalledWith(
+ 1,
+ "thread-2d",
+ "a".repeat(2000),
+ expect.objectContaining({
+ profile: "p2d",
+ isGroup: false,
+ textMode: "markdown",
+ textStyles: [{ start: 0, len: 2000, st: TextStyle.Bold }],
+ }),
+ );
+ expect(mockSendText).toHaveBeenNthCalledWith(
+ 2,
+ "thread-2d",
+ "a".repeat(501),
+ expect.objectContaining({
+ profile: "p2d",
+ isGroup: false,
+ textMode: "markdown",
+ textStyles: [{ start: 0, len: 501, st: TextStyle.Bold }],
+ }),
+ );
+ expect(result).toEqual({ ok: true, messageId: "mid-2d-2" });
+ });
+
+ it("preserves formatted text and styles when newline chunk mode splits after parsing", async () => {
+ const text = `**${"a".repeat(1995)}**\n\nsecond paragraph`;
+ const formatted = parseZalouserTextStyles(text);
+ mockSendText
+ .mockResolvedValueOnce({ ok: true, messageId: "mid-2d-3" })
+ .mockResolvedValueOnce({ ok: true, messageId: "mid-2d-4" });
+
+ const result = await sendMessageZalouser("thread-2d-2", text, {
+ profile: "p2d-2",
+ isGroup: false,
+ textMode: "markdown",
+ textChunkMode: "newline",
+ });
+
+ expect(mockSendText).toHaveBeenCalledTimes(2);
+ expect(mockSendText.mock.calls.map((call) => call[1]).join("")).toBe(formatted.text);
+ expect(mockSendText).toHaveBeenNthCalledWith(
+ 1,
+ "thread-2d-2",
+ `${"a".repeat(1995)}\n\n`,
+ expect.objectContaining({
+ profile: "p2d-2",
+ isGroup: false,
+ textMode: "markdown",
+ textChunkMode: "newline",
+ textStyles: [{ start: 0, len: 1995, st: TextStyle.Bold }],
+ }),
+ );
+ expect(mockSendText).toHaveBeenNthCalledWith(
+ 2,
+ "thread-2d-2",
+ "second paragraph",
+ expect.objectContaining({
+ profile: "p2d-2",
+ isGroup: false,
+ textMode: "markdown",
+ textChunkMode: "newline",
+ textStyles: undefined,
+ }),
+ );
+ expect(result).toEqual({ ok: true, messageId: "mid-2d-4" });
+ });
+
+ it("respects an explicit text chunk limit when splitting formatted markdown", async () => {
+ const text = `**${"a".repeat(1501)}**`;
+ mockSendText
+ .mockResolvedValueOnce({ ok: true, messageId: "mid-2d-5" })
+ .mockResolvedValueOnce({ ok: true, messageId: "mid-2d-6" });
+
+ const result = await sendMessageZalouser("thread-2d-3", text, {
+ profile: "p2d-3",
+ isGroup: false,
+ textMode: "markdown",
+ textChunkLimit: 1200,
+ } as never);
+
+ expect(mockSendText).toHaveBeenCalledTimes(2);
+ expect(mockSendText).toHaveBeenNthCalledWith(
+ 1,
+ "thread-2d-3",
+ "a".repeat(1200),
+ expect.objectContaining({
+ profile: "p2d-3",
+ isGroup: false,
+ textMode: "markdown",
+ textChunkLimit: 1200,
+ textStyles: [{ start: 0, len: 1200, st: TextStyle.Bold }],
+ }),
+ );
+ expect(mockSendText).toHaveBeenNthCalledWith(
+ 2,
+ "thread-2d-3",
+ "a".repeat(301),
+ expect.objectContaining({
+ profile: "p2d-3",
+ isGroup: false,
+ textMode: "markdown",
+ textChunkLimit: 1200,
+ textStyles: [{ start: 0, len: 301, st: TextStyle.Bold }],
+ }),
+ );
+ expect(result).toEqual({ ok: true, messageId: "mid-2d-6" });
+ });
+
+ it("sends overflow markdown captions as follow-up text after the media message", async () => {
+ const caption = "\t".repeat(500) + "a".repeat(1500);
+ const formatted = parseZalouserTextStyles(caption);
+ mockSendText
+ .mockResolvedValueOnce({ ok: true, messageId: "mid-2e-1" })
+ .mockResolvedValueOnce({ ok: true, messageId: "mid-2e-2" });
+
+ const result = await sendImageZalouser("thread-2e", "https://example.com/long.png", {
+ profile: "p2e",
+ caption,
+ isGroup: false,
+ textMode: "markdown",
+ });
+
+ expect(mockSendText).toHaveBeenCalledTimes(2);
+ expect(mockSendText.mock.calls.map((call) => call[1]).join("")).toBe(formatted.text);
+ expect(mockSendText).toHaveBeenNthCalledWith(
+ 1,
+ "thread-2e",
+ expect.any(String),
+ expect.objectContaining({
+ profile: "p2e",
+ caption: undefined,
+ isGroup: false,
+ mediaUrl: "https://example.com/long.png",
+ textMode: "markdown",
+ }),
+ );
+ expect(mockSendText).toHaveBeenNthCalledWith(
+ 2,
+ "thread-2e",
+ expect.any(String),
+ expect.not.objectContaining({
+ mediaUrl: "https://example.com/long.png",
+ }),
+ );
+ expect(result).toEqual({ ok: true, messageId: "mid-2e-2" });
});
it("delegates link helper to JS transport", async () => {
diff --git a/extensions/zalouser/src/send.ts b/extensions/zalouser/src/send.ts
index 07ae1408bff..55ff17df636 100644
--- a/extensions/zalouser/src/send.ts
+++ b/extensions/zalouser/src/send.ts
@@ -1,3 +1,4 @@
+import { parseZalouserTextStyles } from "./text-styles.js";
import type { ZaloEventMessage, ZaloSendOptions, ZaloSendResult } from "./types.js";
import {
sendZaloDeliveredEvent,
@@ -7,16 +8,58 @@ import {
sendZaloTextMessage,
sendZaloTypingEvent,
} from "./zalo-js.js";
+import { TextStyle } from "./zca-client.js";
export type ZalouserSendOptions = ZaloSendOptions;
export type ZalouserSendResult = ZaloSendResult;
+const ZALO_TEXT_LIMIT = 2000;
+const DEFAULT_TEXT_CHUNK_MODE = "length";
+
+type StyledTextChunk = {
+ text: string;
+ styles?: ZaloSendOptions["textStyles"];
+};
+
+type TextChunkMode = NonNullable;
+
export async function sendMessageZalouser(
threadId: string,
text: string,
options: ZalouserSendOptions = {},
): Promise {
- return await sendZaloTextMessage(threadId, text, options);
+ const prepared =
+ options.textMode === "markdown"
+ ? parseZalouserTextStyles(text)
+ : { text, styles: options.textStyles };
+ const textChunkLimit = options.textChunkLimit ?? ZALO_TEXT_LIMIT;
+ const chunks = splitStyledText(
+ prepared.text,
+ (prepared.styles?.length ?? 0) > 0 ? prepared.styles : undefined,
+ textChunkLimit,
+ options.textChunkMode,
+ );
+
+ let lastResult: ZalouserSendResult | null = null;
+ for (const [index, chunk] of chunks.entries()) {
+ const chunkOptions =
+ index === 0
+ ? { ...options, textStyles: chunk.styles }
+ : {
+ ...options,
+ caption: undefined,
+ mediaLocalRoots: undefined,
+ mediaUrl: undefined,
+ textStyles: chunk.styles,
+ };
+ const result = await sendZaloTextMessage(threadId, chunk.text, chunkOptions);
+ if (!result.ok) {
+ return result;
+ }
+ lastResult = result;
+ }
+
+ return lastResult ?? { ok: false, error: "No message content provided" };
}
export async function sendImageZalouser(
@@ -24,8 +67,9 @@ export async function sendImageZalouser(
imageUrl: string,
options: ZalouserSendOptions = {},
): Promise {
- return await sendZaloTextMessage(threadId, options.caption ?? "", {
+ return await sendMessageZalouser(threadId, options.caption ?? "", {
...options,
+ caption: undefined,
mediaUrl: imageUrl,
});
}
@@ -85,3 +129,144 @@ export async function sendSeenZalouser(params: {
}): Promise {
await sendZaloSeenEvent(params);
}
+
+function splitStyledText(
+ text: string,
+ styles: ZaloSendOptions["textStyles"],
+ limit: number,
+ mode: ZaloSendOptions["textChunkMode"],
+): StyledTextChunk[] {
+ if (text.length === 0) {
+ return [{ text, styles: undefined }];
+ }
+
+ const chunks: StyledTextChunk[] = [];
+ for (const range of splitTextRanges(text, limit, mode ?? DEFAULT_TEXT_CHUNK_MODE)) {
+ const { start, end } = range;
+ chunks.push({
+ text: text.slice(start, end),
+ styles: sliceTextStyles(styles, start, end),
+ });
+ }
+ return chunks;
+}
+
+function sliceTextStyles(
+ styles: ZaloSendOptions["textStyles"],
+ start: number,
+ end: number,
+): ZaloSendOptions["textStyles"] {
+ if (!styles || styles.length === 0) {
+ return undefined;
+ }
+
+ const chunkStyles = styles
+ .map((style) => {
+ const overlapStart = Math.max(style.start, start);
+ const overlapEnd = Math.min(style.start + style.len, end);
+ if (overlapEnd <= overlapStart) {
+ return null;
+ }
+
+ if (style.st === TextStyle.Indent) {
+ return {
+ start: overlapStart - start,
+ len: overlapEnd - overlapStart,
+ st: style.st,
+ indentSize: style.indentSize,
+ };
+ }
+
+ return {
+ start: overlapStart - start,
+ len: overlapEnd - overlapStart,
+ st: style.st,
+ };
+ })
+ .filter((style): style is NonNullable => style !== null);
+
+ return chunkStyles.length > 0 ? chunkStyles : undefined;
+}
+
+function splitTextRanges(
+ text: string,
+ limit: number,
+ mode: TextChunkMode,
+): Array<{ start: number; end: number }> {
+ if (mode === "newline") {
+ return splitTextRangesByPreferredBreaks(text, limit);
+ }
+
+ const ranges: Array<{ start: number; end: number }> = [];
+ for (let start = 0; start < text.length; start += limit) {
+ ranges.push({
+ start,
+ end: Math.min(text.length, start + limit),
+ });
+ }
+ return ranges;
+}
+
+function splitTextRangesByPreferredBreaks(
+ text: string,
+ limit: number,
+): Array<{ start: number; end: number }> {
+ const ranges: Array<{ start: number; end: number }> = [];
+ let start = 0;
+
+ while (start < text.length) {
+ const maxEnd = Math.min(text.length, start + limit);
+ let end = maxEnd;
+ if (maxEnd < text.length) {
+ end =
+ findParagraphBreak(text, start, maxEnd) ??
+ findLastBreak(text, "\n", start, maxEnd) ??
+ findLastWhitespaceBreak(text, start, maxEnd) ??
+ maxEnd;
+ }
+
+ if (end <= start) {
+ end = maxEnd;
+ }
+
+ ranges.push({ start, end });
+ start = end;
+ }
+
+ return ranges;
+}
+
+function findParagraphBreak(text: string, start: number, end: number): number | undefined {
+ const slice = text.slice(start, end);
+ const matches = slice.matchAll(/\n[\t ]*\n+/g);
+ let lastMatch: RegExpMatchArray | undefined;
+ for (const match of matches) {
+ lastMatch = match;
+ }
+ if (!lastMatch || lastMatch.index === undefined) {
+ return undefined;
+ }
+ return start + lastMatch.index + lastMatch[0].length;
+}
+
+function findLastBreak(
+ text: string,
+ marker: string,
+ start: number,
+ end: number,
+): number | undefined {
+ const index = text.lastIndexOf(marker, end - 1);
+ if (index < start) {
+ return undefined;
+ }
+ return index + marker.length;
+}
+
+function findLastWhitespaceBreak(text: string, start: number, end: number): number | undefined {
+ for (let index = end - 1; index > start; index -= 1) {
+ if (/\s/.test(text[index])) {
+ return index + 1;
+ }
+ }
+ return undefined;
+}
diff --git a/extensions/zalouser/src/text-styles.test.ts b/extensions/zalouser/src/text-styles.test.ts
new file mode 100644
index 00000000000..01e6c2da86b
--- /dev/null
+++ b/extensions/zalouser/src/text-styles.test.ts
@@ -0,0 +1,203 @@
+import { describe, expect, it } from "vitest";
+import { parseZalouserTextStyles } from "./text-styles.js";
+import { TextStyle } from "./zca-client.js";
+
+describe("parseZalouserTextStyles", () => {
+ it("renders inline markdown emphasis as Zalo style ranges", () => {
+ expect(parseZalouserTextStyles("**bold** *italic* ~~strike~~")).toEqual({
+ text: "bold italic strike",
+ styles: [
+ { start: 0, len: 4, st: TextStyle.Bold },
+ { start: 5, len: 6, st: TextStyle.Italic },
+ { start: 12, len: 6, st: TextStyle.StrikeThrough },
+ ],
+ });
+ });
+
+ it("keeps inline code and plain math markers literal", () => {
+ expect(parseZalouserTextStyles("before `inline *code*` after\n2 * 3 * 4")).toEqual({
+ text: "before `inline *code*` after\n2 * 3 * 4",
+ styles: [],
+ });
+ });
+
+ it("preserves backslash escapes inside code spans and fenced code blocks", () => {
+ expect(parseZalouserTextStyles("before `\\*` after\n```ts\n\\*\\_\\\\\n```")).toEqual({
+ text: "before `\\*` after\n\\*\\_\\\\",
+ styles: [],
+ });
+ });
+
+ it("closes fenced code blocks when the input uses CRLF newlines", () => {
+ expect(parseZalouserTextStyles("```\r\n*code*\r\n```\r\n**after**")).toEqual({
+ text: "*code*\nafter",
+ styles: [{ start: 7, len: 5, st: TextStyle.Bold }],
+ });
+ });
+
+ it("maps headings, block quotes, and lists into line styles", () => {
+ expect(parseZalouserTextStyles(["# Title", "> quoted", " - nested"].join("\n"))).toEqual({
+ text: "Title\nquoted\nnested",
+ styles: [
+ { start: 0, len: 5, st: TextStyle.Bold },
+ { start: 0, len: 5, st: TextStyle.Big },
+ { start: 6, len: 6, st: TextStyle.Indent, indentSize: 1 },
+ { start: 13, len: 6, st: TextStyle.UnorderedList },
+ ],
+ });
+ });
+
+ it("treats 1-3 leading spaces as markdown padding for headings and lists", () => {
+ expect(parseZalouserTextStyles(" # Title\n 1. item\n - bullet")).toEqual({
+ text: "Title\nitem\nbullet",
+ styles: [
+ { start: 0, len: 5, st: TextStyle.Bold },
+ { start: 0, len: 5, st: TextStyle.Big },
+ { start: 6, len: 4, st: TextStyle.OrderedList },
+ { start: 11, len: 6, st: TextStyle.UnorderedList },
+ ],
+ });
+ });
+
+ it("strips fenced code markers and preserves leading indentation with nbsp", () => {
+ expect(parseZalouserTextStyles("```ts\n const x = 1\n\treturn x\n```")).toEqual({
+ text: "\u00A0\u00A0const x = 1\n\u00A0\u00A0\u00A0\u00A0return x",
+ styles: [],
+ });
+ });
+
+ it("treats tilde fences as literal code blocks", () => {
+ expect(parseZalouserTextStyles("~~~bash\n*cmd*\n~~~")).toEqual({
+ text: "*cmd*",
+ styles: [],
+ });
+ });
+
+ it("treats fences indented under list items as literal code blocks", () => {
+ expect(parseZalouserTextStyles(" ```\n*cmd*\n ```")).toEqual({
+ text: "*cmd*",
+ styles: [],
+ });
+ });
+
+ it("treats quoted backtick fences as literal code blocks", () => {
+ expect(parseZalouserTextStyles("> ```js\n> *cmd*\n> ```")).toEqual({
+ text: "*cmd*",
+ styles: [],
+ });
+ });
+
+ it("treats quoted tilde fences as literal code blocks", () => {
+ expect(parseZalouserTextStyles("> ~~~\n> *cmd*\n> ~~~")).toEqual({
+ text: "*cmd*",
+ styles: [],
+ });
+ });
+
+ it("preserves quote-prefixed lines inside normal fenced code blocks", () => {
+ expect(parseZalouserTextStyles("```\n> prompt\n```")).toEqual({
+ text: "> prompt",
+ styles: [],
+ });
+ });
+
+ it("does not treat quote-prefixed fence text inside code as a closing fence", () => {
+ expect(parseZalouserTextStyles("```\n> ```\n*still code*\n```")).toEqual({
+ text: "> ```\n*still code*",
+ styles: [],
+ });
+ });
+
+ it("treats indented blockquotes as quoted lines", () => {
+ expect(parseZalouserTextStyles(" > quoted")).toEqual({
+ text: "quoted",
+ styles: [{ start: 0, len: 6, st: TextStyle.Indent, indentSize: 1 }],
+ });
+ });
+
+ it("treats spaced nested blockquotes as deeper quoted lines", () => {
+ expect(parseZalouserTextStyles("> > quoted")).toEqual({
+ text: "quoted",
+ styles: [{ start: 0, len: 6, st: TextStyle.Indent, indentSize: 2 }],
+ });
+ });
+
+ it("treats indented quoted fences as literal code blocks", () => {
+ expect(parseZalouserTextStyles(" > ```\n > *cmd*\n > ```")).toEqual({
+ text: "*cmd*",
+ styles: [],
+ });
+ });
+
+ it("treats spaced nested quoted fences as literal code blocks", () => {
+ expect(parseZalouserTextStyles("> > ```\n> > code\n> > ```")).toEqual({
+ text: "code",
+ styles: [],
+ });
+ });
+
+ it("preserves inner quote markers inside quoted fenced code blocks", () => {
+ expect(parseZalouserTextStyles("> ```\n>> prompt\n> ```")).toEqual({
+ text: "> prompt",
+ styles: [],
+ });
+ });
+
+ it("keeps quote indentation on heading lines", () => {
+ expect(parseZalouserTextStyles("> # Title")).toEqual({
+ text: "Title",
+ styles: [
+ { start: 0, len: 5, st: TextStyle.Bold },
+ { start: 0, len: 5, st: TextStyle.Big },
+ { start: 0, len: 5, st: TextStyle.Indent, indentSize: 1 },
+ ],
+ });
+ });
+
+ it("keeps unmatched fences literal", () => {
+ expect(parseZalouserTextStyles("```python")).toEqual({
+ text: "```python",
+ styles: [],
+ });
+ });
+
+ it("keeps unclosed fenced blocks literal until eof", () => {
+ expect(parseZalouserTextStyles("```python\n\\*not italic*\n_next_")).toEqual({
+ text: "```python\n\\*not italic*\n_next_",
+ styles: [],
+ });
+ });
+
+ it("supports nested markdown and tag styles regardless of order", () => {
+ expect(parseZalouserTextStyles("**{red}x{/red}** {red}**y**{/red}")).toEqual({
+ text: "x y",
+ styles: [
+ { start: 0, len: 1, st: TextStyle.Bold },
+ { start: 0, len: 1, st: TextStyle.Red },
+ { start: 2, len: 1, st: TextStyle.Red },
+ { start: 2, len: 1, st: TextStyle.Bold },
+ ],
+ });
+ });
+
+ it("treats small text tags as normal text", () => {
+ expect(parseZalouserTextStyles("{small}tiny{/small}")).toEqual({
+ text: "tiny",
+ styles: [],
+ });
+ });
+
+ it("keeps escaped markers literal", () => {
+ expect(parseZalouserTextStyles("\\*literal\\* \\{underline}tag{/underline}")).toEqual({
+ text: "*literal* {underline}tag{/underline}",
+ styles: [],
+ });
+ });
+
+ it("keeps indented code blocks literal", () => {
+ expect(parseZalouserTextStyles(" *cmd*")).toEqual({
+ text: "\u00A0\u00A0\u00A0\u00A0*cmd*",
+ styles: [],
+ });
+ });
+});
diff --git a/extensions/zalouser/src/text-styles.ts b/extensions/zalouser/src/text-styles.ts
new file mode 100644
index 00000000000..cdfe8b492b5
--- /dev/null
+++ b/extensions/zalouser/src/text-styles.ts
@@ -0,0 +1,537 @@
+import { TextStyle, type Style } from "./zca-client.js";
+
+type InlineStyle = (typeof TextStyle)[keyof typeof TextStyle];
+
+type LineStyle = {
+ lineIndex: number;
+ style: InlineStyle;
+ indentSize?: number;
+};
+
+type Segment = {
+ text: string;
+ styles: InlineStyle[];
+};
+
+type InlineMarker = {
+ pattern: RegExp;
+ extractText: (match: RegExpExecArray) => string;
+ resolveStyles?: (match: RegExpExecArray) => InlineStyle[];
+ literal?: boolean;
+};
+
+type ResolvedInlineMatch = {
+ match: RegExpExecArray;
+ marker: InlineMarker;
+ styles: InlineStyle[];
+ text: string;
+ priority: number;
+};
+
+type FenceMarker = {
+ char: "`" | "~";
+ length: number;
+ indent: number;
+};
+
+type ActiveFence = FenceMarker & {
+ quoteIndent: number;
+};
+
+const TAG_STYLE_MAP: Record = {
+ red: TextStyle.Red,
+ orange: TextStyle.Orange,
+ yellow: TextStyle.Yellow,
+ green: TextStyle.Green,
+ small: null,
+ big: TextStyle.Big,
+ underline: TextStyle.Underline,
+};
+
+const INLINE_MARKERS: InlineMarker[] = [
+ {
+ pattern: /`([^`\n]+)`/g,
+ extractText: (match) => match[0],
+ literal: true,
+ },
+ {
+ pattern: /\\([*_~#\\{}>+\-`])/g,
+ extractText: (match) => match[1],
+ literal: true,
+ },
+ {
+ pattern: new RegExp(`\\{(${Object.keys(TAG_STYLE_MAP).join("|")})\\}(.+?)\\{/\\1\\}`, "g"),
+ extractText: (match) => match[2],
+ resolveStyles: (match) => {
+ const style = TAG_STYLE_MAP[match[1]];
+ return style ? [style] : [];
+ },
+ },
+ {
+ pattern: /(? match[1],
+ resolveStyles: () => [TextStyle.Bold, TextStyle.Italic],
+ },
+ {
+ pattern: /(? match[1],
+ resolveStyles: () => [TextStyle.Bold],
+ },
+ {
+ pattern: /(? match[1],
+ resolveStyles: () => [TextStyle.Bold],
+ },
+ {
+ pattern: /(? match[1],
+ resolveStyles: () => [TextStyle.StrikeThrough],
+ },
+ {
+ pattern: /(? match[1],
+ resolveStyles: () => [TextStyle.Italic],
+ },
+ {
+ pattern: /(? match[1],
+ resolveStyles: () => [TextStyle.Italic],
+ },
+];
+
+export function parseZalouserTextStyles(input: string): { text: string; styles: Style[] } {
+ const allStyles: Style[] = [];
+
+ const escapeMap: string[] = [];
+ const lines = input.replace(/\r\n?/g, "\n").split("\n");
+ const lineStyles: LineStyle[] = [];
+ const processedLines: string[] = [];
+ let activeFence: ActiveFence | null = null;
+
+ for (let lineIndex = 0; lineIndex < lines.length; lineIndex += 1) {
+ const rawLine = lines[lineIndex];
+ const { text: unquotedLine, indent: baseIndent } = stripQuotePrefix(rawLine);
+
+ if (activeFence) {
+ const codeLine =
+ activeFence.quoteIndent > 0
+ ? stripQuotePrefix(rawLine, activeFence.quoteIndent).text
+ : rawLine;
+ if (isClosingFence(codeLine, activeFence)) {
+ activeFence = null;
+ continue;
+ }
+ processedLines.push(
+ escapeLiteralText(
+ normalizeCodeBlockLeadingWhitespace(stripCodeFenceIndent(codeLine, activeFence.indent)),
+ escapeMap,
+ ),
+ );
+ continue;
+ }
+
+ let line = unquotedLine;
+ const openingFence = resolveOpeningFence(rawLine);
+ if (openingFence) {
+ const fenceLine = openingFence.quoteIndent > 0 ? unquotedLine : rawLine;
+ if (!hasClosingFence(lines, lineIndex + 1, openingFence)) {
+ processedLines.push(escapeLiteralText(fenceLine, escapeMap));
+ activeFence = openingFence;
+ continue;
+ }
+ activeFence = openingFence;
+ continue;
+ }
+
+ const outputLineIndex = processedLines.length;
+ if (isIndentedCodeBlockLine(line)) {
+ if (baseIndent > 0) {
+ lineStyles.push({
+ lineIndex: outputLineIndex,
+ style: TextStyle.Indent,
+ indentSize: baseIndent,
+ });
+ }
+ processedLines.push(escapeLiteralText(normalizeCodeBlockLeadingWhitespace(line), escapeMap));
+ continue;
+ }
+
+ const { text: markdownLine, size: markdownPadding } = stripOptionalMarkdownPadding(line);
+
+ const headingMatch = markdownLine.match(/^(#{1,4})\s(.*)$/);
+ if (headingMatch) {
+ const depth = headingMatch[1].length;
+ lineStyles.push({ lineIndex: outputLineIndex, style: TextStyle.Bold });
+ if (depth === 1) {
+ lineStyles.push({ lineIndex: outputLineIndex, style: TextStyle.Big });
+ }
+ if (baseIndent > 0) {
+ lineStyles.push({
+ lineIndex: outputLineIndex,
+ style: TextStyle.Indent,
+ indentSize: baseIndent,
+ });
+ }
+ processedLines.push(headingMatch[2]);
+ continue;
+ }
+
+ const indentMatch = markdownLine.match(/^(\s+)(.*)$/);
+ let indentLevel = 0;
+ let content = markdownLine;
+ if (indentMatch) {
+ indentLevel = clampIndent(indentMatch[1].length);
+ content = indentMatch[2];
+ }
+ const totalIndent = Math.min(5, baseIndent + indentLevel);
+
+ if (/^[-*+]\s\[[ xX]\]\s/.test(content)) {
+ if (totalIndent > 0) {
+ lineStyles.push({
+ lineIndex: outputLineIndex,
+ style: TextStyle.Indent,
+ indentSize: totalIndent,
+ });
+ }
+ processedLines.push(content);
+ continue;
+ }
+
+ const orderedListMatch = content.match(/^(\d+)\.\s(.*)$/);
+ if (orderedListMatch) {
+ if (totalIndent > 0) {
+ lineStyles.push({
+ lineIndex: outputLineIndex,
+ style: TextStyle.Indent,
+ indentSize: totalIndent,
+ });
+ }
+ lineStyles.push({ lineIndex: outputLineIndex, style: TextStyle.OrderedList });
+ processedLines.push(orderedListMatch[2]);
+ continue;
+ }
+
+ const unorderedListMatch = content.match(/^[-*+]\s(.*)$/);
+ if (unorderedListMatch) {
+ if (totalIndent > 0) {
+ lineStyles.push({
+ lineIndex: outputLineIndex,
+ style: TextStyle.Indent,
+ indentSize: totalIndent,
+ });
+ }
+ lineStyles.push({ lineIndex: outputLineIndex, style: TextStyle.UnorderedList });
+ processedLines.push(unorderedListMatch[1]);
+ continue;
+ }
+
+ if (markdownPadding > 0) {
+ if (baseIndent > 0) {
+ lineStyles.push({
+ lineIndex: outputLineIndex,
+ style: TextStyle.Indent,
+ indentSize: baseIndent,
+ });
+ }
+ processedLines.push(line);
+ continue;
+ }
+
+ if (totalIndent > 0) {
+ lineStyles.push({
+ lineIndex: outputLineIndex,
+ style: TextStyle.Indent,
+ indentSize: totalIndent,
+ });
+ processedLines.push(content);
+ continue;
+ }
+
+ processedLines.push(line);
+ }
+
+ const segments = parseInlineSegments(processedLines.join("\n"));
+
+ let plainText = "";
+ for (const segment of segments) {
+ const start = plainText.length;
+ plainText += segment.text;
+ for (const style of segment.styles) {
+ allStyles.push({ start, len: segment.text.length, st: style } as Style);
+ }
+ }
+
+ if (escapeMap.length > 0) {
+ const escapeRegex = /\x01(\d+)\x02/g;
+ const shifts: Array<{ pos: number; delta: number }> = [];
+ let cumulativeDelta = 0;
+
+ for (const match of plainText.matchAll(escapeRegex)) {
+ const escapeIndex = Number.parseInt(match[1], 10);
+ cumulativeDelta += match[0].length - escapeMap[escapeIndex].length;
+ shifts.push({ pos: (match.index ?? 0) + match[0].length, delta: cumulativeDelta });
+ }
+
+ for (const style of allStyles) {
+ let startDelta = 0;
+ let endDelta = 0;
+ const end = style.start + style.len;
+ for (const shift of shifts) {
+ if (shift.pos <= style.start) {
+ startDelta = shift.delta;
+ }
+ if (shift.pos <= end) {
+ endDelta = shift.delta;
+ }
+ }
+ style.start -= startDelta;
+ style.len -= endDelta - startDelta;
+ }
+
+ plainText = plainText.replace(
+ escapeRegex,
+ (_match, index) => escapeMap[Number.parseInt(index, 10)],
+ );
+ }
+
+ const finalLines = plainText.split("\n");
+ let offset = 0;
+ for (let lineIndex = 0; lineIndex < finalLines.length; lineIndex += 1) {
+ const lineLength = finalLines[lineIndex].length;
+ if (lineLength > 0) {
+ for (const lineStyle of lineStyles) {
+ if (lineStyle.lineIndex !== lineIndex) {
+ continue;
+ }
+
+ if (lineStyle.style === TextStyle.Indent) {
+ allStyles.push({
+ start: offset,
+ len: lineLength,
+ st: TextStyle.Indent,
+ indentSize: lineStyle.indentSize,
+ });
+ } else {
+ allStyles.push({ start: offset, len: lineLength, st: lineStyle.style } as Style);
+ }
+ }
+ }
+ offset += lineLength + 1;
+ }
+
+ return { text: plainText, styles: allStyles };
+}
+
+function clampIndent(spaceCount: number): number {
+ return Math.min(5, Math.max(1, Math.floor(spaceCount / 2)));
+}
+
+function stripOptionalMarkdownPadding(line: string): { text: string; size: number } {
+ const match = line.match(/^( {1,3})(?=\S)/);
+ if (!match) {
+ return { text: line, size: 0 };
+ }
+ return {
+ text: line.slice(match[1].length),
+ size: match[1].length,
+ };
+}
+
+function hasClosingFence(lines: string[], startIndex: number, fence: ActiveFence): boolean {
+ for (let index = startIndex; index < lines.length; index += 1) {
+ const candidate =
+ fence.quoteIndent > 0 ? stripQuotePrefix(lines[index], fence.quoteIndent).text : lines[index];
+ if (isClosingFence(candidate, fence)) {
+ return true;
+ }
+ }
+ return false;
+}
+
+function resolveOpeningFence(line: string): ActiveFence | null {
+ const directFence = parseFenceMarker(line);
+ if (directFence) {
+ return { ...directFence, quoteIndent: 0 };
+ }
+
+ const quoted = stripQuotePrefix(line);
+ if (quoted.indent === 0) {
+ return null;
+ }
+
+ const quotedFence = parseFenceMarker(quoted.text);
+ if (!quotedFence) {
+ return null;
+ }
+
+ return {
+ ...quotedFence,
+ quoteIndent: quoted.indent,
+ };
+}
+
+function stripQuotePrefix(
+ line: string,
+ maxDepth = Number.POSITIVE_INFINITY,
+): { text: string; indent: number } {
+ let cursor = 0;
+ while (cursor < line.length && cursor < 3 && line[cursor] === " ") {
+ cursor += 1;
+ }
+
+ let removedDepth = 0;
+ let consumedCursor = cursor;
+ while (removedDepth < maxDepth && consumedCursor < line.length && line[consumedCursor] === ">") {
+ removedDepth += 1;
+ consumedCursor += 1;
+ if (line[consumedCursor] === " ") {
+ consumedCursor += 1;
+ }
+ }
+
+ if (removedDepth === 0) {
+ return { text: line, indent: 0 };
+ }
+
+ return {
+ text: line.slice(consumedCursor),
+ indent: Math.min(5, removedDepth),
+ };
+}
+
+function parseFenceMarker(line: string): FenceMarker | null {
+ const match = line.match(/^([ ]{0,3})(`{3,}|~{3,})(.*)$/);
+ if (!match) {
+ return null;
+ }
+
+ const marker = match[2];
+ const char = marker[0];
+ if (char !== "`" && char !== "~") {
+ return null;
+ }
+
+ return {
+ char,
+ length: marker.length,
+ indent: match[1].length,
+ };
+}
+
+function isClosingFence(line: string, fence: FenceMarker): boolean {
+ const match = line.match(/^([ ]{0,3})(`{3,}|~{3,})[ \t]*$/);
+ if (!match) {
+ return false;
+ }
+ return match[2][0] === fence.char && match[2].length >= fence.length;
+}
+
+function escapeLiteralText(input: string, escapeMap: string[]): string {
+ return input.replace(/[\\*_~{}`]/g, (ch) => {
+ const index = escapeMap.length;
+ escapeMap.push(ch);
+ return `\x01${index}\x02`;
+ });
+}
+
+function parseInlineSegments(text: string, inheritedStyles: InlineStyle[] = []): Segment[] {
+ const segments: Segment[] = [];
+ let cursor = 0;
+
+ while (cursor < text.length) {
+ const nextMatch = findNextInlineMatch(text, cursor);
+ if (!nextMatch) {
+ pushSegment(segments, text.slice(cursor), inheritedStyles);
+ break;
+ }
+
+ if (nextMatch.match.index > cursor) {
+ pushSegment(segments, text.slice(cursor, nextMatch.match.index), inheritedStyles);
+ }
+
+ const combinedStyles = [...inheritedStyles, ...nextMatch.styles];
+ if (nextMatch.marker.literal) {
+ pushSegment(segments, nextMatch.text, combinedStyles);
+ } else {
+ segments.push(...parseInlineSegments(nextMatch.text, combinedStyles));
+ }
+
+ cursor = nextMatch.match.index + nextMatch.match[0].length;
+ }
+
+ return segments;
+}
+
+function findNextInlineMatch(text: string, startIndex: number): ResolvedInlineMatch | null {
+ let bestMatch: ResolvedInlineMatch | null = null;
+
+ for (const [priority, marker] of INLINE_MARKERS.entries()) {
+ const regex = new RegExp(marker.pattern.source, marker.pattern.flags);
+ regex.lastIndex = startIndex;
+ const match = regex.exec(text);
+ if (!match) {
+ continue;
+ }
+
+ if (
+ bestMatch &&
+ (match.index > bestMatch.match.index ||
+ (match.index === bestMatch.match.index && priority > bestMatch.priority))
+ ) {
+ continue;
+ }
+
+ bestMatch = {
+ match,
+ marker,
+ text: marker.extractText(match),
+ styles: marker.resolveStyles?.(match) ?? [],
+ priority,
+ };
+ }
+
+ return bestMatch;
+}
+
+function pushSegment(segments: Segment[], text: string, styles: InlineStyle[]): void {
+ if (!text) {
+ return;
+ }
+
+ const lastSegment = segments.at(-1);
+ if (lastSegment && sameStyles(lastSegment.styles, styles)) {
+ lastSegment.text += text;
+ return;
+ }
+
+ segments.push({
+ text,
+ styles: [...styles],
+ });
+}
+
+function sameStyles(left: InlineStyle[], right: InlineStyle[]): boolean {
+ return left.length === right.length && left.every((style, index) => style === right[index]);
+}
+
+function normalizeCodeBlockLeadingWhitespace(line: string): string {
+ return line.replace(/^[ \t]+/, (leadingWhitespace) =>
+ leadingWhitespace.replace(/\t/g, "\u00A0\u00A0\u00A0\u00A0").replace(/ /g, "\u00A0"),
+ );
+}
+
+function isIndentedCodeBlockLine(line: string): boolean {
+ return /^(?: {4,}|\t)/.test(line);
+}
+
+function stripCodeFenceIndent(line: string, indent: number): string {
+ let consumed = 0;
+ let cursor = 0;
+
+ while (cursor < line.length && consumed < indent && line[cursor] === " ") {
+ cursor += 1;
+ consumed += 1;
+ }
+
+ return line.slice(cursor);
+}
diff --git a/extensions/zalouser/src/types.ts b/extensions/zalouser/src/types.ts
index d704a1b3f78..08dc2fd8d12 100644
--- a/extensions/zalouser/src/types.ts
+++ b/extensions/zalouser/src/types.ts
@@ -1,3 +1,5 @@
+import type { Style } from "./zca-client.js";
+
export type ZcaFriend = {
userId: string;
displayName: string;
@@ -59,6 +61,10 @@ export type ZaloSendOptions = {
caption?: string;
isGroup?: boolean;
mediaLocalRoots?: readonly string[];
+ textMode?: "markdown" | "plain";
+ textChunkMode?: "length" | "newline";
+ textChunkLimit?: number;
+ textStyles?: Style[];
};
export type ZaloSendResult = {
@@ -91,6 +97,7 @@ type ZalouserSharedConfig = {
enabled?: boolean;
name?: string;
profile?: string;
+ dangerouslyAllowNameMatching?: boolean;
dmPolicy?: "pairing" | "allowlist" | "open" | "disabled";
allowFrom?: Array;
historyLimit?: number;
diff --git a/extensions/zalouser/src/zalo-js.ts b/extensions/zalouser/src/zalo-js.ts
index 25d263b7d6a..0e2d744232f 100644
--- a/extensions/zalouser/src/zalo-js.ts
+++ b/extensions/zalouser/src/zalo-js.ts
@@ -20,6 +20,7 @@ import type {
} from "./types.js";
import {
LoginQRCallbackEventType,
+ TextStyle,
ThreadType,
Zalo,
type API,
@@ -136,6 +137,39 @@ function toErrorMessage(error: unknown): string {
return String(error);
}
+function clampTextStyles(
+ text: string,
+ styles?: ZaloSendOptions["textStyles"],
+): ZaloSendOptions["textStyles"] {
+ if (!styles || styles.length === 0) {
+ return undefined;
+ }
+ const maxLength = text.length;
+ const clamped = styles
+ .map((style) => {
+ const start = Math.max(0, Math.min(style.start, maxLength));
+ const end = Math.min(style.start + style.len, maxLength);
+ if (end <= start) {
+ return null;
+ }
+ if (style.st === TextStyle.Indent) {
+ return {
+ start,
+ len: end - start,
+ st: style.st,
+ indentSize: style.indentSize,
+ };
+ }
+ return {
+ start,
+ len: end - start,
+ st: style.st,
+ };
+ })
+ .filter((style): style is NonNullable => style !== null);
+ return clamped.length > 0 ? clamped : undefined;
+}
+
function toNumberId(value: unknown): string {
if (typeof value === "number" && Number.isFinite(value)) {
return String(Math.trunc(value));
@@ -1018,11 +1052,16 @@ export async function sendZaloTextMessage(
kind: media.kind,
});
const payloadText = (text || options.caption || "").slice(0, 2000);
+ const textStyles = clampTextStyles(payloadText, options.textStyles);
if (media.kind === "audio") {
let textMessageId: string | undefined;
if (payloadText) {
- const textResponse = await api.sendMessage(payloadText, trimmedThreadId, type);
+ const textResponse = await api.sendMessage(
+ textStyles ? { msg: payloadText, styles: textStyles } : payloadText,
+ trimmedThreadId,
+ type,
+ );
textMessageId = extractSendMessageId(textResponse);
}
@@ -1055,6 +1094,7 @@ export async function sendZaloTextMessage(
const response = await api.sendMessage(
{
msg: payloadText,
+ ...(textStyles ? { styles: textStyles } : {}),
attachments: [
{
data: media.buffer,
@@ -1071,7 +1111,13 @@ export async function sendZaloTextMessage(
return { ok: true, messageId: extractSendMessageId(response) };
}
- const response = await api.sendMessage(text.slice(0, 2000), trimmedThreadId, type);
+ const payloadText = text.slice(0, 2000);
+ const textStyles = clampTextStyles(payloadText, options.textStyles);
+ const response = await api.sendMessage(
+ textStyles ? { msg: payloadText, styles: textStyles } : payloadText,
+ trimmedThreadId,
+ type,
+ );
return { ok: true, messageId: extractSendMessageId(response) };
} catch (error) {
return { ok: false, error: toErrorMessage(error) };
diff --git a/extensions/zalouser/src/zca-client.ts b/extensions/zalouser/src/zca-client.ts
index 57172eef64d..00a1c8c1be0 100644
--- a/extensions/zalouser/src/zca-client.ts
+++ b/extensions/zalouser/src/zca-client.ts
@@ -28,6 +28,39 @@ export const Reactions = ReactionsRuntime as Record & {
NONE: string;
};
+// Mirror zca-js sendMessage style constants locally because the package root
+// typing surface does not consistently expose TextStyle/Style to tsgo.
+export const TextStyle = {
+ Bold: "b",
+ Italic: "i",
+ Underline: "u",
+ StrikeThrough: "s",
+ Red: "c_db342e",
+ Orange: "c_f27806",
+ Yellow: "c_f7b503",
+ Green: "c_15a85f",
+ Small: "f_13",
+ Big: "f_18",
+ UnorderedList: "lst_1",
+ OrderedList: "lst_2",
+ Indent: "ind_$",
+} as const;
+
+type TextStyleValue = (typeof TextStyle)[keyof typeof TextStyle];
+
+export type Style =
+ | {
+ start: number;
+ len: number;
+ st: Exclude;
+ }
+ | {
+ start: number;
+ len: number;
+ st: typeof TextStyle.Indent;
+ indentSize?: number;
+ };
+
export type Credentials = {
imei: string;
cookie: unknown;
diff --git a/package.json b/package.json
index 9c1100bc49f..a3cfeb8d4a8 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "openclaw",
- "version": "2026.3.11",
+ "version": "2026.3.12",
"description": "Multi-channel AI gateway with extensible messaging integrations",
"keywords": [],
"homepage": "https://github.com/openclaw/openclaw#readme",
@@ -294,7 +294,7 @@
"plugins:sync": "node --import tsx scripts/sync-plugin-versions.ts",
"prepack": "pnpm build && pnpm ui:build",
"prepare": "command -v git >/dev/null 2>&1 && git rev-parse --is-inside-work-tree >/dev/null 2>&1 && git config core.hooksPath git-hooks || exit 0",
- "protocol:check": "pnpm protocol:gen && pnpm protocol:gen:swift && git diff --exit-code -- dist/protocol.schema.json apps/macos/Sources/OpenClawProtocol/GatewayModels.swift",
+ "protocol:check": "pnpm protocol:gen && pnpm protocol:gen:swift && git diff --exit-code -- dist/protocol.schema.json apps/macos/Sources/OpenClawProtocol/GatewayModels.swift apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift",
"protocol:gen": "node --import tsx scripts/protocol-gen.ts",
"protocol:gen:swift": "node --import tsx scripts/protocol-gen-swift.ts",
"release:check": "node --import tsx scripts/release-check.ts",
@@ -339,7 +339,7 @@
},
"dependencies": {
"@agentclientprotocol/sdk": "0.16.1",
- "@aws-sdk/client-bedrock": "^3.1007.0",
+ "@aws-sdk/client-bedrock": "^3.1008.0",
"@buape/carbon": "0.0.0-beta-20260216184201",
"@clack/prompts": "^1.1.0",
"@discordjs/voice": "^0.19.1",
@@ -388,7 +388,7 @@
"sqlite-vec": "0.1.7-alpha.2",
"tar": "7.5.11",
"tslog": "^4.10.2",
- "undici": "^7.22.0",
+ "undici": "^7.24.0",
"ws": "^8.19.0",
"yaml": "^2.8.2",
"zod": "^4.3.6"
@@ -399,28 +399,34 @@
"@lit/context": "^1.1.6",
"@types/express": "^5.0.6",
"@types/markdown-it": "^14.1.2",
- "@types/node": "^25.4.0",
+ "@types/node": "^25.5.0",
"@types/qrcode-terminal": "^0.12.2",
"@types/ws": "^8.18.1",
- "@typescript/native-preview": "7.0.0-dev.20260311.1",
- "@vitest/coverage-v8": "^4.0.18",
+ "@typescript/native-preview": "7.0.0-dev.20260312.1",
+ "@vitest/coverage-v8": "^4.1.0",
"jscpd": "4.0.8",
+ "jsdom": "^28.1.0",
"lit": "^3.3.2",
- "oxfmt": "0.38.0",
- "oxlint": "^1.53.0",
+ "oxfmt": "0.40.0",
+ "oxlint": "^1.55.0",
"oxlint-tsgolint": "^0.16.0",
"signal-utils": "0.21.1",
"tsdown": "0.21.2",
"tsx": "^4.21.0",
"typescript": "^5.9.3",
- "vitest": "^4.0.18"
+ "vitest": "^4.1.0"
},
"peerDependencies": {
"@napi-rs/canvas": "^0.1.89",
"node-llama-cpp": "3.16.2"
},
+ "peerDependenciesMeta": {
+ "node-llama-cpp": {
+ "optional": true
+ }
+ },
"engines": {
- "node": ">=22.12.0"
+ "node": ">=22.16.0"
},
"packageManager": "pnpm@10.23.0",
"pnpm": {
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 1e26495971c..ac32d145c57 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -29,8 +29,8 @@ importers:
specifier: 0.16.1
version: 0.16.1(zod@4.3.6)
'@aws-sdk/client-bedrock':
- specifier: ^3.1007.0
- version: 3.1007.0
+ specifier: ^3.1008.0
+ version: 3.1008.0
'@buape/carbon':
specifier: 0.0.0-beta-20260216184201
version: 0.0.0-beta-20260216184201(@discordjs/opus@0.10.0)(hono@4.12.7)(opusscript@0.1.1)
@@ -182,8 +182,8 @@ importers:
specifier: ^4.10.2
version: 4.10.2
undici:
- specifier: ^7.22.0
- version: 7.22.0
+ specifier: ^7.24.0
+ version: 7.24.0
ws:
specifier: ^8.19.0
version: 8.19.0
@@ -210,8 +210,8 @@ importers:
specifier: ^14.1.2
version: 14.1.2
'@types/node':
- specifier: ^25.4.0
- version: 25.4.0
+ specifier: ^25.5.0
+ version: 25.5.0
'@types/qrcode-terminal':
specifier: ^0.12.2
version: 0.12.2
@@ -219,23 +219,26 @@ importers:
specifier: ^8.18.1
version: 8.18.1
'@typescript/native-preview':
- specifier: 7.0.0-dev.20260311.1
- version: 7.0.0-dev.20260311.1
+ specifier: 7.0.0-dev.20260312.1
+ version: 7.0.0-dev.20260312.1
'@vitest/coverage-v8':
- specifier: ^4.0.18
- version: 4.0.18(@vitest/browser@4.0.18(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.18))(vitest@4.0.18)
+ specifier: ^4.1.0
+ version: 4.1.0(@vitest/browser@4.1.0(vite@8.0.0(@types/node@25.5.0)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.1.0))(vitest@4.1.0)
jscpd:
specifier: 4.0.8
version: 4.0.8
+ jsdom:
+ specifier: ^28.1.0
+ version: 28.1.0(@noble/hashes@2.0.1)
lit:
specifier: ^3.3.2
version: 3.3.2
oxfmt:
- specifier: 0.38.0
- version: 0.38.0
+ specifier: 0.40.0
+ version: 0.40.0
oxlint:
- specifier: ^1.53.0
- version: 1.53.0(oxlint-tsgolint@0.16.0)
+ specifier: ^1.55.0
+ version: 1.55.0(oxlint-tsgolint@0.16.0)
oxlint-tsgolint:
specifier: ^0.16.0
version: 0.16.0
@@ -244,7 +247,7 @@ importers:
version: 0.21.1(signal-polyfill@0.2.2)
tsdown:
specifier: 0.21.2
- version: 0.21.2(@typescript/native-preview@7.0.0-dev.20260311.1)(typescript@5.9.3)
+ version: 0.21.2(@typescript/native-preview@7.0.0-dev.20260312.1)(typescript@5.9.3)
tsx:
specifier: ^4.21.0
version: 4.21.0
@@ -252,14 +255,14 @@ importers:
specifier: ^5.9.3
version: 5.9.3
vitest:
- specifier: ^4.0.18
- version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.4.0)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2)
+ specifier: ^4.1.0
+ version: 4.1.0(@opentelemetry/api@1.9.0)(@types/node@25.5.0)(@vitest/browser-playwright@4.1.0)(jsdom@28.1.0(@noble/hashes@2.0.1))(vite@8.0.0(@types/node@25.5.0)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2))
extensions/acpx:
dependencies:
acpx:
- specifier: 0.2.0
- version: 0.2.0(zod@4.3.6)
+ specifier: 0.3.0
+ version: 0.3.0(zod@4.3.6)
extensions/bluebubbles:
dependencies:
@@ -342,8 +345,8 @@ importers:
specifier: ^10.6.1
version: 10.6.1
openclaw:
- specifier: '>=2026.3.7'
- version: 2026.3.8(@discordjs/opus@0.10.0)(@napi-rs/canvas@0.1.95)(@types/express@5.0.6)(audio-decode@2.2.3)(hono@4.12.7)(node-llama-cpp@3.16.2(typescript@5.9.3))
+ specifier: '>=2026.3.11'
+ version: 2026.3.11(@discordjs/opus@0.10.0)(@napi-rs/canvas@0.1.95)(@types/express@5.0.6)(audio-decode@2.2.3)(node-llama-cpp@3.16.2(typescript@5.9.3))
extensions/imessage: {}
@@ -385,8 +388,8 @@ importers:
specifier: 14.1.1
version: 14.1.1
music-metadata:
- specifier: ^11.12.1
- version: 11.12.1
+ specifier: ^11.12.3
+ version: 11.12.3
zod:
specifier: ^4.3.6
version: 4.3.6
@@ -403,8 +406,8 @@ importers:
extensions/memory-core:
dependencies:
openclaw:
- specifier: '>=2026.3.7'
- version: 2026.3.8(@discordjs/opus@0.10.0)(@napi-rs/canvas@0.1.95)(@types/express@5.0.6)(audio-decode@2.2.3)(hono@4.12.7)(node-llama-cpp@3.16.2(typescript@5.9.3))
+ specifier: '>=2026.3.11'
+ version: 2026.3.11(@discordjs/opus@0.10.0)(@napi-rs/canvas@0.1.95)(@types/express@5.0.6)(audio-decode@2.2.3)(node-llama-cpp@3.16.2(typescript@5.9.3))
extensions/memory-lancedb:
dependencies:
@@ -444,8 +447,12 @@ importers:
specifier: ^4.3.6
version: 4.3.6
+ extensions/ollama: {}
+
extensions/open-prose: {}
+ extensions/sglang: {}
+
extensions/signal: {}
extensions/slack: {}
@@ -488,6 +495,8 @@ importers:
specifier: ^4.3.6
version: 4.3.6
+ extensions/vllm: {}
+
extensions/voice-call:
dependencies:
'@sinclair/typebox':
@@ -508,8 +517,8 @@ importers:
extensions/zalo:
dependencies:
undici:
- specifier: 7.22.0
- version: 7.22.0
+ specifier: 7.24.0
+ version: 7.24.0
zod:
specifier: ^4.3.6
version: 4.3.6
@@ -565,21 +574,27 @@ importers:
specifier: ^0.21.1
version: 0.21.1(signal-polyfill@0.2.2)
vite:
- specifier: 7.3.1
- version: 7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2)
+ specifier: 8.0.0
+ version: 8.0.0(@types/node@25.5.0)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2)
devDependencies:
'@vitest/browser-playwright':
- specifier: 4.0.18
- version: 4.0.18(playwright@1.58.2)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.18)
+ specifier: 4.1.0
+ version: 4.1.0(playwright@1.58.2)(vite@8.0.0(@types/node@25.5.0)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.1.0)
+ jsdom:
+ specifier: ^28.1.0
+ version: 28.1.0(@noble/hashes@2.0.1)
playwright:
specifier: ^1.58.2
version: 1.58.2
vitest:
- specifier: 4.0.18
- version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.4.0)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2)
+ specifier: 4.1.0
+ version: 4.1.0(@opentelemetry/api@1.9.0)(@types/node@25.5.0)(@vitest/browser-playwright@4.1.0)(jsdom@28.1.0(@noble/hashes@2.0.1))(vite@8.0.0(@types/node@25.5.0)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2))
packages:
+ '@acemir/cssom@0.9.31':
+ resolution: {integrity: sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==}
+
'@agentclientprotocol/sdk@0.15.0':
resolution: {integrity: sha512-TH4utu23Ix8ec34srBHmDD4p3HI0cYleS1jN9lghRczPfhFlMBNrQgZWeBBe12DWy27L11eIrtciY2MXFSEiDg==}
peerDependencies:
@@ -599,6 +614,16 @@ packages:
zod:
optional: true
+ '@asamuzakjp/css-color@5.0.1':
+ resolution: {integrity: sha512-2SZFvqMyvboVV1d15lMf7XiI3m7SDqXUuKaTymJYLN6dSGadqp+fVojqJlVoMlbZnlTmu3S0TLwLTJpvBMO1Aw==}
+ engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
+
+ '@asamuzakjp/dom-selector@6.8.1':
+ resolution: {integrity: sha512-MvRz1nCqW0fsy8Qz4dnLIvhOlMzqDVBabZx6lH+YywFDdjXhMY37SmpV1XFX3JzG5GWHn63j6HX6QPr3lZXHvQ==}
+
+ '@asamuzakjp/nwsapi@2.3.9':
+ resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==}
+
'@aws-crypto/crc32@5.2.0':
resolution: {integrity: sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==}
engines: {node: '>=16.0.0'}
@@ -630,6 +655,10 @@ packages:
resolution: {integrity: sha512-49hH8o6ALKkCiBUgg20HkwxNamP1yYA/n8Si73Z438EqhZGpCfScP3FfxVhrfD5o+4bV4Whi9BTzPKCa/PfUww==}
engines: {node: '>=20.0.0'}
+ '@aws-sdk/client-bedrock@3.1008.0':
+ resolution: {integrity: sha512-mzxO/DplpZZT7AIZUCG7Q78OlaeHeDybYz+ZlWZPaXFjGDJwUv1E3SKskmaaQvTsMeieie0WX7gzueYrCx4YfQ==}
+ engines: {node: '>=20.0.0'}
+
'@aws-sdk/client-s3@3.1000.0':
resolution: {integrity: sha512-7kPy33qNGq3NfwHC0412T6LDK1bp4+eiPzetX0sVd9cpTSXuQDKpoOFnB0Njj6uZjJDcLS3n2OeyarwwgkQ0Ow==}
engines: {node: '>=20.0.0'}
@@ -686,6 +715,10 @@ packages:
resolution: {integrity: sha512-vthIAXJISZnj2576HeyLBj4WTeX+I7PwWeRkbOa0mVX39K13SCGxCgOFuKj2ytm9qTlLOmXe4cdEnroteFtJfw==}
engines: {node: '>=20.0.0'}
+ '@aws-sdk/credential-provider-ini@3.972.19':
+ resolution: {integrity: sha512-pVJVjWqVrPqjpFq7o0mCmeZu1Y0c94OCHSYgivdCD2wfmYVtBbwQErakruhgOD8pcMcx9SCqRw1pzHKR7OGBcA==}
+ engines: {node: '>=20.0.0'}
+
'@aws-sdk/credential-provider-login@3.972.13':
resolution: {integrity: sha512-RtYcrxdnJHKY8MFQGLltCURcjuMjnaQpAxPE6+/QEdDHHItMKZgabRe/KScX737F9vJMQsmJy9EmMOkCnoC1JQ==}
engines: {node: '>=20.0.0'}
@@ -698,6 +731,10 @@ packages:
resolution: {integrity: sha512-kINzc5BBxdYBkPZ0/i1AMPMOk5b5QaFNbYMElVw5QTX13AKj6jcxnv/YNl9oW9mg+Y08ti19hh01HhyEAxsSJQ==}
engines: {node: '>=20.0.0'}
+ '@aws-sdk/credential-provider-login@3.972.19':
+ resolution: {integrity: sha512-jOXdZ1o+CywQKr6gyxgxuUmnGwTTnY2Kxs1PM7fI6AYtDWDnmW/yKXayNqkF8KjP1unflqMWKVbVt5VgmE3L0g==}
+ engines: {node: '>=20.0.0'}
+
'@aws-sdk/credential-provider-node@3.972.14':
resolution: {integrity: sha512-WqoC2aliIjQM/L3oFf6j+op/enT2i9Cc4UTxxMEKrJNECkq4/PlKE5BOjSYFcq6G9mz65EFbXJh7zOU4CvjSKQ==}
engines: {node: '>=20.0.0'}
@@ -710,6 +747,10 @@ packages:
resolution: {integrity: sha512-yDWQ9dFTr+IMxwanFe7+tbN5++q8psZBjlUwOiCXn1EzANoBgtqBwcpYcHaMGtn0Wlfj4NuXdf2JaEx1lz5RaQ==}
engines: {node: '>=20.0.0'}
+ '@aws-sdk/credential-provider-node@3.972.20':
+ resolution: {integrity: sha512-0xHca2BnPY0kzjDYPH7vk8YbfdBPpWVS67rtqQMalYDQUCBYS37cZ55K6TuFxCoIyNZgSCFrVKr9PXC5BVvQQw==}
+ engines: {node: '>=20.0.0'}
+
'@aws-sdk/credential-provider-process@3.972.13':
resolution: {integrity: sha512-rsRG0LQA4VR+jnDyuqtXi2CePYSmfm5GNL9KxiW8DSe25YwJSr06W8TdUfONAC+rjsTI+aIH2rBGG5FjMeANrw==}
engines: {node: '>=20.0.0'}
@@ -734,6 +775,10 @@ packages:
resolution: {integrity: sha512-YHYEfj5S2aqInRt5ub8nDOX8vAxgMvd84wm2Y3WVNfFa/53vOv9T7WOAqXI25qjj3uEcV46xxfqdDQk04h5XQA==}
engines: {node: '>=20.0.0'}
+ '@aws-sdk/credential-provider-sso@3.972.19':
+ resolution: {integrity: sha512-kVjQsEU3b///q7EZGrUzol9wzwJFKbEzqJKSq82A9ShrUTEO7FNylTtby3sPV19ndADZh1H3FB3+5ZrvKtEEeg==}
+ engines: {node: '>=20.0.0'}
+
'@aws-sdk/credential-provider-web-identity@3.972.13':
resolution: {integrity: sha512-a6iFMh1pgUH0TdcouBppLJUfPM7Yd3R9S1xFodPtCRoLqCz2RQFA3qjA8x4112PVYXEd4/pHX2eihapq39w0rA==}
engines: {node: '>=20.0.0'}
@@ -746,6 +791,10 @@ packages:
resolution: {integrity: sha512-OqlEQpJ+J3T5B96qtC1zLLwkBloechP+fezKbCH0sbd2cCc0Ra55XpxWpk/hRj69xAOYtHvoC4orx6eTa4zU7g==}
engines: {node: '>=20.0.0'}
+ '@aws-sdk/credential-provider-web-identity@3.972.19':
+ resolution: {integrity: sha512-BV1BlTFdG4w4tAihxN7iXDBoNcNewXD4q8uZlNQiUrnqxwGWUhKHODIQVSPlQGxXClEj+63m+cqZskw+ESmeZg==}
+ engines: {node: '>=20.0.0'}
+
'@aws-sdk/eventstream-handler-node@3.972.10':
resolution: {integrity: sha512-g2Z9s6Y4iNh0wICaEqutgYgt/Pmhv5Ev9G3eKGFe2w9VuZDhc76vYdop6I5OocmpHV79d4TuLG+JWg5rQIVDVA==}
engines: {node: '>=20.0.0'}
@@ -830,6 +879,10 @@ packages:
resolution: {integrity: sha512-6HlLm8ciMW8VzfB80kfIx16PBA9lOa9Dl+dmCBi78JDhvGlx3I7Rorwi5PpVRkL31RprXnYna3yBf6UKkD/PqA==}
engines: {node: '>=20.0.0'}
+ '@aws-sdk/nested-clients@3.996.9':
+ resolution: {integrity: sha512-+RpVtpmQbbtzFOKhMlsRcXM/3f1Z49qTOHaA8gEpHOYruERmog6f2AUtf/oTRLCWjR9H2b3roqryV/hI7QMW8w==}
+ engines: {node: '>=20.0.0'}
+
'@aws-sdk/region-config-resolver@3.972.6':
resolution: {integrity: sha512-Aa5PusHLXAqLTX1UKDvI3pHQJtIsF7Q+3turCHqfz/1F61/zDMWfbTC8evjhrrYVAtz9Vsv3SJ/waSUeu7B6gw==}
engines: {node: '>=20.0.0'}
@@ -858,6 +911,10 @@ packages:
resolution: {integrity: sha512-kKvVyr53vvVc5k6RbvI6jhafxufxO2SkEw8QeEzJqwOXH/IMY7Cm0IyhnBGdqj80iiIIiIM2jGe7Fn3TIdwdrw==}
engines: {node: '>=20.0.0'}
+ '@aws-sdk/token-providers@3.1008.0':
+ resolution: {integrity: sha512-TulwlHQBWcJs668kNUDMZHN51DeLrDsYT59Ux4a/nbvr025gM6HjKJJ3LvnZccam7OS/ZKUVkWomCneRQKJbBg==}
+ engines: {node: '>=20.0.0'}
+
'@aws-sdk/token-providers@3.999.0':
resolution: {integrity: sha512-cx0hHUlgXULfykx4rdu/ciNAJaa3AL5xz3rieCz7NKJ68MJwlj3664Y8WR5MGgxfyYJBdamnkjNSx5Kekuc0cg==}
engines: {node: '>=20.0.0'}
@@ -894,6 +951,10 @@ packages:
resolution: {integrity: sha512-H1onv5SkgPBK2P6JR2MjGgbOnttoNzSPIRoeZTNPZYyaplwGg50zS3amXvXqF0/qfXpWEC9rLWU564QTB9bSog==}
engines: {node: '>=20.0.0'}
+ '@aws-sdk/util-locate-window@3.965.5':
+ resolution: {integrity: sha512-WhlJNNINQB+9qtLtZJcpQdgZw3SCDCpXdUJP7cToGwHbCWCnRckGlc6Bx/OhWwIYFNAn+FIydY8SZ0QmVu3xTQ==}
+ engines: {node: '>=20.0.0'}
+
'@aws-sdk/util-user-agent-browser@3.972.6':
resolution: {integrity: sha512-Fwr/llD6GOrFgQnKaI2glhohdGuBDfHfora6iG9qsBBBR8xv1SdCSwbtf5CWlUdCw5X7g76G/9Hf0Inh0EmoxA==}
@@ -927,6 +988,15 @@ packages:
aws-crt:
optional: true
+ '@aws-sdk/util-user-agent-node@3.973.6':
+ resolution: {integrity: sha512-iF7G0prk7AvmOK64FcLvc/fW+Ty1H+vttajL7PvJFReU8urMxfYmynTTuFKDTA76Wgpq3FzTPKwabMQIXQHiXQ==}
+ engines: {node: '>=20.0.0'}
+ peerDependencies:
+ aws-crt: '>=1.0.0'
+ peerDependenciesMeta:
+ aws-crt:
+ optional: true
+
'@aws-sdk/xml-builder@3.972.10':
resolution: {integrity: sha512-OnejAIVD+CxzyAUrVic7lG+3QRltyja9LoNqCE/1YVs8ichoTbJlVSaZ9iSMcnHLyzrSNtvaOGjSDRP+d/ouFA==}
engines: {node: '>=20.0.0'}
@@ -939,6 +1009,10 @@ packages:
resolution: {integrity: sha512-oLvsaPMTBejkkmHhjf09xTgk71mOqyr/409NKhRIL08If7AhVfUsJhVsx386uJaqNd42v9kWamQ9lFbkoC2dYw==}
engines: {node: '>=18.0.0'}
+ '@aws/lambda-invoke-store@0.2.4':
+ resolution: {integrity: sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ==}
+ engines: {node: '>=18.0.0'}
+
'@azure/abort-controller@2.1.2':
resolution: {integrity: sha512-nBrLsEWm4J2u5LpAPjxADTlq3trDgVZZXHNKabeXZtpq3d3AbN/KGO82R87rdDz5/lYB024rtEf10/q0urNgsA==}
engines: {node: '>=18.0.0'}
@@ -1005,8 +1079,15 @@ packages:
resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==}
engines: {node: '>=18'}
- '@borewit/text-codec@0.2.1':
- resolution: {integrity: sha512-k7vvKPbf7J2fZ5klGRD9AeKfUvojuZIQ3BT5u7Jfv+puwXkUBUT5PVyMDfJZpy30CBDXGMgw7fguK/lpOMBvgw==}
+ '@blazediff/core@1.9.1':
+ resolution: {integrity: sha512-ehg3jIkYKulZh+8om/O25vkvSsXXwC+skXmyA87FFx6A/45eqOkZsBltMw/TVteb0mloiGT8oGRTcjRAz66zaA==}
+
+ '@borewit/text-codec@0.2.2':
+ resolution: {integrity: sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==}
+
+ '@bramus/specificity@2.4.2':
+ resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==}
+ hasBin: true
'@buape/carbon@0.0.0-beta-20260216184201':
resolution: {integrity: sha512-u5mgYcigfPVqT7D9gVTGd+3YSflTreQmrWog7ORbb0z5w9eT8ft4rJOdw9fGwr75zMu9kXpSBaAcY2eZoJFSdA==}
@@ -1034,6 +1115,37 @@ packages:
resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==}
engines: {node: '>=0.1.90'}
+ '@csstools/color-helpers@6.0.2':
+ resolution: {integrity: sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==}
+ engines: {node: '>=20.19.0'}
+
+ '@csstools/css-calc@3.1.1':
+ resolution: {integrity: sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ==}
+ engines: {node: '>=20.19.0'}
+ peerDependencies:
+ '@csstools/css-parser-algorithms': ^4.0.0
+ '@csstools/css-tokenizer': ^4.0.0
+
+ '@csstools/css-color-parser@4.0.2':
+ resolution: {integrity: sha512-0GEfbBLmTFf0dJlpsNU7zwxRIH0/BGEMuXLTCvFYxuL1tNhqzTbtnFICyJLTNK4a+RechKP75e7w42ClXSnJQw==}
+ engines: {node: '>=20.19.0'}
+ peerDependencies:
+ '@csstools/css-parser-algorithms': ^4.0.0
+ '@csstools/css-tokenizer': ^4.0.0
+
+ '@csstools/css-parser-algorithms@4.0.0':
+ resolution: {integrity: sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==}
+ engines: {node: '>=20.19.0'}
+ peerDependencies:
+ '@csstools/css-tokenizer': ^4.0.0
+
+ '@csstools/css-syntax-patches-for-csstree@1.1.0':
+ resolution: {integrity: sha512-H4tuz2nhWgNKLt1inYpoVCfbJbMwX/lQKp3g69rrrIMIYlFD9+zTykOKhNR8uGrAmbS/kT9n6hTFkmDkxLgeTA==}
+
+ '@csstools/css-tokenizer@4.0.0':
+ resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==}
+ engines: {node: '>=20.19.0'}
+
'@cypress/request-promise@5.0.0':
resolution: {integrity: sha512-eKdYVpa9cBEw2kTBlHeu1PP16Blwtum6QHg/u9s/MoHkZfuo1pRGka1VlUHXF5kdew82BvOJVVGk0x8X0nbp+w==}
engines: {node: '>=0.10.0'}
@@ -1261,6 +1373,15 @@ packages:
'@eshaz/web-worker@1.2.2':
resolution: {integrity: sha512-WxXiHFmD9u/owrzempiDlBB1ZYqiLnm9s6aPc8AlFQalq2tKmqdmMr9GXOupDgzXtqnBipj8Un0gkIm7Sjf8mw==}
+ '@exodus/bytes@1.15.0':
+ resolution: {integrity: sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==}
+ engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
+ peerDependencies:
+ '@noble/hashes': ^1.8.0 || ^2.0.0
+ peerDependenciesMeta:
+ '@noble/hashes':
+ optional: true
+
'@google/genai@1.44.0':
resolution: {integrity: sha512-kRt9ZtuXmz+tLlcNntN/VV4LRdpl6ZOu5B1KbfNgfR65db15O6sUQcwnwLka8sT/V6qysD93fWrgJHF2L7dA9A==}
engines: {node: '>=20.0.0'}
@@ -2168,119 +2289,123 @@ packages:
resolution: {integrity: sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw==}
engines: {node: '>=14'}
+ '@oxc-project/runtime@0.115.0':
+ resolution: {integrity: sha512-Rg8Wlt5dCbXhQnsXPrkOjL1DTSvXLgb2R/KYfnf1/K+R0k6UMLEmbQXPM+kwrWqSmWA2t0B1EtHy2/3zikQpvQ==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+
'@oxc-project/types@0.115.0':
resolution: {integrity: sha512-4n91DKnebUS4yjUHl2g3/b2T+IUdCfmoZGhmwsovZCDaJSs+QkVAM+0AqqTxHSsHfeiMuueT75cZaZcT/m0pSw==}
- '@oxfmt/binding-android-arm-eabi@0.38.0':
- resolution: {integrity: sha512-lTN4//sgYywK8ulQo7a/EZVzOTGomGQv2IG/7tMYdqTV3xN3QTqWpXcZBGUzaicC4B882N+5zJLYZ37IWfUMcg==}
+ '@oxfmt/binding-android-arm-eabi@0.40.0':
+ resolution: {integrity: sha512-S6zd5r1w/HmqR8t0CTnGjFTBLDq2QKORPwriCHxo4xFNuhmOTABGjPaNvCJJVnrKBLsohOeiDX3YqQfJPF+FXw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm]
os: [android]
- '@oxfmt/binding-android-arm64@0.38.0':
- resolution: {integrity: sha512-XbVgqR1WsIcCkfxwh2tdg3M1MWgR23YOboW2nbB8ab0gInNNLGy7cIAdr78XaoG/bGdaF4488XRhuGWq67xrzA==}
+ '@oxfmt/binding-android-arm64@0.40.0':
+ resolution: {integrity: sha512-/mbS9UUP/5Vbl2D6osIdcYiP0oie63LKMoTyGj5hyMCK/SFkl3EhtyRAfdjPvuvHC0SXdW6ePaTKkBSq1SNcIw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [android]
- '@oxfmt/binding-darwin-arm64@0.38.0':
- resolution: {integrity: sha512-AHb6zUzWaSJra7lnPkI+Sqwu33bVWVTwCozcw9QTX8vwHaI1+5d5STqBcsJf63eSuRVRlflwMS4erlAPh3fXZw==}
+ '@oxfmt/binding-darwin-arm64@0.40.0':
+ resolution: {integrity: sha512-wRt8fRdfLiEhnRMBonlIbKrJWixoEmn6KCjKE9PElnrSDSXETGZfPb8ee+nQNTobXkCVvVLytp2o0obAsxl78Q==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [darwin]
- '@oxfmt/binding-darwin-x64@0.38.0':
- resolution: {integrity: sha512-VmlmTyn7LL7Xi5htjosxGpJJHf3Drx5mgXxKE8+NT10uBXTaG3FHpRYhW3Zg5Qp7omH92Lj1+IHYqQG/HZpLnw==}
+ '@oxfmt/binding-darwin-x64@0.40.0':
+ resolution: {integrity: sha512-fzowhqbOE/NRy+AE5ob0+Y4X243WbWzDb00W+pKwD7d9tOqsAFbtWUwIyqqCoCLxj791m2xXIEeLH/3uz7zCCg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [darwin]
- '@oxfmt/binding-freebsd-x64@0.38.0':
- resolution: {integrity: sha512-LynMLRqaUEAV6n4svTFanFOAnJ9D6aCCfymJ2yhMSh5fYFgCCO4q5LzPV2nATKKoyPocSErFSmYREsOFbkIlCg==}
+ '@oxfmt/binding-freebsd-x64@0.40.0':
+ resolution: {integrity: sha512-agZ9ITaqdBjcerRRFEHB8s0OyVcQW8F9ZxsszjxzeSthQ4fcN2MuOtQFWec1ed8/lDa50jSLHVE2/xPmTgtCfQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [freebsd]
- '@oxfmt/binding-linux-arm-gnueabihf@0.38.0':
- resolution: {integrity: sha512-HRRZtOXcss5+bGqQcYahILgt14+Iu/Olf6fnoKq5ctOzU21PGHVB+zuocgt+/+ixoMLV1Drvok3ns7QwnLwNTA==}
+ '@oxfmt/binding-linux-arm-gnueabihf@0.40.0':
+ resolution: {integrity: sha512-ZM2oQ47p28TP1DVIp7HL1QoMUgqlBFHey0ksHct7tMXoU5BqjNvPWw7888azzMt25lnyPODVuye1wvNbvVUFOA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm]
os: [linux]
- '@oxfmt/binding-linux-arm-musleabihf@0.38.0':
- resolution: {integrity: sha512-kScH8XnH7TRUckMOSZ5115Vvr2CQq+iPsuXPEzwUXSxh+gDLzt+GsXuvCsaPxp1KP+dQj88VrIjeQ4V0f9NRKw==}
+ '@oxfmt/binding-linux-arm-musleabihf@0.40.0':
+ resolution: {integrity: sha512-RBFPAxRAIsMisKM47Oe6Lwdv6agZYLz02CUhVCD1sOv5ajAcRMrnwCFBPWwGXpazToW2mjnZxFos8TuFjTU15A==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm]
os: [linux]
- '@oxfmt/binding-linux-arm64-gnu@0.38.0':
- resolution: {integrity: sha512-PUVn/vGsMs83eLhNXLNjR+Qw/EPiNxU9Tx+p+aZBK0RT9/k6RNgh/O4F1TxS4tdISmf3SSgjdnMOVW3ZfQZ2mA==}
+ '@oxfmt/binding-linux-arm64-gnu@0.40.0':
+ resolution: {integrity: sha512-Nb2XbQ+wV3W2jSIihXdPj7k83eOxeSgYP3N/SRXvQ6ZYPIk6Q86qEh5Gl/7OitX3bQoQrESqm1yMLvZV8/J7dA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
- '@oxfmt/binding-linux-arm64-musl@0.38.0':
- resolution: {integrity: sha512-LhtmaLCMGtAIEtaTBAoKLF3QVt+IDKIjdEZvsf0msLeTUFKxyoTNScYBXbkmvqGrm37vV0JjTPvm+OaSh3np5A==}
+ '@oxfmt/binding-linux-arm64-musl@0.40.0':
+ resolution: {integrity: sha512-tGmWhLD/0YMotCdfezlT6tC/MJG/wKpo4vnQ3Cq+4eBk/BwNv7EmkD0VkD5F/dYkT3b8FNU01X2e8vvJuWoM1w==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
- '@oxfmt/binding-linux-ppc64-gnu@0.38.0':
- resolution: {integrity: sha512-tO6tPaS21o0MaRqmOi9e3sDotlW4c+1gCx4SwdrfDXm3Y1vmIZWh0qB6t/Xh77bIGVr/4fC95eKOhKLPGwdL+Q==}
+ '@oxfmt/binding-linux-ppc64-gnu@0.40.0':
+ resolution: {integrity: sha512-rVbFyM3e7YhkVnp0IVYjaSHfrBWcTRWb60LEcdNAJcE2mbhTpbqKufx0FrhWfoxOrW/+7UJonAOShoFFLigDqQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ppc64]
os: [linux]
- '@oxfmt/binding-linux-riscv64-gnu@0.38.0':
- resolution: {integrity: sha512-djEqwFUHczstFKp5aT43TuRWxyKZSkIZUfGXIEKa0srmIAt1CXQO5O8xLgNG4SGkXTRB1domFfCE68t9SkSmfA==}
+ '@oxfmt/binding-linux-riscv64-gnu@0.40.0':
+ resolution: {integrity: sha512-3ZqBw14JtWeEoLiioJcXSJz8RQyPE+3jLARnYM1HdPzZG4vk+Ua8CUupt2+d+vSAvMyaQBTN2dZK+kbBS/j5mA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
- '@oxfmt/binding-linux-riscv64-musl@0.38.0':
- resolution: {integrity: sha512-76EgMMtS6sIE+9Pl9q2GZgZpbZSzqtjQhUUIWl0RVNfHg66tstdJMhY2LXESjDYhc5vFYt9qdQNM0w0zg3onPw==}
+ '@oxfmt/binding-linux-riscv64-musl@0.40.0':
+ resolution: {integrity: sha512-JJ4PPSdcbGBjPvb+O7xYm2FmAsKCyuEMYhqatBAHMp/6TA6rVlf9Z/sYPa4/3Bommb+8nndm15SPFRHEPU5qFA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
- '@oxfmt/binding-linux-s390x-gnu@0.38.0':
- resolution: {integrity: sha512-JYNr3i9z/YguZg088kopjvz49hDxTEL193mYL2/02uq/6BLlQRMaKrePEITTHm/vUu4ZquAKgu4mDib6pGWdyg==}
+ '@oxfmt/binding-linux-s390x-gnu@0.40.0':
+ resolution: {integrity: sha512-Kp0zNJoX9Ik77wUya2tpBY3W9f40VUoMQLWVaob5SgCrblH/t2xr/9B2bWHfs0WCefuGmqXcB+t0Lq77sbBmZw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x]
os: [linux]
- '@oxfmt/binding-linux-x64-gnu@0.38.0':
- resolution: {integrity: sha512-Lf+/Keaw1kBKx0U3HT5PsA7/3VO4ZOmaqo4sWaeAJ6tYeX8h/2IZcEONhjry6T4BETza78z6xI3Qx+18QZix6A==}
+ '@oxfmt/binding-linux-x64-gnu@0.40.0':
+ resolution: {integrity: sha512-7YTCNzleWTaQTqNGUNQ66qVjpoV6DjbCOea+RnpMBly2bpzrI/uu7Rr+2zcgRfNxyjXaFTVQKaRKjqVdeUfeVA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
- '@oxfmt/binding-linux-x64-musl@0.38.0':
- resolution: {integrity: sha512-4O6sf6OQuz1flk0TDrrtmXOVO3letA7fYe2IEAiJOQvKhJcMU08NiIVODQjMGZ6IQh1q91B+TlliDfbsYalw8A==}
+ '@oxfmt/binding-linux-x64-musl@0.40.0':
+ resolution: {integrity: sha512-hWnSzJ0oegeOwfOEeejYXfBqmnRGHusgtHfCPzmvJvHTwy1s3Neo59UKc1CmpE3zxvrCzJoVHos0rr97GHMNPw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
- '@oxfmt/binding-openharmony-arm64@0.38.0':
- resolution: {integrity: sha512-GNocbjYnielmKVBk+r/2Vc4E3oTsAO4+5gRuroUVx86Jv+mpD+hyFkf260/by0YtpF1ipqyxR8chOSgRQvD2zQ==}
+ '@oxfmt/binding-openharmony-arm64@0.40.0':
+ resolution: {integrity: sha512-28sJC1lR4qtBJGzSRRbPnSW3GxU2+4YyQFE6rCmsUYqZ5XYH8jg0/w+CvEzQ8TuAQz5zLkcA25nFQGwoU0PT3Q==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [openharmony]
- '@oxfmt/binding-win32-arm64-msvc@0.38.0':
- resolution: {integrity: sha512-AwgjBHRxPckbazLpECuPOSzYlppYR1CBeUSuzZuClsmTnlZA9O1MexCEP9CROe03Yo1xBGvYtiCjwKZMBChGkg==}
+ '@oxfmt/binding-win32-arm64-msvc@0.40.0':
+ resolution: {integrity: sha512-cDkRnyT0dqwF5oIX1Cv59HKCeZQFbWWdUpXa3uvnHFT2iwYSSZspkhgjXjU6iDp5pFPaAEAe9FIbMoTgkTmKPg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [win32]
- '@oxfmt/binding-win32-ia32-msvc@0.38.0':
- resolution: {integrity: sha512-c3u+ak6Zrh1g6pM2TgNVvOgkm7q1XaIX+5Mgxvu38ozJ5OfM8c7HZk3glMdBzlTD2uK0sSfgBq1kuXwCe1NOGg==}
+ '@oxfmt/binding-win32-ia32-msvc@0.40.0':
+ resolution: {integrity: sha512-7rPemBJjqm5Gkv6ZRCPvK8lE6AqQ/2z31DRdWazyx2ZvaSgL7QGofHXHNouRpPvNsT9yxRNQJgigsWkc+0qg4w==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ia32]
os: [win32]
- '@oxfmt/binding-win32-x64-msvc@0.38.0':
- resolution: {integrity: sha512-wud1Hz0D2hYrhk6exxQQndn1htcA28wAcFb1vtP3ZXSzPFtMvc7ag/VNPv6nz6mDzM8X660jUwGEac99QcrVsA==}
+ '@oxfmt/binding-win32-x64-msvc@0.40.0':
+ resolution: {integrity: sha512-/Zmj0yTYSvmha6TG1QnoLqVT7ZMRDqXvFXXBQpIjteEwx9qvUYMBH2xbiOFhDeMUJkGwC3D6fdKsFtaqUvkwNA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [win32]
@@ -2315,116 +2440,116 @@ packages:
cpu: [x64]
os: [win32]
- '@oxlint/binding-android-arm-eabi@1.53.0':
- resolution: {integrity: sha512-JC89/jAx4d2zhDIbK8MC4L659FN1WiMXMBkNg7b33KXSkYpUgcbf+0nz7+EPRg+VwWiZVfaoFkNHJ7RXYb5Neg==}
+ '@oxlint/binding-android-arm-eabi@1.55.0':
+ resolution: {integrity: sha512-NhvgAhncTSOhRahQSCnkK/4YIGPjTmhPurQQ2dwt2IvwCMTvZRW5vF2K10UBOxFve4GZDMw6LtXZdC2qeuYIVQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm]
os: [android]
- '@oxlint/binding-android-arm64@1.53.0':
- resolution: {integrity: sha512-CY+pZfi+uyeU7AwFrEnjsNT+VfxYmKLMuk7bVxArd8f+09hQbJb8f7C7EpvTfNqrCK1J8zZlaYI4LltmEctgbQ==}
+ '@oxlint/binding-android-arm64@1.55.0':
+ resolution: {integrity: sha512-P9iWRh+Ugqhg+D7rkc7boHX8o3H2h7YPcZHQIgvVBgnua5tk4LR2L+IBlreZs58/95cd2x3/004p5VsQM9z4SA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [android]
- '@oxlint/binding-darwin-arm64@1.53.0':
- resolution: {integrity: sha512-0aqsC4HDQ94oI6kMz64iaOJ1f3bCVArxvaHJGOScBvFz6CcQedXi5b70Xg09CYjKNaHA56dW0QJfoZ/111kz1A==}
+ '@oxlint/binding-darwin-arm64@1.55.0':
+ resolution: {integrity: sha512-esakkJIt7WFAhT30P/Qzn96ehFpzdZ1mNuzpOb8SCW7lI4oB8VsyQnkSHREM671jfpuBb/o2ppzBCx5l0jpgMA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [darwin]
- '@oxlint/binding-darwin-x64@1.53.0':
- resolution: {integrity: sha512-e+KvuaWtnisyWojO/t5qKDbp2dvVpg+1dl4MGnTb21QpY4+4+9Y1XmZPaztcA2XNvy4BIaXFW+9JH9tMpSBqUg==}
+ '@oxlint/binding-darwin-x64@1.55.0':
+ resolution: {integrity: sha512-xDMFRCCAEK9fOH6As2z8ELsC+VDGSFRHwIKVSilw+xhgLwTDFu37rtmRbmUlx8rRGS6cWKQPTc47AVxAZEVVPQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [darwin]
- '@oxlint/binding-freebsd-x64@1.53.0':
- resolution: {integrity: sha512-hpU0ZHVeblFjmZDfgi9BxhhCpURh0KjoFy5V+Tvp9sg/fRcnMUEfaJrgz+jQfOX4jctlVWrAs1ANs91+5iV+zA==}
+ '@oxlint/binding-freebsd-x64@1.55.0':
+ resolution: {integrity: sha512-mYZqnwUD7ALCRxGenyLd1uuG+rHCL+OTT6S8FcAbVm/ZT2AZMGjvibp3F6k1SKOb2aeqFATmwRykrE41Q0GWVw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [freebsd]
- '@oxlint/binding-linux-arm-gnueabihf@1.53.0':
- resolution: {integrity: sha512-ccKxOpw+X4xa2pO+qbTOpxQ2x1+Ag3ViRQMnWt3gHp1LcpNgS1xd6GYc3OvehmHtrXqEV3YGczZ0I1qpBB4/2A==}
+ '@oxlint/binding-linux-arm-gnueabihf@1.55.0':
+ resolution: {integrity: sha512-LcX6RYcF9vL9ESGwJW3yyIZ/d/ouzdOKXxCdey1q0XJOW1asrHsIg5MmyKdEBR4plQx+shvYeQne7AzW5f3T1w==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm]
os: [linux]
- '@oxlint/binding-linux-arm-musleabihf@1.53.0':
- resolution: {integrity: sha512-UBkBvmzSmlyH2ZObQMDKW/TuyTmUtP/XClPUyU2YLwj0qLopZTZxnDz4VG5d3wz1HQuZXO0o1QqsnQUW1v4a6Q==}
+ '@oxlint/binding-linux-arm-musleabihf@1.55.0':
+ resolution: {integrity: sha512-C+8GS1rPtK+dI7mJFkqoRBkDuqbrNihnyYQsJPS9ez+8zF9JzfvU19lawqt4l/Y23o5uQswE/DORa8aiXUih3w==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm]
os: [linux]
- '@oxlint/binding-linux-arm64-gnu@1.53.0':
- resolution: {integrity: sha512-PQJJ1izoH9p61las6rZ0BWOznAhTDMmdUPL2IEBLuXFwhy2mSloYHvRkk39PSYJ1DyG+trqU5Z9ZbtHSGH6plg==}
+ '@oxlint/binding-linux-arm64-gnu@1.55.0':
+ resolution: {integrity: sha512-ErLE4XbmcCopA4/CIDiH6J1IAaDOMnf/KSx/aFObs4/OjAAM3sFKWGZ57pNOMxhhyBdcmcXwYymph9GwcpcqgQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
- '@oxlint/binding-linux-arm64-musl@1.53.0':
- resolution: {integrity: sha512-GXI1o4Thn/rtnRIL38BwrDMwVcUbIHKCsOixIWf/CkU3fCG3MXFzFTtDMt+34ik0Qk452d8kcpksL0w/hUkMZA==}
+ '@oxlint/binding-linux-arm64-musl@1.55.0':
+ resolution: {integrity: sha512-/kp65avi6zZfqEng56TTuhiy3P/3pgklKIdf38yvYeJ9/PgEeRA2A2AqKAKbZBNAqUzrzHhz9jF6j/PZvhJzTQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
- '@oxlint/binding-linux-ppc64-gnu@1.53.0':
- resolution: {integrity: sha512-Uahk7IVs2yBamCgeJ3XKpKT9Vh+de0pDKISFKnjEcI3c/w2CFHk1+W6Q6G3KI56HGwE9PWCp6ayhA9whXWkNIQ==}
+ '@oxlint/binding-linux-ppc64-gnu@1.55.0':
+ resolution: {integrity: sha512-A6pTdXwcEEwL/nmz0eUJ6WxmxcoIS+97GbH96gikAyre3s5deC7sts38ZVVowjS2QQFuSWkpA4ZmQC0jZSNvJQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ppc64]
os: [linux]
- '@oxlint/binding-linux-riscv64-gnu@1.53.0':
- resolution: {integrity: sha512-sWtcU9UkrKMWsGKdFy8R6jkm9Q0VVG1VCpxVuh0HzRQQi3ENI1Nh5CkpsdfUs2MKRcOoHKbXqTscunuXjhxoxQ==}
+ '@oxlint/binding-linux-riscv64-gnu@1.55.0':
+ resolution: {integrity: sha512-clj0lnIN+V52G9tdtZl0LbdTSurnZ1NZj92Je5X4lC7gP5jiCSW+Y/oiDiSauBAD4wrHt2S7nN3pA0zfKYK/6Q==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
- '@oxlint/binding-linux-riscv64-musl@1.53.0':
- resolution: {integrity: sha512-aXew1+HDvCdExijX/8NBVC854zJwxhKP3l9AHFSHQNo4EanlHtzDMIlIvP3raUkL0vXtFCkTFYezzU5HjstB8A==}
+ '@oxlint/binding-linux-riscv64-musl@1.55.0':
+ resolution: {integrity: sha512-NNu08pllN5x/O94/sgR3DA8lbrGBnTHsINZZR0hcav1sj79ksTiKKm1mRzvZvacwQ0hUnGinFo+JO75ok2PxYg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
- '@oxlint/binding-linux-s390x-gnu@1.53.0':
- resolution: {integrity: sha512-rVpyBSqPGou9sITcsoXqUoGBUH74bxYLYOAGUqN599Zu6BQBlBU9hh3bJQ/20D1xrhhrsbiCpVPvXpLPM5nL1w==}
+ '@oxlint/binding-linux-s390x-gnu@1.55.0':
+ resolution: {integrity: sha512-BvfQz3PRlWZRoEZ17dZCqgQsMRdpzGZomJkVATwCIGhHVVeHJMQdmdXPSjcT1DCNUrOjXnVyj1RGDj5+/Je2+Q==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x]
os: [linux]
- '@oxlint/binding-linux-x64-gnu@1.53.0':
- resolution: {integrity: sha512-eOyeQ8qFQ2geXmlWJuXAOaek0hFhbMLlYsU457NMLKDRoC43Xf+eDPZ9Yk0n9jDaGJ5zBl/3Dy8wo41cnIXuLA==}
+ '@oxlint/binding-linux-x64-gnu@1.55.0':
+ resolution: {integrity: sha512-ngSOoFCSBMKVQd24H8zkbcBNc7EHhjnF1sv3mC9NNXQ/4rRjI/4Dj9+9XoDZeFEkF1SX1COSBXF1b2Pr9rqdEw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
- '@oxlint/binding-linux-x64-musl@1.53.0':
- resolution: {integrity: sha512-S6rBArW/zD1tob8M9PwKYrRmz+j1ss1+wjbRAJCWKd7TC3JB6noDiA95pIj9zOZVVp04MIzy5qymnYusrEyXzg==}
+ '@oxlint/binding-linux-x64-musl@1.55.0':
+ resolution: {integrity: sha512-BDpP7W8GlaG7BR6QjGZAleYzxoyKc/D24spZIF2mB3XsfALQJJT/OBmP8YpeTb1rveFSBHzl8T7l0aqwkWNdGA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
- '@oxlint/binding-openharmony-arm64@1.53.0':
- resolution: {integrity: sha512-sd/A0Ny5sN0D/MJtlk7w2jGY4bJQou7gToa9WZF7Sj6HTyVzvlzKJWiOHfr4SulVk4ndiFQ8rKmF9rXP0EcF3A==}
+ '@oxlint/binding-openharmony-arm64@1.55.0':
+ resolution: {integrity: sha512-PS6GFvmde/pc3fCA2Srt51glr8Lcxhpf6WIBFfLphndjRrD34NEcses4TSxQrEcxYo6qVywGfylM0ZhSCF2gGA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [openharmony]
- '@oxlint/binding-win32-arm64-msvc@1.53.0':
- resolution: {integrity: sha512-QC3q7b51Er/ZurEFcFzc7RpQ/YEoEBLJuCp3WoOzhSHHH/nkUKFy+igOxlj1z3LayhEZPDQQ7sXvv2PM2cdG3Q==}
+ '@oxlint/binding-win32-arm64-msvc@1.55.0':
+ resolution: {integrity: sha512-P6JcLJGs/q1UOvDLzN8otd9JsH4tsuuPDv+p7aHqHM3PrKmYdmUvkNj4K327PTd35AYcznOCN+l4ZOaq76QzSw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [win32]
- '@oxlint/binding-win32-ia32-msvc@1.53.0':
- resolution: {integrity: sha512-3OvLgOqwd705hWHV2i8ni80pilvg6BUgpC2+xtVu++e/q28LKVohGh5J5QYJOrRMfWmxK0M/AUu43vUw62LAKQ==}
+ '@oxlint/binding-win32-ia32-msvc@1.55.0':
+ resolution: {integrity: sha512-gzkk4zE2zsE+WmRxFOiAZHpCpUNDFytEakqNXoNHW+PnYEOTPKDdW6nrzgSeTbGKVPXNAKQnRnMgrh7+n3Xueg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ia32]
os: [win32]
- '@oxlint/binding-win32-x64-msvc@1.53.0':
- resolution: {integrity: sha512-xTiOkntexCdJytZ7ArIIgl3vGW5ujMM3sJNM7/+iqGAVJagCqjFFWn68HRWRLeyT66c95uR+CeFmQFI6mLQqDw==}
+ '@oxlint/binding-win32-x64-msvc@1.55.0':
+ resolution: {integrity: sha512-ZFALNow2/og75gvYzNP7qe+rREQ5xunktwA+lgykoozHZ6hw9bqg4fn5j2UvG4gIn1FXqrZHkOAXuPf5+GOYTQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [win32]
@@ -2622,131 +2747,6 @@ packages:
'@rolldown/pluginutils@1.0.0-rc.9':
resolution: {integrity: sha512-w6oiRWgEBl04QkFZgmW+jnU1EC9b57Oihi2ot3HNWIQRqgHp5PnYDia5iZ5FF7rpa4EQdiqMDXjlqKGXBhsoXw==}
- '@rollup/rollup-android-arm-eabi@4.59.0':
- resolution: {integrity: sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==}
- cpu: [arm]
- os: [android]
-
- '@rollup/rollup-android-arm64@4.59.0':
- resolution: {integrity: sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==}
- cpu: [arm64]
- os: [android]
-
- '@rollup/rollup-darwin-arm64@4.59.0':
- resolution: {integrity: sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==}
- cpu: [arm64]
- os: [darwin]
-
- '@rollup/rollup-darwin-x64@4.59.0':
- resolution: {integrity: sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==}
- cpu: [x64]
- os: [darwin]
-
- '@rollup/rollup-freebsd-arm64@4.59.0':
- resolution: {integrity: sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==}
- cpu: [arm64]
- os: [freebsd]
-
- '@rollup/rollup-freebsd-x64@4.59.0':
- resolution: {integrity: sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==}
- cpu: [x64]
- os: [freebsd]
-
- '@rollup/rollup-linux-arm-gnueabihf@4.59.0':
- resolution: {integrity: sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==}
- cpu: [arm]
- os: [linux]
-
- '@rollup/rollup-linux-arm-musleabihf@4.59.0':
- resolution: {integrity: sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==}
- cpu: [arm]
- os: [linux]
-
- '@rollup/rollup-linux-arm64-gnu@4.59.0':
- resolution: {integrity: sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==}
- cpu: [arm64]
- os: [linux]
-
- '@rollup/rollup-linux-arm64-musl@4.59.0':
- resolution: {integrity: sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==}
- cpu: [arm64]
- os: [linux]
-
- '@rollup/rollup-linux-loong64-gnu@4.59.0':
- resolution: {integrity: sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==}
- cpu: [loong64]
- os: [linux]
-
- '@rollup/rollup-linux-loong64-musl@4.59.0':
- resolution: {integrity: sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==}
- cpu: [loong64]
- os: [linux]
-
- '@rollup/rollup-linux-ppc64-gnu@4.59.0':
- resolution: {integrity: sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==}
- cpu: [ppc64]
- os: [linux]
-
- '@rollup/rollup-linux-ppc64-musl@4.59.0':
- resolution: {integrity: sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==}
- cpu: [ppc64]
- os: [linux]
-
- '@rollup/rollup-linux-riscv64-gnu@4.59.0':
- resolution: {integrity: sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==}
- cpu: [riscv64]
- os: [linux]
-
- '@rollup/rollup-linux-riscv64-musl@4.59.0':
- resolution: {integrity: sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==}
- cpu: [riscv64]
- os: [linux]
-
- '@rollup/rollup-linux-s390x-gnu@4.59.0':
- resolution: {integrity: sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==}
- cpu: [s390x]
- os: [linux]
-
- '@rollup/rollup-linux-x64-gnu@4.59.0':
- resolution: {integrity: sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==}
- cpu: [x64]
- os: [linux]
-
- '@rollup/rollup-linux-x64-musl@4.59.0':
- resolution: {integrity: sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==}
- cpu: [x64]
- os: [linux]
-
- '@rollup/rollup-openbsd-x64@4.59.0':
- resolution: {integrity: sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==}
- cpu: [x64]
- os: [openbsd]
-
- '@rollup/rollup-openharmony-arm64@4.59.0':
- resolution: {integrity: sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==}
- cpu: [arm64]
- os: [openharmony]
-
- '@rollup/rollup-win32-arm64-msvc@4.59.0':
- resolution: {integrity: sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==}
- cpu: [arm64]
- os: [win32]
-
- '@rollup/rollup-win32-ia32-msvc@4.59.0':
- resolution: {integrity: sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==}
- cpu: [ia32]
- os: [win32]
-
- '@rollup/rollup-win32-x64-gnu@4.59.0':
- resolution: {integrity: sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==}
- cpu: [x64]
- os: [win32]
-
- '@rollup/rollup-win32-x64-msvc@4.59.0':
- resolution: {integrity: sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==}
- cpu: [x64]
- os: [win32]
-
'@scure/base@2.0.0':
resolution: {integrity: sha512-3E1kpuZginKkek01ovG8krQ0Z44E3DHPjc5S2rjJw9lZn3KSQOs8S7wqikF/AH7iRanHypj85uGyxk0XAyC37w==}
@@ -2823,6 +2823,10 @@ packages:
resolution: {integrity: sha512-Hj4WoYWMJnSpM6/kchsm4bUNTL9XiSyhvoMb2KIq4VJzyDt7JpGHUZHkVNPZVC7YE1tf8tPeVauxpFBKGW4/KQ==}
engines: {node: '>=18.0.0'}
+ '@smithy/abort-controller@4.2.12':
+ resolution: {integrity: sha512-xolrFw6b+2iYGl6EcOL7IJY71vvyZ0DJ3mcKtpykqPe2uscwtzDZJa1uVQXyP7w9Dd+kGwYnPbMsJrGISKiY/Q==}
+ engines: {node: '>=18.0.0'}
+
'@smithy/chunked-blob-reader-native@4.2.2':
resolution: {integrity: sha512-QzzYIlf4yg0w5TQaC9VId3B3ugSk1MI/wb7tgcHtd7CBV9gNRKZrhc2EPSxSZuDy10zUZ0lomNMgkc6/VVe8xg==}
engines: {node: '>=18.0.0'}
@@ -2835,10 +2839,18 @@ packages:
resolution: {integrity: sha512-IRTkd6ps0ru+lTWnfnsbXzW80A8Od8p3pYiZnW98K2Hb20rqfsX7VTlfUwhrcOeSSy68Gn9WBofwPuw3e5CCsg==}
engines: {node: '>=18.0.0'}
+ '@smithy/config-resolver@4.4.11':
+ resolution: {integrity: sha512-YxFiiG4YDAtX7WMN7RuhHZLeTmRRAOyCbr+zB8e3AQzHPnUhS8zXjB1+cniPVQI3xbWsQPM0X2aaIkO/ME0ymw==}
+ engines: {node: '>=18.0.0'}
+
'@smithy/config-resolver@4.4.9':
resolution: {integrity: sha512-ejQvXqlcU30h7liR9fXtj7PIAau1t/sFbJpgWPfiYDs7zd16jpH0IsSXKcba2jF6ChTXvIjACs27kNMc5xxE2Q==}
engines: {node: '>=18.0.0'}
+ '@smithy/core@3.23.11':
+ resolution: {integrity: sha512-952rGf7hBRnhUIaeLp6q4MptKW8sPFe5VvkoZ5qIzFAtx6c/QZ/54FS3yootsyUSf9gJX/NBqEBNdNR7jMIlpQ==}
+ engines: {node: '>=18.0.0'}
+
'@smithy/core@3.23.6':
resolution: {integrity: sha512-4xE+0L2NrsFKpEVFlFELkIHQddBvMbQ41LRIP74dGCXnY1zQ9DgksrBcRBDJT+iOzGy4VEJIeU3hkUK5mn06kg==}
engines: {node: '>=18.0.0'}
@@ -2855,6 +2867,10 @@ packages:
resolution: {integrity: sha512-lBXrS6ku0kTj3xLmsJW0WwqWbGQ6ueooYyp/1L9lkyT0M02C+DWwYwc5aTyXFbRaK38ojALxNixg+LxKSHZc0g==}
engines: {node: '>=18.0.0'}
+ '@smithy/credential-provider-imds@4.2.12':
+ resolution: {integrity: sha512-cr2lR792vNZcYMriSIj+Um3x9KWrjcu98kn234xA6reOAFMmbRpQMOv8KPgEmLLtx3eldU6c5wALKFqNOhugmg==}
+ engines: {node: '>=18.0.0'}
+
'@smithy/eventstream-codec@4.2.10':
resolution: {integrity: sha512-A4ynrsFFfSXUHicfTcRehytppFBcY3HQxEGYiyGktPIOye3Ot7fxpiy4VR42WmtGI4Wfo6OXt/c1Ky1nUFxYYQ==}
engines: {node: '>=18.0.0'}
@@ -2903,6 +2919,10 @@ packages:
resolution: {integrity: sha512-U2Hcfl2s3XaYjikN9cT4mPu8ybDbImV3baXR0PkVlC0TTx808bRP3FaPGAzPtB8OByI+JqJ1kyS+7GEgae7+qQ==}
engines: {node: '>=18.0.0'}
+ '@smithy/fetch-http-handler@5.3.15':
+ resolution: {integrity: sha512-T4jFU5N/yiIfrtrsb9uOQn7RdELdM/7HbyLNr6uO/mpkj1ctiVs7CihVr51w4LyQlXWDpXFn4BElf1WmQvZu/A==}
+ engines: {node: '>=18.0.0'}
+
'@smithy/hash-blob-browser@4.2.11':
resolution: {integrity: sha512-DrcAx3PM6AEbWZxsKl6CWAGnVwiz28Wp1ZhNu+Hi4uI/6C1PIZBIaPM2VoqBDAsOWbM6ZVzOEQMxFLLdmb4eBQ==}
engines: {node: '>=18.0.0'}
@@ -2915,6 +2935,10 @@ packages:
resolution: {integrity: sha512-T+p1pNynRkydpdL015ruIoyPSRw9e/SQOWmSAMmmprfswMrd5Ow5igOWNVlvyVFZlxXqGmyH3NQwfwy8r5Jx0A==}
engines: {node: '>=18.0.0'}
+ '@smithy/hash-node@4.2.12':
+ resolution: {integrity: sha512-QhBYbGrbxTkZ43QoTPrK72DoYviDeg6YKDrHTMJbbC+A0sml3kSjzFtXP7BtbyJnXojLfTQldGdUR0RGD8dA3w==}
+ engines: {node: '>=18.0.0'}
+
'@smithy/hash-stream-node@4.2.10':
resolution: {integrity: sha512-w78xsYrOlwXKwN5tv1GnKIRbHb1HygSpeZMP6xDxCPGf1U/xDHjCpJu64c5T35UKyEPwa0bPeIcvU69VY3khUA==}
engines: {node: '>=18.0.0'}
@@ -2927,6 +2951,10 @@ packages:
resolution: {integrity: sha512-cGNMrgykRmddrNhYy1yBdrp5GwIgEkniS7k9O1VLB38yxQtlvrxpZtUVvo6T4cKpeZsriukBuuxfJcdZQc/f/g==}
engines: {node: '>=18.0.0'}
+ '@smithy/invalid-dependency@4.2.12':
+ resolution: {integrity: sha512-/4F1zb7Z8LOu1PalTdESFHR0RbPwHd3FcaG1sI3UEIriQTWakysgJr65lc1jj6QY5ye7aFsisajotH6UhWfm/g==}
+ engines: {node: '>=18.0.0'}
+
'@smithy/is-array-buffer@2.2.0':
resolution: {integrity: sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==}
engines: {node: '>=14.0.0'}
@@ -2951,6 +2979,10 @@ packages:
resolution: {integrity: sha512-UvIfKYAKhCzr4p6jFevPlKhQwyQwlJ6IeKLDhmV1PlYfcW3RL4ROjNEDtSik4NYMi9kDkH7eSwyTP3vNJ/u/Dw==}
engines: {node: '>=18.0.0'}
+ '@smithy/middleware-content-length@4.2.12':
+ resolution: {integrity: sha512-YE58Yz+cvFInWI/wOTrB+DbvUVz/pLn5mC5MvOV4fdRUc6qGwygyngcucRQjAhiCEbmfLOXX0gntSIcgMvAjmA==}
+ engines: {node: '>=18.0.0'}
+
'@smithy/middleware-endpoint@4.4.20':
resolution: {integrity: sha512-9W6Np4ceBP3XCYAGLoMCmn8t2RRVzuD1ndWPLBbv7H9CrwM9Bprf6Up6BM9ZA/3alodg0b7Kf6ftBK9R1N04vw==}
engines: {node: '>=18.0.0'}
@@ -2959,6 +2991,10 @@ packages:
resolution: {integrity: sha512-UEFIejZy54T1EJn2aWJ45voB7RP2T+IRzUqocIdM6GFFa5ClZncakYJfcYnoXt3UsQrZZ9ZRauGm77l9UCbBLw==}
engines: {node: '>=18.0.0'}
+ '@smithy/middleware-endpoint@4.4.25':
+ resolution: {integrity: sha512-dqjLwZs2eBxIUG6Qtw8/YZ4DvzHGIf0DA18wrgtfP6a50UIO7e2nY0FPdcbv5tVJKqWCCU5BmGMOUwT7Puan+A==}
+ engines: {node: '>=18.0.0'}
+
'@smithy/middleware-retry@4.4.37':
resolution: {integrity: sha512-/1psZZllBBSQ7+qo5+hhLz7AEPGLx3Z0+e3ramMBEuPK2PfvLK4SrncDB9VegX5mBn+oP/UTDrM6IHrFjvX1ZA==}
engines: {node: '>=18.0.0'}
@@ -2967,6 +3003,10 @@ packages:
resolution: {integrity: sha512-YhEMakG1Ae57FajERdHNZ4ShOPIY7DsgV+ZoAxo/5BT0KIe+f6DDU2rtIymNNFIj22NJfeeI6LWIifrwM0f+rA==}
engines: {node: '>=18.0.0'}
+ '@smithy/middleware-retry@4.4.42':
+ resolution: {integrity: sha512-vbwyqHRIpIZutNXZpLAozakzamcINaRCpEy1MYmK6xBeW3xN+TyPRA123GjXnuxZIjc9848MRRCugVMTXxC4Eg==}
+ engines: {node: '>=18.0.0'}
+
'@smithy/middleware-serde@4.2.11':
resolution: {integrity: sha512-STQdONGPwbbC7cusL60s7vOa6He6A9w2jWhoapL0mgVjmR19pr26slV+yoSP76SIssMTX/95e5nOZ6UQv6jolg==}
engines: {node: '>=18.0.0'}
@@ -2975,6 +3015,10 @@ packages:
resolution: {integrity: sha512-W9g1bOLui7Xn5FABRVS0o3rXL0gfN37d/8I/W7i0N7oxjx9QecUmXEMSUMADTODwdtka9cN43t5BI2CodLJpng==}
engines: {node: '>=18.0.0'}
+ '@smithy/middleware-serde@4.2.14':
+ resolution: {integrity: sha512-+CcaLoLa5apzSRtloOyG7lQvkUw2ZDml3hRh4QiG9WyEPfW5Ke/3tPOPiPjUneuT59Tpn8+c3RVaUvvkkwqZwg==}
+ engines: {node: '>=18.0.0'}
+
'@smithy/middleware-stack@4.2.10':
resolution: {integrity: sha512-pmts/WovNcE/tlyHa8z/groPeOtqtEpp61q3W0nW1nDJuMq/x+hWa/OVQBtgU0tBqupeXq0VBOLA4UZwE8I0YA==}
engines: {node: '>=18.0.0'}
@@ -2983,6 +3027,10 @@ packages:
resolution: {integrity: sha512-s+eenEPW6RgliDk2IhjD2hWOxIx1NKrOHxEwNUaUXxYBxIyCcDfNULZ2Mu15E3kwcJWBedTET/kEASPV1A1Akg==}
engines: {node: '>=18.0.0'}
+ '@smithy/middleware-stack@4.2.12':
+ resolution: {integrity: sha512-kruC5gRHwsCOuyCd4ouQxYjgRAym2uDlCvQ5acuMtRrcdfg7mFBg6blaxcJ09STpt3ziEkis6bhg1uwrWU7txw==}
+ engines: {node: '>=18.0.0'}
+
'@smithy/node-config-provider@4.3.10':
resolution: {integrity: sha512-UALRbJtVX34AdP2VECKVlnNgidLHA2A7YgcJzwSBg1hzmnO/bZBHl/LDQQyYifzUwp1UOODnl9JJ3KNawpUJ9w==}
engines: {node: '>=18.0.0'}
@@ -2991,6 +3039,10 @@ packages:
resolution: {integrity: sha512-xD17eE7kaLgBBGf5CZQ58hh2YmwK1Z0O8YhffwB/De2jsL0U3JklmhVYJ9Uf37OtUDLF2gsW40Xwwag9U869Gg==}
engines: {node: '>=18.0.0'}
+ '@smithy/node-config-provider@4.3.12':
+ resolution: {integrity: sha512-tr2oKX2xMcO+rBOjobSwVAkV05SIfUKz8iI53rzxEmgW3GOOPOv0UioSDk+J8OpRQnpnhsO3Af6IEBabQBVmiw==}
+ engines: {node: '>=18.0.0'}
+
'@smithy/node-http-handler@4.4.12':
resolution: {integrity: sha512-zo1+WKJkR9x7ZtMeMDAAsq2PufwiLDmkhcjpWPRRkmeIuOm6nq1qjFICSZbnjBvD09ei8KMo26BWxsu2BUU+5w==}
engines: {node: '>=18.0.0'}
@@ -2999,6 +3051,10 @@ packages:
resolution: {integrity: sha512-DamSqaU8nuk0xTJDrYnRzZndHwwRnyj/n/+RqGGCcBKB4qrQem0mSDiWdupaNWdwxzyMU91qxDmHOCazfhtO3A==}
engines: {node: '>=18.0.0'}
+ '@smithy/node-http-handler@4.4.16':
+ resolution: {integrity: sha512-ULC8UCS/HivdCB3jhi+kLFYe4B5gxH2gi9vHBfEIiRrT2jfKiZNiETJSlzRtE6B26XbBHjPtc8iZKSNqMol9bw==}
+ engines: {node: '>=18.0.0'}
+
'@smithy/property-provider@4.2.10':
resolution: {integrity: sha512-5jm60P0CU7tom0eNrZ7YrkgBaoLFXzmqB0wVS+4uK8PPGmosSrLNf6rRd50UBvukztawZ7zyA8TxlrKpF5z9jw==}
engines: {node: '>=18.0.0'}
@@ -3007,6 +3063,10 @@ packages:
resolution: {integrity: sha512-14T1V64o6/ndyrnl1ze1ZhyLzIeYNN47oF/QU6P5m82AEtyOkMJTb0gO1dPubYjyyKuPD6OSVMPDKe+zioOnCg==}
engines: {node: '>=18.0.0'}
+ '@smithy/property-provider@4.2.12':
+ resolution: {integrity: sha512-jqve46eYU1v7pZ5BM+fmkbq3DerkSluPr5EhvOcHxygxzD05ByDRppRwRPPpFrsFo5yDtCYLKu+kreHKVrvc7A==}
+ engines: {node: '>=18.0.0'}
+
'@smithy/protocol-http@5.3.10':
resolution: {integrity: sha512-2NzVWpYY0tRdfeCJLsgrR89KE3NTWT2wGulhNUxYlRmtRmPwLQwKzhrfVaiNlA9ZpJvbW7cjTVChYKgnkqXj1A==}
engines: {node: '>=18.0.0'}
@@ -3015,6 +3075,10 @@ packages:
resolution: {integrity: sha512-hI+barOVDJBkNt4y0L2mu3Ugc0w7+BpJ2CZuLwXtSltGAAwCb3IvnalGlbDV/UCS6a9ZuT3+exd1WxNdLb5IlQ==}
engines: {node: '>=18.0.0'}
+ '@smithy/protocol-http@5.3.12':
+ resolution: {integrity: sha512-fit0GZK9I1xoRlR4jXmbLhoN0OdEpa96ul8M65XdmXnxXkuMxM0Y8HDT0Fh0Xb4I85MBvBClOzgSrV1X2s1Hxw==}
+ engines: {node: '>=18.0.0'}
+
'@smithy/querystring-builder@4.2.10':
resolution: {integrity: sha512-HeN7kEvuzO2DmAzLukE9UryiUvejD3tMp9a1D1NJETerIfKobBUCLfviP6QEk500166eD2IATaXM59qgUI+YDA==}
engines: {node: '>=18.0.0'}
@@ -3023,6 +3087,10 @@ packages:
resolution: {integrity: sha512-7spdikrYiljpket6u0up2Ck2mxhy7dZ0+TDd+S53Dg2DHd6wg+YNJrTCHiLdgZmEXZKI7LJZcwL3721ZRDFiqA==}
engines: {node: '>=18.0.0'}
+ '@smithy/querystring-builder@4.2.12':
+ resolution: {integrity: sha512-6wTZjGABQufekycfDGMEB84BgtdOE/rCVTov+EDXQ8NHKTUNIp/j27IliwP7tjIU9LR+sSzyGBOXjeEtVgzCHg==}
+ engines: {node: '>=18.0.0'}
+
'@smithy/querystring-parser@4.2.10':
resolution: {integrity: sha512-4Mh18J26+ao1oX5wXJfWlTT+Q1OpDR8ssiC9PDOuEgVBGloqg18Fw7h5Ct8DyT9NBYwJgtJ2nLjKKFU6RP1G1Q==}
engines: {node: '>=18.0.0'}
@@ -3031,6 +3099,10 @@ packages:
resolution: {integrity: sha512-nE3IRNjDltvGcoThD2abTozI1dkSy8aX+a2N1Rs55en5UsdyyIXgGEmevUL3okZFoJC77JgRGe99xYohhsjivQ==}
engines: {node: '>=18.0.0'}
+ '@smithy/querystring-parser@4.2.12':
+ resolution: {integrity: sha512-P2OdvrgiAKpkPNKlKUtWbNZKB1XjPxM086NeVhK+W+wI46pIKdWBe5QyXvhUm3MEcyS/rkLvY8rZzyUdmyDZBw==}
+ engines: {node: '>=18.0.0'}
+
'@smithy/service-error-classification@4.2.10':
resolution: {integrity: sha512-0R/+/Il5y8nB/By90o8hy/bWVYptbIfvoTYad0igYQO5RefhNCDmNzqxaMx7K1t/QWo0d6UynqpqN5cCQt1MCg==}
engines: {node: '>=18.0.0'}
@@ -3039,6 +3111,10 @@ packages:
resolution: {integrity: sha512-HkMFJZJUhzU3HvND1+Yw/kYWXp4RPDLBWLcK1n+Vqw8xn4y2YiBhdww8IxhkQjP/QlZun5bwm3vcHc8AqIU3zw==}
engines: {node: '>=18.0.0'}
+ '@smithy/service-error-classification@4.2.12':
+ resolution: {integrity: sha512-LlP29oSQN0Tw0b6D0Xo6BIikBswuIiGYbRACy5ujw/JgWSzTdYj46U83ssf6Ux0GyNJVivs2uReU8pt7Eu9okQ==}
+ engines: {node: '>=18.0.0'}
+
'@smithy/shared-ini-file-loader@4.4.5':
resolution: {integrity: sha512-pHgASxl50rrtOztgQCPmOXFjRW+mCd7ALr/3uXNzRrRoGV5G2+78GOsQ3HlQuBVHCh9o6xqMNvlIKZjWn4Euug==}
engines: {node: '>=18.0.0'}
@@ -3047,6 +3123,10 @@ packages:
resolution: {integrity: sha512-IB/M5I8G0EeXZTHsAxpx51tMQ5R719F3aq+fjEB6VtNcCHDc0ajFDIGDZw+FW9GxtEkgTduiPpjveJdA/CX7sw==}
engines: {node: '>=18.0.0'}
+ '@smithy/shared-ini-file-loader@4.4.7':
+ resolution: {integrity: sha512-HrOKWsUb+otTeo1HxVWeEb99t5ER1XrBi/xka2Wv6NVmTbuCUC1dvlrksdvxFtODLBjsC+PHK+fuy2x/7Ynyiw==}
+ engines: {node: '>=18.0.0'}
+
'@smithy/signature-v4@5.3.10':
resolution: {integrity: sha512-Wab3wW8468WqTKIxI+aZe3JYO52/RYT/8sDOdzkUhjnLakLe9qoQqIcfih/qxcF4qWEFoWBszY0mj5uxffaVXA==}
engines: {node: '>=18.0.0'}
@@ -3055,6 +3135,10 @@ packages:
resolution: {integrity: sha512-V1L6N9aKOBAN4wEHLyqjLBnAz13mtILU0SeDrjOaIZEeN6IFa6DxwRt1NNpOdmSpQUfkBj0qeD3m6P77uzMhgQ==}
engines: {node: '>=18.0.0'}
+ '@smithy/signature-v4@5.3.12':
+ resolution: {integrity: sha512-B/FBwO3MVOL00DaRSXfXfa/TRXRheagt/q5A2NM13u7q+sHS59EOVGQNfG7DkmVtdQm5m3vOosoKAXSqn/OEgw==}
+ engines: {node: '>=18.0.0'}
+
'@smithy/smithy-client@4.12.0':
resolution: {integrity: sha512-R8bQ9K3lCcXyZmBnQqUZJF4ChZmtWT5NLi6x5kgWx5D+/j0KorXcA0YcFg/X5TOgnTCy1tbKc6z2g2y4amFupQ==}
engines: {node: '>=18.0.0'}
@@ -3063,10 +3147,18 @@ packages:
resolution: {integrity: sha512-7k4UxjSpHmPN2AxVhvIazRSzFQjWnud3sOsXcFStzagww17j1cFQYqTSiQ8xuYK3vKLR1Ni8FzuT3VlKr3xCNw==}
engines: {node: '>=18.0.0'}
+ '@smithy/smithy-client@4.12.5':
+ resolution: {integrity: sha512-UqwYawyqSr/aog8mnLnfbPurS0gi4G7IYDcD28cUIBhsvWs1+rQcL2IwkUQ+QZ7dibaoRzhNF99fAQ9AUcO00w==}
+ engines: {node: '>=18.0.0'}
+
'@smithy/types@4.13.0':
resolution: {integrity: sha512-COuLsZILbbQsdrwKQpkkpyep7lCsByxwj7m0Mg5v66/ZTyenlfBc40/QFQ5chO0YN/PNEH1Bi3fGtfXPnYNeDw==}
engines: {node: '>=18.0.0'}
+ '@smithy/types@4.13.1':
+ resolution: {integrity: sha512-787F3yzE2UiJIQ+wYW1CVg2odHjmaWLGksnKQHUrK/lYZSEcy1msuLVvxaR/sI2/aDe9U+TBuLsXnr3vod1g0g==}
+ engines: {node: '>=18.0.0'}
+
'@smithy/url-parser@4.2.10':
resolution: {integrity: sha512-uypjF7fCDsRk26u3qHmFI/ePL7bxxB9vKkE+2WKEciHhz+4QtbzWiHRVNRJwU3cKhrYDYQE3b0MRFtqfLYdA4A==}
engines: {node: '>=18.0.0'}
@@ -3075,6 +3167,10 @@ packages:
resolution: {integrity: sha512-oTAGGHo8ZYc5VZsBREzuf5lf2pAurJQsccMusVZ85wDkX66ojEc/XauiGjzCj50A61ObFTPe6d7Pyt6UBYaing==}
engines: {node: '>=18.0.0'}
+ '@smithy/url-parser@4.2.12':
+ resolution: {integrity: sha512-wOPKPEpso+doCZGIlr+e1lVI6+9VAKfL4kZWFgzVgGWY2hZxshNKod4l2LXS3PRC9otH/JRSjtEHqQ/7eLciRA==}
+ engines: {node: '>=18.0.0'}
+
'@smithy/util-base64@4.3.1':
resolution: {integrity: sha512-BKGuawX4Doq/bI/uEmg+Zyc36rJKWuin3py89PquXBIBqmbnJwBBsmKhdHfNEp0+A4TDgLmT/3MSKZ1SxHcR6w==}
engines: {node: '>=18.0.0'}
@@ -3127,6 +3223,10 @@ packages:
resolution: {integrity: sha512-ui7/Ho/+VHqS7Km2wBw4/Ab4RktoiSshgcgpJzC4keFPs6tLJS4IQwbeahxQS3E/w98uq6E1mirCH/id9xIXeQ==}
engines: {node: '>=18.0.0'}
+ '@smithy/util-defaults-mode-browser@4.3.41':
+ resolution: {integrity: sha512-M1w1Ux0rSVvBOxIIiqbxvZvhnjQ+VUjJrugtORE90BbadSTH+jsQL279KRL3Hv0w69rE7EuYkV/4Lepz/NBW9g==}
+ engines: {node: '>=18.0.0'}
+
'@smithy/util-defaults-mode-node@4.2.39':
resolution: {integrity: sha512-otWuoDm35btJV1L8MyHrPl462B07QCdMTktKc7/yM+Psv6KbED/ziXiHnmr7yPHUjfIwE9S8Max0LO24Mo3ZVg==}
engines: {node: '>=18.0.0'}
@@ -3135,6 +3235,10 @@ packages:
resolution: {integrity: sha512-QDA84CWNe8Akpj15ofLO+1N3Rfg8qa2K5uX0y6HnOp4AnRYRgWrKx/xzbYNbVF9ZsyJUYOfcoaN3y93wA/QJ2A==}
engines: {node: '>=18.0.0'}
+ '@smithy/util-defaults-mode-node@4.2.44':
+ resolution: {integrity: sha512-YPze3/lD1KmWuZsl9JlfhcgGLX7AXhSoaCDtiPntUjNW5/YY0lOHjkcgxyE9x/h5vvS1fzDifMGjzqnNlNiqOQ==}
+ engines: {node: '>=18.0.0'}
+
'@smithy/util-endpoints@3.3.1':
resolution: {integrity: sha512-xyctc4klmjmieQiF9I1wssBWleRV0RhJ2DpO8+8yzi2LO1Z+4IWOZNGZGNj4+hq9kdo+nyfrRLmQTzc16Op2Vg==}
engines: {node: '>=18.0.0'}
@@ -3143,6 +3247,10 @@ packages:
resolution: {integrity: sha512-+4HFLpE5u29AbFlTdlKIT7jfOzZ8PDYZKTb3e+AgLz986OYwqTourQ5H+jg79/66DB69Un1+qKecLnkZdAsYcA==}
engines: {node: '>=18.0.0'}
+ '@smithy/util-endpoints@3.3.3':
+ resolution: {integrity: sha512-VACQVe50j0HZPjpwWcjyT51KUQ4AnsvEaQ2lKHOSL4mNLD0G9BjEniQ+yCt1qqfKfiAHRAts26ud7hBjamrwig==}
+ engines: {node: '>=18.0.0'}
+
'@smithy/util-hex-encoding@4.2.1':
resolution: {integrity: sha512-c1hHtkgAWmE35/50gmdKajgGAKV3ePJ7t6UtEmpfCWJmQE9BQAQPz0URUVI89eSkcDqCtzqllxzG28IQoZPvwA==}
engines: {node: '>=18.0.0'}
@@ -3159,6 +3267,10 @@ packages:
resolution: {integrity: sha512-r3dtF9F+TpSZUxpOVVtPfk09Rlo4lT6ORBqEvX3IBT6SkQAdDSVKR5GcfmZbtl7WKhKnmb3wbDTQ6ibR2XHClw==}
engines: {node: '>=18.0.0'}
+ '@smithy/util-middleware@4.2.12':
+ resolution: {integrity: sha512-Er805uFUOvgc0l8nv0e0su0VFISoxhJ/AwOn3gL2NWNY2LUEldP5WtVcRYSQBcjg0y9NfG8JYrCJaYDpupBHJQ==}
+ engines: {node: '>=18.0.0'}
+
'@smithy/util-retry@4.2.10':
resolution: {integrity: sha512-HrBzistfpyE5uqTwiyLsFHscgnwB0kgv8vySp7q5kZ0Eltn/tjosaSGGDj/jJ9ys7pWzIP/icE2d+7vMKXLv7A==}
engines: {node: '>=18.0.0'}
@@ -3167,6 +3279,10 @@ packages:
resolution: {integrity: sha512-XSZULmL5x6aCTTii59wJqKsY1l3eMIAomRAccW7Tzh9r8s7T/7rdo03oektuH5jeYRlJMPcNP92EuRDvk9aXbw==}
engines: {node: '>=18.0.0'}
+ '@smithy/util-retry@4.2.12':
+ resolution: {integrity: sha512-1zopLDUEOwumjcHdJ1mwBHddubYF8GMQvstVCLC54Y46rqoHwlIU+8ZzUeaBcD+WCJHyDGSeZ2ml9YSe9aqcoQ==}
+ engines: {node: '>=18.0.0'}
+
'@smithy/util-stream@4.5.15':
resolution: {integrity: sha512-OlOKnaqnkU9X+6wEkd7mN+WB7orPbCVDauXOj22Q7VtiTkvy7ZdSsOg4QiNAZMgI4OkvNf+/VLUC3VXkxuWJZw==}
engines: {node: '>=18.0.0'}
@@ -3175,6 +3291,10 @@ packages:
resolution: {integrity: sha512-793BYZ4h2JAQkNHcEnyFxDTcZbm9bVybD0UV/LEWmZ5bkTms7JqjfrLMi2Qy0E5WFcCzLwCAPgcvcvxoeALbAQ==}
engines: {node: '>=18.0.0'}
+ '@smithy/util-stream@4.5.19':
+ resolution: {integrity: sha512-v4sa+3xTweL1CLO2UP0p7tvIMH/Rq1X4KKOxd568mpe6LSLMQCnDHs4uv7m3ukpl3HvcN2JH6jiCS0SNRXKP/w==}
+ engines: {node: '>=18.0.0'}
+
'@smithy/util-uri-escape@4.2.1':
resolution: {integrity: sha512-YmiUDn2eo2IOiWYYvGQkgX5ZkBSiTQu4FlDo5jNPpAxng2t6Sjb6WutnZV9l6VR4eJul1ABmCrnWBC9hKHQa6Q==}
engines: {node: '>=18.0.0'}
@@ -3463,8 +3583,8 @@ packages:
'@types/node@24.12.0':
resolution: {integrity: sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==}
- '@types/node@25.4.0':
- resolution: {integrity: sha512-9wLpoeWuBlcbBpOY3XmzSTG3oscB6xjBEEtn+pYXTfhyXhIxC5FsBer2KTopBlvKEiW9l13po9fq+SJY/5lkhw==}
+ '@types/node@25.5.0':
+ resolution: {integrity: sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==}
'@types/qrcode-terminal@0.12.2':
resolution: {integrity: sha512-v+RcIEJ+Uhd6ygSQ0u5YYY7ZM+la7GgPbs0V/7l/kFs2uO4S8BcIUEMoP7za4DNIqNnUD5npf0A/7kBhrCKG5Q==}
@@ -3511,43 +3631,43 @@ packages:
'@types/yauzl@2.10.3':
resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==}
- '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260311.1':
- resolution: {integrity: sha512-k3UqlA40U9m8meAyliJdbTayDSGZRBGNsEDP2rtjOomLUo2IA0eIi4vNAjQKzsXFtyfoQ59MGAqOLSO/CzVrQA==}
+ '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260312.1':
+ resolution: {integrity: sha512-AhPdPuVe4osxWoeImS21jVhc0VJ2QnzLUZtEFMakY0Rf70C0b6il/m7hwRf9wkr9xXZLVOVJ1kYrpvQRuHFE0Q==}
cpu: [arm64]
os: [darwin]
- '@typescript/native-preview-darwin-x64@7.0.0-dev.20260311.1':
- resolution: {integrity: sha512-8PNUCS1HPeXMK1F+1D3A4MyD+9Nil2mM3mWSwayUZpqT/A+dfEtcoo4Oe7Gz6qvMZbhCjbipwhTC84ilisiE1g==}
+ '@typescript/native-preview-darwin-x64@7.0.0-dev.20260312.1':
+ resolution: {integrity: sha512-9I0P1/c/mQ6UVcQq7SYY/FJD23IN5T2y4GbSFOKQvzNVASV0tMnX4YV8YNf6b5jcwCzrVcrGNKKgWCj8xEFf8Q==}
cpu: [x64]
os: [darwin]
- '@typescript/native-preview-linux-arm64@7.0.0-dev.20260311.1':
- resolution: {integrity: sha512-WwRJO5ryMEs4Flro6JKNq0T+hR78eYFrItautu9o6EsIpeevk7Cq7T0BBgCrAf+A5aKts21HpiWzfHI0YP/CuQ==}
+ '@typescript/native-preview-linux-arm64@7.0.0-dev.20260312.1':
+ resolution: {integrity: sha512-xwoMywagcvx9F2ocM+ybeg7eH9PHDpx1FBGOrloL1/xkGC4BCrn/RcaAe0AhzXzoJfHHmg7Sz9VzYmTR4N1Kqw==}
cpu: [arm64]
os: [linux]
- '@typescript/native-preview-linux-arm@7.0.0-dev.20260311.1':
- resolution: {integrity: sha512-9T8kwNALCWzuNe00ri/f6wwoVD64YZW24cqkycFeptIF+DfNxfHMddWd7fvtHf0OKzPtkL83mkjBtviNeVKOfQ==}
+ '@typescript/native-preview-linux-arm@7.0.0-dev.20260312.1':
+ resolution: {integrity: sha512-/nAOhSLTxMJfHY+2cKdUxi2wYadf3g1GtC3VzgPfZMNxA28dJ8x75T26aSLaFYluh7cCSAwuGesCImijQDS2Lw==}
cpu: [arm]
os: [linux]
- '@typescript/native-preview-linux-x64@7.0.0-dev.20260311.1':
- resolution: {integrity: sha512-oMm3cb4njzMLBb61TI4EGq5Igxc+hoPHHNpMWqORfiYu/uQZWnter/twamTrZo6boCFtIa59mrGkhR3Qz7kauA==}
+ '@typescript/native-preview-linux-x64@7.0.0-dev.20260312.1':
+ resolution: {integrity: sha512-vZs0LLpZw50Ac0TCmF9ND7KphJBhOfp9fxLhC+hFWaUU1iCQRjv1MtvroitF5OJKb21qFPJxkU+kfhlCRxLfqg==}
cpu: [x64]
os: [linux]
- '@typescript/native-preview-win32-arm64@7.0.0-dev.20260311.1':
- resolution: {integrity: sha512-EQ5nz4qrwtzMZ5bjdMVQ2ke5BHQWDBz9IQsdh/8UU819cs5ZBnKmFFe5wOrIngqFvq4EoWKDXf983Vw0q4erkg==}
+ '@typescript/native-preview-win32-arm64@7.0.0-dev.20260312.1':
+ resolution: {integrity: sha512-4LY/gd9cj1xDY2nEthB7WDW4j/fIYJ9wp9H71nOLd0wNNtkfqRXWSkQEeb+RByhV+dIb/n6kWbQQMeNfk7q4VQ==}
cpu: [arm64]
os: [win32]
- '@typescript/native-preview-win32-x64@7.0.0-dev.20260311.1':
- resolution: {integrity: sha512-Y/5A7BaRFV1Pro4BqNW3nVDuId7YdPXktl769x1yUjTDQLH6YJEJVeBkFkT0+4e1O5IL92rxxr8rWMLypNKnTw==}
+ '@typescript/native-preview-win32-x64@7.0.0-dev.20260312.1':
+ resolution: {integrity: sha512-EP2JPo9s9EPUwXSX83qTImlDHhgkLeBbJ2MMdj+XrfBltHAvHKktzeSS73UhP77s/TnTkJR6BTWHENKKvLRbGQ==}
cpu: [x64]
os: [win32]
- '@typescript/native-preview@7.0.0-dev.20260311.1':
- resolution: {integrity: sha512-BnyOW/mdZVZGevyeJ4RRY60CI4F121QBa++8Rwd+/Ms48OKQ30eMhaIKWGowz/u4WjJZmrzhFxIzN92XeSWMCQ==}
+ '@typescript/native-preview@7.0.0-dev.20260312.1':
+ resolution: {integrity: sha512-FwhlXG/yG0d7b2UmooBYyszLMpICRYdYGE6v65ZlMnH7cWKQyyFpMFgH9suRf3Np4QCbN+7qisj+F23kQOidVw==}
hasBin: true
'@typespec/ts-http-runtime@0.3.3':
@@ -3568,54 +3688,54 @@ packages:
resolution: {integrity: sha512-2FFo/Kz2vTnOZDv59Q0s803LHf7KzuQ2EwOYYAtO0zUKJ8pV5CPsVC/IHyFb+Fsxl3R9XWFiX529yhslb4v9cQ==}
engines: {node: '>=22.0.0'}
- '@vitest/browser-playwright@4.0.18':
- resolution: {integrity: sha512-gfajTHVCiwpxRj1qh0Sh/5bbGLG4F/ZH/V9xvFVoFddpITfMta9YGow0W6ZpTTORv2vdJuz9TnrNSmjKvpOf4g==}
+ '@vitest/browser-playwright@4.1.0':
+ resolution: {integrity: sha512-2RU7pZELY9/aVMLmABNy1HeZ4FX23FXGY1jRuHLHgWa2zaAE49aNW2GLzebW+BmbTZIKKyFF1QXvk7DEWViUCQ==}
peerDependencies:
playwright: '*'
- vitest: 4.0.18
+ vitest: 4.1.0
- '@vitest/browser@4.0.18':
- resolution: {integrity: sha512-gVQqh7paBz3gC+ZdcCmNSWJMk70IUjDeVqi+5m5vYpEHsIwRgw3Y545jljtajhkekIpIp5Gg8oK7bctgY0E2Ng==}
+ '@vitest/browser@4.1.0':
+ resolution: {integrity: sha512-tG/iOrgbiHQks0ew7CdelUyNEHkv8NLrt+CqdTivIuoSnXvO7scWMn4Kqo78/UGY1NJ6Hv+vp8BvRnED/bjFdQ==}
peerDependencies:
- vitest: 4.0.18
+ vitest: 4.1.0
- '@vitest/coverage-v8@4.0.18':
- resolution: {integrity: sha512-7i+N2i0+ME+2JFZhfuz7Tg/FqKtilHjGyGvoHYQ6iLV0zahbsJ9sljC9OcFcPDbhYKCet+sG8SsVqlyGvPflZg==}
+ '@vitest/coverage-v8@4.1.0':
+ resolution: {integrity: sha512-nDWulKeik2bL2Va/Wl4x7DLuTKAXa906iRFooIRPR+huHkcvp9QDkPQ2RJdmjOFrqOqvNfoSQLF68deE3xC3CQ==}
peerDependencies:
- '@vitest/browser': 4.0.18
- vitest: 4.0.18
+ '@vitest/browser': 4.1.0
+ vitest: 4.1.0
peerDependenciesMeta:
'@vitest/browser':
optional: true
- '@vitest/expect@4.0.18':
- resolution: {integrity: sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==}
+ '@vitest/expect@4.1.0':
+ resolution: {integrity: sha512-EIxG7k4wlWweuCLG9Y5InKFwpMEOyrMb6ZJ1ihYu02LVj/bzUwn2VMU+13PinsjRW75XnITeFrQBMH5+dLvCDA==}
- '@vitest/mocker@4.0.18':
- resolution: {integrity: sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==}
+ '@vitest/mocker@4.1.0':
+ resolution: {integrity: sha512-evxREh+Hork43+Y4IOhTo+h5lGmVRyjqI739Rz4RlUPqwrkFFDF6EMvOOYjTx4E8Tl6gyCLRL8Mu7Ry12a13Tw==}
peerDependencies:
msw: ^2.4.9
- vite: ^6.0.0 || ^7.0.0-0
+ vite: ^6.0.0 || ^7.0.0 || ^8.0.0-0
peerDependenciesMeta:
msw:
optional: true
vite:
optional: true
- '@vitest/pretty-format@4.0.18':
- resolution: {integrity: sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==}
+ '@vitest/pretty-format@4.1.0':
+ resolution: {integrity: sha512-3RZLZlh88Ib0J7NQTRATfc/3ZPOnSUn2uDBUoGNn5T36+bALixmzphN26OUD3LRXWkJu4H0s5vvUeqBiw+kS0A==}
- '@vitest/runner@4.0.18':
- resolution: {integrity: sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==}
+ '@vitest/runner@4.1.0':
+ resolution: {integrity: sha512-Duvx2OzQ7d6OjchL+trw+aSrb9idh7pnNfxrklo14p3zmNL4qPCDeIJAK+eBKYjkIwG96Bc6vYuxhqDXQOWpoQ==}
- '@vitest/snapshot@4.0.18':
- resolution: {integrity: sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==}
+ '@vitest/snapshot@4.1.0':
+ resolution: {integrity: sha512-0Vy9euT1kgsnj1CHttwi9i9o+4rRLEaPRSOJ5gyv579GJkNpgJK+B4HSv/rAWixx2wdAFci1X4CEPjiu2bXIMg==}
- '@vitest/spy@4.0.18':
- resolution: {integrity: sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==}
+ '@vitest/spy@4.1.0':
+ resolution: {integrity: sha512-pz77k+PgNpyMDv2FV6qmk5ZVau6c3R8HC8v342T2xlFxQKTrSeYw9waIJG8KgV9fFwAtTu4ceRzMivPTH6wSxw==}
- '@vitest/utils@4.0.18':
- resolution: {integrity: sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==}
+ '@vitest/utils@4.1.0':
+ resolution: {integrity: sha512-XfPXT6a8TZY3dcGY8EdwsBulFCIw+BeeX0RZn2x/BtiY/75YGh8FeWGG8QISN/WhaqSrE2OrlDgtF8q5uhOTmw==}
'@wasm-audio-decoders/common@9.0.7':
resolution: {integrity: sha512-WRaUuWSKV7pkttBygml/a6dIEpatq2nnZGFIoPTc5yPLkxL6Wk4YaslPM98OPQvWacvNZ+Py9xROGDtrFBDzag==}
@@ -3679,8 +3799,8 @@ packages:
engines: {node: '>=0.4.0'}
hasBin: true
- acpx@0.2.0:
- resolution: {integrity: sha512-5E38uizINoEpTuHjLvlkWTfFqeLRqnO7vS3z3qmAXZCEZVExE+oYhJ1TClIl8KZZ9gKaoJF+5c0ltDcJDzG67g==}
+ acpx@0.3.0:
+ resolution: {integrity: sha512-5F3GRojIqXyMCzWZ6fT3+mgXXS0sRR7Phc6VyAdEUyfjQQTVeJHr81+XQ/Z4jHrP3pbjtqwlRC6E0O5Glc8lOg==}
engines: {node: '>=22.12.0'}
hasBin: true
@@ -3792,8 +3912,8 @@ packages:
resolution: {integrity: sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==}
engines: {node: '>=4'}
- ast-v8-to-istanbul@0.3.11:
- resolution: {integrity: sha512-Qya9fkoofMjCBNVdWINMjB5KZvkYfaO9/anwkWnjxibpWUxo5iHl2sOdP7/uAqaRuUYuoo8rDwnbaaKVFxoUvw==}
+ ast-v8-to-istanbul@1.0.0:
+ resolution: {integrity: sha512-1fSfIwuDICFA4LKkCzRPO7F0hzFf0B7+Xqrl27ynQaa+Rh0e1Es0v6kWHPott3lU10AyAr7oKHa65OppjLn3Rg==}
async-lock@1.4.1:
resolution: {integrity: sha512-Az2ZTpuytrtqENulXwO3GGv1Bztugx6TT37NIo7imr/Qo0gsYiGtSdBa2B6fsXhTpVZDNfu1Qn3pk531e3q+nQ==}
@@ -3904,6 +4024,9 @@ packages:
before-after-hook@4.0.0:
resolution: {integrity: sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ==}
+ bidi-js@1.0.3:
+ resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==}
+
big-integer@1.6.52:
resolution: {integrity: sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==}
engines: {node: '>=0.6'}
@@ -4128,6 +4251,9 @@ packages:
resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==}
engines: {node: '>= 0.6'}
+ convert-source-map@2.0.0:
+ resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
+
cookie-signature@1.0.7:
resolution: {integrity: sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==}
@@ -4159,6 +4285,10 @@ packages:
css-select@5.2.2:
resolution: {integrity: sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==}
+ css-tree@3.2.1:
+ resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==}
+ engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0}
+
css-what@6.2.2:
resolution: {integrity: sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==}
engines: {node: '>= 6'}
@@ -4166,6 +4296,10 @@ packages:
cssom@0.5.0:
resolution: {integrity: sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw==}
+ cssstyle@6.2.0:
+ resolution: {integrity: sha512-Fm5NvhYathRnXNVndkUsCCuR63DCLVVwGOOwQw782coXFi5HhkXdu289l59HlXZBawsyNccXfWRYvLzcDCdDig==}
+ engines: {node: '>=20'}
+
curve25519-js@0.0.4:
resolution: {integrity: sha512-axn2UMEnkhyDUPWOwVKBMVIzSQy2ejH2xRGy1wq81dqRwApXfIzfbE3hIX0ZRFBIihf/KDqK158DLwESu4AK1w==}
@@ -4181,6 +4315,10 @@ packages:
resolution: {integrity: sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==}
engines: {node: '>= 14'}
+ data-urls@7.0.0:
+ resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==}
+ engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
+
date-fns@3.6.0:
resolution: {integrity: sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==}
@@ -4201,6 +4339,9 @@ packages:
supports-color:
optional: true
+ decimal.js@10.6.0:
+ resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==}
+
deep-extend@0.6.0:
resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==}
engines: {node: '>=4.0.0'}
@@ -4324,6 +4465,10 @@ packages:
resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==}
engines: {node: '>=0.12'}
+ entities@6.0.1:
+ resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==}
+ engines: {node: '>=0.12'}
+
entities@7.0.1:
resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==}
engines: {node: '>=0.12'}
@@ -4340,8 +4485,8 @@ packages:
resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==}
engines: {node: '>= 0.4'}
- es-module-lexer@1.7.0:
- resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==}
+ es-module-lexer@2.0.0:
+ resolution: {integrity: sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==}
es-object-atoms@1.1.1:
resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==}
@@ -4706,6 +4851,10 @@ packages:
resolution: {integrity: sha512-M422h7o/BR3rmCQ8UHi7cyyMqKltdP9Uo+J2fXK+RSAY+wTcKOIRyhTuKv4qn+DJf3g+PL890AzId5KZpX+CBg==}
engines: {node: ^20.17.0 || >=22.9.0}
+ html-encoding-sniffer@6.0.0:
+ resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==}
+ engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
+
html-escaper@2.0.2:
resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==}
@@ -4850,6 +4999,9 @@ packages:
resolution: {integrity: sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==}
engines: {node: '>=0.10.0'}
+ is-potential-custom-element-name@1.0.1:
+ resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==}
+
is-promise@2.2.2:
resolution: {integrity: sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ==}
@@ -4922,6 +5074,15 @@ packages:
resolution: {integrity: sha512-d2VNT/2Hv4dxT2/59He8Lyda4DYOxPRyRG9zBaOpTZAqJCVf2xLrBlZkT8Va6Lo9u3X2qz8Bpq4HrDi4JsrQhA==}
hasBin: true
+ jsdom@28.1.0:
+ resolution: {integrity: sha512-0+MoQNYyr2rBHqO1xilltfDjV9G7ymYGlAUazgcDLQaUf8JDHbuGwsxN6U9qWaElZ4w1B2r7yEGIL3GdeW3Rug==}
+ engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
+ peerDependencies:
+ canvas: ^3.0.0
+ peerDependenciesMeta:
+ canvas:
+ optional: true
+
jsesc@3.1.0:
resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==}
engines: {node: '>=6'}
@@ -5007,74 +5168,74 @@ packages:
lifecycle-utils@3.1.1:
resolution: {integrity: sha512-gNd3OvhFNjHykJE3uGntz7UuPzWlK9phrIdXxU9Adis0+ExkwnZibfxCJWiWWZ+a6VbKiZrb+9D9hCQWd4vjTg==}
- lightningcss-android-arm64@1.30.2:
- resolution: {integrity: sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==}
+ lightningcss-android-arm64@1.32.0:
+ resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [android]
- lightningcss-darwin-arm64@1.30.2:
- resolution: {integrity: sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==}
+ lightningcss-darwin-arm64@1.32.0:
+ resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [darwin]
- lightningcss-darwin-x64@1.30.2:
- resolution: {integrity: sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==}
+ lightningcss-darwin-x64@1.32.0:
+ resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [darwin]
- lightningcss-freebsd-x64@1.30.2:
- resolution: {integrity: sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==}
+ lightningcss-freebsd-x64@1.32.0:
+ resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [freebsd]
- lightningcss-linux-arm-gnueabihf@1.30.2:
- resolution: {integrity: sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==}
+ lightningcss-linux-arm-gnueabihf@1.32.0:
+ resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==}
engines: {node: '>= 12.0.0'}
cpu: [arm]
os: [linux]
- lightningcss-linux-arm64-gnu@1.30.2:
- resolution: {integrity: sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==}
+ lightningcss-linux-arm64-gnu@1.32.0:
+ resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
- lightningcss-linux-arm64-musl@1.30.2:
- resolution: {integrity: sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==}
+ lightningcss-linux-arm64-musl@1.32.0:
+ resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
- lightningcss-linux-x64-gnu@1.30.2:
- resolution: {integrity: sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==}
+ lightningcss-linux-x64-gnu@1.32.0:
+ resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
- lightningcss-linux-x64-musl@1.30.2:
- resolution: {integrity: sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==}
+ lightningcss-linux-x64-musl@1.32.0:
+ resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
- lightningcss-win32-arm64-msvc@1.30.2:
- resolution: {integrity: sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==}
+ lightningcss-win32-arm64-msvc@1.32.0:
+ resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [win32]
- lightningcss-win32-x64-msvc@1.30.2:
- resolution: {integrity: sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==}
+ lightningcss-win32-x64-msvc@1.32.0:
+ resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [win32]
- lightningcss@1.30.2:
- resolution: {integrity: sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==}
+ lightningcss@1.32.0:
+ resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==}
engines: {node: '>= 12.0.0'}
limiter@1.1.5:
@@ -5220,6 +5381,9 @@ packages:
mdast-util-to-hast@13.2.1:
resolution: {integrity: sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==}
+ mdn-data@2.27.1:
+ resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==}
+
mdurl@2.0.0:
resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==}
@@ -5340,8 +5504,8 @@ packages:
ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
- music-metadata@11.12.1:
- resolution: {integrity: sha512-j++ltLxHDb5VCXET9FzQ8bnueiLHwQKgCO7vcbkRH/3F7fRjPkv6qncGEJ47yFhmemcYtgvsOAlcQ1dRBTkDjg==}
+ music-metadata@11.12.3:
+ resolution: {integrity: sha512-n6hSTZkuD59qWgHh6IP5dtDlDZQXoxk/bcA85Jywg8Z1iFrlNgl2+GTFgjZyn52W5UgQpV42V4XqrQZZAMbZTQ==}
engines: {node: '>=18'}
mz@2.7.0:
@@ -5525,8 +5689,8 @@ packages:
zod:
optional: true
- openclaw@2026.3.8:
- resolution: {integrity: sha512-e5Rk2Aj55sD/5LyX94mdYCQj7zpHXo0xIZsl+k140+nRopePfPAxC7nsu0V/NyypPRtaotP1riFfzK7IhaYkuQ==}
+ openclaw@2026.3.11:
+ resolution: {integrity: sha512-bxwiBmHPakwfpY5tqC9lrV5TCu5PKf0c1bHNc3nhrb+pqKcPEWV4zOjDVFLQUHr98ihgWA+3pacy4b3LQ8wduQ==}
engines: {node: '>=22.12.0'}
hasBin: true
peerDependencies:
@@ -5547,8 +5711,8 @@ packages:
resolution: {integrity: sha512-4/8JfsetakdeEa4vAYV45FW20aY+B/+K8NEXp5Eiar3wR8726whgHrbSg5Ar/ZY1FLJ/AGtUqV7W2IVF+Gvp9A==}
engines: {node: '>=20'}
- oxfmt@0.38.0:
- resolution: {integrity: sha512-RGYfnnxmCz8dMQ1Oo5KrYkNRc9cne2WL2vfE+datWNkgiSAkfUsqpGLR7rnkN6cQFgQkHDZH400eXN6izJ8Lww==}
+ oxfmt@0.40.0:
+ resolution: {integrity: sha512-g0C3I7xUj4b4DcagevM9kgH6+pUHytikxUcn3/VUkvzTNaaXBeyZqb7IBsHwojeXm4mTBEC/aBjBTMVUkZwWUQ==}
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
@@ -5556,8 +5720,8 @@ packages:
resolution: {integrity: sha512-4RuJK2jP08XwqtUu+5yhCbxEauCm6tv2MFHKEMsjbosK2+vy5us82oI3VLuHwbNyZG7ekZA26U2LLHnGR4frIA==}
hasBin: true
- oxlint@1.53.0:
- resolution: {integrity: sha512-TLW0PzGbpO1JxUnuy1pIqVPjQUGh4fNfxu5XJbdFIRFVaJ0UFzTjjk/hSFTMRxN6lZub53xL/IwJNEkrh7VtDg==}
+ oxlint@1.55.0:
+ resolution: {integrity: sha512-T+FjepiyWpaZMhekqRpH8Z3I4vNM610p6w+Vjfqgj5TZUxHXl7N8N5IPvmOU8U4XdTRxqtNNTh9Y4hLtr7yvFg==}
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
peerDependencies:
@@ -5627,6 +5791,9 @@ packages:
parse5@6.0.1:
resolution: {integrity: sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw==}
+ parse5@8.0.0:
+ resolution: {integrity: sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==}
+
parseley@0.12.1:
resolution: {integrity: sha512-e6qHKe3a9HWr0oMRVDTRhKce+bRO8VGQR3NyVwcjwrbhMmFCX9KszEV35+rn4AdilFAq9VPxP/Fe1wC9Qjd2lw==}
@@ -5703,10 +5870,6 @@ packages:
resolution: {integrity: sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w==}
hasBin: true
- pixelmatch@7.1.0:
- resolution: {integrity: sha512-1wrVzJ2STrpmONHKBy228LM1b84msXDUoAzVEl0R8Mz4Ce6EPr+IVtxm8+yvrqLYMHswREkjYFaMxnyGnaY3Ng==}
- hasBin: true
-
playwright-core@1.58.2:
resolution: {integrity: sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==}
engines: {node: '>=18'}
@@ -5725,6 +5888,10 @@ packages:
resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==}
engines: {node: ^10 || ^12 || >=14}
+ postcss@8.5.8:
+ resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==}
+ engines: {node: ^10 || ^12 || >=14}
+
postgres@3.4.8:
resolution: {integrity: sha512-d+JFcLM17njZaOLkv6SCev7uoLaBtfK86vMUXhW1Z4glPWh4jozno9APvW/XKFJ3CCxVoC7OL38BqRydtu5nGg==}
engines: {node: '>=12'}
@@ -6006,11 +6173,6 @@ packages:
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
- rollup@4.59.0:
- resolution: {integrity: sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==}
- engines: {node: '>=18.0.0', npm: '>=8.0.0'}
- hasBin: true
-
router@2.2.0:
resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==}
engines: {node: '>= 18'}
@@ -6034,6 +6196,10 @@ packages:
sanitize-html@2.17.1:
resolution: {integrity: sha512-ehFCW+q1a4CSOWRAdX97BX/6/PDEkCqw7/0JXZAGQV57FQB3YOkTa/rrzHPeJ+Aghy4vZAFfWMYyfxIiB7F/gw==}
+ saxes@6.0.0:
+ resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==}
+ engines: {node: '>=v12.22.7'}
+
scheduler@0.27.0:
resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==}
@@ -6234,6 +6400,9 @@ packages:
std-env@3.10.0:
resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==}
+ std-env@4.0.0:
+ resolution: {integrity: sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==}
+
stdin-discarder@0.3.1:
resolution: {integrity: sha512-reExS1kSGoElkextOcPkel4NE99S0BWxjUHQeDFnR8S993JxpPX7KU4MNmO19NXhlJp+8dmdCbKQVNgLJh2teA==}
engines: {node: '>=18'}
@@ -6312,6 +6481,9 @@ packages:
resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
engines: {node: '>= 0.4'}
+ symbol-tree@3.2.4:
+ resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==}
+
table-layout@4.1.1:
resolution: {integrity: sha512-iK5/YhZxq5GO5z8wb0bY1317uDF3Zjpha0QFFLA8/trAoiLbQD0HUbMesEaxyzUgDxi2QlcbM8IvqOlEjgoXBA==}
engines: {node: '>=12.17'}
@@ -6354,8 +6526,8 @@ packages:
resolution: {integrity: sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw==}
engines: {node: ^20.0.0 || >=22.0.0}
- tinyrainbow@3.0.3:
- resolution: {integrity: sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==}
+ tinyrainbow@3.1.0:
+ resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==}
engines: {node: '>=14.0.0'}
to-regex-range@5.0.1:
@@ -6388,6 +6560,10 @@ packages:
tr46@0.0.3:
resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==}
+ tr46@6.0.0:
+ resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==}
+ engines: {node: '>=20'}
+
tree-kill@1.2.2:
resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==}
hasBin: true
@@ -6495,6 +6671,10 @@ packages:
resolution: {integrity: sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg==}
engines: {node: '>=20.18.1'}
+ undici@7.24.0:
+ resolution: {integrity: sha512-jxytwMHhsbdpBXxLAcuu0fzlQeXCNnWdDyRHpvWsUl8vd98UwYdl9YTyn8/HcpcJPC3pwUveefsa3zTxyD/ERg==}
+ engines: {node: '>=20.18.1'}
+
unist-util-is@6.0.1:
resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==}
@@ -6581,15 +6761,16 @@ packages:
vfile@6.0.3:
resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==}
- vite@7.3.1:
- resolution: {integrity: sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==}
+ vite@8.0.0:
+ resolution: {integrity: sha512-fPGaRNj9Zytaf8LEiBhY7Z6ijnFKdzU/+mL8EFBaKr7Vw1/FWcTBAMW0wLPJAGMPX38ZPVCVgLceWiEqeoqL2Q==}
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
peerDependencies:
'@types/node': ^20.19.0 || >=22.12.0
+ '@vitejs/devtools': ^0.0.0-alpha.31
+ esbuild: ^0.27.0
jiti: '>=1.21.0'
less: ^4.0.0
- lightningcss: ^1.21.0
sass: ^1.70.0
sass-embedded: ^1.70.0
stylus: '>=0.54.8'
@@ -6600,12 +6781,14 @@ packages:
peerDependenciesMeta:
'@types/node':
optional: true
+ '@vitejs/devtools':
+ optional: true
+ esbuild:
+ optional: true
jiti:
optional: true
less:
optional: true
- lightningcss:
- optional: true
sass:
optional: true
sass-embedded:
@@ -6621,20 +6804,21 @@ packages:
yaml:
optional: true
- vitest@4.0.18:
- resolution: {integrity: sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==}
+ vitest@4.1.0:
+ resolution: {integrity: sha512-YbDrMF9jM2Lqc++2530UourxZHmkKLxrs4+mYhEwqWS97WJ7wOYEkcr+QfRgJ3PW9wz3odRijLZjHEaRLTNbqw==}
engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0}
hasBin: true
peerDependencies:
'@edge-runtime/vm': '*'
'@opentelemetry/api': ^1.9.0
'@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0
- '@vitest/browser-playwright': 4.0.18
- '@vitest/browser-preview': 4.0.18
- '@vitest/browser-webdriverio': 4.0.18
- '@vitest/ui': 4.0.18
+ '@vitest/browser-playwright': 4.1.0
+ '@vitest/browser-preview': 4.1.0
+ '@vitest/browser-webdriverio': 4.1.0
+ '@vitest/ui': 4.1.0
happy-dom: '*'
jsdom: '*'
+ vite: ^6.0.0 || ^7.0.0 || ^8.0.0-0
peerDependenciesMeta:
'@edge-runtime/vm':
optional: true
@@ -6659,6 +6843,10 @@ packages:
resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==}
engines: {node: '>=0.10.0'}
+ w3c-xmlserializer@5.0.0:
+ resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==}
+ engines: {node: '>=18'}
+
web-streams-polyfill@3.3.3:
resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==}
engines: {node: '>= 8'}
@@ -6666,6 +6854,18 @@ packages:
webidl-conversions@3.0.1:
resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==}
+ webidl-conversions@8.0.1:
+ resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==}
+ engines: {node: '>=20'}
+
+ whatwg-mimetype@5.0.0:
+ resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==}
+ engines: {node: '>=20'}
+
+ whatwg-url@16.0.1:
+ resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==}
+ engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
+
whatwg-url@5.0.0:
resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==}
@@ -6721,6 +6921,13 @@ packages:
utf-8-validate:
optional: true
+ xml-name-validator@5.0.0:
+ resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==}
+ engines: {node: '>=18'}
+
+ xmlchars@2.2.0:
+ resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==}
+
y18n@5.0.8:
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
engines: {node: '>=10'}
@@ -6780,6 +6987,8 @@ packages:
snapshots:
+ '@acemir/cssom@0.9.31': {}
+
'@agentclientprotocol/sdk@0.15.0(zod@4.3.6)':
dependencies:
zod: 4.3.6
@@ -6794,6 +7003,24 @@ snapshots:
optionalDependencies:
zod: 4.3.6
+ '@asamuzakjp/css-color@5.0.1':
+ dependencies:
+ '@csstools/css-calc': 3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)
+ '@csstools/css-color-parser': 4.0.2(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)
+ '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0)
+ '@csstools/css-tokenizer': 4.0.0
+ lru-cache: 11.2.6
+
+ '@asamuzakjp/dom-selector@6.8.1':
+ dependencies:
+ '@asamuzakjp/nwsapi': 2.3.9
+ bidi-js: 1.0.3
+ css-tree: 3.2.1
+ is-potential-custom-element-name: 1.0.1
+ lru-cache: 11.2.6
+
+ '@asamuzakjp/nwsapi@2.3.9': {}
+
'@aws-crypto/crc32@5.2.0':
dependencies:
'@aws-crypto/util': 5.2.0
@@ -6821,7 +7048,7 @@ snapshots:
'@aws-crypto/supports-web-crypto': 5.2.0
'@aws-crypto/util': 5.2.0
'@aws-sdk/types': 3.973.5
- '@aws-sdk/util-locate-window': 3.965.4
+ '@aws-sdk/util-locate-window': 3.965.5
'@smithy/util-utf8': 2.3.0
tslib: 2.8.1
@@ -6938,6 +7165,51 @@ snapshots:
transitivePeerDependencies:
- aws-crt
+ '@aws-sdk/client-bedrock@3.1008.0':
+ dependencies:
+ '@aws-crypto/sha256-browser': 5.2.0
+ '@aws-crypto/sha256-js': 5.2.0
+ '@aws-sdk/core': 3.973.19
+ '@aws-sdk/credential-provider-node': 3.972.20
+ '@aws-sdk/middleware-host-header': 3.972.7
+ '@aws-sdk/middleware-logger': 3.972.7
+ '@aws-sdk/middleware-recursion-detection': 3.972.7
+ '@aws-sdk/middleware-user-agent': 3.972.20
+ '@aws-sdk/region-config-resolver': 3.972.7
+ '@aws-sdk/token-providers': 3.1008.0
+ '@aws-sdk/types': 3.973.5
+ '@aws-sdk/util-endpoints': 3.996.4
+ '@aws-sdk/util-user-agent-browser': 3.972.7
+ '@aws-sdk/util-user-agent-node': 3.973.6
+ '@smithy/config-resolver': 4.4.11
+ '@smithy/core': 3.23.11
+ '@smithy/fetch-http-handler': 5.3.15
+ '@smithy/hash-node': 4.2.12
+ '@smithy/invalid-dependency': 4.2.12
+ '@smithy/middleware-content-length': 4.2.12
+ '@smithy/middleware-endpoint': 4.4.25
+ '@smithy/middleware-retry': 4.4.42
+ '@smithy/middleware-serde': 4.2.14
+ '@smithy/middleware-stack': 4.2.12
+ '@smithy/node-config-provider': 4.3.12
+ '@smithy/node-http-handler': 4.4.16
+ '@smithy/protocol-http': 5.3.12
+ '@smithy/smithy-client': 4.12.5
+ '@smithy/types': 4.13.1
+ '@smithy/url-parser': 4.2.12
+ '@smithy/util-base64': 4.3.2
+ '@smithy/util-body-length-browser': 4.2.2
+ '@smithy/util-body-length-node': 4.2.3
+ '@smithy/util-defaults-mode-browser': 4.3.41
+ '@smithy/util-defaults-mode-node': 4.2.44
+ '@smithy/util-endpoints': 3.3.3
+ '@smithy/util-middleware': 4.2.12
+ '@smithy/util-retry': 4.2.12
+ '@smithy/util-utf8': 4.2.2
+ tslib: 2.8.1
+ transitivePeerDependencies:
+ - aws-crt
+
'@aws-sdk/client-s3@3.1000.0':
dependencies:
'@aws-crypto/sha1-browser': 5.2.0
@@ -7034,15 +7306,15 @@ snapshots:
dependencies:
'@aws-sdk/types': 3.973.5
'@aws-sdk/xml-builder': 3.972.10
- '@smithy/core': 3.23.9
- '@smithy/node-config-provider': 4.3.11
- '@smithy/property-provider': 4.2.11
- '@smithy/protocol-http': 5.3.11
- '@smithy/signature-v4': 5.3.11
- '@smithy/smithy-client': 4.12.3
- '@smithy/types': 4.13.0
+ '@smithy/core': 3.23.11
+ '@smithy/node-config-provider': 4.3.12
+ '@smithy/property-provider': 4.2.12
+ '@smithy/protocol-http': 5.3.12
+ '@smithy/signature-v4': 5.3.12
+ '@smithy/smithy-client': 4.12.5
+ '@smithy/types': 4.13.1
'@smithy/util-base64': 4.3.2
- '@smithy/util-middleware': 4.2.11
+ '@smithy/util-middleware': 4.2.12
'@smithy/util-utf8': 4.2.2
tslib: 2.8.1
@@ -7071,8 +7343,8 @@ snapshots:
dependencies:
'@aws-sdk/core': 3.973.19
'@aws-sdk/types': 3.973.5
- '@smithy/property-provider': 4.2.11
- '@smithy/types': 4.13.0
+ '@smithy/property-provider': 4.2.12
+ '@smithy/types': 4.13.1
tslib: 2.8.1
'@aws-sdk/credential-provider-http@3.972.15':
@@ -7105,13 +7377,13 @@ snapshots:
dependencies:
'@aws-sdk/core': 3.973.19
'@aws-sdk/types': 3.973.5
- '@smithy/fetch-http-handler': 5.3.13
- '@smithy/node-http-handler': 4.4.14
- '@smithy/property-provider': 4.2.11
- '@smithy/protocol-http': 5.3.11
- '@smithy/smithy-client': 4.12.3
- '@smithy/types': 4.13.0
- '@smithy/util-stream': 4.5.17
+ '@smithy/fetch-http-handler': 5.3.15
+ '@smithy/node-http-handler': 4.4.16
+ '@smithy/property-provider': 4.2.12
+ '@smithy/protocol-http': 5.3.12
+ '@smithy/smithy-client': 4.12.5
+ '@smithy/types': 4.13.1
+ '@smithy/util-stream': 4.5.19
tslib: 2.8.1
'@aws-sdk/credential-provider-ini@3.972.13':
@@ -7171,6 +7443,25 @@ snapshots:
transitivePeerDependencies:
- aws-crt
+ '@aws-sdk/credential-provider-ini@3.972.19':
+ dependencies:
+ '@aws-sdk/core': 3.973.19
+ '@aws-sdk/credential-provider-env': 3.972.17
+ '@aws-sdk/credential-provider-http': 3.972.19
+ '@aws-sdk/credential-provider-login': 3.972.19
+ '@aws-sdk/credential-provider-process': 3.972.17
+ '@aws-sdk/credential-provider-sso': 3.972.19
+ '@aws-sdk/credential-provider-web-identity': 3.972.19
+ '@aws-sdk/nested-clients': 3.996.9
+ '@aws-sdk/types': 3.973.5
+ '@smithy/credential-provider-imds': 4.2.12
+ '@smithy/property-provider': 4.2.12
+ '@smithy/shared-ini-file-loader': 4.4.7
+ '@smithy/types': 4.13.1
+ tslib: 2.8.1
+ transitivePeerDependencies:
+ - aws-crt
+
'@aws-sdk/credential-provider-login@3.972.13':
dependencies:
'@aws-sdk/core': 3.973.15
@@ -7210,6 +7501,19 @@ snapshots:
transitivePeerDependencies:
- aws-crt
+ '@aws-sdk/credential-provider-login@3.972.19':
+ dependencies:
+ '@aws-sdk/core': 3.973.19
+ '@aws-sdk/nested-clients': 3.996.9
+ '@aws-sdk/types': 3.973.5
+ '@smithy/property-provider': 4.2.12
+ '@smithy/protocol-http': 5.3.12
+ '@smithy/shared-ini-file-loader': 4.4.7
+ '@smithy/types': 4.13.1
+ tslib: 2.8.1
+ transitivePeerDependencies:
+ - aws-crt
+
'@aws-sdk/credential-provider-node@3.972.14':
dependencies:
'@aws-sdk/credential-provider-env': 3.972.13
@@ -7261,6 +7565,23 @@ snapshots:
transitivePeerDependencies:
- aws-crt
+ '@aws-sdk/credential-provider-node@3.972.20':
+ dependencies:
+ '@aws-sdk/credential-provider-env': 3.972.17
+ '@aws-sdk/credential-provider-http': 3.972.19
+ '@aws-sdk/credential-provider-ini': 3.972.19
+ '@aws-sdk/credential-provider-process': 3.972.17
+ '@aws-sdk/credential-provider-sso': 3.972.19
+ '@aws-sdk/credential-provider-web-identity': 3.972.19
+ '@aws-sdk/types': 3.973.5
+ '@smithy/credential-provider-imds': 4.2.12
+ '@smithy/property-provider': 4.2.12
+ '@smithy/shared-ini-file-loader': 4.4.7
+ '@smithy/types': 4.13.1
+ tslib: 2.8.1
+ transitivePeerDependencies:
+ - aws-crt
+
'@aws-sdk/credential-provider-process@3.972.13':
dependencies:
'@aws-sdk/core': 3.973.15
@@ -7283,9 +7604,9 @@ snapshots:
dependencies:
'@aws-sdk/core': 3.973.19
'@aws-sdk/types': 3.973.5
- '@smithy/property-provider': 4.2.11
- '@smithy/shared-ini-file-loader': 4.4.6
- '@smithy/types': 4.13.0
+ '@smithy/property-provider': 4.2.12
+ '@smithy/shared-ini-file-loader': 4.4.7
+ '@smithy/types': 4.13.1
tslib: 2.8.1
'@aws-sdk/credential-provider-sso@3.972.13':
@@ -7327,6 +7648,19 @@ snapshots:
transitivePeerDependencies:
- aws-crt
+ '@aws-sdk/credential-provider-sso@3.972.19':
+ dependencies:
+ '@aws-sdk/core': 3.973.19
+ '@aws-sdk/nested-clients': 3.996.9
+ '@aws-sdk/token-providers': 3.1008.0
+ '@aws-sdk/types': 3.973.5
+ '@smithy/property-provider': 4.2.12
+ '@smithy/shared-ini-file-loader': 4.4.7
+ '@smithy/types': 4.13.1
+ tslib: 2.8.1
+ transitivePeerDependencies:
+ - aws-crt
+
'@aws-sdk/credential-provider-web-identity@3.972.13':
dependencies:
'@aws-sdk/core': 3.973.15
@@ -7363,6 +7697,18 @@ snapshots:
transitivePeerDependencies:
- aws-crt
+ '@aws-sdk/credential-provider-web-identity@3.972.19':
+ dependencies:
+ '@aws-sdk/core': 3.973.19
+ '@aws-sdk/nested-clients': 3.996.9
+ '@aws-sdk/types': 3.973.5
+ '@smithy/property-provider': 4.2.12
+ '@smithy/shared-ini-file-loader': 4.4.7
+ '@smithy/types': 4.13.1
+ tslib: 2.8.1
+ transitivePeerDependencies:
+ - aws-crt
+
'@aws-sdk/eventstream-handler-node@3.972.10':
dependencies:
'@aws-sdk/types': 3.973.5
@@ -7421,8 +7767,8 @@ snapshots:
'@aws-sdk/middleware-host-header@3.972.7':
dependencies:
'@aws-sdk/types': 3.973.5
- '@smithy/protocol-http': 5.3.11
- '@smithy/types': 4.13.0
+ '@smithy/protocol-http': 5.3.12
+ '@smithy/types': 4.13.1
tslib: 2.8.1
'@aws-sdk/middleware-location-constraint@3.972.6':
@@ -7440,7 +7786,7 @@ snapshots:
'@aws-sdk/middleware-logger@3.972.7':
dependencies:
'@aws-sdk/types': 3.973.5
- '@smithy/types': 4.13.0
+ '@smithy/types': 4.13.1
tslib: 2.8.1
'@aws-sdk/middleware-recursion-detection@3.972.6':
@@ -7454,9 +7800,9 @@ snapshots:
'@aws-sdk/middleware-recursion-detection@3.972.7':
dependencies:
'@aws-sdk/types': 3.973.5
- '@aws/lambda-invoke-store': 0.2.3
- '@smithy/protocol-http': 5.3.11
- '@smithy/types': 4.13.0
+ '@aws/lambda-invoke-store': 0.2.4
+ '@smithy/protocol-http': 5.3.12
+ '@smithy/types': 4.13.1
tslib: 2.8.1
'@aws-sdk/middleware-sdk-s3@3.972.15':
@@ -7508,10 +7854,10 @@ snapshots:
'@aws-sdk/core': 3.973.19
'@aws-sdk/types': 3.973.5
'@aws-sdk/util-endpoints': 3.996.4
- '@smithy/core': 3.23.9
- '@smithy/protocol-http': 5.3.11
- '@smithy/types': 4.13.0
- '@smithy/util-retry': 4.2.11
+ '@smithy/core': 3.23.11
+ '@smithy/protocol-http': 5.3.12
+ '@smithy/types': 4.13.1
+ '@smithy/util-retry': 4.2.12
tslib: 2.8.1
'@aws-sdk/middleware-websocket@3.972.12':
@@ -7658,6 +8004,49 @@ snapshots:
transitivePeerDependencies:
- aws-crt
+ '@aws-sdk/nested-clients@3.996.9':
+ dependencies:
+ '@aws-crypto/sha256-browser': 5.2.0
+ '@aws-crypto/sha256-js': 5.2.0
+ '@aws-sdk/core': 3.973.19
+ '@aws-sdk/middleware-host-header': 3.972.7
+ '@aws-sdk/middleware-logger': 3.972.7
+ '@aws-sdk/middleware-recursion-detection': 3.972.7
+ '@aws-sdk/middleware-user-agent': 3.972.20
+ '@aws-sdk/region-config-resolver': 3.972.7
+ '@aws-sdk/types': 3.973.5
+ '@aws-sdk/util-endpoints': 3.996.4
+ '@aws-sdk/util-user-agent-browser': 3.972.7
+ '@aws-sdk/util-user-agent-node': 3.973.6
+ '@smithy/config-resolver': 4.4.11
+ '@smithy/core': 3.23.11
+ '@smithy/fetch-http-handler': 5.3.15
+ '@smithy/hash-node': 4.2.12
+ '@smithy/invalid-dependency': 4.2.12
+ '@smithy/middleware-content-length': 4.2.12
+ '@smithy/middleware-endpoint': 4.4.25
+ '@smithy/middleware-retry': 4.4.42
+ '@smithy/middleware-serde': 4.2.14
+ '@smithy/middleware-stack': 4.2.12
+ '@smithy/node-config-provider': 4.3.12
+ '@smithy/node-http-handler': 4.4.16
+ '@smithy/protocol-http': 5.3.12
+ '@smithy/smithy-client': 4.12.5
+ '@smithy/types': 4.13.1
+ '@smithy/url-parser': 4.2.12
+ '@smithy/util-base64': 4.3.2
+ '@smithy/util-body-length-browser': 4.2.2
+ '@smithy/util-body-length-node': 4.2.3
+ '@smithy/util-defaults-mode-browser': 4.3.41
+ '@smithy/util-defaults-mode-node': 4.2.44
+ '@smithy/util-endpoints': 3.3.3
+ '@smithy/util-middleware': 4.2.12
+ '@smithy/util-retry': 4.2.12
+ '@smithy/util-utf8': 4.2.2
+ tslib: 2.8.1
+ transitivePeerDependencies:
+ - aws-crt
+
'@aws-sdk/region-config-resolver@3.972.6':
dependencies:
'@aws-sdk/types': 3.973.4
@@ -7730,6 +8119,18 @@ snapshots:
transitivePeerDependencies:
- aws-crt
+ '@aws-sdk/token-providers@3.1008.0':
+ dependencies:
+ '@aws-sdk/core': 3.973.19
+ '@aws-sdk/nested-clients': 3.996.9
+ '@aws-sdk/types': 3.973.5
+ '@smithy/property-provider': 4.2.12
+ '@smithy/shared-ini-file-loader': 4.4.7
+ '@smithy/types': 4.13.1
+ tslib: 2.8.1
+ transitivePeerDependencies:
+ - aws-crt
+
'@aws-sdk/token-providers@3.999.0':
dependencies:
'@aws-sdk/core': 3.973.15
@@ -7767,9 +8168,9 @@ snapshots:
'@aws-sdk/util-endpoints@3.996.4':
dependencies:
'@aws-sdk/types': 3.973.5
- '@smithy/types': 4.13.0
- '@smithy/url-parser': 4.2.11
- '@smithy/util-endpoints': 3.3.2
+ '@smithy/types': 4.13.1
+ '@smithy/url-parser': 4.2.12
+ '@smithy/util-endpoints': 3.3.3
tslib: 2.8.1
'@aws-sdk/util-format-url@3.972.6':
@@ -7790,6 +8191,10 @@ snapshots:
dependencies:
tslib: 2.8.1
+ '@aws-sdk/util-locate-window@3.965.5':
+ dependencies:
+ tslib: 2.8.1
+
'@aws-sdk/util-user-agent-browser@3.972.6':
dependencies:
'@aws-sdk/types': 3.973.4
@@ -7800,7 +8205,7 @@ snapshots:
'@aws-sdk/util-user-agent-browser@3.972.7':
dependencies:
'@aws-sdk/types': 3.973.5
- '@smithy/types': 4.13.0
+ '@smithy/types': 4.13.1
bowser: 2.14.1
tslib: 2.8.1
@@ -7828,6 +8233,15 @@ snapshots:
'@smithy/types': 4.13.0
tslib: 2.8.1
+ '@aws-sdk/util-user-agent-node@3.973.6':
+ dependencies:
+ '@aws-sdk/middleware-user-agent': 3.972.20
+ '@aws-sdk/types': 3.973.5
+ '@smithy/node-config-provider': 4.3.12
+ '@smithy/types': 4.13.1
+ '@smithy/util-config-provider': 4.2.2
+ tslib: 2.8.1
+
'@aws-sdk/xml-builder@3.972.10':
dependencies:
'@smithy/types': 4.13.0
@@ -7842,6 +8256,8 @@ snapshots:
'@aws/lambda-invoke-store@0.2.3': {}
+ '@aws/lambda-invoke-store@0.2.4': {}
+
'@azure/abort-controller@2.1.2':
dependencies:
tslib: 2.8.1
@@ -7909,11 +8325,17 @@ snapshots:
'@bcoe/v8-coverage@1.0.2': {}
- '@borewit/text-codec@0.2.1': {}
+ '@blazediff/core@1.9.1': {}
+
+ '@borewit/text-codec@0.2.2': {}
+
+ '@bramus/specificity@2.4.2':
+ dependencies:
+ css-tree: 3.2.1
'@buape/carbon@0.0.0-beta-20260216184201(@discordjs/opus@0.10.0)(hono@4.12.7)(opusscript@0.1.1)':
dependencies:
- '@types/node': 25.4.0
+ '@types/node': 25.5.0
discord-api-types: 0.38.37
optionalDependencies:
'@cloudflare/workers-types': 4.20260120.0
@@ -7964,6 +8386,28 @@ snapshots:
'@colors/colors@1.5.0':
optional: true
+ '@csstools/color-helpers@6.0.2': {}
+
+ '@csstools/css-calc@3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)':
+ dependencies:
+ '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0)
+ '@csstools/css-tokenizer': 4.0.0
+
+ '@csstools/css-color-parser@4.0.2(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)':
+ dependencies:
+ '@csstools/color-helpers': 6.0.2
+ '@csstools/css-calc': 3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)
+ '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0)
+ '@csstools/css-tokenizer': 4.0.0
+
+ '@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0)':
+ dependencies:
+ '@csstools/css-tokenizer': 4.0.0
+
+ '@csstools/css-syntax-patches-for-csstree@1.1.0': {}
+
+ '@csstools/css-tokenizer@4.0.0': {}
+
'@cypress/request-promise@5.0.0(@cypress/request@3.0.10)(@cypress/request@3.0.10)':
dependencies:
'@cypress/request': 3.0.10
@@ -8197,6 +8641,10 @@ snapshots:
'@eshaz/web-worker@1.2.2':
optional: true
+ '@exodus/bytes@1.15.0(@noble/hashes@2.0.1)':
+ optionalDependencies:
+ '@noble/hashes': 2.0.1
+
'@google/genai@1.44.0':
dependencies:
google-auth-library: 10.6.1
@@ -8602,7 +9050,7 @@ snapshots:
openai: 6.26.0(ws@8.19.0)(zod@4.3.6)
partial-json: 0.1.7
proxy-agent: 6.5.0
- undici: 7.22.0
+ undici: 7.24.0
zod-to-json-schema: 3.25.1(zod@4.3.6)
transitivePeerDependencies:
- '@modelcontextprotocol/sdk'
@@ -8632,7 +9080,7 @@ snapshots:
minimatch: 10.2.4
proper-lockfile: 4.1.2
strip-ansi: 7.2.0
- undici: 7.22.0
+ undici: 7.24.0
yaml: 2.8.2
optionalDependencies:
'@mariozechner/clipboard': 0.3.2
@@ -9197,63 +9645,65 @@ snapshots:
'@opentelemetry/semantic-conventions@1.40.0': {}
+ '@oxc-project/runtime@0.115.0': {}
+
'@oxc-project/types@0.115.0': {}
- '@oxfmt/binding-android-arm-eabi@0.38.0':
+ '@oxfmt/binding-android-arm-eabi@0.40.0':
optional: true
- '@oxfmt/binding-android-arm64@0.38.0':
+ '@oxfmt/binding-android-arm64@0.40.0':
optional: true
- '@oxfmt/binding-darwin-arm64@0.38.0':
+ '@oxfmt/binding-darwin-arm64@0.40.0':
optional: true
- '@oxfmt/binding-darwin-x64@0.38.0':
+ '@oxfmt/binding-darwin-x64@0.40.0':
optional: true
- '@oxfmt/binding-freebsd-x64@0.38.0':
+ '@oxfmt/binding-freebsd-x64@0.40.0':
optional: true
- '@oxfmt/binding-linux-arm-gnueabihf@0.38.0':
+ '@oxfmt/binding-linux-arm-gnueabihf@0.40.0':
optional: true
- '@oxfmt/binding-linux-arm-musleabihf@0.38.0':
+ '@oxfmt/binding-linux-arm-musleabihf@0.40.0':
optional: true
- '@oxfmt/binding-linux-arm64-gnu@0.38.0':
+ '@oxfmt/binding-linux-arm64-gnu@0.40.0':
optional: true
- '@oxfmt/binding-linux-arm64-musl@0.38.0':
+ '@oxfmt/binding-linux-arm64-musl@0.40.0':
optional: true
- '@oxfmt/binding-linux-ppc64-gnu@0.38.0':
+ '@oxfmt/binding-linux-ppc64-gnu@0.40.0':
optional: true
- '@oxfmt/binding-linux-riscv64-gnu@0.38.0':
+ '@oxfmt/binding-linux-riscv64-gnu@0.40.0':
optional: true
- '@oxfmt/binding-linux-riscv64-musl@0.38.0':
+ '@oxfmt/binding-linux-riscv64-musl@0.40.0':
optional: true
- '@oxfmt/binding-linux-s390x-gnu@0.38.0':
+ '@oxfmt/binding-linux-s390x-gnu@0.40.0':
optional: true
- '@oxfmt/binding-linux-x64-gnu@0.38.0':
+ '@oxfmt/binding-linux-x64-gnu@0.40.0':
optional: true
- '@oxfmt/binding-linux-x64-musl@0.38.0':
+ '@oxfmt/binding-linux-x64-musl@0.40.0':
optional: true
- '@oxfmt/binding-openharmony-arm64@0.38.0':
+ '@oxfmt/binding-openharmony-arm64@0.40.0':
optional: true
- '@oxfmt/binding-win32-arm64-msvc@0.38.0':
+ '@oxfmt/binding-win32-arm64-msvc@0.40.0':
optional: true
- '@oxfmt/binding-win32-ia32-msvc@0.38.0':
+ '@oxfmt/binding-win32-ia32-msvc@0.40.0':
optional: true
- '@oxfmt/binding-win32-x64-msvc@0.38.0':
+ '@oxfmt/binding-win32-x64-msvc@0.40.0':
optional: true
'@oxlint-tsgolint/darwin-arm64@0.16.0':
@@ -9274,61 +9724,61 @@ snapshots:
'@oxlint-tsgolint/win32-x64@0.16.0':
optional: true
- '@oxlint/binding-android-arm-eabi@1.53.0':
+ '@oxlint/binding-android-arm-eabi@1.55.0':
optional: true
- '@oxlint/binding-android-arm64@1.53.0':
+ '@oxlint/binding-android-arm64@1.55.0':
optional: true
- '@oxlint/binding-darwin-arm64@1.53.0':
+ '@oxlint/binding-darwin-arm64@1.55.0':
optional: true
- '@oxlint/binding-darwin-x64@1.53.0':
+ '@oxlint/binding-darwin-x64@1.55.0':
optional: true
- '@oxlint/binding-freebsd-x64@1.53.0':
+ '@oxlint/binding-freebsd-x64@1.55.0':
optional: true
- '@oxlint/binding-linux-arm-gnueabihf@1.53.0':
+ '@oxlint/binding-linux-arm-gnueabihf@1.55.0':
optional: true
- '@oxlint/binding-linux-arm-musleabihf@1.53.0':
+ '@oxlint/binding-linux-arm-musleabihf@1.55.0':
optional: true
- '@oxlint/binding-linux-arm64-gnu@1.53.0':
+ '@oxlint/binding-linux-arm64-gnu@1.55.0':
optional: true
- '@oxlint/binding-linux-arm64-musl@1.53.0':
+ '@oxlint/binding-linux-arm64-musl@1.55.0':
optional: true
- '@oxlint/binding-linux-ppc64-gnu@1.53.0':
+ '@oxlint/binding-linux-ppc64-gnu@1.55.0':
optional: true
- '@oxlint/binding-linux-riscv64-gnu@1.53.0':
+ '@oxlint/binding-linux-riscv64-gnu@1.55.0':
optional: true
- '@oxlint/binding-linux-riscv64-musl@1.53.0':
+ '@oxlint/binding-linux-riscv64-musl@1.55.0':
optional: true
- '@oxlint/binding-linux-s390x-gnu@1.53.0':
+ '@oxlint/binding-linux-s390x-gnu@1.55.0':
optional: true
- '@oxlint/binding-linux-x64-gnu@1.53.0':
+ '@oxlint/binding-linux-x64-gnu@1.55.0':
optional: true
- '@oxlint/binding-linux-x64-musl@1.53.0':
+ '@oxlint/binding-linux-x64-musl@1.55.0':
optional: true
- '@oxlint/binding-openharmony-arm64@1.53.0':
+ '@oxlint/binding-openharmony-arm64@1.55.0':
optional: true
- '@oxlint/binding-win32-arm64-msvc@1.53.0':
+ '@oxlint/binding-win32-arm64-msvc@1.55.0':
optional: true
- '@oxlint/binding-win32-ia32-msvc@1.53.0':
+ '@oxlint/binding-win32-ia32-msvc@1.55.0':
optional: true
- '@oxlint/binding-win32-x64-msvc@1.53.0':
+ '@oxlint/binding-win32-x64-msvc@1.55.0':
optional: true
'@pierre/diffs@1.0.11(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
@@ -9462,81 +9912,6 @@ snapshots:
'@rolldown/pluginutils@1.0.0-rc.9': {}
- '@rollup/rollup-android-arm-eabi@4.59.0':
- optional: true
-
- '@rollup/rollup-android-arm64@4.59.0':
- optional: true
-
- '@rollup/rollup-darwin-arm64@4.59.0':
- optional: true
-
- '@rollup/rollup-darwin-x64@4.59.0':
- optional: true
-
- '@rollup/rollup-freebsd-arm64@4.59.0':
- optional: true
-
- '@rollup/rollup-freebsd-x64@4.59.0':
- optional: true
-
- '@rollup/rollup-linux-arm-gnueabihf@4.59.0':
- optional: true
-
- '@rollup/rollup-linux-arm-musleabihf@4.59.0':
- optional: true
-
- '@rollup/rollup-linux-arm64-gnu@4.59.0':
- optional: true
-
- '@rollup/rollup-linux-arm64-musl@4.59.0':
- optional: true
-
- '@rollup/rollup-linux-loong64-gnu@4.59.0':
- optional: true
-
- '@rollup/rollup-linux-loong64-musl@4.59.0':
- optional: true
-
- '@rollup/rollup-linux-ppc64-gnu@4.59.0':
- optional: true
-
- '@rollup/rollup-linux-ppc64-musl@4.59.0':
- optional: true
-
- '@rollup/rollup-linux-riscv64-gnu@4.59.0':
- optional: true
-
- '@rollup/rollup-linux-riscv64-musl@4.59.0':
- optional: true
-
- '@rollup/rollup-linux-s390x-gnu@4.59.0':
- optional: true
-
- '@rollup/rollup-linux-x64-gnu@4.59.0':
- optional: true
-
- '@rollup/rollup-linux-x64-musl@4.59.0':
- optional: true
-
- '@rollup/rollup-openbsd-x64@4.59.0':
- optional: true
-
- '@rollup/rollup-openharmony-arm64@4.59.0':
- optional: true
-
- '@rollup/rollup-win32-arm64-msvc@4.59.0':
- optional: true
-
- '@rollup/rollup-win32-ia32-msvc@4.59.0':
- optional: true
-
- '@rollup/rollup-win32-x64-gnu@4.59.0':
- optional: true
-
- '@rollup/rollup-win32-x64-msvc@4.59.0':
- optional: true
-
'@scure/base@2.0.0': {}
'@scure/bip32@2.0.1':
@@ -9618,14 +9993,14 @@ snapshots:
'@slack/logger@4.0.0':
dependencies:
- '@types/node': 25.4.0
+ '@types/node': 25.5.0
'@slack/oauth@3.0.4':
dependencies:
'@slack/logger': 4.0.0
'@slack/web-api': 7.14.1
'@types/jsonwebtoken': 9.0.10
- '@types/node': 25.4.0
+ '@types/node': 25.5.0
jsonwebtoken: 9.0.3
transitivePeerDependencies:
- debug
@@ -9634,7 +10009,7 @@ snapshots:
dependencies:
'@slack/logger': 4.0.0
'@slack/web-api': 7.14.1
- '@types/node': 25.4.0
+ '@types/node': 25.5.0
'@types/ws': 8.18.1
eventemitter3: 5.0.4
ws: 8.19.0
@@ -9649,7 +10024,7 @@ snapshots:
dependencies:
'@slack/logger': 4.0.0
'@slack/types': 2.20.0
- '@types/node': 25.4.0
+ '@types/node': 25.5.0
'@types/retry': 0.12.0
axios: 1.13.5
eventemitter3: 5.0.4
@@ -9672,6 +10047,11 @@ snapshots:
'@smithy/types': 4.13.0
tslib: 2.8.1
+ '@smithy/abort-controller@4.2.12':
+ dependencies:
+ '@smithy/types': 4.13.1
+ tslib: 2.8.1
+
'@smithy/chunked-blob-reader-native@4.2.2':
dependencies:
'@smithy/util-base64': 4.3.1
@@ -9690,6 +10070,15 @@ snapshots:
'@smithy/util-middleware': 4.2.11
tslib: 2.8.1
+ '@smithy/config-resolver@4.4.11':
+ dependencies:
+ '@smithy/node-config-provider': 4.3.12
+ '@smithy/types': 4.13.1
+ '@smithy/util-config-provider': 4.2.2
+ '@smithy/util-endpoints': 3.3.3
+ '@smithy/util-middleware': 4.2.12
+ tslib: 2.8.1
+
'@smithy/config-resolver@4.4.9':
dependencies:
'@smithy/node-config-provider': 4.3.10
@@ -9699,6 +10088,19 @@ snapshots:
'@smithy/util-middleware': 4.2.10
tslib: 2.8.1
+ '@smithy/core@3.23.11':
+ dependencies:
+ '@smithy/protocol-http': 5.3.12
+ '@smithy/types': 4.13.1
+ '@smithy/url-parser': 4.2.12
+ '@smithy/util-base64': 4.3.2
+ '@smithy/util-body-length-browser': 4.2.2
+ '@smithy/util-middleware': 4.2.12
+ '@smithy/util-stream': 4.5.19
+ '@smithy/util-utf8': 4.2.2
+ '@smithy/uuid': 1.1.2
+ tslib: 2.8.1
+
'@smithy/core@3.23.6':
dependencies:
'@smithy/middleware-serde': 4.2.11
@@ -9741,6 +10143,14 @@ snapshots:
'@smithy/url-parser': 4.2.11
tslib: 2.8.1
+ '@smithy/credential-provider-imds@4.2.12':
+ dependencies:
+ '@smithy/node-config-provider': 4.3.12
+ '@smithy/property-provider': 4.2.12
+ '@smithy/types': 4.13.1
+ '@smithy/url-parser': 4.2.12
+ tslib: 2.8.1
+
'@smithy/eventstream-codec@4.2.10':
dependencies:
'@aws-crypto/crc32': 5.2.0
@@ -9817,6 +10227,14 @@ snapshots:
'@smithy/util-base64': 4.3.2
tslib: 2.8.1
+ '@smithy/fetch-http-handler@5.3.15':
+ dependencies:
+ '@smithy/protocol-http': 5.3.12
+ '@smithy/querystring-builder': 4.2.12
+ '@smithy/types': 4.13.1
+ '@smithy/util-base64': 4.3.2
+ tslib: 2.8.1
+
'@smithy/hash-blob-browser@4.2.11':
dependencies:
'@smithy/chunked-blob-reader': 5.2.1
@@ -9838,6 +10256,13 @@ snapshots:
'@smithy/util-utf8': 4.2.2
tslib: 2.8.1
+ '@smithy/hash-node@4.2.12':
+ dependencies:
+ '@smithy/types': 4.13.1
+ '@smithy/util-buffer-from': 4.2.2
+ '@smithy/util-utf8': 4.2.2
+ tslib: 2.8.1
+
'@smithy/hash-stream-node@4.2.10':
dependencies:
'@smithy/types': 4.13.0
@@ -9854,6 +10279,11 @@ snapshots:
'@smithy/types': 4.13.0
tslib: 2.8.1
+ '@smithy/invalid-dependency@4.2.12':
+ dependencies:
+ '@smithy/types': 4.13.1
+ tslib: 2.8.1
+
'@smithy/is-array-buffer@2.2.0':
dependencies:
tslib: 2.8.1
@@ -9884,6 +10314,12 @@ snapshots:
'@smithy/types': 4.13.0
tslib: 2.8.1
+ '@smithy/middleware-content-length@4.2.12':
+ dependencies:
+ '@smithy/protocol-http': 5.3.12
+ '@smithy/types': 4.13.1
+ tslib: 2.8.1
+
'@smithy/middleware-endpoint@4.4.20':
dependencies:
'@smithy/core': 3.23.6
@@ -9906,6 +10342,17 @@ snapshots:
'@smithy/util-middleware': 4.2.11
tslib: 2.8.1
+ '@smithy/middleware-endpoint@4.4.25':
+ dependencies:
+ '@smithy/core': 3.23.11
+ '@smithy/middleware-serde': 4.2.14
+ '@smithy/node-config-provider': 4.3.12
+ '@smithy/shared-ini-file-loader': 4.4.7
+ '@smithy/types': 4.13.1
+ '@smithy/url-parser': 4.2.12
+ '@smithy/util-middleware': 4.2.12
+ tslib: 2.8.1
+
'@smithy/middleware-retry@4.4.37':
dependencies:
'@smithy/node-config-provider': 4.3.10
@@ -9930,6 +10377,18 @@ snapshots:
'@smithy/uuid': 1.1.2
tslib: 2.8.1
+ '@smithy/middleware-retry@4.4.42':
+ dependencies:
+ '@smithy/node-config-provider': 4.3.12
+ '@smithy/protocol-http': 5.3.12
+ '@smithy/service-error-classification': 4.2.12
+ '@smithy/smithy-client': 4.12.5
+ '@smithy/types': 4.13.1
+ '@smithy/util-middleware': 4.2.12
+ '@smithy/util-retry': 4.2.12
+ '@smithy/uuid': 1.1.2
+ tslib: 2.8.1
+
'@smithy/middleware-serde@4.2.11':
dependencies:
'@smithy/protocol-http': 5.3.10
@@ -9942,6 +10401,13 @@ snapshots:
'@smithy/types': 4.13.0
tslib: 2.8.1
+ '@smithy/middleware-serde@4.2.14':
+ dependencies:
+ '@smithy/core': 3.23.11
+ '@smithy/protocol-http': 5.3.12
+ '@smithy/types': 4.13.1
+ tslib: 2.8.1
+
'@smithy/middleware-stack@4.2.10':
dependencies:
'@smithy/types': 4.13.0
@@ -9952,6 +10418,11 @@ snapshots:
'@smithy/types': 4.13.0
tslib: 2.8.1
+ '@smithy/middleware-stack@4.2.12':
+ dependencies:
+ '@smithy/types': 4.13.1
+ tslib: 2.8.1
+
'@smithy/node-config-provider@4.3.10':
dependencies:
'@smithy/property-provider': 4.2.10
@@ -9966,6 +10437,13 @@ snapshots:
'@smithy/types': 4.13.0
tslib: 2.8.1
+ '@smithy/node-config-provider@4.3.12':
+ dependencies:
+ '@smithy/property-provider': 4.2.12
+ '@smithy/shared-ini-file-loader': 4.4.7
+ '@smithy/types': 4.13.1
+ tslib: 2.8.1
+
'@smithy/node-http-handler@4.4.12':
dependencies:
'@smithy/abort-controller': 4.2.10
@@ -9982,6 +10460,14 @@ snapshots:
'@smithy/types': 4.13.0
tslib: 2.8.1
+ '@smithy/node-http-handler@4.4.16':
+ dependencies:
+ '@smithy/abort-controller': 4.2.12
+ '@smithy/protocol-http': 5.3.12
+ '@smithy/querystring-builder': 4.2.12
+ '@smithy/types': 4.13.1
+ tslib: 2.8.1
+
'@smithy/property-provider@4.2.10':
dependencies:
'@smithy/types': 4.13.0
@@ -9992,6 +10478,11 @@ snapshots:
'@smithy/types': 4.13.0
tslib: 2.8.1
+ '@smithy/property-provider@4.2.12':
+ dependencies:
+ '@smithy/types': 4.13.1
+ tslib: 2.8.1
+
'@smithy/protocol-http@5.3.10':
dependencies:
'@smithy/types': 4.13.0
@@ -10002,6 +10493,11 @@ snapshots:
'@smithy/types': 4.13.0
tslib: 2.8.1
+ '@smithy/protocol-http@5.3.12':
+ dependencies:
+ '@smithy/types': 4.13.1
+ tslib: 2.8.1
+
'@smithy/querystring-builder@4.2.10':
dependencies:
'@smithy/types': 4.13.0
@@ -10014,6 +10510,12 @@ snapshots:
'@smithy/util-uri-escape': 4.2.2
tslib: 2.8.1
+ '@smithy/querystring-builder@4.2.12':
+ dependencies:
+ '@smithy/types': 4.13.1
+ '@smithy/util-uri-escape': 4.2.2
+ tslib: 2.8.1
+
'@smithy/querystring-parser@4.2.10':
dependencies:
'@smithy/types': 4.13.0
@@ -10024,6 +10526,11 @@ snapshots:
'@smithy/types': 4.13.0
tslib: 2.8.1
+ '@smithy/querystring-parser@4.2.12':
+ dependencies:
+ '@smithy/types': 4.13.1
+ tslib: 2.8.1
+
'@smithy/service-error-classification@4.2.10':
dependencies:
'@smithy/types': 4.13.0
@@ -10032,6 +10539,10 @@ snapshots:
dependencies:
'@smithy/types': 4.13.0
+ '@smithy/service-error-classification@4.2.12':
+ dependencies:
+ '@smithy/types': 4.13.1
+
'@smithy/shared-ini-file-loader@4.4.5':
dependencies:
'@smithy/types': 4.13.0
@@ -10042,6 +10553,11 @@ snapshots:
'@smithy/types': 4.13.0
tslib: 2.8.1
+ '@smithy/shared-ini-file-loader@4.4.7':
+ dependencies:
+ '@smithy/types': 4.13.1
+ tslib: 2.8.1
+
'@smithy/signature-v4@5.3.10':
dependencies:
'@smithy/is-array-buffer': 4.2.1
@@ -10064,6 +10580,17 @@ snapshots:
'@smithy/util-utf8': 4.2.2
tslib: 2.8.1
+ '@smithy/signature-v4@5.3.12':
+ dependencies:
+ '@smithy/is-array-buffer': 4.2.2
+ '@smithy/protocol-http': 5.3.12
+ '@smithy/types': 4.13.1
+ '@smithy/util-hex-encoding': 4.2.2
+ '@smithy/util-middleware': 4.2.12
+ '@smithy/util-uri-escape': 4.2.2
+ '@smithy/util-utf8': 4.2.2
+ tslib: 2.8.1
+
'@smithy/smithy-client@4.12.0':
dependencies:
'@smithy/core': 3.23.6
@@ -10084,10 +10611,24 @@ snapshots:
'@smithy/util-stream': 4.5.17
tslib: 2.8.1
+ '@smithy/smithy-client@4.12.5':
+ dependencies:
+ '@smithy/core': 3.23.11
+ '@smithy/middleware-endpoint': 4.4.25
+ '@smithy/middleware-stack': 4.2.12
+ '@smithy/protocol-http': 5.3.12
+ '@smithy/types': 4.13.1
+ '@smithy/util-stream': 4.5.19
+ tslib: 2.8.1
+
'@smithy/types@4.13.0':
dependencies:
tslib: 2.8.1
+ '@smithy/types@4.13.1':
+ dependencies:
+ tslib: 2.8.1
+
'@smithy/url-parser@4.2.10':
dependencies:
'@smithy/querystring-parser': 4.2.10
@@ -10100,6 +10641,12 @@ snapshots:
'@smithy/types': 4.13.0
tslib: 2.8.1
+ '@smithy/url-parser@4.2.12':
+ dependencies:
+ '@smithy/querystring-parser': 4.2.12
+ '@smithy/types': 4.13.1
+ tslib: 2.8.1
+
'@smithy/util-base64@4.3.1':
dependencies:
'@smithy/util-buffer-from': 4.2.1
@@ -10165,6 +10712,13 @@ snapshots:
'@smithy/types': 4.13.0
tslib: 2.8.1
+ '@smithy/util-defaults-mode-browser@4.3.41':
+ dependencies:
+ '@smithy/property-provider': 4.2.12
+ '@smithy/smithy-client': 4.12.5
+ '@smithy/types': 4.13.1
+ tslib: 2.8.1
+
'@smithy/util-defaults-mode-node@4.2.39':
dependencies:
'@smithy/config-resolver': 4.4.9
@@ -10185,6 +10739,16 @@ snapshots:
'@smithy/types': 4.13.0
tslib: 2.8.1
+ '@smithy/util-defaults-mode-node@4.2.44':
+ dependencies:
+ '@smithy/config-resolver': 4.4.11
+ '@smithy/credential-provider-imds': 4.2.12
+ '@smithy/node-config-provider': 4.3.12
+ '@smithy/property-provider': 4.2.12
+ '@smithy/smithy-client': 4.12.5
+ '@smithy/types': 4.13.1
+ tslib: 2.8.1
+
'@smithy/util-endpoints@3.3.1':
dependencies:
'@smithy/node-config-provider': 4.3.10
@@ -10197,6 +10761,12 @@ snapshots:
'@smithy/types': 4.13.0
tslib: 2.8.1
+ '@smithy/util-endpoints@3.3.3':
+ dependencies:
+ '@smithy/node-config-provider': 4.3.12
+ '@smithy/types': 4.13.1
+ tslib: 2.8.1
+
'@smithy/util-hex-encoding@4.2.1':
dependencies:
tslib: 2.8.1
@@ -10215,6 +10785,11 @@ snapshots:
'@smithy/types': 4.13.0
tslib: 2.8.1
+ '@smithy/util-middleware@4.2.12':
+ dependencies:
+ '@smithy/types': 4.13.1
+ tslib: 2.8.1
+
'@smithy/util-retry@4.2.10':
dependencies:
'@smithy/service-error-classification': 4.2.10
@@ -10227,6 +10802,12 @@ snapshots:
'@smithy/types': 4.13.0
tslib: 2.8.1
+ '@smithy/util-retry@4.2.12':
+ dependencies:
+ '@smithy/service-error-classification': 4.2.12
+ '@smithy/types': 4.13.1
+ tslib: 2.8.1
+
'@smithy/util-stream@4.5.15':
dependencies:
'@smithy/fetch-http-handler': 5.3.11
@@ -10249,6 +10830,17 @@ snapshots:
'@smithy/util-utf8': 4.2.2
tslib: 2.8.1
+ '@smithy/util-stream@4.5.19':
+ dependencies:
+ '@smithy/fetch-http-handler': 5.3.15
+ '@smithy/node-http-handler': 4.4.16
+ '@smithy/types': 4.13.1
+ '@smithy/util-base64': 4.3.2
+ '@smithy/util-buffer-from': 4.2.2
+ '@smithy/util-hex-encoding': 4.2.2
+ '@smithy/util-utf8': 4.2.2
+ tslib: 2.8.1
+
'@smithy/util-uri-escape@4.2.1':
dependencies:
tslib: 2.8.1
@@ -10474,7 +11066,7 @@ snapshots:
'@types/body-parser@1.19.6':
dependencies:
'@types/connect': 3.4.38
- '@types/node': 25.4.0
+ '@types/node': 25.5.0
'@types/bun@1.3.9':
dependencies:
@@ -10494,7 +11086,7 @@ snapshots:
'@types/connect@3.4.38':
dependencies:
- '@types/node': 25.4.0
+ '@types/node': 25.5.0
'@types/deep-eql@4.0.2': {}
@@ -10502,14 +11094,14 @@ snapshots:
'@types/express-serve-static-core@4.19.8':
dependencies:
- '@types/node': 25.4.0
+ '@types/node': 25.5.0
'@types/qs': 6.14.0
'@types/range-parser': 1.2.7
'@types/send': 1.2.1
'@types/express-serve-static-core@5.1.1':
dependencies:
- '@types/node': 25.4.0
+ '@types/node': 25.5.0
'@types/qs': 6.14.0
'@types/range-parser': 1.2.7
'@types/send': 1.2.1
@@ -10538,7 +11130,7 @@ snapshots:
'@types/jsonwebtoken@9.0.10':
dependencies:
'@types/ms': 2.1.0
- '@types/node': 25.4.0
+ '@types/node': 25.5.0
'@types/linkify-it@5.0.0': {}
@@ -10571,7 +11163,7 @@ snapshots:
dependencies:
undici-types: 7.16.0
- '@types/node@25.4.0':
+ '@types/node@25.5.0':
dependencies:
undici-types: 7.18.2
@@ -10584,7 +11176,7 @@ snapshots:
'@types/request@2.48.13':
dependencies:
'@types/caseless': 0.12.5
- '@types/node': 25.4.0
+ '@types/node': 25.5.0
'@types/tough-cookie': 4.0.5
form-data: 2.5.4
@@ -10595,22 +11187,22 @@ snapshots:
'@types/send@0.17.6':
dependencies:
'@types/mime': 1.3.5
- '@types/node': 25.4.0
+ '@types/node': 25.5.0
'@types/send@1.2.1':
dependencies:
- '@types/node': 25.4.0
+ '@types/node': 25.5.0
'@types/serve-static@1.15.10':
dependencies:
'@types/http-errors': 2.0.5
- '@types/node': 25.4.0
+ '@types/node': 25.5.0
'@types/send': 0.17.6
'@types/serve-static@2.2.0':
dependencies:
'@types/http-errors': 2.0.5
- '@types/node': 25.4.0
+ '@types/node': 25.5.0
'@types/tough-cookie@4.0.5': {}
@@ -10620,43 +11212,43 @@ snapshots:
'@types/ws@8.18.1':
dependencies:
- '@types/node': 25.4.0
+ '@types/node': 25.5.0
'@types/yauzl@2.10.3':
dependencies:
- '@types/node': 25.4.0
+ '@types/node': 25.5.0
optional: true
- '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260311.1':
+ '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260312.1':
optional: true
- '@typescript/native-preview-darwin-x64@7.0.0-dev.20260311.1':
+ '@typescript/native-preview-darwin-x64@7.0.0-dev.20260312.1':
optional: true
- '@typescript/native-preview-linux-arm64@7.0.0-dev.20260311.1':
+ '@typescript/native-preview-linux-arm64@7.0.0-dev.20260312.1':
optional: true
- '@typescript/native-preview-linux-arm@7.0.0-dev.20260311.1':
+ '@typescript/native-preview-linux-arm@7.0.0-dev.20260312.1':
optional: true
- '@typescript/native-preview-linux-x64@7.0.0-dev.20260311.1':
+ '@typescript/native-preview-linux-x64@7.0.0-dev.20260312.1':
optional: true
- '@typescript/native-preview-win32-arm64@7.0.0-dev.20260311.1':
+ '@typescript/native-preview-win32-arm64@7.0.0-dev.20260312.1':
optional: true
- '@typescript/native-preview-win32-x64@7.0.0-dev.20260311.1':
+ '@typescript/native-preview-win32-x64@7.0.0-dev.20260312.1':
optional: true
- '@typescript/native-preview@7.0.0-dev.20260311.1':
+ '@typescript/native-preview@7.0.0-dev.20260312.1':
optionalDependencies:
- '@typescript/native-preview-darwin-arm64': 7.0.0-dev.20260311.1
- '@typescript/native-preview-darwin-x64': 7.0.0-dev.20260311.1
- '@typescript/native-preview-linux-arm': 7.0.0-dev.20260311.1
- '@typescript/native-preview-linux-arm64': 7.0.0-dev.20260311.1
- '@typescript/native-preview-linux-x64': 7.0.0-dev.20260311.1
- '@typescript/native-preview-win32-arm64': 7.0.0-dev.20260311.1
- '@typescript/native-preview-win32-x64': 7.0.0-dev.20260311.1
+ '@typescript/native-preview-darwin-arm64': 7.0.0-dev.20260312.1
+ '@typescript/native-preview-darwin-x64': 7.0.0-dev.20260312.1
+ '@typescript/native-preview-linux-arm': 7.0.0-dev.20260312.1
+ '@typescript/native-preview-linux-arm64': 7.0.0-dev.20260312.1
+ '@typescript/native-preview-linux-x64': 7.0.0-dev.20260312.1
+ '@typescript/native-preview-win32-arm64': 7.0.0-dev.20260312.1
+ '@typescript/native-preview-win32-x64': 7.0.0-dev.20260312.1
'@typespec/ts-http-runtime@0.3.3':
dependencies:
@@ -10697,29 +11289,29 @@ snapshots:
- '@cypress/request'
- supports-color
- '@vitest/browser-playwright@4.0.18(playwright@1.58.2)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.18)':
+ '@vitest/browser-playwright@4.1.0(playwright@1.58.2)(vite@8.0.0(@types/node@25.5.0)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.1.0)':
dependencies:
- '@vitest/browser': 4.0.18(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.18)
- '@vitest/mocker': 4.0.18(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))
+ '@vitest/browser': 4.1.0(vite@8.0.0(@types/node@25.5.0)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.1.0)
+ '@vitest/mocker': 4.1.0(vite@8.0.0(@types/node@25.5.0)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2))
playwright: 1.58.2
- tinyrainbow: 3.0.3
- vitest: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.4.0)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2)
+ tinyrainbow: 3.1.0
+ vitest: 4.1.0(@opentelemetry/api@1.9.0)(@types/node@25.5.0)(@vitest/browser-playwright@4.1.0)(jsdom@28.1.0(@noble/hashes@2.0.1))(vite@8.0.0(@types/node@25.5.0)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2))
transitivePeerDependencies:
- bufferutil
- msw
- utf-8-validate
- vite
- '@vitest/browser@4.0.18(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.18)':
+ '@vitest/browser@4.1.0(vite@8.0.0(@types/node@25.5.0)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.1.0)':
dependencies:
- '@vitest/mocker': 4.0.18(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))
- '@vitest/utils': 4.0.18
+ '@blazediff/core': 1.9.1
+ '@vitest/mocker': 4.1.0(vite@8.0.0(@types/node@25.5.0)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2))
+ '@vitest/utils': 4.1.0
magic-string: 0.30.21
- pixelmatch: 7.1.0
pngjs: 7.0.0
sirv: 3.0.2
- tinyrainbow: 3.0.3
- vitest: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.4.0)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2)
+ tinyrainbow: 3.1.0
+ vitest: 4.1.0(@opentelemetry/api@1.9.0)(@types/node@25.5.0)(@vitest/browser-playwright@4.1.0)(jsdom@28.1.0(@noble/hashes@2.0.1))(vite@8.0.0(@types/node@25.5.0)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2))
ws: 8.19.0
transitivePeerDependencies:
- bufferutil
@@ -10727,60 +11319,62 @@ snapshots:
- utf-8-validate
- vite
- '@vitest/coverage-v8@4.0.18(@vitest/browser@4.0.18(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.18))(vitest@4.0.18)':
+ '@vitest/coverage-v8@4.1.0(@vitest/browser@4.1.0(vite@8.0.0(@types/node@25.5.0)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.1.0))(vitest@4.1.0)':
dependencies:
'@bcoe/v8-coverage': 1.0.2
- '@vitest/utils': 4.0.18
- ast-v8-to-istanbul: 0.3.11
+ '@vitest/utils': 4.1.0
+ ast-v8-to-istanbul: 1.0.0
istanbul-lib-coverage: 3.2.2
istanbul-lib-report: 3.0.1
istanbul-reports: 3.2.0
magicast: 0.5.2
obug: 2.1.1
- std-env: 3.10.0
- tinyrainbow: 3.0.3
- vitest: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.4.0)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2)
+ std-env: 4.0.0
+ tinyrainbow: 3.1.0
+ vitest: 4.1.0(@opentelemetry/api@1.9.0)(@types/node@25.5.0)(@vitest/browser-playwright@4.1.0)(jsdom@28.1.0(@noble/hashes@2.0.1))(vite@8.0.0(@types/node@25.5.0)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2))
optionalDependencies:
- '@vitest/browser': 4.0.18(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.18)
+ '@vitest/browser': 4.1.0(vite@8.0.0(@types/node@25.5.0)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.1.0)
- '@vitest/expect@4.0.18':
+ '@vitest/expect@4.1.0':
dependencies:
'@standard-schema/spec': 1.1.0
'@types/chai': 5.2.3
- '@vitest/spy': 4.0.18
- '@vitest/utils': 4.0.18
+ '@vitest/spy': 4.1.0
+ '@vitest/utils': 4.1.0
chai: 6.2.2
- tinyrainbow: 3.0.3
+ tinyrainbow: 3.1.0
- '@vitest/mocker@4.0.18(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))':
+ '@vitest/mocker@4.1.0(vite@8.0.0(@types/node@25.5.0)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2))':
dependencies:
- '@vitest/spy': 4.0.18
+ '@vitest/spy': 4.1.0
estree-walker: 3.0.3
magic-string: 0.30.21
optionalDependencies:
- vite: 7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2)
+ vite: 8.0.0(@types/node@25.5.0)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2)
- '@vitest/pretty-format@4.0.18':
+ '@vitest/pretty-format@4.1.0':
dependencies:
- tinyrainbow: 3.0.3
+ tinyrainbow: 3.1.0
- '@vitest/runner@4.0.18':
+ '@vitest/runner@4.1.0':
dependencies:
- '@vitest/utils': 4.0.18
+ '@vitest/utils': 4.1.0
pathe: 2.0.3
- '@vitest/snapshot@4.0.18':
+ '@vitest/snapshot@4.1.0':
dependencies:
- '@vitest/pretty-format': 4.0.18
+ '@vitest/pretty-format': 4.1.0
+ '@vitest/utils': 4.1.0
magic-string: 0.30.21
pathe: 2.0.3
- '@vitest/spy@4.0.18': {}
+ '@vitest/spy@4.1.0': {}
- '@vitest/utils@4.0.18':
+ '@vitest/utils@4.1.0':
dependencies:
- '@vitest/pretty-format': 4.0.18
- tinyrainbow: 3.0.3
+ '@vitest/pretty-format': 4.1.0
+ convert-source-map: 2.0.0
+ tinyrainbow: 3.1.0
'@wasm-audio-decoders/common@9.0.7':
dependencies:
@@ -10812,7 +11406,7 @@ snapshots:
async-mutex: 0.5.0
libsignal: '@whiskeysockets/libsignal-node@https://codeload.github.com/whiskeysockets/libsignal-node/tar.gz/1c30d7d7e76a3b0aa120b04dc6a26f5a12dccf67'
lru-cache: 11.2.6
- music-metadata: 11.12.1
+ music-metadata: 11.12.3
p-queue: 9.1.0
pino: 9.14.0
protobufjs: 7.5.4
@@ -10855,7 +11449,7 @@ snapshots:
acorn@8.16.0: {}
- acpx@0.2.0(zod@4.3.6):
+ acpx@0.3.0(zod@4.3.6):
dependencies:
'@agentclientprotocol/sdk': 0.15.0(zod@4.3.6)
commander: 14.0.3
@@ -10959,7 +11553,7 @@ snapshots:
dependencies:
tslib: 2.8.1
- ast-v8-to-istanbul@0.3.11:
+ ast-v8-to-istanbul@1.0.0:
dependencies:
'@jridgewell/trace-mapping': 0.3.31
estree-walker: 3.0.3
@@ -11066,6 +11660,10 @@ snapshots:
before-after-hook@4.0.0: {}
+ bidi-js@1.0.3:
+ dependencies:
+ require-from-string: 2.0.2
+
big-integer@1.6.52: {}
bignumber.js@9.3.1: {}
@@ -11139,7 +11737,7 @@ snapshots:
bun-types@1.3.9:
dependencies:
- '@types/node': 25.4.0
+ '@types/node': 25.5.0
optional: true
bytes@3.1.2: {}
@@ -11306,6 +11904,8 @@ snapshots:
content-type@1.0.5: {}
+ convert-source-map@2.0.0: {}
+
cookie-signature@1.0.7: {}
cookie-signature@1.2.2: {}
@@ -11334,10 +11934,22 @@ snapshots:
domutils: 3.2.2
nth-check: 2.1.1
+ css-tree@3.2.1:
+ dependencies:
+ mdn-data: 2.27.1
+ source-map-js: 1.2.1
+
css-what@6.2.2: {}
cssom@0.5.0: {}
+ cssstyle@6.2.0:
+ dependencies:
+ '@asamuzakjp/css-color': 5.0.1
+ '@csstools/css-syntax-patches-for-csstree': 1.1.0
+ css-tree: 3.2.1
+ lru-cache: 11.2.6
+
curve25519-js@0.0.4: {}
dashdash@1.14.1:
@@ -11348,6 +11960,13 @@ snapshots:
data-uri-to-buffer@6.0.2: {}
+ data-urls@7.0.0(@noble/hashes@2.0.1):
+ dependencies:
+ whatwg-mimetype: 5.0.0
+ whatwg-url: 16.0.1(@noble/hashes@2.0.1)
+ transitivePeerDependencies:
+ - '@noble/hashes'
+
date-fns@3.6.0: {}
debug@2.6.9:
@@ -11358,6 +11977,8 @@ snapshots:
dependencies:
ms: 2.1.3
+ decimal.js@10.6.0: {}
+
deep-extend@0.6.0: {}
deepmerge@4.3.1: {}
@@ -11456,6 +12077,8 @@ snapshots:
entities@4.5.0: {}
+ entities@6.0.1: {}
+
entities@7.0.1: {}
env-var@7.5.0: {}
@@ -11464,7 +12087,7 @@ snapshots:
es-errors@1.3.0: {}
- es-module-lexer@1.7.0: {}
+ es-module-lexer@2.0.0: {}
es-object-atoms@1.1.1:
dependencies:
@@ -11967,6 +12590,12 @@ snapshots:
dependencies:
lru-cache: 11.2.6
+ html-encoding-sniffer@6.0.0(@noble/hashes@2.0.1):
+ dependencies:
+ '@exodus/bytes': 1.15.0(@noble/hashes@2.0.1)
+ transitivePeerDependencies:
+ - '@noble/hashes'
+
html-escaper@2.0.2: {}
html-escaper@3.0.3: {}
@@ -12147,6 +12776,8 @@ snapshots:
is-plain-object@5.0.0: {}
+ is-potential-custom-element-name@1.0.1: {}
+
is-promise@2.2.2: {}
is-promise@4.0.0: {}
@@ -12220,6 +12851,33 @@ snapshots:
gitignore-to-glob: 0.3.0
jscpd-sarif-reporter: 4.0.6
+ jsdom@28.1.0(@noble/hashes@2.0.1):
+ dependencies:
+ '@acemir/cssom': 0.9.31
+ '@asamuzakjp/dom-selector': 6.8.1
+ '@bramus/specificity': 2.4.2
+ '@exodus/bytes': 1.15.0(@noble/hashes@2.0.1)
+ cssstyle: 6.2.0
+ data-urls: 7.0.0(@noble/hashes@2.0.1)
+ decimal.js: 10.6.0
+ html-encoding-sniffer: 6.0.0(@noble/hashes@2.0.1)
+ http-proxy-agent: 7.0.2
+ https-proxy-agent: 7.0.6
+ is-potential-custom-element-name: 1.0.1
+ parse5: 8.0.0
+ saxes: 6.0.0
+ symbol-tree: 3.2.4
+ tough-cookie: 4.1.3
+ undici: 7.24.0
+ w3c-xmlserializer: 5.0.0
+ webidl-conversions: 8.0.1
+ whatwg-mimetype: 5.0.0
+ whatwg-url: 16.0.1(@noble/hashes@2.0.1)
+ xml-name-validator: 5.0.0
+ transitivePeerDependencies:
+ - '@noble/hashes'
+ - supports-color
+
jsesc@3.1.0: {}
json-bigint@1.0.0:
@@ -12323,55 +12981,54 @@ snapshots:
lifecycle-utils@3.1.1: {}
- lightningcss-android-arm64@1.30.2:
+ lightningcss-android-arm64@1.32.0:
optional: true
- lightningcss-darwin-arm64@1.30.2:
+ lightningcss-darwin-arm64@1.32.0:
optional: true
- lightningcss-darwin-x64@1.30.2:
+ lightningcss-darwin-x64@1.32.0:
optional: true
- lightningcss-freebsd-x64@1.30.2:
+ lightningcss-freebsd-x64@1.32.0:
optional: true
- lightningcss-linux-arm-gnueabihf@1.30.2:
+ lightningcss-linux-arm-gnueabihf@1.32.0:
optional: true
- lightningcss-linux-arm64-gnu@1.30.2:
+ lightningcss-linux-arm64-gnu@1.32.0:
optional: true
- lightningcss-linux-arm64-musl@1.30.2:
+ lightningcss-linux-arm64-musl@1.32.0:
optional: true
- lightningcss-linux-x64-gnu@1.30.2:
+ lightningcss-linux-x64-gnu@1.32.0:
optional: true
- lightningcss-linux-x64-musl@1.30.2:
+ lightningcss-linux-x64-musl@1.32.0:
optional: true
- lightningcss-win32-arm64-msvc@1.30.2:
+ lightningcss-win32-arm64-msvc@1.32.0:
optional: true
- lightningcss-win32-x64-msvc@1.30.2:
+ lightningcss-win32-x64-msvc@1.32.0:
optional: true
- lightningcss@1.30.2:
+ lightningcss@1.32.0:
dependencies:
detect-libc: 2.1.2
optionalDependencies:
- lightningcss-android-arm64: 1.30.2
- lightningcss-darwin-arm64: 1.30.2
- lightningcss-darwin-x64: 1.30.2
- lightningcss-freebsd-x64: 1.30.2
- lightningcss-linux-arm-gnueabihf: 1.30.2
- lightningcss-linux-arm64-gnu: 1.30.2
- lightningcss-linux-arm64-musl: 1.30.2
- lightningcss-linux-x64-gnu: 1.30.2
- lightningcss-linux-x64-musl: 1.30.2
- lightningcss-win32-arm64-msvc: 1.30.2
- lightningcss-win32-x64-msvc: 1.30.2
- optional: true
+ lightningcss-android-arm64: 1.32.0
+ lightningcss-darwin-arm64: 1.32.0
+ lightningcss-darwin-x64: 1.32.0
+ lightningcss-freebsd-x64: 1.32.0
+ lightningcss-linux-arm-gnueabihf: 1.32.0
+ lightningcss-linux-arm64-gnu: 1.32.0
+ lightningcss-linux-arm64-musl: 1.32.0
+ lightningcss-linux-x64-gnu: 1.32.0
+ lightningcss-linux-x64-musl: 1.32.0
+ lightningcss-win32-arm64-msvc: 1.32.0
+ lightningcss-win32-x64-msvc: 1.32.0
limiter@1.1.5: {}
@@ -12519,6 +13176,8 @@ snapshots:
unist-util-visit: 5.1.0
vfile: 6.0.3
+ mdn-data@2.27.1: {}
+
mdurl@2.0.0: {}
media-typer@0.3.0: {}
@@ -12614,9 +13273,9 @@ snapshots:
ms@2.1.3: {}
- music-metadata@11.12.1:
+ music-metadata@11.12.3:
dependencies:
- '@borewit/text-codec': 0.2.1
+ '@borewit/text-codec': 0.2.2
'@tokenizer/token': 0.3.0
content-type: 1.0.5
debug: 4.4.3
@@ -12838,9 +13497,9 @@ snapshots:
ws: 8.19.0
zod: 4.3.6
- openclaw@2026.3.8(@discordjs/opus@0.10.0)(@napi-rs/canvas@0.1.95)(@types/express@5.0.6)(audio-decode@2.2.3)(hono@4.12.7)(node-llama-cpp@3.16.2(typescript@5.9.3)):
+ openclaw@2026.3.11(@discordjs/opus@0.10.0)(@napi-rs/canvas@0.1.95)(@types/express@5.0.6)(audio-decode@2.2.3)(node-llama-cpp@3.16.2(typescript@5.9.3)):
dependencies:
- '@agentclientprotocol/sdk': 0.15.0(zod@4.3.6)
+ '@agentclientprotocol/sdk': 0.16.1(zod@4.3.6)
'@aws-sdk/client-bedrock': 3.1007.0
'@buape/carbon': 0.0.0-beta-20260216184201(@discordjs/opus@0.10.0)(hono@4.12.7)(opusscript@0.1.1)
'@clack/prompts': 1.1.0
@@ -12872,7 +13531,8 @@ snapshots:
express: 5.2.1
file-type: 21.3.1
grammy: 1.41.1
- https-proxy-agent: 7.0.6
+ hono: 4.12.7
+ https-proxy-agent: 8.0.0
ipaddr.js: 2.3.0
jiti: 2.6.1
json5: 2.2.3
@@ -12906,7 +13566,6 @@ snapshots:
- debug
- encoding
- ffmpeg-static
- - hono
- jimp
- link-preview-js
- node-opus
@@ -12933,29 +13592,29 @@ snapshots:
osc-progress@0.3.0: {}
- oxfmt@0.38.0:
+ oxfmt@0.40.0:
dependencies:
tinypool: 2.1.0
optionalDependencies:
- '@oxfmt/binding-android-arm-eabi': 0.38.0
- '@oxfmt/binding-android-arm64': 0.38.0
- '@oxfmt/binding-darwin-arm64': 0.38.0
- '@oxfmt/binding-darwin-x64': 0.38.0
- '@oxfmt/binding-freebsd-x64': 0.38.0
- '@oxfmt/binding-linux-arm-gnueabihf': 0.38.0
- '@oxfmt/binding-linux-arm-musleabihf': 0.38.0
- '@oxfmt/binding-linux-arm64-gnu': 0.38.0
- '@oxfmt/binding-linux-arm64-musl': 0.38.0
- '@oxfmt/binding-linux-ppc64-gnu': 0.38.0
- '@oxfmt/binding-linux-riscv64-gnu': 0.38.0
- '@oxfmt/binding-linux-riscv64-musl': 0.38.0
- '@oxfmt/binding-linux-s390x-gnu': 0.38.0
- '@oxfmt/binding-linux-x64-gnu': 0.38.0
- '@oxfmt/binding-linux-x64-musl': 0.38.0
- '@oxfmt/binding-openharmony-arm64': 0.38.0
- '@oxfmt/binding-win32-arm64-msvc': 0.38.0
- '@oxfmt/binding-win32-ia32-msvc': 0.38.0
- '@oxfmt/binding-win32-x64-msvc': 0.38.0
+ '@oxfmt/binding-android-arm-eabi': 0.40.0
+ '@oxfmt/binding-android-arm64': 0.40.0
+ '@oxfmt/binding-darwin-arm64': 0.40.0
+ '@oxfmt/binding-darwin-x64': 0.40.0
+ '@oxfmt/binding-freebsd-x64': 0.40.0
+ '@oxfmt/binding-linux-arm-gnueabihf': 0.40.0
+ '@oxfmt/binding-linux-arm-musleabihf': 0.40.0
+ '@oxfmt/binding-linux-arm64-gnu': 0.40.0
+ '@oxfmt/binding-linux-arm64-musl': 0.40.0
+ '@oxfmt/binding-linux-ppc64-gnu': 0.40.0
+ '@oxfmt/binding-linux-riscv64-gnu': 0.40.0
+ '@oxfmt/binding-linux-riscv64-musl': 0.40.0
+ '@oxfmt/binding-linux-s390x-gnu': 0.40.0
+ '@oxfmt/binding-linux-x64-gnu': 0.40.0
+ '@oxfmt/binding-linux-x64-musl': 0.40.0
+ '@oxfmt/binding-openharmony-arm64': 0.40.0
+ '@oxfmt/binding-win32-arm64-msvc': 0.40.0
+ '@oxfmt/binding-win32-ia32-msvc': 0.40.0
+ '@oxfmt/binding-win32-x64-msvc': 0.40.0
oxlint-tsgolint@0.16.0:
optionalDependencies:
@@ -12966,27 +13625,27 @@ snapshots:
'@oxlint-tsgolint/win32-arm64': 0.16.0
'@oxlint-tsgolint/win32-x64': 0.16.0
- oxlint@1.53.0(oxlint-tsgolint@0.16.0):
+ oxlint@1.55.0(oxlint-tsgolint@0.16.0):
optionalDependencies:
- '@oxlint/binding-android-arm-eabi': 1.53.0
- '@oxlint/binding-android-arm64': 1.53.0
- '@oxlint/binding-darwin-arm64': 1.53.0
- '@oxlint/binding-darwin-x64': 1.53.0
- '@oxlint/binding-freebsd-x64': 1.53.0
- '@oxlint/binding-linux-arm-gnueabihf': 1.53.0
- '@oxlint/binding-linux-arm-musleabihf': 1.53.0
- '@oxlint/binding-linux-arm64-gnu': 1.53.0
- '@oxlint/binding-linux-arm64-musl': 1.53.0
- '@oxlint/binding-linux-ppc64-gnu': 1.53.0
- '@oxlint/binding-linux-riscv64-gnu': 1.53.0
- '@oxlint/binding-linux-riscv64-musl': 1.53.0
- '@oxlint/binding-linux-s390x-gnu': 1.53.0
- '@oxlint/binding-linux-x64-gnu': 1.53.0
- '@oxlint/binding-linux-x64-musl': 1.53.0
- '@oxlint/binding-openharmony-arm64': 1.53.0
- '@oxlint/binding-win32-arm64-msvc': 1.53.0
- '@oxlint/binding-win32-ia32-msvc': 1.53.0
- '@oxlint/binding-win32-x64-msvc': 1.53.0
+ '@oxlint/binding-android-arm-eabi': 1.55.0
+ '@oxlint/binding-android-arm64': 1.55.0
+ '@oxlint/binding-darwin-arm64': 1.55.0
+ '@oxlint/binding-darwin-x64': 1.55.0
+ '@oxlint/binding-freebsd-x64': 1.55.0
+ '@oxlint/binding-linux-arm-gnueabihf': 1.55.0
+ '@oxlint/binding-linux-arm-musleabihf': 1.55.0
+ '@oxlint/binding-linux-arm64-gnu': 1.55.0
+ '@oxlint/binding-linux-arm64-musl': 1.55.0
+ '@oxlint/binding-linux-ppc64-gnu': 1.55.0
+ '@oxlint/binding-linux-riscv64-gnu': 1.55.0
+ '@oxlint/binding-linux-riscv64-musl': 1.55.0
+ '@oxlint/binding-linux-s390x-gnu': 1.55.0
+ '@oxlint/binding-linux-x64-gnu': 1.55.0
+ '@oxlint/binding-linux-x64-musl': 1.55.0
+ '@oxlint/binding-openharmony-arm64': 1.55.0
+ '@oxlint/binding-win32-arm64-msvc': 1.55.0
+ '@oxlint/binding-win32-ia32-msvc': 1.55.0
+ '@oxlint/binding-win32-x64-msvc': 1.55.0
oxlint-tsgolint: 0.16.0
p-finally@1.0.0: {}
@@ -13050,6 +13709,10 @@ snapshots:
parse5@6.0.1: {}
+ parse5@8.0.0:
+ dependencies:
+ entities: 6.0.1
+
parseley@0.12.1:
dependencies:
leac: 0.6.0
@@ -13121,10 +13784,6 @@ snapshots:
sonic-boom: 4.2.1
thread-stream: 3.1.0
- pixelmatch@7.1.0:
- dependencies:
- pngjs: 7.0.0
-
playwright-core@1.58.2: {}
playwright@1.58.2:
@@ -13141,6 +13800,12 @@ snapshots:
picocolors: 1.1.1
source-map-js: 1.2.1
+ postcss@8.5.8:
+ dependencies:
+ nanoid: 3.3.11
+ picocolors: 1.1.1
+ source-map-js: 1.2.1
+
postgres@3.4.8: {}
pretty-bytes@6.1.1: {}
@@ -13202,7 +13867,7 @@ snapshots:
'@protobufjs/path': 1.1.2
'@protobufjs/pool': 1.1.0
'@protobufjs/utf8': 1.1.0
- '@types/node': 25.4.0
+ '@types/node': 25.5.0
long: 5.3.2
proxy-addr@2.0.7:
@@ -13441,7 +14106,7 @@ snapshots:
dependencies:
glob: 10.5.0
- rolldown-plugin-dts@0.22.5(@typescript/native-preview@7.0.0-dev.20260311.1)(rolldown@1.0.0-rc.9)(typescript@5.9.3):
+ rolldown-plugin-dts@0.22.5(@typescript/native-preview@7.0.0-dev.20260312.1)(rolldown@1.0.0-rc.9)(typescript@5.9.3):
dependencies:
'@babel/generator': 8.0.0-rc.2
'@babel/helper-validator-identifier': 8.0.0-rc.2
@@ -13454,7 +14119,7 @@ snapshots:
obug: 2.1.1
rolldown: 1.0.0-rc.9
optionalDependencies:
- '@typescript/native-preview': 7.0.0-dev.20260311.1
+ '@typescript/native-preview': 7.0.0-dev.20260312.1
typescript: 5.9.3
transitivePeerDependencies:
- oxc-resolver
@@ -13480,37 +14145,6 @@ snapshots:
'@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.9
'@rolldown/binding-win32-x64-msvc': 1.0.0-rc.9
- rollup@4.59.0:
- dependencies:
- '@types/estree': 1.0.8
- optionalDependencies:
- '@rollup/rollup-android-arm-eabi': 4.59.0
- '@rollup/rollup-android-arm64': 4.59.0
- '@rollup/rollup-darwin-arm64': 4.59.0
- '@rollup/rollup-darwin-x64': 4.59.0
- '@rollup/rollup-freebsd-arm64': 4.59.0
- '@rollup/rollup-freebsd-x64': 4.59.0
- '@rollup/rollup-linux-arm-gnueabihf': 4.59.0
- '@rollup/rollup-linux-arm-musleabihf': 4.59.0
- '@rollup/rollup-linux-arm64-gnu': 4.59.0
- '@rollup/rollup-linux-arm64-musl': 4.59.0
- '@rollup/rollup-linux-loong64-gnu': 4.59.0
- '@rollup/rollup-linux-loong64-musl': 4.59.0
- '@rollup/rollup-linux-ppc64-gnu': 4.59.0
- '@rollup/rollup-linux-ppc64-musl': 4.59.0
- '@rollup/rollup-linux-riscv64-gnu': 4.59.0
- '@rollup/rollup-linux-riscv64-musl': 4.59.0
- '@rollup/rollup-linux-s390x-gnu': 4.59.0
- '@rollup/rollup-linux-x64-gnu': 4.59.0
- '@rollup/rollup-linux-x64-musl': 4.59.0
- '@rollup/rollup-openbsd-x64': 4.59.0
- '@rollup/rollup-openharmony-arm64': 4.59.0
- '@rollup/rollup-win32-arm64-msvc': 4.59.0
- '@rollup/rollup-win32-ia32-msvc': 4.59.0
- '@rollup/rollup-win32-x64-gnu': 4.59.0
- '@rollup/rollup-win32-x64-msvc': 4.59.0
- fsevents: 2.3.3
-
router@2.2.0:
dependencies:
debug: 4.4.3
@@ -13542,6 +14176,10 @@ snapshots:
parse-srcset: 1.0.2
postcss: 8.5.6
+ saxes@6.0.0:
+ dependencies:
+ xmlchars: 2.2.0
+
scheduler@0.27.0: {}
selderee@0.11.0:
@@ -13817,6 +14455,8 @@ snapshots:
std-env@3.10.0: {}
+ std-env@4.0.0: {}
+
stdin-discarder@0.3.1: {}
stdout-update@4.0.1:
@@ -13904,6 +14544,8 @@ snapshots:
supports-preserve-symlinks-flag@1.0.0: {}
+ symbol-tree@3.2.4: {}
+
table-layout@4.1.1:
dependencies:
array-back: 6.2.2
@@ -13964,7 +14606,7 @@ snapshots:
tinypool@2.1.0: {}
- tinyrainbow@3.0.3: {}
+ tinyrainbow@3.1.0: {}
to-regex-range@5.0.1:
dependencies:
@@ -13978,7 +14620,7 @@ snapshots:
token-types@6.1.2:
dependencies:
- '@borewit/text-codec': 0.2.1
+ '@borewit/text-codec': 0.2.2
'@tokenizer/token': 0.3.0
ieee754: 1.2.1
@@ -13993,13 +14635,17 @@ snapshots:
tr46@0.0.3: {}
+ tr46@6.0.0:
+ dependencies:
+ punycode: 2.3.1
+
tree-kill@1.2.2: {}
trim-lines@3.0.1: {}
ts-algebra@2.0.0: {}
- tsdown@0.21.2(@typescript/native-preview@7.0.0-dev.20260311.1)(typescript@5.9.3):
+ tsdown@0.21.2(@typescript/native-preview@7.0.0-dev.20260312.1)(typescript@5.9.3):
dependencies:
ansis: 4.2.0
cac: 7.0.0
@@ -14010,7 +14656,7 @@ snapshots:
obug: 2.1.1
picomatch: 4.0.3
rolldown: 1.0.0-rc.9
- rolldown-plugin-dts: 0.22.5(@typescript/native-preview@7.0.0-dev.20260311.1)(rolldown@1.0.0-rc.9)(typescript@5.9.3)
+ rolldown-plugin-dts: 0.22.5(@typescript/native-preview@7.0.0-dev.20260312.1)(rolldown@1.0.0-rc.9)(typescript@5.9.3)
semver: 7.7.4
tinyexec: 1.0.2
tinyglobby: 0.2.15
@@ -14081,6 +14727,8 @@ snapshots:
undici@7.22.0: {}
+ undici@7.24.0: {}
+
unist-util-is@6.0.1:
dependencies:
'@types/unist': 3.0.3
@@ -14155,67 +14803,74 @@ snapshots:
'@types/unist': 3.0.3
vfile-message: 4.0.3
- vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2):
+ vite@8.0.0(@types/node@25.5.0)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2):
dependencies:
- esbuild: 0.27.3
- fdir: 6.5.0(picomatch@4.0.3)
+ '@oxc-project/runtime': 0.115.0
+ lightningcss: 1.32.0
picomatch: 4.0.3
- postcss: 8.5.6
- rollup: 4.59.0
+ postcss: 8.5.8
+ rolldown: 1.0.0-rc.9
tinyglobby: 0.2.15
optionalDependencies:
- '@types/node': 25.4.0
+ '@types/node': 25.5.0
+ esbuild: 0.27.3
fsevents: 2.3.3
jiti: 2.6.1
- lightningcss: 1.30.2
tsx: 4.21.0
yaml: 2.8.2
- vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.4.0)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2):
+ vitest@4.1.0(@opentelemetry/api@1.9.0)(@types/node@25.5.0)(@vitest/browser-playwright@4.1.0)(jsdom@28.1.0(@noble/hashes@2.0.1))(vite@8.0.0(@types/node@25.5.0)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2)):
dependencies:
- '@vitest/expect': 4.0.18
- '@vitest/mocker': 4.0.18(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))
- '@vitest/pretty-format': 4.0.18
- '@vitest/runner': 4.0.18
- '@vitest/snapshot': 4.0.18
- '@vitest/spy': 4.0.18
- '@vitest/utils': 4.0.18
- es-module-lexer: 1.7.0
+ '@vitest/expect': 4.1.0
+ '@vitest/mocker': 4.1.0(vite@8.0.0(@types/node@25.5.0)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2))
+ '@vitest/pretty-format': 4.1.0
+ '@vitest/runner': 4.1.0
+ '@vitest/snapshot': 4.1.0
+ '@vitest/spy': 4.1.0
+ '@vitest/utils': 4.1.0
+ es-module-lexer: 2.0.0
expect-type: 1.3.0
magic-string: 0.30.21
obug: 2.1.1
pathe: 2.0.3
picomatch: 4.0.3
- std-env: 3.10.0
+ std-env: 4.0.0
tinybench: 2.9.0
tinyexec: 1.0.2
tinyglobby: 0.2.15
- tinyrainbow: 3.0.3
- vite: 7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2)
+ tinyrainbow: 3.1.0
+ vite: 8.0.0(@types/node@25.5.0)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2)
why-is-node-running: 2.3.0
optionalDependencies:
'@opentelemetry/api': 1.9.0
- '@types/node': 25.4.0
- '@vitest/browser-playwright': 4.0.18(playwright@1.58.2)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.18)
+ '@types/node': 25.5.0
+ '@vitest/browser-playwright': 4.1.0(playwright@1.58.2)(vite@8.0.0(@types/node@25.5.0)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.1.0)
+ jsdom: 28.1.0(@noble/hashes@2.0.1)
transitivePeerDependencies:
- - jiti
- - less
- - lightningcss
- msw
- - sass
- - sass-embedded
- - stylus
- - sugarss
- - terser
- - tsx
- - yaml
void-elements@3.1.0: {}
+ w3c-xmlserializer@5.0.0:
+ dependencies:
+ xml-name-validator: 5.0.0
+
web-streams-polyfill@3.3.3: {}
webidl-conversions@3.0.1: {}
+ webidl-conversions@8.0.1: {}
+
+ whatwg-mimetype@5.0.0: {}
+
+ whatwg-url@16.0.1(@noble/hashes@2.0.1):
+ dependencies:
+ '@exodus/bytes': 1.15.0(@noble/hashes@2.0.1)
+ tr46: 6.0.0
+ webidl-conversions: 8.0.1
+ transitivePeerDependencies:
+ - '@noble/hashes'
+
whatwg-url@5.0.0:
dependencies:
tr46: 0.0.3
@@ -14266,6 +14921,10 @@ snapshots:
ws@8.19.0: {}
+ xml-name-validator@5.0.0: {}
+
+ xmlchars@2.2.0: {}
+
y18n@5.0.8: {}
yallist@4.0.0: {}
diff --git a/scripts/bundle-a2ui.sh b/scripts/bundle-a2ui.sh
index 85bc265c7c9..3888e4cf5cb 100755
--- a/scripts/bundle-a2ui.sh
+++ b/scripts/bundle-a2ui.sh
@@ -32,13 +32,13 @@ INPUT_PATHS=(
)
compute_hash() {
- ROOT_DIR="$ROOT_DIR" node --input-type=module - "${INPUT_PATHS[@]}" <<'NODE'
+ ROOT_DIR="$ROOT_DIR" node --input-type=module --eval '
import { createHash } from "node:crypto";
import { promises as fs } from "node:fs";
import path from "node:path";
const rootDir = process.env.ROOT_DIR ?? process.cwd();
-const inputs = process.argv.slice(2);
+const inputs = process.argv.slice(1);
const files = [];
async function walk(entryPath) {
@@ -73,7 +73,7 @@ for (const filePath of files) {
}
process.stdout.write(hash.digest("hex"));
-NODE
+' "${INPUT_PATHS[@]}"
}
current_hash="$(compute_hash)"
diff --git a/scripts/docker/cleanup-smoke/Dockerfile b/scripts/docker/cleanup-smoke/Dockerfile
index e67a4b1fe87..19b89f3ac62 100644
--- a/scripts/docker/cleanup-smoke/Dockerfile
+++ b/scripts/docker/cleanup-smoke/Dockerfile
@@ -1,6 +1,6 @@
# syntax=docker/dockerfile:1.7
-FROM node:22-bookworm-slim@sha256:3cfe526ec8dd62013b8843e8e5d4877e297b886e5aace4a59fec25dc20736e45
+FROM node:24-bookworm-slim@sha256:b4687aef2571c632a1953695ce4d61d6462a7eda471fe6e272eebf0418f276ba
RUN --mount=type=cache,id=openclaw-cleanup-smoke-apt-cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,id=openclaw-cleanup-smoke-apt-lists,target=/var/lib/apt,sharing=locked \
diff --git a/scripts/docker/install-sh-e2e/Dockerfile b/scripts/docker/install-sh-e2e/Dockerfile
index 05b77f45197..539f18d295d 100644
--- a/scripts/docker/install-sh-e2e/Dockerfile
+++ b/scripts/docker/install-sh-e2e/Dockerfile
@@ -1,6 +1,6 @@
# syntax=docker/dockerfile:1.7
-FROM node:22-bookworm-slim@sha256:3cfe526ec8dd62013b8843e8e5d4877e297b886e5aace4a59fec25dc20736e45
+FROM node:24-bookworm-slim@sha256:b4687aef2571c632a1953695ce4d61d6462a7eda471fe6e272eebf0418f276ba
RUN --mount=type=cache,id=openclaw-install-sh-e2e-apt-cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,id=openclaw-install-sh-e2e-apt-lists,target=/var/lib/apt,sharing=locked \
diff --git a/scripts/docker/install-sh-smoke/Dockerfile b/scripts/docker/install-sh-smoke/Dockerfile
index 94fdca13a31..899af551aeb 100644
--- a/scripts/docker/install-sh-smoke/Dockerfile
+++ b/scripts/docker/install-sh-smoke/Dockerfile
@@ -1,6 +1,6 @@
# syntax=docker/dockerfile:1.7
-FROM node:22-bookworm-slim@sha256:3cfe526ec8dd62013b8843e8e5d4877e297b886e5aace4a59fec25dc20736e45
+FROM node:24-bookworm-slim@sha256:b4687aef2571c632a1953695ce4d61d6462a7eda471fe6e272eebf0418f276ba
RUN --mount=type=cache,id=openclaw-install-sh-smoke-apt-cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,id=openclaw-install-sh-smoke-apt-lists,target=/var/lib/apt,sharing=locked \
diff --git a/scripts/e2e/Dockerfile b/scripts/e2e/Dockerfile
index e8bd039155d..fb390c1190b 100644
--- a/scripts/e2e/Dockerfile
+++ b/scripts/e2e/Dockerfile
@@ -1,6 +1,6 @@
# syntax=docker/dockerfile:1.7
-FROM node:22-bookworm@sha256:cd7bcd2e7a1e6f72052feb023c7f6b722205d3fcab7bbcbd2d1bfdab10b1e935
+FROM node:24-bookworm@sha256:9f3b13503acdf9bc1e0213ccb25ebe86ac881cad17636733a1da1be1d44509df
RUN corepack enable
diff --git a/scripts/e2e/Dockerfile.qr-import b/scripts/e2e/Dockerfile.qr-import
index e221e0278a9..a8c611a9516 100644
--- a/scripts/e2e/Dockerfile.qr-import
+++ b/scripts/e2e/Dockerfile.qr-import
@@ -1,6 +1,6 @@
# syntax=docker/dockerfile:1.7
-FROM node:22-bookworm@sha256:cd7bcd2e7a1e6f72052feb023c7f6b722205d3fcab7bbcbd2d1bfdab10b1e935
+FROM node:24-bookworm@sha256:9f3b13503acdf9bc1e0213ccb25ebe86ac881cad17636733a1da1be1d44509df
RUN corepack enable
diff --git a/scripts/install.sh b/scripts/install.sh
index f7f13490796..ea02c48b6db 100755
--- a/scripts/install.sh
+++ b/scripts/install.sh
@@ -16,8 +16,9 @@ MUTED='\033[38;2;90;100;128m' # text-muted #5a6480
NC='\033[0m' # No Color
DEFAULT_TAGLINE="All your chats, one OpenClaw."
+NODE_DEFAULT_MAJOR=24
NODE_MIN_MAJOR=22
-NODE_MIN_MINOR=12
+NODE_MIN_MINOR=16
NODE_MIN_VERSION="${NODE_MIN_MAJOR}.${NODE_MIN_MINOR}"
ORIGINAL_PATH="${PATH:-}"
@@ -1316,14 +1317,14 @@ print_active_node_paths() {
return 0
}
-ensure_macos_node22_active() {
+ensure_macos_default_node_active() {
if [[ "$OS" != "macos" ]]; then
return 0
fi
local brew_node_prefix=""
if command -v brew &> /dev/null; then
- brew_node_prefix="$(brew --prefix node@22 2>/dev/null || true)"
+ brew_node_prefix="$(brew --prefix "node@${NODE_DEFAULT_MAJOR}" 2>/dev/null || true)"
if [[ -n "$brew_node_prefix" && -x "${brew_node_prefix}/bin/node" ]]; then
export PATH="${brew_node_prefix}/bin:$PATH"
refresh_shell_command_cache
@@ -1340,17 +1341,17 @@ ensure_macos_node22_active() {
active_path="$(command -v node 2>/dev/null || echo "not found")"
active_version="$(node -v 2>/dev/null || echo "missing")"
- ui_error "Node.js v22 was installed but this shell is using ${active_version} (${active_path})"
+ ui_error "Node.js v${NODE_DEFAULT_MAJOR} was installed but this shell is using ${active_version} (${active_path})"
if [[ -n "$brew_node_prefix" ]]; then
echo "Add this to your shell profile and restart shell:"
echo " export PATH=\"${brew_node_prefix}/bin:\$PATH\""
else
- echo "Ensure Homebrew node@22 is first on PATH, then rerun installer."
+ echo "Ensure Homebrew node@${NODE_DEFAULT_MAJOR} is first on PATH, then rerun installer."
fi
return 1
}
-ensure_node22_active_shell() {
+ensure_default_node_active_shell() {
if node_is_at_least_required; then
return 0
fi
@@ -1373,13 +1374,13 @@ ensure_node22_active_shell() {
if [[ "$nvm_detected" -eq 1 ]]; then
echo "nvm appears to be managing Node for this shell."
echo "Run:"
- echo " nvm install 22"
- echo " nvm use 22"
- echo " nvm alias default 22"
+ echo " nvm install ${NODE_DEFAULT_MAJOR}"
+ echo " nvm use ${NODE_DEFAULT_MAJOR}"
+ echo " nvm alias default ${NODE_DEFAULT_MAJOR}"
echo "Then open a new shell and rerun:"
echo " curl -fsSL https://openclaw.ai/install.sh | bash"
else
- echo "Install/select Node.js 22+ and ensure it is first on PATH, then rerun installer."
+ echo "Install/select Node.js ${NODE_DEFAULT_MAJOR} (or Node ${NODE_MIN_VERSION}+ minimum) and ensure it is first on PATH, then rerun installer."
fi
return 1
@@ -1410,9 +1411,9 @@ check_node() {
install_node() {
if [[ "$OS" == "macos" ]]; then
ui_info "Installing Node.js via Homebrew"
- run_quiet_step "Installing node@22" brew install node@22
- brew link node@22 --overwrite --force 2>/dev/null || true
- if ! ensure_macos_node22_active; then
+ run_quiet_step "Installing node@${NODE_DEFAULT_MAJOR}" brew install "node@${NODE_DEFAULT_MAJOR}"
+ brew link "node@${NODE_DEFAULT_MAJOR}" --overwrite --force 2>/dev/null || true
+ if ! ensure_macos_default_node_active; then
exit 1
fi
ui_success "Node.js installed"
@@ -1435,7 +1436,7 @@ install_node() {
else
run_quiet_step "Installing Node.js" sudo pacman -Sy --noconfirm nodejs npm
fi
- ui_success "Node.js v22 installed"
+ ui_success "Node.js v${NODE_DEFAULT_MAJOR} installed"
print_active_node_paths || true
return 0
fi
@@ -1444,7 +1445,7 @@ install_node() {
if command -v apt-get &> /dev/null; then
local tmp
tmp="$(mktempfile)"
- download_file "https://deb.nodesource.com/setup_22.x" "$tmp"
+ download_file "https://deb.nodesource.com/setup_${NODE_DEFAULT_MAJOR}.x" "$tmp"
if is_root; then
run_quiet_step "Configuring NodeSource repository" bash "$tmp"
run_quiet_step "Installing Node.js" apt-get install -y -qq nodejs
@@ -1455,7 +1456,7 @@ install_node() {
elif command -v dnf &> /dev/null; then
local tmp
tmp="$(mktempfile)"
- download_file "https://rpm.nodesource.com/setup_22.x" "$tmp"
+ download_file "https://rpm.nodesource.com/setup_${NODE_DEFAULT_MAJOR}.x" "$tmp"
if is_root; then
run_quiet_step "Configuring NodeSource repository" bash "$tmp"
run_quiet_step "Installing Node.js" dnf install -y -q nodejs
@@ -1466,7 +1467,7 @@ install_node() {
elif command -v yum &> /dev/null; then
local tmp
tmp="$(mktempfile)"
- download_file "https://rpm.nodesource.com/setup_22.x" "$tmp"
+ download_file "https://rpm.nodesource.com/setup_${NODE_DEFAULT_MAJOR}.x" "$tmp"
if is_root; then
run_quiet_step "Configuring NodeSource repository" bash "$tmp"
run_quiet_step "Installing Node.js" yum install -y -q nodejs
@@ -1476,11 +1477,11 @@ install_node() {
fi
else
ui_error "Could not detect package manager"
- echo "Please install Node.js 22+ manually: https://nodejs.org"
+ echo "Please install Node.js ${NODE_DEFAULT_MAJOR} manually (or Node ${NODE_MIN_VERSION}+ minimum): https://nodejs.org"
exit 1
fi
- ui_success "Node.js v22 installed"
+ ui_success "Node.js v${NODE_DEFAULT_MAJOR} installed"
print_active_node_paths || true
fi
}
@@ -2267,7 +2268,7 @@ main() {
if ! check_node; then
install_node
fi
- if ! ensure_node22_active_shell; then
+ if ! ensure_default_node_active_shell; then
exit 1
fi
diff --git a/scripts/ios-beta-prepare.sh b/scripts/ios-beta-prepare.sh
index 1d88add46db..9dd0d891c9e 100755
--- a/scripts/ios-beta-prepare.sh
+++ b/scripts/ios-beta-prepare.sh
@@ -4,11 +4,13 @@ set -euo pipefail
usage() {
cat <<'EOF'
Usage:
- scripts/ios-beta-prepare.sh --build-number 7 [--team-id TEAMID]
+ OPENCLAW_PUSH_RELAY_BASE_URL=https://relay.example.com \
+ scripts/ios-beta-prepare.sh --build-number 7 [--team-id TEAMID]
Prepares local beta-release inputs without touching local signing overrides:
- reads package.json.version and writes apps/ios/build/Version.xcconfig
- writes apps/ios/build/BetaRelease.xcconfig with canonical bundle IDs
+- configures the beta build for relay-backed APNs registration
- regenerates apps/ios/OpenClaw.xcodeproj via xcodegen
EOF
}
@@ -22,6 +24,8 @@ VERSION_HELPER="${ROOT_DIR}/scripts/ios-write-version-xcconfig.sh"
BUILD_NUMBER=""
TEAM_ID="${IOS_DEVELOPMENT_TEAM:-}"
+PUSH_RELAY_BASE_URL="${OPENCLAW_PUSH_RELAY_BASE_URL:-${IOS_PUSH_RELAY_BASE_URL:-}}"
+PUSH_RELAY_BASE_URL_XCCONFIG=""
PACKAGE_VERSION="$(cd "${ROOT_DIR}" && node -p "require('./package.json').version" 2>/dev/null || true)"
prepare_build_dir() {
@@ -47,6 +51,31 @@ write_generated_file() {
mv -f "${tmp_file}" "${output_path}"
}
+validate_push_relay_base_url() {
+ local value="$1"
+
+ if [[ "${value}" =~ [[:space:]] ]]; then
+ echo "Invalid OPENCLAW_PUSH_RELAY_BASE_URL: whitespace is not allowed." >&2
+ exit 1
+ fi
+
+ if [[ "${value}" == *'$'* || "${value}" == *'('* || "${value}" == *')'* || "${value}" == *'='* ]]; then
+ echo "Invalid OPENCLAW_PUSH_RELAY_BASE_URL: contains forbidden xcconfig characters." >&2
+ exit 1
+ fi
+
+ if [[ ! "${value}" =~ ^https://[A-Za-z0-9.-]+(:([0-9]{1,5}))?(/[A-Za-z0-9._~!&*+,;:@%/-]*)?$ ]]; then
+ echo "Invalid OPENCLAW_PUSH_RELAY_BASE_URL: expected https://host[:port][/path]." >&2
+ exit 1
+ fi
+
+ local port="${BASH_REMATCH[2]:-}"
+ if [[ -n "${port}" ]] && (( 10#${port} > 65535 )); then
+ echo "Invalid OPENCLAW_PUSH_RELAY_BASE_URL: port must be between 1 and 65535." >&2
+ exit 1
+ fi
+}
+
while [[ $# -gt 0 ]]; do
case "$1" in
--)
@@ -87,6 +116,20 @@ if [[ -z "${TEAM_ID}" ]]; then
exit 1
fi
+if [[ -z "${PUSH_RELAY_BASE_URL}" ]]; then
+ echo "Missing OPENCLAW_PUSH_RELAY_BASE_URL (or IOS_PUSH_RELAY_BASE_URL) for beta relay registration." >&2
+ exit 1
+fi
+
+validate_push_relay_base_url "${PUSH_RELAY_BASE_URL}"
+
+# `.xcconfig` treats `//` as a comment opener. Break the URL with a helper setting
+# so Xcode still resolves it back to `https://...` at build time.
+PUSH_RELAY_BASE_URL_XCCONFIG="$(
+ printf '%s' "${PUSH_RELAY_BASE_URL}" \
+ | sed 's#//#$(OPENCLAW_URL_SLASH)$(OPENCLAW_URL_SLASH)#g'
+)"
+
prepare_build_dir
(
@@ -106,6 +149,11 @@ OPENCLAW_WATCH_APP_BUNDLE_ID = ai.openclaw.client.watchkitapp
OPENCLAW_WATCH_EXTENSION_BUNDLE_ID = ai.openclaw.client.watchkitapp.extension
OPENCLAW_APP_PROFILE =
OPENCLAW_SHARE_PROFILE =
+OPENCLAW_PUSH_TRANSPORT = relay
+OPENCLAW_PUSH_DISTRIBUTION = official
+OPENCLAW_URL_SLASH = /
+OPENCLAW_PUSH_RELAY_BASE_URL = ${PUSH_RELAY_BASE_URL_XCCONFIG}
+OPENCLAW_PUSH_APNS_ENVIRONMENT = production
EOF
(
diff --git a/scripts/ios-write-version-xcconfig.sh b/scripts/ios-write-version-xcconfig.sh
index b63d3e81adb..4c39885d1dd 100755
--- a/scripts/ios-write-version-xcconfig.sh
+++ b/scripts/ios-write-version-xcconfig.sh
@@ -73,7 +73,7 @@ fi
if [[ "${PACKAGE_VERSION}" =~ ^([0-9]{4}\.[0-9]{1,2}\.[0-9]{1,2})([.-]?beta[.-][0-9]+)?$ ]]; then
MARKETING_VERSION="${BASH_REMATCH[1]}"
else
- echo "Unsupported package.json.version '${PACKAGE_VERSION}'. Expected 2026.3.11 or 2026.3.11-beta.1." >&2
+ echo "Unsupported package.json.version '${PACKAGE_VERSION}'. Expected 2026.3.12 or 2026.3.12-beta.1." >&2
exit 1
fi
diff --git a/scripts/k8s/create-kind.sh b/scripts/k8s/create-kind.sh
new file mode 100755
index 00000000000..688f576a70e
--- /dev/null
+++ b/scripts/k8s/create-kind.sh
@@ -0,0 +1,209 @@
+#!/usr/bin/env bash
+# ============================================================================
+# KIND CLUSTER BOOTSTRAP SCRIPT
+# ============================================================================
+#
+# Usage:
+# ./scripts/k8s/create-kind.sh # Create with auto-detected engine
+# ./scripts/k8s/create-kind.sh --name mycluster
+# ./scripts/k8s/create-kind.sh --delete
+#
+# After creation, deploy with:
+# export _API_KEY="..." && ./scripts/k8s/deploy.sh
+# ============================================================================
+
+set -euo pipefail
+
+# Defaults
+CLUSTER_NAME="openclaw"
+CONTAINER_CMD=""
+DELETE=false
+
+# Colors
+GREEN='\033[0;32m'
+BLUE='\033[0;34m'
+YELLOW='\033[0;33m'
+RED='\033[0;31m'
+NC='\033[0m'
+
+info() { echo -e "${BLUE}[INFO]${NC} $1"; }
+success() { echo -e "${GREEN}[OK]${NC} $1"; }
+warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
+fail() { echo -e "${RED}[ERROR]${NC} $1" >&2; exit 1; }
+
+usage() {
+ cat </dev/null
+}
+
+provider_responsive() {
+ case "$1" in
+ docker)
+ docker info &>/dev/null
+ ;;
+ podman)
+ podman info &>/dev/null
+ ;;
+ *)
+ return 1
+ ;;
+ esac
+}
+
+detect_provider() {
+ local candidate
+
+ for candidate in podman docker; do
+ if provider_installed "$candidate" && provider_responsive "$candidate"; then
+ echo "$candidate"
+ return 0
+ fi
+ done
+
+ for candidate in podman docker; do
+ if provider_installed "$candidate"; then
+ case "$candidate" in
+ podman)
+ fail "Podman is installed but not responding, and no responsive Docker daemon was found. Ensure the podman machine is running (podman machine start) or start Docker."
+ ;;
+ docker)
+ fail "Docker is installed but not running, and no responsive Podman machine was found. Start Docker or start Podman."
+ ;;
+ esac
+ fi
+ done
+
+ fail "Neither podman nor docker found. Install one to use Kind."
+}
+
+CONTAINER_CMD=$(detect_provider)
+info "Auto-detected container engine: $CONTAINER_CMD"
+
+# ---------------------------------------------------------------------------
+# Prerequisites
+# ---------------------------------------------------------------------------
+if ! command -v kind &>/dev/null; then
+ fail "kind is not installed. Install it from https://kind.sigs.k8s.io/"
+fi
+
+if ! command -v kubectl &>/dev/null; then
+ fail "kubectl is not installed. Install it before creating or managing a Kind cluster."
+fi
+
+# Verify the container engine is responsive
+if ! provider_responsive "$CONTAINER_CMD"; then
+ if [[ "$CONTAINER_CMD" == "docker" ]]; then
+ fail "Docker daemon is not running. Start it and try again."
+ elif [[ "$CONTAINER_CMD" == "podman" ]]; then
+ fail "Podman is not responding. Ensure the podman machine is running (podman machine start)."
+ fi
+fi
+
+# ---------------------------------------------------------------------------
+# Delete mode
+# ---------------------------------------------------------------------------
+if $DELETE; then
+ info "Deleting Kind cluster '$CLUSTER_NAME'..."
+ if KIND_EXPERIMENTAL_PROVIDER="$CONTAINER_CMD" kind get clusters 2>/dev/null | grep -qx "$CLUSTER_NAME"; then
+ KIND_EXPERIMENTAL_PROVIDER="$CONTAINER_CMD" kind delete cluster --name "$CLUSTER_NAME"
+ success "Cluster '$CLUSTER_NAME' deleted."
+ else
+ warn "Cluster '$CLUSTER_NAME' does not exist."
+ fi
+ exit 0
+fi
+
+# ---------------------------------------------------------------------------
+# Check if cluster already exists
+# ---------------------------------------------------------------------------
+if KIND_EXPERIMENTAL_PROVIDER="$CONTAINER_CMD" kind get clusters 2>/dev/null | grep -qx "$CLUSTER_NAME"; then
+ warn "Cluster '$CLUSTER_NAME' already exists."
+ info "To recreate it, run: $0 --name \"$CLUSTER_NAME\" --delete && $0 --name \"$CLUSTER_NAME\""
+ info "Switching kubectl context to kind-$CLUSTER_NAME..."
+ kubectl config use-context "kind-$CLUSTER_NAME" &>/dev/null && success "Context set." || warn "Could not switch context."
+ exit 0
+fi
+
+# ---------------------------------------------------------------------------
+# Create cluster
+# ---------------------------------------------------------------------------
+info "Creating Kind cluster '$CLUSTER_NAME' (provider: $CONTAINER_CMD)..."
+
+KIND_EXPERIMENTAL_PROVIDER="$CONTAINER_CMD" kind create cluster \
+ --name "$CLUSTER_NAME" \
+ --config - <<'KINDCFG'
+kind: Cluster
+apiVersion: kind.x-k8s.io/v1alpha4
+nodes:
+- role: control-plane
+ labels:
+ openclaw.dev/role: control-plane
+ # Uncomment to expose services on host ports:
+ # extraPortMappings:
+ # - containerPort: 30080
+ # hostPort: 8080
+ # protocol: TCP
+ # - containerPort: 30443
+ # hostPort: 8443
+ # protocol: TCP
+KINDCFG
+
+success "Kind cluster '$CLUSTER_NAME' created."
+
+# ---------------------------------------------------------------------------
+# Wait for readiness
+# ---------------------------------------------------------------------------
+info "Waiting for cluster to be ready..."
+kubectl --context "kind-$CLUSTER_NAME" wait --for=condition=Ready nodes --all --timeout=120s >/dev/null
+success "All nodes are Ready."
+
+# ---------------------------------------------------------------------------
+# Summary
+# ---------------------------------------------------------------------------
+echo ""
+echo "---------------------------------------------------------------"
+echo " Kind cluster '$CLUSTER_NAME' is ready"
+echo "---------------------------------------------------------------"
+echo ""
+echo " kubectl cluster-info --context kind-$CLUSTER_NAME"
+echo ""
+echo ""
+echo " export _API_KEY=\"...\" && ./scripts/k8s/deploy.sh"
+echo ""
diff --git a/scripts/k8s/deploy.sh b/scripts/k8s/deploy.sh
new file mode 100755
index 00000000000..abd62dedf58
--- /dev/null
+++ b/scripts/k8s/deploy.sh
@@ -0,0 +1,231 @@
+#!/usr/bin/env bash
+# Deploy OpenClaw to Kubernetes.
+#
+# Secrets are generated in a temp directory and applied server-side.
+# No secret material is ever written to the repo checkout.
+#
+# Usage:
+# ./scripts/k8s/deploy.sh # Deploy (requires API key in env or secret already in cluster)
+# ./scripts/k8s/deploy.sh --create-secret # Create or update the K8s Secret from env vars
+# ./scripts/k8s/deploy.sh --show-token # Print the gateway token after deploy
+# ./scripts/k8s/deploy.sh --delete # Tear down
+#
+# Environment:
+# OPENCLAW_NAMESPACE Kubernetes namespace (default: openclaw)
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+MANIFESTS="$SCRIPT_DIR/manifests"
+NS="${OPENCLAW_NAMESPACE:-openclaw}"
+
+# Check prerequisites
+for cmd in kubectl openssl; do
+ command -v "$cmd" &>/dev/null || { echo "Missing: $cmd" >&2; exit 1; }
+done
+kubectl cluster-info &>/dev/null || { echo "Cannot connect to cluster. Check kubeconfig." >&2; exit 1; }
+
+# ---------------------------------------------------------------------------
+# -h / --help
+# ---------------------------------------------------------------------------
+if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
+ cat <<'HELP'
+Usage: ./scripts/k8s/deploy.sh [OPTION]
+
+ (no args) Deploy OpenClaw (creates secret from env if needed)
+ --create-secret Create or update the K8s Secret from env vars without deploying
+ --show-token Print the gateway token after deploy or secret creation
+ --delete Delete the namespace and all resources
+ -h, --help Show this help
+
+Environment:
+ Export at least one provider API key:
+ ANTHROPIC_API_KEY, GEMINI_API_KEY, OPENAI_API_KEY, OPENROUTER_API_KEY
+
+ OPENCLAW_NAMESPACE Kubernetes namespace (default: openclaw)
+HELP
+ exit 0
+fi
+
+SHOW_TOKEN=false
+MODE="deploy"
+
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --create-secret)
+ MODE="create-secret"
+ ;;
+ --delete)
+ MODE="delete"
+ ;;
+ --show-token)
+ SHOW_TOKEN=true
+ ;;
+ *)
+ echo "Unknown option: $1" >&2
+ echo "Run ./scripts/k8s/deploy.sh --help for usage." >&2
+ exit 1
+ ;;
+ esac
+ shift
+done
+
+# ---------------------------------------------------------------------------
+# --delete
+# ---------------------------------------------------------------------------
+if [[ "$MODE" == "delete" ]]; then
+ echo "Deleting namespace '$NS' and all resources..."
+ kubectl delete namespace "$NS" --ignore-not-found
+ echo "Done."
+ exit 0
+fi
+
+# ---------------------------------------------------------------------------
+# Create and apply Secret to the cluster
+# ---------------------------------------------------------------------------
+_apply_secret() {
+ local TMP_DIR
+ local EXISTING_SECRET=false
+ local EXISTING_TOKEN=""
+ local ANTHROPIC_VALUE=""
+ local OPENAI_VALUE=""
+ local GEMINI_VALUE=""
+ local OPENROUTER_VALUE=""
+ local TOKEN
+ local SECRET_MANIFEST
+ TMP_DIR="$(mktemp -d)"
+ chmod 700 "$TMP_DIR"
+ trap 'rm -rf "$TMP_DIR"' EXIT
+
+ if kubectl get secret openclaw-secrets -n "$NS" &>/dev/null; then
+ EXISTING_SECRET=true
+ EXISTING_TOKEN="$(kubectl get secret openclaw-secrets -n "$NS" -o jsonpath='{.data.OPENCLAW_GATEWAY_TOKEN}' | base64 -d)"
+ ANTHROPIC_VALUE="$(kubectl get secret openclaw-secrets -n "$NS" -o jsonpath='{.data.ANTHROPIC_API_KEY}' 2>/dev/null | base64 -d)"
+ OPENAI_VALUE="$(kubectl get secret openclaw-secrets -n "$NS" -o jsonpath='{.data.OPENAI_API_KEY}' 2>/dev/null | base64 -d)"
+ GEMINI_VALUE="$(kubectl get secret openclaw-secrets -n "$NS" -o jsonpath='{.data.GEMINI_API_KEY}' 2>/dev/null | base64 -d)"
+ OPENROUTER_VALUE="$(kubectl get secret openclaw-secrets -n "$NS" -o jsonpath='{.data.OPENROUTER_API_KEY}' 2>/dev/null | base64 -d)"
+ fi
+
+ TOKEN="${EXISTING_TOKEN:-$(openssl rand -hex 32)}"
+ ANTHROPIC_VALUE="${ANTHROPIC_API_KEY:-$ANTHROPIC_VALUE}"
+ OPENAI_VALUE="${OPENAI_API_KEY:-$OPENAI_VALUE}"
+ GEMINI_VALUE="${GEMINI_API_KEY:-$GEMINI_VALUE}"
+ OPENROUTER_VALUE="${OPENROUTER_API_KEY:-$OPENROUTER_VALUE}"
+ SECRET_MANIFEST="$TMP_DIR/secrets.yaml"
+
+ # Write secret material to temp files so kubectl handles encoding safely.
+ printf '%s' "$TOKEN" > "$TMP_DIR/OPENCLAW_GATEWAY_TOKEN"
+ printf '%s' "$ANTHROPIC_VALUE" > "$TMP_DIR/ANTHROPIC_API_KEY"
+ printf '%s' "$OPENAI_VALUE" > "$TMP_DIR/OPENAI_API_KEY"
+ printf '%s' "$GEMINI_VALUE" > "$TMP_DIR/GEMINI_API_KEY"
+ printf '%s' "$OPENROUTER_VALUE" > "$TMP_DIR/OPENROUTER_API_KEY"
+ chmod 600 \
+ "$TMP_DIR/OPENCLAW_GATEWAY_TOKEN" \
+ "$TMP_DIR/ANTHROPIC_API_KEY" \
+ "$TMP_DIR/OPENAI_API_KEY" \
+ "$TMP_DIR/GEMINI_API_KEY" \
+ "$TMP_DIR/OPENROUTER_API_KEY"
+
+ kubectl create secret generic openclaw-secrets \
+ -n "$NS" \
+ --from-file=OPENCLAW_GATEWAY_TOKEN="$TMP_DIR/OPENCLAW_GATEWAY_TOKEN" \
+ --from-file=ANTHROPIC_API_KEY="$TMP_DIR/ANTHROPIC_API_KEY" \
+ --from-file=OPENAI_API_KEY="$TMP_DIR/OPENAI_API_KEY" \
+ --from-file=GEMINI_API_KEY="$TMP_DIR/GEMINI_API_KEY" \
+ --from-file=OPENROUTER_API_KEY="$TMP_DIR/OPENROUTER_API_KEY" \
+ --dry-run=client \
+ -o yaml > "$SECRET_MANIFEST"
+ chmod 600 "$SECRET_MANIFEST"
+
+ kubectl create namespace "$NS" --dry-run=client -o yaml | kubectl apply -f - >/dev/null
+ kubectl apply --server-side --field-manager=openclaw -f "$SECRET_MANIFEST" >/dev/null
+ # Clean up any annotation left by older client-side apply runs.
+ kubectl annotate secret openclaw-secrets -n "$NS" kubectl.kubernetes.io/last-applied-configuration- >/dev/null 2>&1 || true
+ rm -rf "$TMP_DIR"
+ trap - EXIT
+
+ if $EXISTING_SECRET; then
+ echo "Secret updated in namespace '$NS'. Existing gateway token preserved."
+ else
+ echo "Secret created in namespace '$NS'."
+ fi
+
+ if $SHOW_TOKEN; then
+ echo "Gateway token: $TOKEN"
+ else
+ echo "Gateway token stored in Secret only."
+ echo "Retrieve it with:"
+ echo " kubectl get secret openclaw-secrets -n $NS -o jsonpath='{.data.OPENCLAW_GATEWAY_TOKEN}' | base64 -d && echo"
+ fi
+}
+
+# ---------------------------------------------------------------------------
+# --create-secret
+# ---------------------------------------------------------------------------
+if [[ "$MODE" == "create-secret" ]]; then
+ HAS_KEY=false
+ for key in ANTHROPIC_API_KEY OPENAI_API_KEY GEMINI_API_KEY OPENROUTER_API_KEY; do
+ if [[ -n "${!key:-}" ]]; then
+ HAS_KEY=true
+ echo " Found $key in environment"
+ fi
+ done
+
+ if ! $HAS_KEY; then
+ echo "No API keys found in environment. Export at least one and re-run:"
+ echo " export _API_KEY=\"...\" (ANTHROPIC, GEMINI, OPENAI, or OPENROUTER)"
+ echo " ./scripts/k8s/deploy.sh --create-secret"
+ exit 1
+ fi
+
+ _apply_secret
+ echo ""
+ echo "Now run:"
+ echo " ./scripts/k8s/deploy.sh"
+ exit 0
+fi
+
+# ---------------------------------------------------------------------------
+# Check that the secret exists in the cluster
+# ---------------------------------------------------------------------------
+if ! kubectl get secret openclaw-secrets -n "$NS" &>/dev/null; then
+ HAS_KEY=false
+ for key in ANTHROPIC_API_KEY OPENAI_API_KEY GEMINI_API_KEY OPENROUTER_API_KEY; do
+ [[ -n "${!key:-}" ]] && HAS_KEY=true
+ done
+
+ if $HAS_KEY; then
+ echo "Creating secret from environment..."
+ _apply_secret
+ echo ""
+ else
+ echo "No secret found and no API keys in environment."
+ echo ""
+ echo "Export at least one provider API key and re-run:"
+ echo " export _API_KEY=\"...\" (ANTHROPIC, GEMINI, OPENAI, or OPENROUTER)"
+ echo " ./scripts/k8s/deploy.sh"
+ exit 1
+ fi
+fi
+
+# ---------------------------------------------------------------------------
+# Deploy
+# ---------------------------------------------------------------------------
+echo "Deploying to namespace '$NS'..."
+kubectl create namespace "$NS" --dry-run=client -o yaml | kubectl apply -f - >/dev/null
+kubectl apply -k "$MANIFESTS" -n "$NS"
+kubectl rollout restart deployment/openclaw -n "$NS" 2>/dev/null || true
+echo ""
+echo "Waiting for rollout..."
+kubectl rollout status deployment/openclaw -n "$NS" --timeout=300s
+echo ""
+echo "Done. Access the gateway:"
+echo " kubectl port-forward svc/openclaw 18789:18789 -n $NS"
+echo " open http://localhost:18789"
+echo ""
+if $SHOW_TOKEN; then
+ echo "Gateway token (paste into Control UI):"
+ echo " $(kubectl get secret openclaw-secrets -n "$NS" -o jsonpath='{.data.OPENCLAW_GATEWAY_TOKEN}' | base64 -d)"
+echo ""
+fi
+echo "Retrieve the gateway token with:"
+echo " kubectl get secret openclaw-secrets -n $NS -o jsonpath='{.data.OPENCLAW_GATEWAY_TOKEN}' | base64 -d && echo"
diff --git a/scripts/k8s/manifests/configmap.yaml b/scripts/k8s/manifests/configmap.yaml
new file mode 100644
index 00000000000..2334b0370c8
--- /dev/null
+++ b/scripts/k8s/manifests/configmap.yaml
@@ -0,0 +1,38 @@
+apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: openclaw-config
+ labels:
+ app: openclaw
+data:
+ openclaw.json: |
+ {
+ "gateway": {
+ "mode": "local",
+ "bind": "loopback",
+ "port": 18789,
+ "auth": {
+ "mode": "token"
+ },
+ "controlUi": {
+ "enabled": true
+ }
+ },
+ "agents": {
+ "defaults": {
+ "workspace": "~/.openclaw/workspace"
+ },
+ "list": [
+ {
+ "id": "default",
+ "name": "OpenClaw Assistant",
+ "workspace": "~/.openclaw/workspace"
+ }
+ ]
+ },
+ "cron": { "enabled": false }
+ }
+ AGENTS.md: |
+ # OpenClaw Assistant
+
+ You are a helpful AI assistant running in Kubernetes.
diff --git a/scripts/k8s/manifests/deployment.yaml b/scripts/k8s/manifests/deployment.yaml
new file mode 100644
index 00000000000..f87c266930b
--- /dev/null
+++ b/scripts/k8s/manifests/deployment.yaml
@@ -0,0 +1,146 @@
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: openclaw
+ labels:
+ app: openclaw
+spec:
+ replicas: 1
+ selector:
+ matchLabels:
+ app: openclaw
+ strategy:
+ type: Recreate
+ template:
+ metadata:
+ labels:
+ app: openclaw
+ spec:
+ automountServiceAccountToken: false
+ securityContext:
+ fsGroup: 1000
+ seccompProfile:
+ type: RuntimeDefault
+ initContainers:
+ - name: init-config
+ image: busybox:1.37
+ imagePullPolicy: IfNotPresent
+ command:
+ - sh
+ - -c
+ - |
+ cp /config/openclaw.json /home/node/.openclaw/openclaw.json
+ mkdir -p /home/node/.openclaw/workspace
+ cp /config/AGENTS.md /home/node/.openclaw/workspace/AGENTS.md
+ securityContext:
+ runAsUser: 1000
+ runAsGroup: 1000
+ resources:
+ requests:
+ memory: 32Mi
+ cpu: 50m
+ limits:
+ memory: 64Mi
+ cpu: 100m
+ volumeMounts:
+ - name: openclaw-home
+ mountPath: /home/node/.openclaw
+ - name: config
+ mountPath: /config
+ containers:
+ - name: gateway
+ image: ghcr.io/openclaw/openclaw:slim
+ imagePullPolicy: IfNotPresent
+ command:
+ - node
+ - /app/dist/index.js
+ - gateway
+ - run
+ ports:
+ - name: gateway
+ containerPort: 18789
+ protocol: TCP
+ env:
+ - name: HOME
+ value: /home/node
+ - name: OPENCLAW_CONFIG_DIR
+ value: /home/node/.openclaw
+ - name: NODE_ENV
+ value: production
+ - name: OPENCLAW_GATEWAY_TOKEN
+ valueFrom:
+ secretKeyRef:
+ name: openclaw-secrets
+ key: OPENCLAW_GATEWAY_TOKEN
+ - name: ANTHROPIC_API_KEY
+ valueFrom:
+ secretKeyRef:
+ name: openclaw-secrets
+ key: ANTHROPIC_API_KEY
+ optional: true
+ - name: OPENAI_API_KEY
+ valueFrom:
+ secretKeyRef:
+ name: openclaw-secrets
+ key: OPENAI_API_KEY
+ optional: true
+ - name: GEMINI_API_KEY
+ valueFrom:
+ secretKeyRef:
+ name: openclaw-secrets
+ key: GEMINI_API_KEY
+ optional: true
+ - name: OPENROUTER_API_KEY
+ valueFrom:
+ secretKeyRef:
+ name: openclaw-secrets
+ key: OPENROUTER_API_KEY
+ optional: true
+ resources:
+ requests:
+ memory: 512Mi
+ cpu: 250m
+ limits:
+ memory: 2Gi
+ cpu: "1"
+ livenessProbe:
+ exec:
+ command:
+ - node
+ - -e
+ - "require('http').get('http://127.0.0.1:18789/healthz', r => process.exit(r.statusCode < 400 ? 0 : 1)).on('error', () => process.exit(1))"
+ initialDelaySeconds: 60
+ periodSeconds: 30
+ timeoutSeconds: 10
+ readinessProbe:
+ exec:
+ command:
+ - node
+ - -e
+ - "require('http').get('http://127.0.0.1:18789/readyz', r => process.exit(r.statusCode < 400 ? 0 : 1)).on('error', () => process.exit(1))"
+ initialDelaySeconds: 15
+ periodSeconds: 10
+ timeoutSeconds: 5
+ volumeMounts:
+ - name: openclaw-home
+ mountPath: /home/node/.openclaw
+ - name: tmp-volume
+ mountPath: /tmp
+ securityContext:
+ runAsNonRoot: true
+ runAsUser: 1000
+ runAsGroup: 1000
+ allowPrivilegeEscalation: false
+ readOnlyRootFilesystem: true
+ capabilities:
+ drop:
+ - ALL
+ volumes:
+ - name: openclaw-home
+ persistentVolumeClaim:
+ claimName: openclaw-home-pvc
+ - name: config
+ configMap:
+ name: openclaw-config
+ - name: tmp-volume
+ emptyDir: {}
diff --git a/scripts/k8s/manifests/kustomization.yaml b/scripts/k8s/manifests/kustomization.yaml
new file mode 100644
index 00000000000..7d1fa13e10c
--- /dev/null
+++ b/scripts/k8s/manifests/kustomization.yaml
@@ -0,0 +1,7 @@
+apiVersion: kustomize.config.k8s.io/v1beta1
+kind: Kustomization
+resources:
+ - pvc.yaml
+ - configmap.yaml
+ - deployment.yaml
+ - service.yaml
diff --git a/scripts/k8s/manifests/pvc.yaml b/scripts/k8s/manifests/pvc.yaml
new file mode 100644
index 00000000000..e834e788a0e
--- /dev/null
+++ b/scripts/k8s/manifests/pvc.yaml
@@ -0,0 +1,12 @@
+apiVersion: v1
+kind: PersistentVolumeClaim
+metadata:
+ name: openclaw-home-pvc
+ labels:
+ app: openclaw
+spec:
+ accessModes:
+ - ReadWriteOnce
+ resources:
+ requests:
+ storage: 10Gi
diff --git a/scripts/k8s/manifests/service.yaml b/scripts/k8s/manifests/service.yaml
new file mode 100644
index 00000000000..41df6219782
--- /dev/null
+++ b/scripts/k8s/manifests/service.yaml
@@ -0,0 +1,15 @@
+apiVersion: v1
+kind: Service
+metadata:
+ name: openclaw
+ labels:
+ app: openclaw
+spec:
+ type: ClusterIP
+ selector:
+ app: openclaw
+ ports:
+ - name: gateway
+ port: 18789
+ targetPort: 18789
+ protocol: TCP
diff --git a/scripts/openclaw-npm-release-check.ts b/scripts/openclaw-npm-release-check.ts
index 267558a0d0d..fcd2dc8e7e1 100644
--- a/scripts/openclaw-npm-release-check.ts
+++ b/scripts/openclaw-npm-release-check.ts
@@ -11,6 +11,8 @@ type PackageJson = {
license?: string;
repository?: { url?: string } | string;
bin?: Record;
+ peerDependencies?: Record;
+ peerDependenciesMeta?: Record;
};
export type ParsedReleaseVersion = {
@@ -140,6 +142,16 @@ export function collectReleasePackageMetadataErrors(pkg: PackageJson): string[]
`package.json bin.openclaw must be "openclaw.mjs"; found "${pkg.bin?.openclaw ?? ""}".`,
);
}
+ if (pkg.peerDependencies?.["node-llama-cpp"] !== "3.16.2") {
+ errors.push(
+ `package.json peerDependencies["node-llama-cpp"] must be "3.16.2"; found "${
+ pkg.peerDependencies?.["node-llama-cpp"] ?? ""
+ }".`,
+ );
+ }
+ if (pkg.peerDependenciesMeta?.["node-llama-cpp"]?.optional !== true) {
+ errors.push('package.json peerDependenciesMeta["node-llama-cpp"].optional must be true.');
+ }
return errors;
}
diff --git a/scripts/release-check.ts b/scripts/release-check.ts
index fe2a9a1ea9c..6f621cef2d5 100755
--- a/scripts/release-check.ts
+++ b/scripts/release-check.ts
@@ -218,6 +218,16 @@ function runPackDry(): PackResult[] {
return JSON.parse(raw) as PackResult[];
}
+export function collectForbiddenPackPaths(paths: Iterable): string[] {
+ return [...paths]
+ .filter(
+ (path) =>
+ forbiddenPrefixes.some((prefix) => path.startsWith(prefix)) ||
+ /(^|\/)node_modules\//.test(path),
+ )
+ .toSorted();
+}
+
function checkPluginVersions() {
const rootPackagePath = resolve("package.json");
const rootPackage = JSON.parse(readFileSync(rootPackagePath, "utf8")) as PackageJson;
@@ -422,9 +432,7 @@ function main() {
return paths.has(group) ? [] : [group];
})
.toSorted();
- const forbidden = [...paths].filter((path) =>
- forbiddenPrefixes.some((prefix) => path.startsWith(prefix)),
- );
+ const forbidden = collectForbiddenPackPaths(paths);
if (missing.length > 0 || forbidden.length > 0) {
if (missing.length > 0) {
diff --git a/scripts/test-live-gateway-models-docker.sh b/scripts/test-live-gateway-models-docker.sh
index 92ddb905ed5..3998110efa6 100755
--- a/scripts/test-live-gateway-models-docker.sh
+++ b/scripts/test-live-gateway-models-docker.sh
@@ -3,6 +3,7 @@ set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
IMAGE_NAME="${OPENCLAW_IMAGE:-${CLAWDBOT_IMAGE:-openclaw:local}}"
+LIVE_IMAGE_NAME="${OPENCLAW_LIVE_IMAGE:-${CLAWDBOT_LIVE_IMAGE:-${IMAGE_NAME}-live}}"
CONFIG_DIR="${OPENCLAW_CONFIG_DIR:-${CLAWDBOT_CONFIG_DIR:-$HOME/.openclaw}}"
WORKSPACE_DIR="${OPENCLAW_WORKSPACE_DIR:-${CLAWDBOT_WORKSPACE_DIR:-$HOME/.openclaw/workspace}}"
PROFILE_FILE="${OPENCLAW_PROFILE_FILE:-${CLAWDBOT_PROFILE_FILE:-$HOME/.profile}}"
@@ -33,8 +34,8 @@ cd "$tmp_dir"
pnpm test:live
EOF
-echo "==> Build image: $IMAGE_NAME"
-docker build -t "$IMAGE_NAME" -f "$ROOT_DIR/Dockerfile" "$ROOT_DIR"
+echo "==> Build live-test image: $LIVE_IMAGE_NAME (target=build)"
+docker build --target build -t "$LIVE_IMAGE_NAME" -f "$ROOT_DIR/Dockerfile" "$ROOT_DIR"
echo "==> Run gateway live model tests (profile keys)"
docker run --rm -t \
@@ -51,5 +52,5 @@ docker run --rm -t \
-v "$CONFIG_DIR":/home/node/.openclaw \
-v "$WORKSPACE_DIR":/home/node/.openclaw/workspace \
"${PROFILE_MOUNT[@]}" \
- "$IMAGE_NAME" \
+ "$LIVE_IMAGE_NAME" \
-lc "$LIVE_TEST_CMD"
diff --git a/scripts/test-live-models-docker.sh b/scripts/test-live-models-docker.sh
index 5e3e1d0a311..cca4202710d 100755
--- a/scripts/test-live-models-docker.sh
+++ b/scripts/test-live-models-docker.sh
@@ -3,6 +3,7 @@ set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
IMAGE_NAME="${OPENCLAW_IMAGE:-${CLAWDBOT_IMAGE:-openclaw:local}}"
+LIVE_IMAGE_NAME="${OPENCLAW_LIVE_IMAGE:-${CLAWDBOT_LIVE_IMAGE:-${IMAGE_NAME}-live}}"
CONFIG_DIR="${OPENCLAW_CONFIG_DIR:-${CLAWDBOT_CONFIG_DIR:-$HOME/.openclaw}}"
WORKSPACE_DIR="${OPENCLAW_WORKSPACE_DIR:-${CLAWDBOT_WORKSPACE_DIR:-$HOME/.openclaw/workspace}}"
PROFILE_FILE="${OPENCLAW_PROFILE_FILE:-${CLAWDBOT_PROFILE_FILE:-$HOME/.profile}}"
@@ -33,8 +34,8 @@ cd "$tmp_dir"
pnpm test:live
EOF
-echo "==> Build image: $IMAGE_NAME"
-docker build -t "$IMAGE_NAME" -f "$ROOT_DIR/Dockerfile" "$ROOT_DIR"
+echo "==> Build live-test image: $LIVE_IMAGE_NAME (target=build)"
+docker build --target build -t "$LIVE_IMAGE_NAME" -f "$ROOT_DIR/Dockerfile" "$ROOT_DIR"
echo "==> Run live model tests (profile keys)"
docker run --rm -t \
@@ -52,5 +53,5 @@ docker run --rm -t \
-v "$CONFIG_DIR":/home/node/.openclaw \
-v "$WORKSPACE_DIR":/home/node/.openclaw/workspace \
"${PROFILE_MOUNT[@]}" \
- "$IMAGE_NAME" \
+ "$LIVE_IMAGE_NAME" \
-lc "$LIVE_TEST_CMD"
diff --git a/src/acp/runtime/session-meta.test.ts b/src/acp/runtime/session-meta.test.ts
new file mode 100644
index 00000000000..f9a0f399f81
--- /dev/null
+++ b/src/acp/runtime/session-meta.test.ts
@@ -0,0 +1,69 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import type { OpenClawConfig } from "../../config/config.js";
+
+const hoisted = vi.hoisted(() => {
+ const resolveAllAgentSessionStoreTargetsMock = vi.fn();
+ const loadSessionStoreMock = vi.fn();
+ return {
+ resolveAllAgentSessionStoreTargetsMock,
+ loadSessionStoreMock,
+ };
+});
+
+vi.mock("../../config/sessions.js", async () => {
+ const actual = await vi.importActual(
+ "../../config/sessions.js",
+ );
+ return {
+ ...actual,
+ resolveAllAgentSessionStoreTargets: (cfg: OpenClawConfig, opts: unknown) =>
+ hoisted.resolveAllAgentSessionStoreTargetsMock(cfg, opts),
+ loadSessionStore: (storePath: string) => hoisted.loadSessionStoreMock(storePath),
+ };
+});
+
+const { listAcpSessionEntries } = await import("./session-meta.js");
+
+describe("listAcpSessionEntries", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("reads ACP sessions from resolved configured store targets", async () => {
+ const cfg = {
+ session: {
+ store: "/custom/sessions/{agentId}.json",
+ },
+ } as OpenClawConfig;
+ hoisted.resolveAllAgentSessionStoreTargetsMock.mockResolvedValue([
+ {
+ agentId: "ops",
+ storePath: "/custom/sessions/ops.json",
+ },
+ ]);
+ hoisted.loadSessionStoreMock.mockReturnValue({
+ "agent:ops:acp:s1": {
+ updatedAt: 123,
+ acp: {
+ backend: "acpx",
+ agent: "ops",
+ mode: "persistent",
+ state: "idle",
+ },
+ },
+ });
+
+ const entries = await listAcpSessionEntries({ cfg });
+
+ expect(hoisted.resolveAllAgentSessionStoreTargetsMock).toHaveBeenCalledWith(cfg, undefined);
+ expect(hoisted.loadSessionStoreMock).toHaveBeenCalledWith("/custom/sessions/ops.json");
+ expect(entries).toEqual([
+ expect.objectContaining({
+ cfg,
+ storePath: "/custom/sessions/ops.json",
+ sessionKey: "agent:ops:acp:s1",
+ storeSessionKey: "agent:ops:acp:s1",
+ }),
+ ]);
+ });
+});
diff --git a/src/acp/runtime/session-meta.ts b/src/acp/runtime/session-meta.ts
index fd4a5813f9b..ff48d1e1ce6 100644
--- a/src/acp/runtime/session-meta.ts
+++ b/src/acp/runtime/session-meta.ts
@@ -1,9 +1,11 @@
-import path from "node:path";
-import { resolveAgentSessionDirs } from "../../agents/session-dirs.js";
import type { OpenClawConfig } from "../../config/config.js";
import { loadConfig } from "../../config/config.js";
-import { resolveStateDir } from "../../config/paths.js";
-import { loadSessionStore, resolveStorePath, updateSessionStore } from "../../config/sessions.js";
+import {
+ loadSessionStore,
+ resolveAllAgentSessionStoreTargets,
+ resolveStorePath,
+ updateSessionStore,
+} from "../../config/sessions.js";
import {
mergeSessionEntry,
type SessionAcpMeta,
@@ -88,14 +90,17 @@ export function readAcpSessionEntry(params: {
export async function listAcpSessionEntries(params: {
cfg?: OpenClawConfig;
+ env?: NodeJS.ProcessEnv;
}): Promise {
const cfg = params.cfg ?? loadConfig();
- const stateDir = resolveStateDir(process.env);
- const sessionDirs = await resolveAgentSessionDirs(stateDir);
+ const storeTargets = await resolveAllAgentSessionStoreTargets(
+ cfg,
+ params.env ? { env: params.env } : undefined,
+ );
const entries: AcpSessionStoreEntry[] = [];
- for (const sessionsDir of sessionDirs) {
- const storePath = path.join(sessionsDir, "sessions.json");
+ for (const target of storeTargets) {
+ const storePath = target.storePath;
let store: Record;
try {
store = loadSessionStore(storePath);
diff --git a/src/acp/translator.session-rate-limit.test.ts b/src/acp/translator.session-rate-limit.test.ts
index d0f774678a9..3e3f254d0ee 100644
--- a/src/acp/translator.session-rate-limit.test.ts
+++ b/src/acp/translator.session-rate-limit.test.ts
@@ -645,6 +645,77 @@ describe("acp setSessionConfigOption bridge behavior", () => {
sessionStore.clearAllSessionsForTest();
});
+ it("updates fast mode ACP config options through gateway session patches", async () => {
+ const sessionStore = createInMemorySessionStore();
+ const connection = createAcpConnection();
+ const sessionUpdate = connection.__sessionUpdateMock;
+ const request = vi.fn(async (method: string, params?: unknown) => {
+ if (method === "sessions.list") {
+ return {
+ ts: Date.now(),
+ path: "/tmp/sessions.json",
+ count: 1,
+ defaults: {
+ modelProvider: null,
+ model: null,
+ contextTokens: null,
+ },
+ sessions: [
+ {
+ key: "fast-session",
+ kind: "direct",
+ updatedAt: Date.now(),
+ thinkingLevel: "minimal",
+ modelProvider: "openai",
+ model: "gpt-5.4",
+ fastMode: true,
+ },
+ ],
+ };
+ }
+ if (method === "sessions.patch") {
+ expect(params).toEqual({
+ key: "fast-session",
+ fastMode: true,
+ });
+ }
+ return { ok: true };
+ }) as GatewayClient["request"];
+ const agent = new AcpGatewayAgent(connection, createAcpGateway(request), {
+ sessionStore,
+ });
+
+ await agent.loadSession(createLoadSessionRequest("fast-session"));
+ sessionUpdate.mockClear();
+
+ const result = await agent.setSessionConfigOption(
+ createSetSessionConfigOptionRequest("fast-session", "fast_mode", "on"),
+ );
+
+ expect(result.configOptions).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ id: "fast_mode",
+ currentValue: "on",
+ }),
+ ]),
+ );
+ expect(sessionUpdate).toHaveBeenCalledWith({
+ sessionId: "fast-session",
+ update: {
+ sessionUpdate: "config_option_update",
+ configOptions: expect.arrayContaining([
+ expect.objectContaining({
+ id: "fast_mode",
+ currentValue: "on",
+ }),
+ ]),
+ },
+ });
+
+ sessionStore.clearAllSessionsForTest();
+ });
+
it("rejects non-string ACP config option values", async () => {
const sessionStore = createInMemorySessionStore();
const connection = createAcpConnection();
@@ -949,3 +1020,144 @@ describe("acp prompt size hardening", () => {
});
});
});
+
+describe("acp final chat snapshots", () => {
+ async function createSnapshotHarness() {
+ const sessionStore = createInMemorySessionStore();
+ const connection = createAcpConnection();
+ const sessionUpdate = connection.__sessionUpdateMock;
+ const request = vi.fn(async (method: string) => {
+ if (method === "chat.send") {
+ return new Promise(() => {});
+ }
+ return { ok: true };
+ }) as GatewayClient["request"];
+ const agent = new AcpGatewayAgent(connection, createAcpGateway(request), {
+ sessionStore,
+ });
+ await agent.loadSession(createLoadSessionRequest("snapshot-session"));
+ sessionUpdate.mockClear();
+ const promptPromise = agent.prompt(createPromptRequest("snapshot-session", "hello"));
+ const runId = sessionStore.getSession("snapshot-session")?.activeRunId;
+ if (!runId) {
+ throw new Error("Expected ACP prompt run to be active");
+ }
+ return { agent, sessionUpdate, promptPromise, runId, sessionStore };
+ }
+
+ it("emits final snapshot text before resolving end_turn", async () => {
+ const { agent, sessionUpdate, promptPromise, runId, sessionStore } =
+ await createSnapshotHarness();
+
+ await agent.handleGatewayEvent({
+ event: "chat",
+ payload: {
+ sessionKey: "snapshot-session",
+ runId,
+ state: "final",
+ stopReason: "end_turn",
+ message: {
+ content: [{ type: "text", text: "FINAL TEXT SHOULD BE EMITTED" }],
+ },
+ },
+ } as unknown as EventFrame);
+
+ await expect(promptPromise).resolves.toEqual({ stopReason: "end_turn" });
+ expect(sessionUpdate).toHaveBeenCalledWith({
+ sessionId: "snapshot-session",
+ update: {
+ sessionUpdate: "agent_message_chunk",
+ content: { type: "text", text: "FINAL TEXT SHOULD BE EMITTED" },
+ },
+ });
+ expect(sessionStore.getSession("snapshot-session")?.activeRunId).toBeNull();
+ sessionStore.clearAllSessionsForTest();
+ });
+
+ it("does not duplicate text when final repeats the last delta snapshot", async () => {
+ const { agent, sessionUpdate, promptPromise, runId, sessionStore } =
+ await createSnapshotHarness();
+
+ await agent.handleGatewayEvent({
+ event: "chat",
+ payload: {
+ sessionKey: "snapshot-session",
+ runId,
+ state: "delta",
+ message: {
+ content: [{ type: "text", text: "Hello world" }],
+ },
+ },
+ } as unknown as EventFrame);
+
+ await agent.handleGatewayEvent({
+ event: "chat",
+ payload: {
+ sessionKey: "snapshot-session",
+ runId,
+ state: "final",
+ stopReason: "end_turn",
+ message: {
+ content: [{ type: "text", text: "Hello world" }],
+ },
+ },
+ } as unknown as EventFrame);
+
+ await expect(promptPromise).resolves.toEqual({ stopReason: "end_turn" });
+ const chunks = sessionUpdate.mock.calls.filter(
+ (call: unknown[]) =>
+ (call[0] as Record)?.update &&
+ (call[0] as Record>).update?.sessionUpdate ===
+ "agent_message_chunk",
+ );
+ expect(chunks).toHaveLength(1);
+ sessionStore.clearAllSessionsForTest();
+ });
+
+ it("emits only the missing tail when the final snapshot extends prior deltas", async () => {
+ const { agent, sessionUpdate, promptPromise, runId, sessionStore } =
+ await createSnapshotHarness();
+
+ await agent.handleGatewayEvent({
+ event: "chat",
+ payload: {
+ sessionKey: "snapshot-session",
+ runId,
+ state: "delta",
+ message: {
+ content: [{ type: "text", text: "Hello" }],
+ },
+ },
+ } as unknown as EventFrame);
+
+ await agent.handleGatewayEvent({
+ event: "chat",
+ payload: {
+ sessionKey: "snapshot-session",
+ runId,
+ state: "final",
+ stopReason: "max_tokens",
+ message: {
+ content: [{ type: "text", text: "Hello world" }],
+ },
+ },
+ } as unknown as EventFrame);
+
+ await expect(promptPromise).resolves.toEqual({ stopReason: "max_tokens" });
+ expect(sessionUpdate).toHaveBeenCalledWith({
+ sessionId: "snapshot-session",
+ update: {
+ sessionUpdate: "agent_message_chunk",
+ content: { type: "text", text: "Hello" },
+ },
+ });
+ expect(sessionUpdate).toHaveBeenCalledWith({
+ sessionId: "snapshot-session",
+ update: {
+ sessionUpdate: "agent_message_chunk",
+ content: { type: "text", text: " world" },
+ },
+ });
+ sessionStore.clearAllSessionsForTest();
+ });
+});
diff --git a/src/acp/translator.ts b/src/acp/translator.ts
index bb52db7b26b..8ab1f821fc8 100644
--- a/src/acp/translator.ts
+++ b/src/acp/translator.ts
@@ -53,6 +53,7 @@ import { ACP_AGENT_INFO, type AcpServerOptions } from "./types.js";
// Maximum allowed prompt size (2MB) to prevent DoS via memory exhaustion (CWE-400, GHSA-cxpw-2g23-2vgw)
const MAX_PROMPT_BYTES = 2 * 1024 * 1024;
const ACP_THOUGHT_LEVEL_CONFIG_ID = "thought_level";
+const ACP_FAST_MODE_CONFIG_ID = "fast_mode";
const ACP_VERBOSE_LEVEL_CONFIG_ID = "verbose_level";
const ACP_REASONING_LEVEL_CONFIG_ID = "reasoning_level";
const ACP_RESPONSE_USAGE_CONFIG_ID = "response_usage";
@@ -88,6 +89,7 @@ type GatewaySessionPresentationRow = Pick<
| "derivedTitle"
| "updatedAt"
| "thinkingLevel"
+ | "fastMode"
| "modelProvider"
| "model"
| "verboseLevel"
@@ -209,6 +211,13 @@ function buildSessionPresentation(params: {
currentValue: currentModeId,
values: availableLevelIds,
}),
+ buildSelectConfigOption({
+ id: ACP_FAST_MODE_CONFIG_ID,
+ name: "Fast mode",
+ description: "Controls whether OpenAI sessions use the Gateway fast-mode profile.",
+ currentValue: row.fastMode ? "on" : "off",
+ values: ["off", "on"],
+ }),
buildSelectConfigOption({
id: ACP_VERBOSE_LEVEL_CONFIG_ID,
name: "Tool verbosity",
@@ -791,9 +800,15 @@ export class AcpGatewayAgent implements Agent {
return;
}
- if (state === "delta" && messageData) {
+ const shouldHandleMessageSnapshot = messageData && (state === "delta" || state === "final");
+ if (shouldHandleMessageSnapshot) {
+ // Gateway chat events can carry the latest full assistant snapshot on both
+ // incremental updates and the terminal final event. Process the snapshot
+ // first so ACP clients never drop the last visible assistant text.
await this.handleDeltaEvent(pending.sessionId, messageData);
- return;
+ if (state === "delta") {
+ return;
+ }
}
if (state === "final") {
@@ -925,6 +940,7 @@ export class AcpGatewayAgent implements Agent {
thinkingLevel: session.thinkingLevel,
modelProvider: session.modelProvider,
model: session.model,
+ fastMode: session.fastMode,
verboseLevel: session.verboseLevel,
reasoningLevel: session.reasoningLevel,
responseUsage: session.responseUsage,
@@ -940,7 +956,7 @@ export class AcpGatewayAgent implements Agent {
value: string | boolean,
): {
overrides: Partial;
- patch: Record;
+ patch: Record;
} {
if (typeof value !== "string") {
throw new Error(
@@ -953,6 +969,11 @@ export class AcpGatewayAgent implements Agent {
patch: { thinkingLevel: value },
overrides: { thinkingLevel: value },
};
+ case ACP_FAST_MODE_CONFIG_ID:
+ return {
+ patch: { fastMode: value === "on" },
+ overrides: { fastMode: value === "on" },
+ };
case ACP_VERBOSE_LEVEL_CONFIG_ID:
return {
patch: { verboseLevel: value },
diff --git a/src/agents/auth-profiles.runtime.ts b/src/agents/auth-profiles.runtime.ts
new file mode 100644
index 00000000000..5c25bb97c84
--- /dev/null
+++ b/src/agents/auth-profiles.runtime.ts
@@ -0,0 +1 @@
+export { ensureAuthProfileStore } from "./auth-profiles.js";
diff --git a/src/agents/auth-profiles/oauth.ts b/src/agents/auth-profiles/oauth.ts
index 072b3a77246..edc1ddfb24e 100644
--- a/src/agents/auth-profiles/oauth.ts
+++ b/src/agents/auth-profiles/oauth.ts
@@ -1,5 +1,9 @@
-import type { OAuthCredentials, OAuthProvider } from "@mariozechner/pi-ai";
-import { getOAuthApiKey, getOAuthProviders } from "@mariozechner/pi-ai/oauth";
+import {
+ getOAuthApiKey,
+ getOAuthProviders,
+ type OAuthCredentials,
+ type OAuthProvider,
+} from "@mariozechner/pi-ai/oauth";
import { loadConfig, type OpenClawConfig } from "../../config/config.js";
import { coerceSecretRef } from "../../config/types.secrets.js";
import { withFileLock } from "../../infra/file-lock.js";
diff --git a/src/agents/context.lookup.test.ts b/src/agents/context.lookup.test.ts
index 584f9c27cbb..428d47759bc 100644
--- a/src/agents/context.lookup.test.ts
+++ b/src/agents/context.lookup.test.ts
@@ -18,6 +18,26 @@ function mockContextModuleDeps(loadConfigImpl: () => unknown) {
}));
}
+// Shared mock setup used by multiple tests.
+function mockDiscoveryDeps(
+ models: Array<{ id: string; contextWindow: number }>,
+ configModels?: Record }>,
+) {
+ vi.doMock("../config/config.js", () => ({
+ loadConfig: () => ({ models: configModels ? { providers: configModels } : {} }),
+ }));
+ vi.doMock("./models-config.js", () => ({
+ ensureOpenClawModelsJson: vi.fn(async () => {}),
+ }));
+ vi.doMock("./agent-paths.js", () => ({
+ resolveOpenClawAgentDir: () => "/tmp/openclaw-agent",
+ }));
+ vi.doMock("./pi-model-discovery.js", () => ({
+ discoverAuthStorage: vi.fn(() => ({})),
+ discoverModels: vi.fn(() => ({ getAll: () => models })),
+ }));
+}
+
describe("lookupContextTokens", () => {
beforeEach(() => {
vi.resetModules();
@@ -87,4 +107,220 @@ describe("lookupContextTokens", () => {
vi.useRealTimers();
}
});
+
+ it("returns the smaller window when the same bare model id is discovered under multiple providers", async () => {
+ mockDiscoveryDeps([
+ { id: "gemini-3.1-pro-preview", contextWindow: 1_048_576 },
+ { id: "gemini-3.1-pro-preview", contextWindow: 128_000 },
+ ]);
+
+ const { lookupContextTokens } = await import("./context.js");
+ // Trigger async cache population.
+ await new Promise((r) => setTimeout(r, 0));
+ // Conservative minimum: bare-id cache feeds runtime flush/compaction paths.
+ expect(lookupContextTokens("gemini-3.1-pro-preview")).toBe(128_000);
+ });
+
+ it("resolveContextTokensForModel returns discovery value when provider-qualified entry exists in cache", async () => {
+ // Registry returns provider-qualified entries (real-world scenario from #35976).
+ // When no explicit config override exists, the bare cache lookup hits the
+ // provider-qualified raw discovery entry.
+ mockDiscoveryDeps([
+ { id: "github-copilot/gemini-3.1-pro-preview", contextWindow: 128_000 },
+ { id: "google-gemini-cli/gemini-3.1-pro-preview", contextWindow: 1_048_576 },
+ ]);
+
+ const { resolveContextTokensForModel } = await import("./context.js");
+ await new Promise((r) => setTimeout(r, 0));
+
+ // With provider specified and no config override, bare lookup finds the
+ // provider-qualified discovery entry.
+ const result = resolveContextTokensForModel({
+ provider: "google-gemini-cli",
+ model: "gemini-3.1-pro-preview",
+ });
+ expect(result).toBe(1_048_576);
+ });
+
+ it("resolveContextTokensForModel returns configured override via direct config scan (beats discovery)", async () => {
+ // Config has an explicit contextWindow; resolveContextTokensForModel should
+ // return it via direct config scan, preventing collisions with raw discovery
+ // entries. Real callers (status.summary.ts etc.) always pass cfg.
+ mockDiscoveryDeps([
+ { id: "google-gemini-cli/gemini-3.1-pro-preview", contextWindow: 1_048_576 },
+ ]);
+
+ const cfg = {
+ models: {
+ providers: {
+ "google-gemini-cli": {
+ models: [{ id: "gemini-3.1-pro-preview", contextWindow: 200_000 }],
+ },
+ },
+ },
+ };
+
+ const { resolveContextTokensForModel } = await import("./context.js");
+ await new Promise((r) => setTimeout(r, 0));
+
+ const result = resolveContextTokensForModel({
+ cfg: cfg as never,
+ provider: "google-gemini-cli",
+ model: "gemini-3.1-pro-preview",
+ });
+ expect(result).toBe(200_000);
+ });
+
+ it("resolveContextTokensForModel honors configured overrides when provider keys use mixed case", async () => {
+ mockDiscoveryDeps([{ id: "openrouter/anthropic/claude-sonnet-4-5", contextWindow: 1_048_576 }]);
+
+ const cfg = {
+ models: {
+ providers: {
+ " OpenRouter ": {
+ models: [{ id: "anthropic/claude-sonnet-4-5", contextWindow: 200_000 }],
+ },
+ },
+ },
+ };
+
+ const { resolveContextTokensForModel } = await import("./context.js");
+ await new Promise((r) => setTimeout(r, 0));
+
+ const result = resolveContextTokensForModel({
+ cfg: cfg as never,
+ provider: "openrouter",
+ model: "anthropic/claude-sonnet-4-5",
+ });
+ expect(result).toBe(200_000);
+ });
+
+ it("resolveContextTokensForModel: config direct scan prevents OpenRouter qualified key collision for Google provider", async () => {
+ // When provider is explicitly "google" and cfg has a Google contextWindow
+ // override, the config direct scan returns it before any cache lookup —
+ // so the OpenRouter raw "google/gemini-2.5-pro" qualified entry is never hit.
+ // Real callers (status.summary.ts) always pass cfg when provider is explicit.
+ mockDiscoveryDeps([{ id: "google/gemini-2.5-pro", contextWindow: 999_000 }]);
+
+ const cfg = {
+ models: {
+ providers: {
+ google: { models: [{ id: "gemini-2.5-pro", contextWindow: 2_000_000 }] },
+ },
+ },
+ };
+
+ const { resolveContextTokensForModel } = await import("./context.js");
+ await new Promise((r) => setTimeout(r, 0));
+
+ // Google with explicit cfg: config direct scan wins before any cache lookup.
+ const googleResult = resolveContextTokensForModel({
+ cfg: cfg as never,
+ provider: "google",
+ model: "gemini-2.5-pro",
+ });
+ expect(googleResult).toBe(2_000_000);
+
+ // OpenRouter provider with slash model id: bare lookup finds the raw entry.
+ const openrouterResult = resolveContextTokensForModel({
+ provider: "openrouter",
+ model: "google/gemini-2.5-pro",
+ });
+ expect(openrouterResult).toBe(999_000);
+ });
+
+ it("resolveContextTokensForModel prefers exact provider key over alias-normalized match", async () => {
+ // When both "qwen" and "qwen-portal" exist as config keys (alias pattern),
+ // resolveConfiguredProviderContextWindow must return the exact-key match first,
+ // not the first normalized hit — mirroring pi-embedded-runner/model.ts behaviour.
+ mockDiscoveryDeps([]);
+
+ const cfg = {
+ models: {
+ providers: {
+ "qwen-portal": { models: [{ id: "qwen-max", contextWindow: 32_000 }] },
+ qwen: { models: [{ id: "qwen-max", contextWindow: 128_000 }] },
+ },
+ },
+ };
+
+ const { resolveContextTokensForModel } = await import("./context.js");
+ await new Promise((r) => setTimeout(r, 0));
+
+ // Exact key "qwen" wins over the alias-normalized match "qwen-portal".
+ const qwenResult = resolveContextTokensForModel({
+ cfg: cfg as never,
+ provider: "qwen",
+ model: "qwen-max",
+ });
+ expect(qwenResult).toBe(128_000);
+
+ // Exact key "qwen-portal" wins (no alias lookup needed).
+ const portalResult = resolveContextTokensForModel({
+ cfg: cfg as never,
+ provider: "qwen-portal",
+ model: "qwen-max",
+ });
+ expect(portalResult).toBe(32_000);
+ });
+
+ it("resolveContextTokensForModel(model-only) does not apply config scan for inferred provider", async () => {
+ // status.ts log-usage fallback calls resolveContextTokensForModel({ model })
+ // with no provider. When model = "google/gemini-2.5-pro" (OpenRouter ID),
+ // resolveProviderModelRef infers provider="google". Without the guard,
+ // resolveConfiguredProviderContextWindow would return Google's configured
+ // window and misreport context limits for the OpenRouter session.
+ mockDiscoveryDeps([{ id: "google/gemini-2.5-pro", contextWindow: 999_000 }]);
+
+ const cfg = {
+ models: {
+ providers: {
+ google: { models: [{ id: "gemini-2.5-pro", contextWindow: 2_000_000 }] },
+ },
+ },
+ };
+
+ const { resolveContextTokensForModel } = await import("./context.js");
+ await new Promise((r) => setTimeout(r, 0));
+
+ // model-only call (no explicit provider) must NOT apply config direct scan.
+ // Falls through to bare cache lookup: "google/gemini-2.5-pro" → 999k ✓.
+ const modelOnlyResult = resolveContextTokensForModel({
+ cfg: cfg as never,
+ model: "google/gemini-2.5-pro",
+ // no provider
+ });
+ expect(modelOnlyResult).toBe(999_000);
+
+ // Explicit provider still uses config scan ✓.
+ const explicitResult = resolveContextTokensForModel({
+ cfg: cfg as never,
+ provider: "google",
+ model: "gemini-2.5-pro",
+ });
+ expect(explicitResult).toBe(2_000_000);
+ });
+
+ it("resolveContextTokensForModel: qualified key beats bare min when provider is explicit (original #35976 fix)", async () => {
+ // Regression: when both "gemini-3.1-pro-preview" (bare, min=128k) AND
+ // "google-gemini-cli/gemini-3.1-pro-preview" (qualified, 1M) are in cache,
+ // an explicit-provider call must return the provider-specific qualified value,
+ // not the collided bare minimum.
+ mockDiscoveryDeps([
+ { id: "github-copilot/gemini-3.1-pro-preview", contextWindow: 128_000 },
+ { id: "gemini-3.1-pro-preview", contextWindow: 128_000 },
+ { id: "google-gemini-cli/gemini-3.1-pro-preview", contextWindow: 1_048_576 },
+ ]);
+
+ const { resolveContextTokensForModel } = await import("./context.js");
+ await new Promise((r) => setTimeout(r, 0));
+
+ // Qualified "google-gemini-cli/gemini-3.1-pro-preview" → 1M wins over
+ // bare "gemini-3.1-pro-preview" → 128k (cross-provider minimum).
+ const result = resolveContextTokensForModel({
+ provider: "google-gemini-cli",
+ model: "gemini-3.1-pro-preview",
+ });
+ expect(result).toBe(1_048_576);
+ });
});
diff --git a/src/agents/context.test.ts b/src/agents/context.test.ts
index 267755a8849..98eb99d7295 100644
--- a/src/agents/context.test.ts
+++ b/src/agents/context.test.ts
@@ -8,23 +8,44 @@ import {
import { createSessionManagerRuntimeRegistry } from "./pi-extensions/session-manager-runtime-registry.js";
describe("applyDiscoveredContextWindows", () => {
- it("keeps the smallest context window when duplicate model ids are discovered", () => {
+ it("keeps the smallest context window when the same bare model id appears under multiple providers", () => {
const cache = new Map();
applyDiscoveredContextWindows({
cache,
models: [
- { id: "claude-sonnet-4-5", contextWindow: 1_000_000 },
- { id: "claude-sonnet-4-5", contextWindow: 200_000 },
+ { id: "gemini-3.1-pro-preview", contextWindow: 128_000 },
+ { id: "gemini-3.1-pro-preview", contextWindow: 1_048_576 },
],
});
- expect(cache.get("claude-sonnet-4-5")).toBe(200_000);
+ // Keep the conservative (minimum) value: this cache feeds runtime paths such
+ // as flush thresholds and session persistence, not just /status display.
+ // Callers with a known provider should use resolveContextTokensForModel which
+ // tries the provider-qualified key first.
+ expect(cache.get("gemini-3.1-pro-preview")).toBe(128_000);
+ });
+
+ it("stores provider-qualified entries independently", () => {
+ const cache = new Map();
+ applyDiscoveredContextWindows({
+ cache,
+ models: [
+ { id: "github-copilot/gemini-3.1-pro-preview", contextWindow: 128_000 },
+ { id: "google-gemini-cli/gemini-3.1-pro-preview", contextWindow: 1_048_576 },
+ ],
+ });
+
+ expect(cache.get("github-copilot/gemini-3.1-pro-preview")).toBe(128_000);
+ expect(cache.get("google-gemini-cli/gemini-3.1-pro-preview")).toBe(1_048_576);
});
});
describe("applyConfiguredContextWindows", () => {
- it("overrides discovered cache values with explicit models.providers contextWindow", () => {
- const cache = new Map([["anthropic/claude-opus-4-6", 1_000_000]]);
+ it("writes bare model id to cache; does not touch raw provider-qualified discovery entries", () => {
+ // Discovery stored a provider-qualified entry; config override goes into the
+ // bare key only. resolveContextTokensForModel now scans config directly, so
+ // there is no need (and no benefit) to also write a synthetic qualified key.
+ const cache = new Map([["openrouter/anthropic/claude-opus-4-6", 1_000_000]]);
applyConfiguredContextWindows({
cache,
modelsConfig: {
@@ -37,6 +58,33 @@ describe("applyConfiguredContextWindows", () => {
});
expect(cache.get("anthropic/claude-opus-4-6")).toBe(200_000);
+ // Discovery entry is untouched — no synthetic write that could corrupt
+ // an unrelated provider's raw slash-containing model ID.
+ expect(cache.get("openrouter/anthropic/claude-opus-4-6")).toBe(1_000_000);
+ });
+
+ it("does not write synthetic provider-qualified keys; only bare model ids go into cache", () => {
+ // applyConfiguredContextWindows must NOT write "google-gemini-cli/gemini-3.1-pro-preview"
+ // into the cache — that keyspace is reserved for raw discovery model IDs and
+ // a synthetic write would overwrite unrelated entries (e.g. OpenRouter's
+ // "google/gemini-2.5-pro" being clobbered by a Google provider config).
+ const cache = new Map();
+ cache.set("google-gemini-cli/gemini-3.1-pro-preview", 1_048_576); // discovery entry
+ applyConfiguredContextWindows({
+ cache,
+ modelsConfig: {
+ providers: {
+ "google-gemini-cli": {
+ models: [{ id: "gemini-3.1-pro-preview", contextWindow: 200_000 }],
+ },
+ },
+ },
+ });
+
+ // Bare key is written.
+ expect(cache.get("gemini-3.1-pro-preview")).toBe(200_000);
+ // Discovery entry is NOT overwritten.
+ expect(cache.get("google-gemini-cli/gemini-3.1-pro-preview")).toBe(1_048_576);
});
it("adds config-only model context windows and ignores invalid entries", () => {
diff --git a/src/agents/context.ts b/src/agents/context.ts
index bd3aeaf6fc2..c18d9534689 100644
--- a/src/agents/context.ts
+++ b/src/agents/context.ts
@@ -6,6 +6,7 @@ import type { OpenClawConfig } from "../config/config.js";
import { computeBackoff, type BackoffPolicy } from "../infra/backoff.js";
import { consumeRootOptionToken, FLAG_TERMINATOR } from "../infra/cli-root-options.js";
import { resolveOpenClawAgentDir } from "./agent-paths.js";
+import { normalizeProviderId } from "./model-selection.js";
import { ensureOpenClawModelsJson } from "./models-config.js";
type ModelEntry = { id: string; contextWindow?: number };
@@ -41,8 +42,12 @@ export function applyDiscoveredContextWindows(params: {
continue;
}
const existing = params.cache.get(model.id);
- // When multiple providers expose the same model id with different limits,
- // prefer the smaller window so token budgeting is fail-safe (no overestimation).
+ // When the same bare model id appears under multiple providers with different
+ // limits, keep the smaller window. This cache feeds both display paths and
+ // runtime paths (flush thresholds, session context-token persistence), so
+ // overestimating the limit could delay compaction and cause context overflow.
+ // Callers that know the active provider should use resolveContextTokensForModel,
+ // which tries the provider-qualified key first and falls back here.
if (existing === undefined || contextWindow < existing) {
params.cache.set(model.id, contextWindow);
}
@@ -152,7 +157,8 @@ function ensureContextWindowCacheLoaded(): Promise {
}
try {
- const { discoverAuthStorage, discoverModels } = await import("./pi-model-discovery.js");
+ const { discoverAuthStorage, discoverModels } =
+ await import("./pi-model-discovery-runtime.js");
const agentDir = resolveOpenClawAgentDir();
const authStorage = discoverAuthStorage(agentDir);
const modelRegistry = discoverModels(authStorage, agentDir) as unknown as ModelRegistryLike;
@@ -222,13 +228,15 @@ function resolveProviderModelRef(params: {
}
const providerRaw = params.provider?.trim();
if (providerRaw) {
+ // Keep the exact (lowercased) provider key; callers that need the canonical
+ // alias (e.g. cache key construction) apply normalizeProviderId explicitly.
return { provider: providerRaw.toLowerCase(), model: modelRaw };
}
const slash = modelRaw.indexOf("/");
if (slash <= 0) {
return undefined;
}
- const provider = modelRaw.slice(0, slash).trim().toLowerCase();
+ const provider = normalizeProviderId(modelRaw.slice(0, slash));
const model = modelRaw.slice(slash + 1).trim();
if (!provider || !model) {
return undefined;
@@ -236,6 +244,58 @@ function resolveProviderModelRef(params: {
return { provider, model };
}
+// Look up an explicit contextWindow override for a specific provider+model
+// directly from config, without going through the shared discovery cache.
+// This avoids the cache keyspace collision where "provider/model" synthetic
+// keys overlap with raw slash-containing model IDs (e.g. OpenRouter's
+// "google/gemini-2.5-pro" stored as a raw catalog entry).
+function resolveConfiguredProviderContextWindow(
+ cfg: OpenClawConfig | undefined,
+ provider: string,
+ model: string,
+): number | undefined {
+ const providers = (cfg?.models as ModelsConfig | undefined)?.providers;
+ if (!providers) {
+ return undefined;
+ }
+
+ // Mirror the lookup order in pi-embedded-runner/model.ts: exact key first,
+ // then normalized fallback. This prevents alias collisions (e.g. when both
+ // "qwen" and "qwen-portal" exist as config keys) from picking the wrong
+ // contextWindow based on Object.entries iteration order.
+ function findContextWindow(matchProviderId: (id: string) => boolean): number | undefined {
+ for (const [providerId, providerConfig] of Object.entries(providers!)) {
+ if (!matchProviderId(providerId)) {
+ continue;
+ }
+ if (!Array.isArray(providerConfig?.models)) {
+ continue;
+ }
+ for (const m of providerConfig.models) {
+ if (
+ typeof m?.id === "string" &&
+ m.id === model &&
+ typeof m?.contextWindow === "number" &&
+ m.contextWindow > 0
+ ) {
+ return m.contextWindow;
+ }
+ }
+ }
+ return undefined;
+ }
+
+ // 1. Exact match (case-insensitive, no alias expansion).
+ const exactResult = findContextWindow((id) => id.trim().toLowerCase() === provider.toLowerCase());
+ if (exactResult !== undefined) {
+ return exactResult;
+ }
+
+ // 2. Normalized fallback: covers alias keys such as "qwen" → "qwen-portal".
+ const normalizedProvider = normalizeProviderId(provider);
+ return findContextWindow((id) => normalizeProviderId(id) === normalizedProvider);
+}
+
function isAnthropic1MModel(provider: string, model: string): boolean {
if (provider !== "anthropic") {
return false;
@@ -267,7 +327,64 @@ export function resolveContextTokensForModel(params: {
if (modelParams?.context1m === true && isAnthropic1MModel(ref.provider, ref.model)) {
return ANTHROPIC_CONTEXT_1M_TOKENS;
}
+ // Only do the config direct scan when the caller explicitly passed a
+ // provider. When provider is inferred from a slash in the model string
+ // (e.g. "google/gemini-2.5-pro" → ref.provider = "google"), the model ID
+ // may belong to a DIFFERENT provider (e.g. an OpenRouter session). Scanning
+ // cfg.models.providers.google in that case would return Google's configured
+ // window and misreport context limits for the OpenRouter session.
+ // See status.ts log-usage fallback which calls with only { model } set.
+ if (params.provider) {
+ const configuredWindow = resolveConfiguredProviderContextWindow(
+ params.cfg,
+ ref.provider,
+ ref.model,
+ );
+ if (configuredWindow !== undefined) {
+ return configuredWindow;
+ }
+ }
}
- return lookupContextTokens(params.model) ?? params.fallbackContextTokens;
+ // When provider is explicitly given and the model ID is bare (no slash),
+ // try the provider-qualified cache key BEFORE the bare key. Discovery
+ // entries are stored under qualified IDs (e.g. "google-gemini-cli/
+ // gemini-3.1-pro-preview → 1M"), while the bare key may hold a cross-
+ // provider minimum (128k). Returning the qualified entry gives the correct
+ // provider-specific window for /status and session context-token persistence.
+ //
+ // Guard: only when params.provider is explicit (not inferred from a slash in
+ // the model string). For model-only callers (e.g. status.ts log-usage
+ // fallback with model="google/gemini-2.5-pro"), the inferred provider would
+ // construct "google/gemini-2.5-pro" as the qualified key which accidentally
+ // matches OpenRouter's raw discovery entry — the bare lookup is correct there.
+ if (params.provider && ref && !ref.model.includes("/")) {
+ const qualifiedResult = lookupContextTokens(
+ `${normalizeProviderId(ref.provider)}/${ref.model}`,
+ );
+ if (qualifiedResult !== undefined) {
+ return qualifiedResult;
+ }
+ }
+
+ // Bare key fallback. For model-only calls with slash-containing IDs
+ // (e.g. "google/gemini-2.5-pro") this IS the raw discovery cache key.
+ const bareResult = lookupContextTokens(params.model);
+ if (bareResult !== undefined) {
+ return bareResult;
+ }
+
+ // When provider is implicit, try qualified as a last resort so inferred
+ // provider/model pairs (e.g. model="google-gemini-cli/gemini-3.1-pro")
+ // still find discovery entries stored under that qualified ID.
+ if (!params.provider && ref && !ref.model.includes("/")) {
+ const qualifiedResult = lookupContextTokens(
+ `${normalizeProviderId(ref.provider)}/${ref.model}`,
+ );
+ if (qualifiedResult !== undefined) {
+ return qualifiedResult;
+ }
+ }
+
+ return params.fallbackContextTokens;
}
diff --git a/src/agents/failover-error.test.ts b/src/agents/failover-error.test.ts
index db01c03d8c4..1ddd1d9ceef 100644
--- a/src/agents/failover-error.test.ts
+++ b/src/agents/failover-error.test.ts
@@ -69,6 +69,7 @@ describe("failover-error", () => {
expect(resolveFailoverReasonFromError({ status: 408 })).toBe("timeout");
expect(resolveFailoverReasonFromError({ status: 499 })).toBe("timeout");
expect(resolveFailoverReasonFromError({ status: 400 })).toBe("format");
+ expect(resolveFailoverReasonFromError({ status: 422 })).toBe("format");
// Keep the status-only path behavior-preserving and conservative.
expect(resolveFailoverReasonFromError({ status: 500 })).toBeNull();
expect(resolveFailoverReasonFromError({ status: 502 })).toBe("timeout");
@@ -162,6 +163,44 @@ describe("failover-error", () => {
).toBe("billing");
});
+ it("treats HTTP 422 as format error", () => {
+ expect(
+ resolveFailoverReasonFromError({
+ status: 422,
+ message: "check open ai req parameter error",
+ }),
+ ).toBe("format");
+ expect(
+ resolveFailoverReasonFromError({
+ status: 422,
+ message: "Unprocessable Entity",
+ }),
+ ).toBe("format");
+ });
+
+ it("treats 422 with billing message as billing instead of format", () => {
+ expect(
+ resolveFailoverReasonFromError({
+ status: 422,
+ message: "insufficient credits",
+ }),
+ ).toBe("billing");
+ });
+
+ it("classifies OpenRouter 'requires more credits' text as billing", () => {
+ expect(
+ resolveFailoverReasonFromError({
+ message: "This model requires more credits to use",
+ }),
+ ).toBe("billing");
+ expect(
+ resolveFailoverReasonFromError({
+ status: 402,
+ message: "This model require more credits",
+ }),
+ ).toBe("billing");
+ });
+
it("treats zhipuai weekly/monthly limit exhausted as rate_limit", () => {
expect(
resolveFailoverReasonFromError({
@@ -204,6 +243,13 @@ describe("failover-error", () => {
message: "Workspace spend limit reached. Contact your admin.",
}),
).toBe("rate_limit");
+ expect(
+ resolveFailoverReasonFromError({
+ status: 402,
+ message:
+ "You have reached your subscription quota limit. Please wait for automatic quota refresh in the rolling time window, upgrade to a higher plan, or use a Pay-As-You-Go API Key for unlimited access. Learn more: https://zenmux.ai/docs/guide/subscription.html",
+ }),
+ ).toBe("rate_limit");
expect(
resolveFailoverReasonFromError({
status: 402,
@@ -274,6 +320,8 @@ describe("failover-error", () => {
it("infers timeout from common node error codes", () => {
expect(resolveFailoverReasonFromError({ code: "ETIMEDOUT" })).toBe("timeout");
expect(resolveFailoverReasonFromError({ code: "ECONNRESET" })).toBe("timeout");
+ expect(resolveFailoverReasonFromError({ code: "EHOSTDOWN" })).toBe("timeout");
+ expect(resolveFailoverReasonFromError({ code: "EPIPE" })).toBe("timeout");
});
it("infers timeout from abort/error stop-reason messages", () => {
@@ -287,6 +335,9 @@ describe("failover-error", () => {
expect(resolveFailoverReasonFromError({ message: "stop reason: error" })).toBe("timeout");
expect(resolveFailoverReasonFromError({ message: "reason: abort" })).toBe("timeout");
expect(resolveFailoverReasonFromError({ message: "reason: error" })).toBe("timeout");
+ expect(
+ resolveFailoverReasonFromError({ message: "Unhandled stop reason: network_error" }),
+ ).toBe("timeout");
});
it("infers timeout from connection/network error messages", () => {
diff --git a/src/agents/failover-error.ts b/src/agents/failover-error.ts
index a39685e1b16..8c49df40acb 100644
--- a/src/agents/failover-error.ts
+++ b/src/agents/failover-error.ts
@@ -170,7 +170,9 @@ export function resolveFailoverReasonFromError(err: unknown): FailoverReason | n
"ECONNREFUSED",
"ENETUNREACH",
"EHOSTUNREACH",
+ "EHOSTDOWN",
"ENETRESET",
+ "EPIPE",
"EAI_AGAIN",
].includes(code)
) {
diff --git a/src/agents/fast-mode.ts b/src/agents/fast-mode.ts
new file mode 100644
index 00000000000..3935eeae27b
--- /dev/null
+++ b/src/agents/fast-mode.ts
@@ -0,0 +1,58 @@
+import { normalizeFastMode } from "../auto-reply/thinking.js";
+import type { OpenClawConfig } from "../config/config.js";
+import type { SessionEntry } from "../config/sessions.js";
+
+export type FastModeState = {
+ enabled: boolean;
+ source: "session" | "config" | "default";
+};
+
+export function resolveFastModeParam(
+ extraParams: Record | undefined,
+): boolean | undefined {
+ return normalizeFastMode(
+ (extraParams?.fastMode ?? extraParams?.fast_mode) as string | boolean | null | undefined,
+ );
+}
+
+function resolveConfiguredFastModeRaw(params: {
+ cfg: OpenClawConfig | undefined;
+ provider: string;
+ model: string;
+}): unknown {
+ const modelKey = `${params.provider}/${params.model}`;
+ const modelConfig = params.cfg?.agents?.defaults?.models?.[modelKey];
+ return modelConfig?.params?.fastMode ?? modelConfig?.params?.fast_mode;
+}
+
+export function resolveConfiguredFastMode(params: {
+ cfg: OpenClawConfig | undefined;
+ provider: string;
+ model: string;
+}): boolean {
+ return (
+ normalizeFastMode(
+ resolveConfiguredFastModeRaw(params) as string | boolean | null | undefined,
+ ) ?? false
+ );
+}
+
+export function resolveFastModeState(params: {
+ cfg: OpenClawConfig | undefined;
+ provider: string;
+ model: string;
+ sessionEntry?: Pick | undefined;
+}): FastModeState {
+ const sessionOverride = normalizeFastMode(params.sessionEntry?.fastMode);
+ if (sessionOverride !== undefined) {
+ return { enabled: sessionOverride, source: "session" };
+ }
+
+ const configuredRaw = resolveConfiguredFastModeRaw(params);
+ const configured = normalizeFastMode(configuredRaw as string | boolean | null | undefined);
+ if (configured !== undefined) {
+ return { enabled: configured, source: "config" };
+ }
+
+ return { enabled: false, source: "default" };
+}
diff --git a/src/agents/memory-search.test.ts b/src/agents/memory-search.test.ts
index 1d04b730351..8b1b4bc3494 100644
--- a/src/agents/memory-search.test.ts
+++ b/src/agents/memory-search.test.ts
@@ -284,6 +284,7 @@ describe("memory search config", () => {
expect(resolved?.sync.sessions).toEqual({
deltaBytes: 100000,
deltaMessages: 50,
+ postCompactionForce: true,
});
});
diff --git a/src/agents/memory-search.ts b/src/agents/memory-search.ts
index d00dae70639..1cbc83b7781 100644
--- a/src/agents/memory-search.ts
+++ b/src/agents/memory-search.ts
@@ -61,6 +61,7 @@ export type ResolvedMemorySearchConfig = {
sessions: {
deltaBytes: number;
deltaMessages: number;
+ postCompactionForce: boolean;
};
};
query: {
@@ -248,6 +249,10 @@ function mergeConfig(
overrides?.sync?.sessions?.deltaMessages ??
defaults?.sync?.sessions?.deltaMessages ??
DEFAULT_SESSION_DELTA_MESSAGES,
+ postCompactionForce:
+ overrides?.sync?.sessions?.postCompactionForce ??
+ defaults?.sync?.sessions?.postCompactionForce ??
+ true,
},
};
const query = {
@@ -315,6 +320,7 @@ function mergeConfig(
);
const deltaBytes = clampInt(sync.sessions.deltaBytes, 0, Number.MAX_SAFE_INTEGER);
const deltaMessages = clampInt(sync.sessions.deltaMessages, 0, Number.MAX_SAFE_INTEGER);
+ const postCompactionForce = sync.sessions.postCompactionForce;
return {
enabled,
sources,
@@ -336,6 +342,7 @@ function mergeConfig(
sessions: {
deltaBytes,
deltaMessages,
+ postCompactionForce,
},
},
query: {
diff --git a/src/agents/model-auth-env-vars.ts b/src/agents/model-auth-env-vars.ts
index fbe5a78917d..c9cb9159138 100644
--- a/src/agents/model-auth-env-vars.ts
+++ b/src/agents/model-auth-env-vars.ts
@@ -35,6 +35,7 @@ export const PROVIDER_ENV_API_KEY_CANDIDATES: Record = {
qianfan: ["QIANFAN_API_KEY"],
modelstudio: ["MODELSTUDIO_API_KEY"],
ollama: ["OLLAMA_API_KEY"],
+ sglang: ["SGLANG_API_KEY"],
vllm: ["VLLM_API_KEY"],
kilocode: ["KILOCODE_API_KEY"],
};
diff --git a/src/agents/model-catalog.test.ts b/src/agents/model-catalog.test.ts
index b891af4ed2d..cf7d6e444f2 100644
--- a/src/agents/model-catalog.test.ts
+++ b/src/agents/model-catalog.test.ts
@@ -114,6 +114,55 @@ describe("loadModelCatalog", () => {
expect(spark?.reasoning).toBe(true);
});
+ it("filters stale openai gpt-5.3-codex-spark built-ins from the catalog", async () => {
+ mockPiDiscoveryModels([
+ {
+ id: "gpt-5.3-codex-spark",
+ provider: "openai",
+ name: "GPT-5.3 Codex Spark",
+ reasoning: true,
+ contextWindow: 128000,
+ input: ["text", "image"],
+ },
+ {
+ id: "gpt-5.3-codex-spark",
+ provider: "azure-openai-responses",
+ name: "GPT-5.3 Codex Spark",
+ reasoning: true,
+ contextWindow: 128000,
+ input: ["text", "image"],
+ },
+ {
+ id: "gpt-5.3-codex-spark",
+ provider: "openai-codex",
+ name: "GPT-5.3 Codex Spark",
+ reasoning: true,
+ contextWindow: 128000,
+ input: ["text"],
+ },
+ ]);
+
+ const result = await loadModelCatalog({ config: {} as OpenClawConfig });
+ expect(result).not.toContainEqual(
+ expect.objectContaining({
+ provider: "openai",
+ id: "gpt-5.3-codex-spark",
+ }),
+ );
+ expect(result).not.toContainEqual(
+ expect.objectContaining({
+ provider: "azure-openai-responses",
+ id: "gpt-5.3-codex-spark",
+ }),
+ );
+ expect(result).toContainEqual(
+ expect.objectContaining({
+ provider: "openai-codex",
+ id: "gpt-5.3-codex-spark",
+ }),
+ );
+ });
+
it("adds gpt-5.4 forward-compat catalog entries when template models exist", async () => {
mockPiDiscoveryModels([
{
diff --git a/src/agents/model-catalog.ts b/src/agents/model-catalog.ts
index 06423b0604b..6f66e85c49c 100644
--- a/src/agents/model-catalog.ts
+++ b/src/agents/model-catalog.ts
@@ -1,6 +1,7 @@
import { type OpenClawConfig, loadConfig } from "../config/config.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import { resolveOpenClawAgentDir } from "./agent-paths.js";
+import { shouldSuppressBuiltInModel } from "./model-suppression.js";
import { ensureOpenClawModelsJson } from "./models-config.js";
const log = createSubsystemLogger("model-catalog");
@@ -29,7 +30,7 @@ type PiSdkModule = typeof import("./pi-model-discovery.js");
let modelCatalogPromise: Promise | null = null;
let hasLoggedModelCatalogError = false;
-const defaultImportPiSdk = () => import("./pi-model-discovery.js");
+const defaultImportPiSdk = () => import("./pi-model-discovery-runtime.js");
let importPiSdk = defaultImportPiSdk;
const CODEX_PROVIDER = "openai-codex";
@@ -242,6 +243,9 @@ export async function loadModelCatalog(params?: {
if (!provider) {
continue;
}
+ if (shouldSuppressBuiltInModel({ provider, id })) {
+ continue;
+ }
const name = String(entry?.name ?? id).trim() || id;
const contextWindow =
typeof entry?.contextWindow === "number" && entry.contextWindow > 0
diff --git a/src/agents/model-forward-compat.ts b/src/agents/model-forward-compat.ts
index 8735193346e..4afaff4a7a9 100644
--- a/src/agents/model-forward-compat.ts
+++ b/src/agents/model-forward-compat.ts
@@ -16,6 +16,9 @@ const OPENAI_CODEX_GPT_54_CONTEXT_TOKENS = 1_050_000;
const OPENAI_CODEX_GPT_54_MAX_TOKENS = 128_000;
const OPENAI_CODEX_GPT_54_TEMPLATE_MODEL_IDS = ["gpt-5.3-codex", "gpt-5.2-codex"] as const;
const OPENAI_CODEX_GPT_53_MODEL_ID = "gpt-5.3-codex";
+const OPENAI_CODEX_GPT_53_SPARK_MODEL_ID = "gpt-5.3-codex-spark";
+const OPENAI_CODEX_GPT_53_SPARK_CONTEXT_TOKENS = 128_000;
+const OPENAI_CODEX_GPT_53_SPARK_MAX_TOKENS = 128_000;
const OPENAI_CODEX_TEMPLATE_MODEL_IDS = ["gpt-5.2-codex"] as const;
const ANTHROPIC_OPUS_46_MODEL_ID = "claude-opus-4-6";
@@ -133,6 +136,19 @@ function resolveOpenAICodexForwardCompatModel(
contextWindow: OPENAI_CODEX_GPT_54_CONTEXT_TOKENS,
maxTokens: OPENAI_CODEX_GPT_54_MAX_TOKENS,
};
+ } else if (lower === OPENAI_CODEX_GPT_53_SPARK_MODEL_ID) {
+ templateIds = [OPENAI_CODEX_GPT_53_MODEL_ID, ...OPENAI_CODEX_TEMPLATE_MODEL_IDS];
+ eligibleProviders = CODEX_GPT54_ELIGIBLE_PROVIDERS;
+ patch = {
+ api: "openai-codex-responses",
+ provider: normalizedProvider,
+ baseUrl: "https://chatgpt.com/backend-api",
+ reasoning: true,
+ input: ["text"],
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
+ contextWindow: OPENAI_CODEX_GPT_53_SPARK_CONTEXT_TOKENS,
+ maxTokens: OPENAI_CODEX_GPT_53_SPARK_MAX_TOKENS,
+ };
} else if (lower === OPENAI_CODEX_GPT_53_MODEL_ID) {
templateIds = OPENAI_CODEX_TEMPLATE_MODEL_IDS;
eligibleProviders = CODEX_GPT53_ELIGIBLE_PROVIDERS;
diff --git a/src/agents/model-selection.test.ts b/src/agents/model-selection.test.ts
index a9029540ee1..63aef63561c 100644
--- a/src/agents/model-selection.test.ts
+++ b/src/agents/model-selection.test.ts
@@ -73,6 +73,12 @@ describe("model-selection", () => {
});
});
+ describe("modelKey", () => {
+ it("keeps canonical OpenRouter native ids without duplicating the provider", () => {
+ expect(modelKey("openrouter", "openrouter/hunter-alpha")).toBe("openrouter/hunter-alpha");
+ });
+ });
+
describe("parseModelRef", () => {
it("should parse full model refs", () => {
expect(parseModelRef("anthropic/claude-3-5-sonnet", "openai")).toEqual({
@@ -322,6 +328,98 @@ describe("model-selection", () => {
{ provider: "anthropic", id: "claude-sonnet-4-6", name: "claude-sonnet-4-6" },
]);
});
+
+ it("includes fallback models in allowed set", () => {
+ const cfg: OpenClawConfig = {
+ agents: {
+ defaults: {
+ models: {
+ "openai/gpt-4o": {},
+ },
+ model: {
+ primary: "openai/gpt-4o",
+ fallbacks: ["anthropic/claude-sonnet-4-6", "google/gemini-3-pro"],
+ },
+ },
+ },
+ } as OpenClawConfig;
+
+ const result = buildAllowedModelSet({
+ cfg,
+ catalog: [],
+ defaultProvider: "openai",
+ defaultModel: "gpt-4o",
+ });
+
+ expect(result.allowedKeys.has("openai/gpt-4o")).toBe(true);
+ expect(result.allowedKeys.has("anthropic/claude-sonnet-4-6")).toBe(true);
+ expect(result.allowedKeys.has("google/gemini-3-pro-preview")).toBe(true);
+ expect(result.allowAny).toBe(false);
+ });
+
+ it("handles empty fallbacks gracefully", () => {
+ const cfg: OpenClawConfig = {
+ agents: {
+ defaults: {
+ models: {
+ "openai/gpt-4o": {},
+ },
+ model: {
+ primary: "openai/gpt-4o",
+ fallbacks: [],
+ },
+ },
+ },
+ } as OpenClawConfig;
+
+ const result = buildAllowedModelSet({
+ cfg,
+ catalog: [],
+ defaultProvider: "openai",
+ defaultModel: "gpt-4o",
+ });
+
+ expect(result.allowedKeys.has("openai/gpt-4o")).toBe(true);
+ expect(result.allowAny).toBe(false);
+ });
+
+ it("prefers per-agent fallback overrides when agentId is provided", () => {
+ const cfg: OpenClawConfig = {
+ agents: {
+ defaults: {
+ models: {
+ "openai/gpt-4o": {},
+ },
+ model: {
+ primary: "openai/gpt-4o",
+ fallbacks: ["google/gemini-3-pro"],
+ },
+ },
+ list: [
+ {
+ id: "coder",
+ model: {
+ primary: "openai/gpt-4o",
+ fallbacks: ["anthropic/claude-sonnet-4-6"],
+ },
+ },
+ ],
+ },
+ } as OpenClawConfig;
+
+ const result = buildAllowedModelSet({
+ cfg,
+ catalog: [],
+ defaultProvider: "openai",
+ defaultModel: "gpt-4o",
+ agentId: "coder",
+ });
+
+ expect(result.allowedKeys.has("openai/gpt-4o")).toBe(true);
+ expect(result.allowedKeys.has("anthropic/claude-sonnet-4-6")).toBe(true);
+ expect(result.allowedKeys.has("google/gemini-3-pro-preview")).toBe(false);
+ expect(result.allowAny).toBe(false);
+ });
});
describe("resolveAllowedModelRef", () => {
@@ -662,6 +760,28 @@ describe("model-selection", () => {
expect(resolveAnthropicOpusThinking(cfg)).toBe("high");
});
+ it("accepts legacy duplicated OpenRouter keys for per-model thinking", () => {
+ const cfg = {
+ agents: {
+ defaults: {
+ models: {
+ "openrouter/openrouter/hunter-alpha": {
+ params: { thinking: "high" },
+ },
+ },
+ },
+ },
+ } as OpenClawConfig;
+
+ expect(
+ resolveThinkingDefault({
+ cfg,
+ provider: "openrouter",
+ model: "openrouter/hunter-alpha",
+ }),
+ ).toBe("high");
+ });
+
it("accepts per-model params.thinking=adaptive", () => {
const cfg = {
agents: {
diff --git a/src/agents/model-selection.ts b/src/agents/model-selection.ts
index 205c2f1cce0..7bbd8ed8ba7 100644
--- a/src/agents/model-selection.ts
+++ b/src/agents/model-selection.ts
@@ -1,8 +1,17 @@
+import { resolveThinkingDefaultForModel } from "../auto-reply/thinking.js";
import type { OpenClawConfig } from "../config/config.js";
-import { resolveAgentModelPrimaryValue, toAgentModelListLike } from "../config/model-input.js";
+import {
+ resolveAgentModelFallbackValues,
+ resolveAgentModelPrimaryValue,
+ toAgentModelListLike,
+} from "../config/model-input.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import { sanitizeForLog } from "../terminal/ansi.js";
-import { resolveAgentConfig, resolveAgentEffectiveModelPrimary } from "./agent-scope.js";
+import {
+ resolveAgentConfig,
+ resolveAgentEffectiveModelPrimary,
+ resolveAgentModelFallbacksOverride,
+} from "./agent-scope.js";
import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "./defaults.js";
import type { ModelCatalogEntry } from "./model-catalog.js";
import { splitTrailingAuthProfile } from "./model-ref-profile.js";
@@ -28,14 +37,34 @@ const ANTHROPIC_MODEL_ALIASES: Record = {
"sonnet-4.6": "claude-sonnet-4-6",
"sonnet-4.5": "claude-sonnet-4-5",
};
-const CLAUDE_46_MODEL_RE = /claude-(?:opus|sonnet)-4(?:\.|-)6(?:$|[-.])/i;
function normalizeAliasKey(value: string): string {
return value.trim().toLowerCase();
}
export function modelKey(provider: string, model: string) {
- return `${provider}/${model}`;
+ const providerId = provider.trim();
+ const modelId = model.trim();
+ if (!providerId) {
+ return modelId;
+ }
+ if (!modelId) {
+ return providerId;
+ }
+ return modelId.toLowerCase().startsWith(`${providerId.toLowerCase()}/`)
+ ? modelId
+ : `${providerId}/${modelId}`;
+}
+
+export function legacyModelKey(provider: string, model: string): string | null {
+ const providerId = provider.trim();
+ const modelId = model.trim();
+ if (!providerId || !modelId) {
+ return null;
+ }
+ const rawKey = `${providerId}/${modelId}`;
+ const canonicalKey = modelKey(providerId, modelId);
+ return rawKey === canonicalKey ? null : rawKey;
}
export function normalizeProviderId(provider: string): string {
@@ -382,6 +411,16 @@ export function resolveDefaultModelForAgent(params: {
});
}
+function resolveAllowedFallbacks(params: { cfg: OpenClawConfig; agentId?: string }): string[] {
+ if (params.agentId) {
+ const override = resolveAgentModelFallbacksOverride(params.cfg, params.agentId);
+ if (override !== undefined) {
+ return override;
+ }
+ }
+ return resolveAgentModelFallbackValues(params.cfg.agents?.defaults?.model);
+}
+
export function resolveSubagentConfiguredModelSelection(params: {
cfg: OpenClawConfig;
agentId: string;
@@ -419,6 +458,7 @@ export function buildAllowedModelSet(params: {
catalog: ModelCatalogEntry[];
defaultProvider: string;
defaultModel?: string;
+ agentId?: string;
}): {
allowAny: boolean;
allowedCatalog: ModelCatalogEntry[];
@@ -469,6 +509,25 @@ export function buildAllowedModelSet(params: {
}
}
+ for (const fallback of resolveAllowedFallbacks({
+ cfg: params.cfg,
+ agentId: params.agentId,
+ })) {
+ const parsed = parseModelRef(String(fallback), params.defaultProvider);
+ if (parsed) {
+ const key = modelKey(parsed.provider, parsed.model);
+ allowedKeys.add(key);
+
+ if (!catalogKeys.has(key) && !syntheticCatalogEntries.has(key)) {
+ syntheticCatalogEntries.set(key, {
+ id: parsed.model,
+ name: parsed.model,
+ provider: parsed.provider,
+ });
+ }
+ }
+ }
+
if (defaultKey) {
allowedKeys.add(defaultKey);
}
@@ -570,11 +629,14 @@ export function resolveThinkingDefault(params: {
model: string;
catalog?: ModelCatalogEntry[];
}): ThinkLevel {
- const normalizedProvider = normalizeProviderId(params.provider);
- const modelLower = params.model.toLowerCase();
+ const _normalizedProvider = normalizeProviderId(params.provider);
+ const _modelLower = params.model.toLowerCase();
+ const configuredModels = params.cfg.agents?.defaults?.models;
+ const canonicalKey = modelKey(params.provider, params.model);
+ const legacyKey = legacyModelKey(params.provider, params.model);
const perModelThinking =
- params.cfg.agents?.defaults?.models?.[modelKey(params.provider, params.model)]?.params
- ?.thinking;
+ configuredModels?.[canonicalKey]?.params?.thinking ??
+ (legacyKey ? configuredModels?.[legacyKey]?.params?.thinking : undefined);
if (
perModelThinking === "off" ||
perModelThinking === "minimal" ||
@@ -590,21 +652,11 @@ export function resolveThinkingDefault(params: {
if (configured) {
return configured;
}
- const isAnthropicFamilyModel =
- normalizedProvider === "anthropic" ||
- normalizedProvider === "amazon-bedrock" ||
- modelLower.includes("anthropic/") ||
- modelLower.includes(".anthropic.");
- if (isAnthropicFamilyModel && CLAUDE_46_MODEL_RE.test(modelLower)) {
- return "adaptive";
- }
- const candidate = params.catalog?.find(
- (entry) => entry.provider === params.provider && entry.id === params.model,
- );
- if (candidate?.reasoning) {
- return "low";
- }
- return "off";
+ return resolveThinkingDefaultForModel({
+ provider: params.provider,
+ model: params.model,
+ catalog: params.catalog,
+ });
}
/** Default reasoning level when session/directive do not set it: "on" if model supports reasoning, else "off". */
diff --git a/src/agents/model-suppression.ts b/src/agents/model-suppression.ts
new file mode 100644
index 00000000000..378096ea732
--- /dev/null
+++ b/src/agents/model-suppression.ts
@@ -0,0 +1,27 @@
+import { normalizeProviderId } from "./model-selection.js";
+
+const OPENAI_DIRECT_SPARK_MODEL_ID = "gpt-5.3-codex-spark";
+const SUPPRESSED_SPARK_PROVIDERS = new Set(["openai", "azure-openai-responses"]);
+
+export function shouldSuppressBuiltInModel(params: {
+ provider?: string | null;
+ id?: string | null;
+}) {
+ const provider = normalizeProviderId(params.provider?.trim().toLowerCase() ?? "");
+ const id = params.id?.trim().toLowerCase() ?? "";
+
+ // pi-ai still ships non-Codex Spark rows, but OpenClaw treats Spark as
+ // Codex-only until upstream availability is proven on direct API paths.
+ return SUPPRESSED_SPARK_PROVIDERS.has(provider) && id === OPENAI_DIRECT_SPARK_MODEL_ID;
+}
+
+export function buildSuppressedBuiltInModelError(params: {
+ provider?: string | null;
+ id?: string | null;
+}): string | undefined {
+ if (!shouldSuppressBuiltInModel(params)) {
+ return undefined;
+ }
+ const provider = normalizeProviderId(params.provider?.trim().toLowerCase() ?? "") || "openai";
+ return `Unknown model: ${provider}/${OPENAI_DIRECT_SPARK_MODEL_ID}. ${OPENAI_DIRECT_SPARK_MODEL_ID} is only supported via openai-codex OAuth. Use openai-codex/${OPENAI_DIRECT_SPARK_MODEL_ID}.`;
+}
diff --git a/src/agents/models-config.merge.test.ts b/src/agents/models-config.merge.test.ts
index 60c3624c3c1..b84d4e363d6 100644
--- a/src/agents/models-config.merge.test.ts
+++ b/src/agents/models-config.merge.test.ts
@@ -66,6 +66,42 @@ describe("models-config merge helpers", () => {
});
});
+ it("preserves implicit provider headers when explicit config adds extra headers", () => {
+ const merged = mergeProviderModels(
+ {
+ baseUrl: "https://api.example.com",
+ api: "anthropic-messages",
+ headers: { "User-Agent": "claude-code/0.1.0" },
+ models: [
+ {
+ id: "k2p5",
+ name: "Kimi for Coding",
+ input: ["text", "image"],
+ reasoning: true,
+ },
+ ],
+ } as unknown as ProviderConfig,
+ {
+ baseUrl: "https://api.example.com",
+ api: "anthropic-messages",
+ headers: { "X-Kimi-Tenant": "tenant-a" },
+ models: [
+ {
+ id: "k2p5",
+ name: "Kimi for Coding",
+ input: ["text", "image"],
+ reasoning: true,
+ },
+ ],
+ } as unknown as ProviderConfig,
+ );
+
+ expect(merged.headers).toEqual({
+ "User-Agent": "claude-code/0.1.0",
+ "X-Kimi-Tenant": "tenant-a",
+ });
+ });
+
it("replaces stale baseUrl when model api surface changes", () => {
const merged = mergeWithExistingProviderSecrets({
nextProviders: {
diff --git a/src/agents/models-config.merge.ts b/src/agents/models-config.merge.ts
index e227ee413d5..da4f0e8a005 100644
--- a/src/agents/models-config.merge.ts
+++ b/src/agents/models-config.merge.ts
@@ -39,8 +39,27 @@ export function mergeProviderModels(
): ProviderConfig {
const implicitModels = Array.isArray(implicit.models) ? implicit.models : [];
const explicitModels = Array.isArray(explicit.models) ? explicit.models : [];
+ const implicitHeaders =
+ implicit.headers && typeof implicit.headers === "object" && !Array.isArray(implicit.headers)
+ ? implicit.headers
+ : undefined;
+ const explicitHeaders =
+ explicit.headers && typeof explicit.headers === "object" && !Array.isArray(explicit.headers)
+ ? explicit.headers
+ : undefined;
if (implicitModels.length === 0) {
- return { ...implicit, ...explicit };
+ return {
+ ...implicit,
+ ...explicit,
+ ...(implicitHeaders || explicitHeaders
+ ? {
+ headers: {
+ ...implicitHeaders,
+ ...explicitHeaders,
+ },
+ }
+ : {}),
+ };
}
const implicitById = new Map(
@@ -93,6 +112,14 @@ export function mergeProviderModels(
return {
...implicit,
...explicit,
+ ...(implicitHeaders || explicitHeaders
+ ? {
+ headers: {
+ ...implicitHeaders,
+ ...explicitHeaders,
+ },
+ }
+ : {}),
models: mergedModels,
};
}
diff --git a/src/agents/models-config.plan.ts b/src/agents/models-config.plan.ts
index 40777c2cd0d..601a0edfda1 100644
--- a/src/agents/models-config.plan.ts
+++ b/src/agents/models-config.plan.ts
@@ -6,6 +6,7 @@ import {
type ExistingProviderConfig,
} from "./models-config.merge.js";
import {
+ enforceSourceManagedProviderSecrets,
normalizeProviders,
resolveImplicitProviders,
type ProviderConfig,
@@ -86,6 +87,7 @@ async function resolveProvidersForMode(params: {
export async function planOpenClawModelsJson(params: {
cfg: OpenClawConfig;
+ sourceConfigForSecrets?: OpenClawConfig;
agentDir: string;
env: NodeJS.ProcessEnv;
existingRaw: string;
@@ -106,6 +108,8 @@ export async function planOpenClawModelsJson(params: {
agentDir,
env,
secretDefaults: cfg.secrets?.defaults,
+ sourceProviders: params.sourceConfigForSecrets?.models?.providers,
+ sourceSecretDefaults: params.sourceConfigForSecrets?.secrets?.defaults,
secretRefManagedProviders,
}) ?? providers;
const mergedProviders = await resolveProvidersForMode({
@@ -115,7 +119,14 @@ export async function planOpenClawModelsJson(params: {
secretRefManagedProviders,
explicitBaseUrlProviders: resolveExplicitBaseUrlProviders(cfg.models),
});
- const nextContents = `${JSON.stringify({ providers: mergedProviders }, null, 2)}\n`;
+ const secretEnforcedProviders =
+ enforceSourceManagedProviderSecrets({
+ providers: mergedProviders,
+ sourceProviders: params.sourceConfigForSecrets?.models?.providers,
+ sourceSecretDefaults: params.sourceConfigForSecrets?.secrets?.defaults,
+ secretRefManagedProviders,
+ }) ?? mergedProviders;
+ const nextContents = `${JSON.stringify({ providers: secretEnforcedProviders }, null, 2)}\n`;
if (params.existingRaw === nextContents) {
return { action: "noop" };
diff --git a/src/agents/models-config.providers.discovery.ts b/src/agents/models-config.providers.discovery.ts
index 64e1a9abe61..a6d99afa89f 100644
--- a/src/agents/models-config.providers.discovery.ts
+++ b/src/agents/models-config.providers.discovery.ts
@@ -31,17 +31,20 @@ const log = createSubsystemLogger("agents/model-providers");
const OLLAMA_SHOW_CONCURRENCY = 8;
const OLLAMA_SHOW_MAX_MODELS = 200;
-const VLLM_BASE_URL = "http://127.0.0.1:8000/v1";
-const VLLM_DEFAULT_CONTEXT_WINDOW = 128000;
-const VLLM_DEFAULT_MAX_TOKENS = 8192;
-const VLLM_DEFAULT_COST = {
+const OPENAI_COMPAT_LOCAL_DEFAULT_CONTEXT_WINDOW = 128000;
+const OPENAI_COMPAT_LOCAL_DEFAULT_MAX_TOKENS = 8192;
+const OPENAI_COMPAT_LOCAL_DEFAULT_COST = {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
};
-type VllmModelsResponse = {
+const SGLANG_BASE_URL = "http://127.0.0.1:30000/v1";
+
+const VLLM_BASE_URL = "http://127.0.0.1:8000/v1";
+
+type OpenAICompatModelsResponse = {
data?: Array<{
id?: string;
}>;
@@ -96,31 +99,34 @@ async function discoverOllamaModels(
}
}
-async function discoverVllmModels(
- baseUrl: string,
- apiKey?: string,
-): Promise {
+async function discoverOpenAICompatibleLocalModels(params: {
+ baseUrl: string;
+ apiKey?: string;
+ label: string;
+ contextWindow?: number;
+ maxTokens?: number;
+}): Promise {
if (process.env.VITEST || process.env.NODE_ENV === "test") {
return [];
}
- const trimmedBaseUrl = baseUrl.trim().replace(/\/+$/, "");
+ const trimmedBaseUrl = params.baseUrl.trim().replace(/\/+$/, "");
const url = `${trimmedBaseUrl}/models`;
try {
- const trimmedApiKey = apiKey?.trim();
+ const trimmedApiKey = params.apiKey?.trim();
const response = await fetch(url, {
headers: trimmedApiKey ? { Authorization: `Bearer ${trimmedApiKey}` } : undefined,
signal: AbortSignal.timeout(5000),
});
if (!response.ok) {
- log.warn(`Failed to discover vLLM models: ${response.status}`);
+ log.warn(`Failed to discover ${params.label} models: ${response.status}`);
return [];
}
- const data = (await response.json()) as VllmModelsResponse;
+ const data = (await response.json()) as OpenAICompatModelsResponse;
const models = data.data ?? [];
if (models.length === 0) {
- log.warn("No vLLM models found on local instance");
+ log.warn(`No ${params.label} models found on local instance`);
return [];
}
@@ -134,13 +140,13 @@ async function discoverVllmModels(
name: modelId,
reasoning: isReasoningModelHeuristic(modelId),
input: ["text"],
- cost: VLLM_DEFAULT_COST,
- contextWindow: VLLM_DEFAULT_CONTEXT_WINDOW,
- maxTokens: VLLM_DEFAULT_MAX_TOKENS,
+ cost: OPENAI_COMPAT_LOCAL_DEFAULT_COST,
+ contextWindow: params.contextWindow ?? OPENAI_COMPAT_LOCAL_DEFAULT_CONTEXT_WINDOW,
+ maxTokens: params.maxTokens ?? OPENAI_COMPAT_LOCAL_DEFAULT_MAX_TOKENS,
} satisfies ModelDefinitionConfig;
});
} catch (error) {
- log.warn(`Failed to discover vLLM models: ${String(error)}`);
+ log.warn(`Failed to discover ${params.label} models: ${String(error)}`);
return [];
}
}
@@ -192,7 +198,28 @@ export async function buildVllmProvider(params?: {
apiKey?: string;
}): Promise {
const baseUrl = (params?.baseUrl?.trim() || VLLM_BASE_URL).replace(/\/+$/, "");
- const models = await discoverVllmModels(baseUrl, params?.apiKey);
+ const models = await discoverOpenAICompatibleLocalModels({
+ baseUrl,
+ apiKey: params?.apiKey,
+ label: "vLLM",
+ });
+ return {
+ baseUrl,
+ api: "openai-completions",
+ models,
+ };
+}
+
+export async function buildSglangProvider(params?: {
+ baseUrl?: string;
+ apiKey?: string;
+}): Promise {
+ const baseUrl = (params?.baseUrl?.trim() || SGLANG_BASE_URL).replace(/\/+$/, "");
+ const models = await discoverOpenAICompatibleLocalModels({
+ baseUrl,
+ apiKey: params?.apiKey,
+ label: "SGLang",
+ });
return {
baseUrl,
api: "openai-completions",
diff --git a/src/agents/models-config.providers.kimi-coding.test.ts b/src/agents/models-config.providers.kimi-coding.test.ts
index 33e94a2f1c3..91ca62f34e2 100644
--- a/src/agents/models-config.providers.kimi-coding.test.ts
+++ b/src/agents/models-config.providers.kimi-coding.test.ts
@@ -26,6 +26,7 @@ describe("kimi-coding implicit provider (#22409)", () => {
const provider = buildKimiCodingProvider();
expect(provider.api).toBe("anthropic-messages");
expect(provider.baseUrl).toBe("https://api.kimi.com/coding/");
+ expect(provider.headers).toEqual({ "User-Agent": "claude-code/0.1.0" });
expect(provider.models).toBeDefined();
expect(provider.models.length).toBeGreaterThan(0);
expect(provider.models[0].id).toBe("k2p5");
@@ -43,4 +44,55 @@ describe("kimi-coding implicit provider (#22409)", () => {
envSnapshot.restore();
}
});
+
+ it("uses explicit kimi-coding baseUrl when provided", async () => {
+ const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-"));
+ const envSnapshot = captureEnv(["KIMI_API_KEY"]);
+ process.env.KIMI_API_KEY = "test-key";
+
+ try {
+ const providers = await resolveImplicitProvidersForTest({
+ agentDir,
+ explicitProviders: {
+ "kimi-coding": {
+ baseUrl: "https://kimi.example.test/coding/",
+ api: "anthropic-messages",
+ models: buildKimiCodingProvider().models,
+ },
+ },
+ });
+ expect(providers?.["kimi-coding"]?.baseUrl).toBe("https://kimi.example.test/coding/");
+ } finally {
+ envSnapshot.restore();
+ }
+ });
+
+ it("merges explicit kimi-coding headers on top of the built-in user agent", async () => {
+ const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-"));
+ const envSnapshot = captureEnv(["KIMI_API_KEY"]);
+ process.env.KIMI_API_KEY = "test-key";
+
+ try {
+ const providers = await resolveImplicitProvidersForTest({
+ agentDir,
+ explicitProviders: {
+ "kimi-coding": {
+ baseUrl: "https://api.kimi.com/coding/",
+ api: "anthropic-messages",
+ headers: {
+ "User-Agent": "custom-kimi-client/1.0",
+ "X-Kimi-Tenant": "tenant-a",
+ },
+ models: buildKimiCodingProvider().models,
+ },
+ },
+ });
+ expect(providers?.["kimi-coding"]?.headers).toEqual({
+ "User-Agent": "custom-kimi-client/1.0",
+ "X-Kimi-Tenant": "tenant-a",
+ });
+ } finally {
+ envSnapshot.restore();
+ }
+ });
});
diff --git a/src/agents/models-config.providers.moonshot.test.ts b/src/agents/models-config.providers.moonshot.test.ts
new file mode 100644
index 00000000000..00e1f5949c6
--- /dev/null
+++ b/src/agents/models-config.providers.moonshot.test.ts
@@ -0,0 +1,60 @@
+import { mkdtempSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { describe, expect, it } from "vitest";
+import {
+ MOONSHOT_BASE_URL as MOONSHOT_AI_BASE_URL,
+ MOONSHOT_CN_BASE_URL,
+} from "../commands/onboard-auth.models.js";
+import { captureEnv } from "../test-utils/env.js";
+import { resolveImplicitProviders } from "./models-config.providers.js";
+
+describe("moonshot implicit provider (#33637)", () => {
+ it("uses explicit CN baseUrl when provided", async () => {
+ const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-"));
+ const envSnapshot = captureEnv(["MOONSHOT_API_KEY"]);
+ process.env.MOONSHOT_API_KEY = "sk-test-cn";
+
+ try {
+ const providers = await resolveImplicitProviders({
+ agentDir,
+ explicitProviders: {
+ moonshot: {
+ baseUrl: MOONSHOT_CN_BASE_URL,
+ api: "openai-completions",
+ models: [
+ {
+ id: "kimi-k2.5",
+ name: "Kimi K2.5",
+ reasoning: false,
+ input: ["text", "image"],
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
+ contextWindow: 256000,
+ maxTokens: 8192,
+ },
+ ],
+ },
+ },
+ });
+ expect(providers?.moonshot).toBeDefined();
+ expect(providers?.moonshot?.baseUrl).toBe(MOONSHOT_CN_BASE_URL);
+ expect(providers?.moonshot?.apiKey).toBeDefined();
+ } finally {
+ envSnapshot.restore();
+ }
+ });
+
+ it("defaults to .ai baseUrl when no explicit provider", async () => {
+ const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-"));
+ const envSnapshot = captureEnv(["MOONSHOT_API_KEY"]);
+ process.env.MOONSHOT_API_KEY = "sk-test";
+
+ try {
+ const providers = await resolveImplicitProviders({ agentDir });
+ expect(providers?.moonshot).toBeDefined();
+ expect(providers?.moonshot?.baseUrl).toBe(MOONSHOT_AI_BASE_URL);
+ } finally {
+ envSnapshot.restore();
+ }
+ });
+});
diff --git a/src/agents/models-config.providers.normalize-keys.test.ts b/src/agents/models-config.providers.normalize-keys.test.ts
index f8422d797dd..b39705d8ec2 100644
--- a/src/agents/models-config.providers.normalize-keys.test.ts
+++ b/src/agents/models-config.providers.normalize-keys.test.ts
@@ -4,7 +4,10 @@ import path from "node:path";
import { describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import { NON_ENV_SECRETREF_MARKER } from "./model-auth-markers.js";
-import { normalizeProviders } from "./models-config.providers.js";
+import {
+ enforceSourceManagedProviderSecrets,
+ normalizeProviders,
+} from "./models-config.providers.js";
describe("normalizeProviders", () => {
it("trims provider keys so image models remain discoverable for custom providers", async () => {
@@ -136,4 +139,38 @@ describe("normalizeProviders", () => {
await fs.rm(agentDir, { recursive: true, force: true });
}
});
+
+ it("ignores non-object provider entries during source-managed enforcement", () => {
+ const providers = {
+ openai: null,
+ moonshot: {
+ baseUrl: "https://api.moonshot.ai/v1",
+ api: "openai-completions",
+ apiKey: "sk-runtime-moonshot", // pragma: allowlist secret
+ models: [],
+ },
+ } as unknown as NonNullable["providers"]>;
+
+ const sourceProviders: NonNullable["providers"]> = {
+ openai: {
+ baseUrl: "https://api.openai.com/v1",
+ api: "openai-completions",
+ apiKey: { source: "env", provider: "default", id: "OPENAI_API_KEY" }, // pragma: allowlist secret
+ models: [],
+ },
+ moonshot: {
+ baseUrl: "https://api.moonshot.ai/v1",
+ api: "openai-completions",
+ apiKey: { source: "env", provider: "default", id: "MOONSHOT_API_KEY" }, // pragma: allowlist secret
+ models: [],
+ },
+ };
+
+ const enforced = enforceSourceManagedProviderSecrets({
+ providers,
+ sourceProviders,
+ });
+ expect((enforced as Record).openai).toBeNull();
+ expect(enforced?.moonshot?.apiKey).toBe("MOONSHOT_API_KEY"); // pragma: allowlist secret
+ });
});
diff --git a/src/agents/models-config.providers.static.ts b/src/agents/models-config.providers.static.ts
index c525cb32f53..a0aa879c727 100644
--- a/src/agents/models-config.providers.static.ts
+++ b/src/agents/models-config.providers.static.ts
@@ -95,6 +95,7 @@ const MOONSHOT_DEFAULT_COST = {
};
const KIMI_CODING_BASE_URL = "https://api.kimi.com/coding/";
+const KIMI_CODING_USER_AGENT = "claude-code/0.1.0";
const KIMI_CODING_DEFAULT_MODEL_ID = "k2p5";
const KIMI_CODING_DEFAULT_CONTEXT_WINDOW = 262144;
const KIMI_CODING_DEFAULT_MAX_TOKENS = 32768;
@@ -186,7 +187,7 @@ const MODELSTUDIO_MODEL_CATALOG: ReadonlyArray = [
{
id: "MiniMax-M2.5",
name: "MiniMax-M2.5",
- reasoning: false,
+ reasoning: true,
input: ["text"],
cost: MODELSTUDIO_DEFAULT_COST,
contextWindow: 1_000_000,
@@ -308,6 +309,9 @@ export function buildKimiCodingProvider(): ProviderConfig {
return {
baseUrl: KIMI_CODING_BASE_URL,
api: "anthropic-messages",
+ headers: {
+ "User-Agent": KIMI_CODING_USER_AGENT,
+ },
models: [
{
id: KIMI_CODING_DEFAULT_MODEL_ID,
diff --git a/src/agents/models-config.providers.ts b/src/agents/models-config.providers.ts
index c63ed6865a8..4c9febf2ef1 100644
--- a/src/agents/models-config.providers.ts
+++ b/src/agents/models-config.providers.ts
@@ -4,6 +4,7 @@ import {
DEFAULT_COPILOT_API_BASE_URL,
resolveCopilotApiToken,
} from "../providers/github-copilot-token.js";
+import { isRecord } from "../utils.js";
import { normalizeOptionalSecretInput } from "../utils/normalize-secret-input.js";
import { ensureAuthProfileStore, listProfilesForProvider } from "./auth-profiles.js";
import { discoverBedrockModels } from "./bedrock-discovery.js";
@@ -14,10 +15,8 @@ import {
import {
buildHuggingfaceProvider,
buildKilocodeProviderWithDiscovery,
- buildOllamaProvider,
buildVeniceProvider,
buildVercelAiGatewayProvider,
- buildVllmProvider,
resolveOllamaApiBase,
} from "./models-config.providers.discovery.js";
import {
@@ -56,9 +55,13 @@ export {
QIANFAN_DEFAULT_MODEL_ID,
XIAOMI_DEFAULT_MODEL_ID,
} from "./models-config.providers.static.js";
+import {
+ groupPluginDiscoveryProvidersByOrder,
+ normalizePluginDiscoveryResult,
+ resolvePluginDiscoveryProviders,
+} from "../plugins/provider-discovery.js";
import {
MINIMAX_OAUTH_MARKER,
- OLLAMA_LOCAL_AUTH_MARKER,
QWEN_OAUTH_MARKER,
isNonSecretApiKeyMarker,
resolveNonEnvSecretRefApiKeyMarker,
@@ -70,6 +73,11 @@ export { resolveOllamaApiBase } from "./models-config.providers.discovery.js";
type ModelsConfig = NonNullable;
export type ProviderConfig = NonNullable[string];
+type SecretDefaults = {
+ env?: string;
+ file?: string;
+ exec?: string;
+};
const ENV_VAR_NAME_RE = /^[A-Z_][A-Z0-9_]*$/;
@@ -97,13 +105,7 @@ function resolveAwsSdkApiKeyVarName(env: NodeJS.ProcessEnv = process.env): strin
function normalizeHeaderValues(params: {
headers: ProviderConfig["headers"] | undefined;
- secretDefaults:
- | {
- env?: string;
- file?: string;
- exec?: string;
- }
- | undefined;
+ secretDefaults: SecretDefaults | undefined;
}): { headers: ProviderConfig["headers"] | undefined; mutated: boolean } {
const { headers } = params;
if (!headers) {
@@ -276,15 +278,155 @@ function normalizeAntigravityProvider(provider: ProviderConfig): ProviderConfig
return normalizeProviderModels(provider, normalizeAntigravityModelId);
}
+function normalizeSourceProviderLookup(
+ providers: ModelsConfig["providers"] | undefined,
+): Record {
+ if (!providers) {
+ return {};
+ }
+ const out: Record = {};
+ for (const [key, provider] of Object.entries(providers)) {
+ const normalizedKey = key.trim();
+ if (!normalizedKey || !isRecord(provider)) {
+ continue;
+ }
+ out[normalizedKey] = provider;
+ }
+ return out;
+}
+
+function resolveSourceManagedApiKeyMarker(params: {
+ sourceProvider: ProviderConfig | undefined;
+ sourceSecretDefaults: SecretDefaults | undefined;
+}): string | undefined {
+ const sourceApiKeyRef = resolveSecretInputRef({
+ value: params.sourceProvider?.apiKey,
+ defaults: params.sourceSecretDefaults,
+ }).ref;
+ if (!sourceApiKeyRef || !sourceApiKeyRef.id.trim()) {
+ return undefined;
+ }
+ return sourceApiKeyRef.source === "env"
+ ? sourceApiKeyRef.id.trim()
+ : resolveNonEnvSecretRefApiKeyMarker(sourceApiKeyRef.source);
+}
+
+function resolveSourceManagedHeaderMarkers(params: {
+ sourceProvider: ProviderConfig | undefined;
+ sourceSecretDefaults: SecretDefaults | undefined;
+}): Record {
+ const sourceHeaders = isRecord(params.sourceProvider?.headers)
+ ? (params.sourceProvider.headers as Record)
+ : undefined;
+ if (!sourceHeaders) {
+ return {};
+ }
+ const markers: Record = {};
+ for (const [headerName, headerValue] of Object.entries(sourceHeaders)) {
+ const sourceHeaderRef = resolveSecretInputRef({
+ value: headerValue,
+ defaults: params.sourceSecretDefaults,
+ }).ref;
+ if (!sourceHeaderRef || !sourceHeaderRef.id.trim()) {
+ continue;
+ }
+ markers[headerName] =
+ sourceHeaderRef.source === "env"
+ ? resolveEnvSecretRefHeaderValueMarker(sourceHeaderRef.id)
+ : resolveNonEnvSecretRefHeaderValueMarker(sourceHeaderRef.source);
+ }
+ return markers;
+}
+
+export function enforceSourceManagedProviderSecrets(params: {
+ providers: ModelsConfig["providers"];
+ sourceProviders: ModelsConfig["providers"] | undefined;
+ sourceSecretDefaults?: SecretDefaults;
+ secretRefManagedProviders?: Set;
+}): ModelsConfig["providers"] {
+ const { providers } = params;
+ if (!providers) {
+ return providers;
+ }
+ const sourceProvidersByKey = normalizeSourceProviderLookup(params.sourceProviders);
+ if (Object.keys(sourceProvidersByKey).length === 0) {
+ return providers;
+ }
+
+ let nextProviders: Record | null = null;
+ for (const [providerKey, provider] of Object.entries(providers)) {
+ if (!isRecord(provider)) {
+ continue;
+ }
+ const sourceProvider = sourceProvidersByKey[providerKey.trim()];
+ if (!sourceProvider) {
+ continue;
+ }
+ let nextProvider = provider;
+ let providerMutated = false;
+
+ const sourceApiKeyMarker = resolveSourceManagedApiKeyMarker({
+ sourceProvider,
+ sourceSecretDefaults: params.sourceSecretDefaults,
+ });
+ if (sourceApiKeyMarker) {
+ params.secretRefManagedProviders?.add(providerKey.trim());
+ if (nextProvider.apiKey !== sourceApiKeyMarker) {
+ providerMutated = true;
+ nextProvider = {
+ ...nextProvider,
+ apiKey: sourceApiKeyMarker,
+ };
+ }
+ }
+
+ const sourceHeaderMarkers = resolveSourceManagedHeaderMarkers({
+ sourceProvider,
+ sourceSecretDefaults: params.sourceSecretDefaults,
+ });
+ if (Object.keys(sourceHeaderMarkers).length > 0) {
+ const currentHeaders = isRecord(nextProvider.headers)
+ ? (nextProvider.headers as Record)
+ : undefined;
+ const nextHeaders = {
+ ...(currentHeaders as Record[string]>),
+ };
+ let headersMutated = !currentHeaders;
+ for (const [headerName, marker] of Object.entries(sourceHeaderMarkers)) {
+ if (nextHeaders[headerName] === marker) {
+ continue;
+ }
+ headersMutated = true;
+ nextHeaders[headerName] = marker;
+ }
+ if (headersMutated) {
+ providerMutated = true;
+ nextProvider = {
+ ...nextProvider,
+ headers: nextHeaders,
+ };
+ }
+ }
+
+ if (!providerMutated) {
+ continue;
+ }
+ if (!nextProviders) {
+ nextProviders = { ...providers };
+ }
+ nextProviders[providerKey] = nextProvider;
+ }
+
+ return nextProviders ?? providers;
+}
+
export function normalizeProviders(params: {
providers: ModelsConfig["providers"];
agentDir: string;
env?: NodeJS.ProcessEnv;
- secretDefaults?: {
- env?: string;
- file?: string;
- exec?: string;
- };
+ secretDefaults?: SecretDefaults;
+ sourceProviders?: ModelsConfig["providers"];
+ sourceSecretDefaults?: SecretDefaults;
secretRefManagedProviders?: Set;
}): ModelsConfig["providers"] {
const { providers } = params;
@@ -434,13 +576,20 @@ export function normalizeProviders(params: {
next[normalizedKey] = normalizedProvider;
}
- return mutated ? next : providers;
+ const normalizedProviders = mutated ? next : providers;
+ return enforceSourceManagedProviderSecrets({
+ providers: normalizedProviders,
+ sourceProviders: params.sourceProviders,
+ sourceSecretDefaults: params.sourceSecretDefaults,
+ secretRefManagedProviders: params.secretRefManagedProviders,
+ });
}
type ImplicitProviderParams = {
agentDir: string;
config?: OpenClawConfig;
env?: NodeJS.ProcessEnv;
+ workspaceDir?: string;
explicitProviders?: Record | null;
};
@@ -464,6 +613,7 @@ function withApiKey(
build: (params: {
apiKey: string;
discoveryApiKey?: string;
+ explicitProvider?: ProviderConfig;
}) => ProviderConfig | Promise,
): ImplicitProviderLoader {
return async (ctx) => {
@@ -472,7 +622,11 @@ function withApiKey(
return undefined;
}
return {
- [providerKey]: await build({ apiKey, discoveryApiKey }),
+ [providerKey]: await build({
+ apiKey,
+ discoveryApiKey,
+ explicitProvider: ctx.explicitProviders?.[providerKey],
+ }),
};
};
}
@@ -505,8 +659,38 @@ function mergeImplicitProviderSet(
const SIMPLE_IMPLICIT_PROVIDER_LOADERS: ImplicitProviderLoader[] = [
withApiKey("minimax", async ({ apiKey }) => ({ ...buildMinimaxProvider(), apiKey })),
- withApiKey("moonshot", async ({ apiKey }) => ({ ...buildMoonshotProvider(), apiKey })),
- withApiKey("kimi-coding", async ({ apiKey }) => ({ ...buildKimiCodingProvider(), apiKey })),
+ withApiKey("moonshot", async ({ apiKey, explicitProvider }) => {
+ const explicitBaseUrl = explicitProvider?.baseUrl;
+ return {
+ ...buildMoonshotProvider(),
+ ...(typeof explicitBaseUrl === "string" && explicitBaseUrl.trim()
+ ? { baseUrl: explicitBaseUrl.trim() }
+ : {}),
+ apiKey,
+ };
+ }),
+ withApiKey("kimi-coding", async ({ apiKey, explicitProvider }) => {
+ const builtInProvider = buildKimiCodingProvider();
+ const explicitBaseUrl = explicitProvider?.baseUrl;
+ const explicitHeaders = isRecord(explicitProvider?.headers)
+ ? (explicitProvider.headers as ProviderConfig["headers"])
+ : undefined;
+ return {
+ ...builtInProvider,
+ ...(typeof explicitBaseUrl === "string" && explicitBaseUrl.trim()
+ ? { baseUrl: explicitBaseUrl.trim() }
+ : {}),
+ ...(explicitHeaders
+ ? {
+ headers: {
+ ...builtInProvider.headers,
+ ...explicitHeaders,
+ },
+ }
+ : {}),
+ apiKey,
+ };
+ }),
withApiKey("synthetic", async ({ apiKey }) => ({ ...buildSyntheticProvider(), apiKey })),
withApiKey("venice", async ({ apiKey }) => ({ ...(await buildVeniceProvider()), apiKey })),
withApiKey("xiaomi", async ({ apiKey }) => ({ ...buildXiaomiProvider(), apiKey })),
@@ -615,56 +799,35 @@ async function resolveCloudflareAiGatewayImplicitProvider(
return undefined;
}
-async function resolveOllamaImplicitProvider(
+async function resolvePluginImplicitProviders(
ctx: ImplicitProviderContext,
+ order: import("../plugins/types.js").ProviderDiscoveryOrder,
): Promise | undefined> {
- const ollamaKey = ctx.resolveProviderApiKey("ollama").apiKey;
- const explicitOllama = ctx.explicitProviders?.ollama;
- const hasExplicitModels =
- Array.isArray(explicitOllama?.models) && explicitOllama.models.length > 0;
- if (hasExplicitModels && explicitOllama) {
- return {
- ollama: {
- ...explicitOllama,
- baseUrl: resolveOllamaApiBase(explicitOllama.baseUrl),
- api: explicitOllama.api ?? "ollama",
- apiKey: ollamaKey ?? explicitOllama.apiKey ?? OLLAMA_LOCAL_AUTH_MARKER,
- },
- };
- }
-
- const ollamaBaseUrl = explicitOllama?.baseUrl;
- const hasExplicitOllamaConfig = Boolean(explicitOllama);
- const ollamaProvider = await buildOllamaProvider(ollamaBaseUrl, {
- quiet: !ollamaKey && !hasExplicitOllamaConfig,
+ const providers = resolvePluginDiscoveryProviders({
+ config: ctx.config,
+ workspaceDir: ctx.workspaceDir,
+ env: ctx.env,
});
- if (ollamaProvider.models.length === 0 && !ollamaKey && !explicitOllama?.apiKey) {
- return undefined;
+ const byOrder = groupPluginDiscoveryProvidersByOrder(providers);
+ const discovered: Record = {};
+ for (const provider of byOrder[order]) {
+ const result = await provider.discovery?.run({
+ config: ctx.config ?? {},
+ agentDir: ctx.agentDir,
+ workspaceDir: ctx.workspaceDir,
+ env: ctx.env,
+ resolveProviderApiKey: (providerId) =>
+ ctx.resolveProviderApiKey(providerId?.trim() || provider.id),
+ });
+ mergeImplicitProviderSet(
+ discovered,
+ normalizePluginDiscoveryResult({
+ provider,
+ result,
+ }),
+ );
}
- return {
- ollama: {
- ...ollamaProvider,
- apiKey: ollamaKey ?? explicitOllama?.apiKey ?? OLLAMA_LOCAL_AUTH_MARKER,
- },
- };
-}
-
-async function resolveVllmImplicitProvider(
- ctx: ImplicitProviderContext,
-): Promise | undefined> {
- if (ctx.explicitProviders?.vllm) {
- return undefined;
- }
- const { apiKey: vllmKey, discoveryApiKey } = ctx.resolveProviderApiKey("vllm");
- if (!vllmKey) {
- return undefined;
- }
- return {
- vllm: {
- ...(await buildVllmProvider({ apiKey: discoveryApiKey })),
- apiKey: vllmKey,
- },
- };
+ return Object.keys(discovered).length > 0 ? discovered : undefined;
}
export async function resolveImplicitProviders(
@@ -701,15 +864,17 @@ export async function resolveImplicitProviders(
for (const loader of SIMPLE_IMPLICIT_PROVIDER_LOADERS) {
mergeImplicitProviderSet(providers, await loader(context));
}
+ mergeImplicitProviderSet(providers, await resolvePluginImplicitProviders(context, "simple"));
for (const loader of PROFILE_IMPLICIT_PROVIDER_LOADERS) {
mergeImplicitProviderSet(providers, await loader(context));
}
+ mergeImplicitProviderSet(providers, await resolvePluginImplicitProviders(context, "profile"));
for (const loader of PAIRED_IMPLICIT_PROVIDER_LOADERS) {
mergeImplicitProviderSet(providers, await loader(context));
}
+ mergeImplicitProviderSet(providers, await resolvePluginImplicitProviders(context, "paired"));
mergeImplicitProviderSet(providers, await resolveCloudflareAiGatewayImplicitProvider(context));
- mergeImplicitProviderSet(providers, await resolveOllamaImplicitProvider(context));
- mergeImplicitProviderSet(providers, await resolveVllmImplicitProvider(context));
+ mergeImplicitProviderSet(providers, await resolvePluginImplicitProviders(context, "late"));
if (!providers["github-copilot"]) {
const implicitCopilot = await resolveImplicitCopilotProvider({
diff --git a/src/agents/models-config.runtime-source-snapshot.test.ts b/src/agents/models-config.runtime-source-snapshot.test.ts
index 4c5889769cc..cc033fb56a6 100644
--- a/src/agents/models-config.runtime-source-snapshot.test.ts
+++ b/src/agents/models-config.runtime-source-snapshot.test.ts
@@ -209,4 +209,152 @@ describe("models-config runtime source snapshot", () => {
}
});
});
+
+ it("keeps source markers when runtime projection is skipped for incompatible top-level shape", async () => {
+ await withTempHome(async () => {
+ const sourceConfig: OpenClawConfig = {
+ models: {
+ providers: {
+ openai: {
+ baseUrl: "https://api.openai.com/v1",
+ apiKey: { source: "env", provider: "default", id: "OPENAI_API_KEY" }, // pragma: allowlist secret
+ api: "openai-completions" as const,
+ models: [],
+ },
+ },
+ },
+ gateway: {
+ auth: {
+ mode: "token",
+ },
+ },
+ };
+ const runtimeConfig: OpenClawConfig = {
+ models: {
+ providers: {
+ openai: {
+ baseUrl: "https://api.openai.com/v1",
+ apiKey: "sk-runtime-resolved", // pragma: allowlist secret
+ api: "openai-completions" as const,
+ models: [],
+ },
+ },
+ },
+ gateway: {
+ auth: {
+ mode: "token",
+ },
+ },
+ };
+ const incompatibleCandidate: OpenClawConfig = {
+ models: {
+ providers: {
+ openai: {
+ baseUrl: "https://api.openai.com/v1",
+ apiKey: "sk-runtime-resolved", // pragma: allowlist secret
+ api: "openai-completions" as const,
+ models: [],
+ },
+ },
+ },
+ };
+
+ try {
+ setRuntimeConfigSnapshot(runtimeConfig, sourceConfig);
+ await ensureOpenClawModelsJson(incompatibleCandidate);
+
+ const parsed = await readGeneratedModelsJson<{
+ providers: Record;
+ }>();
+ expect(parsed.providers.openai?.apiKey).toBe("OPENAI_API_KEY"); // pragma: allowlist secret
+ } finally {
+ clearRuntimeConfigSnapshot();
+ clearConfigCache();
+ }
+ });
+ });
+
+ it("keeps source header markers when runtime projection is skipped for incompatible top-level shape", async () => {
+ await withTempHome(async () => {
+ const sourceConfig: OpenClawConfig = {
+ models: {
+ providers: {
+ openai: {
+ baseUrl: "https://api.openai.com/v1",
+ api: "openai-completions" as const,
+ headers: {
+ Authorization: {
+ source: "env",
+ provider: "default",
+ id: "OPENAI_HEADER_TOKEN", // pragma: allowlist secret
+ },
+ "X-Tenant-Token": {
+ source: "file",
+ provider: "vault",
+ id: "/providers/openai/tenantToken",
+ },
+ },
+ models: [],
+ },
+ },
+ },
+ gateway: {
+ auth: {
+ mode: "token",
+ },
+ },
+ };
+ const runtimeConfig: OpenClawConfig = {
+ models: {
+ providers: {
+ openai: {
+ baseUrl: "https://api.openai.com/v1",
+ api: "openai-completions" as const,
+ headers: {
+ Authorization: "Bearer runtime-openai-token",
+ "X-Tenant-Token": "runtime-tenant-token",
+ },
+ models: [],
+ },
+ },
+ },
+ gateway: {
+ auth: {
+ mode: "token",
+ },
+ },
+ };
+ const incompatibleCandidate: OpenClawConfig = {
+ models: {
+ providers: {
+ openai: {
+ baseUrl: "https://api.openai.com/v1",
+ api: "openai-completions" as const,
+ headers: {
+ Authorization: "Bearer runtime-openai-token",
+ "X-Tenant-Token": "runtime-tenant-token",
+ },
+ models: [],
+ },
+ },
+ },
+ };
+
+ try {
+ setRuntimeConfigSnapshot(runtimeConfig, sourceConfig);
+ await ensureOpenClawModelsJson(incompatibleCandidate);
+
+ const parsed = await readGeneratedModelsJson<{
+ providers: Record }>;
+ }>();
+ expect(parsed.providers.openai?.headers?.Authorization).toBe(
+ "secretref-env:OPENAI_HEADER_TOKEN", // pragma: allowlist secret
+ );
+ expect(parsed.providers.openai?.headers?.["X-Tenant-Token"]).toBe(NON_ENV_SECRETREF_MARKER);
+ } finally {
+ clearRuntimeConfigSnapshot();
+ clearConfigCache();
+ }
+ });
+ });
});
diff --git a/src/agents/models-config.ts b/src/agents/models-config.ts
index 99714a1a792..3e013799b0b 100644
--- a/src/agents/models-config.ts
+++ b/src/agents/models-config.ts
@@ -42,15 +42,31 @@ async function writeModelsFileAtomic(targetPath: string, contents: string): Prom
await fs.rename(tempPath, targetPath);
}
-function resolveModelsConfigInput(config?: OpenClawConfig): OpenClawConfig {
+function resolveModelsConfigInput(config?: OpenClawConfig): {
+ config: OpenClawConfig;
+ sourceConfigForSecrets: OpenClawConfig;
+} {
const runtimeSource = getRuntimeConfigSourceSnapshot();
if (!config) {
- return runtimeSource ?? loadConfig();
+ const loaded = loadConfig();
+ return {
+ config: runtimeSource ?? loaded,
+ sourceConfigForSecrets: runtimeSource ?? loaded,
+ };
}
if (!runtimeSource) {
- return config;
+ return {
+ config,
+ sourceConfigForSecrets: config,
+ };
}
- return projectConfigOntoRuntimeSourceSnapshot(config);
+ const projected = projectConfigOntoRuntimeSourceSnapshot(config);
+ return {
+ config: projected,
+ // If projection is skipped (for example incompatible top-level shape),
+ // keep managed secret persistence anchored to the active source snapshot.
+ sourceConfigForSecrets: projected === config ? runtimeSource : projected,
+ };
}
async function withModelsJsonWriteLock(targetPath: string, run: () => Promise): Promise {
@@ -76,7 +92,8 @@ export async function ensureOpenClawModelsJson(
config?: OpenClawConfig,
agentDirOverride?: string,
): Promise<{ agentDir: string; wrote: boolean }> {
- const cfg = resolveModelsConfigInput(config);
+ const resolved = resolveModelsConfigInput(config);
+ const cfg = resolved.config;
const agentDir = agentDirOverride?.trim() ? agentDirOverride.trim() : resolveOpenClawAgentDir();
const targetPath = path.join(agentDir, "models.json");
@@ -87,6 +104,7 @@ export async function ensureOpenClawModelsJson(
const existingModelsFile = await readExistingModelsFile(targetPath);
const plan = await planOpenClawModelsJson({
cfg,
+ sourceConfigForSecrets: resolved.sourceConfigForSecrets,
agentDir,
env,
existingRaw: existingModelsFile.raw,
diff --git a/src/agents/models.profiles.live.test.ts b/src/agents/models.profiles.live.test.ts
index 81c7a64cb8c..515d2b48ce6 100644
--- a/src/agents/models.profiles.live.test.ts
+++ b/src/agents/models.profiles.live.test.ts
@@ -11,6 +11,7 @@ import {
} from "./live-auth-keys.js";
import { isModernModelRef } from "./live-model-filter.js";
import { getApiKeyForModel, requireApiKey } from "./model-auth.js";
+import { shouldSuppressBuiltInModel } from "./model-suppression.js";
import { ensureOpenClawModelsJson } from "./models-config.js";
import { isRateLimitErrorMessage } from "./pi-embedded-helpers/errors.js";
import { discoverAuthStorage, discoverModels } from "./pi-model-discovery.js";
@@ -202,6 +203,31 @@ function resolveTestReasoning(
return "low";
}
+function resolveLiveSystemPrompt(model: Model): string | undefined {
+ if (model.provider === "openai-codex") {
+ return "You are a concise assistant. Follow the user's instruction exactly.";
+ }
+ return undefined;
+}
+
+describe("resolveLiveSystemPrompt", () => {
+ it("adds instructions for openai-codex probes", () => {
+ expect(
+ resolveLiveSystemPrompt({
+ provider: "openai-codex",
+ } as Model),
+ ).toContain("Follow the user's instruction exactly.");
+ });
+
+ it("keeps other providers unchanged", () => {
+ expect(
+ resolveLiveSystemPrompt({
+ provider: "openai",
+ } as Model),
+ ).toBeUndefined();
+ });
+});
+
async function completeSimpleWithTimeout(
model: Model,
context: Parameters>[1],
@@ -246,6 +272,7 @@ async function completeOkWithRetry(params: {
const res = await completeSimpleWithTimeout(
params.model,
{
+ systemPrompt: resolveLiveSystemPrompt(params.model),
messages: [
{
role: "user",
@@ -317,6 +344,9 @@ describeLive("live models (profile keys)", () => {
}> = [];
for (const model of models) {
+ if (shouldSuppressBuiltInModel({ provider: model.provider, id: model.id })) {
+ continue;
+ }
if (providers && !providers.has(model.provider)) {
continue;
}
diff --git a/src/agents/openai-ws-stream.ts b/src/agents/openai-ws-stream.ts
index 5b7a80f52ec..307812e6be5 100644
--- a/src/agents/openai-ws-stream.ts
+++ b/src/agents/openai-ws-stream.ts
@@ -797,7 +797,7 @@ export function createOpenAIWebSocketStreamFn(
...(prevResponseId ? { previous_response_id: prevResponseId } : {}),
...extraParams,
};
- const nextPayload = await options?.onPayload?.(payload, model);
+ const nextPayload = options?.onPayload?.(payload, model);
const requestPayload = (nextPayload ?? payload) as Parameters<
OpenAIWebSocketManager["send"]
>[0];
diff --git a/src/agents/openclaw-tools.owner-authorization.test.ts b/src/agents/openclaw-tools.owner-authorization.test.ts
new file mode 100644
index 00000000000..47892235bb6
--- /dev/null
+++ b/src/agents/openclaw-tools.owner-authorization.test.ts
@@ -0,0 +1,22 @@
+import { describe, expect, it } from "vitest";
+import "./test-helpers/fast-core-tools.js";
+import { createOpenClawTools } from "./openclaw-tools.js";
+
+function readToolByName() {
+ return new Map(createOpenClawTools().map((tool) => [tool.name, tool]));
+}
+
+describe("createOpenClawTools owner authorization", () => {
+ it("marks owner-only core tools in raw registration", () => {
+ const tools = readToolByName();
+ expect(tools.get("cron")?.ownerOnly).toBe(true);
+ expect(tools.get("gateway")?.ownerOnly).toBe(true);
+ expect(tools.get("nodes")?.ownerOnly).toBe(true);
+ });
+
+ it("keeps canvas non-owner-only in raw registration", () => {
+ const tools = readToolByName();
+ expect(tools.get("canvas")).toBeDefined();
+ expect(tools.get("canvas")?.ownerOnly).not.toBe(true);
+ });
+});
diff --git a/src/agents/openclaw-tools.session-status.test.ts b/src/agents/openclaw-tools.session-status.test.ts
index db45e8d48b8..8b2d9fc467f 100644
--- a/src/agents/openclaw-tools.session-status.test.ts
+++ b/src/agents/openclaw-tools.session-status.test.ts
@@ -2,6 +2,23 @@ import { describe, expect, it, vi } from "vitest";
const loadSessionStoreMock = vi.fn();
const updateSessionStoreMock = vi.fn();
+const callGatewayMock = vi.fn();
+const loadCombinedSessionStoreForGatewayMock = vi.fn();
+
+const createMockConfig = () => ({
+ session: { mainKey: "main", scope: "per-sender" },
+ agents: {
+ defaults: {
+ model: { primary: "anthropic/claude-opus-4-5" },
+ models: {},
+ },
+ },
+ tools: {
+ agentToAgent: { enabled: false },
+ },
+});
+
+let mockConfig: Record = createMockConfig();
vi.mock("../config/sessions.js", async (importOriginal) => {
const actual = await importOriginal();
@@ -22,19 +39,24 @@ vi.mock("../config/sessions.js", async (importOriginal) => {
};
});
+vi.mock("../gateway/call.js", () => ({
+ callGateway: (opts: unknown) => callGatewayMock(opts),
+}));
+
+vi.mock("../gateway/session-utils.js", async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ loadCombinedSessionStoreForGateway: (cfg: unknown) =>
+ loadCombinedSessionStoreForGatewayMock(cfg),
+ };
+});
+
vi.mock("../config/config.js", async (importOriginal) => {
const actual = await importOriginal();
return {
...actual,
- loadConfig: () => ({
- session: { mainKey: "main", scope: "per-sender" },
- agents: {
- defaults: {
- model: { primary: "anthropic/claude-opus-4-5" },
- models: {},
- },
- },
- }),
+ loadConfig: () => mockConfig,
};
});
@@ -82,13 +104,22 @@ import { createOpenClawTools } from "./openclaw-tools.js";
function resetSessionStore(store: Record) {
loadSessionStoreMock.mockClear();
updateSessionStoreMock.mockClear();
+ callGatewayMock.mockClear();
+ loadCombinedSessionStoreForGatewayMock.mockClear();
loadSessionStoreMock.mockReturnValue(store);
+ loadCombinedSessionStoreForGatewayMock.mockReturnValue({
+ storePath: "(multiple)",
+ store,
+ });
+ callGatewayMock.mockResolvedValue({});
+ mockConfig = createMockConfig();
}
-function getSessionStatusTool(agentSessionKey = "main") {
- const tool = createOpenClawTools({ agentSessionKey }).find(
- (candidate) => candidate.name === "session_status",
- );
+function getSessionStatusTool(agentSessionKey = "main", options?: { sandboxed?: boolean }) {
+ const tool = createOpenClawTools({
+ agentSessionKey,
+ sandboxed: options?.sandboxed,
+ }).find((candidate) => candidate.name === "session_status");
expect(tool).toBeDefined();
if (!tool) {
throw new Error("missing session_status tool");
@@ -145,6 +176,30 @@ describe("session_status tool", () => {
expect(details.sessionKey).toBe("agent:main:main");
});
+ it("resolves duplicate sessionId inputs deterministically", async () => {
+ resetSessionStore({
+ "agent:main:main": {
+ sessionId: "current",
+ updatedAt: 10,
+ },
+ "agent:main:other": {
+ sessionId: "run-dup",
+ updatedAt: 999,
+ },
+ "agent:main:acp:run-dup": {
+ sessionId: "run-dup",
+ updatedAt: 100,
+ },
+ });
+
+ const tool = getSessionStatusTool();
+
+ const result = await tool.execute("call-dup", { sessionKey: "run-dup" });
+ const details = result.details as { ok?: boolean; sessionKey?: string };
+ expect(details.ok).toBe(true);
+ expect(details.sessionKey).toBe("agent:main:acp:run-dup");
+ });
+
it("uses non-standard session keys without sessionId resolution", async () => {
resetSessionStore({
"temp:slug-generator": {
@@ -176,6 +231,153 @@ describe("session_status tool", () => {
);
});
+ it("blocks sandboxed child session_status access outside its tree before store lookup", async () => {
+ resetSessionStore({
+ "agent:main:subagent:child": {
+ sessionId: "s-child",
+ updatedAt: 20,
+ },
+ "agent:main:main": {
+ sessionId: "s-parent",
+ updatedAt: 10,
+ },
+ });
+ mockConfig = {
+ session: { mainKey: "main", scope: "per-sender" },
+ tools: {
+ sessions: { visibility: "all" },
+ agentToAgent: { enabled: true, allow: ["*"] },
+ },
+ agents: {
+ defaults: {
+ model: { primary: "anthropic/claude-opus-4-5" },
+ models: {},
+ sandbox: { sessionToolsVisibility: "spawned" },
+ },
+ },
+ };
+ callGatewayMock.mockImplementation(async (opts: unknown) => {
+ const request = opts as { method?: string; params?: Record };
+ if (request.method === "sessions.list") {
+ return { sessions: [] };
+ }
+ return {};
+ });
+
+ const tool = getSessionStatusTool("agent:main:subagent:child", {
+ sandboxed: true,
+ });
+ const expectedError = "Session status visibility is restricted to the current session tree";
+
+ await expect(
+ tool.execute("call6", {
+ sessionKey: "agent:main:main",
+ model: "anthropic/claude-sonnet-4-5",
+ }),
+ ).rejects.toThrow(expectedError);
+
+ await expect(
+ tool.execute("call7", {
+ sessionKey: "agent:main:subagent:missing",
+ }),
+ ).rejects.toThrow(expectedError);
+
+ expect(loadSessionStoreMock).not.toHaveBeenCalled();
+ expect(updateSessionStoreMock).not.toHaveBeenCalled();
+ expect(callGatewayMock).toHaveBeenCalledTimes(2);
+ expect(callGatewayMock).toHaveBeenNthCalledWith(1, {
+ method: "sessions.list",
+ params: {
+ includeGlobal: false,
+ includeUnknown: false,
+ limit: 500,
+ spawnedBy: "agent:main:subagent:child",
+ },
+ });
+ expect(callGatewayMock).toHaveBeenNthCalledWith(2, {
+ method: "sessions.list",
+ params: {
+ includeGlobal: false,
+ includeUnknown: false,
+ limit: 500,
+ spawnedBy: "agent:main:subagent:child",
+ },
+ });
+ });
+
+ it("keeps legacy main requester keys for sandboxed session tree checks", async () => {
+ resetSessionStore({
+ "agent:main:main": {
+ sessionId: "s-main",
+ updatedAt: 10,
+ },
+ "agent:main:subagent:child": {
+ sessionId: "s-child",
+ updatedAt: 20,
+ },
+ });
+ mockConfig = {
+ session: { mainKey: "main", scope: "per-sender" },
+ tools: {
+ sessions: { visibility: "all" },
+ agentToAgent: { enabled: true, allow: ["*"] },
+ },
+ agents: {
+ defaults: {
+ model: { primary: "anthropic/claude-opus-4-5" },
+ models: {},
+ sandbox: { sessionToolsVisibility: "spawned" },
+ },
+ },
+ };
+ callGatewayMock.mockImplementation(async (opts: unknown) => {
+ const request = opts as { method?: string; params?: Record };
+ if (request.method === "sessions.list") {
+ return {
+ sessions:
+ request.params?.spawnedBy === "main" ? [{ key: "agent:main:subagent:child" }] : [],
+ };
+ }
+ return {};
+ });
+
+ const tool = getSessionStatusTool("main", {
+ sandboxed: true,
+ });
+
+ const mainResult = await tool.execute("call8", {});
+ const mainDetails = mainResult.details as { ok?: boolean; sessionKey?: string };
+ expect(mainDetails.ok).toBe(true);
+ expect(mainDetails.sessionKey).toBe("agent:main:main");
+
+ const childResult = await tool.execute("call9", {
+ sessionKey: "agent:main:subagent:child",
+ });
+ const childDetails = childResult.details as { ok?: boolean; sessionKey?: string };
+ expect(childDetails.ok).toBe(true);
+ expect(childDetails.sessionKey).toBe("agent:main:subagent:child");
+
+ expect(callGatewayMock).toHaveBeenCalledTimes(2);
+ expect(callGatewayMock).toHaveBeenNthCalledWith(1, {
+ method: "sessions.list",
+ params: {
+ includeGlobal: false,
+ includeUnknown: false,
+ limit: 500,
+ spawnedBy: "main",
+ },
+ });
+ expect(callGatewayMock).toHaveBeenNthCalledWith(2, {
+ method: "sessions.list",
+ params: {
+ includeGlobal: false,
+ includeUnknown: false,
+ limit: 500,
+ spawnedBy: "main",
+ },
+ });
+ });
+
it("scopes bare session keys to the requester agent", async () => {
loadSessionStoreMock.mockClear();
updateSessionStoreMock.mockClear();
diff --git a/src/agents/openclaw-tools.subagents.sessions-spawn-depth-limits.test.ts b/src/agents/openclaw-tools.subagents.sessions-spawn-depth-limits.test.ts
index b9c86bf7472..34fcbfbafd4 100644
--- a/src/agents/openclaw-tools.subagents.sessions-spawn-depth-limits.test.ts
+++ b/src/agents/openclaw-tools.subagents.sessions-spawn-depth-limits.test.ts
@@ -85,7 +85,10 @@ describe("sessions_spawn depth + child limits", () => {
});
it("rejects spawning when caller depth reaches maxSpawnDepth", async () => {
- const tool = createSessionsSpawnTool({ agentSessionKey: "agent:main:subagent:parent" });
+ const tool = createSessionsSpawnTool({
+ agentSessionKey: "agent:main:subagent:parent",
+ workspaceDir: "/parent/workspace",
+ });
const result = await tool.execute("call-depth-reject", { task: "hello" });
expect(result.details).toMatchObject({
@@ -109,8 +112,13 @@ describe("sessions_spawn depth + child limits", () => {
const calls = callGatewayMock.mock.calls.map(
(call) => call[0] as { method?: string; params?: Record },
);
- const agentCall = calls.find((entry) => entry.method === "agent");
- expect(agentCall?.params?.spawnedBy).toBe("agent:main:subagent:parent");
+ const spawnedByPatch = calls.find(
+ (entry) =>
+ entry.method === "sessions.patch" &&
+ entry.params?.spawnedBy === "agent:main:subagent:parent",
+ );
+ expect(spawnedByPatch?.params?.key).toMatch(/^agent:main:subagent:/);
+ expect(typeof spawnedByPatch?.params?.spawnedWorkspaceDir).toBe("string");
const spawnDepthPatch = calls.find(
(entry) => entry.method === "sessions.patch" && entry.params?.spawnDepth === 2,
diff --git a/src/agents/openclaw-tools.ts b/src/agents/openclaw-tools.ts
index 8473e4a06e8..58b3570eb89 100644
--- a/src/agents/openclaw-tools.ts
+++ b/src/agents/openclaw-tools.ts
@@ -21,6 +21,7 @@ import { createSessionsHistoryTool } from "./tools/sessions-history-tool.js";
import { createSessionsListTool } from "./tools/sessions-list-tool.js";
import { createSessionsSendTool } from "./tools/sessions-send-tool.js";
import { createSessionsSpawnTool } from "./tools/sessions-spawn-tool.js";
+import { createSessionsYieldTool } from "./tools/sessions-yield-tool.js";
import { createSubagentsTool } from "./tools/subagents-tool.js";
import { createTtsTool } from "./tools/tts-tool.js";
import { createWebFetchTool, createWebSearchTool } from "./tools/web-tools.js";
@@ -77,6 +78,8 @@ export function createOpenClawTools(
* subagents inherit the real workspace path instead of the sandbox copy.
*/
spawnWorkspaceDir?: string;
+ /** Callback invoked when sessions_yield tool is called. */
+ onYield?: (message: string) => Promise | void;
} & SpawnedToolContext,
): AnyAgentTool[] {
const workspaceDir = resolveWorkspaceRoot(options?.workspaceDir);
@@ -181,6 +184,10 @@ export function createOpenClawTools(
agentChannel: options?.agentChannel,
sandboxed: options?.sandboxed,
}),
+ createSessionsYieldTool({
+ sessionId: options?.sessionId,
+ onYield: options?.onYield,
+ }),
createSessionsSpawnTool({
agentSessionKey: options?.agentSessionKey,
agentChannel: options?.agentChannel,
@@ -200,6 +207,7 @@ export function createOpenClawTools(
createSessionStatusTool({
agentSessionKey: options?.agentSessionKey,
config: options?.config,
+ sandboxed: options?.sandboxed,
}),
...(webSearchTool ? [webSearchTool] : []),
...(webFetchTool ? [webFetchTool] : []),
diff --git a/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts b/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts
index 27c89afe425..3cbefadbce8 100644
--- a/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts
+++ b/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import {
classifyFailoverReason,
classifyFailoverReasonFromHttpStatus,
+ extractObservedOverflowTokenCount,
isAuthErrorMessage,
isAuthPermanentErrorMessage,
isBillingErrorMessage,
@@ -109,6 +110,9 @@ describe("isBillingErrorMessage", () => {
// Venice returns "Insufficient USD or Diem balance" which has extra words
// between "insufficient" and "balance"
"Insufficient USD or Diem balance to complete request. Visit https://venice.ai/settings/api to add credits.",
+ // OpenRouter returns "requires more credits" for underfunded accounts
+ "This model requires more credits to use",
+ "This endpoint require more credits",
];
for (const sample of samples) {
expect(isBillingErrorMessage(sample)).toBe(true);
@@ -461,6 +465,29 @@ describe("isLikelyContextOverflowError", () => {
});
});
+describe("extractObservedOverflowTokenCount", () => {
+ it("extracts provider-reported prompt token counts", () => {
+ expect(
+ extractObservedOverflowTokenCount(
+ '400 {"type":"error","error":{"message":"prompt is too long: 277403 tokens > 200000 maximum"}}',
+ ),
+ ).toBe(277403);
+ expect(
+ extractObservedOverflowTokenCount("Context window exceeded: requested 12000 tokens"),
+ ).toBe(12000);
+ expect(
+ extractObservedOverflowTokenCount(
+ "This model's maximum context length is 128000 tokens. However, your messages resulted in 145000 tokens.",
+ ),
+ ).toBe(145000);
+ });
+
+ it("returns undefined when overflow counts are not present", () => {
+ expect(extractObservedOverflowTokenCount("Prompt too large for this model")).toBeUndefined();
+ expect(extractObservedOverflowTokenCount("rate limit exceeded")).toBeUndefined();
+ });
+});
+
describe("isTransientHttpError", () => {
it("returns true for retryable 5xx status codes", () => {
expect(isTransientHttpError("499 Client Closed Request")).toBe(true);
@@ -479,6 +506,18 @@ describe("isTransientHttpError", () => {
});
describe("classifyFailoverReasonFromHttpStatus", () => {
+ it("treats HTTP 422 as format error", () => {
+ expect(classifyFailoverReasonFromHttpStatus(422)).toBe("format");
+ expect(classifyFailoverReasonFromHttpStatus(422, "check open ai req parameter error")).toBe(
+ "format",
+ );
+ expect(classifyFailoverReasonFromHttpStatus(422, "Unprocessable Entity")).toBe("format");
+ });
+
+ it("treats 422 with billing message as billing instead of format", () => {
+ expect(classifyFailoverReasonFromHttpStatus(422, "insufficient credits")).toBe("billing");
+ });
+
it("treats HTTP 499 as transient for structured errors", () => {
expect(classifyFailoverReasonFromHttpStatus(499)).toBe("timeout");
expect(classifyFailoverReasonFromHttpStatus(499, "499 Client Closed Request")).toBe("timeout");
@@ -535,6 +574,36 @@ describe("isFailoverErrorMessage", () => {
}
});
+ it("matches network errno codes in serialized error messages", () => {
+ const samples = [
+ "Error: connect ETIMEDOUT 10.0.0.1:443",
+ "Error: connect ESOCKETTIMEDOUT 10.0.0.1:443",
+ "Error: connect EHOSTUNREACH 10.0.0.1:443",
+ "Error: connect ENETUNREACH 10.0.0.1:443",
+ "Error: write EPIPE",
+ "Error: read ENETRESET",
+ "Error: connect EHOSTDOWN 192.168.1.1:443",
+ ];
+ for (const sample of samples) {
+ expect(isTimeoutErrorMessage(sample)).toBe(true);
+ expect(classifyFailoverReason(sample)).toBe("timeout");
+ expect(isFailoverErrorMessage(sample)).toBe(true);
+ }
+ });
+
+ it("matches z.ai network_error stop reason as timeout", () => {
+ const samples = [
+ "Unhandled stop reason: network_error",
+ "stop reason: network_error",
+ "reason: network_error",
+ ];
+ for (const sample of samples) {
+ expect(isTimeoutErrorMessage(sample)).toBe(true);
+ expect(classifyFailoverReason(sample)).toBe("timeout");
+ expect(isFailoverErrorMessage(sample)).toBe(true);
+ }
+ });
+
it("does not classify MALFORMED_FUNCTION_CALL as timeout", () => {
const sample = "Unhandled stop reason: MALFORMED_FUNCTION_CALL";
expect(isTimeoutErrorMessage(sample)).toBe(false);
@@ -664,6 +733,8 @@ describe("classifyFailoverReason", () => {
"Insufficient USD or Diem balance to complete request. Visit https://venice.ai/settings/api to add credits.",
),
).toBe("billing");
+ // OpenRouter "requires more credits" billing text
+ expect(classifyFailoverReason("This model requires more credits to use")).toBe("billing");
});
it("classifies internal and compatibility error messages", () => {
diff --git a/src/agents/pi-embedded-helpers.ts b/src/agents/pi-embedded-helpers.ts
index 53f21814492..77ae492bc32 100644
--- a/src/agents/pi-embedded-helpers.ts
+++ b/src/agents/pi-embedded-helpers.ts
@@ -22,6 +22,7 @@ export {
isAuthPermanentErrorMessage,
isModelNotFoundErrorMessage,
isBillingAssistantError,
+ extractObservedOverflowTokenCount,
parseApiErrorInfo,
sanitizeUserFacingText,
isBillingErrorMessage,
diff --git a/src/agents/pi-embedded-helpers/errors.ts b/src/agents/pi-embedded-helpers/errors.ts
index e9bfd92951e..6e38d831ad9 100644
--- a/src/agents/pi-embedded-helpers/errors.ts
+++ b/src/agents/pi-embedded-helpers/errors.ts
@@ -185,6 +185,32 @@ export function isCompactionFailureError(errorMessage?: string): boolean {
return lower.includes("context overflow");
}
+const OBSERVED_OVERFLOW_TOKEN_PATTERNS = [
+ /prompt is too long:\s*([\d,]+)\s+tokens\s*>\s*[\d,]+\s+maximum/i,
+ /requested\s+([\d,]+)\s+tokens/i,
+ /resulted in\s+([\d,]+)\s+tokens/i,
+];
+
+export function extractObservedOverflowTokenCount(errorMessage?: string): number | undefined {
+ if (!errorMessage) {
+ return undefined;
+ }
+
+ for (const pattern of OBSERVED_OVERFLOW_TOKEN_PATTERNS) {
+ const match = errorMessage.match(pattern);
+ const rawCount = match?.[1]?.replaceAll(",", "");
+ if (!rawCount) {
+ continue;
+ }
+ const parsed = Number(rawCount);
+ if (Number.isFinite(parsed) && parsed > 0) {
+ return Math.floor(parsed);
+ }
+ }
+
+ return undefined;
+}
+
const ERROR_PAYLOAD_PREFIX_RE =
/^(?:error|api\s*error|apierror|openai\s*error|anthropic\s*error|gateway\s*error)[:\s-]+/i;
const FINAL_TAG_RE = /<\s*\/?\s*final\s*>/gi;
@@ -262,6 +288,13 @@ function hasExplicit402BillingSignal(text: string): boolean {
);
}
+function hasQuotaRefreshWindowSignal(text: string): boolean {
+ return (
+ text.includes("subscription quota limit") &&
+ (text.includes("automatic quota refresh") || text.includes("rolling time window"))
+ );
+}
+
function hasRetryable402TransientSignal(text: string): boolean {
const hasPeriodicHint = includesAnyHint(text, PERIODIC_402_HINTS);
const hasSpendLimit = text.includes("spend limit") || text.includes("spending limit");
@@ -287,6 +320,10 @@ function classify402Message(message: string): PaymentRequiredFailoverReason {
return "billing";
}
+ if (hasQuotaRefreshWindowSignal(normalized)) {
+ return "rate_limit";
+ }
+
if (hasExplicit402BillingSignal(normalized)) {
return "billing";
}
@@ -394,7 +431,7 @@ export function classifyFailoverReasonFromHttpStatus(
if (status === 529) {
return "overloaded";
}
- if (status === 400) {
+ if (status === 400 || status === 422) {
// Some providers return quota/balance errors under HTTP 400, so do not
// let the generic format fallback mask an explicit billing signal.
if (message && isBillingErrorMessage(message)) {
diff --git a/src/agents/pi-embedded-helpers/failover-matches.ts b/src/agents/pi-embedded-helpers/failover-matches.ts
index a9f16fa6202..9f6e83e9461 100644
--- a/src/agents/pi-embedded-helpers/failover-matches.ts
+++ b/src/agents/pi-embedded-helpers/failover-matches.ts
@@ -37,12 +37,19 @@ const ERROR_PATTERNS = {
"fetch failed",
"socket hang up",
/\beconn(?:refused|reset|aborted)\b/i,
+ /\benetunreach\b/i,
+ /\behostunreach\b/i,
+ /\behostdown\b/i,
+ /\benetreset\b/i,
+ /\betimedout\b/i,
+ /\besockettimedout\b/i,
+ /\bepipe\b/i,
/\benotfound\b/i,
/\beai_again\b/i,
/without sending (?:any )?chunks?/i,
- /\bstop reason:\s*(?:abort|error|malformed_response)\b/i,
- /\breason:\s*(?:abort|error|malformed_response)\b/i,
- /\bunhandled stop reason:\s*(?:abort|error|malformed_response)\b/i,
+ /\bstop reason:\s*(?:abort|error|malformed_response|network_error)\b/i,
+ /\breason:\s*(?:abort|error|malformed_response|network_error)\b/i,
+ /\bunhandled stop reason:\s*(?:abort|error|malformed_response|network_error)\b/i,
],
billing: [
/["']?(?:status|code)["']?\s*[:=]\s*402\b|\bhttp\s*402\b|\berror(?:\s+code)?\s*[:=]?\s*402\b|\b(?:got|returned|received)\s+(?:a\s+)?402\b|^\s*402\s+payment/i,
@@ -53,6 +60,7 @@ const ERROR_PATTERNS = {
"plans & billing",
"insufficient balance",
"insufficient usd or diem balance",
+ /requires?\s+more\s+credits/i,
],
authPermanent: [
/api[_ ]?key[_ ]?(?:revoked|invalid|deactivated|deleted)/i,
diff --git a/src/agents/pi-embedded-runner-extraparams.live.test.ts b/src/agents/pi-embedded-runner-extraparams.live.test.ts
index 4116476c71f..22ccccdcac6 100644
--- a/src/agents/pi-embedded-runner-extraparams.live.test.ts
+++ b/src/agents/pi-embedded-runner-extraparams.live.test.ts
@@ -6,12 +6,16 @@ import { isTruthyEnvValue } from "../infra/env.js";
import { applyExtraParamsToAgent } from "./pi-embedded-runner.js";
const OPENAI_KEY = process.env.OPENAI_API_KEY ?? "";
+const ANTHROPIC_KEY = process.env.ANTHROPIC_API_KEY ?? "";
const GEMINI_KEY = process.env.GEMINI_API_KEY ?? "";
const LIVE = isTruthyEnvValue(process.env.OPENAI_LIVE_TEST) || isTruthyEnvValue(process.env.LIVE);
+const ANTHROPIC_LIVE =
+ isTruthyEnvValue(process.env.ANTHROPIC_LIVE_TEST) || isTruthyEnvValue(process.env.LIVE);
const GEMINI_LIVE =
isTruthyEnvValue(process.env.GEMINI_LIVE_TEST) || isTruthyEnvValue(process.env.LIVE);
const describeLive = LIVE && OPENAI_KEY ? describe : describe.skip;
+const describeAnthropicLive = ANTHROPIC_LIVE && ANTHROPIC_KEY ? describe : describe.skip;
const describeGeminiLive = GEMINI_LIVE && GEMINI_KEY ? describe : describe.skip;
describeLive("pi embedded extra params (live)", () => {
@@ -65,6 +69,79 @@ describeLive("pi embedded extra params (live)", () => {
// Should respect maxTokens from config (16) — allow a small buffer for provider rounding.
expect(outputTokens ?? 0).toBeLessThanOrEqual(20);
}, 30_000);
+
+ it("verifies OpenAI fast-mode service_tier semantics against the live API", async () => {
+ const headers = {
+ "content-type": "application/json",
+ authorization: `Bearer ${OPENAI_KEY}`,
+ };
+
+ const runProbe = async (serviceTier: "default" | "priority") => {
+ const res = await fetch("https://api.openai.com/v1/responses", {
+ method: "POST",
+ headers,
+ body: JSON.stringify({
+ model: "gpt-5.4",
+ input: "Reply with OK.",
+ max_output_tokens: 32,
+ service_tier: serviceTier,
+ }),
+ });
+ const json = (await res.json()) as {
+ error?: { message?: string };
+ service_tier?: string;
+ status?: string;
+ };
+ expect(res.ok, json.error?.message ?? `HTTP ${res.status}`).toBe(true);
+ return json;
+ };
+
+ const standard = await runProbe("default");
+ expect(standard.service_tier).toBe("default");
+ expect(standard.status).toBe("completed");
+
+ const fast = await runProbe("priority");
+ expect(fast.service_tier).toBe("priority");
+ expect(fast.status).toBe("completed");
+ }, 45_000);
+});
+
+describeAnthropicLive("pi embedded extra params (anthropic live)", () => {
+ it("verifies Anthropic fast-mode service_tier semantics against the live API", async () => {
+ const headers = {
+ "content-type": "application/json",
+ "x-api-key": ANTHROPIC_KEY,
+ "anthropic-version": "2023-06-01",
+ };
+
+ const runProbe = async (serviceTier: "auto" | "standard_only") => {
+ const res = await fetch("https://api.anthropic.com/v1/messages", {
+ method: "POST",
+ headers,
+ body: JSON.stringify({
+ model: "claude-sonnet-4-5",
+ max_tokens: 32,
+ service_tier: serviceTier,
+ messages: [{ role: "user", content: "Reply with OK." }],
+ }),
+ });
+ const json = (await res.json()) as {
+ error?: { message?: string };
+ stop_reason?: string;
+ usage?: { service_tier?: string };
+ };
+ expect(res.ok, json.error?.message ?? `HTTP ${res.status}`).toBe(true);
+ return json;
+ };
+
+ const standard = await runProbe("standard_only");
+ expect(standard.usage?.service_tier).toBe("standard");
+ expect(standard.stop_reason).toBe("end_turn");
+
+ const fast = await runProbe("auto");
+ expect(["standard", "priority"]).toContain(fast.usage?.service_tier);
+ expect(fast.stop_reason).toBe("end_turn");
+ }, 45_000);
});
describeGeminiLive("pi embedded extra params (gemini live)", () => {
diff --git a/src/agents/pi-embedded-runner-extraparams.test.ts b/src/agents/pi-embedded-runner-extraparams.test.ts
index 3f6fb7a2f5a..7a29f30f9eb 100644
--- a/src/agents/pi-embedded-runner-extraparams.test.ts
+++ b/src/agents/pi-embedded-runner-extraparams.test.ts
@@ -201,9 +201,11 @@ describe("applyExtraParamsToAgent", () => {
model:
| Model<"openai-responses">
| Model<"openai-codex-responses">
- | Model<"openai-completions">;
+ | Model<"openai-completions">
+ | Model<"anthropic-messages">;
options?: SimpleStreamOptions;
cfg?: Record;
+ extraParamsOverride?: Record;
payload?: Record;
}) {
const payload = params.payload ?? { store: false };
@@ -217,6 +219,7 @@ describe("applyExtraParamsToAgent", () => {
params.cfg as Parameters[1],
params.applyProvider,
params.applyModelId,
+ params.extraParamsOverride,
);
const context: Context = { messages: [] };
void agent.streamFn?.(params.model, context, params.options ?? {});
@@ -276,7 +279,7 @@ describe("applyExtraParamsToAgent", () => {
const payloads: Record[] = [];
const baseStreamFn: StreamFn = (_model, _context, options) => {
const payload: Record = { model: "deepseek/deepseek-r1" };
- options?.onPayload?.(payload, model);
+ options?.onPayload?.(payload, _model);
payloads.push(payload);
return {} as ReturnType;
};
@@ -308,7 +311,7 @@ describe("applyExtraParamsToAgent", () => {
const payloads: Record[] = [];
const baseStreamFn: StreamFn = (_model, _context, options) => {
const payload: Record = {};
- options?.onPayload?.(payload, model);
+ options?.onPayload?.(payload, _model);
payloads.push(payload);
return {} as ReturnType;
};
@@ -332,7 +335,7 @@ describe("applyExtraParamsToAgent", () => {
const payloads: Record[] = [];
const baseStreamFn: StreamFn = (_model, _context, options) => {
const payload: Record = { reasoning_effort: "high" };
- options?.onPayload?.(payload, model);
+ options?.onPayload?.(payload, _model);
payloads.push(payload);
return {} as ReturnType;
};
@@ -357,7 +360,7 @@ describe("applyExtraParamsToAgent", () => {
const payloads: Record[] = [];
const baseStreamFn: StreamFn = (_model, _context, options) => {
const payload: Record = { reasoning: { max_tokens: 256 } };
- options?.onPayload?.(payload, model);
+ options?.onPayload?.(payload, _model);
payloads.push(payload);
return {} as ReturnType;
};
@@ -381,7 +384,7 @@ describe("applyExtraParamsToAgent", () => {
const payloads: Record[] = [];
const baseStreamFn: StreamFn = (_model, _context, options) => {
const payload: Record = { reasoning_effort: "medium" };
- options?.onPayload?.(payload, model);
+ options?.onPayload?.(payload, _model);
payloads.push(payload);
return {} as ReturnType;
};
@@ -588,7 +591,7 @@ describe("applyExtraParamsToAgent", () => {
const payloads: Record[] = [];
const baseStreamFn: StreamFn = (_model, _context, options) => {
const payload: Record = { thinking: "off" };
- options?.onPayload?.(payload, model);
+ options?.onPayload?.(payload, _model);
payloads.push(payload);
return {} as ReturnType;
};
@@ -619,7 +622,7 @@ describe("applyExtraParamsToAgent", () => {
const payloads: Record[] = [];
const baseStreamFn: StreamFn = (_model, _context, options) => {
const payload: Record = { thinking: "off" };
- options?.onPayload?.(payload, model);
+ options?.onPayload?.(payload, _model);
payloads.push(payload);
return {} as ReturnType;
};
@@ -650,7 +653,7 @@ describe("applyExtraParamsToAgent", () => {
const payloads: Record[] = [];
const baseStreamFn: StreamFn = (_model, _context, options) => {
const payload: Record = {};
- options?.onPayload?.(payload, model);
+ options?.onPayload?.(payload, _model);
payloads.push(payload);
return {} as ReturnType;
};
@@ -674,7 +677,7 @@ describe("applyExtraParamsToAgent", () => {
const payloads: Record[] = [];
const baseStreamFn: StreamFn = (_model, _context, options) => {
const payload: Record = { tool_choice: "required" };
- options?.onPayload?.(payload, model);
+ options?.onPayload?.(payload, _model);
payloads.push(payload);
return {} as ReturnType;
};
@@ -695,11 +698,38 @@ describe("applyExtraParamsToAgent", () => {
expect(payloads[0]?.tool_choice).toBe("auto");
});
+ it("disables thinking instead of broadening pinned Moonshot tool_choice", () => {
+ const payloads: Record[] = [];
+ const baseStreamFn: StreamFn = (_model, _context, options) => {
+ const payload: Record = {
+ tool_choice: { type: "tool", name: "read" },
+ };
+ options?.onPayload?.(payload, _model);
+ payloads.push(payload);
+ return {} as ReturnType