Keyboard shortcuts

Press or to navigate between chapters

Press ? to show this help

Press Esc to hide this help


type: référence status: accepté last-reviewed: 2026-07-17 relates-to:

  • FND-001
  • ADR-003
  • crates/zeroclaw-plugins

Protocole de plugin

Ce document définit le protocole entre l’hôte de plugins de ZeroClaw et les composants de plugins WASM.

Qu’est-ce qu’un plugin

Un plugin est un composant WebAssembly autonome que ZeroClaw charge à l’exécution pour ajouter une fonctionnalité que le binaire principal n’inclut pas. Il réside dans son propre répertoire sous ~/.zeroclaw/plugins/, accompagné d’un manifeste qui lui donne un nom et déclare ce qu’il fournit. ZeroClaw le détecte au démarrage, le vérifie, et intègre ses fonctions exportées à l’agent en cours d’exécution afin qu’elles se comportent comme des fonctionnalités intégrées : un plugin d’outil apparaît pour le modèle comme un simple outil appelable (WasmTool implémente le même trait Tool qu’un outil natif), un plugin de canal se comporte comme un canal de messagerie, et un plugin de mémoire comme un backend de stockage.

Un plugin peut fournir une ou plusieurs des capacités définies dans PluginCapability (crates/zeroclaw-plugins/src/lib.rs) : un outil callable, un canal de messagerie, un backend mémoire, un backend d’observabilité ou un ensemble de compétences markdown. Le cas des compétences est particulier : il ne contient aucun WASM, uniquement un répertoire skills/ contenant du markdown, ce qui explique pourquoi il s’agit de la seule capacité à omettre le composant compilé.

Pourquoi en construire un

  • Étendez sans fork. Ajoutez un outil ou un canal sans modifier l’arborescence source de ZeroClaw ou attendre une version ; le plugin vous appartient et se charge depuis votre répertoire d’installation.
  • Comportement natif. Un plugin chargé n’est pas un complément de seconde classe. Le pont implémente les mêmes traits d’exécution que les fonctions intégrées, de sorte qu’un outil de plugin est proposé au modèle, attribué et invoqué exactement comme un outil officiel.
  • Choix de langage. Le contrat est le WIT et le WASI Component Model, et non une API Rust. Tout langage compilant vers un composant wasm32-wasip2 peut implémenter un world. Le guide pratique ci-dessous utilise Rust, car c’est l’approche qui offre le plus de support aujourd’hui, mais la frontière elle-même est agnostique du langage.
  • Isolé par défaut. L’hôte charge chaque plugin dans un contexte WASI sans préouverture de système de fichiers ni réseau ambiant. Un plugin ne peut pas atteindre l’hôte discrètement ; il obtient exactement les fonctions hôtes câblées dans son monde et rien de plus. Le HTTP sortant est la seule surface réseau qui peut être ouverte, et uniquement lorsque le manifeste accorde http_client et que l’adaptateur de capacité correspondant active explicitement sa limite HTTP testée. Les adaptateurs d’outils et de canaux le font ; la mémoire pas encore.
  • Provenance vérifiable. Les manifests peuvent être signés avec Ed25519, et un opérateur peut exiger des signatures d’éditeurs de confiance avant le chargement de tout plugin.

Ce qu’un plugin ne peut pas faire (aujourd’hui)

Ce sont de réelles limites de l’hôte actuel, pas des préférences de style. Connaissez-les avant de concevoir autour d’une capacité qui n’est pas disponible.

  • logging, la configuration typée, les secrets propres à l’instance, http_client et les entrées fournies par l’hôte sont opérationnels. Parmi les permissions qu’un manifeste peut déclarer, config_read expose la configuration publique du plugin, validée par son propre schéma. Le schéma d’un outil ou d’un canal peut désigner des secrets exclus de la configuration publique et résolus lors d’appels de service autorisés. Une autorisation http_client est nécessaire pour les requêtes sortantes wasi:http, mais l’adaptateur de capacités doit également activer cette interface hôte. Les adaptateurs d’outils et de canaux le font ; la mémoire reste intentionnellement exempte de HTTP tant que sa frontière réseau ne dispose pas d’une couverture au niveau des composants. Les permissions du système de fichiers et d’accès à la mémoire sont toujours acceptées par le schéma du manifeste, mais restent inactives : leurs fonctions hôtes ne sont pas encore enregistrées dans l’éditeur de liens. Voir Permissions et imports hôte ci-dessous.
  • Aucun réseau hôte ni système de fichiers ambiant. Le contexte WASI n’a aucune préouverture ni réseau ambiant, donc un plugin ne peut pas ouvrir de sockets bruts ni lire les fichiers de l’hôte via WASI ambiant. Un plugin d’outil ou de canal disposant d’une autorisation http_client obtient un accès sortant à wasi:http parce que ces adaptateurs l’activent explicitement ; il ne peut pas écouter. Les plugins de canal qui doivent recevoir du trafic entrant n’ouvrent pas eux-mêmes d’écouteur : l’hôte exécute l’écouteur et transmet les messages via l’import inbound, que le plugin vide depuis son export poll-message.
  • Une frontière sur 32 bits. La cible est wasm32-wasip2. La mémoire de l’invité constitue un espace d’adressage sur 32 bits et l’ABI des composants représente les décalages sur 32 bits, indépendamment de la taille de mot de l’hôte. Les grandes valeurs (par exemple les octets bruts d’une liaison de canal) traversent la frontière par valeur. Consultez la section sur l’espace d’adressage sur 32 bits pour comprendre pourquoi il s’agit d’une contrainte imposée par la chaîne d’outils en amont, et non d’un drapeau que ce dépôt peut activer.
  • Un outil par plugin d’outil. Le monde tool-plugin exporte une seule interface tool avec un nom et un schéma. Un plugin qui doit exposer plusieurs outils livre plusieurs composants, ou un monde différent.
  • Contrat expérimental, non figé. wit/v0 ne comporte pas encore de marqueur .frozen, les interfaces peuvent donc encore évoluer avant la première version stable. Épinglez une version et prévoyez de recompiler lors d’une incrémentation de version WIT.

