diff --git a/config.example.toml b/config.example.toml index b9c02079a2..2bd730af80 100644 --- a/config.example.toml +++ b/config.example.toml @@ -1238,8 +1238,12 @@ exec_policy = true # Set false to remove it from the model's tool catalog. # extension_host = false # EXPERIMENTAL. Run reviewed plugins' `native` host code # (TypeScript/JavaScript, Cordis/DSH plugin model) in a - # Node.js sidecar. Tools only in this phase; every - # extension tool always asks for approval. On macOS + # Node.js sidecar. Tools only in this phase. Every + # extension tool is always Required and never + # read-only; like any Required tool it runs without + # a prompt under Full Access, under Bypass, or with a + # matching session grant, which is bound to the + # plugin's reviewed build (an update asks again). On macOS # the host runs under Seatbelt (no network, no reads of # ~/.codewhale secrets/credentials/config/sessions, # writes only to its data dir and temp dirs); on Linux diff --git a/crates/localization/locales/ca.json b/crates/localization/locales/ca.json index c4db22f5f1..0609f033bb 100644 --- a/crates/localization/locales/ca.json +++ b/crates/localization/locales/ca.json @@ -1,4 +1,11 @@ { + "CmdPluginOwnerReport": "Amfitrió d’extensions:\n Estat: {state}\n Eines actives ({count}): {tools}", + "CmdPluginOwnerActivating": "S’està activant", + "CmdPluginOwnerActive": "Actiu", + "CmdPluginOwnerFailed": "Ha fallat: {reason}", + "CmdPluginOwnerFaulted": "Amb errors: {reason}", + "CmdPluginOwnerRevoked": "Revocat", + "CmdPluginOwnerInactive": "Inactiu", "MobileStreamReplayFailed": "El Runtime no ha pogut llegir l'historial d'aquesta conversa — tornant-ho a provar", "MobileStreamCatchUpFailed": "El Runtime s'ha endarrerit i no s'ha pogut posar al dia — tornant-ho a provar", "MobileStreamRuntimeShutdown": "El Runtime s'està tancant — reconnectant", diff --git a/crates/localization/locales/de.json b/crates/localization/locales/de.json index 94852fc4b6..473d48c135 100644 --- a/crates/localization/locales/de.json +++ b/crates/localization/locales/de.json @@ -1,4 +1,11 @@ { + "CmdPluginOwnerReport": "Erweiterungshost:\n Status: {state}\n Aktive Werkzeuge ({count}): {tools}", + "CmdPluginOwnerActivating": "Wird aktiviert", + "CmdPluginOwnerActive": "Aktiv", + "CmdPluginOwnerFailed": "Fehlgeschlagen: {reason}", + "CmdPluginOwnerFaulted": "Gestört: {reason}", + "CmdPluginOwnerRevoked": "Widerrufen", + "CmdPluginOwnerInactive": "Nicht aktiv", "MobileStreamReplayFailed": "Runtime konnte den Verlauf dieser Unterhaltung nicht lesen — erneuter Versuch", "MobileStreamCatchUpFailed": "Runtime ist zurückgefallen und konnte nicht aufholen — erneuter Versuch", "MobileStreamRuntimeShutdown": "Runtime wird heruntergefahren — Verbindung wird wiederhergestellt", diff --git a/crates/localization/locales/en.json b/crates/localization/locales/en.json index 34dc54b842..2348c21533 100644 --- a/crates/localization/locales/en.json +++ b/crates/localization/locales/en.json @@ -1,4 +1,11 @@ { + "CmdPluginOwnerReport": "Extension host:\n State: {state}\n Live tools ({count}): {tools}", + "CmdPluginOwnerActivating": "Activating", + "CmdPluginOwnerActive": "Active", + "CmdPluginOwnerFailed": "Failed: {reason}", + "CmdPluginOwnerFaulted": "Faulted: {reason}", + "CmdPluginOwnerRevoked": "Revoked", + "CmdPluginOwnerInactive": "Not active", "MobileStreamReplayFailed": "Runtime could not read this conversation's history — retrying", "MobileStreamCatchUpFailed": "Runtime fell behind and could not catch up — retrying", "MobileStreamRuntimeShutdown": "Runtime is shutting down — reconnecting", diff --git a/crates/localization/locales/es-419.json b/crates/localization/locales/es-419.json index 983dbee649..7119756b15 100644 --- a/crates/localization/locales/es-419.json +++ b/crates/localization/locales/es-419.json @@ -1,4 +1,11 @@ { + "CmdPluginOwnerReport": "Host de extensiones:\n Estado: {state}\n Herramientas activas ({count}): {tools}", + "CmdPluginOwnerActivating": "Activando", + "CmdPluginOwnerActive": "Activo", + "CmdPluginOwnerFailed": "Falló: {reason}", + "CmdPluginOwnerFaulted": "Con errores: {reason}", + "CmdPluginOwnerRevoked": "Revocado", + "CmdPluginOwnerInactive": "Inactivo", "MobileStreamReplayFailed": "El Runtime no pudo leer el historial de esta conversación — reintentando", "MobileStreamCatchUpFailed": "El Runtime se atrasó y no pudo ponerse al día — reintentando", "MobileStreamRuntimeShutdown": "El Runtime se está cerrando — reconectando", diff --git a/crates/localization/locales/fr.json b/crates/localization/locales/fr.json index 829a526229..d0bb60e6fd 100644 --- a/crates/localization/locales/fr.json +++ b/crates/localization/locales/fr.json @@ -1,4 +1,11 @@ { + "CmdPluginOwnerReport": "Hôte des extensions :\n État : {state}\n Outils actifs ({count}) : {tools}", + "CmdPluginOwnerActivating": "Activation en cours", + "CmdPluginOwnerActive": "Actif", + "CmdPluginOwnerFailed": "Échec : {reason}", + "CmdPluginOwnerFaulted": "En défaut : {reason}", + "CmdPluginOwnerRevoked": "Révoqué", + "CmdPluginOwnerInactive": "Inactif", "MobileStreamReplayFailed": "Le Runtime n’a pas pu lire l’historique de cette conversation — nouvelle tentative", "MobileStreamCatchUpFailed": "Le Runtime a pris du retard et n’a pas pu le rattraper — nouvelle tentative", "MobileStreamRuntimeShutdown": "Le Runtime est en cours d’arrêt — reconnexion", diff --git a/crates/localization/locales/hi.json b/crates/localization/locales/hi.json index 2905345b40..8729cbc3ac 100644 --- a/crates/localization/locales/hi.json +++ b/crates/localization/locales/hi.json @@ -1,4 +1,11 @@ { + "CmdPluginOwnerReport": "एक्सटेंशन होस्ट:\n स्थिति: {state}\n सक्रिय टूल ({count}): {tools}", + "CmdPluginOwnerActivating": "सक्रिय किया जा रहा है", + "CmdPluginOwnerActive": "सक्रिय", + "CmdPluginOwnerFailed": "विफल: {reason}", + "CmdPluginOwnerFaulted": "त्रुटिग्रस्त: {reason}", + "CmdPluginOwnerRevoked": "अनुमति रद्द", + "CmdPluginOwnerInactive": "निष्क्रिय", "MobileStreamReplayFailed": "Runtime इस बातचीत का इतिहास नहीं पढ़ सका — फिर से कोशिश हो रही है", "MobileStreamCatchUpFailed": "Runtime पीछे रह गया और छूटी हुई घटनाएँ प्राप्त नहीं कर सका — फिर से कोशिश हो रही है", "MobileStreamRuntimeShutdown": "Runtime बंद हो रहा है — फिर से कनेक्ट हो रहा है", diff --git a/crates/localization/locales/id.json b/crates/localization/locales/id.json index 08cb63291c..316fe95faa 100644 --- a/crates/localization/locales/id.json +++ b/crates/localization/locales/id.json @@ -1,4 +1,11 @@ { + "CmdPluginOwnerReport": "Host ekstensi:\n Status: {state}\n Alat aktif ({count}): {tools}", + "CmdPluginOwnerActivating": "Sedang diaktifkan", + "CmdPluginOwnerActive": "Aktif", + "CmdPluginOwnerFailed": "Gagal: {reason}", + "CmdPluginOwnerFaulted": "Bermasalah: {reason}", + "CmdPluginOwnerRevoked": "Dicabut", + "CmdPluginOwnerInactive": "Tidak aktif", "MobileStreamReplayFailed": "Runtime tidak dapat membaca riwayat percakapan ini — mencoba lagi", "MobileStreamCatchUpFailed": "Runtime tertinggal dan tidak dapat mengejar — mencoba lagi", "MobileStreamRuntimeShutdown": "Runtime sedang dimatikan — menghubungkan kembali", diff --git a/crates/localization/locales/ja.json b/crates/localization/locales/ja.json index ad23d46fb7..44673a5652 100644 --- a/crates/localization/locales/ja.json +++ b/crates/localization/locales/ja.json @@ -1,4 +1,11 @@ { + "CmdPluginOwnerReport": "拡張機能ホスト:\n 状態: {state}\n 有効なツール ({count}): {tools}", + "CmdPluginOwnerActivating": "有効化中", + "CmdPluginOwnerActive": "有効", + "CmdPluginOwnerFailed": "失敗: {reason}", + "CmdPluginOwnerFaulted": "異常: {reason}", + "CmdPluginOwnerRevoked": "許可取消済み", + "CmdPluginOwnerInactive": "無効", "MobileStreamReplayFailed": "Runtime がこの会話の履歴を読み込めませんでした — 再試行中", "MobileStreamCatchUpFailed": "Runtime のイベント処理が遅れ、追いつけませんでした — 再試行中", "MobileStreamRuntimeShutdown": "Runtime は終了処理中です — 再接続中", diff --git a/crates/localization/locales/ko.json b/crates/localization/locales/ko.json index 00b42f4281..bc4e45a626 100644 --- a/crates/localization/locales/ko.json +++ b/crates/localization/locales/ko.json @@ -1,4 +1,11 @@ { + "CmdPluginOwnerReport": "확장 호스트:\n 상태: {state}\n 활성 도구 ({count}): {tools}", + "CmdPluginOwnerActivating": "활성화 중", + "CmdPluginOwnerActive": "활성", + "CmdPluginOwnerFailed": "실패: {reason}", + "CmdPluginOwnerFaulted": "오류 발생: {reason}", + "CmdPluginOwnerRevoked": "권한 철회됨", + "CmdPluginOwnerInactive": "비활성", "MobileStreamReplayFailed": "Runtime이 이 대화의 기록을 읽지 못했습니다 — 다시 시도 중", "MobileStreamCatchUpFailed": "Runtime의 이벤트 처리가 지연되어 따라잡지 못했습니다 — 다시 시도 중", "MobileStreamRuntimeShutdown": "Runtime을 종료하고 있습니다 — 다시 연결 중", diff --git a/crates/localization/locales/pt-BR.json b/crates/localization/locales/pt-BR.json index 8f2f6bdd6d..ca292d7f22 100644 --- a/crates/localization/locales/pt-BR.json +++ b/crates/localization/locales/pt-BR.json @@ -1,4 +1,11 @@ { + "CmdPluginOwnerReport": "Host de extensões:\n Estado: {state}\n Ferramentas ativas ({count}): {tools}", + "CmdPluginOwnerActivating": "Ativando", + "CmdPluginOwnerActive": "Ativo", + "CmdPluginOwnerFailed": "Falhou: {reason}", + "CmdPluginOwnerFaulted": "Com erro: {reason}", + "CmdPluginOwnerRevoked": "Revogado", + "CmdPluginOwnerInactive": "Inativo", "MobileStreamReplayFailed": "O Runtime não conseguiu ler o histórico desta conversa — tentando novamente", "MobileStreamCatchUpFailed": "O Runtime ficou para trás e não conseguiu recuperar os eventos — tentando novamente", "MobileStreamRuntimeShutdown": "O Runtime está sendo encerrado — reconectando", diff --git a/crates/localization/locales/ru.json b/crates/localization/locales/ru.json index 741652b9e3..d09ca81104 100644 --- a/crates/localization/locales/ru.json +++ b/crates/localization/locales/ru.json @@ -1,4 +1,11 @@ { + "CmdPluginOwnerReport": "Хост расширений:\n Состояние: {state}\n Активные инструменты ({count}): {tools}", + "CmdPluginOwnerActivating": "Активация", + "CmdPluginOwnerActive": "Активен", + "CmdPluginOwnerFailed": "Сбой: {reason}", + "CmdPluginOwnerFaulted": "Неисправен: {reason}", + "CmdPluginOwnerRevoked": "Доступ отозван", + "CmdPluginOwnerInactive": "Неактивен", "MobileStreamReplayFailed": "Runtime не удалось прочитать историю этой беседы — повторная попытка", "MobileStreamCatchUpFailed": "Runtime отстал и не смог получить пропущенные события — повторная попытка", "MobileStreamRuntimeShutdown": "Runtime завершает работу — переподключение", diff --git a/crates/localization/locales/uk.json b/crates/localization/locales/uk.json index a91fd57fbf..de1b711d00 100644 --- a/crates/localization/locales/uk.json +++ b/crates/localization/locales/uk.json @@ -1,4 +1,11 @@ { + "CmdPluginOwnerReport": "Хост розширень:\n Стан: {state}\n Активні інструменти ({count}): {tools}", + "CmdPluginOwnerActivating": "Активація", + "CmdPluginOwnerActive": "Активний", + "CmdPluginOwnerFailed": "Збій: {reason}", + "CmdPluginOwnerFaulted": "Несправний: {reason}", + "CmdPluginOwnerRevoked": "Доступ відкликано", + "CmdPluginOwnerInactive": "Неактивний", "MobileStreamReplayFailed": "Runtime не вдалося прочитати історію цієї розмови — повторна спроба", "MobileStreamCatchUpFailed": "Runtime відстав і не зміг отримати пропущені події — повторна спроба", "MobileStreamRuntimeShutdown": "Runtime завершує роботу — повторне підключення", diff --git a/crates/localization/locales/vi.json b/crates/localization/locales/vi.json index 6530c0c236..9a4acbeb84 100644 --- a/crates/localization/locales/vi.json +++ b/crates/localization/locales/vi.json @@ -1,4 +1,11 @@ { + "CmdPluginOwnerReport": "Máy chủ tiện ích mở rộng:\n Trạng thái: {state}\n Công cụ đang hoạt động ({count}): {tools}", + "CmdPluginOwnerActivating": "Đang kích hoạt", + "CmdPluginOwnerActive": "Đang hoạt động", + "CmdPluginOwnerFailed": "Thất bại: {reason}", + "CmdPluginOwnerFaulted": "Gặp lỗi: {reason}", + "CmdPluginOwnerRevoked": "Đã thu hồi", + "CmdPluginOwnerInactive": "Chưa hoạt động", "MobileStreamReplayFailed": "Runtime không thể đọc lịch sử cuộc trò chuyện này — đang thử lại", "MobileStreamCatchUpFailed": "Runtime bị chậm và không thể bắt kịp — đang thử lại", "MobileStreamRuntimeShutdown": "Runtime đang tắt — đang kết nối lại", diff --git a/crates/localization/locales/zh-Hans.json b/crates/localization/locales/zh-Hans.json index 1cd05e3e00..d4a1b1a333 100644 --- a/crates/localization/locales/zh-Hans.json +++ b/crates/localization/locales/zh-Hans.json @@ -1,4 +1,11 @@ { + "CmdPluginOwnerReport": "扩展宿主:\n 状态:{state}\n 活动工具({count}):{tools}", + "CmdPluginOwnerActivating": "正在激活", + "CmdPluginOwnerActive": "已激活", + "CmdPluginOwnerFailed": "失败:{reason}", + "CmdPluginOwnerFaulted": "故障:{reason}", + "CmdPluginOwnerRevoked": "已撤销", + "CmdPluginOwnerInactive": "未激活", "MobileStreamReplayFailed": "Runtime 无法读取此对话的历史记录 — 正在重试", "MobileStreamCatchUpFailed": "Runtime 处理落后且无法追赶 — 正在重试", "MobileStreamRuntimeShutdown": "Runtime 正在关闭 — 正在重新连接", diff --git a/crates/localization/locales/zh-Hant.json b/crates/localization/locales/zh-Hant.json index 778ba78a4a..030e916d1d 100644 --- a/crates/localization/locales/zh-Hant.json +++ b/crates/localization/locales/zh-Hant.json @@ -1,4 +1,11 @@ { + "CmdPluginOwnerReport": "擴充功能主機:\n 狀態:{state}\n 活動工具({count}):{tools}", + "CmdPluginOwnerActivating": "正在啟用", + "CmdPluginOwnerActive": "已啟用", + "CmdPluginOwnerFailed": "失敗:{reason}", + "CmdPluginOwnerFaulted": "故障:{reason}", + "CmdPluginOwnerRevoked": "已撤銷", + "CmdPluginOwnerInactive": "未啟用", "MobileStreamReplayFailed": "Runtime 無法讀取此對話的歷史記錄 — 正在重試", "MobileStreamCatchUpFailed": "Runtime 處理落後且無法追趕 — 正在重試", "MobileStreamRuntimeShutdown": "Runtime 正在關閉 — 正在重新連線", diff --git a/crates/localization/src/lib.rs b/crates/localization/src/lib.rs index fa42b5341a..b6e8c39870 100644 --- a/crates/localization/src/lib.rs +++ b/crates/localization/src/lib.rs @@ -707,6 +707,13 @@ pub enum MessageId { PluginSuggestionReason, PagerActionConfirm, CmdPluginBundleDetail, + CmdPluginOwnerReport, + CmdPluginOwnerActivating, + CmdPluginOwnerActive, + CmdPluginOwnerFailed, + CmdPluginOwnerFaulted, + CmdPluginOwnerRevoked, + CmdPluginOwnerInactive, CmdPluginBundleDiagnosticsHeader, CmdPluginBundleMutationSuccess, CmdPluginActionFailed, @@ -3238,6 +3245,13 @@ pub const ALL_MESSAGE_IDS: &[MessageId] = &[ MessageId::PluginSuggestionReason, MessageId::PagerActionConfirm, MessageId::CmdPluginBundleDetail, + MessageId::CmdPluginOwnerReport, + MessageId::CmdPluginOwnerActivating, + MessageId::CmdPluginOwnerActive, + MessageId::CmdPluginOwnerFailed, + MessageId::CmdPluginOwnerFaulted, + MessageId::CmdPluginOwnerRevoked, + MessageId::CmdPluginOwnerInactive, MessageId::CmdPluginBundleDiagnosticsHeader, MessageId::CmdPluginBundleMutationSuccess, MessageId::CmdPluginActionFailed, @@ -5524,6 +5538,37 @@ mod tests { .collect() } + #[test] + fn plugin_owner_report_keeps_translated_state_and_placeholder_parity() { + let english = raw_locale_messages(Locale::En); + let keys: Vec<_> = english + .keys() + .filter(|key| key.starts_with("CmdPluginOwner")) + .collect(); + assert_eq!(keys.len(), 7); + for locale in Locale::shipped_complete() { + let pack = raw_locale_messages(*locale); + for key in &keys { + let original = english[*key].as_str().unwrap(); + let translated = pack[*key].as_str().unwrap(); + assert_eq!( + message_placeholders(translated), + message_placeholders(original), + "{} changed placeholders for {key}", + locale.tag() + ); + if *locale != Locale::En { + assert_ne!( + translated, + original, + "{} left {key} in English", + locale.tag() + ); + } + } + } + } + /// #5906: the parked-agent vocabulary is new copy on the busiest rows in /// the product, so it gets the same hard parity gate coordination copy /// has — and the recovery line must keep the tool tokens it names, or it diff --git a/crates/tui/assets/skills-catalog-matrix.json b/crates/tui/assets/skills-catalog-matrix.json index 0add4f8304..3f0e3a1082 100644 --- a/crates/tui/assets/skills-catalog-matrix.json +++ b/crates/tui/assets/skills-catalog-matrix.json @@ -15,7 +15,7 @@ "in_model_catalogue": "true when the skill renders as an ambient catalogue line", "shadowed_aliases": "aliases that collide with another canonical bundled name; the canonical skill wins resolution" }, - "generation": "15", + "generation": "17", "skills": [ { "name": "skill-creator", diff --git a/crates/tui/assets/skills/plugin-creator/SKILL.generation-15.md b/crates/tui/assets/skills/plugin-creator/SKILL.generation-15.md new file mode 100644 index 0000000000..967779687c --- /dev/null +++ b/crates/tui/assets/skills/plugin-creator/SKILL.generation-15.md @@ -0,0 +1,62 @@ +--- +name: plugin-creator +description: Scaffold a local Codewhale plugin bundle with a versioned manifest, namespaced Skills, and an explicit trust review. +--- + +# Plugin Creator + +Use this skill when a user wants a local Codewhale plugin bundle. Trusted and +enabled bundles may add declarative Skills, commands, agents, hooks, and MCP +servers (stdio and remote) through the existing engines. LSP, native +extensions, filesystem roots, and lifecycle mutation are inventory-only. + +## Workflow + +1. Pick a Codewhale-owned location: + - User bundle: `~/.codewhale/plugins//` + - Workspace bundle: `/.codewhale/plugins//` +2. Normalize the bundle name to lowercase hyphen-case. +3. Create `plugin.toml`: + +```toml +schema_version = 1 + +[plugin] +name = "my-plugin" +version = "0.1.0" +description = "What this bundle provides" + +[skills] +path = "skills" +``` + +4. Put each Skill under `skills//SKILL.md`. Codewhale exposes it + as `my-plugin:`, never as an unqualified command. +5. Add `[mcp_servers.]` only when the bundle needs an existing MCP + engine. Keep stdio commands and paths inside the bundle. Map local + environment values only as exact `${SOURCE_ENV}` references. For remote MCP, + use HTTPS (or loopback HTTP), forbid URL user information/query/fragment, + use only environment-backed headers or bearer tokens, and declare the exact + normalized endpoint host set in `[capabilities].network_hosts`. Never place + credentials in the manifest. +6. Commands (`commands/*.md`), agents (`agents/*.toml`), and hooks + (`hooks/*.toml`) activate under the current policy — workspace bundles win + same-name collisions over user and built-in bundles. LSP, native + extensions, filesystem roots, and lifecycle mutation are inventory-only: + declare them only when inventorying future work. A bundle that declares + only unsupported surfaces cannot be enabled. +7. Validate and review without executing bundle content: + - `/plugin validate ` + - `/plugin show ` + - `/plugin enable ` to open the content/capability review + - run the exact `/plugin trust ...` confirmation shown, then enable again +8. Verify `/skills inspect` reports plugin provenance and `/plugin list` + reports the expected trust and activation state. Trust stages the reviewed + content but does not activate it. After enablement, follow the host's + reload notice: use `/reload` or a new session to apply changes to a live + session's pinned skills and tools. + +Every user and workspace bundle starts untrusted and disabled. Reuse the +existing `/plugin marketplace`, install, update, review and reload surfaces; +do not add a parallel installer, registry or automatic trust flow. Catalog +membership alone never installs, trusts or enables a plugin. diff --git a/crates/tui/assets/skills/plugin-creator/SKILL.generation-16.md b/crates/tui/assets/skills/plugin-creator/SKILL.generation-16.md new file mode 100644 index 0000000000..14bcbc06d1 --- /dev/null +++ b/crates/tui/assets/skills/plugin-creator/SKILL.generation-16.md @@ -0,0 +1,68 @@ +--- +name: plugin-creator +description: Scaffold a local Codewhale plugin bundle with a versioned manifest, namespaced Skills, and an explicit trust review. +--- + +# Plugin Creator + +Use this skill when a user wants a local Codewhale plugin bundle. Trusted and +enabled bundles may add declarative Skills, commands, agents, hooks, and MCP +servers (stdio and remote) through the existing engines. LSP, filesystem +roots, and lifecycle mutation are inventory-only. Native extensions (host +code) are inventory-only unless the user has turned on the experimental +`[features] extension_host` flag. + +## Workflow + +1. Pick a Codewhale-owned location: + - User bundle: `~/.codewhale/plugins//` + - Workspace bundle: `/.codewhale/plugins//` +2. Normalize the bundle name to lowercase hyphen-case. +3. Create `plugin.json` (Agent Plugins v1.0.0; a legacy `plugin.toml` stays + readable, but new bundles use `plugin.json`): + +```json +{ + "$schema": "https://agent-plugins.org/schemas/plugin.json", + "name": "my-plugin", + "version": "0.1.0", + "description": "What this bundle provides" +} +``` + +4. Put each Skill under `skills//SKILL.md`; Codewhale finds + `skills/` automatically and exposes each as `my-plugin:`, + never as an unqualified command. +5. Add MCP servers in a sibling `mcp.json` only when the bundle needs an + existing MCP engine. Keep stdio commands and paths inside the bundle. Map local + environment values only as exact `${SOURCE_ENV}` references. For remote MCP, + use HTTPS (or loopback HTTP), forbid URL user information/query/fragment, + use only environment-backed headers or bearer tokens, and declare the exact + normalized endpoint host set in `capabilities.network_hosts` under + `extensions["net.codewhale"]`. Never place credentials in the manifest. +6. Commands (`commands/*.md`), agents (`agents/*.toml`), and hooks + (`hooks/*.toml`), declared under `extensions["net.codewhale"]`, activate + under the current policy — workspace bundles win same-name collisions over + user and built-in bundles. LSP, filesystem roots, and lifecycle mutation + are inventory-only: declare them only when inventorying future work. A + `native` entry runs only under the experimental extension host; there it + must be one `.mjs` or `.js` ES module file, `/plugin validate` rejects + anything else, and its tools always use `Required` approval, never a + plugin's read-only hint. Full Access, Bypass, or an exact session grant + for the reviewed build can satisfy that gate without a prompt. A bundle + that declares only unsupported surfaces cannot be enabled. +7. Validate and review without executing bundle content: + - `/plugin validate ` + - `/plugin show ` + - `/plugin enable ` to open the content/capability review + - run the exact `/plugin trust ...` confirmation shown, then enable again +8. Verify `/skills inspect` reports plugin provenance and `/plugin list` + reports the expected trust and activation state. Trust stages the reviewed + content but does not activate it. After enablement, follow the host's + reload notice: use `/reload` or a new session to apply changes to a live + session's pinned skills and tools. + +Every user and workspace bundle starts untrusted and disabled. Reuse the +existing `/plugin marketplace`, install, update, review and reload surfaces; +do not add a parallel installer, registry or automatic trust flow. Catalog +membership alone never installs, trusts or enables a plugin. diff --git a/crates/tui/assets/skills/plugin-creator/SKILL.md b/crates/tui/assets/skills/plugin-creator/SKILL.md index 967779687c..c37aa67e2f 100644 --- a/crates/tui/assets/skills/plugin-creator/SKILL.md +++ b/crates/tui/assets/skills/plugin-creator/SKILL.md @@ -7,8 +7,10 @@ description: Scaffold a local Codewhale plugin bundle with a versioned manifest, Use this skill when a user wants a local Codewhale plugin bundle. Trusted and enabled bundles may add declarative Skills, commands, agents, hooks, and MCP -servers (stdio and remote) through the existing engines. LSP, native -extensions, filesystem roots, and lifecycle mutation are inventory-only. +servers (stdio and remote) through the existing engines. LSP, filesystem +roots, and lifecycle mutation are inventory-only. Native extensions (host +code) are inventory-only unless the user has turned on the experimental +`[features] extension_host` flag. ## Workflow @@ -16,40 +18,45 @@ extensions, filesystem roots, and lifecycle mutation are inventory-only. - User bundle: `~/.codewhale/plugins//` - Workspace bundle: `/.codewhale/plugins//` 2. Normalize the bundle name to lowercase hyphen-case. -3. Create `plugin.toml`: +3. Create `plugin.json` (Agent Plugins v1.0.0; a legacy `plugin.toml` stays + readable, but new bundles use `plugin.json`): -```toml -schema_version = 1 - -[plugin] -name = "my-plugin" -version = "0.1.0" -description = "What this bundle provides" - -[skills] -path = "skills" +```json +{ + "$schema": "https://agent-plugins.org/schemas/plugin.json", + "name": "my-plugin", + "version": "0.1.0", + "description": "What this bundle provides" +} ``` -4. Put each Skill under `skills//SKILL.md`. Codewhale exposes it - as `my-plugin:`, never as an unqualified command. -5. Add `[mcp_servers.]` only when the bundle needs an existing MCP - engine. Keep stdio commands and paths inside the bundle. Map local +4. Put each Skill under `skills//SKILL.md`; Codewhale finds + `skills/` automatically and exposes each as `my-plugin:`, + never as an unqualified command. +5. Add MCP servers in a sibling `mcp.json` only when the bundle needs an + existing MCP engine. Keep stdio commands and paths inside the bundle. Map local environment values only as exact `${SOURCE_ENV}` references. For remote MCP, use HTTPS (or loopback HTTP), forbid URL user information/query/fragment, use only environment-backed headers or bearer tokens, and declare the exact - normalized endpoint host set in `[capabilities].network_hosts`. Never place - credentials in the manifest. + normalized endpoint host set in `capabilities.network_hosts` under + `extensions["net.codewhale"]`. Never place credentials in the manifest. 6. Commands (`commands/*.md`), agents (`agents/*.toml`), and hooks - (`hooks/*.toml`) activate under the current policy — workspace bundles win - same-name collisions over user and built-in bundles. LSP, native - extensions, filesystem roots, and lifecycle mutation are inventory-only: - declare them only when inventorying future work. A bundle that declares - only unsupported surfaces cannot be enabled. + (`hooks/*.toml`), declared under `extensions["net.codewhale"]`, activate + under the current policy — workspace bundles win same-name collisions over + user and built-in bundles. LSP, filesystem roots, and lifecycle mutation + are inventory-only: declare them only when inventorying future work. A + `native` entry runs only under the experimental extension host; there it + must be one `.mjs`, `.js` or `.mts` ES module file, `/plugin validate` rejects + anything else, and its tools always use `Required` approval, never a + plugin's read-only hint. Full Access, Bypass, or an exact session grant + for the reviewed build can satisfy that gate without a prompt. A bundle + that declares only unsupported surfaces cannot be enabled. 7. Validate and review without executing bundle content: - `/plugin validate ` - `/plugin show ` - - `/plugin enable ` to open the content/capability review - - run the exact `/plugin trust ...` confirmation shown, then enable again + - stop and present these results; the person runs `/plugin enable ` + to open the content/capability review, reviews it, runs the exact + `/plugin trust ...` confirmation shown, then enables the bundle 8. Verify `/skills inspect` reports plugin provenance and `/plugin list` reports the expected trust and activation state. Trust stages the reviewed content but does not activate it. After enablement, follow the host's @@ -60,3 +67,20 @@ Every user and workspace bundle starts untrusted and disabled. Reuse the existing `/plugin marketplace`, install, update, review and reload surfaces; do not add a parallel installer, registry or automatic trust flow. Catalog membership alone never installs, trusts or enables a plugin. + +## Experimental host code + +Only scaffold host code when the person explicitly uses the experimental +extension-host feature. Start from the tested `hello-extension` example and +`docs/EXTENSIONS.md` in the Codewhale repository. A typed `.mts` entry may use +Node's erasable TypeScript syntax; bundle dependencies locally. Register tools +with a plugin-specific prefix and an object input schema, propagate +`exec.signal`, and use `ctx.effect` for bounded asynchronous cleanup. The +current execution context exposes `signal`, `callId` and `args`; it does not +expose the calling workspace path. Do not change the shared process cwd. + +Stop after install, validate and show; never automate the trust token. A +person reviews, trusts and enables the bundle. `/plugin show ` reports +owner state, live tools and recent attributed diagnostics. Recovery may create +fresh registrations, but never replays an interrupted tool call. Explain the +shared-process and current platform sandbox limits without claiming isolation. diff --git a/crates/tui/extension-host/dist/codewhale-extension-host.mjs b/crates/tui/extension-host/dist/codewhale-extension-host.mjs index eda7cba5aa..454e3a4233 100644 --- a/crates/tui/extension-host/dist/codewhale-extension-host.mjs +++ b/crates/tui/extension-host/dist/codewhale-extension-host.mjs @@ -3969,7 +3969,7 @@ var ErrorCode = { Cancelled: -32800 }; var CORE_TO_HOST = { - requests: ["host/initialize", "host/shutdown", "ext/activate", "ext/deactivate", "tool/call"], + requests: ["host/initialize", "host/shutdown", "host/ping", "ext/activate", "ext/deactivate", "tool/call"], notifications: ["$/cancel"] }; var HOST_TO_CORE = { @@ -4104,6 +4104,7 @@ var PARAMS = { ) }, "host/shutdown": { dir: "core", required: {} }, + "host/ping": { dir: "core", required: {} }, "ext/activate": { dir: "core", required: { owner: "owner", plugin_name: "string", entry: "object" }, @@ -4656,6 +4657,10 @@ rpc.onRequest("host/initialize", (params) => { function requireInitialized() { if (!initialized) throw new RpcError(ErrorCode.InvalidRequest, "host is not initialized"); } +rpc.onRequest("host/ping", () => { + requireInitialized(); + return {}; +}); rpc.onRequest("ext/activate", async (params) => { requireInitialized(); return host.activate(params); diff --git a/crates/tui/extension-host/dist/protocol.mjs b/crates/tui/extension-host/dist/protocol.mjs index ece2da94b0..3f62eb33c2 100644 --- a/crates/tui/extension-host/dist/protocol.mjs +++ b/crates/tui/extension-host/dist/protocol.mjs @@ -19,7 +19,7 @@ var ErrorCode = { Cancelled: -32800 }; var CORE_TO_HOST = { - requests: ["host/initialize", "host/shutdown", "ext/activate", "ext/deactivate", "tool/call"], + requests: ["host/initialize", "host/shutdown", "host/ping", "ext/activate", "ext/deactivate", "tool/call"], notifications: ["$/cancel"] }; var HOST_TO_CORE = { @@ -154,6 +154,7 @@ var PARAMS = { ) }, "host/shutdown": { dir: "core", required: {} }, + "host/ping": { dir: "core", required: {} }, "ext/activate": { dir: "core", required: { owner: "owner", plugin_name: "string", entry: "object" }, diff --git a/crates/tui/extension-host/src/main.ts b/crates/tui/extension-host/src/main.ts index ca6174def7..ef6fa50b38 100644 --- a/crates/tui/extension-host/src/main.ts +++ b/crates/tui/extension-host/src/main.ts @@ -115,6 +115,11 @@ function requireInitialized() { if (!initialized) throw new RpcError(ErrorCode.InvalidRequest, 'host is not initialized') } +rpc.onRequest('host/ping', () => { + requireInitialized() + return {} +}) + rpc.onRequest('ext/activate', async (params: any) => { requireInitialized() return host.activate(params) diff --git a/crates/tui/extension-host/src/protocol.ts b/crates/tui/extension-host/src/protocol.ts index 955a9090f7..8f6103801b 100644 --- a/crates/tui/extension-host/src/protocol.ts +++ b/crates/tui/extension-host/src/protocol.ts @@ -32,7 +32,7 @@ export const ErrorCode = { /** Methods the core sends to the host. */ export const CORE_TO_HOST = { - requests: ['host/initialize', 'host/shutdown', 'ext/activate', 'ext/deactivate', 'tool/call'], + requests: ['host/initialize', 'host/shutdown', 'host/ping', 'ext/activate', 'ext/deactivate', 'tool/call'], notifications: ['$/cancel'], } as const @@ -233,6 +233,7 @@ const PARAMS: Record { + const host = await startHost() + t.after(() => host.stop()) + assert.deepEqual(await host.call('host/ping', {}), {}) + assert.equal(host.registry.length, 0) +}) + +test('the documented typed hello extension activates and executes unchanged', async (t) => { + const host = await startHost() + t.after(() => host.stop()) + const entry = fileURLToPath(new URL('../../../../docs/examples/plugins/hello-extension/hello.mts', import.meta.url)) + const { result } = await activate(host, 'hello-extension', entry) + assert.deepEqual(result, { status: 'ok', tools: ['hello_greet'] }) + const tool = host.registry.find((entry) => entry.op === 'register') + const output = await host.call('tool/call', { handle: tool.handle, call_id: 'hello-1', input: { name: 'Codewhale' }, deadline_ms: 5000 }) + assert.deepEqual(output.structured, { greeting: 'Hello, Codewhale!', callId: 'hello-1' }) +}) + test('the published DSH plugin runs unmodified and returns its payload', async (t) => { const host = await startHost() t.after(() => host.stop()) diff --git a/crates/tui/src/commands/contract.rs b/crates/tui/src/commands/contract.rs index f28096f151..ed3097269c 100644 --- a/crates/tui/src/commands/contract.rs +++ b/crates/tui/src/commands/contract.rs @@ -2049,6 +2049,13 @@ pub(crate) fn key_to_plugin_message_id(key: &str) -> Option { Some(match key { "cmd_plugin_action_failed" => MessageId::CmdPluginActionFailed, "cmd_plugin_bundle_detail" => MessageId::CmdPluginBundleDetail, + "cmd_plugin_owner_report" => MessageId::CmdPluginOwnerReport, + "cmd_plugin_owner_activating" => MessageId::CmdPluginOwnerActivating, + "cmd_plugin_owner_active" => MessageId::CmdPluginOwnerActive, + "cmd_plugin_owner_failed" => MessageId::CmdPluginOwnerFailed, + "cmd_plugin_owner_faulted" => MessageId::CmdPluginOwnerFaulted, + "cmd_plugin_owner_revoked" => MessageId::CmdPluginOwnerRevoked, + "cmd_plugin_owner_inactive" => MessageId::CmdPluginOwnerInactive, "cmd_plugin_bundle_diagnostics_header" => MessageId::CmdPluginBundleDiagnosticsHeader, "cmd_plugin_bundle_list_header" => MessageId::CmdPluginBundleListHeader, "cmd_plugin_bundle_mutation_success" => MessageId::CmdPluginBundleMutationSuccess, diff --git a/crates/tui/src/commands/groups/plugins/mod.rs b/crates/tui/src/commands/groups/plugins/mod.rs index 3e3184ec83..f2aa828669 100644 --- a/crates/tui/src/commands/groups/plugins/mod.rs +++ b/crates/tui/src/commands/groups/plugins/mod.rs @@ -412,7 +412,62 @@ fn show_bundle( ); } }; - CommandResult::message(render::render_bundle_detail(presentation, &detail, true)) + let mut output = render::render_bundle_detail(presentation, &detail, true); + if let Some(report) = crate::extension_host::owner_report(&detail.id) { + append_host_owner_report(presentation, &mut output, &report); + } + CommandResult::message(output) +} + +fn append_host_owner_report( + presentation: &dyn CommandPresentationContext, + output: &mut String, + report: &crate::extension_host::OwnerReport, +) { + use crate::extension_host::registry::OwnerState; + + let (state_key, reason) = match &report.state { + Some(OwnerState::Activating) => ("cmd_plugin_owner_activating", None), + Some(OwnerState::Active) => ("cmd_plugin_owner_active", None), + Some(OwnerState::Failed(reason)) => ("cmd_plugin_owner_failed", Some(reason)), + Some(OwnerState::Faulted(reason)) => ("cmd_plugin_owner_faulted", Some(reason)), + Some(OwnerState::Revoked) => ("cmd_plugin_owner_revoked", None), + None => ("cmd_plugin_owner_inactive", None), + }; + let reason = reason.map(|value| escape_review_text(value)); + let replacements = reason + .as_deref() + .map(|value| vec![("reason", value)]) + .unwrap_or_default(); + let state = presentation + .translate(state_key, &replacements) + .unwrap_or_default(); + let tools = report + .tools + .iter() + .map(|name| escape_review_text(name)) + .collect::>(); + let tool_names = if tools.is_empty() { + "—".to_string() + } else { + tools.join(", ") + }; + output.push('\n'); + output.push_str( + &presentation + .translate( + "cmd_plugin_owner_report", + &[ + ("state", &state), + ("count", &tools.len().to_string()), + ("tools", &tool_names), + ], + ) + .unwrap_or_default(), + ); + for line in &report.diagnostics { + let _ = write!(output, "\n · {}", escape_review_text(line)); + } } /// `/plugin export ` — publish a loaded bundle as a diff --git a/crates/tui/src/commands/groups/plugins/tests.rs b/crates/tui/src/commands/groups/plugins/tests.rs index bc45abd14b..473a0e26d5 100644 --- a/crates/tui/src/commands/groups/plugins/tests.rs +++ b/crates/tui/src/commands/groups/plugins/tests.rs @@ -8,6 +8,56 @@ use std::fs; use std::path::Path; use tempfile::TempDir; +#[test] +fn extension_owner_report_escapes_every_plugin_controlled_field() { + struct Presentation(Locale); + impl CommandPresentationContext for Presentation { + fn translate(&self, key: &str, replacements: &[(&str, &str)]) -> Result { + let id = crate::commands::contract::key_to_plugin_message_id(key).unwrap(); + let mut output = codewhale_localization::tr(self.0, id).to_string(); + for (name, value) in replacements { + output = output.replace(&format!("{{{name}}}"), value); + } + Ok(output) + } + } + let mut output = String::new(); + append_host_owner_report( + &Presentation(Locale::En), + &mut output, + &crate::extension_host::OwnerReport { + state: Some(crate::extension_host::registry::OwnerState::Failed( + "\u{1b}[31m