Compare commits

..
Author SHA1 Message Date
陈奇 8e4dd0c052 fix(plugins): pin every command to its activation runtime 2026-10-26 09:59:34 +00:00
陈奇 0a692dfc72 fix(plugins): bound interactive commands and cancel orphan host prompts 2026-10-26 09:53:25 +00:00
陈奇 d4ca123999 fix(plugins): preserve input fidelity and native permission focus handoff 2026-10-26 09:06:53 +00:00
陈奇 71d455d5de feat(plugins): add host text dialogs and explicit clipboard sharing 2026-10-26 08:05:02 +00:00
陈奇 ff3734d161 fix(plugins): refresh actual state after failed grant revocation 2026-10-26 07:39:37 +00:00
陈奇 185eac3849 fix(plugins): finish revocation cleanup and identify inactive saved grants 2026-10-26 07:18:21 +00:00
陈奇 9af123e5d7 feat(plugins): inspect and revoke grants and align network lease contracts 2026-10-26 07:03:14 +00:00
Codex e5bd294cdd fix(plugins): validate and prepare registry snapshots before publication 2026-10-26 06:32:35 +00:00
Codex b48567d448 Merge branch 'feat/plugin-brand-icons' into feat/plugin-registry-client
# Conflicts:
#	application/i18n/locales/en/core.ts
#	application/i18n/locales/es/core.ts
#	application/i18n/locales/ru/core.ts
#	application/i18n/locales/zh-CN/core.ts
#	application/i18n/locales/zh-TW/core.ts
2026-10-26 06:27:10 +00:00
Codex 933bc93fc3 feat(plugins): enrich plugin cards with author and descriptions 2026-10-26 06:26:36 +00:00
Codex 45ed193579 feat(plugins): expose manual community catalog refresh 2026-10-26 06:19:26 +00:00
Codex f471a17a75 Merge branch 'feat/plugin-brand-icons' into feat/plugin-registry-client 2026-10-26 06:17:13 +00:00
Codex b9293a7bb5 fix(plugins): keep sparse installed grids compact 2026-10-26 06:16:55 +00:00
Codex 257660bcc1 fix(plugins): keep normalized README URLs pinned to reviewed commits 2026-10-26 06:15:49 +00:00
Codex eaf8c9a385 test(plugins): include registry source in catalog fixture 2026-10-26 06:10:18 +00:00
Codex ff42d0c1b9 Merge branch 'feat/plugin-brand-icons' into feat/plugin-registry-client 2026-10-26 06:09:32 +00:00
Codex 0adb7b822e feat(plugins): show installed plugins in a compact grid 2026-10-26 06:09:20 +00:00
Codex 311765eaa1 feat(plugins): read validated official registry snapshots 2026-10-26 06:06:26 +00:00
Codex d2a8398b7a fix(plugins): parent permission prompts to focused settings window 2026-10-26 06:01:13 +00:00
Codex 69307cee16 fix(plugins): validate settings fixture activation and polish manager controls 2026-10-26 05:49:24 +00:00
Codex 4445c292ea fix(plugins): bound icon archive reads and reject prototype glyph lookups 2026-10-26 05:45:53 +00:00
Codex 6fad272afe style(plugins): use theme-accent status pills for enable controls 2026-10-26 05:35:45 +00:00
Codex a7f8480da1 test(plugins): check packaging against the current workspace revision 2026-10-26 05:35:45 +00:00
Codex e7e57f2a52 fix(plugins): align root workspace dependencies with API revision 2026-10-26 05:31:23 +00:00
Codex ab2f95837e test(plugins): add static settings view handoff fixture 2026-10-26 05:29:34 +00:00
Codex f3d04a1912 chore(plugins): pin reviewed Starter brand preview to source snapshot 2026-10-26 05:27:55 +00:00
Codex f4a6b205ad feat(plugins): add versioned offline brand icons and author guidance 2026-10-26 05:26:25 +00:00
105 changed files with 4665 additions and 146 deletions

No files matched your search