Architecture

Les plugins ZeroClaw sont des composants WebAssembly définis par des interfaces WIT dans wit/v0/ et hébergés directement via wasmtime (crates/zeroclaw-plugins). Un plugin est compilé en un composant WASI Preview 2 (wasm32-wasip2) qui exporte l’un des mondes de plugins (tool-plugin, channel-plugin, memory-plugin) et importe les interfaces hôtes déclarées par ce monde dans wit/v0/.

L’hôte se trouve dans crates/zeroclaw-plugins/src/component.rs. Il contient un wasmtime::Engine asynchrone, génère les liaisons du monde avec wasmtime::component::bindgen! à partir de wit/v0, et intègre une surface WASI p2 en bac à sable dans l’éditeur de liens de chaque monde. L’état hôte propre à chaque store (PluginState) contient un WasiCtx configuré sans pré-ouvertures ni réseau, ainsi que la ResourceTable requise par WASI, son périmètre attribué par l’hôte et des handles de service typés actifs. Chaque monde importe logging ; l’outil importe secrets, tandis que le canal importe config, secrets et inbound. Une autorisation http_client accordée ajoute et relie également wasi:http. Les déclarations des mondes et le périmètre admis restent les contrats de référence pour cette surface (voir Importations de l’hôte).

Les trois ponts de mondes mappent chaque monde WIT sur les traits natifs du runtime :

MondeModule de pontSurface d’exécution
tool-pluginruntime.rs, wasm_tool.rszeroclaw_api::tool::Tool
channel-pluginwasm_channel.rstrait de canal
memory-pluginwasm_memory.rstrait de backend mémoire

Les plugins d’outils utilisent un store frais par appel (sans état). Les plugins de canal et de mémoire maintiennent un store chaud protégé par un mutex asynchrone pour la durée de vie du plugin.

Les plugins d’outils sont découverts et enregistrés de bout en bout : le runtime parcourt l’équivalent dédié aux outils de channel_plugin_details() et construit un WasmTool pour chacun. L’adaptateur hôte de canal (WasmChannel, son contrôle d’accès wasi:http, les services de configuration au point d’utilisation et la file inbound alimentée par l’hôte) est terminé et couvert par des tests unitaires, et PluginHost::channel_plugin_details() expose les plugins de canal s’appuyant sur wasm afin qu’ils soient enregistrés. Le runtime résout désormais une liaison [channels.plugin.<alias>] explicitement déclarée, construit son WasmChannel et l’enregistre à partir de l’alias configuré ; cette construction prenant en charge les alias et cette résolution de la configuration du runtime ont été intégrées dans #10146. Le travail restant consiste à ajouter l’écouteur hôte propre à chaque fournisseur, qui achemine chaque transport vers la file inbound du canal. Le pont mémoire (WasmMemory) se trouve dans la même situation, mais une étape en amont : l’adaptateur implémente le trait Memory complet sur le monde memory-plugin, mais l’hôte n’expose pas encore d’équivalent mémoire à channel_plugin_details() et le runtime ne construit pas encore de WasmMemory comme backend configurable.

Structure du plugin

Un plugin est un répertoire contenant :

my-plugin/
  manifest.toml    # Plugin metadata and permissions
  plugin.wasm      # Compiled WASM module (optional for skill-only plugins)

Les plugins sont découverts dans ~/.zeroclaw/plugins/ (configurable via plugins.plugins_dir dans la configuration).

Recherche et installation dans le registre

Le chemin d’installation local des plugins reste la source de vérité pour les plugins installés. Un registre n’est qu’un index JSON utilisé au moment de la commande pour découvrir et télécharger une archive de plugin :

zeroclaw plugin search calendar
zeroclaw plugin install team-calendar
zeroclaw plugin install team-calendar@0.2.0
zeroclaw plugin search calendar --registry https://example.invalid/registry.json
zeroclaw plugin install team-calendar --registry https://example.invalid/registry.json

zeroclaw plugin search récupère les métadonnées du registre et fait correspondre la requête aux noms et descriptions des plugins. Il n’installe pas, n’active pas et n’exécute pas le code des plugins.

zeroclaw plugin install <name> résout le nom depuis le registre, télécharge l’archive zip sélectionnée, vérifie le digest SHA-256 optionnel, extrait l’archive en toute sécurité, puis confie le répertoire du plugin extrait au chemin PluginHost::install existant. Les installations par chemin local restent inchangées :

Lorsqu’aucune version n’est épinglée, ZeroClaw choisit la dernière entrée correspondante dans l’index du registre, les éditeurs de registres doivent donc ordonner intentionnellement les noms répétés.

zeroclaw plugin install ./my-plugin
zeroclaw plugin install ./my-plugin/manifest.toml

L’URL du registre par défaut est :

https://raw.githubusercontent.com/zeroclaw-labs/zeroclaw-plugins/main/registry.jsonPour les registres privés ou de préproduction, utilisez --registry <url> par commande ou définissez ZEROCLAW_PLUGIN_REGISTRY_URL.

Les entrées du registre utilisent cette forme :

{
  extensions: [
    {
      « nom »: team-calendar,
      version: 0.8.5,
      "description": Planifier des réunions sur un calendrier d'équipe,
      auteur: Exemple d'équipe,
      capacités: [outil],
      "url": https://example.invalid/team-calendar-0.2.0.zip,
      sha256: "sha256:<hachage hexadécimal du zip>"
    }
  ]
}

L’archive doit contenir soit un manifest.toml au niveau racine, soit un répertoire de plugin imbriqué contenant manifest.toml. Les archives avec des chemins de traversée, des chemins absolus, des chemins préfixés par un lecteur Windows, ou plus d’un manifeste sont rejetées avant l’installation. Les téléchargements sont plafonnés pendant le streaming, de sorte qu’un serveur sans Content-Length ne peut pas forcer ZeroClaw à mettre en tampon une archive trop volumineuse. L’extraction est également plafonnée, de sorte qu’une archive compressée ne peut pas s’étendre sans limite dans la zone d’installation temporaire.

