mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-21 14:32:03 +08:00
Compare commits
202 Commits
codex/llm-
...
pr/plugin-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e247650f1d | ||
|
|
8aceaf5d0f | ||
|
|
e36c563775 | ||
|
|
48279dca84 | ||
|
|
990545181b | ||
|
|
2170d36171 | ||
|
|
e6ce31eb54 | ||
|
|
a26f4d0f3e | ||
|
|
367969759c | ||
|
|
47f5d72931 | ||
|
|
96b55821bc | ||
|
|
221db4ec74 | ||
|
|
b868b5bf20 | ||
|
|
176c059b05 | ||
|
|
cd41170303 | ||
|
|
8a02716d0d | ||
|
|
7cea7c2970 | ||
|
|
d5b6bfc48c | ||
|
|
e4818a345e | ||
|
|
bf1fcf2e5f | ||
|
|
17f6626ffe | ||
|
|
721cab2b8d | ||
|
|
812a7636fb | ||
|
|
47dcfc49b8 | ||
|
|
34a5c47351 | ||
|
|
462b4020bc | ||
|
|
8d81e76f23 | ||
|
|
578a0ed31a | ||
|
|
59bdf870b9 | ||
|
|
5d524617e1 | ||
|
|
186647cb74 | ||
|
|
4207ca2eb8 | ||
|
|
b5161042b7 | ||
|
|
77e636cf78 | ||
|
|
c0b6531ec7 | ||
|
|
3b6825ab93 | ||
|
|
102462b7a6 | ||
|
|
d300a20440 | ||
|
|
047b701859 | ||
|
|
7e2a450e31 | ||
|
|
1f531d373b | ||
|
|
423f7c3487 | ||
|
|
0ad2dbd307 | ||
|
|
d2ce3e9acc | ||
|
|
988f7627de | ||
|
|
efe9464f5f | ||
|
|
0a76780f57 | ||
|
|
874a585d57 | ||
|
|
576337ef31 | ||
|
|
8c3295038c | ||
|
|
eb261fa690 | ||
|
|
36d953aab6 | ||
|
|
fff6333773 | ||
|
|
cc5146b9c6 | ||
|
|
a5f99f4a30 | ||
|
|
d46240090a | ||
|
|
3872a866a1 | ||
|
|
b6debb4382 | ||
|
|
831729be4a | ||
|
|
52866656c3 | ||
|
|
53c29df2a9 | ||
|
|
4251ad6638 | ||
|
|
7fea8250fb | ||
|
|
316d10637b | ||
|
|
65c1716ad4 | ||
|
|
ef86edacf7 | ||
|
|
b40ef364b7 | ||
|
|
4f692190b4 | ||
|
|
e0d20966ae | ||
|
|
0e3cc12900 | ||
|
|
c4fb15e492 | ||
|
|
ecf72319ed | ||
|
|
bb3f17fc02 | ||
|
|
b0f94a227b | ||
|
|
4269f40811 | ||
|
|
c678ae7e7a | ||
|
|
0500b410c5 | ||
|
|
def5b954a8 | ||
|
|
1ecd92af89 | ||
|
|
a1f95e5278 | ||
|
|
41b81ca7f8 | ||
|
|
59eccef768 | ||
|
|
e45b29b247 | ||
|
|
fcf708665c | ||
|
|
290e5bf219 | ||
|
|
52a6e354a8 | ||
|
|
ec6a07ef05 | ||
|
|
3528e15817 | ||
|
|
3cca07a983 | ||
|
|
b21c9840c2 | ||
|
|
3e4de956c0 | ||
|
|
ef7c553dd1 | ||
|
|
12bd6b7bb9 | ||
|
|
7eae9c0e62 | ||
|
|
54a0878517 | ||
|
|
be10ecef77 | ||
|
|
4c08b0bb08 | ||
|
|
cfbad0a4f9 | ||
|
|
d4f69878da | ||
|
|
6f91f87f3b | ||
|
|
0f45630d19 | ||
|
|
9c22d63669 | ||
|
|
e48ee8ae9e | ||
|
|
b18de06bff | ||
|
|
15e6a88c67 | ||
|
|
761cdc967d | ||
|
|
9823833383 | ||
|
|
6eca1949d5 | ||
|
|
5abd5d889f | ||
|
|
71d49012fc | ||
|
|
5639e8d242 | ||
|
|
e894c7e66e | ||
|
|
b6c3ecedd8 | ||
|
|
a7909d46d2 | ||
|
|
a51c976d27 | ||
|
|
c405bcfa98 | ||
|
|
38d2faee20 | ||
|
|
82d5e6a2f7 | ||
|
|
bbf9800a8e | ||
|
|
474409deb5 | ||
|
|
d49460b417 | ||
|
|
d87bc6706c | ||
|
|
707f5485b9 | ||
|
|
8bdca2323d | ||
|
|
f65da8711a | ||
|
|
dfe95b1e1b | ||
|
|
9aa2ef2736 | ||
|
|
ec17260e26 | ||
|
|
f8e67ef698 | ||
|
|
ecb4ea9830 | ||
|
|
0e9a9dae84 | ||
|
|
2fa4c7cc61 | ||
|
|
52d2bd5cc6 | ||
|
|
ac5bc4fb37 | ||
|
|
0e3da03193 | ||
|
|
e3319b2a63 | ||
|
|
d983970704 | ||
|
|
73c1b45819 | ||
|
|
e48a7b9be8 | ||
|
|
657295c347 | ||
|
|
2eaf5a695e | ||
|
|
2c45b06afd | ||
|
|
5c36c2d0d2 | ||
|
|
304da2cbd7 | ||
|
|
c27b45fd12 | ||
|
|
176ff18d18 | ||
|
|
251ba9b4d2 | ||
|
|
a597938be8 | ||
|
|
d90c8db491 | ||
|
|
331e835dab | ||
|
|
08962b6812 | ||
|
|
b441cd2f4f | ||
|
|
53f1c9968a | ||
|
|
68bb76519a | ||
|
|
8748b7c54c | ||
|
|
ce0ff42ff5 | ||
|
|
a5cd921053 | ||
|
|
c15cfeb21c | ||
|
|
0809c8d29a | ||
|
|
3e52f5a021 | ||
|
|
f28f0f29ba | ||
|
|
9786946b2d | ||
|
|
5c331687ff | ||
|
|
be52594766 | ||
|
|
7b748a57f0 | ||
|
|
b880118d2d | ||
|
|
93fa6920b4 | ||
|
|
16c5bd466c | ||
|
|
52a018680d | ||
|
|
ed6012eb5b | ||
|
|
41aac73590 | ||
|
|
703a363589 | ||
|
|
1707493be4 | ||
|
|
f69570f820 | ||
|
|
ad6e42906f | ||
|
|
0e8e986c95 | ||
|
|
7dc065dab0 | ||
|
|
5b952836e3 | ||
|
|
1a037ff6cd | ||
|
|
4309dc6d5e | ||
|
|
fcfb9ddb1d | ||
|
|
e718493ae6 | ||
|
|
85928e29f1 | ||
|
|
be1b4e6683 | ||
|
|
4fd1e1c64f | ||
|
|
9bbbee32e1 | ||
|
|
6dbdcbda58 | ||
|
|
bfa561b1a7 | ||
|
|
a398520ac8 | ||
|
|
b9c74fc884 | ||
|
|
e1c96785ac | ||
|
|
df60fa8d49 | ||
|
|
45adba882f | ||
|
|
19c954bd78 | ||
|
|
d55cefac00 | ||
|
|
75b5a4c713 | ||
|
|
4a5102c1bb | ||
|
|
d4c7ef3778 | ||
|
|
ee274dbdd1 | ||
|
|
cae1d9bc6d | ||
|
|
8c3167a7c7 | ||
|
|
534f0a644b |
7
.github/workflows/ci.yml
vendored
7
.github/workflows/ci.yml
vendored
@@ -546,6 +546,11 @@ jobs:
|
||||
continue-on-error: true
|
||||
run: pnpm run lint:web-search-provider-boundaries
|
||||
|
||||
- name: Run web fetch provider boundary guard
|
||||
id: web_fetch_provider_boundary
|
||||
continue-on-error: true
|
||||
run: pnpm run lint:web-fetch-provider-boundaries
|
||||
|
||||
- name: Run extension src boundary guard
|
||||
id: extension_src_outside_plugin_sdk_boundary
|
||||
continue-on-error: true
|
||||
@@ -593,6 +598,7 @@ jobs:
|
||||
NO_EXTENSION_TEST_CORE_IMPORTS_OUTCOME: ${{ steps.no_extension_test_core_imports.outcome }}
|
||||
PLUGIN_SDK_SUBPATHS_EXPORTED_OUTCOME: ${{ steps.plugin_sdk_subpaths_exported.outcome }}
|
||||
WEB_SEARCH_PROVIDER_BOUNDARY_OUTCOME: ${{ steps.web_search_provider_boundary.outcome }}
|
||||
WEB_FETCH_PROVIDER_BOUNDARY_OUTCOME: ${{ steps.web_fetch_provider_boundary.outcome }}
|
||||
EXTENSION_SRC_OUTSIDE_PLUGIN_SDK_BOUNDARY_OUTCOME: ${{ steps.extension_src_outside_plugin_sdk_boundary.outcome }}
|
||||
EXTENSION_PLUGIN_SDK_INTERNAL_BOUNDARY_OUTCOME: ${{ steps.extension_plugin_sdk_internal_boundary.outcome }}
|
||||
EXTENSION_RELATIVE_OUTSIDE_PACKAGE_BOUNDARY_OUTCOME: ${{ steps.extension_relative_outside_package_boundary.outcome }}
|
||||
@@ -612,6 +618,7 @@ jobs:
|
||||
"lint:plugins:no-extension-test-core-imports|$NO_EXTENSION_TEST_CORE_IMPORTS_OUTCOME" \
|
||||
"lint:plugins:plugin-sdk-subpaths-exported|$PLUGIN_SDK_SUBPATHS_EXPORTED_OUTCOME" \
|
||||
"web-search-provider-boundary|$WEB_SEARCH_PROVIDER_BOUNDARY_OUTCOME" \
|
||||
"web-fetch-provider-boundary|$WEB_FETCH_PROVIDER_BOUNDARY_OUTCOME" \
|
||||
"extension-src-outside-plugin-sdk-boundary|$EXTENSION_SRC_OUTSIDE_PLUGIN_SDK_BOUNDARY_OUTCOME" \
|
||||
"extension-plugin-sdk-internal-boundary|$EXTENSION_PLUGIN_SDK_INTERNAL_BOUNDARY_OUTCOME" \
|
||||
"extension-relative-outside-package-boundary|$EXTENSION_RELATIVE_OUTSIDE_PACKAGE_BOUNDARY_OUTCOME" \
|
||||
|
||||
176
CHANGELOG.md
176
CHANGELOG.md
@@ -6,17 +6,125 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
### Changes
|
||||
|
||||
- Agents/compaction: add `agents.defaults.compaction.notifyUser` so the `🧹 Compacting context...` start notice is opt-in instead of always being shown. (#54251) Thanks @oguricap0327.
|
||||
- Plugins/hooks: add `before_agent_reply` so plugins can short-circuit the LLM with synthetic replies after inline actions. (#20067) thanks @JoshuaLelon
|
||||
- Providers/runtime: add provider-owned replay hook surfaces for transcript policy, replay cleanup, and reasoning-mode dispatch. (#59143) Thanks @jalehman.
|
||||
- Android/assistant: auto-send Google Assistant App Actions prompts once chat is healthy and idle, while keeping bare assistant launches as open-only. (#59721) Thanks @obviyus.
|
||||
|
||||
### Fixes
|
||||
|
||||
- Providers/OpenAI attribution: centralize versioned attribution header formatting and reuse the shared provider-attribution policy in OpenAI-compatible transcription helpers without broadening attribution to unverified providers. Thanks @fanweixiao and @vincentkoc.
|
||||
- Slack/mrkdwn formatting: add built-in Slack mrkdwn guidance in inbound context so Slack replies stop falling back to generic Markdown patterns that render poorly in Slack. Thanks @jadewon and @vincentkoc.
|
||||
- Gateway/exec loopback: restore legacy-role fallback for empty paired-device token maps and allow silent local role upgrades so local exec and node clients stop failing with pairing-required errors after `2026.3.31`. (#59092) Thanks @openperf.
|
||||
- Agents/tools: include value-shape hints in missing-parameter tool errors so dropped, empty-string, and wrong-type write payloads are easier to diagnose from logs. (#55317) Thanks @priyansh19.
|
||||
- Plugins/browser: block SSRF redirect bypass by installing a real-time Playwright route handler before `page.goto()` so navigation to private/internal IPs is intercepted and aborted mid-redirect instead of checked post-hoc. (#58771) Thanks @pgondhi987.
|
||||
- Android/assistant: keep queued App Actions prompts pending when auto-send enqueue is rejected, so transient chat-health drops do not silently lose the assistant request. Thanks @obviyus.
|
||||
- Zalo/webhook: scope replay-dedupe cache key to path and account using `JSON.stringify` so multi-account deployments do not silently drop events due to cross-account cache poisoning. (#59387) Thanks @pgondhi987.
|
||||
- Plugins/Google: separate OAuth CSRF state from PKCE code verifier during Gemini browser sign-in so state validation and token exchange use independent values. (#59116) Thanks @eleqtrizit.
|
||||
- Exec/Windows: reject malformed drive-less rooted executable paths like `:\Users\...` so approval and allowlist candidate resolution no longer treat them as cwd-relative commands. (#58040) Thanks @SnowSky1.
|
||||
- Exec/preflight: fail closed on complex interpreter invocations that would otherwise skip script-content validation, and correctly inspect quoted script paths before host execution. Thanks @pgondhi987.
|
||||
|
||||
## 2026.4.1-beta.1
|
||||
## 2026.4.2-beta.1
|
||||
|
||||
### Breaking
|
||||
|
||||
- Plugins/xAI: move `x_search` settings from the legacy core `tools.web.x_search.*` path to the plugin-owned `plugins.entries.xai.config.xSearch.*` path, standardize `x_search` auth on `plugins.entries.xai.config.webSearch.apiKey` / `XAI_API_KEY`, and migrate legacy config with `openclaw doctor --fix`. (#59674) Thanks @vincentkoc.
|
||||
- Plugins/web fetch: move Firecrawl `web_fetch` config from the legacy core `tools.web.fetch.firecrawl.*` path to the plugin-owned `plugins.entries.firecrawl.config.webFetch.*` path, route `web_fetch` fallback through the new fetch-provider boundary instead of a Firecrawl-only core branch, and migrate legacy config with `openclaw doctor --fix`. (#59465) Thanks @vincentkoc.
|
||||
|
||||
### Changes
|
||||
|
||||
- Tasks/TaskFlow: restore the core TaskFlow substrate with managed-vs-mirrored sync modes, durable flow state/revision tracking, and `openclaw flows` inspection/recovery primitives so background orchestration can persist and be operated separately from plugin authoring layers. (#58930) Thanks @mbelinky.
|
||||
- Tasks/TaskFlow: add managed child task spawning plus sticky cancel intent, so external orchestrators can stop scheduling immediately and let parent TaskFlows settle to `cancelled` once active child tasks finish. (#59610) Thanks @mbelinky.
|
||||
- Plugins/TaskFlow: add a bound `api.runtime.taskFlow` seam so plugins and trusted authoring layers can create and drive managed TaskFlows from host-resolved OpenClaw context without passing owner identifiers on each call. (#59622) Thanks @mbelinky.
|
||||
- Android/assistant: add assistant-role entrypoints plus Google Assistant App Actions metadata so Android can launch OpenClaw from the assistant trigger and hand prompts into the chat composer. (#59596) Thanks @obviyus.
|
||||
- Exec defaults: make gateway/node host exec default to YOLO mode by requesting `security=full` with `ask=off`, and align host approval-file fallbacks plus docs/doctor reporting with that no-prompt default.
|
||||
- Providers/runtime: add provider-owned replay hook surfaces for transcript policy, replay cleanup, and reasoning-mode dispatch. (#59143) Thanks @jalehman.
|
||||
- Plugins/hooks: add `before_agent_reply` so plugins can short-circuit the LLM with synthetic replies after inline actions. (#20067) Thanks @JoshuaLelon.
|
||||
- Channels/session routing: move provider-specific session conversation grammar into plugin-owned session-key surfaces, preserving Telegram topic routing and Feishu scoped inheritance across bootstrap, model override, restart, and tool-policy paths.
|
||||
- Feishu/comments: add a dedicated Drive comment-event flow with comment-thread context resolution, in-thread replies, and `feishu_drive` comment actions for document collaboration workflows. (#58497) Thanks @wittam-01.
|
||||
- Matrix/plugin: emit spec-compliant `m.mentions` metadata across text sends, media captions, edits, poll fallback text, and action-driven edits so Matrix mentions notify reliably in clients like Element. (#59323) Thanks @gumadeiras.
|
||||
- Diffs: add plugin-owned `viewerBaseUrl` so viewer links can use a stable proxy/public origin without passing `baseUrl` on every tool call. (#59341) Related #59227. Thanks @gumadeiras.
|
||||
- Agents/compaction: resolve `agents.defaults.compaction.model` consistently for manual `/compact` and other context-engine compaction paths, so engine-owned compaction uses the configured override model across runtime entrypoints. (#56710) Thanks @oliviareid-svg.
|
||||
- Agents/compaction: add `agents.defaults.compaction.notifyUser` so the `🧹 Compacting context...` start notice is opt-in instead of always being shown. (#54251) Thanks @oguricap0327.
|
||||
- WhatsApp/reactions: add `reactionLevel` guidance for agent reactions. Thanks @mcaxtr.
|
||||
- Exec approvals/channels: auto-enable DM-first native chat approvals when supported channels can infer approvers from existing owner config, while keeping channel fanout explicit and clarifying forwarding versus native approval client config.
|
||||
|
||||
### Fixes
|
||||
|
||||
- Providers/transport policy: centralize request auth, proxy, TLS, and header shaping across shared HTTP, stream, and websocket paths, block insecure TLS/runtime transport overrides, and keep proxy-hop TLS separate from target mTLS settings. (#59682) Thanks @vincentkoc.
|
||||
- Providers/Copilot: classify native GitHub Copilot API hosts in the shared provider endpoint resolver and harden token-derived proxy endpoint parsing so Copilot base URL routing stays centralized and fails closed on malformed hints. (#59644) Thanks @vincentkoc.
|
||||
- Providers/streaming headers: centralize default and attribution header merging across OpenAI websocket, embedded-runner, and proxy stream paths so provider-specific headers stay consistent and caller overrides only win where intended. (#59542) Thanks @vincentkoc.
|
||||
- Providers/media HTTP: centralize base URL normalization, default auth/header injection, and explicit header override handling across shared OpenAI-compatible audio, Deepgram audio, Gemini media/image, and Moonshot video request paths. (#59469) Thanks @vincentkoc.
|
||||
- Providers/OpenAI-compatible routing: centralize native-vs-proxy request policy so hidden attribution and related OpenAI-family defaults only apply on verified native endpoints across stream, websocket, and shared audio HTTP paths. (#59433) Thanks @vincentkoc.
|
||||
- Providers/Anthropic routing: centralize native-vs-proxy endpoint classification for direct Anthropic `service_tier` handling so spoofed or proxied hosts do not inherit native Anthropic defaults. (#59608) Thanks @vincentkoc.
|
||||
- Gateway/exec loopback: restore legacy-role fallback for empty paired-device token maps and allow silent local role upgrades so local exec and node clients stop failing with pairing-required errors after `2026.3.31`. (#59092) Thanks @openperf.
|
||||
- Agents/subagents: pin admin-only subagent gateway calls to `operator.admin` while keeping `agent` at least privilege, so `sessions_spawn` no longer dies on loopback scope-upgrade pairing with `close(1008) "pairing required"`. (#59555) Thanks @openperf.
|
||||
- Exec approvals/config: strip invalid `security`, `ask`, and `askFallback` values from `~/.openclaw/exec-approvals.json` during normalization so malformed policy enums fall back cleanly to the documented defaults instead of corrupting runtime policy resolution. (#59112) Thanks @openperf.
|
||||
- Exec approvals/doctor: report host policy sources from the real approvals file path and ignore malformed host override values when attributing effective policy conflicts. (#59367) Thanks @gumadeiras.
|
||||
- Slack/mrkdwn formatting: add built-in Slack mrkdwn guidance in inbound context so Slack replies stop falling back to generic Markdown patterns that render poorly in Slack. (#59100) Thanks @jadewon.
|
||||
- WhatsApp/presence: send `unavailable` presence on connect in self-chat mode so personal-phone users stop losing all push notifications while the gateway is running. (#59410) Thanks @mcaxtr.
|
||||
- WhatsApp/media: add HTML, XML, and CSS to the MIME map and fall back gracefully for unknown media types instead of dropping the attachment. (#51562) Thanks @bobbyt74.
|
||||
- Matrix/onboarding: restore guided setup in `openclaw channels add` and `openclaw configure --section channels`, while keeping custom plugin wizards on the shared `setupWizard` seam. (#59462) Thanks @gumadeiras.
|
||||
- Matrix/streaming: keep live partial previews for the current assistant block while preserving completed block updates as separate messages when `channels.matrix.blockStreaming` is enabled. (#59384) Thanks @gumadeiras.
|
||||
- Feishu/comment threads: harden document comment-thread delivery so whole-document comments fall back to `add_comment`, delayed reply lookups retry more reliably, and user-visible replies avoid reasoning/planning spillover. (#59129) Thanks @wittam-01.
|
||||
- MS Teams/streaming: strip already-streamed text from fallback block delivery when replies exceed the 4000-character streaming limit so long responses stop duplicating content. (#59297) Thanks @bradgroux.
|
||||
- Slack/thread context: filter thread starter and history by the effective conversation allowlist without dropping valid open-room, DM, or group DM context. (#58380) Thanks @jacobtomlinson.
|
||||
- Mattermost/probes: route status probes through the SSRF guard and honor `allowPrivateNetwork` so connectivity checks stay safe for self-hosted Mattermost deployments. (#58529) Thanks @mappel-nv.
|
||||
- Zalo/webhook replay: scope replay dedupe key by chat and sender so reused message IDs across different chats or senders no longer collide, and harden metadata reads for partially missing payloads. (#58444)
|
||||
- QQBot/structured payloads: restrict local file paths to QQ Bot-owned media storage, block traversal outside that root, reduce path leakage in logs, and keep inline image data URLs working. (#58453) Thanks @jacobtomlinson.
|
||||
- Image generation/providers: route OpenAI, MiniMax, and fal image requests through the shared provider HTTP transport path so custom base URLs, guarded private-network routing, and provider request defaults stay aligned with the rest of provider HTTP. Thanks @vincentkoc.
|
||||
- Image generation/providers: stop inferring private-network access from configured OpenAI, MiniMax, and fal image base URLs, and cap shared HTTP error-body reads so hostile or misconfigured endpoints fail closed without relaxing SSRF policy or buffering unbounded error payloads. Thanks @vincentkoc.
|
||||
- Browser/host inspection: keep static Chrome inspection helpers out of the activated browser runtime so `openclaw doctor browser` and related checks do not eagerly load the bundled browser plugin. (#59471) Thanks @vincentkoc.
|
||||
- Browser/CDP: normalize trailing-dot localhost absolute-form hosts before loopback checks so remote CDP websocket URLs like `ws://localhost.:...` rewrite back to the configured remote host. (#59236) Thanks @mappel-nv.
|
||||
- Agents/output sanitization: strip namespaced `antml:thinking` blocks from user-visible text so Anthropic-style internal monologue tags do not leak into replies. (#59550) Thanks @obviyus.
|
||||
- Kimi Coding/tools: normalize Anthropic tool payloads into the OpenAI-compatible function shape Kimi Coding expects so tool calls stop losing required arguments. (#59440) Thanks @obviyus.
|
||||
- Image tool/paths: resolve relative local media paths against the agent `workspaceDir` instead of `process.cwd()` so inputs like `inbox/receipt.png` pass the local-path allowlist reliably. (#57222) Thanks Priyansh Gupta.
|
||||
- Podman/launch: remove noisy container output from `scripts/run-openclaw-podman.sh` and align the Podman install guidance with the quieter startup flow. (#59368) Thanks @sallyom.
|
||||
- Plugins/runtime: keep LINE reply directives and browser-backed cleanup/reset flows working even when those plugins are disabled while tightening bundled plugin activation guards. (#59412) Thanks @vincentkoc.
|
||||
- ACP/gateway reconnects: keep ACP prompts alive across transient websocket drops while still failing boundedly when reconnect recovery does not complete. (#59473) Thanks @obviyus.
|
||||
- ACP/gateway reconnects: reject stale pre-ack ACP prompts after reconnect grace expiry so callers fail cleanly instead of hanging indefinitely when the gateway never confirms the run.
|
||||
- Gateway/session kill: enforce HTTP operator scopes on session kill requests and gate authorization before session lookup so unauthenticated callers cannot probe session existence. (#59128) Thanks @jacobtomlinson.
|
||||
- MS Teams/logging: format non-`Error` failures with the shared unknown-error helper so logs stop collapsing caught SDK or Axios objects into `[object Object]`. (#59321) Thanks @bradgroux.
|
||||
- Channels/setup: ignore untrusted workspace channel plugins during setup resolution so a shadowing workspace plugin cannot override built-in channel setup/login flows unless explicitly trusted in config. (#59158) Thanks @mappel-nv.
|
||||
- Exec/Windows: restore allowlist enforcement with quote-aware `argPattern` matching across gateway and node exec, and surface accurate dynamic pre-approved executable hints in the exec tool description. (#56285) Thanks @kpngr.
|
||||
- Gateway: prune empty `node-pending-work` state entries after explicit acknowledgments and natural expiry so the per-node state map no longer grows indefinitely. (#58179) Thanks @gavyngong.
|
||||
- Webhooks/secret comparison: replace ad-hoc timing-safe secret comparisons across BlueBubbles, Feishu, Mattermost, Telegram, Twilio, and Zalo webhook handlers with the shared `safeEqualSecret` helper and reject empty auth tokens in BlueBubbles. (#58432) Thanks @eleqtrizit.
|
||||
- OpenShell/mirror: constrain `remoteWorkspaceDir` and `remoteAgentWorkspaceDir` to the managed `/sandbox` and `/agent` roots, and keep mirror sync from overwriting or removing user-added shell roots during config synchronization. (#58515) Thanks @eleqtrizit.
|
||||
- Plugins/activation: preserve explicit, auto-enabled, and default activation provenance plus reason metadata across CLI, gateway bootstrap, and status surfaces so plugin enablement state stays accurate after auto-enable resolution. (#59641) Thanks @vincentkoc.
|
||||
- Exec/env: block additional host environment override pivots for package roots, language runtimes, compiler include paths, and credential/config locations so request-scoped exec cannot redirect trusted toolchains or config lookups. (#59233) Thanks @drobison00.
|
||||
- Dotenv/workspace overrides: block workspace `.env` files from overriding `OPENCLAW_PINNED_PYTHON` and `OPENCLAW_PINNED_WRITE_PYTHON` so trusted helper interpreters cannot be redirected by repo-local env injection. (#58473) Thanks @eleqtrizit.
|
||||
- Plugins/install: accept JSON5 syntax in `openclaw.plugin.json` and bundle `plugin.json` manifests during install/validation, so third-party plugins with trailing commas, comments, or unquoted keys no longer fail to install. (#59084) Thanks @singleGanghood.
|
||||
- Telegram/exec approvals: rewrite shared `/approve … allow-always` callback payloads to `/approve … always` before Telegram button rendering so plugin approval IDs still fit Telegram's `callback_data` limit and keep the Allow Always action visible. (#59217) Thanks @jameslcowan.
|
||||
- Cron/exec timeouts: surface timed-out `exec` and `bash` failures in isolated cron runs even when `verbose: off`, including custom session-target cron jobs, so scheduled runs stop failing silently. (#58247) Thanks @skainguyen1412.
|
||||
- Telegram/exec approvals: fall back to the origin session key for async approval followups and keep resume-failure status delivery sanitized so Telegram followups still land without leaking raw exec metadata. (#59351) Thanks @seonang.
|
||||
- Node-host/exec approvals: bind `pnpm dlx` invocations through the approval planner's mutable-script path so the effective runtime command is resolved for approval instead of being left unbound. (#58374)
|
||||
|
||||
## 2026.4.2
|
||||
|
||||
### Changes
|
||||
|
||||
- macOS/Voice Wake: add the Voice Wake option to trigger Talk Mode. (#58490) Thanks @SmoothExec.
|
||||
- Tasks/chat: add `/tasks` as a chat-native background task board for the current session, with recent task details and agent-local fallback counts when no linked tasks are visible. Related #54226. Thanks @vincentkoc.
|
||||
- Web search/SearXNG: add the bundled SearXNG provider plugin for `web_search` with configurable host support. (#57317) Thanks @cgdusek.
|
||||
- Feishu/comments: add a dedicated Drive comment-event flow with comment-thread context resolution, in-thread replies, and `feishu_drive` comment actions for document collaboration workflows. (#58497) Thanks @wittam-01.
|
||||
- WhatsApp/reactions: add `reactionLevel` guidance for agent reactions. Thanks @mcaxtr.
|
||||
- Telegram/errors: add configurable `errorPolicy` and `errorCooldownMs` controls so Telegram can suppress repeated delivery errors per account, chat, and topic without muting distinct failures. (#51914) Thanks @chinar-amrutkar
|
||||
- Gateway/webchat: make `chat.history` text truncation configurable with `gateway.webchat.chatHistoryMaxChars` and per-request `maxChars`, while preserving silent-reply filtering and existing default payload limits. (#58900)
|
||||
- Amazon Bedrock/Guardrails: add Bedrock Guardrails support to the bundled provider. (#58588) Thanks @MikeORed.
|
||||
- ZAI/models: add `glm-5.1` and `glm-5v-turbo` to the bundled Z.AI provider catalog. (#58793) Thanks @tomsun28
|
||||
- Agents/default params: add `agents.defaults.params` for global default provider parameters. (#58548) Thanks @lpender.
|
||||
- Agents/failover: cap prompt-side and assistant-side same-provider auth-profile retries for rate-limit failures before cross-provider model fallback, add the `auth.cooldowns.rateLimitedProfileRotations` knob, and document the new fallback behavior. (#58707) Thanks @Forgely3D
|
||||
- Agents/compaction: resolve `agents.defaults.compaction.model` consistently for manual `/compact` and other context-engine compaction paths, so engine-owned compaction uses the configured override model across runtime entrypoints. (#56710) Thanks @oliviareid-svg
|
||||
- Cron/tools allowlist: add `openclaw cron --tools` for per-job tool allowlists. (#58504) Thanks @andyk-ms.
|
||||
- Channels/session routing: move provider-specific session conversation grammar into plugin-owned session-key surfaces, preserving Telegram topic routing and Feishu scoped inheritance across bootstrap, model override, restart, and tool-policy paths.
|
||||
|
||||
### Fixes
|
||||
|
||||
- Chat/error replies: stop leaking raw provider/runtime failures into external chat channels, return a friendly retry message instead, and add a specific `/new` hint for Bedrock toolResult/toolUse session mismatches. (#58831) Thanks @ImLukeF.
|
||||
- Sessions/model switching: keep `/model` changes queued behind busy runs instead of interrupting the active turn, and retarget queued followups so later work picks up the new model as soon as the current turn finishes.
|
||||
- Web UI/OpenResponses: preserve rewritten stream snapshots in webchat and keep OpenResponses final streamed text aligned when models rewind earlier output. (#58641) Thanks @neeravmakwana
|
||||
- Discord/inbound media: pass Discord attachment and sticker downloads through the shared idle-timeout and worker-abort path so slow or stuck inbound media fetches stop hanging message processing. (#58593) Thanks @aquaright1
|
||||
- Telegram/retries: keep non-idempotent sends on the strict safe-send path, retry wrapped pre-connect failures, and preserve `429` / `retry_after` backoff for safe delivery retries. (#51895) Thanks @chinar-amrutkar
|
||||
- Telegram/exec approvals: route topic-aware exec approval followups through Telegram-owned threading and approval-target parsing, so forum-topic approvals stay in the originating topic instead of falling back to the root chat. (#58783)
|
||||
- Telegram/local Bot API: preserve media MIME types for absolute-path downloads so local audio files still trigger transcription and other MIME-based handling. (#54603) Thanks @jzakirov
|
||||
- Channels/WhatsApp: pass inbound message timestamp to model context so the AI can see when WhatsApp messages were sent. (#58590) Thanks @Maninae
|
||||
- QQBot/voice: lazy-load `silk-wasm` in `audio-convert.ts` so qqbot still starts when the optional voice dependency is missing, while voice encode/decode degrades gracefully instead of crashing at module load time. (#58829) Thanks @WideLee.
|
||||
|
||||
## 2026.4.1
|
||||
|
||||
- Plugins/runtime: stop ambient core helper and setup paths from loading non-selected bundled plugins, keep channel-setup snapshot scoping safe for custom channel plugins, and honor env-scoped plugin auth paths. (#59136) Thanks @vincentkoc.
|
||||
- Matrix/multi-account: keep room-level `account` scoping, inherited room overrides, and implicit account selection consistent across top-level default auth, named accounts, and cached-credential env setups. (#58449) thanks @Daanvdplas and @gumadeiras.
|
||||
@@ -36,70 +144,41 @@ Docs: https://docs.openclaw.ai
|
||||
- Matrix/streaming: preserve ordered block flushes before tool, message, and agent boundaries, add explicit `channels.matrix.blockStreaming` opt-in so Matrix `streaming: "off"` stays final-only by default, and move MiniMax plain-text final handling into the MiniMax provider runtime instead of the shared core heuristic. (#59266) thanks @gumadeiras
|
||||
- Agents/compaction: resolve compaction wait before final reply/channel flush completion so slow end-of-run delivery drains no longer delay compaction completion. (#59308) thanks @gumadeiras
|
||||
- Exec approvals: align approval UX, effective-policy reporting, and `allow-always` availability with the host policy so CLI, doctor, and approval surfaces explain the real host-effective decision path. (#59283) Thanks @gumadeiras.
|
||||
|
||||
## 2026.4.2
|
||||
|
||||
### Changes
|
||||
|
||||
- Tasks/chat: add `/tasks` as a chat-native background task board for the current session, with recent task details and agent-local fallback counts when no linked tasks are visible. Related #54226. Thanks @vincentkoc.
|
||||
- Web search/SearXNG: add the bundled SearXNG provider plugin for `web_search` with configurable host support. (#57317) Thanks @cgdusek.
|
||||
- Amazon Bedrock/Guardrails: add Bedrock Guardrails support to the bundled provider. (#58588) Thanks @MikeORed.
|
||||
- macOS/Voice Wake: add the Voice Wake option to trigger Talk Mode. (#58490) Thanks @SmoothExec.
|
||||
- Feishu/comments: add a dedicated Drive comment-event flow with comment-thread context resolution, in-thread replies, and `feishu_drive` comment actions for document collaboration workflows. (#58497) Thanks @wittam-01.
|
||||
- Gateway/webchat: make `chat.history` text truncation configurable with `gateway.webchat.chatHistoryMaxChars` and per-request `maxChars`, while preserving silent-reply filtering and existing default payload limits. (#58900)
|
||||
- Agents/default params: add `agents.defaults.params` for global default provider parameters. (#58548) Thanks @lpender.
|
||||
- Agents/failover: cap prompt-side and assistant-side same-provider auth-profile retries for rate-limit failures before cross-provider model fallback, add the `auth.cooldowns.rateLimitedProfileRotations` knob, and document the new fallback behavior. (#58707) Thanks @Forgely3D
|
||||
- Cron/tools allowlist: add `openclaw cron --tools` for per-job tool allowlists. (#58504) Thanks @andyk-ms.
|
||||
- Channels/session routing: move provider-specific session conversation grammar into plugin-owned session-key surfaces, preserving Telegram topic routing and Feishu scoped inheritance across bootstrap, model override, restart, and tool-policy paths.
|
||||
- WhatsApp/reactions: add `reactionLevel` guidance for agent reactions. Thanks @mcaxtr.
|
||||
- Telegram/errors: add configurable `errorPolicy` and `errorCooldownMs` controls so Telegram can suppress repeated delivery errors per account, chat, and topic without muting distinct failures. (#51914) Thanks @chinar-amrutkar
|
||||
- ZAI/models: add `glm-5.1` and `glm-5v-turbo` to the bundled Z.AI provider catalog. (#58793) Thanks @tomsun28
|
||||
- Agents/compaction: resolve `agents.defaults.compaction.model` consistently for manual `/compact` and other context-engine compaction paths, so engine-owned compaction uses the configured override model across runtime entrypoints. (#56710) Thanks @oliviareid-svg
|
||||
|
||||
### Fixes
|
||||
|
||||
- Chat/error replies: stop leaking raw provider/runtime failures into external chat channels, return a friendly retry message instead, and add a specific `/new` hint for Bedrock toolResult/toolUse session mismatches. (#58831) Thanks @ImLukeF.
|
||||
- Gateway/reload: ignore startup config writes by persisted hash in the config reloader so generated auth tokens and seeded Control UI origins do not trigger a restart loop, while real `gateway.auth.*` edits still require restart. (#58678) Thanks @yelog
|
||||
- Tasks/gateway: keep the task registry maintenance sweep from stalling the gateway event loop under synchronous SQLite pressure, so upgraded gateways stop hanging about a minute after startup. (#58670) Thanks @openperf
|
||||
- Config/Telegram: migrate removed `channels.telegram.groupMentionsOnly` into `channels.telegram.groups["*"].requireMention` on load so legacy configs no longer crash at startup. (#55336) thanks @jameslcowan.
|
||||
- Ollama/model picker: show only Ollama models after provider selection in the CLI picker. (#55290) Thanks @Luckymingxuan.
|
||||
- MiniMax/plugins: auto-enable the bundled MiniMax plugin for API-key auth/config so MiniMax image generation and other plugin-owned capabilities load without manual plugin allowlisting. (#57127) Thanks @tars90percent.
|
||||
- Plugins/bundled runtimes: restore externalized bundled plugin runtime dependency staging across packed installs, Docker builds, and local runtime staging so bundled plugins keep their declared runtime deps after the 2026.3.31 externalization change. (#58782)
|
||||
- LINE/runtime: resolve the packaged runtime contract from the built `dist/plugins/runtime` layout so LINE channels start correctly again after global npm installs on `2026.3.31`. (#58799) Thanks @vincentkoc.
|
||||
- Tasks/status: hide stale completed background tasks from `/status` and `session_status`, prefer live task context, and show recent failures only when no active work remains. (#58661) Thanks @vincentkoc
|
||||
- Tasks/gateway: keep the task registry maintenance sweep from stalling the gateway event loop under synchronous SQLite pressure, so upgraded gateways stop hanging about a minute after startup. (#58670) Thanks @openperf
|
||||
- Tasks/gateway: re-check the current task record before maintenance marks runs lost or prunes them, so a task heartbeat or cleanup update that lands during a sweep no longer gets overwritten by stale snapshot state.
|
||||
- Subagents/tasks: keep subagent completion and cleanup from crashing when task-registry writes fail, so a corrupt or missing task row no longer takes down the gateway during lifecycle finalization. Thanks @vincentkoc.
|
||||
- Gateway/reload: ignore startup config writes by persisted hash in the config reloader so generated auth tokens and seeded Control UI origins do not trigger a restart loop, while real `gateway.auth.*` edits still require restart. (#58678) Thanks @yelog
|
||||
- Exec/approvals: honor `exec-approvals.json` security defaults when inline or configured tool policy is unset, and keep Slack and Discord native approval handling aligned with inferred approvers and real channel enablement so remote exec stops falling into false approval timeouts and disabled states. Thanks @scoootscooob and @vincentkoc.
|
||||
- Exec/approvals: make `allow-always` persist as durable user-approved trust instead of behaving like `allow-once`, reuse exact-command trust on shell-wrapper paths that cannot safely persist an executable allowlist entry, keep static allowlist entries from silently bypassing `ask:"always"`, and require explicit approval when Windows cannot build an allowlist execution plan instead of hard-dead-ending remote exec. Thanks @scoootscooob and @vincentkoc.
|
||||
- Exec/cron: resolve isolated cron no-route approval dead-ends from the effective host fallback policy when trusted automation is allowed, and make `openclaw doctor` warn when `tools.exec` is broader than `~/.openclaw/exec-approvals.json` so stricter host-policy conflicts are explicit. Thanks @scoootscooob and @vincentkoc.
|
||||
- Sessions/model switching: keep `/model` changes queued behind busy runs instead of interrupting the active turn, and retarget queued followups so later work picks up the new model as soon as the current turn finishes.
|
||||
- Gateway/HTTP: skip failing HTTP request stages so one broken facade no longer forces every HTTP endpoint to return 500. (#58746) Thanks @yelog
|
||||
- Gateway/nodes: stop pinning live node commands to the approved node-pair record. Node pairing remains a trust/token flow, while per-node `system.run` policy stays in that node's exec approvals config. Fixes #58824.
|
||||
- WebChat/exec approvals: use native approval UI guidance in agent system prompts instead of telling agents to paste manual `/approve` commands in webchat sessions. Thanks @vincentkoc.
|
||||
- Web UI/OpenResponses: preserve rewritten stream snapshots in webchat and keep OpenResponses final streamed text aligned when models rewind earlier output. (#58641) Thanks @neeravmakwana
|
||||
- Discord/inbound media: pass Discord attachment and sticker downloads through the shared idle-timeout and worker-abort path so slow or stuck inbound media fetches stop hanging message processing. (#58593) Thanks @aquaright1
|
||||
- Telegram/retries: keep non-idempotent sends on the strict safe-send path, retry wrapped pre-connect failures, and preserve `429` / `retry_after` backoff for safe delivery retries. (#51895) Thanks @chinar-amrutkar
|
||||
- Telegram/exec approvals: route topic-aware exec approval followups through Telegram-owned threading and approval-target parsing, so forum-topic approvals stay in the originating topic instead of falling back to the root chat. (#58783)
|
||||
- Telegram/local Bot API: preserve media MIME types for absolute-path downloads so local audio files still trigger transcription and other MIME-based handling. (#54603) Thanks @jzakirov
|
||||
- Channels/WhatsApp: pass inbound message timestamp to model context so the AI can see when WhatsApp messages were sent. (#58590) Thanks @Maninae
|
||||
- Channels/QQ Bot: keep `/bot-logs` export gated behind a truly explicit QQBot allowlist, rejecting wildcard and mixed wildcard entries while preserving the real framework command path. Thanks @vincentkoc.
|
||||
- Channels/plugins: keep bundled channel plugins loadable from legacy `channels.<id>` config even under restrictive plugin allowlists, and make `openclaw doctor` warn only on real plugin blockers instead of misleading setup guidance. (#58873) Thanks @obviyus
|
||||
- Plugins/bundled runtimes: restore externalized bundled plugin runtime dependency staging across packed installs, Docker builds, and local runtime staging so bundled plugins keep their declared runtime deps after the 2026.3.31 externalization change. (#58782)
|
||||
- LINE/runtime: resolve the packaged runtime contract from the built `dist/plugins/runtime` layout so LINE channels start correctly again after global npm installs on `2026.3.31`. (#58799) Thanks @vincentkoc.
|
||||
- MiniMax/plugins: auto-enable the bundled MiniMax plugin for API-key auth/config so MiniMax image generation and other plugin-owned capabilities load without manual plugin allowlisting. (#57127) Thanks @tars90percent.
|
||||
- Ollama/model picker: show only Ollama models after provider selection in the CLI picker. (#55290) Thanks @Luckymingxuan.
|
||||
- CDP/profiles: prefer `cdpPort` over stale WebSocket URLs so browser automation reconnects cleanly. (#58499) Thanks @Mlightsnow.
|
||||
- Media/paths: resolve relative `MEDIA` paths against the agent workspace so local attachment references keep working. (#58624) Thanks @aquaright1.
|
||||
- Memory/session indexing: keep full reindexes from skipping session transcripts when sync is triggered by `session-start` or `watch`, so restart-driven reindexes preserve session memory. (#39732) Thanks @upupc
|
||||
- Memory/QMD: prefer `--mask` over `--glob` when creating QMD collections so default memory collections keep their intended patterns and stop colliding on restart. (#58643) Thanks @GitZhangChi.
|
||||
- Subagents/tasks: keep subagent completion and cleanup from crashing when task-registry writes fail, so a corrupt or missing task row no longer takes down the gateway during lifecycle finalization. Thanks @vincentkoc.
|
||||
- Sandbox/browser: compare browser runtime inspection against `agents.defaults.sandbox.browser.image` so `openclaw sandbox list --browser` stops reporting healthy browser containers as image mismatches. (#58759) Thanks @sandpile.
|
||||
- Plugins/install: forward `--dangerously-force-unsafe-install` through archive and npm-spec plugin installs so the documented override reaches the security scanner on those install paths. (#58879) Thanks @ryanlee-gemini.
|
||||
- Auto-reply/commands: strip inbound metadata before slash command detection so wrapped `/model`, `/new`, and `/status` commands are recognized. (#58725) Thanks @Mlightsnow.
|
||||
- Agents/Anthropic: preserve thinking blocks and signatures across replay, cache-control patching, and context pruning so compacted Anthropic sessions continue working instead of failing on later turns. (#58916) Thanks @obviyus
|
||||
- Agents/Anthropic: recover cleanly after a crash leaves the latest assistant turn with incomplete thinking blocks, dropping or retrying the corrupted turn instead of getting stuck on later Anthropic requests. Thanks @explainanalyze. Maintainer refresh: vincentkoc.
|
||||
- Agents/failover: unify structured and raw provider error classification so provider-specific `400`/`422` payloads no longer get forced into generic format failures before retry, billing, or compaction logic can inspect them. (#58856) Thanks @aaron-he-zhu.
|
||||
- Auth profiles/store: coerce misplaced SecretRef objects out of plaintext `key` and `token` fields during store load so agents without ACP runtime stop crashing on `.trim()` after upgrade. (#58923) Thanks @openperf.
|
||||
- ACPX/runtime: repair `queue owner unavailable` session recovery by replacing dead named sessions and resuming the backend session when ACPX exposes a stable session id, so the first ACP prompt no longer inherits a dead handle. (#58669) Thanks @neeravmakwana
|
||||
- ACPX/runtime: retry dead-session queue-owner repair without `--resume-session` when the reported ACPX session id is stale, so recovery still creates a fresh named session instead of failing session init. Thanks @obviyus.
|
||||
- Auth/OpenAI Codex: persist plugin-refreshed OAuth credentials to `auth-profiles.json` before returning them, so rotated Codex refresh tokens survive restart and stop falling into `refresh_token_reused` loops. (#53082)
|
||||
- Agents/Anthropic: honor explicit `cacheRetention` for custom providers using `anthropic-messages`, so Anthropic-compatible proxy providers can reuse prompt caching when they opt in. (#59049) Thanks @wwerst and @vincentkoc.
|
||||
- Discord/gateway: hand reconnect ownership back to Carbon, keep runtime status aligned with close/reconnect state, and force-stop sockets that open without reaching READY so Discord monitors recover promptly instead of waiting on stale health timeouts. (#59019) Thanks @obviyus
|
||||
- Config/Telegram: migrate removed `channels.telegram.groupMentionsOnly` into `channels.telegram.groups["*"].requireMention` on load so legacy configs no longer crash at startup. (#55336) thanks @jameslcowan.
|
||||
|
||||
### Fixes
|
||||
- Control UI/build: stop `pnpm ui:build` from reinstalling the UI with production-only dependencies, so fresh self-healing UI builds keep `vite` available instead of failing before asset generation. (#59267) Thanks @juliabush.
|
||||
|
||||
## 2026.3.31
|
||||
|
||||
@@ -116,6 +195,7 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
- ACP/plugins: add an explicit default-off ACPX plugin-tools MCP bridge config, document the trust boundary, and harden the built-in bridge packaging/logging path so global installs and stdio MCP sessions work reliably. (#56867) Thanks @joe2643.
|
||||
- Agents/LLM: add a configurable idle-stream timeout for embedded runner requests so stalled model streams abort cleanly instead of hanging until the broader run timeout fires. (#55072) Thanks @liuy.
|
||||
- Docs/plugins: update the community wecom and qqbot plugin listing to the docs catalog. (#57641) Thanks @sliverp.
|
||||
- Agents/MCP: materialize bundle MCP tools with provider-safe names (`serverName__toolName`), support optional `streamable-http` transport selection plus per-server connection timeouts, and preserve real tool results from aborted/error turns unless truncation explicitly drops them. (#49505) Thanks @ziomancer.
|
||||
- Android/notifications: add notification-forwarding controls with package filtering, quiet hours, rate limiting, and safer picker behavior for forwarded notification events. (#40175) Thanks @nimbleenigma.
|
||||
- Background tasks: turn tasks into a real shared background-run control plane instead of ACP-only bookkeeping by unifying ACP, subagent, cron, and background CLI execution under one SQLite-backed ledger, routing detached lifecycle updates through the executor seam, adding audit/maintenance/status visibility, tightening auto-cleanup and lost-run recovery, improving task awareness in internal status/tool surfaces, and clarifying the split between heartbeat/main-session automation and detached scheduled runs. Thanks @mbelinky and @vincentkoc.
|
||||
@@ -149,6 +229,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Slack: stop retry-driven duplicate replies when draft-finalization edits fail ambiguously, and log configured allowlisted users/channels by readable name instead of raw IDs.
|
||||
- Agents/OpenAI Responses: normalize raw bundled MCP tool schemas on the WebSocket/Responses path so bare-object, object-ish, and top-level union MCP tools no longer get rejected by OpenAI during tool registration. (#58299) Thanks @yelog.
|
||||
- ACP/security: replace ACP's dangerous-tool name override with semantic approval classes, so only narrow readonly reads/searches can auto-approve while indirect exec-capable and control-plane tools always require explicit prompt approval. Thanks @vincentkoc.
|
||||
- ACP: derive owner-only approval classes from the shared tool-policy fallback map so `cron`, `nodes`, and `whatsapp_login` cannot drift out of prompt-required coverage.
|
||||
- ACP/sessions_spawn: register ACP child runs for completion tracking and lifecycle cleanup, and make registration-failure cleanup explicitly best-effort so callers do not assume an already-started ACP turn was fully aborted. (#40885) Thanks @xaeon2026 and @vincentkoc.
|
||||
- ACP/tasks: mark cleanly exited ACP runs as blocked when they end on deterministic write or authorization blockers, and wake the parent session with a follow-up instead of falsely reporting success.
|
||||
- ACPX/runtime: derive the bundled ACPX expected version from the extension package metadata instead of hardcoding a separate literal, so plugin-local ACPX installs stop drifting out of health-check parity after version bumps. (#49089) Thanks @jiejiesks and @vincentkoc.
|
||||
@@ -1031,6 +1112,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Exec: harden host env override handling across gateway and node (#51207) Thanks @gladiator9797 and @joshavant.
|
||||
- Voice Call: enforce spoken-output contract and fix stream TTS silence regression (#51500) Thanks @joshavant.
|
||||
- xAI/models: rename the bundled Grok 4.20 catalog entries to the GA IDs and normalize saved deprecated beta IDs at runtime so existing configs and sessions keep resolving. (#50772) thanks @Jaaneek
|
||||
- WhatsApp/outbound media: fix HTML, XML, and CSS files being silently dropped on outbound send by adding missing MIME entries and falling back to `application/octet-stream` for unknown media types. (#51562) Thanks @bobbyt74
|
||||
- Agents/bootstrap warnings: move bootstrap truncation warnings out of the system prompt and into the per-turn prompt body so prompt-cache reuse stays stable when truncation warnings appear or disappear. (#48753) Thanks @scoootscooob and @obviyus.
|
||||
- Telegram/DM topic session keys: route named-account DM topics through the same per-account base session key across inbound messages, native commands, and session-state lookups so `/status` and thread recovery stop creating phantom `agent:main:main:thread:...` sessions. (#48204) Thanks @vincentkoc.
|
||||
- ACP/configured bindings: reinitialize configured ACP sessions that are stuck in `error` state instead of reusing the failed runtime.
|
||||
|
||||
@@ -65,8 +65,8 @@ android {
|
||||
applicationId = "ai.openclaw.app"
|
||||
minSdk = 31
|
||||
targetSdk = 36
|
||||
versionCode = 2026040101
|
||||
versionName = "2026.4.2"
|
||||
versionCode = 2026040201
|
||||
versionName = "2026.4.2-beta.1"
|
||||
ndk {
|
||||
// Support all major ABIs — native libs are tiny (~47 KB per ABI)
|
||||
abiFilters += listOf("armeabi-v7a", "arm64-v8a", "x86", "x86_64")
|
||||
|
||||
@@ -76,10 +76,17 @@
|
||||
android:exported="true"
|
||||
android:windowSoftInputMode="adjustResize"
|
||||
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|uiMode|density|keyboard|keyboardHidden|navigation">
|
||||
<meta-data
|
||||
android:name="android.app.shortcuts"
|
||||
android:resource="@xml/shortcuts" />
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.ASSIST" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
</manifest>
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
package ai.openclaw.app
|
||||
|
||||
import android.content.Intent
|
||||
|
||||
const val actionAskOpenClaw = "ai.openclaw.app.action.ASK_OPENCLAW"
|
||||
const val extraAssistantPrompt = "prompt"
|
||||
|
||||
enum class HomeDestination {
|
||||
Connect,
|
||||
Chat,
|
||||
Voice,
|
||||
Screen,
|
||||
Settings,
|
||||
}
|
||||
|
||||
data class AssistantLaunchRequest(
|
||||
val source: String,
|
||||
val prompt: String?,
|
||||
val autoSend: Boolean,
|
||||
)
|
||||
|
||||
fun parseAssistantLaunchIntent(intent: Intent?): AssistantLaunchRequest? {
|
||||
val action = intent?.action ?: return null
|
||||
return when (action) {
|
||||
Intent.ACTION_ASSIST ->
|
||||
AssistantLaunchRequest(
|
||||
source = "assist",
|
||||
prompt = null,
|
||||
autoSend = false,
|
||||
)
|
||||
|
||||
actionAskOpenClaw -> {
|
||||
val prompt = intent.getStringExtra(extraAssistantPrompt)?.trim()?.ifEmpty { null }
|
||||
AssistantLaunchRequest(
|
||||
source = "app_action",
|
||||
prompt = prompt,
|
||||
autoSend = prompt != null,
|
||||
)
|
||||
}
|
||||
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,7 @@ class MainActivity : ComponentActivity() {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
handleAssistantIntent(intent)
|
||||
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||
permissionRequester = PermissionRequester(this)
|
||||
|
||||
@@ -70,4 +71,15 @@ class MainActivity : ComponentActivity() {
|
||||
viewModel.setForeground(false)
|
||||
super.onStop()
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: android.content.Intent) {
|
||||
super.onNewIntent(intent)
|
||||
setIntent(intent)
|
||||
handleAssistantIntent(intent)
|
||||
}
|
||||
|
||||
private fun handleAssistantIntent(intent: android.content.Intent?) {
|
||||
val request = parseAssistantLaunchIntent(intent) ?: return
|
||||
viewModel.handleAssistantLaunch(request)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,12 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
|
||||
private val prefs = nodeApp.prefs
|
||||
private val runtimeRef = MutableStateFlow<NodeRuntime?>(null)
|
||||
private var foreground = true
|
||||
private val _requestedHomeDestination = MutableStateFlow<HomeDestination?>(null)
|
||||
val requestedHomeDestination: StateFlow<HomeDestination?> = _requestedHomeDestination
|
||||
private val _chatDraft = MutableStateFlow<String?>(null)
|
||||
val chatDraft: StateFlow<String?> = _chatDraft
|
||||
private val _pendingAssistantAutoSend = MutableStateFlow<String?>(null)
|
||||
val pendingAssistantAutoSend: StateFlow<String?> = _pendingAssistantAutoSend
|
||||
|
||||
private fun ensureRuntime(): NodeRuntime {
|
||||
runtimeRef.value?.let { return it }
|
||||
@@ -246,6 +252,29 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
|
||||
ensureRuntime().setVoiceScreenActive(active)
|
||||
}
|
||||
|
||||
fun handleAssistantLaunch(request: AssistantLaunchRequest) {
|
||||
_requestedHomeDestination.value = HomeDestination.Chat
|
||||
if (request.autoSend) {
|
||||
_pendingAssistantAutoSend.value = request.prompt
|
||||
_chatDraft.value = null
|
||||
return
|
||||
}
|
||||
_pendingAssistantAutoSend.value = null
|
||||
_chatDraft.value = request.prompt
|
||||
}
|
||||
|
||||
fun clearRequestedHomeDestination() {
|
||||
_requestedHomeDestination.value = null
|
||||
}
|
||||
|
||||
fun clearChatDraft() {
|
||||
_chatDraft.value = null
|
||||
}
|
||||
|
||||
fun clearPendingAssistantAutoSend() {
|
||||
_pendingAssistantAutoSend.value = null
|
||||
}
|
||||
|
||||
fun setMicEnabled(enabled: Boolean) {
|
||||
ensureRuntime().setMicEnabled(enabled)
|
||||
}
|
||||
@@ -337,4 +366,16 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
|
||||
fun sendChat(message: String, thinking: String, attachments: List<OutgoingAttachment>) {
|
||||
ensureRuntime().sendChat(message = message, thinking = thinking, attachments = attachments)
|
||||
}
|
||||
|
||||
suspend fun sendChatAwaitAcceptance(
|
||||
message: String,
|
||||
thinking: String,
|
||||
attachments: List<OutgoingAttachment>,
|
||||
): Boolean {
|
||||
return ensureRuntime().sendChatAwaitAcceptance(
|
||||
message = message,
|
||||
thinking = thinking,
|
||||
attachments = attachments,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1016,6 +1016,14 @@ class NodeRuntime(
|
||||
chat.sendMessage(message = message, thinkingLevel = thinking, attachments = attachments)
|
||||
}
|
||||
|
||||
suspend fun sendChatAwaitAcceptance(
|
||||
message: String,
|
||||
thinking: String,
|
||||
attachments: List<OutgoingAttachment>,
|
||||
): Boolean {
|
||||
return chat.sendMessageAwaitAcceptance(message = message, thinkingLevel = thinking, attachments = attachments)
|
||||
}
|
||||
|
||||
private fun handleGatewayEvent(event: String, payloadJson: String?) {
|
||||
micCapture.handleGatewayEvent(event, payloadJson)
|
||||
talkMode.handleGatewayEvent(event, payloadJson)
|
||||
|
||||
@@ -130,11 +130,25 @@ class ChatController(
|
||||
thinkingLevel: String,
|
||||
attachments: List<OutgoingAttachment>,
|
||||
) {
|
||||
scope.launch {
|
||||
sendMessageAwaitAcceptance(
|
||||
message = message,
|
||||
thinkingLevel = thinkingLevel,
|
||||
attachments = attachments,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun sendMessageAwaitAcceptance(
|
||||
message: String,
|
||||
thinkingLevel: String,
|
||||
attachments: List<OutgoingAttachment>,
|
||||
): Boolean {
|
||||
val trimmed = message.trim()
|
||||
if (trimmed.isEmpty() && attachments.isEmpty()) return
|
||||
if (trimmed.isEmpty() && attachments.isEmpty()) return false
|
||||
if (!_healthOk.value) {
|
||||
_errorText.value = "Gateway health not OK; cannot send"
|
||||
return
|
||||
return false
|
||||
}
|
||||
|
||||
val runId = UUID.randomUUID().toString()
|
||||
@@ -177,45 +191,45 @@ class ChatController(
|
||||
pendingToolCallsById.clear()
|
||||
publishPendingToolCalls()
|
||||
|
||||
scope.launch {
|
||||
try {
|
||||
val params =
|
||||
buildJsonObject {
|
||||
put("sessionKey", JsonPrimitive(sessionKey))
|
||||
put("message", JsonPrimitive(text))
|
||||
put("thinking", JsonPrimitive(thinking))
|
||||
put("timeoutMs", JsonPrimitive(30_000))
|
||||
put("idempotencyKey", JsonPrimitive(runId))
|
||||
if (attachments.isNotEmpty()) {
|
||||
put(
|
||||
"attachments",
|
||||
JsonArray(
|
||||
attachments.map { att ->
|
||||
buildJsonObject {
|
||||
put("type", JsonPrimitive(att.type))
|
||||
put("mimeType", JsonPrimitive(att.mimeType))
|
||||
put("fileName", JsonPrimitive(att.fileName))
|
||||
put("content", JsonPrimitive(att.base64))
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
val res = session.request("chat.send", params.toString())
|
||||
val actualRunId = parseRunId(res) ?: runId
|
||||
if (actualRunId != runId) {
|
||||
clearPendingRun(runId)
|
||||
armPendingRunTimeout(actualRunId)
|
||||
synchronized(pendingRuns) {
|
||||
pendingRuns.add(actualRunId)
|
||||
_pendingRunCount.value = pendingRuns.size
|
||||
return try {
|
||||
val params =
|
||||
buildJsonObject {
|
||||
put("sessionKey", JsonPrimitive(sessionKey))
|
||||
put("message", JsonPrimitive(text))
|
||||
put("thinking", JsonPrimitive(thinking))
|
||||
put("timeoutMs", JsonPrimitive(30_000))
|
||||
put("idempotencyKey", JsonPrimitive(runId))
|
||||
if (attachments.isNotEmpty()) {
|
||||
put(
|
||||
"attachments",
|
||||
JsonArray(
|
||||
attachments.map { att ->
|
||||
buildJsonObject {
|
||||
put("type", JsonPrimitive(att.type))
|
||||
put("mimeType", JsonPrimitive(att.mimeType))
|
||||
put("fileName", JsonPrimitive(att.fileName))
|
||||
put("content", JsonPrimitive(att.base64))
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
} catch (err: Throwable) {
|
||||
val res = session.request("chat.send", params.toString())
|
||||
val actualRunId = parseRunId(res) ?: runId
|
||||
if (actualRunId != runId) {
|
||||
clearPendingRun(runId)
|
||||
_errorText.value = err.message
|
||||
armPendingRunTimeout(actualRunId)
|
||||
synchronized(pendingRuns) {
|
||||
pendingRuns.add(actualRunId)
|
||||
_pendingRunCount.value = pendingRuns.size
|
||||
}
|
||||
}
|
||||
true
|
||||
} catch (err: Throwable) {
|
||||
clearPendingRun(runId)
|
||||
_errorText.value = err.message
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -46,6 +46,7 @@ import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import ai.openclaw.app.HomeDestination
|
||||
import ai.openclaw.app.MainViewModel
|
||||
|
||||
private enum class HomeTab(
|
||||
@@ -72,6 +73,20 @@ fun PostOnboardingTabs(viewModel: MainViewModel, modifier: Modifier = Modifier)
|
||||
var activeTab by rememberSaveable { mutableStateOf(HomeTab.Connect) }
|
||||
var chatTabStarted by rememberSaveable { mutableStateOf(false) }
|
||||
var screenTabStarted by rememberSaveable { mutableStateOf(false) }
|
||||
val requestedHomeDestination by viewModel.requestedHomeDestination.collectAsState()
|
||||
|
||||
LaunchedEffect(requestedHomeDestination) {
|
||||
val destination = requestedHomeDestination ?: return@LaunchedEffect
|
||||
activeTab =
|
||||
when (destination) {
|
||||
HomeDestination.Connect -> HomeTab.Connect
|
||||
HomeDestination.Chat -> HomeTab.Chat
|
||||
HomeDestination.Voice -> HomeTab.Voice
|
||||
HomeDestination.Screen -> HomeTab.Screen
|
||||
HomeDestination.Settings -> HomeTab.Settings
|
||||
}
|
||||
viewModel.clearRequestedHomeDestination()
|
||||
}
|
||||
|
||||
// Stop TTS when user navigates away from voice tab, and lazily keep the Chat/Screen tabs
|
||||
// alive after the first visit so repeated tab switches do not rebuild their UI trees.
|
||||
|
||||
@@ -9,6 +9,7 @@ import android.hardware.SensorManager
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.provider.Settings
|
||||
import android.app.role.RoleManager
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.background
|
||||
@@ -150,6 +151,8 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
||||
versionName
|
||||
}
|
||||
}
|
||||
var assistantRoleAvailable by remember(context) { mutableStateOf(isAssistantRoleAvailable(context)) }
|
||||
var assistantRoleHeld by remember(context) { mutableStateOf(isAssistantRoleHeld(context)) }
|
||||
val listItemColors =
|
||||
ListItemDefaults.colors(
|
||||
containerColor = Color.Transparent,
|
||||
@@ -326,6 +329,12 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
||||
viewModel.refreshGatewayConnection()
|
||||
}
|
||||
|
||||
val assistantRoleLauncher =
|
||||
rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) {
|
||||
assistantRoleAvailable = isAssistantRoleAvailable(context)
|
||||
assistantRoleHeld = isAssistantRoleHeld(context)
|
||||
}
|
||||
|
||||
DisposableEffect(lifecycleOwner, context) {
|
||||
val observer =
|
||||
LifecycleEventObserver { _, event ->
|
||||
@@ -362,6 +371,8 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
||||
||
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.READ_SMS) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
assistantRoleAvailable = isAssistantRoleAvailable(context)
|
||||
assistantRoleHeld = isAssistantRoleHeld(context)
|
||||
}
|
||||
}
|
||||
lifecycleOwner.lifecycle.addObserver(observer)
|
||||
@@ -478,6 +489,42 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
||||
color = mobileTextTertiary,
|
||||
)
|
||||
}
|
||||
if (assistantRoleAvailable) {
|
||||
HorizontalDivider(color = mobileBorder)
|
||||
ListItem(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = listItemColors,
|
||||
headlineContent = { Text("Default Assistant", style = mobileHeadline) },
|
||||
supportingContent = {
|
||||
Text(
|
||||
if (assistantRoleHeld) {
|
||||
"OpenClaw is registered as the device assistant."
|
||||
} else {
|
||||
"Let Android launch OpenClaw from the assistant gesture. Google Assistant App Actions still work separately."
|
||||
},
|
||||
style = mobileCallout,
|
||||
)
|
||||
},
|
||||
trailingContent = {
|
||||
Button(
|
||||
onClick = {
|
||||
assistantRoleLauncher.launch(
|
||||
context
|
||||
.getSystemService(RoleManager::class.java)
|
||||
.createRequestRoleIntent(RoleManager.ROLE_ASSISTANT),
|
||||
)
|
||||
},
|
||||
colors = settingsPrimaryButtonColors(),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
) {
|
||||
Text(
|
||||
if (assistantRoleHeld) "Manage" else "Enable",
|
||||
style = mobileCallout.copy(fontWeight = FontWeight.Bold),
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1294,3 +1341,11 @@ private fun hasMotionCapabilities(context: Context): Boolean {
|
||||
return sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER) != null ||
|
||||
sensorManager.getDefaultSensor(Sensor.TYPE_STEP_COUNTER) != null
|
||||
}
|
||||
|
||||
private fun isAssistantRoleAvailable(context: Context): Boolean {
|
||||
return context.getSystemService(RoleManager::class.java).isRoleAvailable(RoleManager.ROLE_ASSISTANT)
|
||||
}
|
||||
|
||||
private fun isAssistantRoleHeld(context: Context): Boolean {
|
||||
return context.getSystemService(RoleManager::class.java).isRoleHeld(RoleManager.ROLE_ASSISTANT)
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ import androidx.compose.material3.OutlinedTextFieldDefaults
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
@@ -59,12 +60,45 @@ import ai.openclaw.app.ui.mobileText
|
||||
import ai.openclaw.app.ui.mobileTextSecondary
|
||||
import ai.openclaw.app.ui.mobileTextTertiary
|
||||
|
||||
internal data class DraftApplication(
|
||||
val input: String,
|
||||
val lastAppliedDraft: String?,
|
||||
val consumed: Boolean,
|
||||
)
|
||||
|
||||
internal fun applyDraftText(
|
||||
draftText: String?,
|
||||
currentInput: String,
|
||||
lastAppliedDraft: String?,
|
||||
): DraftApplication {
|
||||
val draft =
|
||||
draftText?.trim()?.ifEmpty { null } ?: return DraftApplication(
|
||||
input = currentInput,
|
||||
lastAppliedDraft = null,
|
||||
consumed = false,
|
||||
)
|
||||
if (draft == lastAppliedDraft) {
|
||||
return DraftApplication(
|
||||
input = currentInput,
|
||||
lastAppliedDraft = lastAppliedDraft,
|
||||
consumed = false,
|
||||
)
|
||||
}
|
||||
return DraftApplication(
|
||||
input = draft,
|
||||
lastAppliedDraft = draft,
|
||||
consumed = true,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ChatComposer(
|
||||
draftText: String?,
|
||||
healthOk: Boolean,
|
||||
thinkingLevel: String,
|
||||
pendingRunCount: Int,
|
||||
attachments: List<PendingImageAttachment>,
|
||||
onDraftApplied: () -> Unit,
|
||||
onPickImages: () -> Unit,
|
||||
onRemoveAttachment: (id: String) -> Unit,
|
||||
onSetThinkingLevel: (level: String) -> Unit,
|
||||
@@ -73,8 +107,18 @@ fun ChatComposer(
|
||||
onSend: (text: String) -> Unit,
|
||||
) {
|
||||
var input by rememberSaveable { mutableStateOf("") }
|
||||
var lastAppliedDraft by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
var showThinkingMenu by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(draftText) {
|
||||
val next = applyDraftText(draftText = draftText, currentInput = input, lastAppliedDraft = lastAppliedDraft)
|
||||
input = next.input
|
||||
lastAppliedDraft = next.lastAppliedDraft
|
||||
if (next.consumed) {
|
||||
onDraftApplied()
|
||||
}
|
||||
}
|
||||
|
||||
val canSend = pendingRunCount == 0 && (input.trim().isNotEmpty() || attachments.isNotEmpty()) && healthOk
|
||||
val sendBusy = pendingRunCount > 0
|
||||
|
||||
|
||||
@@ -48,6 +48,31 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
internal fun resolvePendingAssistantAutoSend(
|
||||
pendingPrompt: String?,
|
||||
healthOk: Boolean,
|
||||
pendingRunCount: Int,
|
||||
): String? {
|
||||
val prompt = pendingPrompt?.trim()?.ifEmpty { null } ?: return null
|
||||
if (!healthOk || pendingRunCount > 0) return null
|
||||
return prompt
|
||||
}
|
||||
|
||||
internal suspend fun dispatchPendingAssistantAutoSend(
|
||||
pendingPrompt: String?,
|
||||
healthOk: Boolean,
|
||||
pendingRunCount: Int,
|
||||
dispatch: suspend (String) -> Boolean,
|
||||
): Boolean {
|
||||
val prompt =
|
||||
resolvePendingAssistantAutoSend(
|
||||
pendingPrompt = pendingPrompt,
|
||||
healthOk = healthOk,
|
||||
pendingRunCount = pendingRunCount,
|
||||
) ?: return false
|
||||
return dispatch(prompt)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ChatSheetContent(viewModel: MainViewModel) {
|
||||
val messages by viewModel.chatMessages.collectAsState()
|
||||
@@ -60,11 +85,30 @@ fun ChatSheetContent(viewModel: MainViewModel) {
|
||||
val streamingAssistantText by viewModel.chatStreamingAssistantText.collectAsState()
|
||||
val pendingToolCalls by viewModel.chatPendingToolCalls.collectAsState()
|
||||
val sessions by viewModel.chatSessions.collectAsState()
|
||||
val chatDraft by viewModel.chatDraft.collectAsState()
|
||||
val pendingAssistantAutoSend by viewModel.pendingAssistantAutoSend.collectAsState()
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.loadChat(mainSessionKey)
|
||||
}
|
||||
|
||||
LaunchedEffect(pendingAssistantAutoSend, healthOk, pendingRunCount, thinkingLevel) {
|
||||
val accepted =
|
||||
dispatchPendingAssistantAutoSend(
|
||||
pendingPrompt = pendingAssistantAutoSend,
|
||||
healthOk = healthOk,
|
||||
pendingRunCount = pendingRunCount,
|
||||
) { prompt ->
|
||||
viewModel.sendChatAwaitAcceptance(
|
||||
message = prompt,
|
||||
thinking = thinkingLevel,
|
||||
attachments = emptyList(),
|
||||
)
|
||||
}
|
||||
if (!accepted) return@LaunchedEffect
|
||||
viewModel.clearPendingAssistantAutoSend()
|
||||
}
|
||||
|
||||
val context = LocalContext.current
|
||||
val resolver = context.contentResolver
|
||||
val scope = rememberCoroutineScope()
|
||||
@@ -118,10 +162,12 @@ fun ChatSheetContent(viewModel: MainViewModel) {
|
||||
|
||||
Row(modifier = Modifier.fillMaxWidth().imePadding()) {
|
||||
ChatComposer(
|
||||
draftText = chatDraft,
|
||||
healthOk = healthOk,
|
||||
thinkingLevel = thinkingLevel,
|
||||
pendingRunCount = pendingRunCount,
|
||||
attachments = attachments,
|
||||
onDraftApplied = viewModel::clearChatDraft,
|
||||
onPickImages = { pickImages.launch("image/*") },
|
||||
onRemoveAttachment = { id -> attachments.removeAll { it.id == id } },
|
||||
onSetThinkingLevel = { level -> viewModel.setChatThinkingLevel(level) },
|
||||
|
||||
7
apps/android/app/src/main/res/values/assistant.xml
Normal file
7
apps/android/app/src/main/res/values/assistant.xml
Normal file
@@ -0,0 +1,7 @@
|
||||
<resources>
|
||||
<string-array name="ask_openclaw_query_patterns">
|
||||
<item>ask OpenClaw $prompt</item>
|
||||
<item>tell OpenClaw to $prompt</item>
|
||||
<item>open OpenClaw and ask $prompt</item>
|
||||
</string-array>
|
||||
</resources>
|
||||
17
apps/android/app/src/main/res/xml/shortcuts.xml
Normal file
17
apps/android/app/src/main/res/xml/shortcuts.xml
Normal file
@@ -0,0 +1,17 @@
|
||||
<shortcuts xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto">
|
||||
<capability
|
||||
android:name="custom.actions.intent.ASK_OPENCLAW"
|
||||
app:queryPatterns="@array/ask_openclaw_query_patterns">
|
||||
<intent
|
||||
android:action="ai.openclaw.app.action.ASK_OPENCLAW"
|
||||
android:targetPackage="ai.openclaw.app"
|
||||
android:targetClass="ai.openclaw.app.MainActivity">
|
||||
<parameter
|
||||
android:name="prompt"
|
||||
android:key="prompt"
|
||||
android:mimeType="text/*"
|
||||
android:required="true" />
|
||||
</intent>
|
||||
</capability>
|
||||
</shortcuts>
|
||||
@@ -0,0 +1,43 @@
|
||||
package ai.openclaw.app
|
||||
|
||||
import android.content.Intent
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34])
|
||||
class AssistantLaunchTest {
|
||||
@Test
|
||||
fun parsesAssistGestureIntent() {
|
||||
val parsed = parseAssistantLaunchIntent(Intent(Intent.ACTION_ASSIST))
|
||||
|
||||
requireNotNull(parsed)
|
||||
assertEquals("assist", parsed.source)
|
||||
assertNull(parsed.prompt)
|
||||
assertFalse(parsed.autoSend)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parsesAppActionPrompt() {
|
||||
val parsed =
|
||||
parseAssistantLaunchIntent(
|
||||
Intent(actionAskOpenClaw).putExtra(extraAssistantPrompt, " summarize my unread texts "),
|
||||
)
|
||||
|
||||
requireNotNull(parsed)
|
||||
assertEquals("app_action", parsed.source)
|
||||
assertEquals("summarize my unread texts", parsed.prompt)
|
||||
assertTrue(parsed.autoSend)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun ignoresUnrelatedIntents() {
|
||||
assertNull(parseAssistantLaunchIntent(Intent(Intent.ACTION_VIEW)))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package ai.openclaw.app.ui.chat
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class ChatComposerDraftTest {
|
||||
@Test
|
||||
fun clearsLastAppliedDraftWhenViewModelDraftResets() {
|
||||
val consumed =
|
||||
applyDraftText(
|
||||
draftText = "repeat this",
|
||||
currentInput = "",
|
||||
lastAppliedDraft = null,
|
||||
)
|
||||
|
||||
assertTrue(consumed.consumed)
|
||||
assertEquals("repeat this", consumed.input)
|
||||
assertEquals("repeat this", consumed.lastAppliedDraft)
|
||||
|
||||
val cleared =
|
||||
applyDraftText(
|
||||
draftText = null,
|
||||
currentInput = consumed.input,
|
||||
lastAppliedDraft = consumed.lastAppliedDraft,
|
||||
)
|
||||
|
||||
assertFalse(cleared.consumed)
|
||||
assertEquals("repeat this", cleared.input)
|
||||
assertEquals(null, cleared.lastAppliedDraft)
|
||||
|
||||
val repeated =
|
||||
applyDraftText(
|
||||
draftText = "repeat this",
|
||||
currentInput = cleared.input,
|
||||
lastAppliedDraft = cleared.lastAppliedDraft,
|
||||
)
|
||||
|
||||
assertTrue(repeated.consumed)
|
||||
assertEquals("repeat this", repeated.input)
|
||||
assertEquals("repeat this", repeated.lastAppliedDraft)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package ai.openclaw.app.ui.chat
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import kotlinx.coroutines.runBlocking
|
||||
|
||||
class ChatSheetContentTest {
|
||||
@Test
|
||||
fun resolvesPendingAssistantAutoSendOnlyWhenChatIsReady() {
|
||||
assertNull(
|
||||
resolvePendingAssistantAutoSend(
|
||||
pendingPrompt = "summarize mail",
|
||||
healthOk = false,
|
||||
pendingRunCount = 0,
|
||||
),
|
||||
)
|
||||
assertNull(
|
||||
resolvePendingAssistantAutoSend(
|
||||
pendingPrompt = "summarize mail",
|
||||
healthOk = true,
|
||||
pendingRunCount = 1,
|
||||
),
|
||||
)
|
||||
assertEquals(
|
||||
"summarize mail",
|
||||
resolvePendingAssistantAutoSend(
|
||||
pendingPrompt = " summarize mail ",
|
||||
healthOk = true,
|
||||
pendingRunCount = 0,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun keepsPendingAssistantAutoSendWhenDispatchRejected() = runBlocking {
|
||||
var dispatchedPrompt: String? = null
|
||||
|
||||
val consumed =
|
||||
dispatchPendingAssistantAutoSend(
|
||||
pendingPrompt = "summarize mail",
|
||||
healthOk = true,
|
||||
pendingRunCount = 0,
|
||||
) { prompt ->
|
||||
dispatchedPrompt = prompt
|
||||
false
|
||||
}
|
||||
|
||||
assertFalse(consumed)
|
||||
assertEquals("summarize mail", dispatchedPrompt)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun clearsPendingAssistantAutoSendOnlyAfterAcceptedDispatch() = runBlocking {
|
||||
var dispatchedPrompt: String? = null
|
||||
|
||||
val consumed =
|
||||
dispatchPendingAssistantAutoSend(
|
||||
pendingPrompt = "summarize mail",
|
||||
healthOk = true,
|
||||
pendingRunCount = 0,
|
||||
) { prompt ->
|
||||
dispatchedPrompt = prompt
|
||||
true
|
||||
}
|
||||
|
||||
assertTrue(consumed)
|
||||
assertEquals("summarize mail", dispatchedPrompt)
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
// Shared iOS version defaults.
|
||||
// Generated overrides live in build/Version.xcconfig (git-ignored).
|
||||
|
||||
OPENCLAW_GATEWAY_VERSION = 2026.4.2
|
||||
OPENCLAW_GATEWAY_VERSION = 2026.4.2-beta.1
|
||||
OPENCLAW_MARKETING_VERSION = 2026.4.2
|
||||
OPENCLAW_BUILD_VERSION = 2026040101
|
||||
OPENCLAW_BUILD_VERSION = 2026040201
|
||||
|
||||
#include? "../build/Version.xcconfig"
|
||||
|
||||
@@ -88,6 +88,7 @@ enum HostEnvSecurityPolicy {
|
||||
"PHP_INI_SCAN_DIR",
|
||||
"DENO_DIR",
|
||||
"BUN_CONFIG_REGISTRY",
|
||||
"YARN_RC_FILENAME",
|
||||
"HTTP_PROXY",
|
||||
"HTTPS_PROXY",
|
||||
"ALL_PROXY",
|
||||
@@ -146,7 +147,8 @@ enum HostEnvSecurityPolicy {
|
||||
|
||||
static let blockedOverridePrefixes: [String] = [
|
||||
"GIT_CONFIG_",
|
||||
"NPM_CONFIG_"
|
||||
"NPM_CONFIG_",
|
||||
"CARGO_REGISTRIES_"
|
||||
]
|
||||
|
||||
static let blockedPrefixes: [String] = [
|
||||
|
||||
@@ -15,9 +15,9 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>2026.4.2</string>
|
||||
<string>2026.4.2-beta.1</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>2026040101</string>
|
||||
<string>2026040201</string>
|
||||
<key>CFBundleIconFile</key>
|
||||
<string>OpenClaw</string>
|
||||
<key>CFBundleURLTypes</key>
|
||||
|
||||
@@ -1086,6 +1086,20 @@
|
||||
"help": "Optional provider/model override used only for compaction summarization. Set this when you want compaction to run on a different model than the session default, and leave it unset to keep using the primary agent model.",
|
||||
"hasChildren": false
|
||||
},
|
||||
{
|
||||
"path": "agents.defaults.compaction.notifyUser",
|
||||
"kind": "core",
|
||||
"type": "boolean",
|
||||
"required": false,
|
||||
"deprecated": false,
|
||||
"sensitive": false,
|
||||
"tags": [
|
||||
"advanced"
|
||||
],
|
||||
"label": "Compaction Notify User",
|
||||
"help": "When enabled, sends a brief compaction notice to the user (e.g. '🧹 Compacting context...') when compaction starts. Disabled by default to keep compaction silent and non-intrusive.",
|
||||
"hasChildren": false
|
||||
},
|
||||
{
|
||||
"path": "agents.defaults.compaction.postCompactionSections",
|
||||
"kind": "core",
|
||||
@@ -2200,16 +2214,6 @@
|
||||
"tags": [],
|
||||
"hasChildren": false
|
||||
},
|
||||
{
|
||||
"path": "agents.defaults.memorySearch.notifyUser",
|
||||
"kind": "core",
|
||||
"type": "boolean",
|
||||
"required": false,
|
||||
"deprecated": false,
|
||||
"sensitive": false,
|
||||
"tags": [],
|
||||
"hasChildren": false
|
||||
},
|
||||
{
|
||||
"path": "agents.defaults.memorySearch.outputDimensionality",
|
||||
"kind": "core",
|
||||
@@ -10209,10 +10213,7 @@
|
||||
{
|
||||
"path": "channels.discord.accounts.*.allowFrom.*",
|
||||
"kind": "channel",
|
||||
"type": [
|
||||
"number",
|
||||
"string"
|
||||
],
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"deprecated": false,
|
||||
"sensitive": false,
|
||||
@@ -10462,10 +10463,7 @@
|
||||
{
|
||||
"path": "channels.discord.accounts.*.dm.allowFrom.*",
|
||||
"kind": "channel",
|
||||
"type": [
|
||||
"number",
|
||||
"string"
|
||||
],
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"deprecated": false,
|
||||
"sensitive": false,
|
||||
@@ -10495,10 +10493,7 @@
|
||||
{
|
||||
"path": "channels.discord.accounts.*.dm.groupChannels.*",
|
||||
"kind": "channel",
|
||||
"type": [
|
||||
"number",
|
||||
"string"
|
||||
],
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"deprecated": false,
|
||||
"sensitive": false,
|
||||
@@ -10720,10 +10715,7 @@
|
||||
{
|
||||
"path": "channels.discord.accounts.*.execApprovals.approvers.*",
|
||||
"kind": "channel",
|
||||
"type": [
|
||||
"number",
|
||||
"string"
|
||||
],
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"deprecated": false,
|
||||
"sensitive": false,
|
||||
@@ -10947,10 +10939,7 @@
|
||||
{
|
||||
"path": "channels.discord.accounts.*.guilds.*.channels.*.roles.*",
|
||||
"kind": "channel",
|
||||
"type": [
|
||||
"number",
|
||||
"string"
|
||||
],
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"deprecated": false,
|
||||
"sensitive": false,
|
||||
@@ -11150,10 +11139,7 @@
|
||||
{
|
||||
"path": "channels.discord.accounts.*.guilds.*.channels.*.users.*",
|
||||
"kind": "channel",
|
||||
"type": [
|
||||
"number",
|
||||
"string"
|
||||
],
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"deprecated": false,
|
||||
"sensitive": false,
|
||||
@@ -11209,10 +11195,7 @@
|
||||
{
|
||||
"path": "channels.discord.accounts.*.guilds.*.roles.*",
|
||||
"kind": "channel",
|
||||
"type": [
|
||||
"number",
|
||||
"string"
|
||||
],
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"deprecated": false,
|
||||
"sensitive": false,
|
||||
@@ -11392,10 +11375,7 @@
|
||||
{
|
||||
"path": "channels.discord.accounts.*.guilds.*.users.*",
|
||||
"kind": "channel",
|
||||
"type": [
|
||||
"number",
|
||||
"string"
|
||||
],
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"deprecated": false,
|
||||
"sensitive": false,
|
||||
@@ -12635,10 +12615,7 @@
|
||||
{
|
||||
"path": "channels.discord.allowFrom.*",
|
||||
"kind": "channel",
|
||||
"type": [
|
||||
"number",
|
||||
"string"
|
||||
],
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"deprecated": false,
|
||||
"sensitive": false,
|
||||
@@ -12946,10 +12923,7 @@
|
||||
{
|
||||
"path": "channels.discord.dm.allowFrom.*",
|
||||
"kind": "channel",
|
||||
"type": [
|
||||
"number",
|
||||
"string"
|
||||
],
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"deprecated": false,
|
||||
"sensitive": false,
|
||||
@@ -12979,10 +12953,7 @@
|
||||
{
|
||||
"path": "channels.discord.dm.groupChannels.*",
|
||||
"kind": "channel",
|
||||
"type": [
|
||||
"number",
|
||||
"string"
|
||||
],
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"deprecated": false,
|
||||
"sensitive": false,
|
||||
@@ -13250,10 +13221,7 @@
|
||||
{
|
||||
"path": "channels.discord.execApprovals.approvers.*",
|
||||
"kind": "channel",
|
||||
"type": [
|
||||
"number",
|
||||
"string"
|
||||
],
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"deprecated": false,
|
||||
"sensitive": false,
|
||||
@@ -13477,10 +13445,7 @@
|
||||
{
|
||||
"path": "channels.discord.guilds.*.channels.*.roles.*",
|
||||
"kind": "channel",
|
||||
"type": [
|
||||
"number",
|
||||
"string"
|
||||
],
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"deprecated": false,
|
||||
"sensitive": false,
|
||||
@@ -13680,10 +13645,7 @@
|
||||
{
|
||||
"path": "channels.discord.guilds.*.channels.*.users.*",
|
||||
"kind": "channel",
|
||||
"type": [
|
||||
"number",
|
||||
"string"
|
||||
],
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"deprecated": false,
|
||||
"sensitive": false,
|
||||
@@ -13739,10 +13701,7 @@
|
||||
{
|
||||
"path": "channels.discord.guilds.*.roles.*",
|
||||
"kind": "channel",
|
||||
"type": [
|
||||
"number",
|
||||
"string"
|
||||
],
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"deprecated": false,
|
||||
"sensitive": false,
|
||||
@@ -13922,10 +13881,7 @@
|
||||
{
|
||||
"path": "channels.discord.guilds.*.users.*",
|
||||
"kind": "channel",
|
||||
"type": [
|
||||
"number",
|
||||
"string"
|
||||
],
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"deprecated": false,
|
||||
"sensitive": false,
|
||||
@@ -22274,6 +22230,16 @@
|
||||
"tags": [],
|
||||
"hasChildren": false
|
||||
},
|
||||
{
|
||||
"path": "channels.matrix.blockStreaming",
|
||||
"kind": "channel",
|
||||
"type": "boolean",
|
||||
"required": false,
|
||||
"deprecated": false,
|
||||
"sensitive": false,
|
||||
"tags": [],
|
||||
"hasChildren": false
|
||||
},
|
||||
{
|
||||
"path": "channels.matrix.chunkMode",
|
||||
"kind": "channel",
|
||||
@@ -22470,6 +22436,16 @@
|
||||
"tags": [],
|
||||
"hasChildren": true
|
||||
},
|
||||
{
|
||||
"path": "channels.matrix.groups.*.account",
|
||||
"kind": "channel",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"deprecated": false,
|
||||
"sensitive": false,
|
||||
"tags": [],
|
||||
"hasChildren": false
|
||||
},
|
||||
{
|
||||
"path": "channels.matrix.groups.*.allow",
|
||||
"kind": "channel",
|
||||
@@ -22839,6 +22815,16 @@
|
||||
"tags": [],
|
||||
"hasChildren": true
|
||||
},
|
||||
{
|
||||
"path": "channels.matrix.rooms.*.account",
|
||||
"kind": "channel",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"deprecated": false,
|
||||
"sensitive": false,
|
||||
"tags": [],
|
||||
"hasChildren": false
|
||||
},
|
||||
{
|
||||
"path": "channels.matrix.rooms.*.allow",
|
||||
"kind": "channel",
|
||||
@@ -31491,7 +31477,7 @@
|
||||
"network"
|
||||
],
|
||||
"label": "Slack Exec Approvals",
|
||||
"help": "Slack-native exec approval routing and approver authorization. Enable this only when Slack should act as an explicit exec-approval client for the selected workspace account.",
|
||||
"help": "Slack-native exec approval routing and approver authorization. When unset, OpenClaw auto-enables DM-first native approvals if approvers can be resolved for this workspace account.",
|
||||
"hasChildren": true
|
||||
},
|
||||
{
|
||||
@@ -31559,7 +31545,7 @@
|
||||
"network"
|
||||
],
|
||||
"label": "Slack Exec Approvals Enabled",
|
||||
"help": "Enable Slack exec approvals for this account. When false or unset, Slack messages/buttons cannot approve exec requests.",
|
||||
"help": "Controls Slack native exec approvals for this account: unset or \"auto\" enables DM-first native approvals when approvers can be resolved, true forces native approvals on, and false disables them.",
|
||||
"hasChildren": false
|
||||
},
|
||||
{
|
||||
@@ -35458,7 +35444,7 @@
|
||||
"network"
|
||||
],
|
||||
"label": "Telegram Exec Approvals",
|
||||
"help": "Telegram-native exec approval routing and approver authorization. Enable this only when Telegram should act as an explicit exec-approval client for the selected bot account.",
|
||||
"help": "Telegram-native exec approval routing and approver authorization. When unset, OpenClaw auto-enables DM-first native approvals if approvers can be resolved for the selected bot account.",
|
||||
"hasChildren": true
|
||||
},
|
||||
{
|
||||
@@ -35526,7 +35512,7 @@
|
||||
"network"
|
||||
],
|
||||
"label": "Telegram Exec Approvals Enabled",
|
||||
"help": "Enable Telegram exec approvals for this account. When false or unset, Telegram messages/buttons cannot approve exec requests.",
|
||||
"help": "Controls Telegram native exec approvals for this account: unset or \"auto\" enables DM-first native approvals when approvers can be resolved, true forces native approvals on, and false disables them.",
|
||||
"hasChildren": false
|
||||
},
|
||||
{
|
||||
@@ -49610,6 +49596,20 @@
|
||||
"help": "Allow non-loopback access to diff viewer URLs when the token path is known.",
|
||||
"hasChildren": false
|
||||
},
|
||||
{
|
||||
"path": "plugins.entries.diffs.config.viewerBaseUrl",
|
||||
"kind": "plugin",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"deprecated": false,
|
||||
"sensitive": false,
|
||||
"tags": [
|
||||
"advanced"
|
||||
],
|
||||
"label": "Viewer Base URL",
|
||||
"help": "Persistent gateway base URL used for returned viewer links when a tool call does not pass baseUrl.",
|
||||
"hasChildren": false
|
||||
},
|
||||
{
|
||||
"path": "plugins.entries.diffs.enabled",
|
||||
"kind": "plugin",
|
||||
@@ -50528,6 +50528,79 @@
|
||||
"help": "Plugin-defined config payload for firecrawl.",
|
||||
"hasChildren": true
|
||||
},
|
||||
{
|
||||
"path": "plugins.entries.firecrawl.config.webFetch",
|
||||
"kind": "plugin",
|
||||
"type": "object",
|
||||
"required": false,
|
||||
"deprecated": false,
|
||||
"sensitive": false,
|
||||
"tags": [],
|
||||
"hasChildren": true
|
||||
},
|
||||
{
|
||||
"path": "plugins.entries.firecrawl.config.webFetch.apiKey",
|
||||
"kind": "plugin",
|
||||
"type": [
|
||||
"object",
|
||||
"string"
|
||||
],
|
||||
"required": false,
|
||||
"deprecated": false,
|
||||
"sensitive": true,
|
||||
"tags": [
|
||||
"auth",
|
||||
"security"
|
||||
],
|
||||
"label": "Firecrawl Fetch API Key",
|
||||
"help": "Firecrawl API key for web fetch fallback (fallback: FIRECRAWL_API_KEY env var).",
|
||||
"hasChildren": false
|
||||
},
|
||||
{
|
||||
"path": "plugins.entries.firecrawl.config.webFetch.baseUrl",
|
||||
"kind": "plugin",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"deprecated": false,
|
||||
"sensitive": false,
|
||||
"tags": [
|
||||
"advanced",
|
||||
"url-secret"
|
||||
],
|
||||
"label": "Firecrawl Fetch Base URL",
|
||||
"help": "Firecrawl Fetch base URL override.",
|
||||
"hasChildren": false
|
||||
},
|
||||
{
|
||||
"path": "plugins.entries.firecrawl.config.webFetch.maxAgeMs",
|
||||
"kind": "plugin",
|
||||
"type": "number",
|
||||
"required": false,
|
||||
"deprecated": false,
|
||||
"sensitive": false,
|
||||
"tags": [],
|
||||
"hasChildren": false
|
||||
},
|
||||
{
|
||||
"path": "plugins.entries.firecrawl.config.webFetch.onlyMainContent",
|
||||
"kind": "plugin",
|
||||
"type": "boolean",
|
||||
"required": false,
|
||||
"deprecated": false,
|
||||
"sensitive": false,
|
||||
"tags": [],
|
||||
"hasChildren": false
|
||||
},
|
||||
{
|
||||
"path": "plugins.entries.firecrawl.config.webFetch.timeoutSeconds",
|
||||
"kind": "plugin",
|
||||
"type": "number",
|
||||
"required": false,
|
||||
"deprecated": false,
|
||||
"sensitive": false,
|
||||
"tags": [],
|
||||
"hasChildren": false
|
||||
},
|
||||
{
|
||||
"path": "plugins.entries.firecrawl.config.webSearch",
|
||||
"kind": "plugin",
|
||||
@@ -60102,6 +60175,101 @@
|
||||
"help": "Grok model override for web search.",
|
||||
"hasChildren": false
|
||||
},
|
||||
{
|
||||
"path": "plugins.entries.xai.config.xSearch",
|
||||
"kind": "plugin",
|
||||
"type": "object",
|
||||
"required": false,
|
||||
"deprecated": false,
|
||||
"sensitive": false,
|
||||
"tags": [],
|
||||
"hasChildren": true
|
||||
},
|
||||
{
|
||||
"path": "plugins.entries.xai.config.xSearch.cacheTtlMinutes",
|
||||
"kind": "plugin",
|
||||
"type": "number",
|
||||
"required": false,
|
||||
"deprecated": false,
|
||||
"sensitive": false,
|
||||
"tags": [
|
||||
"performance",
|
||||
"storage"
|
||||
],
|
||||
"label": "X Search Cache TTL",
|
||||
"help": "Cache TTL in minutes for x_search results.",
|
||||
"hasChildren": false
|
||||
},
|
||||
{
|
||||
"path": "plugins.entries.xai.config.xSearch.enabled",
|
||||
"kind": "plugin",
|
||||
"type": "boolean",
|
||||
"required": false,
|
||||
"deprecated": false,
|
||||
"sensitive": false,
|
||||
"tags": [
|
||||
"advanced"
|
||||
],
|
||||
"label": "Enable X Search",
|
||||
"help": "Enable the x_search tool for searching X posts with xAI.",
|
||||
"hasChildren": false
|
||||
},
|
||||
{
|
||||
"path": "plugins.entries.xai.config.xSearch.inlineCitations",
|
||||
"kind": "plugin",
|
||||
"type": "boolean",
|
||||
"required": false,
|
||||
"deprecated": false,
|
||||
"sensitive": false,
|
||||
"tags": [
|
||||
"advanced"
|
||||
],
|
||||
"label": "X Search Inline Citations",
|
||||
"help": "Keep inline markdown citations from xAI in x_search responses when available.",
|
||||
"hasChildren": false
|
||||
},
|
||||
{
|
||||
"path": "plugins.entries.xai.config.xSearch.maxTurns",
|
||||
"kind": "plugin",
|
||||
"type": "number",
|
||||
"required": false,
|
||||
"deprecated": false,
|
||||
"sensitive": false,
|
||||
"tags": [
|
||||
"performance"
|
||||
],
|
||||
"label": "X Search Max Turns",
|
||||
"help": "Optional max internal tool turns xAI may use per x_search request.",
|
||||
"hasChildren": false
|
||||
},
|
||||
{
|
||||
"path": "plugins.entries.xai.config.xSearch.model",
|
||||
"kind": "plugin",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"deprecated": false,
|
||||
"sensitive": false,
|
||||
"tags": [
|
||||
"models"
|
||||
],
|
||||
"label": "X Search Model",
|
||||
"help": "xAI model override for x_search.",
|
||||
"hasChildren": false
|
||||
},
|
||||
{
|
||||
"path": "plugins.entries.xai.config.xSearch.timeoutSeconds",
|
||||
"kind": "plugin",
|
||||
"type": "number",
|
||||
"required": false,
|
||||
"deprecated": false,
|
||||
"sensitive": false,
|
||||
"tags": [
|
||||
"performance"
|
||||
],
|
||||
"label": "X Search Timeout",
|
||||
"help": "Timeout in seconds for x_search requests.",
|
||||
"hasChildren": false
|
||||
},
|
||||
{
|
||||
"path": "plugins.entries.xai.enabled",
|
||||
"kind": "plugin",
|
||||
@@ -66733,8 +66901,6 @@
|
||||
"security",
|
||||
"tools"
|
||||
],
|
||||
"label": "Firecrawl API Key",
|
||||
"help": "Firecrawl API key (fallback: FIRECRAWL_API_KEY env var).",
|
||||
"hasChildren": true
|
||||
},
|
||||
{
|
||||
@@ -66778,8 +66944,6 @@
|
||||
"tools",
|
||||
"url-secret"
|
||||
],
|
||||
"label": "Firecrawl Base URL",
|
||||
"help": "Firecrawl base URL (e.g. https://api.firecrawl.dev or custom endpoint).",
|
||||
"hasChildren": false
|
||||
},
|
||||
{
|
||||
@@ -66789,11 +66953,7 @@
|
||||
"required": false,
|
||||
"deprecated": false,
|
||||
"sensitive": false,
|
||||
"tags": [
|
||||
"tools"
|
||||
],
|
||||
"label": "Enable Firecrawl Fallback",
|
||||
"help": "Enable Firecrawl fallback for web_fetch (if configured).",
|
||||
"tags": [],
|
||||
"hasChildren": false
|
||||
},
|
||||
{
|
||||
@@ -66803,12 +66963,7 @@
|
||||
"required": false,
|
||||
"deprecated": false,
|
||||
"sensitive": false,
|
||||
"tags": [
|
||||
"performance",
|
||||
"tools"
|
||||
],
|
||||
"label": "Firecrawl Cache Max Age (ms)",
|
||||
"help": "Firecrawl maxAge (ms) for cached results when supported by the API.",
|
||||
"tags": [],
|
||||
"hasChildren": false
|
||||
},
|
||||
{
|
||||
@@ -66818,11 +66973,7 @@
|
||||
"required": false,
|
||||
"deprecated": false,
|
||||
"sensitive": false,
|
||||
"tags": [
|
||||
"tools"
|
||||
],
|
||||
"label": "Firecrawl Main Content Only",
|
||||
"help": "When true, Firecrawl returns only the main content (default: true).",
|
||||
"tags": [],
|
||||
"hasChildren": false
|
||||
},
|
||||
{
|
||||
@@ -66832,12 +66983,7 @@
|
||||
"required": false,
|
||||
"deprecated": false,
|
||||
"sensitive": false,
|
||||
"tags": [
|
||||
"performance",
|
||||
"tools"
|
||||
],
|
||||
"label": "Firecrawl Timeout (sec)",
|
||||
"help": "Timeout in seconds for Firecrawl requests.",
|
||||
"tags": [],
|
||||
"hasChildren": false
|
||||
},
|
||||
{
|
||||
@@ -66901,6 +67047,20 @@
|
||||
"help": "Max download size before truncation.",
|
||||
"hasChildren": false
|
||||
},
|
||||
{
|
||||
"path": "tools.web.fetch.provider",
|
||||
"kind": "core",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"deprecated": false,
|
||||
"sensitive": false,
|
||||
"tags": [
|
||||
"tools"
|
||||
],
|
||||
"label": "Web Fetch Provider",
|
||||
"help": "Web fetch fallback provider id.",
|
||||
"hasChildren": false
|
||||
},
|
||||
{
|
||||
"path": "tools.web.fetch.readability",
|
||||
"kind": "core",
|
||||
@@ -67160,55 +67320,6 @@
|
||||
"tags": [],
|
||||
"hasChildren": true
|
||||
},
|
||||
{
|
||||
"path": "tools.web.x_search.apiKey",
|
||||
"kind": "core",
|
||||
"type": [
|
||||
"object",
|
||||
"string"
|
||||
],
|
||||
"required": false,
|
||||
"deprecated": false,
|
||||
"sensitive": true,
|
||||
"tags": [
|
||||
"auth",
|
||||
"security",
|
||||
"tools"
|
||||
],
|
||||
"label": "xAI API Key",
|
||||
"help": "xAI API key for X search (fallback: XAI_API_KEY env var).",
|
||||
"hasChildren": true
|
||||
},
|
||||
{
|
||||
"path": "tools.web.x_search.apiKey.id",
|
||||
"kind": "core",
|
||||
"type": "string",
|
||||
"required": true,
|
||||
"deprecated": false,
|
||||
"sensitive": false,
|
||||
"tags": [],
|
||||
"hasChildren": false
|
||||
},
|
||||
{
|
||||
"path": "tools.web.x_search.apiKey.provider",
|
||||
"kind": "core",
|
||||
"type": "string",
|
||||
"required": true,
|
||||
"deprecated": false,
|
||||
"sensitive": false,
|
||||
"tags": [],
|
||||
"hasChildren": false
|
||||
},
|
||||
{
|
||||
"path": "tools.web.x_search.apiKey.source",
|
||||
"kind": "core",
|
||||
"type": "string",
|
||||
"required": true,
|
||||
"deprecated": false,
|
||||
"sensitive": false,
|
||||
"tags": [],
|
||||
"hasChildren": false
|
||||
},
|
||||
{
|
||||
"path": "tools.web.x_search.cacheTtlMinutes",
|
||||
"kind": "core",
|
||||
@@ -67216,13 +67327,7 @@
|
||||
"required": false,
|
||||
"deprecated": false,
|
||||
"sensitive": false,
|
||||
"tags": [
|
||||
"performance",
|
||||
"storage",
|
||||
"tools"
|
||||
],
|
||||
"label": "X Search Cache TTL (min)",
|
||||
"help": "Cache TTL in minutes for x_search results.",
|
||||
"tags": [],
|
||||
"hasChildren": false
|
||||
},
|
||||
{
|
||||
@@ -67232,11 +67337,7 @@
|
||||
"required": false,
|
||||
"deprecated": false,
|
||||
"sensitive": false,
|
||||
"tags": [
|
||||
"tools"
|
||||
],
|
||||
"label": "Enable X Search Tool",
|
||||
"help": "Enable the x_search tool (requires XAI_API_KEY or tools.web.x_search.apiKey).",
|
||||
"tags": [],
|
||||
"hasChildren": false
|
||||
},
|
||||
{
|
||||
@@ -67246,11 +67347,7 @@
|
||||
"required": false,
|
||||
"deprecated": false,
|
||||
"sensitive": false,
|
||||
"tags": [
|
||||
"tools"
|
||||
],
|
||||
"label": "X Search Inline Citations",
|
||||
"help": "Keep inline citations from xAI in x_search responses when available (default: false).",
|
||||
"tags": [],
|
||||
"hasChildren": false
|
||||
},
|
||||
{
|
||||
@@ -67260,12 +67357,7 @@
|
||||
"required": false,
|
||||
"deprecated": false,
|
||||
"sensitive": false,
|
||||
"tags": [
|
||||
"performance",
|
||||
"tools"
|
||||
],
|
||||
"label": "X Search Max Turns",
|
||||
"help": "Optional max internal search/tool turns xAI may use per x_search request. Omit to let xAI choose.",
|
||||
"tags": [],
|
||||
"hasChildren": false
|
||||
},
|
||||
{
|
||||
@@ -67275,12 +67367,7 @@
|
||||
"required": false,
|
||||
"deprecated": false,
|
||||
"sensitive": false,
|
||||
"tags": [
|
||||
"models",
|
||||
"tools"
|
||||
],
|
||||
"label": "X Search Model",
|
||||
"help": "Model to use for X search (default: \"grok-4-1-fast-non-reasoning\").",
|
||||
"tags": [],
|
||||
"hasChildren": false
|
||||
},
|
||||
{
|
||||
@@ -67290,12 +67377,7 @@
|
||||
"required": false,
|
||||
"deprecated": false,
|
||||
"sensitive": false,
|
||||
"tags": [
|
||||
"performance",
|
||||
"tools"
|
||||
],
|
||||
"label": "X Search Timeout (sec)",
|
||||
"help": "Timeout in seconds for x_search requests.",
|
||||
"tags": [],
|
||||
"hasChildren": false
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{"generatedBy":"scripts/generate-config-doc-baseline.ts","recordType":"meta","totalPaths":5766}
|
||||
{"generatedBy":"scripts/generate-config-doc-baseline.ts","recordType":"meta","totalPaths":5780}
|
||||
{"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}
|
||||
@@ -92,6 +92,7 @@
|
||||
{"recordType":"path","path":"agents.defaults.compaction.memoryFlush.systemPrompt","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Compaction Memory Flush System Prompt","help":"System-prompt override for the pre-compaction memory flush turn to control extraction style and safety constraints. Use carefully so custom instructions do not reduce memory quality or leak sensitive context.","hasChildren":false}
|
||||
{"recordType":"path","path":"agents.defaults.compaction.mode","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Compaction Mode","help":"Compaction strategy mode: \"default\" uses baseline behavior, while \"safeguard\" applies stricter guardrails to preserve recent context. Keep \"default\" unless you observe aggressive history loss near limit boundaries.","hasChildren":false}
|
||||
{"recordType":"path","path":"agents.defaults.compaction.model","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["models"],"label":"Compaction Model Override","help":"Optional provider/model override used only for compaction summarization. Set this when you want compaction to run on a different model than the session default, and leave it unset to keep using the primary agent model.","hasChildren":false}
|
||||
{"recordType":"path","path":"agents.defaults.compaction.notifyUser","kind":"core","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Compaction Notify User","help":"When enabled, sends a brief compaction notice to the user (e.g. '🧹 Compacting context...') when compaction starts. Disabled by default to keep compaction silent and non-intrusive.","hasChildren":false}
|
||||
{"recordType":"path","path":"agents.defaults.compaction.postCompactionSections","kind":"core","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Post-Compaction Context Sections","help":"AGENTS.md H2/H3 section names re-injected after compaction so the agent reruns critical startup guidance. Leave unset to use \"Session Startup\"/\"Red Lines\" with legacy fallback to \"Every Session\"/\"Safety\"; set to [] to disable reinjection entirely.","hasChildren":true}
|
||||
{"recordType":"path","path":"agents.defaults.compaction.postCompactionSections.*","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"agents.defaults.compaction.postIndexSync","kind":"core","type":"string","required":false,"enumValues":["off","async","await"],"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Compaction Post-Index Sync","help":"Controls post-compaction session memory reindex mode: \"off\", \"async\", or \"await\" (default: \"async\"). Use \"await\" for strongest freshness, \"async\" for lower compaction latency, and \"off\" only when session-memory sync is handled elsewhere.","hasChildren":false}
|
||||
@@ -185,7 +186,6 @@
|
||||
{"recordType":"path","path":"agents.defaults.memorySearch.multimodal.maxFileBytes","kind":"core","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":["performance","storage"],"label":"Memory Search Multimodal Max File Bytes","help":"Sets the maximum bytes allowed per multimodal file before it is skipped during memory indexing. Use this to cap upload cost and indexing latency, or raise it for short high-quality audio clips.","hasChildren":false}
|
||||
{"recordType":"path","path":"agents.defaults.memorySearch.multimodal.modalities","kind":"core","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Memory Search Multimodal Modalities","help":"Selects which multimodal file types are indexed from extraPaths: \"image\", \"audio\", or \"all\". Keep this narrow to avoid indexing large binary corpora unintentionally.","hasChildren":true}
|
||||
{"recordType":"path","path":"agents.defaults.memorySearch.multimodal.modalities.*","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"agents.defaults.memorySearch.notifyUser","kind":"core","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"agents.defaults.memorySearch.outputDimensionality","kind":"core","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Memory Search Output Dimensionality","help":"Gemini embedding-2 only: chooses the output vector size for memory embeddings. Use 768, 1536, or 3072 (default), and expect a full reindex when you change it because stored vector dimensions must stay consistent.","hasChildren":false}
|
||||
{"recordType":"path","path":"agents.defaults.memorySearch.provider","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Memory Search Provider","help":"Selects the embedding backend used to build/query memory vectors: \"openai\", \"gemini\", \"voyage\", \"mistral\", \"ollama\", or \"local\". Keep your most reliable provider here and configure fallback for resilience.","hasChildren":false}
|
||||
{"recordType":"path","path":"agents.defaults.memorySearch.qmd","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Memory Search QMD Collections","help":"Use this when one agent should query another agent's transcript collections; QMD-specific extra collections let you opt into cross-agent memory search without flattening everything into one shared namespace.","hasChildren":true}
|
||||
@@ -896,7 +896,7 @@
|
||||
{"recordType":"path","path":"channels.discord.accounts.*.agentComponents.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.discord.accounts.*.allowBots","kind":"channel","type":["boolean","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.discord.accounts.*.allowFrom","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.discord.accounts.*.allowFrom.*","kind":"channel","type":["number","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.discord.accounts.*.allowFrom.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.discord.accounts.*.autoPresence","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.discord.accounts.*.autoPresence.degradedText","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.discord.accounts.*.autoPresence.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
@@ -920,10 +920,10 @@
|
||||
{"recordType":"path","path":"channels.discord.accounts.*.defaultTo","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.discord.accounts.*.dm","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.discord.accounts.*.dm.allowFrom","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.discord.accounts.*.dm.allowFrom.*","kind":"channel","type":["number","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.discord.accounts.*.dm.allowFrom.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.discord.accounts.*.dm.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.discord.accounts.*.dm.groupChannels","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.discord.accounts.*.dm.groupChannels.*","kind":"channel","type":["number","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.discord.accounts.*.dm.groupChannels.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.discord.accounts.*.dm.groupEnabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.discord.accounts.*.dm.policy","kind":"channel","type":"string","required":false,"enumValues":["pairing","allowlist","open","disabled"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.discord.accounts.*.dmHistoryLimit","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
@@ -944,7 +944,7 @@
|
||||
{"recordType":"path","path":"channels.discord.accounts.*.execApprovals.agentFilter","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.discord.accounts.*.execApprovals.agentFilter.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.discord.accounts.*.execApprovals.approvers","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.discord.accounts.*.execApprovals.approvers.*","kind":"channel","type":["number","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.discord.accounts.*.execApprovals.approvers.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.discord.accounts.*.execApprovals.cleanupAfterResolve","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.discord.accounts.*.execApprovals.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.discord.accounts.*.execApprovals.sessionFilter","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
@@ -964,7 +964,7 @@
|
||||
{"recordType":"path","path":"channels.discord.accounts.*.guilds.*.channels.*.includeThreadStarter","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.discord.accounts.*.guilds.*.channels.*.requireMention","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.discord.accounts.*.guilds.*.channels.*.roles","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.discord.accounts.*.guilds.*.channels.*.roles.*","kind":"channel","type":["number","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.discord.accounts.*.guilds.*.channels.*.roles.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.discord.accounts.*.guilds.*.channels.*.skills","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.discord.accounts.*.guilds.*.channels.*.skills.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.discord.accounts.*.guilds.*.channels.*.systemPrompt","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
@@ -984,12 +984,12 @@
|
||||
{"recordType":"path","path":"channels.discord.accounts.*.guilds.*.channels.*.toolsBySender.*.deny","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.discord.accounts.*.guilds.*.channels.*.toolsBySender.*.deny.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.discord.accounts.*.guilds.*.channels.*.users","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.discord.accounts.*.guilds.*.channels.*.users.*","kind":"channel","type":["number","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.discord.accounts.*.guilds.*.channels.*.users.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.discord.accounts.*.guilds.*.ignoreOtherMentions","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.discord.accounts.*.guilds.*.reactionNotifications","kind":"channel","type":"string","required":false,"enumValues":["off","own","all","allowlist"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.discord.accounts.*.guilds.*.requireMention","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.discord.accounts.*.guilds.*.roles","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.discord.accounts.*.guilds.*.roles.*","kind":"channel","type":["number","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.discord.accounts.*.guilds.*.roles.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.discord.accounts.*.guilds.*.slug","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.discord.accounts.*.guilds.*.tools","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.discord.accounts.*.guilds.*.tools.allow","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
@@ -1007,7 +1007,7 @@
|
||||
{"recordType":"path","path":"channels.discord.accounts.*.guilds.*.toolsBySender.*.deny","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"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.*.guilds.*.users.*","kind":"channel","type":"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}
|
||||
@@ -1121,7 +1121,7 @@
|
||||
{"recordType":"path","path":"channels.discord.agentComponents.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.discord.allowBots","kind":"channel","type":["boolean","string"],"required":false,"deprecated":false,"sensitive":false,"tags":["access","channels","network"],"label":"Discord Allow Bot Messages","help":"Allow bot-authored messages to trigger Discord replies (default: false). Set \"mentions\" to only accept bot messages that mention the bot.","hasChildren":false}
|
||||
{"recordType":"path","path":"channels.discord.allowFrom","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.discord.allowFrom.*","kind":"channel","type":["number","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.discord.allowFrom.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.discord.autoPresence","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.discord.autoPresence.degradedText","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"Discord Auto Presence Degraded Text","help":"Optional custom status text while runtime/model availability is degraded or unknown (idle).","hasChildren":false}
|
||||
{"recordType":"path","path":"channels.discord.autoPresence.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"Discord Auto Presence Enabled","help":"Enable automatic Discord bot presence updates based on runtime/model availability signals. When enabled: healthy=>online, degraded/unknown=>idle, exhausted/unavailable=>dnd.","hasChildren":false}
|
||||
@@ -1146,10 +1146,10 @@
|
||||
{"recordType":"path","path":"channels.discord.defaultTo","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.discord.dm","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.discord.dm.allowFrom","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.discord.dm.allowFrom.*","kind":"channel","type":["number","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.discord.dm.allowFrom.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.discord.dm.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.discord.dm.groupChannels","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.discord.dm.groupChannels.*","kind":"channel","type":["number","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.discord.dm.groupChannels.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.discord.dm.groupEnabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.discord.dm.policy","kind":"channel","type":"string","required":false,"enumValues":["pairing","allowlist","open","disabled"],"deprecated":false,"sensitive":false,"tags":["access","channels","network"],"label":"Discord DM Policy","help":"Direct message access control (\"pairing\" recommended). \"open\" requires channels.discord.allowFrom=[\"*\"] (legacy: channels.discord.dm.allowFrom).","hasChildren":false}
|
||||
{"recordType":"path","path":"channels.discord.dmHistoryLimit","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
@@ -1170,7 +1170,7 @@
|
||||
{"recordType":"path","path":"channels.discord.execApprovals.agentFilter","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.discord.execApprovals.agentFilter.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.discord.execApprovals.approvers","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.discord.execApprovals.approvers.*","kind":"channel","type":["number","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.discord.execApprovals.approvers.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.discord.execApprovals.cleanupAfterResolve","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.discord.execApprovals.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.discord.execApprovals.sessionFilter","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
@@ -1190,7 +1190,7 @@
|
||||
{"recordType":"path","path":"channels.discord.guilds.*.channels.*.includeThreadStarter","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.discord.guilds.*.channels.*.requireMention","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.discord.guilds.*.channels.*.roles","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.discord.guilds.*.channels.*.roles.*","kind":"channel","type":["number","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.discord.guilds.*.channels.*.roles.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.discord.guilds.*.channels.*.skills","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.discord.guilds.*.channels.*.skills.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.discord.guilds.*.channels.*.systemPrompt","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
@@ -1210,12 +1210,12 @@
|
||||
{"recordType":"path","path":"channels.discord.guilds.*.channels.*.toolsBySender.*.deny","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.discord.guilds.*.channels.*.toolsBySender.*.deny.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.discord.guilds.*.channels.*.users","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.discord.guilds.*.channels.*.users.*","kind":"channel","type":["number","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.discord.guilds.*.channels.*.users.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.discord.guilds.*.ignoreOtherMentions","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.discord.guilds.*.reactionNotifications","kind":"channel","type":"string","required":false,"enumValues":["off","own","all","allowlist"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.discord.guilds.*.requireMention","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.discord.guilds.*.roles","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.discord.guilds.*.roles.*","kind":"channel","type":["number","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.discord.guilds.*.roles.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.discord.guilds.*.slug","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.discord.guilds.*.tools","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.discord.guilds.*.tools.allow","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
@@ -1233,7 +1233,7 @@
|
||||
{"recordType":"path","path":"channels.discord.guilds.*.toolsBySender.*.deny","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"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.guilds.*.users.*","kind":"channel","type":"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}
|
||||
@@ -1982,6 +1982,7 @@
|
||||
{"recordType":"path","path":"channels.matrix.autoJoinAllowlist","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.matrix.autoJoinAllowlist.*","kind":"channel","type":["number","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.matrix.avatarUrl","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.matrix.blockStreaming","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.matrix.chunkMode","kind":"channel","type":"string","required":false,"enumValues":["length","newline"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.matrix.defaultAccount","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.matrix.deviceId","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
@@ -1999,6 +2000,7 @@
|
||||
{"recordType":"path","path":"channels.matrix.groupPolicy","kind":"channel","type":"string","required":false,"enumValues":["open","disabled","allowlist"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.matrix.groups","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.matrix.groups.*","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.matrix.groups.*.account","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.matrix.groups.*.allow","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.matrix.groups.*.allowBots","kind":"channel","type":["boolean","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.matrix.groups.*.autoReply","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
@@ -2033,6 +2035,7 @@
|
||||
{"recordType":"path","path":"channels.matrix.responsePrefix","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.matrix.rooms","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.matrix.rooms.*","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.matrix.rooms.*.account","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.matrix.rooms.*.allow","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.matrix.rooms.*.allowBots","kind":"channel","type":["boolean","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.matrix.rooms.*.autoReply","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
@@ -2827,12 +2830,12 @@
|
||||
{"recordType":"path","path":"channels.slack.dms.*","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"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.execApprovals","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"Slack Exec Approvals","help":"Slack-native exec approval routing and approver authorization. Enable this only when Slack should act as an explicit exec-approval client for the selected workspace account.","hasChildren":true}
|
||||
{"recordType":"path","path":"channels.slack.execApprovals","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"Slack Exec Approvals","help":"Slack-native exec approval routing and approver authorization. When unset, OpenClaw auto-enables DM-first native approvals if approvers can be resolved for this workspace account.","hasChildren":true}
|
||||
{"recordType":"path","path":"channels.slack.execApprovals.agentFilter","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"Slack Exec Approval Agent Filter","help":"Optional allowlist of agent IDs eligible for Slack exec approvals, for example `[\"main\", \"ops-agent\"]`. Use this to keep approval prompts scoped to the agents you actually operate from Slack.","hasChildren":true}
|
||||
{"recordType":"path","path":"channels.slack.execApprovals.agentFilter.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.slack.execApprovals.approvers","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"Slack Exec Approval Approvers","help":"Slack user IDs allowed to approve exec requests for this workspace account. Use Slack user IDs or user targets such as `U123`, `user:U123`, or `<@U123>`. If you leave this unset, OpenClaw falls back to commands.ownerAllowFrom when possible.","hasChildren":true}
|
||||
{"recordType":"path","path":"channels.slack.execApprovals.approvers.*","kind":"channel","type":["number","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.slack.execApprovals.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"Slack Exec Approvals Enabled","help":"Enable Slack exec approvals for this account. When false or unset, Slack messages/buttons cannot approve exec requests.","hasChildren":false}
|
||||
{"recordType":"path","path":"channels.slack.execApprovals.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"Slack Exec Approvals Enabled","help":"Controls Slack native exec approvals for this account: unset or \"auto\" enables DM-first native approvals when approvers can be resolved, true forces native approvals on, and false disables them.","hasChildren":false}
|
||||
{"recordType":"path","path":"channels.slack.execApprovals.sessionFilter","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network","storage"],"label":"Slack Exec Approval Session Filter","help":"Optional session-key filters matched as substring or regex-style patterns before Slack approval routing is used. Use narrow patterns so Slack approvals only appear for intended sessions.","hasChildren":true}
|
||||
{"recordType":"path","path":"channels.slack.execApprovals.sessionFilter.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.slack.execApprovals.target","kind":"channel","type":"string","required":false,"enumValues":["dm","channel","both"],"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"Slack Exec Approval Target","help":"Controls where Slack approval prompts are sent: \"dm\" sends to approver DMs (default), \"channel\" sends to the originating Slack chat/thread, and \"both\" sends to both. Channel delivery exposes the command text to the chat, so only use it in trusted channels.","hasChildren":false}
|
||||
@@ -3179,12 +3182,12 @@
|
||||
{"recordType":"path","path":"channels.telegram.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.telegram.errorCooldownMs","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.telegram.errorPolicy","kind":"channel","type":"string","required":false,"enumValues":["always","once","silent"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.telegram.execApprovals","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"Telegram Exec Approvals","help":"Telegram-native exec approval routing and approver authorization. Enable this only when Telegram should act as an explicit exec-approval client for the selected bot account.","hasChildren":true}
|
||||
{"recordType":"path","path":"channels.telegram.execApprovals","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"Telegram Exec Approvals","help":"Telegram-native exec approval routing and approver authorization. When unset, OpenClaw auto-enables DM-first native approvals if approvers can be resolved for the selected bot account.","hasChildren":true}
|
||||
{"recordType":"path","path":"channels.telegram.execApprovals.agentFilter","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"Telegram Exec Approval Agent Filter","help":"Optional allowlist of agent IDs eligible for Telegram exec approvals, for example `[\"main\", \"ops-agent\"]`. Use this to keep approval prompts scoped to the agents you actually operate from Telegram.","hasChildren":true}
|
||||
{"recordType":"path","path":"channels.telegram.execApprovals.agentFilter.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.telegram.execApprovals.approvers","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"Telegram Exec Approval Approvers","help":"Telegram user IDs allowed to approve exec requests for this bot account. Use numeric Telegram user IDs. If you leave this unset, OpenClaw falls back to numeric owner IDs inferred from channels.telegram.allowFrom and direct-message defaultTo when possible.","hasChildren":true}
|
||||
{"recordType":"path","path":"channels.telegram.execApprovals.approvers.*","kind":"channel","type":["number","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.telegram.execApprovals.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"Telegram Exec Approvals Enabled","help":"Enable Telegram exec approvals for this account. When false or unset, Telegram messages/buttons cannot approve exec requests.","hasChildren":false}
|
||||
{"recordType":"path","path":"channels.telegram.execApprovals.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"Telegram Exec Approvals Enabled","help":"Controls Telegram native exec approvals for this account: unset or \"auto\" enables DM-first native approvals when approvers can be resolved, true forces native approvals on, and false disables them.","hasChildren":false}
|
||||
{"recordType":"path","path":"channels.telegram.execApprovals.sessionFilter","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network","storage"],"label":"Telegram Exec Approval Session Filter","help":"Optional session-key filters matched as substring or regex-style patterns before Telegram approval routing is used. Use narrow patterns so Telegram approvals only appear for intended sessions.","hasChildren":true}
|
||||
{"recordType":"path","path":"channels.telegram.execApprovals.sessionFilter.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.telegram.execApprovals.target","kind":"channel","type":"string","required":false,"enumValues":["dm","channel","both"],"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"Telegram Exec Approval Target","help":"Controls where Telegram approval prompts are sent: \"dm\" sends to approver DMs (default), \"channel\" sends to the originating Telegram chat/topic, and \"both\" sends to both. Channel delivery exposes the command text to the chat, so only use it in trusted groups/topics.","hasChildren":false}
|
||||
@@ -4333,6 +4336,7 @@
|
||||
{"recordType":"path","path":"plugins.entries.diffs.config.defaults.wordWrap","kind":"plugin","type":"boolean","required":false,"defaultValue":true,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Default Word Wrap","help":"Wrap long lines by default.","hasChildren":false}
|
||||
{"recordType":"path","path":"plugins.entries.diffs.config.security","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"plugins.entries.diffs.config.security.allowRemoteViewer","kind":"plugin","type":"boolean","required":false,"defaultValue":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Remote Viewer","help":"Allow non-loopback access to diff viewer URLs when the token path is known.","hasChildren":false}
|
||||
{"recordType":"path","path":"plugins.entries.diffs.config.viewerBaseUrl","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Viewer Base URL","help":"Persistent gateway base URL used for returned viewer links when a tool call does not pass baseUrl.","hasChildren":false}
|
||||
{"recordType":"path","path":"plugins.entries.diffs.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable Diffs","hasChildren":false}
|
||||
{"recordType":"path","path":"plugins.entries.diffs.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true}
|
||||
{"recordType":"path","path":"plugins.entries.diffs.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false}
|
||||
@@ -4401,6 +4405,12 @@
|
||||
{"recordType":"path","path":"plugins.entries.feishu.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false}
|
||||
{"recordType":"path","path":"plugins.entries.firecrawl","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/firecrawl-plugin","help":"OpenClaw Firecrawl plugin (plugin: firecrawl)","hasChildren":true}
|
||||
{"recordType":"path","path":"plugins.entries.firecrawl.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/firecrawl-plugin Config","help":"Plugin-defined config payload for firecrawl.","hasChildren":true}
|
||||
{"recordType":"path","path":"plugins.entries.firecrawl.config.webFetch","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"plugins.entries.firecrawl.config.webFetch.apiKey","kind":"plugin","type":["object","string"],"required":false,"deprecated":false,"sensitive":true,"tags":["auth","security"],"label":"Firecrawl Fetch API Key","help":"Firecrawl API key for web fetch fallback (fallback: FIRECRAWL_API_KEY env var).","hasChildren":false}
|
||||
{"recordType":"path","path":"plugins.entries.firecrawl.config.webFetch.baseUrl","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["advanced","url-secret"],"label":"Firecrawl Fetch Base URL","help":"Firecrawl Fetch base URL override.","hasChildren":false}
|
||||
{"recordType":"path","path":"plugins.entries.firecrawl.config.webFetch.maxAgeMs","kind":"plugin","type":"number","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"plugins.entries.firecrawl.config.webFetch.onlyMainContent","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"plugins.entries.firecrawl.config.webFetch.timeoutSeconds","kind":"plugin","type":"number","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"plugins.entries.firecrawl.config.webSearch","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"plugins.entries.firecrawl.config.webSearch.apiKey","kind":"plugin","type":["object","string"],"required":false,"deprecated":false,"sensitive":true,"tags":["auth","security"],"label":"Firecrawl Search API Key","help":"Firecrawl API key for web search (fallback: FIRECRAWL_API_KEY env var).","hasChildren":false}
|
||||
{"recordType":"path","path":"plugins.entries.firecrawl.config.webSearch.baseUrl","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["advanced","url-secret"],"label":"Firecrawl Search Base URL","help":"Firecrawl Search base URL override.","hasChildren":false}
|
||||
@@ -5135,6 +5145,13 @@
|
||||
{"recordType":"path","path":"plugins.entries.xai.config.webSearch.apiKey","kind":"plugin","type":["object","string"],"required":false,"deprecated":false,"sensitive":true,"tags":["auth","security"],"label":"Grok Search API Key","help":"xAI API key for Grok web search (fallback: XAI_API_KEY env var).","hasChildren":false}
|
||||
{"recordType":"path","path":"plugins.entries.xai.config.webSearch.inlineCitations","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Inline Citations","help":"Include inline markdown citations in Grok responses.","hasChildren":false}
|
||||
{"recordType":"path","path":"plugins.entries.xai.config.webSearch.model","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["models"],"label":"Grok Search Model","help":"Grok model override for web search.","hasChildren":false}
|
||||
{"recordType":"path","path":"plugins.entries.xai.config.xSearch","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"plugins.entries.xai.config.xSearch.cacheTtlMinutes","kind":"plugin","type":"number","required":false,"deprecated":false,"sensitive":false,"tags":["performance","storage"],"label":"X Search Cache TTL","help":"Cache TTL in minutes for x_search results.","hasChildren":false}
|
||||
{"recordType":"path","path":"plugins.entries.xai.config.xSearch.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable X Search","help":"Enable the x_search tool for searching X posts with xAI.","hasChildren":false}
|
||||
{"recordType":"path","path":"plugins.entries.xai.config.xSearch.inlineCitations","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"X Search Inline Citations","help":"Keep inline markdown citations from xAI in x_search responses when available.","hasChildren":false}
|
||||
{"recordType":"path","path":"plugins.entries.xai.config.xSearch.maxTurns","kind":"plugin","type":"number","required":false,"deprecated":false,"sensitive":false,"tags":["performance"],"label":"X Search Max Turns","help":"Optional max internal tool turns xAI may use per x_search request.","hasChildren":false}
|
||||
{"recordType":"path","path":"plugins.entries.xai.config.xSearch.model","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["models"],"label":"X Search Model","help":"xAI model override for x_search.","hasChildren":false}
|
||||
{"recordType":"path","path":"plugins.entries.xai.config.xSearch.timeoutSeconds","kind":"plugin","type":"number","required":false,"deprecated":false,"sensitive":false,"tags":["performance"],"label":"X Search Timeout","help":"Timeout in seconds for x_search requests.","hasChildren":false}
|
||||
{"recordType":"path","path":"plugins.entries.xai.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/xai-plugin","hasChildren":false}
|
||||
{"recordType":"path","path":"plugins.entries.xai.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true}
|
||||
{"recordType":"path","path":"plugins.entries.xai.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false}
|
||||
@@ -5694,19 +5711,20 @@
|
||||
{"recordType":"path","path":"tools.web.fetch.cacheTtlMinutes","kind":"core","type":"number","required":false,"deprecated":false,"sensitive":false,"tags":["performance","storage","tools"],"label":"Web Fetch Cache TTL (min)","help":"Cache TTL in minutes for web_fetch results.","hasChildren":false}
|
||||
{"recordType":"path","path":"tools.web.fetch.enabled","kind":"core","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["tools"],"label":"Enable Web Fetch Tool","help":"Enable the web_fetch tool (lightweight HTTP fetch).","hasChildren":false}
|
||||
{"recordType":"path","path":"tools.web.fetch.firecrawl","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"tools.web.fetch.firecrawl.apiKey","kind":"core","type":["object","string"],"required":false,"deprecated":false,"sensitive":true,"tags":["auth","security","tools"],"label":"Firecrawl API Key","help":"Firecrawl API key (fallback: FIRECRAWL_API_KEY env var).","hasChildren":true}
|
||||
{"recordType":"path","path":"tools.web.fetch.firecrawl.apiKey","kind":"core","type":["object","string"],"required":false,"deprecated":false,"sensitive":true,"tags":["auth","security","tools"],"hasChildren":true}
|
||||
{"recordType":"path","path":"tools.web.fetch.firecrawl.apiKey.id","kind":"core","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"tools.web.fetch.firecrawl.apiKey.provider","kind":"core","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"tools.web.fetch.firecrawl.apiKey.source","kind":"core","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"tools.web.fetch.firecrawl.baseUrl","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["tools","url-secret"],"label":"Firecrawl Base URL","help":"Firecrawl base URL (e.g. https://api.firecrawl.dev or custom endpoint).","hasChildren":false}
|
||||
{"recordType":"path","path":"tools.web.fetch.firecrawl.enabled","kind":"core","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["tools"],"label":"Enable Firecrawl Fallback","help":"Enable Firecrawl fallback for web_fetch (if configured).","hasChildren":false}
|
||||
{"recordType":"path","path":"tools.web.fetch.firecrawl.maxAgeMs","kind":"core","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":["performance","tools"],"label":"Firecrawl Cache Max Age (ms)","help":"Firecrawl maxAge (ms) for cached results when supported by the API.","hasChildren":false}
|
||||
{"recordType":"path","path":"tools.web.fetch.firecrawl.onlyMainContent","kind":"core","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["tools"],"label":"Firecrawl Main Content Only","help":"When true, Firecrawl returns only the main content (default: true).","hasChildren":false}
|
||||
{"recordType":"path","path":"tools.web.fetch.firecrawl.timeoutSeconds","kind":"core","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":["performance","tools"],"label":"Firecrawl Timeout (sec)","help":"Timeout in seconds for Firecrawl requests.","hasChildren":false}
|
||||
{"recordType":"path","path":"tools.web.fetch.firecrawl.baseUrl","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["tools","url-secret"],"hasChildren":false}
|
||||
{"recordType":"path","path":"tools.web.fetch.firecrawl.enabled","kind":"core","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"tools.web.fetch.firecrawl.maxAgeMs","kind":"core","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"tools.web.fetch.firecrawl.onlyMainContent","kind":"core","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"tools.web.fetch.firecrawl.timeoutSeconds","kind":"core","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"tools.web.fetch.maxChars","kind":"core","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":["performance","tools"],"label":"Web Fetch Max Chars","help":"Max characters returned by web_fetch (truncated).","hasChildren":false}
|
||||
{"recordType":"path","path":"tools.web.fetch.maxCharsCap","kind":"core","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":["performance","tools"],"label":"Web Fetch Hard Max Chars","help":"Hard cap for web_fetch maxChars (applies to config and tool calls).","hasChildren":false}
|
||||
{"recordType":"path","path":"tools.web.fetch.maxRedirects","kind":"core","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":["performance","storage","tools"],"label":"Web Fetch Max Redirects","help":"Maximum redirects allowed for web_fetch (default: 3).","hasChildren":false}
|
||||
{"recordType":"path","path":"tools.web.fetch.maxResponseBytes","kind":"core","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":["performance","tools"],"label":"Web Fetch Max Download Size (bytes)","help":"Max download size before truncation.","hasChildren":false}
|
||||
{"recordType":"path","path":"tools.web.fetch.provider","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["tools"],"label":"Web Fetch Provider","help":"Web fetch fallback provider id.","hasChildren":false}
|
||||
{"recordType":"path","path":"tools.web.fetch.readability","kind":"core","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["tools"],"label":"Web Fetch Readability Extraction","help":"Use Readability to extract main content from HTML (fallbacks to basic HTML cleanup).","hasChildren":false}
|
||||
{"recordType":"path","path":"tools.web.fetch.timeoutSeconds","kind":"core","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":["performance","tools"],"label":"Web Fetch Timeout (sec)","help":"Timeout in seconds for web_fetch requests.","hasChildren":false}
|
||||
{"recordType":"path","path":"tools.web.fetch.userAgent","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["tools"],"label":"Web Fetch User-Agent","help":"Override User-Agent header for web_fetch requests.","hasChildren":false}
|
||||
@@ -5727,16 +5745,12 @@
|
||||
{"recordType":"path","path":"tools.web.search.provider","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["tools"],"label":"Web Search Provider","help":"Search provider id. Auto-detected from available API keys if omitted.","hasChildren":false}
|
||||
{"recordType":"path","path":"tools.web.search.timeoutSeconds","kind":"core","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":["performance","tools"],"label":"Web Search Timeout (sec)","help":"Timeout in seconds for web_search requests.","hasChildren":false}
|
||||
{"recordType":"path","path":"tools.web.x_search","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"tools.web.x_search.apiKey","kind":"core","type":["object","string"],"required":false,"deprecated":false,"sensitive":true,"tags":["auth","security","tools"],"label":"xAI API Key","help":"xAI API key for X search (fallback: XAI_API_KEY env var).","hasChildren":true}
|
||||
{"recordType":"path","path":"tools.web.x_search.apiKey.id","kind":"core","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"tools.web.x_search.apiKey.provider","kind":"core","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"tools.web.x_search.apiKey.source","kind":"core","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"tools.web.x_search.cacheTtlMinutes","kind":"core","type":"number","required":false,"deprecated":false,"sensitive":false,"tags":["performance","storage","tools"],"label":"X Search Cache TTL (min)","help":"Cache TTL in minutes for x_search results.","hasChildren":false}
|
||||
{"recordType":"path","path":"tools.web.x_search.enabled","kind":"core","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["tools"],"label":"Enable X Search Tool","help":"Enable the x_search tool (requires XAI_API_KEY or tools.web.x_search.apiKey).","hasChildren":false}
|
||||
{"recordType":"path","path":"tools.web.x_search.inlineCitations","kind":"core","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["tools"],"label":"X Search Inline Citations","help":"Keep inline citations from xAI in x_search responses when available (default: false).","hasChildren":false}
|
||||
{"recordType":"path","path":"tools.web.x_search.maxTurns","kind":"core","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":["performance","tools"],"label":"X Search Max Turns","help":"Optional max internal search/tool turns xAI may use per x_search request. Omit to let xAI choose.","hasChildren":false}
|
||||
{"recordType":"path","path":"tools.web.x_search.model","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["models","tools"],"label":"X Search Model","help":"Model to use for X search (default: \"grok-4-1-fast-non-reasoning\").","hasChildren":false}
|
||||
{"recordType":"path","path":"tools.web.x_search.timeoutSeconds","kind":"core","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":["performance","tools"],"label":"X Search Timeout (sec)","help":"Timeout in seconds for x_search requests.","hasChildren":false}
|
||||
{"recordType":"path","path":"tools.web.x_search.cacheTtlMinutes","kind":"core","type":"number","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"tools.web.x_search.enabled","kind":"core","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"tools.web.x_search.inlineCitations","kind":"core","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"tools.web.x_search.maxTurns","kind":"core","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"tools.web.x_search.model","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"tools.web.x_search.timeoutSeconds","kind":"core","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"ui","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"UI","help":"UI presentation settings for accenting and assistant identity shown in control surfaces. Use this for branding and readability customization without changing runtime behavior.","hasChildren":true}
|
||||
{"recordType":"path","path":"ui.assistant","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Assistant Appearance","help":"Assistant display identity settings for name and avatar shown in UI surfaces. Keep these values aligned with your operator-facing persona and support expectations.","hasChildren":true}
|
||||
{"recordType":"path","path":"ui.assistant.avatar","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Assistant Avatar","help":"Assistant avatar image source used in UI surfaces (URL, path, or data URI depending on runtime support). Use trusted assets and consistent branding dimensions for clean rendering.","hasChildren":false}
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,50 +1,8 @@
|
||||
---
|
||||
summary: "Compatibility note for older ClawFlow references in release notes and docs"
|
||||
read_when:
|
||||
- You encounter ClawFlow or openclaw flows in older release notes or docs
|
||||
- You want to understand what ClawFlow terminology maps to in the current CLI
|
||||
- You want to translate older flow references into the supported task commands
|
||||
summary: "Redirect to TaskFlow"
|
||||
title: "ClawFlow"
|
||||
---
|
||||
|
||||
# ClawFlow
|
||||
|
||||
`ClawFlow` appears in some older OpenClaw release notes and documentation as if it were a user-facing runtime with its own `openclaw flows` command surface.
|
||||
|
||||
That is not the current operator-facing surface in this repository.
|
||||
|
||||
Today, the supported CLI surface for inspecting and managing detached work is [`openclaw tasks`](/automation/tasks).
|
||||
|
||||
## What to use today
|
||||
|
||||
- `openclaw tasks list` shows tracked detached runs
|
||||
- `openclaw tasks show <lookup>` shows one task by task id, run id, or session key
|
||||
- `openclaw tasks cancel <lookup>` cancels a running task
|
||||
- `openclaw tasks audit` surfaces stale or broken task runs
|
||||
|
||||
```bash
|
||||
openclaw tasks list
|
||||
openclaw tasks show <lookup>
|
||||
openclaw tasks cancel <lookup>
|
||||
```
|
||||
|
||||
## What this means for older references
|
||||
|
||||
If you see `ClawFlow` or `openclaw flows` in:
|
||||
|
||||
- old release notes
|
||||
- issue threads
|
||||
- stale search results
|
||||
- outdated local notes
|
||||
|
||||
translate those instructions to the current task CLI:
|
||||
|
||||
- `openclaw flows list` -> `openclaw tasks list`
|
||||
- `openclaw flows show <lookup>` -> `openclaw tasks show <lookup>`
|
||||
- `openclaw flows cancel <lookup>` -> `openclaw tasks cancel <lookup>`
|
||||
|
||||
## Related
|
||||
|
||||
- [Background Tasks](/automation/tasks) — detached work ledger
|
||||
- [CLI: flows](/cli/flows) — compatibility note for the mistaken command name
|
||||
- [Cron Jobs](/automation/cron-jobs) — scheduled jobs that may create tasks
|
||||
ClawFlow was renamed to [TaskFlow](/automation/taskflow). See [TaskFlow](/automation/taskflow) for the current documentation.
|
||||
|
||||
@@ -54,15 +54,13 @@ The most effective setups combine multiple mechanisms:
|
||||
|
||||
See [Cron vs Heartbeat](/automation/cron-vs-heartbeat) for a detailed comparison of the two scheduling mechanisms.
|
||||
|
||||
## Older ClawFlow references
|
||||
## TaskFlow
|
||||
|
||||
Older release notes and docs may mention `ClawFlow` or `openclaw flows`, but the current CLI surface in this repo is `openclaw tasks`.
|
||||
|
||||
See [Background Tasks](/automation/tasks) for the supported task ledger commands, plus [ClawFlow](/automation/clawflow) and [CLI: flows](/cli/flows) for compatibility notes.
|
||||
[TaskFlow](/automation/taskflow) is the flow orchestration substrate above background tasks. It manages durable multi-step flows with managed and mirrored sync modes, and exposes `openclaw flows list|show|cancel` for inspection and recovery. See [TaskFlow](/automation/taskflow) for details.
|
||||
|
||||
## Related
|
||||
|
||||
- [Cron vs Heartbeat](/automation/cron-vs-heartbeat) — detailed comparison guide
|
||||
- [ClawFlow](/automation/clawflow) — compatibility note for older docs and release notes
|
||||
- [TaskFlow](/automation/taskflow) — flow orchestration above tasks
|
||||
- [Troubleshooting](/automation/troubleshooting) — debugging automation issues
|
||||
- [Configuration Reference](/gateway/configuration-reference) — all config keys
|
||||
|
||||
51
docs/automation/taskflow.md
Normal file
51
docs/automation/taskflow.md
Normal file
@@ -0,0 +1,51 @@
|
||||
---
|
||||
summary: "TaskFlow flow orchestration layer above background tasks"
|
||||
read_when:
|
||||
- You want to understand how TaskFlow relates to background tasks
|
||||
- You encounter TaskFlow or openclaw flows in release notes or docs
|
||||
- You want to inspect or manage durable flow state
|
||||
title: "TaskFlow"
|
||||
---
|
||||
|
||||
# TaskFlow
|
||||
|
||||
TaskFlow is the flow orchestration substrate that sits above [background tasks](/automation/tasks). It manages durable multi-step flows with their own state, revision tracking, and sync semantics while individual tasks remain the unit of detached work.
|
||||
|
||||
## Sync modes
|
||||
|
||||
TaskFlow supports two sync modes:
|
||||
|
||||
- **Managed** — TaskFlow owns the lifecycle end-to-end, creating and driving tasks as flow steps progress.
|
||||
- **Mirrored** — TaskFlow observes externally created tasks and keeps flow state in sync without taking ownership of task creation.
|
||||
|
||||
## Durable state and revision tracking
|
||||
|
||||
Each flow persists its own state and tracks revisions so progress survives gateway restarts. Revision tracking enables conflict detection when multiple sources attempt to advance the same flow.
|
||||
|
||||
## CLI commands
|
||||
|
||||
```bash
|
||||
# List active and recent flows
|
||||
openclaw flows list
|
||||
|
||||
# Show details for a specific flow
|
||||
openclaw flows show <lookup>
|
||||
|
||||
# Cancel a running flow
|
||||
openclaw flows cancel <lookup>
|
||||
```
|
||||
|
||||
- `openclaw flows list` — shows tracked flows with status and sync mode
|
||||
- `openclaw flows show <lookup>` — inspect one flow by flow id or lookup key
|
||||
- `openclaw flows cancel <lookup>` — cancel a running flow and its active tasks
|
||||
|
||||
## How flows relate to tasks
|
||||
|
||||
Flows coordinate tasks, not replace them. A single flow may drive multiple background tasks over its lifetime. Use `openclaw tasks` to inspect individual task records and `openclaw flows` to inspect the orchestrating flow.
|
||||
|
||||
## Related
|
||||
|
||||
- [Background Tasks](/automation/tasks) — the detached work ledger that flows coordinate
|
||||
- [CLI: flows](/cli/flows) — CLI command reference for `openclaw flows`
|
||||
- [Automation Overview](/automation) — all automation mechanisms at a glance
|
||||
- [Cron Jobs](/automation/cron-jobs) — scheduled jobs that may feed into flows
|
||||
@@ -224,11 +224,11 @@ A sweeper runs every **60 seconds** and handles three things:
|
||||
|
||||
## How tasks relate to other systems
|
||||
|
||||
### Tasks and older flow references
|
||||
### Tasks and TaskFlow
|
||||
|
||||
Some older OpenClaw release notes and docs referred to task management as `ClawFlow` and documented an `openclaw flows` command surface.
|
||||
[TaskFlow](/automation/taskflow) is the flow orchestration layer above background tasks. A single flow may coordinate multiple tasks over its lifetime using managed or mirrored sync modes. Use `openclaw tasks` to inspect individual task records and `openclaw flows` to inspect the orchestrating flow.
|
||||
|
||||
In the current codebase, the supported operator surface is `openclaw tasks`. See [ClawFlow](/automation/clawflow) and [CLI: flows](/cli/flows) for compatibility notes that map those older references to the current task commands.
|
||||
See [TaskFlow](/automation/taskflow) and [CLI: flows](/cli/flows) for details.
|
||||
|
||||
### Tasks and cron
|
||||
|
||||
@@ -253,9 +253,9 @@ A task's `runId` links to the agent run doing the work. Agent lifecycle events (
|
||||
## Related
|
||||
|
||||
- [Automation Overview](/automation) — all automation mechanisms at a glance
|
||||
- [ClawFlow](/automation/clawflow) — compatibility note for older docs and release notes
|
||||
- [TaskFlow](/automation/taskflow) — flow orchestration above tasks
|
||||
- [Cron Jobs](/automation/cron-jobs) — scheduling background work
|
||||
- [Cron vs Heartbeat](/automation/cron-vs-heartbeat) — choosing the right mechanism
|
||||
- [Heartbeat](/gateway/heartbeat) — periodic main-session turns
|
||||
- [CLI: flows](/cli/flows) — compatibility note for the mistaken command name
|
||||
- [CLI: flows](/cli/flows) — CLI reference for `openclaw flows`
|
||||
- [CLI: Tasks](/cli/index#tasks) — CLI command reference
|
||||
|
||||
@@ -192,7 +192,7 @@ In group `120363403215116621@g.us` with agents `["alfred", "baerbel"]`:
|
||||
```
|
||||
Session: agent:alfred:whatsapp:group:120363403215116621@g.us
|
||||
History: [user message, alfred's previous responses]
|
||||
Workspace: /Users/pascal/openclaw-alfred/
|
||||
Workspace: /Users/user/openclaw-alfred/
|
||||
Tools: read, write, exec
|
||||
```
|
||||
|
||||
@@ -201,7 +201,7 @@ Tools: read, write, exec
|
||||
```
|
||||
Session: agent:baerbel:whatsapp:group:120363403215116621@g.us
|
||||
History: [user message, baerbel's previous responses]
|
||||
Workspace: /Users/pascal/openclaw-baerbel/
|
||||
Workspace: /Users/user/openclaw-baerbel/
|
||||
Tools: read only
|
||||
```
|
||||
|
||||
|
||||
@@ -952,7 +952,7 @@ Default slash command settings:
|
||||
- `channels.discord.execApprovals.target` (`dm` | `channel` | `both`, default: `dm`)
|
||||
- `agentFilter`, `sessionFilter`, `cleanupAfterResolve`
|
||||
|
||||
Discord becomes an approval client when `enabled: true` and at least one approver can be resolved, either from `execApprovals.approvers` or from the account's existing owner config (`allowFrom`, legacy `dm.allowFrom`, or explicit DM `defaultTo`).
|
||||
Discord auto-enables native exec approvals when `enabled` is unset or `"auto"` and at least one approver can be resolved, either from `execApprovals.approvers` or from the account's existing owner config (`allowFrom`, legacy `dm.allowFrom`, or explicit DM `defaultTo`). Set `enabled: false` to disable Discord as a native approval client explicitly.
|
||||
|
||||
When `target` is `channel` or `both`, the approval prompt is visible in the channel. Only resolved approvers can use the buttons; other users receive an ephemeral denial. Approval prompts include the command text, so only enable channel delivery in trusted channels. If the channel ID cannot be derived from the session key, OpenClaw falls back to DM delivery.
|
||||
|
||||
|
||||
@@ -183,9 +183,9 @@ done:
|
||||
```
|
||||
|
||||
- `streaming: "off"` is the default. OpenClaw waits for the final reply and sends it once.
|
||||
- `streaming: "partial"` creates one editable preview message instead of sending multiple partial messages.
|
||||
- `blockStreaming: true` enables separate Matrix progress messages instead of final-only delivery when `streaming` is off.
|
||||
- When `streaming: "partial"`, Matrix disables shared block streaming so draft edits do not double-send.
|
||||
- `streaming: "partial"` creates one editable preview message for the current assistant block instead of sending multiple partial messages.
|
||||
- `blockStreaming: true` enables separate Matrix progress messages. With `streaming: "partial"`, Matrix keeps the live draft for the current block and preserves completed blocks as separate messages.
|
||||
- When `streaming: "partial"` and `blockStreaming` is off, Matrix only edits the live draft and sends the completed reply once that block or turn finishes.
|
||||
- If the preview no longer fits in one Matrix event, OpenClaw stops preview streaming and falls back to normal final delivery.
|
||||
- Media replies still send attachments normally. If a stale preview can no longer be reused safely, OpenClaw redacts it before sending the final media reply.
|
||||
- Preview edits cost extra Matrix API calls. Leave streaming off if you want the most conservative rate-limit behavior.
|
||||
|
||||
@@ -487,19 +487,47 @@ Exec approval prompts can route natively through Slack using interactive buttons
|
||||
|
||||
This uses the same shared approval button surface as other channels. When `interactivity` is enabled in your Slack app settings, approval prompts render as Block Kit buttons directly in the conversation.
|
||||
|
||||
Configuration uses the shared `approvals.exec` config with Slack targets:
|
||||
Config path:
|
||||
|
||||
- `channels.slack.execApprovals.enabled`
|
||||
- `channels.slack.execApprovals.approvers` (optional; falls back to `commands.ownerAllowFrom` when possible)
|
||||
- `channels.slack.execApprovals.target` (`dm` | `channel` | `both`, default: `dm`)
|
||||
- `agentFilter`, `sessionFilter`
|
||||
|
||||
Slack auto-enables native exec approvals when `enabled` is unset or `"auto"` and at least one
|
||||
approver resolves. Set `enabled: false` to disable Slack as a native approval client explicitly.
|
||||
Set `enabled: true` to force native approvals on when approvers resolve.
|
||||
|
||||
Default behavior with no explicit Slack exec approval config:
|
||||
|
||||
```json5
|
||||
{
|
||||
approvals: {
|
||||
exec: {
|
||||
enabled: true,
|
||||
targets: [{ channel: "slack", to: "U12345678" }],
|
||||
commands: {
|
||||
ownerAllowFrom: ["slack:U12345678"],
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Explicit Slack-native config is only needed when you want to override approvers, add filters, or
|
||||
opt into origin-chat delivery:
|
||||
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
slack: {
|
||||
execApprovals: {
|
||||
enabled: true,
|
||||
approvers: ["U12345678"],
|
||||
target: "both",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Shared `approvals.exec` forwarding is separate. Use it only when approval prompts must also route
|
||||
to other chats or explicit out-of-band targets.
|
||||
|
||||
Same-chat `/approve` also works in Slack channels and DMs that already support commands. See [Exec approvals](/tools/exec-approvals) for the full approval forwarding model.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
@@ -810,7 +810,7 @@ openclaw message poll --channel telegram --target -1001234567890:topic:42 \
|
||||
- `channels.telegram.execApprovals.target` (`dm` | `channel` | `both`, default: `dm`)
|
||||
- `agentFilter`, `sessionFilter`
|
||||
|
||||
Approvers must be numeric Telegram user IDs. Telegram becomes an exec approval client when `enabled` is true and at least one approver can be resolved, either from `execApprovals.approvers` or from the account's numeric owner config (`allowFrom` and direct-message `defaultTo`). Approval requests otherwise fall back to other configured approval routes or the exec approval fallback policy.
|
||||
Approvers must be numeric Telegram user IDs. Telegram auto-enables native exec approvals when `enabled` is unset or `"auto"` and at least one approver can be resolved, either from `execApprovals.approvers` or from the account's numeric owner config (`allowFrom` and direct-message `defaultTo`). Set `enabled: false` to disable Telegram as a native approval client explicitly. Approval requests otherwise fall back to other configured approval routes or the exec approval fallback policy.
|
||||
|
||||
Telegram also renders the shared approval buttons used by other chat channels. The native Telegram adapter mainly adds approver DM routing, channel/topic fanout, and typing hints before delivery.
|
||||
|
||||
|
||||
@@ -45,6 +45,48 @@ openclaw approvals set --node <id|name|ip> --file ./exec-approvals.json
|
||||
openclaw approvals set --gateway --file ./exec-approvals.json
|
||||
```
|
||||
|
||||
## "Never prompt" / YOLO example
|
||||
|
||||
For a host that should never stop on exec approvals, set the host approvals defaults to `full` + `off`:
|
||||
|
||||
```bash
|
||||
openclaw approvals set --stdin <<'EOF'
|
||||
{
|
||||
version: 1,
|
||||
defaults: {
|
||||
security: "full",
|
||||
ask: "off",
|
||||
askFallback: "full"
|
||||
}
|
||||
}
|
||||
EOF
|
||||
```
|
||||
|
||||
Node variant:
|
||||
|
||||
```bash
|
||||
openclaw approvals set --node <id|name|ip> --stdin <<'EOF'
|
||||
{
|
||||
version: 1,
|
||||
defaults: {
|
||||
security: "full",
|
||||
ask: "off",
|
||||
askFallback: "full"
|
||||
}
|
||||
}
|
||||
EOF
|
||||
```
|
||||
|
||||
This changes the **host approvals file** only. To keep the requested OpenClaw policy aligned, also set:
|
||||
|
||||
```bash
|
||||
openclaw config set tools.exec.host gateway
|
||||
openclaw config set tools.exec.security full
|
||||
openclaw config set tools.exec.ask off
|
||||
```
|
||||
|
||||
This matches the current host-default YOLO behavior. Tighten it if you want approvals.
|
||||
|
||||
## Allowlist helpers
|
||||
|
||||
```bash
|
||||
|
||||
@@ -1,36 +1,43 @@
|
||||
---
|
||||
summary: "Compatibility note for the mistakenly documented `openclaw flows` command"
|
||||
summary: "CLI reference for `openclaw flows` commands"
|
||||
read_when:
|
||||
- You encounter openclaw flows in older release notes, issue threads, or search results
|
||||
- You want to know what command replaced openclaw flows
|
||||
- You want to list, inspect, or cancel TaskFlow flows from the CLI
|
||||
- You encounter openclaw flows in release notes or docs
|
||||
title: "flows"
|
||||
---
|
||||
|
||||
# `openclaw flows`
|
||||
|
||||
`openclaw flows` is **not** a current OpenClaw CLI command.
|
||||
Inspect and manage [TaskFlow](/automation/taskflow) flows from the command line.
|
||||
|
||||
Some older release notes and docs mistakenly documented a `flows` command surface. The supported operator surface is [`openclaw tasks`](/automation/tasks).
|
||||
## Commands
|
||||
|
||||
### `flows list`
|
||||
|
||||
```bash
|
||||
openclaw tasks list
|
||||
openclaw tasks show <lookup>
|
||||
openclaw tasks cancel <lookup>
|
||||
openclaw flows list [--json]
|
||||
```
|
||||
|
||||
## Use instead
|
||||
List active and recent flows with status and sync mode.
|
||||
|
||||
- `openclaw tasks list` — list tracked background tasks
|
||||
- `openclaw tasks show <lookup>` — inspect one task by task id, run id, or session key
|
||||
- `openclaw tasks cancel <lookup>` — cancel a running background task
|
||||
- `openclaw tasks notify <lookup> <policy>` — change task notification behavior
|
||||
- `openclaw tasks audit` — surface stale or broken task runs
|
||||
### `flows show`
|
||||
|
||||
## Why this page exists
|
||||
```bash
|
||||
openclaw flows show <lookup>
|
||||
```
|
||||
|
||||
This page stays in place so existing links from older changelog entries, issue threads, and search results have a clear correction instead of a dead end.
|
||||
Show details for a specific flow by flow id or lookup key, including state, revision history, and associated tasks.
|
||||
|
||||
### `flows cancel`
|
||||
|
||||
```bash
|
||||
openclaw flows cancel <lookup>
|
||||
```
|
||||
|
||||
Cancel a running flow and its active tasks.
|
||||
|
||||
## Related
|
||||
|
||||
- [Background Tasks](/automation/tasks) — detached work ledger
|
||||
- [TaskFlow](/automation/taskflow) — flow orchestration overview
|
||||
- [Background Tasks](/automation/tasks) — the detached work ledger
|
||||
- [CLI reference](/cli/index) — full command tree
|
||||
|
||||
@@ -46,6 +46,7 @@ This page describes the current CLI behavior. If commands change, update this do
|
||||
- [`browser`](/cli/browser)
|
||||
- [`cron`](/cli/cron)
|
||||
- [`tasks`](/cli/index#tasks)
|
||||
- [`flows`](/cli/flows)
|
||||
- [`dns`](/cli/dns)
|
||||
- [`docs`](/cli/docs)
|
||||
- [`hooks`](/cli/hooks)
|
||||
@@ -172,6 +173,10 @@ openclaw [--dev] [--profile <name>] <command>
|
||||
show
|
||||
notify
|
||||
cancel
|
||||
flows
|
||||
list
|
||||
show
|
||||
cancel
|
||||
gateway
|
||||
call
|
||||
health
|
||||
|
||||
@@ -879,6 +879,10 @@
|
||||
{
|
||||
"source": "/gateway/trusted-proxy",
|
||||
"destination": "/gateway/trusted-proxy-auth"
|
||||
},
|
||||
{
|
||||
"source": "/automation/clawflow",
|
||||
"destination": "/automation/taskflow"
|
||||
}
|
||||
],
|
||||
"navigation": {
|
||||
@@ -1122,7 +1126,7 @@
|
||||
"automation/cron-jobs",
|
||||
"automation/cron-vs-heartbeat",
|
||||
"automation/tasks",
|
||||
"automation/clawflow",
|
||||
"automation/taskflow",
|
||||
"automation/troubleshooting",
|
||||
"automation/webhook",
|
||||
"automation/gmail-pubsub",
|
||||
|
||||
@@ -90,6 +90,7 @@ Treat Gateway and node as one operator trust domain, with different roles:
|
||||
- A caller authenticated to the Gateway is trusted at Gateway scope. After pairing, node actions are trusted operator actions on that node.
|
||||
- `sessionKey` is routing/context selection, not per-user auth.
|
||||
- Exec approvals (allowlist + ask) are guardrails for operator intent, not hostile multi-tenant isolation.
|
||||
- OpenClaw's product default for trusted single-operator setups is that host exec on `gateway`/`node` is allowed without approval prompts (`security="full"`, `ask="off"` unless you tighten it). That default is intentional UX, not a vulnerability by itself.
|
||||
- Exec approvals bind exact request context and best-effort direct local file operands; they do not semantically model every runtime/interpreter loader path. Use sandboxing and host isolation for strong boundaries.
|
||||
|
||||
If you need hostile-user isolation, split trust boundaries by OS user/host and run separate gateways.
|
||||
@@ -173,6 +174,7 @@ If more than one person can DM your bot:
|
||||
- **Inbound access** (DM policies, group policies, allowlists): can strangers trigger the bot?
|
||||
- **Tool blast radius** (elevated tools + open rooms): could prompt injection turn into shell/file/network actions?
|
||||
- **Exec approval drift** (`security=full`, `autoAllowSkills`, interpreter allowlists without `strictInlineEval`): are host-exec guardrails still doing what you think they are?
|
||||
- `security="full"` is a broad posture warning, not proof of a bug. It is the chosen default for trusted personal-assistant setups; tighten it only when your threat model needs approval or allowlist guardrails.
|
||||
- **Network exposure** (Gateway bind/auth, Tailscale Serve/Funnel, weak/short auth tokens).
|
||||
- **Browser control exposure** (remote nodes, relay ports, remote CDP endpoints).
|
||||
- **Local disk hygiene** (permissions, symlinks, config includes, “synced folder” paths).
|
||||
@@ -375,6 +377,7 @@ If a macOS node is paired, the Gateway can invoke `system.run` on that node. Thi
|
||||
- The Gateway applies a coarse global node command policy via `gateway.nodes.allowCommands` / `denyCommands`.
|
||||
- Controlled on the Mac via **Settings → Exec approvals** (security + ask + allowlist).
|
||||
- The per-node `system.run` policy is the node's own exec approvals file (`exec.approvals.node.*`), which can be stricter or looser than the gateway's global command-ID policy.
|
||||
- A node running with `security="full"` and `ask="off"` is following the default trusted-operator model. Treat that as expected behavior unless your deployment explicitly requires a tighter approval or allowlist stance.
|
||||
- Approval mode binds exact request context and, when possible, one concrete local script/file operand. If OpenClaw cannot identify exactly one direct local file for an interpreter/runtime command, approval-backed execution is denied rather than promising full semantic coverage.
|
||||
- If you don’t want remote execution, set security to **deny** and remove node pairing for that Mac.
|
||||
|
||||
|
||||
@@ -127,7 +127,7 @@ When set, `OPENCLAW_HOME` replaces the system home directory (`$HOME` / `os.home
|
||||
<key>EnvironmentVariables</key>
|
||||
<dict>
|
||||
<key>OPENCLAW_HOME</key>
|
||||
<string>/Users/kira</string>
|
||||
<string>/Users/user</string>
|
||||
</dict>
|
||||
```
|
||||
|
||||
|
||||
@@ -173,6 +173,27 @@ Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS,
|
||||
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Why are there two exec approval configs for chat approvals?">
|
||||
They control different layers:
|
||||
|
||||
- `approvals.exec`: forwards approval prompts to chat destinations
|
||||
- `channels.<channel>.execApprovals`: makes that channel act as a native approval client
|
||||
|
||||
The host exec policy is still the real approval gate. Chat config only controls where approval
|
||||
prompts appear and how people can answer them.
|
||||
|
||||
In most setups you do **not** need both:
|
||||
|
||||
- If the chat already supports commands and replies, same-chat `/approve` works through the shared path.
|
||||
- If a supported native channel can infer approvers safely, OpenClaw now auto-enables DM-first native approvals when `channels.<channel>.execApprovals.enabled` is unset or `"auto"`.
|
||||
- Use `approvals.exec` only when prompts must also be forwarded to other chats or explicit ops rooms.
|
||||
- Use `channels.<channel>.execApprovals.target: "channel"` or `"both"` only when you explicitly want approval prompts posted back into the originating room/topic.
|
||||
|
||||
Short version: forwarding is for routing, native client config is for richer channel-specific UX.
|
||||
See [Exec Approvals](/tools/exec-approvals).
|
||||
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="What runtime do I need?">
|
||||
Node **>= 22** is required. `pnpm` is recommended. Bun is **not recommended** for the Gateway.
|
||||
</Accordion>
|
||||
|
||||
@@ -498,7 +498,15 @@ If you want to rely on env keys (e.g. exported in your `~/.profile`), run local
|
||||
|
||||
These Docker runners split into two buckets:
|
||||
|
||||
- Live-model runners: `test:docker:live-models` and `test:docker:live-gateway` run `pnpm test:live` inside the repo Docker image, mounting your local config dir and workspace (and sourcing `~/.profile` if mounted).
|
||||
- Live-model runners: `test:docker:live-models` and `test:docker:live-gateway` run only their matching profile-key live file inside the repo Docker image (`src/agents/models.profiles.live.test.ts` and `src/gateway/gateway-models.profiles.live.test.ts`), mounting your local config dir and workspace (and sourcing `~/.profile` if mounted). The matching local entrypoints are `test:live:models-profiles` and `test:live:gateway-profiles`.
|
||||
- Docker live runners default to a smaller smoke cap so a full Docker sweep stays practical:
|
||||
`test:docker:live-models` defaults to `OPENCLAW_LIVE_MAX_MODELS=12`, and
|
||||
`test:docker:live-gateway` defaults to `OPENCLAW_LIVE_GATEWAY_SMOKE=1`,
|
||||
`OPENCLAW_LIVE_GATEWAY_MAX_MODELS=8`,
|
||||
`OPENCLAW_LIVE_GATEWAY_STEP_TIMEOUT_MS=45000`, and
|
||||
`OPENCLAW_LIVE_GATEWAY_MODEL_TIMEOUT_MS=90000`. Override those env vars when you
|
||||
explicitly want the larger exhaustive scan.
|
||||
- `test:docker:all` builds the live Docker image once via `test:docker:live-build`, then reuses it for the two live Docker lanes.
|
||||
- Container smoke runners: `test:docker:openwebui`, `test:docker:onboard`, `test:docker:gateway-network`, `test:docker:mcp-channels`, and `test:docker:plugins` boot one or more real containers and verify higher-level integration paths.
|
||||
|
||||
The live-model Docker runners also bind-mount only the needed CLI auth homes (or all supported ones when the run is not narrowed), then copy them into the container home before the run so external-CLI OAuth can refresh tokens without mutating the host auth store:
|
||||
|
||||
@@ -278,11 +278,11 @@ flowchart TD
|
||||
|
||||
- If `tools.exec.host` is unset, the default is `auto`.
|
||||
- `host=auto` resolves to `sandbox` when a sandbox runtime is active, `gateway` otherwise.
|
||||
- On `gateway` and `node`, unset `tools.exec.security` defaults to `allowlist`.
|
||||
- Unset `tools.exec.ask` defaults to `on-miss`.
|
||||
- Result: ordinary host commands can now pause with `Approval required` instead of running immediately.
|
||||
- On `gateway` and `node`, unset `tools.exec.security` defaults to `full`.
|
||||
- Unset `tools.exec.ask` defaults to `off`.
|
||||
- Result: if you are seeing approvals, some host-local or per-session policy tightened exec away from the current defaults.
|
||||
|
||||
Restore the old gateway no-approval behavior:
|
||||
Restore current default no-approval behavior:
|
||||
|
||||
```bash
|
||||
openclaw config set tools.exec.host gateway
|
||||
@@ -293,8 +293,8 @@ flowchart TD
|
||||
|
||||
Safer alternatives:
|
||||
|
||||
- Set only `tools.exec.host=gateway` if you just want stable host routing and still want approvals.
|
||||
- Keep `security=allowlist` with `ask=on-miss` if you want host exec but still want review on allowlist misses.
|
||||
- Set only `tools.exec.host=gateway` if you just want stable host routing.
|
||||
- Use `security=allowlist` with `ask=on-miss` if you want host exec but still want review on allowlist misses.
|
||||
- Enable sandbox mode if you want `host=auto` to resolve back to `sandbox`.
|
||||
|
||||
Common log signatures:
|
||||
|
||||
@@ -96,6 +96,8 @@ Run a persistent OpenClaw Gateway on Oracle Cloud's **Always Free** ARM tier (up
|
||||
systemctl --user restart openclaw-gateway
|
||||
```
|
||||
|
||||
`gateway.trustedProxies=["127.0.0.1"]` is for the local Tailscale Serve proxy. Diff viewer routes keep fail-closed behavior in this setup: raw `127.0.0.1` viewer requests without forwarded proxy headers can return `Diff not found`. Use `mode=file` / `mode=both` for attachments, or intentionally enable remote viewers and set `plugins.entries.diffs.config.viewerBaseUrl` (or pass a proxy `baseUrl`) if you need shareable viewer links.
|
||||
|
||||
</Step>
|
||||
|
||||
<Step title="Lock down VCN security">
|
||||
|
||||
@@ -98,73 +98,9 @@ openclaw channels login
|
||||
```
|
||||
|
||||
On macOS, Podman machine may make the browser appear non-local to the gateway.
|
||||
If the Control UI reports device-auth errors after launch, prefer the SSH
|
||||
tunnel flow in [macOS Podman SSH tunnel](#macos-podman-ssh-tunnel). For
|
||||
remote HTTPS access, use the Tailscale guidance in
|
||||
If the Control UI reports device-auth errors after launch, use the Tailscale guidance in
|
||||
[Podman + Tailscale](#podman--tailscale).
|
||||
|
||||
## macOS Podman SSH tunnel
|
||||
|
||||
On macOS, Podman machine can make the browser appear non-local to the gateway even when the published port is only on `127.0.0.1`.
|
||||
|
||||
For local browser access, use an SSH tunnel into the Podman VM and open the tunneled localhost port instead.
|
||||
|
||||
Recommended local tunnel port:
|
||||
|
||||
- `28889` on the Mac host
|
||||
- forwarded to `127.0.0.1:18789` inside the Podman VM
|
||||
|
||||
Start the tunnel in a separate terminal:
|
||||
|
||||
```bash
|
||||
ssh -N \
|
||||
-i ~/.local/share/containers/podman/machine/machine \
|
||||
-p <podman-vm-ssh-port> \
|
||||
-L 28889:127.0.0.1:18789 \
|
||||
core@127.0.0.1
|
||||
```
|
||||
|
||||
In that command, `<podman-vm-ssh-port>` is the Podman VM's SSH port on the Mac host. Check your current value with:
|
||||
|
||||
```bash
|
||||
podman system connection list
|
||||
```
|
||||
|
||||
Allow the tunneled browser origin once. This is required the first time you use the tunnel because the launcher can auto-seed the Podman-published port, but it cannot infer your chosen browser tunnel port:
|
||||
|
||||
```bash
|
||||
OPENCLAW_CONTAINER=openclaw openclaw config set gateway.controlUi.allowedOrigins \
|
||||
'["http://127.0.0.1:18789","http://localhost:18789","http://127.0.0.1:28889","http://localhost:28889"]' \
|
||||
--strict-json
|
||||
podman restart openclaw
|
||||
```
|
||||
|
||||
That is a one-time step for the default `28889` tunnel.
|
||||
|
||||
Then open:
|
||||
|
||||
```text
|
||||
http://127.0.0.1:28889/
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- `18789` is usually already occupied on the Mac host by the Podman-published gateway port, so the tunnel uses `28889` as the local browser port.
|
||||
- If the UI asks for pairing approval, prefer explicit container-targeted or explicit-URL commands so the host CLI does not fall back to local pairing files:
|
||||
|
||||
```bash
|
||||
openclaw --container openclaw devices list
|
||||
openclaw --container openclaw devices approve --latest
|
||||
```
|
||||
|
||||
- Equivalent explicit-URL form:
|
||||
|
||||
```bash
|
||||
openclaw devices list \
|
||||
--url ws://127.0.0.1:28889 \
|
||||
--token "$(sed -n 's/^OPENCLAW_GATEWAY_TOKEN=//p' ~/.openclaw/.env | head -n1)"
|
||||
```
|
||||
|
||||
<a id="podman--tailscale"></a>
|
||||
|
||||
## Podman + Tailscale
|
||||
@@ -175,7 +111,7 @@ Podman-specific note:
|
||||
|
||||
- Keep the Podman publish host at `127.0.0.1`.
|
||||
- Prefer host-managed `tailscale serve` over `openclaw gateway --tailscale serve`.
|
||||
- For local macOS browser access without HTTPS, prefer the SSH tunnel section above.
|
||||
- On macOS, if local browser device-auth context is unreliable, use Tailscale access instead of ad hoc local tunnel workarounds.
|
||||
|
||||
See:
|
||||
|
||||
|
||||
@@ -167,6 +167,21 @@ See [Camera node](/nodes/camera) for parameters and CLI helpers.
|
||||
- `sms.search`
|
||||
- `motion.activity`, `motion.pedometer`
|
||||
|
||||
## Assistant entrypoints
|
||||
|
||||
Android supports launching OpenClaw from the system assistant trigger (Google
|
||||
Assistant). When configured, holding the home button or saying "Hey Google, ask
|
||||
OpenClaw..." opens the app and hands the prompt into the chat composer.
|
||||
|
||||
This uses Android **App Actions** metadata declared in the app manifest. No
|
||||
extra configuration is needed on the gateway side -- the assistant intent is
|
||||
handled entirely by the Android app and forwarded as a normal chat message.
|
||||
|
||||
<Note>
|
||||
App Actions availability depends on the device, Google Play Services version,
|
||||
and whether the user has set OpenClaw as the default assistant app.
|
||||
</Note>
|
||||
|
||||
## Notification forwarding
|
||||
|
||||
Android can forward device notifications to the gateway as events. Several controls let you scope which notifications are forwarded and when.
|
||||
|
||||
@@ -126,6 +126,8 @@ openclaw config set gateway.trustedProxies '["127.0.0.1"]'
|
||||
systemctl --user restart openclaw-gateway
|
||||
```
|
||||
|
||||
`gateway.trustedProxies=["127.0.0.1"]` is for the local Tailscale Serve proxy. Diff viewer routes keep fail-closed behavior in this setup: raw `127.0.0.1` viewer requests without forwarded proxy headers can return `Diff not found`. Use `mode=file` / `mode=both` for attachments, or intentionally enable remote viewers and set `plugins.entries.diffs.config.viewerBaseUrl` (or pass a proxy `baseUrl`) if you need shareable viewer links.
|
||||
|
||||
## 7) Verify
|
||||
|
||||
```bash
|
||||
|
||||
@@ -77,18 +77,19 @@ Connect OpenClaw to QQ via the QQ Bot API. Supports private chats, group
|
||||
mentions, channel messages, and rich media including voice, images, videos,
|
||||
and files.
|
||||
|
||||
- **npm:** `@sliverp/qqbot`
|
||||
- **repo:** [github.com/sliverp/qqbot](https://github.com/sliverp/qqbot)
|
||||
- **npm:** `@tencent-connect/openclaw-qqbot`
|
||||
- **repo:** [github.com/tencent-connect/openclaw-qqbot](https://github.com/tencent-connect/openclaw-qqbot)
|
||||
|
||||
```bash
|
||||
openclaw plugins install @sliverp/qqbot
|
||||
openclaw plugins install @tencent-connect/openclaw-qqbot
|
||||
```
|
||||
|
||||
### wecom
|
||||
|
||||
OpenClaw Enterprise WeCom Channel Plugin.
|
||||
A bot plugin powered by WeCom AI Bot WebSocket persistent connections,
|
||||
supports direct messages & group chats, streaming replies, and proactive messaging.
|
||||
WeCom channel plugin for OpenClaw by the Tencent WeCom team. Powered by
|
||||
WeCom Bot WebSocket persistent connections, it supports direct messages & group
|
||||
chats, streaming replies, proactive messaging, image/file processing, Markdown
|
||||
formatting, built-in access control, and document/meeting/messaging skills.
|
||||
|
||||
- **npm:** `@wecom/wecom-openclaw-plugin`
|
||||
- **repo:** [github.com/WecomTeam/wecom-openclaw-plugin](https://github.com/WecomTeam/wecom-openclaw-plugin)
|
||||
|
||||
@@ -115,6 +115,40 @@ await api.runtime.subagent.deleteSession({
|
||||
Untrusted plugins can still run subagents, but override requests are rejected.
|
||||
</Warning>
|
||||
|
||||
### `api.runtime.taskFlow`
|
||||
|
||||
Bind a TaskFlow runtime to an existing OpenClaw session key or trusted tool
|
||||
context, then create and manage TaskFlows without passing an owner on every call.
|
||||
|
||||
```typescript
|
||||
const taskFlow = api.runtime.taskFlow.fromToolContext(ctx);
|
||||
|
||||
const created = taskFlow.createManaged({
|
||||
controllerId: "my-plugin/review-batch",
|
||||
goal: "Review new pull requests",
|
||||
});
|
||||
|
||||
const child = taskFlow.runTask({
|
||||
flowId: created.flowId,
|
||||
runtime: "acp",
|
||||
childSessionKey: "agent:main:subagent:reviewer",
|
||||
task: "Review PR #123",
|
||||
status: "running",
|
||||
startedAt: Date.now(),
|
||||
});
|
||||
|
||||
const waiting = taskFlow.setWaiting({
|
||||
flowId: created.flowId,
|
||||
expectedRevision: created.revision,
|
||||
currentStep: "await-human-reply",
|
||||
waitJson: { kind: "reply", channel: "telegram" },
|
||||
});
|
||||
```
|
||||
|
||||
Use `bindSession({ sessionKey, requesterOrigin })` when you already have a
|
||||
trusted OpenClaw session key from your own binding layer. Do not bind from raw
|
||||
user input.
|
||||
|
||||
### `api.runtime.tts`
|
||||
|
||||
Text-to-speech synthesis.
|
||||
|
||||
@@ -39,7 +39,6 @@ Scope intent:
|
||||
- `plugins.entries.firecrawl.config.webSearch.apiKey`
|
||||
- `plugins.entries.tavily.config.webSearch.apiKey`
|
||||
- `tools.web.search.apiKey`
|
||||
- `tools.web.x_search.apiKey`
|
||||
- `gateway.auth.password`
|
||||
- `gateway.auth.token`
|
||||
- `gateway.remote.token`
|
||||
|
||||
@@ -523,13 +523,6 @@
|
||||
"path": "tools.web.search.apiKey",
|
||||
"secretShape": "secret_input",
|
||||
"optIn": true
|
||||
},
|
||||
{
|
||||
"id": "tools.web.x_search.apiKey",
|
||||
"configFile": "openclaw.json",
|
||||
"path": "tools.web.x_search.apiKey",
|
||||
"secretShape": "secret_input",
|
||||
"optIn": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -119,7 +119,7 @@ All fields are optional unless noted:
|
||||
- `fileScale` (`number`): device scale override (`1`-`4`).
|
||||
- `fileMaxWidth` (`number`): max render width in CSS pixels (`640`-`2400`).
|
||||
- `ttlSeconds` (`number`): viewer artifact TTL in seconds. Default 1800, max 21600.
|
||||
- `baseUrl` (`string`): viewer URL origin override. Must be `http` or `https`, no query/hash.
|
||||
- `baseUrl` (`string`): viewer URL origin override. Overrides plugin `viewerBaseUrl`. Must be `http` or `https`, no query/hash.
|
||||
|
||||
Validation and limits:
|
||||
|
||||
@@ -231,6 +231,29 @@ Supported defaults:
|
||||
|
||||
Explicit tool parameters override these defaults.
|
||||
|
||||
Persistent viewer URL config:
|
||||
|
||||
- `viewerBaseUrl` (`string`, optional)
|
||||
- Plugin-owned fallback for returned viewer links when a tool call does not pass `baseUrl`.
|
||||
- Must be `http` or `https`, no query/hash.
|
||||
|
||||
Example:
|
||||
|
||||
```json5
|
||||
{
|
||||
plugins: {
|
||||
entries: {
|
||||
diffs: {
|
||||
enabled: true,
|
||||
config: {
|
||||
viewerBaseUrl: "https://gateway.example.com/openclaw",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
## Security config
|
||||
|
||||
- `security.allowRemoteViewer` (`boolean`, default `false`)
|
||||
@@ -285,8 +308,9 @@ The viewer document resolves those assets relative to the viewer URL, so an opti
|
||||
|
||||
URL construction behavior:
|
||||
|
||||
- If `baseUrl` is provided, it is used after strict validation.
|
||||
- Without `baseUrl`, viewer URL defaults to loopback `127.0.0.1`.
|
||||
- If tool-call `baseUrl` is provided, it is used after strict validation.
|
||||
- Else if plugin `viewerBaseUrl` is configured, it is used.
|
||||
- Without either override, viewer URL defaults to loopback `127.0.0.1`.
|
||||
- If gateway bind mode is `custom` and `gateway.customBindHost` is set, that host is used.
|
||||
|
||||
`baseUrl` rules:
|
||||
@@ -353,8 +377,13 @@ Viewer accessibility issues:
|
||||
|
||||
- Viewer URL resolves to `127.0.0.1` by default.
|
||||
- For remote access scenarios, either:
|
||||
- set plugin `viewerBaseUrl`, or
|
||||
- pass `baseUrl` per tool call, or
|
||||
- use `gateway.bind=custom` and `gateway.customBindHost`
|
||||
- If `gateway.trustedProxies` includes loopback for a same-host proxy (for example Tailscale Serve), raw loopback viewer requests without forwarded client-IP headers fail closed by design.
|
||||
- For that proxy topology:
|
||||
- prefer `mode: "file"` or `mode: "both"` when you only need an attachment, or
|
||||
- intentionally enable `security.allowRemoteViewer` and set plugin `viewerBaseUrl` or pass a proxy/public `baseUrl` when you need a shareable viewer URL
|
||||
- Enable `security.allowRemoteViewer` only when you intend external viewer access.
|
||||
|
||||
Unmodified-lines row has no expand button:
|
||||
|
||||
@@ -91,6 +91,68 @@ Example schema:
|
||||
}
|
||||
```
|
||||
|
||||
## No-approval "YOLO" mode
|
||||
|
||||
If you want host exec to run without approval prompts, you must open **both** policy layers:
|
||||
|
||||
- requested exec policy in OpenClaw config (`tools.exec.*`)
|
||||
- host-local approvals policy in `~/.openclaw/exec-approvals.json`
|
||||
|
||||
This is now the default host behavior unless you tighten it explicitly:
|
||||
|
||||
- `tools.exec.security`: `full` on `gateway`/`node`
|
||||
- `tools.exec.ask`: `off`
|
||||
- host `askFallback`: `full`
|
||||
|
||||
If you want a more conservative setup, tighten either layer back to `allowlist` / `on-miss`
|
||||
or `deny`.
|
||||
|
||||
Persistent gateway-host "never prompt" setup:
|
||||
|
||||
```bash
|
||||
openclaw config set tools.exec.host gateway
|
||||
openclaw config set tools.exec.security full
|
||||
openclaw config set tools.exec.ask off
|
||||
openclaw gateway restart
|
||||
```
|
||||
|
||||
Then set the host approvals file to match:
|
||||
|
||||
```bash
|
||||
openclaw approvals set --stdin <<'EOF'
|
||||
{
|
||||
version: 1,
|
||||
defaults: {
|
||||
security: "full",
|
||||
ask: "off",
|
||||
askFallback: "full"
|
||||
}
|
||||
}
|
||||
EOF
|
||||
```
|
||||
|
||||
For a node host, apply the same approvals file on that node instead:
|
||||
|
||||
```bash
|
||||
openclaw approvals set --node <id|name|ip> --stdin <<'EOF'
|
||||
{
|
||||
version: 1,
|
||||
defaults: {
|
||||
security: "full",
|
||||
ask: "off",
|
||||
askFallback: "full"
|
||||
}
|
||||
}
|
||||
EOF
|
||||
```
|
||||
|
||||
Session-only shortcut:
|
||||
|
||||
- `/exec security=full ask=off` changes only the current session.
|
||||
- `/elevated full` is a break-glass shortcut that also skips exec approvals for that session.
|
||||
|
||||
If the host approvals file stays stricter than config, the stricter host policy still wins.
|
||||
|
||||
## Policy knobs
|
||||
|
||||
### Security (`exec.security`)
|
||||
@@ -423,24 +485,46 @@ resolved approver list for authorization even when native approval delivery is d
|
||||
|
||||
### Native approval delivery
|
||||
|
||||
Discord, Slack, and Telegram can also act as native approval-delivery adapters with channel-specific config.
|
||||
Some channels can also act as native approval clients. Native clients add approver DMs, origin-chat
|
||||
fanout, and channel-specific interactive approval UX on top of the shared same-chat `/approve`
|
||||
flow.
|
||||
|
||||
Generic model:
|
||||
|
||||
- host exec policy still decides whether exec approval is required
|
||||
- `approvals.exec` controls forwarding approval prompts to other chat destinations
|
||||
- `channels.<channel>.execApprovals` controls whether that channel acts as a native approval client
|
||||
|
||||
Native approval clients auto-enable DM-first delivery when all of these are true:
|
||||
|
||||
- the channel supports native approval delivery
|
||||
- approvers can be resolved from explicit `execApprovals.approvers` or existing owner config
|
||||
- `channels.<channel>.execApprovals.enabled` is unset or `"auto"`
|
||||
|
||||
Set `enabled: false` to disable a native approval client explicitly. Set `enabled: true` to force
|
||||
it on when approvers resolve. Public origin-chat delivery stays explicit through
|
||||
`channels.<channel>.execApprovals.target`.
|
||||
|
||||
FAQ: [Why are there two exec approval configs for chat approvals?](/help/faq#why-are-there-two-exec-approval-configs-for-chat-approvals)
|
||||
|
||||
- Discord: `channels.discord.execApprovals.*`
|
||||
- Slack: uses shared `approvals.exec.targets` with `channel: "slack"` and renders Block Kit approval buttons when interactivity is enabled
|
||||
- Slack: `channels.slack.execApprovals.*`
|
||||
- Telegram: `channels.telegram.execApprovals.*`
|
||||
|
||||
These native delivery adapters are opt-in. They add DM routing and channel fanout on top of the
|
||||
shared same-chat `/approve` flow and the shared approval buttons.
|
||||
These native approval clients add DM routing and optional channel fanout on top of the shared
|
||||
same-chat `/approve` flow and shared approval buttons.
|
||||
|
||||
Shared behavior:
|
||||
|
||||
- Slack, Matrix, Microsoft Teams, and similar deliverable chats use the normal channel auth model
|
||||
for same-chat `/approve`
|
||||
- when a native approval client auto-enables, the default native delivery target is approver DMs
|
||||
- for Discord and Telegram, only resolved approvers can approve or deny
|
||||
- Discord and Telegram approvers can be explicit (`execApprovals.approvers`) or inferred from existing owner config (`allowFrom`, plus direct-message `defaultTo` where supported)
|
||||
- Slack approvers can be explicit (`execApprovals.approvers`) or inferred from `commands.ownerAllowFrom`
|
||||
- the requester does not need to be an approver
|
||||
- the originating chat can approve directly with `/approve` when that chat already supports commands and replies
|
||||
- when channel delivery is enabled, approval prompts include the command text
|
||||
- when native `target` enables origin-chat delivery, approval prompts include the command text
|
||||
- pending exec approvals expire after 30 minutes by default
|
||||
- if no operator UI or configured approval client can accept the request, the prompt falls back to `askFallback`
|
||||
|
||||
|
||||
@@ -54,8 +54,9 @@ Notes:
|
||||
- `tools.exec.notifyOnExit` (default: true): when true, backgrounded exec sessions enqueue a system event and request a heartbeat on exit.
|
||||
- `tools.exec.approvalRunningNoticeMs` (default: 10000): emit a single “running” notice when an approval-gated exec runs longer than this (0 disables).
|
||||
- `tools.exec.host` (default: `auto`; resolves to `sandbox` when sandbox runtime is active, `gateway` otherwise)
|
||||
- `tools.exec.security` (default: `deny` for sandbox, `allowlist` for gateway + node when unset)
|
||||
- `tools.exec.ask` (default: `on-miss`)
|
||||
- `tools.exec.security` (default: `deny` for sandbox, `full` for gateway + node when unset)
|
||||
- `tools.exec.ask` (default: `off`)
|
||||
- No-approval host exec is the default for gateway + node. If you want approvals/allowlist behavior, tighten both `tools.exec.*` and the host `~/.openclaw/exec-approvals.json`; see [Exec approvals](/tools/exec-approvals#no-approval-yolo-mode).
|
||||
- `tools.exec.node` (default: unset)
|
||||
- `tools.exec.strictInlineEval` (default: false): when true, inline interpreter eval forms such as `python -c`, `node -e`, `ruby -e`, `perl -e`, `php -r`, `lua -e`, and `osascript -e` always require explicit approval. `allow-always` can still persist benign interpreter/script invocations, but inline-eval forms still prompt each time.
|
||||
- `tools.exec.pathPrepend`: list of directories to prepend to `PATH` for exec runs (gateway + sandbox only).
|
||||
|
||||
@@ -65,18 +65,14 @@ Notes:
|
||||
entries: {
|
||||
firecrawl: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
tools: {
|
||||
web: {
|
||||
fetch: {
|
||||
firecrawl: {
|
||||
apiKey: "FIRECRAWL_API_KEY_HERE",
|
||||
baseUrl: "https://api.firecrawl.dev",
|
||||
onlyMainContent: true,
|
||||
maxAgeMs: 172800000,
|
||||
timeoutSeconds: 60,
|
||||
config: {
|
||||
webFetch: {
|
||||
apiKey: "FIRECRAWL_API_KEY_HERE",
|
||||
baseUrl: "https://api.firecrawl.dev",
|
||||
onlyMainContent: true,
|
||||
maxAgeMs: 172800000,
|
||||
timeoutSeconds: 60,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -87,10 +83,11 @@ Notes:
|
||||
Notes:
|
||||
|
||||
- `firecrawl.enabled` defaults to `true` unless explicitly set to `false`.
|
||||
- Firecrawl fallback attempts run only when an API key is available (`tools.web.fetch.firecrawl.apiKey` or `FIRECRAWL_API_KEY`).
|
||||
- Firecrawl fallback attempts run only when an API key is available (`plugins.entries.firecrawl.config.webFetch.apiKey` or `FIRECRAWL_API_KEY`).
|
||||
- `maxAgeMs` controls how old cached results can be (ms). Default is 2 days.
|
||||
- Legacy `tools.web.fetch.firecrawl.*` config is auto-migrated by `openclaw doctor --fix`.
|
||||
|
||||
`firecrawl_scrape` reuses the same `tools.web.fetch.firecrawl.*` settings and env vars.
|
||||
`firecrawl_scrape` reuses the same `plugins.entries.firecrawl.config.webFetch.*` settings and env vars.
|
||||
|
||||
## Firecrawl plugin tools
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ read_when:
|
||||
|
||||
Lobster is a workflow shell that lets OpenClaw run multi-step tool sequences as a single, deterministic operation with explicit approval checkpoints.
|
||||
|
||||
Lobster is one authoring layer above detached background work. If you run into older `ClawFlow` terminology, treat it as historical naming around the same task-oriented runtime area; the current operator-facing CLI surface is [`openclaw tasks`](/automation/tasks).
|
||||
Lobster is one authoring layer above detached background work. For flow orchestration above individual tasks, see [TaskFlow](/automation/taskflow) (`openclaw flows`). For the task activity ledger, see [`openclaw tasks`](/automation/tasks).
|
||||
|
||||
## Hook
|
||||
|
||||
|
||||
@@ -82,16 +82,18 @@ If Readability extraction fails, `web_fetch` can fall back to
|
||||
|
||||
```json5
|
||||
{
|
||||
tools: {
|
||||
web: {
|
||||
fetch: {
|
||||
firecrawl: {
|
||||
enabled: true,
|
||||
apiKey: "fc-...", // optional if FIRECRAWL_API_KEY is set
|
||||
baseUrl: "https://api.firecrawl.dev",
|
||||
onlyMainContent: true,
|
||||
maxAgeMs: 86400000, // cache duration (1 day)
|
||||
timeoutSeconds: 60,
|
||||
plugins: {
|
||||
entries: {
|
||||
firecrawl: {
|
||||
enabled: true,
|
||||
config: {
|
||||
webFetch: {
|
||||
apiKey: "fc-...", // optional if FIRECRAWL_API_KEY is set
|
||||
baseUrl: "https://api.firecrawl.dev",
|
||||
onlyMainContent: true,
|
||||
maxAgeMs: 86400000, // cache duration (1 day)
|
||||
timeoutSeconds: 60,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -99,7 +101,8 @@ If Readability extraction fails, `web_fetch` can fall back to
|
||||
}
|
||||
```
|
||||
|
||||
`tools.web.fetch.firecrawl.apiKey` supports SecretRef objects.
|
||||
`plugins.entries.firecrawl.config.webFetch.apiKey` supports SecretRef objects.
|
||||
Legacy `tools.web.fetch.firecrawl.*` config is auto-migrated by `openclaw doctor --fix`.
|
||||
|
||||
<Note>
|
||||
If Firecrawl is enabled and its SecretRef is unresolved with no
|
||||
|
||||
@@ -193,8 +193,9 @@ Provider-specific config (API keys, base URLs, modes) lives under
|
||||
`plugins.entries.<plugin>.config.webSearch.*`. See the provider pages for
|
||||
examples.
|
||||
|
||||
For `x_search`, configure `tools.web.x_search.*` directly. It uses the same
|
||||
`XAI_API_KEY` fallback as Grok web search.
|
||||
For `x_search`, configure `plugins.entries.xai.config.xSearch.*`. It uses the
|
||||
same `XAI_API_KEY` fallback as Grok web search.
|
||||
Legacy `tools.web.x_search.*` config is auto-migrated by `openclaw doctor --fix`.
|
||||
When you choose Grok during `openclaw onboard` or `openclaw configure --section web`,
|
||||
OpenClaw can also offer optional `x_search` setup with the same key.
|
||||
This is a separate follow-up step inside the Grok path, not a separate top-level
|
||||
@@ -280,16 +281,22 @@ tool on the request that serves this tool call.
|
||||
|
||||
```json5
|
||||
{
|
||||
tools: {
|
||||
web: {
|
||||
x_search: {
|
||||
enabled: true,
|
||||
apiKey: "xai-...", // optional if XAI_API_KEY is set
|
||||
model: "grok-4-1-fast-non-reasoning",
|
||||
inlineCitations: false,
|
||||
maxTurns: 2,
|
||||
timeoutSeconds: 30,
|
||||
cacheTtlMinutes: 15,
|
||||
plugins: {
|
||||
entries: {
|
||||
xai: {
|
||||
config: {
|
||||
xSearch: {
|
||||
enabled: true,
|
||||
model: "grok-4-1-fast-non-reasoning",
|
||||
inlineCitations: false,
|
||||
maxTurns: 2,
|
||||
timeoutSeconds: 30,
|
||||
cacheTtlMinutes: 15,
|
||||
},
|
||||
webSearch: {
|
||||
apiKey: "xai-...", // optional if XAI_API_KEY is set
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -29,4 +29,10 @@ describe("anthropic vertex region helpers", () => {
|
||||
"global",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not infer a Vertex region from custom proxy hosts", () => {
|
||||
expect(
|
||||
resolveAnthropicVertexRegionFromBaseUrl("https://proxy.example.com/google/aiplatform"),
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { homedir, platform } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { resolveProviderEndpoint } from "openclaw/plugin-sdk/provider-http";
|
||||
|
||||
const ANTHROPIC_VERTEX_DEFAULT_REGION = "global";
|
||||
const ANTHROPIC_VERTEX_REGION_RE = /^[a-z0-9-]+$/;
|
||||
@@ -47,21 +48,8 @@ export function resolveAnthropicVertexProjectId(
|
||||
}
|
||||
|
||||
export function resolveAnthropicVertexRegionFromBaseUrl(baseUrl?: string): string | undefined {
|
||||
const trimmed = baseUrl?.trim();
|
||||
if (!trimmed) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
const host = new URL(trimmed).hostname.toLowerCase();
|
||||
if (host === "aiplatform.googleapis.com") {
|
||||
return "global";
|
||||
}
|
||||
const match = /^([a-z0-9-]+)-aiplatform\.googleapis\.com$/.exec(host);
|
||||
return match?.[1];
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
const endpoint = resolveProviderEndpoint(baseUrl);
|
||||
return endpoint.endpointClass === "google-vertex" ? endpoint.googleVertexRegion : undefined;
|
||||
}
|
||||
|
||||
export function resolveAnthropicVertexClientRegion(params?: {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { StreamFn } from "@mariozechner/pi-agent-core";
|
||||
import { streamSimple } from "@mariozechner/pi-ai";
|
||||
import { resolveProviderRequestCapabilities } from "openclaw/plugin-sdk/provider-http";
|
||||
import { streamWithPayloadPatch } from "openclaw/plugin-sdk/provider-stream";
|
||||
import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env";
|
||||
|
||||
@@ -51,19 +52,18 @@ function isAnthropicOAuthApiKey(apiKey: unknown): boolean {
|
||||
return typeof apiKey === "string" && apiKey.includes("sk-ant-oat");
|
||||
}
|
||||
|
||||
function isAnthropicPublicApiBaseUrl(baseUrl: unknown): boolean {
|
||||
if (baseUrl == null) {
|
||||
return true;
|
||||
}
|
||||
if (typeof baseUrl !== "string" || !baseUrl.trim()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
return new URL(baseUrl).hostname.toLowerCase() === "api.anthropic.com";
|
||||
} catch {
|
||||
return baseUrl.toLowerCase().includes("api.anthropic.com");
|
||||
}
|
||||
function allowsAnthropicServiceTier(model: {
|
||||
api?: unknown;
|
||||
provider?: unknown;
|
||||
baseUrl?: unknown;
|
||||
}): boolean {
|
||||
return resolveProviderRequestCapabilities({
|
||||
provider: typeof model.provider === "string" ? model.provider : undefined,
|
||||
api: typeof model.api === "string" ? model.api : undefined,
|
||||
baseUrl: typeof model.baseUrl === "string" ? model.baseUrl : undefined,
|
||||
capability: "llm",
|
||||
transport: "stream",
|
||||
}).allowsAnthropicServiceTier;
|
||||
}
|
||||
|
||||
function resolveAnthropicFastServiceTier(enabled: boolean): AnthropicServiceTier {
|
||||
@@ -161,11 +161,7 @@ export function createAnthropicFastModeWrapper(
|
||||
const underlying = baseStreamFn ?? streamSimple;
|
||||
const serviceTier = resolveAnthropicFastServiceTier(enabled);
|
||||
return (model, context, options) => {
|
||||
if (
|
||||
model.api !== "anthropic-messages" ||
|
||||
model.provider !== "anthropic" ||
|
||||
!isAnthropicPublicApiBaseUrl(model.baseUrl)
|
||||
) {
|
||||
if (!allowsAnthropicServiceTier(model)) {
|
||||
return underlying(model, context, options);
|
||||
}
|
||||
|
||||
@@ -183,11 +179,7 @@ export function createAnthropicServiceTierWrapper(
|
||||
): StreamFn {
|
||||
const underlying = baseStreamFn ?? streamSimple;
|
||||
return (model, context, options) => {
|
||||
if (
|
||||
model.api !== "anthropic-messages" ||
|
||||
model.provider !== "anthropic" ||
|
||||
!isAnthropicPublicApiBaseUrl(model.baseUrl)
|
||||
) {
|
||||
if (!allowsAnthropicServiceTier(model)) {
|
||||
return underlying(model, context, options);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { timingSafeEqual } from "node:crypto";
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import { safeEqualSecret } from "openclaw/plugin-sdk/browser-support";
|
||||
import { createBlueBubblesDebounceRegistry } from "./monitor-debounce.js";
|
||||
import { normalizeWebhookMessage, normalizeWebhookReaction } from "./monitor-normalize.js";
|
||||
import { logVerbose, processMessage, processReaction } from "./monitor-processing.js";
|
||||
@@ -116,18 +116,13 @@ function normalizeAuthToken(raw: string): string {
|
||||
return value;
|
||||
}
|
||||
|
||||
function safeEqualSecret(aRaw: string, bRaw: string): boolean {
|
||||
function safeEqualAuthToken(aRaw: string, bRaw: string): boolean {
|
||||
const a = normalizeAuthToken(aRaw);
|
||||
const b = normalizeAuthToken(bRaw);
|
||||
if (!a || !b) {
|
||||
return false;
|
||||
}
|
||||
const bufA = Buffer.from(a, "utf8");
|
||||
const bufB = Buffer.from(b, "utf8");
|
||||
if (bufA.length !== bufB.length) {
|
||||
return false;
|
||||
}
|
||||
return timingSafeEqual(bufA, bufB);
|
||||
return safeEqualSecret(a, b);
|
||||
}
|
||||
|
||||
function collectTrustedProxies(targets: readonly WebhookTarget[]): string[] {
|
||||
@@ -198,7 +193,7 @@ export async function handleBlueBubblesWebhookRequest(
|
||||
res,
|
||||
isMatch: (target) => {
|
||||
const token = target.account.config.password?.trim() ?? "";
|
||||
return safeEqualSecret(guid, token);
|
||||
return safeEqualAuthToken(guid, token);
|
||||
},
|
||||
});
|
||||
if (!target) {
|
||||
|
||||
@@ -320,6 +320,14 @@ describe("cdp", () => {
|
||||
expect(normalized).toBe("wss://user:pass@example.com/devtools/browser/ABC?token=abc");
|
||||
});
|
||||
|
||||
it("rewrites localhost absolute-form websocket URLs for remote CDP hosts", () => {
|
||||
const normalized = normalizeCdpWsUrl(
|
||||
"ws://localhost.:9222/devtools/browser/ABC",
|
||||
"https://user:pass@example.com?token=abc",
|
||||
);
|
||||
expect(normalized).toBe("wss://user:pass@example.com/devtools/browser/ABC?token=abc");
|
||||
});
|
||||
|
||||
it("rewrites 0.0.0.0 wildcard bind address to remote CDP host", () => {
|
||||
const normalized = normalizeCdpWsUrl(
|
||||
"ws://0.0.0.0:3000/devtools/browser/ABC",
|
||||
|
||||
23
extensions/browser/src/browser/errors.test.ts
Normal file
23
extensions/browser/src/browser/errors.test.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { BrowserValidationError, toBrowserErrorResponse } from "./errors.js";
|
||||
|
||||
describe("browser error mapping", () => {
|
||||
it("maps blocked browser targets to conflict responses", () => {
|
||||
const err = new Error(
|
||||
"Browser target is unavailable after SSRF policy blocked its navigation.",
|
||||
);
|
||||
err.name = "BlockedBrowserTargetError";
|
||||
|
||||
expect(toBrowserErrorResponse(err)).toEqual({
|
||||
status: 409,
|
||||
message: "Browser target is unavailable after SSRF policy blocked its navigation.",
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves BrowserError mappings", () => {
|
||||
expect(toBrowserErrorResponse(new BrowserValidationError("bad input"))).toEqual({
|
||||
status: 400,
|
||||
message: "bad input",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -72,6 +72,9 @@ export function toBrowserErrorResponse(err: unknown): {
|
||||
if (err instanceof BrowserError) {
|
||||
return { status: err.status, message: err.message };
|
||||
}
|
||||
if (err instanceof Error && err.name === "BlockedBrowserTargetError") {
|
||||
return { status: 409, message: err.message };
|
||||
}
|
||||
if (err instanceof SsrFBlockedError) {
|
||||
return { status: 400, message: err.message };
|
||||
}
|
||||
|
||||
@@ -2,19 +2,49 @@ import { chromium } from "playwright-core";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { SsrFBlockedError } from "../infra/net/ssrf.js";
|
||||
import * as chromeModule from "./chrome.js";
|
||||
import { BrowserTabNotFoundError } from "./errors.js";
|
||||
import { InvalidBrowserNavigationUrlError } from "./navigation-guard.js";
|
||||
import { closePlaywrightBrowserConnection, createPageViaPlaywright } from "./pw-session.js";
|
||||
import * as navigationGuardModule from "./navigation-guard.js";
|
||||
import {
|
||||
BlockedBrowserTargetError,
|
||||
closePlaywrightBrowserConnection,
|
||||
createPageViaPlaywright,
|
||||
forceDisconnectPlaywrightForTarget,
|
||||
getPageForTargetId,
|
||||
gotoPageWithNavigationGuard,
|
||||
listPagesViaPlaywright,
|
||||
} from "./pw-session.js";
|
||||
|
||||
const connectOverCdpSpy = vi.spyOn(chromium, "connectOverCDP");
|
||||
const getChromeWebSocketUrlSpy = vi.spyOn(chromeModule, "getChromeWebSocketUrl");
|
||||
|
||||
function installBrowserMocks() {
|
||||
const pageOn = vi.fn();
|
||||
let routeHandler:
|
||||
| ((
|
||||
route: { continue: () => Promise<void>; abort: () => Promise<void> },
|
||||
request: unknown,
|
||||
) => Promise<void>)
|
||||
| null = null;
|
||||
const pageGoto = vi.fn<
|
||||
(...args: unknown[]) => Promise<null | { request: () => Record<string, unknown> }>
|
||||
>(async () => null);
|
||||
const pageTitle = vi.fn(async () => "");
|
||||
const pageUrl = vi.fn(() => "about:blank");
|
||||
const pageRoute = vi.fn(async (_pattern: string, handler: typeof routeHandler) => {
|
||||
routeHandler = handler;
|
||||
});
|
||||
const pageUnroute = vi.fn(async () => {
|
||||
routeHandler = null;
|
||||
});
|
||||
const openPages: import("playwright-core").Page[] = [];
|
||||
const pageClose = vi.fn(async () => {
|
||||
const index = openPages.indexOf(page);
|
||||
if (index >= 0) {
|
||||
openPages.splice(index, 1);
|
||||
}
|
||||
});
|
||||
const mainFrame = {};
|
||||
const contextOn = vi.fn();
|
||||
const browserOn = vi.fn();
|
||||
const browserClose = vi.fn(async () => {});
|
||||
@@ -27,9 +57,12 @@ function installBrowserMocks() {
|
||||
const sessionDetach = vi.fn(async () => {});
|
||||
|
||||
const context = {
|
||||
pages: () => [],
|
||||
pages: () => openPages,
|
||||
on: contextOn,
|
||||
newPage: vi.fn(async () => page),
|
||||
newPage: vi.fn(async () => {
|
||||
openPages.push(page);
|
||||
return page;
|
||||
}),
|
||||
newCDPSession: vi.fn(async () => ({
|
||||
send: sessionSend,
|
||||
detach: sessionDetach,
|
||||
@@ -42,6 +75,10 @@ function installBrowserMocks() {
|
||||
goto: pageGoto,
|
||||
title: pageTitle,
|
||||
url: pageUrl,
|
||||
route: pageRoute,
|
||||
unroute: pageUnroute,
|
||||
close: pageClose,
|
||||
mainFrame: () => mainFrame,
|
||||
} as unknown as import("playwright-core").Page;
|
||||
|
||||
const browser = {
|
||||
@@ -53,7 +90,24 @@ function installBrowserMocks() {
|
||||
connectOverCdpSpy.mockResolvedValue(browser);
|
||||
getChromeWebSocketUrlSpy.mockResolvedValue(null);
|
||||
|
||||
return { pageGoto, browserClose };
|
||||
const getBrowserDisconnectedHandler = () =>
|
||||
browserOn.mock.calls.find((call) => call[0] === "disconnected")?.[1] as
|
||||
| (() => void)
|
||||
| undefined;
|
||||
|
||||
return {
|
||||
pageGoto,
|
||||
browserClose,
|
||||
pageClose,
|
||||
sessionSend,
|
||||
getBrowserDisconnectedHandler,
|
||||
getRouteHandler: () => routeHandler,
|
||||
mainFrame,
|
||||
pushOpenPage: () => {
|
||||
openPages.push(page);
|
||||
return page;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
@@ -89,18 +143,29 @@ describe("pw-session createPageViaPlaywright navigation guard", () => {
|
||||
});
|
||||
|
||||
it("blocks private intermediate redirect hops", async () => {
|
||||
const { pageGoto } = installBrowserMocks();
|
||||
pageGoto.mockResolvedValueOnce({
|
||||
request: () => ({
|
||||
url: () => "https://93.184.216.34/final",
|
||||
redirectedFrom: () => ({
|
||||
const { pageGoto, pageClose, getRouteHandler, mainFrame } = installBrowserMocks();
|
||||
pageGoto.mockImplementationOnce(async () => {
|
||||
const handler = getRouteHandler();
|
||||
if (!handler) {
|
||||
throw new Error("missing route handler");
|
||||
}
|
||||
await handler(
|
||||
{ continue: vi.fn(async () => {}), abort: vi.fn(async () => {}) },
|
||||
{
|
||||
isNavigationRequest: () => true,
|
||||
frame: () => mainFrame,
|
||||
url: () => "https://93.184.216.34/start",
|
||||
},
|
||||
);
|
||||
await handler(
|
||||
{ continue: vi.fn(async () => {}), abort: vi.fn(async () => {}) },
|
||||
{
|
||||
isNavigationRequest: () => true,
|
||||
frame: () => mainFrame,
|
||||
url: () => "http://127.0.0.1:18080/internal-hop",
|
||||
redirectedFrom: () => ({
|
||||
url: () => "https://93.184.216.34/start",
|
||||
redirectedFrom: () => null,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
},
|
||||
);
|
||||
throw new Error("Navigation aborted");
|
||||
});
|
||||
|
||||
await expect(
|
||||
@@ -109,5 +174,549 @@ describe("pw-session createPageViaPlaywright navigation guard", () => {
|
||||
url: "https://93.184.216.34/start",
|
||||
}),
|
||||
).rejects.toBeInstanceOf(SsrFBlockedError);
|
||||
|
||||
expect(pageGoto).toHaveBeenCalledTimes(1);
|
||||
expect(pageClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("preserves the created tab on ordinary navigation failure", async () => {
|
||||
const { pageGoto, pageClose } = installBrowserMocks();
|
||||
pageGoto.mockRejectedValueOnce(new Error("page.goto: net::ERR_NAME_NOT_RESOLVED"));
|
||||
|
||||
const created = await createPageViaPlaywright({
|
||||
cdpUrl: "http://127.0.0.1:18792",
|
||||
url: "https://93.184.216.34/start",
|
||||
});
|
||||
|
||||
expect(created.targetId).toBe("TARGET_1");
|
||||
expect(created.url).toBe("about:blank");
|
||||
expect(pageGoto).toHaveBeenCalledTimes(1);
|
||||
expect(pageClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not quarantine a tab when route.continue fails", async () => {
|
||||
const { pageGoto, pageClose, getRouteHandler, mainFrame } = installBrowserMocks();
|
||||
pageGoto.mockImplementationOnce(async () => {
|
||||
const handler = getRouteHandler();
|
||||
if (!handler) {
|
||||
throw new Error("missing route handler");
|
||||
}
|
||||
await handler(
|
||||
{
|
||||
continue: vi.fn(async () => {
|
||||
throw new Error("page.goto: Frame has been detached");
|
||||
}),
|
||||
abort: vi.fn(async () => {}),
|
||||
},
|
||||
{
|
||||
isNavigationRequest: () => true,
|
||||
frame: () => mainFrame,
|
||||
url: () => "https://example.com",
|
||||
},
|
||||
);
|
||||
throw new Error("page.goto: Frame has been detached");
|
||||
});
|
||||
|
||||
const created = await createPageViaPlaywright({
|
||||
cdpUrl: "http://127.0.0.1:18792",
|
||||
url: "https://example.com",
|
||||
});
|
||||
|
||||
expect(created.targetId).toBe("TARGET_1");
|
||||
expect(pageGoto).toHaveBeenCalledTimes(1);
|
||||
expect(pageClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("propagates unsupported redirect protocols as navigation errors", async () => {
|
||||
const { pageGoto, pageClose, getRouteHandler, mainFrame } = installBrowserMocks();
|
||||
pageGoto.mockImplementationOnce(async () => {
|
||||
const handler = getRouteHandler();
|
||||
if (!handler) {
|
||||
throw new Error("missing route handler");
|
||||
}
|
||||
await handler(
|
||||
{ continue: vi.fn(async () => {}), abort: vi.fn(async () => {}) },
|
||||
{
|
||||
isNavigationRequest: () => true,
|
||||
frame: () => mainFrame,
|
||||
url: () => "https://93.184.216.34/start",
|
||||
},
|
||||
);
|
||||
await handler(
|
||||
{ continue: vi.fn(async () => {}), abort: vi.fn(async () => {}) },
|
||||
{
|
||||
isNavigationRequest: () => true,
|
||||
frame: () => mainFrame,
|
||||
url: () => "file:///etc/passwd",
|
||||
},
|
||||
);
|
||||
throw new Error("Navigation aborted");
|
||||
});
|
||||
|
||||
await expect(
|
||||
createPageViaPlaywright({
|
||||
cdpUrl: "http://127.0.0.1:18792",
|
||||
url: "https://93.184.216.34/start",
|
||||
}),
|
||||
).rejects.toBeInstanceOf(InvalidBrowserNavigationUrlError);
|
||||
|
||||
expect(pageGoto).toHaveBeenCalledTimes(1);
|
||||
expect(pageClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not quarantine a tab on transient redirect lookup errors", async () => {
|
||||
const { pageGoto, pageClose, getRouteHandler, mainFrame } = installBrowserMocks();
|
||||
const assertNavigationAllowedSpy = vi.spyOn(
|
||||
navigationGuardModule,
|
||||
"assertBrowserNavigationAllowed",
|
||||
);
|
||||
assertNavigationAllowedSpy.mockImplementation(async (opts: { url: string }) => {
|
||||
if (opts.url === "http://127.0.0.1:18080/internal-hop") {
|
||||
throw new Error("getaddrinfo EAI_AGAIN internal-hop");
|
||||
}
|
||||
});
|
||||
pageGoto.mockImplementationOnce(async () => {
|
||||
const handler = getRouteHandler();
|
||||
if (!handler) {
|
||||
throw new Error("missing route handler");
|
||||
}
|
||||
await handler(
|
||||
{ continue: vi.fn(async () => {}), abort: vi.fn(async () => {}) },
|
||||
{
|
||||
isNavigationRequest: () => true,
|
||||
frame: () => mainFrame,
|
||||
url: () => "https://93.184.216.34/start",
|
||||
},
|
||||
);
|
||||
await handler(
|
||||
{ continue: vi.fn(async () => {}), abort: vi.fn(async () => {}) },
|
||||
{
|
||||
isNavigationRequest: () => true,
|
||||
frame: () => mainFrame,
|
||||
url: () => "http://127.0.0.1:18080/internal-hop",
|
||||
},
|
||||
);
|
||||
throw new Error("Navigation aborted");
|
||||
});
|
||||
|
||||
try {
|
||||
const created = await createPageViaPlaywright({
|
||||
cdpUrl: "http://127.0.0.1:18792",
|
||||
url: "https://93.184.216.34/start",
|
||||
});
|
||||
const pages = await listPagesViaPlaywright({ cdpUrl: "http://127.0.0.1:18792" });
|
||||
|
||||
expect(created.targetId).toBe("TARGET_1");
|
||||
expect(pages).toHaveLength(1);
|
||||
expect(pageClose).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
assertNavigationAllowedSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("does not quarantine a tab on transient post-navigation check errors", async () => {
|
||||
const { pageGoto, pageClose, getRouteHandler, mainFrame } = installBrowserMocks();
|
||||
const assertRedirectChainAllowedSpy = vi.spyOn(
|
||||
navigationGuardModule,
|
||||
"assertBrowserNavigationRedirectChainAllowed",
|
||||
);
|
||||
assertRedirectChainAllowedSpy.mockRejectedValueOnce(
|
||||
new Error("getaddrinfo EAI_AGAIN postcheck.example"),
|
||||
);
|
||||
pageGoto.mockImplementationOnce(async () => {
|
||||
const handler = getRouteHandler();
|
||||
if (!handler) {
|
||||
throw new Error("missing route handler");
|
||||
}
|
||||
await handler(
|
||||
{ continue: vi.fn(async () => {}), abort: vi.fn(async () => {}) },
|
||||
{
|
||||
isNavigationRequest: () => true,
|
||||
frame: () => mainFrame,
|
||||
url: () => "https://93.184.216.34/start",
|
||||
},
|
||||
);
|
||||
return {
|
||||
request: () => ({
|
||||
url: () => "https://93.184.216.34/final",
|
||||
redirectedFrom: () => ({
|
||||
url: () => "https://postcheck.example/hop",
|
||||
redirectedFrom: () => null,
|
||||
}),
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
try {
|
||||
await expect(
|
||||
createPageViaPlaywright({
|
||||
cdpUrl: "http://127.0.0.1:18792",
|
||||
url: "https://93.184.216.34/start",
|
||||
}),
|
||||
).rejects.toThrow(/getaddrinfo .*postcheck\.example/);
|
||||
|
||||
const pages = await listPagesViaPlaywright({ cdpUrl: "http://127.0.0.1:18792" });
|
||||
expect(pages).toHaveLength(1);
|
||||
expect(pages[0]?.targetId).toBe("TARGET_1");
|
||||
expect(pageClose).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
assertRedirectChainAllowedSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps blocked tab quarantined if close fails", async () => {
|
||||
const { pageGoto, pageClose, getRouteHandler, mainFrame } = installBrowserMocks();
|
||||
pageClose.mockRejectedValueOnce(new Error("close failed"));
|
||||
pageGoto.mockImplementationOnce(async () => {
|
||||
const handler = getRouteHandler();
|
||||
if (!handler) {
|
||||
throw new Error("missing route handler");
|
||||
}
|
||||
await handler(
|
||||
{ continue: vi.fn(async () => {}), abort: vi.fn(async () => {}) },
|
||||
{
|
||||
isNavigationRequest: () => true,
|
||||
frame: () => mainFrame,
|
||||
url: () => "https://93.184.216.34/start",
|
||||
},
|
||||
);
|
||||
await handler(
|
||||
{ continue: vi.fn(async () => {}), abort: vi.fn(async () => {}) },
|
||||
{
|
||||
isNavigationRequest: () => true,
|
||||
frame: () => mainFrame,
|
||||
url: () => "http://127.0.0.1:18080/internal-hop",
|
||||
},
|
||||
);
|
||||
throw new Error("Navigation aborted");
|
||||
});
|
||||
|
||||
await expect(
|
||||
createPageViaPlaywright({
|
||||
cdpUrl: "http://127.0.0.1:18792",
|
||||
url: "https://93.184.216.34/start",
|
||||
}),
|
||||
).rejects.toBeInstanceOf(SsrFBlockedError);
|
||||
|
||||
const pages = await listPagesViaPlaywright({ cdpUrl: "http://127.0.0.1:18792" });
|
||||
expect(pages).toHaveLength(0);
|
||||
await expect(
|
||||
getPageForTargetId({
|
||||
cdpUrl: "http://127.0.0.1:18792",
|
||||
targetId: "TARGET_1",
|
||||
}),
|
||||
).rejects.toBeInstanceOf(BlockedBrowserTargetError);
|
||||
await expect(
|
||||
getPageForTargetId({
|
||||
cdpUrl: "http://127.0.0.1:18792",
|
||||
}),
|
||||
).rejects.toBeInstanceOf(BlockedBrowserTargetError);
|
||||
expect(pageClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("preserves blocked-target quarantine across forced reconnects", async () => {
|
||||
const { pageGoto, pageClose, getRouteHandler, mainFrame } = installBrowserMocks();
|
||||
pageClose.mockRejectedValueOnce(new Error("close failed"));
|
||||
pageGoto.mockImplementationOnce(async () => {
|
||||
const handler = getRouteHandler();
|
||||
if (!handler) {
|
||||
throw new Error("missing route handler");
|
||||
}
|
||||
await handler(
|
||||
{ continue: vi.fn(async () => {}), abort: vi.fn(async () => {}) },
|
||||
{
|
||||
isNavigationRequest: () => true,
|
||||
frame: () => mainFrame,
|
||||
url: () => "https://93.184.216.34/start",
|
||||
},
|
||||
);
|
||||
await handler(
|
||||
{ continue: vi.fn(async () => {}), abort: vi.fn(async () => {}) },
|
||||
{
|
||||
isNavigationRequest: () => true,
|
||||
frame: () => mainFrame,
|
||||
url: () => "http://127.0.0.1:18080/internal-hop",
|
||||
},
|
||||
);
|
||||
throw new Error("Navigation aborted");
|
||||
});
|
||||
|
||||
await expect(
|
||||
createPageViaPlaywright({
|
||||
cdpUrl: "http://127.0.0.1:18792",
|
||||
url: "https://93.184.216.34/start",
|
||||
}),
|
||||
).rejects.toBeInstanceOf(SsrFBlockedError);
|
||||
|
||||
await forceDisconnectPlaywrightForTarget({
|
||||
cdpUrl: "http://127.0.0.1:18792",
|
||||
reason: "test forced reconnect",
|
||||
});
|
||||
|
||||
await expect(
|
||||
getPageForTargetId({
|
||||
cdpUrl: "http://127.0.0.1:18792",
|
||||
targetId: "TARGET_1",
|
||||
}),
|
||||
).rejects.toBeInstanceOf(BlockedBrowserTargetError);
|
||||
});
|
||||
|
||||
it("preserves blocked-target quarantine across transport disconnects", async () => {
|
||||
const { pageGoto, pageClose, getBrowserDisconnectedHandler, getRouteHandler, mainFrame } =
|
||||
installBrowserMocks();
|
||||
pageClose.mockRejectedValueOnce(new Error("close failed"));
|
||||
pageGoto.mockImplementationOnce(async () => {
|
||||
const handler = getRouteHandler();
|
||||
if (!handler) {
|
||||
throw new Error("missing route handler");
|
||||
}
|
||||
await handler(
|
||||
{ continue: vi.fn(async () => {}), abort: vi.fn(async () => {}) },
|
||||
{
|
||||
isNavigationRequest: () => true,
|
||||
frame: () => mainFrame,
|
||||
url: () => "https://93.184.216.34/start",
|
||||
},
|
||||
);
|
||||
await handler(
|
||||
{ continue: vi.fn(async () => {}), abort: vi.fn(async () => {}) },
|
||||
{
|
||||
isNavigationRequest: () => true,
|
||||
frame: () => mainFrame,
|
||||
url: () => "http://127.0.0.1:18080/internal-hop",
|
||||
},
|
||||
);
|
||||
throw new Error("Navigation aborted");
|
||||
});
|
||||
|
||||
await expect(
|
||||
createPageViaPlaywright({
|
||||
cdpUrl: "http://127.0.0.1:18792",
|
||||
url: "https://93.184.216.34/start",
|
||||
}),
|
||||
).rejects.toBeInstanceOf(SsrFBlockedError);
|
||||
|
||||
const disconnectedHandler = getBrowserDisconnectedHandler();
|
||||
expect(disconnectedHandler).toBeTypeOf("function");
|
||||
disconnectedHandler?.();
|
||||
|
||||
await expect(
|
||||
getPageForTargetId({
|
||||
cdpUrl: "http://127.0.0.1:18792",
|
||||
targetId: "TARGET_1",
|
||||
}),
|
||||
).rejects.toBeInstanceOf(BlockedBrowserTargetError);
|
||||
});
|
||||
|
||||
it("keeps blocked tabs inaccessible when target lookup fails", async () => {
|
||||
const { pageGoto, pageClose, sessionSend, getRouteHandler, mainFrame } = installBrowserMocks();
|
||||
pageClose.mockRejectedValueOnce(new Error("close failed"));
|
||||
pageGoto.mockImplementationOnce(async () => {
|
||||
const handler = getRouteHandler();
|
||||
if (!handler) {
|
||||
throw new Error("missing route handler");
|
||||
}
|
||||
await handler(
|
||||
{ continue: vi.fn(async () => {}), abort: vi.fn(async () => {}) },
|
||||
{
|
||||
isNavigationRequest: () => true,
|
||||
frame: () => mainFrame,
|
||||
url: () => "https://93.184.216.34/start",
|
||||
},
|
||||
);
|
||||
await handler(
|
||||
{ continue: vi.fn(async () => {}), abort: vi.fn(async () => {}) },
|
||||
{
|
||||
isNavigationRequest: () => true,
|
||||
frame: () => mainFrame,
|
||||
url: () => "http://127.0.0.1:18080/internal-hop",
|
||||
},
|
||||
);
|
||||
throw new Error("Navigation aborted");
|
||||
});
|
||||
|
||||
await expect(
|
||||
createPageViaPlaywright({
|
||||
cdpUrl: "http://127.0.0.1:18792",
|
||||
url: "https://93.184.216.34/start",
|
||||
}),
|
||||
).rejects.toBeInstanceOf(SsrFBlockedError);
|
||||
|
||||
sessionSend.mockRejectedValueOnce(new Error("Target lookup failed"));
|
||||
await expect(
|
||||
getPageForTargetId({
|
||||
cdpUrl: "http://127.0.0.1:18792",
|
||||
}),
|
||||
).rejects.toBeInstanceOf(BlockedBrowserTargetError);
|
||||
});
|
||||
|
||||
it("does not fall back to another tab when explicit target lookup misses", async () => {
|
||||
const { pageGoto, pageClose, sessionSend, getRouteHandler, mainFrame } = installBrowserMocks();
|
||||
pageClose.mockRejectedValueOnce(new Error("close failed"));
|
||||
pageGoto.mockImplementationOnce(async () => {
|
||||
const handler = getRouteHandler();
|
||||
if (!handler) {
|
||||
throw new Error("missing route handler");
|
||||
}
|
||||
await handler(
|
||||
{ continue: vi.fn(async () => {}), abort: vi.fn(async () => {}) },
|
||||
{
|
||||
isNavigationRequest: () => true,
|
||||
frame: () => mainFrame,
|
||||
url: () => "https://93.184.216.34/start",
|
||||
},
|
||||
);
|
||||
await handler(
|
||||
{ continue: vi.fn(async () => {}), abort: vi.fn(async () => {}) },
|
||||
{
|
||||
isNavigationRequest: () => true,
|
||||
frame: () => mainFrame,
|
||||
url: () => "http://127.0.0.1:18080/internal-hop",
|
||||
},
|
||||
);
|
||||
throw new Error("Navigation aborted");
|
||||
});
|
||||
|
||||
await expect(
|
||||
createPageViaPlaywright({
|
||||
cdpUrl: "http://127.0.0.1:18792",
|
||||
url: "https://93.184.216.34/start",
|
||||
}),
|
||||
).rejects.toBeInstanceOf(SsrFBlockedError);
|
||||
|
||||
sessionSend.mockImplementationOnce(async (method: string) => {
|
||||
if (method === "Target.getTargetInfo") {
|
||||
return { targetInfo: { targetId: "TARGET_2" } };
|
||||
}
|
||||
return {};
|
||||
});
|
||||
await createPageViaPlaywright({
|
||||
cdpUrl: "http://127.0.0.1:18792",
|
||||
url: "https://example.com",
|
||||
});
|
||||
|
||||
let targetInfoLookups = 0;
|
||||
sessionSend.mockImplementation(async (method: string) => {
|
||||
if (method === "Target.getTargetInfo") {
|
||||
targetInfoLookups += 1;
|
||||
return {
|
||||
targetInfo: { targetId: targetInfoLookups % 2 === 1 ? "TARGET_1" : "TARGET_2" },
|
||||
};
|
||||
}
|
||||
return {};
|
||||
});
|
||||
|
||||
await expect(
|
||||
getPageForTargetId({
|
||||
cdpUrl: "http://127.0.0.1:18792",
|
||||
targetId: "MISSING_TARGET",
|
||||
}),
|
||||
).rejects.toBeInstanceOf(BrowserTabNotFoundError);
|
||||
});
|
||||
|
||||
it("quarantines the actual page when blocked navigation receives a stale target id", async () => {
|
||||
const { pageGoto, pageClose, sessionSend, getRouteHandler, mainFrame } = installBrowserMocks();
|
||||
pageClose.mockRejectedValueOnce(new Error("close failed"));
|
||||
|
||||
await createPageViaPlaywright({
|
||||
cdpUrl: "http://127.0.0.1:18792",
|
||||
url: "about:blank",
|
||||
});
|
||||
|
||||
const page = await getPageForTargetId({
|
||||
cdpUrl: "http://127.0.0.1:18792",
|
||||
targetId: "MISSING_TARGET",
|
||||
});
|
||||
|
||||
pageGoto.mockImplementationOnce(async () => {
|
||||
const handler = getRouteHandler();
|
||||
if (!handler) {
|
||||
throw new Error("missing route handler");
|
||||
}
|
||||
await handler(
|
||||
{ continue: vi.fn(async () => {}), abort: vi.fn(async () => {}) },
|
||||
{
|
||||
isNavigationRequest: () => true,
|
||||
frame: () => mainFrame,
|
||||
url: () => "http://127.0.0.1:18080/internal-hop",
|
||||
},
|
||||
);
|
||||
throw new Error("Navigation aborted");
|
||||
});
|
||||
|
||||
// Simulate target-info churn while quarantining so caller target id cannot be trusted.
|
||||
sessionSend.mockRejectedValueOnce(new Error("Target lookup failed"));
|
||||
|
||||
await expect(
|
||||
gotoPageWithNavigationGuard({
|
||||
cdpUrl: "http://127.0.0.1:18792",
|
||||
page,
|
||||
url: "https://93.184.216.34/start",
|
||||
timeoutMs: 1000,
|
||||
targetId: "MISSING_TARGET",
|
||||
}),
|
||||
).rejects.toBeInstanceOf(SsrFBlockedError);
|
||||
|
||||
await expect(
|
||||
getPageForTargetId({
|
||||
cdpUrl: "http://127.0.0.1:18792",
|
||||
}),
|
||||
).rejects.toBeInstanceOf(BlockedBrowserTargetError);
|
||||
});
|
||||
|
||||
it("falls back to caller targetId quarantine when target lookup fails", async () => {
|
||||
const first = installBrowserMocks();
|
||||
first.pageClose.mockRejectedValueOnce(new Error("close failed"));
|
||||
|
||||
await createPageViaPlaywright({
|
||||
cdpUrl: "http://127.0.0.1:18792",
|
||||
url: "about:blank",
|
||||
});
|
||||
const page = await getPageForTargetId({
|
||||
cdpUrl: "http://127.0.0.1:18792",
|
||||
targetId: "TARGET_1",
|
||||
});
|
||||
|
||||
first.pageGoto.mockImplementationOnce(async () => {
|
||||
const handler = first.getRouteHandler();
|
||||
if (!handler) {
|
||||
throw new Error("missing route handler");
|
||||
}
|
||||
await handler(
|
||||
{ continue: vi.fn(async () => {}), abort: vi.fn(async () => {}) },
|
||||
{
|
||||
isNavigationRequest: () => true,
|
||||
frame: () => first.mainFrame,
|
||||
url: () => "http://127.0.0.1:18080/internal-hop",
|
||||
},
|
||||
);
|
||||
throw new Error("Navigation aborted");
|
||||
});
|
||||
|
||||
first.sessionSend.mockRejectedValueOnce(new Error("Target lookup failed"));
|
||||
await expect(
|
||||
gotoPageWithNavigationGuard({
|
||||
cdpUrl: "http://127.0.0.1:18792",
|
||||
page,
|
||||
url: "https://93.184.216.34/start",
|
||||
timeoutMs: 1000,
|
||||
targetId: "TARGET_1",
|
||||
}),
|
||||
).rejects.toBeInstanceOf(SsrFBlockedError);
|
||||
|
||||
await forceDisconnectPlaywrightForTarget({
|
||||
cdpUrl: "http://127.0.0.1:18792",
|
||||
reason: "test reconnect after blocked navigation",
|
||||
});
|
||||
|
||||
const second = installBrowserMocks();
|
||||
second.pushOpenPage();
|
||||
|
||||
await expect(
|
||||
getPageForTargetId({
|
||||
cdpUrl: "http://127.0.0.1:18792",
|
||||
targetId: "TARGET_1",
|
||||
}),
|
||||
).rejects.toBeInstanceOf(BlockedBrowserTargetError);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,10 +5,11 @@ import type {
|
||||
Page,
|
||||
Request,
|
||||
Response,
|
||||
Route,
|
||||
} from "playwright-core";
|
||||
import { chromium } from "playwright-core";
|
||||
import { formatErrorMessage } from "../infra/errors.js";
|
||||
import type { SsrFPolicy } from "../infra/net/ssrf.js";
|
||||
import { SsrFBlockedError, type SsrFPolicy } from "../infra/net/ssrf.js";
|
||||
import { withNoProxyForCdpUrl } from "./cdp-proxy-bypass.js";
|
||||
import {
|
||||
appendCdpPath,
|
||||
@@ -24,6 +25,7 @@ import {
|
||||
assertBrowserNavigationAllowed,
|
||||
assertBrowserNavigationRedirectChainAllowed,
|
||||
assertBrowserNavigationResultAllowed,
|
||||
InvalidBrowserNavigationUrlError,
|
||||
withBrowserNavigationPolicy,
|
||||
} from "./navigation-guard.js";
|
||||
import { withPageScopedCdpClient } from "./pw-session.page-cdp.js";
|
||||
@@ -118,6 +120,8 @@ const MAX_NETWORK_REQUESTS = 500;
|
||||
|
||||
const cachedByCdpUrl = new Map<string, ConnectedBrowser>();
|
||||
const connectingByCdpUrl = new Map<string, Promise<ConnectedBrowser>>();
|
||||
const blockedTargetsByCdpUrl = new Set<string>();
|
||||
const blockedPageRefsByCdpUrl = new Map<string, WeakSet<Page>>();
|
||||
|
||||
function normalizeCdpUrl(raw: string) {
|
||||
return raw.replace(/\/$/, "");
|
||||
@@ -133,10 +137,99 @@ function findNetworkRequestById(state: PageState, id: string): BrowserNetworkReq
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function roleRefsKey(cdpUrl: string, targetId: string) {
|
||||
function targetKey(cdpUrl: string, targetId: string) {
|
||||
return `${normalizeCdpUrl(cdpUrl)}::${targetId}`;
|
||||
}
|
||||
|
||||
function roleRefsKey(cdpUrl: string, targetId: string) {
|
||||
return targetKey(cdpUrl, targetId);
|
||||
}
|
||||
|
||||
function isBlockedTarget(cdpUrl: string, targetId?: string): boolean {
|
||||
const normalizedTargetId = targetId?.trim() || "";
|
||||
if (!normalizedTargetId) {
|
||||
return false;
|
||||
}
|
||||
return blockedTargetsByCdpUrl.has(targetKey(cdpUrl, normalizedTargetId));
|
||||
}
|
||||
|
||||
function markTargetBlocked(cdpUrl: string, targetId?: string): void {
|
||||
const normalizedTargetId = targetId?.trim() || "";
|
||||
if (!normalizedTargetId) {
|
||||
return;
|
||||
}
|
||||
blockedTargetsByCdpUrl.add(targetKey(cdpUrl, normalizedTargetId));
|
||||
}
|
||||
|
||||
function clearBlockedTarget(cdpUrl: string, targetId?: string): void {
|
||||
const normalizedTargetId = targetId?.trim() || "";
|
||||
if (!normalizedTargetId) {
|
||||
return;
|
||||
}
|
||||
blockedTargetsByCdpUrl.delete(targetKey(cdpUrl, normalizedTargetId));
|
||||
}
|
||||
|
||||
function clearBlockedTargetsForCdpUrl(cdpUrl?: string): void {
|
||||
if (!cdpUrl) {
|
||||
blockedTargetsByCdpUrl.clear();
|
||||
return;
|
||||
}
|
||||
const prefix = `${normalizeCdpUrl(cdpUrl)}::`;
|
||||
for (const key of blockedTargetsByCdpUrl) {
|
||||
if (key.startsWith(prefix)) {
|
||||
blockedTargetsByCdpUrl.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function blockedPageRefsForCdpUrl(cdpUrl: string): WeakSet<Page> {
|
||||
const normalized = normalizeCdpUrl(cdpUrl);
|
||||
const existing = blockedPageRefsByCdpUrl.get(normalized);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
const created = new WeakSet<Page>();
|
||||
blockedPageRefsByCdpUrl.set(normalized, created);
|
||||
return created;
|
||||
}
|
||||
|
||||
function isBlockedPageRef(cdpUrl: string, page: Page): boolean {
|
||||
return blockedPageRefsByCdpUrl.get(normalizeCdpUrl(cdpUrl))?.has(page) ?? false;
|
||||
}
|
||||
|
||||
function markPageRefBlocked(cdpUrl: string, page: Page): void {
|
||||
blockedPageRefsForCdpUrl(cdpUrl).add(page);
|
||||
}
|
||||
|
||||
function clearBlockedPageRefsForCdpUrl(cdpUrl?: string): void {
|
||||
if (!cdpUrl) {
|
||||
blockedPageRefsByCdpUrl.clear();
|
||||
return;
|
||||
}
|
||||
blockedPageRefsByCdpUrl.delete(normalizeCdpUrl(cdpUrl));
|
||||
}
|
||||
|
||||
function clearBlockedPageRef(cdpUrl: string, page: Page): void {
|
||||
blockedPageRefsByCdpUrl.get(normalizeCdpUrl(cdpUrl))?.delete(page);
|
||||
}
|
||||
|
||||
function hasBlockedTargetsForCdpUrl(cdpUrl: string): boolean {
|
||||
const prefix = `${normalizeCdpUrl(cdpUrl)}::`;
|
||||
for (const key of blockedTargetsByCdpUrl) {
|
||||
if (key.startsWith(prefix)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export class BlockedBrowserTargetError extends Error {
|
||||
constructor() {
|
||||
super("Browser target is unavailable after SSRF policy blocked its navigation.");
|
||||
this.name = "BlockedBrowserTargetError";
|
||||
}
|
||||
}
|
||||
|
||||
export function rememberRoleRefsForTarget(opts: {
|
||||
cdpUrl: string;
|
||||
targetId: string;
|
||||
@@ -395,6 +488,37 @@ async function getAllPages(browser: Browser): Promise<Page[]> {
|
||||
return pages;
|
||||
}
|
||||
|
||||
async function partitionAccessiblePages(opts: {
|
||||
cdpUrl: string;
|
||||
pages: Page[];
|
||||
}): Promise<{ accessible: Page[]; blockedCount: number }> {
|
||||
const accessible: Page[] = [];
|
||||
let blockedCount = 0;
|
||||
for (const page of opts.pages) {
|
||||
if (isBlockedPageRef(opts.cdpUrl, page)) {
|
||||
blockedCount += 1;
|
||||
continue;
|
||||
}
|
||||
const targetId = await pageTargetId(page).catch(() => null);
|
||||
// Fail closed when we cannot resolve a target id while this session has
|
||||
// quarantined targets; otherwise a blocked tab can become selectable.
|
||||
if (!targetId) {
|
||||
if (hasBlockedTargetsForCdpUrl(opts.cdpUrl)) {
|
||||
blockedCount += 1;
|
||||
continue;
|
||||
}
|
||||
accessible.push(page);
|
||||
continue;
|
||||
}
|
||||
if (isBlockedTarget(opts.cdpUrl, targetId)) {
|
||||
blockedCount += 1;
|
||||
continue;
|
||||
}
|
||||
accessible.push(page);
|
||||
}
|
||||
return { accessible, blockedCount };
|
||||
}
|
||||
|
||||
async function pageTargetId(page: Page): Promise<string | null> {
|
||||
const session = await page.context().newCDPSession(page);
|
||||
try {
|
||||
@@ -484,6 +608,9 @@ async function resolvePageByTargetIdOrThrow(opts: {
|
||||
cdpUrl: string;
|
||||
targetId: string;
|
||||
}): Promise<Page> {
|
||||
if (isBlockedTarget(opts.cdpUrl, opts.targetId)) {
|
||||
throw new BlockedBrowserTargetError();
|
||||
}
|
||||
const { browser } = await connectBrowser(opts.cdpUrl);
|
||||
const page = await findPageByTargetId(browser, opts.targetId, opts.cdpUrl);
|
||||
if (!page) {
|
||||
@@ -496,24 +623,165 @@ export async function getPageForTargetId(opts: {
|
||||
cdpUrl: string;
|
||||
targetId?: string;
|
||||
}): Promise<Page> {
|
||||
if (opts.targetId && isBlockedTarget(opts.cdpUrl, opts.targetId)) {
|
||||
throw new BlockedBrowserTargetError();
|
||||
}
|
||||
const { browser } = await connectBrowser(opts.cdpUrl);
|
||||
const pages = await getAllPages(browser);
|
||||
if (!pages.length) {
|
||||
throw new Error("No pages available in the connected browser.");
|
||||
}
|
||||
const first = pages[0];
|
||||
|
||||
const { accessible, blockedCount } = await partitionAccessiblePages({
|
||||
cdpUrl: opts.cdpUrl,
|
||||
pages,
|
||||
});
|
||||
if (!accessible.length) {
|
||||
if (blockedCount > 0) {
|
||||
throw new BlockedBrowserTargetError();
|
||||
}
|
||||
throw new Error("No pages available in the connected browser.");
|
||||
}
|
||||
const first = accessible[0];
|
||||
if (!opts.targetId) {
|
||||
return first;
|
||||
}
|
||||
const found = await findPageByTargetId(browser, opts.targetId, opts.cdpUrl);
|
||||
if (!found) {
|
||||
// If Playwright only exposes a single Page, use it as a best-effort fallback.
|
||||
if (pages.length === 1) {
|
||||
return first;
|
||||
if (found) {
|
||||
if (isBlockedPageRef(opts.cdpUrl, found)) {
|
||||
throw new BlockedBrowserTargetError();
|
||||
}
|
||||
const foundTargetId = await pageTargetId(found).catch(() => null);
|
||||
if (foundTargetId && isBlockedTarget(opts.cdpUrl, foundTargetId)) {
|
||||
throw new BlockedBrowserTargetError();
|
||||
}
|
||||
return found;
|
||||
}
|
||||
// If Playwright only exposes a single Page total, use it as a best-effort fallback.
|
||||
if (pages.length === 1) {
|
||||
return first;
|
||||
}
|
||||
throw new BrowserTabNotFoundError();
|
||||
}
|
||||
|
||||
function isTopLevelNavigationRequest(page: Page, request: Request): boolean {
|
||||
if (!request.isNavigationRequest()) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
return request.frame() === page.mainFrame();
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
function isPolicyDenyNavigationError(err: unknown): boolean {
|
||||
return err instanceof SsrFBlockedError || err instanceof InvalidBrowserNavigationUrlError;
|
||||
}
|
||||
|
||||
async function closeBlockedNavigationTarget(opts: {
|
||||
cdpUrl: string;
|
||||
page: Page;
|
||||
targetId?: string;
|
||||
}): Promise<void> {
|
||||
// Quarantine the concrete page first; then persist by target id when available.
|
||||
markPageRefBlocked(opts.cdpUrl, opts.page);
|
||||
const resolvedTargetId = await pageTargetId(opts.page).catch(() => null);
|
||||
const fallbackTargetId = opts.targetId?.trim() || "";
|
||||
const targetIdToBlock = resolvedTargetId || fallbackTargetId;
|
||||
if (targetIdToBlock) {
|
||||
markTargetBlocked(opts.cdpUrl, targetIdToBlock);
|
||||
}
|
||||
await opts.page.close().catch(() => {});
|
||||
}
|
||||
|
||||
export async function assertPageNavigationCompletedSafely(opts: {
|
||||
cdpUrl: string;
|
||||
page: Page;
|
||||
response: Response | null;
|
||||
ssrfPolicy?: SsrFPolicy;
|
||||
targetId?: string;
|
||||
}): Promise<void> {
|
||||
const navigationPolicy = withBrowserNavigationPolicy(opts.ssrfPolicy);
|
||||
try {
|
||||
await assertBrowserNavigationRedirectChainAllowed({
|
||||
request: opts.response?.request(),
|
||||
...navigationPolicy,
|
||||
});
|
||||
await assertBrowserNavigationResultAllowed({
|
||||
url: opts.page.url(),
|
||||
...navigationPolicy,
|
||||
});
|
||||
} catch (err) {
|
||||
if (isPolicyDenyNavigationError(err)) {
|
||||
await closeBlockedNavigationTarget({
|
||||
cdpUrl: opts.cdpUrl,
|
||||
page: opts.page,
|
||||
targetId: opts.targetId,
|
||||
});
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export async function gotoPageWithNavigationGuard(opts: {
|
||||
cdpUrl: string;
|
||||
page: Page;
|
||||
url: string;
|
||||
timeoutMs: number;
|
||||
ssrfPolicy?: SsrFPolicy;
|
||||
targetId?: string;
|
||||
}): Promise<Response | null> {
|
||||
const navigationPolicy = withBrowserNavigationPolicy(opts.ssrfPolicy);
|
||||
let blockedError: unknown = null;
|
||||
|
||||
const handler = async (route: Route, request: Request) => {
|
||||
if (blockedError) {
|
||||
await route.abort().catch(() => {});
|
||||
return;
|
||||
}
|
||||
if (!isTopLevelNavigationRequest(opts.page, request)) {
|
||||
await route.continue();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await assertBrowserNavigationAllowed({
|
||||
url: request.url(),
|
||||
...navigationPolicy,
|
||||
});
|
||||
} catch (err) {
|
||||
if (isPolicyDenyNavigationError(err)) {
|
||||
blockedError = err;
|
||||
await route.abort().catch(() => {});
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
await route.continue();
|
||||
};
|
||||
|
||||
await opts.page.route("**", handler);
|
||||
try {
|
||||
const response = await opts.page.goto(opts.url, { timeout: opts.timeoutMs });
|
||||
if (blockedError) {
|
||||
throw blockedError;
|
||||
}
|
||||
return response;
|
||||
} catch (err) {
|
||||
if (blockedError) {
|
||||
throw blockedError;
|
||||
}
|
||||
throw err;
|
||||
} finally {
|
||||
await opts.page.unroute("**", handler).catch(() => {});
|
||||
if (blockedError) {
|
||||
await closeBlockedNavigationTarget({
|
||||
cdpUrl: opts.cdpUrl,
|
||||
page: opts.page,
|
||||
targetId: opts.targetId,
|
||||
});
|
||||
}
|
||||
throw new BrowserTabNotFoundError();
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
export function refLocator(page: Page, ref: string) {
|
||||
@@ -559,6 +827,8 @@ export async function closePlaywrightBrowserConnection(opts?: { cdpUrl?: string
|
||||
const normalized = opts?.cdpUrl ? normalizeCdpUrl(opts.cdpUrl) : null;
|
||||
|
||||
if (normalized) {
|
||||
clearBlockedTargetsForCdpUrl(normalized);
|
||||
clearBlockedPageRefsForCdpUrl(normalized);
|
||||
const cur = cachedByCdpUrl.get(normalized);
|
||||
cachedByCdpUrl.delete(normalized);
|
||||
connectingByCdpUrl.delete(normalized);
|
||||
@@ -573,6 +843,8 @@ export async function closePlaywrightBrowserConnection(opts?: { cdpUrl?: string
|
||||
}
|
||||
|
||||
const connections = Array.from(cachedByCdpUrl.values());
|
||||
clearBlockedTargetsForCdpUrl();
|
||||
clearBlockedPageRefsForCdpUrl();
|
||||
cachedByCdpUrl.clear();
|
||||
connectingByCdpUrl.clear();
|
||||
for (const cur of connections) {
|
||||
@@ -733,8 +1005,11 @@ export async function listPagesViaPlaywright(opts: { cdpUrl: string }): Promise<
|
||||
}> = [];
|
||||
|
||||
for (const page of pages) {
|
||||
if (isBlockedPageRef(opts.cdpUrl, page)) {
|
||||
continue;
|
||||
}
|
||||
const tid = await pageTargetId(page).catch(() => null);
|
||||
if (tid) {
|
||||
if (tid && !isBlockedTarget(opts.cdpUrl, tid)) {
|
||||
results.push({
|
||||
targetId: tid,
|
||||
title: await page.title().catch(() => ""),
|
||||
@@ -767,6 +1042,9 @@ export async function createPageViaPlaywright(opts: {
|
||||
|
||||
const page = await context.newPage();
|
||||
ensurePageState(page);
|
||||
clearBlockedPageRef(opts.cdpUrl, page);
|
||||
const createdTargetId = await pageTargetId(page).catch(() => null);
|
||||
clearBlockedTarget(opts.cdpUrl, createdTargetId ?? undefined);
|
||||
|
||||
// Navigate to the URL
|
||||
const targetUrl = opts.url.trim() || "about:blank";
|
||||
@@ -776,22 +1054,32 @@ export async function createPageViaPlaywright(opts: {
|
||||
url: targetUrl,
|
||||
...navigationPolicy,
|
||||
});
|
||||
const response = await page.goto(targetUrl, { timeout: 30_000 }).catch(() => {
|
||||
// Navigation might fail for some URLs, but page is still created
|
||||
return null;
|
||||
});
|
||||
await assertBrowserNavigationRedirectChainAllowed({
|
||||
request: response?.request(),
|
||||
...navigationPolicy,
|
||||
});
|
||||
await assertBrowserNavigationResultAllowed({
|
||||
url: page.url(),
|
||||
...navigationPolicy,
|
||||
let response: Response | null = null;
|
||||
try {
|
||||
response = await gotoPageWithNavigationGuard({
|
||||
cdpUrl: opts.cdpUrl,
|
||||
page,
|
||||
url: targetUrl,
|
||||
timeoutMs: 30_000,
|
||||
ssrfPolicy: opts.ssrfPolicy,
|
||||
targetId: createdTargetId ?? undefined,
|
||||
});
|
||||
} catch (err) {
|
||||
if (isPolicyDenyNavigationError(err) || err instanceof BlockedBrowserTargetError) {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
await assertPageNavigationCompletedSafely({
|
||||
cdpUrl: opts.cdpUrl,
|
||||
page,
|
||||
response,
|
||||
ssrfPolicy: opts.ssrfPolicy,
|
||||
targetId: createdTargetId ?? undefined,
|
||||
});
|
||||
}
|
||||
|
||||
// Get the targetId for this page
|
||||
const tid = await pageTargetId(page).catch(() => null);
|
||||
const tid = createdTargetId || (await pageTargetId(page).catch(() => null));
|
||||
if (!tid) {
|
||||
throw new Error("Failed to get targetId for new page");
|
||||
}
|
||||
|
||||
@@ -63,6 +63,21 @@ describe("pw-tools-core.snapshot navigate guard", () => {
|
||||
});
|
||||
|
||||
expect(goto).toHaveBeenCalledWith("https://example.com", { timeout: 1000 });
|
||||
expect(getPwToolsCoreSessionMocks().gotoPageWithNavigationGuard).toHaveBeenCalledWith({
|
||||
cdpUrl: "http://127.0.0.1:18792",
|
||||
page: expect.anything(),
|
||||
ssrfPolicy: { allowPrivateNetwork: true },
|
||||
targetId: undefined,
|
||||
timeoutMs: 1000,
|
||||
url: "https://example.com",
|
||||
});
|
||||
expect(getPwToolsCoreSessionMocks().assertPageNavigationCompletedSafely).toHaveBeenCalledWith({
|
||||
cdpUrl: "http://127.0.0.1:18792",
|
||||
page: expect.anything(),
|
||||
response: null,
|
||||
ssrfPolicy: { allowPrivateNetwork: true },
|
||||
targetId: undefined,
|
||||
});
|
||||
expect(result.url).toBe("https://example.com");
|
||||
});
|
||||
|
||||
@@ -92,7 +107,7 @@ describe("pw-tools-core.snapshot navigate guard", () => {
|
||||
targetId: "tab-1",
|
||||
reason: "retry navigate after detached frame",
|
||||
});
|
||||
expect(goto).toHaveBeenCalledTimes(2);
|
||||
expect(getPwToolsCoreSessionMocks().gotoPageWithNavigationGuard).toHaveBeenCalledTimes(2);
|
||||
expect(result.url).toBe("https://example.com/recovered");
|
||||
});
|
||||
|
||||
@@ -113,6 +128,9 @@ describe("pw-tools-core.snapshot navigate guard", () => {
|
||||
goto,
|
||||
url: vi.fn(() => "https://93.184.216.34/final"),
|
||||
});
|
||||
getPwToolsCoreSessionMocks().assertPageNavigationCompletedSafely.mockRejectedValueOnce(
|
||||
new SsrFBlockedError("Blocked hostname or private/internal/special-use IP address"),
|
||||
);
|
||||
|
||||
await expect(
|
||||
mod.navigateViaPlaywright({
|
||||
@@ -121,6 +139,9 @@ describe("pw-tools-core.snapshot navigate guard", () => {
|
||||
}),
|
||||
).rejects.toBeInstanceOf(SsrFBlockedError);
|
||||
|
||||
expect(goto).toHaveBeenCalledTimes(1);
|
||||
expect(getPwToolsCoreSessionMocks().gotoPageWithNavigationGuard).toHaveBeenCalledTimes(1);
|
||||
expect(getPwToolsCoreSessionMocks().assertPageNavigationCompletedSafely).toHaveBeenCalledTimes(
|
||||
1,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
import type { SsrFPolicy } from "../infra/net/ssrf.js";
|
||||
import { type AriaSnapshotNode, formatAriaSnapshot, type RawAXNode } from "./cdp.js";
|
||||
import {
|
||||
assertBrowserNavigationAllowed,
|
||||
assertBrowserNavigationRedirectChainAllowed,
|
||||
assertBrowserNavigationResultAllowed,
|
||||
withBrowserNavigationPolicy,
|
||||
} from "./navigation-guard.js";
|
||||
import { assertBrowserNavigationAllowed, withBrowserNavigationPolicy } from "./navigation-guard.js";
|
||||
import {
|
||||
buildRoleSnapshotFromAiSnapshot,
|
||||
buildRoleSnapshotFromAriaSnapshot,
|
||||
@@ -14,9 +9,11 @@ import {
|
||||
type RoleRefMap,
|
||||
} from "./pw-role-snapshot.js";
|
||||
import {
|
||||
assertPageNavigationCompletedSafely,
|
||||
ensurePageState,
|
||||
forceDisconnectPlaywrightForTarget,
|
||||
getPageForTargetId,
|
||||
gotoPageWithNavigationGuard,
|
||||
storeRoleRefsForTarget,
|
||||
type WithSnapshotForAI,
|
||||
} from "./pw-session.js";
|
||||
@@ -197,7 +194,15 @@ export async function navigateViaPlaywright(opts: {
|
||||
const timeout = Math.max(1000, Math.min(120_000, opts.timeoutMs ?? 20_000));
|
||||
let page = await getPageForTargetId(opts);
|
||||
ensurePageState(page);
|
||||
const navigate = async () => await page.goto(url, { timeout });
|
||||
const navigate = async () =>
|
||||
await gotoPageWithNavigationGuard({
|
||||
cdpUrl: opts.cdpUrl,
|
||||
page,
|
||||
url,
|
||||
timeoutMs: timeout,
|
||||
ssrfPolicy: opts.ssrfPolicy,
|
||||
targetId: opts.targetId,
|
||||
});
|
||||
let response;
|
||||
try {
|
||||
response = await navigate();
|
||||
@@ -216,15 +221,14 @@ export async function navigateViaPlaywright(opts: {
|
||||
ensurePageState(page);
|
||||
response = await navigate();
|
||||
}
|
||||
await assertBrowserNavigationRedirectChainAllowed({
|
||||
request: response?.request(),
|
||||
...withBrowserNavigationPolicy(opts.ssrfPolicy),
|
||||
await assertPageNavigationCompletedSafely({
|
||||
cdpUrl: opts.cdpUrl,
|
||||
page,
|
||||
response,
|
||||
ssrfPolicy: opts.ssrfPolicy,
|
||||
targetId: opts.targetId,
|
||||
});
|
||||
const finalUrl = page.url();
|
||||
await assertBrowserNavigationResultAllowed({
|
||||
url: finalUrl,
|
||||
...withBrowserNavigationPolicy(opts.ssrfPolicy),
|
||||
});
|
||||
return { url: finalUrl };
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ let pageState: {
|
||||
};
|
||||
|
||||
const sessionMocks = vi.hoisted(() => ({
|
||||
assertPageNavigationCompletedSafely: vi.fn(async () => {}),
|
||||
getPageForTargetId: vi.fn(async () => {
|
||||
if (!currentPage) {
|
||||
throw new Error("missing page");
|
||||
@@ -23,6 +24,13 @@ const sessionMocks = vi.hoisted(() => ({
|
||||
}),
|
||||
ensurePageState: vi.fn(() => pageState),
|
||||
forceDisconnectPlaywrightForTarget: vi.fn(async () => {}),
|
||||
gotoPageWithNavigationGuard: vi.fn(
|
||||
async (opts: {
|
||||
url: string;
|
||||
timeoutMs: number;
|
||||
page: { goto: (url: string, init: { timeout: number }) => Promise<unknown> };
|
||||
}) => (await opts.page.goto(opts.url, { timeout: opts.timeoutMs })) ?? null,
|
||||
),
|
||||
restoreRoleRefsForTarget: vi.fn(() => {}),
|
||||
storeRoleRefsForTarget: vi.fn(() => {}),
|
||||
refLocator: vi.fn(() => {
|
||||
|
||||
@@ -4,8 +4,8 @@ import type {
|
||||
} from "openclaw/plugin-sdk/media-understanding";
|
||||
import {
|
||||
assertOkOrThrowHttpError,
|
||||
normalizeBaseUrl,
|
||||
postTranscriptionRequest,
|
||||
resolveProviderHttpRequestConfig,
|
||||
requireTranscriptionText,
|
||||
} from "openclaw/plugin-sdk/provider-http";
|
||||
|
||||
@@ -31,9 +31,21 @@ export async function transcribeDeepgramAudio(
|
||||
params: AudioTranscriptionRequest,
|
||||
): Promise<AudioTranscriptionResult> {
|
||||
const fetchFn = params.fetchFn ?? fetch;
|
||||
const baseUrl = normalizeBaseUrl(params.baseUrl, DEFAULT_DEEPGRAM_AUDIO_BASE_URL);
|
||||
const allowPrivate = Boolean(params.baseUrl?.trim());
|
||||
const model = resolveModel(params.model);
|
||||
const { baseUrl, allowPrivateNetwork, headers, dispatcherPolicy } =
|
||||
resolveProviderHttpRequestConfig({
|
||||
baseUrl: params.baseUrl,
|
||||
defaultBaseUrl: DEFAULT_DEEPGRAM_AUDIO_BASE_URL,
|
||||
headers: params.headers,
|
||||
request: params.request,
|
||||
defaultHeaders: {
|
||||
authorization: `Token ${params.apiKey}`,
|
||||
"content-type": params.mime ?? "application/octet-stream",
|
||||
},
|
||||
provider: "deepgram",
|
||||
capability: "audio",
|
||||
transport: "media-understanding",
|
||||
});
|
||||
|
||||
const url = new URL(`${baseUrl}/listen`);
|
||||
url.searchParams.set("model", model);
|
||||
@@ -49,23 +61,15 @@ export async function transcribeDeepgramAudio(
|
||||
}
|
||||
}
|
||||
|
||||
const headers = new Headers(params.headers);
|
||||
if (!headers.has("authorization")) {
|
||||
headers.set("authorization", `Token ${params.apiKey}`);
|
||||
}
|
||||
if (!headers.has("content-type")) {
|
||||
headers.set("content-type", params.mime ?? "application/octet-stream");
|
||||
}
|
||||
|
||||
const body = new Uint8Array(params.buffer);
|
||||
const { response: res, release } = await postTranscriptionRequest({
|
||||
url: url.toString(),
|
||||
provider: "deepgram",
|
||||
headers,
|
||||
body,
|
||||
timeoutMs: params.timeoutMs,
|
||||
fetchFn,
|
||||
allowPrivateNetwork: allowPrivate,
|
||||
allowPrivateNetwork,
|
||||
dispatcherPolicy,
|
||||
});
|
||||
|
||||
try {
|
||||
|
||||
@@ -63,6 +63,7 @@ Useful options:
|
||||
- `title`: explicit viewer title
|
||||
- `ttlSeconds`: artifact lifetime
|
||||
- `baseUrl`: override the gateway base URL used in the returned viewer link (origin or origin+base path only; no query/hash)
|
||||
- `viewerBaseUrl` plugin config: persistent fallback used when a tool call omits `baseUrl`
|
||||
|
||||
Input safety limits:
|
||||
|
||||
@@ -109,6 +110,24 @@ Explicit tool parameters still win over these defaults.
|
||||
Security options:
|
||||
|
||||
- `security.allowRemoteViewer` (default `false`): allows non-loopback access to `/plugins/diffs/view/...` token URLs
|
||||
- `viewerBaseUrl` (optional): persistent viewer-link origin/path fallback for shareable URLs
|
||||
|
||||
Example:
|
||||
|
||||
```json5
|
||||
{
|
||||
plugins: {
|
||||
entries: {
|
||||
diffs: {
|
||||
enabled: true,
|
||||
config: {
|
||||
viewerBaseUrl: "https://gateway.example.com/openclaw",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
## Example Agent Prompts
|
||||
|
||||
@@ -177,7 +196,9 @@ diff --git a/src/example.ts b/src/example.ts
|
||||
|
||||
- The viewer is hosted locally through the gateway under `/plugins/diffs/...`.
|
||||
- Artifacts are ephemeral and stored in the plugin temp subfolder (`$TMPDIR/openclaw-diffs`).
|
||||
- Default viewer URLs use loopback (`127.0.0.1`) unless you set `baseUrl` (or use `gateway.bind=custom` + `gateway.customBindHost`).
|
||||
- Default viewer URLs use loopback (`127.0.0.1`) unless you set plugin `viewerBaseUrl`, pass `baseUrl`, or use `gateway.bind=custom` + `gateway.customBindHost`.
|
||||
- If `gateway.trustedProxies` includes loopback for a same-host proxy (for example Tailscale Serve), raw `127.0.0.1` viewer requests without forwarded client-IP headers fail closed by design.
|
||||
- In that topology, prefer `mode=file` / `mode=both` for attachments, or intentionally enable remote viewers and set plugin `viewerBaseUrl` (or pass a proxy/public `baseUrl`) when you need a shareable viewer URL.
|
||||
- Remote viewer misses are throttled to reduce token-guess abuse.
|
||||
- PNG or PDF rendering requires a Chromium-compatible browser. Set `browser.executablePath` if auto-detection is not enough.
|
||||
- If your delivery channel compresses images heavily (for example Telegram or WhatsApp), prefer `fileFormat: "pdf"` to preserve readability.
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
diffsPluginConfigSchema,
|
||||
resolveDiffsPluginDefaults,
|
||||
resolveDiffsPluginSecurity,
|
||||
resolveDiffsPluginViewerBaseUrl,
|
||||
} from "./src/config.js";
|
||||
import { createDiffsHttpHandler } from "./src/http.js";
|
||||
import { DIFFS_AGENT_GUIDANCE } from "./src/prompt-guidance.js";
|
||||
@@ -22,14 +23,18 @@ export default definePluginEntry({
|
||||
register(api: OpenClawPluginApi) {
|
||||
const defaults = resolveDiffsPluginDefaults(api.pluginConfig);
|
||||
const security = resolveDiffsPluginSecurity(api.pluginConfig);
|
||||
const viewerBaseUrl = resolveDiffsPluginViewerBaseUrl(api.pluginConfig);
|
||||
const store = new DiffArtifactStore({
|
||||
rootDir: path.join(resolvePreferredOpenClawTmpDir(), "openclaw-diffs"),
|
||||
logger: api.logger,
|
||||
});
|
||||
|
||||
api.registerTool((ctx) => createDiffsTool({ api, store, defaults, context: ctx }), {
|
||||
name: "diffs",
|
||||
});
|
||||
api.registerTool(
|
||||
(ctx) => createDiffsTool({ api, store, defaults, viewerBaseUrl, context: ctx }),
|
||||
{
|
||||
name: "diffs",
|
||||
},
|
||||
);
|
||||
api.registerHttpRoute({
|
||||
path: "/plugins/diffs",
|
||||
auth: "plugin",
|
||||
|
||||
@@ -4,6 +4,10 @@
|
||||
"description": "Read-only diff viewer and file renderer for agents.",
|
||||
"skills": ["./skills"],
|
||||
"uiHints": {
|
||||
"viewerBaseUrl": {
|
||||
"label": "Viewer Base URL",
|
||||
"help": "Persistent gateway base URL used for returned viewer links when a tool call does not pass baseUrl."
|
||||
},
|
||||
"defaults.fontFamily": {
|
||||
"label": "Default Font",
|
||||
"help": "Preferred font family name for diff content and headers."
|
||||
@@ -69,6 +73,14 @@
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"viewerBaseUrl": {
|
||||
"type": "string",
|
||||
"format": "uri",
|
||||
"pattern": "^[Hh][Tt][Tt][Pp][Ss]?://",
|
||||
"not": {
|
||||
"pattern": "[?#]"
|
||||
}
|
||||
},
|
||||
"defaults": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
|
||||
@@ -8,6 +8,7 @@ When you need to show edits as a real diff, prefer the `diffs` tool instead of w
|
||||
The `diffs` tool accepts either `before` + `after` text, or a unified `patch` string.
|
||||
|
||||
Use `mode=view` when you want an interactive gateway-hosted viewer. After the tool returns, use `details.viewerUrl` with the canvas tool via `canvas present` or `canvas navigate`.
|
||||
If the deployment uses a loopback trusted proxy (for example Tailscale Serve with `gateway.trustedProxies` including `127.0.0.1`), raw loopback viewer requests can fail closed without forwarded client-IP headers. In that topology, prefer `mode=file` / `mode=both`, or use a configured `viewerBaseUrl` / explicit proxy/public `baseUrl` when you need a shareable viewer URL.
|
||||
|
||||
Use `mode=file` when you need a rendered file artifact. Set `fileFormat=png` (default) or `fileFormat=pdf`. The tool result includes `details.filePath`.
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
resolveDiffImageRenderOptions,
|
||||
resolveDiffsPluginDefaults,
|
||||
resolveDiffsPluginSecurity,
|
||||
resolveDiffsPluginViewerBaseUrl,
|
||||
} from "./config.js";
|
||||
import { renderDiffDocument } from "./render.js";
|
||||
import { buildViewerUrl, normalizeViewerBaseUrl } from "./url.js";
|
||||
@@ -42,6 +43,15 @@ const FULL_DEFAULTS = {
|
||||
mode: "file",
|
||||
} as const;
|
||||
|
||||
function compileManifestConfigSchema() {
|
||||
const manifest = JSON.parse(
|
||||
fs.readFileSync(new URL("../openclaw.plugin.json", import.meta.url), "utf8"),
|
||||
) as { configSchema: Record<string, unknown> };
|
||||
const Ajv = AjvPkg as unknown as new (opts?: object) => import("ajv").default;
|
||||
const ajv = new Ajv({ allErrors: true, strict: false, useDefaults: true });
|
||||
return ajv.compile(manifest.configSchema);
|
||||
}
|
||||
|
||||
describe("resolveDiffsPluginDefaults", () => {
|
||||
it("returns built-in defaults when config is missing", () => {
|
||||
expect(resolveDiffsPluginDefaults(undefined)).toEqual(DEFAULT_DIFFS_TOOL_DEFAULTS);
|
||||
@@ -172,12 +182,7 @@ describe("resolveDiffsPluginDefaults", () => {
|
||||
});
|
||||
|
||||
it("keeps loader-applied schema defaults from shadowing aliases and quality-derived defaults", () => {
|
||||
const manifest = JSON.parse(
|
||||
fs.readFileSync(new URL("../openclaw.plugin.json", import.meta.url), "utf8"),
|
||||
) as { configSchema: Record<string, unknown> };
|
||||
const Ajv = AjvPkg as unknown as new (opts?: object) => import("ajv").default;
|
||||
const ajv = new Ajv({ allErrors: true, strict: false, useDefaults: true });
|
||||
const validate = ajv.compile(manifest.configSchema);
|
||||
const validate = compileManifestConfigSchema();
|
||||
|
||||
const aliasOnly = {
|
||||
defaults: {
|
||||
@@ -219,10 +224,34 @@ describe("resolveDiffsPluginSecurity", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveDiffsPluginViewerBaseUrl", () => {
|
||||
it("defaults to undefined when config is missing", () => {
|
||||
expect(resolveDiffsPluginViewerBaseUrl(undefined)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("normalizes configured viewer base URLs", () => {
|
||||
expect(
|
||||
resolveDiffsPluginViewerBaseUrl({
|
||||
viewerBaseUrl: "https://example.com/openclaw/",
|
||||
}),
|
||||
).toBe("https://example.com/openclaw");
|
||||
});
|
||||
});
|
||||
|
||||
describe("diffs plugin schema surfaces", () => {
|
||||
it("rejects invalid viewerBaseUrl values at manifest-validation time too", () => {
|
||||
const validate = compileManifestConfigSchema();
|
||||
|
||||
expect(validate({ viewerBaseUrl: "javascript:alert(1)" })).toBe(false);
|
||||
expect(validate({ viewerBaseUrl: "https://example.com/openclaw?x=1" })).toBe(false);
|
||||
expect(validate({ viewerBaseUrl: "https://example.com/openclaw#frag" })).toBe(false);
|
||||
expect(validate({ viewerBaseUrl: "https://example.com/openclaw/" })).toBe(true);
|
||||
});
|
||||
|
||||
it("preserves defaults and security for direct safeParse callers", () => {
|
||||
expect(
|
||||
diffsPluginConfigSchema.safeParse?.({
|
||||
viewerBaseUrl: "https://example.com/openclaw/",
|
||||
defaults: {
|
||||
theme: "light",
|
||||
},
|
||||
@@ -233,6 +262,7 @@ describe("diffs plugin schema surfaces", () => {
|
||||
).toMatchObject({
|
||||
success: true,
|
||||
data: {
|
||||
viewerBaseUrl: "https://example.com/openclaw",
|
||||
defaults: {
|
||||
fontFamily: "Fira Code",
|
||||
fontSize: 15,
|
||||
@@ -277,6 +307,24 @@ describe("diffs plugin schema surfaces", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects invalid viewerBaseUrl config values", () => {
|
||||
expect(
|
||||
diffsPluginConfigSchema.safeParse?.({
|
||||
viewerBaseUrl: "javascript:alert(1)",
|
||||
}),
|
||||
).toMatchObject({
|
||||
success: false,
|
||||
error: {
|
||||
issues: [
|
||||
{
|
||||
path: ["viewerBaseUrl"],
|
||||
message: "viewerBaseUrl must use http or https: javascript:alert(1)",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the runtime json schema in sync with the manifest config schema", () => {
|
||||
const manifest = JSON.parse(
|
||||
fs.readFileSync(new URL("../openclaw.plugin.json", import.meta.url), "utf8"),
|
||||
@@ -329,6 +377,16 @@ describe("diffs viewer URL helpers", () => {
|
||||
).toBe("https://example.com/openclaw/plugins/diffs/view/id/token");
|
||||
});
|
||||
|
||||
it("prefers normalized viewerBaseUrl strings too", () => {
|
||||
expect(
|
||||
buildViewerUrl({
|
||||
config: {},
|
||||
baseUrl: "https://example.com/openclaw/",
|
||||
viewerPath: "/plugins/diffs/view/id/token",
|
||||
}),
|
||||
).toBe("https://example.com/openclaw/plugins/diffs/view/id/token");
|
||||
});
|
||||
|
||||
it("rejects base URLs with query/hash", () => {
|
||||
expect(() => normalizeViewerBaseUrl("https://example.com?a=1")).toThrow(
|
||||
"baseUrl must not include query/hash",
|
||||
@@ -337,6 +395,12 @@ describe("diffs viewer URL helpers", () => {
|
||||
"baseUrl must not include query/hash",
|
||||
);
|
||||
});
|
||||
|
||||
it("uses the configured field name in viewerBaseUrl validation errors", () => {
|
||||
expect(() => normalizeViewerBaseUrl("https://example.com?a=1", "viewerBaseUrl")).toThrow(
|
||||
"viewerBaseUrl must not include query/hash",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("renderDiffDocument", () => {
|
||||
|
||||
@@ -18,8 +18,10 @@ import {
|
||||
type DiffTheme,
|
||||
type DiffToolDefaults,
|
||||
} from "./types.js";
|
||||
import { normalizeViewerBaseUrl } from "./url.js";
|
||||
|
||||
type DiffsPluginConfig = {
|
||||
viewerBaseUrl?: string;
|
||||
defaults?: {
|
||||
fontFamily?: string;
|
||||
fontSize?: number;
|
||||
@@ -93,7 +95,29 @@ export const DEFAULT_DIFFS_PLUGIN_SECURITY: DiffsPluginSecurityConfig = {
|
||||
allowRemoteViewer: false,
|
||||
};
|
||||
|
||||
const VIEWER_BASE_URL_JSON_SCHEMA = {
|
||||
type: "string",
|
||||
format: "uri",
|
||||
pattern: "^[Hh][Tt][Tt][Pp][Ss]?://",
|
||||
not: {
|
||||
pattern: "[?#]",
|
||||
},
|
||||
} as const satisfies Record<string, unknown>;
|
||||
|
||||
const DiffsPluginJsonSchemaSource = z.strictObject({
|
||||
viewerBaseUrl: z
|
||||
.string()
|
||||
.superRefine((value, ctx) => {
|
||||
try {
|
||||
normalizeViewerBaseUrl(value, "viewerBaseUrl");
|
||||
} catch (error) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message: error instanceof Error ? error.message : "Invalid viewerBaseUrl",
|
||||
});
|
||||
}
|
||||
})
|
||||
.optional(),
|
||||
defaults: z
|
||||
.strictObject({
|
||||
fontFamily: z.string().default(DEFAULT_DIFFS_TOOL_DEFAULTS.fontFamily).optional(),
|
||||
@@ -141,35 +165,44 @@ const DiffsPluginJsonSchemaSource = z.strictObject({
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export const diffsPluginConfigSchema: OpenClawPluginConfigSchema = buildPluginConfigSchema(
|
||||
DiffsPluginJsonSchemaSource,
|
||||
{
|
||||
safeParse(value: unknown) {
|
||||
if (value === undefined) {
|
||||
return { success: true, data: undefined };
|
||||
}
|
||||
const result = DiffsPluginJsonSchemaSource.safeParse(value);
|
||||
if (result.success) {
|
||||
return {
|
||||
success: true,
|
||||
data: buildDiffsPluginConfigShape(result.data as DiffsPluginConfig),
|
||||
};
|
||||
}
|
||||
const diffsPluginConfigSchemaBase = buildPluginConfigSchema(DiffsPluginJsonSchemaSource, {
|
||||
safeParse(value: unknown) {
|
||||
if (value === undefined) {
|
||||
return { success: true, data: undefined };
|
||||
}
|
||||
const result = DiffsPluginJsonSchemaSource.safeParse(value);
|
||||
if (result.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: {
|
||||
issues: result.error.issues.map((issue) => ({
|
||||
path: issue.path.filter((segment): segment is string | number => {
|
||||
const kind = typeof segment;
|
||||
return kind === "string" || kind === "number";
|
||||
}),
|
||||
message: issue.message,
|
||||
})),
|
||||
},
|
||||
success: true,
|
||||
data: buildDiffsPluginConfigShape(result.data as DiffsPluginConfig),
|
||||
};
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
error: {
|
||||
issues: result.error.issues.map((issue) => ({
|
||||
path: issue.path.filter((segment): segment is string | number => {
|
||||
const kind = typeof segment;
|
||||
return kind === "string" || kind === "number";
|
||||
}),
|
||||
message: issue.message,
|
||||
})),
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const diffsPluginConfigSchema: OpenClawPluginConfigSchema = {
|
||||
...diffsPluginConfigSchemaBase,
|
||||
jsonSchema: {
|
||||
...diffsPluginConfigSchemaBase.jsonSchema,
|
||||
properties: {
|
||||
...(diffsPluginConfigSchemaBase.jsonSchema as { properties?: Record<string, unknown> })
|
||||
.properties,
|
||||
viewerBaseUrl: VIEWER_BASE_URL_JSON_SCHEMA,
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
function resolveConfiguredValue<T>(options: {
|
||||
primary: T | undefined;
|
||||
@@ -184,7 +217,9 @@ function resolveConfiguredValue<T>(options: {
|
||||
}
|
||||
|
||||
function buildDiffsPluginConfigShape(config: DiffsPluginConfig): DiffsPluginConfig {
|
||||
const viewerBaseUrl = resolveDiffsPluginViewerBaseUrl(config);
|
||||
return {
|
||||
...(viewerBaseUrl !== undefined ? { viewerBaseUrl } : {}),
|
||||
...(config.defaults !== undefined ? { defaults: resolveDiffsPluginDefaults(config) } : {}),
|
||||
...(config.security !== undefined ? { security: resolveDiffsPluginSecurity(config) } : {}),
|
||||
};
|
||||
@@ -255,6 +290,20 @@ export function resolveDiffsPluginSecurity(config: unknown): DiffsPluginSecurity
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveDiffsPluginViewerBaseUrl(config: unknown): string | undefined {
|
||||
if (!config || typeof config !== "object" || Array.isArray(config)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const viewerBaseUrl = (config as DiffsPluginConfig).viewerBaseUrl;
|
||||
if (typeof viewerBaseUrl !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const normalized = viewerBaseUrl.trim();
|
||||
return normalized ? normalizeViewerBaseUrl(normalized) : undefined;
|
||||
}
|
||||
|
||||
export function toPresentationDefaults(defaults: DiffToolDefaults): DiffPresentationDefaults {
|
||||
const {
|
||||
fontFamily,
|
||||
|
||||
@@ -340,6 +340,14 @@ describe("createDiffsHttpHandler", () => {
|
||||
allowRemoteViewer: false,
|
||||
expectedStatusCode: 404,
|
||||
},
|
||||
{
|
||||
name: "blocks proxied loopback requests when trusted proxies are configured",
|
||||
request: localReq,
|
||||
headers: { "x-forwarded-for": "203.0.113.10" },
|
||||
trustedProxies: ["127.0.0.1"],
|
||||
allowRemoteViewer: false,
|
||||
expectedStatusCode: 404,
|
||||
},
|
||||
{
|
||||
name: "allows remote access when allowRemoteViewer is enabled",
|
||||
request: remoteReq,
|
||||
|
||||
@@ -41,6 +41,57 @@ describe("diffs tool", () => {
|
||||
expect((result?.details as Record<string, unknown>).viewerUrl).toBeDefined();
|
||||
});
|
||||
|
||||
it("uses configured viewerBaseUrl when tool input omits baseUrl", async () => {
|
||||
const tool = createDiffsTool({
|
||||
api: createApi({
|
||||
viewerBaseUrl: "https://example.com/openclaw/",
|
||||
}),
|
||||
store,
|
||||
defaults: DEFAULT_DIFFS_TOOL_DEFAULTS,
|
||||
viewerBaseUrl: "https://example.com/openclaw",
|
||||
});
|
||||
|
||||
const result = await tool.execute?.("tool-viewer-config", {
|
||||
before: "one\n",
|
||||
after: "two\n",
|
||||
path: "README.md",
|
||||
mode: "view",
|
||||
});
|
||||
|
||||
expect(readTextContent(result, 0)).toContain(
|
||||
"https://example.com/openclaw/plugins/diffs/view/",
|
||||
);
|
||||
expect((result?.details as Record<string, unknown>).viewerUrl).toEqual(
|
||||
expect.stringContaining("https://example.com/openclaw/plugins/diffs/view/"),
|
||||
);
|
||||
});
|
||||
|
||||
it("prefers per-call baseUrl over configured viewerBaseUrl", async () => {
|
||||
const tool = createDiffsTool({
|
||||
api: createApi({
|
||||
viewerBaseUrl: "https://example.com/openclaw",
|
||||
}),
|
||||
store,
|
||||
defaults: DEFAULT_DIFFS_TOOL_DEFAULTS,
|
||||
viewerBaseUrl: "https://example.com/openclaw",
|
||||
});
|
||||
|
||||
const result = await tool.execute?.("tool-viewer-override", {
|
||||
before: "one\n",
|
||||
after: "two\n",
|
||||
path: "README.md",
|
||||
mode: "view",
|
||||
baseUrl: "https://preview.example.com/review",
|
||||
});
|
||||
|
||||
expect(readTextContent(result, 0)).toContain(
|
||||
"https://preview.example.com/review/plugins/diffs/view/",
|
||||
);
|
||||
expect((result?.details as Record<string, unknown>).viewerUrl).toEqual(
|
||||
expect.stringContaining("https://preview.example.com/review/plugins/diffs/view/"),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not expose reserved format in the tool schema", async () => {
|
||||
const tool = createDiffsTool({
|
||||
api: createApi(),
|
||||
@@ -420,7 +471,7 @@ describe("diffs tool", () => {
|
||||
});
|
||||
});
|
||||
|
||||
function createApi(): OpenClawPluginApi {
|
||||
function createApi(pluginConfig?: Record<string, unknown>): OpenClawPluginApi {
|
||||
return createTestPluginApi({
|
||||
id: "diffs",
|
||||
name: "Diffs",
|
||||
@@ -432,6 +483,7 @@ function createApi(): OpenClawPluginApi {
|
||||
bind: "loopback",
|
||||
},
|
||||
},
|
||||
pluginConfig,
|
||||
runtime: {} as OpenClawPluginApi["runtime"],
|
||||
}) as OpenClawPluginApi;
|
||||
}
|
||||
|
||||
@@ -125,7 +125,7 @@ const DiffsToolSchema = Type.Object(
|
||||
baseUrl: Type.Optional(
|
||||
Type.String({
|
||||
description:
|
||||
"Optional gateway base URL override used when building the viewer URL, for example https://gateway.example.com.",
|
||||
"Optional gateway base URL override used when building the viewer URL. Overrides configured viewerBaseUrl, for example https://gateway.example.com.",
|
||||
}),
|
||||
),
|
||||
},
|
||||
@@ -142,6 +142,7 @@ export function createDiffsTool(params: {
|
||||
api: OpenClawPluginApi;
|
||||
store: DiffArtifactStore;
|
||||
defaults: DiffToolDefaults;
|
||||
viewerBaseUrl?: string;
|
||||
screenshotter?: DiffScreenshotter;
|
||||
context?: OpenClawPluginToolContext;
|
||||
}): AnyAgentTool {
|
||||
@@ -237,7 +238,7 @@ export function createDiffsTool(params: {
|
||||
const viewerUrl = buildViewerUrl({
|
||||
config: params.api.config,
|
||||
viewerPath: artifact.viewerPath,
|
||||
baseUrl: normalizeBaseUrl(toolParams.baseUrl),
|
||||
baseUrl: normalizeBaseUrl(toolParams.baseUrl) ?? params.viewerBaseUrl,
|
||||
});
|
||||
|
||||
const baseDetails = {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { OpenClawConfig } from "../api.js";
|
||||
|
||||
const DEFAULT_GATEWAY_PORT = 18789;
|
||||
type ViewerBaseUrlFieldName = "baseUrl" | "viewerBaseUrl";
|
||||
|
||||
export function buildViewerUrl(params: {
|
||||
config: OpenClawConfig;
|
||||
@@ -20,18 +21,21 @@ export function buildViewerUrl(params: {
|
||||
return parsedBase.toString();
|
||||
}
|
||||
|
||||
export function normalizeViewerBaseUrl(raw: string): string {
|
||||
export function normalizeViewerBaseUrl(
|
||||
raw: string,
|
||||
fieldName: ViewerBaseUrlFieldName = "baseUrl",
|
||||
): string {
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(raw);
|
||||
} catch {
|
||||
throw new Error(`Invalid baseUrl: ${raw}`);
|
||||
throw new Error(`Invalid ${fieldName}: ${raw}`);
|
||||
}
|
||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
||||
throw new Error(`baseUrl must use http or https: ${raw}`);
|
||||
throw new Error(`${fieldName} must use http or https: ${raw}`);
|
||||
}
|
||||
if (parsed.search || parsed.hash) {
|
||||
throw new Error(`baseUrl must not include query/hash: ${raw}`);
|
||||
throw new Error(`${fieldName} must not include query/hash: ${raw}`);
|
||||
}
|
||||
parsed.search = "";
|
||||
parsed.hash = "";
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"@discordjs/voice": "^0.19.2",
|
||||
"discord-api-types": "^0.38.43",
|
||||
"https-proxy-agent": "^8.0.0",
|
||||
"opusscript": "^0.1.1"
|
||||
"opusscript": "^0.0.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"openclaw": "workspace:*"
|
||||
|
||||
@@ -9,6 +9,11 @@ import {
|
||||
} from "./approval-native.js";
|
||||
|
||||
const STORE_PATH = path.join(os.tmpdir(), "openclaw-discord-approval-native-test.json");
|
||||
const NATIVE_APPROVAL_CFG = {
|
||||
commands: {
|
||||
ownerAllowFrom: ["discord:555555555"],
|
||||
},
|
||||
} as const;
|
||||
|
||||
function writeStore(store: Record<string, unknown>) {
|
||||
fs.writeFileSync(STORE_PATH, `${JSON.stringify(store, null, 2)}\n`, "utf8");
|
||||
@@ -45,7 +50,7 @@ describe("createDiscordNativeApprovalAdapter", () => {
|
||||
const adapter = createDiscordNativeApprovalAdapter();
|
||||
|
||||
const target = await adapter.native?.resolveOriginTarget?.({
|
||||
cfg: {} as never,
|
||||
cfg: NATIVE_APPROVAL_CFG as never,
|
||||
accountId: "main",
|
||||
approvalKind: "plugin",
|
||||
request: {
|
||||
@@ -69,7 +74,7 @@ describe("createDiscordNativeApprovalAdapter", () => {
|
||||
const adapter = createDiscordNativeApprovalAdapter();
|
||||
|
||||
const target = await adapter.native?.resolveOriginTarget?.({
|
||||
cfg: {} as never,
|
||||
cfg: NATIVE_APPROVAL_CFG as never,
|
||||
accountId: "main",
|
||||
approvalKind: "plugin",
|
||||
request: {
|
||||
@@ -104,7 +109,10 @@ describe("createDiscordNativeApprovalAdapter", () => {
|
||||
|
||||
const adapter = createDiscordNativeApprovalAdapter();
|
||||
const target = await adapter.native?.resolveOriginTarget?.({
|
||||
cfg: { session: { store: STORE_PATH } } as never,
|
||||
cfg: {
|
||||
...NATIVE_APPROVAL_CFG,
|
||||
session: { store: STORE_PATH },
|
||||
} as never,
|
||||
accountId: "main",
|
||||
approvalKind: "plugin",
|
||||
request: {
|
||||
@@ -129,7 +137,7 @@ describe("createDiscordNativeApprovalAdapter", () => {
|
||||
const adapter = createDiscordNativeApprovalAdapter();
|
||||
|
||||
const target = await adapter.native?.resolveOriginTarget?.({
|
||||
cfg: {} as never,
|
||||
cfg: NATIVE_APPROVAL_CFG as never,
|
||||
accountId: "main",
|
||||
approvalKind: "plugin",
|
||||
request: {
|
||||
@@ -154,7 +162,7 @@ describe("createDiscordNativeApprovalAdapter", () => {
|
||||
const adapter = createDiscordNativeApprovalAdapter();
|
||||
|
||||
const target = await adapter.native?.resolveOriginTarget?.({
|
||||
cfg: {} as never,
|
||||
cfg: NATIVE_APPROVAL_CFG as never,
|
||||
accountId: "main",
|
||||
approvalKind: "plugin",
|
||||
request: {
|
||||
@@ -176,7 +184,7 @@ describe("createDiscordNativeApprovalAdapter", () => {
|
||||
const adapter = createDiscordNativeApprovalAdapter();
|
||||
|
||||
const target = await adapter.native?.resolveOriginTarget?.({
|
||||
cfg: {} as never,
|
||||
cfg: NATIVE_APPROVAL_CFG as never,
|
||||
accountId: "main",
|
||||
approvalKind: "plugin",
|
||||
request: {
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
createApproverRestrictedNativeApprovalCapability,
|
||||
splitChannelApprovalCapability,
|
||||
doesApprovalRequestMatchChannelAccount,
|
||||
isChannelExecApprovalClientEnabledFromConfig,
|
||||
matchesApprovalRequestFilters,
|
||||
} from "openclaw/plugin-sdk/approval-runtime";
|
||||
import type { DiscordExecApprovalConfig, OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
@@ -75,16 +76,18 @@ export function shouldHandleDiscordApprovalRequest(params: {
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (!config) {
|
||||
return true;
|
||||
}
|
||||
if (!config.enabled || approvers.length === 0) {
|
||||
if (
|
||||
!isChannelExecApprovalClientEnabledFromConfig({
|
||||
enabled: config?.enabled,
|
||||
approverCount: approvers.length,
|
||||
})
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return matchesApprovalRequestFilters({
|
||||
request: params.request.request,
|
||||
agentFilter: config.agentFilter,
|
||||
sessionFilter: config.sessionFilter,
|
||||
agentFilter: config?.agentFilter,
|
||||
sessionFilter: config?.sessionFilter,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -28,7 +28,6 @@ import {
|
||||
buildDiscordModalCustomId as buildDiscordModalCustomIdImpl,
|
||||
parseDiscordModalCustomIdForCarbon as parseDiscordModalCustomIdForCarbonImpl,
|
||||
} from "./component-custom-id.js";
|
||||
|
||||
// Some test-only module graphs partially mock `@buape/carbon` and can drop `Modal`.
|
||||
// Keep dynamic form definitions loadable instead of crashing unrelated suites.
|
||||
const ModalBase: typeof Modal = (Modal ?? class {}) as typeof Modal;
|
||||
|
||||
@@ -22,34 +22,35 @@ function buildConfig(
|
||||
}
|
||||
|
||||
describe("discord exec approvals", () => {
|
||||
it("requires enablement and explicit or owner approvers", () => {
|
||||
it("auto-enables when owner approvers resolve and disables only when forced off", () => {
|
||||
expect(isDiscordExecApprovalClientEnabled({ cfg: buildConfig() })).toBe(false);
|
||||
expect(isDiscordExecApprovalClientEnabled({ cfg: buildConfig({ enabled: true }) })).toBe(false);
|
||||
expect(
|
||||
isDiscordExecApprovalClientEnabled({
|
||||
cfg: buildConfig({ enabled: true }, { allowFrom: ["123"] }),
|
||||
cfg: buildConfig({ enabled: true }),
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isDiscordExecApprovalClientEnabled({
|
||||
cfg: buildConfig({ enabled: true, approvers: ["123"] }),
|
||||
cfg: buildConfig({ approvers: ["123"] }),
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isDiscordExecApprovalClientEnabled({
|
||||
cfg: {
|
||||
...buildConfig({ enabled: true }),
|
||||
...buildConfig(),
|
||||
commands: { ownerAllowFrom: ["discord:789"] },
|
||||
} as OpenClawConfig,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isDiscordExecApprovalClientEnabled({
|
||||
cfg: buildConfig({ enabled: false, approvers: ["123"] }),
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("prefers explicit approvers when configured", () => {
|
||||
const cfg = buildConfig(
|
||||
{ enabled: true, approvers: ["456"] },
|
||||
{ allowFrom: ["123"], defaultTo: "user:789" },
|
||||
);
|
||||
const cfg = buildConfig({ approvers: ["456"] }, { allowFrom: ["123"], defaultTo: "user:789" });
|
||||
|
||||
expect(getDiscordExecApprovalApprovers({ cfg })).toEqual(["456"]);
|
||||
expect(isDiscordExecApprovalApprover({ cfg, senderId: "456" })).toBe(true);
|
||||
@@ -72,7 +73,7 @@ describe("discord exec approvals", () => {
|
||||
|
||||
it("falls back to commands.ownerAllowFrom for exec approvers", () => {
|
||||
const cfg = {
|
||||
...buildConfig({ enabled: true }),
|
||||
...buildConfig(),
|
||||
commands: { ownerAllowFrom: ["discord:123", "user:456", "789"] },
|
||||
} as OpenClawConfig;
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { getExecApprovalReplyMetadata } from "openclaw/plugin-sdk/approval-runtime";
|
||||
import { isChannelExecApprovalClientEnabledFromConfig } from "openclaw/plugin-sdk/approval-runtime";
|
||||
import { resolveApprovalApprovers } from "openclaw/plugin-sdk/approval-runtime";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import type { DiscordExecApprovalConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
@@ -53,14 +54,14 @@ export function isDiscordExecApprovalClientEnabled(params: {
|
||||
configOverride?: DiscordExecApprovalConfig | null;
|
||||
}): boolean {
|
||||
const config = params.configOverride ?? resolveDiscordAccount(params).config.execApprovals;
|
||||
return Boolean(
|
||||
config?.enabled &&
|
||||
getDiscordExecApprovalApprovers({
|
||||
return isChannelExecApprovalClientEnabledFromConfig({
|
||||
enabled: config?.enabled,
|
||||
approverCount: getDiscordExecApprovalApprovers({
|
||||
cfg: params.cfg,
|
||||
accountId: params.accountId,
|
||||
configOverride: params.configOverride,
|
||||
}).length > 0,
|
||||
);
|
||||
}).length,
|
||||
});
|
||||
}
|
||||
|
||||
export function isDiscordExecApprovalApprover(params: {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
|
||||
let buildDiscordComponentCustomId: typeof import("../components.js").buildDiscordComponentCustomId;
|
||||
let buildDiscordModalCustomId: typeof import("../components.js").buildDiscordModalCustomId;
|
||||
let buildDiscordComponentCustomId: typeof import("../component-custom-id.js").buildDiscordComponentCustomId;
|
||||
let buildDiscordModalCustomId: typeof import("../component-custom-id.js").buildDiscordModalCustomId;
|
||||
let createDiscordComponentButton: typeof import("./agent-components.js").createDiscordComponentButton;
|
||||
let createDiscordComponentChannelSelect: typeof import("./agent-components.js").createDiscordComponentChannelSelect;
|
||||
let createDiscordComponentMentionableSelect: typeof import("./agent-components.js").createDiscordComponentMentionableSelect;
|
||||
@@ -11,7 +11,8 @@ let createDiscordComponentStringSelect: typeof import("./agent-components.js").c
|
||||
let createDiscordComponentUserSelect: typeof import("./agent-components.js").createDiscordComponentUserSelect;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ buildDiscordComponentCustomId, buildDiscordModalCustomId } = await import("../components.js"));
|
||||
({ buildDiscordComponentCustomId, buildDiscordModalCustomId } =
|
||||
await import("../component-custom-id.js"));
|
||||
({
|
||||
createDiscordComponentButton,
|
||||
createDiscordComponentChannelSelect,
|
||||
|
||||
@@ -34,7 +34,10 @@ import {
|
||||
createDiscordApprovalCapability,
|
||||
shouldHandleDiscordApprovalRequest,
|
||||
} from "../approval-native.js";
|
||||
import { getDiscordExecApprovalApprovers } from "../exec-approvals.js";
|
||||
import {
|
||||
getDiscordExecApprovalApprovers,
|
||||
isDiscordExecApprovalClientEnabled,
|
||||
} from "../exec-approvals.js";
|
||||
import { createDiscordClient, stripUndefinedFields } from "../send.shared.js";
|
||||
import { DiscordUiContainer } from "../ui.js";
|
||||
|
||||
@@ -484,7 +487,12 @@ export class DiscordExecApprovalHandler {
|
||||
gatewayUrl: this.opts.gatewayUrl,
|
||||
eventKinds: ["exec", "plugin"],
|
||||
nativeAdapter: createDiscordApprovalCapability(this.opts.config).native,
|
||||
isConfigured: () => Boolean(this.opts.config.enabled && this.getApprovers().length > 0),
|
||||
isConfigured: () =>
|
||||
isDiscordExecApprovalClientEnabled({
|
||||
cfg: this.opts.cfg,
|
||||
accountId: this.opts.accountId,
|
||||
configOverride: this.opts.config,
|
||||
}),
|
||||
shouldHandle: (request) => this.shouldHandle(request),
|
||||
buildPendingContent: ({ request }) => {
|
||||
const actionRow = createApprovalActionRow(request);
|
||||
|
||||
@@ -20,12 +20,6 @@ import {
|
||||
} from "openclaw/plugin-sdk/config-runtime";
|
||||
import type { OpenClawConfig, ReplyToMode } from "openclaw/plugin-sdk/config-runtime";
|
||||
import { loadConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import {
|
||||
GROUP_POLICY_BLOCKED_LABEL,
|
||||
resolveOpenProviderRuntimeGroupPolicy,
|
||||
resolveDefaultGroupPolicy,
|
||||
warnMissingProviderGroupPolicyFallbackOnce,
|
||||
} from "openclaw/plugin-sdk/runtime-group-policy";
|
||||
import { createConnectedChannelStatusPatch } from "openclaw/plugin-sdk/gateway-runtime";
|
||||
import { getPluginCommandSpecs } from "openclaw/plugin-sdk/plugin-runtime";
|
||||
import { resolveTextChunkLimit } from "openclaw/plugin-sdk/reply-runtime";
|
||||
@@ -38,9 +32,16 @@ import {
|
||||
} from "openclaw/plugin-sdk/runtime-env";
|
||||
import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env";
|
||||
import { createNonExitingRuntime, type RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
|
||||
import {
|
||||
GROUP_POLICY_BLOCKED_LABEL,
|
||||
resolveOpenProviderRuntimeGroupPolicy,
|
||||
resolveDefaultGroupPolicy,
|
||||
warnMissingProviderGroupPolicyFallbackOnce,
|
||||
} from "openclaw/plugin-sdk/runtime-group-policy";
|
||||
import { formatErrorMessage } from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
import { summarizeStringEntries } from "openclaw/plugin-sdk/text-runtime";
|
||||
import { resolveDiscordAccount } from "../accounts.js";
|
||||
import { isDiscordExecApprovalClientEnabled } from "../exec-approvals.js";
|
||||
import { fetchDiscordApplicationId } from "../probe.js";
|
||||
import { normalizeDiscordToken } from "../token.js";
|
||||
import { createDiscordVoiceCommand } from "../voice/command.js";
|
||||
@@ -824,7 +825,11 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
||||
|
||||
// Initialize exec approvals handler if enabled
|
||||
const execApprovalsConfig = discordCfg.execApprovals ?? {};
|
||||
const execApprovalsHandler = execApprovalsConfig.enabled
|
||||
const execApprovalsHandler = isDiscordExecApprovalClientEnabled({
|
||||
cfg,
|
||||
accountId: account.accountId,
|
||||
configOverride: execApprovalsConfig,
|
||||
})
|
||||
? new DiscordExecApprovalHandler({
|
||||
token,
|
||||
accountId: account.accountId,
|
||||
|
||||
@@ -11,23 +11,14 @@ import {
|
||||
} from "./image-generation-provider.js";
|
||||
|
||||
function expectFalJsonPost(params: { call: number; url: string; body: Record<string, unknown> }) {
|
||||
expect(fetchWithSsrFGuardMock).toHaveBeenNthCalledWith(
|
||||
params.call,
|
||||
expect.objectContaining({
|
||||
url: params.url,
|
||||
init: expect.objectContaining({
|
||||
method: "POST",
|
||||
headers: expect.objectContaining({
|
||||
Authorization: "Key fal-test-key",
|
||||
"Content-Type": "application/json",
|
||||
}),
|
||||
}),
|
||||
auditContext: "fal-image-generate",
|
||||
}),
|
||||
);
|
||||
|
||||
const request = fetchWithSsrFGuardMock.mock.calls[params.call - 1]?.[0];
|
||||
expect(request).toBeTruthy();
|
||||
expect(request?.url).toBe(params.url);
|
||||
expect(request?.auditContext).toBe("fal-image-generate");
|
||||
expect(request?.init?.method).toBe("POST");
|
||||
const headers = new Headers(request?.init?.headers);
|
||||
expect(headers.get("authorization")).toBe("Key fal-test-key");
|
||||
expect(headers.get("content-type")).toBe("application/json");
|
||||
expect(JSON.parse(String(request?.init?.body))).toEqual(params.body);
|
||||
}
|
||||
|
||||
@@ -361,17 +352,13 @@ describe("fal image-generation provider", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("allows trusted private relay hosts derived from configured baseUrl", async () => {
|
||||
it("does not auto-whitelist trusted private relay hosts from a configured baseUrl", async () => {
|
||||
vi.spyOn(providerAuth, "resolveApiKeyForProvider").mockResolvedValue({
|
||||
apiKey: "fal-test-key",
|
||||
source: "env",
|
||||
mode: "api-key",
|
||||
});
|
||||
_setFalFetchGuardForTesting(fetchWithSsrFGuardMock);
|
||||
const relayPolicy = {
|
||||
allowPrivateNetwork: true,
|
||||
hostnameAllowlist: ["relay.internal", "*.relay.internal"],
|
||||
};
|
||||
fetchWithSsrFGuardMock
|
||||
.mockResolvedValueOnce({
|
||||
response: new Response(
|
||||
@@ -415,7 +402,7 @@ describe("fal image-generation provider", () => {
|
||||
expect.objectContaining({
|
||||
url: "http://relay.internal:8080/fal-ai/flux/dev",
|
||||
auditContext: "fal-image-generate",
|
||||
policy: relayPolicy,
|
||||
policy: undefined,
|
||||
}),
|
||||
);
|
||||
expect(fetchWithSsrFGuardMock).toHaveBeenNthCalledWith(
|
||||
@@ -423,7 +410,7 @@ describe("fal image-generation provider", () => {
|
||||
expect.objectContaining({
|
||||
url: "http://media.relay.internal/files/generated.png",
|
||||
auditContext: "fal-image-download",
|
||||
policy: relayPolicy,
|
||||
policy: undefined,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -3,6 +3,10 @@ import type {
|
||||
ImageGenerationProvider,
|
||||
} from "openclaw/plugin-sdk/image-generation";
|
||||
import { resolveApiKeyForProvider } from "openclaw/plugin-sdk/provider-auth-runtime";
|
||||
import {
|
||||
assertOkOrThrowHttpError,
|
||||
resolveProviderHttpRequestConfig,
|
||||
} from "openclaw/plugin-sdk/provider-http";
|
||||
import {
|
||||
buildHostnameAllowlistPolicyFromSuffixAllowlist,
|
||||
fetchWithSsrFGuard,
|
||||
@@ -81,40 +85,32 @@ function matchesTrustedHostSuffix(hostname: string, trustedSuffix: string): bool
|
||||
return normalizedHost === normalizedSuffix || normalizedHost.endsWith(`.${normalizedSuffix}`);
|
||||
}
|
||||
|
||||
function resolveFalNetworkPolicy(
|
||||
cfg: Parameters<typeof resolveApiKeyForProvider>[0]["cfg"],
|
||||
): FalNetworkPolicy {
|
||||
const baseUrl = resolveFalBaseUrl(cfg);
|
||||
const explicitBaseUrl = cfg?.models?.providers?.fal?.baseUrl?.trim();
|
||||
function resolveFalNetworkPolicy(params: {
|
||||
baseUrl: string;
|
||||
allowPrivateNetwork: boolean;
|
||||
}): FalNetworkPolicy {
|
||||
let parsedBaseUrl: URL;
|
||||
try {
|
||||
parsedBaseUrl = new URL(baseUrl);
|
||||
parsedBaseUrl = new URL(params.baseUrl);
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
|
||||
const hostSuffix = parsedBaseUrl.hostname.trim().toLowerCase();
|
||||
if (!hostSuffix) {
|
||||
if (!hostSuffix || !params.allowPrivateNetwork) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const hostPolicy = buildHostnameAllowlistPolicyFromSuffixAllowlist([hostSuffix]);
|
||||
const privateNetworkPolicy = explicitBaseUrl
|
||||
? ssrfPolicyFromAllowPrivateNetwork(true)
|
||||
: undefined;
|
||||
const privateNetworkPolicy = ssrfPolicyFromAllowPrivateNetwork(true);
|
||||
const trustedHostPolicy = mergeSsrFPolicies(hostPolicy, privateNetworkPolicy);
|
||||
return {
|
||||
apiPolicy: trustedHostPolicy,
|
||||
trustedDownloadHostSuffix: explicitBaseUrl ? hostSuffix : undefined,
|
||||
trustedDownloadPolicy: explicitBaseUrl ? trustedHostPolicy : undefined,
|
||||
trustedDownloadHostSuffix: hostSuffix,
|
||||
trustedDownloadPolicy: trustedHostPolicy,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveFalBaseUrl(cfg: Parameters<typeof resolveApiKeyForProvider>[0]["cfg"]): string {
|
||||
const direct = cfg?.models?.providers?.fal?.baseUrl?.trim();
|
||||
return (direct || DEFAULT_FAL_BASE_URL).replace(/\/+$/u, "");
|
||||
}
|
||||
|
||||
function ensureFalModelPath(model: string | undefined, hasInputImages: boolean): string {
|
||||
const trimmed = model?.trim() || DEFAULT_FAL_IMAGE_MODEL;
|
||||
if (!hasInputImages) {
|
||||
@@ -341,7 +337,21 @@ export function buildFalImageGenerationProvider(): ImageGenerationProvider {
|
||||
hasInputImages,
|
||||
});
|
||||
const model = ensureFalModelPath(req.model, hasInputImages);
|
||||
const networkPolicy = resolveFalNetworkPolicy(req.cfg);
|
||||
const explicitBaseUrl = req.cfg?.models?.providers?.fal?.baseUrl?.trim();
|
||||
const { baseUrl, allowPrivateNetwork, headers, dispatcherPolicy } =
|
||||
resolveProviderHttpRequestConfig({
|
||||
baseUrl: explicitBaseUrl,
|
||||
defaultBaseUrl: DEFAULT_FAL_BASE_URL,
|
||||
allowPrivateNetwork: false,
|
||||
defaultHeaders: {
|
||||
Authorization: `Key ${auth.apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
provider: "fal",
|
||||
capability: "image",
|
||||
transport: "http",
|
||||
});
|
||||
const networkPolicy = resolveFalNetworkPolicy({ baseUrl, allowPrivateNetwork });
|
||||
const requestBody: Record<string, unknown> = {
|
||||
prompt: req.prompt,
|
||||
num_images: req.count ?? 1,
|
||||
@@ -358,27 +368,20 @@ export function buildFalImageGenerationProvider(): ImageGenerationProvider {
|
||||
}
|
||||
requestBody.image_url = toDataUri(input.buffer, input.mimeType);
|
||||
}
|
||||
|
||||
const { response, release } = await falFetchGuard({
|
||||
url: `${resolveFalBaseUrl(req.cfg)}/${model}`,
|
||||
url: `${baseUrl}/${model}`,
|
||||
init: {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Key ${auth.apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
headers,
|
||||
body: JSON.stringify(requestBody),
|
||||
},
|
||||
timeoutMs: req.timeoutMs,
|
||||
policy: networkPolicy.apiPolicy,
|
||||
dispatcherPolicy,
|
||||
auditContext: "fal-image-generate",
|
||||
});
|
||||
try {
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => "");
|
||||
throw new Error(
|
||||
`fal image generation failed (${response.status}): ${text || response.statusText}`,
|
||||
);
|
||||
}
|
||||
await assertOkOrThrowHttpError(response, "fal image generation failed");
|
||||
|
||||
const payload = (await response.json()) as FalImageGenerationResponse;
|
||||
const images: GeneratedImageAsset[] = [];
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
import { resolveFeishuRuntimeAccount } from "./accounts.js";
|
||||
import { createFeishuClient } from "./client.js";
|
||||
import type { CommentFileType } from "./comment-target.js";
|
||||
import { replyComment } from "./drive.js";
|
||||
import { deliverCommentThreadText } from "./drive.js";
|
||||
import { getFeishuRuntime } from "./runtime.js";
|
||||
|
||||
export type CreateFeishuCommentReplyDispatcherParams = {
|
||||
@@ -19,6 +19,7 @@ export type CreateFeishuCommentReplyDispatcherParams = {
|
||||
fileToken: string;
|
||||
fileType: CommentFileType;
|
||||
commentId: string;
|
||||
isWholeComment?: boolean;
|
||||
};
|
||||
|
||||
export function createFeishuCommentReplyDispatcher(
|
||||
@@ -63,11 +64,12 @@ export function createFeishuCommentReplyDispatcher(
|
||||
}
|
||||
const chunks = core.channel.text.chunkTextWithMode(reply.text, textChunkLimit, chunkMode);
|
||||
for (const chunk of chunks) {
|
||||
await replyComment(client, {
|
||||
await deliverCommentThreadText(client, {
|
||||
file_token: params.fileToken,
|
||||
file_type: params.fileType,
|
||||
comment_id: params.commentId,
|
||||
content: chunk,
|
||||
is_whole_comment: params.isWholeComment,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
@@ -8,7 +8,7 @@ const resolveDriveCommentEventTurnMock = vi.hoisted(() => vi.fn());
|
||||
const createFeishuCommentReplyDispatcherMock = vi.hoisted(() => vi.fn());
|
||||
const maybeCreateDynamicAgentMock = vi.hoisted(() => vi.fn());
|
||||
const createFeishuClientMock = vi.hoisted(() => vi.fn(() => ({ request: vi.fn() })));
|
||||
const replyCommentMock = vi.hoisted(() => vi.fn());
|
||||
const deliverCommentThreadTextMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("./monitor.comment.js", () => ({
|
||||
resolveDriveCommentEventTurn: resolveDriveCommentEventTurnMock,
|
||||
@@ -27,7 +27,7 @@ vi.mock("./client.js", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("./drive.js", () => ({
|
||||
replyComment: replyCommentMock,
|
||||
deliverCommentThreadText: deliverCommentThreadTextMock,
|
||||
}));
|
||||
|
||||
function buildConfig(overrides?: Partial<ClawdbotConfig>): ClawdbotConfig {
|
||||
@@ -66,6 +66,7 @@ describe("handleFeishuCommentEvent", () => {
|
||||
noticeType: "add_comment",
|
||||
fileToken: "doc_token_1",
|
||||
fileType: "docx",
|
||||
isWholeComment: false,
|
||||
senderId: "ou_sender",
|
||||
senderUserId: "on_sender_user",
|
||||
timestamp: "1774951528000",
|
||||
@@ -76,7 +77,10 @@ describe("handleFeishuCommentEvent", () => {
|
||||
rootCommentText: "root comment",
|
||||
targetReplyText: "latest reply",
|
||||
});
|
||||
replyCommentMock.mockResolvedValue({ reply_id: "r1" });
|
||||
deliverCommentThreadTextMock.mockResolvedValue({
|
||||
delivery_mode: "reply_comment",
|
||||
reply_id: "r1",
|
||||
});
|
||||
|
||||
const runtime = createPluginRuntimeMock({
|
||||
channel: {
|
||||
@@ -196,7 +200,7 @@ describe("handleFeishuCommentEvent", () => {
|
||||
typeof vi.fn
|
||||
>;
|
||||
expect(dispatchReplyFromConfig).toHaveBeenCalledTimes(1);
|
||||
expect(replyCommentMock).not.toHaveBeenCalled();
|
||||
expect(deliverCommentThreadTextMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("issues a pairing challenge in the comment thread when dmPolicy=pairing", async () => {
|
||||
@@ -232,12 +236,13 @@ describe("handleFeishuCommentEvent", () => {
|
||||
} as never,
|
||||
});
|
||||
|
||||
expect(replyCommentMock).toHaveBeenCalledWith(
|
||||
expect(deliverCommentThreadTextMock).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
file_token: "doc_token_1",
|
||||
file_type: "docx",
|
||||
comment_id: "comment_1",
|
||||
is_whole_comment: false,
|
||||
}),
|
||||
);
|
||||
const dispatchReplyFromConfig = runtime.channel.reply.dispatchReplyFromConfig as ReturnType<
|
||||
@@ -245,4 +250,46 @@ describe("handleFeishuCommentEvent", () => {
|
||||
>;
|
||||
expect(dispatchReplyFromConfig).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("passes whole-comment metadata to the comment reply dispatcher", async () => {
|
||||
resolveDriveCommentEventTurnMock.mockResolvedValueOnce({
|
||||
eventId: "evt_whole",
|
||||
messageId: "drive-comment:evt_whole",
|
||||
commentId: "comment_whole",
|
||||
replyId: "reply_whole",
|
||||
noticeType: "add_reply",
|
||||
fileToken: "doc_token_1",
|
||||
fileType: "docx",
|
||||
isWholeComment: true,
|
||||
senderId: "ou_sender",
|
||||
senderUserId: "on_sender_user",
|
||||
timestamp: "1774951528000",
|
||||
isMentioned: false,
|
||||
documentTitle: "Project review",
|
||||
prompt: "prompt body",
|
||||
preview: "prompt body",
|
||||
rootCommentText: "root comment",
|
||||
targetReplyText: "reply text",
|
||||
});
|
||||
|
||||
await handleFeishuCommentEvent({
|
||||
cfg: buildConfig(),
|
||||
accountId: "default",
|
||||
event: { event_id: "evt_whole" },
|
||||
botOpenId: "ou_bot",
|
||||
runtime: {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
} as never,
|
||||
});
|
||||
|
||||
expect(createFeishuCommentReplyDispatcherMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
commentId: "comment_whole",
|
||||
fileToken: "doc_token_1",
|
||||
fileType: "docx",
|
||||
isWholeComment: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,7 +8,7 @@ import { resolveFeishuRuntimeAccount } from "./accounts.js";
|
||||
import { createFeishuClient } from "./client.js";
|
||||
import { createFeishuCommentReplyDispatcher } from "./comment-dispatcher.js";
|
||||
import { buildFeishuCommentTarget } from "./comment-target.js";
|
||||
import { replyComment } from "./drive.js";
|
||||
import { deliverCommentThreadText } from "./drive.js";
|
||||
import { maybeCreateDynamicAgent } from "./dynamic-agent.js";
|
||||
import {
|
||||
resolveDriveCommentEventTurn,
|
||||
@@ -108,11 +108,12 @@ export async function handleFeishuCommentEvent(
|
||||
);
|
||||
},
|
||||
sendPairingReply: async (text) => {
|
||||
await replyComment(client, {
|
||||
await deliverCommentThreadText(client, {
|
||||
file_token: turn.fileToken,
|
||||
file_type: turn.fileType,
|
||||
comment_id: turn.commentId,
|
||||
content: text,
|
||||
is_whole_comment: turn.isWholeComment,
|
||||
});
|
||||
},
|
||||
onReplyError: (err) => {
|
||||
@@ -221,6 +222,7 @@ export async function handleFeishuCommentEvent(
|
||||
fileToken: turn.fileToken,
|
||||
fileType: turn.fileType,
|
||||
commentId: turn.commentId,
|
||||
isWholeComment: turn.isWholeComment,
|
||||
});
|
||||
|
||||
log(
|
||||
|
||||
@@ -52,22 +52,26 @@ export const FeishuDriveSchema = Type.Union([
|
||||
Type.Object({
|
||||
action: Type.Literal("list_comments"),
|
||||
file_token: Type.String({ description: "Document token" }),
|
||||
file_type: CommentFileType,
|
||||
page_size: Type.Optional(Type.Integer({ minimum: 1, description: "Page size" })),
|
||||
file_type: Type.Optional(CommentFileType),
|
||||
page_size: Type.Optional(Type.Integer({ minimum: 1, maximum: 100, description: "Page size" })),
|
||||
page_token: Type.Optional(Type.String({ description: "Comment page token" })),
|
||||
}),
|
||||
Type.Object({
|
||||
action: Type.Literal("list_comment_replies"),
|
||||
file_token: Type.String({ description: "Document token" }),
|
||||
file_type: CommentFileType,
|
||||
file_type: Type.Optional(CommentFileType),
|
||||
comment_id: Type.String({ description: "Comment id" }),
|
||||
page_size: Type.Optional(Type.Integer({ minimum: 1, description: "Page size" })),
|
||||
page_size: Type.Optional(Type.Integer({ minimum: 1, maximum: 100, description: "Page size" })),
|
||||
page_token: Type.Optional(Type.String({ description: "Reply page token" })),
|
||||
}),
|
||||
Type.Object({
|
||||
action: Type.Literal("add_comment"),
|
||||
file_token: Type.String({ description: "Document token" }),
|
||||
file_type: Type.Union([Type.Literal("doc"), Type.Literal("docx")]),
|
||||
file_type: Type.Optional(
|
||||
Type.Union([Type.Literal("doc"), Type.Literal("docx")], {
|
||||
description: "Document type. Defaults to docx when omitted.",
|
||||
}),
|
||||
),
|
||||
content: Type.String({ description: "Comment text content" }),
|
||||
block_id: Type.Optional(
|
||||
Type.String({
|
||||
@@ -79,7 +83,7 @@ export const FeishuDriveSchema = Type.Union([
|
||||
Type.Object({
|
||||
action: Type.Literal("reply_comment"),
|
||||
file_token: Type.String({ description: "Document token" }),
|
||||
file_type: CommentFileType,
|
||||
file_type: Type.Optional(CommentFileType),
|
||||
comment_id: Type.String({ description: "Comment id" }),
|
||||
content: Type.String({ description: "Reply text content" }),
|
||||
}),
|
||||
|
||||
@@ -206,8 +206,10 @@ describe("registerFeishuDriveTools", () => {
|
||||
|
||||
requestMock
|
||||
.mockResolvedValueOnce({
|
||||
code: 99991663,
|
||||
msg: "invalid request body",
|
||||
code: 0,
|
||||
data: {
|
||||
items: [{ comment_id: "c1", is_whole: false }],
|
||||
},
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
code: 0,
|
||||
@@ -224,7 +226,18 @@ describe("registerFeishuDriveTools", () => {
|
||||
4,
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
url: "/open-apis/drive/v1/files/doc_1/comments/c1/replies?file_type=docx",
|
||||
url: "/open-apis/drive/v1/files/doc_1/comments/batch_query?file_type=docx&user_id_type=open_id",
|
||||
data: {
|
||||
comment_ids: ["c1"],
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(requestMock).toHaveBeenNthCalledWith(
|
||||
5,
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
url: "/open-apis/drive/v1/files/doc_1/comments/c1/replies",
|
||||
params: { file_type: "docx" },
|
||||
data: {
|
||||
content: {
|
||||
elements: [
|
||||
@@ -239,18 +252,821 @@ describe("registerFeishuDriveTools", () => {
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(requestMock).toHaveBeenNthCalledWith(
|
||||
5,
|
||||
expect(replyCommentResult.details).toEqual(
|
||||
expect.objectContaining({ success: true, reply_id: "r4" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("defaults add_comment file_type to docx when omitted", async () => {
|
||||
const registerTool = vi.fn();
|
||||
const infoSpy = vi.spyOn(console, "info").mockImplementation(() => {});
|
||||
registerFeishuDriveTools(
|
||||
createDriveToolApi({
|
||||
config: {
|
||||
channels: {
|
||||
feishu: {
|
||||
enabled: true,
|
||||
appId: "app_id",
|
||||
appSecret: "app_secret", // pragma: allowlist secret
|
||||
tools: { drive: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
registerTool,
|
||||
}),
|
||||
);
|
||||
|
||||
const toolFactory = registerTool.mock.calls[0]?.[0];
|
||||
const tool = toolFactory?.({ agentAccountId: undefined });
|
||||
|
||||
requestMock.mockResolvedValueOnce({
|
||||
code: 0,
|
||||
data: { comment_id: "c-default-docx" },
|
||||
});
|
||||
|
||||
const result = await tool.execute("call-default-docx", {
|
||||
action: "add_comment",
|
||||
file_token: "doc_1",
|
||||
content: "defaulted file type",
|
||||
});
|
||||
|
||||
expect(requestMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
url: "/open-apis/drive/v1/files/doc_1/comments/c1/replies?file_type=docx",
|
||||
url: "/open-apis/drive/v1/files/doc_1/new_comments",
|
||||
data: {
|
||||
reply_elements: [{ type: "text", text: "handled" }],
|
||||
file_type: "docx",
|
||||
reply_elements: [{ type: "text", text: "defaulted file type" }],
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(infoSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining("add_comment missing file_type; defaulting to docx"),
|
||||
);
|
||||
expect(result.details).toEqual(
|
||||
expect.objectContaining({ success: true, comment_id: "c-default-docx" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("defaults list_comments file_type to docx when omitted", async () => {
|
||||
const registerTool = vi.fn();
|
||||
const infoSpy = vi.spyOn(console, "info").mockImplementation(() => {});
|
||||
registerFeishuDriveTools(
|
||||
createDriveToolApi({
|
||||
config: {
|
||||
channels: {
|
||||
feishu: {
|
||||
enabled: true,
|
||||
appId: "app_id",
|
||||
appSecret: "app_secret", // pragma: allowlist secret
|
||||
tools: { drive: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
registerTool,
|
||||
}),
|
||||
);
|
||||
|
||||
const toolFactory = registerTool.mock.calls[0]?.[0];
|
||||
const tool = toolFactory?.({ agentAccountId: undefined });
|
||||
|
||||
requestMock.mockResolvedValueOnce({
|
||||
code: 0,
|
||||
data: { has_more: false, items: [] },
|
||||
});
|
||||
|
||||
await tool.execute("call-list-default-docx", {
|
||||
action: "list_comments",
|
||||
file_token: "doc_1",
|
||||
});
|
||||
|
||||
expect(requestMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
method: "GET",
|
||||
url: "/open-apis/drive/v1/files/doc_1/comments?file_type=docx&user_id_type=open_id",
|
||||
}),
|
||||
);
|
||||
expect(infoSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining("list_comments missing file_type; defaulting to docx"),
|
||||
);
|
||||
});
|
||||
|
||||
it("defaults list_comment_replies file_type to docx when omitted", async () => {
|
||||
const registerTool = vi.fn();
|
||||
const infoSpy = vi.spyOn(console, "info").mockImplementation(() => {});
|
||||
registerFeishuDriveTools(
|
||||
createDriveToolApi({
|
||||
config: {
|
||||
channels: {
|
||||
feishu: {
|
||||
enabled: true,
|
||||
appId: "app_id",
|
||||
appSecret: "app_secret", // pragma: allowlist secret
|
||||
tools: { drive: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
registerTool,
|
||||
}),
|
||||
);
|
||||
|
||||
const toolFactory = registerTool.mock.calls[0]?.[0];
|
||||
const tool = toolFactory?.({ agentAccountId: undefined });
|
||||
|
||||
requestMock.mockResolvedValueOnce({
|
||||
code: 0,
|
||||
data: { has_more: false, items: [] },
|
||||
});
|
||||
|
||||
await tool.execute("call-replies-default-docx", {
|
||||
action: "list_comment_replies",
|
||||
file_token: "doc_1",
|
||||
comment_id: "c1",
|
||||
});
|
||||
|
||||
expect(requestMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
method: "GET",
|
||||
url: "/open-apis/drive/v1/files/doc_1/comments/c1/replies?file_type=docx&user_id_type=open_id",
|
||||
}),
|
||||
);
|
||||
expect(infoSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining("list_comment_replies missing file_type; defaulting to docx"),
|
||||
);
|
||||
});
|
||||
|
||||
it("surfaces reply_comment HTTP errors when the single supported body fails", async () => {
|
||||
const registerTool = vi.fn();
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
registerFeishuDriveTools(
|
||||
createDriveToolApi({
|
||||
config: {
|
||||
channels: {
|
||||
feishu: {
|
||||
enabled: true,
|
||||
appId: "app_id",
|
||||
appSecret: "app_secret", // pragma: allowlist secret
|
||||
tools: { drive: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
registerTool,
|
||||
}),
|
||||
);
|
||||
|
||||
const toolFactory = registerTool.mock.calls[0]?.[0];
|
||||
const tool = toolFactory?.({ agentAccountId: undefined });
|
||||
|
||||
requestMock
|
||||
.mockResolvedValueOnce({
|
||||
code: 0,
|
||||
data: {
|
||||
items: [{ comment_id: "c1", is_whole: false }],
|
||||
},
|
||||
})
|
||||
.mockRejectedValueOnce({
|
||||
message: "Request failed with status code 400",
|
||||
code: "ERR_BAD_REQUEST",
|
||||
config: {
|
||||
method: "post",
|
||||
url: "https://open.feishu.cn/open-apis/drive/v1/files/doc_1/comments/c1/replies",
|
||||
params: { file_type: "docx" },
|
||||
},
|
||||
response: {
|
||||
status: 400,
|
||||
data: {
|
||||
code: 99992402,
|
||||
msg: "field validation failed",
|
||||
log_id: "log_legacy_400",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const replyCommentResult = await tool.execute("call-throw", {
|
||||
action: "reply_comment",
|
||||
file_token: "doc_1",
|
||||
file_type: "docx",
|
||||
comment_id: "c1",
|
||||
content: "inserted successfully",
|
||||
});
|
||||
|
||||
expect(requestMock).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
url: "/open-apis/drive/v1/files/doc_1/comments/batch_query?file_type=docx&user_id_type=open_id",
|
||||
data: {
|
||||
comment_ids: ["c1"],
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(requestMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
url: "/open-apis/drive/v1/files/doc_1/comments/c1/replies",
|
||||
params: { file_type: "docx" },
|
||||
data: {
|
||||
content: {
|
||||
elements: [
|
||||
{
|
||||
type: "text_run",
|
||||
text_run: {
|
||||
text: "inserted successfully",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("replyComment threw"));
|
||||
expect(replyCommentResult.details).toEqual(
|
||||
expect.objectContaining({ error: "Request failed with status code 400" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("defaults reply_comment target fields from the ambient Feishu comment delivery context", async () => {
|
||||
const registerTool = vi.fn();
|
||||
registerFeishuDriveTools(
|
||||
createDriveToolApi({
|
||||
config: {
|
||||
channels: {
|
||||
feishu: {
|
||||
enabled: true,
|
||||
appId: "app_id",
|
||||
appSecret: "app_secret", // pragma: allowlist secret
|
||||
tools: { drive: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
registerTool,
|
||||
}),
|
||||
);
|
||||
|
||||
const toolFactory = registerTool.mock.calls[0]?.[0];
|
||||
const tool = toolFactory?.({
|
||||
agentAccountId: undefined,
|
||||
deliveryContext: {
|
||||
channel: "feishu",
|
||||
to: "comment:docx:doc_1:c1",
|
||||
},
|
||||
});
|
||||
|
||||
requestMock
|
||||
.mockResolvedValueOnce({
|
||||
code: 0,
|
||||
data: {
|
||||
items: [{ comment_id: "c1", is_whole: false }],
|
||||
},
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
code: 0,
|
||||
data: { reply_id: "r6" },
|
||||
});
|
||||
|
||||
const replyCommentResult = await tool.execute("call-ambient", {
|
||||
action: "reply_comment",
|
||||
content: "ambient success",
|
||||
});
|
||||
|
||||
expect(requestMock).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
url: "/open-apis/drive/v1/files/doc_1/comments/batch_query?file_type=docx&user_id_type=open_id",
|
||||
data: {
|
||||
comment_ids: ["c1"],
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(requestMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
url: "/open-apis/drive/v1/files/doc_1/comments/c1/replies",
|
||||
params: { file_type: "docx" },
|
||||
data: {
|
||||
content: {
|
||||
elements: [
|
||||
{
|
||||
type: "text_run",
|
||||
text_run: {
|
||||
text: "ambient success",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(replyCommentResult.details).toEqual(
|
||||
expect.objectContaining({ success: true, reply_id: "r4" }),
|
||||
expect.objectContaining({ success: true, reply_id: "r6" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not inherit non-doc ambient file types for add_comment", async () => {
|
||||
const registerTool = vi.fn();
|
||||
const infoSpy = vi.spyOn(console, "info").mockImplementation(() => {});
|
||||
registerFeishuDriveTools(
|
||||
createDriveToolApi({
|
||||
config: {
|
||||
channels: {
|
||||
feishu: {
|
||||
enabled: true,
|
||||
appId: "app_id",
|
||||
appSecret: "app_secret", // pragma: allowlist secret
|
||||
tools: { drive: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
registerTool,
|
||||
}),
|
||||
);
|
||||
|
||||
const toolFactory = registerTool.mock.calls[0]?.[0];
|
||||
const tool = toolFactory?.({
|
||||
agentAccountId: undefined,
|
||||
deliveryContext: {
|
||||
channel: "feishu",
|
||||
to: "comment:sheet:sheet_1:c1",
|
||||
},
|
||||
});
|
||||
|
||||
requestMock.mockResolvedValueOnce({
|
||||
code: 0,
|
||||
data: { comment_id: "c-add-docx" },
|
||||
});
|
||||
|
||||
const result = await tool.execute("call-add-ignore-sheet-ambient", {
|
||||
action: "add_comment",
|
||||
file_token: "doc_1",
|
||||
content: "default add comment",
|
||||
});
|
||||
|
||||
expect(requestMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
url: "/open-apis/drive/v1/files/doc_1/new_comments",
|
||||
data: {
|
||||
file_type: "docx",
|
||||
reply_elements: [{ type: "text", text: "default add comment" }],
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(infoSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining("add_comment missing file_type; defaulting to docx"),
|
||||
);
|
||||
expect(result.details).toEqual(
|
||||
expect.objectContaining({ success: true, comment_id: "c-add-docx" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("defaults reply_comment file_type to docx when omitted", async () => {
|
||||
const registerTool = vi.fn();
|
||||
const infoSpy = vi.spyOn(console, "info").mockImplementation(() => {});
|
||||
registerFeishuDriveTools(
|
||||
createDriveToolApi({
|
||||
config: {
|
||||
channels: {
|
||||
feishu: {
|
||||
enabled: true,
|
||||
appId: "app_id",
|
||||
appSecret: "app_secret", // pragma: allowlist secret
|
||||
tools: { drive: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
registerTool,
|
||||
}),
|
||||
);
|
||||
|
||||
const toolFactory = registerTool.mock.calls[0]?.[0];
|
||||
const tool = toolFactory?.({ agentAccountId: undefined });
|
||||
|
||||
requestMock
|
||||
.mockResolvedValueOnce({
|
||||
code: 0,
|
||||
data: {
|
||||
items: [{ comment_id: "c1", is_whole: false }],
|
||||
},
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
code: 0,
|
||||
data: { reply_id: "r-default-docx" },
|
||||
});
|
||||
|
||||
const result = await tool.execute("call-reply-default-docx", {
|
||||
action: "reply_comment",
|
||||
file_token: "doc_1",
|
||||
comment_id: "c1",
|
||||
content: "default reply docx",
|
||||
});
|
||||
|
||||
expect(requestMock).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
url: "/open-apis/drive/v1/files/doc_1/comments/batch_query?file_type=docx&user_id_type=open_id",
|
||||
data: { comment_ids: ["c1"] },
|
||||
}),
|
||||
);
|
||||
expect(requestMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
url: "/open-apis/drive/v1/files/doc_1/comments/c1/replies",
|
||||
params: { file_type: "docx" },
|
||||
data: {
|
||||
content: {
|
||||
elements: [
|
||||
{
|
||||
type: "text_run",
|
||||
text_run: {
|
||||
text: "default reply docx",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(infoSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining("reply_comment missing file_type; defaulting to docx"),
|
||||
);
|
||||
expect(result.details).toEqual(
|
||||
expect.objectContaining({ success: true, reply_id: "r-default-docx" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("routes whole-document reply_comment requests through add_comment compatibility", async () => {
|
||||
const registerTool = vi.fn();
|
||||
const infoSpy = vi.spyOn(console, "info").mockImplementation(() => {});
|
||||
registerFeishuDriveTools(
|
||||
createDriveToolApi({
|
||||
config: {
|
||||
channels: {
|
||||
feishu: {
|
||||
enabled: true,
|
||||
appId: "app_id",
|
||||
appSecret: "app_secret", // pragma: allowlist secret
|
||||
tools: { drive: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
registerTool,
|
||||
}),
|
||||
);
|
||||
|
||||
const toolFactory = registerTool.mock.calls[0]?.[0];
|
||||
const tool = toolFactory?.({ agentAccountId: undefined });
|
||||
|
||||
requestMock
|
||||
.mockResolvedValueOnce({
|
||||
code: 0,
|
||||
data: {
|
||||
items: [{ comment_id: "c1", is_whole: true }],
|
||||
},
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
code: 0,
|
||||
data: { comment_id: "c2" },
|
||||
});
|
||||
|
||||
const result = await tool.execute("call-whole", {
|
||||
action: "reply_comment",
|
||||
file_token: "doc_1",
|
||||
file_type: "docx",
|
||||
comment_id: "c1",
|
||||
content: "whole comment follow-up",
|
||||
});
|
||||
|
||||
expect(requestMock).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
url: "/open-apis/drive/v1/files/doc_1/comments/batch_query?file_type=docx&user_id_type=open_id",
|
||||
data: {
|
||||
comment_ids: ["c1"],
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(requestMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
url: "/open-apis/drive/v1/files/doc_1/new_comments",
|
||||
data: {
|
||||
file_type: "docx",
|
||||
reply_elements: [{ type: "text", text: "whole comment follow-up" }],
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(infoSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining("whole-comment compatibility path"),
|
||||
);
|
||||
expect(result.details).toEqual(
|
||||
expect.objectContaining({
|
||||
success: true,
|
||||
comment_id: "c2",
|
||||
delivery_mode: "add_comment",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("continues with reply_comment when comment metadata preflight fails", async () => {
|
||||
const registerTool = vi.fn();
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
registerFeishuDriveTools(
|
||||
createDriveToolApi({
|
||||
config: {
|
||||
channels: {
|
||||
feishu: {
|
||||
enabled: true,
|
||||
appId: "app_id",
|
||||
appSecret: "app_secret", // pragma: allowlist secret
|
||||
tools: { drive: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
registerTool,
|
||||
}),
|
||||
);
|
||||
|
||||
const toolFactory = registerTool.mock.calls[0]?.[0];
|
||||
const tool = toolFactory?.({ agentAccountId: undefined });
|
||||
|
||||
requestMock.mockRejectedValueOnce(new Error("preflight unavailable")).mockResolvedValueOnce({
|
||||
code: 0,
|
||||
data: { reply_id: "r-preflight-fallback" },
|
||||
});
|
||||
|
||||
const result = await tool.execute("call-preflight-fallback", {
|
||||
action: "reply_comment",
|
||||
file_token: "doc_1",
|
||||
file_type: "docx",
|
||||
comment_id: "c1",
|
||||
content: "preflight fallback reply",
|
||||
});
|
||||
|
||||
expect(requestMock).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
url: "/open-apis/drive/v1/files/doc_1/comments/batch_query?file_type=docx&user_id_type=open_id",
|
||||
data: {
|
||||
comment_ids: ["c1"],
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(requestMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
url: "/open-apis/drive/v1/files/doc_1/comments/c1/replies",
|
||||
params: { file_type: "docx" },
|
||||
data: {
|
||||
content: {
|
||||
elements: [
|
||||
{
|
||||
type: "text_run",
|
||||
text_run: {
|
||||
text: "preflight fallback reply",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining("comment metadata preflight failed"),
|
||||
);
|
||||
expect(result.details).toEqual(
|
||||
expect.objectContaining({
|
||||
success: true,
|
||||
reply_id: "r-preflight-fallback",
|
||||
delivery_mode: "reply_comment",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("continues with reply_comment when batch_query returns no exact comment match", async () => {
|
||||
const registerTool = vi.fn();
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
registerFeishuDriveTools(
|
||||
createDriveToolApi({
|
||||
config: {
|
||||
channels: {
|
||||
feishu: {
|
||||
enabled: true,
|
||||
appId: "app_id",
|
||||
appSecret: "app_secret", // pragma: allowlist secret
|
||||
tools: { drive: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
registerTool,
|
||||
}),
|
||||
);
|
||||
|
||||
const toolFactory = registerTool.mock.calls[0]?.[0];
|
||||
const tool = toolFactory?.({ agentAccountId: undefined });
|
||||
|
||||
requestMock
|
||||
.mockResolvedValueOnce({
|
||||
code: 0,
|
||||
data: {
|
||||
items: [{ comment_id: "different_comment", is_whole: true }],
|
||||
},
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
code: 0,
|
||||
data: { reply_id: "r-no-exact-match" },
|
||||
});
|
||||
|
||||
const result = await tool.execute("call-preflight-no-exact-match", {
|
||||
action: "reply_comment",
|
||||
file_token: "doc_1",
|
||||
file_type: "docx",
|
||||
comment_id: "c1",
|
||||
content: "fallback on exact match miss",
|
||||
});
|
||||
|
||||
expect(requestMock).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
url: "/open-apis/drive/v1/files/doc_1/comments/batch_query?file_type=docx&user_id_type=open_id",
|
||||
data: {
|
||||
comment_ids: ["c1"],
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(requestMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
url: "/open-apis/drive/v1/files/doc_1/comments/c1/replies",
|
||||
params: { file_type: "docx" },
|
||||
data: {
|
||||
content: {
|
||||
elements: [
|
||||
{
|
||||
type: "text_run",
|
||||
text_run: {
|
||||
text: "fallback on exact match miss",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(warnSpy).not.toHaveBeenCalledWith(
|
||||
expect.stringContaining("whole-comment compatibility path"),
|
||||
);
|
||||
expect(result.details).toEqual(
|
||||
expect.objectContaining({
|
||||
success: true,
|
||||
reply_id: "r-no-exact-match",
|
||||
delivery_mode: "reply_comment",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to add_comment when reply_comment returns compatibility code 1069302 even without is_whole metadata", async () => {
|
||||
const registerTool = vi.fn();
|
||||
const infoSpy = vi.spyOn(console, "info").mockImplementation(() => {});
|
||||
registerFeishuDriveTools(
|
||||
createDriveToolApi({
|
||||
config: {
|
||||
channels: {
|
||||
feishu: {
|
||||
enabled: true,
|
||||
appId: "app_id",
|
||||
appSecret: "app_secret", // pragma: allowlist secret
|
||||
tools: { drive: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
registerTool,
|
||||
}),
|
||||
);
|
||||
|
||||
const toolFactory = registerTool.mock.calls[0]?.[0];
|
||||
const tool = toolFactory?.({ agentAccountId: undefined });
|
||||
|
||||
requestMock
|
||||
.mockResolvedValueOnce({
|
||||
code: 0,
|
||||
data: {
|
||||
items: [{ comment_id: "c1", is_whole: false }],
|
||||
},
|
||||
})
|
||||
.mockRejectedValueOnce({
|
||||
message: "Request failed with status code 400",
|
||||
code: "ERR_BAD_REQUEST",
|
||||
config: {
|
||||
method: "post",
|
||||
url: "https://open.feishu.cn/open-apis/drive/v1/files/doc_1/comments/c1/replies",
|
||||
params: { file_type: "docx" },
|
||||
},
|
||||
response: {
|
||||
status: 400,
|
||||
data: {
|
||||
code: 1069302,
|
||||
msg: "param error",
|
||||
log_id: "log_reply_forbidden",
|
||||
},
|
||||
},
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
code: 0,
|
||||
data: { comment_id: "c3" },
|
||||
});
|
||||
|
||||
const result = await tool.execute("call-reply-forbidden", {
|
||||
action: "reply_comment",
|
||||
file_token: "doc_1",
|
||||
file_type: "docx",
|
||||
comment_id: "c1",
|
||||
content: "compat follow-up",
|
||||
});
|
||||
|
||||
expect(requestMock).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
url: "/open-apis/drive/v1/files/doc_1/new_comments",
|
||||
data: {
|
||||
file_type: "docx",
|
||||
reply_elements: [{ type: "text", text: "compat follow-up" }],
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(infoSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining("reply-not-allowed compatibility path"),
|
||||
);
|
||||
expect(result.details).toEqual(
|
||||
expect.objectContaining({
|
||||
success: true,
|
||||
comment_id: "c3",
|
||||
delivery_mode: "add_comment",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("clamps comment list page sizes to the Feishu API maximum", async () => {
|
||||
const registerTool = vi.fn();
|
||||
registerFeishuDriveTools(
|
||||
createDriveToolApi({
|
||||
config: {
|
||||
channels: {
|
||||
feishu: {
|
||||
enabled: true,
|
||||
appId: "app_id",
|
||||
appSecret: "app_secret", // pragma: allowlist secret
|
||||
tools: { drive: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
registerTool,
|
||||
}),
|
||||
);
|
||||
|
||||
const toolFactory = registerTool.mock.calls[0]?.[0];
|
||||
const tool = toolFactory?.({ agentAccountId: undefined });
|
||||
|
||||
requestMock.mockResolvedValueOnce({ code: 0, data: { has_more: false, items: [] } });
|
||||
await tool.execute("call-list", {
|
||||
action: "list_comments",
|
||||
file_token: "doc_1",
|
||||
file_type: "docx",
|
||||
page_size: 200,
|
||||
});
|
||||
expect(requestMock).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({
|
||||
method: "GET",
|
||||
url: "/open-apis/drive/v1/files/doc_1/comments?file_type=docx&page_size=100&user_id_type=open_id",
|
||||
}),
|
||||
);
|
||||
|
||||
requestMock.mockResolvedValueOnce({ code: 0, data: { has_more: false, items: [] } });
|
||||
await tool.execute("call-replies", {
|
||||
action: "list_comment_replies",
|
||||
file_token: "doc_1",
|
||||
file_type: "docx",
|
||||
comment_id: "c1",
|
||||
page_size: 200,
|
||||
});
|
||||
expect(requestMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
method: "GET",
|
||||
url: "/open-apis/drive/v1/files/doc_1/comments/c1/replies?file_type=docx&page_size=100&user_id_type=open_id",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type * as Lark from "@larksuiteoapi/node-sdk";
|
||||
import type { OpenClawPluginApi } from "../runtime-api.js";
|
||||
import { listEnabledFeishuAccounts } from "./accounts.js";
|
||||
import { type CommentFileType } from "./comment-target.js";
|
||||
import { parseFeishuCommentTarget, type CommentFileType } from "./comment-target.js";
|
||||
import { FeishuDriveSchema, type FeishuDriveParams } from "./drive-schema.js";
|
||||
import { createFeishuToolClient, resolveAnyEnabledFeishuToolsConfig } from "./tool-account.js";
|
||||
import {
|
||||
@@ -26,6 +26,7 @@ type FeishuDriveInternalClient = Lark.Client & {
|
||||
request(params: {
|
||||
method: "GET" | "POST";
|
||||
url: string;
|
||||
params?: Record<string, string | undefined>;
|
||||
data: unknown;
|
||||
timeout?: number;
|
||||
}): Promise<unknown>;
|
||||
@@ -33,10 +34,33 @@ type FeishuDriveInternalClient = Lark.Client & {
|
||||
|
||||
type FeishuDriveApiResponse<T> = {
|
||||
code: number;
|
||||
log_id?: string;
|
||||
msg?: string;
|
||||
data?: T;
|
||||
};
|
||||
|
||||
class FeishuReplyCommentError extends Error {
|
||||
httpStatus?: number;
|
||||
feishuCode?: number | string;
|
||||
feishuMsg?: string;
|
||||
feishuLogId?: string;
|
||||
|
||||
constructor(params: {
|
||||
message: string;
|
||||
httpStatus?: number;
|
||||
feishuCode?: number | string;
|
||||
feishuMsg?: string;
|
||||
feishuLogId?: string;
|
||||
}) {
|
||||
super(params.message);
|
||||
this.name = "FeishuReplyCommentError";
|
||||
this.httpStatus = params.httpStatus;
|
||||
this.feishuCode = params.feishuCode;
|
||||
this.feishuMsg = params.feishuMsg;
|
||||
this.feishuLogId = params.feishuLogId;
|
||||
}
|
||||
}
|
||||
|
||||
type FeishuDriveCommentReply = {
|
||||
reply_id?: string;
|
||||
user_id?: string;
|
||||
@@ -74,6 +98,13 @@ type FeishuDriveListRepliesResponse = FeishuDriveApiResponse<{
|
||||
page_token?: string;
|
||||
}>;
|
||||
|
||||
type FeishuDriveToolContext = {
|
||||
deliveryContext?: {
|
||||
channel?: string;
|
||||
to?: string;
|
||||
};
|
||||
};
|
||||
|
||||
const FEISHU_DRIVE_REQUEST_TIMEOUT_MS = 30_000;
|
||||
|
||||
function getDriveInternalClient(client: Lark.Client): FeishuDriveInternalClient {
|
||||
@@ -159,12 +190,14 @@ async function requestDriveApi<T>(params: {
|
||||
client: Lark.Client;
|
||||
method: "GET" | "POST";
|
||||
url: string;
|
||||
query?: Record<string, string | undefined>;
|
||||
data?: unknown;
|
||||
}): Promise<T> {
|
||||
const internalClient = getDriveInternalClient(params.client);
|
||||
return (await internalClient.request({
|
||||
method: params.method,
|
||||
url: params.url,
|
||||
params: params.query ?? {},
|
||||
data: params.data ?? {},
|
||||
timeout: FEISHU_DRIVE_REQUEST_TIMEOUT_MS,
|
||||
})) as T;
|
||||
@@ -205,6 +238,149 @@ function normalizeCommentCard(comment: FeishuDriveCommentCard) {
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeCommentPageSize(pageSize: number | undefined): string | undefined {
|
||||
if (typeof pageSize !== "number" || !Number.isFinite(pageSize)) {
|
||||
return undefined;
|
||||
}
|
||||
return String(Math.min(Math.max(Math.floor(pageSize), 1), 100));
|
||||
}
|
||||
|
||||
function resolveAmbientCommentTarget(context: FeishuDriveToolContext | undefined) {
|
||||
const deliveryContext = context?.deliveryContext;
|
||||
if (deliveryContext?.channel && deliveryContext.channel !== "feishu") {
|
||||
return null;
|
||||
}
|
||||
return parseFeishuCommentTarget(deliveryContext?.to);
|
||||
}
|
||||
|
||||
function applyAmbientCommentDefaults<
|
||||
T extends {
|
||||
file_token?: string;
|
||||
file_type?: CommentFileType;
|
||||
comment_id?: string;
|
||||
},
|
||||
>(params: T, context: FeishuDriveToolContext | undefined): T {
|
||||
const ambient = resolveAmbientCommentTarget(context);
|
||||
if (!ambient) {
|
||||
return params;
|
||||
}
|
||||
return {
|
||||
...params,
|
||||
file_token: params.file_token?.trim() || ambient.fileToken,
|
||||
file_type: params.file_type ?? ambient.fileType,
|
||||
comment_id: params.comment_id?.trim() || ambient.commentId,
|
||||
};
|
||||
}
|
||||
|
||||
function applyAddCommentAmbientDefaults<
|
||||
T extends {
|
||||
file_token?: string;
|
||||
file_type?: "doc" | "docx";
|
||||
},
|
||||
>(params: T, context: FeishuDriveToolContext | undefined): T {
|
||||
const ambient = resolveAmbientCommentTarget(context);
|
||||
if (!ambient || (ambient.fileType !== "doc" && ambient.fileType !== "docx")) {
|
||||
return params;
|
||||
}
|
||||
return {
|
||||
...params,
|
||||
file_token: params.file_token?.trim() || ambient.fileToken,
|
||||
file_type: params.file_type ?? ambient.fileType,
|
||||
};
|
||||
}
|
||||
|
||||
function applyAddCommentDefaults<
|
||||
T extends {
|
||||
file_token?: string;
|
||||
file_type?: "doc" | "docx";
|
||||
},
|
||||
>(params: T): T & { file_type: "doc" | "docx" } {
|
||||
const fileType = params.file_type ?? "docx";
|
||||
if (!params.file_type) {
|
||||
console.info(
|
||||
`[feishu_drive] add_comment missing file_type; defaulting to docx ` +
|
||||
`file_token=${params.file_token ?? "unknown"}`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
...params,
|
||||
file_type: fileType,
|
||||
};
|
||||
}
|
||||
|
||||
function applyCommentFileTypeDefault<
|
||||
T extends {
|
||||
file_token?: string;
|
||||
file_type?: CommentFileType;
|
||||
},
|
||||
>(
|
||||
params: T,
|
||||
action: "list_comments" | "list_comment_replies" | "reply_comment",
|
||||
): T & {
|
||||
file_type: CommentFileType;
|
||||
} {
|
||||
const fileType = params.file_type ?? "docx";
|
||||
if (!params.file_type) {
|
||||
console.info(
|
||||
`[feishu_drive] ${action} missing file_type; defaulting to docx ` +
|
||||
`file_token=${params.file_token ?? "unknown"}`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
...params,
|
||||
file_type: fileType,
|
||||
};
|
||||
}
|
||||
|
||||
function formatDriveApiError(error: unknown): string {
|
||||
if (!isRecord(error)) {
|
||||
return String(error);
|
||||
}
|
||||
const response = isRecord(error.response) ? error.response : undefined;
|
||||
const responseData = isRecord(response?.data) ? response?.data : undefined;
|
||||
return JSON.stringify({
|
||||
message: typeof error.message === "string" ? error.message : String(error),
|
||||
code: readString(error.code),
|
||||
method: readString(isRecord(error.config) ? error.config.method : undefined),
|
||||
url: readString(isRecord(error.config) ? error.config.url : undefined),
|
||||
params: isRecord(error.config) ? error.config.params : undefined,
|
||||
http_status: typeof response?.status === "number" ? response.status : undefined,
|
||||
feishu_code:
|
||||
typeof responseData?.code === "number" ? responseData.code : readString(responseData?.code),
|
||||
feishu_msg: readString(responseData?.msg),
|
||||
feishu_log_id: readString(responseData?.log_id),
|
||||
});
|
||||
}
|
||||
|
||||
function extractDriveApiErrorMeta(error: unknown): {
|
||||
message: string;
|
||||
httpStatus?: number;
|
||||
feishuCode?: number | string;
|
||||
feishuMsg?: string;
|
||||
feishuLogId?: string;
|
||||
} {
|
||||
if (!isRecord(error)) {
|
||||
return { message: String(error) };
|
||||
}
|
||||
const response = isRecord(error.response) ? error.response : undefined;
|
||||
const responseData = isRecord(response?.data) ? response?.data : undefined;
|
||||
return {
|
||||
message: typeof error.message === "string" ? error.message : String(error),
|
||||
httpStatus: typeof response?.status === "number" ? response.status : undefined,
|
||||
feishuCode:
|
||||
typeof responseData?.code === "number" ? responseData.code : readString(responseData?.code),
|
||||
feishuMsg: readString(responseData?.msg),
|
||||
feishuLogId: readString(responseData?.log_id),
|
||||
};
|
||||
}
|
||||
|
||||
function isReplyNotAllowedError(error: unknown): boolean {
|
||||
if (!(error instanceof FeishuReplyCommentError)) {
|
||||
return false;
|
||||
}
|
||||
return error.feishuCode === 1069302;
|
||||
}
|
||||
|
||||
async function getRootFolderToken(client: Lark.Client): Promise<string> {
|
||||
// Use generic HTTP client to call the root folder meta API
|
||||
// as it's not directly exposed in the SDK
|
||||
@@ -371,10 +547,7 @@ async function listComments(
|
||||
`/open-apis/drive/v1/files/${encodeURIComponent(params.file_token)}/comments` +
|
||||
encodeQuery({
|
||||
file_type: params.file_type,
|
||||
page_size:
|
||||
typeof params.page_size === "number" && Number.isFinite(params.page_size)
|
||||
? String(params.page_size)
|
||||
: undefined,
|
||||
page_size: normalizeCommentPageSize(params.page_size),
|
||||
page_token: params.page_token,
|
||||
user_id_type: "open_id",
|
||||
}),
|
||||
@@ -407,10 +580,7 @@ async function listCommentReplies(
|
||||
)}/replies` +
|
||||
encodeQuery({
|
||||
file_type: params.file_type,
|
||||
page_size:
|
||||
typeof params.page_size === "number" && Number.isFinite(params.page_size)
|
||||
? String(params.page_size)
|
||||
: undefined,
|
||||
page_size: normalizeCommentPageSize(params.page_size),
|
||||
page_token: params.page_token,
|
||||
user_id_type: "open_id",
|
||||
}),
|
||||
@@ -431,7 +601,7 @@ async function addComment(
|
||||
content: string;
|
||||
block_id?: string;
|
||||
},
|
||||
) {
|
||||
): Promise<{ success: true } & Record<string, unknown>> {
|
||||
if (params.block_id?.trim() && params.file_type !== "docx") {
|
||||
throw new Error("block_id is only supported for docx comments");
|
||||
}
|
||||
@@ -453,6 +623,34 @@ async function addComment(
|
||||
};
|
||||
}
|
||||
|
||||
// Fetch comment metadata via batch_query because the single-comment endpoint
|
||||
// does not support partial comments.
|
||||
async function queryCommentById(
|
||||
client: Lark.Client,
|
||||
params: {
|
||||
file_token: string;
|
||||
file_type: CommentFileType;
|
||||
comment_id: string;
|
||||
},
|
||||
) {
|
||||
const response = assertDriveApiSuccess(
|
||||
await requestDriveApi<FeishuDriveListCommentsResponse>({
|
||||
client,
|
||||
method: "POST",
|
||||
url:
|
||||
`/open-apis/drive/v1/files/${encodeURIComponent(params.file_token)}/comments/batch_query` +
|
||||
encodeQuery({
|
||||
file_type: params.file_type,
|
||||
user_id_type: "open_id",
|
||||
}),
|
||||
data: {
|
||||
comment_ids: [params.comment_id],
|
||||
},
|
||||
}),
|
||||
);
|
||||
return response.data?.items?.find((comment) => comment.comment_id?.trim() === params.comment_id);
|
||||
}
|
||||
|
||||
export async function replyComment(
|
||||
client: Lark.Client,
|
||||
params: {
|
||||
@@ -462,34 +660,28 @@ export async function replyComment(
|
||||
content: string;
|
||||
},
|
||||
): Promise<{ success: true; reply_id?: string } & Record<string, unknown>> {
|
||||
const url =
|
||||
`/open-apis/drive/v1/files/${encodeURIComponent(params.file_token)}/comments/${encodeURIComponent(
|
||||
params.comment_id,
|
||||
)}/replies` + encodeQuery({ file_type: params.file_type });
|
||||
const attempts: unknown[] = [
|
||||
{
|
||||
content: {
|
||||
elements: [
|
||||
{
|
||||
type: "text_run",
|
||||
text_run: {
|
||||
text: params.content,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
reply_elements: buildReplyElements(params.content),
|
||||
},
|
||||
];
|
||||
let lastMessage = "Feishu Drive reply comment failed";
|
||||
for (const data of attempts) {
|
||||
const url = `/open-apis/drive/v1/files/${encodeURIComponent(params.file_token)}/comments/${encodeURIComponent(
|
||||
params.comment_id,
|
||||
)}/replies`;
|
||||
const query = { file_type: params.file_type };
|
||||
try {
|
||||
const response = (await requestDriveApi<FeishuDriveApiResponse<Record<string, unknown>>>({
|
||||
client,
|
||||
method: "POST",
|
||||
url,
|
||||
data,
|
||||
query,
|
||||
data: {
|
||||
content: {
|
||||
elements: [
|
||||
{
|
||||
type: "text_run",
|
||||
text_run: {
|
||||
text: params.content,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
})) as FeishuDriveApiResponse<Record<string, unknown>>;
|
||||
if (response.code === 0) {
|
||||
return {
|
||||
@@ -497,9 +689,116 @@ export async function replyComment(
|
||||
...response.data,
|
||||
};
|
||||
}
|
||||
lastMessage = response.msg ?? lastMessage;
|
||||
console.warn(
|
||||
`[feishu_drive] replyComment failed ` +
|
||||
`comment=${params.comment_id} file_type=${params.file_type} ` +
|
||||
`code=${response.code ?? "unknown"} ` +
|
||||
`msg=${response.msg ?? "unknown"} log_id=${response.log_id ?? "unknown"}`,
|
||||
);
|
||||
throw new FeishuReplyCommentError({
|
||||
message: response.msg ?? "Feishu Drive reply comment failed",
|
||||
feishuCode: response.code,
|
||||
feishuMsg: response.msg,
|
||||
feishuLogId: response.log_id,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof FeishuReplyCommentError) {
|
||||
throw error;
|
||||
}
|
||||
const meta = extractDriveApiErrorMeta(error);
|
||||
console.warn(
|
||||
`[feishu_drive] replyComment threw ` +
|
||||
`comment=${params.comment_id} file_type=${params.file_type} ` +
|
||||
`error=${formatDriveApiError(error)}`,
|
||||
);
|
||||
throw new FeishuReplyCommentError({
|
||||
message: meta.message,
|
||||
httpStatus: meta.httpStatus,
|
||||
feishuCode: meta.feishuCode,
|
||||
feishuMsg: meta.feishuMsg,
|
||||
feishuLogId: meta.feishuLogId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function deliverCommentThreadText(
|
||||
client: Lark.Client,
|
||||
params: {
|
||||
file_token: string;
|
||||
file_type: CommentFileType;
|
||||
comment_id: string;
|
||||
content: string;
|
||||
is_whole_comment?: boolean;
|
||||
},
|
||||
): Promise<
|
||||
| ({ success: true; reply_id?: string } & Record<string, unknown> & {
|
||||
delivery_mode: "reply_comment";
|
||||
})
|
||||
| ({ success: true; comment_id?: string } & Record<string, unknown> & {
|
||||
delivery_mode: "add_comment";
|
||||
})
|
||||
> {
|
||||
let isWholeComment = params.is_whole_comment;
|
||||
if (isWholeComment === undefined) {
|
||||
try {
|
||||
const comment = await queryCommentById(client, params);
|
||||
isWholeComment = comment?.is_whole === true;
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`[feishu_drive] comment metadata preflight failed ` +
|
||||
`comment=${params.comment_id} file_type=${params.file_type} ` +
|
||||
`error=${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
isWholeComment = false;
|
||||
}
|
||||
}
|
||||
if (isWholeComment) {
|
||||
if (params.file_type !== "doc" && params.file_type !== "docx") {
|
||||
throw new Error(
|
||||
`Whole-document comment follow-ups are only supported for doc/docx (got ${params.file_type})`,
|
||||
);
|
||||
}
|
||||
const wholeCommentFileType: "doc" | "docx" = params.file_type;
|
||||
console.info(
|
||||
`[feishu_drive] whole-comment compatibility path ` +
|
||||
`comment=${params.comment_id} file_type=${params.file_type} mode=add_comment`,
|
||||
);
|
||||
return {
|
||||
delivery_mode: "add_comment",
|
||||
...(await addComment(client, {
|
||||
file_token: params.file_token,
|
||||
file_type: wholeCommentFileType,
|
||||
content: params.content,
|
||||
})),
|
||||
};
|
||||
}
|
||||
try {
|
||||
return {
|
||||
delivery_mode: "reply_comment",
|
||||
...(await replyComment(client, params)),
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof FeishuReplyCommentError && isReplyNotAllowedError(error)) {
|
||||
if (params.file_type !== "doc" && params.file_type !== "docx") {
|
||||
throw error;
|
||||
}
|
||||
const fallbackFileType: "doc" | "docx" = params.file_type;
|
||||
console.info(
|
||||
`[feishu_drive] reply-not-allowed compatibility path ` +
|
||||
`comment=${params.comment_id} file_type=${params.file_type} mode=add_comment ` +
|
||||
`log_id=${error.feishuLogId ?? "unknown"}`,
|
||||
);
|
||||
return {
|
||||
delivery_mode: "add_comment",
|
||||
...(await addComment(client, {
|
||||
file_token: params.file_token,
|
||||
file_type: fallbackFileType,
|
||||
content: params.content,
|
||||
})),
|
||||
};
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
throw new Error(lastMessage);
|
||||
}
|
||||
|
||||
// ============ Tool Registration ============
|
||||
@@ -552,14 +851,31 @@ export function registerFeishuDriveTools(api: OpenClawPluginApi) {
|
||||
return jsonToolResult(await moveFile(client, p.file_token, p.type, p.folder_token));
|
||||
case "delete":
|
||||
return jsonToolResult(await deleteFile(client, p.file_token, p.type));
|
||||
case "list_comments":
|
||||
return jsonToolResult(await listComments(client, p));
|
||||
case "list_comment_replies":
|
||||
return jsonToolResult(await listCommentReplies(client, p));
|
||||
case "add_comment":
|
||||
return jsonToolResult(await addComment(client, p));
|
||||
case "reply_comment":
|
||||
return jsonToolResult(await replyComment(client, p));
|
||||
case "list_comments": {
|
||||
const resolved = applyCommentFileTypeDefault(
|
||||
applyAmbientCommentDefaults(p, ctx),
|
||||
"list_comments",
|
||||
);
|
||||
return jsonToolResult(await listComments(client, resolved));
|
||||
}
|
||||
case "list_comment_replies": {
|
||||
const resolved = applyCommentFileTypeDefault(
|
||||
applyAmbientCommentDefaults(p, ctx),
|
||||
"list_comment_replies",
|
||||
);
|
||||
return jsonToolResult(await listCommentReplies(client, resolved));
|
||||
}
|
||||
case "add_comment": {
|
||||
const resolved = applyAddCommentDefaults(applyAddCommentAmbientDefaults(p, ctx));
|
||||
return jsonToolResult(await addComment(client, resolved));
|
||||
}
|
||||
case "reply_comment": {
|
||||
const resolved = applyCommentFileTypeDefault(
|
||||
applyAmbientCommentDefaults(p, ctx),
|
||||
"reply_comment",
|
||||
);
|
||||
return jsonToolResult(await deliverCommentThreadText(client, resolved));
|
||||
}
|
||||
default:
|
||||
return unknownToolActionResult((p as { action?: unknown }).action);
|
||||
}
|
||||
|
||||
@@ -97,11 +97,15 @@ function makeDriveCommentEvent(
|
||||
function makeOpenApiClient(params: {
|
||||
documentTitle?: string;
|
||||
documentUrl?: string;
|
||||
isWholeComment?: boolean;
|
||||
batchCommentId?: string;
|
||||
quoteText?: string;
|
||||
rootReplyText?: string;
|
||||
targetReplyText?: string;
|
||||
includeTargetReplyInBatch?: boolean;
|
||||
repliesSequence?: Array<Array<{ reply_id: string; text: string }>>;
|
||||
}) {
|
||||
const remainingReplyBatches = [...(params.repliesSequence ?? [])];
|
||||
return {
|
||||
request: vi.fn(async (request: { method: "GET" | "POST"; url: string; data: unknown }) => {
|
||||
if (request.url === "/open-apis/drive/v1/metas/batch_query") {
|
||||
@@ -124,7 +128,8 @@ function makeOpenApiClient(params: {
|
||||
data: {
|
||||
items: [
|
||||
{
|
||||
comment_id: "7623358762119646411",
|
||||
comment_id: params.batchCommentId ?? "7623358762119646411",
|
||||
is_whole: params.isWholeComment,
|
||||
quote: params.quoteText ?? "im.message.receive_v1 message trigger implementation",
|
||||
reply_list: {
|
||||
replies: [
|
||||
@@ -169,40 +174,54 @@ function makeOpenApiClient(params: {
|
||||
};
|
||||
}
|
||||
if (request.url.includes("/replies")) {
|
||||
const replyBatch = remainingReplyBatches.shift();
|
||||
const items = replyBatch?.map((reply) => ({
|
||||
reply_id: reply.reply_id,
|
||||
content: {
|
||||
elements: [
|
||||
{
|
||||
type: "text_run",
|
||||
text_run: {
|
||||
content: reply.text,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
})) ?? [
|
||||
{
|
||||
reply_id: "7623358762136374451",
|
||||
content: {
|
||||
elements: [
|
||||
{
|
||||
type: "text_run",
|
||||
text_run: {
|
||||
content:
|
||||
params.rootReplyText ??
|
||||
"Also send it to the agent after receiving the comment event",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
reply_id: "7623359125036043462",
|
||||
content: {
|
||||
elements: [
|
||||
{
|
||||
type: "text_run",
|
||||
text_run: {
|
||||
content: params.targetReplyText ?? "Please follow up on this comment",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
return {
|
||||
code: 0,
|
||||
data: {
|
||||
has_more: false,
|
||||
items: [
|
||||
{
|
||||
reply_id: "7623358762136374451",
|
||||
content: {
|
||||
elements: [
|
||||
{
|
||||
type: "text_run",
|
||||
text_run: {
|
||||
content:
|
||||
params.rootReplyText ??
|
||||
"Also send it to the agent after receiving the comment event",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
reply_id: "7623359125036043462",
|
||||
content: {
|
||||
elements: [
|
||||
{
|
||||
type: "text_run",
|
||||
text_run: {
|
||||
content: params.targetReplyText ?? "Please follow up on this comment",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
items,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -257,11 +276,53 @@ describe("resolveDriveCommentEventTurn", () => {
|
||||
expect(turn?.prompt).toContain(
|
||||
"This is a Feishu document comment-thread event, not a Feishu IM conversation.",
|
||||
);
|
||||
expect(turn?.prompt).toContain("Prefer plain text suitable for a comment thread.");
|
||||
expect(turn?.prompt).toContain("Do not include internal reasoning");
|
||||
expect(turn?.prompt).toContain("Do not narrate your plan or execution process");
|
||||
expect(turn?.prompt).toContain("reply only with the user-facing result itself");
|
||||
expect(turn?.prompt).toContain("comment_id: 7623358762119646411");
|
||||
expect(turn?.prompt).toContain("reply_id: 7623358762136374451");
|
||||
expect(turn?.prompt).toContain("The system will automatically reply with your final answer");
|
||||
});
|
||||
|
||||
it("preserves whole-document comment metadata for downstream delivery mode selection", async () => {
|
||||
const client = makeOpenApiClient({
|
||||
includeTargetReplyInBatch: true,
|
||||
isWholeComment: true,
|
||||
});
|
||||
|
||||
const turn = await resolveDriveCommentEventTurn({
|
||||
cfg: buildMonitorConfig(),
|
||||
accountId: "default",
|
||||
event: makeDriveCommentEvent(),
|
||||
botOpenId: "ou_bot",
|
||||
createClient: () => client as never,
|
||||
});
|
||||
|
||||
expect(turn?.isWholeComment).toBe(true);
|
||||
expect(turn?.prompt).toContain("This is a whole-document comment.");
|
||||
expect(turn?.prompt).toContain("Whole-document comments do not support direct replies.");
|
||||
});
|
||||
|
||||
it("does not trust whole-comment metadata from a mismatched batch_query item", async () => {
|
||||
const client = makeOpenApiClient({
|
||||
includeTargetReplyInBatch: true,
|
||||
isWholeComment: true,
|
||||
batchCommentId: "different_comment_id",
|
||||
});
|
||||
|
||||
const turn = await resolveDriveCommentEventTurn({
|
||||
cfg: buildMonitorConfig(),
|
||||
accountId: "default",
|
||||
event: makeDriveCommentEvent(),
|
||||
botOpenId: "ou_bot",
|
||||
createClient: () => client as never,
|
||||
});
|
||||
|
||||
expect(turn?.isWholeComment).toBeUndefined();
|
||||
expect(turn?.prompt).not.toContain("This is a whole-document comment.");
|
||||
});
|
||||
|
||||
it("preserves sender user_id for downstream allowlist checks", async () => {
|
||||
const client = makeOpenApiClient({ includeTargetReplyInBatch: true });
|
||||
|
||||
@@ -313,6 +374,71 @@ describe("resolveDriveCommentEventTurn", () => {
|
||||
);
|
||||
expect(turn?.prompt).toContain(`file_token: ${TEST_DOC_TOKEN}`);
|
||||
expect(turn?.prompt).toContain("Event type: add_reply");
|
||||
expect(client.request).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
method: "GET",
|
||||
url: expect.stringContaining(
|
||||
`/comments/7623358762119646411/replies?file_type=docx&page_size=100&user_id_type=open_id`,
|
||||
),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("retries comment reply lookup when the requested reply is not immediately visible", async () => {
|
||||
const waitMs = vi.fn(async () => {});
|
||||
const client = makeOpenApiClient({
|
||||
includeTargetReplyInBatch: false,
|
||||
repliesSequence: [
|
||||
[
|
||||
{
|
||||
reply_id: "7623358762136374451",
|
||||
text: "Also send it to the agent after receiving the comment event",
|
||||
},
|
||||
{ reply_id: "7623358762999999999", text: "Earlier assistant summary" },
|
||||
],
|
||||
[
|
||||
{
|
||||
reply_id: "7623358762136374451",
|
||||
text: "Also send it to the agent after receiving the comment event",
|
||||
},
|
||||
{ reply_id: "7623358762999999999", text: "Earlier assistant summary" },
|
||||
],
|
||||
[
|
||||
{
|
||||
reply_id: "7623358762136374451",
|
||||
text: "Also send it to the agent after receiving the comment event",
|
||||
},
|
||||
{ reply_id: "7623359125999999999", text: "Insert a sentence below this paragraph" },
|
||||
],
|
||||
],
|
||||
});
|
||||
|
||||
const turn = await resolveDriveCommentEventTurn({
|
||||
cfg: buildMonitorConfig(),
|
||||
accountId: "default",
|
||||
event: makeDriveCommentEvent({
|
||||
notice_meta: {
|
||||
...makeDriveCommentEvent().notice_meta,
|
||||
notice_type: "add_reply",
|
||||
},
|
||||
reply_id: "7623359125999999999",
|
||||
}),
|
||||
botOpenId: "ou_bot",
|
||||
createClient: () => client as never,
|
||||
waitMs,
|
||||
});
|
||||
|
||||
expect(turn?.targetReplyText).toBe("Insert a sentence below this paragraph");
|
||||
expect(turn?.prompt).toContain("Insert a sentence below this paragraph");
|
||||
expect(waitMs).toHaveBeenCalledTimes(2);
|
||||
expect(waitMs).toHaveBeenNthCalledWith(1, 1000);
|
||||
expect(waitMs).toHaveBeenNthCalledWith(2, 1000);
|
||||
expect(
|
||||
client.request.mock.calls.filter(
|
||||
([request]: [{ method: string; url: string }]) =>
|
||||
request.method === "GET" && request.url.includes("/replies"),
|
||||
),
|
||||
).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("ignores self-authored comment notices", async () => {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user