mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-10-30 10:22:33 +00:00
Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
614b7c997a | ||
|
|
aaef0d2ee1 | ||
|
|
5128b51702 | ||
|
|
f5c951f9a1 | ||
|
|
8de33d9b5a | ||
|
|
c727cca643 | ||
|
|
ba4fab5679 | ||
|
|
27e5a10bfc | ||
|
|
3efabf2b0f | ||
|
|
74a2f99b08 | ||
|
|
80525d9b2d | ||
|
|
741ea4e9c7 | ||
|
|
4336f8fd53 | ||
|
|
aa8989c515 | ||
|
|
d5fd56ecdf |
No files matched your search
@@ -49,6 +49,12 @@ jobs:
|
||||
- name: Verify Codex App Server schema
|
||||
run: npm run check:codex-app-server-schema
|
||||
|
||||
- name: Verify plugin contract and runtime boundaries
|
||||
run: |
|
||||
npm run check:plugin-contract
|
||||
npm run test:plugin-runtime
|
||||
node --test --import tsx components/settings/tabs/Plugin*.test.tsx
|
||||
|
||||
- name: Test
|
||||
run: npm test
|
||||
|
||||
|
||||
@@ -106,10 +106,13 @@ Placement rules (`resolveAgentKinds` in `toolSurfaces.cjs`):
|
||||
|
||||
**Harness domain (`catalog/harness.cjs`):** Catty executes these locally in `capabilityTools.executeLocalCattyCapability`. The read-only `harness.terminal.read_context` also exposes `netcatty/readContext` to MCP, with scope validation before and after the existing renderer bridge call. It reuses the sidebar reader through the screen snapshot registry (80 rows by default, maximum 300); no script or command runs. Other harness tools remain Catty-only. `harness.web.search` is omitted when web search is not configured.
|
||||
|
||||
## Plugin host runtime (internal preview)
|
||||
## Plugin host runtime
|
||||
|
||||
The phase-2 plugin host lives under `electron/plugins/` and is disabled unless
|
||||
`NETCATTY_PLUGIN_DEV=1` is present at application launch. Public wire and package
|
||||
The plugin host under `electron/plugins/` exposes browser-only packages in normal
|
||||
Settings. `NETCATTY_PLUGIN_DEV=1` admits advanced development packages, without
|
||||
granting their permissions. Admission and default placement share
|
||||
`domain/pluginRuntimePolicy.cjs`; permission explanations shared with native
|
||||
prompts live in `domain/pluginPermissionDescriptions.cjs`. Public wire and package
|
||||
types still come only from `packages/plugin-contract/schema/`; do not add a
|
||||
second private RPC shape when extending the host.
|
||||
|
||||
|
||||
@@ -1237,7 +1237,7 @@ Highlight the focused split pane:
|
||||
'settings.tab.plugins': 'Plugins',
|
||||
"settings.plugins.manager.installed": "Installed plugins",
|
||||
"settings.plugins.manager.import": "Import .ncpkg",
|
||||
"settings.plugins.manager.preview": "Developer preview. Imported packages remain disabled until you review and enable them.",
|
||||
"settings.plugins.manager.preview": "Imported packages stay disabled until you review and enable them.",
|
||||
"settings.plugins.manager.empty": "Import a local plugin package to get started.",
|
||||
"settings.plugins.manager.enabled": "Enabled",
|
||||
"settings.plugins.manager.disabled": "Disabled",
|
||||
@@ -1246,15 +1246,38 @@ Highlight the focused split pane:
|
||||
"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.manager.permissions": "Declared permissions",
|
||||
"settings.plugins.manager.permissionNote": "Read from the installed manifest. Declarations are not grants; sensitive operations still require host approval.",
|
||||
"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.",
|
||||
"settings.plugins.manager.required": "Required",
|
||||
"settings.plugins.manager.optional": "Optional",
|
||||
"settings.plugins.manager.browser": "Browser runtime: privileged access goes through host permission brokers.",
|
||||
"settings.plugins.manager.advanced": "Advanced runtime: full Node filesystem and network authority. Broker permissions cannot restrict this access. Development preview only.",
|
||||
"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.title': 'Plugin settings',
|
||||
'settings.plugins.description': 'Native, validated settings contributed by development plugins.',
|
||||
"settings.plugins.market.results": "{count} plugins",
|
||||
"settings.plugins.manager.lastError": "Last recorded error",
|
||||
"settings.plugins.market.installed": "Installed",
|
||||
"settings.plugins.market.sourceBadge": "Source example",
|
||||
"settings.plugins.market.title": "Marketplace",
|
||||
"settings.plugins.market.note": "Curated open-source plugins. Review is not a security certification. Updates are manual.",
|
||||
"settings.plugins.market.back": "All plugins",
|
||||
"settings.plugins.market.install": "Install this version (disabled)",
|
||||
"settings.plugins.market.installNote": "This replaces and stops the installed version. Review its permissions in Installed plugins before enabling.",
|
||||
"settings.plugins.market.sourceOnly": "First-party source example. Build with the local CLI and import the .ncpkg; no downloadable release has been published.",
|
||||
"settings.plugins.market.readmeNote": "Pinned to the commit above. HTML is inert and remote images are not loaded. Written by the plugin author.",
|
||||
"settings.plugins.manager.noVault": "System key storage is unavailable. Secret and credential operations are blocked; browsing and non-secret plugins still work.",
|
||||
"settings.plugins.manager.sensitive": "Sensitive access: review the data and resource scope before granting.",
|
||||
"settings.plugins.manager.dual": "Browser is the default runtime. This package also includes a Node entrypoint with full device authority; developer mode only.",
|
||||
"settings.plugins.manager.advancedDeclaration": "Browser entrypoint with advanced permission declarations. Not eligible for normal browser-only mode; review every declared permission.",
|
||||
"settings.plugins.market.retry": "Retry",
|
||||
"settings.plugins.market.empty": "No catalog entries are available. You can still import a local browser plugin.",
|
||||
"settings.plugins.market.emptyReadme": "This README is empty.",
|
||||
"settings.plugins.market.search": "Search name, author or description",
|
||||
"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.openManager": "Open plugin manager",
|
||||
"settings.plugins.title": "Plugins",
|
||||
"settings.plugins.description": "Manage your plugins and explore the catalog.",
|
||||
'settings.plugins.loading': 'Loading plugin settings…',
|
||||
'settings.plugins.empty': 'No enabled plugin contributes settings.',
|
||||
'settings.plugins.scopeContext': 'Open this setting in its {scope} context.',
|
||||
|
||||
@@ -1237,7 +1237,7 @@ Resalta el panel dividido enfocado:
|
||||
'settings.tab.plugins': 'Plugins',
|
||||
"settings.plugins.manager.installed": "Plugins instalados",
|
||||
"settings.plugins.manager.import": "Importar .ncpkg",
|
||||
"settings.plugins.manager.preview": "Vista previa para desarrolladores. Los paquetes importados permanecen desactivados hasta que los revises y actives.",
|
||||
"settings.plugins.manager.preview": "Los paquetes importados permanecen desactivados hasta que los revises y actives.",
|
||||
"settings.plugins.manager.empty": "Importa un paquete local para empezar.",
|
||||
"settings.plugins.manager.enabled": "Activado",
|
||||
"settings.plugins.manager.disabled": "Desactivado",
|
||||
@@ -1246,15 +1246,38 @@ Resalta el panel dividido enfocado:
|
||||
"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.manager.permissions": "Permisos declarados",
|
||||
"settings.plugins.manager.permissionNote": "Obtenidos del manifiesto instalado. Las declaraciones no son autorizaciones; las operaciones sensibles requieren aprobación del anfitrión.",
|
||||
"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.",
|
||||
"settings.plugins.manager.required": "Obligatorio",
|
||||
"settings.plugins.manager.optional": "Opcional",
|
||||
"settings.plugins.manager.browser": "Entorno de navegador: el acceso privilegiado pasa por los controles de permisos del anfitrión.",
|
||||
"settings.plugins.manager.advanced": "Entorno avanzado: acceso completo de Node al sistema de archivos y a la red. Los permisos del intermediario no restringen este acceso. Solo para desarrollo.",
|
||||
"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.title': 'Configuración de plugins',
|
||||
'settings.plugins.description': 'Ajustes nativos y validados que aportan los plugins de desarrollo.',
|
||||
"settings.plugins.market.results": "{count} plugins",
|
||||
"settings.plugins.manager.lastError": "Último error registrado",
|
||||
"settings.plugins.market.installed": "Instalado",
|
||||
"settings.plugins.market.sourceBadge": "Ejemplo de código",
|
||||
"settings.plugins.market.title": "Catálogo",
|
||||
"settings.plugins.market.note": "Plugins de código abierto seleccionados. La revisión no certifica su seguridad. Las actualizaciones son manuales.",
|
||||
"settings.plugins.market.back": "Todos los plugins",
|
||||
"settings.plugins.market.install": "Instalar esta versión (desactivada)",
|
||||
"settings.plugins.market.installNote": "Esto reemplaza y detiene la versión instalada. Revisa sus permisos antes de activarla.",
|
||||
"settings.plugins.market.sourceOnly": "Ejemplo propio con código fuente. Compílalo con la CLI local e importa el .ncpkg; no hay versión publicada para descargar.",
|
||||
"settings.plugins.market.readmeNote": "Fijado al commit indicado. El HTML no se ejecuta y las imágenes remotas no se cargan. Contenido del autor.",
|
||||
"settings.plugins.manager.noVault": "El almacén de claves del sistema no está disponible. Se bloquean secretos y credenciales; el catálogo y los plugins que no los usan siguen disponibles.",
|
||||
"settings.plugins.manager.sensitive": "Acceso sensible: revisa los datos y el alcance antes de autorizar.",
|
||||
"settings.plugins.manager.dual": "El navegador es la opción predeterminada. El paquete también incluye una entrada Node con acceso completo al dispositivo; solo para desarrollo.",
|
||||
"settings.plugins.manager.advancedDeclaration": "Entrada de navegador con permisos avanzados declarados. No se admite en el modo normal; revisa cada permiso.",
|
||||
"settings.plugins.market.retry": "Reintentar",
|
||||
"settings.plugins.market.empty": "No hay entradas en el catálogo. Puedes importar un plugin local de navegador.",
|
||||
"settings.plugins.market.emptyReadme": "Este README está vacío.",
|
||||
"settings.plugins.market.search": "Buscar nombre, autor o descripción",
|
||||
"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.openManager": "Abrir gestor de plugins",
|
||||
"settings.plugins.title": "Plugins",
|
||||
"settings.plugins.description": "Gestiona tus plugins y explora el catálogo.",
|
||||
'settings.plugins.loading': 'Cargando ajustes del plugin…',
|
||||
'settings.plugins.empty': 'Ningún plugin activado aporta ajustes.',
|
||||
'settings.plugins.scopeContext': 'Abre este ajuste en su contexto de {scope}.',
|
||||
|
||||
@@ -1132,7 +1132,7 @@ export const ruCoreMessages: Messages = {
|
||||
'settings.tab.plugins': 'Плагины',
|
||||
"settings.plugins.manager.installed": "Установленные плагины",
|
||||
"settings.plugins.manager.import": "Импорт .ncpkg",
|
||||
"settings.plugins.manager.preview": "Предварительная версия для разработчиков. Импортированные пакеты отключены до проверки и включения.",
|
||||
"settings.plugins.manager.preview": "Импортированные пакеты отключены до проверки и включения.",
|
||||
"settings.plugins.manager.empty": "Импортируйте локальный пакет плагина.",
|
||||
"settings.plugins.manager.enabled": "Включён",
|
||||
"settings.plugins.manager.disabled": "Отключён",
|
||||
@@ -1141,15 +1141,38 @@ export const ruCoreMessages: Messages = {
|
||||
"settings.plugins.manager.remove": "Удалить",
|
||||
"settings.plugins.manager.removeNote": "Удаление очищает код пакета и локальные данные KV. Настройки пользователя, секреты и данные синхронизации сохраняются.",
|
||||
"settings.plugins.manager.permissions": "Заявленные разрешения",
|
||||
"settings.plugins.manager.permissionNote": "Из установленного манифеста. Объявление не является разрешением; чувствительные операции требуют одобрения приложения.",
|
||||
"settings.plugins.manager.permissionNote": "Из манифеста, а не предоставленные права. Включение допускает отложенную активацию; обязательные разрешения запрашиваются перед ней, необязательные — при использовании. Чувствительные операции проверяются приложением.",
|
||||
"settings.plugins.manager.noPermissions": "Привилегированные разрешения не заявлены.",
|
||||
"settings.plugins.manager.required": "Обязательно",
|
||||
"settings.plugins.manager.optional": "Необязательно",
|
||||
"settings.plugins.manager.browser": "Браузерная среда: привилегированный доступ проходит через проверку разрешений приложения.",
|
||||
"settings.plugins.manager.advanced": "Расширенная среда: полный доступ Node к файловой системе и сети. Разрешения посредника не ограничивают этот доступ. Только для разработки.",
|
||||
"settings.plugins.manager.data": "Данные KV остаются на устройстве. Синхронизируются только явно отмеченные несекретные настройки в зашифрованных данных. Пакеты и разрешения не синхронизируются.",
|
||||
'settings.plugins.title': 'Настройки плагинов',
|
||||
'settings.plugins.description': 'Проверенные нативные настройки от плагинов разработки.',
|
||||
"settings.plugins.market.results": "Плагинов: {count}",
|
||||
"settings.plugins.manager.lastError": "Последняя записанная ошибка",
|
||||
"settings.plugins.market.installed": "Установлен",
|
||||
"settings.plugins.market.sourceBadge": "Пример исходного кода",
|
||||
"settings.plugins.market.title": "Каталог",
|
||||
"settings.plugins.market.note": "Отобранные плагины с открытым кодом. Проверка не гарантирует безопасность. Обновления выполняются вручную.",
|
||||
"settings.plugins.market.back": "Все плагины",
|
||||
"settings.plugins.market.install": "Установить эту версию (отключённой)",
|
||||
"settings.plugins.market.installNote": "Установленная версия будет заменена и остановлена. Проверьте разрешения перед включением.",
|
||||
"settings.plugins.market.sourceOnly": "Пример из этого проекта. Соберите локальной CLI и импортируйте .ncpkg; готовый пакет для скачивания не опубликован.",
|
||||
"settings.plugins.market.readmeNote": "Зафиксирован указанный коммит. HTML не выполняется, внешние изображения не загружаются. Текст автора плагина.",
|
||||
"settings.plugins.manager.noVault": "Системное хранилище ключей недоступно. Операции с секретами и учётными данными заблокированы; каталог и плагины без секретов доступны.",
|
||||
"settings.plugins.manager.sensitive": "Чувствительный доступ: проверьте данные и область ресурсов перед разрешением.",
|
||||
"settings.plugins.manager.dual": "По умолчанию используется браузерная среда. Пакет также содержит вход Node с полным доступом к устройству; только для разработчиков.",
|
||||
"settings.plugins.manager.advancedDeclaration": "Браузерная точка входа с заявленными расширенными разрешениями. Не допускается в обычном режиме; проверьте все разрешения.",
|
||||
"settings.plugins.market.retry": "Повторить",
|
||||
"settings.plugins.market.empty": "Каталог пуст. Можно импортировать локальный браузерный плагин.",
|
||||
"settings.plugins.market.emptyReadme": "Этот README пуст.",
|
||||
"settings.plugins.market.search": "Поиск по имени, автору или описанию",
|
||||
"settings.plugins.market.noResults": "Совпадений нет. Измените запрос.",
|
||||
"settings.plugins.market.manifest": "Совместимость и разрешения",
|
||||
"settings.plugins.manager.unbounded": "Более узкая область ресурсов не заявлена. Проверьте фактическую область в запросе разрешения.",
|
||||
"settings.plugins.openManager": "Открыть управление плагинами",
|
||||
"settings.plugins.title": "Плагины",
|
||||
"settings.plugins.description": "Управляйте плагинами и просматривайте каталог.",
|
||||
'settings.plugins.loading': 'Загрузка настроек плагинов…',
|
||||
'settings.plugins.empty': 'Включённые плагины не предоставляют настройки.',
|
||||
'settings.plugins.scopeContext': 'Откройте эту настройку в контексте {scope}.',
|
||||
|
||||
@@ -1152,7 +1152,7 @@ Vaults 标签保持扁平,不使用这块背景。
|
||||
'settings.tab.plugins': '插件',
|
||||
"settings.plugins.manager.installed": "已安装插件",
|
||||
"settings.plugins.manager.import": "导入 .ncpkg",
|
||||
"settings.plugins.manager.preview": "开发者预览。导入的插件保持停用,请检查后再启用。",
|
||||
"settings.plugins.manager.preview": "导入的插件保持停用,请检查后再启用。",
|
||||
"settings.plugins.manager.empty": "导入本地插件包以开始使用。",
|
||||
"settings.plugins.manager.enabled": "已启用",
|
||||
"settings.plugins.manager.disabled": "已停用",
|
||||
@@ -1161,15 +1161,38 @@ Vaults 标签保持扁平,不使用这块背景。
|
||||
"settings.plugins.manager.remove": "卸载",
|
||||
"settings.plugins.manager.removeNote": "卸载会移除插件代码和本地 KV 数据,保留用户设置、秘密存储和同步 sidecar。",
|
||||
"settings.plugins.manager.permissions": "声明的权限",
|
||||
"settings.plugins.manager.permissionNote": "来自已安装的 manifest。声明不等于授权,敏感操作仍须通过宿主批准。",
|
||||
"settings.plugins.manager.permissionNote": "来自 manifest,不代表已授权。启用表示允许按需激活;必需权限在首次激活前申请,可选权限在用到时申请。敏感操作仍受宿主检查。",
|
||||
"settings.plugins.manager.noPermissions": "未声明特权权限。",
|
||||
"settings.plugins.manager.required": "必需",
|
||||
"settings.plugins.manager.optional": "可选",
|
||||
"settings.plugins.manager.browser": "浏览器运行时:特权访问通过宿主权限代理。",
|
||||
"settings.plugins.manager.advanced": "高级运行时:拥有完整 Node 文件系统和网络能力,代理权限无法限制这些访问。仅供开发预览。",
|
||||
"settings.plugins.manager.data": "本地 KV 仅保留在此设备。只有明确标记为同步的非秘密设置进入加密云 sidecar。插件包和权限授权不参与同步。",
|
||||
'settings.plugins.title': '插件设置',
|
||||
'settings.plugins.description': '由开发插件提供、经 Netcatty 原生组件验证的设置。',
|
||||
"settings.plugins.market.results": "{count} 个插件",
|
||||
"settings.plugins.manager.lastError": "上次记录的错误",
|
||||
"settings.plugins.market.installed": "已安装",
|
||||
"settings.plugins.market.sourceBadge": "源码示例",
|
||||
"settings.plugins.market.title": "插件市场",
|
||||
"settings.plugins.market.note": "精选开源插件。审核不代表安全认证,更新由你手动确认。",
|
||||
"settings.plugins.market.back": "所有插件",
|
||||
"settings.plugins.market.install": "安装此版本(保持停用)",
|
||||
"settings.plugins.market.installNote": "这会替换并停止已安装版本。请在已安装列表检查权限后再启用。",
|
||||
"settings.plugins.market.sourceOnly": "第一方源码示例。请用本地 CLI 构建并导入 .ncpkg;尚未发布可下载发行包。",
|
||||
"settings.plugins.market.readmeNote": "固定到上面的提交。HTML 不执行,远程图片不加载。内容由插件作者提供。",
|
||||
"settings.plugins.manager.noVault": "系统密钥存储不可用。秘密和凭据操作被阻止;浏览市场和不使用秘密的插件仍可使用。",
|
||||
"settings.plugins.manager.sensitive": "敏感访问:授权前请检查涉及的数据和资源范围。",
|
||||
"settings.plugins.manager.dual": "默认使用浏览器运行时,但包中还包含拥有完整设备权限的 Node 入口;仅限开发者模式。",
|
||||
"settings.plugins.manager.advancedDeclaration": "浏览器入口声明了高级权限。不适用于普通浏览器插件模式;请逐项检查声明权限。",
|
||||
"settings.plugins.market.retry": "重试",
|
||||
"settings.plugins.market.empty": "目录暂无插件,仍可导入本地浏览器插件。",
|
||||
"settings.plugins.market.emptyReadme": "README 内容为空。",
|
||||
"settings.plugins.market.search": "搜索名称、作者或描述",
|
||||
"settings.plugins.market.noResults": "没有匹配插件,请尝试其他关键词。",
|
||||
"settings.plugins.market.manifest": "兼容性与权限",
|
||||
"settings.plugins.manager.unbounded": "未声明更细的资源范围。请在权限提示中检查实际授权范围。",
|
||||
"settings.plugins.openManager": "打开插件管理",
|
||||
"settings.plugins.title": "插件",
|
||||
"settings.plugins.description": "管理已安装插件,浏览精选目录。",
|
||||
'settings.plugins.loading': '正在加载插件设置…',
|
||||
'settings.plugins.empty': '当前没有已启用插件提供设置。',
|
||||
'settings.plugins.scopeContext': '请在对应的 {scope} 上下文中打开此设置。',
|
||||
|
||||
@@ -1155,7 +1155,7 @@ Vaults 分頁維持扁平,不使用這塊背景。
|
||||
'settings.tab.plugins': '外掛程式',
|
||||
"settings.plugins.manager.installed": "已安裝插件",
|
||||
"settings.plugins.manager.import": "導入 .ncpkg",
|
||||
"settings.plugins.manager.preview": "開發者預覽。導入的插件保持停用,請檢查後再啟用。",
|
||||
"settings.plugins.manager.preview": "導入的插件保持停用,請檢查後再啟用。",
|
||||
"settings.plugins.manager.empty": "導入本地插件包以開始使用。",
|
||||
"settings.plugins.manager.enabled": "已啟用",
|
||||
"settings.plugins.manager.disabled": "已停用",
|
||||
@@ -1164,16 +1164,39 @@ Vaults 分頁維持扁平,不使用這塊背景。
|
||||
"settings.plugins.manager.remove": "卸載",
|
||||
"settings.plugins.manager.removeNote": "卸載會移除插件代碼和本地 KV 數據,保留用戶設置、秘密存儲和同步 sidecar。",
|
||||
"settings.plugins.manager.permissions": "聲明的權限",
|
||||
"settings.plugins.manager.permissionNote": "來自已安裝的 manifest。聲明不等於授權,敏感操作仍須通過宿主批准。",
|
||||
"settings.plugins.manager.permissionNote": "來自 manifest,不代表已授權。啟用表示允許按需激活;必需權限在首次激活前申請,可選權限在用到時申請。敏感操作仍受宿主檢查。",
|
||||
"settings.plugins.manager.noPermissions": "未聲明特權權限。",
|
||||
"settings.plugins.manager.required": "必需",
|
||||
"settings.plugins.manager.optional": "可選",
|
||||
"settings.plugins.manager.browser": "瀏覽器運行時:特權訪問通過宿主權限代理。",
|
||||
"settings.plugins.manager.advanced": "高級運行時:擁有完整 Node 文件系統和網絡能力,代理權限無法限制這些訪問。僅供開發預覽。",
|
||||
"settings.plugins.manager.data": "本地 KV 僅保留在此設備。只有明確標記為同步的非秘密設置進入加密雲 sidecar。插件包和權限授權不參與同步。",
|
||||
'settings.plugins.title': '外掛程式設定',
|
||||
'settings.plugins.description': '由開發外掛程式提供、經 Netcatty 原生元件驗證的設定。',
|
||||
"settings.plugins.manager.unbounded": "未宣告更細的資源範圍。請在權限提示中檢查實際授權範圍。",
|
||||
"settings.plugins.openManager": "開啟插件管理",
|
||||
"settings.plugins.title": "插件",
|
||||
"settings.plugins.description": "管理已安裝插件,瀏覽精選目錄。",
|
||||
'settings.plugins.loading': '正在載入外掛程式設定…',
|
||||
"settings.plugins.market.results": "{count} 個插件",
|
||||
"settings.plugins.manager.lastError": "上次記錄的錯誤",
|
||||
"settings.plugins.market.installed": "已安裝",
|
||||
"settings.plugins.market.sourceBadge": "原始碼範例",
|
||||
"settings.plugins.market.title": "插件市場",
|
||||
"settings.plugins.market.note": "精選開源插件。審核不代表安全認證,更新由你手動確認。",
|
||||
"settings.plugins.market.back": "所有插件",
|
||||
"settings.plugins.market.install": "安裝此版本(保持停用)",
|
||||
"settings.plugins.market.installNote": "這會替換並停止已安裝版本。請在已安裝列表檢查權限後再啟用。",
|
||||
"settings.plugins.market.sourceOnly": "第一方源碼示例。請用本地 CLI 構建並導入 .ncpkg;尚未發佈可下載發行包。",
|
||||
"settings.plugins.market.readmeNote": "固定到上面的提交。HTML 不執行,遠程圖片不加載。內容由插件作者提供。",
|
||||
"settings.plugins.manager.noVault": "系統密鑰存儲不可用。秘密和憑據操作被阻止;瀏覽市場和不使用秘密的插件仍可使用。",
|
||||
"settings.plugins.manager.sensitive": "敏感訪問:授權前請檢查涉及的數據和資源範圍。",
|
||||
"settings.plugins.manager.dual": "默認使用瀏覽器運行時,但包中還包含擁有完整設備權限的 Node 入口;僅限開發者模式。",
|
||||
"settings.plugins.manager.advancedDeclaration": "瀏覽器入口聲明瞭高級權限。不適用於普通瀏覽器插件模式;請逐項檢查聲明權限。",
|
||||
"settings.plugins.market.retry": "重試",
|
||||
"settings.plugins.market.empty": "目錄暫無插件,仍可導入本地瀏覽器插件。",
|
||||
"settings.plugins.market.emptyReadme": "README 內容為空。",
|
||||
"settings.plugins.market.search": "搜索名稱、作者或描述",
|
||||
"settings.plugins.market.noResults": "沒有匹配插件,請嘗試其他關鍵詞。",
|
||||
"settings.plugins.market.manifest": "兼容性與權限",
|
||||
'settings.plugins.empty': '目前沒有已啟用外掛程式提供設定。',
|
||||
'settings.plugins.scopeContext': '請在對應的 {scope} 情境中開啟此設定。',
|
||||
'settings.plugins.pressKeybinding': '請按下組合鍵',
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
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 { usePluginCatalog } from './usePluginCatalog';
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (cause: Error) => void;
|
||||
const promise = new Promise<T>((yes, no) => { resolve = yes; reject = no; });
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
test('README failures clear on retry/switch and stale requests cannot replace the selected entry', async (context) => {
|
||||
const original = Object.getOwnPropertyDescriptor(globalThis, 'window');
|
||||
const calls: Array<{ id: string; request: ReturnType<typeof deferred<string>> }> = [];
|
||||
Object.defineProperty(globalThis, 'window', { configurable: true, value: {
|
||||
netcatty: {
|
||||
listPluginCatalog: async () => [],
|
||||
getPluginCatalogReadme: (id: string) => { const request = deferred<string>(); calls.push({ id, request }); return request.promise; },
|
||||
},
|
||||
} });
|
||||
(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>;
|
||||
let renders = 0;
|
||||
function Probe({ id }: { id: string }) { state = usePluginCatalog(id); renders++; return null; }
|
||||
let renderer!: ReactTestRenderer;
|
||||
await act(async () => { renderer = create(React.createElement(Probe, { id: 'first' })); });
|
||||
assert.equal(state.readmeStatus, 'loading');
|
||||
await act(async () => calls[0].request.reject(new Error('temporary failure')));
|
||||
assert.equal(state.readmeStatus, 'error');
|
||||
assert.match(state.readmeError!, /temporary failure/);
|
||||
await act(async () => state.retryReadme());
|
||||
assert.equal(state.readmeStatus, 'loading'); assert.equal(state.readmeError, null);
|
||||
await act(async () => calls[1].request.resolve(''));
|
||||
assert.equal(state.readmeStatus, 'ready'); assert.equal(state.readme, ''); assert.equal(state.readmeError, null);
|
||||
await act(async () => state.retryReadme());
|
||||
await act(async () => renderer.update(React.createElement(Probe, { id: 'second' })));
|
||||
assert.equal(state.readmeError, null);
|
||||
await act(async () => calls[3].request.resolve('# Second'));
|
||||
await act(async () => calls[2].request.reject(new Error('stale failure')));
|
||||
assert.equal(state.readme, '# Second'); assert.equal(state.readmeError, null);
|
||||
await act(async () => state.retryReadme());
|
||||
await act(async () => renderer.unmount());
|
||||
const unmountedRenders = renders;
|
||||
await act(async () => calls[4].request.resolve('late content'));
|
||||
assert.equal(renders, unmountedRenders);
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
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 };
|
||||
export function usePluginCatalog(selectedId: string | null) {
|
||||
const [entries, setEntries] = useState<NetcattyPluginCatalogEntry[]>([]);
|
||||
const [listStatus, setListStatus] = useState<'loading' | 'ready' | 'error'>('loading');
|
||||
const [listError, setListError] = useState<string | null>(null);
|
||||
const [listAttempt, setListAttempt] = useState(0);
|
||||
const [readmeState, setReadmeState] = useState<ReadmeState | null>(null);
|
||||
const [readmeAttempt, setReadmeAttempt] = useState(0);
|
||||
const [installError, setInstallError] = useState<{ id: string; message: string } | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const installing = useRef(false);
|
||||
const mounted = useRef(false);
|
||||
useEffect(() => { mounted.current = true; return () => { mounted.current = false; }; }, []);
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
setListStatus('loading'); setListError(null);
|
||||
void (async () => {
|
||||
try {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.listPluginCatalog) throw new Error('Catalog browsing unavailable');
|
||||
const list = await bridge.listPluginCatalog();
|
||||
if (active) { setEntries(list); setListStatus('ready'); }
|
||||
} catch (cause) { if (active) { setListStatus('error'); setListError(String(cause)); } }
|
||||
})();
|
||||
return () => { active = false; };
|
||||
}, [listAttempt]);
|
||||
useEffect(() => {
|
||||
if (!selectedId) return;
|
||||
let active = true;
|
||||
const base = { id: selectedId, 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);
|
||||
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;
|
||||
return {
|
||||
entries, listStatus, listError,
|
||||
readme: currentReadme?.status === 'ready' ? currentReadme.text : null,
|
||||
readmeStatus: currentReadme?.status ?? (selectedId ? 'loading' : 'idle'),
|
||||
readmeError: currentReadme?.error ?? null,
|
||||
error: installError?.id === selectedId ? installError.message : null,
|
||||
busy,
|
||||
retryList: () => setListAttempt((attempt) => attempt + 1),
|
||||
retryReadme: () => setReadmeAttempt((attempt) => attempt + 1),
|
||||
async install(id: string) {
|
||||
if (installing.current) return;
|
||||
installing.current = true;
|
||||
setBusy(true); setInstallError(null);
|
||||
try {
|
||||
const bridge = netcattyBridge.require();
|
||||
if (!bridge.installCatalogPlugin) throw new Error('Catalog installation unavailable');
|
||||
await bridge.installCatalogPlugin(id);
|
||||
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); }
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
export function usePluginManager() {
|
||||
const [plugins, setPlugins] = useState<NetcattyInstalledPlugin[]>([]);
|
||||
const [available, setAvailable] = useState(false);
|
||||
const [credentialStorageAvailable, setCredentialStorageAvailable] = useState<boolean | undefined>();
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const generation = useRef(0);
|
||||
@@ -15,14 +16,16 @@ export function usePluginManager() {
|
||||
const list = status?.available ? await bridge?.listPlugins?.() ?? [] : [];
|
||||
if (current !== generation.current) return;
|
||||
setAvailable(status?.available === true);
|
||||
setCredentialStorageAvailable(status?.credentialStorageAvailable);
|
||||
setPlugins(list);
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
const update = () => { void refresh().catch((cause) => setError(String(cause))); };
|
||||
update();
|
||||
const unsubscribe = netcattyBridge.get()?.onPluginContributionsChanged?.(update);
|
||||
window.addEventListener('netcatty:plugin-packages-changed', update);
|
||||
const refreshGeneration = generation;
|
||||
return () => { refreshGeneration.current++; unsubscribe?.(); };
|
||||
return () => { refreshGeneration.current++; unsubscribe?.(); window.removeEventListener('netcatty:plugin-packages-changed', update); };
|
||||
}, [refresh]);
|
||||
const run = useCallback(async (action: (bridge: NetcattyBridge) => Promise<unknown>) => {
|
||||
if (mutation.current) return;
|
||||
@@ -38,7 +41,7 @@ export function usePluginManager() {
|
||||
}
|
||||
}, [refresh]);
|
||||
return {
|
||||
plugins, available, busy, error,
|
||||
plugins, available, busy, error, credentialStorageAvailable,
|
||||
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'] }]);
|
||||
|
||||
@@ -151,3 +151,24 @@ test('plugin palette items keep repeated placements independently addressable',
|
||||
commandId: 'com.example.palette.run',
|
||||
}]);
|
||||
});
|
||||
|
||||
test('packaged Starter contributions are discoverable through the real palette builder', async (context) => {
|
||||
const { createRequire } = await import('node:module');
|
||||
const { mkdtempSync, rmSync } = await import('node:fs');
|
||||
const { tmpdir } = await import('node:os');
|
||||
const { join } = await import('node:path');
|
||||
const require = createRequire(import.meta.url);
|
||||
const { PluginDatabase } = require('../electron/plugins/database.cjs');
|
||||
const { PluginContributionService } = require('../electron/plugins/contributionService.cjs');
|
||||
const manifest = JSON.parse(readFileSync(new URL('../examples/plugins/starter-netcatty/netcatty.plugin.json', import.meta.url), 'utf8'));
|
||||
const root = mkdtempSync(join(tmpdir(), 'starter-palette-'));
|
||||
const database = new PluginDatabase(join(root, 'plugins.sqlite'));
|
||||
context.after(() => { database.close(); rmSync(root, { recursive: true, force: true }); });
|
||||
database.installVersion({ pluginId: manifest.id, version: manifest.version, manifest, archiveSha256: 'a'.repeat(64), packageRelativePath: 'starter' }, { enable: true });
|
||||
const service = new PluginContributionService({ database, runtimeSupervisor: {}, secretStore: {}, getLocale: () => 'en' });
|
||||
const items = buildPluginPaletteItems(service.snapshot().plugins, 'Starter');
|
||||
assert.ok(items.some((item) => item.type === 'plugin-command' && item.commandId === 'com.netcatty.starter.greet'));
|
||||
assert.ok(items.some((item) => item.type === 'plugin-view' && item.id === 'com.netcatty.starter.panel'));
|
||||
database.setEnabled(manifest.id, false);
|
||||
assert.equal(buildPluginPaletteItems(service.snapshot().plugins, 'Starter').length, 0);
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { JSDOM } from 'jsdom';
|
||||
|
||||
// Exercise the real Radix portal/focus lifecycle, rather than a mocked Dialog.
|
||||
test('plugin manager is single-instance, closes with Escape, restores focus and remounts on reopen', 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, useEffect } = await import('react');
|
||||
const { createRoot } = await import('react-dom/client');
|
||||
const { I18nProvider } = await import('../../../application/i18n/I18nProvider');
|
||||
const { PluginManagementDialog } = await import('./PluginManagementDialog');
|
||||
const root = createRoot(document.getElementById('root')!);
|
||||
let mounts = 0;
|
||||
let unmounts = 0;
|
||||
function Content() {
|
||||
useEffect(() => { mounts++; return () => { unmounts++; }; }, []);
|
||||
return <input aria-label="Plugin search" defaultValue="" />;
|
||||
}
|
||||
const settle = () => new Promise((resolve) => setTimeout(resolve, 20));
|
||||
const click = async (element: HTMLElement) => { await act(async () => { element.click(); await settle(); }); };
|
||||
try {
|
||||
await act(async () => { root.render(<I18nProvider locale="en"><PluginManagementDialog><Content /></PluginManagementDialog></I18nProvider>); });
|
||||
const trigger = document.querySelector<HTMLButtonElement>('button')!;
|
||||
assert.equal(document.querySelector('[role="dialog"]'), null);
|
||||
assert.equal(mounts, 0, 'closed settings entry does not mount manager subscriptions');
|
||||
trigger.focus();
|
||||
await click(trigger);
|
||||
assert.equal(document.querySelectorAll('[role="dialog"]').length, 1);
|
||||
assert.equal(mounts, 1);
|
||||
assert.equal(document.activeElement?.getAttribute('aria-label'), 'Plugin search');
|
||||
assert.equal(document.querySelector('[role="dialog"]')?.contains(document.querySelector('[data-settings-scroll-pane]')), true);
|
||||
await act(async () => { root.render(<I18nProvider locale="en"><PluginManagementDialog><Content /></PluginManagementDialog></I18nProvider>); });
|
||||
assert.equal(document.querySelectorAll('[role="dialog"]').length, 1);
|
||||
assert.equal(mounts, 1, 'rerender does not spawn another manager');
|
||||
const closeButton = document.querySelector<HTMLButtonElement>('[role="dialog"] button[data-dialog-close]:not([aria-hidden])')!;
|
||||
await act(async () => {
|
||||
closeButton.focus();
|
||||
closeButton.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true }));
|
||||
});
|
||||
assert.equal(document.activeElement?.getAttribute('aria-label'), 'Plugin search', 'Tab wraps inside the modal');
|
||||
await act(async () => { document.activeElement!.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); await settle(); });
|
||||
assert.equal(document.querySelector('[role="dialog"]'), null);
|
||||
assert.equal(document.activeElement, trigger);
|
||||
assert.equal(unmounts, 1);
|
||||
await click(trigger);
|
||||
assert.equal(mounts, 2);
|
||||
await click(document.querySelector<HTMLButtonElement>('[role="dialog"] button[data-dialog-close]:not([aria-hidden])')!);
|
||||
assert.equal(document.querySelector('[role="dialog"]'), null);
|
||||
assert.equal(document.activeElement, trigger);
|
||||
assert.equal(unmounts, 2);
|
||||
let handedOff = false;
|
||||
await act(async () => root.render(<I18nProvider locale="en"><PluginManagementDialog>{(handoff) => <button onClick={() => handoff(() => {
|
||||
handedOff = true;
|
||||
assert.equal(document.querySelector('[role="dialog"]'), null);
|
||||
assert.notEqual(document.body.style.pointerEvents, 'none');
|
||||
})}>Open contributed settings view</button>}</PluginManagementDialog></I18nProvider>));
|
||||
await click(trigger);
|
||||
await click(document.querySelector<HTMLButtonElement>('[role="dialog"] button:not([data-dialog-close])')!);
|
||||
assert.equal(handedOff, true, 'external view opens only after modal ownership is released');
|
||||
} 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);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import React, { useRef, useState } from 'react';
|
||||
import { useI18n } from '../../../application/i18n/I18nProvider';
|
||||
import { Button } from '../../ui/button';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '../../ui/dialog';
|
||||
|
||||
/** Uses the existing renderer and host bridge; opening the manager never starts a plugin. */
|
||||
export function PluginManagementDialog({ children }: {
|
||||
children: React.ReactNode | ((handoff: (action: () => void) => void) => React.ReactNode);
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const [open, setOpen] = useState(false);
|
||||
const pendingHandoff = useRef<(() => void) | null>(null);
|
||||
const handoff = (action: () => void) => { pendingHandoff.current = action; setOpen(false); };
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline">{t('settings.plugins.openManager')}</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent onCloseAutoFocus={(event) => {
|
||||
const action = pendingHandoff.current;
|
||||
pendingHandoff.current = null;
|
||||
if (action) {
|
||||
event.preventDefault();
|
||||
// Wait for Radix to release modal pointer/focus ownership before opening
|
||||
// the existing external plugin view host (including exit animations).
|
||||
queueMicrotask(action);
|
||||
}
|
||||
}} className="flex h-[min(88dvh,900px)] w-[calc(100vw-2rem)] max-w-[1180px] flex-col gap-0 overflow-hidden p-0">
|
||||
<DialogHeader className="shrink-0 border-b px-6 py-4 pr-12 text-left">
|
||||
<DialogTitle>{t('settings.plugins.title')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div data-settings-scroll-pane className="min-h-0 flex-1 overflow-y-auto overscroll-contain p-4 sm:p-6">
|
||||
{typeof children === 'function' ? children(handoff) : children}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import React, { useState } from 'react';
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { I18nProvider } from '../../../application/i18n/I18nProvider';
|
||||
import { PluginMarketplaceContent } from './PluginMarketplace';
|
||||
import type { usePluginCatalog } from '../../../application/state/usePluginCatalog';
|
||||
|
||||
const entries = ['Alpha', 'Beta'].map((name) => ({
|
||||
id: `com.example.${name.toLowerCase()}`, name, author: 'Example', version: '1.0.0', license: 'MIT',
|
||||
description: `${name} local utility`, repository: 'example/plugins', commit: 'a'.repeat(40),
|
||||
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,
|
||||
error: null, busy: false, retryList() {}, retryReadme() {}, async install() {},
|
||||
} as ReturnType<typeof usePluginCatalog>;
|
||||
|
||||
test('catalog keeps search during selection/back, switches detail, and displays real installed state', async () => {
|
||||
const previousRaf = globalThis.requestAnimationFrame;
|
||||
globalThis.requestAnimationFrame = (callback) => { callback(0); return 0; };
|
||||
const actGlobal = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean };
|
||||
const previousActEnvironment = actGlobal.IS_REACT_ACT_ENVIRONMENT;
|
||||
actGlobal.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
let renderer: ReactTestRenderer;
|
||||
function Harness() {
|
||||
const [selected, setSelected] = useState<string | null>(null);
|
||||
return <I18nProvider locale="en"><PluginMarketplaceContent catalog={catalog} selected={selected} onSelect={setSelected}
|
||||
installed={[{ id: entries[0].id, activeVersion: '0.9.0', enabled: false, runtime: { lastError: 'Incompatible with this host' } } as NetcattyInstalledPlugin]} /></I18nProvider>;
|
||||
}
|
||||
try {
|
||||
await act(async () => { renderer = create(<Harness />); });
|
||||
const cards = () => renderer.root.findAll((node) => node.type === 'button' && Boolean(node.props['data-plugin-id']));
|
||||
const input = () => renderer.root.findByType('input');
|
||||
assert.equal(cards().length, 2);
|
||||
await act(async () => input().props.onChange({ target: { value: 'Alpha' } }));
|
||||
assert.equal(cards().length, 1);
|
||||
await act(async () => cards()[0].props.onClick());
|
||||
assert.equal(renderer.root.findByType('article').props['aria-label'], 'Alpha');
|
||||
assert.equal(cards()[0].props['aria-current'], 'true');
|
||||
const detailText = JSON.stringify(renderer.toJSON());
|
||||
assert.match(detailText, /0.9.0/);
|
||||
assert.match(detailText, /Disabled/);
|
||||
assert.match(detailText, /Incompatible with this host/);
|
||||
assert.match(detailText, /Last recorded error/);
|
||||
assert.match(cards()[0].props.className, /bg-primary\/10/);
|
||||
assert.match(cards()[0].props.className, /border-primary\/40/);
|
||||
assert.doesNotMatch(cards()[0].props.className, /bg-accent|text-accent-foreground/);
|
||||
assert.match(detailText, /1 plugins/);
|
||||
assert.equal(renderer.root.findAllByType('details').length, 0, 'permissions are not hidden in a disclosure');
|
||||
assert.equal(renderer.root.findAll((node) => node.type === 'button' && node.children.includes('Install this version (disabled)')).length, 0, 'source-only cards have no install action');
|
||||
const back = () => renderer.root.findAllByType('button').find((node) => node.children.includes('All plugins'))!;
|
||||
await act(async () => back().props.onClick());
|
||||
assert.equal(renderer.root.findAllByType('article').length, 0);
|
||||
assert.equal(input().props.value, 'Alpha');
|
||||
await act(async () => input().props.onChange({ target: { value: '' } }));
|
||||
await act(async () => cards()[0].props.onClick());
|
||||
await act(async () => cards()[1].props.onClick());
|
||||
assert.equal(renderer.root.findByType('article').props['aria-label'], 'Beta');
|
||||
assert.equal(cards()[1].props['aria-current'], 'true');
|
||||
assert.equal(cards()[0].props['aria-current'], undefined);
|
||||
await act(async () => input().props.onChange({ target: { value: 'no match' } }));
|
||||
assert.equal(cards().length, 0);
|
||||
assert.equal(renderer.root.findByType('article').props['aria-label'], 'Beta', 'filtering does not erase the selected detail');
|
||||
await act(async () => back().props.onClick());
|
||||
assert.equal(input().props.value, 'no match');
|
||||
await act(async () => renderer.unmount());
|
||||
} finally {
|
||||
globalThis.requestAnimationFrame = previousRaf;
|
||||
actGlobal.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
test('README failure leaves the permissions and source visible, before the failure message', () => {
|
||||
const html = renderToStaticMarkup(<I18nProvider locale="en"><PluginMarketplaceContent
|
||||
catalog={{ ...catalog, readmeStatus: 'error', readme: null, readmeError: 'Network unavailable' }}
|
||||
selected={entries[0].id} onSelect={() => {}} installed={[]} /></I18nProvider>);
|
||||
assert.match(html, /Compatibility and permissions/);
|
||||
assert.match(html, /settings.read/);
|
||||
assert.match(html, /not a grant/);
|
||||
assert.match(html, /Build with the local CLI/);
|
||||
assert.ok(html.indexOf('settings.read') < html.indexOf('Network unavailable'));
|
||||
assert.match(html, /Retry/);
|
||||
assert.doesNotMatch(html, /<details/);
|
||||
});
|
||||
|
||||
test('responsive layout is scoped to marketplace content width, with a narrow detail-only fallback', () => {
|
||||
const css = readFileSync(new URL('./plugin-marketplace.css', import.meta.url), 'utf8');
|
||||
assert.match(css, /\.plugin-marketplace \{ container-type: inline-size;/);
|
||||
assert.match(css, /@container \(min-width: 820px\)/);
|
||||
assert.match(css, /grid-template-columns: 248px minmax\(0, 1fr\)/);
|
||||
assert.match(css, /plugin-marketplace-browser--selected \.plugin-marketplace-catalog \{ display: none;/);
|
||||
});
|
||||
|
||||
test('narrow detail starts at the top and back restores the settings scroll and card focus', async () => {
|
||||
const previousRaf = globalThis.requestAnimationFrame;
|
||||
globalThis.requestAnimationFrame = (callback) => { callback(0); return 0; };
|
||||
const actGlobal = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean };
|
||||
const previousActEnvironment = actGlobal.IS_REACT_ACT_ENVIRONMENT;
|
||||
actGlobal.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
const pane = { scrollTop: 450 };
|
||||
let focused = false;
|
||||
const list = { scrollTop: 0, closest: (selector: string) => selector === '.plugin-marketplace' ? { clientWidth: 700 } : pane,
|
||||
querySelectorAll: () => [{ dataset: { pluginId: entries[0].id }, focus: () => { focused = true; } }] };
|
||||
let renderer!: ReactTestRenderer;
|
||||
function Harness() {
|
||||
const [selected, setSelected] = useState<string | null>(null);
|
||||
return <I18nProvider locale="en"><PluginMarketplaceContent catalog={catalog} selected={selected} onSelect={setSelected} installed={[]} /></I18nProvider>;
|
||||
}
|
||||
try {
|
||||
await act(async () => { renderer = create(<Harness />, { createNodeMock: (element) => element.type === 'div' ? list : { scrollTo() {}, focus() {} } }); });
|
||||
const card = renderer.root.findAll((node) => node.type === 'button' && node.props['data-plugin-id'] === entries[0].id)[0];
|
||||
await act(async () => card.props.onClick());
|
||||
assert.equal(pane.scrollTop, 0);
|
||||
const back = renderer.root.findAllByType('button').find((node) => node.children.includes('All plugins'))!;
|
||||
await act(async () => back.props.onClick());
|
||||
assert.equal(pane.scrollTop, 450);
|
||||
assert.equal(focused, true);
|
||||
await act(async () => renderer.unmount());
|
||||
} finally {
|
||||
globalThis.requestAnimationFrame = previousRaf;
|
||||
actGlobal.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { filterPluginCatalog } from './PluginMarketplace';
|
||||
test('catalog search matches name, author and description without case sensitivity', () => {
|
||||
const entries = [{ id: 'a', name: 'Starter', author: 'Netcatty', description: 'Local greeting' }] as NetcattyPluginCatalogEntry[];
|
||||
for (const query of ['starter', 'NETCATTY', ' greeting ']) assert.equal(filterPluginCatalog(entries, query).length, 1);
|
||||
assert.equal(filterPluginCatalog(entries, 'unrelated').length, 0);
|
||||
assert.equal(filterPluginCatalog(entries, '').length, 1);
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
import React, { useRef, useState } from 'react';
|
||||
import { ArrowLeft, Package } from 'lucide-react';
|
||||
import { useI18n } from '../../../application/i18n/I18nProvider';
|
||||
import { usePluginCatalog } from '../../../application/state/usePluginCatalog';
|
||||
import { Input } from '../../ui/input';
|
||||
import { Button } from '../../ui/button';
|
||||
import { SettingCard } from '../settings-ui';
|
||||
import { PluginPackageDetails } from './PluginPackageManager';
|
||||
import { PluginReadme } from './PluginReadme';
|
||||
|
||||
export function PluginMarketplace({ installed = [] }: { installed?: NetcattyInstalledPlugin[] }) {
|
||||
const [selected, setSelected] = useState<string | null>(null);
|
||||
const catalog = usePluginCatalog(selected);
|
||||
return <PluginMarketplaceContent catalog={catalog} installed={installed} selected={selected} onSelect={setSelected} />;
|
||||
}
|
||||
|
||||
// The browser owns only presentation state; installation and README policy stay in the existing hook.
|
||||
export function PluginMarketplaceContent({ catalog, installed, selected, onSelect }: {
|
||||
catalog: ReturnType<typeof usePluginCatalog>;
|
||||
installed: NetcattyInstalledPlugin[];
|
||||
selected: string | null;
|
||||
onSelect: (id: string | null) => void;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const [search, setSearch] = useState('');
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const detailRef = useRef<HTMLElement>(null);
|
||||
const lastSelection = useRef<string | null>(null);
|
||||
const listScroll = useRef(0);
|
||||
const pageScroll = useRef(0);
|
||||
const entry = catalog.entries.find((item) => item.id === selected);
|
||||
const filtered = filterPluginCatalog(catalog.entries, search);
|
||||
const installedEntry = installed.find((item) => item.id === entry?.id);
|
||||
const choose = (id: string) => {
|
||||
listScroll.current = listRef.current?.scrollTop ?? 0;
|
||||
if (!entry) pageScroll.current = listRef.current?.closest('[data-settings-scroll-pane]')?.scrollTop ?? 0;
|
||||
lastSelection.current = id;
|
||||
onSelect(id);
|
||||
requestAnimationFrame(() => {
|
||||
detailRef.current?.scrollTo?.(0, 0);
|
||||
const container = listRef.current?.closest<HTMLElement>('.plugin-marketplace');
|
||||
const pane = listRef.current?.closest('[data-settings-scroll-pane]');
|
||||
if (container && container.clientWidth < 820 && pane) pane.scrollTop = 0;
|
||||
detailRef.current?.focus({ preventScroll: true });
|
||||
});
|
||||
};
|
||||
const back = () => {
|
||||
onSelect(null);
|
||||
requestAnimationFrame(() => {
|
||||
if (listRef.current) {
|
||||
listRef.current.scrollTop = listScroll.current;
|
||||
const pane = listRef.current.closest('[data-settings-scroll-pane]');
|
||||
if (pane) pane.scrollTop = pageScroll.current;
|
||||
const button = Array.from(listRef.current.querySelectorAll<HTMLButtonElement>('[data-plugin-id]'))
|
||||
.find((item) => item.dataset.pluginId === lastSelection.current);
|
||||
button?.focus({ preventScroll: true });
|
||||
}
|
||||
});
|
||||
};
|
||||
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>
|
||||
{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}>
|
||||
<Input value={search} onChange={(event) => setSearch(event.target.value)} placeholder={t('settings.plugins.market.search')} aria-label={t('settings.plugins.market.search')} />
|
||||
{catalog.listStatus === 'ready' && <p className="text-xs text-muted-foreground" aria-live="polite">{t('settings.plugins.market.results', { count: filtered.length })}</p>}
|
||||
{catalog.listStatus === 'ready' && catalog.entries.length > 0 && filtered.length === 0 && <p className="rounded-lg border border-dashed p-6 text-center text-sm text-muted-foreground">{t('settings.plugins.market.noResults')}</p>}
|
||||
{catalog.listStatus === 'loading' && <p className="text-xs text-muted-foreground">{t('settings.plugins.loading')}</p>}
|
||||
{catalog.listStatus === 'error' && <div role="alert" className="space-y-2 text-xs text-destructive"><p>{catalog.listError}</p><Button variant="outline" size="sm" onClick={catalog.retryList}>{t('settings.plugins.market.retry')}</Button></div>}
|
||||
{catalog.listStatus === 'ready' && catalog.entries.length === 0 && <p className="text-xs text-muted-foreground">{t('settings.plugins.market.empty')}</p>}
|
||||
<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>
|
||||
<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>
|
||||
{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>}
|
||||
<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>}
|
||||
{catalog.readmeStatus === 'ready' && (catalog.readme?.trim() ? <PluginReadme markdown={catalog.readme} /> : <p className="mt-3 text-xs text-muted-foreground">{t('settings.plugins.market.emptyReadme')}</p>)}
|
||||
</div>
|
||||
</article>}
|
||||
</div>
|
||||
</section>;
|
||||
}
|
||||
|
||||
export function filterPluginCatalog(entries: NetcattyPluginCatalogEntry[], query: string) {
|
||||
const needle = query.trim().toLocaleLowerCase();
|
||||
return entries.filter((entry) => `${entry.name} ${entry.author} ${entry.description}`.toLocaleLowerCase().includes(needle));
|
||||
}
|
||||
@@ -22,6 +22,6 @@ test('package detail renders manifest resources as text and explains advanced au
|
||||
assert.match(html, /https:\/\/example.com/);
|
||||
assert.match(html, /<script>/);
|
||||
assert.doesNotMatch(html, /<script>/);
|
||||
assert.match(html, /Declarations are not grants/);
|
||||
assert.match(html, /not a grant/);
|
||||
assert.match(html, /aaaaaaaaaaaaaaaa/);
|
||||
});
|
||||
@@ -1,19 +1,23 @@
|
||||
import { describePluginPermission } from '@/domain/pluginPermissionDescriptions.cjs';
|
||||
import { describePluginRuntime } from '@/domain/pluginRuntimePolicy.cjs';
|
||||
import React, { useState } from 'react';
|
||||
import { Package, Upload } from 'lucide-react';
|
||||
import { ArrowLeft, Package } from 'lucide-react';
|
||||
import { usePluginManager } from '../../../application/state/usePluginManager';
|
||||
import { useI18n } from '../../../application/i18n/I18nProvider';
|
||||
import { Button } from '../../ui/button';
|
||||
|
||||
export function PluginPackageDetails({ plugin }: { plugin: NetcattyInstalledPlugin }) {
|
||||
const { t } = useI18n();
|
||||
const { t, resolvedLocale } = useI18n();
|
||||
const manifest = plugin.manifest;
|
||||
if (!manifest) return null;
|
||||
const permissions = [
|
||||
...(manifest.permissions?.required ?? []).map((value) => ({ value, required: true })),
|
||||
...(manifest.permissions?.optional ?? []).map((value) => ({ value, required: false })),
|
||||
];
|
||||
const advanced = !manifest.main.browser || Boolean(manifest.companionExecutables?.length)
|
||||
|| manifest.contributes?.providers?.some((p) => p.kind === 'terminal.interceptor.input' || p.kind === 'terminal.interceptor.output');
|
||||
const policy = describePluginRuntime(manifest);
|
||||
const runtimeKind = plugin.runtime.kind ?? policy.defaultRuntimeKind;
|
||||
const runtimeLabel = runtimeKind === 'utility' ? 'advanced'
|
||||
: policy.hasNode ? 'dual' : policy.declaresAdvanced ? 'advancedDeclaration' : 'browser';
|
||||
return <div className="space-y-4 border-t border-border/60 pt-4 text-sm">
|
||||
<dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-2 text-xs">
|
||||
<dt className="text-muted-foreground">ID</dt><dd className="break-all font-mono">{plugin.id}</dd>
|
||||
@@ -21,44 +25,47 @@ export function PluginPackageDetails({ plugin }: { plugin: NetcattyInstalledPlug
|
||||
<dt className="text-muted-foreground">API</dt><dd>{manifest.engines.api}</dd>
|
||||
<dt className="text-muted-foreground">SHA-256</dt><dd className="break-all font-mono">{plugin.archiveSha256 ?? '-'}</dd>
|
||||
</dl>
|
||||
<p className={advanced ? 'text-destructive' : 'text-muted-foreground'}>{t(advanced ? 'settings.plugins.manager.advanced' : 'settings.plugins.manager.browser')}</p>
|
||||
<p className={!policy.browserOnly ? 'text-destructive' : 'text-muted-foreground'}>{t(`settings.plugins.manager.${runtimeLabel}`)}</p>
|
||||
<div className="space-y-2">
|
||||
<h4 className="font-medium">{t('settings.plugins.manager.permissions')}</h4>
|
||||
<p className="text-xs text-muted-foreground">{t('settings.plugins.manager.permissionNote')}</p>
|
||||
{permissions.length === 0 && <p className="text-xs text-muted-foreground">{t('settings.plugins.manager.noPermissions')}</p>}
|
||||
{permissions.map(({ value, required }, index) => <div key={index} className="rounded-md bg-muted/40 p-3">
|
||||
<div className="flex justify-between gap-3"><code className="break-all text-xs">{typeof value === 'string' ? value : value.permission}</code><span className="text-xs text-muted-foreground">{t(required ? 'settings.plugins.manager.required' : 'settings.plugins.manager.optional')}</span></div>
|
||||
{permissions.map(({ value, required }, index) => {
|
||||
const id = typeof value === 'string' ? value : value.permission;
|
||||
const description = describePluginPermission(id, resolvedLocale);
|
||||
return <div key={index} className="rounded-md bg-muted/40 p-3">
|
||||
<div className="flex justify-between gap-3"><span className="text-sm font-medium">{description.label}</span><span className="text-xs text-muted-foreground">{t(required ? 'settings.plugins.manager.required' : 'settings.plugins.manager.optional')}</span></div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{description.description}</p>
|
||||
<code className="mt-1 block break-all text-[10px] text-muted-foreground">{id}</code>
|
||||
{description.sensitive && <p className="mt-1 text-xs font-medium text-destructive">{t('settings.plugins.manager.sensitive')}</p>}
|
||||
{(typeof value === 'string' || value.resources.length === 0) && <p className="mt-1 text-xs">{t('settings.plugins.manager.unbounded')}</p>}
|
||||
{typeof value !== 'string' && <><p className="mt-1 whitespace-pre-wrap break-all text-xs">{value.resources.join('\n')}</p>{value.reason && <p className="mt-1 text-xs text-muted-foreground">{value.reason}</p>}</>}
|
||||
</div>)}
|
||||
</div>; })}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{t('settings.plugins.manager.data')}</p>
|
||||
</div>;
|
||||
}
|
||||
|
||||
export function PluginPackageManager() {
|
||||
export function PluginPackageManager({ manager }: { manager: ReturnType<typeof usePluginManager> }) {
|
||||
const { t, resolvedLocale } = useI18n();
|
||||
const manager = usePluginManager();
|
||||
const [selected, setSelected] = useState<string | null>(null);
|
||||
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')}>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h3 className="font-medium">{t('settings.plugins.manager.installed')} <span className="text-muted-foreground">{manager.plugins.length}</span></h3>
|
||||
<Button variant="outline" size="sm" disabled={manager.busy} onClick={() => void manager.importPackage()}><Upload size={14} className="mr-2" />{t('settings.plugins.manager.import')}</Button>
|
||||
</div>
|
||||
<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>}
|
||||
{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.map((plugin) => <article key={plugin.id} className="space-y-4 rounded-xl border border-border/70 bg-background p-4">
|
||||
{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.activeVersion} · {t(plugin.enabled ? 'settings.plugins.manager.enabled' : 'settings.plugins.manager.disabled')}</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>
|
||||
</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>
|
||||
</div>
|
||||
{plugin.runtime.lastError && <p role="status" className="break-words text-xs text-destructive">{plugin.runtime.lastError}</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} />
|
||||
{removing === plugin.id ? <div className="space-y-2 rounded-lg border border-destructive/30 p-3">
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import React from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { PluginReadme } from './PluginReadme';
|
||||
|
||||
test('README never creates active HTML, remote media or script links', () => {
|
||||
const html = renderToStaticMarkup(<PluginReadme markdown={'# Example\n\n<script>alert(1)</script>\n\n<img src="https://tracker.example/pixel">\n\n\n\n[run](javascript:alert%281%29)\n\n[docs](https://example.com)\n\n```html\n<iframe src="file:///secret">\n```'} />);
|
||||
assert.doesNotMatch(html, /<(script|img|iframe|svg)\b/);
|
||||
assert.doesNotMatch(html, /href="(javascript|file):/);
|
||||
assert.match(html, /href="https:\/\/example.com"/);
|
||||
assert.match(html, /rel="noopener noreferrer"/);
|
||||
assert.match(html, /<script>/);
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { fromMarkdown } from 'mdast-util-from-markdown';
|
||||
import type { RootContent, Root } from 'mdast';
|
||||
|
||||
function renderNode(node: Root | RootContent, key: number, depth = 0): React.ReactNode {
|
||||
if (depth > 24) return null;
|
||||
const children = 'children' in node ? node.children.map((child, index) => renderNode(child as RootContent, index, depth + 1)) : null;
|
||||
switch (node.type) {
|
||||
case 'root': return <React.Fragment key={key}>{children}</React.Fragment>;
|
||||
case 'heading': return <h4 key={key} className="mt-5 font-semibold">{children}</h4>;
|
||||
case 'paragraph': return <p key={key} className="my-2 leading-relaxed">{children}</p>;
|
||||
case 'text': return node.value;
|
||||
case 'strong': return <strong key={key}>{children}</strong>;
|
||||
case 'emphasis': return <em key={key}>{children}</em>;
|
||||
case 'inlineCode': return <code key={key} className="rounded bg-muted px-1">{node.value}</code>;
|
||||
case 'code': return <pre key={key} className="my-3 overflow-auto rounded-lg bg-muted p-3 text-xs"><code>{node.value}</code></pre>;
|
||||
case 'list': return <ul key={key} className="my-2 list-disc pl-5">{children}</ul>;
|
||||
case 'listItem': return <li key={key}>{children}</li>;
|
||||
case 'blockquote': return <blockquote key={key} className="border-l-2 pl-3 text-muted-foreground">{children}</blockquote>;
|
||||
case 'break': return <br key={key} />;
|
||||
case 'thematicBreak': return <hr key={key} />;
|
||||
case 'link': {
|
||||
let safe = false;
|
||||
try { const url = new URL(node.url); safe = url.protocol === 'https:' && !url.username && !url.password; } catch { /* relative and unsafe links stay text */ }
|
||||
return safe ? <a key={key} href={node.url} target="_blank" rel="noopener noreferrer" className="underline">{children}</a> : <span key={key}>{children}</span>;
|
||||
}
|
||||
// Never embed HTML, media, SVG, iframes, remote images, or reference URLs.
|
||||
case 'image': return <span key={key} className="text-muted-foreground">[{node.alt ?? 'image'}]</span>;
|
||||
case 'html': return <span key={key}>{node.value}</span>;
|
||||
default: return <React.Fragment key={key}>{children}</React.Fragment>;
|
||||
}
|
||||
}
|
||||
|
||||
export function PluginReadme({ markdown }: { markdown: string }) {
|
||||
const tree = useMemo(() => fromMarkdown(markdown.slice(0, 256 * 1024)), [markdown]);
|
||||
return <div className="break-words text-sm">{renderNode(tree, 0)}</div>;
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
import { usePluginManager } from '../../../application/state/usePluginManager';
|
||||
import { PluginManagementDialog } from './PluginManagementDialog';
|
||||
import { PluginMarketplace } from './PluginMarketplace';
|
||||
import { PluginPackageManager } from './PluginPackageManager';
|
||||
import { FolderOpen } from 'lucide-react';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
@@ -5,6 +8,7 @@ import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { normalizePluginKeyboardEvent } from '../../../application/state/pluginKeybindings';
|
||||
import { usePluginContributions } from '../../../application/state/usePluginContributions';
|
||||
import { useI18n } from '../../../application/i18n/I18nProvider';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '../../ui/tabs';
|
||||
import { Button } from '../../ui/button';
|
||||
import { Input } from '../../ui/input';
|
||||
import { SettingsAnchor, SettingsTabContent } from '../settings-ui';
|
||||
@@ -279,8 +283,10 @@ export function PluginSettingField({
|
||||
);
|
||||
}
|
||||
|
||||
export default function SettingsPluginsTab() {
|
||||
function PluginManagementContent({ handoff }: { handoff: (action: () => void) => void }) {
|
||||
const { t } = useI18n();
|
||||
const manager = usePluginManager();
|
||||
const [pluginView, setPluginView] = useState<'installed' | 'marketplace'>('installed');
|
||||
const availableFonts = useAvailableFonts();
|
||||
const scopeCatalog = usePluginSettingScopeCatalog();
|
||||
const [scopeIds, setScopeIds] = useState<Partial<Record<NetcattyPluginSettingScopeKind, string>>>({});
|
||||
@@ -296,19 +302,22 @@ export default function SettingsPluginsTab() {
|
||||
.flatMap((plugin) => plugin.settings.map((setting) => setting.scope))
|
||||
.filter((scope): scope is NetcattyPluginSettingScopeKind => scope !== 'application')),
|
||||
[contributions.snapshot.plugins]);
|
||||
const hasVisibleContributions = contributions.snapshot.plugins.some((plugin) => (
|
||||
plugin.settings.some((setting) => setting.visible)
|
||||
|| plugin.views.some((view) => view.visible && view.location === 'settings')
|
||||
));
|
||||
|
||||
|
||||
return (
|
||||
<SettingsTabContent value="plugins">
|
||||
<SettingsAnchor anchorId="plugins-root" className="mx-auto w-full max-w-3xl space-y-6 px-8 py-8">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold">{t('settings.plugins.title')}</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{t('settings.plugins.description')}</p>
|
||||
<div className="space-y-5">
|
||||
<Tabs value={pluginView} onValueChange={(value) => 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>
|
||||
<TabsTrigger value="marketplace">{t('settings.plugins.market.title')}</TabsTrigger>
|
||||
</TabsList>
|
||||
<Button variant="outline" size="sm" disabled={!manager.available || manager.busy} onClick={() => { setPluginView('installed'); void manager.importPackage(); }}><FolderOpen size={14} className="mr-2" />{t('settings.plugins.manager.import')}</Button>
|
||||
</div>
|
||||
<PluginPackageManager />
|
||||
{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} />
|
||||
{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) => (
|
||||
@@ -358,18 +367,32 @@ export default function SettingsPluginsTab() {
|
||||
<div className="font-mono text-[10px] text-muted-foreground">{view.id}</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => requestOpenPluginView({
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => handoff(() => requestOpenPluginView({
|
||||
viewId: view.id,
|
||||
context: { 'netcatty.surface': 'settings' },
|
||||
})}>{t('common.open')}</Button>
|
||||
}))}>{t('common.open')}</Button>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
{!contributions.loading && contributions.available && !hasVisibleContributions && (
|
||||
<p className="text-sm text-muted-foreground">{t('settings.plugins.empty')}</p>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
export default function SettingsPluginsTab() {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<SettingsTabContent value="plugins">
|
||||
<SettingsAnchor anchorId="plugins-root" className="space-y-5">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold">{t('settings.plugins.title')}</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{t('settings.plugins.description')}</p>
|
||||
</div>
|
||||
<PluginManagementDialog>{(handoff) => <PluginManagementContent handoff={handoff} />}</PluginManagementDialog>
|
||||
</SettingsAnchor>
|
||||
</SettingsTabContent>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
/* Container breakpoints account for the Settings sidebar, unlike viewport breakpoints. */
|
||||
.plugin-marketplace { container-type: inline-size; }
|
||||
.plugin-marketplace-browser { display: grid; gap: 16px; min-width: 0; }
|
||||
.plugin-marketplace-catalog { min-width: 0; }
|
||||
.plugin-marketplace-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 280px), 1fr)); gap: 12px; }
|
||||
.plugin-marketplace-browser--selected .plugin-marketplace-catalog { display: none; }
|
||||
.plugin-marketplace-detail { overflow-wrap: anywhere; }
|
||||
@container (min-width: 820px) {
|
||||
.plugin-marketplace-browser--selected { grid-template-columns: 248px minmax(0, 1fr); align-items: start; }
|
||||
.plugin-marketplace-browser--selected .plugin-marketplace-catalog { display: block; max-height: min(680px, 65vh); overflow-y: auto; padding: 2px; }
|
||||
.plugin-marketplace-browser--selected .plugin-marketplace-grid { grid-template-columns: minmax(0, 1fr); }
|
||||
.plugin-marketplace-browser--selected .plugin-marketplace-detail { max-height: min(680px, 65vh); overflow-y: auto; }
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
# Curated plugin catalog
|
||||
|
||||
The catalog lives in `electron/plugins/catalog.json` in this repository. There
|
||||
is no new marketplace account service or external directory repository. It is
|
||||
shipped with the app; catalog changes become available with an app update.
|
||||
Normal Settings exposes browser-only management and catalog browsing. Advanced
|
||||
local packages require `NETCATTY_PLUGIN_DEV=1`; that flag never grants permissions.
|
||||
Native acceptance and security/distribution review remain required before merge.
|
||||
|
||||
## Submit or update a plugin
|
||||
|
||||
Open a PR changing one entry. Provide:
|
||||
|
||||
- Stable reverse-domain plugin ID, author, name and short description.
|
||||
- Public GitHub source repository and explicit license matching the package.
|
||||
- Exact version, full 40-character source commit and relative README `.md` path.
|
||||
- The complete validated manifest: engine/API ranges, required/optional permissions,
|
||||
bounded resources, features and data/sync declarations must match package bytes.
|
||||
- Optional published artifact URL and SHA-256. No artifact means a source-only
|
||||
example: the UI does not offer installation. Do not invent an artifact URL.
|
||||
- A concise change log, permission diff, supported OS test results, and synthetic
|
||||
reproduction instructions. Current public runtime eligibility is browser-only
|
||||
on the host platforms; native/platform-specific companions are excluded.
|
||||
|
||||
Use the local CLI from the repository root:
|
||||
|
||||
```sh
|
||||
npm ci
|
||||
npm run build:plugin-packages
|
||||
node packages/plugin-cli/dist/cli.js validate examples/plugins/hello-netcatty
|
||||
node packages/plugin-cli/dist/cli.js pack examples/plugins/hello-netcatty --out /tmp/hello-netcatty.ncpkg
|
||||
node packages/plugin-cli/dist/cli.js validate /tmp/hello-netcatty.ncpkg
|
||||
```
|
||||
|
||||
The catalog contains first-party source examples: Plugin Starter for minimal
|
||||
settings/command use, and Hello Netcatty for terminal provider demonstrations.
|
||||
No downloadable release is fabricated or published by this change. A maintainer
|
||||
must verify real artifact provenance before adding the artifact object.
|
||||
|
||||
## Enforced install boundary
|
||||
|
||||
The renderer sends only a catalog ID. The main process selects the shipped
|
||||
entry, downloads over HTTPS from approved GitHub hosts with bounded size/time
|
||||
and redirects, checks SHA-256, and hands a private file to the same serialized
|
||||
PluginManager and PackageStore used for local imports. The immutable staged
|
||||
snapshot is hashed again before any version switch. Its exact ID/version,
|
||||
license and full manifest must match the catalog. Unknown permissions fail
|
||||
canonical schema validation. Browser-only eligibility rejects Node entrypoints,
|
||||
companions, raw terminal interceptors and advanced runtime declarations.
|
||||
|
||||
The directory is a curated integrity assertion, not a publisher signature.
|
||||
Unsigned runtime grants remain bound to package digest. No latest fallback and
|
||||
no background code updates exist. Installing an update stops the old version;
|
||||
the new version remains disabled until reviewed and explicitly enabled. Required
|
||||
permissions are mediated again by the existing runtime permission engine.
|
||||
Local advanced development packages require the developer flag and use the existing permission model;
|
||||
this does not grant advanced privileges or make Node a sandbox.
|
||||
|
||||
README is fetched only from the selected commit, on opening details. Redirects
|
||||
are refused and responses are limited to 256 KiB. Rendering constructs a small
|
||||
allowlist of React elements from Markdown; HTML remains escaped text and images
|
||||
are not fetched. Only explicit HTTPS links can be opened. README content is
|
||||
untrusted author documentation; permissions come from the manifest.
|
||||
|
||||
## Review and maintenance
|
||||
|
||||
CI now runs canonical plugin contract drift checks and the runtime suite,
|
||||
including catalog validation and install-boundary regression tests.
|
||||
Run `node --test electron/plugins/catalogService.test.cjs` and the plugin
|
||||
contract/runtime tests for every entry change. Review actual package/source
|
||||
provenance and permission/resource differences manually on first submission and
|
||||
sensitive updates. Packaging validation includes traversal/integrity checks but
|
||||
is not a malware audit. Human review is mandatory before adding an artifact;
|
||||
there is no automated acceptance bot in this slice.
|
||||
|
||||
Authors maintain their plugins, document data handling and respond to security
|
||||
reports. Maintainers may remove entries or decline updates. Directory checks
|
||||
are limited checks, not a safety certification, legal waiver or a promise to
|
||||
repair third-party plugins. No third-party terms are copied here.
|
||||
|
||||
## Data and lifecycle
|
||||
|
||||
Local KV is per-plugin and does not sync. Only explicitly syncable non-secret
|
||||
settings enter encrypted sidecars; machine paths, grants, packages, runtime
|
||||
state and secrets do not become cloud authority. Existing sidecar conflict and
|
||||
account isolation mechanisms are reused. A generic KV sync API remains a separate
|
||||
schema/migration decision.
|
||||
|
||||
Incompatible host/API/features disable plugins before lazy contributions are
|
||||
published, retain package/data and record a reason. A compatible downgrade never
|
||||
automatically re-enables them. Disable stops runtime and contributions; uninstall
|
||||
removes package code and local KV, while user-owned settings/secrets/sidecars
|
||||
remain. Existing crash quarantine and generation-bound cancellation apply.
|
||||
|
||||
Missing OS-backed key storage blocks secrets/credentials, without blocking
|
||||
catalog browsing or non-secret plugins. The Settings manager reports this state;
|
||||
never switch to plaintext storage or disable Chromium sandboxing for a demo.
|
||||
|
||||
## When permission prompts appear
|
||||
|
||||
Enabled is an availability switch, not a security review or a grant. A lazy
|
||||
plugin can become enabled without starting code. Its first command/provider use
|
||||
starts the runtime; required permissions are authorized at that activation
|
||||
boundary. Optional permissions are requested when a broker operation needs them.
|
||||
Missing/denied decisions fail closed. Never interpret an Enabled badge as proof
|
||||
that terminal, credential, network or filesystem access has been approved.
|
||||
@@ -0,0 +1,47 @@
|
||||
# Native Electron handoff
|
||||
|
||||
Repository: binaricat/Netcatty. Frozen baseline: 0256e7471d66bbbca077c361ef379d7b11ce8465.
|
||||
Stack: feat/plugin-compatibility-review -> feat/plugin-settings-manager -> feat/plugin-curated-catalog.
|
||||
Use the final catalog branch for all three slices. Do not merge or release.
|
||||
|
||||
1. Check out the branch on the approved desktop. Run npm ci and npm run build.
|
||||
2. Build the first-party sample:
|
||||
npm run build:plugin-packages
|
||||
node packages/plugin-cli/dist/cli.js pack examples/plugins/starter-netcatty --out /tmp/starter-netcatty.ncpkg
|
||||
node packages/plugin-cli/dist/cli.js validate /tmp/starter-netcatty.ncpkg
|
||||
3. Launch the actual Electron binary without NETCATTY_PLUGIN_DEV and with a new disposable
|
||||
--user-data-dir path, keeping Chromium sandbox enabled. Use the desktop's working
|
||||
display and safe configuration; never --no-sandbox or real profiles/accounts.
|
||||
4. Settings > Plugins: record Community plugins list and Plugin Starter details.
|
||||
Open README: actual pinned first-party GitHub retrieval; HTML inert, no remote images.
|
||||
It intentionally says source-only: no downloadable artifact/release was invented.
|
||||
5. Import /tmp/starter-netcatty.ncpkg. It must remain disabled. Expand the installed
|
||||
item: manifest permissions/resources, sensitive markers, Netcatty/API ranges,
|
||||
SHA-256. Enable allows lazy activation; it does not grant permissions.
|
||||
First command/view use prompts before runtime creation. Denying must not run code.
|
||||
6. With approved non-advanced permissions for the first-party example, edit Greeting
|
||||
and open Starter: Greeting Panel (入门:问候面板) in the command palette.
|
||||
Click Show greeting (显示问候语) and record the preference rendered in the panel.
|
||||
No real host, cloud account, terminal credentials, runtime.advanced or third-party code.
|
||||
Use Starter 0.1.1 or later. Its menus/views permissions support only the declared
|
||||
command and sandboxed panel; do not expect terminal output.
|
||||
7. Disable, re-enable, close/restart the app with the same synthetic profile. Confirm
|
||||
settings persist. Re-import same .ncpkg and verify it stops and stays disabled.
|
||||
8. Uninstall only the synthetic plugin using the two-step confirmation. Code/local KV
|
||||
are removed; user-owned settings/secrets/sync sidecars have documented retention.
|
||||
9. On machines without a usable OS vault, the manager shows a downgrade notice;
|
||||
secrets remain blocked but browsing and non-secret examples work.
|
||||
|
||||
Compatibility upgrade/downgrade, required-feature changes, integrity mismatch,
|
||||
manifest mismatch, Node rejection, cleanup and traversal have automated synthetic
|
||||
coverage. Do not claim the above native steps were executed on this executor.
|
||||
|
||||
This executor's native launch failed at Chromium's SUID sandbox configuration:
|
||||
chrome-sandbox is not root-owned mode 4755. No sandbox weakening was attempted.
|
||||
Native video is pending on the parent task's working desktop.
|
||||
|
||||
10. Use the same AI-settings-style subpages: Installed and Community. Search by
|
||||
name/author/description, open details and return. Verify README error/retry UI
|
||||
if network is unavailable; do not bypass the network restriction.
|
||||
11. Inspect human-readable permission name, effect, scope, required/optional and
|
||||
risk. Enabled means eligible for lazy activation, not permissions granted.
|
||||
@@ -0,0 +1,54 @@
|
||||
# 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.
|
||||
|
||||
## Manager window
|
||||
|
||||
Settings > Plugins keeps an explicit Open plugin manager entry. The existing Radix Dialog opens a modal in the current renderer, up to 1180px wide and 88dvh high. Installed and Community, local import, manifest permissions and README remain inside it. Closing releases the mounted manager hooks; reopening starts at Installed. Detail Back stays inside the manager and preserves the Community search. Escape closes the manager and restores focus to its trigger.
|
||||
|
||||
This uses the existing theme, dialog focus handling, plugin host and trusted bridge. A separate Electron BrowserWindow would require another renderer lifecycle, preload/context provisioning and trusted-sender review without providing a needed capability here. No window IPC, plugin runtime or grants are added.
|
||||
|
||||
## Existing icon support
|
||||
|
||||
- `packages/plugin-contract/schema/plugin-contract.schema.json`: `IconReference` is either a host-owned theme icon or a package icon with `light` and optional `dark` paths. It exists on commands, menus and views. `PluginManifest` has no top-level icon and rejects unknown properties.
|
||||
- `packages/plugin-cli/src/manifest.ts` validates contribution icon paths. `archive.ts` checks the declared resources during packaging and archive inspection. Missing files and unsafe paths are rejected.
|
||||
- `electron/plugins/contributionIconService.cjs` checks declaration ownership and current installed version before and after loading. It requires enabled/non-quarantined status, but does not require a running runtime. It reads a verified package root through `pluginProtocol.cjs` containment/realpath checks. Current input/output limits are 512 KiB, source edge 4096px, 16 megapixels, output edge 64px. Formats include PNG/WebP and SVG, JPEG, GIF, ICO.
|
||||
- `contributionIconRasterizer.cjs` decodes in a disposable sandboxed, network-denied BrowserWindow and returns bounded PNG data. Its SVG inspection is not a general SVG sanitizer; isolation/CSP/rasterization remain essential.
|
||||
- `application/state/usePluginContributionIcon.ts` discards stale requests. `components/plugins/PluginContributionIcon.tsx` selects light/dark images or a Puzzle fallback.
|
||||
- `PluginMarketplace.tsx` currently uses the host Package glyph. The catalog has no separately verified logo asset. Installed/detail surfaces do not expose a plugin brand logo.
|
||||
|
||||
An author can already add a contribution icon (inside a command/menu/view):
|
||||
|
||||
```json
|
||||
"icon": { "kind": "package", "light": "assets/action.png", "dark": "assets/action-dark.png" }
|
||||
```
|
||||
|
||||
Include both files in the package and run CLI validate/pack. This changes that contribution's icon, not its market listing logo.
|
||||
|
||||
## Proposed minimal brand-icon contract
|
||||
|
||||
Reuse `IconReference`, adding an **optional** `PluginManifest.icon`; do not invent a URL/logo string format. A package brand icon would look like this after support ships:
|
||||
|
||||
```json
|
||||
"icon": {
|
||||
"kind": "package",
|
||||
"light": "assets/logo.png",
|
||||
"dark": "assets/logo-dark.webp"
|
||||
}
|
||||
```
|
||||
|
||||
Recommended first version:
|
||||
|
||||
- Static PNG or WebP, transparent background if desired; recommend a square 128–256px image. Enforce 256 KiB per source and a 512px maximum edge, validate MIME/signature and decoded dimensions, reject animated input, output bounded raster PNG. A single `light` asset works in both themes; `dark` is optional. Host theme icons can reuse the existing allowlist without package I/O.
|
||||
- Relative package paths only: no URL, absolute/drive/UNC paths, traversal, backslash, NUL, symlink escape or archive special entries. Do not read arbitrary renderer-supplied paths. Verify exact package hash/version and declaration on each uncached resolution; key caches by package identity/hash/theme, and discard stale results on update/uninstall.
|
||||
- Old plugins omit the field and continue to work with one shared host fallback. Per `contract-and-sdk.md`, adding this optional field requires an API/schema revision. New packages using it must declare the matching API range and required feature, plus a minimum host version that understands it: older strict schemas will reject an unknown field. Do not promise new-manifest compatibility with older hosts.
|
||||
- Extend the single contract and regenerate its consumers. Extend CLI path enumeration and both pack/archive checks; enforce image bounds during validation/import, not only in the UI. Reuse contained-file reads and the bounded rasterizer instead of adding file:// or runtime resource access in the application renderer.
|
||||
- Separate brand-image availability from contribution availability. Verified installed packages may show a logo while disabled without enabling or activating code. Do not loosen the existing contribution-icon enabled gate globally. Share the decoding implementation through a narrow brand-image operation that resolves the active installed declaration itself.
|
||||
- For uninstalled market entries, do not execute or install a plugin to obtain its logo. Use a small, catalog-reviewed static asset bundled with Netcatty, with its source commit and digest bound to that catalog version; absent assets use the same fallback. In particular the two current source-only examples have no downloadable artifact to extract. A later remote asset transport would need its own bounded allowlist/hash/redirect/cache policy and is outside this first proposal.
|
||||
- Use one brand-icon component in catalog cards, catalog details and installed lists/details. On decode/read failure keep the fallback and metadata/actions usable; do not fall back to a remote URL. Icons are decorative, with plugin names remaining accessible text.
|
||||
|
||||
Do not add brand SVG support initially. Existing contribution SVG support is not removed. SVG brings XML/CSS/external-reference, filter and decoding complexity; supporting it for distribution means retaining isolated offline rasterization, bounded dimensions/time/concurrency and malformed/hostile-source tests. Raster PNG/WebP covers the initial need with less format complexity, though image decoders still require limits and isolation.
|
||||
|
||||
## Validation required before implementing brand icons
|
||||
|
||||
Contract/CLI tests should cover old manifests, optional fields, missing files, both themes, spoofed MIME, animation, oversized/zero dimensions, archive traversal and symlinks. Host tests should cover disabled installed packages without activation, source-only catalog fallback, stale update/uninstall results and no remote fetch. UI/native checks should cover all three surfaces, broken-image fallback, light/dark themes and accessible names. No new host, filesystem, network or terminal permission should be granted for displaying a verified logo.
|
||||
@@ -43,15 +43,17 @@ directory was present at this baseline.
|
||||
User approved public source plus a clear license for catalog entries, and manual
|
||||
confirmation of plugin updates. Private local packages remain separate.
|
||||
|
||||
Recommended public v1 eligibility: browser runtime only. Utility Node, native
|
||||
User-approved public v1 eligibility: browser runtime only. Utility Node, native
|
||||
companions and raw terminal interceptors retain the development preview path.
|
||||
This excludes advanced provider implementations requiring Node/native binaries;
|
||||
ordinary browser contributions, broker-backed networking and providers remain
|
||||
possible. This policy needs product confirmation; no existing advanced path is
|
||||
removed by this review.
|
||||
possible. No existing local advanced development path is removed.
|
||||
|
||||
Keep `NETCATTY_PLUGIN_DEV=1` until distribution trust, permission UI, compatibility
|
||||
migration and native acceptance are reviewed. Passing tests is not proof of
|
||||
After product review, normal Settings now admits browser-only packages. The
|
||||
`NETCATTY_PLUGIN_DEV=1` flag gates advanced local packages only; their existing
|
||||
permission prompts remain mandatory. Shared host/UI classification preserves
|
||||
browser-first placement for dual-entry packages while excluding them from normal
|
||||
admission. Native acceptance remains pending. Passing tests is not proof of
|
||||
security isolation. Do not describe utility processes as broker-constrained.
|
||||
|
||||
A future KV sync feature needs an explicit manifest opt-in schema, size limits,
|
||||
@@ -65,3 +67,13 @@ A hash mismatch must fail rather than fall back to latest. README must not execu
|
||||
HTML or automatically fetch remote media. Directory PR checks are a bounded
|
||||
review process, not a security certification or a guarantee that maintainers will
|
||||
repair third-party plugins.
|
||||
|
||||
## Validation at the implementation stage
|
||||
|
||||
The full repository TypeScript check has 773 diagnostics on both the frozen main
|
||||
and this implementation. After normalizing worktree paths and line shifts, no
|
||||
new diagnostics were introduced. This existing debt includes plugin integration
|
||||
types and should be addressed in a separate API-drift cleanup rather than being
|
||||
hidden by changing public contracts opportunistically. Canonical contract drift
|
||||
checking and plugin runtime tests pass for this implementation; native Electron
|
||||
UI acceptance is tracked separately in `native-acceptance.md`.
|
||||
@@ -0,0 +1,44 @@
|
||||
"use strict";
|
||||
|
||||
// Presentation only. Admission and grants remain owned by the canonical schema
|
||||
// and permission engine. Tests require exact coverage of its permission enum.
|
||||
const PERMISSION_DESCRIPTIONS = {
|
||||
storage: [false, "Plugin storage", "Save and read this plugin's local data.", "插件存储", "保存和读取此插件自己的本地数据。"],
|
||||
"runtime.advanced": [true, "Full device access", "Run Node code with filesystem and network access outside broker restrictions. Developer mode only.", "完整设备访问", "运行 Node 代码,可直接访问文件系统和网络,不受代理权限限制。仅限开发者模式。"],
|
||||
"settings.read": [false, "Read plugin settings", "Read this plugin's configured preferences.", "读取插件设置", "读取此插件自己的设置。"],
|
||||
"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.", "菜单入口", "将此插件的操作加入应用菜单。"],
|
||||
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.", "写入剪贴板", "替换剪贴板内容,可能影响你随后粘贴的数据。"],
|
||||
"terminal.metadata": [true, "Terminal metadata", "Read terminal/session details that may identify hosts and activity.", "终端元数据", "读取终端和会话信息,可能暴露主机及操作活动。"],
|
||||
"terminal.output": [true, "Read terminal output", "Receive terminal output through supported providers; it may contain credentials or private data.", "读取终端输出", "通过支持的提供器接收终端输出,其中可能包含凭据或隐私信息。"],
|
||||
"terminal.input": [true, "Send terminal input", "Send input to a terminal; input may execute commands or change a remote host.", "发送终端输入", "向终端发送输入,可能执行命令或更改远程主机。"],
|
||||
"terminal.decorate": [false, "Terminal presentation", "Provide annotations and visual changes to terminal content.", "终端展示", "为终端内容提供标注和视觉效果。"],
|
||||
"terminal.complete": [true, "Terminal completion", "Receive completion context and suggest terminal input.", "终端补全", "接收补全上下文并建议终端输入。"],
|
||||
"terminal.intercept.input": [true, "Rewrite terminal input", "Inspect and modify input before delivery. Not every secret/no-echo input can be detected. Developer mode only.", "改写终端输入", "检查并修改发送前的输入。无法识别所有秘密或无回显输入。仅限开发者模式。"],
|
||||
"terminal.intercept.output": [true, "Rewrite terminal output", "Inspect and modify output, including information shown as command results. Developer mode only.", "改写终端输出", "检查并修改输出,包括呈现为命令结果的信息。仅限开发者模式。"],
|
||||
"vault.metadata": [true, "Host inventory", "Read host metadata that can reveal infrastructure details.", "主机清单", "读取主机元数据,可能暴露基础设施信息。"],
|
||||
"vault.write": [true, "Change host inventory", "Request changes to stored host records.", "修改主机清单", "请求更改保存的主机记录。"],
|
||||
"vault.credentials": [true, "Use host credentials", "Use approved host credential references through operation-bound leases; this can authenticate to hosts.", "使用主机凭据", "通过绑定操作的租约使用已批准的主机凭据引用,可能用于向主机认证。"],
|
||||
"sftp.read": [true, "Read remote files", "Read remote file content through supported host APIs.", "读取远程文件", "通过支持的宿主 API 读取远程文件内容。"],
|
||||
"sftp.write": [true, "Change remote files", "Write or change remote files through supported host APIs.", "修改远程文件", "通过支持的宿主 API 写入或修改远程文件。"],
|
||||
network: [true, "Network requests", "Contact approved origins. Data sent to them leaves this device.", "网络请求", "访问已批准的网络来源。发送的数据会离开此设备。"],
|
||||
"filesystem.read": [true, "Read local files", "Read approved local paths, which may contain sensitive files.", "读取本地文件", "读取已批准的本地路径,其中可能包含敏感文件。"],
|
||||
"filesystem.write": [true, "Change local files", "Write to approved local paths; existing content may be changed.", "修改本地文件", "写入已批准的本地路径,可能改变现有文件内容。"],
|
||||
secrets: [true, "Plugin secrets", "Store and use this plugin's secrets through OS-backed storage and controlled leases.", "插件秘密", "通过系统密钥存储与受控租约保存和使用此插件的秘密。"],
|
||||
"companion.execute": [true, "Run native programs", "Launch digest-verified companion executables with device authority. Developer mode only.", "运行本机程序", "启动经过摘要校验的辅助可执行程序,拥有设备权限。仅限开发者模式。"],
|
||||
"provider.terminal": [true, "Terminal providers", "Provide terminal features; separate permissions govern access to terminal data.", "终端提供器", "提供终端扩展功能;终端数据访问由额外权限控制。"],
|
||||
"provider.connection": [true, "Connection provider", "Implement a connection backend and handle its session data.", "连接提供器", "实现连接后端并处理对应会话数据。"],
|
||||
"provider.authentication": [true, "Authentication provider", "Participate in login challenges using approved credential leases.", "认证提供器", "使用已批准的凭据租约参与登录验证。"],
|
||||
"provider.sync": [true, "Encrypted sync storage", "Store encrypted sync objects; this permission does not provide the master key or plaintext vault.", "加密同步存储", "存储加密同步对象;此权限不提供主密钥或明文凭据库。"],
|
||||
"provider.importer": [true, "Import provider", "Parse user-selected files into drafts for host review; input files may be sensitive.", "导入提供器", "解析用户选择的文件并生成供宿主确认的草稿;输入文件可能包含敏感数据。"],
|
||||
};
|
||||
function describePluginPermission(permission, locale = "en") {
|
||||
const entry = Object.hasOwn(PERMISSION_DESCRIPTIONS, permission) ? PERMISSION_DESCRIPTIONS[permission] : null;
|
||||
const chinese = locale.startsWith("zh");
|
||||
if (!entry) return { known: false, sensitive: true, label: chinese ? "无法识别的权限" : "Unrecognized permission", description: chinese ? "此版本无法识别该权限,拒绝授权。" : "This version does not recognize this permission. Access is denied." };
|
||||
return { known: true, sensitive: entry[0], label: entry[chinese ? 3 : 1], description: entry[chinese ? 4 : 2] };
|
||||
}
|
||||
module.exports = { PERMISSION_DESCRIPTIONS, describePluginPermission };
|
||||
@@ -0,0 +1,15 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const test = require('node:test');
|
||||
const schema = require('../packages/plugin-contract/schema/plugin-contract.schema.json');
|
||||
const { PERMISSION_DESCRIPTIONS, describePluginPermission } = require('./pluginPermissionDescriptions.cjs');
|
||||
test('permission explanation coverage cannot drift from the canonical permission catalog', () => {
|
||||
assert.deepEqual(Object.keys(PERMISSION_DESCRIPTIONS).sort(), [...schema.$defs.PluginPermission.enum].sort());
|
||||
for (const id of schema.$defs.PluginPermission.enum) {
|
||||
for (const locale of ['en', 'zh-CN']) {
|
||||
const description = describePluginPermission(id, locale);
|
||||
assert.equal(description.known, true); assert.ok(description.description.length > 5);
|
||||
}
|
||||
}
|
||||
assert.equal(describePluginPermission('unknown.permission').sensitive, true);
|
||||
assert.equal(describePluginPermission('unknown.permission').known, false);
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
"use strict";
|
||||
|
||||
// Shared by host placement, release admission, and the installed-package UI.
|
||||
// A Node entrypoint does not change default placement, but is not admitted to
|
||||
// the normal browser-only product path.
|
||||
function describePluginRuntime(manifest) {
|
||||
const providers = manifest?.contributes?.providers ?? [];
|
||||
const permissions = [...(manifest?.permissions?.required ?? []), ...(manifest?.permissions?.optional ?? [])];
|
||||
const permissionIds = permissions.map((entry) => typeof entry === "string" ? entry : entry.permission);
|
||||
const hasCompanions = Boolean(manifest?.companionExecutables?.length);
|
||||
const hasInterceptors = providers.some((provider) => (
|
||||
provider.kind === "terminal.interceptor.input" || provider.kind === "terminal.interceptor.output"
|
||||
));
|
||||
const declaresAdvanced = permissionIds.some((permission) => [
|
||||
"runtime.advanced", "companion.execute", "terminal.intercept.input", "terminal.intercept.output",
|
||||
].includes(permission));
|
||||
const hasBrowser = Boolean(manifest?.main?.browser);
|
||||
const hasNode = Boolean(manifest?.main?.node);
|
||||
const defaultRuntimeKind = hasBrowser && !hasCompanions && !hasInterceptors ? "browser" : "utility";
|
||||
return {
|
||||
defaultRuntimeKind,
|
||||
hasNode,
|
||||
declaresAdvanced,
|
||||
browserOnly: hasBrowser && !hasNode && !hasCompanions && !hasInterceptors && !declaresAdvanced,
|
||||
};
|
||||
}
|
||||
|
||||
function assertBrowserOnlyPlugin(manifest) {
|
||||
if (!describePluginRuntime(manifest).browserOnly) {
|
||||
const error = new Error("This package requires advanced developer mode; normal plugin management accepts browser-only packages");
|
||||
error.code = "PLUGIN_UNSUPPORTED_RUNTIME";
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { describePluginRuntime, assertBrowserOnlyPlugin };
|
||||
@@ -0,0 +1,21 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const test = require('node:test');
|
||||
const { describePluginRuntime, assertBrowserOnlyPlugin } = require('./pluginRuntimePolicy.cjs');
|
||||
|
||||
test('dual entrypoints retain browser default placement but require developer admission', () => {
|
||||
const manifest = { main: { browser: 'browser.js', node: 'node.js' } };
|
||||
assert.equal(describePluginRuntime(manifest).defaultRuntimeKind, 'browser');
|
||||
assert.equal(describePluginRuntime(manifest).hasNode, true);
|
||||
assert.throws(() => assertBrowserOnlyPlugin(manifest), /developer mode/);
|
||||
});
|
||||
test('normal release admission rejects advanced declarations and privileged providers', () => {
|
||||
assert.doesNotThrow(() => assertBrowserOnlyPlugin({ main: { browser: 'index.js' }, permissions: { required: ['settings.read', 'commands'] } }));
|
||||
for (const permission of ['runtime.advanced', 'companion.execute', 'terminal.intercept.input', 'terminal.intercept.output']) {
|
||||
for (const kind of ['required', 'optional']) {
|
||||
assert.throws(() => assertBrowserOnlyPlugin({ main: { browser: 'index.js' }, permissions: { [kind]: [permission] } }), /developer mode/);
|
||||
}
|
||||
}
|
||||
const manifest = { main: { browser: 'index.js', node: 'node.js' }, contributes: { providers: [{ kind: 'terminal.interceptor.input' }] } };
|
||||
assert.equal(describePluginRuntime(manifest).defaultRuntimeKind, 'utility');
|
||||
assert.throws(() => assertBrowserOnlyPlugin(manifest), /developer mode/);
|
||||
});
|
||||
@@ -68,6 +68,8 @@ module.exports = {
|
||||
// Renderer and Mosh's main-process bootstrap share the same
|
||||
// fail-closed prompt classifier at runtime.
|
||||
'domain/terminalPromptSecurity.shared.cjs',
|
||||
'domain/pluginRuntimePolicy.cjs',
|
||||
'domain/pluginPermissionDescriptions.cjs',
|
||||
// Main-process SSH, SFTP, and port-forward bridges share this matcher.
|
||||
// TypeScript under domain/ is excluded below, so the .cjs must be listed.
|
||||
'domain/systemManager/containerRuntime.shared.cjs',
|
||||
|
||||
@@ -148,8 +148,8 @@ function createBridgeRegistrar(context) {
|
||||
} = require("../plugins/pluginBridge.cjs");
|
||||
let terminalWorkerManager = null;
|
||||
const { toElectronAccelerator } = require("../plugins/keybindings.cjs");
|
||||
const { startDevelopmentPluginHost } = require("../plugins/hostBootstrap.cjs");
|
||||
const pluginHostService = startDevelopmentPluginHost({
|
||||
const { startPluginHost } = require("../plugins/hostBootstrap.cjs");
|
||||
const pluginHostService = startPluginHost({
|
||||
env: process.env,
|
||||
createService: () => {
|
||||
const { createPluginHostService } = require("../plugins/hostService.cjs");
|
||||
@@ -166,6 +166,7 @@ function createBridgeRegistrar(context) {
|
||||
electron: electronModule,
|
||||
safeStorage,
|
||||
requestPermissionDecision: createNativePermissionDecisionProvider({
|
||||
getLocale: () => getWindowManager().getCurrentLanguage?.() ?? "en",
|
||||
dialog: electronModule.dialog,
|
||||
window: win,
|
||||
}),
|
||||
@@ -210,6 +211,7 @@ function createBridgeRegistrar(context) {
|
||||
});
|
||||
registerPluginBridge(ipcMain, {
|
||||
manager: pluginHostService?.manager,
|
||||
catalogService: pluginHostService?.catalogService,
|
||||
contributionService: pluginHostService?.contributionService,
|
||||
terminalProviderService: pluginHostService?.terminalProviderService,
|
||||
terminalDataPipelineService: pluginHostService?.terminalDataPipelineService,
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
[
|
||||
{
|
||||
"id": "com.netcatty.starter",
|
||||
"version": "0.1.1",
|
||||
"name": "Plugin Starter",
|
||||
"description": "First-party greeting panel, local settings and commands, without terminal or secret access.",
|
||||
"author": "Netcatty",
|
||||
"repository": "binaricat/Netcatty",
|
||||
"commit": "3efabf2b0f347e5f8f1ea5c148ab0716c36d3be1",
|
||||
"license": "GPL-3.0-or-later",
|
||||
"readme": "examples/plugins/starter-netcatty/README.md",
|
||||
"manifest": {
|
||||
"manifestVersion": 1,
|
||||
"id": "com.netcatty.starter",
|
||||
"name": "starter-netcatty",
|
||||
"displayName": {
|
||||
"en": "Plugin Starter",
|
||||
"zh-CN": "插件入门"
|
||||
},
|
||||
"description": "First-party settings and command example. No terminal, network, filesystem or secret access.",
|
||||
"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": "dist/index.js"
|
||||
},
|
||||
"activationEvents": [
|
||||
"onCommand:com.netcatty.starter.greet",
|
||||
"onView:com.netcatty.starter.panel"
|
||||
],
|
||||
"permissions": {
|
||||
"required": [
|
||||
"commands",
|
||||
"settings.read",
|
||||
"menus",
|
||||
"views"
|
||||
],
|
||||
"optional": []
|
||||
},
|
||||
"contributes": {
|
||||
"settings": [
|
||||
{
|
||||
"id": "com.netcatty.starter.greeting",
|
||||
"label": {
|
||||
"en": "Greeting",
|
||||
"zh-CN": "问候语"
|
||||
},
|
||||
"description": "A local preference for the example command.",
|
||||
"control": "text",
|
||||
"scope": "application",
|
||||
"default": "Hello, synthetic world!"
|
||||
}
|
||||
],
|
||||
"commands": [
|
||||
{
|
||||
"id": "com.netcatty.starter.greet",
|
||||
"title": {
|
||||
"en": "Starter: Read Greeting",
|
||||
"zh-CN": "入门:读取问候语"
|
||||
}
|
||||
}
|
||||
],
|
||||
"menus": [
|
||||
{
|
||||
"command": "com.netcatty.starter.greet",
|
||||
"location": "commandPalette",
|
||||
"group": "examples"
|
||||
}
|
||||
],
|
||||
"views": [
|
||||
{
|
||||
"id": "com.netcatty.starter.panel",
|
||||
"title": {
|
||||
"en": "Starter: Greeting Panel",
|
||||
"zh-CN": "入门:问候面板"
|
||||
},
|
||||
"location": "modal",
|
||||
"entry": "panel.html"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "com.netcatty.hello",
|
||||
"version": "0.1.0",
|
||||
"name": "Hello Netcatty",
|
||||
"description": "First-party example: greeting settings, commands and terminal providers. Build locally and import.",
|
||||
"author": "Netcatty",
|
||||
"repository": "binaricat/Netcatty",
|
||||
"commit": "4336f8fd53a05a48d6bbc9ad3ce0ca88b121862f",
|
||||
"license": "GPL-3.0-or-later",
|
||||
"readme": "examples/plugins/hello-netcatty/README.md",
|
||||
"manifest": {
|
||||
"$schema": "../../../packages/plugin-contract/schema/plugin-contract.schema.json",
|
||||
"manifestVersion": 1,
|
||||
"id": "com.netcatty.hello",
|
||||
"name": "hello-netcatty",
|
||||
"displayName": {
|
||||
"en": "Hello Netcatty",
|
||||
"zh-CN": "你好,Netcatty"
|
||||
},
|
||||
"description": "Minimal plugin contract and SDK example.",
|
||||
"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"
|
||||
},
|
||||
"main": {
|
||||
"browser": "dist/index.js"
|
||||
},
|
||||
"activationEvents": [
|
||||
"onCommand:com.netcatty.hello.sayHello",
|
||||
"onProvider:com.netcatty.hello.completion",
|
||||
"onProvider:com.netcatty.hello.decoration",
|
||||
"onProvider:com.netcatty.hello.theme"
|
||||
],
|
||||
"permissions": {
|
||||
"required": [
|
||||
"commands",
|
||||
"settings.read",
|
||||
"menus",
|
||||
"provider.terminal",
|
||||
"terminal.complete",
|
||||
"terminal.output",
|
||||
"terminal.decorate"
|
||||
],
|
||||
"optional": []
|
||||
},
|
||||
"contributes": {
|
||||
"settings": [
|
||||
{
|
||||
"id": "com.netcatty.hello.greeting",
|
||||
"label": "Greeting",
|
||||
"description": "Text used by the example command.",
|
||||
"control": "text",
|
||||
"scope": "application",
|
||||
"default": "Hello from Netcatty"
|
||||
}
|
||||
],
|
||||
"commands": [
|
||||
{
|
||||
"id": "com.netcatty.hello.sayHello",
|
||||
"title": "Say Hello",
|
||||
"category": "Examples"
|
||||
}
|
||||
],
|
||||
"menus": [
|
||||
{
|
||||
"command": "com.netcatty.hello.sayHello",
|
||||
"location": "commandPalette",
|
||||
"group": "examples",
|
||||
"order": 10
|
||||
}
|
||||
],
|
||||
"providers": [
|
||||
{
|
||||
"id": "com.netcatty.hello.completion",
|
||||
"label": "Hello completion",
|
||||
"description": "Suggests the example hello command.",
|
||||
"kind": "terminal.completion"
|
||||
},
|
||||
{
|
||||
"id": "com.netcatty.hello.decoration",
|
||||
"label": "Hello decoration",
|
||||
"description": "Highlights the example greeting in terminal output.",
|
||||
"kind": "terminal.decoration"
|
||||
},
|
||||
{
|
||||
"id": "com.netcatty.hello.theme",
|
||||
"label": "Hello theme",
|
||||
"description": "Adds a subtle cursor color to the example terminal theme.",
|
||||
"kind": "terminal.theme"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,13 @@
|
||||
"use strict";
|
||||
|
||||
const { assertBrowserOnlyPlugin } = require("../../domain/pluginRuntimePolicy.cjs");
|
||||
|
||||
function assertCatalogManifest(manifest, expected) {
|
||||
if (manifest.id !== expected.id || manifest.version !== expected.version) {
|
||||
throw new Error("Catalog package identity/version mismatch");
|
||||
}
|
||||
assertBrowserOnlyPlugin(manifest);
|
||||
if (!manifest.license || manifest.license !== expected.license) throw new Error("Catalog package license mismatch");
|
||||
}
|
||||
|
||||
module.exports = { assertCatalogManifest };
|
||||
@@ -0,0 +1,110 @@
|
||||
"use strict";
|
||||
|
||||
const { createHash } = require("node:crypto");
|
||||
const fs = require("node:fs/promises");
|
||||
const path = require("node:path");
|
||||
const catalog = require("./catalog.json");
|
||||
const { getTempFilePath } = require("../bridges/tempDirBridge.cjs");
|
||||
const { createDefinitionValidator, formatValidationErrors } = require("./contractValidator.cjs");
|
||||
const validateManifest = createDefinitionValidator("PluginManifest");
|
||||
const { assertCatalogManifest } = require("./catalogPolicy.cjs");
|
||||
|
||||
const DOWNLOAD_HOSTS = new Set(["github.com", "raw.githubusercontent.com", "release-assets.githubusercontent.com", "objects.githubusercontent.com"]);
|
||||
function assertDownloadUrl(value) {
|
||||
const url = new URL(value);
|
||||
if (url.protocol !== "https:" || url.username || url.password || url.port || !DOWNLOAD_HOSTS.has(url.hostname)) {
|
||||
throw new Error("Catalog download URL is not an approved HTTPS GitHub source");
|
||||
}
|
||||
return url.href;
|
||||
}
|
||||
function validateCatalog(entries) {
|
||||
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)
|
||||
|| !/^[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
|
||||
|| typeof entry.readme !== "string" || !entry.readme.endsWith(".md")
|
||||
|| entry.readme.split("/").some((p) => !p || p === "." || p === "..") || /[\\%?#\0]/u.test(entry.readme)) {
|
||||
throw new Error("Invalid or unpinned plugin catalog 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) {
|
||||
assertDownloadUrl(entry.artifact.url);
|
||||
if (!/^[a-f0-9]{64}$/u.test(entry.artifact.sha256)) throw new Error("Catalog artifact requires SHA-256");
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
async function fetchBounded(url, limit, fetchImpl = fetch, maxRedirects = 4) {
|
||||
const signal = AbortSignal.timeout(30_000);
|
||||
for (let redirects = 0; redirects <= maxRedirects; redirects++) {
|
||||
const response = await fetchImpl(assertDownloadUrl(url), { redirect: "manual", signal, credentials: "omit", referrerPolicy: "no-referrer" });
|
||||
if ([301, 302, 303, 307, 308].includes(response.status)) {
|
||||
await response.body?.cancel();
|
||||
url = new URL(response.headers.get("location"), url).href;
|
||||
continue;
|
||||
}
|
||||
if (!response.ok) { await response.body?.cancel(); throw new Error(`Catalog download failed (${response.status})`); }
|
||||
if (Number(response.headers.get("content-length")) > limit) {
|
||||
await response.body?.cancel(); throw new Error("Catalog download exceeds size limit");
|
||||
}
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) throw new Error("Catalog download has no body");
|
||||
const chunks = [];
|
||||
let size = 0;
|
||||
try {
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
size += value.byteLength;
|
||||
if (size > limit) throw new Error("Catalog download exceeds size limit");
|
||||
chunks.push(Buffer.from(value));
|
||||
}
|
||||
return Buffer.concat(chunks, size);
|
||||
} finally { await reader.cancel(); }
|
||||
}
|
||||
throw new Error("Too many catalog redirects");
|
||||
}
|
||||
class PluginCatalogService {
|
||||
constructor(options) {
|
||||
this.entries = validateCatalog(structuredClone(options.entries ?? catalog));
|
||||
this.fetch = options.fetch ?? fetch;
|
||||
this.getTempFilePath = options.getTempFilePath ?? getTempFilePath;
|
||||
this.manager = options.manager;
|
||||
this.installing = false;
|
||||
}
|
||||
list() { return structuredClone(this.entries); }
|
||||
get(id) {
|
||||
const entry = this.entries.find((item) => item.id === id);
|
||||
if (!entry) throw new Error("Plugin is not in the curated catalog");
|
||||
return entry;
|
||||
}
|
||||
async readme(id) {
|
||||
const entry = this.get(id);
|
||||
const url = `https://raw.githubusercontent.com/${entry.repository}/${entry.commit}/${entry.readme}`;
|
||||
return (await fetchBounded(url, 256 * 1024, this.fetch, 0)).toString("utf8");
|
||||
}
|
||||
async install(id) {
|
||||
const entry = this.get(id);
|
||||
if (!entry.artifact) throw new Error("This source example has no published package; build and import locally");
|
||||
if (this.installing) throw new Error("A catalog installation is already in progress");
|
||||
this.installing = true;
|
||||
let directory;
|
||||
try {
|
||||
const bytes = await fetchBounded(entry.artifact.url, 32 * 1024 * 1024, this.fetch);
|
||||
if (createHash("sha256").update(bytes).digest("hex") !== entry.artifact.sha256) throw new Error("Catalog package SHA-256 mismatch");
|
||||
directory = await fs.mkdtemp(this.getTempFilePath("plugin-catalog-"));
|
||||
const archive = path.join(directory, "package.ncpkg");
|
||||
await fs.writeFile(archive, bytes, { mode: 0o600, flag: "wx" });
|
||||
return await this.manager.install(archive, { enable: false, catalog: entry });
|
||||
} finally {
|
||||
this.installing = false;
|
||||
if (directory) await fs.rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
module.exports = { PluginCatalogService, assertDownloadUrl, fetchBounded, validateCatalog };
|
||||
@@ -0,0 +1,74 @@
|
||||
"use strict";
|
||||
const assert = require("node:assert/strict");
|
||||
const test = require("node:test");
|
||||
const { createHash } = require("node:crypto");
|
||||
const fs = require("node:fs/promises");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { PluginCatalogService, validateCatalog, fetchBounded } = require("./catalogService.cjs");
|
||||
const { assertCatalogManifest } = require("./catalogPolicy.cjs");
|
||||
const entries = require("./catalog.json");
|
||||
|
||||
test("curated catalog uses pinned public first-party source and rejects advanced manifests", () => {
|
||||
assert.equal(validateCatalog(entries).length, 2);
|
||||
for (const main of [{ node: "index.js" }, { browser: "index.js", node: "node.js" }]) {
|
||||
const entry = structuredClone(entries[0]); entry.manifest.main = main;
|
||||
assert.throws(() => validateCatalog([entry]), /browser-only packages/);
|
||||
}
|
||||
const entry = structuredClone(entries[0]);
|
||||
entry.manifest.permissions.optional.push("runtime.advanced");
|
||||
assert.throws(() => assertCatalogManifest(entry.manifest, entry), /advanced developer mode/);
|
||||
for (const readme of ["../README.md", "a/%2e%2e/README.md", "a\\README.md"]) {
|
||||
assert.throws(() => validateCatalog([{ ...entries[0], readme }]), /unpinned/);
|
||||
}
|
||||
assert.throws(() => validateCatalog([{ ...entries[0], commit: "main" }]), /unpinned/);
|
||||
});
|
||||
|
||||
test("README retrieval is bounded and pinned, never follows a redirect to latest", async () => {
|
||||
const requested = [];
|
||||
const service = new PluginCatalogService({ entries, fetch: async (url) => {
|
||||
requested.push(url); return new Response("# First-party example");
|
||||
} });
|
||||
assert.equal(await service.readme(entries[0].id), "# First-party example");
|
||||
assert.equal(requested[0], `https://raw.githubusercontent.com/${entries[0].repository}/${entries[0].commit}/${entries[0].readme}`);
|
||||
service.fetch = async () => new Response(null, { status: 302, headers: { location: "https://raw.githubusercontent.com/a/b/main/README.md" } });
|
||||
await assert.rejects(service.readme(entries[0].id), /redirects/);
|
||||
await assert.rejects(fetchBounded("https://raw.githubusercontent.com/a/b/c", 3, async () => new Response("1234")), /size limit/);
|
||||
await assert.rejects(fetchBounded("https://127.0.0.1/a", 10), /approved HTTPS/);
|
||||
await assert.rejects(fetchBounded("https://github.com/a", 10, async () => new Response(null, { status: 302, headers: { location: "http://localhost/secret" } })), /approved HTTPS/);
|
||||
});
|
||||
|
||||
test("catalog install verifies the digest, shares manager installation, and never auto-enables", async (context) => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "netcatty-catalog-test-"));
|
||||
context.after(() => fs.rm(root, { recursive: true, force: true }));
|
||||
const bytes = Buffer.from("synthetic bytes: package validation belongs to PackageStore");
|
||||
const entry = { ...structuredClone(entries[0]), artifact: { url: "https://github.com/example/repo/releases/download/v1/plugin.ncpkg", sha256: createHash("sha256").update(bytes).digest("hex") } };
|
||||
let installs = 0;
|
||||
const service = new PluginCatalogService({ entries: [entry], getTempFilePath: (name) => {
|
||||
assert.equal(name, "plugin-catalog-");
|
||||
return path.join(root, name);
|
||||
}, fetch: async () => new Response(bytes), manager: {
|
||||
async install(archive, options) {
|
||||
installs++;
|
||||
assert.equal(path.dirname(path.dirname(archive)), root);
|
||||
assert.equal((await fs.stat(path.dirname(archive))).mode & 0o777, 0o700);
|
||||
assert.deepEqual(await fs.readFile(archive), bytes);
|
||||
assert.equal(options.enable, false);
|
||||
assert.equal(options.catalog.artifact.sha256, entry.artifact.sha256);
|
||||
return { id: entry.id };
|
||||
},
|
||||
} });
|
||||
await service.install(entry.id);
|
||||
assert.equal(installs, 1);
|
||||
assert.deepEqual(await fs.readdir(root), []);
|
||||
service.fetch = async () => new Response("changed");
|
||||
await assert.rejects(service.install(entry.id), /SHA-256 mismatch/);
|
||||
assert.equal(installs, 1);
|
||||
await assert.rejects(service.install("unknown"), /curated catalog/);
|
||||
});
|
||||
|
||||
test("catalog rejects unknown permissions instead of presenting an unimplemented grant", () => {
|
||||
const entry = structuredClone(entries[0]);
|
||||
entry.manifest.permissions.optional.push("future.unknown");
|
||||
assert.throws(() => validateCatalog([entry]), /manifest is invalid/);
|
||||
});
|
||||
@@ -47,6 +47,10 @@ class PluginCredentialCatalog {
|
||||
}
|
||||
}
|
||||
|
||||
isAvailable() {
|
||||
try { this.#assertAvailable(); return true; } catch { return false; }
|
||||
}
|
||||
|
||||
update(entries) {
|
||||
if (!Array.isArray(entries) || entries.length > MAX_CREDENTIAL_CATALOG_ENTRIES) {
|
||||
throw new PluginRpcError(RPC_ERRORS.invalidArgument, "Vault credential catalog is invalid or too large");
|
||||
|
||||
@@ -66,3 +66,9 @@ test("Vault credential catalog fails closed when secure storage is unavailable",
|
||||
/unavailable|not found/i,
|
||||
);
|
||||
});
|
||||
|
||||
test("availability reporting does not unlock a missing OS vault", () => {
|
||||
const catalog = new PluginCredentialCatalog({ safeStorage: { isEncryptionAvailable: () => false } });
|
||||
assert.equal(catalog.isAvailable(), false);
|
||||
assert.throws(() => catalog.update([]), /OS-backed Vault/);
|
||||
});
|
||||
@@ -614,14 +614,15 @@ class PluginDatabase {
|
||||
|
||||
listSyncSidecars(pluginId) {
|
||||
return this.db.prepare(`
|
||||
SELECT plugin_id, kind, key, value_json, updated_at
|
||||
SELECT plugin_id, kind, CAST(key AS BLOB) AS key_bytes, value_json, updated_at
|
||||
FROM plugin_sync_sidecars
|
||||
WHERE plugin_id = ?
|
||||
ORDER BY kind COLLATE BINARY, key COLLATE BINARY
|
||||
`).all(pluginId).map((row) => ({
|
||||
pluginId: row.plugin_id,
|
||||
kind: row.kind,
|
||||
key: row.key,
|
||||
// Node 22 truncates TEXT results at NUL; settings keys contain NUL separators.
|
||||
key: Buffer.from(row.key_bytes).toString("utf8"),
|
||||
value: parseJson(row.value_json, "sync sidecar value"),
|
||||
updatedAt: Number(row.updated_at),
|
||||
}));
|
||||
@@ -629,13 +630,14 @@ class PluginDatabase {
|
||||
|
||||
listAllSyncSidecars() {
|
||||
return this.db.prepare(`
|
||||
SELECT plugin_id, kind, key, value_json, updated_at
|
||||
SELECT plugin_id, kind, CAST(key AS BLOB) AS key_bytes, value_json, updated_at
|
||||
FROM plugin_sync_sidecars
|
||||
ORDER BY plugin_id COLLATE BINARY, kind COLLATE BINARY, key COLLATE BINARY
|
||||
`).all().map((row) => ({
|
||||
pluginId: row.plugin_id,
|
||||
kind: row.kind,
|
||||
key: row.key,
|
||||
// Node 22 truncates TEXT results at NUL; settings keys contain NUL separators.
|
||||
key: Buffer.from(row.key_bytes).toString("utf8"),
|
||||
value: parseJson(row.value_json, "sync sidecar value"),
|
||||
updatedAt: Number(row.updated_at),
|
||||
}));
|
||||
|
||||
@@ -833,3 +833,14 @@ test("inferPluginIdForSyncProvider never guesses from credential key prefixes",
|
||||
assert.equal(database.inferPluginIdForSyncProvider("com.missing.sync"), undefined);
|
||||
database.close();
|
||||
});
|
||||
|
||||
test("sync sidecar enumeration preserves NUL-separated setting coordinates", (context) => {
|
||||
const database = createDatabase(context);
|
||||
context.after(() => database.close());
|
||||
const key = "com.example.test.theme\0application\0application";
|
||||
database.setSyncSidecar("com.example.test", "settings", key, "dark", 10);
|
||||
assert.equal(database.listSyncSidecars("com.example.test")[0].key, key);
|
||||
assert.equal(database.listAllSyncSidecars()[0].key, key);
|
||||
database.replaceAllSyncSidecars(database.listAllSyncSidecars());
|
||||
assert.equal(database.getSyncSidecar("com.example.test", "settings", key).value, "dark");
|
||||
});
|
||||
@@ -1,9 +1,6 @@
|
||||
"use strict";
|
||||
|
||||
const { isPluginDevelopmentEnabled } = require("./constants.cjs");
|
||||
|
||||
function startDevelopmentPluginHost(options) {
|
||||
if (!isPluginDevelopmentEnabled(options.env)) return null;
|
||||
function startPluginHost(options) {
|
||||
const reportFailure = (message, error) => {
|
||||
try { options.logger?.error?.(message, error); } catch {}
|
||||
};
|
||||
@@ -30,4 +27,4 @@ function startDevelopmentPluginHost(options) {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { startDevelopmentPluginHost };
|
||||
module.exports = { startPluginHost };
|
||||
@@ -3,24 +3,24 @@
|
||||
const assert = require("node:assert/strict");
|
||||
const test = require("node:test");
|
||||
|
||||
const { startDevelopmentPluginHost } = require("./hostBootstrap.cjs");
|
||||
const { startPluginHost } = require("./hostBootstrap.cjs");
|
||||
|
||||
test("disabled development host does not construct plugin infrastructure", () => {
|
||||
test("normal release constructs the browser plugin host without a developer flag", async () => {
|
||||
let created = 0;
|
||||
const service = startDevelopmentPluginHost({
|
||||
const service = { manager: { async initialize() {}, async shutdown() {} } };
|
||||
assert.equal(startPluginHost({
|
||||
env: {},
|
||||
createService() { created += 1; },
|
||||
createService() { created += 1; return service; },
|
||||
registerShutdown() {},
|
||||
logger: { error() {} },
|
||||
});
|
||||
assert.equal(service, null);
|
||||
assert.equal(created, 0);
|
||||
}), service);
|
||||
assert.equal(created, 1);
|
||||
});
|
||||
|
||||
test("synchronous plugin host construction failure leaves Netcatty available", () => {
|
||||
const errors = [];
|
||||
let registered = 0;
|
||||
const service = startDevelopmentPluginHost({
|
||||
const service = startPluginHost({
|
||||
env: { NETCATTY_PLUGIN_DEV: "1" },
|
||||
createService() { throw new Error("corrupt plugin database"); },
|
||||
registerShutdown() { registered += 1; },
|
||||
@@ -41,7 +41,7 @@ test("created plugin host initializes and registers one shutdown owner", async (
|
||||
async shutdown() { shutdown += 1; },
|
||||
},
|
||||
};
|
||||
assert.equal(startDevelopmentPluginHost({
|
||||
assert.equal(startPluginHost({
|
||||
env: { NETCATTY_PLUGIN_DEV: "1" },
|
||||
createService: () => service,
|
||||
registerShutdown(handler) { shutdownHandler = handler; },
|
||||
@@ -63,7 +63,7 @@ test("asynchronous initialization failure closes and leaves the host unavailable
|
||||
shutdown: async () => { shutdown += 1; },
|
||||
},
|
||||
};
|
||||
assert.equal(startDevelopmentPluginHost({
|
||||
assert.equal(startPluginHost({
|
||||
env: { NETCATTY_PLUGIN_DEV: "1" },
|
||||
createService: () => service,
|
||||
registerShutdown(handler) { shutdownHandler = handler; },
|
||||
@@ -79,7 +79,7 @@ test("asynchronous initialization failure closes and leaves the host unavailable
|
||||
test("failure while registering shutdown closes the constructed host", async () => {
|
||||
let shutdown = 0;
|
||||
const errors = [];
|
||||
const service = startDevelopmentPluginHost({
|
||||
const service = startPluginHost({
|
||||
env: { NETCATTY_PLUGIN_DEV: "1" },
|
||||
createService: () => ({
|
||||
manager: {
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
const path = require("node:path");
|
||||
|
||||
const { PLUGIN_API_VERSION } = require("./constants.cjs");
|
||||
const { assertBrowserOnlyPlugin } = require("../../domain/pluginRuntimePolicy.cjs");
|
||||
const { isPluginDevelopmentEnabled, PLUGIN_API_VERSION } = require("./constants.cjs");
|
||||
const { PluginDatabase } = require("./database.cjs");
|
||||
const { PluginCompanionSupervisor } = require("./companionSupervisor.cjs");
|
||||
const { PluginCredentialBroker, assertLeaseParams } = require("./credentialBroker.cjs");
|
||||
@@ -16,6 +17,7 @@ const {
|
||||
createUtilityModuleMappings,
|
||||
normalizePluginModuleResources,
|
||||
} = require("./moduleResources.cjs");
|
||||
const { PluginCatalogService } = require("./catalogService.cjs");
|
||||
const { PackageStore } = require("./packageStore.cjs");
|
||||
const { createPluginPaths } = require("./paths.cjs");
|
||||
const { PluginManager } = require("./pluginManager.cjs");
|
||||
@@ -78,12 +80,14 @@ function collectInstalledSyncProviderCatalog(database) {
|
||||
}
|
||||
|
||||
function createPluginHostService(options) {
|
||||
const allowAdvancedPlugins = isPluginDevelopmentEnabled(options.env ?? process.env);
|
||||
const paths = createPluginPaths(options.app.getPath("userData"));
|
||||
const appRoot = options.appRoot ?? options.app.getAppPath();
|
||||
const runtimeDirectory = options.runtimeDirectory ?? path.join(__dirname, "runtime");
|
||||
const database = new PluginDatabase(paths.database);
|
||||
try {
|
||||
const packageStore = new PackageStore({
|
||||
allowAdvancedPlugins,
|
||||
paths,
|
||||
database,
|
||||
netcattyVersion: options.app.getVersion(),
|
||||
@@ -178,11 +182,13 @@ function createPluginHostService(options) {
|
||||
}
|
||||
const requestedRuntimeResolver = options.resolveRuntimeKind ?? resolveDefaultRuntimeKind;
|
||||
const resolveRuntimeKind = async (context) => {
|
||||
if (!allowAdvancedPlugins) assertBrowserOnlyPlugin(context.plugin.manifest);
|
||||
const kind = await requestedRuntimeResolver(context);
|
||||
if (
|
||||
(kind !== "browser" && kind !== "utility")
|
||||
|| !context.availableKinds.includes(kind)
|
||||
) throw new Error(`Plugin runtime selection is unavailable: ${String(kind)}`);
|
||||
if (!allowAdvancedPlugins && kind !== "browser") throw new Error("Advanced plugin runtime requires developer mode");
|
||||
await permissionEngine.authorizeRequired(context.plugin, {
|
||||
securityPrincipal: context.securityPrincipal,
|
||||
signal: context.signal,
|
||||
@@ -363,6 +369,7 @@ function createPluginHostService(options) {
|
||||
},
|
||||
});
|
||||
return {
|
||||
catalogService: new PluginCatalogService({ manager, fetch: options.electron.net?.fetch?.bind(options.electron.net) }),
|
||||
companionSupervisor,
|
||||
contributionIconService,
|
||||
contributionService,
|
||||
|
||||
@@ -16,6 +16,7 @@ function createOptions(root, configureRpcRegistry) {
|
||||
getPath: () => root,
|
||||
getVersion: () => "0.0.0",
|
||||
},
|
||||
env: { NETCATTY_PLUGIN_DEV: "1" },
|
||||
electron: {},
|
||||
configureRpcRegistry,
|
||||
};
|
||||
@@ -386,3 +387,17 @@ test("custom host methods without an explicit authorization classification are d
|
||||
/no authorization policy/,
|
||||
);
|
||||
});
|
||||
|
||||
test("normal release blocks a custom utility placement before permission prompts", async (context) => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-plugin-normal-mode-"));
|
||||
context.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
let prompts = 0;
|
||||
const service = createPluginHostService({ ...createOptions(root), env: {}, resolveRuntimeKind: () => "utility", requestPermissionDecision: () => { prompts++; } });
|
||||
context.after(() => service.database.close());
|
||||
await assert.rejects(service.runtimeSupervisor.resolveRuntimeKind({
|
||||
plugin: { id: "com.example.safe", manifest: { main: { browser: "index.js" } } },
|
||||
availableKinds: ["browser", "utility"],
|
||||
signal: new AbortController().signal,
|
||||
}), /requires developer mode/);
|
||||
assert.equal(prompts, 0);
|
||||
});
|
||||
@@ -1,5 +1,7 @@
|
||||
"use strict";
|
||||
|
||||
const { describePluginPermission } = require("../../domain/pluginPermissionDescriptions.cjs");
|
||||
|
||||
const MAX_VISIBLE_RESOURCES = 20;
|
||||
const MAX_VISIBLE_FIELD_LENGTH = 1_024;
|
||||
const UNSAFE_DISPLAY_CHARACTERS = /[\u0000-\u001f\u007f-\u009f\u2028\u2029\u202a-\u202e\u2066-\u2069]/gu;
|
||||
@@ -14,7 +16,8 @@ function escapePermissionText(value, maxLength = MAX_VISIBLE_FIELD_LENGTH) {
|
||||
});
|
||||
}
|
||||
|
||||
function describePermissionRequest(request) {
|
||||
function describePermissionRequest(request, locale = "en") {
|
||||
const permission = describePluginPermission(request.permission, locale);
|
||||
const identity = request.pluginName
|
||||
? `${escapePermissionText(request.pluginName)} (${escapePermissionText(request.pluginId)})`
|
||||
: escapePermissionText(request.pluginId);
|
||||
@@ -31,6 +34,7 @@ function describePermissionRequest(request) {
|
||||
`Plugin: ${identity}`,
|
||||
...(request.publisher ? [`Publisher: ${escapePermissionText(request.publisher)}`] : []),
|
||||
`Permission: ${escapePermissionText(request.permission)}`,
|
||||
`${permission.label}: ${permission.description}`,
|
||||
`Reason: ${escapePermissionText(request.reason)}`,
|
||||
...(visibleResources.length ? ["Resources:", ...visibleResources] : []),
|
||||
].join("\n");
|
||||
@@ -44,6 +48,7 @@ function createNativePermissionDecisionProvider(options) {
|
||||
const window = options.window;
|
||||
return async (request, context = {}) => {
|
||||
context.signal?.throwIfAborted();
|
||||
if (!describePluginPermission(request.permission).known) return Object.freeze({ requestId: request.requestId, decision: "deny" });
|
||||
const allowedScopes = Array.isArray(request.allowedScopes)
|
||||
? new Set(request.allowedScopes)
|
||||
: null;
|
||||
@@ -65,7 +70,7 @@ function createNativePermissionDecisionProvider(options) {
|
||||
type: "warning",
|
||||
title: "Plugin permission request",
|
||||
message: `${escapePermissionText(request.pluginName ?? request.pluginId)} requests ${escapePermissionText(request.permission)}`,
|
||||
detail: describePermissionRequest(request),
|
||||
detail: describePermissionRequest(request, options.getLocale?.() ?? "en"),
|
||||
buttons: choices.map(({ label }) => label),
|
||||
defaultId: 0,
|
||||
cancelId: 0,
|
||||
|
||||
@@ -131,3 +131,12 @@ test("native permission dialogs visibly escape control and bidi characters", asy
|
||||
assert.equal(captured.detail.split("\n").filter((line) => line.startsWith("Permission:")).length, 1);
|
||||
assert.equal(escapePermissionText("x".repeat(MAX_VISIBLE_FIELD_LENGTH + 20)).length, MAX_VISIBLE_FIELD_LENGTH);
|
||||
});
|
||||
|
||||
test("native prompts share human-readable permission explanations and deny unknown permission IDs", async () => {
|
||||
assert.match(describePermissionRequest(permissionRequest({ permission: "terminal.output" }), "zh-CN"), /读取终端输出/);
|
||||
assert.match(describePermissionRequest(permissionRequest({ permission: "terminal.output" })), /credentials or private data/);
|
||||
let prompts = 0;
|
||||
const provider = createNativePermissionDecisionProvider({ dialog: { showMessageBox: async () => { prompts++; return { response: 1 }; } } });
|
||||
assert.deepEqual(await provider(permissionRequest({ permission: "unknown.permission" })), { requestId: "request-1", decision: "deny" });
|
||||
assert.equal(prompts, 0);
|
||||
});
|
||||
@@ -157,7 +157,10 @@ test("network response streaming stops above the byte cap", async () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("network broker reports its own timeout as a stable deadline error", async () => {
|
||||
test("network broker reports its own timeout as a stable deadline error", async (context) => {
|
||||
// A real request owns a socket; this pending fake must also keep the loop alive.
|
||||
const keepAlive = setInterval(() => {}, 1_000);
|
||||
context.after(() => clearInterval(keepAlive));
|
||||
const broker = new PluginNetworkBroker({
|
||||
fetch: async (_url, options) => new Promise((_resolve, reject) => {
|
||||
options.signal.addEventListener("abort", () => reject(options.signal.reason), { once: true });
|
||||
|
||||
@@ -21,6 +21,9 @@ const {
|
||||
resolveInstalledVersionDirectory,
|
||||
} = require("./paths.cjs");
|
||||
|
||||
const { assertBrowserOnlyPlugin } = require("../../domain/pluginRuntimePolicy.cjs");
|
||||
const { assertCatalogManifest } = require("./catalogPolicy.cjs");
|
||||
|
||||
const INSTALL_METADATA_FILE = "install.json";
|
||||
const ARCHIVE_SNAPSHOT_FILE = "package.ncpkg";
|
||||
const PACKAGE_DIRECTORY = "package";
|
||||
@@ -136,6 +139,7 @@ function validateRemovalMetadata(value) {
|
||||
|
||||
class PackageStore {
|
||||
constructor(options) {
|
||||
this.allowAdvancedPlugins = options.allowAdvancedPlugins !== false;
|
||||
this.paths = options.paths;
|
||||
this.database = options.database;
|
||||
this.netcattyVersion = options.netcattyVersion;
|
||||
@@ -156,7 +160,7 @@ class PackageStore {
|
||||
for (const plugin of this.database.listPlugins()) {
|
||||
if (!plugin.enabled) continue;
|
||||
await this.assertCompatible(plugin).catch((error) => {
|
||||
if (error.code !== "PLUGIN_INCOMPATIBLE") throw error;
|
||||
if (!["PLUGIN_INCOMPATIBLE", "PLUGIN_UNSUPPORTED_RUNTIME"].includes(error.code)) throw error;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -168,9 +172,15 @@ class PackageStore {
|
||||
apiVersion: this.apiVersion,
|
||||
features: this.supportedFeatures,
|
||||
});
|
||||
if (result.compatible) return result;
|
||||
const error = new Error(`Plugin is incompatible: ${result.errors.join("; ")}`);
|
||||
error.code = "PLUGIN_INCOMPATIBLE";
|
||||
let error;
|
||||
if (!this.allowAdvancedPlugins) {
|
||||
try { assertBrowserOnlyPlugin(plugin.manifest); } catch (cause) { error = cause; }
|
||||
}
|
||||
if (!error && result.compatible) return result;
|
||||
if (!error) {
|
||||
error = new Error(`Plugin is incompatible: ${result.errors.join("; ")}`);
|
||||
error.code = "PLUGIN_INCOMPATIBLE";
|
||||
}
|
||||
const current = this.database.getActivePlugin(plugin.id);
|
||||
if (current?.activeVersion === plugin.activeVersion) {
|
||||
this.database.setEnabled(plugin.id, false);
|
||||
@@ -204,6 +214,7 @@ class PackageStore {
|
||||
const validation = await pluginCli.extractPluginPackage(archiveSnapshot, extractedDirectory);
|
||||
await syncDirectoryTree(extractedDirectory);
|
||||
const manifest = validation.manifest;
|
||||
if (!this.allowAdvancedPlugins) assertBrowserOnlyPlugin(manifest);
|
||||
const compatibility = pluginCli.checkPluginCompatibility(manifest, {
|
||||
netcattyVersion: this.netcattyVersion,
|
||||
apiVersion: this.apiVersion,
|
||||
@@ -216,6 +227,13 @@ class PackageStore {
|
||||
const version = assertPluginStorageSegment(manifest.version, "version");
|
||||
const targetDirectory = resolveInstalledVersionDirectory(this.paths, pluginId, version);
|
||||
const previousPlugin = this.database.getActivePlugin(pluginId);
|
||||
if (options.catalog) {
|
||||
if (snapshot.sha256 !== options.catalog.artifact.sha256) throw new Error("Catalog package SHA-256 mismatch");
|
||||
assertCatalogManifest(manifest, options.catalog);
|
||||
// Detail-page permissions and engine ranges must describe these exact bytes.
|
||||
const { isDeepStrictEqual } = require("node:util");
|
||||
if (!isDeepStrictEqual(manifest, options.catalog.manifest)) throw new Error("Catalog manifest does not match package");
|
||||
}
|
||||
const enableAfterActivation = options.enable === false
|
||||
? false
|
||||
: options.enable === true || previousPlugin?.enabled === true;
|
||||
|
||||
@@ -421,3 +421,38 @@ test("explicit disabled import never auto-enables an update of an enabled plugin
|
||||
assert.equal(installed.activeVersion, "1.1.0");
|
||||
assert.equal(installed.enabled, false);
|
||||
});
|
||||
|
||||
test("catalog installation verifies actual package manifest and rejects Node before activation", async (context) => {
|
||||
const fixture = createStore(context);
|
||||
await fixture.store.initialize();
|
||||
const archive = await createPackage(fixture.root, { manifest: { license: "MIT" } });
|
||||
const sha256 = createHash("sha256").update(await fs.promises.readFile(archive.archive)).digest("hex");
|
||||
const entry = { id: archive.manifest.id, version: archive.manifest.version, license: "MIT", manifest: archive.manifest, artifact: { sha256 } };
|
||||
await assert.rejects(fixture.store.install(archive.archive, { catalog: { ...entry, version: "9.0.0" } }), /identity\/version/);
|
||||
await assert.rejects(fixture.store.install(archive.archive, { catalog: { ...entry, artifact: { sha256: "0".repeat(64) } } }), /SHA-256/);
|
||||
await assert.rejects(fixture.store.install(archive.archive, { catalog: { ...entry, manifest: { ...archive.manifest, description: "different" } } }), /manifest does not match/);
|
||||
assert.equal(fixture.database.listPlugins().length, 0);
|
||||
assert.equal((await fixture.store.install(archive.archive, { enable: false, catalog: entry })).enabled, false);
|
||||
const advanced = await createPackage(fixture.root, { manifest: { version: "2.0.0", license: "MIT", main: { node: "dist/index.js" }, permissions: { required: ["runtime.advanced"] } } });
|
||||
const advancedEntry = { ...entry, version: "2.0.0", manifest: advanced.manifest, artifact: { sha256: createHash("sha256").update(await fs.promises.readFile(advanced.archive)).digest("hex") } };
|
||||
await assert.rejects(fixture.store.install(advanced.archive, { catalog: advancedEntry }), /browser-only packages/);
|
||||
assert.equal(fixture.database.getActivePlugin(entry.id).activeVersion, "1.0.0");
|
||||
});
|
||||
|
||||
test("normal release refuses advanced local imports and disables previously installed lazy packages", async (context) => {
|
||||
const fixture = createStore(context);
|
||||
await fixture.store.initialize();
|
||||
const archive = await createPackage(fixture.root, { manifest: {
|
||||
main: { browser: "dist/index.js", node: "dist/index.js" },
|
||||
permissions: { optional: ["runtime.advanced"] },
|
||||
} });
|
||||
const installed = await fixture.store.install(archive.archive, { enable: true });
|
||||
fixture.database.setValue(installed.id, "synthetic", 42);
|
||||
fixture.store.allowAdvancedPlugins = false;
|
||||
await fixture.store.initialize();
|
||||
assert.equal(fixture.database.getActivePlugin(installed.id).enabled, false);
|
||||
assert.match(fixture.database.getActivePlugin(installed.id).runtime.lastError, /developer mode/);
|
||||
assert.equal(fixture.database.getValue(installed.id, "synthetic"), 42);
|
||||
await assert.rejects(fixture.store.install(archive.archive), /browser-only packages/);
|
||||
await assert.rejects(fixture.store.assertCompatible(installed), /browser-only packages/);
|
||||
});
|
||||
@@ -19,6 +19,8 @@ test("packaged application declares every plugin host runtime dependency and res
|
||||
assert.match(builderSource, /node_modules\/@netcatty\/plugin-contract\/\*\*\/\*/);
|
||||
assert.match(builderSource, /node_modules\/@netcatty\/plugin-sdk\/\*\*\/\*/);
|
||||
assert.match(builderSource, /electron\/plugins\/runtime\/\*\*\/\*/);
|
||||
assert.match(builderSource, /domain\/pluginRuntimePolicy\.cjs/);
|
||||
assert.match(builderSource, /domain\/pluginPermissionDescriptions\.cjs/);
|
||||
assert.match(builderSource, /!electron\/plugins\/fixtures\/\*\*\/\*/);
|
||||
});
|
||||
|
||||
@@ -35,7 +37,6 @@ test("clean-checkout test entrypoints build plugin runtime workspaces first", ()
|
||||
packageJson.scripts.pretest,
|
||||
/^npm run build:plugin-runtime-deps(?: && npm run check:codebuddy-sdk-contract)?$/,
|
||||
);
|
||||
for (const lifecycle of ["pretest:plugin-runtime", "pretest:plugin-runtime:electron"]) {
|
||||
assert.equal(packageJson.scripts[lifecycle], "npm run build:plugin-runtime-deps");
|
||||
}
|
||||
assert.equal(packageJson.scripts["pretest:plugin-runtime"], "npm run build:plugin-runtime-deps && npm run build --workspace @netcatty/example-starter-plugin");
|
||||
assert.equal(packageJson.scripts["pretest:plugin-runtime:electron"], "npm run build:plugin-runtime-deps");
|
||||
});
|
||||
@@ -3,7 +3,6 @@
|
||||
const fs = require("node:fs/promises");
|
||||
const { randomUUID } = require("node:crypto");
|
||||
|
||||
const { isPluginDevelopmentEnabled } = require("./constants.cjs");
|
||||
const { raceWithAbort } = require("./rpcRouter.cjs");
|
||||
const {
|
||||
INLINE_SYNC_OBJECT_SAFE_BYTES,
|
||||
@@ -15,6 +14,9 @@ const { assertSecretKey } = require("./secretStore.cjs");
|
||||
const CHANNELS = Object.freeze({
|
||||
status: "netcatty:plugins:status",
|
||||
list: "netcatty:plugins:list",
|
||||
catalog: "netcatty:plugins:catalog",
|
||||
catalogReadme: "netcatty:plugins:catalog-readme",
|
||||
catalogInstall: "netcatty:plugins:catalog-install",
|
||||
install: "netcatty:plugins:install",
|
||||
setEnabled: "netcatty:plugins:set-enabled",
|
||||
restart: "netcatty:plugins:restart",
|
||||
@@ -209,7 +211,6 @@ function registerPluginBridge(ipcMain, options) {
|
||||
const getTerminalWorkerManager = options.getTerminalWorkerManager ?? (() => null);
|
||||
const selectImporterFile = options.selectImporterFile;
|
||||
const viewHost = options.viewHost;
|
||||
const env = options.env ?? process.env;
|
||||
const isTrustedSender = options.isTrustedSender;
|
||||
const defaultScopeCatalog = normalizePluginScopeCatalog({ device: [{ id: "device", label: "This device" }] });
|
||||
const scopeCatalogs = new Map();
|
||||
@@ -251,15 +252,14 @@ function registerPluginBridge(ipcMain, options) {
|
||||
if (scopeCatalogs.delete(key)) publishScopeCatalog();
|
||||
});
|
||||
};
|
||||
const configured = isPluginDevelopmentEnabled(env)
|
||||
&& Boolean(manager)
|
||||
const configured = Boolean(manager)
|
||||
&& typeof manager.initialize === "function";
|
||||
const resolveManager = async () => {
|
||||
if (!configured) throw new Error("Plugin development runtime is disabled or unavailable");
|
||||
if (!configured) throw new Error("Plugin runtime is disabled or unavailable");
|
||||
try {
|
||||
await manager.initialize();
|
||||
} catch (cause) {
|
||||
throw new Error("Plugin development runtime is disabled or unavailable", { cause });
|
||||
throw new Error("Plugin runtime is disabled or unavailable", { cause });
|
||||
}
|
||||
return manager;
|
||||
};
|
||||
@@ -641,7 +641,19 @@ function registerPluginBridge(ipcMain, options) {
|
||||
await resolveManager();
|
||||
available = true;
|
||||
} catch {}
|
||||
return { available, experimental: true };
|
||||
return { available, experimental: true,
|
||||
...(typeof credentialResolver?.isAvailable === "function"
|
||||
? { credentialStorageAvailable: credentialResolver.isAvailable() } : {}),
|
||||
};
|
||||
});
|
||||
handle(CHANNELS.catalog, async () => options.catalogService?.list() ?? []);
|
||||
handle(CHANNELS.catalogReadme, async (_manager, payload) => {
|
||||
if (!options.catalogService) throw new Error("Plugin catalog unavailable");
|
||||
return options.catalogService.readme(payload?.id);
|
||||
});
|
||||
handle(CHANNELS.catalogInstall, async (_manager, payload) => {
|
||||
if (!options.catalogService) throw new Error("Plugin catalog unavailable");
|
||||
return options.catalogService.install(payload?.id);
|
||||
});
|
||||
handle(CHANNELS.list, async (activeManager) => activeManager.list());
|
||||
handle(CHANNELS.install, async (activeManager, payload) => activeManager.install(
|
||||
|
||||
@@ -1599,3 +1599,26 @@ test("syncDeleteSecrets uses retained provider binding when contribution is miss
|
||||
assert.deepEqual(deleted, [["com.example", "prefix:sync-credential"]]);
|
||||
assert.deepEqual(unbound, [["com.example", "com.example.sync"]]);
|
||||
});
|
||||
|
||||
test("missing OS vault does not block curated browsing and status exposes the limitation", async () => {
|
||||
const ipcMain = createIpcMain();
|
||||
let selected;
|
||||
registerPluginBridge(ipcMain, {
|
||||
manager: { async initialize() {} },
|
||||
env: { NETCATTY_PLUGIN_DEV: "1" },
|
||||
isTrustedSender: (event) => event.trusted === true,
|
||||
credentialResolver: { isAvailable: () => false },
|
||||
catalogService: {
|
||||
list: () => [{ id: "com.example.synthetic" }],
|
||||
install: async (id) => { selected = id; return { id }; },
|
||||
},
|
||||
});
|
||||
const event = { trusted: true };
|
||||
assert.deepEqual(await ipcMain.handlers.get(CHANNELS.status)(event), {
|
||||
available: true, experimental: true, credentialStorageAvailable: false,
|
||||
});
|
||||
assert.equal((await ipcMain.handlers.get(CHANNELS.catalog)(event)).length, 1);
|
||||
await assert.rejects(ipcMain.handlers.get(CHANNELS.catalog)({ trusted: false }), /Untrusted/);
|
||||
await ipcMain.handlers.get(CHANNELS.catalogInstall)(event, { id: "com.example.synthetic", url: "https://attacker.invalid", enable: true });
|
||||
assert.equal(selected, "com.example.synthetic");
|
||||
});
|
||||
@@ -53,6 +53,7 @@ class PluginManager {
|
||||
try {
|
||||
plugin = await this.packageStore.install(archivePath, {
|
||||
enable: options?.enable,
|
||||
catalog: options?.catalog,
|
||||
beforeActivate: async ({ pluginId, previousPlugin }) => {
|
||||
if (!previousPlugin?.enabled) return;
|
||||
this.database.setEnabled(pluginId, false);
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"use strict";
|
||||
|
||||
const { describePluginRuntime } = require("../../domain/pluginRuntimePolicy.cjs");
|
||||
|
||||
const { randomUUID } = require("node:crypto");
|
||||
const path = require("node:path");
|
||||
const { pathToFileURL } = require("node:url");
|
||||
@@ -43,14 +45,7 @@ function freezeJson(value) {
|
||||
}
|
||||
|
||||
function resolveDefaultRuntimeKind({ plugin }) {
|
||||
const requiresPrivilegedTerminalPipeline = plugin.manifest.contributes?.providers?.some((provider) => (
|
||||
provider.kind === "terminal.interceptor.input"
|
||||
|| provider.kind === "terminal.interceptor.output"
|
||||
));
|
||||
if (plugin.manifest.companionExecutables?.length || requiresPrivilegedTerminalPipeline) {
|
||||
return "utility";
|
||||
}
|
||||
return plugin.manifest.main.browser ? "browser" : "utility";
|
||||
return describePluginRuntime(plugin.manifest).defaultRuntimeKind;
|
||||
}
|
||||
|
||||
class RuntimeSupervisor {
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const vm = require("node:vm");
|
||||
const { pathToFileURL } = require("node:url");
|
||||
const test = require("node:test");
|
||||
|
||||
test("Starter panel renders the actual command result as text and reports denial", async () => {
|
||||
const root = path.resolve(__dirname, "../../examples/plugins/starter-netcatty");
|
||||
const plugin = (await import(pathToFileURL(path.join(root, "dist/index.js")).href)).default;
|
||||
let command;
|
||||
let receive;
|
||||
let click;
|
||||
const elements = new Map(["greet", "result", "title", "description"].map((id) => [id, {
|
||||
textContent: "", disabled: false, addEventListener(_event, callback) { click = callback; },
|
||||
}]));
|
||||
const greeting = '<img src=x onerror="alert(1)"> synthetic hello';
|
||||
await plugin.activate({
|
||||
subscriptions: { add() {} },
|
||||
commands: { registerCommand(id, handler) { assert.equal(id, "com.netcatty.starter.greet"); command = handler; } },
|
||||
settings: { get: async () => greeting },
|
||||
views: { postMessage: (_id, message) => receive?.(message) },
|
||||
});
|
||||
let deny = false;
|
||||
vm.runInNewContext(fs.readFileSync(path.join(root, "panel.js"), "utf8"), {
|
||||
document: { getElementById: (id) => elements.get(id), documentElement: { style: {} } },
|
||||
window: { netcattyView: {
|
||||
onDidChangeEnvironment: (listener) => listener({ locale: "en", theme: "light" }),
|
||||
onDidReceiveMessage: (listener) => { receive = listener; },
|
||||
executeCommand: async (id) => { assert.equal(id, "com.netcatty.starter.greet"); if (deny) throw new Error("Denied"); return command(); },
|
||||
} },
|
||||
});
|
||||
await click();
|
||||
assert.equal(elements.get("result").textContent, greeting);
|
||||
assert.equal(elements.get("greet").disabled, false);
|
||||
deny = true;
|
||||
await click();
|
||||
assert.match(elements.get("result").textContent, /Check permission approval/);
|
||||
assert.equal(elements.get("greet").disabled, false);
|
||||
});
|
||||
@@ -99,6 +99,9 @@ function createPreloadApi(ctx) {
|
||||
with (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 }),
|
||||
listPlugins: () => ipcRenderer.invoke("netcatty:plugins:list"),
|
||||
installPluginPackage: (archivePath, options) => ipcRenderer.invoke("netcatty:plugins:install", {
|
||||
archivePath,
|
||||
|
||||
@@ -6,6 +6,10 @@ import reactHooks from "eslint-plugin-react-hooks";
|
||||
import globals from "globals";
|
||||
|
||||
export default [
|
||||
{
|
||||
files: ["examples/plugins/starter-netcatty/panel.js"],
|
||||
languageOptions: { globals: globals.browser },
|
||||
},
|
||||
// The recommended preset has no file scope of its own, so scope it off all of
|
||||
// electron/ — that main-process tree is historically unlinted. The bridges
|
||||
// get a focused rule set in the dedicated block at the end of this config;
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
# Hello Netcatty
|
||||
|
||||
This package is the runnable internal example for the plugin API. With
|
||||
`NETCATTY_PLUGIN_DEV=1`, package and install it to exercise native settings,
|
||||
This package is the runnable internal example for the plugin API. Package and install it to exercise native settings,
|
||||
command-palette contributions, lazy command and Provider activation,
|
||||
localization, terminal completion/decoration Providers, and the runtime SDK.
|
||||
|
||||
@@ -24,7 +23,7 @@ terminal input/output streams.
|
||||
|
||||
## Import and inspect
|
||||
|
||||
Launch a development build with `NETCATTY_PLUGIN_DEV=1`, using a fresh Electron
|
||||
Launch the normal browser-only plugin build using a fresh Electron
|
||||
user-data directory for demonstrations. In **Settings > Plugins**, choose
|
||||
**Import .ncpkg**, select the generated archive, and open its details. Review the
|
||||
manifest permissions, engine ranges and SHA-256, then choose **Enable**. Native
|
||||
@@ -47,3 +46,8 @@ inspect the package again. Do not connect real hosts or a cloud account. The
|
||||
existing greeting setting is local; declaring `sync: true` on a non-secret
|
||||
setting opts it into the existing encrypted settings sidecar. Generic SDK KV
|
||||
storage does not sync.
|
||||
|
||||
For a demonstration without terminal permissions, use `examples/plugins/starter-netcatty`.
|
||||
Enabling this lazy example does not grant permissions: required permissions are
|
||||
requested before its first command/provider activation, and optional permissions
|
||||
when used. Never treat Enabled as a security certification or grant.
|
||||
@@ -0,0 +1,30 @@
|
||||
# Plugin Starter
|
||||
|
||||
A minimal first-party browser plugin for learning import, settings and lifecycle.
|
||||
It requests `commands`, `settings.read`, `menus` and `views`: no terminal text, host credentials,
|
||||
network, files, secrets or advanced Node access. It works without OS key storage.
|
||||
Permission declarations are not grants; follow the app's normal mediation.
|
||||
|
||||
From the repository root:
|
||||
|
||||
```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 pack examples/plugins/starter-netcatty --out /tmp/starter-netcatty.ncpkg
|
||||
```
|
||||
|
||||
Start the actual app normally with a fresh test profile; no development flag is
|
||||
needed. Open **Settings > Plugins > 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.
|
||||
**Starter: Read Greeting** is also listed and refreshes an already-open panel.
|
||||
The panel inserts text using `textContent`, never HTML. It does not write to
|
||||
a terminal or connect to a host. Denied permissions remain denied.
|
||||
|
||||
Disable, restart and re-enable to verify the preference persists. Re-importing
|
||||
the same package leaves it disabled for review. Uninstall removes code and local
|
||||
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.
|
||||
@@ -0,0 +1,75 @@
|
||||
{
|
||||
"manifestVersion": 1,
|
||||
"id": "com.netcatty.starter",
|
||||
"name": "starter-netcatty",
|
||||
"displayName": {
|
||||
"en": "Plugin Starter",
|
||||
"zh-CN": "插件入门"
|
||||
},
|
||||
"description": "First-party settings and command example. No terminal, network, filesystem or secret access.",
|
||||
"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": "dist/index.js"
|
||||
},
|
||||
"activationEvents": [
|
||||
"onCommand:com.netcatty.starter.greet",
|
||||
"onView:com.netcatty.starter.panel"
|
||||
],
|
||||
"permissions": {
|
||||
"required": [
|
||||
"commands",
|
||||
"settings.read",
|
||||
"menus",
|
||||
"views"
|
||||
],
|
||||
"optional": []
|
||||
},
|
||||
"contributes": {
|
||||
"settings": [
|
||||
{
|
||||
"id": "com.netcatty.starter.greeting",
|
||||
"label": {
|
||||
"en": "Greeting",
|
||||
"zh-CN": "问候语"
|
||||
},
|
||||
"description": "A local preference for the example command.",
|
||||
"control": "text",
|
||||
"scope": "application",
|
||||
"default": "Hello, synthetic world!"
|
||||
}
|
||||
],
|
||||
"commands": [
|
||||
{
|
||||
"id": "com.netcatty.starter.greet",
|
||||
"title": {
|
||||
"en": "Starter: Read Greeting",
|
||||
"zh-CN": "入门:读取问候语"
|
||||
}
|
||||
}
|
||||
],
|
||||
"menus": [
|
||||
{
|
||||
"command": "com.netcatty.starter.greet",
|
||||
"location": "commandPalette",
|
||||
"group": "examples"
|
||||
}
|
||||
],
|
||||
"views": [
|
||||
{
|
||||
"id": "com.netcatty.starter.panel",
|
||||
"title": {
|
||||
"en": "Starter: Greeting Panel",
|
||||
"zh-CN": "入门:问候面板"
|
||||
},
|
||||
"location": "modal",
|
||||
"entry": "panel.html"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "@netcatty/example-starter-plugin",
|
||||
"version": "0.1.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@netcatty/plugin-sdk": "0.1.0-internal"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
:root { color-scheme: light dark; font: 15px/1.6 system-ui, sans-serif; } body { margin: 0; } main { padding: 32px; max-width: 640px; margin: auto; } .eyebrow { font-size: 11px; letter-spacing: .12em; opacity: .65; } h1 { font-size: 24px; } #description { opacity: .75; } button { font: inherit; color: inherit; background: transparent; border: 1px solid currentColor; border-radius: 8px; padding: 8px 16px; cursor: pointer; } button:disabled { opacity: .5; } #result { white-space: pre-wrap; overflow-wrap: anywhere; padding: 16px; border-radius: 8px; background: light-dark(#f3f4f6, #252525); min-height: 24px; }
|
||||
@@ -0,0 +1,3 @@
|
||||
<!doctype html>
|
||||
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><link rel="stylesheet" href="panel.css"><title>Plugin Starter</title></head>
|
||||
<body><main><p class="eyebrow">NETCATTY · PLUGIN STARTER</p><h1 id="title">Your first plugin</h1><p id="description">Read your Greeting preference and show it here. No hosts, terminals or secrets are accessed.</p><button id="greet" type="button">Show greeting</button><p id="result" role="status" aria-live="polite"></p></main><script src="panel.js"></script></body></html>
|
||||
@@ -0,0 +1,26 @@
|
||||
const button = document.getElementById('greet');
|
||||
const result = document.getElementById('result');
|
||||
let chinese = false;
|
||||
window.netcattyView.onDidChangeEnvironment((environment) => {
|
||||
chinese = environment.locale.startsWith('zh');
|
||||
document.documentElement.lang = chinese ? 'zh-CN' : 'en';
|
||||
document.documentElement.style.colorScheme = environment.theme === 'light' ? 'light' : 'dark';
|
||||
document.getElementById('title').textContent = chinese ? '你的第一个插件' : 'Your first plugin';
|
||||
document.getElementById('description').textContent = chinese ? '读取并显示你设置的问候语。不访问主机、终端或秘密。' : 'Read your Greeting preference and show it here. No hosts, terminals or secrets are accessed.';
|
||||
button.textContent = chinese ? '显示问候语' : 'Show greeting';
|
||||
});
|
||||
window.netcattyView.onDidReceiveMessage((message) => {
|
||||
if (typeof message?.greeting === 'string') result.textContent = message.greeting;
|
||||
});
|
||||
button.addEventListener('click', async () => {
|
||||
button.disabled = true;
|
||||
result.textContent = chinese ? '正在读取…' : 'Reading…';
|
||||
try {
|
||||
const response = await window.netcattyView.executeCommand('com.netcatty.starter.greet');
|
||||
result.textContent = response.greeting;
|
||||
} catch {
|
||||
result.textContent = chinese ? '未能读取问候语,请检查权限授权。' : 'Could not read the greeting. Check permission approval.';
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
import { definePlugin } from "@netcatty/plugin-sdk";
|
||||
|
||||
export default definePlugin({
|
||||
activate(context) {
|
||||
context.subscriptions.add(context.commands.registerCommand(
|
||||
"com.netcatty.starter.greet",
|
||||
async () => {
|
||||
const greeting = await context.settings.get<string>("com.netcatty.starter.greeting") ?? "Hello, synthetic world!";
|
||||
context.views.postMessage("com.netcatty.starter.panel", { greeting });
|
||||
return { greeting };
|
||||
},
|
||||
));
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"strict": true,
|
||||
"declaration": true,
|
||||
"rootDir": "src",
|
||||
"outDir": "dist",
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
Vendored
+18
@@ -331,6 +331,21 @@ declare global {
|
||||
interface NetcattyPluginRuntimeStatus {
|
||||
available: boolean;
|
||||
experimental: true;
|
||||
credentialStorageAvailable?: boolean;
|
||||
}
|
||||
|
||||
interface NetcattyPluginCatalogEntry {
|
||||
id: string;
|
||||
version: string;
|
||||
name: string;
|
||||
description: string;
|
||||
author: string;
|
||||
repository: string;
|
||||
commit: string;
|
||||
license: string;
|
||||
readme: string;
|
||||
manifest: import("@netcatty/plugin-contract").PluginManifest;
|
||||
artifact?: { url: string; sha256: string };
|
||||
}
|
||||
|
||||
interface NetcattyInstalledPlugin {
|
||||
@@ -410,6 +425,9 @@ declare global {
|
||||
|
||||
interface NetcattyBridge {
|
||||
getPluginRuntimeStatus?(): Promise<NetcattyPluginRuntimeStatus>;
|
||||
listPluginCatalog?(): Promise<NetcattyPluginCatalogEntry[]>;
|
||||
getPluginCatalogReadme?(id: string): Promise<string>;
|
||||
installCatalogPlugin?(id: string): Promise<NetcattyInstalledPlugin>;
|
||||
listPlugins?(): Promise<NetcattyInstalledPlugin[]>;
|
||||
installPluginPackage?(archivePath: string, options?: { enable?: boolean }): Promise<NetcattyInstalledPlugin>;
|
||||
setPluginEnabled?(pluginId: string, enabled: boolean): Promise<NetcattyInstalledPlugin>;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
@import "tailwindcss";
|
||||
@import "./components/settings/tabs/plugin-marketplace.css";
|
||||
@import "@mdxeditor/editor/style.css";
|
||||
|
||||
/* Bundled icon-only fallback so terminals show Nerd Font glyphs (powerline,
|
||||
|
||||
Generated
+11
@@ -139,6 +139,13 @@
|
||||
"@netcatty/plugin-sdk": "0.1.0-internal"
|
||||
}
|
||||
},
|
||||
"examples/plugins/starter-netcatty": {
|
||||
"name": "@netcatty/example-starter-plugin",
|
||||
"version": "0.1.1",
|
||||
"dependencies": {
|
||||
"@netcatty/plugin-sdk": "0.1.0-internal"
|
||||
}
|
||||
},
|
||||
"node_modules/@agentclientprotocol/sdk": {
|
||||
"version": "0.25.1",
|
||||
"resolved": "https://registry.npmjs.org/@agentclientprotocol/sdk/-/sdk-0.25.1.tgz",
|
||||
@@ -4671,6 +4678,10 @@
|
||||
"resolved": "examples/plugins/hello-netcatty",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@netcatty/example-starter-plugin": {
|
||||
"resolved": "examples/plugins/starter-netcatty",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@netcatty/plugin-cli": {
|
||||
"resolved": "packages/plugin-cli",
|
||||
"link": true
|
||||
|
||||
+3
-3
@@ -31,7 +31,7 @@
|
||||
"build:windows-hello-helper": "node scripts/build-windows-hello-helper.cjs",
|
||||
"build": "npm run build:plugin-packages && vite build",
|
||||
"build:plugin-runtime-deps": "npm run build --workspace @netcatty/plugin-contract && npm run build --workspace @netcatty/plugin-sdk && npm run build --workspace @netcatty/plugin-cli",
|
||||
"build:plugin-packages": "npm run generate:plugin-contract && npm run build:plugin-runtime-deps && npm run build --workspace @netcatty/example-hello-plugin",
|
||||
"build:plugin-packages": "npm run generate:plugin-contract && npm run build:plugin-runtime-deps && npm run build --workspace @netcatty/example-hello-plugin && npm run build --workspace @netcatty/example-starter-plugin",
|
||||
"preview": "vite preview",
|
||||
"start": "node electron/launch.cjs",
|
||||
"pack": "npm run build && cross-env NODE_OPTIONS=--disable-warning=DEP0190 electron-builder --config electron-builder.config.cjs --publish=never",
|
||||
@@ -57,8 +57,8 @@
|
||||
"pretest": "npm run build:plugin-runtime-deps && npm run check:codebuddy-sdk-contract",
|
||||
"test": "node --test --import tsx electron/*.test.cjs electron/preload/*.test.cjs electron/mcp/*.test.cjs electron/shared/*Privacy*.test.cjs electron/scripts/*.test.cjs electron/terminalWorker/*.test.cjs electron/capabilities/*.test.cjs electron/capabilities/*/*.test.cjs electron/capabilities/*/*/*.test.cjs electron/cli/*.test.cjs electron/hostBrowser/*Store.test.cjs electron/hostBrowser/hostBrowserManager.test.cjs electron/bridges/*.test.cjs electron/bridges/*/*.test.cjs electron/bridges/aiBridge/codexAppServer/*.test.cjs electron/bridges/aiBridge/sdk/*.test.cjs scripts/*.test.cjs application/*.test.ts application/app/*.test.ts application/i18n/locales/*.test.ts application/state/*.test.ts application/state/*/*.test.ts components/*.test.ts components/*.test.tsx components/editor/*.test.ts components/editor/*.test.tsx components/notes/*.test.ts components/notes/*.test.tsx components/port-forwarding/*.test.ts components/scripts/*.test.tsx components/systemManager/*.test.ts components/terminalLayer/*.test.ts components/settings/*.test.tsx components/settings/tabs/*.test.ts components/settings/tabs/ai/*.test.ts components/settings/tabs/ai/*.test.tsx components/ai/*.test.ts components/ai/*.test.tsx components/ai/toolArtifacts/*.test.ts components/ai/toolArtifacts/*.test.tsx components/ai-elements/*.test.tsx components/sftp/*.test.ts components/terminal/*.test.ts components/terminal/*.test.tsx components/terminal/runtime/*.test.ts components/vault/*.test.ts components/vault/*.test.tsx domain/*.test.ts domain/*/*.test.ts domain/*/*.test.cjs infrastructure/ai/*.test.ts infrastructure/ai/*/*.test.ts infrastructure/ai/sdk/*.test.ts infrastructure/config/*.test.ts infrastructure/monaco/*.test.ts infrastructure/persistence/*.test.ts infrastructure/services/*.test.ts infrastructure/services/*/*.test.ts lib/*.test.ts packages/*/src/*.test.ts",
|
||||
"test:plugin-contract": "node --test --import tsx packages/*/src/*.test.ts",
|
||||
"pretest:plugin-runtime": "npm run build:plugin-runtime-deps",
|
||||
"test:plugin-runtime": "node --test electron/plugins/*.test.cjs",
|
||||
"pretest:plugin-runtime": "npm run build:plugin-runtime-deps && npm run build --workspace @netcatty/example-starter-plugin",
|
||||
"test:plugin-runtime": "node --test electron/plugins/*.test.cjs domain/pluginRuntimePolicy.test.cjs domain/pluginPermissionDescriptions.test.cjs",
|
||||
"pretest:plugin-runtime:electron": "npm run build:plugin-runtime-deps",
|
||||
"test:plugin-runtime:electron": "electron electron/plugins/electronRuntime.live.test.cjs",
|
||||
"test:sync-crdt": "node --test --import tsx domain/convergentSync/*.test.ts",
|
||||
|
||||
@@ -38,6 +38,17 @@ async function checkDevImport() {
|
||||
}
|
||||
return modules.get(url);
|
||||
}
|
||||
const managerResponse = await fetch(new URL("/components/settings/tabs/PluginPackageManager.tsx", origin));
|
||||
assert.equal(managerResponse.status, 200);
|
||||
const managerSource = await managerResponse.text();
|
||||
for (const [helper, exported] of [["pluginRuntimePolicy", "describePluginRuntime"], ["pluginPermissionDescriptions", "describePluginPermission"]]) {
|
||||
const match = managerSource.match(new RegExp('from "([^"\\n]*' + helper + '[^"\\n]*)"'));
|
||||
assert.ok(match, `Missing prebundled ${helper} import`);
|
||||
const module = await load(new URL(match[1], origin).href);
|
||||
await module.link((specifier, importer) => load(new URL(specifier, importer.identifier).href));
|
||||
await module.evaluate();
|
||||
assert.equal(typeof module.namespace.default[exported], "function");
|
||||
}
|
||||
const entry = await load(new URL("/domain/systemManager/containerRuntime.ts", origin).href);
|
||||
await entry.link((specifier, importer) => load(new URL(specifier, importer.identifier).href));
|
||||
await entry.evaluate();
|
||||
@@ -53,7 +64,7 @@ async function checkDevImport() {
|
||||
}
|
||||
}
|
||||
|
||||
test("Vite serves the renderer container runtime with browser-compatible exports", { timeout: 60_000 }, () => {
|
||||
test("Vite serves the renderer shared helpers with browser-compatible exports", { timeout: 60_000 }, () => {
|
||||
const result = spawnSync(process.execPath, [
|
||||
"--experimental-vm-modules", "--input-type=module", "-e",
|
||||
`(${checkDevImport.toString()})().catch(error => { console.error(error); process.exitCode = 1; });`,
|
||||
|
||||
+3
-1
@@ -85,7 +85,7 @@ export default defineConfig(() => {
|
||||
build: {
|
||||
// The shared runtime is also required by Electron; convert it for browser builds.
|
||||
commonjsOptions: {
|
||||
include: [/node_modules/, /domain\/systemManager\/containerRuntime\.shared\.cjs$/],
|
||||
include: [/node_modules/, /domain\/systemManager\/containerRuntime\.shared\.cjs$/, /domain\/plugin(?:RuntimePolicy|PermissionDescriptions)\.cjs$/],
|
||||
},
|
||||
chunkSizeWarningLimit: 3000,
|
||||
target: 'esnext', // Required for top-level await in WASM modules
|
||||
@@ -130,6 +130,8 @@ export default defineConfig(() => {
|
||||
include: [
|
||||
// Keep the renderer alias pre-bundled so dev serves ESM instead of raw CommonJS.
|
||||
'@/domain/systemManager/containerRuntime.shared.cjs',
|
||||
'@/domain/pluginRuntimePolicy.cjs',
|
||||
'@/domain/pluginPermissionDescriptions.cjs',
|
||||
'ai',
|
||||
'@ai-sdk/openai',
|
||||
'@ai-sdk/anthropic',
|
||||
|
||||
Reference in new issue
Block a user