La recherche est une découverte non authentifiée. L’installation est la frontière de sécurité : les installations depuis le registre utilisent la politique de signature des plugins configurée et les clés d’éditeurs de confiance, de la même façon que les installations de plugins locaux via PluginHost::install.

Disposition de plugin avec compétences uniquement (bundle markdown)

Un plugin dont la seule capacité est skill fournit des skills dans un répertoire skills/ au format agentskills.io et omet wasm_path :

my-toolkit/
  manifest.toml              # declares the skill capability, no wasm_path
  README.md                  # optional bundle-level overview
  skills/
    design-review/
      SKILL.md
      scripts/
      references/
    code-review/
      SKILL.md
    data-analysis/
      SKILL.md
      references/

Chaque SKILL.md doit inclure un frontmatter YAML avec les champs name et description ; le runtime rejette les bundles dont les skills omettent l’un ou l’autre au moment de la découverte plutôt qu’à la première invocation. Les skills s’enregistrent sous des ID à espace de noms de plugin de la forme plugin:<plugin-name>/<skill-name> (par exemple plugin:my-toolkit/design-review) afin d’éviter les collisions avec les skills créés par l’utilisateur et entre les bundles.

Format du manifeste

Le manifeste est le fichier nommé manifest.toml dans le répertoire du plugin. Ses champs sont la surface serde de PluginManifest dans crates/zeroclaw-plugins/src/lib.rs, qui est la source de vérité :

ChampObligatoireSignification
nameouiSlug canonique unique du package et composant du package de chaque clé de configuration d’instance dérivée. Il ne constitue pas lui-même une clé de configuration d’opérateur. Utilisez 1–128 caractères ASCII en minuscules ; commencez et terminez par [a-z0-9], avec uniquement [a-z0-9._-] entre les deux. La découverte rejette les noms non valides ou en double.
versionouiChaîne de version, p. ex. 0.1.0.
descriptionnonDescription lisible affichée par zeroclaw plugin list.
authornonNom de l’auteur ou de l’organisation.
wasm_pathpour les capacités WASMNom du fichier du composant, relatif au répertoire du plugin. Obligatoire sauf si la seule capacité est skill. La découverte ignore le plugin si le fichier nommé n’existe pas.
capabilitiesoui, non videCe qu’est le plugin : l’un de tool, channel, memory, observer, skill (PluginCapability, sérialisé en snake_case).
permissionsnonServices de l’hôte auxquels le code peut accéder : http_client, config_read, file_read, file_write, memory_read, memory_write (PluginPermission). Seuls les deux premiers sont appliqués aujourd’hui ; les autres sont acceptés mais inactifs. Déclarer config_read nécessite config_schema, et seuls les adaptateurs d’outils/de canaux le fournissent actuellement.
config_schemaexactement avec config_readRédigez un schéma JSON Draft 2020-12 pour la configuration privée de ce plugin ; il est inclus dans les octets du manifeste canonique et est donc couvert lorsque le manifeste est signé. La racine doit être un objet avec un mappage properties et additionalProperties = false. Chaque propriété de premier niveau doit avoir un type pris en charge explicite, directement ou via un pointeur JSON local : string, boolean, integer, number, array ou object. Les consommateurs d’outils et de canaux peuvent définir x-secret = true directement sur une propriété de type chaîne de premier niveau pour la retirer de la configuration publique et l’exposer via l’import d’hôte à portée limitée secrets.get. Les outils reçoivent la configuration publique sous __config et peuvent lire les secrets pendant execute. Les canaux lisent l’objet public actuel via config.get et les secrets via secrets.get pendant configure et les appels opérationnels ; ces deux imports sont indisponibles lors de l’instanciation et de la découverte des métadonnées statiques. Les marqueurs secrets imbriqués, false ou non booléens, ainsi que les propriétés secrètes qui ne sont pas de type chaîne sont rejetés. Un schéma sans config_read, ou config_read sans schéma, est rejeté.
signaturenonSignature Ed25519 en Base64url sur les octets canoniques du manifest. Défini lors de la signature pour la distribution.
publisher_keynonClé publique Ed25519 encodée en hexadécimal du signataire.

Déclarez uniquement les permissions effectivement utilisées par le code. Une permission non déclarée est une surface d’hôte que le composant ne peut pas atteindre ; une permission déclarée de manière superflue est une surface d’attaque que vous avez sollicitée et une charge d’audit pour quiconque révise votre plugin.

Les valeurs des opérateurs restent des chaînes dans plugins.entries et sont chiffrées lors de leur persistance, avec pour clé une chaîne zpi1_… versionnée dérivée de l’identité du package détenu par l’hôte, de la capacité et de la liaison (l’installation affiche et initialise la clé d’instance complète de la liaison d’outil par défaut) : les chaînes sont stockées telles quelles, les booléens et les nombres sont représentés par du texte scalaire JSON, et les tableaux et les objets par du texte JSON. Avant l’exécution de tout code invité, l’hôte matérialise ces chaînes selon les types du schéma du package et valide l’objet complet pour les adaptateurs d’outils et de canaux. Les propriétés non secrètes de l’outil constituent __config ; un canal obtient l’objet non secret via config.get. Une propriété marquée x-secret = true est exclue des deux surfaces publiques et n’est accessible que via secrets.get("property") dans un cadre de service autorisé. Les lectures publiques et secrètes d’un canal au cours d’un même appel partagent une révision canonique unique, et l’hôte supprime cette vue matérialisée à la fin de l’appel. Un plugin de canal conforme doit résoudre les deux à chaque point d’utilisation et ne doit pas conserver les valeurs de configuration ou d’identifiants dans l’état invité conservé à chaud ; renvoyer du texte en clair à l’invité signifie que l’hôte ne peut pas imposer la non-rétention face à du code malveillant. Si config_read a été demandé mais n’a pas été effectivement accordé, l’hôte valide un objet vide ; par conséquent, un schéma comportant des propriétés obligatoires échoue en mode sécurisé au lieu de démarrer sans la configuration requise. Si l’objet vide est valide, un outil omet __config lorsqu’il est vide et les imports config/secret du canal renvoient access-denied ; les appels effectués hors d’un cadre autorisé, les échecs de résolution et l’épuisement du budget d’appels à l’hôte renvoient unavailable.