+2
View File
@@ -12,6 +12,7 @@ import { ToastProvider } from './components/ui/toast';
import { TooltipProvider } from './components/ui/tooltip';
import { ScriptAutomationRoot } from './components/scripts/ScriptAutomationRoot';
import { ExternalMcpApprovalsHost } from './components/ai/ExternalMcpApprovalsHost';
import { PluginHostInteractionHost } from './components/plugins/PluginHostInteractionHost';
import { PluginAuthenticationHost } from './components/plugins/PluginAuthenticationHost';
import { MultilinePasteConfirmHost } from './components/terminal/MultilinePasteConfirmDialog';
import { useExternalMcpGrantPersister } from './components/ai/useExternalMcpGrantPersister';
@@ -86,6 +87,7 @@ function AppWithProviders({ settings, appLock }: { settings: SettingsState; appL
<ScriptAutomationRoot />
<ExternalMcpApprovalsHost />
<PluginAuthenticationHost />
<PluginHostInteractionHost />
<MultilinePasteConfirmHost />
<VaultPublisher>
<SessionPublisher persistSessionRestore={!isPeerSessionWindow}>
+28
View File
@@ -1245,6 +1245,30 @@ Highlight the focused split pane:
"settings.plugins.manager.disable": "Disable",
"settings.plugins.manager.remove": "Uninstall",
"settings.plugins.manager.removeNote": "Uninstall removes package code and local KV data. User-owned settings, secrets and sync sidecars are retained.",
"settings.plugins.permissions.statusUnknown": "Status unknown",
"plugins.interaction.title": "Plugin request",
"plugins.interaction.sharing": "Text you share becomes available to this plugin and its granted capabilities, including network access. Reading does not approve a new network destination.",
"plugins.interaction.input": "Input text",
"plugins.interaction.filter": "Filter choices",
"plugins.interaction.preview": "Preview clipboard",
"plugins.interaction.previewNote": "The clipboard is read only after you choose Preview. You can then inspect the text and separately confirm sharing it.",
"plugins.interaction.previewAction": "Preview clipboard",
"plugins.interaction.share": "Share with plugin",
"plugins.interaction.copy": "Copy to clipboard",
"plugins.interaction.failed": "This request expired or could not be completed.",
"settings.plugins.permissions.current": "Matches the current activation",
"settings.plugins.permissions.inactive": "Inactive for the current activation",
"settings.plugins.permissions.title": "Saved grants",
"settings.plugins.permissions.refresh": "Refresh",
"settings.plugins.permissions.loading": "Loading grants...",
"settings.plugins.permissions.empty": "No saved grants. One-use decisions are not retained.",
"settings.plugins.permissions.always": "Until revoked",
"settings.plugins.permissions.application": "Until Netcatty exits",
"settings.plugins.permissions.session": "Until the session closes",
"settings.plugins.permissions.audit": "Recent security events (up to 100)",
"settings.plugins.permissions.revoke": "Disable and revoke all grants",
"settings.plugins.permissions.revokeNote": "This disables the plugin, cancels its work, and removes saved grants. Enable it again to request new access.",
"settings.plugins.permissions.unavailable": "Plugin permission management is unavailable",
"settings.plugins.manager.permissions": "Declared permissions",
"settings.plugins.manager.permissionNote": "From the manifest, not a grant. Enabled allows lazy activation; required permissions are requested before first activation and optional permissions when used. Sensitive operations still pass host checks.",
"settings.plugins.manager.noPermissions": "No privileged permissions declared.",
@@ -1255,6 +1279,9 @@ Highlight the focused split pane:
"settings.plugins.manager.data": "Local KV stays on this device. Only non-secret settings explicitly marked for sync participate in encrypted cloud sidecars. Packages and permission grants do not sync.",
"settings.plugins.market.results": "{count} plugins",
"settings.plugins.manager.lastError": "Last recorded error",
"settings.plugins.market.offline": "Registry unavailable. Showing the last valid catalog or bundled examples. Installation is unavailable until refresh succeeds.",
"settings.plugins.manager.details": "View details",
"settings.plugins.manager.noDescription": "No description provided.",
"settings.plugins.market.installed": "Installed",
"settings.plugins.market.sourceBadge": "Source example",
"settings.plugins.market.title": "Marketplace",
@@ -1275,6 +1302,7 @@ Highlight the focused split pane:
"settings.plugins.market.noResults": "No matching plugins. Try another search.",
"settings.plugins.market.manifest": "Compatibility and permissions",
"settings.plugins.manager.unbounded": "No narrower resource scope is declared. Check the actual scope in the permission prompt.",
"settings.plugins.openView": "Open",
"settings.plugins.openManager": "Open plugin manager",
"settings.plugins.title": "Plugins",
"settings.plugins.description": "Manage your plugins and explore the catalog.",
+28
View File
@@ -1245,6 +1245,30 @@ Resalta el panel dividido enfocado:
"settings.plugins.manager.disable": "Desactivar",
"settings.plugins.manager.remove": "Desinstalar",
"settings.plugins.manager.removeNote": "La desinstalación elimina el código y los datos KV locales. Se conservan los ajustes del usuario, los secretos y los datos auxiliares de sincronización.",
"settings.plugins.permissions.statusUnknown": "Estado desconocido",
"plugins.interaction.title": "Solicitud de complemento",
"plugins.interaction.sharing": "El texto compartido estará disponible para este complemento y sus capacidades autorizadas, incluida la red. Leer no autoriza un nuevo destino de red.",
"plugins.interaction.input": "Introducir texto",
"plugins.interaction.filter": "Filtrar opciones",
"plugins.interaction.preview": "Vista previa del portapapeles",
"plugins.interaction.previewNote": "El portapapeles se lee al elegir Vista previa. Revise el texto y confirme por separado que desea compartirlo.",
"plugins.interaction.previewAction": "Vista previa del portapapeles",
"plugins.interaction.share": "Compartir con el complemento",
"plugins.interaction.copy": "Copiar al portapapeles",
"plugins.interaction.failed": "La solicitud caducó o no pudo completarse.",
"settings.plugins.permissions.current": "Coincide con la ejecución actual",
"settings.plugins.permissions.inactive": "No activa para la ejecución actual",
"settings.plugins.permissions.title": "Autorizaciones guardadas",
"settings.plugins.permissions.refresh": "Actualizar",
"settings.plugins.permissions.loading": "Cargando autorizaciones...",
"settings.plugins.permissions.empty": "No hay autorizaciones guardadas. Las decisiones de un solo uso no se conservan.",
"settings.plugins.permissions.always": "Hasta su revocación",
"settings.plugins.permissions.application": "Hasta cerrar Netcatty",
"settings.plugins.permissions.session": "Hasta cerrar la sesión",
"settings.plugins.permissions.audit": "Eventos de seguridad recientes (hasta 100)",
"settings.plugins.permissions.revoke": "Desactivar y revocar todas las autorizaciones",
"settings.plugins.permissions.revokeNote": "Desactiva el complemento, cancela su trabajo y elimina las autorizaciones guardadas. Actívelo para solicitar acceso de nuevo.",
"settings.plugins.permissions.unavailable": "La gestión de permisos no está disponible",
"settings.plugins.manager.permissions": "Permisos declarados",
"settings.plugins.manager.permissionNote": "Del manifiesto, no es una autorización. Activado permite la activación diferida; los permisos obligatorios se solicitan antes del primer uso y los opcionales cuando se necesitan. Las operaciones sensibles siguen verificándose.",
"settings.plugins.manager.noPermissions": "No se han declarado permisos privilegiados.",
@@ -1255,6 +1279,9 @@ Resalta el panel dividido enfocado:
"settings.plugins.manager.data": "Los datos KV permanecen en este dispositivo. Solo los ajustes no secretos marcados para sincronización participan en los datos auxiliares cifrados. Los paquetes y las autorizaciones no se sincronizan.",
"settings.plugins.market.results": "{count} plugins",
"settings.plugins.manager.lastError": "Último error registrado",
"settings.plugins.market.offline": "El registro no está disponible. Se muestra el último catálogo válido o los ejemplos incluidos. La instalación requiere una actualización correcta.",
"settings.plugins.manager.details": "Ver detalles",
"settings.plugins.manager.noDescription": "No se proporcionó una descripción.",
"settings.plugins.market.installed": "Instalado",
"settings.plugins.market.sourceBadge": "Ejemplo de código",
"settings.plugins.market.title": "Catálogo",
@@ -1275,6 +1302,7 @@ Resalta el panel dividido enfocado:
"settings.plugins.market.noResults": "No hay plugins coincidentes. Prueba otra búsqueda.",
"settings.plugins.market.manifest": "Compatibilidad y permisos",
"settings.plugins.manager.unbounded": "No se declara un alcance de recursos más limitado. Revisa el alcance real en la solicitud de permiso.",
"settings.plugins.openView": "Abrir",
"settings.plugins.openManager": "Abrir gestor de plugins",
"settings.plugins.title": "Plugins",
"settings.plugins.description": "Gestiona tus plugins y explora el catálogo.",
+28
View File
@@ -1140,6 +1140,30 @@ export const ruCoreMessages: Messages = {
"settings.plugins.manager.disable": "Отключить",
"settings.plugins.manager.remove": "Удалить",
"settings.plugins.manager.removeNote": "Удаление очищает код пакета и локальные данные KV. Настройки пользователя, секреты и данные синхронизации сохраняются.",
"settings.plugins.permissions.statusUnknown": "Состояние неизвестно",
"plugins.interaction.title": "Запрос плагина",
"plugins.interaction.sharing": "Переданный текст доступен плагину и его разрешённым возможностям, включая сеть. Чтение не разрешает новый сетевой адрес.",
"plugins.interaction.input": "Ввести текст",
"plugins.interaction.filter": "Фильтр вариантов",
"plugins.interaction.preview": "Предпросмотр буфера обмена",
"plugins.interaction.previewNote": "Буфер читается только после нажатия предпросмотра. Затем проверьте текст и отдельно подтвердите передачу.",
"plugins.interaction.previewAction": "Предпросмотр буфера обмена",
"plugins.interaction.share": "Передать плагину",
"plugins.interaction.copy": "Копировать в буфер обмена",
"plugins.interaction.failed": "Запрос истёк или не выполнен.",
"settings.plugins.permissions.current": "Соответствует текущему запуску",
"settings.plugins.permissions.inactive": "Не действует для текущего запуска",
"settings.plugins.permissions.title": "Сохранённые разрешения",
"settings.plugins.permissions.refresh": "Обновить",
"settings.plugins.permissions.loading": "Загрузка разрешений...",
"settings.plugins.permissions.empty": "Сохранённых разрешений нет. Одноразовые решения не сохраняются.",
"settings.plugins.permissions.always": "До отзыва",
"settings.plugins.permissions.application": "До выхода из Netcatty",
"settings.plugins.permissions.session": "До закрытия сеанса",
"settings.plugins.permissions.audit": "Последние события безопасности (до 100)",
"settings.plugins.permissions.revoke": "Отключить и отозвать все разрешения",
"settings.plugins.permissions.revokeNote": "Плагин будет отключён, его задачи отменены, а сохранённые разрешения удалены. Включите его для нового запроса доступа.",
"settings.plugins.permissions.unavailable": "Управление разрешениями недоступно",
"settings.plugins.manager.permissions": "Заявленные разрешения",
"settings.plugins.manager.permissionNote": "Из манифеста, а не предоставленные права. Включение допускает отложенную активацию; обязательные разрешения запрашиваются перед ней, необязательные — при использовании. Чувствительные операции проверяются приложением.",
"settings.plugins.manager.noPermissions": "Привилегированные разрешения не заявлены.",
@@ -1150,6 +1174,9 @@ export const ruCoreMessages: Messages = {
"settings.plugins.manager.data": "Данные KV остаются на устройстве. Синхронизируются только явно отмеченные несекретные настройки в зашифрованных данных. Пакеты и разрешения не синхронизируются.",
"settings.plugins.market.results": "Плагинов: {count}",
"settings.plugins.manager.lastError": "Последняя записанная ошибка",
"settings.plugins.market.offline": "Реестр недоступен. Показан последний корректный каталог или встроенные примеры. Для установки необходимо успешно обновить каталог.",
"settings.plugins.manager.details": "Подробнее",
"settings.plugins.manager.noDescription": "Описание не предоставлено.",
"settings.plugins.market.installed": "Установлен",
"settings.plugins.market.sourceBadge": "Пример исходного кода",
"settings.plugins.market.title": "Каталог",
@@ -1170,6 +1197,7 @@ export const ruCoreMessages: Messages = {
"settings.plugins.market.noResults": "Совпадений нет. Измените запрос.",
"settings.plugins.market.manifest": "Совместимость и разрешения",
"settings.plugins.manager.unbounded": "Более узкая область ресурсов не заявлена. Проверьте фактическую область в запросе разрешения.",
"settings.plugins.openView": "Открыть",
"settings.plugins.openManager": "Открыть управление плагинами",
"settings.plugins.title": "Плагины",
"settings.plugins.description": "Управляйте плагинами и просматривайте каталог.",
+28
View File
@@ -1160,6 +1160,30 @@ Vaults 标签保持扁平,不使用这块背景。
"settings.plugins.manager.disable": "停用",
"settings.plugins.manager.remove": "卸载",
"settings.plugins.manager.removeNote": "卸载会移除插件代码和本地 KV 数据,保留用户设置、秘密存储和同步 sidecar。",
"settings.plugins.permissions.statusUnknown": "状态未知",
"plugins.interaction.title": "插件请求",
"plugins.interaction.sharing": "分享的文本将交给此插件及其已授权的能力(包括网络访问)。读取文本不代表批准新的网络目标。",
"plugins.interaction.input": "输入文本",
"plugins.interaction.filter": "筛选选项",
"plugins.interaction.preview": "剪贴板预览",
"plugins.interaction.previewNote": "仅在点击“预览剪贴板”后读取。检查文本后,还需单独确认才能分享给插件。",
"plugins.interaction.previewAction": "预览剪贴板",
"plugins.interaction.share": "分享给插件",
"plugins.interaction.copy": "复制到剪贴板",
"plugins.interaction.failed": "此请求已过期或无法完成。",
"settings.plugins.permissions.current": "匹配当前运行实例",
"settings.plugins.permissions.inactive": "对当前运行实例不生效",
"settings.plugins.permissions.title": "已保存的授权",
"settings.plugins.permissions.refresh": "刷新",
"settings.plugins.permissions.loading": "正在读取授权...",
"settings.plugins.permissions.empty": "没有已保存的授权。单次决定不会保留。",
"settings.plugins.permissions.always": "直到撤销",
"settings.plugins.permissions.application": "直到退出 Netcatty",
"settings.plugins.permissions.session": "直到会话关闭",
"settings.plugins.permissions.audit": "最近的安全事件(最多 100 条)",
"settings.plugins.permissions.revoke": "停用并撤销全部授权",
"settings.plugins.permissions.revokeNote": "此操作会停用插件、取消其工作并删除已保存的授权。重新启用后可申请新授权。",
"settings.plugins.permissions.unavailable": "插件授权管理不可用",
"settings.plugins.manager.permissions": "声明的权限",
"settings.plugins.manager.permissionNote": "来自 manifest,不代表已授权。启用表示允许按需激活;必需权限在首次激活前申请,可选权限在用到时申请。敏感操作仍受宿主检查。",
"settings.plugins.manager.noPermissions": "未声明特权权限。",
@@ -1170,6 +1194,9 @@ Vaults 标签保持扁平,不使用这块背景。
"settings.plugins.manager.data": "本地 KV 仅保留在此设备。只有明确标记为同步的非秘密设置进入加密云 sidecar。插件包和权限授权不参与同步。",
"settings.plugins.market.results": "{count} 个插件",
"settings.plugins.manager.lastError": "上次记录的错误",
"settings.plugins.market.offline": "官方目录暂时不可用,正在显示上次有效目录或内置示例。刷新成功前不能从市场安装。",
"settings.plugins.manager.details": "查看详情",
"settings.plugins.manager.noDescription": "作者暂未提供功能简介。",
"settings.plugins.market.installed": "已安装",
"settings.plugins.market.sourceBadge": "源码示例",
"settings.plugins.market.title": "插件市场",
@@ -1190,6 +1217,7 @@ Vaults 标签保持扁平,不使用这块背景。
"settings.plugins.market.noResults": "没有匹配插件,请尝试其他关键词。",
"settings.plugins.market.manifest": "兼容性与权限",
"settings.plugins.manager.unbounded": "未声明更细的资源范围。请在权限提示中检查实际授权范围。",
"settings.plugins.openView": "打开",
"settings.plugins.openManager": "打开插件管理",
"settings.plugins.title": "插件",
"settings.plugins.description": "管理已安装插件,浏览精选目录。",
+28
View File
@@ -1163,6 +1163,30 @@ Vaults 分頁維持扁平,不使用這塊背景。
"settings.plugins.manager.disable": "停用",
"settings.plugins.manager.remove": "卸載",
"settings.plugins.manager.removeNote": "卸載會移除插件代碼和本地 KV 數據,保留用戶設置、秘密存儲和同步 sidecar。",
"settings.plugins.permissions.statusUnknown": "狀態不明",
"plugins.interaction.title": "外掛請求",
"plugins.interaction.sharing": "分享的文字會交給此外掛及其已授權能力(包括網路存取)。讀取文字不代表核准新的網路目的地。",
"plugins.interaction.input": "輸入文字",
"plugins.interaction.filter": "篩選選項",
"plugins.interaction.preview": "剪貼簿預覽",
"plugins.interaction.previewNote": "僅在點選「預覽剪貼簿」後讀取。檢查文字後,還需另外確認才能分享給外掛。",
"plugins.interaction.previewAction": "預覽剪貼簿",
"plugins.interaction.share": "分享給外掛",
"plugins.interaction.copy": "複製到剪貼簿",
"plugins.interaction.failed": "此請求已過期或無法完成。",
"settings.plugins.permissions.current": "符合目前執行個體",
"settings.plugins.permissions.inactive": "對目前執行個體不生效",
"settings.plugins.permissions.title": "已儲存的授權",
"settings.plugins.permissions.refresh": "重新整理",
"settings.plugins.permissions.loading": "正在讀取授權...",
"settings.plugins.permissions.empty": "沒有已儲存的授權。單次決定不會保留。",
"settings.plugins.permissions.always": "直到撤銷",
"settings.plugins.permissions.application": "直到退出 Netcatty",
"settings.plugins.permissions.session": "直到工作階段關閉",
"settings.plugins.permissions.audit": "最近的安全事件(最多 100 筆)",
"settings.plugins.permissions.revoke": "停用並撤銷全部授權",
"settings.plugins.permissions.revokeNote": "此操作會停用外掛、取消其工作並刪除已儲存的授權。重新啟用後可申請新授權。",
"settings.plugins.permissions.unavailable": "外掛授權管理無法使用",
"settings.plugins.manager.permissions": "聲明的權限",
"settings.plugins.manager.permissionNote": "來自 manifest,不代表已授權。啟用表示允許按需激活;必需權限在首次激活前申請,可選權限在用到時申請。敏感操作仍受宿主檢查。",
"settings.plugins.manager.noPermissions": "未聲明特權權限。",
@@ -1172,12 +1196,16 @@ Vaults 分頁維持扁平,不使用這塊背景。
"settings.plugins.manager.advanced": "高級運行時:擁有完整 Node 文件系統和網絡能力,代理權限無法限制這些訪問。僅供開發預覽。",
"settings.plugins.manager.data": "本地 KV 僅保留在此設備。只有明確標記為同步的非秘密設置進入加密雲 sidecar。插件包和權限授權不參與同步。",
"settings.plugins.manager.unbounded": "未宣告更細的資源範圍。請在權限提示中檢查實際授權範圍。",
"settings.plugins.openView": "開啟",
"settings.plugins.openManager": "開啟插件管理",
"settings.plugins.title": "插件",
"settings.plugins.description": "管理已安裝插件,瀏覽精選目錄。",
'settings.plugins.loading': '正在載入外掛程式設定…',
"settings.plugins.market.results": "{count} 個插件",
"settings.plugins.manager.lastError": "上次記錄的錯誤",
"settings.plugins.market.offline": "官方目錄暫時無法使用,正在顯示上次有效目錄或內建範例。重新整理成功前無法從市場安裝。",
"settings.plugins.manager.details": "查看詳情",
"settings.plugins.manager.noDescription": "作者尚未提供功能簡介。",
"settings.plugins.market.installed": "已安裝",
"settings.plugins.market.sourceBadge": "原始碼範例",
"settings.plugins.market.title": "插件市場",
+19
View File
@@ -0,0 +1,19 @@
import { useEffect, useState } from 'react';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
type Raster = { light: string; dark?: string };
export function usePluginBrandIcon(plugin?: NetcattyInstalledPlugin): Raster | null {
const pluginId = plugin?.id;
const kind = plugin?.manifest?.icon?.kind;
const key = JSON.stringify([pluginId, plugin?.activeVersion, plugin?.archiveSha256, plugin?.runtime?.quarantinedAt, plugin?.manifest?.icon]);
const [loaded, setLoaded] = useState<{ key: string; value: Raster | null } | null>(null);
useEffect(() => {
if (!pluginId || kind !== 'package') return;
let cancelled = false;
void netcattyBridge.get()?.getPluginBrandIcon?.(pluginId).then((value) => {
if (!cancelled) setLoaded({ key, value });
}).catch(() => { if (!cancelled) setLoaded({ key, value: null }); });
return () => { cancelled = true; };
}, [key, pluginId, kind]);
return loaded?.key === key ? loaded.value : null;
}
+33 -1
View File
@@ -16,7 +16,7 @@ test('README failures clear on retry/switch and stale requests cannot replace th
const calls: Array<{ id: string; request: ReturnType<typeof deferred<string>> }> = [];
Object.defineProperty(globalThis, 'window', { configurable: true, value: {
netcatty: {
listPluginCatalog: async () => [],
listPluginCatalog: async () => ({ entries: [], source: "builtin", error: null }),
getPluginCatalogReadme: (id: string) => { const request = deferred<string>(); calls.push({ id, request }); return request.promise; },
},
} });
@@ -50,3 +50,35 @@ test('README failures clear on retry/switch and stale requests cannot replace th
await act(async () => calls[4].request.resolve('late content'));
assert.equal(renders, unmountedRenders);
});
test('offline snapshot stays browsable, retry clears warning, and operations bind the selected token', async (context) => {
const original = Object.getOwnPropertyDescriptor(globalThis, 'window');
const entry = { id: 'com.test.registry', selectionToken: 'first' } as NetcattyPluginCatalogEntry;
let offline = true;
const reads: unknown[][] = []; const installs: unknown[][] = [];
Object.defineProperty(globalThis, 'window', { configurable: true, value: {
dispatchEvent() {}, netcatty: {
listPluginCatalog: async () => ({ entries: [{ ...entry, selectionToken: offline ? 'first' : 'second' }], source: offline ? 'cached' : 'registry', error: offline ? 'offline' : null }),
getPluginCatalogReadme: async (...args: unknown[]) => { reads.push(args); return '# Pinned'; },
installCatalogPlugin: async (...args: unknown[]) => { installs.push(args); },
},
} });
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
context.after(() => {
if (original) Object.defineProperty(globalThis, 'window', original); else Reflect.deleteProperty(globalThis, 'window');
Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
});
let state!: ReturnType<typeof usePluginCatalog>;
function Probe() { state = usePluginCatalog(entry.id); return null; }
let renderer!: ReactTestRenderer;
await act(async () => { renderer = create(React.createElement(Probe)); });
assert.equal(state.entries.length, 1); assert.equal(state.listStatus, 'ready'); assert.equal(state.listError, 'offline');
assert.equal(state.source, 'cached');
offline = false;
await act(async () => state.retryList());
assert.equal(state.listError, null); assert.equal(state.source, 'registry');
assert.deepEqual(reads.at(-1), [entry.id, 'second']);
await act(async () => state.install(entry.id));
assert.deepEqual(installs, [[entry.id, 'second']]);
await act(async () => renderer.unmount());
});
+10 -8
View File
@@ -1,10 +1,11 @@
import { useEffect, useRef, useState } from 'react';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
type ReadmeState = { id: string; attempt: number; status: 'loading' | 'ready' | 'error'; text: string; error: string | null };
type ReadmeState = { id: string; token?: string; attempt: number; status: 'loading' | 'ready' | 'error'; text: string; error: string | null };
export function usePluginCatalog(selectedId: string | null) {
const [entries, setEntries] = useState<NetcattyPluginCatalogEntry[]>([]);
const [listStatus, setListStatus] = useState<'loading' | 'ready' | 'error'>('loading');
const [source, setSource] = useState<NetcattyPluginCatalogSnapshot['source']>('builtin');
const [listError, setListError] = useState<string | null>(null);
const [listAttempt, setListAttempt] = useState(0);
const [readmeState, setReadmeState] = useState<ReadmeState | null>(null);
@@ -22,29 +23,30 @@ export function usePluginCatalog(selectedId: string | null) {
const bridge = netcattyBridge.get();
if (!bridge?.listPluginCatalog) throw new Error('Catalog browsing unavailable');
const list = await bridge.listPluginCatalog();
if (active) { setEntries(list); setListStatus('ready'); }
if (active) { setEntries(list.entries); setSource(list.source); setListError(list.error); setListStatus('ready'); }
} catch (cause) { if (active) { setListStatus('error'); setListError(String(cause)); } }
})();
return () => { active = false; };
}, [listAttempt]);
const selectionToken = entries.find((entry) => entry.id === selectedId)?.selectionToken;
useEffect(() => {
if (!selectedId) return;
let active = true;
const base = { id: selectedId, attempt: readmeAttempt, text: '', error: null };
const base = { id: selectedId, token: selectionToken, attempt: readmeAttempt, text: '', error: null };
setReadmeState({ ...base, status: 'loading' });
void (async () => {
try {
const bridge = netcattyBridge.get();
if (!bridge?.getPluginCatalogReadme) throw new Error('README retrieval unavailable');
const text = await bridge.getPluginCatalogReadme(selectedId);
const text = await bridge.getPluginCatalogReadme(selectedId, selectionToken);
if (active) setReadmeState({ ...base, status: 'ready', text });
} catch (cause) { if (active) setReadmeState({ ...base, status: 'error', error: String(cause) }); }
})();
return () => { active = false; };
}, [selectedId, readmeAttempt]);
const currentReadme = readmeState?.id === selectedId && readmeState.attempt === readmeAttempt ? readmeState : null;
}, [selectedId, selectionToken, readmeAttempt]);
const currentReadme = readmeState?.id === selectedId && readmeState.token === selectionToken && readmeState.attempt === readmeAttempt ? readmeState : null;
return {
entries, listStatus, listError,
entries, listStatus, listError, source,
readme: currentReadme?.status === 'ready' ? currentReadme.text : null,
readmeStatus: currentReadme?.status ?? (selectedId ? 'loading' : 'idle'),
readmeError: currentReadme?.error ?? null,
@@ -59,7 +61,7 @@ export function usePluginCatalog(selectedId: string | null) {
try {
const bridge = netcattyBridge.require();
if (!bridge.installCatalogPlugin) throw new Error('Catalog installation unavailable');
await bridge.installCatalogPlugin(id);
await bridge.installCatalogPlugin(id, entries.find((entry) => entry.id === id)?.selectionToken);
window.dispatchEvent(new Event('netcatty:plugin-packages-changed'));
} catch (cause) { if (mounted.current) setInstallError({ id, message: String(cause) }); }
finally { installing.current = false; if (mounted.current) setBusy(false); }
@@ -0,0 +1,53 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import type { HostInteractionPrompt, HostInteractionResponse } from '@netcatty/plugin-contract';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
export function usePluginHostInteractions() {
const [prompt, setPrompt] = useState<HostInteractionPrompt | null>(null);
const [value, setValue] = useState('');
const [error, setError] = useState(false);
const [busy, setBusy] = useState(false);
const current = useRef<HostInteractionPrompt | null>(null);
const pending = useRef(false);
useEffect(() => {
const bridge = netcattyBridge.get();
if (!bridge?.onPluginInteraction || !bridge.setPluginInteractionReady) return;
const unsubscribe = bridge.onPluginInteraction((event) => {
if ('closed' in event) {
if (current.current?.requestId !== event.requestId) return;
current.current = null; setPrompt(null);
} else {
current.current = event; setPrompt(event);
setValue(event.request.kind === 'input' ? event.request.initialValue ?? '' : '');
}
pending.current = false; setBusy(false); setError(false);
});
void bridge.setPluginInteractionReady(true).catch(() => {});
return () => {
current.current = null;
unsubscribe();
void bridge.setPluginInteractionReady?.(false).catch(() => {});
};
}, []);
const respond = useCallback(async (action: HostInteractionResponse['action']) => {
const active = current.current;
if (!active || pending.current) return;
pending.current = true; setBusy(true); setError(false);
try {
const bridge = netcattyBridge.require();
if (!bridge.respondPluginInteraction) throw new Error('Host interactions unavailable');
const accepted = await bridge.respondPluginInteraction({
requestId: active.requestId, actionToken: active.actionToken, action,
...(action === 'submit' && (active.request.kind === 'input' || active.request.kind === 'quickPick') ? { value } : {}),
});
if (!accepted && current.current?.actionToken === active.actionToken) {
current.current = null; setPrompt(null);
}
} catch {
if (current.current?.actionToken === active.actionToken) setError(true);
} finally {
if (current.current?.actionToken === active.actionToken) { pending.current = false; setBusy(false); }
}
}, [value]);
return { prompt, value, setValue, error, busy, respond };
}
@@ -0,0 +1,39 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import React from 'react';
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
import { usePluginManager } from './usePluginManager';
test('failed manager refresh marks previous enabled state unknown until a successful read', async (context) => {
const previous = Object.getOwnPropertyDescriptor(globalThis, 'window');
const actPrevious = Object.getOwnPropertyDescriptor(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
let fail = false;
const plugin = { id: 'com.example.synthetic', enabled: true } as NetcattyInstalledPlugin;
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', { configurable: true, value: true });
Object.defineProperty(globalThis, 'window', { configurable: true, value: {
addEventListener() {}, removeEventListener() {}, netcatty: {
async getPluginRuntimeStatus() { return { available: true }; },
async listPlugins() { if (fail) throw new Error('synthetic status read failed'); return [plugin]; },
},
} });
context.after(() => {
if (previous) Object.defineProperty(globalThis, 'window', previous); else Reflect.deleteProperty(globalThis, 'window');
if (actPrevious) Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', actPrevious); else Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
});
let state!: ReturnType<typeof usePluginManager>;
function Probe() { state = usePluginManager(); return null; }
let renderer!: ReactTestRenderer;
await act(async () => { renderer = create(React.createElement(Probe)); });
try {
assert.equal(state.statusKnown, true);
assert.equal(state.plugins[0].enabled, true);
fail = true;
await act(async () => { await assert.rejects(state.refresh(), /synthetic status read failed/); });
assert.equal(state.statusKnown, false);
assert.match(state.error ?? '', /synthetic status read failed/);
fail = false; plugin.enabled = false;
await act(async () => { await state.refresh(); });
assert.equal(state.statusKnown, true);
assert.equal(state.plugins[0].enabled, false);
} finally { await act(async () => { renderer.unmount(); }); }
});
+18 -8
View File
@@ -4,6 +4,7 @@ import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
export function usePluginManager() {
const [plugins, setPlugins] = useState<NetcattyInstalledPlugin[]>([]);
const [available, setAvailable] = useState(false);
const [statusKnown, setStatusKnown] = useState(false);
const [credentialStorageAvailable, setCredentialStorageAvailable] = useState<boolean | undefined>();
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -11,13 +12,22 @@ export function usePluginManager() {
const mutation = useRef(false);
const refresh = useCallback(async () => {
const current = ++generation.current;
const bridge = netcattyBridge.get();
const status = await bridge?.getPluginRuntimeStatus?.();
const list = status?.available ? await bridge?.listPlugins?.() ?? [] : [];
if (current !== generation.current) return;
setAvailable(status?.available === true);
setCredentialStorageAvailable(status?.credentialStorageAvailable);
setPlugins(list);
try {
const bridge = netcattyBridge.get();
const status = await bridge?.getPluginRuntimeStatus?.();
const list = status?.available ? await bridge?.listPlugins?.() ?? [] : [];
if (current !== generation.current) return;
setAvailable(status?.available === true);
setStatusKnown(true);
setCredentialStorageAvailable(status?.credentialStorageAvailable);
setPlugins(list);
} catch (cause) {
if (current === generation.current) {
setStatusKnown(false);
setError(cause instanceof Error ? cause.message : String(cause));
}
throw cause;
}
}, []);
useEffect(() => {
const update = () => { void refresh().catch((cause) => setError(String(cause))); };
@@ -41,7 +51,7 @@ export function usePluginManager() {
}
}, [refresh]);
return {
plugins, available, busy, error, credentialStorageAvailable,
plugins, available, statusKnown, busy, error, credentialStorageAvailable, refresh,
importPackage: () => run(async (bridge) => {
if (!bridge.selectFile || !bridge.installPluginPackage) throw new Error('Plugin import unavailable');
const path = await bridge.selectFile('Import plugin package', undefined, [{ name: 'Netcatty plugin', extensions: ['ncpkg'] }]);
@@ -0,0 +1,91 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { EventEmitter } from 'node:events';
import { JSDOM } from 'jsdom';
test('host dialog drives choice/input/copy and the two-phase clipboard share using real service action tokens', async () => {
const dom = new JSDOM('<!doctype html><html><body><div id="root"></div></body></html>', { pretendToBeVisual: true, url: 'http://localhost' });
const previous = new Map<string, PropertyDescriptor | undefined>();
const install = (key: string, value: unknown) => {
previous.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
Object.defineProperty(globalThis, key, { configurable: true, writable: true, value });
};
for (const key of ['window', 'document', 'navigator', 'HTMLElement', 'HTMLInputElement', 'Element', 'Node', 'NodeFilter', 'MutationObserver', 'CustomEvent', 'Event', 'KeyboardEvent', 'MouseEvent']) {
install(key, key === 'window' ? dom.window : dom.window[key as keyof typeof dom.window]);
}
install('getComputedStyle', dom.window.getComputedStyle.bind(dom.window));
install('requestAnimationFrame', dom.window.requestAnimationFrame.bind(dom.window));
install('cancelAnimationFrame', dom.window.cancelAnimationFrame.bind(dom.window));
install('IS_REACT_ACT_ENVIRONMENT', true);
const { default: React, act } = await import('react');
const { createRoot } = await import('react-dom/client');
const { I18nProvider } = await import('../application/i18n/I18nProvider');
const { PluginHostInteractionHost } = await import('./plugins/PluginHostInteractionHost');
const { PluginHostInteractionService, assertInteractionRequest } = await import('../electron/plugins/hostInteractionService.cjs');
let listener: ((event: unknown) => void) | null = null;
let reads = 0;
const writes: string[] = [];
const audits: unknown[] = [];
const sender = Object.assign(new EventEmitter(), { id: 7, isDestroyed: () => false, send(_channel: string, payload: unknown) { listener?.(payload); } });
const owner = Object.assign(new EventEmitter(), { webContents: sender, isFocused: () => true, isVisible: () => true, isDestroyed: () => false });
const service = new PluginHostInteractionService({
getWindow: () => owner, isLocked: () => false,
permissionEngine: { onDidRevoke() { return { dispose() {} }; } },
database: { recordSecurityAudit(...args: unknown[]) { audits.push(args); } },
quotaManager: { chargeBytes() {} },
clipboard: { readText() { reads++; return 'synthetic private text'; }, writeText(value: string) { writes.push(value); } },
});
Object.assign(dom.window, { netcatty: {
onPluginInteraction(callback: (event: unknown) => void) { listener = callback; return () => { listener = null; }; },
async setPluginInteractionReady(ready: boolean) { return service.setReady(sender, ready); },
async respondPluginInteraction(response: unknown) { return service.respond(sender, response); },
} });
const context = { pluginId: 'com.example.synthetic', pluginVersion: '1.0.0', runtimeId: 'runtime-synthetic', signal: new AbortController().signal, async assertActive() {} };
const root = createRoot(document.getElementById('root')!);
const settle = () => new Promise((resolve) => setTimeout(resolve, 20));
const button = (text: string) => Array.from(document.querySelectorAll<HTMLButtonElement>('button')).find((item) => item.textContent === text)!;
const click = async (element: HTMLElement) => { assert.ok(element); await act(async () => { element.click(); await settle(); }); };
const begin = async (params: unknown) => {
let promise!: Promise<unknown>;
await act(async () => { promise = service.request(assertInteractionRequest(params), context); void promise.catch(() => {}); await settle(); });
return { promise };
};
try {
await act(async () => root.render(<I18nProvider locale="en"><PluginHostInteractionHost /></I18nProvider>));
const choice = await begin({ kind: 'quickPick', title: 'Synthetic choice', items: [{ id: 'compose', label: 'Compose' }] });
assert.match(document.querySelector('[role="dialog"]')?.textContent ?? '', /com.example.synthetic/);
assert.equal(button('Continue').disabled, true);
await click(document.querySelector<HTMLInputElement>('input[type="radio"]')!);
await click(button('Continue'));
assert.deepEqual(await choice.promise, { cancelled: false, value: 'compose' });
await assert.rejects(begin({ kind: 'input', title: 'Invalid hidden newline', initialValue: 'first\nsecond' }), /invalid/);
assert.equal(document.querySelector('[role="dialog"]'), null, 'invalid initial text never creates a misleading single-line dialog');
const input = await begin({ kind: 'input', title: 'Synthetic input', initialValue: 'synthetic output' });
assert.equal(document.querySelector<HTMLInputElement>('input')?.value, 'synthetic output');
await click(button('Continue'));
assert.deepEqual(await input.promise, { cancelled: false, value: 'synthetic output' });
const copy = await begin({ kind: 'clipboard.write', title: 'Copy result', text: 'synthetic output' });
assert.deepEqual(writes, []);
assert.equal(document.querySelector('textarea')?.value, 'synthetic output');
await click(button('Copy to clipboard'));
assert.deepEqual(await copy.promise, { cancelled: false, value: true });
assert.deepEqual(writes, ['synthetic output']);
const share = await begin({ kind: 'clipboard.read', title: 'Share clipboard' });
assert.equal(reads, 0);
assert.equal(document.querySelector('textarea'), null);
await click(button('Preview clipboard'));
assert.equal(reads, 1);
assert.equal(document.querySelector('textarea')?.value, 'synthetic private text');
await click(button('Share with plugin'));
assert.deepEqual(await share.promise, { cancelled: false, value: 'synthetic private text' });
assert.equal(JSON.stringify(audits).includes('synthetic private text'), false);
const cancelled = await begin({ kind: 'clipboard.write', title: 'Cancelled copy', text: 'must not copy' });
await click(button('Cancel'));
assert.deepEqual(await cancelled.promise, { cancelled: true });
assert.deepEqual(writes, ['synthetic output']);
assert.equal(service.pending.size, 0);
} finally {
await act(async () => { root.unmount(); await settle(); }); service.shutdown(); dom.window.close();
for (const [key, descriptor] of previous) { if (descriptor) Object.defineProperty(globalThis, key, descriptor); else Reflect.deleteProperty(globalThis, key); }
}
});
+26
View File
@@ -0,0 +1,26 @@
import React, { useState } from 'react';
import { Package } from 'lucide-react';
import { usePluginBrandIcon } from '../../application/state/usePluginBrandIcon';
import { PluginContributionIcon } from './PluginContributionIcon';
import { resolveCatalogBrandIcon } from './catalogBrandIcons';
export function PluginBrandIcon({ plugin, entry, size = 32, className = '' }: {
plugin?: NetcattyInstalledPlugin;
entry?: NetcattyPluginCatalogEntry;
size?: number;
className?: string;
}) {
const manifest = plugin?.manifest ?? entry?.manifest;
const icon = manifest?.icon;
const key = JSON.stringify([plugin?.id, plugin?.activeVersion, plugin?.archiveSha256, plugin?.runtime?.quarantinedAt, icon]);
const loaded = usePluginBrandIcon(plugin);
const [failed, setFailed] = useState<string | null>(null);
const raster = plugin ? loaded : entry ? resolveCatalogBrandIcon(entry) : null;
const imageKey = JSON.stringify([key, raster]);
if (icon?.kind === 'theme') return <PluginContributionIcon icon={icon} size={size} className={className} />;
if (!raster || failed === imageKey) return <Package size={size} className={className} aria-hidden="true" data-plugin-brand="fallback" />;
return <span className={`inline-flex shrink-0 ${className}`} style={{ width: size, height: size }} aria-hidden="true" data-plugin-brand="package">
<img src={raster.light} alt="" width={size} height={size} className={raster.dark ? 'h-full w-full object-contain dark:hidden' : 'h-full w-full object-contain'} onError={() => setFailed(imageKey)} />
{raster.dark && <img src={raster.dark} alt="" width={size} height={size} className="hidden h-full w-full object-contain dark:block" onError={() => setFailed(imageKey)} />}
</span>;
}
@@ -60,7 +60,7 @@ export function PluginContributionIcon({
const packageIcon = usePluginContributionIcon(pluginId, icon);
if (icon?.kind === 'theme') {
const Icon = THEME_ICONS[icon.name] ?? Puzzle;
const Icon = Object.hasOwn(THEME_ICONS, icon.name) ? THEME_ICONS[icon.name] : Puzzle;
return <Icon size={size} className={className} aria-hidden="true" data-plugin-icon-kind="theme" />;
}
if (packageIcon) {
@@ -0,0 +1,54 @@
import React, { useState } from 'react';
import { PLUGIN_HOST_INTERACTION_LIMITS } from '@netcatty/plugin-contract';
import { useI18n } from '../../application/i18n/I18nProvider';
import { usePluginHostInteractions } from '../../application/state/usePluginHostInteractions';
import { Button } from '../ui/button';
import { Input } from '../ui/input';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '../ui/dialog';
export function PluginHostInteractionHost() {
const state = usePluginHostInteractions();
if (!state.prompt) return null;
return <HostInteractionDialog key={state.prompt.requestId} state={state} prompt={state.prompt} />;
}
function HostInteractionDialog({ state, prompt }: {
state: ReturnType<typeof usePluginHostInteractions>;
prompt: NonNullable<ReturnType<typeof usePluginHostInteractions>['prompt']>;
}) {
const { t } = useI18n();
const [search, setSearch] = useState('');
const request = prompt.request;
const previewClipboard = request.kind === 'clipboard.read' && prompt.phase === 'request';
const textBytes = new TextEncoder().encode(state.value).length;
const canSubmit = request.kind === 'quickPick' ? request.items.some((item) => item.id === state.value) : textBytes <= PLUGIN_HOST_INTERACTION_LIMITS.textBytes;
return <Dialog open onOpenChange={(open) => { if (!open) void state.respond('cancel'); }}>
<DialogContent className="sm:max-w-[520px]" hideCloseButton>
<DialogHeader>
<DialogTitle>{t('plugins.interaction.title')}</DialogTitle>
<DialogDescription className="break-all">{prompt.pluginId} · {prompt.pluginVersion}</DialogDescription>
</DialogHeader>
<p className="break-words text-sm font-medium">{request.title}</p>
<p className="text-xs text-muted-foreground">{t('plugins.interaction.sharing')}</p>
{request.kind === 'input' && <Input aria-label={t('plugins.interaction.input')} value={state.value} maxLength={PLUGIN_HOST_INTERACTION_LIMITS.textBytes} autoComplete="off" autoFocus disabled={state.busy} onChange={(event) => state.setValue(event.target.value)} />}
{request.kind === 'quickPick' && <>
<Input aria-label={t('plugins.interaction.filter')} value={search} onChange={(event) => setSearch(event.target.value)} autoFocus disabled={state.busy} />
<div className="max-h-64 space-y-2 overflow-y-auto" role="radiogroup" aria-label={request.title}>
{request.items.filter((item) => `${item.label} ${item.description ?? ''}`.toLocaleLowerCase().includes(search.toLocaleLowerCase())).map((item) => <label key={item.id} className="flex items-start gap-3 rounded-md border p-3">
<input type="radio" name="plugin-host-choice" value={item.id} checked={state.value === item.id} disabled={state.busy} onChange={() => state.setValue(item.id)} />
<span className="min-w-0 break-words text-sm">{item.label}{item.description && <span className="block text-xs text-muted-foreground">{item.description}</span>}</span>
</label>)}
</div>
</>}
{previewClipboard && <p className="text-sm">{t('plugins.interaction.previewNote')}</p>}
{(request.kind === 'clipboard.write' || prompt.phase === 'preview') && <textarea aria-label={t('plugins.interaction.preview')} readOnly className="min-h-28 max-h-64 w-full resize-none rounded-md border bg-muted/30 p-3 font-mono text-xs" value={request.kind === 'clipboard.write' ? request.text : prompt.previewText ?? ''} />}
{state.error && <p role="alert" className="text-sm text-destructive">{t('plugins.interaction.failed')}</p>}
<DialogFooter>
<Button variant="outline" disabled={state.busy} onClick={() => void state.respond('cancel')}>{t('common.cancel')}</Button>
<Button disabled={state.busy || !canSubmit} onClick={() => void state.respond(previewClipboard ? 'preview' : 'submit')}>
{t(previewClipboard ? 'plugins.interaction.previewAction' : request.kind === 'clipboard.read' ? 'plugins.interaction.share' : request.kind === 'clipboard.write' ? 'plugins.interaction.copy' : 'common.continue')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>;
}
@@ -0,0 +1,17 @@
[
{
"id": "com.netcatty.starter",
"version": "0.1.2",
"repository": "binaricat/Netcatty",
"commit": "f4a6b205adca486d4e9cda0270e13a9ff0afd7be",
"icon": {
"kind": "package",
"light": "assets/logo-light.png",
"dark": "assets/logo-dark.png"
},
"light": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAIAAAACACAYAAADDPmHLAAADMElEQVR4nO2d200cQRAAmwuAGJAcF3E4BsfhuCwRAwEYf9lGyz5mdqZf01WfSNydqJreYbUMIgAAAFCRJ8s3e3v//WH5fpl5eX6YuFF9E4TPQysIlRdFvB6zQ5j6Yoi3Y1YIU14E8X6MhvAY/QDI92X05z8UAPJjMOLh1vhAfFx6LwndEwD5sen10xUA8nPQ42l4Ewi5aQ6A1Z+LVl9NASA/Jy3eLgNAfm6u/LEHKM5pAKz+NTjzyAQozmEArP61OPLJBCjObgCs/jXZ88oEKA4BFIcAivMlAK7/a7P1ywQoDgEUhwCKQwDFIYDiEEBxCKA4YQL49v2n90coSYgA/sonAnvcA9hKJwJbXAM4kk0EdrgFcCWZCGxwCaBVLhHoYx5Ar1Qi0MU0gLsyiUAP0wB+/Xi9/b1EoIP5JYAIYuGyCSSCOLj9GkgEMXC9EUQE/rjfCiYCX9wDECECT0IEIEIEXoQJQIQIPAgVgAgRWBMuABEisCRkACJEYEXYAESIwILQAYgQgTbhAxAhAk1SBCBCBFqkCUCECDRIFYDI/QhG4lmZdAGI9MtE/jEpAxBpl4r8c9IGwKPlc0gZAI+WzyNdADxaPpdUAYxIZC+wT5oAkK9DigCQr0f4AJCvS+gAkK9P2ACQb0PIAJBvR7gAkG9LqACQb0+YAJDvQ4gAkO+HewDI9yXkOYEtIH8OYc8JPAP58wh9TuAeyJ9L+HMCP4P8+aQ4J1AE+VqkOCcQ+Xo8bb9g8Y8jeyaBhXzvx8WsA395fvzzHvqcQFa+PmHPCUS+DSHPCUS+He63greykW+LewAi/6Uj354QAYgg34swAYAPBFAclxtB4Iv7jSCIAwEUhwCKQwDFIYDiEEBxCKA4BFCcLwF8vkkA67H1ywQoDgEUhwCKsxsA+4A12fPKBCjOYQBMgbU48skEKM5pAEyBNTjzyAQozmUATIHcXPlrmgBEkJMWb82XACLIRasv9gDF6QqAKZCDHk/dE4AIYtPrZ0gmf0QSh7sLc2gPwDSIwYiH4U0gEfgy+vOfKo9Lgh2zFp7K6iUEPWZPXNXxTQjz0LrUml6/CaId9lYAAKDMHwpGNG+oRdPRAAAAAElFTkSuQmCC",
"lightSha256": "ec7bcd5e470ee3d3d9f7f77d120a4de7496a9c0ba984094f84e560d240bea947",
"dark": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAIAAAACACAYAAADDPmHLAAADMElEQVR4nO2cO07EMBQAH4iSk1FyBmokToJEzRkoOdn2UCHQko8d+/3yZvrdrJjxi4kiiwAAAEBFbiwvdv/w/GV5vcxcPt9M3KheBOHz0ApC5UsRr8fsEKZ+GeLtmBXClC9BvB+jIdyO/gDk+zL69x8KAPkxGPFwaHwgPi69t4TuCYD82PT66QoA+Tno8TS8CYTcNAfA6s9Fq6+mAJCfkxZvuwEgPzd7/tgDFGczAFb/OdjyyAQozmoArP5zseaTCVCcxQBY/edkySsToDgEUBwCKM6/ALj/n5trv0yA4hBAcQigOARQHAIoDgEUhwCKEyaAj/dX759QkhAB/MgnAnvcA7iWTgS2uAawJpsI7HALYE8yEdjgEkCrXCLQxzyAXqlEoItpAEdlEoEepgE8Pr0c/iwR6GB+CyCCWLhsAokgDm7/BhJBDFwfBBGBP+6PgonAF/cARIjAkxABiBCBF2ECECECD0IFIEIE1oQLQIQILAkZgAgRWBE2ABEisCB0ACJEoE34AESIQJMUAYgQgRZpAhAhAg1SBSByPIKReM5MugBE+mUif52UAYi0S0X+NmkD4NXyOaQMgFfL55EuAF4tn0uqAEYkshdYJk0AyNchRQDI1yN8AMjXJXQAyNcnbADItyFkAMi3I1wAyLclVADItydMAMj3IUQAyPfDPQDk+xLynMAWkD+HsOcEboH8eYQ+J3AJ5M8l/DmBf0H+fFKcEyiCfC1SnBOIfD3urC/4+PTSNQks5Hu/LuYZeOhzAln5+oQ9JxD5NoQ8JxD5drg/Cr6WjXxb3AMQ+ZWOfHtCBCCCfC/CBAA+EEBxzB8ERaTy7YcJUBwCKA4BFIcAikMAxSGA4hBAcQigOP8CuHy+3Xj8ELDh2i8ToDgEUBwCKM5iAOwDzsmSVyZAcVYDYAqcizWfTIDibAbAFDgHWx6ZAMXZDYApkJs9f00TgAhy0uKt+RZABLlo9cUeoDhdATAFctDjqXsCEEFsev0Mybx/eP4a+TzM4+jCHNoDMA1iMOJheBNIBL6M/v2nyuOWYMeshaeyeglBj9kTV3V8E8I8tG61pvdvgmiHvRUAACjzDQ0NL/vgx29SAAAAAElFTkSuQmCC",
"darkSha256": "7a897a67ae6b652339c77a113d1c759919776fead82d2eefd0238d3c05ba87fe"
}
]
+9
View File
@@ -0,0 +1,9 @@
import previews from './catalog-brand-icons.json';
/** Only bundled reviewed assets; catalog data never becomes an image URL. */
export function resolveCatalogBrandIcon(entry: NetcattyPluginCatalogEntry): { light: string; dark?: string } | null {
const preview = previews.find((item) => item.id === entry.id && item.version === entry.version
&& item.repository === entry.repository && item.commit === entry.commit
&& JSON.stringify(item.icon) === JSON.stringify(entry.manifest.icon));
return preview ? { light: preview.light, dark: preview.dark } : null;
}
@@ -0,0 +1,70 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import React from 'react';
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
import { renderToStaticMarkup } from 'react-dom/server';
import { readFileSync } from 'node:fs';
import { createHash } from 'node:crypto';
import { inspectPluginBrandIcon } from '@netcatty/plugin-contract';
import { PluginBrandIcon } from '../../plugins/PluginBrandIcon';
import { resolveCatalogBrandIcon } from '../../plugins/catalogBrandIcons';
import previews from '../../plugins/catalog-brand-icons.json';
const preview = previews[0];
const entry = { ...preview, manifest: { icon: preview.icon } } as unknown as NetcattyPluginCatalogEntry;
test('reviewed catalog brand previews bind version, source and declaration, never arbitrary URLs', () => {
assert.ok(resolveCatalogBrandIcon(entry));
const catalog = JSON.parse(readFileSync(new URL('../../../electron/plugins/catalog.json', import.meta.url), 'utf8')) as NetcattyPluginCatalogEntry[];
assert.ok(resolveCatalogBrandIcon(catalog.find((item) => item.id === preview.id)!));
for (const replacement of [{ version: '9.0.0' }, { commit: '0'.repeat(40) }, { repository: 'untrusted/plugin' }, { manifest: { icon: { kind: 'package', light: 'https://attacker.invalid/a.png' } } }]) {
assert.equal(resolveCatalogBrandIcon({ ...entry, ...replacement } as NetcattyPluginCatalogEntry), null);
}
for (const theme of ['light', 'dark'] as const) {
const bytes = Buffer.from(preview[theme].split(',')[1], 'base64');
assert.equal(createHash('sha256').update(bytes).digest('hex'), preview[`${theme}Sha256`]);
assert.equal(inspectPluginBrandIcon(bytes, preview.icon[theme]).width, 128);
assert.deepEqual(bytes, readFileSync(new URL(`../../../examples/plugins/starter-netcatty/${preview.icon[theme]}`, import.meta.url)));
}
const html = renderToStaticMarkup(<PluginBrandIcon entry={entry} />);
assert.match(html, /data-plugin-brand="package"/);
assert.match(html, /dark:hidden/);
assert.match(html, /dark:block/);
assert.doesNotMatch(html, /src="https?:/);
assert.match(renderToStaticMarkup(<PluginBrandIcon />), /data-plugin-brand="fallback"/);
});
test('installed brand uses metadata only, rejects stale responses, and falls back after image errors', async () => {
const oldWindow = Object.getOwnPropertyDescriptor(globalThis, 'window');
const previousAct = (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT;
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const requests: Array<(v: { light: string }) => void> = [];
Object.defineProperty(globalThis, 'window', { configurable: true, value: { netcatty: { getPluginBrandIcon: () => new Promise((resolve) => requests.push(resolve)) } } });
const plugin = { id: 'com.netcatty.starter', enabled: false, activeVersion: '1.0.0', archiveSha256: 'a', runtime: {}, manifest: { icon: preview.icon } } as NetcattyInstalledPlugin;
let renderer: ReactTestRenderer | undefined;
try {
await act(async () => { renderer = create(<PluginBrandIcon plugin={plugin} />); });
assert.equal(requests.length, 1);
await act(async () => { renderer!.update(<PluginBrandIcon plugin={{ ...plugin, archiveSha256: 'b' }} />); });
await act(async () => requests[0]({ light: preview.light }));
assert.equal(renderer!.root.findAllByType('img').length, 0);
await act(async () => requests[1]({ light: preview.light }));
assert.equal(renderer!.root.findAllByType('img').length, 1);
await act(async () => renderer!.root.findByType('img').props.onError());
assert.equal(renderer!.root.findAllByType('img').length, 0);
await act(async () => { renderer!.update(<PluginBrandIcon />); });
assert.equal(renderer!.root.findAllByType('img').length, 0);
} finally {
await act(async () => renderer?.unmount());
if (oldWindow) Object.defineProperty(globalThis, 'window', oldWindow); else Reflect.deleteProperty(globalThis, 'window');
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = previousAct;
}
});
test('disabled brand theme names cannot resolve Object prototype members', () => {
for (const name of ['constructor', '__proto__', 'toString', 'valueOf', 'hasOwnProperty']) {
const plugin = { id: 'com.test.theme', enabled: false, activeVersion: '1.0.0', runtime: {}, manifest: { icon: { kind: 'theme', name } } } as NetcattyInstalledPlugin;
const html = renderToStaticMarkup(<PluginBrandIcon plugin={plugin} />);
assert.match(html, /lucide-puzzle/);
assert.doesNotMatch(html, /<img/);
}
});
@@ -0,0 +1,63 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import React from 'react';
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
import { I18nProvider } from '../../../application/i18n/I18nProvider';
import { PluginPackageManager } from './PluginPackageManager';
import type { usePluginManager } from '../../../application/state/usePluginManager';
test('installed grid filters, opens one detail and restores search, scroll and card focus without pill navigation', async () => {
const globals = globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean };
const previous = globals.IS_REACT_ACT_ENVIRONMENT; globals.IS_REACT_ACT_ENVIRONMENT = true;
const raf = globalThis.requestAnimationFrame;
const frames: FrameRequestCallback[] = [];
globalThis.requestAnimationFrame = (fn) => { frames.push(fn); return frames.length; };
const selections: unknown[] = []; const toggles: unknown[] = []; const focus: string[] = [];
const pane = { scrollTop: 160 };
const plugins = ['Alpha', 'Beta'].map((name) => ({ id: `com.test.${name.toLowerCase()}`, enabled: false, activeVersion: '1.0.0',
manifest: { name, publisher: 'Example', description: `${name} safely formats synthetic greetings without accessing hosts or secrets.`, license: 'MIT', main: { browser: 'index.js' }, engines: { api: '0.1.0-internal', netcatty: '*' } },
runtime: { lastError: name === 'Beta' ? 'Incompatible version' : null },
})) as NetcattyInstalledPlugin[];
const manager = { available: true, plugins, busy: false, setEnabled: async (...args: unknown[]) => { toggles.push(args); } } as unknown as ReturnType<typeof usePluginManager>;
let renderer!: ReactTestRenderer;
try {
await act(async () => { renderer = create(<I18nProvider locale="en"><PluginPackageManager manager={manager} onSelectionChange={(id) => selections.push(id)} /></I18nProvider>, {
createNodeMock: (element) => element.type === 'section' ? {
closest: () => pane,
querySelector: () => ({ focus: () => focus.push('detail') }),
querySelectorAll: () => plugins.map((plugin) => ({ dataset: { pluginCard: plugin.id }, focus: () => focus.push(plugin.id) })),
} : null,
}); });
const cards = () => renderer.root.findAllByType('button').filter((node) => node.props['data-plugin-card']);
assert.equal(cards().length, 2);
const alphaCard = renderer.root.findAllByType('article')[0];
assert.match(JSON.stringify(renderer.toJSON()), /Alpha safely formats synthetic greetings/);
assert.ok(alphaCard.findAllByType('p').some((node) => node.children.includes('Example')), 'author has its own line');
assert.ok(alphaCard.findAllByType('p').some((node) => node.props.className?.includes('line-clamp-3')), 'card description is bounded');
assert.match(JSON.stringify(renderer.toJSON()), /MIT/);
assert.match(JSON.stringify(renderer.toJSON()), /Incompatible version/);
assert.ok(renderer.root.findAllByType('div').some((node) => node.props.className?.includes('repeat(auto-fill')));
const pill = renderer.root.findAllByType('button').find((node) => node.props['aria-pressed'] !== undefined)!;
await act(async () => pill.props.onClick());
assert.deepEqual(toggles, [[plugins[0].id, true]]); assert.deepEqual(selections, []);
await act(async () => renderer.root.findByType('input').props.onChange({ target: { value: 'Alpha' } }));
assert.equal(cards().length, 1);
await act(async () => cards()[0].props.onClick()); frames.splice(0).forEach((fn) => fn(0));
assert.deepEqual(selections, [plugins[0].id]); assert.equal(pane.scrollTop, 0); assert.equal(focus.at(-1), 'detail');
assert.equal(renderer.root.findAllByType('input').length, 0);
assert.match(JSON.stringify(renderer.toJSON()), /Declared permissions/);
assert.ok(renderer.root.findByType('article').findAllByType('p').some((node) => node.props.className?.includes('whitespace-pre-wrap')), 'detail shows the complete description');
const back = renderer.root.findAllByType('button').find((node) => node.children.includes('Installed plugins'))!;
await act(async () => back.props.onClick()); frames.splice(0).forEach((fn) => fn(0));
assert.equal(renderer.root.findByType('input').props.value, 'Alpha'); assert.equal(cards().length, 1);
assert.equal(pane.scrollTop, 160); assert.equal(focus.at(-1), plugins[0].id);
await act(async () => renderer.root.findByType('input').props.onChange({ target: { value: 'none' } }));
assert.equal(cards().length, 0);
await act(async () => renderer.root.findByType('input').props.onChange({ target: { value: '' } }));
await act(async () => renderer.update(<I18nProvider locale="en"><PluginPackageManager manager={{ ...manager, statusKnown: false, error: 'synthetic state unavailable' }} onSelectionChange={(id) => selections.push(id)} /></I18nProvider>));
const unknown = renderer.root.findAllByType('button').filter((node) => node.children.includes('Status unknown'));
assert.equal(unknown.length, 2);
assert.ok(unknown.every((node) => node.props.disabled === true && node.props['aria-pressed'] === undefined));
await act(async () => renderer.unmount());
} finally { globals.IS_REACT_ACT_ENVIRONMENT = previous; globalThis.requestAnimationFrame = raf; }
});
@@ -14,7 +14,7 @@ const entries = ['Alpha', 'Beta'].map((name) => ({
manifest: { manifestVersion: 1, id: `com.example.${name.toLowerCase()}`, name, version: '1.0.0', publisher: 'Example', engines: { netcatty: '>=1.0.0', api: '0.1.0-internal' }, main: { browser: 'index.js' }, permissions: { required: ['settings.read'] } },
})) as NetcattyPluginCatalogEntry[];
const catalog = {
entries, listStatus: 'ready', listError: null, readme: '# Author documentation', readmeStatus: 'ready', readmeError: null,
entries, source: 'registry', listStatus: 'ready', listError: null, readme: '# Author documentation', readmeStatus: 'ready', readmeError: null,
error: null, busy: false, retryList() {}, retryReadme() {}, async install() {},
} as ReturnType<typeof usePluginCatalog>;
@@ -125,3 +125,22 @@ test('narrow detail starts at the top and back restores the settings scroll and
actGlobal.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
}
});
test('offline catalog warning keeps details visible and disables market installation', () => {
const downloadable = { ...entries[0], artifact: { url: 'https://github.com/example/plugin/releases/download/v1/plugin.ncpkg', sha256: 'a'.repeat(64) } };
const html = renderToStaticMarkup(<I18nProvider locale="en"><PluginMarketplaceContent
catalog={{ ...catalog, entries: [downloadable], listError: 'Registry offline', source: 'cached' }}
selected={downloadable.id} onSelect={() => {}} installed={[]} /></I18nProvider>);
assert.match(html, /Registry offline/);
assert.match(html, /Installation is unavailable until refresh succeeds/);
assert.match(html, /Compatibility and permissions/);
assert.match(html, /<button[^>]*disabled=""[^>]*>Install this version/);
assert.match(html, /Retry/);
});
test('manual refresh remains available after success and is disabled while loading', () => {
const ready = renderToStaticMarkup(<I18nProvider locale="en"><PluginMarketplaceContent catalog={catalog} selected={null} onSelect={() => {}} installed={[]} /></I18nProvider>);
assert.match(ready, />Refresh<\/button>/);
const loading = renderToStaticMarkup(<I18nProvider locale="en"><PluginMarketplaceContent catalog={{ ...catalog, listStatus: 'loading' }} selected={null} onSelect={() => {}} installed={[]} /></I18nProvider>);
assert.match(loading, /<button[^>]*disabled=""[^>]*>Refresh<\/button>/);
});
+11 -8
View File
@@ -1,5 +1,6 @@
import React, { useRef, useState } from 'react';
import { ArrowLeft, Package } from 'lucide-react';
import { PluginBrandIcon } from '../../plugins/PluginBrandIcon';
import { ArrowLeft } from 'lucide-react';
import { useI18n } from '../../../application/i18n/I18nProvider';
import { usePluginCatalog } from '../../../application/state/usePluginCatalog';
import { Input } from '../../ui/input';
@@ -58,7 +59,8 @@ export function PluginMarketplaceContent({ catalog, installed, selected, onSelec
});
};
return <section className="plugin-marketplace space-y-4" aria-label={t('settings.plugins.market.title')}>
<p className="text-xs text-muted-foreground">{t('settings.plugins.market.note')}</p>
<div className="flex items-start justify-between gap-3"><p className="text-xs text-muted-foreground">{t('settings.plugins.market.note')}</p><Button variant="outline" size="sm" disabled={catalog.listStatus === 'loading'} onClick={catalog.retryList}>{t('common.refresh')}</Button></div>
{catalog.listStatus === 'ready' && catalog.listError && <div role="status" className="space-y-2 rounded-lg border p-3 text-xs text-muted-foreground"><p>{t('settings.plugins.market.offline')}</p><p>{catalog.listError}</p><Button variant="outline" size="sm" onClick={catalog.retryList}>{t('settings.plugins.market.retry')}</Button></div>}
{catalog.error && <p role="alert" className="text-xs text-destructive">{catalog.error}</p>}
<div className={`plugin-marketplace-browser${entry ? ' plugin-marketplace-browser--selected' : ''}`}>
<div className="plugin-marketplace-catalog space-y-3" ref={listRef}>
@@ -71,23 +73,24 @@ export function PluginMarketplaceContent({ catalog, installed, selected, onSelec
<div className="plugin-marketplace-grid">{filtered.map((item) => {
const isInstalled = installed.some((plugin) => plugin.id === item.id);
return <button key={item.id} type="button" data-plugin-id={item.id} aria-current={selected === item.id ? 'true' : undefined} onClick={() => choose(item.id)}
className={`min-w-0 space-y-3 rounded-lg border p-4 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring ${selected === item.id ? 'border-primary/40 bg-primary/10 text-foreground' : 'border-border bg-card hover:bg-muted/50'}`}>
<div className="flex items-start gap-2"><Package size={18} className="mt-0.5 shrink-0 text-muted-foreground" /><span className="min-w-0 break-words text-sm font-semibold">{item.name}</span></div>
<p className="line-clamp-2 text-xs leading-5 text-muted-foreground">{item.description}</p>
<p className="break-words text-xs text-muted-foreground">{item.author} · {item.version} · {item.license}</p>
className={`min-w-0 flex flex-col gap-3 rounded-lg border p-4 text-left ${entry ? '' : 'min-h-[200px]'} transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring ${selected === item.id ? 'border-primary/40 bg-primary/10 text-foreground' : 'border-border bg-card hover:bg-muted/50'}`}>
<div className="flex items-start gap-2"><PluginBrandIcon entry={item} size={32} className="mt-0.5 shrink-0 text-muted-foreground" /><span className="min-w-0 break-words text-sm font-semibold">{item.name}</span></div>
<p className="break-words text-xs text-muted-foreground">{item.author}</p>
<p className="line-clamp-3 break-words text-sm leading-5 text-muted-foreground">{item.description}</p>
<div className="mt-auto flex flex-wrap items-center justify-between gap-2 pt-1 text-xs text-muted-foreground"><span>{item.version} · {item.license}</span><span className="text-foreground">{t('settings.plugins.manager.details')}</span></div>
<div className="flex flex-wrap gap-2 text-xs text-muted-foreground">{isInstalled && <span className="rounded bg-muted px-2 py-0.5 text-foreground">{t('settings.plugins.market.installed')}</span>}{!item.artifact && <span className="rounded border px-2 py-0.5">{t('settings.plugins.market.sourceBadge')}</span>}</div>
</button>;
})}</div>
</div>
{entry && <article key={entry.id} ref={detailRef} tabIndex={-1} aria-label={entry.name} className="plugin-marketplace-detail min-w-0 space-y-5 rounded-lg border bg-card p-4 focus:outline-none">
<Button variant="ghost" size="sm" onClick={back}><ArrowLeft size={14} className="mr-2" />{t('settings.plugins.market.back')}</Button>
<div className="space-y-2"><h4 className="break-words text-xl font-semibold">{entry.name}</h4><p className="text-xs text-muted-foreground">{entry.author} · {entry.version} · {entry.license}</p>
<div className="space-y-2"><div className="flex items-center gap-3"><PluginBrandIcon entry={entry} size={48} /><h4 className="break-words text-xl font-semibold">{entry.name}</h4></div><p className="text-xs text-muted-foreground">{entry.author} · {entry.version} · {entry.license}</p>
{installedEntry && <p className="text-xs text-muted-foreground">{t('settings.plugins.market.installed')} · {installedEntry.activeVersion} · {t(installedEntry.enabled ? 'settings.plugins.manager.enabled' : 'settings.plugins.manager.disabled')}</p>}
</div>
{installedEntry?.runtime?.lastError && <p role="status" className="break-words text-xs text-destructive">{t('settings.plugins.manager.lastError')}: {installedEntry.runtime.lastError}</p>}
<p className="text-sm leading-6 text-muted-foreground">{entry.description}</p>
<a className="block break-all text-xs text-primary underline underline-offset-4" href={`https://github.com/${entry.repository}/tree/${entry.commit}`} target="_blank" rel="noopener noreferrer">{entry.repository} @ {entry.commit.slice(0, 12)}</a>
{entry.artifact ? <div className="space-y-2"><p className="text-xs text-muted-foreground">{t('settings.plugins.market.installNote')}</p><Button size="sm" disabled={catalog.busy} onClick={() => void catalog.install(entry.id)}>{t('settings.plugins.market.install')}</Button></div> : <p className="rounded-lg bg-muted/40 p-3 text-xs leading-5">{t('settings.plugins.market.sourceOnly')}</p>}
{entry.artifact ? <div className="space-y-2"><p className="text-xs text-muted-foreground">{t('settings.plugins.market.installNote')}</p><Button size="sm" disabled={catalog.busy || Boolean(catalog.listError)} onClick={() => void catalog.install(entry.id)}>{t('settings.plugins.market.install')}</Button></div> : <p className="rounded-lg bg-muted/40 p-3 text-xs leading-5">{t('settings.plugins.market.sourceOnly')}</p>}
<SettingCard padded><h4 className="mb-3 text-sm font-semibold">{t('settings.plugins.market.manifest')}</h4><PluginPackageDetails plugin={{ id: entry.id, activeVersion: entry.version, enabled: false, manifest: entry.manifest, archiveSha256: entry.artifact?.sha256, runtime: { status: 'stopped', kind: null, lastError: null, quarantinedAt: null } }} /></SettingCard>
<div className="min-w-0 border-t pt-4"><h4 className="text-sm font-semibold">README</h4><p className="mt-2 text-xs text-muted-foreground">{t('settings.plugins.market.readmeNote')}</p>{catalog.readmeStatus === 'loading' && <p className="mt-3 text-xs text-muted-foreground">{t('settings.plugins.loading')}</p>}
{catalog.readmeStatus === 'error' && <div role="alert" className="mt-3 space-y-2 text-xs text-destructive"><p>{catalog.readmeError}</p><Button variant="outline" size="sm" onClick={catalog.retryReadme}>{t('settings.plugins.market.retry')}</Button></div>}
@@ -1,10 +1,13 @@
import { describePluginPermission } from '@/domain/pluginPermissionDescriptions.cjs';
import { describePluginRuntime } from '@/domain/pluginRuntimePolicy.cjs';
import React, { useState } from 'react';
import { ArrowLeft, Package } from 'lucide-react';
import React, { useRef, useState } from 'react';
import { PluginPermissionPanel } from './PluginPermissionPanel';
import { PluginBrandIcon } from '../../plugins/PluginBrandIcon';
import { ArrowLeft } from 'lucide-react';
import { usePluginManager } from '../../../application/state/usePluginManager';
import { useI18n } from '../../../application/i18n/I18nProvider';
import { Button } from '../../ui/button';
import { Input } from '../../ui/input';
export function PluginPackageDetails({ plugin }: { plugin: NetcattyInstalledPlugin }) {
const { t, resolvedLocale } = useI18n();
@@ -46,28 +49,67 @@ export function PluginPackageDetails({ plugin }: { plugin: NetcattyInstalledPlug
</div>;
}
export function PluginPackageManager({ manager }: { manager: ReturnType<typeof usePluginManager> }) {
export function PluginPackageManager({ manager, onSelectionChange }: { manager: ReturnType<typeof usePluginManager>; onSelectionChange?: (id: string | null) => void }) {
const { t, resolvedLocale } = useI18n();
const [selected, setSelected] = useState<string | null>(null);
const [search, setSearch] = useState('');
const rootRef = useRef<HTMLElement>(null);
const scrollTop = useRef(0);
const lastSelection = useRef<string | null>(null);
const selectedPlugin = manager.plugins.find((plugin) => plugin.id === selected);
const nameOf = (plugin: NetcattyInstalledPlugin) => typeof plugin.manifest?.displayName === 'string' ? plugin.manifest.displayName : plugin.manifest?.displayName?.[resolvedLocale] ?? plugin.manifest?.displayName?.en ?? plugin.manifest?.name ?? plugin.id;
const descriptionOf = (plugin: NetcattyInstalledPlugin) => {
const value = plugin.manifest?.description;
return typeof value === 'string' ? value : value?.[resolvedLocale] ?? value?.en ?? '';
};
const choose = (id: string | null) => {
const pane = rootRef.current?.closest<HTMLElement>('[data-settings-scroll-pane]');
if (id) { scrollTop.current = pane?.scrollTop ?? 0; lastSelection.current = id; }
setSelected(id); setRemoving(null); onSelectionChange?.(id);
requestAnimationFrame(() => {
if (pane) pane.scrollTop = id ? 0 : scrollTop.current;
if (id) rootRef.current?.querySelector<HTMLElement>('[data-plugin-detail]')?.focus({ preventScroll: true });
else Array.from(rootRef.current?.querySelectorAll<HTMLButtonElement>('[data-plugin-card]') ?? []).find((button) => button.dataset.pluginCard === lastSelection.current)?.focus({ preventScroll: true });
});
};
const visible = selectedPlugin ? [selectedPlugin] : manager.plugins.filter((plugin) => [nameOf(plugin), plugin.manifest?.publisher, plugin.manifest?.description, plugin.id].some((value) => JSON.stringify(value ?? '').toLocaleLowerCase().includes(search.trim().toLocaleLowerCase())));
const [removing, setRemoving] = useState<string | null>(null);
if (!manager.available) return null;
return <section className="space-y-4" aria-label={t('settings.plugins.manager.installed')}>
return <section ref={rootRef} className="space-y-4" aria-label={t('settings.plugins.manager.installed')}>
<p className="text-xs text-muted-foreground">{t('settings.plugins.manager.preview')}</p>
{selected && <Button variant="ghost" size="sm" onClick={() => { setSelected(null); setRemoving(null); }}><ArrowLeft size={14} className="mr-2" />{t('settings.plugins.manager.installed')}</Button>}
{selectedPlugin && <Button variant="ghost" size="sm" onClick={() => choose(null)}><ArrowLeft size={14} className="mr-2" />{t('settings.plugins.manager.installed')}</Button>}
{!selectedPlugin && manager.plugins.length > 0 && <Input value={search} onChange={(event) => setSearch(event.target.value)} placeholder={t('settings.plugins.market.search')} aria-label={t('settings.plugins.market.search')} />}
{!selectedPlugin && manager.plugins.length > 0 && visible.length === 0 && <p className="p-6 text-center text-sm text-muted-foreground">{t('settings.plugins.market.noResults')}</p>}
{manager.error && <p role="alert" className="text-sm text-destructive">{manager.error}</p>}
{!manager.plugins.length && <div className="rounded-xl border border-dashed border-border p-8 text-center text-sm text-muted-foreground">{t('settings.plugins.manager.empty')}</div>}
{manager.plugins.filter((plugin) => !selected || plugin.id === selected).map((plugin) => <article key={plugin.id} className="space-y-4 rounded-xl border border-border/70 bg-background p-4">
<div className="flex items-center gap-3">
<div className="rounded-lg bg-muted p-2"><Package size={20} /></div>
<button type="button" className="min-w-0 flex-1 text-left" aria-expanded={selected === plugin.id} onClick={() => setSelected(selected === plugin.id ? null : plugin.id)}>
<div className="truncate text-sm font-medium">{typeof plugin.manifest?.displayName === 'string' ? plugin.manifest.displayName : plugin.manifest?.displayName?.[resolvedLocale] ?? plugin.manifest?.displayName?.en ?? plugin.manifest?.name ?? plugin.id}</div>
<div className="text-xs text-muted-foreground">{plugin.manifest?.publisher} · {plugin.activeVersion} · {t(plugin.enabled ? 'settings.plugins.manager.enabled' : 'settings.plugins.manager.disabled')}</div>
<div className={selectedPlugin ? 'space-y-4' : 'grid grid-cols-[repeat(auto-fill,minmax(min(100%,280px),1fr))] gap-3'}>
{visible.map((plugin) => <article key={plugin.id} data-plugin-detail={selectedPlugin ? true : undefined} tabIndex={selectedPlugin ? -1 : undefined} aria-label={nameOf(plugin)} className={`min-w-0 rounded-lg border bg-card p-4 focus:outline-none ${selectedPlugin ? 'space-y-4' : 'flex min-h-[200px] flex-col gap-3'}`}>
<div className="flex flex-wrap items-center gap-3">
<div className="flex h-12 w-12 shrink-0 items-center justify-center"><PluginBrandIcon plugin={plugin} size={32} /></div>
<button type="button" data-plugin-card={plugin.id} className="min-w-0 flex-1 rounded text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" aria-expanded={selected === plugin.id} onClick={() => choose(selected === plugin.id ? null : plugin.id)}>
<div className="break-words text-sm font-medium">{nameOf(plugin)}</div>
</button>
<Button variant="outline" size="sm" disabled={manager.busy} onClick={() => void manager.setEnabled(plugin.id, !plugin.enabled)}>{t(plugin.enabled ? 'settings.plugins.manager.disable' : 'settings.plugins.manager.enable')}</Button>
<Button variant="ghost" size="sm" type="button"
className={`h-7 shrink-0 rounded-full border-0 px-3 text-xs focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background ${manager.statusKnown !== false && plugin.enabled ? 'bg-primary/10 hover:bg-primary/15' : 'bg-muted/60 text-muted-foreground hover:bg-muted hover:text-foreground'}`}
style={manager.statusKnown !== false && plugin.enabled ? { color: 'color-mix(in srgb, hsl(var(--primary)) 40%, hsl(var(--foreground)))' } : undefined}
aria-pressed={manager.statusKnown === false ? undefined : plugin.enabled} aria-busy={manager.busy}
aria-label={`${t(manager.statusKnown === false ? 'settings.plugins.permissions.statusUnknown' : plugin.enabled ? 'settings.plugins.manager.disable' : 'settings.plugins.manager.enable')} ${plugin.manifest?.name ?? plugin.id}`}
title={t(manager.statusKnown === false ? 'settings.plugins.permissions.statusUnknown' : plugin.enabled ? 'settings.plugins.manager.disable' : 'settings.plugins.manager.enable')}
disabled={manager.busy || manager.statusKnown === false} onClick={() => void manager.setEnabled(plugin.id, !plugin.enabled)}>
{t(manager.statusKnown === false ? 'settings.plugins.permissions.statusUnknown' : plugin.enabled ? 'settings.plugins.manager.enabled' : 'settings.plugins.manager.disabled')}
</Button>
</div>
{plugin.manifest?.publisher && <p className="break-words text-xs text-muted-foreground">{plugin.manifest.publisher}</p>}
<p className={`break-words text-sm leading-5 text-muted-foreground ${selectedPlugin ? 'whitespace-pre-wrap' : 'line-clamp-3'}`}>{descriptionOf(plugin) || t('settings.plugins.manager.noDescription')}</p>
{!selectedPlugin ? <div className="mt-auto flex flex-wrap items-center justify-between gap-2 pt-1">
<p className="break-words text-xs text-muted-foreground">{plugin.activeVersion}{plugin.manifest?.license ? ` · ${plugin.manifest.license}` : ''}</p>
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={() => choose(plugin.id)} aria-label={`${t('settings.plugins.manager.details')} ${nameOf(plugin)}`}>{t('settings.plugins.manager.details')}</Button>
</div> : <p className="text-xs text-muted-foreground">{plugin.activeVersion}{plugin.manifest?.license ? ` · ${plugin.manifest.license}` : ''}</p>}
{plugin.runtime.lastError && <p role="status" className="break-words text-xs text-destructive">{t('settings.plugins.manager.lastError')}: {plugin.runtime.lastError}</p>}
{selected === plugin.id && <>
<PluginPackageDetails plugin={plugin} />
<PluginPermissionPanel key={plugin.id} pluginId={plugin.id} onChanged={async () => { await manager.refresh(); }} />
{removing === plugin.id ? <div className="space-y-2 rounded-lg border border-destructive/30 p-3">
<p className="text-xs">{t('settings.plugins.manager.removeNote')}</p>
<Button variant="destructive" size="sm" disabled={manager.busy} onClick={() => { void manager.uninstall(plugin.id); setRemoving(null); }}>{t('settings.plugins.manager.remove')}</Button>
@@ -75,5 +117,6 @@ export function PluginPackageManager({ manager }: { manager: ReturnType<typeof u
</div> : <Button variant="ghost" size="sm" disabled={manager.busy} onClick={() => setRemoving(plugin.id)}>{t('settings.plugins.manager.remove')}</Button>}
</>}
</article>)}
</div>
</section>;
}
@@ -0,0 +1,145 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { JSDOM } from 'jsdom';
test('permission panel refreshes real grants, revokes once, and keeps stale reads from replacing new results', async () => {
const dom = new JSDOM('<!doctype html><html><body><div id="root"></div></body></html>', { pretendToBeVisual: true, url: 'http://localhost' });
const previous = new Map<string, PropertyDescriptor | undefined>();
const install = (key: string, value: unknown) => {
previous.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
Object.defineProperty(globalThis, key, { configurable: true, writable: true, value });
};
for (const key of ['window', 'document', 'navigator', 'HTMLElement', 'Element', 'Node', 'Event', 'MouseEvent']) {
install(key, key === 'window' ? dom.window : dom.window[key as keyof typeof dom.window]);
}
install('IS_REACT_ACT_ENVIRONMENT', true);
const { default: React, act } = await import('react');
const { createRoot } = await import('react-dom/client');
const { I18nProvider } = await import('../../../application/i18n/I18nProvider');
const { PluginPermissionPanel } = await import('./PluginPermissionPanel');
const root = createRoot(document.getElementById('root')!);
const initial = {
grants: [{ permission: 'network', resource: 'https://synthetic.invalid/<text>', resourceKind: 'exact', scope: 'always' as const, sessionId: null, grantedAt: 1, matchesCurrentActivation: false }],
audit: [{ event: 'permission.used', createdAt: 2, permission: 'network' }],
};
let reads = 0;
let revokes = 0;
let changed = 0;
let staleRead: (value: typeof initial) => void = () => {};
let releaseRevoke: () => void = () => {};
const revokePending = new Promise<void>((resolve) => { releaseRevoke = resolve; });
Object.assign(dom.window, { netcatty: {
async getPluginPermissions(id: string) {
assert.equal(id, 'com.example.synthetic');
reads++;
if (reads === 2) return new Promise<typeof initial>((resolve) => { staleRead = resolve; });
return reads > 2 ? { grants: [], audit: [] } : initial;
},
async revokePluginPermissions(id: string) { assert.equal(id, 'com.example.synthetic'); revokes++; await revokePending; },
} });
const button = (text: string) => Array.from(document.querySelectorAll<HTMLButtonElement>('button')).find((item) => item.textContent === text)!;
try {
await act(async () => root.render(<I18nProvider locale="en"><PluginPermissionPanel pluginId="com.example.synthetic" onChanged={async () => { changed++; }} /></I18nProvider>));
assert.match(document.body.textContent!, /synthetic.invalid/);
assert.equal(document.querySelector('text'), null, 'resource is rendered as plain text');
assert.match(document.body.textContent!, /Until revoked/);
assert.match(document.body.textContent!, /Inactive for the current activation/);
await act(async () => { button('Refresh').click(); });
await act(async () => {
button('Disable and revoke all grants').click();
button('Disable and revoke all grants').click();
});
assert.equal(revokes, 1);
assert.equal(button('Refresh').disabled, true);
await act(async () => { releaseRevoke(); });
assert.equal(changed, 1);
assert.match(document.body.textContent!, /No saved grants/);
await act(async () => { staleRead(initial); });
assert.doesNotMatch(document.body.textContent!, /synthetic.invalid/);
Object.assign(dom.window, { netcatty: { async getPluginPermissions() { throw new Error('synthetic read failed'); } } });
await act(async () => { button('Refresh').click(); });
assert.match(document.querySelector('[role="alert"]')?.textContent ?? '', /synthetic read failed/);
} finally {
await act(async () => root.unmount());
dom.window.close();
for (const [key, descriptor] of previous) {
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
else Reflect.deleteProperty(globalThis, key);
}
}
});
test('partial revocation failure refreshes actual manager and engine state without clearing the original error', async () => {
const dom = new JSDOM('<!doctype html><html><body><div id="root"></div></body></html>', { pretendToBeVisual: true, url: 'http://localhost' });
const previous = new Map<string, PropertyDescriptor | undefined>();
for (const key of ['window', 'document', 'navigator', 'HTMLElement', 'Element', 'Node', 'Event', 'MouseEvent', 'IS_REACT_ACT_ENVIRONMENT']) {
previous.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
Object.defineProperty(globalThis, key, { configurable: true, writable: true, value: key === 'window' ? dom.window : key === 'IS_REACT_ACT_ENVIRONMENT' ? true : dom.window[key as keyof typeof dom.window] });
}
const { act } = await import('react');
const { createRoot } = await import('react-dom/client');
const { I18nProvider } = await import('../../../application/i18n/I18nProvider');
const { PluginPermissionPanel } = await import('./PluginPermissionPanel');
const { PluginManager } = await import('../../../electron/plugins/pluginManager.cjs');
const { PluginPermissionEngine } = await import('../../../electron/plugins/permissionEngine.cjs');
const { defaultSecurityPrincipal, normalizePermissionDeclarations, permissionDeclarationHash } = await import('../../../electron/plugins/permissionResources.cjs');
const manifest = { id: 'com.example.synthetic', publisher: 'example', permissions: { optional: ['storage'] } };
const principal = defaultSecurityPrincipal(manifest);
const grant = { pluginId: manifest.id, permission: 'storage', resource: '*', resourceKind: 'exact', grantedAt: 1,
declarationHash: permissionDeclarationHash(normalizePermissionDeclarations(manifest).get('storage'), principal) };
let enabled = true, running = true, stopped = false, readFails = false;
let grants = [grant];
let reads = 0, changed = 0;
const database = {
getActivePlugin: () => ({ id: manifest.id, enabled, manifest }),
listPlugins: () => [{ id: manifest.id, enabled, manifest }],
setEnabled(_id: string, value: boolean) { enabled = value; },
listPermissionGrants: () => grants,
deleteAllPermissionGrants() { grants = []; },
recordSecurityAudit() { throw new Error('synthetic audit write failed'); },
};
const engine = new PluginPermissionEngine({ database });
const manager = new PluginManager({ database, permissionEngine: engine,
packageStore: { async initialize() {} },
contributionService: { async initialize() {}, onPluginDisabled() {} },
runtimeSupervisor: { async stop() { running = false; stopped = true; } },
});
Object.assign(dom.window, { netcatty: {
async getPluginPermissions() {
reads++;
if (readFails) throw new Error('synthetic grants refresh failed');
const groups = engine.describeGrants(manifest.id, running ? { pluginId: manifest.id, manifest, securityPrincipal: principal } : null);
return { grants: groups.always, audit: [] };
},
async revokePluginPermissions(id: string) { return manager.revokePermissions(id); },
} });
const root = createRoot(document.getElementById('root')!);
const refreshManager = async () => { changed++; assert.equal((await manager.list())[0].enabled, false); };
const revoke = () => Array.from(document.querySelectorAll<HTMLButtonElement>('button')).find((item) => item.textContent === 'Disable and revoke all grants')!;
try {
await act(async () => root.render(<I18nProvider locale="en"><PluginPermissionPanel key="first" pluginId={manifest.id} onChanged={refreshManager} /></I18nProvider>));
assert.match(document.body.textContent!, /Matches the current activation/);
await act(async () => { revoke().click(); });
assert.equal(enabled, false); assert.equal(stopped, true);
assert.equal(changed, 1); assert.equal(reads, 2);
assert.match(document.querySelector('[role="alert"]')?.textContent ?? '', /synthetic audit write failed/);
assert.match(document.body.textContent!, /No saved grants/);
assert.doesNotMatch(document.body.textContent!, /Matches the current activation/);
// A failed state read must also remove the old grant snapshot while
// retaining the mutation error and reporting the refresh failure.
enabled = true; running = true; stopped = false; grants = [grant];
await act(async () => root.render(<I18nProvider locale="en"><PluginPermissionPanel key="second" pluginId={manifest.id} onChanged={async () => { await refreshManager(); readFails = true; throw new Error('synthetic manager refresh failed'); }} /></I18nProvider>));
assert.match(document.body.textContent!, /Matches the current activation/);
await act(async () => { revoke().click(); });
assert.equal(enabled, false); assert.equal(stopped, true);
assert.equal(changed, 2); assert.equal(reads, 4);
const error = document.querySelector('[role="alert"]')?.textContent ?? '';
assert.match(error, /synthetic audit write failed/);
assert.match(error, /synthetic manager refresh failed/);
assert.match(error, /synthetic grants refresh failed/);
assert.doesNotMatch(document.body.textContent!, /Matches the current activation/);
} finally {
await act(async () => root.unmount()); engine.shutdown(); dom.window.close();
for (const [key, descriptor] of previous) { if (descriptor) Object.defineProperty(globalThis, key, descriptor); else Reflect.deleteProperty(globalThis, key); }
}
});
@@ -0,0 +1,86 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { netcattyBridge } from '../../../infrastructure/services/netcattyBridge';
import { useI18n } from '../../../application/i18n/I18nProvider';
import { describePluginPermission } from '../../../domain/pluginPermissionDescriptions.cjs';
import { Button } from '../../ui/button';
/** Host UI only. Plugin views cannot query or mutate this security state. */
export function PluginPermissionPanel({ pluginId, onChanged }: {
pluginId: string;
onChanged: () => Promise<void>;
}) {
const { t, resolvedLocale } = useI18n();
const [snapshot, setSnapshot] = useState<NetcattyPluginPermissionSnapshot | null>(null);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const generation = useRef(0);
const pending = useRef(false);
const refresh = useCallback(async (preserveError = false) => {
const current = ++generation.current;
if (!preserveError) setError(null);
try {
const bridge = netcattyBridge.require();
if (!bridge.getPluginPermissions) throw new Error(t('settings.plugins.permissions.unavailable'));
const result = await bridge.getPluginPermissions(pluginId);
if (current === generation.current) setSnapshot(result);
} catch (cause) {
if (current === generation.current) {
setSnapshot(null);
const message = cause instanceof Error ? cause.message : String(cause);
setError((previous) => preserveError && previous ? `${previous}\n${message}` : message);
}
}
}, [pluginId, t]);
useEffect(() => {
const requestGeneration = generation;
void refresh();
return () => { requestGeneration.current++; };
}, [refresh]);
const revoke = async () => {
if (pending.current) return;
pending.current = true;
setBusy(true);
setError(null);
try {
const bridge = netcattyBridge.require();
if (!bridge.revokePluginPermissions) throw new Error(t('settings.plugins.permissions.unavailable'));
await bridge.revokePluginPermissions(pluginId);
} catch (cause) { setError(cause instanceof Error ? cause.message : String(cause)); }
finally {
try { await onChanged(); }
catch (cause) {
const message = cause instanceof Error ? cause.message : String(cause);
setError((previous) => previous ? `${previous}\n${message}` : message);
}
await refresh(true);
pending.current = false; setBusy(false);
}
};
return <section className="space-y-3 border-t border-border/60 pt-4 text-sm" aria-label={t('settings.plugins.permissions.title')}>
<div className="flex flex-wrap items-center justify-between gap-2">
<h4 className="font-medium">{t('settings.plugins.permissions.title')}</h4>
<Button variant="ghost" size="sm" disabled={busy} onClick={() => void refresh()}>{t('settings.plugins.permissions.refresh')}</Button>
</div>
{error && <p role="alert" className="text-xs text-destructive">{error}</p>}
{!snapshot && !error && <p role="status">{t('settings.plugins.permissions.loading')}</p>}
{snapshot && <>
{snapshot.grants.length === 0 && <p className="text-xs text-muted-foreground">{t('settings.plugins.permissions.empty')}</p>}
{snapshot.grants.map((grant, index) => <div key={index} className="rounded-md bg-muted/40 p-3 text-xs">
<p className="font-medium">{describePluginPermission(grant.permission, resolvedLocale).label}</p>
<p className="whitespace-pre-wrap break-all font-mono">{grant.resource}</p>
<p>{t(grant.matchesCurrentActivation ? 'settings.plugins.permissions.current' : 'settings.plugins.permissions.inactive')}</p>
<p>{t(`settings.plugins.permissions.${grant.scope}`)}{grant.sessionId ? ` · ${grant.sessionId}` : ''}</p>
<time dateTime={new Date(grant.grantedAt).toISOString()}>{new Date(grant.grantedAt).toLocaleString(resolvedLocale)}</time>
</div>)}
<details className="text-xs">
<summary className="cursor-pointer">{t('settings.plugins.permissions.audit')}</summary>
<ol className="mt-2 space-y-1">{snapshot.audit.map((entry, index) => <li key={index} className="break-words">
<time dateTime={new Date(entry.createdAt).toISOString()}>{new Date(entry.createdAt).toLocaleString(resolvedLocale)}</time>{' · '}
{entry.event}{entry.permission ? ` · ${entry.permission}` : ''}{entry.scope ? ` · ${entry.scope}` : ''}
</li>)}</ol>
</details>
<p className="text-xs text-muted-foreground">{t('settings.plugins.permissions.revokeNote')}</p>
<Button variant="destructive" size="sm" disabled={busy} onClick={() => void revoke()}>{t('settings.plugins.permissions.revoke')}</Button>
</>}
</section>;
}
@@ -0,0 +1,43 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import React from 'react';
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
import { I18nProvider } from '../../../application/i18n/I18nProvider';
import { PluginPackageManager } from './PluginPackageManager';
import type { usePluginManager } from '../../../application/state/usePluginManager';
test('state pill exposes actual state, opposite action, focus styling and pending disable without granting permissions', async () => {
const globals = globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean };
const previous = globals.IS_REACT_ACT_ENVIRONMENT; globals.IS_REACT_ACT_ENVIRONMENT = true;
const calls: unknown[] = [];
const plugin = { id: 'com.test.pill', activeVersion: '1.0.0', enabled: true, manifest: { name: 'Pill' }, runtime: {} } as NetcattyInstalledPlugin;
const manager = { available: true, plugins: [plugin], busy: false, setEnabled: async (...args: unknown[]) => { calls.push(args); } } as unknown as ReturnType<typeof usePluginManager>;
let renderer: ReactTestRenderer;
const render = (enabled: boolean, busy = false) => <I18nProvider locale="en"><PluginPackageManager manager={{ ...manager, busy, plugins: [{ ...plugin, enabled }] }} /></I18nProvider>;
try {
await act(async () => { renderer = create(render(true)); });
const pill = () => renderer.root.findAllByType('button').find((node) => node.props['aria-pressed'] !== undefined)!;
assert.equal(pill().props['aria-pressed'], true);
const logoSlot = renderer.root.findAllByType('div').find((node) => node.props.className === 'flex h-12 w-12 shrink-0 items-center justify-center');
assert.ok(logoSlot, 'logo keeps its 48px layout slot without an extra decorative background');
assert.equal(pill().props['aria-label'], 'Disable Pill');
assert.ok(pill().children.includes('Enabled'));
assert.match(pill().props.className, /rounded-full/);
assert.match(pill().props.className, /border-0/);
assert.doesNotMatch(pill().props.className, /border-primary|border-border/);
assert.match(pill().props.className, /bg-primary\/10/);
assert.match(pill().props.className, /focus-visible:ring-2/);
assert.match(pill().props.style.color, /--primary/);
await act(async () => pill().props.onClick());
assert.deepEqual(calls, [['com.test.pill', false]]);
await act(async () => renderer.update(render(false, true)));
assert.equal(pill().props['aria-pressed'], false);
assert.equal(pill().props['aria-label'], 'Enable Pill');
assert.ok(pill().children.includes('Disabled'));
assert.equal(pill().props.disabled, true);
assert.equal(pill().props['aria-busy'], true);
assert.match(pill().props.className, /bg-muted/);
assert.equal(pill().props.style, undefined);
await act(async () => renderer.unmount());
} finally { globals.IS_REACT_ACT_ENVIRONMENT = previous; }
});
@@ -286,6 +286,7 @@ export function PluginSettingField({
function PluginManagementContent({ handoff }: { handoff: (action: () => void) => void }) {
const { t } = useI18n();
const manager = usePluginManager();
const [selectedInstalled, setSelectedInstalled] = useState<string | null>(null);
const [pluginView, setPluginView] = useState<'installed' | 'marketplace'>('installed');
const availableFonts = useAvailableFonts();
const scopeCatalog = usePluginSettingScopeCatalog();
@@ -299,14 +300,15 @@ function PluginManagementContent({ handoff }: { handoff: (action: () => void) =>
}), [scopeIds]);
const contributions = usePluginContributions(query);
const contextualScopes = useMemo(() => new Set(contributions.snapshot.plugins
.filter((plugin) => plugin.id === selectedInstalled)
.flatMap((plugin) => plugin.settings.map((setting) => setting.scope))
.filter((scope): scope is NetcattyPluginSettingScopeKind => scope !== 'application')),
[contributions.snapshot.plugins]);
[contributions.snapshot.plugins, selectedInstalled]);
return (
<div className="space-y-5">
<Tabs value={pluginView} onValueChange={(value) => setPluginView(value as 'installed' | 'marketplace')} className="space-y-5">
<Tabs value={pluginView} onValueChange={(value) => { setSelectedInstalled(null); setPluginView(value as 'installed' | 'marketplace'); }} className="space-y-5">
<div className="flex flex-wrap items-center justify-between gap-3">
<TabsList className="h-auto flex-wrap justify-start bg-muted/50">
<TabsTrigger value="installed">{t('settings.plugins.manager.installed')} <span className="ml-2 text-muted-foreground">{manager.plugins.length}</span></TabsTrigger>
@@ -317,7 +319,7 @@ function PluginManagementContent({ handoff }: { handoff: (action: () => void) =>
{manager.credentialStorageAvailable === false && <p role="status" className="text-xs text-muted-foreground">{t('settings.plugins.manager.noVault')}</p>}
<TabsContent value="marketplace" className="m-0 space-y-6"><PluginMarketplace installed={manager.plugins} /></TabsContent>
<TabsContent value="installed" className="m-0 space-y-6">
<PluginPackageManager manager={manager} />
<PluginPackageManager manager={manager} onSelectionChange={setSelectedInstalled} />
{contextualScopes.size > 0 && (
<section className="grid gap-3 rounded-lg border border-border/70 bg-muted/10 p-4 sm:grid-cols-2" aria-label={t('settings.plugins.scopeTargets')}>
{[...contextualScopes].map((kind) => (
@@ -337,7 +339,7 @@ function PluginManagementContent({ handoff }: { handoff: (action: () => void) =>
)}
{contributions.loading && <p className="text-sm text-muted-foreground">{t('settings.plugins.loading')}</p>}
{contributions.error && <p role="alert" className="text-sm text-destructive">{contributions.error.message}</p>}
{contributions.snapshot.plugins.map((plugin) => {
{contributions.snapshot.plugins.filter((plugin) => plugin.id === selectedInstalled).map((plugin) => {
const settings = plugin.settings.filter((setting) => setting.visible);
const views = plugin.views.filter((view) => view.visible && view.location === 'settings');
if (!settings.length && !views.length) return null;
@@ -370,7 +372,7 @@ function PluginManagementContent({ handoff }: { handoff: (action: () => void) =>
<Button type="button" variant="outline" size="sm" onClick={() => handoff(() => requestOpenPluginView({
viewId: view.id,
context: { 'netcatty.surface': 'settings' },
}))}>{t('common.open')}</Button>
}))}>{t('settings.plugins.openView')}</Button>
</div>
))}
</section>
@@ -0,0 +1,85 @@
# Sandbox capability coverage
This matrix tracks expansion from Draft #3951 at
`e5bd294cddbf58dfe04c646b6291933d980498fd`. Each accepted item requires a
canonical schema, generated SDK, broker, permission policy, quota and audit,
host adapter, lifecycle cleanup, synthetic boundary tests, review, and exact-SHA
CI. Enumeration in a manifest is not a working API. Status `planned` does not
mean complete. Existing APIs remain available.
## Coverage matrix
| Subsystem | Working API at baseline | Enumeration or missing adapter | Accepted bounded entry | Data and action risk | Authorization resource | Revocation, audit, quota | Verification and disposition |
| --- | --- | --- | --- | --- | --- | --- | --- |
| Authorization management | Engine `listGrants`, `revokeAll`, database audit | Manager shows declarations only | Host-only grant list, bounded audit summary, disable and revoke | Paths, session IDs, grant metadata are sensitive | Exact plugin ID, trusted app sender | Serialized manager mutation, disable before revoke, runtime teardown, retained audit | Batch 1 limited acceptance complete on ff3734d: independent review fixes, host/manager/UI tests, exact test/packaging CI, parent native own-views Application grant/revoke/disable/runtime-stop check. Visible view was manually closed beforehand. No plugin RPC for managing grants |
| Network and credential leases | `network.request`, `credentials.createLease`, single-use host redemption | SDK request omits lease and authorization fields | Canonical request types matching broker | Network mutation and credentials | Exact origin plus secret reference and operation | Existing timeout, redirects, byte quotas, runtime checks, lease TTL | Batch 1 contract/SDK consistency complete: canonical types, synthetic fixture/lease tests, feature discovery and exact CI. No live network or credential acceptance claimed. No plaintext secret API |
| Commands, menus, toolbar, status bar, views | `commands.execute`, settings, context keys, view state and messages, UI contributions | No editor or SFTP menu slots | Existing contribution system | Plugin text and arguments may be sensitive | Namespaced contributions and permissions | Existing lifecycle, rate and message limits | Existing tests. New context menu slots planned with bounded context DTOs |
| Terminal providers | Decorations, links, completion, snapshots through provider requests | Session change delivery follows prior provider request and non-once grant | Preserve providers. Standalone selected-session watch planned | Hostnames, session state, endpoint identity | Host-selected session, runtime identity | Session-close and revoke cleanup, bounded event rate | Existing provider tests. Ordinary views cannot independently subscribe today |
| Current context and host picker | Host context keys are boolean state | `vault.metadata` enumeration is not generic SDK | Selected host/session DTO from an explicit whitelist | Labels and endpoint metadata are sensitive | Host-owned user-action context, selected host/session | TTL, runtime and session invalidation, redacted audit | Planned. Never reuse agent `sanitizeHostForAgent` as plugin DTO |
| Terminal selection and input draft | Provider-supplied snapshots, completion suggestions | No explicit selection-share or draft API | User shares selection, plugin proposes a visible editable draft | Terminal plaintext, accidental execution | Host action token plus exact live session/endpoint | Single-use token, size cap, no plaintext audit, active identity before commit | Planned. Reject Enter/control characters for draft insertion. No full history or direct shell |
| Native interactions and clipboard | Native permission dialogs; new browser SDK host choice/input and explicit copy/share workflow | Progress/toast remain unimplemented | Host-owned prompts, two-phase clipboard snapshot/share, exact copy preview | Clipboard and input may contain secrets; UI discloses granted network capability; prompt spam | `ui.interact`, clipboard direction, host-signed window/runtime/version/phase/TTL action | Admission before permission prompt, byte/rate/concurrency limits, lock/blur/close/revoke cleanup, active guard, content-free audit | [Host interaction slice](./host-interactions.md) implemented and synthetic-tested; native acceptance/review/CI pending. Progress/toast still planned. No background reads or trusted plugin `userGesture` boolean |
| Local filesystem | Read file, stat, overwrite existing regular file | Directory listing lacks same-handle native adapter | Keep current boundary, evaluate file picker handles | Arbitrary local file content and writes | Canonical exact path or selected file handle | Inode checks, no symlink alias, bounded bytes, active runtime | Existing filesystem tests. Directory enumeration and new arbitrary-path creation deferred until portable safe native adapter exists |
| SFTP and remote files | No generic plugin API | `sftp.read` and `sftp.write` enumerated only | Host-selected session/path/mode/TTL handles, read and explicit save/upload | Remote secrets and destructive overwrite | Live endpoint identity, exact path, read/write split | Cancel streams, byte quota, overwrite confirmation, close/revoke cleanup | Batch 2 planned. Never forward arbitrary MCP catalog or session ID alone |
| Editor and Notes | No plugin document API | No versioned snapshot or edit preview | Explicit snapshot and versioned proposed edit | Document secrets, lost updates, executable files | Selected document plus revision and action token | Version conflict check, preview, bounded bytes, revocation | Planned evaluation after shared action context. No unattended edits |
| Snippets and scripts | Provider import drafts only | No generic read/run API | Explicit selection and create draft | Script contents may contain secrets. Running executes commands | Selected item, read versus draft write | Preview, byte cap, no automatic execution | Planned evaluation. Script `run` and onConnect modification deferred |
| Connections and authentication | Connection/authentication providers, opaque credentials, streams | Generic host open/connect absent | Existing providers remain | Connect can execute saved onConnect scripts | Provider operation plus live endpoint and credential lease | Existing provider lifecycle, streams and quotas | Existing tests. Generic connect is a write/execution capability, deferred pending explicit execution design |
| Sync and storage | Local namespaced KV, encrypted-object sync providers, opt-in setting sidecars | No general KV cloud sync | Existing opt-in provider protocol | Conflicts, deletes, credential access | Provider/account/object, settings scope | Existing limits and lifecycle, non-cascade metadata | Existing sync tests. General KV sync deferred until conflict/delete/quota/migration policy is defined |
| Vault and keychain | Credential references and import preview/commit | Metadata/write enums do not grant a generic Vault SDK | Only user-selected whitelist DTOs and drafts | Notes, environmentVariables, startupCommand, keys and credentials | Selected object plus field/direction | Explicit preview, identity checks, redacted audit | Full Vault export and secret reads deferred indefinitely. No agent field-exclusion DTO reuse |
| System manager and Docker | No plugin system API | Agent/MCP adapters are separate authority | Typed read-only summaries excluding args/env/inspect | Process command lines and Docker inspect can expose secrets | Selected live session and summary kind | Bounded result, endpoint identity, cancellation | Planned evaluation. Arbitrary process/inspect forwarding deferred |
| Port forwarding | No generic plugin API | Existing agent start is write-sensitive | Selected rule status and open host form | Rule endpoints and local exposure | Selected rule plus host action | Bounded DTO, session close and revoke | Planned evaluation. Direct start deferred until binding/listener approval design |
| Themes, settings, appearance, i18n | Terminal theme/background Providers, isolated view CSS, scoped settings and environment updates | No host global theme contribution. Personal customCSS is not plugin authority | Accepted ThemePack stage: static palette registry, selector, local preview, revision-safe apply/cancel/reset, terminal linkage | Unreadable palettes and misleading security UI. Nineteen UiThemeTokens fields are not independent: accent derives primary/ring/foregrounds | User explicitly selects a theme; static contribution enablement separate from code permissions | Immutable version/hash, bounded JSON, contrast/security-surface gates, preview owner/revision and safe fallback | Planned independent implementation after current controlled workflow/navigation. No arbitrary DOM/CSS, remote assets, layout replacement or security UI customization |
| Transfer center | Host transfer queue exists, no plugin entry | No ownership-bound task API | Observe and cancel only plugin-created tasks | Paths and host endpoints reveal user data. Cancel may leave partial files | Own transfer handle plus source/destination authority | Shared backpressure, cancel/close cleanup, honest partial status | Planned with batch 2. Do not broadcast or cancel other tasks |
| AI, MCP, automation | Host agent catalog and harness exist separately | No generic plugin AI or tool forwarding | Selection-to-proposal workflow through the same controlled primitives | Content disclosure, account authority and model cost | Explicit content sharing plus service origin/budget | No inherited history, tools, account, or hidden cron | Controlled workflow planned. Generic host AI and visible schedules deferred to distinct product decision |
| Known hosts, app lock and global security | Host security management UI | No plugin management API | Future allowlisted security-page navigation | Host-key trust, unlock, keys and security policy affect all sessions | Host navigation action only | No mutation or sensitive DTO | Security policy mutation, private-key reads, auto-trust and unlocking deferred indefinitely |
| Plugin blob/cache | Local KV exists | No exclusive blob cache | Evaluate bounded plugin-owned cache when a real workflow needs it | Disk growth and lifecycle ambiguity | Plugin namespace, no arbitrary path | Size quota, eviction and uninstall policy required | Deferred until a concrete larger-than-KV workflow establishes value and retention policy |
| Vault and terminal navigation | `tab` mounts a sandbox page through TopTabs/QuickSwitcher; aside/panel are overlays | No Vault subpage registry or terminal side-panel plugin tool. `settings` is a detail entry, not a new settings category | Accepted independent slice: host-owned namespaced Vault pages in an Extensions group, mounted in the Vault content area through existing ViewHost; then terminal pane/split pages for the selected session | Navigation cannot disclose session contents or replace built-in/security navigation | Declared page contribution plus host selection. Content reads have independent permissions | Host order/when/focus, cancellation of late open, view cleanup on hide/update/disable/uninstall, safe invalid-link fallback | Planned after host interaction/selected-context workflow. A button that merely opens a top tab does not satisfy Vault-page acceptance. No arbitrary router/DOM API |
| Distribution and runtimes | Browser-only public catalog, immutable packages, advanced developer gate | Signing/trust and release remain separate phase | Retain existing boundary | Package execution and publisher trust | Pinned catalog selection, hash, engine/features | Existing install transaction, quarantine and cleanup | No merge/release. Do not extend to arbitrary Node, IPC, shell, or global capabilities |
## Delivery order and stopping rule
1. Batch 1: actual authorization management plus network contract consistency.
Keep the existing manager layout. Add one independent permission panel.
2. Batch 1b: the host text interaction slice, then selected context and terminal
sharing/drafts. A plugin-supplied gesture is never authority.
3. A separate navigation slice mounts actual Vault extension pages, then terminal
sidebar panes, reusing the isolated ViewHost and keeping content permissions separate.
4. Deliver the declarative ThemePack stage with security appearance and contrast gates.
5. Batch 2: SFTP selected-file handles and explicit transfer destinations.
6. Reassess editor, Notes, snippets, menus, system summaries, and port forwarding
using the tested host-action and resource primitives.
Stop only when every row is implemented and tested, reasonably deferred with a
specific boundary and rationale, or awaiting a concrete user decision. A
planned row remains unfinished. Native screenshots and recordings belong to
the parent thread. Real credentials, hosts, or transfers require approval for a
specific target there. Synthetic checks do not establish native acceptance.
## Batch 1 evidence and next workflow
The independent permission panel adds only an import and a detail mount to
`components/settings/tabs/PluginPackageManager.tsx`. It uses the manager hook's
new `refresh()` after host revocation. Marketplace layout remains owned by the
parallel UI layer. Five locale files contain the panel strings.
The next usable workflow is a browser plugin that asks a host quick-pick/input
question, then explicitly copies its result or asks the user to share clipboard
text. The host displays the plugin source and action before reading or writing.
A canonical `HostInteractionRequest` and bounded result carry only text/choice
IDs. The host owns request IDs, requester webContents, runtime/version, TTL,
cancellation, and the consent result. The renderer cannot reply for another
window. Plugin `userGesture` values are ignored. Runtime/revoke shutdown aborts
all outstanding prompts and ignores late replies. A shared prompt primitive
then supports selected host/session context, selection share, and input drafts.
Before proceeding to the sensitive context/terminal and SFTP entries, the
native path must prove consent, cancellation, quota, and identity binding with
synthetic data. UI screenshots and real-resource approval remain parent-owned.
Baseline code references: `electron/plugins/hostService.cjs`,
`secureCapabilities.cjs`, `permissionEngine.cjs`, `terminalProviderService.cjs`,
`filesystemBroker.cjs`, `extensionProviderService.cjs`,
`packages/plugin-sdk/src/index.ts`, and
`packages/plugin-contract/schema/plugin-contract.schema.json`. Host business
implementations under `electron/capabilities/catalog/` are reference material,
not plugin authority. `infrastructure/ai/vaultAgentBridgeClient.ts` is not a
plugin DTO sanitizer.
+16 -1
View File
@@ -1,6 +1,6 @@
# Netcatty plugin contract and SDK
Status: internal preview (`0.1.0-internal`)
Status: internal preview (`0.1.0-internal.1`; legacy `0.1.0-internal` remains offered)
Tracking issue: [#2269](https://github.com/binaricat/Netcatty/issues/2269)
Phases 2 and 3 consume this contract in the isolated host runtime and secure
@@ -615,3 +615,18 @@ npm run build:plugin-packages
The complete application checks remain mandatory because workspace and root
dependency changes affect installation and release builds.
## Plugin brand icons (internal revision 0.1.0-internal.1)
Optional top-level `icon` reuses `IconReference` from contributions. A package
using it must require `metadata.brand-icon` and an API range accepting
`0.1.0-internal.1` but excluding `0.1.0-internal`. See
[the author guide](plugin-icons.md) for bounds and packaging commands.
The host still offers the previous wire revision to plugins with no top-level
icon whose range matches only that revision. Compatibility selection returns the
negotiated API version and runtime initialization uses and verifies that exact
version. Host version bounds remain independently enforced; unknown required
features still fail closed. No old manifest is rewritten and no permission grant
is migrated by this revision.
+130
View File
@@ -0,0 +1,130 @@
# Host text interactions
Status: internal preview. This layer builds on the grant management in Draft
#3955. It adds `host.interactions` feature discovery, a generated request/result
contract, and an ordinary browser SDK workflow. Native acceptance remains a
separate parent-owned gate.
## SDK and authorization
| SDK call | Permission | Result |
| --- | --- | --- |
| `ui.quickPick(title, items)` | `ui.interact` | Approved item ID or `undefined` on cancel |
| `ui.input(title, initialValue?)` | `ui.interact` | Approved single-line text or `undefined` on cancel |
| `clipboard.readText(title)` | `clipboard.read` | Explicitly shared snapshot or `undefined` on cancel |
| `clipboard.writeText(text, title)` | `clipboard.write` | `true` after copy, `false` on cancel |
`ui.input` rejects CR and LF in initial values before any dialog is displayed,
and rejects line breaks on submission. It does not silently remove hidden
newlines. The displayed single-line value is the value submitted to the plugin.
Clipboard text continues to support multiple lines.
All calls use the canonical `ui.interact` RPC. No host DOM, native object,
router, general command ID, or arbitrary IPC channel enters the SDK. Existing
API versions remain offered. A plugin requiring the new workflow declares
`features.required: ["host.interactions"]`; old hosts reject unsupported
required features. Old plugins keep their existing APIs and permissions.
Clipboard prompts restrict new permission decisions to once. Required or
previously saved grants never skip the host action confirmation. Input and
choice dialogs need their own declaration; a `views` or `menus` declaration is
not permission to open them. Direction-specific clipboard permissions remain
separate. The normal sample uses no network, host/session, filesystem, secret,
Node, or shell capability.
## User action and text sharing
The host always displays plugin ID/version and a fixed source heading. Titles,
choices, and text render as plain text. No plugin-supplied `userGesture` flag is
accepted. The host creates an HMAC action token bound to request ID, runtime,
plugin version, owner window, action kind, phase, nonce, and expiry. It sends
that token only to the trusted host renderer. A response must come from the
same WebContents and present the current token. Tokens are consumed before the
first asynchronous action guard, so old-phase and duplicate responses cannot
commit an action.
Clipboard reading has two steps. **Preview clipboard** authorizes a host read
into a bounded in-memory snapshot. The plugin receives nothing yet. **Share
with plugin** sends exactly that snapshot after another active-identity check.
Changes to the system clipboard after preview do not replace it. Cancel, lock,
window loss, or revoke discards the snapshot. Copy shows the exact replacement
text and requires its own confirmation before a native write.
Shared text can be sensitive. The host explains that it becomes available to
the plugin and its granted capabilities, including network access. Reading
text does not approve a new network destination. This UI does not claim to
prevent a plugin from transmitting derived text through an already granted
capability. The example avoids network permission entirely.
## Availability, limits, and cleanup
The first slice supports the focused, visible, unlocked main window only.
The host renderer registers readiness before any prompt can be presented.
Other or unready windows return unavailable instead of inheriting an owner's
authority. One interaction occupies an owner window at a time; there is no
hidden prompt queue.
`HostInteractionLimits` is the shared canonical source for 4,096 UTF-8 text
bytes, 100 choices, 64 KiB serialized requests, one pending request per runtime,
eight total, ten attempts per minute per runtime, and a 60-second lifetime.
The current main-window-only adapter is narrower than the total pending bound.
Admission runs before the permission prompt. The generic capability quotas and
byte windows still apply. Text exceeding its cap is rejected without silent
truncation. RPC uses the same generated deadline.
After a native permission decision, an originally foreground request can wait
at most one second for the same owner to regain focus. The host never focuses
the window itself, presents content in the background, or postpones admission
for an originally background request. Hide, lock, revoke, runtime stop, owner
replacement and reload invalidate this admission, even during the permission
dialog. Runtime activity and foreground state are checked again after waiting.
Input cancellation, timeout, disable/update/crash/revoke, owner blur/hide/close,
main-document reload, renderer exit, and app lock invalidate pending tokens.
Same-document or subframe navigation does not permanently unregister a live
host UI. Runtime teardown clears rate accounting. The broker checks runtime
activity, owner readiness/focus, and app lock again immediately before native
read/write or disclosure. A revoked operation cannot resume from a late reply.
Commands normally retain the existing 30-second RPC deadline. A command that
declares `interactive: true` must require `commands.interactive`; the host gives
only that command a fixed 180-second total budget. This is not a global timeout
increase or a UI/clipboard permission. Each individual host interaction still
has its own 60-second deadline, including permission approval time.
When an outer command fails, times out or is cancelled, the host cancels pending
interactions and pre-prompt admissions for its captured runtime. On successful
completion it closes orphan interactions once the last concurrent command ends.
This cleanup does not reset rate/byte quotas and cannot affect a replacement
runtime. A failure conservatively closes that runtime's single shared pending
dialog even if another command is active. Late action tokens cannot commit a
clipboard effect. Cancellation does not forcibly terminate arbitrary plugin
JavaScript; ignored errors remain subject to permission, focus, rate and runtime
limits. Do not start independent interactive workflows concurrently.
Audit records contain only fixed event/action/kind fields. They contain no
prompt, choices, user text, clipboard bytes, action tokens, or hashes derived
from those values. A failed consent-audit write prevents the native action;
no successful operation is reported for that failure.
## Verification and native acceptance
The [browser fixture](../../examples/plugins/host-interactions/README.md)
exercises choice, input, copy, and clipboard transformation without a plugin
view. Tests cover the actual runtime/SDK RPC path through permission middleware,
actual host React dialog events against service-signed actions, denial, cancel,
quota before prompt, overlong text, duplicate/forged actions, wrong owners,
stale runtime, revoke during an asynchronous guard, audit failure, deadline,
lock, blur, document navigation, and renderer teardown.
Parent native acceptance uses a separately approved temporary example-plugin
session and synthetic clipboard text. It needs no real hosts, credentials,
network destination, shell, or persistent grant. Check both complete workflows,
cancel before reading, cancel after preview, cancel copy, and disable/revoke
with a pending dialog. Record the exact SHA and restore the test clipboard.
Unit tests are not evidence of native Electron acceptance.
Progress/toast, selected context and terminal sharing/drafts remain later slices.
Navigation uses the existing sandbox ViewHost and adds no content permission.
ThemePack and SFTP remain separate stages. This layer alone does not finish the
capability coverage matrix.
@@ -1,6 +1,6 @@
# Plugin manager window and brand icons
Status: the manager dialog is implemented in this change. The brand-icon contract below is a proposal, not a supported manifest field yet.
Status: historical design investigation. The manager dialog and the accepted brand-icon design are now implemented. See [the current author guide](plugin-icons.md) for the shipped contract and bounds. The sections below preserve the original investigation and recommendations.
## Manager window
+81
View File
@@ -0,0 +1,81 @@
# Add a plugin brand icon
Brand icons appear on Community cards/details and Installed lists/details. They
are optional; plugins without them retain a host-owned fallback. An icon does
not activate code or grant plugin permissions.
## Declare the icon
Use the existing `IconReference` shape at the top level of `netcatty.plugin.json`:
```json
{
"engines": { "netcatty": ">=0.0.0", "api": ">=0.1.0-internal.1 <0.2.0" },
"features": { "required": ["metadata.brand-icon"] },
"icon": {
"kind": "package",
"light": "assets/logo-light.png",
"dark": "assets/logo-dark.webp"
}
}
```
This is a manifest fragment. Preserve the required ID, name, version, publisher,
main and other fields. The host-version value above is for this repository's
unreleased development build (version 0.0.0); use the actual first supporting
Netcatty release as your lower bound when it is assigned. Do not invent a release
number. The API range and required feature enforce support in the meantime.
Alternatively use a host-owned theme glyph, for example
`"icon": { "kind": "theme", "name": "terminal" }`, with the same API/feature
requirements. Theme glyphs use the existing host allowlist; unknown names fall back safely.
## Prepare files
Use a static PNG or WebP. A square transparent 128×128 or 256×256 image is
recommended. Each source must be at most 256 KiB, with both dimensions between
1 and 512 pixels. `dark` is optional; otherwise `light` is used in both themes.
Use lowercase `.png` / `.webp` extensions matching the encoded bytes. Animated
PNG/WebP and SVG are not accepted for brand icons. Existing contribution icons
are unchanged, including their separately isolated SVG support.
Paths are relative to the package root. URLs, absolute paths, traversal,
backslashes and symlinks escaping the package are not supported. Include each
referenced file in the package. No external image URL is loaded as a fallback.
The host performs offline isolated decoding and displays a bounded PNG result;
invalid or unreadable images fall back to the host glyph.
## Validate and package
From this repository, after building your plugin:
```sh
npm run build:plugin-packages
node packages/plugin-cli/dist/cli.js validate examples/plugins/starter-netcatty
node packages/plugin-cli/dist/cli.js compatibility examples/plugins/starter-netcatty --netcatty 0.0.0 --api 0.1.0-internal.1 --features metadata.brand-icon
node packages/plugin-cli/dist/cli.js pack examples/plugins/starter-netcatty --out /tmp/starter-netcatty.ncpkg
```
Directory validation, packaging and imported-archive validation check declared
brand files and bounds. The host reads installed metadata by plugin ID and
verified package identity, not a renderer-provided file path. Disabled packages
can display their icon; quarantined, missing or changed packages fall back. The
existing enabled-only contribution icon service gate is not relaxed.
## Community previews
For an uninstalled plugin, the directory maintainer includes a reviewed raster
preview in the application bundle. The preview binds the plugin ID, version,
repository, source commit and icon declaration. Its bytes and SHA-256 are checked
against the reviewed source files in tests. The current Starter preview uses its
first-party source assets; source-only listings do not pretend to have a release
artifact. Missing or mismatched previews use the same fallback, without fetching
an image from the author's server. Submit the source assets and updated directory
metadata through the existing repository PR review process.
## Compatibility
This is API/schema revision `0.1.0-internal.1`. An older host rejects the unknown
manifest field or missing required feature. The new host continues to offer
`0.1.0-internal` for legacy plugins without the new field, including exact old
API ranges. Existing plugins need no manifest or data migration.
+56
View File
@@ -0,0 +1,56 @@
# Official registry client
The Community page refreshes the fixed endpoint
`https://raw.githubusercontent.com/binaricat/netcatty-plugins/main/catalog.json`
in the main process. The renderer cannot choose a registry URL. This endpoint
must be published separately; client implementation and synthetic tests do not
prove the repository or an online artifact is available.
## Catalog contract
`catalog.json` is an array of at most 1,000 current entries. Each entry supplies
`id`, `version`, `name`, `description`, `author`, `repository` (`owner/repository`),
`commit` (full 40-character SHA), `license`, `readme` (relative Markdown path),
and `manifest`. Optional `artifact` contains `url` and SHA-256 `sha256`.
Unknown catalog-entry and artifact fields are rejected. The whole JSON value,
including manifest defaults, uses the existing 128-level / 100,000-value budget
and finite-number validation. The existing manifest schema, browser-only distribution policy, identity/version
and license checks apply. Source-only entries remain build-and-import examples.
No download counts, ratings, or timestamps are invented. Brand previews remain
bundled and reviewed; arbitrary author image URLs are never loaded.
Reads are anonymous HTTPS, redirect-free for the registry, bounded to 2 MiB,
and time out after 30 seconds. All entries must validate, safely clone, and generate every selection token before
atomically replacing the current snapshot. Invalid JSON/schema, network failure, and oversized responses
leave the previous good snapshot intact. Errors and Retry remain visible. The Community header also has Refresh, including
when the last refresh succeeded; it is disabled while a request is pending.
The last good catalog is retained **in memory for this application session**;
a fresh offline launch uses bundled examples. There is no new disk cache,
database migration, cloud sync record, or credential requirement.
## Selection and installation
Each returned entry has a host-generated `selectionToken` covering its complete
JSON contents. This is a concurrency check, **not a signature or trust proof**.
README requests must still match that selection and fetch its pinned source
commit. A changed entry requires refresh and renewed review.
A manual install must first refresh the official catalog successfully and match
the selected token again. Failed refresh is browse-only, even if the cached
entry has an artifact. Withdrawal, version changes, permission changes, README
changes, or artifact hash changes cannot silently replace the selected package.
Archive download then uses the existing approved-host, size, exact SHA-256,
manifest and package validation pipeline. Installation leaves the plugin disabled;
existing authorization gates still apply on activation/use.
`index.json` release history and withdrawal records are not consumed by this
first client. Removing an entry does **not** disable, uninstall, update, or delete
data from an already installed plugin. There are no background code updates.
## Verification boundary
Service regressions use synthetic network responses and package bytes; the
existing package manager tests cover archive admission. No third-party plugin,
real credential, real sync account, or live registry write is used. Native QA
must separately verify the visible offline/retry state and eventual real catalog
availability once the repository is published.
@@ -226,3 +226,33 @@ after durable user data can exist.
- PR 9 supplies signed publisher principals and trust policy through placement;
signed identity changes force a fresh grant instead of widening an unsigned
grant silently.
## Inspect and revoke actual grants
The installed package detail separates declared permissions from **Saved
grants**. The host-only panel lists resource, scope, session (when applicable),
and grant time. Each row indicates whether its declaration hash and security
principal match the host-owned current activation. Saved grants from an older
package or a stopped/disabled activation remain visible as inactive. One-use decisions do not appear as retained grants. Refresh
reads the engine again. Recent security events include at most 100 entries and
project only event, time, permission, and scope. Arbitrary database details,
operation IDs, terminal content, and credential material never cross this UI
boundary.
**Disable and revoke all grants** runs through the manager mutation queue. It
persists disabled state before revoking grants, invalidates contributions, and
waits for runtime shutdown. Existing runtime teardown cancels RPC, streams,
leases, providers, and companions. Every cleanup stage is attempted if grant deletion, audit persistence, or
contribution invalidation fails. Multiple errors are aggregated and reported;
the UI never claims successful revocation when persistence or cleanup failed. Explicit enable requests fresh access.
This management bridge is available only to the trusted app renderer. It is
not a plugin RPC method and grants no plugin authority over another package.
`PluginNetworkRequest` and `PluginNetworkResponse` now derive from the canonical
schema. `credentialLease` and `authorization` describe the existing host lease
injection path. Hosts advertise `network.credential-lease` for discovery without
changing existing API versions or disabling legacy requests. Plugins may
require that feature or handle its absence. Optional authorization defaults to
Bearer. Basic authorization accepts a bounded username. Plaintext is redeemed
only inside the host after origin permission and operation binding checks.
See the [synthetic fixture](../../examples/plugins/lease-http/README.md).
+1
View File
@@ -9,6 +9,7 @@ const PERMISSION_DESCRIPTIONS = {
"settings.write": [false, "Change plugin settings", "Update this plugin's preferences.", "修改插件设置", "修改此插件自己的设置。"],
commands: [false, "Plugin commands", "Register and run this plugin's own commands.", "插件命令", "注册和执行此插件自己的命令。"],
menus: [false, "Menu entries", "Add this plugin's actions to host menus.", "菜单入口", "将此插件的操作加入应用菜单。"],
"ui.interact": [false, "Host prompts", "Ask the host to show bounded input and choice dialogs.", "宿主交互", "请求宿主显示有界的输入和选择对话框。"],
views: [false, "Plugin panels", "Display plugin-owned panels inside the app.", "插件面板", "在应用中显示插件自己的面板。"],
"clipboard.read": [true, "Read clipboard", "Read copied text, which may contain passwords or other sensitive data.", "读取剪贴板", "读取复制的文本,其中可能包含密码或其他敏感数据。"],
"clipboard.write": [true, "Write clipboard", "Replace clipboard content that you may paste elsewhere.", "写入剪贴板", "替换剪贴板内容,可能影响你随后粘贴的数据。"],
+14
View File
@@ -165,10 +165,19 @@ function createBridgeRegistrar(context) {
app,
electron: electronModule,
safeStorage,
getInteractionWindow: () => win,
isAppLocked: () => getAppLockController?.()?.getRuntimeState?.()?.locked !== false,
subscribeAppLock: (listener) => getAppLockController?.()?.subscribe?.(listener),
requestPermissionDecision: createNativePermissionDecisionProvider({
getLocale: () => getWindowManager().getCurrentLanguage?.() ?? "en",
dialog: electronModule.dialog,
window: win,
getWindow: () => {
const settingsWindow = getWindowManager().getSettingsWindow?.();
return settingsWindow && !settingsWindow.isDestroyed() && settingsWindow.isFocused()
? settingsWindow
: win;
},
}),
getLocale: () => getWindowManager().getCurrentLanguage?.() ?? "en",
requestTerminalInterceptorSelection: async ({ direction, providers }) => {
@@ -211,6 +220,10 @@ function createBridgeRegistrar(context) {
});
registerPluginBridge(ipcMain, {
manager: pluginHostService?.manager,
interactionService: pluginHostService?.interactionService,
permissionEngine: pluginHostService?.permissionEngine,
runtimeSupervisor: pluginHostService?.runtimeSupervisor,
database: pluginHostService?.database,
catalogService: pluginHostService?.catalogService,
contributionService: pluginHostService?.contributionService,
terminalProviderService: pluginHostService?.terminalProviderService,
@@ -229,6 +242,7 @@ function createBridgeRegistrar(context) {
});
return result.canceled ? null : result.filePaths[0] ?? null;
},
resolveBrandIcon: (pluginId) => pluginHostService?.contributionIconService?.resolveBrand(pluginId),
resolveContributionIcon: (payload) => pluginHostService?.contributionIconService?.resolve(payload),
viewHost: pluginHostService?.viewHost,
env: process.env,
+6
View File
@@ -23,3 +23,9 @@ require("./plugins/runtimeSupervisor.test.cjs");
require("./plugins/shutdownCoordinator.test.cjs");
require("./plugins/streamRouter.test.cjs");
require("./plugins/utilityPluginRuntime.test.cjs");
require('./plugins/brandIconService.test.cjs');
require('./plugins/settingsViewFixture.test.cjs');
require('./plugins/registryCatalogService.test.cjs');
@@ -0,0 +1,38 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs/promises');
const path = require('node:path');
const os = require('node:os');
const { PluginContributionIconService } = require('./contributionIconService.cjs');
async function fixture(t, rasterize) {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'brand-icon-'));
t.after(() => fs.rm(root, { recursive: true, force: true }));
await fs.copyFile(path.join(__dirname, '../..', 'examples/plugins/starter-netcatty/assets/logo-light.png'), path.join(root, 'logo.png'));
let plugin = { id: 'com.test.brand', activeVersion: '1.0.0', archiveSha256: 'a', enabled: false, runtime: {}, manifest: { icon: { kind: 'package', light: 'logo.png' } } };
const service = new PluginContributionIconService({ database: { getActivePlugin: () => plugin }, packageStore: { preparePackageRoot: async () => root }, rasterizeIcon: rasterize ?? (async () => {
const output = Buffer.alloc(24); Buffer.from([137,80,78,71,13,10,26,10]).copy(output); output.write('IHDR', 12); output.writeUInt32BE(32, 16); output.writeUInt32BE(32, 20); return output;
}) });
return { service, plugin, setPlugin: (value) => { plugin = value; } };
}
test('disabled installed branding is readable without runtime activation or changing contribution admission', async (t) => {
const { service, plugin } = await fixture(t);
assert.match((await service.resolveBrand(plugin.id)).light, /^data:image\/png;base64,/);
await assert.rejects(service.resolve({ pluginId: plugin.id, icon: plugin.manifest.icon }), /not declared/);
plugin.manifest.contributes = { views: [{ icon: plugin.manifest.icon }] };
await assert.rejects(service.resolve({ pluginId: plugin.id, icon: plugin.manifest.icon }), /unavailable/);
plugin.runtime.quarantinedAt = 1;
assert.equal(await service.resolveBrand(plugin.id), null);
});
test('brand results cannot survive uninstall or a changed package hash during decoding', async (t) => {
for (const change of ['uninstall', 'hash']) {
let release; let entered;
const started = new Promise((resolve) => { entered = resolve; });
const gate = new Promise((resolve) => { release = resolve; });
const f = await fixture(t, async () => { entered(); await gate; const b = Buffer.alloc(24); Buffer.from([137,80,78,71,13,10,26,10]).copy(b); b.write('IHDR',12); b.writeUInt32BE(32,16); b.writeUInt32BE(32,20); return b; });
const pending = f.service.resolveBrand(f.plugin.id);
await started;
if (change === 'uninstall') f.setPlugin(null); else f.plugin.archiveSha256 = 'b';
release();
await assert.rejects(pending, /ownership changed/);
}
});
+14 -4
View File
@@ -1,12 +1,12 @@
[
{
"id": "com.netcatty.starter",
"version": "0.1.1",
"version": "0.1.2",
"name": "Plugin Starter",
"description": "First-party greeting panel, local settings and commands, without terminal or secret access.",
"author": "Netcatty",
"repository": "binaricat/Netcatty",
"commit": "3efabf2b0f347e5f8f1ea5c148ab0716c36d3be1",
"commit": "f4a6b205adca486d4e9cda0270e13a9ff0afd7be",
"license": "GPL-3.0-or-later",
"readme": "examples/plugins/starter-netcatty/README.md",
"manifest": {
@@ -18,12 +18,12 @@
"zh-CN": "插件入门"
},
"description": "First-party settings and command example. No terminal, network, filesystem or secret access.",
"version": "0.1.1",
"version": "0.1.2",
"publisher": "netcatty",
"license": "GPL-3.0-or-later",
"engines": {
"netcatty": ">=0.0.0",
"api": ">=0.1.0-internal <0.2.0"
"api": ">=0.1.0-internal.1 <0.2.0"
},
"main": {
"browser": "dist/index.js"
@@ -82,6 +82,16 @@
"entry": "panel.html"
}
]
},
"features": {
"required": [
"metadata.brand-icon"
]
},
"icon": {
"kind": "package",
"light": "assets/logo-light.png",
"dark": "assets/logo-dark.png"
}
}
},
+25 -5
View File
@@ -8,6 +8,9 @@ const { getTempFilePath } = require("../bridges/tempDirBridge.cjs");
const { createDefinitionValidator, formatValidationErrors } = require("./contractValidator.cjs");
const validateManifest = createDefinitionValidator("PluginManifest");
const { assertCatalogManifest } = require("./catalogPolicy.cjs");
const { assertPluginJsonValue } = require("./jsonBoundary.cjs");
const MAX_CATALOG_BYTES = 2 * 1024 * 1024;
const CATALOG_FIELDS = new Set(["id", "version", "name", "description", "author", "repository", "commit", "license", "readme", "manifest", "artifact"]);
const DOWNLOAD_HOSTS = new Set(["github.com", "raw.githubusercontent.com", "release-assets.githubusercontent.com", "objects.githubusercontent.com"]);
function assertDownloadUrl(value) {
@@ -17,11 +20,22 @@ function assertDownloadUrl(value) {
}
return url.href;
}
function pinnedReadmeUrl(entry) {
if (typeof entry.readme !== "string" || /[\u0000-\u001f\u007f\\%?#]/u.test(entry.readme)
|| entry.readme.split("/").some((segment) => !segment || segment === "." || segment === "..")) {
throw new Error("Invalid or unpinned README path");
}
const prefix = `/${entry.repository}/${entry.commit}/`;
const url = new URL(`https://raw.githubusercontent.com${prefix}${entry.readme.split("/").map(encodeURIComponent).join("/")}`);
if (!url.pathname.startsWith(prefix)) throw new Error("README URL escapes the pinned commit");
return url.href;
}
function validateCatalog(entries) {
assertPluginJsonValue(entries, { maxBytes: MAX_CATALOG_BYTES });
if (!Array.isArray(entries) || entries.length > 1000) throw new Error("Invalid plugin catalog");
const ids = new Set();
for (const entry of entries) {
if (!entry || !/^[a-z0-9][a-z0-9.-]+$/u.test(entry.id) || ids.has(entry.id)
if (!entry || Object.keys(entry).some((key) => !CATALOG_FIELDS.has(key)) || ["id", "version", "name", "description", "author", "repository", "commit", "license", "readme"].some((key) => typeof entry[key] !== "string" || entry[key].length > 4096) || !/^[a-z0-9][a-z0-9.-]+$/u.test(entry.id) || ids.has(entry.id)
|| !/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/u.test(entry.repository)
|| !/^[a-f0-9]{40}$/u.test(entry.commit)
|| !entry.license || !entry.author || !entry.name
@@ -29,10 +43,16 @@ function validateCatalog(entries) {
|| entry.readme.split("/").some((p) => !p || p === "." || p === "..") || /[\\%?#\0]/u.test(entry.readme)) {
throw new Error("Invalid or unpinned plugin catalog entry");
}
pinnedReadmeUrl(entry);
if (!validateManifest(entry.manifest)) throw new Error(`Catalog manifest is invalid: ${formatValidationErrors(validateManifest.errors)}`);
assertCatalogManifest(entry.manifest, entry);
ids.add(entry.id);
if (entry.artifact) {
if (Object.hasOwn(entry, "artifact")) {
if (!entry.artifact || typeof entry.artifact !== "object" || Array.isArray(entry.artifact)
|| Object.keys(entry.artifact).some((key) => key !== "url" && key !== "sha256")
|| typeof entry.artifact.url !== "string" || typeof entry.artifact.sha256 !== "string") {
throw new Error("Invalid catalog artifact metadata");
}
assertDownloadUrl(entry.artifact.url);
if (!/^[a-f0-9]{64}$/u.test(entry.artifact.sha256)) throw new Error("Catalog artifact requires SHA-256");
}
@@ -71,7 +91,7 @@ async function fetchBounded(url, limit, fetchImpl = fetch, maxRedirects = 4) {
}
class PluginCatalogService {
constructor(options) {
this.entries = validateCatalog(structuredClone(options.entries ?? catalog));
this.entries = structuredClone(validateCatalog(options.entries ?? catalog));
this.fetch = options.fetch ?? fetch;
this.getTempFilePath = options.getTempFilePath ?? getTempFilePath;
this.manager = options.manager;
@@ -85,7 +105,7 @@ class PluginCatalogService {
}
async readme(id) {
const entry = this.get(id);
const url = `https://raw.githubusercontent.com/${entry.repository}/${entry.commit}/${entry.readme}`;
const url = pinnedReadmeUrl(entry);
return (await fetchBounded(url, 256 * 1024, this.fetch, 0)).toString("utf8");
}
async install(id) {
@@ -107,4 +127,4 @@ class PluginCatalogService {
}
}
}
module.exports = { PluginCatalogService, assertDownloadUrl, fetchBounded, validateCatalog };
module.exports = { PluginCatalogService, assertDownloadUrl, fetchBounded, validateCatalog, pinnedReadmeUrl, MAX_CATALOG_BYTES };
+1 -1
View File
@@ -2,7 +2,7 @@
const PLUGIN_DEV_ENV = "NETCATTY_PLUGIN_DEV";
const PLUGIN_PROTOCOL_SCHEME = "netcatty-plugin";
const PLUGIN_API_VERSION = "0.1.0-internal";
const PLUGIN_API_VERSION = "0.1.0-internal.1";
const PLUGIN_ACTIVATION_TIMEOUT_MS = 5_000;
const PLUGIN_DEACTIVATION_TIMEOUT_MS = 2_000;
const PLUGIN_UTILITY_TERMINATION_GRACE_MS = 500;
+32 -2
View File
@@ -231,12 +231,42 @@ class PluginContributionIconService {
return result;
}
async #readIcon(packageRoot, packagePath) {
// Branding is inert installed metadata. It does not share the enabled-only
// contribution admission path, and accepts no renderer-provided resource path.
async resolveBrand(pluginId) {
if (typeof pluginId !== "string" || pluginId.length > 256) throw new TypeError("Invalid plugin ID");
const plugin = this.database.getActivePlugin(pluginId);
if (!plugin?.activeVersion || plugin.runtime?.quarantinedAt != null) return null;
const icon = plugin.manifest?.icon;
if (icon?.kind !== "package") return null;
const identity = JSON.stringify([plugin.activeVersion, plugin.archiveSha256, icon]);
const key = JSON.stringify(["brand", pluginId, identity]);
if (this.cache.has(key)) return this.cache.get(key);
if (this.pending.has(key)) return this.pending.get(key);
const pending = (async () => {
const root = await this.packageStore.preparePackageRoot(plugin);
const result = { light: await this.#readIcon(root, icon.light, true),
...(icon.dark ? { dark: await this.#readIcon(root, icon.dark, true) } : {}) };
const current = this.database.getActivePlugin(pluginId);
if (!current || current.runtime?.quarantinedAt != null || identity !== JSON.stringify([current.activeVersion, current.archiveSha256, current.manifest?.icon])) {
throw new Error("Plugin brand icon ownership changed while loading");
}
if (this.cache.size >= 256) this.cache.clear();
this.cache.set(key, result);
return result;
})().finally(() => this.pending.delete(key));
this.pending.set(key, pending);
return pending;
}
async #readIcon(packageRoot, packagePath, brand = false) {
const segments = packagePath.split("/");
const file = await readContainedFile(packageRoot, segments);
if (file.body.byteLength > MAX_ICON_SOURCE_BYTES) throw new Error("Plugin contribution icon is too large");
const extension = path.extname(file.filePath).toLowerCase();
const inspected = inspectContributionIconSource(file.body, extension);
const inspected = brand
? (await import("@netcatty/plugin-contract")).inspectPluginBrandIcon(file.body, packagePath)
: inspectContributionIconSource(file.body, extension);
const png = await this.rasterizeIcon({
body: file.body,
...inspected,
+28 -6
View File
@@ -6,6 +6,7 @@ const { assertPluginContextKey, evaluateContextKeyExpression } = require("./cont
const { assertPluginJsonValue } = require("./jsonBoundary.cjs");
const { compileRestrictedJsonSchema } = require("./restrictedJsonSchema.cjs");
const { PluginRpcError, RPC_ERRORS } = require("./rpcRouter.cjs");
const { InteractiveCommandFeature, InteractiveCommandLimits } = require("./generated/plugin-contract.schema.json").$defs;
const MAX_SETTING_BYTES = 128 * 1024;
const MAX_PATTERN_LENGTH = 512;
@@ -149,6 +150,8 @@ class PluginContributionService {
constructor(options) {
this.database = options.database;
this.runtimeSupervisor = options.runtimeSupervisor;
this.onCommandFinished = options.onCommandFinished;
this.activeCommandCalls = new Map();
this.secretStore = options.secretStore;
this.getLocale = options.getLocale ?? (() => "en");
this.contextKeys = new Map();
@@ -336,12 +339,31 @@ class PluginContributionService {
throw new PluginRpcError(RPC_ERRORS.failedPrecondition, `Plugin command is disabled: ${commandId}`);
}
assertJsonValue(args ?? null, "command arguments");
await this.#ensureActivated(plugin, `onCommand:${commandId}`);
return this.runtimeSupervisor.request(plugin.id, "plugin.command.execute", {
command: commandId,
...(args === undefined ? {} : { args }),
invocation: freezeJson({ source: invocation.source ?? "host", context: invocation.context ?? {} }),
});
if (contribution.interactive && !plugin.manifest.features?.required?.includes(InteractiveCommandFeature.const)) {
throw new PluginRpcError(RPC_ERRORS.failedPrecondition, "Interactive command feature is required");
}
const identity = await this.#ensureActivated(plugin, `onCommand:${commandId}`);
const runtimeId = identity?.runtimeId;
if (runtimeId) this.activeCommandCalls.set(runtimeId, (this.activeCommandCalls.get(runtimeId) ?? 0) + 1);
let failed = true;
try {
const result = await this.runtimeSupervisor.request(plugin.id, "plugin.command.execute", {
command: commandId,
...(args === undefined ? {} : { args }),
invocation: freezeJson({ source: invocation.source ?? "host", context: invocation.context ?? {} }),
}, { expectedIdentity: identity, ...(contribution.interactive ? { timeoutMs: InteractiveCommandLimits.const.timeoutMs } : {}) });
failed = false;
return result;
} finally {
if (runtimeId) {
const remaining = Math.max(0, (this.activeCommandCalls.get(runtimeId) ?? 1) - 1);
if (remaining) this.activeCommandCalls.set(runtimeId, remaining);
else this.activeCommandCalls.delete(runtimeId);
// A failed command must not leave a live native consent prompt. On
// success wait for other concurrent commands before closing orphans.
if (failed || remaining === 0) this.onCommandFinished?.(runtimeId);
}
}
}
async activateView(viewId, params = {}) {
@@ -87,6 +87,7 @@ function setup(context, pluginManifest, options = {}) {
runtimeSupervisor,
secretStore,
getLocale: () => "zh-CN",
onCommandFinished: options.onCommandFinished,
});
return { calls, database, secretStore, service, secrets };
}
@@ -100,6 +101,48 @@ test("plugin contribution values enforce the exact JSON value boundary", () => {
}
});
test("only explicitly declared interactive commands receive the fixed budget and command completion is runtime-bound", async (t) => {
const pluginManifest = manifest();
delete pluginManifest.contributes.commands[0].enablement;
pluginManifest.features = { required: ["commands.interactive"] };
pluginManifest.contributes.commands.push({ id: `${pluginManifest.id}.interactive`, title: "Interactive", interactive: true });
const optionsSeen = [], finished = [];
const runtimeId = "runtime-budget";
const { service } = setup(t, pluginManifest, {
onCommandFinished: (id) => finished.push(id),
runtimeSupervisor: {
async start() { return { runtimeId, pluginVersion: pluginManifest.version }; },
async request(_id, _method, _params, options) { optionsSeen.push(options); return null; },
},
});
await service.executeCommand(`${pluginManifest.id}.hello`);
await service.executeCommand(`${pluginManifest.id}.interactive`);
assert.equal(Object.hasOwn(optionsSeen[0], "timeoutMs"), false, "ordinary commands retain the existing router deadline");
assert.equal(optionsSeen[0].expectedIdentity.runtimeId, runtimeId);
assert.equal(optionsSeen[1].timeoutMs, 180_000);
assert.equal(optionsSeen[1].expectedIdentity.runtimeId, runtimeId);
assert.deepEqual(finished, [runtimeId, runtimeId]);
assert.equal(service.activeCommandCalls.size, 0);
});
test("concurrent successful commands retain the runtime interaction until the last ends, while a failed command cancels it", async (t) => {
const pluginManifest = manifest(); delete pluginManifest.contributes.commands[0].enablement;
const pending = [], finished = [];
const { service } = setup(t, pluginManifest, { onCommandFinished: (id) => finished.push(id),
runtimeSupervisor: { async start() { return { runtimeId: "runtime-concurrent" }; },
request() { return new Promise((resolve, reject) => pending.push({ resolve, reject })); } },
});
const first = service.executeCommand(`${pluginManifest.id}.hello`);
const second = service.executeCommand(`${pluginManifest.id}.hello`);
await new Promise((resolve) => setImmediate(resolve));
pending[0].resolve(null); await first;
assert.deepEqual(finished, []);
pending[1].reject(new Error("synthetic timeout"));
await assert.rejects(second, /synthetic timeout/);
assert.deepEqual(finished, ["runtime-concurrent"]);
assert.equal(service.activeCommandCalls.size, 0);
});
test("only onStartupFinished plugins activate during contribution initialization", async (context) => {
const pluginManifest = manifest("com.example.startup", ["onStartupFinished"]);
const { calls, service } = setup(context, pluginManifest);
@@ -1,6 +1,6 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://netcatty.com/schemas/plugins/0.1.0-internal/plugin-contract.schema.json",
"$id": "https://netcatty.com/schemas/plugins/0.1.0-internal.1/plugin-contract.schema.json",
"title": "Netcatty Plugin Contract",
"description": "Canonical public contract for Netcatty plugin manifests and host RPC messages.",
"$ref": "#/$defs/PluginManifest",
@@ -231,6 +231,440 @@
},
"additionalProperties": false
},
"HostInteractionLimits": {
"const": {
"textBytes": 4096,
"requestBytes": 65536,
"items": 100,
"pendingPerRuntime": 1,
"pendingTotal": 8,
"requestsPerMinute": 10,
"timeoutMs": 60000
}
},
"HostInteractionFeature": {
"const": "host.interactions"
},
"InteractiveCommandFeature": {
"const": "commands.interactive"
},
"InteractiveCommandLimits": {
"const": {
"timeoutMs": 180000
}
},
"HostQuickPickItem": {
"type": "object",
"required": [
"id",
"label"
],
"properties": {
"id": {
"type": "string",
"minLength": 1,
"maxLength": 128
},
"label": {
"type": "string",
"minLength": 1,
"maxLength": 512
},
"description": {
"type": "string",
"maxLength": 512
}
},
"additionalProperties": false
},
"HostQuickPickRequest": {
"type": "object",
"required": [
"kind",
"title",
"items"
],
"properties": {
"kind": {
"const": "quickPick"
},
"title": {
"type": "string",
"minLength": 1,
"maxLength": 128,
"pattern": "^[^\\u0000-\\u001f\\u007f]*$"
},
"items": {
"type": "array",
"minItems": 1,
"maxItems": 100,
"items": {
"$ref": "#/$defs/HostQuickPickItem"
}
}
},
"additionalProperties": false
},
"HostSingleLineInputText": {
"description": "Single-line host input. CR and LF are rejected before display and on submission; clipboard text remains multiline.",
"type": "string",
"maxLength": 4096,
"pattern": "^[^\\u0000\\r\\n]*$"
},
"HostInputRequest": {
"type": "object",
"required": [
"kind",
"title"
],
"properties": {
"kind": {
"const": "input"
},
"title": {
"type": "string",
"minLength": 1,
"maxLength": 128,
"pattern": "^[^\\u0000-\\u001f\\u007f]*$"
},
"initialValue": {
"$ref": "#/$defs/HostSingleLineInputText"
}
},
"additionalProperties": false
},
"HostClipboardReadRequest": {
"type": "object",
"required": [
"kind",
"title"
],
"properties": {
"kind": {
"const": "clipboard.read"
},
"title": {
"type": "string",
"minLength": 1,
"maxLength": 128,
"pattern": "^[^\\u0000-\\u001f\\u007f]*$"
}
},
"additionalProperties": false
},
"HostClipboardWriteRequest": {
"type": "object",
"required": [
"kind",
"title",
"text"
],
"properties": {
"kind": {
"const": "clipboard.write"
},
"title": {
"type": "string",
"minLength": 1,
"maxLength": 128,
"pattern": "^[^\\u0000-\\u001f\\u007f]*$"
},
"text": {
"type": "string",
"maxLength": 4096,
"pattern": "^[^\\u0000]*$"
}
},
"additionalProperties": false
},
"HostInteractionRequest": {
"oneOf": [
{
"$ref": "#/$defs/HostQuickPickRequest"
},
{
"$ref": "#/$defs/HostInputRequest"
},
{
"$ref": "#/$defs/HostClipboardReadRequest"
},
{
"$ref": "#/$defs/HostClipboardWriteRequest"
}
]
},
"HostInteractionResult": {
"oneOf": [
{
"type": "object",
"required": [
"cancelled"
],
"properties": {
"cancelled": {
"const": true
}
},
"additionalProperties": false
},
{
"type": "object",
"required": [
"cancelled",
"value"
],
"properties": {
"cancelled": {
"const": false
},
"value": {
"oneOf": [
{
"type": "string",
"maxLength": 4096,
"pattern": "^[^\\u0000]*$"
},
{
"const": true
}
]
}
},
"additionalProperties": false
}
]
},
"HostInteractionResponse": {
"type": "object",
"required": [
"requestId",
"actionToken",
"action"
],
"properties": {
"requestId": {
"type": "string",
"minLength": 1,
"maxLength": 128
},
"actionToken": {
"type": "string",
"minLength": 32,
"maxLength": 256
},
"action": {
"enum": [
"cancel",
"preview",
"submit"
]
},
"value": {
"type": "string",
"maxLength": 4096,
"pattern": "^[^\\u0000]*$"
}
},
"additionalProperties": false
},
"HostInteractionClosed": {
"type": "object",
"required": [
"requestId",
"closed"
],
"properties": {
"requestId": {
"type": "string",
"maxLength": 128
},
"closed": {
"const": true
}
},
"additionalProperties": false
},
"HostInteractionEvent": {
"oneOf": [
{
"$ref": "#/$defs/HostInteractionPrompt"
},
{
"$ref": "#/$defs/HostInteractionClosed"
}
]
},
"HostInteractionPrompt": {
"type": "object",
"required": [
"requestId",
"actionToken",
"pluginId",
"pluginVersion",
"expiresAt",
"phase",
"request"
],
"properties": {
"requestId": {
"type": "string",
"maxLength": 128
},
"actionToken": {
"type": "string",
"maxLength": 256
},
"pluginId": {
"$ref": "#/$defs/PluginId"
},
"pluginVersion": {
"$ref": "#/$defs/SemanticVersion"
},
"expiresAt": {
"$ref": "#/$defs/SafePositiveInteger"
},
"phase": {
"enum": [
"request",
"preview"
]
},
"request": {
"$ref": "#/$defs/HostInteractionRequest"
},
"previewText": {
"type": "string",
"maxLength": 4096,
"pattern": "^[^\\u0000]*$"
}
},
"additionalProperties": false
},
"NetworkCredentialLeaseFeature": {
"const": "network.credential-lease"
},
"PluginNetworkAuthorization": {
"type": "object",
"properties": {
"scheme": {
"type": "string",
"enum": [
"Basic",
"Bearer"
]
},
"username": {
"type": "string",
"maxLength": 8192,
"pattern": "^[^\\u0000-\\u001f\\u007f]*$"
}
},
"additionalProperties": false
},
"PluginNetworkRequest": {
"description": "Origin-authorized HTTP request. The host consumes a one-use credentialLease for network:<canonical origin>; plaintext credentials never return to the plugin.",
"type": "object",
"required": [
"url"
],
"properties": {
"url": {
"type": "string",
"minLength": 1,
"maxLength": 8192
},
"method": {
"type": "string",
"enum": [
"GET",
"POST",
"PUT",
"PATCH",
"DELETE",
"HEAD"
]
},
"headers": {
"type": "object",
"maxProperties": 64,
"additionalProperties": {
"type": "string",
"maxLength": 8192
}
},
"body": {
"type": "object",
"required": [
"encoding",
"data"
],
"properties": {
"encoding": {
"enum": [
"utf8",
"base64"
]
},
"data": {
"type": "string"
}
},
"additionalProperties": false
},
"timeoutMs": {
"type": "integer",
"minimum": 1,
"maximum": 300000
},
"credentialLease": {
"$ref": "#/$defs/SecretLeaseRef"
},
"authorization": {
"$ref": "#/$defs/PluginNetworkAuthorization"
}
},
"additionalProperties": false
},
"PluginNetworkResponse": {
"type": "object",
"required": [
"url",
"status",
"headers",
"body"
],
"properties": {
"url": {
"type": "string"
},
"status": {
"type": "integer"
},
"headers": {
"type": "object",
"additionalProperties": {
"type": "string"
}
},
"body": {
"type": "object",
"required": [
"encoding",
"data"
],
"properties": {
"encoding": {
"const": "base64"
},
"data": {
"type": "string"
}
},
"additionalProperties": false
}
},
"additionalProperties": false
},
"SemanticVersion": {
"type": "string",
"pattern": "^(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)(?:-(?:0|[1-9]\\d*|\\d*[A-Za-z-][0-9A-Za-z-]*)(?:\\.(?:0|[1-9]\\d*|\\d*[A-Za-z-][0-9A-Za-z-]*))*)?(?:\\+[0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*)?$"
@@ -273,6 +707,7 @@
"commands",
"menus",
"views",
"ui.interact",
"clipboard.read",
"clipboard.write",
"terminal.metadata",
@@ -310,6 +745,7 @@
"commands",
"menus",
"views",
"ui.interact",
"clipboard.read",
"clipboard.write",
"terminal.metadata",
@@ -746,6 +1182,11 @@
},
"enablement": {
"$ref": "#/$defs/ContextKeyExpression"
},
"interactive": {
"type": "boolean",
"default": false,
"description": "Require commands.interactive for this command's fixed bounded host interaction budget. Does not grant UI or clipboard permissions."
}
},
"additionalProperties": false
@@ -2949,6 +3390,9 @@
"type": "string",
"maxLength": 128
},
"icon": {
"$ref": "#/$defs/IconReference"
},
"homepage": {
"type": "string",
"format": "uri"
@@ -0,0 +1,117 @@
"use strict";
const assert = require("node:assert/strict");
const { readFile } = require("node:fs/promises");
const { EventEmitter } = require("node:events");
const { pathToFileURL } = require("node:url");
const test = require("node:test");
const { MessageChannel } = require("node:worker_threads");
const { PluginRpcRouter } = require("./rpcRouter.cjs");
const { PluginHostRpcRegistry } = require("./hostRpcRegistry.cjs");
const { PluginPermissionEngine } = require("./permissionEngine.cjs");
const { PluginHostInteractionService } = require("./hostInteractionService.cjs");
const { PluginContributionService } = require("./contributionService.cjs");
for (const source of ["compose", "share"]) test(`normal browser example completes the ${source} text workflow through permission middleware`, async () => {
const { startPluginRuntime } = await import("./runtime/runtimePeer.mjs");
const plugin = await import("../../examples/plugins/host-interactions/browser.js");
const manifest = JSON.parse(await readFile(new URL("../../examples/plugins/host-interactions/netcatty.plugin.json", pathToFileURL(__filename)), "utf8"));
const audit = [], copied = [], requests = [];
let reads = 0, active = true;
const database = { listPermissionGrants: () => [], recordSecurityAudit(...args) { audit.push(args); } };
const permissionEngine = new PluginPermissionEngine({ database,
requestDecision: async (request) => { requests.push(request.permission); return { requestId: request.requestId, decision: "allow", scope: "once" }; },
});
let service;
const sender = Object.assign(new EventEmitter(), { id: 7, isDestroyed: () => false,
send(_channel, message) {
if (message.closed) return;
queueMicrotask(() => {
const action = message.request.kind === "clipboard.read" && message.phase === "request" ? "preview" : "submit";
const value = message.request.kind === "quickPick" ? source : message.request.kind === "input" ? "synthetic output" : undefined;
void service.respond(sender, { requestId: message.requestId, actionToken: message.actionToken, action, ...(value === undefined ? {} : { value }) }).catch(() => {});
});
},
});
const window = Object.assign(new EventEmitter(), { webContents: sender, isFocused: () => true, isVisible: () => true, isDestroyed: () => false });
service = new PluginHostInteractionService({ database, permissionEngine, getWindow: () => window, isLocked: () => false,
quotaManager: { chargeBytes() {} },
clipboard: { readText() { reads++; return "synthetic private text"; }, writeText(text) { copied.push(text); } },
});
service.setReady(sender, true);
const registry = new PluginHostRpcRegistry();
registry.use(permissionEngine.createMiddleware()); service.registerRpcCapabilities(registry);
const routes = registry.createRoutes({ pluginId: manifest.id, pluginVersion: manifest.version, runtimeId: "runtime-synthetic", runtimeKind: "browser", manifest,
assertCurrent() { if (!active) throw new Error("stale runtime"); },
});
const { port1, port2 } = new MessageChannel();
const host = new PluginRpcRouter({ pluginId: manifest.id, send: (message) => port1.postMessage(message), handlers: routes.requestHandlers });
port1.on("message", (message) => { void host.accept(message); });
const peer = await startPluginRuntime({ port: port2,
config: { pluginId: manifest.id, pluginVersion: manifest.version, netcattyVersion: "0.0.0", apiVersion: "0.1.0-internal.1", enabledFeatures: ["host.interactions"] },
loadPlugin: async () => plugin,
});
try {
await host.request("plugin.initialize", { netcattyVersion: "0.0.0", apiVersion: "0.1.0-internal.1", supportedFeatures: ["host.interactions"] });
await host.request("plugin.activate", {});
const result = await host.request("plugin.command.execute", { command: "com.netcatty.host-interactions.text", invocation: { source: "palette" } });
const output = source === "compose" ? "synthetic output" : "SYNTHETIC PRIVATE TEXT";
assert.deepEqual(result, { copied: true, characters: output.length });
assert.deepEqual(copied, [output]); assert.equal(reads, source === "share" ? 1 : 0);
assert.ok(requests.includes("ui.interact")); assert.ok(requests.includes("clipboard.write"));
assert.equal(requests.includes("clipboard.read"), source === "share");
assert.equal(JSON.stringify(audit).includes("synthetic private text"), false);
assert.equal(JSON.stringify(audit).includes("synthetic output"), false);
assert.equal(service.pending.size, 0);
} finally { active = false; await peer.dispose(); host.close(); port1.close(); service.shutdown(); permissionEngine.shutdown(); }
});
test("outer command deadline closes its real browser clipboard confirmation and rejects late commit", async () => {
const { startPluginRuntime } = await import("./runtime/runtimePeer.mjs");
const plugin = await import("../../examples/plugins/host-interactions/browser.js");
const manifest = JSON.parse(await readFile(new URL("../../examples/plugins/host-interactions/netcatty.plugin.json", pathToFileURL(__filename)), "utf8"));
const identity = { pluginId: manifest.id, pluginVersion: manifest.version, runtimeId: "runtime-deadline", runtimeKind: "browser" };
const writes = [], messages = [];
const database = { listPermissionGrants: () => [], recordSecurityAudit() {},
listPlugins: () => [{ id: manifest.id, activeVersion: manifest.version, manifest, enabled: true, runtime: { status: "running" } }],
};
const engine = new PluginPermissionEngine({ database, requestDecision: async (request) => ({ requestId: request.requestId, decision: "allow", scope: "once" }) });
let service, confirm;
const copyPrompt = new Promise((resolve) => { confirm = resolve; });
const sender = Object.assign(new EventEmitter(), { id: 8, isDestroyed: () => false, send(_channel, message) {
messages.push(message); if (message.closed) return;
if (message.request.kind === "clipboard.write") { confirm(message); return; }
const value = message.request.kind === "quickPick" ? "compose" : "synthetic pending copy";
queueMicrotask(() => { void service.respond(sender, { requestId: message.requestId, actionToken: message.actionToken, action: "submit", value }).catch(() => {}); });
} });
const owner = Object.assign(new EventEmitter(), { webContents: sender, isFocused: () => true, isVisible: () => true, isDestroyed: () => false });
service = new PluginHostInteractionService({ database, permissionEngine: engine, getWindow: () => owner, isLocked: () => false,
quotaManager: { chargeBytes() {} }, clipboard: { writeText(text) { writes.push(text); } } });
service.setReady(sender, true);
const registry = new PluginHostRpcRegistry(); registry.use(engine.createMiddleware()); service.registerRpcCapabilities(registry);
const routes = registry.createRoutes({ ...identity, manifest, assertCurrent() {} });
const { port1, port2 } = new MessageChannel();
const host = new PluginRpcRouter({ pluginId: manifest.id, send: (message) => port1.postMessage(message), handlers: routes.requestHandlers });
port1.on("message", (message) => { void host.accept(message); });
const features = manifest.features.required;
const peer = await startPluginRuntime({ port: port2, config: { ...identity, netcattyVersion: "0.0.0", apiVersion: "0.1.0-internal.1", enabledFeatures: features }, loadPlugin: async () => plugin });
try {
await host.request("plugin.initialize", { netcattyVersion: "0.0.0", apiVersion: "0.1.0-internal.1", supportedFeatures: features });
await host.request("plugin.activate", {});
const contributions = new PluginContributionService({ database, onCommandFinished: (runtimeId) => service.cancelRuntimeRequests(runtimeId),
runtimeSupervisor: { async start() { return identity; }, request(_pluginId, method, params, options) {
assert.equal(options.timeoutMs, 180_000, "host chooses the fixed declared budget");
return host.request(method, params, { timeoutMs: 500 }); // bounded synthetic timer, no production timeout change
} },
});
const command = contributions.executeCommand("com.netcatty.host-interactions.text");
const prompt = await Promise.race([copyPrompt, command.then(() => { throw new Error("command finished before confirmation"); })]);
const attempts = service.rates.get(identity.runtimeId).count;
await assert.rejects(command, /timed out/);
assert.equal(service.pending.size, 0);
assert.equal(service.rates.get(identity.runtimeId).count, attempts);
assert.ok(messages.some((message) => message.closed && message.requestId === prompt.requestId));
assert.equal(await service.respond(sender, { requestId: prompt.requestId, actionToken: prompt.actionToken, action: "submit" }), false);
assert.deepEqual(writes, []);
assert.equal(contributions.activeCommandCalls.size, 0);
} finally { await peer.dispose(); host.close(); port1.close(); service.shutdown(); engine.shutdown(); }
});
+339
View File
@@ -0,0 +1,339 @@
"use strict";
const { createHmac, randomBytes, randomUUID, timingSafeEqual } = require("node:crypto");
const { createDefinitionValidator } = require("./contractValidator.cjs");
const { PluginRpcError, RPC_ERRORS } = require("./rpcRouter.cjs");
const schema = require("./generated/plugin-contract.schema.json");
const LIMITS = schema.$defs.HostInteractionLimits.const;
const HOST_INTERACTION_FEATURE = schema.$defs.HostInteractionFeature.const;
const validateRequest = createDefinitionValidator("HostInteractionRequest");
const validateResponse = createDefinitionValidator("HostInteractionResponse");
const validateResult = createDefinitionValidator("HostInteractionResult");
const validateSingleLineInput = createDefinitionValidator("HostSingleLineInputText");
const PROMPT_CHANNEL = "netcatty:plugins:host-interaction";
function fail(code, message) { return new PluginRpcError(RPC_ERRORS[code], message); }
function assertTextBytes(text) {
if (typeof text !== "string" || Buffer.byteLength(text, "utf8") > LIMITS.textBytes || text.includes("\0")) {
throw fail("resourceExhausted", "Host interaction text exceeds its limit");
}
return text;
}
function assertInteractionRequest(params) {
if (!validateRequest(params)) throw fail("invalidArgument", "Host interaction request is invalid");
if (Buffer.byteLength(JSON.stringify(params)) > LIMITS.requestBytes) {
throw fail("resourceExhausted", "Host interaction request exceeds its limit");
}
if (params.kind === "input") assertTextBytes(params.initialValue ?? "");
if (params.kind === "clipboard.write") assertTextBytes(params.text);
if (params.kind === "quickPick" && new Set(params.items.map((item) => item.id)).size !== params.items.length) {
throw fail("invalidArgument", "Host interaction choice IDs must be unique");
}
return Object.freeze(structuredClone(params));
}
function interactionAuthorization(params) {
const permission = params.kind === "clipboard.read" ? "clipboard.read"
: params.kind === "clipboard.write" ? "clipboard.write" : "ui.interact";
return {
permission,
resources: [permission.startsWith("clipboard.") ? "clipboard:text" : "*"],
reason: permission.startsWith("clipboard.") ? "Explicitly share or replace clipboard text" : "Show a host input or choice dialog",
operationId: `ui:${params.kind}`,
...(permission.startsWith("clipboard.") ? { allowedScopes: ["once"] } : {}),
};
}
class PluginHostInteractionService {
constructor(options) {
this.database = options.database;
this.permissionEngine = options.permissionEngine;
this.quotaManager = options.quotaManager;
this.clipboard = options.clipboard;
this.getWindow = options.getWindow ?? (() => null);
this.isLocked = options.isLocked ?? (() => true);
this.clock = options.clock ?? Date.now;
this.timeoutMs = Math.min(options.timeoutMs ?? LIMITS.timeoutMs, LIMITS.timeoutMs);
this.key = randomBytes(32);
this.pending = new Map();
this.rates = new Map();
this.admissions = new WeakMap();
this.admissionGuards = new Map();
this.admissionRevision = 0;
this.focusRestoreMs = Math.min(options.focusRestoreMs ?? 1_000, 1_000);
this.owner = null;
this.ownerCleanup = null;
this.closed = false;
this.revocation = this.permissionEngine.onDidRevoke((event) => this.releasePlugin(event.pluginId));
this.unsubscribeLock = options.subscribeLock?.((state) => { if (state.locked) this.cancelAll(); });
}
registerRpcCapabilities(registry) {
registry.registerRequest("ui.interact", (params, context) => this.request(params, context), {
metadata: { capability: "ui", mutating: true },
validateParams: assertInteractionRequest,
authorization: (params, context) => {
const sender = this.#admit(params, context);
let guard = this.admissionGuards.get(context.runtimeId);
if (!guard) { guard = { active: true }; this.admissionGuards.set(context.runtimeId, guard); }
this.admissions.set(params, { sender, runtimeId: context.runtimeId, revision: this.admissionRevision, guard });
return interactionAuthorization(params);
},
});
}
setReady(sender, ready) {
const window = this.getWindow();
if (!sender || window?.webContents !== sender || sender.isDestroyed?.()) {
throw fail("permissionDenied", "Host interaction window is not the owner");
}
if (typeof ready !== "boolean") throw fail("invalidArgument", "Host interaction readiness is invalid");
if (!ready) { this.releaseWindow(sender); return true; }
if (this.closed) throw fail("unavailable", "Host interactions are unavailable");
if (this.owner === sender) return true;
if (this.owner) this.releaseWindow(this.owner);
this.owner = sender;
const close = () => this.releaseWindow(sender);
// A native permission dialog can temporarily blur its parent. Already
// displayed interactions still cancel immediately; pre-prompt admission
// can only resume once this same owner actually regains focus.
const blur = () => this.cancelAll(false);
const hide = () => this.cancelAll();
const navigate = (details, _url, isInPlace, isMainFrame) => {
const main = details?.isMainFrame ?? isMainFrame;
const sameDocument = details?.isSameDocument ?? isInPlace;
if (main !== false && sameDocument !== true) close();
};
sender.once?.("destroyed", close);
sender.on?.("did-start-navigation", navigate);
sender.on?.("render-process-gone", close);
window.on?.("blur", blur);
window.on?.("hide", hide);
this.ownerCleanup = () => {
sender.removeListener?.("destroyed", close);
sender.removeListener?.("did-start-navigation", navigate);
sender.removeListener?.("render-process-gone", close);
window.removeListener?.("blur", blur);
window.removeListener?.("hide", hide);
};
return true;
}
#assertWindow(sender = this.owner) {
const window = this.getWindow();
if (this.closed || this.isLocked() || !sender || sender !== this.owner
|| window?.webContents !== sender || window.isDestroyed?.()
|| sender.isDestroyed?.() || window.isVisible?.() === false || window.isFocused?.() !== true) {
throw fail("unavailable", "Host interactions require the focused unlocked main window");
}
return sender;
}
#token(entry) {
const nonce = randomBytes(24).toString("base64url");
const binding = JSON.stringify([
nonce, entry.id, entry.context.pluginId, entry.context.pluginVersion,
entry.context.runtimeId, entry.sender.id, entry.params.kind, entry.phase, entry.expiresAt,
]);
return `${nonce}.${createHmac("sha256", this.key).update(binding).digest("base64url")}`;
}
#present(entry) {
entry.actionToken = this.#token(entry);
entry.sender.send(PROMPT_CHANNEL, {
requestId: entry.id, actionToken: entry.actionToken,
pluginId: entry.context.pluginId, pluginVersion: entry.context.pluginVersion,
expiresAt: entry.expiresAt, phase: entry.phase, request: entry.params,
...(entry.previewText === undefined ? {} : { previewText: entry.previewText }),
});
}
#finish(entry, result, error) {
if (this.pending.get(entry.id) !== entry) return false;
this.pending.delete(entry.id);
entry.actionToken = null;
entry.previewText = undefined;
clearTimeout(entry.timer);
entry.context.signal?.removeEventListener("abort", entry.onAbort);
try { entry.sender.send(PROMPT_CHANNEL, { requestId: entry.id, closed: true }); } catch {}
if (error) entry.reject(error);
else if (!validateResult(result)) entry.reject(fail("internal", "Host interaction result is invalid"));
else entry.resolve(result);
return true;
}
#audit(entry, action) {
// Fixed metadata only. Never record title, choices, user input, clipboard
// bytes, action token or a hash derived from user content.
try {
this.database.recordSecurityAudit(entry.context.pluginId, "ui.interaction", { kind: entry.params.kind, action });
} catch {
throw fail("unavailable", "Host interaction audit is unavailable");
}
}
#admit(params, context) {
const sender = this.#assertWindow();
if ((params.kind === "clipboard.read" && typeof this.clipboard?.readText !== "function")
|| (params.kind === "clipboard.write" && typeof this.clipboard?.writeText !== "function")) {
throw fail("unavailable", "Host clipboard text is unavailable");
}
const now = this.clock();
const rate = this.rates.get(context.runtimeId);
const nextRate = !rate || now - rate.startedAt >= 60_000 ? { startedAt: now, count: 0 } : rate;
if (nextRate.count >= LIMITS.requestsPerMinute || this.pending.size >= LIMITS.pendingTotal
|| [...this.pending.values()].some((entry) => entry.context.runtimeId === context.runtimeId || entry.sender === sender)) {
throw fail("resourceExhausted", "Host interaction limit reached");
}
nextRate.count++;
this.rates.set(context.runtimeId, nextRate);
this.quotaManager.chargeBytes(context.runtimeId, "ui", Buffer.byteLength(JSON.stringify(params)));
return sender;
}
async request(params, context) {
await context.assertActive();
const admission = this.admissions.get(params);
this.admissions.delete(params);
let sender;
if (admission?.runtimeId === context.runtimeId) {
// Electron may resolve showMessageBox before restoring parent focus.
// Never focus it ourselves or defer an originally background request.
// Owner replacement, lock, hide, revoke and runtime stop invalidate this
// host-owned admission, including changes during the permission prompt.
const deadline = Date.now() + this.focusRestoreMs;
for (;;) {
context.signal?.throwIfAborted();
if (!admission.guard.active || admission.revision !== this.admissionRevision || admission.sender !== this.owner) {
throw fail("cancelled", "Host interaction admission was revoked");
}
const window = this.getWindow();
if (this.closed || this.isLocked() || window?.webContents !== admission.sender
|| window.isDestroyed?.() || admission.sender.isDestroyed?.() || window.isVisible?.() === false) {
throw fail("unavailable", "Host interaction owner is unavailable");
}
if (window.isFocused?.() === true) break;
if (Date.now() >= deadline) throw fail("unavailable", "Host interactions require the focused unlocked main window");
await new Promise((resolve) => setTimeout(resolve, Math.min(20, Math.max(1, deadline - Date.now()))));
}
await context.assertActive();
if (!admission.guard.active || admission.revision !== this.admissionRevision) throw fail("cancelled", "Host interaction admission was revoked");
sender = this.#assertWindow(admission.sender);
} else sender = this.#admit(params, context);
if (this.pending.size >= LIMITS.pendingTotal || [...this.pending.values()].some((entry) => entry.context.runtimeId === context.runtimeId || entry.sender === sender)) {
throw fail("resourceExhausted", "Host interaction limit reached");
}
const now = this.clock();
return new Promise((resolve, reject) => {
const entry = {
id: randomUUID(), context, params, sender, resolve, reject,
expiresAt: now + this.timeoutMs, phase: "request", actionToken: null,
};
entry.onAbort = () => this.#finish(entry, null, fail("cancelled", "Host interaction was cancelled"));
entry.timer = setTimeout(() => this.#finish(entry, null, fail("deadlineExceeded", "Host interaction expired")), this.timeoutMs);
entry.timer.unref?.();
this.pending.set(entry.id, entry);
context.signal?.addEventListener("abort", entry.onAbort, { once: true });
if (context.signal?.aborted) { entry.onAbort(); return; }
try { this.#present(entry); }
catch { this.#finish(entry, null, fail("unavailable", "Host interaction window is unavailable")); }
});
}
async respond(sender, response) {
this.#assertWindow(sender);
if (!validateResponse(response)) throw fail("invalidArgument", "Host interaction response is invalid");
const entry = this.pending.get(response.requestId);
if (!entry || entry.sender !== sender || !entry.actionToken || this.clock() >= entry.expiresAt) return false;
const expected = Buffer.from(entry.actionToken);
const supplied = Buffer.from(response.actionToken);
if (expected.length !== supplied.length || !timingSafeEqual(expected, supplied)) return false;
// Consume before the first asynchronous boundary. Duplicate and old-phase
// replies cannot race the same native action.
entry.actionToken = null;
try {
await entry.context.assertActive();
this.#assertWindow(sender);
if (this.pending.get(entry.id) !== entry) return false;
if (response.action === "cancel") {
this.#audit(entry, "cancelled");
return this.#finish(entry, { cancelled: true });
}
if (entry.params.kind === "clipboard.read" && entry.phase === "request") {
if (response.action !== "preview") throw fail("invalidArgument", "Clipboard sharing requires a preview action");
this.#audit(entry, "preview-confirmed");
let rawText;
try { rawText = this.clipboard.readText(); }
catch { throw fail("unavailable", "Host clipboard text is unavailable"); }
const text = assertTextBytes(rawText);
this.quotaManager.chargeBytes(entry.context.runtimeId, "clipboard", Buffer.byteLength(text));
entry.previewText = text;
entry.phase = "preview";
try { this.#present(entry); }
catch { throw fail("unavailable", "Host interaction window is unavailable"); }
return true;
}
if (response.action !== "submit") throw fail("invalidArgument", "Host interaction action is invalid");
let value;
if (entry.params.kind === "input") {
if (!validateSingleLineInput(response.value)) throw fail("invalidArgument", "Host input must be a single line");
value = assertTextBytes(response.value);
}
else if (entry.params.kind === "quickPick") {
if (!entry.params.items.some((item) => item.id === response.value)) throw fail("invalidArgument", "Host interaction choice is invalid");
value = response.value;
} else if (entry.params.kind === "clipboard.read") value = entry.previewText;
else value = true;
this.#audit(entry, entry.params.kind === "clipboard.write" ? "copy-confirmed" : "share-confirmed");
if (entry.params.kind === "clipboard.write") {
try { this.clipboard.writeText(entry.params.text); }
catch { throw fail("unavailable", "Host clipboard write failed"); }
}
return this.#finish(entry, { cancelled: false, value });
} catch (error) {
this.#finish(entry, null, error);
throw error;
}
}
releaseWindow(sender) {
if (this.owner !== sender) return;
this.admissionRevision++;
this.owner = null;
this.ownerCleanup?.(); this.ownerCleanup = null;
for (const entry of [...this.pending.values()]) {
if (entry.sender === sender) this.#finish(entry, null, fail("cancelled", "Host interaction window closed"));
}
}
releasePlugin(pluginId) {
this.admissionRevision++;
for (const entry of [...this.pending.values()]) {
if (entry.context.pluginId === pluginId) this.#finish(entry, null, fail("cancelled", "Plugin access was revoked"));
}
}
releaseRuntime(runtimeId) {
this.cancelRuntimeRequests(runtimeId, "Plugin runtime stopped");
this.rates.delete(runtimeId);
}
cancelRuntimeRequests(runtimeId, reason = "Host command finished") {
const guard = this.admissionGuards.get(runtimeId);
if (guard) guard.active = false;
this.admissionGuards.delete(runtimeId);
for (const entry of [...this.pending.values()]) {
if (entry.context.runtimeId === runtimeId) this.#finish(entry, null, fail("cancelled", reason));
}
}
cancelAll(invalidateAdmission = true) {
if (invalidateAdmission) this.admissionRevision++;
for (const entry of [...this.pending.values()]) this.#finish(entry, null, fail("cancelled", "Host interaction was cancelled"));
}
shutdown() {
if (this.closed) return;
this.closed = true;
if (this.owner) this.releaseWindow(this.owner);
this.cancelAll(); this.rates.clear(); this.admissionGuards.clear();
this.revocation.dispose(); this.unsubscribeLock?.();
this.key.fill(0);
}
}
module.exports = { HOST_INTERACTION_FEATURE, LIMITS, PROMPT_CHANNEL, PluginHostInteractionService, assertInteractionRequest, interactionAuthorization };
@@ -0,0 +1,301 @@
"use strict";
const assert = require("node:assert/strict");
const { EventEmitter } = require("node:events");
const test = require("node:test");
const { PluginHostInteractionService, assertInteractionRequest, interactionAuthorization, PROMPT_CHANNEL } = require("./hostInteractionService.cjs");
function fixture(t, options = {}) {
const messages = [], audit = [], writes = [], charges = [];
let reads = 0, locked = false, focused = true, active = true;
const sender = Object.assign(new EventEmitter(), { id: 7, isDestroyed: () => false, send: (channel, message) => { assert.equal(channel, PROMPT_CHANNEL); messages.push(message); } });
const window = Object.assign(new EventEmitter(), { webContents: sender, isFocused: () => focused, isVisible: () => true, isDestroyed: () => false });
const signal = new AbortController();
let onRevoke;
const service = new PluginHostInteractionService({
getWindow: () => window, isLocked: () => locked,
permissionEngine: { onDidRevoke(fn) { onRevoke = fn; return { dispose() {} }; } },
database: { recordSecurityAudit(...args) { audit.push(args); } },
quotaManager: { chargeBytes(...args) { charges.push(args); } },
clipboard: { readText() { reads++; return "synthetic clipboard"; }, writeText(text) { writes.push(text); } },
...options,
});
service.setReady(sender, true);
t.after(() => service.shutdown());
const context = {
pluginId: "com.example.synthetic", pluginVersion: "1.0.0", runtimeId: "runtime-synthetic", signal: signal.signal,
async assertActive() { signal.signal.throwIfAborted(); if (!active) throw new Error("stale runtime"); },
};
const begin = async (params) => {
const promise = service.request(assertInteractionRequest(params), context);
void promise.catch(() => {});
await new Promise((resolve) => setImmediate(resolve));
return { promise, prompt: messages.filter((message) => !message.closed).at(-1) };
};
const respond = (prompt, action, value) => service.respond(sender, { requestId: prompt.requestId, actionToken: prompt.actionToken, action, ...(value === undefined ? {} : { value }) });
return { service, sender, window, context, messages, audit, writes, charges, begin, respond, signal,
get reads() { return reads; }, revoke() { onRevoke({ pluginId: context.pluginId }); },
lock() { locked = true; }, blur() { focused = false; window.emit("blur"); }, focus() { focused = true; window.emit("focus"); }, stale() { active = false; },
};
}
test("normal input and quick pick return only an approved bounded value", async (t) => {
const f = fixture(t);
const input = await f.begin({ kind: "input", title: "Synthetic input", initialValue: "draft" });
assert.equal(await f.respond(input.prompt, "submit", "approved"), true);
assert.deepEqual(await input.promise, { cancelled: false, value: "approved" });
const choice = await f.begin({ kind: "quickPick", title: "Pick", items: [{ id: "one", label: "One" }] });
assert.equal(await f.respond(choice.prompt, "submit", "one"), true);
assert.deepEqual(await choice.promise, { cancelled: false, value: "one" });
assert.equal(JSON.stringify(f.audit).includes("approved"), false);
assert.equal(f.reads, 0); assert.deepEqual(f.writes, []);
});
test("ending a host command cancels pending native actions without resetting its quota", async (t) => {
const f = fixture(t);
const request = await f.begin({ kind: "clipboard.write", title: "Synthetic", text: "must not copy" });
const count = f.service.rates.get(f.context.runtimeId).count;
f.service.cancelRuntimeRequests(f.context.runtimeId);
await assert.rejects(request.promise, /command finished/);
assert.equal(await f.respond(request.prompt, "submit"), false);
assert.deepEqual(f.writes, []);
assert.equal(f.service.rates.get(f.context.runtimeId).count, count);
});
test("old command completion cannot invalidate a replacement runtime's host-owned admission", async (t) => {
const f = fixture(t);
let authorize;
f.service.registerRpcCapabilities({ registerRequest(_name, _handler, options) { authorize = options.authorization; } });
const old = assertInteractionRequest({ kind: "input", title: "Old" });
authorize(old, f.context);
f.service.cancelRuntimeRequests(f.context.runtimeId);
await assert.rejects(f.service.request(old, f.context), /revoked/);
const nextContext = { ...f.context, runtimeId: "runtime-replacement" };
const next = assertInteractionRequest({ kind: "input", title: "New" });
authorize(next, nextContext);
f.service.cancelRuntimeRequests(f.context.runtimeId);
const request = f.service.request(next, nextContext);
await new Promise((resolve) => setImmediate(resolve));
const prompt = f.messages.find((message) => !message.closed);
assert.ok(prompt);
await f.respond(prompt, "submit", "synthetic new input");
assert.deepEqual(await request, { cancelled: false, value: "synthetic new input" });
});
test("Once permission dialog may restore the same owner's focus after its decision resolves", async (t) => {
const { PluginHostRpcRegistry } = require("./hostRpcRegistry.cjs");
const { PluginPermissionEngine } = require("./permissionEngine.cjs");
const f = fixture(t);
let decisions = 0;
const engine = new PluginPermissionEngine({
database: { listPermissionGrants: () => [], recordSecurityAudit() {} },
requestDecision: async (request) => {
decisions++;
f.blur();
setTimeout(() => f.focus(), 30);
return { requestId: request.requestId, decision: "allow", scope: "once" };
},
});
t.after(() => engine.shutdown());
const registry = new PluginHostRpcRegistry();
registry.use(engine.createMiddleware());
f.service.registerRpcCapabilities(registry);
const route = registry.createRoutes({ ...f.context, runtimeKind: "browser", assertCurrent() {},
manifest: { id: f.context.pluginId, publisher: "example", permissions: { optional: ["ui.interact"] } },
}).requestHandlers["ui.interact"];
const result = route({ kind: "input", title: "Synthetic input", initialValue: "visible" }, { signal: f.context.signal });
await new Promise((resolve) => setTimeout(resolve, 10));
assert.equal(f.messages.length, 0, "no dialog is delivered while the owner is backgrounded");
await new Promise((resolve) => setTimeout(resolve, 45));
const prompt = f.messages.find((message) => !message.closed);
assert.ok(prompt);
await f.respond(prompt, "submit", "visible");
assert.deepEqual(await result, { cancelled: false, value: "visible" });
assert.equal(decisions, 1);
assert.equal(f.reads, 0); assert.deepEqual(f.writes, []);
});
test("focus restoration is bounded and admission cannot survive owner or access lifecycle changes", async (t) => {
for (const action of ["timeout", "hide", "revoke", "runtime", "window", "lock", "signal", "stale"]) {
const f = fixture(t, { focusRestoreMs: 35 });
let authorize;
f.service.registerRpcCapabilities({ registerRequest(_name, _handler, options) { authorize = options.authorization; } });
const params = assertInteractionRequest({ kind: "clipboard.write", title: "Never copied", text: "synthetic" });
authorize(params, f.context); // admission is host-owned and originally foregrounded
f.blur();
const result = f.service.request(params, f.context);
void result.catch(() => {});
await new Promise((resolve) => setTimeout(resolve, 5));
if (action === "hide") f.window.emit("hide");
if (action === "revoke") f.revoke();
if (action === "runtime") f.service.releaseRuntime(f.context.runtimeId);
if (action === "window") { f.sender.emit("did-start-navigation", { isMainFrame: true, isSameDocument: false }); f.service.setReady(f.sender, true); }
if (action === "lock") f.lock();
if (action === "signal") f.signal.abort();
if (action === "stale") f.stale();
if (action !== "timeout") f.focus();
await assert.rejects(result, /focused unlocked|revoked|unavailable|abort|stale/i, action);
assert.equal(f.messages.length, 0, action);
assert.deepEqual(f.writes, []); assert.equal(f.reads, 0);
}
const background = fixture(t);
background.blur();
await assert.rejects(background.service.request(assertInteractionRequest({ kind: "input", title: "Background" }), background.context), /focused unlocked/);
});
test("clipboard read waits for preview action and a separate single-use sharing confirmation", async (t) => {
const f = fixture(t);
const request = await f.begin({ kind: "clipboard.read", title: "Share text" });
let done = false; void request.promise.then(() => { done = true; });
assert.equal(f.reads, 0);
await f.respond(request.prompt, "preview");
const preview = f.messages.filter((message) => !message.closed).at(-1);
assert.equal(preview.phase, "preview"); assert.equal(preview.previewText, "synthetic clipboard");
assert.notEqual(preview.actionToken, request.prompt.actionToken);
assert.equal(f.reads, 1); assert.equal(done, false);
assert.equal(await f.respond(request.prompt, "submit"), false, "old-phase action cannot share text");
assert.equal(await f.respond(preview, "submit"), true);
assert.deepEqual(await request.promise, { cancelled: false, value: "synthetic clipboard" });
assert.equal(await f.respond(preview, "submit"), false, "no replay");
assert.equal(f.reads, 1); assert.equal(JSON.stringify(f.audit).includes("synthetic clipboard"), false);
});
test("clipboard copy is explicit and duplicate replies write once", async (t) => {
const f = fixture(t);
const request = await f.begin({ kind: "clipboard.write", title: "Copy", text: "synthetic output" });
assert.deepEqual(f.writes, []);
const first = f.respond(request.prompt, "submit");
assert.equal(await f.respond(request.prompt, "submit"), false);
assert.equal(await first, true);
assert.deepEqual(await request.promise, { cancelled: false, value: true });
assert.deepEqual(f.writes, ["synthetic output"]);
});
test("cancel, blur, revoke, runtime release, and incoming cancellation revoke pending action tokens", async (t) => {
for (const action of ["cancel", "blur", "revoke", "runtime", "signal", "window"]) {
const f = fixture(t);
const request = await f.begin({ kind: "clipboard.write", title: "Copy", text: "never written" });
if (action === "cancel") { await f.respond(request.prompt, "cancel"); assert.deepEqual(await request.promise, { cancelled: true }); }
else {
if (action === "blur") f.blur();
if (action === "revoke") f.revoke();
if (action === "runtime") f.service.releaseRuntime(f.context.runtimeId);
if (action === "signal") f.signal.abort();
if (action === "window") f.sender.emit("destroyed");
await assert.rejects(request.promise, /cancelled|revoked|stopped|closed/);
}
assert.deepEqual(f.writes, []);
assert.equal(f.service.pending.size, 0);
}
});
test("wrong windows, forged tokens and stale runtimes cannot commit a native action", async (t) => {
const f = fixture(t);
const request = await f.begin({ kind: "clipboard.write", title: "Copy", text: "never written" });
await assert.rejects(f.service.respond({ id: 9 }, { requestId: request.prompt.requestId, actionToken: request.prompt.actionToken, action: "submit" }), /focused unlocked/);
assert.equal(await f.respond({ ...request.prompt, actionToken: "x".repeat(64) }, "submit"), false);
f.stale();
await assert.rejects(f.respond(request.prompt, "submit"), /stale runtime/);
await assert.rejects(request.promise, /stale runtime/);
assert.deepEqual(f.writes, []);
});
test("quota, deadline, locked host, unknown fields, duplicate choices and text bounds fail closed", async (t) => {
const f = fixture(t, { timeoutMs: 20 });
const request = await f.begin({ kind: "input", title: "Input" });
await assert.rejects(f.service.request(assertInteractionRequest({ kind: "input", title: "Another" }), f.context), /limit/);
const keepAlive = setTimeout(() => {}, 100);
await assert.rejects(request.promise, /expired/); clearTimeout(keepAlive);
f.lock();
await assert.rejects(f.service.request(assertInteractionRequest({ kind: "input", title: "Locked" }), f.context), /focused unlocked/);
assert.throws(() => assertInteractionRequest({ kind: "clipboard.read", title: "Forged", userGesture: true }), /invalid/);
assert.throws(() => assertInteractionRequest({ kind: "quickPick", title: "Duplicate", items: [{ id: "same", label: "A" }, { id: "same", label: "B" }] }), /unique/);
assert.throws(() => assertInteractionRequest({ kind: "clipboard.write", title: "Too long", text: "文".repeat(1500) }), /limit/);
assert.equal(interactionAuthorization({ kind: "clipboard.read" }).permission, "clipboard.read");
assert.equal(interactionAuthorization({ kind: "clipboard.write" }).permission, "clipboard.write");
assert.equal(interactionAuthorization({ kind: "input" }).permission, "ui.interact");
});
test("manifest permission checks and interaction quota run before any host prompt", async (t) => {
const { PluginHostRpcRegistry } = require("./hostRpcRegistry.cjs");
const { PluginPermissionEngine } = require("./permissionEngine.cjs");
const f = fixture(t);
let decisions = 0;
const engine = new PluginPermissionEngine({
database: { listPermissionGrants: () => [], recordSecurityAudit() {} },
requestDecision: async (request) => { decisions++; return { requestId: request.requestId, decision: "deny" }; },
});
t.after(() => engine.shutdown());
const registry = new PluginHostRpcRegistry();
registry.use(engine.createMiddleware());
f.service.registerRpcCapabilities(registry);
const base = { ...f.context, runtimeKind: "browser", manifest: { id: f.context.pluginId, publisher: "example", permissions: {} }, assertCurrent() {} };
const denied = registry.createRoutes(base).requestHandlers["ui.interact"];
await assert.rejects(denied({ kind: "input", title: "Undeclared" }, { signal: f.context.signal }), /declare/);
assert.equal(decisions, 0); assert.equal(f.messages.length, 0);
const route = registry.createRoutes({ ...base, manifest: { ...base.manifest, permissions: { optional: ["ui.interact"] } } }).requestHandlers["ui.interact"];
for (let count = 1; count < 10; count++) {
await assert.rejects(route({ kind: "input", title: "Denied" }, { signal: f.context.signal }), /denied/);
}
const before = decisions;
await assert.rejects(route({ kind: "input", title: "Over quota" }, { signal: f.context.signal }), /limit/);
assert.equal(decisions, before, "over-quota work cannot open another permission prompt");
assert.equal(f.messages.length, 0);
});
test("revoke during an asynchronous active guard and audit failure cannot write clipboard", async (t) => {
const f = fixture(t);
const request = await f.begin({ kind: "clipboard.write", title: "Copy", text: "never copied" });
let resume;
f.context.assertActive = () => new Promise((resolve) => { resume = resolve; });
const reply = f.respond(request.prompt, "submit");
f.revoke();
resume();
assert.equal(await reply, false);
await assert.rejects(request.promise, /revoked/);
assert.deepEqual(f.writes, []);
const g = fixture(t, { database: { recordSecurityAudit() { throw new Error("synthetic audit failure"); } } });
const failed = await g.begin({ kind: "clipboard.write", title: "Copy", text: "never copied" });
await assert.rejects(g.respond(failed.prompt, "submit"), /audit is unavailable/);
await assert.rejects(failed.promise, /audit is unavailable/);
assert.deepEqual(g.writes, []);
});
test("same-document navigation keeps ownership, while document reload and renderer exit cancel it", async (t) => {
for (const event of ["reload", "renderer"]) {
const f = fixture(t);
const request = await f.begin({ kind: "clipboard.read", title: "Share" });
f.sender.emit("did-start-navigation", { isMainFrame: false, isSameDocument: false });
f.sender.emit("did-start-navigation", { isMainFrame: true, isSameDocument: true });
assert.equal(f.service.pending.size, 1);
if (event === "reload") f.sender.emit("did-start-navigation", { isMainFrame: true, isSameDocument: false });
else f.sender.emit("render-process-gone");
await assert.rejects(request.promise, /window closed/);
assert.equal(f.reads, 0); assert.equal(f.service.owner, null);
}
});
test("host lock subscription cancels previewed text before it can be shared", async (t) => {
let changed;
const f = fixture(t, { subscribeLock(listener) { changed = listener; return () => {}; } });
const request = await f.begin({ kind: "clipboard.read", title: "Share" });
await f.respond(request.prompt, "preview");
const preview = f.messages.filter((message) => !message.closed).at(-1);
changed({ locked: true });
await assert.rejects(request.promise, /cancelled/);
assert.equal(await f.respond(preview, "submit"), false);
assert.equal(f.reads, 1); assert.equal(f.service.pending.size, 0);
});
test("single-line inputs reject CR/LF before display and submission while clipboard text stays multiline", async (t) => {
const f = fixture(t);
for (const text of ["first\nsecond", "first\rsecond", "first\r\nsecond"]) {
assert.throws(() => assertInteractionRequest({ kind: "input", title: "Single line", initialValue: text }), /invalid/);
const request = await f.begin({ kind: "input", title: "Single line", initialValue: "first second" });
await assert.rejects(f.respond(request.prompt, "submit", text), /single line/);
await assert.rejects(request.promise, /single line/);
}
const copy = await f.begin({ kind: "clipboard.write", title: "Multiline clipboard", text: "first\nsecond" });
await f.respond(copy.prompt, "submit");
assert.deepEqual(await copy.promise, { cancelled: false, value: true });
assert.deepEqual(f.writes, ["first\nsecond"]);
});
+22 -4
View File
@@ -1,6 +1,8 @@
"use strict";
const path = require("node:path");
const NETWORK_CREDENTIAL_LEASE_FEATURE = require("./generated/plugin-contract.schema.json").$defs.NetworkCredentialLeaseFeature.const;
const INTERACTIVE_COMMAND_FEATURE = require("./generated/plugin-contract.schema.json").$defs.InteractiveCommandFeature.const;
const { assertBrowserOnlyPlugin } = require("../../domain/pluginRuntimePolicy.cjs");
const { isPluginDevelopmentEnabled, PLUGIN_API_VERSION } = require("./constants.cjs");
@@ -17,11 +19,12 @@ const {
createUtilityModuleMappings,
normalizePluginModuleResources,
} = require("./moduleResources.cjs");
const { PluginCatalogService } = require("./catalogService.cjs");
const { RegistryCatalogService } = require("./registryCatalogService.cjs");
const { PackageStore } = require("./packageStore.cjs");
const { createPluginPaths } = require("./paths.cjs");
const { PluginManager } = require("./pluginManager.cjs");
const { PluginNetworkBroker } = require("./networkBroker.cjs");
const { HOST_INTERACTION_FEATURE, PluginHostInteractionService } = require("./hostInteractionService.cjs");
const { PluginPermissionEngine } = require("./permissionEngine.cjs");
const { PluginProtocol } = require("./pluginProtocol.cjs");
const { PluginViewHost } = require("./pluginViewHost.cjs");
@@ -85,6 +88,7 @@ function createPluginHostService(options) {
const appRoot = options.appRoot ?? options.app.getAppPath();
const runtimeDirectory = options.runtimeDirectory ?? path.join(__dirname, "runtime");
const database = new PluginDatabase(paths.database);
let interactionService;
try {
const packageStore = new PackageStore({
allowAdvancedPlugins,
@@ -92,7 +96,7 @@ function createPluginHostService(options) {
database,
netcattyVersion: options.app.getVersion(),
apiVersion: PLUGIN_API_VERSION,
supportedFeatures: options.supportedFeatures ?? [],
supportedFeatures: [...new Set(["metadata.brand-icon", NETWORK_CREDENTIAL_LEASE_FEATURE, HOST_INTERACTION_FEATURE, INTERACTIVE_COMMAND_FEATURE, ...(options.supportedFeatures ?? [])])],
});
const contributionIconService = new PluginContributionIconService({
database,
@@ -164,6 +168,7 @@ function createPluginHostService(options) {
runtimeSupervisor: runtimeAccess,
secretStore,
getLocale: options.getLocale,
onCommandFinished: (runtimeId) => interactionService?.cancelRuntimeRequests(runtimeId),
});
contributionService.registerRpcCapabilities(rpcRegistry);
registerSecurePluginCapabilities(rpcRegistry, {
@@ -176,6 +181,14 @@ function createPluginHostService(options) {
quotaManager,
secretStore,
});
interactionService = new PluginHostInteractionService({
database, permissionEngine, quotaManager,
clipboard: options.electron.clipboard,
getWindow: options.getInteractionWindow,
isLocked: options.isAppLocked,
subscribeLock: options.subscribeAppLock,
});
interactionService.registerRpcCapabilities(rpcRegistry);
const configuredRegistry = options.configureRpcRegistry?.(rpcRegistry);
if (configuredRegistry && typeof configuredRegistry.then === "function") {
throw new TypeError("Plugin host RPC registry configuration must be synchronous");
@@ -223,7 +236,7 @@ function createPluginHostService(options) {
paths,
netcattyVersion: options.app.getVersion(),
apiVersion: PLUGIN_API_VERSION,
supportedFeatures: options.supportedFeatures ?? [],
supportedFeatures: [...new Set(["metadata.brand-icon", NETWORK_CREDENTIAL_LEASE_FEATURE, HOST_INTERACTION_FEATURE, INTERACTIVE_COMMAND_FEATURE, ...(options.supportedFeatures ?? [])])],
runtimeDirectory,
appRoot,
rpcRegistry,
@@ -233,6 +246,7 @@ function createPluginHostService(options) {
getInitialEnvironment: () => contributionService.getEnvironment(),
runtimeResourceMonitor: quotaManager,
runtimeCleanup: async (identity) => {
interactionService?.releaseRuntime(identity.runtimeId);
leaseStore.revokeRuntime(identity.runtimeId);
await companionSupervisor.releaseRuntime(identity.runtimeId);
},
@@ -353,11 +367,13 @@ function createPluginHostService(options) {
})
: null;
const manager = new PluginManager({
permissionEngine,
database,
packageStore,
runtimeSupervisor,
contributionService,
beforeClose: async () => {
interactionService.shutdown();
terminalDataPipelineService?.shutdown();
extensionProviderService.shutdown();
credentialResolver.shutdown?.();
@@ -369,7 +385,8 @@ function createPluginHostService(options) {
},
});
return {
catalogService: new PluginCatalogService({ manager, fetch: options.electron.net?.fetch?.bind(options.electron.net) }),
interactionService,
catalogService: new RegistryCatalogService({ manager, fetch: options.electron.net?.fetch?.bind(options.electron.net) }),
companionSupervisor,
contributionIconService,
contributionService,
@@ -396,6 +413,7 @@ function createPluginHostService(options) {
viewHost,
};
} catch (error) {
try { interactionService?.shutdown(); } catch {}
database.close();
throw error;
}
@@ -0,0 +1,47 @@
"use strict";
const assert = require("node:assert/strict");
const test = require("node:test");
const { MessageChannel } = require("node:worker_threads");
const { PluginRpcRouter } = require("./rpcRouter.cjs");
test("browser lease example negotiates its feature and forwards only an opaque lease", async () => {
const { startPluginRuntime } = await import("./runtime/runtimePeer.mjs");
const fixture = await import("../../examples/plugins/lease-http/browser.js");
const calls = [];
const secret = { kind: "secret", id: "s".repeat(32), key: "synthetic-token" };
const lease = { kind: "secret-lease", id: "l".repeat(32), operationId: "network:https://example.invalid", expiresAt: Date.now() + 30_000 };
const { port1, port2 } = new MessageChannel();
const host = new PluginRpcRouter({
pluginId: "com.netcatty.lease-http",
send: (message) => port1.postMessage(message),
handlers: {
"secrets.set": async (params) => { calls.push("secret"); assert.equal(params.key, secret.key); return { secret }; },
"credentials.createLease": async (params) => {
calls.push("lease"); assert.deepEqual(params.secret, secret); assert.equal(params.operationId, lease.operationId); return lease;
},
"network.request": async (params) => {
calls.push("network");
assert.deepEqual(params, { url: "https://example.invalid/synthetic", credentialLease: lease, authorization: { scheme: "Bearer" } });
assert.equal(JSON.stringify(params).includes("synthetic-acceptance-token"), false);
return { status: 200, url: params.url, headers: {}, body: { encoding: "base64", data: "" } };
},
},
});
port1.on("message", (message) => { void host.accept(message); });
const peer = await startPluginRuntime({
port: port2,
config: { pluginId: "com.netcatty.lease-http", pluginVersion: "0.1.0", netcattyVersion: "0.0.0", apiVersion: "0.1.0-internal.1", enabledFeatures: ["network.credential-lease"] },
loadPlugin: async () => fixture,
});
try {
const initialized = await host.request("plugin.initialize", { netcattyVersion: "0.0.0", apiVersion: "0.1.0-internal.1", supportedFeatures: ["network.credential-lease"] });
assert.deepEqual(initialized.enabledFeatures, ["network.credential-lease"]);
await host.request("plugin.activate", {});
assert.deepEqual(await host.request("plugin.command.execute", { command: "com.netcatty.lease-http.request", invocation: { source: "palette" } }), { status: 200 });
assert.deepEqual(calls, ["secret", "lease", "network"]);
} finally {
await peer.dispose();
host.close();
port1.close();
}
});
@@ -45,7 +45,6 @@ function createNativePermissionDecisionProvider(options) {
throw new TypeError("Native plugin permission decisions require an Electron dialog");
}
const dialog = options.dialog;
const window = options.window;
return async (request, context = {}) => {
context.signal?.throwIfAborted();
if (!describePluginPermission(request.permission).known) return Object.freeze({ requestId: request.requestId, decision: "deny" });
@@ -77,6 +76,9 @@ function createNativePermissionDecisionProvider(options) {
noLink: true,
...(context.signal ? { signal: context.signal } : {}),
};
// Choose a host-owned parent when the prompt opens, not once at bootstrap.
const candidate = options.getWindow?.() ?? options.window;
const window = candidate?.isDestroyed?.() ? undefined : candidate;
const result = window
? await dialog.showMessageBox(window, messageBoxOptions)
: await dialog.showMessageBox(messageBoxOptions);
@@ -140,3 +140,27 @@ test("native prompts share human-readable permission explanations and deny unkno
assert.deepEqual(await provider(permissionRequest({ permission: "unknown.permission" })), { requestId: "request-1", decision: "deny" });
assert.equal(prompts, 0);
});
test("permission parent is resolved at prompt time without forcing focus", async () => {
const main = { name: "main" };
const settings = { name: "settings", isDestroyed: () => false };
let active = main;
const parents = [];
const provider = createNativePermissionDecisionProvider({
window: main,
getWindow: () => active,
dialog: { async showMessageBox(...args) {
parents.push(args.length === 2 ? args[0] : null);
return { response: 0 };
} },
});
await provider(permissionRequest());
active = settings;
await provider(permissionRequest());
active = { isDestroyed: () => true };
await provider(permissionRequest());
active = null;
await provider(permissionRequest());
assert.deepEqual(parents, [main, settings, null, main]);
});
+6 -10
View File
@@ -1,5 +1,9 @@
"use strict";
const { createDefinitionValidator } = require("./contractValidator.cjs");
const validateLease = createDefinitionValidator("SecretLeaseRef");
const validateAuthorization = createDefinitionValidator("PluginNetworkAuthorization");
const { PluginRpcError, RPC_ERRORS } = require("./rpcRouter.cjs");
const { PLUGIN_RPC_MAX_RAW_BYTES } = require("./constants.cjs");
const { canonicalizeNetworkOrigin } = require("./permissionResources.cjs");
@@ -180,15 +184,7 @@ class PluginNetworkBroker {
// network.request lease injection would become dead code.
if (params?.credentialLease !== undefined) {
const lease = params.credentialLease;
if (
!lease
|| typeof lease !== "object"
|| Array.isArray(lease)
|| lease.kind !== "secret-lease"
|| typeof lease.id !== "string"
|| typeof lease.operationId !== "string"
|| !Number.isFinite(lease.expiresAt)
) {
if (!validateLease(lease)) {
throw invalidArgument("Plugin network credential lease is invalid");
}
validated.credentialLease = Object.freeze({
@@ -200,7 +196,7 @@ class PluginNetworkBroker {
}
if (params?.authorization !== undefined) {
const authorization = params.authorization;
if (!authorization || typeof authorization !== "object" || Array.isArray(authorization)) {
if (!validateAuthorization(authorization)) {
throw invalidArgument("Plugin network authorization is invalid");
}
const scheme = authorization.scheme === "Basic" ? "Basic" : "Bearer";
+11
View File
@@ -172,3 +172,14 @@ test("network broker reports its own timeout as a stable deadline error", async
(error) => error.code === RPC_ERRORS.deadlineExceeded,
);
});
test("credential field validation follows the canonical contract and keeps Bearer default", () => {
const broker = new PluginNetworkBroker({ permissionEngine: {} });
const lease = { kind: "secret-lease", id: "l".repeat(32), operationId: "network:https://example.invalid", expiresAt: 1000 };
const request = { url: "https://example.invalid", credentialLease: lease, authorization: {} };
assert.deepEqual(broker.validate(request).authorization, { scheme: "Bearer" });
for (const authorization of [{ scheme: "Digest" }, { scheme: "Basic", username: "name\n" }, { password: "synthetic" }]) {
assert.throws(() => broker.validate({ ...request, authorization }), /authorization is invalid/);
}
assert.throws(() => broker.validate({ ...request, credentialLease: { ...lease, id: "short" } }), /lease is invalid/);
});
+3 -1
View File
@@ -12,7 +12,9 @@ test("packaged application declares every plugin host runtime dependency and res
"@netcatty/plugin-contract",
"@netcatty/plugin-sdk",
]) {
assert.equal(packageJson.dependencies[dependency], "0.1.0-internal");
const workspace = JSON.parse(fs.readFileSync(path.join(__dirname, "../../packages", dependency.split("/")[1], "package.json"), "utf8"));
assert.equal(packageJson.dependencies[dependency], workspace.version);
assert.equal(workspace.version, require("./constants.cjs").PLUGIN_API_VERSION);
}
const builderSource = fs.readFileSync(path.join(__dirname, "../../electron-builder.config.cjs"), "utf8");
assert.match(builderSource, /node_modules\/@netcatty\/plugin-cli\/\*\*\/\*/);
+16
View File
@@ -660,6 +660,22 @@ class PluginPermissionEngine {
});
}
describeGrants(pluginId, context) {
const groups = this.listGrants(pluginId);
const declarations = context ? normalizePermissionDeclarations(context.manifest) : new Map();
return Object.fromEntries(Object.entries(groups).map(([scope, grants]) => [scope, grants.map((grant) => {
const declaration = declarations.get(grant.permission);
const matchesCurrentActivation = Boolean(
context?.pluginId === pluginId
&& context.securityPrincipal
&& declaration
&& declarationAllowsResource(declaration, grant.resource)
&& grant.declarationHash === permissionDeclarationHash(declaration, context.securityPrincipal),
);
return Object.freeze({ ...grant, matchesCurrentActivation });
})]));
}
revokeAlways(pluginId, permission, resource) {
assertPluginPermission(permission);
const canonicalResource = canonicalizePermissionResource(permission, resource);
@@ -631,3 +631,25 @@ test("permission middleware rejects unclassified methods and permits explicit pu
}, async () => "ok"), "ok");
database.close();
});
test("grant descriptions distinguish current activation from old package and changed declarations", async (context) => {
const database = createDatabase(context);
const engine = new PluginPermissionEngine({ database, requestDecision: async (request) => ({
requestId: request.requestId, decision: "allow", scope: "always",
}) });
const pluginManifest = manifest({ optional: [{ permission: "network", resources: ["https://example.invalid"] }] });
const first = runtimeContext(pluginManifest, { securityPrincipal: defaultSecurityPrincipal(pluginManifest, "a".repeat(64)) });
const descriptor = { permission: "network", resources: ["https://example.invalid"], reason: "Synthetic request" };
await engine.authorize(first, descriptor);
assert.equal(engine.describeGrants(first.pluginId, first).always[0].matchesCurrentActivation, true);
assert.equal(engine.describeGrants(first.pluginId, null).always[0].matchesCurrentActivation, false);
const upgraded = { ...first, securityPrincipal: defaultSecurityPrincipal(pluginManifest, "b".repeat(64)) };
assert.equal(engine.describeGrants(first.pluginId, upgraded).always[0].matchesCurrentActivation, false);
await assert.rejects(engine.authorize(upgraded, { ...descriptor, interactive: false }), /permission/i);
const changed = { ...first, manifest: { ...pluginManifest, permissions: { optional: [{ permission: "network", resources: ["https://example.invalid", "https://other.invalid"] }] } } };
assert.equal(engine.describeGrants(first.pluginId, changed).always[0].matchesCurrentActivation, false);
await assert.rejects(engine.authorize(changed, { ...descriptor, interactive: false }), /permission/i);
assert.equal(engine.describeGrants(first.pluginId, { ...first, manifest: { ...pluginManifest, permissions: {} } }).always[0].matchesCurrentActivation, false);
engine.shutdown();
database.close();
});
+62 -3
View File
@@ -3,6 +3,9 @@
const fs = require("node:fs/promises");
const { randomUUID } = require("node:crypto");
const { createDefinitionValidator } = require("./contractValidator.cjs");
const validateManagedPluginId = createDefinitionValidator("PluginId");
const { raceWithAbort } = require("./rpcRouter.cjs");
const {
INLINE_SYNC_OBJECT_SAFE_BYTES,
@@ -13,7 +16,11 @@ const { assertSecretKey } = require("./secretStore.cjs");
const CHANNELS = Object.freeze({
status: "netcatty:plugins:status",
interactionReady: "netcatty:plugins:interaction-ready",
interactionResponse: "netcatty:plugins:interaction-response",
list: "netcatty:plugins:list",
permissions: "netcatty:plugins:permissions",
revokePermissions: "netcatty:plugins:revoke-permissions",
catalog: "netcatty:plugins:catalog",
catalogReadme: "netcatty:plugins:catalog-readme",
catalogInstall: "netcatty:plugins:catalog-install",
@@ -22,6 +29,7 @@ const CHANNELS = Object.freeze({
restart: "netcatty:plugins:restart",
uninstall: "netcatty:plugins:uninstall",
contributions: "netcatty:plugins:contributions",
brandIcon: "netcatty:plugins:brand-icon",
contributionIcon: "netcatty:plugins:contribution-icon",
contributionsChanged: "netcatty:plugins:contributions-changed",
executeCommand: "netcatty:plugins:execute-command",
@@ -646,16 +654,63 @@ function registerPluginBridge(ipcMain, options) {
? { credentialStorageAvailable: credentialResolver.isAvailable() } : {}),
};
});
handle(CHANNELS.catalog, async () => options.catalogService?.list() ?? []);
handle(CHANNELS.catalog, async () => options.catalogService?.list() ?? { entries: [], source: "builtin", error: "Plugin catalog unavailable" });
handle(CHANNELS.catalogReadme, async (_manager, payload) => {
if (!options.catalogService) throw new Error("Plugin catalog unavailable");
return options.catalogService.readme(payload?.id);
return options.catalogService.readme(payload?.id, payload?.selectionToken);
});
handle(CHANNELS.catalogInstall, async (_manager, payload) => {
if (!options.catalogService) throw new Error("Plugin catalog unavailable");
return options.catalogService.install(payload?.id);
return options.catalogService.install(payload?.id, payload?.selectionToken);
});
handle(CHANNELS.interactionReady, async (_manager, payload, event) => {
if (!options.interactionService) throw new Error("Host interactions are unavailable");
return options.interactionService.setReady(event.sender, payload?.ready);
});
handle(CHANNELS.interactionResponse, async (_manager, payload, event) => {
if (!options.interactionService) throw new Error("Host interactions are unavailable");
return options.interactionService.respond(event.sender, payload);
});
handle(CHANNELS.list, async (activeManager) => activeManager.list());
const assertManagedPluginId = (payload) => {
const id = payload?.pluginId;
if (!validateManagedPluginId(id)) {
throw new TypeError("Plugin ID is invalid");
}
return id;
};
handle(CHANNELS.permissions, async (_manager, payload) => {
const pluginId = assertManagedPluginId(payload);
if (!options.permissionEngine || !options.database) throw new Error("Plugin permissions are unavailable");
const plugin = options.database.getActivePlugin(pluginId);
const identity = options.runtimeSupervisor?.getRuntimeIdentity(pluginId);
// A stored grant is effective only for its matching host-owned activation.
// Disabled/lazy/stopped plugins retain saved grants without claiming they
// currently apply. Never infer a principal from plugin-provided metadata.
const context = plugin?.enabled && identity?.pluginVersion === plugin.activeVersion
? { ...identity, manifest: plugin.manifest }
: null;
const groups = options.permissionEngine.describeGrants(pluginId, context);
// Host-only summaries: never return arbitrary audit details, reasons,
// operation IDs, credentials, terminal content or future database columns.
const grants = ["always", "application", "session"].flatMap((scope) => (
(groups[scope] ?? []).map((grant) => ({
permission: grant.permission, resource: grant.resource,
resourceKind: grant.resourceKind, scope,
sessionId: grant.sessionId ?? null, grantedAt: grant.grantedAt,
matchesCurrentActivation: grant.matchesCurrentActivation,
}))
));
const audit = options.database.listSecurityAudit(pluginId, 100).map((entry) => ({
event: entry.event, createdAt: entry.createdAt,
...(typeof entry.details?.permission === "string" ? { permission: entry.details.permission } : {}),
...(typeof entry.details?.scope === "string" ? { scope: entry.details.scope } : {}),
}));
return { grants, audit };
});
handle(CHANNELS.revokePermissions, async (activeManager, payload) => (
activeManager.revokePermissions(assertManagedPluginId(payload))
));
handle(CHANNELS.install, async (activeManager, payload) => activeManager.install(
payload?.archivePath,
{ enable: typeof payload?.enable === "boolean" ? payload.enable : undefined },
@@ -670,6 +725,10 @@ function registerPluginBridge(ipcMain, options) {
if (!contributionService) throw new Error("Plugin contributions are unavailable");
return contributionService.snapshot(payload ?? {});
});
handle(CHANNELS.brandIcon, async (_activeManager, payload) => {
if (!options.resolveBrandIcon) throw new Error("Plugin brand icons are unavailable");
return options.resolveBrandIcon(payload?.pluginId);
});
handle(CHANNELS.contributionIcon, async (_activeManager, payload) => {
if (!options.resolveContributionIcon) throw new Error("Plugin contribution icons are unavailable");
return options.resolveContributionIcon(payload);
+92
View File
@@ -1622,3 +1622,95 @@ test("missing OS vault does not block curated browsing and status exposes the li
await ipcMain.handlers.get(CHANNELS.catalogInstall)(event, { id: "com.example.synthetic", url: "https://attacker.invalid", enable: true });
assert.equal(selected, "com.example.synthetic");
});
test('brand metadata bridge accepts only trusted UI and forwards only the plugin ID', async () => {
const ipcMain = createIpcMain(); const seen = [];
registerPluginBridge(ipcMain, {
manager: { initialize: async () => {} },
isTrustedSender: (event) => event.trusted === true,
resolveBrandIcon: async (...args) => { seen.push(args); return { light: 'data:image/png;base64,fixture' }; },
});
await assert.rejects(ipcMain.handlers.get(CHANNELS.brandIcon)({ trusted: false }, { pluginId: 'com.test.brand' }), /Untrusted/);
assert.equal(seen.length, 0);
await ipcMain.handlers.get(CHANNELS.brandIcon)({ trusted: true }, { pluginId: 'com.test.brand', path: '/secret', url: 'https://attacker.invalid/' });
assert.deepEqual(seen, [['com.test.brand']]);
});
test("permission management is trusted-host-only and projects bounded audit summaries", async () => {
const ipcMain = createIpcMain();
let reads = 0;
const revoked = [];
registerPluginBridge(ipcMain, {
manager: { async initialize() {}, async revokePermissions(id) { revoked.push(id); return { enabled: false }; } },
isTrustedSender: (event) => event?.trusted === true,
permissionEngine: { describeGrants(id, context) {
assert.equal(context, null);
reads++;
assert.equal(id, "com.example.synthetic");
return { always: [{ permission: "network", resource: "https://example.invalid", resourceKind: "exact", grantedAt: 12, matchesCurrentActivation: false, declarationHash: "private" }], application: [], session: [] };
} },
database: { getActivePlugin() { return null; }, listSecurityAudit(id, limit) {
assert.equal(id, "com.example.synthetic");
assert.equal(limit, 100);
return [{ event: "permission.used", createdAt: 13, details: { permission: "network", scope: "always", operationId: "hidden", secret: "synthetic-private" } }];
} },
});
const payload = { pluginId: "com.example.synthetic" };
for (const channel of [CHANNELS.permissions, CHANNELS.revokePermissions]) {
await assert.rejects(ipcMain.handlers.get(channel)({}, payload), /Untrusted/);
await assert.rejects(ipcMain.handlers.get(channel)({ trusted: true }, { pluginId: "../invalid" }), /Plugin ID/);
}
assert.equal(reads, 0);
assert.deepEqual(await ipcMain.handlers.get(CHANNELS.permissions)({ trusted: true }, payload), {
grants: [{ permission: "network", resource: "https://example.invalid", resourceKind: "exact", scope: "always", sessionId: null, grantedAt: 12, matchesCurrentActivation: false }],
audit: [{ event: "permission.used", createdAt: 13, permission: "network", scope: "always" }],
});
assert.deepEqual(await ipcMain.handlers.get(CHANNELS.revokePermissions)({ trusted: true }, payload), { enabled: false });
assert.deepEqual(revoked, [payload.pluginId]);
});
test("grant classification uses only the current host activation and stops matching after disable or upgrade", async () => {
const ipcMain = createIpcMain();
let plugin = { enabled: true, activeVersion: "1.0.0", manifest: { id: "com.example.synthetic" } };
let identity = { pluginId: "com.example.synthetic", pluginVersion: "1.0.0", runtimeId: "runtime-host", securityPrincipal: "host-owned-principal" };
const contexts = [];
registerPluginBridge(ipcMain, {
manager: { async initialize() {} }, isTrustedSender: () => true,
runtimeSupervisor: { getRuntimeIdentity: () => identity },
permissionEngine: { describeGrants(_id, context) { contexts.push(context); return { always: [], application: [], session: [] }; } },
database: { getActivePlugin: () => plugin, listSecurityAudit: () => [] },
});
const payload = { pluginId: "com.example.synthetic", securityPrincipal: "plugin-forgery", manifest: {} };
const read = ipcMain.handlers.get(CHANNELS.permissions);
await read({}, payload);
assert.deepEqual(contexts[0], { ...identity, manifest: plugin.manifest });
plugin = { ...plugin, enabled: false };
await read({}, payload);
assert.equal(contexts[1], null);
plugin = { ...plugin, enabled: true, activeVersion: "2.0.0" };
await read({}, payload);
assert.equal(contexts[2], null, "old activation cannot label upgraded package grants as current");
identity = null;
await read({}, payload);
assert.equal(contexts[3], null, "stopped and lazy plugins show saved grants as inactive");
});
test("host interaction ready and response channels reject untrusted senders before touching the adapter", async () => {
const ipcMain = createIpcMain(), calls = [];
const sender = { id: 7 };
registerPluginBridge(ipcMain, {
manager: { async initialize() {} }, isTrustedSender: (event) => event?.trusted === true,
interactionService: {
setReady(...args) { calls.push(args); return true; },
respond(...args) { calls.push(args); return true; },
},
});
for (const channel of [CHANNELS.interactionReady, CHANNELS.interactionResponse]) {
await assert.rejects(ipcMain.handlers.get(channel)({ sender }, {}), /Untrusted/);
}
assert.deepEqual(calls, []);
await ipcMain.handlers.get(CHANNELS.interactionReady)({ trusted: true, sender }, { ready: true });
const response = { requestId: "opaque", actionToken: "host-token", action: "cancel" };
await ipcMain.handlers.get(CHANNELS.interactionResponse)({ trusted: true, sender }, response);
assert.deepEqual(calls, [[sender, true], [sender, response]]);
});
+21
View File
@@ -11,6 +11,7 @@ class PluginManager {
onPluginEnabled: (pluginId) => this.runtimeSupervisor.start(pluginId),
};
this.beforeClose = options.beforeClose ?? null;
this.permissionEngine = options.permissionEngine ?? null;
this.initialized = false;
this.initializePromise = null;
this.mutationTail = Promise.resolve();
@@ -128,6 +129,26 @@ class PluginManager {
});
}
revokePermissions(pluginId) {
return this.#mutate(async () => {
await this.#ready();
if (!this.permissionEngine) throw new Error("Plugin permissions are unavailable");
if (!this.database.getActivePlugin(pluginId)) throw new Error("Plugin is not installed");
// Disable before revoking. Lazy activation cannot re-create authority while
// runtime shutdown cancels pending RPC, streams, leases and provider work.
const errors = [];
// Every containment stage must be attempted even when persistence,
// revocation audit, or contribution invalidation fails.
try { this.database.setEnabled(pluginId, false); } catch (error) { errors.push(error); }
try { this.permissionEngine.revokeAll(pluginId); } catch (error) { errors.push(error); }
try { this.contributionService.onPluginDisabled(pluginId); } catch (error) { errors.push(error); }
try { await this.runtimeSupervisor.stop(pluginId); } catch (error) { errors.push(error); }
if (errors.length === 1) throw errors[0];
if (errors.length > 1) throw new AggregateError(errors, "Plugin grant revocation or cleanup failed");
return this.database.getActivePlugin(pluginId);
});
}
restart(pluginId) {
return this.#mutate(async () => {
await this.#ready();
+72
View File
@@ -511,3 +511,75 @@ test("explicit enable checks compatibility before clearing quarantine or exposin
await assert.rejects(manager.setEnabled(plugin.id, true), /incompatible/);
assert.deepEqual(calls, []);
});
test("permission revocation disables before clearing grants and waits for runtime cleanup", async () => {
const calls = [];
let releaseStop;
const stopped = new Promise((resolve) => { releaseStop = resolve; });
let enabled = true;
const manager = new PluginManager({
database: {
getActivePlugin: (id) => ({ id, enabled }),
setEnabled(_id, value) { enabled = value; calls.push("disable"); },
},
packageStore: { async initialize() {} },
contributionService: {
async initialize() {},
onPluginDisabled() { calls.push("contributions"); },
},
permissionEngine: { revokeAll() { assert.equal(enabled, false); calls.push("revoke"); } },
runtimeSupervisor: { async stop() { calls.push("stop"); await stopped; calls.push("clean"); } },
});
let complete = false;
const revoke = manager.revokePermissions("com.example.revoke").then((value) => { complete = true; return value; });
const enable = manager.setEnabled("com.example.revoke", false);
await new Promise((resolve) => setImmediate(resolve));
assert.deepEqual(calls, ["disable", "revoke", "contributions", "stop"]);
assert.equal(complete, false);
releaseStop();
assert.equal((await revoke).enabled, false);
await enable;
assert.deepEqual(calls.slice(4), ["clean", "disable", "contributions", "stop", "clean"]);
});
test("failed runtime containment leaves the revoked plugin disabled and reports failure", async () => {
let enabled = true;
let revoked = false;
const manager = new PluginManager({
database: { getActivePlugin: () => ({ enabled }), setEnabled(_id, value) { enabled = value; } },
packageStore: { async initialize() {} },
contributionService: { async initialize() {}, onPluginDisabled() {} },
permissionEngine: { revokeAll() { revoked = true; } },
runtimeSupervisor: { async stop() { throw new Error("containment failed"); } },
});
await assert.rejects(manager.revokePermissions("com.example.revoke"), /containment failed/);
assert.equal(enabled, false);
assert.equal(revoked, true);
});
test("revocation attempts every cleanup stage and aggregates independent failures", async () => {
for (const failedStages of [["revoke"], ["contributions"], ["disable"], ["stop"], ["disable", "revoke", "contributions", "stop"]]) {
const calls = [];
const fail = (stage) => {
calls.push(stage);
if (failedStages.includes(stage)) throw new Error(`${stage} failed`);
};
const manager = new PluginManager({
database: {
getActivePlugin: () => ({ id: "com.example.revoke", enabled: false }),
setEnabled() { fail("disable"); },
},
packageStore: { async initialize() {} },
contributionService: { async initialize() {}, onPluginDisabled() { fail("contributions"); } },
permissionEngine: { revokeAll() { fail("revoke"); } },
runtimeSupervisor: { async stop() { fail("stop"); } },
});
await assert.rejects(manager.revokePermissions("com.example.revoke"), (error) => {
if (failedStages.length === 1) return error.message === `${failedStages[0]} failed`;
assert.ok(error instanceof AggregateError);
assert.deepEqual(error.errors.map((entry) => entry.message), failedStages.map((stage) => `${stage} failed`));
return true;
});
assert.deepEqual(calls, ["disable", "revoke", "contributions", "stop"]);
}
});
@@ -0,0 +1,70 @@
"use strict";
const { createHash } = require("node:crypto");
const { PluginCatalogService, fetchBounded, validateCatalog, MAX_CATALOG_BYTES } = require("./catalogService.cjs");
const REGISTRY_URL = "https://raw.githubusercontent.com/binaricat/netcatty-plugins/main/catalog.json";
const tokenFor = (entry) => createHash("sha256").update(JSON.stringify(entry)).digest("hex");
function prepareSnapshot(value) {
const entries = structuredClone(validateCatalog(value));
const publicEntries = structuredClone(entries).map((entry) => ({ ...entry, selectionToken: tokenFor(entry) }));
return { entries, publicEntries };
}
class RegistryCatalogService extends PluginCatalogService {
constructor(options) {
super(options);
this.publicEntries = prepareSnapshot(this.entries).publicEntries;
this.source = "builtin";
this.error = null;
this.refreshing = null;
}
async refresh() {
if (this.refreshing) return this.refreshing;
this.refreshing = (async () => {
try {
// No redirects: only this exact registry endpoint can replace the catalog.
const bytes = await fetchBounded(REGISTRY_URL, MAX_CATALOG_BYTES, this.fetch, 0);
const next = prepareSnapshot(JSON.parse(bytes.toString("utf8")));
// Publish only after full validation, clones and every token succeeded.
this.entries = next.entries;
this.publicEntries = next.publicEntries;
this.source = "registry";
this.error = null;
return true;
} catch (error) {
if (this.source === "registry") this.source = "cached";
this.error = String(error.message ?? error).slice(0, 1024);
return false;
}
})();
try { return await this.refreshing; } finally { this.refreshing = null; }
}
async list() {
await this.refresh();
return {
entries: structuredClone(this.publicEntries),
source: this.source,
error: this.error,
};
}
selected(id, token) {
const entry = this.get(id);
if (typeof token !== "string" || token !== tokenFor(entry)) {
throw new Error("Catalog entry changed. Refresh and review this version before continuing.");
}
return entry;
}
async readme(id, token) {
this.selected(id, token);
// Base method captures the pinned entry synchronously before its first await.
return super.readme(id);
}
async install(id, token) {
this.selected(id, token);
if (!await this.refresh()) throw new Error("Registry refresh failed. Cached catalogs are browse-only; retry when online.");
this.selected(id, token);
return super.install(id);
}
}
module.exports = { RegistryCatalogService, REGISTRY_URL, MAX_CATALOG_BYTES };
@@ -0,0 +1,153 @@
"use strict";
const assert = require("node:assert/strict");
const test = require("node:test");
const { RegistryCatalogService, REGISTRY_URL, MAX_CATALOG_BYTES } = require("./registryCatalogService.cjs");
const builtin = require("./catalog.json");
test("registry uses a fixed bounded anonymous endpoint and retains only validated snapshots", async () => {
let response = () => new Response(JSON.stringify([builtin[0]]));
const service = new RegistryCatalogService({ fetch: async (url, options) => {
assert.equal(url, REGISTRY_URL);
assert.equal(options.credentials, "omit"); assert.equal(options.redirect, "manual");
assert.equal(options.referrerPolicy, "no-referrer"); assert.ok(options.signal);
return response();
} });
const online = await service.list();
assert.equal(online.source, "registry"); assert.equal(online.entries.length, 1);
assert.match(online.entries[0].selectionToken, /^[a-f0-9]{64}$/);
for (const invalid of ["{", '{}', JSON.stringify([{ ...builtin[0], author: {} }]), JSON.stringify([{ ...builtin[0], commit: "main" }])]) {
response = () => new Response(invalid);
const cached = await service.list();
assert.equal(cached.source, "cached"); assert.ok(cached.error);
assert.deepEqual(cached.entries, online.entries);
}
response = () => new Response("[]", { headers: { "content-length": String(MAX_CATALOG_BYTES + 1) } });
assert.match((await service.list()).error, /size limit/);
response = () => new Response(null, { status: 302, headers: { location: "https://raw.githubusercontent.com/other/repo/main/catalog.json" } });
assert.match((await service.list()).error, /redirects/);
response = () => new Response("[]");
const empty = await service.list();
assert.equal(empty.error, null); assert.deepEqual(empty.entries, []);
});
test("offline first launch returns bundled source examples and never mutates installed plugins", async () => {
const service = new RegistryCatalogService({ fetch: async () => { throw new Error("offline"); }, manager: {
install() { assert.fail("offline must not install"); },
} });
const snapshot = await service.list();
assert.equal(snapshot.source, "builtin"); assert.equal(snapshot.entries.length, 2);
assert.match(snapshot.error, /offline/);
await assert.rejects(service.install(snapshot.entries[0].id, snapshot.entries[0].selectionToken), /browse-only/);
});
test("selection binds manifest, README, version and artifact; refresh changes or withdrawal cannot silently install", async () => {
let entries = structuredClone(builtin);
let requests = 0;
const service = new RegistryCatalogService({ fetch: async (url) => {
requests++;
if (url === REGISTRY_URL) return new Response(JSON.stringify(entries));
return new Response("# Pinned README");
}, manager: { install() { assert.fail("changed selection must not install"); } } });
const selected = (await service.list()).entries[0];
assert.equal(await service.readme(selected.id, selected.selectionToken), "# Pinned README");
await assert.rejects(service.readme(selected.id, "bad"), /changed/);
const before = requests;
await assert.rejects(service.install(selected.id), /changed/);
assert.equal(requests, before);
entries[0].description += " updated";
await assert.rejects(service.install(selected.id, selected.selectionToken), /changed/);
await assert.rejects(service.readme(selected.id, selected.selectionToken), /changed/);
entries = [];
const next = (await service.list()).entries;
assert.deepEqual(next, []);
await assert.rejects(service.install(selected.id, selected.selectionToken), /curated catalog/);
});
test("concurrent list refreshes share one request", async () => {
let finish; let calls = 0;
const service = new RegistryCatalogService({ fetch: async () => { calls++; return new Promise((resolve) => { finish = resolve; }); } });
const first = service.list(); const second = service.list();
finish(new Response(JSON.stringify(builtin)));
assert.deepEqual(await first, await second); assert.equal(calls, 1);
});
test("fresh unchanged selection installs only its pinned bytes and stays disabled", async (context) => {
const fs = require("node:fs/promises"); const os = require("node:os"); const path = require("node:path");
const { createHash } = require("node:crypto");
const root = await fs.mkdtemp(path.join(os.tmpdir(), "registry-test-"));
context.after(() => fs.rm(root, { recursive: true, force: true }));
const bytes = Buffer.from("synthetic validated-by-manager archive");
const entry = { ...structuredClone(builtin[0]), artifact: { url: "https://github.com/example/plugin/releases/download/v1/plugin.ncpkg", sha256: createHash("sha256").update(bytes).digest("hex") } };
const requests = []; let installs = 0;
const service = new RegistryCatalogService({
getTempFilePath: (name) => path.join(root, name),
fetch: async (url) => { requests.push(url); return new Response(url === REGISTRY_URL ? JSON.stringify([entry]) : bytes); },
manager: { async install(archive, options) {
installs++; assert.deepEqual(await fs.readFile(archive), bytes); assert.equal(options.enable, false);
assert.deepEqual(options.catalog, entry); return { id: entry.id };
} },
});
const selected = (await service.list()).entries[0];
await service.install(selected.id, selected.selectionToken);
assert.deepEqual(requests, [REGISTRY_URL, REGISTRY_URL, entry.artifact.url]);
assert.equal(installs, 1); assert.deepEqual(await fs.readdir(root), []);
});
test("URL-stripped README controls cannot escape a selected commit or replace the good snapshot", async () => {
let entries = structuredClone(builtin);
const requested = [];
const service = new RegistryCatalogService({ fetch: async (url) => {
requested.push(url); return new Response(JSON.stringify(entries));
} });
const good = await service.list();
for (const readme of ["\t../main/README.md", ".\n./main/README.md", "\r../main/README.md", "a/\t../../main/README.md", "README\u007f.md"]) {
entries = [{ ...builtin[0], readme }];
const snapshot = await service.list();
assert.equal(snapshot.source, "cached"); assert.match(snapshot.error, /unpinned/);
assert.deepEqual(snapshot.entries, good.entries);
}
entries = [{ ...builtin[0], repository: "example/.." }];
assert.match((await service.list()).error, /escapes the pinned commit/);
assert.ok(requested.every((url) => url === REGISTRY_URL));
});
test("whole-catalog JSON budgets and unknown fields cannot poison a good offline snapshot", async () => {
let response = JSON.stringify(builtin);
const service = new RegistryCatalogService({ fetch: async () => {
if (response === null) throw new Error("offline");
return new Response(response);
} });
const good = await service.list();
let nested = null;
for (let depth = 0; depth < 130; depth++) nested = { child: nested };
const nonfinite = structuredClone(builtin[0]);
nonfinite.manifest.contributes.settings[0].default = "finite-marker";
for (const invalid of [
JSON.stringify([{ ...builtin[0], unknownNestedField: nested }]),
JSON.stringify([{ ...builtin[0], unknownField: true }]),
JSON.stringify([nonfinite]).replace('"finite-marker"', '1e999'),
JSON.stringify([{ ...builtin[0], artifact: { url: "https://github.com/a/b", sha256: "a".repeat(64), unexpected: true } }]),
]) {
response = invalid;
const rejected = await service.list();
assert.equal(rejected.source, "cached"); assert.ok(rejected.error);
assert.deepEqual(rejected.entries, good.entries);
response = null;
const offline = await service.list();
assert.deepEqual(offline.entries, good.entries); assert.match(offline.error, /offline/);
}
});
test("snapshot preparation failure leaves both private entries and public tokens unchanged", async () => {
const service = new RegistryCatalogService({ fetch: async () => new Response(JSON.stringify(builtin)) });
const good = await service.list();
const originalClone = global.structuredClone;
try {
global.structuredClone = () => { throw new Error("synthetic clone failure"); };
assert.equal(await service.refresh(), false);
} finally { global.structuredClone = originalClone; }
service.fetch = async () => { throw new Error("offline"); };
assert.deepEqual((await service.list()).entries, good.entries);
assert.equal(service.selected(good.entries[0].id, good.entries[0].selectionToken).id, good.entries[0].id);
});
+13 -1
View File
@@ -5,7 +5,7 @@ import {
PLUGIN_ERROR_WIRE_CODES,
pluginErrorToRpcError,
} from "@netcatty/plugin-sdk";
import { createMessagePortStreamEnvelope } from "@netcatty/plugin-contract";
import { createMessagePortStreamEnvelope, PLUGIN_HOST_INTERACTION_LIMITS } from "@netcatty/plugin-contract";
import { createPluginStreamEndpoint } from "./pluginStreamEndpoint.mjs";
let terminalInterceptorTransportPromise;
@@ -365,6 +365,16 @@ function createPluginContext(config, client, runtimeApi) {
...(options?.ttlMs === undefined ? {} : { ttlMs: options.ttlMs }),
}),
};
const interact = (params) => client.request("ui.interact", params, { deadlineMs: PLUGIN_HOST_INTERACTION_LIMITS.timeoutMs })
.then((result) => result.cancelled ? undefined : result.value);
const ui = {
quickPick: (title, items) => interact({ kind: "quickPick", title, items }),
input: (title, initialValue) => interact({ kind: "input", title, ...(initialValue === undefined ? {} : { initialValue }) }),
};
const clipboard = {
readText: (title) => interact({ kind: "clipboard.read", title }),
writeText: (text, title) => interact({ kind: "clipboard.write", title, text }).then((value) => value === true),
};
const network = {
request: (request) => client.request("network.request", request, {
deadlineMs: forwardedDeadline(request?.timeoutMs, 300_000),
@@ -543,6 +553,8 @@ function createPluginContext(config, client, runtimeApi) {
secrets,
credentials,
network,
ui,
clipboard,
filesystem,
companions,
streams,
+2 -2
View File
@@ -351,7 +351,7 @@ class RuntimeSupervisor {
pluginId,
pluginVersion: plugin.activeVersion,
netcattyVersion: this.netcattyVersion,
apiVersion: this.apiVersion,
apiVersion: compatibility.apiVersion,
supportedFeatures: this.supportedFeatures,
enabledFeatures: compatibility.enabledFeatures,
environment,
@@ -362,7 +362,7 @@ class RuntimeSupervisor {
if (
initialized.pluginId !== pluginId
|| initialized.pluginVersion !== plugin.activeVersion
|| initialized.apiVersion !== this.apiVersion
|| initialized.apiVersion !== compatibility.apiVersion
|| JSON.stringify([...initialized.enabledFeatures].sort())
!== JSON.stringify([...compatibility.enabledFeatures].sort())
) {
@@ -12,6 +12,7 @@ const { createPluginPaths } = require("./paths.cjs");
const { RPC_ERRORS } = require("./rpcRouter.cjs");
const { RuntimeSupervisor } = require("./runtimeSupervisor.cjs");
const { createContainmentError } = require("./utilityPluginRuntime.cjs");
const { PluginContributionService } = require("./contributionService.cjs");
function pluginManifest(overrides = {}) {
return {
@@ -66,6 +67,31 @@ function createFixture(context, runtimeFactory, supervisorOptions = {}) {
return { database, manifest, runtimeOptions, supervisor };
}
test("ordinary command activation A cannot dispatch on replacement runtime B", async (t) => {
let executed = 0, activation;
const fixture = createFixture(t, () => ({ async start(config) { return { pluginId: config.pluginId, pluginVersion: config.pluginVersion,
apiVersion: config.apiVersion, enabledFeatures: config.enabledFeatures }; }, async stop() {}, async request() { executed++; return null; } }));
t.after(() => fixture.supervisor.shutdown());
const manifest = { ...fixture.manifest, contributes: { commands: [{ id: `${fixture.manifest.id}.ordinary`, title: "Ordinary" }] } };
const finished = [];
const service = new PluginContributionService({
database: { listPlugins: () => [{ id: manifest.id, manifest, activeVersion: manifest.version, enabled: true, runtime: {} }] },
onCommandFinished: (runtimeId) => finished.push(runtimeId),
runtimeSupervisor: {
async start(pluginId) {
activation = await fixture.supervisor.start(pluginId);
await fixture.supervisor.restart(pluginId);
return activation;
},
request: (...args) => fixture.supervisor.request(...args),
},
});
await assert.rejects(service.executeCommand(`${manifest.id}.ordinary`), /no longer matches the authorized activation/);
assert.notEqual(fixture.supervisor.getRuntimeIdentity(manifest.id).runtimeId, activation.runtimeId);
assert.equal(executed, 0, "replacement B receives no stale command");
assert.deepEqual(finished, [activation.runtimeId]);
});
test("supervisor prefers the ordinary browser runtime and enforces negotiated identity", async (context) => {
const calls = [];
const initialEnvironment = { locale: "en", theme: "light", reducedMotion: false, highContrast: false };
@@ -951,3 +977,19 @@ test("a stale runtime crash cannot quarantine or overwrite a replacement version
assert.equal(priorVersion.runtime.status, "error");
assert.match(priorVersion.runtime.lastError, /stale process crashed/);
});
test('new host initializes an exact legacy API plugin with its negotiated legacy revision', async (context) => {
const observed = [];
const fixture = createFixture(context, () => ({
async start(config) { observed.push(config.apiVersion); return { pluginId: config.pluginId, pluginVersion: config.pluginVersion, apiVersion: config.apiVersion, enabledFeatures: config.enabledFeatures }; },
async stop() {},
}), { apiVersion: '0.1.0-internal.1', supportedFeatures: ['metadata.brand-icon'] });
fixture.database.installVersion({
pluginId: fixture.manifest.id, version: fixture.manifest.version,
manifest: { ...fixture.manifest, engines: { netcatty: '>=0.0.0', api: '0.1.0-internal' } },
archiveSha256: 'a'.repeat(64), packageRelativePath: `${fixture.manifest.id}/${fixture.manifest.version}/package`,
}, { enable: true });
await fixture.supervisor.start(fixture.manifest.id);
assert.deepEqual(observed, ['0.1.0-internal']);
await fixture.supervisor.stop(fixture.manifest.id);
});
+1 -1
View File
@@ -324,7 +324,7 @@ test("networkBroker.validate preserves credentialLease for registry dispatch", (
url: "https://example.com/vault",
credentialLease: {
kind: "secret-lease",
id: "lease-1",
id: "l".repeat(32),
operationId: "network:https://example.com",
expiresAt: Date.now() + 60_000,
},
@@ -0,0 +1,32 @@
const assert = require('node:assert/strict');
const test = require('node:test');
const { readFile } = require('node:fs/promises');
const { resolve } = require('node:path');
const { pathToFileURL } = require('node:url');
const { MessageChannel } = require('node:worker_threads');
const { PluginRpcRouter } = require('./rpcRouter.cjs');
test('actual settings-view fixture default export initializes and activates through the runtime peer', async (t) => {
const root = resolve(__dirname, '../../examples/plugins/settings-view-fixture');
const manifest = JSON.parse(await readFile(resolve(root, 'netcatty.plugin.json'), 'utf8'));
assert.deepEqual(manifest.permissions.required, ['views']);
assert.equal(manifest.contributes.views[0].location, 'settings');
const html = await readFile(resolve(root, 'view.html'), 'utf8');
assert.doesNotMatch(html, /<script|https?:\/\/|<iframe/i);
const { startPluginRuntime } = await import('./runtime/runtimePeer.mjs');
const { port1, port2 } = new MessageChannel();
t.after(() => { port1.close(); port2.close(); });
const host = new PluginRpcRouter({ pluginId: manifest.id, send: (message) => port1.postMessage(message) });
t.after(() => host.close());
port1.on('message', (message) => host.accept(message));
const peer = await startPluginRuntime({
port: port2,
config: { pluginId: manifest.id, pluginVersion: manifest.version, netcattyVersion: '0.0.0', apiVersion: '0.1.0-internal', enabledFeatures: [] },
loadPlugin: () => import(pathToFileURL(resolve(root, manifest.main.browser)).href),
});
t.after(() => peer.dispose());
const initialized = await host.request('plugin.initialize', { netcattyVersion: '0.0.0', apiVersion: '0.1.0-internal', supportedFeatures: [] });
assert.equal(initialized.pluginId, manifest.id);
await host.request('plugin.activate', {});
await host.request('plugin.deactivate', {});
});
+12 -2
View File
@@ -100,9 +100,18 @@ function createPreloadApi(ctx) {
return {
getPluginRuntimeStatus: () => ipcRenderer.invoke("netcatty:plugins:status"),
listPluginCatalog: () => ipcRenderer.invoke("netcatty:plugins:catalog"),
getPluginCatalogReadme: (id) => ipcRenderer.invoke("netcatty:plugins:catalog-readme", { id }),
installCatalogPlugin: (id) => ipcRenderer.invoke("netcatty:plugins:catalog-install", { id }),
getPluginCatalogReadme: (id, selectionToken) => ipcRenderer.invoke("netcatty:plugins:catalog-readme", { id, selectionToken }),
installCatalogPlugin: (id, selectionToken) => ipcRenderer.invoke("netcatty:plugins:catalog-install", { id, selectionToken }),
setPluginInteractionReady: (ready) => ipcRenderer.invoke("netcatty:plugins:interaction-ready", { ready }),
respondPluginInteraction: (response) => ipcRenderer.invoke("netcatty:plugins:interaction-response", response),
onPluginInteraction: (callback) => {
const listener = (_event, payload) => callback(payload);
ipcRenderer.on("netcatty:plugins:host-interaction", listener);
return () => ipcRenderer.removeListener("netcatty:plugins:host-interaction", listener);
},
listPlugins: () => ipcRenderer.invoke("netcatty:plugins:list"),
getPluginPermissions: (pluginId) => ipcRenderer.invoke("netcatty:plugins:permissions", { pluginId }),
revokePluginPermissions: (pluginId) => ipcRenderer.invoke("netcatty:plugins:revoke-permissions", { pluginId }),
installPluginPackage: (archivePath, options) => ipcRenderer.invoke("netcatty:plugins:install", {
archivePath,
enable: options?.enable === true,
@@ -114,6 +123,7 @@ function createPreloadApi(ctx) {
restartPlugin: (pluginId) => ipcRenderer.invoke("netcatty:plugins:restart", { pluginId }),
uninstallPlugin: (pluginId) => ipcRenderer.invoke("netcatty:plugins:uninstall", { pluginId }),
getPluginContributions: (options) => ipcRenderer.invoke("netcatty:plugins:contributions", options ?? {}),
getPluginBrandIcon: (pluginId) => ipcRenderer.invoke("netcatty:plugins:brand-icon", { pluginId }),
getPluginContributionIcon: (pluginId, icon) => ipcRenderer.invoke("netcatty:plugins:contribution-icon", { pluginId, icon }),
executePluginCommand: (command, args, context) => ipcRenderer.invoke("netcatty:plugins:execute-command", {
command,
+1 -1
View File
@@ -7,6 +7,6 @@
"build": "tsc -p tsconfig.json"
},
"dependencies": {
"@netcatty/plugin-sdk": "0.1.0-internal"
"@netcatty/plugin-sdk": "0.1.0-internal.1"
}
}
@@ -0,0 +1,41 @@
# Host text workflow
This browser-only fixture uses host choice/input dialogs and explicit clipboard
copy/share. It has no network, filesystem, host/session, credential, Node, or
shell permission. Shared text stays in the current operation's memory and is
not returned in the command result or written to plugin storage/logs.
Version 0.1.1 requires `host.interactions` and `commands.interactive`. Its one
command explicitly declares the fixed 180-second interactive budget; ordinary
commands keep 30 seconds and each dialog keeps 60 seconds. The host closes
pending confirmations when the command fails or ends, without resetting quotas.
These features add no new data permission. The permission list remains
required `commands`/`menus`, optional `ui.interact`/`clipboard.read`/`clipboard.write`.
Pack the source after building the plugin packages:
```sh
node packages/plugin-cli/dist/cli.js pack examples/plugins/host-interactions --out /tmp/host-interactions.ncpkg
```
Import it disabled in the normal manager. Inspect permissions before enabling.
Use one-use permissions for this check. With the unlocked main window focused,
choose **Host text workflow** in the command palette.
- **Compose synthetic text** asks for text and then previews the exact clipboard
replacement. Cancel leaves the clipboard unchanged.
- **Share clipboard and uppercase it** first asks before reading the clipboard.
The host previews the captured text and requires a second confirmation to
share it with the plugin. The transformed output needs a separate copy
confirmation. Clipboard changes after preview do not change the shared
snapshot.
Use only synthetic clipboard text. Do not use account data or real secrets.
The fixture does not require a persistent grant or any network destination.
A different plugin that also has network permission could send shared text
through its granted capabilities; reading text does not approve a new origin.
Blur/lock/close the owner window, cancel, revoke access, or disable the plugin
to end a pending request. Late/duplicate replies cannot commit an old action.
Only the focused unlocked main window is supported in this first slice. Other
windows return unavailable instead of inheriting the request's authority.
@@ -0,0 +1,21 @@
import { definePlugin, PLUGIN_HOST_INTERACTION_FEATURE } from '@netcatty/plugin-sdk';
export default definePlugin({
activate(context) {
context.subscriptions.add(context.commands.registerCommand('com.netcatty.host-interactions.text', async () => {
if (!context.enabledFeatures.has(PLUGIN_HOST_INTERACTION_FEATURE)) throw new Error('Host interactions are unavailable');
const source = await context.ui.quickPick('Choose a text workflow', [
{ id: 'compose', label: 'Compose synthetic text', description: 'Edit a draft, then explicitly confirm clipboard replacement.' },
{ id: 'share', label: 'Share clipboard and uppercase it', description: 'Preview text, confirm sharing, then confirm the transformed copy.' },
]);
if (source === undefined) return { cancelled: true };
const text = source === 'compose'
? await context.ui.input('Edit synthetic text', 'synthetic text')
: await context.clipboard.readText('Share clipboard text for uppercase conversion');
if (text === undefined) return { cancelled: true };
const output = source === 'share' ? text.toUpperCase() : text;
const copied = await context.clipboard.writeText(output, 'Confirm the exact text to copy');
return { copied, characters: output.length };
}));
},
});
@@ -0,0 +1,52 @@
{
"manifestVersion": 1,
"id": "com.netcatty.host-interactions",
"name": "host-interactions",
"displayName": "Host text interactions",
"description": "Synthetic host choice, input and explicit clipboard sharing workflow.",
"version": "0.1.1",
"publisher": "netcatty",
"license": "GPL-3.0-or-later",
"engines": {
"netcatty": ">=0.0.0",
"api": ">=0.1.0-internal <0.2.0"
},
"features": {
"required": [
"host.interactions",
"commands.interactive"
]
},
"main": {
"browser": "browser.js"
},
"activationEvents": [
"onCommand:com.netcatty.host-interactions.text"
],
"permissions": {
"required": [
"commands",
"menus"
],
"optional": [
"ui.interact",
"clipboard.read",
"clipboard.write"
]
},
"contributes": {
"commands": [
{
"id": "com.netcatty.host-interactions.text",
"title": "Host text workflow",
"interactive": true
}
],
"menus": [
{
"command": "com.netcatty.host-interactions.text",
"location": "commandPalette"
}
]
}
}
+25
View File
@@ -0,0 +1,25 @@
# Synthetic credential lease fixture
This browser-only package exercises feature negotiation, plugin-owned secret
references, a one-use host-consumed network lease, and the host grant panel.
It contains only synthetic data and targets `https://example.invalid`.
A synthetic host test supplies the HTTP response. The normal app reports a
network failure at that deliberately non-routable address after permission.
This fixture does not prove a live third-party integration.
Pack the source after building the plugin packages:
```sh
node packages/plugin-cli/dist/cli.js pack examples/plugins/lease-http --out /tmp/lease-http.ncpkg
```
In the normal plugin manager, import the package disabled, inspect its declared
permissions, and enable it. The command palette contains **Synthetic credential
lease request**. Reject a permission prompt to verify that the request stops.
OS secret storage must be available to proceed past `secrets.set`.
The package detail's **Saved grants** panel shows retained decisions. One-use
decisions are not retained. **Disable and revoke all grants** disables the
plugin and waits for its runtime cleanup. Re-enable it to request access again.
Recent events show event type and permission without terminal or secret data.
Do not change the destination or substitute real credentials for this check.
+24
View File
@@ -0,0 +1,24 @@
import { definePlugin, PLUGIN_NETWORK_CREDENTIAL_LEASE_FEATURE } from '@netcatty/plugin-sdk';
// Isolated acceptance fixture: the target is deliberately non-routable.
// Tests provide a synthetic HTTP transport. Never substitute a real credential.
export default definePlugin({
activate(context) {
context.subscriptions.add(context.commands.registerCommand('com.netcatty.lease-http.request', async () => {
if (!context.enabledFeatures.has(PLUGIN_NETWORK_CREDENTIAL_LEASE_FEATURE)) {
throw new Error('Host does not advertise network credential leases');
}
const secret = await context.secrets.set('synthetic-token', 'synthetic-acceptance-token');
const credentialLease = await context.credentials.createLease(secret, {
operationId: 'network:https://example.invalid',
purpose: 'Authenticate an isolated synthetic request',
});
const response = await context.network.request({
url: 'https://example.invalid/synthetic',
credentialLease,
authorization: { scheme: 'Bearer' },
});
return { status: response.status };
}));
},
});
@@ -0,0 +1,25 @@
{
"manifestVersion": 1,
"id": "com.netcatty.lease-http",
"name": "lease-http",
"displayName": "Synthetic credential lease",
"description": "Isolated credential lease and grant revocation acceptance fixture.",
"version": "0.1.0",
"publisher": "netcatty",
"license": "GPL-3.0-or-later",
"engines": { "netcatty": ">=0.0.0", "api": ">=0.1.0-internal <0.2.0" },
"features": { "required": ["network.credential-lease"] },
"main": { "browser": "browser.js" },
"activationEvents": ["onCommand:com.netcatty.lease-http.request"],
"permissions": {
"required": ["commands", "menus"],
"optional": [
{ "permission": "secrets", "resources": ["secret:synthetic-token"] },
{ "permission": "network", "resources": ["https://example.invalid"] }
]
},
"contributes": {
"commands": [{ "id": "com.netcatty.lease-http.request", "title": "Synthetic credential lease request" }],
"menus": [{ "command": "com.netcatty.lease-http.request", "location": "commandPalette" }]
}
}
@@ -0,0 +1,23 @@
# Settings view handoff fixture
First-party synthetic native QA fixture, compatible with the manager-dialog
baseline `614b7c997a75afff2c64b796a9f11c3eec804b43`. This is not a marketplace
release. It declares only `views`, has an empty browser activation function and
one static `location: settings` HTML panel. It has no commands, host, terminal,
network, filesystem, secret or sync operation. Declarations are not grants.
```sh
node packages/plugin-cli/dist/cli.js validate examples/plugins/settings-view-fixture
node packages/plugin-cli/dist/cli.js pack examples/plugins/settings-view-fixture --out /tmp/settings-view-fixture.ncpkg
```
In a synthetic Electron profile, import disabled, inspect the manifest, then
Enable. In Installed, find **Static Settings View** and click its **Open** button
(the command palette is not this regression path). Review the normal permission
prompt; use only a specifically approved temporary grant. Denying must not open
a view. If allowed, the plugin manager must close before the external Settings
view appears. Verify its host-provided title/close button is clickable and can
receive focus; close it, then reopen plugin management. Do not use existing
Starter modal permission approval as authorization for this separate fixture.
Version 0.1.1 corrects the default-export entrypoint contract. It retains the same sole `views` declaration and static HTML. Version 0.1.0 failed before activation and must not be used for successful native acceptance.
@@ -0,0 +1 @@
export default { activate() {} };
@@ -0,0 +1,21 @@
{
"manifestVersion": 1,
"id": "com.netcatty.settings-view-fixture",
"name": "settings-view-fixture",
"displayName": { "en": "Settings View QA", "zh-CN": "设置视图验收" },
"version": "0.1.1",
"publisher": "netcatty",
"license": "GPL-3.0-or-later",
"engines": { "netcatty": ">=0.0.0", "api": ">=0.1.0-internal <0.2.0" },
"main": { "browser": "index.js" },
"activationEvents": ["onView:com.netcatty.settings-view-fixture.panel"],
"permissions": { "required": ["views"] },
"contributes": {
"views": [{
"id": "com.netcatty.settings-view-fixture.panel",
"title": { "en": "Static Settings View", "zh-CN": "静态设置视图" },
"location": "settings",
"entry": "view.html"
}]
}
}
@@ -0,0 +1,3 @@
<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Static Settings View</title></head>
<body><h1>Settings view handoff / 设置视图交接</h1><p>This first-party static panel has no script, external resource or host data.</p><p>Close this view with Netcatty's header button. The manager should already be dismissed.</p></body></html>
+8 -1
View File
@@ -14,7 +14,7 @@ node packages/plugin-cli/dist/cli.js pack examples/plugins/starter-netcatty --ou
```
Start the actual app normally with a fresh test profile; no development flag is
needed. Open **Settings > Plugins > Installed**, import the package, inspect its
needed. Open **Settings > Plugins > Open plugin manager > Installed**, import the package, inspect its
permissions and enable it. Change **Greeting**, then find **Starter: Greeting Panel** (入门:问候面板)
in the command palette. Review the normal permission prompts, then click
**Show greeting** to see the exact preference in the sandboxed panel.
@@ -28,3 +28,10 @@ KV; user-owned settings remain. The greeting is local only, not cloud-synced.
The separate Hello Netcatty example demonstrates terminal providers and requests
terminal permissions. Use this Starter for the default no-sensitive-access demo.
Version 0.1.2 adds light/dark PNG brand icons in `assets/`. Displaying these
images does not activate the plugin or request permissions. This example now
requires API `>=0.1.0-internal.1 <0.2.0` and `metadata.brand-icon`.
Older Starter 0.1.1 packages remain compatible with the new host.
See [brand icon author instructions](../../../docs/plugin-platform/plugin-icons.md).
Binary file not shown.

After

Width:  |  Height:  |  Size: 873 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 873 B

@@ -7,12 +7,12 @@
"zh-CN": "插件入门"
},
"description": "First-party settings and command example. No terminal, network, filesystem or secret access.",
"version": "0.1.1",
"version": "0.1.2",
"publisher": "netcatty",
"license": "GPL-3.0-or-later",
"engines": {
"netcatty": ">=0.0.0",
"api": ">=0.1.0-internal <0.2.0"
"api": ">=0.1.0-internal.1 <0.2.0"
},
"main": {
"browser": "dist/index.js"
@@ -71,5 +71,15 @@
"entry": "panel.html"
}
]
},
"features": {
"required": [
"metadata.brand-icon"
]
},
"icon": {
"kind": "package",
"light": "assets/logo-light.png",
"dark": "assets/logo-dark.png"
}
}
@@ -1,12 +1,12 @@
{
"name": "@netcatty/example-starter-plugin",
"version": "0.1.1",
"version": "0.1.2",
"private": true,
"type": "module",
"scripts": {
"build": "tsc -p tsconfig.json"
},
"dependencies": {
"@netcatty/plugin-sdk": "0.1.0-internal"
"@netcatty/plugin-sdk": "0.1.0-internal.1"
}
}
Vendored
+25 -3
View File
@@ -334,7 +334,14 @@ declare global {
credentialStorageAvailable?: boolean;
}
interface NetcattyPluginCatalogSnapshot {
entries: NetcattyPluginCatalogEntry[];
source: "registry" | "cached" | "builtin";
error: string | null;
}
interface NetcattyPluginCatalogEntry {
selectionToken?: string;
id: string;
version: string;
name: string;
@@ -348,6 +355,15 @@ declare global {
artifact?: { url: string; sha256: string };
}
interface NetcattyPluginPermissionSnapshot {
grants: Array<{
permission: string; resource: string; resourceKind: string;
scope: 'always' | 'application' | 'session'; sessionId: string | null; grantedAt: number;
matchesCurrentActivation: boolean;
}>;
audit: Array<{ event: string; createdAt: number; permission?: string; scope?: string }>;
}
interface NetcattyInstalledPlugin {
id: string;
enabled: boolean;
@@ -425,15 +441,21 @@ declare global {
interface NetcattyBridge {
getPluginRuntimeStatus?(): Promise<NetcattyPluginRuntimeStatus>;
listPluginCatalog?(): Promise<NetcattyPluginCatalogEntry[]>;
getPluginCatalogReadme?(id: string): Promise<string>;
installCatalogPlugin?(id: string): Promise<NetcattyInstalledPlugin>;
listPluginCatalog?(): Promise<NetcattyPluginCatalogSnapshot>;
getPluginCatalogReadme?(id: string, selectionToken?: string): Promise<string>;
installCatalogPlugin?(id: string, selectionToken?: string): Promise<NetcattyInstalledPlugin>;
setPluginInteractionReady?(ready: boolean): Promise<boolean>;
respondPluginInteraction?(response: import("@netcatty/plugin-contract").HostInteractionResponse): Promise<boolean>;
onPluginInteraction?(callback: (event: import("@netcatty/plugin-contract").HostInteractionEvent) => void): () => void;
listPlugins?(): Promise<NetcattyInstalledPlugin[]>;
getPluginPermissions?(pluginId: string): Promise<NetcattyPluginPermissionSnapshot>;
revokePluginPermissions?(pluginId: string): Promise<NetcattyInstalledPlugin>;
installPluginPackage?(archivePath: string, options?: { enable?: boolean }): Promise<NetcattyInstalledPlugin>;
setPluginEnabled?(pluginId: string, enabled: boolean): Promise<NetcattyInstalledPlugin>;
restartPlugin?(pluginId: string): Promise<NetcattyInstalledPlugin>;
uninstallPlugin?(pluginId: string): Promise<boolean>;
getPluginContributions?(options?: NetcattyPluginContributionQuery): Promise<NetcattyPluginContributionSnapshot>;
getPluginBrandIcon?(pluginId: string): Promise<{ light: string; dark?: string } | null>;
getPluginContributionIcon?(pluginId: string, icon: Extract<NetcattyPluginIconReference, { kind: 'package' }>): Promise<{ light: string; dark?: string }>;
executePluginCommand?(command: string, args?: unknown, context?: Record<string, unknown>): Promise<unknown>;
updatePluginSetting?(pluginId: string, settingId: string, value: unknown, scopeId?: string): Promise<{ restartRequired: boolean }>;
+11 -11
View File
@@ -17,9 +17,9 @@
"@aws-sdk/client-s3": "^3.956.0",
"@mimo-ai/sdk": "^0.1.15",
"@modelcontextprotocol/sdk": "^1.29.0",
"@netcatty/plugin-cli": "0.1.0-internal",
"@netcatty/plugin-contract": "0.1.0-internal",
"@netcatty/plugin-sdk": "0.1.0-internal",
"@netcatty/plugin-cli": "0.1.0-internal.1",
"@netcatty/plugin-contract": "0.1.0-internal.1",
"@netcatty/plugin-sdk": "0.1.0-internal.1",
"@openai/codex-sdk": "0.144.3",
"@smithy/node-http-handler": "^4.4.9",
"@withfig/autocomplete": "^2.692.3",
@@ -136,14 +136,14 @@
"name": "@netcatty/example-hello-plugin",
"version": "0.1.0",
"dependencies": {
"@netcatty/plugin-sdk": "0.1.0-internal"
"@netcatty/plugin-sdk": "0.1.0-internal.1"
}
},
"examples/plugins/starter-netcatty": {
"name": "@netcatty/example-starter-plugin",
"version": "0.1.1",
"version": "0.1.2",
"dependencies": {
"@netcatty/plugin-sdk": "0.1.0-internal"
"@netcatty/plugin-sdk": "0.1.0-internal.1"
}
},
"node_modules/@agentclientprotocol/sdk": {
@@ -20689,10 +20689,10 @@
},
"packages/plugin-cli": {
"name": "@netcatty/plugin-cli",
"version": "0.1.0-internal",
"version": "0.1.0-internal.1",
"license": "GPL-3.0-or-later",
"dependencies": {
"@netcatty/plugin-contract": "0.1.0-internal",
"@netcatty/plugin-contract": "0.1.0-internal.1",
"ajv": "8.18.0",
"ajv-formats": "3.0.1",
"semver": "^7.7.3",
@@ -20720,7 +20720,7 @@
},
"packages/plugin-contract": {
"name": "@netcatty/plugin-contract",
"version": "0.1.0-internal",
"version": "0.1.0-internal.1",
"license": "GPL-3.0-or-later",
"devDependencies": {
"ajv": "8.18.0",
@@ -20729,10 +20729,10 @@
},
"packages/plugin-sdk": {
"name": "@netcatty/plugin-sdk",
"version": "0.1.0-internal",
"version": "0.1.0-internal.1",
"license": "GPL-3.0-or-later",
"dependencies": {
"@netcatty/plugin-contract": "0.1.0-internal"
"@netcatty/plugin-contract": "0.1.0-internal.1"
}
}
}
+3 -3
View File
@@ -81,9 +81,9 @@
"@aws-sdk/client-s3": "^3.956.0",
"@mimo-ai/sdk": "^0.1.15",
"@modelcontextprotocol/sdk": "^1.29.0",
"@netcatty/plugin-cli": "0.1.0-internal",
"@netcatty/plugin-contract": "0.1.0-internal",
"@netcatty/plugin-sdk": "0.1.0-internal",
"@netcatty/plugin-cli": "0.1.0-internal.1",
"@netcatty/plugin-contract": "0.1.0-internal.1",
"@netcatty/plugin-sdk": "0.1.0-internal.1",
"@openai/codex-sdk": "0.144.3",
"@smithy/node-http-handler": "^4.4.9",
"@withfig/autocomplete": "^2.692.3",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@netcatty/plugin-cli",
"version": "0.1.0-internal",
"version": "0.1.0-internal.1",
"private": true,
"type": "module",
"license": "GPL-3.0-or-later",
@@ -18,7 +18,7 @@
"build": "tsc -p tsconfig.build.json"
},
"dependencies": {
"@netcatty/plugin-contract": "0.1.0-internal",
"@netcatty/plugin-contract": "0.1.0-internal.1",
"ajv": "8.18.0",
"ajv-formats": "3.0.1",
"semver": "^7.7.3",
+33 -3
View File
@@ -14,6 +14,7 @@ import {
import path from "node:path";
import { once } from "node:events";
import { inspectPluginBrandIcon, PLUGIN_BRAND_ICON_MAX_BYTES } from "@netcatty/plugin-contract";
import type { IconReference, PluginManifest } from "@netcatty/plugin-contract";
import yauzl, { type Entry, type ZipFile } from "yauzl";
@@ -292,6 +293,7 @@ async function scanPackageDirectory(
const packagedPaths = new Set(files.map(({ packagePath }) => packagePath));
const requiredPaths = [
"netcatty.plugin.json",
...packageIconPaths(manifest.icon),
manifest.main.browser,
manifest.main.node,
...(manifest.contributes?.views ?? []).map(({ entry }) => entry),
@@ -305,6 +307,18 @@ async function scanPackageDirectory(
throw new Error(`Manifest references a missing package file: ${requiredPath}`);
}
}
for (const iconPath of packageIconPaths(manifest.icon)) {
const file = files.find((entry) => entry.packagePath === iconPath)!;
if (file.size > PLUGIN_BRAND_ICON_MAX_BYTES) throw new Error('Plugin brand icon exceeds size limit');
const handle = await open(file.absolutePath, constants.O_RDONLY | constants.O_NOFOLLOW);
try {
const stats = await handle.stat();
if (!stats.isFile() || stats.size !== file.size) throw new Error('Brand icon changed during packaging');
const bytes = await handle.readFile();
if (createHash('sha256').update(bytes).digest('hex') !== file.sha256) throw new Error('Brand icon changed during packaging');
inspectPluginBrandIcon(bytes, iconPath);
} finally { await handle.close(); }
}
return files.sort(sortPackagePaths);
}
@@ -490,7 +504,7 @@ export async function validatePluginDirectory(
};
}
function openZip(filePath: string): Promise<ZipFile> {
function openZip(filePath: string, autoClose = true): Promise<ZipFile> {
return new Promise((resolve, reject) => {
yauzl.open(
filePath,
@@ -498,7 +512,7 @@ function openZip(filePath: string): Promise<ZipFile> {
// AssertByteCountStream that rejects an overflowing chunk before it is
// forwarded to readEntry(). Combined with the metadata limits below,
// captured manifest contents cannot exceed PACKAGE_LIMITS.manifestBytes.
{ lazyEntries: true, decodeStrings: true, strictFileNames: true, validateEntrySizes: true },
{ lazyEntries: true, autoClose, decodeStrings: true, strictFileNames: true, validateEntrySizes: true },
(error, zipFile) => {
if (error || !zipFile) reject(error ?? new Error("Unable to open plugin archive"));
else resolve(zipFile);
@@ -645,9 +659,11 @@ async function inspectPluginPackage(
if (archiveStats.size > PACKAGE_LIMITS.archiveBytes) {
throw new Error(`Plugin archive exceeds ${PACKAGE_LIMITS.archiveBytes} bytes`);
}
const zipFile = await openZip(archivePath);
const zipFile = await openZip(archivePath, false);
try {
const registry = new PackagePathRegistry();
const entries = new Map<string, {
zipEntry: Entry;
contents?: Buffer;
mode: number;
sha256: string;
@@ -704,6 +720,7 @@ async function inspectPluginPackage(
throw new Error(`ZIP entry integrity check failed: ${packagePath}`);
}
entries.set(packagePath, {
zipEntry: entry,
contents: result.contents,
mode,
sha256: result.sha256,
@@ -733,6 +750,7 @@ async function inspectPluginPackage(
}
}
const requiredPaths = [
...packageIconPaths(manifest.icon),
manifest.main.browser,
manifest.main.node,
...(manifest.contributes?.views ?? []).map(({ entry }) => entry),
@@ -745,6 +763,17 @@ async function inspectPluginPackage(
throw new Error(`Manifest references a missing package file: ${requiredPath}`);
}
}
// The manifest may follow its assets in ZIP order. Keep the same verified
// descriptor open, then capture only the (at most two) declared brand images.
// Non-logo images remain streamed regardless of extension or size.
for (const iconPath of new Set(packageIconPaths(manifest.icon))) {
const record = entries.get(iconPath);
if (!record || record.size > PLUGIN_BRAND_ICON_MAX_BYTES) throw new Error('Brand icon is missing or exceeds size limit');
const result = await readEntry(zipFile, record.zipEntry, true);
if (!result.contents || result.sha256 !== record.sha256 || result.bytes !== record.size
|| result.crc32 !== record.zipEntry.crc32) throw new Error('Brand icon changed during archive validation');
inspectPluginBrandIcon(result.contents, iconPath);
}
for (const [companionPath, companion] of declaredCompanions) {
const entry = entries.get(companionPath);
if (!entry) throw new Error(`Manifest references a missing companion: ${companionPath}`);
@@ -769,6 +798,7 @@ async function inspectPluginPackage(
})),
);
return { manifest, fileCount: entries.size, uncompressedBytes: totalBytes, contentSha256 };
} finally { zipFile.close(); }
}
export async function validatePluginPackage(
@@ -0,0 +1,79 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { mkdtemp, mkdir, readFile, writeFile, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { inspectPluginBrandIcon } from '@netcatty/plugin-contract';
import { validateManifestValue } from './manifest.js';
import { checkPluginCompatibility } from './compatibility.js';
import { buildPluginPackage, validatePluginDirectory, validatePluginPackage } from './archive.js';
const example = new URL('../../../examples/plugins/starter-netcatty/', import.meta.url);
const manifest = JSON.parse(await readFile(new URL('netcatty.plugin.json', example), 'utf8'));
const png = await readFile(new URL('assets/logo-light.png', example));
test('brand metadata requires the new API and feature; legacy exact API remains supported', () => {
assert.equal(validateManifestValue(manifest).valid, true);
assert.equal(validateManifestValue({ ...manifest, features: {} }).valid, false);
assert.equal(validateManifestValue({ ...manifest, engines: { ...manifest.engines, api: '0.1.0-internal' } }).valid, false);
for (const light of ['../outside.png', '/tmp/logo.png', 'https://example.com/logo.png', 'assets/a.svg', 'a\\b.png']) {
assert.equal(validateManifestValue({ ...manifest, icon: { kind: 'package', light } }).valid, false, light);
}
assert.equal(checkPluginCompatibility(manifest, { netcattyVersion: '1.0.0', apiVersion: '0.1.0-internal', features: [] }).compatible, false);
assert.equal(checkPluginCompatibility(manifest, { netcattyVersion: '1.0.0', features: ['metadata.brand-icon'] }).compatible, true);
const { icon: _icon, features: _features, ...legacy } = manifest;
const result = checkPluginCompatibility({ ...legacy, engines: { ...legacy.engines, api: '0.1.0-internal' } }, { netcattyVersion: '1.0.0' });
assert.equal(result.compatible, true);
assert.equal(result.apiVersion, '0.1.0-internal');
});
test('brand raster inspection rejects oversized, animated, truncated and disguised inputs', () => {
assert.equal(inspectPluginBrandIcon(png, 'logo.png').width, 128);
const webp = Buffer.from('UklGRhwAAABXRUJQVlA4TA8AAAAvD8ADAAcQ0f/+ByKi/wEA', 'base64');
assert.equal(inspectPluginBrandIcon(webp, 'logo.webp').width, 16);
const animatedWebp = Buffer.from(webp); animatedWebp.write('ANIM', 12);
assert.throws(() => inspectPluginBrandIcon(animatedWebp, 'logo.webp'), /Animated/);
assert.throws(() => inspectPluginBrandIcon(png, 'logo.webp'));
assert.throws(() => inspectPluginBrandIcon(Buffer.alloc(256 * 1024 + 1), 'logo.png'));
const huge = Buffer.from(png); huge.writeUInt32BE(513, 16);
assert.throws(() => inspectPluginBrandIcon(huge, 'logo.png'), /dimensions/);
assert.throws(() => inspectPluginBrandIcon(png.subarray(0, 32), 'logo.png'));
const animated = Buffer.from(png); const idat = animated.indexOf('IDAT'); animated.write('acTL', idat);
assert.throws(() => inspectPluginBrandIcon(animated, 'logo.png'), /Animated/);
});
test('directory validation, packing and archive validation include both brand resources', async (t) => {
const root = await mkdtemp(path.join(tmpdir(), 'netcatty-brand-'));
t.after(() => rm(root, { recursive: true, force: true }));
const dir = path.join(root, 'plugin'); await mkdir(path.join(dir, 'assets'), { recursive: true });
await writeFile(path.join(dir, 'index.js'), 'export function activate() {}');
const m = { ...manifest, main: { browser: 'index.js' }, contributes: undefined, activationEvents: [] };
await writeFile(path.join(dir, 'netcatty.plugin.json'), JSON.stringify(m));
await writeFile(path.join(dir, 'assets/logo-light.png'), png);
await assert.rejects(validatePluginDirectory(dir), /missing package file/);
await writeFile(path.join(dir, 'assets/logo-dark.png'), png);
const archive = path.join(root, 'plugin.ncpkg');
await buildPluginPackage(dir, archive);
assert.deepEqual((await validatePluginPackage(archive)).manifest.icon, m.icon);
await writeFile(path.join(dir, 'assets/logo-dark.png'), Buffer.alloc(256 * 1024 + 1));
await assert.rejects(validatePluginDirectory(dir), /size limit/);
});
test('undeclared PNG assets remain streamed even when their names sort before the manifest', async (t) => {
const root = await mkdtemp(path.join(tmpdir(), 'netcatty-brand-stream-'));
t.after(() => rm(root, { recursive: true, force: true }));
const dir = path.join(root, 'plugin'); await mkdir(dir);
const { icon: _icon, features: _features, ...plain } = manifest;
await writeFile(path.join(dir, 'netcatty.plugin.json'), JSON.stringify({ ...plain, main: { browser: 'index.js' }, contributes: undefined, activationEvents: [] }));
await writeFile(path.join(dir, 'index.js'), 'export function activate() {}');
for (let i = 0; i < 16; i++) await writeFile(path.join(dir, `a-${i}.png`), Buffer.alloc(240 * 1024));
const archive = path.join(root, 'no-brand.ncpkg'); await buildPluginPackage(dir, archive);
// Instrument the explicit byte aggregation boundary, not RSS/GC timing.
const originalConcat = Buffer.concat; let retainedImageBodies = 0;
Buffer.concat = (list, totalLength) => {
if (list.reduce((sum, item) => sum + item.length, 0) >= 240 * 1024) retainedImageBodies++;
return originalConcat(list, totalLength);
};
try {
const result = await validatePluginPackage(archive);
assert.equal(result.fileCount, 18);
assert.equal(retainedImageBodies, 0, 'unreferenced images must never be concatenated and retained');
} finally { Buffer.concat = originalConcat; }
});
+2 -2
View File
@@ -30,7 +30,7 @@ export async function initPlugin(
const activationMessage = JSON.stringify(`${displayName} activated`);
const packageName = options.id.replaceAll(".", "-");
const manifest: PluginManifest = {
$schema: "https://netcatty.com/schemas/plugins/0.1.0-internal/plugin-contract.schema.json",
$schema: "https://netcatty.com/schemas/plugins/0.1.0-internal.1/plugin-contract.schema.json",
manifestVersion: 1,
id: options.id,
name: displayName,
@@ -61,7 +61,7 @@ export async function initPlugin(
private: true,
type: "module",
scripts: { build: "tsc -p tsconfig.json" },
dependencies: { "@netcatty/plugin-sdk": "0.1.0-internal" },
dependencies: { "@netcatty/plugin-sdk": "0.1.0-internal.1" },
devDependencies: { typescript: "^5.9.0" },
}, null, 2)}\n`,
"utf8",
+8 -2
View File
@@ -3,7 +3,7 @@ import {
} from "@netcatty/plugin-contract";
import { satisfies, valid, validRange } from "semver";
const DEFAULT_PLUGIN_API_VERSION = "0.1.0-internal";
const DEFAULT_PLUGIN_API_VERSION = "0.1.0-internal.1";
export interface PluginCompatibilityTarget {
readonly netcattyVersion: string;
@@ -43,7 +43,13 @@ export function checkPluginCompatibility(
manifest: PluginManifest,
target: PluginCompatibilityTarget,
): PluginCompatibilityResult {
const apiVersion = target.apiVersion ?? DEFAULT_PLUGIN_API_VERSION;
let apiVersion = target.apiVersion ?? DEFAULT_PLUGIN_API_VERSION;
// This host still offers the previous, unchanged wire contract to legacy packages.
// Never negotiate new brand metadata down to a host revision that cannot validate it.
if (apiVersion === DEFAULT_PLUGIN_API_VERSION && !manifest.icon
&& !satisfies(apiVersion, manifest.engines.api) && satisfies('0.1.0-internal', manifest.engines.api)) {
apiVersion = '0.1.0-internal';
}
const errors: string[] = [];
checkEngineVersion("Netcatty", target.netcattyVersion, manifest.engines.netcatty, errors);
checkEngineVersion("plugin API", apiVersion, manifest.engines.api, errors);
@@ -0,0 +1,15 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { readFile } from 'node:fs/promises';
import { validateManifestValue } from './manifest.js';
import { checkPluginCompatibility } from './compatibility.js';
const manifest = JSON.parse(await readFile(new URL('../../../examples/plugins/host-interactions/netcatty.plugin.json', import.meta.url), 'utf8'));
test('interactive command budget requires feature negotiation without changing ordinary command admission', () => {
assert.equal(validateManifestValue(manifest).valid, true);
assert.equal(validateManifestValue({ ...manifest, features: { required: ['host.interactions'] } }).valid, false);
assert.equal(checkPluginCompatibility(manifest, { netcattyVersion: '1.0.0', features: ['host.interactions'] }).compatible, false);
assert.equal(checkPluginCompatibility(manifest, { netcattyVersion: '1.0.0', features: ['host.interactions', 'commands.interactive'] }).compatible, true);
const legacy = structuredClone(manifest); delete legacy.contributes.commands[0].interactive; legacy.features.required = ['host.interactions'];
assert.equal(validateManifestValue(legacy).valid, true);
});
+11 -1
View File
@@ -15,7 +15,7 @@ import type {
} from "@netcatty/plugin-contract";
import Ajv2020, { type ErrorObject } from "ajv/dist/2020.js";
import addFormats from "ajv-formats";
import { valid, validRange } from "semver";
import { satisfies, valid, validRange } from "semver";
import { PACKAGE_LIMITS } from "./constants.js";
import { assertSafePackagePath } from "./packagePath.js";
@@ -367,6 +367,15 @@ function validateSemantics(manifest: PluginManifest): string[] {
}
const requiredFeatures = new Set(manifest.features?.required ?? []);
if (manifest.contributes?.commands?.some((command) => command.interactive)
&& !requiredFeatures.has('commands.interactive')) errors.push('Interactive commands require commands.interactive');
if (manifest.icon) {
if (!requiredFeatures.has('metadata.brand-icon')) errors.push('Brand icons require metadata.brand-icon');
if (!validRange(manifest.engines.api) || !satisfies('0.1.0-internal.1', manifest.engines.api) || satisfies('0.1.0-internal', manifest.engines.api)) errors.push('Brand icons require API >=0.1.0-internal.1 excluding the legacy revision');
for (const iconPath of packageIconPaths(manifest.icon)) {
if (!/\.(png|webp)$/.test(iconPath)) errors.push('Brand icons require PNG or WebP paths');
}
}
for (const feature of manifest.features?.optional ?? []) {
if (requiredFeatures.has(feature)) {
errors.push(`Feature cannot be both required and optional: ${feature}`);
@@ -672,6 +681,7 @@ function validateSemantics(manifest: PluginManifest): string[] {
}
for (const entryPath of [
...packageIconPaths(manifest.icon),
manifest.main.browser,
manifest.main.node,
...(manifest.contributes?.views ?? []).map(({ entry }) => entry),
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@netcatty/plugin-contract",
"version": "0.1.0-internal",
"version": "0.1.0-internal.1",
"private": true,
"type": "module",
"license": "GPL-3.0-or-later",
@@ -1,6 +1,6 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://netcatty.com/schemas/plugins/0.1.0-internal/plugin-contract.schema.json",
"$id": "https://netcatty.com/schemas/plugins/0.1.0-internal.1/plugin-contract.schema.json",
"title": "Netcatty Plugin Contract",
"description": "Canonical public contract for Netcatty plugin manifests and host RPC messages.",
"$ref": "#/$defs/PluginManifest",
@@ -178,6 +178,436 @@
},
"additionalProperties": false
},
"HostInteractionLimits": {
"const": {
"textBytes": 4096,
"requestBytes": 65536,
"items": 100,
"pendingPerRuntime": 1,
"pendingTotal": 8,
"requestsPerMinute": 10,
"timeoutMs": 60000
}
},
"HostInteractionFeature": {
"const": "host.interactions"
},
"InteractiveCommandFeature": {
"const": "commands.interactive"
},
"InteractiveCommandLimits": {
"const": { "timeoutMs": 180000 }
},
"HostQuickPickItem": {
"type": "object",
"required": [
"id",
"label"
],
"properties": {
"id": {
"type": "string",
"minLength": 1,
"maxLength": 128
},
"label": {
"type": "string",
"minLength": 1,
"maxLength": 512
},
"description": {
"type": "string",
"maxLength": 512
}
},
"additionalProperties": false
},
"HostQuickPickRequest": {
"type": "object",
"required": [
"kind",
"title",
"items"
],
"properties": {
"kind": {
"const": "quickPick"
},
"title": {
"type": "string",
"minLength": 1,
"maxLength": 128,
"pattern": "^[^\\u0000-\\u001f\\u007f]*$"
},
"items": {
"type": "array",
"minItems": 1,
"maxItems": 100,
"items": {
"$ref": "#/$defs/HostQuickPickItem"
}
}
},
"additionalProperties": false
},
"HostSingleLineInputText": {
"description": "Single-line host input. CR and LF are rejected before display and on submission; clipboard text remains multiline.",
"type": "string",
"maxLength": 4096,
"pattern": "^[^\\u0000\\r\\n]*$"
},
"HostInputRequest": {
"type": "object",
"required": [
"kind",
"title"
],
"properties": {
"kind": {
"const": "input"
},
"title": {
"type": "string",
"minLength": 1,
"maxLength": 128,
"pattern": "^[^\\u0000-\\u001f\\u007f]*$"
},
"initialValue": { "$ref": "#/$defs/HostSingleLineInputText" }
},
"additionalProperties": false
},
"HostClipboardReadRequest": {
"type": "object",
"required": [
"kind",
"title"
],
"properties": {
"kind": {
"const": "clipboard.read"
},
"title": {
"type": "string",
"minLength": 1,
"maxLength": 128,
"pattern": "^[^\\u0000-\\u001f\\u007f]*$"
}
},
"additionalProperties": false
},
"HostClipboardWriteRequest": {
"type": "object",
"required": [
"kind",
"title",
"text"
],
"properties": {
"kind": {
"const": "clipboard.write"
},
"title": {
"type": "string",
"minLength": 1,
"maxLength": 128,
"pattern": "^[^\\u0000-\\u001f\\u007f]*$"
},
"text": {
"type": "string",
"maxLength": 4096,
"pattern": "^[^\\u0000]*$"
}
},
"additionalProperties": false
},
"HostInteractionRequest": {
"oneOf": [
{
"$ref": "#/$defs/HostQuickPickRequest"
},
{
"$ref": "#/$defs/HostInputRequest"
},
{
"$ref": "#/$defs/HostClipboardReadRequest"
},
{
"$ref": "#/$defs/HostClipboardWriteRequest"
}
]
},
"HostInteractionResult": {
"oneOf": [
{
"type": "object",
"required": [
"cancelled"
],
"properties": {
"cancelled": {
"const": true
}
},
"additionalProperties": false
},
{
"type": "object",
"required": [
"cancelled",
"value"
],
"properties": {
"cancelled": {
"const": false
},
"value": {
"oneOf": [
{
"type": "string",
"maxLength": 4096,
"pattern": "^[^\\u0000]*$"
},
{
"const": true
}
]
}
},
"additionalProperties": false
}
]
},
"HostInteractionResponse": {
"type": "object",
"required": [
"requestId",
"actionToken",
"action"
],
"properties": {
"requestId": {
"type": "string",
"minLength": 1,
"maxLength": 128
},
"actionToken": {
"type": "string",
"minLength": 32,
"maxLength": 256
},
"action": {
"enum": [
"cancel",
"preview",
"submit"
]
},
"value": {
"type": "string",
"maxLength": 4096,
"pattern": "^[^\\u0000]*$"
}
},
"additionalProperties": false
},
"HostInteractionClosed": {
"type": "object",
"required": [
"requestId",
"closed"
],
"properties": {
"requestId": {
"type": "string",
"maxLength": 128
},
"closed": {
"const": true
}
},
"additionalProperties": false
},
"HostInteractionEvent": {
"oneOf": [
{
"$ref": "#/$defs/HostInteractionPrompt"
},
{
"$ref": "#/$defs/HostInteractionClosed"
}
]
},
"HostInteractionPrompt": {
"type": "object",
"required": [
"requestId",
"actionToken",
"pluginId",
"pluginVersion",
"expiresAt",
"phase",
"request"
],
"properties": {
"requestId": {
"type": "string",
"maxLength": 128
},
"actionToken": {
"type": "string",
"maxLength": 256
},
"pluginId": {
"$ref": "#/$defs/PluginId"
},
"pluginVersion": {
"$ref": "#/$defs/SemanticVersion"
},
"expiresAt": {
"$ref": "#/$defs/SafePositiveInteger"
},
"phase": {
"enum": [
"request",
"preview"
]
},
"request": {
"$ref": "#/$defs/HostInteractionRequest"
},
"previewText": {
"type": "string",
"maxLength": 4096,
"pattern": "^[^\\u0000]*$"
}
},
"additionalProperties": false
},
"NetworkCredentialLeaseFeature": {
"const": "network.credential-lease"
},
"PluginNetworkAuthorization": {
"type": "object",
"properties": {
"scheme": {
"type": "string",
"enum": [
"Basic",
"Bearer"
]
},
"username": {
"type": "string",
"maxLength": 8192,
"pattern": "^[^\\u0000-\\u001f\\u007f]*$"
}
},
"additionalProperties": false
},
"PluginNetworkRequest": {
"description": "Origin-authorized HTTP request. The host consumes a one-use credentialLease for network:<canonical origin>; plaintext credentials never return to the plugin.",
"type": "object",
"required": [
"url"
],
"properties": {
"url": {
"type": "string",
"minLength": 1,
"maxLength": 8192
},
"method": {
"type": "string",
"enum": [
"GET",
"POST",
"PUT",
"PATCH",
"DELETE",
"HEAD"
]
},
"headers": {
"type": "object",
"maxProperties": 64,
"additionalProperties": {
"type": "string",
"maxLength": 8192
}
},
"body": {
"type": "object",
"required": [
"encoding",
"data"
],
"properties": {
"encoding": {
"enum": [
"utf8",
"base64"
]
},
"data": {
"type": "string"
}
},
"additionalProperties": false
},
"timeoutMs": {
"type": "integer",
"minimum": 1,
"maximum": 300000
},
"credentialLease": {
"$ref": "#/$defs/SecretLeaseRef"
},
"authorization": {
"$ref": "#/$defs/PluginNetworkAuthorization"
}
},
"additionalProperties": false
},
"PluginNetworkResponse": {
"type": "object",
"required": [
"url",
"status",
"headers",
"body"
],
"properties": {
"url": {
"type": "string"
},
"status": {
"type": "integer"
},
"headers": {
"type": "object",
"additionalProperties": {
"type": "string"
}
},
"body": {
"type": "object",
"required": [
"encoding",
"data"
],
"properties": {
"encoding": {
"const": "base64"
},
"data": {
"type": "string"
}
},
"additionalProperties": false
}
},
"additionalProperties": false
},
"SemanticVersion": {
"type": "string",
"pattern": "^(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)(?:-(?:0|[1-9]\\d*|\\d*[A-Za-z-][0-9A-Za-z-]*)(?:\\.(?:0|[1-9]\\d*|\\d*[A-Za-z-][0-9A-Za-z-]*))*)?(?:\\+[0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*)?$"
@@ -210,6 +640,7 @@
"commands",
"menus",
"views",
"ui.interact",
"clipboard.read",
"clipboard.write",
"terminal.metadata",
@@ -247,6 +678,7 @@
"commands",
"menus",
"views",
"ui.interact",
"clipboard.read",
"clipboard.write",
"terminal.metadata",
@@ -523,7 +955,8 @@
"category": { "$ref": "#/$defs/LocalizedText" },
"description": { "$ref": "#/$defs/LocalizedText" },
"icon": { "$ref": "#/$defs/IconReference" },
"enablement": { "$ref": "#/$defs/ContextKeyExpression" }
"enablement": { "$ref": "#/$defs/ContextKeyExpression" },
"interactive": { "type": "boolean", "default": false, "description": "Require commands.interactive for this command's fixed bounded host interaction budget. Does not grant UI or clipboard permissions." }
},
"additionalProperties": false
},
@@ -1478,6 +1911,7 @@
"version": { "$ref": "#/$defs/SemanticVersion" },
"publisher": { "type": "string", "minLength": 1, "maxLength": 128 },
"license": { "type": "string", "maxLength": 128 },
"icon": { "$ref": "#/$defs/IconReference" },
"homepage": { "type": "string", "format": "uri" },
"repository": { "type": "string", "format": "uri" },
"engines": { "$ref": "#/$defs/PluginEngines" },
+55
View File
@@ -0,0 +1,55 @@
/** Brand metadata only: static, bounded raster input. Decoding still runs in isolation. */
export const PLUGIN_BRAND_ICON_FEATURE = 'metadata.brand-icon';
export const PLUGIN_BRAND_ICON_MAX_BYTES = 256 * 1024;
export const PLUGIN_BRAND_ICON_MAX_EDGE = 512;
export function inspectPluginBrandIcon(bytes: Uint8Array, packagePath: string): { mimeType: string; width: number; height: number } {
if (!bytes.length || bytes.length > PLUGIN_BRAND_ICON_MAX_BYTES) throw new Error('Plugin brand icon exceeds size limit or is empty');
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
const ascii = (start: number, count: number) => String.fromCharCode(...bytes.subarray(start, start + count));
let width = 0; let height = 0; let mimeType = '';
if (packagePath.endsWith('.png') && bytes.length >= 33 && [137,80,78,71,13,10,26,10].every((v, i) => bytes[i] === v)) {
mimeType = 'image/png';
let end = false; let data = false;
for (let offset = 8; offset < bytes.length;) {
if (offset + 12 > bytes.length) throw new Error('Invalid PNG chunk');
const size = view.getUint32(offset); const kind = ascii(offset + 4, 4);
if (offset + 12 + size > bytes.length) throw new Error('Invalid PNG length');
if (kind === 'acTL' || kind === 'fcTL' || kind === 'fdAT') throw new Error('Animated brand icons are unsupported');
if (offset === 8) {
if (kind !== 'IHDR' || size !== 13) throw new Error('Invalid PNG header');
width = view.getUint32(offset + 8); height = view.getUint32(offset + 12);
} else if (kind === 'IHDR') throw new Error('Duplicate PNG header');
if (kind === 'IDAT') data = true;
offset += 12 + size;
if (kind === 'IEND') { end = size === 0 && offset === bytes.length; break; }
}
if (!end || !data) throw new Error('Incomplete PNG icon');
} else if (packagePath.endsWith('.webp') && bytes.length >= 30 && ascii(0, 4) === 'RIFF' && ascii(8, 4) === 'WEBP') {
mimeType = 'image/webp';
if (view.getUint32(4, true) + 8 !== bytes.length) throw new Error('Invalid WebP length');
let image = false;
for (let offset = 12; offset < bytes.length;) {
if (offset + 8 > bytes.length) throw new Error('Invalid WebP chunk');
const kind = ascii(offset, 4); const size = view.getUint32(offset + 4, true); const start = offset + 8;
if (start + size + (size % 2) > bytes.length) throw new Error('Invalid WebP chunk length');
if (kind === 'ANIM' || kind === 'ANMF' || (kind === 'VP8X' && (bytes[start] & 2))) throw new Error('Animated brand icons are unsupported');
if (kind === 'VP8X' && size === 10) {
width = 1 + bytes[start+4] + (bytes[start+5]<<8) + (bytes[start+6]<<16);
height = 1 + bytes[start+7] + (bytes[start+8]<<8) + (bytes[start+9]<<16);
} else if (kind === 'VP8L' && size >= 5 && bytes[start] === 0x2f) {
const bits = view.getUint32(start + 1, true);
const w = 1 + (bits & 0x3fff); const h = 1 + ((bits >>> 14) & 0x3fff);
if (w > 512 || h > 512) throw new Error('WebP image dimensions exceed limit');
width ||= w; height ||= h; image = true;
} else if (kind === 'VP8 ' && size >= 10 && bytes[start+3] === 0x9d && bytes[start+4] === 1 && bytes[start+5] === 0x2a) {
const w = view.getUint16(start+6, true) & 0x3fff; const h = view.getUint16(start+8, true) & 0x3fff;
if (w > 512 || h > 512) throw new Error('WebP image dimensions exceed limit');
width ||= w; height ||= h; image = true;
}
offset = start + size + (size % 2);
}
if (!image) throw new Error('Missing WebP image');
} else throw new Error('Brand icons require matching PNG or WebP bytes and extension');
if (width < 1 || height < 1 || width > PLUGIN_BRAND_ICON_MAX_EDGE || height > PLUGIN_BRAND_ICON_MAX_EDGE) throw new Error('Plugin brand icon dimensions exceed 512px or are invalid');
return { mimeType, width, height };
}
@@ -6,6 +6,7 @@ export const PLUGIN_JSON_MAX_DEPTH = 128 as const;
export const PLUGIN_JSON_MAX_NODES = 100000 as const;
export const PLUGIN_WIRE_MAX_SAFE_INTEGER = 9007199254740991 as const;
export const PLUGIN_RPC_MAX_JSON_BYTES = 1048576 as const;
export const PLUGIN_HOST_INTERACTION_LIMITS = {"textBytes":4096,"requestBytes":65536,"items":100,"pendingPerRuntime":1,"pendingTotal":8,"requestsPerMinute":10,"timeoutMs":60000} as const;
export const PLUGIN_RPC_ERROR_CODES = [-32700,-32600,-32601,-32602,-32603,-32001,-32002,-32003,-32004,-32005,-32006,-32007,-32008,-32009,-32010,-32011,-32012,-32013,-32014,-32015,-32016] as const;
export const PLUGIN_STREAM_MAX_ID_LENGTH = 128 as const;
export const PLUGIN_STREAM_MAX_CHUNK_BYTES = 16777216 as const;
@@ -80,6 +80,7 @@ export type CommandContribution = {
description?: LocalizedText;
icon?: IconReference;
enablement?: ContextKeyExpression;
interactive?: boolean;
};
export type CompanionExecutable = {
@@ -167,6 +168,75 @@ export type CredentialRef = {
export type FeatureId = string;
export type HostClipboardReadRequest = {
kind: "clipboard.read";
title: string;
};
export type HostClipboardWriteRequest = {
kind: "clipboard.write";
title: string;
text: string;
};
export type HostInputRequest = {
kind: "input";
title: string;
initialValue?: HostSingleLineInputText;
};
export type HostInteractionClosed = {
requestId: string;
closed: true;
};
export type HostInteractionEvent = (HostInteractionPrompt) | (HostInteractionClosed);
export type HostInteractionFeature = "host.interactions";
export type HostInteractionLimits = {"textBytes":4096,"requestBytes":65536,"items":100,"pendingPerRuntime":1,"pendingTotal":8,"requestsPerMinute":10,"timeoutMs":60000};
export type HostInteractionPrompt = {
requestId: string;
actionToken: string;
pluginId: PluginId;
pluginVersion: SemanticVersion;
expiresAt: SafePositiveInteger;
phase: "request" | "preview";
request: HostInteractionRequest;
previewText?: string;
};
export type HostInteractionRequest = (HostQuickPickRequest) | (HostInputRequest) | (HostClipboardReadRequest) | (HostClipboardWriteRequest);
export type HostInteractionResponse = {
requestId: string;
actionToken: string;
action: "cancel" | "preview" | "submit";
value?: string;
};
export type HostInteractionResult = ({
cancelled: true;
}) | ({
cancelled: false;
value: (string) | (true);
});
export type HostQuickPickItem = {
id: string;
label: string;
description?: string;
};
export type HostQuickPickRequest = {
kind: "quickPick";
title: string;
items: Array<HostQuickPickItem>;
};
export type HostSingleLineInputText = string;
export type IconReference = (ThemeIcon) | (PackageIcon);
export type ImporterDetectPayload = {
@@ -319,6 +389,10 @@ export type ImporterSnippetDraft = {
description?: string;
};
export type InteractiveCommandFeature = "commands.interactive";
export type InteractiveCommandLimits = {"timeoutMs":180000};
export type JsonPrimitive = (string) | (number) | (boolean) | (null);
export type JsonRpcStandardErrorCode = -32700 | -32600 | -32601 | -32602 | -32603;
@@ -355,7 +429,9 @@ export type MenuContribution = {
export type MenuLocation = "commandPalette" | "application" | "host/context" | "terminal/context" | "terminal/toolbar" | "statusBar";
export type NonResourceScopedPermission = "storage" | "runtime.advanced" | "settings.read" | "settings.write" | "commands" | "menus" | "views" | "clipboard.read" | "clipboard.write" | "terminal.metadata" | "terminal.output" | "terminal.input" | "terminal.decorate" | "terminal.complete" | "terminal.intercept.input" | "terminal.intercept.output" | "vault.metadata" | "vault.write" | "vault.credentials" | "sftp.read" | "sftp.write" | "secrets" | "provider.terminal" | "provider.connection" | "provider.authentication" | "provider.sync" | "provider.importer";
export type NetworkCredentialLeaseFeature = "network.credential-lease";
export type NonResourceScopedPermission = "storage" | "runtime.advanced" | "settings.read" | "settings.write" | "commands" | "menus" | "views" | "ui.interact" | "clipboard.read" | "clipboard.write" | "terminal.metadata" | "terminal.output" | "terminal.input" | "terminal.decorate" | "terminal.complete" | "terminal.intercept.input" | "terminal.intercept.output" | "vault.metadata" | "vault.write" | "vault.credentials" | "sftp.read" | "sftp.write" | "secrets" | "provider.terminal" | "provider.connection" | "provider.authentication" | "provider.sync" | "provider.importer";
export type NullableRpcId = (RpcId) | (null);
@@ -461,6 +537,7 @@ export type PluginManifest = {
version: SemanticVersion;
publisher: string;
license?: string;
icon?: IconReference;
homepage?: string;
repository?: string;
engines: PluginEngines;
@@ -480,7 +557,35 @@ export type PluginManifestHeader = ({
engines: PluginEngineHeader;
} & Record<string, unknown>);
export type PluginPermission = "storage" | "runtime.advanced" | "settings.read" | "settings.write" | "commands" | "menus" | "views" | "clipboard.read" | "clipboard.write" | "terminal.metadata" | "terminal.output" | "terminal.input" | "terminal.decorate" | "terminal.complete" | "terminal.intercept.input" | "terminal.intercept.output" | "vault.metadata" | "vault.write" | "vault.credentials" | "sftp.read" | "sftp.write" | "network" | "filesystem.read" | "filesystem.write" | "secrets" | "companion.execute" | "provider.terminal" | "provider.connection" | "provider.authentication" | "provider.sync" | "provider.importer";
export type PluginNetworkAuthorization = {
scheme?: "Basic" | "Bearer";
username?: string;
};
export type PluginNetworkRequest = {
url: string;
method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "HEAD";
headers?: { [key: string]: string };
body?: {
encoding: "utf8" | "base64";
data: string;
};
timeoutMs?: number;
credentialLease?: SecretLeaseRef;
authorization?: PluginNetworkAuthorization;
};
export type PluginNetworkResponse = {
url: string;
status: number;
headers: { [key: string]: string };
body: {
encoding: "base64";
data: string;
};
};
export type PluginPermission = "storage" | "runtime.advanced" | "settings.read" | "settings.write" | "commands" | "menus" | "views" | "ui.interact" | "clipboard.read" | "clipboard.write" | "terminal.metadata" | "terminal.output" | "terminal.input" | "terminal.decorate" | "terminal.complete" | "terminal.intercept.input" | "terminal.intercept.output" | "vault.metadata" | "vault.write" | "vault.credentials" | "sftp.read" | "sftp.write" | "network" | "filesystem.read" | "filesystem.write" | "secrets" | "companion.execute" | "provider.terminal" | "provider.connection" | "provider.authentication" | "provider.sync" | "provider.importer";
export type PluginWireErrorCode = -32001 | -32002 | -32003 | -32004 | -32005 | -32006 | -32007 | -32008 | -32009 | -32010 | -32011 | -32012 | -32013 | -32014 | -32015 | -32016;
Loaded 100 of 105 files, more files were not shown because too many files have changed in this diff. Show more