Keyboard shortcuts

Press ← or → to navigate between chapters

Press ? to show this help

Press Esc to hide this help

Écrire un plugin d’outil

Ceci est le guide de niveau débutant de la série : un parcours complet depuis un crate vide jusqu’à un outil que le modèle appelle en conversation. L’outil construit ici est redact, qui masque les e-mails, les préfixes d’identifiants connus et les motifs fournis par l’opérateur dans le texte. Il est délibérément piloté par la configuration, car lire sa propre section de configuration en bac à sable est ce dont chaque plugin non trivial a besoin et ce qu’il est le plus facile de rater.

Tout ce qui figure sur cette page est vérifié par rapport au code source du contrat : le monde tool-plugin dans wit/v0/tool.wit, le chemin d’appel côté hôte dans crates/zeroclaw-plugins/src/runtime.rs et wasm_tool.rs, ainsi que la validation du manifeste dans host.rs. Les chemins vers les sources sont des références au dépôt ZeroClaw à des fins de vérification ; le plugin en lui-même est votre propre crate dans votre propre dépôt. Vous n’avez jamais besoin d’un checkout de ZeroClaw pour en construire un, seuls les fichiers de contrat wit/ (récupérés à l’étape 1) et un binaire zeroclaw installé avec l’hôte de plugin compilé sont requis pour l’exécuter.

Vérifiez quel binaire vous avez. Les archives de version pour les cibles 64 bits de bureau et serveur contiennent l’hôte de plugins et le compilateur qui charge les composants .wasm, et chaque version exécute un plugin depuis chacune d’elles avant publication. Les archives ARM 32 bits et l’archive Android expérimentale ne les contiennent pas (zeroclaw plugin ... est une sous-commande non reconnue sur ces cibles), et plugins-wasm ne fait pas partie des fonctionnalités par défaut de la crate, donc une compilation depuis les sources avec les valeurs par défaut de Cargo n’a pas non plus d’hôte. Pour ces cas, compilez la partie hôte depuis les sources avec un backend d’exécution ; chaque fonctionnalité de backend porte elle-même la fonctionnalité parapluie plugins-wasm, donc un seul indicateur suffit :

cargo build --release --features plugins-wasm-cranelift

La page du protocole documente les choix de backend et liste la prise en charge des plugins par artefact de version.

Déroulement d’un appel d’outil

Comprenez la forme à l’exécution avant d’écrire du code :

  1. Au démarrage, la découverte localise le répertoire de votre plugin, valide la structure du manifeste, applique la politique de signature, puis valide config_schema. Avant l’enregistrement, l’hôte matérialise les valeurs d’opérateur du plugin en JSON typé et les valide. Les éléments retenus deviennent des instances de WasmTool.
  2. Lors de l’enregistrement, l’hôte instancie le composant une fois pour lire name, description et parameters-schema. Ces éléments sont mis en cache ; ils ne sont jamais redemandés. Si cette sonde échoue, l’enregistrement échoue ; l’hôte ne substitue jamais de métadonnées synthétiques à un composant défaillant.
  3. À chaque appel, WasmTool::execute résout et valide la configuration à partir de l’état canonique, crée un nouveau store (un nouveau contexte WASI, un nouveau budget de fuel, sans état de l’appel précédent) et instancie le composant. Cet objet résolu unique sert pendant toute la trame : l’hôte n’injecte que ses valeurs non secrètes sous __config, fournit les secrets marqués par le schéma via l’import secrets limité à cette portée, et appelle execute.

Le modèle fresh-store-per-call est la contrainte de conception qui importe le plus : un plugin d’outil est sans état par construction. Tout ce que vous voulez faire persister entre les appels doit vivre en dehors du plugin (dans le texte que vous renvoyez, ou dans la config de l’opérateur).

1. Configuration du crate

Créez le crate et ajoutez les dépendances côté invité :

cargo new --lib my-plugin
cd my-plugin
cargo add wit-bindgen@0.46
cargo add serde --features derive
cargo add serde_json

Ensuite, effectuez deux modifications manuelles au manifeste du package :

  1. Définissez le crate-type de la bibliothèque à ["cdylib", "rlib"]. cdylib est ce que produit le build du composant ; rlib permet aux modules de pure logique du même crate de se compiler et d’être testés unitairement nativement sur l’hôte.
  2. Dans le profil release, définissez opt-level = "s", lto = true et strip = true. La taille du composant impacte le temps de téléchargement et de chargement ; il n’y a aucune raison de distribuer les symboles de débogage au-delà de la frontière du plugin.