Capacités

capabilities est une liste non vide de valeurs PluginCapability, définies dans crates/zeroclaw-plugins/src/lib.rs (sérialisées en snake_case). Chaque valeur sélectionne le monde WIT que le plugin exporte (tool, channel, memory), nomme un backend d’observabilité (observer), ou marque un bundle de compétences uniquement markdown (skill). Consultez l’enum pour l’ensemble canonique ; c’est la source de vérité et cette page ne le reformule pas.

Un manifeste doit déclarer au moins une capacité. wasm_path est requis pour chaque capacité, à l’exception d’un plugin dont la seule capacité est skill, qui ne transporte aucune charge utile WASM et qui est rejeté lors de la découverte s’il omet un bundle skills/ valide (validate_manifest_shape dans host.rs).

Autorisations

permissions est une liste de valeurs PluginPermission, également définies dans crates/zeroclaw-plugins/src/lib.rs. Consultez l’enum pour l’ensemble canonique.

Soyez attentif à l’écart entre ce qui est déclaré et ce qui est appliqué : dans l’hôte de composants actuel, config_read et http_client ont aujourd’hui un effet comportemental. La demande de config_read nécessite un config_schema, et la déclaration de ce schéma sans la permission correspondante est également rejetée. Avant d’utiliser un composant d’outil ou de canal, l’hôte détermine son autorisation effective, matérialise les valeurs de l’opérateur du plugin en JSON typé et valide l’objet complet. runtime.rs supprime tout __config fourni par l’appelant avant d’injecter les valeurs validées non secrètes dans un appel d’outil ; les propriétés de type chaîne directement au niveau supérieur marquées x-secret: true sont omises de la configuration publique et lues via l’import secrets de portée hôte. Les outils reçoivent ce service lors de execute. Les canaux reçoivent la configuration publique via config.get et les secrets via secrets.get pendant configure et les appels opérationnels, tandis que l’instanciation et la découverte des métadonnées statiques restent indisponibles. http_client est une autorisation nécessaire, mais ne constitue pas à lui seul une décision d’autorisation complète : l’adaptateur de capacité doit également construire le contexte HTTP et lier wasi:http. Les adaptateurs d’outils et de canaux s’activent après validation de l’autorisation. L’adaptateur mémoire s’en abstient délibérément ; ainsi, accorder http_client au seul périmètre mémoire n’ajoute aucune surface réseau. Les variantes restantes (file_read, file_write, memory_read, memory_write) sont acceptées par le schéma du manifeste, mais ne sont pas encore reliées à un import hôte : leur déclaration n’accorde rien à elle seule. Elles réservent les noms pour les fonctions hôte qui les contrôleront (voir Imports hôte ci-dessous).

interfaces WIT

Le contrat de plugin est l’ensemble des fichiers WIT dans wit/v0/, package zeroclaw:plugin@0.1.0. Chaque élément est soumis à @unstable(feature = plugins-wit-v0) jusqu’à ce que le package se stabilise ; voir wit/VERSIONING.md pour les règles de compatibilité. Les interfaces ci-dessous sont résumées à titre d’orientation ; les fichiers .wit font autorité pour les signatures exactes.

Mondes

wit/v0/ définit trois mondes, liés par bindgen! dans component.rs. Chacun importe logging (hôte) et exporte plugin-info ainsi que son interface principale : tool-plugin exporte tool, channel-plugin exporte channel et memory-plugin exporte memory. Tool importe également secrets ; channel importe config, secrets et inbound. Les exports obligatoires (sans valeur par défaut) de chaque monde sont répertoriés dans le commentaire de documentation du monde dans son fichier .wit.

interface tool

wit/v0/tool.wit définit la surface d’outil unique. L’hôte appelle name, description et parameters-schema une fois au moment du chargement, puis dispatche execute par invocation :

record tool-result {
    success: bool,
    output: string,
    error: option<string>,
}

name: func() -> string;
description: func() -> string;
parameters-schema: func() -> json-string;
execute: func(args: json-string) -> result<tool-result, string>;

parameters-schema renvoie une chaîne JSON Schema présentée au LLM pour l’appel d’outils. execute reçoit des arguments encodés en JSON correspondant à ce schéma et renvoie un tool-result ou une chaîne d’erreur. json-string est un alias de type string provenant de wit/v0/types.wit ; les appelants produisent du JSON valide, les récepteurs le parsent.

channel et memory interfaces

wit/v0/channel.wit et wit/v0/memory.wit définissent des surfaces contrôlées par des capacités. L’hôte appelle get-channel-capabilities / get-memory-capabilities une fois au chargement, et pour chaque flag non défini il utilise la valeur par défaut du trait Rust au lieu d’appeler le plugin. Un plugin doit toujours exporter chaque fonction (un stub renvoyant la valeur par défaut documentée est suffisant) ; l’hôte n’appelle simplement jamais celles dont le flag est absent. La valeur par défaut à laquelle se résout chaque flag non défini est documentée en ligne dans le WIT à côté des flags *-capabilities, qui est la source de vérité à la fois pour l’ensemble des flags et leurs valeurs par défaut.

Indicateurs de capacité

Les méthodes optionnelles sont annoncées via flags channel-capabilities et flags memory-capabilities. Parce que les flags sont un masque de bits, de nouvelles méthodes optionnelles peuvent être ajoutées à un package vN/ sans changement cassant, associées à une nouvelle fonction @since. Supprimer ou renommer un flag, une fonction, un champ ou un cas de variante est un changement cassant et nécessite un nouveau répertoire vN+1/.

Importations de l’hôte

