Compare commits

..

4 Commits

Author SHA1 Message Date
Agustin Rivera
ded626d171 fix(browser): thread ssrf policy through file-chooser hook click 2026-04-06 18:28:21 +00:00
Agustin Rivera
2c8697b694 fix(browser): run post-interaction SSRF guard unconditionally 2026-04-06 18:03:12 +00:00
Agustin Rivera
f3719cef5e fix(browser): thread ssrf policy through batches 2026-04-06 17:50:16 +00:00
Agustin Rivera
08019bbda0 fix(browser): guard interaction-driven navigations 2026-04-06 16:44:49 +00:00
2379 changed files with 34684 additions and 67906 deletions

View File

@@ -39,6 +39,7 @@ pnpm openclaw qa suite \
--provider-mode live-openai \
--model openai/gpt-5.4 \
--alt-model openai/gpt-5.4 \
--fast \
--output-dir .artifacts/qa-e2e/run-all-live-openai-<tag>
```

4
.github/labeler.yml vendored
View File

@@ -257,10 +257,6 @@
- changed-files:
- any-glob-to-any-file:
- "extensions/acpx/**"
"extensions: arcee":
- changed-files:
- any-glob-to-any-file:
- "extensions/arcee/**"
"extensions: byteplus":
- changed-files:
- any-glob-to-any-file:

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

View File

@@ -6,82 +6,57 @@ Docs: https://docs.openclaw.ai
### Changes
- Providers/Anthropic: restore Claude CLI as the preferred local Anthropic path in onboarding, model-auth guidance, and doctor flows again, and keep the Docker Claude CLI live lane aligned with the restored guidance.
- Plugins/webhooks: add a bundled webhook ingress plugin so external automation can create and drive bound TaskFlows through per-route shared-secret endpoints. (#61892) Thanks @mbelinky.
- Tools/media generation: preserve intent across auth-backed image, music, and video provider fallback, remap size, aspect ratio, resolution, and duration hints to the closest supported option, and surface explicit provider capabilities plus mode-aware video-to-video support.
- Memory/wiki: restore the bundled `memory-wiki` stack with plugin, CLI, sync/query/apply tooling, and memory-host integration for wiki-backed memory workflows.
- Providers/Arcee AI: add a bundled Arcee AI provider plugin with Trinity catalog entries, OpenRouter support, and updated onboarding/auth guidance. (#62068) Thanks @arthurbr11.
- Providers/Google: add Gemma 4 model support and keep Google fallback resolution on the requested provider path so native Google Gemma routes work again. (#61507) Thanks @eyjohn.
- Providers/Anthropic: restore Claude CLI as the preferred local Anthropic path in onboarding, model-auth guidance, doctor flows, and Docker Claude CLI live lanes again.
- ACP/ACPX plugin: bump the bundled `acpx` pin to `0.5.1` so plugin-local installs and strict version checks pick up the latest published runtime release. (#62148) Thanks @onutc.
- Tools/media generation: auto-fallback across auth-backed image, music, and video providers by default, and remap fallback size, aspect ratio, resolution, and duration hints to the closest supported option instead of dropping intent on provider switches.
- Tools/media generation: report applied fallback geometry and duration settings consistently in tool results, add a shared normalization contract for image/music/video runtimes, and simplify the bundled image-generation-core runtime test to only verify the plugin-sdk re-export seam.
- Gateway/sessions: add persisted compaction checkpoints plus Sessions UI branch/restore actions so operators can inspect and recover pre-compaction session state. (#62146) Thanks @scoootscooob.
- Providers/Ollama: detect vision capability from the `/api/show` response and set image input on models that support it so Ollama vision models accept image attachments. (#62193) Thanks @BruceMacD.
- Memory/dreaming: ingest redacted session transcripts into the dreaming corpus with per-day session-corpus notes, cursor checkpointing, and promotion/doctor support. (#62227) Thanks @vignesh07.
- Plugins/memory: add a public memory-artifact export seam to the unified memory capability so companion plugins like `memory-wiki` can bridge the active memory plugin without reaching into `memory-core` internals. Thanks @vincentkoc.
- Memory/wiki: add structured claim/evidence fields plus compiled agent digest artifacts so `memory-wiki` behaves more like a persistent knowledge layer and less like markdown-only page storage. Thanks @vincentkoc.
- Memory/wiki: add claim-health linting, contradiction clustering, staleness-aware dashboards, and freshness-weighted wiki search so `memory-wiki` can act more like a maintained belief layer than a passive markdown dump. Thanks @vincentkoc.
- Memory/wiki: use compiled digest artifacts as the first-pass wiki index for search/get flows, and resolve claim ids back to owning pages so agents can retrieve knowledge by belief identity instead of only by file path. Thanks @vincentkoc.
- Memory/wiki: add an opt-in `context.includeCompiledDigestPrompt` flag so memory prompt supplements can append a compact compiled wiki snapshot for legacy prompt assembly and context engines that explicitly consume memory prompt sections. Thanks @vincentkoc.
- Memory/wiki: add task-backed `wiki import` with automatic local-file and markdown-vault detection so existing note stores can be backfilled into source pages with shared task progress instead of ad hoc one-off ingest flows. Thanks @vincentkoc.
- Memory/wiki: keep imported Obsidian and Logseq notes readable by preserving markdown note bodies plus imported tags, aliases, and link hints instead of flattening every vault note into a fenced text blob. Thanks @vincentkoc.
- Memory/wiki: use imported vault tags, aliases, and link hints in the compiled digest and wiki search ranking so imported Obsidian and Logseq notes are easier to recall by their original note metadata. Thanks @vincentkoc.
- Memory/wiki: use imported vault aliases and link hints when building `## Related` backlinks so imported Obsidian and Logseq notes can reconnect their note graph after import. Thanks @vincentkoc.
- Memory/wiki: let `wiki_get` and metadata updates resolve imported vault titles and aliases directly, so imported notes stay addressable by their original note names instead of only generated paths. Thanks @vincentkoc.
- Memory/wiki: upgrade `reports/import-review.md` to flag duplicate imported titles and aliases plus obviously low-signal notes, so large vault imports are easier to triage before promotion or synthesis work. Thanks @vincentkoc.
- Memory/wiki: add duplicate-body clustering to `reports/import-review.md` so large vault imports can surface copied or renamed notes even when titles and aliases differ. Thanks @vincentkoc.
- Memory/wiki: preserve imported markdown vault relative paths in digest, lookup, and related-link reconstruction so imported note identity survives search and `wiki_get`. Thanks @vincentkoc.
- Memory/wiki: auto-detect and import ChatGPT export JSON files as conversation source pages instead of misclassifying them as generic local files. Thanks @vincentkoc.
- Plugin SDK/context engines: pass `availableTools` and `citationsMode` into `assemble()`, and expose `buildMemorySystemPromptAddition(...)` so non-legacy context engines can adopt the active memory prompt path without reimplementing it. Thanks @vincentkoc.
- Tools/media: document per-provider music and video generation capabilities, and add shared live video-to-video sweep coverage for providers that support local reference clips.
### Fixes
- Plugins/media: when `plugins.allow` is set, capability fallback now merges bundled capability plugin ids into the allowlist (not only `plugins.entries`), so media understanding providers such as OpenAI-compatible STT load for voice transcription without requiring `openai` in `plugins.allow`. (#62205) Thanks @neeravmakwana.
- Auth/OpenAI Codex OAuth: reload fresh on-disk credentials inside the locked refresh path and retry once after `refresh_token_reused` rotates only the stored refresh token, so relogin/restart recovery stops getting stuck on stale cached auth state. Thanks @owen-ever.
- Agents/history and replies: buffer phaseless OpenAI WS text until a real assistant phase arrives, keep replay and SSE history sequence tracking aligned, hide commentary and leaked tool XML from user-visible history, and keep history-based follow-up replies on `final_answer` text only. (#61729, #61747, #61829, #61855, #61954) Thanks @100yenadmin, @afurm, and @openperf.
- Plugins/channels: keep bundled channel artifact and secret-contract loading stable under lazy loading, preserve plugin-schema defaults during install, and fix Windows `file://` plus native-Jiti plugin loader paths so onboarding, doctor, `openclaw secret`, and bundled plugin installs work again. (#61832, #61836, #61853, #61856) Thanks @Zeesejo and @SuperMarioYL.
- Auto-reply/media: allow managed generated-media `MEDIA:` paths from normal reply text again while still blocking arbitrary host-local media and document paths, so generated media keep delivering without reopening host-path injection holes.
- Runtime event trust: mark background `notifyOnExit` summaries, ACP parent-stream relays, and wake-hook payloads as untrusted system events so lower-trust runtime output no longer re-enters later turns as trusted `System:` text. (#62003)
- Providers/Anthropic: preserve thinking blocks for Claude Opus 4.5+, Sonnet 4.5+, and newer Claude 4-family models so prompt-cache prefixes keep matching, and skip `service_tier` injection on OAuth-authenticated stream wrapper requests so Claude OAuth streaming stops failing with HTTP 401. (#60356, #61793)
- Control UI: show `/tts` audio replies in webchat, detect mistaken `?token=` auth links with the correct `#token=` hint, and keep Copy, Canvas, and mobile exec-approval UI from covering chat content on narrow screens. (#54842, #61514, #61598) Thanks @neeravmakwana.
- TUI: route `/status` through the shared session-status command, keep commentary hidden in history, strip raw envelope metadata from async command notices, preserve fallback streaming before per-attempt failures finalize, and restore Kitty keyboard state on exit or fatal crashes. (#49130, #59985, #60043, #61463) Thanks @biefan, @MoerAI, @jwchmodx, and @100yenadmin.
- Sessions/model selection: resolve the explicitly selected session model separately from runtime fallback resolution so session status and live model switching stay aligned with the chosen model.
- Memory/wiki: follow `current_node` when importing ChatGPT export mapping trees so imported conversation transcripts stop pulling in stale alternate branches. Thanks @vincentkoc.
- Memory/wiki: extract readable text from object-shaped ChatGPT export message parts so imported conversation transcripts stop dropping rich content blocks. Thanks @vincentkoc.
- Memory/wiki: preserve conversation turn order for ChatGPT imports when timestamps are missing or tied, so imported transcripts stop scrambling equal-time messages and current-branch lineage. Thanks @vincentkoc.
- Memory/wiki: skip hidden and tool-role ChatGPT export messages during import so conversation source pages stop filling up with export-only scaffolding. Thanks @vincentkoc.
- Memory/wiki: skip ChatGPT export conversations that end up with no readable visible turns, so imports stop generating empty placeholder source pages from hidden/tool-only records. Thanks @vincentkoc.
- iOS/Watch exec approvals: keep Apple Watch review and approval recovery working while the iPhone is locked or backgrounded, including reconnect recovery, pending approval persistence, notification cleanup, and APNs-backed watch refresh recovery. (#61757) Thanks @ngutman.
- Agents/context overflow: combine oversized and aggregate tool-result recovery in one pass and restore a total-context overflow backstop so recoverable sessions retry instead of failing early. (#61651) Thanks @Takhoffman.
- Agents/exec: preserve explicit `host=node` routing under elevated defaults when `tools.exec.host=auto`, fail loud on invalid elevated cross-host overrides, and keep `strictInlineEval` commands blocked after approval timeouts instead of falling through to automatic execution. (#61739) Thanks @obviyus.
- Channels/secrets: keep bundled channel artifact and secret-contract loading stable under lazy loading so bundled channel secrets continue to appear in `openclaw secret`, status, and security-audit surfaces.
- Providers/xAI: recognize `api.grok.x.ai` as an xAI-native endpoint again so native xAI web-search attribution keeps working on Grok-hosted base URLs. (#61377) Thanks @jjjojoj.
- Providers/Anthropic/cache: preserve thinking blocks for Claude Opus 4.5+, Sonnet 4.5+, and newer Claude 4-family models so Anthropic prompt-cache prefixes keep matching after thinking turns. (#61793)
- Providers/Ollama: honor the selected provider's `baseUrl` during streaming so multi-Ollama setups stop routing every stream to the first configured Ollama endpoint. (#61678)
- Browser/remote CDP: retry the DevTools websocket once after remote browser restarts so healthy remote browser profiles do not fail availability checks during CDP warm-up. (#57397) Thanks @ThanhNguyxn07.
- Gateway/status and containers: auto-bind to `0.0.0.0` inside Docker and Podman environments, and probe local TLS gateways over `wss://` with self-signed fingerprint forwarding so container startup and loopback TLS status checks work again. (#61818, #61935) Thanks @openperf and @ThanhNguyxn07.
- macOS/gateway version: strip trailing commit metadata from CLI version output before semver parsing so the Mac app recognizes installed gateway versions like `OpenClaw 2026.4.2 (d74a122)` again. (#61111) Thanks @oliviareid-svg.
- Discord: recover forwarded referenced message text and attachments when snapshots are missing, use `ws://` again for gateway monitor sockets, stop forcing a hardcoded temperature for Codex-backed auto-thread titles, and harden voice receive recovery so rapid speaker restarts keep their next utterance. (#41536, #61670) Thanks @artwalker and @wit-oc.
- Slack/threading: keep legacy thread stickiness for real replies when older callers omit `isThreadReply`, while still honoring `replyToMode` for Slack's auto-created top-level `thread_ts`. (#61835) Thanks @kaonash.
- Providers/xAI: recognize `api.grok.x.ai` as an xAI-native endpoint again and keep legacy `x_search` auth resolution working so older xAI web-search configs continue to load. (#61377) Thanks @jjjojoj.
- Memory/vector recall: surface explicit warnings when `sqlite-vec` is unavailable or vector writes are degraded, and strip managed Light Sleep and REM blocks before daily-note ingestion so memory indexing and dreaming stop reporting false-success or re-ingesting staged output. (#61720) Thanks @MonkeyLeeT.
- Matrix/formatting: preserve multi-paragraph and loose-list rendering in Element so numbered and bulleted Markdown keeps their content attached to the correct list item. (#60997) Thanks @gucasbrg.
- Memory/vector recall: surface explicit warnings when `sqlite-vec` is unavailable or vector writes are degraded so memory indexing no longer reports false-success while semantic recall is impaired.
- MS Teams/security: validate file-consent upload URLs against HTTPS, Microsoft/SharePoint host allowlists, and private-IP DNS checks before uploading attachments, blocking SSRF-style consent-upload abuse. (#23596)
- QQ Bot/media: route gateway-side attachment and fallback downloads through guarded QQ/Tencent HTTPS fetches so QQ media handling no longer follows arbitrary remote hosts.
- Discord/gateway monitor: use `ws://` again for gateway monitor sockets so Discord monitor connections recover reliably after recent gateway socket changes.
- Control UI/auth URLs: detect mistaken `?token=` links, show the correct `#token=` fragment hint only on real auth failures, and stop masking the real problem behind a generic device-identity error. (#54842)
- Control UI/chat layout: keep Copy and Canvas actions plus mobile exec-approval overlays from covering chat text or command previews on narrow screens. (#61514)
- Matrix/formatting: preserve multi-paragraph and loose-list rendering in Element so numbered and bulleted Markdown keeps its content attached to the correct list item. (#60997) Thanks @gucasbrg.
- Sessions/model selection: resolve the explicitly selected session model separately from runtime fallback resolution so session status and live model switching stay aligned with the chosen model.
- Secrets/x_search: keep legacy `x_search` auth resolution working so older xAI web-search configs continue to load after the plugin-owned auth move.
- iOS/Watch exec approvals: keep Apple Watch review and approval recovery working while the iPhone is locked or backgrounded, including background-safe reconnects, persisted pending approvals, notification cleanup, and APNs-backed watch refresh recovery. (#61757) Thanks @ngutman.
- Discord/forwarding: recover forwarded referenced message text and attachments when Discord omits snapshot payloads, so forwarded-message relays keep the original content. (#61670) Thanks @artwalker.
- TUI/status: route `/status` through the shared session-status command and move the old gateway-wide diagnostic summary to `/gateway-status` (`/gwstatus`). Thanks @vincentkoc.
- TUI/history and heartbeat: keep assistant commentary hidden on both streamed and reloaded TUI history views, preserve the phase-sanitized REST history contract, and stop forced heartbeat runs from targeting subagent sessions. (#61463) Thanks @100yenadmin.
- TUI/command messages: strip inbound envelope metadata before rendering command/system messages so async completion notices stop leaking raw wrappers into the operator terminal. (#59985) Thanks @MoerAI.
- TUI/terminal: restore Kitty keyboard protocol and `modifyOtherKeys` state on TUI exit and fatal CLI crashes so parent shells stop inheriting broken keyboard input after `openclaw tui` exits. (#49130) Thanks @biefan.
- Plugins/Windows: load plugin entrypoints through `file://` import specifiers on Windows without breaking plugin SDK alias resolution, fixing `ERR_UNSUPPORTED_ESM_URL_SCHEME` for absolute plugin paths. (#61832) Thanks @Zeesejo.
- Plugins/Windows: disable native Jiti loading for setup and doctor contract registries on Windows so onboarding and config-doctor plugin probes stop crashing with `ERR_UNSUPPORTED_ESM_URL_SCHEME`. (#61836, #61853)
- Plugins/install: preserve plugin-schema defaults during fresh-install raw config validation so bundled plugin installs stop failing when required fields rely on schema defaults. (#61856) Thanks @SuperMarioYL.
- macOS/gateway version: strip trailing commit metadata from CLI version output before semver parsing so the Mac app recognizes installed gateway versions like `OpenClaw 2026.4.2 (d74a122)` again. (#61111) Thanks @oliviareid-svg.
- Gateway/containers: auto-bind to `0.0.0.0` during container startup for Docker and Podman compatibility, while keeping host-side status and doctor checks on the hardened loopback default when `gateway.bind` is unset. (#61818) Thanks @openperf.
- Gateway/status: probe local TLS gateways over `wss://`, forward the local cert fingerprint for self-signed loopback probes, and warn when the local TLS runtime cannot load the configured cert. (#61935) Thanks @ThanhNguyxn07.
- Slack/threading: keep legacy thread stickiness for real replies when older callers omit `isThreadReply`, while still honoring `replyToMode` for Slack's auto-created top-level `thread_ts`. (#61835) Thanks @kaonash.
- Providers/Google: recognize Gemma model ids in native Google forward-compat resolution, keep the requested provider when cloning fallback templates, and force Gemma reasoning off so Gemma 4 routes stop failing through the Google catalog fallback. (#61507) Thanks @eyjohn.
- Providers/Anthropic: skip `service_tier` injection for OAuth-authenticated stream wrapper requests so Claude OAuth requests stop failing with HTTP 401. (#60356) thanks @openperf.
- Providers/OpenAI: keep WebSocket text buffered until a real assistant phase arrives, even when text deltas land before a phaseless `output_item.added` announcement. (#61954) Thanks @100yenadmin.
- Providers/OpenAI: accept case-insensitive `plugins.entries.openai.config.personality` values, keep unknown overrides on the friendly overlay path, and add `on` as an alias for `friendly`. Thanks @vincentkoc.
- Discord/thread titles: stop forcing a hardcoded temperature for generated auto-thread names so Codex-backed thread title generation works on `openai-codex/*` models again. (#59525)
- Agents/message tool: add a `read` plus `threadId` discoverability hint when the configured channel actions support threaded message reads.
- Agents/context overflow: combine oversized and aggregate tool-result recovery in one repair pass, and restore a total-context overflow backstop during tool loops so recoverable sessions retry instead of failing early. (#61651) Thanks @Takhoffman.
- Agents/exec: preserve explicit `host=node` routing under elevated defaults when `tools.exec.host=auto`, and fail loud on invalid elevated cross-host overrides. (#61739) Thanks @obviyus.
- Agents/heartbeat: stop truncating live session transcripts after no-op heartbeat acks, move heartbeat cleanup to prompt assembly and compaction, and keep post-filter context-engine ingestion aligned with the real session baseline. (#60998) Thanks @nxmxbbd.
- Gateway/TUI: defer terminal chat finalization for per-attempt lifecycle errors so fallback retries keep streaming before the run is marked failed. (#60043) Thanks @jwchmodx.
- Gateway/history: seed SSE startup history and raw transcript sequence tracking from one initial transcript snapshot so first history events cannot diverge from subsequent message sequence numbering. (#61855) Thanks @100yenadmin.
- Agents/history: keep history-based reply reads and subagent completion summaries on `final_answer` text only so internal commentary stops leaking into user-visible follow-up replies. (#61747) Thanks @afurm.
- Agents/history: suppress commentary-only visible-text leaks in streaming and chat history views, and keep sanitized SSE history sequence numbers monotonic after transcript-only refreshes. (#61829) Thanks @100yenadmin.
- Agents/history: use one shared assistant-visible sanitizer across embedded delivery and chat-history extraction so leaked `<tool_call>` and `<tool_result>` XML blocks stay hidden from user-facing replies. (#61729) Thanks @openperf.
- Agents/history: keep truly legacy unsigned replay text unphased when mixed with phased OpenAI WS assistant blocks, while still inheriting message phase for id-only replay signatures. (#61529) Thanks @100yenadmin.
- Memory/dreaming: strip managed Light Sleep and REM blocks before daily-note ingestion so dreaming summaries stop re-ingesting their own staged output into new candidates. (#61720) Thanks @MonkeyLeeT.
- Docs/i18n: relocalize final localized-page links after translation so generated locale pages stop keeping stale English-root links when targets appear later in the same run. (#61796) thanks @hxy91819.
- Docs/i18n: remove the zh-CN homepage redirect override so Mintlify can resolve the localized Chinese homepage without self-redirecting `/zh-CN/index`.
- Tools/web_fetch and web_search: fix `TypeError: fetch failed` caused by undici 8.0 enabling HTTP/2 by default; pinned SSRF-guard dispatchers now explicitly set `allowH2: false` to restore HTTP/1.1 behavior and keep the custom DNS-pinning lookup compatible. (#61738, #61777) Thanks @zozo123.
- Tools/web search/Exa: show Exa Search in onboarding and configure provider pickers again by marking the bundled Exa provider as setup-visible. Thanks @vincentkoc.
- Docs/i18n: relocalize final localized-page links after translation and remove the zh-CN homepage redirect override so localized Mintlify pages resolve to the correct language roots again. (#61796) Thanks @hxy91819.
- Plugins/provider hooks: stop recursive provider snapshot loads from overflowing the stack during plugin initialization, while still preserving cached nested provider-hook results. (#61922, #61938, #61946, #61951)
- Exec/runtime events: mark background `notifyOnExit` summaries and ACP parent-stream relays as untrusted system events so lower-trust runtime output no longer re-enters later turns as trusted `System:` text.
- Hooks/wake: queue direct and mapped wake-hook payloads as untrusted system events so external wake content no longer enters the main session as trusted input. (#62003)
- Slack/thread mentions: add `channels.slack.thread.requireExplicitMention` so Slack channels that already require mentions can also require explicit `@bot` mentions inside bot-participated threads. (#58276) Thanks @praktika-engineer.
- UI/light mode: target both root and nested WebKit scrollbar thumbs in the light theme so page-level and container scrollbars stay visible on light backgrounds. (#61753) Thanks @chziyue.
- Matrix/onboarding: add an invite auto-join setup step with explicit off warnings and strict stable-target validation so new Matrix accounts stop silently ignoring invited rooms and fresh DM-style invites unless operators opt in. (#62168) Thanks @gumadeiras.
- Telegram/doctor: keep top-level access-control fallback in place during multi-account normalization while still promoting legacy default auth into `accounts.default`, so existing named bots keep inherited allowlists without dropping the legacy default bot. (#62263) Thanks @obviyus.
- Agents/subagents: honor `sessions_spawn(lightContext: true)` for spawned subagent runs by preserving lightweight bootstrap context through the gateway and embedded runner instead of silently falling back to full workspace bootstrap injection. (#62264) Thanks @theSamPadilla.
- Slack/media: keep attachment downloads on the SSRF-guarded dispatcher path so Slack media fetching works on Node 22 without dropping pinned transport enforcement. (#62239) Thanks @openperf.
- Docker/plugins: stop forcing bundled plugin discovery to `/app/extensions` in runtime images so packaged installs use compiled `dist/extensions` artifacts again and Node 24 containers do not boot through source-only plugin entry paths. Fixes #62044. (#62316) Thanks @gumadeiras.
- Cron: load `jobId` into `id` when the on-disk store omits `id`, matching doctor migration and fixing `unknown cron job id` for hand-edited `jobs.json`. (#62246) Thanks @neeravmakwana.
- Agents/model fallback: classify minimal HTTP 404 API errors (for example `404 status code (no body)`) as `model_not_found` so assistant failures throw into the fallback chain instead of stopping at the first fallback candidate. (#62119) Thanks @neeravmakwana.
- Providers/Mistral: send `reasoning_effort` for `mistral/mistral-small-latest` (Mistral Small 4) with thinking-level mapping, and mark the catalog entry as reasoning-capable so adjustable reasoning matches Mistrals Chat Completions API. (#62162) Thanks @neeravmakwana.
- OpenAI TTS/Groq: send `wav` to Groq-compatible speech endpoints, honor explicit `responseFormat` overrides on OpenAI-compatible paths, and only mark voice-note output as voice-compatible when the actual format is `opus`. (#62233) Thanks @neeravmakwana.
## 2026.4.5
@@ -93,7 +68,6 @@ Docs: https://docs.openclaw.ai
- Agents/video generation: add the built-in `video_generate` tool so agents can create videos through configured providers and return the generated media directly in the reply.
- Agents/music generation: ignore unsupported optional hints such as `durationSeconds` with a warning instead of hard-failing requests on providers like Google Lyria.
- Providers/Arcee AI: add a bundled Arcee AI provider plugin with `ARCEEAI_API_KEY` onboarding, Trinity model catalog (mini, large-preview, large-thinking), OpenAI-compatible API support, and OpenRouter as an alternative auth path. (#62068) Thanks @arthurbr11.
- Providers/ComfyUI: add a bundled `comfy` workflow media plugin for local ComfyUI and Comfy Cloud workflows, including shared `image_generate`, `video_generate`, and workflow-backed `music_generate` support, with prompt injection, optional reference-image upload, live tests, and output download.
- Tools/music generation: add the built-in `music_generate` tool with bundled Google (Lyria) and MiniMax providers plus workflow-backed Comfy support, including async task tracking and follow-up delivery of finished audio.
- Providers: add bundled Qwen, Fireworks AI, and StepFun providers, plus MiniMax TTS, Ollama Web Search, and MiniMax Search integrations for chat, speech, and search workflows. (#60032, #55921, #59318, #54648)
@@ -975,7 +949,6 @@ Docs: https://docs.openclaw.ai
- Security/path resolution: prefer non-user-writable absolute helper binaries for OpenClaw CLI, ffmpeg, and OpenSSL resolution so PATH hijacks cannot replace trusted helpers with attacker-controlled executables.
- Security/gateway command scopes: require `operator.admin` before Telegram target writeback and Talk Voice `/voice set` config writes persist through gateway message flows.
- Security/OpenShell mirror: exclude workspace `hooks/` from mirror sync so untrusted sandbox files cannot become trusted host hooks on gateway startup.
- Exec env policy: block Mercurial config redirects, Rust compiler wrappers, and GNU make flag env vars in host exec sanitization so inherited env and request-scoped overrides cannot redirect build-tool execution.
## 2026.3.24-beta.2

View File

@@ -62,7 +62,6 @@ RUN corepack enable
WORKDIR /app
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc ./
COPY openclaw.mjs ./
COPY ui/package.json ./ui/package.json
COPY patches ./patches
COPY scripts/postinstall-bundled-plugins.mjs scripts/npm-runner.mjs scripts/windows-cmd-helpers.mjs ./scripts/
@@ -103,19 +102,7 @@ RUN pnpm qa:lab:build
# Prune dev dependencies and strip build-only metadata before copying
# runtime assets into the final image.
FROM build AS runtime-assets
ARG OPENCLAW_EXTENSIONS
ARG OPENCLAW_BUNDLED_PLUGIN_DIR
# Keep the install layer frozen, but allow prune to run against the full copied
# workspace tree subset used during `pnpm install`. The build stage only copied
# the root, `ui`, and opted-in plugin manifests into the install layer, so
# prune must not rediscover unrelated workspaces from the later full source
# copy.
RUN printf 'packages:\n - .\n - ui\n' > /tmp/pnpm-workspace.runtime.yaml && \
for ext in $OPENCLAW_EXTENSIONS; do \
printf ' - %s/%s\n' "$OPENCLAW_BUNDLED_PLUGIN_DIR" "$ext" >> /tmp/pnpm-workspace.runtime.yaml; \
done && \
cp /tmp/pnpm-workspace.runtime.yaml pnpm-workspace.yaml && \
CI=true NPM_CONFIG_FROZEN_LOCKFILE=false pnpm prune --prod && \
RUN CI=true pnpm prune --prod && \
find dist -type f \( -name '*.d.ts' -o -name '*.d.mts' -o -name '*.d.cts' -o -name '*.map' \) -delete
# ── Runtime base images ─────────────────────────────────────────
@@ -172,6 +159,10 @@ COPY --from=runtime-assets --chown=node:node /app/skills ./skills
COPY --from=runtime-assets --chown=node:node /app/docs ./docs
COPY --from=runtime-assets --chown=node:node /app/qa ./qa
# In npm-installed Docker images, prefer the copied source extension tree for
# bundled discovery so package metadata that points at source entries stays valid.
ENV OPENCLAW_BUNDLED_PLUGINS_DIR=/app/${OPENCLAW_BUNDLED_PLUGIN_DIR}
# Keep pnpm available in the runtime image for container-local workflows.
# Use a shared Corepack home so the non-root `node` user does not need a
# first-run network fetch when invoking pnpm.

View File

@@ -89,7 +89,7 @@ New install? Start here: [Getting started](https://docs.openclaw.ai/start/gettin
- **[OpenAI](https://openai.com/)** (ChatGPT/Codex)
Model note: while many providers and models are supported, prefer a current flagship model from the provider you trust and already use. See [Onboarding](https://docs.openclaw.ai/start/onboarding).
Model note: while many providers/models are supported, for the best experience and lower prompt-injection risk use the strongest latest-generation model available to you. See [Onboarding](https://docs.openclaw.ai/start/onboarding).
## Models (selection + auth)
@@ -371,7 +371,7 @@ Minimal `~/.openclaw/openclaw.json` (model + defaults):
```json5
{
agent: {
model: "<provider>/<model-id>",
model: "anthropic/claude-opus-4-6",
},
}
```

View File

@@ -6,7 +6,7 @@
<title>2026.4.5</title>
<pubDate>Mon, 06 Apr 2026 04:55:17 +0100</pubDate>
<link>https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml</link>
<sparkle:version>2026040590</sparkle:version>
<sparkle:version>2026040501</sparkle:version>
<sparkle:shortVersionString>2026.4.5</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
<description><![CDATA[<h2>OpenClaw 2026.4.5</h2>
@@ -436,4 +436,4 @@
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.4.1/OpenClaw-2026.4.1.zip" length="25841903" type="application/octet-stream" sparkle:edSignature="0TPiyshScmwDbgs626JU08NOUUFJmIsVFa5g0xmizfl64Fr+IoT4l/dkXarFqbZAJidtj5WN7Bff7fG8ye/7AA=="/>
</item>
</channel>
</rss>
</rss>

View File

@@ -28,8 +28,6 @@ enum HostEnvSecurityPolicy {
"CC",
"CXX",
"CARGO_BUILD_RUSTC",
"CARGO_BUILD_RUSTC_WRAPPER",
"RUSTC_WRAPPER",
"CMAKE_C_COMPILER",
"CMAKE_CXX_COMPILER",
"SHELL",
@@ -46,12 +44,9 @@ enum HostEnvSecurityPolicy {
"DOTNET_ADDITIONAL_DEPS",
"GLIBC_TUNABLES",
"MAVEN_OPTS",
"MAKEFLAGS",
"MFLAGS",
"SBT_OPTS",
"GRADLE_OPTS",
"ANT_OPTS",
"HGRCPATH"
"ANT_OPTS"
]
static let blockedOverrideKeys: Set<String> = [
@@ -88,8 +83,6 @@ enum HostEnvSecurityPolicy {
"CGO_CFLAGS",
"CGO_LDFLAGS",
"GOFLAGS",
"MAKEFLAGS",
"MFLAGS",
"CORECLR_PROFILER_PATH",
"PHPRC",
"PHP_INI_SCAN_DIR",
@@ -141,9 +134,7 @@ enum HostEnvSecurityPolicy {
"GOPRIVATE",
"GOENV",
"GOPATH",
"HGRCPATH",
"PYTHONUSERBASE",
"RUSTC_WRAPPER",
"VIRTUAL_ENV",
"LUA_PATH",
"LUA_CPATH",
@@ -151,7 +142,6 @@ enum HostEnvSecurityPolicy {
"GEM_PATH",
"BUNDLE_GEMFILE",
"COMPOSER_HOME",
"CARGO_BUILD_RUSTC_WRAPPER",
"XDG_CONFIG_HOME",
"AWS_CONFIG_FILE"
]

View File

@@ -1327,236 +1327,6 @@ public struct SessionsResolveParams: Codable, Sendable {
}
}
public struct SessionCompactionCheckpoint: Codable, Sendable {
public let checkpointid: String
public let sessionkey: String
public let sessionid: String
public let createdat: Int
public let reason: AnyCodable
public let tokensbefore: Int?
public let tokensafter: Int?
public let summary: String?
public let firstkeptentryid: String?
public let precompaction: [String: AnyCodable]
public let postcompaction: [String: AnyCodable]
public init(
checkpointid: String,
sessionkey: String,
sessionid: String,
createdat: Int,
reason: AnyCodable,
tokensbefore: Int?,
tokensafter: Int?,
summary: String?,
firstkeptentryid: String?,
precompaction: [String: AnyCodable],
postcompaction: [String: AnyCodable])
{
self.checkpointid = checkpointid
self.sessionkey = sessionkey
self.sessionid = sessionid
self.createdat = createdat
self.reason = reason
self.tokensbefore = tokensbefore
self.tokensafter = tokensafter
self.summary = summary
self.firstkeptentryid = firstkeptentryid
self.precompaction = precompaction
self.postcompaction = postcompaction
}
private enum CodingKeys: String, CodingKey {
case checkpointid = "checkpointId"
case sessionkey = "sessionKey"
case sessionid = "sessionId"
case createdat = "createdAt"
case reason
case tokensbefore = "tokensBefore"
case tokensafter = "tokensAfter"
case summary
case firstkeptentryid = "firstKeptEntryId"
case precompaction = "preCompaction"
case postcompaction = "postCompaction"
}
}
public struct SessionsCompactionListParams: Codable, Sendable {
public let key: String
public init(
key: String)
{
self.key = key
}
private enum CodingKeys: String, CodingKey {
case key
}
}
public struct SessionsCompactionGetParams: Codable, Sendable {
public let key: String
public let checkpointid: String
public init(
key: String,
checkpointid: String)
{
self.key = key
self.checkpointid = checkpointid
}
private enum CodingKeys: String, CodingKey {
case key
case checkpointid = "checkpointId"
}
}
public struct SessionsCompactionBranchParams: Codable, Sendable {
public let key: String
public let checkpointid: String
public init(
key: String,
checkpointid: String)
{
self.key = key
self.checkpointid = checkpointid
}
private enum CodingKeys: String, CodingKey {
case key
case checkpointid = "checkpointId"
}
}
public struct SessionsCompactionRestoreParams: Codable, Sendable {
public let key: String
public let checkpointid: String
public init(
key: String,
checkpointid: String)
{
self.key = key
self.checkpointid = checkpointid
}
private enum CodingKeys: String, CodingKey {
case key
case checkpointid = "checkpointId"
}
}
public struct SessionsCompactionListResult: Codable, Sendable {
public let ok: Bool
public let key: String
public let checkpoints: [SessionCompactionCheckpoint]
public init(
ok: Bool,
key: String,
checkpoints: [SessionCompactionCheckpoint])
{
self.ok = ok
self.key = key
self.checkpoints = checkpoints
}
private enum CodingKeys: String, CodingKey {
case ok
case key
case checkpoints
}
}
public struct SessionsCompactionGetResult: Codable, Sendable {
public let ok: Bool
public let key: String
public let checkpoint: SessionCompactionCheckpoint
public init(
ok: Bool,
key: String,
checkpoint: SessionCompactionCheckpoint)
{
self.ok = ok
self.key = key
self.checkpoint = checkpoint
}
private enum CodingKeys: String, CodingKey {
case ok
case key
case checkpoint
}
}
public struct SessionsCompactionBranchResult: Codable, Sendable {
public let ok: Bool
public let sourcekey: String
public let key: String
public let sessionid: String
public let checkpoint: SessionCompactionCheckpoint
public let entry: [String: AnyCodable]
public init(
ok: Bool,
sourcekey: String,
key: String,
sessionid: String,
checkpoint: SessionCompactionCheckpoint,
entry: [String: AnyCodable])
{
self.ok = ok
self.sourcekey = sourcekey
self.key = key
self.sessionid = sessionid
self.checkpoint = checkpoint
self.entry = entry
}
private enum CodingKeys: String, CodingKey {
case ok
case sourcekey = "sourceKey"
case key
case sessionid = "sessionId"
case checkpoint
case entry
}
}
public struct SessionsCompactionRestoreResult: Codable, Sendable {
public let ok: Bool
public let key: String
public let sessionid: String
public let checkpoint: SessionCompactionCheckpoint
public let entry: [String: AnyCodable]
public init(
ok: Bool,
key: String,
sessionid: String,
checkpoint: SessionCompactionCheckpoint,
entry: [String: AnyCodable])
{
self.ok = ok
self.key = key
self.sessionid = sessionid
self.checkpoint = checkpoint
self.entry = entry
}
private enum CodingKeys: String, CodingKey {
case ok
case key
case sessionid = "sessionId"
case checkpoint
case entry
}
}
public struct SessionsCreateParams: Codable, Sendable {
public let key: String?
public let agentid: String?

View File

@@ -1327,236 +1327,6 @@ public struct SessionsResolveParams: Codable, Sendable {
}
}
public struct SessionCompactionCheckpoint: Codable, Sendable {
public let checkpointid: String
public let sessionkey: String
public let sessionid: String
public let createdat: Int
public let reason: AnyCodable
public let tokensbefore: Int?
public let tokensafter: Int?
public let summary: String?
public let firstkeptentryid: String?
public let precompaction: [String: AnyCodable]
public let postcompaction: [String: AnyCodable]
public init(
checkpointid: String,
sessionkey: String,
sessionid: String,
createdat: Int,
reason: AnyCodable,
tokensbefore: Int?,
tokensafter: Int?,
summary: String?,
firstkeptentryid: String?,
precompaction: [String: AnyCodable],
postcompaction: [String: AnyCodable])
{
self.checkpointid = checkpointid
self.sessionkey = sessionkey
self.sessionid = sessionid
self.createdat = createdat
self.reason = reason
self.tokensbefore = tokensbefore
self.tokensafter = tokensafter
self.summary = summary
self.firstkeptentryid = firstkeptentryid
self.precompaction = precompaction
self.postcompaction = postcompaction
}
private enum CodingKeys: String, CodingKey {
case checkpointid = "checkpointId"
case sessionkey = "sessionKey"
case sessionid = "sessionId"
case createdat = "createdAt"
case reason
case tokensbefore = "tokensBefore"
case tokensafter = "tokensAfter"
case summary
case firstkeptentryid = "firstKeptEntryId"
case precompaction = "preCompaction"
case postcompaction = "postCompaction"
}
}
public struct SessionsCompactionListParams: Codable, Sendable {
public let key: String
public init(
key: String)
{
self.key = key
}
private enum CodingKeys: String, CodingKey {
case key
}
}
public struct SessionsCompactionGetParams: Codable, Sendable {
public let key: String
public let checkpointid: String
public init(
key: String,
checkpointid: String)
{
self.key = key
self.checkpointid = checkpointid
}
private enum CodingKeys: String, CodingKey {
case key
case checkpointid = "checkpointId"
}
}
public struct SessionsCompactionBranchParams: Codable, Sendable {
public let key: String
public let checkpointid: String
public init(
key: String,
checkpointid: String)
{
self.key = key
self.checkpointid = checkpointid
}
private enum CodingKeys: String, CodingKey {
case key
case checkpointid = "checkpointId"
}
}
public struct SessionsCompactionRestoreParams: Codable, Sendable {
public let key: String
public let checkpointid: String
public init(
key: String,
checkpointid: String)
{
self.key = key
self.checkpointid = checkpointid
}
private enum CodingKeys: String, CodingKey {
case key
case checkpointid = "checkpointId"
}
}
public struct SessionsCompactionListResult: Codable, Sendable {
public let ok: Bool
public let key: String
public let checkpoints: [SessionCompactionCheckpoint]
public init(
ok: Bool,
key: String,
checkpoints: [SessionCompactionCheckpoint])
{
self.ok = ok
self.key = key
self.checkpoints = checkpoints
}
private enum CodingKeys: String, CodingKey {
case ok
case key
case checkpoints
}
}
public struct SessionsCompactionGetResult: Codable, Sendable {
public let ok: Bool
public let key: String
public let checkpoint: SessionCompactionCheckpoint
public init(
ok: Bool,
key: String,
checkpoint: SessionCompactionCheckpoint)
{
self.ok = ok
self.key = key
self.checkpoint = checkpoint
}
private enum CodingKeys: String, CodingKey {
case ok
case key
case checkpoint
}
}
public struct SessionsCompactionBranchResult: Codable, Sendable {
public let ok: Bool
public let sourcekey: String
public let key: String
public let sessionid: String
public let checkpoint: SessionCompactionCheckpoint
public let entry: [String: AnyCodable]
public init(
ok: Bool,
sourcekey: String,
key: String,
sessionid: String,
checkpoint: SessionCompactionCheckpoint,
entry: [String: AnyCodable])
{
self.ok = ok
self.sourcekey = sourcekey
self.key = key
self.sessionid = sessionid
self.checkpoint = checkpoint
self.entry = entry
}
private enum CodingKeys: String, CodingKey {
case ok
case sourcekey = "sourceKey"
case key
case sessionid = "sessionId"
case checkpoint
case entry
}
}
public struct SessionsCompactionRestoreResult: Codable, Sendable {
public let ok: Bool
public let key: String
public let sessionid: String
public let checkpoint: SessionCompactionCheckpoint
public let entry: [String: AnyCodable]
public init(
ok: Bool,
key: String,
sessionid: String,
checkpoint: SessionCompactionCheckpoint,
entry: [String: AnyCodable])
{
self.ok = ok
self.key = key
self.sessionid = sessionid
self.checkpoint = checkpoint
self.entry = entry
}
private enum CodingKeys: String, CodingKey {
case ok
case key
case sessionid = "sessionId"
case checkpoint
case entry
}
}
public struct SessionsCreateParams: Codable, Sendable {
public let key: String?
public let agentid: String?

View File

@@ -1,4 +1,4 @@
64ff922efc6146d867f3858141772094a8a72cba99a8fd61878551175dd8c822 config-baseline.json
5d0ce975352ff2b03077f6d71e9fe99ab0f0b118da0f72d47dc989c83f13d668 config-baseline.core.json
d22f4414b79ee03d896e58d875c80523bcc12303cbacb1700261e6ec73945187 config-baseline.channel.json
1891bcb68d80ab8b7546a2946b5a9d82b18c3e92ffd2c834d15928e73fa11564 config-baseline.plugin.json
1c74540dd152c55dbda3e5dee1e37008ee3e6aabb0608e571292832c7a1c012c config-baseline.json
7e30316f2326b7d07b71d7b8a96049a74b81428921299b5c4b5aa3d080e03305 config-baseline.core.json
66edc86a9d16db1b9e9e7dd99b7032e2d9bcfb9ff210256a21f4b4f088cb3dc1 config-baseline.channel.json
d6ebc4948499b997c4a3727cf31849d4a598de9f1a4c197417dcc0b0ec1b734f config-baseline.plugin.json

View File

@@ -1,2 +1,2 @@
3d483bffbe5abb831df3b1efdf40e1ae0d22d644853a7629ecdaa6d535386ee6 plugin-sdk-api-baseline.json
eebeff7cc3ca490d3cae268ea97c5968f37f50fe1a9c7eabeeab85a4ae66a9d9 plugin-sdk-api-baseline.jsonl
9883b1242051e830bafa7035351c9a2dd0fb84f81be28d7b5be2b69a1179e519 plugin-sdk-api-baseline.json
43dd28ba4502b207413d00471ea2e4ae5cf644922ab153387fa4bf99e540e6d1 plugin-sdk-api-baseline.jsonl

View File

@@ -227,10 +227,7 @@ Quick mental model (evaluation order for group messages):
Group messages require a mention unless overridden per group. Defaults live per subsystem under `*.groups."*"`.
Replying to a bot message counts as an implicit mention when the channel
supports reply metadata. Quoting a bot message can also count as an implicit
mention on channels that expose quote metadata. Current built-in cases include
Telegram, WhatsApp, Slack, Discord, Microsoft Teams, and ZaloUser.
Replying to a bot message counts as an implicit mention (when the channel supports reply metadata). This applies to Telegram, WhatsApp, Slack, Discord, and Microsoft Teams.
```json5
{

View File

@@ -61,17 +61,13 @@ What the Matrix wizard actually asks for:
- optional device name
- whether to enable E2EE
- whether to configure Matrix room access now
- whether to configure Matrix invite auto-join now
- when invite auto-join is enabled, whether it should be `allowlist`, `always`, or `off`
Wizard behavior that matters:
- If Matrix auth env vars already exist for the selected account, and that account does not already have auth saved in config, the wizard offers an env shortcut so setup can keep auth in env vars instead of copying secrets into config.
- If Matrix auth env vars already exist for the selected account, and that account does not already have auth saved in config, the wizard offers an env shortcut and only writes `enabled: true` for that account.
- When you add another Matrix account interactively, the entered account name is normalized into the account ID used in config and env vars. For example, `Ops Bot` becomes `ops-bot`.
- DM allowlist prompts accept full `@user:server` values immediately. Display names only work when live directory lookup finds one exact match; otherwise the wizard asks you to retry with a full Matrix ID.
- Room allowlist prompts accept room IDs and aliases directly. They can also resolve joined-room names live, but unresolved names are only kept as typed during setup and are ignored later by runtime allowlist resolution. Prefer `!room:server` or `#alias:server`.
- The wizard now shows an explicit warning before the invite auto-join step because `channels.matrix.autoJoin` defaults to `off`; agents will not join invited rooms or fresh DM-style invites unless you set it.
- In invite auto-join allowlist mode, use only stable invite targets: `!roomId:server`, `#alias:server`, or `*`. Plain room names are rejected.
- Runtime room/session identity uses the stable Matrix room ID. Room-declared aliases are only used as lookup inputs, not as the long-term session key or stable group identity.
- To resolve room names before saving them, use `openclaw channels resolve --channel matrix "Project Room"`.
@@ -81,8 +77,6 @@ Wizard behavior that matters:
If you leave it unset, the bot will not join invited rooms or fresh DM-style invites, so it will not appear in new groups or invited DMs unless you join manually first.
Set `autoJoin: "allowlist"` together with `autoJoinAllowlist` to restrict which invites it accepts, or set `autoJoin: "always"` if you want it to join every invite.
In `allowlist` mode, `autoJoinAllowlist` only accepts `!roomId:server`, `#alias:server`, or `*`.
</Warning>
Allowlist example:

View File

@@ -75,13 +75,10 @@ self-check, and writes a Markdown report under `.artifacts/qa-e2e/`.
Private debugger UI:
```bash
pnpm qa:lab:up
pnpm qa:lab:build
pnpm openclaw qa ui
```
That one command builds the QA site, starts the Docker-backed gateway + QA Lab
stack, and prints the QA Lab URL. From that site you can pick scenarios, choose
the model lane, launch individual runs, and watch results live.
Full repo-backed QA suite:
```bash
@@ -99,10 +96,10 @@ Current scope is intentionally narrow:
- threaded routing grammar
- channel-owned message actions
- Markdown reporting
- Docker-backed QA site with run controls
Follow-up work will add:
- Dockerized OpenClaw orchestration
- provider/model matrix execution
- richer scenario discovery
- OpenClaw-native orchestration later

View File

@@ -399,7 +399,7 @@ Current Slack message actions include `send`, `upload-file`, `download-file`, `r
- explicit app mention (`<@botId>`)
- mention regex patterns (`agents.list[].groupChat.mentionPatterns`, fallback `messages.groupChat.mentionPatterns`)
- implicit reply-to-bot thread behavior (disabled when `thread.requireExplicitMention` is `true`)
- implicit reply-to-bot thread behavior
Per-channel controls (`channels.slack.channels.<id>`; names only via startup resolution or `dangerouslyAllowNameMatching`):
@@ -423,7 +423,6 @@ Current Slack message actions include `send`, `upload-file`, `download-file`, `r
- Thread replies can create thread session suffixes (`:thread:<threadTs>`) when applicable.
- `channels.slack.thread.historyScope` default is `thread`; `thread.inheritParent` default is `false`.
- `channels.slack.thread.initialHistoryLimit` controls how many existing thread messages are fetched when a new thread session starts (default `20`; set `0` to disable).
- `channels.slack.thread.requireExplicitMention` (default `false`): when `true`, suppress implicit thread mentions so the bot only responds to explicit `@bot` mentions inside threads, even when the bot already participated in the thread. Without this, replies in a bot-participated thread bypass `requireMention` gating.
Reply threading controls:

View File

@@ -124,7 +124,6 @@ Example:
- `channels.zalouser.groups.<group>.requireMention` controls whether group replies require a mention.
- Resolution order: exact group id/name -> normalized group slug -> `*` -> default (`true`).
- This applies both to allowlisted groups and open group mode.
- Quoting a bot message counts as an implicit mention for group activation.
- Authorized control commands (for example `/new`) can bypass mention gating.
- When a group message is skipped because mention is required, OpenClaw stores it as pending group history and includes it on the next processed group message.
- Group history limit defaults to `messages.groupChat.historyLimit` (fallback `50`). You can override per account with `channels.zalouser.historyLimit`.

View File

@@ -115,8 +115,6 @@ engine is used automatically.
A plugin can register a context engine using the plugin API:
```ts
import { buildMemorySystemPromptAddition } from "openclaw/plugin-sdk/core";
export default function register(api) {
api.registerContextEngine("my-engine", () => ({
info: {
@@ -130,15 +128,12 @@ export default function register(api) {
return { ingested: true };
},
async assemble({ sessionId, messages, tokenBudget, availableTools, citationsMode }) {
async assemble({ sessionId, messages, tokenBudget }) {
// Return messages that fit the budget
return {
messages: buildContext(messages, tokenBudget),
estimatedTokens: countTokens(messages),
systemPromptAddition: buildMemorySystemPromptAddition({
availableTools: availableTools ?? new Set(),
citationsMode,
}),
systemPromptAddition: "Use lcm_grep to search history...",
};
},
@@ -253,13 +248,7 @@ OpenClaw resolves when it needs a context engine.
- **Memory plugins** (`plugins.slots.memory`) are separate from context engines.
Memory plugins provide search/retrieval; context engines control what the
model sees. They can work together — a context engine might use memory
plugin data during assembly. Plugin engines that want the active memory
prompt path should prefer `buildMemorySystemPromptAddition(...)` from
`openclaw/plugin-sdk/core`, which converts the active memory prompt sections
into a ready-to-prepend `systemPromptAddition`. If an engine needs lower-level
control, it can still pull raw lines from
`openclaw/plugin-sdk/memory-host-core` via
`buildActiveMemoryPromptSection(...)`.
plugin data during assembly.
- **Session pruning** (trimming old tool results in-memory) still runs
regardless of which context engine is active.

View File

@@ -21,36 +21,13 @@ Current pieces:
- `qa/`: repo-backed seed assets for the kickoff task and baseline QA
scenarios.
The current QA operator flow is a two-pane QA site:
The long-term goal is a two-pane QA site:
- Left: Gateway dashboard (Control UI) with the agent.
- Right: QA Lab, showing the Slack-ish transcript and scenario plan.
Run it with:
```bash
pnpm qa:lab:up
```
That builds the QA site, starts the Docker-backed gateway lane, and exposes the
QA Lab page where an operator or automation loop can give the agent a QA
mission, observe real channel behavior, and record what worked, failed, or
stayed blocked.
For faster QA Lab UI iteration without rebuilding the Docker image each time,
start the stack with a bind-mounted QA Lab bundle:
```bash
pnpm openclaw qa docker-build-image
pnpm qa:lab:build
pnpm qa:lab:up:fast
pnpm qa:lab:watch
```
`qa:lab:up:fast` keeps the Docker services on a prebuilt image and bind-mounts
`extensions/qa-lab/web/dist` into the `qa-lab` container. `qa:lab:watch`
rebuilds that bundle on change, and the browser auto-reloads when the QA Lab
asset hash changes.
That lets an operator or automation loop give the agent a QA mission, observe
real channel behavior, and record what worked, failed, or stayed blocked.
## Repo-backed seeds

View File

@@ -1231,7 +1231,6 @@
"pages": [
"providers/alibaba",
"providers/anthropic",
"providers/arcee",
"providers/bedrock",
"providers/bedrock-mantle",
"providers/chutes",

View File

@@ -26,7 +26,6 @@ Most days:
- Faster local full-suite run on a roomy machine: `pnpm test:max`
- Direct Vitest watch loop: `pnpm test:watch`
- Direct file targeting now routes extension/channel paths too: `pnpm test extensions/discord/src/monitor/message-handler.preflight.test.ts`
- Docker-backed QA site: `pnpm qa:lab:up`
When you touch tests or want extra confidence:
@@ -47,7 +46,7 @@ Think of the suites as “increasing realism” (and increasing flakiness/cost):
### Unit / integration (default)
- Command: `pnpm test`
- Config: ten sequential shard runs (`vitest.full-*.config.ts`) over the existing scoped Vitest projects
- Config: five sequential shard runs (`vitest.full-*.config.ts`) over the existing scoped Vitest projects
- Files: core/unit inventories under `src/**/*.test.ts`, `packages/**/*.test.ts`, `test/**/*.test.ts`, and the whitelisted `ui` node tests covered by `vitest.unit.config.ts`
- Scope:
- Pure unit tests
@@ -58,7 +57,7 @@ Think of the suites as “increasing realism” (and increasing flakiness/cost):
- No real keys required
- Should be fast and stable
- Projects note:
- Untargeted `pnpm test` now runs eleven smaller shard configs (`core-unit-src`, `core-unit-security`, `core-unit-ui`, `core-unit-support`, `core-support-boundary`, `core-contracts`, `core-bundled`, `core-runtime`, `agentic`, `auto-reply`, `extensions`) instead of one giant native root-project process. This cuts peak RSS on loaded machines and avoids auto-reply/extension work starving unrelated suites.
- Untargeted `pnpm test` now runs five smaller shard configs (`core-unit`, `core-runtime`, `agentic`, `auto-reply`, `extensions`) instead of one giant native root-project process. This cuts peak RSS on loaded machines and avoids auto-reply/extension work starving unrelated suites.
- `pnpm test --watch` still uses the native root `vitest.config.ts` project graph, because a multi-shard watch loop is not practical.
- `pnpm test`, `pnpm test:watch`, and `pnpm test:perf:imports` route explicit file/directory targets through scoped lanes first, so `pnpm test extensions/discord/src/monitor/message-handler.preflight.test.ts` avoids paying the full root project startup tax.
- `pnpm test:changed` expands changed git paths into the same scoped lanes when the diff only touches routable source/test files; config/setup edits still fall back to the broad root-project rerun.
@@ -253,17 +252,17 @@ openclaw models list
openclaw models list --json
```
## Live: CLI backend smoke (Claude, Codex, Gemini, or other local CLIs)
## Live: CLI backend smoke (Codex CLI or other local CLIs)
- Test: `src/gateway/gateway-cli-backend.live.test.ts`
- Goal: validate the Gateway + agent pipeline using a local CLI backend, without touching your default config.
- Backend-specific smoke defaults live with the owning extension's `cli-backend.ts` definition.
- Enable:
- `pnpm test:live` (or `OPENCLAW_LIVE_TEST=1` if invoking Vitest directly)
- `OPENCLAW_LIVE_CLI_BACKEND=1`
- Defaults:
- Default provider/model: `claude-cli/claude-sonnet-4-6`
- Command/args/image behavior come from the owning CLI backend plugin metadata.
- Model: `codex-cli/gpt-5.4`
- Command: `codex`
- Args: `["exec","--json","--color","never","--sandbox","read-only","--skip-git-repo-check"]`
- Overrides (optional):
- `OPENCLAW_LIVE_CLI_BACKEND_MODEL="codex-cli/gpt-5.4"`
- `OPENCLAW_LIVE_CLI_BACKEND_COMMAND="/full/path/to/codex"`
@@ -287,19 +286,11 @@ Docker recipe:
pnpm test:docker:live-cli-backend
```
Single-provider Docker recipes:
```bash
pnpm test:docker:live-cli-backend:claude
pnpm test:docker:live-cli-backend:codex
pnpm test:docker:live-cli-backend:gemini
```
Notes:
- The Docker runner lives at `scripts/test-live-cli-backend-docker.sh`.
- It runs the live CLI-backend smoke inside the repo Docker image as the non-root `node` user.
- It resolves CLI smoke metadata from the owning extension, then installs the matching Linux CLI package (`@anthropic-ai/claude-code`, `@openai/codex`, or `@google/gemini-cli`) into a cached writable prefix at `OPENCLAW_DOCKER_CLI_TOOLS_DIR` (default: `~/.cache/openclaw/docker-cli-tools`).
- For `codex-cli`, it installs the Linux `@openai/codex` package into a cached writable prefix at `OPENCLAW_DOCKER_CLI_TOOLS_DIR` (default: `~/.cache/openclaw/docker-cli-tools`).
## Live: ACP bind smoke (`/acp spawn ... --bind here`)
@@ -313,15 +304,12 @@ Notes:
- `pnpm test:live src/gateway/gateway-acp-bind.live.test.ts`
- `OPENCLAW_LIVE_ACP_BIND=1`
- Defaults:
- ACP agents in Docker: `claude,codex,gemini`
- ACP agent for direct `pnpm test:live ...`: `claude`
- ACP agent: `claude`
- Synthetic channel: Slack DM-style conversation context
- ACP backend: `acpx`
- Overrides:
- `OPENCLAW_LIVE_ACP_BIND_AGENT=claude`
- `OPENCLAW_LIVE_ACP_BIND_AGENT=codex`
- `OPENCLAW_LIVE_ACP_BIND_AGENT=gemini`
- `OPENCLAW_LIVE_ACP_BIND_AGENTS=claude,codex,gemini`
- `OPENCLAW_LIVE_ACP_BIND_AGENT_COMMAND='npx -y @agentclientprotocol/claude-agent-acp@<version>'`
- Notes:
- This lane uses the gateway `chat.send` surface with admin-only synthetic originating-route fields so tests can attach message-channel context without pretending to deliver externally.
@@ -341,20 +329,10 @@ Docker recipe:
pnpm test:docker:live-acp-bind
```
Single-agent Docker recipes:
```bash
pnpm test:docker:live-acp-bind:claude
pnpm test:docker:live-acp-bind:codex
pnpm test:docker:live-acp-bind:gemini
```
Docker notes:
- The Docker runner lives at `scripts/test-live-acp-bind-docker.sh`.
- By default, it runs the ACP bind smoke against all supported live CLI agents in sequence: `claude`, `codex`, then `gemini`.
- Use `OPENCLAW_LIVE_ACP_BIND_AGENTS=claude`, `OPENCLAW_LIVE_ACP_BIND_AGENTS=codex`, or `OPENCLAW_LIVE_ACP_BIND_AGENTS=gemini` to narrow the matrix.
- It sources `~/.profile`, stages the matching CLI auth material into the container, installs `acpx` into a writable npm prefix, then installs the requested live CLI (`@anthropic-ai/claude-code`, `@openai/codex`, or `@google/gemini-cli`) if missing.
- It sources `~/.profile`, stages the matching CLI auth material into the container, installs `acpx` into a writable npm prefix, then installs the requested live CLI (`@anthropic-ai/claude-code` or `@openai/codex`) if missing.
- Inside Docker, the runner sets `OPENCLAW_LIVE_ACP_BIND_ACPX_COMMAND=$HOME/.npm-global/bin/acpx` so acpx keeps provider env vars from the sourced profile available to the child harness CLI.
### Recommended live recipes
@@ -475,7 +453,6 @@ If you want to rely on env keys (e.g. exported in your `~/.profile`), run local
- Test: `src/image-generation/runtime.live.test.ts`
- Command: `pnpm test:live src/image-generation/runtime.live.test.ts`
- Harness: `pnpm test:live:media image`
- Scope:
- Enumerates every registered image-generation provider plugin
- Loads missing provider env vars from your login shell (`~/.profile`) before probing
@@ -500,7 +477,6 @@ If you want to rely on env keys (e.g. exported in your `~/.profile`), run local
- Test: `extensions/music-generation-providers.live.test.ts`
- Enable: `OPENCLAW_LIVE_TEST=1 pnpm test:live -- extensions/music-generation-providers.live.test.ts`
- Harness: `pnpm test:live:media music`
- Scope:
- Exercises the shared bundled music-generation provider path
- Currently covers Google and MiniMax
@@ -524,7 +500,6 @@ If you want to rely on env keys (e.g. exported in your `~/.profile`), run local
- Test: `extensions/video-generation-providers.live.test.ts`
- Enable: `OPENCLAW_LIVE_TEST=1 pnpm test:live -- extensions/video-generation-providers.live.test.ts`
- Harness: `pnpm test:live:media video`
- Scope:
- Exercises the shared bundled video-generation provider path
- Loads provider env vars from your login shell (`~/.profile`) before probing
@@ -532,39 +507,20 @@ If you want to rely on env keys (e.g. exported in your `~/.profile`), run local
- Skips providers with no usable auth/profile/model
- Runs both declared runtime modes when available:
- `generate` with prompt-only input
- `imageToVideo` when the provider declares `capabilities.imageToVideo.enabled` and the selected provider/model accepts buffer-backed local image input in the shared sweep
- `imageToVideo` when the provider declares `capabilities.imageToVideo.enabled`
- `videoToVideo` when the provider declares `capabilities.videoToVideo.enabled` and the selected provider/model accepts buffer-backed local video input in the shared sweep
- Current declared-but-skipped `imageToVideo` providers in the shared sweep:
- `vydra` because bundled `veo3` is text-only and bundled `kling` requires a remote image URL
- Provider-specific Vydra coverage:
- `OPENCLAW_LIVE_TEST=1 OPENCLAW_LIVE_VYDRA_VIDEO=1 pnpm test:live -- extensions/vydra/vydra.live.test.ts`
- that file runs `veo3` text-to-video plus a `kling` lane that uses a remote image URL fixture by default
- Current `videoToVideo` live coverage:
- `google`
- `openai`
- `runway` only when the selected model is `runway/gen4_aleph`
- Current declared-but-skipped `videoToVideo` providers in the shared sweep:
- `alibaba`, `qwen`, `xai` because those paths currently require remote `http(s)` / MP4 reference URLs
- `google` because the current shared Gemini/Veo lane uses local buffer-backed input and that path is not accepted in the shared sweep
- `openai` because the current shared lane lacks org-specific video inpaint/remix access guarantees
- Optional narrowing:
- `OPENCLAW_LIVE_VIDEO_GENERATION_PROVIDERS="google,openai,runway"`
- `OPENCLAW_LIVE_VIDEO_GENERATION_MODELS="google/veo-3.1-fast-generate-preview,openai/sora-2,runway/gen4_aleph"`
- Optional auth behavior:
- `OPENCLAW_LIVE_REQUIRE_PROFILE_KEYS=1` to force profile-store auth and ignore env-only overrides
## Media live harness
- Command: `pnpm test:live:media`
- Purpose:
- Runs the shared image, music, and video live suites through one repo-native entrypoint
- Auto-loads missing provider env vars from `~/.profile`
- Auto-narrows each suite to providers that currently have usable auth by default
- Reuses `scripts/test-live.mjs`, so heartbeat and quiet-mode behavior stay consistent
- Examples:
- `pnpm test:live:media`
- `pnpm test:live:media image video --providers openai,google,minimax`
- `pnpm test:live:media video --video-providers openai,runway --all-providers`
- `pnpm test:live:media music --quiet`
## Docker runners (optional "works in Linux" checks)
These Docker runners split into two buckets:

View File

@@ -609,9 +609,8 @@ conversation, and it runs after core approval handling finishes.
Provider plugins now have two layers:
- manifest metadata: `providerAuthEnvVars` for cheap provider env-auth lookup
before runtime load, `channelEnvVars` for cheap channel env/setup lookup
before runtime load, plus `providerAuthChoices` for cheap onboarding/auth-choice
- manifest metadata: `providerAuthEnvVars` for cheap env-auth lookup before
runtime load, plus `providerAuthChoices` for cheap onboarding/auth-choice
labels and CLI flag metadata before runtime load
- config-time hooks: `catalog` / legacy `discovery` plus `applyConfigDefaults`
- runtime hooks: `normalizeModelId`, `normalizeTransport`,
@@ -646,10 +645,6 @@ one-flag auth wiring without loading provider runtime. Keep provider runtime
`envVars` for operator-facing hints such as onboarding labels or OAuth
client-id/client-secret setup vars.
Use manifest `channelEnvVars` when a channel has env-driven auth or setup that
generic shell-env fallback, config/status checks, or setup prompts should see
without loading channel runtime.
### Hook order and usage
For model/provider plugins, OpenClaw calls hooks in this rough order.
@@ -1120,8 +1115,7 @@ authoring plugins:
`openclaw/plugin-sdk/secret-input`, and
`openclaw/plugin-sdk/webhook-ingress` for shared setup/auth/reply/webhook
wiring. `channel-inbound` is the shared home for debounce, mention matching,
inbound mention-policy helpers, envelope formatting, and inbound envelope
context helpers.
envelope formatting, and inbound envelope context helpers.
`channel-setup` is the narrow optional-install setup seam.
`setup-runtime` is the runtime-safe setup surface used by `setupEntry` /
deferred startup, including the import-safe setup patch adapters.
@@ -1494,23 +1488,14 @@ Use this when your plugin needs to replace or extend the default context
pipeline rather than just add memory search or hooks.
```ts
import { buildMemorySystemPromptAddition } from "openclaw/plugin-sdk/core";
export default function (api) {
api.registerContextEngine("lossless-claw", () => ({
info: { id: "lossless-claw", name: "Lossless Claw", ownsCompaction: true },
async ingest() {
return { ingested: true };
},
async assemble({ messages, availableTools, citationsMode }) {
return {
messages,
estimatedTokens: 0,
systemPromptAddition: buildMemorySystemPromptAddition({
availableTools: availableTools ?? new Set(),
citationsMode,
}),
};
async assemble({ messages }) {
return { messages, estimatedTokens: 0 };
},
async compact() {
return { ok: true, compacted: false };
@@ -1523,10 +1508,7 @@ If your engine does **not** own the compaction algorithm, keep `compact()`
implemented and delegate it explicitly:
```ts
import {
buildMemorySystemPromptAddition,
delegateCompactionToRuntime,
} from "openclaw/plugin-sdk/core";
import { delegateCompactionToRuntime } from "openclaw/plugin-sdk/core";
export default function (api) {
api.registerContextEngine("my-memory-engine", () => ({
@@ -1538,15 +1520,8 @@ export default function (api) {
async ingest() {
return { ingested: true };
},
async assemble({ messages, availableTools, citationsMode }) {
return {
messages,
estimatedTokens: 0,
systemPromptAddition: buildMemorySystemPromptAddition({
availableTools: availableTools ?? new Set(),
citationsMode,
}),
};
async assemble({ messages }) {
return { messages, estimatedTokens: 0 };
},
async compact(params) {
return await delegateCompactionToRuntime(params);

View File

@@ -93,9 +93,6 @@ Those belong in your plugin code and `package.json`.
"providerAuthEnvVars": {
"openrouter": ["OPENROUTER_API_KEY"]
},
"channelEnvVars": {
"openrouter-chatops": ["OPENROUTER_CHATOPS_TOKEN"]
},
"providerAuthChoices": [
{
"provider": "openrouter",
@@ -145,7 +142,6 @@ Those belong in your plugin code and `package.json`.
| `modelSupport` | No | `object` | Manifest-owned shorthand model-family metadata used to auto-load the plugin before runtime. |
| `cliBackends` | No | `string[]` | CLI inference backend ids owned by this plugin. Used for startup auto-activation from explicit config refs. |
| `providerAuthEnvVars` | No | `Record<string, string[]>` | Cheap provider-auth env metadata that OpenClaw can inspect without loading plugin code. |
| `channelEnvVars` | No | `Record<string, string[]>` | Cheap channel env metadata that OpenClaw can inspect without loading plugin code. Use this for env-driven channel setup or auth surfaces that generic startup/config helpers should see. |
| `providerAuthChoices` | No | `object[]` | Cheap auth-choice metadata for onboarding pickers, preferred-provider resolution, and simple CLI flag wiring. |
| `contracts` | No | `object` | Static bundled capability snapshot for speech, realtime transcription, realtime voice, media-understanding, image-generation, music-generation, video-generation, web-fetch, web search, and tool ownership. |
| `channelConfigs` | No | `Record<string, object>` | Manifest-owned channel config metadata merged into discovery and validation surfaces before runtime loads. |
@@ -440,9 +436,6 @@ See [Configuration reference](/gateway/configuration) for the full `plugins.*` s
- `providerAuthEnvVars` is the cheap metadata path for auth probes, env-marker
validation, and similar provider-auth surfaces that should not boot plugin
runtime just to inspect env names.
- `channelEnvVars` is the cheap metadata path for shell-env fallback, setup
prompts, and similar channel surfaces that should not boot plugin runtime
just to inspect env names.
- `providerAuthChoices` is the cheap metadata path for auth-choice pickers,
`--auth-choice` resolution, preferred-provider mapping, and simple onboarding
CLI flag registration before provider runtime loads. For runtime wizard

View File

@@ -108,15 +108,9 @@ For setup specifically:
- `openclaw/plugin-sdk/channel-setup` covers the optional-install setup
builders plus a few setup-safe primitives:
`createOptionalChannelSetupSurface`, `createOptionalChannelSetupAdapter`,
If your channel supports env-driven setup or auth and generic startup/config
flows should know those env names before runtime loads, declare them in the
plugin manifest with `channelEnvVars`. Keep channel runtime `envVars` or local
constants for operator-facing copy only.
`createOptionalChannelSetupWizard`, `DEFAULT_ACCOUNT_ID`,
`createTopLevelChannelDmPolicy`, `setSetupChannelEnabled`, and
`splitSetupEntries`
`createOptionalChannelSetupWizard`, `DEFAULT_ACCOUNT_ID`,
`createTopLevelChannelDmPolicy`, `setSetupChannelEnabled`, and
`splitSetupEntries`
- use the broader `openclaw/plugin-sdk/setup` seam only when you also need the
heavier shared setup/config helpers such as
`moveSingleAccountChannelSectionToDefaultAccount(...)`
@@ -152,87 +146,6 @@ surfaces:
Auth-only channels can usually stop at the default path: core handles approvals and the plugin just exposes outbound/auth capabilities. Native approval channels such as Matrix, Slack, Telegram, and custom chat transports should use the shared native helpers instead of rolling their own approval lifecycle.
## Inbound mention policy
Keep inbound mention handling split in two layers:
- plugin-owned evidence gathering
- shared policy evaluation
Use `openclaw/plugin-sdk/channel-inbound` for the shared layer.
Good fit for plugin-local logic:
- reply-to-bot detection
- quoted-bot detection
- thread-participation checks
- service/system-message exclusions
- platform-native caches needed to prove bot participation
Good fit for the shared helper:
- `requireMention`
- explicit mention result
- implicit mention allowlist
- command bypass
- final skip decision
Preferred flow:
1. Compute local mention facts.
2. Pass those facts into `resolveInboundMentionDecision({ facts, policy })`.
3. Use `decision.effectiveWasMentioned`, `decision.shouldBypassMention`, and `decision.shouldSkip` in your inbound gate.
```typescript
import {
implicitMentionKindWhen,
matchesMentionWithExplicit,
resolveInboundMentionDecision,
} from "openclaw/plugin-sdk/channel-inbound";
const mentionMatch = matchesMentionWithExplicit(text, {
mentionRegexes,
mentionPatterns,
});
const facts = {
canDetectMention: true,
wasMentioned: mentionMatch.matched,
hasAnyMention: mentionMatch.hasExplicitMention,
implicitMentionKinds: [
...implicitMentionKindWhen("reply_to_bot", isReplyToBot),
...implicitMentionKindWhen("quoted_bot", isQuoteOfBot),
],
};
const decision = resolveInboundMentionDecision({
facts,
policy: {
isGroup,
requireMention,
allowedImplicitMentionKinds: requireExplicitMention ? [] : ["reply_to_bot", "quoted_bot"],
allowTextCommands,
hasControlCommand,
commandAuthorized,
},
});
if (decision.shouldSkip) return;
```
`api.runtime.channel.mentions` exposes the same shared mention helpers for
bundled channel plugins that already depend on runtime injection:
- `buildMentionRegexes`
- `matchesMentionPatterns`
- `matchesMentionWithExplicit`
- `implicitMentionKindWhen`
- `resolveInboundMentionDecision`
The older `resolveMentionGating*` helpers remain on
`openclaw/plugin-sdk/channel-inbound` as compatibility exports only. New code
should use `resolveInboundMentionDecision({ facts, policy })`.
## Walkthrough
<Steps>

View File

@@ -249,8 +249,7 @@ Current bundled provider examples:
| `plugin-sdk/provider-onboard` | Provider onboarding patches | Onboarding config helpers |
| `plugin-sdk/provider-http` | Provider HTTP helpers | Generic provider HTTP/endpoint capability helpers |
| `plugin-sdk/provider-web-fetch` | Provider web-fetch helpers | Web-fetch provider registration/cache helpers |
| `plugin-sdk/provider-web-search-contract` | Provider web-search contract helpers | Narrow web-search config/credential contract helpers such as `enablePluginInConfig`, `resolveProviderWebSearchPluginConfig`, and scoped credential setters/getters |
| `plugin-sdk/provider-web-search` | Provider web-search helpers | Web-search provider registration/cache/runtime helpers |
| `plugin-sdk/provider-web-search` | Provider web-search helpers | Web-search provider registration/cache/config helpers |
| `plugin-sdk/provider-tools` | Provider tool/schema compat helpers | `ProviderToolCompatFamily`, `buildProviderToolCompatFamilyHooks`, Gemini schema cleanup + diagnostics, and xAI compat helpers such as `resolveXaiModelCompatPatch` / `applyXaiModelCompat` |
| `plugin-sdk/provider-usage` | Provider usage helpers | `fetchClaudeUsage`, `fetchGeminiUsage`, `fetchGithubCopilotUsage`, and other provider usage helpers |
| `plugin-sdk/provider-stream` | Provider stream wrapper helpers | `ProviderStreamFamily`, `buildProviderStreamFamilyHooks`, `composeProviderStreamWrappers`, stream wrapper types, and shared Anthropic/Bedrock/Google/Kilocode/Moonshot/OpenAI/OpenRouter/Z.A.I/MiniMax/Copilot wrapper helpers |

View File

@@ -108,13 +108,12 @@ explicitly promotes one as public.
| `plugin-sdk/group-access` | Shared group-access decision helpers |
| `plugin-sdk/direct-dm` | Shared direct-DM auth/guard helpers |
| `plugin-sdk/interactive-runtime` | Interactive reply payload normalization/reduction helpers |
| `plugin-sdk/channel-inbound` | Inbound debounce, mention matching, mention-policy helpers, and envelope helpers |
| `plugin-sdk/channel-inbound` | Debounce, mention matching, envelope helpers |
| `plugin-sdk/channel-send-result` | Reply result types |
| `plugin-sdk/channel-actions` | `createMessageToolButtonsSchema`, `createMessageToolCardSchema` |
| `plugin-sdk/channel-targets` | Target parsing/matching helpers |
| `plugin-sdk/channel-contract` | Channel contract types |
| `plugin-sdk/channel-feedback` | Feedback/reaction wiring |
| `plugin-sdk/channel-secret-runtime` | Narrow secret-contract helpers such as `collectSimpleChannelFieldAssignments`, `getChannelSurface`, `pushAssignment`, and secret target types |
</Accordion>
<Accordion title="Provider subpaths">
@@ -133,10 +132,8 @@ explicitly promotes one as public.
| `plugin-sdk/provider-model-shared` | `ProviderReplayFamily`, `buildProviderReplayFamilyHooks`, `normalizeModelCompat`, shared replay-policy builders, provider-endpoint helpers, and model-id normalization helpers such as `normalizeNativeXaiModelId` |
| `plugin-sdk/provider-catalog-shared` | `findCatalogTemplate`, `buildSingleProviderApiKeyCatalog`, `supportsNativeStreamingUsageCompat`, `applyProviderNativeStreamingUsageCompat` |
| `plugin-sdk/provider-http` | Generic provider HTTP/endpoint capability helpers |
| `plugin-sdk/provider-web-fetch-contract` | Narrow web-fetch config/selection contract helpers such as `enablePluginInConfig` and `WebFetchProviderPlugin` |
| `plugin-sdk/provider-web-fetch` | Web-fetch provider registration/cache helpers |
| `plugin-sdk/provider-web-search-contract` | Narrow web-search config/credential contract helpers such as `enablePluginInConfig`, `resolveProviderWebSearchPluginConfig`, and scoped credential setters/getters |
| `plugin-sdk/provider-web-search` | Web-search provider registration/cache/runtime helpers |
| `plugin-sdk/provider-web-search` | Web-search provider registration/cache/config helpers |
| `plugin-sdk/provider-tools` | `ProviderToolCompatFamily`, `buildProviderToolCompatFamilyHooks`, Gemini schema cleanup + diagnostics, and xAI compat helpers such as `resolveXaiModelCompatPatch` / `applyXaiModelCompat` |
| `plugin-sdk/provider-usage` | `fetchClaudeUsage` and similar |
| `plugin-sdk/provider-stream` | `ProviderStreamFamily`, `buildProviderStreamFamilyHooks`, `composeProviderStreamWrappers`, stream wrapper types, and shared Anthropic/Bedrock/Google/Kilocode/Moonshot/OpenAI/OpenRouter/Z.A.I/MiniMax/Copilot wrapper helpers |
@@ -157,8 +154,6 @@ explicitly promotes one as public.
| `plugin-sdk/command-detection` | Shared command detection helpers |
| `plugin-sdk/command-surface` | Command-body normalization and command-surface helpers |
| `plugin-sdk/allow-from` | `formatAllowFromLowercase` |
| `plugin-sdk/channel-secret-runtime` | Narrow secret-contract collection helpers for channel/plugin secret surfaces |
| `plugin-sdk/secret-ref-runtime` | Narrow `coerceSecretRef` and SecretRef typing helpers for secret-contract/config parsing |
| `plugin-sdk/security-runtime` | Shared trust, DM gating, external-content, and secret-collection helpers |
| `plugin-sdk/ssrf-policy` | Host allowlist and private-network SSRF policy helpers |
| `plugin-sdk/ssrf-runtime` | Pinned-dispatcher, SSRF-guarded fetch, and SSRF policy helpers |
@@ -386,7 +381,6 @@ AI CLI backend such as `codex-cli`.
| Method | What it registers |
| ------------------------------------------ | ------------------------------------- |
| `api.registerContextEngine(id, factory)` | Context engine (one active at a time) |
| `api.registerMemoryCapability(capability)` | Unified memory capability |
| `api.registerMemoryPromptSection(builder)` | Memory prompt section builder |
| `api.registerMemoryFlushPlan(resolver)` | Memory flush plan resolver |
| `api.registerMemoryRuntime(runtime)` | Memory runtime adapter |
@@ -397,13 +391,8 @@ AI CLI backend such as `codex-cli`.
| ---------------------------------------------- | ---------------------------------------------- |
| `api.registerMemoryEmbeddingProvider(adapter)` | Memory embedding adapter for the active plugin |
- `registerMemoryCapability` is the preferred exclusive memory-plugin API.
- `registerMemoryCapability` may also expose `publicArtifacts.listArtifacts(...)`
so companion plugins can consume exported memory artifacts through
`openclaw/plugin-sdk/memory-host-core` instead of reaching into a specific
memory plugin's private layout.
- `registerMemoryPromptSection`, `registerMemoryFlushPlan`, and
`registerMemoryRuntime` are legacy-compatible exclusive memory-plugin APIs.
`registerMemoryRuntime` are exclusive to memory plugins.
- `registerMemoryEmbeddingProvider` lets the active memory plugin register one
or more embedding adapter ids (for example `openai`, `gemini`, or a custom
plugin-defined id).

View File

@@ -330,46 +330,6 @@ api.runtime.tools.registerMemoryCli(/* ... */);
Channel-specific runtime helpers (available when a channel plugin is loaded).
`api.runtime.channel.mentions` is the shared inbound mention-policy surface for
bundled channel plugins that use runtime injection:
```typescript
const mentionMatch = api.runtime.channel.mentions.matchesMentionWithExplicit(text, {
mentionRegexes,
mentionPatterns,
});
const decision = api.runtime.channel.mentions.resolveInboundMentionDecision({
facts: {
canDetectMention: true,
wasMentioned: mentionMatch.matched,
implicitMentionKinds: api.runtime.channel.mentions.implicitMentionKindWhen(
"reply_to_bot",
isReplyToBot,
),
},
policy: {
isGroup,
requireMention,
allowTextCommands,
hasControlCommand,
commandAuthorized,
},
});
```
Available mention helpers:
- `buildMentionRegexes`
- `matchesMentionPatterns`
- `matchesMentionWithExplicit`
- `implicitMentionKindWhen`
- `resolveInboundMentionDecision`
`api.runtime.channel.mentions` intentionally does not expose the older
`resolveMentionGating*` compatibility helpers. Prefer the normalized
`{ facts, policy }` path.
## Storing runtime references
Use `createPluginRuntimeStore` to store the runtime reference for use outside

View File

@@ -1,87 +0,0 @@
---
title: "Arcee AI"
summary: "Arcee AI setup (auth + model selection)"
read_when:
- You want to use Arcee AI with OpenClaw
- You need the API key env var or CLI auth choice
---
# Arcee AI
[Arcee AI](https://arcee.ai) provides access to the Trinity family of mixture-of-experts models through an OpenAI-compatible API. All Trinity models are Apache 2.0 licensed.
Arcee AI models can be accessed directly via the Arcee platform or through [OpenRouter](/providers/openrouter).
- Provider: `arcee`
- Auth: `ARCEEAI_API_KEY` (direct) or `OPENROUTER_API_KEY` (via OpenRouter)
- API: OpenAI-compatible
- Base URL: `https://api.arcee.ai/api/v1` (direct) or `https://openrouter.ai/api/v1` (OpenRouter)
## Quick start
1. Get an API key from [Arcee AI](https://chat.arcee.ai/) or [OpenRouter](https://openrouter.ai/keys).
2. Set the API key (recommended: store it for the Gateway):
```bash
# Direct (Arcee platform)
openclaw onboard --auth-choice arceeai-api-key
# Via OpenRouter
openclaw onboard --auth-choice arceeai-openrouter
```
3. Set a default model:
```json5
{
agents: {
defaults: {
model: { primary: "arcee/trinity-large-thinking" },
},
},
}
```
## Non-interactive example
```bash
# Direct (Arcee platform)
openclaw onboard --non-interactive \
--mode local \
--auth-choice arceeai-api-key \
--arceeai-api-key "$ARCEEAI_API_KEY"
# Via OpenRouter
openclaw onboard --non-interactive \
--mode local \
--auth-choice arceeai-openrouter \
--openrouter-api-key "$OPENROUTER_API_KEY"
```
## Environment note
If the Gateway runs as a daemon (launchd/systemd), make sure `ARCEEAI_API_KEY`
(or `OPENROUTER_API_KEY`) is available to that process (for example, in
`~/.openclaw/.env` or via `env.shellEnv`).
## Built-in catalog
OpenClaw currently ships this bundled Arcee catalog:
| Model ref | Name | Input | Context | Cost (in/out per 1M) | Notes |
| ------------------------------ | ---------------------- | ----- | ------- | -------------------- | ----------------------------------------- |
| `arcee/trinity-large-thinking` | Trinity Large Thinking | text | 256K | $0.25 / $0.90 | Default model; reasoning enabled |
| `arcee/trinity-large-preview` | Trinity Large Preview | text | 128K | $0.25 / $1.00 | General-purpose; 400B params, 13B active |
| `arcee/trinity-mini` | Trinity Mini 26B | text | 128K | $0.045 / $0.15 | Fast and cost-efficient; function calling |
The same model refs work for both direct and OpenRouter setups (for example `arcee/trinity-large-thinking`).
The onboarding preset sets `arcee/trinity-large-thinking` as the default model.
## Supported features
- Streaming
- Tool use / function calling
- Structured output (JSON mode and JSON schema)
- Extended thinking (Trinity Large Thinking)

View File

@@ -29,7 +29,6 @@ Looking for chat channel docs (WhatsApp/Telegram/Discord/Slack/Mattermost (plugi
- [Alibaba Model Studio](/providers/alibaba)
- [Amazon Bedrock](/providers/bedrock)
- [Anthropic (API + Claude CLI)](/providers/anthropic)
- [Arcee AI (Trinity models)](/providers/arcee)
- [BytePlus (International)](/concepts/model-providers#byteplus-international)
- [Chutes](/providers/chutes)
- [ComfyUI](/providers/comfy)

View File

@@ -33,15 +33,15 @@ openclaw onboard --mistral-api-key "$MISTRAL_API_KEY"
OpenClaw currently ships this bundled Mistral catalog:
| Model ref | Input | Context | Max output | Notes |
| -------------------------------- | ----------- | ------- | ---------- | ---------------------------------------------------------------- |
| `mistral/mistral-large-latest` | text, image | 262,144 | 16,384 | Default model |
| `mistral/mistral-medium-2508` | text, image | 262,144 | 8,192 | Mistral Medium 3.1 |
| `mistral/mistral-small-latest` | text, image | 128,000 | 16,384 | Mistral Small 4; adjustable reasoning via API `reasoning_effort` |
| `mistral/pixtral-large-latest` | text, image | 128,000 | 32,768 | Pixtral |
| `mistral/codestral-latest` | text | 256,000 | 4,096 | Coding |
| `mistral/devstral-medium-latest` | text | 262,144 | 32,768 | Devstral 2 |
| `mistral/magistral-small` | text | 128,000 | 40,000 | Reasoning-enabled |
| Model ref | Input | Context | Max output | Notes |
| -------------------------------- | ----------- | ------- | ---------- | ------------------------ |
| `mistral/mistral-large-latest` | text, image | 262,144 | 16,384 | Default model |
| `mistral/mistral-medium-2508` | text, image | 262,144 | 8,192 | Mistral Medium 3.1 |
| `mistral/mistral-small-latest` | text, image | 128,000 | 16,384 | Smaller multimodal model |
| `mistral/pixtral-large-latest` | text, image | 128,000 | 32,768 | Pixtral |
| `mistral/codestral-latest` | text | 256,000 | 4,096 | Coding |
| `mistral/devstral-medium-latest` | text | 262,144 | 32,768 | Devstral 2 |
| `mistral/magistral-small` | text | 128,000 | 40,000 | Reasoning-enabled |
## Config snippet (audio transcription with Voxtral)
@@ -58,17 +58,6 @@ OpenClaw currently ships this bundled Mistral catalog:
}
```
## Adjustable reasoning (`mistral-small-latest`)
`mistral/mistral-small-latest` maps to Mistral Small 4 and supports [adjustable reasoning](https://docs.mistral.ai/capabilities/reasoning/adjustable) on the Chat Completions API via `reasoning_effort` (`none` minimizes extra thinking in the output; `high` surfaces full thinking traces before the final answer).
OpenClaw maps the session **thinking** level to Mistrals API:
- **off** / **minimal**`none`
- **low** / **medium** / **high** / **xhigh** / **adaptive**`high`
Other bundled Mistral catalog models do not use this parameter; keep using `magistral-*` models when you want Mistrals native reasoning-first behavior.
## Notes
- Mistral auth uses `MISTRAL_API_KEY`.

View File

@@ -85,28 +85,8 @@ Notes:
- `vydra/veo3` is bundled as text-to-video only.
- `vydra/kling` currently requires a remote image URL reference. Local file uploads are rejected up front.
- Vydra's current `kling` HTTP route has been inconsistent about whether it requires `image_url` or `video_url`; the bundled provider maps the same remote image URL into both fields.
- The bundled plugin stays conservative and does not forward undocumented style knobs such as aspect ratio, resolution, watermark, or generated audio.
Provider-specific live coverage:
```bash
OPENCLAW_LIVE_TEST=1 \
OPENCLAW_LIVE_VYDRA_VIDEO=1 \
pnpm test:live -- extensions/vydra/vydra.live.test.ts
```
The bundled Vydra live file now covers:
- `vydra/veo3` text-to-video
- `vydra/kling` image-to-video using a remote image URL
Override the remote image fixture when needed:
```bash
export OPENCLAW_LIVE_VYDRA_KLING_IMAGE_URL="https://example.com/reference.png"
```
See [Video Generation](/tools/video-generation) for shared tool behavior.
## Speech synthesis

View File

@@ -13,7 +13,7 @@ title: "Tests"
- `pnpm test:coverage`: Runs the unit suite with V8 coverage (via `vitest.unit.config.ts`). Global thresholds are 70% lines/branches/functions/statements. Coverage excludes integration-heavy entrypoints (CLI wiring, gateway/telegram bridges, webchat static server) to keep the target focused on unit-testable logic.
- `pnpm test:coverage:changed`: Runs unit coverage only for files changed since `origin/main`.
- `pnpm test:changed`: expands changed git paths into scoped Vitest lanes when the diff only touches routable source/test files. Config/setup changes still fall back to the native root projects run so wiring edits rerun broadly when needed.
- `pnpm test`: routes explicit file/directory targets through scoped Vitest lanes. Untargeted runs now execute eleven sequential shard configs (`vitest.full-core-unit-src.config.ts`, `vitest.full-core-unit-security.config.ts`, `vitest.full-core-unit-ui.config.ts`, `vitest.full-core-unit-support.config.ts`, `vitest.full-core-support-boundary.config.ts`, `vitest.full-core-contracts.config.ts`, `vitest.full-core-bundled.config.ts`, `vitest.full-core-runtime.config.ts`, `vitest.full-agentic.config.ts`, `vitest.full-auto-reply.config.ts`, `vitest.full-extensions.config.ts`) instead of one giant root-project process.
- `pnpm test`: routes explicit file/directory targets through scoped Vitest lanes. Untargeted runs now execute five sequential shard configs (`vitest.full-core-unit.config.ts`, `vitest.full-core-runtime.config.ts`, `vitest.full-agentic.config.ts`, `vitest.full-auto-reply.config.ts`, `vitest.full-extensions.config.ts`) instead of one giant root-project process.
- Selected `plugin-sdk` and `commands` test files now route through dedicated light lanes that keep only `test/setup.ts`, leaving runtime-heavy cases on their existing lanes.
- Selected `plugin-sdk` and `commands` helper source files also map `pnpm test:changed` to explicit sibling tests in those light lanes, so small helper edits avoid rerunning the heavy runtime-backed suites.
- `auto-reply` now also splits into three dedicated configs (`core`, `top-level`, `reply`) so the reply harness does not dominate the lighter top-level status/token/helper tests.

View File

@@ -68,9 +68,7 @@ Use `action: "list"` to inspect available providers and models at runtime:
| `count` | number | Number of images to generate (14) |
| `filename` | string | Output filename hint |
Not all providers support all parameters. When a fallback provider supports a nearby geometry option instead of the exact requested one, OpenClaw remaps to the closest supported size, aspect ratio, or resolution before submission. Truly unsupported overrides are still reported in the tool result.
Tool results report the applied settings. When OpenClaw remaps geometry during provider fallback, the returned `size`, `aspectRatio`, and `resolution` values reflect what was actually sent, and `details.normalization` captures the requested-to-applied translation.
Not all providers support all parameters. The tool passes what each provider supports, ignores the rest, and reports dropped overrides in the tool result.
## Configuration
@@ -106,10 +104,6 @@ Notes:
- Auto-detection is auth-aware. A provider default only enters the candidate list
when OpenClaw can actually authenticate that provider.
- Auto-detection is enabled by default. Set
`agents.defaults.mediaGenerationAutoProviderFallback: false` if you want image
generation to use only the explicit `model`, `primary`, and `fallbacks`
entries.
- Use `action: "list"` to inspect the currently registered providers, their
default models, and auth env-var hints.

View File

@@ -131,12 +131,8 @@ Direct generation example:
| `filename` | string | Output filename hint |
Not all providers support all parameters. OpenClaw still validates hard limits
such as input counts before submission. When a provider supports duration but
uses a shorter maximum than the requested value, OpenClaw automatically clamps
to the closest supported duration. Truly unsupported optional hints are ignored
with a warning when the selected provider or model cannot honor them.
Tool results report the applied settings. When OpenClaw clamps duration during provider fallback, the returned `durationSeconds` reflects the submitted value and `details.normalization.durationSeconds` shows the requested-to-applied mapping.
such as input counts before submission, but unsupported optional hints are
ignored with a warning when the selected provider or model cannot honor them.
## Async behavior for the shared provider-backed path
@@ -198,10 +194,6 @@ When generating music, OpenClaw tries providers in this order:
If a provider fails, the next candidate is tried automatically. If all fail, the
error includes details from each attempt.
Set `agents.defaults.mediaGenerationAutoProviderFallback: false` if you want
music generation to use only the explicit `model`, `primary`, and `fallbacks`
entries.
## Provider notes
- Google uses Lyria 3 batch generation. The current bundled flow supports
@@ -256,12 +248,6 @@ Opt-in live coverage for the shared bundled providers:
OPENCLAW_LIVE_TEST=1 pnpm test:live -- extensions/music-generation-providers.live.test.ts
```
Repo wrapper:
```bash
pnpm test:live:media music
```
This live file loads missing provider env vars from `~/.profile`, prefers
live/env API keys ahead of stored auth profiles by default, and runs both
`generate` and declared `edit` coverage when the provider enables edit mode.

View File

@@ -103,20 +103,20 @@ runtime modes at runtime.
This is the explicit mode contract used by `video_generate`, contract tests,
and the shared live sweep.
| Provider | `generate` | `imageToVideo` | `videoToVideo` | Shared live lanes today |
| -------- | ---------- | -------------- | -------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
| Alibaba | Yes | Yes | Yes | `generate`, `imageToVideo`; `videoToVideo` skipped because this provider needs remote `http(s)` video URLs |
| BytePlus | Yes | Yes | No | `generate`, `imageToVideo` |
| ComfyUI | Yes | Yes | No | Not in the shared sweep; workflow-specific coverage lives with Comfy tests |
| fal | Yes | Yes | No | `generate`, `imageToVideo` |
| Google | Yes | Yes | Yes | `generate`, `imageToVideo`; shared `videoToVideo` skipped because the current buffer-backed Gemini/Veo sweep does not accept that input |
| MiniMax | Yes | Yes | No | `generate`, `imageToVideo` |
| OpenAI | Yes | Yes | Yes | `generate`, `imageToVideo`; shared `videoToVideo` skipped because this org/input path currently needs provider-side inpaint/remix access |
| Qwen | Yes | Yes | Yes | `generate`, `imageToVideo`; `videoToVideo` skipped because this provider needs remote `http(s)` video URLs |
| Runway | Yes | Yes | Yes | `generate`, `imageToVideo`; `videoToVideo` runs only when the selected model is `runway/gen4_aleph` |
| Together | Yes | Yes | No | `generate`, `imageToVideo` |
| Vydra | Yes | Yes | No | `generate`; shared `imageToVideo` skipped because bundled `veo3` is text-only and bundled `kling` requires a remote image URL |
| xAI | Yes | Yes | Yes | `generate`, `imageToVideo`; `videoToVideo` skipped because this provider currently needs a remote MP4 URL |
| Provider | `generate` | `imageToVideo` | `videoToVideo` | Shared live lanes today |
| -------- | ---------- | -------------- | -------------- | ---------------------------------------------------------------------------------------------------------- |
| Alibaba | Yes | Yes | Yes | `generate`, `imageToVideo`; `videoToVideo` skipped because this provider needs remote `http(s)` video URLs |
| BytePlus | Yes | Yes | No | `generate`, `imageToVideo` |
| ComfyUI | Yes | Yes | No | Not in the shared sweep; workflow-specific coverage lives with Comfy tests |
| fal | Yes | Yes | No | `generate`, `imageToVideo` |
| Google | Yes | Yes | Yes | `generate`, `imageToVideo`, `videoToVideo` |
| MiniMax | Yes | Yes | No | `generate`, `imageToVideo` |
| OpenAI | Yes | Yes | Yes | `generate`, `imageToVideo`, `videoToVideo` |
| Qwen | Yes | Yes | Yes | `generate`, `imageToVideo`; `videoToVideo` skipped because this provider needs remote `http(s)` video URLs |
| Runway | Yes | Yes | Yes | `generate`, `imageToVideo`; `videoToVideo` runs only when the selected model is `runway/gen4_aleph` |
| Together | Yes | Yes | No | `generate`, `imageToVideo` |
| Vydra | Yes | Yes | No | `generate`, `imageToVideo` |
| xAI | Yes | Yes | Yes | `generate`, `imageToVideo`; `videoToVideo` skipped because this provider currently needs a remote MP4 URL |
## Tool parameters
@@ -140,7 +140,7 @@ and the shared live sweep.
| Parameter | Type | Description |
| ----------------- | ------- | ------------------------------------------------------------------------ |
| `aspectRatio` | string | `1:1`, `2:3`, `3:2`, `3:4`, `4:3`, `4:5`, `5:4`, `9:16`, `16:9`, `21:9` |
| `resolution` | string | `480P`, `720P`, `768P`, or `1080P` |
| `resolution` | string | `480P`, `720P`, or `1080P` |
| `durationSeconds` | number | Target duration in seconds (rounded to nearest provider-supported value) |
| `size` | string | Size hint when the provider supports it |
| `audio` | boolean | Enable generated audio when supported |
@@ -154,9 +154,7 @@ and the shared live sweep.
| `model` | string | Provider/model override (e.g. `runway/gen4.5`) |
| `filename` | string | Output filename hint |
Not all providers support all parameters. OpenClaw already normalizes duration to the closest provider-supported value, and it also remaps translated geometry hints such as size-to-aspect-ratio when a fallback provider exposes a different control surface. Truly unsupported overrides are ignored on a best-effort basis and reported as warnings in the tool result. Hard capability limits (such as too many reference inputs) fail before submission.
Tool results report the applied settings. When OpenClaw remaps duration or geometry during provider fallback, the returned `durationSeconds`, `size`, `aspectRatio`, and `resolution` values reflect what was submitted, and `details.normalization` captures the requested-to-applied translation.
Not all providers support all parameters. Unsupported overrides are ignored on a best-effort basis and reported as warnings in the tool result. Hard capability limits (such as too many reference inputs) fail before submission.
Reference inputs also select the runtime mode:
@@ -184,10 +182,6 @@ When generating a video, OpenClaw resolves the model in this order:
If a provider fails, the next candidate is tried automatically. If all candidates fail, the error includes details from each attempt.
Set `agents.defaults.mediaGenerationAutoProviderFallback: false` if you want
video generation to use only the explicit `model`, `primary`, and `fallbacks`
entries.
```json5
{
agents: {
@@ -260,12 +254,6 @@ Opt-in live coverage for the shared bundled providers:
OPENCLAW_LIVE_TEST=1 pnpm test:live -- extensions/video-generation-providers.live.test.ts
```
Repo wrapper:
```bash
pnpm test:live:media video
```
This live file loads missing provider env vars from `~/.profile`, prefers
live/env API keys ahead of stored auth profiles by default, and runs the
declared modes it can exercise safely with local media:
@@ -277,6 +265,8 @@ declared modes it can exercise safely with local media:
Today the shared `videoToVideo` live lane covers:
- `google`
- `openai`
- `runway` only when you select `runway/gen4_aleph`
## Configuration

View File

@@ -4,10 +4,7 @@
"description": "OpenClaw ACP runtime backend",
"type": "module",
"dependencies": {
"acpx": "0.5.1"
},
"devDependencies": {
"@openclaw/plugin-sdk": "workspace:*"
"acpx": "0.5.0"
},
"openclaw": {
"extensions": [

View File

@@ -1,56 +0,0 @@
declare module "acpx/runtime" {
export const ACPX_BACKEND_ID: string;
export type AcpRuntimeDoctorReport = import("../runtime-api.js").AcpRuntimeDoctorReport;
export type AcpRuntimeEnsureInput = import("../runtime-api.js").AcpRuntimeEnsureInput;
export type AcpRuntimeEvent = import("../runtime-api.js").AcpRuntimeEvent;
export type AcpRuntimeHandle = import("../runtime-api.js").AcpRuntimeHandle;
export type AcpRuntimeCapabilities = import("../runtime-api.js").AcpRuntimeCapabilities;
export type AcpRuntimeStatus = import("../runtime-api.js").AcpRuntimeStatus;
export type AcpRuntimeTurnInput = import("../runtime-api.js").AcpRuntimeTurnInput;
export type AcpAgentRegistry = {
resolve(agent: string): string | undefined;
list(): string[];
};
export type AcpSessionRecord = Record<string, unknown>;
export type AcpSessionStore = {
load(sessionId: string): Promise<AcpSessionRecord | undefined>;
save(record: AcpSessionRecord): Promise<void>;
};
export type AcpRuntimeOptions = {
cwd: string;
sessionStore: AcpSessionStore;
agentRegistry: AcpAgentRegistry;
mcpServers?: unknown;
permissionMode?: unknown;
nonInteractivePermissions?: unknown;
timeoutMs?: number;
};
export class AcpxRuntime {
constructor(options: AcpRuntimeOptions, testOptions?: unknown);
isHealthy(): boolean;
probeAvailability(): Promise<void>;
doctor(): Promise<AcpRuntimeDoctorReport>;
ensureSession(input: AcpRuntimeEnsureInput): Promise<AcpRuntimeHandle>;
runTurn(input: AcpRuntimeTurnInput): AsyncIterable<AcpRuntimeEvent>;
getCapabilities(input?: {
handle?: AcpRuntimeHandle;
}): AcpRuntimeCapabilities | Promise<AcpRuntimeCapabilities>;
getStatus(input: { handle: AcpRuntimeHandle; signal?: AbortSignal }): Promise<AcpRuntimeStatus>;
setMode(input: { handle: AcpRuntimeHandle; mode: string }): Promise<void>;
setConfigOption(input: { handle: AcpRuntimeHandle; key: string; value: string }): Promise<void>;
cancel(input: { handle: AcpRuntimeHandle; reason?: string }): Promise<void>;
close(input: { handle: AcpRuntimeHandle; reason?: string }): Promise<void>;
}
export function createAcpRuntime(...args: unknown[]): AcpxRuntime;
export function createAgentRegistry(params: { overrides?: unknown }): AcpAgentRegistry;
export function createFileSessionStore(params: { stateDir: string }): AcpSessionStore;
export function decodeAcpxRuntimeHandleState(...args: unknown[]): unknown;
export function encodeAcpxRuntimeHandleState(...args: unknown[]): unknown;
}

55
extensions/acpx/src/acpx-runtime.d.ts vendored Normal file
View File

@@ -0,0 +1,55 @@
declare module "acpx/runtime" {
export const ACPX_BACKEND_ID: string;
export type AcpRuntimeDoctorReport =
import("../../../src/acp/runtime/types.js").AcpRuntimeDoctorReport;
export type AcpRuntimeEnsureInput =
import("../../../src/acp/runtime/types.js").AcpRuntimeEnsureInput;
export type AcpRuntimeEvent = import("../../../src/acp/runtime/types.js").AcpRuntimeEvent;
export type AcpRuntimeHandle = import("../../../src/acp/runtime/types.js").AcpRuntimeHandle;
export type AcpRuntimeTurnInput = import("../../../src/acp/runtime/types.js").AcpRuntimeTurnInput;
export type AcpRuntimeStatus = import("../../../src/acp/runtime/types.js").AcpRuntimeStatus;
export type AcpRuntimeCapabilities =
import("../../../src/acp/runtime/types.js").AcpRuntimeCapabilities;
export type AcpSessionStore = {
load(sessionId: string): Promise<unknown>;
save(record: unknown): Promise<void>;
};
export type AcpAgentRegistry = {
resolve(agentId: string): string;
list(): string[];
};
export type AcpRuntimeOptions = {
cwd: string;
sessionStore: AcpSessionStore;
agentRegistry: AcpAgentRegistry;
permissionMode: string;
mcpServers?: unknown[];
nonInteractivePermissions?: unknown;
timeoutMs?: number;
};
export class AcpxRuntime {
constructor(options: AcpRuntimeOptions, testOptions?: unknown);
isHealthy(): boolean;
probeAvailability(): Promise<void>;
doctor(): Promise<AcpRuntimeDoctorReport>;
ensureSession(input: AcpRuntimeEnsureInput): Promise<AcpRuntimeHandle>;
runTurn(input: AcpRuntimeTurnInput): AsyncIterable<AcpRuntimeEvent>;
getCapabilities(input?: { handle?: AcpRuntimeHandle }): AcpRuntimeCapabilities;
getStatus(input: { handle: AcpRuntimeHandle; signal?: AbortSignal }): Promise<AcpRuntimeStatus>;
setMode(input: { handle: AcpRuntimeHandle; mode: string }): Promise<void>;
setConfigOption(input: { handle: AcpRuntimeHandle; key: string; value: string }): Promise<void>;
cancel(input: { handle: AcpRuntimeHandle; reason?: string }): Promise<void>;
close(input: { handle: AcpRuntimeHandle; reason: string }): Promise<void>;
}
export function createAcpRuntime(...args: unknown[]): unknown;
export function createAgentRegistry(...args: unknown[]): AcpAgentRegistry;
export function createFileSessionStore(...args: unknown[]): AcpSessionStore;
export function decodeAcpxRuntimeHandleState(...args: unknown[]): unknown;
export function encodeAcpxRuntimeHandleState(...args: unknown[]): unknown;
}

View File

@@ -1,6 +0,0 @@
export function formatErrorMessage(error) {
if (error instanceof Error) {
return error.message || error.name || "Error";
}
return String(error);
}

View File

@@ -4,7 +4,6 @@ import { spawn } from "node:child_process";
import path from "node:path";
import { createInterface } from "node:readline";
import { pathToFileURL } from "node:url";
import { formatErrorMessage } from "./error-format.mjs";
import { splitCommandLine } from "./mcp-command-line.mjs";
function decodePayload(argv) {
@@ -95,7 +94,7 @@ function main() {
child.stdout.pipe(process.stdout);
child.on("error", (error) => {
process.stderr.write(`${formatErrorMessage(error)}\n`);
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
process.exit(1);
});

View File

@@ -17,13 +17,6 @@ vi.mock("../runtime-api.js", () => ({
},
}));
vi.mock("./runtime.js", () => ({
ACPX_BACKEND_ID: "acpx",
AcpxRuntime: class {},
createAgentRegistry: vi.fn(() => ({})),
createFileSessionStore: vi.fn(() => ({})),
}));
import { getAcpRuntimeBackend } from "../runtime-api.js";
import { createAcpxRuntimeService } from "./service.js";
@@ -37,8 +30,6 @@ async function makeTempDir(): Promise<string> {
afterEach(async () => {
runtimeRegistry.clear();
delete process.env.OPENCLAW_SKIP_ACPX_RUNTIME;
delete process.env.OPENCLAW_SKIP_ACPX_RUNTIME_PROBE;
for (const dir of tempDirs.splice(0)) {
await fs.rm(dir, { recursive: true, force: true });
}
@@ -142,50 +133,4 @@ describe("createAcpxRuntimeService", () => {
await service.stop?.(ctx);
});
it("can skip the embedded runtime probe via env", async () => {
process.env.OPENCLAW_SKIP_ACPX_RUNTIME_PROBE = "1";
const workspaceDir = await makeTempDir();
const ctx = createServiceContext(workspaceDir);
const probeAvailability = vi.fn(async () => {});
const service = createAcpxRuntimeService({
runtimeFactory: () =>
({
ensureSession: vi.fn(),
runTurn: vi.fn(),
cancel: vi.fn(),
close: vi.fn(),
probeAvailability,
isHealthy: () => false,
doctor: async () => ({ ok: false, message: "nope" }),
}) as never,
});
await service.start(ctx);
expect(probeAvailability).not.toHaveBeenCalled();
expect(getAcpRuntimeBackend("acpx")).toBeTruthy();
await service.stop?.(ctx);
});
it("can skip the embedded runtime backend via env", async () => {
process.env.OPENCLAW_SKIP_ACPX_RUNTIME = "1";
const workspaceDir = await makeTempDir();
const ctx = createServiceContext(workspaceDir);
const runtimeFactory = vi.fn(() => {
throw new Error("runtime factory should not run when ACPX is skipped");
});
const service = createAcpxRuntimeService({
runtimeFactory: runtimeFactory as never,
});
await service.start(ctx);
expect(runtimeFactory).not.toHaveBeenCalled();
expect(getAcpRuntimeBackend("acpx")).toBeUndefined();
expect(ctx.logger.info).toHaveBeenCalledWith(
"skipping embedded acpx runtime backend (OPENCLAW_SKIP_ACPX_RUNTIME=1)",
);
});
});

View File

@@ -1,5 +1,4 @@
import fs from "node:fs/promises";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import type {
AcpRuntime,
OpenClawPluginService,
@@ -91,11 +90,6 @@ export function createAcpxRuntimeService(
return {
id: "acpx-runtime",
async start(ctx: OpenClawPluginServiceContext): Promise<void> {
if (process.env.OPENCLAW_SKIP_ACPX_RUNTIME === "1") {
ctx.logger.info("skipping embedded acpx runtime backend (OPENCLAW_SKIP_ACPX_RUNTIME=1)");
return;
}
const pluginConfig = resolveAcpxPluginConfig({
rawConfig: params.pluginConfig,
workspaceDir: ctx.workspaceDir,
@@ -119,10 +113,6 @@ export function createAcpxRuntimeService(
});
ctx.logger.info(`embedded acpx runtime backend registered (cwd: ${pluginConfig.cwd})`);
if (process.env.OPENCLAW_SKIP_ACPX_RUNTIME_PROBE === "1") {
return;
}
lifecycleRevision += 1;
const currentRevision = lifecycleRevision;
void (async () => {
@@ -146,7 +136,9 @@ export function createAcpxRuntimeService(
if (currentRevision !== lifecycleRevision) {
return;
}
ctx.logger.warn(`embedded acpx runtime setup failed: ${formatErrorMessage(err)}`);
ctx.logger.warn(
`embedded acpx runtime setup failed: ${err instanceof Error ? err.message : String(err)}`,
);
}
})();
},

View File

@@ -1,16 +0,0 @@
{
"extends": "../tsconfig.package-boundary.base.json",
"compilerOptions": {
"rootDir": "."
},
"include": ["./*.ts", "./src/**/*.ts"],
"exclude": [
"./**/*.test.ts",
"./dist/**",
"./node_modules/**",
"./src/test-support/**",
"./src/**/*test-helpers.ts",
"./src/**/*test-harness.ts",
"./src/**/*test-support.ts"
]
}

View File

@@ -4,9 +4,6 @@
"private": true,
"description": "OpenClaw Alibaba Model Studio video provider plugin",
"type": "module",
"devDependencies": {
"@openclaw/plugin-sdk": "workspace:*"
},
"openclaw": {
"extensions": [
"./index.ts"

View File

@@ -1,16 +0,0 @@
{
"extends": "../tsconfig.package-boundary.base.json",
"compilerOptions": {
"rootDir": "."
},
"include": ["./*.ts", "./src/**/*.ts"],
"exclude": [
"./**/*.test.ts",
"./dist/**",
"./node_modules/**",
"./src/test-support/**",
"./src/**/*test-helpers.ts",
"./src/**/*test-harness.ts",
"./src/**/*test-support.ts"
]
}

View File

@@ -2,28 +2,48 @@ import { isProviderApiKeyConfigured } from "openclaw/plugin-sdk/provider-auth";
import { resolveApiKeyForProvider } from "openclaw/plugin-sdk/provider-auth-runtime";
import {
assertOkOrThrowHttpError,
fetchWithTimeout,
postJsonRequest,
resolveProviderHttpRequestConfig,
} from "openclaw/plugin-sdk/provider-http";
import {
DEFAULT_VIDEO_GENERATION_DURATION_SECONDS,
DEFAULT_VIDEO_GENERATION_TIMEOUT_MS,
DEFAULT_VIDEO_RESOLUTION_TO_SIZE,
buildDashscopeVideoGenerationInput,
buildDashscopeVideoGenerationParameters,
downloadDashscopeGeneratedVideos,
extractDashscopeVideoUrls,
pollDashscopeVideoTaskUntilComplete,
} from "openclaw/plugin-sdk/video-generation";
import type {
DashscopeVideoGenerationResponse,
GeneratedVideoAsset,
VideoGenerationProvider,
VideoGenerationRequest,
VideoGenerationResult,
VideoGenerationSourceAsset,
} from "openclaw/plugin-sdk/video-generation";
const DEFAULT_ALIBABA_VIDEO_BASE_URL = "https://dashscope-intl.aliyuncs.com";
const DEFAULT_ALIBABA_VIDEO_MODEL = "wan2.6-t2v";
const DEFAULT_DURATION_SECONDS = 5;
const DEFAULT_TIMEOUT_MS = 120_000;
const POLL_INTERVAL_MS = 2_500;
const MAX_POLL_ATTEMPTS = 120;
const RESOLUTION_TO_SIZE: Record<string, string> = {
"480P": "832*480",
"720P": "1280*720",
"1080P": "1920*1080",
};
type AlibabaVideoGenerationResponse = {
output?: {
task_id?: string;
task_status?: string;
submit_time?: string;
results?: Array<{
video_url?: string;
orig_prompt?: string;
actual_prompt?: string;
}>;
video_url?: string;
code?: string;
message?: string;
};
request_id?: string;
code?: string;
message?: string;
};
function resolveAlibabaVideoBaseUrl(req: VideoGenerationRequest): string {
return req.cfg?.models?.providers?.alibaba?.baseUrl?.trim() || DEFAULT_ALIBABA_VIDEO_BASE_URL;
@@ -33,6 +53,139 @@ function resolveDashscopeAigcApiBaseUrl(baseUrl: string): string {
return baseUrl.replace(/\/+$/u, "");
}
function resolveReferenceUrls(
inputImages: VideoGenerationSourceAsset[] | undefined,
inputVideos: VideoGenerationSourceAsset[] | undefined,
): string[] {
return [...(inputImages ?? []), ...(inputVideos ?? [])]
.map((asset) => asset.url?.trim())
.filter((value): value is string => Boolean(value));
}
function assertAlibabaReferenceInputsSupported(
inputImages: VideoGenerationSourceAsset[] | undefined,
inputVideos: VideoGenerationSourceAsset[] | undefined,
): void {
const unsupported = [...(inputImages ?? []), ...(inputVideos ?? [])].some(
(asset) => !asset.url?.trim() && asset.buffer,
);
if (unsupported) {
throw new Error(
"Alibaba Wan video generation currently requires remote http(s) URLs for reference images/videos.",
);
}
}
function buildAlibabaVideoGenerationInput(req: VideoGenerationRequest): Record<string, unknown> {
assertAlibabaReferenceInputsSupported(req.inputImages, req.inputVideos);
const input: Record<string, unknown> = {
prompt: req.prompt,
};
const referenceUrls = resolveReferenceUrls(req.inputImages, req.inputVideos);
if (
referenceUrls.length === 1 &&
(req.inputImages?.length ?? 0) === 1 &&
!req.inputVideos?.length
) {
input.img_url = referenceUrls[0];
} else if (referenceUrls.length > 0) {
input.reference_urls = referenceUrls;
}
return input;
}
function buildAlibabaVideoGenerationParameters(
req: VideoGenerationRequest,
): Record<string, unknown> | undefined {
const parameters: Record<string, unknown> = {};
const size =
req.size?.trim() || (req.resolution ? RESOLUTION_TO_SIZE[req.resolution] : undefined);
if (size) {
parameters.size = size;
}
if (req.aspectRatio?.trim()) {
parameters.aspect_ratio = req.aspectRatio.trim();
}
if (typeof req.durationSeconds === "number" && Number.isFinite(req.durationSeconds)) {
parameters.duration = Math.max(1, Math.round(req.durationSeconds));
}
if (typeof req.audio === "boolean") {
parameters.enable_audio = req.audio;
}
if (typeof req.watermark === "boolean") {
parameters.watermark = req.watermark;
}
return Object.keys(parameters).length > 0 ? parameters : undefined;
}
function extractVideoUrls(payload: AlibabaVideoGenerationResponse): string[] {
const urls = [
...(payload.output?.results?.map((entry) => entry.video_url).filter(Boolean) ?? []),
payload.output?.video_url,
].filter((value): value is string => typeof value === "string" && value.trim().length > 0);
return [...new Set(urls)];
}
async function pollTaskUntilComplete(params: {
taskId: string;
headers: Headers;
timeoutMs?: number;
fetchFn: typeof fetch;
baseUrl: string;
}): Promise<AlibabaVideoGenerationResponse> {
for (let attempt = 0; attempt < MAX_POLL_ATTEMPTS; attempt += 1) {
const response = await fetchWithTimeout(
`${params.baseUrl}/api/v1/tasks/${params.taskId}`,
{
method: "GET",
headers: params.headers,
},
params.timeoutMs ?? DEFAULT_TIMEOUT_MS,
params.fetchFn,
);
await assertOkOrThrowHttpError(response, "Alibaba Wan video-generation task poll failed");
const payload = (await response.json()) as AlibabaVideoGenerationResponse;
const status = payload.output?.task_status?.trim().toUpperCase();
if (status === "SUCCEEDED") {
return payload;
}
if (status === "FAILED" || status === "CANCELED") {
throw new Error(
payload.output?.message?.trim() ||
payload.message?.trim() ||
`Alibaba Wan video generation task ${params.taskId} ${status?.toLowerCase()}`,
);
}
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
}
throw new Error(`Alibaba Wan video generation task ${params.taskId} did not finish in time`);
}
async function downloadGeneratedVideos(params: {
urls: string[];
timeoutMs?: number;
fetchFn: typeof fetch;
}): Promise<GeneratedVideoAsset[]> {
const videos: GeneratedVideoAsset[] = [];
for (const [index, url] of params.urls.entries()) {
const response = await fetchWithTimeout(
url,
{ method: "GET" },
params.timeoutMs ?? DEFAULT_TIMEOUT_MS,
params.fetchFn,
);
await assertOkOrThrowHttpError(response, "Alibaba Wan generated video download failed");
const arrayBuffer = await response.arrayBuffer();
videos.push({
buffer: Buffer.from(arrayBuffer),
mimeType: response.headers.get("content-type")?.trim() || "video/mp4",
fileName: `video-${index + 1}.mp4`,
metadata: { sourceUrl: url },
});
}
return videos;
}
export function buildAlibabaVideoGenerationProvider(): VideoGenerationProvider {
return {
id: "alibaba",
@@ -110,17 +263,11 @@ export function buildAlibabaVideoGenerationProvider(): VideoGenerationProvider {
headers,
body: {
model,
input: buildDashscopeVideoGenerationInput({
providerLabel: "Alibaba Wan",
req,
input: buildAlibabaVideoGenerationInput(req),
parameters: buildAlibabaVideoGenerationParameters({
...req,
durationSeconds: req.durationSeconds ?? DEFAULT_DURATION_SECONDS,
}),
parameters: buildDashscopeVideoGenerationParameters(
{
...req,
durationSeconds: req.durationSeconds ?? DEFAULT_VIDEO_GENERATION_DURATION_SECONDS,
},
DEFAULT_VIDEO_RESOLUTION_TO_SIZE,
),
},
timeoutMs: req.timeoutMs,
fetchFn,
@@ -130,30 +277,26 @@ export function buildAlibabaVideoGenerationProvider(): VideoGenerationProvider {
try {
await assertOkOrThrowHttpError(response, "Alibaba Wan video generation failed");
const submitted = (await response.json()) as DashscopeVideoGenerationResponse;
const submitted = (await response.json()) as AlibabaVideoGenerationResponse;
const taskId = submitted.output?.task_id?.trim();
if (!taskId) {
throw new Error("Alibaba Wan video generation response missing task_id");
}
const completed = await pollDashscopeVideoTaskUntilComplete({
providerLabel: "Alibaba Wan",
const completed = await pollTaskUntilComplete({
taskId,
headers,
timeoutMs: req.timeoutMs,
fetchFn,
baseUrl: resolveDashscopeAigcApiBaseUrl(baseUrl),
defaultTimeoutMs: DEFAULT_VIDEO_GENERATION_TIMEOUT_MS,
});
const urls = extractDashscopeVideoUrls(completed);
const urls = extractVideoUrls(completed);
if (urls.length === 0) {
throw new Error("Alibaba Wan video generation completed without output video URLs");
}
const videos = await downloadDashscopeGeneratedVideos({
providerLabel: "Alibaba Wan",
const videos = await downloadGeneratedVideos({
urls,
timeoutMs: req.timeoutMs,
fetchFn,
defaultTimeoutMs: DEFAULT_VIDEO_GENERATION_TIMEOUT_MS,
});
return {
videos,

View File

@@ -1,5 +1,4 @@
import { createSubsystemLogger } from "openclaw/plugin-sdk/core";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import type {
ModelDefinitionConfig,
ModelProviderConfig,
@@ -103,7 +102,7 @@ export async function generateBearerTokenFromIam(params: {
} catch (error) {
log.debug?.("Mantle IAM token generation unavailable", {
region: params.region,
error: formatErrorMessage(error),
error: error instanceof Error ? error.message : String(error),
});
return undefined;
}
@@ -234,7 +233,7 @@ export async function discoverMantleModels(params: {
return models;
} catch (error) {
log.debug?.("Mantle model discovery error", {
error: formatErrorMessage(error),
error: error instanceof Error ? error.message : String(error),
});
return cached?.models ?? [];
}

View File

@@ -7,9 +7,6 @@
"dependencies": {
"@aws/bedrock-token-generator": "^1.1.0"
},
"devDependencies": {
"@openclaw/plugin-sdk": "workspace:*"
},
"openclaw": {
"bundle": {
"stageRuntimeDependencies": true

View File

@@ -1,16 +0,0 @@
{
"extends": "../tsconfig.package-boundary.base.json",
"compilerOptions": {
"rootDir": "."
},
"include": ["./*.ts", "./src/**/*.ts"],
"exclude": [
"./**/*.test.ts",
"./dist/**",
"./node_modules/**",
"./src/test-support/**",
"./src/**/*test-helpers.ts",
"./src/**/*test-harness.ts",
"./src/**/*test-support.ts"
]
}

View File

@@ -1,11 +1,13 @@
import { isRecord } from "openclaw/plugin-sdk/text-runtime";
type JsonRecord = Record<string, unknown>;
const LEGACY_PATH = "models.bedrockDiscovery";
const TARGET_PATH = "plugins.entries.amazon-bedrock.config.discovery";
const BLOCKED_OBJECT_KEYS = new Set(["__proto__", "prototype", "constructor"]);
function isRecord(value: unknown): value is JsonRecord {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function isBlockedObjectKey(key: string): boolean {
return BLOCKED_OBJECT_KEYS.has(key);
}

View File

@@ -6,7 +6,6 @@ import {
type ListInferenceProfilesCommandOutput,
} from "@aws-sdk/client-bedrock";
import { createSubsystemLogger } from "openclaw/plugin-sdk/core";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { resolveAwsSdkEnvVarName } from "openclaw/plugin-sdk/provider-auth-runtime";
import type {
BedrockDiscoveryConfig,
@@ -215,7 +214,7 @@ async function fetchInferenceProfileSummaries(
return profiles;
} catch (error) {
log.debug?.("Skipping inference profile discovery", {
error: formatErrorMessage(error),
error: error instanceof Error ? error.message : String(error),
});
return [];
}
@@ -407,7 +406,7 @@ export async function discoverBedrockModels(params: {
if (!hasLoggedBedrockError) {
hasLoggedBedrockError = true;
log.warn("Failed to discover Bedrock models", {
error: formatErrorMessage(error),
error: error instanceof Error ? error.message : String(error),
});
}
return [];

View File

@@ -34,9 +34,6 @@
}
}
},
"configContracts": {
"compatibilityMigrationPaths": ["models.bedrockDiscovery"]
},
"uiHints": {
"discovery": {
"label": "Model Discovery",

View File

@@ -7,9 +7,6 @@
"dependencies": {
"@aws-sdk/client-bedrock": "3.1024.0"
},
"devDependencies": {
"@openclaw/plugin-sdk": "workspace:*"
},
"openclaw": {
"bundle": {
"stageRuntimeDependencies": true

View File

@@ -1,16 +0,0 @@
{
"extends": "../tsconfig.package-boundary.base.json",
"compilerOptions": {
"rootDir": "."
},
"include": ["./*.ts", "./src/**/*.ts"],
"exclude": [
"./**/*.test.ts",
"./dist/**",
"./node_modules/**",
"./src/test-support/**",
"./src/**/*test-helpers.ts",
"./src/**/*test-harness.ts",
"./src/**/*test-support.ts"
]
}

View File

@@ -1,3 +1,4 @@
import type { ModelProviderConfig } from "openclaw/plugin-sdk/provider-model-shared";
export {
ANTHROPIC_VERTEX_DEFAULT_MODEL_ID,
buildAnthropicVertexProvider,
@@ -15,9 +16,9 @@ import { buildAnthropicVertexProvider } from "./provider-catalog.js";
import { hasAnthropicVertexAvailableAuth } from "./region.js";
export function mergeImplicitAnthropicVertexProvider(params: {
existing?: ReturnType<typeof buildAnthropicVertexProvider>;
implicit: ReturnType<typeof buildAnthropicVertexProvider>;
}) {
existing: ModelProviderConfig | undefined;
implicit: ModelProviderConfig;
}): ModelProviderConfig {
const { existing, implicit } = params;
if (!existing) {
return implicit;
@@ -32,7 +33,9 @@ export function mergeImplicitAnthropicVertexProvider(params: {
};
}
export function resolveImplicitAnthropicVertexProvider(params?: { env?: NodeJS.ProcessEnv }) {
export function resolveImplicitAnthropicVertexProvider(params?: {
env?: NodeJS.ProcessEnv;
}): ModelProviderConfig | null {
const env = params?.env ?? process.env;
if (!hasAnthropicVertexAvailableAuth(env)) {
return null;

View File

@@ -4,9 +4,6 @@
"private": true,
"description": "OpenClaw Anthropic Vertex provider plugin",
"type": "module",
"devDependencies": {
"@openclaw/plugin-sdk": "workspace:*"
},
"openclaw": {
"extensions": [
"./index.ts"

View File

@@ -1,16 +0,0 @@
{
"extends": "../tsconfig.package-boundary.base.json",
"compilerOptions": {
"rootDir": "."
},
"include": ["./*.ts", "./src/**/*.ts"],
"exclude": [
"./**/*.test.ts",
"./dist/**",
"./node_modules/**",
"./src/test-support/**",
"./src/**/*test-helpers.ts",
"./src/**/*test-harness.ts",
"./src/**/*test-support.ts"
]
}

View File

@@ -5,7 +5,6 @@ import {
} from "openclaw/plugin-sdk/cli-backend";
import {
CLAUDE_CLI_BACKEND_ID,
CLAUDE_CLI_DEFAULT_MODEL_REF,
CLAUDE_CLI_CLEAR_ENV,
CLAUDE_CLI_HOST_MANAGED_ENV,
CLAUDE_CLI_MODEL_ALIASES,
@@ -16,14 +15,6 @@ import {
export function buildAnthropicCliBackend(): CliBackendPlugin {
return {
id: CLAUDE_CLI_BACKEND_ID,
liveTest: {
defaultModelRef: CLAUDE_CLI_DEFAULT_MODEL_REF,
defaultImageProbe: true,
docker: {
npmPackage: "@anthropic-ai/claude-code",
binaryName: "claude",
},
},
bundleMcp: true,
config: {
command: "claude",

View File

@@ -4,9 +4,6 @@
"private": true,
"description": "OpenClaw Anthropic provider plugin",
"type": "module",
"devDependencies": {
"@openclaw/plugin-sdk": "workspace:*"
},
"openclaw": {
"extensions": [
"./index.ts"

View File

@@ -1,15 +0,0 @@
import {
applyAnthropicConfigDefaults,
normalizeAnthropicProviderConfig,
} from "./config-defaults.js";
export function normalizeConfig(params: {
provider: string;
providerConfig: Parameters<typeof normalizeAnthropicProviderConfig>[0];
}) {
return normalizeAnthropicProviderConfig(params.providerConfig);
}
export function applyConfigDefaults(params: Parameters<typeof applyAnthropicConfigDefaults>[0]) {
return applyAnthropicConfigDefaults(params);
}

View File

@@ -8,7 +8,6 @@ import {
streamWithPayloadPatch,
} from "openclaw/plugin-sdk/provider-stream-shared";
import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env";
import { normalizeOptionalString, readStringValue } from "openclaw/plugin-sdk/text-runtime";
const log = createSubsystemLogger("anthropic-stream");
@@ -83,7 +82,7 @@ function normalizeAnthropicServiceTier(value: unknown): AnthropicServiceTier | u
if (typeof value !== "string") {
return undefined;
}
const normalized = normalizeOptionalString(value)?.toLowerCase();
const normalized = value.trim().toLowerCase();
if (normalized === "auto" || normalized === "standard_only") {
return normalized;
}
@@ -158,9 +157,9 @@ export function createAnthropicFastModeWrapper(
}
const payloadPolicy = resolveAnthropicPayloadPolicy({
provider: readStringValue(model.provider),
api: readStringValue(model.api),
baseUrl: readStringValue(model.baseUrl),
provider: typeof model.provider === "string" ? model.provider : undefined,
api: typeof model.api === "string" ? model.api : undefined,
baseUrl: typeof model.baseUrl === "string" ? model.baseUrl : undefined,
serviceTier,
});
if (!payloadPolicy.allowsServiceTier) {
@@ -184,9 +183,9 @@ export function createAnthropicServiceTierWrapper(
}
const payloadPolicy = resolveAnthropicPayloadPolicy({
provider: readStringValue(model.provider),
api: readStringValue(model.api),
baseUrl: readStringValue(model.baseUrl),
provider: typeof model.provider === "string" ? model.provider : undefined,
api: typeof model.api === "string" ? model.api : undefined,
baseUrl: typeof model.baseUrl === "string" ? model.baseUrl : undefined,
serviceTier,
});
if (!payloadPolicy.allowsServiceTier) {

View File

@@ -1,16 +0,0 @@
{
"extends": "../tsconfig.package-boundary.base.json",
"compilerOptions": {
"rootDir": "."
},
"include": ["./*.ts", "./src/**/*.ts"],
"exclude": [
"./**/*.test.ts",
"./dist/**",
"./node_modules/**",
"./src/test-support/**",
"./src/**/*test-helpers.ts",
"./src/**/*test-harness.ts",
"./src/**/*test-support.ts"
]
}

View File

@@ -1,8 +0,0 @@
export { buildArceeModelDefinition, ARCEE_BASE_URL, ARCEE_MODEL_CATALOG } from "./models.js";
export { buildArceeProvider, buildArceeOpenRouterProvider } from "./provider-catalog.js";
export {
applyArceeConfig,
applyArceeOpenRouterConfig,
ARCEE_DEFAULT_MODEL_REF,
ARCEE_OPENROUTER_DEFAULT_MODEL_REF,
} from "./onboard.js";

View File

@@ -1,167 +0,0 @@
import { describe, expect, it } from "vitest";
import { resolveProviderPluginChoice } from "../../src/plugins/provider-auth-choice.runtime.js";
import { resolveProviderAuthEnvVarCandidates } from "../../src/secrets/provider-env-vars.js";
import { registerSingleProviderPlugin } from "../../test/helpers/plugins/plugin-registration.js";
import arceePlugin from "./index.js";
describe("arcee provider plugin", () => {
it("registers Arcee AI with direct and OpenRouter auth choices", async () => {
const provider = await registerSingleProviderPlugin(arceePlugin);
expect(provider.id).toBe("arcee");
expect(provider.label).toBe("Arcee AI");
expect(provider.envVars).toEqual(["ARCEEAI_API_KEY", "OPENROUTER_API_KEY"]);
expect(provider.auth).toHaveLength(2);
const directChoice = resolveProviderPluginChoice({
providers: [provider],
choice: "arceeai-api-key",
});
expect(directChoice).not.toBeNull();
expect(directChoice?.provider.id).toBe("arcee");
expect(directChoice?.method.id).toBe("arcee-platform");
const orChoice = resolveProviderPluginChoice({
providers: [provider],
choice: "arceeai-openrouter",
});
expect(orChoice).not.toBeNull();
expect(orChoice?.provider.id).toBe("arcee");
expect(orChoice?.method.id).toBe("openrouter");
});
it("stores the OpenRouter onboarding path under the OpenRouter auth profile", async () => {
const provider = await registerSingleProviderPlugin(arceePlugin);
const openRouterMethod = provider.auth?.find((method) => method.id === "openrouter");
if (!openRouterMethod?.runNonInteractive) {
throw new Error("expected OpenRouter non-interactive auth");
}
const config = await openRouterMethod.runNonInteractive({
config: {},
opts: {},
env: {},
runtime: {
error: () => {},
exit: () => {},
log: () => {},
},
resolveApiKey: async () => ({
key: "sk-or-test",
source: "profile",
}),
toApiKeyCredential: () => null,
} as never);
expect(config?.auth?.profiles?.["openrouter:default"]).toMatchObject({
provider: "openrouter",
mode: "api_key",
});
expect(config?.models?.providers?.arcee).toMatchObject({
baseUrl: "https://openrouter.ai/api/v1",
api: "openai-completions",
});
expect(config?.models?.providers?.arcee?.models?.map((model) => model.id)).toEqual([
"arcee/trinity-mini",
"arcee/trinity-large-preview",
"arcee/trinity-large-thinking",
]);
});
it("keeps direct Arcee auth env candidates separate from OpenRouter", () => {
const candidates = resolveProviderAuthEnvVarCandidates();
expect(candidates.arcee).toEqual(["ARCEEAI_API_KEY"]);
expect(candidates.openrouter).toEqual(["OPENROUTER_API_KEY"]);
});
it("builds the direct Arcee AI model catalog", async () => {
const provider = await registerSingleProviderPlugin(arceePlugin);
expect(provider.catalog).toBeDefined();
const catalog = await provider.catalog!.run({
config: {},
env: {},
resolveProviderApiKey: (id: string) =>
id === "arcee" ? { apiKey: "test-key" } : { apiKey: undefined },
resolveProviderAuth: () => ({
apiKey: "test-key",
mode: "api_key",
source: "env",
}),
} as never);
expect(catalog && "provider" in catalog).toBe(true);
if (!catalog || !("provider" in catalog)) {
throw new Error("expected single-provider catalog");
}
expect(catalog.provider.api).toBe("openai-completions");
expect(catalog.provider.baseUrl).toBe("https://api.arcee.ai/api/v1");
expect(catalog.provider.models?.map((model) => model.id)).toEqual([
"trinity-mini",
"trinity-large-preview",
"trinity-large-thinking",
]);
});
it("builds the OpenRouter-backed Arcee AI model catalog", async () => {
const provider = await registerSingleProviderPlugin(arceePlugin);
const catalog = await provider.catalog!.run({
config: {},
env: {},
resolveProviderApiKey: (id: string) =>
id === "openrouter" ? { apiKey: "sk-or-test" } : { apiKey: undefined },
resolveProviderAuth: () => ({
apiKey: "sk-or-test",
mode: "api_key",
source: "env",
}),
} as never);
expect(catalog && "provider" in catalog).toBe(true);
if (!catalog || !("provider" in catalog)) {
throw new Error("expected single-provider catalog");
}
expect(catalog.provider.baseUrl).toBe("https://openrouter.ai/api/v1");
expect(catalog.provider.models?.map((model) => model.id)).toEqual([
"arcee/trinity-mini",
"arcee/trinity-large-preview",
"arcee/trinity-large-thinking",
]);
});
it("normalizes Arcee OpenRouter models to vendor-prefixed runtime ids", async () => {
const provider = await registerSingleProviderPlugin(arceePlugin);
expect(
provider.normalizeResolvedModel?.({
modelId: "arcee/trinity-large-thinking",
model: {
provider: "arcee",
id: "trinity-large-thinking",
name: "Trinity Large Thinking",
api: "openai-completions",
baseUrl: "https://openrouter.ai/api/v1",
},
} as never),
).toMatchObject({
id: "arcee/trinity-large-thinking",
});
expect(
provider.normalizeResolvedModel?.({
modelId: "arcee/trinity-large-thinking",
model: {
provider: "arcee",
id: "trinity-large-thinking",
name: "Trinity Large Thinking",
api: "openai-completions",
baseUrl: "https://api.arcee.ai/api/v1",
},
} as never),
).toBeUndefined();
});
});

View File

@@ -1,128 +0,0 @@
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth-api-key";
import {
readConfiguredProviderCatalogEntries,
type ProviderCatalogContext,
} from "openclaw/plugin-sdk/provider-catalog-shared";
import { buildProviderReplayFamilyHooks } from "openclaw/plugin-sdk/provider-model-shared";
import type { OpenClawConfig } from "openclaw/plugin-sdk/provider-onboard";
import {
applyArceeConfig,
applyArceeOpenRouterConfig,
ARCEE_DEFAULT_MODEL_REF,
ARCEE_OPENROUTER_DEFAULT_MODEL_REF,
} from "./onboard.js";
import {
buildArceeProvider,
buildArceeOpenRouterProvider,
isArceeOpenRouterBaseUrl,
toArceeOpenRouterModelId,
} from "./provider-catalog.js";
const PROVIDER_ID = "arcee";
const OPENAI_COMPATIBLE_REPLAY_HOOKS = buildProviderReplayFamilyHooks({
family: "openai-compatible",
});
const ARCEE_WIZARD_GROUP = {
groupId: "arcee",
groupLabel: "Arcee AI",
groupHint: "Direct API or OpenRouter",
} as const;
function buildArceeAuthMethods() {
return [
createProviderApiKeyAuthMethod({
providerId: PROVIDER_ID,
methodId: "arcee-platform",
label: "Arcee AI API key",
hint: "Direct access to Arcee platform",
optionKey: "arceeaiApiKey",
flagName: "--arceeai-api-key",
envVar: "ARCEEAI_API_KEY",
promptMessage: "Enter Arcee AI API key",
defaultModel: ARCEE_DEFAULT_MODEL_REF,
expectedProviders: [PROVIDER_ID],
applyConfig: (cfg) => applyArceeConfig(cfg),
wizard: {
choiceId: "arceeai-api-key",
choiceLabel: "Arcee AI API key",
choiceHint: "Direct (chat.arcee.ai)",
...ARCEE_WIZARD_GROUP,
},
}),
createProviderApiKeyAuthMethod({
providerId: PROVIDER_ID,
methodId: "openrouter",
label: "OpenRouter API key",
hint: "Access Arcee models via OpenRouter",
optionKey: "openrouterApiKey",
flagName: "--openrouter-api-key",
envVar: "OPENROUTER_API_KEY",
promptMessage: "Enter OpenRouter API key",
profileId: "openrouter:default",
defaultModel: ARCEE_OPENROUTER_DEFAULT_MODEL_REF,
expectedProviders: [PROVIDER_ID, "openrouter"],
applyConfig: (cfg) => applyArceeOpenRouterConfig(cfg),
wizard: {
choiceId: "arceeai-openrouter",
choiceLabel: "OpenRouter API key",
choiceHint: "Via OpenRouter (openrouter.ai)",
...ARCEE_WIZARD_GROUP,
},
}),
];
}
function readConfiguredArceeCatalogEntries(config: OpenClawConfig | undefined) {
return readConfiguredProviderCatalogEntries({
config,
providerId: PROVIDER_ID,
});
}
async function resolveArceeCatalog(ctx: ProviderCatalogContext) {
const directKey = ctx.resolveProviderApiKey(PROVIDER_ID).apiKey;
if (directKey) {
return { provider: { ...buildArceeProvider(), apiKey: directKey } };
}
const openRouterKey = ctx.resolveProviderApiKey("openrouter").apiKey;
if (openRouterKey) {
return { provider: { ...buildArceeOpenRouterProvider(), apiKey: openRouterKey } };
}
return null;
}
function normalizeArceeResolvedModel<T extends { baseUrl?: string; id: string }>(
model: T,
): T | undefined {
if (!isArceeOpenRouterBaseUrl(model.baseUrl)) {
return undefined;
}
return {
...model,
id: toArceeOpenRouterModelId(model.id),
};
}
export default definePluginEntry({
id: PROVIDER_ID,
name: "Arcee AI Provider",
description: "Bundled Arcee AI provider plugin",
register(api) {
api.registerProvider({
id: PROVIDER_ID,
label: "Arcee AI",
docsPath: "/providers/arcee",
envVars: ["ARCEEAI_API_KEY", "OPENROUTER_API_KEY"],
auth: buildArceeAuthMethods(),
catalog: {
run: resolveArceeCatalog,
},
augmentModelCatalog: ({ config }) => readConfiguredArceeCatalogEntries(config),
normalizeResolvedModel: ({ model }) => normalizeArceeResolvedModel(model),
...OPENAI_COMPATIBLE_REPLAY_HOOKS,
});
},
});

View File

@@ -1,67 +0,0 @@
import type { ModelDefinitionConfig } from "openclaw/plugin-sdk/provider-model-shared";
export const ARCEE_BASE_URL = "https://api.arcee.ai/api/v1";
export const ARCEE_MODEL_CATALOG: ModelDefinitionConfig[] = [
{
id: "trinity-mini",
name: "Trinity Mini 26B",
reasoning: false,
input: ["text"],
contextWindow: 131072,
maxTokens: 80000,
cost: {
input: 0.045,
output: 0.15,
cacheRead: 0.045,
cacheWrite: 0.045,
},
},
{
id: "trinity-large-preview",
name: "Trinity Large Preview",
reasoning: false,
input: ["text"],
contextWindow: 131072,
maxTokens: 16384,
cost: {
input: 0.25,
output: 1.0,
cacheRead: 0.25,
cacheWrite: 0.25,
},
},
{
id: "trinity-large-thinking",
name: "Trinity Large Thinking",
reasoning: true,
input: ["text"],
contextWindow: 262144,
maxTokens: 80000,
cost: {
input: 0.25,
output: 0.9,
cacheRead: 0.25,
cacheWrite: 0.25,
},
compat: {
supportsReasoningEffort: false,
},
},
];
export function buildArceeModelDefinition(
model: (typeof ARCEE_MODEL_CATALOG)[number],
): ModelDefinitionConfig {
return {
id: model.id,
name: model.name,
api: "openai-completions",
reasoning: model.reasoning,
input: model.input,
cost: model.cost,
contextWindow: model.contextWindow,
maxTokens: model.maxTokens,
...(model.compat ? { compat: model.compat } : {}),
};
}

View File

@@ -1,47 +0,0 @@
import {
createModelCatalogPresetAppliers,
type OpenClawConfig,
} from "openclaw/plugin-sdk/provider-onboard";
import { ARCEE_BASE_URL } from "./api.js";
import {
buildArceeCatalogModels,
buildArceeOpenRouterCatalogModels,
OPENROUTER_BASE_URL,
} from "./provider-catalog.js";
export const ARCEE_DEFAULT_MODEL_REF = "arcee/trinity-large-thinking";
export const ARCEE_OPENROUTER_DEFAULT_MODEL_REF = "arcee/trinity-large-thinking";
const arceePresetAppliers = createModelCatalogPresetAppliers({
primaryModelRef: ARCEE_DEFAULT_MODEL_REF,
resolveParams: (_cfg: OpenClawConfig) => ({
providerId: "arcee",
api: "openai-completions",
baseUrl: ARCEE_BASE_URL,
catalogModels: buildArceeCatalogModels(),
aliases: [{ modelRef: ARCEE_DEFAULT_MODEL_REF, alias: "Arcee AI" }],
}),
});
const arceeOpenRouterPresetAppliers = createModelCatalogPresetAppliers({
primaryModelRef: ARCEE_OPENROUTER_DEFAULT_MODEL_REF,
resolveParams: (_cfg: OpenClawConfig) => ({
providerId: "arcee",
api: "openai-completions",
baseUrl: OPENROUTER_BASE_URL,
catalogModels: buildArceeOpenRouterCatalogModels(),
aliases: [{ modelRef: ARCEE_OPENROUTER_DEFAULT_MODEL_REF, alias: "Arcee AI (OpenRouter)" }],
}),
});
export function applyArceeProviderConfig(cfg: OpenClawConfig): OpenClawConfig {
return arceePresetAppliers.applyProviderConfig(cfg);
}
export function applyArceeConfig(cfg: OpenClawConfig): OpenClawConfig {
return arceePresetAppliers.applyConfig(cfg);
}
export function applyArceeOpenRouterConfig(cfg: OpenClawConfig): OpenClawConfig {
return arceeOpenRouterPresetAppliers.applyConfig(cfg);
}

View File

@@ -1,43 +0,0 @@
{
"id": "arcee",
"enabledByDefault": true,
"providers": ["arcee"],
"providerAuthEnvVars": {
"arcee": ["ARCEEAI_API_KEY"]
},
"providerAuthChoices": [
{
"provider": "arcee",
"method": "arcee-platform",
"choiceId": "arceeai-api-key",
"choiceLabel": "Arcee AI API key",
"choiceHint": "Direct (chat.arcee.ai)",
"groupId": "arcee",
"groupLabel": "Arcee AI",
"groupHint": "Direct API or OpenRouter",
"optionKey": "arceeaiApiKey",
"cliFlag": "--arceeai-api-key",
"cliOption": "--arceeai-api-key <key>",
"cliDescription": "Arcee AI API key"
},
{
"provider": "arcee",
"method": "openrouter",
"choiceId": "arceeai-openrouter",
"choiceLabel": "OpenRouter API key",
"choiceHint": "Via OpenRouter (openrouter.ai)",
"groupId": "arcee",
"groupLabel": "Arcee AI",
"groupHint": "Direct API or OpenRouter",
"optionKey": "openrouterApiKey",
"cliFlag": "--openrouter-api-key",
"cliOption": "--openrouter-api-key <key>",
"cliDescription": "OpenRouter API key for Arcee AI models"
}
],
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
}
}

View File

@@ -1,15 +0,0 @@
{
"name": "@openclaw/arcee-provider",
"version": "2026.4.4",
"private": true,
"description": "OpenClaw Arcee provider plugin",
"type": "module",
"devDependencies": {
"@openclaw/plugin-sdk": "workspace:*"
},
"openclaw": {
"extensions": [
"./index.ts"
]
}
}

View File

@@ -1,49 +0,0 @@
import type { ModelProviderConfig } from "openclaw/plugin-sdk/provider-model-shared";
import { buildArceeModelDefinition, ARCEE_BASE_URL, ARCEE_MODEL_CATALOG } from "./api.js";
export const OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1";
function normalizeBaseUrl(baseUrl: string | undefined): string {
return String(baseUrl ?? "")
.trim()
.replace(/\/+$/, "");
}
export function isArceeOpenRouterBaseUrl(baseUrl: string | undefined): boolean {
return normalizeBaseUrl(baseUrl) === OPENROUTER_BASE_URL;
}
export function toArceeOpenRouterModelId(modelId: string): string {
const normalized = modelId.trim();
if (!normalized || normalized.startsWith("arcee/")) {
return normalized;
}
return `arcee/${normalized}`;
}
export function buildArceeCatalogModels(): NonNullable<ModelProviderConfig["models"]> {
return ARCEE_MODEL_CATALOG.map(buildArceeModelDefinition);
}
export function buildArceeOpenRouterCatalogModels(): NonNullable<ModelProviderConfig["models"]> {
return buildArceeCatalogModels().map((model) => ({
...model,
id: toArceeOpenRouterModelId(model.id),
}));
}
export function buildArceeProvider(): ModelProviderConfig {
return {
baseUrl: ARCEE_BASE_URL,
api: "openai-completions",
models: buildArceeCatalogModels(),
};
}
export function buildArceeOpenRouterProvider(): ModelProviderConfig {
return {
baseUrl: OPENROUTER_BASE_URL,
api: "openai-completions",
models: buildArceeOpenRouterCatalogModels(),
};
}

View File

@@ -1,16 +0,0 @@
{
"extends": "../tsconfig.package-boundary.base.json",
"compilerOptions": {
"rootDir": "."
},
"include": ["./*.ts", "./src/**/*.ts"],
"exclude": [
"./**/*.test.ts",
"./dist/**",
"./node_modules/**",
"./src/test-support/**",
"./src/**/*test-helpers.ts",
"./src/**/*test-harness.ts",
"./src/**/*test-support.ts"
]
}

View File

@@ -1,4 +0,0 @@
export {
collectRuntimeConfigAssignments,
secretTargetRegistryEntries,
} from "./src/secret-contract.js";

View File

@@ -4,7 +4,6 @@
"description": "OpenClaw BlueBubbles channel plugin",
"type": "module",
"devDependencies": {
"@openclaw/plugin-sdk": "workspace:*",
"openclaw": "workspace:*"
},
"peerDependencies": {

View File

@@ -1,4 +0,0 @@
export {
collectRuntimeConfigAssignments,
secretTargetRegistryEntries,
} from "./src/secret-contract.js";

View File

@@ -5,7 +5,6 @@ import {
} from "openclaw/plugin-sdk/account-resolution";
import { resolveChannelStreamingChunkMode } from "openclaw/plugin-sdk/channel-streaming";
import type { OpenClawConfig } from "openclaw/plugin-sdk/core";
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
import { hasConfiguredSecretInput, normalizeSecretInputString } from "./secret-input.js";
import { normalizeBlueBubblesServerUrl, type BlueBubblesAccountConfig } from "./types.js";
@@ -59,7 +58,7 @@ export function resolveBlueBubblesAccount(params: {
return {
accountId,
enabled: baseEnabled !== false && accountEnabled,
name: normalizeOptionalString(merged.name),
name: merged.name?.trim() || undefined,
config: merged,
configured,
baseUrl,

View File

@@ -1,7 +1,5 @@
import crypto from "node:crypto";
import path from "node:path";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
import { resolveBlueBubblesServerAccount } from "./account-resolve.js";
import { assertMultipartActionOk, postMultipartFormData } from "./multipart.js";
import {
@@ -132,7 +130,7 @@ export async function downloadBlueBubblesAttachment(
cause: error,
});
}
const text = formatErrorMessage(error);
const text = error instanceof Error ? error.message : String(error);
throw new Error(`BlueBubbles attachment download failed: ${text}`, { cause: error });
}
}
@@ -162,7 +160,7 @@ export async function sendBlueBubblesAttachment(params: {
const wantsVoice = asVoice === true;
const fallbackName = wantsVoice ? "Audio Message" : "attachment";
filename = sanitizeFilename(filename, fallbackName);
contentType = normalizeOptionalString(contentType);
contentType = contentType?.trim() || undefined;
const { baseUrl, password, accountId, allowPrivateNetwork } = resolveAccount(opts);
const privateApiStatus = getCachedBlueBubblesPrivateApiStatus(accountId);
const privateApiEnabled = isBlueBubblesPrivateApiStatusEnabled(privateApiStatus);

View File

@@ -17,7 +17,6 @@ import {
createComputedAccountStatusAdapter,
createDefaultChannelRuntimeState,
} from "openclaw/plugin-sdk/status-helpers";
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
import { type ResolvedBlueBubblesAccount } from "./accounts.js";
import { bluebubblesMessageActions } from "./actions.js";
import {
@@ -136,7 +135,7 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount, BlueBu
looksLikeId: looksLikeBlueBubblesExplicitTargetId,
hint: "<handle|chat_guid:GUID|chat_id:ID|chat_identifier:ID>",
resolveTarget: async ({ normalized }) => {
const to = normalizeOptionalString(normalized);
const to = normalized?.trim();
if (!to) {
return null;
}
@@ -161,7 +160,7 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount, BlueBu
// Helper to extract a clean handle from any BlueBubbles target format
const extractCleanDisplay = (value: string | undefined): string | null => {
const trimmed = normalizeOptionalString(value);
const trimmed = value?.trim();
if (!trimmed) {
return null;
}
@@ -197,7 +196,7 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount, BlueBu
};
// Try to get a clean display from the display parameter first
const trimmedDisplay = normalizeOptionalString(display);
const trimmedDisplay = display?.trim();
if (trimmedDisplay) {
if (!shouldParseDisplay(trimmedDisplay)) {
return trimmedDisplay;
@@ -215,7 +214,7 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount, BlueBu
}
// Last resort: return display or target as-is
return normalizeOptionalString(display) || normalizeOptionalString(target) || "";
return display?.trim() || target?.trim() || "";
},
},
setup: blueBubblesSetupAdapter,
@@ -287,7 +286,7 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount, BlueBu
},
threading: {
buildToolContext: ({ context, hasRepliedRef }) => ({
currentChannelId: normalizeOptionalString(context.To),
currentChannelId: context.To?.trim() || undefined,
currentThreadTs: context.ReplyToIdFull ?? context.ReplyToId,
hasRepliedRef,
}),
@@ -315,7 +314,7 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount, BlueBu
deliveryMode: "direct",
textChunkLimit: 4000,
resolveTarget: ({ to }) => {
const trimmed = normalizeOptionalString(to);
const trimmed = to?.trim();
if (!trimmed) {
return {
ok: false,
@@ -329,7 +328,7 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount, BlueBu
channel: "bluebubbles",
sendText: async ({ cfg, to, text, accountId, replyToId }) => {
const runtime = await loadBlueBubblesChannelRuntime();
const rawReplyToId = normalizeOptionalString(replyToId) ?? "";
const rawReplyToId = typeof replyToId === "string" ? replyToId.trim() : "";
const replyToMessageGuid = rawReplyToId
? runtime.resolveBlueBubblesMessageId(rawReplyToId, { requireKnownShortId: true })
: "";

View File

@@ -8,10 +8,6 @@ type BlueBubblesConfigPatch = {
};
type AccountEnabledMode = boolean | "preserve-or-true";
type BlueBubblesAccountEntry = {
enabled?: boolean;
[key: string]: unknown;
};
function normalizePatch(
patch: BlueBubblesConfigPatch,
@@ -55,9 +51,7 @@ export function applyBlueBubblesConnectionConfig(params: {
};
}
const currentAccount = params.cfg.channels?.bluebubbles?.accounts?.[params.accountId] as
| BlueBubblesAccountEntry
| undefined;
const currentAccount = params.cfg.channels?.bluebubbles?.accounts?.[params.accountId];
const enabled =
params.accountEnabled === "preserve-or-true"
? (currentAccount?.enabled ?? true)

View File

@@ -27,13 +27,7 @@ describe("bluebubbles doctor", () => {
expect(result.config.channels?.bluebubbles?.network).toEqual({
dangerouslyAllowPrivateNetwork: true,
});
expect(
(
result.config.channels?.bluebubbles?.accounts?.default as {
network?: { dangerouslyAllowPrivateNetwork?: boolean };
}
)?.network,
).toEqual({
expect(result.config.channels?.bluebubbles?.accounts?.default?.network).toEqual({
dangerouslyAllowPrivateNetwork: false,
});
});

View File

@@ -201,8 +201,8 @@ export async function sendBlueBubblesMedia(params: {
const maxBytes = resolveChannelMediaMaxBytes({
cfg,
resolveChannelLimitMb: ({ cfg, accountId }) =>
(cfg.channels?.bluebubbles?.accounts?.[accountId] as { mediaMaxMb?: number } | undefined)
?.mediaMaxMb ?? cfg.channels?.bluebubbles?.mediaMaxMb,
cfg.channels?.bluebubbles?.accounts?.[accountId]?.mediaMaxMb ??
cfg.channels?.bluebubbles?.mediaMaxMb,
accountId,
});
const mediaLocalRoots = resolveMediaLocalRoots({ cfg, accountId });

View File

@@ -1,14 +1,20 @@
import { parseFiniteNumber } from "openclaw/plugin-sdk/infra-runtime";
import {
asNullableRecord,
normalizeOptionalString,
readStringField,
} from "openclaw/plugin-sdk/text-runtime";
import { extractHandleFromChatGuid, normalizeBlueBubblesHandle } from "./targets.js";
import type { BlueBubblesAttachment } from "./types.js";
export const asRecord = asNullableRecord;
const readString = readStringField;
function asRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
function readString(record: Record<string, unknown> | null, key: string): string | undefined {
if (!record) {
return undefined;
}
const value = record[key];
return typeof value === "string" ? value : undefined;
}
function readNumber(record: Record<string, unknown> | null, key: string): number | undefined {
if (!record) {
@@ -168,8 +174,8 @@ function extractReplyMetadata(message: Record<string, unknown>): {
: undefined;
return {
replyToId: normalizeOptionalString(replyToId ?? fallbackReplyId),
replyToBody: normalizeOptionalString(replyToBody),
replyToId: (replyToId ?? fallbackReplyId)?.trim() || undefined,
replyToBody: replyToBody?.trim() || undefined,
replyToSender: normalizedSender || undefined,
};
}
@@ -340,7 +346,7 @@ function normalizeParticipantEntry(entry: unknown): BlueBubblesParticipant | nul
if (!normalizedId) {
return null;
}
const name = normalizeOptionalString(nameRaw);
const name = nameRaw?.trim() || undefined;
return { id: normalizedId, name };
}
@@ -567,9 +573,7 @@ export function resolveTapbackContext(message: NormalizedWebhookMessage): {
if (!hasTapbackType && !hasTapbackMarker) {
return null;
}
const replyToId =
normalizeOptionalString(message.associatedMessageGuid) ??
normalizeOptionalString(message.replyToId);
const replyToId = message.associatedMessageGuid?.trim() || message.replyToId?.trim() || undefined;
const actionHint = resolveTapbackActionHint(associatedType);
const emojiHint =
message.associatedMessageEmoji?.trim() || REACTION_TYPE_MAP.get(associatedType ?? -1)?.emoji;

View File

@@ -4,7 +4,6 @@ import {
sendMediaWithLeadingCaption,
} from "openclaw/plugin-sdk/reply-payload";
import { isPrivateNetworkOptInEnabled } from "openclaw/plugin-sdk/ssrf-runtime";
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
import { downloadBlueBubblesAttachment } from "./attachments.js";
import { markBlueBubblesChatRead, sendBlueBubblesTyping } from "./chat.js";
import { resolveBlueBubblesConversationRoute } from "./conversation-route.js";
@@ -92,6 +91,11 @@ type PendingOutboundMessageId = {
const pendingOutboundMessageIds: PendingOutboundMessageId[] = [];
let pendingOutboundMessageIdCounter = 0;
function trimOrUndefined(value?: string | null): string | undefined {
const trimmed = value?.trim();
return trimmed ? trimmed : undefined;
}
function normalizeSnippet(value: string): string {
return stripMarkdown(value).replace(/\s+/g, " ").trim().toLowerCase();
}
@@ -264,8 +268,8 @@ function rememberPendingOutboundMessageId(entry: {
accountId: entry.accountId,
sessionKey: entry.sessionKey,
outboundTarget: entry.outboundTarget,
chatGuid: normalizeOptionalString(entry.chatGuid),
chatIdentifier: normalizeOptionalString(entry.chatIdentifier),
chatGuid: trimOrUndefined(entry.chatGuid),
chatIdentifier: trimOrUndefined(entry.chatIdentifier),
chatId: typeof entry.chatId === "number" ? entry.chatId : undefined,
snippetRaw,
snippetNorm,
@@ -286,14 +290,14 @@ function chatsMatch(
left: Pick<PendingOutboundMessageId, "chatGuid" | "chatIdentifier" | "chatId">,
right: { chatGuid?: string; chatIdentifier?: string; chatId?: number },
): boolean {
const leftGuid = normalizeOptionalString(left.chatGuid);
const rightGuid = normalizeOptionalString(right.chatGuid);
const leftGuid = trimOrUndefined(left.chatGuid);
const rightGuid = trimOrUndefined(right.chatGuid);
if (leftGuid && rightGuid) {
return leftGuid === rightGuid;
}
const leftIdentifier = normalizeOptionalString(left.chatIdentifier);
const rightIdentifier = normalizeOptionalString(right.chatIdentifier);
const leftIdentifier = trimOrUndefined(left.chatIdentifier);
const rightIdentifier = trimOrUndefined(right.chatIdentifier);
if (leftIdentifier && rightIdentifier) {
return leftIdentifier === rightIdentifier;
}
@@ -728,7 +732,7 @@ export async function processMessage(
chatId: message.chatId ?? undefined,
chatIdentifier: message.chatIdentifier ?? undefined,
});
const groupName = normalizeOptionalString(message.chatName);
const groupName = message.chatName?.trim() || undefined;
if (accessDecision.decision !== "allow") {
if (isGroup) {
@@ -1101,11 +1105,11 @@ export async function processMessage(
// The sender identity is included in the envelope body via formatInboundEnvelope.
const senderLabel = message.senderName || `user:${message.senderId}`;
const fromLabel = isGroup
? `${normalizeOptionalString(message.chatName) || "Group"} id:${peerId}`
? `${message.chatName?.trim() || "Group"} id:${peerId}`
: senderLabel !== message.senderId
? `${senderLabel} id:${message.senderId}`
: senderLabel;
const groupSubject = isGroup ? normalizeOptionalString(message.chatName) : undefined;
const groupSubject = isGroup ? message.chatName?.trim() || undefined : undefined;
const groupMembers = isGroup
? formatGroupMembers({
participants: message.participants,

View File

@@ -1,5 +1,3 @@
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
const REPLY_CACHE_MAX = 2000;
const REPLY_CACHE_TTL_MS = 6 * 60 * 60 * 1000;
@@ -23,6 +21,11 @@ const blueBubblesShortIdToUuid = new Map<string, string>();
const blueBubblesUuidToShortId = new Map<string, string>();
let blueBubblesShortIdCounter = 0;
function trimOrUndefined(value?: string | null): string | undefined {
const trimmed = value?.trim();
return trimmed ? trimmed : undefined;
}
function generateShortId(): string {
blueBubblesShortIdCounter += 1;
return String(blueBubblesShortIdCounter);
@@ -155,10 +158,10 @@ export function resolveReplyContextFromCache(params: {
return null;
}
const chatGuid = normalizeOptionalString(params.chatGuid);
const chatIdentifier = normalizeOptionalString(params.chatIdentifier);
const cachedChatGuid = normalizeOptionalString(cached.chatGuid);
const cachedChatIdentifier = normalizeOptionalString(cached.chatIdentifier);
const chatGuid = trimOrUndefined(params.chatGuid);
const chatIdentifier = trimOrUndefined(params.chatIdentifier);
const cachedChatGuid = trimOrUndefined(cached.chatGuid);
const cachedChatIdentifier = trimOrUndefined(cached.chatIdentifier);
const chatId = typeof params.chatId === "number" ? params.chatId : undefined;
const cachedChatId = typeof cached.chatId === "number" ? cached.chatId : undefined;

View File

@@ -1,5 +1,4 @@
import { createHash } from "node:crypto";
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
import { extractHandleFromChatGuid, normalizeBlueBubblesHandle } from "./targets.js";
type SelfChatCacheKeyParts = {
@@ -40,6 +39,11 @@ 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) {
@@ -52,8 +56,8 @@ function resolveCanonicalChatTarget(parts: SelfChatCacheKeyParts): string | null
}
return (
normalizeOptionalString(parts.chatGuid) ??
normalizeOptionalString(parts.chatIdentifier) ??
trimOrUndefined(parts.chatGuid) ??
trimOrUndefined(parts.chatIdentifier) ??
(typeof parts.chatId === "number" ? String(parts.chatId) : null)
);
}

View File

@@ -1,13 +1,8 @@
import type { IncomingMessage, ServerResponse } from "node:http";
import { safeEqualSecret } from "openclaw/plugin-sdk/browser-security-runtime";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { isPrivateNetworkOptInEnabled } from "openclaw/plugin-sdk/ssrf-runtime";
import { createBlueBubblesDebounceRegistry } from "./monitor-debounce.js";
import {
asRecord,
normalizeWebhookMessage,
normalizeWebhookReaction,
} from "./monitor-normalize.js";
import { normalizeWebhookMessage, normalizeWebhookReaction } from "./monitor-normalize.js";
import { logVerbose, processMessage, processReaction } from "./monitor-processing.js";
import {
_resetBlueBubblesShortIdState,
@@ -93,11 +88,17 @@ function parseBlueBubblesWebhookPayload(
try {
return { ok: true, value: JSON.parse(payload) as unknown };
} catch (error) {
return { ok: false, error: formatErrorMessage(error) };
return { ok: false, error: error instanceof Error ? error.message : String(error) };
}
}
}
function asRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
function maskSecret(value: string): string {
if (value.length <= 6) {
return "***";

View File

@@ -2,7 +2,6 @@ import { execFile, type ExecFileOptionsWithStringEncoding } from "node:child_pro
import { access, readdir } from "node:fs/promises";
import { join } from "node:path";
import { promisify } from "node:util";
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
import type { BlueBubblesParticipant } from "./monitor-normalize.js";
const execFileAsync = promisify(execFile) as ExecFileRunner;
@@ -314,7 +313,7 @@ export async function enrichBlueBubblesParticipantsWithContactNames(
try {
const resolved = await lookup([...pendingPhoneKeys]);
for (const phoneKey of pendingPhoneKeys) {
const name = normalizeOptionalString(resolved.get(phoneKey));
const name = resolved.get(phoneKey)?.trim() || undefined;
writeCacheEntry(phoneKey, name, nowMs);
if (name) {
cachedNames.set(phoneKey, name);

View File

@@ -1,5 +1,3 @@
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
import type { BaseProbeResult } from "./runtime-api.js";
import { normalizeSecretInputString } from "./secret-input.js";
import { buildBlueBubblesApiUrl, blueBubblesFetchWithTimeout } from "./types.js";
@@ -24,6 +22,10 @@ const MAX_SERVER_INFO_CACHE_SIZE = 64;
const serverInfoCache = new Map<string, { info: BlueBubblesServerInfo; expires: number }>();
const CACHE_TTL_MS = 10 * 60 * 1000; // 10 minutes
function buildCacheKey(accountId?: string): string {
return accountId?.trim() || "default";
}
/**
* Fetch server info from BlueBubbles API and cache it.
* Returns cached result if available and not expired.
@@ -41,7 +43,7 @@ export async function fetchBlueBubblesServerInfo(params: {
return null;
}
const cacheKey = normalizeOptionalString(params.accountId) || "default";
const cacheKey = buildCacheKey(params.accountId);
const cached = serverInfoCache.get(cacheKey);
if (cached && cached.expires > Date.now()) {
return cached.info;
@@ -82,7 +84,7 @@ export async function fetchBlueBubblesServerInfo(params: {
* Returns null if not cached or expired.
*/
export function getCachedBlueBubblesServerInfo(accountId?: string): BlueBubblesServerInfo | null {
const cacheKey = normalizeOptionalString(accountId) || "default";
const cacheKey = buildCacheKey(accountId);
const cached = serverInfoCache.get(cacheKey);
if (cached && cached.expires > Date.now()) {
return cached.info;
@@ -170,7 +172,7 @@ export async function probeBlueBubbles(params: {
return {
ok: false,
status: null,
error: formatErrorMessage(err),
error: err instanceof Error ? err.message : String(err),
};
}
}

View File

@@ -4,7 +4,7 @@ import {
type ResolverContext,
type SecretDefaults,
type SecretTargetRegistryEntry,
} from "openclaw/plugin-sdk/channel-secret-runtime";
} from "openclaw/plugin-sdk/security-runtime";
export const secretTargetRegistryEntries = [
{

View File

@@ -1,4 +1,3 @@
import { asRecord } from "./monitor-normalize.js";
import { normalizeBlueBubblesHandle, parseBlueBubblesTarget } from "./targets.js";
import type { BlueBubblesSendTarget } from "./types.js";
@@ -25,6 +24,11 @@ export function extractBlueBubblesMessageId(payload: unknown): string {
return "unknown";
}
const asRecord = (value: unknown): Record<string, unknown> | null =>
value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
const record = payload as Record<string, unknown>;
const dataRecord = asRecord(record.data);
const resultRecord = asRecord(record.result);

View File

@@ -1,5 +1,5 @@
import crypto from "node:crypto";
import { normalizeOptionalString, stripMarkdown } from "openclaw/plugin-sdk/text-runtime";
import { stripMarkdown } from "openclaw/plugin-sdk/text-runtime";
import { resolveBlueBubblesServerAccount } from "./account-resolve.js";
import {
getCachedBlueBubblesPrivateApiStatus,
@@ -137,9 +137,8 @@ function extractChatGuid(chat: BlueBubblesChatRecord): string | null {
chat.chat_identifier,
];
for (const candidate of candidates) {
const value = normalizeOptionalString(candidate);
if (value) {
return value;
if (typeof candidate === "string" && candidate.trim()) {
return candidate.trim();
}
}
return null;
@@ -160,7 +159,8 @@ function extractChatIdentifierFromChatGuid(chatGuid: string): string | null {
if (parts.length < 3) {
return null;
}
return normalizeOptionalString(parts[2]) ?? null;
const identifier = parts[2]?.trim();
return identifier ? identifier : null;
}
function extractParticipantAddresses(chat: BlueBubblesChatRecord): string[] {
@@ -479,7 +479,7 @@ export async function sendMessageBlueBubbles(
);
}
const effectId = resolveEffectId(opts.effectId);
const wantsReplyThread = normalizeOptionalString(opts.replyToMessageGuid) !== undefined;
const wantsReplyThread = Boolean(opts.replyToMessageGuid?.trim());
const wantsEffect = Boolean(effectId);
const privateApiDecision = resolvePrivateApiDecision({
privateApiStatus,

View File

@@ -21,11 +21,8 @@ export function setBlueBubblesDmPolicy(
const existingAllowFrom =
resolvedAccountId === "default"
? cfg.channels?.bluebubbles?.allowFrom
: ((
cfg.channels?.bluebubbles?.accounts?.[resolvedAccountId] as
| { allowFrom?: ReadonlyArray<string | number> }
| undefined
)?.allowFrom ?? cfg.channels?.bluebubbles?.allowFrom);
: (cfg.channels?.bluebubbles?.accounts?.[resolvedAccountId]?.allowFrom ??
cfg.channels?.bluebubbles?.allowFrom);
return patchScopedAccountConfig({
cfg,
channelKey: channel,

View File

@@ -36,9 +36,6 @@ async function createBlueBubblesConfigureAdapter() {
docsPath: "/channels/bluebubbles",
blurb: "iMessage via BlueBubbles",
},
capabilities: {
chatTypes: ["direct", "group"],
},
config: {
listAccountIds: () => [DEFAULT_ACCOUNT_ID],
defaultAccountId: () => DEFAULT_ACCOUNT_ID,
@@ -216,13 +213,8 @@ describe("bluebubbles setup surface", () => {
});
const next = blueBubblesSetupWizard.dmPolicy?.setPolicy(cfg, "open");
const workAccount = next?.channels?.bluebubbles?.accounts?.work as
| {
dmPolicy?: string;
}
| undefined;
expect(next?.channels?.bluebubbles?.dmPolicy).toBe("disabled");
expect(workAccount?.dmPolicy).toBe("open");
expect(next?.channels?.bluebubbles?.accounts?.work?.dmPolicy).toBe("open");
});
it("uses configured defaultAccount when accountId is omitted in account resolution", async () => {
@@ -302,15 +294,12 @@ describe("bluebubbles setup surface", () => {
"work",
);
const workAccount = next?.channels?.bluebubbles?.accounts?.work as
| {
dmPolicy?: string;
allowFrom?: string[];
}
| undefined;
expect(next?.channels?.bluebubbles?.dmPolicy).toBeUndefined();
expect(workAccount?.dmPolicy).toBe("open");
expect(workAccount?.allowFrom).toEqual(["user@example.com", "*"]);
expect(next?.channels?.bluebubbles?.accounts?.work?.dmPolicy).toBe("open");
expect(next?.channels?.bluebubbles?.accounts?.work?.allowFrom).toEqual([
"user@example.com",
"*",
]);
});
});

View File

@@ -8,7 +8,6 @@ import {
type ChannelSetupWizard,
type OpenClawConfig,
} from "openclaw/plugin-sdk/setup";
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
import { resolveBlueBubblesAccount, resolveDefaultBlueBubblesAccountId } from "./accounts.js";
import { applyBlueBubblesConnectionConfig } from "./config-apply.js";
import { hasConfiguredSecretInput, normalizeSecretInputString } from "./secret-input.js";
@@ -40,7 +39,7 @@ function validateBlueBubblesAllowFromEntry(value: string): string | null {
if (parsed.kind === "handle" && !parsed.handle) {
return null;
}
return normalizeOptionalString(value) ?? null;
return value.trim() || null;
} catch {
return null;
}
@@ -77,7 +76,7 @@ const promptBlueBubblesAllowFrom = createPromptParsedAllowFromForAccount({
});
function validateBlueBubblesServerUrlInput(value: unknown): string | undefined {
const trimmed = normalizeOptionalString(value) ?? "";
const trimmed = typeof value === "string" ? value.trim() : "";
if (!trimmed) {
return "Required";
}
@@ -108,6 +107,14 @@ function applyBlueBubblesSetupPatch(
});
}
function resolveBlueBubblesServerUrl(cfg: OpenClawConfig, accountId: string): string | undefined {
return resolveBlueBubblesAccount({ cfg, accountId }).config.serverUrl?.trim() || undefined;
}
function resolveBlueBubblesWebhookPath(cfg: OpenClawConfig, accountId: string): string | undefined {
return resolveBlueBubblesAccount({ cfg, accountId }).config.webhookPath?.trim() || undefined;
}
function validateBlueBubblesWebhookPath(value: string): string | undefined {
const trimmed = String(value ?? "").trim();
if (!trimmed) {
@@ -164,9 +171,7 @@ export const blueBubblesSetupWizard: ChannelSetupWizard = {
configured ? "configured" : "iMessage via BlueBubbles app",
},
prepare: async ({ cfg, accountId, prompter, credentialValues }) => {
const existingWebhookPath = normalizeOptionalString(
resolveBlueBubblesAccount({ cfg, accountId }).config.webhookPath,
);
const existingWebhookPath = resolveBlueBubblesWebhookPath(cfg, accountId);
const wantsCustomWebhook = await prompter.confirm({
message: `Configure a custom webhook path? (default: ${DEFAULT_WEBHOOK_PATH})`,
initialValue: Boolean(existingWebhookPath && existingWebhookPath !== DEFAULT_WEBHOOK_PATH),
@@ -219,8 +224,7 @@ export const blueBubblesSetupWizard: ChannelSetupWizard = {
"Find this in the BlueBubbles Server app under Connection.",
`Docs: ${formatDocsLink("/channels/bluebubbles", "bluebubbles")}`,
],
currentValue: ({ cfg, accountId }) =>
normalizeOptionalString(resolveBlueBubblesAccount({ cfg, accountId }).config.serverUrl),
currentValue: ({ cfg, accountId }) => resolveBlueBubblesServerUrl(cfg, accountId),
validate: ({ value }) => validateBlueBubblesServerUrlInput(value),
normalizeValue: ({ value }) => String(value).trim(),
applySet: async ({ cfg, accountId, value }) =>
@@ -233,9 +237,7 @@ export const blueBubblesSetupWizard: ChannelSetupWizard = {
message: "Webhook path",
placeholder: DEFAULT_WEBHOOK_PATH,
currentValue: ({ cfg, accountId }) => {
const value = normalizeOptionalString(
resolveBlueBubblesAccount({ cfg, accountId }).config.webhookPath,
);
const value = resolveBlueBubblesWebhookPath(cfg, accountId);
return value && value !== DEFAULT_WEBHOOK_PATH ? value : undefined;
},
shouldPrompt: ({ credentialValues }) =>

View File

@@ -1,5 +1,4 @@
import { collectIssuesForEnabledAccounts } from "openclaw/plugin-sdk/status-helpers";
import { asRecord } from "./monitor-normalize.js";
import type { ChannelAccountSnapshot } from "./runtime-api.js";
type BlueBubblesAccountStatus = {
@@ -18,6 +17,10 @@ type BlueBubblesProbeResult = {
error?: string | null;
};
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function asString(value: unknown): string | null {
return typeof value === "string" && value.length > 0 ? value : null;
}
@@ -25,30 +28,28 @@ function asString(value: unknown): string | null {
function readBlueBubblesAccountStatus(
value: ChannelAccountSnapshot,
): BlueBubblesAccountStatus | null {
const record = asRecord(value);
if (!record) {
if (!isRecord(value)) {
return null;
}
return {
accountId: record.accountId,
enabled: record.enabled,
configured: record.configured,
running: record.running,
baseUrl: record.baseUrl,
lastError: record.lastError,
probe: record.probe,
accountId: value.accountId,
enabled: value.enabled,
configured: value.configured,
running: value.running,
baseUrl: value.baseUrl,
lastError: value.lastError,
probe: value.probe,
};
}
function readBlueBubblesProbeResult(value: unknown): BlueBubblesProbeResult | null {
const record = asRecord(value);
if (!record) {
if (!isRecord(value)) {
return null;
}
return {
ok: typeof record.ok === "boolean" ? record.ok : undefined,
status: typeof record.status === "number" ? record.status : null,
error: asString(record.error) ?? null,
ok: typeof value.ok === "boolean" ? value.ok : undefined,
status: typeof value.status === "number" ? value.status : null,
error: asString(value.error) ?? null,
};
}

View File

@@ -6,7 +6,6 @@ import {
resolveServicePrefixedAllowTarget,
resolveServicePrefixedTarget,
} from "openclaw/plugin-sdk/channel-targets";
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
export type BlueBubblesService = "imessage" | "sms" | "auto";
@@ -30,7 +29,7 @@ const CHAT_IDENTIFIER_UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4
const CHAT_IDENTIFIER_HEX_RE = /^[0-9a-f]{24,64}$/i;
function parseRawChatGuid(value: string): string | null {
const trimmed = normalizeOptionalString(value);
const trimmed = value.trim();
if (!trimmed) {
return null;
}
@@ -38,9 +37,9 @@ function parseRawChatGuid(value: string): string | null {
if (parts.length !== 3) {
return null;
}
const service = normalizeOptionalString(parts[0]);
const separator = normalizeOptionalString(parts[1]);
const identifier = normalizeOptionalString(parts[2]);
const service = parts[0]?.trim();
const separator = parts[1]?.trim();
const identifier = parts[2]?.trim();
if (!service || !identifier) {
return null;
}
@@ -55,7 +54,7 @@ function stripPrefix(value: string, prefix: string): string {
}
function stripBlueBubblesPrefix(value: string): string {
const trimmed = normalizeOptionalString(value) ?? "";
const trimmed = value.trim();
if (!trimmed) {
return "";
}
@@ -66,7 +65,7 @@ function stripBlueBubblesPrefix(value: string): string {
}
function looksLikeRawChatIdentifier(value: string): boolean {
const trimmed = normalizeOptionalString(value);
const trimmed = value.trim();
if (!trimmed) {
return false;
}
@@ -140,7 +139,7 @@ export function extractHandleFromChatGuid(chatGuid: string): string | null {
const parts = chatGuid.split(";");
// DM format: service;-;handle (3 parts, middle is "-")
if (parts.length === 3 && parts[1] === "-") {
const handle = normalizeOptionalString(parts[2]);
const handle = parts[2]?.trim();
if (handle) {
return normalizeBlueBubblesHandle(handle);
}
@@ -223,7 +222,7 @@ export function looksLikeBlueBubblesTargetId(raw: string, normalized?: string):
return true;
}
if (normalized) {
const normalizedTrimmed = normalizeOptionalString(normalized);
const normalizedTrimmed = normalized.trim();
if (!normalizedTrimmed) {
return false;
}
@@ -347,7 +346,7 @@ export function parseBlueBubblesTarget(raw: string): BlueBubblesTarget {
}
export function parseBlueBubblesAllowTarget(raw: string): BlueBubblesAllowTarget {
const trimmed = normalizeOptionalString(raw) ?? "";
const trimmed = raw.trim();
if (!trimmed) {
return { kind: "handle", handle: "" };
}
@@ -413,11 +412,11 @@ export function formatBlueBubblesChatTarget(params: {
if (params.chatId && Number.isFinite(params.chatId)) {
return `chat_id:${params.chatId}`;
}
const guid = normalizeOptionalString(params.chatGuid);
const guid = params.chatGuid?.trim();
if (guid) {
return `chat_guid:${guid}`;
}
const identifier = normalizeOptionalString(params.chatIdentifier);
const identifier = params.chatIdentifier?.trim();
if (identifier) {
return `chat_identifier:${identifier}`;
}

View File

@@ -1,4 +1,3 @@
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
import { normalizeWebhookPath } from "openclaw/plugin-sdk/webhook-path";
import type { BlueBubblesAccountConfig } from "./types.js";
@@ -7,7 +6,7 @@ export { normalizeWebhookPath };
export const DEFAULT_WEBHOOK_PATH = "/bluebubbles-webhook";
export function resolveWebhookPathFromConfig(config?: BlueBubblesAccountConfig): string {
const raw = normalizeOptionalString(config?.webhookPath);
const raw = config?.webhookPath?.trim();
if (raw) {
return normalizeWebhookPath(raw);
}

View File

@@ -1,16 +0,0 @@
{
"extends": "../tsconfig.package-boundary.base.json",
"compilerOptions": {
"rootDir": "."
},
"include": ["./*.ts", "./src/**/*.ts"],
"exclude": [
"./**/*.test.ts",
"./dist/**",
"./node_modules/**",
"./src/test-support/**",
"./src/**/*test-helpers.ts",
"./src/**/*test-harness.ts",
"./src/**/*test-support.ts"
]
}

View File

@@ -4,9 +4,6 @@
"private": true,
"description": "OpenClaw Brave plugin",
"type": "module",
"devDependencies": {
"@openclaw/plugin-sdk": "workspace:*"
},
"openclaw": {
"extensions": [
"./index.ts"

View File

@@ -1,367 +0,0 @@
import type { SearchConfigRecord } from "openclaw/plugin-sdk/provider-web-search";
import {
buildSearchCacheKey,
DEFAULT_SEARCH_COUNT,
formatCliCommand,
normalizeFreshness,
parseIsoDateRange,
readCachedSearchPayload,
readConfiguredSecretString,
readNumberParam,
readProviderEnvValue,
readStringParam,
resolveSearchCacheTtlMs,
resolveSearchCount,
resolveSearchTimeoutSeconds,
resolveSiteName,
withTrustedWebSearchEndpoint,
wrapWebContent,
writeCachedSearchPayload,
} from "openclaw/plugin-sdk/provider-web-search";
import {
type BraveLlmContextResponse,
mapBraveLlmContextResults,
normalizeBraveCountry,
normalizeBraveLanguageParams,
resolveBraveConfig,
resolveBraveMode,
} from "./brave-web-search-provider.shared.js";
const BRAVE_SEARCH_ENDPOINT = "https://api.search.brave.com/res/v1/web/search";
const BRAVE_LLM_CONTEXT_ENDPOINT = "https://api.search.brave.com/res/v1/llm/context";
type BraveSearchResult = {
title?: string;
url?: string;
description?: string;
age?: string;
};
type BraveSearchResponse = {
web?: {
results?: BraveSearchResult[];
};
};
function resolveBraveApiKey(searchConfig?: SearchConfigRecord): string | undefined {
return (
readConfiguredSecretString(searchConfig?.apiKey, "tools.web.search.apiKey") ??
readProviderEnvValue(["BRAVE_API_KEY"])
);
}
function missingBraveKeyPayload() {
return {
error: "missing_brave_api_key",
message: `web_search (brave) needs a Brave Search API key. Run \`${formatCliCommand("openclaw configure --section web")}\` to store it, or set BRAVE_API_KEY in the Gateway environment.`,
docs: "https://docs.openclaw.ai/tools/web",
};
}
async function runBraveLlmContextSearch(params: {
query: string;
apiKey: string;
timeoutSeconds: number;
country?: string;
search_lang?: string;
freshness?: string;
}): Promise<{
results: Array<{
url: string;
title: string;
snippets: string[];
siteName?: string;
}>;
sources?: BraveLlmContextResponse["sources"];
}> {
const url = new URL(BRAVE_LLM_CONTEXT_ENDPOINT);
url.searchParams.set("q", params.query);
if (params.country) {
url.searchParams.set("country", params.country);
}
if (params.search_lang) {
url.searchParams.set("search_lang", params.search_lang);
}
if (params.freshness) {
url.searchParams.set("freshness", params.freshness);
}
return withTrustedWebSearchEndpoint(
{
url: url.toString(),
timeoutSeconds: params.timeoutSeconds,
init: {
method: "GET",
headers: {
Accept: "application/json",
"X-Subscription-Token": params.apiKey,
},
},
},
async (response) => {
if (!response.ok) {
const detail = await response.text();
throw new Error(
`Brave LLM Context API error (${response.status}): ${detail || response.statusText}`,
);
}
const data = (await response.json()) as BraveLlmContextResponse;
return { results: mapBraveLlmContextResults(data), sources: data.sources };
},
);
}
async function runBraveWebSearch(params: {
query: string;
count: number;
apiKey: string;
timeoutSeconds: number;
country?: string;
search_lang?: string;
ui_lang?: string;
freshness?: string;
dateAfter?: string;
dateBefore?: string;
}): Promise<Array<Record<string, unknown>>> {
const url = new URL(BRAVE_SEARCH_ENDPOINT);
url.searchParams.set("q", params.query);
url.searchParams.set("count", String(params.count));
if (params.country) {
url.searchParams.set("country", params.country);
}
if (params.search_lang) {
url.searchParams.set("search_lang", params.search_lang);
}
if (params.ui_lang) {
url.searchParams.set("ui_lang", params.ui_lang);
}
if (params.freshness) {
url.searchParams.set("freshness", params.freshness);
} else if (params.dateAfter && params.dateBefore) {
url.searchParams.set("freshness", `${params.dateAfter}to${params.dateBefore}`);
} else if (params.dateAfter) {
url.searchParams.set(
"freshness",
`${params.dateAfter}to${new Date().toISOString().slice(0, 10)}`,
);
} else if (params.dateBefore) {
url.searchParams.set("freshness", `1970-01-01to${params.dateBefore}`);
}
return withTrustedWebSearchEndpoint(
{
url: url.toString(),
timeoutSeconds: params.timeoutSeconds,
init: {
method: "GET",
headers: {
Accept: "application/json",
"X-Subscription-Token": params.apiKey,
},
},
},
async (response) => {
if (!response.ok) {
const detail = await response.text();
throw new Error(
`Brave Search API error (${response.status}): ${detail || response.statusText}`,
);
}
const data = (await response.json()) as BraveSearchResponse;
const results = Array.isArray(data.web?.results) ? (data.web?.results ?? []) : [];
return results.map((entry) => {
const description = entry.description ?? "";
const title = entry.title ?? "";
const url = entry.url ?? "";
return {
title: title ? wrapWebContent(title, "web_search") : "",
url,
description: description ? wrapWebContent(description, "web_search") : "",
published: entry.age || undefined,
siteName: resolveSiteName(url) || undefined,
};
});
},
);
}
export async function executeBraveSearch(
args: Record<string, unknown>,
searchConfig?: SearchConfigRecord,
): Promise<Record<string, unknown>> {
const apiKey = resolveBraveApiKey(searchConfig);
if (!apiKey) {
return missingBraveKeyPayload();
}
const braveConfig = resolveBraveConfig(searchConfig);
const braveMode = resolveBraveMode(braveConfig);
const query = readStringParam(args, "query", { required: true });
const count =
readNumberParam(args, "count", { integer: true }) ?? searchConfig?.maxResults ?? undefined;
const country = normalizeBraveCountry(readStringParam(args, "country"));
const language = readStringParam(args, "language");
const search_lang = readStringParam(args, "search_lang");
const ui_lang = readStringParam(args, "ui_lang");
const normalizedLanguage = normalizeBraveLanguageParams({
search_lang: search_lang || language,
ui_lang,
});
if (normalizedLanguage.invalidField === "search_lang") {
return {
error: "invalid_search_lang",
message:
"search_lang must be a Brave-supported language code like 'en', 'en-gb', 'zh-hans', or 'zh-hant'.",
docs: "https://docs.openclaw.ai/tools/web",
};
}
if (normalizedLanguage.invalidField === "ui_lang") {
return {
error: "invalid_ui_lang",
message: "ui_lang must be a language-region locale like 'en-US'.",
docs: "https://docs.openclaw.ai/tools/web",
};
}
if (normalizedLanguage.ui_lang && braveMode === "llm-context") {
return {
error: "unsupported_ui_lang",
message:
"ui_lang is not supported by Brave llm-context mode. Remove ui_lang or use Brave web mode for locale-based UI hints.",
docs: "https://docs.openclaw.ai/tools/web",
};
}
const rawFreshness = readStringParam(args, "freshness");
if (rawFreshness && braveMode === "llm-context") {
return {
error: "unsupported_freshness",
message:
"freshness filtering is not supported by Brave llm-context mode. Remove freshness or use Brave web mode.",
docs: "https://docs.openclaw.ai/tools/web",
};
}
const freshness = rawFreshness ? normalizeFreshness(rawFreshness, "brave") : undefined;
if (rawFreshness && !freshness) {
return {
error: "invalid_freshness",
message: "freshness must be day, week, month, or year.",
docs: "https://docs.openclaw.ai/tools/web",
};
}
const rawDateAfter = readStringParam(args, "date_after");
const rawDateBefore = readStringParam(args, "date_before");
if (rawFreshness && (rawDateAfter || rawDateBefore)) {
return {
error: "conflicting_time_filters",
message:
"freshness and date_after/date_before cannot be used together. Use either freshness (day/week/month/year) or a date range (date_after/date_before), not both.",
docs: "https://docs.openclaw.ai/tools/web",
};
}
if ((rawDateAfter || rawDateBefore) && braveMode === "llm-context") {
return {
error: "unsupported_date_filter",
message:
"date_after/date_before filtering is not supported by Brave llm-context mode. Use Brave web mode for date filters.",
docs: "https://docs.openclaw.ai/tools/web",
};
}
const parsedDateRange = parseIsoDateRange({
rawDateAfter,
rawDateBefore,
invalidDateAfterMessage: "date_after must be YYYY-MM-DD format.",
invalidDateBeforeMessage: "date_before must be YYYY-MM-DD format.",
invalidDateRangeMessage: "date_after must be before date_before.",
});
if ("error" in parsedDateRange) {
return parsedDateRange;
}
const { dateAfter, dateBefore } = parsedDateRange;
const cacheKey = buildSearchCacheKey([
"brave",
braveMode,
query,
resolveSearchCount(count, DEFAULT_SEARCH_COUNT),
country,
normalizedLanguage.search_lang,
normalizedLanguage.ui_lang,
freshness,
dateAfter,
dateBefore,
]);
const cached = readCachedSearchPayload(cacheKey);
if (cached) {
return cached;
}
const start = Date.now();
const timeoutSeconds = resolveSearchTimeoutSeconds(searchConfig);
const cacheTtlMs = resolveSearchCacheTtlMs(searchConfig);
if (braveMode === "llm-context") {
const { results, sources } = await runBraveLlmContextSearch({
query,
apiKey,
timeoutSeconds,
country: country ?? undefined,
search_lang: normalizedLanguage.search_lang,
freshness,
});
const payload = {
query,
provider: "brave",
mode: "llm-context" as const,
count: results.length,
tookMs: Date.now() - start,
externalContent: {
untrusted: true,
source: "web_search",
provider: "brave",
wrapped: true,
},
results: results.map((entry) => ({
title: entry.title ? wrapWebContent(entry.title, "web_search") : "",
url: entry.url,
snippets: entry.snippets.map((snippet) => wrapWebContent(snippet, "web_search")),
siteName: entry.siteName,
})),
sources,
};
writeCachedSearchPayload(cacheKey, payload, cacheTtlMs);
return payload;
}
const results = await runBraveWebSearch({
query,
count: resolveSearchCount(count, DEFAULT_SEARCH_COUNT),
apiKey,
timeoutSeconds,
country: country ?? undefined,
search_lang: normalizedLanguage.search_lang,
ui_lang: normalizedLanguage.ui_lang,
freshness,
dateAfter,
dateBefore,
});
const payload = {
query,
provider: "brave",
count: results.length,
tookMs: Date.now() - start,
externalContent: {
untrusted: true,
source: "web_search",
provider: "brave",
wrapped: true,
},
results,
};
writeCachedSearchPayload(cacheKey, payload, cacheTtlMs);
return payload;
}

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