mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-12 01:01:37 +08:00
Compare commits
3 Commits
codex/exte
...
vincentkoc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
db3f25ae75 | ||
|
|
7b3630e310 | ||
|
|
c71fb8cda0 |
@@ -1,8 +1,8 @@
|
||||
---
|
||||
description: Update OpenClaw from upstream when branch has diverged (ahead/behind)
|
||||
description: Update Clawdbot from upstream when branch has diverged (ahead/behind)
|
||||
---
|
||||
|
||||
# OpenClaw Upstream Sync Workflow
|
||||
# Clawdbot Upstream Sync Workflow
|
||||
|
||||
Use this workflow when your fork has diverged from upstream (e.g., "18 commits ahead, 29 commits behind").
|
||||
|
||||
@@ -132,16 +132,16 @@ pnpm mac:package
|
||||
|
||||
```bash
|
||||
# Kill running app
|
||||
pkill -x "OpenClaw" || true
|
||||
pkill -x "Clawdbot" || true
|
||||
|
||||
# Move old version
|
||||
mv /Applications/OpenClaw.app /tmp/OpenClaw-backup.app
|
||||
mv /Applications/Clawdbot.app /tmp/Clawdbot-backup.app
|
||||
|
||||
# Install new build
|
||||
cp -R dist/OpenClaw.app /Applications/
|
||||
cp -R dist/Clawdbot.app /Applications/
|
||||
|
||||
# Launch
|
||||
open /Applications/OpenClaw.app
|
||||
open /Applications/Clawdbot.app
|
||||
```
|
||||
|
||||
---
|
||||
@@ -235,7 +235,7 @@ If upstream introduced new model configurations:
|
||||
# Check for OpenRouter API key requirements
|
||||
grep -r "openrouter\|OPENROUTER" src/ --include="*.ts" --include="*.js"
|
||||
|
||||
# Update openclaw.json with fallback chains
|
||||
# Update clawdbot.json with fallback chains
|
||||
# Add model fallback configurations as needed
|
||||
```
|
||||
|
||||
|
||||
64
.github/labeler.yml
vendored
64
.github/labeler.yml
vendored
@@ -238,79 +238,15 @@
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/acpx/**"
|
||||
"extensions: byteplus":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/byteplus/**"
|
||||
"extensions: cloudflare-ai-gateway":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/cloudflare-ai-gateway/**"
|
||||
"extensions: minimax-portal-auth":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/minimax-portal-auth/**"
|
||||
"extensions: huggingface":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/huggingface/**"
|
||||
"extensions: kilocode":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/kilocode/**"
|
||||
"extensions: kimi-coding":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/kimi-coding/**"
|
||||
"extensions: minimax":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/minimax/**"
|
||||
"extensions: modelstudio":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/modelstudio/**"
|
||||
"extensions: moonshot":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/moonshot/**"
|
||||
"extensions: nvidia":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/nvidia/**"
|
||||
"extensions: phone-control":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/phone-control/**"
|
||||
"extensions: qianfan":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/qianfan/**"
|
||||
"extensions: synthetic":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/synthetic/**"
|
||||
"extensions: talk-voice":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/talk-voice/**"
|
||||
"extensions: together":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/together/**"
|
||||
"extensions: venice":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/venice/**"
|
||||
"extensions: vercel-ai-gateway":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/vercel-ai-gateway/**"
|
||||
"extensions: volcengine":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/volcengine/**"
|
||||
"extensions: xiaomi":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/xiaomi/**"
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
docs/.generated/
|
||||
44
AGENTS.md
44
AGENTS.md
@@ -72,8 +72,6 @@
|
||||
|
||||
- `docs/zh-CN/**` is generated; do not edit unless the user explicitly asks.
|
||||
- Pipeline: update English docs → adjust glossary (`docs/.i18n/glossary.zh-CN.json`) → run `scripts/docs-i18n` → apply targeted fixes only if instructed.
|
||||
- Before rerunning `scripts/docs-i18n`, add glossary entries for any new technical terms, page titles, or short nav labels that must stay in English or use a fixed translation (for example `Doctor` or `Polls`).
|
||||
- `pnpm docs:check-i18n-glossary` enforces glossary coverage for changed English doc titles and short internal doc labels before translation reruns.
|
||||
- Translation memory: `docs/.i18n/zh-CN.tm.jsonl` (generated).
|
||||
- See `docs/.i18n/README.md`.
|
||||
- The pipeline can be slow/inefficient; if it’s dragging, ping @jospalmbier on Discord instead of hacking around it.
|
||||
@@ -99,7 +97,7 @@
|
||||
- Prefer Bun for TypeScript execution (scripts, dev, tests): `bun <file.ts>` / `bunx <tool>`.
|
||||
- Run CLI in dev: `pnpm openclaw ...` (bun) or `pnpm dev`.
|
||||
- Node remains supported for running built output (`dist/*`) and production installs.
|
||||
- Mac packaging (dev): `scripts/package-mac-app.sh` defaults to current arch.
|
||||
- Mac packaging (dev): `scripts/package-mac-app.sh` defaults to current arch. Release checklist: `docs/platforms/mac/release.md`.
|
||||
- Type-check/build: `pnpm build`
|
||||
- TypeScript checks: `pnpm tsgo`
|
||||
- Lint/format: `pnpm check`
|
||||
@@ -181,7 +179,7 @@
|
||||
- Pi sessions live under `~/.openclaw/sessions/` by default; the base directory is not configurable.
|
||||
- Environment variables: see `~/.profile`.
|
||||
- Never commit or publish real phone numbers, videos, or live configuration values. Use obviously fake placeholders in docs, tests, and examples.
|
||||
- Release flow: use the private [maintainer release docs](https://github.com/openclaw/maintainers/blob/main/release/README.md) for the actual runbook; use `docs/reference/RELEASING.md` for the public release policy.
|
||||
- Release flow: always read `docs/reference/RELEASING.md` and `docs/platforms/mac/release.md` before any release work; do not ask routine questions once those docs answer them.
|
||||
|
||||
## GHSA (Repo Advisory) Patch/Publish
|
||||
|
||||
@@ -258,13 +256,14 @@
|
||||
- If shared guardrails are available locally, review them; otherwise follow this repo's guidance.
|
||||
- SwiftUI state management (iOS/macOS): prefer the `Observation` framework (`@Observable`, `@Bindable`) over `ObservableObject`/`@StateObject`; don’t introduce new `ObservableObject` unless required for compatibility, and migrate existing usages when touching related code.
|
||||
- Connection providers: when adding a new connection, update every UI surface and docs (macOS app, web UI, mobile if applicable, onboarding/overview docs) and add matching status + configuration forms so provider lists and settings stay in sync.
|
||||
- Version locations: `package.json` (CLI), `apps/android/app/build.gradle.kts` (versionName/versionCode), `apps/ios/Sources/Info.plist` + `apps/ios/Tests/Info.plist` (CFBundleShortVersionString/CFBundleVersion), `apps/macos/Sources/OpenClaw/Resources/Info.plist` (CFBundleShortVersionString/CFBundleVersion), `docs/install/updating.md` (pinned npm version), and Peekaboo Xcode projects/Info.plists (MARKETING_VERSION/CURRENT_PROJECT_VERSION).
|
||||
- Version locations: `package.json` (CLI), `apps/android/app/build.gradle.kts` (versionName/versionCode), `apps/ios/Sources/Info.plist` + `apps/ios/Tests/Info.plist` (CFBundleShortVersionString/CFBundleVersion), `apps/macos/Sources/OpenClaw/Resources/Info.plist` (CFBundleShortVersionString/CFBundleVersion), `docs/install/updating.md` (pinned npm version), `docs/platforms/mac/release.md` (APP_VERSION/APP_BUILD examples), Peekaboo Xcode projects/Info.plists (MARKETING_VERSION/CURRENT_PROJECT_VERSION).
|
||||
- "Bump version everywhere" means all version locations above **except** `appcast.xml` (only touch appcast when cutting a new macOS Sparkle release).
|
||||
- **Restart apps:** “restart iOS/Android apps” means rebuild (recompile/install) and relaunch, not just kill/launch.
|
||||
- **Device checks:** before testing, verify connected real devices (iOS/Android) before reaching for simulators/emulators.
|
||||
- iOS Team ID lookup: `security find-identity -p codesigning -v` → use Apple Development (…) TEAMID. Fallback: `defaults read com.apple.dt.Xcode IDEProvisioningTeamIdentifiers`.
|
||||
- A2UI bundle hash: `src/canvas-host/a2ui/.bundle.hash` is auto-generated; ignore unexpected changes, and only regenerate via `pnpm canvas:a2ui:bundle` (or `scripts/bundle-a2ui.sh`) when needed. Commit the hash as a separate commit.
|
||||
- Release signing/notary credentials are managed outside the repo; maintainers keep that setup in the private [maintainer release docs](https://github.com/openclaw/maintainers/tree/main/release).
|
||||
- Release signing/notary keys are managed outside the repo; follow internal release docs.
|
||||
- Notary auth env vars (`APP_STORE_CONNECT_ISSUER_ID`, `APP_STORE_CONNECT_KEY_ID`, `APP_STORE_CONNECT_API_KEY_P8`) are expected in your environment (per internal release docs).
|
||||
- **Multi-agent safety:** do **not** create/apply/drop `git stash` entries unless explicitly requested (this includes `git pull --rebase --autostash`). Assume other agents may be working; keep unrelated WIP untouched and avoid cross-cutting state changes.
|
||||
- **Multi-agent safety:** when the user says "push", you may `git pull --rebase` to integrate latest changes (never discard other agents' work). When the user says "commit", scope to your changes only. When the user says "commit all", commit everything in grouped chunks.
|
||||
- **Multi-agent safety:** do **not** create/remove/modify `git worktree` checkouts (or edit `.worktrees/*`) unless explicitly requested.
|
||||
@@ -291,12 +290,35 @@
|
||||
- Release guardrails: do not change version numbers without operator’s explicit consent; always ask permission before running any npm publish/release step.
|
||||
- Beta release guardrail: when using a beta Git tag (for example `vYYYY.M.D-beta.N`), publish npm with a matching beta version suffix (for example `YYYY.M.D-beta.N`) rather than a plain version on `--tag beta`; otherwise the plain version name gets consumed/blocked.
|
||||
|
||||
## Release Auth
|
||||
## NPM + 1Password (publish/verify)
|
||||
|
||||
- Core `openclaw` publish uses GitHub trusted publishing; do not use `NPM_TOKEN` or the plugin OTP flow for core releases.
|
||||
- Separate `@openclaw/*` plugin publishes use a different maintainer-only auth flow.
|
||||
- Plugin scope: only publish already-on-npm `@openclaw/*` plugins. Bundled disk-tree-only plugins stay out.
|
||||
- Maintainers: private 1Password item names, tmux rules, plugin publish helpers, and local mac signing/notary setup live in the private [maintainer release docs](https://github.com/openclaw/maintainers/blob/main/release/README.md).
|
||||
- Use the 1password skill; all `op` commands must run inside a fresh tmux session.
|
||||
- Correct 1Password path for npm release auth: `op://Private/Npmjs` (use that item; OTP stays `op://Private/Npmjs/one-time password?attribute=otp`).
|
||||
- Sign in: `eval "$(op signin --account my.1password.com)"` (app unlocked + integration on).
|
||||
- OTP: `op read 'op://Private/Npmjs/one-time password?attribute=otp'`.
|
||||
- Publish: `npm publish --access public --otp="<otp>"` (run from the package dir).
|
||||
- Verify without local npmrc side effects: `npm view <pkg> version --userconfig "$(mktemp)"`.
|
||||
- Kill the tmux session after publish.
|
||||
|
||||
## Plugin Release Fast Path (no core `openclaw` publish)
|
||||
|
||||
- Release only already-on-npm plugins. Source list is in `docs/reference/RELEASING.md` under "Current npm plugin list".
|
||||
- Run all CLI `op` calls and `npm publish` inside tmux to avoid hangs/interruption:
|
||||
- `tmux new -d -s release-plugins-$(date +%Y%m%d-%H%M%S)`
|
||||
- `eval "$(op signin --account my.1password.com)"`
|
||||
- 1Password helpers:
|
||||
- password used by `npm login`:
|
||||
`op item get Npmjs --format=json | jq -r '.fields[] | select(.id=="password").value'`
|
||||
- OTP:
|
||||
`op read 'op://Private/Npmjs/one-time password?attribute=otp'`
|
||||
- Fast publish loop (local helper script in `/tmp` is fine; keep repo clean):
|
||||
- compare local plugin `version` to `npm view <name> version`
|
||||
- only run `npm publish --access public --otp="<otp>"` when versions differ
|
||||
- skip if package is missing on npm or version already matches.
|
||||
- Keep `openclaw` untouched: never run publish from repo root unless explicitly requested.
|
||||
- Post-check for each release:
|
||||
- per-plugin: `npm view @openclaw/<name> version --userconfig "$(mktemp)"` should be `2026.2.17`
|
||||
- core guard: `npm view openclaw version --userconfig "$(mktemp)"` should stay at previous version unless explicitly requested.
|
||||
|
||||
## Changelog Release Notes
|
||||
|
||||
|
||||
75
CHANGELOG.md
75
CHANGELOG.md
@@ -6,73 +6,33 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
### Changes
|
||||
|
||||
- Android/mobile: add a system-aware dark theme across onboarding and post-onboarding screens so the app follows the device theme through setup, chat, and voice flows. (#46249) Thanks @sibbl.
|
||||
- Commands/btw: add `/btw` side questions for quick tool-less answers about the current session without changing future session context, with dismissible in-session TUI answers and explicit BTW replies on external channels. (#45444) Thanks @ngutman.
|
||||
- Gateway/health monitor: add configurable stale-event thresholds and restart limits, plus per-channel and per-account `healthMonitor.enabled` overrides, while keeping the existing global disable path on `gateway.channelHealthCheckMinutes=0`. (#42107) Thanks @rstar327.
|
||||
- Feishu/cards: add identity-aware structured card headers and note footers for Feishu replies and direct sends, while keeping that presentation wired through the shared outbound identity path. (#29938) Thanks @nszhsl.
|
||||
- Feishu/streaming: add `onReasoningStream` and `onReasoningEnd` support to streaming cards, so `/reasoning stream` renders thinking tokens as markdown blockquotes in the same card — matching the Telegram channel's reasoning lane behavior. (#46029)
|
||||
- Refactor/channels: remove the legacy channel shim directories and point channel-specific imports directly at the extension-owned implementations. (#45967) thanks @scoootscooob.
|
||||
- Android/nodes: add `callLog.search` plus shared Call Log permission wiring so Android nodes can search recent call history through the gateway. (#44073) Thanks @lxk7280.
|
||||
- Docs/Zalo: clarify the Marketplace-bot support matrix and config guidance so the Zalo channel docs match current Bot Creator behavior more closely. (#47552) Thanks @No898.
|
||||
- Install/update: allow package-manager installs from GitHub `main` via `openclaw update --tag main`, installer `--version main`, or direct npm/pnpm git specs.
|
||||
- Plugins/providers: move OpenRouter, GitHub Copilot, and OpenAI Codex provider/runtime logic into bundled plugins, including dynamic model fallback, runtime auth exchange, stream wrappers, capability hints, and cache-TTL policy.
|
||||
- Plugins/bundles: add compatible Codex, Claude, and Cursor bundle discovery/install support, map bundle skills into OpenClaw skills, and apply Claude bundle `settings.json` defaults to embedded Pi with shell overrides sanitized.
|
||||
- Plugins/agent integrations: broaden the plugin surface for app-server integrations with channel-aware commands, interactive callbacks, inbound claims, and Discord/Telegram conversation binding support. (#45318) Thanks @huntharo and @vincentkoc.
|
||||
- Feishu/streaming: add `onReasoningStream` and `onReasoningEnd` support to streaming cards, so `/reasoning stream` renders thinking tokens as markdown blockquotes in the same card — matching the Telegram channel's reasoning lane behavior.
|
||||
- Feishu/cards: add identity-aware structured card headers and note footers for Feishu replies and direct sends, while keeping that presentation wired through the shared outbound identity path. (#29938) Thanks @nszhsl.
|
||||
- Gateway/health monitor: add configurable stale-event thresholds and restart limits, plus per-channel and per-account `healthMonitor.enabled` overrides, while keeping the existing global disable path on `gateway.channelHealthCheckMinutes=0`. (#42107) Thanks @rstar327.
|
||||
- Android/mobile: add a system-aware dark theme across onboarding and post-onboarding screens so the app follows the device theme through setup, chat, and voice flows. (#46249) Thanks @sibbl.
|
||||
|
||||
### Fixes
|
||||
|
||||
- Group mention gating: reject invalid and unsafe nested-repetition `mentionPatterns`, reuse the shared safe config-regex compiler across mention stripping and detection, and cache strip-time regex compilation so noisy groups avoid repeated recompiles.
|
||||
- Control UI/chat sessions: show human-readable labels in the grouped session dropdown again, keep unique scoped fallbacks when metadata is missing, and disambiguate duplicate labels only when needed. (#45130) thanks @luzhidong.
|
||||
- Control UI: scope persisted session selection per gateway, prevent stale session bleed across tokenized gateway opens, and cap stored gateway session history. (#47453) Thanks @sallyom.
|
||||
- Slack/interactive replies: preserve `channelData.slack.blocks` through live DM delivery and preview-finalized edits so Block Kit button and select directives render instead of falling back to raw text. (#45890) Thanks @vincentkoc.
|
||||
- Feishu/topic threads: fetch full thread context, including prior bot replies, when starting a topic-thread session so follow-up turns in Feishu topics keep the right conversation state. (#45254) Thanks @Coobiw.
|
||||
- Configure/startup: move outbound send-deps resolution into a lightweight helper so `openclaw configure` no longer stalls after the banner while eagerly loading channel plugins. (#46301) thanks @scoootscooob.
|
||||
- Control UI/dashboard: preserve structured gateway shutdown reasons across restart disconnects so config-triggered restarts no longer fall back to `disconnected (1006): no reason`. (#46532) Thanks @vincentkoc.
|
||||
- Android/chat: theme the thinking dropdown and TLS trust dialogs explicitly so popup surfaces match the active app theme instead of falling back to mismatched Material defaults.
|
||||
- Z.AI/onboarding: detect a working default model even for explicit `zai-coding-*` endpoint choices, so Coding Plan setup can keep the selected endpoint while defaulting to `glm-5` when available or `glm-4.7` as fallback. (#45969)
|
||||
- Models/OpenRouter runtime capabilities: fetch uncatalogued OpenRouter model metadata on first use so newly added vision models keep image input instead of silently degrading to text-only, with top-level capability field fallbacks for `/api/v1/models`. (#45824) Thanks @DJjjjhao.
|
||||
- Z.AI/onboarding: add `glm-5-turbo` to the default Z.AI provider catalog so onboarding-generated configs expose the new model alongside the existing GLM defaults. (#46670) Thanks @tomsun28.
|
||||
- Zalo Personal/group gating: stop reapplying `dmPolicy.allowFrom` as a sender gate for already-allowlisted groups when `groupAllowFrom` is unset, so any member of an allowed group can trigger replies while DMs stay restricted. (#40146)
|
||||
- Browser/remote CDP: honor strict browser SSRF policy during remote CDP reachability and `/json/version` discovery checks, redact sensitive `cdpUrl` tokens from status output, and warn when remote CDP targets private/internal hosts.
|
||||
- Plugins/install precedence: keep bundled plugins ahead of auto-discovered globals by default, but let an explicitly installed plugin record win its own duplicate-id tie so installed channel plugins load from `~/.openclaw/extensions` after `openclaw plugins install`.
|
||||
- ACP/acpx: resolve the bundled plugin root from the actual plugin directory so plugin-local installs stay under `dist/extensions/acpx` instead of escaping to `dist/extensions` and failing runtime setup.
|
||||
- Gateway/auth: ignore spoofed loopback hops in trusted forwarding chains and block device approvals that request scopes above the caller session. Thanks @vincentkoc.
|
||||
- Gateway/config views: strip embedded credentials from URL-based endpoint fields before returning read-only account and config snapshots. Thanks @vincentkoc.
|
||||
- Tools/apply-patch: revalidate workspace-only delete and directory targets immediately before mutating host paths. Thanks @vincentkoc.
|
||||
- Webhooks/runtime: move auth earlier and tighten pre-auth body limits and timeouts across bundled webhook handlers, including slow-body handling for Mattermost slash commands. Thanks @vincentkoc.
|
||||
- Subagents/follow-ups: require the same controller ownership checks for `/subagents send` as other control actions, so leaf sessions cannot message nested child runs they do not control. Thanks @vincentkoc.
|
||||
- Inbound policy hardening: tighten callback and webhook sender checks across Mattermost and Google Chat, match Nextcloud Talk rooms by stable room token, and treat explicit empty Twitch allowlists as deny-all. (#46787) Thanks @zpbrent, @ijxpwastaken and @vincentkoc.
|
||||
- macOS/canvas actions: keep unattended local agent actions on trusted in-app canvas surfaces only, and stop exposing the deep-link fallback key to arbitrary page scripts. (#46790) Thanks @vincentkoc.
|
||||
- Agents/compaction: extend the enclosing run deadline once while compaction is actively in flight, and abort the underlying SDK compaction on timeout/cancel so large-session compactions stop freezing mid-run. (#46889) Thanks @asyncjason.
|
||||
- Models/openai-completions: default non-native OpenAI-compatible providers to omit tool-definition `strict` fields unless users explicitly opt back in, so tool calling keeps working on providers that reject that option. (#45497) Thanks @sahancava.
|
||||
- WhatsApp/reconnect: restore the append recency filter in the extension inbox monitor and handle protobuf `Long` timestamps correctly, so fresh post-reconnect append messages are processed while stale history sync stays suppressed. (#42588) thanks @MonkeyLeeT.
|
||||
- WhatsApp/login: wait for pending creds writes before reopening after Baileys `515` pairing restarts in both QR login and `channels login` flows, and keep the restart coverage pinned to the real wrapped error shape plus per-account creds queues. (#27910) Thanks @asyncjason.
|
||||
- Agents/openai-compatible tool calls: deduplicate repeated tool call ids across live assistant messages and replayed history so OpenAI-compatible backends no longer reject duplicate `tool_call_id` values with HTTP 400. (#40996) Thanks @xaeon2026.
|
||||
- Security/device pairing: harden `device.token.rotate` deny handling by keeping public failures generic while logging internal deny reasons and preserving approved-baseline enforcement. (`GHSA-7jrw-x62h-64p8`)
|
||||
- Slack/interactive replies: preserve `channelData.slack.blocks` through live DM delivery and preview-finalized edits so Block Kit button and select directives render instead of falling back to raw text. (#45890) Thanks @vincentkoc.
|
||||
- Zalo/plugin runtime: export `resolveClientIp` from `openclaw/plugin-sdk/zalo` so installed builds no longer crash on startup when the webhook monitor loads from the packaged extension instead of the monorepo source tree. (#46549) Thanks @No898.
|
||||
- Z.AI/onboarding: add `glm-5-turbo` to the default Z.AI provider catalog so onboarding-generated configs expose the new model alongside the existing GLM defaults. (#46670) Thanks @tomsun28.
|
||||
- Control UI/chat sessions: show human-readable labels in the grouped session dropdown again, keep unique scoped fallbacks when metadata is missing, and disambiguate duplicate labels only when needed. (#45130) thanks @luzhidong.
|
||||
- Configure/startup: move outbound send-deps resolution into a lightweight helper so `openclaw configure` no longer stalls after the banner while eagerly loading channel plugins. (#46301) thanks @scoootscooob.
|
||||
- Zalo Personal/group gating: stop reapplying `dmPolicy.allowFrom` as a sender gate for already-allowlisted groups when `groupAllowFrom` is unset, so any member of an allowed group can trigger replies while DMs stay restricted. (#40146)
|
||||
- Plugins/install precedence: keep bundled plugins ahead of auto-discovered globals by default, but let an explicitly installed plugin record win its own duplicate-id tie so installed channel plugins load from `~/.openclaw/extensions` after `openclaw plugins install`.
|
||||
- Device pairing/setup codes: bind setup-code pairing to the intended node role and scope set so approval keeps the expected device profile. Thanks @vincentkoc.
|
||||
|
||||
### Fixes
|
||||
|
||||
- Slack/interactive replies: preserve `channelData.slack.blocks` through live DM delivery and preview-finalized edits so Block Kit button and select directives render instead of falling back to raw text. Thanks @vincentkoc.
|
||||
- CI/channel test routing: move the built-in channel suites into `test:channels` and keep them out of `test:extensions`, so extension CI no longer fails after the channel migration while targeted test routing still sends Slack, Signal, and iMessage suites to the right lane. (#46066) Thanks @scoootscooob.
|
||||
- Node/startup: remove leftover debug `console.log("node host PATH: ...")` that printed the resolved PATH on every `openclaw node run` invocation. (#46411)
|
||||
- Control UI/dashboard: preserve structured gateway shutdown reasons across restart disconnects so config-triggered restarts no longer fall back to `disconnected (1006): no reason`. (#46532) Thanks @vincentkoc.
|
||||
- Feishu/topic threads: fetch full thread context, including prior bot replies, when starting a topic-thread session so follow-up turns in Feishu topics keep the right conversation state. Thanks @Coobiw.
|
||||
- Browser/profiles: drop the auto-created `chrome-relay` browser profile; users who need the Chrome extension relay must now create their own profile via `openclaw browser create-profile`. (#45777) Thanks @odysseus0.
|
||||
- Docs/Mintlify: fix MDX marker syntax on Perplexity, Model Providers, Moonshot, and exec approvals pages so local docs preview no longer breaks rendering or leaves stale pages unpublished. (#46695) Thanks @velvet-shark.
|
||||
- Email/webhook wrapping: sanitize sender and subject metadata before external-content wrapping so metadata fields cannot break the wrapper structure. Thanks @vincentkoc.
|
||||
- Node/startup: remove leftover debug `console.log("node host PATH: ...")` that printed the resolved PATH on every `openclaw node run` invocation. (#46411)
|
||||
- Nodes/pending actions: re-check queued foreground actions against the current node command policy before returning them to the node. (#46815) Thanks @zpbrent and @vincentkoc.
|
||||
- ACP/approvals: use canonical tool identity for prompting decisions and fail closed when conflicting tool identity hints are present. (#46817) Thanks @zpbrent and @vincentkoc.
|
||||
- Telegram/message send: forward `--force-document` through the `sendPayload` path as well as `sendMedia`, so Telegram payload sends with `channelData` keep uploading images as documents instead of silently falling back to compressed photo sends. (#47119) Thanks @thepagent.
|
||||
- Telegram/message chunking: preserve spaces, paragraph separators, and word boundaries when HTML overflow rechunking splits formatted replies. (#47274)
|
||||
- Plugins/scoped ids: preserve scoped plugin ids during install and config keying, and keep bundled plugins ahead of discovered duplicate ids by default so `@scope/name` plugins no longer collide with unscoped installs. Thanks @vincentkoc.
|
||||
- CLI: avoid loading provider discovery during startup model normalization. (#46522) Thanks @ItsAditya-xyz and @vincentkoc.
|
||||
- Tlon: honor explicit empty allowlists and defer cite expansion. (#46788) Thanks @zpbrent and @vincentkoc.
|
||||
- ACP: require admin scope for mutating internal actions. (#46789) Thanks @tdjackey and @vincentkoc.
|
||||
- Gateway/config validation: stop treating the implicit default memory slot as a required explicit plugin config, so startup no longer fails with `plugins.slots.memory: plugin not found: memory-core` when `memory-core` was only inferred. (#47494) Thanks @ngutman.
|
||||
- CLI/startup: lazy-load channel add and root help startup paths to trim avoidable RSS and help latency on constrained hosts. (#46784) Thanks @vincentkoc.
|
||||
- CLI/onboarding: import static provider definitions directly for onboarding model/config helpers so those paths no longer pull provider discovery just for built-in defaults. (#47467) Thanks @vincentkoc.
|
||||
- CLI/auth choice: lazy-load plugin/provider fallback resolution so mapped auth choices stay on the static path and only unknown choices pay the heavy provider load. (#47495) Thanks @vincentkoc.
|
||||
- CLI/completion: reduce recursive completion-script string churn and fix nested PowerShell command-path matching so generated nested completions resolve on PowerShell too. (#45537) Thanks @yiShanXin and @vincentkoc.
|
||||
- Gateway/startup: load bundled channel plugins from compiled `dist/extensions` entries in built installs, so gateway boot no longer recompiles bundled extension TypeScript on every startup and WhatsApp-class cold starts drop back to seconds instead of tens of seconds or worse.
|
||||
- Gateway/watch mode: restart on bundled-plugin package and manifest metadata changes, rebuild `dist` for extension source and `tsdown.config.ts` changes, and still ignore extension docs. (#47571) thanks @gumadeiras.
|
||||
- Gateway/watch mode: recreate bundled plugin runtime metadata after clean or stale `dist` states, so `pnpm gateway:watch` no longer fails on missing `dist/extensions/*/openclaw.plugin.json` manifests after a rebuild. Thanks @gumadeiras.
|
||||
- Plugins/context engines: enforce owner-aware context-engine registration on both loader and public SDK paths so plugins cannot spoof privileged ownership, claim the core `legacy` engine id, or overwrite an existing engine id through direct SDK imports. (#47595) Thanks @vincentkoc.
|
||||
|
||||
## 2026.3.13
|
||||
|
||||
@@ -108,7 +68,6 @@ Docs: https://docs.openclaw.ai
|
||||
- macOS/exec approvals: respect per-agent exec approval settings in the gateway prompter, including allowlist fallback when the native prompt cannot be shown, so gateway-triggered `system.run` requests follow configured policy instead of always prompting or denying unexpectedly. (#13707) Thanks @sliekens.
|
||||
- Telegram/media downloads: thread the same direct or proxy transport policy into SSRF-guarded file fetches so inbound attachments keep working when Telegram falls back between env-proxy and direct networking. (#44639) Thanks @obviyus.
|
||||
- Telegram/inbound media IPv4 fallback: retry SSRF-guarded Telegram file downloads once with the same IPv4 fallback policy as Bot API calls so fresh installs on IPv6-broken hosts no longer fail to download inbound images.
|
||||
- Commands/onboarding: split static auth-choice help from the plugin-backed onboarding catalog so `openclaw onboard` registration no longer pulls provider-wizard imports just to describe `--auth-choice`. (#47545) Thanks @vincentkoc.
|
||||
- Windows/gateway install: bound `schtasks` calls and fall back to the Startup-folder login item when task creation hangs, so native `openclaw gateway install` fails fast instead of wedging forever on broken Scheduled Task setups.
|
||||
- Windows/gateway stop: resolve Startup-folder fallback listeners from the installed `gateway.cmd` port, so `openclaw gateway stop` now actually kills fallback-launched gateway processes before restart.
|
||||
- Windows/gateway status: reuse the installed service command environment when reading runtime status, so startup-fallback gateways keep reporting the configured port and running state in `gateway status --json` instead of falling back to `gateway port unknown`.
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
<p align="center">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/openclaw/openclaw/main/docs/assets/openclaw-logo-text-dark.svg">
|
||||
<img src="https://raw.githubusercontent.com/openclaw/openclaw/main/docs/assets/openclaw-logo-text.svg" alt="OpenClaw" width="500">
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/openclaw/openclaw/main/docs/assets/openclaw-logo-text-dark.png">
|
||||
<img src="https://raw.githubusercontent.com/openclaw/openclaw/main/docs/assets/openclaw-logo-text.png" alt="OpenClaw" width="500">
|
||||
</picture>
|
||||
</p>
|
||||
|
||||
@@ -103,7 +103,7 @@ pnpm build
|
||||
|
||||
pnpm openclaw onboard --install-daemon
|
||||
|
||||
# Dev loop (auto-reload on source/config changes)
|
||||
# Dev loop (auto-reload on TS changes)
|
||||
pnpm gateway:watch
|
||||
```
|
||||
|
||||
|
||||
@@ -19,7 +19,6 @@
|
||||
android:maxSdkVersion="32" />
|
||||
<uses-permission android:name="android.permission.READ_CONTACTS" />
|
||||
<uses-permission android:name="android.permission.WRITE_CONTACTS" />
|
||||
<uses-permission android:name="android.permission.READ_CALL_LOG" />
|
||||
<uses-permission android:name="android.permission.READ_CALENDAR" />
|
||||
<uses-permission android:name="android.permission.WRITE_CALENDAR" />
|
||||
<uses-permission android:name="android.permission.ACTIVITY_RECOGNITION" />
|
||||
|
||||
@@ -110,10 +110,6 @@ class NodeRuntime(context: Context) {
|
||||
appContext = appContext,
|
||||
)
|
||||
|
||||
private val callLogHandler: CallLogHandler = CallLogHandler(
|
||||
appContext = appContext,
|
||||
)
|
||||
|
||||
private val motionHandler: MotionHandler = MotionHandler(
|
||||
appContext = appContext,
|
||||
)
|
||||
@@ -155,7 +151,6 @@ class NodeRuntime(context: Context) {
|
||||
smsHandler = smsHandlerImpl,
|
||||
a2uiHandler = a2uiHandler,
|
||||
debugHandler = debugHandler,
|
||||
callLogHandler = callLogHandler,
|
||||
isForeground = { _isForeground.value },
|
||||
cameraEnabled = { cameraEnabled.value },
|
||||
locationEnabled = { locationMode.value != LocationMode.Off },
|
||||
|
||||
@@ -1,247 +0,0 @@
|
||||
package ai.openclaw.app.node
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.provider.CallLog
|
||||
import androidx.core.content.ContextCompat
|
||||
import ai.openclaw.app.gateway.GatewaySession
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.buildJsonArray
|
||||
import kotlinx.serialization.json.put
|
||||
|
||||
private const val DEFAULT_CALL_LOG_LIMIT = 25
|
||||
|
||||
internal data class CallLogRecord(
|
||||
val number: String?,
|
||||
val cachedName: String?,
|
||||
val date: Long,
|
||||
val duration: Long,
|
||||
val type: Int,
|
||||
)
|
||||
|
||||
internal data class CallLogSearchRequest(
|
||||
val limit: Int, // Number of records to return
|
||||
val offset: Int, // Offset value
|
||||
val cachedName: String?, // Search by contact name
|
||||
val number: String?, // Search by phone number
|
||||
val date: Long?, // Search by time (timestamp, deprecated, use dateStart/dateEnd)
|
||||
val dateStart: Long?, // Query start time (timestamp)
|
||||
val dateEnd: Long?, // Query end time (timestamp)
|
||||
val duration: Long?, // Search by duration (seconds)
|
||||
val type: Int?, // Search by call log type
|
||||
)
|
||||
|
||||
internal interface CallLogDataSource {
|
||||
fun hasReadPermission(context: Context): Boolean
|
||||
|
||||
fun search(context: Context, request: CallLogSearchRequest): List<CallLogRecord>
|
||||
}
|
||||
|
||||
private object SystemCallLogDataSource : CallLogDataSource {
|
||||
override fun hasReadPermission(context: Context): Boolean {
|
||||
return ContextCompat.checkSelfPermission(
|
||||
context,
|
||||
Manifest.permission.READ_CALL_LOG
|
||||
) == android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
|
||||
override fun search(context: Context, request: CallLogSearchRequest): List<CallLogRecord> {
|
||||
val resolver = context.contentResolver
|
||||
val projection = arrayOf(
|
||||
CallLog.Calls.NUMBER,
|
||||
CallLog.Calls.CACHED_NAME,
|
||||
CallLog.Calls.DATE,
|
||||
CallLog.Calls.DURATION,
|
||||
CallLog.Calls.TYPE,
|
||||
)
|
||||
|
||||
// Build selection and selectionArgs for filtering
|
||||
val selections = mutableListOf<String>()
|
||||
val selectionArgs = mutableListOf<String>()
|
||||
|
||||
request.cachedName?.let {
|
||||
selections.add("${CallLog.Calls.CACHED_NAME} LIKE ?")
|
||||
selectionArgs.add("%$it%")
|
||||
}
|
||||
|
||||
request.number?.let {
|
||||
selections.add("${CallLog.Calls.NUMBER} LIKE ?")
|
||||
selectionArgs.add("%$it%")
|
||||
}
|
||||
|
||||
// Support time range query
|
||||
if (request.dateStart != null && request.dateEnd != null) {
|
||||
selections.add("${CallLog.Calls.DATE} >= ? AND ${CallLog.Calls.DATE} <= ?")
|
||||
selectionArgs.add(request.dateStart.toString())
|
||||
selectionArgs.add(request.dateEnd.toString())
|
||||
} else if (request.dateStart != null) {
|
||||
selections.add("${CallLog.Calls.DATE} >= ?")
|
||||
selectionArgs.add(request.dateStart.toString())
|
||||
} else if (request.dateEnd != null) {
|
||||
selections.add("${CallLog.Calls.DATE} <= ?")
|
||||
selectionArgs.add(request.dateEnd.toString())
|
||||
} else if (request.date != null) {
|
||||
// Compatible with the old date parameter (exact match)
|
||||
selections.add("${CallLog.Calls.DATE} = ?")
|
||||
selectionArgs.add(request.date.toString())
|
||||
}
|
||||
|
||||
request.duration?.let {
|
||||
selections.add("${CallLog.Calls.DURATION} = ?")
|
||||
selectionArgs.add(it.toString())
|
||||
}
|
||||
|
||||
request.type?.let {
|
||||
selections.add("${CallLog.Calls.TYPE} = ?")
|
||||
selectionArgs.add(it.toString())
|
||||
}
|
||||
|
||||
val selection = if (selections.isNotEmpty()) selections.joinToString(" AND ") else null
|
||||
val selectionArgsArray = if (selectionArgs.isNotEmpty()) selectionArgs.toTypedArray() else null
|
||||
|
||||
val sortOrder = "${CallLog.Calls.DATE} DESC"
|
||||
|
||||
resolver.query(
|
||||
CallLog.Calls.CONTENT_URI,
|
||||
projection,
|
||||
selection,
|
||||
selectionArgsArray,
|
||||
sortOrder,
|
||||
).use { cursor ->
|
||||
if (cursor == null) return emptyList()
|
||||
|
||||
val numberIndex = cursor.getColumnIndex(CallLog.Calls.NUMBER)
|
||||
val cachedNameIndex = cursor.getColumnIndex(CallLog.Calls.CACHED_NAME)
|
||||
val dateIndex = cursor.getColumnIndex(CallLog.Calls.DATE)
|
||||
val durationIndex = cursor.getColumnIndex(CallLog.Calls.DURATION)
|
||||
val typeIndex = cursor.getColumnIndex(CallLog.Calls.TYPE)
|
||||
|
||||
// Skip offset rows
|
||||
if (request.offset > 0 && cursor.moveToPosition(request.offset - 1)) {
|
||||
// Successfully moved to offset position
|
||||
}
|
||||
|
||||
val out = mutableListOf<CallLogRecord>()
|
||||
var count = 0
|
||||
while (cursor.moveToNext() && count < request.limit) {
|
||||
out += CallLogRecord(
|
||||
number = cursor.getString(numberIndex),
|
||||
cachedName = cursor.getString(cachedNameIndex),
|
||||
date = cursor.getLong(dateIndex),
|
||||
duration = cursor.getLong(durationIndex),
|
||||
type = cursor.getInt(typeIndex),
|
||||
)
|
||||
count++
|
||||
}
|
||||
return out
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class CallLogHandler private constructor(
|
||||
private val appContext: Context,
|
||||
private val dataSource: CallLogDataSource,
|
||||
) {
|
||||
constructor(appContext: Context) : this(appContext = appContext, dataSource = SystemCallLogDataSource)
|
||||
|
||||
fun handleCallLogSearch(paramsJson: String?): GatewaySession.InvokeResult {
|
||||
if (!dataSource.hasReadPermission(appContext)) {
|
||||
return GatewaySession.InvokeResult.error(
|
||||
code = "CALL_LOG_PERMISSION_REQUIRED",
|
||||
message = "CALL_LOG_PERMISSION_REQUIRED: grant Call Log permission",
|
||||
)
|
||||
}
|
||||
|
||||
val request = parseSearchRequest(paramsJson)
|
||||
?: return GatewaySession.InvokeResult.error(
|
||||
code = "INVALID_REQUEST",
|
||||
message = "INVALID_REQUEST: expected JSON object",
|
||||
)
|
||||
|
||||
return try {
|
||||
val callLogs = dataSource.search(appContext, request)
|
||||
GatewaySession.InvokeResult.ok(
|
||||
buildJsonObject {
|
||||
put(
|
||||
"callLogs",
|
||||
buildJsonArray {
|
||||
callLogs.forEach { add(callLogJson(it)) }
|
||||
},
|
||||
)
|
||||
}.toString(),
|
||||
)
|
||||
} catch (err: Throwable) {
|
||||
GatewaySession.InvokeResult.error(
|
||||
code = "CALL_LOG_UNAVAILABLE",
|
||||
message = "CALL_LOG_UNAVAILABLE: ${err.message ?: "call log query failed"}",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseSearchRequest(paramsJson: String?): CallLogSearchRequest? {
|
||||
if (paramsJson.isNullOrBlank()) {
|
||||
return CallLogSearchRequest(
|
||||
limit = DEFAULT_CALL_LOG_LIMIT,
|
||||
offset = 0,
|
||||
cachedName = null,
|
||||
number = null,
|
||||
date = null,
|
||||
dateStart = null,
|
||||
dateEnd = null,
|
||||
duration = null,
|
||||
type = null,
|
||||
)
|
||||
}
|
||||
|
||||
val params = try {
|
||||
Json.parseToJsonElement(paramsJson).asObjectOrNull()
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
} ?: return null
|
||||
|
||||
val limit = ((params["limit"] as? JsonPrimitive)?.content?.toIntOrNull() ?: DEFAULT_CALL_LOG_LIMIT)
|
||||
.coerceIn(1, 200)
|
||||
val offset = ((params["offset"] as? JsonPrimitive)?.content?.toIntOrNull() ?: 0)
|
||||
.coerceAtLeast(0)
|
||||
val cachedName = (params["cachedName"] as? JsonPrimitive)?.content?.takeIf { it.isNotBlank() }
|
||||
val number = (params["number"] as? JsonPrimitive)?.content?.takeIf { it.isNotBlank() }
|
||||
val date = (params["date"] as? JsonPrimitive)?.content?.toLongOrNull()
|
||||
val dateStart = (params["dateStart"] as? JsonPrimitive)?.content?.toLongOrNull()
|
||||
val dateEnd = (params["dateEnd"] as? JsonPrimitive)?.content?.toLongOrNull()
|
||||
val duration = (params["duration"] as? JsonPrimitive)?.content?.toLongOrNull()
|
||||
val type = (params["type"] as? JsonPrimitive)?.content?.toIntOrNull()
|
||||
|
||||
return CallLogSearchRequest(
|
||||
limit = limit,
|
||||
offset = offset,
|
||||
cachedName = cachedName,
|
||||
number = number,
|
||||
date = date,
|
||||
dateStart = dateStart,
|
||||
dateEnd = dateEnd,
|
||||
duration = duration,
|
||||
type = type,
|
||||
)
|
||||
}
|
||||
|
||||
private fun callLogJson(callLog: CallLogRecord): JsonObject {
|
||||
return buildJsonObject {
|
||||
put("number", JsonPrimitive(callLog.number))
|
||||
put("cachedName", JsonPrimitive(callLog.cachedName))
|
||||
put("date", JsonPrimitive(callLog.date))
|
||||
put("duration", JsonPrimitive(callLog.duration))
|
||||
put("type", JsonPrimitive(callLog.type))
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
internal fun forTesting(
|
||||
appContext: Context,
|
||||
dataSource: CallLogDataSource,
|
||||
): CallLogHandler = CallLogHandler(appContext = appContext, dataSource = dataSource)
|
||||
}
|
||||
}
|
||||
@@ -212,13 +212,6 @@ class DeviceHandler(
|
||||
promptableWhenDenied = true,
|
||||
),
|
||||
)
|
||||
put(
|
||||
"callLog",
|
||||
permissionStateJson(
|
||||
granted = hasPermission(Manifest.permission.READ_CALL_LOG),
|
||||
promptableWhenDenied = true,
|
||||
),
|
||||
)
|
||||
put(
|
||||
"motion",
|
||||
permissionStateJson(
|
||||
|
||||
@@ -5,7 +5,6 @@ import ai.openclaw.app.protocol.OpenClawCanvasA2UICommand
|
||||
import ai.openclaw.app.protocol.OpenClawCanvasCommand
|
||||
import ai.openclaw.app.protocol.OpenClawCameraCommand
|
||||
import ai.openclaw.app.protocol.OpenClawCapability
|
||||
import ai.openclaw.app.protocol.OpenClawCallLogCommand
|
||||
import ai.openclaw.app.protocol.OpenClawContactsCommand
|
||||
import ai.openclaw.app.protocol.OpenClawDeviceCommand
|
||||
import ai.openclaw.app.protocol.OpenClawLocationCommand
|
||||
@@ -85,7 +84,6 @@ object InvokeCommandRegistry {
|
||||
name = OpenClawCapability.Motion.rawValue,
|
||||
availability = NodeCapabilityAvailability.MotionAvailable,
|
||||
),
|
||||
NodeCapabilitySpec(name = OpenClawCapability.CallLog.rawValue),
|
||||
)
|
||||
|
||||
val all: List<InvokeCommandSpec> =
|
||||
@@ -189,9 +187,6 @@ object InvokeCommandRegistry {
|
||||
name = OpenClawSmsCommand.Send.rawValue,
|
||||
availability = InvokeCommandAvailability.SmsAvailable,
|
||||
),
|
||||
InvokeCommandSpec(
|
||||
name = OpenClawCallLogCommand.Search.rawValue,
|
||||
),
|
||||
InvokeCommandSpec(
|
||||
name = "debug.logs",
|
||||
availability = InvokeCommandAvailability.DebugBuild,
|
||||
|
||||
@@ -5,7 +5,6 @@ import ai.openclaw.app.protocol.OpenClawCalendarCommand
|
||||
import ai.openclaw.app.protocol.OpenClawCanvasA2UICommand
|
||||
import ai.openclaw.app.protocol.OpenClawCanvasCommand
|
||||
import ai.openclaw.app.protocol.OpenClawCameraCommand
|
||||
import ai.openclaw.app.protocol.OpenClawCallLogCommand
|
||||
import ai.openclaw.app.protocol.OpenClawContactsCommand
|
||||
import ai.openclaw.app.protocol.OpenClawDeviceCommand
|
||||
import ai.openclaw.app.protocol.OpenClawLocationCommand
|
||||
@@ -28,7 +27,6 @@ class InvokeDispatcher(
|
||||
private val smsHandler: SmsHandler,
|
||||
private val a2uiHandler: A2UIHandler,
|
||||
private val debugHandler: DebugHandler,
|
||||
private val callLogHandler: CallLogHandler,
|
||||
private val isForeground: () -> Boolean,
|
||||
private val cameraEnabled: () -> Boolean,
|
||||
private val locationEnabled: () -> Boolean,
|
||||
@@ -163,9 +161,6 @@ class InvokeDispatcher(
|
||||
// SMS command
|
||||
OpenClawSmsCommand.Send.rawValue -> smsHandler.handleSmsSend(paramsJson)
|
||||
|
||||
// CallLog command
|
||||
OpenClawCallLogCommand.Search.rawValue -> callLogHandler.handleCallLogSearch(paramsJson)
|
||||
|
||||
// Debug commands
|
||||
"debug.ed25519" -> debugHandler.handleEd25519()
|
||||
"debug.logs" -> debugHandler.handleLogs()
|
||||
|
||||
@@ -13,7 +13,6 @@ enum class OpenClawCapability(val rawValue: String) {
|
||||
Contacts("contacts"),
|
||||
Calendar("calendar"),
|
||||
Motion("motion"),
|
||||
CallLog("callLog"),
|
||||
}
|
||||
|
||||
enum class OpenClawCanvasCommand(val rawValue: String) {
|
||||
@@ -138,12 +137,3 @@ enum class OpenClawMotionCommand(val rawValue: String) {
|
||||
const val NamespacePrefix: String = "motion."
|
||||
}
|
||||
}
|
||||
|
||||
enum class OpenClawCallLogCommand(val rawValue: String) {
|
||||
Search("callLog.search"),
|
||||
;
|
||||
|
||||
companion object {
|
||||
const val NamespacePrefix: String = "callLog."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,28 +92,20 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
|
||||
val prompt = pendingTrust!!
|
||||
AlertDialog(
|
||||
onDismissRequest = { viewModel.declineGatewayTrustPrompt() },
|
||||
containerColor = mobileCardSurface,
|
||||
title = { Text("Trust this gateway?", style = mobileHeadline, color = mobileText) },
|
||||
title = { Text("Trust this gateway?") },
|
||||
text = {
|
||||
Text(
|
||||
"First-time TLS connection.\n\nVerify this SHA-256 fingerprint before trusting:\n${prompt.fingerprintSha256}",
|
||||
style = mobileCallout,
|
||||
color = mobileText,
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = { viewModel.acceptGatewayTrustPrompt() },
|
||||
colors = ButtonDefaults.textButtonColors(contentColor = mobileAccent),
|
||||
) {
|
||||
TextButton(onClick = { viewModel.acceptGatewayTrustPrompt() }) {
|
||||
Text("Trust and continue")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(
|
||||
onClick = { viewModel.declineGatewayTrustPrompt() },
|
||||
colors = ButtonDefaults.textButtonColors(contentColor = mobileTextSecondary),
|
||||
) {
|
||||
TextButton(onClick = { viewModel.declineGatewayTrustPrompt() }) {
|
||||
Text("Cancel")
|
||||
}
|
||||
},
|
||||
|
||||
@@ -121,7 +121,6 @@ private enum class PermissionToggle {
|
||||
Calendar,
|
||||
Motion,
|
||||
Sms,
|
||||
CallLog,
|
||||
}
|
||||
|
||||
private enum class SpecialAccessToggle {
|
||||
@@ -289,10 +288,6 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
|
||||
rememberSaveable {
|
||||
mutableStateOf(smsAvailable && isPermissionGranted(context, Manifest.permission.SEND_SMS))
|
||||
}
|
||||
var enableCallLog by
|
||||
rememberSaveable {
|
||||
mutableStateOf(isPermissionGranted(context, Manifest.permission.READ_CALL_LOG))
|
||||
}
|
||||
|
||||
var pendingPermissionToggle by remember { mutableStateOf<PermissionToggle?>(null) }
|
||||
var pendingSpecialAccessToggle by remember { mutableStateOf<SpecialAccessToggle?>(null) }
|
||||
@@ -309,7 +304,6 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
|
||||
PermissionToggle.Calendar -> enableCalendar = enabled
|
||||
PermissionToggle.Motion -> enableMotion = enabled && motionAvailable
|
||||
PermissionToggle.Sms -> enableSms = enabled && smsAvailable
|
||||
PermissionToggle.CallLog -> enableCallLog = enabled
|
||||
}
|
||||
}
|
||||
|
||||
@@ -337,7 +331,6 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
|
||||
isPermissionGranted(context, Manifest.permission.ACTIVITY_RECOGNITION)
|
||||
PermissionToggle.Sms ->
|
||||
!smsAvailable || isPermissionGranted(context, Manifest.permission.SEND_SMS)
|
||||
PermissionToggle.CallLog -> isPermissionGranted(context, Manifest.permission.READ_CALL_LOG)
|
||||
}
|
||||
|
||||
fun setSpecialAccessToggleEnabled(toggle: SpecialAccessToggle, enabled: Boolean) {
|
||||
@@ -359,7 +352,6 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
|
||||
enableCalendar,
|
||||
enableMotion,
|
||||
enableSms,
|
||||
enableCallLog,
|
||||
smsAvailable,
|
||||
motionAvailable,
|
||||
) {
|
||||
@@ -375,7 +367,6 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
|
||||
if (enableCalendar) enabled += "Calendar"
|
||||
if (enableMotion && motionAvailable) enabled += "Motion"
|
||||
if (smsAvailable && enableSms) enabled += "SMS"
|
||||
if (enableCallLog) enabled += "Call Log"
|
||||
if (enabled.isEmpty()) "None selected" else enabled.joinToString(", ")
|
||||
}
|
||||
|
||||
@@ -464,28 +455,19 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
|
||||
val prompt = pendingTrust!!
|
||||
AlertDialog(
|
||||
onDismissRequest = { viewModel.declineGatewayTrustPrompt() },
|
||||
containerColor = onboardingSurface,
|
||||
title = { Text("Trust this gateway?", style = onboardingHeadlineStyle, color = onboardingText) },
|
||||
title = { Text("Trust this gateway?") },
|
||||
text = {
|
||||
Text(
|
||||
"First-time TLS connection.\n\nVerify this SHA-256 fingerprint before trusting:\n${prompt.fingerprintSha256}",
|
||||
style = onboardingCalloutStyle,
|
||||
color = onboardingText,
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = { viewModel.acceptGatewayTrustPrompt() },
|
||||
colors = ButtonDefaults.textButtonColors(contentColor = onboardingAccent),
|
||||
) {
|
||||
TextButton(onClick = { viewModel.acceptGatewayTrustPrompt() }) {
|
||||
Text("Trust and continue")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(
|
||||
onClick = { viewModel.declineGatewayTrustPrompt() },
|
||||
colors = ButtonDefaults.textButtonColors(contentColor = onboardingTextSecondary),
|
||||
) {
|
||||
TextButton(onClick = { viewModel.declineGatewayTrustPrompt() }) {
|
||||
Text("Cancel")
|
||||
}
|
||||
},
|
||||
@@ -604,7 +586,6 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
|
||||
motionPermissionRequired = motionPermissionRequired,
|
||||
enableSms = enableSms,
|
||||
smsAvailable = smsAvailable,
|
||||
enableCallLog = enableCallLog,
|
||||
context = context,
|
||||
onDiscoveryChange = { checked ->
|
||||
requestPermissionToggle(
|
||||
@@ -702,13 +683,6 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
|
||||
)
|
||||
}
|
||||
},
|
||||
onCallLogChange = { checked ->
|
||||
requestPermissionToggle(
|
||||
PermissionToggle.CallLog,
|
||||
checked,
|
||||
listOf(Manifest.permission.READ_CALL_LOG),
|
||||
)
|
||||
},
|
||||
)
|
||||
OnboardingStep.FinalCheck ->
|
||||
FinalStep(
|
||||
@@ -1299,7 +1273,6 @@ private fun PermissionsStep(
|
||||
motionPermissionRequired: Boolean,
|
||||
enableSms: Boolean,
|
||||
smsAvailable: Boolean,
|
||||
enableCallLog: Boolean,
|
||||
context: Context,
|
||||
onDiscoveryChange: (Boolean) -> Unit,
|
||||
onLocationChange: (Boolean) -> Unit,
|
||||
@@ -1312,7 +1285,6 @@ private fun PermissionsStep(
|
||||
onCalendarChange: (Boolean) -> Unit,
|
||||
onMotionChange: (Boolean) -> Unit,
|
||||
onSmsChange: (Boolean) -> Unit,
|
||||
onCallLogChange: (Boolean) -> Unit,
|
||||
) {
|
||||
val discoveryPermission = if (Build.VERSION.SDK_INT >= 33) Manifest.permission.NEARBY_WIFI_DEVICES else Manifest.permission.ACCESS_FINE_LOCATION
|
||||
val locationGranted =
|
||||
@@ -1443,15 +1415,6 @@ private fun PermissionsStep(
|
||||
onCheckedChange = onSmsChange,
|
||||
)
|
||||
}
|
||||
InlineDivider()
|
||||
PermissionToggleRow(
|
||||
title = "Call Log",
|
||||
subtitle = "callLog.search",
|
||||
checked = enableCallLog,
|
||||
granted = isPermissionGranted(context, Manifest.permission.READ_CALL_LOG),
|
||||
onCheckedChange = onCallLogChange,
|
||||
)
|
||||
Text("All settings can be changed later in Settings.", style = onboardingCalloutStyle, color = onboardingTextSecondary)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -218,18 +218,6 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
||||
calendarPermissionGranted = readOk && writeOk
|
||||
}
|
||||
|
||||
var callLogPermissionGranted by
|
||||
remember {
|
||||
mutableStateOf(
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CALL_LOG) ==
|
||||
PackageManager.PERMISSION_GRANTED,
|
||||
)
|
||||
}
|
||||
val callLogPermissionLauncher =
|
||||
rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
|
||||
callLogPermissionGranted = granted
|
||||
}
|
||||
|
||||
var motionPermissionGranted by
|
||||
remember {
|
||||
mutableStateOf(
|
||||
@@ -278,9 +266,6 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
||||
PackageManager.PERMISSION_GRANTED &&
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.WRITE_CALENDAR) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
callLogPermissionGranted =
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CALL_LOG) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
motionPermissionGranted =
|
||||
!motionPermissionRequired ||
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.ACTIVITY_RECOGNITION) ==
|
||||
@@ -616,31 +601,6 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
||||
}
|
||||
},
|
||||
)
|
||||
HorizontalDivider(color = mobileBorder)
|
||||
ListItem(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = listItemColors,
|
||||
headlineContent = { Text("Call Log", style = mobileHeadline) },
|
||||
supportingContent = { Text("Search recent call history.", style = mobileCallout) },
|
||||
trailingContent = {
|
||||
Button(
|
||||
onClick = {
|
||||
if (callLogPermissionGranted) {
|
||||
openAppSettings(context)
|
||||
} else {
|
||||
callLogPermissionLauncher.launch(Manifest.permission.READ_CALL_LOG)
|
||||
}
|
||||
},
|
||||
colors = settingsPrimaryButtonColors(),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
) {
|
||||
Text(
|
||||
if (callLogPermissionGranted) "Manage" else "Grant",
|
||||
style = mobileCallout.copy(fontWeight = FontWeight.Bold),
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
if (motionAvailable) {
|
||||
HorizontalDivider(color = mobileBorder)
|
||||
ListItem(
|
||||
@@ -822,7 +782,7 @@ private fun openNotificationListenerSettings(context: Context) {
|
||||
private fun hasNotificationsPermission(context: Context): Boolean {
|
||||
if (Build.VERSION.SDK_INT < 33) return true
|
||||
return ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
|
||||
private fun isNotificationListenerEnabled(context: Context): Boolean {
|
||||
@@ -832,5 +792,5 @@ private fun isNotificationListenerEnabled(context: Context): Boolean {
|
||||
private fun hasMotionCapabilities(context: Context): Boolean {
|
||||
val sensorManager = context.getSystemService(SensorManager::class.java) ?: return false
|
||||
return sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER) != null ||
|
||||
sensorManager.getDefaultSensor(Sensor.TYPE_STEP_COUNTER) != null
|
||||
sensorManager.getDefaultSensor(Sensor.TYPE_STEP_COUNTER) != null
|
||||
}
|
||||
|
||||
@@ -128,15 +128,7 @@ fun ChatComposer(
|
||||
}
|
||||
}
|
||||
|
||||
DropdownMenu(
|
||||
expanded = showThinkingMenu,
|
||||
onDismissRequest = { showThinkingMenu = false },
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
containerColor = mobileCardSurface,
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 8.dp,
|
||||
border = BorderStroke(1.dp, mobileBorder),
|
||||
) {
|
||||
DropdownMenu(expanded = showThinkingMenu, onDismissRequest = { showThinkingMenu = false }) {
|
||||
ThinkingMenuItem("off", thinkingLevel, onSetThinkingLevel) { showThinkingMenu = false }
|
||||
ThinkingMenuItem("low", thinkingLevel, onSetThinkingLevel) { showThinkingMenu = false }
|
||||
ThinkingMenuItem("medium", thinkingLevel, onSetThinkingLevel) { showThinkingMenu = false }
|
||||
|
||||
@@ -1,193 +0,0 @@
|
||||
package ai.openclaw.app.node
|
||||
|
||||
import android.content.Context
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class CallLogHandlerTest : NodeHandlerRobolectricTest() {
|
||||
@Test
|
||||
fun handleCallLogSearch_requiresPermission() {
|
||||
val handler = CallLogHandler.forTesting(appContext(), FakeCallLogDataSource(canRead = false))
|
||||
|
||||
val result = handler.handleCallLogSearch(null)
|
||||
|
||||
assertFalse(result.ok)
|
||||
assertEquals("CALL_LOG_PERMISSION_REQUIRED", result.error?.code)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun handleCallLogSearch_rejectsInvalidJson() {
|
||||
val handler = CallLogHandler.forTesting(appContext(), FakeCallLogDataSource(canRead = true))
|
||||
|
||||
val result = handler.handleCallLogSearch("invalid json")
|
||||
|
||||
assertFalse(result.ok)
|
||||
assertEquals("INVALID_REQUEST", result.error?.code)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun handleCallLogSearch_returnsCallLogs() {
|
||||
val callLog =
|
||||
CallLogRecord(
|
||||
number = "+123456",
|
||||
cachedName = "lixuankai",
|
||||
date = 1709280000000L,
|
||||
duration = 60L,
|
||||
type = 1,
|
||||
)
|
||||
val handler =
|
||||
CallLogHandler.forTesting(
|
||||
appContext(),
|
||||
FakeCallLogDataSource(canRead = true, searchResults = listOf(callLog)),
|
||||
)
|
||||
|
||||
val result = handler.handleCallLogSearch("""{"limit":1}""")
|
||||
|
||||
assertTrue(result.ok)
|
||||
val payload = Json.parseToJsonElement(result.payloadJson ?: error("missing payload")).jsonObject
|
||||
val callLogs = payload.getValue("callLogs").jsonArray
|
||||
assertEquals(1, callLogs.size)
|
||||
assertEquals("+123456", callLogs.first().jsonObject.getValue("number").jsonPrimitive.content)
|
||||
assertEquals("lixuankai", callLogs.first().jsonObject.getValue("cachedName").jsonPrimitive.content)
|
||||
assertEquals(1709280000000L, callLogs.first().jsonObject.getValue("date").jsonPrimitive.content.toLong())
|
||||
assertEquals(60L, callLogs.first().jsonObject.getValue("duration").jsonPrimitive.content.toLong())
|
||||
assertEquals(1, callLogs.first().jsonObject.getValue("type").jsonPrimitive.content.toInt())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun handleCallLogSearch_withFilters() {
|
||||
val callLog =
|
||||
CallLogRecord(
|
||||
number = "+123456",
|
||||
cachedName = "lixuankai",
|
||||
date = 1709280000000L,
|
||||
duration = 120L,
|
||||
type = 2,
|
||||
)
|
||||
val handler =
|
||||
CallLogHandler.forTesting(
|
||||
appContext(),
|
||||
FakeCallLogDataSource(canRead = true, searchResults = listOf(callLog)),
|
||||
)
|
||||
|
||||
val result = handler.handleCallLogSearch(
|
||||
"""{"number":"123456","cachedName":"lixuankai","dateStart":1709270000000,"dateEnd":1709290000000,"duration":120,"type":2}"""
|
||||
)
|
||||
|
||||
assertTrue(result.ok)
|
||||
val payload = Json.parseToJsonElement(result.payloadJson ?: error("missing payload")).jsonObject
|
||||
val callLogs = payload.getValue("callLogs").jsonArray
|
||||
assertEquals(1, callLogs.size)
|
||||
assertEquals("lixuankai", callLogs.first().jsonObject.getValue("cachedName").jsonPrimitive.content)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun handleCallLogSearch_withPagination() {
|
||||
val callLogs =
|
||||
listOf(
|
||||
CallLogRecord(
|
||||
number = "+123456",
|
||||
cachedName = "lixuankai",
|
||||
date = 1709280000000L,
|
||||
duration = 60L,
|
||||
type = 1,
|
||||
),
|
||||
CallLogRecord(
|
||||
number = "+654321",
|
||||
cachedName = "lixuankai2",
|
||||
date = 1709280001000L,
|
||||
duration = 120L,
|
||||
type = 2,
|
||||
),
|
||||
)
|
||||
val handler =
|
||||
CallLogHandler.forTesting(
|
||||
appContext(),
|
||||
FakeCallLogDataSource(canRead = true, searchResults = callLogs),
|
||||
)
|
||||
|
||||
val result = handler.handleCallLogSearch("""{"limit":1,"offset":1}""")
|
||||
|
||||
assertTrue(result.ok)
|
||||
val payload = Json.parseToJsonElement(result.payloadJson ?: error("missing payload")).jsonObject
|
||||
val callLogsResult = payload.getValue("callLogs").jsonArray
|
||||
assertEquals(1, callLogsResult.size)
|
||||
assertEquals("lixuankai2", callLogsResult.first().jsonObject.getValue("cachedName").jsonPrimitive.content)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun handleCallLogSearch_withDefaultParams() {
|
||||
val callLog =
|
||||
CallLogRecord(
|
||||
number = "+123456",
|
||||
cachedName = "lixuankai",
|
||||
date = 1709280000000L,
|
||||
duration = 60L,
|
||||
type = 1,
|
||||
)
|
||||
val handler =
|
||||
CallLogHandler.forTesting(
|
||||
appContext(),
|
||||
FakeCallLogDataSource(canRead = true, searchResults = listOf(callLog)),
|
||||
)
|
||||
|
||||
val result = handler.handleCallLogSearch(null)
|
||||
|
||||
assertTrue(result.ok)
|
||||
val payload = Json.parseToJsonElement(result.payloadJson ?: error("missing payload")).jsonObject
|
||||
val callLogs = payload.getValue("callLogs").jsonArray
|
||||
assertEquals(1, callLogs.size)
|
||||
assertEquals("+123456", callLogs.first().jsonObject.getValue("number").jsonPrimitive.content)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun handleCallLogSearch_withNullFields() {
|
||||
val callLog =
|
||||
CallLogRecord(
|
||||
number = null,
|
||||
cachedName = null,
|
||||
date = 1709280000000L,
|
||||
duration = 60L,
|
||||
type = 1,
|
||||
)
|
||||
val handler =
|
||||
CallLogHandler.forTesting(
|
||||
appContext(),
|
||||
FakeCallLogDataSource(canRead = true, searchResults = listOf(callLog)),
|
||||
)
|
||||
|
||||
val result = handler.handleCallLogSearch("""{"limit":1}""")
|
||||
|
||||
assertTrue(result.ok)
|
||||
val payload = Json.parseToJsonElement(result.payloadJson ?: error("missing payload")).jsonObject
|
||||
val callLogs = payload.getValue("callLogs").jsonArray
|
||||
assertEquals(1, callLogs.size)
|
||||
// Verify null values are properly serialized
|
||||
val callLogObj = callLogs.first().jsonObject
|
||||
assertTrue(callLogObj.containsKey("number"))
|
||||
assertTrue(callLogObj.containsKey("cachedName"))
|
||||
}
|
||||
}
|
||||
|
||||
private class FakeCallLogDataSource(
|
||||
private val canRead: Boolean,
|
||||
private val searchResults: List<CallLogRecord> = emptyList(),
|
||||
) : CallLogDataSource {
|
||||
override fun hasReadPermission(context: Context): Boolean = canRead
|
||||
|
||||
override fun search(context: Context, request: CallLogSearchRequest): List<CallLogRecord> {
|
||||
val startIndex = request.offset.coerceAtLeast(0)
|
||||
val endIndex = (startIndex + request.limit).coerceAtMost(searchResults.size)
|
||||
return if (startIndex < searchResults.size) {
|
||||
searchResults.subList(startIndex, endIndex)
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -93,7 +93,6 @@ class DeviceHandlerTest {
|
||||
"photos",
|
||||
"contacts",
|
||||
"calendar",
|
||||
"callLog",
|
||||
"motion",
|
||||
)
|
||||
for (key in expected) {
|
||||
|
||||
@@ -2,7 +2,6 @@ package ai.openclaw.app.node
|
||||
|
||||
import ai.openclaw.app.protocol.OpenClawCalendarCommand
|
||||
import ai.openclaw.app.protocol.OpenClawCameraCommand
|
||||
import ai.openclaw.app.protocol.OpenClawCallLogCommand
|
||||
import ai.openclaw.app.protocol.OpenClawCapability
|
||||
import ai.openclaw.app.protocol.OpenClawContactsCommand
|
||||
import ai.openclaw.app.protocol.OpenClawDeviceCommand
|
||||
@@ -26,7 +25,6 @@ class InvokeCommandRegistryTest {
|
||||
OpenClawCapability.Photos.rawValue,
|
||||
OpenClawCapability.Contacts.rawValue,
|
||||
OpenClawCapability.Calendar.rawValue,
|
||||
OpenClawCapability.CallLog.rawValue,
|
||||
)
|
||||
|
||||
private val optionalCapabilities =
|
||||
@@ -52,7 +50,6 @@ class InvokeCommandRegistryTest {
|
||||
OpenClawContactsCommand.Add.rawValue,
|
||||
OpenClawCalendarCommand.Events.rawValue,
|
||||
OpenClawCalendarCommand.Add.rawValue,
|
||||
OpenClawCallLogCommand.Search.rawValue,
|
||||
)
|
||||
|
||||
private val optionalCommands =
|
||||
|
||||
@@ -34,7 +34,6 @@ class OpenClawProtocolConstantsTest {
|
||||
assertEquals("contacts", OpenClawCapability.Contacts.rawValue)
|
||||
assertEquals("calendar", OpenClawCapability.Calendar.rawValue)
|
||||
assertEquals("motion", OpenClawCapability.Motion.rawValue)
|
||||
assertEquals("callLog", OpenClawCapability.CallLog.rawValue)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -85,9 +84,4 @@ class OpenClawProtocolConstantsTest {
|
||||
assertEquals("motion.activity", OpenClawMotionCommand.Activity.rawValue)
|
||||
assertEquals("motion.pedometer", OpenClawMotionCommand.Pedometer.rawValue)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun callLogCommandsUseStableStrings() {
|
||||
assertEquals("callLog.search", OpenClawCallLogCommand.Search.rawValue)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,10 +18,13 @@ final class CanvasA2UIActionMessageHandler: NSObject, WKScriptMessageHandler {
|
||||
func userContentController(_: WKUserContentController, didReceive message: WKScriptMessage) {
|
||||
guard Self.allMessageNames.contains(message.name) else { return }
|
||||
|
||||
// Only accept actions from the in-app canvas scheme. Local-network HTTP
|
||||
// pages are regular web content and must not get direct agent dispatch.
|
||||
// Only accept actions from local Canvas content (not arbitrary web pages).
|
||||
guard let webView = message.webView, let url = webView.url else { return }
|
||||
guard let scheme = url.scheme, CanvasScheme.allSchemes.contains(scheme) else {
|
||||
if let scheme = url.scheme, CanvasScheme.allSchemes.contains(scheme) {
|
||||
// ok
|
||||
} else if Self.isLocalNetworkCanvasURL(url) {
|
||||
// ok
|
||||
} else {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -104,5 +107,10 @@ final class CanvasA2UIActionMessageHandler: NSObject, WKScriptMessageHandler {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static func isLocalNetworkCanvasURL(_ url: URL) -> Bool {
|
||||
LocalNetworkURLSupport.isLocalNetworkHTTPURL(url)
|
||||
}
|
||||
|
||||
// Formatting helpers live in OpenClawKit (`OpenClawCanvasA2UIAction`).
|
||||
}
|
||||
|
||||
@@ -50,24 +50,21 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS
|
||||
|
||||
// Bridge A2UI "a2uiaction" DOM events back into the native agent loop.
|
||||
//
|
||||
// Keep the bridge on the trusted in-app canvas scheme only, and do not
|
||||
// expose unattended deep-link credentials to page JavaScript.
|
||||
// Prefer WKScriptMessageHandler when WebKit exposes it, otherwise fall back to an unattended deep link
|
||||
// (includes the app-generated key so it won't prompt).
|
||||
canvasWindowLogger.debug("CanvasWindowController init building A2UI bridge script")
|
||||
let deepLinkKey = DeepLinkHandler.currentCanvasKey()
|
||||
let injectedSessionKey = sessionKey.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty ?? "main"
|
||||
let allowedSchemesJSON = (
|
||||
try? String(
|
||||
data: JSONSerialization.data(withJSONObject: CanvasScheme.allSchemes),
|
||||
encoding: .utf8)
|
||||
) ?? "[]"
|
||||
let bridgeScript = """
|
||||
(() => {
|
||||
try {
|
||||
const allowedSchemes = \(allowedSchemesJSON);
|
||||
const allowedSchemes = \(String(describing: CanvasScheme.allSchemes));
|
||||
const protocol = location.protocol.replace(':', '');
|
||||
if (!allowedSchemes.includes(protocol)) return;
|
||||
if (globalThis.__openclawA2UIBridgeInstalled) return;
|
||||
globalThis.__openclawA2UIBridgeInstalled = true;
|
||||
|
||||
const deepLinkKey = \(Self.jsStringLiteral(deepLinkKey));
|
||||
const sessionKey = \(Self.jsStringLiteral(injectedSessionKey));
|
||||
const machineName = \(Self.jsStringLiteral(InstanceIdentity.displayName));
|
||||
const instanceId = \(Self.jsStringLiteral(InstanceIdentity.instanceId));
|
||||
@@ -107,8 +104,24 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS
|
||||
return;
|
||||
}
|
||||
|
||||
// Without the native handler, fail closed instead of exposing an
|
||||
// unattended deep-link credential to page JavaScript.
|
||||
const ctx = userAction.context ? (' ctx=' + JSON.stringify(userAction.context)) : '';
|
||||
const message =
|
||||
'CANVAS_A2UI action=' + userAction.name +
|
||||
' session=' + sessionKey +
|
||||
' surface=' + userAction.surfaceId +
|
||||
' component=' + (userAction.sourceComponentId || '-') +
|
||||
' host=' + machineName.replace(/\\s+/g, '_') +
|
||||
' instance=' + instanceId +
|
||||
ctx +
|
||||
' default=update_canvas';
|
||||
const params = new URLSearchParams();
|
||||
params.set('message', message);
|
||||
params.set('sessionKey', sessionKey);
|
||||
params.set('thinking', 'low');
|
||||
params.set('deliver', 'false');
|
||||
params.set('channel', 'last');
|
||||
params.set('key', deepLinkKey);
|
||||
location.href = 'openclaw://agent?' + params.toString();
|
||||
} catch {}
|
||||
}, true);
|
||||
} catch {}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
||||
{"generatedBy":"scripts/generate-config-doc-baseline.ts","recordType":"meta","totalPaths":4889}
|
||||
{"generatedBy":"scripts/generate-config-doc-baseline.ts","recordType":"meta","totalPaths":4733}
|
||||
{"recordType":"path","path":"acp","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"ACP","help":"ACP runtime controls for enabling dispatch, selecting backends, constraining allowed agent targets, and tuning streamed turn projection behavior.","hasChildren":true}
|
||||
{"recordType":"path","path":"acp.allowedAgents","kind":"core","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"ACP Allowed Agents","help":"Allowlist of ACP target agent ids permitted for ACP runtime sessions. Empty means no additional allowlist restriction.","hasChildren":true}
|
||||
{"recordType":"path","path":"acp.allowedAgents.*","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
@@ -101,7 +101,6 @@
|
||||
{"recordType":"path","path":"agents.defaults.compaction.recentTurnsPreserve","kind":"core","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Compaction Preserve Recent Turns","help":"Number of most recent user/assistant turns kept verbatim outside safeguard summarization (default: 3). Raise this to preserve exact recent dialogue context, or lower it to maximize compaction savings.","hasChildren":false}
|
||||
{"recordType":"path","path":"agents.defaults.compaction.reserveTokens","kind":"core","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":["auth","security"],"label":"Compaction Reserve Tokens","help":"Token headroom reserved for reply generation and tool output after compaction runs. Use higher reserves for verbose/tool-heavy sessions, and lower reserves when maximizing retained history matters more.","hasChildren":false}
|
||||
{"recordType":"path","path":"agents.defaults.compaction.reserveTokensFloor","kind":"core","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":["auth","security"],"label":"Compaction Reserve Token Floor","help":"Minimum floor enforced for reserveTokens in Pi compaction paths (0 disables the floor guard). Use a non-zero floor to avoid over-aggressive compression under fluctuating token estimates.","hasChildren":false}
|
||||
{"recordType":"path","path":"agents.defaults.compaction.timeoutSeconds","kind":"core","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":["performance"],"label":"Compaction Timeout (Seconds)","help":"Maximum time in seconds allowed for a single compaction operation before it is aborted (default: 900). Increase this for very large sessions that need more time to summarize, or decrease it to fail faster on unresponsive models.","hasChildren":false}
|
||||
{"recordType":"path","path":"agents.defaults.contextPruning","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"agents.defaults.contextPruning.hardClear","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"agents.defaults.contextPruning.hardClear.enabled","kind":"core","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
@@ -144,7 +143,7 @@
|
||||
{"recordType":"path","path":"agents.defaults.heartbeat.prompt","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"agents.defaults.heartbeat.session","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"agents.defaults.heartbeat.suppressToolErrorWarnings","kind":"core","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["automation"],"label":"Heartbeat Suppress Tool Error Warnings","help":"Suppress tool error warning payloads during heartbeat runs.","hasChildren":false}
|
||||
{"recordType":"path","path":"agents.defaults.heartbeat.target","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["automation"],"help":"Delivery target (\"last\", \"none\", or a channel id). Known channels: telegram, whatsapp, discord, irc, googlechat, slack, signal, imessage, line, bluebubbles, feishu, matrix, mattermost, msteams, nextcloud-talk, nostr, synology-chat, tlon, twitch, zalo, zalouser.","hasChildren":false}
|
||||
{"recordType":"path","path":"agents.defaults.heartbeat.target","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["automation"],"help":"Delivery target (\"last\", \"none\", or a channel id). Known channels: telegram, whatsapp, discord, irc, googlechat, slack, signal, imessage, line, zalouser, zalo, tlon, feishu, nextcloud-talk, msteams, bluebubbles, synology-chat, mattermost, twitch, matrix, nostr.","hasChildren":false}
|
||||
{"recordType":"path","path":"agents.defaults.heartbeat.to","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"agents.defaults.humanDelay","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"agents.defaults.humanDelay.maxMs","kind":"core","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":["performance"],"label":"Human Delay Max (ms)","help":"Maximum delay in ms for custom humanDelay (default: 2500).","hasChildren":false}
|
||||
@@ -348,7 +347,7 @@
|
||||
{"recordType":"path","path":"agents.list.*.heartbeat.prompt","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"agents.list.*.heartbeat.session","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"agents.list.*.heartbeat.suppressToolErrorWarnings","kind":"core","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["automation"],"label":"Agent Heartbeat Suppress Tool Error Warnings","help":"Suppress tool error warning payloads during heartbeat runs.","hasChildren":false}
|
||||
{"recordType":"path","path":"agents.list.*.heartbeat.target","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["automation"],"help":"Delivery target (\"last\", \"none\", or a channel id). Known channels: telegram, whatsapp, discord, irc, googlechat, slack, signal, imessage, line, bluebubbles, feishu, matrix, mattermost, msteams, nextcloud-talk, nostr, synology-chat, tlon, twitch, zalo, zalouser.","hasChildren":false}
|
||||
{"recordType":"path","path":"agents.list.*.heartbeat.target","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["automation"],"help":"Delivery target (\"last\", \"none\", or a channel id). Known channels: telegram, whatsapp, discord, irc, googlechat, slack, signal, imessage, line, zalouser, zalo, tlon, feishu, nextcloud-talk, msteams, bluebubbles, synology-chat, mattermost, twitch, matrix, nostr.","hasChildren":false}
|
||||
{"recordType":"path","path":"agents.list.*.heartbeat.to","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"agents.list.*.humanDelay","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"agents.list.*.humanDelay.maxMs","kind":"core","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
@@ -913,8 +912,6 @@
|
||||
{"recordType":"path","path":"channels.discord.accounts.*.guilds.*.toolsBySender.*.deny.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.discord.accounts.*.guilds.*.users","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.discord.accounts.*.guilds.*.users.*","kind":"channel","type":["number","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.discord.accounts.*.healthMonitor","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.discord.accounts.*.healthMonitor.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.discord.accounts.*.heartbeat","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.discord.accounts.*.heartbeat.showAlerts","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.discord.accounts.*.heartbeat.showOk","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
@@ -1168,8 +1165,6 @@
|
||||
{"recordType":"path","path":"channels.discord.guilds.*.toolsBySender.*.deny.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.discord.guilds.*.users","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.discord.guilds.*.users.*","kind":"channel","type":["number","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.discord.healthMonitor","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.discord.healthMonitor.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.discord.heartbeat","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.discord.heartbeat.showAlerts","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.discord.heartbeat.showOk","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
@@ -1285,182 +1280,61 @@
|
||||
{"recordType":"path","path":"channels.feishu","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"Feishu","help":"飞书/Lark enterprise messaging.","hasChildren":true}
|
||||
{"recordType":"path","path":"channels.feishu.accounts","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.actions","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.actions.reactions","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.allowFrom","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.allowFrom.*","kind":"channel","type":["number","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.appId","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.appSecret","kind":"channel","type":["object","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.appSecret.id","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.appSecret.provider","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.appSecret.source","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.blockStreamingCoalesce","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.blockStreamingCoalesce.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.blockStreamingCoalesce.maxDelayMs","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.blockStreamingCoalesce.minDelayMs","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.capabilities","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.capabilities.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.chunkMode","kind":"channel","type":"string","required":false,"enumValues":["length","newline"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.configWrites","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.appSecret.source","kind":"channel","type":"string","required":true,"enumValues":["env","file","exec"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.connectionMode","kind":"channel","type":"string","required":false,"enumValues":["websocket","webhook"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.dmHistoryLimit","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.dmPolicy","kind":"channel","type":"string","required":false,"enumValues":["open","pairing","allowlist"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.dms","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.dms.*","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.dms.*.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.dms.*.systemPrompt","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.domain","kind":"channel","type":"string","required":false,"enumValues":["feishu","lark"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.encryptKey","kind":"channel","type":["object","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.encryptKey.id","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.encryptKey.provider","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.encryptKey.source","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.groupAllowFrom","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.groupAllowFrom.*","kind":"channel","type":["number","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.groupPolicy","kind":"channel","type":"string","required":false,"enumValues":["open","allowlist","disabled"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.groups","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.groups.*","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.groups.*.allowFrom","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.groups.*.allowFrom.*","kind":"channel","type":["number","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.groups.*.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.groups.*.groupSessionScope","kind":"channel","type":"string","required":false,"enumValues":["group","group_sender","group_topic","group_topic_sender"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.groups.*.replyInThread","kind":"channel","type":"string","required":false,"enumValues":["disabled","enabled"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.groups.*.requireMention","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.groups.*.skills","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.groups.*.skills.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.groups.*.systemPrompt","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.groups.*.tools","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.groups.*.tools.allow","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.groups.*.tools.allow.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.groups.*.tools.deny","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.groups.*.tools.deny.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.groups.*.topicSessionMode","kind":"channel","type":"string","required":false,"enumValues":["disabled","enabled"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.groupSenderAllowFrom","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.groupSenderAllowFrom.*","kind":"channel","type":["number","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.groupSessionScope","kind":"channel","type":"string","required":false,"enumValues":["group","group_sender","group_topic","group_topic_sender"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.heartbeat","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.heartbeat.intervalMs","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.heartbeat.visibility","kind":"channel","type":"string","required":false,"enumValues":["visible","hidden"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.historyLimit","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.httpTimeoutMs","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.markdown","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.markdown.mode","kind":"channel","type":"string","required":false,"enumValues":["native","escape","strip"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.markdown.tableMode","kind":"channel","type":"string","required":false,"enumValues":["native","ascii","simple"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.mediaMaxMb","kind":"channel","type":"number","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.encryptKey.source","kind":"channel","type":"string","required":true,"enumValues":["env","file","exec"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.name","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.reactionNotifications","kind":"channel","type":"string","required":false,"enumValues":["off","own","all"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.renderMode","kind":"channel","type":"string","required":false,"enumValues":["auto","raw","card"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.replyInThread","kind":"channel","type":"string","required":false,"enumValues":["disabled","enabled"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.requireMention","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.resolveSenderNames","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.streaming","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.textChunkLimit","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.tools","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.tools.chat","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.tools.doc","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.tools.drive","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.tools.perm","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.tools.scopes","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.tools.wiki","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.topicSessionMode","kind":"channel","type":"string","required":false,"enumValues":["disabled","enabled"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.typingIndicator","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.verificationToken","kind":"channel","type":["object","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.verificationToken.id","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.verificationToken.provider","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.verificationToken.source","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.verificationToken.source","kind":"channel","type":"string","required":true,"enumValues":["env","file","exec"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.webhookHost","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.webhookPath","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.webhookPort","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.actions","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.feishu.actions.reactions","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.allowFrom","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.feishu.allowFrom.*","kind":"channel","type":["number","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.appId","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.appSecret","kind":"channel","type":["object","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.feishu.appSecret.id","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.appSecret.provider","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.appSecret.source","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.blockStreamingCoalesce","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.feishu.blockStreamingCoalesce.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.blockStreamingCoalesce.maxDelayMs","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.blockStreamingCoalesce.minDelayMs","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.capabilities","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.feishu.capabilities.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.appSecret.source","kind":"channel","type":"string","required":true,"enumValues":["env","file","exec"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.chunkMode","kind":"channel","type":"string","required":false,"enumValues":["length","newline"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.configWrites","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.connectionMode","kind":"channel","type":"string","required":true,"enumValues":["websocket","webhook"],"defaultValue":"websocket","deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.connectionMode","kind":"channel","type":"string","required":false,"enumValues":["websocket","webhook"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.defaultAccount","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.dmHistoryLimit","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.dmPolicy","kind":"channel","type":"string","required":true,"enumValues":["open","pairing","allowlist"],"defaultValue":"pairing","deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.dms","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.feishu.dms.*","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.feishu.dms.*.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.dms.*.systemPrompt","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.domain","kind":"channel","type":"string","required":true,"enumValues":["feishu","lark"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.dynamicAgentCreation","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.feishu.dynamicAgentCreation.agentDirTemplate","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.dynamicAgentCreation.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.dynamicAgentCreation.maxAgents","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.dynamicAgentCreation.workspaceTemplate","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.dmPolicy","kind":"channel","type":"string","required":false,"enumValues":["open","pairing","allowlist"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.domain","kind":"channel","type":"string","required":false,"enumValues":["feishu","lark"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.encryptKey","kind":"channel","type":["object","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.feishu.encryptKey.id","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.encryptKey.provider","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.encryptKey.source","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.encryptKey.source","kind":"channel","type":"string","required":true,"enumValues":["env","file","exec"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.groupAllowFrom","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.feishu.groupAllowFrom.*","kind":"channel","type":["number","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.groupPolicy","kind":"channel","type":"string","required":true,"enumValues":["open","allowlist","disabled"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.groups","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.feishu.groups.*","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.feishu.groups.*.allowFrom","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.feishu.groups.*.allowFrom.*","kind":"channel","type":["number","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.groups.*.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.groups.*.groupSessionScope","kind":"channel","type":"string","required":false,"enumValues":["group","group_sender","group_topic","group_topic_sender"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.groups.*.replyInThread","kind":"channel","type":"string","required":false,"enumValues":["disabled","enabled"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.groups.*.requireMention","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.groups.*.skills","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.feishu.groups.*.skills.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.groups.*.systemPrompt","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.groups.*.tools","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.feishu.groups.*.tools.allow","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.feishu.groups.*.tools.allow.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.groups.*.tools.deny","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.feishu.groups.*.tools.deny.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.groups.*.topicSessionMode","kind":"channel","type":"string","required":false,"enumValues":["disabled","enabled"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.groupSenderAllowFrom","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.feishu.groupSenderAllowFrom.*","kind":"channel","type":["number","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.groupPolicy","kind":"channel","type":"string","required":false,"enumValues":["open","allowlist","disabled"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.groupSessionScope","kind":"channel","type":"string","required":false,"enumValues":["group","group_sender","group_topic","group_topic_sender"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.heartbeat","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.feishu.heartbeat.intervalMs","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.heartbeat.visibility","kind":"channel","type":"string","required":false,"enumValues":["visible","hidden"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.historyLimit","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.httpTimeoutMs","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.markdown","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.feishu.markdown.mode","kind":"channel","type":"string","required":false,"enumValues":["native","escape","strip"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.markdown.tableMode","kind":"channel","type":"string","required":false,"enumValues":["native","ascii","simple"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.mediaMaxMb","kind":"channel","type":"number","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.reactionNotifications","kind":"channel","type":"string","required":true,"enumValues":["off","own","all"],"defaultValue":"own","deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.renderMode","kind":"channel","type":"string","required":false,"enumValues":["auto","raw","card"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.replyInThread","kind":"channel","type":"string","required":false,"enumValues":["disabled","enabled"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.requireMention","kind":"channel","type":"boolean","required":true,"defaultValue":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.resolveSenderNames","kind":"channel","type":"boolean","required":true,"defaultValue":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.streaming","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.requireMention","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.textChunkLimit","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.tools","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.feishu.tools.chat","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.tools.doc","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.tools.drive","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.tools.perm","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.tools.scopes","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.tools.wiki","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.topicSessionMode","kind":"channel","type":"string","required":false,"enumValues":["disabled","enabled"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.typingIndicator","kind":"channel","type":"boolean","required":true,"defaultValue":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.verificationToken","kind":"channel","type":["object","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.feishu.verificationToken.id","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.verificationToken.provider","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.verificationToken.source","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.verificationToken.source","kind":"channel","type":"string","required":true,"enumValues":["env","file","exec"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.webhookHost","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.webhookPath","kind":"channel","type":"string","required":true,"defaultValue":"/feishu/events","deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.webhookPath","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.webhookPort","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.googlechat","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"Google Chat","help":"Google Workspace Chat app with HTTP webhook.","hasChildren":true}
|
||||
{"recordType":"path","path":"channels.googlechat.accounts","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
@@ -1468,7 +1342,6 @@
|
||||
{"recordType":"path","path":"channels.googlechat.accounts.*.actions","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.googlechat.accounts.*.actions.reactions","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.googlechat.accounts.*.allowBots","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.googlechat.accounts.*.appPrincipal","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.googlechat.accounts.*.audience","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.googlechat.accounts.*.audienceType","kind":"channel","type":"string","required":false,"enumValues":["app-url","project-number"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.googlechat.accounts.*.blockStreaming","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
@@ -1504,8 +1377,6 @@
|
||||
{"recordType":"path","path":"channels.googlechat.accounts.*.groups.*.systemPrompt","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.googlechat.accounts.*.groups.*.users","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.googlechat.accounts.*.groups.*.users.*","kind":"channel","type":["number","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.googlechat.accounts.*.healthMonitor","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.googlechat.accounts.*.healthMonitor.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.googlechat.accounts.*.historyLimit","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.googlechat.accounts.*.mediaMaxMb","kind":"channel","type":"number","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.googlechat.accounts.*.name","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
@@ -1530,7 +1401,6 @@
|
||||
{"recordType":"path","path":"channels.googlechat.actions","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.googlechat.actions.reactions","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.googlechat.allowBots","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.googlechat.appPrincipal","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.googlechat.audience","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.googlechat.audienceType","kind":"channel","type":"string","required":false,"enumValues":["app-url","project-number"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.googlechat.blockStreaming","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
@@ -1567,8 +1437,6 @@
|
||||
{"recordType":"path","path":"channels.googlechat.groups.*.systemPrompt","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.googlechat.groups.*.users","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.googlechat.groups.*.users.*","kind":"channel","type":["number","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.googlechat.healthMonitor","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.googlechat.healthMonitor.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.googlechat.historyLimit","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.googlechat.mediaMaxMb","kind":"channel","type":"number","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.googlechat.name","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
@@ -1636,8 +1504,6 @@
|
||||
{"recordType":"path","path":"channels.imessage.accounts.*.groups.*.toolsBySender.*.alsoAllow.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.imessage.accounts.*.groups.*.toolsBySender.*.deny","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.imessage.accounts.*.groups.*.toolsBySender.*.deny.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.imessage.accounts.*.healthMonitor","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.imessage.accounts.*.healthMonitor.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.imessage.accounts.*.heartbeat","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.imessage.accounts.*.heartbeat.showAlerts","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.imessage.accounts.*.heartbeat.showOk","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
@@ -1699,8 +1565,6 @@
|
||||
{"recordType":"path","path":"channels.imessage.groups.*.toolsBySender.*.alsoAllow.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.imessage.groups.*.toolsBySender.*.deny","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.imessage.groups.*.toolsBySender.*.deny.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.imessage.healthMonitor","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.imessage.healthMonitor.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.imessage.heartbeat","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.imessage.heartbeat.showAlerts","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.imessage.heartbeat.showOk","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
@@ -2105,8 +1969,6 @@
|
||||
{"recordType":"path","path":"channels.msteams.groupAllowFrom","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.msteams.groupAllowFrom.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.msteams.groupPolicy","kind":"channel","type":"string","required":true,"enumValues":["open","disabled","allowlist"],"defaultValue":"allowlist","deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.msteams.healthMonitor","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.msteams.healthMonitor.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.msteams.heartbeat","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.msteams.heartbeat.showAlerts","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.msteams.heartbeat.showOk","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
@@ -2352,8 +2214,6 @@
|
||||
{"recordType":"path","path":"channels.signal.accounts.*.groups.*.toolsBySender.*.alsoAllow.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.signal.accounts.*.groups.*.toolsBySender.*.deny","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.signal.accounts.*.groups.*.toolsBySender.*.deny.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.signal.accounts.*.healthMonitor","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.signal.accounts.*.healthMonitor.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.signal.accounts.*.heartbeat","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.signal.accounts.*.heartbeat.showAlerts","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.signal.accounts.*.heartbeat.showOk","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
@@ -2422,8 +2282,6 @@
|
||||
{"recordType":"path","path":"channels.signal.groups.*.toolsBySender.*.alsoAllow.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.signal.groups.*.toolsBySender.*.deny","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.signal.groups.*.toolsBySender.*.deny.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.signal.healthMonitor","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.signal.healthMonitor.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.signal.heartbeat","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.signal.heartbeat.showAlerts","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.signal.heartbeat.showOk","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
@@ -2528,8 +2386,6 @@
|
||||
{"recordType":"path","path":"channels.slack.accounts.*.dms.*.historyLimit","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.slack.accounts.*.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.slack.accounts.*.groupPolicy","kind":"channel","type":"string","required":false,"enumValues":["open","disabled","allowlist"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.slack.accounts.*.healthMonitor","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.slack.accounts.*.healthMonitor.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.slack.accounts.*.heartbeat","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.slack.accounts.*.heartbeat.showAlerts","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.slack.accounts.*.heartbeat.showOk","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
@@ -2653,8 +2509,6 @@
|
||||
{"recordType":"path","path":"channels.slack.dms.*.historyLimit","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.slack.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.slack.groupPolicy","kind":"channel","type":"string","required":true,"enumValues":["open","disabled","allowlist"],"defaultValue":"allowlist","deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.slack.healthMonitor","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.slack.healthMonitor.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.slack.heartbeat","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.slack.heartbeat.showAlerts","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.slack.heartbeat.showOk","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
@@ -2834,8 +2688,6 @@
|
||||
{"recordType":"path","path":"channels.telegram.accounts.*.groups.*.topics.*.skills","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.telegram.accounts.*.groups.*.topics.*.skills.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.telegram.accounts.*.groups.*.topics.*.systemPrompt","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.telegram.accounts.*.healthMonitor","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.telegram.accounts.*.healthMonitor.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.telegram.accounts.*.heartbeat","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.telegram.accounts.*.heartbeat.showAlerts","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.telegram.accounts.*.heartbeat.showOk","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
@@ -3010,8 +2862,6 @@
|
||||
{"recordType":"path","path":"channels.telegram.groups.*.topics.*.skills","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.telegram.groups.*.topics.*.skills.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.telegram.groups.*.topics.*.systemPrompt","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.telegram.healthMonitor","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.telegram.healthMonitor.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.telegram.heartbeat","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.telegram.heartbeat.showAlerts","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.telegram.heartbeat.showOk","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
@@ -3182,8 +3032,6 @@
|
||||
{"recordType":"path","path":"channels.whatsapp.accounts.*.groups.*.toolsBySender.*.alsoAllow.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.whatsapp.accounts.*.groups.*.toolsBySender.*.deny","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.whatsapp.accounts.*.groups.*.toolsBySender.*.deny.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.whatsapp.accounts.*.healthMonitor","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.whatsapp.accounts.*.healthMonitor.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.whatsapp.accounts.*.heartbeat","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.whatsapp.accounts.*.heartbeat.showAlerts","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.whatsapp.accounts.*.heartbeat.showOk","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
@@ -3247,8 +3095,6 @@
|
||||
{"recordType":"path","path":"channels.whatsapp.groups.*.toolsBySender.*.alsoAllow.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.whatsapp.groups.*.toolsBySender.*.deny","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.whatsapp.groups.*.toolsBySender.*.deny.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.whatsapp.healthMonitor","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.whatsapp.healthMonitor.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.whatsapp.heartbeat","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.whatsapp.heartbeat.showAlerts","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.whatsapp.heartbeat.showOk","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
@@ -3484,8 +3330,6 @@
|
||||
{"recordType":"path","path":"gateway.auth.trustedProxy.userHeader","kind":"core","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"gateway.bind","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["network"],"label":"Gateway Bind Mode","help":"Network bind profile: \"auto\", \"lan\", \"loopback\", \"custom\", or \"tailnet\" to control interface exposure. Keep \"loopback\" or \"auto\" for safest local operation unless external clients must connect.","hasChildren":false}
|
||||
{"recordType":"path","path":"gateway.channelHealthCheckMinutes","kind":"core","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":["network","reliability"],"label":"Gateway Channel Health Check Interval (min)","help":"Interval in minutes for automatic channel health probing and status updates. Use lower intervals for faster detection, or higher intervals to reduce periodic probe noise.","hasChildren":false}
|
||||
{"recordType":"path","path":"gateway.channelMaxRestartsPerHour","kind":"core","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":["network","performance"],"label":"Gateway Channel Max Restarts Per Hour","help":"Maximum number of health-monitor-initiated channel restarts allowed within a rolling one-hour window. Once hit, further restarts are skipped until the window expires. Default: 10.","hasChildren":false}
|
||||
{"recordType":"path","path":"gateway.channelStaleEventThresholdMinutes","kind":"core","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":["network"],"label":"Gateway Channel Stale Event Threshold (min)","help":"How many minutes a connected channel can go without receiving any event before the health monitor treats it as a stale socket and triggers a restart. Default: 30.","hasChildren":false}
|
||||
{"recordType":"path","path":"gateway.controlUi","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["network"],"label":"Control UI","help":"Control UI hosting settings including enablement, pathing, and browser-origin/auth hardening behavior. Keep UI exposure minimal and pair with strong auth controls before internet-facing deployments.","hasChildren":true}
|
||||
{"recordType":"path","path":"gateway.controlUi.allowedOrigins","kind":"core","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access","network"],"label":"Control UI Allowed Origins","help":"Allowed browser origins for Control UI/WebChat websocket connections (full origins only, e.g. https://control.example.com). Required for non-loopback Control UI deployments unless dangerous Host-header fallback is explicitly enabled.","hasChildren":true}
|
||||
{"recordType":"path","path":"gateway.controlUi.allowedOrigins.*","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
@@ -3740,7 +3584,7 @@
|
||||
{"recordType":"path","path":"messages.ackReactionScope","kind":"core","type":"string","required":false,"enumValues":["group-mentions","group-all","direct","all","off","none"],"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Ack Reaction Scope","help":"When to send ack reactions (\"group-mentions\", \"group-all\", \"direct\", \"all\", \"off\", \"none\"). \"off\"/\"none\" disables ack reactions entirely.","hasChildren":false}
|
||||
{"recordType":"path","path":"messages.groupChat","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Group Chat Rules","help":"Group-message handling controls including mention triggers and history window sizing. Keep mention patterns narrow so group channels do not trigger on every message.","hasChildren":true}
|
||||
{"recordType":"path","path":"messages.groupChat.historyLimit","kind":"core","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":["performance"],"label":"Group History Limit","help":"Maximum number of prior group messages loaded as context per turn for group sessions. Use higher values for richer continuity, or lower values for faster and cheaper responses.","hasChildren":false}
|
||||
{"recordType":"path","path":"messages.groupChat.mentionPatterns","kind":"core","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Group Mention Patterns","help":"Safe case-insensitive regex patterns used to detect explicit mentions/trigger phrases in group chats. Use precise patterns to reduce false positives in high-volume channels; invalid or unsafe nested-repetition patterns are ignored.","hasChildren":true}
|
||||
{"recordType":"path","path":"messages.groupChat.mentionPatterns","kind":"core","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Group Mention Patterns","help":"Regex-like patterns used to detect explicit mentions/trigger phrases in group chats. Use precise patterns to reduce false positives in high-volume channels.","hasChildren":true}
|
||||
{"recordType":"path","path":"messages.groupChat.mentionPatterns.*","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"messages.inbound","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Inbound Debounce","help":"Direct inbound debounce settings used before queue/turn processing starts. Configure this for provider-specific rapid message bursts from the same sender.","hasChildren":true}
|
||||
{"recordType":"path","path":"messages.inbound.byChannel","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Inbound Debounce by Channel (ms)","help":"Per-channel inbound debounce overrides keyed by provider id in milliseconds. Use this where some providers send message fragments more aggressively than others.","hasChildren":true}
|
||||
|
||||
@@ -123,22 +123,6 @@
|
||||
"source": "Network model",
|
||||
"target": "网络模型"
|
||||
},
|
||||
{
|
||||
"source": "Doctor",
|
||||
"target": "Doctor"
|
||||
},
|
||||
{
|
||||
"source": "Polls",
|
||||
"target": "投票"
|
||||
},
|
||||
{
|
||||
"source": "Release Policy",
|
||||
"target": "发布策略"
|
||||
},
|
||||
{
|
||||
"source": "Release policy",
|
||||
"target": "发布策略"
|
||||
},
|
||||
{
|
||||
"source": "for full details",
|
||||
"target": "了解详情"
|
||||
|
||||
@@ -1,642 +0,0 @@
|
||||
Temporary internal migration note: remove this document once the extension-host migration is complete.
|
||||
|
||||
# OpenClaw Capability Catalog And Arbitration Spec
|
||||
|
||||
Date: 2026-03-15
|
||||
|
||||
## Purpose
|
||||
|
||||
This document defines how the system compiles agent-visible, operator-visible, and runtime-internal catalogs from active contributions and how it resolves conflicting or parallel providers.
|
||||
|
||||
The kernel should expose canonical actions, not raw plugin identities.
|
||||
|
||||
Host-managed install, onboarding, and lightweight channel catalogs remain separate from the kernel capability catalog.
|
||||
|
||||
## TODOs
|
||||
|
||||
- [ ] Implement kernel-owned internal and agent-visible catalogs.
|
||||
- [ ] Implement host-owned operator catalogs and static setup catalogs.
|
||||
- [ ] Implement canonical action registration and review workflow in code.
|
||||
- [ ] Implement arbitration and conflict handling for at least one multi-provider family.
|
||||
- [ ] Migrate the existing tool, provider, setup, and slot-selection surfaces so they no longer act as parallel catalog or arbitration systems.
|
||||
- [ ] Record pilot parity for `thread-ownership` first and `telegram` second before broader catalog publication.
|
||||
- [ ] Track which current `main` actions have been mapped into canonical action ids.
|
||||
|
||||
## Implementation Status
|
||||
|
||||
Current status against this spec:
|
||||
|
||||
- canonical catalogs and arbitration have not started
|
||||
- host-managed static metadata work and early runtime/lifecycle boundary extraction have landed
|
||||
|
||||
What has been implemented:
|
||||
|
||||
- an initial Phase 0 cutover inventory now exists in `src/extension-host/cutover-inventory.md`
|
||||
- channel catalog package metadata parsing now routes through host-owned schema helpers
|
||||
- host-owned resolved-extension records now carry the static metadata needed for install, onboarding, and lightweight operator UX
|
||||
- config doc baseline generation now uses the same host-owned resolved-extension metadata path
|
||||
- plugin SDK alias resolution now routes through `src/extension-host/compat/loader-compat.ts`
|
||||
- loader alias-wired module loader creation now routes through `src/extension-host/activation/loader-module-loader.ts`
|
||||
- loader cache key construction and registry cache control now route through `src/extension-host/activation/loader-cache.ts`
|
||||
- loader lazy runtime proxy creation now routes through `src/extension-host/activation/loader-runtime-proxy.ts`
|
||||
- loader provenance helpers now route through `src/extension-host/policy/loader-provenance.ts`
|
||||
- loader duplicate-order and record/error policy now route through `src/extension-host/policy/loader-policy.ts`
|
||||
- loader discovery policy outcomes now route through `src/extension-host/policy/loader-discovery-policy.ts`
|
||||
- loader initial candidate planning and record creation now route through `src/extension-host/activation/loader-records.ts`
|
||||
- loader entry-path opening and module import now route through `src/extension-host/activation/loader-import.ts`
|
||||
- loader module-export resolution, config validation, and memory-slot load decisions now route through `src/extension-host/activation/loader-runtime.ts`
|
||||
- loader post-import planning and `register(...)` execution now route through `src/extension-host/activation/loader-register.ts`
|
||||
- loader per-candidate orchestration now routes through `src/extension-host/activation/loader-flow.ts`
|
||||
- loader top-level load orchestration now routes through `src/extension-host/activation/loader-orchestrator.ts`
|
||||
- loader host process state now routes through `src/extension-host/activation/loader-host-state.ts`
|
||||
- loader preflight and cache-hit setup now routes through `src/extension-host/activation/loader-preflight.ts`
|
||||
- loader post-preflight pipeline composition now routes through `src/extension-host/activation/loader-pipeline.ts`
|
||||
- loader execution setup composition now routes through `src/extension-host/activation/loader-execution.ts`
|
||||
- loader discovery and manifest bootstrap now routes through `src/extension-host/activation/loader-bootstrap.ts`
|
||||
- loader mutable activation state now routes through `src/extension-host/activation/loader-session.ts`
|
||||
- loader session run and finalization composition now routes through `src/extension-host/activation/loader-run.ts`
|
||||
- loader activation policy outcomes now route through `src/extension-host/policy/loader-activation-policy.ts`
|
||||
- loader record-state transitions now route through `src/extension-host/activation/loader-state.ts`, which now enforces an explicit loader lifecycle state machine while preserving compatibility `PluginRecord.status` values
|
||||
- loader finalization policy results now route through `src/extension-host/policy/loader-finalization-policy.ts`
|
||||
- loader final cache, readiness promotion, and activation finalization now routes through `src/extension-host/activation/loader-finalize.ts`
|
||||
- channel, provider, gateway-method, tool, CLI, service, command, context-engine, and hook registration normalization now has a host-owned helper boundary for future catalog migration
|
||||
- low-risk runtime compatibility writes for channel, provider, gateway-method, HTTP-route, tool, CLI, service, command, context-engine, and hook registrations now route through `src/extension-host/contributions/registry-writes.ts` ahead of broader catalog-backed registry ownership
|
||||
- context-engine registration and runtime resolution now route through `src/extension-host/contributions/context-engine-runtime.ts` ahead of broader catalog-backed ownership
|
||||
- exclusive-slot selection and default-slot resolution now route through `src/extension-host/policy/slot-arbitration.ts` ahead of broader catalog-backed arbitration ownership
|
||||
- ACP backend registration and runtime resolution now route through `src/extension-host/contributions/acp-runtime-backend-registry.ts` ahead of broader catalog-backed backend ownership
|
||||
- embedding-provider auto-selection, provider creation, local-setup guidance, and primary plus fallback routing now route through `src/extension-host/contributions/embedding-runtime-registry.ts`, shared fallback-model selection now routes through `src/extension-host/policy/embedding-runtime-policy.ts`, the public embedding runtime surface plus result typing now route through `src/extension-host/contributions/embedding-runtime.ts` and `src/extension-host/contributions/embedding-runtime-types.ts`, manager-side batch policy plus fallback activation now route through `src/extension-host/contributions/embedding-manager-runtime.ts`, sync plus reindex planning now route through `src/extension-host/contributions/embedding-sync-planning.ts`, sync plus reindex orchestration now route through `src/extension-host/contributions/embedding-sync-execution.ts`, reindex sync-body execution plus unsafe reset now route through `src/extension-host/contributions/embedding-reindex-execution.ts`, and safe-reindex temp-db creation, file swap, reopen, and cleanup now route through `src/extension-host/contributions/embedding-safe-reindex.ts` while `src/memory/embeddings.ts` remains the compatibility facade
|
||||
- built-in media backend definitions, provider normalization, auto-selection seed order, selector-key shaping, and default-model metadata now route through `src/extension-host/static/media-runtime-backends.ts`; override merging and runtime lookup now route through `src/extension-host/contributions/media-runtime-registry.ts`; shared default and preferred runtime-backend ordering plus fallback chaining now route through `src/extension-host/policy/runtime-backend-policy.ts`; provider candidate ordering, active-model precedence, and default-model fallback selection now route through `src/extension-host/policy/media-runtime-policy.ts`; provider and CLI entry execution, output parsing, provider query normalization, provider auth/context shaping, and proxy-aware fetch handling now route through `src/extension-host/contributions/media-runtime-execution.ts`; local-binary probing, auto-entry selection, and top-level capability orchestration now route through `src/extension-host/contributions/media-runtime-auto.ts` and `src/extension-host/contributions/media-runtime-orchestration.ts`; and media prompt, timeout, scope, model-entry, concurrency, and decision helpers now route through `src/extension-host/contributions/media-runtime-config.ts` and `src/extension-host/contributions/media-runtime-decision.ts` while `src/media-understanding/providers/index.ts`, `src/media-understanding/runner.ts`, `src/media-understanding/runner.entries.ts`, and `src/media-understanding/resolve.ts` remain compatibility facades
|
||||
- TTS provider metadata, provider ordering, API-key resolution, configuration checks, and telephony support now route through `src/extension-host/contributions/tts-runtime-registry.ts`, provider execution loops, output-format selection, telephony synthesis, and provider-error shaping now route through `src/extension-host/contributions/tts-runtime-execution.ts`, provider selection plus request setup now route through `src/extension-host/contributions/tts-runtime-setup.ts`, TTS config normalization, defaults, and model-override policy now route through `src/extension-host/contributions/tts-config.ts`, prefs-path resolution, auto-mode policy, and persisted TTS preference reads and writes now route through `src/extension-host/contributions/tts-preferences.ts`, auto-TTS gating, directive cleanup, truncation, summarization, and payload planning now route through `src/extension-host/contributions/tts-payload.ts`, and last-attempt state plus shared status snapshots now route through `src/extension-host/contributions/tts-status.ts` while `src/tts/tts.ts` remains the compatibility facade
|
||||
- legacy internal-hook bridging and typed prompt-injection compatibility policy now route through `src/extension-host/compat/hook-compat.ts` ahead of broader catalog-backed registry ownership
|
||||
- compatibility `OpenClawPluginApi` composition and logger shaping now route through `src/extension-host/compat/plugin-api.ts` ahead of broader catalog-backed registry ownership
|
||||
- compatibility plugin-registry facade ownership now routes through `src/extension-host/compat/plugin-registry.ts` ahead of broader catalog-backed registry ownership
|
||||
- compatibility plugin-registry policy now routes through `src/extension-host/compat/plugin-registry-compat.ts` ahead of broader catalog-backed registry ownership
|
||||
- compatibility plugin-registry registration actions now route through `src/extension-host/compat/plugin-registry-registrations.ts` ahead of broader catalog-backed registry ownership
|
||||
- host-owned runtime registry accessors now route through `src/extension-host/contributions/runtime-registry.ts` ahead of broader catalog-backed registry ownership, and the channel, provider, tool, command, HTTP-route, gateway-method, CLI, and service slices now keep host-owned storage there with mirrored legacy compatibility views
|
||||
- plugin command registration, matching, execution, listing, native command-spec projection, and loader reload clearing now route through `src/extension-host/contributions/command-runtime.ts` ahead of broader catalog-backed ownership
|
||||
- service startup, stop ordering, service-context creation, and failure logging now route through `src/extension-host/contributions/service-lifecycle.ts` ahead of broader catalog-backed lifecycle ownership
|
||||
- CLI duplicate detection, registrar invocation, and async failure logging now route through `src/extension-host/contributions/cli-lifecycle.ts` ahead of broader catalog-backed CLI ownership
|
||||
- gateway method-id aggregation, plugin diagnostic shaping, and extra-handler composition now route through `src/extension-host/contributions/gateway-methods.ts` ahead of broader catalog-backed gateway ownership
|
||||
- plugin tool resolution, conflict handling, optional-tool gating, and plugin-tool metadata tracking now route through `src/extension-host/contributions/tool-runtime.ts` ahead of broader catalog-backed tool ownership
|
||||
- plugin provider projection from registry entries into runtime provider objects now routes through `src/extension-host/contributions/provider-runtime.ts` ahead of broader catalog-backed provider ownership
|
||||
- plugin provider discovery filtering, order grouping, and result normalization now route through `src/extension-host/contributions/provider-discovery.ts` ahead of broader catalog-backed provider-discovery ownership
|
||||
- provider matching, auth-method selection, config-patch merging, and default-model application now route through `src/extension-host/contributions/provider-auth.ts` ahead of broader catalog-backed provider-auth ownership
|
||||
- provider onboarding option building, model-picker entry building, and provider-method choice resolution now route through `src/extension-host/contributions/provider-wizard.ts` ahead of broader catalog-backed provider-setup ownership
|
||||
- loaded-provider auth application, plugin-enable gating, auth-method execution, and post-auth default-model handling now route through `src/extension-host/contributions/provider-auth-flow.ts` ahead of broader catalog-backed provider-setup ownership
|
||||
- provider post-selection hook lookup and invocation now route through `src/extension-host/contributions/provider-model-selection.ts` ahead of broader catalog-backed provider-setup ownership
|
||||
|
||||
How it has been implemented:
|
||||
|
||||
- by moving package metadata parsing behind `src/extension-host/manifests/schema.ts`
|
||||
- by keeping the existing catalog behavior intact while shifting metadata ownership into normalized host-owned records
|
||||
- by reusing the resolved-extension registry for static operator/documentation surfaces instead of creating separate metadata caches
|
||||
- by beginning runtime registration migration with host-owned normalization helpers before attempting full canonical catalog publication
|
||||
- by beginning actual low-risk runtime write ownership for channel, provider, gateway-method, HTTP-route, tool, CLI, service, command, context-engine, and hook registrations before attempting full canonical catalog publication
|
||||
- by moving cache-key construction and registry cache control behind host-owned helpers before attempting canonical catalog publication
|
||||
- by beginning loader-path migration with host-owned compatibility, candidate-planning, import-flow, policy, runtime, register-flow, candidate-orchestration, top-level load orchestration, record-state with compatibility lifecycle mapping, and finalization helpers before attempting canonical catalog publication
|
||||
- by extracting lazy runtime proxy creation and alias-wired Jiti module-loader creation into host-owned helpers before catalog publication work
|
||||
- by extracting discovery, manifest loading, manifest diagnostics, discovery-policy logging, provenance building, and candidate ordering into a host-owned loader-bootstrap helper before catalog publication work
|
||||
- by extracting candidate iteration, manifest lookup, per-candidate session processing, and finalization handoff into a host-owned loader-run helper before catalog publication work
|
||||
- by converting the compatibility record-state layer into an enforced loader lifecycle state machine before catalog publication work
|
||||
- by extracting shared discovery warning-cache state and loader reset behavior into a host-owned loader-host-state helper before catalog publication work
|
||||
- by extracting test-default application, config normalization, cache-key construction, cache-hit activation, and command-clear setup into a host-owned loader-preflight helper before catalog publication work
|
||||
- by extracting post-preflight execution setup and session-run composition into a host-owned loader-pipeline helper before catalog publication work
|
||||
- by extracting runtime creation, registry creation, bootstrap setup, module-loader creation, and session creation into a host-owned loader-execution helper before catalog publication work
|
||||
- by moving mutable activation state into a host-owned loader session before catalog publication work
|
||||
- by extracting shared provenance path matching and install-rule evaluation into `src/extension-host/policy/loader-provenance.ts` so activation and finalization policy seams reuse one host-owned implementation
|
||||
- by turning open-allowlist discovery warnings into explicit host-owned discovery-policy results before catalog publication work
|
||||
- by moving duplicate precedence, config enablement, and early memory-slot gating into explicit host-owned activation-policy outcomes before catalog publication work
|
||||
- by turning provenance-based untracked-extension warnings and final memory-slot warnings into explicit host-owned finalization-policy results before catalog publication work
|
||||
- by extracting legacy internal-hook bridging and typed prompt-injection compatibility policy into a host-owned hook-compat helper while leaving actual hook execution ownership unchanged
|
||||
- by extracting compatibility `OpenClawPluginApi` composition and logger shaping into a host-owned plugin-api helper while keeping the concrete registration callbacks in the legacy registry surface
|
||||
- by extracting the remaining compatibility plugin-registry facade into a host-owned helper so `src/plugins/registry.ts` becomes a thin wrapper instead of the real owner
|
||||
- by extracting provider normalization, command duplicate enforcement, and registry-local diagnostic shaping into a host-owned registry-compat helper while leaving the underlying provider-validation and plugin-command subsystems unchanged
|
||||
- by extracting low-risk registry registration actions into a host-owned registry-registrations helper so the compatibility facade composes host-owned actions instead of implementing them inline
|
||||
- by extracting service startup, stop ordering, service-context creation, and failure logging into a host-owned service-lifecycle helper before broader catalog-backed service ownership
|
||||
- by extracting CLI duplicate detection, registrar invocation, and async failure logging into a host-owned CLI-lifecycle helper before broader catalog-backed CLI ownership
|
||||
- by extracting gateway method-id aggregation, plugin diagnostic shaping, and extra-handler composition into a host-owned gateway-methods helper before broader catalog-backed gateway ownership
|
||||
- by extracting plugin tool resolution, conflict handling, optional-tool gating, and plugin-tool metadata tracking into a host-owned tool-runtime helper before broader catalog-backed tool ownership
|
||||
- by extracting provider projection from registry entries into runtime provider objects into a host-owned provider-runtime helper before broader catalog-backed provider ownership
|
||||
- by extracting provider discovery filtering, order grouping, and result normalization into a host-owned provider-discovery helper before broader catalog-backed provider-discovery ownership
|
||||
- by extracting provider matching, auth-method selection, config-patch merging, and default-model application into a host-owned provider-auth helper before broader catalog-backed provider-auth ownership
|
||||
- by extracting provider onboarding option building, model-picker entry building, and provider-method choice resolution into a host-owned provider-wizard helper before broader catalog-backed provider-setup ownership
|
||||
- by extracting loaded-provider auth application, plugin-enable gating, auth-method execution, and post-auth default-model handling into a host-owned provider-auth-flow helper before broader catalog-backed provider-setup ownership
|
||||
- by extracting provider post-selection hook lookup and invocation into a host-owned provider-model-selection helper before broader catalog-backed provider-setup ownership
|
||||
- by extracting provider-id normalization into `src/agents/provider-id.ts` so provider-only host seams do not inherit the heavier agent and browser dependency graph from `src/agents/model-selection.ts`
|
||||
- by extracting model-ref parsing into `src/agents/model-ref.ts` and Google model-id normalization into `src/agents/google-model-id.ts` so provider auth and setup seams can be tested without pulling the heavier provider-loader and browser dependency graph
|
||||
- by introducing host-owned runtime-registry accessors for low-risk runtime consumers first, then moving channel, provider, tool, command, HTTP-route, gateway-method, CLI, and service storage into that host-owned state while keeping mirrored legacy compatibility arrays and handler maps before broader catalog publication or arbitration work
|
||||
- by moving plugin command duplicate enforcement, registration, matching, execution, listing, native command-spec projection, and loader reload clearing into `src/extension-host/contributions/command-runtime.ts` before broader catalog publication or arbitration work
|
||||
|
||||
What remains pending:
|
||||
|
||||
- canonical capability ids
|
||||
- runtime-derived kernel catalogs
|
||||
- host-owned operator catalogs beyond the existing lightweight static paths
|
||||
- arbitration modes and selection logic
|
||||
- tool/provider/slot migration into one canonical catalog and arbitration model
|
||||
|
||||
## Goals
|
||||
|
||||
- agents see a stable, context-aware catalog of what they can do
|
||||
- multiple active providers for the same functional area are supported
|
||||
- collisions are detected and resolved deterministically
|
||||
- operator commands and runtime backends stay separate from agent tools
|
||||
- the catalog covers the broader current action surface, not only send and reply
|
||||
- slot-backed providers such as context engines are selected explicitly
|
||||
- setup and install metadata stay in host-managed catalogs instead of leaking into runtime catalogs
|
||||
|
||||
## Migration Framing
|
||||
|
||||
This spec replaces existing partial catalog and arbitration behavior already present on `main`.
|
||||
|
||||
It is not a standalone greenfield system.
|
||||
|
||||
Current behavior already exists in at least these places:
|
||||
|
||||
- agent-visible plugin tool grouping in `src/gateway/server-methods/tools-catalog.ts:71`
|
||||
- provider auth and setup selection in `src/commands/auth-choice.apply.plugin-provider.ts:106`
|
||||
- slot selection in `src/plugins/slots.ts:39`
|
||||
- channel picker and onboarding metadata in `src/channels/plugins/catalog.ts:26`
|
||||
|
||||
Implementation rule:
|
||||
|
||||
- Phase 5 and Phase 6 are only complete when those legacy paths have been absorbed into the canonical or host-owned catalog model rather than left as a second source of truth
|
||||
|
||||
## Catalog Types
|
||||
|
||||
The system should maintain separate catalogs for:
|
||||
|
||||
- agent-visible capabilities
|
||||
- operator-visible capabilities
|
||||
- runtime-internal providers
|
||||
|
||||
These catalogs may draw from the same contributions but have different visibility and arbitration rules.
|
||||
|
||||
Ownership split:
|
||||
|
||||
- the kernel publishes runtime-derived internal and agent-visible catalogs
|
||||
- the extension host publishes operator-visible catalogs, including host-only surfaces and any runtime-derived entries the operator surface needs
|
||||
|
||||
## Host-Managed Setup And Install Catalogs
|
||||
|
||||
Current `main` also has host-managed metadata that is not a kernel capability catalog:
|
||||
|
||||
- install metadata from `src/plugins/install.ts:48`
|
||||
- channel picker and onboarding metadata from `src/channels/plugins/catalog.ts:26`
|
||||
- lightweight shared channel behavior from `src/channels/dock.ts:228`
|
||||
|
||||
The extension host should keep publishing these static catalogs for setup and operator UX.
|
||||
|
||||
They should not be folded into the agent capability catalog.
|
||||
|
||||
This host-managed layer should also publish:
|
||||
|
||||
- local operator CLI commands from `surface.cli`
|
||||
- setup and onboarding flows from `surface.setup`
|
||||
- static channel picker metadata and lightweight dock-derived operator hints without activating heavy runtimes
|
||||
|
||||
Sequencing rule:
|
||||
|
||||
- these host-managed static catalogs should migrate before broad runtime catalog publication because they depend on static metadata, not heavy activation
|
||||
|
||||
## Canonical Capability Model
|
||||
|
||||
Each catalog entry should contain:
|
||||
|
||||
- `capabilityId`
|
||||
- `kind`
|
||||
- `canonicalAction`
|
||||
- `displayName`
|
||||
- `description`
|
||||
- `providerKey`
|
||||
- `scope`
|
||||
- `availability`
|
||||
- `requiresSelection`
|
||||
- `inputSchema`
|
||||
- `outputSchema`
|
||||
- `policy`
|
||||
- `telemetryTags`
|
||||
|
||||
### `capabilityId`
|
||||
|
||||
Stable runtime id for the contribution-backed capability.
|
||||
|
||||
### `canonicalAction`
|
||||
|
||||
A stable action family such as:
|
||||
|
||||
- `message.send`
|
||||
- `message.reply`
|
||||
- `directory.lookup`
|
||||
- `provider.authenticate`
|
||||
- `provider.configure`
|
||||
- `memory.search`
|
||||
- `memory.store`
|
||||
- `message.broadcast`
|
||||
- `message.poll`
|
||||
- `message.react`
|
||||
- `message.edit`
|
||||
- `message.delete`
|
||||
- `message.pin`
|
||||
- `message.thread.manage`
|
||||
- `voice.call.start`
|
||||
- `diff.render`
|
||||
|
||||
The agent planner reasons over canonical actions first.
|
||||
|
||||
Governance decision:
|
||||
|
||||
- canonical action ids are open, namespaced strings
|
||||
- core action families should still live in one source-of-truth registry in code
|
||||
- if a new capability fits an existing family, reuse it
|
||||
- if semantics are new, add a reviewed canonical action id to that registry
|
||||
- contributions may not define new arbitration modes or planner semantics outside the core catalog and arbitration schema
|
||||
|
||||
### `providerKey`
|
||||
|
||||
Identifies the concrete provider instance behind the action.
|
||||
|
||||
Examples:
|
||||
|
||||
- `messaging:slack:work`
|
||||
- `messaging:telegram:personal`
|
||||
- `memory:lancedb:default`
|
||||
- `runtime-backend:acp:acpx`
|
||||
|
||||
## Visibility Rules
|
||||
|
||||
### Agent-visible
|
||||
|
||||
Used for agent planning and tool calling.
|
||||
|
||||
Includes:
|
||||
|
||||
- agent tools
|
||||
- channel messaging actions such as send, reply, broadcast, poll, react, edit, delete, pin, and thread actions when available in context
|
||||
- memory actions when policy allows them
|
||||
- voice or telephony actions
|
||||
- selected interaction or workflow actions
|
||||
|
||||
Important interaction rule:
|
||||
|
||||
- interaction-driven actions must be filtered by the current binding and route context
|
||||
- a bound conversation should only surface interaction actions that are valid for the owning extension and current adapter capabilities
|
||||
|
||||
### Operator-visible
|
||||
|
||||
Used for admin, control, setup, CLI, and diagnostic surfaces.
|
||||
|
||||
Includes:
|
||||
|
||||
- control commands
|
||||
- setup flows
|
||||
- provider integration and auth flows
|
||||
- status surfaces
|
||||
- CLI commands
|
||||
|
||||
Important distinction:
|
||||
|
||||
- `capability.control-command` is for chat or native commands that bypass the model
|
||||
- `surface.cli` and `surface.setup` are host-managed local operator surfaces and are not kernel runtime capabilities
|
||||
|
||||
Operator-visible control-command surfaces should preserve current command metadata such as:
|
||||
|
||||
- whether the command accepts arguments
|
||||
- provider-specific native command names when a provider supports native slash or menu registration
|
||||
|
||||
### Runtime-internal
|
||||
|
||||
Not shown to agents or operators as catalog actions.
|
||||
|
||||
Includes:
|
||||
|
||||
- runtime backends
|
||||
- context engines
|
||||
- pure event observers
|
||||
- route augmenters
|
||||
|
||||
## Conflict Classes
|
||||
|
||||
The host must resolve different conflict types differently.
|
||||
|
||||
### 1. Runtime id conflict
|
||||
|
||||
Fatal during validation.
|
||||
|
||||
### 2. Canonical action overlap
|
||||
|
||||
Multiple providers implement the same action family.
|
||||
|
||||
This is expected for messaging, auth, or directory.
|
||||
|
||||
### 3. Planner-visible name collision
|
||||
|
||||
Two agent-visible capabilities want the same public name.
|
||||
|
||||
This must be resolved before catalog publication.
|
||||
|
||||
### 4. Singleton slot conflict
|
||||
|
||||
Two contributions claim a slot that is intentionally exclusive.
|
||||
|
||||
Examples:
|
||||
|
||||
- default memory backend
|
||||
- default context engine
|
||||
|
||||
### 5. Route surface conflict
|
||||
|
||||
Two contributions require the same target or routing ownership semantics.
|
||||
|
||||
### 6. Backend selector conflict
|
||||
|
||||
Two runtime backends claim the same selector with incompatible exclusivity.
|
||||
|
||||
## Arbitration Modes
|
||||
|
||||
### `exclusive`
|
||||
|
||||
Exactly one active provider may exist for the slot.
|
||||
|
||||
Examples:
|
||||
|
||||
- one default context engine
|
||||
- one default memory store, unless the operator opts into parallel memory providers
|
||||
|
||||
### `ranked`
|
||||
|
||||
Many providers may exist, but one default is chosen by rank.
|
||||
|
||||
Examples:
|
||||
|
||||
- multiple auth methods for one provider
|
||||
- multiple backends for the same subsystem
|
||||
|
||||
### `parallel`
|
||||
|
||||
Many providers may remain simultaneously available.
|
||||
|
||||
Examples:
|
||||
|
||||
- Slack, Discord, and Telegram messaging providers for the same agent
|
||||
- multiple directory sources
|
||||
|
||||
### `composed`
|
||||
|
||||
Many providers contribute to a single pipeline.
|
||||
|
||||
Examples:
|
||||
|
||||
- context augmentation
|
||||
- prompt guidance
|
||||
- telemetry enrichment
|
||||
|
||||
## Agent Catalog Compilation
|
||||
|
||||
The kernel compiles the agent-visible catalog from:
|
||||
|
||||
- active contributions
|
||||
- current workspace
|
||||
- current agent
|
||||
- active session bindings
|
||||
- route and account context
|
||||
- current adapter action support
|
||||
- policy restrictions
|
||||
- contribution visibility rules
|
||||
|
||||
Catalog compilation is context-sensitive.
|
||||
|
||||
The same agent may see different capability sets in:
|
||||
|
||||
- Slack thread context
|
||||
- Telegram DM context
|
||||
- voice call context
|
||||
- local CLI session
|
||||
|
||||
First-cut migration targets:
|
||||
|
||||
- plugin tools currently exposed by plugin grouping
|
||||
- messaging actions for the first channel pilot
|
||||
- route-affecting behaviors that influence whether an action is available at all
|
||||
|
||||
## Capability Selection Rules
|
||||
|
||||
When the agent or runtime needs one provider for a canonical action, selection should use this order:
|
||||
|
||||
1. explicit target or provider selector
|
||||
2. explicit session binding
|
||||
3. current conversation or thread route binding
|
||||
4. current adapter or account capability support
|
||||
5. policy-forced default
|
||||
6. ranked default provider
|
||||
7. deterministic fallback by extension id and contribution id
|
||||
|
||||
This is especially important for `message.send` and `message.reply`.
|
||||
|
||||
It also applies to interaction and conversation-control actions, which should prefer:
|
||||
|
||||
- current binding owner
|
||||
- current adapter support
|
||||
- explicit target selection only when ownership or adapter support is ambiguous
|
||||
|
||||
## Messaging Example
|
||||
|
||||
One agent may have:
|
||||
|
||||
- Discord adapter on work account
|
||||
- Slack adapter on work account
|
||||
- Telegram adapter on personal account
|
||||
|
||||
The agent should not see three unrelated tools named “send message”.
|
||||
|
||||
Instead it should see canonical action families, with provider resolution handled by:
|
||||
|
||||
- current conversation route
|
||||
- current session binding
|
||||
- explicit target selector when needed
|
||||
|
||||
Examples:
|
||||
|
||||
- `message.send`
|
||||
- `message.reply`
|
||||
- `message.broadcast`
|
||||
- `message.poll`
|
||||
- `message.react`
|
||||
|
||||
If disambiguation is required, the planner or runtime can use structured selectors such as:
|
||||
|
||||
- target channel kind
|
||||
- account id
|
||||
- conversation ref
|
||||
|
||||
## Agent Naming Rules
|
||||
|
||||
Agent-visible names must be stable and minimally ambiguous.
|
||||
|
||||
Rules:
|
||||
|
||||
- canonical names belong to action families
|
||||
- provider labels are attached only when needed for disambiguation
|
||||
- aliases do not create additional planner-visible tools unless explicitly requested
|
||||
- the host rejects duplicate planner-visible names when the runtime cannot disambiguate them
|
||||
|
||||
This avoids exposing raw extension names unless necessary.
|
||||
|
||||
## Operator Command Separation
|
||||
|
||||
Control commands are not agent tools.
|
||||
|
||||
Examples today:
|
||||
|
||||
- `src/extension-host/contributions/command-runtime.ts:1`
|
||||
- `extensions/phone-control/index.ts:330`
|
||||
|
||||
They belong only in operator catalogs and control surfaces.
|
||||
|
||||
## Provider Integration Selection
|
||||
|
||||
Provider integration flows should be modeled as operator-visible capabilities, not agent-visible tools.
|
||||
|
||||
Selection rules:
|
||||
|
||||
- provider id first
|
||||
- method id second
|
||||
- rank or policy third
|
||||
|
||||
Multiple auth methods for one provider may coexist.
|
||||
|
||||
The selected provider integration may also contribute:
|
||||
|
||||
- discovery order
|
||||
- onboarding metadata
|
||||
- token refresh behavior
|
||||
- model-selected hooks
|
||||
|
||||
It should not silently absorb unrelated subsystem runtimes such as embeddings, transcription, media understanding, or TTS.
|
||||
It should also not silently absorb agent-visible search surfaces, which belong in the agent-tool catalog even when they call remote search services.
|
||||
|
||||
## Memory Arbitration
|
||||
|
||||
Memory needs both backend arbitration and agent action arbitration.
|
||||
|
||||
### Backend arbitration
|
||||
|
||||
Usually `exclusive` or `ranked`.
|
||||
|
||||
### Agent action arbitration
|
||||
|
||||
May still expose:
|
||||
|
||||
- `memory.search`
|
||||
- `memory.store`
|
||||
|
||||
If parallel memory providers are enabled, the planner should either target the default store or use explicit selectors.
|
||||
|
||||
## Context Engine Arbitration
|
||||
|
||||
Context engines are runtime-internal providers selected through an explicit exclusive slot.
|
||||
|
||||
Selection rules:
|
||||
|
||||
- explicit configured engine id wins
|
||||
- otherwise use the slot default
|
||||
- if the selected engine is unavailable, fail with a typed configuration error rather than silently picking an arbitrary fallback
|
||||
|
||||
## Runtime Backend Arbitration
|
||||
|
||||
Runtime backends such as ACP are runtime-internal providers.
|
||||
|
||||
Selection rules:
|
||||
|
||||
- explicit backend id wins
|
||||
- otherwise use healthy highest-ranked backend
|
||||
- if a subsystem declares an exclusive slot, the host enforces it before kernel startup
|
||||
|
||||
This is why `capability.runtime-backend` must be a first-class family.
|
||||
|
||||
The same model should be available for other subsystem runtimes discovered during migration:
|
||||
|
||||
- embeddings
|
||||
- audio transcription
|
||||
- image understanding
|
||||
- video understanding
|
||||
- text-to-speech
|
||||
|
||||
Selection rules for these subsystem runtimes should preserve these required behaviors:
|
||||
|
||||
- capability-based selection
|
||||
- normalized provider ids
|
||||
- explicit built-in fallback policy
|
||||
- typed host-injected request envelopes
|
||||
|
||||
Architecture rule:
|
||||
|
||||
- keep those selection and envelope rules inside host-owned subsystem runtime registries for typed backend families
|
||||
- do not widen provider-integration or legacy plugin-provider APIs into a universal surface for unrelated runtime subsystems
|
||||
- if search is agent-visible, publish it through canonical tool catalogs; reserve `capability.runtime-backend` for search backends that are consumed internally by the host or another subsystem
|
||||
|
||||
## Catalog Publication
|
||||
|
||||
The kernel should publish:
|
||||
|
||||
- a full internal catalog
|
||||
- a filtered agent catalog
|
||||
|
||||
The extension host should publish:
|
||||
|
||||
- a filtered operator catalog
|
||||
|
||||
Publication should occur after:
|
||||
|
||||
- dependency resolution
|
||||
- policy approval
|
||||
- contribution activation
|
||||
- route and account context binding
|
||||
|
||||
Host-managed install and onboarding descriptors may move into host ownership earlier because they come from static metadata, not runtime activation.
|
||||
|
||||
Full catalog publication, consolidation, and legacy-path replacement still belong to the catalog-migration phase.
|
||||
|
||||
Performance requirement:
|
||||
|
||||
- publishing host-managed setup and install catalogs must not require activating heavy adapter runtimes
|
||||
- publishing operator-visible static catalogs must preserve current dock-style cheap-path behavior, including prompt hints and shared formatting helpers where those are consumed without runtime activation
|
||||
|
||||
## Telemetry And Auditing
|
||||
|
||||
Capability selection must emit structured events for:
|
||||
|
||||
- conflict detection
|
||||
- provider selection
|
||||
- fallback selection
|
||||
- planner-visible disambiguation
|
||||
- veto or cancellation caused by route augmenters
|
||||
- slot selection for context engines or other exclusive runtime providers
|
||||
|
||||
## Migration Mapping From Today
|
||||
|
||||
- channel capabilities from `extensions/discord/src/channel.ts:74`, `extensions/slack/src/channel.ts:107`, and `extensions/telegram/src/channel.ts:120` collapse into canonical messaging action families
|
||||
- diffs becomes an agent-visible tool family plus a host-managed route surface from `extensions/diffs/index.ts:27`
|
||||
- provider integration from `extensions/google-gemini-cli-auth/index.ts:24` becomes operator-visible setup and auth capabilities
|
||||
- catalog-backed runtime-family descriptors for embeddings, media, and TTS now route through `src/extension-host/static/runtime-backend-catalog.ts`; embedding shared backend definitions in `src/extension-host/static/embedding-runtime-backends.ts`, media shared backend definitions in `src/extension-host/static/media-runtime-backends.ts`, TTS shared backend definitions in `src/extension-host/static/tts-runtime-backends.ts`, initial runtime-family arbitration in `src/extension-host/policy/runtime-backend-arbitration.ts`, and shared default and preferred backend ordering plus fallback chaining in `src/extension-host/policy/runtime-backend-policy.ts`; the catalog helpers for generic subsystem ordering, media auto-ordering, and TTS preferred-provider ordering now also route through that shared backend-policy layer; embedding auto-provider selection, embedding local setup guidance, memory doctor guidance for embedding auto-provider order, and explicit embedding fallback policy in `src/extension-host/policy/embedding-runtime-policy.ts`, media provider candidate ordering plus default-model fallback selection in `src/extension-host/policy/media-runtime-policy.ts`, and TTS request setup, status, compatibility provider-order exports, plus explicit TTS configured-fallback policy in `src/extension-host/policy/tts-runtime-policy.ts` already consume that catalog-backed arbitration and backend-policy data, and broader consumer adoption and arbitration should continue moving those subsystem runtimes toward runtime-internal registries rather than leaving them as a universal plugin-provider API shape
|
||||
- extension-backed web search should become an agent-visible tool family unless it is only a runtime-internal backend feeding another host-owned surface
|
||||
- voice-call from `extensions/voice-call/index.ts:230` becomes a mix of agent-visible actions, runtime providers, and operator surfaces
|
||||
- ACP backend registration from `extensions/acpx/src/service.ts:55` becomes runtime-internal backend arbitration
|
||||
- context-engine registration becomes runtime-internal slot arbitration from `src/context-engine/registry.ts:60`
|
||||
- native command registration remains an operator or transport surface concern rather than an agent-visible catalog concern
|
||||
|
||||
## Immediate Implementation Work
|
||||
|
||||
1. Add canonical action ids and provider keys to resolved contributions.
|
||||
2. Implement host-side conflict detection for planner-visible names and singleton slots.
|
||||
3. Implement kernel-side context-aware catalog compilation.
|
||||
4. Add host-managed static catalogs for install and onboarding metadata alongside the runtime catalogs.
|
||||
5. Migrate the existing plugin tool grouping path onto canonical agent catalog entries.
|
||||
6. Migrate the existing provider auth and setup selection path onto host-owned setup catalogs and canonical provider metadata.
|
||||
7. Add provider selection logic for the broader messaging action family before migrating all channels.
|
||||
8. Add runtime-backend and context-engine arbitration using the same rank and slot model where appropriate.
|
||||
9. Finish broader consumer adoption and arbitration on top of the catalog-backed runtime-family descriptors for embeddings, media, and TTS, with explicit capability routing and built-in fallback policy.
|
||||
10. Decide whether extension-backed search needs only canonical tool publication or also a host-owned runtime registry for internal search backends, and keep those two cases distinct.
|
||||
11. Ensure lightweight setup catalogs can be built from static descriptors alone.
|
||||
12. Add a reviewed core registry for canonical action families and document how new ids are introduced.
|
||||
13. Record catalog and arbitration parity for `thread-ownership` first and `telegram` second before broader rollout.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,705 +0,0 @@
|
||||
Temporary internal migration note: remove this document once the extension-host migration is complete.
|
||||
|
||||
# OpenClaw Extension Host Implementation Guide
|
||||
|
||||
Date: 2026-03-15
|
||||
|
||||
## Purpose
|
||||
|
||||
This is the main execution guide for implementing the extension-host and kernel transition.
|
||||
|
||||
Use it as the top-level implementation document.
|
||||
|
||||
## How We Fix It
|
||||
|
||||
Fix this as a staged architectural migration, not a broad refactor.
|
||||
|
||||
1. Lock the boundary first by writing the cutover inventory and adding anti-corruption interfaces so no new plugin-specific behavior leaks into the kernel.
|
||||
2. Introduce source-of-truth extension schema types and the `ResolvedExtension` model while preserving current `openclaw/plugin-sdk/*` loading through minimal compatibility support.
|
||||
3. Move discovery, policy, provenance, static metadata, and registration ownership into the extension host, including hooks, channels, providers, tools, routes, CLI, setup, services, and slot-backed providers.
|
||||
4. Prove the path with pilot migrations: `thread-ownership` first for non-channel hook behavior, then `telegram` for channel compatibility.
|
||||
5. After pilot parity is established, move runtime behavior onto canonical event stages and replace the fragmented tool, provider, and slot-selection paths with one catalog and arbitration model.
|
||||
6. Remove the legacy plugin runtime as the default path only after the host path has parity and the duplicate legacy systems are gone or explicitly downgraded to compatibility-only shims.
|
||||
|
||||
The other docs remain the source of truth for their domains:
|
||||
|
||||
- `openclaw-extension-contribution-schema-spec.md`
|
||||
- `openclaw-extension-host-lifecycle-and-security-spec.md`
|
||||
- `openclaw-kernel-event-pipeline-spec.md`
|
||||
- `openclaw-capability-catalog-and-arbitration-spec.md`
|
||||
- `openclaw-kernel-extension-host-transition-plan.md`
|
||||
|
||||
## TODOs
|
||||
|
||||
- [ ] Confirm the implementation order and owners for each phase.
|
||||
- [x] Create the initial code skeleton for kernel and extension-host boundaries.
|
||||
- [x] Write the initial boundary cutover inventory for every current plugin-owned surface.
|
||||
- [ ] Keep the boundary cutover inventory updated as surfaces move.
|
||||
- [ ] Track PRs, migrations, and follow-up gaps by phase.
|
||||
- [ ] Keep the linked spec TODO sections in sync with implementation progress.
|
||||
- [ ] Define the detailed pilot migration matrix and parity checks before Phase 3 starts.
|
||||
- [ ] Mark this guide complete only when the legacy plugin path is no longer the primary runtime path.
|
||||
|
||||
## Implementation Status
|
||||
|
||||
Current status against this guide:
|
||||
|
||||
- Phase 0 has started but is not complete.
|
||||
- Phase 1 has started but is not complete.
|
||||
- Phase 2 has started in a broad, compatibility-preserving form but is not complete.
|
||||
- Phases 3 through 7 have not started in a meaningful way yet.
|
||||
|
||||
What has been implemented so far:
|
||||
|
||||
- a new `src/extension-host/*` boundary now exists in code
|
||||
- active runtime registry ownership moved into `src/extension-host/static/active-registry.ts`
|
||||
- `src/plugins/runtime.ts` now acts as a compatibility facade over the host-owned active registry
|
||||
- registry activation now routes through `src/extension-host/activation.ts`
|
||||
- initial source-of-truth types landed in `src/extension-host/manifests/schema.ts`, including `ResolvedExtension`, `ResolvedContribution`, and `ContributionPolicy`
|
||||
- static manifest and package metadata are now normalized through host-owned helpers rather than being interpreted only inside plugin-era modules
|
||||
- `src/plugins/manifest-registry.ts` now carries a normalized `resolvedExtension` alongside the legacy flat manifest record
|
||||
- `src/extension-host/manifests/resolved-registry.ts` now exposes a host-owned resolved-extension registry view
|
||||
- an initial Phase 0 inventory now exists in `src/extension-host/cutover-inventory.md`
|
||||
- plugin SDK alias resolution now routes through `src/extension-host/compat/loader-compat.ts`
|
||||
- loader alias-wired module loader creation now routes through `src/extension-host/activation/loader-module-loader.ts`
|
||||
- loader cache key construction and registry cache control now route through `src/extension-host/activation/loader-cache.ts`
|
||||
- loader lazy runtime proxy creation now routes through `src/extension-host/activation/loader-runtime-proxy.ts`
|
||||
- loader provenance helpers now route through `src/extension-host/policy/loader-provenance.ts`
|
||||
- loader duplicate-order and record/error policy now route through `src/extension-host/policy/loader-policy.ts`
|
||||
- loader discovery policy outcomes now route through `src/extension-host/policy/loader-discovery-policy.ts`
|
||||
- loader initial candidate planning and record creation now route through `src/extension-host/activation/loader-records.ts`
|
||||
- loader entry-path opening and module import now route through `src/extension-host/activation/loader-import.ts`
|
||||
- loader module-export resolution, config validation, and memory-slot load decisions now route through `src/extension-host/activation/loader-runtime.ts`
|
||||
- loader post-import planning and `register(...)` execution now route through `src/extension-host/activation/loader-register.ts`
|
||||
- loader per-candidate orchestration now routes through `src/extension-host/activation/loader-flow.ts`
|
||||
- loader top-level load orchestration now routes through `src/extension-host/activation/loader-orchestrator.ts`
|
||||
- loader host process state now routes through `src/extension-host/activation/loader-host-state.ts`
|
||||
- loader preflight and cache-hit setup now routes through `src/extension-host/activation/loader-preflight.ts`
|
||||
- loader post-preflight pipeline composition now routes through `src/extension-host/activation/loader-pipeline.ts`
|
||||
- loader execution setup composition now routes through `src/extension-host/activation/loader-execution.ts`
|
||||
- loader discovery and manifest bootstrap now routes through `src/extension-host/activation/loader-bootstrap.ts`
|
||||
- loader mutable activation state now routes through `src/extension-host/activation/loader-session.ts`
|
||||
- loader session run and finalization composition now routes through `src/extension-host/activation/loader-run.ts`
|
||||
- loader activation policy outcomes now route through `src/extension-host/policy/loader-activation-policy.ts`
|
||||
- loader record-state transitions now route through `src/extension-host/activation/loader-state.ts`, which now enforces an explicit loader lifecycle state machine while preserving compatibility `PluginRecord.status` values
|
||||
- loader finalization policy results now route through `src/extension-host/policy/loader-finalization-policy.ts`
|
||||
- loader final cache, readiness promotion, and activation finalization now routes through `src/extension-host/activation/loader-finalize.ts`
|
||||
- runtime registration normalization has started in `src/extension-host/contributions/runtime-registrations.ts` for channel, provider, HTTP-route, gateway-method, tool, CLI, service, command, context-engine, and hook registrations
|
||||
- low-risk runtime compatibility writes for channel, provider, gateway-method, HTTP-route, tool, CLI, service, command, context-engine, and hook registrations now route through `src/extension-host/contributions/registry-writes.ts`
|
||||
- context-engine registration and runtime resolution now route through `src/extension-host/contributions/context-engine-runtime.ts` while `src/context-engine/registry.ts` remains the compatibility facade
|
||||
- exclusive-slot selection and default-slot resolution now route through `src/extension-host/policy/slot-arbitration.ts` while `src/plugins/slots.ts` remains the compatibility facade
|
||||
- ACP backend registration and resolution now route through `src/extension-host/contributions/acp-runtime-backend-registry.ts` while `src/acp/runtime/registry.ts` remains the compatibility facade
|
||||
- legacy internal-hook bridging and typed prompt-injection compatibility policy now route through `src/extension-host/compat/hook-compat.ts`
|
||||
- compatibility `OpenClawPluginApi` composition and logger shaping now route through `src/extension-host/compat/plugin-api.ts`
|
||||
- compatibility plugin-registry facade ownership now routes through `src/extension-host/compat/plugin-registry.ts`
|
||||
- compatibility plugin-registry policy now routes through `src/extension-host/compat/plugin-registry-compat.ts`
|
||||
- compatibility plugin-registry registration actions now route through `src/extension-host/compat/plugin-registry-registrations.ts`
|
||||
- host-owned runtime registry accessors now route through `src/extension-host/contributions/runtime-registry.ts`, and the channel, provider, tool, command, HTTP-route, gateway-method, CLI, and service slices now keep host-owned storage there with mirrored legacy compatibility views
|
||||
- service startup, stop ordering, service-context creation, and failure logging now route through `src/extension-host/contributions/service-lifecycle.ts`
|
||||
- CLI duplicate detection, registrar invocation, and async failure logging now route through `src/extension-host/contributions/cli-lifecycle.ts`
|
||||
- gateway method-id aggregation, plugin diagnostic shaping, and extra-handler composition now route through `src/extension-host/contributions/gateway-methods.ts`
|
||||
- plugin tool resolution, conflict handling, optional-tool gating, and plugin-tool metadata tracking now route through `src/extension-host/contributions/tool-runtime.ts`
|
||||
- plugin provider projection from registry entries into runtime provider objects now routes through `src/extension-host/contributions/provider-runtime.ts`
|
||||
- plugin provider discovery filtering, order grouping, and result normalization now route through `src/extension-host/contributions/provider-discovery.ts`
|
||||
- provider matching, auth-method selection, config-patch merging, and default-model application now route through `src/extension-host/contributions/provider-auth.ts`
|
||||
- embedding-provider auto-selection, provider creation, local-setup guidance, and primary plus fallback routing now route through `src/extension-host/contributions/embedding-runtime-registry.ts`, shared fallback-model selection now routes through `src/extension-host/policy/embedding-runtime-policy.ts`, the public embedding runtime surface plus result typing now route through `src/extension-host/contributions/embedding-runtime.ts` and `src/extension-host/contributions/embedding-runtime-types.ts`, manager-side batch policy plus fallback activation now route through `src/extension-host/contributions/embedding-manager-runtime.ts`, sync plus reindex planning now route through `src/extension-host/contributions/embedding-sync-planning.ts`, sync plus reindex orchestration now route through `src/extension-host/contributions/embedding-sync-execution.ts`, reindex sync-body execution plus unsafe reset now route through `src/extension-host/contributions/embedding-reindex-execution.ts`, and safe-reindex temp-db creation, file swap, reopen, and cleanup now route through `src/extension-host/contributions/embedding-safe-reindex.ts` while `src/memory/embeddings.ts` remains the compatibility facade
|
||||
- built-in media backend definitions, provider normalization, auto-selection seed order, selector-key shaping, and default-model metadata now route through `src/extension-host/static/media-runtime-backends.ts`; override merging and runtime lookup now route through `src/extension-host/contributions/media-runtime-registry.ts`; shared default and preferred runtime-backend ordering plus fallback chaining now route through `src/extension-host/policy/runtime-backend-policy.ts`; provider candidate ordering, active-model precedence, and default-model fallback selection now route through `src/extension-host/policy/media-runtime-policy.ts`; provider and CLI entry execution, output parsing, provider query normalization, provider auth/context shaping, and proxy-aware fetch handling now route through `src/extension-host/contributions/media-runtime-execution.ts`; local-binary probing, auto-entry selection, and top-level capability orchestration now route through `src/extension-host/contributions/media-runtime-auto.ts` and `src/extension-host/contributions/media-runtime-orchestration.ts`; and media prompt, timeout, scope, model-entry, concurrency, and decision helpers now route through `src/extension-host/contributions/media-runtime-config.ts` and `src/extension-host/contributions/media-runtime-decision.ts` while `src/media-understanding/providers/index.ts`, `src/media-understanding/runner.ts`, `src/media-understanding/runner.entries.ts`, and `src/media-understanding/resolve.ts` remain compatibility facades
|
||||
- TTS provider metadata, provider ordering, API-key resolution, configuration checks, and telephony support now route through `src/extension-host/contributions/tts-runtime-registry.ts`, provider execution loops, output-format selection, telephony synthesis, and provider-error shaping now route through `src/extension-host/contributions/tts-runtime-execution.ts`, provider selection plus request setup now route through `src/extension-host/contributions/tts-runtime-setup.ts`, TTS config normalization, defaults, and model-override policy now route through `src/extension-host/contributions/tts-config.ts`, prefs-path resolution, auto-mode policy, and persisted TTS preference reads and writes now route through `src/extension-host/contributions/tts-preferences.ts`, auto-TTS gating, directive cleanup, truncation, summarization, and payload planning now route through `src/extension-host/contributions/tts-payload.ts`, and last-attempt state plus shared status snapshots now route through `src/extension-host/contributions/tts-status.ts` while `src/tts/tts.ts` remains the compatibility facade
|
||||
- provider onboarding option building, model-picker entry building, and provider-method choice resolution now route through `src/extension-host/contributions/provider-wizard.ts`
|
||||
- loaded-provider auth application, plugin-enable gating, auth-method execution, and post-auth default-model handling now route through `src/extension-host/contributions/provider-auth-flow.ts`
|
||||
- provider post-selection hook lookup and invocation now route through `src/extension-host/contributions/provider-model-selection.ts`
|
||||
- the filesystem-layout passes have also broadened: activation helpers now live under `src/extension-host/activation/`, host-owned policy seams now live under `src/extension-host/policy/`, compatibility facades now live under `src/extension-host/compat/`, manifest and resolved-static helpers now live under `src/extension-host/manifests/`, host-owned static descriptors now live under `src/extension-host/static/`, and host-owned runtime plus contribution seams now live under `src/extension-host/contributions/`, matching the module layout already called for in the transition plan without changing the underlying ownership split
|
||||
- several static and lookup consumers now read through the host boundary or resolved-extension model:
|
||||
- channel registry and dock lookups
|
||||
- message-channel normalization
|
||||
- plugin HTTP route registry default lookup
|
||||
- discovery and install package metadata parsing
|
||||
- channel catalog package metadata parsing
|
||||
- plugin skill discovery
|
||||
- plugin auto-enable
|
||||
- config doc baseline generation
|
||||
- config validation indexing
|
||||
- several runtime consumers now also read through host-owned runtime-registry accessors instead of touching raw plugin-registry arrays or handler maps directly:
|
||||
- channel lookup
|
||||
- provider projection
|
||||
- tool resolution
|
||||
- service lifecycle startup
|
||||
- CLI registration
|
||||
- command runtime entry detection
|
||||
- gateway method aggregation
|
||||
- gateway plugin HTTP route matching
|
||||
- plugin command execution and command-status listing now read through `src/extension-host/contributions/command-runtime.ts` instead of the legacy `src/plugins/commands.ts` implementation
|
||||
- the channel, provider, tool, command, HTTP-route, gateway-method, CLI, and service slices now also keep host-owned runtime-registry storage with mirrored legacy compatibility arrays and handler maps
|
||||
- `src/cli/plugin-registry.ts` now treats any pre-seeded runtime entry surface as already loaded, not just plugins, channels, or tools
|
||||
|
||||
How it has been done:
|
||||
|
||||
- by extracting narrow host-owned modules first and making existing plugin modules delegate to them
|
||||
- by preserving current behavior and import surfaces wherever possible instead of attempting a broad rewrite
|
||||
- by introducing normalized static records before touching heavy runtime activation paths
|
||||
- by converting one static consumer at a time so each call site can move without forcing a loader rewrite
|
||||
- by extracting low-risk runtime registration helpers next and letting `src/plugins/registry.ts` delegate to them as a compatibility facade
|
||||
- by starting actual low-risk runtime write ownership next for channel, provider, gateway-method, HTTP-route, tool, CLI, service, command, context-engine, and hook registrations while keeping lifecycle semantics in legacy owners where that behavior still lives
|
||||
- by moving plugin command duplicate enforcement, registration, matching, execution, listing, native command-spec projection, and loader reload clearing behind `src/extension-host/contributions/command-runtime.ts` while keeping `src/plugins/commands.ts` as the compatibility facade
|
||||
- by starting loader and lifecycle migration with compatibility helpers for activation and SDK alias resolution before changing discovery or policy behavior
|
||||
- by moving cache-key construction, cache reads, cache writes, and cache clearing behind host-owned helpers before changing activation-state ownership
|
||||
- by extracting lazy runtime proxy creation and alias-wired Jiti module-loader creation into host-owned helpers before broader bootstrap or lifecycle ownership changes
|
||||
- by extracting discovery, manifest loading, manifest diagnostics, discovery-policy logging, provenance building, and candidate ordering into a host-owned loader-bootstrap helper before broader lifecycle ownership changes
|
||||
- by extracting candidate iteration, manifest lookup, per-candidate session processing, and finalization handoff into a host-owned loader-run helper before broader lifecycle ownership changes
|
||||
- by moving loader-owned policy helpers next, while keeping module loading and enablement flow behavior unchanged
|
||||
- by moving initial candidate planning and record construction behind host-owned helpers before changing import and registration flow
|
||||
- by moving entry-path opening and module import behind host-owned helpers before changing cache wiring or lifecycle orchestration
|
||||
- by moving loader runtime decisions behind host-owned helpers while preserving lazy loading, config validation behavior, and memory-slot policy behavior
|
||||
- by moving post-import planning and `register(...)` execution behind host-owned helpers before changing entry-path and import flow
|
||||
- by composing those seams into one host-owned per-candidate orchestrator before changing cache and lifecycle finalization behavior
|
||||
- by moving loader record-state transitions into host-owned helpers before enforcing them as a loader lifecycle state machine
|
||||
- by moving cache writes, provenance warnings, final memory-slot warnings, and activation into a host-owned loader finalizer before introducing an explicit lifecycle state machine
|
||||
- by adding explicit compatibility `lifecycleState` mapping on loader-owned plugin records before enforcing the loader lifecycle state machine
|
||||
- by turning that compatibility `lifecycleState` field into an enforced loader lifecycle state machine with readiness promotion during finalization
|
||||
- by moving the remaining top-level loader orchestration into a host-owned module so `src/plugins/loader.ts` becomes a compatibility facade instead of the real owner
|
||||
- by extracting shared discovery warning-cache state and loader reset behavior into a host-owned loader-host-state helper before shrinking the remaining orchestrator surface
|
||||
- by extracting test-default application, config normalization, cache-key construction, cache-hit activation, and command-clear setup into a host-owned loader-preflight helper before shrinking the remaining orchestrator surface
|
||||
- by extracting post-preflight execution setup and session-run composition into a host-owned loader-pipeline helper before shrinking the remaining orchestrator surface
|
||||
- by extracting runtime creation, registry creation, bootstrap setup, module-loader creation, and session creation into a host-owned loader-execution helper before shrinking the remaining orchestrator surface
|
||||
- by moving mutable activation state such as seen-id tracking, memory-slot selection, and finalization inputs into a host-owned loader session instead of leaving them in top-level loader variables
|
||||
- by extracting shared provenance path matching and install-rule evaluation into `src/extension-host/policy/loader-provenance.ts` so activation and finalization policy seams reuse one host-owned implementation
|
||||
- by turning open-allowlist discovery warnings into explicit host-owned discovery-policy results before the orchestrator logs them
|
||||
- by moving duplicate precedence, config enablement, and early memory-slot gating into explicit host-owned activation-policy outcomes instead of leaving them inline in the loader flow
|
||||
- by turning provenance-based untracked-extension warnings and final memory-slot warnings into explicit host-owned finalization-policy results before the finalizer applies them
|
||||
- by extracting legacy internal-hook bridging and typed prompt-injection compatibility policy into a host-owned hook-compat helper while leaving actual hook execution ownership unchanged
|
||||
- by extracting compatibility `OpenClawPluginApi` composition and logger shaping into a host-owned plugin-api helper while keeping the concrete registration callbacks in the legacy registry surface
|
||||
- by extracting the remaining compatibility plugin-registry facade into a host-owned helper so `src/plugins/registry.ts` becomes a thin wrapper instead of the real owner
|
||||
- by extracting provider normalization, command duplicate enforcement, and registry-local diagnostic shaping into a host-owned registry-compat helper while leaving the underlying provider-validation and plugin-command subsystems unchanged
|
||||
- by extracting low-risk registry registration actions into a host-owned registry-registrations helper so the compatibility facade composes host-owned actions instead of implementing them inline
|
||||
- by extracting service startup, stop ordering, service-context creation, and failure logging into a host-owned service-lifecycle helper while `src/plugins/services.ts` remains the compatibility entry point
|
||||
- by extracting CLI duplicate detection, registrar invocation, and async failure logging into a host-owned CLI-lifecycle helper while `src/plugins/cli.ts` remains the compatibility entry point
|
||||
- by extracting gateway method-id aggregation, plugin diagnostic shaping, and extra-handler composition into a host-owned gateway-methods helper while request dispatch semantics remain in the gateway server code
|
||||
- by extracting plugin tool resolution, conflict handling, optional-tool gating, and plugin-tool metadata tracking into a host-owned tool-runtime helper while `src/plugins/tools.ts` remains the loader and config-normalization facade
|
||||
- by extracting provider projection from registry entries into runtime provider objects into a host-owned provider-runtime helper while `src/plugins/providers.ts` remains the loader and config-normalization facade
|
||||
- by extracting provider discovery filtering, order grouping, and result normalization into a host-owned provider-discovery helper while `src/plugins/provider-discovery.ts` remains the compatibility facade around the legacy provider loader path
|
||||
- by extracting provider matching, auth-method selection, config-patch merging, and default-model application into a host-owned provider-auth helper while `src/commands/provider-auth-helpers.ts` remains the command-facing compatibility facade
|
||||
- by extracting provider onboarding option building, model-picker entry building, and provider-method choice resolution into a host-owned provider-wizard helper while `src/plugins/provider-wizard.ts` remains the compatibility facade around loader-backed provider access and post-selection hooks
|
||||
- by extracting loaded-provider auth application, plugin-enable gating, auth-method execution, and post-auth default-model handling into a host-owned provider-auth-flow helper while `src/commands/auth-choice.apply.plugin-provider.ts` remains the compatibility entry point
|
||||
- by extracting provider post-selection hook lookup and invocation into a host-owned provider-model-selection helper while `src/plugins/provider-wizard.ts` remains the compatibility facade and existing command consumers continue migrating onto the host-owned surface
|
||||
- by extracting provider-id normalization into `src/agents/provider-id.ts` so provider-only host seams do not inherit the heavier agent and browser dependency graph from `src/agents/model-selection.ts`
|
||||
- by extracting model-ref parsing into `src/agents/model-ref.ts` and Google model-id normalization into `src/agents/google-model-id.ts` so provider auth and setup seams can be tested without pulling the heavier provider-loader and browser dependency graph
|
||||
- by introducing host-owned runtime-registry accessors for low-risk runtime consumers first, then moving channel, provider, tool, command, HTTP-route, gateway-method, CLI, and service storage into that host-owned state while keeping mirrored legacy compatibility arrays and handler maps
|
||||
- by tightening the CLI pre-load fast path to treat any host-known runtime entry surface as already loaded rather than only plugins, channels, or tools
|
||||
- by extracting media local-binary probing and auto-entry selection into `src/extension-host/contributions/media-runtime-auto.ts`, then moving top-level media capability orchestration into `src/extension-host/contributions/media-runtime-orchestration.ts` so `src/media-understanding/runner.ts` can collapse to a compatibility facade
|
||||
- by moving central readers first, so later lifecycle and compatibility work can land on one boundary instead of many ad hoc call sites
|
||||
- by adding focused tests for each extracted seam before widening the boundary further
|
||||
|
||||
Committed implementation slices so far:
|
||||
|
||||
- `6abf6750ee` `Plugins: add extension host registry boundary`
|
||||
- `1aab89e820` `Plugins: extract loader host seams`
|
||||
- `7bc3135082` `Plugins: extract loader candidate planning`
|
||||
- `3a122c95fa` `Plugins: extract loader register flow`
|
||||
- `fc81454038` `Plugins: extract loader import flow`
|
||||
- `e1b207f4cf` `Plugins: extract loader candidate orchestration`
|
||||
- `0c44d8049b` `Plugins: extract loader finalization`
|
||||
- `33ef55a9ee` `Plugins: add loader lifecycle state mapping`
|
||||
- `6590e19095` `Plugins: extract loader cache control`
|
||||
- `c8d82a8f19` `Plugins: extract loader orchestration`
|
||||
- `d32f65eb5e` `Plugins: add loader lifecycle state machine`
|
||||
- `da9aad0c0f` `Plugins: add loader activation session`
|
||||
- `fc51ce2867` `Plugins: add loader activation policy`
|
||||
- `fd7488e10a` `Plugins: add loader finalization policy`
|
||||
- `97e2af7f97` `Plugins: add loader discovery policy`
|
||||
- `83b18eab72` `Plugins: share loader provenance helpers`
|
||||
- `52495d23d5` `Plugins: extract loader runtime factories`
|
||||
- `6e187ffb62` `Plugins: extract loader bootstrap`
|
||||
- `234a540720` `Plugins: extract loader session runner`
|
||||
- `a98443c39d` `Plugins: extract loader execution setup`
|
||||
- `c9323aa016` `Plugins: extract loader preflight`
|
||||
- `0df51ae6b4` `Plugins: extract loader pipeline`
|
||||
- `e557b39cb2` `Plugins: extract loader host state`
|
||||
- `07c3ae9c87` `Plugins: extract low-risk registry writes`
|
||||
- `bc71592270` `Plugins: extend registry write helpers`
|
||||
- `27fc645484` `Plugins: extend registry writes for hooks`
|
||||
- `b407d7f476` `Plugins: extract hook compatibility`
|
||||
- `a1e1dcc01a` `Plugins: extract plugin api facade`
|
||||
- `0e190d64d4` `Plugins: extract registry compatibility facade`
|
||||
- `944d787df1` `Plugins: extract registry compatibility policy`
|
||||
- `4ca9cd7e5e` `Plugins: extract registry registration actions`
|
||||
- `6b24e65719` `Plugins: extract service lifecycle`
|
||||
- `b5757a6625` `Plugins: extract CLI lifecycle`
|
||||
- `e0e3229bcb` `Gateway: extract extension host method surface`
|
||||
- `af7ac14eed` `Plugins: extract tool runtime`
|
||||
- `19087405d2` `Plugins: extract provider runtime`
|
||||
- `1303419471` `Plugins: extract provider discovery`
|
||||
- `afb6e4b185` `Plugins: extract provider auth and wizard flows`
|
||||
- `cc3d59d59e` `Plugins: extract provider auth application flow`
|
||||
- `e6cd834f8e` `Plugins: extract provider model selection hook`
|
||||
- `11cbe08ec6` `Plugins: add host-owned route and gateway storage`
|
||||
- `89e6b38152` `Docs: refresh runtime registry storage status`
|
||||
- `ad0c235d16` `Plugins: add host-owned CLI and service storage`
|
||||
- `d34a5aa870` `Docs: refresh runtime registry storage progress`
|
||||
- `2be54e9861` `Plugins: add host-owned tool and provider storage`
|
||||
- `235021766c` `Docs: refresh tool and provider storage status`
|
||||
- `e109d5ef1b` `Plugins: add host-owned channel storage`
|
||||
- `24fca48453` `Docs: refresh channel storage status`
|
||||
- `961015f08c` `Channels: finish message-channel host lookup`
|
||||
- `4c7f62649b` `Plugins: extract command runtime`
|
||||
- `17b7f6a3e4` `Context: extract extension host engine runtime`
|
||||
- `871086537b` `Plugins: extract slot arbitration`
|
||||
- `b7868d06ba` `ACP: extract runtime backend registry`
|
||||
- `df0cb8193c` `Memory: extract embedding runtime registry`
|
||||
- `edae8761b3` `Memory: extract embedding runtime surface`
|
||||
- `bbe97e886f` `Memory: extract embedding manager runtime`
|
||||
- `d5de696768` `Memory: extract embedding sync planning`
|
||||
- `72da1b1821` `Memory: extract embedding sync execution`
|
||||
- `1d2720a379` `Memory: extract embedding reindex execution`
|
||||
- `3b29826ac2` `Memory: extract embedding safe reindex`
|
||||
- `ce5019e4ef` `TTS: extract config surface`
|
||||
- `d047f604d3` `TTS: extract status surface`
|
||||
- `e592f60fa7` `Media: extract runtime provider registry`
|
||||
- `9305a235b3` `Media: extract runtime execution`
|
||||
- `37ee04e9b0` `Media: extract runtime auto and orchestration`
|
||||
- `523e30a2d8` `Media: extract runtime planning helpers`
|
||||
- `8c68a4c63b` `Media: extract runtime API composition`
|
||||
- `6d7aa4f840` `Extensions: add runtime backend catalog`
|
||||
- `36711383f6` `TTS: extract runtime registry`
|
||||
- `f36f8f9e2d` `TTS: extract runtime execution`
|
||||
- `fa4f53896e` `TTS: extract runtime setup`
|
||||
- `64353a2b16` `TTS: extract preferences`
|
||||
- `ed5941ed7e` `TTS: extract payload planning`
|
||||
- `454e44242f` `TTS: extract API composition`
|
||||
- `6240fb31b5` `TTS: adopt runtime backend catalog`
|
||||
- `cfb6779ac2` `Runtime: adopt backend catalog consumers`
|
||||
- `24bb26b6dc` `TTS: adopt backend catalog order`
|
||||
- `05959b84c6` `TTS: share runtime backend definitions`
|
||||
- `3b7f993690` `Memory: share runtime backend definitions`
|
||||
- `4b1f31f947` `Media: share runtime backend definitions`
|
||||
- `7ff79aa494` `Memory: adopt embedding backend catalog`
|
||||
- `c674dc50a7` `Runtime: add backend-family arbitration`
|
||||
- `1b87cdeca1` `TTS: add backend fallback policy`
|
||||
- `fcb6dd911c` `Memory: add embedding fallback policy`
|
||||
- `c205208497` `Media: add runtime selection policy`
|
||||
- `a9e5d36f7e` `Runtime: share backend policy helper`
|
||||
- `8d8b9ba42d` `Memory: adopt shared backend policy`
|
||||
- `dd0c10c999` `Runtime: route backend catalog through shared policy`
|
||||
- `89414ed857` `Docs: track extension host migration internally`
|
||||
- `d8af1eceaf` `Docs: refresh extension host migration status`
|
||||
|
||||
What is still missing for these phases:
|
||||
|
||||
- keeping the cutover inventory current as more surfaces move
|
||||
- broader lifecycle ownership beyond the loader state machine, service-lifecycle boundary, CLI-lifecycle boundary, session-owned activation state, and explicit discovery-policy, activation-policy, and finalization-policy outcomes, remaining policy gate ownership, and broad host-owned registries described for Phase 2
|
||||
- minimal SDK compatibility work beyond preserving current behavior indirectly through existing loading
|
||||
- host-owned conversation binding, interaction routing, ingress claim, and generic interactive control surfaces
|
||||
- broader consumer adoption and arbitration on top of catalog-backed runtime-family descriptors for embeddings, media, and TTS
|
||||
- explicit support for extension-backed search, with a generic split between agent-visible tool publication and optional runtime-internal search backends
|
||||
- any pilot migration, event pipeline, or broader canonical catalog and arbitration rollout
|
||||
|
||||
Recent plan refinements:
|
||||
|
||||
- the plan now explicitly treats conversation binding ownership, approval persistence, restore-on-restart behavior, and detached-binding cleanup as first-class migration surfaces
|
||||
- it now explicitly treats interactive callback routing, namespace ownership, dedupe, and fallback behavior as first-class migration surfaces
|
||||
- it now explicitly treats inbound claim as a canonical ingress-stage concern rather than a permanent plugin-era hook shape
|
||||
- it now explicitly treats Telegram and Discord as the first validated rollout targets for interactive control surfaces while keeping the underlying contracts generic, host-owned, and kernel-agnostic
|
||||
- it now explicitly treats embeddings, media understanding, and TTS as in-progress host-owned subsystem runtimes, with embedding backend definitions now shared between `src/extension-host/static/embedding-runtime-backends.ts`, `src/extension-host/contributions/embedding-runtime-registry.ts`, and `src/extension-host/static/runtime-backend-catalog.ts`, shared default and preferred runtime-backend ordering plus fallback chaining now landing in `src/extension-host/policy/runtime-backend-policy.ts`, the catalog helpers for generic subsystem ordering, media auto-ordering, and TTS preferred-provider ordering now also routing through that shared backend-policy layer, embedding selection, fallback routing, explicit fallback policy in `src/extension-host/policy/embedding-runtime-policy.ts`, public runtime surface, result typing, manager-side batch and fallback policy, sync plus reindex planning, sync plus reindex orchestration, reindex sync-body execution plus unsafe reset, safe-reindex temp-db creation, file swap, reopen, and cleanup, plus runtime-backend catalog descriptors now extracted and embedding auto-provider selection, local setup guidance, and memory doctor guidance already consuming the catalog-backed embedding backend-policy order, media backend definitions now shared between `src/extension-host/static/media-runtime-backends.ts`, `src/extension-host/contributions/media-runtime-registry.ts`, and `src/extension-host/static/runtime-backend-catalog.ts`, with media registry, explicit selection policy in `src/extension-host/policy/media-runtime-policy.ts`, execution, auto-entry selection, orchestration, planning helpers, remaining API composition, lazy entrypoint wiring, plus runtime-backend catalog descriptors now extracted and media provider candidate ordering plus default-model fallback lookup already consuming that catalog-backed backend-policy order, TTS backend definitions now shared between `src/extension-host/static/tts-runtime-backends.ts`, `src/extension-host/contributions/tts-runtime-registry.ts`, and `src/extension-host/static/runtime-backend-catalog.ts`, with TTS registry, execution, request setup, config normalization, preferences, payload planning, shared status state, API composition, plus runtime-backend catalog descriptors now extracted, initial runtime-family arbitration now landing in `src/extension-host/policy/runtime-backend-arbitration.ts`, explicit configured-fallback policy now landing in `src/extension-host/policy/tts-runtime-policy.ts`, and TTS request setup, status, plus compatibility provider-order exports already consuming that arbitration-backed backend-policy order, and broader consumer adoption and arbitration on top of those catalog-backed runtime-family descriptors still pending, all with capability routing, typed request envelopes, provider-id normalization, and fallback policy
|
||||
- it now explicitly rejects widening the legacy `registerProvider(...)` or `ProviderPlugin` surface into a universal runtime API while retaining capability routing, typed request envelopes, provider-id normalization, and fallback behavior where those are part of the target model
|
||||
- it now explicitly treats extension-backed search as either a canonical tool contribution or a host-owned runtime backend depending on whether the search surface is agent-visible
|
||||
|
||||
## Implementation Order
|
||||
|
||||
Implement phases in this order:
|
||||
|
||||
1. Phase 0: boundary inventory and anti-corruption layer
|
||||
2. Phase 1: contribution schema, package metadata, and minimal SDK compatibility
|
||||
3. Phase 2: extension host lifecycle and registries
|
||||
4. Phase 3: broader legacy compatibility bridges
|
||||
5. Phase 4: canonical event pipeline
|
||||
6. Phase 5: capability catalog migration
|
||||
7. Phase 6: arbitration migration
|
||||
8. Phase 7: broader migration and legacy removal
|
||||
|
||||
This order matters because each layer depends on the previous one:
|
||||
|
||||
- catalogs depend on normalized contributions
|
||||
- normalized contributions depend on host discovery and validation
|
||||
- existing extensions must keep loading while the schema and SDK boundary changes
|
||||
- migrated hooks depend on the canonical event pipeline
|
||||
- install, onboarding, and status flows depend on static metadata before runtime activation
|
||||
- catalogs and arbitration already exist in partial forms, so their phases are migrations, not greenfield work
|
||||
- useful ideas from implementation review should be harvested as parity requirements and host-owned capabilities, not by broadening legacy `src/plugins/*` or `src/plugin-sdk/*` surfaces as the target architecture
|
||||
- safe removal of legacy paths depends on compatibility coverage and parity checks
|
||||
|
||||
## Implementation Guardrails
|
||||
|
||||
Do not implement every abstraction in the docs in the first cut.
|
||||
|
||||
Treat some parts of the design as ceilings rather than immediate scope:
|
||||
|
||||
- event taxonomy should start with three execution modes only:
|
||||
- parallel observers
|
||||
- sequential merge or decision handlers
|
||||
- sync transcript hot paths
|
||||
- permission modes should implement `advisory` and `host-enforced` first
|
||||
- `sandbox-enforced` should remain a future contract until real isolation exists
|
||||
- catalog publication should start small:
|
||||
- kernel internal catalog
|
||||
- kernel agent catalog
|
||||
- host operator and static registries
|
||||
- adapter metadata should stay minimal and parity-driven
|
||||
- setup flow typing should start with a small result set:
|
||||
- config patch
|
||||
- credential result
|
||||
- status note
|
||||
- follow-up action
|
||||
- canonical action governance should start as one source file plus tests, not a larger process framework
|
||||
- arbitration should start with:
|
||||
- exclusive slot
|
||||
- ranked provider
|
||||
- parallel provider
|
||||
|
||||
The first implementation goal is parity for pilot migrations, not maximum generality.
|
||||
|
||||
If a design choice is not required to migrate one channel extension and one non-channel extension safely, defer it.
|
||||
|
||||
## Current Runtime Surfaces That Must Be Accounted For
|
||||
|
||||
The current plugin system already owns more than runtime activation.
|
||||
|
||||
Before implementation starts, write and maintain a cutover inventory for these surfaces:
|
||||
|
||||
- manifest loading and static metadata
|
||||
- package-level install and onboarding metadata
|
||||
- discovery, provenance, and origin precedence
|
||||
- config schema and UI hint loading
|
||||
- typed hooks and legacy hook bridges
|
||||
- channels and channel lookup
|
||||
- providers and provider auth/setup flows
|
||||
- tools and agent-visible tool catalogs
|
||||
- HTTP routes and gateway methods
|
||||
- CLI registrars and plugin commands
|
||||
- services and context-engine registrations
|
||||
- slot selection and other existing arbitration paths
|
||||
- status, reload, install, update, and diagnostics surfaces
|
||||
|
||||
Do not treat Phase 5 and Phase 6 as new systems built in isolation.
|
||||
|
||||
They must absorb and replace the existing partial catalog and arbitration behaviors rather than creating a second source of truth.
|
||||
|
||||
## Phase Guide
|
||||
|
||||
### Phase 0: Lock the boundary
|
||||
|
||||
Goal:
|
||||
|
||||
- define the kernel versus extension-host boundary in code and imports
|
||||
- inventory every current plugin-owned surface that crosses that boundary
|
||||
|
||||
Deliverables:
|
||||
|
||||
- boundary cutover inventory
|
||||
- anti-corruption interfaces for host-owned registration surfaces
|
||||
- initial feature flags for host-path versus legacy-path execution
|
||||
- directory and import boundaries for kernel and extension-host code
|
||||
|
||||
Primary docs:
|
||||
|
||||
- `openclaw-kernel-extension-host-transition-plan.md`
|
||||
- `openclaw-extension-contribution-schema-spec.md`
|
||||
|
||||
Exit criteria:
|
||||
|
||||
- kernel code does not take new dependencies on legacy plugin shapes
|
||||
- extension-host directory structure exists
|
||||
- compatibility-only surfaces are identified
|
||||
- each current plugin-owned surface is tagged as kernel-owned, host-owned, or compatibility-only
|
||||
- no new direct writes to global registries are introduced without going through the new boundary
|
||||
|
||||
Current implementation status:
|
||||
|
||||
- partially implemented
|
||||
- the code boundary exists in `src/extension-host/*`
|
||||
- central active-registry ownership now routes through the host boundary
|
||||
- several central runtime readers now consume the host-owned boundary instead of reading directly from `src/plugins/runtime.ts`
|
||||
- the initial cutover inventory now exists in `src/extension-host/cutover-inventory.md` and is being updated as surfaces move, but the phase is still incomplete because loader orchestration, lifecycle ownership, and later compatibility phases have not moved yet
|
||||
|
||||
### Phase 1: Define the schema
|
||||
|
||||
Goal:
|
||||
|
||||
- implement the source-of-truth manifest and contribution types
|
||||
- preserve existing extension loading while the schema and SDK boundary changes
|
||||
|
||||
Primary doc:
|
||||
|
||||
- `openclaw-extension-contribution-schema-spec.md`
|
||||
|
||||
Deliverables:
|
||||
|
||||
- manifest parser
|
||||
- package metadata parser
|
||||
- contribution validators
|
||||
- `ResolvedExtension`
|
||||
- `ResolvedContribution`
|
||||
- typed `ContributionPolicy`
|
||||
- static metadata parser
|
||||
- new versioned SDK contract surface
|
||||
- minimal SDK compatibility loading surface
|
||||
- normalized install and onboarding metadata model
|
||||
|
||||
Exit criteria:
|
||||
|
||||
- extensions can be normalized into static and runtime sections without activating heavy runtime code
|
||||
- existing extension SDK imports still resolve through the compatibility loading path
|
||||
|
||||
Current implementation status:
|
||||
|
||||
- partially implemented
|
||||
- `ResolvedExtension`, `ResolvedContribution`, and `ContributionPolicy` landed as initial code types
|
||||
- legacy manifest and package metadata now converge into a normalized `resolvedExtension` record carried by the manifest registry
|
||||
- discovery, install, and catalog metadata parsing now go through host-owned schema helpers
|
||||
- partial explicit compatibility now exists through host-owned loader-compat and loader-runtime helpers, but full manifest or contribution validators and a versioned SDK compatibility layer are not implemented yet
|
||||
|
||||
### Phase 2: Build the extension host
|
||||
|
||||
Goal:
|
||||
|
||||
- implement discovery, validation, policy, registries, and lifecycle ownership
|
||||
|
||||
Primary doc:
|
||||
|
||||
- `openclaw-extension-host-lifecycle-and-security-spec.md`
|
||||
|
||||
Deliverables:
|
||||
|
||||
- discovery pipeline
|
||||
- activation state machine
|
||||
- policy evaluator
|
||||
- host-owned registries
|
||||
- host-owned adapters for hooks, channels, providers, tools, HTTP routes, gateway methods, CLI, services, commands, and context engines
|
||||
- per-extension state ownership
|
||||
- provenance and origin handling
|
||||
- config redaction-aware schema loading
|
||||
- reload and route ownership handling
|
||||
|
||||
Exit criteria:
|
||||
|
||||
- the host can load bundled and external extensions into normalized registries
|
||||
- the host can populate normalized registries without direct kernel writes except through explicit compatibility adapters
|
||||
|
||||
Current implementation status:
|
||||
|
||||
- partially implemented in a compatibility-preserving form
|
||||
- the host owns the active registry state
|
||||
- the host exposes a resolved-extension registry view for static consumers
|
||||
- plugin skills, plugin auto-enable, and config validation indexing now consume host-owned resolved-extension data
|
||||
- activation, loader cache control, loader policy, loader discovery-policy outcomes, loader activation-policy outcomes, loader finalization-policy outcomes, loader candidate planning, loader import flow, loader runtime decisions, loader post-import register flow, loader candidate orchestration, loader top-level load orchestration, loader session state, loader record-state helpers, and loader finalization now route through `src/extension-host/*`
|
||||
- broader lifecycle state ownership beyond the loader state machine, activation states, policy evaluation, and broad host-owned registries are still not implemented
|
||||
|
||||
### Phase 3: Build compatibility bridges
|
||||
|
||||
Goal:
|
||||
|
||||
- keep current extensions working through the host without leaking legacy contracts into the kernel
|
||||
|
||||
Primary docs:
|
||||
|
||||
- `openclaw-kernel-extension-host-transition-plan.md`
|
||||
- `openclaw-extension-contribution-schema-spec.md`
|
||||
|
||||
Deliverables:
|
||||
|
||||
- `ChannelPlugin` compatibility translators
|
||||
- plugin SDK compatibility loading
|
||||
- runtime-channel namespace translation into the new SDK modules
|
||||
- legacy setup and CLI translation
|
||||
- legacy config schema and UI hint translation
|
||||
- pilot migration matrix with explicit parity labels
|
||||
|
||||
Exit criteria:
|
||||
|
||||
- `thread-ownership` runs through the host path as the first non-channel pilot
|
||||
- `telegram` runs through the host path as the first channel pilot
|
||||
- both pilots have explicit parity results for discovery, config, activation, diagnostics, and runtime behavior
|
||||
|
||||
### Phase 4: Implement the canonical event pipeline
|
||||
|
||||
Goal:
|
||||
|
||||
- move runtime hook behavior onto explicit canonical events
|
||||
|
||||
Primary doc:
|
||||
|
||||
- `openclaw-kernel-event-pipeline-spec.md`
|
||||
|
||||
Deliverables:
|
||||
|
||||
- event type definitions
|
||||
- stage runner
|
||||
- sync transcript-write stages
|
||||
- bridges from legacy hook buses
|
||||
- mapping table from existing typed and legacy hooks to canonical stages
|
||||
|
||||
Exit criteria:
|
||||
|
||||
- migrated extensions can use canonical events without relying directly on old plugin hook execution
|
||||
- pilot hook behaviors have parity coverage against the pre-host path
|
||||
|
||||
### Phase 5: Implement catalogs
|
||||
|
||||
Goal:
|
||||
|
||||
- compile runtime-derived agent and internal catalogs, plus host-owned operator catalogs
|
||||
- replace existing plugin-identity-driven catalog surfaces with canonical family-based catalogs
|
||||
|
||||
Primary doc:
|
||||
|
||||
- `openclaw-capability-catalog-and-arbitration-spec.md`
|
||||
|
||||
Deliverables:
|
||||
|
||||
- kernel internal catalog
|
||||
- kernel agent catalog
|
||||
- host operator catalog
|
||||
- static setup and install catalogs
|
||||
- canonical action registry
|
||||
- migration plan for existing tool, provider, and setup catalog surfaces
|
||||
|
||||
Exit criteria:
|
||||
|
||||
- agent-visible tools are compiled from canonical action families instead of plugin identity
|
||||
- setup and install catalogs no longer depend on duplicated legacy metadata paths
|
||||
|
||||
### Phase 6: Implement arbitration
|
||||
|
||||
Goal:
|
||||
|
||||
- resolve overlap, ranking, selection, and slot conflicts deterministically
|
||||
- absorb the existing slot and provider selection behavior into canonical arbitration
|
||||
|
||||
Primary doc:
|
||||
|
||||
- `openclaw-capability-catalog-and-arbitration-spec.md`
|
||||
|
||||
Deliverables:
|
||||
|
||||
- conflict detection
|
||||
- provider selection
|
||||
- slot arbitration
|
||||
- planner-visible name collision handling
|
||||
- migration plan for existing slot and name-collision behaviors
|
||||
|
||||
Exit criteria:
|
||||
|
||||
- at least one multi-provider family works through canonical arbitration
|
||||
- legacy slot and provider-selection paths no longer act as separate arbitration systems
|
||||
|
||||
### Phase 7: Migrate and remove legacy paths
|
||||
|
||||
Goal:
|
||||
|
||||
- finish migration and shrink compatibility-only surfaces
|
||||
|
||||
Primary docs:
|
||||
|
||||
- `openclaw-kernel-extension-host-transition-plan.md`
|
||||
- all other docs as parity references
|
||||
|
||||
Deliverables:
|
||||
|
||||
- channel migrations
|
||||
- non-channel extension migrations
|
||||
- parity tests
|
||||
- deprecation markers
|
||||
- removal plan for obsolete compatibility shims
|
||||
|
||||
Exit criteria:
|
||||
|
||||
- legacy plugin runtime is no longer the default execution path
|
||||
|
||||
## Pilot Matrix
|
||||
|
||||
Initial pilot set:
|
||||
|
||||
- non-channel pilot: `thread-ownership`
|
||||
- channel pilot: `telegram`
|
||||
|
||||
Why these pilots:
|
||||
|
||||
- `thread-ownership` exercises typed hook loading without introducing CLI, HTTP route, or service migration at the same time
|
||||
- `telegram` exercises the `ChannelPlugin` compatibility path with a minimal top-level plugin registration surface
|
||||
|
||||
Second-wave compatibility candidates after the pilots are stable:
|
||||
|
||||
- `line` for channel plus command registration
|
||||
- `device-pair` for command, service, and setup flow coverage
|
||||
|
||||
Each pilot must record parity for:
|
||||
|
||||
- discovery and precedence
|
||||
- manifest and static metadata loading
|
||||
- config schema and UI hints
|
||||
- enabled and disabled state handling
|
||||
- activation and reload behavior
|
||||
- diagnostics and status output
|
||||
- runtime behavior on the migrated path
|
||||
- compatibility-only gaps that still remain
|
||||
|
||||
## Recommended First Implementation Slice
|
||||
|
||||
If you want the lowest-risk start, do this first:
|
||||
|
||||
1. write the boundary cutover inventory
|
||||
2. add source-of-truth types
|
||||
3. add the static metadata and package metadata parsers
|
||||
4. add `ResolvedExtension`
|
||||
5. add minimal SDK compatibility loading
|
||||
6. add host discovery and validation
|
||||
7. bring `thread-ownership` through the host path first
|
||||
8. bring `telegram` through the host path second
|
||||
|
||||
Status of this slice:
|
||||
|
||||
- steps 2 through 6 are underway
|
||||
- step 1 has landed as `src/extension-host/cutover-inventory.md`
|
||||
- steps 7 and 8 have not started
|
||||
|
||||
Concrete landings from this slice:
|
||||
|
||||
- the host boundary exists
|
||||
- source-of-truth schema types exist
|
||||
- package metadata parsing now routes through the host schema layer
|
||||
- `ResolvedExtension` exists in code and is attached to manifest-registry records
|
||||
- host-owned active-registry and resolved-registry views exist
|
||||
- early static consumers have moved onto the new host-owned data path
|
||||
|
||||
Do not start with catalogs or arbitration first.
|
||||
|
||||
Also avoid these first-cut traps:
|
||||
|
||||
- do not build a broad event scheduling framework before the canonical stages exist
|
||||
- do not turn permission descriptors into fake sandbox guarantees
|
||||
- do not build a large operator catalog publication layer before the host registries are real
|
||||
- do not over-type setup flows before the pilot migrations prove the minimum result model is insufficient
|
||||
|
||||
## Tracking Rules
|
||||
|
||||
When implementation begins:
|
||||
|
||||
- update this guide first with phase status
|
||||
- update the matching spec TODOs when a domain changes
|
||||
- record where the implementation intentionally diverged from the spec
|
||||
- record which behaviors are full parity, partial parity, or compatibility-only
|
||||
- update the pilot parity matrix whenever a migrated surface changes
|
||||
|
||||
## Suggested Status Format
|
||||
|
||||
Use this format in each doc when work starts:
|
||||
|
||||
- `not started`
|
||||
- `in progress`
|
||||
- `implemented`
|
||||
- `verified`
|
||||
- `deferred`
|
||||
|
||||
For example:
|
||||
|
||||
- `ResolvedExtension` registry: `implemented`
|
||||
- setup fallback removal: `deferred`
|
||||
- sync transcript-write parity tests: `in progress`
|
||||
@@ -1,757 +0,0 @@
|
||||
Temporary internal migration note: remove this document once the extension-host migration is complete.
|
||||
|
||||
# OpenClaw Extension Host Lifecycle And Security Spec
|
||||
|
||||
Date: 2026-03-15
|
||||
|
||||
## Purpose
|
||||
|
||||
This document defines how the extension host discovers, validates, activates, isolates, and stops extensions while applying operator policy, permission metadata, persistence boundaries, and contribution dependencies.
|
||||
|
||||
The kernel does not participate in these concerns directly.
|
||||
|
||||
## TODOs
|
||||
|
||||
- [x] Write the initial boundary cutover inventory for every current plugin-owned surface.
|
||||
- [ ] Keep the boundary cutover inventory updated as surfaces move.
|
||||
- [ ] Extend the loader lifecycle state machine into full extension-host lifecycle ownership and document the concrete runtime states in code.
|
||||
- [ ] Implement advisory versus enforced permission handling exactly as specified here.
|
||||
- [ ] Implement host-owned registries for config, setup, CLI, routes, services, slots, and backends.
|
||||
- [ ] Implement per-extension state ownership and migration from current shared plugin state.
|
||||
- [ ] Record pilot parity for `thread-ownership` first and `telegram` second before broad legacy rollout.
|
||||
- [ ] Track which hardening, reload, and provenance rules have reached parity with `main`.
|
||||
|
||||
## Implementation Status
|
||||
|
||||
Current status against this spec:
|
||||
|
||||
- registry ownership and the first compatibility-preserving loader slices have landed
|
||||
- a loader-scoped lifecycle state machine has landed
|
||||
- broader lifecycle orchestration, policy gates, and activation-state management beyond the current loader, service, and CLI seams have not landed
|
||||
|
||||
What has been implemented:
|
||||
|
||||
- an initial Phase 0 cutover inventory now exists in `src/extension-host/cutover-inventory.md`
|
||||
- active registry ownership now lives in the extension host boundary rather than only in plugin-era runtime state
|
||||
- central lookup surfaces now consume the host-owned active registry
|
||||
- registry activation now routes through `src/extension-host/activation.ts`
|
||||
- a host-owned resolved-extension registry exists for static consumers
|
||||
- static config-baseline generation now reads bundled extension metadata through the host-owned resolved-extension registry
|
||||
- channel, provider, HTTP-route, gateway-method, tool, CLI, service, command, context-engine, and hook registration normalization now delegates through `src/extension-host/contributions/runtime-registrations.ts`
|
||||
- low-risk runtime compatibility writes for channel, provider, gateway-method, HTTP-route, tool, CLI, service, command, context-engine, and hook registrations now delegate through `src/extension-host/contributions/registry-writes.ts`
|
||||
- context-engine registration and runtime resolution now delegate through `src/extension-host/contributions/context-engine-runtime.ts` while `src/context-engine/registry.ts` remains the compatibility facade
|
||||
- exclusive-slot selection and default-slot resolution now delegate through `src/extension-host/policy/slot-arbitration.ts` while `src/plugins/slots.ts` remains the compatibility facade
|
||||
- ACP backend registration and runtime resolution now delegate through `src/extension-host/contributions/acp-runtime-backend-registry.ts` while `src/acp/runtime/registry.ts` remains the compatibility facade
|
||||
- embedding-provider auto-selection, provider creation, local-setup guidance, and primary plus fallback routing now delegate through `src/extension-host/contributions/embedding-runtime-registry.ts`, shared fallback-model selection now delegates through `src/extension-host/policy/embedding-runtime-policy.ts`, the public embedding runtime surface plus result typing now delegate through `src/extension-host/contributions/embedding-runtime.ts` and `src/extension-host/contributions/embedding-runtime-types.ts`, manager-side batch policy plus fallback activation now delegate through `src/extension-host/contributions/embedding-manager-runtime.ts`, sync plus reindex planning now delegate through `src/extension-host/contributions/embedding-sync-planning.ts`, sync plus reindex orchestration now delegate through `src/extension-host/contributions/embedding-sync-execution.ts`, reindex sync-body execution plus unsafe reset now delegate through `src/extension-host/contributions/embedding-reindex-execution.ts`, and safe-reindex temp-db creation, file swap, reopen, and cleanup now delegate through `src/extension-host/contributions/embedding-safe-reindex.ts` while `src/memory/embeddings.ts` remains the compatibility facade
|
||||
- built-in media backend definitions, provider normalization, auto-selection seed order, selector-key shaping, and default-model metadata now delegate through `src/extension-host/static/media-runtime-backends.ts`; override merging and runtime lookup now delegate through `src/extension-host/contributions/media-runtime-registry.ts`; shared default and preferred runtime-backend ordering plus fallback chaining now delegate through `src/extension-host/policy/runtime-backend-policy.ts`; provider candidate ordering, active-model precedence, and default-model fallback selection now delegate through `src/extension-host/policy/media-runtime-policy.ts`; provider and CLI entry execution, output parsing, provider query normalization, provider auth/context shaping, and proxy-aware fetch handling now delegate through `src/extension-host/contributions/media-runtime-execution.ts`; local-binary probing, auto-entry selection, and top-level capability orchestration now delegate through `src/extension-host/contributions/media-runtime-auto.ts` and `src/extension-host/contributions/media-runtime-orchestration.ts`; and media prompt, timeout, scope, model-entry, concurrency, and decision helpers now delegate through `src/extension-host/contributions/media-runtime-config.ts` and `src/extension-host/contributions/media-runtime-decision.ts` while `src/media-understanding/providers/index.ts`, `src/media-understanding/runner.ts`, `src/media-understanding/runner.entries.ts`, and `src/media-understanding/resolve.ts` remain the compatibility facades
|
||||
- TTS provider metadata, provider ordering, API-key resolution, configuration checks, and telephony support now delegate through `src/extension-host/contributions/tts-runtime-registry.ts`, provider execution loops, output-format selection, telephony synthesis, and provider-error shaping now delegate through `src/extension-host/contributions/tts-runtime-execution.ts`, provider selection plus request setup now delegate through `src/extension-host/contributions/tts-runtime-setup.ts`, TTS config normalization, defaults, and model-override policy now delegate through `src/extension-host/contributions/tts-config.ts`, prefs-path resolution, auto-mode policy, and persisted TTS preference reads and writes now delegate through `src/extension-host/contributions/tts-preferences.ts`, auto-TTS gating, directive cleanup, truncation, summarization, and payload planning now delegate through `src/extension-host/contributions/tts-payload.ts`, and last-attempt state plus shared status snapshots now delegate through `src/extension-host/contributions/tts-status.ts` while `src/tts/tts.ts` remains the compatibility facade
|
||||
- legacy internal-hook bridging and typed prompt-injection compatibility policy now delegate through `src/extension-host/compat/hook-compat.ts`
|
||||
- compatibility `OpenClawPluginApi` composition and logger shaping now delegate through `src/extension-host/compat/plugin-api.ts`
|
||||
- compatibility plugin-registry facade ownership now delegates through `src/extension-host/compat/plugin-registry.ts`
|
||||
- compatibility plugin-registry policy now delegates through `src/extension-host/compat/plugin-registry-compat.ts`
|
||||
- compatibility plugin-registry registration actions now delegate through `src/extension-host/compat/plugin-registry-registrations.ts`
|
||||
- host-owned runtime registry accessors now delegate through `src/extension-host/contributions/runtime-registry.ts`, and the channel, provider, tool, command, HTTP-route, gateway-method, CLI, and service slices now keep host-owned storage there with mirrored legacy compatibility views
|
||||
- plugin command registration, matching, execution, listing, native command-spec projection, and loader reload clearing now delegate through `src/extension-host/contributions/command-runtime.ts`
|
||||
- service startup, stop ordering, service-context creation, and failure logging now delegate through `src/extension-host/contributions/service-lifecycle.ts`
|
||||
- CLI duplicate detection, registrar invocation, and async failure logging now delegate through `src/extension-host/contributions/cli-lifecycle.ts`
|
||||
- gateway method-id aggregation, plugin diagnostic shaping, and extra-handler composition now delegate through `src/extension-host/contributions/gateway-methods.ts`
|
||||
- plugin tool resolution, conflict handling, optional-tool gating, and plugin-tool metadata tracking now delegate through `src/extension-host/contributions/tool-runtime.ts`
|
||||
- plugin provider projection from registry entries into runtime provider objects now delegates through `src/extension-host/contributions/provider-runtime.ts`
|
||||
- plugin provider discovery filtering, order grouping, and result normalization now delegate through `src/extension-host/contributions/provider-discovery.ts`
|
||||
- provider matching, auth-method selection, config-patch merging, and default-model application now delegate through `src/extension-host/contributions/provider-auth.ts`
|
||||
- provider onboarding option building, model-picker entry building, and provider-method choice resolution now delegate through `src/extension-host/contributions/provider-wizard.ts`
|
||||
- loaded-provider auth application, plugin-enable gating, auth-method execution, and post-auth default-model handling now delegate through `src/extension-host/contributions/provider-auth-flow.ts`
|
||||
- provider post-selection hook lookup and invocation now delegate through `src/extension-host/contributions/provider-model-selection.ts`
|
||||
- loader alias-wired module loader creation now routes through `src/extension-host/activation/loader-module-loader.ts`
|
||||
- loader cache key construction and registry cache control now route through `src/extension-host/activation/loader-cache.ts`
|
||||
- loader lazy runtime proxy creation now routes through `src/extension-host/activation/loader-runtime-proxy.ts`
|
||||
- loader provenance helpers now route through `src/extension-host/policy/loader-provenance.ts`
|
||||
- loader duplicate-order and record/error policy now route through `src/extension-host/policy/loader-policy.ts`
|
||||
- loader discovery policy outcomes now route through `src/extension-host/policy/loader-discovery-policy.ts`
|
||||
- loader initial candidate planning and record creation now route through `src/extension-host/activation/loader-records.ts`
|
||||
- loader entry-path opening and module import now route through `src/extension-host/activation/loader-import.ts`
|
||||
- loader module-export resolution, config validation, and memory-slot load decisions now route through `src/extension-host/activation/loader-runtime.ts`
|
||||
- loader post-import planning and `register(...)` execution now route through `src/extension-host/activation/loader-register.ts`
|
||||
- loader per-candidate orchestration now routes through `src/extension-host/activation/loader-flow.ts`
|
||||
- loader top-level load orchestration now routes through `src/extension-host/activation/loader-orchestrator.ts`
|
||||
- loader host process state now routes through `src/extension-host/activation/loader-host-state.ts`
|
||||
- loader preflight and cache-hit setup now routes through `src/extension-host/activation/loader-preflight.ts`
|
||||
- loader post-preflight pipeline composition now routes through `src/extension-host/activation/loader-pipeline.ts`
|
||||
- loader execution setup composition now routes through `src/extension-host/activation/loader-execution.ts`
|
||||
- loader discovery and manifest bootstrap now routes through `src/extension-host/activation/loader-bootstrap.ts`
|
||||
- loader mutable activation state now routes through `src/extension-host/activation/loader-session.ts`
|
||||
- loader session run and finalization composition now routes through `src/extension-host/activation/loader-run.ts`
|
||||
- loader activation policy outcomes now route through `src/extension-host/policy/loader-activation-policy.ts`
|
||||
- loader record-state transitions now route through `src/extension-host/activation/loader-state.ts`, which now enforces an explicit loader lifecycle state machine while preserving compatibility `PluginRecord.status` values
|
||||
- loader finalization policy results now route through `src/extension-host/policy/loader-finalization-policy.ts`
|
||||
- loader final cache, readiness promotion, and activation finalization now routes through `src/extension-host/activation/loader-finalize.ts`
|
||||
|
||||
How it has been implemented:
|
||||
|
||||
- by extracting `src/extension-host/static/active-registry.ts` and making `src/plugins/runtime.ts` delegate to it
|
||||
- by leaving lifecycle behavior unchanged for now and only moving ownership of the shared registry boundary
|
||||
- by moving low-risk readers first, such as channel lookup, dock lookup, message-channel lookup, and default HTTP route registry access
|
||||
- by extending that same host-owned boundary into static consumers instead of introducing separate one-off metadata loaders
|
||||
- by starting runtime-registry migration with low-risk validation and normalization helpers while leaving lifecycle ordering and activation behavior unchanged
|
||||
- by starting actual low-risk runtime write ownership for channel, provider, gateway-method, HTTP-route, tool, CLI, service, command, context-engine, and hook registrations only after normalization helpers existed, while leaving lifecycle ordering and activation behavior unchanged
|
||||
- by leaving start/stop ordering and duplicate-enforcement behavior in legacy subsystems where those subsystems are still the real owner
|
||||
- by treating hook execution and hook registration as separate migration concerns so event-pipeline work does not get conflated with record normalization
|
||||
- by starting loader/lifecycle migration with activation and SDK alias compatibility helpers while leaving discovery and policy flow unchanged
|
||||
- by moving cache-key construction, cache reads, cache writes, and cache clearing next while leaving activation-state ownership unchanged
|
||||
- by moving provenance and duplicate-order policy next, so lifecycle migration can land on host-owned policy helpers instead of loader-local utilities
|
||||
- by extracting lazy runtime proxy creation and alias-wired Jiti module-loader creation into host-owned helpers before broader bootstrap or lifecycle ownership changes
|
||||
- by extracting discovery, manifest loading, manifest diagnostics, discovery-policy logging, provenance building, and candidate ordering into a host-owned loader-bootstrap helper before broader lifecycle ownership changes
|
||||
- by extracting candidate iteration, manifest lookup, per-candidate session processing, and finalization handoff into a host-owned loader-run helper before broader lifecycle ownership changes
|
||||
- by moving initial candidate planning and record construction next while leaving module import and registration flow unchanged
|
||||
- by moving entry-path opening and module import next while leaving cache wiring and lifecycle orchestration unchanged
|
||||
- by moving loader runtime decisions next while preserving the current lazy-load, config-validation, and memory-slot behavior
|
||||
- by moving post-import planning and `register(...)` execution next while leaving entry-path and import flow unchanged
|
||||
- by composing those seams into one host-owned per-candidate loader orchestrator before moving final lifecycle-state behavior
|
||||
- by moving the remaining top-level loader orchestration into a host-owned module before enforcing the loader lifecycle state machine
|
||||
- by extracting shared discovery warning-cache state and loader reset behavior into a host-owned loader-host-state helper before shrinking the remaining orchestrator surface
|
||||
- by extracting test-default application, config normalization, cache-key construction, cache-hit activation, and command-clear setup into a host-owned loader-preflight helper before shrinking the remaining orchestrator surface
|
||||
- by extracting post-preflight execution setup and session-run composition into a host-owned loader-pipeline helper before shrinking the remaining orchestrator surface
|
||||
- by extracting runtime creation, registry creation, bootstrap setup, module-loader creation, and session creation into a host-owned loader-execution helper before shrinking the remaining orchestrator surface
|
||||
- by moving record-state transitions first into a compatibility layer and then into an enforced loader lifecycle state machine
|
||||
- by moving cache writes, provenance warnings, final memory-slot warnings, and activation into a host-owned loader finalizer before introducing an explicit lifecycle state machine
|
||||
- by adding explicit compatibility `lifecycleState` mapping on loader-owned plugin records before enforcing the loader lifecycle state machine
|
||||
- by promoting successfully registered plugins to `ready` during host-owned finalization while leaving broader activation-state semantics for later phases
|
||||
- by moving mutable activation state such as seen-id tracking, memory-slot selection, and finalization inputs into a host-owned loader session before broader activation-state semantics move
|
||||
- by extracting shared provenance path matching and install-rule evaluation into `src/extension-host/policy/loader-provenance.ts` so activation and finalization policy seams reuse one host-owned implementation
|
||||
- by turning open-allowlist discovery warnings into explicit host-owned discovery-policy results before the orchestrator logs them
|
||||
- by moving duplicate precedence, config enablement, and early memory-slot gating into explicit host-owned activation-policy outcomes before broader policy semantics move
|
||||
- by turning provenance-based untracked-extension warnings and final memory-slot warnings into explicit host-owned finalization-policy results before the finalizer applies them
|
||||
- by extracting legacy internal-hook bridging and typed prompt-injection compatibility policy into a host-owned hook-compat helper while leaving actual hook execution ownership unchanged
|
||||
- by extracting compatibility `OpenClawPluginApi` composition and logger shaping into a host-owned plugin-api helper while keeping the concrete registration callbacks in the legacy registry surface
|
||||
- by extracting the remaining compatibility plugin-registry facade into a host-owned helper so `src/plugins/registry.ts` becomes a thin wrapper instead of the real owner
|
||||
- by extracting provider normalization, command duplicate enforcement, and registry-local diagnostic shaping into a host-owned registry-compat helper while leaving the underlying provider-validation and plugin-command subsystems unchanged
|
||||
- by extracting low-risk registry registration actions into a host-owned registry-registrations helper so the compatibility facade composes host-owned actions instead of implementing them inline
|
||||
- by extracting service startup, stop ordering, service-context creation, and failure logging into a host-owned service-lifecycle helper while `src/plugins/services.ts` remains the compatibility entry point
|
||||
- by extracting CLI duplicate detection, registrar invocation, and async failure logging into a host-owned CLI-lifecycle helper while `src/plugins/cli.ts` remains the compatibility entry point
|
||||
- by extracting gateway method-id aggregation, plugin diagnostic shaping, and extra-handler composition into a host-owned gateway-methods helper while request dispatch semantics remain in the gateway server code
|
||||
- by extracting plugin tool resolution, conflict handling, optional-tool gating, and plugin-tool metadata tracking into a host-owned tool-runtime helper while `src/plugins/tools.ts` remains the loader and config-normalization facade
|
||||
- by extracting provider projection from registry entries into runtime provider objects into a host-owned provider-runtime helper while `src/plugins/providers.ts` remains the loader and config-normalization facade
|
||||
- by extracting provider discovery filtering, order grouping, and result normalization into a host-owned provider-discovery helper while `src/plugins/provider-discovery.ts` remains the compatibility facade around the legacy provider loader path
|
||||
- by extracting provider matching, auth-method selection, config-patch merging, and default-model application into a host-owned provider-auth helper while `src/commands/provider-auth-helpers.ts` remains the command-facing compatibility facade
|
||||
- by extracting provider onboarding option building, model-picker entry building, and provider-method choice resolution into a host-owned provider-wizard helper while `src/plugins/provider-wizard.ts` remains the compatibility facade around loader-backed provider access and post-selection hooks
|
||||
- by extracting loaded-provider auth application, plugin-enable gating, auth-method execution, and post-auth default-model handling into a host-owned provider-auth-flow helper while `src/commands/auth-choice.apply.plugin-provider.ts` remains the compatibility entry point
|
||||
- by extracting provider post-selection hook lookup and invocation into a host-owned provider-model-selection helper while `src/plugins/provider-wizard.ts` remains the compatibility facade and existing command consumers continue migrating onto the host-owned surface
|
||||
- by extracting provider-id normalization into `src/agents/provider-id.ts` so provider-only host seams do not inherit the heavier agent and browser dependency graph from `src/agents/model-selection.ts`
|
||||
- by extracting model-ref parsing into `src/agents/model-ref.ts` and Google model-id normalization into `src/agents/google-model-id.ts` so provider auth and setup seams can be tested without pulling the heavier provider-loader and browser dependency graph
|
||||
- by introducing host-owned runtime-registry accessors for channel, provider, tool, service, CLI, command, gateway-method, and HTTP-route consumers first, then moving channel, provider, tool, command, HTTP-route, gateway-method, CLI, and service storage into that host-owned state while keeping mirrored legacy compatibility arrays and handler maps
|
||||
- by moving plugin command duplicate enforcement, registration, matching, execution, listing, native command-spec projection, and loader reload clearing into `src/extension-host/contributions/command-runtime.ts` while keeping the legacy command module as a compatibility facade
|
||||
- by tightening the CLI pre-load fast path to treat any host-known runtime entry surface as already loaded rather than only plugins, channels, or tools
|
||||
- by moving the remaining TTS API composition, system-prompt hint assembly, request validation, telephony setup, auto-TTS payload application, and failed-conversion fallback handling into `src/extension-host/contributions/tts-api.ts` while keeping `src/tts/tts.ts` as the compatibility facade
|
||||
|
||||
What is still pending from this spec:
|
||||
|
||||
- broader extension-host lifecycle ownership beyond the loader state machine, service-lifecycle boundary, CLI-lifecycle boundary, session-owned activation state, and explicit discovery-policy, activation-policy, and finalization-policy outcomes
|
||||
- activation pipeline ownership
|
||||
- host-owned registries for setup, CLI, routes, services, slots, and backends
|
||||
- broader consumer adoption and arbitration on top of the now-started catalog-backed runtime-family descriptors for embeddings, media, and TTS, including explicit fallback and override policy instead of plugin-era capability reads; embeddings, media, and TTS now all have host-owned policy seams on top of that arbitration layer
|
||||
- a clear host-owned split for extension-backed search between agent-visible tool publication and any optional runtime-internal search backend registry
|
||||
- permission-mode enforcement
|
||||
- per-extension state ownership and migration
|
||||
- provenance, reload, and hardening parity tracking
|
||||
|
||||
## Goals
|
||||
|
||||
- deterministic activation and shutdown
|
||||
- explicit failure states
|
||||
- no hidden privilege escalation
|
||||
- stable persistence ownership rules
|
||||
- truthful security semantics for the current trusted in-process model
|
||||
- safe support for bundled and external extensions under the same model
|
||||
- preserve existing hardening and prompt-mutation policy behavior during the migration
|
||||
|
||||
## Implementation Sequencing Constraints
|
||||
|
||||
This spec is not a greenfield host design.
|
||||
|
||||
The host must absorb existing behavior that already lives in:
|
||||
|
||||
- plugin discovery and manifest loading
|
||||
- config schema and UI hint handling
|
||||
- route and gateway registration
|
||||
- channels and channel lookup
|
||||
- providers and provider auth or setup flows
|
||||
- tools, commands, and CLI registration
|
||||
- services, backends, and slot-backed providers
|
||||
- reload, diagnostics, install, update, and status behavior
|
||||
|
||||
Therefore:
|
||||
|
||||
- Phase 0 must produce a cutover inventory for those surfaces before registry ownership changes begin
|
||||
- Phase 1 must preserve current SDK loading through minimal compatibility support
|
||||
- Phase 2 registry work must be broad enough to cover all currently registered surfaces, not only a narrow runtime subset
|
||||
- Phase 3 must prove parity through `thread-ownership` first and `telegram` second before broader rollout
|
||||
|
||||
## Trust Model Reality
|
||||
|
||||
Current `main` treats installed and enabled extensions as trusted code running in-process:
|
||||
|
||||
- trusted plugin concept in `SECURITY.md:108`
|
||||
- in-process loading in `src/plugins/loader.ts:621`
|
||||
|
||||
That means the initial extension host has two separate jobs:
|
||||
|
||||
- enforce operator policy for activation, route exposure, host-owned registries, and auditing
|
||||
- accurately communicate that this is not yet a hard sandbox against arbitrary extension code
|
||||
|
||||
Recommended enforcement levels:
|
||||
|
||||
- `advisory`
|
||||
Host policy, audit, and compatibility guidance only. This is the current default. Permission mismatch alone should not block activation in this mode, though the host may warn and withhold optional host-published surfaces.
|
||||
- `host-enforced`
|
||||
Host-owned capabilities and registries are gated, but extension code still runs in-process.
|
||||
- `sandbox-enforced`
|
||||
A future mode with real process, VM, or IPC isolation where permissions become a true security boundary.
|
||||
|
||||
## Lifecycle States
|
||||
|
||||
Every extension instance moves through these states:
|
||||
|
||||
1. `discovered`
|
||||
2. `manifest-loaded`
|
||||
3. `validated`
|
||||
4. `dependency-resolved`
|
||||
5. `policy-approved`
|
||||
6. `instantiated`
|
||||
7. `registered`
|
||||
8. `starting`
|
||||
9. `ready`
|
||||
10. `degraded`
|
||||
11. `stopping`
|
||||
12. `stopped`
|
||||
13. `failed`
|
||||
|
||||
The host owns the state machine.
|
||||
|
||||
## Activation Pipeline
|
||||
|
||||
### 1. Discovery
|
||||
|
||||
The host scans:
|
||||
|
||||
- bundled extension inventory
|
||||
- configured external extension paths or packages
|
||||
- disabled extension state
|
||||
|
||||
Discovery is metadata-only. No extension code executes in this phase.
|
||||
|
||||
### 2. Manifest Load
|
||||
|
||||
The host loads and validates manifest syntax.
|
||||
|
||||
Failures here prevent instantiation.
|
||||
|
||||
This phase must cover both:
|
||||
|
||||
- runtime contribution descriptors
|
||||
- package-level static metadata used for install, onboarding, status, and lightweight operator UX
|
||||
|
||||
### 3. Schema Validation
|
||||
|
||||
The host validates:
|
||||
|
||||
- top-level extension manifest
|
||||
- contribution descriptors
|
||||
- config schema
|
||||
- config UI hints and sensitivity metadata
|
||||
- permission declarations
|
||||
- dependency declarations
|
||||
- policy declarations such as prompt-mutation behavior
|
||||
|
||||
### 4. Dependency Resolution
|
||||
|
||||
The host resolves:
|
||||
|
||||
- extension api compatibility
|
||||
- SDK compatibility mode and deprecation requirements
|
||||
- required contribution dependencies
|
||||
- optional dependencies
|
||||
- conflict declarations
|
||||
- singleton slot collisions
|
||||
|
||||
Compatibility decision:
|
||||
|
||||
- the host should support only a short compatibility window, ideally one or two older SDK contract versions at a time
|
||||
- extensions outside that window must fail validation with a clear remediation path
|
||||
|
||||
Sequencing rule:
|
||||
|
||||
- minimal compatibility loading must exist before broader schema or registry changes depend on the new manifest model
|
||||
|
||||
### 5. Policy Gate
|
||||
|
||||
The host computes the requested permission set and compares it against operator policy.
|
||||
|
||||
In `host-enforced` or `sandbox-enforced` mode, extensions that are not allowed to receive all required permissions do not activate or do not register the gated contributions.
|
||||
|
||||
In `advisory` mode, this gate records warnings, informs operator-visible policy state, and may withhold optional host-published surfaces, but permission mismatch alone does not fail activation.
|
||||
|
||||
It does not sandbox arbitrary filesystem, network, or child-process access from trusted in-process extension code.
|
||||
|
||||
### 6. Instantiation
|
||||
|
||||
The host loads the extension entrypoint and asks it to emit contribution descriptors and runtime factories.
|
||||
|
||||
Unless the host is running in a future isolated mode, instantiation still executes trusted extension code inside the OpenClaw process.
|
||||
|
||||
### 7. Registration
|
||||
|
||||
The host resolves runtime ids, arbitration metadata, and activation order, then registers contributions into host-owned registries.
|
||||
|
||||
This includes host-managed operator registries for:
|
||||
|
||||
- CLI commands
|
||||
- setup and onboarding flows
|
||||
- config and status surfaces
|
||||
- dynamic HTTP routes
|
||||
- config reload descriptors and gateway feature advertisement where those surfaces remain host-managed during migration
|
||||
|
||||
Callable gateway or runtime methods are separate from this advertisement layer and should continue to register through the runtime contribution model as `capability.rpc`.
|
||||
|
||||
The registration boundary should cover the full current surface area as one migration set:
|
||||
|
||||
- hooks and event handlers
|
||||
- channels and lightweight channel descriptors
|
||||
- providers and provider-setup surfaces
|
||||
- tools and control commands
|
||||
- CLI, setup, config, and status surfaces
|
||||
- HTTP routes and gateway methods
|
||||
- services, runtime backends, and slot-backed providers
|
||||
|
||||
Do not migrate only a subset and leave the rest writing into the legacy registry model indefinitely.
|
||||
|
||||
### 8. Start
|
||||
|
||||
The host starts host-managed services, assigns per-extension state and route ownership, and activates kernel-facing contributions.
|
||||
|
||||
### 9. Ready
|
||||
|
||||
The extension is active and visible to kernel or operator surfaces as appropriate.
|
||||
|
||||
## Failure Modes
|
||||
|
||||
Supported failure classes:
|
||||
|
||||
- `manifest-invalid`
|
||||
- `api-version-unsupported`
|
||||
- `dependency-missing`
|
||||
- `dependency-conflict`
|
||||
- `policy-denied`
|
||||
- `instantiation-failed`
|
||||
- `registration-conflict`
|
||||
- `startup-failed`
|
||||
- `runtime-degraded`
|
||||
|
||||
The host must record failure class, extension id, contribution ids, and operator-visible remediation.
|
||||
|
||||
## Dependency Rules
|
||||
|
||||
Dependencies must be explicit and machine-checkable.
|
||||
|
||||
### Extension-level dependencies
|
||||
|
||||
Used when one extension package requires another package to be present.
|
||||
|
||||
### Contribution-level dependencies
|
||||
|
||||
Used when a specific runtime contract depends on another contribution.
|
||||
|
||||
Examples:
|
||||
|
||||
- a route augmenter may require a specific adapter family
|
||||
- an auth helper may require a provider contribution
|
||||
- a diagnostics extension may optionally bind to a runtime backend if present
|
||||
|
||||
### Conflict rules
|
||||
|
||||
Extensions may declare:
|
||||
|
||||
- `conflicts`
|
||||
- `supersedes`
|
||||
- `replaces`
|
||||
|
||||
The host resolves these before activation.
|
||||
|
||||
## Discovery And Load Hardening
|
||||
|
||||
The extension host must preserve current path-safety, provenance, and duplicate-resolution protections.
|
||||
|
||||
At minimum, preserve parity with:
|
||||
|
||||
- path and boundary checks during load in `src/plugins/loader.ts:744`
|
||||
- manifest precedence and duplicate-origin handling in `src/plugins/manifest-registry.ts:15`
|
||||
- provenance warnings during activation in `src/plugins/loader.ts:500`
|
||||
|
||||
Security hardening from the current loader is part of the host contract, not an optional implementation detail.
|
||||
|
||||
Parity requirement:
|
||||
|
||||
- the pilot migrations must show that these hardening rules still apply on the host path, not only on the legacy path
|
||||
|
||||
## Policy And Permission Model
|
||||
|
||||
Permissions are granted to extension instances by the host as policy metadata and host capability grants.
|
||||
|
||||
The kernel must never infer privilege from contribution kind alone.
|
||||
|
||||
The host must track both:
|
||||
|
||||
- requested permissions
|
||||
- enforcement level (`advisory`, `host-enforced`, or `sandbox-enforced`)
|
||||
- host-managed policy gates such as prompt mutation and sync hot-path eligibility
|
||||
|
||||
### Recommended permission set
|
||||
|
||||
- `runtime.adapter`
|
||||
- `runtime.route-augment`
|
||||
- `runtime.veto-send`
|
||||
- `runtime.backend-register`
|
||||
- `agent.tool.expose`
|
||||
- `control.command.expose`
|
||||
- `interaction.handle`
|
||||
- `conversation.bind`
|
||||
- `conversation.bind.approve`
|
||||
- `conversation.control`
|
||||
- `rpc.expose`
|
||||
- `service.background`
|
||||
- `http.route.gateway`
|
||||
- `http.route.plugin`
|
||||
- `config.read`
|
||||
- `config.write`
|
||||
- `state.read`
|
||||
- `state.write`
|
||||
- `credentials.read`
|
||||
- `credentials.write`
|
||||
- `network.outbound`
|
||||
- `process.spawn`
|
||||
- `filesystem.workspace.read`
|
||||
- `filesystem.workspace.write`
|
||||
|
||||
Permissions should be independently reviewable and denyable.
|
||||
|
||||
In `advisory` mode they also function as:
|
||||
|
||||
- operator review prompts
|
||||
- activation policy inputs
|
||||
- audit and telemetry tags
|
||||
- documentation of why an extension needs sensitive host-owned surfaces
|
||||
|
||||
### Fine-grained policy gates
|
||||
|
||||
Some behavior should remain under dedicated policy gates instead of being flattened into generic permissions.
|
||||
|
||||
Examples:
|
||||
|
||||
- prompt mutation or prompt injection behavior
|
||||
- sync transcript-write participation
|
||||
- fail-open versus fail-closed route augmentation
|
||||
- whether an extension may bind conversations without per-request operator approval
|
||||
- whether an interaction handler may invoke conversation-control verbs
|
||||
|
||||
This preserves the intent of current controls such as `plugins.entries.<id>.hooks.allowPromptInjection`.
|
||||
|
||||
### High-risk permissions
|
||||
|
||||
These should require explicit operator approval or a strong default policy:
|
||||
|
||||
- `runtime.veto-send`
|
||||
- `runtime.route-augment`
|
||||
- `conversation.bind`
|
||||
- `conversation.bind.approve`
|
||||
- `runtime.backend-register`
|
||||
- `credentials.write`
|
||||
- `process.spawn`
|
||||
- `http.route.plugin`
|
||||
- `filesystem.workspace.write`
|
||||
|
||||
High-risk permissions should still matter in `advisory` mode because they drive operator trust decisions even before real isolation exists.
|
||||
|
||||
### Binding and interaction ownership
|
||||
|
||||
Conversation binding and interactive callback routing should be treated as host-owned lifecycle surfaces.
|
||||
|
||||
The host must own:
|
||||
|
||||
- namespace registration and dedupe for interactive callbacks
|
||||
- approval persistence for extension-requested conversation binds
|
||||
- restore-on-restart behavior for approved bindings
|
||||
- cleanup behavior for detached or stale bindings
|
||||
- channel-surface gating for first-cut conversation-control verbs
|
||||
|
||||
Extensions may own:
|
||||
|
||||
- the logic that decides whether to request a bind
|
||||
- the interaction payload semantics
|
||||
- channel-specific presentation details that fit inside the host-owned adapter contract
|
||||
|
||||
Important migration rule:
|
||||
|
||||
- do not turn `src/plugins/conversation-binding.ts` or `src/plugins/interactive.ts` into the permanent architecture target
|
||||
- those behaviors should migrate into host-owned lifecycle and policy surfaces, with compatibility bridges only where needed
|
||||
|
||||
## Persistence Ownership
|
||||
|
||||
Persistence must be partitioned by owner and intent.
|
||||
|
||||
### Config
|
||||
|
||||
Operator-managed configuration belongs to the host.
|
||||
|
||||
Extensions may contribute:
|
||||
|
||||
- config schema
|
||||
- config UI hints and sensitivity metadata
|
||||
- defaults
|
||||
- migration hints
|
||||
- setup flow outputs such as config patches produced through host-owned setup primitives
|
||||
|
||||
Extensions must not arbitrarily mutate unrelated config keys.
|
||||
|
||||
The host must also preserve current config redaction semantics:
|
||||
|
||||
- config UI hints such as `sensitive` affect host behavior, not only UI decoration
|
||||
- config read, redact, restore, and validate flows must preserve round-trippable secret handling comparable to `src/gateway/server-methods/config.ts:151` and `src/config/redact-snapshot.ts:349`
|
||||
|
||||
### State
|
||||
|
||||
Each extension gets a host-assigned state directory.
|
||||
|
||||
This is where background services and caches persist local state.
|
||||
|
||||
This is a required migration change from the current shared plugin service state shape in `src/plugins/services.ts:18`.
|
||||
|
||||
The host must also define a migration strategy for existing state:
|
||||
|
||||
- detect old shared plugin state layouts
|
||||
- migrate or alias data into per-extension directories
|
||||
- keep rollback behavior explicit
|
||||
|
||||
### Credentials
|
||||
|
||||
Credential persistence is host-owned.
|
||||
|
||||
Provider integration extensions may return credential payloads, but they must not choose final storage shape or bypass the credential store.
|
||||
|
||||
This is required because auth flows like `extensions/google-gemini-cli-auth/index.ts:24` interact with credentials and config together.
|
||||
|
||||
This rule also applies when those flows are invoked through extension-owned CLI or setup flows.
|
||||
|
||||
### Session and transcript state
|
||||
|
||||
Kernel-owned.
|
||||
|
||||
Extensions may observe or augment session state through declared runtime contracts, but they do not own transcript persistence.
|
||||
|
||||
### Backend-owned state
|
||||
|
||||
Runtime backends such as ACP may require separate service state, but ownership still flows through the host-assigned state boundary.
|
||||
|
||||
### Distribution and onboarding metadata
|
||||
|
||||
Install metadata, channel catalog metadata, docs links, and quickstart hints are host-owned static metadata.
|
||||
|
||||
They are not kernel persistence and they are not extension-private state.
|
||||
|
||||
That static metadata should preserve current channel catalog fields from `src/plugins/manifest.ts:121`, including aliases, docs labels, precedence hints, binding hints, picker extras, and announce-target hints.
|
||||
|
||||
## HTTP And Webhook Ownership
|
||||
|
||||
The host owns all HTTP route registration and conflict resolution.
|
||||
|
||||
This is required because routes can conflict across extensions today, as seen in `src/plugins/http-registry.ts:12`.
|
||||
|
||||
### Route classes
|
||||
|
||||
- ingress transport routes
|
||||
- authenticated plugin routes
|
||||
- public callback routes
|
||||
- diagnostic or admin routes
|
||||
- dynamic account-scoped routes
|
||||
|
||||
### Required route metadata
|
||||
|
||||
- path
|
||||
- auth mode
|
||||
- match mode
|
||||
- owner contribution id
|
||||
- whether the route is externally reachable
|
||||
- whether the route is safe to expose when the extension is disabled
|
||||
- lifecycle mode (`static` or `dynamic`)
|
||||
- scope metadata such as account, workspace, or provider binding
|
||||
|
||||
### Conflict rules
|
||||
|
||||
- exact path collisions require explicit resolution
|
||||
- prefix collisions require overlap analysis
|
||||
- auth mismatches are fatal
|
||||
- one extension may not replace another extension's route without explicit policy
|
||||
|
||||
Dynamic route registration must also return an unregister handle so route ownership can be cleaned up during reload, account removal, or degraded shutdown.
|
||||
|
||||
## Runtime Backend Contract
|
||||
|
||||
Some extension contributions provide runtime backends consumed by subsystems rather than directly by the agent.
|
||||
|
||||
ACP is the reference case today:
|
||||
|
||||
- backend type in `src/acp/runtime/registry.ts:4`
|
||||
- registration in `extensions/acpx/src/service.ts:55`
|
||||
|
||||
### Required backend descriptor
|
||||
|
||||
- backend class id
|
||||
- backend instance id
|
||||
- selector key
|
||||
- health probe
|
||||
- capability list
|
||||
- selection rank
|
||||
- arbitration mode
|
||||
|
||||
### Required backend lifecycle
|
||||
|
||||
- register
|
||||
- unregister
|
||||
- probe
|
||||
- health
|
||||
- degrade
|
||||
- recover
|
||||
|
||||
### Backend selection rules
|
||||
|
||||
- explicit requested backend id wins
|
||||
- if none requested, pick the healthiest backend with the best rank
|
||||
- if multiple healthy backends tie, use deterministic ordering by extension id then contribution id
|
||||
- if all backends are unhealthy, expose a typed unavailability error
|
||||
|
||||
### Singleton vs parallel
|
||||
|
||||
Not every backend is singleton.
|
||||
|
||||
ACP may remain effectively singleton at first, but the contract should support future parallel backends with explicit selectors.
|
||||
|
||||
## Slot-Backed Provider Contract
|
||||
|
||||
Not every exclusive runtime provider is a generic backend.
|
||||
|
||||
Current `main` already has slot-backed provider selection in:
|
||||
|
||||
- `src/plugins/slots.ts:12`
|
||||
- `src/context-engine/registry.ts:60`
|
||||
|
||||
The host must model explicit slot-backed providers for cases such as:
|
||||
|
||||
- context engines
|
||||
- default memory providers
|
||||
- future execution or planning engines
|
||||
|
||||
Required slot rules:
|
||||
|
||||
- each slot has a stable slot id
|
||||
- each slot has a host-defined default
|
||||
- explicit config selection wins
|
||||
- only one active provider may own an exclusive slot
|
||||
- migration preserves existing config semantics such as `plugins.slots.memory` and `plugins.slots.contextEngine`
|
||||
|
||||
Migration rule:
|
||||
|
||||
- slot-backed providers must move into host-owned registries before broader catalog and arbitration migration claims are considered complete
|
||||
|
||||
## Isolation Rules
|
||||
|
||||
The host must isolate extension failures from the kernel as much as possible.
|
||||
|
||||
Minimum requirements:
|
||||
|
||||
- one extension failing startup does not block unrelated extensions
|
||||
- one contribution registration failure does not corrupt host state
|
||||
- background-service failures transition the extension to `degraded` or `failed` without leaving stale registrations behind
|
||||
- stop hooks are best-effort and time-bounded
|
||||
|
||||
In the current trusted in-process mode, "isolation" here means lifecycle and registry isolation, not a security sandbox.
|
||||
|
||||
## Reload And Upgrade Rules
|
||||
|
||||
Hot reload is optional. Deterministic restart behavior is required.
|
||||
|
||||
On reload or upgrade:
|
||||
|
||||
1. stop host-managed services
|
||||
2. unregister contributions
|
||||
3. clear host-owned route, command, backend, and slot registrations
|
||||
4. clear dynamic account-scoped routes and stale runtime handles
|
||||
5. instantiate the new version
|
||||
6. reactivate only after validation and policy checks succeed
|
||||
|
||||
If the host continues to support config-driven hot reload during migration, it must also preserve:
|
||||
|
||||
- channel-owned reload prefix behavior equivalent to current `configPrefixes` and `noopPrefixes`
|
||||
- gateway feature advertisement cleanup and re-registration
|
||||
- setup-flow and native-command registrations that depend on account-scoped runtime state
|
||||
|
||||
This advertisement handling does not replace callable RPC registration. If a migrated extension exposes callable gateway-style methods, those should still be re-registered through `capability.rpc`.
|
||||
|
||||
During migration, keep the current built-in onboarding fallback in place until host-owned setup surfaces cover bundled channels with parity.
|
||||
|
||||
Pilot rule:
|
||||
|
||||
- the fallback stays in place until `telegram` parity has been recorded for setup-adjacent host behavior, even if runtime messaging parity lands earlier
|
||||
|
||||
## Operator Policy
|
||||
|
||||
The host should support policy controls for:
|
||||
|
||||
- allowed extension ids
|
||||
- denied permissions
|
||||
- default permission grants for bundled extensions
|
||||
- allowed extension origins and provenance requirements
|
||||
- origin precedence and duplicate resolution
|
||||
- workspace extensions disabled by default unless explicitly allowed
|
||||
- bundled channel auto-enable rules tied to channel config
|
||||
- route exposure policy
|
||||
- network egress policy
|
||||
- backend selection policy
|
||||
- whether external extensions are permitted at all
|
||||
- SDK compatibility level and deprecation mode
|
||||
- prompt-mutation policy defaults
|
||||
- whether interactive extension-owned CLI and setup flows are allowed
|
||||
- whether extension-owned native command registration is allowed on specific providers
|
||||
- whether config-driven hot reload descriptors are honored or downgraded to restart-only behavior
|
||||
|
||||
## Observability
|
||||
|
||||
The host must emit structured telemetry for:
|
||||
|
||||
- activation timings
|
||||
- policy denials
|
||||
- contribution conflicts
|
||||
- route conflicts
|
||||
- backend registration and health
|
||||
- service start and stop
|
||||
- extension degradation and recovery
|
||||
- provenance warnings and origin overrides
|
||||
- state migration outcomes
|
||||
- compatibility-mode activation and deprecated SDK usage
|
||||
- setup flow phase transitions and fallback-path usage
|
||||
- config redaction or restore validation failures
|
||||
- reload descriptor application and gateway feature re-registration
|
||||
|
||||
## Immediate Implementation Work
|
||||
|
||||
1. Write the boundary cutover inventory for every current plugin-owned surface.
|
||||
2. Introduce an extension-host lifecycle state machine.
|
||||
3. Move route registration policy out of plugin internals into host-owned registries.
|
||||
4. Add a policy evaluator that understands advisory versus enforced permission modes.
|
||||
5. Add host-owned credential and per-extension state boundaries for extension services.
|
||||
6. Generalize backend registration into a host-managed `capability.runtime-backend` registry.
|
||||
7. Finish broader consumer adoption and arbitration on top of the catalog-backed runtime-family descriptors for embeddings, media, and TTS, instead of widening `registerProvider(...)`.
|
||||
8. Keep extension-backed search generic by publishing agent-visible search through tool contracts and using runtime-backend only for search backends consumed internally by the host or another subsystem.
|
||||
9. Add slot-backed provider management for context engines and other exclusive runtime providers.
|
||||
10. Preserve provenance, origin precedence, and current workspace and bundled enablement rules in host policy.
|
||||
11. Preserve prompt-mutation policy gates and add explicit state migration handling.
|
||||
12. Add explicit host registries and typed contracts for extension-owned hooks, channels, providers, tools, commands, CLI, setup flows, config surfaces, and status surfaces.
|
||||
13. Preserve config redaction-aware schema behavior and current reload or gateway feature contracts during migration.
|
||||
14. Record lifecycle parity for `thread-ownership` first and `telegram` second before broadening the compatibility bridges.
|
||||
@@ -1,841 +0,0 @@
|
||||
Temporary internal migration note: remove this document once the extension-host migration is complete.
|
||||
|
||||
# OpenClaw Kernel Event Pipeline Spec
|
||||
|
||||
Date: 2026-03-15
|
||||
|
||||
## Purpose
|
||||
|
||||
This document defines the canonical kernel event model, execution stages, handler classes, ordering, mutation rules, and veto semantics.
|
||||
|
||||
The goal is to replace today's mixed plugin hook behavior with one explicit runtime pipeline and a small set of execution modes that match current `main` behavior.
|
||||
|
||||
## TODOs
|
||||
|
||||
- [ ] Implement canonical event types and stage ordering in code.
|
||||
- [ ] Bridge current plugin hooks, internal hooks, and agent event streams into the pipeline.
|
||||
- [ ] Implement sync transcript-write stages with parity for current hot paths.
|
||||
- [ ] Record the legacy-to-canonical mapping table used by the first pilot migrations.
|
||||
- [ ] Record parity for `thread-ownership` first and `telegram` second before broader event migration.
|
||||
- [ ] Document which legacy hook sources are still bridged and which have been retired.
|
||||
- [ ] Add parity tests for veto, resolver, and sync-stage behavior.
|
||||
|
||||
## Implementation Status
|
||||
|
||||
Current status against this spec:
|
||||
|
||||
- no canonical event pipeline work has landed yet
|
||||
- only the prerequisites from earlier phases are underway
|
||||
|
||||
Relevant prerequisite work that has landed:
|
||||
|
||||
- an initial Phase 0 cutover inventory now exists in `src/extension-host/cutover-inventory.md`
|
||||
- the extension-host boundary now owns active registry state
|
||||
- registry activation now routes through `src/extension-host/activation.ts`
|
||||
- initial normalized extension schema types now exist
|
||||
- static consumers can now read host-owned resolved-extension data
|
||||
- config doc baseline generation now uses the same host-owned resolved-extension data path
|
||||
- channel, provider, HTTP-route, gateway-method, tool, CLI, service, command, context-engine, and hook registration normalization now has a host-owned helper boundary
|
||||
- context-engine registration and runtime resolution now have a host-owned helper boundary in `src/extension-host/contributions/context-engine-runtime.ts`
|
||||
- exclusive-slot selection and default-slot resolution now have a host-owned helper boundary in `src/extension-host/policy/slot-arbitration.ts`
|
||||
- ACP backend registration and runtime resolution now have a host-owned helper boundary in `src/extension-host/contributions/acp-runtime-backend-registry.ts`
|
||||
- embedding-provider auto-selection, provider creation, local-setup guidance, and primary plus fallback routing now have a host-owned helper boundary in `src/extension-host/contributions/embedding-runtime-registry.ts`, shared fallback-model selection now has a host-owned helper boundary in `src/extension-host/policy/embedding-runtime-policy.ts`, the public embedding runtime surface plus result typing now have host-owned boundaries in `src/extension-host/contributions/embedding-runtime.ts` and `src/extension-host/contributions/embedding-runtime-types.ts`, manager-side batch policy plus fallback activation now have a host-owned helper boundary in `src/extension-host/contributions/embedding-manager-runtime.ts`, sync plus reindex planning now have a host-owned helper boundary in `src/extension-host/contributions/embedding-sync-planning.ts`, sync plus reindex orchestration now has a host-owned helper boundary in `src/extension-host/contributions/embedding-sync-execution.ts`, reindex sync-body execution plus unsafe reset now have a host-owned helper boundary in `src/extension-host/contributions/embedding-reindex-execution.ts`, and safe-reindex temp-db creation, file swap, reopen, and cleanup now have a host-owned helper boundary in `src/extension-host/contributions/embedding-safe-reindex.ts`
|
||||
- built-in media backend definitions, provider normalization, auto-selection seed order, selector-key shaping, and default-model metadata now have a host-owned helper boundary in `src/extension-host/static/media-runtime-backends.ts`; override merging and runtime lookup now have a host-owned helper boundary in `src/extension-host/contributions/media-runtime-registry.ts`; shared default and preferred runtime-backend ordering plus fallback chaining now have a host-owned helper boundary in `src/extension-host/policy/runtime-backend-policy.ts`; provider candidate ordering, active-model precedence, and default-model fallback selection now have a host-owned helper boundary in `src/extension-host/policy/media-runtime-policy.ts`; provider and CLI entry execution, output parsing, provider query normalization, provider auth/context shaping, and proxy-aware fetch handling now have a host-owned helper boundary in `src/extension-host/contributions/media-runtime-execution.ts`; local-binary probing, auto-entry selection, and top-level capability orchestration now have host-owned helper boundaries in `src/extension-host/contributions/media-runtime-auto.ts` and `src/extension-host/contributions/media-runtime-orchestration.ts`; and media prompt, timeout, scope, model-entry, concurrency, and decision helpers now have host-owned helper boundaries in `src/extension-host/contributions/media-runtime-config.ts` and `src/extension-host/contributions/media-runtime-decision.ts`
|
||||
- TTS provider metadata, provider ordering, API-key resolution, configuration checks, and telephony support now have a host-owned helper boundary in `src/extension-host/contributions/tts-runtime-registry.ts`, provider execution loops, output-format selection, telephony synthesis, and provider-error shaping now have a host-owned helper boundary in `src/extension-host/contributions/tts-runtime-execution.ts`, provider selection plus request setup now have a host-owned helper boundary in `src/extension-host/contributions/tts-runtime-setup.ts`, TTS config normalization, defaults, and model-override policy now have a host-owned helper boundary in `src/extension-host/contributions/tts-config.ts`, prefs-path resolution, auto-mode policy, and persisted TTS preference reads and writes now have a host-owned helper boundary in `src/extension-host/contributions/tts-preferences.ts`, auto-TTS gating, directive cleanup, truncation, summarization, and payload planning now have a host-owned helper boundary in `src/extension-host/contributions/tts-payload.ts`, and last-attempt state plus shared status snapshots now have a host-owned helper boundary in `src/extension-host/contributions/tts-status.ts`
|
||||
- loader cache key construction and registry cache control now have a host-owned helper boundary
|
||||
- loader provenance helpers now have a host-owned helper boundary
|
||||
- loader duplicate-order policy now has a host-owned helper boundary
|
||||
- loader alias-wired module loader creation now has a host-owned helper boundary
|
||||
- loader lazy runtime proxy creation now has a host-owned helper boundary
|
||||
- loader initial candidate planning and record creation now have a host-owned helper boundary
|
||||
- loader entry-path opening and module import now have a host-owned helper boundary
|
||||
- loader module-export resolution, config validation, and memory-slot load decisions now have a host-owned helper boundary
|
||||
- loader post-import planning and `register(...)` execution now have a host-owned helper boundary
|
||||
- loader per-candidate orchestration now has a host-owned helper boundary
|
||||
- loader top-level load orchestration now has a host-owned helper boundary
|
||||
- loader host process state now has a host-owned helper boundary
|
||||
- loader preflight and cache-hit setup now has a host-owned helper boundary
|
||||
- loader post-preflight pipeline composition now has a host-owned helper boundary
|
||||
- loader execution setup composition now has a host-owned helper boundary
|
||||
- loader discovery and manifest bootstrap now has a host-owned helper boundary
|
||||
- loader discovery policy outcomes now have a host-owned helper boundary
|
||||
- loader mutable activation state now has a host-owned helper boundary
|
||||
- loader session run and finalization composition now has a host-owned helper boundary
|
||||
- loader activation policy outcomes now have a host-owned helper boundary
|
||||
- loader record-state transitions now have a host-owned helper boundary and enforced loader lifecycle state machine, while still preserving compatibility `PluginRecord.status` values
|
||||
- loader finalization policy outcomes now have a host-owned helper boundary
|
||||
- loader final cache, readiness promotion, and activation finalization now has a host-owned helper boundary
|
||||
- low-risk channel, provider, gateway-method, HTTP-route, tool, CLI, service, command, context-engine, and hook compatibility writes now have a host-owned helper boundary in `src/extension-host/contributions/registry-writes.ts`
|
||||
- legacy internal-hook bridging and typed prompt-injection compatibility policy now have a host-owned helper boundary in `src/extension-host/compat/hook-compat.ts`
|
||||
- compatibility `OpenClawPluginApi` composition and logger shaping now have a host-owned helper boundary in `src/extension-host/compat/plugin-api.ts`
|
||||
- compatibility plugin-registry facade ownership now has a host-owned helper boundary in `src/extension-host/compat/plugin-registry.ts`
|
||||
- compatibility plugin-registry policy now has a host-owned helper boundary in `src/extension-host/compat/plugin-registry-compat.ts`
|
||||
- compatibility plugin-registry registration actions now have a host-owned helper boundary in `src/extension-host/compat/plugin-registry-registrations.ts`
|
||||
- host-owned runtime registry accessors now have a host-owned helper boundary in `src/extension-host/contributions/runtime-registry.ts`, and the channel, provider, tool, command, HTTP-route, gateway-method, CLI, and service slices now keep host-owned storage there with mirrored legacy compatibility views
|
||||
- plugin command registration, matching, execution, listing, native command-spec projection, and loader reload clearing now have a host-owned helper boundary in `src/extension-host/contributions/command-runtime.ts`
|
||||
- service startup, stop ordering, service-context creation, and failure logging now have a host-owned helper boundary in `src/extension-host/contributions/service-lifecycle.ts`
|
||||
- CLI duplicate detection, registrar invocation, and async failure logging now have a host-owned helper boundary in `src/extension-host/contributions/cli-lifecycle.ts`
|
||||
- gateway method-id aggregation, plugin diagnostic shaping, and extra-handler composition now have a host-owned helper boundary in `src/extension-host/contributions/gateway-methods.ts`
|
||||
- plugin tool resolution, conflict handling, optional-tool gating, and plugin-tool metadata tracking now have a host-owned helper boundary in `src/extension-host/contributions/tool-runtime.ts`
|
||||
- plugin provider projection from registry entries into runtime provider objects now have a host-owned helper boundary in `src/extension-host/contributions/provider-runtime.ts`
|
||||
- plugin provider discovery filtering, order grouping, and result normalization now have a host-owned helper boundary in `src/extension-host/contributions/provider-discovery.ts`
|
||||
- provider matching, auth-method selection, config-patch merging, and default-model application now have a host-owned helper boundary in `src/extension-host/contributions/provider-auth.ts`
|
||||
- provider onboarding option building, model-picker entry building, and provider-method choice resolution now have a host-owned helper boundary in `src/extension-host/contributions/provider-wizard.ts`
|
||||
- loaded-provider auth application, plugin-enable gating, auth-method execution, and post-auth default-model handling now have a host-owned helper boundary in `src/extension-host/contributions/provider-auth-flow.ts`
|
||||
- provider post-selection hook lookup and invocation now have a host-owned helper boundary in `src/extension-host/contributions/provider-model-selection.ts`
|
||||
|
||||
Why this matters for this spec:
|
||||
|
||||
- event work should land on top of a host-owned boundary and normalized contribution model rather than on top of more plugin-era runtime seams
|
||||
- the current implementation has deliberately not started canonical bridge or stage work before those earlier boundaries were in place, including the first loader-runtime, record-state, discovery-policy, activation-policy, finalization-policy, low-risk registry-write, hook-compat, plugin-api, plugin-registry, plugin-registry-compat, plugin-registry-registrations, runtime-registry storage and accessors, command-runtime, service-lifecycle, CLI-lifecycle, gateway-methods, tool-runtime, provider-runtime, provider-discovery, provider-auth, provider-wizard, provider-auth-flow, and provider-model-selection seams
|
||||
|
||||
## Design Goals
|
||||
|
||||
- every inbound and outbound path goes through one canonical pipeline
|
||||
- handler behavior is declared, not inferred
|
||||
- routing-affecting handlers are distinct from passive observers
|
||||
- ordering and merge rules are deterministic
|
||||
- extension failures are isolated and visible
|
||||
- sync transcript-write paths remain explicit rather than being hidden inside generic async stages
|
||||
- current plugin hooks, internal hooks, and agent event streams can be bridged into one model incrementally
|
||||
- the migration path for legacy event buses is explicit rather than accidental
|
||||
|
||||
## Sequencing Constraints
|
||||
|
||||
This pipeline is a migration target, not a prerequisite for every other host change.
|
||||
|
||||
Therefore:
|
||||
|
||||
- minimal SDK compatibility and host registry ownership should land before broad hook migration
|
||||
- the first event migration should prove parity for a small non-channel hook case and a channel case
|
||||
- do not require every event family to be implemented before pilot migrations can bridge the current hook set
|
||||
- do not leave legacy hook buses as undocumented permanent peers to the canonical pipeline
|
||||
|
||||
## Canonical Event Families
|
||||
|
||||
The kernel should emit typed event families instead of raw plugin hook names.
|
||||
|
||||
Recommended families:
|
||||
|
||||
- `runtime.started`
|
||||
- `runtime.stopping`
|
||||
- `gateway.starting`
|
||||
- `gateway.started`
|
||||
- `gateway.stopping`
|
||||
- `command.received`
|
||||
- `command.completed`
|
||||
- `account.started`
|
||||
- `account.stopped`
|
||||
- `ingress.received`
|
||||
- `ingress.normalized`
|
||||
- `ingress.claiming`
|
||||
- `routing.resolving`
|
||||
- `routing.resolved`
|
||||
- `session.starting`
|
||||
- `session.started`
|
||||
- `session.resetting`
|
||||
- `agent.starting`
|
||||
- `agent.model.resolving`
|
||||
- `agent.prompt.building`
|
||||
- `agent.llm.input`
|
||||
- `agent.llm.output`
|
||||
- `agent.tool.calling`
|
||||
- `agent.tool.called`
|
||||
- `transcript.tool-result.persisting`
|
||||
- `transcript.message.writing`
|
||||
- `compaction.before`
|
||||
- `compaction.after`
|
||||
- `agent.completed`
|
||||
- `egress.preparing`
|
||||
- `egress.sending`
|
||||
- `egress.sent`
|
||||
- `egress.cancelled`
|
||||
- `egress.failed`
|
||||
- `interaction.received`
|
||||
- `subagent.spawning`
|
||||
- `subagent.spawned`
|
||||
- `subagent.delivery.resolving`
|
||||
- `subagent.delivery.resolved`
|
||||
- `subagent.completed`
|
||||
|
||||
These families intentionally cover the behavior currently spread across `src/plugins/hooks.ts:1`, `src/hooks/internal-hooks.ts:13`, `src/infra/agent-events.ts:3`, and channel monitors.
|
||||
|
||||
`ingress.claiming` exists to absorb behavior that is currently tempting to model as plugin-specific hooks or direct dispatch short-circuits:
|
||||
|
||||
- bound conversation ownership
|
||||
- first-claim-wins plugin or extension routing
|
||||
- future route-claim or veto decisions that must run before command or agent dispatch
|
||||
|
||||
## Canonical Event Envelope
|
||||
|
||||
Every event should carry:
|
||||
|
||||
- `eventId`
|
||||
- `family`
|
||||
- `occurredAt`
|
||||
- `workspaceId`
|
||||
- `agentId`
|
||||
- `sessionId`
|
||||
- `accountRef`
|
||||
- `conversationRef`
|
||||
- `threadRef`
|
||||
- `messageRef`
|
||||
- `sourceContributionId`
|
||||
- `correlationId`
|
||||
- `payload`
|
||||
- `metadata`
|
||||
- `providerMetadata`
|
||||
- `hotPath`
|
||||
|
||||
The event envelope is immutable. Mutation happens through stage outputs, not by mutating the event object in place.
|
||||
|
||||
## Handler Classes
|
||||
|
||||
Each handler contribution must declare exactly one class:
|
||||
|
||||
- `observer`
|
||||
- `augmenter`
|
||||
- `mutator`
|
||||
- `veto`
|
||||
- `resolver`
|
||||
|
||||
### `observer`
|
||||
|
||||
Side effects only. No runtime decision output.
|
||||
|
||||
### `augmenter`
|
||||
|
||||
May attach additional context for downstream stages.
|
||||
|
||||
Examples:
|
||||
|
||||
- prompt context injection
|
||||
- memory recall summaries
|
||||
- diagnostics enrichment
|
||||
|
||||
### `mutator`
|
||||
|
||||
May modify a typed working object for the current pipeline stage.
|
||||
|
||||
Examples:
|
||||
|
||||
- prompt build additions
|
||||
- model override
|
||||
- tool call decoration
|
||||
|
||||
### `veto`
|
||||
|
||||
May cancel a downstream action with a typed reason.
|
||||
|
||||
Examples today:
|
||||
|
||||
- send cancellation in `extensions/thread-ownership/index.ts:63`
|
||||
|
||||
### `resolver`
|
||||
|
||||
May produce a selected target or route decision.
|
||||
|
||||
Examples today:
|
||||
|
||||
- subagent delivery target selection in `extensions/discord/src/subagent-hooks.ts:103`
|
||||
|
||||
Only `veto` and `resolver` handlers may influence routing or delivery decisions.
|
||||
|
||||
`ingress.claiming` is the first concrete place where a resolver-like route claim is expected to matter during migration.
|
||||
|
||||
First-cut parity rule for `ingress.claiming`:
|
||||
|
||||
- claim handlers run sequentially in deterministic order
|
||||
- the first successful claim wins ownership of the inbound turn
|
||||
- passive observers still run in their own stages instead of being skipped accidentally
|
||||
- the migration bridge may target a single extension when a host-owned binding already resolved the owner
|
||||
|
||||
## Execution Modes
|
||||
|
||||
The semantic handler class is not enough by itself.
|
||||
|
||||
Each stage must also declare one of three execution modes:
|
||||
|
||||
- `parallel`
|
||||
For read-only observers and low-risk side effects.
|
||||
- `sequential`
|
||||
For merge, mutation, veto, and resolver stages.
|
||||
- `sync-sequential`
|
||||
For transcript and persistence hot paths where async handlers are not allowed.
|
||||
|
||||
This mirrors current `main` behavior in `src/plugins/hooks.ts:199`, `src/plugins/hooks.ts:226`, `src/plugins/hooks.ts:465`, and `src/plugins/hooks.ts:528`.
|
||||
|
||||
## Deterministic Ordering
|
||||
|
||||
Within a stage, handlers run in this order:
|
||||
|
||||
1. explicit priority descending
|
||||
2. extension id ascending
|
||||
3. contribution id ascending
|
||||
|
||||
Priority is optional. Ties must resolve deterministically.
|
||||
|
||||
## Stage Execution Model
|
||||
|
||||
Every pipeline stage declares:
|
||||
|
||||
- which handler classes are allowed
|
||||
- execution mode
|
||||
- whether handlers run in parallel or sequentially
|
||||
- how outputs are merged
|
||||
- whether errors fail open or fail closed
|
||||
|
||||
## Gateway And Command Pipeline
|
||||
|
||||
### Stage: `gateway.starting`, `gateway.started`, `gateway.stopping`
|
||||
|
||||
Allowed handler classes:
|
||||
|
||||
- `observer`
|
||||
|
||||
Execution mode:
|
||||
|
||||
- `parallel`
|
||||
|
||||
Purpose:
|
||||
|
||||
- lifecycle telemetry
|
||||
- startup and shutdown side effects
|
||||
|
||||
### Stage: `command.received`, `command.completed`
|
||||
|
||||
Allowed handler classes:
|
||||
|
||||
- `observer`
|
||||
- `augmenter`
|
||||
|
||||
Execution mode:
|
||||
|
||||
- `sequential`
|
||||
|
||||
Purpose:
|
||||
|
||||
- command audit
|
||||
- command lifecycle integration
|
||||
- operator-visible side effects
|
||||
- preserve source-surface metadata for chat commands, native commands, and host CLI invocations when those flows are bridged into canonical command events
|
||||
|
||||
Bridge requirement:
|
||||
|
||||
- the current internal hook bus in `src/hooks/internal-hooks.ts:13`
|
||||
- and the current agent event stream in `src/infra/agent-events.ts:3`
|
||||
|
||||
must be mapped deliberately into canonical families during migration.
|
||||
|
||||
Acceptable end states are:
|
||||
|
||||
- they become compatibility sources that emit canonical events
|
||||
- or they are fully retired after parity is reached
|
||||
|
||||
An undocumented permanent fourth event system is not acceptable.
|
||||
|
||||
## Ingress Pipeline
|
||||
|
||||
### Stage 1: `ingress.received`
|
||||
|
||||
Input:
|
||||
|
||||
- raw adapter payload
|
||||
|
||||
Allowed handler classes:
|
||||
|
||||
- `observer`
|
||||
|
||||
Execution mode:
|
||||
|
||||
- `parallel`
|
||||
|
||||
Purpose:
|
||||
|
||||
- telemetry
|
||||
- raw audit
|
||||
- diagnostics
|
||||
|
||||
### Stage 2: `ingress.normalized`
|
||||
|
||||
Input:
|
||||
|
||||
- normalized inbound envelope from `adapter.runtime.decodeIngress`
|
||||
|
||||
Allowed handler classes:
|
||||
|
||||
- `observer`
|
||||
- `augmenter`
|
||||
- `mutator`
|
||||
|
||||
Execution mode:
|
||||
|
||||
- `sequential`
|
||||
|
||||
Purpose:
|
||||
|
||||
- add normalized metadata
|
||||
- enrich source/account context
|
||||
- attach pre-routing annotations
|
||||
|
||||
This stage must not choose a route.
|
||||
|
||||
### Stage 3: `routing.resolving`
|
||||
|
||||
Allowed handler classes:
|
||||
|
||||
- `augmenter`
|
||||
- `resolver`
|
||||
- `veto`
|
||||
|
||||
Execution mode:
|
||||
|
||||
- `sequential`
|
||||
|
||||
Purpose:
|
||||
|
||||
- route lookup
|
||||
- ownership checks
|
||||
- subagent delivery target resolution
|
||||
- policy application before route finalization
|
||||
|
||||
Merge rules:
|
||||
|
||||
- `resolver` outputs produce candidate route decisions
|
||||
- highest-precedence valid decision wins
|
||||
- `veto` may cancel route selection
|
||||
|
||||
### Stage 4: `routing.resolved`
|
||||
|
||||
Allowed handler classes:
|
||||
|
||||
- `observer`
|
||||
- `augmenter`
|
||||
|
||||
Execution mode:
|
||||
|
||||
- `sequential`
|
||||
|
||||
Purpose:
|
||||
|
||||
- emit resolved route metadata
|
||||
- enrich downstream session context
|
||||
|
||||
### Stage 5: `session.starting`
|
||||
|
||||
Allowed handler classes:
|
||||
|
||||
- `observer`
|
||||
- `augmenter`
|
||||
- `mutator`
|
||||
|
||||
Execution mode:
|
||||
|
||||
- `sequential`
|
||||
|
||||
Purpose:
|
||||
|
||||
- bind session context
|
||||
- attach memory lookup keys
|
||||
- prepare session-scoped metadata
|
||||
|
||||
### Stage 6: `session.started`
|
||||
|
||||
Allowed handler classes:
|
||||
|
||||
- `observer`
|
||||
|
||||
Execution mode:
|
||||
|
||||
- `parallel`
|
||||
|
||||
Purpose:
|
||||
|
||||
- fire lifecycle observers
|
||||
|
||||
### Stage 7: `agent.starting`
|
||||
|
||||
Allowed handler classes:
|
||||
|
||||
- `observer`
|
||||
- `augmenter`
|
||||
|
||||
Execution mode:
|
||||
|
||||
- `sequential`
|
||||
|
||||
Purpose:
|
||||
|
||||
- last pre-run annotations
|
||||
|
||||
## Prompt And Model Pipeline
|
||||
|
||||
### Stage: `agent.model.resolving`
|
||||
|
||||
Allowed handler classes:
|
||||
|
||||
- `mutator`
|
||||
|
||||
Execution mode:
|
||||
|
||||
- `sequential`
|
||||
|
||||
Merge rules:
|
||||
|
||||
- first defined model override wins
|
||||
- first defined provider override wins
|
||||
|
||||
This mirrors current precedence in `src/plugins/hooks.ts:117`.
|
||||
|
||||
### Stage: `agent.prompt.building`
|
||||
|
||||
Allowed handler classes:
|
||||
|
||||
- `augmenter`
|
||||
- `mutator`
|
||||
|
||||
Execution mode:
|
||||
|
||||
- `sequential`
|
||||
|
||||
Merge rules:
|
||||
|
||||
- static system guidance composes in declared order
|
||||
- ephemeral prompt additions compose in declared order
|
||||
- direct system prompt replacement is allowed only for explicitly trusted mutators
|
||||
|
||||
This replaces the ambiguous overlap between `before_prompt_build` and legacy `before_agent_start` in `src/plugins/types.ts:422`.
|
||||
|
||||
### Stage: `agent.llm.input`
|
||||
|
||||
Allowed handler classes:
|
||||
|
||||
- `observer`
|
||||
- `augmenter`
|
||||
|
||||
Execution mode:
|
||||
|
||||
- `sequential`
|
||||
|
||||
Purpose:
|
||||
|
||||
- provider-call audit
|
||||
- input usage and prompt metadata capture
|
||||
|
||||
### Stage: `agent.llm.output`
|
||||
|
||||
Allowed handler classes:
|
||||
|
||||
- `observer`
|
||||
- `augmenter`
|
||||
|
||||
Execution mode:
|
||||
|
||||
- `sequential`
|
||||
|
||||
Purpose:
|
||||
|
||||
- provider response audit
|
||||
- usage capture
|
||||
- output enrichment
|
||||
|
||||
## Tool Pipeline
|
||||
|
||||
### Stage: `agent.tool.calling`
|
||||
|
||||
Allowed handler classes:
|
||||
|
||||
- `observer`
|
||||
- `augmenter`
|
||||
- `mutator`
|
||||
- `veto`
|
||||
|
||||
Execution mode:
|
||||
|
||||
- `sequential`
|
||||
|
||||
Purpose:
|
||||
|
||||
- tool policy checks
|
||||
- argument normalization
|
||||
- tool-call audit
|
||||
|
||||
### Stage: `agent.tool.called`
|
||||
|
||||
Allowed handler classes:
|
||||
|
||||
- `observer`
|
||||
- `augmenter`
|
||||
|
||||
Execution mode:
|
||||
|
||||
- `sequential`
|
||||
|
||||
Purpose:
|
||||
|
||||
- result indexing
|
||||
- memory capture
|
||||
- diagnostics
|
||||
|
||||
### Stage: `agent.completed`
|
||||
|
||||
Allowed handler classes:
|
||||
|
||||
- `observer`
|
||||
- `augmenter`
|
||||
|
||||
Execution mode:
|
||||
|
||||
- `sequential`
|
||||
|
||||
Purpose:
|
||||
|
||||
- end-of-run capture
|
||||
- automatic memory storage
|
||||
- metrics
|
||||
|
||||
## Persistence Pipeline
|
||||
|
||||
### Stage: `transcript.tool-result.persisting`
|
||||
|
||||
Allowed handler classes:
|
||||
|
||||
- `mutator`
|
||||
|
||||
Execution mode:
|
||||
|
||||
- `sync-sequential`
|
||||
|
||||
Purpose:
|
||||
|
||||
- mutate the tool-result message that will be appended to transcripts
|
||||
|
||||
Rules:
|
||||
|
||||
- async handlers are invalid
|
||||
- handlers run in deterministic priority order
|
||||
- each handler sees the previous handler's output
|
||||
|
||||
This is the explicit replacement for today's sync-only `tool_result_persist` hook in `src/plugins/hooks.ts:465`.
|
||||
|
||||
### Stage: `transcript.message.writing`
|
||||
|
||||
Allowed handler classes:
|
||||
|
||||
- `mutator`
|
||||
- `veto`
|
||||
|
||||
Execution mode:
|
||||
|
||||
- `sync-sequential`
|
||||
|
||||
Purpose:
|
||||
|
||||
- final transcript message mutation
|
||||
- transcript write suppression when explicitly requested
|
||||
|
||||
Rules:
|
||||
|
||||
- async handlers are invalid
|
||||
- successful veto decisions are terminal
|
||||
- mutation happens before the final write
|
||||
|
||||
This is the explicit replacement for today's sync-only `before_message_write` hook in `src/plugins/hooks.ts:528`.
|
||||
|
||||
## Compaction And Reset Pipeline
|
||||
|
||||
Canonical stages:
|
||||
|
||||
- `compaction.before`
|
||||
- `compaction.after`
|
||||
- `session.resetting`
|
||||
|
||||
## Egress Pipeline
|
||||
|
||||
### Stage 1: `egress.preparing`
|
||||
|
||||
Input:
|
||||
|
||||
- normalized outbound envelope
|
||||
|
||||
Allowed handler classes:
|
||||
|
||||
- `observer`
|
||||
- `augmenter`
|
||||
- `mutator`
|
||||
- `veto`
|
||||
- `resolver`
|
||||
|
||||
Execution mode:
|
||||
|
||||
- `sequential`
|
||||
|
||||
Purpose:
|
||||
|
||||
- choose provider or account when not explicit
|
||||
- attach send metadata
|
||||
- enforce ownership or safety policy
|
||||
|
||||
This stage replaces today’s mixed send hooks and route checks.
|
||||
|
||||
### Stage 2: `egress.sending`
|
||||
|
||||
Allowed handler classes:
|
||||
|
||||
- `observer`
|
||||
|
||||
Execution mode:
|
||||
|
||||
- `parallel`
|
||||
|
||||
Purpose:
|
||||
|
||||
- telemetry and audit before transport send
|
||||
|
||||
### Stage 3: `egress.sent`, `egress.cancelled`, `egress.failed`
|
||||
|
||||
Allowed handler classes:
|
||||
|
||||
- `observer`
|
||||
- `augmenter`
|
||||
|
||||
Execution mode:
|
||||
|
||||
- `sequential`
|
||||
|
||||
Purpose:
|
||||
|
||||
- post-send side effects
|
||||
- delivery-state indexing
|
||||
|
||||
## Interaction Pipeline
|
||||
|
||||
Interaction events should not be routed through message hooks.
|
||||
|
||||
Canonical stages:
|
||||
|
||||
- `interaction.received`
|
||||
- `interaction.resolved`
|
||||
- `interaction.completed`
|
||||
|
||||
These handle slash commands, button presses, modal submissions, and similar surfaces.
|
||||
|
||||
## Subagent Pipeline
|
||||
|
||||
The current hook set already proves this needs explicit treatment:
|
||||
|
||||
- `subagent_spawning`
|
||||
- `subagent_delivery_target`
|
||||
- `subagent_spawned`
|
||||
- `subagent_ended`
|
||||
|
||||
The canonical form should be:
|
||||
|
||||
- `subagent.spawning`
|
||||
- `subagent.spawned`
|
||||
- `subagent.delivery.resolving`
|
||||
- `subagent.delivery.resolved`
|
||||
- `subagent.completed`
|
||||
|
||||
Resolver semantics:
|
||||
|
||||
- multiple candidates may be proposed
|
||||
- explicit target beats inferred target
|
||||
- otherwise highest-ranked valid candidate wins
|
||||
|
||||
## Merge Rules
|
||||
|
||||
### Observer
|
||||
|
||||
No merge output.
|
||||
|
||||
### Augmenter
|
||||
|
||||
Produces additive metadata only.
|
||||
|
||||
Conflicts merge by:
|
||||
|
||||
- key append for list-like fields
|
||||
- last-writer-wins only for fields explicitly marked replaceable
|
||||
|
||||
### Mutator
|
||||
|
||||
Produces typed patch objects.
|
||||
|
||||
Rules:
|
||||
|
||||
- patch schema is stage-specific
|
||||
- patches apply in deterministic order
|
||||
- later patches see earlier outputs
|
||||
|
||||
### Veto
|
||||
|
||||
Produces:
|
||||
|
||||
- `allow`
|
||||
- `cancel`
|
||||
|
||||
Rules:
|
||||
|
||||
- one `cancel` is terminal if the stage is fail-closed
|
||||
- fail-open stages may ignore veto errors but not successful veto decisions
|
||||
|
||||
### Resolver
|
||||
|
||||
Produces candidate selections.
|
||||
|
||||
Rules:
|
||||
|
||||
- explicit target selectors win
|
||||
- otherwise rank, policy, and deterministic tie-breakers apply
|
||||
|
||||
## Error Handling
|
||||
|
||||
Per-stage error policy must be explicit.
|
||||
|
||||
Recommended defaults:
|
||||
|
||||
- telemetry and observer stages fail open
|
||||
- routing and send veto stages fail open unless the contribution declares `failClosed`
|
||||
- credential or auth mutation stages fail closed
|
||||
- backend selection stages fail closed when no valid provider remains
|
||||
- sync transcript stages fail open on handler failure but must still reject accidental async handlers
|
||||
|
||||
## Legacy Hook Mapping
|
||||
|
||||
Current hook names map approximately like this:
|
||||
|
||||
- `before_model_resolve` -> `agent.model.resolving`
|
||||
- `before_prompt_build` -> `agent.prompt.building`
|
||||
- `before_agent_start` -> split between `agent.model.resolving` and `agent.prompt.building`
|
||||
- `llm_input` -> `agent.llm.input`
|
||||
- `llm_output` -> `agent.llm.output`
|
||||
- `message_received` -> `ingress.normalized`
|
||||
- `message_sending` -> `egress.preparing`
|
||||
- `message_sent` -> `egress.sent`
|
||||
- `before_tool_call` -> `agent.tool.calling`
|
||||
- `after_tool_call` -> `agent.tool.called`
|
||||
- `tool_result_persist` -> `transcript.tool-result.persisting`
|
||||
- `before_message_write` -> `transcript.message.writing`
|
||||
- `before_compaction` -> `compaction.before`
|
||||
- `after_compaction` -> `compaction.after`
|
||||
- `before_reset` -> `session.resetting`
|
||||
- `gateway_start` -> `gateway.started`
|
||||
- `gateway_stop` -> `gateway.stopping`
|
||||
- `subagent_delivery_target` -> `subagent.delivery.resolving`
|
||||
|
||||
First pilot focus:
|
||||
|
||||
- `thread-ownership` should validate `message_received` and `message_sending` migration into canonical ingress and egress stages
|
||||
- `telegram` should validate that channel-path runtime behavior can participate in canonical events without reintroducing plugin-shaped kernel seams
|
||||
|
||||
## Immediate Implementation Work
|
||||
|
||||
1. Add canonical event and stage types to the kernel.
|
||||
2. Build a stage runner with explicit handler-class validation.
|
||||
3. Add typed patch and veto result contracts per stage, including sync-sequential stages.
|
||||
4. Bridge legacy plugin hooks, internal hooks, and agent events into canonical stages in the extension host only.
|
||||
5. Record the exact legacy-to-canonical mapping used by `thread-ownership`.
|
||||
6. Record the exact legacy-to-canonical mapping used by `telegram`.
|
||||
7. Refactor one channel and one non-channel extension through the new pipeline before broader migration.
|
||||
8. Decide and document the retirement plan for any legacy event bus that remains after parity is achieved.
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 64 KiB |
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 64 KiB |
@@ -532,75 +532,6 @@ Feishu supports streaming replies via interactive cards. When enabled, the bot u
|
||||
|
||||
Set `streaming: false` to wait for the full reply before sending.
|
||||
|
||||
### ACP sessions
|
||||
|
||||
Feishu supports ACP for:
|
||||
|
||||
- DMs
|
||||
- group topic conversations
|
||||
|
||||
Feishu ACP is text-command driven. There are no native slash-command menus, so use `/acp ...` messages directly in the conversation.
|
||||
|
||||
#### Persistent ACP bindings
|
||||
|
||||
Use top-level typed ACP bindings to pin a Feishu DM or topic conversation to a persistent ACP session.
|
||||
|
||||
```json5
|
||||
{
|
||||
agents: {
|
||||
list: [
|
||||
{
|
||||
id: "codex",
|
||||
runtime: {
|
||||
type: "acp",
|
||||
acp: {
|
||||
agent: "codex",
|
||||
backend: "acpx",
|
||||
mode: "persistent",
|
||||
cwd: "/workspace/openclaw",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
bindings: [
|
||||
{
|
||||
type: "acp",
|
||||
agentId: "codex",
|
||||
match: {
|
||||
channel: "feishu",
|
||||
accountId: "default",
|
||||
peer: { kind: "direct", id: "ou_1234567890" },
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "acp",
|
||||
agentId: "codex",
|
||||
match: {
|
||||
channel: "feishu",
|
||||
accountId: "default",
|
||||
peer: { kind: "group", id: "oc_group_chat:topic:om_topic_root" },
|
||||
},
|
||||
acp: { label: "codex-feishu-topic" },
|
||||
},
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
#### Thread-bound ACP spawn from chat
|
||||
|
||||
In a Feishu DM or topic conversation, you can spawn and bind an ACP session in place:
|
||||
|
||||
```text
|
||||
/acp spawn codex --thread here
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- `--thread here` works for DMs and Feishu topics.
|
||||
- Follow-up messages in the bound DM/topic route directly to that ACP session.
|
||||
- v1 does not target generic non-topic group chats.
|
||||
|
||||
### Multi-agent routing
|
||||
|
||||
Use `bindings` to route Feishu DMs or groups to different agents.
|
||||
|
||||
@@ -13,7 +13,7 @@ Note: `agents.list[].groupChat.mentionPatterns` is now used by Telegram/Discord/
|
||||
|
||||
## What’s implemented (2025-12-03)
|
||||
|
||||
- Activation modes: `mention` (default) or `always`. `mention` requires a ping (real WhatsApp @-mentions via `mentionedJids`, safe regex patterns, or the bot’s E.164 anywhere in the text). `always` wakes the agent on every message but it should reply only when it can add meaningful value; otherwise it returns the silent token `NO_REPLY`. Defaults can be set in config (`channels.whatsapp.groups`) and overridden per group via `/activation`. When `channels.whatsapp.groups` is set, it also acts as a group allowlist (include `"*"` to allow all).
|
||||
- Activation modes: `mention` (default) or `always`. `mention` requires a ping (real WhatsApp @-mentions via `mentionedJids`, regex patterns, or the bot’s E.164 anywhere in the text). `always` wakes the agent on every message but it should reply only when it can add meaningful value; otherwise it returns the silent token `NO_REPLY`. Defaults can be set in config (`channels.whatsapp.groups`) and overridden per group via `/activation`. When `channels.whatsapp.groups` is set, it also acts as a group allowlist (include `"*"` to allow all).
|
||||
- Group policy: `channels.whatsapp.groupPolicy` controls whether group messages are accepted (`open|disabled|allowlist`). `allowlist` uses `channels.whatsapp.groupAllowFrom` (fallback: explicit `channels.whatsapp.allowFrom`). Default is `allowlist` (blocked until you add senders).
|
||||
- Per-group sessions: session keys look like `agent:<agentId>:whatsapp:group:<jid>` so commands such as `/verbose on` or `/think high` (sent as standalone messages) are scoped to that group; personal DM state is untouched. Heartbeats are skipped for group threads.
|
||||
- Context injection: **pending-only** group messages (default 50) that _did not_ trigger a run are prefixed under `[Chat messages since your last reply - for context]`, with the triggering line under `[Current message - respond to this]`. Messages already in the session are not re-injected.
|
||||
@@ -50,7 +50,7 @@ Add a `groupChat` block to `~/.openclaw/openclaw.json` so display-name pings wor
|
||||
|
||||
Notes:
|
||||
|
||||
- The regexes are case-insensitive and use the same safe-regex guardrails as other config regex surfaces; invalid patterns and unsafe nested repetition are ignored.
|
||||
- The regexes are case-insensitive; they cover a display-name ping like `@openclaw` and the raw number with or without `+`/spaces.
|
||||
- WhatsApp still sends canonical mentions via `mentionedJids` when someone taps the contact, so the number fallback is rarely needed but is a useful safety net.
|
||||
|
||||
### Activation command (owner-only)
|
||||
|
||||
@@ -243,7 +243,7 @@ Replying to a bot message counts as an implicit mention (when the channel suppor
|
||||
|
||||
Notes:
|
||||
|
||||
- `mentionPatterns` are case-insensitive safe regex patterns; invalid patterns and unsafe nested-repetition forms are ignored.
|
||||
- `mentionPatterns` are case-insensitive regexes.
|
||||
- Surfaces that provide explicit mentions still pass; patterns are a fallback.
|
||||
- Per-agent override: `agents.list[].groupChat.mentionPatterns` (useful when multiple agents share a group).
|
||||
- Mention gating is only enforced when mention detection is possible (native mentions or `mentionPatterns` are configured).
|
||||
|
||||
@@ -7,7 +7,7 @@ title: "Zalo"
|
||||
|
||||
# Zalo (Bot API)
|
||||
|
||||
Status: experimental. DMs are supported. The [Capabilities](#capabilities) section below reflects current Marketplace-bot behavior.
|
||||
Status: experimental. DMs are supported; group handling is available with explicit group policy controls.
|
||||
|
||||
## Plugin required
|
||||
|
||||
@@ -25,7 +25,7 @@ Zalo ships as a plugin and is not bundled with the core install.
|
||||
- Or pick **Zalo** in onboarding and confirm the install prompt
|
||||
2. Set the token:
|
||||
- Env: `ZALO_BOT_TOKEN=...`
|
||||
- Or config: `channels.zalo.accounts.default.botToken: "..."`.
|
||||
- Or config: `channels.zalo.botToken: "..."`.
|
||||
3. Restart the gateway (or finish onboarding).
|
||||
4. DM access is pairing by default; approve the pairing code on first contact.
|
||||
|
||||
@@ -36,12 +36,8 @@ Minimal config:
|
||||
channels: {
|
||||
zalo: {
|
||||
enabled: true,
|
||||
accounts: {
|
||||
default: {
|
||||
botToken: "12345689:abc-xyz",
|
||||
dmPolicy: "pairing",
|
||||
},
|
||||
},
|
||||
botToken: "12345689:abc-xyz",
|
||||
dmPolicy: "pairing",
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -52,13 +48,10 @@ Minimal config:
|
||||
Zalo is a Vietnam-focused messaging app; its Bot API lets the Gateway run a bot for 1:1 conversations.
|
||||
It is a good fit for support or notifications where you want deterministic routing back to Zalo.
|
||||
|
||||
This page reflects current OpenClaw behavior for **Zalo Bot Creator / Marketplace bots**.
|
||||
**Zalo Official Account (OA) bots** are a different Zalo product surface and may behave differently.
|
||||
|
||||
- A Zalo Bot API channel owned by the Gateway.
|
||||
- Deterministic routing: replies go back to Zalo; the model never chooses channels.
|
||||
- DMs share the agent's main session.
|
||||
- The [Capabilities](#capabilities) section below shows current Marketplace-bot support.
|
||||
- Groups are supported with policy controls (`groupPolicy` + `groupAllowFrom`) and default to fail-closed allowlist behavior.
|
||||
|
||||
## Setup (fast path)
|
||||
|
||||
@@ -66,7 +59,7 @@ This page reflects current OpenClaw behavior for **Zalo Bot Creator / Marketplac
|
||||
|
||||
1. Go to [https://bot.zaloplatforms.com](https://bot.zaloplatforms.com) and sign in.
|
||||
2. Create a new bot and configure its settings.
|
||||
3. Copy the full bot token (typically `numeric_id:secret`). For Marketplace bots, the usable runtime token may appear in the bot's welcome message after creation.
|
||||
3. Copy the bot token (format: `12345689:abc-xyz`).
|
||||
|
||||
### 2) Configure the token (env or config)
|
||||
|
||||
@@ -77,19 +70,13 @@ Example:
|
||||
channels: {
|
||||
zalo: {
|
||||
enabled: true,
|
||||
accounts: {
|
||||
default: {
|
||||
botToken: "12345689:abc-xyz",
|
||||
dmPolicy: "pairing",
|
||||
},
|
||||
},
|
||||
botToken: "12345689:abc-xyz",
|
||||
dmPolicy: "pairing",
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
If you later move to a Zalo bot surface where groups are available, you can add group-specific config such as `groupPolicy` and `groupAllowFrom` explicitly. For current Marketplace-bot behavior, see [Capabilities](#capabilities).
|
||||
|
||||
Env option: `ZALO_BOT_TOKEN=...` (works for the default account only).
|
||||
|
||||
Multi-account support: use `channels.zalo.accounts` with per-account tokens and optional `name`.
|
||||
@@ -122,23 +109,14 @@ Multi-account support: use `channels.zalo.accounts` with per-account tokens and
|
||||
|
||||
## Access control (Groups)
|
||||
|
||||
For **Zalo Bot Creator / Marketplace bots**, group support was not available in practice because the bot could not be added to a group at all.
|
||||
|
||||
That means the group-related config keys below exist in the schema, but were not usable for Marketplace bots:
|
||||
|
||||
- `channels.zalo.groupPolicy` controls group inbound handling: `open | allowlist | disabled`.
|
||||
- Default behavior is fail-closed: `allowlist`.
|
||||
- `channels.zalo.groupAllowFrom` restricts which sender IDs can trigger the bot in groups.
|
||||
- If `groupAllowFrom` is unset, Zalo falls back to `allowFrom` for sender checks.
|
||||
- `groupPolicy: "disabled"` blocks all group messages.
|
||||
- `groupPolicy: "open"` allows any group member (mention-gated).
|
||||
- Runtime note: if `channels.zalo` is missing entirely, runtime still falls back to `groupPolicy="allowlist"` for safety.
|
||||
|
||||
The group policy values (when group access is available on your bot surface) are:
|
||||
|
||||
- `groupPolicy: "disabled"` — blocks all group messages.
|
||||
- `groupPolicy: "open"` — allows any group member (mention-gated).
|
||||
- `groupPolicy: "allowlist"` — fail-closed default; only allowed senders are accepted.
|
||||
|
||||
If you are using a different Zalo bot product surface and have verified working group behavior, document that separately rather than assuming it matches the Marketplace-bot flow.
|
||||
|
||||
## Long-polling vs webhook
|
||||
|
||||
- Default: long-polling (no public URL required).
|
||||
@@ -155,36 +133,23 @@ If you are using a different Zalo bot product surface and have verified working
|
||||
|
||||
## Supported message types
|
||||
|
||||
For a quick support snapshot, see [Capabilities](#capabilities). The notes below add detail where the behavior needs extra context.
|
||||
|
||||
- **Text messages**: Full support with 2000 character chunking.
|
||||
- **Plain URLs in text**: Behave like normal text input.
|
||||
- **Link previews / rich link cards**: See the Marketplace-bot status in [Capabilities](#capabilities); they did not reliably trigger a reply.
|
||||
- **Image messages**: See the Marketplace-bot status in [Capabilities](#capabilities); inbound image handling was unreliable (typing indicator without a final reply).
|
||||
- **Stickers**: See the Marketplace-bot status in [Capabilities](#capabilities).
|
||||
- **Voice notes / audio files / video / generic file attachments**: See the Marketplace-bot status in [Capabilities](#capabilities).
|
||||
- **Unsupported types**: Logged (for example, messages from protected users).
|
||||
- **Image messages**: Download and process inbound images; send images via `sendPhoto`.
|
||||
- **Stickers**: Logged but not fully processed (no agent response).
|
||||
- **Unsupported types**: Logged (e.g., messages from protected users).
|
||||
|
||||
## Capabilities
|
||||
|
||||
This table summarizes current **Zalo Bot Creator / Marketplace bot** behavior in OpenClaw.
|
||||
|
||||
| Feature | Status |
|
||||
| --------------------------- | --------------------------------------- |
|
||||
| Direct messages | ✅ Supported |
|
||||
| Groups | ❌ Not available for Marketplace bots |
|
||||
| Media (inbound images) | ⚠️ Limited / verify in your environment |
|
||||
| Media (outbound images) | ⚠️ Not re-tested for Marketplace bots |
|
||||
| Plain URLs in text | ✅ Supported |
|
||||
| Link previews | ⚠️ Unreliable for Marketplace bots |
|
||||
| Reactions | ❌ Not supported |
|
||||
| Stickers | ⚠️ No agent reply for Marketplace bots |
|
||||
| Voice notes / audio / video | ⚠️ No agent reply for Marketplace bots |
|
||||
| File attachments | ⚠️ No agent reply for Marketplace bots |
|
||||
| Threads | ❌ Not supported |
|
||||
| Polls | ❌ Not supported |
|
||||
| Native commands | ❌ Not supported |
|
||||
| Streaming | ⚠️ Blocked (2000 char limit) |
|
||||
| Feature | Status |
|
||||
| --------------- | -------------------------------------------------------- |
|
||||
| Direct messages | ✅ Supported |
|
||||
| Groups | ⚠️ Supported with policy controls (allowlist by default) |
|
||||
| Media (images) | ✅ Supported |
|
||||
| Reactions | ❌ Not supported |
|
||||
| Threads | ❌ Not supported |
|
||||
| Polls | ❌ Not supported |
|
||||
| Native commands | ❌ Not supported |
|
||||
| Streaming | ⚠️ Blocked (2000 char limit) |
|
||||
|
||||
## Delivery targets (CLI/cron)
|
||||
|
||||
@@ -210,8 +175,6 @@ This table summarizes current **Zalo Bot Creator / Marketplace bot** behavior in
|
||||
|
||||
Full configuration: [Configuration](/gateway/configuration)
|
||||
|
||||
The flat top-level keys (`channels.zalo.botToken`, `channels.zalo.dmPolicy`, and similar) are a legacy single-account shorthand. Prefer `channels.zalo.accounts.<id>.*` for new configs. Both forms are still documented here because they exist in the schema.
|
||||
|
||||
Provider options:
|
||||
|
||||
- `channels.zalo.enabled`: enable/disable channel startup.
|
||||
@@ -219,7 +182,7 @@ Provider options:
|
||||
- `channels.zalo.tokenFile`: read token from a regular file path. Symlinks are rejected.
|
||||
- `channels.zalo.dmPolicy`: `pairing | allowlist | open | disabled` (default: pairing).
|
||||
- `channels.zalo.allowFrom`: DM allowlist (user IDs). `open` requires `"*"`. The wizard will ask for numeric IDs.
|
||||
- `channels.zalo.groupPolicy`: `open | allowlist | disabled` (default: allowlist). Present in config; see [Capabilities](#capabilities) and [Access control (Groups)](#access-control-groups) for current Marketplace-bot behavior.
|
||||
- `channels.zalo.groupPolicy`: `open | allowlist | disabled` (default: allowlist).
|
||||
- `channels.zalo.groupAllowFrom`: group sender allowlist (user IDs). Falls back to `allowFrom` when unset.
|
||||
- `channels.zalo.mediaMaxMb`: inbound/outbound media cap (MB, default 5).
|
||||
- `channels.zalo.webhookUrl`: enable webhook mode (HTTPS required).
|
||||
@@ -235,7 +198,7 @@ Multi-account options:
|
||||
- `channels.zalo.accounts.<id>.enabled`: enable/disable account.
|
||||
- `channels.zalo.accounts.<id>.dmPolicy`: per-account DM policy.
|
||||
- `channels.zalo.accounts.<id>.allowFrom`: per-account allowlist.
|
||||
- `channels.zalo.accounts.<id>.groupPolicy`: per-account group policy. Present in config; see [Capabilities](#capabilities) and [Access control (Groups)](#access-control-groups) for current Marketplace-bot behavior.
|
||||
- `channels.zalo.accounts.<id>.groupPolicy`: per-account group policy.
|
||||
- `channels.zalo.accounts.<id>.groupAllowFrom`: per-account group sender allowlist.
|
||||
- `channels.zalo.accounts.<id>.webhookUrl`: per-account webhook URL.
|
||||
- `channels.zalo.accounts.<id>.webhookSecret`: per-account webhook secret.
|
||||
|
||||
@@ -1,19 +1,18 @@
|
||||
---
|
||||
summary: "CLI reference for `openclaw plugins` (list, install, uninstall, enable/disable, doctor)"
|
||||
read_when:
|
||||
- You want to install or manage Gateway plugins or compatible bundles
|
||||
- You want to install or manage in-process Gateway plugins
|
||||
- You want to debug plugin load failures
|
||||
title: "plugins"
|
||||
---
|
||||
|
||||
# `openclaw plugins`
|
||||
|
||||
Manage Gateway plugins/extensions and compatible bundles.
|
||||
Manage Gateway plugins/extensions (loaded in-process).
|
||||
|
||||
Related:
|
||||
|
||||
- Plugin system: [Plugins](/tools/plugin)
|
||||
- Bundle compatibility: [Plugin bundles](/plugins/bundles)
|
||||
- Plugin manifest + schema: [Plugin manifest](/plugins/manifest)
|
||||
- Security hardening: [Security](/gateway/security)
|
||||
|
||||
@@ -33,13 +32,9 @@ openclaw plugins update --all
|
||||
Bundled plugins ship with OpenClaw but start disabled. Use `plugins enable` to
|
||||
activate them.
|
||||
|
||||
Native OpenClaw plugins must ship `openclaw.plugin.json` with an inline JSON
|
||||
Schema (`configSchema`, even if empty). Compatible bundles use their own bundle
|
||||
manifests instead.
|
||||
|
||||
`plugins list` shows `Format: openclaw` or `Format: bundle`. Verbose list/info
|
||||
output also shows the bundle subtype (`codex`, `claude`, or `cursor`) plus detected bundle
|
||||
capabilities.
|
||||
All plugins must ship a `openclaw.plugin.json` file with an inline JSON Schema
|
||||
(`configSchema`, even if empty). Missing/invalid manifests or schemas prevent
|
||||
the plugin from loading and fail config validation.
|
||||
|
||||
### Install
|
||||
|
||||
@@ -65,20 +60,6 @@ name, use an explicit scoped spec (for example `@scope/diffs`).
|
||||
|
||||
Supported archives: `.zip`, `.tgz`, `.tar.gz`, `.tar`.
|
||||
|
||||
For local paths and archives, OpenClaw auto-detects:
|
||||
|
||||
- native OpenClaw plugins (`openclaw.plugin.json`)
|
||||
- Codex-compatible bundles (`.codex-plugin/plugin.json`)
|
||||
- Claude-compatible bundles (`.claude-plugin/plugin.json` or the default Claude
|
||||
component layout)
|
||||
- Cursor-compatible bundles (`.cursor-plugin/plugin.json`)
|
||||
|
||||
Compatible bundles install into the normal extensions root and participate in
|
||||
the same list/info/enable/disable flow. Today, bundle skills, Claude
|
||||
command-skills, Claude `settings.json` defaults, Cursor command-skills, and compatible Codex hook
|
||||
directories are supported; other detected bundle capabilities are shown in
|
||||
diagnostics/info but are not yet wired into runtime execution.
|
||||
|
||||
Use `--link` to avoid copying a local directory (adds to `plugins.load.paths`):
|
||||
|
||||
```bash
|
||||
|
||||
@@ -21,7 +21,6 @@ openclaw update wizard
|
||||
openclaw update --channel beta
|
||||
openclaw update --channel dev
|
||||
openclaw update --tag beta
|
||||
openclaw update --tag main
|
||||
openclaw update --dry-run
|
||||
openclaw update --no-restart
|
||||
openclaw update --json
|
||||
@@ -32,7 +31,7 @@ openclaw --update
|
||||
|
||||
- `--no-restart`: skip restarting the Gateway service after a successful update.
|
||||
- `--channel <stable|beta|dev>`: set the update channel (git + npm; persisted in config).
|
||||
- `--tag <dist-tag|version|spec>`: override the package target for this update only. For package installs, `main` maps to `github:openclaw/openclaw#main`.
|
||||
- `--tag <dist-tag|version>`: override the npm dist-tag or version for this update only.
|
||||
- `--dry-run`: preview planned update actions (channel/tag/target/restart flow) without writing config, installing, syncing plugins, or restarting.
|
||||
- `--json`: print machine-readable `UpdateRunResult` JSON.
|
||||
- `--timeout <seconds>`: per-step timeout (default is 1200s).
|
||||
|
||||
@@ -16,53 +16,6 @@ For model selection rules, see [/concepts/models](/concepts/models).
|
||||
- Model refs use `provider/model` (example: `opencode/claude-opus-4-6`).
|
||||
- If you set `agents.defaults.models`, it becomes the allowlist.
|
||||
- CLI helpers: `openclaw onboard`, `openclaw models list`, `openclaw models set <provider/model>`.
|
||||
- Provider plugins can inject model catalogs via `registerProvider({ catalog })`;
|
||||
OpenClaw merges that output into `models.providers` before writing
|
||||
`models.json`.
|
||||
- Provider plugins can also own provider runtime behavior via
|
||||
`resolveDynamicModel`, `prepareDynamicModel`, `normalizeResolvedModel`,
|
||||
`capabilities`, `prepareExtraParams`, `wrapStreamFn`,
|
||||
`isCacheTtlEligible`, and `prepareRuntimeAuth`.
|
||||
|
||||
## Plugin-owned provider behavior
|
||||
|
||||
Provider plugins can now own most provider-specific logic while OpenClaw keeps
|
||||
the generic inference loop.
|
||||
|
||||
Typical split:
|
||||
|
||||
- `catalog`: provider appears in `models.providers`
|
||||
- `resolveDynamicModel`: provider accepts model ids not present in the local
|
||||
static catalog yet
|
||||
- `prepareDynamicModel`: provider needs a metadata refresh before retrying
|
||||
dynamic resolution
|
||||
- `normalizeResolvedModel`: provider needs transport or base URL rewrites
|
||||
- `capabilities`: provider publishes transcript/tooling/provider-family quirks
|
||||
- `prepareExtraParams`: provider defaults or normalizes per-model request params
|
||||
- `wrapStreamFn`: provider applies request headers/body/model compat wrappers
|
||||
- `isCacheTtlEligible`: provider decides which upstream model ids support prompt-cache TTL
|
||||
- `prepareRuntimeAuth`: provider turns a configured credential into a short
|
||||
lived runtime token
|
||||
|
||||
Current bundled examples:
|
||||
|
||||
- `openrouter`: pass-through model ids, request wrappers, provider capability
|
||||
hints, and cache-TTL policy
|
||||
- `github-copilot`: forward-compat model fallback, Claude-thinking transcript
|
||||
hints, and runtime token exchange
|
||||
- `openai-codex`: forward-compat model fallback, transport normalization, and
|
||||
default transport params
|
||||
- `moonshot`: shared transport, plugin-owned thinking payload normalization
|
||||
- `kilocode`: shared transport, plugin-owned request headers, reasoning payload
|
||||
normalization, Gemini transcript hints, and cache-TTL policy
|
||||
- `byteplus`, `cloudflare-ai-gateway`, `huggingface`, `kimi-coding`,
|
||||
`minimax`, `minimax-portal`, `modelstudio`, `nvidia`, `qianfan`,
|
||||
`qwen-portal`, `synthetic`, `together`, `venice`, `vercel-ai-gateway`,
|
||||
`volcengine`, and `xiaomi`: plugin-owned catalogs only
|
||||
|
||||
That covers providers that still fit OpenClaw's normal transports. A provider
|
||||
that needs a totally custom request executor is a separate, deeper extension
|
||||
surface.
|
||||
|
||||
## API key rotation
|
||||
|
||||
@@ -201,26 +154,12 @@ OpenClaw ships with the pi‑ai catalog. These providers require **no**
|
||||
|
||||
See [/providers/kilocode](/providers/kilocode) for setup details.
|
||||
|
||||
### Other bundled provider plugins
|
||||
### Other built-in providers
|
||||
|
||||
- OpenRouter: `openrouter` (`OPENROUTER_API_KEY`)
|
||||
- Example model: `openrouter/anthropic/claude-sonnet-4-5`
|
||||
- Kilo Gateway: `kilocode` (`KILOCODE_API_KEY`)
|
||||
- Example model: `kilocode/anthropic/claude-opus-4.6`
|
||||
- MiniMax: `minimax` (`MINIMAX_API_KEY`)
|
||||
- Moonshot: `moonshot` (`MOONSHOT_API_KEY`)
|
||||
- Kimi Coding: `kimi-coding` (`KIMI_API_KEY` or `KIMICODE_API_KEY`)
|
||||
- Qianfan: `qianfan` (`QIANFAN_API_KEY`)
|
||||
- Model Studio: `modelstudio` (`MODELSTUDIO_API_KEY`)
|
||||
- NVIDIA: `nvidia` (`NVIDIA_API_KEY`)
|
||||
- Together: `together` (`TOGETHER_API_KEY`)
|
||||
- Venice: `venice` (`VENICE_API_KEY`)
|
||||
- Xiaomi: `xiaomi` (`XIAOMI_API_KEY`)
|
||||
- Vercel AI Gateway: `vercel-ai-gateway` (`AI_GATEWAY_API_KEY`)
|
||||
- Hugging Face Inference: `huggingface` (`HUGGINGFACE_HUB_TOKEN` or `HF_TOKEN`)
|
||||
- Cloudflare AI Gateway: `cloudflare-ai-gateway` (`CLOUDFLARE_AI_GATEWAY_API_KEY`)
|
||||
- Volcengine: `volcengine` (`VOLCANO_ENGINE_API_KEY`)
|
||||
- BytePlus: `byteplus` (`BYTEPLUS_API_KEY`)
|
||||
- xAI: `xai` (`XAI_API_KEY`)
|
||||
- Mistral: `mistral` (`MISTRAL_API_KEY`)
|
||||
- Example model: `mistral/mistral-large-latest`
|
||||
@@ -230,17 +169,13 @@ See [/providers/kilocode](/providers/kilocode) for setup details.
|
||||
- GLM models on Cerebras use ids `zai-glm-4.7` and `zai-glm-4.6`.
|
||||
- OpenAI-compatible base URL: `https://api.cerebras.ai/v1`.
|
||||
- GitHub Copilot: `github-copilot` (`COPILOT_GITHUB_TOKEN` / `GH_TOKEN` / `GITHUB_TOKEN`)
|
||||
- Hugging Face Inference example model: `huggingface/deepseek-ai/DeepSeek-R1`; CLI: `openclaw onboard --auth-choice huggingface-api-key`. See [Hugging Face (Inference)](/providers/huggingface).
|
||||
- Hugging Face Inference: `huggingface` (`HUGGINGFACE_HUB_TOKEN` or `HF_TOKEN`) — OpenAI-compatible router; example model: `huggingface/deepseek-ai/DeepSeek-R1`; CLI: `openclaw onboard --auth-choice huggingface-api-key`. See [Hugging Face (Inference)](/providers/huggingface).
|
||||
|
||||
## Providers via `models.providers` (custom/base URL)
|
||||
|
||||
Use `models.providers` (or `models.json`) to add **custom** providers or
|
||||
OpenAI/Anthropic‑compatible proxies.
|
||||
|
||||
Many of the bundled provider plugins below already publish a default catalog.
|
||||
Use explicit `models.providers.<id>` entries only when you want to override the
|
||||
default base URL, headers, or model list.
|
||||
|
||||
### Moonshot AI (Kimi)
|
||||
|
||||
Moonshot uses OpenAI-compatible endpoints, so configure it as a custom provider:
|
||||
@@ -300,9 +235,10 @@ Kimi Coding uses Moonshot AI's Anthropic-compatible endpoint:
|
||||
### Qwen OAuth (free tier)
|
||||
|
||||
Qwen provides OAuth access to Qwen Coder + Vision via a device-code flow.
|
||||
The bundled provider plugin is enabled by default, so just log in:
|
||||
Enable the bundled plugin, then log in:
|
||||
|
||||
```bash
|
||||
openclaw plugins enable qwen-portal-auth
|
||||
openclaw models auth login --provider qwen-portal --set-default
|
||||
```
|
||||
|
||||
|
||||
@@ -469,7 +469,7 @@
|
||||
},
|
||||
{
|
||||
"source": "/mac/release",
|
||||
"destination": "/reference/RELEASING"
|
||||
"destination": "/platforms/mac/release"
|
||||
},
|
||||
{
|
||||
"source": "/mac/remote",
|
||||
@@ -1046,7 +1046,6 @@
|
||||
"group": "Extensions",
|
||||
"pages": [
|
||||
"plugins/community",
|
||||
"plugins/bundles",
|
||||
"plugins/voice-call",
|
||||
"plugins/zalouser",
|
||||
"plugins/manifest",
|
||||
@@ -1167,6 +1166,7 @@
|
||||
"platforms/mac/permissions",
|
||||
"platforms/mac/remote",
|
||||
"platforms/mac/signing",
|
||||
"platforms/mac/release",
|
||||
"platforms/mac/bundled-gateway",
|
||||
"platforms/mac/xpc",
|
||||
"platforms/mac/skills",
|
||||
@@ -1351,7 +1351,7 @@
|
||||
"pages": ["reference/credits"]
|
||||
},
|
||||
{
|
||||
"group": "Release policy",
|
||||
"group": "Release notes",
|
||||
"pages": ["reference/RELEASING", "reference/test"]
|
||||
},
|
||||
{
|
||||
@@ -1750,6 +1750,7 @@
|
||||
"zh-CN/platforms/mac/permissions",
|
||||
"zh-CN/platforms/mac/remote",
|
||||
"zh-CN/platforms/mac/signing",
|
||||
"zh-CN/platforms/mac/release",
|
||||
"zh-CN/platforms/mac/bundled-gateway",
|
||||
"zh-CN/platforms/mac/xpc",
|
||||
"zh-CN/platforms/mac/skills",
|
||||
@@ -1932,7 +1933,7 @@
|
||||
"pages": ["zh-CN/reference/credits"]
|
||||
},
|
||||
{
|
||||
"group": "发布策略",
|
||||
"group": "发布说明",
|
||||
"pages": ["zh-CN/reference/RELEASING", "zh-CN/reference/test"]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -655,12 +655,12 @@ See the full channel index: [Channels](/channels).
|
||||
|
||||
### Group chat mention gating
|
||||
|
||||
Group messages default to **require mention** (metadata mention or safe regex patterns). Applies to WhatsApp, Telegram, Discord, Google Chat, and iMessage group chats.
|
||||
Group messages default to **require mention** (metadata mention or regex patterns). Applies to WhatsApp, Telegram, Discord, Google Chat, and iMessage group chats.
|
||||
|
||||
**Mention types:**
|
||||
|
||||
- **Metadata mentions**: Native platform @-mentions. Ignored in WhatsApp self-chat mode.
|
||||
- **Text patterns**: Safe regex patterns in `agents.list[].groupChat.mentionPatterns`. Invalid patterns and unsafe nested repetition are ignored.
|
||||
- **Text patterns**: Regex patterns in `agents.list[].groupChat.mentionPatterns`. Always checked.
|
||||
- Mention gating is enforced only when detection is possible (native mentions or at least one pattern).
|
||||
|
||||
```json5
|
||||
@@ -1005,7 +1005,6 @@ Periodic heartbeat runs.
|
||||
defaults: {
|
||||
compaction: {
|
||||
mode: "safeguard", // default | safeguard
|
||||
timeoutSeconds: 900,
|
||||
reserveTokensFloor: 24000,
|
||||
identifierPolicy: "strict", // strict | off | custom
|
||||
identifierInstructions: "Preserve deployment IDs, ticket IDs, and host:port pairs exactly.", // used when identifierPolicy=custom
|
||||
@@ -1024,7 +1023,6 @@ Periodic heartbeat runs.
|
||||
```
|
||||
|
||||
- `mode`: `default` or `safeguard` (chunked summarization for long histories). See [Compaction](/concepts/compaction).
|
||||
- `timeoutSeconds`: maximum seconds allowed for a single compaction operation before OpenClaw aborts it. Default: `900`.
|
||||
- `identifierPolicy`: `strict` (default), `off`, or `custom`. `strict` prepends built-in opaque identifier retention guidance during compaction summarization.
|
||||
- `identifierInstructions`: optional custom identifier-preservation text used when `identifierPolicy=custom`.
|
||||
- `postCompactionSections`: optional AGENTS.md H2/H3 section names to re-inject after compaction. Defaults to `["Session Startup", "Red Lines"]`; set `[]` to disable reinjection. When unset or explicitly set to that default pair, older `Every Session`/`Safety` headings are also accepted as a legacy fallback.
|
||||
@@ -2323,14 +2321,12 @@ See [Local Models](/gateway/local-models). TL;DR: run MiniMax M2.5 via LM Studio
|
||||
```
|
||||
|
||||
- Loaded from `~/.openclaw/extensions`, `<workspace>/.openclaw/extensions`, plus `plugins.load.paths`.
|
||||
- Discovery accepts native OpenClaw plugins plus compatible Codex bundles and Claude bundles, including manifestless Claude default-layout bundles.
|
||||
- **Config changes require a gateway restart.**
|
||||
- `allow`: optional allowlist (only listed plugins load). `deny` wins.
|
||||
- `plugins.entries.<id>.apiKey`: plugin-level API key convenience field (when supported by the plugin).
|
||||
- `plugins.entries.<id>.env`: plugin-scoped env var map.
|
||||
- `plugins.entries.<id>.hooks.allowPromptInjection`: when `false`, core blocks `before_prompt_build` and ignores prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride`. Applies to native plugin hooks and supported bundle-provided hook directories.
|
||||
- `plugins.entries.<id>.config`: plugin-defined config object (validated by native OpenClaw plugin schema when available).
|
||||
- Enabled Claude bundle plugins can also contribute embedded Pi defaults from `settings.json`; OpenClaw applies those as sanitized agent settings, not as raw OpenClaw config patches.
|
||||
- `plugins.entries.<id>.hooks.allowPromptInjection`: when `false`, core blocks `before_prompt_build` and ignores prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride`.
|
||||
- `plugins.entries.<id>.config`: plugin-defined config object (validated by plugin schema).
|
||||
- `plugins.slots.memory`: pick the active memory plugin id, or `"none"` to disable memory plugins.
|
||||
- `plugins.slots.contextEngine`: pick the active context engine plugin id; defaults to `"legacy"` unless you install and select another engine.
|
||||
- `plugins.installs`: CLI-managed install metadata used by `openclaw plugins update`.
|
||||
@@ -2374,7 +2370,6 @@ See [Plugins](/tools/plugin).
|
||||
- `evaluateEnabled: false` disables `act:evaluate` and `wait --fn`.
|
||||
- `ssrfPolicy.dangerouslyAllowPrivateNetwork` defaults to `true` when unset (trusted-network model).
|
||||
- Set `ssrfPolicy.dangerouslyAllowPrivateNetwork: false` for strict public-only browser navigation.
|
||||
- In strict mode, remote CDP profile endpoints (`profiles.*.cdpUrl`) are subject to the same private-network blocking during reachability/discovery checks.
|
||||
- `ssrfPolicy.allowPrivateNetwork` remains supported as a legacy alias.
|
||||
- In strict mode, use `ssrfPolicy.hostnameAllowlist` and `ssrfPolicy.allowedHostnames` for explicit exceptions.
|
||||
- Remote profiles are attach-only (start/stop/reset disabled).
|
||||
@@ -2492,11 +2487,6 @@ See [Plugins](/tools/plugin).
|
||||
- Relay-backed registrations are delegated to a specific gateway identity. The paired iOS app fetches `gateway.identity.get`, includes that identity in the relay registration, and forwards a registration-scoped send grant to the gateway. Another gateway cannot reuse that stored registration.
|
||||
- `OPENCLAW_APNS_RELAY_BASE_URL` / `OPENCLAW_APNS_RELAY_TIMEOUT_MS`: temporary env overrides for the relay config above.
|
||||
- `OPENCLAW_APNS_RELAY_ALLOW_HTTP=true`: development-only escape hatch for loopback HTTP relay URLs. Production relay URLs should stay on HTTPS.
|
||||
- `gateway.channelHealthCheckMinutes`: channel health-monitor interval in minutes. Set `0` to disable health-monitor restarts globally. Default: `5`.
|
||||
- `gateway.channelStaleEventThresholdMinutes`: stale-socket threshold in minutes. Keep this greater than or equal to `gateway.channelHealthCheckMinutes`. Default: `30`.
|
||||
- `gateway.channelMaxRestartsPerHour`: maximum health-monitor restarts per channel/account in a rolling hour. Default: `10`.
|
||||
- `channels.<provider>.healthMonitor.enabled`: per-channel opt-out for health-monitor restarts while keeping the global monitor enabled.
|
||||
- `channels.<provider>.accounts.<accountId>.healthMonitor.enabled`: per-account override for multi-account channels. When set, it takes precedence over the channel-level override.
|
||||
- Local gateway call paths can use `gateway.remote.*` as fallback only when `gateway.auth.*` is unset.
|
||||
- If `gateway.auth.token` / `gateway.auth.password` is explicitly configured via SecretRef and unresolved, resolution fails closed (no remote fallback masking).
|
||||
- `trustedProxies`: reverse proxy IPs that terminate TLS. Only list proxies you control.
|
||||
|
||||
@@ -170,41 +170,11 @@ When validation fails:
|
||||
```
|
||||
|
||||
- **Metadata mentions**: native @-mentions (WhatsApp tap-to-mention, Telegram @bot, etc.)
|
||||
- **Text patterns**: safe regex patterns in `mentionPatterns`
|
||||
- **Text patterns**: regex patterns in `mentionPatterns`
|
||||
- See [full reference](/gateway/configuration-reference#group-chat-mention-gating) for per-channel overrides and self-chat mode.
|
||||
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Tune gateway channel health monitoring">
|
||||
Control how aggressively the gateway restarts channels that look stale:
|
||||
|
||||
```json5
|
||||
{
|
||||
gateway: {
|
||||
channelHealthCheckMinutes: 5,
|
||||
channelStaleEventThresholdMinutes: 30,
|
||||
channelMaxRestartsPerHour: 10,
|
||||
},
|
||||
channels: {
|
||||
telegram: {
|
||||
healthMonitor: { enabled: false },
|
||||
accounts: {
|
||||
alerts: {
|
||||
healthMonitor: { enabled: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
- Set `gateway.channelHealthCheckMinutes: 0` to disable health-monitor restarts globally.
|
||||
- `channelStaleEventThresholdMinutes` should be greater than or equal to the check interval.
|
||||
- Use `channels.<provider>.healthMonitor.enabled` or `channels.<provider>.accounts.<id>.healthMonitor.enabled` to disable auto-restarts for one channel or account without disabling the global monitor.
|
||||
- See [Health Checks](/gateway/health) for operational debugging and the [full reference](/gateway/configuration-reference#gateway) for all fields.
|
||||
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Configure sessions and resets">
|
||||
Sessions control conversation continuity and isolation:
|
||||
|
||||
|
||||
@@ -24,15 +24,6 @@ Short guide to verify channel connectivity without guessing.
|
||||
- Session store: `ls -l ~/.openclaw/agents/<agentId>/sessions/sessions.json` (path can be overridden in config). Count and recent recipients are surfaced via `status`.
|
||||
- Relink flow: `openclaw channels logout && openclaw channels login --verbose` when status codes 409–515 or `loggedOut` appear in logs. (Note: the QR login flow auto-restarts once for status 515 after pairing.)
|
||||
|
||||
## Health monitor config
|
||||
|
||||
- `gateway.channelHealthCheckMinutes`: how often the gateway checks channel health. Default: `5`. Set `0` to disable health-monitor restarts globally.
|
||||
- `gateway.channelStaleEventThresholdMinutes`: how long a connected channel can stay idle before the health monitor treats it as stale and restarts it. Default: `30`. Keep this greater than or equal to `gateway.channelHealthCheckMinutes`.
|
||||
- `gateway.channelMaxRestartsPerHour`: rolling one-hour cap for health-monitor restarts per channel/account. Default: `10`.
|
||||
- `channels.<provider>.healthMonitor.enabled`: disable health-monitor restarts for a specific channel while leaving global monitoring enabled.
|
||||
- `channels.<provider>.accounts.<accountId>.healthMonitor.enabled`: multi-account override that wins over the channel-level setting.
|
||||
- These per-channel overrides apply to the built-in channel monitors that expose them today: Discord, Google Chat, iMessage, Microsoft Teams, Signal, Slack, Telegram, and WhatsApp.
|
||||
|
||||
## When something fails
|
||||
|
||||
- `logged out` or status 409–515 → relink with `openclaw channels logout` then `openclaw channels login`.
|
||||
|
||||
@@ -40,17 +40,11 @@ pnpm gateway:watch
|
||||
This maps to:
|
||||
|
||||
```bash
|
||||
node scripts/watch-node.mjs gateway --force
|
||||
node --watch-path src --watch-path tsconfig.json --watch-path package.json --watch-preserve-output scripts/run-node.mjs gateway --force
|
||||
```
|
||||
|
||||
The watcher restarts on build-relevant files under `src/`, extension source files,
|
||||
extension `package.json` and `openclaw.plugin.json` metadata, `tsconfig.json`,
|
||||
`package.json`, and `tsdown.config.ts`. Extension metadata changes restart the
|
||||
gateway without forcing a `tsdown` rebuild; source and config changes still
|
||||
rebuild `dist` first.
|
||||
|
||||
Add any gateway CLI flags after `gateway:watch` and they will be passed through on
|
||||
each restart.
|
||||
Add any gateway CLI flags after `gateway:watch` and they will be passed through
|
||||
on each restart.
|
||||
|
||||
## Dev profile + dev gateway (--dev)
|
||||
|
||||
|
||||
@@ -102,16 +102,6 @@ For VPS/cloud hosts, avoid third-party "1-click" marketplace images when possibl
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
Want the current GitHub `main` head with a package-manager install?
|
||||
|
||||
```bash
|
||||
npm install -g github:openclaw/openclaw#main
|
||||
```
|
||||
|
||||
```bash
|
||||
pnpm add -g github:openclaw/openclaw#main
|
||||
```
|
||||
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="From source" icon="github">
|
||||
|
||||
@@ -116,11 +116,6 @@ The script exits with code `2` for invalid method selection or invalid `--instal
|
||||
curl -fsSL --proto '=https' --tlsv1.2 https://openclaw.ai/install.sh | bash -s -- --install-method git
|
||||
```
|
||||
</Tab>
|
||||
<Tab title="GitHub main via npm">
|
||||
```bash
|
||||
curl -fsSL --proto '=https' --tlsv1.2 https://openclaw.ai/install.sh | bash -s -- --version main
|
||||
```
|
||||
</Tab>
|
||||
<Tab title="Dry run">
|
||||
```bash
|
||||
curl -fsSL --proto '=https' --tlsv1.2 https://openclaw.ai/install.sh | bash -s -- --dry-run
|
||||
@@ -131,39 +126,39 @@ The script exits with code `2` for invalid method selection or invalid `--instal
|
||||
<AccordionGroup>
|
||||
<Accordion title="Flags reference">
|
||||
|
||||
| Flag | Description |
|
||||
| ------------------------------------- | ---------------------------------------------------------- |
|
||||
| `--install-method npm\|git` | Choose install method (default: `npm`). Alias: `--method` |
|
||||
| `--npm` | Shortcut for npm method |
|
||||
| `--git` | Shortcut for git method. Alias: `--github` |
|
||||
| `--version <version\|dist-tag\|spec>` | npm version, dist-tag, or package spec (default: `latest`) |
|
||||
| `--beta` | Use beta dist-tag if available, else fallback to `latest` |
|
||||
| `--git-dir <path>` | Checkout directory (default: `~/openclaw`). Alias: `--dir` |
|
||||
| `--no-git-update` | Skip `git pull` for existing checkout |
|
||||
| `--no-prompt` | Disable prompts |
|
||||
| `--no-onboard` | Skip onboarding |
|
||||
| `--onboard` | Enable onboarding |
|
||||
| `--dry-run` | Print actions without applying changes |
|
||||
| `--verbose` | Enable debug output (`set -x`, npm notice-level logs) |
|
||||
| `--help` | Show usage (`-h`) |
|
||||
| Flag | Description |
|
||||
| ------------------------------- | ---------------------------------------------------------- |
|
||||
| `--install-method npm\|git` | Choose install method (default: `npm`). Alias: `--method` |
|
||||
| `--npm` | Shortcut for npm method |
|
||||
| `--git` | Shortcut for git method. Alias: `--github` |
|
||||
| `--version <version\|dist-tag>` | npm version or dist-tag (default: `latest`) |
|
||||
| `--beta` | Use beta dist-tag if available, else fallback to `latest` |
|
||||
| `--git-dir <path>` | Checkout directory (default: `~/openclaw`). Alias: `--dir` |
|
||||
| `--no-git-update` | Skip `git pull` for existing checkout |
|
||||
| `--no-prompt` | Disable prompts |
|
||||
| `--no-onboard` | Skip onboarding |
|
||||
| `--onboard` | Enable onboarding |
|
||||
| `--dry-run` | Print actions without applying changes |
|
||||
| `--verbose` | Enable debug output (`set -x`, npm notice-level logs) |
|
||||
| `--help` | Show usage (`-h`) |
|
||||
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Environment variables reference">
|
||||
|
||||
| Variable | Description |
|
||||
| ------------------------------------------------------- | --------------------------------------------- |
|
||||
| `OPENCLAW_INSTALL_METHOD=git\|npm` | Install method |
|
||||
| `OPENCLAW_VERSION=latest\|next\|main\|<semver>\|<spec>` | npm version, dist-tag, or package spec |
|
||||
| `OPENCLAW_BETA=0\|1` | Use beta if available |
|
||||
| `OPENCLAW_GIT_DIR=<path>` | Checkout directory |
|
||||
| `OPENCLAW_GIT_UPDATE=0\|1` | Toggle git updates |
|
||||
| `OPENCLAW_NO_PROMPT=1` | Disable prompts |
|
||||
| `OPENCLAW_NO_ONBOARD=1` | Skip onboarding |
|
||||
| `OPENCLAW_DRY_RUN=1` | Dry run mode |
|
||||
| `OPENCLAW_VERBOSE=1` | Debug mode |
|
||||
| `OPENCLAW_NPM_LOGLEVEL=error\|warn\|notice` | npm log level |
|
||||
| `SHARP_IGNORE_GLOBAL_LIBVIPS=0\|1` | Control sharp/libvips behavior (default: `1`) |
|
||||
| Variable | Description |
|
||||
| ------------------------------------------- | --------------------------------------------- |
|
||||
| `OPENCLAW_INSTALL_METHOD=git\|npm` | Install method |
|
||||
| `OPENCLAW_VERSION=latest\|next\|<semver>` | npm version or dist-tag |
|
||||
| `OPENCLAW_BETA=0\|1` | Use beta if available |
|
||||
| `OPENCLAW_GIT_DIR=<path>` | Checkout directory |
|
||||
| `OPENCLAW_GIT_UPDATE=0\|1` | Toggle git updates |
|
||||
| `OPENCLAW_NO_PROMPT=1` | Disable prompts |
|
||||
| `OPENCLAW_NO_ONBOARD=1` | Skip onboarding |
|
||||
| `OPENCLAW_DRY_RUN=1` | Dry run mode |
|
||||
| `OPENCLAW_VERBOSE=1` | Debug mode |
|
||||
| `OPENCLAW_NPM_LOGLEVEL=error\|warn\|notice` | npm log level |
|
||||
| `SHARP_IGNORE_GLOBAL_LIBVIPS=0\|1` | Control sharp/libvips behavior (default: `1`) |
|
||||
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
@@ -281,11 +276,6 @@ Designed for environments where you want everything under a local prefix (defaul
|
||||
& ([scriptblock]::Create((iwr -useb https://openclaw.ai/install.ps1))) -InstallMethod git
|
||||
```
|
||||
</Tab>
|
||||
<Tab title="GitHub main via npm">
|
||||
```powershell
|
||||
& ([scriptblock]::Create((iwr -useb https://openclaw.ai/install.ps1))) -Tag main
|
||||
```
|
||||
</Tab>
|
||||
<Tab title="Custom git directory">
|
||||
```powershell
|
||||
& ([scriptblock]::Create((iwr -useb https://openclaw.ai/install.ps1))) -InstallMethod git -GitDir "C:\openclaw"
|
||||
@@ -309,14 +299,14 @@ Designed for environments where you want everything under a local prefix (defaul
|
||||
<AccordionGroup>
|
||||
<Accordion title="Flags reference">
|
||||
|
||||
| Flag | Description |
|
||||
| --------------------------- | ---------------------------------------------------------- |
|
||||
| `-InstallMethod npm\|git` | Install method (default: `npm`) |
|
||||
| `-Tag <tag\|version\|spec>` | npm dist-tag, version, or package spec (default: `latest`) |
|
||||
| `-GitDir <path>` | Checkout directory (default: `%USERPROFILE%\openclaw`) |
|
||||
| `-NoOnboard` | Skip onboarding |
|
||||
| `-NoGitUpdate` | Skip `git pull` |
|
||||
| `-DryRun` | Print actions only |
|
||||
| Flag | Description |
|
||||
| ------------------------- | ------------------------------------------------------ |
|
||||
| `-InstallMethod npm\|git` | Install method (default: `npm`) |
|
||||
| `-Tag <tag>` | npm dist-tag (default: `latest`) |
|
||||
| `-GitDir <path>` | Checkout directory (default: `%USERPROFILE%\openclaw`) |
|
||||
| `-NoOnboard` | Skip onboarding |
|
||||
| `-NoGitUpdate` | Skip `git pull` |
|
||||
| `-DryRun` | Print actions only |
|
||||
|
||||
</Accordion>
|
||||
|
||||
|
||||
@@ -65,25 +65,7 @@ openclaw update --channel dev
|
||||
openclaw update --channel stable
|
||||
```
|
||||
|
||||
Use `--tag <dist-tag|version|spec>` for a one-off package target override.
|
||||
|
||||
For the current GitHub `main` head via a package-manager install:
|
||||
|
||||
```bash
|
||||
openclaw update --tag main
|
||||
```
|
||||
|
||||
Manual equivalents:
|
||||
|
||||
```bash
|
||||
npm i -g github:openclaw/openclaw#main
|
||||
```
|
||||
|
||||
```bash
|
||||
pnpm add -g github:openclaw/openclaw#main
|
||||
```
|
||||
|
||||
You can also pass an explicit package spec to `--tag` for one-off updates (for example a GitHub ref or tarball URL).
|
||||
Use `--tag <dist-tag|version>` for a one-off install tag/version.
|
||||
|
||||
See [Development channels](/install/development-channels) for channel semantics and release notes.
|
||||
|
||||
|
||||
@@ -285,7 +285,6 @@ Available families:
|
||||
- `photos.latest`
|
||||
- `contacts.search`, `contacts.add`
|
||||
- `calendar.events`, `calendar.add`
|
||||
- `callLog.search`
|
||||
- `motion.activity`, `motion.pedometer`
|
||||
|
||||
Example invokes:
|
||||
|
||||
@@ -163,5 +163,4 @@ See [Camera node](/nodes/camera) for parameters and CLI helpers.
|
||||
- `photos.latest`
|
||||
- `contacts.search`, `contacts.add`
|
||||
- `calendar.events`, `calendar.add`
|
||||
- `callLog.search`
|
||||
- `motion.activity`, `motion.pedometer`
|
||||
|
||||
90
docs/platforms/mac/release.md
Normal file
90
docs/platforms/mac/release.md
Normal file
@@ -0,0 +1,90 @@
|
||||
---
|
||||
summary: "OpenClaw macOS release checklist (Sparkle feed, packaging, signing)"
|
||||
read_when:
|
||||
- Cutting or validating a OpenClaw macOS release
|
||||
- Updating the Sparkle appcast or feed assets
|
||||
title: "macOS Release"
|
||||
---
|
||||
|
||||
# OpenClaw macOS release (Sparkle)
|
||||
|
||||
This app now ships Sparkle auto-updates. Release builds must be Developer ID–signed, zipped, and published with a signed appcast entry.
|
||||
|
||||
## Prereqs
|
||||
|
||||
- Developer ID Application cert installed (example: `Developer ID Application: <Developer Name> (<TEAMID>)`).
|
||||
- Sparkle private key path set in the environment as `SPARKLE_PRIVATE_KEY_FILE` (path to your Sparkle ed25519 private key; public key baked into Info.plist). If it is missing, check `~/.profile`.
|
||||
- Notary credentials (keychain profile or API key) for `xcrun notarytool` if you want Gatekeeper-safe DMG/zip distribution.
|
||||
- We use a Keychain profile named `openclaw-notary`, created from App Store Connect API key env vars in your shell profile:
|
||||
- `APP_STORE_CONNECT_API_KEY_P8`, `APP_STORE_CONNECT_KEY_ID`, `APP_STORE_CONNECT_ISSUER_ID`
|
||||
- `echo "$APP_STORE_CONNECT_API_KEY_P8" | sed 's/\\n/\n/g' > /tmp/openclaw-notary.p8`
|
||||
- `xcrun notarytool store-credentials "openclaw-notary" --key /tmp/openclaw-notary.p8 --key-id "$APP_STORE_CONNECT_KEY_ID" --issuer "$APP_STORE_CONNECT_ISSUER_ID"`
|
||||
- `pnpm` deps installed (`pnpm install --config.node-linker=hoisted`).
|
||||
- Sparkle tools are fetched automatically via SwiftPM at `apps/macos/.build/artifacts/sparkle/Sparkle/bin/` (`sign_update`, `generate_appcast`, etc.).
|
||||
|
||||
## Build & package
|
||||
|
||||
Notes:
|
||||
|
||||
- `APP_BUILD` maps to `CFBundleVersion`/`sparkle:version`; keep it numeric + monotonic (no `-beta`), or Sparkle compares it as equal.
|
||||
- If `APP_BUILD` is omitted, `scripts/package-mac-app.sh` derives a Sparkle-safe default from `APP_VERSION` (`YYYYMMDDNN`: stable defaults to `90`, prereleases use a suffix-derived lane) and uses the higher of that value and git commit count.
|
||||
- You can still override `APP_BUILD` explicitly when release engineering needs a specific monotonic value.
|
||||
- For `BUILD_CONFIG=release`, `scripts/package-mac-app.sh` now defaults to universal (`arm64 x86_64`) automatically. You can still override with `BUILD_ARCHS=arm64` or `BUILD_ARCHS=x86_64`. For local/dev builds (`BUILD_CONFIG=debug`), it defaults to the current architecture (`$(uname -m)`).
|
||||
- Use `scripts/package-mac-dist.sh` for release artifacts (zip + DMG + notarization). Use `scripts/package-mac-app.sh` for local/dev packaging.
|
||||
|
||||
```bash
|
||||
# From repo root; set release IDs so Sparkle feed is enabled.
|
||||
# This command builds release artifacts without notarization.
|
||||
# APP_BUILD must be numeric + monotonic for Sparkle compare.
|
||||
# Default is auto-derived from APP_VERSION when omitted.
|
||||
SKIP_NOTARIZE=1 \
|
||||
BUNDLE_ID=ai.openclaw.mac \
|
||||
APP_VERSION=2026.3.13 \
|
||||
BUILD_CONFIG=release \
|
||||
SIGN_IDENTITY="Developer ID Application: <Developer Name> (<TEAMID>)" \
|
||||
scripts/package-mac-dist.sh
|
||||
|
||||
# `package-mac-dist.sh` already creates the zip + DMG.
|
||||
# If you used `package-mac-app.sh` directly instead, create them manually:
|
||||
# If you want notarization/stapling in this step, use the NOTARIZE command below.
|
||||
ditto -c -k --sequesterRsrc --keepParent dist/OpenClaw.app dist/OpenClaw-2026.3.13.zip
|
||||
|
||||
# Optional: build a styled DMG for humans (drag to /Applications)
|
||||
scripts/create-dmg.sh dist/OpenClaw.app dist/OpenClaw-2026.3.13.dmg
|
||||
|
||||
# Recommended: build + notarize/staple zip + DMG
|
||||
# First, create a keychain profile once:
|
||||
# xcrun notarytool store-credentials "openclaw-notary" \
|
||||
# --apple-id "<apple-id>" --team-id "<team-id>" --password "<app-specific-password>"
|
||||
NOTARIZE=1 NOTARYTOOL_PROFILE=openclaw-notary \
|
||||
BUNDLE_ID=ai.openclaw.mac \
|
||||
APP_VERSION=2026.3.13 \
|
||||
BUILD_CONFIG=release \
|
||||
SIGN_IDENTITY="Developer ID Application: <Developer Name> (<TEAMID>)" \
|
||||
scripts/package-mac-dist.sh
|
||||
|
||||
# Optional: ship dSYM alongside the release
|
||||
ditto -c -k --keepParent apps/macos/.build/release/OpenClaw.app.dSYM dist/OpenClaw-2026.3.13.dSYM.zip
|
||||
```
|
||||
|
||||
## Appcast entry
|
||||
|
||||
Use the release note generator so Sparkle renders formatted HTML notes:
|
||||
|
||||
```bash
|
||||
SPARKLE_PRIVATE_KEY_FILE=/path/to/ed25519-private-key scripts/make_appcast.sh dist/OpenClaw-2026.3.13.zip https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml
|
||||
```
|
||||
|
||||
Generates HTML release notes from `CHANGELOG.md` (via [`scripts/changelog-to-html.sh`](https://github.com/openclaw/openclaw/blob/main/scripts/changelog-to-html.sh)) and embeds them in the appcast entry.
|
||||
Commit the updated `appcast.xml` alongside the release assets (zip + dSYM) when publishing.
|
||||
|
||||
## Publish & verify
|
||||
|
||||
- Upload `OpenClaw-2026.3.13.zip` (and `OpenClaw-2026.3.13.dSYM.zip`) to the GitHub release for tag `v2026.3.13`.
|
||||
- Ensure the raw appcast URL matches the baked feed: `https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml`.
|
||||
- Sanity checks:
|
||||
- `curl -I https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml` returns 200.
|
||||
- `curl -I <enclosure url>` returns 200 after assets upload.
|
||||
- On a previous public build, run “Check for Updates…” from the About tab and verify Sparkle installs the new build cleanly.
|
||||
|
||||
Definition of done: signed app + appcast are published, update flow works from an older installed version, and release assets are attached to the GitHub release.
|
||||
@@ -1,245 +0,0 @@
|
||||
---
|
||||
summary: "Compatible Codex/Claude bundle formats: detection, mapping, and current OpenClaw support"
|
||||
read_when:
|
||||
- You want to install or debug a Codex/Claude-compatible bundle
|
||||
- You need to understand how OpenClaw maps bundle content into native features
|
||||
- You are documenting bundle compatibility or current support limits
|
||||
title: "Plugin Bundles"
|
||||
---
|
||||
|
||||
# Plugin bundles
|
||||
|
||||
OpenClaw supports three **compatible bundle formats** in addition to native
|
||||
OpenClaw plugins:
|
||||
|
||||
- Codex bundles
|
||||
- Claude bundles
|
||||
- Cursor bundles
|
||||
|
||||
OpenClaw shows both as `Format: bundle` in `openclaw plugins list`. Verbose
|
||||
output and `openclaw plugins info <id>` also show the bundle subtype
|
||||
(`codex`, `claude`, or `cursor`).
|
||||
|
||||
Related:
|
||||
|
||||
- Plugin system overview: [Plugins](/tools/plugin)
|
||||
- CLI install/list flows: [plugins](/cli/plugins)
|
||||
- Native manifest schema: [Plugin manifest](/plugins/manifest)
|
||||
|
||||
## What a bundle is
|
||||
|
||||
A bundle is a **content/metadata pack**, not a native in-process OpenClaw
|
||||
plugin.
|
||||
|
||||
Today, OpenClaw does **not** execute bundle runtime code in-process. Instead,
|
||||
it detects known bundle files, reads the metadata, and maps supported bundle
|
||||
content into native OpenClaw surfaces such as skills, hook packs, and embedded
|
||||
Pi settings.
|
||||
|
||||
That is the main trust boundary:
|
||||
|
||||
- native OpenClaw plugin: runtime module executes in-process
|
||||
- bundle: metadata/content pack, with selective feature mapping
|
||||
|
||||
## Supported bundle formats
|
||||
|
||||
### Codex bundles
|
||||
|
||||
Typical markers:
|
||||
|
||||
- `.codex-plugin/plugin.json`
|
||||
- optional `skills/`
|
||||
- optional `hooks/`
|
||||
- optional `.mcp.json`
|
||||
- optional `.app.json`
|
||||
|
||||
### Claude bundles
|
||||
|
||||
OpenClaw supports both:
|
||||
|
||||
- manifest-based Claude bundles: `.claude-plugin/plugin.json`
|
||||
- manifestless Claude bundles that use the default component layout
|
||||
|
||||
Default Claude layout markers OpenClaw recognizes:
|
||||
|
||||
- `skills/`
|
||||
- `commands/`
|
||||
- `agents/`
|
||||
- `hooks/hooks.json`
|
||||
- `.mcp.json`
|
||||
- `.lsp.json`
|
||||
- `settings.json`
|
||||
|
||||
### Cursor bundles
|
||||
|
||||
Typical markers:
|
||||
|
||||
- `.cursor-plugin/plugin.json`
|
||||
- optional `skills/`
|
||||
- optional `.cursor/commands/`
|
||||
- optional `.cursor/agents/`
|
||||
- optional `.cursor/rules/`
|
||||
- optional `.cursor/hooks.json`
|
||||
- optional `.mcp.json`
|
||||
|
||||
## Detection order
|
||||
|
||||
OpenClaw prefers native OpenClaw plugin/package layouts before bundle handling.
|
||||
|
||||
Practical effect:
|
||||
|
||||
- `openclaw.plugin.json` wins over bundle detection
|
||||
- package installs with valid `package.json` + `openclaw.extensions` use the
|
||||
native install path
|
||||
- if a directory contains both native and bundle metadata, OpenClaw treats it
|
||||
as native first
|
||||
|
||||
That avoids partially installing a dual-format package as a bundle and then
|
||||
loading it later as a native plugin.
|
||||
|
||||
## Current mapping
|
||||
|
||||
OpenClaw normalizes bundle metadata into one internal bundle record, then maps
|
||||
supported surfaces into existing native behavior.
|
||||
|
||||
### Supported now
|
||||
|
||||
#### Skills
|
||||
|
||||
- Codex `skills` roots load as normal OpenClaw skill roots
|
||||
- Claude `skills` roots load as normal OpenClaw skill roots
|
||||
- Claude `commands` roots are treated as additional skill roots
|
||||
- Cursor `skills` roots load as normal OpenClaw skill roots
|
||||
- Cursor `.cursor/commands` roots are treated as additional skill roots
|
||||
|
||||
This means Claude markdown command files work through the normal OpenClaw skill
|
||||
loader. Cursor command markdown works through the same path.
|
||||
|
||||
#### Hook packs
|
||||
|
||||
- Codex `hooks` roots work **only** when they use the normal OpenClaw hook-pack
|
||||
layout:
|
||||
- `HOOK.md`
|
||||
- `handler.ts` or `handler.js`
|
||||
|
||||
#### Embedded Pi settings
|
||||
|
||||
- Claude `settings.json` is imported as default embedded Pi settings when the
|
||||
bundle is enabled
|
||||
- OpenClaw sanitizes shell override keys before applying them
|
||||
|
||||
Sanitized keys:
|
||||
|
||||
- `shellPath`
|
||||
- `shellCommandPrefix`
|
||||
|
||||
### Detected but not executed
|
||||
|
||||
These surfaces are detected, shown in bundle capabilities, and may appear in
|
||||
diagnostics/info output, but OpenClaw does not run them yet:
|
||||
|
||||
- Claude `agents`
|
||||
- Claude `hooks.json` automation
|
||||
- Claude `mcpServers`
|
||||
- Claude `lspServers`
|
||||
- Claude `outputStyles`
|
||||
- Cursor `.cursor/agents`
|
||||
- Cursor `.cursor/hooks.json`
|
||||
- Cursor `.cursor/rules`
|
||||
- Cursor `mcpServers`
|
||||
- Codex inline/app metadata beyond capability reporting
|
||||
|
||||
## Claude path behavior
|
||||
|
||||
Claude bundle manifests can declare custom component paths. OpenClaw treats
|
||||
those paths as **additive**, not replacing defaults.
|
||||
|
||||
Currently recognized custom path keys:
|
||||
|
||||
- `skills`
|
||||
- `commands`
|
||||
- `agents`
|
||||
- `hooks`
|
||||
- `mcpServers`
|
||||
- `lspServers`
|
||||
- `outputStyles`
|
||||
|
||||
Examples:
|
||||
|
||||
- default `commands/` plus manifest `commands: "extra-commands"` =>
|
||||
OpenClaw scans both
|
||||
- default `skills/` plus manifest `skills: ["team-skills"]` =>
|
||||
OpenClaw scans both
|
||||
|
||||
## Capability reporting
|
||||
|
||||
`openclaw plugins info <id>` shows bundle capabilities from the normalized
|
||||
bundle record.
|
||||
|
||||
Supported capabilities are loaded quietly. Unsupported capabilities produce a
|
||||
warning such as:
|
||||
|
||||
```text
|
||||
bundle capability detected but not wired into OpenClaw yet: agents
|
||||
```
|
||||
|
||||
Current exceptions:
|
||||
|
||||
- Claude `commands` is considered supported because it maps to skills
|
||||
- Claude `settings` is considered supported because it maps to embedded Pi settings
|
||||
- Cursor `commands` is considered supported because it maps to skills
|
||||
- Codex `hooks` is considered supported only for OpenClaw hook-pack layouts
|
||||
|
||||
## Security model
|
||||
|
||||
Bundle support is intentionally narrower than native plugin support.
|
||||
|
||||
Current behavior:
|
||||
|
||||
- bundle discovery reads files inside the plugin root with boundary checks
|
||||
- skills and hook-pack paths must stay inside the plugin root
|
||||
- bundle settings files are read with the same boundary checks
|
||||
- OpenClaw does not execute arbitrary bundle runtime code in-process
|
||||
|
||||
This makes bundle support safer by default than native plugin modules, but you
|
||||
should still treat third-party bundles as trusted content for the features they
|
||||
do expose.
|
||||
|
||||
## Install examples
|
||||
|
||||
```bash
|
||||
openclaw plugins install ./my-codex-bundle
|
||||
openclaw plugins install ./my-claude-bundle
|
||||
openclaw plugins install ./my-cursor-bundle
|
||||
openclaw plugins install ./my-bundle.tgz
|
||||
openclaw plugins info my-bundle
|
||||
```
|
||||
|
||||
If the directory is a native OpenClaw plugin/package, the native install path
|
||||
still wins.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Bundle is detected but capabilities do not run
|
||||
|
||||
Check `openclaw plugins info <id>`.
|
||||
|
||||
If the capability is listed but OpenClaw says it is not wired yet, that is a
|
||||
real product limit, not a broken install.
|
||||
|
||||
### Claude command files do not appear
|
||||
|
||||
Make sure the bundle is enabled and the markdown files are inside a detected
|
||||
`commands` root or `skills` root.
|
||||
|
||||
### Claude settings do not apply
|
||||
|
||||
Current support is limited to embedded Pi settings from `settings.json`.
|
||||
OpenClaw does not treat bundle settings as raw OpenClaw config patches.
|
||||
|
||||
### Claude hooks do not execute
|
||||
|
||||
`hooks/hooks.json` is only detected today.
|
||||
|
||||
If you need runnable bundle hooks today, use the normal OpenClaw hook-pack
|
||||
layout through a supported Codex hook root or ship a native OpenClaw plugin.
|
||||
@@ -8,28 +8,10 @@ title: "Plugin Manifest"
|
||||
|
||||
# Plugin manifest (openclaw.plugin.json)
|
||||
|
||||
This page is for the **native OpenClaw plugin manifest** only.
|
||||
|
||||
For compatible bundle layouts, see [Plugin bundles](/plugins/bundles).
|
||||
|
||||
Compatible bundle formats use different manifest files:
|
||||
|
||||
- Codex bundle: `.codex-plugin/plugin.json`
|
||||
- Claude bundle: `.claude-plugin/plugin.json` or the default Claude component
|
||||
layout without a manifest
|
||||
- Cursor bundle: `.cursor-plugin/plugin.json`
|
||||
|
||||
OpenClaw auto-detects those bundle layouts too, but they are not validated
|
||||
against the `openclaw.plugin.json` schema described here.
|
||||
|
||||
For compatible bundles, OpenClaw currently reads bundle metadata plus declared
|
||||
skill roots, Claude command roots, Claude bundle `settings.json` defaults, and
|
||||
supported hook packs when the layout matches OpenClaw runtime expectations.
|
||||
|
||||
Every native OpenClaw plugin **must** ship a `openclaw.plugin.json` file in the
|
||||
**plugin root**. OpenClaw uses this manifest to validate configuration
|
||||
**without executing plugin code**. Missing or invalid manifests are treated as
|
||||
plugin errors and block config validation.
|
||||
Every plugin **must** ship a `openclaw.plugin.json` file in the **plugin root**.
|
||||
OpenClaw uses this manifest to validate configuration **without executing plugin
|
||||
code**. Missing or invalid manifests are treated as plugin errors and block
|
||||
config validation.
|
||||
|
||||
See the full plugin system guide: [Plugins](/tools/plugin).
|
||||
|
||||
@@ -81,7 +63,7 @@ Optional keys:
|
||||
|
||||
## Notes
|
||||
|
||||
- The manifest is **required for native OpenClaw plugins**, including local filesystem loads.
|
||||
- The manifest is **required for all plugins**, including local filesystem loads.
|
||||
- Runtime still loads the plugin module separately; the manifest is only for
|
||||
discovery + validation.
|
||||
- Exclusive plugin kinds are selected through `plugins.slots.*`.
|
||||
|
||||
@@ -1,42 +1,161 @@
|
||||
---
|
||||
title: "Release Policy"
|
||||
summary: "Public release channels, version naming, and cadence"
|
||||
title: "Release Checklist"
|
||||
summary: "Step-by-step release checklist for npm + macOS app"
|
||||
read_when:
|
||||
- Looking for public release channel definitions
|
||||
- Looking for version naming and cadence
|
||||
- Cutting a new npm release
|
||||
- Cutting a new macOS app release
|
||||
- Verifying metadata before publishing
|
||||
---
|
||||
|
||||
# Release Policy
|
||||
# Release Checklist (npm + macOS)
|
||||
|
||||
OpenClaw has three public release lanes:
|
||||
Use `pnpm` from the repo root with Node 24 by default. Node 22 LTS, currently `22.16+`, remains supported for compatibility. Keep the working tree clean before tagging/publishing.
|
||||
|
||||
- stable: tagged releases that publish to npm `latest`
|
||||
- beta: prerelease tags that publish to npm `beta`
|
||||
- dev: the moving head of `main`
|
||||
## Operator trigger
|
||||
|
||||
## Version naming
|
||||
When the operator says “release”, immediately do this preflight (no extra questions unless blocked):
|
||||
|
||||
- Read this doc and `docs/platforms/mac/release.md`.
|
||||
- Load env from `~/.profile` and confirm `SPARKLE_PRIVATE_KEY_FILE` + App Store Connect vars are set (SPARKLE_PRIVATE_KEY_FILE should live in `~/.profile`).
|
||||
- Use Sparkle keys from `~/Library/CloudStorage/Dropbox/Backup/Sparkle` if needed.
|
||||
|
||||
## Versioning
|
||||
|
||||
Current OpenClaw releases use date-based versioning.
|
||||
|
||||
- Stable release version: `YYYY.M.D`
|
||||
- Git tag: `vYYYY.M.D`
|
||||
- Examples from repo history: `v2026.2.26`, `v2026.3.8`
|
||||
- Beta prerelease version: `YYYY.M.D-beta.N`
|
||||
- Git tag: `vYYYY.M.D-beta.N`
|
||||
- Do not zero-pad month or day
|
||||
- `latest` means the current stable npm release
|
||||
- `beta` means the current prerelease npm release
|
||||
- Beta releases may ship before the macOS app catches up
|
||||
- Examples from repo history: `v2026.2.15-beta.1`, `v2026.3.8-beta.1`
|
||||
- Fallback correction tag: `vYYYY.M.D-N`
|
||||
- Use only as a last-resort recovery tag when a published immutable release burned the original stable tag and you cannot reuse it.
|
||||
- The npm package version stays `YYYY.M.D`; the `-N` suffix is only for the git tag and GitHub release.
|
||||
- Prefer betas for normal pre-release iteration, then cut a clean stable tag once ready.
|
||||
- Use the same version string everywhere, minus the leading `v` where Git tags are not used:
|
||||
- `package.json`: `2026.3.8`
|
||||
- Git tag: `v2026.3.8`
|
||||
- GitHub release title: `openclaw 2026.3.8`
|
||||
- Do not zero-pad month or day. Use `2026.3.8`, not `2026.03.08`.
|
||||
- Stable and beta are npm dist-tags, not separate release lines:
|
||||
- `latest` = stable
|
||||
- `beta` = prerelease/testing
|
||||
- Dev is the moving head of `main`, not a normal git-tagged release.
|
||||
- The tag-triggered preview run accepts stable, beta, and fallback correction tags, and rejects versions whose CalVer date is more than 2 UTC calendar days away from the release date.
|
||||
|
||||
## Release cadence
|
||||
Historical note:
|
||||
|
||||
- Releases move beta-first
|
||||
- Stable follows only after the latest beta is validated
|
||||
- Detailed release procedure, approvals, credentials, and recovery notes are
|
||||
maintainer-only
|
||||
- Older tags such as `v2026.1.11-1`, `v2026.2.6-3`, and `v2.0.0-beta2` exist in repo history.
|
||||
- Treat correction tags as a fallback-only escape hatch. New releases should still use `vYYYY.M.D` for stable and `vYYYY.M.D-beta.N` for beta.
|
||||
|
||||
## Public references
|
||||
1. **Version & metadata**
|
||||
|
||||
- [`.github/workflows/openclaw-npm-release.yml`](https://github.com/openclaw/openclaw/blob/main/.github/workflows/openclaw-npm-release.yml)
|
||||
- [`scripts/openclaw-npm-release-check.ts`](https://github.com/openclaw/openclaw/blob/main/scripts/openclaw-npm-release-check.ts)
|
||||
- [ ] Bump `package.json` version (e.g., `2026.1.29`).
|
||||
- [ ] Run `pnpm plugins:sync` to align extension package versions + changelogs.
|
||||
- [ ] Update CLI/version strings in [`src/version.ts`](https://github.com/openclaw/openclaw/blob/main/src/version.ts) and the Baileys user agent in [`src/web/session.ts`](https://github.com/openclaw/openclaw/blob/main/src/web/session.ts).
|
||||
- [ ] Confirm package metadata (name, description, repository, keywords, license) and `bin` map points to [`openclaw.mjs`](https://github.com/openclaw/openclaw/blob/main/openclaw.mjs) for `openclaw`.
|
||||
- [ ] If dependencies changed, run `pnpm install` so `pnpm-lock.yaml` is current.
|
||||
|
||||
Maintainers use the private release docs in
|
||||
[`openclaw/maintainers/release/README.md`](https://github.com/openclaw/maintainers/blob/main/release/README.md)
|
||||
for the actual runbook.
|
||||
2. **Build & artifacts**
|
||||
|
||||
- [ ] If A2UI inputs changed, run `pnpm canvas:a2ui:bundle` and commit any updated [`src/canvas-host/a2ui/a2ui.bundle.js`](https://github.com/openclaw/openclaw/blob/main/src/canvas-host/a2ui/a2ui.bundle.js).
|
||||
- [ ] `pnpm run build` (regenerates `dist/`).
|
||||
- [ ] Verify npm package `files` includes all required `dist/*` folders (notably `dist/node-host/**` and `dist/acp/**` for headless node + ACP CLI).
|
||||
- [ ] Confirm `dist/build-info.json` exists and includes the expected `commit` hash (CLI banner uses this for npm installs).
|
||||
- [ ] Optional: `npm pack --pack-destination /tmp` after the build; inspect the tarball contents and keep it handy for the GitHub release (do **not** commit it).
|
||||
|
||||
3. **Changelog & docs**
|
||||
|
||||
- [ ] Update `CHANGELOG.md` with user-facing highlights (create the file if missing); keep entries strictly descending by version.
|
||||
- [ ] Ensure README examples/flags match current CLI behavior (notably new commands or options).
|
||||
|
||||
4. **Validation**
|
||||
|
||||
- [ ] `pnpm build`
|
||||
- [ ] `pnpm check`
|
||||
- [ ] `pnpm test` (or `pnpm test:coverage` if you need coverage output)
|
||||
- [ ] `pnpm release:check` (verifies npm pack contents)
|
||||
- [ ] If `pnpm config:docs:check` fails as part of release validation and the config-surface change is intentional, run `pnpm config:docs:gen`, review `docs/.generated/config-baseline.json` and `docs/.generated/config-baseline.jsonl`, commit the updated baselines, then rerun `pnpm release:check`.
|
||||
- [ ] `OPENCLAW_INSTALL_SMOKE_SKIP_NONROOT=1 pnpm test:install:smoke` (Docker install smoke test, fast path; required before release)
|
||||
- If the immediate previous npm release is known broken, set `OPENCLAW_INSTALL_SMOKE_PREVIOUS=<last-good-version>` or `OPENCLAW_INSTALL_SMOKE_SKIP_PREVIOUS=1` for the preinstall step.
|
||||
- [ ] (Optional) Full installer smoke (adds non-root + CLI coverage): `pnpm test:install:smoke`
|
||||
- [ ] (Optional) Installer E2E (Docker, runs `curl -fsSL https://openclaw.ai/install.sh | bash`, onboards, then runs real tool calls):
|
||||
- `pnpm test:install:e2e:openai` (requires `OPENAI_API_KEY`)
|
||||
- `pnpm test:install:e2e:anthropic` (requires `ANTHROPIC_API_KEY`)
|
||||
- `pnpm test:install:e2e` (requires both keys; runs both providers)
|
||||
- [ ] (Optional) Spot-check the web gateway if your changes affect send/receive paths.
|
||||
|
||||
5. **macOS app (Sparkle)**
|
||||
|
||||
- [ ] Build + sign the macOS app, then zip it for distribution.
|
||||
- [ ] Generate the Sparkle appcast (HTML notes via [`scripts/make_appcast.sh`](https://github.com/openclaw/openclaw/blob/main/scripts/make_appcast.sh)) and update `appcast.xml`.
|
||||
- [ ] Keep the app zip (and optional dSYM zip) ready to attach to the GitHub release.
|
||||
- [ ] Follow [macOS release](/platforms/mac/release) for the exact commands and required env vars.
|
||||
- `APP_BUILD` must be numeric + monotonic (no `-beta`) so Sparkle compares versions correctly.
|
||||
- If notarizing, use the `openclaw-notary` keychain profile created from App Store Connect API env vars (see [macOS release](/platforms/mac/release)).
|
||||
|
||||
6. **Publish (npm)**
|
||||
|
||||
- [ ] Confirm git status is clean; commit and push as needed.
|
||||
- [ ] Confirm npm trusted publishing is configured for the `openclaw` package.
|
||||
- [ ] Do not rely on an `NPM_TOKEN` secret for this workflow; the publish job uses GitHub OIDC trusted publishing.
|
||||
- [ ] Push the matching git tag to trigger the preview run in `.github/workflows/openclaw-npm-release.yml`.
|
||||
- [ ] Run `OpenClaw NPM Release` manually with the same tag to publish after `npm-release` environment approval.
|
||||
- Stable tags publish to npm `latest`.
|
||||
- Beta tags publish to npm `beta`.
|
||||
- Fallback correction tags like `v2026.3.13-1` map to npm version `2026.3.13`.
|
||||
- Both the preview run and the manual publish run reject tags that do not map back to `package.json`, are not on `main`, or whose CalVer date is more than 2 UTC calendar days away from the release date.
|
||||
- If `openclaw@YYYY.M.D` is already published, a fallback correction tag is still useful for GitHub release and Docker recovery, but npm publish will not republish that version.
|
||||
- [ ] Verify the registry: `npm view openclaw version`, `npm view openclaw dist-tags`, and `npx -y openclaw@X.Y.Z --version` (or `--help`).
|
||||
|
||||
### Troubleshooting (notes from 2.0.0-beta2 release)
|
||||
|
||||
- **npm pack/publish hangs or produces huge tarball**: the macOS app bundle in `dist/OpenClaw.app` (and release zips) get swept into the package. Fix by whitelisting publish contents via `package.json` `files` (include dist subdirs, docs, skills; exclude app bundles). Confirm with `npm pack --dry-run` that `dist/OpenClaw.app` is not listed.
|
||||
- **npm auth web loop for dist-tags**: use legacy auth to get an OTP prompt:
|
||||
- `NPM_CONFIG_AUTH_TYPE=legacy npm dist-tag add openclaw@X.Y.Z latest`
|
||||
- **`npx` verification fails with `ECOMPROMISED: Lock compromised`**: retry with a fresh cache:
|
||||
- `NPM_CONFIG_CACHE=/tmp/npm-cache-$(date +%s) npx -y openclaw@X.Y.Z --version`
|
||||
- **Tag needs recovery after a late fix**: if the original stable tag is tied to an immutable GitHub release, mint a fallback correction tag like `vX.Y.Z-1` instead of trying to force-update `vX.Y.Z`.
|
||||
- Keep the npm package version at `X.Y.Z`; the correction suffix is for the git tag and GitHub release only.
|
||||
- Use this only as a last resort. For normal iteration, prefer beta tags and then cut a clean stable release.
|
||||
|
||||
7. **GitHub release + appcast**
|
||||
|
||||
- [ ] Tag and push: `git tag vX.Y.Z && git push origin vX.Y.Z` (or `git push --tags`).
|
||||
- Pushing the tag also triggers the npm release workflow.
|
||||
- [ ] Create/refresh the GitHub release for `vX.Y.Z` with **title `openclaw X.Y.Z`** (not just the tag); body should include the **full** changelog section for that version (Highlights + Changes + Fixes), inline (no bare links), and **must not repeat the title inside the body**.
|
||||
- [ ] Attach artifacts: `npm pack` tarball (optional), `OpenClaw-X.Y.Z.zip`, and `OpenClaw-X.Y.Z.dSYM.zip` (if generated).
|
||||
- [ ] Commit the updated `appcast.xml` and push it (Sparkle feeds from main).
|
||||
- [ ] From a clean temp directory (no `package.json`), run `npx -y openclaw@X.Y.Z send --help` to confirm install/CLI entrypoints work.
|
||||
- [ ] Announce/share release notes.
|
||||
|
||||
## Plugin publish scope (npm)
|
||||
|
||||
We only publish **existing npm plugins** under the `@openclaw/*` scope. Bundled
|
||||
plugins that are not on npm stay **disk-tree only** (still shipped in
|
||||
`extensions/**`).
|
||||
|
||||
Process to derive the list:
|
||||
|
||||
1. `npm search @openclaw --json` and capture the package names.
|
||||
2. Compare with `extensions/*/package.json` names.
|
||||
3. Publish only the **intersection** (already on npm).
|
||||
|
||||
Current npm plugin list (update as needed):
|
||||
|
||||
- @openclaw/bluebubbles
|
||||
- @openclaw/diagnostics-otel
|
||||
- @openclaw/discord
|
||||
- @openclaw/feishu
|
||||
- @openclaw/lobster
|
||||
- @openclaw/matrix
|
||||
- @openclaw/msteams
|
||||
- @openclaw/nextcloud-talk
|
||||
- @openclaw/nostr
|
||||
- @openclaw/voice-call
|
||||
- @openclaw/zalo
|
||||
- @openclaw/zalouser
|
||||
|
||||
Release notes must also call out **new optional bundled plugins** that are **not
|
||||
on by default** (example: `tlon`).
|
||||
|
||||
@@ -157,6 +157,7 @@ Use these hubs to discover every page, including deep dives and reference docs t
|
||||
- [macOS permissions](/platforms/mac/permissions)
|
||||
- [macOS remote](/platforms/mac/remote)
|
||||
- [macOS signing](/platforms/mac/signing)
|
||||
- [macOS release](/platforms/mac/release)
|
||||
- [macOS gateway (launchd)](/platforms/mac/bundled-gateway)
|
||||
- [macOS XPC](/platforms/mac/xpc)
|
||||
- [macOS skills](/platforms/mac/skills)
|
||||
@@ -189,5 +190,5 @@ Use these hubs to discover every page, including deep dives and reference docs t
|
||||
## Testing + release
|
||||
|
||||
- [Testing](/reference/test)
|
||||
- [Release policy](/reference/RELEASING)
|
||||
- [Release checklist](/reference/RELEASING)
|
||||
- [Device models](/reference/device-models)
|
||||
|
||||
@@ -96,8 +96,7 @@ pnpm install
|
||||
pnpm gateway:watch
|
||||
```
|
||||
|
||||
`gateway:watch` runs the gateway in watch mode and reloads on relevant source,
|
||||
config, and bundled-plugin metadata changes.
|
||||
`gateway:watch` runs the gateway in watch mode and reloads on TypeScript changes.
|
||||
|
||||
### 2) Point the macOS app at your running Gateway
|
||||
|
||||
|
||||
@@ -114,7 +114,6 @@ Notes:
|
||||
- `remoteCdpTimeoutMs` applies to remote (non-loopback) CDP reachability checks.
|
||||
- `remoteCdpHandshakeTimeoutMs` applies to remote CDP WebSocket reachability checks.
|
||||
- Browser navigation/open-tab is SSRF-guarded before navigation and best-effort re-checked on final `http(s)` URL after navigation.
|
||||
- In strict SSRF mode, remote CDP endpoint discovery/probes (`cdpUrl`, including `/json/version` lookups) are checked too.
|
||||
- `browser.ssrfPolicy.dangerouslyAllowPrivateNetwork` defaults to `true` (trusted-network model). Set it to `false` for strict public-only browsing.
|
||||
- `browser.ssrfPolicy.allowPrivateNetwork` remains supported as a legacy alias for compatibility.
|
||||
- `attachOnly: true` means “never launch a local browser; only attach if it is already running.”
|
||||
|
||||
@@ -3,7 +3,6 @@ summary: "OpenClaw plugins/extensions: discovery, config, and safety"
|
||||
read_when:
|
||||
- Adding or modifying plugins/extensions
|
||||
- Documenting plugin install or load rules
|
||||
- Working with Codex/Claude-compatible plugin bundles
|
||||
title: "Plugins"
|
||||
---
|
||||
|
||||
@@ -11,13 +10,8 @@ title: "Plugins"
|
||||
|
||||
## Quick start (new to plugins?)
|
||||
|
||||
A plugin is either:
|
||||
|
||||
- a native **OpenClaw plugin** (`openclaw.plugin.json` + runtime module), or
|
||||
- a compatible **bundle** (`.codex-plugin/plugin.json` or `.claude-plugin/plugin.json`)
|
||||
|
||||
Both show up under `openclaw plugins`, but only native OpenClaw plugins execute
|
||||
runtime code in-process.
|
||||
A plugin is just a **small code module** that extends OpenClaw with extra
|
||||
features (commands, tools, and Gateway RPC).
|
||||
|
||||
Most of the time, you’ll use plugins when you want a feature that’s not built
|
||||
into core OpenClaw yet (or you want to keep optional features out of your main
|
||||
@@ -48,14 +42,6 @@ prerelease tag such as `@beta`/`@rc` or an exact prerelease version.
|
||||
|
||||
See [Voice Call](/plugins/voice-call) for a concrete example plugin.
|
||||
Looking for third-party listings? See [Community plugins](/plugins/community).
|
||||
Need the bundle compatibility details? See [Plugin bundles](/plugins/bundles).
|
||||
|
||||
For compatible bundles, install from a local directory or archive:
|
||||
|
||||
```bash
|
||||
openclaw plugins install ./my-bundle
|
||||
openclaw plugins install ./my-bundle.tgz
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
@@ -63,15 +49,14 @@ OpenClaw's plugin system has four layers:
|
||||
|
||||
1. **Manifest + discovery**
|
||||
OpenClaw finds candidate plugins from configured paths, workspace roots,
|
||||
global extension roots, and bundled extensions. Discovery reads native
|
||||
`openclaw.plugin.json` manifests plus supported bundle manifests first.
|
||||
global extension roots, and bundled extensions. Discovery reads
|
||||
`openclaw.plugin.json` plus package metadata first.
|
||||
2. **Enablement + validation**
|
||||
Core decides whether a discovered plugin is enabled, disabled, blocked, or
|
||||
selected for an exclusive slot such as memory.
|
||||
3. **Runtime loading**
|
||||
Native OpenClaw plugins are loaded in-process via jiti and register
|
||||
capabilities into a central registry. Compatible bundles are normalized into
|
||||
registry records without importing runtime code.
|
||||
Enabled plugins are loaded in-process via jiti and register capabilities into
|
||||
a central registry.
|
||||
4. **Surface consumption**
|
||||
The rest of OpenClaw reads the registry to expose tools, channels, provider
|
||||
setup, hooks, HTTP routes, CLI commands, and services.
|
||||
@@ -80,68 +65,22 @@ The important design boundary:
|
||||
|
||||
- discovery + config validation should work from **manifest/schema metadata**
|
||||
without executing plugin code
|
||||
- native runtime behavior comes from the plugin module's `register(api)` path
|
||||
- runtime behavior comes from the plugin module's `register(api)` path
|
||||
|
||||
That split lets OpenClaw validate config, explain missing/disabled plugins, and
|
||||
build UI/schema hints before the full runtime is active.
|
||||
|
||||
## Compatible bundles
|
||||
|
||||
OpenClaw also recognizes two compatible external bundle layouts:
|
||||
|
||||
- Codex-style bundles: `.codex-plugin/plugin.json`
|
||||
- Claude-style bundles: `.claude-plugin/plugin.json` or the default Claude
|
||||
component layout without a manifest
|
||||
- Cursor-style bundles: `.cursor-plugin/plugin.json`
|
||||
|
||||
They are shown in the plugin list as `format=bundle`, with a subtype of
|
||||
`codex` or `claude` in verbose/info output.
|
||||
|
||||
See [Plugin bundles](/plugins/bundles) for the exact detection rules, mapping
|
||||
behavior, and current support matrix.
|
||||
|
||||
Today, OpenClaw treats these as **capability packs**, not native runtime
|
||||
plugins:
|
||||
|
||||
- supported now: bundled `skills`
|
||||
- supported now: Claude `commands/` markdown roots, mapped into the normal
|
||||
OpenClaw skill loader
|
||||
- supported now: Claude bundle `settings.json` defaults for embedded Pi agent
|
||||
settings (with shell override keys sanitized)
|
||||
- supported now: Cursor `.cursor/commands/*.md` roots, mapped into the normal
|
||||
OpenClaw skill loader
|
||||
- supported now: Codex bundle hook directories that use the OpenClaw hook-pack
|
||||
layout (`HOOK.md` + `handler.ts`/`handler.js`)
|
||||
- detected but not wired yet: other declared bundle capabilities such as
|
||||
agents, Claude hook automation, Cursor rules/hooks/MCP metadata, MCP/app/LSP
|
||||
metadata, output styles
|
||||
|
||||
That means bundle install/discovery/list/info/enablement all work, and bundle
|
||||
skills, Claude command-skills, Claude bundle settings defaults, and compatible
|
||||
Codex hook directories load when the bundle is enabled, but bundle runtime code
|
||||
is not executed in-process.
|
||||
|
||||
Bundle hook support is limited to the normal OpenClaw hook directory format
|
||||
(`HOOK.md` plus `handler.ts`/`handler.js` under the declared hook roots).
|
||||
Vendor-specific shell/JSON hook runtimes, including Claude `hooks.json`, are
|
||||
only detected today and are not executed directly.
|
||||
|
||||
## Execution model
|
||||
|
||||
Native OpenClaw plugins run **in-process** with the Gateway. They are not
|
||||
sandboxed. A loaded native plugin has the same process-level trust boundary as
|
||||
core code.
|
||||
Plugins run **in-process** with the Gateway. They are not sandboxed. A loaded
|
||||
plugin has the same process-level trust boundary as core code.
|
||||
|
||||
Implications:
|
||||
|
||||
- a native plugin can register tools, network handlers, hooks, and services
|
||||
- a native plugin bug can crash or destabilize the gateway
|
||||
- a malicious native plugin is equivalent to arbitrary code execution inside
|
||||
the OpenClaw process
|
||||
|
||||
Compatible bundles are safer by default because OpenClaw currently treats them
|
||||
as metadata/content packs. In current releases, that mostly means bundled
|
||||
skills.
|
||||
- a plugin can register tools, network handlers, hooks, and services
|
||||
- a plugin bug can crash or destabilize the gateway
|
||||
- a malicious plugin is equivalent to arbitrary code execution inside the
|
||||
OpenClaw process
|
||||
|
||||
Use allowlists and explicit install/load paths for non-bundled plugins. Treat
|
||||
workspace plugins as development-time code, not production defaults.
|
||||
@@ -164,36 +103,16 @@ Important trust note:
|
||||
- [Nostr](/channels/nostr) — `@openclaw/nostr`
|
||||
- [Zalo](/channels/zalo) — `@openclaw/zalo`
|
||||
- [Microsoft Teams](/channels/msteams) — `@openclaw/msteams`
|
||||
- BytePlus provider catalog — bundled as `byteplus` (enabled by default)
|
||||
- Cloudflare AI Gateway provider catalog — bundled as `cloudflare-ai-gateway` (enabled by default)
|
||||
- Google Antigravity OAuth (provider auth) — bundled as `google-antigravity-auth` (disabled by default)
|
||||
- Gemini CLI OAuth (provider auth) — bundled as `google-gemini-cli-auth` (disabled by default)
|
||||
- GitHub Copilot provider runtime — bundled as `github-copilot` (enabled by default)
|
||||
- Hugging Face provider catalog — bundled as `huggingface` (enabled by default)
|
||||
- Kilo Gateway provider runtime — bundled as `kilocode` (enabled by default)
|
||||
- Kimi Coding provider catalog — bundled as `kimi-coding` (enabled by default)
|
||||
- MiniMax provider catalog — bundled as `minimax` (enabled by default)
|
||||
- MiniMax OAuth (provider auth + catalog) — bundled as `minimax-portal-auth` (enabled by default)
|
||||
- Model Studio provider catalog — bundled as `modelstudio` (enabled by default)
|
||||
- Moonshot provider runtime — bundled as `moonshot` (enabled by default)
|
||||
- NVIDIA provider catalog — bundled as `nvidia` (enabled by default)
|
||||
- OpenAI Codex provider runtime — bundled as `openai-codex` (enabled by default)
|
||||
- OpenRouter provider runtime — bundled as `openrouter` (enabled by default)
|
||||
- Qianfan provider catalog — bundled as `qianfan` (enabled by default)
|
||||
- Qwen OAuth (provider auth + catalog) — bundled as `qwen-portal-auth` (enabled by default)
|
||||
- Synthetic provider catalog — bundled as `synthetic` (enabled by default)
|
||||
- Together provider catalog — bundled as `together` (enabled by default)
|
||||
- Venice provider catalog — bundled as `venice` (enabled by default)
|
||||
- Vercel AI Gateway provider catalog — bundled as `vercel-ai-gateway` (enabled by default)
|
||||
- Volcengine provider catalog — bundled as `volcengine` (enabled by default)
|
||||
- Xiaomi provider catalog — bundled as `xiaomi` (enabled by default)
|
||||
- Qwen OAuth (provider auth) — bundled as `qwen-portal-auth` (disabled by default)
|
||||
- Copilot Proxy (provider auth) — local VS Code Copilot Proxy bridge; distinct from built-in `github-copilot` device login (bundled, disabled by default)
|
||||
|
||||
Native OpenClaw plugins are **TypeScript modules** loaded at runtime via jiti.
|
||||
**Config validation does not execute plugin code**; it uses the plugin manifest
|
||||
and JSON Schema instead. See [Plugin manifest](/plugins/manifest).
|
||||
OpenClaw plugins are **TypeScript modules** loaded at runtime via jiti. **Config
|
||||
validation does not execute plugin code**; it uses the plugin manifest and JSON
|
||||
Schema instead. See [Plugin manifest](/plugins/manifest).
|
||||
|
||||
Native OpenClaw plugins can register:
|
||||
Plugins can register:
|
||||
|
||||
- Gateway RPC methods
|
||||
- Gateway HTTP routes
|
||||
@@ -201,168 +120,25 @@ Native OpenClaw plugins can register:
|
||||
- CLI commands
|
||||
- Background services
|
||||
- Context engines
|
||||
- Provider auth flows and model catalogs
|
||||
- Provider runtime hooks for dynamic model ids, transport normalization, capability metadata, stream wrapping, cache TTL policy, and runtime auth exchange
|
||||
- Optional config validation
|
||||
- **Skills** (by listing `skills` directories in the plugin manifest)
|
||||
- **Auto-reply commands** (execute without invoking the AI agent)
|
||||
|
||||
Native OpenClaw plugins run **in‑process** with the Gateway, so treat them as trusted code.
|
||||
Plugins run **in‑process** with the Gateway, so treat them as trusted code.
|
||||
Tool authoring guide: [Plugin agent tools](/plugins/agent-tools).
|
||||
|
||||
## Provider runtime hooks
|
||||
|
||||
Provider plugins now have two layers:
|
||||
|
||||
- config-time hooks: `catalog` / legacy `discovery`
|
||||
- runtime hooks: `resolveDynamicModel`, `prepareDynamicModel`, `normalizeResolvedModel`, `capabilities`, `prepareExtraParams`, `wrapStreamFn`, `isCacheTtlEligible`, `prepareRuntimeAuth`
|
||||
|
||||
OpenClaw still owns the generic agent loop, failover, transcript handling, and
|
||||
tool policy. These hooks are the seam for provider-specific behavior without
|
||||
needing a whole custom inference transport.
|
||||
|
||||
### Hook order
|
||||
|
||||
For model/provider plugins, OpenClaw uses hooks in this rough order:
|
||||
|
||||
1. `catalog`
|
||||
Publish provider config into `models.providers` during `models.json`
|
||||
generation.
|
||||
2. built-in/discovered model lookup
|
||||
OpenClaw tries the normal registry/catalog path first.
|
||||
3. `resolveDynamicModel`
|
||||
Sync fallback for provider-owned model ids that are not in the local
|
||||
registry yet.
|
||||
4. `prepareDynamicModel`
|
||||
Async warm-up only on async model resolution paths, then
|
||||
`resolveDynamicModel` runs again.
|
||||
5. `normalizeResolvedModel`
|
||||
Final rewrite before the embedded runner uses the resolved model.
|
||||
6. `capabilities`
|
||||
Provider-owned transcript/tooling metadata used by shared core logic.
|
||||
7. `prepareExtraParams`
|
||||
Provider-owned request-param normalization before generic stream option wrappers.
|
||||
8. `wrapStreamFn`
|
||||
Provider-owned stream wrapper after generic wrappers are applied.
|
||||
9. `isCacheTtlEligible`
|
||||
Provider-owned prompt-cache policy for proxy/backhaul providers.
|
||||
10. `prepareRuntimeAuth`
|
||||
Exchanges a configured credential into the actual runtime token/key just
|
||||
before inference.
|
||||
|
||||
### Which hook to use
|
||||
|
||||
- `catalog`: publish provider config and model catalogs into `models.providers`
|
||||
- `resolveDynamicModel`: handle pass-through or forward-compat model ids that are not in the local registry yet
|
||||
- `prepareDynamicModel`: async warm-up before retrying dynamic resolution (for example refresh provider metadata cache)
|
||||
- `normalizeResolvedModel`: rewrite a resolved model's transport/base URL/compat before inference
|
||||
- `capabilities`: publish provider-family and transcript/tooling quirks without hardcoding provider ids in core
|
||||
- `prepareExtraParams`: set provider defaults or normalize provider-specific per-model params before generic stream wrapping
|
||||
- `wrapStreamFn`: add provider-specific headers/payload/model compat patches while still using the normal `pi-ai` execution path
|
||||
- `isCacheTtlEligible`: decide whether provider/model pairs should use cache TTL metadata
|
||||
- `prepareRuntimeAuth`: exchange a configured credential into the actual short-lived runtime token/key used for requests
|
||||
|
||||
Rule of thumb:
|
||||
|
||||
- provider owns a catalog or base URL defaults: use `catalog`
|
||||
- provider accepts arbitrary upstream model ids: use `resolveDynamicModel`
|
||||
- provider needs network metadata before resolving unknown ids: add `prepareDynamicModel`
|
||||
- provider needs transport rewrites but still uses a core transport: use `normalizeResolvedModel`
|
||||
- provider needs transcript/provider-family quirks: use `capabilities`
|
||||
- provider needs default request params or per-provider param cleanup: use `prepareExtraParams`
|
||||
- provider needs request headers/body/model compat wrappers without a custom transport: use `wrapStreamFn`
|
||||
- provider needs proxy-specific cache TTL gating: use `isCacheTtlEligible`
|
||||
- provider needs a token exchange or short-lived request credential: use `prepareRuntimeAuth`
|
||||
|
||||
If the provider needs a fully custom wire protocol or custom request executor,
|
||||
that is a different class of extension. These hooks are for provider behavior
|
||||
that still runs on OpenClaw's normal inference loop.
|
||||
|
||||
### Example
|
||||
|
||||
```ts
|
||||
api.registerProvider({
|
||||
id: "example-proxy",
|
||||
label: "Example Proxy",
|
||||
auth: [],
|
||||
catalog: {
|
||||
order: "simple",
|
||||
run: async (ctx) => {
|
||||
const apiKey = ctx.resolveProviderApiKey("example-proxy").apiKey;
|
||||
if (!apiKey) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
provider: {
|
||||
baseUrl: "https://proxy.example.com/v1",
|
||||
apiKey,
|
||||
api: "openai-completions",
|
||||
models: [{ id: "auto", name: "Auto" }],
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
resolveDynamicModel: (ctx) => ({
|
||||
id: ctx.modelId,
|
||||
name: ctx.modelId,
|
||||
provider: "example-proxy",
|
||||
api: "openai-completions",
|
||||
baseUrl: "https://proxy.example.com/v1",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 128000,
|
||||
maxTokens: 8192,
|
||||
}),
|
||||
prepareRuntimeAuth: async (ctx) => {
|
||||
const exchanged = await exchangeToken(ctx.apiKey);
|
||||
return {
|
||||
apiKey: exchanged.token,
|
||||
baseUrl: exchanged.baseUrl,
|
||||
expiresAt: exchanged.expiresAt,
|
||||
};
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Built-in examples
|
||||
|
||||
- OpenRouter uses `catalog` plus `resolveDynamicModel` and
|
||||
`prepareDynamicModel` because the provider is pass-through and may expose new
|
||||
model ids before OpenClaw's static catalog updates.
|
||||
- GitHub Copilot uses `catalog`, `resolveDynamicModel`, and
|
||||
`capabilities` plus `prepareRuntimeAuth` because it needs model fallback
|
||||
behavior, Claude transcript quirks, and a GitHub token -> Copilot token exchange.
|
||||
- OpenAI Codex uses `catalog`, `resolveDynamicModel`, and
|
||||
`normalizeResolvedModel` plus `prepareExtraParams` because it still runs on
|
||||
core OpenAI transports but owns its transport/base URL normalization and
|
||||
default transport choice.
|
||||
- OpenRouter uses `capabilities`, `wrapStreamFn`, and `isCacheTtlEligible`
|
||||
to keep provider-specific request headers, routing metadata, reasoning
|
||||
patches, and prompt-cache policy out of core.
|
||||
- Moonshot uses `catalog` plus `wrapStreamFn` because it still uses the shared
|
||||
OpenAI transport but needs provider-owned thinking payload normalization.
|
||||
- Kilocode uses `catalog`, `capabilities`, `wrapStreamFn`, and
|
||||
`isCacheTtlEligible` because it needs provider-owned request headers,
|
||||
reasoning payload normalization, Gemini transcript hints, and Anthropic
|
||||
cache-TTL gating.
|
||||
- Catalog-only bundled providers such as `byteplus`, `cloudflare-ai-gateway`,
|
||||
`huggingface`, `kimi-coding`, `minimax`, `minimax-portal`, `modelstudio`,
|
||||
`nvidia`, `qianfan`, `qwen-portal`, `synthetic`, `together`, `venice`,
|
||||
`vercel-ai-gateway`, `volcengine`, and `xiaomi` use `catalog` only.
|
||||
|
||||
## Load pipeline
|
||||
|
||||
At startup, OpenClaw does roughly this:
|
||||
|
||||
1. discover candidate plugin roots
|
||||
2. read native or compatible bundle manifests and package metadata
|
||||
2. read `openclaw.plugin.json` and package metadata
|
||||
3. reject unsafe candidates
|
||||
4. normalize plugin config (`plugins.enabled`, `allow`, `deny`, `entries`,
|
||||
`slots`, `load.paths`)
|
||||
5. decide enablement for each candidate
|
||||
6. load enabled native modules via jiti
|
||||
7. call native `register(api)` hooks and collect registrations into the plugin registry
|
||||
6. load enabled modules via jiti
|
||||
7. call `register(api)` and collect registrations into the plugin registry
|
||||
8. expose the registry to commands/runtime surfaces
|
||||
|
||||
The safety gates happen **before** runtime execution. Candidates are blocked
|
||||
@@ -374,13 +150,13 @@ ownership looks suspicious for non-bundled plugins.
|
||||
The manifest is the control-plane source of truth. OpenClaw uses it to:
|
||||
|
||||
- identify the plugin
|
||||
- discover declared channels/skills/config schema or bundle capabilities
|
||||
- discover declared channels/skills/config schema
|
||||
- validate `plugins.entries.<id>.config`
|
||||
- augment Control UI labels/placeholders
|
||||
- show install/catalog metadata
|
||||
|
||||
For native plugins, the runtime module is the data-plane part. It registers
|
||||
actual behavior such as hooks, tools, commands, or provider flows.
|
||||
The runtime module is the data-plane part. It registers actual behavior such as
|
||||
hooks, tools, commands, or provider flows.
|
||||
|
||||
### What the loader caches
|
||||
|
||||
@@ -492,36 +268,6 @@ authoring plugins:
|
||||
`openclaw/plugin-sdk/twitch`, `openclaw/plugin-sdk/voice-call`,
|
||||
`openclaw/plugin-sdk/zalo`, and `openclaw/plugin-sdk/zalouser`.
|
||||
|
||||
## Provider catalogs
|
||||
|
||||
Provider plugins can define model catalogs for inference with
|
||||
`registerProvider({ catalog: { run(...) { ... } } })`.
|
||||
|
||||
`catalog.run(...)` returns the same shape OpenClaw writes into
|
||||
`models.providers`:
|
||||
|
||||
- `{ provider }` for one provider entry
|
||||
- `{ providers }` for multiple provider entries
|
||||
|
||||
Use `catalog` when the plugin owns provider-specific model ids, base URL
|
||||
defaults, or auth-gated model metadata.
|
||||
|
||||
`catalog.order` controls when a plugin's catalog merges relative to OpenClaw's
|
||||
built-in implicit providers:
|
||||
|
||||
- `simple`: plain API-key or env-driven providers
|
||||
- `profile`: providers that appear when auth profiles exist
|
||||
- `paired`: providers that synthesize multiple related provider entries
|
||||
- `late`: last pass, after other implicit providers
|
||||
|
||||
Later providers win on key collision, so plugins can intentionally override a
|
||||
built-in provider entry with the same provider id.
|
||||
|
||||
Compatibility:
|
||||
|
||||
- `discovery` still works as a legacy alias
|
||||
- if both `catalog` and `discovery` are registered, OpenClaw uses `catalog`
|
||||
|
||||
Compatibility note:
|
||||
|
||||
- `openclaw/plugin-sdk` remains supported for existing external plugins.
|
||||
@@ -588,44 +334,18 @@ OpenClaw scans, in order:
|
||||
- `~/.openclaw/extensions/*.ts`
|
||||
- `~/.openclaw/extensions/*/index.ts`
|
||||
|
||||
4. Bundled extensions (shipped with OpenClaw; mixed default-on/default-off)
|
||||
4. Bundled extensions (shipped with OpenClaw, mostly disabled by default)
|
||||
|
||||
- `<openclaw>/extensions/*`
|
||||
|
||||
Many bundled provider plugins are enabled by default so model catalogs/runtime
|
||||
hooks stay available without extra setup. Others still require explicit
|
||||
enablement via `plugins.entries.<id>.enabled` or
|
||||
`openclaw plugins enable <id>`.
|
||||
Most bundled plugins must be enabled explicitly via
|
||||
`plugins.entries.<id>.enabled` or `openclaw plugins enable <id>`.
|
||||
|
||||
Default-on bundled plugin examples:
|
||||
Default-on bundled plugin exceptions:
|
||||
|
||||
- `byteplus`
|
||||
- `cloudflare-ai-gateway`
|
||||
- `device-pair`
|
||||
- `github-copilot`
|
||||
- `huggingface`
|
||||
- `kilocode`
|
||||
- `kimi-coding`
|
||||
- `minimax`
|
||||
- `minimax-portal-auth`
|
||||
- `modelstudio`
|
||||
- `moonshot`
|
||||
- `nvidia`
|
||||
- `ollama`
|
||||
- `openai-codex`
|
||||
- `openrouter`
|
||||
- `phone-control`
|
||||
- `qianfan`
|
||||
- `qwen-portal-auth`
|
||||
- `sglang`
|
||||
- `synthetic`
|
||||
- `talk-voice`
|
||||
- `together`
|
||||
- `venice`
|
||||
- `vercel-ai-gateway`
|
||||
- `vllm`
|
||||
- `volcengine`
|
||||
- `xiaomi`
|
||||
- active memory slot plugin (default slot: `memory-core`)
|
||||
|
||||
Installed plugins are enabled by default, but can be disabled the same way.
|
||||
@@ -643,16 +363,9 @@ Hardening notes:
|
||||
- path ownership is suspicious for non-bundled plugins (POSIX owner is neither current uid nor root).
|
||||
- Loaded non-bundled plugins without install/load-path provenance emit a warning so you can pin trust (`plugins.allow`) or install tracking (`plugins.installs`).
|
||||
|
||||
Each native OpenClaw plugin must include a `openclaw.plugin.json` file in its
|
||||
root. If a path points at a file, the plugin root is the file's directory and
|
||||
must contain the manifest.
|
||||
|
||||
Compatible bundles may instead provide one of:
|
||||
|
||||
- `.codex-plugin/plugin.json`
|
||||
- `.claude-plugin/plugin.json`
|
||||
|
||||
Bundle directories are discovered from the same roots as native plugins.
|
||||
Each plugin must include a `openclaw.plugin.json` file in its root. If a path
|
||||
points at a file, the plugin root is the file's directory and must contain the
|
||||
manifest.
|
||||
|
||||
If multiple plugins resolve to the same id, the first match in the order above
|
||||
wins and lower-precedence copies are ignored.
|
||||
@@ -681,8 +394,9 @@ Enablement is resolved after discovery:
|
||||
- channel config implicitly enables the bundled channel plugin
|
||||
- exclusive slots can force-enable the selected plugin for that slot
|
||||
|
||||
In current core, bundled default-on ids include the local/provider helpers
|
||||
above plus the active memory slot plugin.
|
||||
In current core, bundled default-on ids include local/provider helpers such as
|
||||
`ollama`, `sglang`, `vllm`, plus `device-pair`, `phone-control`, and
|
||||
`talk-voice`.
|
||||
|
||||
### Package packs
|
||||
|
||||
@@ -823,9 +537,8 @@ Validation rules (strict):
|
||||
- Unknown plugin ids in `entries`, `allow`, `deny`, or `slots` are **errors**.
|
||||
- Unknown `channels.<id>` keys are **errors** unless a plugin manifest declares
|
||||
the channel id.
|
||||
- Native plugin config is validated using the JSON Schema embedded in
|
||||
- Plugin config is validated using the JSON Schema embedded in
|
||||
`openclaw.plugin.json` (`configSchema`).
|
||||
- Compatible bundles currently do not expose native OpenClaw config schemas.
|
||||
- If a plugin is disabled, its config is preserved and a **warning** is emitted.
|
||||
|
||||
### Disabled vs missing vs invalid
|
||||
@@ -925,10 +638,6 @@ openclaw plugins disable <id>
|
||||
openclaw plugins doctor
|
||||
```
|
||||
|
||||
`openclaw plugins list` shows the top-level format as `openclaw` or `bundle`.
|
||||
Verbose list/info output also shows bundle subtype (`codex` or `claude`) plus
|
||||
detected bundle capabilities.
|
||||
|
||||
`plugins update` only works for npm installs tracked under `plugins.installs`.
|
||||
If stored integrity metadata changes between updates, OpenClaw warns and asks for confirmation (use global `--yes` to bypass prompts).
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
- 目标文档:`docs/zh-CN/**/*.md`
|
||||
- 术语表:`docs/.i18n/glossary.zh-CN.json`
|
||||
- 翻译记忆库:`docs/.i18n/zh-CN.tm.jsonl`
|
||||
- 提示词规则:`scripts/docs-i18n/prompt.go`
|
||||
- 提示词规则:`scripts/docs-i18n/translator.go`
|
||||
|
||||
常用运行方式:
|
||||
|
||||
@@ -31,8 +31,6 @@ go run scripts/docs-i18n/main.go -mode segment docs/channels/matrix.md
|
||||
注意事项:
|
||||
|
||||
- doc 模式用于整页翻译;segment 模式用于小范围修补(依赖 TM)。
|
||||
- 新增技术术语、页面标题或短导航标签时,先更新 `docs/.i18n/glossary.zh-CN.json`,再跑 `doc` 模式;不要指望模型自行保留英文术语或固定译名。
|
||||
- `pnpm docs:check-i18n-glossary` 会检查变更过的英文文档标题和短内部链接标签是否已写入 glossary。
|
||||
- 超大文件若超时,优先做**定点替换**或拆分后再跑。
|
||||
- 翻译后检查中文引号、CJK-Latin 间距和术语一致性。
|
||||
|
||||
|
||||
92
docs/zh-CN/platforms/mac/release.md
Normal file
92
docs/zh-CN/platforms/mac/release.md
Normal file
@@ -0,0 +1,92 @@
|
||||
---
|
||||
read_when:
|
||||
- 制作或验证 OpenClaw macOS 发布版本
|
||||
- 更新 Sparkle appcast 或订阅源资源
|
||||
summary: OpenClaw macOS 发布清单(Sparkle 订阅源、打包、签名)
|
||||
title: macOS 发布
|
||||
x-i18n:
|
||||
generated_at: "2026-02-01T21:33:17Z"
|
||||
model: claude-opus-4-5
|
||||
provider: pi
|
||||
source_hash: 703c08c13793cd8c96bd4c31fb4904cdf4ffff35576e7ea48a362560d371cb30
|
||||
source_path: platforms/mac/release.md
|
||||
workflow: 15
|
||||
---
|
||||
|
||||
# OpenClaw macOS 发布(Sparkle)
|
||||
|
||||
本应用现已支持 Sparkle 自动更新。发布构建必须经过 Developer ID 签名、压缩,并发布包含签名的 appcast 条目。
|
||||
|
||||
## 前提条件
|
||||
|
||||
- 已安装 Developer ID Application 证书(示例:`Developer ID Application: <Developer Name> (<TEAMID>)`)。
|
||||
- 环境变量 `SPARKLE_PRIVATE_KEY_FILE` 已设置为 Sparkle ed25519 私钥路径(公钥已嵌入 Info.plist)。如果缺失,请检查 `~/.profile`。
|
||||
- 用于 `xcrun notarytool` 的公证凭据(钥匙串配置文件或 API 密钥),以实现通过 Gatekeeper 安全分发的 DMG/zip。
|
||||
- 我们使用名为 `openclaw-notary` 的钥匙串配置文件,由 shell 配置文件中的 App Store Connect API 密钥环境变量创建:
|
||||
- `APP_STORE_CONNECT_API_KEY_P8`、`APP_STORE_CONNECT_KEY_ID`、`APP_STORE_CONNECT_ISSUER_ID`
|
||||
- `echo "$APP_STORE_CONNECT_API_KEY_P8" | sed 's/\\n/\n/g' > /tmp/openclaw-notary.p8`
|
||||
- `xcrun notarytool store-credentials "openclaw-notary" --key /tmp/openclaw-notary.p8 --key-id "$APP_STORE_CONNECT_KEY_ID" --issuer "$APP_STORE_CONNECT_ISSUER_ID"`
|
||||
- 已安装 `pnpm` 依赖(`pnpm install --config.node-linker=hoisted`)。
|
||||
- Sparkle 工具通过 SwiftPM 自动获取,位于 `apps/macos/.build/artifacts/sparkle/Sparkle/bin/`(`sign_update`、`generate_appcast` 等)。
|
||||
|
||||
## 构建与打包
|
||||
|
||||
注意事项:
|
||||
|
||||
- `APP_BUILD` 映射到 `CFBundleVersion`/`sparkle:version`;保持纯数字且单调递增(不含 `-beta`),否则 Sparkle 会将其视为相同版本。
|
||||
- 默认为当前架构(`$(uname -m)`)。对于发布/通用构建,设置 `BUILD_ARCHS="arm64 x86_64"`(或 `BUILD_ARCHS=all`)。
|
||||
- 使用 `scripts/package-mac-dist.sh` 生成发布产物(zip + DMG + 公证)。使用 `scripts/package-mac-app.sh` 进行本地/开发打包。
|
||||
|
||||
```bash
|
||||
# 从仓库根目录运行;设置发布 ID 以启用 Sparkle 订阅源。
|
||||
# APP_BUILD 必须为纯数字且单调递增,以便 Sparkle 正确比较。
|
||||
BUNDLE_ID=bot.molt.mac \
|
||||
APP_VERSION=2026.1.27-beta.1 \
|
||||
APP_BUILD="$(git rev-list --count HEAD)" \
|
||||
BUILD_CONFIG=release \
|
||||
SIGN_IDENTITY="Developer ID Application: <Developer Name> (<TEAMID>)" \
|
||||
scripts/package-mac-app.sh
|
||||
|
||||
# 打包用于分发的 zip(包含资源分支以支持 Sparkle 增量更新)
|
||||
ditto -c -k --sequesterRsrc --keepParent dist/OpenClaw.app dist/OpenClaw-2026.1.27-beta.1.zip
|
||||
|
||||
# 可选:同时构建适合用户使用的样式化 DMG(拖拽到 /Applications)
|
||||
scripts/create-dmg.sh dist/OpenClaw.app dist/OpenClaw-2026.1.27-beta.1.dmg
|
||||
|
||||
# 推荐:构建 + 公证/装订 zip + DMG
|
||||
# 首先,创建一次钥匙串配置文件:
|
||||
# xcrun notarytool store-credentials "openclaw-notary" \
|
||||
# --apple-id "<apple-id>" --team-id "<team-id>" --password "<app-specific-password>"
|
||||
NOTARIZE=1 NOTARYTOOL_PROFILE=openclaw-notary \
|
||||
BUNDLE_ID=bot.molt.mac \
|
||||
APP_VERSION=2026.1.27-beta.1 \
|
||||
APP_BUILD="$(git rev-list --count HEAD)" \
|
||||
BUILD_CONFIG=release \
|
||||
SIGN_IDENTITY="Developer ID Application: <Developer Name> (<TEAMID>)" \
|
||||
scripts/package-mac-dist.sh
|
||||
|
||||
# 可选:随发布一起提供 dSYM
|
||||
ditto -c -k --keepParent apps/macos/.build/release/OpenClaw.app.dSYM dist/OpenClaw-2026.1.27-beta.1.dSYM.zip
|
||||
```
|
||||
|
||||
## Appcast 条目
|
||||
|
||||
使用发布说明生成器,以便 Sparkle 渲染格式化的 HTML 说明:
|
||||
|
||||
```bash
|
||||
SPARKLE_PRIVATE_KEY_FILE=/path/to/ed25519-private-key scripts/make_appcast.sh dist/OpenClaw-2026.1.27-beta.1.zip https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml
|
||||
```
|
||||
|
||||
从 `CHANGELOG.md`(通过 [`scripts/changelog-to-html.sh`](https://github.com/openclaw/openclaw/blob/main/scripts/changelog-to-html.sh))生成 HTML 发布说明,并将其嵌入 appcast 条目。
|
||||
发布时,将更新后的 `appcast.xml` 与发布资源(zip + dSYM)一起提交。
|
||||
|
||||
## 发布与验证
|
||||
|
||||
- 将 `OpenClaw-2026.1.27-beta.1.zip`(和 `OpenClaw-2026.1.27-beta.1.dSYM.zip`)上传到标签 `v2026.1.27-beta.1` 对应的 GitHub 发布。
|
||||
- 确保原始 appcast URL 与内置的订阅源匹配:`https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml`。
|
||||
- 完整性检查:
|
||||
- `curl -I https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml` 返回 200。
|
||||
- `curl -I <enclosure url>` 在资源上传后返回 200。
|
||||
- 在之前的公开构建版本上,从 About 选项卡运行"Check for Updates…",验证 Sparkle 能正常安装新构建。
|
||||
|
||||
完成定义:已签名的应用 + appcast 已发布,从旧版本的更新流程正常工作,且发布资源已附加到 GitHub 发布。
|
||||
@@ -1,48 +1,123 @@
|
||||
---
|
||||
read_when:
|
||||
- 查找公开发布渠道的定义
|
||||
- 查找版本命名与发布节奏
|
||||
summary: 公开发布渠道、版本命名与发布节奏
|
||||
title: 发布策略
|
||||
- 发布新的 npm 版本
|
||||
- 发布新的 macOS 应用版本
|
||||
- 发布前验证元数据
|
||||
summary: npm + macOS 应用的逐步发布清单
|
||||
x-i18n:
|
||||
generated_at: "2026-03-15T19:23:11Z"
|
||||
model: claude-opus-4-6
|
||||
generated_at: "2026-02-03T10:09:28Z"
|
||||
model: claude-opus-4-5
|
||||
provider: pi
|
||||
source_hash: df332d3169de7099661725d9266955456e80fc3d3ff95cb7aaf9997a02f0baaf
|
||||
source_hash: 1a684bc26665966eb3c9c816d58d18eead008fd710041181ece38c21c5ff1c62
|
||||
source_path: reference/RELEASING.md
|
||||
workflow: 15
|
||||
---
|
||||
|
||||
# 发布策略
|
||||
# 发布清单(npm + macOS)
|
||||
|
||||
OpenClaw 有三个公开发布渠道:
|
||||
从仓库根目录使用 `pnpm`(Node 22+)。在打标签/发布前保持工作树干净。
|
||||
|
||||
- stable:带标签的正式发布,发布到 npm `latest`
|
||||
- beta:预发布标签,发布到 npm `beta`
|
||||
- dev:`main` 分支的最新提交
|
||||
## 操作员触发
|
||||
|
||||
## 版本命名
|
||||
当操作员说"release"时,立即执行此预检(除非遇到阻碍否则不要额外提问):
|
||||
|
||||
- 正式发布版本号:`YYYY.M.D`
|
||||
- Git 标签:`vYYYY.M.D`
|
||||
- Beta 预发布版本号:`YYYY.M.D-beta.N`
|
||||
- Git 标签:`vYYYY.M.D-beta.N`
|
||||
- 月份和日期不补零
|
||||
- `latest` 表示当前 npm 正式发布版本
|
||||
- `beta` 表示当前 npm 预发布版本
|
||||
- Beta 版本可能会在 macOS 应用跟进之前发布
|
||||
- 阅读本文档和 `docs/platforms/mac/release.md`。
|
||||
- 从 `~/.profile` 加载环境变量并确认 `SPARKLE_PRIVATE_KEY_FILE` + App Store Connect 变量已设置(SPARKLE_PRIVATE_KEY_FILE 应位于 `~/.profile` 中)。
|
||||
- 如需要,使用 `~/Library/CloudStorage/Dropbox/Backup/Sparkle` 中的 Sparkle 密钥。
|
||||
|
||||
## 发布节奏
|
||||
1. **版本和元数据**
|
||||
|
||||
- 发布遵循 beta 优先原则
|
||||
- 仅在最新的 beta 版本验证通过后才会发布正式版本
|
||||
- 详细的发布流程、审批、凭证和恢复说明仅限维护者查阅
|
||||
- [ ] 更新 `package.json` 版本(例如 `2026.1.29`)。
|
||||
- [ ] 运行 `pnpm plugins:sync` 以对齐扩展包版本和变更日志。
|
||||
- [ ] 更新 CLI/版本字符串:[`src/cli/program.ts`](https://github.com/openclaw/openclaw/blob/main/src/cli/program.ts) 和 [`src/provider-web.ts`](https://github.com/openclaw/openclaw/blob/main/src/provider-web.ts) 中的 Baileys user agent。
|
||||
- [ ] 确认包元数据(name、description、repository、keywords、license)以及 `bin` 映射指向 [`openclaw.mjs`](https://github.com/openclaw/openclaw/blob/main/openclaw.mjs) 作为 `openclaw`。
|
||||
- [ ] 如果依赖项有变化,运行 `pnpm install` 确保 `pnpm-lock.yaml` 是最新的。
|
||||
|
||||
## 公开参考
|
||||
2. **构建和产物**
|
||||
|
||||
- [`.github/workflows/openclaw-npm-release.yml`](https://github.com/openclaw/openclaw/blob/main/.github/workflows/openclaw-npm-release.yml)
|
||||
- [`scripts/openclaw-npm-release-check.ts`](https://github.com/openclaw/openclaw/blob/main/scripts/openclaw-npm-release-check.ts)
|
||||
- [ ] 如果 A2UI 输入有变化,运行 `pnpm canvas:a2ui:bundle` 并提交更新后的 [`src/canvas-host/a2ui/a2ui.bundle.js`](https://github.com/openclaw/openclaw/blob/main/src/canvas-host/a2ui/a2ui.bundle.js)。
|
||||
- [ ] `pnpm run build`(重新生成 `dist/`)。
|
||||
- [ ] 验证 npm 包的 `files` 包含所有必需的 `dist/*` 文件夹(特别是用于 headless node + ACP CLI 的 `dist/node-host/**` 和 `dist/acp/**`)。
|
||||
- [ ] 确认 `dist/build-info.json` 存在并包含预期的 `commit` 哈希(CLI 横幅在 npm 安装时使用此信息)。
|
||||
- [ ] 可选:构建后运行 `npm pack --pack-destination /tmp`;检查 tarball 内容并保留以备 GitHub 发布使用(**不要**提交它)。
|
||||
|
||||
维护者使用
|
||||
[`openclaw/maintainers/release/README.md`](https://github.com/openclaw/maintainers/blob/main/release/README.md)
|
||||
中的私有发布文档作为实际操作手册。
|
||||
3. **变更日志和文档**
|
||||
|
||||
- [ ] 更新 `CHANGELOG.md`,添加面向用户的亮点(如果文件不存在则创建);按版本严格降序排列条目。
|
||||
- [ ] 确保 README 示例/标志与当前 CLI 行为匹配(特别是新命令或选项)。
|
||||
|
||||
4. **验证**
|
||||
|
||||
- [ ] `pnpm build`
|
||||
- [ ] `pnpm check`
|
||||
- [ ] `pnpm test`(如需覆盖率输出则使用 `pnpm test:coverage`)
|
||||
- [ ] `pnpm release:check`(验证 npm pack 内容)
|
||||
- [ ] `OPENCLAW_INSTALL_SMOKE_SKIP_NONROOT=1 pnpm test:install:smoke`(Docker 安装冒烟测试,快速路径;发布前必需)
|
||||
- 如果已知上一个 npm 发布版本有问题,为预安装步骤设置 `OPENCLAW_INSTALL_SMOKE_PREVIOUS=<last-good-version>` 或 `OPENCLAW_INSTALL_SMOKE_SKIP_PREVIOUS=1`。
|
||||
- [ ](可选)完整安装程序冒烟测试(添加非 root + CLI 覆盖):`pnpm test:install:smoke`
|
||||
- [ ](可选)安装程序 E2E(Docker,运行 `curl -fsSL https://openclaw.ai/install.sh | bash`,新手引导,然后运行真实工具调用):
|
||||
- `pnpm test:install:e2e:openai`(需要 `OPENAI_API_KEY`)
|
||||
- `pnpm test:install:e2e:anthropic`(需要 `ANTHROPIC_API_KEY`)
|
||||
- `pnpm test:install:e2e`(需要两个密钥;运行两个提供商)
|
||||
- [ ](可选)如果你的更改影响发送/接收路径,抽查 Web Gateway 网关。
|
||||
|
||||
5. **macOS 应用(Sparkle)**
|
||||
|
||||
- [ ] 构建并签名 macOS 应用,然后压缩以供分发。
|
||||
- [ ] 生成 Sparkle appcast(通过 [`scripts/make_appcast.sh`](https://github.com/openclaw/openclaw/blob/main/scripts/make_appcast.sh) 生成 HTML 注释)并更新 `appcast.xml`。
|
||||
- [ ] 保留应用 zip(和可选的 dSYM zip)以便附加到 GitHub 发布。
|
||||
- [ ] 按照 [macOS 发布](/platforms/mac/release) 获取确切命令和所需环境变量。
|
||||
- `APP_BUILD` 必须是数字且单调递增(不带 `-beta`),以便 Sparkle 正确比较版本。
|
||||
- 如果进行公证,使用从 App Store Connect API 环境变量创建的 `openclaw-notary` 钥匙串配置文件(参见 [macOS 发布](/platforms/mac/release))。
|
||||
|
||||
6. **发布(npm)**
|
||||
|
||||
- [ ] 确认 git 状态干净;根据需要提交并推送。
|
||||
- [ ] 如需要,`npm login`(验证 2FA)。
|
||||
- [ ] `npm publish --access public`(预发布版本使用 `--tag beta`)。
|
||||
- [ ] 验证注册表:`npm view openclaw version`、`npm view openclaw dist-tags` 和 `npx -y openclaw@X.Y.Z --version`(或 `--help`)。
|
||||
|
||||
### 故障排除(来自 2.0.0-beta2 发布的笔记)
|
||||
|
||||
- **npm pack/publish 挂起或产生巨大 tarball**:`dist/OpenClaw.app` 中的 macOS 应用包(和发布 zip)被扫入包中。通过 `package.json` 的 `files` 白名单发布内容来修复(包含 dist 子目录、docs、skills;排除应用包)。用 `npm pack --dry-run` 确认 `dist/OpenClaw.app` 未列出。
|
||||
- **npm auth dist-tags 的 Web 循环**:使用旧版认证以获取 OTP 提示:
|
||||
- `NPM_CONFIG_AUTH_TYPE=legacy npm dist-tag add openclaw@X.Y.Z latest`
|
||||
- **`npx` 验证失败并显示 `ECOMPROMISED: Lock compromised`**:使用新缓存重试:
|
||||
- `NPM_CONFIG_CACHE=/tmp/npm-cache-$(date +%s) npx -y openclaw@X.Y.Z --version`
|
||||
- **延迟修复后需要重新指向标签**:强制更新并推送标签,然后确保 GitHub 发布资产仍然匹配:
|
||||
- `git tag -f vX.Y.Z && git push -f origin vX.Y.Z`
|
||||
|
||||
7. **GitHub 发布 + appcast**
|
||||
|
||||
- [ ] 打标签并推送:`git tag vX.Y.Z && git push origin vX.Y.Z`(或 `git push --tags`)。
|
||||
- [ ] 为 `vX.Y.Z` 创建/刷新 GitHub 发布,**标题为 `openclaw X.Y.Z`**(不仅仅是标签);正文应包含该版本的**完整**变更日志部分(亮点 + 更改 + 修复),内联显示(无裸链接),且**不得在正文中重复标题**。
|
||||
- [ ] 附加产物:`npm pack` tarball(可选)、`OpenClaw-X.Y.Z.zip` 和 `OpenClaw-X.Y.Z.dSYM.zip`(如果生成)。
|
||||
- [ ] 提交更新后的 `appcast.xml` 并推送(Sparkle 从 main 获取源)。
|
||||
- [ ] 从干净的临时目录(无 `package.json`),运行 `npx -y openclaw@X.Y.Z send --help` 确认安装/CLI 入口点正常工作。
|
||||
- [ ] 宣布/分享发布说明。
|
||||
|
||||
## 插件发布范围(npm)
|
||||
|
||||
我们只发布 `@openclaw/*` 范围下的**现有 npm 插件**。不在 npm 上的内置插件保持**仅磁盘树**(仍在 `extensions/**` 中发布)。
|
||||
|
||||
获取列表的流程:
|
||||
|
||||
1. `npm search @openclaw --json` 并捕获包名。
|
||||
2. 与 `extensions/*/package.json` 名称比较。
|
||||
3. 只发布**交集**(已在 npm 上)。
|
||||
|
||||
当前 npm 插件列表(根据需要更新):
|
||||
|
||||
- @openclaw/bluebubbles
|
||||
- @openclaw/diagnostics-otel
|
||||
- @openclaw/discord
|
||||
- @openclaw/lobster
|
||||
- @openclaw/matrix
|
||||
- @openclaw/msteams
|
||||
- @openclaw/nextcloud-talk
|
||||
- @openclaw/nostr
|
||||
- @openclaw/voice-call
|
||||
- @openclaw/zalo
|
||||
- @openclaw/zalouser
|
||||
|
||||
发布说明还必须标注**默认未启用**的**新可选内置插件**(例如:`tlon`)。
|
||||
|
||||
@@ -1,24 +1,20 @@
|
||||
---
|
||||
read_when:
|
||||
- 你想要一份完整的文档地图
|
||||
summary: 链接到所有 OpenClaw 文档的导航中心
|
||||
summary: 链接到每篇 OpenClaw 文档的导航中心
|
||||
title: 文档导航中心
|
||||
x-i18n:
|
||||
generated_at: "2026-03-15T19:29:16Z"
|
||||
model: claude-opus-4-6
|
||||
generated_at: "2026-02-04T17:55:29Z"
|
||||
model: claude-opus-4-5
|
||||
provider: pi
|
||||
source_hash: e12e8b7881311fdaf08cd297392911dfa30dc46031a7038b6bb9011d166b1669
|
||||
source_hash: c4b4572b64d36c9690988b8f964b0712f551ee6491b18a493701a17d2d352cb4
|
||||
source_path: start/hubs.md
|
||||
workflow: 15
|
||||
---
|
||||
|
||||
# 文档导航中心
|
||||
|
||||
<Note>
|
||||
如果你是 OpenClaw 新用户,请从[入门指南](/start/getting-started)开始。
|
||||
</Note>
|
||||
|
||||
使用这些导航中心发现每一个页面,包括深入解析和参考文档——它们可能不会出现在左侧导航栏中。
|
||||
使用这些导航中心发现每一个页面,包括深入解析和参考文档——它们不一定出现在左侧导航栏中。
|
||||
|
||||
## 从这里开始
|
||||
|
||||
@@ -79,6 +75,7 @@ x-i18n:
|
||||
- [模型提供商中心](/providers/models)
|
||||
- [WhatsApp](/channels/whatsapp)
|
||||
- [Telegram](/channels/telegram)
|
||||
- [Telegram(grammY 注意事项)](/channels/grammy)
|
||||
- [Slack](/channels/slack)
|
||||
- [Discord](/channels/discord)
|
||||
- [Mattermost](/channels/mattermost)(插件)
|
||||
@@ -116,18 +113,17 @@ x-i18n:
|
||||
- [OpenProse](/prose)
|
||||
- [CLI 参考](/cli)
|
||||
- [Exec 工具](/tools/exec)
|
||||
- [PDF 工具](/tools/pdf)
|
||||
- [提权模式](/tools/elevated)
|
||||
- [定时任务](/automation/cron-jobs)
|
||||
- [定时任务 vs 心跳](/automation/cron-vs-heartbeat)
|
||||
- [思考 + 详细输出](/tools/thinking)
|
||||
- [模型](/concepts/models)
|
||||
- [子智能体](/tools/subagents)
|
||||
- [智能体发送 CLI](/tools/agent-send)
|
||||
- [Agent send CLI](/tools/agent-send)
|
||||
- [终端界面](/web/tui)
|
||||
- [浏览器控制](/tools/browser)
|
||||
- [浏览器(Linux 故障排除)](/tools/browser-linux-troubleshooting)
|
||||
- [投票](/automation/poll)
|
||||
- [轮询](/automation/poll)
|
||||
|
||||
## 节点、媒体、语音
|
||||
|
||||
@@ -164,6 +160,7 @@ x-i18n:
|
||||
- [macOS 权限](/platforms/mac/permissions)
|
||||
- [macOS 远程](/platforms/mac/remote)
|
||||
- [macOS 签名](/platforms/mac/signing)
|
||||
- [macOS 发布](/platforms/mac/release)
|
||||
- [macOS Gateway 网关 (launchd)](/platforms/mac/bundled-gateway)
|
||||
- [macOS XPC](/platforms/mac/xpc)
|
||||
- [macOS Skills](/platforms/mac/skills)
|
||||
@@ -186,6 +183,8 @@ x-i18n:
|
||||
## 实验(探索性)
|
||||
|
||||
- [新手引导配置协议](/experiments/onboarding-config-protocol)
|
||||
- [定时任务加固笔记](/experiments/plans/cron-add-hardening)
|
||||
- [群组策略加固笔记](/experiments/plans/group-policy-hardening)
|
||||
- [研究:记忆](/experiments/research/memory)
|
||||
- [模型配置探索](/experiments/proposals/model-config)
|
||||
|
||||
@@ -196,5 +195,5 @@ x-i18n:
|
||||
## 测试 + 发布
|
||||
|
||||
- [测试](/reference/test)
|
||||
- [发布策略](/reference/RELEASING)
|
||||
- [发布检查清单](/reference/RELEASING)
|
||||
- [设备型号](/reference/device-models)
|
||||
|
||||
@@ -1,44 +1,13 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
ACPX_BUNDLED_BIN,
|
||||
ACPX_PINNED_VERSION,
|
||||
createAcpxPluginConfigSchema,
|
||||
resolveAcpxPluginRoot,
|
||||
resolveAcpxPluginConfig,
|
||||
} from "./config.js";
|
||||
|
||||
describe("acpx plugin config parsing", () => {
|
||||
it("resolves source-layout plugin root from a file under src", () => {
|
||||
const pluginRoot = fs.mkdtempSync(path.join(os.tmpdir(), "acpx-root-source-"));
|
||||
try {
|
||||
fs.mkdirSync(path.join(pluginRoot, "src"), { recursive: true });
|
||||
fs.writeFileSync(path.join(pluginRoot, "package.json"), "{}\n", "utf8");
|
||||
fs.writeFileSync(path.join(pluginRoot, "openclaw.plugin.json"), "{}\n", "utf8");
|
||||
|
||||
const moduleUrl = pathToFileURL(path.join(pluginRoot, "src", "config.ts")).href;
|
||||
expect(resolveAcpxPluginRoot(moduleUrl)).toBe(pluginRoot);
|
||||
} finally {
|
||||
fs.rmSync(pluginRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("resolves bundled-layout plugin root from the dist entry file", () => {
|
||||
const pluginRoot = fs.mkdtempSync(path.join(os.tmpdir(), "acpx-root-dist-"));
|
||||
try {
|
||||
fs.writeFileSync(path.join(pluginRoot, "package.json"), "{}\n", "utf8");
|
||||
fs.writeFileSync(path.join(pluginRoot, "openclaw.plugin.json"), "{}\n", "utf8");
|
||||
|
||||
const moduleUrl = pathToFileURL(path.join(pluginRoot, "index.js")).href;
|
||||
expect(resolveAcpxPluginRoot(moduleUrl)).toBe(pluginRoot);
|
||||
} finally {
|
||||
fs.rmSync(pluginRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("resolves bundled acpx with pinned version by default", () => {
|
||||
const resolved = resolveAcpxPluginConfig({
|
||||
rawConfig: {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type { OpenClawPluginConfigSchema } from "openclaw/plugin-sdk/acpx";
|
||||
@@ -12,27 +11,7 @@ export type AcpxNonInteractivePermissionPolicy = (typeof ACPX_NON_INTERACTIVE_PO
|
||||
export const ACPX_PINNED_VERSION = "0.1.16";
|
||||
export const ACPX_VERSION_ANY = "any";
|
||||
const ACPX_BIN_NAME = process.platform === "win32" ? "acpx.cmd" : "acpx";
|
||||
|
||||
export function resolveAcpxPluginRoot(moduleUrl: string = import.meta.url): string {
|
||||
let cursor = path.dirname(fileURLToPath(moduleUrl));
|
||||
for (let i = 0; i < 3; i += 1) {
|
||||
// Bundled entries live at the plugin root while source files still live under src/.
|
||||
if (
|
||||
fs.existsSync(path.join(cursor, "openclaw.plugin.json")) &&
|
||||
fs.existsSync(path.join(cursor, "package.json"))
|
||||
) {
|
||||
return cursor;
|
||||
}
|
||||
const parent = path.dirname(cursor);
|
||||
if (parent === cursor) {
|
||||
break;
|
||||
}
|
||||
cursor = parent;
|
||||
}
|
||||
return path.resolve(path.dirname(fileURLToPath(moduleUrl)), "..");
|
||||
}
|
||||
|
||||
export const ACPX_PLUGIN_ROOT = resolveAcpxPluginRoot();
|
||||
export const ACPX_PLUGIN_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
export const ACPX_BUNDLED_BIN = path.join(ACPX_PLUGIN_ROOT, "node_modules", ".bin", ACPX_BIN_NAME);
|
||||
export function buildAcpxLocalInstallCommand(version: string = ACPX_PINNED_VERSION): string {
|
||||
return `npm install --omit=dev --no-save acpx@${version}`;
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
import { emptyPluginConfigSchema, type OpenClawPluginApi } from "openclaw/plugin-sdk/core";
|
||||
import {
|
||||
buildBytePlusCodingProvider,
|
||||
buildBytePlusProvider,
|
||||
} from "../../src/agents/models-config.providers.static.js";
|
||||
|
||||
const PROVIDER_ID = "byteplus";
|
||||
|
||||
const byteplusPlugin = {
|
||||
id: PROVIDER_ID,
|
||||
name: "BytePlus Provider",
|
||||
description: "Bundled BytePlus provider plugin",
|
||||
configSchema: emptyPluginConfigSchema(),
|
||||
register(api: OpenClawPluginApi) {
|
||||
api.registerProvider({
|
||||
id: PROVIDER_ID,
|
||||
label: "BytePlus",
|
||||
docsPath: "/concepts/model-providers#byteplus-international",
|
||||
envVars: ["BYTEPLUS_API_KEY"],
|
||||
auth: [],
|
||||
catalog: {
|
||||
order: "paired",
|
||||
run: async (ctx) => {
|
||||
const apiKey = ctx.resolveProviderApiKey(PROVIDER_ID).apiKey;
|
||||
if (!apiKey) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
providers: {
|
||||
byteplus: { ...buildBytePlusProvider(), apiKey },
|
||||
"byteplus-plan": { ...buildBytePlusCodingProvider(), apiKey },
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export default byteplusPlugin;
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"id": "byteplus",
|
||||
"providers": ["byteplus", "byteplus-plan"],
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {}
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"name": "@openclaw/byteplus-provider",
|
||||
"version": "2026.3.14",
|
||||
"private": true,
|
||||
"description": "OpenClaw BytePlus provider plugin",
|
||||
"type": "module",
|
||||
"openclaw": {
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
import { emptyPluginConfigSchema, type OpenClawPluginApi } from "openclaw/plugin-sdk/core";
|
||||
import { ensureAuthProfileStore, listProfilesForProvider } from "../../src/agents/auth-profiles.js";
|
||||
import {
|
||||
buildCloudflareAiGatewayModelDefinition,
|
||||
resolveCloudflareAiGatewayBaseUrl,
|
||||
} from "../../src/agents/cloudflare-ai-gateway.js";
|
||||
import { resolveNonEnvSecretRefApiKeyMarker } from "../../src/agents/model-auth-markers.js";
|
||||
import { coerceSecretRef } from "../../src/config/types.secrets.js";
|
||||
|
||||
const PROVIDER_ID = "cloudflare-ai-gateway";
|
||||
const PROVIDER_ENV_VAR = "CLOUDFLARE_AI_GATEWAY_API_KEY";
|
||||
|
||||
function resolveApiKeyFromCredential(
|
||||
cred: ReturnType<typeof ensureAuthProfileStore>["profiles"][string] | undefined,
|
||||
): string | undefined {
|
||||
if (!cred || cred.type !== "api_key") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const keyRef = coerceSecretRef(cred.keyRef);
|
||||
if (keyRef && keyRef.id.trim()) {
|
||||
return keyRef.source === "env"
|
||||
? keyRef.id.trim()
|
||||
: resolveNonEnvSecretRefApiKeyMarker(keyRef.source);
|
||||
}
|
||||
return cred.key?.trim() || undefined;
|
||||
}
|
||||
|
||||
const cloudflareAiGatewayPlugin = {
|
||||
id: PROVIDER_ID,
|
||||
name: "Cloudflare AI Gateway Provider",
|
||||
description: "Bundled Cloudflare AI Gateway provider plugin",
|
||||
configSchema: emptyPluginConfigSchema(),
|
||||
register(api: OpenClawPluginApi) {
|
||||
api.registerProvider({
|
||||
id: PROVIDER_ID,
|
||||
label: "Cloudflare AI Gateway",
|
||||
docsPath: "/providers/cloudflare-ai-gateway",
|
||||
envVars: ["CLOUDFLARE_AI_GATEWAY_API_KEY"],
|
||||
auth: [],
|
||||
catalog: {
|
||||
order: "late",
|
||||
run: async (ctx) => {
|
||||
const authStore = ensureAuthProfileStore(ctx.agentDir, {
|
||||
allowKeychainPrompt: false,
|
||||
});
|
||||
const envManagedApiKey = ctx.env[PROVIDER_ENV_VAR]?.trim() ? PROVIDER_ENV_VAR : undefined;
|
||||
for (const profileId of listProfilesForProvider(authStore, PROVIDER_ID)) {
|
||||
const cred = authStore.profiles[profileId];
|
||||
if (!cred || cred.type !== "api_key") {
|
||||
continue;
|
||||
}
|
||||
const apiKey = envManagedApiKey ?? resolveApiKeyFromCredential(cred);
|
||||
if (!apiKey) {
|
||||
continue;
|
||||
}
|
||||
const accountId = cred.metadata?.accountId?.trim();
|
||||
const gatewayId = cred.metadata?.gatewayId?.trim();
|
||||
if (!accountId || !gatewayId) {
|
||||
continue;
|
||||
}
|
||||
const baseUrl = resolveCloudflareAiGatewayBaseUrl({ accountId, gatewayId });
|
||||
if (!baseUrl) {
|
||||
continue;
|
||||
}
|
||||
return {
|
||||
provider: {
|
||||
baseUrl,
|
||||
api: "anthropic-messages",
|
||||
apiKey,
|
||||
models: [buildCloudflareAiGatewayModelDefinition()],
|
||||
},
|
||||
};
|
||||
}
|
||||
return null;
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export default cloudflareAiGatewayPlugin;
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"id": "cloudflare-ai-gateway",
|
||||
"providers": ["cloudflare-ai-gateway"],
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {}
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"name": "@openclaw/cloudflare-ai-gateway-provider",
|
||||
"version": "2026.3.14",
|
||||
"private": true,
|
||||
"description": "OpenClaw Cloudflare AI Gateway provider plugin",
|
||||
"type": "module",
|
||||
"openclaw": {
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -407,7 +407,12 @@ export default function register(api: OpenClawPluginApi) {
|
||||
|
||||
const payload: SetupPayload = {
|
||||
url: urlResult.url,
|
||||
bootstrapToken: (await issueDeviceBootstrapToken()).token,
|
||||
bootstrapToken: (
|
||||
await issueDeviceBootstrapToken({
|
||||
role: "node",
|
||||
scopes: [],
|
||||
})
|
||||
).token,
|
||||
};
|
||||
|
||||
if (action === "qr") {
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
import {
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
normalizeAccountId,
|
||||
type OpenClawConfig,
|
||||
type DiscordAccountConfig,
|
||||
} from "openclaw/plugin-sdk/discord";
|
||||
import type { OpenClawConfig } from "../../../src/config/config.js";
|
||||
import type { DiscordAccountConfig } from "../../../src/config/types.discord.js";
|
||||
import {
|
||||
hasConfiguredSecretInput,
|
||||
normalizeSecretInputString,
|
||||
} from "../../../src/config/types.secrets.js";
|
||||
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../src/routing/session-key.js";
|
||||
import {
|
||||
mergeDiscordAccountConfig,
|
||||
resolveDefaultDiscordAccountId,
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import type {
|
||||
OpenClawConfig,
|
||||
DiscordAccountConfig,
|
||||
DiscordActionConfig,
|
||||
} from "openclaw/plugin-sdk/discord";
|
||||
import { createAccountActionGate } from "../../../src/channels/plugins/account-action-gate.js";
|
||||
import { createAccountListHelpers } from "../../../src/channels/plugins/account-helpers.js";
|
||||
import type { OpenClawConfig } from "../../../src/config/config.js";
|
||||
import type { DiscordAccountConfig, DiscordActionConfig } from "../../../src/config/types.js";
|
||||
import { resolveAccountEntry } from "../../../src/routing/account-lookup.js";
|
||||
import { normalizeAccountId } from "../../../src/routing/session-key.js";
|
||||
import { resolveDiscordToken } from "./token.js";
|
||||
|
||||
@@ -19,13 +19,11 @@ describe("discord components", () => {
|
||||
blocks: [
|
||||
{
|
||||
type: "actions",
|
||||
buttons: [{ label: "Approve", style: "success", callbackData: "codex:approve" }],
|
||||
buttons: [{ label: "Approve", style: "success" }],
|
||||
},
|
||||
],
|
||||
modal: {
|
||||
title: "Details",
|
||||
callbackData: "codex:modal",
|
||||
allowedUsers: ["discord:user-1"],
|
||||
fields: [{ type: "text", label: "Requester" }],
|
||||
},
|
||||
});
|
||||
@@ -41,11 +39,6 @@ describe("discord components", () => {
|
||||
|
||||
const trigger = result.entries.find((entry) => entry.kind === "modal-trigger");
|
||||
expect(trigger?.modalId).toBe(result.modals[0]?.id);
|
||||
expect(result.entries.find((entry) => entry.kind === "button")?.callbackData).toBe(
|
||||
"codex:approve",
|
||||
);
|
||||
expect(result.modals[0]?.callbackData).toBe("codex:modal");
|
||||
expect(result.modals[0]?.allowedUsers).toEqual(["discord:user-1"]);
|
||||
});
|
||||
|
||||
it("requires options for modal select fields", () => {
|
||||
|
||||
@@ -46,7 +46,6 @@ export type DiscordComponentButtonSpec = {
|
||||
label: string;
|
||||
style?: DiscordComponentButtonStyle;
|
||||
url?: string;
|
||||
callbackData?: string;
|
||||
emoji?: {
|
||||
name: string;
|
||||
id?: string;
|
||||
@@ -71,12 +70,10 @@ export type DiscordComponentSelectOption = {
|
||||
|
||||
export type DiscordComponentSelectSpec = {
|
||||
type?: DiscordComponentSelectType;
|
||||
callbackData?: string;
|
||||
placeholder?: string;
|
||||
minValues?: number;
|
||||
maxValues?: number;
|
||||
options?: DiscordComponentSelectOption[];
|
||||
allowedUsers?: string[];
|
||||
};
|
||||
|
||||
export type DiscordComponentSectionAccessory =
|
||||
@@ -139,10 +136,8 @@ export type DiscordModalFieldSpec = {
|
||||
|
||||
export type DiscordModalSpec = {
|
||||
title: string;
|
||||
callbackData?: string;
|
||||
triggerLabel?: string;
|
||||
triggerStyle?: DiscordComponentButtonStyle;
|
||||
allowedUsers?: string[];
|
||||
fields: DiscordModalFieldSpec[];
|
||||
};
|
||||
|
||||
@@ -161,7 +156,6 @@ export type DiscordComponentEntry = {
|
||||
id: string;
|
||||
kind: "button" | "select" | "modal-trigger";
|
||||
label: string;
|
||||
callbackData?: string;
|
||||
selectType?: DiscordComponentSelectType;
|
||||
options?: Array<{ value: string; label: string }>;
|
||||
modalId?: string;
|
||||
@@ -194,7 +188,6 @@ export type DiscordModalFieldDefinition = {
|
||||
export type DiscordModalEntry = {
|
||||
id: string;
|
||||
title: string;
|
||||
callbackData?: string;
|
||||
fields: DiscordModalFieldDefinition[];
|
||||
sessionKey?: string;
|
||||
agentId?: string;
|
||||
@@ -203,7 +196,6 @@ export type DiscordModalEntry = {
|
||||
messageId?: string;
|
||||
createdAt?: number;
|
||||
expiresAt?: number;
|
||||
allowedUsers?: string[];
|
||||
};
|
||||
|
||||
export type DiscordComponentBuildResult = {
|
||||
@@ -372,7 +364,6 @@ function parseButtonSpec(raw: unknown, label: string): DiscordComponentButtonSpe
|
||||
label: readString(obj.label, `${label}.label`),
|
||||
style,
|
||||
url,
|
||||
callbackData: readOptionalString(obj.callbackData),
|
||||
emoji:
|
||||
typeof obj.emoji === "object" && obj.emoji && !Array.isArray(obj.emoji)
|
||||
? {
|
||||
@@ -404,12 +395,10 @@ function parseSelectSpec(raw: unknown, label: string): DiscordComponentSelectSpe
|
||||
}
|
||||
return {
|
||||
type,
|
||||
callbackData: readOptionalString(obj.callbackData),
|
||||
placeholder: readOptionalString(obj.placeholder),
|
||||
minValues: readOptionalNumber(obj.minValues),
|
||||
maxValues: readOptionalNumber(obj.maxValues),
|
||||
options: parseSelectOptions(obj.options, `${label}.options`),
|
||||
allowedUsers: readOptionalStringArray(obj.allowedUsers, `${label}.allowedUsers`),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -589,10 +578,8 @@ export function readDiscordComponentSpec(raw: unknown): DiscordComponentMessageS
|
||||
);
|
||||
modal = {
|
||||
title: readString(modalObj.title, "components.modal.title"),
|
||||
callbackData: readOptionalString(modalObj.callbackData),
|
||||
triggerLabel: readOptionalString(modalObj.triggerLabel),
|
||||
triggerStyle: readOptionalString(modalObj.triggerStyle) as DiscordComponentButtonStyle,
|
||||
allowedUsers: readOptionalStringArray(modalObj.allowedUsers, "components.modal.allowedUsers"),
|
||||
fields,
|
||||
};
|
||||
}
|
||||
@@ -731,7 +718,6 @@ function createButtonComponent(params: {
|
||||
id: componentId,
|
||||
kind: params.modalId ? "modal-trigger" : "button",
|
||||
label: params.spec.label,
|
||||
callbackData: params.spec.callbackData,
|
||||
modalId: params.modalId,
|
||||
allowedUsers: params.spec.allowedUsers,
|
||||
},
|
||||
@@ -772,10 +758,8 @@ function createSelectComponent(params: {
|
||||
id: componentId,
|
||||
kind: "select",
|
||||
label: params.spec.placeholder ?? "select",
|
||||
callbackData: params.spec.callbackData,
|
||||
selectType: "string",
|
||||
options: options.map((option) => ({ value: option.value, label: option.label })),
|
||||
allowedUsers: params.spec.allowedUsers,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -793,9 +777,7 @@ function createSelectComponent(params: {
|
||||
id: componentId,
|
||||
kind: "select",
|
||||
label: params.spec.placeholder ?? "user select",
|
||||
callbackData: params.spec.callbackData,
|
||||
selectType: "user",
|
||||
allowedUsers: params.spec.allowedUsers,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -813,9 +795,7 @@ function createSelectComponent(params: {
|
||||
id: componentId,
|
||||
kind: "select",
|
||||
label: params.spec.placeholder ?? "role select",
|
||||
callbackData: params.spec.callbackData,
|
||||
selectType: "role",
|
||||
allowedUsers: params.spec.allowedUsers,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -833,9 +813,7 @@ function createSelectComponent(params: {
|
||||
id: componentId,
|
||||
kind: "select",
|
||||
label: params.spec.placeholder ?? "mentionable select",
|
||||
callbackData: params.spec.callbackData,
|
||||
selectType: "mentionable",
|
||||
allowedUsers: params.spec.allowedUsers,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -852,9 +830,7 @@ function createSelectComponent(params: {
|
||||
id: componentId,
|
||||
kind: "select",
|
||||
label: params.spec.placeholder ?? "channel select",
|
||||
callbackData: params.spec.callbackData,
|
||||
selectType: "channel",
|
||||
allowedUsers: params.spec.allowedUsers,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1071,19 +1047,16 @@ export function buildDiscordComponentMessage(params: {
|
||||
modals.push({
|
||||
id: modalId,
|
||||
title: params.spec.modal.title,
|
||||
callbackData: params.spec.modal.callbackData,
|
||||
fields,
|
||||
sessionKey: params.sessionKey,
|
||||
agentId: params.agentId,
|
||||
accountId: params.accountId,
|
||||
reusable: params.spec.reusable,
|
||||
allowedUsers: params.spec.modal.allowedUsers,
|
||||
});
|
||||
|
||||
const triggerSpec: DiscordComponentButtonSpec = {
|
||||
label: params.spec.modal.triggerLabel ?? "Open form",
|
||||
style: params.spec.modal.triggerStyle ?? "primary",
|
||||
allowedUsers: params.spec.modal.allowedUsers,
|
||||
};
|
||||
|
||||
const { component, entry } = createButtonComponent({
|
||||
|
||||
@@ -1,72 +1,74 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { DirectoryConfigParams } from "../../../src/channels/plugins/directory-config.js";
|
||||
import type { OpenClawConfig } from "../../../src/config/config.js";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
fetchDiscord: vi.fn(),
|
||||
normalizeDiscordToken: vi.fn((token: string) => token.trim()),
|
||||
resolveDiscordAccount: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./accounts.js", () => ({
|
||||
resolveDiscordAccount: mocks.resolveDiscordAccount,
|
||||
}));
|
||||
|
||||
vi.mock("./api.js", () => ({
|
||||
fetchDiscord: mocks.fetchDiscord,
|
||||
}));
|
||||
|
||||
vi.mock("./token.js", () => ({
|
||||
normalizeDiscordToken: mocks.normalizeDiscordToken,
|
||||
}));
|
||||
|
||||
import { listDiscordDirectoryGroupsLive, listDiscordDirectoryPeersLive } from "./directory-live.js";
|
||||
|
||||
function makeParams(overrides: Partial<DirectoryConfigParams> = {}): DirectoryConfigParams {
|
||||
return {
|
||||
cfg: {
|
||||
channels: {
|
||||
discord: {
|
||||
token: "test-token",
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
accountId: "default",
|
||||
cfg: {} as DirectoryConfigParams["cfg"],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function jsonResponse(value: unknown): Response {
|
||||
return new Response(JSON.stringify(value), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
describe("discord directory live lookups", () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.clearAllMocks();
|
||||
mocks.resolveDiscordAccount.mockReturnValue({ token: "test-token" });
|
||||
mocks.normalizeDiscordToken.mockImplementation((token: string) => token.trim());
|
||||
});
|
||||
|
||||
it("returns empty group directory when token is missing", async () => {
|
||||
const rows = await listDiscordDirectoryGroupsLive({
|
||||
...makeParams(),
|
||||
cfg: { channels: { discord: { token: "" } } } as OpenClawConfig,
|
||||
query: "general",
|
||||
});
|
||||
mocks.normalizeDiscordToken.mockReturnValue("");
|
||||
|
||||
const rows = await listDiscordDirectoryGroupsLive(makeParams({ query: "general" }));
|
||||
|
||||
expect(rows).toEqual([]);
|
||||
expect(mocks.fetchDiscord).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns empty peer directory without query and skips guild listing", async () => {
|
||||
const fetchSpy = vi.spyOn(globalThis, "fetch");
|
||||
|
||||
const rows = await listDiscordDirectoryPeersLive(makeParams({ query: " " }));
|
||||
|
||||
expect(rows).toEqual([]);
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
expect(mocks.fetchDiscord).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("filters group channels by query and respects limit", async () => {
|
||||
vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => {
|
||||
const url = String(input);
|
||||
if (url.endsWith("/users/@me/guilds")) {
|
||||
return jsonResponse([
|
||||
mocks.fetchDiscord.mockImplementation(async (path: string) => {
|
||||
if (path === "/users/@me/guilds") {
|
||||
return [
|
||||
{ id: "g1", name: "Guild 1" },
|
||||
{ id: "g2", name: "Guild 2" },
|
||||
]);
|
||||
];
|
||||
}
|
||||
if (url.endsWith("/guilds/g1/channels")) {
|
||||
return jsonResponse([
|
||||
if (path === "/guilds/g1/channels") {
|
||||
return [
|
||||
{ id: "c1", name: "general" },
|
||||
{ id: "c2", name: "random" },
|
||||
]);
|
||||
];
|
||||
}
|
||||
if (url.endsWith("/guilds/g2/channels")) {
|
||||
return jsonResponse([{ id: "c3", name: "announcements" }]);
|
||||
if (path === "/guilds/g2/channels") {
|
||||
return [{ id: "c3", name: "announcements" }];
|
||||
}
|
||||
return jsonResponse([]);
|
||||
return [];
|
||||
});
|
||||
|
||||
const rows = await listDiscordDirectoryGroupsLive(makeParams({ query: "an", limit: 2 }));
|
||||
@@ -78,22 +80,21 @@ describe("discord directory live lookups", () => {
|
||||
});
|
||||
|
||||
it("returns ranked peer results and caps member search by limit", async () => {
|
||||
vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => {
|
||||
const url = String(input);
|
||||
if (url.endsWith("/users/@me/guilds")) {
|
||||
return jsonResponse([{ id: "g1", name: "Guild 1" }]);
|
||||
mocks.fetchDiscord.mockImplementation(async (path: string) => {
|
||||
if (path === "/users/@me/guilds") {
|
||||
return [{ id: "g1", name: "Guild 1" }];
|
||||
}
|
||||
if (url.includes("/guilds/g1/members/search?")) {
|
||||
const params = new URL(url).searchParams;
|
||||
if (path.startsWith("/guilds/g1/members/search?")) {
|
||||
const params = new URLSearchParams(path.split("?")[1] ?? "");
|
||||
expect(params.get("query")).toBe("alice");
|
||||
expect(params.get("limit")).toBe("2");
|
||||
return jsonResponse([
|
||||
return [
|
||||
{ user: { id: "u1", username: "alice", bot: false }, nick: "Ali" },
|
||||
{ user: { id: "u2", username: "alice-bot", bot: true }, nick: null },
|
||||
{ user: { id: "u3", username: "ignored", bot: false }, nick: null },
|
||||
]);
|
||||
];
|
||||
}
|
||||
return jsonResponse([]);
|
||||
return [];
|
||||
});
|
||||
|
||||
const rows = await listDiscordDirectoryPeersLive(makeParams({ query: "alice", limit: 2 }));
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
type ModalInteraction,
|
||||
type RoleSelectMenuInteraction,
|
||||
type StringSelectMenuInteraction,
|
||||
type TopLevelComponents,
|
||||
type UserSelectMenuInteraction,
|
||||
} from "@buape/carbon";
|
||||
import type { APIStringSelectComponent } from "discord-api-types/v10";
|
||||
@@ -41,12 +40,6 @@ import { logDebug, logError } from "../../../../src/logger.js";
|
||||
import { getAgentScopedMediaLocalRoots } from "../../../../src/media/local-roots.js";
|
||||
import { issuePairingChallenge } from "../../../../src/pairing/pairing-challenge.js";
|
||||
import { upsertChannelPairingRequest } from "../../../../src/pairing/pairing-store.js";
|
||||
import {
|
||||
buildPluginBindingResolvedText,
|
||||
parsePluginBindingApprovalCustomId,
|
||||
resolvePluginConversationBindingApproval,
|
||||
} from "../../../../src/plugins/conversation-binding.js";
|
||||
import { dispatchPluginInteractiveHandler } from "../../../../src/plugins/interactive.js";
|
||||
import { resolveAgentRoute } from "../../../../src/routing/resolve-route.js";
|
||||
import { createNonExitingRuntime, type RuntimeEnv } from "../../../../src/runtime.js";
|
||||
import {
|
||||
@@ -778,159 +771,6 @@ function formatModalSubmissionText(
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function resolveDiscordInteractionId(interaction: AgentComponentInteraction): string {
|
||||
const rawId =
|
||||
interaction.rawData && typeof interaction.rawData === "object" && "id" in interaction.rawData
|
||||
? (interaction.rawData as { id?: unknown }).id
|
||||
: undefined;
|
||||
if (typeof rawId === "string" && rawId.trim()) {
|
||||
return rawId.trim();
|
||||
}
|
||||
if (typeof rawId === "number" && Number.isFinite(rawId)) {
|
||||
return String(rawId);
|
||||
}
|
||||
return `discord-interaction:${Date.now()}`;
|
||||
}
|
||||
|
||||
async function dispatchPluginDiscordInteractiveEvent(params: {
|
||||
ctx: AgentComponentContext;
|
||||
interaction: AgentComponentInteraction;
|
||||
interactionCtx: ComponentInteractionContext;
|
||||
channelCtx: DiscordChannelContext;
|
||||
isAuthorizedSender: boolean;
|
||||
data: string;
|
||||
kind: "button" | "select" | "modal";
|
||||
values?: string[];
|
||||
fields?: Array<{ id: string; name: string; values: string[] }>;
|
||||
messageId?: string;
|
||||
}): Promise<"handled" | "unmatched"> {
|
||||
const normalizedConversationId =
|
||||
params.interactionCtx.rawGuildId || params.channelCtx.channelType === ChannelType.GroupDM
|
||||
? `channel:${params.interactionCtx.channelId}`
|
||||
: `user:${params.interactionCtx.userId}`;
|
||||
let responded = false;
|
||||
const respond = {
|
||||
acknowledge: async () => {
|
||||
responded = true;
|
||||
await params.interaction.acknowledge();
|
||||
},
|
||||
reply: async ({ text, ephemeral = true }: { text: string; ephemeral?: boolean }) => {
|
||||
responded = true;
|
||||
await params.interaction.reply({
|
||||
content: text,
|
||||
ephemeral,
|
||||
});
|
||||
},
|
||||
followUp: async ({ text, ephemeral = true }: { text: string; ephemeral?: boolean }) => {
|
||||
responded = true;
|
||||
await params.interaction.followUp({
|
||||
content: text,
|
||||
ephemeral,
|
||||
});
|
||||
},
|
||||
editMessage: async ({
|
||||
text,
|
||||
components,
|
||||
}: {
|
||||
text?: string;
|
||||
components?: TopLevelComponents[];
|
||||
}) => {
|
||||
if (!("update" in params.interaction) || typeof params.interaction.update !== "function") {
|
||||
throw new Error("Discord interaction cannot update the source message");
|
||||
}
|
||||
responded = true;
|
||||
await params.interaction.update({
|
||||
...(text !== undefined ? { content: text } : {}),
|
||||
...(components !== undefined ? { components } : {}),
|
||||
});
|
||||
},
|
||||
clearComponents: async (input?: { text?: string }) => {
|
||||
if (!("update" in params.interaction) || typeof params.interaction.update !== "function") {
|
||||
throw new Error("Discord interaction cannot clear components on the source message");
|
||||
}
|
||||
responded = true;
|
||||
await params.interaction.update({
|
||||
...(input?.text !== undefined ? { content: input.text } : {}),
|
||||
components: [],
|
||||
});
|
||||
},
|
||||
};
|
||||
const pluginBindingApproval = parsePluginBindingApprovalCustomId(params.data);
|
||||
if (pluginBindingApproval) {
|
||||
const resolved = await resolvePluginConversationBindingApproval({
|
||||
approvalId: pluginBindingApproval.approvalId,
|
||||
decision: pluginBindingApproval.decision,
|
||||
senderId: params.interactionCtx.userId,
|
||||
});
|
||||
let cleared = false;
|
||||
try {
|
||||
await respond.clearComponents();
|
||||
cleared = true;
|
||||
} catch {
|
||||
try {
|
||||
await respond.acknowledge();
|
||||
} catch {
|
||||
// Interaction may already be acknowledged; continue with best-effort follow-up.
|
||||
}
|
||||
}
|
||||
try {
|
||||
await respond.followUp({
|
||||
text: buildPluginBindingResolvedText(resolved),
|
||||
ephemeral: true,
|
||||
});
|
||||
} catch (err) {
|
||||
logError(`discord plugin binding approval: failed to follow up: ${String(err)}`);
|
||||
if (!cleared) {
|
||||
try {
|
||||
await respond.reply({
|
||||
text: buildPluginBindingResolvedText(resolved),
|
||||
ephemeral: true,
|
||||
});
|
||||
} catch {
|
||||
// Interaction may no longer accept a direct reply.
|
||||
}
|
||||
}
|
||||
}
|
||||
return "handled";
|
||||
}
|
||||
const dispatched = await dispatchPluginInteractiveHandler({
|
||||
channel: "discord",
|
||||
data: params.data,
|
||||
interactionId: resolveDiscordInteractionId(params.interaction),
|
||||
ctx: {
|
||||
accountId: params.ctx.accountId,
|
||||
interactionId: resolveDiscordInteractionId(params.interaction),
|
||||
conversationId: normalizedConversationId,
|
||||
parentConversationId: params.channelCtx.parentId,
|
||||
guildId: params.interactionCtx.rawGuildId,
|
||||
senderId: params.interactionCtx.userId,
|
||||
senderUsername: params.interactionCtx.username,
|
||||
auth: { isAuthorizedSender: params.isAuthorizedSender },
|
||||
interaction: {
|
||||
kind: params.kind,
|
||||
messageId: params.messageId,
|
||||
values: params.values,
|
||||
fields: params.fields,
|
||||
},
|
||||
},
|
||||
respond,
|
||||
});
|
||||
if (!dispatched.matched) {
|
||||
return "unmatched";
|
||||
}
|
||||
if (dispatched.handled) {
|
||||
if (!responded) {
|
||||
try {
|
||||
await respond.acknowledge();
|
||||
} catch {
|
||||
// Interaction may have expired after the handler finished.
|
||||
}
|
||||
}
|
||||
return "handled";
|
||||
}
|
||||
return "unmatched";
|
||||
}
|
||||
|
||||
function resolveComponentCommandAuthorized(params: {
|
||||
ctx: AgentComponentContext;
|
||||
interactionCtx: ComponentInteractionContext;
|
||||
@@ -1262,17 +1102,6 @@ async function handleDiscordComponentEvent(params: {
|
||||
guildEntries: params.ctx.guildEntries,
|
||||
});
|
||||
const channelCtx = resolveDiscordChannelContext(params.interaction);
|
||||
const allowNameMatching = isDangerousNameMatchingEnabled(params.ctx.discordConfig);
|
||||
const channelConfig = resolveDiscordChannelConfigWithFallback({
|
||||
guildInfo,
|
||||
channelId,
|
||||
channelName: channelCtx.channelName,
|
||||
channelSlug: channelCtx.channelSlug,
|
||||
parentId: channelCtx.parentId,
|
||||
parentName: channelCtx.parentName,
|
||||
parentSlug: channelCtx.parentSlug,
|
||||
scope: channelCtx.isThread ? "thread" : "channel",
|
||||
});
|
||||
const unauthorizedReply = `You are not authorized to use this ${params.componentLabel}.`;
|
||||
const memberAllowed = await ensureGuildComponentMemberAllowed({
|
||||
interaction: params.interaction,
|
||||
@@ -1285,7 +1114,7 @@ async function handleDiscordComponentEvent(params: {
|
||||
replyOpts,
|
||||
componentLabel: params.componentLabel,
|
||||
unauthorizedReply,
|
||||
allowNameMatching,
|
||||
allowNameMatching: isDangerousNameMatchingEnabled(params.ctx.discordConfig),
|
||||
});
|
||||
if (!memberAllowed) {
|
||||
return;
|
||||
@@ -1298,18 +1127,11 @@ async function handleDiscordComponentEvent(params: {
|
||||
replyOpts,
|
||||
componentLabel: params.componentLabel,
|
||||
unauthorizedReply,
|
||||
allowNameMatching,
|
||||
allowNameMatching: isDangerousNameMatchingEnabled(params.ctx.discordConfig),
|
||||
});
|
||||
if (!componentAllowed) {
|
||||
return;
|
||||
}
|
||||
const commandAuthorized = resolveComponentCommandAuthorized({
|
||||
ctx: params.ctx,
|
||||
interactionCtx,
|
||||
channelConfig,
|
||||
guildInfo,
|
||||
allowNameMatching,
|
||||
});
|
||||
|
||||
const consumed = resolveDiscordComponentEntry({
|
||||
id: parsed.componentId,
|
||||
@@ -1340,22 +1162,6 @@ async function handleDiscordComponentEvent(params: {
|
||||
}
|
||||
|
||||
const values = params.values ? mapSelectValues(consumed, params.values) : undefined;
|
||||
if (consumed.callbackData) {
|
||||
const pluginDispatch = await dispatchPluginDiscordInteractiveEvent({
|
||||
ctx: params.ctx,
|
||||
interaction: params.interaction,
|
||||
interactionCtx,
|
||||
channelCtx,
|
||||
isAuthorizedSender: commandAuthorized,
|
||||
data: consumed.callbackData,
|
||||
kind: consumed.kind === "select" ? "select" : "button",
|
||||
values,
|
||||
messageId: consumed.messageId ?? params.interaction.message?.id,
|
||||
});
|
||||
if (pluginDispatch === "handled") {
|
||||
return;
|
||||
}
|
||||
}
|
||||
const eventText = formatDiscordComponentEventText({
|
||||
kind: consumed.kind === "select" ? "select" : "button",
|
||||
label: consumed.label,
|
||||
@@ -1900,17 +1706,6 @@ class DiscordComponentModal extends Modal {
|
||||
guildEntries: this.ctx.guildEntries,
|
||||
});
|
||||
const channelCtx = resolveDiscordChannelContext(interaction);
|
||||
const allowNameMatching = isDangerousNameMatchingEnabled(this.ctx.discordConfig);
|
||||
const channelConfig = resolveDiscordChannelConfigWithFallback({
|
||||
guildInfo,
|
||||
channelId,
|
||||
channelName: channelCtx.channelName,
|
||||
channelSlug: channelCtx.channelSlug,
|
||||
parentId: channelCtx.parentId,
|
||||
parentName: channelCtx.parentName,
|
||||
parentSlug: channelCtx.parentSlug,
|
||||
scope: channelCtx.isThread ? "thread" : "channel",
|
||||
});
|
||||
const memberAllowed = await ensureGuildComponentMemberAllowed({
|
||||
interaction,
|
||||
guildInfo,
|
||||
@@ -1922,37 +1717,12 @@ class DiscordComponentModal extends Modal {
|
||||
replyOpts,
|
||||
componentLabel: "form",
|
||||
unauthorizedReply: "You are not authorized to use this form.",
|
||||
allowNameMatching,
|
||||
allowNameMatching: isDangerousNameMatchingEnabled(this.ctx.discordConfig),
|
||||
});
|
||||
if (!memberAllowed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const modalAllowed = await ensureComponentUserAllowed({
|
||||
entry: {
|
||||
id: modalEntry.id,
|
||||
kind: "button",
|
||||
label: modalEntry.title,
|
||||
allowedUsers: modalEntry.allowedUsers,
|
||||
},
|
||||
interaction,
|
||||
user,
|
||||
replyOpts,
|
||||
componentLabel: "form",
|
||||
unauthorizedReply: "You are not authorized to use this form.",
|
||||
allowNameMatching,
|
||||
});
|
||||
if (!modalAllowed) {
|
||||
return;
|
||||
}
|
||||
const commandAuthorized = resolveComponentCommandAuthorized({
|
||||
ctx: this.ctx,
|
||||
interactionCtx,
|
||||
channelConfig,
|
||||
guildInfo,
|
||||
allowNameMatching,
|
||||
});
|
||||
|
||||
const consumed = resolveDiscordModalEntry({
|
||||
id: modalId,
|
||||
consume: !modalEntry.reusable,
|
||||
@@ -1969,28 +1739,6 @@ class DiscordComponentModal extends Modal {
|
||||
return;
|
||||
}
|
||||
|
||||
if (consumed.callbackData) {
|
||||
const fields = consumed.fields.map((field) => ({
|
||||
id: field.id,
|
||||
name: field.name,
|
||||
values: resolveModalFieldValues(field, interaction),
|
||||
}));
|
||||
const pluginDispatch = await dispatchPluginDiscordInteractiveEvent({
|
||||
ctx: this.ctx,
|
||||
interaction,
|
||||
interactionCtx,
|
||||
channelCtx,
|
||||
isAuthorizedSender: commandAuthorized,
|
||||
data: consumed.callbackData,
|
||||
kind: "modal",
|
||||
fields,
|
||||
messageId: consumed.messageId,
|
||||
});
|
||||
if (pluginDispatch === "handled") {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await interaction.acknowledge();
|
||||
} catch (err) {
|
||||
|
||||
@@ -90,20 +90,6 @@ function createThreadClient(params: { threadId: string; parentId: string }): Dis
|
||||
} as unknown as DiscordClient;
|
||||
}
|
||||
|
||||
function createDmClient(channelId: string): DiscordClient {
|
||||
return {
|
||||
fetchChannel: async (id: string) => {
|
||||
if (id === channelId) {
|
||||
return {
|
||||
id: channelId,
|
||||
type: ChannelType.DM,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
},
|
||||
} as unknown as DiscordClient;
|
||||
}
|
||||
|
||||
async function runThreadBoundPreflight(params: {
|
||||
threadId: string;
|
||||
parentId: string;
|
||||
@@ -171,25 +157,6 @@ async function runGuildPreflight(params: {
|
||||
});
|
||||
}
|
||||
|
||||
async function runDmPreflight(params: {
|
||||
channelId: string;
|
||||
message: import("@buape/carbon").Message;
|
||||
discordConfig: DiscordConfig;
|
||||
}) {
|
||||
return preflightDiscordMessage({
|
||||
...createPreflightArgs({
|
||||
cfg: DEFAULT_PREFLIGHT_CFG,
|
||||
discordConfig: params.discordConfig,
|
||||
data: {
|
||||
channel_id: params.channelId,
|
||||
author: params.message.author,
|
||||
message: params.message,
|
||||
} as DiscordMessageEvent,
|
||||
client: createDmClient(params.channelId),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
async function runMentionOnlyBotPreflight(params: {
|
||||
channelId: string;
|
||||
guildId: string;
|
||||
@@ -291,60 +258,6 @@ describe("preflightDiscordMessage", () => {
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("restores direct-message bindings by user target instead of DM channel id", async () => {
|
||||
registerSessionBindingAdapter({
|
||||
channel: "discord",
|
||||
accountId: "default",
|
||||
listBySession: () => [],
|
||||
resolveByConversation: (ref) =>
|
||||
ref.conversationId === "user:user-1"
|
||||
? createThreadBinding({
|
||||
conversation: {
|
||||
channel: "discord",
|
||||
accountId: "default",
|
||||
conversationId: "user:user-1",
|
||||
},
|
||||
metadata: {
|
||||
pluginBindingOwner: "plugin",
|
||||
pluginId: "openclaw-codex-app-server",
|
||||
pluginRoot: "/Users/huntharo/github/openclaw-app-server",
|
||||
},
|
||||
})
|
||||
: null,
|
||||
});
|
||||
|
||||
const result = await runDmPreflight({
|
||||
channelId: "dm-channel-1",
|
||||
message: createDiscordMessage({
|
||||
id: "m-dm-1",
|
||||
channelId: "dm-channel-1",
|
||||
content: "who are you",
|
||||
author: {
|
||||
id: "user-1",
|
||||
bot: false,
|
||||
username: "alice",
|
||||
},
|
||||
}),
|
||||
discordConfig: {
|
||||
allowBots: true,
|
||||
dmPolicy: "open",
|
||||
} as DiscordConfig,
|
||||
});
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.threadBinding).toMatchObject({
|
||||
conversation: {
|
||||
channel: "discord",
|
||||
accountId: "default",
|
||||
conversationId: "user:user-1",
|
||||
},
|
||||
metadata: {
|
||||
pluginBindingOwner: "plugin",
|
||||
pluginId: "openclaw-codex-app-server",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps bound-thread regular bot messages flowing when allowBots=true", async () => {
|
||||
const threadBinding = createThreadBinding({
|
||||
targetKind: "session",
|
||||
|
||||
@@ -29,7 +29,6 @@ import { enqueueSystemEvent } from "../../../../src/infra/system-events.js";
|
||||
import { logDebug } from "../../../../src/logger.js";
|
||||
import { getChildLogger } from "../../../../src/logging.js";
|
||||
import { buildPairingReply } from "../../../../src/pairing/pairing-messages.js";
|
||||
import { isPluginOwnedSessionBindingRecord } from "../../../../src/plugins/conversation-binding.js";
|
||||
import { DEFAULT_ACCOUNT_ID } from "../../../../src/routing/session-key.js";
|
||||
import { fetchPluralKitMessageInfo } from "../pluralkit.js";
|
||||
import { sendMessageDiscord } from "../send.js";
|
||||
@@ -351,13 +350,12 @@ export async function preflightDiscordMessage(
|
||||
}),
|
||||
parentConversationId: earlyThreadParentId,
|
||||
});
|
||||
const bindingConversationId = isDirectMessage ? `user:${author.id}` : messageChannelId;
|
||||
let threadBinding: SessionBindingRecord | undefined;
|
||||
threadBinding =
|
||||
getSessionBindingService().resolveByConversation({
|
||||
channel: "discord",
|
||||
accountId: params.accountId,
|
||||
conversationId: bindingConversationId,
|
||||
conversationId: messageChannelId,
|
||||
parentConversationId: earlyThreadParentId,
|
||||
}) ?? undefined;
|
||||
const configuredRoute =
|
||||
@@ -386,9 +384,7 @@ export async function preflightDiscordMessage(
|
||||
logVerbose(`discord: drop bound-thread webhook echo message ${message.id}`);
|
||||
return null;
|
||||
}
|
||||
const boundSessionKey = isPluginOwnedSessionBindingRecord(threadBinding)
|
||||
? ""
|
||||
: threadBinding?.targetSessionKey?.trim();
|
||||
const boundSessionKey = threadBinding?.targetSessionKey?.trim();
|
||||
const effectiveRoute = resolveDiscordEffectiveRoute({
|
||||
route,
|
||||
boundSessionKey,
|
||||
@@ -396,7 +392,7 @@ export async function preflightDiscordMessage(
|
||||
matchedBy: "binding.channel",
|
||||
});
|
||||
const boundAgentId = boundSessionKey ? effectiveRoute.agentId : undefined;
|
||||
const isBoundThreadSession = Boolean(threadBinding && earlyThreadChannel);
|
||||
const isBoundThreadSession = Boolean(boundSessionKey && earlyThreadChannel);
|
||||
if (
|
||||
isBoundThreadBotSystemMessage({
|
||||
isBoundThreadSession,
|
||||
|
||||
@@ -5,12 +5,10 @@ import type {
|
||||
StringSelectMenuInteraction,
|
||||
} from "@buape/carbon";
|
||||
import type { Client } from "@buape/carbon";
|
||||
import { ChannelType } from "discord-api-types/v10";
|
||||
import type { GatewayPresenceUpdate } from "discord-api-types/v10";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../../../../src/config/config.js";
|
||||
import type { DiscordAccountConfig } from "../../../../src/config/types.discord.js";
|
||||
import { buildPluginBindingApprovalCustomId } from "../../../../src/plugins/conversation-binding.js";
|
||||
import { buildAgentSessionKey } from "../../../../src/routing/resolve-route.js";
|
||||
import {
|
||||
clearDiscordComponentEntries,
|
||||
@@ -54,9 +52,6 @@ const deliverDiscordReplyMock = vi.hoisted(() => vi.fn());
|
||||
const recordInboundSessionMock = vi.hoisted(() => vi.fn());
|
||||
const readSessionUpdatedAtMock = vi.hoisted(() => vi.fn());
|
||||
const resolveStorePathMock = vi.hoisted(() => vi.fn());
|
||||
const dispatchPluginInteractiveHandlerMock = vi.hoisted(() => vi.fn());
|
||||
const resolvePluginConversationBindingApprovalMock = vi.hoisted(() => vi.fn());
|
||||
const buildPluginBindingResolvedTextMock = vi.hoisted(() => vi.fn());
|
||||
let lastDispatchCtx: Record<string, unknown> | undefined;
|
||||
|
||||
vi.mock("../../../../src/pairing/pairing-store.js", () => ({
|
||||
@@ -93,27 +88,6 @@ vi.mock("../../../../src/config/sessions.js", async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../../../src/plugins/conversation-binding.js", async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import("../../../../src/plugins/conversation-binding.js")>();
|
||||
return {
|
||||
...actual,
|
||||
resolvePluginConversationBindingApproval: (...args: unknown[]) =>
|
||||
resolvePluginConversationBindingApprovalMock(...args),
|
||||
buildPluginBindingResolvedText: (...args: unknown[]) =>
|
||||
buildPluginBindingResolvedTextMock(...args),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../../../src/plugins/interactive.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../../../src/plugins/interactive.js")>();
|
||||
return {
|
||||
...actual,
|
||||
dispatchPluginInteractiveHandler: (...args: unknown[]) =>
|
||||
dispatchPluginInteractiveHandlerMock(...args),
|
||||
};
|
||||
});
|
||||
|
||||
describe("agent components", () => {
|
||||
const createCfg = (): OpenClawConfig => ({}) as OpenClawConfig;
|
||||
|
||||
@@ -367,38 +341,6 @@ describe("discord component interactions", () => {
|
||||
recordInboundSessionMock.mockClear().mockResolvedValue(undefined);
|
||||
readSessionUpdatedAtMock.mockClear().mockReturnValue(undefined);
|
||||
resolveStorePathMock.mockClear().mockReturnValue("/tmp/openclaw-sessions-test.json");
|
||||
dispatchPluginInteractiveHandlerMock.mockReset().mockResolvedValue({
|
||||
matched: false,
|
||||
handled: false,
|
||||
duplicate: false,
|
||||
});
|
||||
resolvePluginConversationBindingApprovalMock.mockReset().mockResolvedValue({
|
||||
status: "approved",
|
||||
binding: {
|
||||
bindingId: "binding-1",
|
||||
pluginId: "openclaw-codex-app-server",
|
||||
pluginName: "OpenClaw App Server",
|
||||
pluginRoot: "/plugins/codex",
|
||||
channel: "discord",
|
||||
accountId: "default",
|
||||
conversationId: "user:123456789",
|
||||
boundAt: Date.now(),
|
||||
},
|
||||
request: {
|
||||
id: "approval-1",
|
||||
pluginId: "openclaw-codex-app-server",
|
||||
pluginName: "OpenClaw App Server",
|
||||
pluginRoot: "/plugins/codex",
|
||||
requestedAt: Date.now(),
|
||||
conversation: {
|
||||
channel: "discord",
|
||||
accountId: "default",
|
||||
conversationId: "user:123456789",
|
||||
},
|
||||
},
|
||||
decision: "allow-once",
|
||||
});
|
||||
buildPluginBindingResolvedTextMock.mockReset().mockReturnValue("Binding approved.");
|
||||
});
|
||||
|
||||
it("routes button clicks with reply references", async () => {
|
||||
@@ -557,200 +499,6 @@ describe("discord component interactions", () => {
|
||||
expect(acknowledge).toHaveBeenCalledTimes(1);
|
||||
expect(resolveDiscordModalEntry({ id: "mdl_1", consume: false })).not.toBeNull();
|
||||
});
|
||||
|
||||
it("passes false auth to plugin Discord interactions for non-allowlisted guild users", async () => {
|
||||
registerDiscordComponentEntries({
|
||||
entries: [createButtonEntry({ callbackData: "codex:approve" })],
|
||||
modals: [],
|
||||
});
|
||||
dispatchPluginInteractiveHandlerMock.mockResolvedValue({
|
||||
matched: true,
|
||||
handled: true,
|
||||
duplicate: false,
|
||||
});
|
||||
|
||||
const button = createDiscordComponentButton(
|
||||
createComponentContext({
|
||||
cfg: {
|
||||
commands: { useAccessGroups: true },
|
||||
channels: { discord: { replyToMode: "first" } },
|
||||
} as OpenClawConfig,
|
||||
allowFrom: ["owner-1"],
|
||||
}),
|
||||
);
|
||||
const { interaction } = createComponentButtonInteraction({
|
||||
rawData: {
|
||||
channel_id: "guild-channel",
|
||||
guild_id: "guild-1",
|
||||
id: "interaction-guild-plugin-1",
|
||||
member: { roles: [] },
|
||||
} as unknown as ButtonInteraction["rawData"],
|
||||
guild: { id: "guild-1", name: "Test Guild" } as unknown as ButtonInteraction["guild"],
|
||||
});
|
||||
|
||||
await button.run(interaction, { cid: "btn_1" } as ComponentData);
|
||||
|
||||
expect(dispatchPluginInteractiveHandlerMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
ctx: expect.objectContaining({
|
||||
auth: { isAuthorizedSender: false },
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(dispatchReplyMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("passes true auth to plugin Discord interactions for allowlisted guild users", async () => {
|
||||
registerDiscordComponentEntries({
|
||||
entries: [createButtonEntry({ callbackData: "codex:approve" })],
|
||||
modals: [],
|
||||
});
|
||||
dispatchPluginInteractiveHandlerMock.mockResolvedValue({
|
||||
matched: true,
|
||||
handled: true,
|
||||
duplicate: false,
|
||||
});
|
||||
|
||||
const button = createDiscordComponentButton(
|
||||
createComponentContext({
|
||||
cfg: {
|
||||
commands: { useAccessGroups: true },
|
||||
channels: { discord: { replyToMode: "first" } },
|
||||
} as OpenClawConfig,
|
||||
allowFrom: ["123456789"],
|
||||
}),
|
||||
);
|
||||
const { interaction } = createComponentButtonInteraction({
|
||||
rawData: {
|
||||
channel_id: "guild-channel",
|
||||
guild_id: "guild-1",
|
||||
id: "interaction-guild-plugin-2",
|
||||
member: { roles: [] },
|
||||
} as unknown as ButtonInteraction["rawData"],
|
||||
guild: { id: "guild-1", name: "Test Guild" } as unknown as ButtonInteraction["guild"],
|
||||
});
|
||||
|
||||
await button.run(interaction, { cid: "btn_1" } as ComponentData);
|
||||
|
||||
expect(dispatchPluginInteractiveHandlerMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
ctx: expect.objectContaining({
|
||||
auth: { isAuthorizedSender: true },
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(dispatchReplyMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("routes plugin Discord interactions in group DMs by channel id instead of sender id", async () => {
|
||||
registerDiscordComponentEntries({
|
||||
entries: [createButtonEntry({ callbackData: "codex:approve" })],
|
||||
modals: [],
|
||||
});
|
||||
dispatchPluginInteractiveHandlerMock.mockResolvedValue({
|
||||
matched: true,
|
||||
handled: true,
|
||||
duplicate: false,
|
||||
});
|
||||
|
||||
const button = createDiscordComponentButton(createComponentContext());
|
||||
const { interaction } = createComponentButtonInteraction({
|
||||
rawData: {
|
||||
channel_id: "group-dm-1",
|
||||
id: "interaction-group-dm-1",
|
||||
} as unknown as ButtonInteraction["rawData"],
|
||||
channel: {
|
||||
id: "group-dm-1",
|
||||
type: ChannelType.GroupDM,
|
||||
} as unknown as ButtonInteraction["channel"],
|
||||
});
|
||||
|
||||
await button.run(interaction, { cid: "btn_1" } as ComponentData);
|
||||
|
||||
expect(dispatchPluginInteractiveHandlerMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
ctx: expect.objectContaining({
|
||||
conversationId: "channel:group-dm-1",
|
||||
senderId: "123456789",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(dispatchReplyMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not fall through to Claw when a plugin Discord interaction already replied", async () => {
|
||||
registerDiscordComponentEntries({
|
||||
entries: [createButtonEntry({ callbackData: "codex:approve" })],
|
||||
modals: [],
|
||||
});
|
||||
dispatchPluginInteractiveHandlerMock.mockImplementation(async (params: any) => {
|
||||
await params.respond.reply({ text: "✓", ephemeral: true });
|
||||
return {
|
||||
matched: true,
|
||||
handled: true,
|
||||
duplicate: false,
|
||||
};
|
||||
});
|
||||
|
||||
const button = createDiscordComponentButton(createComponentContext());
|
||||
const { interaction, reply } = createComponentButtonInteraction();
|
||||
|
||||
await button.run(interaction, { cid: "btn_1" } as ComponentData);
|
||||
|
||||
expect(dispatchPluginInteractiveHandlerMock).toHaveBeenCalledTimes(1);
|
||||
expect(reply).toHaveBeenCalledWith({ content: "✓", ephemeral: true });
|
||||
expect(dispatchReplyMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls through to built-in Discord component routing when a plugin declines handling", async () => {
|
||||
registerDiscordComponentEntries({
|
||||
entries: [createButtonEntry({ callbackData: "codex:approve" })],
|
||||
modals: [],
|
||||
});
|
||||
dispatchPluginInteractiveHandlerMock.mockResolvedValue({
|
||||
matched: true,
|
||||
handled: false,
|
||||
duplicate: false,
|
||||
});
|
||||
|
||||
const button = createDiscordComponentButton(createComponentContext());
|
||||
const { interaction, reply } = createComponentButtonInteraction();
|
||||
|
||||
await button.run(interaction, { cid: "btn_1" } as ComponentData);
|
||||
|
||||
expect(dispatchPluginInteractiveHandlerMock).toHaveBeenCalledTimes(1);
|
||||
expect(reply).toHaveBeenCalledWith({ content: "✓" });
|
||||
expect(dispatchReplyMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("resolves plugin binding approvals without falling through to Claw", async () => {
|
||||
registerDiscordComponentEntries({
|
||||
entries: [
|
||||
createButtonEntry({
|
||||
callbackData: buildPluginBindingApprovalCustomId("approval-1", "allow-once"),
|
||||
}),
|
||||
],
|
||||
modals: [],
|
||||
});
|
||||
const button = createDiscordComponentButton(createComponentContext());
|
||||
const update = vi.fn().mockResolvedValue(undefined);
|
||||
const followUp = vi.fn().mockResolvedValue(undefined);
|
||||
const interaction = {
|
||||
...(createComponentButtonInteraction().interaction as any),
|
||||
update,
|
||||
followUp,
|
||||
} as ButtonInteraction;
|
||||
|
||||
await button.run(interaction, { cid: "btn_1" } as ComponentData);
|
||||
|
||||
expect(resolvePluginConversationBindingApprovalMock).toHaveBeenCalledTimes(1);
|
||||
expect(update).toHaveBeenCalledWith({ components: [] });
|
||||
expect(followUp).toHaveBeenCalledWith({
|
||||
content: "Binding approved.",
|
||||
ephemeral: true,
|
||||
});
|
||||
expect(dispatchReplyMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveDiscordOwnerAllowFrom", () => {
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
Row,
|
||||
StringSelectMenu,
|
||||
TextDisplay,
|
||||
type TopLevelComponents,
|
||||
type AutocompleteInteraction,
|
||||
type ButtonInteraction,
|
||||
type CommandInteraction,
|
||||
@@ -275,12 +274,6 @@ function hasRenderableReplyPayload(payload: ReplyPayload): boolean {
|
||||
if (payload.mediaUrls?.some((entry) => entry.trim())) {
|
||||
return true;
|
||||
}
|
||||
const discordData = payload.channelData?.discord as
|
||||
| { components?: TopLevelComponents[] }
|
||||
| undefined;
|
||||
if (Array.isArray(discordData?.components) && discordData.components.length > 0) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -1779,25 +1772,13 @@ async function deliverDiscordInteractionReply(params: {
|
||||
const { interaction, payload, textLimit, maxLinesPerMessage, preferFollowUp, chunkMode } = params;
|
||||
const mediaList = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
|
||||
const text = payload.text ?? "";
|
||||
const discordData = payload.channelData?.discord as
|
||||
| { components?: TopLevelComponents[] }
|
||||
| undefined;
|
||||
let firstMessageComponents =
|
||||
Array.isArray(discordData?.components) && discordData.components.length > 0
|
||||
? discordData.components
|
||||
: undefined;
|
||||
|
||||
let hasReplied = false;
|
||||
const sendMessage = async (
|
||||
content: string,
|
||||
files?: { name: string; data: Buffer }[],
|
||||
components?: TopLevelComponents[],
|
||||
) => {
|
||||
const sendMessage = async (content: string, files?: { name: string; data: Buffer }[]) => {
|
||||
const payload =
|
||||
files && files.length > 0
|
||||
? {
|
||||
content,
|
||||
...(components ? { components } : {}),
|
||||
files: files.map((file) => {
|
||||
if (file.data instanceof Blob) {
|
||||
return { name: file.name, data: file.data };
|
||||
@@ -1806,20 +1787,15 @@ async function deliverDiscordInteractionReply(params: {
|
||||
return { name: file.name, data: new Blob([arrayBuffer]) };
|
||||
}),
|
||||
}
|
||||
: {
|
||||
content,
|
||||
...(components ? { components } : {}),
|
||||
};
|
||||
: { content };
|
||||
await safeDiscordInteractionCall("interaction send", async () => {
|
||||
if (!preferFollowUp && !hasReplied) {
|
||||
await interaction.reply(payload);
|
||||
hasReplied = true;
|
||||
firstMessageComponents = undefined;
|
||||
return;
|
||||
}
|
||||
await interaction.followUp(payload);
|
||||
hasReplied = true;
|
||||
firstMessageComponents = undefined;
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1844,7 +1820,7 @@ async function deliverDiscordInteractionReply(params: {
|
||||
chunks.push(text);
|
||||
}
|
||||
const caption = chunks[0] ?? "";
|
||||
await sendMessage(caption, media, firstMessageComponents);
|
||||
await sendMessage(caption, media);
|
||||
for (const chunk of chunks.slice(1)) {
|
||||
if (!chunk.trim()) {
|
||||
continue;
|
||||
@@ -1854,7 +1830,7 @@ async function deliverDiscordInteractionReply(params: {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!text.trim() && !firstMessageComponents) {
|
||||
if (!text.trim()) {
|
||||
return;
|
||||
}
|
||||
const chunks = chunkDiscordTextWithMode(text, {
|
||||
@@ -1862,13 +1838,13 @@ async function deliverDiscordInteractionReply(params: {
|
||||
maxLines: maxLinesPerMessage,
|
||||
chunkMode,
|
||||
});
|
||||
if (!chunks.length && (text || firstMessageComponents)) {
|
||||
if (!chunks.length && text) {
|
||||
chunks.push(text);
|
||||
}
|
||||
for (const chunk of chunks) {
|
||||
if (!chunk.trim() && !firstMessageComponents) {
|
||||
if (!chunk.trim()) {
|
||||
continue;
|
||||
}
|
||||
await sendMessage(chunk, undefined, firstMessageComponents);
|
||||
await sendMessage(chunk);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
} from "./thread-bindings.types.js";
|
||||
|
||||
function buildThreadTarget(threadId: string): string {
|
||||
return /^(channel:|user:)/i.test(threadId) ? threadId : `channel:${threadId}`;
|
||||
return `channel:${threadId}`;
|
||||
}
|
||||
|
||||
export function isThreadArchived(raw: unknown): boolean {
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
setRuntimeConfigSnapshot,
|
||||
type OpenClawConfig,
|
||||
} from "../../../../src/config/config.js";
|
||||
import { getSessionBindingService } from "../../../../src/infra/outbound/session-binding-service.js";
|
||||
|
||||
const hoisted = vi.hoisted(() => {
|
||||
const sendMessageDiscord = vi.fn(async (_to: string, _text: string, _opts?: unknown) => ({}));
|
||||
@@ -789,57 +788,6 @@ describe("thread binding lifecycle", () => {
|
||||
expect(usedTokenNew).toBe(true);
|
||||
});
|
||||
|
||||
it("binds current Discord DMs as direct conversation bindings", async () => {
|
||||
createThreadBindingManager({
|
||||
accountId: "default",
|
||||
persist: false,
|
||||
enableSweeper: false,
|
||||
idleTimeoutMs: 24 * 60 * 60 * 1000,
|
||||
maxAgeMs: 0,
|
||||
});
|
||||
|
||||
hoisted.restGet.mockClear();
|
||||
hoisted.restPost.mockClear();
|
||||
|
||||
const bound = await getSessionBindingService().bind({
|
||||
targetSessionKey: "plugin-binding:openclaw-codex-app-server:dm",
|
||||
targetKind: "session",
|
||||
conversation: {
|
||||
channel: "discord",
|
||||
accountId: "default",
|
||||
conversationId: "user:1177378744822943744",
|
||||
},
|
||||
placement: "current",
|
||||
metadata: {
|
||||
pluginBindingOwner: "plugin",
|
||||
pluginId: "openclaw-codex-app-server",
|
||||
pluginRoot: "/Users/huntharo/github/openclaw-app-server",
|
||||
},
|
||||
});
|
||||
|
||||
expect(bound).toMatchObject({
|
||||
conversation: {
|
||||
channel: "discord",
|
||||
accountId: "default",
|
||||
conversationId: "user:1177378744822943744",
|
||||
parentConversationId: "user:1177378744822943744",
|
||||
},
|
||||
});
|
||||
expect(
|
||||
getSessionBindingService().resolveByConversation({
|
||||
channel: "discord",
|
||||
accountId: "default",
|
||||
conversationId: "user:1177378744822943744",
|
||||
}),
|
||||
).toMatchObject({
|
||||
conversation: {
|
||||
conversationId: "user:1177378744822943744",
|
||||
},
|
||||
});
|
||||
expect(hoisted.restGet).not.toHaveBeenCalled();
|
||||
expect(hoisted.restPost).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps overlapping thread ids isolated per account", async () => {
|
||||
const a = createThreadBindingManager({
|
||||
accountId: "a",
|
||||
@@ -1000,47 +948,6 @@ describe("thread binding lifecycle", () => {
|
||||
expect(manager.getByThreadId("thread-acp-uncertain")).toBeDefined();
|
||||
});
|
||||
|
||||
it("does not reconcile plugin-owned direct bindings as stale ACP sessions", async () => {
|
||||
const manager = createThreadBindingManager({
|
||||
accountId: "default",
|
||||
persist: false,
|
||||
enableSweeper: false,
|
||||
idleTimeoutMs: 24 * 60 * 60 * 1000,
|
||||
maxAgeMs: 0,
|
||||
});
|
||||
|
||||
await manager.bindTarget({
|
||||
threadId: "user:1177378744822943744",
|
||||
channelId: "user:1177378744822943744",
|
||||
targetKind: "acp",
|
||||
targetSessionKey: "plugin-binding:openclaw-codex-app-server:dm",
|
||||
agentId: "codex",
|
||||
metadata: {
|
||||
pluginBindingOwner: "plugin",
|
||||
pluginId: "openclaw-codex-app-server",
|
||||
pluginRoot: "/Users/huntharo/github/openclaw-app-server",
|
||||
},
|
||||
});
|
||||
|
||||
hoisted.readAcpSessionEntry.mockReturnValue(null);
|
||||
|
||||
const result = await reconcileAcpThreadBindingsOnStartup({
|
||||
cfg: {} as OpenClawConfig,
|
||||
accountId: "default",
|
||||
});
|
||||
|
||||
expect(result.checked).toBe(0);
|
||||
expect(result.removed).toBe(0);
|
||||
expect(result.staleSessionKeys).toEqual([]);
|
||||
expect(manager.getByThreadId("user:1177378744822943744")).toMatchObject({
|
||||
threadId: "user:1177378744822943744",
|
||||
metadata: {
|
||||
pluginBindingOwner: "plugin",
|
||||
pluginId: "openclaw-codex-app-server",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("removes ACP bindings when health probe marks running session as stale", async () => {
|
||||
const manager = createThreadBindingManager({
|
||||
accountId: "default",
|
||||
|
||||
@@ -323,12 +323,7 @@ export async function reconcileAcpThreadBindingsOnStartup(params: {
|
||||
};
|
||||
}
|
||||
|
||||
const acpBindings = manager
|
||||
.listBindings()
|
||||
.filter(
|
||||
(binding) =>
|
||||
binding.targetKind === "acp" && binding.metadata?.pluginBindingOwner !== "plugin",
|
||||
);
|
||||
const acpBindings = manager.listBindings().filter((binding) => binding.targetKind === "acp");
|
||||
const staleBindings: ThreadBindingRecord[] = [];
|
||||
const probeTargets: Array<{
|
||||
binding: ThreadBindingRecord;
|
||||
|
||||
@@ -117,11 +117,6 @@ function toThreadBindingTargetKind(raw: BindingTargetKind): "subagent" | "acp" {
|
||||
return raw === "subagent" ? "subagent" : "acp";
|
||||
}
|
||||
|
||||
function isDirectConversationBindingId(value?: string | null): boolean {
|
||||
const trimmed = value?.trim();
|
||||
return Boolean(trimmed && /^(user:|channel:)/i.test(trimmed));
|
||||
}
|
||||
|
||||
function toSessionBindingRecord(
|
||||
record: ThreadBindingRecord,
|
||||
defaults: { idleTimeoutMs: number; maxAgeMs: number },
|
||||
@@ -163,7 +158,6 @@ function toSessionBindingRecord(
|
||||
record,
|
||||
defaultMaxAgeMs: defaults.maxAgeMs,
|
||||
}),
|
||||
...record.metadata,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -270,8 +264,6 @@ export function createThreadBindingManager(
|
||||
const cfg = resolveCurrentCfg();
|
||||
let threadId = normalizeThreadId(bindParams.threadId);
|
||||
let channelId = bindParams.channelId?.trim() || "";
|
||||
const directConversationBinding =
|
||||
isDirectConversationBindingId(threadId) || isDirectConversationBindingId(channelId);
|
||||
|
||||
if (!threadId && bindParams.createThread) {
|
||||
if (!channelId) {
|
||||
@@ -295,10 +287,6 @@ export function createThreadBindingManager(
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!channelId && directConversationBinding) {
|
||||
channelId = threadId;
|
||||
}
|
||||
|
||||
if (!channelId) {
|
||||
channelId =
|
||||
(await resolveChannelIdForBinding({
|
||||
@@ -321,12 +309,12 @@ export function createThreadBindingManager(
|
||||
const targetKind = normalizeTargetKind(bindParams.targetKind, targetSessionKey);
|
||||
let webhookId = bindParams.webhookId?.trim() || "";
|
||||
let webhookToken = bindParams.webhookToken?.trim() || "";
|
||||
if (!directConversationBinding && (!webhookId || !webhookToken)) {
|
||||
if (!webhookId || !webhookToken) {
|
||||
const cachedWebhook = findReusableWebhook({ accountId, channelId });
|
||||
webhookId = cachedWebhook.webhookId ?? "";
|
||||
webhookToken = cachedWebhook.webhookToken ?? "";
|
||||
}
|
||||
if (!directConversationBinding && (!webhookId || !webhookToken)) {
|
||||
if (!webhookId || !webhookToken) {
|
||||
const createdWebhook = await createWebhookForChannel({
|
||||
cfg,
|
||||
accountId,
|
||||
@@ -353,10 +341,6 @@ export function createThreadBindingManager(
|
||||
lastActivityAt: now,
|
||||
idleTimeoutMs,
|
||||
maxAgeMs,
|
||||
metadata:
|
||||
bindParams.metadata && typeof bindParams.metadata === "object"
|
||||
? { ...bindParams.metadata }
|
||||
: undefined,
|
||||
};
|
||||
|
||||
setBindingRecord(record);
|
||||
@@ -524,9 +508,6 @@ export function createThreadBindingManager(
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (isDirectConversationBindingId(binding.threadId)) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const channel = await rest.get(Routes.channel(binding.threadId));
|
||||
if (!channel || typeof channel !== "object") {
|
||||
@@ -623,7 +604,6 @@ export function createThreadBindingManager(
|
||||
label,
|
||||
boundBy,
|
||||
introText,
|
||||
metadata,
|
||||
});
|
||||
return bound
|
||||
? toSessionBindingRecord(bound, {
|
||||
|
||||
@@ -183,8 +183,6 @@ function normalizePersistedBinding(threadIdKey: string, raw: unknown): ThreadBin
|
||||
typeof value.maxAgeMs === "number" && Number.isFinite(value.maxAgeMs)
|
||||
? Math.max(0, Math.floor(value.maxAgeMs))
|
||||
: undefined;
|
||||
const metadata =
|
||||
value.metadata && typeof value.metadata === "object" ? { ...value.metadata } : undefined;
|
||||
const legacyExpiresAt =
|
||||
typeof (value as { expiresAt?: unknown }).expiresAt === "number" &&
|
||||
Number.isFinite((value as { expiresAt?: unknown }).expiresAt)
|
||||
@@ -224,7 +222,6 @@ function normalizePersistedBinding(threadIdKey: string, raw: unknown): ThreadBin
|
||||
lastActivityAt,
|
||||
idleTimeoutMs: migratedIdleTimeoutMs,
|
||||
maxAgeMs: migratedMaxAgeMs,
|
||||
metadata,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,6 @@ export type ThreadBindingRecord = {
|
||||
idleTimeoutMs?: number;
|
||||
/** Hard max-age window in milliseconds from bind time (0 disables hard cap). */
|
||||
maxAgeMs?: number;
|
||||
metadata?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type PersistedThreadBindingRecord = ThreadBindingRecord & {
|
||||
@@ -57,7 +56,6 @@ export type ThreadBindingManager = {
|
||||
introText?: string;
|
||||
webhookId?: string;
|
||||
webhookToken?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}) => Promise<ThreadBindingRecord | null>;
|
||||
unbindThread: (params: {
|
||||
threadId: string;
|
||||
|
||||
@@ -5,9 +5,9 @@ import type {
|
||||
import { configureChannelAccessWithAllowlist } from "../../../src/channels/plugins/onboarding/channel-access-configure.js";
|
||||
import {
|
||||
applySingleTokenPromptResult,
|
||||
parseMentionOrPrefixedId,
|
||||
noteChannelLookupFailure,
|
||||
noteChannelLookupSummary,
|
||||
parseMentionOrPrefixedId,
|
||||
patchChannelConfigForAccount,
|
||||
promptLegacyChannelAllowFrom,
|
||||
resolveAccountIdForConfigure,
|
||||
|
||||
@@ -45,7 +45,6 @@ export {
|
||||
sendVoiceMessageDiscord,
|
||||
} from "./send.outbound.js";
|
||||
export { sendDiscordComponentMessage } from "./send.components.js";
|
||||
export { sendTypingDiscord } from "./send.typing.js";
|
||||
export {
|
||||
fetchChannelPermissionsDiscord,
|
||||
hasAllGuildPermissionsDiscord,
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
import { Routes } from "discord-api-types/v10";
|
||||
import { resolveDiscordRest } from "./client.js";
|
||||
import type { DiscordReactOpts } from "./send.types.js";
|
||||
|
||||
export async function sendTypingDiscord(channelId: string, opts: DiscordReactOpts = {}) {
|
||||
const rest = resolveDiscordRest(opts);
|
||||
await rest.post(Routes.channelTyping(channelId));
|
||||
return { ok: true, channelId };
|
||||
}
|
||||
@@ -1,9 +1,13 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../../../src/config/config.js";
|
||||
import * as directoryLive from "./directory-live.js";
|
||||
import { listDiscordDirectoryPeersLive } from "./directory-live.js";
|
||||
import { normalizeDiscordMessagingTarget } from "./normalize.js";
|
||||
import { parseDiscordTarget, resolveDiscordChannelId, resolveDiscordTarget } from "./targets.js";
|
||||
|
||||
vi.mock("./directory-live.js", () => ({
|
||||
listDiscordDirectoryPeersLive: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("parseDiscordTarget", () => {
|
||||
it("parses user mention and prefixes", () => {
|
||||
const cases = [
|
||||
@@ -69,15 +73,14 @@ describe("resolveDiscordChannelId", () => {
|
||||
|
||||
describe("resolveDiscordTarget", () => {
|
||||
const cfg = { channels: { discord: {} } } as OpenClawConfig;
|
||||
const listPeers = vi.mocked(listDiscordDirectoryPeersLive);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
listPeers.mockClear();
|
||||
});
|
||||
|
||||
it("returns a resolved user for usernames", async () => {
|
||||
vi.spyOn(directoryLive, "listDiscordDirectoryPeersLive").mockResolvedValueOnce([
|
||||
{ kind: "user", id: "user:999", name: "Jane" } as const,
|
||||
]);
|
||||
listPeers.mockResolvedValueOnce([{ kind: "user", id: "user:999", name: "Jane" } as const]);
|
||||
|
||||
await expect(
|
||||
resolveDiscordTarget("jane", { cfg, accountId: "default" }),
|
||||
@@ -85,14 +88,14 @@ describe("resolveDiscordTarget", () => {
|
||||
});
|
||||
|
||||
it("falls back to parsing when lookup misses", async () => {
|
||||
vi.spyOn(directoryLive, "listDiscordDirectoryPeersLive").mockResolvedValueOnce([]);
|
||||
listPeers.mockResolvedValueOnce([]);
|
||||
await expect(
|
||||
resolveDiscordTarget("general", { cfg, accountId: "default" }),
|
||||
).resolves.toMatchObject({ kind: "channel", id: "general" });
|
||||
});
|
||||
|
||||
it("does not call directory lookup for explicit user ids", async () => {
|
||||
const listPeers = vi.spyOn(directoryLive, "listDiscordDirectoryPeersLive");
|
||||
listPeers.mockResolvedValueOnce([]);
|
||||
await expect(
|
||||
resolveDiscordTarget("user:123", { cfg, accountId: "default" }),
|
||||
).resolves.toMatchObject({ kind: "user", id: "123" });
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/feishu";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
const registerFeishuDocToolsMock = vi.hoisted(() => vi.fn());
|
||||
const registerFeishuChatToolsMock = vi.hoisted(() => vi.fn());
|
||||
const registerFeishuWikiToolsMock = vi.hoisted(() => vi.fn());
|
||||
const registerFeishuDriveToolsMock = vi.hoisted(() => vi.fn());
|
||||
const registerFeishuPermToolsMock = vi.hoisted(() => vi.fn());
|
||||
const registerFeishuBitableToolsMock = vi.hoisted(() => vi.fn());
|
||||
const setFeishuRuntimeMock = vi.hoisted(() => vi.fn());
|
||||
const registerFeishuSubagentHooksMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("./src/docx.js", () => ({
|
||||
registerFeishuDocTools: registerFeishuDocToolsMock,
|
||||
}));
|
||||
|
||||
vi.mock("./src/chat.js", () => ({
|
||||
registerFeishuChatTools: registerFeishuChatToolsMock,
|
||||
}));
|
||||
|
||||
vi.mock("./src/wiki.js", () => ({
|
||||
registerFeishuWikiTools: registerFeishuWikiToolsMock,
|
||||
}));
|
||||
|
||||
vi.mock("./src/drive.js", () => ({
|
||||
registerFeishuDriveTools: registerFeishuDriveToolsMock,
|
||||
}));
|
||||
|
||||
vi.mock("./src/perm.js", () => ({
|
||||
registerFeishuPermTools: registerFeishuPermToolsMock,
|
||||
}));
|
||||
|
||||
vi.mock("./src/bitable.js", () => ({
|
||||
registerFeishuBitableTools: registerFeishuBitableToolsMock,
|
||||
}));
|
||||
|
||||
vi.mock("./src/runtime.js", () => ({
|
||||
setFeishuRuntime: setFeishuRuntimeMock,
|
||||
}));
|
||||
|
||||
vi.mock("./src/subagent-hooks.js", () => ({
|
||||
registerFeishuSubagentHooks: registerFeishuSubagentHooksMock,
|
||||
}));
|
||||
|
||||
describe("feishu plugin register", () => {
|
||||
it("registers the Feishu channel, tools, and subagent hooks", async () => {
|
||||
const { default: plugin } = await import("./index.js");
|
||||
const registerChannel = vi.fn();
|
||||
const api = {
|
||||
runtime: { log: vi.fn() },
|
||||
registerChannel,
|
||||
on: vi.fn(),
|
||||
config: {},
|
||||
} as unknown as OpenClawPluginApi;
|
||||
|
||||
plugin.register(api);
|
||||
|
||||
expect(setFeishuRuntimeMock).toHaveBeenCalledWith(api.runtime);
|
||||
expect(registerChannel).toHaveBeenCalledTimes(1);
|
||||
expect(registerFeishuSubagentHooksMock).toHaveBeenCalledWith(api);
|
||||
expect(registerFeishuDocToolsMock).toHaveBeenCalledWith(api);
|
||||
expect(registerFeishuChatToolsMock).toHaveBeenCalledWith(api);
|
||||
expect(registerFeishuWikiToolsMock).toHaveBeenCalledWith(api);
|
||||
expect(registerFeishuDriveToolsMock).toHaveBeenCalledWith(api);
|
||||
expect(registerFeishuPermToolsMock).toHaveBeenCalledWith(api);
|
||||
expect(registerFeishuBitableToolsMock).toHaveBeenCalledWith(api);
|
||||
});
|
||||
});
|
||||
@@ -7,7 +7,6 @@ import { registerFeishuDocTools } from "./src/docx.js";
|
||||
import { registerFeishuDriveTools } from "./src/drive.js";
|
||||
import { registerFeishuPermTools } from "./src/perm.js";
|
||||
import { setFeishuRuntime } from "./src/runtime.js";
|
||||
import { registerFeishuSubagentHooks } from "./src/subagent-hooks.js";
|
||||
import { registerFeishuWikiTools } from "./src/wiki.js";
|
||||
|
||||
export { monitorFeishuProvider } from "./src/monitor.js";
|
||||
@@ -54,7 +53,6 @@ const plugin = {
|
||||
register(api: OpenClawPluginApi) {
|
||||
setFeishuRuntime(api.runtime);
|
||||
api.registerChannel({ plugin: feishuPlugin });
|
||||
registerFeishuSubagentHooks(api);
|
||||
registerFeishuDocTools(api);
|
||||
registerFeishuChatTools(api);
|
||||
registerFeishuWikiTools(api);
|
||||
|
||||
@@ -21,10 +21,6 @@ const {
|
||||
mockResolveAgentRoute,
|
||||
mockReadSessionUpdatedAt,
|
||||
mockResolveStorePath,
|
||||
mockResolveConfiguredAcpRoute,
|
||||
mockEnsureConfiguredAcpRouteReady,
|
||||
mockResolveBoundConversation,
|
||||
mockTouchBinding,
|
||||
} = vi.hoisted(() => ({
|
||||
mockCreateFeishuReplyDispatcher: vi.fn(() => ({
|
||||
dispatcher: vi.fn(),
|
||||
@@ -50,13 +46,6 @@ const {
|
||||
})),
|
||||
mockReadSessionUpdatedAt: vi.fn(),
|
||||
mockResolveStorePath: vi.fn(() => "/tmp/feishu-sessions.json"),
|
||||
mockResolveConfiguredAcpRoute: vi.fn(({ route }) => ({
|
||||
configuredBinding: null,
|
||||
route,
|
||||
})),
|
||||
mockEnsureConfiguredAcpRouteReady: vi.fn(async (_params?: unknown) => ({ ok: true })),
|
||||
mockResolveBoundConversation: vi.fn(() => null),
|
||||
mockTouchBinding: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./reply-dispatcher.js", () => ({
|
||||
@@ -77,18 +66,6 @@ vi.mock("./client.js", () => ({
|
||||
createFeishuClient: mockCreateFeishuClient,
|
||||
}));
|
||||
|
||||
vi.mock("../../../src/acp/persistent-bindings.route.js", () => ({
|
||||
resolveConfiguredAcpRoute: (params: unknown) => mockResolveConfiguredAcpRoute(params),
|
||||
ensureConfiguredAcpRouteReady: (params: unknown) => mockEnsureConfiguredAcpRouteReady(params),
|
||||
}));
|
||||
|
||||
vi.mock("../../../src/infra/outbound/session-binding-service.js", () => ({
|
||||
getSessionBindingService: () => ({
|
||||
resolveByConversation: mockResolveBoundConversation,
|
||||
touch: mockTouchBinding,
|
||||
}),
|
||||
}));
|
||||
|
||||
function createRuntimeEnv(): RuntimeEnv {
|
||||
return {
|
||||
log: vi.fn(),
|
||||
@@ -133,261 +110,6 @@ describe("buildFeishuAgentBody", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleFeishuMessage ACP routing", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockResolveConfiguredAcpRoute.mockReset().mockImplementation(
|
||||
({ route }) =>
|
||||
({
|
||||
configuredBinding: null,
|
||||
route,
|
||||
}) as any,
|
||||
);
|
||||
mockEnsureConfiguredAcpRouteReady.mockReset().mockResolvedValue({ ok: true });
|
||||
mockResolveBoundConversation.mockReset().mockReturnValue(null);
|
||||
mockTouchBinding.mockReset();
|
||||
mockResolveAgentRoute.mockReset().mockReturnValue({
|
||||
agentId: "main",
|
||||
channel: "feishu",
|
||||
accountId: "default",
|
||||
sessionKey: "agent:main:feishu:direct:ou_sender_1",
|
||||
mainSessionKey: "agent:main:main",
|
||||
matchedBy: "default",
|
||||
});
|
||||
mockSendMessageFeishu
|
||||
.mockReset()
|
||||
.mockResolvedValue({ messageId: "reply-msg", chatId: "oc_dm" });
|
||||
mockCreateFeishuReplyDispatcher.mockReset().mockReturnValue({
|
||||
dispatcher: {
|
||||
sendToolResult: vi.fn(),
|
||||
sendBlockReply: vi.fn(),
|
||||
sendFinalReply: vi.fn(),
|
||||
waitForIdle: vi.fn(),
|
||||
getQueuedCounts: vi.fn(() => ({ tool: 0, block: 0, final: 0 })),
|
||||
markComplete: vi.fn(),
|
||||
} as any,
|
||||
replyOptions: {},
|
||||
markDispatchIdle: vi.fn(),
|
||||
});
|
||||
|
||||
setFeishuRuntime(
|
||||
createPluginRuntimeMock({
|
||||
channel: {
|
||||
routing: {
|
||||
resolveAgentRoute:
|
||||
mockResolveAgentRoute as unknown as PluginRuntime["channel"]["routing"]["resolveAgentRoute"],
|
||||
},
|
||||
session: {
|
||||
readSessionUpdatedAt:
|
||||
mockReadSessionUpdatedAt as unknown as PluginRuntime["channel"]["session"]["readSessionUpdatedAt"],
|
||||
resolveStorePath:
|
||||
mockResolveStorePath as unknown as PluginRuntime["channel"]["session"]["resolveStorePath"],
|
||||
},
|
||||
reply: {
|
||||
resolveEnvelopeFormatOptions: vi.fn(
|
||||
() => ({}),
|
||||
) as unknown as PluginRuntime["channel"]["reply"]["resolveEnvelopeFormatOptions"],
|
||||
formatAgentEnvelope: vi.fn((params: { body: string }) => params.body),
|
||||
finalizeInboundContext: ((ctx: unknown) =>
|
||||
ctx) as unknown as PluginRuntime["channel"]["reply"]["finalizeInboundContext"],
|
||||
dispatchReplyFromConfig: vi.fn().mockResolvedValue({
|
||||
queuedFinal: false,
|
||||
counts: { final: 1 },
|
||||
}),
|
||||
withReplyDispatcher: vi.fn(
|
||||
async ({
|
||||
run,
|
||||
}: Parameters<PluginRuntime["channel"]["reply"]["withReplyDispatcher"]>[0]) =>
|
||||
await run(),
|
||||
) as unknown as PluginRuntime["channel"]["reply"]["withReplyDispatcher"],
|
||||
},
|
||||
commands: {
|
||||
shouldComputeCommandAuthorized: vi.fn(() => false),
|
||||
resolveCommandAuthorizedFromAuthorizers: vi.fn(() => false),
|
||||
},
|
||||
pairing: {
|
||||
readAllowFromStore: vi.fn().mockResolvedValue(["ou_sender_1"]),
|
||||
upsertPairingRequest: vi.fn(),
|
||||
buildPairingReply: vi.fn(),
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("ensures configured ACP routes for Feishu DMs", async () => {
|
||||
mockResolveConfiguredAcpRoute.mockReturnValue({
|
||||
configuredBinding: {
|
||||
spec: {
|
||||
channel: "feishu",
|
||||
accountId: "default",
|
||||
conversationId: "ou_sender_1",
|
||||
agentId: "codex",
|
||||
mode: "persistent",
|
||||
},
|
||||
record: {
|
||||
bindingId: "config:acp:feishu:default:ou_sender_1",
|
||||
targetSessionKey: "agent:codex:acp:binding:feishu:default:abc123",
|
||||
targetKind: "session",
|
||||
conversation: {
|
||||
channel: "feishu",
|
||||
accountId: "default",
|
||||
conversationId: "ou_sender_1",
|
||||
},
|
||||
status: "active",
|
||||
boundAt: 0,
|
||||
metadata: { source: "config" },
|
||||
},
|
||||
},
|
||||
route: {
|
||||
agentId: "codex",
|
||||
channel: "feishu",
|
||||
accountId: "default",
|
||||
sessionKey: "agent:codex:acp:binding:feishu:default:abc123",
|
||||
mainSessionKey: "agent:codex:main",
|
||||
matchedBy: "binding.channel",
|
||||
},
|
||||
} as any);
|
||||
|
||||
await dispatchMessage({
|
||||
cfg: {
|
||||
session: { mainKey: "main", scope: "per-sender" },
|
||||
channels: { feishu: { enabled: true, allowFrom: ["ou_sender_1"], dmPolicy: "open" } },
|
||||
},
|
||||
event: {
|
||||
sender: { sender_id: { open_id: "ou_sender_1" } },
|
||||
message: {
|
||||
message_id: "msg-1",
|
||||
chat_id: "oc_dm",
|
||||
chat_type: "p2p",
|
||||
message_type: "text",
|
||||
content: JSON.stringify({ text: "hello" }),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(mockResolveConfiguredAcpRoute).toHaveBeenCalledTimes(1);
|
||||
expect(mockEnsureConfiguredAcpRouteReady).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("surfaces configured ACP initialization failures to the Feishu conversation", async () => {
|
||||
mockResolveConfiguredAcpRoute.mockReturnValue({
|
||||
configuredBinding: {
|
||||
spec: {
|
||||
channel: "feishu",
|
||||
accountId: "default",
|
||||
conversationId: "ou_sender_1",
|
||||
agentId: "codex",
|
||||
mode: "persistent",
|
||||
},
|
||||
record: {
|
||||
bindingId: "config:acp:feishu:default:ou_sender_1",
|
||||
targetSessionKey: "agent:codex:acp:binding:feishu:default:abc123",
|
||||
targetKind: "session",
|
||||
conversation: {
|
||||
channel: "feishu",
|
||||
accountId: "default",
|
||||
conversationId: "ou_sender_1",
|
||||
},
|
||||
status: "active",
|
||||
boundAt: 0,
|
||||
metadata: { source: "config" },
|
||||
},
|
||||
},
|
||||
route: {
|
||||
agentId: "codex",
|
||||
channel: "feishu",
|
||||
accountId: "default",
|
||||
sessionKey: "agent:codex:acp:binding:feishu:default:abc123",
|
||||
mainSessionKey: "agent:codex:main",
|
||||
matchedBy: "binding.channel",
|
||||
},
|
||||
} as any);
|
||||
mockEnsureConfiguredAcpRouteReady.mockResolvedValue({
|
||||
ok: false,
|
||||
error: "runtime unavailable",
|
||||
} as any);
|
||||
|
||||
await dispatchMessage({
|
||||
cfg: {
|
||||
session: { mainKey: "main", scope: "per-sender" },
|
||||
channels: { feishu: { enabled: true, allowFrom: ["ou_sender_1"], dmPolicy: "open" } },
|
||||
},
|
||||
event: {
|
||||
sender: { sender_id: { open_id: "ou_sender_1" } },
|
||||
message: {
|
||||
message_id: "msg-2",
|
||||
chat_id: "oc_dm",
|
||||
chat_type: "p2p",
|
||||
message_type: "text",
|
||||
content: JSON.stringify({ text: "hello" }),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(mockSendMessageFeishu).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
to: "chat:oc_dm",
|
||||
text: expect.stringContaining("runtime unavailable"),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("routes Feishu topic messages through active bound conversations", async () => {
|
||||
mockResolveBoundConversation.mockReturnValue({
|
||||
bindingId: "default:oc_group_chat:topic:om_topic_root",
|
||||
targetSessionKey: "agent:codex:acp:binding:feishu:default:feedface",
|
||||
targetKind: "session",
|
||||
conversation: {
|
||||
channel: "feishu",
|
||||
accountId: "default",
|
||||
conversationId: "oc_group_chat:topic:om_topic_root",
|
||||
parentConversationId: "oc_group_chat",
|
||||
},
|
||||
status: "active",
|
||||
boundAt: 0,
|
||||
} as any);
|
||||
|
||||
await dispatchMessage({
|
||||
cfg: {
|
||||
session: { mainKey: "main", scope: "per-sender" },
|
||||
channels: {
|
||||
feishu: {
|
||||
enabled: true,
|
||||
allowFrom: ["ou_sender_1"],
|
||||
groups: {
|
||||
oc_group_chat: {
|
||||
allow: true,
|
||||
requireMention: false,
|
||||
groupSessionScope: "group_topic",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
event: {
|
||||
sender: { sender_id: { open_id: "ou_sender_1" } },
|
||||
message: {
|
||||
message_id: "msg-3",
|
||||
chat_id: "oc_group_chat",
|
||||
chat_type: "group",
|
||||
message_type: "text",
|
||||
root_id: "om_topic_root",
|
||||
content: JSON.stringify({ text: "hello topic" }),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(mockResolveBoundConversation).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
channel: "feishu",
|
||||
conversationId: "oc_group_chat:topic:om_topic_root",
|
||||
}),
|
||||
);
|
||||
expect(mockTouchBinding).toHaveBeenCalledWith("default:oc_group_chat:topic:om_topic_root");
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleFeishuMessage command authorization", () => {
|
||||
const mockFinalizeInboundContext = vi.fn((ctx: unknown) => ctx);
|
||||
const mockDispatchReplyFromConfig = vi
|
||||
@@ -431,16 +153,6 @@ describe("handleFeishuMessage command authorization", () => {
|
||||
mockListFeishuThreadMessages.mockReset().mockResolvedValue([]);
|
||||
mockReadSessionUpdatedAt.mockReturnValue(undefined);
|
||||
mockResolveStorePath.mockReturnValue("/tmp/feishu-sessions.json");
|
||||
mockResolveConfiguredAcpRoute.mockReset().mockImplementation(
|
||||
({ route }) =>
|
||||
({
|
||||
configuredBinding: null,
|
||||
route,
|
||||
}) as any,
|
||||
);
|
||||
mockEnsureConfiguredAcpRouteReady.mockReset().mockResolvedValue({ ok: true });
|
||||
mockResolveBoundConversation.mockReset().mockReturnValue(null);
|
||||
mockTouchBinding.mockReset();
|
||||
mockResolveAgentRoute.mockReturnValue({
|
||||
agentId: "main",
|
||||
channel: "feishu",
|
||||
|
||||
@@ -14,16 +14,8 @@ import {
|
||||
resolveDefaultGroupPolicy,
|
||||
warnMissingProviderGroupPolicyFallbackOnce,
|
||||
} from "openclaw/plugin-sdk/feishu";
|
||||
import {
|
||||
ensureConfiguredAcpRouteReady,
|
||||
resolveConfiguredAcpRoute,
|
||||
} from "../../../src/acp/persistent-bindings.route.js";
|
||||
import { getSessionBindingService } from "../../../src/infra/outbound/session-binding-service.js";
|
||||
import { deriveLastRoutePolicy } from "../../../src/routing/resolve-route.js";
|
||||
import { resolveAgentIdFromSessionKey } from "../../../src/routing/session-key.js";
|
||||
import { resolveFeishuAccount } from "./accounts.js";
|
||||
import { createFeishuClient } from "./client.js";
|
||||
import { buildFeishuConversationId } from "./conversation-id.js";
|
||||
import { finalizeFeishuMessageProcessing, tryRecordMessagePersistent } from "./dedup.js";
|
||||
import { maybeCreateDynamicAgent } from "./dynamic-agent.js";
|
||||
import { normalizeFeishuExternalKey } from "./external-keys.js";
|
||||
@@ -281,34 +273,15 @@ function resolveFeishuGroupSession(params: {
|
||||
let peerId = chatId;
|
||||
switch (groupSessionScope) {
|
||||
case "group_sender":
|
||||
peerId = buildFeishuConversationId({
|
||||
chatId,
|
||||
scope: "group_sender",
|
||||
senderOpenId,
|
||||
});
|
||||
peerId = `${chatId}:sender:${senderOpenId}`;
|
||||
break;
|
||||
case "group_topic":
|
||||
peerId = topicScope
|
||||
? buildFeishuConversationId({
|
||||
chatId,
|
||||
scope: "group_topic",
|
||||
topicId: topicScope,
|
||||
})
|
||||
: chatId;
|
||||
peerId = topicScope ? `${chatId}:topic:${topicScope}` : chatId;
|
||||
break;
|
||||
case "group_topic_sender":
|
||||
peerId = topicScope
|
||||
? buildFeishuConversationId({
|
||||
chatId,
|
||||
scope: "group_topic_sender",
|
||||
topicId: topicScope,
|
||||
senderOpenId,
|
||||
})
|
||||
: buildFeishuConversationId({
|
||||
chatId,
|
||||
scope: "group_sender",
|
||||
senderOpenId,
|
||||
});
|
||||
? `${chatId}:topic:${topicScope}:sender:${senderOpenId}`
|
||||
: `${chatId}:sender:${senderOpenId}`;
|
||||
break;
|
||||
case "group":
|
||||
default:
|
||||
@@ -1195,10 +1168,6 @@ export async function handleFeishuMessage(params: {
|
||||
const peerId = isGroup ? (groupSession?.peerId ?? ctx.chatId) : ctx.senderOpenId;
|
||||
const parentPeer = isGroup ? (groupSession?.parentPeer ?? null) : null;
|
||||
const replyInThread = isGroup ? (groupSession?.replyInThread ?? false) : false;
|
||||
const feishuAcpConversationSupported =
|
||||
!isGroup ||
|
||||
groupSession?.groupSessionScope === "group_topic" ||
|
||||
groupSession?.groupSessionScope === "group_topic_sender";
|
||||
|
||||
if (isGroup && groupSession) {
|
||||
log(
|
||||
@@ -1247,76 +1216,6 @@ export async function handleFeishuMessage(params: {
|
||||
}
|
||||
}
|
||||
|
||||
const currentConversationId = peerId;
|
||||
const parentConversationId = isGroup ? (parentPeer?.id ?? ctx.chatId) : undefined;
|
||||
let configuredBinding = null;
|
||||
if (feishuAcpConversationSupported) {
|
||||
const configuredRoute = resolveConfiguredAcpRoute({
|
||||
cfg: effectiveCfg,
|
||||
route,
|
||||
channel: "feishu",
|
||||
accountId: account.accountId,
|
||||
conversationId: currentConversationId,
|
||||
parentConversationId,
|
||||
});
|
||||
configuredBinding = configuredRoute.configuredBinding;
|
||||
route = configuredRoute.route;
|
||||
|
||||
// Bound Feishu conversations intentionally require an exact live conversation-id match.
|
||||
// Sender-scoped topic sessions therefore bind on `chat:topic:root:sender:user`, while
|
||||
// configured ACP bindings may still inherit the shared `chat:topic:root` topic session.
|
||||
const threadBinding = getSessionBindingService().resolveByConversation({
|
||||
channel: "feishu",
|
||||
accountId: account.accountId,
|
||||
conversationId: currentConversationId,
|
||||
...(parentConversationId ? { parentConversationId } : {}),
|
||||
});
|
||||
const boundSessionKey = threadBinding?.targetSessionKey?.trim();
|
||||
if (threadBinding && boundSessionKey) {
|
||||
route = {
|
||||
...route,
|
||||
sessionKey: boundSessionKey,
|
||||
agentId: resolveAgentIdFromSessionKey(boundSessionKey) || route.agentId,
|
||||
lastRoutePolicy: deriveLastRoutePolicy({
|
||||
sessionKey: boundSessionKey,
|
||||
mainSessionKey: route.mainSessionKey,
|
||||
}),
|
||||
matchedBy: "binding.channel",
|
||||
};
|
||||
configuredBinding = null;
|
||||
getSessionBindingService().touch(threadBinding.bindingId);
|
||||
log(
|
||||
`feishu[${account.accountId}]: routed via bound conversation ${currentConversationId} -> ${boundSessionKey}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (configuredBinding) {
|
||||
const ensured = await ensureConfiguredAcpRouteReady({
|
||||
cfg: effectiveCfg,
|
||||
configuredBinding,
|
||||
});
|
||||
if (!ensured.ok) {
|
||||
const replyTargetMessageId =
|
||||
isGroup &&
|
||||
(groupSession?.groupSessionScope === "group_topic" ||
|
||||
groupSession?.groupSessionScope === "group_topic_sender")
|
||||
? (ctx.rootId ?? ctx.messageId)
|
||||
: ctx.messageId;
|
||||
await sendMessageFeishu({
|
||||
cfg: effectiveCfg,
|
||||
to: `chat:${ctx.chatId}`,
|
||||
text: `⚠️ Failed to initialize the configured ACP session for this Feishu conversation: ${ensured.error}`,
|
||||
replyToMessageId: replyTargetMessageId,
|
||||
replyInThread: isGroup ? (groupSession?.replyInThread ?? false) : false,
|
||||
accountId: account.accountId,
|
||||
}).catch((err) => {
|
||||
log(`feishu[${account.accountId}]: failed to send ACP init error reply: ${String(err)}`);
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const preview = ctx.content.replace(/\s+/g, " ").slice(0, 160);
|
||||
const inboundLabel = isGroup
|
||||
? `Feishu[${account.accountId}] message in group ${ctx.chatId}`
|
||||
|
||||
@@ -1,125 +0,0 @@
|
||||
export type FeishuGroupSessionScope =
|
||||
| "group"
|
||||
| "group_sender"
|
||||
| "group_topic"
|
||||
| "group_topic_sender";
|
||||
|
||||
function normalizeText(value: unknown): string | undefined {
|
||||
if (typeof value !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
return trimmed || undefined;
|
||||
}
|
||||
|
||||
export function buildFeishuConversationId(params: {
|
||||
chatId: string;
|
||||
scope: FeishuGroupSessionScope;
|
||||
senderOpenId?: string;
|
||||
topicId?: string;
|
||||
}): string {
|
||||
const chatId = normalizeText(params.chatId) ?? "unknown";
|
||||
const senderOpenId = normalizeText(params.senderOpenId);
|
||||
const topicId = normalizeText(params.topicId);
|
||||
|
||||
switch (params.scope) {
|
||||
case "group_sender":
|
||||
return senderOpenId ? `${chatId}:sender:${senderOpenId}` : chatId;
|
||||
case "group_topic":
|
||||
return topicId ? `${chatId}:topic:${topicId}` : chatId;
|
||||
case "group_topic_sender":
|
||||
if (topicId && senderOpenId) {
|
||||
return `${chatId}:topic:${topicId}:sender:${senderOpenId}`;
|
||||
}
|
||||
if (topicId) {
|
||||
return `${chatId}:topic:${topicId}`;
|
||||
}
|
||||
return senderOpenId ? `${chatId}:sender:${senderOpenId}` : chatId;
|
||||
case "group":
|
||||
default:
|
||||
return chatId;
|
||||
}
|
||||
}
|
||||
|
||||
export function parseFeishuConversationId(params: {
|
||||
conversationId: string;
|
||||
parentConversationId?: string;
|
||||
}): {
|
||||
canonicalConversationId: string;
|
||||
chatId: string;
|
||||
topicId?: string;
|
||||
senderOpenId?: string;
|
||||
scope: FeishuGroupSessionScope;
|
||||
} | null {
|
||||
const conversationId = normalizeText(params.conversationId);
|
||||
const parentConversationId = normalizeText(params.parentConversationId);
|
||||
if (!conversationId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const topicSenderMatch = conversationId.match(/^(.+):topic:([^:]+):sender:([^:]+)$/);
|
||||
if (topicSenderMatch) {
|
||||
const [, chatId, topicId, senderOpenId] = topicSenderMatch;
|
||||
return {
|
||||
canonicalConversationId: buildFeishuConversationId({
|
||||
chatId,
|
||||
scope: "group_topic_sender",
|
||||
topicId,
|
||||
senderOpenId,
|
||||
}),
|
||||
chatId,
|
||||
topicId,
|
||||
senderOpenId,
|
||||
scope: "group_topic_sender",
|
||||
};
|
||||
}
|
||||
|
||||
const topicMatch = conversationId.match(/^(.+):topic:([^:]+)$/);
|
||||
if (topicMatch) {
|
||||
const [, chatId, topicId] = topicMatch;
|
||||
return {
|
||||
canonicalConversationId: buildFeishuConversationId({
|
||||
chatId,
|
||||
scope: "group_topic",
|
||||
topicId,
|
||||
}),
|
||||
chatId,
|
||||
topicId,
|
||||
scope: "group_topic",
|
||||
};
|
||||
}
|
||||
|
||||
const senderMatch = conversationId.match(/^(.+):sender:([^:]+)$/);
|
||||
if (senderMatch) {
|
||||
const [, chatId, senderOpenId] = senderMatch;
|
||||
return {
|
||||
canonicalConversationId: buildFeishuConversationId({
|
||||
chatId,
|
||||
scope: "group_sender",
|
||||
senderOpenId,
|
||||
}),
|
||||
chatId,
|
||||
senderOpenId,
|
||||
scope: "group_sender",
|
||||
};
|
||||
}
|
||||
|
||||
if (parentConversationId) {
|
||||
return {
|
||||
canonicalConversationId: buildFeishuConversationId({
|
||||
chatId: parentConversationId,
|
||||
scope: "group_topic",
|
||||
topicId: conversationId,
|
||||
}),
|
||||
chatId: parentConversationId,
|
||||
topicId: conversationId,
|
||||
scope: "group_topic",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
canonicalConversationId: conversationId,
|
||||
chatId: conversationId,
|
||||
scope: "group",
|
||||
};
|
||||
}
|
||||
@@ -24,7 +24,6 @@ import { botNames, botOpenIds } from "./monitor.state.js";
|
||||
import { monitorWebhook, monitorWebSocket } from "./monitor.transport.js";
|
||||
import { getFeishuRuntime } from "./runtime.js";
|
||||
import { getMessageFeishu } from "./send.js";
|
||||
import { createFeishuThreadBindingManager } from "./thread-bindings.js";
|
||||
import type { FeishuChatType, ResolvedFeishuAccount } from "./types.js";
|
||||
|
||||
const FEISHU_REACTION_VERIFY_TIMEOUT_MS = 1_500;
|
||||
@@ -632,25 +631,19 @@ export async function monitorSingleAccount(params: MonitorSingleAccountParams):
|
||||
log(`feishu[${accountId}]: dedup warmup loaded ${warmupCount} entries from disk`);
|
||||
}
|
||||
|
||||
let threadBindingManager: ReturnType<typeof createFeishuThreadBindingManager> | null = null;
|
||||
try {
|
||||
const eventDispatcher = createEventDispatcher(account);
|
||||
const chatHistories = new Map<string, HistoryEntry[]>();
|
||||
threadBindingManager = createFeishuThreadBindingManager({ accountId, cfg });
|
||||
const eventDispatcher = createEventDispatcher(account);
|
||||
const chatHistories = new Map<string, HistoryEntry[]>();
|
||||
|
||||
registerEventHandlers(eventDispatcher, {
|
||||
cfg,
|
||||
accountId,
|
||||
runtime,
|
||||
chatHistories,
|
||||
fireAndForget: true,
|
||||
});
|
||||
registerEventHandlers(eventDispatcher, {
|
||||
cfg,
|
||||
accountId,
|
||||
runtime,
|
||||
chatHistories,
|
||||
fireAndForget: true,
|
||||
});
|
||||
|
||||
if (connectionMode === "webhook") {
|
||||
return await monitorWebhook({ account, accountId, runtime, abortSignal, eventDispatcher });
|
||||
}
|
||||
return await monitorWebSocket({ account, accountId, runtime, abortSignal, eventDispatcher });
|
||||
} finally {
|
||||
threadBindingManager?.stop();
|
||||
if (connectionMode === "webhook") {
|
||||
return monitorWebhook({ account, accountId, runtime, abortSignal, eventDispatcher });
|
||||
}
|
||||
return monitorWebSocket({ account, accountId, runtime, abortSignal, eventDispatcher });
|
||||
}
|
||||
|
||||
@@ -17,7 +17,6 @@ const handleFeishuMessageMock = vi.hoisted(() => vi.fn(async (_params: { event?:
|
||||
const createEventDispatcherMock = vi.hoisted(() => vi.fn());
|
||||
const monitorWebSocketMock = vi.hoisted(() => vi.fn(async () => {}));
|
||||
const monitorWebhookMock = vi.hoisted(() => vi.fn(async () => {}));
|
||||
const createFeishuThreadBindingManagerMock = vi.hoisted(() => vi.fn(() => ({ stop: vi.fn() })));
|
||||
|
||||
let handlers: Record<string, (data: unknown) => Promise<void>> = {};
|
||||
|
||||
@@ -38,10 +37,6 @@ vi.mock("./monitor.transport.js", () => ({
|
||||
monitorWebhook: monitorWebhookMock,
|
||||
}));
|
||||
|
||||
vi.mock("./thread-bindings.js", () => ({
|
||||
createFeishuThreadBindingManager: createFeishuThreadBindingManagerMock,
|
||||
}));
|
||||
|
||||
const cfg = {} as ClawdbotConfig;
|
||||
|
||||
function makeReactionEvent(
|
||||
@@ -424,94 +419,6 @@ describe("resolveReactionSyntheticEvent", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("monitorSingleAccount lifecycle", () => {
|
||||
beforeEach(() => {
|
||||
createFeishuThreadBindingManagerMock.mockReset().mockImplementation(() => ({
|
||||
stop: vi.fn(),
|
||||
}));
|
||||
createEventDispatcherMock.mockReset().mockReturnValue({
|
||||
register: vi.fn(),
|
||||
});
|
||||
});
|
||||
|
||||
it("stops the Feishu thread binding manager when the monitor exits", async () => {
|
||||
setFeishuRuntime(
|
||||
createPluginRuntimeMock({
|
||||
channel: {
|
||||
debounce: {
|
||||
resolveInboundDebounceMs,
|
||||
createInboundDebouncer,
|
||||
},
|
||||
text: {
|
||||
hasControlCommand,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await monitorSingleAccount({
|
||||
cfg: buildDebounceConfig(),
|
||||
account: buildDebounceAccount(),
|
||||
runtime: {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
exit: vi.fn(),
|
||||
} as RuntimeEnv,
|
||||
botOpenIdSource: {
|
||||
kind: "prefetched",
|
||||
botOpenId: "ou_bot",
|
||||
},
|
||||
});
|
||||
|
||||
const manager = createFeishuThreadBindingManagerMock.mock.results[0]?.value as
|
||||
| { stop: ReturnType<typeof vi.fn> }
|
||||
| undefined;
|
||||
expect(manager?.stop).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("stops the Feishu thread binding manager when setup fails before transport starts", async () => {
|
||||
setFeishuRuntime(
|
||||
createPluginRuntimeMock({
|
||||
channel: {
|
||||
debounce: {
|
||||
resolveInboundDebounceMs,
|
||||
createInboundDebouncer,
|
||||
},
|
||||
text: {
|
||||
hasControlCommand,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
createEventDispatcherMock.mockReturnValue({
|
||||
get register() {
|
||||
throw new Error("register failed");
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
monitorSingleAccount({
|
||||
cfg: buildDebounceConfig(),
|
||||
account: buildDebounceAccount(),
|
||||
runtime: {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
exit: vi.fn(),
|
||||
} as RuntimeEnv,
|
||||
botOpenIdSource: {
|
||||
kind: "prefetched",
|
||||
botOpenId: "ou_bot",
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow("register failed");
|
||||
|
||||
const manager = createFeishuThreadBindingManagerMock.mock.results[0]?.value as
|
||||
| { stop: ReturnType<typeof vi.fn> }
|
||||
| undefined;
|
||||
expect(manager?.stop).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Feishu inbound debounce regressions", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
@@ -1,623 +0,0 @@
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/feishu";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { registerFeishuSubagentHooks } from "./subagent-hooks.js";
|
||||
import {
|
||||
__testing as threadBindingTesting,
|
||||
createFeishuThreadBindingManager,
|
||||
} from "./thread-bindings.js";
|
||||
|
||||
const baseConfig = {
|
||||
session: { mainKey: "main", scope: "per-sender" },
|
||||
channels: { feishu: {} },
|
||||
};
|
||||
|
||||
function registerHandlersForTest(config: Record<string, unknown> = baseConfig) {
|
||||
const handlers = new Map<string, (event: unknown, ctx: unknown) => unknown>();
|
||||
const api = {
|
||||
config,
|
||||
on: (hookName: string, handler: (event: unknown, ctx: unknown) => unknown) => {
|
||||
handlers.set(hookName, handler);
|
||||
},
|
||||
} as unknown as OpenClawPluginApi;
|
||||
registerFeishuSubagentHooks(api);
|
||||
return handlers;
|
||||
}
|
||||
|
||||
function getRequiredHandler(
|
||||
handlers: Map<string, (event: unknown, ctx: unknown) => unknown>,
|
||||
hookName: string,
|
||||
): (event: unknown, ctx: unknown) => unknown {
|
||||
const handler = handlers.get(hookName);
|
||||
if (!handler) {
|
||||
throw new Error(`expected ${hookName} hook handler`);
|
||||
}
|
||||
return handler;
|
||||
}
|
||||
|
||||
describe("feishu subagent hook handlers", () => {
|
||||
beforeEach(() => {
|
||||
threadBindingTesting.resetFeishuThreadBindingsForTests();
|
||||
});
|
||||
|
||||
it("registers Feishu subagent hooks", () => {
|
||||
const handlers = registerHandlersForTest();
|
||||
expect(handlers.has("subagent_spawning")).toBe(true);
|
||||
expect(handlers.has("subagent_delivery_target")).toBe(true);
|
||||
expect(handlers.has("subagent_ended")).toBe(true);
|
||||
expect(handlers.has("subagent_spawned")).toBe(false);
|
||||
});
|
||||
|
||||
it("binds a Feishu DM conversation on subagent_spawning", async () => {
|
||||
const handlers = registerHandlersForTest();
|
||||
const handler = getRequiredHandler(handlers, "subagent_spawning");
|
||||
createFeishuThreadBindingManager({ cfg: baseConfig as any, accountId: "work" });
|
||||
|
||||
const result = await handler(
|
||||
{
|
||||
childSessionKey: "agent:main:subagent:child",
|
||||
agentId: "codex",
|
||||
label: "banana",
|
||||
mode: "session",
|
||||
requester: {
|
||||
channel: "feishu",
|
||||
accountId: "work",
|
||||
to: "user:ou_sender_1",
|
||||
},
|
||||
threadRequested: true,
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
||||
expect(result).toEqual({ status: "ok", threadBindingReady: true });
|
||||
|
||||
const deliveryTargetHandler = getRequiredHandler(handlers, "subagent_delivery_target");
|
||||
expect(
|
||||
deliveryTargetHandler(
|
||||
{
|
||||
childSessionKey: "agent:main:subagent:child",
|
||||
requesterSessionKey: "agent:main:main",
|
||||
requesterOrigin: {
|
||||
channel: "feishu",
|
||||
accountId: "work",
|
||||
to: "user:ou_sender_1",
|
||||
},
|
||||
expectsCompletionMessage: true,
|
||||
},
|
||||
{},
|
||||
),
|
||||
).toEqual({
|
||||
origin: {
|
||||
channel: "feishu",
|
||||
accountId: "work",
|
||||
to: "user:ou_sender_1",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves the original Feishu DM delivery target", async () => {
|
||||
const handlers = registerHandlersForTest();
|
||||
const deliveryHandler = getRequiredHandler(handlers, "subagent_delivery_target");
|
||||
const manager = createFeishuThreadBindingManager({ cfg: baseConfig as any, accountId: "work" });
|
||||
|
||||
manager.bindConversation({
|
||||
conversationId: "ou_sender_1",
|
||||
targetKind: "subagent",
|
||||
targetSessionKey: "agent:main:subagent:chat-dm-child",
|
||||
metadata: {
|
||||
deliveryTo: "chat:oc_dm_chat_1",
|
||||
boundBy: "system",
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
deliveryHandler(
|
||||
{
|
||||
childSessionKey: "agent:main:subagent:chat-dm-child",
|
||||
requesterSessionKey: "agent:main:main",
|
||||
requesterOrigin: {
|
||||
channel: "feishu",
|
||||
accountId: "work",
|
||||
to: "chat:oc_dm_chat_1",
|
||||
},
|
||||
expectsCompletionMessage: true,
|
||||
},
|
||||
{},
|
||||
),
|
||||
).toEqual({
|
||||
origin: {
|
||||
channel: "feishu",
|
||||
accountId: "work",
|
||||
to: "chat:oc_dm_chat_1",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("binds a Feishu topic conversation and preserves parent context", async () => {
|
||||
const handlers = registerHandlersForTest();
|
||||
const spawnHandler = getRequiredHandler(handlers, "subagent_spawning");
|
||||
const deliveryHandler = getRequiredHandler(handlers, "subagent_delivery_target");
|
||||
createFeishuThreadBindingManager({ cfg: baseConfig as any, accountId: "work" });
|
||||
|
||||
const result = await spawnHandler(
|
||||
{
|
||||
childSessionKey: "agent:main:subagent:topic-child",
|
||||
agentId: "codex",
|
||||
label: "topic-child",
|
||||
mode: "session",
|
||||
requester: {
|
||||
channel: "feishu",
|
||||
accountId: "work",
|
||||
to: "chat:oc_group_chat",
|
||||
threadId: "om_topic_root",
|
||||
},
|
||||
threadRequested: true,
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
||||
expect(result).toEqual({ status: "ok", threadBindingReady: true });
|
||||
expect(
|
||||
deliveryHandler(
|
||||
{
|
||||
childSessionKey: "agent:main:subagent:topic-child",
|
||||
requesterSessionKey: "agent:main:main",
|
||||
requesterOrigin: {
|
||||
channel: "feishu",
|
||||
accountId: "work",
|
||||
to: "chat:oc_group_chat",
|
||||
threadId: "om_topic_root",
|
||||
},
|
||||
expectsCompletionMessage: true,
|
||||
},
|
||||
{},
|
||||
),
|
||||
).toEqual({
|
||||
origin: {
|
||||
channel: "feishu",
|
||||
accountId: "work",
|
||||
to: "chat:oc_group_chat",
|
||||
threadId: "om_topic_root",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the requester session binding to preserve sender-scoped topic conversations", async () => {
|
||||
const handlers = registerHandlersForTest();
|
||||
const spawnHandler = getRequiredHandler(handlers, "subagent_spawning");
|
||||
const deliveryHandler = getRequiredHandler(handlers, "subagent_delivery_target");
|
||||
const manager = createFeishuThreadBindingManager({ cfg: baseConfig as any, accountId: "work" });
|
||||
|
||||
manager.bindConversation({
|
||||
conversationId: "oc_group_chat:topic:om_topic_root:sender:ou_sender_1",
|
||||
parentConversationId: "oc_group_chat",
|
||||
targetKind: "subagent",
|
||||
targetSessionKey: "agent:main:parent",
|
||||
metadata: {
|
||||
agentId: "codex",
|
||||
label: "parent",
|
||||
boundBy: "system",
|
||||
},
|
||||
});
|
||||
|
||||
const reboundResult = await spawnHandler(
|
||||
{
|
||||
childSessionKey: "agent:main:subagent:sender-child",
|
||||
agentId: "codex",
|
||||
label: "sender-child",
|
||||
mode: "session",
|
||||
requester: {
|
||||
channel: "feishu",
|
||||
accountId: "work",
|
||||
to: "chat:oc_group_chat",
|
||||
threadId: "om_topic_root",
|
||||
},
|
||||
threadRequested: true,
|
||||
},
|
||||
{
|
||||
requesterSessionKey: "agent:main:parent",
|
||||
},
|
||||
);
|
||||
|
||||
expect(reboundResult).toEqual({ status: "ok", threadBindingReady: true });
|
||||
expect(manager.listBySessionKey("agent:main:subagent:sender-child")).toMatchObject([
|
||||
{
|
||||
conversationId: "oc_group_chat:topic:om_topic_root:sender:ou_sender_1",
|
||||
parentConversationId: "oc_group_chat",
|
||||
},
|
||||
]);
|
||||
expect(
|
||||
deliveryHandler(
|
||||
{
|
||||
childSessionKey: "agent:main:subagent:sender-child",
|
||||
requesterSessionKey: "agent:main:parent",
|
||||
requesterOrigin: {
|
||||
channel: "feishu",
|
||||
accountId: "work",
|
||||
to: "chat:oc_group_chat",
|
||||
threadId: "om_topic_root",
|
||||
},
|
||||
expectsCompletionMessage: true,
|
||||
},
|
||||
{},
|
||||
),
|
||||
).toEqual({
|
||||
origin: {
|
||||
channel: "feishu",
|
||||
accountId: "work",
|
||||
to: "chat:oc_group_chat",
|
||||
threadId: "om_topic_root",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("prefers requester-matching bindings when multiple child bindings exist", async () => {
|
||||
const handlers = registerHandlersForTest();
|
||||
const spawnHandler = getRequiredHandler(handlers, "subagent_spawning");
|
||||
const deliveryHandler = getRequiredHandler(handlers, "subagent_delivery_target");
|
||||
createFeishuThreadBindingManager({ cfg: baseConfig as any, accountId: "work" });
|
||||
|
||||
await spawnHandler(
|
||||
{
|
||||
childSessionKey: "agent:main:subagent:shared",
|
||||
agentId: "codex",
|
||||
label: "shared",
|
||||
mode: "session",
|
||||
requester: {
|
||||
channel: "feishu",
|
||||
accountId: "work",
|
||||
to: "user:ou_sender_1",
|
||||
},
|
||||
threadRequested: true,
|
||||
},
|
||||
{},
|
||||
);
|
||||
await spawnHandler(
|
||||
{
|
||||
childSessionKey: "agent:main:subagent:shared",
|
||||
agentId: "codex",
|
||||
label: "shared",
|
||||
mode: "session",
|
||||
requester: {
|
||||
channel: "feishu",
|
||||
accountId: "work",
|
||||
to: "user:ou_sender_2",
|
||||
},
|
||||
threadRequested: true,
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
||||
expect(
|
||||
deliveryHandler(
|
||||
{
|
||||
childSessionKey: "agent:main:subagent:shared",
|
||||
requesterSessionKey: "agent:main:main",
|
||||
requesterOrigin: {
|
||||
channel: "feishu",
|
||||
accountId: "work",
|
||||
to: "user:ou_sender_2",
|
||||
},
|
||||
expectsCompletionMessage: true,
|
||||
},
|
||||
{},
|
||||
),
|
||||
).toEqual({
|
||||
origin: {
|
||||
channel: "feishu",
|
||||
accountId: "work",
|
||||
to: "user:ou_sender_2",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("fails closed when requester-session bindings remain ambiguous for the same topic", async () => {
|
||||
const handlers = registerHandlersForTest();
|
||||
const spawnHandler = getRequiredHandler(handlers, "subagent_spawning");
|
||||
const deliveryHandler = getRequiredHandler(handlers, "subagent_delivery_target");
|
||||
const manager = createFeishuThreadBindingManager({ cfg: baseConfig as any, accountId: "work" });
|
||||
|
||||
manager.bindConversation({
|
||||
conversationId: "oc_group_chat:topic:om_topic_root:sender:ou_sender_1",
|
||||
parentConversationId: "oc_group_chat",
|
||||
targetKind: "subagent",
|
||||
targetSessionKey: "agent:main:parent",
|
||||
metadata: { boundBy: "system" },
|
||||
});
|
||||
manager.bindConversation({
|
||||
conversationId: "oc_group_chat:topic:om_topic_root:sender:ou_sender_2",
|
||||
parentConversationId: "oc_group_chat",
|
||||
targetKind: "subagent",
|
||||
targetSessionKey: "agent:main:parent",
|
||||
metadata: { boundBy: "system" },
|
||||
});
|
||||
|
||||
await expect(
|
||||
spawnHandler(
|
||||
{
|
||||
childSessionKey: "agent:main:subagent:ambiguous-child",
|
||||
agentId: "codex",
|
||||
label: "ambiguous-child",
|
||||
mode: "session",
|
||||
requester: {
|
||||
channel: "feishu",
|
||||
accountId: "work",
|
||||
to: "chat:oc_group_chat",
|
||||
threadId: "om_topic_root",
|
||||
},
|
||||
threadRequested: true,
|
||||
},
|
||||
{
|
||||
requesterSessionKey: "agent:main:parent",
|
||||
},
|
||||
),
|
||||
).resolves.toMatchObject({
|
||||
status: "error",
|
||||
error: expect.stringContaining("direct messages or topic conversations"),
|
||||
});
|
||||
|
||||
expect(
|
||||
deliveryHandler(
|
||||
{
|
||||
childSessionKey: "agent:main:subagent:ambiguous-child",
|
||||
requesterSessionKey: "agent:main:parent",
|
||||
requesterOrigin: {
|
||||
channel: "feishu",
|
||||
accountId: "work",
|
||||
to: "chat:oc_group_chat",
|
||||
threadId: "om_topic_root",
|
||||
},
|
||||
expectsCompletionMessage: true,
|
||||
},
|
||||
{},
|
||||
),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("fails closed when both topic-level and sender-scoped requester bindings exist", async () => {
|
||||
const handlers = registerHandlersForTest();
|
||||
const spawnHandler = getRequiredHandler(handlers, "subagent_spawning");
|
||||
const deliveryHandler = getRequiredHandler(handlers, "subagent_delivery_target");
|
||||
const manager = createFeishuThreadBindingManager({ cfg: baseConfig as any, accountId: "work" });
|
||||
|
||||
manager.bindConversation({
|
||||
conversationId: "oc_group_chat:topic:om_topic_root",
|
||||
parentConversationId: "oc_group_chat",
|
||||
targetKind: "subagent",
|
||||
targetSessionKey: "agent:main:parent",
|
||||
metadata: { boundBy: "system" },
|
||||
});
|
||||
manager.bindConversation({
|
||||
conversationId: "oc_group_chat:topic:om_topic_root:sender:ou_sender_1",
|
||||
parentConversationId: "oc_group_chat",
|
||||
targetKind: "subagent",
|
||||
targetSessionKey: "agent:main:parent",
|
||||
metadata: { boundBy: "system" },
|
||||
});
|
||||
|
||||
await expect(
|
||||
spawnHandler(
|
||||
{
|
||||
childSessionKey: "agent:main:subagent:mixed-topic-child",
|
||||
agentId: "codex",
|
||||
label: "mixed-topic-child",
|
||||
mode: "session",
|
||||
requester: {
|
||||
channel: "feishu",
|
||||
accountId: "work",
|
||||
to: "chat:oc_group_chat",
|
||||
threadId: "om_topic_root",
|
||||
},
|
||||
threadRequested: true,
|
||||
},
|
||||
{
|
||||
requesterSessionKey: "agent:main:parent",
|
||||
},
|
||||
),
|
||||
).resolves.toMatchObject({
|
||||
status: "error",
|
||||
error: expect.stringContaining("direct messages or topic conversations"),
|
||||
});
|
||||
|
||||
expect(
|
||||
deliveryHandler(
|
||||
{
|
||||
childSessionKey: "agent:main:subagent:mixed-topic-child",
|
||||
requesterSessionKey: "agent:main:parent",
|
||||
requesterOrigin: {
|
||||
channel: "feishu",
|
||||
accountId: "work",
|
||||
to: "chat:oc_group_chat",
|
||||
threadId: "om_topic_root",
|
||||
},
|
||||
expectsCompletionMessage: true,
|
||||
},
|
||||
{},
|
||||
),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("no-ops for non-Feishu channels and non-threaded spawns", async () => {
|
||||
const handlers = registerHandlersForTest();
|
||||
const spawnHandler = getRequiredHandler(handlers, "subagent_spawning");
|
||||
const deliveryHandler = getRequiredHandler(handlers, "subagent_delivery_target");
|
||||
const endedHandler = getRequiredHandler(handlers, "subagent_ended");
|
||||
|
||||
await expect(
|
||||
spawnHandler(
|
||||
{
|
||||
childSessionKey: "agent:main:subagent:child",
|
||||
agentId: "codex",
|
||||
mode: "run",
|
||||
requester: {
|
||||
channel: "discord",
|
||||
accountId: "work",
|
||||
to: "channel:123",
|
||||
},
|
||||
threadRequested: true,
|
||||
},
|
||||
{},
|
||||
),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
await expect(
|
||||
spawnHandler(
|
||||
{
|
||||
childSessionKey: "agent:main:subagent:child",
|
||||
agentId: "codex",
|
||||
mode: "run",
|
||||
requester: {
|
||||
channel: "feishu",
|
||||
accountId: "work",
|
||||
to: "user:ou_sender_1",
|
||||
},
|
||||
threadRequested: false,
|
||||
},
|
||||
{},
|
||||
),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(
|
||||
deliveryHandler(
|
||||
{
|
||||
childSessionKey: "agent:main:subagent:child",
|
||||
requesterSessionKey: "agent:main:main",
|
||||
requesterOrigin: {
|
||||
channel: "discord",
|
||||
accountId: "work",
|
||||
to: "channel:123",
|
||||
},
|
||||
expectsCompletionMessage: true,
|
||||
},
|
||||
{},
|
||||
),
|
||||
).toBeUndefined();
|
||||
|
||||
expect(
|
||||
endedHandler(
|
||||
{
|
||||
targetSessionKey: "agent:main:subagent:child",
|
||||
targetKind: "subagent",
|
||||
reason: "done",
|
||||
accountId: "work",
|
||||
},
|
||||
{},
|
||||
),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns an error for unsupported non-topic Feishu group conversations", async () => {
|
||||
const handler = getRequiredHandler(registerHandlersForTest(), "subagent_spawning");
|
||||
createFeishuThreadBindingManager({ cfg: baseConfig as any, accountId: "work" });
|
||||
|
||||
await expect(
|
||||
handler(
|
||||
{
|
||||
childSessionKey: "agent:main:subagent:child",
|
||||
agentId: "codex",
|
||||
mode: "session",
|
||||
requester: {
|
||||
channel: "feishu",
|
||||
accountId: "work",
|
||||
to: "chat:oc_group_chat",
|
||||
},
|
||||
threadRequested: true,
|
||||
},
|
||||
{},
|
||||
),
|
||||
).resolves.toMatchObject({
|
||||
status: "error",
|
||||
error: expect.stringContaining("direct messages or topic conversations"),
|
||||
});
|
||||
});
|
||||
|
||||
it("unbinds Feishu bindings on subagent_ended", async () => {
|
||||
const handlers = registerHandlersForTest();
|
||||
const spawnHandler = getRequiredHandler(handlers, "subagent_spawning");
|
||||
const deliveryHandler = getRequiredHandler(handlers, "subagent_delivery_target");
|
||||
const endedHandler = getRequiredHandler(handlers, "subagent_ended");
|
||||
createFeishuThreadBindingManager({ cfg: baseConfig as any, accountId: "work" });
|
||||
|
||||
await spawnHandler(
|
||||
{
|
||||
childSessionKey: "agent:main:subagent:child",
|
||||
agentId: "codex",
|
||||
mode: "session",
|
||||
requester: {
|
||||
channel: "feishu",
|
||||
accountId: "work",
|
||||
to: "user:ou_sender_1",
|
||||
},
|
||||
threadRequested: true,
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
||||
endedHandler(
|
||||
{
|
||||
targetSessionKey: "agent:main:subagent:child",
|
||||
targetKind: "subagent",
|
||||
reason: "done",
|
||||
accountId: "work",
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
||||
expect(
|
||||
deliveryHandler(
|
||||
{
|
||||
childSessionKey: "agent:main:subagent:child",
|
||||
requesterSessionKey: "agent:main:main",
|
||||
requesterOrigin: {
|
||||
channel: "feishu",
|
||||
accountId: "work",
|
||||
to: "user:ou_sender_1",
|
||||
},
|
||||
expectsCompletionMessage: true,
|
||||
},
|
||||
{},
|
||||
),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("fails closed when the Feishu monitor-owned binding manager is unavailable", async () => {
|
||||
const handlers = registerHandlersForTest();
|
||||
const spawnHandler = getRequiredHandler(handlers, "subagent_spawning");
|
||||
const deliveryHandler = getRequiredHandler(handlers, "subagent_delivery_target");
|
||||
|
||||
await expect(
|
||||
spawnHandler(
|
||||
{
|
||||
childSessionKey: "agent:main:subagent:no-manager",
|
||||
agentId: "codex",
|
||||
mode: "session",
|
||||
requester: {
|
||||
channel: "feishu",
|
||||
accountId: "work",
|
||||
to: "user:ou_sender_1",
|
||||
},
|
||||
threadRequested: true,
|
||||
},
|
||||
{},
|
||||
),
|
||||
).resolves.toMatchObject({
|
||||
status: "error",
|
||||
error: expect.stringContaining("monitor is not active"),
|
||||
});
|
||||
|
||||
expect(
|
||||
deliveryHandler(
|
||||
{
|
||||
childSessionKey: "agent:main:subagent:no-manager",
|
||||
requesterSessionKey: "agent:main:main",
|
||||
requesterOrigin: {
|
||||
channel: "feishu",
|
||||
accountId: "work",
|
||||
to: "user:ou_sender_1",
|
||||
},
|
||||
expectsCompletionMessage: true,
|
||||
},
|
||||
{},
|
||||
),
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user