Les fonctions hôtes sont importées par le plugin et fournies par l’environnement d’exécution. L’éditeur de liens de chaque world relie logging (via l’implémentation hôte dans component_logging.rs, liée aux côtés de add_wasi dans component.rs). Tool et channel relient le service secrets propre à l’instance. Channel importe également config pour son objet public typé et inbound pour la file de messages alimentée par l’hôte qu’il vide via poll-message. Les adaptateurs de Tool et channel ne relient wasi:http en sortie qu’une fois que la portée admise a accordé http_client (PluginStoreSpec::with_granted_http et add_wasi_http dans component.rs). Memory ne reçoit ni le contexte ni l’interface de l’éditeur de liens. Les permissions d’accès au système de fichiers et à la mémoire restent inactives : les fonctions hôtes qui devraient les contrôler ne sont pas encore reliées à l’éditeur de liens. L’autorité ambiante d’un plugin est le contexte WASI (aucune préouverture, aucun réseau ambiant), auquel s’ajoutent exactement les imports hôtes activés conjointement par ses autorisations et les activations explicites de ses adaptateurs.

Les imports gérés par ZeroClaw partagent un budget de sécurité fixe par trame de service distribuée par l’hôte. Le plafond de référence est MAX_HOST_CALLS_PER_FRAME dans crates/zeroclaw-plugins/src/component.rs. À l’épuisement, la journalisation devient une opération sans effet, l’interrogation des entrées renvoie un résultat vide, et les lectures de la configuration publique ou des secrets renvoient unavailable. Une nouvelle trame réinitialise le budget. Ce plafond relève d’une politique d’hôte fixe, et non d’une configuration opérateur dupliquée.

inbound

wit/v0/inbound.wit est importé par le world channel-plugin. Un plugin de canal s’exécute sans écouteur propre, donc l’hôte exécute l’écouteur (un serveur webhook, un tunnel fournisseur, un client de polling) et met en file d’attente chaque message reçu. Le plugin vide la file d’attente depuis son export poll-message en appelant inbound-poll, avec inbound-pending disponible pour vider par lots :

inbound-poll: func() -> option<host-inbound-message>;
inbound-pending: func() -> u32;

Le côté hôte possède une InboundQueue par canal ; WasmChannel::inbound remet un clone à la tâche d’écoute afin que le trafic mis en file d’attente soit visible pour le drain du plugin.

logging

wit/v0/logging.wit est importé par les trois worlds. Les plugins appellent log-record pour émettre des événements structurés vers l’hôte :

log-record: func(level: log-level, event: plugin-event);

L’appel est sans attente : il ne renvoie rien et l’hôte (component_logging.rs) absorbe toutes les erreurs, de sorte qu’une écriture de journalisation échouée ne peut jamais faire planter l’exécution du plugin. La livraison est asynchrone : l’import remet l’enregistrement à une file d’attente bornée côté hôte, vidée par un thread dédié, et retourne sans bloquer, de sorte qu’un consommateur de journaux lent ou bloqué ne peut jamais maintenir une exportation du guest bloquée au-delà de plugins.limits.call_timeout_ms. Le fait de différer l’opération ne modifie pas la signification d’un événement : chaque enregistrement capture le span hôte actif au point d’appel du guest et est écrit dans ce contexte, si bien que l’attribution de l’agent, du canal et de l’outil, ainsi que le libellé terminal, correspondent à l’émission inline. La limite est une véritable limite mémoire, car les champs des événements sont des chaînes non bornées copiées dans la mémoire de l’hôte, en dehors du plafond max_memory_mb du guest : un enregistrement dont le nombre d’octets contrôlés par le guest dépasse 64 KiB est abandonné plutôt que tronqué, et les enregistrements en file d’attente consomment un budget fixe de 8 MiB au total en octets, qui n’est libéré qu’après l’écriture d’un enregistrement. Une file pleine, un enregistrement dépassant la limite ou un budget épuisé entraînent tous l’abandon de l’enregistrement le plus récent ; le thread de vidage signale le nombre cumulé d’enregistrements abandonnés après chaque écriture et lors d’un réveil en période d’inactivité, afin que la perte reste observable, même si aucun enregistrement accepté ne suit jamais les enregistrements rejetés. plugin-action et plugin-outcome reprennent les taxonomies fermées Action / EventOutcome de zeroclaw-log ; aucune variante fourre-tout n’est prévue, volontairement. N’appelez pas directement wasi:logging : les événements des plugins seraient formatés de manière incohérente et n’atteindraient pas toutes les destinations vers lesquelles écrit zeroclaw_log.

config

wit/v0/config.wit est importé par le monde de canal. Il renvoie l’objet actuel validé par rapport au schéma et ne contenant aucun secret au format JSON :

get: func() -> result<json-string, config-error>;

L’objet conserve les types déclarés par config_schema ; les propriétés marquées x-secret: true sont omises. Le service est disponible pendant configure et les exports de canaux opérationnels. Il renvoie access-denied lorsque l’instance admise ne dispose pas de l’autorisation effective config_read. Les appels effectués pendant l’initialisation du composant ou la découverte des métadonnées statiques, en cas d’échec du résolveur ou de la validation, et en cas d’épuisement du budget d’appels à l’hôte renvoient unavailable sans divulguer de détails internes.

config.get est un accès au point d’utilisation, et non un instantané pris au moment du chargement. Un plugin de canal conforme doit l’appeler dans chaque opération qui utilise la configuration et ne doit pas conserver l’objet qu’il renvoie dans l’état à chaud du code invité. Il s’agit d’une règle de conformité des plugins : après avoir renvoyé du JSON à du code invité de confiance, l’hôte ne peut pas empêcher un composant malveillant de le copier.

secrets

wit/v0/secrets.wit est importé par les mondes tool et channel. Le composant invité fournit uniquement un nom de propriété de niveau supérieur :

get: func(name: string) -> result<string, secret-error>;