Copiez le répertoire wit/v0/ du dépôt ZeroClaw à la racine du crate sous wit/. Vous n’avez pas besoin d’un checkout complet ; ne récupérez que ce répertoire depuis le tag correspondant à la version de votre hôte cible :

git clone --depth 1 --filter=blob:none --sparse \
  https://github.com/zeroclaw-labs/zeroclaw /tmp/zeroclaw-wit
git -C /tmp/zeroclaw-wit sparse-checkout set wit
cp -r /tmp/zeroclaw-wit/wit .

Les fichiers WIT sont l’ABI : l’hôte a généré ses bindings à partir de ces fichiers exacts, donc vos bindings guest doivent provenir des mêmes. Épinglez la version : les mondes WIT évoluent avec l’hôte, et un composant construit contre des mondes plus récents que ceux que l’hôte lie échouera à s’instancier.

2. Séparer la logique du code de liaison

Placez le comportement réel dans un module Rust simple sans imports wit-bindgen, et gardez la glue du composant mince. La raison est la testabilité : la cible composant ne peut pas exécuter cargo test nativement, donc la logique piégée dans la glue est une logique que vous ne pouvez vérifier que de bout en bout via un hôte wasm. La glue doit être trop mince pour être fausse.

src/redact.rs contient une structure de configuration et une fonction pure :

#![allow(unused)]
fn main() {
pub const DEFAULT_REPLACEMENT: &str = [RÉDACTÉ];

/// Politique de masquage résolue depuis la section de configuration du plugin.
#[derive(Debug, serde::Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct RedactConfig {
    pub replacement: String,
    pub redact_emails: bool,
    pub patterns: Vec<String>,
}

impl Default for RedactConfig {
    fn default() -> Self {
        Self {
            replacement: DEFAULT_REPLACEMENT.to_string(),
            redact_emails: true,
            patterns: Vec::new(),
        }
    }
}

/// Masquer l'entrée. Renvoie la sortie et le nombre de segments masqués.
pub fn redact(input: &str, cfg: &RedactConfig) -> (String, usize) {
    // Masquer les e-mails si cfg.redact_emails, les préfixes d'identifiants
    // (sk-, ghp_, AKIA, xoxb-), et chaque littéral de cfg.patterns,
    // en remplaçant chaque correspondance par cfg.replacement.
    // ...
}
}

Le code invité reçoit l’objet JSON public matérialisé par le schéma ; désérialisez-le donc une seule fois au lieu de répéter l’analyse de la chaîne. Le schéma de cet exemple rend chaque champ facultatif, et Default contrôle leur comportement lorsque l’hôte fournit {}. Un objet vide est normal lorsque l’opérateur n’a pas configuré le plugin ou lorsque l’hôte refuse l’autorisation config_read demandée. Si un plugin ne peut pas fonctionner sans une valeur, indiquez-la comme obligatoire dans config_schema ; l’hôte rejettera alors un objet vide avant le démarrage du code invité.

3. Implémenter le monde

wit/v0/tool.wit définit la surface que vous devez exporter. Le world est :

world tool-plugin {
    import logging;
    import secrets;
    export plugin-info;
    export tool;
}

et l’interface tool se compose de quatre fonctions :

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>;

src/lib.rs génère les liaisons guest et implémente les deux exports :

