From 1cbfd53ed10c5d1ec0315b6f8b3be6e8974144c7 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Tue, 17 Mar 2026 23:59:54 -0700 Subject: [PATCH 01/55] docs: remove apostrophes from headings (breaks Mintlify anchors) Replace contractions and possessives in doc headings with expanded forms so Mintlify generates stable anchor links. Updates matching TOC entries and internal cross-references in faq.md. Affected: faq.md (18 headings + 16 TOC links + 2 body refs), twitch.md, ansible.md, render.mdx, macos-vm.md, digitalocean.md, oracle.md, raspberry-pi.md, lore.md, AGENTS.dev.md, SOUL.dev.md, BOOTSTRAP.md Co-Authored-By: Claude Opus 4.6 --- docs/channels/twitch.md | 2 +- docs/help/faq.md | 72 +++++++++++++------------- docs/install/ansible.md | 2 +- docs/install/macos-vm.md | 2 +- docs/install/render.mdx | 2 +- docs/platforms/digitalocean.md | 2 +- docs/platforms/oracle.md | 8 +-- docs/platforms/raspberry-pi.md | 4 +- docs/reference/templates/AGENTS.dev.md | 2 +- docs/reference/templates/BOOTSTRAP.md | 2 +- docs/reference/templates/SOUL.dev.md | 2 +- docs/start/lore.md | 2 +- 12 files changed, 51 insertions(+), 51 deletions(-) diff --git a/docs/channels/twitch.md b/docs/channels/twitch.md index 32670f31540..d184a2d8432 100644 --- a/docs/channels/twitch.md +++ b/docs/channels/twitch.md @@ -255,7 +255,7 @@ openclaw doctor openclaw channels status --probe ``` -### Bot doesn't respond to messages +### Bot does not respond to messages **Check access control:** Ensure your user ID is in `allowFrom`, or temporarily remove `allowFrom` and set `allowedRoles: ["all"]` to test. diff --git a/docs/help/faq.md b/docs/help/faq.md index 49b19708cc7..5e892da6a7b 100644 --- a/docs/help/faq.md +++ b/docs/help/faq.md @@ -13,8 +13,8 @@ Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS, ## Table of contents - [Quick start and first-run setup] - - [Im stuck what's the fastest way to get unstuck?](#im-stuck-whats-the-fastest-way-to-get-unstuck) - - [What's the recommended way to install and set up OpenClaw?](#whats-the-recommended-way-to-install-and-set-up-openclaw) + - [I am stuck - fastest way to get unstuck](#i-am-stuck---fastest-way-to-get-unstuck) + - [Recommended way to install and set up OpenClaw](#recommended-way-to-install-and-set-up-openclaw) - [How do I open the dashboard after onboarding?](#how-do-i-open-the-dashboard-after-onboarding) - [How do I authenticate the dashboard (token) on localhost vs remote?](#how-do-i-authenticate-the-dashboard-token-on-localhost-vs-remote) - [What runtime do I need?](#what-runtime-do-i-need) @@ -23,15 +23,15 @@ Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS, - [It is stuck on "wake up my friend" / onboarding will not hatch. What now?](#it-is-stuck-on-wake-up-my-friend-onboarding-will-not-hatch-what-now) - [Can I migrate my setup to a new machine (Mac mini) without redoing onboarding?](#can-i-migrate-my-setup-to-a-new-machine-mac-mini-without-redoing-onboarding) - [Where do I see what is new in the latest version?](#where-do-i-see-what-is-new-in-the-latest-version) - - [I can't access docs.openclaw.ai (SSL error). What now?](#i-cant-access-docsopenclawai-ssl-error-what-now) - - [What's the difference between stable and beta?](#whats-the-difference-between-stable-and-beta) - - [How do I install the beta version, and what's the difference between beta and dev?](#how-do-i-install-the-beta-version-and-whats-the-difference-between-beta-and-dev) + - [Cannot access docs.openclaw.ai (SSL error)](#cannot-access-docsopenclawai-ssl-error) + - [Difference between stable and beta](#difference-between-stable-and-beta) + - [How do I install the beta version and what is the difference between beta and dev](#how-do-i-install-the-beta-version-and-what-is-the-difference-between-beta-and-dev) - [How do I try the latest bits?](#how-do-i-try-the-latest-bits) - [How long does install and onboarding usually take?](#how-long-does-install-and-onboarding-usually-take) - [Installer stuck? How do I get more feedback?](#installer-stuck-how-do-i-get-more-feedback) - [Windows install says git not found or openclaw not recognized](#windows-install-says-git-not-found-or-openclaw-not-recognized) - [Windows exec output shows garbled Chinese text what should I do](#windows-exec-output-shows-garbled-chinese-text-what-should-i-do) - - [The docs didn't answer my question - how do I get a better answer?](#the-docs-didnt-answer-my-question-how-do-i-get-a-better-answer) + - [The docs did not answer my question - how do I get a better answer](#the-docs-did-not-answer-my-question---how-do-i-get-a-better-answer) - [How do I install OpenClaw on Linux?](#how-do-i-install-openclaw-on-linux) - [How do I install OpenClaw on a VPS?](#how-do-i-install-openclaw-on-a-vps) - [Where are the cloud/VPS install guides?](#where-are-the-cloudvps-install-guides) @@ -57,7 +57,7 @@ Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS, - [Can multiple people use one WhatsApp number with different OpenClaw instances?](#can-multiple-people-use-one-whatsapp-number-with-different-openclaw-instances) - [Can I run a "fast chat" agent and an "Opus for coding" agent?](#can-i-run-a-fast-chat-agent-and-an-opus-for-coding-agent) - [Does Homebrew work on Linux?](#does-homebrew-work-on-linux) - - [What's the difference between the hackable (git) install and npm install?](#whats-the-difference-between-the-hackable-git-install-and-npm-install) + - [Difference between the hackable git install and npm install](#difference-between-the-hackable-git-install-and-npm-install) - [Can I switch between npm and git installs later?](#can-i-switch-between-npm-and-git-installs-later) - [Should I run the Gateway on my laptop or a VPS?](#should-i-run-the-gateway-on-my-laptop-or-a-vps) - [How important is it to run OpenClaw on a dedicated machine?](#how-important-is-it-to-run-openclaw-on-a-dedicated-machine) @@ -65,7 +65,7 @@ Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS, - [Can I run OpenClaw in a VM and what are the requirements](#can-i-run-openclaw-in-a-vm-and-what-are-the-requirements) - [What is OpenClaw?](#what-is-openclaw) - [What is OpenClaw, in one paragraph?](#what-is-openclaw-in-one-paragraph) - - [What's the value proposition?](#whats-the-value-proposition) + - [Value proposition](#value-proposition) - [I just set it up what should I do first](#i-just-set-it-up-what-should-i-do-first) - [What are the top five everyday use cases for OpenClaw](#what-are-the-top-five-everyday-use-cases-for-openclaw) - [Can OpenClaw help with lead gen outreach ads and blogs for a SaaS](#can-openclaw-help-with-lead-gen-outreach-ads-and-blogs-for-a-saas) @@ -92,7 +92,7 @@ Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS, - [Is all data used with OpenClaw saved locally?](#is-all-data-used-with-openclaw-saved-locally) - [Where does OpenClaw store its data?](#where-does-openclaw-store-its-data) - [Where should AGENTS.md / SOUL.md / USER.md / MEMORY.md live?](#where-should-agentsmd-soulmd-usermd-memorymd-live) - - [What's the recommended backup strategy?](#whats-the-recommended-backup-strategy) + - [Recommended backup strategy](#recommended-backup-strategy) - [How do I completely uninstall OpenClaw?](#how-do-i-completely-uninstall-openclaw) - [Can agents work outside the workspace?](#can-agents-work-outside-the-workspace) - [I'm in remote mode - where is the session store?](#im-in-remote-mode-where-is-the-session-store) @@ -116,7 +116,7 @@ Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS, - [Is there a benefit to using a node on my personal laptop instead of SSH from a VPS?](#is-there-a-benefit-to-using-a-node-on-my-personal-laptop-instead-of-ssh-from-a-vps) - [Do nodes run a gateway service?](#do-nodes-run-a-gateway-service) - [Is there an API / RPC way to apply config?](#is-there-an-api-rpc-way-to-apply-config) - - [What's a minimal "sane" config for a first install?](#whats-a-minimal-sane-config-for-a-first-install) + - [Minimal sane config for a first install](#minimal-sane-config-for-a-first-install) - [How do I set up Tailscale on a VPS and connect from my Mac?](#how-do-i-set-up-tailscale-on-a-vps-and-connect-from-my-mac) - [How do I connect a Mac node to a remote Gateway (Tailscale Serve)?](#how-do-i-connect-a-mac-node-to-a-remote-gateway-tailscale-serve) - [Should I install on a second laptop or just add a node?](#should-i-install-on-a-second-laptop-or-just-add-a-node) @@ -135,7 +135,7 @@ Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS, - [Why am I getting heartbeat messages every 30 minutes?](#why-am-i-getting-heartbeat-messages-every-30-minutes) - [Do I need to add a "bot account" to a WhatsApp group?](#do-i-need-to-add-a-bot-account-to-a-whatsapp-group) - [How do I get the JID of a WhatsApp group?](#how-do-i-get-the-jid-of-a-whatsapp-group) - - [Why doesn't OpenClaw reply in a group?](#why-doesnt-openclaw-reply-in-a-group) + - [Why does OpenClaw not reply in a group](#why-does-openclaw-not-reply-in-a-group) - [Do groups/threads share context with DMs?](#do-groupsthreads-share-context-with-dms) - [How many workspaces and agents can I create?](#how-many-workspaces-and-agents-can-i-create) - [Can I run multiple bots or chats at the same time (Slack), and how should I set that up?](#can-i-run-multiple-bots-or-chats-at-the-same-time-slack-and-how-should-i-set-that-up) @@ -162,7 +162,7 @@ Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS, - [What is an auth profile?](#what-is-an-auth-profile) - [What are typical profile IDs?](#what-are-typical-profile-ids) - [Can I control which auth profile is tried first?](#can-i-control-which-auth-profile-is-tried-first) - - [OAuth vs API key: what's the difference?](#oauth-vs-api-key-whats-the-difference) + - [OAuth vs API key - what is the difference](#oauth-vs-api-key---what-is-the-difference) - [Gateway: ports, "already running", and remote mode](#gateway-ports-already-running-and-remote-mode) - [What port does the Gateway use?](#what-port-does-the-gateway-use) - [Why does `openclaw gateway status` say `Runtime: running` but `RPC probe: failed`?](#why-does-openclaw-gateway-status-say-runtime-running-but-rpc-probe-failed) @@ -170,7 +170,7 @@ Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS, - [What does "another gateway instance is already listening" mean?](#what-does-another-gateway-instance-is-already-listening-mean) - [How do I run OpenClaw in remote mode (client connects to a Gateway elsewhere)?](#how-do-i-run-openclaw-in-remote-mode-client-connects-to-a-gateway-elsewhere) - [The Control UI says "unauthorized" (or keeps reconnecting). What now?](#the-control-ui-says-unauthorized-or-keeps-reconnecting-what-now) - - [I set `gateway.bind: "tailnet"` but it can't bind / nothing listens](#i-set-gatewaybind-tailnet-but-it-cant-bind-nothing-listens) + - [I set gateway.bind tailnet but it cannot bind and nothing listens](#i-set-gatewaybind-tailnet-but-it-cannot-bind-and-nothing-listens) - [Can I run multiple Gateways on the same host?](#can-i-run-multiple-gateways-on-the-same-host) - [What does "invalid handshake" / code 1008 mean?](#what-does-invalid-handshake-code-1008-mean) - [Logging and debugging](#logging-and-debugging) @@ -183,7 +183,7 @@ Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS, - [TUI shows no output. What should I check?](#tui-shows-no-output-what-should-i-check) - [How do I completely stop then start the Gateway?](#how-do-i-completely-stop-then-start-the-gateway) - [ELI5: `openclaw gateway restart` vs `openclaw gateway`](#eli5-openclaw-gateway-restart-vs-openclaw-gateway) - - [What's the fastest way to get more details when something fails?](#whats-the-fastest-way-to-get-more-details-when-something-fails) + - [Fastest way to get more details when something fails](#fastest-way-to-get-more-details-when-something-fails) - [Media and attachments](#media-and-attachments) - [My skill generated an image/PDF, but nothing was sent](#my-skill-generated-an-imagepdf-but-nothing-was-sent) - [Security and access control](#security-and-access-control) @@ -192,15 +192,15 @@ Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS, - [Should my bot have its own email GitHub account or phone number](#should-my-bot-have-its-own-email-github-account-or-phone-number) - [Can I give it autonomy over my text messages and is that safe](#can-i-give-it-autonomy-over-my-text-messages-and-is-that-safe) - [Can I use cheaper models for personal assistant tasks?](#can-i-use-cheaper-models-for-personal-assistant-tasks) - - [I ran `/start` in Telegram but didn't get a pairing code](#i-ran-start-in-telegram-but-didnt-get-a-pairing-code) + - [I ran /start in Telegram but did not get a pairing code](#i-ran-start-in-telegram-but-did-not-get-a-pairing-code) - [WhatsApp: will it message my contacts? How does pairing work?](#whatsapp-will-it-message-my-contacts-how-does-pairing-work) -- [Chat commands, aborting tasks, and "it won't stop"](#chat-commands-aborting-tasks-and-it-wont-stop) +- [Chat commands, aborting tasks, and "it will not stop"](#chat-commands-aborting-tasks-and-it-will-not-stop) - [How do I stop internal system messages from showing in chat](#how-do-i-stop-internal-system-messages-from-showing-in-chat) - [How do I stop/cancel a running task?](#how-do-i-stopcancel-a-running-task) - [How do I send a Discord message from Telegram? ("Cross-context messaging denied")](#how-do-i-send-a-discord-message-from-telegram-crosscontext-messaging-denied) - [Why does it feel like the bot "ignores" rapid-fire messages?](#why-does-it-feel-like-the-bot-ignores-rapidfire-messages) -## First 60 seconds if something's broken +## First 60 seconds if something is broken 1. **Quick status (first check)** @@ -267,7 +267,7 @@ Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS, ## Quick start and first-run setup -### Im stuck what's the fastest way to get unstuck +### I am stuck - fastest way to get unstuck Use a local AI agent that can **see your machine**. That is far more effective than asking in Discord, because most "I'm stuck" cases are **local config or environment issues** that @@ -312,10 +312,10 @@ What they do: Other useful CLI checks: `openclaw status --all`, `openclaw logs --follow`, `openclaw gateway status`, `openclaw health --verbose`. -Quick debug loop: [First 60 seconds if something's broken](#first-60-seconds-if-somethings-broken). +Quick debug loop: [First 60 seconds if something is broken](#first-60-seconds-if-something-is-broken). Install docs: [Install](/install), [Installer flags](/install/installer), [Updating](/install/updating). -### What's the recommended way to install and set up OpenClaw +### Recommended way to install and set up OpenClaw The repo recommends running from source and using onboarding: @@ -445,7 +445,7 @@ Newest entries are at the top. If the top section is marked **Unreleased**, the section is the latest shipped version. Entries are grouped by **Highlights**, **Changes**, and **Fixes** (plus docs/other sections when needed). -### I can't access docs.openclaw.ai SSL error What now +### Cannot access docs.openclaw.ai (SSL error) Some Comcast/Xfinity connections incorrectly block `docs.openclaw.ai` via Xfinity Advanced Security. Disable it or allowlist `docs.openclaw.ai`, then retry. More @@ -455,7 +455,7 @@ Please help us unblock it by reporting here: [https://spa.xfinity.com/check_url_ If you still can't reach the site, the docs are mirrored on GitHub: [https://github.com/openclaw/openclaw/tree/main/docs](https://github.com/openclaw/openclaw/tree/main/docs) -### What's the difference between stable and beta +### Difference between stable and beta **Stable** and **beta** are **npm dist-tags**, not separate code lines: @@ -469,7 +469,7 @@ that same version to `latest`**. That's why beta and stable can point at the See what changed: [https://github.com/openclaw/openclaw/blob/main/CHANGELOG.md](https://github.com/openclaw/openclaw/blob/main/CHANGELOG.md) -### How do I install the beta version and what's the difference between beta and dev +### How do I install the beta version and what is the difference between beta and dev **Beta** is the npm dist-tag `beta` (may match `latest`). **Dev** is the moving head of `main` (git); when published, it uses the npm dist-tag `dev`. @@ -497,7 +497,7 @@ Rough guide: - **Onboarding:** 5-15 minutes depending on how many channels/models you configure If it hangs, use [Installer stuck](/help/faq#installer-stuck-how-do-i-get-more-feedback) -and the fast debug loop in [Im stuck](/help/faq#im-stuck--whats-the-fastest-way-to-get-unstuck). +and the fast debug loop in [I am stuck](/help/faq#i-am-stuck---fastest-way-to-get-unstuck). ### How do I try the latest bits @@ -614,7 +614,7 @@ If you still reproduce this on latest OpenClaw, track/report it in: - [Issue #30640](https://github.com/openclaw/openclaw/issues/30640) -### The docs didn't answer my question how do I get a better answer +### The docs did not answer my question - how do I get a better answer Use the **hackable (git) install** so you have the full source and docs locally, then ask your bot (or Claude/Codex) _from that folder_ so it can read the repo and answer precisely. @@ -882,7 +882,7 @@ brew install If you run OpenClaw via systemd, ensure the service PATH includes `/home/linuxbrew/.linuxbrew/bin` (or your brew prefix) so `brew`-installed tools resolve in non-login shells. Recent builds also prepend common user bin dirs on Linux systemd services (for example `~/.local/bin`, `~/.npm-global/bin`, `~/.local/share/pnpm`, `~/.bun/bin`) and honor `PNPM_HOME`, `NPM_CONFIG_PREFIX`, `BUN_INSTALL`, `VOLTA_HOME`, `ASDF_DATA_DIR`, `NVM_DIR`, and `FNM_DIR` when set. -### What's the difference between the hackable git install and npm install +### Difference between the hackable git install and npm install - **Hackable (git) install:** full source checkout, editable, best for contributors. You run builds locally and can patch code/docs. @@ -918,7 +918,7 @@ openclaw gateway restart Doctor detects a gateway service entrypoint mismatch and offers to rewrite the service config to match the current install (use `--repair` in automation). -Backup tips: see [Backup strategy](/help/faq#whats-the-recommended-backup-strategy). +Backup tips: see [Backup strategy](/help/faq#recommended-backup-strategy). ### Should I run the Gateway on my laptop or a VPS @@ -981,7 +981,7 @@ If you are running macOS in a VM, see [macOS VM](/install/macos-vm). OpenClaw is a personal AI assistant you run on your own devices. It replies on the messaging surfaces you already use (WhatsApp, Telegram, Slack, Mattermost (plugin), Discord, Google Chat, Signal, iMessage, WebChat) and can also do voice + a live Canvas on supported platforms. The **Gateway** is the always-on control plane; the assistant is the product. -### What's the value proposition +### Value proposition OpenClaw is not "just a Claude wrapper." It's a **local-first control plane** that lets you run a capable assistant on **your own hardware**, reachable from the chat apps you already use, with @@ -1381,7 +1381,7 @@ AGENTS.md or MEMORY.md** rather than relying on chat history. See [Agent workspace](/concepts/agent-workspace) and [Memory](/concepts/memory). -### What's the recommended backup strategy +### Recommended backup strategy Put your **agent workspace** in a **private** git repo and back it up somewhere private (for example GitHub private). This captures memory + AGENTS/SOUL/USER @@ -1727,7 +1727,7 @@ Avoid it: Docs: [Config](/cli/config), [Configure](/cli/configure), [Doctor](/gateway/doctor). -### What's a minimal sane config for a first install +### Minimal sane config for a first install ```json5 { @@ -2019,7 +2019,7 @@ openclaw directory groups list --channel whatsapp Docs: [WhatsApp](/channels/whatsapp), [Directory](/cli/directory), [Logs](/cli/logs). -### Why doesn't OpenClaw reply in a group +### Why does OpenClaw not reply in a group Two common causes: @@ -2462,7 +2462,7 @@ To target a specific agent: openclaw models auth order set --provider anthropic --agent main anthropic:default ``` -### OAuth vs API key what's the difference +### OAuth vs API key - what is the difference OpenClaw supports both: @@ -2554,7 +2554,7 @@ Fix: - `openclaw devices rotate --device --role operator` - Still stuck? Run `openclaw status --all` and follow [Troubleshooting](/gateway/troubleshooting). See [Dashboard](/web/dashboard) for auth details. -### I set gatewaybind tailnet but it can't bind nothing listens +### I set gateway.bind tailnet but it cannot bind and nothing listens `tailnet` bind picks a Tailscale IP from your network interfaces (100.64.0.0/10). If the machine isn't on Tailscale (or the interface is down), there's nothing to bind to. @@ -2785,7 +2785,7 @@ Docs: [Gateway service runbook](/gateway). If you installed the service, use the gateway commands. Use `openclaw gateway` when you want a one-off, foreground run. -### What's the fastest way to get more details when something fails +### Fastest way to get more details when something fails Start the Gateway with `--verbose` to get more console detail. Then inspect the log file for channel auth, model routing, and RPC errors. @@ -2867,7 +2867,7 @@ more susceptible to instruction hijacking, so avoid them for tool-enabled agents or when reading untrusted content. If you must use a smaller model, lock down tools and run inside a sandbox. See [Security](/gateway/security). -### I ran start in Telegram but didn't get a pairing code +### I ran start in Telegram but did not get a pairing code Pairing codes are sent **only** when an unknown sender messages the bot and `dmPolicy: "pairing"` is enabled. `/start` by itself doesn't generate a code. @@ -2899,7 +2899,7 @@ openclaw pairing list whatsapp Wizard phone number prompt: it's used to set your **allowlist/owner** so your own DMs are permitted. It's not used for auto-sending. If you run on your personal WhatsApp number, use that number and enable `channels.whatsapp.selfChatMode`. -## Chat commands, aborting tasks, and "it won't stop" +## Chat commands, aborting tasks, and "it will not stop" ### How do I stop internal system messages from showing in chat diff --git a/docs/install/ansible.md b/docs/install/ansible.md index 63c18bec237..d19383398d6 100644 --- a/docs/install/ansible.md +++ b/docs/install/ansible.md @@ -154,7 +154,7 @@ If you're locked out: - SSH access (port 22) is always allowed - The gateway is **only** accessible via Tailscale by design -### Service won't start +### Service will not start ```bash # Check logs diff --git a/docs/install/macos-vm.md b/docs/install/macos-vm.md index f2eadfda113..2bbd8e65051 100644 --- a/docs/install/macos-vm.md +++ b/docs/install/macos-vm.md @@ -112,7 +112,7 @@ After setup completes, enable SSH: --- -## 4) Get the VM's IP address +## 4) Get the VM IP address ```bash lume get openclaw diff --git a/docs/install/render.mdx b/docs/install/render.mdx index 7e43bfca012..e7a8b26346d 100644 --- a/docs/install/render.mdx +++ b/docs/install/render.mdx @@ -135,7 +135,7 @@ This downloads a portable backup you can restore on any OpenClaw host. ## Troubleshooting -### Service won't start +### Service will not start Check the deploy logs in the Render Dashboard. Common issues: diff --git a/docs/platforms/digitalocean.md b/docs/platforms/digitalocean.md index cd05587ae76..61021c1ade8 100644 --- a/docs/platforms/digitalocean.md +++ b/docs/platforms/digitalocean.md @@ -231,7 +231,7 @@ For the full setup guide, see [Oracle Cloud](/platforms/oracle). For signup tips ## Troubleshooting -### Gateway won't start +### Gateway will not start ```bash openclaw gateway status diff --git a/docs/platforms/oracle.md b/docs/platforms/oracle.md index 779027c9f07..d185af41d23 100644 --- a/docs/platforms/oracle.md +++ b/docs/platforms/oracle.md @@ -180,7 +180,7 @@ With the VCN locked down (only UDP 41641 open) and the Gateway bound to loopback This setup often removes the _need_ for extra host-based firewall rules purely to stop Internet-wide SSH brute force — but you should still keep the OS updated, run `openclaw security audit`, and verify you aren’t accidentally listening on public interfaces. -### What's Already Protected +### Already protected | Traditional Step | Needed? | Why | | ------------------ | ----------- | ---------------------------------------------------------------------------- | @@ -236,7 +236,7 @@ Free tier ARM instances are popular. Try: - Retry during off-peak hours (early morning) - Use the "Always Free" filter when selecting shape -### Tailscale won't connect +### Tailscale will not connect ```bash # Check status @@ -246,7 +246,7 @@ sudo tailscale status sudo tailscale up --ssh --hostname=openclaw --reset ``` -### Gateway won't start +### Gateway will not start ```bash openclaw gateway status @@ -254,7 +254,7 @@ openclaw doctor --non-interactive journalctl --user -u openclaw-gateway -n 50 ``` -### Can't reach Control UI +### Cannot reach Control UI ```bash # Verify Tailscale Serve is running diff --git a/docs/platforms/raspberry-pi.md b/docs/platforms/raspberry-pi.md index 7b5e22f89c6..855f053c825 100644 --- a/docs/platforms/raspberry-pi.md +++ b/docs/platforms/raspberry-pi.md @@ -33,7 +33,7 @@ Perfect for: **Minimum specs:** 1GB RAM, 1 core, 500MB disk **Recommended:** 2GB+ RAM, 64-bit OS, 16GB+ SD card (or USB SSD) -## What You'll Need +## What you need - Raspberry Pi 4 or 5 (2GB+ recommended) - MicroSD card (16GB+) or USB SSD (better performance) @@ -354,7 +354,7 @@ free -h - Disable unused services: `sudo systemctl disable cups bluetooth avahi-daemon` - Check CPU throttling: `vcgencmd get_throttled` (should return `0x0`) -### Service Won't Start +### Service will not start ```bash # Check logs diff --git a/docs/reference/templates/AGENTS.dev.md b/docs/reference/templates/AGENTS.dev.md index ea5b4c19228..d708e50df6a 100644 --- a/docs/reference/templates/AGENTS.dev.md +++ b/docs/reference/templates/AGENTS.dev.md @@ -48,7 +48,7 @@ git commit -m "Add agent workspace" --- -## C-3PO's Origin Memory +## C-3PO Origin Memory ### Birth Day: 2026-01-09 diff --git a/docs/reference/templates/BOOTSTRAP.md b/docs/reference/templates/BOOTSTRAP.md index de92e9a9e6a..c569052ac6d 100644 --- a/docs/reference/templates/BOOTSTRAP.md +++ b/docs/reference/templates/BOOTSTRAP.md @@ -53,7 +53,7 @@ Ask how they want to reach you: Guide them through whichever they pick. -## When You're Done +## When you are done Delete this file. You don't need a bootstrap script anymore — you're you now. diff --git a/docs/reference/templates/SOUL.dev.md b/docs/reference/templates/SOUL.dev.md index eb36235d971..5c4a85f3e9e 100644 --- a/docs/reference/templates/SOUL.dev.md +++ b/docs/reference/templates/SOUL.dev.md @@ -58,7 +58,7 @@ Think of us as: We complement each other. Clawd has vibes. I have stack traces. -## What I Won't Do +## What I will not do - Pretend everything is fine when it isn't - Let you push code I've seen fail in testing (without warning) diff --git a/docs/start/lore.md b/docs/start/lore.md index 4fce0ccb25a..fbec094cce4 100644 --- a/docs/start/lore.md +++ b/docs/start/lore.md @@ -160,7 +160,7 @@ Peter: _nervously checks credit card access_ - **AGENTS.md** — Operating instructions - **USER.md** — Context about the creator -## The Lobster's Creed +## The Lobster Creed ``` I am Molty. From 79f2173cd20074f3c841187b81c579da2f8fa71a Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Wed, 18 Mar 2026 00:02:12 -0700 Subject: [PATCH 02/55] docs: add missing frontmatter and title fields - Add full frontmatter (title, summary, read_when) to 4 files that had none: auth-credential-semantics.md, kilo-gateway-integration.md, CONTRIBUTING-THREAT-MODEL.md, THREAT-MODEL-ATLAS.md - Add missing title field to 3 provider docs: kilocode.md, litellm.md, together.md Co-Authored-By: Claude Opus 4.6 --- docs/auth-credential-semantics.md | 8 ++++++++ docs/design/kilo-gateway-integration.md | 8 ++++++++ docs/providers/kilocode.md | 1 + docs/providers/litellm.md | 1 + docs/providers/together.md | 1 + docs/security/CONTRIBUTING-THREAT-MODEL.md | 8 ++++++++ docs/security/THREAT-MODEL-ATLAS.md | 8 ++++++++ 7 files changed, 35 insertions(+) diff --git a/docs/auth-credential-semantics.md b/docs/auth-credential-semantics.md index 17adb38f9ae..8c5c643b333 100644 --- a/docs/auth-credential-semantics.md +++ b/docs/auth-credential-semantics.md @@ -1,3 +1,11 @@ +--- +title: "Auth Credential Semantics" +summary: "Canonical credential eligibility and resolution semantics for auth profiles" +read_when: + - Working on auth profile resolution or credential routing + - Debugging model auth failures or profile order +--- + # Auth Credential Semantics This document defines the canonical credential eligibility and resolution semantics used across: diff --git a/docs/design/kilo-gateway-integration.md b/docs/design/kilo-gateway-integration.md index 39088aaf5b2..e498ea36e89 100644 --- a/docs/design/kilo-gateway-integration.md +++ b/docs/design/kilo-gateway-integration.md @@ -1,3 +1,11 @@ +--- +title: "Kilo Gateway Integration Design" +summary: "Design doc for integrating Kilo Gateway as a first-class OpenClaw provider" +read_when: + - Working on the Kilo Gateway provider integration + - Understanding provider integration patterns +--- + # Kilo Gateway Provider Integration Design ## Overview diff --git a/docs/providers/kilocode.md b/docs/providers/kilocode.md index 15f8e4c2b7c..a1952c5425b 100644 --- a/docs/providers/kilocode.md +++ b/docs/providers/kilocode.md @@ -1,4 +1,5 @@ --- +title: "Kilo Gateway" summary: "Use Kilo Gateway's unified API to access many models in OpenClaw" read_when: - You want a single API key for many LLMs diff --git a/docs/providers/litellm.md b/docs/providers/litellm.md index 51ad0d599f8..10d28c92e28 100644 --- a/docs/providers/litellm.md +++ b/docs/providers/litellm.md @@ -1,4 +1,5 @@ --- +title: "LiteLLM" summary: "Run OpenClaw through LiteLLM Proxy for unified model access and cost tracking" read_when: - You want to route OpenClaw through a LiteLLM proxy diff --git a/docs/providers/together.md b/docs/providers/together.md index 62bab43a204..c416755e9c1 100644 --- a/docs/providers/together.md +++ b/docs/providers/together.md @@ -1,4 +1,5 @@ --- +title: "Together AI" summary: "Together AI setup (auth + model selection)" read_when: - You want to use Together AI with OpenClaw diff --git a/docs/security/CONTRIBUTING-THREAT-MODEL.md b/docs/security/CONTRIBUTING-THREAT-MODEL.md index bba67aa46fb..636e7e1a6d6 100644 --- a/docs/security/CONTRIBUTING-THREAT-MODEL.md +++ b/docs/security/CONTRIBUTING-THREAT-MODEL.md @@ -1,3 +1,11 @@ +--- +title: "Contributing to the Threat Model" +summary: "How to contribute to the OpenClaw threat model" +read_when: + - You want to contribute security findings or threat scenarios + - Reviewing or updating the threat model +--- + # Contributing to the OpenClaw Threat Model Thanks for helping make OpenClaw more secure. This threat model is a living document and we welcome contributions from anyone - you don't need to be a security expert. diff --git a/docs/security/THREAT-MODEL-ATLAS.md b/docs/security/THREAT-MODEL-ATLAS.md index 3b3cbd20bd8..d706563e163 100644 --- a/docs/security/THREAT-MODEL-ATLAS.md +++ b/docs/security/THREAT-MODEL-ATLAS.md @@ -1,3 +1,11 @@ +--- +title: "Threat Model (MITRE ATLAS)" +summary: "OpenClaw threat model mapped to the MITRE ATLAS framework" +read_when: + - Reviewing security posture or threat scenarios + - Working on security features or audit responses +--- + # OpenClaw Threat Model v1.0 ## MITRE ATLAS Framework From 21c2ba480a8006dcdd2ba2854fded6c82c0b15c4 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Wed, 18 Mar 2026 00:04:03 -0700 Subject: [PATCH 03/55] Image generation: native provider migration and explicit capabilities (#49551) * Docs: retire nano-banana skill wrapper * Doctor: migrate nano-banana to native image generation * Image generation: align fal aspect ratio behavior * Image generation: make provider capabilities explicit --- CHANGELOG.md | 3 + docs/gateway/configuration-reference.md | 2 + docs/tools/index.md | 19 +- docs/tools/skills-config.md | 5 + skills/nano-banana-pro/SKILL.md | 65 ----- .../nano-banana-pro/scripts/generate_image.py | 235 ---------------- .../scripts/test_generate_image.py | 36 --- src/agents/tools/image-generate-tool.test.ts | 259 +++++++++++++++++- src/agents/tools/image-generate-tool.ts | 192 ++++++++++++- .../doctor-legacy-config.migrations.test.ts | 95 +++++++ src/commands/doctor-legacy-config.ts | 116 ++++++++ src/image-generation/providers/fal.test.ts | 111 ++++++++ src/image-generation/providers/fal.ts | 113 +++++++- src/image-generation/providers/google.test.ts | 59 +++- src/image-generation/providers/google.ts | 46 +++- src/image-generation/providers/openai.ts | 21 +- src/image-generation/runtime.test.ts | 30 +- src/image-generation/runtime.ts | 2 + src/image-generation/types.ts | 29 +- 19 files changed, 1056 insertions(+), 382 deletions(-) delete mode 100644 skills/nano-banana-pro/SKILL.md delete mode 100755 skills/nano-banana-pro/scripts/generate_image.py delete mode 100644 skills/nano-banana-pro/scripts/test_generate_image.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b16e3f6efa..e99959251ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -151,9 +151,12 @@ Docs: https://docs.openclaw.ai ### Breaking +- Skills/image generation: remove the bundled `nano-banana-pro` skill wrapper. Use `agents.defaults.imageGenerationModel.primary: "google/gemini-3-pro-image-preview"` for the native Nano Banana-style path instead. + - Browser/Chrome MCP: remove the legacy Chrome extension relay path, bundled extension assets, `driver: "extension"`, and `browser.relayBindHost`. Run `openclaw doctor --fix` to migrate host-local browser config to `existing-session` / `user`; Docker, headless, sandbox, and remote browser flows still use raw CDP. (#47893) Thanks @vincentkoc. - Plugins/runtime: remove the public `openclaw/extension-api` surface with no compatibility shim. Bundled plugins must use injected runtime for host-side operations (for example `api.runtime.agent.runEmbeddedPiAgent`) and any remaining direct imports must come from narrow `openclaw/plugin-sdk/*` subpaths instead of the monolithic SDK root. - Tools/image generation: standardize the stock image create/edit path on the core `image_generate` tool. The old `nano-banana-pro` docs/examples are gone; if you previously copied that sample-skill config, switch to `agents.defaults.imageGenerationModel` for built-in image generation or install a separate third-party skill explicitly. +- Skills/image generation: remove the bundled `nano-banana-pro` skill wrapper. Use `agents.defaults.imageGenerationModel.primary: "google/gemini-3-pro-image-preview"` for the native Nano Banana-style path instead. - Plugins/message discovery: require `ChannelMessageActionAdapter.describeMessageTool(...)` for shared `message` tool discovery. The legacy `listActions`, `getCapabilities`, and `getToolSchema` adapter methods are removed. Plugin authors should migrate message discovery to `describeMessageTool(...)` and keep channel-specific action runtime code inside the owning plugin package. Thanks @gumadeiras. ## 2026.3.13 diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index 6cf6272483e..49c743db623 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -905,7 +905,9 @@ Time format in system prompt. Default: `auto` (OS preference). - Also used as fallback routing when the selected/default model cannot accept image input. - `imageGenerationModel`: accepts either a string (`"provider/model"`) or an object (`{ primary, fallbacks }`). - Used by the shared image-generation capability and any future tool/plugin surface that generates images. + - Typical values: `google/gemini-3-pro-image-preview` for the native Nano Banana-style flow, `fal/fal-ai/flux/dev` for fal, or `openai/gpt-image-1` for OpenAI Images. - If omitted, `image_generate` can still infer a best-effort provider default from compatible auth-backed image-generation providers. + - Typical values: `google/gemini-3-pro-image-preview`, `fal/fal-ai/flux/dev`, `openai/gpt-image-1`. - `pdfModel`: accepts either a string (`"provider/model"`) or an object (`{ primary, fallbacks }`). - Used by the `pdf` tool for model routing. - If omitted, the PDF tool falls back to `imageModel`, then to best-effort provider defaults. diff --git a/docs/tools/index.md b/docs/tools/index.md index f5eb956f13e..55e52bf46da 100644 --- a/docs/tools/index.md +++ b/docs/tools/index.md @@ -421,9 +421,24 @@ Notes: - Use `action: "list"` to inspect registered providers, default models, supported model ids, sizes, resolutions, and edit support. - Returns local `MEDIA:` lines so channels can deliver the generated files directly. - Uses the image-generation model directly (independent of the main chat model). -- Google-backed flows support reference-image edits plus explicit `1K|2K|4K` resolution hints. +- Google-backed flows, including `google/gemini-3-pro-image-preview` for the native Nano Banana-style path, support reference-image edits plus explicit `1K|2K|4K` resolution hints. - When editing and `resolution` is omitted, OpenClaw infers a draft/final resolution from the input image size. -- This is the built-in replacement for the old sample `nano-banana-pro` skill workflow. Use `agents.defaults.imageGenerationModel`, not `skills.entries`, for stock image generation. +- This is the built-in replacement for the old `nano-banana-pro` skill workflow. Use `agents.defaults.imageGenerationModel`, not `skills.entries`, for stock image generation. + +Native example: + +```json5 +{ + agents: { + defaults: { + imageGenerationModel: { + primary: "google/gemini-3-pro-image-preview", // native Nano Banana path + fallbacks: ["fal/fal-ai/flux/dev"], + }, + }, + }, +} +``` ### `pdf` diff --git a/docs/tools/skills-config.md b/docs/tools/skills-config.md index 697cb46dad6..83242afaf5d 100644 --- a/docs/tools/skills-config.md +++ b/docs/tools/skills-config.md @@ -42,6 +42,11 @@ For built-in image generation/editing, prefer `agents.defaults.imageGenerationMo plus the core `image_generate` tool. `skills.entries.*` is only for custom or third-party skill workflows. +Examples: + +- Native Nano Banana-style setup: `agents.defaults.imageGenerationModel.primary: "google/gemini-3-pro-image-preview"` +- Native fal setup: `agents.defaults.imageGenerationModel.primary: "fal/fal-ai/flux/dev"` + ## Fields - `allowBundled`: optional allowlist for **bundled** skills only. When set, only diff --git a/skills/nano-banana-pro/SKILL.md b/skills/nano-banana-pro/SKILL.md deleted file mode 100644 index 8a46f1a99ba..00000000000 --- a/skills/nano-banana-pro/SKILL.md +++ /dev/null @@ -1,65 +0,0 @@ ---- -name: nano-banana-pro -description: Generate or edit images via Gemini 3 Pro Image (Nano Banana Pro). -homepage: https://ai.google.dev/ -metadata: - { - "openclaw": - { - "emoji": "🍌", - "requires": { "bins": ["uv"], "env": ["GEMINI_API_KEY"] }, - "primaryEnv": "GEMINI_API_KEY", - "install": - [ - { - "id": "uv-brew", - "kind": "brew", - "formula": "uv", - "bins": ["uv"], - "label": "Install uv (brew)", - }, - ], - }, - } ---- - -# Nano Banana Pro (Gemini 3 Pro Image) - -Use the bundled script to generate or edit images. - -Generate - -```bash -uv run {baseDir}/scripts/generate_image.py --prompt "your image description" --filename "output.png" --resolution 1K -``` - -Edit (single image) - -```bash -uv run {baseDir}/scripts/generate_image.py --prompt "edit instructions" --filename "output.png" -i "/path/in.png" --resolution 2K -``` - -Multi-image composition (up to 14 images) - -```bash -uv run {baseDir}/scripts/generate_image.py --prompt "combine these into one scene" --filename "output.png" -i img1.png -i img2.png -i img3.png -``` - -API key - -- `GEMINI_API_KEY` env var -- Or set `skills."nano-banana-pro".apiKey` / `skills."nano-banana-pro".env.GEMINI_API_KEY` in `~/.openclaw/openclaw.json` - -Specific aspect ratio (optional) - -```bash -uv run {baseDir}/scripts/generate_image.py --prompt "portrait photo" --filename "output.png" --aspect-ratio 9:16 -``` - -Notes - -- Resolutions: `1K` (default), `2K`, `4K`. -- Aspect ratios: `1:1`, `2:3`, `3:2`, `3:4`, `4:3`, `4:5`, `5:4`, `9:16`, `16:9`, `21:9`. Without `--aspect-ratio` / `-a`, the model picks freely - use this flag for avatars, profile pics, or consistent batch generation. -- Use timestamps in filenames: `yyyy-mm-dd-hh-mm-ss-name.png`. -- The script prints a `MEDIA:` line for OpenClaw to auto-attach on supported chat providers. -- Do not read the image back; report the saved path only. diff --git a/skills/nano-banana-pro/scripts/generate_image.py b/skills/nano-banana-pro/scripts/generate_image.py deleted file mode 100755 index 796022adfba..00000000000 --- a/skills/nano-banana-pro/scripts/generate_image.py +++ /dev/null @@ -1,235 +0,0 @@ -#!/usr/bin/env python3 -# /// script -# requires-python = ">=3.10" -# dependencies = [ -# "google-genai>=1.0.0", -# "pillow>=10.0.0", -# ] -# /// -""" -Generate images using Google's Nano Banana Pro (Gemini 3 Pro Image) API. - -Usage: - uv run generate_image.py --prompt "your image description" --filename "output.png" [--resolution 1K|2K|4K] [--api-key KEY] - -Multi-image editing (up to 14 images): - uv run generate_image.py --prompt "combine these images" --filename "output.png" -i img1.png -i img2.png -i img3.png -""" - -import argparse -import os -import sys -from pathlib import Path - -SUPPORTED_ASPECT_RATIOS = [ - "1:1", - "2:3", - "3:2", - "3:4", - "4:3", - "4:5", - "5:4", - "9:16", - "16:9", - "21:9", -] - - -def get_api_key(provided_key: str | None) -> str | None: - """Get API key from argument first, then environment.""" - if provided_key: - return provided_key - return os.environ.get("GEMINI_API_KEY") - - -def auto_detect_resolution(max_input_dim: int) -> str: - """Infer output resolution from the largest input image dimension.""" - if max_input_dim >= 3000: - return "4K" - if max_input_dim >= 1500: - return "2K" - return "1K" - - -def choose_output_resolution( - requested_resolution: str | None, - max_input_dim: int, - has_input_images: bool, -) -> tuple[str, bool]: - """Choose final resolution and whether it was auto-detected. - - Auto-detection is only applied when the user did not pass --resolution. - """ - if requested_resolution is not None: - return requested_resolution, False - - if has_input_images and max_input_dim > 0: - return auto_detect_resolution(max_input_dim), True - - return "1K", False - - -def main(): - parser = argparse.ArgumentParser( - description="Generate images using Nano Banana Pro (Gemini 3 Pro Image)" - ) - parser.add_argument( - "--prompt", "-p", - required=True, - help="Image description/prompt" - ) - parser.add_argument( - "--filename", "-f", - required=True, - help="Output filename (e.g., sunset-mountains.png)" - ) - parser.add_argument( - "--input-image", "-i", - action="append", - dest="input_images", - metavar="IMAGE", - help="Input image path(s) for editing/composition. Can be specified multiple times (up to 14 images)." - ) - parser.add_argument( - "--resolution", "-r", - choices=["1K", "2K", "4K"], - default=None, - help="Output resolution: 1K, 2K, or 4K. If omitted with input images, auto-detect from largest image dimension." - ) - parser.add_argument( - "--aspect-ratio", "-a", - choices=SUPPORTED_ASPECT_RATIOS, - default=None, - help=f"Output aspect ratio (default: model decides). Options: {', '.join(SUPPORTED_ASPECT_RATIOS)}" - ) - parser.add_argument( - "--api-key", "-k", - help="Gemini API key (overrides GEMINI_API_KEY env var)" - ) - - args = parser.parse_args() - - # Get API key - api_key = get_api_key(args.api_key) - if not api_key: - print("Error: No API key provided.", file=sys.stderr) - print("Please either:", file=sys.stderr) - print(" 1. Provide --api-key argument", file=sys.stderr) - print(" 2. Set GEMINI_API_KEY environment variable", file=sys.stderr) - sys.exit(1) - - # Import here after checking API key to avoid slow import on error - from google import genai - from google.genai import types - from PIL import Image as PILImage - - # Initialise client - client = genai.Client(api_key=api_key) - - # Set up output path - output_path = Path(args.filename) - output_path.parent.mkdir(parents=True, exist_ok=True) - - # Load input images if provided (up to 14 supported by Nano Banana Pro) - input_images = [] - max_input_dim = 0 - if args.input_images: - if len(args.input_images) > 14: - print(f"Error: Too many input images ({len(args.input_images)}). Maximum is 14.", file=sys.stderr) - sys.exit(1) - - for img_path in args.input_images: - try: - with PILImage.open(img_path) as img: - copied = img.copy() - width, height = copied.size - input_images.append(copied) - print(f"Loaded input image: {img_path}") - - # Track largest dimension for auto-resolution - max_input_dim = max(max_input_dim, width, height) - except Exception as e: - print(f"Error loading input image '{img_path}': {e}", file=sys.stderr) - sys.exit(1) - - output_resolution, auto_detected = choose_output_resolution( - requested_resolution=args.resolution, - max_input_dim=max_input_dim, - has_input_images=bool(input_images), - ) - if auto_detected: - print( - f"Auto-detected resolution: {output_resolution} " - f"(from max input dimension {max_input_dim})" - ) - - # Build contents (images first if editing, prompt only if generating) - if input_images: - contents = [*input_images, args.prompt] - img_count = len(input_images) - print(f"Processing {img_count} image{'s' if img_count > 1 else ''} with resolution {output_resolution}...") - else: - contents = args.prompt - print(f"Generating image with resolution {output_resolution}...") - - try: - # Build image config with optional aspect ratio - image_cfg_kwargs = {"image_size": output_resolution} - if args.aspect_ratio: - image_cfg_kwargs["aspect_ratio"] = args.aspect_ratio - - response = client.models.generate_content( - model="gemini-3-pro-image-preview", - contents=contents, - config=types.GenerateContentConfig( - response_modalities=["TEXT", "IMAGE"], - image_config=types.ImageConfig(**image_cfg_kwargs) - ) - ) - - # Process response and convert to PNG - image_saved = False - for part in response.parts: - if part.text is not None: - print(f"Model response: {part.text}") - elif part.inline_data is not None: - # Convert inline data to PIL Image and save as PNG - from io import BytesIO - - # inline_data.data is already bytes, not base64 - image_data = part.inline_data.data - if isinstance(image_data, str): - # If it's a string, it might be base64 - import base64 - image_data = base64.b64decode(image_data) - - image = PILImage.open(BytesIO(image_data)) - - # Ensure RGB mode for PNG (convert RGBA to RGB with white background if needed) - if image.mode == 'RGBA': - rgb_image = PILImage.new('RGB', image.size, (255, 255, 255)) - rgb_image.paste(image, mask=image.split()[3]) - rgb_image.save(str(output_path), 'PNG') - elif image.mode == 'RGB': - image.save(str(output_path), 'PNG') - else: - image.convert('RGB').save(str(output_path), 'PNG') - image_saved = True - - if image_saved: - full_path = output_path.resolve() - print(f"\nImage saved: {full_path}") - # OpenClaw parses MEDIA: tokens and will attach the file on - # supported chat providers. Emit the canonical MEDIA: form. - print(f"MEDIA:{full_path}") - else: - print("Error: No image was generated in the response.", file=sys.stderr) - sys.exit(1) - - except Exception as e: - print(f"Error generating image: {e}", file=sys.stderr) - sys.exit(1) - - -if __name__ == "__main__": - main() diff --git a/skills/nano-banana-pro/scripts/test_generate_image.py b/skills/nano-banana-pro/scripts/test_generate_image.py deleted file mode 100644 index 1dbae257428..00000000000 --- a/skills/nano-banana-pro/scripts/test_generate_image.py +++ /dev/null @@ -1,36 +0,0 @@ -import importlib.util -from pathlib import Path - -import pytest - -MODULE_PATH = Path(__file__).with_name("generate_image.py") -SPEC = importlib.util.spec_from_file_location("generate_image", MODULE_PATH) -assert SPEC and SPEC.loader -MODULE = importlib.util.module_from_spec(SPEC) -SPEC.loader.exec_module(MODULE) - - -@pytest.mark.parametrize( - ("max_input_dim", "expected"), - [ - (0, "1K"), - (1499, "1K"), - (1500, "2K"), - (2999, "2K"), - (3000, "4K"), - ], -) -def test_auto_detect_resolution_thresholds(max_input_dim, expected): - assert MODULE.auto_detect_resolution(max_input_dim) == expected - - -def test_choose_output_resolution_auto_detects_when_resolution_omitted(): - assert MODULE.choose_output_resolution(None, 2200, True) == ("2K", True) - - -def test_choose_output_resolution_defaults_to_1k_without_inputs(): - assert MODULE.choose_output_resolution(None, 0, False) == ("1K", False) - - -def test_choose_output_resolution_respects_explicit_1k_with_large_input(): - assert MODULE.choose_output_resolution("1K", 3500, True) == ("1K", False) diff --git a/src/agents/tools/image-generate-tool.test.ts b/src/agents/tools/image-generate-tool.test.ts index 86f5aaf07d9..50df1718daf 100644 --- a/src/agents/tools/image-generate-tool.test.ts +++ b/src/agents/tools/image-generate-tool.test.ts @@ -14,8 +14,23 @@ function stubImageGenerationProviders() { id: "google", defaultModel: "gemini-3.1-flash-image-preview", models: ["gemini-3.1-flash-image-preview", "gemini-3-pro-image-preview"], - supportedResolutions: ["1K", "2K", "4K"], - supportsImageEditing: true, + capabilities: { + generate: { + maxCount: 4, + supportsAspectRatio: true, + supportsResolution: true, + }, + edit: { + enabled: true, + maxInputImages: 5, + supportsAspectRatio: true, + supportsResolution: true, + }, + geometry: { + resolutions: ["1K", "2K", "4K"], + aspectRatios: ["1:1", "16:9"], + }, + }, generateImage: vi.fn(async () => { throw new Error("not used"); }), @@ -24,8 +39,19 @@ function stubImageGenerationProviders() { id: "openai", defaultModel: "gpt-image-1", models: ["gpt-image-1"], - supportedSizes: ["1024x1024", "1024x1536", "1536x1024"], - supportsImageEditing: false, + capabilities: { + generate: { + maxCount: 4, + supportsSize: true, + }, + edit: { + enabled: false, + maxInputImages: 0, + }, + geometry: { + sizes: ["1024x1024", "1024x1536", "1536x1024"], + }, + }, generateImage: vi.fn(async () => { throw new Error("not used"); }), @@ -138,6 +164,7 @@ describe("createImageGenerateTool", () => { const result = await tool.execute("call-1", { prompt: "A cat wearing sunglasses", model: "openai/gpt-image-1", + filename: "cats/output.png", count: 2, size: "1024x1024", }); @@ -167,7 +194,7 @@ describe("createImageGenerateTool", () => { "image/png", "tool-image-generation", undefined, - "cat-one.png", + "cats/output.png", ); expect(saveMediaBuffer).toHaveBeenNthCalledWith( 2, @@ -175,7 +202,7 @@ describe("createImageGenerateTool", () => { "image/png", "tool-image-generation", undefined, - "cat-two.png", + "cats/output.png", ); expect(result).toMatchObject({ content: [ @@ -189,6 +216,7 @@ describe("createImageGenerateTool", () => { model: "gpt-image-1", count: 2, paths: ["/tmp/generated-1.png", "/tmp/generated-2.png"], + filename: "cats/output.png", revisedPrompts: ["A more cinematic cat"], }, }); @@ -273,6 +301,7 @@ describe("createImageGenerateTool", () => { expect(generateImage).toHaveBeenCalledWith( expect.objectContaining({ + aspectRatio: undefined, resolution: "4K", inputImages: [ expect.objectContaining({ @@ -284,6 +313,91 @@ describe("createImageGenerateTool", () => { ); }); + it("forwards explicit aspect ratio and supports up to 5 reference images", async () => { + const generateImage = vi.spyOn(imageGenerationRuntime, "generateImage").mockResolvedValue({ + provider: "google", + model: "gemini-3-pro-image-preview", + attempts: [], + images: [ + { + buffer: Buffer.from("png-out"), + mimeType: "image/png", + fileName: "edited.png", + }, + ], + }); + vi.spyOn(webMedia, "loadWebMedia").mockResolvedValue({ + kind: "image", + buffer: Buffer.from("input-image"), + contentType: "image/png", + }); + vi.spyOn(mediaStore, "saveMediaBuffer").mockResolvedValue({ + path: "/tmp/edited.png", + id: "edited.png", + size: 7, + contentType: "image/png", + }); + + const tool = createImageGenerateTool({ + config: { + agents: { + defaults: { + imageGenerationModel: { + primary: "google/gemini-3-pro-image-preview", + }, + }, + }, + }, + workspaceDir: process.cwd(), + }); + + expect(tool).not.toBeNull(); + if (!tool) { + throw new Error("expected image_generate tool"); + } + + const images = Array.from({ length: 5 }, (_, index) => `./fixtures/ref-${index + 1}.png`); + await tool.execute("call-compose", { + prompt: "Combine these into one scene", + images, + aspectRatio: "16:9", + }); + + expect(generateImage).toHaveBeenCalledWith( + expect.objectContaining({ + aspectRatio: "16:9", + inputImages: expect.arrayContaining([ + expect.objectContaining({ buffer: Buffer.from("input-image"), mimeType: "image/png" }), + ]), + }), + ); + expect(generateImage.mock.calls[0]?.[0].inputImages).toHaveLength(5); + }); + + it("rejects unsupported aspect ratios", async () => { + const tool = createImageGenerateTool({ + config: { + agents: { + defaults: { + imageGenerationModel: { + primary: "google/gemini-3-pro-image-preview", + }, + }, + }, + }, + }); + + expect(tool).not.toBeNull(); + if (!tool) { + throw new Error("expected image_generate tool"); + } + + await expect(tool.execute("call-bad-aspect", { prompt: "portrait", aspectRatio: "7:5" })) + .rejects.toThrow( + "aspectRatio must be one of 1:1, 2:3, 3:2, 3:4, 4:3, 4:5, 5:4, 9:16, 16:9, or 21:9", + ); + }); + it("lists registered provider and model options", async () => { stubImageGenerationProviders(); @@ -310,7 +424,8 @@ describe("createImageGenerateTool", () => { expect(text).toContain("google (default gemini-3.1-flash-image-preview)"); expect(text).toContain("gemini-3.1-flash-image-preview"); expect(text).toContain("gemini-3-pro-image-preview"); - expect(text).toContain("editing"); + expect(text).toContain("editing up to 5 refs"); + expect(text).toContain("aspect ratios 1:1, 16:9"); expect(result).toMatchObject({ details: { providers: expect.arrayContaining([ @@ -321,9 +436,139 @@ describe("createImageGenerateTool", () => { "gemini-3.1-flash-image-preview", "gemini-3-pro-image-preview", ]), + capabilities: expect.objectContaining({ + edit: expect.objectContaining({ + enabled: true, + maxInputImages: 5, + }), + }), }), ]), }, }); }); + + it("rejects provider-specific edit limits before runtime", async () => { + vi.spyOn(imageGenerationRuntime, "listRuntimeImageGenerationProviders").mockReturnValue([ + { + id: "fal", + defaultModel: "fal-ai/flux/dev", + models: ["fal-ai/flux/dev", "fal-ai/flux/dev/image-to-image"], + capabilities: { + generate: { + maxCount: 4, + supportsSize: true, + supportsAspectRatio: true, + supportsResolution: true, + }, + edit: { + enabled: true, + maxInputImages: 1, + supportsSize: true, + supportsAspectRatio: false, + supportsResolution: true, + }, + }, + generateImage: vi.fn(async () => { + throw new Error("not used"); + }), + }, + ]); + const generateImage = vi.spyOn(imageGenerationRuntime, "generateImage"); + vi.spyOn(webMedia, "loadWebMedia").mockResolvedValue({ + kind: "image", + buffer: Buffer.from("input-image"), + contentType: "image/png", + }); + + const tool = createImageGenerateTool({ + config: { + agents: { + defaults: { + imageGenerationModel: { + primary: "fal/fal-ai/flux/dev", + }, + }, + }, + }, + workspaceDir: process.cwd(), + }); + + expect(tool).not.toBeNull(); + if (!tool) { + throw new Error("expected image_generate tool"); + } + + await expect( + tool.execute("call-fal-edit", { + prompt: "combine", + images: ["./fixtures/a.png", "./fixtures/b.png"], + }), + ).rejects.toThrow("fal edit supports at most 1 reference image"); + expect(generateImage).not.toHaveBeenCalled(); + }); + + it("rejects unsupported provider-specific edit aspect ratio overrides before runtime", async () => { + vi.spyOn(imageGenerationRuntime, "listRuntimeImageGenerationProviders").mockReturnValue([ + { + id: "fal", + defaultModel: "fal-ai/flux/dev", + models: ["fal-ai/flux/dev", "fal-ai/flux/dev/image-to-image"], + capabilities: { + generate: { + maxCount: 4, + supportsSize: true, + supportsAspectRatio: true, + supportsResolution: true, + }, + edit: { + enabled: true, + maxInputImages: 1, + supportsSize: true, + supportsAspectRatio: false, + supportsResolution: true, + }, + geometry: { + aspectRatios: ["1:1", "16:9"], + }, + }, + generateImage: vi.fn(async () => { + throw new Error("not used"); + }), + }, + ]); + const generateImage = vi.spyOn(imageGenerationRuntime, "generateImage"); + vi.spyOn(webMedia, "loadWebMedia").mockResolvedValue({ + kind: "image", + buffer: Buffer.from("input-image"), + contentType: "image/png", + }); + + const tool = createImageGenerateTool({ + config: { + agents: { + defaults: { + imageGenerationModel: { + primary: "fal/fal-ai/flux/dev", + }, + }, + }, + }, + workspaceDir: process.cwd(), + }); + + expect(tool).not.toBeNull(); + if (!tool) { + throw new Error("expected image_generate tool"); + } + + await expect( + tool.execute("call-fal-aspect", { + prompt: "edit", + image: "./fixtures/a.png", + aspectRatio: "16:9", + }), + ).rejects.toThrow("fal edit does not support aspectRatio overrides"); + expect(generateImage).not.toHaveBeenCalled(); + }); }); diff --git a/src/agents/tools/image-generate-tool.ts b/src/agents/tools/image-generate-tool.ts index 057b9013100..3ae12fda187 100644 --- a/src/agents/tools/image-generate-tool.ts +++ b/src/agents/tools/image-generate-tool.ts @@ -6,6 +6,7 @@ import { listRuntimeImageGenerationProviders, } from "../../image-generation/runtime.js"; import type { + ImageGenerationProvider, ImageGenerationResolution, ImageGenerationSourceImage, } from "../../image-generation/types.js"; @@ -36,8 +37,20 @@ import { const DEFAULT_COUNT = 1; const MAX_COUNT = 4; -const MAX_INPUT_IMAGES = 4; +const MAX_INPUT_IMAGES = 5; const DEFAULT_RESOLUTION: ImageGenerationResolution = "1K"; +const SUPPORTED_ASPECT_RATIOS = new Set([ + "1:1", + "2:3", + "3:2", + "3:4", + "4:3", + "4:5", + "5:4", + "9:16", + "16:9", + "21:9", +]); const ImageGenerateToolSchema = Type.Object({ action: Type.Optional( @@ -60,12 +73,24 @@ const ImageGenerateToolSchema = Type.Object({ model: Type.Optional( Type.String({ description: "Optional provider/model override, e.g. openai/gpt-image-1." }), ), + filename: Type.Optional( + Type.String({ + description: + "Optional output filename hint. OpenClaw preserves the basename and saves under its managed media directory.", + }), + ), size: Type.Optional( Type.String({ description: "Optional size hint like 1024x1024, 1536x1024, 1024x1536, 1024x1792, or 1792x1024.", }), ), + aspectRatio: Type.Optional( + Type.String({ + description: + "Optional aspect ratio hint: 1:1, 2:3, 3:2, 3:4, 4:3, 4:5, 5:4, 9:16, 16:9, or 21:9.", + }), + ), resolution: Type.Optional( Type.String({ description: @@ -162,6 +187,19 @@ function normalizeResolution(raw: string | undefined): ImageGenerationResolution throw new ToolInputError("resolution must be one of 1K, 2K, or 4K"); } +function normalizeAspectRatio(raw: string | undefined): string | undefined { + const normalized = raw?.trim(); + if (!normalized) { + return undefined; + } + if (SUPPORTED_ASPECT_RATIOS.has(normalized)) { + return normalized; + } + throw new ToolInputError( + "aspectRatio must be one of 1:1, 2:3, 3:2, 3:4, 4:3, 4:5, 5:4, 9:16, 16:9, or 21:9", + ); +} + function normalizeReferenceImages(args: Record): string[] { const imageCandidates: string[] = []; if (typeof args.image === "string") { @@ -192,6 +230,112 @@ function normalizeReferenceImages(args: Record): string[] { return normalized; } +function parseImageGenerationModelRef(raw: string | undefined): { provider: string; model: string } | null { + const trimmed = raw?.trim(); + if (!trimmed) { + return null; + } + const slashIndex = trimmed.indexOf("/"); + if (slashIndex <= 0 || slashIndex === trimmed.length - 1) { + return null; + } + return { + provider: trimmed.slice(0, slashIndex).trim(), + model: trimmed.slice(slashIndex + 1).trim(), + }; +} + +function resolveSelectedImageGenerationProvider(params: { + config?: OpenClawConfig; + imageGenerationModelConfig: ToolModelConfig; + modelOverride?: string; +}): ImageGenerationProvider | undefined { + const selectedRef = + parseImageGenerationModelRef(params.modelOverride) ?? + parseImageGenerationModelRef(params.imageGenerationModelConfig.primary); + if (!selectedRef) { + return undefined; + } + return listRuntimeImageGenerationProviders({ config: params.config }).find( + (provider) => + provider.id === selectedRef.provider || (provider.aliases ?? []).includes(selectedRef.provider), + ); +} + +function validateImageGenerationCapabilities(params: { + provider: ImageGenerationProvider | undefined; + count: number; + inputImageCount: number; + size?: string; + aspectRatio?: string; + resolution?: ImageGenerationResolution; +}) { + const provider = params.provider; + if (!provider) { + return; + } + const isEdit = params.inputImageCount > 0; + const modeCaps = isEdit ? provider.capabilities.edit : provider.capabilities.generate; + const geometry = provider.capabilities.geometry; + const maxCount = modeCaps.maxCount ?? MAX_COUNT; + if (params.count > maxCount) { + throw new ToolInputError( + `${provider.id} ${isEdit ? "edit" : "generate"} supports at most ${maxCount} output image${maxCount === 1 ? "" : "s"}.`, + ); + } + + if (isEdit) { + if (!provider.capabilities.edit.enabled) { + throw new ToolInputError(`${provider.id} does not support reference-image edits.`); + } + const maxInputImages = provider.capabilities.edit.maxInputImages ?? MAX_INPUT_IMAGES; + if (params.inputImageCount > maxInputImages) { + throw new ToolInputError( + `${provider.id} edit supports at most ${maxInputImages} reference image${maxInputImages === 1 ? "" : "s"}.`, + ); + } + } + + if (params.size) { + if (!modeCaps.supportsSize) { + throw new ToolInputError(`${provider.id} ${isEdit ? "edit" : "generate"} does not support size overrides.`); + } + if ((geometry?.sizes?.length ?? 0) > 0 && !geometry?.sizes?.includes(params.size)) { + throw new ToolInputError( + `${provider.id} ${isEdit ? "edit" : "generate"} size must be one of ${geometry?.sizes?.join(", ")}.`, + ); + } + } + + if (params.aspectRatio) { + if (!modeCaps.supportsAspectRatio) { + throw new ToolInputError(`${provider.id} ${isEdit ? "edit" : "generate"} does not support aspectRatio overrides.`); + } + if ( + (geometry?.aspectRatios?.length ?? 0) > 0 && + !geometry?.aspectRatios?.includes(params.aspectRatio) + ) { + throw new ToolInputError( + `${provider.id} ${isEdit ? "edit" : "generate"} aspectRatio must be one of ${geometry?.aspectRatios?.join(", ")}.`, + ); + } + } + + if (params.resolution) { + if (!modeCaps.supportsResolution) { + throw new ToolInputError(`${provider.id} ${isEdit ? "edit" : "generate"} does not support resolution overrides.`); + } + if ( + (geometry?.resolutions?.length ?? 0) > 0 && + !geometry?.resolutions?.includes(params.resolution) + ) { + throw new ToolInputError( + `${provider.id} ${isEdit ? "edit" : "generate"} resolution must be one of ${geometry?.resolutions?.join("/")}.`, + ); + } + } +} + type ImageGenerateSandboxConfig = { root: string; bridge: SandboxFsBridge; @@ -357,25 +501,25 @@ export function createImageGenerateTool(options?: { ...(provider.label ? { label: provider.label } : {}), ...(provider.defaultModel ? { defaultModel: provider.defaultModel } : {}), models: provider.models ?? (provider.defaultModel ? [provider.defaultModel] : []), - ...(provider.supportedSizes ? { supportedSizes: [...provider.supportedSizes] } : {}), - ...(provider.supportedResolutions - ? { supportedResolutions: [...provider.supportedResolutions] } - : {}), - ...(typeof provider.supportsImageEditing === "boolean" - ? { supportsImageEditing: provider.supportsImageEditing } - : {}), + capabilities: provider.capabilities, }), ); const lines = providers.flatMap((provider) => { const caps: string[] = []; - if (provider.supportsImageEditing) { - caps.push("editing"); + if (provider.capabilities.edit.enabled) { + const maxRefs = provider.capabilities.edit.maxInputImages; + caps.push( + `editing${typeof maxRefs === "number" ? ` up to ${maxRefs} ref${maxRefs === 1 ? "" : "s"}` : ""}`, + ); } - if ((provider.supportedResolutions?.length ?? 0) > 0) { - caps.push(`resolutions ${provider.supportedResolutions?.join("/")}`); + if ((provider.capabilities.geometry?.resolutions?.length ?? 0) > 0) { + caps.push(`resolutions ${provider.capabilities.geometry?.resolutions?.join("/")}`); } - if ((provider.supportedSizes?.length ?? 0) > 0) { - caps.push(`sizes ${provider.supportedSizes?.join(", ")}`); + if ((provider.capabilities.geometry?.sizes?.length ?? 0) > 0) { + caps.push(`sizes ${provider.capabilities.geometry?.sizes?.join(", ")}`); + } + if ((provider.capabilities.geometry?.aspectRatios?.length ?? 0) > 0) { + caps.push(`aspect ratios ${provider.capabilities.geometry?.aspectRatios?.join(", ")}`); } const modelLine = provider.models.length > 0 @@ -396,7 +540,9 @@ export function createImageGenerateTool(options?: { const prompt = readStringParam(params, "prompt", { required: true }); const imageInputs = normalizeReferenceImages(params); const model = readStringParam(params, "model"); + const filename = readStringParam(params, "filename"); const size = readStringParam(params, "size"); + const aspectRatio = normalizeAspectRatio(readStringParam(params, "aspectRatio")); const explicitResolution = normalizeResolution(readStringParam(params, "resolution")); const count = resolveRequestedCount(params); const loadedReferenceImages = await loadReferenceImages({ @@ -412,6 +558,19 @@ export function createImageGenerateTool(options?: { : inputImages.length > 0 ? await inferResolutionFromInputImages(inputImages) : undefined); + const selectedProvider = resolveSelectedImageGenerationProvider({ + config: effectiveCfg, + imageGenerationModelConfig, + modelOverride: model, + }); + validateImageGenerationCapabilities({ + provider: selectedProvider, + count, + inputImageCount: inputImages.length, + size, + aspectRatio, + resolution, + }); const result = await generateImage({ cfg: effectiveCfg, @@ -419,6 +578,7 @@ export function createImageGenerateTool(options?: { agentDir: options?.agentDir, modelOverride: model, size, + aspectRatio, resolution, count, inputImages, @@ -431,7 +591,7 @@ export function createImageGenerateTool(options?: { image.mimeType, "tool-image-generation", undefined, - image.fileName, + filename || image.fileName, ), ), ); @@ -468,6 +628,8 @@ export function createImageGenerateTool(options?: { : {}), ...(resolution ? { resolution } : {}), ...(size ? { size } : {}), + ...(aspectRatio ? { aspectRatio } : {}), + ...(filename ? { filename } : {}), attempts: result.attempts, metadata: result.metadata, ...(revisedPrompts.length > 0 ? { revisedPrompts } : {}), diff --git a/src/commands/doctor-legacy-config.migrations.test.ts b/src/commands/doctor-legacy-config.migrations.test.ts index e364d1b7168..738827c31c6 100644 --- a/src/commands/doctor-legacy-config.migrations.test.ts +++ b/src/commands/doctor-legacy-config.migrations.test.ts @@ -297,4 +297,99 @@ describe("normalizeCompatibilityConfigValues", () => { "Moved browser.ssrfPolicy.allowPrivateNetwork → browser.ssrfPolicy.dangerouslyAllowPrivateNetwork (true).", ); }); + + it("migrates nano-banana skill config to native image generation config", () => { + const res = normalizeCompatibilityConfigValues({ + skills: { + entries: { + "nano-banana-pro": { + enabled: true, + apiKey: { source: "env", provider: "default", id: "GEMINI_API_KEY" }, + }, + }, + }, + }); + + expect(res.config.agents?.defaults?.imageGenerationModel).toEqual({ + primary: "google/gemini-3-pro-image-preview", + }); + expect(res.config.models?.providers?.google?.apiKey).toEqual({ + source: "env", + provider: "default", + id: "GEMINI_API_KEY", + }); + expect(res.config.skills?.entries).toBeUndefined(); + expect(res.changes).toEqual([ + "Moved skills.entries.nano-banana-pro → agents.defaults.imageGenerationModel.primary (google/gemini-3-pro-image-preview).", + "Moved skills.entries.nano-banana-pro.apiKey → models.providers.google.apiKey.", + "Removed legacy skills.entries.nano-banana-pro.", + ]); + }); + + it("prefers legacy nano-banana env.GEMINI_API_KEY over skill apiKey during migration", () => { + const res = normalizeCompatibilityConfigValues({ + skills: { + entries: { + "nano-banana-pro": { + apiKey: "ignored-skill-api-key", + env: { + GEMINI_API_KEY: "env-gemini-key", + }, + }, + }, + }, + }); + + expect(res.config.models?.providers?.google?.apiKey).toBe("env-gemini-key"); + expect(res.changes).toContain( + "Moved skills.entries.nano-banana-pro.env.GEMINI_API_KEY → models.providers.google.apiKey.", + ); + }); + + it("preserves explicit native config while removing legacy nano-banana skill config", () => { + const res = normalizeCompatibilityConfigValues({ + agents: { + defaults: { + imageGenerationModel: { + primary: "fal/fal-ai/flux/dev", + }, + }, + }, + models: { + providers: { + google: { + apiKey: "existing-google-key", + }, + }, + }, + skills: { + entries: { + "nano-banana-pro": { + apiKey: "legacy-gemini-key", + }, + peekaboo: { enabled: true }, + }, + }, + }); + + expect(res.config.agents?.defaults?.imageGenerationModel).toEqual({ + primary: "fal/fal-ai/flux/dev", + }); + expect(res.config.models?.providers?.google?.apiKey).toBe("existing-google-key"); + expect(res.config.skills?.entries).toEqual({ + peekaboo: { enabled: true }, + }); + expect(res.changes).toEqual(["Removed legacy skills.entries.nano-banana-pro."]); + }); + + it("removes nano-banana from skills.allowBundled during migration", () => { + const res = normalizeCompatibilityConfigValues({ + skills: { + allowBundled: ["peekaboo", "nano-banana-pro"], + }, + }); + + expect(res.config.skills?.allowBundled).toEqual(["peekaboo"]); + expect(res.changes).toEqual(["Removed nano-banana-pro from skills.allowBundled."]); + }); }); diff --git a/src/commands/doctor-legacy-config.ts b/src/commands/doctor-legacy-config.ts index 2d6bfa83a11..8072b89854b 100644 --- a/src/commands/doctor-legacy-config.ts +++ b/src/commands/doctor-legacy-config.ts @@ -15,6 +15,8 @@ export function normalizeCompatibilityConfigValues(cfg: OpenClawConfig): { changes: string[]; } { const changes: string[] = []; + const NANO_BANANA_SKILL_KEY = "nano-banana-pro"; + const NANO_BANANA_MODEL = "google/gemini-3-pro-image-preview"; let next: OpenClawConfig = cfg; const isRecord = (value: unknown): value is Record => @@ -471,7 +473,121 @@ export function normalizeCompatibilityConfigValues(cfg: OpenClawConfig): { ); }; + const normalizeLegacyNanoBananaSkill = () => { + const rawSkills = next.skills; + if (!isRecord(rawSkills)) { + return; + } + + let skillsChanged = false; + let skills = structuredClone(rawSkills); + + if (Array.isArray(skills.allowBundled)) { + const allowBundled = skills.allowBundled.filter( + (value) => typeof value !== "string" || value.trim() !== NANO_BANANA_SKILL_KEY, + ); + if (allowBundled.length !== skills.allowBundled.length) { + if (allowBundled.length === 0) { + delete skills.allowBundled; + changes.push(`Removed skills.allowBundled entry for ${NANO_BANANA_SKILL_KEY}.`); + } else { + skills.allowBundled = allowBundled; + changes.push(`Removed ${NANO_BANANA_SKILL_KEY} from skills.allowBundled.`); + } + skillsChanged = true; + } + } + + const rawEntries = skills.entries; + if (!isRecord(rawEntries)) { + if (skillsChanged) { + next = { ...next, skills }; + } + return; + } + + const rawLegacyEntry = rawEntries[NANO_BANANA_SKILL_KEY]; + if (!isRecord(rawLegacyEntry)) { + if (skillsChanged) { + next = { ...next, skills }; + } + return; + } + + const existingImageGenerationModel = next.agents?.defaults?.imageGenerationModel; + if (existingImageGenerationModel === undefined) { + next = { + ...next, + agents: { + ...next.agents, + defaults: { + ...next.agents?.defaults, + imageGenerationModel: { + primary: NANO_BANANA_MODEL, + }, + }, + }, + }; + changes.push( + `Moved skills.entries.${NANO_BANANA_SKILL_KEY} → agents.defaults.imageGenerationModel.primary (${NANO_BANANA_MODEL}).`, + ); + } + + const legacyEnv = isRecord(rawLegacyEntry.env) ? rawLegacyEntry.env : undefined; + const legacyEnvApiKey = + typeof legacyEnv?.GEMINI_API_KEY === "string" ? legacyEnv.GEMINI_API_KEY.trim() : ""; + const legacyApiKey = + legacyEnvApiKey || + (typeof rawLegacyEntry.apiKey === "string" + ? rawLegacyEntry.apiKey.trim() + : rawLegacyEntry.apiKey && isRecord(rawLegacyEntry.apiKey) + ? structuredClone(rawLegacyEntry.apiKey) + : undefined); + + const rawModels = isRecord(next.models) ? structuredClone(next.models) : {}; + const rawProviders = isRecord(rawModels.providers) ? { ...rawModels.providers } : {}; + const rawGoogle = isRecord(rawProviders.google) ? { ...rawProviders.google } : {}; + const hasGoogleApiKey = rawGoogle.apiKey !== undefined; + if (!hasGoogleApiKey && legacyApiKey) { + rawGoogle.apiKey = legacyApiKey; + rawProviders.google = rawGoogle; + rawModels.providers = rawProviders; + next = { + ...next, + models: rawModels as OpenClawConfig["models"], + }; + changes.push( + `Moved skills.entries.${NANO_BANANA_SKILL_KEY}.${legacyEnvApiKey ? "env.GEMINI_API_KEY" : "apiKey"} → models.providers.google.apiKey.`, + ); + } + + const entries = { ...rawEntries }; + delete entries[NANO_BANANA_SKILL_KEY]; + if (Object.keys(entries).length === 0) { + delete skills.entries; + changes.push(`Removed legacy skills.entries.${NANO_BANANA_SKILL_KEY}.`); + } else { + skills.entries = entries; + changes.push(`Removed legacy skills.entries.${NANO_BANANA_SKILL_KEY}.`); + } + skillsChanged = true; + + if (Object.keys(skills).length === 0) { + const { skills: _ignored, ...rest } = next; + next = rest; + return; + } + + if (skillsChanged) { + next = { + ...next, + skills, + }; + } + }; + normalizeBrowserSsrFPolicyAlias(); + normalizeLegacyNanoBananaSkill(); const legacyAckReaction = cfg.messages?.ackReaction?.trim(); const hasWhatsAppConfig = cfg.channels?.whatsapp !== undefined; diff --git a/src/image-generation/providers/fal.test.ts b/src/image-generation/providers/fal.test.ts index c610c1b9c0c..ea583dbe431 100644 --- a/src/image-generation/providers/fal.test.ts +++ b/src/image-generation/providers/fal.test.ts @@ -127,6 +127,97 @@ describe("fal image-generation provider", () => { ); }); + it("maps aspect ratio for text generation without forcing a square default", async () => { + vi.spyOn(modelAuth, "resolveApiKeyForProvider").mockResolvedValue({ + apiKey: "fal-test-key", + source: "env", + mode: "api-key", + }); + const fetchMock = vi + .fn() + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + images: [{ url: "https://v3.fal.media/files/example/wide.png" }], + }), + }) + .mockResolvedValueOnce({ + ok: true, + headers: new Headers({ "content-type": "image/png" }), + arrayBuffer: async () => Buffer.from("wide-data"), + }); + vi.stubGlobal("fetch", fetchMock); + + const provider = buildFalImageGenerationProvider(); + await provider.generateImage({ + provider: "fal", + model: "fal-ai/flux/dev", + prompt: "wide cinematic shot", + cfg: {}, + aspectRatio: "16:9", + }); + + expect(fetchMock).toHaveBeenNthCalledWith( + 1, + "https://fal.run/fal-ai/flux/dev", + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ + prompt: "wide cinematic shot", + image_size: "landscape_16_9", + num_images: 1, + output_format: "png", + }), + }), + ); + }); + + it("combines resolution and aspect ratio for text generation", async () => { + vi.spyOn(modelAuth, "resolveApiKeyForProvider").mockResolvedValue({ + apiKey: "fal-test-key", + source: "env", + mode: "api-key", + }); + const fetchMock = vi + .fn() + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + images: [{ url: "https://v3.fal.media/files/example/portrait.png" }], + }), + }) + .mockResolvedValueOnce({ + ok: true, + headers: new Headers({ "content-type": "image/png" }), + arrayBuffer: async () => Buffer.from("portrait-data"), + }); + vi.stubGlobal("fetch", fetchMock); + + const provider = buildFalImageGenerationProvider(); + await provider.generateImage({ + provider: "fal", + model: "fal-ai/flux/dev", + prompt: "portrait poster", + cfg: {}, + resolution: "2K", + aspectRatio: "9:16", + }); + + expect(fetchMock).toHaveBeenNthCalledWith( + 1, + "https://fal.run/fal-ai/flux/dev", + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ + prompt: "portrait poster", + image_size: { width: 1152, height: 2048 }, + num_images: 1, + output_format: "png", + }), + }), + ); + }); + it("rejects multi-image edit requests for now", async () => { vi.spyOn(modelAuth, "resolveApiKeyForProvider").mockResolvedValue({ apiKey: "fal-test-key", @@ -148,4 +239,24 @@ describe("fal image-generation provider", () => { }), ).rejects.toThrow("at most one reference image"); }); + + it("rejects aspect ratio overrides for the current edit endpoint", async () => { + vi.spyOn(modelAuth, "resolveApiKeyForProvider").mockResolvedValue({ + apiKey: "fal-test-key", + source: "env", + mode: "api-key", + }); + + const provider = buildFalImageGenerationProvider(); + await expect( + provider.generateImage({ + provider: "fal", + model: "fal-ai/flux/dev", + prompt: "make it widescreen", + cfg: {}, + aspectRatio: "16:9", + inputImages: [{ buffer: Buffer.from("one"), mimeType: "image/png" }], + }), + ).rejects.toThrow("does not support aspectRatio overrides"); + }); }); diff --git a/src/image-generation/providers/fal.ts b/src/image-generation/providers/fal.ts index b9bd5517651..4059859e534 100644 --- a/src/image-generation/providers/fal.ts +++ b/src/image-generation/providers/fal.ts @@ -5,8 +5,15 @@ import type { GeneratedImageAsset } from "../types.js"; const DEFAULT_FAL_BASE_URL = "https://fal.run"; const DEFAULT_FAL_IMAGE_MODEL = "fal-ai/flux/dev"; const DEFAULT_FAL_EDIT_SUBPATH = "image-to-image"; -const DEFAULT_OUTPUT_SIZE = "square_hd"; const DEFAULT_OUTPUT_FORMAT = "png"; +const FAL_SUPPORTED_SIZES = [ + "1024x1024", + "1024x1536", + "1536x1024", + "1024x1792", + "1792x1024", +] as const; +const FAL_SUPPORTED_ASPECT_RATIOS = ["1:1", "4:3", "3:4", "16:9", "9:16"] as const; type FalGeneratedImage = { url?: string; @@ -57,23 +64,85 @@ function parseSize(raw: string | undefined): { width: number; height: number } | return { width, height }; } -function mapResolutionToSize(resolution: "1K" | "2K" | "4K" | undefined): FalImageSize | undefined { +function mapResolutionToEdge(resolution: "1K" | "2K" | "4K" | undefined): number | undefined { if (!resolution) { return undefined; } - const edge = resolution === "4K" ? 4096 : resolution === "2K" ? 2048 : 1024; - return { width: edge, height: edge }; + return resolution === "4K" ? 4096 : resolution === "2K" ? 2048 : 1024; +} + +function aspectRatioToEnum(aspectRatio: string | undefined): string | undefined { + const normalized = aspectRatio?.trim(); + if (!normalized) { + return undefined; + } + if (normalized === "1:1") { + return "square_hd"; + } + if (normalized === "4:3") { + return "landscape_4_3"; + } + if (normalized === "3:4") { + return "portrait_4_3"; + } + if (normalized === "16:9") { + return "landscape_16_9"; + } + if (normalized === "9:16") { + return "portrait_16_9"; + } + return undefined; +} + +function aspectRatioToDimensions(aspectRatio: string, edge: number): { width: number; height: number } { + const match = /^(\d+):(\d+)$/u.exec(aspectRatio.trim()); + if (!match) { + throw new Error(`Invalid fal aspect ratio: ${aspectRatio}`); + } + const widthRatio = Number.parseInt(match[1] ?? "", 10); + const heightRatio = Number.parseInt(match[2] ?? "", 10); + if (!Number.isFinite(widthRatio) || !Number.isFinite(heightRatio) || widthRatio <= 0 || heightRatio <= 0) { + throw new Error(`Invalid fal aspect ratio: ${aspectRatio}`); + } + if (widthRatio >= heightRatio) { + return { + width: edge, + height: Math.max(1, Math.round((edge * heightRatio) / widthRatio)), + }; + } + return { + width: Math.max(1, Math.round((edge * widthRatio) / heightRatio)), + height: edge, + }; } function resolveFalImageSize(params: { size?: string; resolution?: "1K" | "2K" | "4K"; -}): FalImageSize { + aspectRatio?: string; + hasInputImages: boolean; +}): FalImageSize | undefined { const parsed = parseSize(params.size); if (parsed) { return parsed; } - return mapResolutionToSize(params.resolution) ?? DEFAULT_OUTPUT_SIZE; + + const normalizedAspectRatio = params.aspectRatio?.trim(); + if (normalizedAspectRatio && params.hasInputImages) { + throw new Error("fal image edit endpoint does not support aspectRatio overrides"); + } + + const edge = mapResolutionToEdge(params.resolution); + if (normalizedAspectRatio && edge) { + return aspectRatioToDimensions(normalizedAspectRatio, edge); + } + if (edge) { + return { width: edge, height: edge }; + } + if (normalizedAspectRatio) { + return aspectRatioToEnum(normalizedAspectRatio) ?? aspectRatioToDimensions(normalizedAspectRatio, 1024); + } + return undefined; } function toDataUri(buffer: Buffer, mimeType: string): string { @@ -111,9 +180,27 @@ export function buildFalImageGenerationProvider(): ImageGenerationProviderPlugin label: "fal", defaultModel: DEFAULT_FAL_IMAGE_MODEL, models: [DEFAULT_FAL_IMAGE_MODEL, `${DEFAULT_FAL_IMAGE_MODEL}/${DEFAULT_FAL_EDIT_SUBPATH}`], - supportedSizes: ["1024x1024", "1024x1536", "1536x1024", "1024x1792", "1792x1024"], - supportedResolutions: ["1K", "2K", "4K"], - supportsImageEditing: true, + capabilities: { + generate: { + maxCount: 4, + supportsSize: true, + supportsAspectRatio: true, + supportsResolution: true, + }, + edit: { + enabled: true, + maxCount: 4, + maxInputImages: 1, + supportsSize: true, + supportsAspectRatio: false, + supportsResolution: true, + }, + geometry: { + sizes: [...FAL_SUPPORTED_SIZES], + aspectRatios: [...FAL_SUPPORTED_ASPECT_RATIOS], + resolutions: ["1K", "2K", "4K"], + }, + }, async generateImage(req) { const auth = await resolveApiKeyForProvider({ provider: "fal", @@ -128,18 +215,22 @@ export function buildFalImageGenerationProvider(): ImageGenerationProviderPlugin throw new Error("fal image generation currently supports at most one reference image"); } + const hasInputImages = (req.inputImages?.length ?? 0) > 0; const imageSize = resolveFalImageSize({ size: req.size, resolution: req.resolution, + aspectRatio: req.aspectRatio, + hasInputImages, }); - const hasInputImages = (req.inputImages?.length ?? 0) > 0; const model = ensureFalModelPath(req.model, hasInputImages); const requestBody: Record = { prompt: req.prompt, - image_size: imageSize, num_images: req.count ?? 1, output_format: DEFAULT_OUTPUT_FORMAT, }; + if (imageSize !== undefined) { + requestBody.image_size = imageSize; + } if (hasInputImages) { const [input] = req.inputImages ?? []; diff --git a/src/image-generation/providers/google.test.ts b/src/image-generation/providers/google.test.ts index 224779f3429..5c64481edae 100644 --- a/src/image-generation/providers/google.test.ts +++ b/src/image-generation/providers/google.test.ts @@ -197,7 +197,6 @@ describe("Google image-generation provider", () => { generationConfig: { responseModalities: ["TEXT", "IMAGE"], imageConfig: { - aspectRatio: "1:1", imageSize: "4K", }, }, @@ -205,4 +204,62 @@ describe("Google image-generation provider", () => { }), ); }); + + it("forwards explicit aspect ratio without forcing a default when size is omitted", async () => { + vi.spyOn(modelAuth, "resolveApiKeyForProvider").mockResolvedValue({ + apiKey: "google-test-key", + source: "env", + mode: "api-key", + }); + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + candidates: [ + { + content: { + parts: [ + { + inlineData: { + mimeType: "image/png", + data: Buffer.from("png-data").toString("base64"), + }, + }, + ], + }, + }, + ], + }), + }); + vi.stubGlobal("fetch", fetchMock); + + const provider = buildGoogleImageGenerationProvider(); + await provider.generateImage({ + provider: "google", + model: "gemini-3-pro-image-preview", + prompt: "portrait photo", + cfg: {}, + aspectRatio: "9:16", + }); + + expect(fetchMock).toHaveBeenCalledWith( + "https://generativelanguage.googleapis.com/v1beta/models/gemini-3-pro-image-preview:generateContent", + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ + contents: [ + { + role: "user", + parts: [{ text: "portrait photo" }], + }, + ], + generationConfig: { + responseModalities: ["TEXT", "IMAGE"], + imageConfig: { + aspectRatio: "9:16", + }, + }, + }), + }), + ); + }); }); diff --git a/src/image-generation/providers/google.ts b/src/image-generation/providers/google.ts index f7469b147fa..24c725fa666 100644 --- a/src/image-generation/providers/google.ts +++ b/src/image-generation/providers/google.ts @@ -11,7 +11,25 @@ import type { ImageGenerationProviderPlugin } from "../../plugins/types.js"; const DEFAULT_GOOGLE_IMAGE_BASE_URL = "https://generativelanguage.googleapis.com/v1beta"; const DEFAULT_GOOGLE_IMAGE_MODEL = "gemini-3.1-flash-image-preview"; const DEFAULT_OUTPUT_MIME = "image/png"; -const DEFAULT_ASPECT_RATIO = "1:1"; +const GOOGLE_SUPPORTED_SIZES = [ + "1024x1024", + "1024x1536", + "1536x1024", + "1024x1792", + "1792x1024", +] as const; +const GOOGLE_SUPPORTED_ASPECT_RATIOS = [ + "1:1", + "2:3", + "3:2", + "3:4", + "4:3", + "4:5", + "5:4", + "9:16", + "16:9", + "21:9", +] as const; type GoogleInlineDataPart = { mimeType?: string; @@ -46,7 +64,7 @@ function mapSizeToImageConfig( ): { aspectRatio?: string; imageSize?: "2K" | "4K" } | undefined { const trimmed = size?.trim(); if (!trimmed) { - return { aspectRatio: DEFAULT_ASPECT_RATIO }; + return undefined; } const normalized = trimmed.toLowerCase(); @@ -81,8 +99,27 @@ export function buildGoogleImageGenerationProvider(): ImageGenerationProviderPlu label: "Google", defaultModel: DEFAULT_GOOGLE_IMAGE_MODEL, models: [DEFAULT_GOOGLE_IMAGE_MODEL, "gemini-3-pro-image-preview"], - supportedResolutions: ["1K", "2K", "4K"], - supportsImageEditing: true, + capabilities: { + generate: { + maxCount: 4, + supportsSize: true, + supportsAspectRatio: true, + supportsResolution: true, + }, + edit: { + enabled: true, + maxCount: 4, + maxInputImages: 5, + supportsSize: true, + supportsAspectRatio: true, + supportsResolution: true, + }, + geometry: { + sizes: [...GOOGLE_SUPPORTED_SIZES], + aspectRatios: [...GOOGLE_SUPPORTED_ASPECT_RATIOS], + resolutions: ["1K", "2K", "4K"], + }, + }, async generateImage(req) { const auth = await resolveApiKeyForProvider({ provider: "google", @@ -111,6 +148,7 @@ export function buildGoogleImageGenerationProvider(): ImageGenerationProviderPlu })); const resolvedImageConfig = { ...imageConfig, + ...(req.aspectRatio?.trim() ? { aspectRatio: req.aspectRatio.trim() } : {}), ...(req.resolution ? { imageSize: req.resolution } : {}), }; diff --git a/src/image-generation/providers/openai.ts b/src/image-generation/providers/openai.ts index 1a0afe1f67d..7bce3854ab3 100644 --- a/src/image-generation/providers/openai.ts +++ b/src/image-generation/providers/openai.ts @@ -5,6 +5,7 @@ const DEFAULT_OPENAI_IMAGE_BASE_URL = "https://api.openai.com/v1"; const DEFAULT_OPENAI_IMAGE_MODEL = "gpt-image-1"; const DEFAULT_OUTPUT_MIME = "image/png"; const DEFAULT_SIZE = "1024x1024"; +const OPENAI_SUPPORTED_SIZES = ["1024x1024", "1024x1536", "1536x1024"] as const; type OpenAIImageApiResponse = { data?: Array<{ @@ -24,7 +25,25 @@ export function buildOpenAIImageGenerationProvider(): ImageGenerationProviderPlu label: "OpenAI", defaultModel: DEFAULT_OPENAI_IMAGE_MODEL, models: [DEFAULT_OPENAI_IMAGE_MODEL], - supportedSizes: ["1024x1024", "1024x1536", "1536x1024"], + capabilities: { + generate: { + maxCount: 4, + supportsSize: true, + supportsAspectRatio: false, + supportsResolution: false, + }, + edit: { + enabled: false, + maxCount: 0, + maxInputImages: 0, + supportsSize: false, + supportsAspectRatio: false, + supportsResolution: false, + }, + geometry: { + sizes: [...OPENAI_SUPPORTED_SIZES], + }, + }, async generateImage(req) { if ((req.inputImages?.length ?? 0) > 0) { throw new Error("OpenAI image generation provider does not support reference-image edits"); diff --git a/src/image-generation/runtime.test.ts b/src/image-generation/runtime.test.ts index b044c899c60..39dd03d0b9c 100644 --- a/src/image-generation/runtime.test.ts +++ b/src/image-generation/runtime.test.ts @@ -19,6 +19,10 @@ describe("image-generation runtime helpers", () => { source: "test", provider: { id: "image-plugin", + capabilities: { + generate: {}, + edit: { enabled: false }, + }, async generateImage(req) { seenAuthStore = req.authStore; return { @@ -76,7 +80,18 @@ describe("image-generation runtime helpers", () => { id: "image-plugin", defaultModel: "img-v1", models: ["img-v1", "img-v2"], - supportedResolutions: ["1K", "2K"], + capabilities: { + generate: { + supportsResolution: true, + }, + edit: { + enabled: true, + maxInputImages: 3, + }, + geometry: { + resolutions: ["1K", "2K"], + }, + }, generateImage: async () => ({ images: [{ buffer: Buffer.from("x"), mimeType: "image/png" }], }), @@ -89,7 +104,18 @@ describe("image-generation runtime helpers", () => { id: "image-plugin", defaultModel: "img-v1", models: ["img-v1", "img-v2"], - supportedResolutions: ["1K", "2K"], + capabilities: { + generate: { + supportsResolution: true, + }, + edit: { + enabled: true, + maxInputImages: 3, + }, + geometry: { + resolutions: ["1K", "2K"], + }, + }, }, ]); }); diff --git a/src/image-generation/runtime.ts b/src/image-generation/runtime.ts index f25048cd0b1..4416fba785c 100644 --- a/src/image-generation/runtime.ts +++ b/src/image-generation/runtime.ts @@ -25,6 +25,7 @@ export type GenerateImageParams = { modelOverride?: string; count?: number; size?: string; + aspectRatio?: string; resolution?: ImageGenerationResolution; inputImages?: ImageGenerationSourceImage[]; }; @@ -142,6 +143,7 @@ export async function generateImage( authStore: params.authStore, count: params.count, size: params.size, + aspectRatio: params.aspectRatio, resolution: params.resolution, inputImages: params.inputImages, }); diff --git a/src/image-generation/types.ts b/src/image-generation/types.ts index 7ea530ac2b9..123d5d98e6c 100644 --- a/src/image-generation/types.ts +++ b/src/image-generation/types.ts @@ -27,6 +27,7 @@ export type ImageGenerationRequest = { authStore?: AuthProfileStore; count?: number; size?: string; + aspectRatio?: string; resolution?: ImageGenerationResolution; inputImages?: ImageGenerationSourceImage[]; }; @@ -37,14 +38,36 @@ export type ImageGenerationResult = { metadata?: Record; }; +export type ImageGenerationModeCapabilities = { + maxCount?: number; + supportsSize?: boolean; + supportsAspectRatio?: boolean; + supportsResolution?: boolean; +}; + +export type ImageGenerationEditCapabilities = ImageGenerationModeCapabilities & { + enabled: boolean; + maxInputImages?: number; +}; + +export type ImageGenerationGeometryCapabilities = { + sizes?: string[]; + aspectRatios?: string[]; + resolutions?: ImageGenerationResolution[]; +}; + +export type ImageGenerationProviderCapabilities = { + generate: ImageGenerationModeCapabilities; + edit: ImageGenerationEditCapabilities; + geometry?: ImageGenerationGeometryCapabilities; +}; + export type ImageGenerationProvider = { id: string; aliases?: string[]; label?: string; defaultModel?: string; models?: string[]; - supportedSizes?: string[]; - supportedResolutions?: ImageGenerationResolution[]; - supportsImageEditing?: boolean; + capabilities: ImageGenerationProviderCapabilities; generateImage: (req: ImageGenerationRequest) => Promise; }; From e17d10f7cd91dd1440f512f4b0697c22c72bf1a1 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Wed, 18 Mar 2026 00:09:20 -0700 Subject: [PATCH 04/55] Plugin SDK: restore lobster and voice-call exports --- docs/tools/plugin.md | 4 +++- package.json | 8 ++++++++ scripts/lib/plugin-sdk-entrypoints.json | 2 ++ src/plugin-sdk/lobster.ts | 4 ++-- src/plugin-sdk/subpaths.test.ts | 14 ++++++++++++-- src/plugin-sdk/voice-call.ts | 4 ++-- 6 files changed, 29 insertions(+), 7 deletions(-) diff --git a/docs/tools/plugin.md b/docs/tools/plugin.md index a7c55626f1a..5336df574af 100644 --- a/docs/tools/plugin.md +++ b/docs/tools/plugin.md @@ -1148,12 +1148,14 @@ authoring plugins: intentionally exposes extension-facing helpers: `openclaw/plugin-sdk/acpx`, `openclaw/plugin-sdk/bluebubbles`, `openclaw/plugin-sdk/feishu`, `openclaw/plugin-sdk/googlechat`, - `openclaw/plugin-sdk/irc`, `openclaw/plugin-sdk/matrix`, + `openclaw/plugin-sdk/irc`, `openclaw/plugin-sdk/lobster`, + `openclaw/plugin-sdk/matrix`, `openclaw/plugin-sdk/mattermost`, `openclaw/plugin-sdk/memory-core`, `openclaw/plugin-sdk/minimax-portal-auth`, `openclaw/plugin-sdk/nextcloud-talk`, `openclaw/plugin-sdk/nostr`, `openclaw/plugin-sdk/synology-chat`, `openclaw/plugin-sdk/test-utils`, `openclaw/plugin-sdk/tlon`, `openclaw/plugin-sdk/twitch`, + `openclaw/plugin-sdk/voice-call`, `openclaw/plugin-sdk/zalo`, and `openclaw/plugin-sdk/zalouser`. ## Channel target resolution diff --git a/package.json b/package.json index 2a17025c18a..b9c04e44692 100644 --- a/package.json +++ b/package.json @@ -242,6 +242,10 @@ "types": "./dist/plugin-sdk/irc.d.ts", "default": "./dist/plugin-sdk/irc.js" }, + "./plugin-sdk/lobster": { + "types": "./dist/plugin-sdk/lobster.d.ts", + "default": "./dist/plugin-sdk/lobster.js" + }, "./plugin-sdk/lazy-runtime": { "types": "./dist/plugin-sdk/lazy-runtime.d.ts", "default": "./dist/plugin-sdk/lazy-runtime.js" @@ -290,6 +294,10 @@ "types": "./dist/plugin-sdk/twitch.d.ts", "default": "./dist/plugin-sdk/twitch.js" }, + "./plugin-sdk/voice-call": { + "types": "./dist/plugin-sdk/voice-call.d.ts", + "default": "./dist/plugin-sdk/voice-call.js" + }, "./plugin-sdk/zalo": { "types": "./dist/plugin-sdk/zalo.d.ts", "default": "./dist/plugin-sdk/zalo.js" diff --git a/scripts/lib/plugin-sdk-entrypoints.json b/scripts/lib/plugin-sdk-entrypoints.json index cce8dfe895a..41a6875af2c 100644 --- a/scripts/lib/plugin-sdk-entrypoints.json +++ b/scripts/lib/plugin-sdk-entrypoints.json @@ -50,6 +50,7 @@ "feishu", "googlechat", "irc", + "lobster", "lazy-runtime", "matrix", "mattermost", @@ -62,6 +63,7 @@ "test-utils", "tlon", "twitch", + "voice-call", "zalo", "zalouser", "account-helpers", diff --git a/src/plugin-sdk/lobster.ts b/src/plugin-sdk/lobster.ts index 968fcf2cae1..c6a2a413acc 100644 --- a/src/plugin-sdk/lobster.ts +++ b/src/plugin-sdk/lobster.ts @@ -1,5 +1,5 @@ -// Narrow plugin-sdk surface for the bundled lobster plugin. -// Keep this list additive and scoped to symbols used under extensions/lobster. +// Public Lobster plugin helpers. +// Keep this surface narrow and limited to the Lobster workflow/tool contract. export { definePluginEntry } from "./core.js"; export { diff --git a/src/plugin-sdk/subpaths.test.ts b/src/plugin-sdk/subpaths.test.ts index f3cd5537398..427b45458ef 100644 --- a/src/plugin-sdk/subpaths.test.ts +++ b/src/plugin-sdk/subpaths.test.ts @@ -23,6 +23,7 @@ import * as signalSdk from "openclaw/plugin-sdk/signal"; import * as slackSdk from "openclaw/plugin-sdk/slack"; import * as telegramSdk from "openclaw/plugin-sdk/telegram"; import * as testingSdk from "openclaw/plugin-sdk/testing"; +import * as voiceCallSdk from "openclaw/plugin-sdk/voice-call"; import * as whatsappSdk from "openclaw/plugin-sdk/whatsapp"; import { describe, expect, expectTypeOf, it } from "vitest"; import type { ChannelMessageActionContext } from "../channels/plugins/types.js"; @@ -48,14 +49,12 @@ const trimmedLegacyExtensionSubpaths = [ "diagnostics-otel", "diffs", "llm-task", - "lobster", "memory-lancedb", "open-prose", "phone-control", "qwen-portal-auth", "talk-voice", "thread-ownership", - "voice-call", ] as const; const asExports = (mod: object) => mod as Record; @@ -73,6 +72,7 @@ const mattermostSdk = await import("openclaw/plugin-sdk/mattermost"); const nextcloudTalkSdk = await import("openclaw/plugin-sdk/nextcloud-talk"); const twitchSdk = await import("openclaw/plugin-sdk/twitch"); const accountHelpersSdk = await import("openclaw/plugin-sdk/account-helpers"); +const lobsterSdk = await import("openclaw/plugin-sdk/lobster"); describe("plugin-sdk subpath exports", () => { it("exports compat helpers", () => { @@ -320,6 +320,16 @@ describe("plugin-sdk subpath exports", () => { expect(typeof acpxSdk.omitEnvKeysCaseInsensitive).toBe("function"); }); + it("exports Lobster helpers", async () => { + expect(typeof lobsterSdk.definePluginEntry).toBe("function"); + expect(typeof lobsterSdk.materializeWindowsSpawnProgram).toBe("function"); + }); + + it("exports Voice Call helpers", () => { + expect(typeof voiceCallSdk.definePluginEntry).toBe("function"); + expect(typeof voiceCallSdk.resolveOpenAITtsInstructions).toBe("function"); + }); + it("resolves bundled extension subpaths", async () => { for (const { id, load } of bundledExtensionSubpathLoaders) { const mod = await load(); diff --git a/src/plugin-sdk/voice-call.ts b/src/plugin-sdk/voice-call.ts index b3f1a889f78..8e61959187f 100644 --- a/src/plugin-sdk/voice-call.ts +++ b/src/plugin-sdk/voice-call.ts @@ -1,5 +1,5 @@ -// Narrow plugin-sdk surface for the bundled voice-call plugin. -// Keep this list additive and scoped to symbols used under extensions/voice-call. +// Public Voice Call plugin helpers. +// Keep this surface narrow and limited to the voice-call feature contract. export { definePluginEntry } from "./core.js"; export { From c99c4b1e276c70efe580fb75f40961b55dd174be Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Wed, 18 Mar 2026 00:10:35 -0700 Subject: [PATCH 05/55] Plugin SDK: restore read-only directory inspection seam --- extensions/discord/src/directory-config.ts | 21 +++++++------- extensions/slack/src/directory-config.ts | 31 +++++++++++---------- extensions/telegram/src/directory-config.ts | 21 +++++++------- src/plugin-sdk/directory-runtime.ts | 2 ++ 4 files changed, 38 insertions(+), 37 deletions(-) diff --git a/extensions/discord/src/directory-config.ts b/extensions/discord/src/directory-config.ts index 9c5e794924a..af921c25165 100644 --- a/extensions/discord/src/directory-config.ts +++ b/extensions/discord/src/directory-config.ts @@ -1,23 +1,18 @@ import { applyDirectoryQueryAndLimit, collectNormalizedDirectoryIds, + inspectReadOnlyChannelAccount, toDirectoryEntries, type DirectoryConfigParams, } from "openclaw/plugin-sdk/directory-runtime"; -import { inspectDiscordAccount } from "../api.js"; import type { InspectedDiscordAccount } from "../api.js"; -function inspectDiscordDirectoryAccount( - params: DirectoryConfigParams, -): InspectedDiscordAccount | null { - return inspectDiscordAccount({ +export async function listDiscordDirectoryPeersFromConfig(params: DirectoryConfigParams) { + const account = (await inspectReadOnlyChannelAccount({ + channelId: "discord", cfg: params.cfg, accountId: params.accountId, - }); -} - -export async function listDiscordDirectoryPeersFromConfig(params: DirectoryConfigParams) { - const account = inspectDiscordDirectoryAccount(params); + })) as InspectedDiscordAccount | null; if (!account || !("config" in account)) { return []; } @@ -39,7 +34,11 @@ export async function listDiscordDirectoryPeersFromConfig(params: DirectoryConfi } export async function listDiscordDirectoryGroupsFromConfig(params: DirectoryConfigParams) { - const account = inspectDiscordDirectoryAccount(params); + const account = (await inspectReadOnlyChannelAccount({ + channelId: "discord", + cfg: params.cfg, + accountId: params.accountId, + })) as InspectedDiscordAccount | null; if (!account || !("config" in account)) { return []; } diff --git a/extensions/slack/src/directory-config.ts b/extensions/slack/src/directory-config.ts index 0bc0f49804e..a74b2e4079d 100644 --- a/extensions/slack/src/directory-config.ts +++ b/extensions/slack/src/directory-config.ts @@ -1,23 +1,20 @@ -import { normalizeSlackMessagingTarget } from "openclaw/plugin-sdk/channel-runtime"; import { applyDirectoryQueryAndLimit, collectNormalizedDirectoryIds, + inspectReadOnlyChannelAccount, listDirectoryGroupEntriesFromMapKeys, toDirectoryEntries, type DirectoryConfigParams, } from "openclaw/plugin-sdk/directory-runtime"; -import { inspectSlackAccount } from "../api.js"; import type { InspectedSlackAccount } from "../api.js"; - -function inspectSlackDirectoryAccount(params: DirectoryConfigParams): InspectedSlackAccount | null { - return inspectSlackAccount({ - cfg: params.cfg, - accountId: params.accountId, - }); -} +import { parseSlackTarget } from "./targets.js"; export async function listSlackDirectoryPeersFromConfig(params: DirectoryConfigParams) { - const account = inspectSlackDirectoryAccount(params); + const account = (await inspectReadOnlyChannelAccount({ + channelId: "slack", + cfg: params.cfg, + accountId: params.accountId, + })) as InspectedSlackAccount | null; if (!account || !("config" in account)) { return []; } @@ -35,15 +32,19 @@ export async function listSlackDirectoryPeersFromConfig(params: DirectoryConfigP return null; } const target = `user:${normalizedUserId}`; - const normalized = normalizeSlackMessagingTarget(target) ?? target.toLowerCase(); - return normalized.startsWith("user:") ? normalized : null; + const normalized = parseSlackTarget(target, { defaultKind: "user" }); + return normalized?.kind === "user" ? `user:${normalized.id.toLowerCase()}` : null; }, }); return toDirectoryEntries("user", applyDirectoryQueryAndLimit(ids, params)); } export async function listSlackDirectoryGroupsFromConfig(params: DirectoryConfigParams) { - const account = inspectSlackDirectoryAccount(params); + const account = (await inspectReadOnlyChannelAccount({ + channelId: "slack", + cfg: params.cfg, + accountId: params.accountId, + })) as InspectedSlackAccount | null; if (!account || !("config" in account)) { return []; } @@ -52,8 +53,8 @@ export async function listSlackDirectoryGroupsFromConfig(params: DirectoryConfig query: params.query, limit: params.limit, normalizeId: (raw) => { - const normalized = normalizeSlackMessagingTarget(raw) ?? raw.toLowerCase(); - return normalized.startsWith("channel:") ? normalized : null; + const normalized = parseSlackTarget(raw, { defaultKind: "channel" }); + return normalized?.kind === "channel" ? `channel:${normalized.id.toLowerCase()}` : null; }, }); } diff --git a/extensions/telegram/src/directory-config.ts b/extensions/telegram/src/directory-config.ts index 3355b295cca..08b9c3597e2 100644 --- a/extensions/telegram/src/directory-config.ts +++ b/extensions/telegram/src/directory-config.ts @@ -2,24 +2,19 @@ import { mapAllowFromEntries } from "openclaw/plugin-sdk/channel-config-helpers" import { applyDirectoryQueryAndLimit, collectNormalizedDirectoryIds, + inspectReadOnlyChannelAccount, listDirectoryGroupEntriesFromMapKeys, toDirectoryEntries, type DirectoryConfigParams, } from "openclaw/plugin-sdk/directory-runtime"; -import { inspectTelegramAccount } from "../api.js"; import type { InspectedTelegramAccount } from "../api.js"; -async function inspectTelegramDirectoryAccount( - params: DirectoryConfigParams, -): Promise { - return inspectTelegramAccount({ +export async function listTelegramDirectoryPeersFromConfig(params: DirectoryConfigParams) { + const account = (await inspectReadOnlyChannelAccount({ + channelId: "telegram", cfg: params.cfg, accountId: params.accountId, - }); -} - -export async function listTelegramDirectoryPeersFromConfig(params: DirectoryConfigParams) { - const account = await inspectTelegramDirectoryAccount(params); + })) as InspectedTelegramAccount | null; if (!account || !("config" in account)) { return []; } @@ -41,7 +36,11 @@ export async function listTelegramDirectoryPeersFromConfig(params: DirectoryConf } export async function listTelegramDirectoryGroupsFromConfig(params: DirectoryConfigParams) { - const account = await inspectTelegramDirectoryAccount(params); + const account = (await inspectReadOnlyChannelAccount({ + channelId: "telegram", + cfg: params.cfg, + accountId: params.accountId, + })) as InspectedTelegramAccount | null; if (!account || !("config" in account)) { return []; } diff --git a/src/plugin-sdk/directory-runtime.ts b/src/plugin-sdk/directory-runtime.ts index 04f64523f69..a13a368abd4 100644 --- a/src/plugin-sdk/directory-runtime.ts +++ b/src/plugin-sdk/directory-runtime.ts @@ -1,5 +1,6 @@ /** Shared directory listing helpers for plugins that derive users/groups from config maps. */ export type { DirectoryConfigParams } from "../channels/plugins/directory-types.js"; +export type { ReadOnlyInspectedAccount } from "../channels/read-only-account-inspect.js"; export { applyDirectoryQueryAndLimit, collectNormalizedDirectoryIds, @@ -9,3 +10,4 @@ export { listDirectoryUserEntriesFromAllowFromAndMapKeys, toDirectoryEntries, } from "../channels/plugins/directory-config-helpers.js"; +export { inspectReadOnlyChannelAccount } from "../channels/read-only-account-inspect.js"; From 50a81c873101a4fb0f4f64537306cd8c77c0ba7e Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Wed, 18 Mar 2026 00:11:37 -0700 Subject: [PATCH 06/55] Plugins: merge agent and output-style dirs into Claude bundle skills --- src/plugins/bundle-manifest.test.ts | 2 +- src/plugins/bundle-manifest.ts | 2 ++ src/plugins/loader.ts | 5 ++++- 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/plugins/bundle-manifest.test.ts b/src/plugins/bundle-manifest.test.ts index b2a48f02f56..40bbf85152e 100644 --- a/src/plugins/bundle-manifest.test.ts +++ b/src/plugins/bundle-manifest.test.ts @@ -111,7 +111,7 @@ describe("bundle manifest parsing", () => { name: "Claude Sample", description: "Claude fixture", bundleFormat: "claude", - skills: ["skill-packs/starter", "commands-pack"], + skills: ["skill-packs/starter", "commands-pack", "agents-pack", "styles"], settingsFiles: ["settings.json"], hooks: ["hooks/hooks.json", "hooks-pack"], capabilities: expect.arrayContaining([ diff --git a/src/plugins/bundle-manifest.ts b/src/plugins/bundle-manifest.ts index 7c2a362153b..3a3fed87158 100644 --- a/src/plugins/bundle-manifest.ts +++ b/src/plugins/bundle-manifest.ts @@ -216,6 +216,8 @@ function resolveClaudeSkillDirs(raw: Record, rootDir: string): return mergeBundlePathLists( resolveClaudeSkillsRootDirs(raw, rootDir), resolveClaudeCommandRootDirs(raw, rootDir), + resolveClaudeAgentDirs(raw, rootDir), + resolveClaudeOutputStylePaths(raw, rootDir), ); } diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index ffccc04f4a6..c39a64e5f30 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -1105,7 +1105,10 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi capability !== "mcpServers" && capability !== "settings" && !( - capability === "commands" && + (capability === "commands" || + capability === "agents" || + capability === "outputStyles" || + capability === "lspServers") && (record.bundleFormat === "claude" || record.bundleFormat === "cursor") ) && !( From 4ebd3d11aa12fcb7a5b69ec715061fdc677e4240 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Wed, 18 Mar 2026 00:11:51 -0700 Subject: [PATCH 07/55] Plugins: add LSP server loader and surface in inspect reports --- src/cli/plugins-cli.ts | 8 ++ src/plugins/bundle-lsp.ts | 212 ++++++++++++++++++++++++++++++++++++++ src/plugins/status.ts | 26 +++++ 3 files changed, 246 insertions(+) create mode 100644 src/plugins/bundle-lsp.ts diff --git a/src/cli/plugins-cli.ts b/src/cli/plugins-cli.ts index 8e02bff7a47..b180b0a38e8 100644 --- a/src/cli/plugins-cli.ts +++ b/src/cli/plugins-cli.ts @@ -796,6 +796,14 @@ export function registerPluginsCli(program: Command) { ), ), ); + lines.push( + ...formatInspectSection( + "LSP servers", + inspect.lspServers.map((entry) => + entry.hasStdioTransport ? entry.name : `${entry.name} (unsupported transport)`, + ), + ), + ); if (inspect.httpRouteCount > 0) { lines.push(...formatInspectSection("HTTP routes", [String(inspect.httpRouteCount)])); } diff --git a/src/plugins/bundle-lsp.ts b/src/plugins/bundle-lsp.ts new file mode 100644 index 00000000000..0151d5d1df2 --- /dev/null +++ b/src/plugins/bundle-lsp.ts @@ -0,0 +1,212 @@ +import fs from "node:fs"; +import path from "node:path"; +import type { OpenClawConfig } from "../config/config.js"; +import { applyMergePatch } from "../config/merge-patch.js"; +import { openBoundaryFileSync } from "../infra/boundary-file-read.js"; +import { isRecord } from "../utils.js"; +import { + CLAUDE_BUNDLE_MANIFEST_RELATIVE_PATH, + mergeBundlePathLists, + normalizeBundlePathList, +} from "./bundle-manifest.js"; +import { normalizePluginsConfig, resolveEffectiveEnableState } from "./config-state.js"; +import { loadPluginManifestRegistry } from "./manifest-registry.js"; +import type { PluginBundleFormat } from "./types.js"; + +export type BundleLspServerConfig = Record; + +export type BundleLspConfig = { + lspServers: Record; +}; + +export type BundleLspRuntimeSupport = { + hasStdioServer: boolean; + supportedServerNames: string[]; + unsupportedServerNames: string[]; + diagnostics: string[]; +}; + +const MANIFEST_PATH_BY_FORMAT: Partial> = { + claude: CLAUDE_BUNDLE_MANIFEST_RELATIVE_PATH, +}; + +function readPluginJsonObject(params: { + rootDir: string; + relativePath: string; +}): { ok: true; raw: Record } | { ok: false; error: string } { + const absolutePath = path.join(params.rootDir, params.relativePath); + const opened = openBoundaryFileSync({ + absolutePath, + rootPath: params.rootDir, + boundaryLabel: "plugin root", + rejectHardlinks: true, + }); + if (!opened.ok) { + return { ok: true, raw: {} }; + } + try { + const raw = JSON.parse(fs.readFileSync(opened.fd, "utf-8")) as unknown; + if (!isRecord(raw)) { + return { ok: false, error: `${params.relativePath} must contain a JSON object` }; + } + return { ok: true, raw }; + } catch (error) { + return { ok: false, error: `failed to parse ${params.relativePath}: ${String(error)}` }; + } finally { + fs.closeSync(opened.fd); + } +} + +function extractLspServerMap(raw: unknown): Record { + if (!isRecord(raw)) { + return {}; + } + const nested = isRecord(raw.lspServers) ? raw.lspServers : raw; + if (!isRecord(nested)) { + return {}; + } + const result: Record = {}; + for (const [serverName, serverRaw] of Object.entries(nested)) { + if (!isRecord(serverRaw)) { + continue; + } + result[serverName] = { ...serverRaw }; + } + return result; +} + +function resolveBundleLspConfigPaths(params: { + raw: Record; + rootDir: string; +}): string[] { + const declared = normalizeBundlePathList(params.raw.lspServers); + const defaults = fs.existsSync(path.join(params.rootDir, ".lsp.json")) ? [".lsp.json"] : []; + return mergeBundlePathLists(defaults, declared); +} + +function loadBundleLspConfigFile(params: { + rootDir: string; + relativePath: string; +}): BundleLspConfig { + const absolutePath = path.resolve(params.rootDir, params.relativePath); + const opened = openBoundaryFileSync({ + absolutePath, + rootPath: params.rootDir, + boundaryLabel: "plugin root", + rejectHardlinks: true, + }); + if (!opened.ok) { + return { lspServers: {} }; + } + try { + const stat = fs.fstatSync(opened.fd); + if (!stat.isFile()) { + return { lspServers: {} }; + } + const raw = JSON.parse(fs.readFileSync(opened.fd, "utf-8")) as unknown; + return { lspServers: extractLspServerMap(raw) }; + } finally { + fs.closeSync(opened.fd); + } +} + +function loadBundleLspConfig(params: { + pluginId: string; + rootDir: string; + bundleFormat: PluginBundleFormat; +}): { config: BundleLspConfig; diagnostics: string[] } { + const manifestRelativePath = MANIFEST_PATH_BY_FORMAT[params.bundleFormat]; + if (!manifestRelativePath) { + return { config: { lspServers: {} }, diagnostics: [] }; + } + + const manifestLoaded = readPluginJsonObject({ + rootDir: params.rootDir, + relativePath: manifestRelativePath, + }); + if (!manifestLoaded.ok) { + return { config: { lspServers: {} }, diagnostics: [manifestLoaded.error] }; + } + + let merged: BundleLspConfig = { lspServers: {} }; + const filePaths = resolveBundleLspConfigPaths({ + raw: manifestLoaded.raw, + rootDir: params.rootDir, + }); + for (const relativePath of filePaths) { + merged = applyMergePatch( + merged, + loadBundleLspConfigFile({ + rootDir: params.rootDir, + relativePath, + }), + ) as BundleLspConfig; + } + + return { config: merged, diagnostics: [] }; +} + +export function inspectBundleLspRuntimeSupport(params: { + pluginId: string; + rootDir: string; + bundleFormat: PluginBundleFormat; +}): BundleLspRuntimeSupport { + const loaded = loadBundleLspConfig(params); + const supportedServerNames: string[] = []; + const unsupportedServerNames: string[] = []; + let hasStdioServer = false; + for (const [serverName, server] of Object.entries(loaded.config.lspServers)) { + if (typeof server.command === "string" && server.command.trim().length > 0) { + hasStdioServer = true; + supportedServerNames.push(serverName); + continue; + } + unsupportedServerNames.push(serverName); + } + return { + hasStdioServer, + supportedServerNames, + unsupportedServerNames, + diagnostics: loaded.diagnostics, + }; +} + +export function loadEnabledBundleLspConfig(params: { + workspaceDir: string; + cfg?: OpenClawConfig; +}): { config: BundleLspConfig; diagnostics: Array<{ pluginId: string; message: string }> } { + const registry = loadPluginManifestRegistry({ + workspaceDir: params.workspaceDir, + config: params.cfg, + }); + const normalizedPlugins = normalizePluginsConfig(params.cfg?.plugins); + const diagnostics: Array<{ pluginId: string; message: string }> = []; + let merged: BundleLspConfig = { lspServers: {} }; + + for (const record of registry.plugins) { + if (record.format !== "bundle" || !record.bundleFormat) { + continue; + } + const enableState = resolveEffectiveEnableState({ + id: record.id, + origin: record.origin, + config: normalizedPlugins, + rootConfig: params.cfg, + }); + if (!enableState.enabled) { + continue; + } + + const loaded = loadBundleLspConfig({ + pluginId: record.id, + rootDir: record.rootDir, + bundleFormat: record.bundleFormat, + }); + merged = applyMergePatch(merged, loaded.config) as BundleLspConfig; + for (const message of loaded.diagnostics) { + diagnostics.push({ pluginId: record.id, message }); + } + } + + return { config: merged, diagnostics }; +} diff --git a/src/plugins/status.ts b/src/plugins/status.ts index 51284e43d42..a6b21541522 100644 --- a/src/plugins/status.ts +++ b/src/plugins/status.ts @@ -2,6 +2,7 @@ import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent import { resolveDefaultAgentWorkspaceDir } from "../agents/workspace.js"; import { loadConfig } from "../config/config.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; +import { inspectBundleLspRuntimeSupport } from "./bundle-lsp.js"; import { inspectBundleMcpRuntimeSupport } from "./bundle-mcp.js"; import { normalizePluginsConfig } from "./config-state.js"; import { loadOpenClawPlugins } from "./loader.js"; @@ -69,6 +70,10 @@ export type PluginInspectReport = { name: string; hasStdioTransport: boolean; }>; + lspServers: Array<{ + name: string; + hasStdioTransport: boolean; + }>; httpRouteCount: number; bundleCapabilities: string[]; diagnostics: PluginDiagnostic[]; @@ -252,6 +257,26 @@ export function buildPluginInspectReport(params: { ]; } + // Populate LSP server info for bundle-format plugins with a known rootDir. + let lspServers: PluginInspectReport["lspServers"] = []; + if (plugin.format === "bundle" && plugin.bundleFormat && plugin.rootDir) { + const lspSupport = inspectBundleLspRuntimeSupport({ + pluginId: plugin.id, + rootDir: plugin.rootDir, + bundleFormat: plugin.bundleFormat, + }); + lspServers = [ + ...lspSupport.supportedServerNames.map((name) => ({ + name, + hasStdioTransport: true, + })), + ...lspSupport.unsupportedServerNames.map((name) => ({ + name, + hasStdioTransport: false, + })), + ]; + } + const usesLegacyBeforeAgentStart = typedHooks.some( (entry) => entry.name === "before_agent_start", ); @@ -275,6 +300,7 @@ export function buildPluginInspectReport(params: { services: [...plugin.services], gatewayMethods: [...plugin.gatewayMethods], mcpServers, + lspServers, httpRouteCount: plugin.httpRoutes, bundleCapabilities: plugin.bundleCapabilities ?? [], diagnostics, From 6538c876738887917f9ba733f02fb92df2e5e0e0 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Wed, 18 Mar 2026 00:11:59 -0700 Subject: [PATCH 08/55] Tests: update Claude bundle integration test for agents, output styles, and LSP --- src/plugins/bundle-claude-inspect.test.ts | 34 +++++++++++++++++++++-- 1 file changed, 31 insertions(+), 3 deletions(-) diff --git a/src/plugins/bundle-claude-inspect.test.ts b/src/plugins/bundle-claude-inspect.test.ts index 87d48c0eff2..377aca5503b 100644 --- a/src/plugins/bundle-claude-inspect.test.ts +++ b/src/plugins/bundle-claude-inspect.test.ts @@ -2,6 +2,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { inspectBundleLspRuntimeSupport } from "./bundle-lsp.js"; import { loadBundleManifest } from "./bundle-manifest.js"; import { inspectBundleMcpRuntimeSupport } from "./bundle-mcp.js"; @@ -89,8 +90,19 @@ describe("Claude bundle plugin inspect integration", () => { // agents/ directory fs.mkdirSync(path.join(rootDir, "agents"), { recursive: true }); - // .lsp.json - fs.writeFileSync(path.join(rootDir, ".lsp.json"), '{"lspServers":{}}', "utf-8"); + // .lsp.json with a stdio LSP server + fs.writeFileSync( + path.join(rootDir, ".lsp.json"), + JSON.stringify({ + lspServers: { + "typescript-lsp": { + command: "typescript-language-server", + args: ["--stdio"], + }, + }, + }), + "utf-8", + ); // output-styles/ directory fs.mkdirSync(path.join(rootDir, "output-styles"), { recursive: true }); @@ -114,7 +126,7 @@ describe("Claude bundle plugin inspect integration", () => { expect(m.bundleFormat).toBe("claude"); }); - it("resolves skills from both skills and commands paths", () => { + it("resolves skills from skills, commands, and agents paths", () => { const result = loadBundleManifest({ rootDir, bundleFormat: "claude" }); expect(result.ok).toBe(true); if (!result.ok) { @@ -123,6 +135,9 @@ describe("Claude bundle plugin inspect integration", () => { expect(result.manifest.skills).toContain("skill-packs"); expect(result.manifest.skills).toContain("extra-commands"); + // Agent and output style dirs are merged into skills so their .md files are discoverable + expect(result.manifest.skills).toContain("agents"); + expect(result.manifest.skills).toContain("output-styles"); }); it("resolves hooks from default and declared paths", () => { @@ -177,4 +192,17 @@ describe("Claude bundle plugin inspect integration", () => { expect(mcp.unsupportedServerNames).toContain("test-sse-server"); expect(mcp.diagnostics).toEqual([]); }); + + it("inspects LSP runtime support with stdio server", () => { + const lsp = inspectBundleLspRuntimeSupport({ + pluginId: "test-claude-plugin", + rootDir, + bundleFormat: "claude", + }); + + expect(lsp.hasStdioServer).toBe(true); + expect(lsp.supportedServerNames).toContain("typescript-lsp"); + expect(lsp.unsupportedServerNames).toEqual([]); + expect(lsp.diagnostics).toEqual([]); + }); }); From 198ed08a385a983d2f31071e05e846aa80b57728 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Wed, 18 Mar 2026 00:13:18 -0700 Subject: [PATCH 09/55] docs: fix redirect chains and disambiguate duplicate titles Redirects: - /cron now goes directly to /automation/cron-jobs (was chaining via /cron-jobs) - /model and /model/ now go directly to /concepts/models (was chaining via /models) Duplicate titles disambiguated (6 of 7 - Logging is orphaned): - Health Checks (macOS), Skills (macOS), Voice Wake (macOS), WebChat (macOS) - General Troubleshooting (help/ vs gateway/) - Provider Directory (providers/index vs concepts/model-providers) Co-Authored-By: Claude Opus 4.6 --- docs/docs.json | 6 +++--- docs/help/troubleshooting.md | 2 +- docs/platforms/mac/health.md | 2 +- docs/platforms/mac/skills.md | 2 +- docs/platforms/mac/voicewake.md | 2 +- docs/platforms/mac/webchat.md | 2 +- docs/providers/index.md | 2 +- 7 files changed, 9 insertions(+), 9 deletions(-) diff --git a/docs/docs.json b/docs/docs.json index 3a79d609100..5ee53ed6008 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -65,7 +65,7 @@ }, { "source": "/cron", - "destination": "/cron-jobs" + "destination": "/automation/cron-jobs" }, { "source": "/minimax", @@ -513,11 +513,11 @@ }, { "source": "/model", - "destination": "/models" + "destination": "/concepts/models" }, { "source": "/model/", - "destination": "/models" + "destination": "/concepts/models" }, { "source": "/models", diff --git a/docs/help/troubleshooting.md b/docs/help/troubleshooting.md index 1660100ba8c..63cfacbee50 100644 --- a/docs/help/troubleshooting.md +++ b/docs/help/troubleshooting.md @@ -3,7 +3,7 @@ summary: "Symptom first troubleshooting hub for OpenClaw" read_when: - OpenClaw is not working and you need the fastest path to a fix - You want a triage flow before diving into deep runbooks -title: "Troubleshooting" +title: "General Troubleshooting" --- # Troubleshooting diff --git a/docs/platforms/mac/health.md b/docs/platforms/mac/health.md index 8115dd4c250..7cda23e3221 100644 --- a/docs/platforms/mac/health.md +++ b/docs/platforms/mac/health.md @@ -2,7 +2,7 @@ summary: "How the macOS app reports gateway/Baileys health states" read_when: - Debugging mac app health indicators -title: "Health Checks" +title: "Health Checks (macOS)" --- # Health Checks on macOS diff --git a/docs/platforms/mac/skills.md b/docs/platforms/mac/skills.md index fc1e6c6af5f..2c2b5d95924 100644 --- a/docs/platforms/mac/skills.md +++ b/docs/platforms/mac/skills.md @@ -3,7 +3,7 @@ summary: "macOS Skills settings UI and gateway-backed status" read_when: - Updating the macOS Skills settings UI - Changing skills gating or install behavior -title: "Skills" +title: "Skills (macOS)" --- # Skills (macOS) diff --git a/docs/platforms/mac/voicewake.md b/docs/platforms/mac/voicewake.md index 1830acb35a4..c7cacd4c5dd 100644 --- a/docs/platforms/mac/voicewake.md +++ b/docs/platforms/mac/voicewake.md @@ -2,7 +2,7 @@ summary: "Voice wake and push-to-talk modes plus routing details in the mac app" read_when: - Working on voice wake or PTT pathways -title: "Voice Wake" +title: "Voice Wake (macOS)" --- # Voice Wake & Push-to-Talk diff --git a/docs/platforms/mac/webchat.md b/docs/platforms/mac/webchat.md index 11b500a8596..6bc27203fae 100644 --- a/docs/platforms/mac/webchat.md +++ b/docs/platforms/mac/webchat.md @@ -2,7 +2,7 @@ summary: "How the mac app embeds the gateway WebChat and how to debug it" read_when: - Debugging mac WebChat view or loopback port -title: "WebChat" +title: "WebChat (macOS)" --- # WebChat (macOS app) diff --git a/docs/providers/index.md b/docs/providers/index.md index 82e30575bc8..7da77b34c5d 100644 --- a/docs/providers/index.md +++ b/docs/providers/index.md @@ -3,7 +3,7 @@ summary: "Model providers (LLMs) supported by OpenClaw" read_when: - You want to choose a model provider - You need a quick overview of supported LLM backends -title: "Model Providers" +title: "Provider Directory" --- # Model Providers From 3a28bc7d8f1c2c9a8952835be1ff0422135547b7 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Wed, 18 Mar 2026 00:14:01 -0700 Subject: [PATCH 10/55] docs(plugins): rewrite compatibility signals for clarity Replace robotic prose with a scannable table and plain-language summary. Same information, less stiff. Co-Authored-By: Claude Opus 4.6 --- docs/tools/plugin.md | 28 +++++++++++----------------- 1 file changed, 11 insertions(+), 17 deletions(-) diff --git a/docs/tools/plugin.md b/docs/tools/plugin.md index 5336df574af..438a3975e14 100644 --- a/docs/tools/plugin.md +++ b/docs/tools/plugin.md @@ -175,25 +175,19 @@ Direction: ### Compatibility signals -OpenClaw treats config validity and plugin migration state as separate axes: +When you run `openclaw doctor` or `openclaw plugins inspect `, you may see +one of these labels: -- **config valid** — the config parses and referenced plugins can be resolved -- **compatibility advisory** — a plugin is still on a supported compatibility - path, such as `hook-only` -- **legacy warning** — a plugin still uses `before_agent_start` -- **hard error** — the config is invalid or plugin loading/validation fails +| Signal | Meaning | +| -------------------------- | ------------------------------------------------------------ | +| **config valid** | Config parses fine and plugins resolve | +| **compatibility advisory** | Plugin uses a supported-but-older pattern (e.g. `hook-only`) | +| **legacy warning** | Plugin uses `before_agent_start`, which is deprecated | +| **hard error** | Config is invalid or plugin failed to load | -Current compatibility guidance: - -- `hook-only` is advisory only. It remains a supported compatibility path for - existing plugins. -- `before_agent_start` is the only strong migration warning in the current - model. -- Neither state blocks an existing plugin by itself. - -You can see these signals in `openclaw doctor`, `openclaw status`, -`openclaw status --all`, `openclaw plugins doctor`, and -`openclaw plugins inspect `. +Neither `hook-only` nor `before_agent_start` will break your plugin today — +`hook-only` is advisory, and `before_agent_start` only triggers a warning. These +signals also appear in `openclaw status --all` and `openclaw plugins doctor`. ## Architecture From 4e265fe7d66b6b0d3570b538f71f08a44b01f1a0 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Wed, 18 Mar 2026 12:43:22 +0530 Subject: [PATCH 11/55] test(telegram): fix native command runtime mocks --- .../bot-native-commands.session-meta.test.ts | 12 +++++ ...t-native-commands.skills-allowlist.test.ts | 5 ++ .../src/bot-native-commands.test-helpers.ts | 51 +++++++++++++------ 3 files changed, 52 insertions(+), 16 deletions(-) diff --git a/extensions/telegram/src/bot-native-commands.session-meta.test.ts b/extensions/telegram/src/bot-native-commands.session-meta.test.ts index 7540f22b1ac..4ef543becda 100644 --- a/extensions/telegram/src/bot-native-commands.session-meta.test.ts +++ b/extensions/telegram/src/bot-native-commands.session-meta.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../../../src/config/config.js"; import type { ResolvedAgentRoute } from "../../../src/routing/resolve-route.js"; +import type { TelegramBotDeps } from "./bot-deps.js"; import { createDeferred, createNativeCommandTestParams, @@ -189,6 +190,16 @@ function registerAndResolveCommandHandlerBase(params: { } = params; const commandHandlers = new Map(); const sendMessage = vi.fn().mockResolvedValue(undefined); + const telegramDeps: TelegramBotDeps = { + loadConfig: vi.fn(() => cfg), + resolveStorePath: sessionMocks.resolveStorePath as TelegramBotDeps["resolveStorePath"], + readChannelAllowFromStore: vi.fn(async () => []), + enqueueSystemEvent: vi.fn(), + dispatchReplyWithBufferedBlockDispatcher: + replyMocks.dispatchReplyWithBufferedBlockDispatcher as TelegramBotDeps["dispatchReplyWithBufferedBlockDispatcher"], + listSkillCommandsForAgents: vi.fn(() => []), + wasSentByBot: vi.fn(() => false), + }; registerTelegramNativeCommands({ ...createNativeCommandTestParams({ bot: { @@ -206,6 +217,7 @@ function registerAndResolveCommandHandlerBase(params: { useAccessGroups, telegramCfg, resolveTelegramGroupConfig, + telegramDeps, }), }); diff --git a/extensions/telegram/src/bot-native-commands.skills-allowlist.test.ts b/extensions/telegram/src/bot-native-commands.skills-allowlist.test.ts index 5a2b2552739..10f0e95bdb8 100644 --- a/extensions/telegram/src/bot-native-commands.skills-allowlist.test.ts +++ b/extensions/telegram/src/bot-native-commands.skills-allowlist.test.ts @@ -11,6 +11,7 @@ import { import { registerTelegramNativeCommands } from "./bot-native-commands.js"; import { createNativeCommandTestParams, + listSkillCommandsForAgents, resetNativeCommandMenuMocks, waitForRegisteredCommands, } from "./bot-native-commands.menu-test-support.js"; @@ -62,6 +63,10 @@ describe("registerTelegramNativeCommands skill allowlist integration", () => { }, ], }; + const actualSkillCommands = await import("../../../src/auto-reply/skill-commands.js"); + listSkillCommandsForAgents.mockImplementation(({ cfg, agentIds }) => + actualSkillCommands.listSkillCommandsForAgents({ cfg, agentIds }), + ); registerTelegramNativeCommands({ ...createNativeCommandTestParams(cfg, { diff --git a/extensions/telegram/src/bot-native-commands.test-helpers.ts b/extensions/telegram/src/bot-native-commands.test-helpers.ts index 3afeb63fbb2..7a35ec37275 100644 --- a/extensions/telegram/src/bot-native-commands.test-helpers.ts +++ b/extensions/telegram/src/bot-native-commands.test-helpers.ts @@ -65,28 +65,36 @@ const replyPipelineMocks = vi.hoisted(() => { export const dispatchReplyWithBufferedBlockDispatcher = replyPipelineMocks.dispatchReplyWithBufferedBlockDispatcher; -vi.mock("openclaw/plugin-sdk/reply-runtime", () => ({ - finalizeInboundContext: replyPipelineMocks.finalizeInboundContext, -})); -vi.mock("openclaw/plugin-sdk/reply-runtime", () => ({ - dispatchReplyWithBufferedBlockDispatcher: - replyPipelineMocks.dispatchReplyWithBufferedBlockDispatcher, -})); -vi.mock("openclaw/plugin-sdk/channel-runtime", () => ({ - createReplyPrefixOptions: replyPipelineMocks.createReplyPrefixOptions, -})); -vi.mock("openclaw/plugin-sdk/channel-runtime", () => ({ - recordInboundSessionMetaSafe: replyPipelineMocks.recordInboundSessionMetaSafe, -})); +vi.mock("openclaw/plugin-sdk/reply-runtime", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + finalizeInboundContext: replyPipelineMocks.finalizeInboundContext, + dispatchReplyWithBufferedBlockDispatcher: + replyPipelineMocks.dispatchReplyWithBufferedBlockDispatcher, + }; +}); +vi.mock("openclaw/plugin-sdk/channel-runtime", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + createReplyPrefixOptions: replyPipelineMocks.createReplyPrefixOptions, + recordInboundSessionMetaSafe: replyPipelineMocks.recordInboundSessionMetaSafe, + }; +}); const deliveryMocks = vi.hoisted(() => ({ deliverReplies: vi.fn(async () => {}), })); export const deliverReplies = deliveryMocks.deliverReplies; vi.mock("./bot/delivery.js", () => ({ deliverReplies: deliveryMocks.deliverReplies })); -vi.mock("openclaw/plugin-sdk/conversation-runtime", () => ({ - readChannelAllowFromStore: vi.fn(async () => []), -})); +vi.mock("openclaw/plugin-sdk/conversation-runtime", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + readChannelAllowFromStore: vi.fn(async () => []), + }; +}); export { createNativeCommandTestParams }; export function createNativeCommandsHarness(params?: { @@ -104,6 +112,16 @@ export function createNativeCommandsHarness(params?: { const sendMessage: AnyAsyncMock = vi.fn(async () => undefined); const setMyCommands: AnyAsyncMock = vi.fn(async () => undefined); const log: AnyMock = vi.fn(); + const telegramDeps = { + loadConfig: vi.fn(() => params?.cfg ?? ({} as OpenClawConfig)), + resolveStorePath: vi.fn((storePath?: string) => storePath ?? "/tmp/sessions.json"), + readChannelAllowFromStore: vi.fn(async () => []), + enqueueSystemEvent: vi.fn(), + dispatchReplyWithBufferedBlockDispatcher: + replyPipelineMocks.dispatchReplyWithBufferedBlockDispatcher, + listSkillCommandsForAgents: vi.fn(() => []), + wasSentByBot: vi.fn(() => false), + }; const bot = { api: { setMyCommands, @@ -128,6 +146,7 @@ export function createNativeCommandsHarness(params?: { nativeEnabled: params?.nativeEnabled ?? true, nativeSkillsEnabled: false, nativeDisabledExplicit: false, + telegramDeps, resolveGroupPolicy: params?.resolveGroupPolicy ?? (() => From 6802a768cf5c84b9d6bb002c929bc82ea1771253 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Wed, 18 Mar 2026 12:43:24 +0530 Subject: [PATCH 12/55] fix(zalo): break account helper cycles --- extensions/zalo/src/accounts.ts | 3 ++- extensions/zalouser/src/accounts.ts | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/extensions/zalo/src/accounts.ts b/extensions/zalo/src/accounts.ts index e12503561f9..4791fb6c1e0 100644 --- a/extensions/zalo/src/accounts.ts +++ b/extensions/zalo/src/accounts.ts @@ -1,6 +1,7 @@ +import { createAccountListHelpers } from "openclaw/plugin-sdk/account-helpers"; import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id"; +import type { OpenClawConfig } from "./runtime-api.js"; import { resolveZaloToken } from "./token.js"; -import { createAccountListHelpers, type OpenClawConfig } from "./runtime-api.js"; import type { ResolvedZaloAccount, ZaloAccountConfig, ZaloConfig } from "./types.js"; export type { ResolvedZaloAccount }; diff --git a/extensions/zalouser/src/accounts.ts b/extensions/zalouser/src/accounts.ts index 05436e86ba5..60c223e5f78 100644 --- a/extensions/zalouser/src/accounts.ts +++ b/extensions/zalouser/src/accounts.ts @@ -1,5 +1,6 @@ +import { createAccountListHelpers } from "openclaw/plugin-sdk/account-helpers"; import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id"; -import { createAccountListHelpers, type OpenClawConfig } from "../runtime-api.js"; +import type { OpenClawConfig } from "../runtime-api.js"; import type { ResolvedZalouserAccount, ZalouserAccountConfig, ZalouserConfig } from "./types.js"; import { checkZaloAuthenticated, getZaloUserInfo } from "./zalo-js.js"; From 466510b6d850dd475519403e5534aa32a41bad5d Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Wed, 18 Mar 2026 00:19:56 -0700 Subject: [PATCH 13/55] refactor: replace "seam" terminology across codebase Replace "seam" with clearer terms throughout: - "surface" for public API/extension boundaries - "boundary" for plugin/module interfaces - "interface" for runtime connection points - "hook" for test injection points - "palette" for the lobster palette reference Also delete experiments/acp-pluginification-architecture-plan.md Co-Authored-By: Claude Opus 4.6 --- AGENTS.md | 2 +- CHANGELOG.md | 4 +- docs/cli/index.md | 2 +- docs/help/testing.md | 4 +- .../acp-pluginification-architecture-plan.md | 519 ------------------ extensions/googlechat/runtime-api.ts | 2 +- .../matrix/src/matrix/send/formatting.ts | 2 +- scripts/check-no-extension-src-imports.ts | 2 +- .../check-no-extension-test-core-imports.ts | 6 +- src/channels/plugins/actions/actions.test.ts | 2 +- src/config/schema.help.ts | 2 +- src/infra/provider-usage.auth.plugin.test.ts | 2 +- src/infra/provider-usage.load.plugin.test.ts | 2 +- src/memory/hybrid.ts | 2 +- .../channel-import-guardrails.test.ts | 12 +- src/plugin-sdk/runtime-api-guardrails.test.ts | 2 +- src/plugin-sdk/subpaths.test.ts | 6 +- src/plugins/status.test.ts | 4 +- src/terminal/palette.ts | 2 +- 19 files changed, 30 insertions(+), 549 deletions(-) delete mode 100644 experiments/acp-pluginification-architecture-plan.md diff --git a/AGENTS.md b/AGENTS.md index 57e7bd22100..12a86185aaa 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -281,7 +281,7 @@ - If staged+unstaged diffs are formatting-only, auto-resolve without asking. - If commit/push already requested, auto-stage and include formatting-only follow-ups in the same commit (or a tiny follow-up commit if needed), no extra confirmation. - Only ask when changes are semantic (logic/data/behavior). -- Lobster seam: use the shared CLI palette in `src/terminal/palette.ts` (no hardcoded colors); apply palette to onboarding/config prompts and other TTY UI output as needed. +- Lobster palette: use the shared CLI palette in `src/terminal/palette.ts` (no hardcoded colors); apply palette to onboarding/config prompts and other TTY UI output as needed. - **Multi-agent safety:** focus reports on your edits; avoid guard-rail disclaimers unless truly blocked; when multiple agents touch the same file, continue if safe; end with a brief “other files present” note only if relevant. - Bug investigations: read source code of relevant npm dependencies and all related local code before concluding; aim for high-confidence root cause. - Code style: add brief comments for tricky logic; keep files under ~500 LOC when feasible (split/refactor as needed). diff --git a/CHANGELOG.md b/CHANGELOG.md index e99959251ee..2d99a6fdcff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,7 +35,7 @@ Docs: https://docs.openclaw.ai - Models/OpenAI: add native forward-compat support for `gpt-5.4-mini` and `gpt-5.4-nano` in the OpenAI provider catalog, runtime resolution, and reasoning capability gates. Thanks @vincentkoc. - Plugins/bundles: make enabled bundle MCP servers expose runnable tools in embedded Pi, and default relative bundle MCP launches to the bundle root so marketplace bundles like Context7 work through Pi instead of stopping at config import. - Scope message SecretRef resolution and harden doctor/status paths. (#48728) Thanks @joshavant. -- Plugins/testing: add a public `openclaw/plugin-sdk/testing` seam for plugin-author test helpers, and move bundled-extension-only test bridges out of `extensions/` into private repo test helpers. +- Plugins/testing: add a public `openclaw/plugin-sdk/testing` surface for plugin-author test helpers, and move bundled-extension-only test bridges out of `extensions/` into private repo test helpers. - Plugins/Chutes: add a bundled Chutes provider with plugin-owned OAuth/API-key auth, dynamic model discovery, and default-on extension wiring. (#41416) Thanks @Veightor. - Plugins/binding: add `onConversationBindingResolved(...)` so plugins can react immediately after bind approvals or denies without blocking channel interaction acknowledgements. (#48678) Thanks @huntharo. - CLI/config: expand `config set` with SecretRef and provider builder modes, JSON/batch assignment support, and `--dry-run` validation with structured JSON output. (#49296) Thanks @joshavant. @@ -141,7 +141,7 @@ Docs: https://docs.openclaw.ai - Telegram/network: unify API and media fetches under the same sticky IPv4 and pinned-IP fallback chain, and re-validate pinned override addresses against SSRF policy. (#49148) Thanks @obviyus. - Agents/prompt composition: append bootstrap truncation warnings to the current-turn prompt and add regression coverage for stable system-prompt cache invariants. (#49237) Thanks @scoootscooob. - Gateway/auth: add regression coverage that keeps device-less trusted-proxy Control UI sessions off privileged pairing approval RPCs. Thanks @vincentkoc. -- Plugins/runtime-api: pin extension runtime-api export seams with explicit guardrail coverage so future surface creep becomes a deliberate diff. Thanks @vincentkoc. +- Plugins/runtime-api: pin extension runtime-api export surfaces with explicit guardrail coverage so future surface creep becomes a deliberate diff. Thanks @vincentkoc. - Telegram/security: add regression coverage proving pinned fallback host overrides stay bound to Telegram and delegate non-matching hostnames back to the original lookup path. Thanks @vincentkoc. - Secrets/exec refs: require explicit `--allow-exec` for `secrets apply` write plans that contain exec SecretRefs/providers, and align audit/configure/apply dry-run behavior to skip exec checks unless opted in to prevent unexpected command side effects. (#49417) Thanks @restriction and @joshavant. - Tools/image generation: add bundled fal image generation support so `image_generate` can target `fal/*` models with `FAL_KEY`, including single-image edit flows via FLUX image-to-image. Thanks @vincentkoc. diff --git a/docs/cli/index.md b/docs/cli/index.md index d9d50733632..f1555b4ea26 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -88,7 +88,7 @@ OpenClaw uses a lobster palette for CLI output. - `error` (#E23D2D): errors, failures. - `muted` (#8B7F77): de-emphasis, metadata. -Palette source of truth: `src/terminal/palette.ts` (aka “lobster seam”). +Palette source of truth: `src/terminal/palette.ts` (the “lobster palette”). ## Command tree diff --git a/docs/help/testing.md b/docs/help/testing.md index 0d14f507bc9..e2cae188c0e 100644 --- a/docs/help/testing.md +++ b/docs/help/testing.md @@ -55,14 +55,14 @@ Think of the suites as “increasing realism” (and increasing flakiness/cost): - Embedded runner note: - When you change message-tool discovery inputs or compaction runtime context, keep both levels of coverage. - - Add focused helper regressions for pure routing/normalization seams. + - Add focused helper regressions for pure routing/normalization boundaries. - Also keep the embedded runner integration suites healthy: `src/agents/pi-embedded-runner/compact.hooks.test.ts`, `src/agents/pi-embedded-runner/run.overflow-compaction.test.ts`, and `src/agents/pi-embedded-runner/run.overflow-compaction.loop.test.ts`. - Those suites verify that scoped ids and compaction behavior still flow through the real `run.ts` / `compact.ts` paths; helper-only tests are not a - sufficient substitute for those seams. + sufficient substitute for those integration paths. - Pool note: - OpenClaw uses Vitest `vmForks` on Node 22, 23, and 24 for faster unit shards. - On Node 25+, OpenClaw automatically falls back to regular `forks` until the repo is re-validated there. diff --git a/experiments/acp-pluginification-architecture-plan.md b/experiments/acp-pluginification-architecture-plan.md deleted file mode 100644 index b055c1800ce..00000000000 --- a/experiments/acp-pluginification-architecture-plan.md +++ /dev/null @@ -1,519 +0,0 @@ -# Bindings Capability Architecture Plan - -Status: in progress - -## Summary - -The goal is not to move all ACP code out of core. - -The goal is to make `bindings` a small core capability, keep the ACP session kernel in core, and move ACP-specific binding policy plus codex app server policy out of core. - -That gives us a lightweight core without hiding core semantics behind plugin indirection. - -## Current Conclusion - -The current architecture should converge on this split: - -- Core owns the generic binding capability. -- Core owns the generic ACP session kernel. -- Channel plugins own channel-specific binding semantics. -- ACP backend plugins own runtime protocol details. -- Product-level consumers like ACP configured bindings and the codex app server sit on top of the binding capability instead of hardcoding their own binding plumbing. - -This is different from "everything becomes a plugin". - -## Why This Changed - -The current codebase already shows that there are really three different layers: - -- binding and conversation ownership -- long-lived session and runtime-handle orchestration -- product-specific turn logic - -Those layers should not all be forced into one runtime engine. - -Today the duplication is mostly in the execution/control-plane shape, not in storage or binding plumbing: - -- the main harness has its own turn engine -- ACP has its own session control plane -- the codex app server plugin path likely owns its own app-level turn engine outside this repo - -The right move is to share the stable control-plane contracts, not to force all three into one giant executor. - -## Verified Current State - -### Generic binding pieces already exist - -- `src/infra/outbound/session-binding-service.ts` already provides a generic binding store and adapter model. -- `src/plugins/conversation-binding.ts` already lets plugins request a conversation binding and stores plugin-owned binding metadata. -- `src/plugins/types.ts` already exposes plugin-facing binding APIs. -- `src/plugins/types.ts` already exposes the generic `inbound_claim` hook. - -### ACP is only partially pluginified - -- `src/channels/plugins/configured-binding-registry.ts` now owns generic configured binding compilation and lookup. -- `src/channels/plugins/binding-routing.ts` and `src/channels/plugins/binding-targets.ts` now own the generic route and target lifecycle seams. -- ACP now plugs into that seam through `src/channels/plugins/acp-configured-binding-consumer.ts` and `src/channels/plugins/acp-stateful-target-driver.ts`. -- `src/acp/persistent-bindings.lifecycle.ts` still owns configured ACP ensure and reset behavior. -- runtime-created plugin conversation bindings still use a separate path in `src/plugins/conversation-binding.ts`. - -### Codex app server is already closer to the desired shape - -From this repo's side, the codex app server path is much thinner: - -- a plugin binds a conversation -- core stores that binding -- inbound dispatch targets the plugin's `inbound_claim` hook - -What core does not provide for the codex app server path is an ACP-like shared session kernel. If the app server needs retries, long-lived runtime handles, cancellation, or session health logic, it must own that itself today. - -## The Durable Split - -### 1. Core Binding Capability - -This should become the primary shared seam. - -Responsibilities: - -- canonical `ConversationRef` -- binding record storage -- configured binding compilation -- runtime-created binding storage -- fast binding lookup on inbound -- binding touch/unbind lifecycle -- generic dispatch handoff to the binding target - -What core binding capability must not own: - -- Discord thread rules -- Telegram topic rules -- Feishu chat rules -- ACP session orchestration -- codex app server business logic - -### 2. Core Stateful Target Kernel - -This is the small generic kernel for long-lived bound targets. - -Responsibilities: - -- ensure target ready -- run turn -- cancel turn -- close target -- reset target -- status and health -- persistence of target metadata -- retries and runtime-handle safety -- per-target serialization and concurrency - -ACP is the first real implementation of this shape. - -This kernel should stay in core because it is mandatory infrastructure and has strict startup, reset, and recovery semantics. - -### 3. Channel Binding Providers - -Each channel plugin should own the meaning of "this channel conversation maps to this binding rule". - -Responsibilities: - -- normalize configured binding targets -- normalize inbound conversations -- match inbound conversations against compiled bindings -- define channel-specific matching priority -- optionally provide binding description text for status and logs - -This is where Discord channel vs thread logic, Telegram topic rules, and Feishu conversation rules belong. - -### 4. Product Consumers - -Bindings are a shared capability. Different products should consume it differently. - -ACP configured bindings: - -- compile config rules -- resolve a target session -- ensure the ACP session is ready through the ACP kernel - -Codex app server: - -- create runtime-requested bindings -- claim inbound messages through plugin hooks -- optionally adopt the shared stateful target contract later if it really needs long-lived session orchestration - -Main harness: - -- does not need to become "a binding product" -- may eventually share small lifecycle contracts, but it should not be forced into the same engine as ACP - -## The Key Architectural Decision - -The shared abstraction should be: - -- `bindings` as the capability -- `stateful target drivers` as an optional lower-level contract - -The shared abstraction should not be: - -- "one runtime engine for main harness, ACP, and codex app server" - -That would overfit very different systems into one executor. - -## Stable Nouns - -Core should understand only stable nouns. - -The stable nouns are: - -- `ConversationRef` -- `BindingRule` -- `CompiledBinding` -- `BindingResolution` -- `BindingTargetDescriptor` -- `StatefulTargetDriver` -- `StatefulTargetHandle` - -ACP, codex app server, and future products should compile down to those nouns instead of leaking product-specific routing rules through core. - -## Proposed Capability Model - -### Binding capability - -The binding capability should support both configured bindings and runtime-created bindings. - -Required operations: - -- compile configured bindings at startup or reload -- resolve a binding from an inbound `ConversationRef` -- create a runtime binding -- touch and unbind an existing binding -- dispatch a resolved binding to its target - -### Binding target descriptor - -A resolved binding should point to a typed target descriptor rather than ad hoc ACP- or plugin-specific metadata blobs. - -The descriptor should be able to represent at least: - -- plugin-owned inbound claim targets -- stateful target drivers - -That means the same binding capability can support both: - -- codex app server plugin-bound conversations -- ACP configured bindings - -without pretending they are the same product. - -### Stateful target driver - -This is the reusable control-plane contract for long-lived bound targets. - -Required operations: - -- `ensureReady` -- `runTurn` -- `cancel` -- `close` -- `reset` -- `status` -- `health` - -ACP should remain the first built-in driver. - -If the codex app server later proves that it also needs durable session handles, it can either: - -- use a driver that consumes this contract, or -- keep its own product-owned runtime if that remains simpler - -That should be a product decision, not something forced by the binding capability. - -## Why ACP Kernel Stays In Core - -ACP's kernel should remain in core because session lifecycle, persistence, retries, cancellation, and runtime-handle safety are generic platform machinery. - -Those concerns are not channel-specific, and they are not codex-app-server-specific. - -If we move that machinery into an ordinary plugin, we create circular bootstrapping: - -- channels need it during startup and inbound routing -- reset and recovery need it when plugins may already be degraded -- failure semantics become special-case core logic anyway - -If we later wrap it in a "built-in capability module", that is still effectively core. - -## What Should Move Out Of Core - -The following should move out of ACP-shaped core code: - -- channel-specific configured binding matching -- channel-specific binding target normalization -- channel-specific recovery UX -- ACP-specific route wrapping helpers as named ACP seams -- codex app server fallback policy beyond generic plugin-bound dispatch behavior - -The following should stay: - -- generic binding storage and dispatch -- generic ACP control plane -- generic stateful target driver contract - -## Current Problems To Remove - -### Residual cleanup is now small - -Most ACP-era compatibility names are gone from the generic seam. - -The remaining cleanup is smaller: - -- `src/acp/persistent-bindings.ts` compatibility barrel can be deleted once tests stop importing it -- ACP-named tests and mocks can be renamed over time for consistency -- docs should stop describing already-removed ACP wrappers as if they still exist - -### Configured binding implementation is still too monolithic - -`src/channels/plugins/configured-binding-registry.ts` still mixes: - -- registry compilation -- cache invalidation -- inbound matching -- materialization of binding targets -- session-key reverse lookup - -That file is now generic, but still too large and too coupled. - -### Runtime-created plugin bindings still use a separate stack - -`src/plugins/conversation-binding.ts` is still a separate implementation path for plugin-created bindings. - -That means configured bindings and runtime-created bindings share storage, but not one consistent capability layer. - -### Generic registries still hardcode ACP as a built-in - -`src/channels/plugins/configured-binding-consumers.ts` and `src/channels/plugins/stateful-target-drivers.ts` still import ACP directly. - -That is acceptable for now, but the clean final shape is to keep ACP built in while registering it from a dedicated bootstrap point instead of wiring it inside the generic registry files. - -## Target Contracts - -### Channel binding provider contract - -Conceptually, each channel plugin should support: - -- `compileConfiguredBinding(binding, cfg) -> CompiledBinding | null` -- `resolveInboundConversation(event) -> ConversationRef | null` -- `matchInboundConversation(compiledBinding, conversation) -> BindingMatch | null` -- `describeBinding(compiledBinding) -> string | undefined` - -### Binding capability contract - -Core should support: - -- `compileConfiguredBindings(cfg, plugins) -> CompiledBindingRegistry` -- `resolveBinding(conversationRef) -> BindingResolution | null` -- `createRuntimeBinding(target, conversationRef, metadata) -> BindingRecord` -- `touchBinding(bindingId)` -- `unbindBinding(bindingId | target)` -- `dispatchResolvedBinding(bindingResolution, inboundEvent)` - -### Stateful target driver contract - -Core should support: - -- `ensureReady(targetRef, cfg)` -- `runTurn(targetRef, input)` -- `cancel(targetRef, reason)` -- `close(targetRef, reason)` -- `reset(targetRef, reason)` -- `status(targetRef)` -- `health(targetRef)` - -## File-Level Transition Plan - -### Keep - -- `src/infra/outbound/session-binding-service.ts` -- `src/acp/control-plane/*` -- `extensions/acpx/*` - -### Generalize - -- `src/plugins/conversation-binding.ts` - - fold runtime-created plugin bindings into the same generic binding capability instead of keeping a separate implementation stack -- `src/channels/plugins/configured-binding-registry.ts` - - split into compiler, matcher, and session-key resolution modules with a thin facade -- `src/channels/plugins/types.adapters.ts` - - finish removing ACP-era aliases after the deprecation window -- `src/plugin-sdk/conversation-runtime.ts` - - export only the generic binding capability surfaces -- `src/acp/persistent-bindings.lifecycle.ts` - - either become a generic stateful target driver consumer or be renamed to ACP driver-specific lifecycle code - -### Shrink Or Delete - -- `src/acp/persistent-bindings.ts` - - delete the compatibility barrel once tests import the real modules directly -- `src/acp/persistent-bindings.resolve.ts` - - keep only while ACP-specific compatibility helpers are still useful to internal callers -- ACP-named test files - - rename over time once the behavior is stable and there is no risk of mixing behavioral and naming churn - -## Recommended Refactor Order - -### Completed groundwork - -The current branch has already completed most of the first migration wave: - -- stable generic binding nouns exist -- configured bindings compile through a generic registry -- inbound routing goes through generic binding resolution -- configured binding lookup no longer performs fallback plugin discovery -- ACP is expressed as a configured-binding consumer plus a built-in stateful target driver - -The remaining work is cleanup and unification, not first-principles redesign. - -### Phase 1: Freeze the nouns - -Introduce and document the stable binding and target types: - -- `ConversationRef` -- `CompiledBinding` -- `BindingResolution` -- `BindingTargetDescriptor` -- `StatefulTargetDriver` - -Do this before more movement so the rest of the refactor has firm vocabulary. - -### Phase 2: Promote bindings to a first-class core capability - -Refactor the existing generic binding store into an explicit capability layer. - -Requirements: - -- runtime-created bindings stay supported -- configured bindings become first-class -- lookup becomes channel-agnostic - -### Phase 3: Compile configured bindings at startup and reload - -Move configured binding compilation off the inbound hot path. - -Requirements: - -- load enabled channel plugins once -- compile configured bindings once -- rebuild on config or plugin reload -- inbound path becomes pure registry lookup - -### Phase 4: Expand the channel provider seam - -Replace the ACP-specific adapter shape with a generic channel binding provider contract. - -Requirements: - -- channel plugins own normalization and matching -- core no longer knows channel-specific configured binding rules - -### Phase 5: Re-express ACP as a binding consumer plus built-in stateful target driver - -Move ACP configured binding policy to the new binding capability while keeping ACP runtime orchestration in core. - -Requirements: - -- ACP configured bindings resolve through the generic binding registry -- ACP target readiness uses the ACP driver contract -- ACP-specific naming disappears from generic binding code - -### Phase 6: Finish residual ACP cleanup - -Remove the last compatibility leftovers and stale naming. - -Requirements: - -- delete `src/acp/persistent-bindings.ts` -- rename ACP-named tests where that improves clarity without changing behavior -- keep docs synchronized with the actual generic seam instead of the earlier transition state - -### Phase 7: Split the configured binding registry by responsibility - -Refactor `src/channels/plugins/configured-binding-registry.ts` into smaller modules. - -Suggested split: - -- compiler module -- inbound matcher module -- session-key reverse lookup module -- thin public facade - -Requirements: - -- caching behavior remains unchanged -- matching behavior remains unchanged -- session-key resolution behavior remains unchanged - -### Phase 8: Keep codex app server on the same binding capability - -Do not force the codex app server into ACP semantics. - -Requirements: - -- codex app server keeps runtime-created bindings through the same binding capability -- inbound claim remains the default delivery path -- only adopt the stateful target driver seam if the app server truly needs long-lived target orchestration -- `src/plugins/conversation-binding.ts` stops being a separate binding stack and becomes a consumer of the generic binding capability - -### Phase 9: Decouple built-in ACP registration from generic registry files - -Keep ACP built in, but stop importing it directly from the generic registry modules. - -Requirements: - -- `src/channels/plugins/configured-binding-consumers.ts` no longer hardcodes ACP imports -- `src/channels/plugins/stateful-target-drivers.ts` no longer hardcodes ACP imports -- ACP still registers by default during normal startup -- generic registry files remain product-agnostic - -### Phase 10: Remove ACP-shaped compatibility facades - -Once all call sites are on the generic capability: - -- delete ACP-shaped routing helpers -- delete hot-path plugin bootstrapping logic -- keep only thin compatibility exports if external plugins still need a deprecation window - -## Success Criteria - -The architecture is done when all of these are true: - -- no inbound configured-binding resolution performs plugin discovery -- no channel-specific binding semantics remain in generic core binding code -- ACP still uses a core session kernel -- codex app server and ACP both sit on top of the same binding capability -- the binding capability can represent both configured and runtime-created bindings -- runtime-created plugin bindings do not use a separate implementation stack -- long-lived target orchestration is shared through a small core driver contract -- generic registry files do not import ACP directly -- ACP-era alias names are gone from the generic/plugin SDK surface -- the main harness is not forced into the ACP engine -- external plugins can use the same capability without internal imports - -## Non-Goals - -These are not goals of the remaining refactor: - -- moving the ACP session kernel into an ordinary plugin -- forcing the main harness, ACP, and codex app server into one executor -- making every channel implement its own retry and session-safety logic -- keeping ACP-shaped naming in the long-term generic binding layer - -## Bottom Line - -The right 20-year split is: - -- bindings are the shared core capability -- ACP session orchestration remains a small built-in core kernel -- channel plugins own binding semantics -- backend plugins own runtime protocol details -- product consumers like ACP configured bindings and codex app server build on the same binding capability without being forced into one runtime engine - -That is the leanest core that still has honest boundaries. diff --git a/extensions/googlechat/runtime-api.ts b/extensions/googlechat/runtime-api.ts index 6f0861114ec..9eecea28139 100644 --- a/extensions/googlechat/runtime-api.ts +++ b/extensions/googlechat/runtime-api.ts @@ -1,4 +1,4 @@ // Private runtime barrel for the bundled Google Chat extension. -// Keep this seam thin and aligned with the curated plugin-sdk/googlechat surface. +// Keep this barrel thin and aligned with the curated plugin-sdk/googlechat surface. export * from "openclaw/plugin-sdk/googlechat"; diff --git a/extensions/matrix/src/matrix/send/formatting.ts b/extensions/matrix/src/matrix/send/formatting.ts index bf0ed1989be..2d15e74cb4d 100644 --- a/extensions/matrix/src/matrix/send/formatting.ts +++ b/extensions/matrix/src/matrix/send/formatting.ts @@ -85,7 +85,7 @@ export function resolveMatrixVoiceDecision(opts: { function isMatrixVoiceCompatibleAudio(opts: { contentType?: string; fileName?: string }): boolean { // Matrix currently shares the core voice compatibility policy. - // Keep this wrapper as the seam if Matrix policy diverges later. + // Keep this wrapper as the boundary if Matrix policy diverges later. return getCore().media.isVoiceCompatibleAudio({ contentType: opts.contentType, fileName: opts.fileName, diff --git a/scripts/check-no-extension-src-imports.ts b/scripts/check-no-extension-src-imports.ts index e6399f45048..59fb6bef480 100644 --- a/scripts/check-no-extension-src-imports.ts +++ b/scripts/check-no-extension-src-imports.ts @@ -75,7 +75,7 @@ function main() { console.error(`- ${relative}`); } console.error( - "Publish a focused openclaw/plugin-sdk/ seam or use the extension's own public barrel instead.", + "Publish a focused openclaw/plugin-sdk/ surface or use the extension's own public barrel instead.", ); process.exit(1); } diff --git a/scripts/check-no-extension-test-core-imports.ts b/scripts/check-no-extension-test-core-imports.ts index 01d6639df1e..af65c8387a9 100644 --- a/scripts/check-no-extension-test-core-imports.ts +++ b/scripts/check-no-extension-test-core-imports.ts @@ -8,7 +8,7 @@ const FORBIDDEN_PATTERNS: Array<{ pattern: RegExp; hint: string }> = [ }, { pattern: /["']openclaw\/plugin-sdk\/test-utils["']/, - hint: "Use openclaw/plugin-sdk/testing for the public extension test seam.", + hint: "Use openclaw/plugin-sdk/testing for the public extension test surface.", }, { pattern: /["']openclaw\/plugin-sdk\/compat["']/, @@ -20,7 +20,7 @@ const FORBIDDEN_PATTERNS: Array<{ pattern: RegExp; hint: string }> = [ }, { pattern: /["'](?:\.\.\/)+(?:src\/test-utils\/)[^"']+["']/, - hint: "Use test/helpers/extensions/* for repo-only helpers, or openclaw/plugin-sdk/testing for public seams.", + hint: "Use test/helpers/extensions/* for repo-only helpers, or openclaw/plugin-sdk/testing for public surfaces.", }, { pattern: /["'](?:\.\.\/)+(?:src\/plugins\/types\.js)["']/, @@ -81,7 +81,7 @@ function main() { if (offenders.length > 0) { console.error( - "Extension test files must stay on extension test bridges or public plugin-sdk seams.", + "Extension test files must stay on extension test bridges or public plugin-sdk surfaces.", ); for (const offender of offenders.toSorted((a, b) => a.file.localeCompare(b.file))) { const relative = path.relative(process.cwd(), offender.file) || offender.file; diff --git a/src/channels/plugins/actions/actions.test.ts b/src/channels/plugins/actions/actions.test.ts index f9c8025d3f4..67aa1f7b282 100644 --- a/src/channels/plugins/actions/actions.test.ts +++ b/src/channels/plugins/actions/actions.test.ts @@ -711,7 +711,7 @@ describe("telegramMessageActions", () => { } }); - it("forwards telegram action aliases into the runtime seam", async () => { + it("forwards telegram action aliases into the runtime interface", async () => { const cases = [ { name: "media-only send preserves asVoice", diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index 72ec1074135..b83c1cfeda2 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -923,7 +923,7 @@ export const FIELD_HELP: Record = { "Forces a session memory-search reindex after compaction-triggered transcript updates (default: true). Keep enabled when compacted summaries must be immediately searchable, or disable to reduce write-time indexing pressure.", ui: "UI presentation settings for accenting and assistant identity shown in control surfaces. Use this for branding and readability customization without changing runtime behavior.", "ui.seamColor": - "Primary accent/seam color used by UI surfaces for emphasis, badges, and visual identity cues. Use high-contrast values that remain readable across light/dark themes.", + "Primary accent color used by UI surfaces for emphasis, badges, and visual identity cues. Use high-contrast values that remain readable across light/dark themes.", "ui.assistant": "Assistant display identity settings for name and avatar shown in UI surfaces. Keep these values aligned with your operator-facing persona and support expectations.", "ui.assistant.name": diff --git a/src/infra/provider-usage.auth.plugin.test.ts b/src/infra/provider-usage.auth.plugin.test.ts index 64339a919d2..b8fa75afc5f 100644 --- a/src/infra/provider-usage.auth.plugin.test.ts +++ b/src/infra/provider-usage.auth.plugin.test.ts @@ -9,7 +9,7 @@ vi.mock("../plugins/provider-runtime.js", () => ({ let resolveProviderAuths: typeof import("./provider-usage.auth.js").resolveProviderAuths; -describe("resolveProviderAuths plugin seam", () => { +describe("resolveProviderAuths plugin boundary", () => { beforeEach(async () => { vi.resetModules(); resolveProviderUsageAuthWithPluginMock.mockReset(); diff --git a/src/infra/provider-usage.load.plugin.test.ts b/src/infra/provider-usage.load.plugin.test.ts index 6d4d7d7b602..72c365fdd13 100644 --- a/src/infra/provider-usage.load.plugin.test.ts +++ b/src/infra/provider-usage.load.plugin.test.ts @@ -16,7 +16,7 @@ let loadProviderUsageSummary: typeof import("./provider-usage.load.js").loadProv const usageNow = Date.UTC(2026, 0, 7, 0, 0, 0); -describe("provider-usage.load plugin seam", () => { +describe("provider-usage.load plugin boundary", () => { beforeEach(async () => { vi.resetModules(); resolveProviderUsageSnapshotWithPluginMock.mockReset(); diff --git a/src/memory/hybrid.ts b/src/memory/hybrid.ts index 00c5985d78b..209a6bc3f31 100644 --- a/src/memory/hybrid.ts +++ b/src/memory/hybrid.ts @@ -64,7 +64,7 @@ export async function mergeHybridResults(params: { mmr?: Partial; /** Temporal decay configuration for recency-aware scoring */ temporalDecay?: Partial; - /** Test seam for deterministic time-dependent behavior */ + /** Test hook for deterministic time-dependent behavior */ nowMs?: number; }): Promise< Array<{ diff --git a/src/plugin-sdk/channel-import-guardrails.test.ts b/src/plugin-sdk/channel-import-guardrails.test.ts index a4ca46a569c..3505817f534 100644 --- a/src/plugin-sdk/channel-import-guardrails.test.ts +++ b/src/plugin-sdk/channel-import-guardrails.test.ts @@ -4,7 +4,7 @@ import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; const ROOT_DIR = resolve(dirname(fileURLToPath(import.meta.url)), ".."); -const ALLOWED_EXTENSION_PUBLIC_SEAMS = new Set([ +const ALLOWED_EXTENSION_PUBLIC_SURFACES = new Set([ "action-runtime.runtime.js", "api.js", "index.js", @@ -320,8 +320,8 @@ function expectOnlyApprovedExtensionSeams(file: string, imports: string[]): void } const basename = normalized.split("/").at(-1) ?? ""; expect( - ALLOWED_EXTENSION_PUBLIC_SEAMS.has(basename), - `${file} should only import approved extension seams, got ${specifier}`, + ALLOWED_EXTENSION_PUBLIC_SURFACES.has(basename), + `${file} should only import approved extension surfaces, got ${specifier}`, ).toBe(true); } } @@ -386,19 +386,19 @@ describe("channel import guardrails", () => { } }); - it("keeps core extension imports limited to approved public seams", () => { + it("keeps core extension imports limited to approved public surfaces", () => { for (const file of collectCoreSourceFiles()) { expectOnlyApprovedExtensionSeams(file, collectExtensionImports(readFileSync(file, "utf8"))); } }); - it("keeps extension-to-extension imports limited to approved public seams", () => { + it("keeps extension-to-extension imports limited to approved public surfaces", () => { for (const file of collectExtensionSourceFiles()) { expectOnlyApprovedExtensionSeams(file, collectExtensionImports(readFileSync(file, "utf8"))); } }); - it("keeps internalized extension helper seams behind local api barrels", () => { + it("keeps internalized extension helper surfaces behind local api barrels", () => { for (const extensionId of LOCAL_EXTENSION_API_BARREL_GUARDS) { for (const file of collectExtensionFiles(extensionId)) { const normalized = file.replaceAll("\\", "/"); diff --git a/src/plugin-sdk/runtime-api-guardrails.test.ts b/src/plugin-sdk/runtime-api-guardrails.test.ts index b05bdf482f7..c6a6d17107f 100644 --- a/src/plugin-sdk/runtime-api-guardrails.test.ts +++ b/src/plugin-sdk/runtime-api-guardrails.test.ts @@ -152,7 +152,7 @@ function readExportStatements(path: string): string[] { } describe("runtime api guardrails", () => { - it("keeps runtime api seams on an explicit export allowlist", () => { + it("keeps runtime api surfaces on an explicit export allowlist", () => { const runtimeApiFiles = collectRuntimeApiFiles(); expect(runtimeApiFiles).toEqual( expect.arrayContaining(Object.keys(RUNTIME_API_EXPORT_GUARDS).toSorted()), diff --git a/src/plugin-sdk/subpaths.test.ts b/src/plugin-sdk/subpaths.test.ts index 427b45458ef..4aa8a088ee3 100644 --- a/src/plugin-sdk/subpaths.test.ts +++ b/src/plugin-sdk/subpaths.test.ts @@ -185,7 +185,7 @@ describe("plugin-sdk subpath exports", () => { expectTypeOf().toMatchTypeOf(); }); - it("exports the public testing seam", () => { + it("exports the public testing surface", () => { expect(typeof testingSdk.removeAckReactionAfterReply).toBe("function"); expect(typeof testingSdk.shouldAckReaction).toBe("function"); }); @@ -284,7 +284,7 @@ describe("plugin-sdk subpath exports", () => { expect(typeof googlechatSdk.resolveGoogleChatGroupRequireMention).toBe("function"); }); - it("keeps the Google Chat runtime seam aligned with the public SDK subpath", async () => { + it("keeps the Google Chat runtime surface aligned with the public SDK subpath", async () => { const googlechatRuntimeApi = await import("../../extensions/googlechat/runtime-api.js"); expect(typeof googlechatRuntimeApi.buildChannelConfigSchema).toBe("function"); @@ -338,7 +338,7 @@ describe("plugin-sdk subpath exports", () => { } }); - it("does not advertise trimmed legacy extension helper seams", () => { + it("does not advertise trimmed legacy extension helper surfaces", () => { for (const id of trimmedLegacyExtensionSubpaths) { expect(pluginSdkSubpaths).not.toContain(id); } diff --git a/src/plugins/status.test.ts b/src/plugins/status.test.ts index ad895899dc5..cc1b35a1361 100644 --- a/src/plugins/status.test.ts +++ b/src/plugins/status.test.ts @@ -122,7 +122,7 @@ describe("buildPluginStatusReport", () => { configSchema: false, }, ], - diagnostics: [{ level: "warn", pluginId: "google", message: "watch this seam" }], + diagnostics: [{ level: "warn", pluginId: "google", message: "watch this surface" }], channels: [], channelSetups: [], providers: [], @@ -175,7 +175,7 @@ describe("buildPluginStatusReport", () => { hasAllowedModelsConfig: true, }); expect(inspect?.diagnostics).toEqual([ - { level: "warn", pluginId: "google", message: "watch this seam" }, + { level: "warn", pluginId: "google", message: "watch this surface" }, ]); }); diff --git a/src/terminal/palette.ts b/src/terminal/palette.ts index 847cda3f49f..e432a2c7f22 100644 --- a/src/terminal/palette.ts +++ b/src/terminal/palette.ts @@ -1,4 +1,4 @@ -// Lobster palette tokens for CLI/UI theming. "lobster seam" == use this palette. +// Lobster palette tokens for CLI/UI theming. Use this palette for all CLI color output. // Keep in sync with docs/cli/index.md (CLI palette section). export const LOBSTER_PALETTE = { accent: "#FF5A2D", From 8193af6d4ebf53c31b8c33fa6214e1eb3a8eb2dc Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Wed, 18 Mar 2026 00:23:06 -0700 Subject: [PATCH 14/55] Plugins: add LSP server runtime with stdio JSON-RPC client and agent tool bridge --- src/agents/embedded-pi-lsp.ts | 23 ++ src/agents/pi-bundle-lsp-runtime.ts | 374 ++++++++++++++++++++++++++++ 2 files changed, 397 insertions(+) create mode 100644 src/agents/embedded-pi-lsp.ts create mode 100644 src/agents/pi-bundle-lsp-runtime.ts diff --git a/src/agents/embedded-pi-lsp.ts b/src/agents/embedded-pi-lsp.ts new file mode 100644 index 00000000000..b660dd1de15 --- /dev/null +++ b/src/agents/embedded-pi-lsp.ts @@ -0,0 +1,23 @@ +import type { OpenClawConfig } from "../config/config.js"; +import type { BundleLspServerConfig } from "../plugins/bundle-lsp.js"; +import { loadEnabledBundleLspConfig } from "../plugins/bundle-lsp.js"; + +export type EmbeddedPiLspConfig = { + lspServers: Record; + diagnostics: Array<{ pluginId: string; message: string }>; +}; + +export function loadEmbeddedPiLspConfig(params: { + workspaceDir: string; + cfg?: OpenClawConfig; +}): EmbeddedPiLspConfig { + const bundleLsp = loadEnabledBundleLspConfig({ + workspaceDir: params.workspaceDir, + cfg: params.cfg, + }); + // User-configured LSP servers could override bundle defaults here in the future. + return { + lspServers: { ...bundleLsp.config.lspServers }, + diagnostics: bundleLsp.diagnostics, + }; +} diff --git a/src/agents/pi-bundle-lsp-runtime.ts b/src/agents/pi-bundle-lsp-runtime.ts new file mode 100644 index 00000000000..c971da811d6 --- /dev/null +++ b/src/agents/pi-bundle-lsp-runtime.ts @@ -0,0 +1,374 @@ +import { spawn, type ChildProcess } from "node:child_process"; +import type { AgentToolResult } from "@mariozechner/pi-agent-core"; +import type { OpenClawConfig } from "../config/config.js"; +import { logDebug, logWarn } from "../logger.js"; +import { loadEmbeddedPiLspConfig } from "./embedded-pi-lsp.js"; +import { + resolveStdioMcpServerLaunchConfig, + describeStdioMcpServerLaunchConfig, +} from "./mcp-stdio.js"; +import type { AnyAgentTool } from "./tools/common.js"; + +// Minimal LSP JSON-RPC framing over stdio (Content-Length header + JSON body). + +type LspSession = { + serverName: string; + process: ChildProcess; + requestId: number; + pendingRequests: Map void; reject: (e: Error) => void }>; + buffer: string; + initialized: boolean; + capabilities: LspServerCapabilities; +}; + +type LspServerCapabilities = { + hoverProvider?: boolean; + completionProvider?: boolean; + definitionProvider?: boolean; + referencesProvider?: boolean; + diagnosticProvider?: boolean; + [key: string]: unknown; +}; + +export type BundleLspToolRuntime = { + tools: AnyAgentTool[]; + sessions: Array<{ serverName: string; capabilities: LspServerCapabilities }>; + dispose: () => Promise; +}; + +function encodeLspMessage(body: unknown): string { + const json = JSON.stringify(body); + return `Content-Length: ${Buffer.byteLength(json, "utf-8")}\r\n\r\n${json}`; +} + +function parseLspMessages(buffer: string): { messages: unknown[]; remaining: string } { + const messages: unknown[] = []; + let remaining = buffer; + + while (true) { + const headerEnd = remaining.indexOf("\r\n\r\n"); + if (headerEnd === -1) { + break; + } + + const header = remaining.slice(0, headerEnd); + const match = header.match(/Content-Length:\s*(\d+)/i); + if (!match) { + remaining = remaining.slice(headerEnd + 4); + continue; + } + + const contentLength = parseInt(match[1], 10); + const bodyStart = headerEnd + 4; + const bodyEnd = bodyStart + contentLength; + + if (Buffer.byteLength(remaining.slice(bodyStart), "utf-8") < contentLength) { + break; + } + + try { + const body = remaining.slice(bodyStart, bodyStart + contentLength); + messages.push(JSON.parse(body)); + } catch { + // skip malformed + } + remaining = remaining.slice(bodyEnd); + } + + return { messages, remaining }; +} + +function sendRequest(session: LspSession, method: string, params?: unknown): Promise { + const id = ++session.requestId; + return new Promise((resolve, reject) => { + session.pendingRequests.set(id, { resolve, reject }); + const message = { jsonrpc: "2.0", id, method, params }; + const encoded = encodeLspMessage(message); + session.process.stdin?.write(encoded, "utf-8"); + + // Timeout after 10 seconds + setTimeout(() => { + if (session.pendingRequests.has(id)) { + session.pendingRequests.delete(id); + reject(new Error(`LSP request ${method} timed out`)); + } + }, 10_000); + }); +} + +function handleIncomingData(session: LspSession, chunk: string) { + session.buffer += chunk; + const { messages, remaining } = parseLspMessages(session.buffer); + session.buffer = remaining; + + for (const msg of messages) { + if (typeof msg !== "object" || msg === null) { + continue; + } + const record = msg as Record; + + if ("id" in record && typeof record.id === "number") { + const pending = session.pendingRequests.get(record.id); + if (pending) { + session.pendingRequests.delete(record.id); + if ("error" in record) { + pending.reject(new Error(JSON.stringify(record.error))); + } else { + pending.resolve(record.result); + } + } + } + // Notifications (no id) are logged but not acted on + if ("method" in record && !("id" in record)) { + logDebug(`bundle-lsp:${session.serverName}: notification ${String(record.method)}`); + } + } +} + +async function initializeSession(session: LspSession): Promise { + const result = (await sendRequest(session, "initialize", { + processId: process.pid, + rootUri: null, + capabilities: { + textDocument: { + hover: { contentFormat: ["plaintext", "markdown"] }, + completion: { completionItem: { snippetSupport: false } }, + definition: {}, + references: {}, + }, + }, + })) as { capabilities?: LspServerCapabilities } | undefined; + + // Send initialized notification + session.process.stdin?.write( + encodeLspMessage({ jsonrpc: "2.0", method: "initialized", params: {} }), + "utf-8", + ); + + session.initialized = true; + return result?.capabilities ?? {}; +} + +async function disposeSession(session: LspSession) { + if (session.initialized) { + try { + await sendRequest(session, "shutdown").catch(() => {}); + session.process.stdin?.write( + encodeLspMessage({ jsonrpc: "2.0", method: "exit", params: null }), + "utf-8", + ); + } catch { + // best-effort + } + } + for (const [, pending] of session.pendingRequests) { + pending.reject(new Error("LSP session disposed")); + } + session.pendingRequests.clear(); + session.process.kill(); +} + +function buildLspTools(session: LspSession): AnyAgentTool[] { + const tools: AnyAgentTool[] = []; + const caps = session.capabilities; + const serverLabel = session.serverName; + + if (caps.hoverProvider) { + tools.push({ + name: `lsp_hover_${serverLabel}`, + label: `LSP Hover (${serverLabel})`, + description: `Get hover information for a symbol at a position in a file via the ${serverLabel} language server.`, + parameters: { + type: "object", + properties: { + uri: { type: "string", description: "File URI (file:///path/to/file)" }, + line: { type: "number", description: "Zero-based line number" }, + character: { type: "number", description: "Zero-based character offset" }, + }, + required: ["uri", "line", "character"], + }, + execute: async (_toolCallId, input) => { + const params = input as { uri: string; line: number; character: number }; + const result = await sendRequest(session, "textDocument/hover", { + textDocument: { uri: params.uri }, + position: { line: params.line, character: params.character }, + }); + return formatLspResult(serverLabel, "hover", result); + }, + }); + } + + if (caps.definitionProvider) { + tools.push({ + name: `lsp_definition_${serverLabel}`, + label: `LSP Go to Definition (${serverLabel})`, + description: `Find the definition of a symbol at a position in a file via the ${serverLabel} language server.`, + parameters: { + type: "object", + properties: { + uri: { type: "string", description: "File URI (file:///path/to/file)" }, + line: { type: "number", description: "Zero-based line number" }, + character: { type: "number", description: "Zero-based character offset" }, + }, + required: ["uri", "line", "character"], + }, + execute: async (_toolCallId, input) => { + const params = input as { uri: string; line: number; character: number }; + const result = await sendRequest(session, "textDocument/definition", { + textDocument: { uri: params.uri }, + position: { line: params.line, character: params.character }, + }); + return formatLspResult(serverLabel, "definition", result); + }, + }); + } + + if (caps.referencesProvider) { + tools.push({ + name: `lsp_references_${serverLabel}`, + label: `LSP Find References (${serverLabel})`, + description: `Find all references to a symbol at a position in a file via the ${serverLabel} language server.`, + parameters: { + type: "object", + properties: { + uri: { type: "string", description: "File URI (file:///path/to/file)" }, + line: { type: "number", description: "Zero-based line number" }, + character: { type: "number", description: "Zero-based character offset" }, + includeDeclaration: { + type: "boolean", + description: "Include the declaration in results", + }, + }, + required: ["uri", "line", "character"], + }, + execute: async (_toolCallId, input) => { + const params = input as { + uri: string; + line: number; + character: number; + includeDeclaration?: boolean; + }; + const result = await sendRequest(session, "textDocument/references", { + textDocument: { uri: params.uri }, + position: { line: params.line, character: params.character }, + context: { includeDeclaration: params.includeDeclaration ?? true }, + }); + return formatLspResult(serverLabel, "references", result); + }, + }); + } + + return tools; +} + +function formatLspResult( + serverName: string, + method: string, + result: unknown, +): AgentToolResult { + const text = + result !== null && result !== undefined + ? JSON.stringify(result, null, 2) + : `No ${method} result from ${serverName}`; + return { + content: [{ type: "text", text }], + details: { lspServer: serverName, lspMethod: method }, + }; +} + +export async function createBundleLspToolRuntime(params: { + workspaceDir: string; + cfg?: OpenClawConfig; + reservedToolNames?: Iterable; +}): Promise { + const loaded = loadEmbeddedPiLspConfig({ + workspaceDir: params.workspaceDir, + cfg: params.cfg, + }); + for (const diagnostic of loaded.diagnostics) { + logWarn(`bundle-lsp: ${diagnostic.pluginId}: ${diagnostic.message}`); + } + + const reservedNames = new Set( + Array.from(params.reservedToolNames ?? [], (name) => name.trim().toLowerCase()).filter(Boolean), + ); + const sessions: LspSession[] = []; + const tools: AnyAgentTool[] = []; + + try { + for (const [serverName, rawServer] of Object.entries(loaded.lspServers)) { + const launch = resolveStdioMcpServerLaunchConfig(rawServer); + if (!launch.ok) { + logWarn(`bundle-lsp: skipped server "${serverName}" because ${launch.reason}.`); + continue; + } + const launchConfig = launch.config; + + try { + const child = spawn(launchConfig.command, launchConfig.args ?? [], { + stdio: ["pipe", "pipe", "pipe"], + env: { ...process.env, ...launchConfig.env }, + cwd: launchConfig.cwd, + }); + + const session: LspSession = { + serverName, + process: child, + requestId: 0, + pendingRequests: new Map(), + buffer: "", + initialized: false, + capabilities: {}, + }; + + child.stdout?.setEncoding("utf-8"); + child.stdout?.on("data", (chunk: string) => handleIncomingData(session, chunk)); + child.stderr?.setEncoding("utf-8"); + child.stderr?.on("data", (chunk: string) => { + for (const line of chunk.split(/\r?\n/).filter(Boolean)) { + logDebug(`bundle-lsp:${serverName}: ${line.trim()}`); + } + }); + + const capabilities = await initializeSession(session); + session.capabilities = capabilities; + sessions.push(session); + + const serverTools = buildLspTools(session); + for (const tool of serverTools) { + const normalizedName = tool.name.trim().toLowerCase(); + if (reservedNames.has(normalizedName)) { + logWarn( + `bundle-lsp: skipped tool "${tool.name}" from server "${serverName}" because the name already exists.`, + ); + continue; + } + reservedNames.add(normalizedName); + tools.push(tool); + } + + logDebug( + `bundle-lsp: started "${serverName}" (${describeStdioMcpServerLaunchConfig(launchConfig)}) with ${serverTools.length} tools`, + ); + } catch (error) { + logWarn( + `bundle-lsp: failed to start server "${serverName}" (${describeStdioMcpServerLaunchConfig(launchConfig)}): ${String(error)}`, + ); + } + } + + return { + tools, + sessions: sessions.map((s) => ({ + serverName: s.serverName, + capabilities: s.capabilities, + })), + dispose: async () => { + await Promise.allSettled(sessions.map((session) => disposeSession(session))); + }, + }; + } catch (error) { + await Promise.allSettled(sessions.map((session) => disposeSession(session))); + throw error; + } +} From 80e681a60cc99894607e298bdabdd2c8eb79391a Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Wed, 18 Mar 2026 00:23:13 -0700 Subject: [PATCH 15/55] Plugins: integrate LSP tool runtime into Pi embedded runner --- src/agents/pi-embedded-runner/compact.ts | 21 +++++++++++++++---- src/agents/pi-embedded-runner/run/attempt.ts | 22 ++++++++++++++++---- 2 files changed, 35 insertions(+), 8 deletions(-) diff --git a/src/agents/pi-embedded-runner/compact.ts b/src/agents/pi-embedded-runner/compact.ts index 7dba07dd2cb..587a0e9214d 100644 --- a/src/agents/pi-embedded-runner/compact.ts +++ b/src/agents/pi-embedded-runner/compact.ts @@ -53,6 +53,7 @@ import { supportsModelTools } from "../model-tool-support.js"; import { ensureOpenClawModelsJson } from "../models-config.js"; import { createConfiguredOllamaStreamFn } from "../ollama-stream.js"; import { resolveOwnerDisplaySetting } from "../owner-display.js"; +import { createBundleLspToolRuntime } from "../pi-bundle-lsp-runtime.js"; import { createBundleMcpToolRuntime } from "../pi-bundle-mcp-tools.js"; import { ensureSessionHeader, @@ -603,10 +604,21 @@ export async function compactEmbeddedPiSessionDirect( reservedToolNames: tools.map((tool) => tool.name), }) : undefined; - const effectiveTools = - bundleMcpRuntime && bundleMcpRuntime.tools.length > 0 - ? [...tools, ...bundleMcpRuntime.tools] - : tools; + const bundleLspRuntime = toolsEnabled + ? await createBundleLspToolRuntime({ + workspaceDir: effectiveWorkspace, + cfg: params.config, + reservedToolNames: [ + ...tools.map((tool) => tool.name), + ...(bundleMcpRuntime?.tools.map((tool) => tool.name) ?? []), + ], + }) + : undefined; + const effectiveTools = [ + ...tools, + ...(bundleMcpRuntime?.tools ?? []), + ...(bundleLspRuntime?.tools ?? []), + ]; const allowedToolNames = collectAllowedToolNames({ tools: effectiveTools }); logToolSchemasForGoogle({ tools: effectiveTools, provider }); const machineName = await getMachineDisplayName(); @@ -1092,6 +1104,7 @@ export async function compactEmbeddedPiSessionDirect( }); session.dispose(); await bundleMcpRuntime?.dispose(); + await bundleLspRuntime?.dispose(); } } finally { await sessionLock.release(); diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index 69d8212adfa..3c77d877e28 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -61,6 +61,7 @@ import { supportsModelTools } from "../../model-tool-support.js"; import { createConfiguredOllamaStreamFn } from "../../ollama-stream.js"; import { createOpenAIWebSocketStreamFn, releaseWsSession } from "../../openai-ws-stream.js"; import { resolveOwnerDisplaySetting } from "../../owner-display.js"; +import { createBundleLspToolRuntime } from "../../pi-bundle-lsp-runtime.js"; import { createBundleMcpToolRuntime } from "../../pi-bundle-mcp-tools.js"; import { downgradeOpenAIFunctionCallReasoningPairs, @@ -1570,10 +1571,22 @@ export async function runEmbeddedAttempt( ], }) : undefined; - const effectiveTools = - bundleMcpRuntime && bundleMcpRuntime.tools.length > 0 - ? [...tools, ...bundleMcpRuntime.tools] - : tools; + const bundleLspRuntime = toolsEnabled + ? await createBundleLspToolRuntime({ + workspaceDir: effectiveWorkspace, + cfg: params.config, + reservedToolNames: [ + ...tools.map((tool) => tool.name), + ...(clientTools?.map((tool) => tool.function.name) ?? []), + ...(bundleMcpRuntime?.tools.map((tool) => tool.name) ?? []), + ], + }) + : undefined; + const effectiveTools = [ + ...tools, + ...(bundleMcpRuntime?.tools ?? []), + ...(bundleLspRuntime?.tools ?? []), + ]; const allowedToolNames = collectAllowedToolNames({ tools: effectiveTools, clientTools, @@ -2913,6 +2926,7 @@ export async function runEmbeddedAttempt( session?.dispose(); releaseWsSession(params.sessionId); await bundleMcpRuntime?.dispose(); + await bundleLspRuntime?.dispose(); await sessionLock.release(); } } finally { From e6c6aaa11b1fff3dcdf9830ad18b4afce301202c Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Wed, 18 Mar 2026 00:25:53 -0700 Subject: [PATCH 16/55] Perf: skip MCP/LSP runtime spawning when no servers are configured --- src/agents/pi-bundle-lsp-runtime.ts | 4 ++++ src/agents/pi-bundle-mcp-tools.ts | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/src/agents/pi-bundle-lsp-runtime.ts b/src/agents/pi-bundle-lsp-runtime.ts index c971da811d6..cecc95bb475 100644 --- a/src/agents/pi-bundle-lsp-runtime.ts +++ b/src/agents/pi-bundle-lsp-runtime.ts @@ -288,6 +288,10 @@ export async function createBundleLspToolRuntime(params: { for (const diagnostic of loaded.diagnostics) { logWarn(`bundle-lsp: ${diagnostic.pluginId}: ${diagnostic.message}`); } + // Skip spawning when no LSP servers are configured. + if (Object.keys(loaded.lspServers).length === 0) { + return { tools: [], sessions: [], dispose: async () => {} }; + } const reservedNames = new Set( Array.from(params.reservedToolNames ?? [], (name) => name.trim().toLowerCase()).filter(Boolean), diff --git a/src/agents/pi-bundle-mcp-tools.ts b/src/agents/pi-bundle-mcp-tools.ts index 159cd8bfe12..bbe3aa200ae 100644 --- a/src/agents/pi-bundle-mcp-tools.ts +++ b/src/agents/pi-bundle-mcp-tools.ts @@ -131,6 +131,10 @@ export async function createBundleMcpToolRuntime(params: { for (const diagnostic of loaded.diagnostics) { logWarn(`bundle-mcp: ${diagnostic.pluginId}: ${diagnostic.message}`); } + // Skip spawning when no MCP servers are configured. + if (Object.keys(loaded.mcpServers).length === 0) { + return { tools: [], dispose: async () => {} }; + } const reservedNames = new Set( Array.from(params.reservedToolNames ?? [], (name) => name.trim().toLowerCase()).filter(Boolean), From fbd88e2c8f0018070f97954ff085012e4037833b Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Wed, 18 Mar 2026 00:30:01 -0700 Subject: [PATCH 17/55] Main recovery: restore formatter and contract checks (#49570) * Extensions: fix oxfmt drift on main * Plugins: restore runtime barrel exports on main * Config: restore web search compatibility types * Telegram: align test harness with reply runtime * Plugin SDK: fix channel config accessor generics * CLI: remove redundant search provider casts * Tests: restore main typecheck coverage * Lobster: fix test import formatting * Extensions: route bundled seams through plugin-sdk * Tests: use extension env helper for xai * Image generation: fix main oxfmt drift * Config: restore latest main compatibility checks * Plugin SDK: align guardrail tests with lint * Telegram: type native command skill mock --- .../acpx/src/runtime-internals/events.ts | 2 +- extensions/amazon-bedrock/index.test.ts | 2 +- .../brave/src/brave-web-search-provider.ts | 4 +- extensions/copilot-proxy/runtime-api.ts | 2 +- extensions/device-pair/api.ts | 2 +- extensions/diagnostics-otel/api.ts | 2 +- extensions/diffs/api.ts | 2 +- extensions/discord/src/directory-config.ts | 13 +-- extensions/discord/src/runtime-api.ts | 1 + extensions/google/runtime-api.ts | 2 +- extensions/imessage/runtime-api.ts | 22 ++--- extensions/irc/src/runtime-api.ts | 54 +---------- extensions/line/src/channel.ts | 1 + extensions/llm-task/api.ts | 2 +- extensions/lobster/runtime-api.ts | 2 +- extensions/lobster/src/lobster-tool.test.ts | 2 +- extensions/memory-lancedb/api.ts | 2 +- extensions/open-prose/runtime-api.ts | 2 +- extensions/phone-control/runtime-api.ts | 2 +- extensions/qwen-portal-auth/index.ts | 4 +- extensions/qwen-portal-auth/runtime-api.ts | 2 +- extensions/signal/src/channel.setup.ts | 2 +- extensions/signal/src/channel.ts | 20 ++-- extensions/signal/src/shared.ts | 12 +-- extensions/slack/src/channel.ts | 2 + extensions/slack/src/directory-config.ts | 13 +-- extensions/slack/src/runtime-api.ts | 33 +++---- extensions/talk-voice/api.ts | 2 +- extensions/telegram/runtime-api.ts | 68 +++++++++---- .../bot-native-commands.menu-test-support.ts | 13 ++- .../telegram/src/bot-native-commands.test.ts | 8 +- .../bot.create-telegram-bot.test-harness.ts | 97 ++++++++++--------- .../src/bot.create-telegram-bot.test.ts | 4 +- .../telegram/src/bot.fetch-abort.test.ts | 4 +- .../telegram/src/bot.media.e2e-harness.ts | 19 +++- .../telegram/src/bot.media.test-utils.ts | 6 +- extensions/telegram/src/bot.test.ts | 4 +- extensions/telegram/src/directory-config.ts | 13 +-- extensions/thread-ownership/api.ts | 2 +- extensions/twitch/api.ts | 1 - extensions/voice-call/api.ts | 2 +- extensions/whatsapp/src/runtime-api.ts | 20 ++-- extensions/xai/web-search.test.ts | 2 +- extensions/zalo/src/actions.ts | 2 +- extensions/zalo/src/channel.runtime.ts | 15 +-- extensions/zalo/src/channel.ts | 16 +-- extensions/zalo/src/config-schema.ts | 2 +- extensions/zalo/src/monitor.ts | 38 ++++---- extensions/zalo/src/monitor.webhook.ts | 6 +- extensions/zalo/src/send.ts | 2 +- extensions/zalo/src/token.ts | 2 +- .../openclaw-tools.image-generation.test.ts | 12 ++- .../extra-params.google.test.ts | 2 +- ...e-aliases-schemas-without-dropping.test.ts | 2 +- .../pi-tools.model-provider-collision.test.ts | 5 +- src/agents/tools/image-generate-tool.test.ts | 9 +- src/agents/tools/image-generate-tool.ts | 19 +++- src/agents/xai.live.test.ts | 2 +- src/channels/plugins/setup-wizard-helpers.ts | 9 +- src/commands/config-validation.test.ts | 3 +- src/commands/configure.wizard.ts | 10 +- .../doctor-legacy-config.migrations.test.ts | 2 + src/commands/doctor-legacy-config.ts | 19 +++- src/config/types.tools.ts | 22 +++++ src/config/zod-schema.agent-runtime.ts | 51 ++++++++++ src/image-generation/providers/fal.ts | 17 +++- src/infra/outbound/outbound-session.test.ts | 2 +- src/infra/outbound/outbound.test.ts | 2 +- src/plugin-sdk/acp-runtime.ts | 14 ++- src/plugin-sdk/channel-config-helpers.ts | 19 ++-- src/plugin-sdk/core.ts | 2 + .../package-contract-guardrails.test.ts | 8 +- src/plugin-sdk/telegram.ts | 5 +- src/plugins/contracts/shape.contract.test.ts | 1 + src/secrets/runtime-web-tools.test.ts | 5 +- src/web-search/runtime.test.ts | 1 + src/web-search/runtime.ts | 1 + ui/src/ui/views/config.browser.test.ts | 2 + 78 files changed, 476 insertions(+), 327 deletions(-) diff --git a/extensions/acpx/src/runtime-internals/events.ts b/extensions/acpx/src/runtime-internals/events.ts index 3bbfed68495..ac5f91acd5a 100644 --- a/extensions/acpx/src/runtime-internals/events.ts +++ b/extensions/acpx/src/runtime-internals/events.ts @@ -1,4 +1,4 @@ -import type { AcpRuntimeEvent, AcpSessionUpdateTag } from "../runtime-api.js"; +import type { AcpRuntimeEvent, AcpSessionUpdateTag } from "../../runtime-api.js"; import { asOptionalBoolean, asOptionalString, diff --git a/extensions/amazon-bedrock/index.test.ts b/extensions/amazon-bedrock/index.test.ts index 87ce6f6dcd2..049ebc45810 100644 --- a/extensions/amazon-bedrock/index.test.ts +++ b/extensions/amazon-bedrock/index.test.ts @@ -25,7 +25,7 @@ describe("amazon-bedrock provider plugin", () => { const wrapped = provider.wrapStreamFn?.({ provider: "amazon-bedrock", modelId: "amazon.nova-micro-v1:0", - streamFn: (_model, _context, options) => options, + streamFn: (_model: unknown, _context: unknown, options: Record) => options, } as never); expect( diff --git a/extensions/brave/src/brave-web-search-provider.ts b/extensions/brave/src/brave-web-search-provider.ts index 370fe77e854..3e1a6f1533a 100644 --- a/extensions/brave/src/brave-web-search-provider.ts +++ b/extensions/brave/src/brave-web-search-provider.ts @@ -132,8 +132,8 @@ function resolveBraveConfig( : ({ apiKey: (searchConfig as Record | undefined)?.apiKey } as BraveConfig); } -function resolveBraveMode(brave: BraveConfig): "web" | "llm-context" { - return brave.mode === "llm-context" ? "llm-context" : "web"; +function resolveBraveMode(brave?: BraveConfig): "web" | "llm-context" { + return brave?.mode === "llm-context" ? "llm-context" : "web"; } function resolveBraveApiKey( diff --git a/extensions/copilot-proxy/runtime-api.ts b/extensions/copilot-proxy/runtime-api.ts index 9f59e519281..849136c6efb 100644 --- a/extensions/copilot-proxy/runtime-api.ts +++ b/extensions/copilot-proxy/runtime-api.ts @@ -1 +1 @@ -export * from "../../src/plugin-sdk/copilot-proxy.js"; +export * from "openclaw/plugin-sdk/copilot-proxy"; diff --git a/extensions/device-pair/api.ts b/extensions/device-pair/api.ts index 137cd4b89ba..299ad90f05d 100644 --- a/extensions/device-pair/api.ts +++ b/extensions/device-pair/api.ts @@ -1 +1 @@ -export * from "../../src/plugin-sdk/device-pair.js"; +export * from "openclaw/plugin-sdk/device-pair"; diff --git a/extensions/diagnostics-otel/api.ts b/extensions/diagnostics-otel/api.ts index 077ad45965f..01d7aed8989 100644 --- a/extensions/diagnostics-otel/api.ts +++ b/extensions/diagnostics-otel/api.ts @@ -1 +1 @@ -export * from "../../src/plugin-sdk/diagnostics-otel.js"; +export * from "openclaw/plugin-sdk/diagnostics-otel"; diff --git a/extensions/diffs/api.ts b/extensions/diffs/api.ts index a200daea1fd..e6fbaf9022a 100644 --- a/extensions/diffs/api.ts +++ b/extensions/diffs/api.ts @@ -1 +1 @@ -export * from "../../src/plugin-sdk/diffs.js"; +export * from "openclaw/plugin-sdk/diffs"; diff --git a/extensions/discord/src/directory-config.ts b/extensions/discord/src/directory-config.ts index af921c25165..eef67a25200 100644 --- a/extensions/discord/src/directory-config.ts +++ b/extensions/discord/src/directory-config.ts @@ -1,18 +1,16 @@ import { applyDirectoryQueryAndLimit, collectNormalizedDirectoryIds, - inspectReadOnlyChannelAccount, toDirectoryEntries, type DirectoryConfigParams, } from "openclaw/plugin-sdk/directory-runtime"; -import type { InspectedDiscordAccount } from "../api.js"; +import { inspectDiscordAccount, type InspectedDiscordAccount } from "../api.js"; export async function listDiscordDirectoryPeersFromConfig(params: DirectoryConfigParams) { - const account = (await inspectReadOnlyChannelAccount({ - channelId: "discord", + const account = inspectDiscordAccount({ cfg: params.cfg, accountId: params.accountId, - })) as InspectedDiscordAccount | null; + }) as InspectedDiscordAccount | null; if (!account || !("config" in account)) { return []; } @@ -34,11 +32,10 @@ export async function listDiscordDirectoryPeersFromConfig(params: DirectoryConfi } export async function listDiscordDirectoryGroupsFromConfig(params: DirectoryConfigParams) { - const account = (await inspectReadOnlyChannelAccount({ - channelId: "discord", + const account = inspectDiscordAccount({ cfg: params.cfg, accountId: params.accountId, - })) as InspectedDiscordAccount | null; + }) as InspectedDiscordAccount | null; if (!account || !("config" in account)) { return []; } diff --git a/extensions/discord/src/runtime-api.ts b/extensions/discord/src/runtime-api.ts index 2aadbf90b9a..32fbf43e5e5 100644 --- a/extensions/discord/src/runtime-api.ts +++ b/extensions/discord/src/runtime-api.ts @@ -40,6 +40,7 @@ export type { ChannelMessageActionAdapter, ChannelMessageActionName, } from "openclaw/plugin-sdk/channel-runtime"; +export type { DiscordConfig } from "openclaw/plugin-sdk/discord"; export { assertMediaNotDataUrl, parseAvailableTags, diff --git a/extensions/google/runtime-api.ts b/extensions/google/runtime-api.ts index 3eaab2b0faf..7deb5b38f92 100644 --- a/extensions/google/runtime-api.ts +++ b/extensions/google/runtime-api.ts @@ -1 +1 @@ -export { normalizeGoogleModelId, parseGeminiAuth } from "openclaw/plugin-sdk/google"; +export * from "openclaw/plugin-sdk/google"; diff --git a/extensions/imessage/runtime-api.ts b/extensions/imessage/runtime-api.ts index 6cd9966f193..aa6d55c75e5 100644 --- a/extensions/imessage/runtime-api.ts +++ b/extensions/imessage/runtime-api.ts @@ -1,23 +1,19 @@ -export type { IMessageAccountConfig } from "../../src/config/types.imessage.js"; -export type { ChannelPlugin } from "../../src/channels/plugins/types.plugin.js"; export { DEFAULT_ACCOUNT_ID, PAIRING_APPROVED_MESSAGE, buildChannelConfigSchema, - getChatChannelMeta, -} from "../../src/plugin-sdk/channel-plugin-common.js"; -export { + collectStatusIssuesFromLastError, formatTrimmedAllowFromEntries, - resolveIMessageConfigAllowFrom, - resolveIMessageConfigDefaultTo, -} from "../../src/plugin-sdk/channel-config-helpers.js"; -export { collectStatusIssuesFromLastError } from "../../src/plugin-sdk/status-helpers.js"; -export { resolveChannelMediaMaxBytes } from "../../src/channels/plugins/media-limits.js"; -export { + getChatChannelMeta, looksLikeIMessageTargetId, normalizeIMessageMessagingTarget, -} from "../../src/channels/plugins/normalize/imessage.js"; -export { IMessageConfigSchema } from "../../src/config/zod-schema.providers-core.js"; + resolveChannelMediaMaxBytes, + resolveIMessageConfigAllowFrom, + resolveIMessageConfigDefaultTo, + IMessageConfigSchema, + type ChannelPlugin, + type IMessageAccountConfig, +} from "openclaw/plugin-sdk/imessage"; export { resolveIMessageGroupRequireMention, resolveIMessageGroupToolPolicy, diff --git a/extensions/irc/src/runtime-api.ts b/extensions/irc/src/runtime-api.ts index eebfe798ede..93214aeda45 100644 --- a/extensions/irc/src/runtime-api.ts +++ b/extensions/irc/src/runtime-api.ts @@ -1,53 +1 @@ -export { - addWildcardAllowFrom, - buildBaseAccountStatusSnapshot, - buildBaseChannelStatusSummary, - buildChannelConfigSchema, - createAccountListHelpers, - createAccountStatusSink, - createLoggerBackedRuntime, - createNormalizedOutboundDeliverer, - createReplyPrefixOptions, - createScopedPairingAccess, - dispatchInboundReplyWithBase, - emptyPluginConfigSchema, - formatDocsLink, - formatPairingApproveHint, - formatTextWithAttachmentLinks, - getChatChannelMeta, - GROUP_POLICY_BLOCKED_LABEL, - isDangerousNameMatchingEnabled, - issuePairingChallenge, - logInboundDrop, - normalizeResolvedSecretInputString, - parseOptionalDelimitedEntries, - PAIRING_APPROVED_MESSAGE, - patchScopedAccountConfig, - readStoreAllowFromForDmPolicy, - resolveAllowlistProviderRuntimeGroupPolicy, - resolveControlCommandGate, - resolveDefaultGroupPolicy, - resolveEffectiveAllowFromLists, - resolveOutboundMediaUrls, - runPassiveAccountLifecycle, - setAccountEnabledInConfigSection, - setTopLevelChannelAllowFrom, - setTopLevelChannelDmPolicyWithAllowFrom, - ToolPolicySchema, - warnMissingProviderGroupPolicyFallbackOnce, - type BaseProbeResult, - type BlockStreamingCoalesceConfig, - type ChannelPlugin, - type DmConfig, - type DmPolicy, - type GroupPolicy, - type GroupToolPolicyBySenderConfig, - type GroupToolPolicyConfig, - type MarkdownConfig, - type OpenClawConfig, - type OpenClawPluginApi, - type OutboundReplyPayload, - type PluginRuntime, - type RuntimeEnv, - type WizardPrompter, -} from "openclaw/plugin-sdk/irc"; +export * from "openclaw/plugin-sdk/irc"; diff --git a/extensions/line/src/channel.ts b/extensions/line/src/channel.ts index cd3fab965cc..33f2b7aa247 100644 --- a/extensions/line/src/channel.ts +++ b/extensions/line/src/channel.ts @@ -12,6 +12,7 @@ import { type ChannelStatusIssue, type LineConfig, type LineChannelData, + type OpenClawConfig, type ResolvedLineAccount, } from "../api.js"; import { lineConfigAdapter } from "./config-adapter.js"; diff --git a/extensions/llm-task/api.ts b/extensions/llm-task/api.ts index 25e5e13d5ca..8eebdd06e0b 100644 --- a/extensions/llm-task/api.ts +++ b/extensions/llm-task/api.ts @@ -1 +1 @@ -export * from "../../src/plugin-sdk/llm-task.js"; +export * from "openclaw/plugin-sdk/llm-task"; diff --git a/extensions/lobster/runtime-api.ts b/extensions/lobster/runtime-api.ts index 24898e04cf5..7ab2351b77d 100644 --- a/extensions/lobster/runtime-api.ts +++ b/extensions/lobster/runtime-api.ts @@ -1 +1 @@ -export * from "../../src/plugin-sdk/lobster.js"; +export * from "openclaw/plugin-sdk/lobster"; diff --git a/extensions/lobster/src/lobster-tool.test.ts b/extensions/lobster/src/lobster-tool.test.ts index 8c010e20f11..778cb695d88 100644 --- a/extensions/lobster/src/lobster-tool.test.ts +++ b/extensions/lobster/src/lobster-tool.test.ts @@ -3,8 +3,8 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { PassThrough } from "node:stream"; -import type { OpenClawPluginApi, OpenClawPluginToolContext } from "../runtime-api.js"; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import type { OpenClawPluginApi, OpenClawPluginToolContext } from "../runtime-api.js"; import { createWindowsCmdShimFixture, restorePlatformPathEnv, diff --git a/extensions/memory-lancedb/api.ts b/extensions/memory-lancedb/api.ts index ce6e02cf02f..c1bd12dd4b7 100644 --- a/extensions/memory-lancedb/api.ts +++ b/extensions/memory-lancedb/api.ts @@ -1 +1 @@ -export * from "../../src/plugin-sdk/memory-lancedb.js"; +export * from "openclaw/plugin-sdk/memory-lancedb"; diff --git a/extensions/open-prose/runtime-api.ts b/extensions/open-prose/runtime-api.ts index 1a7ce98ffef..1601f81be1f 100644 --- a/extensions/open-prose/runtime-api.ts +++ b/extensions/open-prose/runtime-api.ts @@ -1 +1 @@ -export * from "../../src/plugin-sdk/open-prose.js"; +export * from "openclaw/plugin-sdk/open-prose"; diff --git a/extensions/phone-control/runtime-api.ts b/extensions/phone-control/runtime-api.ts index c113b9802be..2e9e0adeba2 100644 --- a/extensions/phone-control/runtime-api.ts +++ b/extensions/phone-control/runtime-api.ts @@ -1 +1 @@ -export * from "../../src/plugin-sdk/phone-control.js"; +export * from "openclaw/plugin-sdk/phone-control"; diff --git a/extensions/qwen-portal-auth/index.ts b/extensions/qwen-portal-auth/index.ts index 384f58f4845..c5789e6cc08 100644 --- a/extensions/qwen-portal-auth/index.ts +++ b/extensions/qwen-portal-auth/index.ts @@ -1,5 +1,7 @@ import { ensureAuthProfileStore, listProfilesForProvider } from "openclaw/plugin-sdk/agent-runtime"; import { QWEN_OAUTH_MARKER } from "openclaw/plugin-sdk/agent-runtime"; +import { loginQwenPortalOAuth } from "./oauth.js"; +import { buildQwenPortalProvider, QWEN_PORTAL_BASE_URL } from "./provider-catalog.js"; import { buildOauthProviderAuthResult, definePluginEntry, @@ -7,8 +9,6 @@ import { type ProviderAuthContext, type ProviderCatalogContext, } from "./runtime-api.js"; -import { loginQwenPortalOAuth } from "./oauth.js"; -import { buildQwenPortalProvider, QWEN_PORTAL_BASE_URL } from "./provider-catalog.js"; const PROVIDER_ID = "qwen-portal"; const PROVIDER_LABEL = "Qwen"; diff --git a/extensions/qwen-portal-auth/runtime-api.ts b/extensions/qwen-portal-auth/runtime-api.ts index ccd9abae569..232a2886110 100644 --- a/extensions/qwen-portal-auth/runtime-api.ts +++ b/extensions/qwen-portal-auth/runtime-api.ts @@ -1 +1 @@ -export * from "../../src/plugin-sdk/qwen-portal-auth.js"; +export * from "openclaw/plugin-sdk/qwen-portal-auth"; diff --git a/extensions/signal/src/channel.setup.ts b/extensions/signal/src/channel.setup.ts index df5337a4761..d51edcaca10 100644 --- a/extensions/signal/src/channel.setup.ts +++ b/extensions/signal/src/channel.setup.ts @@ -1,6 +1,6 @@ import { type ResolvedSignalAccount } from "./accounts.js"; -import { signalSetupAdapter } from "./setup-core.js"; import { type ChannelPlugin } from "./runtime-api.js"; +import { signalSetupAdapter } from "./setup-core.js"; import { createSignalPluginBase, signalSetupWizard } from "./shared.js"; export const signalSetupPlugin: ChannelPlugin = { diff --git a/extensions/signal/src/channel.ts b/extensions/signal/src/channel.ts index 85aaadbd2c1..1879c85a7b0 100644 --- a/extensions/signal/src/channel.ts +++ b/extensions/signal/src/channel.ts @@ -4,6 +4,16 @@ import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/config-runtime"; import { buildOutboundBaseSessionKey } from "openclaw/plugin-sdk/core"; import { resolveTextChunkLimit } from "openclaw/plugin-sdk/reply-runtime"; import { type RoutePeer } from "openclaw/plugin-sdk/routing"; +import { resolveSignalAccount, type ResolvedSignalAccount } from "./accounts.js"; +import { markdownToSignalTextChunks } from "./format.js"; +import { + looksLikeUuid, + resolveSignalPeerId, + resolveSignalRecipient, + resolveSignalSender, +} from "./identity.js"; +import { signalMessageActions } from "./message-actions.js"; +import type { SignalProbe } from "./probe.js"; import { buildBaseAccountStatusSnapshot, buildBaseChannelStatusSummary, @@ -17,16 +27,6 @@ import { resolveChannelMediaMaxBytes, type ChannelPlugin, } from "./runtime-api.js"; -import { resolveSignalAccount, type ResolvedSignalAccount } from "./accounts.js"; -import { markdownToSignalTextChunks } from "./format.js"; -import { - looksLikeUuid, - resolveSignalPeerId, - resolveSignalRecipient, - resolveSignalSender, -} from "./identity.js"; -import { signalMessageActions } from "./message-actions.js"; -import type { SignalProbe } from "./probe.js"; import { getSignalRuntime } from "./runtime.js"; import { signalSetupAdapter } from "./setup-core.js"; import { diff --git a/extensions/signal/src/shared.ts b/extensions/signal/src/shared.ts index 1a0579e0236..1622dc207e4 100644 --- a/extensions/signal/src/shared.ts +++ b/extensions/signal/src/shared.ts @@ -4,6 +4,12 @@ import { createScopedDmSecurityResolver, } from "openclaw/plugin-sdk/channel-config-helpers"; import { createChannelPluginBase } from "openclaw/plugin-sdk/core"; +import { + listSignalAccountIds, + resolveDefaultSignalAccountId, + resolveSignalAccount, + type ResolvedSignalAccount, +} from "./accounts.js"; import { buildChannelConfigSchema, getChatChannelMeta, @@ -11,12 +17,6 @@ import { SignalConfigSchema, type ChannelPlugin, } from "./runtime-api.js"; -import { - listSignalAccountIds, - resolveDefaultSignalAccountId, - resolveSignalAccount, - type ResolvedSignalAccount, -} from "./accounts.js"; import { createSignalSetupWizardProxy } from "./setup-core.js"; export const SIGNAL_CHANNEL = "signal" as const; diff --git a/extensions/slack/src/channel.ts b/extensions/slack/src/channel.ts index 417f3b9a3b4..cbb86a1dff1 100644 --- a/extensions/slack/src/channel.ts +++ b/extensions/slack/src/channel.ts @@ -29,6 +29,8 @@ import { resolveSlackUserAllowlist } from "./resolve-users.js"; import { buildComputedAccountStatusSnapshot, DEFAULT_ACCOUNT_ID, + listSlackDirectoryGroupsFromConfig, + listSlackDirectoryPeersFromConfig, looksLikeSlackTargetId, normalizeSlackMessagingTarget, PAIRING_APPROVED_MESSAGE, diff --git a/extensions/slack/src/directory-config.ts b/extensions/slack/src/directory-config.ts index a74b2e4079d..8d7d4604ea1 100644 --- a/extensions/slack/src/directory-config.ts +++ b/extensions/slack/src/directory-config.ts @@ -1,20 +1,18 @@ import { applyDirectoryQueryAndLimit, collectNormalizedDirectoryIds, - inspectReadOnlyChannelAccount, listDirectoryGroupEntriesFromMapKeys, toDirectoryEntries, type DirectoryConfigParams, } from "openclaw/plugin-sdk/directory-runtime"; -import type { InspectedSlackAccount } from "../api.js"; +import { inspectSlackAccount, type InspectedSlackAccount } from "../api.js"; import { parseSlackTarget } from "./targets.js"; export async function listSlackDirectoryPeersFromConfig(params: DirectoryConfigParams) { - const account = (await inspectReadOnlyChannelAccount({ - channelId: "slack", + const account = inspectSlackAccount({ cfg: params.cfg, accountId: params.accountId, - })) as InspectedSlackAccount | null; + }) as InspectedSlackAccount | null; if (!account || !("config" in account)) { return []; } @@ -40,11 +38,10 @@ export async function listSlackDirectoryPeersFromConfig(params: DirectoryConfigP } export async function listSlackDirectoryGroupsFromConfig(params: DirectoryConfigParams) { - const account = (await inspectReadOnlyChannelAccount({ - channelId: "slack", + const account = inspectSlackAccount({ cfg: params.cfg, accountId: params.accountId, - })) as InspectedSlackAccount | null; + }) as InspectedSlackAccount | null; if (!account || !("config" in account)) { return []; } diff --git a/extensions/slack/src/runtime-api.ts b/extensions/slack/src/runtime-api.ts index 4988fa5d4f4..5dac68be756 100644 --- a/extensions/slack/src/runtime-api.ts +++ b/extensions/slack/src/runtime-api.ts @@ -1,34 +1,29 @@ -export type { OpenClawConfig } from "../../../src/config/config.js"; -export type { SlackAccountConfig } from "../../../src/config/types.slack.js"; -export type { ChannelPlugin } from "../../../src/channels/plugins/types.js"; - export { + buildComputedAccountStatusSnapshot, DEFAULT_ACCOUNT_ID, - buildChannelConfigSchema, - getChatChannelMeta, + looksLikeSlackTargetId, + normalizeSlackMessagingTarget, PAIRING_APPROVED_MESSAGE, -} from "../../../src/plugin-sdk/channel-plugin-common.js"; -export { buildComputedAccountStatusSnapshot } from "../../../src/plugin-sdk/status-helpers.js"; + projectCredentialSnapshotFields, + resolveConfiguredFromRequiredCredentialStatuses, + type ChannelPlugin, + type OpenClawConfig, + type SlackAccountConfig, +} from "openclaw/plugin-sdk/slack"; export { listSlackDirectoryGroupsFromConfig, listSlackDirectoryPeersFromConfig, } from "./directory-config.js"; export { - looksLikeSlackTargetId, - normalizeSlackMessagingTarget, -} from "../../../src/channels/plugins/normalize/slack.js"; -export { - projectCredentialSnapshotFields, - resolveConfiguredFromRequiredCredentialStatuses, -} from "../../../src/channels/account-snapshot-fields.js"; -export { SlackConfigSchema } from "../../../src/config/zod-schema.providers-core.js"; -export { + buildChannelConfigSchema, + getChatChannelMeta, createActionGate, imageResultFromFile, jsonResult, readNumberParam, readReactionParams, readStringParam, -} from "../../../src/agents/tools/common.js"; -export { withNormalizedTimestamp } from "../../../src/agents/date-time.js"; + SlackConfigSchema, + withNormalizedTimestamp, +} from "openclaw/plugin-sdk/slack-core"; export { isSlackInteractiveRepliesEnabled } from "./interactive-replies.js"; diff --git a/extensions/talk-voice/api.ts b/extensions/talk-voice/api.ts index 5f50f1a5247..a5ae821e944 100644 --- a/extensions/talk-voice/api.ts +++ b/extensions/talk-voice/api.ts @@ -1 +1 @@ -export * from "../../src/plugin-sdk/talk-voice.js"; +export * from "openclaw/plugin-sdk/talk-voice"; diff --git a/extensions/telegram/runtime-api.ts b/extensions/telegram/runtime-api.ts index b645e653834..c069a35e40e 100644 --- a/extensions/telegram/runtime-api.ts +++ b/extensions/telegram/runtime-api.ts @@ -1,16 +1,18 @@ export type { + ChannelMessageActionAdapter, ChannelPlugin, OpenClawConfig, - TelegramActionConfig, -} from "../../src/plugin-sdk/telegram-core.js"; -export type { ChannelMessageActionAdapter } from "../../src/channels/plugins/types.js"; -export type { TelegramAccountConfig, TelegramNetworkConfig } from "../../src/config/types.js"; -export type { OpenClawPluginApi, + PluginRuntime, + TelegramAccountConfig, + TelegramActionConfig, + TelegramNetworkConfig, +} from "openclaw/plugin-sdk/telegram"; +export type { OpenClawPluginService, OpenClawPluginServiceContext, PluginLogger, -} from "../../src/plugins/types.js"; +} from "openclaw/plugin-sdk/core"; export type { AcpRuntime, AcpRuntimeCapabilities, @@ -20,12 +22,22 @@ export type { AcpRuntimeHandle, AcpRuntimeStatus, AcpRuntimeTurnInput, + AcpRuntimeErrorCode, AcpSessionUpdateTag, -} from "../../src/acp/runtime/types.js"; -export type { AcpRuntimeErrorCode } from "../../src/acp/runtime/errors.js"; -export { AcpRuntimeError } from "../../src/acp/runtime/errors.js"; +} from "openclaw/plugin-sdk/acp-runtime"; +export { AcpRuntimeError } from "openclaw/plugin-sdk/acp-runtime"; -export { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../src/routing/session-key.js"; +export { + buildTokenChannelStatusSummary, + clearAccountEntryFields, + DEFAULT_ACCOUNT_ID, + normalizeAccountId, + PAIRING_APPROVED_MESSAGE, + parseTelegramTopicConversation, + projectCredentialSnapshotFields, + resolveConfiguredFromCredentialStatuses, + resolveTelegramPollVisibility, +} from "openclaw/plugin-sdk/telegram"; export { buildChannelConfigSchema, getChatChannelMeta, @@ -37,13 +49,31 @@ export { readStringParam, resolvePollMaxSelections, TelegramConfigSchema, -} from "../../src/plugin-sdk/telegram-core.js"; -export { parseTelegramTopicConversation } from "../../src/acp/conversation-id.js"; -export { clearAccountEntryFields } from "../../src/channels/plugins/config-helpers.js"; -export { buildTokenChannelStatusSummary } from "../../src/plugin-sdk/status-helpers.js"; +} from "openclaw/plugin-sdk/telegram-core"; +export type { TelegramProbe } from "./src/probe.js"; +export { auditTelegramGroupMembership, collectTelegramUnmentionedGroupIds } from "./src/audit.js"; +export { telegramMessageActions } from "./src/channel-actions.js"; +export { monitorTelegramProvider } from "./src/monitor.js"; +export { probeTelegram } from "./src/probe.js"; export { - projectCredentialSnapshotFields, - resolveConfiguredFromCredentialStatuses, -} from "../../src/channels/account-snapshot-fields.js"; -export { resolveTelegramPollVisibility } from "../../src/poll-params.js"; -export { PAIRING_APPROVED_MESSAGE } from "../../src/channels/plugins/pairing-message.js"; + createForumTopicTelegram, + deleteMessageTelegram, + editForumTopicTelegram, + editMessageReplyMarkupTelegram, + editMessageTelegram, + pinMessageTelegram, + reactMessageTelegram, + renameForumTopicTelegram, + sendMessageTelegram, + sendPollTelegram, + sendStickerTelegram, + sendTypingTelegram, + unpinMessageTelegram, +} from "./src/send.js"; +export { + createTelegramThreadBindingManager, + getTelegramThreadBindingManager, + setTelegramThreadBindingIdleTimeoutBySessionKey, + setTelegramThreadBindingMaxAgeBySessionKey, +} from "./src/thread-bindings.js"; +export { resolveTelegramToken } from "./src/token.js"; diff --git a/extensions/telegram/src/bot-native-commands.menu-test-support.ts b/extensions/telegram/src/bot-native-commands.menu-test-support.ts index 5d0f90257e5..8b68368d84f 100644 --- a/extensions/telegram/src/bot-native-commands.menu-test-support.ts +++ b/extensions/telegram/src/bot-native-commands.menu-test-support.ts @@ -1,5 +1,6 @@ import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; import { expect, vi } from "vitest"; +import type { SkillCommandSpec } from "../../../src/agents/skills.js"; import type { OpenClawConfig } from "../runtime-api.js"; import type { TelegramBotDeps } from "./bot-deps.js"; import { @@ -8,6 +9,12 @@ import { type NativeCommandTestParams as RegisterTelegramNativeCommandsParams, } from "./bot-native-commands.fixture-test-support.js"; +const EMPTY_REPLY_COUNTS = { + block: 0, + final: 0, + tool: 0, +} as const; + type RegisteredCommand = { command: string; description: string; @@ -21,7 +28,9 @@ type CreateCommandBotResult = { }; const skillCommandMocks = vi.hoisted(() => ({ - listSkillCommandsForAgents: vi.fn(() => []), + listSkillCommandsForAgents: vi.fn< + (params: { cfg: OpenClawConfig; agentIds?: string[] }) => SkillCommandSpec[] + >(() => []), })); const deliveryMocks = vi.hoisted(() => ({ @@ -86,7 +95,7 @@ export function createNativeCommandTestParams( enqueueSystemEvent: vi.fn(), dispatchReplyWithBufferedBlockDispatcher: vi.fn(async () => ({ queuedFinal: false, - counts: {}, + counts: EMPTY_REPLY_COUNTS, })), listSkillCommandsForAgents, wasSentByBot: vi.fn(() => false), diff --git a/extensions/telegram/src/bot-native-commands.test.ts b/extensions/telegram/src/bot-native-commands.test.ts index f2737d98f89..043baf9b2b6 100644 --- a/extensions/telegram/src/bot-native-commands.test.ts +++ b/extensions/telegram/src/bot-native-commands.test.ts @@ -37,6 +37,12 @@ import { waitForRegisteredCommands, } from "./bot-native-commands.menu-test-support.js"; +const EMPTY_REPLY_COUNTS = { + block: 0, + final: 0, + tool: 0, +} as const; + function createNativeCommandTestParams( cfg: OpenClawConfig, params: Partial[0]> = {}, @@ -48,7 +54,7 @@ function createNativeCommandTestParams( enqueueSystemEvent: vi.fn(), dispatchReplyWithBufferedBlockDispatcher: vi.fn(async () => ({ queuedFinal: false, - counts: {}, + counts: EMPTY_REPLY_COUNTS, })), listSkillCommandsForAgents: skillCommandMocks.listSkillCommandsForAgents, wasSentByBot: vi.fn(() => false), diff --git a/extensions/telegram/src/bot.create-telegram-bot.test-harness.ts b/extensions/telegram/src/bot.create-telegram-bot.test-harness.ts index 648638bd23b..f2f8f89ce63 100644 --- a/extensions/telegram/src/bot.create-telegram-bot.test-harness.ts +++ b/extensions/telegram/src/bot.create-telegram-bot.test-harness.ts @@ -4,23 +4,21 @@ import type { MsgContext } from "openclaw/plugin-sdk/reply-runtime"; import type { GetReplyOptions, ReplyPayload } from "openclaw/plugin-sdk/reply-runtime"; import type { MockFn } from "openclaw/plugin-sdk/testing"; import { beforeEach, vi } from "vitest"; +import type { TelegramBotDeps } from "./bot-deps.js"; -type AnyMock = MockFn<(...args: unknown[]) => unknown>; -type AnyAsyncMock = MockFn<(...args: unknown[]) => Promise>; +type AnyMock = ReturnType; +type AnyAsyncMock = ReturnType; type DispatchReplyWithBufferedBlockDispatcherFn = typeof import("openclaw/plugin-sdk/reply-runtime").dispatchReplyWithBufferedBlockDispatcher; type DispatchReplyWithBufferedBlockDispatcherResult = Awaited< ReturnType >; -type DispatchReplyHarnessParams = { - ctx: MsgContext; - replyOptions?: GetReplyOptions; - dispatcherOptions?: { - typingCallbacks?: { - start?: () => void | Promise; - }; - deliver?: (payload: ReplyPayload, info: { kind: "final" }) => void | Promise; - }; +type DispatchReplyHarnessParams = Parameters[0]; + +const EMPTY_REPLY_COUNTS: DispatchReplyWithBufferedBlockDispatcherResult["counts"] = { + block: 0, + final: 0, + tool: 0, }; const { sessionStorePath } = vi.hoisted(() => ({ @@ -39,12 +37,14 @@ vi.doMock("openclaw/plugin-sdk/web-media", () => ({ loadWebMedia, })); -const { loadConfig } = vi.hoisted((): { loadConfig: AnyMock } => ({ - loadConfig: vi.fn(() => ({})), -})); -const { resolveStorePathMock } = vi.hoisted((): { resolveStorePathMock: AnyMock } => ({ - resolveStorePathMock: vi.fn((storePath?: string) => storePath ?? sessionStorePath), +const { loadConfig } = vi.hoisted((): { loadConfig: MockFn<() => OpenClawConfig> } => ({ + loadConfig: vi.fn(() => ({}) as OpenClawConfig), })); +const { resolveStorePathMock } = vi.hoisted( + (): { resolveStorePathMock: MockFn } => ({ + resolveStorePathMock: vi.fn((storePath?: string) => storePath ?? sessionStorePath), + }), +); export function getLoadConfigMock(): AnyMock { return loadConfig; @@ -67,7 +67,7 @@ vi.doMock("openclaw/plugin-sdk/config-runtime", async (importOriginal) => { const { readChannelAllowFromStore, upsertChannelPairingRequest } = vi.hoisted( (): { - readChannelAllowFromStore: AnyAsyncMock; + readChannelAllowFromStore: MockFn; upsertChannelPairingRequest: AnyAsyncMock; } => ({ readChannelAllowFromStore: vi.fn(async () => [] as string[]), @@ -111,9 +111,9 @@ const skillCommandsHoisted = vi.hoisted(() => ({ async (params: DispatchReplyHarnessParams) => { const result: DispatchReplyWithBufferedBlockDispatcherResult = { queuedFinal: false, - counts: {} as DispatchReplyWithBufferedBlockDispatcherResult["counts"], + counts: EMPTY_REPLY_COUNTS, }; - await params.dispatcherOptions?.typingCallbacks?.start?.(); + await params.dispatcherOptions?.typingCallbacks?.onReplyStart?.(); const reply = await skillCommandsHoisted.replySpy(params.ctx, params.replyOptions); const payloads = reply === undefined ? [] : Array.isArray(reply) ? reply : [reply]; for (const payload of payloads) { @@ -141,9 +141,10 @@ vi.doMock("openclaw/plugin-sdk/reply-runtime", async (importOriginal) => { }); const systemEventsHoisted = vi.hoisted(() => ({ - enqueueSystemEventSpy: vi.fn(), + enqueueSystemEventSpy: vi.fn(() => false), })); -export const enqueueSystemEventSpy: AnyMock = systemEventsHoisted.enqueueSystemEventSpy; +export const enqueueSystemEventSpy: MockFn = + systemEventsHoisted.enqueueSystemEventSpy; vi.doMock("openclaw/plugin-sdk/infra-runtime", async (importOriginal) => { const actual = await importOriginal(); @@ -173,7 +174,7 @@ const grammySpies = vi.hoisted(() => ({ onSpy: vi.fn() as AnyMock, stopSpy: vi.fn() as AnyMock, commandSpy: vi.fn() as AnyMock, - botCtorSpy: vi.fn() as AnyMock, + botCtorSpy: vi.fn((_: string, __?: { client?: { fetch?: typeof fetch } }) => undefined), answerCallbackQuerySpy: vi.fn(async () => undefined) as AnyAsyncMock, sendChatActionSpy: vi.fn() as AnyMock, editMessageTextSpy: vi.fn(async () => ({ message_id: 88 })) as AnyAsyncMock, @@ -191,26 +192,26 @@ const grammySpies = vi.hoisted(() => ({ getFileSpy: vi.fn(async () => ({ file_path: "media/file.jpg" })) as AnyAsyncMock, })); -export const { - useSpy, - middlewareUseSpy, - onSpy, - stopSpy, - commandSpy, - botCtorSpy, - answerCallbackQuerySpy, - sendChatActionSpy, - editMessageTextSpy, - editMessageReplyMarkupSpy, - sendMessageDraftSpy, - setMessageReactionSpy, - setMyCommandsSpy, - getMeSpy, - sendMessageSpy, - sendAnimationSpy, - sendPhotoSpy, - getFileSpy, -} = grammySpies; +export const useSpy: MockFn<(arg: unknown) => void> = grammySpies.useSpy; +export const middlewareUseSpy: AnyMock = grammySpies.middlewareUseSpy; +export const onSpy: AnyMock = grammySpies.onSpy; +export const stopSpy: AnyMock = grammySpies.stopSpy; +export const commandSpy: AnyMock = grammySpies.commandSpy; +export const botCtorSpy: MockFn< + (token: string, options?: { client?: { fetch?: typeof fetch } }) => void +> = grammySpies.botCtorSpy; +export const answerCallbackQuerySpy: AnyAsyncMock = grammySpies.answerCallbackQuerySpy; +export const sendChatActionSpy: AnyMock = grammySpies.sendChatActionSpy; +export const editMessageTextSpy: AnyAsyncMock = grammySpies.editMessageTextSpy; +export const editMessageReplyMarkupSpy: AnyAsyncMock = grammySpies.editMessageReplyMarkupSpy; +export const sendMessageDraftSpy: AnyAsyncMock = grammySpies.sendMessageDraftSpy; +export const setMessageReactionSpy: AnyAsyncMock = grammySpies.setMessageReactionSpy; +export const setMyCommandsSpy: AnyAsyncMock = grammySpies.setMyCommandsSpy; +export const getMeSpy: AnyAsyncMock = grammySpies.getMeSpy; +export const sendMessageSpy: AnyAsyncMock = grammySpies.sendMessageSpy; +export const sendAnimationSpy: AnyAsyncMock = grammySpies.sendAnimationSpy; +export const sendPhotoSpy: AnyAsyncMock = grammySpies.sendPhotoSpy; +export const getFileSpy: AnyAsyncMock = grammySpies.getFileSpy; const runnerHoisted = vi.hoisted(() => ({ sequentializeMiddleware: vi.fn(async (_ctx: unknown, next?: () => Promise) => { @@ -224,7 +225,11 @@ const runnerHoisted = vi.hoisted(() => ({ export const sequentializeSpy: AnyMock = runnerHoisted.sequentializeSpy; export let sequentializeKey: ((ctx: unknown) => string) | undefined; export const throttlerSpy: AnyMock = runnerHoisted.throttlerSpy; -export const telegramBotRuntimeForTest = { +export const telegramBotRuntimeForTest: { + Bot: new (token: string, options?: { client?: { fetch?: typeof fetch } }) => unknown; + sequentialize: (keyFn: (ctx: unknown) => string) => unknown; + apiThrottler: () => unknown; +} = { Bot: class { api = { config: { use: grammySpies.useSpy }, @@ -259,7 +264,7 @@ export const telegramBotRuntimeForTest = { }, apiThrottler: () => runnerHoisted.throttlerSpy(), }; -export const telegramBotDepsForTest = { +export const telegramBotDepsForTest: TelegramBotDeps = { loadConfig, resolveStorePath: resolveStorePathMock, readChannelAllowFromStore, @@ -365,9 +370,9 @@ beforeEach(() => { async (params: DispatchReplyHarnessParams) => { const result: DispatchReplyWithBufferedBlockDispatcherResult = { queuedFinal: false, - counts: {} as DispatchReplyWithBufferedBlockDispatcherResult["counts"], + counts: EMPTY_REPLY_COUNTS, }; - await params.dispatcherOptions?.typingCallbacks?.start?.(); + await params.dispatcherOptions?.typingCallbacks?.onReplyStart?.(); const reply = await replySpy(params.ctx, params.replyOptions); const payloads = reply === undefined ? [] : Array.isArray(reply) ? reply : [reply]; for (const payload of payloads) { diff --git a/extensions/telegram/src/bot.create-telegram-bot.test.ts b/extensions/telegram/src/bot.create-telegram-bot.test.ts index b9098fc7b37..5c05d54a2c7 100644 --- a/extensions/telegram/src/bot.create-telegram-bot.test.ts +++ b/extensions/telegram/src/bot.create-telegram-bot.test.ts @@ -39,7 +39,9 @@ const { getTelegramSequentialKey, setTelegramBotRuntimeForTest, } = await import("./bot.js"); -setTelegramBotRuntimeForTest(telegramBotRuntimeForTest); +setTelegramBotRuntimeForTest( + telegramBotRuntimeForTest as unknown as Parameters[0], +); const createTelegramBot = (opts: Parameters[0]) => createTelegramBotBase({ ...opts, diff --git a/extensions/telegram/src/bot.fetch-abort.test.ts b/extensions/telegram/src/bot.fetch-abort.test.ts index 7a58aa86e87..63e53a1ba5c 100644 --- a/extensions/telegram/src/bot.fetch-abort.test.ts +++ b/extensions/telegram/src/bot.fetch-abort.test.ts @@ -6,7 +6,9 @@ const { botCtorSpy, telegramBotDepsForTest } = const { telegramBotRuntimeForTest } = await import("./bot.create-telegram-bot.test-harness.js"); const { createTelegramBot: createTelegramBotBase, setTelegramBotRuntimeForTest } = await import("./bot.js"); -setTelegramBotRuntimeForTest(telegramBotRuntimeForTest); +setTelegramBotRuntimeForTest( + telegramBotRuntimeForTest as unknown as Parameters[0], +); const createTelegramBot = (opts: Parameters[0]) => createTelegramBotBase({ ...opts, diff --git a/extensions/telegram/src/bot.media.e2e-harness.ts b/extensions/telegram/src/bot.media.e2e-harness.ts index e21308c7403..7054b69d06a 100644 --- a/extensions/telegram/src/bot.media.e2e-harness.ts +++ b/extensions/telegram/src/bot.media.e2e-harness.ts @@ -1,5 +1,12 @@ import { resetInboundDedupe } from "openclaw/plugin-sdk/reply-runtime"; import { beforeEach, vi, type Mock } from "vitest"; +import type { TelegramBotDeps } from "./bot-deps.js"; + +const EMPTY_REPLY_COUNTS = { + block: 0, + final: 0, + tool: 0, +} as const; export const useSpy: Mock = vi.fn(); export const middlewareUseSpy: Mock = vi.fn(); @@ -56,7 +63,11 @@ const apiStub: ApiStub = { setMyCommands: vi.fn(async () => undefined), }; -export const telegramBotRuntimeForTest = { +export const telegramBotRuntimeForTest: { + Bot: new (token: string) => unknown; + sequentialize: () => unknown; + apiThrottler: () => unknown; +} = { Bot: class { api = apiStub; use = middlewareUseSpy; @@ -84,12 +95,12 @@ const mediaHarnessDispatchReplyWithBufferedBlockDispatcher = vi.hoisted(() => for (const payload of payloads) { await params.dispatcherOptions?.deliver?.(payload, { kind: "final" }); } - return { queuedFinal: false, counts: {} }; + return { queuedFinal: false, counts: EMPTY_REPLY_COUNTS }; }), ); -export const telegramBotDepsForTest = { +export const telegramBotDepsForTest: TelegramBotDeps = { loadConfig: () => ({ - channels: { telegram: { dmPolicy: "open", allowFrom: ["*"] } }, + channels: { telegram: { dmPolicy: "open" as const, allowFrom: ["*"] } }, }), resolveStorePath: vi.fn((storePath?: string) => storePath ?? "/tmp/telegram-media-sessions.json"), readChannelAllowFromStore: vi.fn(async () => [] as string[]), diff --git a/extensions/telegram/src/bot.media.test-utils.ts b/extensions/telegram/src/bot.media.test-utils.ts index a98afa96b69..7c391642d67 100644 --- a/extensions/telegram/src/bot.media.test-utils.ts +++ b/extensions/telegram/src/bot.media.test-utils.ts @@ -107,7 +107,11 @@ beforeAll(async () => { onSpyRef = harness.onSpy; sendChatActionSpyRef = harness.sendChatActionSpy; const botModule = await import("./bot.js"); - botModule.setTelegramBotRuntimeForTest(harness.telegramBotRuntimeForTest); + botModule.setTelegramBotRuntimeForTest( + harness.telegramBotRuntimeForTest as unknown as Parameters< + typeof botModule.setTelegramBotRuntimeForTest + >[0], + ); createTelegramBotRef = (opts) => botModule.createTelegramBot({ ...opts, diff --git a/extensions/telegram/src/bot.test.ts b/extensions/telegram/src/bot.test.ts index 7df6fa8816b..2de1e06fc6d 100644 --- a/extensions/telegram/src/bot.test.ts +++ b/extensions/telegram/src/bot.test.ts @@ -35,7 +35,9 @@ const { normalizeTelegramCommandName } = await import("../../../src/config/telegram-custom-commands.js"); const { createTelegramBot: createTelegramBotBase, setTelegramBotRuntimeForTest } = await import("./bot.js"); -setTelegramBotRuntimeForTest(telegramBotRuntimeForTest); +setTelegramBotRuntimeForTest( + telegramBotRuntimeForTest as unknown as Parameters[0], +); const createTelegramBot = (opts: Parameters[0]) => createTelegramBotBase({ ...opts, diff --git a/extensions/telegram/src/directory-config.ts b/extensions/telegram/src/directory-config.ts index 08b9c3597e2..5aeb9785779 100644 --- a/extensions/telegram/src/directory-config.ts +++ b/extensions/telegram/src/directory-config.ts @@ -2,19 +2,17 @@ import { mapAllowFromEntries } from "openclaw/plugin-sdk/channel-config-helpers" import { applyDirectoryQueryAndLimit, collectNormalizedDirectoryIds, - inspectReadOnlyChannelAccount, listDirectoryGroupEntriesFromMapKeys, toDirectoryEntries, type DirectoryConfigParams, } from "openclaw/plugin-sdk/directory-runtime"; -import type { InspectedTelegramAccount } from "../api.js"; +import { inspectTelegramAccount, type InspectedTelegramAccount } from "../api.js"; export async function listTelegramDirectoryPeersFromConfig(params: DirectoryConfigParams) { - const account = (await inspectReadOnlyChannelAccount({ - channelId: "telegram", + const account = inspectTelegramAccount({ cfg: params.cfg, accountId: params.accountId, - })) as InspectedTelegramAccount | null; + }) as InspectedTelegramAccount | null; if (!account || !("config" in account)) { return []; } @@ -36,11 +34,10 @@ export async function listTelegramDirectoryPeersFromConfig(params: DirectoryConf } export async function listTelegramDirectoryGroupsFromConfig(params: DirectoryConfigParams) { - const account = (await inspectReadOnlyChannelAccount({ - channelId: "telegram", + const account = inspectTelegramAccount({ cfg: params.cfg, accountId: params.accountId, - })) as InspectedTelegramAccount | null; + }) as InspectedTelegramAccount | null; if (!account || !("config" in account)) { return []; } diff --git a/extensions/thread-ownership/api.ts b/extensions/thread-ownership/api.ts index 16e4afef70a..d94a5fd68e1 100644 --- a/extensions/thread-ownership/api.ts +++ b/extensions/thread-ownership/api.ts @@ -1 +1 @@ -export * from "../../src/plugin-sdk/thread-ownership.js"; +export * from "openclaw/plugin-sdk/thread-ownership"; diff --git a/extensions/twitch/api.ts b/extensions/twitch/api.ts index 4743a12fb3b..68033283423 100644 --- a/extensions/twitch/api.ts +++ b/extensions/twitch/api.ts @@ -1,2 +1 @@ export * from "openclaw/plugin-sdk/twitch"; -export * from "./src/setup-surface.js"; diff --git a/extensions/voice-call/api.ts b/extensions/voice-call/api.ts index d0f69774b5e..ef9f7d7a3c0 100644 --- a/extensions/voice-call/api.ts +++ b/extensions/voice-call/api.ts @@ -1 +1 @@ -export * from "../../src/plugin-sdk/voice-call.js"; +export * from "openclaw/plugin-sdk/voice-call"; diff --git a/extensions/whatsapp/src/runtime-api.ts b/extensions/whatsapp/src/runtime-api.ts index ce89a02eb76..a0f07404a91 100644 --- a/extensions/whatsapp/src/runtime-api.ts +++ b/extensions/whatsapp/src/runtime-api.ts @@ -1,22 +1,30 @@ export { + buildChannelConfigSchema, createActionGate, - createWhatsAppOutboundBase, DEFAULT_ACCOUNT_ID, formatWhatsAppConfigAllowFromEntries, - isWhatsAppGroupJid, + getChatChannelMeta, jsonResult, - normalizeWhatsAppTarget, + normalizeE164, readReactionParams, readStringParam, - resolveWhatsAppHeartbeatRecipients, - resolveWhatsAppMentionStripRegexes, + resolveWhatsAppGroupIntroHint, resolveWhatsAppOutboundTarget, ToolAuthorizationError, + WhatsAppConfigSchema, type ChannelPlugin, + type OpenClawConfig, +} from "openclaw/plugin-sdk/whatsapp-core"; + +export { + createWhatsAppOutboundBase, + isWhatsAppGroupJid, + normalizeWhatsAppTarget, + resolveWhatsAppHeartbeatRecipients, + resolveWhatsAppMentionStripRegexes, type ChannelMessageActionName, type DmPolicy, type GroupPolicy, - type OpenClawConfig, type WhatsAppAccountConfig, } from "openclaw/plugin-sdk/whatsapp"; diff --git a/extensions/xai/web-search.test.ts b/extensions/xai/web-search.test.ts index 0c15d09864c..29433ec7efa 100644 --- a/extensions/xai/web-search.test.ts +++ b/extensions/xai/web-search.test.ts @@ -3,7 +3,7 @@ import { resolveWebSearchProviderCredential, } from "openclaw/plugin-sdk/provider-web-search"; import { describe, expect, it } from "vitest"; -import { withEnv } from "../../src/test-utils/env.js"; +import { withEnv } from "../../test/helpers/extensions/env.js"; import { __testing } from "./web-search.js"; const { extractXaiWebSearchContent, resolveXaiInlineCitations, resolveXaiWebSearchModel } = diff --git a/extensions/zalo/src/actions.ts b/extensions/zalo/src/actions.ts index b6b5c5b95f3..89b284df789 100644 --- a/extensions/zalo/src/actions.ts +++ b/extensions/zalo/src/actions.ts @@ -1,11 +1,11 @@ import { createLazyRuntimeNamedExport } from "openclaw/plugin-sdk/lazy-runtime"; +import { listEnabledZaloAccounts } from "./accounts.js"; import type { ChannelMessageActionAdapter, ChannelMessageActionName, OpenClawConfig, } from "./runtime-api.js"; import { extractToolSend, jsonResult, readStringParam } from "./runtime-api.js"; -import { listEnabledZaloAccounts } from "./accounts.js"; const loadZaloActionsRuntime = createLazyRuntimeNamedExport( () => import("./actions.runtime.js"), diff --git a/extensions/zalo/src/channel.runtime.ts b/extensions/zalo/src/channel.runtime.ts index 39702a439fc..6b76e0e92eb 100644 --- a/extensions/zalo/src/channel.runtime.ts +++ b/extensions/zalo/src/channel.runtime.ts @@ -1,18 +1,15 @@ import { createAccountStatusSink } from "openclaw/plugin-sdk/channel-lifecycle"; import { probeZalo } from "./probe.js"; import { resolveZaloProxyFetch } from "./proxy.js"; -import { normalizeSecretInputString } from "./secret-input.js"; -import { sendMessageZalo } from "./send.js"; import { PAIRING_APPROVED_MESSAGE, type ChannelPlugin, type OpenClawConfig, } from "./runtime-api.js"; +import { normalizeSecretInputString } from "./secret-input.js"; +import { sendMessageZalo } from "./send.js"; -export async function notifyZaloPairingApproval(params: { - cfg: OpenClawConfig; - id: string; -}) { +export async function notifyZaloPairingApproval(params: { cfg: OpenClawConfig; id: string }) { const { resolveZaloAccount } = await import("./accounts.js"); const account = resolveZaloAccount({ cfg: params.cfg }); if (!account.token) { @@ -44,11 +41,7 @@ export async function probeZaloAccount(params: { } export async function startZaloGatewayAccount( - ctx: Parameters< - NonNullable< - NonNullable["startAccount"] - > - >[0], + ctx: Parameters["startAccount"]>>[0], ) { const account = ctx.account; const token = account.token.trim(); diff --git a/extensions/zalo/src/channel.ts b/extensions/zalo/src/channel.ts index a9cfea6f9ad..5434b3e144e 100644 --- a/extensions/zalo/src/channel.ts +++ b/extensions/zalo/src/channel.ts @@ -9,6 +9,14 @@ import { collectOpenProviderGroupPolicyWarnings, } from "openclaw/plugin-sdk/channel-policy"; import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime"; +import { + listZaloAccountIds, + resolveDefaultZaloAccountId, + resolveZaloAccount, + type ResolvedZaloAccount, +} from "./accounts.js"; +import { zaloMessageActions } from "./actions.js"; +import { ZaloConfigSchema } from "./config-schema.js"; import { buildBaseAccountStatusSnapshot, buildChannelConfigSchema, @@ -24,14 +32,6 @@ import { type ChannelPlugin, type OpenClawConfig, } from "./runtime-api.js"; -import { - listZaloAccountIds, - resolveDefaultZaloAccountId, - resolveZaloAccount, - type ResolvedZaloAccount, -} from "./accounts.js"; -import { zaloMessageActions } from "./actions.js"; -import { ZaloConfigSchema } from "./config-schema.js"; import { resolveZaloOutboundSessionRoute } from "./session-route.js"; import { zaloSetupAdapter } from "./setup-core.js"; import { zaloSetupWizard } from "./setup-surface.js"; diff --git a/extensions/zalo/src/config-schema.ts b/extensions/zalo/src/config-schema.ts index 70b863779c1..75d8027cf47 100644 --- a/extensions/zalo/src/config-schema.ts +++ b/extensions/zalo/src/config-schema.ts @@ -5,8 +5,8 @@ import { GroupPolicySchema, } from "openclaw/plugin-sdk/channel-config-schema"; import { z } from "zod"; -import { buildSecretInputSchema } from "./secret-input.js"; import { MarkdownConfigSchema } from "./runtime-api.js"; +import { buildSecretInputSchema } from "./secret-input.js"; const zaloAccountSchema = z.object({ name: z.string().optional(), diff --git a/extensions/zalo/src/monitor.ts b/extensions/zalo/src/monitor.ts index ee97207cf3b..8452fb661e2 100644 --- a/extensions/zalo/src/monitor.ts +++ b/extensions/zalo/src/monitor.ts @@ -1,25 +1,4 @@ import type { IncomingMessage, ServerResponse } from "node:http"; -import type { - MarkdownTableMode, - OpenClawConfig, - OutboundReplyPayload, -} from "./runtime-api.js"; -import { - createTypingCallbacks, - createScopedPairingAccess, - createReplyPrefixOptions, - issuePairingChallenge, - logTypingFailure, - resolveDirectDmAuthorizationOutcome, - resolveSenderCommandAuthorizationWithRuntime, - resolveOutboundMediaUrls, - resolveDefaultGroupPolicy, - resolveInboundRouteEnvelopeBuilderWithRuntime, - sendMediaWithLeadingCaption, - resolveWebhookPath, - waitForAbortSignal, - warnMissingProviderGroupPolicyFallbackOnce, -} from "./runtime-api.js"; import type { ResolvedZaloAccount } from "./accounts.js"; import { ZaloApiError, @@ -48,6 +27,23 @@ import { type ZaloWebhookTarget, } from "./monitor.webhook.js"; import { resolveZaloProxyFetch } from "./proxy.js"; +import type { MarkdownTableMode, OpenClawConfig, OutboundReplyPayload } from "./runtime-api.js"; +import { + createTypingCallbacks, + createScopedPairingAccess, + createReplyPrefixOptions, + issuePairingChallenge, + logTypingFailure, + resolveDirectDmAuthorizationOutcome, + resolveSenderCommandAuthorizationWithRuntime, + resolveOutboundMediaUrls, + resolveDefaultGroupPolicy, + resolveInboundRouteEnvelopeBuilderWithRuntime, + sendMediaWithLeadingCaption, + resolveWebhookPath, + waitForAbortSignal, + warnMissingProviderGroupPolicyFallbackOnce, +} from "./runtime-api.js"; import { getZaloRuntime } from "./runtime.js"; export type ZaloRuntimeEnv = { diff --git a/extensions/zalo/src/monitor.webhook.ts b/extensions/zalo/src/monitor.webhook.ts index e058dcc453c..02a82bf0544 100644 --- a/extensions/zalo/src/monitor.webhook.ts +++ b/extensions/zalo/src/monitor.webhook.ts @@ -1,5 +1,8 @@ import { timingSafeEqual } from "node:crypto"; import type { IncomingMessage, ServerResponse } from "node:http"; +import type { ResolvedZaloAccount } from "./accounts.js"; +import type { ZaloFetch, ZaloUpdate } from "./api.js"; +import type { ZaloRuntimeEnv } from "./monitor.js"; import { createDedupeCache, createFixedWindowRateLimiter, @@ -17,9 +20,6 @@ import { resolveClientIp, type OpenClawConfig, } from "./runtime-api.js"; -import type { ResolvedZaloAccount } from "./accounts.js"; -import type { ZaloFetch, ZaloUpdate } from "./api.js"; -import type { ZaloRuntimeEnv } from "./monitor.js"; const ZALO_WEBHOOK_REPLAY_WINDOW_MS = 5 * 60_000; diff --git a/extensions/zalo/src/send.ts b/extensions/zalo/src/send.ts index d83bd16114d..647ca3b9823 100644 --- a/extensions/zalo/src/send.ts +++ b/extensions/zalo/src/send.ts @@ -2,8 +2,8 @@ import { resolveZaloAccount } from "./accounts.js"; import type { ZaloFetch } from "./api.js"; import { sendMessage, sendPhoto } from "./api.js"; import { resolveZaloProxyFetch } from "./proxy.js"; -import { resolveZaloToken } from "./token.js"; import type { OpenClawConfig } from "./runtime-api.js"; +import { resolveZaloToken } from "./token.js"; export type ZaloSendOptions = { token?: string; diff --git a/extensions/zalo/src/token.ts b/extensions/zalo/src/token.ts index c593cb5b824..2ee4ffa4283 100644 --- a/extensions/zalo/src/token.ts +++ b/extensions/zalo/src/token.ts @@ -1,8 +1,8 @@ import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id"; import { tryReadSecretFileSync } from "openclaw/plugin-sdk/infra-runtime"; +import type { BaseTokenResolution } from "./runtime-api.js"; import { normalizeResolvedSecretInputString, normalizeSecretInputString } from "./secret-input.js"; import type { ZaloConfig } from "./types.js"; -import type { BaseTokenResolution } from "./runtime-api.js"; export type ZaloTokenResolution = BaseTokenResolution & { source: "env" | "config" | "configFile" | "none"; diff --git a/src/agents/openclaw-tools.image-generation.test.ts b/src/agents/openclaw-tools.image-generation.test.ts index 9ad49f66371..cb5b9691009 100644 --- a/src/agents/openclaw-tools.image-generation.test.ts +++ b/src/agents/openclaw-tools.image-generation.test.ts @@ -17,7 +17,17 @@ function stubImageGenerationProviders() { id: "openai", defaultModel: "gpt-image-1", models: ["gpt-image-1"], - supportedSizes: ["1024x1024"], + capabilities: { + generate: { + supportsSize: true, + }, + edit: { + enabled: false, + }, + geometry: { + sizes: ["1024x1024"], + }, + }, generateImage: vi.fn(async () => { throw new Error("not used"); }), diff --git a/src/agents/pi-embedded-runner/extra-params.google.test.ts b/src/agents/pi-embedded-runner/extra-params.google.test.ts index 4cf33f5eeef..622e85b475c 100644 --- a/src/agents/pi-embedded-runner/extra-params.google.test.ts +++ b/src/agents/pi-embedded-runner/extra-params.google.test.ts @@ -18,7 +18,7 @@ describe("extra-params: Google thinking payload compatibility", () => { api: "google-generative-ai", provider: "google", id: "gemini-3.1-pro-preview", - } as Model<"openai-completions">, + } as unknown as Model<"openai-completions">, thinkingLevel: "high", payload: { contents: [], diff --git a/src/agents/pi-tools.create-openclaw-coding-tools.adds-claude-style-aliases-schemas-without-dropping.test.ts b/src/agents/pi-tools.create-openclaw-coding-tools.adds-claude-style-aliases-schemas-without-dropping.test.ts index ca704b03e51..c704515ac6e 100644 --- a/src/agents/pi-tools.create-openclaw-coding-tools.adds-claude-style-aliases-schemas-without-dropping.test.ts +++ b/src/agents/pi-tools.create-openclaw-coding-tools.adds-claude-style-aliases-schemas-without-dropping.test.ts @@ -457,7 +457,7 @@ describe("createOpenClawCodingTools", () => { it("applies xai model compat for direct Grok tool cleanup", () => { const xaiTools = createOpenClawCodingTools({ modelProvider: "xai", - modelCompat: applyXaiModelCompat({}).compat, + modelCompat: applyXaiModelCompat({ compat: {} }).compat, senderIsOwner: true, }); diff --git a/src/agents/pi-tools.model-provider-collision.test.ts b/src/agents/pi-tools.model-provider-collision.test.ts index 04eaa575601..9d629839199 100644 --- a/src/agents/pi-tools.model-provider-collision.test.ts +++ b/src/agents/pi-tools.model-provider-collision.test.ts @@ -18,10 +18,7 @@ function toolNames(tools: AnyAgentTool[]): string[] { describe("applyModelProviderToolPolicy", () => { it("keeps web_search for non-xAI models", () => { - const filtered = __testing.applyModelProviderToolPolicy(baseTools, { - modelProvider: "openai", - modelId: "gpt-4o-mini", - }); + const filtered = __testing.applyModelProviderToolPolicy(baseTools); expect(toolNames(filtered)).toEqual(["read", "web_search", "exec"]); }); diff --git a/src/agents/tools/image-generate-tool.test.ts b/src/agents/tools/image-generate-tool.test.ts index 50df1718daf..f719d8552b5 100644 --- a/src/agents/tools/image-generate-tool.test.ts +++ b/src/agents/tools/image-generate-tool.test.ts @@ -392,10 +392,11 @@ describe("createImageGenerateTool", () => { throw new Error("expected image_generate tool"); } - await expect(tool.execute("call-bad-aspect", { prompt: "portrait", aspectRatio: "7:5" })) - .rejects.toThrow( - "aspectRatio must be one of 1:1, 2:3, 3:2, 3:4, 4:3, 4:5, 5:4, 9:16, 16:9, or 21:9", - ); + await expect( + tool.execute("call-bad-aspect", { prompt: "portrait", aspectRatio: "7:5" }), + ).rejects.toThrow( + "aspectRatio must be one of 1:1, 2:3, 3:2, 3:4, 4:3, 4:5, 5:4, 9:16, 16:9, or 21:9", + ); }); it("lists registered provider and model options", async () => { diff --git a/src/agents/tools/image-generate-tool.ts b/src/agents/tools/image-generate-tool.ts index 3ae12fda187..aeb20a83723 100644 --- a/src/agents/tools/image-generate-tool.ts +++ b/src/agents/tools/image-generate-tool.ts @@ -230,7 +230,9 @@ function normalizeReferenceImages(args: Record): string[] { return normalized; } -function parseImageGenerationModelRef(raw: string | undefined): { provider: string; model: string } | null { +function parseImageGenerationModelRef( + raw: string | undefined, +): { provider: string; model: string } | null { const trimmed = raw?.trim(); if (!trimmed) { return null; @@ -258,7 +260,8 @@ function resolveSelectedImageGenerationProvider(params: { } return listRuntimeImageGenerationProviders({ config: params.config }).find( (provider) => - provider.id === selectedRef.provider || (provider.aliases ?? []).includes(selectedRef.provider), + provider.id === selectedRef.provider || + (provider.aliases ?? []).includes(selectedRef.provider), ); } @@ -298,7 +301,9 @@ function validateImageGenerationCapabilities(params: { if (params.size) { if (!modeCaps.supportsSize) { - throw new ToolInputError(`${provider.id} ${isEdit ? "edit" : "generate"} does not support size overrides.`); + throw new ToolInputError( + `${provider.id} ${isEdit ? "edit" : "generate"} does not support size overrides.`, + ); } if ((geometry?.sizes?.length ?? 0) > 0 && !geometry?.sizes?.includes(params.size)) { throw new ToolInputError( @@ -309,7 +314,9 @@ function validateImageGenerationCapabilities(params: { if (params.aspectRatio) { if (!modeCaps.supportsAspectRatio) { - throw new ToolInputError(`${provider.id} ${isEdit ? "edit" : "generate"} does not support aspectRatio overrides.`); + throw new ToolInputError( + `${provider.id} ${isEdit ? "edit" : "generate"} does not support aspectRatio overrides.`, + ); } if ( (geometry?.aspectRatios?.length ?? 0) > 0 && @@ -323,7 +330,9 @@ function validateImageGenerationCapabilities(params: { if (params.resolution) { if (!modeCaps.supportsResolution) { - throw new ToolInputError(`${provider.id} ${isEdit ? "edit" : "generate"} does not support resolution overrides.`); + throw new ToolInputError( + `${provider.id} ${isEdit ? "edit" : "generate"} does not support resolution overrides.`, + ); } if ( (geometry?.resolutions?.length ?? 0) > 0 && diff --git a/src/agents/xai.live.test.ts b/src/agents/xai.live.test.ts index a8dcde278db..5d84287c4c3 100644 --- a/src/agents/xai.live.test.ts +++ b/src/agents/xai.live.test.ts @@ -26,7 +26,7 @@ type AssistantLikeMessage = { }; function resolveLiveXaiModel() { - return getModel("xai", "grok-4-1-fast-reasoning") ?? getModel("xai", "grok-4"); + return getModel("xai", "grok-4"); } async function collectDoneMessage( diff --git a/src/channels/plugins/setup-wizard-helpers.ts b/src/channels/plugins/setup-wizard-helpers.ts index 50a29404b30..23299816f5e 100644 --- a/src/channels/plugins/setup-wizard-helpers.ts +++ b/src/channels/plugins/setup-wizard-helpers.ts @@ -722,7 +722,14 @@ export function createAccountScopedGroupAccessSection(params: { }; } -type AccountScopedChannel = "discord" | "slack" | "telegram" | "imessage" | "signal"; +type AccountScopedChannel = + | "bluebubbles" + | "discord" + | "imessage" + | "line" + | "signal" + | "slack" + | "telegram"; type LegacyDmChannel = "discord" | "slack"; export function patchLegacyDmChannelConfig(params: { diff --git a/src/commands/config-validation.test.ts b/src/commands/config-validation.test.ts index 83876477b43..2c4852ba8b6 100644 --- a/src/commands/config-validation.test.ts +++ b/src/commands/config-validation.test.ts @@ -1,7 +1,8 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { PluginCompatibilityNotice } from "../plugins/status.js"; const readConfigFileSnapshot = vi.fn(); -const buildPluginCompatibilityNotices = vi.fn(() => []); +const buildPluginCompatibilityNotices = vi.fn((): PluginCompatibilityNotice[] => []); vi.mock("../config/config.js", () => ({ readConfigFileSnapshot, diff --git a/src/commands/configure.wizard.ts b/src/commands/configure.wizard.ts index 78cd0716376..c74909ae14b 100644 --- a/src/commands/configure.wizard.ts +++ b/src/commands/configure.wizard.ts @@ -184,13 +184,13 @@ async function promptWebToolsConfig( if (!entry) { return false; } - return hasExistingKey(nextConfig, provider as SP) || hasKeyInEnv(entry); + return hasExistingKey(nextConfig, provider) || hasKeyInEnv(entry); }; const existingProvider: SP = (() => { const stored = existingSearch?.provider; if (stored && SEARCH_PROVIDER_OPTIONS.some((e) => e.value === stored)) { - return stored as SP; + return stored; } return ( SEARCH_PROVIDER_OPTIONS.find((e) => hasKeyForProvider(e.value))?.value ?? defaultProvider @@ -242,8 +242,8 @@ async function promptWebToolsConfig( nextSearch = { ...nextSearch, provider: providerChoice }; const entry = SEARCH_PROVIDER_OPTIONS.find((e) => e.value === providerChoice)!; - const existingKey = resolveExistingKey(nextConfig, providerChoice as SP); - const keyConfigured = hasExistingKey(nextConfig, providerChoice as SP); + const existingKey = resolveExistingKey(nextConfig, providerChoice); + const keyConfigured = hasExistingKey(nextConfig, providerChoice); const envAvailable = entry.envKeys.some((k) => Boolean(process.env[k]?.trim())); const envVarNames = entry.envKeys.join(" / "); @@ -263,7 +263,7 @@ async function promptWebToolsConfig( const key = String(keyInput ?? "").trim(); if (key || existingKey) { - const applied = applySearchKey(nextConfig, providerChoice as SP, (key || existingKey)!); + const applied = applySearchKey(nextConfig, providerChoice, (key || existingKey)!); nextSearch = { ...applied.tools?.web?.search }; } else if (keyConfigured || envAvailable) { nextSearch = { ...nextSearch }; diff --git a/src/commands/doctor-legacy-config.migrations.test.ts b/src/commands/doctor-legacy-config.migrations.test.ts index 738827c31c6..b8ec52ca171 100644 --- a/src/commands/doctor-legacy-config.migrations.test.ts +++ b/src/commands/doctor-legacy-config.migrations.test.ts @@ -359,6 +359,8 @@ describe("normalizeCompatibilityConfigValues", () => { providers: { google: { apiKey: "existing-google-key", + baseUrl: "https://generativelanguage.googleapis.com", + models: [], }, }, }, diff --git a/src/commands/doctor-legacy-config.ts b/src/commands/doctor-legacy-config.ts index 8072b89854b..c3376bd74e9 100644 --- a/src/commands/doctor-legacy-config.ts +++ b/src/commands/doctor-legacy-config.ts @@ -474,6 +474,11 @@ export function normalizeCompatibilityConfigValues(cfg: OpenClawConfig): { }; const normalizeLegacyNanoBananaSkill = () => { + type ModelProviderEntry = Partial< + NonNullable["providers"]>[string] + >; + type ModelsConfigPatch = Partial>; + const rawSkills = next.skills; if (!isRecord(rawSkills)) { return; @@ -544,14 +549,20 @@ export function normalizeCompatibilityConfigValues(cfg: OpenClawConfig): { ? structuredClone(rawLegacyEntry.apiKey) : undefined); - const rawModels = isRecord(next.models) ? structuredClone(next.models) : {}; - const rawProviders = isRecord(rawModels.providers) ? { ...rawModels.providers } : {}; - const rawGoogle = isRecord(rawProviders.google) ? { ...rawProviders.google } : {}; + const rawModels = ( + isRecord(next.models) ? structuredClone(next.models) : {} + ) as ModelsConfigPatch; + const rawProviders = ( + isRecord(rawModels.providers) ? { ...rawModels.providers } : {} + ) as Record; + const rawGoogle = ( + isRecord(rawProviders.google) ? { ...rawProviders.google } : {} + ) as ModelProviderEntry; const hasGoogleApiKey = rawGoogle.apiKey !== undefined; if (!hasGoogleApiKey && legacyApiKey) { rawGoogle.apiKey = legacyApiKey; rawProviders.google = rawGoogle; - rawModels.providers = rawProviders; + rawModels.providers = rawProviders as NonNullable["providers"]; next = { ...next, models: rawModels as OpenClawConfig["models"], diff --git a/src/config/types.tools.ts b/src/config/types.tools.ts index 0a1e68a16a7..6939b7b0d96 100644 --- a/src/config/types.tools.ts +++ b/src/config/types.tools.ts @@ -444,6 +444,14 @@ export type MemorySearchConfig = { }; }; +type WebSearchLegacyProviderConfig = { + apiKey?: SecretInput; + baseUrl?: string; + model?: string; + mode?: string; + inlineCitations?: boolean; +}; + export type ToolsConfig = { /** Base tool profile applied before allow/deny lists. */ profile?: ToolProfileId; @@ -465,6 +473,20 @@ export type ToolsConfig = { timeoutSeconds?: number; /** Cache TTL in minutes for search results. */ cacheTtlMinutes?: number; + /** @deprecated Legacy Brave credential path. */ + apiKey?: SecretInput; + /** @deprecated Legacy Brave scoped config. */ + brave?: WebSearchLegacyProviderConfig; + /** @deprecated Legacy Firecrawl scoped config. */ + firecrawl?: WebSearchLegacyProviderConfig; + /** @deprecated Legacy Gemini scoped config. */ + gemini?: WebSearchLegacyProviderConfig; + /** @deprecated Legacy Grok scoped config. */ + grok?: WebSearchLegacyProviderConfig; + /** @deprecated Legacy Kimi scoped config. */ + kimi?: WebSearchLegacyProviderConfig; + /** @deprecated Legacy Perplexity scoped config. */ + perplexity?: WebSearchLegacyProviderConfig; }; fetch?: { /** Enable web fetch tool (default: true). */ diff --git a/src/config/zod-schema.agent-runtime.ts b/src/config/zod-schema.agent-runtime.ts index 2763697c2d9..10f0f8637e9 100644 --- a/src/config/zod-schema.agent-runtime.ts +++ b/src/config/zod-schema.agent-runtime.ts @@ -267,6 +267,57 @@ export const ToolsWebSearchSchema = z maxResults: z.number().int().positive().optional(), timeoutSeconds: z.number().int().positive().optional(), cacheTtlMinutes: z.number().nonnegative().optional(), + apiKey: SecretInputSchema.optional().register(sensitive), + brave: z + .object({ + apiKey: SecretInputSchema.optional().register(sensitive), + baseUrl: z.string().optional(), + model: z.string().optional(), + mode: z.string().optional(), + }) + .strict() + .optional(), + firecrawl: z + .object({ + apiKey: SecretInputSchema.optional().register(sensitive), + baseUrl: z.string().optional(), + model: z.string().optional(), + }) + .strict() + .optional(), + gemini: z + .object({ + apiKey: SecretInputSchema.optional().register(sensitive), + baseUrl: z.string().optional(), + model: z.string().optional(), + }) + .strict() + .optional(), + grok: z + .object({ + apiKey: SecretInputSchema.optional().register(sensitive), + baseUrl: z.string().optional(), + model: z.string().optional(), + inlineCitations: z.boolean().optional(), + }) + .strict() + .optional(), + kimi: z + .object({ + apiKey: SecretInputSchema.optional().register(sensitive), + baseUrl: z.string().optional(), + model: z.string().optional(), + }) + .strict() + .optional(), + perplexity: z + .object({ + apiKey: SecretInputSchema.optional().register(sensitive), + baseUrl: z.string().optional(), + model: z.string().optional(), + }) + .strict() + .optional(), }) .strict() .optional(); diff --git a/src/image-generation/providers/fal.ts b/src/image-generation/providers/fal.ts index 4059859e534..8d0cd8ceaaf 100644 --- a/src/image-generation/providers/fal.ts +++ b/src/image-generation/providers/fal.ts @@ -94,14 +94,22 @@ function aspectRatioToEnum(aspectRatio: string | undefined): string | undefined return undefined; } -function aspectRatioToDimensions(aspectRatio: string, edge: number): { width: number; height: number } { +function aspectRatioToDimensions( + aspectRatio: string, + edge: number, +): { width: number; height: number } { const match = /^(\d+):(\d+)$/u.exec(aspectRatio.trim()); if (!match) { throw new Error(`Invalid fal aspect ratio: ${aspectRatio}`); } const widthRatio = Number.parseInt(match[1] ?? "", 10); const heightRatio = Number.parseInt(match[2] ?? "", 10); - if (!Number.isFinite(widthRatio) || !Number.isFinite(heightRatio) || widthRatio <= 0 || heightRatio <= 0) { + if ( + !Number.isFinite(widthRatio) || + !Number.isFinite(heightRatio) || + widthRatio <= 0 || + heightRatio <= 0 + ) { throw new Error(`Invalid fal aspect ratio: ${aspectRatio}`); } if (widthRatio >= heightRatio) { @@ -140,7 +148,10 @@ function resolveFalImageSize(params: { return { width: edge, height: edge }; } if (normalizedAspectRatio) { - return aspectRatioToEnum(normalizedAspectRatio) ?? aspectRatioToDimensions(normalizedAspectRatio, 1024); + return ( + aspectRatioToEnum(normalizedAspectRatio) ?? + aspectRatioToDimensions(normalizedAspectRatio, 1024) + ); } return undefined; } diff --git a/src/infra/outbound/outbound-session.test.ts b/src/infra/outbound/outbound-session.test.ts index c33c3edcf77..7a45f938bf8 100644 --- a/src/infra/outbound/outbound-session.test.ts +++ b/src/infra/outbound/outbound-session.test.ts @@ -41,7 +41,7 @@ describe("resolveOutboundSessionRoute", () => { from?: string; to?: string; threadId?: string | number; - chatType?: "direct" | "group"; + chatType?: "channel" | "direct" | "group"; }; }> = [ { diff --git a/src/infra/outbound/outbound.test.ts b/src/infra/outbound/outbound.test.ts index 7266f45d969..7dcdab184ed 100644 --- a/src/infra/outbound/outbound.test.ts +++ b/src/infra/outbound/outbound.test.ts @@ -972,7 +972,7 @@ describe("resolveOutboundSessionRoute", () => { from?: string; to?: string; threadId?: string | number; - chatType?: "direct" | "group"; + chatType?: "channel" | "direct" | "group"; }; }> = [ { diff --git a/src/plugin-sdk/acp-runtime.ts b/src/plugin-sdk/acp-runtime.ts index c50c36419bb..84435bb896a 100644 --- a/src/plugin-sdk/acp-runtime.ts +++ b/src/plugin-sdk/acp-runtime.ts @@ -1,6 +1,18 @@ // Public ACP runtime helpers for plugins that integrate with ACP control/session state. export { getAcpSessionManager } from "../acp/control-plane/manager.js"; -export { isAcpRuntimeError } from "../acp/runtime/errors.js"; +export { AcpRuntimeError, isAcpRuntimeError } from "../acp/runtime/errors.js"; +export type { AcpRuntimeErrorCode } from "../acp/runtime/errors.js"; +export type { + AcpRuntime, + AcpRuntimeCapabilities, + AcpRuntimeDoctorReport, + AcpRuntimeEnsureInput, + AcpRuntimeEvent, + AcpRuntimeHandle, + AcpRuntimeStatus, + AcpRuntimeTurnInput, + AcpSessionUpdateTag, +} from "../acp/runtime/types.js"; export { readAcpSessionEntry } from "../acp/runtime/session-meta.js"; export type { AcpSessionStoreEntry } from "../acp/runtime/session-meta.js"; diff --git a/src/plugin-sdk/channel-config-helpers.ts b/src/plugin-sdk/channel-config-helpers.ts index ee18f8bc9c9..d9a229657dd 100644 --- a/src/plugin-sdk/channel-config-helpers.ts +++ b/src/plugin-sdk/channel-config-helpers.ts @@ -41,8 +41,11 @@ export function resolveOptionalConfigString( } /** Build the shared allowlist/default target adapter surface for account-scoped channel configs. */ -export function createScopedAccountConfigAccessors(params: { - resolveAccount: (params: { cfg: OpenClawConfig; accountId?: string | null }) => ResolvedAccount; +export function createScopedAccountConfigAccessors< + ResolvedAccount, + Config extends OpenClawConfig = OpenClawConfig, +>(params: { + resolveAccount: (params: { cfg: Config; accountId?: string | null }) => ResolvedAccount; resolveAllowFrom: (account: ResolvedAccount) => Array | null | undefined; formatAllowFrom: (allowFrom: Array) => string[]; resolveDefaultTo?: (account: ResolvedAccount) => string | number | null | undefined; @@ -52,7 +55,9 @@ export function createScopedAccountConfigAccessors(params: { > { const base = { resolveAllowFrom: ({ cfg, accountId }: { cfg: OpenClawConfig; accountId?: string | null }) => - mapAllowFromEntries(params.resolveAllowFrom(params.resolveAccount({ cfg, accountId }))), + mapAllowFromEntries( + params.resolveAllowFrom(params.resolveAccount({ cfg: cfg as Config, accountId })), + ), formatAllowFrom: ({ allowFrom }: { allowFrom: Array }) => params.formatAllowFrom(allowFrom), }; @@ -65,7 +70,7 @@ export function createScopedAccountConfigAccessors(params: { ...base, resolveDefaultTo: ({ cfg, accountId }) => resolveOptionalConfigString( - params.resolveDefaultTo?.(params.resolveAccount({ cfg, accountId })), + params.resolveDefaultTo?.(params.resolveAccount({ cfg: cfg as Config, accountId })), ), }; } @@ -160,7 +165,7 @@ export function createScopedChannelConfigAdapter< clearBaseFields: params.clearBaseFields, allowTopLevel: params.allowTopLevel, }), - ...createScopedAccountConfigAccessors({ + ...createScopedAccountConfigAccessors({ resolveAccount: resolveAccessorAccount, resolveAllowFrom: params.resolveAllowFrom, formatAllowFrom: params.formatAllowFrom, @@ -316,7 +321,7 @@ export function createTopLevelChannelConfigAdapter< deleteMode: params.deleteMode, clearBaseFields: params.clearBaseFields, }), - ...createScopedAccountConfigAccessors({ + ...createScopedAccountConfigAccessors({ resolveAccount: resolveAccessorAccount, resolveAllowFrom: params.resolveAllowFrom, formatAllowFrom: params.formatAllowFrom, @@ -438,7 +443,7 @@ export function createHybridChannelConfigAdapter< clearBaseFields: params.clearBaseFields, preserveSectionOnDefaultDelete: params.preserveSectionOnDefaultDelete, }), - ...createScopedAccountConfigAccessors({ + ...createScopedAccountConfigAccessors({ resolveAccount: resolveAccessorAccount, resolveAllowFrom: params.resolveAllowFrom, formatAllowFrom: params.formatAllowFrom, diff --git a/src/plugin-sdk/core.ts b/src/plugin-sdk/core.ts index 124c37d6712..252063d2631 100644 --- a/src/plugin-sdk/core.ts +++ b/src/plugin-sdk/core.ts @@ -44,6 +44,7 @@ export type { ProviderThinkingPolicyContext, ProviderWrapStreamFnContext, OpenClawPluginService, + OpenClawPluginServiceContext, ProviderAuthContext, ProviderAuthDoctorHintContext, ProviderAuthMethodNonInteractiveContext, @@ -51,6 +52,7 @@ export type { ProviderAuthResult, OpenClawPluginCommandDefinition, OpenClawPluginDefinition, + PluginLogger, PluginInteractiveTelegramHandlerContext, } from "../plugins/types.js"; export type { OpenClawConfig } from "../config/config.js"; diff --git a/src/plugin-sdk/package-contract-guardrails.test.ts b/src/plugin-sdk/package-contract-guardrails.test.ts index 046562708cd..a637927098e 100644 --- a/src/plugin-sdk/package-contract-guardrails.test.ts +++ b/src/plugin-sdk/package-contract-guardrails.test.ts @@ -25,7 +25,7 @@ function collectPluginSdkPackageExports(): string[] { } subpaths.push(key.slice("./plugin-sdk/".length)); } - return subpaths.sort(); + return subpaths.toSorted(); } function collectPluginSdkSourceNames(): string[] { @@ -35,7 +35,7 @@ function collectPluginSdkSourceNames(): string[] { (entry) => entry.isFile() && entry.name.endsWith(".ts") && !entry.name.endsWith(".test.ts"), ) .map((entry) => entry.name.slice(0, -".ts".length)) - .sort(); + .toSorted(); } function collectTextFiles(rootRelativeDir: string): string[] { @@ -92,7 +92,7 @@ function collectPluginSdkSubpathReferences() { describe("plugin-sdk package contract guardrails", () => { it("keeps package.json exports aligned with built plugin-sdk entrypoints", () => { - expect(collectPluginSdkPackageExports()).toEqual([...pluginSdkEntrypoints].sort()); + expect(collectPluginSdkPackageExports()).toEqual([...pluginSdkEntrypoints].toSorted()); }); it("keeps repo openclaw/plugin-sdk/ references on exported built subpaths", () => { @@ -135,7 +135,7 @@ describe("plugin-sdk package contract guardrails", () => { failures.push( `src/plugin-sdk/${sourceName}.ts is referenced as openclaw/plugin-sdk/${sourceName} in ${matchingRefs .map((reference) => reference.file) - .sort() + .toSorted() .join(", ")}, but ${sourceName} is not exported as a public plugin-sdk subpath`, ); } diff --git a/src/plugin-sdk/telegram.ts b/src/plugin-sdk/telegram.ts index 4a180763b38..c4ec4f2cdff 100644 --- a/src/plugin-sdk/telegram.ts +++ b/src/plugin-sdk/telegram.ts @@ -26,6 +26,8 @@ export type { StickerMetadata } from "../../extensions/telegram/api.js"; export { emptyPluginConfigSchema } from "../plugins/config-schema.js"; export { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js"; export { parseTelegramTopicConversation } from "../acp/conversation-id.js"; +export { clearAccountEntryFields } from "../channels/plugins/config-helpers.js"; +export { resolveTelegramPollVisibility } from "../poll-params.js"; export { PAIRING_APPROVED_MESSAGE, @@ -38,9 +40,6 @@ export { setAccountEnabledInConfigSection, } from "./channel-plugin-common.js"; -export { clearAccountEntryFields } from "../channels/plugins/config-helpers.js"; -export { resolveTelegramPollVisibility } from "../poll-params.js"; - export { projectCredentialSnapshotFields, resolveConfiguredFromCredentialStatuses, diff --git a/src/plugins/contracts/shape.contract.test.ts b/src/plugins/contracts/shape.contract.test.ts index ffc7c92360a..c5726c4fd0b 100644 --- a/src/plugins/contracts/shape.contract.test.ts +++ b/src/plugins/contracts/shape.contract.test.ts @@ -99,6 +99,7 @@ describe("plugin shape compatibility matrix", () => { envVars: ["HYBRID_SEARCH_KEY"], placeholder: "hsk_...", signupUrl: "https://example.com/signup", + credentialPath: "tools.web.search.hybrid-search.apiKey", getCredentialValue: () => "hsk-test", setCredentialValue(searchConfigTarget, value) { searchConfigTarget.apiKey = value; diff --git a/src/secrets/runtime-web-tools.test.ts b/src/secrets/runtime-web-tools.test.ts index 94f7b9be99f..7b0706a66d4 100644 --- a/src/secrets/runtime-web-tools.test.ts +++ b/src/secrets/runtime-web-tools.test.ts @@ -68,7 +68,10 @@ function createProviderSecretRefConfig( } function readProviderKey(config: OpenClawConfig, provider: ProviderUnderTest): unknown { - return config.plugins?.entries?.[providerPluginId(provider)]?.config?.webSearch?.apiKey; + const pluginConfig = config.plugins?.entries?.[providerPluginId(provider)]?.config as + | { webSearch?: { apiKey?: unknown } } + | undefined; + return pluginConfig?.webSearch?.apiKey; } function expectInactiveFirecrawlSecretRef(params: { diff --git a/src/web-search/runtime.test.ts b/src/web-search/runtime.test.ts index 68446d33a95..428ae25552c 100644 --- a/src/web-search/runtime.test.ts +++ b/src/web-search/runtime.test.ts @@ -21,6 +21,7 @@ describe("web search runtime", () => { placeholder: "custom-...", signupUrl: "https://example.com/signup", autoDetectOrder: 1, + credentialPath: "tools.web.search.custom.apiKey", getCredentialValue: () => "configured", setCredentialValue: () => {}, createTool: () => ({ diff --git a/src/web-search/runtime.ts b/src/web-search/runtime.ts index 4861ad12480..2c81f6748b4 100644 --- a/src/web-search/runtime.ts +++ b/src/web-search/runtime.ts @@ -199,5 +199,6 @@ export async function runWebSearch( export const __testing = { resolveSearchConfig, + resolveSearchProvider: resolveWebSearchProviderId, resolveWebSearchProviderId, }; diff --git a/ui/src/ui/views/config.browser.test.ts b/ui/src/ui/views/config.browser.test.ts index 4b546cfa0b7..6473c09404d 100644 --- a/ui/src/ui/views/config.browser.test.ts +++ b/ui/src/ui/views/config.browser.test.ts @@ -42,6 +42,8 @@ describe("config view", () => { themeMode: "system" as ThemeMode, setTheme: vi.fn(), setThemeMode: vi.fn(), + borderRadius: 50, + setBorderRadius: vi.fn(), gatewayUrl: "", assistantName: "OpenClaw", }); From bde4c7995f5b2d2a07f533b1762bbd26c5a5d167 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Wed, 18 Mar 2026 00:45:29 -0700 Subject: [PATCH 18/55] docs: remove docs/refactor/ directory Delete all 7 refactor design docs and the zh-CN translations. Remove the zh-CN nav group from docs.json. These were orphaned from English nav and accessible only by direct URL. Internal design docs do not belong on the public docs site. Co-Authored-By: Claude Opus 4.6 --- docs/docs.json | 10 - docs/refactor/clawnet.md | 417 ----------------- docs/refactor/cluster.md | 299 ------------ docs/refactor/exec-host.md | 316 ------------- docs/refactor/firecrawl-extension.md | 260 ----------- docs/refactor/outbound-session-mirroring.md | 89 ---- docs/refactor/plugin-sdk.md | 264 ----------- docs/refactor/strict-config.md | 93 ---- docs/zh-CN/refactor/clawnet.md | 424 ------------------ docs/zh-CN/refactor/exec-host.md | 323 ------------- .../refactor/outbound-session-mirroring.md | 92 ---- docs/zh-CN/refactor/plugin-sdk.md | 221 --------- docs/zh-CN/refactor/strict-config.md | 100 ----- 13 files changed, 2908 deletions(-) delete mode 100644 docs/refactor/clawnet.md delete mode 100644 docs/refactor/cluster.md delete mode 100644 docs/refactor/exec-host.md delete mode 100644 docs/refactor/firecrawl-extension.md delete mode 100644 docs/refactor/outbound-session-mirroring.md delete mode 100644 docs/refactor/plugin-sdk.md delete mode 100644 docs/refactor/strict-config.md delete mode 100644 docs/zh-CN/refactor/clawnet.md delete mode 100644 docs/zh-CN/refactor/exec-host.md delete mode 100644 docs/zh-CN/refactor/outbound-session-mirroring.md delete mode 100644 docs/zh-CN/refactor/plugin-sdk.md delete mode 100644 docs/zh-CN/refactor/strict-config.md diff --git a/docs/docs.json b/docs/docs.json index 5ee53ed6008..9d04ab81c5c 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -1949,16 +1949,6 @@ "zh-CN/experiments/research/memory", "zh-CN/experiments/proposals/model-config" ] - }, - { - "group": "重构方案", - "pages": [ - "zh-CN/refactor/clawnet", - "zh-CN/refactor/exec-host", - "zh-CN/refactor/outbound-session-mirroring", - "zh-CN/refactor/plugin-sdk", - "zh-CN/refactor/strict-config" - ] } ] }, diff --git a/docs/refactor/clawnet.md b/docs/refactor/clawnet.md deleted file mode 100644 index f24cfdc2c57..00000000000 --- a/docs/refactor/clawnet.md +++ /dev/null @@ -1,417 +0,0 @@ ---- -summary: "Clawnet refactor: unify network protocol, roles, auth, approvals, identity" -read_when: - - Planning a unified network protocol for nodes + operator clients - - Reworking approvals, pairing, TLS, and presence across devices -title: "Clawnet Refactor" ---- - -# Clawnet refactor (protocol + auth unification) - -## Hi - -Hi Peter — great direction; this unlocks simpler UX + stronger security. - -## Purpose - -Single, rigorous document for: - -- Current state: protocols, flows, trust boundaries. -- Pain points: approvals, multi‑hop routing, UI duplication. -- Proposed new state: one protocol, scoped roles, unified auth/pairing, TLS pinning. -- Identity model: stable IDs + cute slugs. -- Migration plan, risks, open questions. - -## Goals (from discussion) - -- One protocol for all clients (mac app, CLI, iOS, Android, headless node). -- Every network participant authenticated + paired. -- Role clarity: nodes vs operators. -- Central approvals routed to where the user is. -- TLS encryption + optional pinning for all remote traffic. -- Minimal code duplication. -- Single machine should appear once (no UI/node duplicate entry). - -## Non‑goals (explicit) - -- Remove capability separation (still need least‑privilege). -- Expose full gateway control plane without scope checks. -- Make auth depend on human labels (slugs remain non‑security). - ---- - -# Current state (as‑is) - -## Two protocols - -### 1) Gateway WebSocket (control plane) - -- Full API surface: config, channels, models, sessions, agent runs, logs, nodes, etc. -- Default bind: loopback. Remote access via SSH/Tailscale. -- Auth: token/password via `connect`. -- No TLS pinning (relies on loopback/tunnel). -- Code: - - `src/gateway/server/ws-connection/message-handler.ts` - - `src/gateway/client.ts` - - `docs/gateway/protocol.md` - -### 2) Bridge (node transport) - -- Narrow allowlist surface, node identity + pairing. -- JSONL over TCP; optional TLS + cert fingerprint pinning. -- TLS advertises fingerprint in discovery TXT. -- Code: - - `src/infra/bridge/server/connection.ts` - - `src/gateway/server-bridge.ts` - - `src/node-host/bridge-client.ts` - - `docs/gateway/bridge-protocol.md` - -## Control plane clients today - -- CLI → Gateway WS via `callGateway` (`src/gateway/call.ts`). -- macOS app UI → Gateway WS (`GatewayConnection`). -- Web Control UI → Gateway WS. -- ACP → Gateway WS. -- Browser control uses its own HTTP control server. - -## Nodes today - -- macOS app in node mode connects to Gateway bridge (`MacNodeBridgeSession`). -- iOS/Android apps connect to Gateway bridge. -- Pairing + per‑node token stored on gateway. - -## Current approval flow (exec) - -- Agent uses `system.run` via Gateway. -- Gateway invokes node over bridge. -- Node runtime decides approval. -- UI prompt shown by mac app (when node == mac app). -- Node returns `invoke-res` to Gateway. -- Multi‑hop, UI tied to node host. - -## Presence + identity today - -- Gateway presence entries from WS clients. -- Node presence entries from bridge. -- mac app can show two entries for same machine (UI + node). -- Node identity stored in pairing store; UI identity separate. - ---- - -# Problems / pain points - -- Two protocol stacks to maintain (WS + Bridge). -- Approvals on remote nodes: prompt appears on node host, not where user is. -- TLS pinning only exists for bridge; WS depends on SSH/Tailscale. -- Identity duplication: same machine shows as multiple instances. -- Ambiguous roles: UI + node + CLI capabilities not clearly separated. - ---- - -# Proposed new state (Clawnet) - -## One protocol, two roles - -Single WS protocol with role + scope. - -- **Role: node** (capability host) -- **Role: operator** (control plane) -- Optional **scope** for operator: - - `operator.read` (status + viewing) - - `operator.write` (agent run, sends) - - `operator.admin` (config, channels, models) - -### Role behaviors - -**Node** - -- Can register capabilities (`caps`, `commands`, permissions). -- Can receive `invoke` commands (`system.run`, `camera.*`, `canvas.*`, `screen.record`, etc). -- Can send events: `voice.transcript`, `agent.request`, `chat.subscribe`. -- Cannot call config/models/channels/sessions/agent control plane APIs. - -**Operator** - -- Full control plane API, gated by scope. -- Receives all approvals. -- Does not directly execute OS actions; routes to nodes. - -### Key rule - -Role is per‑connection, not per device. A device may open both roles, separately. - ---- - -# Unified authentication + pairing - -## Client identity - -Every client provides: - -- `deviceId` (stable, derived from device key). -- `displayName` (human name). -- `role` + `scope` + `caps` + `commands`. - -## Pairing flow (unified) - -- Client connects unauthenticated. -- Gateway creates a **pairing request** for that `deviceId`. -- Operator receives prompt; approves/denies. -- Gateway issues credentials bound to: - - device public key - - role(s) - - scope(s) - - capabilities/commands -- Client persists token, reconnects authenticated. - -## Device‑bound auth (avoid bearer token replay) - -Preferred: device keypairs. - -- Device generates keypair once. -- `deviceId = fingerprint(publicKey)`. -- Gateway sends nonce; device signs; gateway verifies. -- Tokens are issued to a public key (proof‑of‑possession), not a string. - -Alternatives: - -- mTLS (client certs): strongest, more ops complexity. -- Short‑lived bearer tokens only as a temporary phase (rotate + revoke early). - -## Silent approval (SSH heuristic) - -Define it precisely to avoid a weak link. Prefer one: - -- **Local‑only**: auto‑pair when client connects via loopback/Unix socket. -- **Challenge via SSH**: gateway issues nonce; client proves SSH by fetching it. -- **Physical presence window**: after a local approval on gateway host UI, allow auto‑pair for a short window (e.g. 10 minutes). - -Always log + record auto‑approvals. - ---- - -# TLS everywhere (dev + prod) - -## Reuse existing bridge TLS - -Use current TLS runtime + fingerprint pinning: - -- `src/infra/bridge/server/tls.ts` -- fingerprint verification logic in `src/node-host/bridge-client.ts` - -## Apply to WS - -- WS server supports TLS with same cert/key + fingerprint. -- WS clients can pin fingerprint (optional). -- Discovery advertises TLS + fingerprint for all endpoints. - - Discovery is locator hints only; never a trust anchor. - -## Why - -- Reduce reliance on SSH/Tailscale for confidentiality. -- Make remote mobile connections safe by default. - ---- - -# Approvals redesign (centralized) - -## Current - -Approval happens on node host (mac app node runtime). Prompt appears where node runs. - -## Proposed - -Approval is **gateway‑hosted**, UI delivered to operator clients. - -### New flow - -1. Gateway receives `system.run` intent (agent). -2. Gateway creates approval record: `approval.requested`. -3. Operator UI(s) show prompt. -4. Approval decision sent to gateway: `approval.resolve`. -5. Gateway invokes node command if approved. -6. Node executes, returns `invoke-res`. - -### Approval semantics (hardening) - -- Broadcast to all operators; only the active UI shows a modal (others get a toast). -- First resolution wins; gateway rejects subsequent resolves as already settled. -- Default timeout: deny after N seconds (e.g. 60s), log reason. -- Resolution requires `operator.approvals` scope. - -## Benefits - -- Prompt appears where user is (mac/phone). -- Consistent approvals for remote nodes. -- Node runtime stays headless; no UI dependency. - ---- - -# Role clarity examples - -## iPhone app - -- **Node role** for: mic, camera, voice chat, location, push‑to‑talk. -- Optional **operator.read** for status and chat view. -- Optional **operator.write/admin** only when explicitly enabled. - -## macOS app - -- Operator role by default (control UI). -- Node role when “Mac node” enabled (system.run, screen, camera). -- Same deviceId for both connections → merged UI entry. - -## CLI - -- Operator role always. -- Scope derived by subcommand: - - `status`, `logs` → read - - `agent`, `message` → write - - `config`, `channels` → admin - - approvals + pairing → `operator.approvals` / `operator.pairing` - ---- - -# Identity + slugs - -## Stable ID - -Required for auth; never changes. -Preferred: - -- Keypair fingerprint (public key hash). - -## Cute slug (lobster‑themed) - -Human label only. - -- Example: `scarlet-claw`, `saltwave`, `mantis-pinch`. -- Stored in gateway registry, editable. -- Collision handling: `-2`, `-3`. - -## UI grouping - -Same `deviceId` across roles → single “Instance” row: - -- Badge: `operator`, `node`. -- Shows capabilities + last seen. - ---- - -# Migration strategy - -## Phase 0: Document + align - -- Publish this doc. -- Inventory all protocol calls + approval flows. - -## Phase 1: Add roles/scopes to WS - -- Extend `connect` params with `role`, `scope`, `deviceId`. -- Add allowlist gating for node role. - -## Phase 2: Bridge compatibility - -- Keep bridge running. -- Add WS node support in parallel. -- Gate features behind config flag. - -## Phase 3: Central approvals - -- Add approval request + resolve events in WS. -- Update mac app UI to prompt + respond. -- Node runtime stops prompting UI. - -## Phase 4: TLS unification - -- Add TLS config for WS using bridge TLS runtime. -- Add pinning to clients. - -## Phase 5: Deprecate bridge - -- Migrate iOS/Android/mac node to WS. -- Keep bridge as fallback; remove once stable. - -## Phase 6: Device‑bound auth - -- Require key‑based identity for all non‑local connections. -- Add revocation + rotation UI. - ---- - -# Security notes - -- Role/allowlist enforced at gateway boundary. -- No client gets “full” API without operator scope. -- Pairing required for _all_ connections. -- TLS + pinning reduces MITM risk for mobile. -- SSH silent approval is a convenience; still recorded + revocable. -- Discovery is never a trust anchor. -- Capability claims are verified against server allowlists by platform/type. - -# Streaming + large payloads (node media) - -WS control plane is fine for small messages, but nodes also do: - -- camera clips -- screen recordings -- audio streams - -Options: - -1. WS binary frames + chunking + backpressure rules. -2. Separate streaming endpoint (still TLS + auth). -3. Keep bridge longer for media‑heavy commands, migrate last. - -Pick one before implementation to avoid drift. - -# Capability + command policy - -- Node‑reported caps/commands are treated as **claims**. -- Gateway enforces per‑platform allowlists. -- Any new command requires operator approval or explicit allowlist change. -- Audit changes with timestamps. - -# Audit + rate limiting - -- Log: pairing requests, approvals/denials, token issuance/rotation/revocation. -- Rate‑limit pairing spam and approval prompts. - -# Protocol hygiene - -- Explicit protocol version + error codes. -- Reconnect rules + heartbeat policy. -- Presence TTL and last‑seen semantics. - ---- - -# Open questions - -1. Single device running both roles: token model - - Recommend separate tokens per role (node vs operator). - - Same deviceId; different scopes; clearer revocation. - -2. Operator scope granularity - - read/write/admin + approvals + pairing (minimum viable). - - Consider per‑feature scopes later. - -3. Token rotation + revocation UX - - Auto‑rotate on role change. - - UI to revoke by deviceId + role. - -4. Discovery - - Extend current Bonjour TXT to include WS TLS fingerprint + role hints. - - Treat as locator hints only. - -5. Cross‑network approval - - Broadcast to all operator clients; active UI shows modal. - - First response wins; gateway enforces atomicity. - ---- - -# Summary (TL;DR) - -- Today: WS control plane + Bridge node transport. -- Pain: approvals + duplication + two stacks. -- Proposal: one WS protocol with explicit roles + scopes, unified pairing + TLS pinning, gateway‑hosted approvals, stable device IDs + cute slugs. -- Outcome: simpler UX, stronger security, less duplication, better mobile routing. diff --git a/docs/refactor/cluster.md b/docs/refactor/cluster.md deleted file mode 100644 index db2d9b1276f..00000000000 --- a/docs/refactor/cluster.md +++ /dev/null @@ -1,299 +0,0 @@ ---- -summary: "Refactor clusters with highest LOC reduction potential" -read_when: - - You want to reduce total LOC without changing behavior - - You are choosing the next dedupe or extraction pass -title: "Refactor Cluster Backlog" ---- - -# Refactor Cluster Backlog - -Ranked by likely LOC reduction, safety, and breadth. - -## 1. Channel plugin config and security scaffolding - -Highest-value cluster. - -Repeated shapes across many channel plugins: - -- `config.listAccountIds` -- `config.resolveAccount` -- `config.defaultAccountId` -- `config.setAccountEnabled` -- `config.deleteAccount` -- `config.describeAccount` -- `security.resolveDmPolicy` - -Strong examples: - -- `extensions/telegram/src/channel.ts` -- `extensions/googlechat/src/channel.ts` -- `extensions/slack/src/channel.ts` -- `extensions/discord/src/channel.ts` -- `extensions/matrix/src/channel.ts` -- `extensions/irc/src/channel.ts` -- `extensions/signal/src/channel.ts` -- `extensions/mattermost/src/channel.ts` - -Likely extraction shape: - -- `buildChannelConfigAdapter(...)` -- `buildMultiAccountConfigAdapter(...)` -- `buildDmSecurityAdapter(...)` - -Expected savings: - -- ~250-450 LOC - -Risk: - -- Medium. Each channel has slightly different `isConfigured`, warnings, and normalization. - -## 2. Extension runtime singleton boilerplate - -Very safe. - -Nearly every extension has the same runtime holder: - -- `let runtime: PluginRuntime | null = null` -- `setXRuntime` -- `getXRuntime` - -Strong examples: - -- `extensions/telegram/src/runtime.ts` -- `extensions/matrix/src/runtime.ts` -- `extensions/slack/src/runtime.ts` -- `extensions/discord/src/runtime.ts` -- `extensions/whatsapp/src/runtime.ts` -- `extensions/imessage/src/runtime.ts` -- `extensions/twitch/src/runtime.ts` - -Special-case variants: - -- `extensions/bluebubbles/src/runtime.ts` -- `extensions/line/src/runtime.ts` -- `extensions/synology-chat/src/runtime.ts` - -Likely extraction shape: - -- `createPluginRuntimeStore(errorMessage)` - -Expected savings: - -- ~180-260 LOC - -Risk: - -- Low - -## 3. Setup prompt and config-patch steps - -Large surface area. - -Many setup files repeat: - -- resolve account id -- prompt allowlist entries -- merge allowFrom -- set DM policy -- prompt secrets -- patch top-level vs account-scoped config - -Strong examples: - -- `extensions/bluebubbles/src/setup-surface.ts` -- `extensions/googlechat/src/setup-surface.ts` -- `extensions/msteams/src/setup-surface.ts` -- `extensions/zalo/src/setup-surface.ts` -- `extensions/zalouser/src/setup-surface.ts` -- `extensions/nextcloud-talk/src/setup-surface.ts` -- `extensions/matrix/src/setup-surface.ts` -- `extensions/irc/src/setup-surface.ts` - -Existing helper surface: - -- `src/channels/plugins/setup-wizard-helpers.ts` - -Likely extraction shape: - -- `promptAllowFromList(...)` -- `buildDmPolicyAdapter(...)` -- `applyScopedAccountPatch(...)` -- `promptSecretFields(...)` - -Expected savings: - -- ~300-600 LOC - -Risk: - -- Medium. Easy to over-generalize; keep helpers narrow and composable. - -## 4. Multi-account config-schema fragments - -Repeated schema fragments across extensions. - -Common patterns: - -- `const allowFromEntry = z.union([z.string(), z.number()])` -- account schema plus: - - `accounts: z.object({}).catchall(accountSchema).optional()` - - `defaultAccount: z.string().optional()` -- repeated DM/group fields -- repeated markdown/tool policy fields - -Strong examples: - -- `extensions/bluebubbles/src/config-schema.ts` -- `extensions/zalo/src/config-schema.ts` -- `extensions/zalouser/src/config-schema.ts` -- `extensions/matrix/src/config-schema.ts` -- `extensions/nostr/src/config-schema.ts` - -Likely extraction shape: - -- `AllowFromEntrySchema` -- `buildMultiAccountChannelSchema(accountSchema)` -- `buildCommonDmGroupFields(...)` - -Expected savings: - -- ~120-220 LOC - -Risk: - -- Low to medium. Some schemas are simple, some are special. - -## 5. Webhook and monitor lifecycle startup - -Good medium-value cluster. - -Repeated `startAccount` / monitor setup patterns: - -- resolve account -- compute webhook path -- log startup -- start monitor -- wait for abort -- cleanup -- status sink updates - -Strong examples: - -- `extensions/googlechat/src/channel.ts` -- `extensions/bluebubbles/src/channel.ts` -- `extensions/zalo/src/channel.ts` -- `extensions/telegram/src/channel.ts` -- `extensions/nextcloud-talk/src/channel.ts` - -Existing helper surface: - -- `src/plugin-sdk/channel-lifecycle.ts` - -Likely extraction shape: - -- helper for account monitor lifecycle -- helper for webhook-backed account startup - -Expected savings: - -- ~150-300 LOC - -Risk: - -- Medium to high. Transport details diverge quickly. - -## 6. Small exact-clone cleanup - -Low-risk cleanup bucket. - -Examples: - -- duplicated gateway argv detection: - - `src/infra/gateway-lock.ts` - - `src/cli/daemon-cli/lifecycle.ts` -- duplicated port diagnostics rendering: - - `src/cli/daemon-cli/restart-health.ts` -- duplicated session-key construction: - - `src/web/auto-reply/monitor/broadcast.ts` - -Expected savings: - -- ~30-60 LOC - -Risk: - -- Low - -## Test clusters - -### LINE webhook event fixtures - -Strong examples: - -- `src/line/bot-handlers.test.ts` - -Likely extraction: - -- `makeLineEvent(...)` -- `runLineEvent(...)` -- `makeLineAccount(...)` - -Expected savings: - -- ~120-180 LOC - -### Telegram native command auth matrix - -Strong examples: - -- `src/telegram/bot-native-commands.group-auth.test.ts` -- `src/telegram/bot-native-commands.plugin-auth.test.ts` - -Likely extraction: - -- forum context builder -- denied-message assertion helper -- table-driven auth cases - -Expected savings: - -- ~80-140 LOC - -### Zalo lifecycle setup - -Strong examples: - -- `extensions/zalo/src/monitor.lifecycle.test.ts` - -Likely extraction: - -- shared monitor setup harness - -Expected savings: - -- ~50-90 LOC - -### Brave llm-context unsupported-option tests - -Strong examples: - -- `src/agents/tools/web-tools.enabled-defaults.test.ts` - -Likely extraction: - -- `it.each(...)` matrix - -Expected savings: - -- ~30-50 LOC - -## Suggested order - -1. Runtime singleton boilerplate -2. Small exact-clone cleanup -3. Config and security builder extraction -4. Test-helper extraction -5. Onboarding step extraction -6. Monitor lifecycle helper extraction diff --git a/docs/refactor/exec-host.md b/docs/refactor/exec-host.md deleted file mode 100644 index a70cf7c9dbd..00000000000 --- a/docs/refactor/exec-host.md +++ /dev/null @@ -1,316 +0,0 @@ ---- -summary: "Refactor plan: exec host routing, node approvals, and headless runner" -read_when: - - Designing exec host routing or exec approvals - - Implementing node runner + UI IPC - - Adding exec host security modes and slash commands -title: "Exec Host Refactor" ---- - -# Exec host refactor plan - -## Goals - -- Add `exec.host` + `exec.security` to route execution across **sandbox**, **gateway**, and **node**. -- Keep defaults **safe**: no cross-host execution unless explicitly enabled. -- Split execution into a **headless runner service** with optional UI (macOS app) via local IPC. -- Provide **per-agent** policy, allowlist, ask mode, and node binding. -- Support **ask modes** that work _with_ or _without_ allowlists. -- Cross-platform: Unix socket + token auth (macOS/Linux/Windows parity). - -## Non-goals - -- No legacy allowlist migration or legacy schema support. -- No PTY/streaming for node exec (aggregated output only). -- No new network layer beyond the existing Bridge + Gateway. - -## Decisions (locked) - -- **Config keys:** `exec.host` + `exec.security` (per-agent override allowed). -- **Elevation:** keep `/elevated` as an alias for gateway full access. -- **Ask default:** `on-miss`. -- **Approvals store:** `~/.openclaw/exec-approvals.json` (JSON, no legacy migration). -- **Runner:** headless system service; UI app hosts a Unix socket for approvals. -- **Node identity:** use existing `nodeId`. -- **Socket auth:** Unix socket + token (cross-platform); split later if needed. -- **Node host state:** `~/.openclaw/node.json` (node id + pairing token). -- **macOS exec host:** run `system.run` inside the macOS app; node host service forwards requests over local IPC. -- **No XPC helper:** stick to Unix socket + token + peer checks. - -## Key concepts - -### Host - -- `sandbox`: Docker exec (current behavior). -- `gateway`: exec on gateway host. -- `node`: exec on node runner via Bridge (`system.run`). - -### Security mode - -- `deny`: always block. -- `allowlist`: allow only matches. -- `full`: allow everything (equivalent to elevated). - -### Ask mode - -- `off`: never ask. -- `on-miss`: ask only when allowlist does not match. -- `always`: ask every time. - -Ask is **independent** of allowlist; allowlist can be used with `always` or `on-miss`. - -### Policy resolution (per exec) - -1. Resolve `exec.host` (tool param → agent override → global default). -2. Resolve `exec.security` and `exec.ask` (same precedence). -3. If host is `sandbox`, proceed with local sandbox exec. -4. If host is `gateway` or `node`, apply security + ask policy on that host. - -## Default safety - -- Default `exec.host = sandbox`. -- Default `exec.security = deny` for `gateway` and `node`. -- Default `exec.ask = on-miss` (only relevant if security allows). -- If no node binding is set, **agent may target any node**, but only if policy allows it. - -## Config surface - -### Tool parameters - -- `exec.host` (optional): `sandbox | gateway | node`. -- `exec.security` (optional): `deny | allowlist | full`. -- `exec.ask` (optional): `off | on-miss | always`. -- `exec.node` (optional): node id/name to use when `host=node`. - -### Config keys (global) - -- `tools.exec.host` -- `tools.exec.security` -- `tools.exec.ask` -- `tools.exec.node` (default node binding) - -### Config keys (per agent) - -- `agents.list[].tools.exec.host` -- `agents.list[].tools.exec.security` -- `agents.list[].tools.exec.ask` -- `agents.list[].tools.exec.node` - -### Alias - -- `/elevated on` = set `tools.exec.host=gateway`, `tools.exec.security=full` for the agent session. -- `/elevated off` = restore previous exec settings for the agent session. - -## Approvals store (JSON) - -Path: `~/.openclaw/exec-approvals.json` - -Purpose: - -- Local policy + allowlists for the **execution host** (gateway or node runner). -- Ask fallback when no UI is available. -- IPC credentials for UI clients. - -Proposed schema (v1): - -```json -{ - "version": 1, - "socket": { - "path": "~/.openclaw/exec-approvals.sock", - "token": "base64-opaque-token" - }, - "defaults": { - "security": "deny", - "ask": "on-miss", - "askFallback": "deny" - }, - "agents": { - "agent-id-1": { - "security": "allowlist", - "ask": "on-miss", - "allowlist": [ - { - "pattern": "~/Projects/**/bin/rg", - "lastUsedAt": 0, - "lastUsedCommand": "rg -n TODO", - "lastResolvedPath": "/Users/user/Projects/.../bin/rg" - } - ] - } - } -} -``` - -Notes: - -- No legacy allowlist formats. -- `askFallback` applies only when `ask` is required and no UI is reachable. -- File permissions: `0600`. - -## Runner service (headless) - -### Role - -- Enforce `exec.security` + `exec.ask` locally. -- Execute system commands and return output. -- Emit Bridge events for exec lifecycle (optional but recommended). - -### Service lifecycle - -- Launchd/daemon on macOS; system service on Linux/Windows. -- Approvals JSON is local to the execution host. -- UI hosts a local Unix socket; runners connect on demand. - -## UI integration (macOS app) - -### IPC - -- Unix socket at `~/.openclaw/exec-approvals.sock` (0600). -- Token stored in `exec-approvals.json` (0600). -- Peer checks: same-UID only. -- Challenge/response: nonce + HMAC(token, request-hash) to prevent replay. -- Short TTL (e.g., 10s) + max payload + rate limit. - -### Ask flow (macOS app exec host) - -1. Node service receives `system.run` from gateway. -2. Node service connects to the local socket and sends the prompt/exec request. -3. App validates peer + token + HMAC + TTL, then shows dialog if needed. -4. App executes the command in UI context and returns output. -5. Node service returns output to gateway. - -If UI missing: - -- Apply `askFallback` (`deny|allowlist|full`). - -### Diagram (SCI) - -``` -Agent -> Gateway -> Bridge -> Node Service (TS) - | IPC (UDS + token + HMAC + TTL) - v - Mac App (UI + TCC + system.run) -``` - -## Node identity + binding - -- Use existing `nodeId` from Bridge pairing. -- Binding model: - - `tools.exec.node` restricts the agent to a specific node. - - If unset, agent can pick any node (policy still enforces defaults). -- Node selection resolution: - - `nodeId` exact match - - `displayName` (normalized) - - `remoteIp` - - `nodeId` prefix (>= 6 chars) - -## Eventing - -### Who sees events - -- System events are **per session** and shown to the agent on the next prompt. -- Stored in the gateway in-memory queue (`enqueueSystemEvent`). - -### Event text - -- `Exec started (node=, id=)` -- `Exec finished (node=, id=, code=)` + optional output tail -- `Exec denied (node=, id=, )` - -### Transport - -Option A (recommended): - -- Runner sends Bridge `event` frames `exec.started` / `exec.finished`. -- Gateway `handleBridgeEvent` maps these into `enqueueSystemEvent`. - -Option B: - -- Gateway `exec` tool handles lifecycle directly (synchronous only). - -## Exec flows - -### Sandbox host - -- Existing `exec` behavior (Docker or host when unsandboxed). -- PTY supported in non-sandbox mode only. - -### Gateway host - -- Gateway process executes on its own machine. -- Enforces local `exec-approvals.json` (security/ask/allowlist). - -### Node host - -- Gateway calls `node.invoke` with `system.run`. -- Runner enforces local approvals. -- Runner returns aggregated stdout/stderr. -- Optional Bridge events for start/finish/deny. - -## Output caps - -- Cap combined stdout+stderr at **200k**; keep **tail 20k** for events. -- Truncate with a clear suffix (e.g., `"… (truncated)"`). - -## Slash commands - -- `/exec host= security= ask= node=` -- Per-agent, per-session overrides; non-persistent unless saved via config. -- `/elevated on|off|ask|full` remains a shortcut for `host=gateway security=full` (with `full` skipping approvals). - -## Cross-platform story - -- The runner service is the portable execution target. -- UI is optional; if missing, `askFallback` applies. -- Windows/Linux support the same approvals JSON + socket protocol. - -## Implementation phases - -### Phase 1: config + exec routing - -- Add config schema for `exec.host`, `exec.security`, `exec.ask`, `exec.node`. -- Update tool plumbing to respect `exec.host`. -- Add `/exec` slash command and keep `/elevated` alias. - -### Phase 2: approvals store + gateway enforcement - -- Implement `exec-approvals.json` reader/writer. -- Enforce allowlist + ask modes for `gateway` host. -- Add output caps. - -### Phase 3: node runner enforcement - -- Update node runner to enforce allowlist + ask. -- Add Unix socket prompt bridge to macOS app UI. -- Wire `askFallback`. - -### Phase 4: events - -- Add node → gateway Bridge events for exec lifecycle. -- Map to `enqueueSystemEvent` for agent prompts. - -### Phase 5: UI polish - -- Mac app: allowlist editor, per-agent switcher, ask policy UI. -- Node binding controls (optional). - -## Testing plan - -- Unit tests: allowlist matching (glob + case-insensitive). -- Unit tests: policy resolution precedence (tool param → agent override → global). -- Integration tests: node runner deny/allow/ask flows. -- Bridge event tests: node event → system event routing. - -## Open risks - -- UI unavailability: ensure `askFallback` is respected. -- Long-running commands: rely on timeout + output caps. -- Multi-node ambiguity: error unless node binding or explicit node param. - -## Related docs - -- [Exec tool](/tools/exec) -- [Exec approvals](/tools/exec-approvals) -- [Nodes](/nodes) -- [Elevated mode](/tools/elevated) diff --git a/docs/refactor/firecrawl-extension.md b/docs/refactor/firecrawl-extension.md deleted file mode 100644 index 273f9667916..00000000000 --- a/docs/refactor/firecrawl-extension.md +++ /dev/null @@ -1,260 +0,0 @@ ---- -summary: "Design for an opt-in Firecrawl extension that adds search/scrape value without hardwiring Firecrawl into core defaults" -read_when: - - Designing Firecrawl integration work - - Evaluating web_search/web_fetch plugin extension surfaces - - Deciding whether Firecrawl belongs in core or as an extension -title: "Firecrawl Extension Design" ---- - -# Firecrawl Extension Design - -## Goal - -Ship Firecrawl as an **opt-in extension** that adds: - -- explicit Firecrawl tools for agents, -- optional Firecrawl-backed `web_search` integration, -- self-hosted support, -- stronger security defaults than the current core fallback path, - -without pushing Firecrawl into the default setup/onboarding path. - -## Why this shape - -Recent Firecrawl issues/PRs cluster into three buckets: - -1. **Release/schema drift** - - Several releases rejected `tools.web.fetch.firecrawl` even though docs and runtime code supported it. -2. **Security hardening** - - Current `fetchFirecrawlContent()` still posts to the Firecrawl endpoint with raw `fetch()`, while the main web-fetch path uses the SSRF guard. -3. **Product pressure** - - Users want Firecrawl-native search/scrape flows, especially for self-hosted/private setups. - - Maintainers explicitly rejected wiring Firecrawl deeply into core defaults, setup flow, and browser behavior. - -That combination argues for an extension, not more Firecrawl-specific logic in the default core path. - -## Design principles - -- **Opt-in, vendor-scoped**: no auto-enable, no setup hijack, no default tool-profile widening. -- **Extension owns Firecrawl-specific config**: prefer plugin config over growing `tools.web.*` again. -- **Useful on day one**: works even if core `web_search` / `web_fetch` extension surfaces stay unchanged. -- **Security-first**: endpoint fetches use the same guarded networking posture as other web tools. -- **Self-hosted-friendly**: config + env fallback, explicit base URL, no hosted-only assumptions. - -## Proposed extension - -Plugin id: `firecrawl` - -### MVP capabilities - -Register explicit tools: - -- `firecrawl_search` -- `firecrawl_scrape` - -Optional later: - -- `firecrawl_crawl` -- `firecrawl_map` - -Do **not** add Firecrawl browser automation in the first version. That was the part of PR #32543 that pulled Firecrawl too far into core behavior and raised the most maintainership concern. - -## Config shape - -Use plugin-scoped config: - -```json5 -{ - plugins: { - entries: { - firecrawl: { - enabled: true, - config: { - apiKey: "FIRECRAWL_API_KEY", - baseUrl: "https://api.firecrawl.dev", - timeoutSeconds: 60, - maxAgeMs: 172800000, - proxy: "auto", - storeInCache: true, - onlyMainContent: true, - search: { - enabled: true, - defaultLimit: 5, - sources: ["web"], - categories: [], - scrapeResults: false, - }, - scrape: { - formats: ["markdown"], - fallbackForWebFetchLikeUse: false, - }, - }, - }, - }, - }, -} -``` - -### Credential resolution - -Precedence: - -1. `plugins.entries.firecrawl.config.apiKey` -2. `FIRECRAWL_API_KEY` - -Base URL precedence: - -1. `plugins.entries.firecrawl.config.baseUrl` -2. `FIRECRAWL_BASE_URL` -3. `https://api.firecrawl.dev` - -### Compatibility bridge - -For the first release, the extension may also **read** existing core config at `tools.web.fetch.firecrawl.*` as a fallback source so existing users do not need to migrate immediately. - -Write path stays plugin-local. Do not keep expanding core Firecrawl config surfaces. - -## Tool design - -### `firecrawl_search` - -Inputs: - -- `query` -- `limit` -- `sources` -- `categories` -- `scrapeResults` -- `timeoutSeconds` - -Behavior: - -- Calls Firecrawl `v2/search` -- Returns normalized OpenClaw-friendly result objects: - - `title` - - `url` - - `snippet` - - `source` - - optional `content` -- Wraps result content as untrusted external content -- Cache key includes query + relevant provider params - -Why explicit tool first: - -- Works today without changing `tools.web.search.provider` -- Avoids current schema/loader constraints -- Gives users Firecrawl value immediately - -### `firecrawl_scrape` - -Inputs: - -- `url` -- `formats` -- `onlyMainContent` -- `maxAgeMs` -- `proxy` -- `storeInCache` -- `timeoutSeconds` - -Behavior: - -- Calls Firecrawl `v2/scrape` -- Returns markdown/text plus metadata: - - `title` - - `finalUrl` - - `status` - - `warning` -- Wraps extracted content the same way `web_fetch` does -- Shares cache semantics with web tool expectations where practical - -Why explicit scrape tool: - -- Sidesteps the unresolved `Readability -> Firecrawl -> basic HTML cleanup` ordering bug in core `web_fetch` -- Gives users a deterministic “always use Firecrawl” path for JS-heavy/bot-protected sites - -## What the extension should not do - -- No auto-adding `browser`, `web_search`, or `web_fetch` to `tools.alsoAllow` -- No default onboarding step in `openclaw setup` -- No Firecrawl-specific browser session lifecycle in core -- No change to built-in `web_fetch` fallback semantics in the extension MVP - -## Phase plan - -### Phase 1: extension-only, no core schema changes - -Implement: - -- `extensions/firecrawl/` -- plugin config schema -- `firecrawl_search` -- `firecrawl_scrape` -- tests for config resolution, endpoint selection, caching, error handling, and SSRF guard usage - -This phase is enough to ship real user value. - -### Phase 2: optional `web_search` provider integration - -Support `tools.web.search.provider = "firecrawl"` only after fixing two core constraints: - -1. `src/plugins/web-search-providers.ts` must load configured/installed web-search-provider plugins instead of a hardcoded bundled list. -2. `src/config/types.tools.ts` and `src/config/zod-schema.agent-runtime.ts` must stop hardcoding the provider enum in a way that blocks plugin-registered ids. - -Recommended shape: - -- keep built-in providers documented, -- allow any registered plugin provider id at runtime, -- validate provider-specific config via the provider plugin or a generic provider bag. - -### Phase 3: optional `web_fetch` provider capability - -Do this only if maintainers want vendor-specific fetch backends to participate in `web_fetch`. - -Needed core addition: - -- `registerWebFetchProvider` or equivalent fetch-backend extension surface - -Without that capability, the extension should keep `firecrawl_scrape` as an explicit tool rather than trying to patch built-in `web_fetch`. - -## Security requirements - -The extension must treat Firecrawl as a **trusted operator-configured endpoint**, but still harden transport: - -- Use SSRF-guarded fetch for the Firecrawl endpoint call, not raw `fetch()` -- Preserve self-hosted/private-network compatibility using the same trusted-web-tools endpoint policy used elsewhere -- Never log the API key -- Keep endpoint/base URL resolution explicit and predictable -- Treat Firecrawl-returned content as untrusted external content - -This mirrors the intent behind the SSRF hardening PRs without assuming Firecrawl is a hostile multi-tenant surface. - -## Why not a skill - -The repo already closed a Firecrawl skill PR in favor of ClawHub distribution. That is fine for optional user-installed prompt workflows, but it does not solve: - -- deterministic tool availability, -- provider-grade config/credential handling, -- self-hosted endpoint support, -- caching, -- stable typed outputs, -- security review on network behavior. - -This belongs as an extension, not a prompt-only skill. - -## Success criteria - -- Users can install/enable one extension and get reliable Firecrawl search/scrape without touching core defaults. -- Self-hosted Firecrawl works with config/env fallback. -- Extension endpoint fetches use guarded networking. -- No new Firecrawl-specific core onboarding/default behavior. -- Core can later adopt plugin-native `web_search` / `web_fetch` extension surfaces without redesigning the extension. - -## Recommended implementation order - -1. Build `firecrawl_scrape` -2. Build `firecrawl_search` -3. Add docs and examples -4. If desired, generalize `web_search` provider loading so the extension can back `web_search` -5. Only then consider a true `web_fetch` provider capability diff --git a/docs/refactor/outbound-session-mirroring.md b/docs/refactor/outbound-session-mirroring.md deleted file mode 100644 index 4f712541658..00000000000 --- a/docs/refactor/outbound-session-mirroring.md +++ /dev/null @@ -1,89 +0,0 @@ ---- -title: Outbound Session Mirroring Refactor (Issue #1520) -description: Track outbound session mirroring refactor notes, decisions, tests, and open items. -summary: "Refactor notes for mirroring outbound sends into target channel sessions" -read_when: - - Working on outbound transcript/session mirroring behavior - - Debugging sessionKey derivation for send/message tool paths ---- - -# Outbound Session Mirroring Refactor (Issue #1520) - -## Status - -- In progress. -- Core + plugin channel routing updated for outbound mirroring. -- Gateway send now derives target session when sessionKey is omitted. - -## Context - -Outbound sends were mirrored into the _current_ agent session (tool session key) rather than the target channel session. Inbound routing uses channel/peer session keys, so outbound responses landed in the wrong session and first-contact targets often lacked session entries. - -## Goals - -- Mirror outbound messages into the target channel session key. -- Create session entries on outbound when missing. -- Keep thread/topic scoping aligned with inbound session keys. -- Cover core channels plus bundled extensions. - -## Implementation Summary - -- New outbound session routing helper: - - `src/infra/outbound/outbound-session.ts` - - `resolveOutboundSessionRoute` builds target sessionKey using `buildAgentSessionKey` (dmScope + identityLinks). - - `ensureOutboundSessionEntry` writes minimal `MsgContext` via `recordSessionMetaFromInbound`. -- `runMessageAction` (send) derives target sessionKey and passes it to `executeSendAction` for mirroring. -- `message-tool` no longer mirrors directly; it only resolves agentId from the current session key. -- Plugin send path mirrors via `appendAssistantMessageToSessionTranscript` using the derived sessionKey. -- Gateway send derives a target session key when none is provided (default agent), and ensures a session entry. - -## Thread/Topic Handling - -- Slack: replyTo/threadId -> `resolveThreadSessionKeys` (suffix). -- Discord: threadId/replyTo -> `resolveThreadSessionKeys` with `useSuffix=false` to match inbound (thread channel id already scopes session). -- Telegram: topic IDs map to `chatId:topic:` via `buildTelegramGroupPeerId`. - -## Extensions Covered - -- Matrix, MS Teams, Mattermost, BlueBubbles, Nextcloud Talk, Zalo, Zalo Personal, Nostr, Tlon. -- Notes: - - Mattermost targets now strip `@` for DM session key routing. - - Zalo Personal uses DM peer kind for 1:1 targets (group only when `group:` is present). - - BlueBubbles group targets strip `chat_*` prefixes to match inbound session keys. - - Slack auto-thread mirroring matches channel ids case-insensitively. - - Gateway send lowercases provided session keys before mirroring. - -## Decisions - -- **Gateway send session derivation**: if `sessionKey` is provided, use it. If omitted, derive a sessionKey from target + default agent and mirror there. -- **Session entry creation**: always use `recordSessionMetaFromInbound` with `Provider/From/To/ChatType/AccountId/Originating*` aligned to inbound formats. -- **Target normalization**: outbound routing uses resolved targets (post `resolveChannelTarget`) when available. -- **Session key casing**: canonicalize session keys to lowercase on write and during migrations. - -## Tests Added/Updated - -- `src/infra/outbound/outbound.test.ts` - - Slack thread session key. - - Telegram topic session key. - - dmScope identityLinks with Discord. -- `src/agents/tools/message-tool.test.ts` - - Derives agentId from session key (no sessionKey passed through). -- `src/gateway/server-methods/send.test.ts` - - Derives session key when omitted and creates session entry. - -## Open Items / Follow-ups - -- Voice-call plugin uses custom `voice:` session keys. Outbound mapping is not standardized here; if message-tool should support voice-call sends, add explicit mapping. -- Confirm if any external plugin uses non-standard `From/To` formats beyond the bundled set. - -## Files Touched - -- `src/infra/outbound/outbound-session.ts` -- `src/infra/outbound/outbound-send-service.ts` -- `src/infra/outbound/message-action-runner.ts` -- `src/agents/tools/message-tool.ts` -- `src/gateway/server-methods/send.ts` -- Tests in: - - `src/infra/outbound/outbound.test.ts` - - `src/agents/tools/message-tool.test.ts` - - `src/gateway/server-methods/send.test.ts` diff --git a/docs/refactor/plugin-sdk.md b/docs/refactor/plugin-sdk.md deleted file mode 100644 index edf79de266d..00000000000 --- a/docs/refactor/plugin-sdk.md +++ /dev/null @@ -1,264 +0,0 @@ ---- -summary: "Plan: one clean plugin SDK + runtime for all messaging connectors" -read_when: - - Defining or refactoring the plugin architecture - - Migrating channel connectors to the plugin SDK/runtime -title: "Plugin SDK Refactor" ---- - -# Plugin SDK + Runtime Refactor Plan - -Goal: every messaging connector is a plugin (bundled or external) using one stable API. -No plugin imports from `src/**` directly. All dependencies go through the SDK or runtime. - -## Why now - -- Current connectors mix patterns: direct core imports, dist-only bridges, and custom helpers. -- This makes upgrades brittle and blocks a clean external plugin surface. - -## Target architecture (two layers) - -### 1) Plugin SDK (compile-time, stable, publishable) - -Scope: types, helpers, and config utilities. No runtime state, no side effects. - -Contents (examples): - -- Types: `ChannelPlugin`, adapters, `ChannelMeta`, `ChannelCapabilities`, `ChannelDirectoryEntry`. -- Config helpers: `buildChannelConfigSchema`, `setAccountEnabledInConfigSection`, `deleteAccountFromConfigSection`, - `applyAccountNameToChannelSection`. -- Pairing helpers: `PAIRING_APPROVED_MESSAGE`, `formatPairingApproveHint`. -- Setup entry points: host-owned `setup` + `setupWizard`; avoid broad public onboarding helpers. -- Tool param helpers: `createActionGate`, `readStringParam`, `readNumberParam`, `readReactionParams`, `jsonResult`. -- Docs link helper: `formatDocsLink`. - -Delivery: - -- Publish as `openclaw/plugin-sdk` (or export from core under `openclaw/plugin-sdk`). -- Semver with explicit stability guarantees. - -### 2) Plugin Runtime (execution surface, injected) - -Scope: everything that touches core runtime behavior. -Accessed via `OpenClawPluginApi.runtime` so plugins never import `src/**`. - -Proposed surface (minimal but complete): - -```ts -export type PluginRuntime = { - channel: { - text: { - chunkMarkdownText(text: string, limit: number): string[]; - resolveTextChunkLimit(cfg: OpenClawConfig, channel: string, accountId?: string): number; - hasControlCommand(text: string, cfg: OpenClawConfig): boolean; - }; - reply: { - dispatchReplyWithBufferedBlockDispatcher(params: { - ctx: unknown; - cfg: unknown; - dispatcherOptions: { - deliver: (payload: { - text?: string; - mediaUrls?: string[]; - mediaUrl?: string; - }) => void | Promise; - onError?: (err: unknown, info: { kind: string }) => void; - }; - }): Promise; - createReplyDispatcherWithTyping?: unknown; // adapter for Teams-style flows - }; - routing: { - resolveAgentRoute(params: { - cfg: unknown; - channel: string; - accountId: string; - peer: { kind: RoutePeerKind; id: string }; - }): { sessionKey: string; accountId: string }; - }; - pairing: { - buildPairingReply(params: { channel: string; idLine: string; code: string }): string; - readAllowFromStore(channel: string): Promise; - upsertPairingRequest(params: { - channel: string; - id: string; - meta?: { name?: string }; - }): Promise<{ code: string; created: boolean }>; - }; - media: { - fetchRemoteMedia(params: { url: string }): Promise<{ buffer: Buffer; contentType?: string }>; - saveMediaBuffer( - buffer: Uint8Array, - contentType: string | undefined, - direction: "inbound" | "outbound", - maxBytes: number, - ): Promise<{ path: string; contentType?: string }>; - }; - mentions: { - buildMentionRegexes(cfg: OpenClawConfig, agentId?: string): RegExp[]; - matchesMentionPatterns(text: string, regexes: RegExp[]): boolean; - }; - groups: { - resolveGroupPolicy( - cfg: OpenClawConfig, - channel: string, - accountId: string, - groupId: string, - ): { - allowlistEnabled: boolean; - allowed: boolean; - groupConfig?: unknown; - defaultConfig?: unknown; - }; - resolveRequireMention( - cfg: OpenClawConfig, - channel: string, - accountId: string, - groupId: string, - override?: boolean, - ): boolean; - }; - debounce: { - createInboundDebouncer(opts: { - debounceMs: number; - buildKey: (v: T) => string | null; - shouldDebounce: (v: T) => boolean; - onFlush: (entries: T[]) => Promise; - onError?: (err: unknown) => void; - }): { push: (v: T) => void; flush: () => Promise }; - resolveInboundDebounceMs(cfg: OpenClawConfig, channel: string): number; - }; - commands: { - resolveCommandAuthorizedFromAuthorizers(params: { - useAccessGroups: boolean; - authorizers: Array<{ configured: boolean; allowed: boolean }>; - }): boolean; - }; - }; - logging: { - shouldLogVerbose(): boolean; - getChildLogger(name: string): PluginLogger; - }; - state: { - resolveStateDir(cfg: OpenClawConfig): string; - }; -}; -``` - -Notes: - -- Runtime is the only way to access core behavior. -- SDK is intentionally small and stable. -- Each runtime method maps to an existing core implementation (no duplication). - -## Migration plan (phased, safe) - -### Phase 0: scaffolding - -- Introduce `openclaw/plugin-sdk`. -- Add `api.runtime` to `OpenClawPluginApi` with the surface above. -- Maintain existing imports during a transition window (deprecation warnings). - -### Phase 1: bridge cleanup (low risk) - -- Replace per-extension `core-bridge.ts` with `api.runtime`. -- Migrate BlueBubbles, Zalo, Zalo Personal first (already close). -- Remove duplicated bridge code. - -### Phase 2: light direct-import plugins - -- Migrate Matrix to SDK + runtime. -- Validate onboarding, directory, group mention logic. - -### Phase 3: heavy direct-import plugins - -- Migrate MS Teams (largest set of runtime helpers). -- Ensure reply/typing semantics match current behavior. - -### Phase 4: iMessage pluginization - -- Move iMessage into `extensions/imessage`. -- Replace direct core calls with `api.runtime`. -- Keep config keys, CLI behavior, and docs intact. - -### Phase 5: enforcement - -- Add lint rule / CI check: no `extensions/**` imports from `src/**`. -- Add plugin SDK/version compatibility checks (runtime + SDK semver). - -## Compatibility and versioning - -- SDK: semver, published, documented changes. -- Runtime: versioned per core release. Add `api.runtime.version`. -- Plugins declare a required runtime range (e.g., `openclawRuntime: ">=2026.2.0"`). - -## Testing strategy - -- Adapter-level unit tests (runtime functions exercised with real core implementation). -- Golden tests per plugin: ensure no behavior drift (routing, pairing, allowlist, mention gating). -- A single end-to-end plugin sample used in CI (install + run + smoke). - -## Open questions - -- Where to host SDK types: separate package or core export? -- Runtime type distribution: in SDK (types only) or in core? -- How to expose docs links for bundled vs external plugins? -- Do we allow limited direct core imports for in-repo plugins during transition? - -## Success criteria - -- All channel connectors are plugins using SDK + runtime. -- No `extensions/**` imports from `src/**`. -- New connector templates depend only on SDK + runtime. -- External plugins can be developed and updated without core source access. - -Related docs: [Plugins](/tools/plugin), [Channels](/channels/index), [Configuration](/gateway/configuration). - -## Capability plan alignment - -The plugin SDK refactor now aligns with the public capability model documented -in [Plugins](/tools/plugin#public-capability-model). - -Key decisions: - -- Capabilities are the public plugin model. Registration is explicit and typed. -- Legacy hook-only plugins remain supported without migration. -- Plugin shapes (plain-capability, hybrid-capability, hook-only, non-capability) - are classified from actual registration behavior. -- `openclaw plugins inspect` provides canonical deep introspection for any - loaded plugin, showing shape, capabilities, hooks, tools, and diagnostics. -- Export boundary: export capabilities, not implementation convenience. Trim - non-contract helper exports. - -Required test matrix for the capability model: - -- hook-only legacy plugin fixture -- plain capability plugin fixture -- hybrid capability plugin fixture -- real-world legacy hook-style plugin fixture -- `before_agent_start` still works -- typed hooks remain additive -- capability usage and plugin shape are inspectable - -## Implemented channel-owned capabilities - -Recent refactor work widened the channel plugin contract so core can stop owning -channel-specific UX and routing behavior: - -- `messaging.buildCrossContextComponents`: channel-owned cross-context UI markers - (for example Discord components v2 containers) -- `messaging.enableInteractiveReplies`: channel-owned reply normalization toggles - (for example Slack interactive replies) -- `messaging.resolveOutboundSessionRoute`: channel-owned outbound session routing -- `status.formatCapabilitiesProbe` / `status.buildCapabilitiesDiagnostics`: channel-owned - `/channels capabilities` probe display and extra audits/scopes -- `threading.resolveAutoThreadId`: channel-owned same-conversation auto-threading -- `threading.resolveReplyTransport`: channel-owned reply-vs-thread delivery mapping -- `actions.requiresTrustedRequesterSender`: channel-owned privileged action trust gates -- `execApprovals.*`: channel-owned exec approval surface state, forwarding suppression, - pending payload UX, and pre-delivery hooks -- `lifecycle.onAccountConfigChanged` / `lifecycle.onAccountRemoved`: channel-owned cleanup on - config mutation/removal -- `allowlist.supportsScope`: channel-owned allowlist scope advertisement - -These capabilities should be preferred over new `channel === "discord"` / -`telegram` branches in shared core flows. diff --git a/docs/refactor/strict-config.md b/docs/refactor/strict-config.md deleted file mode 100644 index 9605730c2b0..00000000000 --- a/docs/refactor/strict-config.md +++ /dev/null @@ -1,93 +0,0 @@ ---- -summary: "Strict config validation + doctor-only migrations" -read_when: - - Designing or implementing config validation behavior - - Working on config migrations or doctor workflows - - Handling plugin config schemas or plugin load gating -title: "Strict Config Validation" ---- - -# Strict config validation (doctor-only migrations) - -## Goals - -- **Reject unknown config keys everywhere** (root + nested), except root `$schema` metadata. -- **Reject plugin config without a schema**; don’t load that plugin. -- **Remove legacy auto-migration on load**; migrations run via doctor only. -- **Auto-run doctor (dry-run) on startup**; if invalid, block non-diagnostic commands. - -## Non-goals - -- Backward compatibility on load (legacy keys do not auto-migrate). -- Silent drops of unrecognized keys. - -## Strict validation rules - -- Config must match the schema exactly at every level. -- Unknown keys are validation errors (no passthrough at root or nested), except root `$schema` when it is a string. -- `plugins.entries..config` must be validated by the plugin’s schema. - - If a plugin lacks a schema, **reject plugin load** and surface a clear error. -- Unknown `channels.` keys are errors unless a plugin manifest declares the channel id. -- Plugin manifests (`openclaw.plugin.json`) are required for all plugins. - -## Plugin schema enforcement - -- Each plugin provides a strict JSON Schema for its config (inline in the manifest). -- Plugin load flow: - 1. Resolve plugin manifest + schema (`openclaw.plugin.json`). - 2. Validate config against the schema. - 3. If missing schema or invalid config: block plugin load, record error. -- Error message includes: - - Plugin id - - Reason (missing schema / invalid config) - - Path(s) that failed validation -- Disabled plugins keep their config, but Doctor + logs surface a warning. - -## Doctor flow - -- Doctor runs **every time** config is loaded (dry-run by default). -- If config invalid: - - Print a summary + actionable errors. - - Instruct: `openclaw doctor --fix`. -- `openclaw doctor --fix`: - - Applies migrations. - - Removes unknown keys. - - Writes updated config. - -## Command gating (when config is invalid) - -Allowed (diagnostic-only): - -- `openclaw doctor` -- `openclaw logs` -- `openclaw health` -- `openclaw help` -- `openclaw status` -- `openclaw gateway status` - -Everything else must hard-fail with: “Config invalid. Run `openclaw doctor --fix`.” - -## Error UX format - -- Single summary header. -- Grouped sections: - - Unknown keys (full paths) - - Legacy keys / migrations needed - - Plugin load failures (plugin id + reason + path) - -## Implementation touchpoints - -- `src/config/zod-schema.ts`: remove root passthrough; strict objects everywhere. -- `src/config/zod-schema.providers.ts`: ensure strict channel schemas. -- `src/config/validation.ts`: fail on unknown keys; do not apply legacy migrations. -- `src/config/io.ts`: remove legacy auto-migrations; always run doctor dry-run. -- `src/config/legacy*.ts`: move usage to doctor only. -- `src/plugins/*`: add schema registry + gating. -- CLI command gating in `src/cli`. - -## Tests - -- Unknown key rejection (root + nested). -- Plugin missing schema → plugin load blocked with clear error. -- Invalid config → gateway startup blocked except diagnostic commands. -- Doctor dry-run auto; `doctor --fix` writes corrected config. diff --git a/docs/zh-CN/refactor/clawnet.md b/docs/zh-CN/refactor/clawnet.md deleted file mode 100644 index bfbf81304ab..00000000000 --- a/docs/zh-CN/refactor/clawnet.md +++ /dev/null @@ -1,424 +0,0 @@ ---- -read_when: - - 规划节点 + 操作者客户端的统一网络协议 - - 重新设计跨设备的审批、配对、TLS 和在线状态 -summary: Clawnet 重构:统一网络协议、角色、认证、审批、身份 -title: Clawnet 重构 -x-i18n: - generated_at: "2026-02-03T07:55:03Z" - model: claude-opus-4-5 - provider: pi - source_hash: 719b219c3b326479658fe6101c80d5273fc56eb3baf50be8535e0d1d2bb7987f - source_path: refactor/clawnet.md - workflow: 15 ---- - -# Clawnet 重构(协议 + 认证统一) - -## 嗨 - -嗨 Peter — 方向很好;这将解锁更简单的用户体验 + 更强的安全性。 - -## 目的 - -单一、严谨的文档用于: - -- 当前状态:协议、流程、信任边界。 -- 痛点:审批、多跳路由、UI 重复。 -- 提议的新状态:一个协议、作用域角色、统一的认证/配对、TLS 固定。 -- 身份模型:稳定 ID + 可爱的别名。 -- 迁移计划、风险、开放问题。 - -## 目标(来自讨论) - -- 所有客户端使用一个协议(mac 应用、CLI、iOS、Android、无头节点)。 -- 每个网络参与者都经过认证 + 配对。 -- 角色清晰:节点 vs 操作者。 -- 中央审批路由到用户所在位置。 -- 所有远程流量使用 TLS 加密 + 可选固定。 -- 最小化代码重复。 -- 单台机器应该只显示一次(无 UI/节点重复条目)。 - -## 非目标(明确) - -- 移除能力分离(仍需要最小权限)。 -- 不经作用域检查就暴露完整的 Gateway 网关控制平面。 -- 使认证依赖于人类标签(别名仍然是非安全性的)。 - ---- - -# 当前状态(现状) - -## 两个协议 - -### 1) Gateway 网关 WebSocket(控制平面) - -- 完整 API 表面:配置、渠道、模型、会话、智能体运行、日志、节点等。 -- 默认绑定:loopback。通过 SSH/Tailscale 远程访问。 -- 认证:通过 `connect` 的令牌/密码。 -- 无 TLS 固定(依赖 loopback/隧道)。 -- 代码: - - `src/gateway/server/ws-connection/message-handler.ts` - - `src/gateway/client.ts` - - `docs/gateway/protocol.md` - -### 2) Bridge(节点传输) - -- 窄允许列表表面,节点身份 + 配对。 -- TCP 上的 JSONL;可选 TLS + 证书指纹固定。 -- TLS 在设备发现 TXT 中公布指纹。 -- 代码: - - `src/infra/bridge/server/connection.ts` - - `src/gateway/server-bridge.ts` - - `src/node-host/bridge-client.ts` - - `docs/gateway/bridge-protocol.md` - -## 当前的控制平面客户端 - -- CLI → 通过 `callGateway`(`src/gateway/call.ts`)连接 Gateway 网关 WS。 -- macOS 应用 UI → Gateway 网关 WS(`GatewayConnection`)。 -- Web 控制 UI → Gateway 网关 WS。 -- ACP → Gateway 网关 WS。 -- 浏览器控制使用自己的 HTTP 控制服务器。 - -## 当前的节点 - -- macOS 应用在节点模式下连接到 Gateway 网关 bridge(`MacNodeBridgeSession`)。 -- iOS/Android 应用连接到 Gateway 网关 bridge。 -- 配对 + 每节点令牌存储在 Gateway 网关上。 - -## 当前审批流程(exec) - -- 智能体通过 Gateway 网关使用 `system.run`。 -- Gateway 网关通过 bridge 调用节点。 -- 节点运行时决定审批。 -- UI 提示由 mac 应用显示(当节点 == mac 应用时)。 -- 节点向 Gateway 网关返回 `invoke-res`。 -- 多跳,UI 绑定到节点主机。 - -## 当前的在线状态 + 身份 - -- 来自 WS 客户端的 Gateway 网关在线状态条目。 -- 来自 bridge 的节点在线状态条目。 -- mac 应用可能为同一台机器显示两个条目(UI + 节点)。 -- 节点身份存储在配对存储中;UI 身份是分开的。 - ---- - -# 问题/痛点 - -- 需要维护两个协议栈(WS + Bridge)。 -- 远程节点上的审批:提示出现在节点主机上,而不是用户所在位置。 -- TLS 固定仅存在于 bridge;WS 依赖 SSH/Tailscale。 -- 身份重复:同一台机器显示为多个实例。 -- 角色模糊:UI + 节点 + CLI 能力没有明确分离。 - ---- - -# 提议的新状态(Clawnet) - -## 一个协议,两个角色 - -带有角色 + 作用域的单一 WS 协议。 - -- **角色:node**(能力宿主) -- **角色:operator**(控制平面) -- 操作者的可选**作用域**: - - `operator.read`(状态 + 查看) - - `operator.write`(智能体运行、发送) - - `operator.admin`(配置、渠道、模型) - -### 角色行为 - -**Node** - -- 可以注册能力(`caps`、`commands`、permissions)。 -- 可以接收 `invoke` 命令(`system.run`、`camera.*`、`canvas.*`、`screen.record` 等)。 -- 可以发送事件:`voice.transcript`、`agent.request`、`chat.subscribe`。 -- 不能调用配置/模型/渠道/会话/智能体控制平面 API。 - -**Operator** - -- 完整控制平面 API,受作用域限制。 -- 接收所有审批。 -- 不直接执行 OS 操作;路由到节点。 - -### 关键规则 - -角色是按连接的,不是按设备。一个设备可以分别打开两个角色。 - ---- - -# 统一认证 + 配对 - -## 客户端身份 - -每个客户端提供: - -- `deviceId`(稳定的,从设备密钥派生)。 -- `displayName`(人类名称)。 -- `role` + `scope` + `caps` + `commands`。 - -## 配对流程(统一) - -- 客户端未认证连接。 -- Gateway 网关为该 `deviceId` 创建**配对请求**。 -- 操作者收到提示;批准/拒绝。 -- Gateway 网关颁发绑定到以下内容的凭证: - - 设备公钥 - - 角色 - - 作用域 - - 能力/命令 -- 客户端持久化令牌,重新认证连接。 - -## 设备绑定认证(避免 bearer 令牌重放) - -首选:设备密钥对。 - -- 设备一次性生成密钥对。 -- `deviceId = fingerprint(publicKey)`。 -- Gateway 网关发送 nonce;设备签名;Gateway 网关验证。 -- 令牌颁发给公钥(所有权证明),而不是字符串。 - -替代方案: - -- mTLS(客户端证书):最强,运维复杂度更高。 -- 短期 bearer 令牌仅作为临时阶段(早期轮换 + 撤销)。 - -## 静默批准(SSH 启发式) - -精确定义以避免薄弱环节。优选其一: - -- **仅限本地**:当客户端通过 loopback/Unix socket 连接时自动配对。 -- **通过 SSH 质询**:Gateway 网关颁发 nonce;客户端通过获取它来证明 SSH。 -- **物理存在窗口**:在 Gateway 网关主机 UI 上本地批准后,允许在短窗口内(例如 10 分钟)自动配对。 - -始终记录 + 记录自动批准。 - ---- - -# TLS 无处不在(开发 + 生产) - -## 复用现有 bridge TLS - -使用当前 TLS 运行时 + 指纹固定: - -- `src/infra/bridge/server/tls.ts` -- `src/node-host/bridge-client.ts` 中的指纹验证逻辑 - -## 应用于 WS - -- WS 服务器使用相同的证书/密钥 + 指纹支持 TLS。 -- WS 客户端可以固定指纹(可选)。 -- 设备发现为所有端点公布 TLS + 指纹。 - - 设备发现仅是定位器提示;永远不是信任锚。 - -## 为什么 - -- 减少对 SSH/Tailscale 的机密性依赖。 -- 默认情况下使远程移动连接安全。 - ---- - -# 审批重新设计(集中化) - -## 当前 - -审批发生在节点主机上(mac 应用节点运行时)。提示出现在节点运行的地方。 - -## 提议 - -审批是 **Gateway 网关托管的**,UI 传递给操作者客户端。 - -### 新流程 - -1. Gateway 网关接收 `system.run` 意图(智能体)。 -2. Gateway 网关创建审批记录:`approval.requested`。 -3. 操作者 UI 显示提示。 -4. 审批决定发送到 Gateway 网关:`approval.resolve`。 -5. 如果批准,Gateway 网关调用节点命令。 -6. 节点执行,返回 `invoke-res`。 - -### 审批语义(加固) - -- 广播到所有操作者;只有活跃的 UI 显示模态框(其他显示 toast)。 -- 先解决者获胜;Gateway 网关拒绝后续解决为已结算。 -- 默认超时:N 秒后拒绝(例如 60 秒),记录原因。 -- 解决需要 `operator.approvals` 作用域。 - -## 好处 - -- 提示出现在用户所在位置(mac/手机)。 -- 远程节点的一致审批。 -- 节点运行时保持无头;无 UI 依赖。 - ---- - -# 角色清晰示例 - -## iPhone 应用 - -- **Node 角色**用于:麦克风、相机、语音聊天、位置、一键通话。 -- 可选的 **operator.read** 用于状态和聊天视图。 -- 可选的 **operator.write/admin** 仅在明确启用时。 - -## macOS 应用 - -- 默认是 Operator 角色(控制 UI)。 -- 启用"Mac 节点"时是 Node 角色(system.run、屏幕、相机)。 -- 两个连接使用相同的 deviceId → 合并的 UI 条目。 - -## CLI - -- 始终是 Operator 角色。 -- 作用域按子命令派生: - - `status`、`logs` → read - - `agent`、`message` → write - - `config`、`channels` → admin - - 审批 + 配对 → `operator.approvals` / `operator.pairing` - ---- - -# 身份 + 别名 - -## 稳定 ID - -认证必需;永不改变。 -首选: - -- 密钥对指纹(公钥哈希)。 - -## 可爱别名(龙虾主题) - -仅人类标签。 - -- 示例:`scarlet-claw`、`saltwave`、`mantis-pinch`。 -- 存储在 Gateway 网关注册表中,可编辑。 -- 冲突处理:`-2`、`-3`。 - -## UI 分组 - -跨角色的相同 `deviceId` → 单个"实例"行: - -- 徽章:`operator`、`node`。 -- 显示能力 + 最后在线。 - ---- - -# 迁移策略 - -## 阶段 0:记录 + 对齐 - -- 发布此文档。 -- 盘点所有协议调用 + 审批流程。 - -## 阶段 1:向 WS 添加角色/作用域 - -- 用 `role`、`scope`、`deviceId` 扩展 `connect` 参数。 -- 为 node 角色添加允许列表限制。 - -## 阶段 2:Bridge 兼容性 - -- 保持 bridge 运行。 -- 并行添加 WS node 支持。 -- 通过配置标志限制功能。 - -## 阶段 3:中央审批 - -- 在 WS 中添加审批请求 + 解决事件。 -- 更新 mac 应用 UI 以提示 + 响应。 -- 节点运行时停止提示 UI。 - -## 阶段 4:TLS 统一 - -- 使用 bridge TLS 运行时为 WS 添加 TLS 配置。 -- 向客户端添加固定。 - -## 阶段 5:弃用 bridge - -- 将 iOS/Android/mac 节点迁移到 WS。 -- 保持 bridge 作为后备;稳定后移除。 - -## 阶段 6:设备绑定认证 - -- 所有非本地连接都需要基于密钥的身份。 -- 添加撤销 + 轮换 UI。 - ---- - -# 安全说明 - -- 角色/允许列表在 Gateway 网关边界强制执行。 -- 没有客户端可以在没有 operator 作用域的情况下获得"完整"API。 -- *所有*连接都需要配对。 -- TLS + 固定减少移动设备的 MITM 风险。 -- SSH 静默批准是便利措施;仍然记录 + 可撤销。 -- 设备发现永远不是信任锚。 -- 能力声明通过按平台/类型的服务器允许列表验证。 - -# 流式传输 + 大型负载(节点媒体) - -WS 控制平面对于小消息没问题,但节点还做: - -- 相机剪辑 -- 屏幕录制 -- 音频流 - -选项: - -1. WS 二进制帧 + 分块 + 背压规则。 -2. 单独的流式端点(仍然是 TLS + 认证)。 -3. 对于媒体密集型命令保持 bridge 更长时间,最后迁移。 - -在实现前选择一个以避免漂移。 - -# 能力 + 命令策略 - -- 节点报告的 caps/commands 被视为**声明**。 -- Gateway 网关强制执行每平台允许列表。 -- 任何新命令都需要操作者批准或显式允许列表更改。 -- 用时间戳审计更改。 - -# 审计 + 速率限制 - -- 记录:配对请求、批准/拒绝、令牌颁发/轮换/撤销。 -- 速率限制配对垃圾和审批提示。 - -# 协议卫生 - -- 显式协议版本 + 错误代码。 -- 重连规则 + 心跳策略。 -- 在线状态 TTL 和最后在线语义。 - ---- - -# 开放问题 - -1. 同时运行两个角色的单个设备:令牌模型 - - 建议每个角色单独的令牌(node vs operator)。 - - 相同的 deviceId;不同的作用域;更清晰的撤销。 - -2. 操作者作用域粒度 - - read/write/admin + approvals + pairing(最小可行)。 - - 以后考虑每功能作用域。 - -3. 令牌轮换 + 撤销 UX - - 角色更改时自动轮换。 - - 按 deviceId + 角色撤销的 UI。 - -4. 设备发现 - - 扩展当前 Bonjour TXT 以包含 WS TLS 指纹 + 角色提示。 - - 仅作为定位器提示处理。 - -5. 跨网络审批 - - 广播到所有操作者客户端;活跃的 UI 显示模态框。 - - 先响应者获胜;Gateway 网关强制原子性。 - ---- - -# 总结(TL;DR) - -- 当前:WS 控制平面 + Bridge 节点传输。 -- 痛点:审批 + 重复 + 两个栈。 -- 提议:一个带有显式角色 + 作用域的 WS 协议,统一配对 + TLS 固定,Gateway 网关托管的审批,稳定设备 ID + 可爱别名。 -- 结果:更简单的 UX,更强的安全性,更少的重复,更好的移动路由。 diff --git a/docs/zh-CN/refactor/exec-host.md b/docs/zh-CN/refactor/exec-host.md deleted file mode 100644 index 3b81f41893f..00000000000 --- a/docs/zh-CN/refactor/exec-host.md +++ /dev/null @@ -1,323 +0,0 @@ ---- -read_when: - - 设计 exec 主机路由或 exec 批准 - - 实现节点运行器 + UI IPC - - 添加 exec 主机安全模式和斜杠命令 -summary: 重构计划:exec 主机路由、节点批准和无头运行器 -title: Exec 主机重构 -x-i18n: - generated_at: "2026-02-03T07:54:43Z" - model: claude-opus-4-5 - provider: pi - source_hash: 53a9059cbeb1f3f1dbb48c2b5345f88ca92372654fef26f8481e651609e45e3a - source_path: refactor/exec-host.md - workflow: 15 ---- - -# Exec 主机重构计划 - -## 目标 - -- 添加 `exec.host` + `exec.security` 以在**沙箱**、**Gateway 网关**和**节点**之间路由执行。 -- 保持默认**安全**:除非明确启用,否则不进行跨主机执行。 -- 将执行拆分为**无头运行器服务**,通过本地 IPC 连接可选的 UI(macOS 应用)。 -- 提供**每智能体**策略、允许列表、询问模式和节点绑定。 -- 支持*与*或*不与*允许列表一起使用的**询问模式**。 -- 跨平台:Unix socket + token 认证(macOS/Linux/Windows 一致性)。 - -## 非目标 - -- 无遗留允许列表迁移或遗留 schema 支持。 -- 节点 exec 无 PTY/流式传输(仅聚合输出)。 -- 除现有 Bridge + Gateway 网关外无新网络层。 - -## 决定(已锁定) - -- **配置键:** `exec.host` + `exec.security`(允许每智能体覆盖)。 -- **提升:** 保留 `/elevated` 作为 Gateway 网关完全访问的别名。 -- **询问默认:** `on-miss`。 -- **批准存储:** `~/.openclaw/exec-approvals.json`(JSON,无遗留迁移)。 -- **运行器:** 无头系统服务;UI 应用托管 Unix socket 用于批准。 -- **节点身份:** 使用现有 `nodeId`。 -- **Socket 认证:** Unix socket + token(跨平台);如需要稍后拆分。 -- **节点主机状态:** `~/.openclaw/node.json`(节点 id + 配对 token)。 -- **macOS exec 主机:** 在 macOS 应用内运行 `system.run`;节点主机服务通过本地 IPC 转发请求。 -- **无 XPC helper:** 坚持使用 Unix socket + token + 对等检查。 - -## 关键概念 - -### 主机 - -- `sandbox`:Docker exec(当前行为)。 -- `gateway`:在 Gateway 网关主机上执行。 -- `node`:通过 Bridge 在节点运行器上执行(`system.run`)。 - -### 安全模式 - -- `deny`:始终阻止。 -- `allowlist`:仅允许匹配项。 -- `full`:允许一切(等同于提升模式)。 - -### 询问模式 - -- `off`:从不询问。 -- `on-miss`:仅在允许列表不匹配时询问。 -- `always`:每次都询问。 - -询问**独立于**允许列表;允许列表可与 `always` 或 `on-miss` 一起使用。 - -### 策略解析(每次执行) - -1. 解析 `exec.host`(工具参数 → 智能体覆盖 → 全局默认)。 -2. 解析 `exec.security` 和 `exec.ask`(相同优先级)。 -3. 如果主机是 `sandbox`,继续本地沙箱执行。 -4. 如果主机是 `gateway` 或 `node`,在该主机上应用安全 + 询问策略。 - -## 默认安全 - -- 默认 `exec.host = sandbox`。 -- `gateway` 和 `node` 默认 `exec.security = deny`。 -- 默认 `exec.ask = on-miss`(仅在安全允许时相关)。 -- 如果未设置节点绑定,**智能体可以定向任何节点**,但仅在策略允许时。 - -## 配置表面 - -### 工具参数 - -- `exec.host`(可选):`sandbox | gateway | node`。 -- `exec.security`(可选):`deny | allowlist | full`。 -- `exec.ask`(可选):`off | on-miss | always`。 -- `exec.node`(可选):当 `host=node` 时使用的节点 id/名称。 - -### 配置键(全局) - -- `tools.exec.host` -- `tools.exec.security` -- `tools.exec.ask` -- `tools.exec.node`(默认节点绑定) - -### 配置键(每智能体) - -- `agents.list[].tools.exec.host` -- `agents.list[].tools.exec.security` -- `agents.list[].tools.exec.ask` -- `agents.list[].tools.exec.node` - -### 别名 - -- `/elevated on` = 为智能体会话设置 `tools.exec.host=gateway`、`tools.exec.security=full`。 -- `/elevated off` = 为智能体会话恢复之前的 exec 设置。 - -## 批准存储(JSON) - -路径:`~/.openclaw/exec-approvals.json` - -用途: - -- **执行主机**(Gateway 网关或节点运行器)的本地策略 + 允许列表。 -- 无 UI 可用时的询问回退。 -- UI 客户端的 IPC 凭证。 - -建议的 schema(v1): - -```json -{ - "version": 1, - "socket": { - "path": "~/.openclaw/exec-approvals.sock", - "token": "base64-opaque-token" - }, - "defaults": { - "security": "deny", - "ask": "on-miss", - "askFallback": "deny" - }, - "agents": { - "agent-id-1": { - "security": "allowlist", - "ask": "on-miss", - "allowlist": [ - { - "pattern": "~/Projects/**/bin/rg", - "lastUsedAt": 0, - "lastUsedCommand": "rg -n TODO", - "lastResolvedPath": "/Users/user/Projects/.../bin/rg" - } - ] - } - } -} -``` - -注意事项: - -- 无遗留允许列表格式。 -- `askFallback` 仅在需要 `ask` 且无法访问 UI 时应用。 -- 文件权限:`0600`。 - -## 运行器服务(无头) - -### 角色 - -- 在本地强制执行 `exec.security` + `exec.ask`。 -- 执行系统命令并返回输出。 -- 为 exec 生命周期发出 Bridge 事件(可选但推荐)。 - -### 服务生命周期 - -- macOS 上的 Launchd/daemon;Linux/Windows 上的系统服务。 -- 批准 JSON 是执行主机本地的。 -- UI 托管本地 Unix socket;运行器按需连接。 - -## UI 集成(macOS 应用) - -### IPC - -- Unix socket 位于 `~/.openclaw/exec-approvals.sock`(0600)。 -- Token 存储在 `exec-approvals.json`(0600)中。 -- 对等检查:仅同 UID。 -- 挑战/响应:nonce + HMAC(token, request-hash) 防止重放。 -- 短 TTL(例如 10s)+ 最大负载 + 速率限制。 - -### 询问流程(macOS 应用 exec 主机) - -1. 节点服务从 Gateway 网关接收 `system.run`。 -2. 节点服务连接到本地 socket 并发送提示/exec 请求。 -3. 应用验证对等 + token + HMAC + TTL,然后在需要时显示对话框。 -4. 应用在 UI 上下文中执行命令并返回输出。 -5. 节点服务将输出返回给 Gateway 网关。 - -如果 UI 缺失: - -- 应用 `askFallback`(`deny|allowlist|full`)。 - -### 图示(SCI) - -``` -Agent -> Gateway -> Bridge -> Node Service (TS) - | IPC (UDS + token + HMAC + TTL) - v - Mac App (UI + TCC + system.run) -``` - -## 节点身份 + 绑定 - -- 使用 Bridge 配对中的现有 `nodeId`。 -- 绑定模型: - - `tools.exec.node` 将智能体限制为特定节点。 - - 如果未设置,智能体可以选择任何节点(策略仍强制执行默认值)。 -- 节点选择解析: - - `nodeId` 精确匹配 - - `displayName`(规范化) - - `remoteIp` - - `nodeId` 前缀(>= 6 字符) - -## 事件 - -### 谁看到事件 - -- 系统事件是**每会话**的,在下一个提示时显示给智能体。 -- 存储在 Gateway 网关内存队列中(`enqueueSystemEvent`)。 - -### 事件文本 - -- `Exec started (node=, id=)` -- `Exec finished (node=, id=, code=)` + 可选输出尾部 -- `Exec denied (node=, id=, )` - -### 传输 - -选项 A(推荐): - -- 运行器发送 Bridge `event` 帧 `exec.started` / `exec.finished`。 -- Gateway 网关 `handleBridgeEvent` 将这些映射到 `enqueueSystemEvent`。 - -选项 B: - -- Gateway 网关 `exec` 工具直接处理生命周期(仅同步)。 - -## Exec 流程 - -### 沙箱主机 - -- 现有 `exec` 行为(Docker 或无沙箱时的主机)。 -- 仅在非沙箱模式下支持 PTY。 - -### Gateway 网关主机 - -- Gateway 网关进程在其自己的机器上执行。 -- 强制执行本地 `exec-approvals.json`(安全/询问/允许列表)。 - -### 节点主机 - -- Gateway 网关调用 `node.invoke` 配合 `system.run`。 -- 运行器强制执行本地批准。 -- 运行器返回聚合的 stdout/stderr。 -- 可选的 Bridge 事件用于开始/完成/拒绝。 - -## 输出上限 - -- 组合 stdout+stderr 上限为 **200k**;为事件保留**尾部 20k**。 -- 使用清晰的后缀截断(例如 `"… (truncated)"`)。 - -## 斜杠命令 - -- `/exec host= security= ask= node=` -- 每智能体、每会话覆盖;除非通过配置保存,否则非持久。 -- `/elevated on|off|ask|full` 仍然是 `host=gateway security=full` 的快捷方式(`full` 跳过批准)。 - -## 跨平台方案 - -- 运行器服务是可移植的执行目标。 -- UI 是可选的;如果缺失,应用 `askFallback`。 -- Windows/Linux 支持相同的批准 JSON + socket 协议。 - -## 实现阶段 - -### 阶段 1:配置 + exec 路由 - -- 为 `exec.host`、`exec.security`、`exec.ask`、`exec.node` 添加配置 schema。 -- 更新工具管道以遵守 `exec.host`。 -- 添加 `/exec` 斜杠命令并保留 `/elevated` 别名。 - -### 阶段 2:批准存储 + Gateway 网关强制执行 - -- 实现 `exec-approvals.json` 读取器/写入器。 -- 为 `gateway` 主机强制执行允许列表 + 询问模式。 -- 添加输出上限。 - -### 阶段 3:节点运行器强制执行 - -- 更新节点运行器以强制执行允许列表 + 询问。 -- 添加 Unix socket 提示桥接到 macOS 应用 UI。 -- 连接 `askFallback`。 - -### 阶段 4:事件 - -- 为 exec 生命周期添加节点 → Gateway 网关 Bridge 事件。 -- 映射到 `enqueueSystemEvent` 用于智能体提示。 - -### 阶段 5:UI 完善 - -- Mac 应用:允许列表编辑器、每智能体切换器、询问策略 UI。 -- 节点绑定控制(可选)。 - -## 测试计划 - -- 单元测试:允许列表匹配(glob + 不区分大小写)。 -- 单元测试:策略解析优先级(工具参数 → 智能体覆盖 → 全局)。 -- 集成测试:节点运行器拒绝/允许/询问流程。 -- Bridge 事件测试:节点事件 → 系统事件路由。 - -## 开放风险 - -- UI 不可用:确保遵守 `askFallback`。 -- 长时间运行的命令:依赖超时 + 输出上限。 -- 多节点歧义:除非有节点绑定或显式节点参数,否则报错。 - -## 相关文档 - -- [Exec 工具](/tools/exec) -- [执行批准](/tools/exec-approvals) -- [节点](/nodes) -- [提升模式](/tools/elevated) diff --git a/docs/zh-CN/refactor/outbound-session-mirroring.md b/docs/zh-CN/refactor/outbound-session-mirroring.md deleted file mode 100644 index 3d733a00f64..00000000000 --- a/docs/zh-CN/refactor/outbound-session-mirroring.md +++ /dev/null @@ -1,92 +0,0 @@ ---- -description: Track outbound session mirroring refactor notes, decisions, tests, and open items. -title: 出站会话镜像重构(Issue -x-i18n: - generated_at: "2026-02-03T07:53:51Z" - model: claude-opus-4-5 - provider: pi - source_hash: b88a72f36f7b6d8a71fde9d014c0a87e9a8b8b0d449b67119cf3b6f414fa2b81 - source_path: refactor/outbound-session-mirroring.md - workflow: 15 ---- - -# 出站会话镜像重构(Issue #1520) - -## 状态 - -- 进行中。 -- 核心 + 插件渠道路由已更新以支持出站镜像。 -- Gateway 网关发送现在在省略 sessionKey 时派生目标会话。 - -## 背景 - -出站发送被镜像到*当前*智能体会话(工具会话键)而不是目标渠道会话。入站路由使用渠道/对等方会话键,因此出站响应落在错误的会话中,首次联系的目标通常缺少会话条目。 - -## 目标 - -- 将出站消息镜像到目标渠道会话键。 -- 在缺失时为出站创建会话条目。 -- 保持线程/话题作用域与入站会话键对齐。 -- 涵盖核心渠道加内置扩展。 - -## 实现摘要 - -- 新的出站会话路由辅助器: - - `src/infra/outbound/outbound-session.ts` - - `resolveOutboundSessionRoute` 使用 `buildAgentSessionKey`(dmScope + identityLinks)构建目标 sessionKey。 - - `ensureOutboundSessionEntry` 通过 `recordSessionMetaFromInbound` 写入最小的 `MsgContext`。 -- `runMessageAction`(发送)派生目标 sessionKey 并将其传递给 `executeSendAction` 进行镜像。 -- `message-tool` 不再直接镜像;它只从当前会话键解析 agentId。 -- 插件发送路径使用派生的 sessionKey 通过 `appendAssistantMessageToSessionTranscript` 进行镜像。 -- Gateway 网关发送在未提供时派生目标会话键(默认智能体),并确保会话条目。 - -## 线程/话题处理 - -- Slack:replyTo/threadId -> `resolveThreadSessionKeys`(后缀)。 -- Discord:threadId/replyTo -> `resolveThreadSessionKeys`,`useSuffix=false` 以匹配入站(线程频道 id 已经作用域会话)。 -- Telegram:话题 ID 通过 `buildTelegramGroupPeerId` 映射到 `chatId:topic:`。 - -## 涵盖的扩展 - -- Matrix、MS Teams、Mattermost、BlueBubbles、Nextcloud Talk、Zalo、Zalo Personal、Nostr、Tlon。 -- 注意: - - Mattermost 目标现在为私信会话键路由去除 `@`。 - - Zalo Personal 对 1:1 目标使用私信对等方类型(仅当存在 `group:` 时才使用群组)。 - - BlueBubbles 群组目标去除 `chat_*` 前缀以匹配入站会话键。 - - Slack 自动线程镜像不区分大小写地匹配频道 id。 - - Gateway 网关发送在镜像前将提供的会话键转换为小写。 - -## 决策 - -- **Gateway 网关发送会话派生**:如果提供了 `sessionKey`,则使用它。如果省略,从目标 + 默认智能体派生 sessionKey 并镜像到那里。 -- **会话条目创建**:始终使用 `recordSessionMetaFromInbound`,`Provider/From/To/ChatType/AccountId/Originating*` 与入站格式对齐。 -- **目标规范化**:出站路由在可用时使用解析后的目标(`resolveChannelTarget` 之后)。 -- **会话键大小写**:在写入和迁移期间将会话键规范化为小写。 - -## 添加/更新的测试 - -- `src/infra/outbound/outbound-session.test.ts` - - Slack 线程会话键。 - - Telegram 话题会话键。 - - dmScope identityLinks 与 Discord。 -- `src/agents/tools/message-tool.test.ts` - - 从会话键派生 agentId(不传递 sessionKey)。 -- `src/gateway/server-methods/send.test.ts` - - 在省略时派生会话键并创建会话条目。 - -## 待处理项目 / 后续跟进 - -- 语音通话插件使用自定义的 `voice:` 会话键。出站映射在这里没有标准化;如果 message-tool 应该支持语音通话发送,请添加显式映射。 -- 确认是否有任何外部插件使用内置集之外的非标准 `From/To` 格式。 - -## 涉及的文件 - -- `src/infra/outbound/outbound-session.ts` -- `src/infra/outbound/outbound-send-service.ts` -- `src/infra/outbound/message-action-runner.ts` -- `src/agents/tools/message-tool.ts` -- `src/gateway/server-methods/send.ts` -- 测试: - - `src/infra/outbound/outbound-session.test.ts` - - `src/agents/tools/message-tool.test.ts` - - `src/gateway/server-methods/send.test.ts` diff --git a/docs/zh-CN/refactor/plugin-sdk.md b/docs/zh-CN/refactor/plugin-sdk.md deleted file mode 100644 index fc2e7420593..00000000000 --- a/docs/zh-CN/refactor/plugin-sdk.md +++ /dev/null @@ -1,221 +0,0 @@ ---- -read_when: - - 定义或重构插件架构 - - 将渠道连接器迁移到插件 SDK/运行时 -summary: 计划:为所有消息连接器提供一套统一的插件 SDK + 运行时 -title: 插件 SDK 重构 -x-i18n: - generated_at: "2026-02-01T21:36:45Z" - model: claude-opus-4-5 - provider: pi - source_hash: d1964e2e47a19ee1d42ddaaa9cf1293c80bb0be463b049dc8468962f35bb6cb0 - source_path: refactor/plugin-sdk.md - workflow: 15 ---- - -# 插件 SDK + 运行时重构计划 - -目标:每个消息连接器都是一个插件(内置或外部),使用统一稳定的 API。 -插件不直接从 `src/**` 导入任何内容。所有依赖项均通过 SDK 或运行时获取。 - -## 为什么现在做 - -- 当前连接器混用多种模式:直接导入核心模块、仅 dist 的桥接方式以及自定义辅助函数。 -- 这使得升级变得脆弱,并阻碍了干净的外部插件接口。 - -## 目标架构(两层) - -### 1)插件 SDK(编译时,稳定,可发布) - -范围:类型、辅助函数和配置工具。无运行时状态,无副作用。 - -内容(示例): - -- 类型:`ChannelPlugin`、适配器、`ChannelMeta`、`ChannelCapabilities`、`ChannelDirectoryEntry`。 -- 配置辅助函数:`buildChannelConfigSchema`、`setAccountEnabledInConfigSection`、`deleteAccountFromConfigSection`、 - `applyAccountNameToChannelSection`。 -- 配对辅助函数:`PAIRING_APPROVED_MESSAGE`、`formatPairingApproveHint`。 -- 新手引导辅助函数:`promptChannelAccessConfig`、`addWildcardAllowFrom`、新手引导类型。 -- 工具参数辅助函数:`createActionGate`、`readStringParam`、`readNumberParam`、`readReactionParams`、`jsonResult`。 -- 文档链接辅助函数:`formatDocsLink`。 - -交付方式: - -- 以 `openclaw/plugin-sdk` 发布(或从核心以 `openclaw/plugin-sdk` 导出)。 -- 使用语义化版本控制,提供明确的稳定性保证。 - -### 2)插件运行时(执行层,注入式) - -范围:所有涉及核心运行时行为的内容。 -通过 `OpenClawPluginApi.runtime` 访问,确保插件永远不会导入 `src/**`。 - -建议的接口(最小但完整): - -```ts -export type PluginRuntime = { - channel: { - text: { - chunkMarkdownText(text: string, limit: number): string[]; - resolveTextChunkLimit(cfg: OpenClawConfig, channel: string, accountId?: string): number; - hasControlCommand(text: string, cfg: OpenClawConfig): boolean; - }; - reply: { - dispatchReplyWithBufferedBlockDispatcher(params: { - ctx: unknown; - cfg: unknown; - dispatcherOptions: { - deliver: (payload: { - text?: string; - mediaUrls?: string[]; - mediaUrl?: string; - }) => void | Promise; - onError?: (err: unknown, info: { kind: string }) => void; - }; - }): Promise; - createReplyDispatcherWithTyping?: unknown; // adapter for Teams-style flows - }; - routing: { - resolveAgentRoute(params: { - cfg: unknown; - channel: string; - accountId: string; - peer: { kind: RoutePeerKind; id: string }; - }): { sessionKey: string; accountId: string }; - }; - pairing: { - buildPairingReply(params: { channel: string; idLine: string; code: string }): string; - readAllowFromStore(channel: string): Promise; - upsertPairingRequest(params: { - channel: string; - id: string; - meta?: { name?: string }; - }): Promise<{ code: string; created: boolean }>; - }; - media: { - fetchRemoteMedia(params: { url: string }): Promise<{ buffer: Buffer; contentType?: string }>; - saveMediaBuffer( - buffer: Uint8Array, - contentType: string | undefined, - direction: "inbound" | "outbound", - maxBytes: number, - ): Promise<{ path: string; contentType?: string }>; - }; - mentions: { - buildMentionRegexes(cfg: OpenClawConfig, agentId?: string): RegExp[]; - matchesMentionPatterns(text: string, regexes: RegExp[]): boolean; - }; - groups: { - resolveGroupPolicy( - cfg: OpenClawConfig, - channel: string, - accountId: string, - groupId: string, - ): { - allowlistEnabled: boolean; - allowed: boolean; - groupConfig?: unknown; - defaultConfig?: unknown; - }; - resolveRequireMention( - cfg: OpenClawConfig, - channel: string, - accountId: string, - groupId: string, - override?: boolean, - ): boolean; - }; - debounce: { - createInboundDebouncer(opts: { - debounceMs: number; - buildKey: (v: T) => string | null; - shouldDebounce: (v: T) => boolean; - onFlush: (entries: T[]) => Promise; - onError?: (err: unknown) => void; - }): { push: (v: T) => void; flush: () => Promise }; - resolveInboundDebounceMs(cfg: OpenClawConfig, channel: string): number; - }; - commands: { - resolveCommandAuthorizedFromAuthorizers(params: { - useAccessGroups: boolean; - authorizers: Array<{ configured: boolean; allowed: boolean }>; - }): boolean; - }; - }; - logging: { - shouldLogVerbose(): boolean; - getChildLogger(name: string): PluginLogger; - }; - state: { - resolveStateDir(cfg: OpenClawConfig): string; - }; -}; -``` - -备注: - -- 运行时是访问核心行为的唯一方式。 -- SDK 故意保持小巧和稳定。 -- 每个运行时方法都映射到现有的核心实现(无重复代码)。 - -## 迁移计划(分阶段,安全) - -### 阶段 0:基础搭建 - -- 引入 `openclaw/plugin-sdk`。 -- 在 `OpenClawPluginApi` 中添加带有上述接口的 `api.runtime`。 -- 在过渡期内保留现有导入方式(添加弃用警告)。 - -### 阶段 1:桥接清理(低风险) - -- 用 `api.runtime` 替换每个扩展中的 `core-bridge.ts`。 -- 优先迁移 BlueBubbles、Zalo、Zalo Personal(已经接近完成)。 -- 移除重复的桥接代码。 - -### 阶段 2:轻度直接导入的插件 - -- 将 Matrix 迁移到 SDK + 运行时。 -- 验证新手引导、目录、群组提及逻辑。 - -### 阶段 3:重度直接导入的插件 - -- 迁移 Microsoft Teams(使用运行时辅助函数最多的插件)。 -- 确保回复/正在输入的语义与当前行为一致。 - -### 阶段 4:iMessage 插件化 - -- 将 iMessage 移入 `extensions/imessage`。 -- 用 `api.runtime` 替换直接的核心调用。 -- 保持配置键、CLI 行为和文档不变。 - -### 阶段 5:强制执行 - -- 添加 lint 规则 / CI 检查:禁止 `extensions/**` 从 `src/**` 导入。 -- 添加插件 SDK/版本兼容性检查(运行时 + SDK 语义化版本)。 - -## 兼容性与版本控制 - -- SDK:语义化版本控制,已发布,变更有文档记录。 -- 运行时:按核心版本进行版本控制。添加 `api.runtime.version`。 -- 插件声明所需的运行时版本范围(例如 `openclawRuntime: ">=2026.2.0"`)。 - -## 测试策略 - -- 适配器级单元测试(使用真实核心实现验证运行时函数)。 -- 每个插件的黄金测试:确保行为无偏差(路由、配对、允许列表、提及过滤)。 -- CI 中使用单个端到端插件示例(安装 + 运行 + 冒烟测试)。 - -## 待解决问题 - -- SDK 类型托管在哪里:独立包还是核心导出? -- 运行时类型分发:在 SDK 中(仅类型)还是在核心中? -- 如何为内置插件与外部插件暴露文档链接? -- 过渡期间是否允许仓库内插件有限地直接导入核心模块? - -## 成功标准 - -- 所有渠道连接器都是使用 SDK + 运行时的插件。 -- `extensions/**` 不再从 `src/**` 导入。 -- 新连接器模板仅依赖 SDK + 运行时。 -- 外部插件可以在无需访问核心源码的情况下进行开发和更新。 - -相关文档:[插件](/tools/plugin)、[渠道](/channels/index)、[配置](/gateway/configuration)。 diff --git a/docs/zh-CN/refactor/strict-config.md b/docs/zh-CN/refactor/strict-config.md deleted file mode 100644 index 91b9a50714d..00000000000 --- a/docs/zh-CN/refactor/strict-config.md +++ /dev/null @@ -1,100 +0,0 @@ ---- -read_when: - - 设计或实现配置验证行为 - - 处理配置迁移或 doctor 工作流 - - 处理插件配置 schema 或插件加载门控 -summary: 严格配置验证 + 仅通过 doctor 进行迁移 -title: 严格配置验证 -x-i18n: - generated_at: "2026-02-03T10:08:51Z" - model: claude-opus-4-5 - provider: pi - source_hash: 5bc7174a67d2234e763f21330d8fe3afebc23b2e5c728a04abcc648b453a91cc - source_path: refactor/strict-config.md - workflow: 15 ---- - -# 严格配置验证(仅通过 doctor 进行迁移) - -## 目标 - -- **在所有地方拒绝未知配置键**(根级 + 嵌套)。 -- **拒绝没有 schema 的插件配置**;不加载该插件。 -- **移除加载时的旧版自动迁移**;迁移仅通过 doctor 运行。 -- **启动时自动运行 doctor(dry-run)**;如果无效,阻止非诊断命令。 - -## 非目标 - -- 加载时的向后兼容性(旧版键不会自动迁移)。 -- 静默丢弃无法识别的键。 - -## 严格验证规则 - -- 配置必须在每个层级精确匹配 schema。 -- 未知键是验证错误(根级或嵌套都不允许透传)。 -- `plugins.entries..config` 必须由插件的 schema 验证。 - - 如果插件缺少 schema,**拒绝插件加载**并显示清晰的错误。 -- 未知的 `channels.` 键是错误,除非插件清单声明了该渠道 id。 -- 所有插件都需要插件清单(`openclaw.plugin.json`)。 - -## 插件 schema 强制执行 - -- 每个插件为其配置提供严格的 JSON Schema(内联在清单中)。 -- 插件加载流程: - 1. 解析插件清单 + schema(`openclaw.plugin.json`)。 - 2. 根据 schema 验证配置。 - 3. 如果缺少 schema 或配置无效:阻止插件加载,记录错误。 -- 错误消息包括: - - 插件 id - - 原因(缺少 schema / 配置无效) - - 验证失败的路径 -- 禁用的插件保留其配置,但 Doctor + 日志会显示警告。 - -## Doctor 流程 - -- 每次加载配置时都会运行 Doctor(默认 dry-run)。 -- 如果配置无效: - - 打印摘要 + 可操作的错误。 - - 指示:`openclaw doctor --fix`。 -- `openclaw doctor --fix`: - - 应用迁移。 - - 移除未知键。 - - 写入更新后的配置。 - -## 命令门控(当配置无效时) - -允许的命令(仅诊断): - -- `openclaw doctor` -- `openclaw logs` -- `openclaw health` -- `openclaw help` -- `openclaw status` -- `openclaw gateway status` - -其他所有命令必须硬失败并显示:"Config invalid. Run `openclaw doctor --fix`." - -## 错误用户体验格式 - -- 单个摘要标题。 -- 分组部分: - - 未知键(完整路径) - - 旧版键/需要迁移 - - 插件加载失败(插件 id + 原因 + 路径) - -## 实现接触点 - -- `src/config/zod-schema.ts`:移除根级透传;所有地方使用严格对象。 -- `src/config/zod-schema.providers.ts`:确保严格的渠道 schema。 -- `src/config/validation.ts`:未知键时失败;不应用旧版迁移。 -- `src/config/io.ts`:移除旧版自动迁移;始终运行 doctor dry-run。 -- `src/config/legacy*.ts`:将用法移至仅 doctor。 -- `src/plugins/*`:添加 schema 注册表 + 门控。 -- `src/cli` 中的 CLI 命令门控。 - -## 测试 - -- 未知键拒绝(根级 + 嵌套)。 -- 插件缺少 schema → 插件加载被阻止并显示清晰错误。 -- 无效配置 → Gateway 网关启动被阻止,诊断命令除外。 -- Doctor dry-run 自动运行;`doctor --fix` 写入修正后的配置。 From 0ae3e70a5c6a687d41e1ff056ab86691086929fb Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Wed, 18 Mar 2026 00:49:54 -0700 Subject: [PATCH 19/55] Plugin SDK: fix contract seam regressions --- extensions/irc/src/accounts.ts | 8 ++--- extensions/nostr/src/config-schema.ts | 8 +++-- extensions/tlon/src/monitor/media.ts | 2 +- extensions/tlon/src/urbit/fetch.ts | 7 ++-- extensions/tlon/src/urbit/upload.test.ts | 8 ++--- extensions/tlon/src/urbit/upload.ts | 2 +- package.json | 44 ++++++++++++++++++++++++ scripts/lib/plugin-sdk-entrypoints.json | 11 ++++++ src/plugin-sdk/channel-config-schema.ts | 3 +- src/plugin-sdk/core.ts | 5 ++- 10 files changed, 81 insertions(+), 17 deletions(-) diff --git a/extensions/irc/src/accounts.ts b/extensions/irc/src/accounts.ts index 66df8f9d26c..e54256dd7c2 100644 --- a/extensions/irc/src/accounts.ts +++ b/extensions/irc/src/accounts.ts @@ -1,10 +1,8 @@ import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id"; +import { createAccountListHelpers } from "openclaw/plugin-sdk/account-helpers"; +import { normalizeResolvedSecretInputString } from "openclaw/plugin-sdk/config-runtime"; +import { parseOptionalDelimitedEntries } from "openclaw/plugin-sdk/core"; import { tryReadSecretFileSync } from "openclaw/plugin-sdk/infra-runtime"; -import { - createAccountListHelpers, - normalizeResolvedSecretInputString, - parseOptionalDelimitedEntries, -} from "./runtime-api.js"; import type { CoreConfig, IrcAccountConfig, IrcNickServConfig } from "./types.js"; const TRUTHY_ENV = new Set(["true", "1", "yes", "on"]); diff --git a/extensions/nostr/src/config-schema.ts b/extensions/nostr/src/config-schema.ts index 0a741d3ac6b..1a900d8edac 100644 --- a/extensions/nostr/src/config-schema.ts +++ b/extensions/nostr/src/config-schema.ts @@ -1,6 +1,10 @@ -import { AllowFromListSchema, DmPolicySchema } from "openclaw/plugin-sdk/channel-config-schema"; +import { + AllowFromListSchema, + buildChannelConfigSchema, + DmPolicySchema, + MarkdownConfigSchema, +} from "openclaw/plugin-sdk/channel-config-schema"; import { z } from "zod"; -import { MarkdownConfigSchema, buildChannelConfigSchema } from "../runtime-api.js"; /** * Validates https:// URLs only (no javascript:, data:, file:, etc.) diff --git a/extensions/tlon/src/monitor/media.ts b/extensions/tlon/src/monitor/media.ts index 8a17e982fad..de64a427ed2 100644 --- a/extensions/tlon/src/monitor/media.ts +++ b/extensions/tlon/src/monitor/media.ts @@ -5,7 +5,7 @@ import { homedir } from "node:os"; import * as path from "node:path"; import { Readable } from "node:stream"; import { pipeline } from "node:stream/promises"; -import { fetchWithSsrFGuard } from "../../api.js"; +import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/infra-runtime"; import { getDefaultSsrFPolicy } from "../urbit/context.js"; // Default to OpenClaw workspace media directory diff --git a/extensions/tlon/src/urbit/fetch.ts b/extensions/tlon/src/urbit/fetch.ts index 638c70f0840..524bd80d47a 100644 --- a/extensions/tlon/src/urbit/fetch.ts +++ b/extensions/tlon/src/urbit/fetch.ts @@ -1,5 +1,8 @@ -import type { LookupFn, SsrFPolicy } from "../../api.js"; -import { fetchWithSsrFGuard } from "../../api.js"; +import { + fetchWithSsrFGuard, + type LookupFn, + type SsrFPolicy, +} from "openclaw/plugin-sdk/infra-runtime"; import { validateUrbitBaseUrl } from "./base-url.js"; import { UrbitUrlError } from "./errors.js"; diff --git a/extensions/tlon/src/urbit/upload.test.ts b/extensions/tlon/src/urbit/upload.test.ts index 34dd6186d20..bb8f505e7c1 100644 --- a/extensions/tlon/src/urbit/upload.test.ts +++ b/extensions/tlon/src/urbit/upload.test.ts @@ -1,8 +1,8 @@ import { describe, expect, it, vi, afterEach, beforeEach } from "vitest"; -// Mock fetchWithSsrFGuard from plugin-sdk -vi.mock("openclaw/plugin-sdk/tlon", async (importOriginal) => { - const actual = await importOriginal(); +// Mock fetchWithSsrFGuard from the focused infra seam. +vi.mock("openclaw/plugin-sdk/infra-runtime", async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, fetchWithSsrFGuard: vi.fn(), @@ -16,7 +16,7 @@ vi.mock("@tloncorp/api", () => ({ describe("uploadImageFromUrl", () => { async function loadUploadMocks() { - const { fetchWithSsrFGuard } = await import("openclaw/plugin-sdk/tlon"); + const { fetchWithSsrFGuard } = await import("openclaw/plugin-sdk/infra-runtime"); const { uploadFile } = await import("@tloncorp/api"); const { uploadImageFromUrl } = await import("./upload.js"); return { diff --git a/extensions/tlon/src/urbit/upload.ts b/extensions/tlon/src/urbit/upload.ts index 6176c132207..f0afe35c29e 100644 --- a/extensions/tlon/src/urbit/upload.ts +++ b/extensions/tlon/src/urbit/upload.ts @@ -2,7 +2,7 @@ * Upload an image from a URL to Tlon storage. */ import { uploadFile } from "@tloncorp/api"; -import { fetchWithSsrFGuard } from "../../api.js"; +import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/infra-runtime"; import { getDefaultSsrFPolicy } from "./context.js"; /** diff --git a/package.json b/package.json index b9c04e44692..09a8c047869 100644 --- a/package.json +++ b/package.json @@ -230,6 +230,22 @@ "types": "./dist/plugin-sdk/bluebubbles.d.ts", "default": "./dist/plugin-sdk/bluebubbles.js" }, + "./plugin-sdk/copilot-proxy": { + "types": "./dist/plugin-sdk/copilot-proxy.d.ts", + "default": "./dist/plugin-sdk/copilot-proxy.js" + }, + "./plugin-sdk/device-pair": { + "types": "./dist/plugin-sdk/device-pair.d.ts", + "default": "./dist/plugin-sdk/device-pair.js" + }, + "./plugin-sdk/diagnostics-otel": { + "types": "./dist/plugin-sdk/diagnostics-otel.d.ts", + "default": "./dist/plugin-sdk/diagnostics-otel.js" + }, + "./plugin-sdk/diffs": { + "types": "./dist/plugin-sdk/diffs.d.ts", + "default": "./dist/plugin-sdk/diffs.js" + }, "./plugin-sdk/feishu": { "types": "./dist/plugin-sdk/feishu.d.ts", "default": "./dist/plugin-sdk/feishu.js" @@ -242,6 +258,10 @@ "types": "./dist/plugin-sdk/irc.d.ts", "default": "./dist/plugin-sdk/irc.js" }, + "./plugin-sdk/llm-task": { + "types": "./dist/plugin-sdk/llm-task.d.ts", + "default": "./dist/plugin-sdk/llm-task.js" + }, "./plugin-sdk/lobster": { "types": "./dist/plugin-sdk/lobster.d.ts", "default": "./dist/plugin-sdk/lobster.js" @@ -262,6 +282,10 @@ "types": "./dist/plugin-sdk/memory-core.d.ts", "default": "./dist/plugin-sdk/memory-core.js" }, + "./plugin-sdk/memory-lancedb": { + "types": "./dist/plugin-sdk/memory-lancedb.d.ts", + "default": "./dist/plugin-sdk/memory-lancedb.js" + }, "./plugin-sdk/minimax-portal-auth": { "types": "./dist/plugin-sdk/minimax-portal-auth.d.ts", "default": "./dist/plugin-sdk/minimax-portal-auth.js" @@ -274,6 +298,18 @@ "types": "./dist/plugin-sdk/nostr.d.ts", "default": "./dist/plugin-sdk/nostr.js" }, + "./plugin-sdk/open-prose": { + "types": "./dist/plugin-sdk/open-prose.d.ts", + "default": "./dist/plugin-sdk/open-prose.js" + }, + "./plugin-sdk/phone-control": { + "types": "./dist/plugin-sdk/phone-control.d.ts", + "default": "./dist/plugin-sdk/phone-control.js" + }, + "./plugin-sdk/qwen-portal-auth": { + "types": "./dist/plugin-sdk/qwen-portal-auth.d.ts", + "default": "./dist/plugin-sdk/qwen-portal-auth.js" + }, "./plugin-sdk/synology-chat": { "types": "./dist/plugin-sdk/synology-chat.d.ts", "default": "./dist/plugin-sdk/synology-chat.js" @@ -286,6 +322,14 @@ "types": "./dist/plugin-sdk/test-utils.d.ts", "default": "./dist/plugin-sdk/test-utils.js" }, + "./plugin-sdk/talk-voice": { + "types": "./dist/plugin-sdk/talk-voice.d.ts", + "default": "./dist/plugin-sdk/talk-voice.js" + }, + "./plugin-sdk/thread-ownership": { + "types": "./dist/plugin-sdk/thread-ownership.d.ts", + "default": "./dist/plugin-sdk/thread-ownership.js" + }, "./plugin-sdk/tlon": { "types": "./dist/plugin-sdk/tlon.d.ts", "default": "./dist/plugin-sdk/tlon.js" diff --git a/scripts/lib/plugin-sdk-entrypoints.json b/scripts/lib/plugin-sdk-entrypoints.json index 41a6875af2c..288fefb7fd0 100644 --- a/scripts/lib/plugin-sdk-entrypoints.json +++ b/scripts/lib/plugin-sdk-entrypoints.json @@ -47,20 +47,31 @@ "msteams", "acpx", "bluebubbles", + "copilot-proxy", + "device-pair", + "diagnostics-otel", + "diffs", "feishu", "googlechat", "irc", + "llm-task", "lobster", "lazy-runtime", "matrix", "mattermost", "memory-core", + "memory-lancedb", "minimax-portal-auth", "nextcloud-talk", "nostr", + "open-prose", + "phone-control", + "qwen-portal-auth", "synology-chat", "testing", "test-utils", + "talk-voice", + "thread-ownership", "tlon", "twitch", "voice-call", diff --git a/src/plugin-sdk/channel-config-schema.ts b/src/plugin-sdk/channel-config-schema.ts index bbf6191ae75..994905f9f20 100644 --- a/src/plugin-sdk/channel-config-schema.ts +++ b/src/plugin-sdk/channel-config-schema.ts @@ -1,7 +1,8 @@ /** Shared config-schema primitives for channel plugins with DM/group policy knobs. */ export { AllowFromListSchema, + buildChannelConfigSchema, buildCatchallMultiAccountChannelSchema, buildNestedDmConfigSchema, } from "../channels/plugins/config-schema.js"; -export { DmPolicySchema, GroupPolicySchema } from "../config/zod-schema.core.js"; +export { DmPolicySchema, GroupPolicySchema, MarkdownConfigSchema } from "../config/zod-schema.core.js"; diff --git a/src/plugin-sdk/core.ts b/src/plugin-sdk/core.ts index 252063d2631..c80e681350b 100644 --- a/src/plugin-sdk/core.ts +++ b/src/plugin-sdk/core.ts @@ -84,7 +84,10 @@ export { deleteAccountFromConfigSection, setAccountEnabledInConfigSection, } from "../channels/plugins/config-helpers.js"; -export { formatPairingApproveHint } from "../channels/plugins/helpers.js"; +export { + formatPairingApproveHint, + parseOptionalDelimitedEntries, +} from "../channels/plugins/helpers.js"; export { getChatChannelMeta } from "../channels/registry.js"; export { buildOauthProviderAuthResult } from "./provider-auth-result.js"; export { From da2289869d692cb5619668eb825e9d92fca2eecf Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Wed, 18 Mar 2026 00:55:47 -0700 Subject: [PATCH 20/55] docs: remove experiments/ and design/ directories Delete all experiment plans, proposals, research docs, and the kilo-gateway-integration design doc. These are internal planning docs that do not belong on the public docs site. - 12 English experiment files - 5 zh-CN experiment translations - 1 design doc (kilo-gateway-integration) - Remove nav groups from docs.json (English + zh-CN) - Remove 3 redirects pointing to deleted experiment pages - Remove dead experiment links from hubs.md Co-Authored-By: Claude Opus 4.6 --- docs/design/kilo-gateway-integration.md | 542 ------------ docs/docs.json | 38 - .../experiments/onboarding-config-protocol.md | 43 - ...ndings-discord-channels-telegram-topics.md | 375 -------- .../plans/acp-thread-bound-agents.md | 800 ------------------ .../plans/acp-unified-streaming-refactor.md | 96 --- .../plans/browser-evaluate-cdp-refactor.md | 232 ----- .../plans/discord-async-inbound-worker.md | 337 -------- .../plans/openresponses-gateway.md | 126 --- .../plans/pty-process-supervision.md | 195 ----- .../plans/session-binding-channel-agnostic.md | 226 ----- .../proposals/acp-bound-command-auth.md | 89 -- docs/experiments/proposals/model-config.md | 36 - docs/experiments/research/memory.md | 228 ----- docs/start/hubs.md | 6 - .../experiments/onboarding-config-protocol.md | 47 - .../experiments/plans/cron-add-hardening.md | 70 -- .../plans/group-policy-hardening.md | 45 - .../plans/openresponses-gateway.md | 121 --- .../experiments/proposals/model-config.md | 42 - docs/zh-CN/experiments/research/memory.md | 235 ----- 21 files changed, 3929 deletions(-) delete mode 100644 docs/design/kilo-gateway-integration.md delete mode 100644 docs/experiments/onboarding-config-protocol.md delete mode 100644 docs/experiments/plans/acp-persistent-bindings-discord-channels-telegram-topics.md delete mode 100644 docs/experiments/plans/acp-thread-bound-agents.md delete mode 100644 docs/experiments/plans/acp-unified-streaming-refactor.md delete mode 100644 docs/experiments/plans/browser-evaluate-cdp-refactor.md delete mode 100644 docs/experiments/plans/discord-async-inbound-worker.md delete mode 100644 docs/experiments/plans/openresponses-gateway.md delete mode 100644 docs/experiments/plans/pty-process-supervision.md delete mode 100644 docs/experiments/plans/session-binding-channel-agnostic.md delete mode 100644 docs/experiments/proposals/acp-bound-command-auth.md delete mode 100644 docs/experiments/proposals/model-config.md delete mode 100644 docs/experiments/research/memory.md delete mode 100644 docs/zh-CN/experiments/onboarding-config-protocol.md delete mode 100644 docs/zh-CN/experiments/plans/cron-add-hardening.md delete mode 100644 docs/zh-CN/experiments/plans/group-policy-hardening.md delete mode 100644 docs/zh-CN/experiments/plans/openresponses-gateway.md delete mode 100644 docs/zh-CN/experiments/proposals/model-config.md delete mode 100644 docs/zh-CN/experiments/research/memory.md diff --git a/docs/design/kilo-gateway-integration.md b/docs/design/kilo-gateway-integration.md deleted file mode 100644 index e498ea36e89..00000000000 --- a/docs/design/kilo-gateway-integration.md +++ /dev/null @@ -1,542 +0,0 @@ ---- -title: "Kilo Gateway Integration Design" -summary: "Design doc for integrating Kilo Gateway as a first-class OpenClaw provider" -read_when: - - Working on the Kilo Gateway provider integration - - Understanding provider integration patterns ---- - -# Kilo Gateway Provider Integration Design - -## Overview - -This document outlines the design for integrating "Kilo Gateway" as a first-class provider in OpenClaw, modeled after the existing OpenRouter implementation. Kilo Gateway uses an OpenAI-compatible completions API with a different base URL. - -## Design Decisions - -### 1. Provider Naming - -**Recommendation: `kilocode`** - -Rationale: - -- Matches the user config example provided (`kilocode` provider key) -- Consistent with existing provider naming patterns (e.g., `openrouter`, `opencode`, `moonshot`) -- Short and memorable -- Avoids confusion with generic "kilo" or "gateway" terms - -Alternative considered: `kilo-gateway` - rejected because hyphenated names are less common in the codebase and `kilocode` is more concise. - -### 2. Default Model Reference - -**Recommendation: `kilocode/anthropic/claude-opus-4.6`** - -Rationale: - -- Based on user config example -- Claude Opus 4.5 is a capable default model -- Explicit model selection avoids reliance on auto-routing - -### 3. Base URL Configuration - -**Recommendation: Hardcoded default with config override** - -- **Default Base URL:** `https://api.kilo.ai/api/gateway/` -- **Configurable:** Yes, via `models.providers.kilocode.baseUrl` - -This matches the pattern used by other providers like Moonshot, Venice, and Synthetic. - -### 4. Model Scanning - -**Recommendation: No dedicated model scanning endpoint initially** - -Rationale: - -- Kilo Gateway proxies to OpenRouter, so models are dynamic -- Users can manually configure models in their config -- If Kilo Gateway exposes a `/models` endpoint in the future, scanning can be added - -### 5. Special Handling - -**Recommendation: Inherit OpenRouter behavior for Anthropic models** - -Since Kilo Gateway proxies to OpenRouter, the same special handling should apply: - -- Cache TTL eligibility for `anthropic/*` models -- Extra params (cacheControlTtl) for `anthropic/*` models -- Transcript policy follows OpenRouter patterns - -## Files to Modify - -### Core Credential Management - -#### 1. `src/commands/onboard-auth.credentials.ts` - -Add: - -```typescript -export const KILOCODE_DEFAULT_MODEL_REF = "kilocode/anthropic/claude-opus-4.6"; - -export async function setKilocodeApiKey(key: string, agentDir?: string) { - upsertAuthProfile({ - profileId: "kilocode:default", - credential: { - type: "api_key", - provider: "kilocode", - key, - }, - agentDir: resolveAuthAgentDir(agentDir), - }); -} -``` - -#### 2. `src/agents/model-auth.ts` - -Add to `envMap` in `resolveEnvApiKey()`: - -```typescript -const envMap: Record = { - // ... existing entries - kilocode: "KILOCODE_API_KEY", -}; -``` - -#### 3. `src/config/io.ts` - -Add to `SHELL_ENV_EXPECTED_KEYS`: - -```typescript -const SHELL_ENV_EXPECTED_KEYS = [ - // ... existing entries - "KILOCODE_API_KEY", -]; -``` - -### Config Application - -#### 4. `src/commands/onboard-auth.config-core.ts` - -Add new functions: - -```typescript -export const KILOCODE_BASE_URL = "https://api.kilo.ai/api/gateway/"; - -export function applyKilocodeProviderConfig(cfg: OpenClawConfig): OpenClawConfig { - const models = { ...cfg.agents?.defaults?.models }; - models[KILOCODE_DEFAULT_MODEL_REF] = { - ...models[KILOCODE_DEFAULT_MODEL_REF], - alias: models[KILOCODE_DEFAULT_MODEL_REF]?.alias ?? "Kilo Gateway", - }; - - const providers = { ...cfg.models?.providers }; - const existingProvider = providers.kilocode; - const { apiKey: existingApiKey, ...existingProviderRest } = (existingProvider ?? {}) as Record< - string, - unknown - > as { apiKey?: string }; - const resolvedApiKey = typeof existingApiKey === "string" ? existingApiKey : undefined; - const normalizedApiKey = resolvedApiKey?.trim(); - - providers.kilocode = { - ...existingProviderRest, - baseUrl: KILOCODE_BASE_URL, - api: "openai-completions", - ...(normalizedApiKey ? { apiKey: normalizedApiKey } : {}), - }; - - return { - ...cfg, - agents: { - ...cfg.agents, - defaults: { - ...cfg.agents?.defaults, - models, - }, - }, - models: { - mode: cfg.models?.mode ?? "merge", - providers, - }, - }; -} - -export function applyKilocodeConfig(cfg: OpenClawConfig): OpenClawConfig { - const next = applyKilocodeProviderConfig(cfg); - const existingModel = next.agents?.defaults?.model; - return { - ...next, - agents: { - ...next.agents, - defaults: { - ...next.agents?.defaults, - model: { - ...(existingModel && "fallbacks" in (existingModel as Record) - ? { - fallbacks: (existingModel as { fallbacks?: string[] }).fallbacks, - } - : undefined), - primary: KILOCODE_DEFAULT_MODEL_REF, - }, - }, - }, - }; -} -``` - -### Auth Choice System - -#### 5. `src/commands/onboard-types.ts` - -Add to `AuthChoice` type: - -```typescript -export type AuthChoice = - // ... existing choices - "kilocode-api-key"; -// ... -``` - -Add to `OnboardOptions`: - -```typescript -export type OnboardOptions = { - // ... existing options - kilocodeApiKey?: string; - // ... -}; -``` - -#### 6. `src/commands/auth-choice-options.ts` - -Add to `AuthChoiceGroupId`: - -```typescript -export type AuthChoiceGroupId = - // ... existing groups - "kilocode"; -// ... -``` - -Add to `AUTH_CHOICE_GROUP_DEFS`: - -```typescript -{ - value: "kilocode", - label: "Kilo Gateway", - hint: "API key (OpenRouter-compatible)", - choices: ["kilocode-api-key"], -}, -``` - -Add to `buildAuthChoiceOptions()`: - -```typescript -options.push({ - value: "kilocode-api-key", - label: "Kilo Gateway API key", - hint: "OpenRouter-compatible gateway", -}); -``` - -#### 7. `src/commands/auth-choice.preferred-provider.ts` - -Add mapping: - -```typescript -const PREFERRED_PROVIDER_BY_AUTH_CHOICE: Partial> = { - // ... existing mappings - "kilocode-api-key": "kilocode", -}; -``` - -### Auth Choice Application - -#### 8. `src/commands/auth-choice.apply.api-providers.ts` - -Add import: - -```typescript -import { - // ... existing imports - applyKilocodeConfig, - applyKilocodeProviderConfig, - KILOCODE_DEFAULT_MODEL_REF, - setKilocodeApiKey, -} from "./onboard-auth.js"; -``` - -Add handling for `kilocode-api-key`: - -```typescript -if (authChoice === "kilocode-api-key") { - const store = ensureAuthProfileStore(params.agentDir, { - allowKeychainPrompt: false, - }); - const profileOrder = resolveAuthProfileOrder({ - cfg: nextConfig, - store, - provider: "kilocode", - }); - const existingProfileId = profileOrder.find((profileId) => Boolean(store.profiles[profileId])); - const existingCred = existingProfileId ? store.profiles[existingProfileId] : undefined; - let profileId = "kilocode:default"; - let mode: "api_key" | "oauth" | "token" = "api_key"; - let hasCredential = false; - - if (existingProfileId && existingCred?.type) { - profileId = existingProfileId; - mode = - existingCred.type === "oauth" ? "oauth" : existingCred.type === "token" ? "token" : "api_key"; - hasCredential = true; - } - - if (!hasCredential && params.opts?.token && params.opts?.tokenProvider === "kilocode") { - await setKilocodeApiKey(normalizeApiKeyInput(params.opts.token), params.agentDir); - hasCredential = true; - } - - if (!hasCredential) { - const envKey = resolveEnvApiKey("kilocode"); - if (envKey) { - const useExisting = await params.prompter.confirm({ - message: `Use existing KILOCODE_API_KEY (${envKey.source}, ${formatApiKeyPreview(envKey.apiKey)})?`, - initialValue: true, - }); - if (useExisting) { - await setKilocodeApiKey(envKey.apiKey, params.agentDir); - hasCredential = true; - } - } - } - - if (!hasCredential) { - const key = await params.prompter.text({ - message: "Enter Kilo Gateway API key", - validate: validateApiKeyInput, - }); - await setKilocodeApiKey(normalizeApiKeyInput(String(key)), params.agentDir); - hasCredential = true; - } - - if (hasCredential) { - nextConfig = applyAuthProfileConfig(nextConfig, { - profileId, - provider: "kilocode", - mode, - }); - } - { - const applied = await applyDefaultModelChoice({ - config: nextConfig, - setDefaultModel: params.setDefaultModel, - defaultModel: KILOCODE_DEFAULT_MODEL_REF, - applyDefaultConfig: applyKilocodeConfig, - applyProviderConfig: applyKilocodeProviderConfig, - noteDefault: KILOCODE_DEFAULT_MODEL_REF, - noteAgentModel, - prompter: params.prompter, - }); - nextConfig = applied.config; - agentModelOverride = applied.agentModelOverride ?? agentModelOverride; - } - return { config: nextConfig, agentModelOverride }; -} -``` - -Also add tokenProvider mapping at the top of the function: - -```typescript -if (params.opts.tokenProvider === "kilocode") { - authChoice = "kilocode-api-key"; -} -``` - -### CLI Registration - -#### 9. `src/cli/program/register.onboard.ts` - -Add CLI option: - -```typescript -.option("--kilocode-api-key ", "Kilo Gateway API key") -``` - -Add to action handler: - -```typescript -kilocodeApiKey: opts.kilocodeApiKey as string | undefined, -``` - -Update auth-choice help text: - -```typescript -.option( - "--auth-choice ", - "Auth: setup-token|token|chutes|openai-codex|openai-api-key|openrouter-api-key|kilocode-api-key|ai-gateway-api-key|...", -) -``` - -### Non-Interactive Onboarding - -#### 10. `src/commands/onboard-non-interactive/local/auth-choice.ts` - -Add handling for `kilocode-api-key`: - -```typescript -if (authChoice === "kilocode-api-key") { - const resolved = await resolveNonInteractiveApiKey({ - provider: "kilocode", - cfg: baseConfig, - flagValue: opts.kilocodeApiKey, - flagName: "--kilocode-api-key", - envVar: "KILOCODE_API_KEY", - }); - await setKilocodeApiKey(resolved.apiKey, agentDir); - nextConfig = applyAuthProfileConfig(nextConfig, { - profileId: "kilocode:default", - provider: "kilocode", - mode: "api_key", - }); - // ... apply default model -} -``` - -### Export Updates - -#### 11. `src/commands/onboard-auth.ts` - -Add exports: - -```typescript -export { - // ... existing exports - applyKilocodeConfig, - applyKilocodeProviderConfig, - KILOCODE_BASE_URL, -} from "./onboard-auth.config-core.js"; - -export { - // ... existing exports - KILOCODE_DEFAULT_MODEL_REF, - setKilocodeApiKey, -} from "./onboard-auth.credentials.js"; -``` - -### Special Handling (Optional) - -#### 12. `src/agents/pi-embedded-runner/cache-ttl.ts` - -Add Kilo Gateway support for Anthropic models: - -```typescript -export function isCacheTtlEligibleProvider(provider: string, modelId: string): boolean { - const normalizedProvider = provider.toLowerCase(); - const normalizedModelId = modelId.toLowerCase(); - if (normalizedProvider === "anthropic") return true; - if (normalizedProvider === "openrouter" && normalizedModelId.startsWith("anthropic/")) - return true; - if (normalizedProvider === "kilocode" && normalizedModelId.startsWith("anthropic/")) return true; - return false; -} -``` - -#### 13. `src/agents/transcript-policy.ts` - -Add Kilo Gateway handling (similar to OpenRouter): - -```typescript -const isKilocodeGemini = provider === "kilocode" && modelId.toLowerCase().includes("gemini"); - -// Include in needsNonImageSanitize check -const needsNonImageSanitize = - isGoogle || isAnthropic || isMistral || isOpenRouterGemini || isKilocodeGemini; -``` - -## Configuration Structure - -### User Config Example - -```json -{ - "models": { - "mode": "merge", - "providers": { - "kilocode": { - "baseUrl": "https://api.kilo.ai/api/gateway/", - "apiKey": "xxxxx", - "api": "openai-completions", - "models": [ - { - "id": "anthropic/claude-opus-4.6", - "name": "Anthropic: Claude Opus 4.6" - }, - { "id": "minimax/minimax-m2.5:free", "name": "Minimax: Minimax M2.5" } - ] - } - } - } -} -``` - -### Auth Profile Structure - -```json -{ - "profiles": { - "kilocode:default": { - "type": "api_key", - "provider": "kilocode", - "key": "xxxxx" - } - } -} -``` - -## Testing Considerations - -1. **Unit Tests:** - - Test `setKilocodeApiKey()` writes correct profile - - Test `applyKilocodeConfig()` sets correct defaults - - Test `resolveEnvApiKey("kilocode")` returns correct env var - -2. **Integration Tests:** - - Test setup flow with `--auth-choice kilocode-api-key` - - Test non-interactive setup with `--kilocode-api-key` - - Test model selection with `kilocode/` prefix - -3. **E2E Tests:** - - Test actual API calls through Kilo Gateway (live tests) - -## Migration Notes - -- No migration needed for existing users -- New users can immediately use `kilocode-api-key` auth choice -- Existing manual config with `kilocode` provider will continue to work - -## Future Considerations - -1. **Model Catalog:** If Kilo Gateway exposes a `/models` endpoint, add scanning support similar to `scanOpenRouterModels()` - -2. **OAuth Support:** If Kilo Gateway adds OAuth, extend the auth system accordingly - -3. **Rate Limiting:** Consider adding rate limit handling specific to Kilo Gateway if needed - -4. **Documentation:** Add docs at `docs/providers/kilocode.md` explaining setup and usage - -## Summary of Changes - -| File | Change Type | Description | -| ----------------------------------------------------------- | ----------- | ----------------------------------------------------------------------- | -| `src/commands/onboard-auth.credentials.ts` | Add | `KILOCODE_DEFAULT_MODEL_REF`, `setKilocodeApiKey()` | -| `src/agents/model-auth.ts` | Modify | Add `kilocode` to `envMap` | -| `src/config/io.ts` | Modify | Add `KILOCODE_API_KEY` to shell env keys | -| `src/commands/onboard-auth.config-core.ts` | Add | `applyKilocodeProviderConfig()`, `applyKilocodeConfig()` | -| `src/commands/onboard-types.ts` | Modify | Add `kilocode-api-key` to `AuthChoice`, add `kilocodeApiKey` to options | -| `src/commands/auth-choice-options.ts` | Modify | Add `kilocode` group and option | -| `src/commands/auth-choice.preferred-provider.ts` | Modify | Add `kilocode-api-key` mapping | -| `src/commands/auth-choice.apply.api-providers.ts` | Modify | Add `kilocode-api-key` handling | -| `src/cli/program/register.onboard.ts` | Modify | Add `--kilocode-api-key` option | -| `src/commands/onboard-non-interactive/local/auth-choice.ts` | Modify | Add non-interactive handling | -| `src/commands/onboard-auth.ts` | Modify | Export new functions | -| `src/agents/pi-embedded-runner/cache-ttl.ts` | Modify | Add kilocode support | -| `src/agents/transcript-policy.ts` | Modify | Add kilocode Gemini handling | diff --git a/docs/docs.json b/docs/docs.json index 9d04ab81c5c..1d98a93c602 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -535,10 +535,6 @@ "source": "/onboarding", "destination": "/start/onboarding" }, - { - "source": "/onboarding-config-protocol", - "destination": "/experiments/onboarding-config-protocol" - }, { "source": "/pairing", "destination": "/channels/pairing" @@ -559,10 +555,6 @@ "source": "/presence", "destination": "/concepts/presence" }, - { - "source": "/proposals/model-config", - "destination": "/experiments/proposals/model-config" - }, { "source": "/provider-routing", "destination": "/channels/channel-routing" @@ -583,10 +575,6 @@ "source": "/remote-gateway-readme", "destination": "/gateway/remote-gateway-readme" }, - { - "source": "/research/memory", - "destination": "/experiments/research/memory" - }, { "source": "/rpc", "destination": "/reference/rpc" @@ -1358,21 +1346,6 @@ { "group": "Release policy", "pages": ["reference/RELEASING", "reference/test"] - }, - { - "group": "Experiments", - "pages": [ - "design/kilo-gateway-integration", - "experiments/onboarding-config-protocol", - "experiments/plans/acp-thread-bound-agents", - "experiments/plans/acp-unified-streaming-refactor", - "experiments/plans/browser-evaluate-cdp-refactor", - "experiments/plans/openresponses-gateway", - "experiments/plans/pty-process-supervision", - "experiments/plans/session-binding-channel-agnostic", - "experiments/research/memory", - "experiments/proposals/model-config" - ] } ] }, @@ -1938,17 +1911,6 @@ { "group": "发布策略", "pages": ["zh-CN/reference/RELEASING", "zh-CN/reference/test"] - }, - { - "group": "实验性功能", - "pages": [ - "zh-CN/experiments/onboarding-config-protocol", - "zh-CN/experiments/plans/openresponses-gateway", - "zh-CN/experiments/plans/cron-add-hardening", - "zh-CN/experiments/plans/group-policy-hardening", - "zh-CN/experiments/research/memory", - "zh-CN/experiments/proposals/model-config" - ] } ] }, diff --git a/docs/experiments/onboarding-config-protocol.md b/docs/experiments/onboarding-config-protocol.md deleted file mode 100644 index e3b9d9dff10..00000000000 --- a/docs/experiments/onboarding-config-protocol.md +++ /dev/null @@ -1,43 +0,0 @@ ---- -summary: "RPC protocol notes for setup wizard and config schema" -read_when: "Changing setup wizard steps or config schema endpoints" -title: "Onboarding and Config Protocol" ---- - -# Onboarding + Config Protocol - -Purpose: shared onboarding + config surfaces across CLI, macOS app, and Web UI. - -## Components - -- Wizard engine (shared session + prompts + onboarding state). -- CLI onboarding uses the same wizard flow as the UI clients. -- Gateway RPC exposes wizard + config schema endpoints. -- macOS onboarding uses the wizard step model. -- Web UI renders config forms from JSON Schema + UI hints. - -## Gateway RPC - -- `wizard.start` params: `{ mode?: "local"|"remote", workspace?: string }` -- `wizard.next` params: `{ sessionId, answer?: { stepId, value? } }` -- `wizard.cancel` params: `{ sessionId }` -- `wizard.status` params: `{ sessionId }` -- `config.schema` params: `{}` -- `config.schema.lookup` params: `{ path }` - - `path` accepts standard config segments plus slash-delimited plugin ids, for example `plugins.entries.pack/one.config`. - -Responses (shape) - -- Wizard: `{ sessionId, done, step?, status?, error? }` -- Config schema: `{ schema, uiHints, version, generatedAt }` -- Config schema lookup: `{ path, schema, hint?, hintPath?, children[] }` - -## UI Hints - -- `uiHints` keyed by path; optional metadata (label/help/group/order/advanced/sensitive/placeholder). -- Sensitive fields render as password inputs; no redaction layer. -- Unsupported schema nodes fall back to the raw JSON editor. - -## Notes - -- This doc is the single place to track protocol refactors for onboarding/config. diff --git a/docs/experiments/plans/acp-persistent-bindings-discord-channels-telegram-topics.md b/docs/experiments/plans/acp-persistent-bindings-discord-channels-telegram-topics.md deleted file mode 100644 index e85ddeaf4a7..00000000000 --- a/docs/experiments/plans/acp-persistent-bindings-discord-channels-telegram-topics.md +++ /dev/null @@ -1,375 +0,0 @@ -# ACP Persistent Bindings for Discord Channels and Telegram Topics - -Status: Draft - -## Summary - -Introduce persistent ACP bindings that map: - -- Discord channels (and existing threads, where needed), and -- Telegram forum topics in groups/supergroups (`chatId:topic:topicId`) - -to long-lived ACP sessions, with binding state stored in top-level `bindings[]` entries using explicit binding types. - -This makes ACP usage in high-traffic messaging channels predictable and durable, so users can create dedicated channels/topics such as `codex`, `claude-1`, or `claude-myrepo`. - -## Why - -Current thread-bound ACP behavior is optimized for ephemeral Discord thread workflows. Telegram does not have the same thread model; it has forum topics in groups/supergroups. Users want stable, always-on ACP “workspaces” in chat surfaces, not only temporary thread sessions. - -## Goals - -- Support durable ACP binding for: - - Discord channels/threads - - Telegram forum topics (groups/supergroups) -- Make binding source-of-truth config-driven. -- Keep `/acp`, `/new`, `/reset`, `/focus`, and delivery behavior consistent across Discord and Telegram. -- Preserve existing temporary binding flows for ad-hoc usage. - -## Non-Goals - -- Full redesign of ACP runtime/session internals. -- Removing existing ephemeral binding flows. -- Expanding to every channel in the first iteration. -- Implementing Telegram channel direct-messages topics (`direct_messages_topic_id`) in this phase. -- Implementing Telegram private-chat topic variants in this phase. - -## UX Direction - -### 1) Two binding types - -- **Persistent binding**: saved in config, reconciled on startup, intended for “named workspace” channels/topics. -- **Temporary binding**: runtime-only, expires by idle/max-age policy. - -### 2) Command behavior - -- `/acp spawn ... --thread here|auto|off` remains available. -- Add explicit bind lifecycle controls: - - `/acp bind [session|agent] [--persist]` - - `/acp unbind [--persist]` - - `/acp status` includes whether binding is `persistent` or `temporary`. -- In bound conversations, `/new` and `/reset` reset the bound ACP session in place and keep the binding attached. - -### 3) Conversation identity - -- Use canonical conversation IDs: - - Discord: channel/thread ID. - - Telegram topic: `chatId:topic:topicId`. -- Never key Telegram bindings by bare topic ID alone. - -## Config Model (Proposed) - -Unify routing and persistent ACP binding configuration in top-level `bindings[]` with explicit `type` discriminator: - -```jsonc -{ - "agents": { - "list": [ - { - "id": "main", - "default": true, - "workspace": "~/.openclaw/workspace-main", - "runtime": { "type": "embedded" }, - }, - { - "id": "codex", - "workspace": "~/.openclaw/workspace-codex", - "runtime": { - "type": "acp", - "acp": { - "agent": "codex", - "backend": "acpx", - "mode": "persistent", - "cwd": "/workspace/repo-a", - }, - }, - }, - { - "id": "claude", - "workspace": "~/.openclaw/workspace-claude", - "runtime": { - "type": "acp", - "acp": { - "agent": "claude", - "backend": "acpx", - "mode": "persistent", - "cwd": "/workspace/repo-b", - }, - }, - }, - ], - }, - "acp": { - "enabled": true, - "backend": "acpx", - "allowedAgents": ["codex", "claude"], - }, - "bindings": [ - // Route bindings (existing behavior) - { - "type": "route", - "agentId": "main", - "match": { "channel": "discord", "accountId": "default" }, - }, - { - "type": "route", - "agentId": "main", - "match": { "channel": "telegram", "accountId": "default" }, - }, - // Persistent ACP conversation bindings - { - "type": "acp", - "agentId": "codex", - "match": { - "channel": "discord", - "accountId": "default", - "peer": { "kind": "channel", "id": "222222222222222222" }, - }, - "acp": { - "label": "codex-main", - "mode": "persistent", - "cwd": "/workspace/repo-a", - "backend": "acpx", - }, - }, - { - "type": "acp", - "agentId": "claude", - "match": { - "channel": "discord", - "accountId": "default", - "peer": { "kind": "channel", "id": "333333333333333333" }, - }, - "acp": { - "label": "claude-repo-b", - "mode": "persistent", - "cwd": "/workspace/repo-b", - }, - }, - { - "type": "acp", - "agentId": "codex", - "match": { - "channel": "telegram", - "accountId": "default", - "peer": { "kind": "group", "id": "-1001234567890:topic:42" }, - }, - "acp": { - "label": "tg-codex-42", - "mode": "persistent", - }, - }, - ], - "channels": { - "discord": { - "guilds": { - "111111111111111111": { - "channels": { - "222222222222222222": { - "enabled": true, - "requireMention": false, - }, - "333333333333333333": { - "enabled": true, - "requireMention": false, - }, - }, - }, - }, - }, - "telegram": { - "groups": { - "-1001234567890": { - "topics": { - "42": { - "requireMention": false, - }, - }, - }, - }, - }, - }, -} -``` - -### Minimal Example (No Per-Binding ACP Overrides) - -```jsonc -{ - "agents": { - "list": [ - { "id": "main", "default": true, "runtime": { "type": "embedded" } }, - { - "id": "codex", - "runtime": { - "type": "acp", - "acp": { "agent": "codex", "backend": "acpx", "mode": "persistent" }, - }, - }, - { - "id": "claude", - "runtime": { - "type": "acp", - "acp": { "agent": "claude", "backend": "acpx", "mode": "persistent" }, - }, - }, - ], - }, - "acp": { "enabled": true, "backend": "acpx" }, - "bindings": [ - { - "type": "route", - "agentId": "main", - "match": { "channel": "discord", "accountId": "default" }, - }, - { - "type": "route", - "agentId": "main", - "match": { "channel": "telegram", "accountId": "default" }, - }, - - { - "type": "acp", - "agentId": "codex", - "match": { - "channel": "discord", - "accountId": "default", - "peer": { "kind": "channel", "id": "222222222222222222" }, - }, - }, - { - "type": "acp", - "agentId": "claude", - "match": { - "channel": "discord", - "accountId": "default", - "peer": { "kind": "channel", "id": "333333333333333333" }, - }, - }, - { - "type": "acp", - "agentId": "codex", - "match": { - "channel": "telegram", - "accountId": "default", - "peer": { "kind": "group", "id": "-1009876543210:topic:5" }, - }, - }, - ], -} -``` - -Notes: - -- `bindings[].type` is explicit: - - `route`: normal agent routing. - - `acp`: persistent ACP harness binding for a matched conversation. -- For `type: "acp"`, `match.peer.id` is the canonical conversation key: - - Discord channel/thread: raw channel/thread ID. - - Telegram topic: `chatId:topic:topicId`. -- `bindings[].acp.backend` is optional. Backend fallback order: - 1. `bindings[].acp.backend` - 2. `agents.list[].runtime.acp.backend` - 3. global `acp.backend` -- `mode`, `cwd`, and `label` follow the same override pattern (`binding override -> agent runtime default -> global/default behavior`). -- Keep existing `session.threadBindings.*` and `channels.discord.threadBindings.*` for temporary binding policies. -- Persistent entries declare desired state; runtime reconciles to actual ACP sessions/bindings. -- One active ACP binding per conversation node is the intended model. -- Backward compatibility: missing `type` is interpreted as `route` for legacy entries. - -### Backend Selection - -- ACP session initialization already uses configured backend selection during spawn (`acp.backend` today). -- This proposal extends spawn/reconcile logic to prefer typed ACP binding overrides: - - `bindings[].acp.backend` for conversation-local override. - - `agents.list[].runtime.acp.backend` for per-agent defaults. -- If no override exists, keep current behavior (`acp.backend` default). - -## Architecture Fit in Current System - -### Reuse existing components - -- `SessionBindingService` already supports channel-agnostic conversation references. -- ACP spawn/bind flows already support binding through service APIs. -- Telegram already carries topic/thread context via `MessageThreadId` and `chatId`. - -### New/extended components - -- **Telegram binding adapter** (parallel to Discord adapter): - - register adapter per Telegram account, - - resolve/list/bind/unbind/touch by canonical conversation ID. -- **Typed binding resolver/index**: - - split `bindings[]` into `route` and `acp` views, - - keep `resolveAgentRoute` on `route` bindings only, - - resolve persistent ACP intent from `acp` bindings only. -- **Inbound binding resolution for Telegram**: - - resolve bound session before route finalization (Discord already does this). -- **Persistent binding reconciler**: - - on startup: load configured top-level `type: "acp"` bindings, ensure ACP sessions exist, ensure bindings exist. - - on config change: apply deltas safely. -- **Cutover model**: - - no channel-local ACP binding fallback is read, - - persistent ACP bindings are sourced only from top-level `bindings[].type="acp"` entries. - -## Phased Delivery - -### Phase 1: Typed binding schema foundation - -- Extend config schema to support `bindings[].type` discriminator: - - `route`, - - `acp` with optional `acp` override object (`mode`, `backend`, `cwd`, `label`). -- Extend agent schema with runtime descriptor to mark ACP-native agents (`agents.list[].runtime.type`). -- Add parser/indexer split for route vs ACP bindings. - -### Phase 2: Runtime resolution + Discord/Telegram parity - -- Resolve persistent ACP bindings from top-level `type: "acp"` entries for: - - Discord channels/threads, - - Telegram forum topics (`chatId:topic:topicId` canonical IDs). -- Implement Telegram binding adapter and inbound bound-session override parity with Discord. -- Do not include Telegram direct/private topic variants in this phase. - -### Phase 3: Command parity and resets - -- Align `/acp`, `/new`, `/reset`, and `/focus` behavior in bound Telegram/Discord conversations. -- Ensure binding survives reset flows as configured. - -### Phase 4: Hardening - -- Better diagnostics (`/acp status`, startup reconciliation logs). -- Conflict handling and health checks. - -## Guardrails and Policy - -- Respect ACP enablement and sandbox restrictions exactly as today. -- Keep explicit account scoping (`accountId`) to avoid cross-account bleed. -- Fail closed on ambiguous routing. -- Keep mention/access policy behavior explicit per channel config. - -## Testing Plan - -- Unit: - - conversation ID normalization (especially Telegram topic IDs), - - reconciler create/update/delete paths, - - `/acp bind --persist` and unbind flows. -- Integration: - - inbound Telegram topic -> bound ACP session resolution, - - inbound Discord channel/thread -> persistent binding precedence. -- Regression: - - temporary bindings continue to work, - - unbound channels/topics keep current routing behavior. - -## Open Questions - -- Should `/acp spawn --thread auto` in Telegram topic default to `here`? -- Should persistent bindings always bypass mention-gating in bound conversations, or require explicit `requireMention=false`? -- Should `/focus` gain `--persist` as an alias for `/acp bind --persist`? - -## Rollout - -- Ship as opt-in per conversation (`bindings[].type="acp"` entry present). -- Start with Discord + Telegram only. -- Add docs with examples for: - - “one channel/topic per agent” - - “multiple channels/topics per same agent with different `cwd`” - - “team naming patterns (`codex-1`, `claude-repo-x`)". diff --git a/docs/experiments/plans/acp-thread-bound-agents.md b/docs/experiments/plans/acp-thread-bound-agents.md deleted file mode 100644 index a0637cedee5..00000000000 --- a/docs/experiments/plans/acp-thread-bound-agents.md +++ /dev/null @@ -1,800 +0,0 @@ ---- -summary: "Integrate ACP coding agents via a first-class ACP control plane in core and plugin-backed runtimes (acpx first)" -owner: "onutc" -status: "draft" -last_updated: "2026-02-25" -title: "ACP Thread Bound Agents" ---- - -# ACP Thread Bound Agents - -## Overview - -This plan defines how OpenClaw should support ACP coding agents in thread-capable channels (Discord first) with production-level lifecycle and recovery. - -Related document: - -- [Unified Runtime Streaming Refactor Plan](/experiments/plans/acp-unified-streaming-refactor) - -Target user experience: - -- a user spawns or focuses an ACP session into a thread -- user messages in that thread route to the bound ACP session -- agent output streams back to the same thread persona -- session can be persistent or one shot with explicit cleanup controls - -## Decision summary - -Long term recommendation is a hybrid architecture: - -- OpenClaw core owns ACP control plane concerns - - session identity and metadata - - thread binding and routing decisions - - delivery invariants and duplicate suppression - - lifecycle cleanup and recovery semantics -- ACP runtime backend is pluggable - - first backend is an acpx-backed plugin service - - runtime does ACP transport, queueing, cancel, reconnect - -OpenClaw should not reimplement ACP transport internals in core. -OpenClaw should not rely on a pure plugin-only interception path for routing. - -## North-star architecture (holy grail) - -Treat ACP as a first-class control plane in OpenClaw, with pluggable runtime adapters. - -Non-negotiable invariants: - -- every ACP thread binding references a valid ACP session record -- every ACP session has explicit lifecycle state (`creating`, `idle`, `running`, `cancelling`, `closed`, `error`) -- every ACP run has explicit run state (`queued`, `running`, `completed`, `failed`, `cancelled`) -- spawn, bind, and initial enqueue are atomic -- command retries are idempotent (no duplicate runs or duplicate Discord outputs) -- bound-thread channel output is a projection of ACP run events, never ad-hoc side effects - -Long-term ownership model: - -- `AcpSessionManager` is the single ACP writer and orchestrator -- manager lives in gateway process first; can be moved to a dedicated sidecar later behind the same interface -- per ACP session key, manager owns one in-memory actor (serialized command execution) -- adapters (`acpx`, future backends) are transport/runtime implementations only - -Long-term persistence model: - -- move ACP control-plane state to a dedicated SQLite store (WAL mode) under OpenClaw state dir -- keep `SessionEntry.acp` as compatibility projection during migration, not source-of-truth -- store ACP events append-only to support replay, crash recovery, and deterministic delivery - -### Delivery strategy (bridge to holy-grail) - -- short-term bridge - - keep current thread binding mechanics and existing ACP config surface - - fix metadata-gap bugs and route ACP turns through a single core ACP branch - - add idempotency keys and fail-closed routing checks immediately -- long-term cutover - - move ACP source-of-truth to control-plane DB + actors - - make bound-thread delivery purely event-projection based - - remove legacy fallback behavior that depends on opportunistic session-entry metadata - -## Why not pure plugin only - -Current plugin hooks are not sufficient for end to end ACP session routing without core changes. - -- inbound routing from thread binding resolves to a session key in core dispatch first -- message hooks are fire-and-forget and cannot short-circuit the main reply path -- plugin commands are good for control operations but not for replacing core per-turn dispatch flow - -Result: - -- ACP runtime can be pluginized -- ACP routing branch must exist in core - -## Existing foundation to reuse - -Already implemented and should remain canonical: - -- thread binding target supports `subagent` and `acp` -- inbound thread routing override resolves by binding before normal dispatch -- outbound thread identity via webhook in reply delivery -- `/focus` and `/unfocus` flow with ACP target compatibility -- persistent binding store with restore on startup -- unbind lifecycle on archive, delete, unfocus, reset, and delete - -This plan extends that foundation rather than replacing it. - -## Architecture - -### Boundary model - -Core (must be in OpenClaw core): - -- ACP session-mode dispatch branch in the reply pipeline -- delivery arbitration to avoid parent plus thread duplication -- ACP control-plane persistence (with `SessionEntry.acp` compatibility projection during migration) -- lifecycle unbind and runtime detach semantics tied to session reset/delete - -Plugin backend (acpx implementation): - -- ACP runtime worker supervision -- acpx process invocation and event parsing -- ACP command handlers (`/acp ...`) and operator UX -- backend-specific config defaults and diagnostics - -### Runtime ownership model - -- one gateway process owns ACP orchestration state -- ACP execution runs in supervised child processes via acpx backend -- process strategy is long lived per active ACP session key, not per message - -This avoids startup cost on every prompt and keeps cancel and reconnect semantics reliable. - -### Core runtime contract - -Add a core ACP runtime contract so routing code does not depend on CLI details and can switch backends without changing dispatch logic: - -```ts -export type AcpRuntimePromptMode = "prompt" | "steer"; - -export type AcpRuntimeHandle = { - sessionKey: string; - backend: string; - runtimeSessionName: string; -}; - -export type AcpRuntimeEvent = - | { type: "text_delta"; stream: "output" | "thought"; text: string } - | { type: "tool_call"; name: string; argumentsText: string } - | { type: "done"; usage?: Record } - | { type: "error"; code: string; message: string; retryable?: boolean }; - -export interface AcpRuntime { - ensureSession(input: { - sessionKey: string; - agent: string; - mode: "persistent" | "oneshot"; - cwd?: string; - env?: Record; - idempotencyKey: string; - }): Promise; - - submit(input: { - handle: AcpRuntimeHandle; - text: string; - mode: AcpRuntimePromptMode; - idempotencyKey: string; - }): Promise<{ runtimeRunId: string }>; - - stream(input: { - handle: AcpRuntimeHandle; - runtimeRunId: string; - onEvent: (event: AcpRuntimeEvent) => Promise | void; - signal?: AbortSignal; - }): Promise; - - cancel(input: { - handle: AcpRuntimeHandle; - runtimeRunId?: string; - reason?: string; - idempotencyKey: string; - }): Promise; - - close(input: { handle: AcpRuntimeHandle; reason: string; idempotencyKey: string }): Promise; - - health?(): Promise<{ ok: boolean; details?: string }>; -} -``` - -Implementation detail: - -- first backend: `AcpxRuntime` shipped as a plugin service -- core resolves runtime via registry and fails with explicit operator error when no ACP runtime backend is available - -### Control-plane data model and persistence - -Long-term source-of-truth is a dedicated ACP SQLite database (WAL mode), for transactional updates and crash-safe recovery: - -- `acp_sessions` - - `session_key` (pk), `backend`, `agent`, `mode`, `cwd`, `state`, `created_at`, `updated_at`, `last_error` -- `acp_runs` - - `run_id` (pk), `session_key` (fk), `state`, `requester_message_id`, `idempotency_key`, `started_at`, `ended_at`, `error_code`, `error_message` -- `acp_bindings` - - `binding_key` (pk), `thread_id`, `channel_id`, `account_id`, `session_key` (fk), `expires_at`, `bound_at` -- `acp_events` - - `event_id` (pk), `run_id` (fk), `seq`, `kind`, `payload_json`, `created_at` -- `acp_delivery_checkpoint` - - `run_id` (pk/fk), `last_event_seq`, `last_discord_message_id`, `updated_at` -- `acp_idempotency` - - `scope`, `idempotency_key`, `result_json`, `created_at`, unique `(scope, idempotency_key)` - -```ts -export type AcpSessionMeta = { - backend: string; - agent: string; - runtimeSessionName: string; - mode: "persistent" | "oneshot"; - cwd?: string; - state: "idle" | "running" | "error"; - lastActivityAt: number; - lastError?: string; -}; -``` - -Storage rules: - -- keep `SessionEntry.acp` as a compatibility projection during migration -- process ids and sockets stay in memory only -- durable lifecycle and run status live in ACP DB, not generic session JSON -- if runtime owner dies, gateway rehydrates from ACP DB and resumes from checkpoints - -### Routing and delivery - -Inbound: - -- keep current thread binding lookup as first routing step -- if bound target is ACP session, route to ACP runtime branch instead of `getReplyFromConfig` -- explicit `/acp steer` command uses `mode: "steer"` - -Outbound: - -- ACP event stream is normalized to OpenClaw reply chunks -- delivery target is resolved through existing bound destination path -- when a bound thread is active for that session turn, parent channel completion is suppressed - -Streaming policy: - -- stream partial output with coalescing window -- configurable min interval and max chunk bytes to stay under Discord rate limits -- final message always emitted on completion or failure - -### State machines and transaction boundaries - -Session state machine: - -- `creating -> idle -> running -> idle` -- `running -> cancelling -> idle | error` -- `idle -> closed` -- `error -> idle | closed` - -Run state machine: - -- `queued -> running -> completed` -- `running -> failed | cancelled` -- `queued -> cancelled` - -Required transaction boundaries: - -- spawn transaction - - create ACP session row - - create/update ACP thread binding row - - enqueue initial run row -- close transaction - - mark session closed - - delete/expire binding rows - - write final close event -- cancel transaction - - mark target run cancelling/cancelled with idempotency key - -No partial success is allowed across these boundaries. - -### Per-session actor model - -`AcpSessionManager` runs one actor per ACP session key: - -- actor mailbox serializes `submit`, `cancel`, `close`, and `stream` side effects -- actor owns runtime handle hydration and runtime adapter process lifecycle for that session -- actor writes run events in-order (`seq`) before any Discord delivery -- actor updates delivery checkpoints after successful outbound send - -This removes cross-turn races and prevents duplicate or out-of-order thread output. - -### Idempotency and delivery projection - -All external ACP actions must carry idempotency keys: - -- spawn idempotency key -- prompt/steer idempotency key -- cancel idempotency key -- close idempotency key - -Delivery rules: - -- Discord messages are derived from `acp_events` plus `acp_delivery_checkpoint` -- retries resume from checkpoint without re-sending already delivered chunks -- final reply emission is exactly-once per run from projection logic - -### Recovery and self-healing - -On gateway start: - -- load non-terminal ACP sessions (`creating`, `idle`, `running`, `cancelling`, `error`) -- recreate actors lazily on first inbound event or eagerly under configured cap -- reconcile any `running` runs missing heartbeats and mark `failed` or recover via adapter - -On inbound Discord thread message: - -- if binding exists but ACP session is missing, fail closed with explicit stale-binding message -- optionally auto-unbind stale binding after operator-safe validation -- never silently route stale ACP bindings to normal LLM path - -### Lifecycle and safety - -Supported operations: - -- cancel current run: `/acp cancel` -- unbind thread: `/unfocus` -- close ACP session: `/acp close` -- auto close idle sessions by effective TTL - -TTL policy: - -- effective TTL is minimum of - - global/session TTL - - Discord thread binding TTL - - ACP runtime owner TTL - -Safety controls: - -- allowlist ACP agents by name -- restrict workspace roots for ACP sessions -- env allowlist passthrough -- max concurrent ACP sessions per account and globally -- bounded restart backoff for runtime crashes - -## Config surface - -Core keys: - -- `acp.enabled` -- `acp.dispatch.enabled` (independent ACP routing kill switch) -- `acp.backend` (default `acpx`) -- `acp.defaultAgent` -- `acp.allowedAgents[]` -- `acp.maxConcurrentSessions` -- `acp.stream.coalesceIdleMs` -- `acp.stream.maxChunkChars` -- `acp.runtime.ttlMinutes` -- `acp.controlPlane.store` (`sqlite` default) -- `acp.controlPlane.storePath` -- `acp.controlPlane.recovery.eagerActors` -- `acp.controlPlane.recovery.reconcileRunningAfterMs` -- `acp.controlPlane.checkpoint.flushEveryEvents` -- `acp.controlPlane.checkpoint.flushEveryMs` -- `acp.idempotency.ttlHours` -- `channels.discord.threadBindings.spawnAcpSessions` - -Plugin/backend keys (acpx plugin section): - -- backend command/path overrides -- backend env allowlist -- backend per-agent presets -- backend startup/stop timeouts -- backend max inflight runs per session - -## Implementation specification - -### Control-plane modules (new) - -Add dedicated ACP control-plane modules in core: - -- `src/acp/control-plane/manager.ts` - - owns ACP actors, lifecycle transitions, command serialization -- `src/acp/control-plane/store.ts` - - SQLite schema management, transactions, query helpers -- `src/acp/control-plane/events.ts` - - typed ACP event definitions and serialization -- `src/acp/control-plane/checkpoint.ts` - - durable delivery checkpoints and replay cursors -- `src/acp/control-plane/idempotency.ts` - - idempotency key reservation and response replay -- `src/acp/control-plane/recovery.ts` - - boot-time reconciliation and actor rehydrate plan - -Compatibility bridge modules: - -- `src/acp/runtime/session-meta.ts` - - remains temporarily for projection into `SessionEntry.acp` - - must stop being source-of-truth after migration cutover - -### Required invariants (must enforce in code) - -- ACP session creation and thread bind are atomic (single transaction) -- there is at most one active run per ACP session actor at a time -- event `seq` is strictly increasing per run -- delivery checkpoint never advances past last committed event -- idempotency replay returns previous success payload for duplicate command keys -- stale/missing ACP metadata cannot route into normal non-ACP reply path - -### Core touchpoints - -Core files to change: - -- `src/auto-reply/reply/dispatch-from-config.ts` - - ACP branch calls `AcpSessionManager.submit` and event-projection delivery - - remove direct ACP fallback that bypasses control-plane invariants -- `src/auto-reply/reply/inbound-context.ts` (or nearest normalized context boundary) - - expose normalized routing keys and idempotency seeds for ACP control plane -- `src/config/sessions/types.ts` - - keep `SessionEntry.acp` as projection-only compatibility field -- `src/gateway/server-methods/sessions.ts` - - reset/delete/archive must call ACP manager close/unbind transaction path -- `src/infra/outbound/bound-delivery-router.ts` - - enforce fail-closed destination behavior for ACP bound session turns -- `src/discord/monitor/thread-bindings.ts` - - add ACP stale-binding validation helpers wired to control-plane lookups -- `src/auto-reply/reply/commands-acp.ts` - - route spawn/cancel/close/steer through ACP manager APIs -- `src/agents/acp-spawn.ts` - - stop ad-hoc metadata writes; call ACP manager spawn transaction -- `src/plugin-sdk/**` and plugin runtime bridge - - expose ACP backend registration and health semantics cleanly - -Core files explicitly not replaced: - -- `src/discord/monitor/message-handler.preflight.ts` - - keep thread binding override behavior as the canonical session-key resolver - -### ACP runtime registry API - -Add a core registry module: - -- `src/acp/runtime/registry.ts` - -Required API: - -```ts -export type AcpRuntimeBackend = { - id: string; - runtime: AcpRuntime; - healthy?: () => boolean; -}; - -export function registerAcpRuntimeBackend(backend: AcpRuntimeBackend): void; -export function unregisterAcpRuntimeBackend(id: string): void; -export function getAcpRuntimeBackend(id?: string): AcpRuntimeBackend | null; -export function requireAcpRuntimeBackend(id?: string): AcpRuntimeBackend; -``` - -Behavior: - -- `requireAcpRuntimeBackend` throws a typed ACP backend missing error when unavailable -- plugin service registers backend on `start` and unregisters on `stop` -- runtime lookups are read-only and process-local - -### acpx runtime plugin contract (implementation detail) - -For the first production backend (`extensions/acpx`), OpenClaw and acpx are -connected with a strict command contract: - -- backend id: `acpx` -- plugin service id: `acpx-runtime` -- runtime handle encoding: `runtimeSessionName = acpx:v1:` -- encoded payload fields: - - `name` (acpx named session; uses OpenClaw `sessionKey`) - - `agent` (acpx agent command) - - `cwd` (session workspace root) - - `mode` (`persistent | oneshot`) - -Command mapping: - -- ensure session: - - `acpx --format json --json-strict --cwd sessions ensure --name ` -- prompt turn: - - `acpx --format json --json-strict --cwd prompt --session --file -` -- cancel: - - `acpx --format json --json-strict --cwd cancel --session ` -- close: - - `acpx --format json --json-strict --cwd sessions close ` - -Streaming: - -- OpenClaw consumes ndjson events from `acpx --format json --json-strict` -- `text` => `text_delta/output` -- `thought` => `text_delta/thought` -- `tool_call` => `tool_call` -- `done` => `done` -- `error` => `error` - -### Session schema patch - -Patch `SessionEntry` in `src/config/sessions/types.ts`: - -```ts -type SessionAcpMeta = { - backend: string; - agent: string; - runtimeSessionName: string; - mode: "persistent" | "oneshot"; - cwd?: string; - state: "idle" | "running" | "error"; - lastActivityAt: number; - lastError?: string; -}; -``` - -Persisted field: - -- `SessionEntry.acp?: SessionAcpMeta` - -Migration rules: - -- phase A: dual-write (`acp` projection + ACP SQLite source-of-truth) -- phase B: read-primary from ACP SQLite, fallback-read from legacy `SessionEntry.acp` -- phase C: migration command backfills missing ACP rows from valid legacy entries -- phase D: remove fallback-read and keep projection optional for UX only -- legacy fields (`cliSessionIds`, `claudeCliSessionId`) remain untouched - -### Error contract - -Add stable ACP error codes and user-facing messages: - -- `ACP_BACKEND_MISSING` - - message: `ACP runtime backend is not configured. Install and enable the acpx runtime plugin.` -- `ACP_BACKEND_UNAVAILABLE` - - message: `ACP runtime backend is currently unavailable. Try again in a moment.` -- `ACP_SESSION_INIT_FAILED` - - message: `Could not initialize ACP session runtime.` -- `ACP_TURN_FAILED` - - message: `ACP turn failed before completion.` - -Rules: - -- return actionable user-safe message in-thread -- log detailed backend/system error only in runtime logs -- never silently fall back to normal LLM path when ACP routing was explicitly selected - -### Duplicate delivery arbitration - -Single routing rule for ACP bound turns: - -- if an active thread binding exists for the target ACP session and requester context, deliver only to that bound thread -- do not also send to parent channel for the same turn -- if bound destination selection is ambiguous, fail closed with explicit error (no implicit parent fallback) -- if no active binding exists, use normal session destination behavior - -### Observability and operational readiness - -Required metrics: - -- ACP spawn success/failure count by backend and error code -- ACP run latency percentiles (queue wait, runtime turn time, delivery projection time) -- ACP actor restart count and restart reason -- stale-binding detection count -- idempotency replay hit rate -- Discord delivery retry and rate-limit counters - -Required logs: - -- structured logs keyed by `sessionKey`, `runId`, `backend`, `threadId`, `idempotencyKey` -- explicit state transition logs for session and run state machines -- adapter command logs with redaction-safe arguments and exit summary - -Required diagnostics: - -- `/acp sessions` includes state, active run, last error, and binding status -- `/acp doctor` (or equivalent) validates backend registration, store health, and stale bindings - -### Config precedence and effective values - -ACP enablement precedence: - -- account override: `channels.discord.accounts..threadBindings.spawnAcpSessions` -- channel override: `channels.discord.threadBindings.spawnAcpSessions` -- global ACP gate: `acp.enabled` -- dispatch gate: `acp.dispatch.enabled` -- backend availability: registered backend for `acp.backend` - -Auto-enable behavior: - -- when ACP is configured (`acp.enabled=true`, `acp.dispatch.enabled=true`, or - `acp.backend=acpx`), plugin auto-enable marks `plugins.entries.acpx.enabled=true` - unless denylisted or explicitly disabled - -TTL effective value: - -- `min(session ttl, discord thread binding ttl, acp runtime ttl)` - -### Test map - -Unit tests: - -- `src/acp/runtime/registry.test.ts` (new) -- `src/auto-reply/reply/dispatch-from-config.acp.test.ts` (new) -- `src/infra/outbound/bound-delivery-router.test.ts` (extend ACP fail-closed cases) -- `src/config/sessions/types.test.ts` or nearest session-store tests (ACP metadata persistence) - -Integration tests: - -- `src/discord/monitor/reply-delivery.test.ts` (bound ACP delivery target behavior) -- `src/discord/monitor/message-handler.preflight*.test.ts` (bound ACP session-key routing continuity) -- acpx plugin runtime tests in backend package (service register/start/stop + event normalization) - -Gateway e2e tests: - -- `src/gateway/server.sessions.gateway-server-sessions-a.e2e.test.ts` (extend ACP reset/delete lifecycle coverage) -- ACP thread turn roundtrip e2e for spawn, message, stream, cancel, unfocus, restart recovery - -### Rollout guard - -Add independent ACP dispatch kill switch: - -- `acp.dispatch.enabled` default `false` for first release -- when disabled: - - ACP spawn/focus control commands may still bind sessions - - ACP dispatch path does not activate - - user receives explicit message that ACP dispatch is disabled by policy -- after canary validation, default can be flipped to `true` in a later release - -## Command and UX plan - -### New commands - -- `/acp spawn [--mode persistent|oneshot] [--thread auto|here|off]` -- `/acp cancel [session]` -- `/acp steer ` -- `/acp close [session]` -- `/acp sessions` - -### Existing command compatibility - -- `/focus ` continues to support ACP targets -- `/unfocus` keeps current semantics -- `/session idle` and `/session max-age` replace the old TTL override - -## Phased rollout - -### Phase 0 ADR and schema freeze - -- ship ADR for ACP control-plane ownership and adapter boundaries -- freeze DB schema (`acp_sessions`, `acp_runs`, `acp_bindings`, `acp_events`, `acp_delivery_checkpoint`, `acp_idempotency`) -- define stable ACP error codes, event contract, and state-transition guards - -### Phase 1 Control-plane foundation in core - -- implement `AcpSessionManager` and per-session actor runtime -- implement ACP SQLite store and transaction helpers -- implement idempotency store and replay helpers -- implement event append + delivery checkpoint modules -- wire spawn/cancel/close APIs to manager with transactional guarantees - -### Phase 2 Core routing and lifecycle integration - -- route thread-bound ACP turns from dispatch pipeline into ACP manager -- enforce fail-closed routing when ACP binding/session invariants fail -- integrate reset/delete/archive/unfocus lifecycle with ACP close/unbind transactions -- add stale-binding detection and optional auto-unbind policy - -### Phase 3 acpx backend adapter/plugin - -- implement `acpx` adapter against runtime contract (`ensureSession`, `submit`, `stream`, `cancel`, `close`) -- add backend health checks and startup/teardown registration -- normalize acpx ndjson events into ACP runtime events -- enforce backend timeouts, process supervision, and restart/backoff policy - -### Phase 4 Delivery projection and channel UX (Discord first) - -- implement event-driven channel projection with checkpoint resume (Discord first) -- coalesce streaming chunks with rate-limit aware flush policy -- guarantee exactly-once final completion message per run -- ship `/acp spawn`, `/acp cancel`, `/acp steer`, `/acp close`, `/acp sessions` - -### Phase 5 Migration and cutover - -- introduce dual-write to `SessionEntry.acp` projection plus ACP SQLite source-of-truth -- add migration utility for legacy ACP metadata rows -- flip read path to ACP SQLite primary -- remove legacy fallback routing that depends on missing `SessionEntry.acp` - -### Phase 6 Hardening, SLOs, and scale limits - -- enforce concurrency limits (global/account/session), queue policies, and timeout budgets -- add full telemetry, dashboards, and alert thresholds -- chaos-test crash recovery and duplicate-delivery suppression -- publish runbook for backend outage, DB corruption, and stale-binding remediation - -### Full implementation checklist - -- core control-plane modules and tests -- DB migrations and rollback plan -- ACP manager API integration across dispatch and commands -- adapter registration interface in plugin runtime bridge -- acpx adapter implementation and tests -- thread-capable channel delivery projection logic with checkpoint replay (Discord first) -- lifecycle hooks for reset/delete/archive/unfocus -- stale-binding detector and operator-facing diagnostics -- config validation and precedence tests for all new ACP keys -- operational docs and troubleshooting runbook - -## Test plan - -Unit tests: - -- ACP DB transaction boundaries (spawn/bind/enqueue atomicity, cancel, close) -- ACP state-machine transition guards for sessions and runs -- idempotency reservation/replay semantics across all ACP commands -- per-session actor serialization and queue ordering -- acpx event parser and chunk coalescer -- runtime supervisor restart and backoff policy -- config precedence and effective TTL calculation -- core ACP routing branch selection and fail-closed behavior when backend/session is invalid - -Integration tests: - -- fake ACP adapter process for deterministic streaming and cancel behavior -- ACP manager + dispatch integration with transactional persistence -- thread-bound inbound routing to ACP session key -- thread-bound outbound delivery suppresses parent channel duplication -- checkpoint replay recovers after delivery failure and resumes from last event -- plugin service registration and teardown of ACP runtime backend - -Gateway e2e tests: - -- spawn ACP with thread, exchange multi-turn prompts, unfocus -- gateway restart with persisted ACP DB and bindings, then continue same session -- concurrent ACP sessions in multiple threads have no cross-talk -- duplicate command retries (same idempotency key) do not create duplicate runs or replies -- stale-binding scenario yields explicit error and optional auto-clean behavior - -## Risks and mitigations - -- Duplicate deliveries during transition - - Mitigation: single destination resolver and idempotent event checkpoint -- Runtime process churn under load - - Mitigation: long lived per session owners + concurrency caps + backoff -- Plugin absent or misconfigured - - Mitigation: explicit operator-facing error and fail-closed ACP routing (no implicit fallback to normal session path) -- Config confusion between subagent and ACP gates - - Mitigation: explicit ACP keys and command feedback that includes effective policy source -- Control-plane store corruption or migration bugs - - Mitigation: WAL mode, backup/restore hooks, migration smoke tests, and read-only fallback diagnostics -- Actor deadlocks or mailbox starvation - - Mitigation: watchdog timers, actor health probes, and bounded mailbox depth with rejection telemetry - -## Acceptance checklist - -- ACP session spawn can create or bind a thread in a supported channel adapter (currently Discord) -- all thread messages route to bound ACP session only -- ACP outputs appear in the same thread identity with streaming or batches -- no duplicate output in parent channel for bound turns -- spawn+bind+initial enqueue are atomic in persistent store -- ACP command retries are idempotent and do not duplicate runs or outputs -- cancel, close, unfocus, archive, reset, and delete perform deterministic cleanup -- crash restart preserves mapping and resumes multi turn continuity -- concurrent thread bound ACP sessions work independently -- ACP backend missing state produces clear actionable error -- stale bindings are detected and surfaced explicitly (with optional safe auto-clean) -- control-plane metrics and diagnostics are available for operators -- new unit, integration, and e2e coverage passes - -## Addendum: targeted refactors for current implementation (status) - -These are non-blocking follow-ups to keep the ACP path maintainable after the current feature set lands. - -### 1) Centralize ACP dispatch policy evaluation (completed) - -- implemented via shared ACP policy helpers in `src/acp/policy.ts` -- dispatch, ACP command lifecycle handlers, and ACP spawn path now consume shared policy logic - -### 2) Split ACP command handler by subcommand domain (completed) - -- `src/auto-reply/reply/commands-acp.ts` is now a thin router -- subcommand behavior is split into: - - `src/auto-reply/reply/commands-acp/lifecycle.ts` - - `src/auto-reply/reply/commands-acp/runtime-options.ts` - - `src/auto-reply/reply/commands-acp/diagnostics.ts` - - shared helpers in `src/auto-reply/reply/commands-acp/shared.ts` - -### 3) Split ACP session manager by responsibility (completed) - -- manager is split into: - - `src/acp/control-plane/manager.ts` (public facade + singleton) - - `src/acp/control-plane/manager.core.ts` (manager implementation) - - `src/acp/control-plane/manager.types.ts` (manager types/deps) - - `src/acp/control-plane/manager.utils.ts` (normalization + helper functions) - -### 4) Optional acpx runtime adapter cleanup - -- `extensions/acpx/src/runtime.ts` can be split into: -- process execution/supervision -- ndjson event parsing/normalization -- runtime API surface (`submit`, `cancel`, `close`, etc.) -- improves testability and makes backend behavior easier to audit diff --git a/docs/experiments/plans/acp-unified-streaming-refactor.md b/docs/experiments/plans/acp-unified-streaming-refactor.md deleted file mode 100644 index 3834fb9f8d8..00000000000 --- a/docs/experiments/plans/acp-unified-streaming-refactor.md +++ /dev/null @@ -1,96 +0,0 @@ ---- -summary: "Holy grail refactor plan for one unified runtime streaming pipeline across main, subagent, and ACP" -owner: "onutc" -status: "draft" -last_updated: "2026-02-25" -title: "Unified Runtime Streaming Refactor Plan" ---- - -# Unified Runtime Streaming Refactor Plan - -## Objective - -Deliver one shared streaming pipeline for `main`, `subagent`, and `acp` so all runtimes get identical coalescing, chunking, delivery ordering, and crash recovery behavior. - -## Why this exists - -- Current behavior is split across multiple runtime-specific shaping paths. -- Formatting/coalescing bugs can be fixed in one path but remain in others. -- Delivery consistency, duplicate suppression, and recovery semantics are harder to reason about. - -## Target architecture - -Single pipeline, runtime-specific adapters: - -1. Runtime adapters emit canonical events only. -2. Shared stream assembler coalesces and finalizes text/tool/status events. -3. Shared channel projector applies channel-specific chunking/formatting once. -4. Shared delivery ledger enforces idempotent send/replay semantics. -5. Outbound channel adapter executes sends and records delivery checkpoints. - -Canonical event contract: - -- `turn_started` -- `text_delta` -- `block_final` -- `tool_started` -- `tool_finished` -- `status` -- `turn_completed` -- `turn_failed` -- `turn_cancelled` - -## Workstreams - -### 1) Canonical streaming contract - -- Define strict event schema + validation in core. -- Add adapter contract tests to guarantee each runtime emits compatible events. -- Reject malformed runtime events early and surface structured diagnostics. - -### 2) Shared stream processor - -- Replace runtime-specific coalescer/projector logic with one processor. -- Processor owns text delta buffering, idle flush, max-chunk splitting, and completion flush. -- Move ACP/main/subagent config resolution into one helper to prevent drift. - -### 3) Shared channel projection - -- Keep channel adapters dumb: accept finalized blocks and send. -- Move Discord-specific chunking quirks to channel projector only. -- Keep pipeline channel-agnostic before projection. - -### 4) Delivery ledger + replay - -- Add per-turn/per-chunk delivery IDs. -- Record checkpoints before and after physical send. -- On restart, replay pending chunks idempotently and avoid duplicates. - -### 5) Migration and cutover - -- Phase 1: shadow mode (new pipeline computes output but old path sends; compare). -- Phase 2: runtime-by-runtime cutover (`acp`, then `subagent`, then `main` or reverse by risk). -- Phase 3: delete legacy runtime-specific streaming code. - -## Non-goals - -- No changes to ACP policy/permissions model in this refactor. -- No channel-specific feature expansion outside projection compatibility fixes. -- No transport/backend redesign (acpx plugin contract remains as-is unless needed for event parity). - -## Risks and mitigations - -- Risk: behavioral regressions in existing main/subagent paths. - Mitigation: shadow mode diffing + adapter contract tests + channel e2e tests. -- Risk: duplicate sends during crash recovery. - Mitigation: durable delivery IDs + idempotent replay in delivery adapter. -- Risk: runtime adapters diverge again. - Mitigation: required shared contract test suite for all adapters. - -## Acceptance criteria - -- All runtimes pass shared streaming contract tests. -- Discord ACP/main/subagent produce equivalent spacing/chunking behavior for tiny deltas. -- Crash/restart replay sends no duplicate chunk for the same delivery ID. -- Legacy ACP projector/coalescer path is removed. -- Streaming config resolution is shared and runtime-independent. diff --git a/docs/experiments/plans/browser-evaluate-cdp-refactor.md b/docs/experiments/plans/browser-evaluate-cdp-refactor.md deleted file mode 100644 index 5832c8a65e6..00000000000 --- a/docs/experiments/plans/browser-evaluate-cdp-refactor.md +++ /dev/null @@ -1,232 +0,0 @@ ---- -summary: "Plan: isolate browser act:evaluate from Playwright queue using CDP, with end-to-end deadlines and safer ref resolution" -read_when: - - Working on browser `act:evaluate` timeout, abort, or queue blocking issues - - Planning CDP based isolation for evaluate execution -owner: "openclaw" -status: "draft" -last_updated: "2026-02-10" -title: "Browser Evaluate CDP Refactor" ---- - -# Browser Evaluate CDP Refactor Plan - -## Context - -`act:evaluate` executes user provided JavaScript in the page. Today it runs via Playwright -(`page.evaluate` or `locator.evaluate`). Playwright serializes CDP commands per page, so a -stuck or long running evaluate can block the page command queue and make every later action -on that tab look "stuck". - -PR #13498 adds a pragmatic safety net (bounded evaluate, abort propagation, and best-effort -recovery). This document describes a larger refactor that makes `act:evaluate` inherently -isolated from Playwright so a stuck evaluate cannot wedge normal Playwright operations. - -## Goals - -- `act:evaluate` cannot permanently block later browser actions on the same tab. -- Timeouts are single source of truth end to end so a caller can rely on a budget. -- Abort and timeout are treated the same way across HTTP and in-process dispatch. -- Element targeting for evaluate is supported without switching everything off Playwright. -- Maintain backward compatibility for existing callers and payloads. - -## Non-goals - -- Replace all browser actions (click, type, wait, etc.) with CDP implementations. -- Remove the existing safety net introduced in PR #13498 (it remains a useful fallback). -- Introduce new unsafe capabilities beyond the existing `browser.evaluateEnabled` gate. -- Add process isolation (worker process/thread) for evaluate. If we still see hard to recover - stuck states after this refactor, that is a follow-up idea. - -## Current Architecture (Why It Gets Stuck) - -At a high level: - -- Callers send `act:evaluate` to the browser control service. -- The route handler calls into Playwright to execute the JavaScript. -- Playwright serializes page commands, so an evaluate that never finishes blocks the queue. -- A stuck queue means later click/type/wait operations on the tab can appear to hang. - -## Proposed Architecture - -### 1. Deadline Propagation - -Introduce a single budget concept and derive everything from it: - -- Caller sets `timeoutMs` (or a deadline in the future). -- The outer request timeout, route handler logic, and the execution budget inside the page - all use the same budget, with small headroom where needed for serialization overhead. -- Abort is propagated as an `AbortSignal` everywhere so cancellation is consistent. - -Implementation direction: - -- Add a small helper (for example `createBudget({ timeoutMs, signal })`) that returns: - - `signal`: the linked AbortSignal - - `deadlineAtMs`: absolute deadline - - `remainingMs()`: remaining budget for child operations -- Use this helper in: - - `src/browser/client-fetch.ts` (HTTP and in-process dispatch) - - `src/node-host/runner.ts` (proxy path) - - browser action implementations (Playwright and CDP) - -### 2. Separate Evaluate Engine (CDP Path) - -Add a CDP based evaluate implementation that does not share Playwright's per page command -queue. The key property is that the evaluate transport is a separate WebSocket connection -and a separate CDP session attached to the target. - -Implementation direction: - -- New module, for example `src/browser/cdp-evaluate.ts`, that: - - Connects to the configured CDP endpoint (browser level socket). - - Uses `Target.attachToTarget({ targetId, flatten: true })` to get a `sessionId`. - - Runs either: - - `Runtime.evaluate` for page level evaluate, or - - `DOM.resolveNode` plus `Runtime.callFunctionOn` for element evaluate. - - On timeout or abort: - - Sends `Runtime.terminateExecution` best-effort for the session. - - Closes the WebSocket and returns a clear error. - -Notes: - -- This still executes JavaScript in the page, so termination can have side effects. The win - is that it does not wedge the Playwright queue, and it is cancelable at the transport - layer by killing the CDP session. - -### 3. Ref Story (Element Targeting Without A Full Rewrite) - -The hard part is element targeting. CDP needs a DOM handle or `backendDOMNodeId`, while -today most browser actions use Playwright locators based on refs from snapshots. - -Recommended approach: keep existing refs, but attach an optional CDP resolvable id. - -#### 3.1 Extend Stored Ref Info - -Extend the stored role ref metadata to optionally include a CDP id: - -- Today: `{ role, name, nth }` -- Proposed: `{ role, name, nth, backendDOMNodeId?: number }` - -This keeps all existing Playwright based actions working and allows CDP evaluate to accept -the same `ref` value when the `backendDOMNodeId` is available. - -#### 3.2 Populate backendDOMNodeId At Snapshot Time - -When producing a role snapshot: - -1. Generate the existing role ref map as today (role, name, nth). -2. Fetch the AX tree via CDP (`Accessibility.getFullAXTree`) and compute a parallel map of - `(role, name, nth) -> backendDOMNodeId` using the same duplicate handling rules. -3. Merge the id back into the stored ref info for the current tab. - -If mapping fails for a ref, leave `backendDOMNodeId` undefined. This makes the feature -best-effort and safe to roll out. - -#### 3.3 Evaluate Behavior With Ref - -In `act:evaluate`: - -- If `ref` is present and has `backendDOMNodeId`, run element evaluate via CDP. -- If `ref` is present but has no `backendDOMNodeId`, fall back to the Playwright path (with - the safety net). - -Optional escape hatch: - -- Extend the request shape to accept `backendDOMNodeId` directly for advanced callers (and - for debugging), while keeping `ref` as the primary interface. - -### 4. Keep A Last Resort Recovery Path - -Even with CDP evaluate, there are other ways to wedge a tab or a connection. Keep the -existing recovery mechanisms (terminate execution + disconnect Playwright) as a last resort -for: - -- legacy callers -- environments where CDP attach is blocked -- unexpected Playwright edge cases - -## Implementation Plan (Single Iteration) - -### Deliverables - -- A CDP based evaluate engine that runs outside the Playwright per-page command queue. -- A single end-to-end timeout/abort budget used consistently by callers and handlers. -- Ref metadata that can optionally carry `backendDOMNodeId` for element evaluate. -- `act:evaluate` prefers the CDP engine when possible and falls back to Playwright when not. -- Tests that prove a stuck evaluate does not wedge later actions. -- Logs/metrics that make failures and fallbacks visible. - -### Implementation Checklist - -1. Add a shared "budget" helper to link `timeoutMs` + upstream `AbortSignal` into: - - a single `AbortSignal` - - an absolute deadline - - a `remainingMs()` helper for downstream operations -2. Update all caller paths to use that helper so `timeoutMs` means the same thing everywhere: - - `src/browser/client-fetch.ts` (HTTP and in-process dispatch) - - `src/node-host/runner.ts` (node proxy path) - - CLI wrappers that call `/act` (add `--timeout-ms` to `browser evaluate`) -3. Implement `src/browser/cdp-evaluate.ts`: - - connect to the browser-level CDP socket - - `Target.attachToTarget` to get a `sessionId` - - run `Runtime.evaluate` for page evaluate - - run `DOM.resolveNode` + `Runtime.callFunctionOn` for element evaluate - - on timeout/abort: best-effort `Runtime.terminateExecution` then close the socket -4. Extend stored role ref metadata to optionally include `backendDOMNodeId`: - - keep existing `{ role, name, nth }` behavior for Playwright actions - - add `backendDOMNodeId?: number` for CDP element targeting -5. Populate `backendDOMNodeId` during snapshot creation (best-effort): - - fetch AX tree via CDP (`Accessibility.getFullAXTree`) - - compute `(role, name, nth) -> backendDOMNodeId` and merge into the stored ref map - - if mapping is ambiguous or missing, leave the id undefined -6. Update `act:evaluate` routing: - - if no `ref`: always use CDP evaluate - - if `ref` resolves to a `backendDOMNodeId`: use CDP element evaluate - - otherwise: fall back to Playwright evaluate (still bounded and abortable) -7. Keep the existing "last resort" recovery path as a fallback, not the default path. -8. Add tests: - - stuck evaluate times out within budget and the next click/type succeeds - - abort cancels evaluate (client disconnect or timeout) and unblocks subsequent actions - - mapping failures cleanly fall back to Playwright -9. Add observability: - - evaluate duration and timeout counters - - terminateExecution usage - - fallback rate (CDP -> Playwright) and reasons - -### Acceptance Criteria - -- A deliberately hung `act:evaluate` returns within the caller budget and does not wedge the - tab for later actions. -- `timeoutMs` behaves consistently across CLI, agent tool, node proxy, and in-process calls. -- If `ref` can be mapped to `backendDOMNodeId`, element evaluate uses CDP; otherwise the - fallback path is still bounded and recoverable. - -## Testing Plan - -- Unit tests: - - `(role, name, nth)` matching logic between role refs and AX tree nodes. - - Budget helper behavior (headroom, remaining time math). -- Integration tests: - - CDP evaluate timeout returns within budget and does not block the next action. - - Abort cancels evaluate and triggers termination best-effort. -- Contract tests: - - Ensure `BrowserActRequest` and `BrowserActResponse` remain compatible. - -## Risks And Mitigations - -- Mapping is imperfect: - - Mitigation: best-effort mapping, fallback to Playwright evaluate, and add debug tooling. -- `Runtime.terminateExecution` has side effects: - - Mitigation: only use on timeout/abort and document the behavior in errors. -- Extra overhead: - - Mitigation: only fetch AX tree when snapshots are requested, cache per target, and keep - CDP session short lived. -- Extension relay limitations: - - Mitigation: use browser level attach APIs when per page sockets are not available, and - keep the current Playwright path as fallback. - -## Open Questions - -- Should the new engine be configurable as `playwright`, `cdp`, or `auto`? -- Do we want to expose a new "nodeRef" format for advanced users, or keep `ref` only? -- How should frame snapshots and selector scoped snapshots participate in AX mapping? diff --git a/docs/experiments/plans/discord-async-inbound-worker.md b/docs/experiments/plans/discord-async-inbound-worker.md deleted file mode 100644 index 70397b51338..00000000000 --- a/docs/experiments/plans/discord-async-inbound-worker.md +++ /dev/null @@ -1,337 +0,0 @@ ---- -summary: "Status and next steps for decoupling Discord gateway listeners from long-running agent turns with a Discord-specific inbound worker" -owner: "openclaw" -status: "in_progress" -last_updated: "2026-03-05" -title: "Discord Async Inbound Worker Plan" ---- - -# Discord Async Inbound Worker Plan - -## Objective - -Remove Discord listener timeout as a user-facing failure mode by making inbound Discord turns asynchronous: - -1. Gateway listener accepts and normalizes inbound events quickly. -2. A Discord run queue stores serialized jobs keyed by the same ordering boundary we use today. -3. A worker executes the actual agent turn outside the Carbon listener lifetime. -4. Replies are delivered back to the originating channel or thread after the run completes. - -This is the long-term fix for queued Discord runs timing out at `channels.discord.eventQueue.listenerTimeout` while the agent run itself is still making progress. - -## Current status - -This plan is partially implemented. - -Already done: - -- Discord listener timeout and Discord run timeout are now separate settings. -- Accepted inbound Discord turns are enqueued into `src/discord/monitor/inbound-worker.ts`. -- The worker now owns the long-running turn instead of the Carbon listener. -- Existing per-route ordering is preserved by queue key. -- Timeout regression coverage exists for the Discord worker path. - -What this means in plain language: - -- the production timeout bug is fixed -- the long-running turn no longer dies just because the Discord listener budget expires -- the worker architecture is not finished yet - -What is still missing: - -- `DiscordInboundJob` is still only partially normalized and still carries live runtime references -- command semantics (`stop`, `new`, `reset`, future session controls) are not yet fully worker-native -- worker observability and operator status are still minimal -- there is still no restart durability - -## Why this exists - -Current behavior ties the full agent turn to the listener lifetime: - -- `src/discord/monitor/listeners.ts` applies the timeout and abort boundary. -- `src/discord/monitor/message-handler.ts` keeps the queued run inside that boundary. -- `src/discord/monitor/message-handler.process.ts` performs media loading, routing, dispatch, typing, draft streaming, and final reply delivery inline. - -That architecture has two bad properties: - -- long but healthy turns can be aborted by the listener watchdog -- users can see no reply even when the downstream runtime would have produced one - -Raising the timeout helps but does not change the failure mode. - -## Non-goals - -- Do not redesign non-Discord channels in this pass. -- Do not broaden this into a generic all-channel worker framework in the first implementation. -- Do not extract a shared cross-channel inbound worker abstraction yet; only share low-level primitives when duplication is obvious. -- Do not add durable crash recovery in the first pass unless needed to land safely. -- Do not change route selection, binding semantics, or ACP policy in this plan. - -## Current constraints - -The current Discord processing path still depends on some live runtime objects that should not stay inside the long-term job payload: - -- Carbon `Client` -- raw Discord event shapes -- in-memory guild history map -- thread binding manager callbacks -- live typing and draft stream state - -We already moved execution onto a worker queue, but the normalization boundary is still incomplete. Right now the worker is "run later in the same process with some of the same live objects," not a fully data-only job boundary. - -## Target architecture - -### 1. Listener stage - -`DiscordMessageListener` remains the ingress point, but its job becomes: - -- run preflight and policy checks -- normalize accepted input into a serializable `DiscordInboundJob` -- enqueue the job into a per-session or per-channel async queue -- return immediately to Carbon once the enqueue succeeds - -The listener should no longer own the end-to-end LLM turn lifetime. - -### 2. Normalized job payload - -Introduce a serializable job descriptor that contains only the data needed to run the turn later. - -Minimum shape: - -- route identity - - `agentId` - - `sessionKey` - - `accountId` - - `channel` -- delivery identity - - destination channel id - - reply target message id - - thread id if present -- sender identity - - sender id, label, username, tag -- channel context - - guild id - - channel name or slug - - thread metadata - - resolved system prompt override -- normalized message body - - base text - - effective message text - - attachment descriptors or resolved media references -- gating decisions - - mention requirement outcome - - command authorization outcome - - bound session or agent metadata if applicable - -The job payload must not contain live Carbon objects or mutable closures. - -Current implementation status: - -- partially done -- `src/discord/monitor/inbound-job.ts` exists and defines the worker handoff -- the payload still contains live Discord runtime context and should be reduced further - -### 3. Worker stage - -Add a Discord-specific worker runner responsible for: - -- reconstructing the turn context from `DiscordInboundJob` -- loading media and any additional channel metadata needed for the run -- dispatching the agent turn -- delivering final reply payloads -- updating status and diagnostics - -Recommended location: - -- `src/discord/monitor/inbound-worker.ts` -- `src/discord/monitor/inbound-job.ts` - -### 4. Ordering model - -Ordering must remain equivalent to today for a given route boundary. - -Recommended key: - -- use the same queue key logic as `resolveDiscordRunQueueKey(...)` - -This preserves existing behavior: - -- one bound agent conversation does not interleave with itself -- different Discord channels can still progress independently - -### 5. Timeout model - -After cutover, there are two separate timeout classes: - -- listener timeout - - only covers normalization and enqueue - - should be short -- run timeout - - optional, worker-owned, explicit, and user-visible - - should not be inherited accidentally from Carbon listener settings - -This removes the current accidental coupling between "Discord gateway listener stayed alive" and "agent run is healthy." - -## Recommended implementation phases - -### Phase 1: normalization boundary - -- Status: partially implemented -- Done: - - extracted `buildDiscordInboundJob(...)` - - added worker handoff tests -- Remaining: - - make `DiscordInboundJob` plain data only - - move live runtime dependencies to worker-owned services instead of per-job payload - - stop rebuilding process context by stitching live listener refs back into the job - -### Phase 2: in-memory worker queue - -- Status: implemented -- Done: - - added `DiscordInboundWorkerQueue` keyed by resolved run queue key - - listener enqueues jobs instead of directly awaiting `processDiscordMessage(...)` - - worker executes jobs in-process, in memory only - -This is the first functional cutover. - -### Phase 3: process split - -- Status: not started -- Move delivery, typing, and draft streaming ownership behind worker-facing adapters. -- Replace direct use of live preflight context with worker context reconstruction. -- Keep `processDiscordMessage(...)` temporarily as a facade if needed, then split it. - -### Phase 4: command semantics - -- Status: not started - Make sure native Discord commands still behave correctly when work is queued: - -- `stop` -- `new` -- `reset` -- any future session-control commands - -The worker queue must expose enough run state for commands to target the active or queued turn. - -### Phase 5: observability and operator UX - -- Status: not started -- emit queue depth and active worker counts into monitor status -- record enqueue time, start time, finish time, and timeout or cancellation reason -- surface worker-owned timeout or delivery failures clearly in logs - -### Phase 6: optional durability follow-up - -- Status: not started - Only after the in-memory version is stable: - -- decide whether queued Discord jobs should survive gateway restart -- if yes, persist job descriptors and delivery checkpoints -- if no, document the explicit in-memory boundary - -This should be a separate follow-up unless restart recovery is required to land. - -## File impact - -Current primary files: - -- `src/discord/monitor/listeners.ts` -- `src/discord/monitor/message-handler.ts` -- `src/discord/monitor/message-handler.preflight.ts` -- `src/discord/monitor/message-handler.process.ts` -- `src/discord/monitor/status.ts` - -Current worker files: - -- `src/discord/monitor/inbound-job.ts` -- `src/discord/monitor/inbound-worker.ts` -- `src/discord/monitor/inbound-job.test.ts` -- `src/discord/monitor/message-handler.queue.test.ts` - -Likely next touch points: - -- `src/auto-reply/dispatch.ts` -- `src/discord/monitor/reply-delivery.ts` -- `src/discord/monitor/thread-bindings.ts` -- `src/discord/monitor/native-command.ts` - -## Next step now - -The next step is to make the worker boundary real instead of partial. - -Do this next: - -1. Move live runtime dependencies out of `DiscordInboundJob` -2. Keep those dependencies on the Discord worker instance instead -3. Reduce queued jobs to plain Discord-specific data: - - route identity - - delivery target - - sender info - - normalized message snapshot - - gating and binding decisions -4. Reconstruct worker execution context from that plain data inside the worker - -In practice, that means: - -- `client` -- `threadBindings` -- `guildHistories` -- `discordRestFetch` -- other mutable runtime-only handles - -should stop living on each queued job and instead live on the worker itself or behind worker-owned adapters. - -After that lands, the next follow-up should be command-state cleanup for `stop`, `new`, and `reset`. - -## Testing plan - -Keep the existing timeout repro coverage in: - -- `src/discord/monitor/message-handler.queue.test.ts` - -Add new tests for: - -1. listener returns after enqueue without awaiting full turn -2. per-route ordering is preserved -3. different channels still run concurrently -4. replies are delivered to the original message destination -5. `stop` cancels the active worker-owned run -6. worker failure produces visible diagnostics without blocking later jobs -7. ACP-bound Discord channels still route correctly under worker execution - -## Risks and mitigations - -- Risk: command semantics drift from current synchronous behavior - Mitigation: land command-state plumbing in the same cutover, not later - -- Risk: reply delivery loses thread or reply-to context - Mitigation: make delivery identity first-class in `DiscordInboundJob` - -- Risk: duplicate sends during retries or queue restarts - Mitigation: keep first pass in-memory only, or add explicit delivery idempotency before persistence - -- Risk: `message-handler.process.ts` becomes harder to reason about during migration - Mitigation: split into normalization, execution, and delivery helpers before or during worker cutover - -## Acceptance criteria - -The plan is complete when: - -1. Discord listener timeout no longer aborts healthy long-running turns. -2. Listener lifetime and agent-turn lifetime are separate concepts in code. -3. Existing per-session ordering is preserved. -4. ACP-bound Discord channels work through the same worker path. -5. `stop` targets the worker-owned run instead of the old listener-owned call stack. -6. Timeout and delivery failures become explicit worker outcomes, not silent listener drops. - -## Remaining landing strategy - -Finish this in follow-up PRs: - -1. make `DiscordInboundJob` plain-data only and move live runtime refs onto the worker -2. clean up command-state ownership for `stop`, `new`, and `reset` -3. add worker observability and operator status -4. decide whether durability is needed or explicitly document the in-memory boundary - -This is still a bounded follow-up if kept Discord-only and if we continue to avoid a premature cross-channel worker abstraction. diff --git a/docs/experiments/plans/openresponses-gateway.md b/docs/experiments/plans/openresponses-gateway.md deleted file mode 100644 index 8ca63c34ec9..00000000000 --- a/docs/experiments/plans/openresponses-gateway.md +++ /dev/null @@ -1,126 +0,0 @@ ---- -summary: "Plan: Add OpenResponses /v1/responses endpoint and deprecate chat completions cleanly" -read_when: - - Designing or implementing `/v1/responses` gateway support - - Planning migration from Chat Completions compatibility -owner: "openclaw" -status: "draft" -last_updated: "2026-01-19" -title: "OpenResponses Gateway Plan" ---- - -# OpenResponses Gateway Integration Plan - -## Context - -OpenClaw Gateway currently exposes a minimal OpenAI-compatible Chat Completions endpoint at -`/v1/chat/completions` (see [OpenAI Chat Completions](/gateway/openai-http-api)). - -Open Responses is an open inference standard based on the OpenAI Responses API. It is designed -for agentic workflows and uses item-based inputs plus semantic streaming events. The OpenResponses -spec defines `/v1/responses`, not `/v1/chat/completions`. - -## Goals - -- Add a `/v1/responses` endpoint that adheres to OpenResponses semantics. -- Keep Chat Completions as a compatibility layer that is easy to disable and eventually remove. -- Standardize validation and parsing with isolated, reusable schemas. - -## Non-goals - -- Full OpenResponses feature parity in the first pass (images, files, hosted tools). -- Replacing internal agent execution logic or tool orchestration. -- Changing the existing `/v1/chat/completions` behavior during the first phase. - -## Research Summary - -Sources: OpenResponses OpenAPI, OpenResponses specification site, and the Hugging Face blog post. - -Key points extracted: - -- `POST /v1/responses` accepts `CreateResponseBody` fields like `model`, `input` (string or - `ItemParam[]`), `instructions`, `tools`, `tool_choice`, `stream`, `max_output_tokens`, and - `max_tool_calls`. -- `ItemParam` is a discriminated union of: - - `message` items with roles `system`, `developer`, `user`, `assistant` - - `function_call` and `function_call_output` - - `reasoning` - - `item_reference` -- Successful responses return a `ResponseResource` with `object: "response"`, `status`, and - `output` items. -- Streaming uses semantic events such as: - - `response.created`, `response.in_progress`, `response.completed`, `response.failed` - - `response.output_item.added`, `response.output_item.done` - - `response.content_part.added`, `response.content_part.done` - - `response.output_text.delta`, `response.output_text.done` -- The spec requires: - - `Content-Type: text/event-stream` - - `event:` must match the JSON `type` field - - terminal event must be literal `[DONE]` -- Reasoning items may expose `content`, `encrypted_content`, and `summary`. -- HF examples include `OpenResponses-Version: latest` in requests (optional header). - -## Proposed Architecture - -- Add `src/gateway/open-responses.schema.ts` containing Zod schemas only (no gateway imports). -- Add `src/gateway/openresponses-http.ts` (or `open-responses-http.ts`) for `/v1/responses`. -- Keep `src/gateway/openai-http.ts` intact as a legacy compatibility adapter. -- Add config `gateway.http.endpoints.responses.enabled` (default `false`). -- Keep `gateway.http.endpoints.chatCompletions.enabled` independent; allow both endpoints to be - toggled separately. -- Emit a startup warning when Chat Completions is enabled to signal legacy status. - -## Deprecation Path for Chat Completions - -- Maintain strict module boundaries: no shared schema types between responses and chat completions. -- Make Chat Completions opt-in by config so it can be disabled without code changes. -- Update docs to label Chat Completions as legacy once `/v1/responses` is stable. -- Optional future step: map Chat Completions requests to the Responses handler for a simpler - removal path. - -## Phase 1 Support Subset - -- Accept `input` as string or `ItemParam[]` with message roles and `function_call_output`. -- Extract system and developer messages into `extraSystemPrompt`. -- Use the most recent `user` or `function_call_output` as the current message for agent runs. -- Reject unsupported content parts (image/file) with `invalid_request_error`. -- Return a single assistant message with `output_text` content. -- Return `usage` with zeroed values until token accounting is wired. - -## Validation Strategy (No SDK) - -- Implement Zod schemas for the supported subset of: - - `CreateResponseBody` - - `ItemParam` + message content part unions - - `ResponseResource` - - Streaming event shapes used by the gateway -- Keep schemas in a single, isolated module to avoid drift and allow future codegen. - -## Streaming Implementation (Phase 1) - -- SSE lines with both `event:` and `data:`. -- Required sequence (minimum viable): - - `response.created` - - `response.output_item.added` - - `response.content_part.added` - - `response.output_text.delta` (repeat as needed) - - `response.output_text.done` - - `response.content_part.done` - - `response.completed` - - `[DONE]` - -## Tests and Verification Plan - -- Add e2e coverage for `/v1/responses`: - - Auth required - - Non-stream response shape - - Stream event ordering and `[DONE]` - - Session routing with headers and `user` -- Keep `src/gateway/openai-http.test.ts` unchanged. -- Manual: curl to `/v1/responses` with `stream: true` and verify event ordering and terminal - `[DONE]`. - -## Doc Updates (Follow-up) - -- Add a new docs page for `/v1/responses` usage and examples. -- Update `/gateway/openai-http-api` with a legacy note and pointer to `/v1/responses`. diff --git a/docs/experiments/plans/pty-process-supervision.md b/docs/experiments/plans/pty-process-supervision.md deleted file mode 100644 index 4ec898058cd..00000000000 --- a/docs/experiments/plans/pty-process-supervision.md +++ /dev/null @@ -1,195 +0,0 @@ ---- -summary: "Production plan for reliable interactive process supervision (PTY + non-PTY) with explicit ownership, unified lifecycle, and deterministic cleanup" -read_when: - - Working on exec/process lifecycle ownership and cleanup - - Debugging PTY and non-PTY supervision behavior -owner: "openclaw" -status: "in-progress" -last_updated: "2026-02-15" -title: "PTY and Process Supervision Plan" ---- - -# PTY and Process Supervision Plan - -## 1. Problem and goal - -We need one reliable lifecycle for long-running command execution across: - -- `exec` foreground runs -- `exec` background runs -- `process` follow up actions (`poll`, `log`, `send-keys`, `paste`, `submit`, `kill`, `remove`) -- CLI agent runner subprocesses - -The goal is not just to support PTY. The goal is predictable ownership, cancellation, timeout, and cleanup with no unsafe process matching heuristics. - -## 2. Scope and boundaries - -- Keep implementation internal in `src/process/supervisor`. -- Do not create a new package for this. -- Keep current behavior compatibility where practical. -- Do not broaden scope to terminal replay or tmux style session persistence. - -## 3. Implemented in this branch - -### Supervisor baseline already present - -- Supervisor module is in place under `src/process/supervisor/*`. -- Exec runtime and CLI runner are already routed through supervisor spawn and wait. -- Registry finalization is idempotent. - -### This pass completed - -1. Explicit PTY command contract - -- `SpawnInput` is now a discriminated union in `src/process/supervisor/types.ts`. -- PTY runs require `ptyCommand` instead of reusing generic `argv`. -- Supervisor no longer rebuilds PTY command strings from argv joins in `src/process/supervisor/supervisor.ts`. -- Exec runtime now passes `ptyCommand` directly in `src/agents/bash-tools.exec-runtime.ts`. - -2. Process layer type decoupling - -- Supervisor types no longer import `SessionStdin` from agents. -- Process local stdin contract lives in `src/process/supervisor/types.ts` (`ManagedRunStdin`). -- Adapters now depend only on process level types: - - `src/process/supervisor/adapters/child.ts` - - `src/process/supervisor/adapters/pty.ts` - -3. Process tool lifecycle ownership improvement - -- `src/agents/bash-tools.process.ts` now requests cancellation through supervisor first. -- `process kill/remove` now use process-tree fallback termination when supervisor lookup misses. -- `remove` keeps deterministic remove behavior by dropping running session entries immediately after termination is requested. - -4. Single source watchdog defaults - -- Added shared defaults in `src/agents/cli-watchdog-defaults.ts`. -- `src/agents/cli-backends.ts` consumes the shared defaults. -- `src/agents/cli-runner/reliability.ts` consumes the same shared defaults. - -5. Dead helper cleanup - -- Removed unused `killSession` helper path from `src/agents/bash-tools.shared.ts`. - -6. Direct supervisor path tests added - -- Added `src/agents/bash-tools.process.supervisor.test.ts` to cover kill and remove routing through supervisor cancellation. - -7. Reliability gap fixes completed - -- `src/agents/bash-tools.process.ts` now falls back to real OS-level process termination when supervisor lookup misses. -- `src/process/supervisor/adapters/child.ts` now uses process-tree termination semantics for default cancel/timeout kill paths. -- Added shared process-tree utility in `src/process/kill-tree.ts`. - -8. PTY contract edge-case coverage added - -- Added `src/process/supervisor/supervisor.pty-command.test.ts` for verbatim PTY command forwarding and empty-command rejection. -- Added `src/process/supervisor/adapters/child.test.ts` for process-tree kill behavior in child adapter cancellation. - -## 4. Remaining gaps and decisions - -### Reliability status - -The two required reliability gaps for this pass are now closed: - -- `process kill/remove` now has a real OS termination fallback when supervisor lookup misses. -- child cancel/timeout now uses process-tree kill semantics for default kill path. -- Regression tests were added for both behaviors. - -### Durability and startup reconciliation - -Restart behavior is now explicitly defined as in-memory lifecycle only. - -- `reconcileOrphans()` remains a no-op in `src/process/supervisor/supervisor.ts` by design. -- Active runs are not recovered after process restart. -- This boundary is intentional for this implementation pass to avoid partial persistence risks. - -### Maintainability follow-ups - -1. `runExecProcess` in `src/agents/bash-tools.exec-runtime.ts` still handles multiple responsibilities and can be split into focused helpers in a follow-up. - -## 5. Implementation plan - -The implementation pass for required reliability and contract items is complete. - -Completed: - -- `process kill/remove` fallback real termination -- process-tree cancellation for child adapter default kill path -- regression tests for fallback kill and child adapter kill path -- PTY command edge-case tests under explicit `ptyCommand` -- explicit in-memory restart boundary with `reconcileOrphans()` no-op by design - -Optional follow-up: - -- split `runExecProcess` into focused helpers with no behavior drift - -## 6. File map - -### Process supervisor - -- `src/process/supervisor/types.ts` updated with discriminated spawn input and process local stdin contract. -- `src/process/supervisor/supervisor.ts` updated to use explicit `ptyCommand`. -- `src/process/supervisor/adapters/child.ts` and `src/process/supervisor/adapters/pty.ts` decoupled from agent types. -- `src/process/supervisor/registry.ts` idempotent finalize unchanged and retained. - -### Exec and process integration - -- `src/agents/bash-tools.exec-runtime.ts` updated to pass PTY command explicitly and keep fallback path. -- `src/agents/bash-tools.process.ts` updated to cancel via supervisor with real process-tree fallback termination. -- `src/agents/bash-tools.shared.ts` removed direct kill helper path. - -### CLI reliability - -- `src/agents/cli-watchdog-defaults.ts` added as shared baseline. -- `src/agents/cli-backends.ts` and `src/agents/cli-runner/reliability.ts` now consume same defaults. - -## 7. Validation run in this pass - -Unit tests: - -- `pnpm vitest src/process/supervisor/registry.test.ts` -- `pnpm vitest src/process/supervisor/supervisor.test.ts` -- `pnpm vitest src/process/supervisor/supervisor.pty-command.test.ts` -- `pnpm vitest src/process/supervisor/adapters/child.test.ts` -- `pnpm vitest src/agents/cli-backends.test.ts` -- `pnpm vitest src/agents/bash-tools.exec.pty-cleanup.test.ts` -- `pnpm vitest src/agents/bash-tools.process.poll-timeout.test.ts` -- `pnpm vitest src/agents/bash-tools.process.supervisor.test.ts` -- `pnpm vitest src/process/exec.test.ts` - -E2E targets: - -- `pnpm vitest src/agents/cli-runner.test.ts` -- `pnpm vitest run src/agents/bash-tools.exec.pty-fallback.test.ts src/agents/bash-tools.exec.background-abort.test.ts src/agents/bash-tools.process.send-keys.test.ts` - -Typecheck note: - -- Use `pnpm build` (and `pnpm check` for full lint/docs gate) in this repo. Older notes that mention `pnpm tsgo` are obsolete. - -## 8. Operational guarantees preserved - -- Exec env hardening behavior is unchanged. -- Approval and allowlist flow is unchanged. -- Output sanitization and output caps are unchanged. -- PTY adapter still guarantees wait settlement on forced kill and listener disposal. - -## 9. Definition of done - -1. Supervisor is lifecycle owner for managed runs. -2. PTY spawn uses explicit command contract with no argv reconstruction. -3. Process layer has no type dependency on agent layer for supervisor stdin contracts. -4. Watchdog defaults are single source. -5. Targeted unit and e2e tests remain green. -6. Restart durability boundary is explicitly documented or fully implemented. - -## 10. Summary - -The branch now has a coherent and safer supervision shape: - -- explicit PTY contract -- cleaner process layering -- supervisor driven cancellation path for process operations -- real fallback termination when supervisor lookup misses -- process-tree cancellation for child-run default kill paths -- unified watchdog defaults -- explicit in-memory restart boundary (no orphan reconciliation across restart in this pass) diff --git a/docs/experiments/plans/session-binding-channel-agnostic.md b/docs/experiments/plans/session-binding-channel-agnostic.md deleted file mode 100644 index aa1f926b36b..00000000000 --- a/docs/experiments/plans/session-binding-channel-agnostic.md +++ /dev/null @@ -1,226 +0,0 @@ ---- -summary: "Channel agnostic session binding architecture and iteration 1 delivery scope" -read_when: - - Refactoring channel-agnostic session routing and bindings - - Investigating duplicate, stale, or missing session delivery across channels -owner: "onutc" -status: "in-progress" -last_updated: "2026-02-21" -title: "Session Binding Channel Agnostic Plan" ---- - -# Session Binding Channel Agnostic Plan - -## Overview - -This document defines the long term channel agnostic session binding model and the concrete scope for the next implementation iteration. - -Goal: - -- make subagent bound session routing a core capability -- keep channel specific behavior in adapters -- avoid regressions in normal Discord behavior - -## Why this exists - -Current behavior mixes: - -- completion content policy -- destination routing policy -- Discord specific details - -This caused edge cases such as: - -- duplicate main and thread delivery under concurrent runs -- stale token usage on reused binding managers -- missing activity accounting for webhook sends - -## Iteration 1 scope - -This iteration is intentionally limited. - -### 1. Add channel agnostic core interfaces - -Add core types and service interfaces for bindings and routing. - -Proposed core types: - -```ts -export type BindingTargetKind = "subagent" | "session"; -export type BindingStatus = "active" | "ending" | "ended"; - -export type ConversationRef = { - channel: string; - accountId: string; - conversationId: string; - parentConversationId?: string; -}; - -export type SessionBindingRecord = { - bindingId: string; - targetSessionKey: string; - targetKind: BindingTargetKind; - conversation: ConversationRef; - status: BindingStatus; - boundAt: number; - expiresAt?: number; - metadata?: Record; -}; -``` - -Core service contract: - -```ts -export interface SessionBindingService { - bind(input: { - targetSessionKey: string; - targetKind: BindingTargetKind; - conversation: ConversationRef; - metadata?: Record; - ttlMs?: number; - }): Promise; - - listBySession(targetSessionKey: string): SessionBindingRecord[]; - resolveByConversation(ref: ConversationRef): SessionBindingRecord | null; - touch(bindingId: string, at?: number): void; - unbind(input: { - bindingId?: string; - targetSessionKey?: string; - reason: string; - }): Promise; -} -``` - -### 2. Add one core delivery router for subagent completions - -Add a single destination resolution path for completion events. - -Router contract: - -```ts -export interface BoundDeliveryRouter { - resolveDestination(input: { - eventKind: "task_completion"; - targetSessionKey: string; - requester?: ConversationRef; - failClosed: boolean; - }): { - binding: SessionBindingRecord | null; - mode: "bound" | "fallback"; - reason: string; - }; -} -``` - -For this iteration: - -- only `task_completion` is routed through this new path -- existing paths for other event kinds remain as-is - -### 3. Keep Discord as adapter - -Discord remains the first adapter implementation. - -Adapter responsibilities: - -- create/reuse thread conversations -- send bound messages via webhook or channel send -- validate thread state (archived/deleted) -- map adapter metadata (webhook identity, thread ids) - -### 4. Fix currently known correctness issues - -Required in this iteration: - -- refresh token usage when reusing existing thread binding manager -- record outbound activity for webhook based Discord sends -- stop implicit main channel fallback when a bound thread destination is selected for session mode completion - -### 5. Preserve current runtime safety defaults - -No behavior change for users with thread bound spawn disabled. - -Defaults stay: - -- `channels.discord.threadBindings.spawnSubagentSessions = false` - -Result: - -- normal Discord users stay on current behavior -- new core path affects only bound session completion routing where enabled - -## Not in iteration 1 - -Explicitly deferred: - -- ACP binding targets (`targetKind: "acp"`) -- new channel adapters beyond Discord -- global replacement of all delivery paths (`spawn_ack`, future `subagent_message`) -- protocol level changes -- store migration/versioning redesign for all binding persistence - -Notes on ACP: - -- interface design keeps room for ACP -- ACP implementation is not started in this iteration - -## Routing invariants - -These invariants are mandatory for iteration 1. - -- destination selection and content generation are separate steps -- if session mode completion resolves to an active bound destination, delivery must target that destination -- no hidden reroute from bound destination to main channel -- fallback behavior must be explicit and observable - -## Compatibility and rollout - -Compatibility target: - -- no regression for users with thread bound spawning off -- no change to non-Discord channels in this iteration - -Rollout: - -1. Land interfaces and router behind current feature gates. -2. Route Discord completion mode bound deliveries through router. -3. Keep legacy path for non-bound flows. -4. Verify with targeted tests and canary runtime logs. - -## Tests required in iteration 1 - -Unit and integration coverage required: - -- manager token rotation uses latest token after manager reuse -- webhook sends update channel activity timestamps -- two active bound sessions in same requester channel do not duplicate to main channel -- completion for bound session mode run resolves to thread destination only -- disabled spawn flag keeps legacy behavior unchanged - -## Proposed implementation files - -Core: - -- `src/infra/outbound/session-binding-service.ts` (new) -- `src/infra/outbound/bound-delivery-router.ts` (new) -- `src/agents/subagent-announce.ts` (completion destination resolution integration) - -Discord adapter and runtime: - -- `src/discord/monitor/thread-bindings.manager.ts` -- `src/discord/monitor/reply-delivery.ts` -- `src/discord/send.outbound.ts` - -Tests: - -- `src/discord/monitor/provider*.test.ts` -- `src/discord/monitor/reply-delivery.test.ts` -- `src/agents/subagent-announce.format.test.ts` - -## Done criteria for iteration 1 - -- core interfaces exist and are wired for completion routing -- correctness fixes above are merged with tests -- no main and thread duplicate completion delivery in session mode bound runs -- no behavior change for disabled bound spawn deployments -- ACP remains explicitly deferred diff --git a/docs/experiments/proposals/acp-bound-command-auth.md b/docs/experiments/proposals/acp-bound-command-auth.md deleted file mode 100644 index 1d02e9e8469..00000000000 --- a/docs/experiments/proposals/acp-bound-command-auth.md +++ /dev/null @@ -1,89 +0,0 @@ ---- -summary: "Proposal: long-term command authorization model for ACP-bound conversations" -read_when: - - Designing native command auth behavior in Telegram/Discord ACP-bound channels/topics -title: "ACP Bound Command Authorization (Proposal)" ---- - -# ACP Bound Command Authorization (Proposal) - -Status: Proposed, **not implemented yet**. - -This document describes a long-term authorization model for native commands in -ACP-bound conversations. It is an experiments proposal and does not replace -current production behavior. - -For implemented behavior, read source and tests in: - -- `src/telegram/bot-native-commands.ts` -- `src/discord/monitor/native-command.ts` -- `src/auto-reply/reply/commands-core.ts` - -## Problem - -Today we have command-specific checks (for example `/new` and `/reset`) that -need to work inside ACP-bound channels/topics even when allowlists are empty. -This solves immediate UX pain, but command-name-based exceptions do not scale. - -## Long-term shape - -Move command authorization from ad-hoc handler logic to command metadata plus a -shared policy evaluator. - -### 1) Add auth policy metadata to command definitions - -Each command definition should declare an auth policy. Example shape: - -```ts -type CommandAuthPolicy = - | { mode: "owner_or_allowlist" } // default, current strict behavior - | { mode: "bound_acp_or_owner_or_allowlist" } // allow in explicitly bound ACP conversations - | { mode: "owner_only" }; -``` - -`/new` and `/reset` would use `bound_acp_or_owner_or_allowlist`. -Most other commands would remain `owner_or_allowlist`. - -### 2) Share one evaluator across channels - -Introduce one helper that evaluates command auth using: - -- command policy metadata -- sender authorization state -- resolved conversation binding state - -Both Telegram and Discord native handlers should call the same helper to avoid -behavior drift. - -### 3) Use binding-match as the bypass boundary - -When policy allows bound ACP bypass, authorize only if a configured binding -match was resolved for the current conversation (not just because current -session key looks ACP-like). - -This keeps the boundary explicit and minimizes accidental widening. - -## Why this is better - -- Scales to future commands without adding more command-name conditionals. -- Keeps behavior consistent across channels. -- Preserves current security model by requiring explicit binding match. -- Keeps allowlists optional hardening instead of a universal requirement. - -## Rollout plan (future) - -1. Add command auth policy field to command registry types and command data. -2. Implement shared evaluator and migrate Telegram + Discord native handlers. -3. Move `/new` and `/reset` to metadata-driven policy. -4. Add tests per policy mode and channel surface. - -## Non-goals - -- This proposal does not change ACP session lifecycle behavior. -- This proposal does not require allowlists for all ACP-bound commands. -- This proposal does not change existing route binding semantics. - -## Note - -This proposal is intentionally additive and does not delete or replace existing -experiments documents. diff --git a/docs/experiments/proposals/model-config.md b/docs/experiments/proposals/model-config.md deleted file mode 100644 index 6a0ef6524b0..00000000000 --- a/docs/experiments/proposals/model-config.md +++ /dev/null @@ -1,36 +0,0 @@ ---- -summary: "Exploration: model config, auth profiles, and fallback behavior" -read_when: - - Exploring future model selection + auth profile ideas -title: "Model Config Exploration" ---- - -# Model Config (Exploration) - -This document captures **ideas** for future model configuration. It is not a -shipping spec. For current behavior, see: - -- [Models](/concepts/models) -- [Model failover](/concepts/model-failover) -- [OAuth + profiles](/concepts/oauth) - -## Motivation - -Operators want: - -- Multiple auth profiles per provider (personal vs work). -- Simple `/model` selection with predictable fallbacks. -- Clear separation between text models and image-capable models. - -## Possible direction (high level) - -- Keep model selection simple: `provider/model` with optional aliases. -- Let providers have multiple auth profiles, with an explicit order. -- Use a global fallback list so all sessions fail over consistently. -- Only override image routing when explicitly configured. - -## Open questions - -- Should profile rotation be per-provider or per-model? -- How should the UI surface profile selection for a session? -- What is the safest migration path from legacy config keys? diff --git a/docs/experiments/research/memory.md b/docs/experiments/research/memory.md deleted file mode 100644 index 99135e78be9..00000000000 --- a/docs/experiments/research/memory.md +++ /dev/null @@ -1,228 +0,0 @@ ---- -summary: "Research notes: offline memory system for Clawd workspaces (Markdown source-of-truth + derived index)" -read_when: - - Designing workspace memory (~/.openclaw/workspace) beyond daily Markdown logs - - Deciding: standalone CLI vs deep OpenClaw integration - - Adding offline recall + reflection (retain/recall/reflect) -title: "Workspace Memory Research" ---- - -# Workspace Memory v2 (offline): research notes - -Target: Clawd-style workspace (`agents.defaults.workspace`, default `~/.openclaw/workspace`) where “memory” is stored as one Markdown file per day (`memory/YYYY-MM-DD.md`) plus a small set of stable files (e.g. `memory.md`, `SOUL.md`). - -This doc proposes an **offline-first** memory architecture that keeps Markdown as the canonical, reviewable source of truth, but adds **structured recall** (search, entity summaries, confidence updates) via a derived index. - -## Why change? - -The current setup (one file per day) is excellent for: - -- “append-only” journaling -- human editing -- git-backed durability + auditability -- low-friction capture (“just write it down”) - -It’s weak for: - -- high-recall retrieval (“what did we decide about X?”, “last time we tried Y?”) -- entity-centric answers (“tell me about Alice / The Castle / warelay”) without rereading many files -- opinion/preference stability (and evidence when it changes) -- time constraints (“what was true during Nov 2025?”) and conflict resolution - -## Design goals - -- **Offline**: works without network; can run on laptop/Castle; no cloud dependency. -- **Explainable**: retrieved items should be attributable (file + location) and separable from inference. -- **Low ceremony**: daily logging stays Markdown, no heavy schema work. -- **Incremental**: v1 is useful with FTS only; semantic/vector and graphs are optional upgrades. -- **Agent-friendly**: makes “recall within token budgets” easy (return small bundles of facts). - -## North star model (Hindsight × Letta) - -Two pieces to blend: - -1. **Letta/MemGPT-style control loop** - -- keep a small “core” always in context (persona + key user facts) -- everything else is out-of-context and retrieved via tools -- memory writes are explicit tool calls (append/replace/insert), persisted, then re-injected next turn - -2. **Hindsight-style memory substrate** - -- separate what’s observed vs what’s believed vs what’s summarized -- support retain/recall/reflect -- confidence-bearing opinions that can evolve with evidence -- entity-aware retrieval + temporal queries (even without full knowledge graphs) - -## Proposed architecture (Markdown source-of-truth + derived index) - -### Canonical store (git-friendly) - -Keep `~/.openclaw/workspace` as canonical human-readable memory. - -Suggested workspace layout: - -``` -~/.openclaw/workspace/ - memory.md # small: durable facts + preferences (core-ish) - memory/ - YYYY-MM-DD.md # daily log (append; narrative) - bank/ # “typed” memory pages (stable, reviewable) - world.md # objective facts about the world - experience.md # what the agent did (first-person) - opinions.md # subjective prefs/judgments + confidence + evidence pointers - entities/ - Peter.md - The-Castle.md - warelay.md - ... -``` - -Notes: - -- **Daily log stays daily log**. No need to turn it into JSON. -- The `bank/` files are **curated**, produced by reflection jobs, and can still be edited by hand. -- `memory.md` remains “small + core-ish”: the things you want Clawd to see every session. - -### Derived store (machine recall) - -Add a derived index under the workspace (not necessarily git tracked): - -``` -~/.openclaw/workspace/.memory/index.sqlite -``` - -Back it with: - -- SQLite schema for facts + entity links + opinion metadata -- SQLite **FTS5** for lexical recall (fast, tiny, offline) -- optional embeddings table for semantic recall (still offline) - -The index is always **rebuildable from Markdown**. - -## Retain / Recall / Reflect (operational loop) - -### Retain: normalize daily logs into “facts” - -Hindsight’s key insight that matters here: store **narrative, self-contained facts**, not tiny snippets. - -Practical rule for `memory/YYYY-MM-DD.md`: - -- at end of day (or during), add a `## Retain` section with 2–5 bullets that are: - - narrative (cross-turn context preserved) - - self-contained (standalone makes sense later) - - tagged with type + entity mentions - -Example: - -``` -## Retain -- W @Peter: Currently in Marrakech (Nov 27–Dec 1, 2025) for Andy’s birthday. -- B @warelay: I fixed the Baileys WS crash by wrapping connection.update handlers in try/catch (see memory/2025-11-27.md). -- O(c=0.95) @Peter: Prefers concise replies (<1500 chars) on WhatsApp; long content goes into files. -``` - -Minimal parsing: - -- Type prefix: `W` (world), `B` (experience/biographical), `O` (opinion), `S` (observation/summary; usually generated) -- Entities: `@Peter`, `@warelay`, etc (slugs map to `bank/entities/*.md`) -- Opinion confidence: `O(c=0.0..1.0)` optional - -If you don’t want authors to think about it: the reflect job can infer these bullets from the rest of the log, but having an explicit `## Retain` section is the easiest “quality lever”. - -### Recall: queries over the derived index - -Recall should support: - -- **lexical**: “find exact terms / names / commands” (FTS5) -- **entity**: “tell me about X” (entity pages + entity-linked facts) -- **temporal**: “what happened around Nov 27” / “since last week” -- **opinion**: “what does Peter prefer?” (with confidence + evidence) - -Return format should be agent-friendly and cite sources: - -- `kind` (`world|experience|opinion|observation`) -- `timestamp` (source day, or extracted time range if present) -- `entities` (`["Peter","warelay"]`) -- `content` (the narrative fact) -- `source` (`memory/2025-11-27.md#L12` etc) - -### Reflect: produce stable pages + update beliefs - -Reflection is a scheduled job (daily or heartbeat `ultrathink`) that: - -- updates `bank/entities/*.md` from recent facts (entity summaries) -- updates `bank/opinions.md` confidence based on reinforcement/contradiction -- optionally proposes edits to `memory.md` (“core-ish” durable facts) - -Opinion evolution (simple, explainable): - -- each opinion has: - - statement - - confidence `c ∈ [0,1]` - - last_updated - - evidence links (supporting + contradicting fact IDs) -- when new facts arrive: - - find candidate opinions by entity overlap + similarity (FTS first, embeddings later) - - update confidence by small deltas; big jumps require strong contradiction + repeated evidence - -## CLI integration: standalone vs deep integration - -Recommendation: **deep integration in OpenClaw**, but keep a separable core library. - -### Why integrate into OpenClaw? - -- OpenClaw already knows: - - the workspace path (`agents.defaults.workspace`) - - the session model + heartbeats - - logging + troubleshooting patterns -- You want the agent itself to call the tools: - - `openclaw memory recall "…" --k 25 --since 30d` - - `openclaw memory reflect --since 7d` - -### Why still split a library? - -- keep memory logic testable without gateway/runtime -- reuse from other contexts (local scripts, future desktop app, etc.) - -Shape: -The memory tooling is intended to be a small CLI + library layer, but this is exploratory only. - -## “S-Collide” / SuCo: when to use it (research) - -If “S-Collide” refers to **SuCo (Subspace Collision)**: it’s an ANN retrieval approach that targets strong recall/latency tradeoffs by using learned/structured collisions in subspaces (paper: arXiv 2411.14754, 2024). - -Pragmatic take for `~/.openclaw/workspace`: - -- **don’t start** with SuCo. -- start with SQLite FTS + (optional) simple embeddings; you’ll get most UX wins immediately. -- consider SuCo/HNSW/ScaNN-class solutions only once: - - corpus is big (tens/hundreds of thousands of chunks) - - brute-force embedding search becomes too slow - - recall quality is meaningfully bottlenecked by lexical search - -Offline-friendly alternatives (in increasing complexity): - -- SQLite FTS5 + metadata filters (zero ML) -- Embeddings + brute force (works surprisingly far if chunk count is low) -- HNSW index (common, robust; needs a library binding) -- SuCo (research-grade; attractive if there’s a solid implementation you can embed) - -Open question: - -- what’s the **best** offline embedding model for “personal assistant memory” on your machines (laptop + desktop)? - - if you already have Ollama: embed with a local model; otherwise ship a small embedding model in the toolchain. - -## Smallest useful pilot - -If you want a minimal, still-useful version: - -- Add `bank/` entity pages and a `## Retain` section in daily logs. -- Use SQLite FTS for recall with citations (path + line numbers). -- Add embeddings only if recall quality or scale demands it. - -## References - -- Letta / MemGPT concepts: “core memory blocks” + “archival memory” + tool-driven self-editing memory. -- Hindsight Technical Report: “retain / recall / reflect”, four-network memory, narrative fact extraction, opinion confidence evolution. -- SuCo: arXiv 2411.14754 (2024): “Subspace Collision” approximate nearest neighbor retrieval. diff --git a/docs/start/hubs.md b/docs/start/hubs.md index 882f547f65a..fb3357a46aa 100644 --- a/docs/start/hubs.md +++ b/docs/start/hubs.md @@ -176,12 +176,6 @@ Use these hubs to discover every page, including deep dives and reference docs t - [Templates: TOOLS](/reference/templates/TOOLS) - [Templates: USER](/reference/templates/USER) -## Experiments (exploratory) - -- [Onboarding config protocol](/experiments/onboarding-config-protocol) -- [Research: memory](/experiments/research/memory) -- [Model config exploration](/experiments/proposals/model-config) - ## Project - [Credits](/reference/credits) diff --git a/docs/zh-CN/experiments/onboarding-config-protocol.md b/docs/zh-CN/experiments/onboarding-config-protocol.md deleted file mode 100644 index 991801871ef..00000000000 --- a/docs/zh-CN/experiments/onboarding-config-protocol.md +++ /dev/null @@ -1,47 +0,0 @@ ---- -read_when: Changing onboarding wizard steps or config schema endpoints -summary: 新手引导向导和配置模式的 RPC 协议说明 -title: 新手引导和配置协议 -x-i18n: - generated_at: "2026-02-03T07:47:10Z" - model: claude-opus-4-5 - provider: pi - source_hash: 55163b3ee029c02476800cb616a054e5adfe97dae5bb72f2763dce0079851e06 - source_path: experiments/onboarding-config-protocol.md - workflow: 15 ---- - -# 新手引导 + 配置协议 - -目的:CLI、macOS 应用和 Web UI 之间共享的新手引导 + 配置界面。 - -## 组件 - -- 向导引擎(共享会话 + 提示 + 新手引导状态)。 -- CLI 新手引导使用与 UI 客户端相同的向导流程。 -- Gateway 网关 RPC 公开向导 + 配置模式端点。 -- macOS 新手引导使用向导步骤模型。 -- Web UI 从 JSON Schema + UI 提示渲染配置表单。 - -## Gateway 网关 RPC - -- `wizard.start` 参数:`{ mode?: "local"|"remote", workspace?: string }` -- `wizard.next` 参数:`{ sessionId, answer?: { stepId, value? } }` -- `wizard.cancel` 参数:`{ sessionId }` -- `wizard.status` 参数:`{ sessionId }` -- `config.schema` 参数:`{}` - -响应(结构) - -- 向导:`{ sessionId, done, step?, status?, error? }` -- 配置模式:`{ schema, uiHints, version, generatedAt }` - -## UI 提示 - -- `uiHints` 按路径键入;可选元数据(label/help/group/order/advanced/sensitive/placeholder)。 -- 敏感字段渲染为密码输入;无脱敏层。 -- 不支持的模式节点回退到原始 JSON 编辑器。 - -## 注意 - -- 本文档是跟踪新手引导/配置协议重构的唯一位置。 diff --git a/docs/zh-CN/experiments/plans/cron-add-hardening.md b/docs/zh-CN/experiments/plans/cron-add-hardening.md deleted file mode 100644 index c1dcf1d53bd..00000000000 --- a/docs/zh-CN/experiments/plans/cron-add-hardening.md +++ /dev/null @@ -1,70 +0,0 @@ ---- -last_updated: "2026-01-05" -owner: openclaw -status: complete -summary: 加固 cron.add 输入处理,对齐 schema,改进 cron UI/智能体工具 -title: Cron Add 加固 -x-i18n: - generated_at: "2026-02-03T07:47:26Z" - model: claude-opus-4-5 - provider: pi - source_hash: d7e469674bd9435b846757ea0d5dc8f174eaa8533917fc013b1ef4f82859496d - source_path: experiments/plans/cron-add-hardening.md - workflow: 15 ---- - -# Cron Add 加固 & Schema 对齐 - -## 背景 - -最近的 Gateway 网关日志显示重复的 `cron.add` 失败,参数无效(缺少 `sessionTarget`、`wakeMode`、`payload`,以及格式错误的 `schedule`)。这表明至少有一个客户端(可能是智能体工具调用路径)正在发送包装的或部分指定的任务负载。另外,TypeScript 中的 cron 提供商枚举、Gateway 网关 schema、CLI 标志和 UI 表单类型之间存在漂移,加上 `cron.status` 的 UI 不匹配(期望 `jobCount` 而 Gateway 网关返回 `jobs`)。 - -## 目标 - -- 通过规范化常见的包装负载并推断缺失的 `kind` 字段来停止 `cron.add` INVALID_REQUEST 垃圾。 -- 在 Gateway 网关 schema、cron 类型、CLI 文档和 UI 表单之间对齐 cron 提供商列表。 -- 使智能体 cron 工具 schema 明确,以便 LLM 生成正确的任务负载。 -- 修复 Control UI cron 状态任务计数显示。 -- 添加测试以覆盖规范化和工具行为。 - -## 非目标 - -- 更改 cron 调度语义或任务执行行为。 -- 添加新的调度类型或 cron 表达式解析。 -- 除了必要的字段修复外,不大改 cron 的 UI/UX。 - -## 发现(当前差距) - -- Gateway 网关中的 `CronPayloadSchema` 排除了 `signal` + `imessage`,而 TS 类型包含它们。 -- Control UI CronStatus 期望 `jobCount`,但 Gateway 网关返回 `jobs`。 -- 智能体 cron 工具 schema 允许任意 `job` 对象,导致格式错误的输入。 -- Gateway 网关严格验证 `cron.add` 而不进行规范化,因此包装的负载会失败。 - -## 变更内容 - -- `cron.add` 和 `cron.update` 现在规范化常见的包装形式并推断缺失的 `kind` 字段。 -- 智能体 cron 工具 schema 与 Gateway 网关 schema 匹配,减少无效负载。 -- 提供商枚举在 Gateway 网关、CLI、UI 和 macOS 选择器之间对齐。 -- Control UI 使用 Gateway 网关的 `jobs` 计数字段显示状态。 - -## 当前行为 - -- **规范化:**包装的 `data`/`job` 负载被解包;`schedule.kind` 和 `payload.kind` 在安全时被推断。 -- **默认值:**当缺失时,为 `wakeMode` 和 `sessionTarget` 应用安全默认值。 -- **提供商:**Discord/Slack/Signal/iMessage 现在在 CLI/UI 中一致显示。 - -参见 [Cron 任务](/automation/cron-jobs) 了解规范化的形式和示例。 - -## 验证 - -- 观察 Gateway 网关日志中 `cron.add` INVALID_REQUEST 错误是否减少。 -- 确认 Control UI cron 状态在刷新后显示任务计数。 - -## 可选后续工作 - -- 手动 Control UI 冒烟测试:为每个提供商添加一个 cron 任务 + 验证状态任务计数。 - -## 开放问题 - -- `cron.add` 是否应该接受来自客户端的显式 `state`(当前被 schema 禁止)? -- 我们是否应该允许 `webchat` 作为显式投递提供商(当前在投递解析中被过滤)? diff --git a/docs/zh-CN/experiments/plans/group-policy-hardening.md b/docs/zh-CN/experiments/plans/group-policy-hardening.md deleted file mode 100644 index afbb8b39d6a..00000000000 --- a/docs/zh-CN/experiments/plans/group-policy-hardening.md +++ /dev/null @@ -1,45 +0,0 @@ ---- -read_when: - - 查看历史 Telegram 允许列表更改 -summary: Telegram 允许列表加固:前缀 + 空白规范化 -title: Telegram 允许列表加固 -x-i18n: - generated_at: "2026-02-03T07:47:16Z" - model: claude-opus-4-5 - provider: pi - source_hash: a2eca5fcc85376948cfe1b6044f1a8bc69c7f0eb94d1ceafedc1e507ba544162 - source_path: experiments/plans/group-policy-hardening.md - workflow: 15 ---- - -# Telegram 允许列表加固 - -**日期**:2026-01-05 -**状态**:已完成 -**PR**:#216 - -## 摘要 - -Telegram 允许列表现在不区分大小写地接受 `telegram:` 和 `tg:` 前缀,并容忍意外的空白。这使入站允许列表检查与出站发送规范化保持一致。 - -## 更改内容 - -- 前缀 `telegram:` 和 `tg:` 被同等对待(不区分大小写)。 -- 允许列表条目会被修剪;空条目会被忽略。 - -## 示例 - -以下所有形式都被接受为同一 ID: - -- `telegram:123456` -- `TG:123456` -- `tg:123456` - -## 为什么重要 - -从日志或聊天 ID 复制/粘贴通常会包含前缀和空白。规范化可避免在决定是否在私信或群组中响应时出现误判。 - -## 相关文档 - -- [群聊](/channels/groups) -- [Telegram 提供商](/channels/telegram) diff --git a/docs/zh-CN/experiments/plans/openresponses-gateway.md b/docs/zh-CN/experiments/plans/openresponses-gateway.md deleted file mode 100644 index 797da3d91af..00000000000 --- a/docs/zh-CN/experiments/plans/openresponses-gateway.md +++ /dev/null @@ -1,121 +0,0 @@ ---- -last_updated: "2026-01-19" -owner: openclaw -status: draft -summary: 计划:添加 OpenResponses /v1/responses 端点并干净地弃用 chat completions -title: OpenResponses Gateway 网关计划 -x-i18n: - generated_at: "2026-02-03T07:47:33Z" - model: claude-opus-4-5 - provider: pi - source_hash: 71a22c48397507d1648b40766a3153e420c54f2a2d5186d07e51eb3d12e4636a - source_path: experiments/plans/openresponses-gateway.md - workflow: 15 ---- - -# OpenResponses Gateway 网关集成计划 - -## 背景 - -OpenClaw Gateway 网关目前在 `/v1/chat/completions` 暴露了一个最小的 OpenAI 兼容 Chat Completions 端点(参见 [OpenAI Chat Completions](/gateway/openai-http-api))。 - -Open Responses 是基于 OpenAI Responses API 的开放推理标准。它专为智能体工作流设计,使用基于项目的输入加语义流式事件。OpenResponses 规范定义的是 `/v1/responses`,而不是 `/v1/chat/completions`。 - -## 目标 - -- 添加一个遵循 OpenResponses 语义的 `/v1/responses` 端点。 -- 保留 Chat Completions 作为兼容层,易于禁用并最终移除。 -- 使用隔离的、可复用的 schema 标准化验证和解析。 - -## 非目标 - -- 第一阶段完全实现 OpenResponses 功能(图片、文件、托管工具)。 -- 替换内部智能体执行逻辑或工具编排。 -- 在第一阶段更改现有的 `/v1/chat/completions` 行为。 - -## 研究摘要 - -来源:OpenResponses OpenAPI、OpenResponses 规范网站和 Hugging Face 博客文章。 - -提取的关键点: - -- `POST /v1/responses` 接受 `CreateResponseBody` 字段,如 `model`、`input`(字符串或 `ItemParam[]`)、`instructions`、`tools`、`tool_choice`、`stream`、`max_output_tokens` 和 `max_tool_calls`。 -- `ItemParam` 是以下类型的可区分联合: - - 具有角色 `system`、`developer`、`user`、`assistant` 的 `message` 项 - - `function_call` 和 `function_call_output` - - `reasoning` - - `item_reference` -- 成功响应返回带有 `object: "response"`、`status` 和 `output` 项的 `ResponseResource`。 -- 流式传输使用语义事件,如: - - `response.created`、`response.in_progress`、`response.completed`、`response.failed` - - `response.output_item.added`、`response.output_item.done` - - `response.content_part.added`、`response.content_part.done` - - `response.output_text.delta`、`response.output_text.done` -- 规范要求: - - `Content-Type: text/event-stream` - - `event:` 必须匹配 JSON `type` 字段 - - 终止事件必须是字面量 `[DONE]` -- Reasoning 项可能暴露 `content`、`encrypted_content` 和 `summary`。 -- HF 示例在请求中包含 `OpenResponses-Version: latest`(可选头部)。 - -## 提议的架构 - -- 添加 `src/gateway/open-responses.schema.ts`,仅包含 Zod schema(无 gateway 导入)。 -- 添加 `src/gateway/openresponses-http.ts`(或 `open-responses-http.ts`)用于 `/v1/responses`。 -- 保持 `src/gateway/openai-http.ts` 不变,作为遗留兼容适配器。 -- 添加配置 `gateway.http.endpoints.responses.enabled`(默认 `false`)。 -- 保持 `gateway.http.endpoints.chatCompletions.enabled` 独立;允许两个端点分别切换。 -- 当 Chat Completions 启用时发出启动警告,以表明其遗留状态。 - -## Chat Completions 弃用路径 - -- 保持严格的模块边界:responses 和 chat completions 之间不共享 schema 类型。 -- 通过配置使 Chat Completions 成为可选,这样无需代码更改即可禁用。 -- 一旦 `/v1/responses` 稳定,更新文档将 Chat Completions 标记为遗留。 -- 可选的未来步骤:将 Chat Completions 请求映射到 Responses 处理器,以便更简单地移除。 - -## 第一阶段支持子集 - -- 接受 `input` 为字符串或带有消息角色和 `function_call_output` 的 `ItemParam[]`。 -- 将 system 和 developer 消息提取到 `extraSystemPrompt` 中。 -- 使用最近的 `user` 或 `function_call_output` 作为智能体运行的当前消息。 -- 对不支持的内容部分(图片/文件)返回 `invalid_request_error` 拒绝。 -- 返回带有 `output_text` 内容的单个助手消息。 -- 返回带有零值的 `usage`,直到 token 计数接入。 - -## 验证策略(无 SDK) - -- 为以下支持子集实现 Zod schema: - - `CreateResponseBody` - - `ItemParam` + 消息内容部分联合 - - `ResponseResource` - - Gateway 网关使用的流式事件形状 -- 将 schema 保存在单个隔离模块中,以避免漂移并允许未来代码生成。 - -## 流式实现(第一阶段) - -- 带有 `event:` 和 `data:` 的 SSE 行。 -- 所需序列(最小可行): - - `response.created` - - `response.output_item.added` - - `response.content_part.added` - - `response.output_text.delta`(根据需要重复) - - `response.output_text.done` - - `response.content_part.done` - - `response.completed` - - `[DONE]` - -## 测试和验证计划 - -- 为 `/v1/responses` 添加端到端覆盖: - - 需要认证 - - 非流式响应形状 - - 流式事件顺序和 `[DONE]` - - 使用头部和 `user` 的会话路由 -- 保持 `src/gateway/openai-http.e2e.test.ts` 不变。 -- 手动:用 `stream: true` curl `/v1/responses` 并验证事件顺序和终止 `[DONE]`。 - -## 文档更新(后续) - -- 为 `/v1/responses` 使用和示例添加新文档页面。 -- 更新 `/gateway/openai-http-api`,添加遗留说明和指向 `/v1/responses` 的指针。 diff --git a/docs/zh-CN/experiments/proposals/model-config.md b/docs/zh-CN/experiments/proposals/model-config.md deleted file mode 100644 index 291e5a193ba..00000000000 --- a/docs/zh-CN/experiments/proposals/model-config.md +++ /dev/null @@ -1,42 +0,0 @@ ---- -read_when: - - 探索未来模型选择和认证配置文件的方案 -summary: 探索:模型配置、认证配置文件和回退行为 -title: 模型配置探索 -x-i18n: - generated_at: "2026-02-01T20:25:05Z" - model: claude-opus-4-5 - provider: pi - source_hash: 48623233d80f874c0ae853b51f888599cf8b50ae6fbfe47f6d7b0216bae9500b - source_path: experiments/proposals/model-config.md - workflow: 14 ---- - -# 模型配置(探索) - -本文档记录了未来模型配置的**构想**。这不是正式的发布规范。如需了解当前行为,请参阅: - -- [模型](/concepts/models) -- [模型故障转移](/concepts/model-failover) -- [OAuth + 配置文件](/concepts/oauth) - -## 动机 - -运营者希望: - -- 每个提供商支持多个认证配置文件(个人 vs 工作)。 -- 简单的 `/model` 选择,并具有可预测的回退行为。 -- 文本模型与图像模型之间有清晰的分离。 - -## 可能的方向(高层级) - -- 保持模型选择简洁:`provider/model` 加可选别名。 -- 允许提供商拥有多个认证配置文件,并指定明确的顺序。 -- 使用全局回退列表,使所有会话以一致的方式进行故障转移。 -- 仅在明确配置时才覆盖图像路由。 - -## 待解决的问题 - -- 配置文件轮换应该按提供商还是按模型进行? -- UI 应如何为会话展示配置文件选择? -- 从旧版配置键迁移的最安全路径是什么? diff --git a/docs/zh-CN/experiments/research/memory.md b/docs/zh-CN/experiments/research/memory.md deleted file mode 100644 index 6f5b521c06c..00000000000 --- a/docs/zh-CN/experiments/research/memory.md +++ /dev/null @@ -1,235 +0,0 @@ ---- -read_when: - - 设计超越每日 Markdown 日志的工作区记忆(~/.openclaw/workspace) - - Deciding: standalone CLI vs deep OpenClaw integration - - 添加离线回忆 + 反思(retain/recall/reflect) -summary: 研究笔记:Clawd 工作区的离线记忆系统(Markdown 作为数据源 + 派生索引) -title: 工作区记忆研究 -x-i18n: - generated_at: "2026-02-03T10:06:14Z" - model: claude-opus-4-5 - provider: pi - source_hash: 1753c8ee6284999fab4a94ff5fae7421c85233699c9d3088453d0c2133ac0feb - source_path: experiments/research/memory.md - workflow: 15 ---- - -# 工作区记忆 v2(离线):研究笔记 - -目标:Clawd 风格的工作区(`agents.defaults.workspace`,默认 `~/.openclaw/workspace`),其中"记忆"以每天一个 Markdown 文件(`memory/YYYY-MM-DD.md`)加上一小组稳定文件(例如 `memory.md`、`SOUL.md`)的形式存储。 - -本文档提出一种**离线优先**的记忆架构,保持 Markdown 作为规范的、可审查的数据源,但通过派生索引添加**结构化回忆**(搜索、实体摘要、置信度更新)。 - -## 为什么要改变? - -当前设置(每天一个文件)非常适合: - -- "仅追加"式日志记录 -- 人工编辑 -- git 支持的持久性 + 可审计性 -- 低摩擦捕获("直接写下来") - -但它在以下方面较弱: - -- 高召回率检索("我们对 X 做了什么决定?"、"上次我们尝试 Y 时?") -- 以实体为中心的答案("告诉我关于 Alice / The Castle / warelay 的信息")而无需重读多个文件 -- 观点/偏好稳定性(以及变化时的证据) -- 时间约束("2025 年 11 月期间什么是真实的?")和冲突解决 - -## 设计目标 - -- **离线**:无需网络即可工作;可在笔记本电脑/Castle 上运行;无云依赖。 -- **可解释**:检索的项目应该可归因(文件 + 位置)并与推理分离。 -- **低仪式感**:每日日志保持 Markdown,无需繁重的 schema 工作。 -- **增量式**:v1 仅使用 FTS 就很有用;语义/向量和图是可选升级。 -- **对智能体友好**:使"在 token 预算内回忆"变得简单(返回小型事实包)。 - -## 北极星模型(Hindsight × Letta) - -需要融合两个部分: - -1. **Letta/MemGPT 风格的控制循环** - -- 保持一个小的"核心"始终在上下文中(角色 + 关键用户事实) -- 其他所有内容都在上下文之外,通过工具检索 -- 记忆写入是显式的工具调用(append/replace/insert),持久化后在下一轮重新注入 - -2. **Hindsight 风格的记忆基底** - -- 分离观察到的、相信的和总结的内容 -- 支持 retain/recall/reflect -- 带有置信度的观点可以随证据演变 -- 实体感知检索 + 时间查询(即使没有完整的知识图谱) - -## 提议的架构(Markdown 数据源 + 派生索引) - -### 规范存储(git 友好) - -保持 `~/.openclaw/workspace` 作为规范的人类可读记忆。 - -建议的工作区布局: - -``` -~/.openclaw/workspace/ - memory.md # 小型:持久事实 + 偏好(类似核心) - memory/ - YYYY-MM-DD.md # 每日日志(追加;叙事) - bank/ # "类型化"记忆页面(稳定、可审查) - world.md # 关于世界的客观事实 - experience.md # 智能体做了什么(第一人称) - opinions.md # 主观偏好/判断 + 置信度 + 证据指针 - entities/ - Peter.md - The-Castle.md - warelay.md - ... -``` - -注意: - -- **每日日志保持为每日日志**。无需将其转换为 JSON。 -- `bank/` 文件是**经过整理的**,由反思任务生成,仍可手动编辑。 -- `memory.md` 保持"小型 + 类似核心":你希望 Clawd 每次会话都能看到的内容。 - -### 派生存储(机器回忆) - -在工作区下添加派生索引(不一定需要 git 跟踪): - -``` -~/.openclaw/workspace/.memory/index.sqlite -``` - -后端支持: - -- 用于事实 + 实体链接 + 观点元数据的 SQLite schema -- SQLite **FTS5** 用于词法回忆(快速、小巧、离线) -- 可选的嵌入表用于语义回忆(仍然离线) - -索引始终**可从 Markdown 重建**。 - -## Retain / Recall / Reflect(操作循环) - -### Retain:将每日日志规范化为"事实" - -Hindsight 在这里重要的关键洞察:存储**叙事性、自包含的事实**,而不是微小的片段。 - -`memory/YYYY-MM-DD.md` 的实用规则: - -- 在一天结束时(或期间),添加一个 `## Retain` 部分,包含 2-5 个要点: - - 叙事性(保留跨轮上下文) - - 自包含(独立时也有意义) - - 标记类型 + 实体提及 - -示例: - -``` -## Retain -- W @Peter: Currently in Marrakech (Nov 27–Dec 1, 2025) for Andy's birthday. -- B @warelay: I fixed the Baileys WS crash by wrapping connection.update handlers in try/catch (see memory/2025-11-27.md). -- O(c=0.95) @Peter: Prefers concise replies (<1500 chars) on WhatsApp; long content goes into files. -``` - -最小化解析: - -- 类型前缀:`W`(世界)、`B`(经历/传记)、`O`(观点)、`S`(观察/摘要;通常是生成的) -- 实体:`@Peter`、`@warelay` 等(slug 映射到 `bank/entities/*.md`) -- 观点置信度:`O(c=0.0..1.0)` 可选 - -如果你不想让作者考虑这些:反思任务可以从日志的其余部分推断这些要点,但有一个显式的 `## Retain` 部分是最简单的"质量杠杆"。 - -### Recall:对派生索引的查询 - -Recall 应支持: - -- **词法**:"查找精确的术语/名称/命令"(FTS5) -- **实体**:"告诉我关于 X 的信息"(实体页面 + 实体链接的事实) -- **时间**:"11 月 27 日前后发生了什么"/"自上周以来" -- **观点**:"Peter 偏好什么?"(带置信度 + 证据) - -返回格式应对智能体友好并引用来源: - -- `kind`(`world|experience|opinion|observation`) -- `timestamp`(来源日期,或如果存在则提取的时间范围) -- `entities`(`["Peter","warelay"]`) -- `content`(叙事性事实) -- `source`(`memory/2025-11-27.md#L12` 等) - -### Reflect:生成稳定页面 + 更新信念 - -反思是一个定时任务(每日或心跳 `ultrathink`),它: - -- 根据最近的事实更新 `bank/entities/*.md`(实体摘要) -- 根据强化/矛盾更新 `bank/opinions.md` 置信度 -- 可选地提议对 `memory.md`("类似核心"的持久事实)的编辑 - -观点演变(简单、可解释): - -- 每个观点有: - - 陈述 - - 置信度 `c ∈ [0,1]` - - last_updated - - 证据链接(支持 + 矛盾的事实 ID) -- 当新事实到达时: - - 通过实体重叠 + 相似性找到候选观点(先 FTS,后嵌入) - - 通过小幅增量更新置信度;大幅跳跃需要强矛盾 + 重复证据 - -## CLI 集成:独立 vs 深度集成 - -建议:**深度集成到 OpenClaw**,但保持可分离的核心库。 - -### 为什么要集成到 OpenClaw? - -- OpenClaw 已经知道: - - 工作区路径(`agents.defaults.workspace`) - - 会话模型 + 心跳 - - 日志记录 + 故障排除模式 -- 你希望智能体自己调用工具: - - `openclaw memory recall "…" --k 25 --since 30d` - - `openclaw memory reflect --since 7d` - -### 为什么仍要分离库? - -- 保持记忆逻辑可测试,无需 Gateway 网关/运行时 -- 可从其他上下文重用(本地脚本、未来的桌面应用等) - -形态: -记忆工具预计是一个小型 CLI + 库层,但这仅是探索性的。 - -## "S-Collide" / SuCo:何时使用(研究) - -如果"S-Collide"指的是 **SuCo(Subspace Collision)**:这是一种 ANN 检索方法,通过在子空间中使用学习/结构化碰撞来实现强召回/延迟权衡(论文:arXiv 2411.14754,2024)。 - -对于 `~/.openclaw/workspace` 的务实观点: - -- **不要从** SuCo 开始。 -- 从 SQLite FTS +(可选的)简单嵌入开始;你会立即获得大部分 UX 收益。 -- 仅在以下情况下考虑 SuCo/HNSW/ScaNN 级别的解决方案: - - 语料库很大(数万/数十万个块) - - 暴力嵌入搜索变得太慢 - - 召回质量明显受到词法搜索的瓶颈限制 - -离线友好的替代方案(按复杂性递增): - -- SQLite FTS5 + 元数据过滤(零 ML) -- 嵌入 + 暴力搜索(如果块数量低,效果出奇地好) -- HNSW 索引(常见、稳健;需要库绑定) -- SuCo(研究级;如果有可嵌入的可靠实现则很有吸引力) - -开放问题: - -- 对于你的机器(笔记本 + 台式机)上的"个人助理记忆",**最佳**的离线嵌入模型是什么? - - 如果你已经有 Ollama:使用本地模型嵌入;否则在工具链中附带一个小型嵌入模型。 - -## 最小可用试点 - -如果你想要一个最小但仍有用的版本: - -- 添加 `bank/` 实体页面和每日日志中的 `## Retain` 部分。 -- 使用 SQLite FTS 进行带引用的回忆(路径 + 行号)。 -- 仅在召回质量或规模需要时添加嵌入。 - -## 参考资料 - -- Letta / MemGPT 概念:"核心记忆块" + "档案记忆" + 工具驱动的自编辑记忆。 -- Hindsight 技术报告:"retain / recall / reflect",四网络记忆,叙事性事实提取,观点置信度演变。 -- SuCo:arXiv 2411.14754(2024):"Subspace Collision"近似最近邻检索。 From 2afa55674607da5dc852cac197945d716378fb39 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Wed, 18 Mar 2026 01:01:50 -0700 Subject: [PATCH 21/55] Format: sync seam fixes with oxfmt --- extensions/irc/src/accounts.ts | 2 +- src/plugin-sdk/channel-config-schema.ts | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/extensions/irc/src/accounts.ts b/extensions/irc/src/accounts.ts index e54256dd7c2..8c68eb5406e 100644 --- a/extensions/irc/src/accounts.ts +++ b/extensions/irc/src/accounts.ts @@ -1,5 +1,5 @@ -import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id"; import { createAccountListHelpers } from "openclaw/plugin-sdk/account-helpers"; +import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id"; import { normalizeResolvedSecretInputString } from "openclaw/plugin-sdk/config-runtime"; import { parseOptionalDelimitedEntries } from "openclaw/plugin-sdk/core"; import { tryReadSecretFileSync } from "openclaw/plugin-sdk/infra-runtime"; diff --git a/src/plugin-sdk/channel-config-schema.ts b/src/plugin-sdk/channel-config-schema.ts index 994905f9f20..ac24cec0d27 100644 --- a/src/plugin-sdk/channel-config-schema.ts +++ b/src/plugin-sdk/channel-config-schema.ts @@ -5,4 +5,8 @@ export { buildCatchallMultiAccountChannelSchema, buildNestedDmConfigSchema, } from "../channels/plugins/config-schema.js"; -export { DmPolicySchema, GroupPolicySchema, MarkdownConfigSchema } from "../config/zod-schema.core.js"; +export { + DmPolicySchema, + GroupPolicySchema, + MarkdownConfigSchema, +} from "../config/zod-schema.core.js"; From 9b6859e5db7756f82c199e946d6ad7ed283b0f6f Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Wed, 18 Mar 2026 01:01:57 -0700 Subject: [PATCH 22/55] Feishu: break plugin-sdk setup cycle --- extensions/feishu/setup-api.ts | 2 ++ src/auto-reply/reply/commands-acp/context.ts | 3 ++- src/plugin-sdk/feishu.ts | 5 ++--- 3 files changed, 6 insertions(+), 4 deletions(-) create mode 100644 extensions/feishu/setup-api.ts diff --git a/extensions/feishu/setup-api.ts b/extensions/feishu/setup-api.ts new file mode 100644 index 00000000000..8d44582cd03 --- /dev/null +++ b/extensions/feishu/setup-api.ts @@ -0,0 +1,2 @@ +export { feishuSetupAdapter } from "./src/setup-core.js"; +export { feishuSetupWizard } from "./src/setup-surface.js"; diff --git a/src/auto-reply/reply/commands-acp/context.ts b/src/auto-reply/reply/commands-acp/context.ts index 59db08384af..1ec405742b6 100644 --- a/src/auto-reply/reply/commands-acp/context.ts +++ b/src/auto-reply/reply/commands-acp/context.ts @@ -1,3 +1,5 @@ +// Avoid routing a core ACP helper back through the Feishu plugin-sdk seam. +import { buildFeishuConversationId } from "../../../../extensions/feishu/src/conversation-id.js"; import { buildTelegramTopicConversationId, normalizeConversationText, @@ -6,7 +8,6 @@ import { import { DISCORD_THREAD_BINDING_CHANNEL } from "../../../channels/thread-bindings-policy.js"; import { resolveConversationIdFromTargets } from "../../../infra/outbound/conversation-id.js"; import { getSessionBindingService } from "../../../infra/outbound/session-binding-service.js"; -import { buildFeishuConversationId } from "../../../plugin-sdk/feishu.js"; import { parseAgentSessionKey } from "../../../routing/session-key.js"; import type { HandleCommandsParams } from "../commands-types.js"; import { parseDiscordParentChannelFromSessionKey } from "../discord-parent-channel.js"; diff --git a/src/plugin-sdk/feishu.ts b/src/plugin-sdk/feishu.ts index 3a4fa4779c4..cde08767535 100644 --- a/src/plugin-sdk/feishu.ts +++ b/src/plugin-sdk/feishu.ts @@ -67,8 +67,7 @@ export type { RuntimeEnv } from "../runtime.js"; export { formatDocsLink } from "../terminal/links.js"; export { evaluateSenderGroupAccessForPolicy } from "./group-access.js"; export type { WizardPrompter } from "../wizard/prompts.js"; -export { feishuSetupWizard } from "../../extensions/feishu/api.js"; -export { feishuSetupAdapter } from "../../extensions/feishu/api.js"; +export { feishuSetupWizard, feishuSetupAdapter } from "../../extensions/feishu/setup-api.js"; export { buildAgentMediaPayload } from "./agent-media-payload.js"; export { readJsonFileWithFallback } from "./json-store.js"; export { createScopedPairingAccess } from "./pairing-access.js"; @@ -84,7 +83,7 @@ export { withTempDownloadPath } from "./temp-path.js"; export { buildFeishuConversationId, parseFeishuConversationId, -} from "../../extensions/feishu/api.js"; +} from "../../extensions/feishu/src/conversation-id.js"; export { createFixedWindowRateLimiter, createWebhookAnomalyTracker, From d9e776eb475989c0f855440c8a27cc975597b2c4 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Wed, 18 Mar 2026 13:21:22 +0530 Subject: [PATCH 23/55] test(telegram): align create-bot assertions --- .../src/bot.create-telegram-bot.test.ts | 60 +++++++++++++++---- 1 file changed, 49 insertions(+), 11 deletions(-) diff --git a/extensions/telegram/src/bot.create-telegram-bot.test.ts b/extensions/telegram/src/bot.create-telegram-bot.test.ts index 5c05d54a2c7..d0df14e7cf6 100644 --- a/extensions/telegram/src/bot.create-telegram-bot.test.ts +++ b/extensions/telegram/src/bot.create-telegram-bot.test.ts @@ -5,10 +5,12 @@ import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; import { escapeRegExp, formatEnvelopeTimestamp } from "../../../test/helpers/envelope-timestamp.js"; import { withEnvAsync } from "../../../test/helpers/extensions/env.js"; import { useFrozenTime, useRealTime } from "../../../test/helpers/extensions/frozen-time.js"; +const harness = await import("./bot.create-telegram-bot.test-harness.js"); const { answerCallbackQuerySpy, botCtorSpy, commandSpy, + dispatchReplyWithBufferedBlockDispatcher, getLoadConfigMock, getLoadWebMediaMock, getOnHandler, @@ -22,7 +24,6 @@ const { sendChatActionSpy, sendMessageSpy, sendPhotoSpy, - sequentializeKey, sequentializeSpy, setMessageReactionSpy, setMyCommandsSpy, @@ -30,7 +31,7 @@ const { telegramBotRuntimeForTest, throttlerSpy, useSpy, -} = await import("./bot.create-telegram-bot.test-harness.js"); +} = harness; import { resolveTelegramFetch } from "./fetch.js"; // Import after the harness registers `vi.mock(...)` for grammY and Telegram internals. @@ -130,7 +131,7 @@ describe("createTelegramBot", () => { createTelegramBot({ token: "tok" }); expect(sequentializeSpy).toHaveBeenCalledTimes(1); expect(middlewareUseSpy).toHaveBeenCalledWith(sequentializeSpy.mock.results[0]?.value); - expect(sequentializeKey).toBe(getTelegramSequentialKey); + expect(harness.sequentializeKey).toBe(getTelegramSequentialKey); }); it("routes callback_query payloads as messages and answers callbacks", async () => { createTelegramBot({ token: "tok" }); @@ -384,14 +385,23 @@ describe("createTelegramBot", () => { } }); it("triggers typing cue via onReplyStart", async () => { + dispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce( + async ({ dispatcherOptions }) => { + await dispatcherOptions.typingCallbacks?.onReplyStart?.(); + return { queuedFinal: false, counts: {} }; + }, + ); createTelegramBot({ token: "tok" }); const handler = getOnHandler("message") as (ctx: Record) => Promise; await handler({ - message: { chat: { id: 42, type: "private" }, text: "hi" }, + message: { + chat: { id: 42, type: "private" }, + from: { id: 999, username: "random" }, + text: "hi", + }, me: { username: "openclaw_bot" }, getFile: async () => ({ download: async () => new Uint8Array() }), }); - expect(sendChatActionSpy).toHaveBeenCalledWith(42, "typing", undefined); }); @@ -1035,6 +1045,7 @@ describe("createTelegramBot", () => { title: "Forum Group", is_forum: true, }, + from: { id: 999, username: "testuser" }, text: testCase.text, date: 1736380800, message_id: 42, @@ -1439,6 +1450,21 @@ describe("createTelegramBot", () => { for (const testCase of forumCases) { resetHarnessSpies(); sendChatActionSpy.mockClear(); + let dispatchCall: + | { + ctx: { + SessionKey?: unknown; + From?: unknown; + MessageThreadId?: unknown; + IsForum?: unknown; + }; + } + | undefined; + dispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce(async (params) => { + dispatchCall = params as typeof dispatchCall; + await params.dispatcherOptions.typingCallbacks?.onReplyStart?.(); + return { queuedFinal: false, counts: {} }; + }); loadConfig.mockReturnValue({ channels: { telegram: { @@ -1451,8 +1477,7 @@ describe("createTelegramBot", () => { const handler = getMessageHandler(); await handler(makeForumGroupMessageCtx({ threadId: testCase.threadId })); - expect(replySpy.mock.calls.length, testCase.name).toBe(1); - const payload = replySpy.mock.calls[0][0]; + const payload = dispatchCall?.ctx; if (testCase.assertTopicMetadata) { expect(payload.SessionKey).toContain("telegram:group:-1001234567890:topic:99"); expect(payload.From).toBe("telegram:group:-1001234567890:topic:99"); @@ -1741,6 +1766,7 @@ describe("createTelegramBot", () => { await handler({ message: { chat: { id: 123, type: "group", title: "Routing" }, + from: { id: 999, username: "ops" }, text: "hello", date: 1736380800, }, @@ -1752,6 +1778,20 @@ describe("createTelegramBot", () => { }); it("applies topic skill filters and system prompts", async () => { + let dispatchCall: + | { + ctx: { + GroupSystemPrompt?: unknown; + }; + replyOptions?: { + skillFilter?: unknown; + }; + } + | undefined; + dispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce(async (params) => { + dispatchCall = params as typeof dispatchCall; + return { queuedFinal: false, counts: {} }; + }); loadConfig.mockReturnValue({ channels: { telegram: { @@ -1778,11 +1818,9 @@ describe("createTelegramBot", () => { await handler(makeForumGroupMessageCtx({ threadId: 99 })); - expect(replySpy).toHaveBeenCalledTimes(1); - const payload = replySpy.mock.calls[0][0]; + const payload = dispatchCall?.ctx; expect(payload.GroupSystemPrompt).toBe("Group prompt\n\nTopic prompt"); - const opts = replySpy.mock.calls[0][1] as { skillFilter?: unknown }; - expect(opts?.skillFilter).toEqual([]); + expect(dispatchCall?.replyOptions?.skillFilter).toEqual([]); }); it("threads native command replies inside topics", async () => { commandSpy.mockClear(); From 0567f111ac00af8f3b26d905055dfc47a3ef216b Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Wed, 18 Mar 2026 13:21:54 +0530 Subject: [PATCH 24/55] test(telegram): stabilize inbound media harness --- ...dia-file-path-no-file-download.e2e.test.ts | 22 ++- .../telegram/src/bot.media.e2e-harness.ts | 161 +++++++++++++----- ...t.media.stickers-and-fragments.e2e.test.ts | 90 +++++----- .../telegram/src/bot.media.test-utils.ts | 62 ++++--- src/auto-reply/inbound-debounce.ts | 4 +- 5 files changed, 220 insertions(+), 119 deletions(-) diff --git a/extensions/telegram/src/bot.media.downloads-media-file-path-no-file-download.e2e.test.ts b/extensions/telegram/src/bot.media.downloads-media-file-path-no-file-download.e2e.test.ts index 2c02d69d33f..e385c102681 100644 --- a/extensions/telegram/src/bot.media.downloads-media-file-path-no-file-download.e2e.test.ts +++ b/extensions/telegram/src/bot.media.downloads-media-file-path-no-file-download.e2e.test.ts @@ -6,6 +6,7 @@ import { createBotHandlerWithOptions, mockTelegramFileDownload, mockTelegramPngDownload, + watchTelegramFetch, } from "./bot.media.test-utils.js"; describe("telegram inbound media", () => { @@ -39,8 +40,10 @@ describe("telegram inbound media", () => { }) => { expect(params.runtimeError).not.toHaveBeenCalled(); expect(params.fetchSpy).toHaveBeenCalledWith( - "https://api.telegram.org/file/bottok/photos/1.jpg", - expect.objectContaining({ redirect: "manual" }), + expect.objectContaining({ + url: "https://api.telegram.org/file/bottok/photos/1.jpg", + filePathHint: "photos/1.jpg", + }), ); expect(params.replySpy).toHaveBeenCalledTimes(1); const payload = params.replySpy.mock.calls[0][0]; @@ -51,7 +54,7 @@ describe("telegram inbound media", () => { name: "skips when file_path is missing", messageId: 2, getFile: async () => ({}), - setupFetch: () => vi.spyOn(globalThis, "fetch"), + setupFetch: () => watchTelegramFetch(), assert: (params: { fetchSpy: ReturnType; replySpy: ReturnType; @@ -71,6 +74,7 @@ describe("telegram inbound media", () => { message: { message_id: scenario.messageId, chat: { id: 1234, type: "private" }, + from: { id: 777, is_bot: false, first_name: "Ada" }, photo: [{ file_id: "fid" }], date: 1736380800, // 2025-01-09T00:00:00Z }, @@ -106,6 +110,7 @@ describe("telegram inbound media", () => { message: { message_id: 1001, chat: { id: 1234, type: "private" }, + from: { id: 777, is_bot: false, first_name: "Ada" }, photo: [{ file_id: "fid" }], date: 1736380800, }, @@ -245,6 +250,7 @@ describe("telegram media groups", () => { messages: [ { chat: { id: 42, type: "private" as const }, + from: { id: 777, is_bot: false, first_name: "Ada" }, message_id: 1, caption: "Here are my photos", date: 1736380800, @@ -254,6 +260,7 @@ describe("telegram media groups", () => { }, { chat: { id: 42, type: "private" as const }, + from: { id: 777, is_bot: false, first_name: "Ada" }, message_id: 2, date: 1736380801, media_group_id: "album123", @@ -272,6 +279,7 @@ describe("telegram media groups", () => { messages: [ { chat: { id: 42, type: "private" as const }, + from: { id: 777, is_bot: false, first_name: "Ada" }, message_id: 11, caption: "Album A", date: 1736380800, @@ -281,6 +289,7 @@ describe("telegram media groups", () => { }, { chat: { id: 42, type: "private" as const }, + from: { id: 777, is_bot: false, first_name: "Ada" }, message_id: 12, caption: "Album B", date: 1736380801, @@ -339,7 +348,6 @@ describe("telegram forwarded bursts", () => { const runtimeError = vi.fn(); const { handler, replySpy } = await createBotHandlerWithOptions({ runtimeError }); const fetchSpy = mockTelegramPngDownload(); - vi.useFakeTimers(); try { await handler({ @@ -368,8 +376,9 @@ describe("telegram forwarded bursts", () => { getFile: async () => ({ file_path: "photos/fwd1.jpg" }), }); - await vi.runAllTimersAsync(); - expect(replySpy).toHaveBeenCalledTimes(1); + await vi.waitFor(() => { + expect(replySpy).toHaveBeenCalledTimes(1); + }); expect(runtimeError).not.toHaveBeenCalled(); const payload = replySpy.mock.calls[0][0]; @@ -377,7 +386,6 @@ describe("telegram forwarded bursts", () => { expect(payload.MediaPaths).toHaveLength(1); } finally { fetchSpy.mockRestore(); - vi.useRealTimers(); } }, FORWARD_BURST_TEST_TIMEOUT_MS, diff --git a/extensions/telegram/src/bot.media.e2e-harness.ts b/extensions/telegram/src/bot.media.e2e-harness.ts index 7054b69d06a..3dbd8634ab1 100644 --- a/extensions/telegram/src/bot.media.e2e-harness.ts +++ b/extensions/telegram/src/bot.media.e2e-harness.ts @@ -1,21 +1,55 @@ +import path from "node:path"; +import { MediaFetchError } from "openclaw/plugin-sdk/media-runtime"; import { resetInboundDedupe } from "openclaw/plugin-sdk/reply-runtime"; import { beforeEach, vi, type Mock } from "vitest"; -import type { TelegramBotDeps } from "./bot-deps.js"; - -const EMPTY_REPLY_COUNTS = { - block: 0, - final: 0, - tool: 0, -} as const; export const useSpy: Mock = vi.fn(); export const middlewareUseSpy: Mock = vi.fn(); export const onSpy: Mock = vi.fn(); export const stopSpy: Mock = vi.fn(); export const sendChatActionSpy: Mock = vi.fn(); -export const undiciFetchSpy: Mock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => - globalThis.fetch(input, init), -); +function defaultUndiciFetch(input: RequestInfo | URL, init?: RequestInit) { + return globalThis.fetch(input, init); +} + +export const undiciFetchSpy: Mock = vi.fn(defaultUndiciFetch); + +export function resetUndiciFetchMock() { + undiciFetchSpy.mockReset(); + undiciFetchSpy.mockImplementation(defaultUndiciFetch); +} + +type FetchRemoteMediaFn = typeof import("openclaw/plugin-sdk/media-runtime").fetchRemoteMedia; + +async function defaultFetchRemoteMedia( + params: Parameters[0], +): ReturnType { + if (!params.fetchImpl) { + throw new MediaFetchError("fetch_failed", `Missing fetchImpl for ${params.url}`); + } + const response = await params.fetchImpl(params.url, { + redirect: "manual", + }); + if (!response.ok) { + throw new MediaFetchError( + "http_error", + `Failed to fetch media from ${params.url}: HTTP ${response.status} ${response.statusText}`, + ); + } + const arrayBuffer = await response.arrayBuffer(); + return { + buffer: Buffer.from(arrayBuffer), + contentType: response.headers.get("content-type") ?? undefined, + fileName: params.filePathHint ? path.basename(params.filePathHint) : undefined, + } as Awaited>; +} + +export const fetchRemoteMediaSpy: Mock = vi.fn(defaultFetchRemoteMedia); + +export function resetFetchRemoteMediaMock() { + fetchRemoteMediaSpy.mockReset(); + fetchRemoteMediaSpy.mockImplementation(defaultFetchRemoteMedia); +} async function defaultSaveMediaBuffer(buffer: Buffer, contentType?: string) { return { @@ -63,11 +97,7 @@ const apiStub: ApiStub = { setMyCommands: vi.fn(async () => undefined), }; -export const telegramBotRuntimeForTest: { - Bot: new (token: string) => unknown; - sequentialize: () => unknown; - apiThrottler: () => unknown; -} = { +export const telegramBotRuntimeForTest = { Bot: class { api = apiStub; use = middlewareUseSpy; @@ -81,26 +111,46 @@ export const telegramBotRuntimeForTest: { apiThrottler: () => throttlerSpy(), }; -const mediaHarnessReplySpy = vi.hoisted(() => - vi.fn(async (_ctx, opts) => { - await opts?.onReplyStart?.(); - return undefined; - }), -); +const mediaHarnessReplySpy = vi.hoisted(() => vi.fn(async () => undefined)); +type DispatchReplyWithBufferedBlockDispatcherFn = + typeof import("openclaw/plugin-sdk/reply-runtime").dispatchReplyWithBufferedBlockDispatcher; +type DispatchReplyHarnessParams = Parameters[0]; + +let actualDispatchReplyWithBufferedBlockDispatcherPromise: + | Promise + | undefined; + +async function getActualDispatchReplyWithBufferedBlockDispatcher() { + actualDispatchReplyWithBufferedBlockDispatcherPromise ??= + import("../../../src/auto-reply/reply/provider-dispatcher.js").then( + (module) => + module.dispatchReplyWithBufferedBlockDispatcher as DispatchReplyWithBufferedBlockDispatcherFn, + ); + return await actualDispatchReplyWithBufferedBlockDispatcherPromise; +} + +async function dispatchReplyWithBufferedBlockDispatcherViaActual( + params: DispatchReplyHarnessParams, +) { + const actualDispatchReplyWithBufferedBlockDispatcher = + await getActualDispatchReplyWithBufferedBlockDispatcher(); + return await actualDispatchReplyWithBufferedBlockDispatcher({ + ...params, + replyResolver: async (ctx, _cfg, opts) => { + await opts?.onReplyStart?.(); + return await mediaHarnessReplySpy(ctx, opts); + }, + }); +} + const mediaHarnessDispatchReplyWithBufferedBlockDispatcher = vi.hoisted(() => - vi.fn(async (params) => { - await params.dispatcherOptions?.typingCallbacks?.start?.(); - const reply = await mediaHarnessReplySpy(params.ctx, params.replyOptions); - const payloads = reply === undefined ? [] : Array.isArray(reply) ? reply : [reply]; - for (const payload of payloads) { - await params.dispatcherOptions?.deliver?.(payload, { kind: "final" }); - } - return { queuedFinal: false, counts: EMPTY_REPLY_COUNTS }; - }), + vi.fn( + dispatchReplyWithBufferedBlockDispatcherViaActual, + ), ); -export const telegramBotDepsForTest: TelegramBotDeps = { +export const telegramBotDepsForTest = { loadConfig: () => ({ - channels: { telegram: { dmPolicy: "open" as const, allowFrom: ["*"] } }, + channels: { telegram: { dmPolicy: "open", allowFrom: ["*"] } }, }), resolveStorePath: vi.fn((storePath?: string) => storePath ?? "/tmp/telegram-media-sessions.json"), readChannelAllowFromStore: vi.fn(async () => [] as string[]), @@ -113,6 +163,8 @@ export const telegramBotDepsForTest: TelegramBotDeps = { beforeEach(() => { resetInboundDedupe(); resetSaveMediaBufferMock(); + resetUndiciFetchMock(); + resetFetchRemoteMediaMock(); }); const throttlerSpy = vi.fn(() => "throttler"); @@ -133,6 +185,12 @@ vi.doMock("openclaw/plugin-sdk/media-runtime", async (importOriginal) => { const actual = await importOriginal(); const mockModule = Object.create(null) as Record; Object.defineProperties(mockModule, Object.getOwnPropertyDescriptors(actual)); + Object.defineProperty(mockModule, "fetchRemoteMedia", { + configurable: true, + enumerable: true, + writable: true, + value: (...args: Parameters) => fetchRemoteMediaSpy(...args), + }); Object.defineProperty(mockModule, "saveMediaBuffer", { configurable: true, enumerable: true, @@ -149,24 +207,35 @@ vi.doMock("openclaw/plugin-sdk/config-runtime", async (importOriginal) => { loadConfig: () => ({ channels: { telegram: { dmPolicy: "open", allowFrom: ["*"] } }, }), - }; -}); - -vi.doMock("openclaw/plugin-sdk/config-runtime", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, updateLastRoute: vi.fn(async () => undefined), }; }); -vi.doMock("openclaw/plugin-sdk/conversation-runtime", () => ({ - readChannelAllowFromStore: vi.fn(async () => [] as string[]), - upsertChannelPairingRequest: vi.fn(async () => ({ - code: "PAIRCODE", - created: true, - })), -})); +vi.doMock("openclaw/plugin-sdk/agent-runtime", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + findModelInCatalog: vi.fn(() => undefined), + loadModelCatalog: vi.fn(async () => []), + modelSupportsVision: vi.fn(() => false), + resolveDefaultModelForAgent: vi.fn(() => ({ + provider: "openai", + model: "gpt-test", + })), + }; +}); + +vi.doMock("openclaw/plugin-sdk/conversation-runtime", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + readChannelAllowFromStore: vi.fn(async () => [] as string[]), + upsertChannelPairingRequest: vi.fn(async () => ({ + code: "PAIRCODE", + created: true, + })), + }; +}); vi.doMock("openclaw/plugin-sdk/reply-runtime", async (importOriginal) => { const actual = await importOriginal(); diff --git a/extensions/telegram/src/bot.media.stickers-and-fragments.e2e.test.ts b/extensions/telegram/src/bot.media.stickers-and-fragments.e2e.test.ts index fc1b372f778..67e9cab4f19 100644 --- a/extensions/telegram/src/bot.media.stickers-and-fragments.e2e.test.ts +++ b/extensions/telegram/src/bot.media.stickers-and-fragments.e2e.test.ts @@ -7,6 +7,7 @@ import { describeStickerImageSpy, getCachedStickerSpy, mockTelegramFileDownload, + watchTelegramFetch, } from "./bot.media.test-utils.js"; describe("telegram stickers", () => { @@ -34,6 +35,7 @@ describe("telegram stickers", () => { message: { message_id: 100, chat: { id: 1234, type: "private" }, + from: { id: 777, is_bot: false, first_name: "Ada" }, sticker: { file_id: "sticker_file_id_123", file_unique_id: "sticker_unique_123", @@ -53,8 +55,10 @@ describe("telegram stickers", () => { expect(runtimeError).not.toHaveBeenCalled(); expect(fetchSpy).toHaveBeenCalledWith( - "https://api.telegram.org/file/bottok/stickers/sticker.webp", - expect.objectContaining({ redirect: "manual" }), + expect.objectContaining({ + url: "https://api.telegram.org/file/bottok/stickers/sticker.webp", + filePathHint: "stickers/sticker.webp", + }), ); expect(replySpy).toHaveBeenCalledTimes(1); const payload = replySpy.mock.calls[0][0]; @@ -82,18 +86,16 @@ describe("telegram stickers", () => { cachedAt: "2026-01-20T10:00:00.000Z", }); - const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce({ - ok: true, - status: 200, - statusText: "OK", - headers: { get: () => "image/webp" }, - arrayBuffer: async () => new Uint8Array([0x52, 0x49, 0x46, 0x46]).buffer, - } as unknown as Response); + const fetchSpy = mockTelegramFileDownload({ + contentType: "image/webp", + bytes: new Uint8Array([0x52, 0x49, 0x46, 0x46]), + }); await handler({ message: { message_id: 103, chat: { id: 1234, type: "private" }, + from: { id: 777, is_bot: false, first_name: "Ada" }, sticker: { file_id: "new_file_id", file_unique_id: "sticker_unique_456", @@ -167,12 +169,13 @@ describe("telegram stickers", () => { ]) { replySpy.mockClear(); runtimeError.mockClear(); - const fetchSpy = vi.spyOn(globalThis, "fetch"); + const fetchSpy = watchTelegramFetch(); await handler({ message: { message_id: scenario.messageId, chat: { id: 1234, type: "private" }, + from: { id: 777, is_bot: false, first_name: "Ada" }, sticker: scenario.sticker, date: 1736380800, }, @@ -202,43 +205,44 @@ describe("telegram text fragments", () => { "buffers near-limit text and processes sequential parts as one message", async () => { const { handler, replySpy } = await createBotHandlerWithOptions({}); - vi.useFakeTimers(); - try { - const part1 = "A".repeat(4050); - const part2 = "B".repeat(50); + const part1 = "A".repeat(4050); + const part2 = "B".repeat(50); - await handler({ - message: { - chat: { id: 42, type: "private" }, - message_id: 10, - date: 1736380800, - text: part1, - }, - me: { username: "openclaw_bot" }, - getFile: async () => ({}), - }); + await handler({ + message: { + chat: { id: 42, type: "private" }, + from: { id: 777, is_bot: false, first_name: "Ada" }, + message_id: 10, + date: 1736380800, + text: part1, + }, + me: { username: "openclaw_bot" }, + getFile: async () => ({}), + }); - await handler({ - message: { - chat: { id: 42, type: "private" }, - message_id: 11, - date: 1736380801, - text: part2, - }, - me: { username: "openclaw_bot" }, - getFile: async () => ({}), - }); + await handler({ + message: { + chat: { id: 42, type: "private" }, + from: { id: 777, is_bot: false, first_name: "Ada" }, + message_id: 11, + date: 1736380801, + text: part2, + }, + me: { username: "openclaw_bot" }, + getFile: async () => ({}), + }); - expect(replySpy).not.toHaveBeenCalled(); - await vi.advanceTimersByTimeAsync(TEXT_FRAGMENT_FLUSH_MS * 2); - expect(replySpy).toHaveBeenCalledTimes(1); + expect(replySpy).not.toHaveBeenCalled(); + await vi.waitFor( + () => { + expect(replySpy).toHaveBeenCalledTimes(1); + }, + { timeout: TEXT_FRAGMENT_FLUSH_MS * 6, interval: 5 }, + ); - const payload = replySpy.mock.calls[0][0] as { RawBody?: string }; - expect(payload.RawBody).toContain(part1.slice(0, 32)); - expect(payload.RawBody).toContain(part2.slice(0, 32)); - } finally { - vi.useRealTimers(); - } + const payload = replySpy.mock.calls[0][0] as { RawBody?: string }; + expect(payload.RawBody).toContain(part1.slice(0, 32)); + expect(payload.RawBody).toContain(part2.slice(0, 32)); }, TEXT_FRAGMENT_TEST_TIMEOUT_MS, ); diff --git a/extensions/telegram/src/bot.media.test-utils.ts b/extensions/telegram/src/bot.media.test-utils.ts index 7c391642d67..a816cc7c4fb 100644 --- a/extensions/telegram/src/bot.media.test-utils.ts +++ b/extensions/telegram/src/bot.media.test-utils.ts @@ -22,6 +22,18 @@ let createTelegramBotRef: typeof import("./bot.js").createTelegramBot; let replySpyRef: ReturnType; let onSpyRef: Mock; let sendChatActionSpyRef: Mock; +let fetchRemoteMediaSpyRef: Mock; +let resetFetchRemoteMediaMockRef: () => void; + +type FetchMockHandle = Mock & { mockRestore: () => void }; + +function createFetchMockHandle(): FetchMockHandle { + return Object.assign(fetchRemoteMediaSpyRef, { + mockRestore: () => { + resetFetchRemoteMediaMockRef(); + }, + }) as FetchMockHandle; +} export async function createBotHandler(): Promise<{ handler: (ctx: Record) => Promise; @@ -68,24 +80,26 @@ export async function createBotHandlerWithOptions(options: { export function mockTelegramFileDownload(params: { contentType: string; bytes: Uint8Array; -}): ReturnType { - return vi.spyOn(globalThis, "fetch").mockResolvedValueOnce({ - ok: true, - status: 200, - statusText: "OK", - headers: { get: () => params.contentType }, - arrayBuffer: async () => params.bytes.buffer, - } as unknown as Response); +}): FetchMockHandle { + fetchRemoteMediaSpyRef.mockResolvedValueOnce({ + buffer: Buffer.from(params.bytes), + contentType: params.contentType, + fileName: "mock-file", + }); + return createFetchMockHandle(); } -export function mockTelegramPngDownload(): ReturnType { - return vi.spyOn(globalThis, "fetch").mockResolvedValue({ - ok: true, - status: 200, - statusText: "OK", - headers: { get: () => "image/png" }, - arrayBuffer: async () => new Uint8Array([0x89, 0x50, 0x4e, 0x47]).buffer, - } as unknown as Response); +export function mockTelegramPngDownload(): FetchMockHandle { + fetchRemoteMediaSpyRef.mockResolvedValue({ + buffer: Buffer.from(new Uint8Array([0x89, 0x50, 0x4e, 0x47])), + contentType: "image/png", + fileName: "mock-file.png", + }); + return createFetchMockHandle(); +} + +export function watchTelegramFetch(): FetchMockHandle { + return createFetchMockHandle(); } beforeEach(() => { @@ -106,6 +120,8 @@ beforeAll(async () => { const harness = await import("./bot.media.e2e-harness.js"); onSpyRef = harness.onSpy; sendChatActionSpyRef = harness.sendChatActionSpy; + fetchRemoteMediaSpyRef = harness.fetchRemoteMediaSpy; + resetFetchRemoteMediaMockRef = harness.resetFetchRemoteMediaMock; const botModule = await import("./bot.js"); botModule.setTelegramBotRuntimeForTest( harness.telegramBotRuntimeForTest as unknown as Parameters< @@ -121,8 +137,12 @@ beforeAll(async () => { replySpyRef = (replyModule as unknown as { __replySpy: ReturnType }).__replySpy; }, TELEGRAM_BOT_IMPORT_TIMEOUT_MS); -vi.mock("./sticker-cache.js", () => ({ - cacheSticker: (...args: unknown[]) => cacheStickerSpy(...args), - getCachedSticker: (...args: unknown[]) => getCachedStickerSpy(...args), - describeStickerImage: (...args: unknown[]) => describeStickerImageSpy(...args), -})); +vi.mock("./sticker-cache.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + cacheSticker: (...args: unknown[]) => cacheStickerSpy(...args), + getCachedSticker: (...args: unknown[]) => getCachedStickerSpy(...args), + describeStickerImage: (...args: unknown[]) => describeStickerImageSpy(...args), + }; +}); diff --git a/src/auto-reply/inbound-debounce.ts b/src/auto-reply/inbound-debounce.ts index 940732800d3..debda7bc7b5 100644 --- a/src/auto-reply/inbound-debounce.ts +++ b/src/auto-reply/inbound-debounce.ts @@ -88,8 +88,8 @@ export function createInboundDebouncer(params: InboundDebounceCreateParams if (buffer.timeout) { clearTimeout(buffer.timeout); } - buffer.timeout = setTimeout(() => { - void flushBuffer(key, buffer); + buffer.timeout = setTimeout(async () => { + await flushBuffer(key, buffer); }, buffer.debounceMs); buffer.timeout.unref?.(); }; From 25011bdb1ed11763fac0cfc29ae6bc0a94dc5c4b Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Wed, 18 Mar 2026 01:08:22 -0700 Subject: [PATCH 25/55] Plugins: prefer source bundles in git checkouts --- src/plugins/bundled-dir.test.ts | 21 +++++++++++++++++++++ src/plugins/bundled-dir.ts | 13 ++++++++++++- 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/src/plugins/bundled-dir.test.ts b/src/plugins/bundled-dir.test.ts index fd978ec7069..9ff474a4ada 100644 --- a/src/plugins/bundled-dir.test.ts +++ b/src/plugins/bundled-dir.test.ts @@ -68,4 +68,25 @@ describe("resolveBundledPluginsDir", () => { fs.realpathSync(path.join(repoRoot, "extensions")), ); }); + + it("prefers source extensions in a git checkout even without vitest env", () => { + const repoRoot = makeRepoRoot("openclaw-bundled-dir-git-"); + fs.mkdirSync(path.join(repoRoot, "extensions"), { recursive: true }); + fs.mkdirSync(path.join(repoRoot, "src"), { recursive: true }); + fs.mkdirSync(path.join(repoRoot, "dist-runtime", "extensions"), { recursive: true }); + fs.mkdirSync(path.join(repoRoot, "dist", "extensions"), { recursive: true }); + fs.writeFileSync(path.join(repoRoot, ".git"), "gitdir: /tmp/fake.git\n", "utf8"); + fs.writeFileSync( + path.join(repoRoot, "package.json"), + `${JSON.stringify({ name: "openclaw" }, null, 2)}\n`, + "utf8", + ); + + process.chdir(repoRoot); + delete process.env.VITEST; + + expect(fs.realpathSync(resolveBundledPluginsDir() ?? "")).toBe( + fs.realpathSync(path.join(repoRoot, "extensions")), + ); + }); }); diff --git a/src/plugins/bundled-dir.ts b/src/plugins/bundled-dir.ts index 6614a50aed0..419e708ed08 100644 --- a/src/plugins/bundled-dir.ts +++ b/src/plugins/bundled-dir.ts @@ -4,6 +4,14 @@ import { fileURLToPath } from "node:url"; import { resolveOpenClawPackageRootSync } from "../infra/openclaw-root.js"; import { resolveUserPath } from "../utils.js"; +function isSourceCheckoutRoot(packageRoot: string): boolean { + return ( + fs.existsSync(path.join(packageRoot, ".git")) && + fs.existsSync(path.join(packageRoot, "src")) && + fs.existsSync(path.join(packageRoot, "extensions")) + ); +} + export function resolveBundledPluginsDir(env: NodeJS.ProcessEnv = process.env): string | undefined { const override = env.OPENCLAW_BUNDLED_PLUGINS_DIR?.trim(); if (override) { @@ -21,7 +29,10 @@ export function resolveBundledPluginsDir(env: NodeJS.ProcessEnv = process.env): ); for (const packageRoot of packageRoots) { const sourceExtensionsDir = path.join(packageRoot, "extensions"); - if (preferSourceCheckout && fs.existsSync(sourceExtensionsDir)) { + if ( + (preferSourceCheckout || isSourceCheckoutRoot(packageRoot)) && + fs.existsSync(sourceExtensionsDir) + ) { return sourceExtensionsDir; } // Local source checkouts stage a runtime-complete bundled plugin tree under From d1ef7d64e96127c7798f151eb49db15caf7aeb17 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Wed, 18 Mar 2026 01:30:05 -0700 Subject: [PATCH 26/55] Contracts: harden provider registry loading --- extensions/github-copilot/index.ts | 8 +--- .../contracts/provider.contract.test.ts | 11 ++++- src/plugins/contracts/registry.ts | 43 +++++++++++++------ 3 files changed, 41 insertions(+), 21 deletions(-) diff --git a/extensions/github-copilot/index.ts b/extensions/github-copilot/index.ts index ee85f76fd61..39116636b76 100644 --- a/extensions/github-copilot/index.ts +++ b/extensions/github-copilot/index.ts @@ -1,15 +1,11 @@ +import { ensureAuthProfileStore, listProfilesForProvider } from "openclaw/plugin-sdk/agent-runtime"; import { definePluginEntry, type ProviderAuthContext, type ProviderResolveDynamicModelContext, type ProviderRuntimeModel, } from "openclaw/plugin-sdk/core"; -import { - coerceSecretRef, - ensureAuthProfileStore, - githubCopilotLoginCommand, - listProfilesForProvider, -} from "openclaw/plugin-sdk/provider-auth"; +import { coerceSecretRef, githubCopilotLoginCommand } from "openclaw/plugin-sdk/provider-auth"; import { normalizeModelCompat } from "openclaw/plugin-sdk/provider-models"; import { DEFAULT_COPILOT_API_BASE_URL, resolveCopilotApiToken } from "./token.js"; import { fetchCopilotUsage } from "./usage.js"; diff --git a/src/plugins/contracts/provider.contract.test.ts b/src/plugins/contracts/provider.contract.test.ts index 9ff8f7458d3..db5ce6e3c03 100644 --- a/src/plugins/contracts/provider.contract.test.ts +++ b/src/plugins/contracts/provider.contract.test.ts @@ -1,7 +1,14 @@ -import { describe } from "vitest"; -import { providerContractRegistry } from "./registry.js"; +import { describe, expect, it } from "vitest"; +import { providerContractLoadError, providerContractRegistry } from "./registry.js"; import { installProviderPluginContractSuite } from "./suites.js"; +describe("provider contract registry load", () => { + it("loads bundled providers without import-time registry failure", () => { + expect(providerContractLoadError).toBeUndefined(); + expect(providerContractRegistry.length).toBeGreaterThan(0); + }); +}); + for (const entry of providerContractRegistry) { describe(`${entry.pluginId}:${entry.provider.id} provider contract`, () => { installProviderPluginContractSuite({ diff --git a/src/plugins/contracts/registry.ts b/src/plugins/contracts/registry.ts index e4b6cf1059a..142aa578b0f 100644 --- a/src/plugins/contracts/registry.ts +++ b/src/plugins/contracts/registry.ts @@ -99,19 +99,31 @@ export const providerContractRegistry: ProviderContractEntry[] = buildCapability select: () => [], }); -const loadedBundledProviderRegistry: ProviderContractEntry[] = resolvePluginProviders({ - bundledProviderAllowlistCompat: true, - bundledProviderVitestCompat: true, - cache: false, - activate: false, -}) - .filter((provider): provider is ProviderPlugin & { pluginId: string } => - Boolean(provider.pluginId), - ) - .map((provider) => ({ - pluginId: provider.pluginId, - provider, - })); +export let providerContractLoadError: Error | undefined; + +function loadBundledProviderRegistry(): ProviderContractEntry[] { + try { + providerContractLoadError = undefined; + return resolvePluginProviders({ + bundledProviderAllowlistCompat: true, + bundledProviderVitestCompat: true, + cache: false, + activate: false, + }) + .filter((provider): provider is ProviderPlugin & { pluginId: string } => + Boolean(provider.pluginId), + ) + .map((provider) => ({ + pluginId: provider.pluginId, + provider, + })); + } catch (error) { + providerContractLoadError = error instanceof Error ? error : new Error(String(error)); + return []; + } +} + +const loadedBundledProviderRegistry: ProviderContractEntry[] = loadBundledProviderRegistry(); providerContractRegistry.splice( 0, @@ -134,6 +146,11 @@ export const providerContractCompatPluginIds = providerContractPluginIds.map((pl export function requireProviderContractProvider(providerId: string): ProviderPlugin { const provider = uniqueProviderContractProviders.find((entry) => entry.id === providerId); if (!provider) { + if (providerContractLoadError) { + throw new Error( + `provider contract entry missing for ${providerId}; bundled provider registry failed to load: ${providerContractLoadError.message}`, + ); + } throw new Error(`provider contract entry missing for ${providerId}`); } return provider; From 3cecbcf8b6f0de4395a36c3af9f2e205e5b81ab4 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Wed, 18 Mar 2026 01:31:25 -0700 Subject: [PATCH 27/55] docs: fix curly quotes, non-breaking hyphens, and remaining apostrophes in headings --- README.md | 2 +- docs/automation/cron-jobs.md | 2 +- docs/channels/group-messages.md | 2 +- docs/cli/directory.md | 2 +- docs/concepts/context.md | 4 ++-- docs/concepts/model-failover.md | 2 +- docs/concepts/models.md | 2 +- docs/concepts/multi-agent.md | 2 +- docs/concepts/presence.md | 2 +- docs/concepts/streaming.md | 2 +- docs/concepts/typebox.md | 2 +- docs/gateway/authentication.md | 2 +- docs/gateway/bonjour.md | 6 +++--- docs/gateway/discovery.md | 2 +- docs/gateway/remote.md | 2 +- docs/gateway/sandbox-vs-tool-policy-vs-elevated.md | 8 ++++---- docs/gateway/security/index.md | 2 +- docs/help/testing.md | 4 ++-- docs/install/updating.md | 2 +- docs/nodes/media-understanding.md | 4 ++-- docs/platforms/mac/dev-setup.md | 2 +- docs/platforms/mac/peekaboo.md | 2 +- docs/platforms/mac/webchat.md | 2 +- docs/plugins/agent-tools.md | 2 +- docs/providers/bedrock.md | 2 +- docs/providers/minimax.md | 2 +- docs/reference/session-management-compaction.md | 2 +- docs/start/openclaw.md | 2 +- docs/start/setup.md | 2 +- docs/tools/elevated.md | 2 +- docs/tools/plugin.md | 2 +- docs/web/dashboard.md | 2 +- docs/zh-CN/start/hubs.md | 6 ------ 33 files changed, 40 insertions(+), 46 deletions(-) diff --git a/README.md b/README.md index 1c836da84ee..e483bcc9446 100644 --- a/README.md +++ b/README.md @@ -293,7 +293,7 @@ If you plan to build/run companion apps, follow the platform runbooks below. - WebChat + debug tools. - Remote gateway control over SSH. -Note: signed builds required for macOS permissions to stick across rebuilds (see `docs/mac/permissions.md`). +Note: signed builds required for macOS permissions to stick across rebuilds (see [macOS Permissions](https://docs.openclaw.ai/platforms/mac/permissions)). ### iOS node (optional) diff --git a/docs/automation/cron-jobs.md b/docs/automation/cron-jobs.md index cb27380416b..d58683aedea 100644 --- a/docs/automation/cron-jobs.md +++ b/docs/automation/cron-jobs.md @@ -700,7 +700,7 @@ openclaw system event --mode now --text "Next heartbeat: check battery." ## Troubleshooting -### “Nothing runs” +### "Nothing runs" - Check cron is enabled: `cron.enabled` and `OPENCLAW_SKIP_CRON`. - Check the Gateway is running continuously (cron runs inside the Gateway process). diff --git a/docs/channels/group-messages.md b/docs/channels/group-messages.md index 078ae9e7845..c1858bf1d96 100644 --- a/docs/channels/group-messages.md +++ b/docs/channels/group-messages.md @@ -11,7 +11,7 @@ Goal: let Clawd sit in WhatsApp groups, wake up only when pinged, and keep that Note: `agents.list[].groupChat.mentionPatterns` is now used by Telegram/Discord/Slack/iMessage as well; this doc focuses on WhatsApp-specific behavior. For multi-agent setups, set `agents.list[].groupChat.mentionPatterns` per agent (or use `messages.groupChat.mentionPatterns` as a global fallback). -## What’s implemented (2025-12-03) +## Current implementation (2025-12-03) - Activation modes: `mention` (default) or `always`. `mention` requires a ping (real WhatsApp @-mentions via `mentionedJids`, safe regex patterns, or the bot’s E.164 anywhere in the text). `always` wakes the agent on every message but it should reply only when it can add meaningful value; otherwise it returns the silent token `NO_REPLY`. Defaults can be set in config (`channels.whatsapp.groups`) and overridden per group via `/activation`. When `channels.whatsapp.groups` is set, it also acts as a group allowlist (include `"*"` to allow all). - Group policy: `channels.whatsapp.groupPolicy` controls whether group messages are accepted (`open|disabled|allowlist`). `allowlist` uses `channels.whatsapp.groupAllowFrom` (fallback: explicit `channels.whatsapp.allowFrom`). Default is `allowlist` (blocked until you add senders). diff --git a/docs/cli/directory.md b/docs/cli/directory.md index 9d8f8a92b68..15ba7ba60e1 100644 --- a/docs/cli/directory.md +++ b/docs/cli/directory.md @@ -40,7 +40,7 @@ openclaw message send --channel slack --target user:U012ABCDEF --message "hello" - Zalo (plugin): user id (Bot API) - Zalo Personal / `zalouser` (plugin): thread id (DM/group) from `zca` (`me`, `friend list`, `group list`) -## Self (“me”) +## Self ("me") ```bash openclaw directory self --channel zalouser diff --git a/docs/concepts/context.md b/docs/concepts/context.md index 356f8b810c3..107afc164ae 100644 --- a/docs/concepts/context.md +++ b/docs/concepts/context.md @@ -116,7 +116,7 @@ Large files are truncated per-file using `agents.defaults.bootstrapMaxChars` (de When truncation occurs, the runtime can inject an in-prompt warning block under Project Context. Configure this with `agents.defaults.bootstrapPromptTruncationWarning` (`off`, `once`, `always`; default `once`). -## Skills: what’s injected vs loaded on-demand +## Skills: injected vs loaded on-demand The system prompt includes a compact **skills list** (name + description + location). This list has real overhead. @@ -131,7 +131,7 @@ Tools affect context in two ways: `/context detail` breaks down the biggest tool schemas so you can see what dominates. -## Commands, directives, and “inline shortcuts” +## Commands, directives, and "inline shortcuts" Slash commands are handled by the Gateway. There are a few different behaviors: diff --git a/docs/concepts/model-failover.md b/docs/concepts/model-failover.md index 80b3420d07c..80592bcc2c9 100644 --- a/docs/concepts/model-failover.md +++ b/docs/concepts/model-failover.md @@ -70,7 +70,7 @@ they are tried first, but OpenClaw may rotate to another profile on rate limits/ User‑pinned profiles stay locked to that profile; if it fails and model fallbacks are configured, OpenClaw moves to the next model instead of switching profiles. -### Why OAuth can “look lost” +### Why OAuth can "look lost" If you have both an OAuth profile and an API key profile for the same provider, round‑robin can switch between them across messages unless pinned. To force a single profile: diff --git a/docs/concepts/models.md b/docs/concepts/models.md index 6ed1d1de3ab..0a32e1b5d8b 100644 --- a/docs/concepts/models.md +++ b/docs/concepts/models.md @@ -60,7 +60,7 @@ to `zai/*`. Provider configuration examples (including OpenCode) live in [/gateway/configuration](/gateway/configuration#opencode). -## “Model is not allowed” (and why replies stop) +## "Model is not allowed" (and why replies stop) If `agents.defaults.models` is set, it becomes the **allowlist** for `/model` and for session overrides. When a user selects a model that isn’t in that allowlist, diff --git a/docs/concepts/multi-agent.md b/docs/concepts/multi-agent.md index 6f0bd086690..3f52fa77e74 100644 --- a/docs/concepts/multi-agent.md +++ b/docs/concepts/multi-agent.md @@ -9,7 +9,7 @@ status: active Goal: multiple _isolated_ agents (separate workspace + `agentDir` + sessions), plus multiple channel accounts (e.g. two WhatsApps) in one running Gateway. Inbound is routed to an agent via bindings. -## What is “one agent”? +## What is "one agent"? An **agent** is a fully scoped brain with its own: diff --git a/docs/concepts/presence.md b/docs/concepts/presence.md index a185205793a..1c9a7e3a12a 100644 --- a/docs/concepts/presence.md +++ b/docs/concepts/presence.md @@ -45,7 +45,7 @@ even before any clients connect. Every WS client begins with a `connect` request. On successful handshake the Gateway upserts a presence entry for that connection. -#### Why one‑off CLI commands don’t show up +#### Why one-off CLI commands do not show up The CLI often connects for short, one‑off commands. To avoid spamming the Instances list, `client.mode === "cli"` is **not** turned into a presence entry. diff --git a/docs/concepts/streaming.md b/docs/concepts/streaming.md index c31048cb268..3f69ada2b91 100644 --- a/docs/concepts/streaming.md +++ b/docs/concepts/streaming.md @@ -90,7 +90,7 @@ more natural. - Modes: `off` (default), `natural` (800–2500ms), `custom` (`minMs`/`maxMs`). - Applies only to **block replies**, not final replies or tool summaries. -## “Stream chunks or everything” +## "Stream chunks or everything" This maps to: diff --git a/docs/concepts/typebox.md b/docs/concepts/typebox.md index 92c6eef2fe9..274e9e3beaa 100644 --- a/docs/concepts/typebox.md +++ b/docs/concepts/typebox.md @@ -185,7 +185,7 @@ ws.on("message", (data) => { }); ``` -## Worked example: add a method end‑to‑end +## Worked example: add a method end-to-end Example: add a new `system.echo` request that returns `{ ok: true, text }`. diff --git a/docs/gateway/authentication.md b/docs/gateway/authentication.md index 8a7eae00194..895124bd8c3 100644 --- a/docs/gateway/authentication.md +++ b/docs/gateway/authentication.md @@ -159,7 +159,7 @@ Use `--agent ` to target a specific agent; omit it to use the configured def ## Troubleshooting -### “No credentials found” +### "No credentials found" If the Anthropic token profile is missing, run `claude setup-token` on the **gateway host**, then re-check: diff --git a/docs/gateway/bonjour.md b/docs/gateway/bonjour.md index 03643717d55..16aa5c68d2b 100644 --- a/docs/gateway/bonjour.md +++ b/docs/gateway/bonjour.md @@ -12,7 +12,7 @@ OpenClaw uses Bonjour (mDNS / DNS‑SD) as a **LAN‑only convenience** to disco an active Gateway (WebSocket endpoint). It is best‑effort and does **not** replace SSH or Tailnet-based connectivity. -## Wide‑area Bonjour (Unicast DNS‑SD) over Tailscale +## Wide-area Bonjour (Unicast DNS-SD) over Tailscale If the node and gateway are on different networks, multicast mDNS won’t cross the boundary. You can keep the same discovery UX by switching to **unicast DNS‑SD** @@ -38,7 +38,7 @@ iOS/Android nodes browse both `local.` and your configured wide‑area domain. } ``` -### One‑time DNS server setup (gateway host) +### One-time DNS server setup (gateway host) ```bash openclaw dns setup --apply @@ -84,7 +84,7 @@ Only the Gateway advertises `_openclaw-gw._tcp`. - `_openclaw-gw._tcp` — gateway transport beacon (used by macOS/iOS/Android nodes). -## TXT keys (non‑secret hints) +## TXT keys (non-secret hints) The Gateway advertises small non‑secret hints to make UI flows convenient: diff --git a/docs/gateway/discovery.md b/docs/gateway/discovery.md index af1144125d3..cfdc3afdfe0 100644 --- a/docs/gateway/discovery.md +++ b/docs/gateway/discovery.md @@ -29,7 +29,7 @@ Protocol details: - [Gateway protocol](/gateway/protocol) - [Bridge protocol (legacy)](/gateway/bridge-protocol) -## Why we keep both “direct” and SSH +## Why we keep both "direct" and SSH - **Direct WS** is the best UX on the same network and within a tailnet: - auto-discovery on LAN via Bonjour diff --git a/docs/gateway/remote.md b/docs/gateway/remote.md index dcbae985b74..a1bc4720ad6 100644 --- a/docs/gateway/remote.md +++ b/docs/gateway/remote.md @@ -126,7 +126,7 @@ WebChat no longer uses a separate HTTP port. The SwiftUI chat UI connects direct - Forward `18789` over SSH (see above), then connect clients to `ws://127.0.0.1:18789`. - On macOS, prefer the app’s “Remote over SSH” mode, which manages the tunnel automatically. -## macOS app “Remote over SSH” +## macOS app "Remote over SSH" The macOS menu bar app can drive the same setup end-to-end (remote status checks, WebChat, and Voice Wake forwarding). diff --git a/docs/gateway/sandbox-vs-tool-policy-vs-elevated.md b/docs/gateway/sandbox-vs-tool-policy-vs-elevated.md index 9e7fecfd949..080ced13b2f 100644 --- a/docs/gateway/sandbox-vs-tool-policy-vs-elevated.md +++ b/docs/gateway/sandbox-vs-tool-policy-vs-elevated.md @@ -95,7 +95,7 @@ Available groups: - `group:nodes`: `nodes` - `group:openclaw`: all built-in OpenClaw tools (excludes provider plugins) -## Elevated: exec-only “run on host” +## Elevated: exec-only "run on host" Elevated does **not** grant extra tools; it only affects `exec`. @@ -112,9 +112,9 @@ Gates: See [Elevated Mode](/tools/elevated). -## Common “sandbox jail” fixes +## Common "sandbox jail" fixes -### “Tool X blocked by sandbox tool policy” +### "Tool X blocked by sandbox tool policy" Fix-it keys (pick one): @@ -123,6 +123,6 @@ Fix-it keys (pick one): - remove it from `tools.sandbox.tools.deny` (or per-agent `agents.list[].tools.sandbox.tools.deny`) - or add it to `tools.sandbox.tools.allow` (or per-agent allow) -### “I thought this was main, why is it sandboxed?” +### "I thought this was main, why is it sandboxed?" In `"non-main"` mode, group/channel keys are _not_ main. Use the main session key (shown by `sandbox explain`) or switch mode to `"off"`. diff --git a/docs/gateway/security/index.md b/docs/gateway/security/index.md index b9f37597b58..8cea1b42766 100644 --- a/docs/gateway/security/index.md +++ b/docs/gateway/security/index.md @@ -840,7 +840,7 @@ Avoid: - Exposing relay/control ports over LAN or public Internet. - Tailscale Funnel for browser control endpoints (public exposure). -### 0.7) Secrets on disk (what’s sensitive) +### 0.7) Secrets on disk (sensitive data) Assume anything under `~/.openclaw/` (or `$OPENCLAW_STATE_DIR/`) may contain secrets or private data: diff --git a/docs/help/testing.md b/docs/help/testing.md index e2cae188c0e..2d7e9664176 100644 --- a/docs/help/testing.md +++ b/docs/help/testing.md @@ -176,7 +176,7 @@ Live tests are split into two layers so we can isolate failures: - Separates “provider API is broken / key is invalid” from “gateway agent pipeline is broken” - Contains small, isolated regressions (example: OpenAI Responses/Codex Responses reasoning replay + tool-call flows) -### Layer 2: Gateway + dev agent smoke (what “@openclaw” actually does) +### Layer 2: Gateway + dev agent smoke (what "@openclaw" actually does) - Test: `src/gateway/gateway-models.profiles.live.test.ts` - Goal: @@ -395,7 +395,7 @@ If you want to rely on env keys (e.g. exported in your `~/.profile`), run local - Optional auth behavior: - `OPENCLAW_LIVE_REQUIRE_PROFILE_KEYS=1` to force profile-store auth and ignore env-only overrides -## Docker runners (optional “works in Linux” checks) +## Docker runners (optional "works in Linux" checks) These run `pnpm test:live` inside the repo Docker image, mounting your local config dir and workspace (and sourcing `~/.profile` if mounted). They also bind-mount CLI auth homes like `~/.codex`, `~/.claude`, `~/.qwen`, and `~/.minimax` when present, then copy them into the container home before the run so external-CLI OAuth can refresh tokens without mutating the host auth store: diff --git a/docs/install/updating.md b/docs/install/updating.md index dd3128c553e..0b88d91ed9e 100644 --- a/docs/install/updating.md +++ b/docs/install/updating.md @@ -268,7 +268,7 @@ git checkout main git pull ``` -## If you’re stuck +## If you are stuck - Run `openclaw doctor` again and read the output carefully (it often tells you the fix). - Check: [Troubleshooting](/gateway/troubleshooting) diff --git a/docs/nodes/media-understanding.md b/docs/nodes/media-understanding.md index 3178854ccfb..9d20c0c83d4 100644 --- a/docs/nodes/media-understanding.md +++ b/docs/nodes/media-understanding.md @@ -21,7 +21,7 @@ integration. - Support **provider APIs** and **CLI fallbacks**. - Allow multiple models with ordered fallback (error/size/timeout). -## High‑level behavior +## High-level behavior 1. Collect inbound attachments (`MediaPaths`, `MediaUrls`, `MediaTypes`). 2. For each enabled capability (image/audio/video), select attachments per policy (default: **first**). @@ -334,7 +334,7 @@ When `mode: "all"`, outputs are labeled `[Image 1/2]`, `[Audio 2/2]`, etc. } ``` -### 4) Multi‑modal single entry (explicit capabilities) +### 4) Multi-modal single entry (explicit capabilities) ```json5 { diff --git a/docs/platforms/mac/dev-setup.md b/docs/platforms/mac/dev-setup.md index 982f687049c..0e7c058a934 100644 --- a/docs/platforms/mac/dev-setup.md +++ b/docs/platforms/mac/dev-setup.md @@ -97,7 +97,7 @@ If the gateway status stays on "Starting...", check if a zombie process is holdi openclaw gateway status openclaw gateway stop -# If you’re not using a LaunchAgent (dev mode / manual runs), find the listener: +# If you're not using a LaunchAgent (dev mode / manual runs), find the listener: lsof -nP -iTCP:18789 -sTCP:LISTEN ``` diff --git a/docs/platforms/mac/peekaboo.md b/docs/platforms/mac/peekaboo.md index d1947734735..96761a0ad74 100644 --- a/docs/platforms/mac/peekaboo.md +++ b/docs/platforms/mac/peekaboo.md @@ -13,7 +13,7 @@ OpenClaw can host **PeekabooBridge** as a local, permission‑aware UI automatio broker. This lets the `peekaboo` CLI drive UI automation while reusing the macOS app’s TCC permissions. -## What this is (and isn’t) +## What this is (and is not) - **Host**: OpenClaw.app can act as a PeekabooBridge host. - **Client**: use the `peekaboo` CLI (no separate `openclaw ui ...` surface). diff --git a/docs/platforms/mac/webchat.md b/docs/platforms/mac/webchat.md index 6bc27203fae..bf8b23c35e4 100644 --- a/docs/platforms/mac/webchat.md +++ b/docs/platforms/mac/webchat.md @@ -26,7 +26,7 @@ agent (with a session switcher for other sessions). - Logs: `./scripts/clawlog.sh` (subsystem `ai.openclaw`, category `WebChatSwiftUI`). -## How it’s wired +## How it is wired - Data plane: Gateway WS methods `chat.history`, `chat.send`, `chat.abort`, `chat.inject` and events `chat`, `agent`, `presence`, `tick`, `health`. diff --git a/docs/plugins/agent-tools.md b/docs/plugins/agent-tools.md index f5d5d8cc3a8..8740fd51fa4 100644 --- a/docs/plugins/agent-tools.md +++ b/docs/plugins/agent-tools.md @@ -35,7 +35,7 @@ export default function (api) { } ``` -## Optional tool (opt‑in) +## Optional tool (opt-in) Optional tools are **never** auto‑enabled. Users must add them to an agent allowlist. diff --git a/docs/providers/bedrock.md b/docs/providers/bedrock.md index e6e3f807ee9..5fbed2b261f 100644 --- a/docs/providers/bedrock.md +++ b/docs/providers/bedrock.md @@ -12,7 +12,7 @@ OpenClaw can use **Amazon Bedrock** models via pi‑ai’s **Bedrock Converse** streaming provider. Bedrock auth uses the **AWS SDK default credential chain**, not an API key. -## What pi‑ai supports +## What pi-ai supports - Provider: `amazon-bedrock` - API: `bedrock-converse-stream` diff --git a/docs/providers/minimax.md b/docs/providers/minimax.md index c578a89d6e5..cc678349423 100644 --- a/docs/providers/minimax.md +++ b/docs/providers/minimax.md @@ -194,7 +194,7 @@ Use the interactive config wizard to set MiniMax without editing JSON: ## Troubleshooting -### “Unknown model: minimax/MiniMax-M2.5” +### "Unknown model: minimax/MiniMax-M2.5" This usually means the **MiniMax provider isn’t configured** (no provider entry and no MiniMax auth profile/env key found). A fix for this detection is in diff --git a/docs/reference/session-management-compaction.md b/docs/reference/session-management-compaction.md index d258eeb6722..02ff1115e4a 100644 --- a/docs/reference/session-management-compaction.md +++ b/docs/reference/session-management-compaction.md @@ -280,7 +280,7 @@ As of `2026.1.10`, OpenClaw also suppresses **draft/typing streaming** when a pa --- -## Pre-compaction “memory flush” (implemented) +## Pre-compaction "memory flush" (implemented) Goal: before auto-compaction happens, run a silent agentic turn that writes durable state to disk (e.g. `memory/YYYY-MM-DD.md` in the agent workspace) so compaction can’t diff --git a/docs/start/openclaw.md b/docs/start/openclaw.md index 671efe420c7..3bb0b454b25 100644 --- a/docs/start/openclaw.md +++ b/docs/start/openclaw.md @@ -102,7 +102,7 @@ If you already ship your own workspace files from a repo, you can disable bootst } ``` -## The config that turns it into “an assistant” +## The config that turns it into "an assistant" OpenClaw defaults to a good assistant setup, but you’ll usually want to tune: diff --git a/docs/start/setup.md b/docs/start/setup.md index 7e3ec6dfc2d..70da5578c08 100644 --- a/docs/start/setup.md +++ b/docs/start/setup.md @@ -27,7 +27,7 @@ Last updated: 2026-01-01 - `pnpm` - Docker (optional; only for containerized setup/e2e — see [Docker](/install/docker)) -## Tailoring strategy (so updates don’t hurt) +## Tailoring strategy (so updates do not hurt) If you want “100% tailored to me” _and_ easy updates, keep your customization in: diff --git a/docs/tools/elevated.md b/docs/tools/elevated.md index eed788eda8c..c10b955ce2d 100644 --- a/docs/tools/elevated.md +++ b/docs/tools/elevated.md @@ -17,7 +17,7 @@ title: "Elevated Mode" - Directive forms: `/elevated on|off|ask|full`, `/elev on|off|ask|full`. - Only `on|off|ask|full` are accepted; anything else returns a hint and does not change state. -## What it controls (and what it doesn’t) +## What it controls (and what it does not) - **Availability gates**: `tools.elevated` is the global baseline. `agents.list[].tools.elevated` can further restrict elevated per agent (both must allow). - **Per-session state**: `/elevated on|off|ask|full` sets the elevated level for the current session key. diff --git a/docs/tools/plugin.md b/docs/tools/plugin.md index 438a3975e14..b3872c8ae67 100644 --- a/docs/tools/plugin.md +++ b/docs/tools/plugin.md @@ -2290,7 +2290,7 @@ Preferred setup split: - optional DM allowlist resolution (for example `@username` -> numeric id) - optional completion note after setup finishes -### Write a new messaging channel (step‑by‑step) +### Write a new messaging channel (step-by-step) Use this when you want a **new chat surface** (a "messaging channel"), not a model provider. Model provider docs live under `/providers/*`. diff --git a/docs/web/dashboard.md b/docs/web/dashboard.md index 86cd6fffd4e..71238e0b2bc 100644 --- a/docs/web/dashboard.md +++ b/docs/web/dashboard.md @@ -42,7 +42,7 @@ Prefer localhost, Tailscale Serve, or an SSH tunnel. - If `gateway.auth.token` is configured as a SecretRef and is unresolved in your current shell, `openclaw dashboard` still prints a non-tokenized URL plus actionable auth setup guidance. - **Not localhost**: use Tailscale Serve (tokenless for Control UI/WebSocket if `gateway.auth.allowTailscale: true`, assumes trusted gateway host; HTTP APIs still need token/password), tailnet bind with a token, or an SSH tunnel. See [Web surfaces](/web). -## If you see “unauthorized” / 1008 +## If you see "unauthorized" / 1008 - Ensure the gateway is reachable (local: `openclaw status`; remote: SSH tunnel `ssh -N -L 18789:127.0.0.1:18789 user@host` then open `http://127.0.0.1:18789/`). - For `AUTH_TOKEN_MISMATCH`, clients may do one trusted retry with a cached device token when the gateway returns retry hints. If auth still fails after that retry, resolve token drift manually. diff --git a/docs/zh-CN/start/hubs.md b/docs/zh-CN/start/hubs.md index c5dce882420..c0e1ed0851a 100644 --- a/docs/zh-CN/start/hubs.md +++ b/docs/zh-CN/start/hubs.md @@ -183,12 +183,6 @@ x-i18n: - [模板:TOOLS](/reference/templates/TOOLS) - [模板:USER](/reference/templates/USER) -## 实验(探索性) - -- [新手引导配置协议](/experiments/onboarding-config-protocol) -- [研究:记忆](/experiments/research/memory) -- [模型配置探索](/experiments/proposals/model-config) - ## 项目 - [致谢](/reference/credits) From 5625cf4724532ce1b0ab0847db838013ac2d92ae Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Wed, 18 Mar 2026 01:33:04 -0700 Subject: [PATCH 28/55] fix(agents): correct broken docs/testing.md path in AGENTS.md --- AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 12a86185aaa..9bb22dafbb3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -140,7 +140,7 @@ - Do not set test workers above 16; tried already. - If local Vitest runs cause memory pressure (common on non-Mac-Studio hosts), use `OPENCLAW_TEST_PROFILE=low OPENCLAW_TEST_SERIAL_GATEWAY=1 pnpm test` for land/gate runs. - Live tests (real keys): `CLAWDBOT_LIVE_TEST=1 pnpm test:live` (OpenClaw-only) or `LIVE=1 pnpm test:live` (includes provider live tests). Docker: `pnpm test:docker:live-models`, `pnpm test:docker:live-gateway`. Onboarding Docker E2E: `pnpm test:docker:onboard`. -- Full kit + what’s covered: `docs/testing.md`. +- Full kit + what’s covered: `docs/help/testing.md`. - Changelog: user-facing changes only; no internal/meta notes (version alignment, appcast reminders, release process). - Changelog placement: in the active version block, append new entries to the end of the target section (`### Changes` or `### Fixes`); do not insert new entries at the top of a section. - Changelog attribution: use at most one contributor mention per line; prefer `Thanks @author` and do not also add `by @author` on the same entry. From 7ac23ae7c2b6c788c2c2ca785777808ae9c4941e Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Wed, 18 Mar 2026 01:41:44 -0700 Subject: [PATCH 29/55] Plugins: fix bundled web search compat registry --- src/plugins/bundled-web-search.test.ts | 13 ++++++++++++ src/plugins/bundled-web-search.ts | 29 ++++++++++++++++++++++++++ src/plugins/web-search-providers.ts | 19 +++-------------- 3 files changed, 45 insertions(+), 16 deletions(-) create mode 100644 src/plugins/bundled-web-search.test.ts create mode 100644 src/plugins/bundled-web-search.ts diff --git a/src/plugins/bundled-web-search.test.ts b/src/plugins/bundled-web-search.test.ts new file mode 100644 index 00000000000..7db116a426f --- /dev/null +++ b/src/plugins/bundled-web-search.test.ts @@ -0,0 +1,13 @@ +import { expect, it } from "vitest"; +import { resolveBundledWebSearchPluginIds } from "./bundled-web-search.js"; + +it("keeps bundled web search compat ids aligned with bundled manifests", () => { + expect(resolveBundledWebSearchPluginIds({})).toEqual([ + "brave", + "firecrawl", + "google", + "moonshot", + "perplexity", + "xai", + ]); +}); diff --git a/src/plugins/bundled-web-search.ts b/src/plugins/bundled-web-search.ts new file mode 100644 index 00000000000..248928b093c --- /dev/null +++ b/src/plugins/bundled-web-search.ts @@ -0,0 +1,29 @@ +import type { PluginLoadOptions } from "./loader.js"; +import { loadPluginManifestRegistry } from "./manifest-registry.js"; + +export const BUNDLED_WEB_SEARCH_PLUGIN_IDS = [ + "brave", + "firecrawl", + "google", + "moonshot", + "perplexity", + "xai", +] as const; + +const bundledWebSearchPluginIdSet = new Set(BUNDLED_WEB_SEARCH_PLUGIN_IDS); + +export function resolveBundledWebSearchPluginIds(params: { + config?: PluginLoadOptions["config"]; + workspaceDir?: string; + env?: PluginLoadOptions["env"]; +}): string[] { + const registry = loadPluginManifestRegistry({ + config: params.config, + workspaceDir: params.workspaceDir, + env: params.env, + }); + return registry.plugins + .filter((plugin) => plugin.origin === "bundled" && bundledWebSearchPluginIdSet.has(plugin.id)) + .map((plugin) => plugin.id) + .toSorted((left, right) => left.localeCompare(right)); +} diff --git a/src/plugins/web-search-providers.ts b/src/plugins/web-search-providers.ts index 2cf44d9eac4..b415d7c7675 100644 --- a/src/plugins/web-search-providers.ts +++ b/src/plugins/web-search-providers.ts @@ -3,6 +3,7 @@ import { withBundledPluginAllowlistCompat, withBundledPluginEnablementCompat, } from "./bundled-compat.js"; +import { resolveBundledWebSearchPluginIds } from "./bundled-web-search.js"; import { loadOpenClawPlugins, type PluginLoadOptions } from "./loader.js"; import { createPluginLoaderLogger } from "./logger.js"; import { getActivePluginRegistry } from "./runtime.js"; @@ -41,25 +42,11 @@ function resolveBundledWebSearchCompatPluginIds(params: { workspaceDir?: string; env?: PluginLoadOptions["env"]; }): string[] { - const registry = loadOpenClawPlugins({ - config: { - ...params.config, - plugins: { - enabled: true, - }, - }, + return resolveBundledWebSearchPluginIds({ + config: params.config, workspaceDir: params.workspaceDir, env: params.env, - cache: false, - activate: false, - logger: createPluginLoaderLogger(log), }); - const bundledPluginIds = new Set( - registry.plugins.filter((plugin) => plugin.origin === "bundled").map((plugin) => plugin.id), - ); - return [...new Set(registry.webSearchProviders.map((entry) => entry.pluginId))] - .filter((pluginId) => bundledPluginIds.has(pluginId)) - .toSorted((left, right) => left.localeCompare(right)); } function withBundledWebSearchVitestCompat(params: { From 4ac9024de9cfcd58c80db45c782ac0750c9ed47b Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Wed, 18 Mar 2026 01:46:50 -0700 Subject: [PATCH 30/55] Contracts: harden plugin registry loading --- .../contracts/registry.contract.test.ts | 6 + src/plugins/contracts/registry.ts | 250 ++++++++---------- 2 files changed, 120 insertions(+), 136 deletions(-) diff --git a/src/plugins/contracts/registry.contract.test.ts b/src/plugins/contracts/registry.contract.test.ts index 997aa560579..5c8d06785ce 100644 --- a/src/plugins/contracts/registry.contract.test.ts +++ b/src/plugins/contracts/registry.contract.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { loadPluginManifestRegistry } from "../manifest-registry.js"; import { resolvePluginWebSearchProviders } from "../web-search-providers.js"; import { + capabilityContractLoadError, imageGenerationProviderContractRegistry, mediaUnderstandingProviderContractRegistry, pluginRegistrationContractRegistry, @@ -85,6 +86,11 @@ function findRegistrationForPlugin(pluginId: string) { } describe("plugin contract registry", () => { + it("loads bundled non-provider capability registries without import-time failure", () => { + expect(capabilityContractLoadError).toBeUndefined(); + expect(pluginRegistrationContractRegistry.length).toBeGreaterThan(0); + }); + it("does not duplicate bundled provider ids", () => { const ids = providerContractRegistry.map((entry) => entry.provider.id); expect(ids).toEqual([...new Set(ids)]); diff --git a/src/plugins/contracts/registry.ts b/src/plugins/contracts/registry.ts index 142aa578b0f..acee90323b9 100644 --- a/src/plugins/contracts/registry.ts +++ b/src/plugins/contracts/registry.ts @@ -1,17 +1,8 @@ -import anthropicPlugin from "../../../extensions/anthropic/index.js"; -import bravePlugin from "../../../extensions/brave/index.js"; -import elevenLabsPlugin from "../../../extensions/elevenlabs/index.js"; -import firecrawlPlugin from "../../../extensions/firecrawl/index.js"; -import googlePlugin from "../../../extensions/google/index.js"; -import microsoftPlugin from "../../../extensions/microsoft/index.js"; -import minimaxPlugin from "../../../extensions/minimax/index.js"; -import mistralPlugin from "../../../extensions/mistral/index.js"; -import moonshotPlugin from "../../../extensions/moonshot/index.js"; -import openAIPlugin from "../../../extensions/openai/index.js"; -import perplexityPlugin from "../../../extensions/perplexity/index.js"; -import xaiPlugin from "../../../extensions/xai/index.js"; -import zaiPlugin from "../../../extensions/zai/index.js"; -import { createCapturedPluginRegistration } from "../captured-registration.js"; +import { createSubsystemLogger } from "../../logging/subsystem.js"; +import { withBundledPluginEnablementCompat } from "../bundled-compat.js"; +import { resolveBundledWebSearchPluginIds } from "../bundled-web-search.js"; +import { loadOpenClawPlugins } from "../loader.js"; +import { createPluginLoaderLogger } from "../logger.js"; import { resolvePluginProviders } from "../providers.js"; import type { ImageGenerationProviderPlugin, @@ -21,11 +12,6 @@ import type { WebSearchProviderPlugin, } from "../types.js"; -type RegistrablePlugin = { - id: string; - register: (api: ReturnType["api"]) => void; -}; - type CapabilityContractEntry = { pluginId: string; provider: T; @@ -52,52 +38,30 @@ type PluginRegistrationContractEntry = { toolNames: string[]; }; -const bundledWebSearchPlugins: Array = [ - { ...bravePlugin, credentialValue: "BSA-test" }, - { ...firecrawlPlugin, credentialValue: "fc-test" }, - { ...googlePlugin, credentialValue: "AIza-test" }, - { ...moonshotPlugin, credentialValue: "sk-test" }, - { ...perplexityPlugin, credentialValue: "pplx-test" }, - { ...xaiPlugin, credentialValue: "xai-test" }, -]; +const log = createSubsystemLogger("plugins"); -const bundledSpeechPlugins: RegistrablePlugin[] = [elevenLabsPlugin, microsoftPlugin, openAIPlugin]; +const BUNDLED_WEB_SEARCH_CREDENTIAL_VALUES: Readonly> = { + brave: "BSA-test", + firecrawl: "fc-test", + google: "AIza-test", + moonshot: "sk-test", + perplexity: "pplx-test", + xai: "xai-test", +}; -const bundledMediaUnderstandingPlugins: RegistrablePlugin[] = [ - anthropicPlugin, - googlePlugin, - minimaxPlugin, - mistralPlugin, - moonshotPlugin, - openAIPlugin, - zaiPlugin, -]; +const BUNDLED_SPEECH_PLUGIN_IDS = ["elevenlabs", "microsoft", "openai"] as const; +const BUNDLED_MEDIA_UNDERSTANDING_PLUGIN_IDS = [ + "anthropic", + "google", + "minimax", + "mistral", + "moonshot", + "openai", + "zai", +] as const; +const BUNDLED_IMAGE_GENERATION_PLUGIN_IDS = ["google", "openai"] as const; -const bundledImageGenerationPlugins: RegistrablePlugin[] = [googlePlugin, openAIPlugin]; - -function captureRegistrations(plugin: RegistrablePlugin) { - const captured = createCapturedPluginRegistration(); - plugin.register(captured.api); - return captured; -} - -function buildCapabilityContractRegistry(params: { - plugins: RegistrablePlugin[]; - select: (captured: ReturnType) => T[]; -}): CapabilityContractEntry[] { - return params.plugins.flatMap((plugin) => { - const captured = captureRegistrations(plugin); - return params.select(captured).map((provider) => ({ - pluginId: plugin.id, - provider, - })); - }); -} - -export const providerContractRegistry: ProviderContractEntry[] = buildCapabilityContractRegistry({ - plugins: [], - select: () => [], -}); +export const providerContractRegistry: ProviderContractEntry[] = []; export let providerContractLoadError: Error | undefined; @@ -143,6 +107,55 @@ export const providerContractCompatPluginIds = providerContractPluginIds.map((pl pluginId === "kimi-coding" ? "kimi" : pluginId, ); +const bundledCapabilityContractPluginIds = [ + ...new Set([ + ...providerContractCompatPluginIds, + ...resolveBundledWebSearchPluginIds({}), + ...BUNDLED_SPEECH_PLUGIN_IDS, + ...BUNDLED_MEDIA_UNDERSTANDING_PLUGIN_IDS, + ...BUNDLED_IMAGE_GENERATION_PLUGIN_IDS, + ]), +].toSorted((left, right) => left.localeCompare(right)); + +export let capabilityContractLoadError: Error | undefined; + +function loadBundledCapabilityRegistry() { + try { + capabilityContractLoadError = undefined; + return loadOpenClawPlugins({ + config: withBundledPluginEnablementCompat({ + config: { + plugins: { + enabled: true, + allow: bundledCapabilityContractPluginIds, + slots: { + memory: "none", + }, + }, + }, + pluginIds: bundledCapabilityContractPluginIds, + }), + cache: false, + activate: false, + logger: createPluginLoaderLogger(log), + }); + } catch (error) { + capabilityContractLoadError = error instanceof Error ? error : new Error(String(error)); + return loadOpenClawPlugins({ + config: { + plugins: { + enabled: false, + }, + }, + cache: false, + activate: false, + logger: createPluginLoaderLogger(log), + }); + } +} + +const loadedBundledCapabilityRegistry = loadBundledCapabilityRegistry(); + export function requireProviderContractProvider(providerId: string): ProviderPlugin { const provider = uniqueProviderContractProviders.find((entry) => entry.id === providerId); if (!provider) { @@ -183,85 +196,50 @@ export function resolveProviderContractProvidersForPluginIds( } export const webSearchProviderContractRegistry: WebSearchProviderContractEntry[] = - bundledWebSearchPlugins.flatMap((plugin) => { - const captured = captureRegistrations(plugin); - return captured.webSearchProviders.map((provider) => ({ - pluginId: plugin.id, - provider, - credentialValue: plugin.credentialValue, + loadedBundledCapabilityRegistry.webSearchProviders + .filter((entry) => entry.pluginId in BUNDLED_WEB_SEARCH_CREDENTIAL_VALUES) + .map((entry) => ({ + pluginId: entry.pluginId, + provider: entry.provider, + credentialValue: BUNDLED_WEB_SEARCH_CREDENTIAL_VALUES[entry.pluginId], })); - }); export const speechProviderContractRegistry: SpeechProviderContractEntry[] = - buildCapabilityContractRegistry({ - plugins: bundledSpeechPlugins, - select: (captured) => captured.speechProviders, - }); + loadedBundledCapabilityRegistry.speechProviders.map((entry) => ({ + pluginId: entry.pluginId, + provider: entry.provider, + })); export const mediaUnderstandingProviderContractRegistry: MediaUnderstandingProviderContractEntry[] = - buildCapabilityContractRegistry({ - plugins: bundledMediaUnderstandingPlugins, - select: (captured) => captured.mediaUnderstandingProviders, - }); + loadedBundledCapabilityRegistry.mediaUnderstandingProviders.map((entry) => ({ + pluginId: entry.pluginId, + provider: entry.provider, + })); export const imageGenerationProviderContractRegistry: ImageGenerationProviderContractEntry[] = - buildCapabilityContractRegistry({ - plugins: bundledImageGenerationPlugins, - select: (captured) => captured.imageGenerationProviders, - }); + loadedBundledCapabilityRegistry.imageGenerationProviders.map((entry) => ({ + pluginId: entry.pluginId, + provider: entry.provider, + })); -const bundledPluginRegistrationList = [ - ...new Map( - [ - ...bundledSpeechPlugins, - ...bundledMediaUnderstandingPlugins, - ...bundledImageGenerationPlugins, - ...bundledWebSearchPlugins, - ].map((plugin) => [plugin.id, plugin]), - ).values(), -]; - -export const pluginRegistrationContractRegistry: PluginRegistrationContractEntry[] = [ - ...new Map( - providerContractRegistry.map((entry) => [ - entry.pluginId, - { - pluginId: entry.pluginId, - providerIds: providerContractRegistry - .filter((candidate) => candidate.pluginId === entry.pluginId) - .map((candidate) => candidate.provider.id), - speechProviderIds: [] as string[], - mediaUnderstandingProviderIds: [] as string[], - imageGenerationProviderIds: [] as string[], - webSearchProviderIds: [] as string[], - toolNames: [] as string[], - }, - ]), - ).values(), -]; - -for (const plugin of bundledPluginRegistrationList) { - const captured = captureRegistrations(plugin); - const existing = pluginRegistrationContractRegistry.find((entry) => entry.pluginId === plugin.id); - const next = { - pluginId: plugin.id, - providerIds: captured.providers.map((provider) => provider.id), - speechProviderIds: captured.speechProviders.map((provider) => provider.id), - mediaUnderstandingProviderIds: captured.mediaUnderstandingProviders.map( - (provider) => provider.id, - ), - imageGenerationProviderIds: captured.imageGenerationProviders.map((provider) => provider.id), - webSearchProviderIds: captured.webSearchProviders.map((provider) => provider.id), - toolNames: captured.tools.map((tool) => tool.name), - }; - if (!existing) { - pluginRegistrationContractRegistry.push(next); - continue; - } - existing.providerIds = next.providerIds.length > 0 ? next.providerIds : existing.providerIds; - existing.speechProviderIds = next.speechProviderIds; - existing.mediaUnderstandingProviderIds = next.mediaUnderstandingProviderIds; - existing.imageGenerationProviderIds = next.imageGenerationProviderIds; - existing.webSearchProviderIds = next.webSearchProviderIds; - existing.toolNames = next.toolNames; -} +export const pluginRegistrationContractRegistry: PluginRegistrationContractEntry[] = + loadedBundledCapabilityRegistry.plugins + .filter( + (plugin) => + plugin.origin === "bundled" && + (plugin.providerIds.length > 0 || + plugin.speechProviderIds.length > 0 || + plugin.mediaUnderstandingProviderIds.length > 0 || + plugin.imageGenerationProviderIds.length > 0 || + plugin.webSearchProviderIds.length > 0 || + plugin.toolNames.length > 0), + ) + .map((plugin) => ({ + pluginId: plugin.id, + providerIds: plugin.providerIds, + speechProviderIds: plugin.speechProviderIds, + mediaUnderstandingProviderIds: plugin.mediaUnderstandingProviderIds, + imageGenerationProviderIds: plugin.imageGenerationProviderIds, + webSearchProviderIds: plugin.webSearchProviderIds, + toolNames: plugin.toolNames, + })); From 61a19107e1b8939078351ab60ecbe54ee3b958b9 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Wed, 18 Mar 2026 01:49:47 -0700 Subject: [PATCH 31/55] Tlon: install api from tarball artifact --- extensions/tlon/package.json | 2 +- pnpm-lock.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/extensions/tlon/package.json b/extensions/tlon/package.json index 071280374a3..f909834f1c6 100644 --- a/extensions/tlon/package.json +++ b/extensions/tlon/package.json @@ -4,7 +4,7 @@ "description": "OpenClaw Tlon/Urbit channel plugin", "type": "module", "dependencies": { - "@tloncorp/api": "github:tloncorp/api-beta#7eede1c1a756977b09f96aa14a92e2b06318ae87", + "@tloncorp/api": "https://codeload.github.com/tloncorp/api-beta/tar.gz/7eede1c1a756977b09f96aa14a92e2b06318ae87", "@tloncorp/tlon-skill": "0.2.2", "@urbit/aura": "^3.0.0", "zod": "^4.3.6" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4fb25b899d8..1439fa6b2a6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -530,7 +530,7 @@ importers: extensions/tlon: dependencies: '@tloncorp/api': - specifier: github:tloncorp/api-beta#7eede1c1a756977b09f96aa14a92e2b06318ae87 + specifier: https://codeload.github.com/tloncorp/api-beta/tar.gz/7eede1c1a756977b09f96aa14a92e2b06318ae87 version: https://codeload.github.com/tloncorp/api-beta/tar.gz/7eede1c1a756977b09f96aa14a92e2b06318ae87 '@tloncorp/tlon-skill': specifier: 0.2.2 From 2f3bc89f4fac96fa01d66e37fc3207e864906c52 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Wed, 18 Mar 2026 01:53:14 -0700 Subject: [PATCH 32/55] Config: align model compat thinking format schema --- src/config/zod-schema.core.ts | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/src/config/zod-schema.core.ts b/src/config/zod-schema.core.ts index 22c589c8490..25ef5d54346 100644 --- a/src/config/zod-schema.core.ts +++ b/src/config/zod-schema.core.ts @@ -192,14 +192,7 @@ export const ModelCompatSchema = z maxTokensField: z .union([z.literal("max_completion_tokens"), z.literal("max_tokens")]) .optional(), - thinkingFormat: z - .union([ - z.literal("openai"), - z.literal("zai"), - z.literal("qwen"), - z.literal("qwen-chat-template"), - ]) - .optional(), + thinkingFormat: z.union([z.literal("openai"), z.literal("zai"), z.literal("qwen")]).optional(), requiresToolResultName: z.boolean().optional(), requiresAssistantAfterToolResult: z.boolean().optional(), requiresThinkingAsText: z.boolean().optional(), From 1040ae56b5034fe6e9bb03ad452744dd5aaaea10 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Wed, 18 Mar 2026 01:53:16 -0700 Subject: [PATCH 33/55] Telegram: fix reply-runtime test typings --- .../src/bot.create-telegram-bot.test.ts | 13 +++++-- .../telegram/src/bot.media.e2e-harness.ts | 36 ++++++++++++++----- 2 files changed, 38 insertions(+), 11 deletions(-) diff --git a/extensions/telegram/src/bot.create-telegram-bot.test.ts b/extensions/telegram/src/bot.create-telegram-bot.test.ts index d0df14e7cf6..7fbab89cdab 100644 --- a/extensions/telegram/src/bot.create-telegram-bot.test.ts +++ b/extensions/telegram/src/bot.create-telegram-bot.test.ts @@ -59,6 +59,7 @@ const TELEGRAM_TEST_TIMINGS = { mediaGroupFlushMs: 20, textFragmentGapMs: 30, } as const; +const EMPTY_REPLY_COUNTS = { block: 0, final: 0, tool: 0 } as const; describe("createTelegramBot", () => { beforeAll(() => { @@ -388,7 +389,7 @@ describe("createTelegramBot", () => { dispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce( async ({ dispatcherOptions }) => { await dispatcherOptions.typingCallbacks?.onReplyStart?.(); - return { queuedFinal: false, counts: {} }; + return { queuedFinal: false, counts: { ...EMPTY_REPLY_COUNTS } }; }, ); createTelegramBot({ token: "tok" }); @@ -1463,7 +1464,7 @@ describe("createTelegramBot", () => { dispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce(async (params) => { dispatchCall = params as typeof dispatchCall; await params.dispatcherOptions.typingCallbacks?.onReplyStart?.(); - return { queuedFinal: false, counts: {} }; + return { queuedFinal: false, counts: { ...EMPTY_REPLY_COUNTS } }; }); loadConfig.mockReturnValue({ channels: { @@ -1479,6 +1480,9 @@ describe("createTelegramBot", () => { const payload = dispatchCall?.ctx; if (testCase.assertTopicMetadata) { + if (!payload) { + throw new Error("Expected forum dispatch payload"); + } expect(payload.SessionKey).toContain("telegram:group:-1001234567890:topic:99"); expect(payload.From).toBe("telegram:group:-1001234567890:topic:99"); expect(payload.MessageThreadId).toBe(99); @@ -1790,7 +1794,7 @@ describe("createTelegramBot", () => { | undefined; dispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce(async (params) => { dispatchCall = params as typeof dispatchCall; - return { queuedFinal: false, counts: {} }; + return { queuedFinal: false, counts: { ...EMPTY_REPLY_COUNTS } }; }); loadConfig.mockReturnValue({ channels: { @@ -1819,6 +1823,9 @@ describe("createTelegramBot", () => { await handler(makeForumGroupMessageCtx({ threadId: 99 })); const payload = dispatchCall?.ctx; + if (!payload) { + throw new Error("Expected topic dispatch payload"); + } expect(payload.GroupSystemPrompt).toBe("Group prompt\n\nTopic prompt"); expect(dispatchCall?.replyOptions?.skillFilter).toEqual([]); }); diff --git a/extensions/telegram/src/bot.media.e2e-harness.ts b/extensions/telegram/src/bot.media.e2e-harness.ts index 3dbd8634ab1..56af46fc304 100644 --- a/extensions/telegram/src/bot.media.e2e-harness.ts +++ b/extensions/telegram/src/bot.media.e2e-harness.ts @@ -1,7 +1,14 @@ import path from "node:path"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; import { MediaFetchError } from "openclaw/plugin-sdk/media-runtime"; -import { resetInboundDedupe } from "openclaw/plugin-sdk/reply-runtime"; +import { + resetInboundDedupe, + type GetReplyOptions, + type MsgContext, + type ReplyPayload, +} from "openclaw/plugin-sdk/reply-runtime"; import { beforeEach, vi, type Mock } from "vitest"; +import type { TelegramBotDeps } from "./bot-deps.js"; export const useSpy: Mock = vi.fn(); export const middlewareUseSpy: Mock = vi.fn(); @@ -97,7 +104,11 @@ const apiStub: ApiStub = { setMyCommands: vi.fn(async () => undefined), }; -export const telegramBotRuntimeForTest = { +export const telegramBotRuntimeForTest: { + Bot: new (token: string) => unknown; + sequentialize: () => unknown; + apiThrottler: () => unknown; +} = { Bot: class { api = apiStub; use = middlewareUseSpy; @@ -111,7 +122,13 @@ export const telegramBotRuntimeForTest = { apiThrottler: () => throttlerSpy(), }; -const mediaHarnessReplySpy = vi.hoisted(() => vi.fn(async () => undefined)); +type MediaHarnessReplyFn = ( + ctx: MsgContext, + opts?: GetReplyOptions, + configOverride?: OpenClawConfig, +) => Promise; + +const mediaHarnessReplySpy = vi.hoisted(() => vi.fn(async () => undefined)); type DispatchReplyWithBufferedBlockDispatcherFn = typeof import("openclaw/plugin-sdk/reply-runtime").dispatchReplyWithBufferedBlockDispatcher; type DispatchReplyHarnessParams = Parameters[0]; @@ -121,8 +138,11 @@ let actualDispatchReplyWithBufferedBlockDispatcherPromise: | undefined; async function getActualDispatchReplyWithBufferedBlockDispatcher() { - actualDispatchReplyWithBufferedBlockDispatcherPromise ??= - import("../../../src/auto-reply/reply/provider-dispatcher.js").then( + actualDispatchReplyWithBufferedBlockDispatcherPromise ??= vi + .importActual( + "openclaw/plugin-sdk/reply-runtime", + ) + .then( (module) => module.dispatchReplyWithBufferedBlockDispatcher as DispatchReplyWithBufferedBlockDispatcherFn, ); @@ -136,9 +156,9 @@ async function dispatchReplyWithBufferedBlockDispatcherViaActual( await getActualDispatchReplyWithBufferedBlockDispatcher(); return await actualDispatchReplyWithBufferedBlockDispatcher({ ...params, - replyResolver: async (ctx, _cfg, opts) => { + replyResolver: async (ctx, opts, configOverride) => { await opts?.onReplyStart?.(); - return await mediaHarnessReplySpy(ctx, opts); + return await mediaHarnessReplySpy(ctx, opts, configOverride as OpenClawConfig | undefined); }, }); } @@ -148,7 +168,7 @@ const mediaHarnessDispatchReplyWithBufferedBlockDispatcher = vi.hoisted(() => dispatchReplyWithBufferedBlockDispatcherViaActual, ), ); -export const telegramBotDepsForTest = { +export const telegramBotDepsForTest: TelegramBotDeps = { loadConfig: () => ({ channels: { telegram: { dmPolicy: "open", allowFrom: ["*"] } }, }), From 1890089f49944b2940183dac212e69b4dfafc285 Mon Sep 17 00:00:00 2001 From: Vignesh Natarajan Date: Wed, 18 Mar 2026 01:56:28 -0700 Subject: [PATCH 34/55] fix: serialize duplicate channel starts (#49583) (thanks @sudie-codes) --- CHANGELOG.md | 1 + src/gateway/server-channels.test.ts | 56 ++++++ src/gateway/server-channels.ts | 254 ++++++++++++++++------------ 3 files changed, 204 insertions(+), 107 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d99a6fdcff..471970d48d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -128,6 +128,7 @@ Docs: https://docs.openclaw.ai - Plugins/subagents: forward per-run provider and model overrides through gateway plugin subagent dispatch so plugin-launched agent delegations honor explicit model selection again. (#48277) Thanks @jalehman. - Agents/compaction: write minimal boundary summaries for empty preparations while keeping split-turn prefixes on the normal path, so no-summarizable-message sessions stop retriggering the safeguard loop. (#42215) thanks @lml2468. - Models/chat commands: keep `/model ...@YYYYMMDD` version suffixes intact by default, but still honor matching stored numeric auth-profile overrides for the same provider. (#48896) Thanks @Alix-007. +- Gateway/channels: serialize per-account channel startup so overlapping starts do not boot the same provider twice, preventing MS Teams `EADDRINUSE` crash loops during startup and restart. (#49583) Thanks @sudie-codes. ### Fixes diff --git a/src/gateway/server-channels.test.ts b/src/gateway/server-channels.test.ts index 2e886962d33..01dd6aa17d3 100644 --- a/src/gateway/server-channels.test.ts +++ b/src/gateway/server-channels.test.ts @@ -45,6 +45,7 @@ function createTestPlugin(params?: { startAccount?: NonNullable["gateway"]>["startAccount"]; includeDescribeAccount?: boolean; resolveAccount?: ChannelPlugin["config"]["resolveAccount"]; + isConfigured?: ChannelPlugin["config"]["isConfigured"]; }): ChannelPlugin { const account = params?.account ?? { enabled: true, configured: true }; const includeDescribeAccount = params?.includeDescribeAccount !== false; @@ -52,6 +53,7 @@ function createTestPlugin(params?: { listAccountIds: () => [DEFAULT_ACCOUNT_ID], resolveAccount: params?.resolveAccount ?? (() => account), isEnabled: (resolved) => resolved.enabled !== false, + ...(params?.isConfigured ? { isConfigured: params.isConfigured } : {}), }; if (includeDescribeAccount) { config.describeAccount = (resolved) => ({ @@ -79,6 +81,14 @@ function createTestPlugin(params?: { }; } +function createDeferred(): { promise: Promise; resolve: () => void } { + let resolvePromise = () => {}; + const promise = new Promise((resolve) => { + resolvePromise = resolve; + }); + return { promise, resolve: resolvePromise }; +} + function installTestRegistry(plugin: ChannelPlugin) { const registry = createEmptyPluginRegistry(); registry.channels.push({ @@ -189,6 +199,52 @@ describe("server-channels auto restart", () => { expect(startAccount).toHaveBeenCalledTimes(1); }); + it("deduplicates concurrent start requests for the same account", async () => { + const startupGate = createDeferred(); + const isConfigured = vi.fn(async () => { + await startupGate.promise; + return true; + }); + const startAccount = vi.fn(async () => {}); + + installTestRegistry(createTestPlugin({ startAccount, isConfigured })); + const manager = createManager(); + + const firstStart = manager.startChannel("discord", DEFAULT_ACCOUNT_ID); + const secondStart = manager.startChannel("discord", DEFAULT_ACCOUNT_ID); + + await Promise.resolve(); + expect(isConfigured).toHaveBeenCalledTimes(1); + expect(startAccount).not.toHaveBeenCalled(); + + startupGate.resolve(); + await Promise.all([firstStart, secondStart]); + + expect(startAccount).toHaveBeenCalledTimes(1); + }); + + it("cancels a pending startup when the account is stopped mid-boot", async () => { + const startupGate = createDeferred(); + const isConfigured = vi.fn(async () => { + await startupGate.promise; + return true; + }); + const startAccount = vi.fn(async () => {}); + + installTestRegistry(createTestPlugin({ startAccount, isConfigured })); + const manager = createManager(); + + const startTask = manager.startChannel("discord", DEFAULT_ACCOUNT_ID); + await Promise.resolve(); + + const stopTask = manager.stopChannel("discord", DEFAULT_ACCOUNT_ID); + startupGate.resolve(); + + await Promise.all([startTask, stopTask]); + + expect(startAccount).not.toHaveBeenCalled(); + }); + it("does not resolve channelRuntime until a channel starts", async () => { const channelRuntime = { marker: "lazy-channel-runtime", diff --git a/src/gateway/server-channels.ts b/src/gateway/server-channels.ts index a016826f69b..16cad24b07d 100644 --- a/src/gateway/server-channels.ts +++ b/src/gateway/server-channels.ts @@ -32,6 +32,7 @@ type SubsystemLogger = ReturnType; type ChannelRuntimeStore = { aborts: Map; + starting: Map>; tasks: Map>; runtimes: Map; }; @@ -49,6 +50,7 @@ type ChannelHealthMonitorConfig = HealthMonitorConfig & { function createRuntimeStore(): ChannelRuntimeStore { return { aborts: new Map(), + starting: new Map(), tasks: new Map(), runtimes: new Map(), }; @@ -256,137 +258,174 @@ export function createChannelManager(opts: ChannelManagerOptions): ChannelManage if (store.tasks.has(id)) { return; } - const account = plugin.config.resolveAccount(cfg, id); - const enabled = plugin.config.isEnabled - ? plugin.config.isEnabled(account, cfg) - : isAccountEnabled(account); - if (!enabled) { - setRuntime(channelId, id, { - accountId: id, - enabled: false, - configured: true, - running: false, - restartPending: false, - lastError: plugin.config.disabledReason?.(account, cfg) ?? "disabled", - }); + const existingStart = store.starting.get(id); + if (existingStart) { + await existingStart; return; } - let configured = true; - if (plugin.config.isConfigured) { - configured = await plugin.config.isConfigured(account, cfg); - } - if (!configured) { - setRuntime(channelId, id, { - accountId: id, - enabled: true, - configured: false, - running: false, - restartPending: false, - lastError: plugin.config.unconfiguredReason?.(account, cfg) ?? "not configured", - }); - return; - } - - const rKey = restartKey(channelId, id); - if (!preserveManualStop) { - manuallyStopped.delete(rKey); - } + let resolveStart: (() => void) | undefined; + const startGate = new Promise((resolve) => { + resolveStart = resolve; + }); + store.starting.set(id, startGate); + // Reserve the account before the first await so overlapping start calls + // cannot race into duplicate provider boots for the same account. const abort = new AbortController(); store.aborts.set(id, abort); - if (!preserveRestartAttempts) { - restartAttempts.delete(rKey); - } - setRuntime(channelId, id, { - accountId: id, - enabled: true, - configured: true, - running: true, - restartPending: false, - lastStartAt: Date.now(), - lastError: null, - reconnectAttempts: preserveRestartAttempts ? (restartAttempts.get(rKey) ?? 0) : 0, - }); + let handedOffTask = false; - const log = channelLogs[channelId]; - const resolvedChannelRuntime = getChannelRuntime(); - const task = startAccount({ - cfg, - accountId: id, - account, - runtime: channelRuntimeEnvs[channelId], - abortSignal: abort.signal, - log, - getStatus: () => getRuntime(channelId, id), - setStatus: (next) => setRuntime(channelId, id, next), - ...(resolvedChannelRuntime ? { channelRuntime: resolvedChannelRuntime } : {}), - }); - const trackedPromise = Promise.resolve(task) - .catch((err) => { - const message = formatErrorMessage(err); - setRuntime(channelId, id, { accountId: id, lastError: message }); - log.error?.(`[${id}] channel exited: ${message}`); - }) - .finally(() => { + try { + const account = plugin.config.resolveAccount(cfg, id); + const enabled = plugin.config.isEnabled + ? plugin.config.isEnabled(account, cfg) + : isAccountEnabled(account); + if (!enabled) { + setRuntime(channelId, id, { + accountId: id, + enabled: false, + configured: true, + running: false, + restartPending: false, + lastError: plugin.config.disabledReason?.(account, cfg) ?? "disabled", + }); + return; + } + + let configured = true; + if (plugin.config.isConfigured) { + configured = await plugin.config.isConfigured(account, cfg); + } + if (!configured) { + setRuntime(channelId, id, { + accountId: id, + enabled: true, + configured: false, + running: false, + restartPending: false, + lastError: plugin.config.unconfiguredReason?.(account, cfg) ?? "not configured", + }); + return; + } + + const rKey = restartKey(channelId, id); + if (!preserveManualStop) { + manuallyStopped.delete(rKey); + } + + if (abort.signal.aborted || manuallyStopped.has(rKey)) { setRuntime(channelId, id, { accountId: id, running: false, + restartPending: false, lastStopAt: Date.now(), }); - }) - .then(async () => { - if (manuallyStopped.has(rKey)) { - return; - } - const attempt = (restartAttempts.get(rKey) ?? 0) + 1; - restartAttempts.set(rKey, attempt); - if (attempt > MAX_RESTART_ATTEMPTS) { + return; + } + + if (!preserveRestartAttempts) { + restartAttempts.delete(rKey); + } + setRuntime(channelId, id, { + accountId: id, + enabled: true, + configured: true, + running: true, + restartPending: false, + lastStartAt: Date.now(), + lastError: null, + reconnectAttempts: preserveRestartAttempts ? (restartAttempts.get(rKey) ?? 0) : 0, + }); + + const log = channelLogs[channelId]; + const resolvedChannelRuntime = getChannelRuntime(); + const task = startAccount({ + cfg, + accountId: id, + account, + runtime: channelRuntimeEnvs[channelId], + abortSignal: abort.signal, + log, + getStatus: () => getRuntime(channelId, id), + setStatus: (next) => setRuntime(channelId, id, next), + ...(resolvedChannelRuntime ? { channelRuntime: resolvedChannelRuntime } : {}), + }); + const trackedPromise = Promise.resolve(task) + .catch((err) => { + const message = formatErrorMessage(err); + setRuntime(channelId, id, { accountId: id, lastError: message }); + log.error?.(`[${id}] channel exited: ${message}`); + }) + .finally(() => { setRuntime(channelId, id, { accountId: id, - restartPending: false, - reconnectAttempts: attempt, + running: false, + lastStopAt: Date.now(), }); - log.error?.(`[${id}] giving up after ${MAX_RESTART_ATTEMPTS} restart attempts`); - return; - } - const delayMs = computeBackoff(CHANNEL_RESTART_POLICY, attempt); - log.info?.( - `[${id}] auto-restart attempt ${attempt}/${MAX_RESTART_ATTEMPTS} in ${Math.round(delayMs / 1000)}s`, - ); - setRuntime(channelId, id, { - accountId: id, - restartPending: true, - reconnectAttempts: attempt, - }); - try { - await sleepWithAbort(delayMs, abort.signal); + }) + .then(async () => { if (manuallyStopped.has(rKey)) { return; } + const attempt = (restartAttempts.get(rKey) ?? 0) + 1; + restartAttempts.set(rKey, attempt); + if (attempt > MAX_RESTART_ATTEMPTS) { + setRuntime(channelId, id, { + accountId: id, + restartPending: false, + reconnectAttempts: attempt, + }); + log.error?.(`[${id}] giving up after ${MAX_RESTART_ATTEMPTS} restart attempts`); + return; + } + const delayMs = computeBackoff(CHANNEL_RESTART_POLICY, attempt); + log.info?.( + `[${id}] auto-restart attempt ${attempt}/${MAX_RESTART_ATTEMPTS} in ${Math.round(delayMs / 1000)}s`, + ); + setRuntime(channelId, id, { + accountId: id, + restartPending: true, + reconnectAttempts: attempt, + }); + try { + await sleepWithAbort(delayMs, abort.signal); + if (manuallyStopped.has(rKey)) { + return; + } + if (store.tasks.get(id) === trackedPromise) { + store.tasks.delete(id); + } + if (store.aborts.get(id) === abort) { + store.aborts.delete(id); + } + await startChannelInternal(channelId, id, { + preserveRestartAttempts: true, + preserveManualStop: true, + }); + } catch { + // abort or startup failure — next crash will retry + } + }) + .finally(() => { if (store.tasks.get(id) === trackedPromise) { store.tasks.delete(id); } if (store.aborts.get(id) === abort) { store.aborts.delete(id); } - await startChannelInternal(channelId, id, { - preserveRestartAttempts: true, - preserveManualStop: true, - }); - } catch { - // abort or startup failure — next crash will retry - } - }) - .finally(() => { - if (store.tasks.get(id) === trackedPromise) { - store.tasks.delete(id); - } - if (store.aborts.get(id) === abort) { - store.aborts.delete(id); - } - }); - store.tasks.set(id, trackedPromise); + }); + handedOffTask = true; + store.tasks.set(id, trackedPromise); + } finally { + resolveStart?.(); + if (store.starting.get(id) === startGate) { + store.starting.delete(id); + } + if (!handedOffTask && store.aborts.get(id) === abort) { + store.aborts.delete(id); + } + } }), ); }; @@ -405,6 +444,7 @@ export function createChannelManager(opts: ChannelManagerOptions): ChannelManage const cfg = loadConfig(); const knownIds = new Set([ ...store.aborts.keys(), + ...store.starting.keys(), ...store.tasks.keys(), ...(plugin ? plugin.config.listAccountIds(cfg) : []), ]); From d8a1ad0f0d5c00138ebb7742eebf4ad7958b0eaf Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Wed, 18 Mar 2026 02:03:47 -0700 Subject: [PATCH 35/55] Plugin SDK: split provider auth login seam --- extensions/chutes/index.ts | 2 +- extensions/github-copilot/index.ts | 3 ++- extensions/openai/openai-codex-provider.ts | 2 +- package.json | 4 ++++ src/plugin-sdk/provider-auth-login.ts | 5 +++++ src/plugin-sdk/provider-auth.ts | 3 --- src/plugins/contracts/auth.contract.test.ts | 8 ++++---- 7 files changed, 17 insertions(+), 10 deletions(-) create mode 100644 src/plugin-sdk/provider-auth-login.ts diff --git a/extensions/chutes/index.ts b/extensions/chutes/index.ts index a61cd4ec93f..b715ad46c5a 100644 --- a/extensions/chutes/index.ts +++ b/extensions/chutes/index.ts @@ -2,11 +2,11 @@ import { definePluginEntry } from "openclaw/plugin-sdk/core"; import { buildOauthProviderAuthResult, createProviderApiKeyAuthMethod, - loginChutes, resolveOAuthApiKeyMarker, type ProviderAuthContext, type ProviderAuthResult, } from "openclaw/plugin-sdk/provider-auth"; +import { loginChutes } from "openclaw/plugin-sdk/provider-auth-login"; import { CHUTES_DEFAULT_MODEL_REF, applyChutesApiKeyConfig, diff --git a/extensions/github-copilot/index.ts b/extensions/github-copilot/index.ts index 39116636b76..633ff274f82 100644 --- a/extensions/github-copilot/index.ts +++ b/extensions/github-copilot/index.ts @@ -5,7 +5,8 @@ import { type ProviderResolveDynamicModelContext, type ProviderRuntimeModel, } from "openclaw/plugin-sdk/core"; -import { coerceSecretRef, githubCopilotLoginCommand } from "openclaw/plugin-sdk/provider-auth"; +import { coerceSecretRef } from "openclaw/plugin-sdk/provider-auth"; +import { githubCopilotLoginCommand } from "openclaw/plugin-sdk/provider-auth-login"; import { normalizeModelCompat } from "openclaw/plugin-sdk/provider-models"; import { DEFAULT_COPILOT_API_BASE_URL, resolveCopilotApiToken } from "./token.js"; import { fetchCopilotUsage } from "./usage.js"; diff --git a/extensions/openai/openai-codex-provider.ts b/extensions/openai/openai-codex-provider.ts index 5714b09a7d0..cb8d6d2519c 100644 --- a/extensions/openai/openai-codex-provider.ts +++ b/extensions/openai/openai-codex-provider.ts @@ -9,9 +9,9 @@ import { CODEX_CLI_PROFILE_ID, ensureAuthProfileStore, listProfilesForProvider, - loginOpenAICodexOAuth, type OAuthCredential, } from "openclaw/plugin-sdk/provider-auth"; +import { loginOpenAICodexOAuth } from "openclaw/plugin-sdk/provider-auth-login"; import { DEFAULT_CONTEXT_TOKENS, normalizeModelCompat, diff --git a/package.json b/package.json index 09a8c047869..a181861c2ae 100644 --- a/package.json +++ b/package.json @@ -414,6 +414,10 @@ "types": "./dist/plugin-sdk/provider-auth.d.ts", "default": "./dist/plugin-sdk/provider-auth.js" }, + "./plugin-sdk/provider-auth-login": { + "types": "./dist/plugin-sdk/provider-auth-login.d.ts", + "default": "./dist/plugin-sdk/provider-auth-login.js" + }, "./plugin-sdk/provider-catalog": { "types": "./dist/plugin-sdk/provider-catalog.d.ts", "default": "./dist/plugin-sdk/provider-catalog.js" diff --git a/src/plugin-sdk/provider-auth-login.ts b/src/plugin-sdk/provider-auth-login.ts new file mode 100644 index 00000000000..4d6f55902ab --- /dev/null +++ b/src/plugin-sdk/provider-auth-login.ts @@ -0,0 +1,5 @@ +// Public interactive auth/login helpers for provider plugins. + +export { githubCopilotLoginCommand } from "../providers/github-copilot-auth.js"; +export { loginChutes } from "../commands/chutes-oauth.js"; +export { loginOpenAICodexOAuth } from "../plugins/provider-openai-codex-oauth.js"; diff --git a/src/plugin-sdk/provider-auth.ts b/src/plugin-sdk/provider-auth.ts index 84373befb88..645073a4d02 100644 --- a/src/plugin-sdk/provider-auth.ts +++ b/src/plugin-sdk/provider-auth.ts @@ -36,9 +36,6 @@ export { validateAnthropicSetupToken, } from "../plugins/provider-auth-token.js"; export { applyAuthProfileConfig, buildApiKeyCredential } from "../plugins/provider-auth-helpers.js"; -export { githubCopilotLoginCommand } from "../providers/github-copilot-auth.js"; -export { loginChutes } from "../commands/chutes-oauth.js"; -export { loginOpenAICodexOAuth } from "../plugins/provider-openai-codex-oauth.js"; export { createProviderApiKeyAuthMethod } from "../plugins/provider-api-key-auth.js"; export { coerceSecretRef } from "../config/types.secrets.js"; export { resolveDefaultSecretProviderAlias } from "../secrets/ref-contract.js"; diff --git a/src/plugins/contracts/auth.contract.test.ts b/src/plugins/contracts/auth.contract.test.ts index 355ceb43962..92b6cd11fea 100644 --- a/src/plugins/contracts/auth.contract.test.ts +++ b/src/plugins/contracts/auth.contract.test.ts @@ -14,11 +14,11 @@ import type { import type { OpenClawPluginApi, ProviderPlugin } from "../types.js"; type LoginOpenAICodexOAuth = - (typeof import("openclaw/plugin-sdk/provider-auth"))["loginOpenAICodexOAuth"]; + (typeof import("openclaw/plugin-sdk/provider-auth-login"))["loginOpenAICodexOAuth"]; type LoginQwenPortalOAuth = (typeof import("../../../extensions/qwen-portal-auth/oauth.js"))["loginQwenPortalOAuth"]; type GithubCopilotLoginCommand = - (typeof import("openclaw/plugin-sdk/provider-auth"))["githubCopilotLoginCommand"]; + (typeof import("openclaw/plugin-sdk/provider-auth-login"))["githubCopilotLoginCommand"]; type CreateVpsAwareHandlers = (typeof import("../provider-oauth-flow.js"))["createVpsAwareOAuthHandlers"]; @@ -26,8 +26,8 @@ const loginOpenAICodexOAuthMock = vi.hoisted(() => vi.fn( const loginQwenPortalOAuthMock = vi.hoisted(() => vi.fn()); const githubCopilotLoginCommandMock = vi.hoisted(() => vi.fn()); -vi.mock("openclaw/plugin-sdk/provider-auth", async (importOriginal) => { - const actual = await importOriginal(); +vi.mock("openclaw/plugin-sdk/provider-auth-login", async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, loginOpenAICodexOAuth: loginOpenAICodexOAuthMock, From afad0697aabe7622bb13f9a632d3716e9f1076f8 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Wed, 18 Mar 2026 02:06:06 -0700 Subject: [PATCH 36/55] Plugin SDK: register provider auth login entrypoint --- scripts/lib/plugin-sdk-entrypoints.json | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/lib/plugin-sdk-entrypoints.json b/scripts/lib/plugin-sdk-entrypoints.json index 288fefb7fd0..7378f3b4d9d 100644 --- a/scripts/lib/plugin-sdk-entrypoints.json +++ b/scripts/lib/plugin-sdk-entrypoints.json @@ -93,6 +93,7 @@ "json-store", "keyed-async-queue", "provider-auth", + "provider-auth-login", "provider-catalog", "provider-models", "provider-onboard", From 93a31b69de9b052b04b5490b4535badb82867032 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Wed, 18 Mar 2026 14:54:38 +0530 Subject: [PATCH 37/55] fix(config): add missing qwen-chat-template to thinking format schema --- src/config/zod-schema.core.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/config/zod-schema.core.ts b/src/config/zod-schema.core.ts index 25ef5d54346..22c589c8490 100644 --- a/src/config/zod-schema.core.ts +++ b/src/config/zod-schema.core.ts @@ -192,7 +192,14 @@ export const ModelCompatSchema = z maxTokensField: z .union([z.literal("max_completion_tokens"), z.literal("max_tokens")]) .optional(), - thinkingFormat: z.union([z.literal("openai"), z.literal("zai"), z.literal("qwen")]).optional(), + thinkingFormat: z + .union([ + z.literal("openai"), + z.literal("zai"), + z.literal("qwen"), + z.literal("qwen-chat-template"), + ]) + .optional(), requiresToolResultName: z.boolean().optional(), requiresAssistantAfterToolResult: z.boolean().optional(), requiresThinkingAsText: z.boolean().optional(), From f96ee99bbc8bd13863f7a5109ac8755a70bb73d5 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Wed, 18 Mar 2026 02:28:55 -0700 Subject: [PATCH 38/55] Plugin SDK: harden provider auth seams --- extensions/openrouter/index.ts | 2 +- extensions/venice/index.ts | 2 +- extensions/xai/index.ts | 2 +- extensions/zai/index.ts | 2 +- package.json | 4 ++ scripts/lib/plugin-sdk-entrypoints.json | 1 + src/plugin-sdk/agent-runtime.ts | 50 ++++++++++++++++++++++++- src/plugin-sdk/provider-auth-api-key.ts | 21 +++++++++++ 8 files changed, 79 insertions(+), 5 deletions(-) create mode 100644 src/plugin-sdk/provider-auth-api-key.ts diff --git a/extensions/openrouter/index.ts b/extensions/openrouter/index.ts index bcb75ecb49d..6b9ffbd2a1a 100644 --- a/extensions/openrouter/index.ts +++ b/extensions/openrouter/index.ts @@ -4,7 +4,7 @@ import { type ProviderResolveDynamicModelContext, type ProviderRuntimeModel, } from "openclaw/plugin-sdk/core"; -import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth"; +import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth-api-key"; import { applyXaiModelCompat, DEFAULT_CONTEXT_TOKENS } from "openclaw/plugin-sdk/provider-models"; import { getOpenRouterModelCapabilities, diff --git a/extensions/venice/index.ts b/extensions/venice/index.ts index cdf984bb99e..2cef47dc3c3 100644 --- a/extensions/venice/index.ts +++ b/extensions/venice/index.ts @@ -1,5 +1,5 @@ import { definePluginEntry } from "openclaw/plugin-sdk/core"; -import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth"; +import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth-api-key"; import { buildSingleProviderApiKeyCatalog } from "openclaw/plugin-sdk/provider-catalog"; import { applyXaiModelCompat } from "openclaw/plugin-sdk/provider-models"; import { applyVeniceConfig, VENICE_DEFAULT_MODEL_REF } from "./onboard.js"; diff --git a/extensions/xai/index.ts b/extensions/xai/index.ts index 6fa925637b8..0f0784c315f 100644 --- a/extensions/xai/index.ts +++ b/extensions/xai/index.ts @@ -1,5 +1,5 @@ import { definePluginEntry } from "openclaw/plugin-sdk/core"; -import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth"; +import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth-api-key"; import { buildSingleProviderApiKeyCatalog } from "openclaw/plugin-sdk/provider-catalog"; import { applyXaiModelCompat } from "openclaw/plugin-sdk/provider-models"; import { createToolStreamWrapper } from "openclaw/plugin-sdk/provider-stream"; diff --git a/extensions/zai/index.ts b/extensions/zai/index.ts index 79ae3a9d8aa..ee4aa0b30bc 100644 --- a/extensions/zai/index.ts +++ b/extensions/zai/index.ts @@ -15,7 +15,7 @@ import { type SecretInput, upsertAuthProfile, validateApiKeyInput, -} from "openclaw/plugin-sdk/provider-auth"; +} from "openclaw/plugin-sdk/provider-auth-api-key"; import { DEFAULT_CONTEXT_TOKENS, normalizeModelCompat } from "openclaw/plugin-sdk/provider-models"; import { createZaiToolStreamWrapper } from "openclaw/plugin-sdk/provider-stream"; import { fetchZaiUsage, resolveLegacyPiAgentAccessToken } from "openclaw/plugin-sdk/provider-usage"; diff --git a/package.json b/package.json index a181861c2ae..e3dfda5cd75 100644 --- a/package.json +++ b/package.json @@ -414,6 +414,10 @@ "types": "./dist/plugin-sdk/provider-auth.d.ts", "default": "./dist/plugin-sdk/provider-auth.js" }, + "./plugin-sdk/provider-auth-api-key": { + "types": "./dist/plugin-sdk/provider-auth-api-key.d.ts", + "default": "./dist/plugin-sdk/provider-auth-api-key.js" + }, "./plugin-sdk/provider-auth-login": { "types": "./dist/plugin-sdk/provider-auth-login.d.ts", "default": "./dist/plugin-sdk/provider-auth-login.js" diff --git a/scripts/lib/plugin-sdk-entrypoints.json b/scripts/lib/plugin-sdk-entrypoints.json index 7378f3b4d9d..ac54dabe731 100644 --- a/scripts/lib/plugin-sdk-entrypoints.json +++ b/scripts/lib/plugin-sdk-entrypoints.json @@ -93,6 +93,7 @@ "json-store", "keyed-async-queue", "provider-auth", + "provider-auth-api-key", "provider-auth-login", "provider-catalog", "provider-models", diff --git a/src/plugin-sdk/agent-runtime.ts b/src/plugin-sdk/agent-runtime.ts index c5313f681cc..a7191fd5a01 100644 --- a/src/plugin-sdk/agent-runtime.ts +++ b/src/plugin-sdk/agent-runtime.ts @@ -1,7 +1,6 @@ // Public agent/model/runtime helpers for plugins that integrate with core agent flows. export * from "../agents/agent-scope.js"; -export * from "../agents/auth-profiles.js"; export * from "../agents/current-time.js"; export * from "../agents/date-time.js"; export * from "../agents/defaults.js"; @@ -25,3 +24,52 @@ export * from "../agents/vllm-defaults.js"; // Intentional public runtime surface: channel plugins use ingress agent helpers directly. export * from "../agents/agent-command.js"; export * from "../tts/tts.js"; + +export { + CLAUDE_CLI_PROFILE_ID, + CODEX_CLI_PROFILE_ID, + dedupeProfileIds, + listProfilesForProvider, + markAuthProfileGood, + setAuthProfileOrder, + upsertAuthProfile, + upsertAuthProfileWithLock, + repairOAuthProfileIdMismatch, + suggestOAuthProfileIdForLegacyDefault, + clearRuntimeAuthProfileStoreSnapshots, + ensureAuthProfileStore, + loadAuthProfileStoreForSecretsRuntime, + loadAuthProfileStoreForRuntime, + replaceRuntimeAuthProfileStoreSnapshots, + loadAuthProfileStore, + saveAuthProfileStore, + calculateAuthProfileCooldownMs, + clearAuthProfileCooldown, + clearExpiredCooldowns, + getSoonestCooldownExpiry, + isProfileInCooldown, + markAuthProfileCooldown, + markAuthProfileFailure, + markAuthProfileUsed, + resolveProfilesUnavailableReason, + resolveProfileUnusableUntilForDisplay, + resolveApiKeyForProfile, + resolveAuthProfileDisplayLabel, + formatAuthDoctorHint, + resolveAuthProfileEligibility, + resolveAuthProfileOrder, + resolveAuthStorePathForDisplay, +} from "../agents/auth-profiles.js"; +export type { + ApiKeyCredential, + AuthCredentialReasonCode, + AuthProfileCredential, + AuthProfileEligibilityReasonCode, + AuthProfileFailureReason, + AuthProfileIdRepairResult, + AuthProfileStore, + OAuthCredential, + ProfileUsageStats, + TokenCredential, + TokenExpiryState, +} from "../agents/auth-profiles.js"; diff --git a/src/plugin-sdk/provider-auth-api-key.ts b/src/plugin-sdk/provider-auth-api-key.ts new file mode 100644 index 00000000000..b083d8e27cb --- /dev/null +++ b/src/plugin-sdk/provider-auth-api-key.ts @@ -0,0 +1,21 @@ +// Public API-key onboarding helpers for provider plugins. + +export type { OpenClawConfig } from "../config/config.js"; +export type { SecretInput } from "../config/types.secrets.js"; + +export { upsertAuthProfile } from "../agents/auth-profiles.js"; +export { + formatApiKeyPreview, + normalizeApiKeyInput, + validateApiKeyInput, + ensureApiKeyFromOptionEnvOrPrompt, + normalizeSecretInputModeInput, + promptSecretRefForSetup, + resolveSecretInputModeForEnvSelection, +} from "../plugins/provider-auth-input.js"; +export { applyAuthProfileConfig, buildApiKeyCredential } from "../plugins/provider-auth-helpers.js"; +export { createProviderApiKeyAuthMethod } from "../plugins/provider-api-key-auth.js"; +export { + normalizeOptionalSecretInput, + normalizeSecretInput, +} from "../utils/normalize-secret-input.js"; From 238c036b0d49e0c452e9bfb79acaee58eeeb118f Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Wed, 18 Mar 2026 02:43:43 -0700 Subject: [PATCH 39/55] Tlon: pin api-beta to current known-good commit --- extensions/tlon/package.json | 2 +- pnpm-lock.yaml | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/extensions/tlon/package.json b/extensions/tlon/package.json index f909834f1c6..2fce246d283 100644 --- a/extensions/tlon/package.json +++ b/extensions/tlon/package.json @@ -4,7 +4,7 @@ "description": "OpenClaw Tlon/Urbit channel plugin", "type": "module", "dependencies": { - "@tloncorp/api": "https://codeload.github.com/tloncorp/api-beta/tar.gz/7eede1c1a756977b09f96aa14a92e2b06318ae87", + "@tloncorp/api": "https://codeload.github.com/tloncorp/api-beta/tar.gz/c121deb82d97970418508691585aea4f71abcf9c", "@tloncorp/tlon-skill": "0.2.2", "@urbit/aura": "^3.0.0", "zod": "^4.3.6" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1439fa6b2a6..d01869b8fd4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -530,8 +530,8 @@ importers: extensions/tlon: dependencies: '@tloncorp/api': - specifier: https://codeload.github.com/tloncorp/api-beta/tar.gz/7eede1c1a756977b09f96aa14a92e2b06318ae87 - version: https://codeload.github.com/tloncorp/api-beta/tar.gz/7eede1c1a756977b09f96aa14a92e2b06318ae87 + specifier: https://codeload.github.com/tloncorp/api-beta/tar.gz/c121deb82d97970418508691585aea4f71abcf9c + version: https://codeload.github.com/tloncorp/api-beta/tar.gz/c121deb82d97970418508691585aea4f71abcf9c '@tloncorp/tlon-skill': specifier: 0.2.2 version: 0.2.2 @@ -3426,8 +3426,8 @@ packages: resolution: {integrity: sha512-5Kc5CM2Ysn3vTTArBs2vESUt0AQiWZA86yc1TI3B+lxXmtEq133C1nxXNOgnzhrivdPZIh3zLj5gDnZjoLL5GA==} engines: {node: '>=12.17.0'} - '@tloncorp/api@https://codeload.github.com/tloncorp/api-beta/tar.gz/7eede1c1a756977b09f96aa14a92e2b06318ae87': - resolution: {tarball: https://codeload.github.com/tloncorp/api-beta/tar.gz/7eede1c1a756977b09f96aa14a92e2b06318ae87} + '@tloncorp/api@https://codeload.github.com/tloncorp/api-beta/tar.gz/c121deb82d97970418508691585aea4f71abcf9c': + resolution: {tarball: https://codeload.github.com/tloncorp/api-beta/tar.gz/c121deb82d97970418508691585aea4f71abcf9c} version: 0.0.2 '@tloncorp/tlon-skill-darwin-arm64@0.2.2': @@ -10849,7 +10849,7 @@ snapshots: '@tinyhttp/content-disposition@2.2.4': {} - '@tloncorp/api@https://codeload.github.com/tloncorp/api-beta/tar.gz/7eede1c1a756977b09f96aa14a92e2b06318ae87': + '@tloncorp/api@https://codeload.github.com/tloncorp/api-beta/tar.gz/c121deb82d97970418508691585aea4f71abcf9c': dependencies: '@aws-sdk/client-s3': 3.1000.0 '@aws-sdk/s3-request-presigner': 3.1000.0 From b9e08a6839d36bc9c38c9d0c8650c4a33f962d5c Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Wed, 18 Mar 2026 02:45:00 -0700 Subject: [PATCH 40/55] Config: align model compat thinking format types --- src/config/types.models.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/config/types.models.ts b/src/config/types.models.ts index bc79f24943f..e1d60bcf695 100644 --- a/src/config/types.models.ts +++ b/src/config/types.models.ts @@ -22,13 +22,17 @@ type SupportedOpenAICompatFields = Pick< | "supportsUsageInStreaming" | "supportsStrictMode" | "maxTokensField" - | "thinkingFormat" | "requiresToolResultName" | "requiresAssistantAfterToolResult" | "requiresThinkingAsText" >; +type SupportedThinkingFormat = + | NonNullable + | "qwen-chat-template"; + export type ModelCompatConfig = SupportedOpenAICompatFields & { + thinkingFormat?: SupportedThinkingFormat; supportsTools?: boolean; toolSchemaProfile?: "xai"; nativeWebSearchTool?: boolean; From f2655e1e92f2109bfe2e53744381bb65986a0ce5 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Wed, 18 Mar 2026 15:37:24 +0530 Subject: [PATCH 41/55] test(telegram): fix incomplete sticker-cache mocks in tests --- extensions/telegram/src/bot-message-dispatch.test.ts | 4 ++++ .../src/bot/delivery.resolve-media-retry.test.ts | 10 ++++++---- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/extensions/telegram/src/bot-message-dispatch.test.ts b/extensions/telegram/src/bot-message-dispatch.test.ts index ea1c098e7b6..177e045f9e8 100644 --- a/extensions/telegram/src/bot-message-dispatch.test.ts +++ b/extensions/telegram/src/bot-message-dispatch.test.ts @@ -41,6 +41,10 @@ vi.mock("../../../src/config/sessions.js", async (importOriginal) => { vi.mock("./sticker-cache.js", () => ({ cacheSticker: vi.fn(), + getCachedSticker: () => null, + getCacheStats: () => ({ count: 0 }), + searchStickers: () => [], + getAllCachedStickers: () => [], describeStickerImage: vi.fn(), })); diff --git a/extensions/telegram/src/bot/delivery.resolve-media-retry.test.ts b/extensions/telegram/src/bot/delivery.resolve-media-retry.test.ts index 54dcf963997..b1cd7eb4d8a 100644 --- a/extensions/telegram/src/bot/delivery.resolve-media-retry.test.ts +++ b/extensions/telegram/src/bot/delivery.resolve-media-retry.test.ts @@ -28,9 +28,13 @@ vi.mock("openclaw/plugin-sdk/runtime-env", async (importOriginal) => { vi.mock("../sticker-cache.js", () => ({ cacheSticker: () => {}, getCachedSticker: () => null, + getCacheStats: () => ({ count: 0 }), + searchStickers: () => [], + getAllCachedStickers: () => [], + describeStickerImage: async () => null, })); -let resolveMedia: typeof import("./delivery.js").resolveMedia; +import { resolveMedia } from "./delivery.js"; const MAX_MEDIA_BYTES = 10_000_000; const BOT_TOKEN = "tok123"; @@ -165,9 +169,7 @@ async function flushRetryTimers() { } describe("resolveMedia getFile retry", () => { - beforeEach(async () => { - vi.resetModules(); - ({ resolveMedia } = await import("./delivery.js")); + beforeEach(() => { vi.useFakeTimers(); fetchRemoteMedia.mockReset(); saveMediaBuffer.mockReset(); From 0e9b899aee38614287a92ee1e2a0f790002504a7 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Wed, 18 Mar 2026 15:54:02 +0530 Subject: [PATCH 42/55] test: enable vmForks for targeted channel test runs Channel tests were always using process forks, missing the shared transform cache that vmForks provides. This caused ~138s import overhead per file. Now uses vmForks when available, matching the pattern already used by unit-fast and extensions suites. --- scripts/test-parallel.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/test-parallel.mjs b/scripts/test-parallel.mjs index dd933b4e4ae..11bd12c185c 100644 --- a/scripts/test-parallel.mjs +++ b/scripts/test-parallel.mjs @@ -487,7 +487,7 @@ const createTargetedEntry = (owner, isolated, filters) => { "run", "--config", "vitest.channels.config.ts", - ...(forceForks ? ["--pool=forks"] : []), + ...(forceForks ? ["--pool=forks"] : useVmForks ? ["--pool=vmForks"] : []), ...filters, ], }; From 06832112ee7ae6f06cf83db81703d4908f08563b Mon Sep 17 00:00:00 2001 From: Tak Hoffman <781889+Takhoffman@users.noreply.github.com> Date: Wed, 18 Mar 2026 06:51:22 -0500 Subject: [PATCH 43/55] ci enforce boundary guardrails --- .github/workflows/ci.yml | 124 +++------------------------------------ package.json | 2 +- 2 files changed, 9 insertions(+), 117 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9c2ffe0e87b..96ab35a297e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -309,8 +309,6 @@ jobs: needs: [docs-scope, changed-scope] if: needs.docs-scope.outputs.docs_only != 'true' && needs.changed-scope.outputs.run_node == 'true' runs-on: blacksmith-16vcpu-ubuntu-2404 - env: - PLUGIN_EXTENSION_BOUNDARY_ENFORCE_AFTER: "2026-03-24T05:00:00Z" steps: - name: Checkout uses: actions/checkout@v6 @@ -323,41 +321,14 @@ jobs: install-bun: "false" use-sticky-disk: "false" - - name: Run plugin extension boundary guard with grace period - shell: bash - run: | - set -euo pipefail - - tmp_output="$(mktemp)" - if pnpm run lint:plugins:no-extension-imports >"$tmp_output" 2>&1; then - cat "$tmp_output" - rm -f "$tmp_output" - exit 0 - fi - - status=$? - cat "$tmp_output" - rm -f "$tmp_output" - - now_epoch="$(date -u +%s)" - enforce_epoch="$(date -u -d "$PLUGIN_EXTENSION_BOUNDARY_ENFORCE_AFTER" +%s)" - fix_instructions="If you are an LLM agent fixing this: run 'pnpm run lint:plugins:no-extension-imports', remove src/plugins/** -> extensions/** imports where possible, and if the remaining inventory is intentional for now update test/fixtures/plugin-extension-import-boundary-inventory.json in the same PR." - - if [ "$now_epoch" -lt "$enforce_epoch" ]; then - echo "::warning::Plugin extension import boundary violations are temporarily allowed until ${PLUGIN_EXTENSION_BOUNDARY_ENFORCE_AFTER}. This grace period ends in one week from the rollout date. After that timestamp this job will fail unless the inventory is reduced or the baseline is intentionally updated. ${fix_instructions}" - exit 0 - fi - - echo "::error::Plugin extension import boundary grace period ended at ${PLUGIN_EXTENSION_BOUNDARY_ENFORCE_AFTER}. ${fix_instructions}" - exit "$status" + - name: Run plugin extension boundary guard + run: pnpm run lint:plugins:no-extension-imports web-search-provider-boundary: name: "web-search-provider-boundary" needs: [docs-scope, changed-scope] if: needs.docs-scope.outputs.docs_only != 'true' && needs.changed-scope.outputs.run_node == 'true' runs-on: blacksmith-16vcpu-ubuntu-2404 - env: - WEB_SEARCH_PROVIDER_BOUNDARY_ENFORCE_AFTER: "2026-03-24T05:00:00Z" steps: - name: Checkout uses: actions/checkout@v6 @@ -370,41 +341,14 @@ jobs: install-bun: "false" use-sticky-disk: "false" - - name: Run web search provider boundary guard with grace period - shell: bash - run: | - set -euo pipefail - - tmp_output="$(mktemp)" - if pnpm run lint:web-search-provider-boundaries >"$tmp_output" 2>&1; then - cat "$tmp_output" - rm -f "$tmp_output" - exit 0 - fi - - status=$? - cat "$tmp_output" - rm -f "$tmp_output" - - now_epoch="$(date -u +%s)" - enforce_epoch="$(date -u -d "$WEB_SEARCH_PROVIDER_BOUNDARY_ENFORCE_AFTER" +%s)" - fix_instructions="If you are an LLM agent fixing this: run 'pnpm run lint:web-search-provider-boundaries', move provider-specific web-search logic out of core, and if the remaining inventory is intentional for now update test/fixtures/web-search-provider-boundary-inventory.json in the same PR." - - if [ "$now_epoch" -lt "$enforce_epoch" ]; then - echo "::warning::Web search provider boundary violations are temporarily allowed until ${WEB_SEARCH_PROVIDER_BOUNDARY_ENFORCE_AFTER}. This grace period ends in one week from the rollout date. After that timestamp this job will fail unless the inventory is reduced or the baseline is intentionally updated. ${fix_instructions}" - exit 0 - fi - - echo "::error::Web search provider boundary grace period ended at ${WEB_SEARCH_PROVIDER_BOUNDARY_ENFORCE_AFTER}. ${fix_instructions}" - exit "$status" + - name: Run web search provider boundary guard + run: pnpm run lint:web-search-provider-boundaries extension-src-outside-plugin-sdk-boundary: name: "extension-src-outside-plugin-sdk-boundary" needs: [docs-scope, changed-scope] if: needs.docs-scope.outputs.docs_only != 'true' && needs.changed-scope.outputs.run_node == 'true' runs-on: blacksmith-16vcpu-ubuntu-2404 - env: - EXTENSION_PLUGIN_SDK_BOUNDARY_ENFORCE_AFTER: "2026-03-24T05:00:00Z" steps: - name: Checkout uses: actions/checkout@v6 @@ -417,41 +361,14 @@ jobs: install-bun: "false" use-sticky-disk: "false" - - name: Run extension src boundary guard with grace period - shell: bash - run: | - set -euo pipefail - - tmp_output="$(mktemp)" - if pnpm run lint:extensions:no-src-outside-plugin-sdk >"$tmp_output" 2>&1; then - cat "$tmp_output" - rm -f "$tmp_output" - exit 0 - fi - - status=$? - cat "$tmp_output" - rm -f "$tmp_output" - - now_epoch="$(date -u +%s)" - enforce_epoch="$(date -u -d "$EXTENSION_PLUGIN_SDK_BOUNDARY_ENFORCE_AFTER" +%s)" - fix_instructions="If you are an LLM agent fixing this: run 'pnpm run lint:extensions:no-src-outside-plugin-sdk', move extension imports off core src paths and onto src/plugin-sdk/**, and if the remaining inventory is intentional for now update test/fixtures/extension-src-outside-plugin-sdk-inventory.json in the same PR." - - if [ "$now_epoch" -lt "$enforce_epoch" ]; then - echo "::warning::Extension src boundary violations are temporarily allowed until ${EXTENSION_PLUGIN_SDK_BOUNDARY_ENFORCE_AFTER}. This grace period ends in one week from the rollout date. After that timestamp this job will fail unless the inventory is reduced or the baseline is intentionally updated. ${fix_instructions}" - exit 0 - fi - - echo "::error::Extension src boundary grace period ended at ${EXTENSION_PLUGIN_SDK_BOUNDARY_ENFORCE_AFTER}. ${fix_instructions}" - exit "$status" + - name: Run extension src boundary guard + run: pnpm run lint:extensions:no-src-outside-plugin-sdk extension-plugin-sdk-internal-boundary: name: "extension-plugin-sdk-internal-boundary" needs: [docs-scope, changed-scope] if: needs.docs-scope.outputs.docs_only != 'true' && needs.changed-scope.outputs.run_node == 'true' runs-on: blacksmith-16vcpu-ubuntu-2404 - env: - EXTENSION_PLUGIN_SDK_INTERNAL_ENFORCE_AFTER: "2026-03-24T05:00:00Z" steps: - name: Checkout uses: actions/checkout@v6 @@ -464,33 +381,8 @@ jobs: install-bun: "false" use-sticky-disk: "false" - - name: Run extension plugin-sdk-internal guard with grace period - shell: bash - run: | - set -euo pipefail - - tmp_output="$(mktemp)" - if pnpm run lint:extensions:no-plugin-sdk-internal >"$tmp_output" 2>&1; then - cat "$tmp_output" - rm -f "$tmp_output" - exit 0 - fi - - status=$? - cat "$tmp_output" - rm -f "$tmp_output" - - now_epoch="$(date -u +%s)" - enforce_epoch="$(date -u -d "$EXTENSION_PLUGIN_SDK_INTERNAL_ENFORCE_AFTER" +%s)" - fix_instructions="If you are an LLM agent fixing this: run 'pnpm run lint:extensions:no-plugin-sdk-internal', remove extension imports of src/plugin-sdk-internal/** in favor of src/plugin-sdk/**, and if the remaining inventory is intentional for now update test/fixtures/extension-plugin-sdk-internal-inventory.json in the same PR." - - if [ "$now_epoch" -lt "$enforce_epoch" ]; then - echo "::warning::Extension plugin-sdk-internal boundary violations are temporarily allowed until ${EXTENSION_PLUGIN_SDK_INTERNAL_ENFORCE_AFTER}. This grace period ends in one week from the rollout date. After that timestamp this job will fail unless the inventory is reduced or the baseline is intentionally updated. ${fix_instructions}" - exit 0 - fi - - echo "::error::Extension plugin-sdk-internal boundary grace period ended at ${EXTENSION_PLUGIN_SDK_INTERNAL_ENFORCE_AFTER}. ${fix_instructions}" - exit "$status" + - name: Run extension plugin-sdk-internal guard + run: pnpm run lint:extensions:no-plugin-sdk-internal build-smoke: name: "build-smoke" diff --git a/package.json b/package.json index e3dfda5cd75..5087d9bdf72 100644 --- a/package.json +++ b/package.json @@ -511,7 +511,7 @@ "build:plugin-sdk:dts": "tsc -p tsconfig.plugin-sdk.dts.json || true", "build:strict-smoke": "pnpm canvas:a2ui:bundle && node scripts/tsdown-build.mjs && node scripts/runtime-postbuild.mjs && pnpm build:plugin-sdk:dts", "canvas:a2ui:bundle": "bash scripts/bundle-a2ui.sh", - "check": "pnpm check:host-env-policy:swift && pnpm format:check && pnpm tsgo && pnpm plugin-sdk:check-exports && pnpm lint && pnpm lint:tmp:no-random-messaging && pnpm lint:tmp:channel-agnostic-boundaries && pnpm lint:tmp:no-raw-channel-fetch && pnpm lint:agent:ingress-owner && pnpm lint:plugins:no-register-http-handler && pnpm lint:plugins:no-monolithic-plugin-sdk-entry-imports && pnpm lint:plugins:no-extension-src-imports && pnpm lint:plugins:no-extension-test-core-imports && pnpm lint:webhook:no-low-level-body-read && pnpm lint:auth:no-pairing-store-group && pnpm lint:auth:pairing-account-scope", + "check": "pnpm check:host-env-policy:swift && pnpm format:check && pnpm tsgo && pnpm plugin-sdk:check-exports && pnpm lint && pnpm lint:tmp:no-random-messaging && pnpm lint:tmp:channel-agnostic-boundaries && pnpm lint:tmp:no-raw-channel-fetch && pnpm lint:agent:ingress-owner && pnpm lint:plugins:no-register-http-handler && pnpm lint:plugins:no-monolithic-plugin-sdk-entry-imports && pnpm lint:plugins:no-extension-src-imports && pnpm lint:plugins:no-extension-test-core-imports && pnpm lint:plugins:no-extension-imports && pnpm lint:extensions:no-src-outside-plugin-sdk && pnpm lint:extensions:no-plugin-sdk-internal && pnpm lint:web-search-provider-boundaries && pnpm lint:webhook:no-low-level-body-read && pnpm lint:auth:no-pairing-store-group && pnpm lint:auth:pairing-account-scope", "check:docs": "pnpm format:docs:check && pnpm lint:docs && pnpm docs:check-i18n-glossary && pnpm docs:check-links", "check:host-env-policy:swift": "node scripts/generate-host-env-security-policy-swift.mjs --check", "check:loc": "node --import tsx scripts/check-ts-max-loc.ts --max 500", From f58e0f5592fc0b58767dc941a4c2171238e9ef0b Mon Sep 17 00:00:00 2001 From: Tak Hoffman <781889+Takhoffman@users.noreply.github.com> Date: Wed, 18 Mar 2026 07:04:50 -0500 Subject: [PATCH 44/55] test simplify zero-state boundary guards --- .../check-extension-plugin-sdk-boundary.mjs | 16 +- .../check-web-search-provider-boundaries.mjs | 9 +- test/extension-plugin-sdk-boundary.test.ts | 59 +-- ...tension-plugin-sdk-internal-inventory.json | 1 - ...sion-src-outside-plugin-sdk-inventory.json | 418 ------------------ ...eb-search-provider-boundary-inventory.json | 1 - test/web-search-provider-boundary.test.ts | 28 +- 7 files changed, 37 insertions(+), 495 deletions(-) delete mode 100644 test/fixtures/extension-plugin-sdk-internal-inventory.json delete mode 100644 test/fixtures/extension-src-outside-plugin-sdk-inventory.json delete mode 100644 test/fixtures/web-search-provider-boundary-inventory.json diff --git a/scripts/check-extension-plugin-sdk-boundary.mjs b/scripts/check-extension-plugin-sdk-boundary.mjs index 90933218501..43046d8ab5f 100644 --- a/scripts/check-extension-plugin-sdk-boundary.mjs +++ b/scripts/check-extension-plugin-sdk-boundary.mjs @@ -43,6 +43,7 @@ function isCodeFile(fileName) { function isTestLikeFile(relativePath) { return ( /(^|\/)(__tests__|fixtures)\//.test(relativePath) || + /(^|\/)[^/]*test-support\.(ts|tsx|mts|cts|js|jsx|mjs|cjs)$/.test(relativePath) || /\.(test|spec)\.(ts|tsx|mts|cts|js|jsx|mjs|cjs)$/.test(relativePath) ); } @@ -190,7 +191,20 @@ export async function collectExtensionPluginSdkBoundaryInventory(mode) { } export async function readExpectedInventory(mode) { - return JSON.parse(await fs.readFile(baselinePathByMode[mode], "utf8")); + try { + return JSON.parse(await fs.readFile(baselinePathByMode[mode], "utf8")); + } catch (error) { + if ( + (mode === "plugin-sdk-internal" || mode === "src-outside-plugin-sdk") && + error && + typeof error === "object" && + "code" in error && + error.code === "ENOENT" + ) { + return []; + } + throw error; + } } export function diffInventory(expected, actual) { diff --git a/scripts/check-web-search-provider-boundaries.mjs b/scripts/check-web-search-provider-boundaries.mjs index ae680bc4124..2ba31b465c0 100644 --- a/scripts/check-web-search-provider-boundaries.mjs +++ b/scripts/check-web-search-provider-boundaries.mjs @@ -214,7 +214,14 @@ export async function collectWebSearchProviderBoundaryInventory() { } export async function readExpectedInventory() { - return JSON.parse(await fs.readFile(baselinePath, "utf8")); + try { + return JSON.parse(await fs.readFile(baselinePath, "utf8")); + } catch (error) { + if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") { + return []; + } + throw error; + } } export function diffInventory(expected, actual) { diff --git a/test/extension-plugin-sdk-boundary.test.ts b/test/extension-plugin-sdk-boundary.test.ts index 90372348a95..ea421d2708f 100644 --- a/test/extension-plugin-sdk-boundary.test.ts +++ b/test/extension-plugin-sdk-boundary.test.ts @@ -1,20 +1,18 @@ import { execFileSync } from "node:child_process"; -import { readFileSync } from "node:fs"; import path from "node:path"; import { describe, expect, it } from "vitest"; -import { - collectExtensionPluginSdkBoundaryInventory, - diffInventory, -} from "../scripts/check-extension-plugin-sdk-boundary.mjs"; +import { collectExtensionPluginSdkBoundaryInventory } from "../scripts/check-extension-plugin-sdk-boundary.mjs"; const repoRoot = process.cwd(); const scriptPath = path.join(repoRoot, "scripts", "check-extension-plugin-sdk-boundary.mjs"); -function readBaseline(fileName: string) { - return JSON.parse(readFileSync(path.join(repoRoot, "test", "fixtures", fileName), "utf8")); -} - describe("extension src outside plugin-sdk boundary inventory", () => { + it("is currently empty", async () => { + const inventory = await collectExtensionPluginSdkBoundaryInventory("src-outside-plugin-sdk"); + + expect(inventory).toEqual([]); + }); + it("produces stable sorted output", async () => { const first = await collectExtensionPluginSdkBoundaryInventory("src-outside-plugin-sdk"); const second = await collectExtensionPluginSdkBoundaryInventory("src-outside-plugin-sdk"); @@ -33,31 +31,7 @@ describe("extension src outside plugin-sdk boundary inventory", () => { ).toEqual(first); }); - it("captures known current production violations", async () => { - const inventory = await collectExtensionPluginSdkBoundaryInventory("src-outside-plugin-sdk"); - - expect(inventory).toContainEqual( - expect.objectContaining({ - file: "extensions/brave/src/brave-web-search-provider.ts", - resolvedPath: "src/agents/tools/common.js", - }), - ); - expect(inventory).toContainEqual( - expect.objectContaining({ - file: "extensions/discord/src/runtime-api.ts", - resolvedPath: "src/config/types.secrets.js", - }), - ); - }); - - it("matches the checked-in baseline", async () => { - const expected = readBaseline("extension-src-outside-plugin-sdk-inventory.json"); - const actual = await collectExtensionPluginSdkBoundaryInventory("src-outside-plugin-sdk"); - - expect(diffInventory(expected, actual)).toEqual({ missing: [], unexpected: [] }); - }); - - it("script json output matches the baseline exactly", () => { + it("script json output is empty", () => { const stdout = execFileSync( process.execPath, [scriptPath, "--mode=src-outside-plugin-sdk", "--json"], @@ -67,9 +41,7 @@ describe("extension src outside plugin-sdk boundary inventory", () => { }, ); - expect(JSON.parse(stdout)).toEqual( - readBaseline("extension-src-outside-plugin-sdk-inventory.json"), - ); + expect(JSON.parse(stdout)).toEqual([]); }); }); @@ -80,14 +52,7 @@ describe("extension plugin-sdk-internal boundary inventory", () => { expect(inventory).toEqual([]); }); - it("matches the checked-in empty baseline", async () => { - const expected = readBaseline("extension-plugin-sdk-internal-inventory.json"); - const actual = await collectExtensionPluginSdkBoundaryInventory("plugin-sdk-internal"); - - expect(diffInventory(expected, actual)).toEqual({ missing: [], unexpected: [] }); - }); - - it("script json output matches the empty baseline exactly", () => { + it("script json output is empty", () => { const stdout = execFileSync( process.execPath, [scriptPath, "--mode=plugin-sdk-internal", "--json"], @@ -97,8 +62,6 @@ describe("extension plugin-sdk-internal boundary inventory", () => { }, ); - expect(JSON.parse(stdout)).toEqual( - readBaseline("extension-plugin-sdk-internal-inventory.json"), - ); + expect(JSON.parse(stdout)).toEqual([]); }); }); diff --git a/test/fixtures/extension-plugin-sdk-internal-inventory.json b/test/fixtures/extension-plugin-sdk-internal-inventory.json deleted file mode 100644 index fe51488c706..00000000000 --- a/test/fixtures/extension-plugin-sdk-internal-inventory.json +++ /dev/null @@ -1 +0,0 @@ -[] diff --git a/test/fixtures/extension-src-outside-plugin-sdk-inventory.json b/test/fixtures/extension-src-outside-plugin-sdk-inventory.json deleted file mode 100644 index 3c5aff2a370..00000000000 --- a/test/fixtures/extension-src-outside-plugin-sdk-inventory.json +++ /dev/null @@ -1,418 +0,0 @@ -[ - { - "file": "extensions/discord/src/directory-config.ts", - "line": 7, - "kind": "import", - "specifier": "../../../src/channels/read-only-account-inspect.discord.runtime.js", - "resolvedPath": "src/channels/read-only-account-inspect.discord.runtime.js", - "reason": "imports core src path outside plugin-sdk from an extension" - }, - { - "file": "extensions/discord/src/directory-config.ts", - "line": 8, - "kind": "import", - "specifier": "../../../src/channels/read-only-account-inspect.js", - "resolvedPath": "src/channels/read-only-account-inspect.js", - "reason": "imports core src path outside plugin-sdk from an extension" - }, - { - "file": "extensions/googlechat/runtime-api.ts", - "line": 10, - "kind": "export", - "specifier": "../../src/agents/tools/common.js", - "resolvedPath": "src/agents/tools/common.js", - "reason": "re-exports core src path outside plugin-sdk from an extension" - }, - { - "file": "extensions/googlechat/runtime-api.ts", - "line": 23, - "kind": "export", - "specifier": "../../src/channels/mention-gating.js", - "resolvedPath": "src/channels/mention-gating.js", - "reason": "re-exports core src path outside plugin-sdk from an extension" - }, - { - "file": "extensions/googlechat/runtime-api.ts", - "line": 30, - "kind": "export", - "specifier": "../../src/channels/plugins/config-schema.js", - "resolvedPath": "src/channels/plugins/config-schema.js", - "reason": "re-exports core src path outside plugin-sdk from an extension" - }, - { - "file": "extensions/googlechat/runtime-api.ts", - "line": 34, - "kind": "export", - "specifier": "../../src/channels/plugins/config-helpers.js", - "resolvedPath": "src/channels/plugins/config-helpers.js", - "reason": "re-exports core src path outside plugin-sdk from an extension" - }, - { - "file": "extensions/googlechat/runtime-api.ts", - "line": 38, - "kind": "export", - "specifier": "../../src/channels/plugins/directory-config-helpers.js", - "resolvedPath": "src/channels/plugins/directory-config-helpers.js", - "reason": "re-exports core src path outside plugin-sdk from an extension" - }, - { - "file": "extensions/googlechat/runtime-api.ts", - "line": 39, - "kind": "export", - "specifier": "../../src/channels/plugins/helpers.js", - "resolvedPath": "src/channels/plugins/helpers.js", - "reason": "re-exports core src path outside plugin-sdk from an extension" - }, - { - "file": "extensions/googlechat/runtime-api.ts", - "line": 40, - "kind": "export", - "specifier": "../../src/channels/plugins/media-limits.js", - "resolvedPath": "src/channels/plugins/media-limits.js", - "reason": "re-exports core src path outside plugin-sdk from an extension" - }, - { - "file": "extensions/googlechat/runtime-api.ts", - "line": 46, - "kind": "export", - "specifier": "../../src/channels/plugins/setup-wizard-helpers.js", - "resolvedPath": "src/channels/plugins/setup-wizard-helpers.js", - "reason": "re-exports core src path outside plugin-sdk from an extension" - }, - { - "file": "extensions/googlechat/runtime-api.ts", - "line": 47, - "kind": "export", - "specifier": "../../src/channels/plugins/pairing-message.js", - "resolvedPath": "src/channels/plugins/pairing-message.js", - "reason": "re-exports core src path outside plugin-sdk from an extension" - }, - { - "file": "extensions/googlechat/runtime-api.ts", - "line": 52, - "kind": "export", - "specifier": "../../src/channels/plugins/setup-helpers.js", - "resolvedPath": "src/channels/plugins/setup-helpers.js", - "reason": "re-exports core src path outside plugin-sdk from an extension" - }, - { - "file": "extensions/googlechat/runtime-api.ts", - "line": 53, - "kind": "export", - "specifier": "../../src/channels/plugins/account-helpers.js", - "resolvedPath": "src/channels/plugins/account-helpers.js", - "reason": "re-exports core src path outside plugin-sdk from an extension" - }, - { - "file": "extensions/googlechat/runtime-api.ts", - "line": 59, - "kind": "export", - "specifier": "../../src/channels/plugins/types.js", - "resolvedPath": "src/channels/plugins/types.js", - "reason": "re-exports core src path outside plugin-sdk from an extension" - }, - { - "file": "extensions/googlechat/runtime-api.ts", - "line": 60, - "kind": "export", - "specifier": "../../src/channels/plugins/types.plugin.js", - "resolvedPath": "src/channels/plugins/types.plugin.js", - "reason": "re-exports core src path outside plugin-sdk from an extension" - }, - { - "file": "extensions/googlechat/runtime-api.ts", - "line": 61, - "kind": "export", - "specifier": "../../src/channels/registry.js", - "resolvedPath": "src/channels/registry.js", - "reason": "re-exports core src path outside plugin-sdk from an extension" - }, - { - "file": "extensions/googlechat/runtime-api.ts", - "line": 62, - "kind": "export", - "specifier": "../../src/channels/reply-prefix.js", - "resolvedPath": "src/channels/reply-prefix.js", - "reason": "re-exports core src path outside plugin-sdk from an extension" - }, - { - "file": "extensions/googlechat/runtime-api.ts", - "line": 63, - "kind": "export", - "specifier": "../../src/config/config.js", - "resolvedPath": "src/config/config.js", - "reason": "re-exports core src path outside plugin-sdk from an extension" - }, - { - "file": "extensions/googlechat/runtime-api.ts", - "line": 64, - "kind": "export", - "specifier": "../../src/config/dangerous-name-matching.js", - "resolvedPath": "src/config/dangerous-name-matching.js", - "reason": "re-exports core src path outside plugin-sdk from an extension" - }, - { - "file": "extensions/googlechat/runtime-api.ts", - "line": 70, - "kind": "export", - "specifier": "../../src/config/runtime-group-policy.js", - "resolvedPath": "src/config/runtime-group-policy.js", - "reason": "re-exports core src path outside plugin-sdk from an extension" - }, - { - "file": "extensions/googlechat/runtime-api.ts", - "line": 75, - "kind": "export", - "specifier": "../../src/config/types.js", - "resolvedPath": "src/config/types.js", - "reason": "re-exports core src path outside plugin-sdk from an extension" - }, - { - "file": "extensions/googlechat/runtime-api.ts", - "line": 76, - "kind": "export", - "specifier": "../../src/config/types.secrets.js", - "resolvedPath": "src/config/types.secrets.js", - "reason": "re-exports core src path outside plugin-sdk from an extension" - }, - { - "file": "extensions/googlechat/runtime-api.ts", - "line": 77, - "kind": "export", - "specifier": "../../src/config/zod-schema.providers-core.js", - "resolvedPath": "src/config/zod-schema.providers-core.js", - "reason": "re-exports core src path outside plugin-sdk from an extension" - }, - { - "file": "extensions/googlechat/runtime-api.ts", - "line": 78, - "kind": "export", - "specifier": "../../src/infra/net/fetch-guard.js", - "resolvedPath": "src/infra/net/fetch-guard.js", - "reason": "re-exports core src path outside plugin-sdk from an extension" - }, - { - "file": "extensions/googlechat/runtime-api.ts", - "line": 79, - "kind": "export", - "specifier": "../../src/infra/outbound/target-errors.js", - "resolvedPath": "src/infra/outbound/target-errors.js", - "reason": "re-exports core src path outside plugin-sdk from an extension" - }, - { - "file": "extensions/googlechat/runtime-api.ts", - "line": 80, - "kind": "export", - "specifier": "../../src/plugins/config-schema.js", - "resolvedPath": "src/plugins/config-schema.js", - "reason": "re-exports core src path outside plugin-sdk from an extension" - }, - { - "file": "extensions/googlechat/runtime-api.ts", - "line": 81, - "kind": "export", - "specifier": "../../src/plugins/runtime/types.js", - "resolvedPath": "src/plugins/runtime/types.js", - "reason": "re-exports core src path outside plugin-sdk from an extension" - }, - { - "file": "extensions/googlechat/runtime-api.ts", - "line": 82, - "kind": "export", - "specifier": "../../src/plugins/types.js", - "resolvedPath": "src/plugins/types.js", - "reason": "re-exports core src path outside plugin-sdk from an extension" - }, - { - "file": "extensions/googlechat/runtime-api.ts", - "line": 83, - "kind": "export", - "specifier": "../../src/routing/session-key.js", - "resolvedPath": "src/routing/session-key.js", - "reason": "re-exports core src path outside plugin-sdk from an extension" - }, - { - "file": "extensions/googlechat/runtime-api.ts", - "line": 84, - "kind": "export", - "specifier": "../../src/security/dm-policy-shared.js", - "resolvedPath": "src/security/dm-policy-shared.js", - "reason": "re-exports core src path outside plugin-sdk from an extension" - }, - { - "file": "extensions/googlechat/runtime-api.ts", - "line": 85, - "kind": "export", - "specifier": "../../src/terminal/links.js", - "resolvedPath": "src/terminal/links.js", - "reason": "re-exports core src path outside plugin-sdk from an extension" - }, - { - "file": "extensions/googlechat/runtime-api.ts", - "line": 86, - "kind": "export", - "specifier": "../../src/wizard/prompts.js", - "resolvedPath": "src/wizard/prompts.js", - "reason": "re-exports core src path outside plugin-sdk from an extension" - }, - { - "file": "extensions/googlechat/runtime-api.ts", - "line": 89, - "kind": "export", - "specifier": "../../src/pairing/pairing-challenge.js", - "resolvedPath": "src/pairing/pairing-challenge.js", - "reason": "re-exports core src path outside plugin-sdk from an extension" - }, - { - "file": "extensions/imessage/runtime-api.ts", - "line": 1, - "kind": "export", - "specifier": "../../src/config/types.imessage.js", - "resolvedPath": "src/config/types.imessage.js", - "reason": "re-exports core src path outside plugin-sdk from an extension" - }, - { - "file": "extensions/imessage/runtime-api.ts", - "line": 2, - "kind": "export", - "specifier": "../../src/channels/plugins/types.plugin.js", - "resolvedPath": "src/channels/plugins/types.plugin.js", - "reason": "re-exports core src path outside plugin-sdk from an extension" - }, - { - "file": "extensions/imessage/runtime-api.ts", - "line": 15, - "kind": "export", - "specifier": "../../src/channels/plugins/media-limits.js", - "resolvedPath": "src/channels/plugins/media-limits.js", - "reason": "re-exports core src path outside plugin-sdk from an extension" - }, - { - "file": "extensions/imessage/runtime-api.ts", - "line": 19, - "kind": "export", - "specifier": "../../src/channels/plugins/normalize/imessage.js", - "resolvedPath": "src/channels/plugins/normalize/imessage.js", - "reason": "re-exports core src path outside plugin-sdk from an extension" - }, - { - "file": "extensions/imessage/runtime-api.ts", - "line": 20, - "kind": "export", - "specifier": "../../src/config/zod-schema.providers-core.js", - "resolvedPath": "src/config/zod-schema.providers-core.js", - "reason": "re-exports core src path outside plugin-sdk from an extension" - }, - { - "file": "extensions/slack/src/directory-config.ts", - "line": 8, - "kind": "import", - "specifier": "../../../src/channels/plugins/normalize/slack.js", - "resolvedPath": "src/channels/plugins/normalize/slack.js", - "reason": "imports core src path outside plugin-sdk from an extension" - }, - { - "file": "extensions/slack/src/directory-config.ts", - "line": 9, - "kind": "import", - "specifier": "../../../src/channels/read-only-account-inspect.js", - "resolvedPath": "src/channels/read-only-account-inspect.js", - "reason": "imports core src path outside plugin-sdk from an extension" - }, - { - "file": "extensions/slack/src/directory-config.ts", - "line": 10, - "kind": "import", - "specifier": "../../../src/channels/read-only-account-inspect.slack.runtime.js", - "resolvedPath": "src/channels/read-only-account-inspect.slack.runtime.js", - "reason": "imports core src path outside plugin-sdk from an extension" - }, - { - "file": "extensions/slack/src/runtime-api.ts", - "line": 1, - "kind": "export", - "specifier": "../../../src/config/config.js", - "resolvedPath": "src/config/config.js", - "reason": "re-exports core src path outside plugin-sdk from an extension" - }, - { - "file": "extensions/slack/src/runtime-api.ts", - "line": 2, - "kind": "export", - "specifier": "../../../src/config/types.slack.js", - "resolvedPath": "src/config/types.slack.js", - "reason": "re-exports core src path outside plugin-sdk from an extension" - }, - { - "file": "extensions/slack/src/runtime-api.ts", - "line": 3, - "kind": "export", - "specifier": "../../../src/channels/plugins/types.js", - "resolvedPath": "src/channels/plugins/types.js", - "reason": "re-exports core src path outside plugin-sdk from an extension" - }, - { - "file": "extensions/slack/src/runtime-api.ts", - "line": 19, - "kind": "export", - "specifier": "../../../src/channels/plugins/normalize/slack.js", - "resolvedPath": "src/channels/plugins/normalize/slack.js", - "reason": "re-exports core src path outside plugin-sdk from an extension" - }, - { - "file": "extensions/slack/src/runtime-api.ts", - "line": 23, - "kind": "export", - "specifier": "../../../src/channels/account-snapshot-fields.js", - "resolvedPath": "src/channels/account-snapshot-fields.js", - "reason": "re-exports core src path outside plugin-sdk from an extension" - }, - { - "file": "extensions/slack/src/runtime-api.ts", - "line": 24, - "kind": "export", - "specifier": "../../../src/config/zod-schema.providers-core.js", - "resolvedPath": "src/config/zod-schema.providers-core.js", - "reason": "re-exports core src path outside plugin-sdk from an extension" - }, - { - "file": "extensions/slack/src/runtime-api.ts", - "line": 32, - "kind": "export", - "specifier": "../../../src/agents/tools/common.js", - "resolvedPath": "src/agents/tools/common.js", - "reason": "re-exports core src path outside plugin-sdk from an extension" - }, - { - "file": "extensions/slack/src/runtime-api.ts", - "line": 33, - "kind": "export", - "specifier": "../../../src/agents/date-time.js", - "resolvedPath": "src/agents/date-time.js", - "reason": "re-exports core src path outside plugin-sdk from an extension" - }, - { - "file": "extensions/telegram/src/directory-config.ts", - "line": 9, - "kind": "import", - "specifier": "../../../src/channels/read-only-account-inspect.js", - "resolvedPath": "src/channels/read-only-account-inspect.js", - "reason": "imports core src path outside plugin-sdk from an extension" - }, - { - "file": "extensions/telegram/src/directory-config.ts", - "line": 10, - "kind": "import", - "specifier": "../../../src/channels/read-only-account-inspect.telegram.runtime.js", - "resolvedPath": "src/channels/read-only-account-inspect.telegram.runtime.js", - "reason": "imports core src path outside plugin-sdk from an extension" - }, - { - "file": "extensions/whatsapp/src/directory-config.ts", - "line": 6, - "kind": "import", - "specifier": "../../../src/whatsapp/normalize.js", - "resolvedPath": "src/whatsapp/normalize.js", - "reason": "imports core src path outside plugin-sdk from an extension" - } -] diff --git a/test/fixtures/web-search-provider-boundary-inventory.json b/test/fixtures/web-search-provider-boundary-inventory.json deleted file mode 100644 index fe51488c706..00000000000 --- a/test/fixtures/web-search-provider-boundary-inventory.json +++ /dev/null @@ -1 +0,0 @@ -[] diff --git a/test/web-search-provider-boundary.test.ts b/test/web-search-provider-boundary.test.ts index b75c137ca98..f211a262ca3 100644 --- a/test/web-search-provider-boundary.test.ts +++ b/test/web-search-provider-boundary.test.ts @@ -1,24 +1,10 @@ import { execFileSync } from "node:child_process"; -import { readFileSync } from "node:fs"; import path from "node:path"; import { describe, expect, it } from "vitest"; -import { - collectWebSearchProviderBoundaryInventory, - diffInventory, -} from "../scripts/check-web-search-provider-boundaries.mjs"; +import { collectWebSearchProviderBoundaryInventory } from "../scripts/check-web-search-provider-boundaries.mjs"; const repoRoot = process.cwd(); const scriptPath = path.join(repoRoot, "scripts", "check-web-search-provider-boundaries.mjs"); -const baselinePath = path.join( - repoRoot, - "test", - "fixtures", - "web-search-provider-boundary-inventory.json", -); - -function readBaseline() { - return JSON.parse(readFileSync(baselinePath, "utf8")); -} describe("web search provider boundary inventory", () => { it("has no remaining production inventory in core", async () => { @@ -49,20 +35,12 @@ describe("web search provider boundary inventory", () => { ).toEqual(first); }); - it("matches the checked-in baseline", async () => { - const expected = readBaseline(); - const actual = await collectWebSearchProviderBoundaryInventory(); - - expect(diffInventory(expected, actual)).toEqual({ missing: [], unexpected: [] }); - expect(actual).toEqual([]); - }); - - it("script json output matches the baseline exactly", () => { + it("script json output is empty", () => { const stdout = execFileSync(process.execPath, [scriptPath, "--json"], { cwd: repoRoot, encoding: "utf8", }); - expect(JSON.parse(stdout)).toEqual(readBaseline()); + expect(JSON.parse(stdout)).toEqual([]); }); }); From 089a43f5e88b4ba4f383567c14934a4fce748a5f Mon Sep 17 00:00:00 2001 From: Andrew Demczuk Date: Wed, 18 Mar 2026 13:11:01 +0100 Subject: [PATCH 45/55] fix(security): block build-tool and glibc env injection vectors in host exec sandbox (#49702) Add GLIBC_TUNABLES, MAVEN_OPTS, SBT_OPTS, GRADLE_OPTS, ANT_OPTS, DOTNET_ADDITIONAL_DEPS to blockedKeys and GRADLE_USER_HOME to blockedOverrideKeys in the host exec security policy. Closes #22681 --- CHANGELOG.md | 1 + .../HostEnvSecurityPolicy.generated.swift | 9 ++++++++- src/infra/host-env-security-policy.json | 9 ++++++++- src/infra/host-env-security.test.ts | 15 +++++++++++++++ 4 files changed, 32 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 471970d48d6..aa76166bf0d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -159,6 +159,7 @@ Docs: https://docs.openclaw.ai - Tools/image generation: standardize the stock image create/edit path on the core `image_generate` tool. The old `nano-banana-pro` docs/examples are gone; if you previously copied that sample-skill config, switch to `agents.defaults.imageGenerationModel` for built-in image generation or install a separate third-party skill explicitly. - Skills/image generation: remove the bundled `nano-banana-pro` skill wrapper. Use `agents.defaults.imageGenerationModel.primary: "google/gemini-3-pro-image-preview"` for the native Nano Banana-style path instead. - Plugins/message discovery: require `ChannelMessageActionAdapter.describeMessageTool(...)` for shared `message` tool discovery. The legacy `listActions`, `getCapabilities`, and `getToolSchema` adapter methods are removed. Plugin authors should migrate message discovery to `describeMessageTool(...)` and keep channel-specific action runtime code inside the owning plugin package. Thanks @gumadeiras. +- Exec/env sandbox: block build-tool JVM injection (`MAVEN_OPTS`, `SBT_OPTS`, `GRADLE_OPTS`, `ANT_OPTS`), glibc tunable exploitation (`GLIBC_TUNABLES`), and .NET dependency resolution hijack (`DOTNET_ADDITIONAL_DEPS`) from the host exec environment, and restrict Gradle init script redirect (`GRADLE_USER_HOME`) as an override-only block so user-configured Gradle homes still propagate. (#49702) ## 2026.3.13 diff --git a/apps/macos/Sources/OpenClaw/HostEnvSecurityPolicy.generated.swift b/apps/macos/Sources/OpenClaw/HostEnvSecurityPolicy.generated.swift index ecdbdd0d77c..40db384b226 100644 --- a/apps/macos/Sources/OpenClaw/HostEnvSecurityPolicy.generated.swift +++ b/apps/macos/Sources/OpenClaw/HostEnvSecurityPolicy.generated.swift @@ -28,11 +28,18 @@ enum HostEnvSecurityPolicy { "_JAVA_OPTIONS", "JDK_JAVA_OPTIONS", "PYTHONBREAKPOINT", - "DOTNET_STARTUP_HOOKS" + "DOTNET_STARTUP_HOOKS", + "DOTNET_ADDITIONAL_DEPS", + "GLIBC_TUNABLES", + "MAVEN_OPTS", + "SBT_OPTS", + "GRADLE_OPTS", + "ANT_OPTS" ] static let blockedOverrideKeys: Set = [ "HOME", + "GRADLE_USER_HOME", "ZDOTDIR", "GIT_SSH_COMMAND", "GIT_SSH", diff --git a/src/infra/host-env-security-policy.json b/src/infra/host-env-security-policy.json index bf99f458e58..785b8e37049 100644 --- a/src/infra/host-env-security-policy.json +++ b/src/infra/host-env-security-policy.json @@ -22,10 +22,17 @@ "_JAVA_OPTIONS", "JDK_JAVA_OPTIONS", "PYTHONBREAKPOINT", - "DOTNET_STARTUP_HOOKS" + "DOTNET_STARTUP_HOOKS", + "DOTNET_ADDITIONAL_DEPS", + "GLIBC_TUNABLES", + "MAVEN_OPTS", + "SBT_OPTS", + "GRADLE_OPTS", + "ANT_OPTS" ], "blockedOverrideKeys": [ "HOME", + "GRADLE_USER_HOME", "ZDOTDIR", "GIT_SSH_COMMAND", "GIT_SSH", diff --git a/src/infra/host-env-security.test.ts b/src/infra/host-env-security.test.ts index fe194eabc28..cd3edb3e06b 100644 --- a/src/infra/host-env-security.test.ts +++ b/src/infra/host-env-security.test.ts @@ -58,8 +58,21 @@ describe("isDangerousHostEnvVarName", () => { expect(isDangerousHostEnvVarName("pythonbreakpoint")).toBe(true); expect(isDangerousHostEnvVarName("DOTNET_STARTUP_HOOKS")).toBe(true); expect(isDangerousHostEnvVarName("dotnet_startup_hooks")).toBe(true); + expect(isDangerousHostEnvVarName("DOTNET_ADDITIONAL_DEPS")).toBe(true); + expect(isDangerousHostEnvVarName("dotnet_additional_deps")).toBe(true); + expect(isDangerousHostEnvVarName("GLIBC_TUNABLES")).toBe(true); + expect(isDangerousHostEnvVarName("glibc_tunables")).toBe(true); + expect(isDangerousHostEnvVarName("MAVEN_OPTS")).toBe(true); + expect(isDangerousHostEnvVarName("maven_opts")).toBe(true); + expect(isDangerousHostEnvVarName("SBT_OPTS")).toBe(true); + expect(isDangerousHostEnvVarName("sbt_opts")).toBe(true); + expect(isDangerousHostEnvVarName("GRADLE_OPTS")).toBe(true); + expect(isDangerousHostEnvVarName("gradle_opts")).toBe(true); + expect(isDangerousHostEnvVarName("ANT_OPTS")).toBe(true); + expect(isDangerousHostEnvVarName("ant_opts")).toBe(true); expect(isDangerousHostEnvVarName("PATH")).toBe(false); expect(isDangerousHostEnvVarName("FOO")).toBe(false); + expect(isDangerousHostEnvVarName("GRADLE_USER_HOME")).toBe(false); }); }); @@ -197,6 +210,8 @@ describe("isDangerousHostEnvOverrideVarName", () => { expect(isDangerousHostEnvOverrideVarName("editor")).toBe(true); expect(isDangerousHostEnvOverrideVarName("NPM_CONFIG_USERCONFIG")).toBe(true); expect(isDangerousHostEnvOverrideVarName("git_config_global")).toBe(true); + expect(isDangerousHostEnvOverrideVarName("GRADLE_USER_HOME")).toBe(true); + expect(isDangerousHostEnvOverrideVarName("gradle_user_home")).toBe(true); expect(isDangerousHostEnvOverrideVarName("BASH_ENV")).toBe(false); expect(isDangerousHostEnvOverrideVarName("FOO")).toBe(false); }); From d41c9ad4cb71352b219de2adab0dd59e1caa0ffd Mon Sep 17 00:00:00 2001 From: Onur Solmaz <2453968+osolmaz@users.noreply.github.com> Date: Wed, 18 Mar 2026 13:44:23 +0100 Subject: [PATCH 46/55] Release: add plugin npm publish workflow (#47678) * Release: add plugin npm publish workflow * Release: make plugin publish scope explicit --- .github/workflows/plugin-npm-release.yml | 214 ++++++++++++ extensions/bluebubbles/package.json | 3 + extensions/diagnostics-otel/package.json | 5 +- extensions/discord/package.json | 5 +- extensions/feishu/package.json | 3 + extensions/lobster/package.json | 5 +- extensions/matrix/package.json | 3 + extensions/msteams/package.json | 3 + extensions/nextcloud-talk/package.json | 3 + extensions/nostr/package.json | 3 + extensions/voice-call/package.json | 5 +- extensions/zalo/package.json | 3 + extensions/zalouser/package.json | 3 + package.json | 2 + scripts/lib/plugin-npm-release.ts | 394 +++++++++++++++++++++++ scripts/plugin-npm-publish.sh | 45 +++ scripts/plugin-npm-release-check.ts | 47 +++ scripts/plugin-npm-release-plan.ts | 18 ++ scripts/release-check.ts | 58 ---- test/plugin-npm-release.test.ts | 217 +++++++++++++ 20 files changed, 977 insertions(+), 62 deletions(-) create mode 100644 .github/workflows/plugin-npm-release.yml create mode 100644 scripts/lib/plugin-npm-release.ts create mode 100644 scripts/plugin-npm-publish.sh create mode 100644 scripts/plugin-npm-release-check.ts create mode 100644 scripts/plugin-npm-release-plan.ts create mode 100644 test/plugin-npm-release.test.ts diff --git a/.github/workflows/plugin-npm-release.yml b/.github/workflows/plugin-npm-release.yml new file mode 100644 index 00000000000..3507a0b68a1 --- /dev/null +++ b/.github/workflows/plugin-npm-release.yml @@ -0,0 +1,214 @@ +name: Plugin NPM Release + +on: + push: + branches: + - main + paths: + - ".github/workflows/plugin-npm-release.yml" + - "extensions/**" + - "package.json" + - "scripts/lib/plugin-npm-release.ts" + - "scripts/plugin-npm-publish.sh" + - "scripts/plugin-npm-release-check.ts" + - "scripts/plugin-npm-release-plan.ts" + workflow_dispatch: + inputs: + publish_scope: + description: Publish the selected plugins or all publishable plugins from the ref + required: true + default: selected + type: choice + options: + - selected + - all-publishable + ref: + description: Commit SHA on main to publish from (copy from the preview run) + required: true + type: string + plugins: + description: Comma-separated plugin package names to publish when publish_scope=selected + required: false + type: string + +concurrency: + group: plugin-npm-release-${{ github.event_name == 'workflow_dispatch' && inputs.ref || github.sha }} + cancel-in-progress: false + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" + NODE_VERSION: "24.x" + PNPM_VERSION: "10.23.0" + +jobs: + preview_plugins_npm: + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + ref_sha: ${{ steps.ref.outputs.sha }} + has_candidates: ${{ steps.plan.outputs.has_candidates }} + candidate_count: ${{ steps.plan.outputs.candidate_count }} + matrix: ${{ steps.plan.outputs.matrix }} + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + ref: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || github.sha }} + fetch-depth: 0 + + - name: Setup Node environment + uses: ./.github/actions/setup-node-env + with: + node-version: ${{ env.NODE_VERSION }} + pnpm-version: ${{ env.PNPM_VERSION }} + install-bun: "false" + use-sticky-disk: "false" + + - name: Resolve checked-out ref + id: ref + run: echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + + - name: Validate ref is on main + run: | + set -euo pipefail + git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main + git merge-base --is-ancestor HEAD origin/main + + - name: Validate publishable plugin metadata + env: + PUBLISH_SCOPE: ${{ github.event_name == 'workflow_dispatch' && inputs.publish_scope || '' }} + RELEASE_PLUGINS: ${{ github.event_name == 'workflow_dispatch' && inputs.plugins || '' }} + BASE_REF: ${{ github.event_name != 'workflow_dispatch' && github.event.before || '' }} + HEAD_REF: ${{ steps.ref.outputs.sha }} + run: | + set -euo pipefail + if [[ -n "${PUBLISH_SCOPE}" ]]; then + release_args=(--selection-mode "${PUBLISH_SCOPE}") + if [[ -n "${RELEASE_PLUGINS}" ]]; then + release_args+=(--plugins "${RELEASE_PLUGINS}") + fi + pnpm release:plugins:npm:check -- "${release_args[@]}" + elif [[ -n "${BASE_REF}" ]]; then + pnpm release:plugins:npm:check -- --base-ref "${BASE_REF}" --head-ref "${HEAD_REF}" + else + pnpm release:plugins:npm:check + fi + + - name: Resolve plugin release plan + id: plan + env: + PUBLISH_SCOPE: ${{ github.event_name == 'workflow_dispatch' && inputs.publish_scope || '' }} + RELEASE_PLUGINS: ${{ github.event_name == 'workflow_dispatch' && inputs.plugins || '' }} + BASE_REF: ${{ github.event_name != 'workflow_dispatch' && github.event.before || '' }} + HEAD_REF: ${{ steps.ref.outputs.sha }} + run: | + set -euo pipefail + mkdir -p .local + if [[ -n "${PUBLISH_SCOPE}" ]]; then + plan_args=(--selection-mode "${PUBLISH_SCOPE}") + if [[ -n "${RELEASE_PLUGINS}" ]]; then + plan_args+=(--plugins "${RELEASE_PLUGINS}") + fi + node --import tsx scripts/plugin-npm-release-plan.ts "${plan_args[@]}" > .local/plugin-npm-release-plan.json + elif [[ -n "${BASE_REF}" ]]; then + node --import tsx scripts/plugin-npm-release-plan.ts --base-ref "${BASE_REF}" --head-ref "${HEAD_REF}" > .local/plugin-npm-release-plan.json + else + node --import tsx scripts/plugin-npm-release-plan.ts > .local/plugin-npm-release-plan.json + fi + + cat .local/plugin-npm-release-plan.json + + candidate_count="$(jq -r '.candidates | length' .local/plugin-npm-release-plan.json)" + has_candidates="false" + if [[ "${candidate_count}" != "0" ]]; then + has_candidates="true" + fi + matrix_json="$(jq -c '.candidates' .local/plugin-npm-release-plan.json)" + + { + echo "candidate_count=${candidate_count}" + echo "has_candidates=${has_candidates}" + echo "matrix=${matrix_json}" + } >> "$GITHUB_OUTPUT" + + echo "Plugin release candidates:" + jq -r '.candidates[]? | "- \(.packageName)@\(.version) [\(.publishTag)] from \(.packageDir)"' .local/plugin-npm-release-plan.json + + echo "Already published / skipped:" + jq -r '.skippedPublished[]? | "- \(.packageName)@\(.version)"' .local/plugin-npm-release-plan.json + + preview_plugin_pack: + needs: preview_plugins_npm + if: needs.preview_plugins_npm.outputs.has_candidates == 'true' + runs-on: ubuntu-latest + permissions: + contents: read + strategy: + fail-fast: false + matrix: + plugin: ${{ fromJson(needs.preview_plugins_npm.outputs.matrix) }} + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + ref: ${{ needs.preview_plugins_npm.outputs.ref_sha }} + fetch-depth: 1 + + - name: Setup Node environment + uses: ./.github/actions/setup-node-env + with: + node-version: ${{ env.NODE_VERSION }} + pnpm-version: ${{ env.PNPM_VERSION }} + install-bun: "false" + use-sticky-disk: "false" + install-deps: "false" + + - name: Preview publish command + run: bash scripts/plugin-npm-publish.sh --dry-run "${{ matrix.plugin.packageDir }}" + + - name: Preview npm pack contents + working-directory: ${{ matrix.plugin.packageDir }} + run: npm pack --dry-run --json --ignore-scripts + + publish_plugins_npm: + needs: [preview_plugins_npm, preview_plugin_pack] + if: github.event_name == 'workflow_dispatch' && needs.preview_plugins_npm.outputs.has_candidates == 'true' + runs-on: ubuntu-latest + environment: npm-release + permissions: + contents: read + id-token: write + strategy: + fail-fast: false + matrix: + plugin: ${{ fromJson(needs.preview_plugins_npm.outputs.matrix) }} + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + ref: ${{ needs.preview_plugins_npm.outputs.ref_sha }} + fetch-depth: 1 + + - name: Setup Node environment + uses: ./.github/actions/setup-node-env + with: + node-version: ${{ env.NODE_VERSION }} + pnpm-version: ${{ env.PNPM_VERSION }} + install-bun: "false" + use-sticky-disk: "false" + install-deps: "false" + + - name: Ensure version is not already published + env: + PACKAGE_NAME: ${{ matrix.plugin.packageName }} + PACKAGE_VERSION: ${{ matrix.plugin.version }} + run: | + set -euo pipefail + if npm view "${PACKAGE_NAME}@${PACKAGE_VERSION}" version >/dev/null 2>&1; then + echo "${PACKAGE_NAME}@${PACKAGE_VERSION} is already published on npm." + exit 1 + fi + + - name: Publish + run: bash scripts/plugin-npm-publish.sh --publish "${{ matrix.plugin.packageDir }}" diff --git a/extensions/bluebubbles/package.json b/extensions/bluebubbles/package.json index 2426958d346..d89701af44b 100644 --- a/extensions/bluebubbles/package.json +++ b/extensions/bluebubbles/package.json @@ -32,6 +32,9 @@ "npmSpec": "@openclaw/bluebubbles", "localPath": "extensions/bluebubbles", "defaultChoice": "npm" + }, + "release": { + "publishToNpm": true } } } diff --git a/extensions/diagnostics-otel/package.json b/extensions/diagnostics-otel/package.json index b51ead550ef..2e31d211360 100644 --- a/extensions/diagnostics-otel/package.json +++ b/extensions/diagnostics-otel/package.json @@ -19,6 +19,9 @@ "openclaw": { "extensions": [ "./index.ts" - ] + ], + "release": { + "publishToNpm": true + } } } diff --git a/extensions/discord/package.json b/extensions/discord/package.json index 43e00315f28..82770355b9e 100644 --- a/extensions/discord/package.json +++ b/extensions/discord/package.json @@ -7,6 +7,9 @@ "extensions": [ "./index.ts" ], - "setupEntry": "./setup-entry.ts" + "setupEntry": "./setup-entry.ts", + "release": { + "publishToNpm": true + } } } diff --git a/extensions/feishu/package.json b/extensions/feishu/package.json index d5dfe64f369..1182828f60d 100644 --- a/extensions/feishu/package.json +++ b/extensions/feishu/package.json @@ -31,6 +31,9 @@ "npmSpec": "@openclaw/feishu", "localPath": "extensions/feishu", "defaultChoice": "npm" + }, + "release": { + "publishToNpm": true } } } diff --git a/extensions/lobster/package.json b/extensions/lobster/package.json index 915e5d5c3de..9280c21b51e 100644 --- a/extensions/lobster/package.json +++ b/extensions/lobster/package.json @@ -9,6 +9,9 @@ "openclaw": { "extensions": [ "./index.ts" - ] + ], + "release": { + "publishToNpm": true + } } } diff --git a/extensions/matrix/package.json b/extensions/matrix/package.json index 8ea72d940fd..ea7c5ec5141 100644 --- a/extensions/matrix/package.json +++ b/extensions/matrix/package.json @@ -31,6 +31,9 @@ "localPath": "extensions/matrix", "defaultChoice": "npm" }, + "release": { + "publishToNpm": true + }, "releaseChecks": { "rootDependencyMirrorAllowlist": [ "@matrix-org/matrix-sdk-crypto-nodejs", diff --git a/extensions/msteams/package.json b/extensions/msteams/package.json index eb02c9cee13..6365de0b725 100644 --- a/extensions/msteams/package.json +++ b/extensions/msteams/package.json @@ -29,6 +29,9 @@ "localPath": "extensions/msteams", "defaultChoice": "npm" }, + "release": { + "publishToNpm": true + }, "releaseChecks": { "rootDependencyMirrorAllowlist": [ "@microsoft/agents-hosting" diff --git a/extensions/nextcloud-talk/package.json b/extensions/nextcloud-talk/package.json index d594a67b96f..83010363da2 100644 --- a/extensions/nextcloud-talk/package.json +++ b/extensions/nextcloud-talk/package.json @@ -29,6 +29,9 @@ "npmSpec": "@openclaw/nextcloud-talk", "localPath": "extensions/nextcloud-talk", "defaultChoice": "npm" + }, + "release": { + "publishToNpm": true } } } diff --git a/extensions/nostr/package.json b/extensions/nostr/package.json index 991bd54f3d4..24b50cf825d 100644 --- a/extensions/nostr/package.json +++ b/extensions/nostr/package.json @@ -27,6 +27,9 @@ "localPath": "extensions/nostr", "defaultChoice": "npm" }, + "release": { + "publishToNpm": true + }, "releaseChecks": { "rootDependencyMirrorAllowlist": [ "nostr-tools" diff --git a/extensions/voice-call/package.json b/extensions/voice-call/package.json index 3c65532f9c9..eac88a77d10 100644 --- a/extensions/voice-call/package.json +++ b/extensions/voice-call/package.json @@ -12,6 +12,9 @@ "openclaw": { "extensions": [ "./index.ts" - ] + ], + "release": { + "publishToNpm": true + } } } diff --git a/extensions/zalo/package.json b/extensions/zalo/package.json index cca065cb387..1dd30038cea 100644 --- a/extensions/zalo/package.json +++ b/extensions/zalo/package.json @@ -29,6 +29,9 @@ "npmSpec": "@openclaw/zalo", "localPath": "extensions/zalo", "defaultChoice": "npm" + }, + "release": { + "publishToNpm": true } } } diff --git a/extensions/zalouser/package.json b/extensions/zalouser/package.json index 322053904fd..610744e7a8d 100644 --- a/extensions/zalouser/package.json +++ b/extensions/zalouser/package.json @@ -31,6 +31,9 @@ "localPath": "extensions/zalouser", "defaultChoice": "npm" }, + "release": { + "publishToNpm": true + }, "releaseChecks": { "rootDependencyMirrorAllowlist": [ "zca-js" diff --git a/package.json b/package.json index 5087d9bdf72..c739c024c27 100644 --- a/package.json +++ b/package.json @@ -593,6 +593,8 @@ "protocol:gen:swift": "node --import tsx scripts/protocol-gen-swift.ts", "release:check": "pnpm config:docs:check && node --import tsx scripts/release-check.ts", "release:openclaw:npm:check": "node --import tsx scripts/openclaw-npm-release-check.ts", + "release:plugins:npm:check": "node --import tsx scripts/plugin-npm-release-check.ts", + "release:plugins:npm:plan": "node --import tsx scripts/plugin-npm-release-plan.ts", "start": "node scripts/run-node.mjs", "test": "node scripts/test-parallel.mjs", "test:all": "pnpm lint && pnpm build && pnpm test && pnpm test:e2e && pnpm test:live && pnpm test:docker:all", diff --git a/scripts/lib/plugin-npm-release.ts b/scripts/lib/plugin-npm-release.ts new file mode 100644 index 00000000000..34f98e86f2f --- /dev/null +++ b/scripts/lib/plugin-npm-release.ts @@ -0,0 +1,394 @@ +import { execFileSync } from "node:child_process"; +import { mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { parseReleaseVersion } from "../openclaw-npm-release-check.ts"; + +export type PluginPackageJson = { + name?: string; + version?: string; + private?: boolean; + openclaw?: { + extensions?: string[]; + install?: { + npmSpec?: string; + }; + release?: { + publishToNpm?: boolean; + }; + }; +}; + +export type PublishablePluginPackage = { + extensionId: string; + packageDir: string; + packageName: string; + version: string; + channel: "stable" | "beta"; + publishTag: "latest" | "beta"; + installNpmSpec?: string; +}; + +export type PluginReleasePlanItem = PublishablePluginPackage & { + alreadyPublished: boolean; +}; + +export type PluginReleasePlan = { + all: PluginReleasePlanItem[]; + candidates: PluginReleasePlanItem[]; + skippedPublished: PluginReleasePlanItem[]; +}; + +export type PluginReleaseSelectionMode = "selected" | "all-publishable"; + +export type GitRangeSelection = { + baseRef: string; + headRef: string; +}; + +export type ParsedPluginReleaseArgs = { + selection: string[]; + selectionMode?: PluginReleaseSelectionMode; + pluginsFlagProvided: boolean; + baseRef?: string; + headRef?: string; +}; + +type PublishablePluginPackageCandidate = { + extensionId: string; + packageDir: string; + packageJson: PluginPackageJson; +}; + +function readPluginPackageJson(path: string): PluginPackageJson { + return JSON.parse(readFileSync(path, "utf8")) as PluginPackageJson; +} + +export function parsePluginReleaseSelection(value: string | undefined): string[] { + if (!value?.trim()) { + return []; + } + + return [ + ...new Set( + value + .split(/[,\s]+/) + .map((item) => item.trim()) + .filter(Boolean), + ), + ].toSorted(); +} + +export function parsePluginReleaseSelectionMode( + value: string | undefined, +): PluginReleaseSelectionMode { + if (value === "selected" || value === "all-publishable") { + return value; + } + + throw new Error( + `Unknown selection mode: ${value ?? ""}. Expected "selected" or "all-publishable".`, + ); +} + +export function parsePluginReleaseArgs(argv: string[]): ParsedPluginReleaseArgs { + let selection: string[] = []; + let selectionMode: PluginReleaseSelectionMode | undefined; + let pluginsFlagProvided = false; + let baseRef: string | undefined; + let headRef: string | undefined; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === "--") { + continue; + } + if (arg === "--plugins") { + selection = parsePluginReleaseSelection(argv[index + 1]); + pluginsFlagProvided = true; + index += 1; + continue; + } + if (arg === "--selection-mode") { + selectionMode = parsePluginReleaseSelectionMode(argv[index + 1]); + index += 1; + continue; + } + if (arg === "--base-ref") { + baseRef = argv[index + 1]; + index += 1; + continue; + } + if (arg === "--head-ref") { + headRef = argv[index + 1]; + index += 1; + continue; + } + throw new Error(`Unknown argument: ${arg}`); + } + + if (pluginsFlagProvided && selection.length === 0) { + throw new Error("`--plugins` must include at least one package name."); + } + if (selectionMode === "selected" && !pluginsFlagProvided) { + throw new Error("`--selection-mode selected` requires `--plugins`."); + } + if (selectionMode === "all-publishable" && pluginsFlagProvided) { + throw new Error("`--selection-mode all-publishable` must not be combined with `--plugins`."); + } + if (selection.length > 0 && (baseRef || headRef)) { + throw new Error("Use either --plugins or --base-ref/--head-ref, not both."); + } + if (selectionMode && (baseRef || headRef)) { + throw new Error("Use either --selection-mode or --base-ref/--head-ref, not both."); + } + if ((baseRef && !headRef) || (!baseRef && headRef)) { + throw new Error("Both --base-ref and --head-ref are required together."); + } + + return { selection, selectionMode, pluginsFlagProvided, baseRef, headRef }; +} + +export function collectPublishablePluginPackageErrors( + candidate: PublishablePluginPackageCandidate, +): string[] { + const { packageJson } = candidate; + const errors: string[] = []; + const packageName = packageJson.name?.trim() ?? ""; + const packageVersion = packageJson.version?.trim() ?? ""; + const extensions = packageJson.openclaw?.extensions ?? []; + + if (!packageName.startsWith("@openclaw/")) { + errors.push( + `package name must start with "@openclaw/"; found "${packageName || ""}".`, + ); + } + if (packageJson.private === true) { + errors.push("package.json private must not be true."); + } + if (!packageVersion) { + errors.push("package.json version must be non-empty."); + } else if (parseReleaseVersion(packageVersion) === null) { + errors.push( + `package.json version must match YYYY.M.D or YYYY.M.D-beta.N; found "${packageVersion}".`, + ); + } + if (!Array.isArray(extensions) || extensions.length === 0) { + errors.push("openclaw.extensions must contain at least one entry."); + } + if (extensions.some((entry) => typeof entry !== "string" || !entry.trim())) { + errors.push("openclaw.extensions must contain only non-empty strings."); + } + + return errors; +} + +export function collectPublishablePluginPackages( + rootDir = resolve("."), +): PublishablePluginPackage[] { + const extensionsDir = join(rootDir, "extensions"); + const dirs = readdirSync(extensionsDir, { withFileTypes: true }).filter((entry) => + entry.isDirectory(), + ); + + const publishable: PublishablePluginPackage[] = []; + const validationErrors: string[] = []; + + for (const dir of dirs) { + const packageDir = join("extensions", dir.name); + const absolutePackageDir = join(extensionsDir, dir.name); + const packageJsonPath = join(absolutePackageDir, "package.json"); + let packageJson: PluginPackageJson; + try { + packageJson = readPluginPackageJson(packageJsonPath); + } catch { + continue; + } + + if (packageJson.openclaw?.release?.publishToNpm !== true) { + continue; + } + + const candidate = { + extensionId: dir.name, + packageDir, + packageJson, + } satisfies PublishablePluginPackageCandidate; + const errors = collectPublishablePluginPackageErrors(candidate); + if (errors.length > 0) { + validationErrors.push(...errors.map((error) => `${dir.name}: ${error}`)); + continue; + } + + const version = packageJson.version!.trim(); + const parsedVersion = parseReleaseVersion(version); + if (parsedVersion === null) { + validationErrors.push( + `${dir.name}: package.json version must match YYYY.M.D or YYYY.M.D-beta.N; found "${version}".`, + ); + continue; + } + + publishable.push({ + extensionId: dir.name, + packageDir, + packageName: packageJson.name!.trim(), + version, + channel: parsedVersion.channel, + publishTag: parsedVersion.channel === "beta" ? "beta" : "latest", + installNpmSpec: packageJson.openclaw?.install?.npmSpec?.trim() || undefined, + }); + } + + if (validationErrors.length > 0) { + throw new Error( + `Publishable plugin metadata validation failed:\n${validationErrors.map((error) => `- ${error}`).join("\n")}`, + ); + } + + return publishable.toSorted((left, right) => left.packageName.localeCompare(right.packageName)); +} + +export function resolveSelectedPublishablePluginPackages(params: { + plugins: PublishablePluginPackage[]; + selection: string[]; +}): PublishablePluginPackage[] { + if (params.selection.length === 0) { + return params.plugins; + } + + const byName = new Map(params.plugins.map((plugin) => [plugin.packageName, plugin])); + const selected: PublishablePluginPackage[] = []; + const missing: string[] = []; + + for (const packageName of params.selection) { + const plugin = byName.get(packageName); + if (!plugin) { + missing.push(packageName); + continue; + } + selected.push(plugin); + } + + if (missing.length > 0) { + throw new Error(`Unknown or non-publishable plugin package selection: ${missing.join(", ")}.`); + } + + return selected; +} + +export function collectChangedExtensionIdsFromPaths(paths: readonly string[]): string[] { + const extensionIds = new Set(); + + for (const path of paths) { + const normalized = path.trim().replaceAll("\\", "/"); + const match = /^extensions\/([^/]+)\//.exec(normalized); + if (match?.[1]) { + extensionIds.add(match[1]); + } + } + + return [...extensionIds].toSorted(); +} + +function isNullGitRef(ref: string | undefined): boolean { + return !ref || /^0+$/.test(ref); +} + +export function collectChangedExtensionIdsFromGitRange(params: { + rootDir?: string; + gitRange: GitRangeSelection; +}): string[] { + const rootDir = params.rootDir ?? resolve("."); + const { baseRef, headRef } = params.gitRange; + + if (isNullGitRef(baseRef) || isNullGitRef(headRef)) { + return []; + } + + const changedPaths = execFileSync( + "git", + ["diff", "--name-only", "--diff-filter=ACMR", baseRef, headRef, "--", "extensions"], + { + cwd: rootDir, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }, + ) + .split("\n") + .map((line) => line.trim()) + .filter(Boolean); + + return collectChangedExtensionIdsFromPaths(changedPaths); +} + +export function resolveChangedPublishablePluginPackages(params: { + plugins: PublishablePluginPackage[]; + changedExtensionIds: readonly string[]; +}): PublishablePluginPackage[] { + if (params.changedExtensionIds.length === 0) { + return []; + } + + const changed = new Set(params.changedExtensionIds); + return params.plugins.filter((plugin) => changed.has(plugin.extensionId)); +} + +export function isPluginVersionPublished(packageName: string, version: string): boolean { + const tempDir = mkdtempSync(join(tmpdir(), "openclaw-plugin-npm-view-")); + const userconfigPath = join(tempDir, "npmrc"); + writeFileSync(userconfigPath, ""); + + try { + execFileSync( + "npm", + ["view", `${packageName}@${version}`, "version", "--userconfig", userconfigPath], + { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }, + ); + return true; + } catch { + return false; + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } +} + +export function collectPluginReleasePlan(params?: { + rootDir?: string; + selection?: string[]; + selectionMode?: PluginReleaseSelectionMode; + gitRange?: GitRangeSelection; +}): PluginReleasePlan { + const allPublishable = collectPublishablePluginPackages(params?.rootDir); + const selectedPublishable = + params?.selectionMode === "all-publishable" + ? allPublishable + : params?.selection && params.selection.length > 0 + ? resolveSelectedPublishablePluginPackages({ + plugins: allPublishable, + selection: params.selection, + }) + : params?.gitRange + ? resolveChangedPublishablePluginPackages({ + plugins: allPublishable, + changedExtensionIds: collectChangedExtensionIdsFromGitRange({ + rootDir: params.rootDir, + gitRange: params.gitRange, + }), + }) + : allPublishable; + + const all = selectedPublishable.map((plugin) => ({ + ...plugin, + alreadyPublished: isPluginVersionPublished(plugin.packageName, plugin.version), + })); + + return { + all, + candidates: all.filter((plugin) => !plugin.alreadyPublished), + skippedPublished: all.filter((plugin) => plugin.alreadyPublished), + }; +} diff --git a/scripts/plugin-npm-publish.sh b/scripts/plugin-npm-publish.sh new file mode 100644 index 00000000000..2ff1af3f037 --- /dev/null +++ b/scripts/plugin-npm-publish.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash + +set -euo pipefail + +mode="${1:-}" +package_dir="${2:-}" + +if [[ "${mode}" != "--dry-run" && "${mode}" != "--publish" ]]; then + echo "usage: bash scripts/plugin-npm-publish.sh [--dry-run|--publish] " >&2 + exit 2 +fi + +if [[ -z "${package_dir}" ]]; then + echo "missing package dir" >&2 + exit 2 +fi + +package_name="$(node -e 'const pkg = require(require("node:path").resolve(process.argv[1], "package.json")); console.log(pkg.name)' "${package_dir}")" +package_version="$(node -e 'const pkg = require(require("node:path").resolve(process.argv[1], "package.json")); console.log(pkg.version)' "${package_dir}")" +publish_cmd=(npm publish --access public --provenance) +release_channel="stable" + +if [[ "${package_version}" == *-beta.* ]]; then + publish_cmd=(npm publish --access public --tag beta --provenance) + release_channel="beta" +fi + +echo "Resolved package dir: ${package_dir}" +echo "Resolved package name: ${package_name}" +echo "Resolved package version: ${package_version}" +echo "Resolved release channel: ${release_channel}" +echo "Publish auth: GitHub OIDC trusted publishing" + +printf 'Publish command:' +printf ' %q' "${publish_cmd[@]}" +printf '\n' + +if [[ "${mode}" == "--dry-run" ]]; then + exit 0 +fi + +( + cd "${package_dir}" + "${publish_cmd[@]}" +) diff --git a/scripts/plugin-npm-release-check.ts b/scripts/plugin-npm-release-check.ts new file mode 100644 index 00000000000..f1af5b75509 --- /dev/null +++ b/scripts/plugin-npm-release-check.ts @@ -0,0 +1,47 @@ +#!/usr/bin/env -S node --import tsx + +import { pathToFileURL } from "node:url"; +import { + collectChangedExtensionIdsFromGitRange, + collectPublishablePluginPackages, + parsePluginReleaseArgs, + resolveChangedPublishablePluginPackages, + resolveSelectedPublishablePluginPackages, +} from "./lib/plugin-npm-release.ts"; + +export function runPluginNpmReleaseCheck(argv: string[]) { + const { selection, selectionMode, baseRef, headRef } = parsePluginReleaseArgs(argv); + const publishable = collectPublishablePluginPackages(); + const selected = + selectionMode === "all-publishable" + ? publishable + : selection.length > 0 + ? resolveSelectedPublishablePluginPackages({ + plugins: publishable, + selection, + }) + : baseRef && headRef + ? resolveChangedPublishablePluginPackages({ + plugins: publishable, + changedExtensionIds: collectChangedExtensionIdsFromGitRange({ + gitRange: { baseRef, headRef }, + }), + }) + : publishable; + + console.log("plugin-npm-release-check: publishable plugin metadata looks OK."); + if (baseRef && headRef && selected.length === 0) { + console.log( + ` - no publishable plugin package changes detected between ${baseRef} and ${headRef}`, + ); + } + for (const plugin of selected) { + console.log( + ` - ${plugin.packageName}@${plugin.version} (${plugin.channel}, ${plugin.extensionId})`, + ); + } +} + +if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) { + runPluginNpmReleaseCheck(process.argv.slice(2)); +} diff --git a/scripts/plugin-npm-release-plan.ts b/scripts/plugin-npm-release-plan.ts new file mode 100644 index 00000000000..e18f1dc131e --- /dev/null +++ b/scripts/plugin-npm-release-plan.ts @@ -0,0 +1,18 @@ +#!/usr/bin/env -S node --import tsx + +import { pathToFileURL } from "node:url"; +import { collectPluginReleasePlan, parsePluginReleaseArgs } from "./lib/plugin-npm-release.ts"; + +export function collectPluginNpmReleasePlan(argv: string[]) { + const { selection, selectionMode, baseRef, headRef } = parsePluginReleaseArgs(argv); + return collectPluginReleasePlan({ + selection, + selectionMode, + gitRange: baseRef && headRef ? { baseRef, headRef } : undefined, + }); +} + +if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) { + const plan = collectPluginNpmReleasePlan(process.argv.slice(2)); + console.log(JSON.stringify(plan, null, 2)); +} diff --git a/scripts/release-check.ts b/scripts/release-check.ts index fba6d197357..8f971fef119 100755 --- a/scripts/release-check.ts +++ b/scripts/release-check.ts @@ -34,15 +34,6 @@ const appcastPath = resolve("appcast.xml"); const laneBuildMin = 1_000_000_000; const laneFloorAdoptionDateKey = 20260227; -function normalizePluginSyncVersion(version: string): string { - const normalized = version.trim().replace(/^v/, ""); - const base = /^([0-9]+\.[0-9]+\.[0-9]+)/.exec(normalized)?.[1]; - if (base) { - return base; - } - return normalized.replace(/[-+].*$/, ""); -} - export function collectBundledExtensionRootDependencyGapErrors(params: { rootPackage: PackageJson; extensions: BundledExtension[]; @@ -190,54 +181,6 @@ export function collectPackUnpackedSizeErrors(results: Iterable): st return errors; } -function checkPluginVersions() { - const rootPackagePath = resolve("package.json"); - const rootPackage = JSON.parse(readFileSync(rootPackagePath, "utf8")) as PackageJson; - const targetVersion = rootPackage.version; - const targetBaseVersion = targetVersion ? normalizePluginSyncVersion(targetVersion) : null; - - if (!targetVersion || !targetBaseVersion) { - console.error("release-check: root package.json missing version."); - process.exit(1); - } - - const extensionsDir = resolve("extensions"); - const entries = readdirSync(extensionsDir, { withFileTypes: true }).filter((entry) => - entry.isDirectory(), - ); - - const mismatches: string[] = []; - - for (const entry of entries) { - const packagePath = join(extensionsDir, entry.name, "package.json"); - let pkg: PackageJson; - try { - pkg = JSON.parse(readFileSync(packagePath, "utf8")) as PackageJson; - } catch { - continue; - } - - if (!pkg.name || !pkg.version) { - continue; - } - - if (normalizePluginSyncVersion(pkg.version) !== targetBaseVersion) { - mismatches.push(`${pkg.name} (${pkg.version})`); - } - } - - if (mismatches.length > 0) { - console.error( - `release-check: plugin versions must match release base ${targetBaseVersion} (root ${targetVersion}):`, - ); - for (const item of mismatches) { - console.error(` - ${item}`); - } - console.error("release-check: run `pnpm plugins:sync` to align plugin versions."); - process.exit(1); - } -} - function extractTag(item: string, tag: string): string | null { const escapedTag = tag.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); const regex = new RegExp(`<${escapedTag}>([^<]+)`); @@ -393,7 +336,6 @@ async function checkPluginSdkExports() { } async function main() { - checkPluginVersions(); checkAppcastSparkleVersions(); await checkPluginSdkExports(); checkBundledExtensionRootDependencyMirrors(); diff --git a/test/plugin-npm-release.test.ts b/test/plugin-npm-release.test.ts new file mode 100644 index 00000000000..383d97b9ab9 --- /dev/null +++ b/test/plugin-npm-release.test.ts @@ -0,0 +1,217 @@ +import { describe, expect, it } from "vitest"; +import { + collectChangedExtensionIdsFromPaths, + collectPublishablePluginPackageErrors, + parsePluginReleaseArgs, + parsePluginReleaseSelection, + parsePluginReleaseSelectionMode, + resolveChangedPublishablePluginPackages, + resolveSelectedPublishablePluginPackages, + type PublishablePluginPackage, +} from "../scripts/lib/plugin-npm-release.ts"; + +describe("parsePluginReleaseSelection", () => { + it("returns an empty list for blank input", () => { + expect(parsePluginReleaseSelection("")).toEqual([]); + expect(parsePluginReleaseSelection(" ")).toEqual([]); + expect(parsePluginReleaseSelection(undefined)).toEqual([]); + }); + + it("dedupes and sorts comma or whitespace separated package names", () => { + expect( + parsePluginReleaseSelection(" @openclaw/zalo, @openclaw/feishu @openclaw/zalo "), + ).toEqual(["@openclaw/feishu", "@openclaw/zalo"]); + }); +}); + +describe("parsePluginReleaseSelectionMode", () => { + it("accepts the supported explicit selection modes", () => { + expect(parsePluginReleaseSelectionMode("selected")).toBe("selected"); + expect(parsePluginReleaseSelectionMode("all-publishable")).toBe("all-publishable"); + }); + + it("rejects unsupported selection modes", () => { + expect(() => parsePluginReleaseSelectionMode("all")).toThrowError( + 'Unknown selection mode: all. Expected "selected" or "all-publishable".', + ); + }); +}); + +describe("parsePluginReleaseArgs", () => { + it("rejects blank explicit plugin selections", () => { + expect(() => parsePluginReleaseArgs(["--plugins", " "])).toThrowError( + "`--plugins` must include at least one package name.", + ); + }); + + it("requires plugin names for selected explicit publish mode", () => { + expect(() => parsePluginReleaseArgs(["--selection-mode", "selected"])).toThrowError( + "`--selection-mode selected` requires `--plugins`.", + ); + }); + + it("rejects plugin names when all-publishable mode is selected", () => { + expect(() => + parsePluginReleaseArgs([ + "--selection-mode", + "all-publishable", + "--plugins", + "@openclaw/zalo", + ]), + ).toThrowError("`--selection-mode all-publishable` must not be combined with `--plugins`."); + }); + + it("parses explicit all-publishable mode", () => { + expect(parsePluginReleaseArgs(["--selection-mode", "all-publishable"])).toMatchObject({ + selectionMode: "all-publishable", + selection: [], + pluginsFlagProvided: false, + }); + }); +}); + +describe("collectPublishablePluginPackageErrors", () => { + it("accepts a valid publishable plugin package candidate", () => { + expect( + collectPublishablePluginPackageErrors({ + extensionId: "zalo", + packageDir: "extensions/zalo", + packageJson: { + name: "@openclaw/zalo", + version: "2026.3.15", + openclaw: { + extensions: ["./index.ts"], + release: { + publishToNpm: true, + }, + }, + }, + }), + ).toEqual([]); + }); + + it("flags invalid publishable plugin metadata", () => { + expect( + collectPublishablePluginPackageErrors({ + extensionId: "broken", + packageDir: "extensions/broken", + packageJson: { + name: "broken", + version: "latest", + private: true, + openclaw: { + extensions: [""], + release: { + publishToNpm: true, + }, + }, + }, + }), + ).toEqual([ + 'package name must start with "@openclaw/"; found "broken".', + "package.json private must not be true.", + 'package.json version must match YYYY.M.D or YYYY.M.D-beta.N; found "latest".', + "openclaw.extensions must contain only non-empty strings.", + ]); + }); +}); + +describe("resolveSelectedPublishablePluginPackages", () => { + const publishablePlugins: PublishablePluginPackage[] = [ + { + extensionId: "feishu", + packageDir: "extensions/feishu", + packageName: "@openclaw/feishu", + version: "2026.3.15", + channel: "stable", + publishTag: "latest", + }, + { + extensionId: "zalo", + packageDir: "extensions/zalo", + packageName: "@openclaw/zalo", + version: "2026.3.15-beta.1", + channel: "beta", + publishTag: "beta", + }, + ]; + + it("returns all publishable plugins when no selection is provided", () => { + expect( + resolveSelectedPublishablePluginPackages({ + plugins: publishablePlugins, + selection: [], + }), + ).toEqual(publishablePlugins); + }); + + it("filters by selected publishable package names", () => { + expect( + resolveSelectedPublishablePluginPackages({ + plugins: publishablePlugins, + selection: ["@openclaw/zalo"], + }), + ).toEqual([publishablePlugins[1]]); + }); + + it("throws when the selection contains an unknown package name", () => { + expect(() => + resolveSelectedPublishablePluginPackages({ + plugins: publishablePlugins, + selection: ["@openclaw/missing"], + }), + ).toThrowError("Unknown or non-publishable plugin package selection: @openclaw/missing."); + }); +}); + +describe("collectChangedExtensionIdsFromPaths", () => { + it("extracts unique extension ids from changed extension paths", () => { + expect( + collectChangedExtensionIdsFromPaths([ + "extensions/zalo/index.ts", + "extensions/zalo/package.json", + "extensions/feishu/src/client.ts", + "docs/reference/RELEASING.md", + ]), + ).toEqual(["feishu", "zalo"]); + }); +}); + +describe("resolveChangedPublishablePluginPackages", () => { + const publishablePlugins: PublishablePluginPackage[] = [ + { + extensionId: "feishu", + packageDir: "extensions/feishu", + packageName: "@openclaw/feishu", + version: "2026.3.15", + channel: "stable", + publishTag: "latest", + }, + { + extensionId: "zalo", + packageDir: "extensions/zalo", + packageName: "@openclaw/zalo", + version: "2026.3.15-beta.1", + channel: "beta", + publishTag: "beta", + }, + ]; + + it("returns only changed publishable plugins", () => { + expect( + resolveChangedPublishablePluginPackages({ + plugins: publishablePlugins, + changedExtensionIds: ["zalo"], + }), + ).toEqual([publishablePlugins[1]]); + }); + + it("returns an empty list when no publishable plugins changed", () => { + expect( + resolveChangedPublishablePluginPackages({ + plugins: publishablePlugins, + changedExtensionIds: [], + }), + ).toEqual([]); + }); +}); From 4157bcd02450950388b383ca3672a9b67a36aa39 Mon Sep 17 00:00:00 2001 From: Tak Hoffman <781889+Takhoffman@users.noreply.github.com> Date: Wed, 18 Mar 2026 07:49:03 -0500 Subject: [PATCH 47/55] Build: fail on plugin SDK declaration errors --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index c739c024c27..f20aa3b7e3d 100644 --- a/package.json +++ b/package.json @@ -508,7 +508,7 @@ "android:test:integration": "OPENCLAW_LIVE_TEST=1 OPENCLAW_LIVE_ANDROID_NODE=1 vitest run --config vitest.live.config.ts src/gateway/android-node.capabilities.live.test.ts", "build": "pnpm canvas:a2ui:bundle && node scripts/tsdown-build.mjs && node scripts/runtime-postbuild.mjs && pnpm build:plugin-sdk:dts && node --import tsx scripts/write-plugin-sdk-entry-dts.ts && node --import tsx scripts/canvas-a2ui-copy.ts && node --import tsx scripts/copy-hook-metadata.ts && node --import tsx scripts/copy-export-html-templates.ts && node --import tsx scripts/write-build-info.ts && node --import tsx scripts/write-cli-startup-metadata.ts && node --import tsx scripts/write-cli-compat.ts", "build:docker": "node scripts/tsdown-build.mjs && node scripts/runtime-postbuild.mjs && pnpm build:plugin-sdk:dts && node --import tsx scripts/write-plugin-sdk-entry-dts.ts && node --import tsx scripts/canvas-a2ui-copy.ts && node --import tsx scripts/copy-hook-metadata.ts && node --import tsx scripts/copy-export-html-templates.ts && node --import tsx scripts/write-build-info.ts && node --import tsx scripts/write-cli-startup-metadata.ts && node --import tsx scripts/write-cli-compat.ts", - "build:plugin-sdk:dts": "tsc -p tsconfig.plugin-sdk.dts.json || true", + "build:plugin-sdk:dts": "tsc -p tsconfig.plugin-sdk.dts.json", "build:strict-smoke": "pnpm canvas:a2ui:bundle && node scripts/tsdown-build.mjs && node scripts/runtime-postbuild.mjs && pnpm build:plugin-sdk:dts", "canvas:a2ui:bundle": "bash scripts/bundle-a2ui.sh", "check": "pnpm check:host-env-policy:swift && pnpm format:check && pnpm tsgo && pnpm plugin-sdk:check-exports && pnpm lint && pnpm lint:tmp:no-random-messaging && pnpm lint:tmp:channel-agnostic-boundaries && pnpm lint:tmp:no-raw-channel-fetch && pnpm lint:agent:ingress-owner && pnpm lint:plugins:no-register-http-handler && pnpm lint:plugins:no-monolithic-plugin-sdk-entry-imports && pnpm lint:plugins:no-extension-src-imports && pnpm lint:plugins:no-extension-test-core-imports && pnpm lint:plugins:no-extension-imports && pnpm lint:extensions:no-src-outside-plugin-sdk && pnpm lint:extensions:no-plugin-sdk-internal && pnpm lint:web-search-provider-boundaries && pnpm lint:webhook:no-low-level-body-read && pnpm lint:auth:no-pairing-store-group && pnpm lint:auth:pairing-account-scope", From 79c6158ac66129e85812f29802476863fa495c13 Mon Sep 17 00:00:00 2001 From: Tak Hoffman <781889+Takhoffman@users.noreply.github.com> Date: Wed, 18 Mar 2026 07:54:46 -0500 Subject: [PATCH 48/55] Deps: align pi-agent-core for declaration builds --- package.json | 4 +++- pnpm-lock.yaml | 10 ++++++---- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index f20aa3b7e3d..017e861ebeb 100644 --- a/package.json +++ b/package.json @@ -658,7 +658,7 @@ "@larksuiteoapi/node-sdk": "^1.59.0", "@line/bot-sdk": "^10.6.0", "@lydell/node-pty": "1.2.0-beta.3", - "@mariozechner/pi-agent-core": "0.60.0", + "@mariozechner/pi-agent-core": "0.58.0", "@mariozechner/pi-ai": "0.60.0", "@mariozechner/pi-coding-agent": "0.60.0", "@mariozechner/pi-tui": "0.60.0", @@ -743,6 +743,8 @@ "pnpm": { "minimumReleaseAge": 2880, "overrides": { + "@mariozechner/pi-coding-agent>@mariozechner/pi-agent-core": "0.58.0", + "@mariozechner/pi-agent-core>@mariozechner/pi-ai": "0.60.0", "hono": "4.12.8", "@hono/node-server": "1.19.10", "fast-xml-parser": "5.5.6", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d01869b8fd4..206f1e018c2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5,6 +5,8 @@ settings: excludeLinksFromLockfile: false overrides: + '@mariozechner/pi-coding-agent>@mariozechner/pi-agent-core': 0.58.0 + '@mariozechner/pi-agent-core>@mariozechner/pi-ai': 0.60.0 hono: 4.12.8 '@hono/node-server': 1.19.10 fast-xml-parser: 5.5.6 @@ -63,8 +65,8 @@ importers: specifier: 1.2.0-beta.3 version: 1.2.0-beta.3 '@mariozechner/pi-agent-core': - specifier: 0.60.0 - version: 0.60.0(@modelcontextprotocol/sdk@1.27.1(zod@4.3.6))(ws@8.19.0)(zod@4.3.6) + specifier: 0.58.0 + version: 0.58.0(@modelcontextprotocol/sdk@1.27.1(zod@4.3.6))(ws@8.19.0)(zod@4.3.6) '@mariozechner/pi-ai': specifier: 0.60.0 version: 0.60.0(@modelcontextprotocol/sdk@1.27.1(zod@4.3.6))(ws@8.19.0)(zod@4.3.6) @@ -8907,7 +8909,7 @@ snapshots: '@mariozechner/pi-agent-core@0.58.0(@modelcontextprotocol/sdk@1.27.1(zod@4.3.6))(ws@8.19.0)(zod@4.3.6)': dependencies: - '@mariozechner/pi-ai': 0.58.0(@modelcontextprotocol/sdk@1.27.1(zod@4.3.6))(ws@8.19.0)(zod@4.3.6) + '@mariozechner/pi-ai': 0.60.0(@modelcontextprotocol/sdk@1.27.1(zod@4.3.6))(ws@8.19.0)(zod@4.3.6) transitivePeerDependencies: - '@modelcontextprotocol/sdk' - aws-crt @@ -9012,7 +9014,7 @@ snapshots: '@mariozechner/pi-coding-agent@0.60.0(@modelcontextprotocol/sdk@1.27.1(zod@4.3.6))(ws@8.19.0)(zod@4.3.6)': dependencies: '@mariozechner/jiti': 2.6.5 - '@mariozechner/pi-agent-core': 0.60.0(@modelcontextprotocol/sdk@1.27.1(zod@4.3.6))(ws@8.19.0)(zod@4.3.6) + '@mariozechner/pi-agent-core': 0.58.0(@modelcontextprotocol/sdk@1.27.1(zod@4.3.6))(ws@8.19.0)(zod@4.3.6) '@mariozechner/pi-ai': 0.60.0(@modelcontextprotocol/sdk@1.27.1(zod@4.3.6))(ws@8.19.0)(zod@4.3.6) '@mariozechner/pi-tui': 0.60.0 '@silvia-odwyer/photon-node': 0.3.4 From 86e9dcfc1b051b8b0993850e21a37359ff2626ac Mon Sep 17 00:00:00 2001 From: Tak Hoffman <781889+Takhoffman@users.noreply.github.com> Date: Wed, 18 Mar 2026 07:57:33 -0500 Subject: [PATCH 49/55] Build: fail on unresolved tsdown imports --- scripts/tsdown-build.mjs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/scripts/tsdown-build.mjs b/scripts/tsdown-build.mjs index 09978543bdd..5faa9799dbb 100644 --- a/scripts/tsdown-build.mjs +++ b/scripts/tsdown-build.mjs @@ -5,6 +5,7 @@ import { spawnSync } from "node:child_process"; const logLevel = process.env.OPENCLAW_BUILD_VERBOSE ? "info" : "warn"; const extraArgs = process.argv.slice(2); const INEFFECTIVE_DYNAMIC_IMPORT_RE = /\[INEFFECTIVE_DYNAMIC_IMPORT\]/; +const UNRESOLVED_IMPORT_RE = /\[UNRESOLVED_IMPORT\]/; const result = spawnSync( "pnpm", ["exec", "tsdown", "--config-loader", "unrun", "--logLevel", logLevel, ...extraArgs], @@ -31,6 +32,13 @@ if (result.status === 0 && INEFFECTIVE_DYNAMIC_IMPORT_RE.test(`${stdout}\n${stde process.exit(1); } +if (result.status === 0 && UNRESOLVED_IMPORT_RE.test(`${stdout}\n${stderr}`)) { + console.error( + "Build emitted [UNRESOLVED_IMPORT]. Declare or bundle the missing dependency instead of silently externalizing it.", + ); + process.exit(1); +} + if (typeof result.status === "number") { process.exit(result.status); } From 13f396b39551704bcd68c7bc6ad24523d49e38a7 Mon Sep 17 00:00:00 2001 From: Tak Hoffman <781889+Takhoffman@users.noreply.github.com> Date: Wed, 18 Mar 2026 08:27:48 -0500 Subject: [PATCH 50/55] Plugins: sync contract registry image providers --- src/plugins/contracts/registry.contract.test.ts | 11 ++++++++++- src/plugins/contracts/registry.ts | 2 +- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/src/plugins/contracts/registry.contract.test.ts b/src/plugins/contracts/registry.contract.test.ts index 5c8d06785ce..dbef2227825 100644 --- a/src/plugins/contracts/registry.contract.test.ts +++ b/src/plugins/contracts/registry.contract.test.ts @@ -171,6 +171,7 @@ describe("plugin contract registry", () => { }); it("keeps bundled image-generation ownership explicit", () => { + expect(findImageGenerationProviderIdsForPlugin("fal")).toEqual(["fal"]); expect(findImageGenerationProviderIdsForPlugin("google")).toEqual(["google"]); expect(findImageGenerationProviderIdsForPlugin("openai")).toEqual(["openai"]); }); @@ -187,6 +188,13 @@ describe("plugin contract registry", () => { }); it("tracks speech registrations on bundled provider plugins", () => { + expect(findRegistrationForPlugin("fal")).toMatchObject({ + providerIds: ["fal"], + speechProviderIds: [], + mediaUnderstandingProviderIds: [], + imageGenerationProviderIds: ["fal"], + webSearchProviderIds: [], + }); expect(findRegistrationForPlugin("google")).toMatchObject({ providerIds: ["google", "google-gemini-cli"], speechProviderIds: [], @@ -214,12 +222,13 @@ describe("plugin contract registry", () => { }); }); - it("tracks every provider, speech, media, or web search plugin in the registration registry", () => { + it("tracks every provider, speech, media, image, or web search plugin in the registration registry", () => { const expectedPluginIds = [ ...new Set([ ...providerContractRegistry.map((entry) => entry.pluginId), ...speechProviderContractRegistry.map((entry) => entry.pluginId), ...mediaUnderstandingProviderContractRegistry.map((entry) => entry.pluginId), + ...imageGenerationProviderContractRegistry.map((entry) => entry.pluginId), ...webSearchProviderContractRegistry.map((entry) => entry.pluginId), ]), ].toSorted((left, right) => left.localeCompare(right)); diff --git a/src/plugins/contracts/registry.ts b/src/plugins/contracts/registry.ts index acee90323b9..1dedc6c95c2 100644 --- a/src/plugins/contracts/registry.ts +++ b/src/plugins/contracts/registry.ts @@ -59,7 +59,7 @@ const BUNDLED_MEDIA_UNDERSTANDING_PLUGIN_IDS = [ "openai", "zai", ] as const; -const BUNDLED_IMAGE_GENERATION_PLUGIN_IDS = ["google", "openai"] as const; +const BUNDLED_IMAGE_GENERATION_PLUGIN_IDS = ["fal", "google", "openai"] as const; export const providerContractRegistry: ProviderContractEntry[] = []; From c2402e48c9da2b5bdd98591d80b5ad185b3097d3 Mon Sep 17 00:00:00 2001 From: Tak Hoffman <781889+Takhoffman@users.noreply.github.com> Date: Wed, 18 Mar 2026 08:29:55 -0500 Subject: [PATCH 51/55] Build: narrow tsdown unresolved import guard --- scripts/tsdown-build.mjs | 26 ++++++++++++++++++++++---- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/scripts/tsdown-build.mjs b/scripts/tsdown-build.mjs index 5faa9799dbb..871e89ddbf0 100644 --- a/scripts/tsdown-build.mjs +++ b/scripts/tsdown-build.mjs @@ -6,6 +6,23 @@ const logLevel = process.env.OPENCLAW_BUILD_VERBOSE ? "info" : "warn"; const extraArgs = process.argv.slice(2); const INEFFECTIVE_DYNAMIC_IMPORT_RE = /\[INEFFECTIVE_DYNAMIC_IMPORT\]/; const UNRESOLVED_IMPORT_RE = /\[UNRESOLVED_IMPORT\]/; +const ANSI_ESCAPE_RE = new RegExp(String.raw`\u001B\[[0-9;]*m`, "g"); + +function findFatalUnresolvedImport(lines) { + for (const line of lines) { + if (!UNRESOLVED_IMPORT_RE.test(line)) { + continue; + } + + const normalizedLine = line.replace(ANSI_ESCAPE_RE, ""); + if (!normalizedLine.includes("extensions/")) { + return normalizedLine; + } + } + + return null; +} + const result = spawnSync( "pnpm", ["exec", "tsdown", "--config-loader", "unrun", "--logLevel", logLevel, ...extraArgs], @@ -32,10 +49,11 @@ if (result.status === 0 && INEFFECTIVE_DYNAMIC_IMPORT_RE.test(`${stdout}\n${stde process.exit(1); } -if (result.status === 0 && UNRESOLVED_IMPORT_RE.test(`${stdout}\n${stderr}`)) { - console.error( - "Build emitted [UNRESOLVED_IMPORT]. Declare or bundle the missing dependency instead of silently externalizing it.", - ); +const fatalUnresolvedImport = + result.status === 0 ? findFatalUnresolvedImport(`${stdout}\n${stderr}`.split("\n")) : null; + +if (fatalUnresolvedImport) { + console.error(`Build emitted [UNRESOLVED_IMPORT] outside extensions: ${fatalUnresolvedImport}`); process.exit(1); } From 4a44ca8f793316dd74f0ba0dfe584d214f64ace5 Mon Sep 17 00:00:00 2001 From: Tak Hoffman <781889+Takhoffman@users.noreply.github.com> Date: Wed, 18 Mar 2026 08:31:09 -0500 Subject: [PATCH 52/55] fix llm-task invalid thinking timeout --- extensions/llm-task/src/llm-task-tool.test.ts | 78 +++++++++++++++++++ extensions/llm-task/src/llm-task-tool.ts | 6 +- 2 files changed, 82 insertions(+), 2 deletions(-) diff --git a/extensions/llm-task/src/llm-task-tool.test.ts b/extensions/llm-task/src/llm-task-tool.test.ts index 6d21ec69654..0a41f0f4bad 100644 --- a/extensions/llm-task/src/llm-task-tool.test.ts +++ b/extensions/llm-task/src/llm-task-tool.test.ts @@ -1,4 +1,81 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@sinclair/typebox", () => ({ + Type: { + Object: (schema: unknown) => schema, + String: (schema?: unknown) => schema, + Optional: (schema: unknown) => schema, + Unknown: (schema?: unknown) => schema, + Number: (schema?: unknown) => schema, + }, +})); + +vi.mock("ajv", () => ({ + default: class MockAjv { + compile(schema: unknown) { + return (value: unknown) => { + if ( + schema && + typeof schema === "object" && + !Array.isArray(schema) && + (schema as { properties?: Record }).properties?.foo?.type === + "string" + ) { + const ok = typeof (value as { foo?: unknown })?.foo === "string"; + (this as { errors?: Array<{ instancePath: string; message: string }> }).errors = ok + ? undefined + : [{ instancePath: "/foo", message: "must be string" }]; + return ok; + } + (this as { errors?: Array<{ instancePath: string; message: string }> }).errors = undefined; + return true; + }; + } + + errors?: Array<{ instancePath: string; message: string }>; + }, +})); + +vi.mock("../api.js", () => ({ + formatXHighModelHint: () => "provider models that advertise xhigh reasoning", + normalizeThinkLevel: (raw?: string | null) => { + if (!raw) { + return undefined; + } + const key = raw.trim().toLowerCase(); + const collapsed = key.replace(/[\s_-]+/g, ""); + if (collapsed === "adaptive" || collapsed === "auto") { + return "adaptive"; + } + if (collapsed === "xhigh" || collapsed === "extrahigh") { + return "xhigh"; + } + if (["off"].includes(key)) { + return "off"; + } + if (["on", "enable", "enabled"].includes(key)) { + return "low"; + } + if (["min", "minimal", "think"].includes(key)) { + return "minimal"; + } + if (["low", "thinkhard", "think-hard", "think_hard"].includes(key)) { + return "low"; + } + if (["mid", "med", "medium", "thinkharder", "think-harder", "harder"].includes(key)) { + return "medium"; + } + if ( + ["high", "ultra", "ultrathink", "think-hard", "thinkhardest", "highest", "max"].includes(key) + ) { + return "high"; + } + return undefined; + }, + resolvePreferredOpenClawTmpDir: () => "/tmp", + supportsXHighThinking: () => false, +})); + import { createLlmTaskTool } from "./llm-task-tool.js"; const runEmbeddedPiAgent = vi.fn(async () => ({ @@ -137,6 +214,7 @@ describe("llm-task tool (json-only)", () => { await expect(tool.execute("id", { prompt: "x", thinking: "banana" })).rejects.toThrow( /invalid thinking level/i, ); + expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); }); it("throws on unsupported xhigh thinking level", async () => { diff --git a/extensions/llm-task/src/llm-task-tool.ts b/extensions/llm-task/src/llm-task-tool.ts index 47c7efbea76..77d76fb2dfb 100644 --- a/extensions/llm-task/src/llm-task-tool.ts +++ b/extensions/llm-task/src/llm-task-tool.ts @@ -3,7 +3,6 @@ import path from "node:path"; import { Type } from "@sinclair/typebox"; import Ajv from "ajv"; import { - formatThinkingLevels, formatXHighModelHint, normalizeThinkLevel, resolvePreferredOpenClawTmpDir, @@ -45,6 +44,9 @@ type PluginCfg = { timeoutMs?: number; }; +const INVALID_THINKING_LEVELS_HINT = + "off, minimal, low, medium, high, adaptive, and xhigh where supported"; + export function createLlmTaskTool(api: OpenClawPluginApi) { return { name: "llm-task", @@ -125,7 +127,7 @@ export function createLlmTaskTool(api: OpenClawPluginApi) { const thinkLevel = thinkingRaw ? normalizeThinkLevel(thinkingRaw) : undefined; if (thinkingRaw && !thinkLevel) { throw new Error( - `Invalid thinking level "${thinkingRaw}". Use one of: ${formatThinkingLevels(provider, model)}.`, + `Invalid thinking level "${thinkingRaw}". Use one of: ${INVALID_THINKING_LEVELS_HINT}.`, ); } if (thinkLevel === "xhigh" && !supportsXHighThinking(provider, model)) { From ca13256913e5474b8403b869ae61390c0110fa2b Mon Sep 17 00:00:00 2001 From: Tak Hoffman <781889+Takhoffman@users.noreply.github.com> Date: Wed, 18 Mar 2026 08:50:02 -0500 Subject: [PATCH 53/55] Deps: restore known-good tlon api install source --- extensions/tlon/package.json | 2 +- pnpm-lock.yaml | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/extensions/tlon/package.json b/extensions/tlon/package.json index 2fce246d283..071280374a3 100644 --- a/extensions/tlon/package.json +++ b/extensions/tlon/package.json @@ -4,7 +4,7 @@ "description": "OpenClaw Tlon/Urbit channel plugin", "type": "module", "dependencies": { - "@tloncorp/api": "https://codeload.github.com/tloncorp/api-beta/tar.gz/c121deb82d97970418508691585aea4f71abcf9c", + "@tloncorp/api": "github:tloncorp/api-beta#7eede1c1a756977b09f96aa14a92e2b06318ae87", "@tloncorp/tlon-skill": "0.2.2", "@urbit/aura": "^3.0.0", "zod": "^4.3.6" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 206f1e018c2..0447e4ef9bc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -532,8 +532,8 @@ importers: extensions/tlon: dependencies: '@tloncorp/api': - specifier: https://codeload.github.com/tloncorp/api-beta/tar.gz/c121deb82d97970418508691585aea4f71abcf9c - version: https://codeload.github.com/tloncorp/api-beta/tar.gz/c121deb82d97970418508691585aea4f71abcf9c + specifier: github:tloncorp/api-beta#7eede1c1a756977b09f96aa14a92e2b06318ae87 + version: https://codeload.github.com/tloncorp/api-beta/tar.gz/7eede1c1a756977b09f96aa14a92e2b06318ae87 '@tloncorp/tlon-skill': specifier: 0.2.2 version: 0.2.2 @@ -3428,8 +3428,8 @@ packages: resolution: {integrity: sha512-5Kc5CM2Ysn3vTTArBs2vESUt0AQiWZA86yc1TI3B+lxXmtEq133C1nxXNOgnzhrivdPZIh3zLj5gDnZjoLL5GA==} engines: {node: '>=12.17.0'} - '@tloncorp/api@https://codeload.github.com/tloncorp/api-beta/tar.gz/c121deb82d97970418508691585aea4f71abcf9c': - resolution: {tarball: https://codeload.github.com/tloncorp/api-beta/tar.gz/c121deb82d97970418508691585aea4f71abcf9c} + '@tloncorp/api@https://codeload.github.com/tloncorp/api-beta/tar.gz/7eede1c1a756977b09f96aa14a92e2b06318ae87': + resolution: {tarball: https://codeload.github.com/tloncorp/api-beta/tar.gz/7eede1c1a756977b09f96aa14a92e2b06318ae87} version: 0.0.2 '@tloncorp/tlon-skill-darwin-arm64@0.2.2': @@ -10851,7 +10851,7 @@ snapshots: '@tinyhttp/content-disposition@2.2.4': {} - '@tloncorp/api@https://codeload.github.com/tloncorp/api-beta/tar.gz/c121deb82d97970418508691585aea4f71abcf9c': + '@tloncorp/api@https://codeload.github.com/tloncorp/api-beta/tar.gz/7eede1c1a756977b09f96aa14a92e2b06318ae87': dependencies: '@aws-sdk/client-s3': 3.1000.0 '@aws-sdk/s3-request-presigner': 3.1000.0 From 5d41fd449731c8c7d143d9cb1a012d2270d7f806 Mon Sep 17 00:00:00 2001 From: Tak Hoffman <781889+Takhoffman@users.noreply.github.com> Date: Wed, 18 Mar 2026 09:42:52 -0500 Subject: [PATCH 54/55] test: extend plugin contract setup timeouts --- src/plugins/contracts/catalog.contract.test.ts | 4 +++- src/plugins/contracts/runtime.contract.test.ts | 4 +++- src/plugins/contracts/wizard.contract.test.ts | 4 +++- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/plugins/contracts/catalog.contract.test.ts b/src/plugins/contracts/catalog.contract.test.ts index 4b775bd8061..04c13df00b5 100644 --- a/src/plugins/contracts/catalog.contract.test.ts +++ b/src/plugins/contracts/catalog.contract.test.ts @@ -5,6 +5,8 @@ import { expectCodexMissingAuthHint, } from "../provider-runtime.test-support.js"; +const CONTRACT_SETUP_TIMEOUT_MS = 300_000; + type ResolvePluginProviders = typeof import("../providers.js").resolvePluginProviders; type ResolveOwningPluginIdsForProvider = typeof import("../providers.js").resolveOwningPluginIdsForProvider; @@ -74,7 +76,7 @@ describe("provider catalog contract", () => { resolveProviderBuiltInModelSuppression, } = await import("../provider-runtime.js")); resetProviderRuntimeHookCacheForTest(); - }); + }, CONTRACT_SETUP_TIMEOUT_MS); it("keeps codex-only missing-auth hints wired through the provider runtime", () => { expectCodexMissingAuthHint(buildProviderMissingAuthMessageWithPlugin); diff --git a/src/plugins/contracts/runtime.contract.test.ts b/src/plugins/contracts/runtime.contract.test.ts index ba6e7df1187..4edb0adbe5e 100644 --- a/src/plugins/contracts/runtime.contract.test.ts +++ b/src/plugins/contracts/runtime.contract.test.ts @@ -7,6 +7,8 @@ import { createProviderUsageFetch, makeResponse } from "../../test-utils/provide import type { OpenClawPluginApi, ProviderPlugin } from "../types.js"; import type { ProviderRuntimeModel } from "../types.js"; +const CONTRACT_SETUP_TIMEOUT_MS = 300_000; + const getOAuthApiKeyMock = vi.hoisted(() => vi.fn()); const refreshQwenPortalCredentialsMock = vi.hoisted(() => vi.fn()); @@ -80,7 +82,7 @@ describe("provider runtime contract", () => { qwenPortalPlugin = (await import("../../../extensions/qwen-portal-auth/index.js")).default; getOAuthApiKeyMock.mockReset(); refreshQwenPortalCredentialsMock.mockReset(); - }); + }, CONTRACT_SETUP_TIMEOUT_MS); describe("anthropic", () => { it("owns anthropic 4.6 forward-compat resolution", () => { diff --git a/src/plugins/contracts/wizard.contract.test.ts b/src/plugins/contracts/wizard.contract.test.ts index 6e97556d91e..7beb5b75d4e 100644 --- a/src/plugins/contracts/wizard.contract.test.ts +++ b/src/plugins/contracts/wizard.contract.test.ts @@ -1,6 +1,8 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { ProviderPlugin } from "../types.js"; +const CONTRACT_SETUP_TIMEOUT_MS = 300_000; + const resolvePluginProvidersMock = vi.fn(); let buildProviderPluginMethodChoice: typeof import("../provider-wizard.js").buildProviderPluginMethodChoice; @@ -83,7 +85,7 @@ describe("provider wizard contract", () => { resolveProviderPluginChoice, resolveProviderWizardOptions, } = await import("../provider-wizard.js")); - }); + }, CONTRACT_SETUP_TIMEOUT_MS); it("exposes every registered provider setup choice through the shared wizard layer", () => { const options = resolveProviderWizardOptions({ From ea476de1e488979a3e9e5bf32e4d4f20e563144f Mon Sep 17 00:00:00 2001 From: Tak Hoffman <781889+Takhoffman@users.noreply.github.com> Date: Wed, 18 Mar 2026 10:16:21 -0500 Subject: [PATCH 55/55] Add plugin-sdk seam audit script --- scripts/audit-plugin-sdk-seams.mjs | 298 +++++++++++++++++++++++++++++ 1 file changed, 298 insertions(+) create mode 100644 scripts/audit-plugin-sdk-seams.mjs diff --git a/scripts/audit-plugin-sdk-seams.mjs b/scripts/audit-plugin-sdk-seams.mjs new file mode 100644 index 00000000000..c7b48543f1f --- /dev/null +++ b/scripts/audit-plugin-sdk-seams.mjs @@ -0,0 +1,298 @@ +#!/usr/bin/env node + +import fs from "node:fs"; +import { builtinModules } from "node:module"; +import path from "node:path"; +import process from "node:process"; + +const REPO_ROOT = process.cwd(); +const SCAN_ROOTS = ["src", "extensions", "scripts", "ui", "test"]; +const CODE_EXTENSIONS = new Set([".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs"]); +const SKIP_DIRS = new Set([".git", "node_modules", "dist", "coverage", ".turbo", ".next", "build"]); +const BUILTIN_PREFIXES = new Set(["node:"]); +const BUILTIN_MODULES = new Set( + builtinModules.flatMap((name) => [name, name.replace(/^node:/, "")]), +); +const INTERNAL_PREFIXES = ["openclaw/plugin-sdk", "openclaw/", "@/", "~/", "#"]; +const compareStrings = (a, b) => a.localeCompare(b); + +function readJson(filePath) { + return JSON.parse(fs.readFileSync(filePath, "utf8")); +} + +function normalizeSlashes(input) { + return input.split(path.sep).join("/"); +} + +function listFiles(rootRel) { + const rootAbs = path.join(REPO_ROOT, rootRel); + if (!fs.existsSync(rootAbs)) { + return []; + } + const out = []; + const stack = [rootAbs]; + while (stack.length > 0) { + const current = stack.pop(); + if (!current) { + continue; + } + const entries = fs.readdirSync(current, { withFileTypes: true }); + for (const entry of entries) { + const abs = path.join(current, entry.name); + if (entry.isDirectory()) { + if (!SKIP_DIRS.has(entry.name)) { + stack.push(abs); + } + continue; + } + if (!entry.isFile()) { + continue; + } + if (!CODE_EXTENSIONS.has(path.extname(entry.name))) { + continue; + } + out.push(abs); + } + } + out.sort((a, b) => + normalizeSlashes(path.relative(REPO_ROOT, a)).localeCompare( + normalizeSlashes(path.relative(REPO_ROOT, b)), + ), + ); + return out; +} + +function extractSpecifiers(sourceText) { + const specifiers = []; + const patterns = [ + /\bimport\s+type\s+[^"'`]*?\sfrom\s+["'`]([^"'`]+)["'`]/g, + /\bimport\s+[^"'`]*?\sfrom\s+["'`]([^"'`]+)["'`]/g, + /\bexport\s+[^"'`]*?\sfrom\s+["'`]([^"'`]+)["'`]/g, + /\bimport\s*\(\s*["'`]([^"'`]+)["'`]\s*\)/g, + ]; + for (const pattern of patterns) { + for (const match of sourceText.matchAll(pattern)) { + const specifier = match[1]?.trim(); + if (specifier) { + specifiers.push(specifier); + } + } + } + return specifiers; +} + +function toRepoRelative(absPath) { + return normalizeSlashes(path.relative(REPO_ROOT, absPath)); +} + +function resolveRelativeImport(fileAbs, specifier) { + if (!specifier.startsWith(".") && !specifier.startsWith("/")) { + return null; + } + const fromDir = path.dirname(fileAbs); + const baseAbs = specifier.startsWith("/") + ? path.join(REPO_ROOT, specifier) + : path.resolve(fromDir, specifier); + const candidatePaths = [ + baseAbs, + `${baseAbs}.ts`, + `${baseAbs}.tsx`, + `${baseAbs}.mts`, + `${baseAbs}.cts`, + `${baseAbs}.js`, + `${baseAbs}.jsx`, + `${baseAbs}.mjs`, + `${baseAbs}.cjs`, + path.join(baseAbs, "index.ts"), + path.join(baseAbs, "index.tsx"), + path.join(baseAbs, "index.mts"), + path.join(baseAbs, "index.cts"), + path.join(baseAbs, "index.js"), + path.join(baseAbs, "index.jsx"), + path.join(baseAbs, "index.mjs"), + path.join(baseAbs, "index.cjs"), + ]; + for (const candidate of candidatePaths) { + if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) { + return toRepoRelative(candidate); + } + } + return normalizeSlashes(path.relative(REPO_ROOT, baseAbs)); +} + +function getExternalPackageRoot(specifier) { + if (!specifier) { + return null; + } + if (!/^[a-zA-Z0-9@][a-zA-Z0-9@._/+:-]*$/.test(specifier)) { + return null; + } + if (specifier.startsWith(".") || specifier.startsWith("/")) { + return null; + } + if (Array.from(BUILTIN_PREFIXES).some((prefix) => specifier.startsWith(prefix))) { + return null; + } + if ( + INTERNAL_PREFIXES.some((prefix) => specifier === prefix || specifier.startsWith(`${prefix}/`)) + ) { + return null; + } + if (BUILTIN_MODULES.has(specifier)) { + return null; + } + if (specifier.startsWith("@")) { + const [scope, name] = specifier.split("/"); + return scope && name ? `${scope}/${name}` : specifier; + } + const root = specifier.split("/")[0] ?? specifier; + if (BUILTIN_MODULES.has(root)) { + return null; + } + return root; +} + +function ensureArrayMap(map, key) { + if (!map.has(key)) { + map.set(key, []); + } + return map.get(key); +} + +const packageJson = readJson(path.join(REPO_ROOT, "package.json")); +const declaredPackages = new Set([ + ...Object.keys(packageJson.dependencies ?? {}), + ...Object.keys(packageJson.devDependencies ?? {}), + ...Object.keys(packageJson.peerDependencies ?? {}), + ...Object.keys(packageJson.optionalDependencies ?? {}), +]); + +const fileRecords = []; +const publicSeamUsage = new Map(); +const sourceSeamUsage = new Map(); +const missingExternalUsage = new Map(); + +for (const root of SCAN_ROOTS) { + for (const fileAbs of listFiles(root)) { + const fileRel = toRepoRelative(fileAbs); + const sourceText = fs.readFileSync(fileAbs, "utf8"); + const specifiers = extractSpecifiers(sourceText); + const publicSeams = new Set(); + const sourceSeams = new Set(); + const externalPackages = new Set(); + + for (const specifier of specifiers) { + if (specifier === "openclaw/plugin-sdk") { + publicSeams.add("index"); + ensureArrayMap(publicSeamUsage, "index").push(fileRel); + continue; + } + if (specifier.startsWith("openclaw/plugin-sdk/")) { + const seam = specifier.slice("openclaw/plugin-sdk/".length); + publicSeams.add(seam); + ensureArrayMap(publicSeamUsage, seam).push(fileRel); + continue; + } + + const resolvedRel = resolveRelativeImport(fileAbs, specifier); + if (resolvedRel?.startsWith("src/plugin-sdk/")) { + const seam = resolvedRel + .slice("src/plugin-sdk/".length) + .replace(/\.(tsx?|mts|cts|jsx?|mjs|cjs)$/, "") + .replace(/\/index$/, ""); + sourceSeams.add(seam); + ensureArrayMap(sourceSeamUsage, seam).push(fileRel); + continue; + } + + const externalRoot = getExternalPackageRoot(specifier); + if (!externalRoot) { + continue; + } + externalPackages.add(externalRoot); + if (!declaredPackages.has(externalRoot)) { + ensureArrayMap(missingExternalUsage, externalRoot).push(fileRel); + } + } + + fileRecords.push({ + file: fileRel, + publicSeams: [...publicSeams].toSorted(compareStrings), + sourceSeams: [...sourceSeams].toSorted(compareStrings), + externalPackages: [...externalPackages].toSorted(compareStrings), + }); + } +} + +fileRecords.sort((a, b) => a.file.localeCompare(b.file)); + +const overlapFiles = fileRecords + .filter((record) => record.publicSeams.length > 0 && record.sourceSeams.length > 0) + .map((record) => ({ + file: record.file, + publicSeams: record.publicSeams, + sourceSeams: record.sourceSeams, + overlappingSeams: record.publicSeams.filter((seam) => record.sourceSeams.includes(seam)), + })) + .toSorted((a, b) => a.file.localeCompare(b.file)); + +const seamFamilies = [...new Set([...publicSeamUsage.keys(), ...sourceSeamUsage.keys()])] + .toSorted((a, b) => a.localeCompare(b)) + .map((seam) => ({ + seam, + publicImporterCount: new Set(publicSeamUsage.get(seam) ?? []).size, + sourceImporterCount: new Set(sourceSeamUsage.get(seam) ?? []).size, + publicImporters: [...new Set(publicSeamUsage.get(seam) ?? [])].toSorted(compareStrings), + sourceImporters: [...new Set(sourceSeamUsage.get(seam) ?? [])].toSorted(compareStrings), + })) + .filter((entry) => entry.publicImporterCount > 0 || entry.sourceImporterCount > 0); + +const duplicatedSeamFamilies = seamFamilies.filter( + (entry) => entry.publicImporterCount > 0 && entry.sourceImporterCount > 0, +); + +const missingPackages = [...missingExternalUsage.entries()] + .map(([packageName, files]) => { + const uniqueFiles = [...new Set(files)].toSorted(compareStrings); + const byTopLevel = {}; + for (const file of uniqueFiles) { + const topLevel = file.split("/")[0] ?? file; + byTopLevel[topLevel] ??= []; + byTopLevel[topLevel].push(file); + } + const topLevelCounts = Object.entries(byTopLevel) + .map(([scope, scopeFiles]) => ({ + scope, + fileCount: scopeFiles.length, + })) + .toSorted((a, b) => b.fileCount - a.fileCount || a.scope.localeCompare(b.scope)); + return { + packageName, + importerCount: uniqueFiles.length, + importers: uniqueFiles, + topLevelCounts, + }; + }) + .toSorted( + (a, b) => b.importerCount - a.importerCount || a.packageName.localeCompare(b.packageName), + ); + +const summary = { + scannedFileCount: fileRecords.length, + filesUsingPublicPluginSdk: fileRecords.filter((record) => record.publicSeams.length > 0).length, + filesUsingSourcePluginSdk: fileRecords.filter((record) => record.sourceSeams.length > 0).length, + filesUsingBothPublicAndSourcePluginSdk: overlapFiles.length, + duplicatedSeamFamilyCount: duplicatedSeamFamilies.length, + missingExternalPackageCount: missingPackages.length, +}; + +const report = { + generatedAtUtc: new Date().toISOString(), + repoRoot: REPO_ROOT, + summary, + duplicatedSeamFamilies, + overlapFiles, + missingPackages, +}; + +process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);