L’hôte déduit le package, les capacités, les liaisons et les autorisations effectives à partir du PluginInstanceScope admis ; aucun de ces éléments ne provient de l’invité. Seules les propriétés de type chaîne situées directement au niveau supérieur et marquées x-secret: true dans le schéma du manifeste sont lisibles. Les outils peuvent les lire pendant le traitement de execute par l’hôte. Les canaux peuvent les lire pendant configure et lors d’appels opérationnels tels que send, poll, health, ainsi que lors d’actions protégées par des capacités. L’initialisation des composants et les exports de métadonnées statiques renvoient unavailable sans résoudre la configuration. Dans un même contexte de service de canal, chaque config.get et secrets.get utilise une seule révision canonique résolue de la configuration ; ce contexte est détruit quel que soit le chemin de sortie. Un plugin conforme observe donc, lors de son opération suivante, la rotation simultanée des valeurs publique et secrète d’une même liaison. Les états access-denied, not-found et unavailable ne révèlent délibérément aucun détail sur le résolveur ou le schéma. Les lectures réussies renvoient le texte en clair à l’invité de confiance. Le service empêche l’injection de valeurs publiques et la sélection inter-instance ; ce n’est pas un proxy de sortie qui garde la valeur cachée du code du plugin. Un plugin de canal conforme doit résoudre les secrets à chaque point d’utilisation et ne doit pas en conserver une seconde copie dans l’état à chaud. L’hôte ne peut pas imposer la non-conservation une fois le texte en clair renvoyé.

Configuration par plugin (__config et config.get)

Autorisation : config_read

Un plugin ne lit pas les variables d’environnement du processus. Son manifeste doit associer config_read à un config_schema Draft 2020-12 ; l’un sans l’autre constitue un manifeste non valide. La racine du schéma doit être un objet doté d’une table de correspondance properties et de additionalProperties = false. Chaque propriété de premier niveau doit déclarer l’un des types string, boolean, integer, number, array ou object, directement ou via un JSON Pointer local au package. Les consommateurs d’outils et de canaux peuvent définir x-secret: true sur une propriété de type chaîne située directement au premier niveau ; les marqueurs imbriqués, ayant la valeur false ou non booléens, ainsi que les propriétés secrètes qui ne sont pas de type chaîne, sont rejetés. Les clés inconnues, les encodages mal formés et les violations de contraintes entraînent le rejet de l’instance avant la transmission des valeurs au code invité.

Les valeurs canoniques plugins.entries.<instance-key>.config de l’opérateur restent en mémoire sous la forme d’une map de chaînes marquées comme secrètes et sont chiffrées lors de leur persistance. L’hôte dérive la clé d’entrée versionnée zpi1_… à partir de l’identité complète du package, de la capacité et de la liaison ; cela permet à différents packages et mondes de capacités de réutiliser en toute sécurité des alias tels que main. Le manifeste du package autorisé sélectionne le schéma. Une valeur string est stockée directement ; les valeurs boolean, integer et number utilisent un texte scalaire JSON tel que "true", "4" ou "0.5" ; les valeurs array et object utilisent du texte JSON tel que '["urgent","ops"]' ou '{"region":"us-east"}'. L’hôte matérialise et valide l’ensemble des données en JSON typé à chaque utilisation, puis partitionne chaque propriété exactement une fois. Pour un outil, les valeurs non secrètes sont injectées sous la clé réservée __config :

{
  "invite": un coucher de soleil,
  __config: {
    retry_limit: 4,
    "activé": true,
    étiquettes: [urgent, "ops"]
  }
}

La propriété api_key omise est lue explicitement avec secrets.get("api_key") si son schéma la marque comme secrète. runtime.rs supprime tout __config fourni par l’appelant avant d’injecter la section publique, afin que celle-ci ne puisse pas être usurpée. L’injection de la configuration publique dans l’outil et les lectures de secrets au sein d’un même contexte d’exécution execute partagent une même révision résolue de la configuration active ; ce contexte est libéré en cas de succès, d’erreur, de trappe, de panique ou d’annulation. L’export configure d’un canal ne comporte aucun paramètre config. Il appelle config.get pour l’objet public et secrets.get pour les propriétés secrètes, tout comme chaque export opérationnel ultérieur qui utilise la configuration. Les deux imports au sein d’un même appel partagent une même révision résolue. L’hôte libère cette vue matérialisée en cas de succès, d’erreur, de trappe, de panique ou d’annulation. Les modifications de la configuration publique et des identifiants au sein de la même liaison logique sont donc disponibles conjointement lors de l’opération suivante lorsque le module invité conforme résout les deux au point d’utilisation.

Lorsque le manifeste demande config_read, mais que l’hôte ne l’accorde pas effectivement, la résolution remplace la valeur par un objet vide et valide cet objet avant l’exécution du code invité. Un schéma comportant des champs obligatoires échoue par défaut lors de la construction. Si l’objet vide est valide, les outils omettent le __config vide, tandis que les canaux config.get et secrets.get renvoient access-denied. Un plugin ne voit jamais que sa propre section.

Les exports de métadonnées statiques des canaux ne peuvent appeler aucun des deux services de configuration et sont lus une seule fois au chargement. Modifier l’identité d’un bot ou d’un compte, ou toute capacité, tout auto-identifiant, toute mention ou tout délai entre plusieurs messages dérivé de la configuration, nécessite donc de reconstruire le cycle de vie du canal ; la configuration publique ordinaire et la rotation des identifiants pour la même liaison logique ne le nécessitent pas. Tool et channel sont les consommateurs actuels de la configuration. Le monde memory ne dispose pas encore d’un import de configuration ; les plugins memory ne doivent donc pas demander config_read tant que cette ABI et son intégration au runtime ne sont pas disponibles.

Hôte de composants WASI

