mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-10-29 18:11:40 +00:00
Compare commits
27
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8e4dd0c052 | ||
|
|
0a692dfc72 | ||
|
|
d4ca123999 | ||
|
|
71d455d5de | ||
|
|
ff3734d161 | ||
|
|
185eac3849 | ||
|
|
9af123e5d7 | ||
|
|
e5bd294cdd | ||
|
|
b48567d448 | ||
|
|
933bc93fc3 | ||
|
|
45ed193579 | ||
|
|
f471a17a75 | ||
|
|
b9293a7bb5 | ||
|
|
257660bcc1 | ||
|
|
eaf8c9a385 | ||
|
|
ff42d0c1b9 | ||
|
|
0adb7b822e | ||
|
|
311765eaa1 | ||
|
|
d2a8398b7a | ||
|
|
69307cee16 | ||
|
|
4445c292ea | ||
|
|
6fad272afe | ||
|
|
a7f8480da1 | ||
|
|
e7e57f2a52 | ||
|
|
ab2f95837e | ||
|
|
f3d04a1912 | ||
|
|
f4a6b205ad |
No files matched your search
@@ -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}>
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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": "Управляйте плагинами и просматривайте каталог.",
|
||||
|
||||
@@ -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": "管理已安装插件,浏览精选目录。",
|
||||
|
||||
@@ -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": "插件市場",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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());
|
||||
});
|
||||
@@ -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(); }); }
|
||||
});
|
||||
@@ -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); }
|
||||
}
|
||||
});
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
@@ -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>/);
|
||||
});
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
@@ -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.", "写入剪贴板", "替换剪贴板内容,可能影响你随后粘贴的数据。"],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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/);
|
||||
}
|
||||
});
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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 };
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(); }
|
||||
});
|
||||
@@ -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"]);
|
||||
});
|
||||
@@ -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]);
|
||||
});
|
||||
@@ -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";
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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\/\*\*\/\*/);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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]]);
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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', {});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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>
|
||||
@@ -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
@@ -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 }>;
|
||||
|
||||
Generated
+11
-11
@@ -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
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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; }
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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,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" },
|
||||
|
||||
@@ -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
Reference in new issue
Block a user