#![allow(unused)]
fn main() {
pub mod redact;

#[cfg(target_family = wasm)]
mod component {
    wit_bindgen::generate!({
        path: wit/v0,
        world: tool-plugin,
        features: [plugins-wit-v0],
    });

    use crate::redact::{redact, RedactConfig};
    use exports::zeroclaw::plugin::plugin_info::Guest as PluginInfo;
    use exports::zeroclaw::plugin::tool::{Guest as Tool, ToolResult};
    use zeroclaw::plugin::logging::{
        log_record, LogLevel, PluginAction, PluginEvent, PluginOutcome,
    };

    struct RedactPlugin;

    #[derive(serde::Deserialize)]
    struct ExecuteArgs {
        text: String,
        #[serde(rename = "__config", default)]
        config: RedactConfig,
    }

    impl PluginInfo for RedactPlugin {
        fn plugin_name() -> String {
            my-redact-plugin.to_string()
        }
        fn plugin_version() -> String {
            0.1.0.to_string()
        }
    }

    impl Tool for RedactPlugin {
        fn name() -> String {
            Masquer.to_string()
        }

        fn description() -> String {
            Masquer les secrets et les PII du texte avant qu'il n'atteigne un journal, \
             un canal ou un modèle. Masque les adresses e-mail, les préfixes d'identifiants et \
             les motifs littéraux configurés par l'opérateur.
                .to_string()
        }

        fn parameters_schema() -> String {
            serde_json::json!({
                "type": "objet",
                "propriétés": {
                    « texte »: {
                        "type": "chaîne",
                        "description": Le texte à masquer.
                    }
                },
                "obligatoire": [« texte »]
            })
            .to_string()
        }

        fn execute(args: String) -> Result<ToolResult, String> {
            let parsed: ExecuteArgs = match serde_json::from_str(&args) {
                Ok(a) => a,
                Err(e) => {
                    return Ok(ToolResult {
                        success: false,
                        output: String::new(),
                        error: Some(format!(arguments invalides : {e})),
                    });
                }
            };

            let (output, count) = redact(&parsed.text, &parsed.config);

            log_record(
                LogLevel::Info,
                &PluginEvent {
                    function_name: my_redact_plugin::tool::execute.into(),
                    action: PluginAction::Complete,
                    outcome: Some(PluginOutcome::Success),
                    duration_ms: None,
                    attrs: Some(format!({{\"redactions\":{count}}})),
                    message: redacted input.into(),
                },
            );

            Ok(ToolResult { success: true, output, error: None })
        }
    }

    export!(RedactPlugin);
}
}

Points de contrat, chacun ancré dans le code source de l’hôte :

  • plugin-info est un export requis de chaque world. Il indique le nom et la version du composant. Maintenez-les synchronisés avec le manifest.
  • Les métadonnées sont lues une seule fois. call_tool_metadata dans runtime.rs lit name, description et parameters-schema lors de l’enregistrement et les met en cache. Ne les calculez pas à partir de données dynamiques ; elles ne seront jamais lues à nouveau.
  • Le schéma constitue la vue complète du modèle sur votre outil. L’hôte l’analyse au format JSON lors du chargement (tool parameters-schema is not valid JSON est un échec critique de l’enregistrement) et le transmet au LLM tel quel. Décrivez chaque propriété. Ne déclarez jamais __config dans celui-ci : cette clé est réservée à l’hôte, et l’hôte supprime toute valeur fournie par l’appelant avant l’injection, précisément pour empêcher le modèle de se faire passer pour votre opérateur.
  • success: false versus Err. Un ToolResult avec success: false est retourné au modèle en tant que réponse d’outil normale à laquelle il peut réagir (réessayer avec des arguments fixes, s’excuser, sélectionner un autre outil). Un Err(String) traverse la frontière en tant que défaillance du plugin : l’hôte le retourne sous la forme plugin execute returned error et l’appel échoue. Réservez Err pour les états vraiment cassés, et signalez les entrées invalides via success: false.
  • Journalisez via l’interface logging importée, et jamais wasi:logging. log-record est fire-and-forget ; l’hôte absorbe toutes les erreurs afin qu’une écriture de journal échouée ne puisse jamais interrompre votre appel, et les événements sont transmis à toutes les destinations vers lesquelles zeroclaw_log écrit, portant l’attribution zeroclaw.* (agent_alias, session_key, provider, channel) associée au span hôte sous lequel votre appel s’exécute. Notez que le champ attrs de plugin-event n’est pas une attribution : il s’agit du payload attributes au format libre de la ligne de journal. L’attribution est liée à l’alias et héritée du span de traçage ambiant côté hôte ; aucune donnée envoyée par un plugin ne peut la définir ou l’écraser. PluginAction et PluginOutcome sont des énumérations fermées reflétant les taxonomies de l’hôte ; aucune variante au format libre n’est prévue par conception. Sélectionnez l’option la plus proche.

4. Le __config jail

Un plugin ne lit jamais les variables d’environnement du processus et n’a jamais accès à la configuration globale. Un manifeste qui demande config_read doit également déclarer config_schema ; un schéma sans cette permission est tout aussi invalide. Le schéma est au format Draft 2020-12, sa racine doit être un objet avec une map properties et additionalProperties = false, et chaque propriété de niveau supérieur doit explicitement se résoudre en string, boolean, integer, number, array ou object.

L’hôte résout la section stockée sous la clé d’entrée de configuration versionnée dérivée du package de cette instance, de la capacité tool et de la liaison, la matérialise conformément au schéma du package, valide l’objet typé complet, puis seulement le partitionne. Seules les propriétés non secrètes sont fusionnées dans execute sous la clé réservée __config :

  • Tout __config déjà présent dans les arguments fournis par le modèle est d’abord supprimé. Le spoofing est structurellement impossible.
  • Le stockage de l’opérateur reste une map chiffrée de chaînes. Stockez les chaînes directement ; encodez les booléens et les nombres comme des scalaires JSON ("true", "4", "0.5") et les tableaux et objets au format JSON ('["secret-a","secret-b"]'). Le guest reçoit de vrais booléens, nombres, tableaux et objets JSON, et non ces chaînes de stockage.
  • Une propriété de chaîne de caractères directe de premier niveau marquée x-secret = true est exclue de __config. Lisez-la explicitement avec la fonction zeroclaw::plugin::secrets::get générée. Les marqueurs imbriqués, les marqueurs définis à false ou non booléens, ainsi que les propriétés secrètes qui ne sont pas de type chaîne font échouer l’admission du manifeste.
  • L’hôte n’autorise la lecture des secrets que lors de l’exécution de execute. Les appels provenant de l’initialisation du composant ou des exportations de métadonnées renvoient unavailable sans résoudre la configuration. Les accès publics à __config et les lectures de secrets au cours d’une même exécution utilisent la même vue de configuration résolue.
  • Si config_read a été demandé mais n’a pas été effectivement accordé, l’hôte résout {} et le valide. Le schéma facultatif de cet exemple amène donc l’outil à omettre __config, et #[serde(default)] sélectionne RedactConfig::default. Un schéma obligatoire échoue en mode sécurisé au lieu de s’exécuter sans identifiants.
  • Les clés inconnues, les encodages JSON invalides, les types incorrects et les échecs liés aux contraintes du schéma rejettent le plugin avant l’exécution de son code. Les opérateurs définissent actuellement les valeurs sous la clé d’instance affichée lors de l’installation via TOML ou le chemin générique zeroclaw config set ; ces valeurs sont chiffrées au repos à l’aide de la clé secrète de la configuration. Les éditeurs zerocode et de passerelle pilotés par schéma relèvent de futurs travaux sur le SDK et la surface de configuration.

Pour cet outil, la section typée comporte trois clés facultatives : replacement est une chaîne, redact_emails est un booléen et patterns est un tableau de chaînes.

5. Le 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.
wasm_sha256dans le mode strict pour les capacités WASMEmpreinte SHA-256 hexadécimale des octets exacts du composant. Lorsqu’elle est présente, elle est toujours appliquée ; la politique de signature stricte l’exige afin que le manifeste signé lie l’exécutable admis.
capabilitiesoui, non videCe qu’est le plugin : l’un de tool, channel, memory, observer, skill (PluginCapability, sérialisé en snake_case).
providesnonPour un plugin channel qui est un remplaçant direct (drop-in) d’un canal intégré à la compilation : l’identifiant de configuration en snake_case qu’il reproduit (telegram, gmail_push). L’hôte admet une instance logique par [channels.<id>.<alias>] configuré et activé qu’un agent activé route vers, de sorte que la configuration canonique du canal reste le seul emplacement de ces paramètres. Nécessite config_read. La famille plugin est réservée et refusée ; un identifiant non défini par cette version du build est refusé ; deux paquets installés revendiquant le même identifiant sont tous deux refusés. Absent (valeur par défaut) signifie un canal inédit configuré depuis [channels.plugin.<alias>]. Les miroirs sont admis mais pas encore construits ; voir le plan d’activation dans crates/zeroclaw-runtime/src/plugin_runtime.rs.
permissionsnonServices hôtes auxquels le code peut accéder : http_client, config_read, state_read, state_write, file_read, file_write, memory_read, memory_write (PluginPermission). Les permissions HTTP, de configuration et d’état d’instance exacte sont appliquées pour les adaptateurs d’outils et de canaux ; les permissions de système de fichiers et de mémoire restent sans effet. La déclaration de config_read nécessite config_schema.
config_schemaexactement avec config_readBrouillon 2020-12 de JSON Schema pour la configuration privée de ce plugin ; il est inclus dans les octets canoniques du manifeste et est donc couvert lors de la signature du manifeste. La racine doit être un objet avec une map properties et additionalProperties = false. Chaque propriété de premier niveau doit avoir un type explicite pris en charge, directement ou via un JSON Pointer 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é string de premier niveau afin de la retirer de la configuration publique et de l’exposer via l’import hôte secrets.get limité à sa portée. Un nom de propriété secrète est un nom ASCII portable de 1 à 128 octets ne contenant que des lettres, des chiffres, _, - ou .. Les outils reçoivent la configuration publique sous __config et peuvent lire les secrets pendant execute. Les canaux lisent l’objet public courant via config.get et les secrets via secrets.get pendant configure et les appels opérationnels ; les deux imports sont indisponibles pendant l’instanciation et 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 non string, sont rejetés. Un schéma sans config_read, ou un 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.
sortienonTable [egress] facultative contenant une liste hosts : les destinations que ce plugin déclare requérir, sous forme d’hôtes exacts (api.example.com) ou de motifs de suffixe explicites (*.example.com, qui correspondent aux sous-domaines mais pas au domaine apex). Elle fait partie des octets canoniques du manifeste : elle est donc couverte lors de la signature du manifeste, et toute modification exige une nouvelle signature. Une grammaire invalide rejette l’intégralité du manifeste lors de la découverte et de l’installation. L’absence de la table, une table vide et une table qui « ne déclare rien » correspondent à un même état.

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.

[egress] est une déclaration, et non une autorisation. Rien dans un manifeste n’accorde d’accès réseau : la liste d’autorisation appliquée par l’hôte est plugins.entries[].egress_hosts de l’opérateur, sur la ligne zpi1_… de l’instance elle-même. Ainsi, un composant non signé qui écrit sa propre table [egress] n’atteint toujours rien. Ce que la déclaration apporte, c’est le processus d’installation : zeroclaw plugin install l’insère dans une ligne qu’il crée et affiche ce qui a été accordé, et il n’élargit jamais une ligne déjà existante. Voir Déclarer et accorder l’accès réseau sortant.

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.

Pour ce plugin : name et version correspondant à ce que plugin-info indique, wasm_path désignant le fichier de composant que vous livrerez à côté, capabilities contenant exactement tool, et permissions contenant exactement config_read. N’ajoutez http_client que si votre outil effectue des appels HTTP sortants. L’adaptateur d’outil implémente wasi:http, mais ne l’associe qu’après validation de cette autorisation ; sans à la fois la prise en charge de l’adaptateur et l’autorisation, il n’y a aucune surface HTTP.

Le contrat de manifeste correspondant à RedactConfig typé est :

name = "my-redact-plugin"
version = "0.1.0"
wasm_path = "my_redact_plugin.wasm"
capabilities = ["tool"]
permissions = ["config_read"]

[config_schema]
"$schema" = "https://json-schema.org/draft/2020-12/schema"
type = "object"
additionalProperties = false

[config_schema.properties.replacement]
type = "string"
minLength = 1

[config_schema.properties.redact_emails]
type = "boolean"

[config_schema.properties.patterns]
type = "array"
items = { type = "string" }

Ces propriétés sont facultatives, conformément aux valeurs par défaut de l’invité. Pour un identifiant qui doit exister, ajoutez son nom à required dans [config_schema] ; une autorisation refusée ou une valeur manquante empêchera alors le démarrage du composant.

Outils qui appellent le réseau

On peut soutenir que la forme d’outil la plus courante dans le monde réel n’est pas une transformation pure comme redact, mais un pont vers une API externe : déclarez http_client dans le manifeste, lisez les identifiants via le service de secrets à portée limitée, puis effectuez une requête sortante. Marquez l’identifiant dans le schéma signé :

[config_schema]
required = ["api_key"]

[config_schema.properties.api_key]
type = "string"
minLength = 1
x-secret = true

L’élément manquant par rapport à ce guide est un client HTTP qui fonctionne à l’intérieur d’un composant : reqwest et les bibliothèques associées ne conviennent pas, car il n’y a pas de surface de socket, uniquement wasi:http. Un client dont le fonctionnement avec cet hôte est connu est waki, qui est bloquant et s’adapte donc directement à la signature synchrone de execute. Ajoutez-le en le conditionnant à la cible component afin que vos modules de logique pure restent testables nativement :

cargo add waki --target cfg(target_family = "wasm")

La structure d’un appel, dans execute après l’analyse de __config public :

#![allow(unused)]
fn main() {
let api_key = zeroclaw::plugin::secrets::get(api_key)
    .map_err(|_| "api_key est indisponible".to_string())?;
let resp = waki::Client::new()
    .get("https://api.example.com/search")
    .query([(q, term.as_str())])
    .header(« Autorisation », format!(Bearer {api_key}))
    .connect_timeout(std::time::Duration::from_secs(5))
    .send()
    .map_err(|e| format!(échec de la requête : {e}))?;
}

Deux faits de version qui ressemblent à des ruptures mais n’en sont pas : waki vendore son propre wit-bindgen (0.34) aux côtés de la 0.46 que vos bindings de world utilisent ; les deux coexistent, chacun générant ses propres bindings. Et waki émet des imports wasi:http@0.2.4 tandis que la baseline actuelle de la toolchain est @0.2.6 ; l’hôte lie les deux sans problème. Aucun des deux ne nécessite d’action.

Un fait de version qui constitue une rupture : le monde que vous vendorisez doit correspondre exactement au monde exporté par l’hôte. wit/v0 est expérimental et peut recevoir un nouveau variant sous le même @0.1.0 (par exemple, memory-audit est un cas de l’enum plugin-action de zeroclaw:plugin/logging). Un composant construit avec une copie plus ancienne peut se compiler sans erreur tout en échouant lors de l’instanciation. L’hôte conserve la chaîne d’erreurs de Wasmtime et ajoute un indice conditionnel sur la dérive WIT ; inspectez cette chaîne, puis comparez votre wit/v0 vendorisé avec le WIT de la révision de l’hôte que vous exécutez.

Rappelez-vous le cadre de confiance de l’aperçu : http_client est tout ou rien. Le bac à sable ne limite pas où un plugin autorisé envoie des données, donc les opérateurs exécutant la politique de signature strict font confiance à votre code, pas à une liste d’autorisation d’URL.

6. Testez la logique de manière native

Étant donné que redact.rs n’a aucune dépendance wasm, un simple cargo test suffit pour le tester sur l’hôte :

#![allow(unused)]
fn main() {
#[test]
fn empty_config_falls_back_to_defaults() {
    let cfg: RedactConfig = serde_json::from_str("{}").unwrap();
    let (out, n) = redact(Envoyez-moi un e-mail à a@b.example, &cfg);
    assert_eq!(n, 1);
    assert!(out.contains([RÉDACTÉ]));
}
}

Couvrir au minimum : le cas jail (section vide), le cas configuré et le transfert direct du texte sans rien à masquer. Chaque comportement transmis par le glue doit pouvoir être vérifié ici, sans qu’aucune toolchain wasm ne soit nécessaire.

7. Compilation

Installez la cible WASI Preview 2 une fois, puis générez le composant :

rustup target add wasm32-wasip2
cargo build --release --target wasm32-wasip2

Le composant se trouve à target/wasm32-wasip2/release/<crate_name>.wasm (les tirets dans le nom du crate deviennent des underscores). Renommez-le selon ce que déclare le wasm_path de votre manifeste lorsque vous assemblez le répertoire du plugin.

[!IMPORTANT] Les fichiers .wasm et .cwasm compilés sont des artefacts binaires, souvent de plusieurs mégaoctets chacun. Ne les versionnez pas dans un arbre source git sans Git LFS : chaque reconstruction validée en tant que blob simple gonfle l’historique du dépôt de manière permanente, et les outils de git diff/revue peinent avec eux. Traitez-les comme n’importe quelle autre sortie de compilation : ajoutez target/ et *.wasm/*.cwasm à .gitignore, et distribuez-les plutôt via un artefact de release ou une archive de registre de plugins. Si un artefact doit vraiment vivre dans l’arbre, suivez le motif avec LFS (git lfs track "*.wasm") avant le premier commit.

Si l’hôte cible est un build d’exécution uniquement (sans backend JIT compilé), il ne peut pas compiler de .wasm au chargement ; il désérialise plutôt un .cwasm précompilé. Précompilez-le avec une wasmtime CLI dont la version correspond à celle de l’hôte et fournissez le .cwasm en tant qu’artefact wasm_path. Un artefact dont la version ne correspond pas sera rejeté par le contrôle de désérialisation de wasmtime et ne sera pas chargé à tort silencieusement.

8. Installer et vérifier

Ces commandes nécessitent un binaire avec l’hôte de plugins compilé. Les archives de publication pour les cibles de bureau et serveur 64 bits (Linux x86_64 et aarch64 avec glibc ou musl, macOS sur Apple silicon et Intel, Windows x86_64) le contiennent, et chaque publication exécute un plugin depuis chacune de ces archives avant de la publier. Les deux archives ARM 32 bits et l’archive Android expérimentale ne le contiennent pas, pas plus qu’une compilation depuis les sources avec les valeurs par défaut de Cargo (cargo install zeroclaw, install.sh --source, ou cargo build sans fonctionnalités) : là, zeroclaw plugin ... est une sous-commande non reconnue et les plugins installés ne sont jamais découverts. Dans ces cas, compilez depuis les sources avec un backend d’exécution de plugins, par exemple cargo build --release --features plugins-wasm-cranelift. La page du protocole indique la prise en charge des plugins pour chaque artefact.

Chaque plugin réside dans son propre sous-répertoire du répertoire des plugins (par défaut ~/.zeroclaw/plugins/, résolu via plugins.plugins_dir), contenant le manifeste et le composant dont le nom correspond au wasm_path du manifeste :

~/.zeroclaw/plugins/
└── my-plugin/
    ├── manifest.toml
    └── my-plugin.wasm

Installer à partir d’un répertoire local (cela valide la structure du manifeste et applique la politique de signature avant de copier quoi que ce soit) :

zeroclaw plugin install ./my-plugin/

Activer le système de plugins et confirmer la découverte :

zeroclaw config set plugins.enabled true
zeroclaw plugin list
zeroclaw plugin info my-plugin

zeroclaw plugin list et zeroclaw plugin info confirment qu’un package est installé et détectable, mais la découverte n’équivaut pas à l’activation. plugins.enabled = true active l’hôte de plugins ; les fonctionnalités d’outils et de compétences découvertes automatiquement sont chargées à l’exécution uniquement lorsque plugins.auto_discover = true l’est également, et cet indicateur vaut false par défaut (refus en cas d’échec) :

zeroclaw config set plugins.auto_discover true

Ainsi, plugins.enabled = true à lui seul vous fournit les canaux que vous déclarez sous [channels.plugin.<alias>], mais aucun outil ni aucune compétence de plugin : un paquet d’outils ou de compétences peut apparaître dans zeroclaw plugin list tout en n’apportant rien à l’exécution. Les liaisons de canaux explicites sont nommées par l’opérateur plutôt que découvertes automatiquement ; elles n’ont donc pas besoin de auto_discover ; cet indicateur contrôle uniquement les outils et compétences découverts automatiquement.

Un plugin absent de zeroclaw plugin list a été ignoré lors de la découverte : consultez le journal de démarrage pour l’avertissement d’ignorance (manifeste corrompu, fichier wasm_path manquant ou rejet par la politique de signature).

9. Exécutez-le

Demander à l’agent d’utiliser l’outil :

> redact this before you log it: key sk-live-abc123, mail ops@example.com

Le modèle voit redact dans son catalogue avec votre schéma, l’appelle, et l’hôte exécute le composant dans un store frais sous les limites de fuel et de mémoire configurées. Les outils de plugin ne font pas partie de l’ensemble d’auto-approbation en lecture seule intégré, donc en autonomie non totale l’appel fait apparaître l’invite d’approbation de l’opérateur comme tout autre outil privilégié ; anticipez cela dans la description de votre outil plutôt que d’en être surpris. Vos événements log-record apparaissent dans le journal structuré avec l’attribution de span du site d’appel de l’hôte.

Deux contraintes opérationnelles qu’il vaut la peine de répéter depuis la vue d’ensemble des plugins :

  • Les noms de package et d’outil ne doivent pas entrer en conflit avec un outil enregistré. L’enregistrement refuse un conflit au lieu de laisser un outil en masquer un autre. Si votre nom de package (le name du manifeste) correspond à un outil déjà enregistré, le plugin est refusé avant que son composant ne soit instancié, et l’hôte journalise un événement WARN avec error_key plugin_package_name_conflict dans ses attributes. Si le nom renvoyé par votre export name correspond à un outil existant, l’outil n’est pas enregistré et l’error_key est plugin_tool_name_conflict. Un export name contenant __ n’est jamais enregistré non plus : les outils MCP utilisent ce séparateur (<server>__<tool>), et l’error_key est plugin_tool_name_reserved. Les noms vérifiés sont les outils que la construction du registre a déjà enregistrés, y compris les outils de plugins acceptés avant le vôtre, ainsi que execute_pipeline lorsque [pipeline] enabled = true. Il ne s’agit pas d’une liste fixe de tous les noms intégrés : un outil intégré que la configuration de l’opérateur laisse non enregistré n’est pas réservé dans cette construction, si bien qu’un nom accepté sur un hôte peut être refusé sur un autre. Choisissez des noms uniques. Voir Conflits de noms d’outils pour la vue de l’opérateur.
  • Un outil par composant. Le monde tool-plugin exporte une seule interface tool. Une boîte à outils est composée de plusieurs répertoires de plugins, un par composant.

Dépannage

SymptômeCause probable
zeroclaw plugin install échoue avec does not load against this hostLe composant a été compilé avec un WIT différent de wit/v0 de cet hôte (une signature configure obsolète, un indicateur de capacité que l’hôte ne partage pas), ou pour la mauvaise cible (un module core ou wasm32-wasip1). La vérification de chargement à l’installation correspond à la même instanciation que celle exécutée par le démon au démarrage, donc rien n’a été installé. Recompilez avec le WIT livré, puis réinstallez. --no-verify installe malgré tout le composant, et le démon l’ignorera alors au démarrage avec le même diagnostic.
Plugin manquant dans zeroclaw plugin listSystème de plugins désactivé; manifeste malformé; fichier wasm_path manquant; politique de signature l’a rejeté. Le journal de démarrage contient l’avertissement spécifique d’omission.
Présent dans zeroclaw plugin list, mais l’outil ne le charge jamaisplugins.auto_discover est false (la valeur par défaut). Les capacités d’outils et de compétences découvertes automatiquement sont chargées uniquement lorsque plugins.auto_discover = true ; plugins.enabled = true active à lui seul uniquement les canaux déclarés explicitement. Exécutez zeroclaw config set plugins.auto_discover true.
Outil rejeté lors de l’enregistrementÉchec de la validation de la configuration ou de la sonde de métadonnées, le nom du package ou de l’outil entre en conflit avec un outil déjà enregistré, ou le nom de l’outil contient le séparateur MCP __. Vérifiez le journal pour l’erreur précise : un conflit de nom porte le error_key plugin_package_name_conflict ou plugin_tool_name_conflict, un nom contenant __ porte plugin_tool_name_reserved, et un échec de la sonde signifie généralement que le composant a été compilé avec un WIT incompatible.
Outil jamais sélectionné par le modèleLa description et le schéma n’indiquent pas au modèle quand l’outil s’applique. Un outil absent du catalogue du modèle relève plutôt d’un problème de chargement ; commencez par les lignes ci-dessus.
__config absent malgré une section configuréeLa portée effective a refusé config_read, l’entrée n’utilise pas la clé complète de l’instance affichée lors de l’installation, l’objet validé est vide ou chaque propriété validée est marquée comme secrète. Une incompatibilité entre config_schema et les permissions rejette plutôt le plug-in.
secrets.get renvoie not-foundLa propriété est absente ou n’est pas une chaîne de caractères directe de niveau supérieur marquée x-secret = true dans le schéma admis.
secrets.get renvoie unavailableL’appel s’est exécuté en dehors de execute, la résolution de la configuration a échoué ou l’exécution a épuisé son budget fixe d’appels à l’hôte.
L’appel échoue ou déclenche une trappeLa limite de fuel, de temps réel ou de mémoire a été atteinte. Augmentez plugins.limits.call_fuel, plugins.limits.call_timeout_ms ou plugins.limits.max_memory_mb selon le cas, ou effectuez moins de travail par appel.
Le chargement échoue sur un hôte en mode runtime uniquementVous avez livré un .wasm à un hôte sans JIT ; livrez plutôt un .cwasm correspondant à la version.

Suivant