L’hôte (crates/zeroclaw-plugins/src/component.rs) compile et instancie les composants avec un unique wasmtime::Engine asynchrone. La façon dont un fichier .wasm est chargé dépend du backend d’exécution du build :

  • plugins-wasm-cranelift: un backend JIT est présent, donc load_component compile un composant .wasm lors du chargement via Component::from_file.`
  • Aucun backend JIT (plugins-wasm-pulley ou runtime uniquement) : il n’y a pas de compilateur dans le binaire, donc load_component désérialise directement le fichier via Component::deserialize_file, en le traitant comme un .cwasm précompilé produit par un wasmtime correspondant. Un artefact incompatible est rejeté par le contrôle de version de la désérialisation.

Les deux fonctionnalités back-end incluent plugins-wasmtime ; le chemin de chargement dépend de la présence du compilateur cranelift dans le build, et non de pulley.

Limites d’exécution par appel

Chaque export de l’invité s’exécute avec des limites de ressources par appel que l’hôte applique au store. Le moteur active le comptage du fuel, et chaque appel reçoit un nouveau budget de fuel, afin qu’un composant hors de contrôle ou malveillant déclenche un trap plutôt que de bloquer l’hôte. L’hôte applique également une échéance en temps réel autour de la future complète de l’export, y compris le temps passé à attendre des imports hôte asynchrones tels que wasi:http ; des cessions périodiques de contrôle liées au fuel empêchent un calcul invité ininterrompu de monopoliser ce minuteur, et les imports hôte accessibles depuis l’invité ne bloquent jamais l’exécuteur (les enregistrements de journal sont transmis à une file d’attente bornée et écrits par un thread hôte dédié), de sorte que l’échéance reste observable pendant l’exécution du travail de l’hôte. Un plafond StoreLimits borne la mémoire linéaire, les éléments des tables et le nombre d’instances. Le monde de l’outil reçoit un store neuf à chaque exécution ; le canal chaud et les stores de mémoire sont réapprovisionnés en fuel avant chaque appel, afin qu’un plugin de longue durée de vie dispose d’un budget neuf au lieu de l’épuiser au cours de sa durée de vie.

Les cinq limites peuvent être configurées par l’opérateur et chaque valeur est validée comme étant non nulle : plugins.limits.call_fuel (par défaut, 1 000 000 000 unités d’instructions), plugins.limits.call_timeout_ms (par défaut, 30 000 millisecondes), plugins.limits.max_memory_mb (par défaut, 256), plugins.limits.max_table_elements (par défaut, 100 000) et plugins.limits.max_instances (par défaut, 64). Un store ne peut être construit qu’avec des limites explicites, de sorte qu’aucun chemin de chargement ne puisse construire un plug-in sans sandbox. Les options de requête wasi:http de l’invité peuvent mettre fin à un appel plus tôt, mais ne peuvent pas repousser l’échéance définie par l’hôte. Un store préchauffé interrompu n’est jamais repris : les canaux le recréent à partir d’entrées appartenant à l’hôte lors de l’appel suivant, tandis que les instances mémoire restent indisponibles jusqu’à ce que leur propriétaire les reconstruise. Les champs canoniques et les valeurs par défaut figurent dans la référence de configuration.

Espace d’adressage 32 bits (wasip2 est wasm32)

La cible du plugin est wasm32-wasip2, et le moteur hôte est construit avec la mesure de fuel activée (Config::consume_fuel(true)) sans wasm_memory64. La frontière du plugin est un format 32 bits fixe, et cela a des conséquences qu’il convient d’énoncer clairement :

  • L’espace d’adressage de l’invité est de 32 bits. Un plugin s’exécute dans une mémoire linéaire wasm32. Les grandes valeurs franchissent la limite par valeur : le media-attachment d’un plugin de canal transporte l’intégralité de ses octets sous forme de list<u8>, et wit/v0/channel.wit indique déjà qu’il peut s’agir de plusieurs mégaoctets et reporte un modèle resource-handle à une version ultérieure. Dans cet espace 32 bits, l’hôte applique un plafond mémoire explicite par store défini via plugins.limits.max_memory_mb (valeur par défaut : 256), de sorte qu’un invité est limité par la plus petite valeur entre l’espace d’adressage wasm32 et ce plafond configuré pour ZeroClaw.
  • L’ABI du composant représente les décalages sur 32 bits, quelle que soit la taille du mot hôte. Même sur un hôte 64 bits, les décalages des listes et des chaînes dans l’ABI canonique sont de type i32. memory64 étend l’adressage de la mémoire linéaire de l’invité, et non l’ABI canonique du modèle de composant ; son activation ne ferait donc pas passer les champs de niveau WIT sur 64 bits.
  • Il n’existe pas de cible 64 bits pour wasip2 contre laquelle se lier. wasm32-wasip2 est la seule cible WASI Preview 2 dans rustc et LLVM à ce jour ; un plugin ne peut pas être compilé en un composant p2 64 bits, il n’y a donc rien à charger pour l’hôte même si le moteur active memory64.

Il s’agit d’une contrainte de la toolchain amont, et non d’une limitation de l’hôte qu’un flag de ce dépôt peut lever. Lorsqu’une cible p2 64 bits et une ABI de composant plus large arriveront en amont, la jointure bindgen! se régénèrera par rapport à elles et les largeurs de champs seront réexaminées dans le WIT sous la fenêtre wit/VERSIONING.md. D’ici là, traitez la frontière du plugin comme 32 bits par construction.

Signatures

Les manifestes de plugin peuvent contenir une signature Ed25519 (crates/zeroclaw-plugins/src/signature.rs). La signature est encodée en base64url sur les octets canoniques du manifeste (le TOML analysé, après suppression des seules entrées racine nommées exactement signature et publisher_key) ; la clé publique de l’éditeur est encodée en hexadécimal. Les propriétés imbriquées du schéma portant ces noms restent signées. L’hôte impose l’un des trois modes suivants via plugins.security.signature_mode :

ModePlugin non signéSignature non fiable ou invalide
strictrejetérejeté
permissivechargé avec un avertissementchargé avec un avertissement
disabledchargénon coché

La vérification s’exécute à la fois lors de la découverte et de l’installation. La découverte ignore un plugin qui échoue à sa politique plutôt que d’interrompre l’hôte entier ; l’installation renvoie l’erreur.

Écrire un plugin en Rust

Un plugin est une crate cdylib qui cible le modèle de composants. Générez les bindings guest à partir du même package wit/v0 utilisé par l’hôte, implémentez le monde exporté et compilez vers wasm32-wasip2. Pour les didacticiels complets allant d’un crate vide à un plugin installé, consultez les guides des plugins ; les notes ci-dessous couvrent les mécanismes de compilation et d’installation.

Construction

sh

# Installer la cible WASI Preview 2 (une seule fois)
rustup target add wasm32-wasip2

# Construire le composant
cargo build --target wasm32-wasip2 --release

Le composant de sortie se trouve à target/wasm32-wasip2/release/<crate_name>.wasm. Copiez-le à côté de votre manifest.toml. Pour un build d’hôte runtime uniquement sans backend JIT, précompilez le composant en un .cwasm avec un wasmtime correspondant et livrez celui-ci à la place, car un tel hôte désérialise plutôt que de compiler au chargement.

Les tests du plugin d’outils de l’hôte ne dépendent pas d’un artefact publié : crates/zeroclaw-plugins/tests/fixtures/tool-fixture est un composant intégré à l’arborescence, compilé à partir des sources au moment des tests, et reference_plugin.rs et reference_plugin_e2e.rs le pilotent via les mêmes chemins PluginHost, config_schema et de résolution de la configuration que ceux utilisés par le daemon. Si la fixture ne peut pas être compilée, ces tests échouent.

Installation

sh

# Copier dans le répertoire du plugin
zeroclaw plugin install /path/to/my-plugin/

# Ou manuellement
cp -r my-plugin/ ~/.zeroclaw/plugins/my-plugin/

Configuration

Les valeurs d’opérateur sont actuellement saisies via un stockage générique de paires clé-valeur sous forme de chaînes : modifiez [[plugins.entries]] dans TOML, ou utilisez zeroclaw config set après qu’une installation d’outil a initialisé son entrée de liaison par défaut. zeroclaw plugin info <package> affiche la même clé d’outil pour la migration et les modifications ultérieures. Ces mécanismes automatiques d’affichage et d’initialisation sont réservés aux outils. Une clé de canal dépend de son alias configuré, qui n’est pas géré par install ni info. La construction tenant compte de l’alias, qui résout la configuration typée d’un canal à partir de cet alias configuré, a été intégrée dans #10146 ; l’affichage automatique et l’initialisation de la clé de canal au moment de l’installation restent manuels jusqu’à la cérémonie d’octroi dans #9584, de sorte qu’un package limité à un canal ne peut toujours pas effectuer cette migration via install et info uniquement. Les formulaires pilotés par schéma et l’aide en ligne des champs ne sont pas encore implémentés. Les interfaces actuelles sont :

  • La CLI gère le cycle de vie des plugins avec list, search, install, remove, info et migrate. zeroclaw config set écrit les valeurs brutes individuelles des plugins ; elle n’interprète pas le schéma du plugin.
  • zerocode peut modifier les paramètres statiques de l’hôte de plug-ins de ZeroClaw, mais ne génère pas encore les champs propres à chaque plug-in à partir de config_schema.
  • La passerelle web est en lecture seule pour les plugins : GET /api/plugins indique les plugins chargés et si le système est activé.
  • L’hôte valide config_schema lors de l’admission du paquet et valide/matérialise à nouveau les valeurs des opérateurs avant leur utilisation par l’invité.
  • Le schéma du manifeste, pour les auteurs de plugins, est l’unique contrat de typage et de validation à la frontière avec l’invité. Définissez-y chaque clé prise en charge et chaque contrainte ; ne dupliquez pas ce contrat dans une structure de configuration d’exécution de l’hôte. Le code invité doit désérialiser le JSON validé par l’hôte dans sa structure typée native.

Le schéma de configuration statique fournit le chemin générique de stockage et de marquage des secrets, et non un éditeur dynamique propre à chaque plugin. Les types de configuration des plugins dans crates/zeroclaw-config/src/schema.rs portent #[prefix = "plugins"], #[prefix = "plugins.entries"] et #[prefix = "plugins.security"], et la dérivation Configurable transforme chaque champ préfixé en chemin de configuration générique. Les champs secrets (la map config d’une entrée de plugin est marquée #[secret]) sont chiffrés au repos sous la clé .secret_key adjacente. Les champs canoniques, les valeurs par défaut et les valeurs de signature_mode pour la configuration de l’hôte figurent dans la référence de configuration ; ce schéma est la source de vérité, tandis que chaque manifeste de plugin est la source de vérité pour la structure de sa configuration privée.

Fonctionnalités de build

L’hôte de plugins est une option d’inclusion à la compilation. Les fonctionnalités au niveau binaire dans le Cargo.toml de l’espace de travail sélectionnent si les plugins sont intégrés ou non et quel backend d’exécution est livré :

  • plugins-wasm est le parapluie qui intègre l’hôte de plugin et son intégration d’exécution dans le binaire. Chaque fonctionnalité de backend ci-dessous l’implique, donc activer n’importe quel backend d’exécution (par exemple --features plugins-wasm-cranelift) embarque toujours l’hôte de plugin et sa surface CLI ; une compilation limitée au backend ne peut pas produire silencieusement un binaire sans la sous-commande plugin. Le parapluie seul est équivalent à plugins-wasm-runtime-only : pas de JIT, donc seuls les composants .cwasm précompilés se chargent.
  • plugins-wasm-runtime-only est le plus petit et le plus rapide à démarrer : pas de JIT, donc les composants sont désérialisés à partir d’un .cwasm précompilé.
  • plugins-wasm-cranelift ajoute le JIT Cranelift, de sorte qu’un composant .wasm est compilé au chargement.
  • plugins-wasm-pulley est le plus portable, supportant la compilation sur des cibles que Cranelift ne couvre pas.

Celles-ci délèguent aux features du crate zeroclaw-plugins (plugins-wasmtime, plugins-wasm-cranelift, plugins-wasm-pulley) qui branchent wasmtime. Le chemin de chargement dépend de la présence du compilateur Cranelift dans le build, comme décrit sous WASI Component Host. Consultez les commentaires des features dans le Cargo.toml du workspace pour les descriptions faisant autorité.