Compare commits

..
155 changed files with 2078 additions and 10278 deletions

No files matched your search

-2
View File
@@ -13,7 +13,6 @@ import { TooltipProvider } from './components/ui/tooltip';
import { ScriptAutomationRoot } from './components/scripts/ScriptAutomationRoot';
import { ExternalMcpApprovalsHost } from './components/ai/ExternalMcpApprovalsHost';
import { PluginHostInteractionHost } from './components/plugins/PluginHostInteractionHost';
import { PluginHostActionHost } from './components/plugins/PluginHostActionHost';
import { PluginAuthenticationHost } from './components/plugins/PluginAuthenticationHost';
import { MultilinePasteConfirmHost } from './components/terminal/MultilinePasteConfirmDialog';
import { useExternalMcpGrantPersister } from './components/ai/useExternalMcpGrantPersister';
@@ -93,7 +92,6 @@ function AppWithProviders({ settings, appLock }: { settings: SettingsState; appL
<VaultPublisher>
<SessionPublisher persistSessionRestore={!isPeerSessionWindow}>
<App />
<PluginHostActionHost />
</SessionPublisher>
</VaultPublisher>
</TooltipProvider>
-37
View File
@@ -1298,9 +1298,6 @@ Highlight the focused split pane:
"settings.plugins.manager.data": "Local KV stays on this device. Only non-secret settings explicitly marked for sync participate in encrypted cloud sidecars. Packages and permission grants do not sync.",
"settings.plugins.market.results": "{count} plugins",
"settings.plugins.manager.lastError": "Last recorded error",
"settings.plugins.sources.offline": "Source unavailable. If a previously validated catalog exists for this source, it remains available for browsing only. Installation is unavailable until refresh succeeds.",
"settings.plugins.sources.note": "This catalog is provided by the selected third party, not curated by Netcatty. Review the source domain and permissions before installing. Updates are manual.",
"settings.plugins.sources.sourceOnly": "Source-only entry from this provider. No downloadable package is available; review the author’s build and import instructions.",
"settings.plugins.market.offline": "Registry unavailable. Showing the last valid catalog or bundled examples. Installation is unavailable until refresh succeeds.",
"settings.plugins.manager.details": "View details",
"settings.plugins.manager.noDescription": "No description provided.",
@@ -1331,25 +1328,6 @@ Highlight the focused split pane:
"settings.plugins.manager.unbounded": "No narrower resource scope is declared. Check the actual scope in the permission prompt.",
"settings.plugins.openView": "Open",
"settings.plugins.openManager": "Open plugin manager",
"settings.plugins.sources.conflict": "This ID is installed from another source. Uninstall and explicitly reset its private data before switching. Existing source:",
"settings.plugins.sources.clearPrivate": "Also clear this source’s private data, grants and secrets to reinstall from another source. This cannot be undone.",
"settings.plugins.sources.official": "Official marketplace",
"settings.plugins.sources.thirdparty": "Third-party marketplace",
"settings.plugins.sources.manage": "Manage sources",
"settings.plugins.sources.select": "Select a source",
"settings.plugins.sources.empty": "Choose or add a third-party source.",
"settings.plugins.sources.add": "Add source",
"settings.plugins.sources.remove": "Remove source",
"settings.plugins.sources.name": "Source name",
"settings.plugins.sources.address": "HTTPS source address",
"settings.plugins.sources.risk": "Third-party sources are not maintained or endorsed by Netcatty. Review the real domain, package permissions and author before installing.",
"settings.plugins.sources.accept": "I trust this domain to supply plugin metadata and packages. This does not grant plugin permissions.",
"settings.plugins.sources.removeNote": "Installed plugins and their data remain. Updates from this source stop; another source will not replace it.",
"settings.plugins.sources.disablePlugins": "Also disable plugins installed from this source",
"settings.plugins.sources.installConfirm": "Review third-party installation",
"settings.plugins.sources.source": "Source",
"settings.plugins.sources.local": "Local import",
"settings.plugins.sources.all": "All sources",
"settings.plugins.title": "Plugins",
"settings.plugins.description": "Manage your plugins and explore the catalog.",
'settings.plugins.loading': 'Loading plugin settings…',
@@ -1380,20 +1358,5 @@ Highlight the focused split pane:
'plugins.authentication.responseFailed': 'Could not send the authentication response. Try again or cancel the request.',
'plugins.authentication.responseFailedWithMessage': 'Could not send the authentication response: {message}. Try again or cancel the request.',
'plugins.authentication.continue': 'Continue',
'plugins.hostActions.select': 'Share selected host metadata',
'plugins.hostActions.selectionNote': 'Share saved labels, addresses, usernames, protocols, groups and tags with this plugin. Group selection includes only the hosts selected now. Connecting requires a separate confirmation.',
'plugins.hostActions.create': 'Review host draft',
'plugins.hostActions.edit': 'Review host changes',
'plugins.hostActions.connect': 'Confirm connection',
'plugins.hostActions.fieldDiff': 'Changes to save',
'plugins.hostActions.defaultsWarning': 'Saved connection defaults apply. Changing a group can change inherited authentication, proxy settings and scripts.',
'plugins.hostActions.invalidDraft': 'Enter a valid host draft.',
'plugins.hostActions.scriptsRun': 'Saved startup, connect or output scripts may run with this connection.',
'plugins.hostActions.noScripts': 'No saved startup, connect or output scripts are configured for this host.',
'plugins.hostActions.connectionNote': 'Netcatty will open this connection using the saved settings. This does not share session content with the plugin.',
'plugins.hostActions.localExecution': 'Local execution: this opens a shell on this device.',
'plugins.hostActions.managedUnsupported': 'Managed SSH config changes require the Vault host editor.',
'plugins.hostActions.failed': 'The request expired, was revoked, or the host changed. Select the host again.',
'plugins.hostActions.selectionLimit': 'Select up to 50 current hosts.',
};
-37
View File
@@ -1298,9 +1298,6 @@ Resalta el panel dividido enfocado:
"settings.plugins.manager.data": "Los datos KV permanecen en este dispositivo. Solo los ajustes no secretos marcados para sincronización participan en los datos auxiliares cifrados. Los paquetes y las autorizaciones no se sincronizan.",
"settings.plugins.market.results": "{count} plugins",
"settings.plugins.manager.lastError": "Último error registrado",
"settings.plugins.sources.offline": "Fuente no disponible. Si existe un catálogo validado en caché de esta fuente, solo se permite consultarlo. La instalación no estará disponible hasta que se actualice correctamente.",
"settings.plugins.sources.note": "Este catálogo lo proporciona el tercero seleccionado; Netcatty no lo selecciona ni revisa. Comprueba el dominio y los permisos antes de instalar. Las actualizaciones son manuales.",
"settings.plugins.sources.sourceOnly": "Entrada de código fuente de este proveedor. No hay un paquete descargable; consulta las instrucciones de compilación e importación del autor.",
"settings.plugins.market.offline": "El registro no está disponible. Se muestra el último catálogo válido o los ejemplos incluidos. La instalación requiere una actualización correcta.",
"settings.plugins.manager.details": "Ver detalles",
"settings.plugins.manager.noDescription": "No se proporcionó una descripción.",
@@ -1331,25 +1328,6 @@ Resalta el panel dividido enfocado:
"settings.plugins.manager.unbounded": "No se declara un alcance de recursos más limitado. Revisa el alcance real en la solicitud de permiso.",
"settings.plugins.openView": "Abrir",
"settings.plugins.openManager": "Abrir gestor de plugins",
"settings.plugins.sources.conflict": "This ID is installed from another source. Uninstall and explicitly reset its private data before switching. Existing source:",
"settings.plugins.sources.clearPrivate": "Also clear this source’s private data, grants and secrets to reinstall from another source. This cannot be undone.",
"settings.plugins.sources.official": "Official marketplace",
"settings.plugins.sources.thirdparty": "Third-party marketplace",
"settings.plugins.sources.manage": "Manage sources",
"settings.plugins.sources.select": "Select a source",
"settings.plugins.sources.empty": "Choose or add a third-party source.",
"settings.plugins.sources.add": "Add source",
"settings.plugins.sources.remove": "Remove source",
"settings.plugins.sources.name": "Source name",
"settings.plugins.sources.address": "HTTPS source address",
"settings.plugins.sources.risk": "Third-party sources are not maintained or endorsed by Netcatty. Review the real domain, package permissions and author before installing.",
"settings.plugins.sources.accept": "I trust this domain to supply plugin metadata and packages. This does not grant plugin permissions.",
"settings.plugins.sources.removeNote": "Installed plugins and their data remain. Updates from this source stop; another source will not replace it.",
"settings.plugins.sources.disablePlugins": "Also disable plugins installed from this source",
"settings.plugins.sources.installConfirm": "Review third-party installation",
"settings.plugins.sources.source": "Source",
"settings.plugins.sources.local": "Local import",
"settings.plugins.sources.all": "All sources",
"settings.plugins.title": "Plugins",
"settings.plugins.description": "Gestiona tus plugins y explora el catálogo.",
'settings.plugins.loading': 'Cargando ajustes del plugin…',
@@ -1380,20 +1358,5 @@ Resalta el panel dividido enfocado:
'plugins.authentication.responseFailed': 'No se pudo enviar la respuesta de autenticación. Inténtalo de nuevo o cancela la solicitud.',
'plugins.authentication.responseFailedWithMessage': 'No se pudo enviar la respuesta de autenticación: {message}. Inténtalo de nuevo o cancela la solicitud.',
'plugins.authentication.continue': 'Continuar',
'plugins.hostActions.select': 'Compartir metadatos de los equipos seleccionados',
'plugins.hostActions.selectionNote': 'Comparte nombres, direcciones, usuarios, protocolos, grupos y etiquetas guardados con este complemento. Los grupos solo incluyen los equipos seleccionados ahora. Conectar requiere otra confirmación.',
'plugins.hostActions.create': 'Revisar borrador de equipo',
'plugins.hostActions.edit': 'Revisar cambios del equipo',
'plugins.hostActions.connect': 'Confirmar conexión',
'plugins.hostActions.fieldDiff': 'Cambios que se guardarán',
'plugins.hostActions.defaultsWarning': 'Se aplican los ajustes de conexión guardados. Cambiar el grupo puede cambiar la autenticación, el proxy y los scripts heredados.',
'plugins.hostActions.invalidDraft': 'Introduce un borrador de equipo válido.',
'plugins.hostActions.scriptsRun': 'Esta conexión puede ejecutar scripts guardados de inicio, conexión o salida.',
'plugins.hostActions.noScripts': 'Este equipo no tiene scripts guardados de inicio, conexión o salida.',
'plugins.hostActions.connectionNote': 'Netcatty abrirá la conexión con los ajustes guardados. Esto no comparte el contenido de la sesión con el complemento.',
'plugins.hostActions.localExecution': 'Ejecución local: esto abre una shell en este dispositivo.',
'plugins.hostActions.managedUnsupported': 'Los cambios de configuración SSH administrada requieren el editor de equipos de Vault.',
'plugins.hostActions.failed': 'La solicitud caducó, se revocó o el equipo cambió. Selecciona el equipo de nuevo.',
'plugins.hostActions.selectionLimit': 'Selecciona hasta 50 equipos actuales.',
};
-37
View File
@@ -1193,9 +1193,6 @@ export const ruCoreMessages: Messages = {
"settings.plugins.manager.data": "Данные KV остаются на устройстве. Синхронизируются только явно отмеченные несекретные настройки в зашифрованных данных. Пакеты и разрешения не синхронизируются.",
"settings.plugins.market.results": "Плагинов: {count}",
"settings.plugins.manager.lastError": "Последняя записанная ошибка",
"settings.plugins.sources.offline": "Источник недоступен. Ранее проверенный каталог этого источника, если он есть в кэше, доступен только для просмотра. Установка недоступна до успешного обновления.",
"settings.plugins.sources.note": "Этот каталог предоставляет выбранная третья сторона; Netcatty не отбирает его содержимое. Перед установкой проверьте домен источника и разрешения. Обновления выполняются вручную.",
"settings.plugins.sources.sourceOnly": "Запись с исходным кодом от этого поставщика. Пакет для загрузки отсутствует; см. инструкции автора по сборке и импорту.",
"settings.plugins.market.offline": "Реестр недоступен. Показан последний корректный каталог или встроенные примеры. Для установки необходимо успешно обновить каталог.",
"settings.plugins.manager.details": "Подробнее",
"settings.plugins.manager.noDescription": "Описание не предоставлено.",
@@ -1226,25 +1223,6 @@ export const ruCoreMessages: Messages = {
"settings.plugins.manager.unbounded": "Более узкая область ресурсов не заявлена. Проверьте фактическую область в запросе разрешения.",
"settings.plugins.openView": "Открыть",
"settings.plugins.openManager": "Открыть управление плагинами",
"settings.plugins.sources.conflict": "This ID is installed from another source. Uninstall and explicitly reset its private data before switching. Existing source:",
"settings.plugins.sources.clearPrivate": "Also clear this source’s private data, grants and secrets to reinstall from another source. This cannot be undone.",
"settings.plugins.sources.official": "Official marketplace",
"settings.plugins.sources.thirdparty": "Third-party marketplace",
"settings.plugins.sources.manage": "Manage sources",
"settings.plugins.sources.select": "Select a source",
"settings.plugins.sources.empty": "Choose or add a third-party source.",
"settings.plugins.sources.add": "Add source",
"settings.plugins.sources.remove": "Remove source",
"settings.plugins.sources.name": "Source name",
"settings.plugins.sources.address": "HTTPS source address",
"settings.plugins.sources.risk": "Third-party sources are not maintained or endorsed by Netcatty. Review the real domain, package permissions and author before installing.",
"settings.plugins.sources.accept": "I trust this domain to supply plugin metadata and packages. This does not grant plugin permissions.",
"settings.plugins.sources.removeNote": "Installed plugins and their data remain. Updates from this source stop; another source will not replace it.",
"settings.plugins.sources.disablePlugins": "Also disable plugins installed from this source",
"settings.plugins.sources.installConfirm": "Review third-party installation",
"settings.plugins.sources.source": "Source",
"settings.plugins.sources.local": "Local import",
"settings.plugins.sources.all": "All sources",
"settings.plugins.title": "Плагины",
"settings.plugins.description": "Управляйте плагинами и просматривайте каталог.",
'settings.plugins.loading': 'Загрузка настроек плагинов…',
@@ -1275,19 +1253,4 @@ export const ruCoreMessages: Messages = {
'plugins.authentication.responseFailed': 'Не удалось отправить ответ аутентификации. Повторите попытку или отмените запрос.',
'plugins.authentication.responseFailedWithMessage': 'Не удалось отправить ответ аутентификации: {message}. Повторите попытку или отмените запрос.',
'plugins.authentication.continue': 'Продолжить',
'plugins.hostActions.select': 'Передать метаданные выбранных хостов',
'plugins.hostActions.selectionNote': 'Плагину будут переданы сохранённые имена, адреса, пользователи, протоколы, группы и теги. Группа включает только выбранные сейчас хосты. Подключение требует отдельного подтверждения.',
'plugins.hostActions.create': 'Проверить черновик хоста',
'plugins.hostActions.edit': 'Проверить изменения хоста',
'plugins.hostActions.connect': 'Подтвердить подключение',
'plugins.hostActions.fieldDiff': 'Изменения для сохранения',
'plugins.hostActions.defaultsWarning': 'Применяются сохранённые настройки подключения. Смена группы может изменить унаследованные настройки входа, прокси и скрипты.',
'plugins.hostActions.invalidDraft': 'Введите корректные данные хоста.',
'plugins.hostActions.scriptsRun': 'При подключении могут выполняться сохранённые скрипты запуска, подключения или вывода.',
'plugins.hostActions.noScripts': 'Для этого хоста не настроены сохранённые скрипты запуска, подключения или вывода.',
'plugins.hostActions.connectionNote': 'Netcatty откроет подключение с сохранёнными настройками. Содержимое сеанса не передаётся плагину.',
'plugins.hostActions.localExecution': 'Локальное выполнение: откроется оболочка на этом устройстве.',
'plugins.hostActions.managedUnsupported': 'Изменяйте управляемые настройки SSH в редакторе хостов Vault.',
'plugins.hostActions.failed': 'Запрос истёк, был отозван или хост изменился. Выберите хост заново.',
'plugins.hostActions.selectionLimit': 'Выберите не более 50 текущих хостов.',
};
-37
View File
@@ -1213,9 +1213,6 @@ Vaults 标签保持扁平,不使用这块背景。
"settings.plugins.manager.data": "本地 KV 仅保留在此设备。只有明确标记为同步的非秘密设置进入加密云 sidecar。插件包和权限授权不参与同步。",
"settings.plugins.market.results": "{count} 个插件",
"settings.plugins.manager.lastError": "上次记录的错误",
"settings.plugins.sources.offline": "来源暂不可用。如已缓存此来源通过校验的目录,仅可继续浏览;刷新成功前无法安装。",
"settings.plugins.sources.note": "此目录由所选第三方提供,不属于 Netcatty 精选目录。安装前请核对来源域名和权限,更新需手动确认。",
"settings.plugins.sources.sourceOnly": "此提供方仅提供源码条目,没有可下载插件包;请查看作者的构建和导入说明。",
"settings.plugins.market.offline": "官方目录暂时不可用,正在显示上次有效目录或内置示例。刷新成功前不能从市场安装。",
"settings.plugins.manager.details": "查看详情",
"settings.plugins.manager.noDescription": "作者暂未提供功能简介。",
@@ -1246,25 +1243,6 @@ Vaults 标签保持扁平,不使用这块背景。
"settings.plugins.manager.unbounded": "未声明更细的资源范围。请在权限提示中检查实际授权范围。",
"settings.plugins.openView": "打开",
"settings.plugins.openManager": "打开插件管理",
"settings.plugins.sources.conflict": "此 ID 已从其他来源安装。请先卸载并明确清理旧来源数据,再换源重装。原来源:",
"settings.plugins.sources.clearPrivate": "同时清除本来源的私有数据、授权和秘密,以便显式换源重装。此操作不可撤销。",
"settings.plugins.sources.official": "官方市场",
"settings.plugins.sources.thirdparty": "第三方市场",
"settings.plugins.sources.manage": "管理来源",
"settings.plugins.sources.select": "选择来源",
"settings.plugins.sources.empty": "选择或添加第三方来源。",
"settings.plugins.sources.add": "添加来源",
"settings.plugins.sources.remove": "移除来源",
"settings.plugins.sources.name": "来源名称",
"settings.plugins.sources.address": "HTTPS 来源地址",
"settings.plugins.sources.risk": "第三方来源不代表 Netcatty 维护或认可。安装前请核对真实域名、插件权限与作者。",
"settings.plugins.sources.accept": "我信任此域名提供插件目录和软件包。此操作不会授予插件权限。",
"settings.plugins.sources.removeNote": "保留已安装插件及数据,停止此来源更新,不会自动切换到其他来源。",
"settings.plugins.sources.disablePlugins": "同时停用来自该来源的插件",
"settings.plugins.sources.installConfirm": "确认第三方安装",
"settings.plugins.sources.source": "来源",
"settings.plugins.sources.local": "本地导入",
"settings.plugins.sources.all": "全部来源",
"settings.plugins.title": "插件",
"settings.plugins.description": "管理已安装插件,浏览精选目录。",
'settings.plugins.loading': '正在加载插件设置…',
@@ -1295,20 +1273,5 @@ Vaults 标签保持扁平,不使用这块背景。
'plugins.authentication.responseFailed': '无法发送认证响应。请重试或取消此请求。',
'plugins.authentication.responseFailedWithMessage': '无法发送认证响应:{message}。请重试或取消此请求。',
'plugins.authentication.continue': '继续',
'plugins.hostActions.select': '分享所选主机的元数据',
'plugins.hostActions.selectionNote': '向此插件分享已保存的名称、地址、用户名、协议、分组和标签。选择分组仅包含本次选中的成员。连接主机需要单独确认。',
'plugins.hostActions.create': '预览主机草稿',
'plugins.hostActions.edit': '预览主机修改',
'plugins.hostActions.connect': '确认连接',
'plugins.hostActions.fieldDiff': '即将保存的修改',
'plugins.hostActions.defaultsWarning': '将应用已保存的连接默认设置。修改分组可能改变继承的认证、代理和脚本设置。',
'plugins.hostActions.invalidDraft': '请输入有效的主机草稿。',
'plugins.hostActions.scriptsRun': '此连接可能运行已保存的启动、连接或输出触发脚本。',
'plugins.hostActions.noScripts': '此主机未配置已保存的启动、连接或输出触发脚本。',
'plugins.hostActions.connectionNote': 'Netcatty 将使用已保存的设置打开连接。这不会向插件分享会话内容。',
'plugins.hostActions.localExecution': '本地执行:这将在此设备上打开一个 Shell。',
'plugins.hostActions.managedUnsupported': '受管理的 SSH 配置变更需在 Vault 主机编辑器中完成。',
'plugins.hostActions.failed': '请求已过期、被撤销或主机已改变。请重新选择主机。',
'plugins.hostActions.selectionLimit': '最多选择 50 个当前主机。',
};
-37
View File
@@ -1217,33 +1217,11 @@ Vaults 分頁維持扁平,不使用這塊背景。
"settings.plugins.manager.unbounded": "未宣告更細的資源範圍。請在權限提示中檢查實際授權範圍。",
"settings.plugins.openView": "開啟",
"settings.plugins.openManager": "開啟插件管理",
"settings.plugins.sources.conflict": "此 ID 已从其他来源安装。请先卸载并明确清理旧来源数据,再换源重装。原来源:",
"settings.plugins.sources.clearPrivate": "同时清除本来源的私有数据、授权和秘密,以便显式换源重装。此操作不可撤销。",
"settings.plugins.sources.official": "官方市场",
"settings.plugins.sources.thirdparty": "第三方市场",
"settings.plugins.sources.manage": "管理来源",
"settings.plugins.sources.select": "选择来源",
"settings.plugins.sources.empty": "选择或添加第三方来源。",
"settings.plugins.sources.add": "添加来源",
"settings.plugins.sources.remove": "移除来源",
"settings.plugins.sources.name": "来源名称",
"settings.plugins.sources.address": "HTTPS 来源地址",
"settings.plugins.sources.risk": "第三方来源不代表 Netcatty 维护或认可。安装前请核对真实域名、插件权限与作者。",
"settings.plugins.sources.accept": "我信任此域名提供插件目录和软件包。此操作不会授予插件权限。",
"settings.plugins.sources.removeNote": "保留已安装插件及数据,停止此来源更新,不会自动切换到其他来源。",
"settings.plugins.sources.disablePlugins": "同时停用来自该来源的插件",
"settings.plugins.sources.installConfirm": "确认第三方安装",
"settings.plugins.sources.source": "来源",
"settings.plugins.sources.local": "本地导入",
"settings.plugins.sources.all": "全部来源",
"settings.plugins.title": "插件",
"settings.plugins.description": "管理已安裝插件,瀏覽精選目錄。",
'settings.plugins.loading': '正在載入外掛程式設定…',
"settings.plugins.market.results": "{count} 個插件",
"settings.plugins.manager.lastError": "上次記錄的錯誤",
"settings.plugins.sources.offline": "來源暫不可用。如已快取此來源通過驗證的目錄,僅可繼續瀏覽;重新整理成功前無法安裝。",
"settings.plugins.sources.note": "此目錄由所選第三方提供,不屬於 Netcatty 精選目錄。安裝前請核對來源網域和權限,更新需手動確認。",
"settings.plugins.sources.sourceOnly": "此提供方僅提供原始碼項目,沒有可下載外掛套件;請查看作者的建置和匯入說明。",
"settings.plugins.market.offline": "官方目錄暫時無法使用,正在顯示上次有效目錄或內建範例。重新整理成功前無法從市場安裝。",
"settings.plugins.manager.details": "查看詳情",
"settings.plugins.manager.noDescription": "作者尚未提供功能簡介。",
@@ -1298,19 +1276,4 @@ Vaults 分頁維持扁平,不使用這塊背景。
'plugins.authentication.responseFailed': '無法送出驗證回應。請重試或取消此要求。',
'plugins.authentication.responseFailedWithMessage': '無法送出驗證回應:{message}。請重試或取消此要求。',
'plugins.authentication.continue': '繼續',
'plugins.hostActions.select': '分享所選主機的中繼資料',
'plugins.hostActions.selectionNote': '向此外掛分享已儲存的名稱、位址、使用者名稱、協定、群組和標籤。選擇群組僅包含本次選取的成員。連線需要另外確認。',
'plugins.hostActions.create': '預覽主機草稿',
'plugins.hostActions.edit': '預覽主機修改',
'plugins.hostActions.connect': '確認連線',
'plugins.hostActions.fieldDiff': '即將儲存的修改',
'plugins.hostActions.defaultsWarning': '將套用已儲存的連線預設設定。修改群組可能改變繼承的驗證、代理和指令碼設定。',
'plugins.hostActions.invalidDraft': '請輸入有效的主機草稿。',
'plugins.hostActions.scriptsRun': '此連線可能執行已儲存的啟動、連線或輸出觸發指令碼。',
'plugins.hostActions.noScripts': '此主機未設定已儲存的啟動、連線或輸出觸發指令碼。',
'plugins.hostActions.connectionNote': 'Netcatty 將使用已儲存的設定開啟連線。這不會向外掛分享工作階段內容。',
'plugins.hostActions.localExecution': '本機執行:這會在此裝置上開啟 Shell。',
'plugins.hostActions.managedUnsupported': '受管理的 SSH 設定變更需在 Vault 主機編輯器中完成。',
'plugins.hostActions.failed': '請求已過期、被撤銷或主機已變更。請重新選擇主機。',
'plugins.hostActions.selectionLimit': '最多選擇 50 部目前的主機。',
};
@@ -1,128 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { executePluginHostAction, type PluginHostAction, type PluginHostActionVault } from './pluginHostActionExecution';
import { PluginHostActionResources } from './pluginHostActionResources';
import type { Host, ManagedSource } from '../../domain/models';
const synthetic: Host = { id: 'synthetic', label: 'Synthetic', hostname: 'synthetic.invalid', username: 'test', tags: [], os: 'linux', password: 'synthetic-only', startupCommand: 'synthetic-script' };
function fixture() {
const resources = new PluginHostActionResources();
let active = true, authorized = 0, connections = 0, writes = 0;
let beforeRead: (() => void) | undefined;
let beforeCommit: (() => void) | undefined;
let duringAuthorize: (() => void) | undefined;
let storedSources: ManagedSource[] | undefined;
const vault: PluginHostActionVault = {
hosts: [synthetic], customGroups: [], groupConfigs: [], snippets: [], managedSources: [], isInitialized: true,
async readPersistedHosts() { beforeRead?.(); return [structuredClone(synthetic)]; },
async commitVaultImportTransaction(next, groups, sources, _configs, _expected, _lock, authorize) {
const nextSources = sources(storedSources ?? vault.managedSources);
beforeCommit?.();
await authorize?.();
writes++; vault.hosts = next;
return { status: 'persisted', groups: groups([]), sources: nextSources, groupConfigs: [] };
},
};
resources.observe(vault.hosts);
const target = resources.metadata(synthetic.id);
const options = {
getVault: () => vault, resources,
assertCurrent() { if (!active) throw new Error('Action cancelled'); },
async authorizeCommit() { duringAuthorize?.(); if (!active) throw new Error('Action revoked'); authorized++; },
connect(host: Host) { assert.equal(host, synthetic); connections++; return 'private-session-id'; },
};
return { vault, target, options, get authorized() { return authorized; }, get connections() { return connections; }, get writes() { return writes; },
cancel() { active = false; }, beforeRead(fn: () => void) { beforeRead = fn; }, beforeCommit(fn: () => void) { beforeCommit = fn; }, duringAuthorize(fn: () => void) { duringAuthorize = fn; },
storedSources(value: ManagedSource[]) { storedSources = value; },
};
}
test('reviewed connection dispatches through existing handler and returns no session data', async () => {
const f = fixture();
assert.equal(await executePluginHostAction({ kind: 'connect', target: f.target }, f.options), 'requested');
assert.equal(f.authorized, 1); assert.equal(f.connections, 1); assert.equal(f.writes, 0);
});
test('host edit preserves credentials and scripts and claims action authority after preparation', async () => {
const f = fixture();
assert.equal(await executePluginHostAction({ kind: 'edit', target: f.target, draft: { label: 'Reviewed' } }, f.options), 'saved');
assert.equal(f.vault.hosts[0].password, synthetic.password);
assert.equal(f.vault.hosts[0].startupCommand, synthetic.startupCommand);
assert.equal(f.vault.hosts[0].label, 'Reviewed');
assert.equal(f.authorized, 1); assert.equal(f.writes, 1); assert.equal(f.connections, 0);
});
for (const point of ['beforeRead', 'beforeCommit', 'duringAuthorize'] as const) test(`cancel/revoke at ${point} prevents the Vault write`, async () => {
const f = fixture(); f[point](() => f.cancel());
await assert.rejects(executePluginHostAction({ kind: 'edit', target: f.target, draft: { label: 'Never saved' } }, f.options), /cancelled|revoked/);
assert.equal(f.writes, 0); assert.equal(f.connections, 0);
});
for (const change of ['remove', 'replace', 'scripts', 'locked'] as const) test(`${change} during awaited connection preparation prevents dispatch`, async () => {
const f = fixture();
f.beforeRead(() => {
if (change === 'remove') f.vault.hosts = [];
if (change === 'replace') f.vault.hosts = [{ ...synthetic }];
if (change === 'scripts') f.vault.snippets = [];
if (change === 'locked') f.cancel();
});
await assert.rejects(executePluginHostAction({ kind: 'connect', target: f.target }, f.options));
assert.equal(f.authorized, 0); assert.equal(f.connections, 0);
});
test('create produces a saved SSH draft without executable or credential injection', async () => {
const f = fixture();
const action: PluginHostAction = { kind: 'create', draft: { hostname: 'new.invalid', label: 'New', group: 'Synthetic', password: 'untrusted', startupCommand: 'untrusted' } as never };
assert.equal(await executePluginHostAction(action, f.options), 'saved');
const created = f.vault.hosts[1];
assert.notEqual(created.id, synthetic.id);
assert.equal(created.password, undefined); assert.equal(created.startupCommand, undefined);
});
const managedSources: ManagedSource[] = [
{ id: 'synthetic-source-a', type: 'ssh_config', filePath: '/synthetic/a.conf', groupName: 'A', lastSyncedAt: 0 },
{ id: 'synthetic-source-b', type: 'ssh_config', filePath: '/synthetic/b.conf', groupName: 'B', lastSyncedAt: 0 },
];
for (const destination of ['B', 'Unmanaged', ''] as const) test(`managed A to ${destination || 'no group'} is refused before action authority or persistence`, async () => {
const f = fixture();
const host = { ...synthetic, group: 'A', managedSourceId: managedSources[0].id };
f.vault.hosts = [host]; f.vault.managedSources = managedSources;
f.vault.readPersistedHosts = async () => [structuredClone(host)];
f.options.resources.observe(f.vault.hosts);
const target = f.options.resources.metadata(host.id);
await assert.rejects(executePluginHostAction({ kind: 'edit', target, draft: { group: destination } }, f.options), /Managed.*Vault/);
assert.equal(f.writes, 0); assert.equal(f.authorized, 0);
assert.equal(f.vault.hosts[0].managedSourceId, managedSources[0].id);
});
for (const kind of ['create', 'edit'] as const) test(`${kind} into a managed destination is refused`, async () => {
const f = fixture(); f.vault.managedSources = managedSources;
const action: PluginHostAction = kind === 'create'
? { kind, draft: { hostname: 'new.invalid', group: 'B/Child' } }
: { kind, target: f.target, draft: { group: 'B/Child' } };
await assert.rejects(executePluginHostAction(action, f.options), /Managed.*Vault/);
assert.equal(f.writes, 0); assert.equal(f.authorized, 0);
});
test('ordinary unmanaged moves preserve the managed-source catalog without taking ownership', async () => {
const f = fixture(); f.vault.managedSources = managedSources;
assert.equal(await executePluginHostAction({ kind: 'edit', target: f.target, draft: { group: 'Unmanaged' } }, f.options), 'saved');
assert.equal(f.vault.hosts[0].managedSourceId, undefined);
assert.equal(f.vault.hosts[0].group, 'Unmanaged');
assert.equal(f.vault.managedSources, managedSources);
});
test('a managed-source catalog replacement during preparation prevents the write', async () => {
const f = fixture();
f.beforeCommit(() => { f.vault.managedSources = managedSources; });
await assert.rejects(executePluginHostAction({ kind: 'create', draft: { hostname: 'new.invalid', group: 'B' } }, f.options), /Managed sources changed/);
assert.equal(f.writes, 0); assert.equal(f.authorized, 0);
});
test('a newer persisted managed-source catalog rejects a stale unmanaged preview before the claim', async () => {
const f = fixture(); f.storedSources(managedSources);
await assert.rejects(executePluginHostAction({ kind: 'create', draft: { hostname: 'new.invalid', group: 'B' } }, f.options), /Managed sources changed/);
assert.equal(f.writes, 0); assert.equal(f.authorized, 0);
});
@@ -1,102 +0,0 @@
import type { Host, GroupConfig, Snippet, ManagedSource } from '../../domain/models';
import { canonicalJsonString, normalizeJsonValue } from '../../domain/convergentSync/json';
import { applyPluginHostEdit, normalizePluginHostDraft, pluginHostDraftTouchesManagedSource, type PluginHostDraftFields } from '../../domain/pluginHostActions';
import { applyVaultHostCreates } from '../../domain/vaultHostCreate';
import type { PluginHostActionResources } from './pluginHostActionResources';
import type { AppVaultRuntime } from './appRuntimeBridge';
// Application action model. Public RPC types live only in plugin-contract.
export type PluginHostAction =
| { kind: 'create'; draft: PluginHostDraftFields }
| { kind: 'edit'; target: { resourceId: string; revision: string }; draft: PluginHostDraftFields }
| { kind: 'connect'; target: { resourceId: string; revision: string } };
export interface PluginHostActionVault {
hosts: Host[];
customGroups: string[];
groupConfigs: GroupConfig[];
snippets: Snippet[];
managedSources: ManagedSource[];
isInitialized: boolean;
readPersistedHosts: () => Promise<Host[]>;
commitVaultImportTransaction: AppVaultRuntime['commitVaultImportTransaction'];
}
export interface PluginHostActionExecution {
getVault: () => PluginHostActionVault;
resources: PluginHostActionResources;
assertCurrent: () => void;
authorizeCommit: () => Promise<void>;
connect: (host: Host) => unknown;
}
const sameHost = (left: Host, right: Host) => (
canonicalJsonString(normalizeJsonValue(left)) === canonicalJsonString(normalizeJsonValue(right))
);
/** Execute only a host-reviewed action. authorizeCommit claims separate,
* one-use main-process action authority after the final asynchronous work. */
export async function executePluginHostAction(action: PluginHostAction, options: PluginHostActionExecution): Promise<'saved' | 'requested'> {
const current = () => {
options.assertCurrent();
const vault = options.getVault();
if (!vault.isInitialized) throw new Error('Vault is unavailable');
options.resources.observe(vault.hosts);
return vault;
};
const initialVault = current();
const reviewedGroups = initialVault.groupConfigs;
const reviewedSnippets = initialVault.snippets;
const reviewedSources = initialVault.managedSources;
const selected = action.kind === 'create' ? undefined : options.resources.resolve(action.target.resourceId, action.target.revision);
const assertSelected = () => {
const vault = current();
if (action.kind !== 'create' && options.resources.resolve(action.target.resourceId, action.target.revision) !== selected) {
throw new Error('Selected host changed');
}
// A connection preview includes inherited and global automation. Any
// settings change while the preview is open requires fresh confirmation.
if (action.kind === 'connect' && (vault.groupConfigs !== reviewedGroups || vault.snippets !== reviewedSnippets)) {
throw new Error('Saved connection settings changed');
}
if (action.kind !== 'connect' && vault.managedSources !== reviewedSources) throw new Error('Managed sources changed during draft review');
return vault;
};
const persisted = await initialVault.readPersistedHosts();
assertSelected();
if (selected) {
const persistedHost = persisted.find((host) => host.id === selected.id);
if (!persistedHost || !sameHost(selected, persistedHost)) throw new Error('Selected host changed');
}
if (action.kind === 'connect') {
await options.authorizeCommit();
assertSelected();
// The existing host connection handler owns authentication, saved scripts,
// and sessions. Its return value never reaches the sandboxed plugin.
options.connect(selected!);
return 'requested';
}
const nextHost = action.kind === 'create'
? normalizePluginHostDraft(action.draft, crypto.randomUUID())
: applyPluginHostEdit(selected!, action.draft);
if (pluginHostDraftTouchesManagedSource(nextHost, reviewedSources)) {
throw new Error('Managed SSH config changes require the Vault host editor');
}
const nextHosts = action.kind === 'create'
? applyVaultHostCreates(persisted, [], [nextHost]).hosts
: persisted.map((host) => host.id === nextHost.id ? nextHost : host);
if (action.kind === 'create' && nextHosts.length !== persisted.length + 1) throw new Error('Host already exists');
const updateGroups = (groups: string[]) => nextHost.group && !groups.includes(nextHost.group) ? [...groups, nextHost.group] : groups;
const sourceSnapshot = canonicalJsonString(normalizeJsonValue(reviewedSources));
const keepSources = (sources: ManagedSource[]) => {
if (canonicalJsonString(normalizeJsonValue(sources)) !== sourceSnapshot) throw new Error('Managed sources changed during draft review');
return sources;
};
const result = await initialVault.commitVaultImportTransaction(
nextHosts, updateGroups, keepSources,
undefined, persisted, undefined,
async () => { assertSelected(); await options.authorizeCommit(); assertSelected(); },
);
if (result.status !== 'persisted') throw new Error('Vault changed before the draft was saved');
return 'saved';
}
@@ -1,32 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { PluginHostActionResources } from './pluginHostActionResources';
import type { Host } from '../../domain/models';
const first: Host = { id: 'synthetic-1', label: 'First', hostname: 'first.invalid', username: 'test', tags: [], os: 'linux', group: 'Synthetic', password: 'synthetic-password', startupCommand: 'synthetic-script', totp: { uri: 'synthetic-totp' } as never };
test('selected metadata projects only the allowlist and replacement invalidates revision', () => {
const resources = new PluginHostActionResources();
resources.observe([first]);
const metadata = resources.metadata(first.id);
assert.deepEqual(Object.keys(metadata), ['resourceId', 'revision', 'label', 'hostname', 'username', 'protocol', 'group', 'tags']);
assert.equal(JSON.stringify(metadata).includes('synthetic-password'), false);
resources.observe([first]);
assert.equal(resources.resolve(first.id, metadata.revision), first);
resources.observe([{ ...first }]);
assert.throws(() => resources.resolve(first.id, metadata.revision), /changed/);
const replaced = resources.metadata(first.id);
resources.observe([]);
assert.throws(() => resources.resolve(first.id, replaced.revision), /changed/);
resources.observe([first]);
assert.throws(() => resources.resolve(first.id, replaced.revision), /changed/);
});
test('group choices are explicit current members and never expand to later members', () => {
const resources = new PluginHostActionResources();
resources.observe([first]);
const selection = [resources.metadata(first.id)];
resources.observe([first, { ...first, id: 'synthetic-2' }]);
assert.deepEqual(selection.map((item) => item.resourceId), [first.id]);
assert.equal(resources.resolve(first.id, selection[0].revision), first);
});
@@ -1,34 +0,0 @@
import type { Host } from '../../domain/models';
/** Renderer-local revisions track replacement as well as visible edits. They
* are never authorization handles; only the main-process authority issues one. */
export class PluginHostActionResources {
private entries = new Map<string, { host: Host; revision: string }>();
observe(hosts: readonly Host[]) {
const next = new Map<string, { host: Host; revision: string }>();
for (const host of hosts) {
const existing = this.entries.get(host.id);
next.set(host.id, existing?.host === host ? existing : { host, revision: crypto.randomUUID() });
}
this.entries = next;
}
resolve(id: string, expectedRevision: string): Host {
const entry = this.entries.get(id);
if (!entry || entry.revision !== expectedRevision) throw new Error('Selected host changed; select it again');
return entry.host;
}
metadata(id: string) {
const entry = this.entries.get(id);
if (!entry) throw new Error('Selected host is unavailable');
const host = entry.host;
return {
resourceId: host.id, revision: entry.revision,
label: host.label, hostname: host.hostname, username: host.username,
...(host.port === undefined ? {} : { port: host.port }), protocol: host.protocol ?? 'ssh',
group: host.group ?? '', tags: [...host.tags],
};
}
}
+2 -2
View File
@@ -77,8 +77,8 @@ test('offline snapshot stays browsable, retry clears warning, and operations bin
offline = false;
await act(async () => state.retryList());
assert.equal(state.listError, null); assert.equal(state.source, 'registry');
assert.deepEqual(reads.at(-1), [entry.id, 'second', 'official']);
assert.deepEqual(reads.at(-1), [entry.id, 'second']);
await act(async () => state.install(entry.id));
assert.deepEqual(installs, [[entry.id, 'second', 'official', undefined]]);
assert.deepEqual(installs, [[entry.id, 'second']]);
await act(async () => renderer.unmount());
});
+8 -20
View File
@@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from 'react';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
type ReadmeState = { id: string; token?: string; attempt: number; status: 'loading' | 'ready' | 'error'; text: string; error: string | null };
export function usePluginCatalog(selectedId: string | null, sourceId = 'official') {
export function usePluginCatalog(selectedId: string | null) {
const [entries, setEntries] = useState<NetcattyPluginCatalogEntry[]>([]);
const [listStatus, setListStatus] = useState<'loading' | 'ready' | 'error'>('loading');
const [source, setSource] = useState<NetcattyPluginCatalogSnapshot['source']>('builtin');
@@ -12,7 +12,6 @@ export function usePluginCatalog(selectedId: string | null, sourceId = 'official
const [readmeAttempt, setReadmeAttempt] = useState(0);
const [installError, setInstallError] = useState<{ id: string; message: string } | null>(null);
const [busy, setBusy] = useState(false);
const [sourceBinding, setSourceBinding] = useState<NetcattyPluginSource | null>(null);
const installing = useRef(false);
const mounted = useRef(false);
useEffect(() => { mounted.current = true; return () => { mounted.current = false; }; }, []);
@@ -23,18 +22,12 @@ export function usePluginCatalog(selectedId: string | null, sourceId = 'official
try {
const bridge = netcattyBridge.get();
if (!bridge?.listPluginCatalog) throw new Error('Catalog browsing unavailable');
const list = await bridge.listPluginCatalog(sourceId);
const list = await bridge.listPluginCatalog();
if (active) { setEntries(list.entries); setSource(list.source); setListError(list.error); setListStatus('ready'); }
} catch (cause) { if (active) { setListStatus('error'); setListError(String(cause)); } }
})();
return () => { active = false; };
}, [listAttempt, sourceId]);
useEffect(() => {
let active = true;
setSourceBinding(null);
if (selectedId) void netcattyBridge.get()?.getPluginSourceBinding?.(selectedId).then((binding) => { if (active) setSourceBinding(binding); }).catch((cause) => { if (active) setInstallError({ id: selectedId, message: String(cause) }); });
return () => { active = false; };
}, [selectedId, sourceId]);
}, [listAttempt]);
const selectionToken = entries.find((entry) => entry.id === selectedId)?.selectionToken;
useEffect(() => {
if (!selectedId) return;
@@ -45,15 +38,15 @@ export function usePluginCatalog(selectedId: string | null, sourceId = 'official
try {
const bridge = netcattyBridge.get();
if (!bridge?.getPluginCatalogReadme) throw new Error('README retrieval unavailable');
const text = await bridge.getPluginCatalogReadme(selectedId, selectionToken, sourceId);
const text = await bridge.getPluginCatalogReadme(selectedId, selectionToken);
if (active) setReadmeState({ ...base, status: 'ready', text });
} catch (cause) { if (active) setReadmeState({ ...base, status: 'error', error: String(cause) }); }
})();
return () => { active = false; };
}, [selectedId, selectionToken, readmeAttempt, sourceId]);
}, [selectedId, selectionToken, readmeAttempt]);
const currentReadme = readmeState?.id === selectedId && readmeState.token === selectionToken && readmeState.attempt === readmeAttempt ? readmeState : null;
return {
entries, listStatus, listError, source, sourceBinding,
entries, listStatus, listError, source,
readme: currentReadme?.status === 'ready' ? currentReadme.text : null,
readmeStatus: currentReadme?.status ?? (selectedId ? 'loading' : 'idle'),
readmeError: currentReadme?.error ?? null,
@@ -61,19 +54,14 @@ export function usePluginCatalog(selectedId: string | null, sourceId = 'official
busy,
retryList: () => setListAttempt((attempt) => attempt + 1),
retryReadme: () => setReadmeAttempt((attempt) => attempt + 1),
async install(id: string, acceptedSourceId?: string, clearPreviousSource = false) {
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');
if (clearPreviousSource && sourceBinding && sourceBinding.id !== sourceId) {
if (!bridge.resetPluginSource) throw new Error('Source reset unavailable');
await bridge.resetPluginSource(id, sourceBinding.id);
setSourceBinding(null);
}
await bridge.installCatalogPlugin(id, entries.find((entry) => entry.id === id)?.selectionToken, sourceId, acceptedSourceId);
await bridge.installCatalogPlugin(id, entries.find((entry) => entry.id === id)?.selectionToken);
window.dispatchEvent(new Event('netcatty:plugin-packages-changed'));
} catch (cause) { if (mounted.current) setInstallError({ id, message: String(cause) }); }
finally { installing.current = false; if (mounted.current) setBusy(false); }
-168
View File
@@ -1,168 +0,0 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import type { HostInteractionPrompt, VaultHostActionRequest } from '@netcatty/plugin-contract';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
import { getAppHandlers } from '../app/appHandlersBridge';
import { getAppAppLockRuntime, getAppVaultRuntime } from './appRuntimeBridge';
import { PluginHostActionResources } from './pluginHostActionResources';
import { executePluginHostAction, type PluginHostAction } from './pluginHostActionExecution';
import { pluginHostRunsSavedScripts, type PluginHostDraftFields } from '../../domain/pluginHostActions';
import type { Host, GroupConfig, ManagedSource, Snippet } from '../../domain/models';
export type VaultHostPrompt = Omit<HostInteractionPrompt, 'request'> & { request: VaultHostActionRequest };
type Review = {
prompt: VaultHostPrompt;
candidates: Host[];
candidateRevisions: Map<string, string>;
customGroups: string[];
host?: Host;
groups: GroupConfig[];
snippets: Snippet[];
managedSources: ManagedSource[];
scriptsRun: boolean;
claimed: boolean;
completionToken?: string;
};
export function usePluginHostActions() {
const [review, setReview] = useState<Review | null>(null);
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const [busy, setBusy] = useState(false);
const [error, setError] = useState(false);
const current = useRef<Review | null>(null);
const resources = useRef(new PluginHostActionResources());
const responding = useRef(false);
const getVault = useCallback(() => {
const vault = getAppVaultRuntime();
const lock = getAppAppLockRuntime();
if (!vault?.isInitialized || !lock?.initialized || lock.locked) throw new Error('Vault is locked or unavailable');
const actionState = vault.readCurrentHostActionState();
resources.current.observe(actionState.hosts);
return { ...vault, ...actionState };
}, []);
const assertCurrent = useCallback((entry: Review) => {
if (current.current !== entry || Date.now() >= entry.prompt.expiresAt) throw new Error('Host action expired');
const vault = getVault();
const target = entry.prompt.vaultTarget;
if (target && resources.current.resolve(target.resourceId, target.revision) !== entry.host) throw new Error('Selected host changed');
if (entry.prompt.request.kind === 'vault.requestConnect' && (vault.groupConfigs !== entry.groups || vault.snippets !== entry.snippets)) {
throw new Error('Saved connection settings changed');
}
if (entry.prompt.request.kind === 'vault.requestHostDraft' && vault.managedSources !== entry.managedSources) throw new Error('Managed sources changed during draft review');
}, [getVault]);
const fail = useCallback(async (entry: Review) => {
if (current.current !== entry) return;
setError(true);
try {
await netcattyBridge.get()?.respondPluginInteraction?.({
requestId: entry.prompt.requestId, actionToken: entry.completionToken ?? entry.prompt.actionToken, action: 'fail',
});
} catch { /* A stopped runtime or closed window already invalidates it. */ }
}, []);
const execute = useCallback(async (entry: Review) => {
const bridge = netcattyBridge.get();
if (!bridge?.respondPluginInteraction) return fail(entry);
setBusy(true);
try {
assertCurrent(entry);
const request = entry.prompt.request;
const target = entry.prompt.vaultTarget;
let action: PluginHostAction;
if (request.kind === 'vault.requestConnect' && target) action = { kind: 'connect', target };
else if (request.kind === 'vault.requestHostDraft' && entry.prompt.reviewedDraft) {
action = target ? { kind: 'edit', target, draft: entry.prompt.reviewedDraft } : { kind: 'create', draft: entry.prompt.reviewedDraft };
} else throw new Error('Host action is invalid');
await executePluginHostAction(action, {
getVault, resources: resources.current,
assertCurrent: () => assertCurrent(entry),
authorizeCommit: async () => {
assertCurrent(entry);
const receipt = await bridge.respondPluginInteraction!({ requestId: entry.prompt.requestId, actionToken: entry.prompt.actionToken, action: 'commit' });
if (!receipt || typeof receipt !== 'object' || !receipt.completionToken) throw new Error('Host action was revoked');
entry.completionToken = receipt.completionToken;
entry.claimed = true;
assertCurrent(entry);
},
connect: (host) => {
const connect = getAppHandlers()?.handleConnectToHost;
if (typeof connect !== 'function') throw new Error('Host connection flow unavailable');
return connect(host);
},
});
await bridge.respondPluginInteraction({ requestId: entry.prompt.requestId, actionToken: entry.completionToken!, action: 'complete' });
} catch { await fail(entry); }
}, [assertCurrent, fail, getVault]);
useEffect(() => {
const bridge = netcattyBridge.get();
if (!bridge?.onPluginInteraction) return;
const unsubscribe = bridge.onPluginInteraction((event) => {
if ('closed' in event) {
if (current.current?.prompt.requestId !== event.requestId) return;
current.current = null; setReview(null); setBusy(false); responding.current = false;
return;
}
if (!event.request.kind.startsWith('vault.')) return;
const prompt = event as VaultHostPrompt;
try {
if (prompt.phase === 'commit') {
const entry = current.current;
if (!entry || entry.prompt.requestId !== prompt.requestId || entry.prompt.request.kind !== prompt.request.kind) throw new Error('Host review is unavailable');
entry.prompt = prompt;
setReview({ ...entry });
void execute(entry);
return;
}
const vault = getVault();
const candidates = [...vault.hosts];
const host = prompt.vaultTarget ? resources.current.resolve(prompt.vaultTarget.resourceId, prompt.vaultTarget.revision) : undefined;
const entry: Review = {
prompt, candidates, candidateRevisions: new Map(candidates.map((candidate) => [candidate.id, resources.current.metadata(candidate.id).revision])),
customGroups: vault.customGroups, host, groups: vault.groupConfigs, snippets: vault.snippets,
managedSources: vault.managedSources,
scriptsRun: host ? pluginHostRunsSavedScripts(host, vault.groupConfigs, vault.snippets) : false,
claimed: false,
};
current.current = entry; setReview(entry); setSelectedIds([]); setBusy(false); setError(false); responding.current = false;
} catch {
void bridge.respondPluginInteraction?.({ requestId: prompt.requestId, actionToken: prompt.actionToken, action: 'fail' }).catch(() => {});
}
});
return () => {
const entry = current.current;
current.current = null;
unsubscribe();
if (entry && !entry.claimed) void bridge.respondPluginInteraction?.({ requestId: entry.prompt.requestId, actionToken: entry.prompt.actionToken, action: 'cancel' }).catch(() => {});
};
}, [execute, getVault]);
const respond = useCallback(async (action: 'cancel' | 'submit', draft?: PluginHostDraftFields) => {
const entry = current.current;
if (!entry || responding.current || (action === 'cancel' && entry.claimed)) return;
responding.current = true; setBusy(true); setError(false);
try {
assertCurrent(entry);
const request = entry.prompt.request;
const selected = request.kind === 'vault.selectHosts' && action === 'submit' ? selectedIds.map((id) => {
const revision = entry.candidateRevisions.get(id);
if (!revision) throw new Error('Host was not part of the current selection');
resources.current.resolve(id, revision);
return resources.current.metadata(id);
}) : undefined;
await netcattyBridge.require().respondPluginInteraction?.({
requestId: entry.prompt.requestId, actionToken: entry.prompt.actionToken, action,
...(selected === undefined ? {} : { resources: selected }),
...(draft === undefined ? {} : { draft }),
...(request.kind === 'vault.requestConnect' && action === 'submit' ? { scriptsRun: entry.scriptsRun } : {}),
});
} catch { await fail(entry); }
finally {
if (current.current === entry && entry.prompt.phase === 'request') { responding.current = false; setBusy(false); }
}
}, [assertCurrent, fail, selectedIds]);
return { review, selectedIds, setSelectedIds, busy, error, respond };
}
@@ -1,14 +1,13 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import type { HostInteractionPrompt, HostInteractionRequest, HostInteractionResponse } from '@netcatty/plugin-contract';
import type { HostInteractionPrompt, HostInteractionResponse } from '@netcatty/plugin-contract';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
export function usePluginHostInteractions() {
type UiPrompt = Omit<HostInteractionPrompt, 'request'> & { request: HostInteractionRequest };
const [prompt, setPrompt] = useState<UiPrompt | null>(null);
const [prompt, setPrompt] = useState<HostInteractionPrompt | null>(null);
const [value, setValue] = useState('');
const [error, setError] = useState(false);
const [busy, setBusy] = useState(false);
const current = useRef<UiPrompt | null>(null);
const current = useRef<HostInteractionPrompt | null>(null);
const pending = useRef(false);
useEffect(() => {
const bridge = netcattyBridge.get();
@@ -18,8 +17,7 @@ export function usePluginHostInteractions() {
if (current.current?.requestId !== event.requestId) return;
current.current = null; setPrompt(null);
} else {
if (event.request.kind.startsWith('vault.')) return;
current.current = event as UiPrompt; setPrompt(event as UiPrompt);
current.current = event; setPrompt(event);
setValue(event.request.kind === 'input' ? event.request.initialValue ?? '' : '');
}
pending.current = false; setBusy(false); setError(false);
+49
View File
@@ -0,0 +1,49 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
export function usePluginLogs() {
const [status, setStatus] = useState<NetcattyPluginLogStatus | null>(null);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const pending = useRef(false);
const mounted = useRef(true);
const run = useCallback(async (action: 'status' | 'configure' | 'open' | 'export' | 'clear', settings?: { saveToDisk: boolean; verbose: boolean }) => {
if (pending.current) return;
pending.current = true;
setBusy(true);
setError(null);
try {
const bridge = netcattyBridge.require();
const result = await (action === 'configure' ? bridge.configurePluginLogs?.(settings!)
: action === 'open' ? bridge.openPluginLogFolder?.()
: action === 'export' ? bridge.exportPluginLogs?.()
: action === 'clear' ? bridge.clearPluginLogs?.() : bridge.getPluginLogStatus?.());
if (!result) throw new Error('Local plugin logging is unavailable');
if (mounted.current) setStatus(result);
} catch (cause) {
let message = cause instanceof Error ? cause.message : String(cause);
if (action === 'configure') {
// Configuration fails closed before disk I/O. Show actual host state,
// even when persisting a setting failed, rather than keeping stale ON.
try {
const actual = await netcattyBridge.require().getPluginLogStatus?.();
if (!actual) throw new Error('Local plugin log status is unavailable');
if (mounted.current) setStatus(actual);
} catch (refreshError) {
if (mounted.current) setStatus(null);
message += `\n${refreshError instanceof Error ? refreshError.message : String(refreshError)}`;
}
}
if (mounted.current) setError(message);
} finally {
pending.current = false;
if (mounted.current) setBusy(false);
}
}, []);
useEffect(() => {
mounted.current = true;
void run('status');
return () => { mounted.current = false; };
}, [run]);
return { status, error, busy, run };
}
+2 -7
View File
@@ -61,14 +61,9 @@ export function usePluginManager() {
if (!bridge.setPluginEnabled) throw new Error('Plugin management unavailable');
return bridge.setPluginEnabled(id, enabled);
}),
uninstall: (id: string, clearSource = false) => run(async (bridge) => {
uninstall: (id: string) => run(async (bridge) => {
if (!bridge.uninstallPlugin) throw new Error('Plugin management unavailable');
const source = clearSource ? await bridge.getPluginSourceBinding?.(id) : null;
await bridge.uninstallPlugin(id);
if (clearSource && source) {
if (!bridge.resetPluginSource) throw new Error('Private source reset unavailable');
await bridge.resetPluginSource(id, source.id);
}
return bridge.uninstallPlugin(id);
}),
};
}
-25
View File
@@ -1,25 +0,0 @@
import { useCallback, useEffect, useState } from 'react';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
export function usePluginSources() {
const [sources, setSources] = useState<NetcattyPluginSource[]>([]);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const reload = useCallback(async () => {
const values = await netcattyBridge.require().listPluginSources?.();
if (!values) throw new Error('Source management unavailable');
setSources(values);
}, []);
useEffect(() => { void reload().catch((cause) => setError(String(cause))); }, [reload]);
const mutate = async (operation: () => Promise<unknown>) => {
setBusy(true); setError(null);
try { await operation(); await reload(); window.dispatchEvent(new Event('netcatty:plugin-packages-changed')); return true; }
catch (cause) { setError(String(cause)); return false; }
finally { setBusy(false); }
};
return { sources, error, busy,
add: (name: string, address: string) => mutate(async () => netcattyBridge.require().addPluginSource?.({ name, address })),
update: (source: NetcattyPluginSource, name: string, enabled: boolean) => mutate(async () => netcattyBridge.require().updatePluginSource?.({ id: source.id, name, enabled })),
remove: (id: string, disablePlugins: boolean) => mutate(async () => netcattyBridge.require().removePluginSource?.(id, disablePlugins)),
};
}
-13
View File
@@ -776,13 +776,6 @@ export const useVaultState = (options?: UseVaultStateOptions) => {
}
}, [waitForPendingVaultWrites]);
// Host-owned action guards need the synchronous mutation snapshot, including
// edits that React has not published yet. This accessor never crosses RPC.
const readCurrentHostActionState = useCallback(() => ({
hosts: hostsRef.current, groupConfigs: groupConfigsRef.current, snippets: snippetsRef.current,
managedSources: managedSourcesRef.current,
}), []);
const updateKeys = useCallback((data: SSHKey[]) => {
const cleaned = normalizeVaultOrder(data);
setKeys(cleaned);
@@ -1255,7 +1248,6 @@ export const useVaultState = (options?: UseVaultStateOptions) => {
updateGroupConfigs?: (current: GroupConfig[]) => GroupConfig[],
expectedHosts?: Host[],
lock?: VaultLockHandle | null,
assertAuthorized?: () => void | Promise<void>,
): Promise<
| {
status: "persisted";
@@ -1330,10 +1322,6 @@ export const useVaultState = (options?: UseVaultStateOptions) => {
encryptHosts(cleanedHosts),
encryptGroupConfigs(nextGroupConfigs),
]);
// Optional plugin draft guard: runtime/source/revoke/lock authority must
// still hold after encryption, immediately before the existing atomic
// persistence boundary. Ordinary imports retain their existing behavior.
if (assertAuthorized) await assertAuthorized();
if (
version !== hostsWriteVersion.current
|| groupConfigsVersion !== groupConfigsWriteVersion.current
@@ -2531,7 +2519,6 @@ export const useVaultState = (options?: UseVaultStateOptions) => {
groupConfigs,
updateHosts,
readPersistedHosts,
readCurrentHostActionState,
updateKeys,
importOrReuseKey,
updateIdentities,
@@ -1,290 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { EventEmitter } from 'node:events';
import { createRequire } from 'node:module';
import { mkdtemp, cp, rm } from 'node:fs/promises';
import path from 'node:path';
import { tmpdir } from 'node:os';
import { JSDOM } from 'jsdom';
import { sanitizeHost } from '../domain/host';
import type { ConnectionLog, Host, ManagedSource } from '../domain/models';
import { applyGroupDefaults, resolveGroupDefaults } from '../domain/groupConfig';
import type { HostInteractionEvent, HostInteractionPrompt, VaultHostSelectionResult, VaultHostActionResult, VaultHostRef } from '@netcatty/plugin-contract';
const require = createRequire(import.meta.url);
test('installed sample entrypoint uses real SDK, source authority, signed host dialogs and renderer action guards', { timeout: 45_000 }, async (t) => {
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', 'HTMLButtonElement', 'Element', 'SVGElement', '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);
install('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} });
const { default: React, act } = await import('react');
const { createRoot } = await import('react-dom/client');
const { I18nProvider } = await import('../application/i18n/I18nProvider');
const { PluginHostActionHost } = await import('./plugins/PluginHostActionHost');
const { handleConnectToHostImpl } = await import('../application/app/AppHandlers');
const runtime = await import('../application/state/appRuntimeBridge');
const { registerAppHandlers } = await import('../application/app/appHandlersBridge');
const { createPluginHostService } = require('../electron/plugins/hostService.cjs');
const { inProcessBrowserRuntime } = require('../electron/plugins/fixtures/inProcessBrowserRuntime.cjs');
const directory = await mkdtemp(path.join(tmpdir(), 'netcatty-host-entry-'));
const listeners = new Set<(event: unknown) => void>();
const prompts: HostInteractionPrompt[] = [];
const sender = Object.assign(new EventEmitter(), { id: 77, isDestroyed: () => false, send(_channel: string, event: HostInteractionEvent) { if (!('closed' in event)) prompts.push(event); for (const listener of listeners) listener(event); } });
const owner = Object.assign(new EventEmitter(), { webContents: sender, isFocused: () => true, isVisible: () => true, isDestroyed: () => false });
let locked = false;
let onLock: ((state: { locked: boolean }) => void) | undefined;
const service = createPluginHostService({
app: { getAppPath: () => process.cwd(), getPath: () => directory, getVersion: () => '0.0.0' }, electron: {}, env: {},
getInteractionWindow: () => owner, isAppLocked: () => locked,
subscribeAppLock: (callback: typeof onLock) => { onLock = callback; return () => {}; },
requestPermissionDecision: async (request: { requestId: string; permission: string }) => ({ requestId: request.requestId, decision: 'allow', scope: ['vault.metadata', 'vault.write', 'ui.interact'].includes(request.permission) ? 'once' : 'application' }),
});
let requestClock = Date.now();
service.interactionService.clock = () => requestClock;
service.runtimeSupervisor.runtimeFactories.browser = inProcessBrowserRuntime;
Object.assign(dom.window, { netcatty: {
onPluginInteraction(callback: (event: unknown) => void) { listeners.add(callback); return () => listeners.delete(callback); },
respondPluginInteraction: (response: unknown) => service.interactionService.respond(sender, response),
} });
const first = sanitizeHost({ id: 'synthetic-first', label: 'Synthetic first', hostname: 'first.invalid', username: 'test', port: 22, protocol: 'ssh', os: 'linux', tags: ['synthetic'], group: 'Synthetic', password: 'synthetic-password', startupCommand: 'synthetic-script' });
const second = sanitizeHost({ ...first, id: 'synthetic-second', label: 'Synthetic second', hostname: 'second.invalid', password: undefined, startupCommand: undefined });
let hosts: Host[] = [first, second];
let persisted = hosts.map((host) => structuredClone(host));
let beforeCommit: (() => Promise<void>) | undefined;
let saves = 0;
const connections: Host[] = [];
const vault = {
hosts, customGroups: ['Synthetic'], groupConfigs: [], snippets: [], managedSources: [] as ManagedSource[], isInitialized: true,
readCurrentHostActionState: () => ({ hosts, groupConfigs: vault.groupConfigs, snippets: vault.snippets, managedSources: vault.managedSources }),
async readPersistedHosts() { return persisted.map((host) => structuredClone(host)); },
async commitVaultImportTransaction(next: Host[], groups: (groups: string[]) => string[], sources: (sources: ManagedSource[]) => ManagedSource[], _configs: unknown, _expected: unknown, _lock: unknown, authorize: () => Promise<void>) {
const nextSources = sources(vault.managedSources);
await beforeCommit?.();
await authorize();
saves++; hosts = next; persisted = next.map((host) => structuredClone(host));
return { status: 'persisted', groups: groups(vault.customGroups), sources: nextSources, groupConfigs: [] };
},
};
const publish = () => {
runtime.registerAppVaultRuntime(vault as never);
runtime.registerAppAppLockRuntime({ initialized: true, locked } as never);
};
publish();
registerAppHandlers({ handleConnectToHost: (host: Host) => { connections.push(host); return 'private-session-id'; } });
const root = createRoot(document.getElementById('root')!);
const settle = () => new Promise((resolve) => setTimeout(resolve, 20));
const waitFor = async (predicate: () => boolean) => {
for (let count = 0; count < 80; count++) { await act(async () => { await settle(); }); if (predicate()) return; }
assert.fail(`Expected host UI state; current text: ${document.body.textContent}`);
};
const button = (text: string) => Array.from(document.querySelectorAll<HTMLButtonElement>('button')).find((item) => item.textContent === text)!;
const click = async (element: HTMLElement) => { assert.ok(element); await act(async () => { element.click(); await settle(); }); };
const begin = async (name: string) => {
requestClock += 60_001;
let promise!: Promise<VaultHostSelectionResult | VaultHostActionResult>;
await act(async () => { promise = service.contributionService.executeCommand(`com.netcatty.host-actions.${name}`); void promise.catch(() => {}); await settle(); });
await waitFor(() => document.querySelector('[role="dialog"]') !== null);
return { promise };
};
const chooseGroup = async () => {
await click(document.querySelector<HTMLElement>('[role="option"] button[aria-pressed]')!);
await click(Array.from(document.querySelectorAll<HTMLButtonElement>('button')).find((item) => item.textContent?.startsWith('Continue ('))!);
};
try {
await service.manager.initialize();
const packageRoot = path.join(directory, 'source');
await cp(new URL('../examples/plugins/host-actions/', import.meta.url), packageRoot, { recursive: true });
const { buildPluginPackage } = await import('@netcatty/plugin-cli');
const archive = path.join(directory, 'fixture.ncpkg');
await buildPluginPackage(packageRoot, archive);
await service.manager.install(archive, { enable: true });
service.interactionService.setReady(sender, true);
await act(async () => root.render(<I18nProvider locale="en"><PluginHostActionHost /></I18nProvider>));
let selectedTarget: VaultHostRef;
await t.test('group selection shares only explicit current members and grants no connection', async () => {
const run = await begin('select');
await click(document.querySelector<HTMLElement>('[role="option"] button[aria-pressed]')!);
hosts = [...hosts, sanitizeHost({ ...second, id: 'synthetic-future', hostname: 'future.invalid' })];
persisted = hosts.map((host) => structuredClone(host));
await click(Array.from(document.querySelectorAll<HTMLButtonElement>('button')).find((item) => item.textContent?.startsWith('Continue ('))!);
const result = await run.promise;
assert.ok('hosts' in result);
assert.equal(result.hosts.length, 2); assert.equal(result.groups[0].members.length, 2);
assert.equal(result.hosts.some((host) => host.metadata.hostname === 'future.invalid'), false);
assert.equal(result.hosts[0].target.scope, 'metadata');
selectedTarget = result.hosts[0].target;
for (const text of ['synthetic-password', 'synthetic-script', 'resourceId', 'private-session-id']) assert.equal(JSON.stringify(result).includes(text), false);
assert.equal(connections.length, 0); assert.equal(saves, 0);
});
await t.test('registered boundaries reject AI text, caller owners, tampered targets and stale expected revisions', async () => {
const identity = service.runtimeSupervisor.getRuntimeIdentity('com.netcatty.host-actions');
const routes = service.rpcRegistry.createRoutes({ ...identity, manifest: service.database.getActivePlugin(identity.pluginId).manifest });
const transport = { signal: new AbortController().signal };
for (const target of [
{ handle: 'text-only', version: 'v1', scope: 'host' },
{ ...selectedTarget, handle: 'synthetic-tampered-selection-handle-0000' },
]) await assert.rejects(routes.requestHandlers['vault.requestConnect']({ target, expectedRevision: selectedTarget.revision }, transport), /invalid|expired|changed/);
await assert.rejects(routes.requestHandlers['vault.requestConnect']({ target: selectedTarget, expectedRevision: 'stale' }, transport), /expired|changed/);
await assert.rejects(routes.requestHandlers['vault.requestHostDraft']({ privateOwner: 'spoof', draft: { hostname: 'synthetic.invalid' } }, transport), /invalid/);
assert.equal(service.interactionService.pending.size, 0); assert.equal(connections.length, 0);
});
await t.test('cancel creates no draft or connection', async () => {
const run = await begin('create'); await click(button('Cancel'));
assert.deepEqual(await run.promise, { cancelled: true }); assert.equal(saves, 0); assert.equal(connections.length, 0);
});
await t.test('reviewed create saves only the synthetic SSH draft', async () => {
const run = await begin('create');
assert.match(document.body.textContent ?? '', /Changes to save/);
await click(button('Save Host'));
assert.deepEqual(await run.promise, { cancelled: false, status: 'saved' });
assert.equal(saves, 1);
const saved = hosts.find((host) => host.hostname === 'synthetic.invalid')!;
assert.equal(saved.protocol, 'ssh'); assert.equal(saved.password, undefined); assert.equal(saved.startupCommand, undefined);
});
for (const change of ['remove', 'replace'] as const) await t.test(`${change} after selection prevents a stale edit`, async () => {
hosts = [first, second]; persisted = hosts.map((host) => structuredClone(host));
const run = await begin('edit'); await chooseGroup();
await waitFor(() => document.body.textContent?.includes('Changes to save') === true);
hosts = change === 'remove' ? [second] : [{ ...first }, second];
await click(button('Save Host'));
await assert.rejects(run.promise, /changed|cancelled|complete/);
assert.equal(saves, 1); assert.equal(connections.length, 0);
});
await t.test('connection confirmation discloses saved scripts before dispatch and returns no session', async () => {
hosts = [first, second]; persisted = hosts.map((host) => structuredClone(host));
const run = await begin('connect'); await chooseGroup();
await waitFor(() => document.body.textContent?.includes('Saved startup, connect or output scripts may run') === true);
assert.equal(connections.length, 0); assert.equal(document.body.textContent?.includes('synthetic-script'), false);
await click(button('Connect'));
assert.deepEqual(await run.promise, { cancelled: false, status: 'requested' });
assert.equal(connections.length, 1); assert.equal(connections[0], first);
});
await t.test('Vault lock cancels a pending connection', async () => {
const run = await begin('connect'); await chooseGroup();
await waitFor(() => button('Connect') !== undefined);
await act(async () => { locked = true; publish(); onLock?.({ locked: true }); await settle(); });
await assert.rejects(run.promise, /cancelled/); assert.equal(connections.length, 1);
locked = false; publish();
});
await t.test('revoke during asynchronous preparation prevents the write', async () => {
hosts = [first, second]; persisted = hosts.map((host) => structuredClone(host));
let release!: () => void; let preparing = false;
beforeCommit = async () => { preparing = true; await new Promise<void>((resolve) => { release = resolve; }); };
const run = await begin('edit'); await chooseGroup();
await waitFor(() => button('Save Host') !== undefined); await click(button('Save Host'));
await waitFor(() => preparing);
await act(async () => { service.permissionEngine.revokeApplication('com.netcatty.host-actions', 'vault.write'); release(); await settle(); });
await assert.rejects(run.promise, /revoked|cancelled/); assert.equal(saves, 1); beforeCommit = undefined;
});
await t.test('source replacement during review cannot use the old action authority', async () => {
const run = await begin('create');
const original = service.database.getRetainedPluginSource;
service.database.getRetainedPluginSource = () => ({ id: 'changed-source', kind: 'local', origin: null });
try { await click(button('Save Host')); await assert.rejects(run.promise, /source binding/); }
finally { service.database.getRetainedPluginSource = original; }
assert.equal(saves, 1); assert.equal(connections.length, 1);
});
await t.test('managed A to B stays in the Vault editor and cannot acquire file-write effects', async () => {
const candidate = sanitizeHost({ ...first, group: 'A', managedSourceId: 'synthetic-source-a' });
hosts = [candidate]; persisted = hosts.map((host) => structuredClone(host));
vault.managedSources = [
{ id: 'synthetic-source-a', type: 'ssh_config', filePath: '/synthetic/a.conf', groupName: 'A', lastSyncedAt: 0 },
{ id: 'synthetic-source-b', type: 'ssh_config', filePath: '/synthetic/b.conf', groupName: 'B', lastSyncedAt: 0 },
];
const run = await begin('edit'); await chooseGroup();
await waitFor(() => button('Save Host') !== undefined);
const group = Array.from(document.querySelectorAll('label')).find((label) => label.querySelector('span')?.textContent === 'Group')?.querySelector('input');
assert.ok(group);
const setValue = Object.getOwnPropertyDescriptor(dom.window.HTMLInputElement.prototype, 'value')!.set!;
await act(async () => { setValue.call(group, 'B'); group.dispatchEvent(new dom.window.Event('input', { bubbles: true })); await settle(); });
assert.match(document.querySelector('[role="dialog"]')?.textContent ?? '', /"A"\s*\u2192\s*"B"/u);
assert.match(document.querySelector('[role="alert"]')?.textContent ?? '', /Managed SSH config changes require the Vault host editor/);
assert.equal(button('Save Host').disabled, true);
await click(button('Save Host')); assert.equal(saves, 1);
await click(button('Cancel')); assert.deepEqual(await run.promise, { cancelled: true });
assert.equal(hosts[0].group, 'A'); assert.equal(hosts[0].managedSourceId, 'synthetic-source-a');
vault.managedSources = [];
});
await t.test('creating into a managed group is explicitly unavailable in the small draft UI', async () => {
vault.managedSources = [{ id: 'synthetic-source-b', type: 'ssh_config', filePath: '/synthetic/b.conf', groupName: 'Samples', lastSyncedAt: 0 }];
const run = await begin('create');
assert.match(document.querySelector('[role="alert"]')?.textContent ?? '', /Managed SSH config changes require the Vault host editor/);
assert.equal(button('Save Host').disabled, true);
await click(button('Cancel')); assert.deepEqual(await run.promise, { cancelled: true });
assert.equal(saves, 1); vault.managedSources = [];
});
for (const transport of [
{ name: 'Mosh', expected: 'mosh', overrides: { moshEnabled: true } },
{ name: 'ET', expected: 'et', overrides: { etEnabled: true } },
{ name: 'implicit localhost shell', expected: 'local', overrides: { hostname: 'localhost' } },
] as const) await t.test(`${transport.name} confirmation matches the real connection handler`, async () => {
const candidate = sanitizeHost({ ...second, id: `synthetic-${transport.expected}`, label: `Synthetic ${transport.expected}`, ...transport.overrides });
hosts = [candidate]; persisted = hosts.map((host) => structuredClone(host));
const dispatched: Host[] = [];
const logs: Array<Pick<ConnectionLog, 'protocol'>> = [];
registerAppHandlers({ handleConnectToHost: (host: Host) => handleConnectToHostImpl(() => ({
resolveEffectiveHost: (saved: Host) => applyGroupDefaults(saved, saved.group ? resolveGroupDefaults(saved.group, vault.groupConfigs) : {}),
discoveredShells: [], terminalSettings: {}, resolveShellSetting: () => null,
systemInfoRef: { current: { username: 'synthetic-local-user', hostname: 'synthetic-device' } },
keys: [], identities: [], resolveHostAuth: () => ({ username: 'synthetic-user' }),
connectToHost: (resolved: Host) => { dispatched.push(resolved); return 'synthetic-transport-session'; },
addConnectionLog: (entry: Pick<ConnectionLog, 'protocol'>) => logs.push(entry),
}), host) });
const run = await begin('connect');
try {
await chooseGroup(); await waitFor(() => button('Connect') !== undefined);
const confirmation = document.querySelector('[role="dialog"]')?.textContent ?? '';
assert.match(confirmation, new RegExp(`\\(${transport.expected}\\)`));
assert.equal(confirmation.includes('Local execution: this opens a shell on this device.'), transport.expected === 'local');
if (transport.expected === 'local') assert.doesNotMatch(confirmation, /test@localhost:22/u);
assert.equal(dispatched.length, 0);
await click(button('Connect'));
assert.deepEqual(await run.promise, { cancelled: false, status: 'requested' });
assert.equal(dispatched.length, 1);
assert.equal(logs[0].protocol, transport.expected);
} finally {
if (button('Cancel')) await click(button('Cancel'));
await run.promise.catch(() => {});
registerAppHandlers({ handleConnectToHost: (host: Host) => { connections.push(host); return 'private-session-id'; } });
}
});
await t.test('runtime removal rejects old signed replies', async () => {
const run = await begin('create');
const old = prompts.at(-1)!;
await act(async () => { await service.manager.setEnabled('com.netcatty.host-actions', false); await settle(); });
await assert.rejects(run.promise);
assert.equal(await service.interactionService.respond(sender, { requestId: old.requestId, actionToken: old.actionToken, action: 'submit', draft: { hostname: 'late.invalid' } }), false);
assert.equal(saves, 1); assert.equal(connections.length, 1);
});
} finally {
await act(async () => { root.unmount(); await settle(); });
await service.manager.shutdown();
runtime.registerAppVaultRuntime(null); runtime.registerAppAppLockRuntime(null); registerAppHandlers(null);
dom.window.close(); await rm(directory, { recursive: true, force: true });
for (const [key, descriptor] of previous) { if (descriptor) Object.defineProperty(globalThis, key, descriptor); else Reflect.deleteProperty(globalThis, key); }
}
});
@@ -1,38 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import React from 'react';
import { I18nProvider } from '../application/i18n/I18nProvider';
import { PluginHostDraftPreview } from './plugins/PluginHostDraftPreview';
import { createDomRenderer, dispatchDomEvent, installDomEnvironment } from './test-support/renderReactDom';
import type { Host } from '../domain/models';
import type { PluginHostDraftFields } from '../domain/pluginHostActions';
test('host-owned draft preview shows a field diff and submits only reviewed editable fields', async () => {
const dom = installDomEnvironment();
const renderer = await createDomRenderer(dom.document);
const host: Host = { id: 'synthetic', label: 'Before', hostname: 'host.invalid', username: 'test', tags: [], os: 'linux', password: 'synthetic-password', startupCommand: 'synthetic-script' };
const submitted: PluginHostDraftFields[] = [];
try {
await renderer.render(<I18nProvider locale="en"><PluginHostDraftPreview before={host} draft={{ label: 'After' }} busy={false} onConfirm={(draft) => submitted.push(draft)} /></I18nProvider>);
assert.equal(dom.document.querySelectorAll('input').length, 3);
assert.match(renderer.container.textContent ?? '', /Before.*After/u);
assert.equal(renderer.container.innerHTML.includes('synthetic-password'), false);
assert.equal(renderer.container.innerHTML.includes('synthetic-script'), false);
const save = dom.document.querySelector('button');
assert.ok(save);
await dispatchDomEvent(save, new dom.window.MouseEvent('click', { bubbles: true }));
assert.deepEqual(submitted, [{ label: 'After', group: '', tags: [] }]);
} finally { await renderer.unmount(); dom.cleanup(); }
});
test('invalid create draft cannot be confirmed', async () => {
const dom = installDomEnvironment();
const renderer = await createDomRenderer(dom.document);
try {
await renderer.render(<I18nProvider locale="en"><PluginHostDraftPreview draft={{ hostname: 'invalid host' }} busy={false} onConfirm={() => { throw new Error('Invalid draft was submitted'); }} /></I18nProvider>);
assert.ok(dom.document.querySelector('[role="alert"]'));
const save = dom.document.querySelector('button');
assert.ok(save?.disabled);
await dispatchDomEvent(save, new dom.window.MouseEvent('click', { bubbles: true }));
} finally { await renderer.unmount(); dom.cleanup(); }
});
+1 -3
View File
@@ -50,7 +50,6 @@ export interface SelectHostPanelContentProps {
/** Preferred multi-select path for host/group toggles (selection resolves to host ids). */
onSelectionChange?: (selectedHostIds: string[]) => void;
onConfirm: () => void;
confirmDisabled?: boolean;
onNewHost?: () => void;
availableKeys?: SSHKey[];
identities?: import('../domain/models').Identity[];
@@ -80,7 +79,6 @@ export const SelectHostPanelContent: React.FC<SelectHostPanelContentProps> = ({
onSelect,
onSelectionChange,
onConfirm,
confirmDisabled = false,
onNewHost,
availableKeys = [],
identities = [],
@@ -645,7 +643,7 @@ export const SelectHostPanelContent: React.FC<SelectHostPanelContentProps> = ({
<div className="px-4 py-3 border-t border-border/60 shrink-0">
<Button
className="w-full"
disabled={confirmDisabled || selectedHostIds.length === 0}
disabled={selectedHostIds.length === 0}
onClick={onConfirm}
>
{multiSelect
@@ -1,45 +0,0 @@
import { useI18n } from '../../application/i18n/I18nProvider';
import { usePluginHostActions } from '../../application/state/usePluginHostActions';
import { applyGroupDefaults, resolveGroupDefaults } from '../../domain/groupConfig';
import { resolveEffectiveTerminalProtocol } from '../../domain/terminalProtocol';
import { SelectHostPanelContent } from '../SelectHostPanelContent';
import { Button } from '../ui/button';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '../ui/dialog';
import { PluginHostDraftPreview } from './PluginHostDraftPreview';
export function PluginHostActionHost() {
const { t } = useI18n();
const state = usePluginHostActions();
const entry = state.review;
if (!entry) return null;
const request = entry.prompt.request;
const selection = request.kind === 'vault.selectHosts';
const connection = request.kind === 'vault.requestConnect';
const effective = entry.host ? applyGroupDefaults(entry.host, entry.host.group ? resolveGroupDefaults(entry.host.group, entry.groups) : {}) : undefined;
const protocol = effective ? resolveEffectiveTerminalProtocol(effective) : undefined;
return <Dialog open onOpenChange={(open) => { if (!open) void state.respond('cancel'); }}>
<DialogContent className={selection ? 'max-w-md h-[min(85vh,640px)] flex flex-col p-0 gap-0 overflow-hidden' : 'sm:max-w-[560px] max-h-[85vh] overflow-y-auto'} hideCloseButton>
<DialogHeader className={selection ? 'px-4 pt-4 pb-2 shrink-0 border-b border-border/60' : undefined}>
<DialogTitle>{t(selection ? 'plugins.hostActions.select' : connection ? 'plugins.hostActions.connect' : entry.host ? 'plugins.hostActions.edit' : 'plugins.hostActions.create')}</DialogTitle>
<DialogDescription className="break-all">{entry.prompt.pluginId} · {entry.prompt.pluginVersion}</DialogDescription>
</DialogHeader>
{selection ? <>
<p className="px-4 pt-3 text-xs text-muted-foreground">{t('plugins.hostActions.selectionNote')}</p>
<p className="px-4 pt-2 text-sm font-medium break-words">{request.title}</p>
{state.selectedIds.length > 50 && <p role="alert" className="px-4 text-sm text-destructive">{t('plugins.hostActions.selectionLimit')}</p>}
<SelectHostPanelContent hosts={entry.candidates} customGroups={entry.customGroups} selectedHostIds={state.selectedIds} multiSelect className="flex-1 min-h-0" confirmDisabled={state.busy || state.selectedIds.length > 50} onSelect={(host) => state.setSelectedIds((ids) => ids.includes(host.id) ? ids.filter((id) => id !== host.id) : [...ids, host.id])} onSelectionChange={state.setSelectedIds} onConfirm={() => { if (!state.busy && state.selectedIds.length <= 50) void state.respond('submit'); }} />
</> : connection && effective ? <>
<p className="break-all text-sm font-medium">{effective.label}</p>
<p className="break-all text-sm">{protocol !== 'local' && effective.username ? `${effective.username}@` : ''}{effective.hostname}{protocol !== 'local' && effective.port ? `:${effective.port}` : ''} ({protocol})</p>
{protocol === 'local' && <p className="text-sm font-medium" role="note">{t('plugins.hostActions.localExecution')}</p>}
<p className="text-sm" role="note">{t(entry.scriptsRun ? 'plugins.hostActions.scriptsRun' : 'plugins.hostActions.noScripts')}</p>
<p className="text-xs text-muted-foreground">{t('plugins.hostActions.connectionNote')}</p>
</> : request.kind === 'vault.requestHostDraft' ? <PluginHostDraftPreview key={entry.prompt.requestId} before={entry.host} draft={request.draft} managedSources={entry.managedSources} busy={state.busy} onConfirm={(draft) => void state.respond('submit', draft)} /> : null}
{state.error && <p role="alert" className="text-sm text-destructive">{t('plugins.hostActions.failed')}</p>}
<DialogFooter className={selection ? 'p-3 border-t' : undefined}>
<Button variant="outline" disabled={state.busy} onClick={() => void state.respond('cancel')}>{t('common.cancel')}</Button>
{connection && <Button disabled={state.busy} onClick={() => void state.respond('submit')}>{t('common.connect')}</Button>}
</DialogFooter>
</DialogContent>
</Dialog>;
}
@@ -1,58 +0,0 @@
import { useMemo, useState } from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { applyPluginHostEdit, normalizePluginHostDraft, pluginHostDraftTouchesManagedSource, pluginHostFieldDiff, PLUGIN_HOST_CREATE_FIELDS, PLUGIN_HOST_EDIT_FIELDS, type PluginHostDraftFields } from '../../domain/pluginHostActions';
import type { Host, ManagedSource } from '../../domain/models';
import { Button } from '../ui/button';
import { Input } from '../ui/input';
/** Host-owned editable draft and exact normalized field diff. The containing
* dialog owns confirmation, expiry and cancellation; this body has no IPC. */
export function PluginHostDraftPreview({ before, draft, busy, onConfirm, managedSources = [] }: {
before?: Host;
draft: PluginHostDraftFields;
busy: boolean;
onConfirm: (draft: PluginHostDraftFields) => void;
managedSources?: ManagedSource[];
}) {
const { t } = useI18n();
const [fields, setFields] = useState<Record<string, string>>(() => ({
label: draft.label ?? before?.label ?? '',
hostname: draft.hostname ?? '', username: draft.username ?? '',
port: String(draft.port ?? 22), group: draft.group ?? before?.group ?? '',
tags: (draft.tags ?? before?.tags ?? []).join(', '),
}));
const preview = useMemo(() => {
const value = {
label: fields.label, group: fields.group,
tags: fields.tags.split(',').map((tag) => tag.trim()).filter(Boolean),
...(!before ? { hostname: fields.hostname, username: fields.username, port: Number(fields.port) } : {}),
};
try {
const after = before ? applyPluginHostEdit(before, value) : normalizePluginHostDraft(value, 'preview');
return { value: {
label: after.label, group: after.group ?? '', tags: after.tags,
...(!before ? { hostname: after.hostname, username: after.username, port: after.port } : {}),
}, diff: pluginHostFieldDiff(before, after), managed: pluginHostDraftTouchesManagedSource(after, managedSources) };
} catch { return null; }
}, [before, fields, managedSources]);
return <div className="space-y-4">
<div className="grid grid-cols-2 gap-3">
{(before ? PLUGIN_HOST_EDIT_FIELDS : PLUGIN_HOST_CREATE_FIELDS).map((field) => <label key={field} className="space-y-1 text-sm">
<span>{t(`hostForm.field.${field}`)}</span>
<Input value={fields[field]} type={field === 'port' ? 'number' : 'text'} min={field === 'port' ? 1 : undefined} max={field === 'port' ? 65535 : undefined} maxLength={field === 'tags' ? 32_896 : 1024} autoComplete="off" disabled={busy} onChange={(event) => setFields((previous) => ({ ...previous, [field]: event.target.value }))} />
</label>)}
</div>
<p className="text-xs text-muted-foreground">{t('plugins.hostActions.defaultsWarning')}</p>
{preview?.managed && <p role="alert" className="text-sm text-destructive">{t('plugins.hostActions.managedUnsupported')}</p>}
{preview ? <div className="max-h-48 overflow-auto rounded-md border p-3">
<p className="mb-2 text-sm font-medium">{t('plugins.hostActions.fieldDiff')}</p>
<dl className="space-y-2 text-xs">
{preview.diff.map((change) => <div key={change.field} className="break-all">
<dt className="font-medium">{t(`hostForm.field.${change.field}`)}</dt>
<dd>{JSON.stringify(change.before)} &rarr; {JSON.stringify(change.after)}</dd>
</div>)}
</dl>
</div> : <p role="alert" className="text-sm text-destructive">{t('plugins.hostActions.invalidDraft')}</p>}
<Button className="w-full" disabled={busy || !preview || preview.managed || preview.diff.length === 0} onClick={() => { if (preview && !preview.managed) onConfirm(preview.value); }}>{t('hostForm.saveHost')}</Button>
</div>;
}
@@ -52,7 +52,7 @@ test('information previews on hover/focus, pins on click and Escape returns focu
const entry = { id: 'com.example.guide', name: 'Guide', author: 'Example', version: '1.0.0', license: 'MIT', repository: 'example/plugin', commit: 'a'.repeat(40), description: 'Synthetic metadata',
manifest: { id: 'com.example.guide', name: 'Guide', publisher: 'Example', main: { browser: 'index.js' }, engines: { netcatty: '*', api: '0.1.0-internal' } },
} as NetcattyPluginCatalogEntry;
const catalog = { entries: [entry], source: 'registry', sourceBinding: null, listStatus: 'ready', listError: null, readme: '# Guide', readmeStatus: 'ready', readmeError: null, error: null, busy: false, retryList() {}, retryReadme() {}, async install() {} } as ReturnType<typeof import('../../../application/state/usePluginCatalog').usePluginCatalog>;
const catalog = { entries: [entry], source: 'registry', listStatus: 'ready', listError: null, readme: '# Guide', readmeStatus: 'ready', readmeError: null, error: null, busy: false, retryList() {}, retryReadme() {}, async install() {} } as ReturnType<typeof import('../../../application/state/usePluginCatalog').usePluginCatalog>;
function Browser() {
const [selected, setSelected] = useState<string | null>(null);
const [toolbar, setToolbar] = useState<HTMLDivElement | null>(null);
@@ -104,28 +104,8 @@ test('information previews on hover/focus, pins on click and Escape returns focu
await act(async () => { healthy.click(); await settle(); });
assert.match(document.querySelector('[role="dialog"]')?.textContent ?? '', /Updates are manual/);
await act(async () => { document.querySelector('[role="dialog"]')!.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true })); await settle(); });
const thirdParty: NetcattyPluginSource = { id: 'third-party:synthetic', kind: 'third-party', name: 'Independent source', origin: 'https://example.com' };
await act(async () => { root.render(<I18nProvider locale="en"><PluginMarketplaceContent marketSource={thirdParty} catalog={{ ...catalog, entries: [], listError: 'ECONNREFUSED', source: 'cached' }} installed={[]} selected={null} onSelect={() => {}} /></I18nProvider>); await settle(); });
const thirdStatus = document.querySelector<HTMLButtonElement>('button[aria-label$="Notices: 1"]')!;
await act(async () => { thirdStatus.focus(); await settle(); });
const thirdPreview = document.querySelector('[role="tooltip"]')?.textContent ?? '';
assert.match(thirdPreview, /provided by the selected third party/);
assert.match(thirdPreview, /If a previously validated catalog exists for this source/);
assert.doesNotMatch(thirdPreview, /bundled examples|Curated open-source|First-party/);
assert.equal(document.querySelector('[role="tooltip"] button'), null);
await act(async () => { thirdStatus.click(); await settle(); });
assert.match(document.querySelector('[role="dialog"]')?.textContent ?? '', /ECONNREFUSED/);
assert.match(document.querySelector('[role="dialog"]')?.textContent ?? '', /Retry/);
assert.doesNotMatch(document.querySelector('[role="dialog"]')?.textContent ?? '', /bundled examples|Curated open-source/);
await act(async () => { document.querySelector('[role="dialog"]')!.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true })); await settle(); });
await act(async () => { root.render(<I18nProvider locale="en"><PluginMarketplaceContent marketSource={thirdParty} catalog={catalog} installed={[]} selected={entry.id} onSelect={() => {}} /></I18nProvider>); await settle(); });
const sourceInfo = document.querySelector<HTMLButtonElement>('button[aria-label="Source example"]')!;
await act(async () => { sourceInfo.click(); await settle(); });
assert.match(document.querySelector('[role="dialog"]')?.textContent ?? '', /Source-only entry from this provider/);
assert.doesNotMatch(document.querySelector('[role="dialog"]')?.textContent ?? '', /First-party/);
await act(async () => { document.querySelector('[role="dialog"]')!.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true })); await settle(); });
} finally {
await act(async () => { root.unmount(); await settle(); });
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,66 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { JSDOM } from 'jsdom';
test('local logging controls default off, disclose verbose risk, and call host-only management actions', 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', 'Event', 'MouseEvent', 'MutationObserver', 'NodeFilter', 'DocumentFragment', 'CustomEvent', 'getComputedStyle']) {
install(key, key === 'window' ? dom.window : dom.window[key as keyof typeof dom.window]);
}
install('IS_REACT_ACT_ENVIRONMENT', true);
const { default: React, act } = await import('react');
const { createRoot } = await import('react-dom/client');
const { I18nProvider } = await import('../../../application/i18n/I18nProvider');
const { PluginLogSettings } = await import('./PluginLogSettings');
const root = createRoot(document.getElementById('root')!);
let status = { saveToDisk: false, verbose: false, droppedRecords: 2, writeErrors: 0, pendingBytes: 0, maxBytes: 3145728, retentionDays: 7 };
const settings: Array<{ saveToDisk: boolean; verbose: boolean }> = [];
const actions: string[] = [];
let failConfigure = false;
Object.assign(dom.window, { netcatty: {
async getPluginLogStatus() { return status; },
async configurePluginLogs(value: { saveToDisk: boolean; verbose: boolean }) { settings.push(value); status = { ...status, ...value }; if (failConfigure) throw new Error('synthetic preferences write failed'); return status; },
async openPluginLogFolder() { actions.push('open'); return status; },
async exportPluginLogs() { actions.push('export'); return status; },
async clearPluginLogs() { actions.push('clear-confirmed-by-native-host'); return status; },
} });
const button = (label: string) => [...document.querySelectorAll<HTMLButtonElement>('button')].find((item) => item.textContent === label)!;
const checkboxes = () => [...document.querySelectorAll<HTMLInputElement>('input[type="checkbox"]')];
try {
await act(async () => root.render(<React.StrictMode><I18nProvider locale="en"><PluginLogSettings /></I18nProvider></React.StrictMode>));
assert.equal(checkboxes()[0].checked, false);
assert.equal(checkboxes()[0].disabled, false, 'StrictMode initialization completes');
assert.equal(checkboxes()[1].disabled, true);
assert.match(document.body.textContent!, /no upload or sync/);
assert.match(document.body.textContent!, /Dropped: 2/);
await act(async () => checkboxes()[0].click());
assert.deepEqual(settings, [{ saveToDisk: true, verbose: false }]);
await act(async () => checkboxes()[1].click());
assert.equal(settings.length, 1, 'opening disclosure does not enable verbose logs');
assert.match(document.body.textContent!, /cannot make arbitrary text safe/);
await act(async () => button('Enable temporarily').click());
assert.deepEqual(settings[1], { saveToDisk: true, verbose: true });
await act(async () => button('Open folder').click());
await act(async () => button('Export').click());
await act(async () => button('Clear logs…').click());
assert.deepEqual(actions, ['open', 'export', 'clear-confirmed-by-native-host']);
failConfigure = true;
await act(async () => checkboxes()[0].click());
assert.deepEqual(settings.at(-1), { saveToDisk: false, verbose: false });
assert.equal(checkboxes()[0].checked, false, 'failed configuration refreshes actual OFF host state');
assert.equal(checkboxes()[1].checked, false);
assert.match(document.querySelector('[role=alert]')!.textContent!, /synthetic preferences write failed/);
} finally {
await act(async () => root.unmount());
dom.window.close();
for (const [key, descriptor] of previous) {
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
else Reflect.deleteProperty(globalThis, key);
}
}
});
@@ -0,0 +1,31 @@
import React, { useState } from 'react';
import { usePluginLogs } from '../../../application/state/usePluginLogs';
import { useI18n } from '../../../application/i18n/I18nProvider';
import { Button } from '../../ui/button';
import { ConfirmDialog } from '../../ui/confirm-dialog';
/** Embed in the existing plugin settings surface, without a new marketplace tab. */
export function PluginLogSettings() {
const { resolvedLocale } = useI18n();
const zh = resolvedLocale.startsWith('zh');
const { status, error, busy, run } = usePluginLogs();
const [confirmVerbose, setConfirmVerbose] = useState(false);
const disclosure = zh
? '插件编写的日志文字可能包含敏感数据。截断和字段过滤不能保证任意文字安全。只为排查问题临时开启,导出前请检查。重启应用后自动关闭详细日志。'
: 'Plugin-authored text may contain sensitive data. Truncation and field filtering cannot make arbitrary text safe. Enable temporarily for troubleshooting and review files before sharing. Verbose diagnostics turn off after app restart.';
return <section data-host-security-surface="" className="space-y-3 rounded-lg border border-border/70 p-4 text-sm" aria-label={zh ? '本地插件日志' : 'Local plugin logs'}>
<h4 className="font-medium">{zh ? '本地插件日志' : 'Local plugin logs'}</h4>
<p className="text-xs text-muted-foreground">{zh ? '默认不保存。仅保存在此设备,不上传或同步。权限面板中的有限安全历史独立保留。' : 'Saving is off by default. Files stay on this device with no upload or sync. The bounded security history in plugin permissions is separate.'}</p>
{error && <p role="alert" className="text-xs text-destructive">{error}</p>}
<label className="flex items-center gap-2"><input type="checkbox" checked={status?.saveToDisk ?? false} disabled={busy || !status} onChange={(event) => void run('configure', { saveToDisk: event.target.checked, verbose: false })} />{zh ? '将宿主审计日志保存到本地' : 'Save host audit logs locally'}</label>
<label className="flex items-center gap-2"><input type="checkbox" checked={status?.verbose ?? false} disabled={busy || !status?.saveToDisk} onChange={(event) => event.target.checked ? setConfirmVerbose(true) : void run('configure', { saveToDisk: true, verbose: false })} />{zh ? '包含插件详细诊断文字(可能含敏感数据)' : 'Include verbose plugin text (may contain sensitive data)'}</label>
{status && <p className="text-xs text-muted-foreground">{zh ? `最多 ${status.maxBytes / 1048576} MiB,最长保留 ${status.retentionDays} 天。丢弃 ${status.droppedRecords} 条,写入错误 ${status.writeErrors} 次。` : `Up to ${status.maxBytes / 1048576} MiB, up to ${status.retentionDays} days. Dropped: ${status.droppedRecords}. Write errors: ${status.writeErrors}.`}</p>}
<div className="flex flex-wrap gap-2">
<Button variant="outline" size="sm" disabled={busy || !status} onClick={() => void run('open')}>{zh ? '打开文件夹' : 'Open folder'}</Button>
<Button variant="outline" size="sm" disabled={busy || !status} onClick={() => void run('export')}>{zh ? '导出' : 'Export'}</Button>
<Button variant="ghost" size="sm" disabled={busy || !status} onClick={() => void run('clear')}>{zh ? '清除日志…' : 'Clear logs…'}</Button>
<Button variant="ghost" size="sm" disabled={busy} onClick={() => void run('status')}>{zh ? '刷新' : 'Refresh'}</Button>
</div>
<ConfirmDialog open={confirmVerbose} title={zh ? '开启插件详细诊断?' : 'Enable verbose plugin diagnostics?'} message={disclosure} confirmLabel={zh ? '临时开启' : 'Enable temporarily'} onOpenChange={setConfirmVerbose} onConfirm={() => { setConfirmVerbose(false); void run('configure', { saveToDisk: true, verbose: true }); }} />
</section>;
}
@@ -28,7 +28,7 @@ const entries = ['Alpha', 'Beta'].map((name) => ({
manifest: { manifestVersion: 1, id: `com.example.${name.toLowerCase()}`, name, version: '1.0.0', publisher: 'Example', engines: { netcatty: '>=1.0.0', api: '0.1.0-internal' }, main: { browser: 'index.js' }, permissions: { required: ['settings.read'] } },
})) as NetcattyPluginCatalogEntry[];
const catalog = {
entries, source: 'registry', sourceBinding: null, listStatus: 'ready', listError: null, readme: '# Author documentation', readmeStatus: 'ready', readmeError: null,
entries, source: 'registry', listStatus: 'ready', listError: null, readme: '# Author documentation', readmeStatus: 'ready', readmeError: null,
error: null, busy: false, retryList() {}, retryReadme() {}, async install() {},
} as ReturnType<typeof usePluginCatalog>;
@@ -42,7 +42,7 @@ test('catalog keeps search during selection/back, switches detail, and displays
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, source: { id: 'official', kind: 'official', name: 'Official', origin: 'https://plugins.netcatty.app' }, runtime: { lastError: 'Incompatible with this host' } } as NetcattyInstalledPlugin]} /></I18nProvider>;
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 />); });
@@ -158,21 +158,7 @@ test('replacing an installed plugin discloses that its running version will stop
const downloadable = { ...entries[0], artifact: { url: 'https://github.com/example/plugin/releases/download/v1/plugin.ncpkg', sha256: 'a'.repeat(64) } };
const html = renderToStaticMarkup(<I18nProvider locale="en"><PluginMarketplaceContent
catalog={{ ...catalog, entries: [downloadable] }} selected={downloadable.id} onSelect={() => {}}
installed={[{ id: downloadable.id, activeVersion: '0.9.0', enabled: true, source: { id: 'official', kind: 'official', name: 'Official', origin: 'https://plugins.netcatty.app' } } as NetcattyInstalledPlugin]} /></I18nProvider>);
installed={[{ id: downloadable.id, activeVersion: '0.9.0', enabled: true } as NetcattyInstalledPlugin]} /></I18nProvider>);
assert.match(html, /<p[^>]*>This replaces and stops the installed version\./);
assert.ok(html.indexOf('This replaces and stops') < html.indexOf('Install this version (disabled)'));
});
test('a mirror cannot claim another source installation or offer cross-source replacement', () => {
const mirror = { id: 'third-party:mirror', kind: 'third-party' as const, name: 'Netcatty', origin: 'https://mirror.example.com' };
const existing = { id: entries[0].id, activeVersion: '1.0.0', enabled: true, source: { id: 'official', kind: 'official', name: 'Official', origin: 'https://plugins.netcatty.app' } } as NetcattyInstalledPlugin;
const list = renderToStaticMarkup(<I18nProvider locale="en"><PluginMarketplaceContent catalog={catalog}
marketSource={mirror} selected={null} onSelect={() => {}} installed={[existing]} /></I18nProvider>);
assert.doesNotMatch(list, />Installed</);
const downloadable = { ...entries[0], artifact: { url: `${mirror.origin}/assets/${'a'.repeat(64)}.ncpkg`, sha256: 'a'.repeat(64) } };
const detail = renderToStaticMarkup(<I18nProvider locale="en"><PluginMarketplaceContent catalog={{ ...catalog, entries: [downloadable] }}
marketSource={mirror} selected={downloadable.id} onSelect={() => {}} installed={[existing]} /></I18nProvider>);
assert.match(detail, /installed from another source/);
assert.match(detail, /https:\/\/plugins.netcatty.app/);
assert.match(detail, /disabled=""[^>]*>Install this version/);
});
+8 -24
View File
@@ -1,6 +1,5 @@
import { createPortal } from 'react-dom';
import { PluginStatus } from './PluginStatus';
import { Dialog, DialogContent, DialogDescription, DialogTitle } from '../../ui/dialog';
import { PluginInfo } from './PluginInfo';
import React, { useRef, useState } from 'react';
import { PluginBrandIcon } from '../../plugins/PluginBrandIcon';
@@ -12,15 +11,14 @@ import { Button } from '../../ui/button';
import { PluginPackageDetails } from './PluginPackageManager';
import { PluginReadme } from './PluginReadme';
export function PluginMarketplace({ installed = [], toolbarContainer, noVault, source }: { source?: NetcattyPluginSource; noVault?: boolean; installed?: NetcattyInstalledPlugin[]; toolbarContainer?: HTMLElement | null }) {
export function PluginMarketplace({ installed = [], toolbarContainer, noVault }: { noVault?: boolean; installed?: NetcattyInstalledPlugin[]; toolbarContainer?: HTMLElement | null }) {
const [selected, setSelected] = useState<string | null>(null);
const catalog = usePluginCatalog(selected, source?.id);
return <PluginMarketplaceContent catalog={catalog} marketSource={source} installed={installed} selected={selected} onSelect={setSelected} toolbarContainer={toolbarContainer} noVault={noVault} />;
const catalog = usePluginCatalog(selected);
return <PluginMarketplaceContent catalog={catalog} installed={installed} selected={selected} onSelect={setSelected} toolbarContainer={toolbarContainer} noVault={noVault} />;
}
// The browser owns only presentation state; installation and README policy stay in the existing hook.
export function PluginMarketplaceContent({ catalog, installed, selected, onSelect, toolbarContainer, noVault, marketSource }: {
marketSource?: NetcattyPluginSource;
export function PluginMarketplaceContent({ catalog, installed, selected, onSelect, toolbarContainer, noVault }: {
noVault?: boolean;
toolbarContainer?: HTMLElement | null;
catalog: ReturnType<typeof usePluginCatalog>;
@@ -30,19 +28,13 @@ export function PluginMarketplaceContent({ catalog, installed, selected, onSelec
}) {
const { t } = useI18n();
const [search, setSearch] = useState('');
const [confirmInstall, setConfirmInstall] = useState(false);
const [clearPreviousSource, setClearPreviousSource] = useState(false);
const listRef = useRef<HTMLDivElement>(null);
const detailRef = useRef<HTMLHeadingElement>(null);
const installButtonRef = useRef<HTMLButtonElement>(null);
const lastSelection = useRef<string | null>(null);
const pageScroll = useRef(0);
const entry = catalog.entries.find((item) => item.id === selected);
const filtered = filterPluginCatalog(catalog.entries, search);
const sourceId = marketSource?.id ?? 'official';
const sameSource = (plugin: NetcattyInstalledPlugin) => (plugin.source?.id ?? 'local') === sourceId;
const installedEntry = installed.find((item) => item.id === entry?.id && sameSource(item));
const conflictingInstall = installed.find((item) => item.id === entry?.id && !sameSource(item));
const installedEntry = installed.find((item) => item.id === entry?.id);
const choose = (id: string) => {
const pane = listRef.current?.closest<HTMLElement>('[data-settings-scroll-pane]');
pageScroll.current = pane?.scrollTop ?? 0;
@@ -67,7 +59,7 @@ export function PluginMarketplaceContent({ catalog, installed, selected, onSelec
};
const refresh = !entry && <Button variant="outline" size="sm" disabled={catalog.listStatus === 'loading'} onClick={catalog.retryList}>{t('common.refresh')}</Button>;
return <section className="plugin-marketplace space-y-4" aria-label={t('settings.plugins.market.title')}>
{toolbarContainer ? createPortal(<><PluginStatus sourceKind={marketSource?.kind} noVault={noVault} offlineError={catalog.listError} retry={catalog.retryList} />{refresh}</>, toolbarContainer) : <div className="flex items-center gap-2"><PluginStatus sourceKind={marketSource?.kind} noVault={noVault} offlineError={catalog.listError} retry={catalog.retryList} />{refresh}</div>}
{toolbarContainer ? createPortal(<><PluginStatus noVault={noVault} offlineError={catalog.listError} retry={catalog.retryList} />{refresh}</>, toolbarContainer) : <div className="flex items-center gap-2"><PluginStatus noVault={noVault} offlineError={catalog.listError} retry={catalog.retryList} />{refresh}</div>}
{catalog.error && <p role="alert" className="text-xs text-destructive">{catalog.error}</p>}
<div className="plugin-marketplace-browser">
{!entry && <div className="plugin-marketplace-catalog space-y-3" ref={listRef}>
@@ -78,7 +70,7 @@ export function PluginMarketplaceContent({ catalog, installed, selected, onSelec
{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 && sameSource(plugin));
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="plugin-card min-w-0 flex min-h-[200px] flex-col gap-3 rounded-lg bg-card p-4 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
<div className="flex items-center gap-3"><span className="flex h-8 w-8 shrink-0 items-center justify-center"><PluginBrandIcon entry={item} size={32} className="text-muted-foreground" /></span><span className="min-w-0 break-words text-sm font-semibold leading-5">{item.name}</span></div>
@@ -97,8 +89,7 @@ export function PluginMarketplaceContent({ catalog, installed, selected, onSelec
{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>
{conflictingInstall && <p role="status" className="text-xs text-muted-foreground">{t('settings.plugins.sources.conflict')} {conflictingInstall.source?.origin ?? t('settings.plugins.sources.local')}</p>}
{entry.artifact ? <div className="space-y-2"><p className="text-xs text-muted-foreground">{t('settings.plugins.market.installNote')}</p>{catalog.listError && <p role="status" className="text-xs text-muted-foreground">{t('settings.plugins.market.offlineShort')}</p>}<Button ref={installButtonRef} size="sm" disabled={catalog.busy || Boolean(catalog.listError) || Boolean(conflictingInstall)} onClick={() => { if (marketSource?.kind === 'third-party' || (catalog.sourceBinding && catalog.sourceBinding.id !== (marketSource?.id ?? 'official'))) { setClearPreviousSource(false); setConfirmInstall(true); } else void catalog.install(entry.id); }}>{t('settings.plugins.market.install')}</Button></div> : <div className="flex items-center gap-1 text-xs text-muted-foreground"><span>{t('settings.plugins.market.sourceBadge')}</span><PluginInfo label={t('settings.plugins.market.sourceBadge')}>{t(marketSource?.kind === 'third-party' ? 'settings.plugins.sources.sourceOnly' : 'settings.plugins.market.sourceOnly')}</PluginInfo></div>}
{entry.artifact ? <div className="space-y-2"><p className="text-xs text-muted-foreground">{t('settings.plugins.market.installNote')}</p>{catalog.listError && <p role="status" className="text-xs text-muted-foreground">{t('settings.plugins.market.offlineShort')}</p>}<Button size="sm" disabled={catalog.busy || Boolean(catalog.listError)} onClick={() => void catalog.install(entry.id)}>{t('settings.plugins.market.install')}</Button></div> : <div className="flex items-center gap-1 text-xs text-muted-foreground"><span>{t('settings.plugins.market.sourceBadge')}</span><PluginInfo label={t('settings.plugins.market.sourceBadge')}>{t('settings.plugins.market.sourceOnly')}</PluginInfo></div>}
<section><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 } }} /></section>
<div className="min-w-0 border-t pt-4"><div className="flex items-center gap-1"><h4 className="text-sm font-semibold">README</h4><PluginInfo label={t('settings.plugins.market.readmeInfo')}>{t('settings.plugins.market.readmeNote')}</PluginInfo></div>{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>}
@@ -106,13 +97,6 @@ export function PluginMarketplaceContent({ catalog, installed, selected, onSelec
</div>
</article>}
</div>
<Dialog open={confirmInstall && Boolean(entry)} onOpenChange={setConfirmInstall}><DialogContent className="max-h-[85vh] overflow-y-auto" onCloseAutoFocus={(event) => { event.preventDefault(); installButtonRef.current?.focus(); }}>
<DialogTitle>{t('settings.plugins.sources.installConfirm')}</DialogTitle><DialogDescription>{t('settings.plugins.sources.risk')}</DialogDescription>
<p className="break-all text-sm font-medium">{marketSource?.origin}</p>
{entry && <PluginPackageDetails plugin={{ id: entry.id, activeVersion: entry.version, enabled: false, manifest: entry.manifest, source: marketSource, archiveSha256: entry.artifact?.sha256, runtime: { status: 'stopped', kind: null, lastError: null, quarantinedAt: null } }} />}
{catalog.sourceBinding && catalog.sourceBinding.id !== (marketSource?.id ?? 'official') && <label className="flex items-start gap-2 text-xs"><input type="checkbox" checked={clearPreviousSource} onChange={(event) => setClearPreviousSource(event.target.checked)} />{t('settings.plugins.sources.clearPrivate')}<span className="break-all">{catalog.sourceBinding.origin ?? t('settings.plugins.sources.local')}</span></label>}
<Button disabled={catalog.busy || Boolean(catalog.listError) || Boolean(catalog.sourceBinding && catalog.sourceBinding.id !== (marketSource?.id ?? 'official') && !clearPreviousSource)} onClick={() => { if (entry) void catalog.install(entry.id, marketSource?.id, clearPreviousSource).then(() => setConfirmInstall(false)); }}>{t('settings.plugins.market.install')}</Button>
</DialogContent></Dialog>
</section>;
}
@@ -53,7 +53,6 @@ export function PluginPackageManager({ manager, onSelectionChange }: { manager:
const { t, resolvedLocale } = useI18n();
const [selected, setSelected] = useState<string | null>(null);
const [search, setSearch] = useState('');
const [sourceFilter, setSourceFilter] = useState('');
const rootRef = useRef<HTMLElement>(null);
const scrollTop = useRef(0);
const lastSelection = useRef<string | null>(null);
@@ -73,13 +72,11 @@ export function PluginPackageManager({ manager, onSelectionChange }: { manager:
else Array.from(rootRef.current?.querySelectorAll<HTMLButtonElement>('[data-plugin-card]') ?? []).find((button) => button.dataset.pluginCard === lastSelection.current)?.focus({ preventScroll: true });
});
};
const visible = selectedPlugin ? [selectedPlugin] : manager.plugins.filter((plugin) => (!sourceFilter || (plugin.source?.id ?? 'local') === sourceFilter) && [nameOf(plugin), plugin.manifest?.publisher, plugin.manifest?.description, plugin.id].some((value) => JSON.stringify(value ?? '').toLocaleLowerCase().includes(search.trim().toLocaleLowerCase())));
const visible = selectedPlugin ? [selectedPlugin] : manager.plugins.filter((plugin) => [nameOf(plugin), plugin.manifest?.publisher, plugin.manifest?.description, plugin.id].some((value) => JSON.stringify(value ?? '').toLocaleLowerCase().includes(search.trim().toLocaleLowerCase())));
const [removing, setRemoving] = useState<string | null>(null);
const [clearSource, setClearSource] = useState(false);
if (!manager.available) return null;
return <section ref={rootRef} className="space-y-4" aria-label={t('settings.plugins.manager.installed')}>
{selectedPlugin && <Button variant="ghost" size="sm" onClick={() => choose(null)}><ArrowLeft size={14} className="mr-2" />{t('settings.plugins.manager.installed')}</Button>}
{!selectedPlugin && manager.plugins.length > 0 && <select aria-label={t('settings.plugins.sources.source')} value={sourceFilter} onChange={(event) => setSourceFilter(event.target.value)} className="h-9 rounded-md border border-input bg-background px-3 text-sm"><option value="">{t('settings.plugins.sources.all')}</option>{[...new Map(manager.plugins.map((plugin) => [plugin.source?.id ?? 'local', plugin.source])).entries()].map(([id, source]) => <option key={id} value={id}>{source?.origin ?? t('settings.plugins.sources.local')}</option>)}</select>}
{!selectedPlugin && manager.plugins.length > 0 && <Input value={search} onChange={(event) => setSearch(event.target.value)} placeholder={t('settings.plugins.market.search')} aria-label={t('settings.plugins.market.search')} />}
{!selectedPlugin && manager.plugins.length > 0 && visible.length === 0 && <p className="p-6 text-center text-sm text-muted-foreground">{t('settings.plugins.market.noResults')}</p>}
{manager.error && <p role="alert" className="text-sm text-destructive">{manager.error}</p>}
@@ -102,7 +99,6 @@ export function PluginPackageManager({ manager, onSelectionChange }: { manager:
{t(manager.statusKnown === false ? 'settings.plugins.permissions.statusUnknown' : plugin.enabled ? 'settings.plugins.manager.enabled' : 'settings.plugins.manager.disabled')}
</Button>
</div>
<p className="break-all text-xs text-muted-foreground">{t('settings.plugins.sources.source')}: {t(plugin.source?.kind === 'official' ? 'settings.plugins.sources.official' : plugin.source?.kind === 'third-party' ? 'settings.plugins.sources.thirdparty' : 'settings.plugins.sources.local')}{plugin.source?.origin ? ` · ${plugin.source.origin}` : ''}</p>
{plugin.manifest?.publisher && <p className="break-words text-xs text-muted-foreground">{plugin.manifest.publisher}</p>}
<p className={`break-words text-sm leading-5 text-muted-foreground ${selectedPlugin ? 'whitespace-pre-wrap' : 'line-clamp-3'}`}>{descriptionOf(plugin) || t('settings.plugins.manager.noDescription')}</p>
{!selectedPlugin ? <div className="mt-auto flex flex-wrap items-center justify-between gap-2 pt-1">
@@ -115,10 +111,9 @@ export function PluginPackageManager({ manager, onSelectionChange }: { manager:
<PluginPermissionPanel key={plugin.id} pluginId={plugin.id} onChanged={async () => { await manager.refresh(); }} />
{removing === plugin.id ? <div className="space-y-2 rounded-lg border border-destructive/30 p-3">
<p className="text-xs">{t('settings.plugins.manager.removeNote')}</p>
<label className="flex items-start gap-2 text-xs"><input type="checkbox" checked={clearSource} onChange={(event) => setClearSource(event.target.checked)} />{t('settings.plugins.sources.clearPrivate')}</label>
<Button variant="destructive" size="sm" disabled={manager.busy} onClick={() => { void manager.uninstall(plugin.id, clearSource); setRemoving(null); }}>{t('settings.plugins.manager.remove')}</Button>
<Button variant="destructive" size="sm" disabled={manager.busy} onClick={() => { void manager.uninstall(plugin.id); setRemoving(null); }}>{t('settings.plugins.manager.remove')}</Button>
<Button variant="ghost" size="sm" onClick={() => setRemoving(null)}>{t('common.cancel')}</Button>
</div> : <Button variant="ghost" size="sm" disabled={manager.busy} onClick={() => { setClearSource(false); setRemoving(plugin.id); }}>{t('settings.plugins.manager.remove')}</Button>}
</div> : <Button variant="ghost" size="sm" disabled={manager.busy} onClick={() => setRemoving(plugin.id)}>{t('settings.plugins.manager.remove')}</Button>}
</>}
</article>)}
</div>
+3 -3
View File
@@ -3,12 +3,12 @@ import { useI18n } from '../../../application/i18n/I18nProvider';
import { Button } from '../../ui/button';
import { PluginInfo } from './PluginInfo';
export function PluginStatus({ noVault, offlineError, retry, sourceKind }: { sourceKind?: NetcattyPluginSource['kind']; noVault?: boolean; offlineError?: string | null; retry?: () => void }) {
export function PluginStatus({ noVault, offlineError, retry }: { noVault?: boolean; offlineError?: string | null; retry?: () => void }) {
const { t } = useI18n();
const count = Number(Boolean(noVault)) + Number(Boolean(offlineError));
const explanation = <>{noVault && <p>{t('settings.plugins.manager.noVault')}</p>}{offlineError && <><p>{t(sourceKind === 'third-party' ? 'settings.plugins.sources.offline' : 'settings.plugins.market.offline')}</p><p className="break-words">{offlineError}</p></>}</>;
const explanation = <>{noVault && <p>{t('settings.plugins.manager.noVault')}</p>}{offlineError && <><p>{t('settings.plugins.market.offline')}</p><p className="break-words">{offlineError}</p></>}</>;
const label = count ? `${t('settings.plugins.info')} · ${t('settings.plugins.status', { count })}` : t('settings.plugins.info');
const help = <><p>{t('settings.plugins.manager.preview')}</p><p>{t(sourceKind === 'third-party' ? 'settings.plugins.sources.note' : 'settings.plugins.market.note')}</p></>;
const help = <><p>{t('settings.plugins.manager.preview')}</p><p>{t('settings.plugins.market.note')}</p></>;
const preview = <>{count > 0 && <p>{t('settings.plugins.status', { count })}</p>}{explanation}{help}</>;
return <PluginInfo label={label} badgeCount={count} previewContent={preview}>{explanation}{offlineError && retry && <Button variant="outline" size="sm" onClick={retry}>{t('settings.plugins.market.retry')}</Button>}{count > 0 && <hr className="border-border/60" />}{help}</PluginInfo>;
}
@@ -1,55 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { JSDOM } from 'jsdom';
test('source management requires consent and Escape restores either opener inside the plugin window', 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);
// Install a synthetic preload surface on the isolated DOM window.
Object.defineProperty(dom.window, 'netcatty', { value: { listPluginSources: async () => [] }, configurable: true });
const { default: React, act } = await import('react');
const { createRoot } = await import('react-dom/client');
const { PluginThirdPartyMarket } = await import('./PluginThirdPartyMarket');
const { PluginManagementDialog } = await import('./PluginManagementDialog');
const { I18nProvider } = await import('../../../application/i18n/I18nProvider');
const root = createRoot(document.getElementById('root')!);
const settle = () => new Promise((resolve) => setTimeout(resolve, 60));
try {
await act(async () => root.render(<I18nProvider locale="en"><PluginManagementDialog><PluginThirdPartyMarket installed={[]} /></PluginManagementDialog></I18nProvider>));
await act(async () => { document.querySelector<HTMLButtonElement>('button')!.click(); await settle(); });
for (const label of ['Manage sources', 'Add source']) {
const opener = [...document.querySelectorAll<HTMLButtonElement>('button')].find((button) => button.textContent === label)!;
assert.ok(opener);
await act(async () => { opener.focus(); opener.click(); await settle(); });
assert.equal(document.querySelectorAll('[role="dialog"]').length, 2);
const dialogs = document.querySelectorAll<HTMLElement>('[role="dialog"]');
const dialog = dialogs[dialogs.length - 1];
assert.match(dialog.textContent ?? '', /real domain/);
assert.equal(dialog.querySelector<HTMLInputElement>('input[type="checkbox"]')?.checked, false);
assert.equal(dialog.querySelector<HTMLButtonElement>('button[type="submit"]')?.disabled, true);
await act(async () => { dialog.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true })); await settle(); });
assert.equal(document.querySelectorAll('[role="dialog"]').length, 1);
assert.equal(document.activeElement, opener);
}
} finally {
// Radix schedules unmount autofocus on a timer. Drain it while the DOM's
// Event constructor is still installed (Node 22 has a distinct global Event).
await act(async () => { root.unmount(); await settle(); });
dom.window.close();
for (const [key, descriptor] of previous) {
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
else Reflect.deleteProperty(globalThis, key);
}
}
});
@@ -1,59 +0,0 @@
import React, { useRef, useState } from 'react';
import { usePluginSources } from '../../../application/state/usePluginSources';
import { useI18n } from '../../../application/i18n/I18nProvider';
import { Button } from '../../ui/button';
import { Input } from '../../ui/input';
import { Dialog, DialogContent, DialogDescription, DialogTitle } from '../../ui/dialog';
import { PluginMarketplace } from './PluginMarketplace';
export function PluginThirdPartyMarket({ installed, toolbarContainer, noVault }: {
installed: NetcattyInstalledPlugin[]; toolbarContainer?: HTMLElement | null; noVault?: boolean;
}) {
const { t } = useI18n();
const model = usePluginSources();
const [selected, setSelected] = useState('');
const [visited, setVisited] = useState<string[]>([]);
const [managing, setManaging] = useState(false);
const opener = useRef<HTMLButtonElement | null>(null);
const openManager = (event: React.MouseEvent<HTMLButtonElement>) => { opener.current = event.currentTarget; setManaging(true); };
const [name, setName] = useState('');
const [address, setAddress] = useState('');
const [accepted, setAccepted] = useState(false);
const [removing, setRemoving] = useState<string | null>(null);
const [disablePlugins, setDisablePlugins] = useState(false);
const sources = model.sources.filter((source) => source.kind === 'third-party');
const source = sources.find((item) => item.id === selected && item.enabled);
let domain = '';
try { domain = new URL(address).host; } catch { /* Host validates before saving. */ }
return <div className="space-y-4">
<div className="flex flex-wrap items-center gap-3">
<select aria-label={t('settings.plugins.sources.select')} value={selected} onChange={(event) => { setSelected(event.target.value); setVisited((items) => [...new Set([...items, event.target.value])]); }} className="h-9 min-w-0 rounded-md border border-input bg-background px-3 text-sm">
<option value="">{t('settings.plugins.sources.select')}</option>
{sources.filter((item) => item.enabled).map((item) => <option key={item.id} value={item.id}>{item.name} — {new URL(item.origin!).hostname}</option>)}
</select>
<Button variant="outline" size="sm" onClick={openManager}>{t('settings.plugins.sources.manage')}</Button>
</div>
{!source && <div className="py-8 text-center text-sm text-muted-foreground"><p>{t('settings.plugins.sources.empty')}</p><Button className="mt-3" variant="outline" onClick={openManager}>{t('settings.plugins.sources.add')}</Button></div>}
{/* Keep each visited source mounted to preserve its own selection/search/scroll. */}
{sources.filter((item) => item.enabled && (item.id === selected || visited.includes(item.id))).map((item) => {
return <div key={item.id} hidden={item.id !== source?.id}><PluginMarketplace source={item} installed={installed} toolbarContainer={item.id === source?.id ? toolbarContainer : undefined} noVault={noVault} /></div>;
})}
<Dialog open={managing} onOpenChange={setManaging}><DialogContent className="max-h-[85vh] overflow-y-auto" onCloseAutoFocus={(event) => { event.preventDefault(); opener.current?.focus(); }}>
<DialogTitle>{t('settings.plugins.sources.manage')}</DialogTitle><DialogDescription>{t('settings.plugins.sources.risk')}</DialogDescription>
{model.error && <p role="alert" className="text-sm text-destructive">{model.error}</p>}
<div className="space-y-3">{sources.map((item) => <div key={item.id} className="space-y-2 rounded-lg bg-muted/30 p-3">
<Input aria-label={t('settings.plugins.sources.name')} defaultValue={item.name} onBlur={(event) => { if (event.target.value !== item.name) void model.update(item, event.target.value, Boolean(item.enabled)); }} disabled={model.busy} />
<p className="break-all text-xs text-muted-foreground">{item.origin}</p>
<div className="flex items-center justify-between"><label className="flex items-center gap-2 text-sm"><input type="checkbox" checked={Boolean(item.enabled)} disabled={model.busy} onChange={(event) => void model.update(item, item.name, event.target.checked)} />{t('settings.plugins.manager.enabled')}</label><Button variant="ghost" size="sm" disabled={model.busy} onClick={() => { setRemoving(item.id); setDisablePlugins(false); }}>{t('settings.plugins.sources.remove')}</Button></div>
{removing === item.id && <div className="space-y-2 text-sm"><p>{t('settings.plugins.sources.removeNote')}</p><label className="flex items-center gap-2"><input type="checkbox" checked={disablePlugins} onChange={(event) => setDisablePlugins(event.target.checked)} />{t('settings.plugins.sources.disablePlugins')}</label><Button size="sm" variant="destructive" disabled={model.busy} onClick={() => void model.remove(item.id, disablePlugins).then((ok) => { if (ok) setRemoving(null); })}>{t('settings.plugins.sources.remove')}</Button></div>}
</div>)}</div>
<form className="space-y-3" onSubmit={(event) => { event.preventDefault(); if (accepted) void model.add(name, address).then((ok) => { if (ok) { setName(''); setAddress(''); setAccepted(false); } }); }}>
<Input required value={name} onChange={(event) => setName(event.target.value)} placeholder={t('settings.plugins.sources.name')} aria-label={t('settings.plugins.sources.name')} />
<Input required type="url" value={address} onChange={(event) => { setAddress(event.target.value); setAccepted(false); }} placeholder="https://plugins.example.com" aria-label={t('settings.plugins.sources.address')} />
{domain && <p className="break-all text-sm font-medium">{domain}</p>}
<label className="flex items-start gap-2 text-xs"><input type="checkbox" checked={accepted} onChange={(event) => setAccepted(event.target.checked)} />{t('settings.plugins.sources.accept')}</label>
<Button type="submit" disabled={model.busy || !accepted}>{t('settings.plugins.sources.add')}</Button>
</form>
</DialogContent></Dialog>
</div>;
}
@@ -1,10 +1,10 @@
import { PluginThirdPartyMarket } from './PluginThirdPartyMarket';
import { usePluginManager } from '../../../application/state/usePluginManager';
import { PluginManagementDialog } from './PluginManagementDialog';
import { PluginMarketplace } from './PluginMarketplace';
import { PluginPackageManager } from './PluginPackageManager';
import { FolderOpen } from 'lucide-react';
import { PluginStatus } from './PluginStatus';
import { PluginLogSettings } from './PluginLogSettings';
import React, { useEffect, useMemo, useState } from 'react';
import { normalizePluginKeyboardEvent } from '../../../application/state/pluginKeybindings';
@@ -290,7 +290,7 @@ function PluginManagementContent({ handoff }: { handoff: (action: () => void) =>
const manager = usePluginManager();
const [catalogToolbar, setCatalogToolbar] = useState<HTMLDivElement | null>(null);
const [selectedInstalled, setSelectedInstalled] = useState<string | null>(null);
const [pluginView, setPluginView] = useState<'installed' | 'marketplace' | 'thirdparty'>('installed');
const [pluginView, setPluginView] = useState<'installed' | 'marketplace'>('installed');
const availableFonts = useAvailableFonts();
const scopeCatalog = usePluginSettingScopeCatalog();
const [scopeIds, setScopeIds] = useState<Partial<Record<NetcattyPluginSettingScopeKind, string>>>({});
@@ -311,12 +311,11 @@ function PluginManagementContent({ handoff }: { handoff: (action: () => void) =>
return (
<div className="space-y-5">
<Tabs value={pluginView} onValueChange={(value) => { setSelectedInstalled(null); setPluginView(value as 'installed' | 'marketplace' | 'thirdparty'); }} className="space-y-5">
<Tabs value={pluginView} onValueChange={(value) => { setSelectedInstalled(null); setPluginView(value as 'installed' | 'marketplace'); }} className="space-y-5">
<div className="flex flex-wrap items-center justify-between gap-3">
<TabsList className="h-auto flex-wrap justify-start bg-muted/50">
<TabsTrigger value="installed">{t('settings.plugins.manager.installed')} <span className="ml-2 text-muted-foreground">{manager.plugins.length}</span></TabsTrigger>
<TabsTrigger value="marketplace">{t('settings.plugins.sources.official')}</TabsTrigger>
<TabsTrigger value="thirdparty">{t('settings.plugins.sources.thirdparty')}</TabsTrigger>
<TabsTrigger value="marketplace">{t('settings.plugins.market.title')}</TabsTrigger>
</TabsList>
<div className="flex items-center gap-2">
{pluginView === 'installed' && <PluginStatus noVault={manager.credentialStorageAvailable === false} />}
@@ -324,8 +323,7 @@ function PluginManagementContent({ handoff }: { handoff: (action: () => void) =>
<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>
</div>
<TabsContent forceMount hidden={pluginView !== 'marketplace'} value="marketplace" className="m-0 space-y-6"><PluginMarketplace installed={manager.plugins} toolbarContainer={pluginView === 'marketplace' ? catalogToolbar : undefined} noVault={manager.credentialStorageAvailable === false} /></TabsContent>
<TabsContent forceMount hidden={pluginView !== 'thirdparty'} value="thirdparty" className="m-0 space-y-6"><PluginThirdPartyMarket installed={manager.plugins} toolbarContainer={pluginView === 'thirdparty' ? catalogToolbar : undefined} noVault={manager.credentialStorageAvailable === false} /></TabsContent>
<TabsContent value="marketplace" className="m-0 space-y-6"><PluginMarketplace installed={manager.plugins} toolbarContainer={catalogToolbar} noVault={manager.credentialStorageAvailable === false} /></TabsContent>
<TabsContent value="installed" className="m-0 space-y-6">
<PluginPackageManager manager={manager} onSelectionChange={setSelectedInstalled} />
{contextualScopes.size > 0 && (
@@ -403,6 +401,7 @@ export default function SettingsPluginsTab() {
<p className="mt-1 text-sm text-muted-foreground">{t('settings.plugins.description')}</p>
</div>
<PluginManagementDialog>{(handoff) => <PluginManagementContent handoff={handoff} />}</PluginManagementDialog>
<PluginLogSettings />
</SettingsAnchor>
</SettingsTabContent>
);
-152
View File
@@ -1,152 +0,0 @@
# Plugin AI and selected context: implementation checkpoint
Base: ThemePack #3978, `91b8927c56314e68c06e2c008e84559ddfb96d78`.
Branch: `feat/plugin-ai-context`.
Status: isolated host internals and fixture-tested transport reuse. Public contract,
production adapters, installable example, independent review, and native acceptance
are pending. This source is not ready to merge or release.
## Proposed public boundaries
The proposed optional features are `ai.model-invocation` and
`context.selected-read`. The parent has not confirmed these names. No canonical
schema, generated contract, SDK, runtime peer, hostService, or installer changes
are included in this checkpoint.
The model API uses a host-selected configured model. Plugins receive a chat
handle, a display label, an expiry, turn handles, text chunks, and usage totals.
Plugins do not supply provider IDs, URLs, credentials, tools, or Agent policies.
The internal operations are `open`, `start`, `read`, `stop`, and `close`.
`read` pulls one chunk with one pending read per turn. The parent can map this
iterator onto the existing stream router after the canonical contract is agreed.
Context summary enumeration returns only references explicitly shared with the
current runtime and source owner. Content reads require an opaque handle and an
expected version. Summary enumeration never calls the content reader. Supported
selected scopes are workspace, tab, session, host, and Note. There is no inventory
or arbitrary entity-ID lookup.
## File ownership
| Owner | Files or responsibility |
| --- | --- |
| This task | `electron/plugins/ai/`, `electron/plugins/context/`, `infrastructure/ai/plugin/`, `examples/plugins/assistant/`, this document |
| This task | Opt-in `requireBridge` and `maxStreamBytes` request context in `infrastructure/ai/sdk/providers.ts` |
| Parent coordination | Canonical schema, generated files, SDK, runtime forwarding, hostService registration, trusted renderer adapters |
| Market task | Source identity, private data-owner namespace, installer database, marketplace UI |
| Transport task | Generic TCP and TLS streams |
| Logging task | Optional local plugin logs |
The existing `feat/plugin-terminal-sharing` commit
`3cdc97782ef3262969eee9d540e03ebe6fdee210` is preserved. Its
`terminal-sharing-wip.md` describes incomplete selection publication and review.
This branch does not claim that workflow is complete.
## Implemented authority
`ModelInvocationBroker` requires host-provided `resolveDataOwner` and
`selectModel` adapters. It keeps chats and turns in memory. It stores no chat
history and makes no database changes. Data owners are opaque host identities;
no private storage is keyed by `pluginId`.
Each handle is bound to a data owner, a runtime ID, and a plugin version.
Every read rechecks runtime authority, the current model, handle lifetime, and
selected context versions. Revocation aborts pending turns. Late provider results
are discarded. Another source with the same plugin ID cannot read, cancel, or
close the original source's handles.
`SelectedContextAuthority.share` is a trusted host operation. A plugin must never
call it directly. Its descriptor accepts only a scope, a title, a version, a text
reader, and a current-selection guard. Unknown Host fields are rejected. Content
has a 16 KiB cap, excludes NUL, and rejects PEM private keys. The production host
reader must project safe fields explicitly and exclude passwords, private keys,
TOTP values, identity credentials, startup commands, and environment variables.
Free-form Notes can contain secrets that field projection cannot detect. The
trusted sharing UI must preview the selected text before sharing it.
`createPluginModelAdapter` calls the existing `createModelFromConfig` and
`streamText`, through the existing host provider transport. Credential injection
stays in Netcatty. Direct-fetch fallback is disabled for this adapter. Incoming
SSE payloads have an aggregate 128 KiB cap before SDK buffering. SDK retries are
disabled. Only text and allowlisted token totals reach the broker. Provider
errors, raw chunks, reasoning, headers, and tool payloads do not cross it.
No AgentRuntime, internal prompt, full MCP catalog, tool executor, Node runtime,
raw host IPC, or global renderer store is exposed to plugins. A model tool request
ends the turn without executing an action. Notes edits and terminal input are
outside this checkpoint. No command is written to a terminal or followed by Enter.
The default broker limits are defined in `MODEL_INVOCATION_LIMITS` and
`SELECTED_CONTEXT_LIMITS`. They include concurrent handles, turn and chat lifetime,
input and output bytes, output token caps, event count, requests per minute, and
reserved tokens per minute. Input token reservation uses UTF-8 bytes plus 1,024
bytes for framing and the output token cap. This is a conservative estimate, not
a tokenizer or a monetary billing cap. Provider usage is reported separately.
Reservations are not refunded after cancellation. Rate limits survive runtime
restart within the broker's lifetime. Host shutdown clears this in-memory state.
## Required host adapters
Production integration is blocked on these parent-owned contracts:
1. `resolveDataOwner(context)` must return the current private source owner from
host provenance. A plugin-supplied source value or plugin ID is insufficient.
2. `selectModel(context)` must return a trusted invocation adapter for the model
explicitly selected in the main UI. No provider configuration crosses RPC.
3. The renderer adapter must validate the current window, selected resource,
lifecycle generation, version, and owner before and after asynchronous reads.
Reconnect, tab or session close, navigation, lock, disable, and permission
revocation must invalidate handles and abort turns.
4. Summary enumeration, content reads, and model invocation need separate
permissions. Only the canonical schema can define their wire format.
Model invocation permission does not grant context or action permissions.
5. Host review must show selected content before it becomes model input.
A command-palette or menu ID is never resource authority.
6. The shared hostService adapter must register teardown and permission-revoke
hooks, and discard results if its renderer owner changes.
## QA evidence
The domain suites and transport suite use synthetic data only. The transport
suite starts a deterministic OpenAI-compatible HTTP server on loopback and
checks this path:
`ModelInvocationBroker` -> `createPluginModelAdapter` -> `createModelFromConfig`
-> `createBridgeFetchForSDK` -> existing `_streamRequestForTests` main transport
-> local fixture -> text and usage.
This is not real model inference or paid API testing. It uses no paid keys, real
Notes, hosts, or chat history. The credential value is a fixed synthetic marker;
the existing model factory replaces it with its IPC placeholder. The test does
not exercise native credential storage or production credential injection.
The tests cover source, runtime, and version denial; separate summaries and
content reads; context invalidation; provider cancellation and late results;
deadlines; request, token, event, input, output, and stream-byte limits; and
allowlisted response serialization. Raw provider diagnostics remain inside the
host.
Commands:
```sh
node --test electron/plugins/modelInvocation.test.cjs
node --test --import tsx infrastructure/ai/plugin/modelInvocationAdapter.test.ts
npx eslint infrastructure/ai/plugin/*.ts infrastructure/ai/sdk/providers.ts examples/plugins/assistant/panel.js
```
The final focused domain run passes 16 tests. The transport suite passes three
tests, including the actual assistant panel's Send, streaming, usage, and Stop
handlers in JSDOM. The test bridge uses structured clones to model the existing
view command boundary. It does not replace installed-plugin or native coverage.
The wider plugin runtime run passes 556 tests with one skipped. The existing
provider regression suites pass 34 tests.
The full repository typecheck fails in existing code, including provider tool-call
inference and plugin CLI test manifest literals. No new adapter type errors were
reported. The focused lint passes.
Native Electron launch aborts because the bundled `chrome-sandbox` is not owned
by root with mode 4755. No sandbox bypass was attempted. Native screenshots,
video, installed-plugin UI tests, packaged builds, and independent review are
pending on the parent's working desktop after shared integration. The panel
source alone is not evidence of native acceptance.
@@ -1,111 +0,0 @@
# Byte stream integration checkpoint
The branch starts at #3978 `91b8927c56314e68c06e2c008e84559ddfb96d78`.
It integrates source APIs from #3981 `ab8764a31752fd2d226c9d029c4d65498ba7b063`
and AI service/adapters from #3986 `264f1d983cd1ed83fe4873974a614fff2f47f989`.
No source database or sync implementation is changed by the transport delta.
## Registered transport
Canonical definitions are in `packages/plugin-contract/schema/plugin-contract.schema.json`.
`network.byte-streams` negotiates the capability. The separate `network.stream`
permission requires explicit `tcp://host:port` or `tls://host:port` resources.
Wildcard declarations and grants cannot authorize raw streams. HTTP grants and
payload limits are unchanged.
The SDK exposes `context.network.connect({ endpoint }, cancellationToken?)`.
The returned connection has `endpoint`, `readable`, `writable`, `close()`, and
`dispose()`. The control methods are `network.stream.connect`,
`network.stream.attach`, and `network.stream.close`. Descriptors, request
shapes, feature names, limits, generated SDK types, and host validators share
the canonical schema. Unknown request properties are rejected.
The registered service authenticates the host runtime before source lookup.
Runtime startup captures the existing opaque source owner. Every asynchronous
commit revalidates the runtime, active and retained source, and opaque owner
through the source task's APIs. Caller-supplied owner/source parameters are
rejected. Audit commits also verify the source binding.
Connection and stream IDs are host-issued and bound to that runtime and owner.
The SDK prepares its incoming waiter before attach starts the host producer.
Both directions reuse the existing binary stream router and credit windows.
Input credit returns after the socket write callback. Output backpressure
bounds the producer. The router has one optional producer `onClose` hook;
peer cancellation releases the socket even while it is idle.
The host validates all DNS answers and pins a literal IP. Production rejects
loopback, link-local, unspecified, multicast, mapped local addresses, selected
IPv6 tunnel forms, and the machine's own interface addresses. The production
host constructor never forwards the core's explicit local-test override.
There is no proxy discovery, reconnect, UDP, listen, generic IPC, shell, Node
plugin runtime, or filesystem escape.
Limits are four connections per runtime, 64 globally, 64 KiB chunks and socket
buffer thresholds, and 8 MiB combined bytes per connection, plus the existing
runtime byte quota. Connect, idle, and lifetime deadlines are 10, 30, and 120
seconds. Close tombstones are capped at 256 small identity records. Revocation,
runtime stop, disable, shutdown, and stream cancellation release resources.
TLS requires trust and original-hostname validation with TLS 1.2 or later.
Plugins cannot set custom CAs, SNI, or validation exceptions. STARTTLS is not
implemented. Generic audit records contain endpoint, runtime, handle, and byte
counts. The host does not inspect SQL or record payloads or credentials.
## Additive AI interface for the parallel owner
The same canonical schema defines the relayed `AI_CONTEXT_ROUTES` types.
The SDK/runtime expose `context.ai.openChat/startTurn/readTurn/stopTurn/closeChat`
and `context.selectedContext.list/read`. Host registration advertises
`ai.model-invocation` and `context.selected-read`, and wires runtime cleanup,
plugin disable, revocation through the service, and shutdown.
The trusted host adapter constructor option is `pluginAIHostAdapter`. It has
the service owner's `assertActive`, `selectModel`, `listSelected`, `reviewText`,
and optional lifecycle callbacks. These callbacks are never exposed to plugin
views. The default adapter rejects model invocation, returns no selected
resources, and refuses text review. The AI owner still owns real renderer
wiring and model/context UX. A registered synthetic adapter test is not proof
of that remaining production wiring.
## Database example
`examples/plugins/database-minimal` is an ordinary browser plugin. Its developer
preparation tool creates an `.ncpkg` for one exact disposable private IPv4
endpoint. The output contains a browser bundle, a normal manifest, and view
assets. The preparation tool and its Node dependencies are not in the plugin.
Only the example contains the database protocol: common protocol 4.1,
`mysql_native_password`, and
`SELECT value FROM plugin_demo.example_result LIMIT 1`.
The view accepts session-only fixture credentials and clears its password
field before dispatch. It does not save view state, settings, logs, or secrets.
The host never exposes SSH or AI credentials. JavaScript does not promise
memory zeroization. The account's server-side SELECT grant enforces read-only
access. The fixed query is not a SQL security classifier.
The example has no query editor, schema browser, pool, arbitrary SQL, or host
connection-management subsystem. See the example README for native setup.
## Evidence and limits
- Both source-reviewed races have executable real-loopback regressions here.
Delayed final-byte consumption previously lost EOF after the socket closed.
Double-microtask cancellation previously created an untracked late socket.
Each regression failed before its respective fix and passed afterward.
- The broker tests use real TCP/TLS sockets, including trusted fixture CA,
untrusted certificate and wrong-hostname denial. Connect and idle deadline
cases use mock clocks around real sockets or delayed DNS.
- The installed entrypoint test builds and installs a normal package, executes
its actual browser entry module through SDK/RPC/binary streams, and exercises
grant denial, exact resources, unknown-field rejection, close, revoke, and
disable. It also exercises registered AI/context SDK routes with a synthetic
trusted adapter. Its runtime harness is Node and is explicitly not a browser
sandbox or OS sandbox test.
- The disposable MariaDB test checks a synthetic SELECT result and server-side
INSERT denial. It creates an internal network, publishes no host ports, and
removes the container and network. Database transport is plain TCP inside
that disposable network, not database TLS.
Normal native BrowserWindow, full app/package checks, exact-head independent
review, and screenshot/video acceptance remain separate required evidence.
Do not disable the Chromium sandbox to obtain that evidence.
@@ -1,125 +0,0 @@
# Host and Vault actions
Feature: `vault.host-actions`. The SDK registers three methods under
`context.vault`. Netcatty owns the picker, field-diff review, and connection
confirmation. Plugin business logic stays in the offline browser sandbox.
Base: source integration #3981 at
`4213d8e6726c44e7a479418ece3adf16ea494782`.
The published stream/AI branch point `c4433d496` is preserved through an ordinary
merge. Its owner was still completing integration checks when it was published.
## Reusable boundaries
| Concern | Existing contract | Reuse |
| --- | --- | --- |
| Host picker | `components/SelectHostPanelContent.tsx` | Controlled `selectedHostIds` and `onSelectionChange`; group toggles resolve to explicit host IDs. |
| Typed drafts | `ImporterHostDraft` in `packages/plugin-contract/schema/plugin-contract.schema.json` | Create allowlist: label, hostname, username, port, group, tags. Edit allowlist: label, group, tags. |
| Validation and preview | `normalizePluginImporterRecords`, `buildVaultHostFromDraft` | Validate the raw address, normalize the importer draft, validate the normalized result, and show the normalized field diff. |
| Vault writes | `useVaultState.commitVaultImportTransaction` | Existing shared Vault lock, expected host snapshot, encryption, stale-write checks, and journaled persistence. Optional authorization guard runs after encryption. |
| Connection | `AppSideEffects.handleConnectToHost` | Existing saved-settings, group defaults, authentication, logging, and session creation flow. |
| Script disclosure | `applyGroupDefaults`, `hasHostConnectAutomation`, `snippetAppliesToOutputTrigger` | Boolean disclosure covers inherited startup commands, global/group/host connect scripts, unresolved bindings, host output triggers, and applicable global output scripts. Script contents remain host-owned. |
| Lifecycle | `PluginHostInteractionService`, `PluginPermissionEngine`, runtime route `assertActive` | Existing window ownership, focus, lock, signed replies, cancellation, and revocation patterns. |
| Source identity | `PluginDatabase` | `getActivePlugin`, `getRetainedPluginSource`, `privateOwner`, and `publicPluginId`; no caller-supplied owner or bare-ID fallback. |
## Public methods
| Method | Parameters | Result |
| --- | --- | --- |
| `vault.selectHosts` | `{ title: string }` | `{ cancelled: true }`, or `{ cancelled: false, hosts, groups }`. |
| `vault.requestHostDraft` | Create: `{ draft: { label?, hostname, username?, port?, group?, tags? } }`. Edit: `{ target, expectedRevision, draft: { label?, group?, tags? } }`. | Cancelled, or `{ cancelled: false, status: "saved" }`. |
| `vault.requestConnect` | `{ target, expectedRevision }` | Cancelled, or `{ cancelled: false, status: "requested" }`; no session identifier or content. |
Selected metadata contains label, hostname, username, optional port, protocol,
group path, and tags. Each host result contains `{ target, metadata }`. Host IDs
remain behind the typed target reference; its revision is the expected revision.
Group metadata describes the explicitly selected current member handles. The
grant never expands when another host joins a group.
Selection grants metadata access only. Each draft save and each connection
requires its own host-owned confirmation. A connection confirmation displays the
saved target and whether saved scripts can run. It does not grant terminal input,
terminal output, or session access.
All requests are bounded and cancel on runtime stop, source replacement, revoke,
Vault lock, owner window replacement, expiry, or user cancellation. The host
revalidates authority and expected revision after asynchronous work and before
the Vault write or connection dispatch.
Bounds reuse `HostInteractionLimits`: one pending request per runtime,
eight total, ten per minute, a 60-second confirmation deadline, and a 64-KiB
request ceiling. Selection adds a maximum of 50 current hosts. Titles use the
existing 128-character single-line limit. Draft field lengths use
`ImporterHostDraft`. Unknown properties are rejected by the shared schema.
Creates produce saved SSH drafts. An empty group clears the saved group. Edits
preserve connection endpoints and credentials. Changing a group can change
inherited authentication, proxies, and scripts; the draft review states this.
Drafts involving managed SSH-config hosts or SSH destinations inside managed
groups are explicitly unavailable in this small API. The review disables saving
and directs the user to the Vault host editor. The execution adapter enforces the
same restriction before claiming action authority, using the existing
`applyVaultHostUpdate` ownership rules. It also checks the live and persisted
managed-source catalog for changes before writing. This API does not initiate
managed-file sync or expose plugin file access.
The API excludes Vault and renderer stores, the internal MCP catalog, plaintext
credentials, private keys, TOTP configuration, startup scripts, and arbitrary
execution. Host selection and action UI live in Netcatty. Plugin business logic
stays in the sandboxed plugin.
## Typed target and action authority
`VaultHostRef` is a signed `host.selection` reference with `scope: "metadata"`,
an opaque handle, a revision, and an expiry. It identifies an exact chosen host.
It does not authorize editing, connecting, terminal input, or terminal output.
References bind source, private owner, plugin version, runtime, window owner and
generation, exact host ID, revision, and expiry. They live for five minutes at
most, with at most 500 retained references. Another selection replaces that
runtime's previous references. Lock, revoke, window replacement, and runtime stop
invalidate the affected references. Removal or replacement of a host makes its
renderer revision stale, even if its visible fields are identical.
`PluginAISourceBinding` supplies the existing host-only source identity primitive.
It revalidates after asynchronous authentication. AI selected-context handles are
text-disclosure handles and cannot target these actions.
The permission engine gates selection with `vault.metadata`, drafts with
`vault.write`, and connection requests with `ui.interact`. Every save or connection
also requires a separate host-owned confirmation and a signed one-use action
claim. A stored permission grant cannot bypass this review.
After review, the renderer prepares the existing transaction or connection
request. It claims action authority after the final asynchronous preparation,
immediately before persistence or dispatch. This claim is the action's commit
point. Completion only acknowledges the committed action. Automation changes
during connection review require fresh confirmation.
The connection label uses the same `resolveEffectiveTerminalProtocol` helper as
the launcher, including Mosh/ET flags and implicit localhost shells. Local
execution displays an explicit on-device shell notice and omits the remote SSH
username and port.
## Sample and validation
`examples/plugins/host-actions` adds four commands through the existing command
palette slot. Its create draft uses `synthetic.invalid`; it requests no network,
filesystem, terminal-content, or credential permissions.
`components/PluginHostActions.entrypoint.test.tsx` installs that sample and drives
its actual entrypoint, SDK, RPC registry, source binding, signed replies,
production React dialog, and renderer execution adapter. It covers explicit
selection, cancel, stale revisions, locked Vault, host removal and replacement,
revoke, source replacement, runtime removal, and connection confirmation. Registry
checks reject text handles, tampered references, caller owners, and mismatched
expected revisions. Only synthetic resources are used. The Node transport fixture
does not certify the operating-system sandbox.
Focused action tests compare Mosh, ET, and localhost confirmations with the real
`handleConnectToHostImpl` dispatcher while replacing terminal creation with a
synthetic recorder. Managed A-to-B/outside moves, managed destinations, ordinary
unmanaged saves, and catalog changes use synthetic source records; no SSH config
files are read or written.
Native screenshots and video run on the parent's existing cloud GUI. This branch
does not change Electron sandbox or helper permissions. Tests use synthetic hosts
and credentials. No real host connection or credential access is authorized.
@@ -0,0 +1,87 @@
# Local logging validation
Base: PR #3978 at `91b8927c56314e68c06e2c008e84559ddfb96d78`.
All data used in these checks is synthetic and each persistence test creates its
own temporary profile. No real logs or credentials were opened.
| Check | Result |
| --- | --- |
| `node --test electron/plugins/*.test.cjs` | 561 passed, 2 expected skips (Electron live test and the #3981 database integration test on this older base) |
| New logging settings, existing settings and permission-panel interaction tests | 4 passed |
| `npm run build` | Passed; existing large-chunk warning |
| `npm run lint` | No errors, 7 existing warnings; final changed-source lint also passed |
| `npm run check:plugin-contract` | Passed, no generated drift |
| `npx tsc --noEmit` against this change and the exact base | 773 diagnostics in each, zero added or removed after normalizing worktree paths |
| `git diff --check` | Passed |
Coverage includes disabled and enabled persistence, opt-in plugin text, sensitive
field exclusion, host source attribution, unknown-source fallback, URL rejection,
spoofed metadata, cyclic/accessor/proxy input, noisy writers, shared queue and disk
limits, rotation, expiration, drop/error counts, export, clear confirmation,
disable/revoke, stale runtime callbacks, and manager shutdown. The filesystem
regressions cover direct paths, symlink aliases, a symlinked profile directory,
and revocation during the final protected-storage check.
## Remaining native checks
Electron launch failed before application/profile startup. The executable at
`node_modules/electron/dist/electron --version` aborted with:
> The SUID sandbox helper binary was found, but is not configured correctly.
Its message requires `chrome-sandbox` to be root-owned with mode 4755. No sandbox
switch, privilege escalation, permission repair, or native retry followed. Native
smoke execution, screenshots, and a short recording remain pending. The acceptance
flow is in [Local plugin logging](local-logging.md#native-qa-with-synthetic-data).
`npm run pack:dir` completed its production build, then failed during serialport
native rebuilding: node-gyp returned ENOENT for mkdir
`/home/agent/.electron-gyp`. No system-directory change or packaging retry followed.
A native package was not produced.
## Parent integration
The only shared settings-page change is one import and one `<PluginLogSettings />`
element. Preserve that element when integrating the marketplace task's page.
There are no Installed, Official, Third-party, source-database, or unrelated host
data changes.
The default host-owned resolver is wired through `createPluginHostService`. It
consumes #3981's confirmed API without editing its database or sync implementation.
On the older #3987 base, missing source APIs remain explicitly unknown. No shared
transport-registration change is needed beyond the existing logging registration.
## Source and review follow-up
A separate worktree at `59721f97172acffffe960a6268a2916a90a6c554` received only
the logging backend overlay. Its logging/source/host regression command passed
33 tests with no skips, including the real database replacement, encoded local-owner roundtrip and
shutdown-drain case. The product branch does not import marketplace changes or bump its database
schema.
Independent review of the implementation follow-up found no remaining blocking
findings. Independent runs passed 50 focused regressions with one expected source
skip, 20 real-source integration regressions with no skips, and the logging UI
interaction test. The reviewer made no edits and did not launch Electron. A further independent
review of the newer encoded-local-owner contract found no issues; all six
provenance tests passed on exact `59721f97172acffffe960a6268a2916a90a6c554`,
with an additional probe confirming the local encoded owner.
Fixed review findings have deterministic regressions: 31 days of low-volume
appends and export cannot extend segment retention via mtime; stale sources cannot
rotate away existing evidence; settings-write failure shows actual host state;
log draining still completes when another shutdown stage rejects; and activation
permission denial produces a fixed host record before runtime creation.
Exact prior head `e3cc23bca81a4187ccc13b90d3882889d524ca3b` passed remote
[test 40339839263](https://github.com/binaricat/Netcatty/actions/runs/40339839263)
and [build-packages 40339839660](https://github.com/binaricat/Netcatty/actions/runs/40339839660).
Those runs do not validate the follow-up head. Its exact-head CI is reported in
the PR after pushing.
The native fixture build is reproducible: 3,270 bytes, SHA-256
`c3770a55676ed708f01ec1e0e0b9968ffb5e03c02303ee7cb9f739bde27c9de0`.
The concise flow and commands are in [Local plugin logging](local-logging.md#native-qa-with-synthetic-data).
Native screenshots and recording remain assigned to the parent's working cloud
GUI machine. No local sandbox bypass or packaging retry is authorized by this
handoff.
+178
View File
@@ -0,0 +1,178 @@
# Local plugin logging
Local file persistence defaults to off. The existing permission panel keeps its
bounded security history independently. Changing or clearing local log files does
not clear that history, grant permissions, or change a plugin's source.
## Saved records
`PluginLogService` extends the existing `PluginLogger`. It owns one shared sink at
`<userData>/plugins/logs/host-v1`, outside plugin storage. Files contain JSON Lines.
They stay on the device. No cloud upload or sync path includes these files or the
local preference at `<userData>/plugins/local-logging.json`.
Host audit records contain these facts:
- Host-assigned plugin ID, installed version, archive SHA-256, runtime ID and kind.
- Source ID, source kind, and namespace from an injected host provenance resolver.
Missing provenance is `null`. Manifest claims never imply an official source.
- Registered host RPC operation, authorization outcome, result, numeric error code,
elapsed milliseconds, and available input/output `data` field byte counts.
- Target identifiers from host authorization descriptors, replaced with a keyed
SHA-256 digest. The random key stays in memory, so target correlation lasts only
for the current app process.
- Cumulative dropped-record and write-error counts at enqueue time.
Runtime start, running, stop, failure, protocol rejection, and permission revocation
produce separate host facts. Required and advanced activation permission failures
produce a fixed `runtime.authorization` event even before a runtime ID exists. The registry records known RPC attempts even when
validation or authorization fails. Authorization has separate `allowed`, `denied`,
`not-required`, and `not-reached` states. A handler error after approval preserves
`allowed` and records the failed result. Unknown values remain explicit.
The byte counts measure UTF-8 bytes in a string `data` field, or byte length for a
binary `data` field. They are not total wire traffic or decoded base64 size.
Missing measurements are `null`. Streaming traffic outside these calls does not
become a complete per-byte trace. Host facts describe what the host observed;
plugin-returned success cannot prove an external side effect occurred.
Host audit records never copy request bodies, response bodies, error messages,
credentials, API keys, passwords, prompts, Notes, SQL, query results, or terminal
content. Error codes come from the host RPC error set. No audit field accepts
plugin-supplied source identity.
## Optional plugin text
Verbose diagnostics require a separate confirmation and turn off after app
restart. Console messages, utility stdout/stderr, and `logger.write` text use
`recordType: plugin-diagnostic` and `trust: plugin-authored-unverified`. The host
adds identity outside the nested `diagnostic` object. Plugin fields named
`pluginId`, `sourceId`, or `recordType` cannot replace reserved metadata.
Text and structured fields have traversal, length, depth and total-record limits.
Control characters are removed and known sensitive field names are replaced.
These bounds do **not** make arbitrary plugin text safe. A plugin can put a secret
under an unexpected key or inside a sentence. Review files before sharing them.
No plugin text is persisted when verbose diagnostics are off.
## Limits and lifecycle
The shared sink retains at most three files of 1 MiB each, for at most seven days.
Expiry uses the first host timestamp in each segment, read within the existing
16 KiB record bound. Appending cannot extend a segment's lifetime. Export uses
the same cutoff. A segment is removed when its first record expires; capacity
rotation or segment expiry can remove later records sooner. Retention runs on
startup, writes and hourly while the app runs, including after saving is disabled. The app cannot delete expired files while it is closed.
The asynchronous queue accepts at most 256 KiB and records drops when full.
Plugin text can occupy only a quarter of that queue, leaving capacity for host
facts. Diagnostics also reuse the existing per-runtime log token bucket (240
records per minute, with a 240-record initial burst), including console output. Each
record is limited to 16 KiB. Oversized records are dropped. Write failures increase
both drop and error counts and never fail the plugin operation.
Turning saving off stops ingress, discards queued records that have not started,
and waits for any current write. Runtime cleanup closes its diagnostic logger.
App shutdown drains accepted writes through the existing manager shutdown path,
including when another cleanup stage rejects. A failed settings write refreshes
the checkbox from actual host state while retaining the original error.
Clearing pauses ingress, discards queued records, waits for a current write and
removes only the sink's known files. Cancellation leaves them intact. Export takes
a bounded snapshot on the write queue and uses the native Save dialog.
Files use mode 0600 and the directory uses 0700 on systems that support them.
The host filesystem broker rejects writes to the audit directory and preferences,
including canonical paths reached through symlinks. Plugin views cannot invoke
log management IPC. No plugin API exposes audit mutation. These local files are
not a tamper-evident ledger against the OS account or the development-only utility
runtime, which already has full Node and filesystem authority.
## Integration boundaries
`PluginLogSettings` is a standalone compact component, mounted by a single import
and element in `SettingsPluginsTab`. It adds no marketplace tab and changes no
Installed, Official, Third-party, or source-database implementation. When merging
with the marketplace branch, preserve this element in the host settings surface.
`createPluginHostService` now injects the host-owned
`createPluginLogProvenanceResolver(database)` by default. It consumes the source
API confirmed on #3981 at `59721f97172acffffe960a6268a2916a90a6c554`:
- `getActivePlugin(pluginId)` supplies the installed version, hash and source.
- `getRetainedPluginSource(pluginId)` must agree with the installed source.
- `privateOwner(pluginId)` supplies the opaque namespace.
- `publicPluginId(owner)` must round-trip to the host-assigned public ID.
The adapter receives host activation identity only, after the existing caller
guards. It never accepts source or owner fields from plugin request parameters.
Missing APIs, an absent installation, changed version/hash, a retained-source
mismatch or an orphaned owner leave provenance explicitly unknown. There is no
bare-plugin-ID fallback. The current host API encodes every owner, including
local imports; a bare ID returned as an owner remains unknown.
Each logger snapshots the source binding and supplies a host-only `isCurrent`
check. The sink checks it at enqueue, after asynchronous preflight, before
rotation mutations and immediately before appending. Records from a replaced or
uninstalled source are dropped and counted. They are never relabelled as the new
source. Already saved records keep their original identity.
The optional `resolvePluginProvenance` override remains host-owned. Its result
contains `sourceId`, `sourceKind`, `namespace`, and an optional synchronous
`isCurrent` function. Source IDs and kinds accept 1-128 ASCII letters, digits,
periods, underscores, colons and hyphens. Opaque namespaces accept the same
characters up to 256 bytes. URLs, display names and plugin claims are not source
identity. `official` describes the host-bound source, not official maintenance.
The source database, marketplace page and sync implementation are unchanged by
this adapter. It also needs no new shared host-registration change for transport.
## Native QA with synthetic data
Use the parent's working cloud GUI machine, a fresh isolated profile, and the
synthetic fixture below. Do not retry the blocked local Electron launch.
Do not open real logs or credentials. Capture screenshots of the settings card,
verbose disclosure, and native Clear confirmation. Record a short flow:
1. Open Plugins settings. Confirm both options are off and no host-v1 files exist.
2. Enable local saving. Invoke a synthetic plugin host operation and deny another.
3. Open the folder and export to a synthetic destination. Inspect parsed JSONL for
host identity, outcomes, target digests, and absence of synthetic secret bodies.
4. Open verbose disclosure and cancel. Confirm no plugin text is saved. Confirm
the disclosure, write synthetic text, and verify the unverified record type.
5. Disable/revoke the plugin. Confirm its later text cannot append. Restart the
app and verify that local saving remains selected but verbose text is off.
6. Cancel Clear, then confirm it. Confirm the files are removed and the permission
panel's separate security history remains. Turn local saving off.
The fixture is `electron/plugins/fixtures/local-logging-plugin`. Build it with:
```sh
node --input-type=module -e 'import { buildPluginPackage } from "@netcatty/plugin-cli"; await buildPluginPackage("electron/plugins/fixtures/local-logging-plugin", "/tmp/netcatty-local-logging-fixture.ncpkg");'
```
Package: 3,270 bytes. SHA-256:
`c3770a55676ed708f01ec1e0e0b9968ffb5e03c02303ee7cb9f739bde27c9de0`.
It declares only `commands` and `menus`; its command-palette entries are under
**Logging QA**. **Emit synthetic diagnostic** tests redaction and nested spoofed
identity fields. **Emit synthetic diagnostic burst** emits 400 console messages
to exercise drop accounting. **Test undeclared network denial** must return denial
without a prompt or network I/O because the manifest declares no network access.
No request body or credential is supplied. Do not grant additional permissions.
Capture the compact settings card, verbose disclosure and native Clear dialog,
plus a short recording of the enable/emit/export/clear flow. On a #3981 integration
build, a normal local import must log the source and opaque owner returned by its
host database. On the older base, both stay explicitly unknown. Do not forge an
Official source through fixture metadata. The real-source replacement case is
covered by `pluginLogProvenance.integration.test.cjs` on the documented source
integration worktree; it uses only temporary synthetic database state.
The execution environment for this change could not launch Electron: its SUID
sandbox helper is not root-owned with mode 4755. Electron aborted before opening
any profile. No sandbox-disable retry or system-permission change was attempted.
`npm run pack:dir` also failed while rebuilding the serialport native dependency:
node-gyp tried to create `/home/agent/.electron-gyp` and received ENOENT. No
system-directory change or packaging retry was attempted.
Native screenshots and recording therefore remain pending in the parent desktop
QA queue. Component interaction tests are separate evidence and do not replace
that acceptance.
-101
View File
@@ -1,101 +0,0 @@
# Market sources (unreleased)
The plugin manager has Installed, Official marketplace and Third-party marketplace
pages. Listing a plugin in the official market does not certify it or establish
that Netcatty maintains it. Remote manifests, publisher names, source names and
README content cannot create a host-issued official badge. This revision does not
issue maintainer badges. README text and images remain author content.
## Add a source
Enter a public HTTPS origin, for example `https://plugins.example.com`, or that
origin's `/v1/snapshot.json`. Names can change; identity is derived by the host
from the canonical origin. Credentials, query strings, fragments, custom ports,
IP literals and local hostnames are rejected. The initial third-party transport
requires public IPv4 DNS records, pins the checked address to the TLS connection,
and rejects private/reserved destinations. IPv6-only sources are not supported
in this revision. No cookies, login tokens, redirects or GitHub fallback are used.
Third-party sources begin empty. A failed request never substitutes the official
catalog. Each source has its own in-memory last-known-good snapshot and selection
tokens; an offline snapshot is browse-only. A process restart requires a fresh
third-party fetch. The official source remains fixed to `plugins.netcatty.app`
and retains its bundled source examples when unavailable.
## Publish a static source
Use the existing snapshot protocol in the registry project's
`docs/STATIC-DISTRIBUTION.md`. Every source
serves `/v1/snapshot.json`, pointing to hash/size-bound catalog, index and delivery
JSON under `/snapshots/<delivery-sha256>/`. All resources remain on the source's
origin under content-addressed `/assets/` paths. Whole-JSON budgets, schema,
manifest, compatibility, browser-runtime admission and archive validation still
apply. Third-party source consent is not permission consent.
Delivery v1 is unchanged and source-only. Delivery **v2** has the same fields and
strict schema as v1, with `schemaVersion: 2` and one extension: each plugin's
`artifact` can be null or a package reference:
```json
{"path":"/assets/<64-lowercase-hex-sha256>.ncpkg","sha256":"<same-sha256>","size":12345}
```
Packages are at most 32 MiB. The catalog entry must contain
`artifact: {url: "https://plugins.example.com/assets/<same-sha256>.ncpkg", sha256}`.
The index's current, non-withdrawn version must exactly equal the catalog entry
and use `distribution: "release"`. Source-only entries retain null delivery
artifacts and `distribution: "source-only"`. Avatars remain null; remote README
embeds are not loaded. README and PNG/WebP references retain the v1 bounds.
Existing official bootstrap entries are not changed or made installable.
Before every install, refresh and validate the entire snapshot again. A changed
selection, withdrawal, unavailable source, altered hash or size prevents install;
there is no fallback to another source or latest release. Packages install disabled.
## Ownership and changing source
A public plugin ID can have only one installed source. The package's host install
metadata and durable database binding record that source, including across
recovery and uninstall. Cross-source install or update is rejected, even when
version and package bytes match. Local imports are a separate source.
Private settings, view state, persistent and in-memory grants, secrets, and cloud
sidecars are namespaced by host-derived source identity. Every source, including local imports, uses an opaque encoded owner; public plugin IDs are never storage-owner aliases in sync payloads. Before each sync entry and after every awaited contribution update/reset, the host rechecks that owner against the live source and contributions. Hydration captures its owner before yielding and never remaps a public ID for a later write. KV keys are qualified
inside the existing per-plugin FK. Code, permissions and source configuration do
not participate in cloud sync. Old-source cloud settings may remain orphaned;
they cannot hydrate the same plugin ID from a different source. Generic KV still
is not cloud-synced. No host/Vault/input migration changes are made.
To change source, uninstall and explicitly select **clear this source's private
data, grants and secrets**. The host serializes shutdown, revocation, private-file
cleanup and binding reset. This is destructive and is never selected by default.
Then install from the new source and review permissions again. An uninstall that
retains data deliberately retains the binding and continues to reject other sources.
Removing a market keeps its installed plugins and data. Updates stop; optionally
disable its plugins. No other market silently takes over. Disabling a source only
stops browsing/install/update access; it does not silently revoke existing plugins.
## Validation limits
Tests use synthetic source responses and first-party packages. Passing tests is
not a sandbox certification. Native UI, real third-party hosting, network
availability and multi-device encrypted-sync acceptance must be reported
separately. No real credentials or untrusted community code are needed for tests.
## Source-owner integration
Use `PluginDatabase.getActivePlugin(context.pluginId)` to require a current host
installation, then `database.privateOwner(plugin.id)` for an opaque data partition.
`plugin.source` is host-owned `{id, kind, name, origin}`.
`getRetainedPluginSource(pluginId)` returns a retained uninstall binding or null.
`publicPluginId(owner)` resolves only an encoded owner matching the current source;
it rejects raw public IDs and stale owners. Never accept owner/source from a plugin
request or invent a `pluginId` fallback. Capture owner/source at async operation
start and revalidate before committing; follow runtime lifecycle cancellation.
Plugin database schema 7 atomically converts existing raw local private rows and
KV keys to the same encoded-owner model without deleting them. This is a one-time
conversion, not a parallel runtime compatibility path. A conflicting conversion
fails without publishing schema 7. Old raw-ID cloud entries remain orphaned and
cannot hydrate an installed plugin. Host/Vault/input schemas are unchanged.
+46 -74
View File
@@ -1,84 +1,56 @@
# Official static distribution client
# Official registry client
Community starts at the fixed main-process endpoint
`https://plugins.netcatty.app/v1/snapshot.json`. The renderer cannot choose an
endpoint. All automatic catalog, README and potential package reads allow only
that exact HTTPS origin: no credentials, query, fragment, noncanonical URL or
redirect. No GitHub API/raw/avatar/release fallback exists. Explicit user-opened
source links may still open GitHub in an external browser.
The Community page refreshes the fixed endpoint
`https://raw.githubusercontent.com/binaricat/netcatty-plugins/main/catalog.json`
in the main process. The renderer cannot choose a registry URL. This endpoint
must be published separately; client implementation and synthetic tests do not
prove the repository or an online artifact is available.
## Snapshot contract
## Catalog contract
This client implements the version-1 contract from
`binaricat/netcatty-plugins@7bfe23e5627e16ba63806de6dded5b2667c02984`.
The source catalog/index remain unchanged; **no `readmeAsset` field is added**.
`catalog.json` is an array of at most 1,000 current entries. Each entry supplies
`id`, `version`, `name`, `description`, `author`, `repository` (`owner/repository`),
`commit` (full 40-character SHA), `license`, `readme` (relative Markdown path),
and `manifest`. Optional `artifact` contains `url` and SHA-256 `sha256`.
Unknown catalog-entry and artifact fields are rejected. The whole JSON value,
including manifest defaults, uses the existing 128-level / 100,000-value budget
and finite-number validation. The existing manifest schema, browser-only distribution policy, identity/version
and license checks apply. Source-only entries remain build-and-import examples.
No download counts, ratings, or timestamps are invented. Brand previews remain
bundled and reviewed; arbitrary author image URLs are never loaded.
The pointer contains `schemaVersion: 1`, `registryCommit`, `snapshotId`, and
`catalog`, `index`, `delivery` references, each `{path, sha256, size}`. Paths must
be `/snapshots/<snapshotId>/<name>.json`; `snapshotId` equals the raw delivery
SHA-256. The host checks the pointer before requesting its three documents,
then checks raw byte counts/hashes, JSON budgets, schemas and cross-document
identity. The three documents must all validate before replacing any cache.
Reads are anonymous HTTPS, redirect-free for the registry, bounded to 2 MiB,
and time out after 30 seconds. All entries must validate, safely clone, and generate every selection token before
atomically replacing the current snapshot. Invalid JSON/schema, network failure, and oversized responses
leave the previous good snapshot intact. Errors and Retry remain visible. The Community header also has Refresh, including
when the last refresh succeeded; it is disabled while a request is pending.
The last good catalog is retained **in memory for this application session**;
a fresh offline launch uses bundled examples. There is no new disk cache,
database migration, cloud sync record, or credential requirement.
Delivery contains `schemaVersion`, `registryCommit`, `catalogSha256`,
`indexSha256`, `sourceLockSha256`, `builderSha256` and `plugins`. Each item matches
one exact catalog `id/version/repository/commit` tuple and supplies `readme`,
`iconLight`, `iconDark`, `avatar`, `artifact`. Asset references are
`{path, sha256, size}`, with `/assets/<sha256>.md` for README and `.png`/`.webp`
for brand icons. This v1 publishing schema requires **avatar and artifact to
be null**. The client rejects unsupported delivery metadata rather than
inventing installable packages or deriving a GitHub avatar URL.
## Selection and installation
Index is validated with its pinned schema. Plugin/version identities must be
unique; each catalog entry must exactly equal the index's current, non-withdrawn,
source-only entry. Withdrawal does not disable, uninstall or delete any existing
plugin: this is catalog admission, not a remote installed-code kill switch.
Each returned entry has a host-generated `selectionToken` covering its complete
JSON contents. This is a concurrency check, **not a signature or trust proof**.
README requests must still match that selection and fetch its pinned source
commit. A changed entry requires refresh and renewed review.
Schemas are vendored under `electron/plugins/distribution/`, with provenance and
fixtures described there. All schema references resolve locally; schema validation
does not fetch GitHub or netcatty.com.
A manual install must first refresh the official catalog successfully and match
the selected token again. Failed refresh is browse-only, even if the cached
entry has an artifact. Withdrawal, version changes, permission changes, README
changes, or artifact hash changes cannot silently replace the selected package.
Archive download then uses the existing approved-host, size, exact SHA-256,
manifest and package validation pipeline. Installation leaves the plugin disabled;
existing authorization gates still apply on activation/use.
## Bounds, cache and selection
`index.json` release history and withdrawal records are not consumed by this
first client. Removing an entry does **not** disable, uninstall, update, or delete
data from an already installed plugin. There are no background code updates.
Pointer reads are bounded to 16 KiB, each JSON document to 2 MiB, and each README
to 256 KiB. Reads time out after 30 seconds. Whole JSON values retain the existing
128-level / 100,000-value / finite-number boundary. Catalogs contain at most 1,000
entries. Existing manifest identity, license and browser-only policies remain.
## Verification boundary
The host prepares private entries, private delivery resources and all public
selection tokens before one synchronous cache publication. Parse, integrity,
schema, clone, tuple or network failures preserve the last complete good snapshot.
The cache remains **in memory for this app session**; a fresh offline launch lists
bundled examples. No new disk database, sync record or credential is introduced.
A host-generated selection token binds the complete entry and its delivery item.
This prevents silent selection changes; it is **not a signature**. README requests
capture the matched static reference and verify its exact byte count and digest.
Missing/offline snapshots show an error/Retry; they never synthesize a raw GitHub URL.
README HTML and remote images remain inert in the existing safe renderer.
The current v1 distribution cannot install packages. A later publishing/client
schema revision must jointly introduce reviewed package mirroring, keep source
manifest/hash admission, and recheck the current pointer and fresh root withdrawal
ledger before any install. A historical snapshot alone cannot establish availability.
The existing lower-level package downloader accepts only official static URLs and
exact SHA-256 bytes; it does not relax those future gates or auto-enable code.
## Network inventory
| Path | Automatic source | Failure behavior |
| --- | --- | --- |
| Pointer / manual refresh | Fixed `/v1/snapshot.json` | Complete session cache, or bundled source-only entries |
| Catalog/index/delivery | Pointer-bound same-origin immutable paths | Reject partial or mixed snapshots |
| README details | Exact delivery `/assets/<hash>.md` | Visible error/Retry, no upstream fallback |
| Uninstalled brand icons | Existing reviewed bundled previews | Default icon; delivery icon metadata validated but not fetched by UI |
| Installed brand icons | Host-owned local package decoder | Default icon; no activation/network request |
| Author avatars | Not implemented; delivery v1 requires null | Existing local presentation; no avatar lookup |
| Package artifacts | Not published or admitted by delivery v1 | Source-only; no download action fabricated |
| Source/README links | Explicit user-opened external HTTPS link | May open GitHub; no background dependency |
The real PR3 bootstrap is retained as a test fixture with its original bytes and
hashes. Tests also verify its brand-icon asset bytes; this does not claim a new
remote icon renderer. The static publisher is not a proxy. Client tests do not
prove deployment, mainland reachability or successful native CDN access. Native
QA must independently verify the published endpoint and visible failure state.
Service regressions use synthetic network responses and package bytes; the
existing package manager tests cover archive admission. No third-party plugin,
real credential, real sync account, or live registry write is used. Native QA
must separately verify the visible offline/retry state and eventual real catalog
availability once the repository is published.
-40
View File
@@ -1,40 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyPluginHostEdit, normalizePluginHostDraft, pluginHostFieldDiff, pluginHostRunsSavedScripts } from './pluginHostActions';
import type { Host, Snippet } from './models';
const host: Host = { id: 'synthetic-host', label: 'Synthetic', hostname: 'host.invalid', username: 'test', os: 'linux', tags: [], password: 'synthetic-secret', startupCommand: 'synthetic-script' };
test('host action drafts reuse importer normalization and discard credential/executable fields', () => {
const created = normalizePluginHostDraft({ ...host, password: 'untrusted', protocol: 'local' } as never, 'host-owned-id');
assert.equal(created.id, 'host-owned-id');
assert.equal(created.protocol, 'ssh');
assert.equal(created.password, undefined);
assert.equal(created.startupCommand, undefined);
assert.throws(() => normalizePluginHostDraft({ hostname: 'host.invalid', port: 0 }, 'id'));
assert.throws(() => normalizePluginHostDraft({ hostname: 'host.invalid\ninvalid' }, 'id'));
assert.throws(() => normalizePluginHostDraft({ hostname: 'host.invalid other.invalid' }, 'id'));
});
test('edit draft preserves saved connection and credentials and diff includes only approved metadata', () => {
const edited = applyPluginHostEdit(host, { label: ' Renamed ', tags: ['new'], hostname: 'attacker.invalid', password: 'untrusted' } as never);
assert.equal(edited.password, host.password);
assert.equal(edited.hostname, host.hostname);
assert.equal(edited.startupCommand, host.startupCommand);
assert.throws(() => applyPluginHostEdit(host, { label: ' ' }), /required/);
assert.deepEqual(pluginHostFieldDiff(host, edited), [
{ field: 'label', before: 'Synthetic', after: 'Renamed' },
{ field: 'tags', before: [], after: ['new'] },
]);
assert.equal(JSON.stringify(pluginHostFieldDiff(undefined, normalizePluginHostDraft(host, 'id'))).includes('synthetic-secret'), false);
});
test('connection warning covers host, inherited, global and output automation without returning script content', () => {
const clean = { ...host, startupCommand: undefined };
assert.equal(pluginHostRunsSavedScripts(clean, [], []), false);
assert.equal(pluginHostRunsSavedScripts(host, [], []), true);
assert.equal(pluginHostRunsSavedScripts({ ...clean, group: 'Synthetic' }, [{ path: 'Synthetic', startupCommand: 'synthetic-group-script' }], []), true);
assert.equal(pluginHostRunsSavedScripts(clean, [], [{ id: 'global', label: 'Global', command: 'synthetic', kind: 'script', trigger: 'onConnect', targetsAllHosts: true } as Snippet]), true);
assert.equal(pluginHostRunsSavedScripts(clean, [], [{ id: 'output', label: 'Output', command: 'synthetic', kind: 'script', trigger: 'onOutput', triggerPattern: 'ready' } as Snippet]), true);
assert.equal(pluginHostRunsSavedScripts({ ...clean, outputTriggers: [{ id: 't', pattern: 'ready', scriptId: 'synthetic' }] }, [], []), true);
});
-77
View File
@@ -1,77 +0,0 @@
import type { ImporterHostDraft } from '@netcatty/plugin-contract';
import type { GroupConfig, Host, ManagedSource, Snippet } from './models';
import { applyGroupDefaults, resolveGroupDefaults } from './groupConfig';
import { hasHostConnectAutomation } from './hostConnectScripts';
import { normalizePluginImporterRecords } from './pluginImporter';
import { applyVaultHostUpdate, buildVaultHostFromDraft } from './vaultHostCreate';
import { isScriptSnippet } from './snippetScript';
import { snippetAppliesToOutputTrigger } from './snippetTargets';
// Keep the public host draft a subset of the existing typed importer. In
// particular, references to credentials, plugin configuration and executable
// settings are never accepted by this surface.
export type PluginHostDraftFields = Pick<ImporterHostDraft,
'label' | 'hostname' | 'username' | 'port' | 'group' | 'tags'>;
export type PluginHostEditFields = Pick<PluginHostDraftFields, 'label' | 'group' | 'tags'>;
export const PLUGIN_HOST_CREATE_FIELDS = ['label', 'hostname', 'username', 'port', 'group', 'tags'] as const;
export const PLUGIN_HOST_EDIT_FIELDS = ['label', 'group', 'tags'] as const;
export function normalizePluginHostDraft(draft: PluginHostDraftFields, id: string): Host {
const fields = Object.fromEntries(PLUGIN_HOST_CREATE_FIELDS.flatMap((field) => (
draft[field] === undefined ? [] : [[field, draft[field]]]
)));
// Validate before sanitization as well: an invalid address must not become
// a different, valid endpoint when the importer removes control characters.
const raw = { ...fields, hostname: draft.hostname ?? '', protocol: 'ssh' as const };
if (!buildVaultHostFromDraft(raw).ok) throw new Error('Host draft is invalid');
const normalized = normalizePluginImporterRecords([{
type: 'draft', draft: { kind: 'host', value: raw },
}]);
if (normalized.errors.length || normalized.hosts.length !== 1) {
throw new Error('Host draft is invalid');
}
const validated = buildVaultHostFromDraft(normalized.hosts[0]);
if (!validated.ok) throw new Error('Host draft is invalid');
return { ...validated.host, id };
}
export function applyPluginHostEdit(host: Host, draft: PluginHostEditFields): Host {
if (draft.label !== undefined && !draft.label.trim()) throw new Error('Host label is required');
const normalized = normalizePluginHostDraft({
label: draft.label ?? host.label,
hostname: 'draft.invalid', username: '',
group: draft.group ?? host.group, tags: draft.tags ?? host.tags,
}, host.id);
return { ...host, label: normalized.label, group: normalized.group, tags: normalized.tags };
}
export function pluginHostFieldDiff(before: Host | undefined, after: Host) {
const fields = before ? PLUGIN_HOST_EDIT_FIELDS : PLUGIN_HOST_CREATE_FIELDS;
return fields.flatMap((field) => {
const previous = before?.[field];
const value = after[field];
return JSON.stringify(previous) === JSON.stringify(value) ? [] : [{
field, before: previous ?? null, after: value ?? null,
}];
});
}
/** Managed SSH-config writes stay in the full Vault editor. Reuse its group
* ownership rules without opening or syncing any managed file here. */
export function pluginHostDraftTouchesManagedSource(host: Host, managedSources: ManagedSource[]): boolean {
if (host.managedSourceId) return true;
if (managedSources.length === 0) return false;
const reconciled = applyVaultHostUpdate([host], [], host.id, { group: host.group ?? '' }, { managedSources });
if (!reconciled.ok) throw new Error('Host draft is invalid');
return Boolean(reconciled.updatedHost.managedSourceId);
}
/** Only the host UI consumes this disclosure; script text never crosses RPC. */
export function pluginHostRunsSavedScripts(host: Host, groups: GroupConfig[], snippets: Snippet[]): boolean {
const effective = applyGroupDefaults(host, host.group ? resolveGroupDefaults(host.group, groups) : {});
return Boolean(effective.startupCommand?.trim())
|| hasHostConnectAutomation(effective, snippets)
|| snippets.some((snippet) => isScriptSnippet(snippet) && snippetAppliesToOutputTrigger(snippet, effective))
|| Boolean(effective.outputTriggers?.length);
}
-4
View File
@@ -11,9 +11,6 @@ const PERMISSION_DESCRIPTIONS = {
menus: [false, "Menu entries", "Add this plugin's actions to host menus.", "菜单入口", "将此插件的操作加入应用菜单。"],
"ui.interact": [false, "Host prompts", "Ask the host to show bounded input and choice dialogs.", "宿主交互", "请求宿主显示有界的输入和选择对话框。"],
views: [false, "Plugin panels", "Display plugin-owned panels inside the app.", "插件面板", "在应用中显示插件自己的面板。"],
"ai.invoke": [true, "Use configured AI model", "Send plugin prompts and separately approved selected text to the configured model. Provider charges may apply. Does not grant tools or host actions.", "使用已配置的 AI 模型", "向已配置的模型发送插件提示和另行批准的所选文本。模型提供商可能收费。此权限不授予工具或宿主操作权限。"],
"context.selected.list": [true, "Selected context summaries", "Ask you to share summaries of selected workspace, tab, session, host, or Note references. Does not read their text.", "所选上下文摘要", "请求你分享所选工作区、标签页、会话、主机或笔记引用的摘要。此权限不会读取其文本。"],
"context.selected.read": [true, "Read selected context", "Read text from references you explicitly select and approve. It may contain private data.", "读取所选上下文", "读取你明确选择并批准的引用中的文本,其中可能包含隐私信息。"],
"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.", "终端元数据", "读取终端和会话信息,可能暴露主机及操作活动。"],
@@ -29,7 +26,6 @@ const PERMISSION_DESCRIPTIONS = {
"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.", "网络请求", "访问已批准的网络来源。发送的数据会离开此设备。"],
"network.stream": [true, "TCP and TLS connections", "Send and receive data over TCP or TLS at approved endpoints. Data may leave this device.", "TCP 和 TLS 连接", "通过 TCP 或 TLS 与已批准的端点收发数据。数据可能离开此设备。"],
"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.", "插件秘密", "通过系统密钥存储与受控租约保存和使用此插件的秘密。"],
+1
View File
@@ -245,6 +245,7 @@ function createBridgeRegistrar(context) {
resolveBrandIcon: (pluginId) => pluginHostService?.contributionIconService?.resolveBrand(pluginId),
resolveContributionIcon: (payload) => pluginHostService?.contributionIconService?.resolve(payload),
themeService: pluginHostService?.themeService,
logService: pluginHostService?.logService,
viewHost: pluginHostService?.viewHost,
env: process.env,
isTrustedSender: createTrustedPluginBridgeSender({ devServerUrl: effectiveDevServerUrl }),
-169
View File
@@ -1,169 +0,0 @@
"use strict";
const { PluginRpcError, RPC_ERRORS } = require("../rpcRouter.cjs");
const { createDefinitionValidator } = require("../contractValidator.cjs");
const { ModelInvocationBroker } = require("./modelInvocationBroker.cjs");
const { SelectedContextAuthority } = require("../context/selectedContextAuthority.cjs");
const { PluginAISourceBinding } = require("./sourceBinding.cjs");
const fail = (code, message) => new PluginRpcError(RPC_ERRORS[code], message);
const FEATURES = Object.freeze(["ai.model-invocation", "context.selected-read"]);
const ROUTES = Object.freeze([
["ai.openChat", "PluginAIEmptyParams", "PluginAIChatResult", "ai.invoke", "open"],
["ai.startTurn", "PluginAIStartTurnParams", "PluginAITurnResult", "ai.invoke", "start"],
["ai.readTurn", "PluginAITurnRef", "PluginAIStreamEvent", "ai.invoke", "read"],
["ai.stopTurn", "PluginAITurnRef", "PluginAIBooleanResult", "ai.invoke", "stop"],
["ai.closeChat", "PluginAIChatRef", "PluginAIBooleanResult", "ai.invoke", "close"],
["context.selected.list", "PluginAIEmptyParams", "PluginSelectedContextListResult", "context.selected.list", "list"],
["context.selected.read", "PluginSelectedContextRef", "PluginSelectedContextContent", "context.selected.read", "readSelected"],
]);
class PluginAIContextService {
constructor(options) {
this.host = options.hostAdapter;
for (const method of ["selectModel", "listSelected", "reviewText", "assertActive"]) {
if (typeof this.host?.[method] !== "function") throw new TypeError(`AI host adapter ${method} required`);
}
this.source = new PluginAISourceBinding(options);
this.permissionEngine = options.permissionEngine;
this.resolveOwner = (context) => this.source.resolve(context);
this.selected = new SelectedContextAuthority({ resolveDataOwner: this.resolveOwner });
this.runtimeContexts = new Map();
this.registrations = [];
this.broker = new ModelInvocationBroker({ resolveDataOwner: this.resolveOwner,
selectModel: async (context) => {
await this.#guard(context);
const model = await this.host.selectModel(context);
await this.#guard(context);
return model;
},
contexts: {
read: (reference, context) => this.readSelected(reference, context),
assertCurrent: (reference, context) => this.selected.assertCurrent(reference, context),
onDidInvalidate: (listener) => this.selected.onDidInvalidate(listener),
releaseRuntime: (runtimeId) => this.selected.releaseRuntime(runtimeId),
releaseOwner: (owner) => this.selected.releaseOwner(owner),
},
});
this.revocation = this.permissionEngine.onDidRevoke((event) => this.releasePlugin(event.pluginId));
this.unsubscribeHost = this.host.onDidInvalidate?.(() => this.releaseAll());
}
async #guard(context) {
const owner = await this.resolveOwner(context);
await this.host.assertActive(context);
if (await this.resolveOwner(context) !== owner) throw fail("permissionDenied", "Plugin source changed");
return owner;
}
#authorization(permission, params) {
const resource = permission === "context.selected.read" ? `selected:${params.handle}:${params.version}` : "*";
return { permission, resources: [resource],
reason: permission === "ai.invoke" ? "Use the model selected in Netcatty without tools"
: permission === "context.selected.list" ? "List summaries of the current selected context"
: "Review and share this selected context version",
...(permission === "context.selected.read" ? { allowedScopes: ["once"] } : {}),
};
}
registerRpcCapabilities(registry) {
if (this.registrations.length) throw new Error("AI/context routes are already registered");
// Canonical validators are created only when transport integration registers
// this service. There is no second plugin wire schema in these modules.
const prepared = ROUTES.map(([method, paramsType, resultType, permission, operation]) => ({
method, permission, operation,
validateParams: createDefinitionValidator(paramsType),
validateResult: createDefinitionValidator(resultType),
}));
for (const route of prepared) {
this.registrations.push(registry.registerRequest(route.method, async (params, context) => {
await this.#guard(context);
const result = await this[route.operation](params, context);
await this.#guard(context);
if (!route.validateResult(result)) throw fail("internal", "AI/context response is invalid");
return result;
}, {
metadata: { capability: "plugin-ai-context", mutating: false, permission: route.permission },
validateParams: (params) => {
if (!route.validateParams(params)) throw fail("invalidArgument", "AI/context request is invalid");
return Object.freeze(structuredClone(params));
},
authorization: async (params, context) => {
await this.#guard(context);
if (route.operation === "readSelected") await this.selected.assertCurrent(params, context);
return this.#authorization(route.permission, params);
},
}));
}
}
open(_params, context) { return this.broker.open(context); }
start(params, context) { return this.broker.start(params, context); }
read({ turnHandle }, context) { return this.broker.read(turnHandle, context); }
async stop({ turnHandle }, context) { return { ok: await this.broker.stop(turnHandle, context) }; }
async close({ chatHandle }, context) { return { ok: await this.broker.close(chatHandle, context) }; }
async list(_params, context) {
const owner = await this.#guard(context);
const selections = await this.host.listSelected(context);
await this.#guard(context);
if (!Array.isArray(selections) || selections.length > 5 || new Set(selections.map((item) => item.scope)).size !== selections.length) {
throw fail("resourceExhausted", "Selected scope limit reached");
}
const previous = this.runtimeContexts.get(context.runtimeId);
if (previous?.owner === owner && previous.keys === JSON.stringify(selections.map(({ key, version }) => [key, version]))) {
return { items: await this.selected.summaries(context) };
}
this.selected.releaseRuntime(context.runtimeId);
const created = [];
try {
for (const { key, scope, title, version, readText, isCurrent } of selections) {
if (typeof key !== "string" || !key || key.length > 256) throw fail("invalidArgument", "Host selection key is invalid");
const summary = await this.selected.share({ scope, title, version, readText, isCurrent }, context);
created.push(summary.handle);
}
await this.#guard(context);
this.runtimeContexts.set(context.runtimeId, { owner, keys: JSON.stringify(selections.map(({ key, version }) => [key, version])) });
return { items: await this.selected.summaries(context) };
} catch (error) { for (const handle of created) this.selected.release(handle); throw error; }
}
async readSelected(reference, context) {
await this.#guard(context);
await this.selected.assertCurrent(reference, context);
const authorization = this.#authorization("context.selected.read", reference);
// Direct RPC reads have already passed the registry permission middleware.
// A model turn must obtain its separate content grant here.
if (context.method !== "context.selected.read" || context.authorization?.permission !== authorization.permission
|| context.authorization.resources?.[0] !== authorization.resources[0]) {
await this.permissionEngine.authorize(context, authorization);
}
await this.#guard(context);
const selected = await this.selected.read(reference, context);
await this.#guard(context);
if (!await this.host.reviewText({ scope: selected.scope, title: selected.title, version: selected.version,
text: selected.text, pluginId: context.pluginId }, context)) throw fail("cancelled", "Selected context sharing was cancelled");
await this.#guard(context);
await this.selected.assertCurrent(reference, context);
return selected;
}
releaseRuntime(runtimeId) {
this.broker.releaseRuntime(runtimeId);
this.runtimeContexts.delete(runtimeId);
this.source.releaseRuntime(runtimeId);
this.host.releaseRuntime?.(runtimeId);
}
releasePlugin(pluginId) {
for (const [runtimeId, { owner }] of [...this.runtimeContexts]) if (owner.pluginId === pluginId) this.releaseRuntime(runtimeId);
for (const binding of [...this.source.bindings.values()]) if (binding.pluginId === pluginId) this.releaseRuntime(binding.runtimeId);
this.source.releasePlugin(pluginId);
}
releaseAll() { for (const binding of [...this.source.bindings.values()]) this.releaseRuntime(binding.runtimeId); }
shutdown() {
for (const registration of this.registrations.splice(0)) registration.dispose();
this.releaseAll(); this.broker.shutdown(); this.selected.shutdown(); this.source.shutdown();
this.revocation.dispose(); this.unsubscribeHost?.(); this.host.shutdown?.();
}
}
module.exports = { PluginAIContextService, PLUGIN_AI_CONTEXT_FEATURES: FEATURES, AI_CONTEXT_ROUTES: ROUTES };
@@ -1,263 +0,0 @@
"use strict";
const { randomUUID } = require("node:crypto");
const { PluginRpcError, RPC_ERRORS } = require("../rpcRouter.cjs");
const { assertPluginJsonValue } = require("../jsonBoundary.cjs");
const fail = (code, message) => new PluginRpcError(RPC_ERRORS[code], message);
const LIMITS = Object.freeze({ chats: 32, perRuntime: 2, turnMs: 60_000, chatMs: 300_000,
inputBytes: 16_384, outputBytes: 32_768, outputTokens: 2_048, requestsPerMinute: 8,
reservedTokensPerMinute: 65_536, contexts: 4, chunkBytes: 4_096, events: 8_192, rateOwners: 128 });
function boundedLimits(overrides) {
return Object.freeze(Object.fromEntries(Object.entries(LIMITS).map(([key, max]) => {
const value = overrides[key] ?? max;
if (!Number.isSafeInteger(value) || value < 1 || value > max) throw new TypeError(`Invalid ${key} limit`);
return [key, value];
})));
}
function abortable(promise, signal) {
signal.throwIfAborted();
return new Promise((resolve, reject) => {
const abort = () => reject(signal.reason);
signal.addEventListener("abort", abort, { once: true });
Promise.resolve(promise).then(resolve, reject).finally(() => signal.removeEventListener("abort", abort));
});
}
// In-memory, source-isolated handles. No AgentRuntime, tools, chat persistence,
// provider configuration, URLs, credentials or arbitrary system prompts cross it.
class ModelInvocationBroker {
constructor({ resolveDataOwner, selectModel, contexts, clock = Date.now, limits = {} }) {
if (typeof resolveDataOwner !== "function" || typeof selectModel !== "function") throw new TypeError("Host adapters required");
this.resolveDataOwner = resolveDataOwner;
this.selectModel = selectModel;
this.contexts = contexts;
this.clock = clock;
this.limits = boundedLimits(limits);
this.chats = new Map();
this.turns = new Map();
this.rates = new Map();
this.closed = false;
this.contextSubscription = contexts?.onDidInvalidate((handle) => {
for (const turn of [...this.turns.values()]) {
if (turn.references.some((reference) => reference.handle === handle)) {
this.#end(turn, fail("cancelled", "Selected context was revoked"));
}
}
});
}
async #binding(context) {
await context.assertActive();
const owner = await this.resolveDataOwner(context);
await context.assertActive();
if (!owner || (typeof owner !== "object" && typeof owner !== "string")) throw fail("permissionDenied", "Host data owner is unavailable");
return { owner, runtimeId: context.runtimeId, pluginVersion: context.pluginVersion };
}
async #ownedChat(chat, context) {
const binding = await this.#binding(context);
if (this.closed || this.chats.get(chat.handle) !== chat
|| chat.owner !== binding.owner || chat.runtimeId !== binding.runtimeId || chat.pluginVersion !== binding.pluginVersion) {
throw fail("permissionDenied", "Plugin chat is unavailable");
}
}
async #check(chat, context) {
await this.#ownedChat(chat, context);
if (!await chat.model.isCurrent()) throw fail("permissionDenied", "Selected model changed");
const currentOwner = await this.resolveDataOwner(context);
await context.assertActive();
if (currentOwner !== chat.owner || this.closed || this.chats.get(chat.handle) !== chat || chat.expiresAt <= this.clock()) {
throw fail("permissionDenied", "Plugin chat is unavailable");
}
}
#prune() {
for (const chat of [...this.chats.values()]) if (chat.expiresAt <= this.clock()) this.#close(chat);
for (const [owner, rate] of this.rates) if (rate.startedAt + 60_000 <= this.clock()) this.rates.delete(owner);
}
async open(context) {
const binding = await this.#binding(context);
// Host chooses from configured models. Plugin parameters cannot override it.
const model = await this.selectModel(context);
await context.assertActive();
if (!model || typeof model.invoke !== "function" || typeof model.isCurrent !== "function"
|| typeof model.label !== "string" || model.label.length > 160 || !await model.isCurrent()) {
throw fail("unavailable", "Choose a configured model in the host first");
}
await context.assertActive();
this.#prune();
if (this.closed || this.chats.size >= this.limits.chats
|| [...this.chats.values()].filter((chat) => chat.runtimeId === context.runtimeId).length >= this.limits.perRuntime) {
throw fail("resourceExhausted", "Plugin chat limit reached");
}
const chat = { ...binding, handle: randomUUID(), model, expiresAt: this.clock() + this.limits.chatMs, turn: null };
this.chats.set(chat.handle, chat);
return { chatHandle: chat.handle, model: model.label, expiresAt: chat.expiresAt };
}
async start({ chatHandle, prompt, contexts = [] }, context) {
assertPluginJsonValue({ chatHandle, prompt, contexts }, { maxBytes: this.limits.inputBytes + 2048 });
if (typeof prompt !== "string" || !prompt.trim() || prompt.includes("\0") || !Array.isArray(contexts)
|| contexts.length > this.limits.contexts || Buffer.byteLength(prompt) > this.limits.inputBytes) {
throw fail("invalidArgument", "Invalid model input");
}
const references = contexts.map((reference) => {
if (!reference || Array.isArray(reference) || Object.keys(reference).some((key) => !["handle", "version"].includes(key))
|| typeof reference.handle !== "string" || !reference.handle || reference.handle.length > 128
|| typeof reference.version !== "string" || !reference.version || reference.version.length > 128) {
throw fail("invalidArgument", "Invalid context reference");
}
return Object.freeze({ handle: reference.handle, version: reference.version });
});
const chat = this.chats.get(chatHandle);
if (!chat) throw fail("permissionDenied", "Plugin chat is unavailable");
await this.#check(chat, context);
if (chat.turn) throw fail("resourceExhausted", "A turn is already active");
const controller = new AbortController();
const turn = { handle: randomUUID(), chat, controller, reading: false, bytes: 0, sequence: 0, iterator: null, references: Object.freeze(references) };
chat.turn = turn;
this.turns.set(turn.handle, turn);
const abort = () => this.#end(turn, fail("cancelled", "Model turn cancelled"));
context.signal?.addEventListener("abort", abort, { once: true });
turn.cleanup = () => context.signal?.removeEventListener("abort", abort);
turn.timer = setTimeout(() => this.#end(turn, fail("deadlineExceeded", "Model turn timed out")), this.limits.turnMs);
try {
await this.#guard(turn, context);
const selected = [];
let bytes = Buffer.byteLength(prompt);
for (const reference of references) {
const result = await abortable(this.contexts.read(reference, { ...context, signal: controller.signal }), controller.signal);
bytes += Buffer.byteLength(result.text);
if (bytes > this.limits.inputBytes) throw fail("resourceExhausted", "Model input byte budget exceeded");
selected.push(result.text);
}
await this.#guard(turn, context);
this.#prune();
const rate = this.rates.get(chat.owner) ?? { startedAt: this.clock(), count: 0, tokens: 0 };
// Reserve conservatively using UTF-8 input bytes plus the output cap. Do
// not refund unknown provider usage after cancellation or disconnect.
const reserved = bytes + 1_024 + this.limits.outputTokens;
if ((!this.rates.has(chat.owner) && this.rates.size >= this.limits.rateOwners) || rate.count >= this.limits.requestsPerMinute || rate.tokens + reserved > this.limits.reservedTokensPerMinute) {
throw fail("resourceExhausted", "Model request/token budget reached");
}
rate.count++; rate.tokens += reserved; this.rates.set(chat.owner, rate);
await this.#guard(turn, context);
const iterator = await abortable(chat.model.invoke({ prompt, contexts: selected, signal: controller.signal,
maxOutputTokens: this.limits.outputTokens, maxChunkBytes: this.limits.chunkBytes }), controller.signal);
await this.#guard(turn, context);
if (!iterator || typeof iterator.next !== "function") throw fail("internal", "Model adapter failed");
turn.iterator = iterator;
return { turnHandle: turn.handle, maxOutputTokens: this.limits.outputTokens, reservedTokens: reserved };
} catch (error) {
this.#end(turn, error);
throw this.#safe(error);
}
}
async #guard(turn, context) {
turn.controller.signal.throwIfAborted();
await abortable(this.#check(turn.chat, context), turn.controller.signal);
for (const reference of turn.references) {
await abortable(this.contexts.assertCurrent(reference, { ...context, signal: turn.controller.signal }), turn.controller.signal);
}
turn.controller.signal.throwIfAborted();
if (this.turns.get(turn.handle) !== turn || turn.chat.turn !== turn) throw fail("cancelled", "Model turn ended");
}
#safe(error) {
// SDK/provider errors may contain headers, URLs or echoed request content.
return error instanceof PluginRpcError ? error : fail("unavailable", "Model invocation failed");
}
async read(turnHandle, context) {
const turn = this.turns.get(turnHandle);
if (!turn) throw fail("permissionDenied", "Model turn is unavailable");
// Validate owner before mutating/aborting another caller's turn.
await this.#ownedChat(turn.chat, context);
if (turn.reading || !turn.iterator) throw fail("resourceExhausted", "A model read is already pending");
turn.reading = true;
const abort = () => this.#end(turn, fail("cancelled", "Model read cancelled"));
context.signal?.addEventListener("abort", abort, { once: true });
try {
if (context.signal?.aborted) abort();
await this.#guard(turn, context);
if (turn.sequence >= this.limits.events) throw fail("resourceExhausted", "Model event budget exceeded");
const next = await abortable(turn.iterator.next(), turn.controller.signal);
await this.#guard(turn, context);
if (next.done) {
this.#end(turn);
return { kind: "end", sequence: turn.sequence++ };
}
const event = next.value;
if (event?.kind === "text" && typeof event.text === "string") {
const bytes = Buffer.byteLength(event.text);
if (bytes > this.limits.chunkBytes || turn.bytes + bytes > this.limits.outputBytes) {
throw fail("resourceExhausted", "Model output byte budget exceeded");
}
turn.bytes += bytes;
return { kind: "text", sequence: turn.sequence++, text: event.text };
}
if (event?.kind === "usage") {
const usage = {};
for (const key of ["inputTokens", "outputTokens", "totalTokens"]) {
if (event[key] !== undefined) {
if (!Number.isSafeInteger(event[key]) || event[key] < 0) throw fail("internal", "Invalid model usage");
usage[key] = event[key];
}
}
if ((usage.outputTokens ?? 0) > this.limits.outputTokens) throw fail("resourceExhausted", "Model output token budget exceeded");
return { kind: "usage", sequence: turn.sequence++, ...usage };
}
// A model's tool request is data, never host authorization. This phase
// exposes no execution path, and drops raw tool payloads/reasoning/errors.
throw fail("permissionDenied", "Model tools and unsupported events are disabled");
} catch (error) {
this.#end(turn, error);
throw this.#safe(error);
} finally { turn.reading = false; context.signal?.removeEventListener("abort", abort); }
}
async stop(turnHandle, context) {
const turn = this.turns.get(turnHandle);
if (!turn) return false;
await this.#ownedChat(turn.chat, context);
this.#end(turn, fail("cancelled", "Model turn cancelled"));
return true;
}
#end(turn, reason = fail("cancelled", "Model turn ended")) {
if (this.turns.get(turn.handle) !== turn) return;
this.turns.delete(turn.handle);
if (turn.chat.turn === turn) turn.chat.turn = null;
clearTimeout(turn.timer); turn.cleanup?.(); turn.controller.abort(reason);
// Never wait for a provider which ignores cancellation. Its late next()
// result is observed by abortable() and cannot re-enter the output path.
try { Promise.resolve(turn.iterator?.return?.()).catch(() => {}); } catch {}
turn.iterator = null;
}
#close(chat) {
if (chat.turn) this.#end(chat.turn);
this.chats.delete(chat.handle);
}
async close(chatHandle, context) {
const chat = this.chats.get(chatHandle);
if (!chat) return false;
await this.#ownedChat(chat, context); this.#close(chat); return true;
}
releaseRuntime(runtimeId) {
for (const chat of [...this.chats.values()]) if (chat.runtimeId === runtimeId) this.#close(chat);
this.contexts?.releaseRuntime(runtimeId);
}
releaseOwner(owner) {
for (const chat of [...this.chats.values()]) if (chat.owner === owner) this.#close(chat);
this.contexts?.releaseOwner(owner);
}
shutdown() { this.closed = true; for (const chat of [...this.chats.values()]) this.#close(chat); this.rates.clear(); this.contextSubscription?.dispose(); }
}
module.exports = { ModelInvocationBroker, MODEL_INVOCATION_LIMITS: LIMITS };
@@ -1,211 +0,0 @@
"use strict";
const assert = require("node:assert/strict");
const test = require("node:test");
const { ModelInvocationBroker } = require("./modelInvocationBroker.cjs");
const { SelectedContextAuthority } = require("../context/selectedContextAuthority.cjs");
const { RPC_ERRORS } = require("../rpcRouter.cjs");
const deferred = () => { let resolve; const promise = new Promise((r) => { resolve = r; }); return { promise, resolve }; };
const denied = (error) => error.code === RPC_ERRORS.permissionDenied;
function fixture(options = {}) {
const owner = {}; let currentOwner = owner; let current = true; let active = true; let now = 0;
const request = new AbortController();
const context = { pluginId: "same-id", pluginVersion: "1", runtimeId: "r1", signal: request.signal,
assertActive: async () => { request.signal.throwIfAborted(); if (!active) throw Error("stopped"); } };
const calls = [];
const model = { label: "Host selected model", isCurrent: () => current,
invoke: (input) => { calls.push(input); return (async function* () { yield { kind: "text", text: "Summary" };
yield { kind: "usage", inputTokens: 7, outputTokens: 2, totalTokens: 9, secret: "DO_NOT_SERIALIZE" }; })(); }, ...options.model };
const resolveDataOwner = async () => currentOwner;
const contexts = new SelectedContextAuthority({ resolveDataOwner, clock: () => now });
const broker = new ModelInvocationBroker({ resolveDataOwner, selectModel: async () => model, contexts,
clock: () => now, limits: options.limits });
return { broker, context, contexts, calls, request, owner, model, setOwner: (value) => { currentOwner = value; },
setCurrent: (value) => { current = value; }, setActive: (value) => { active = value; }, setTime: (value) => { now = value; } };
}
test("selected synthetic Note traverses actual broker; text/usage are projected without configuration or secrets", async (t) => {
const f = fixture(); t.after(() => f.broker.shutdown());
const note = await f.contexts.share({ scope: "note", title: "Synthetic note", version: "v1",
readText: () => "Synthetic release: add retry button", isCurrent: () => true }, f.context);
assert.equal(f.calls.length, 0);
const chat = await f.broker.open(f.context);
const turn = await f.broker.start({ chatHandle: chat.chatHandle, prompt: "Summarize", contexts: [{ handle: note.handle, version: note.version }] }, f.context);
assert.deepEqual(f.calls[0].contexts, ["Synthetic release: add retry button"]);
assert.equal(f.calls[0].tools, undefined);
const events = [await f.broker.read(turn.turnHandle, f.context), await f.broker.read(turn.turnHandle, f.context), await f.broker.read(turn.turnHandle, f.context)];
assert.equal(events[0].text, "Summary");
assert.deepEqual(events[1], { kind: "usage", sequence: 1, inputTokens: 7, outputTokens: 2, totalTokens: 9 });
assert.equal(events[2].kind, "end");
assert.doesNotMatch(JSON.stringify({ chat, turn, events, note }), /apiKey|config|DO_NOT_SERIALIZE/);
});
test("same pluginId with another source owner, runtime or version cannot use handles or stop victim turns", async (t) => {
const f = fixture(); t.after(() => f.broker.shutdown());
const chat = await f.broker.open(f.context);
const turn = await f.broker.start({ chatHandle: chat.chatHandle, prompt: "Hi" }, f.context);
for (const changed of [{ runtimeId: "r2" }, { pluginVersion: "2" }]) {
await assert.rejects(f.broker.read(turn.turnHandle, { ...f.context, ...changed }), denied);
await assert.rejects(f.broker.stop(turn.turnHandle, { ...f.context, ...changed }), denied);
}
f.setOwner({});
await assert.rejects(f.broker.read(turn.turnHandle, f.context), denied);
await assert.rejects(f.broker.close(chat.chatHandle, f.context), denied);
f.setOwner(f.owner);
assert.equal((await f.broker.read(turn.turnHandle, f.context)).text, "Summary");
});
test("pending read cancels promptly and a provider's late result never escapes", async (t) => {
const pending = deferred(); let signal;
const f = fixture({ model: { invoke: (input) => { signal = input.signal; return { next: () => pending.promise }; } } });
t.after(() => f.broker.shutdown());
const chat = await f.broker.open(f.context);
const turn = await f.broker.start({ chatHandle: chat.chatHandle, prompt: "Hi" }, f.context);
const reading = f.broker.read(turn.turnHandle, f.context);
await new Promise(setImmediate);
assert.equal(await f.broker.stop(turn.turnHandle, f.context), true);
await assert.rejects(reading, (e) => e.code === RPC_ERRORS.cancelled);
assert.equal(signal.aborted, true);
pending.resolve({ value: { kind: "text", text: "Late output" } });
await assert.rejects(f.broker.read(turn.turnHandle, f.context), denied);
});
test("runtime teardown, owner revoke and request abort discard pending results", async () => {
for (const action of ["runtime", "owner", "abort"]) {
const pending = deferred();
const f = fixture({ model: { invoke: () => ({ next: () => pending.promise }) } });
const chat = await f.broker.open(f.context);
const turn = await f.broker.start({ chatHandle: chat.chatHandle, prompt: "Hi" }, f.context);
const reading = f.broker.read(turn.turnHandle, f.context);
await new Promise(setImmediate);
if (action === "runtime") f.broker.releaseRuntime("r1");
if (action === "owner") f.broker.releaseOwner(f.owner);
if (action === "abort") f.request.abort();
await assert.rejects(reading);
pending.resolve({ value: { kind: "text", text: "Late" } });
assert.equal(f.broker.turns.size, 0); f.broker.shutdown();
}
});
test("deadline also aborts providers that ignore AbortSignal", async () => {
const f = fixture({ model: { invoke: () => ({ next: () => new Promise(() => {}) }) }, limits: { turnMs: 20 } });
const chat = await f.broker.open(f.context);
const turn = await f.broker.start({ chatHandle: chat.chatHandle, prompt: "Hi" }, f.context);
await assert.rejects(f.broker.read(turn.turnHandle, f.context), (e) => e.code === RPC_ERRORS.deadlineExceeded);
assert.equal(f.broker.turns.size, 0); f.broker.shutdown();
});
test("model switch and chat expiration reject results", async () => {
for (const action of ["model", "expiry"]) {
const pending = deferred(); const f = fixture({ model: { invoke: () => ({ next: () => pending.promise }) } });
const chat = await f.broker.open(f.context);
const turn = await f.broker.start({ chatHandle: chat.chatHandle, prompt: "Hi" }, f.context);
const reading = f.broker.read(turn.turnHandle, f.context); await new Promise(setImmediate);
if (action === "model") f.setCurrent(false); else f.setTime(300_001);
pending.resolve({ value: { kind: "text", text: "Late" } });
await assert.rejects(reading, denied); f.broker.shutdown();
}
});
test("bounds cover request rate, output bytes/tokens, concurrent read and raw error serialization", async () => {
const f = fixture({ limits: { requestsPerMinute: 1 } });
const chat = await f.broker.open(f.context);
const turn = await f.broker.start({ chatHandle: chat.chatHandle, prompt: "Hi" }, f.context);
await f.broker.stop(turn.turnHandle, f.context);
await assert.rejects(f.broker.start({ chatHandle: chat.chatHandle, prompt: "Hi" }, f.context), (e) => e.code === RPC_ERRORS.resourceExhausted);
f.broker.releaseRuntime("r1");
const again = await f.broker.open(f.context);
await assert.rejects(f.broker.start({ chatHandle: again.chatHandle, prompt: "Hi" }, f.context), (e) => e.code === RPC_ERRORS.resourceExhausted);
f.broker.shutdown();
for (const event of [{ kind: "text", text: "X".repeat(4097) }, { kind: "usage", outputTokens: 2049 }, { kind: "tool-call", command: "rm -rf /" }]) {
const instance = fixture({ model: { invoke: () => (async function* () { yield event; })() } });
const selected = await instance.broker.open(instance.context);
const started = await instance.broker.start({ chatHandle: selected.chatHandle, prompt: "Hi" }, instance.context);
await assert.rejects(instance.broker.read(started.turnHandle, instance.context));
assert.equal(instance.broker.turns.size, 0); instance.broker.shutdown();
}
const broken = fixture({ model: { invoke: () => { throw Error("api-key secret request body"); } } });
const selected = await broken.broker.open(broken.context);
await assert.rejects(broken.broker.start({ chatHandle: selected.chatHandle, prompt: "Hi" }, broken.context), (e) => !/secret|api-key/.test(e.message));
broken.broker.shutdown();
});
test("revoking selected context aborts an in-flight turn; changing its version suppresses late output", async () => {
for (const action of ["revoke", "version"]) {
let current = true; const pending = deferred();
const f = fixture({ model: { invoke: () => ({ next: () => pending.promise }) } });
const note = await f.contexts.share({ scope: "note", title: "Synthetic", version: "1",
readText: () => "Synthetic", isCurrent: () => current }, f.context);
const chat = await f.broker.open(f.context);
const turn = await f.broker.start({ chatHandle: chat.chatHandle, prompt: "Hi", contexts: [{ handle: note.handle, version: note.version }] }, f.context);
const reading = f.broker.read(turn.turnHandle, f.context); await new Promise(setImmediate);
if (action === "revoke") f.contexts.release(note.handle); else current = false;
pending.resolve({ value: { kind: "text", text: "Late" } });
await assert.rejects(reading); f.broker.shutdown();
}
});
test("input, aggregate output, reserved token and concurrent read bounds", async () => {
const cases = [
{ limits: { inputBytes: 2 }, prompt: "too big", start: true },
{ limits: { reservedTokensPerMinute: 1 }, prompt: "Hi", start: true },
{ limits: { outputBytes: 2 }, prompt: "Hi", start: false },
];
for (const scenario of cases) {
const f = fixture({ limits: scenario.limits }); const chat = await f.broker.open(f.context);
const start = () => f.broker.start({ chatHandle: chat.chatHandle, prompt: scenario.prompt }, f.context);
if (scenario.start) await assert.rejects(start());
else { const turn = await start(); await assert.rejects(f.broker.read(turn.turnHandle, f.context)); }
assert.equal(f.broker.turns.size, 0); f.broker.shutdown();
}
const pending = deferred(); const f = fixture({ model: { invoke: () => ({ next: () => pending.promise }) } });
const chat = await f.broker.open(f.context); const turn = await f.broker.start({ chatHandle: chat.chatHandle, prompt: "Hi" }, f.context);
const reading = f.broker.read(turn.turnHandle, f.context); await new Promise(setImmediate);
await assert.rejects(f.broker.read(turn.turnHandle, f.context), (e) => e.code === RPC_ERRORS.resourceExhausted);
pending.resolve({ value: { kind: "text", text: "OK" } }); assert.equal((await reading).text, "OK"); f.broker.shutdown();
});
test("the owner can stop or close after a model switch; read request cancellation aborts the provider", async () => {
const f = fixture(); const chat = await f.broker.open(f.context);
const turn = await f.broker.start({ chatHandle: chat.chatHandle, prompt: "Hi" }, f.context);
f.setCurrent(false); assert.equal(await f.broker.stop(turn.turnHandle, f.context), true);
assert.equal(await f.broker.close(chat.chatHandle, f.context), true); f.broker.shutdown();
const pending = deferred(); const instance = fixture({ model: { invoke: () => ({ next: () => pending.promise }) } });
const selected = await instance.broker.open(instance.context);
const started = await instance.broker.start({ chatHandle: selected.chatHandle, prompt: "Hi" }, instance.context);
const controller = new AbortController();
const reading = instance.broker.read(started.turnHandle, { ...instance.context, signal: controller.signal });
await new Promise(setImmediate); controller.abort(); await assert.rejects(reading);
assert.equal(instance.broker.turns.size, 0); pending.resolve({ done: true }); instance.broker.shutdown();
});
test("event and handle caps prevent cheap empty streams and unbounded allocation", async () => {
const f = fixture({ limits: { perRuntime: 1, events: 1 }, model: { invoke: () => (async function* () {
yield { kind: "text", text: "" }; yield { kind: "text", text: "" };
})() } });
const chat = await f.broker.open(f.context); await assert.rejects(f.broker.open(f.context));
const turn = await f.broker.start({ chatHandle: chat.chatHandle, prompt: "Hi" }, f.context);
assert.equal((await f.broker.read(turn.turnHandle, f.context)).text, "");
await assert.rejects(f.broker.read(turn.turnHandle, f.context), (e) => e.code === RPC_ERRORS.resourceExhausted); f.broker.shutdown();
});
test("malformed context cannot publish a turn or interrupt two-handle runtime revocation", async (t) => {
const entered = deferred(); const resume = deferred(); let checks = 0;
const f = fixture({ model: { isCurrent: async () => { if (++checks === 3) { entered.resolve(); await resume.promise; } return true; } } });
t.after(() => { resume.resolve(); f.broker.shutdown(); });
const selection = { scope: "note", title: "Synthetic", version: "1", readText: () => "Synthetic", isCurrent: () => true };
await f.contexts.share(selection, f.context); await f.contexts.share(selection, f.context);
const chat = await f.broker.open(f.context);
const start = f.broker.start({ chatHandle: chat.chatHandle, prompt: "Hi", contexts: [null] }, f.context);
const outcome = start.then(() => "accepted", (error) => error);
// On the old implementation, reach the reported post-publication model guard.
// On the fixed implementation, validation rejects before that await exists.
await Promise.race([entered.promise, outcome]);
assert.doesNotThrow(() => f.contexts.releaseRuntime("r1"));
assert.equal(f.contexts.handles.size, 0);
resume.resolve();
const error = await outcome;
assert.equal(error.code, RPC_ERRORS.invalidArgument);
assert.equal(f.broker.turns.size, 0);
assert.equal(f.calls.length, 0);
f.broker.shutdown();
});
-61
View File
@@ -1,61 +0,0 @@
"use strict";
const { PluginRpcError, RPC_ERRORS } = require("../rpcRouter.cjs");
const denied = () => new PluginRpcError(RPC_ERRORS.permissionDenied, "Plugin source binding is unavailable");
// Consume the market task's host-owned API. No source or owner is read from RPC
// parameters, and a missing API/retained source fails closed.
class PluginAISourceBinding {
constructor({ database, getRuntimeIdentity }) {
for (const method of ["getActivePlugin", "getRetainedPluginSource", "privateOwner", "publicPluginId"]) {
if (typeof database?.[method] !== "function") throw new TypeError(`Source database ${method} is required`);
}
if (typeof getRuntimeIdentity !== "function") throw new TypeError("Runtime identity resolver required");
this.database = database;
this.getRuntimeIdentity = getRuntimeIdentity;
this.bindings = new Map();
}
#snapshot(context) {
const identity = this.getRuntimeIdentity(context.pluginId);
if (!identity || identity.pluginId !== context.pluginId || identity.runtimeId !== context.runtimeId
|| identity.pluginVersion !== context.pluginVersion || identity.runtimeKind !== "browser") throw denied();
const plugin = this.database.getActivePlugin(identity.pluginId);
const source = plugin?.source;
const retained = this.database.getRetainedPluginSource(identity.pluginId);
if (!plugin?.enabled || plugin.activeVersion !== identity.pluginVersion || !source || !retained
|| !["starting", "running"].includes(plugin.runtime?.status)
|| typeof source.id !== "string" || typeof source.kind !== "string"
|| source.id !== retained.id || source.kind !== retained.kind || source.origin !== retained.origin) throw denied();
const privateOwner = this.database.privateOwner(identity.pluginId);
if (typeof privateOwner !== "string" || !privateOwner || this.database.publicPluginId(privateOwner) !== identity.pluginId) throw denied();
return { runtimeId: identity.runtimeId, pluginId: identity.pluginId, pluginVersion: identity.pluginVersion,
sourceKey: JSON.stringify([source.id, source.kind, source.origin ?? null]), privateOwner };
}
async resolve(context) {
// Authenticate first. Even the database source lookup follows this guard.
await context.assertActive();
context.signal?.throwIfAborted();
const snapshot = this.#snapshot(context);
const previous = this.bindings.get(snapshot.runtimeId);
if (previous && (previous.sourceKey !== snapshot.sourceKey || previous.privateOwner !== snapshot.privateOwner
|| previous.pluginId !== snapshot.pluginId || previous.pluginVersion !== snapshot.pluginVersion)) throw denied();
await context.assertActive();
context.signal?.throwIfAborted();
const current = this.#snapshot(context);
if (current.sourceKey !== snapshot.sourceKey || current.privateOwner !== snapshot.privateOwner) throw denied();
if (previous) return previous;
const binding = Object.freeze(snapshot);
this.bindings.set(binding.runtimeId, binding);
return binding;
}
releaseRuntime(runtimeId) { this.bindings.delete(runtimeId); }
releasePlugin(pluginId) {
for (const [runtimeId, binding] of this.bindings) if (binding.pluginId === pluginId) this.bindings.delete(runtimeId);
}
shutdown() { this.bindings.clear(); }
}
module.exports = { PluginAISourceBinding };
@@ -1,41 +0,0 @@
"use strict";
const assert = require("node:assert/strict");
const test = require("node:test");
const { PluginAISourceBinding } = require("./sourceBinding.cjs");
function fixture() {
let source = { id: "source-a", kind: "third-party", origin: "https://registry.example" };
let owner = "source:opaque-a:com.example.assistant"; let active = true; let runtimeId = "r1"; let reads = 0;
const context = { pluginId: "com.example.assistant", pluginVersion: "1", runtimeId: "r1", assertActive: async () => { if (!active) throw Error("Inactive"); } };
const database = {
getActivePlugin: () => { reads++; return { source, enabled: true, activeVersion: "1", runtime: { status: "running" } }; },
getRetainedPluginSource: () => source,
privateOwner: () => owner,
publicPluginId: (value) => value === owner ? context.pluginId : null,
};
const binding = new PluginAISourceBinding({ database, getRuntimeIdentity: () => ({ ...context, runtimeId, runtimeKind: "browser" }) });
return { binding, context, database, reads: () => reads, activeTo: (v) => { active = v; }, runtimeTo: (v) => { runtimeId = v; },
sourceTo: (v) => { source = v; }, ownerTo: (v) => { owner = v; } };
}
test("host authentication precedes database access; forged runtime and missing provenance fail closed", async () => {
const f = fixture(); f.activeTo(false); await assert.rejects(f.binding.resolve(f.context)); assert.equal(f.reads(), 0);
f.activeTo(true); f.runtimeTo("different"); await assert.rejects(f.binding.resolve(f.context)); assert.equal(f.reads(), 0);
f.runtimeTo("r1"); f.database.getRetainedPluginSource = () => null; await assert.rejects(f.binding.resolve(f.context));
assert.throws(() => new PluginAISourceBinding({ database: {}, getRuntimeIdentity: () => null }));
});
test("opaque host owner remains stable and source changes cannot resume the old runtime", async () => {
const f = fixture(); const owner = await f.binding.resolve({ ...f.context, source: "attacker", privateOwner: "attacker" });
assert.equal(owner.privateOwner, "source:opaque-a:com.example.assistant");
assert.equal(await f.binding.resolve(f.context), owner);
f.sourceTo({ id: "source-b", kind: "third-party", origin: "https://other.example" });
f.ownerTo("source:opaque-b:com.example.assistant"); await assert.rejects(f.binding.resolve(f.context));
// A fresh authenticated runtime may acquire its own distinct binding.
f.binding.releaseRuntime("r1"); const next = await f.binding.resolve(f.context); assert.notEqual(next, owner);
});
test("source/owner changes during asynchronous authentication cannot commit a binding", async () => {
const f = fixture(); let checks = 0;
const context = { ...f.context, assertActive: async () => { if (++checks === 2) f.ownerTo("changed-owner"); } };
await assert.rejects(f.binding.resolve(context)); assert.equal(f.binding.bindings.size, 0);
});
+2 -2
View File
@@ -288,9 +288,9 @@ class BrowserPluginRuntime {
this.router.notify(method, params);
}
openStream(streamId, windowBytes, options) {
openStream(streamId, windowBytes) {
if (!this.router) return Promise.reject(new Error("Plugin browser runtime is not connected"));
return this.router.streams.openOutgoing(streamId, windowBytes, options);
return this.router.streams.openOutgoing(streamId, windowBytes);
}
getProcessId() {
-297
View File
@@ -1,297 +0,0 @@
"use strict";
// Host-only transport core. No protocol, credential lookup, or plugin-supplied
// Node runs here. The registered service authenticates source-bound runtimes.
const net = require("node:net");
const tls = require("node:tls");
const dns = require("node:dns/promises");
const { randomUUID } = require("node:crypto");
const { PluginRpcError, RPC_ERRORS, raceWithAbort } = require("./rpcRouter.cjs");
const LIMITS = Object.freeze(require("./generated/plugin-contract.schema.json").$defs.ByteStreamLimits.const);
const { canonicalizeStreamEndpoint, isLocalControlAddress } = require("./byteStreamEndpoint.cjs");
function failure(code, message) { return new PluginRpcError(RPC_ERRORS[code], message); }
class PluginByteStreamBroker {
constructor(options) {
this.permissionEngine = options.permissionEngine;
this.quotaManager = options.quotaManager;
this.audit = options.audit ?? (() => {});
this.lookup = options.lookup ?? dns.lookup;
this.connectSocket = options.connectSocket ?? ((params) => net.createConnection(params));
this.connectTls = options.connectTls ?? ((params) => tls.connect(params));
// Only a host test harness can opt a specific loopback endpoint in. The
// production default excludes all loopback/link-local control surfaces.
this.testLocalEndpoints = new Set(options.testLocalEndpoints ?? []);
this.isControlEndpoint = options.isControlEndpoint ?? (() => false);
this.connections = new Map();
this.closeListeners = new Set();
this.closed = false;
this.revocation = this.permissionEngine.onDidRevoke((event) => {
for (const state of this.connections.values()) {
if (state.pluginId === event.pluginId
&& (event.permission === undefined || event.permission === "network.stream")
&& (event.resource === undefined || event.resource === state.endpoint)) {
this.finish(state, failure("permissionDenied", "Byte stream permission was revoked"));
}
}
});
}
record(state, operation) {
// Audit only host-observed generic operations, never bytes or peer messages.
try { this.audit(state.pluginId, `network.stream.${operation}`, {
runtimeId: state.runtimeId, endpoint: state.endpoint,
handleId: state.id, bytesRead: state.bytesRead, bytesWritten: state.bytesWritten,
}, state); } catch { /* An audit sink cannot keep a revoked socket alive. */ }
}
finish(state, reason) {
if (state.closed) return;
state.closed = true;
clearTimeout(state.connectTimer);
clearTimeout(state.idleTimer);
clearTimeout(state.lifetimeTimer);
state.error = reason;
this.connections.delete(state.id);
state.controller.abort(reason ?? failure("cancelled", "Byte stream closed"));
state.socket?.destroy();
this.record(state, "close");
for (const listener of this.closeListeners) {
try { listener(state, reason); } catch {}
}
}
onDidClose(listener) {
this.closeListeners.add(listener);
return { dispose: () => this.closeListeners.delete(listener) };
}
touch(state) {
clearTimeout(state.idleTimer);
state.idleTimer = setTimeout(() => this.finish(state,
failure("deadlineExceeded", "Byte stream idle timeout")), LIMITS.idleMs);
state.idleTimer.unref?.();
}
async active(state, context) {
this.assertCommit(state, context);
await context.assertActive();
this.assertCommit(state, context);
}
assertCommit(state, context) {
if (this.closed || state.closed || this.connections.get(state.id) !== state) {
throw state.error ?? failure("unavailable", "Byte stream is closed");
}
state.controller.signal.throwIfAborted();
context.signal?.throwIfAborted();
context.assertCommit?.();
}
async connect(endpoint, context) {
let target;
try { target = canonicalizeStreamEndpoint(endpoint); }
catch { throw failure("invalidArgument", "Byte stream endpoint is invalid"); }
if (this.closed) throw failure("unavailable", "Byte stream broker stopped");
if (!context.runtimeId || !context.pluginId || typeof context.assertActive !== "function") {
throw failure("unauthenticated", "Byte streams require a host runtime identity");
}
const count = [...this.connections.values()].filter((state) => state.runtimeId === context.runtimeId).length;
if (count >= LIMITS.connectionsPerRuntime || this.connections.size >= LIMITS.connectionsTotal) {
throw failure("resourceExhausted", "Byte stream connection limit exceeded");
}
const state = {
...target, id: randomUUID(), pluginId: context.pluginId, runtimeId: context.runtimeId,
controller: new AbortController(), bytesRead: 0, bytesWritten: 0,
sourceOwner: context.sourceOwner, sourceId: context.sourceId,
closed: false, reading: false, writing: false, socket: null,
};
this.connections.set(state.id, state);
const onAbort = () => this.finish(state, failure("cancelled", "Byte stream connection cancelled"));
context.signal?.addEventListener("abort", onAbort, { once: true });
try {
await this.active(state, context);
this.assertCommit(state, context);
await raceWithAbort(Promise.resolve(this.permissionEngine.authorize({ ...context, signal: state.controller.signal }, {
permission: "network.stream", resources: [target.endpoint],
reason: `Open byte stream to ${target.endpoint}`,
operationId: `network.stream:${target.endpoint}`,
validateBeforeGrant: () => this.assertCommit(state, context),
})), state.controller.signal);
await this.active(state, context);
state.connectTimer = setTimeout(() => this.finish(state,
failure("deadlineExceeded", "Byte stream connect timeout")), LIMITS.connectMs);
const addresses = net.isIP(target.host)
? [{ address: target.host, family: net.isIP(target.host) }]
: await raceWithAbort(Promise.resolve(this.lookup(target.host, { all: true, verbatim: true })), state.controller.signal);
await this.active(state, context);
if (!Array.isArray(addresses) || addresses.length === 0 || addresses.length > 64) {
throw failure("unavailable", "Byte stream hostname could not be resolved");
}
// Validate the whole DNS answer and pin one literal address. No second
// lookup, redirect, proxy discovery, or reconnect can change the peer.
for (const peer of addresses) {
if (![4, 6].includes(peer.family) || net.isIP(peer.address) !== peer.family) {
throw failure("unavailable", "Byte stream resolver returned an invalid address");
}
const local = isLocalControlAddress(peer.address);
const allowedTestPeer = this.testLocalEndpoints.has(target.endpoint)
&& !this.isControlEndpoint(peer.address, target.port);
if ((local && !allowedTestPeer) || (!local && this.isControlEndpoint(peer.address, target.port))) {
throw failure("permissionDenied", "Byte streams cannot reach local or application control endpoints");
}
}
const peer = addresses[0];
const params = {
host: peer.address, port: target.port, family: peer.family,
highWaterMark: LIMITS.bufferBytes,
};
if (target.transport === "tls") {
Object.assign(params, {
rejectUnauthorized: true, minVersion: "TLSv1.2",
...(net.isIP(target.host) ? {} : { servername: target.host }),
checkServerIdentity: (_name, certificate) => tls.checkServerIdentity(target.host, certificate),
});
}
this.assertCommit(state, context);
const socket = target.transport === "tls" ? this.connectTls(params) : this.connectSocket(params);
state.socket = socket;
if (state.closed) { socket.destroy(); throw state.error ?? failure("cancelled", "Byte stream connection cancelled"); }
socket.on("error", () => this.finish(state, failure("unavailable", "Byte stream transport failed")));
// Preserve one EOF observation for a delayed consumer. This reservation
// still counts toward quotas and expires on the existing idle/life timer.
socket.on("close", () => {
if (!state.eof) this.finish(state, failure("unavailable", "Byte stream transport closed"));
});
socket.on("end", () => { state.eof = true; });
socket.pause();
await raceWithAbort(new Promise((resolve) => socket.once(
target.transport === "tls" ? "secureConnect" : "connect", resolve,
)), state.controller.signal);
await this.active(state, context);
if (target.transport === "tls" && !socket.authorized) {
throw failure("permissionDenied", "Byte stream TLS validation failed");
}
clearTimeout(state.connectTimer);
state.lifetimeTimer = setTimeout(() => this.finish(state,
failure("deadlineExceeded", "Byte stream lifetime exceeded")), LIMITS.lifetimeMs);
state.lifetimeTimer.unref?.();
this.touch(state);
this.assertCommit(state, context);
this.record(state, "open");
return Object.freeze({ handleId: state.id, endpoint: state.endpoint });
} catch (error) {
this.finish(state, error);
throw error;
} finally {
context.signal?.removeEventListener("abort", onAbort);
}
}
get(id, context) {
const state = this.connections.get(id);
if (!state || state.pluginId !== context.pluginId || state.runtimeId !== context.runtimeId
|| state.sourceOwner !== context.sourceOwner || state.sourceId !== context.sourceId) {
throw failure("permissionDenied", "Byte stream handle does not belong to this runtime");
}
return state;
}
charge(state, direction, size) {
if (state.bytesRead + state.bytesWritten + size > LIMITS.connectionBytes) {
throw failure("resourceExhausted", "Byte stream byte limit exceeded");
}
this.quotaManager?.chargeBytes(state.runtimeId, "network.stream", size);
state[direction] += size;
this.touch(state);
}
async read(id, context) {
const state = this.get(id, context);
if (state.reading) throw failure("failedPrecondition", "Byte stream has a pending read");
state.reading = true;
const onAbort = () => this.finish(state, failure("cancelled", "Byte stream read cancelled"));
context.signal?.addEventListener("abort", onAbort, { once: true });
try {
await this.active(state, context);
while (true) {
this.assertCommit(state, context);
const available = state.socket.readableLength;
const bytes = state.socket.read(Math.min(available || LIMITS.chunkBytes, LIMITS.chunkBytes));
if (bytes !== null) {
this.charge(state, "bytesRead", bytes.byteLength);
await this.active(state, context);
this.assertCommit(state, context);
return new Uint8Array(bytes);
}
if (state.eof || state.socket.readableEnded) { this.finish(state); return null; }
await raceWithAbort(new Promise((resolve) => {
const readable = () => {
state.socket.off("end", readable);
state.socket.off("readable", readable);
state.controller.signal.removeEventListener("abort", readable);
resolve();
};
state.socket.once("readable", readable);
state.socket.once("end", readable);
// Remove listeners even when cancellation wins the race.
state.controller.signal.addEventListener("abort", readable, { once: true });
}), state.controller.signal);
await this.active(state, context);
}
} catch (error) {
this.finish(state, error);
throw error;
} finally {
context.signal?.removeEventListener("abort", onAbort);
state.reading = false;
}
}
async write(id, bytes, context) {
const state = this.get(id, context);
if (!(bytes instanceof Uint8Array) || bytes.byteLength < 1 || bytes.byteLength > LIMITS.chunkBytes) {
throw failure("invalidArgument", "Byte stream chunk size is invalid");
}
if (state.writing) throw failure("resourceExhausted", "Byte stream has a pending write");
state.writing = true;
const onAbort = () => this.finish(state, failure("cancelled", "Byte stream write cancelled"));
context.signal?.addEventListener("abort", onAbort, { once: true });
try {
await this.active(state, context);
this.assertCommit(state, context);
this.charge(state, "bytesWritten", bytes.byteLength);
const owned = Buffer.from(bytes);
await raceWithAbort(new Promise((resolve, reject) => {
state.socket.write(owned, (error) => error ? reject(error) : resolve());
}), state.controller.signal);
await this.active(state, context);
} catch (error) {
this.finish(state, error);
throw error;
} finally {
context.signal?.removeEventListener("abort", onAbort);
state.writing = false;
}
}
close(id, context) { this.finish(this.get(id, context)); }
releaseRuntime(runtimeId) {
for (const state of this.connections.values()) {
if (state.runtimeId === runtimeId) this.finish(state,
failure("unavailable", "Byte stream runtime stopped"));
}
}
shutdown() {
this.closed = true;
for (const state of this.connections.values()) this.finish(state,
failure("unavailable", "Byte stream broker stopped"));
this.revocation.dispose();
}
}
module.exports = { LIMITS, PluginByteStreamBroker, canonicalizeStreamEndpoint, isLocalControlAddress };
-330
View File
@@ -1,330 +0,0 @@
"use strict";
const assert = require("node:assert/strict");
const { test } = require("node:test");
const net = require("node:net");
const tls = require("node:tls");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { execFileSync } = require("node:child_process");
const { once } = require("node:events");
const { LIMITS, PluginByteStreamBroker, canonicalizeStreamEndpoint, isLocalControlAddress } = require("./byteStreamBroker.cjs");
const { RPC_ERRORS } = require("./rpcRouter.cjs");
function context(runtimeId = "runtime-a", controller = new AbortController()) {
return { pluginId: "test.stream", runtimeId, signal: controller.signal,
assertActive: async () => controller.signal.throwIfAborted() };
}
async function server(t, listener, options) {
const sockets = new Set();
const instance = options ? tls.createServer(options, listener) : net.createServer(listener);
instance.on("connection", (socket) => {
sockets.add(socket);
socket.on("error", () => {});
socket.on("close", () => sockets.delete(socket));
});
instance.on("tlsClientError", () => {});
instance.listen(0, "127.0.0.1");
await once(instance, "listening");
t.after(async () => {
for (const socket of sockets) socket.destroy();
await new Promise((resolve) => instance.close(resolve));
});
return instance;
}
function broker(t, endpoint, options = {}) {
const audit = [];
const requests = [];
let revoke;
const instance = new PluginByteStreamBroker({
permissionEngine: {
authorize: async (_ctx, request) => requests.push(request),
onDidRevoke(listener) { revoke = listener; return { dispose() {} }; },
},
audit: (...args) => audit.push(args),
testLocalEndpoints: endpoint ? [endpoint] : [],
...options,
});
t.after(() => instance.shutdown());
return { instance, requests, audit, revoke: (event) => revoke(event) };
}
test("stream endpoint canonicalization requires one exact host, transport and port", () => {
assert.equal(canonicalizeStreamEndpoint("tls://EXAMPLE.test:443").endpoint, "tls://example.test:443");
assert.equal(canonicalizeStreamEndpoint("tcp://[0:0:0:0:0:0:0:1]:3306").endpoint, "tcp://[::1]:3306");
for (const value of ["*", "tcp://*:3306", "tcp://x:0", "tcp://x:01", "tcp://x:65536", "udp://x:22",
"tcp://x", "tcp://x:22/", "tcp://user@x:22", "tcp://127.1:22", "tcp://x:22?x", "tcp://x.:22",
"tcp://[::1%25lo]:22", "tcp://x:22#x", "tcp://-x:22"]) {
assert.throws(() => canonicalizeStreamEndpoint(value), TypeError, value);
}
});
test("local/control address classification handles mapped IPv6 and alternate literals", () => {
for (const address of ["127.0.0.1", "127.9.8.7", "0.0.0.0", "169.254.169.254", "224.0.0.1",
"::1", "::", "::ffff:127.0.0.1", "::ffff:7f00:1", "fe80::1", "ff02::1", "2002:7f00:1::"]) {
assert.equal(isLocalControlAddress(address), true, address);
}
for (const address of ["192.168.1.2", "10.0.0.8", "203.0.113.1", "2001:db8::1", "fd00::1"]) {
assert.equal(isLocalControlAddress(address), false, address);
}
});
test("real TCP transport exchanges binary bytes and records no payloads", async (t) => {
const peer = await server(t, (socket) => socket.on("data", (bytes) => socket.write(bytes)));
const endpoint = `tcp://127.0.0.1:${peer.address().port}`;
const { instance, requests, audit } = broker(t, endpoint);
const ctx = context();
const handle = await instance.connect(endpoint, ctx);
const bytes = Uint8Array.from([0, 255, 1, 254, 19]);
await instance.write(handle.handleId, bytes, ctx);
assert.deepEqual(await instance.read(handle.handleId, ctx), bytes);
instance.close(handle.handleId, ctx);
assert.equal(instance.connections.size, 0);
assert.deepEqual(requests[0].resources, [endpoint]);
assert.equal(requests[0].permission, "network.stream");
assert.deepEqual(audit.map((entry) => entry[1]), ["network.stream.open", "network.stream.close"]);
assert.equal(audit[1][2].bytesRead, bytes.byteLength);
assert.equal(audit[1][2].bytesWritten, bytes.byteLength);
assert.equal(JSON.stringify(audit).includes("data"), false);
});
test("remote EOF returns the final bytes and releases the connection", async (t) => {
const peer = await server(t, (socket) => socket.end("synthetic final bytes"));
const endpoint = `tcp://127.0.0.1:${peer.address().port}`;
const { instance } = broker(t, endpoint);
const ctx = context();
const { handleId } = await instance.connect(endpoint, ctx);
const chunks = [];
while (true) {
const chunk = await instance.read(handleId, ctx);
if (chunk === null) break;
chunks.push(Buffer.from(chunk));
}
assert.equal(Buffer.concat(chunks).toString(), "synthetic final bytes");
assert.equal(instance.connections.size, 0);
});
test("a delayed consumer can observe EOF after the real socket has closed", async (t) => {
const peer = await server(t, (socket) => socket.end("final"));
const endpoint = `tcp://127.0.0.1:${peer.address().port}`;
const { instance, revoke } = broker(t, endpoint);
const ctx = context();
const { handleId } = await instance.connect(endpoint, ctx);
const socket = instance.connections.get(handleId).socket;
const closed = once(socket, "close");
assert.equal(Buffer.from(await instance.read(handleId, ctx)).toString(), "final");
await closed;
await assert.rejects(instance.read(handleId, context("other-runtime")), { code: RPC_ERRORS.permissionDenied });
assert.equal(await instance.read(handleId, ctx), null);
assert.equal(instance.connections.size, 0);
const second = await instance.connect(endpoint, ctx);
const secondClosed = once(instance.connections.get(second.handleId).socket, "close");
await instance.read(second.handleId, ctx);
await secondClosed;
revoke({ pluginId: ctx.pluginId, permission: "network.stream", resource: endpoint });
await assert.rejects(instance.read(second.handleId, ctx), { code: RPC_ERRORS.permissionDenied });
});
test("denial happens before DNS or a socket is created", async (t) => {
let lookedUp = false;
const { instance } = broker(t, null, {
permissionEngine: { authorize: async () => { throw new Error("denied"); }, onDidRevoke: () => ({ dispose() {} }) },
lookup: () => { lookedUp = true; },
});
await assert.rejects(instance.connect("tcp://example.test:22", context()), /denied/u);
assert.equal(lookedUp, false);
assert.equal(instance.connections.size, 0);
});
test("production rejects loopback, mixed DNS answers, and app control peers", async (t) => {
const endpoint = "tcp://example.test:22";
const { instance } = broker(t, null, {
lookup: async () => [{ address: "203.0.113.1", family: 4 }, { address: "127.0.0.1", family: 4 }],
});
await assert.rejects(instance.connect(endpoint, context()), { code: RPC_ERRORS.permissionDenied });
await assert.rejects(instance.connect("tcp://127.0.0.1:22", context()), { code: RPC_ERRORS.permissionDenied });
const protectedBroker = broker(t, "tcp://127.0.0.1:22", { isControlEndpoint: () => true }).instance;
await assert.rejects(protectedBroker.connect("tcp://127.0.0.1:22", context()), { code: RPC_ERRORS.permissionDenied });
});
test("runtime handles cannot cross runtime or plugin boundaries", async (t) => {
const peer = await server(t, () => {});
const endpoint = `tcp://127.0.0.1:${peer.address().port}`;
const { instance } = broker(t, endpoint);
const ctx = context();
const { handleId } = await instance.connect(endpoint, ctx);
for (const other of [context("runtime-b"), { ...ctx, pluginId: "test.other" }]) {
await assert.rejects(instance.read(handleId, other), { code: RPC_ERRORS.permissionDenied });
assert.throws(() => instance.close(handleId, other), { code: RPC_ERRORS.permissionDenied });
}
instance.close(handleId, ctx);
await assert.rejects(instance.write(handleId, Uint8Array.of(1), ctx), { code: RPC_ERRORS.permissionDenied });
});
test("revocation and runtime release cancel pending real reads", async (t) => {
const peer = await server(t, () => {});
const endpoint = `tcp://127.0.0.1:${peer.address().port}`;
const { instance, revoke } = broker(t, endpoint);
const ctx = context();
let handle = await instance.connect(endpoint, ctx);
const reading = instance.read(handle.handleId, ctx);
await new Promise((resolve) => setImmediate(resolve));
revoke({ pluginId: ctx.pluginId, permission: "network.stream", resource: endpoint });
await assert.rejects(reading, { code: RPC_ERRORS.permissionDenied });
assert.equal(instance.connections.size, 0);
handle = await instance.connect(endpoint, ctx);
const secondRead = instance.read(handle.handleId, ctx);
instance.releaseRuntime(ctx.runtimeId);
await assert.rejects(secondRead, { code: RPC_ERRORS.unavailable });
assert.equal(instance.connections.size, 0);
});
test("cancellation during DNS rejects promptly and ignores late resolution", async (t) => {
let resolveDns;
let dialed = false;
const { instance } = broker(t, null, {
lookup: () => new Promise((resolve) => { resolveDns = resolve; }),
connectSocket: () => { dialed = true; },
});
const controller = new AbortController();
const pending = instance.connect("tcp://example.test:22", context("runtime-a", controller));
await new Promise((resolve) => setImmediate(resolve));
controller.abort();
await assert.rejects(pending, { code: RPC_ERRORS.cancelled });
resolveDns([{ address: "203.0.113.1", family: 4 }]);
await new Promise((resolve) => setImmediate(resolve));
assert.equal(dialed, false);
assert.equal(instance.connections.size, 0);
});
test("cancellation after the last async guard cannot create a late real socket", async (t) => {
const peer = await server(t, () => {});
const endpoint = `tcp://127.0.0.1:${peer.address().port}`;
let dialed = false;
const { instance } = broker(t, endpoint, { connectSocket(options) {
dialed = true;
const socket = net.createConnection(options);
t.after(() => socket.destroy());
return socket;
} });
const controller = new AbortController();
let guards = 0;
const ctx = context("runtime-a", controller);
ctx.assertActive = async () => {
controller.signal.throwIfAborted();
if (++guards === 3) queueMicrotask(() => queueMicrotask(() => controller.abort()));
};
await assert.rejects(instance.connect(endpoint, ctx), { code: RPC_ERRORS.cancelled });
assert.equal(dialed, false);
assert.equal(instance.connections.size, 0);
});
test("connect and idle deadlines close resources and reject pending operations", async (t) => {
const peer = await server(t, () => {});
const endpoint = `tcp://127.0.0.1:${peer.address().port}`;
const { instance } = broker(t, endpoint);
t.mock.timers.enable({ apis: ["setTimeout"] });
const ctx = context();
const { handleId } = await instance.connect(endpoint, ctx);
const reading = instance.read(handleId, ctx);
await new Promise((resolve) => setImmediate(resolve));
t.mock.timers.tick(LIMITS.idleMs);
await assert.rejects(reading, { code: RPC_ERRORS.deadlineExceeded });
assert.equal(instance.connections.size, 0);
let resolveDns;
let dialed = false;
const slow = broker(t, null, {
lookup: () => new Promise((resolve) => { resolveDns = resolve; }),
connectSocket: () => { dialed = true; },
}).instance;
const pending = slow.connect("tcp://example.test:22", ctx);
await new Promise((resolve) => setImmediate(resolve));
t.mock.timers.tick(LIMITS.connectMs);
await assert.rejects(pending, { code: RPC_ERRORS.deadlineExceeded });
resolveDns([{ address: "203.0.113.1", family: 4 }]);
await new Promise((resolve) => setImmediate(resolve));
assert.equal(dialed, false);
assert.equal(slow.connections.size, 0);
});
test("concurrent operations and oversized writes cannot grow an unbounded queue", async (t) => {
const peer = await server(t, () => {});
const endpoint = `tcp://127.0.0.1:${peer.address().port}`;
const { instance } = broker(t, endpoint);
const ctx = context();
const { handleId } = await instance.connect(endpoint, ctx);
const pending = instance.read(handleId, ctx);
await assert.rejects(instance.read(handleId, ctx), { code: RPC_ERRORS.failedPrecondition });
await assert.rejects(instance.write(handleId, new Uint8Array(LIMITS.chunkBytes + 1), ctx), { code: RPC_ERRORS.invalidArgument });
const writing = instance.write(handleId, Uint8Array.of(1), ctx);
await assert.rejects(instance.write(handleId, Uint8Array.of(2), ctx), { code: RPC_ERRORS.resourceExhausted });
await writing;
instance.close(handleId, ctx);
await assert.rejects(pending, { code: RPC_ERRORS.cancelled });
});
test("connection reservations bound concurrent DNS operations", async (t) => {
const { instance } = broker(t, null, { lookup: () => new Promise(() => {}) });
const ctx = context();
const pending = Array.from({ length: LIMITS.connectionsPerRuntime }, () => instance.connect("tcp://example.test:22", ctx));
const results = Promise.allSettled(pending);
await assert.rejects(instance.connect("tcp://example.test:22", ctx), { code: RPC_ERRORS.resourceExhausted });
instance.releaseRuntime(ctx.runtimeId);
assert.ok((await results).every((result) => result.status === "rejected"));
assert.equal(instance.connections.size, 0);
});
test("real unread TCP output stays bounded and byte quota closes the socket", async (t) => {
const peer = await server(t, (socket) => {
const flood = () => { while (!socket.destroyed && socket.write(Buffer.alloc(LIMITS.chunkBytes))) {} };
socket.on("drain", flood);
flood();
});
const endpoint = `tcp://127.0.0.1:${peer.address().port}`;
const { instance } = broker(t, endpoint, { quotaManager: {
chargeBytes() { throw new Error("quota reached"); },
} });
const ctx = context();
const { handleId } = await instance.connect(endpoint, ctx);
await new Promise((resolve) => setTimeout(resolve, 30));
const state = instance.connections.get(handleId);
assert.ok(state.socket.readableLength <= LIMITS.bufferBytes + LIMITS.chunkBytes);
await assert.rejects(instance.read(handleId, ctx), /quota reached/u);
assert.equal(instance.connections.size, 0);
});
test("real TLS validates trust and the original hostname against a pinned IP", async (t) => {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-stream-tls-test-"));
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
const keyPath = path.join(directory, "test.key");
const certPath = path.join(directory, "test.crt");
execFileSync("openssl", ["req", "-x509", "-newkey", "rsa:2048", "-nodes", "-keyout", keyPath,
"-out", certPath, "-days", "1", "-subj", "/CN=stream-fixture.test", "-addext", "subjectAltName=DNS:stream-fixture.test"],
{ stdio: "ignore" });
const cert = fs.readFileSync(certPath);
const peer = await server(t, (socket) => socket.on("data", (data) => socket.write(data)), {
key: fs.readFileSync(keyPath), cert,
});
const endpoint = `tls://stream-fixture.test:${peer.address().port}`;
const lookup = async () => [{ address: "127.0.0.1", family: 4 }];
const untrusted = broker(t, endpoint, { lookup }).instance;
await assert.rejects(untrusted.connect(endpoint, context()), { code: RPC_ERRORS.unavailable });
let dialOptions;
const trusted = broker(t, endpoint, { lookup,
connectTls: (options) => { dialOptions = options; return tls.connect({ ...options, ca: cert }); },
}).instance;
const ctx = context();
const { handleId } = await trusted.connect(endpoint, ctx);
assert.equal(dialOptions.host, "127.0.0.1");
assert.equal(dialOptions.servername, "stream-fixture.test");
assert.equal(dialOptions.rejectUnauthorized, true);
await trusted.write(handleId, Uint8Array.of(42), ctx);
assert.deepEqual(await trusted.read(handleId, ctx), Uint8Array.of(42));
trusted.close(handleId, ctx);
const wrongEndpoint = `tls://wrong-fixture.test:${peer.address().port}`;
const wrong = broker(t, wrongEndpoint, { lookup, connectTls: (options) => tls.connect({ ...options, ca: cert }) }).instance;
await assert.rejects(wrong.connect(wrongEndpoint, context()), { code: RPC_ERRORS.unavailable });
});
@@ -1,78 +0,0 @@
"use strict";
// Creates only an isolated disposable fixture. Never accepts a user host,
// existing database, database URL, or real credentials from the environment.
const assert = require("node:assert/strict");
const { test } = require("node:test");
const { execFileSync } = require("node:child_process");
const { randomUUID, randomBytes } = require("node:crypto");
const { pathToFileURL } = require("node:url");
const path = require("node:path");
const { PluginByteStreamBroker } = require("./byteStreamBroker.cjs");
const FIXTURE_IMAGE = "mariadb:11.4@sha256:250e1609e93c0e6aecf64bfbab2d84d5e89d2481540fe77447ff6c855c3b1873";
function docker(args, input) {
return execFileSync("docker", args, { input, encoding: "utf8", stdio: ["pipe", "pipe", "pipe"], timeout: 60_000 }).trim();
}
test("minimal browser protocol queries a disposable MariaDB through the real byte broker", {
skip: process.env.NETCATTY_BYTE_STREAM_DATABASE_TEST !== "1",
timeout: 120_000,
}, async (t) => {
const suffix = randomUUID().slice(0, 8);
const network = `netcatty-stream-test-${suffix}`;
const container = `netcatty-stream-test-${suffix}`;
const sessionPassword = randomBytes(24).toString("hex");
docker(["network", "create", "--internal", network]);
let created = false;
t.after(() => {
if (created) docker(["rm", "--force", container]);
docker(["network", "rm", network]);
});
docker(["run", "--detach", "--rm", "--name", container, "--network", network,
"--env", "MARIADB_ALLOW_EMPTY_ROOT_PASSWORD=1", FIXTURE_IMAGE]);
created = true;
let ready = false;
for (let attempt = 0; attempt < 60; attempt++) {
try { docker(["exec", container, "mariadb-admin", "--host=127.0.0.1", "ping", "--silent"]); ready = true; break; }
catch { await new Promise((resolve) => setTimeout(resolve, 500)); }
}
assert.equal(ready, true, "isolated MariaDB fixture starts");
docker(["exec", "--interactive", container, "mariadb", "--protocol=socket", "--user=root"], `
CREATE DATABASE plugin_demo;
CREATE TABLE plugin_demo.example_result (value VARCHAR(64));
INSERT INTO plugin_demo.example_result VALUES ('synthetic stream result');
CREATE USER 'plugin_demo'@'%' IDENTIFIED BY '${sessionPassword}';
GRANT SELECT ON plugin_demo.example_result TO 'plugin_demo'@'%';
`);
assert.equal(docker(["port", container]), "", "the isolated fixture publishes no host ports");
const address = docker(["inspect", "--format", "{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}", container]);
assert.match(address, /^172\.[0-9]+\.[0-9]+\.[0-9]+$/u);
const endpoint = `tcp://${address}:3306`;
const broker = new PluginByteStreamBroker({
permissionEngine: {
authorize: async (_context, request) => assert.deepEqual(request.resources, [endpoint]),
onDidRevoke: () => ({ dispose() {} }),
},
});
t.after(() => broker.shutdown());
const context = { pluginId: "com.netcatty.database-minimal", runtimeId: `fixture-${suffix}`, assertActive: async () => {} };
const { handleId } = await broker.connect(endpoint, context);
const { queryExample, READ_QUERY } = await import(pathToFileURL(path.resolve(
__dirname, "../../examples/plugins/database-minimal/protocol.mjs",
)).href);
assert.equal(READ_QUERY, "SELECT value FROM plugin_demo.example_result LIMIT 1");
const result = await queryExample({
read: () => broker.read(handleId, context),
write: (bytes) => broker.write(handleId, bytes, context),
close: () => { if (broker.connections.has(handleId)) broker.close(handleId, context); },
}, "plugin_demo", sessionPassword);
assert.deepEqual(result, { columns: ["value"], rows: [["synthetic stream result"]] });
assert.equal(broker.connections.size, 0);
// Server-side grants, not a SQL-prefix check, enforce the read-only fixture.
// MYSQL_PWD exists only inside this disposable test exec and is never logged.
assert.throws(() => execFileSync("docker", ["exec", "--interactive", container, "sh", "-c",
'read -r MYSQL_PWD; export MYSQL_PWD; exec mariadb --host=127.0.0.1 --user=plugin_demo --execute="INSERT INTO plugin_demo.example_result VALUES (1)"'],
{ input: `${sessionPassword}\n`, encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] }),
(error) => error.status === 1 && /INSERT command denied/u.test(String(error.stderr)));
});
-53
View File
@@ -1,53 +0,0 @@
"use strict";
const net = require("node:net");
function failure(_code, message) { return new TypeError(message); }
function canonicalizeStreamEndpoint(value) {
if (typeof value !== "string" || value.length > 512) {
throw failure("invalidArgument", "Byte stream endpoint is invalid");
}
// URL's permissive non-special scheme parser accepts paths, userinfo, and
// alternate IPv4 spellings. Require a complete unambiguous authority instead.
const match = /^(tcp|tls):\/\/(\[[0-9a-fA-F:.]+\]|[A-Za-z0-9.-]+):([0-9]{1,5})$/u.exec(value);
if (!match) throw failure("invalidArgument", "Byte streams require an exact TCP or TLS host and port");
let host = match[2].toLowerCase();
const port = Number(match[3]);
if (port < 1 || port > 65535 || String(port) !== match[3]) {
throw failure("invalidArgument", "Byte stream port is invalid");
}
if (host.startsWith("[")) {
host = host.slice(1, -1);
if (net.isIP(host) !== 6) throw failure("invalidArgument", "Byte stream IPv6 address is invalid");
host = new URL(`http://[${host}]`).hostname.slice(1, -1);
} else if (!net.isIP(host)) {
if (host.length > 253 || /^[0-9.]+$/u.test(host) || host.split(".").some((label) => (
!/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/u.test(label)
))) throw failure("invalidArgument", "Byte stream hostname is invalid");
}
return Object.freeze({
endpoint: `${match[1]}://${net.isIP(host) === 6 ? `[${host}]` : host}:${port}`,
transport: match[1], host, port,
});
}
function isLocalControlAddress(address) {
if (net.isIP(address) === 4) {
const [a, b] = address.split(".").map(Number);
return a === 0 || a === 127 || a >= 224 || (a === 169 && b === 254);
}
if (net.isIP(address) !== 6) return true;
const canonical = new URL(`http://[${address}]`).hostname.slice(1, -1);
if (canonical.startsWith("::ffff:")) {
const words = canonical.slice(7).split(":");
const n = (parseInt(words[0], 16) * 65536) + parseInt(words[1], 16);
return isLocalControlAddress([n >>> 24, (n >>> 16) & 255, (n >>> 8) & 255, n & 255].join("."));
}
// Also exclude compatible/tunnel addresses that can obscure a local IPv4 peer.
return canonical.startsWith("::") || canonical.startsWith("ff")
|| /^fe[89ab]/u.test(canonical) || canonical.startsWith("2002:")
|| canonical.startsWith("2001:0:");
}
module.exports = { canonicalizeStreamEndpoint, isLocalControlAddress };
@@ -1,126 +0,0 @@
"use strict";
const assert = require("node:assert/strict");
const test = require("node:test");
const fs = require("node:fs/promises");
const os = require("node:os");
const path = require("node:path");
const net = require("node:net");
const { once } = require("node:events");
const { createPluginHostService } = require("./hostService.cjs");
const { inProcessBrowserRuntime } = require("./fixtures/inProcessBrowserRuntime.cjs");
test("installed browser entrypoint uses registered SDK/RPC streams, denial and revoke", { timeout: 15_000 }, async (t) => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "netcatty-byte-entry-"));
let service;
const sockets = new Set();
const server = net.createServer((socket) => {
sockets.add(socket);
socket.on("close", () => sockets.delete(socket));
socket.on("error", () => {});
socket.on("data", (bytes) => socket.write(bytes));
});
server.listen(0, "127.0.0.1");
await once(server, "listening");
t.after(async () => {
await service?.manager.shutdown();
for (const socket of sockets) socket.destroy();
await new Promise((resolve) => server.close(resolve));
await fs.rm(root, { recursive: true, force: true });
});
const endpoint = `tcp://127.0.0.1:${server.address().port}`;
const packageRoot = path.join(root, "source");
await fs.mkdir(packageRoot);
const id = "com.netcatty.byte-entry-test";
const manifest = {
manifestVersion: 1, id, name: "byte-entry-test", version: "0.1.0", publisher: "netcatty",
engines: { netcatty: ">=0.0.0", api: ">=0.1.0-internal.1 <0.2.0" },
main: { browser: "browser.js" }, features: { required: ["network.byte-streams", "ai.model-invocation", "context.selected-read"] },
activationEvents: [`onCommand:${id}.run`],
permissions: { required: ["commands"], optional: [{ permission: "network.stream", resources: [endpoint] }, "ai.invoke", "context.selected.list", "context.selected.read"] },
contributes: { commands: ["run", "pending", "cancel", "spoof", "ai", "selected", "aiSpoof"].map((name) => ({ id: `${id}.${name}`, title: name })) },
};
await fs.writeFile(path.join(packageRoot, "netcatty.plugin.json"), JSON.stringify(manifest));
await fs.writeFile(path.join(packageRoot, "browser.js"), `
export default { activate(context) {
let connection;
for (const name of ["run", "pending", "cancel", "spoof", "ai", "selected", "aiSpoof"]) context.subscriptions.add(context.commands.registerCommand(${JSON.stringify(id)} + "." + name, async () => {
if (name === "aiSpoof") return context.ai.openChat({ sourceOwner: "spoofed" });
if (name === "selected") {
const selected = await context.selectedContext.list({});
return context.selectedContext.read({ handle: selected.items[0].handle, version: selected.items[0].version });
}
if (name === "ai") {
const chat = await context.ai.openChat({});
try {
const turn = await context.ai.startTurn({ chatHandle: chat.chatHandle, prompt: "synthetic prompt" });
const events = [];
while (true) { const event = await context.ai.readTurn({ turnHandle: turn.turnHandle }); events.push(event); if (event.kind === "end") return events; }
} finally { await context.ai.closeChat({ chatHandle: chat.chatHandle }); }
}
if (name === "cancel") { await connection?.close(); return true; }
if (name === "spoof") return context.network.connect({ endpoint: ${JSON.stringify(endpoint)}, sourceOwner: "spoofed" });
connection = await context.network.connect({ endpoint: ${JSON.stringify(endpoint)} });
if (name === "pending") return { connected: true };
try {
await connection.writable.write(Uint8Array.of(0, 255, 42));
return Array.from(await connection.readable.read());
} finally { await connection.close(); await connection.close(); }
}));
} };`);
let allow = false;
const decisions = [];
service = createPluginHostService({
app: { getAppPath: () => process.cwd(), getPath: () => root, getVersion: () => "0.0.0" },
electron: {}, env: {},
pluginAIHostAdapter: {
assertActive: (context) => context.assertActive(),
selectModel: async () => ({ label: "Synthetic fixture", isCurrent: async () => true,
invoke: async () => (async function* () { yield { kind: "text", text: "synthetic model result" }; yield { kind: "usage", inputTokens: 2, outputTokens: 3, totalTokens: 5 }; })(),
}),
listSelected: async () => [{ key: "fixture", scope: "tab", title: "Synthetic tab", version: "v1",
readText: async () => "synthetic selected text", isCurrent: async () => true }],
reviewText: async () => true,
},
requestPermissionDecision: async (request) => {
decisions.push(request);
return request.permission === "network.stream" && !allow
? { requestId: request.requestId, decision: "deny" }
: { requestId: request.requestId, decision: "allow", scope: request.permission === "context.selected.read" ? "once" : "application" };
},
});
// Explicit test-only loopback fixture injection. The production constructor
// exposes no option that forwards these overrides to the broker.
service.byteStreamService.broker.testLocalEndpoints.add(endpoint);
service.byteStreamService.broker.isControlEndpoint = () => false;
service.runtimeSupervisor.runtimeFactories.browser = inProcessBrowserRuntime;
await service.manager.initialize();
const { buildPluginPackage } = await import("@netcatty/plugin-cli");
const archive = path.join(root, "fixture.ncpkg");
await buildPluginPackage(packageRoot, archive);
await service.manager.install(archive, { enable: true });
const run = (name) => service.contributionService.executeCommand(`${id}.${name}`);
await assert.rejects(run("run"), /denied/u);
assert.equal(service.byteStreamService.broker.connections.size, 0);
allow = true;
assert.deepEqual(await run("run"), [0, 255, 42]);
assert.equal(service.byteStreamService.records.size, 0);
assert.deepEqual(decisions.find((request) => request.permission === "network.stream").resources, [endpoint]);
await assert.rejects(run("spoof"), /contract/u);
assert.deepEqual(await run("ai"), [
{ kind: "text", sequence: 0, text: "synthetic model result" },
{ kind: "usage", sequence: 1, inputTokens: 2, outputTokens: 3, totalTokens: 5 },
{ kind: "end", sequence: 2 },
]);
assert.equal((await run("selected")).text, "synthetic selected text");
await assert.rejects(run("aiSpoof"), /invalid/u);
assert.deepEqual(await run("pending"), { connected: true });
service.permissionEngine.revokeApplication(id, "network.stream", endpoint);
assert.equal(service.byteStreamService.records.size, 0);
assert.equal(service.byteStreamService.broker.connections.size, 0);
await run("cancel");
await run("pending");
await service.manager.setEnabled(id, false);
assert.equal(service.byteStreamService.broker.connections.size, 0);
assert.equal(service.byteStreamService.closedHandles.size, 0);
});
-206
View File
@@ -1,206 +0,0 @@
"use strict";
const { randomUUID } = require("node:crypto");
const os = require("node:os");
const { PluginByteStreamBroker, LIMITS } = require("./byteStreamBroker.cjs");
const { PluginRpcError, RPC_ERRORS } = require("./rpcRouter.cjs");
const { createDefinitionValidator } = require("./contractValidator.cjs");
const FEATURE = require("./generated/plugin-contract.schema.json").$defs.ByteStreamFeature.const;
function denied() { return new PluginRpcError(RPC_ERRORS.permissionDenied, "Byte stream runtime or source binding is invalid"); }
function validator(name) {
const validate = createDefinitionValidator(name);
return (params) => {
if (!validate(params)) throw new PluginRpcError(RPC_ERRORS.invalidArgument, "Byte stream parameters violate the contract");
return params;
};
}
class PluginByteStreamService {
constructor(options) {
this.database = options.database;
this.runtimeSupervisor = options.runtimeSupervisor;
this.records = new Map();
this.closedHandles = new Map();
this.closed = false;
this.broker = new PluginByteStreamBroker({
permissionEngine: options.permissionEngine,
quotaManager: options.quotaManager,
// The public host service never forwards transport fixture overrides.
isControlEndpoint: (address) => Object.values(os.networkInterfaces()).flat().some((item) => item?.address === address),
audit: (pluginId, operation, fields, state) => {
if (this.sourceMatches(state)) this.database.recordSecurityAudit(pluginId, operation, fields);
},
});
this.brokerClose = this.broker.onDidClose((state, reason) => {
if (reason) this.closeRecord(this.records.get(state.id), reason);
});
}
sourceMatches(identity) {
const active = this.database.getActivePlugin(identity.pluginId);
const retained = this.database.getRetainedPluginSource(identity.pluginId);
return Boolean(active?.enabled && identity.sourceOwner && identity.sourceId
&& active.source?.id === identity.sourceId && retained?.id === identity.sourceId
&& this.database.privateOwner(identity.pluginId) === identity.sourceOwner
&& this.database.publicPluginId(identity.sourceOwner) === identity.pluginId);
}
async authenticate(context) {
await context.assertActive();
const assertCommit = () => {
context.signal?.throwIfAborted();
const current = this.runtimeSupervisor.getRuntimeIdentity(context.pluginId);
if (this.closed || !current || current.runtimeId !== context.runtimeId
|| current.pluginVersion !== context.pluginVersion
|| current.securityPrincipal !== context.securityPrincipal
|| current.sourceOwner !== context.sourceOwner || current.sourceId !== context.sourceId
|| !this.sourceMatches(context)) throw denied();
};
assertCommit();
if (!context.manifest?.features?.required?.includes(FEATURE)) {
throw new PluginRpcError(RPC_ERRORS.unsupported, "Byte stream feature was not negotiated");
}
return Object.freeze({ ...context, assertCommit,
assertActive: async () => { await context.assertActive(); assertCommit(); },
});
}
register(registry) {
// These routes own authorization: connect authenticates before the broker
// obtains its exact endpoint grant; attach/close prove handle ownership.
// The registry public flag skips only the generic permission middleware.
// Runtime authentication and quota middleware still apply to every call.
registry.registerRequest("network.stream.connect", async ({ endpoint }, caller) => {
const context = await this.authenticate(caller);
const handle = await this.broker.connect(endpoint, context);
try {
context.assertCommit();
this.broker.get(handle.handleId, context);
const record = {
...handle, context,
inputStreamId: `byte-in-${randomUUID()}`, outputStreamId: `byte-out-${randomUUID()}`,
attached: false, closing: false, input: null, output: null,
};
this.records.set(handle.handleId, record);
record.abort = () => this.closeRecord(record);
context.signal?.addEventListener("abort", record.abort, { once: true });
return { ...handle, inputStreamId: record.inputStreamId,
outputStreamId: record.outputStreamId, windowBytes: LIMITS.bufferBytes };
} catch (error) {
const state = this.broker.connections.get(handle.handleId);
if (state) this.broker.finish(state, error);
throw error;
}
}, { validateParams: validator("ByteStreamConnectRequest"), metadata: { capability: "network.stream", mutating: true, public: true } });
registry.registerRequest("network.stream.attach", async ({ handleId }, caller) => {
const context = await this.authenticate(caller);
const record = this.get(handleId, context);
if (record.attached) throw new PluginRpcError(RPC_ERRORS.failedPrecondition, "Byte stream is already attached");
record.attached = true;
try {
record.output = await this.runtimeSupervisor.openStream(context.pluginId, record.outputStreamId, LIMITS.bufferBytes, {
expectedIdentity: context,
onClose: (reason) => { if (reason !== "end") this.closeRecord(record); },
});
context.assertCommit();
if (record.closing) { record.output.cancel(); throw new PluginRpcError(RPC_ERRORS.cancelled, "Byte stream attachment cancelled"); }
context.signal?.addEventListener("abort", record.abort, { once: true });
// A cancelled attachment cannot leave a producer running after its RPC.
void this.pump(record).finally(() => context.signal?.removeEventListener("abort", record.abort));
return { attached: true };
} catch (error) {
try { record.output?.cancel(); } catch {}
this.closeRecord(record, error);
throw error;
}
}, { validateParams: validator("ByteStreamHandleRequest"), metadata: { capability: "network.stream", mutating: true, public: true } });
registry.registerRequest("network.stream.close", async ({ handleId }, caller) => {
const context = await this.authenticate(caller);
if (!this.wasClosed(handleId, context)) this.closeRecord(this.get(handleId, context));
return null;
}, { validateParams: validator("ByteStreamHandleRequest"), metadata: { capability: "network.stream", mutating: true, public: true } });
this.streamRegistration = registry.registerIncomingStream(async (stream, caller) => {
const record = [...this.records.values()].find((item) => item.inputStreamId === stream.streamId);
if (!record) return false;
const context = await this.authenticate(caller);
this.get(record.handleId, context);
if (record.input || stream.windowBytes !== LIMITS.bufferBytes) throw denied();
record.input = stream;
stream.bind({
onChunk: async (chunk, release) => {
try {
if (chunk.encoding !== "binary" || !(chunk.bytes instanceof Uint8Array)) throw denied();
await this.broker.write(record.handleId, chunk.bytes, record.context);
record.context.assertCommit();
release();
} catch (error) { this.closeRecord(record, error); }
},
onClose: () => this.closeRecord(record),
});
return true;
});
}
get(handleId, context) {
const record = this.records.get(handleId);
if (!record || record.closing || record.context.runtimeId !== context.runtimeId
|| record.context.pluginId !== context.pluginId || record.context.sourceOwner !== context.sourceOwner
|| record.context.sourceId !== context.sourceId) throw denied();
return record;
}
wasClosed(handleId, context) {
const owner = this.closedHandles.get(handleId);
return owner && owner.runtimeId === context.runtimeId && owner.pluginId === context.pluginId
&& owner.sourceOwner === context.sourceOwner && owner.sourceId === context.sourceId;
}
async pump(record) {
try {
while (!record.closing) {
const bytes = await this.broker.read(record.handleId, record.context);
if (record.closing) return;
record.context.assertCommit();
if (bytes === null) { record.output.end(); break; }
await record.output.write(bytes);
}
this.closeRecord(record);
} catch (error) { this.closeRecord(record, error); }
}
closeRecord(record, reason) {
if (!record || record.closing) return;
record.closing = true;
record.context.signal?.removeEventListener("abort", record.abort);
this.records.delete(record.handleId);
const { pluginId, runtimeId, sourceOwner, sourceId } = record.context;
this.closedHandles.set(record.handleId, { pluginId, runtimeId, sourceOwner, sourceId });
while (this.closedHandles.size > 256) this.closedHandles.delete(this.closedHandles.keys().next().value);
const state = this.broker.connections.get(record.handleId);
if (state) this.broker.finish(state, reason);
try { record.output?.cancel(); } catch {}
try { void Promise.resolve(record.input?.cancel()).catch(() => {}); } catch {}
}
releaseRuntime(runtimeId) {
for (const record of this.records.values()) {
if (record.context.runtimeId === runtimeId) this.closeRecord(record);
}
this.broker.releaseRuntime(runtimeId);
for (const [id, owner] of this.closedHandles) {
if (owner.runtimeId === runtimeId) this.closedHandles.delete(id);
}
}
shutdown() {
this.closed = true;
for (const record of this.records.values()) this.closeRecord(record);
this.broker.shutdown();
this.brokerClose.dispose();
this.streamRegistration?.dispose();
this.closedHandles.clear();
}
}
module.exports = { PluginByteStreamService, FEATURE };
+54 -57
View File
@@ -12,29 +12,25 @@ const { assertPluginJsonValue } = require("./jsonBoundary.cjs");
const MAX_CATALOG_BYTES = 2 * 1024 * 1024;
const CATALOG_FIELDS = new Set(["id", "version", "name", "description", "author", "repository", "commit", "license", "readme", "manifest", "artifact"]);
const { OFFICIAL_SOURCE, assertSource } = require("./sourceIdentity.cjs");
const DISTRIBUTION_ORIGIN = "https://plugins.netcatty.app";
function assertDownloadUrl(value, origin = DISTRIBUTION_ORIGIN) {
if (typeof value !== "string" || /[\u0000-\u0020\u007f\\%?#]/u.test(value)) {
throw new Error("Catalog download URL is not an approved HTTPS static distribution source");
}
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.origin !== origin || url.username || url.password || url.port
|| url.href !== value || url.pathname.split("/").some((part) => part === "." || part === "..")) {
throw new Error("Catalog download URL is not an approved HTTPS static distribution source");
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 assertReadmeSource(entry) {
function pinnedReadmeUrl(entry) {
if (typeof entry.readme !== "string" || /[\u0000-\u001f\u007f\\%?#]/u.test(entry.readme)
|| entry.readme.split("/").some((segment) => !segment || segment === "." || segment === "..")) {
throw new Error("Invalid or unpinned README path");
}
if (entry.repository.split("/").some((part) => part === "." || part === "..")) {
throw new Error("README source escapes the pinned commit");
}
const prefix = `/${entry.repository}/${entry.commit}/`;
const url = new URL(`https://raw.githubusercontent.com${prefix}${entry.readme.split("/").map(encodeURIComponent).join("/")}`);
if (!url.pathname.startsWith(prefix)) throw new Error("README URL escapes the pinned commit");
return url.href;
}
function validateCatalog(entries, origin = DISTRIBUTION_ORIGIN) {
function validateCatalog(entries) {
assertPluginJsonValue(entries, { maxBytes: MAX_CATALOG_BYTES });
if (!Array.isArray(entries) || entries.length > 1000) throw new Error("Invalid plugin catalog");
const ids = new Set();
@@ -47,68 +43,71 @@ function validateCatalog(entries, origin = DISTRIBUTION_ORIGIN) {
|| entry.readme.split("/").some((p) => !p || p === "." || p === "..") || /[\\%?#\0]/u.test(entry.readme)) {
throw new Error("Invalid or unpinned plugin catalog entry");
}
assertReadmeSource(entry);
pinnedReadmeUrl(entry);
if (!validateManifest(entry.manifest)) throw new Error(`Catalog manifest is invalid: ${formatValidationErrors(validateManifest.errors)}`);
assertCatalogManifest(entry.manifest, entry);
ids.add(entry.id);
for (const field of ["artifact"]) {
if (!Object.hasOwn(entry, field)) continue;
const asset = entry[field];
if (!asset || typeof asset !== "object" || Array.isArray(asset)
|| Object.keys(asset).some((key) => key !== "url" && key !== "sha256")
|| typeof asset.url !== "string" || typeof asset.sha256 !== "string") {
throw new Error(`Invalid catalog ${field} metadata`);
if (Object.hasOwn(entry, "artifact")) {
if (!entry.artifact || typeof entry.artifact !== "object" || Array.isArray(entry.artifact)
|| Object.keys(entry.artifact).some((key) => key !== "url" && key !== "sha256")
|| typeof entry.artifact.url !== "string" || typeof entry.artifact.sha256 !== "string") {
throw new Error("Invalid catalog artifact metadata");
}
assertDownloadUrl(asset.url, origin);
if (!/^[a-f0-9]{64}$/u.test(asset.sha256)) throw new Error(`Catalog ${field} requires SHA-256`);
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, origin = DISTRIBUTION_ORIGIN) {
async function fetchBounded(url, limit, fetchImpl = fetch, maxRedirects = 4) {
const signal = AbortSignal.timeout(30_000);
const response = await fetchImpl(assertDownloadUrl(url, origin), { redirect: "manual", signal, credentials: "omit", referrerPolicy: "no-referrer" });
if ([301, 302, 303, 307, 308].includes(response.status)) {
await response.body?.cancel();
throw new Error("Catalog redirects are not allowed");
}
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));
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;
}
return Buffer.concat(chunks, size);
} finally { await reader.cancel(); }
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.marketSource = assertSource(options.source ?? OFFICIAL_SOURCE);
this.origin = this.marketSource.origin;
this.entries = structuredClone(validateCatalog(options.entries ?? (this.marketSource.id === "official" ? catalog : []), this.origin));
this.entries = structuredClone(validateCatalog(options.entries ?? catalog));
this.fetch = options.fetch ?? fetch;
this.getTempFilePath = options.getTempFilePath ?? getTempFilePath;
this.manager = options.manager;
this.sourceGuard = options.sourceGuard ?? (() => {});
this.installing = false;
}
list() { return structuredClone(this.entries); }
artifactSize() { return undefined; }
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 = pinnedReadmeUrl(entry);
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");
@@ -116,18 +115,16 @@ class PluginCatalogService {
this.installing = true;
let directory;
try {
const bytes = await fetchBounded(entry.artifact.url, 32 * 1024 * 1024, this.fetch, this.origin);
const expectedSize = this.artifactSize(entry);
if (expectedSize !== undefined && bytes.byteLength !== expectedSize) throw new Error("Catalog package size mismatch");
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, source: this.marketSource, sourceGuard: this.sourceGuard });
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, assertReadmeSource, MAX_CATALOG_BYTES, DISTRIBUTION_ORIGIN };
module.exports = { PluginCatalogService, assertDownloadUrl, fetchBounded, validateCatalog, pinnedReadmeUrl, MAX_CATALOG_BYTES };
+12 -4
View File
@@ -24,17 +24,25 @@ test("curated catalog uses pinned public first-party source and rejects advanced
assert.throws(() => validateCatalog([{ ...entries[0], commit: "main" }]), /unpinned/);
});
test("static downloader is bounded and never follows redirects", async () => {
await assert.rejects(fetchBounded("https://plugins.netcatty.app/a", 3, async () => new Response("1234")), /size limit/);
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://plugins.netcatty.app/a", 10, async () => new Response(null, { status: 302, headers: { location: "https://github.com/a" } })), /redirects/);
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://plugins.netcatty.app/packages/synthetic.ncpkg", sha256: createHash("sha256").update(bytes).digest("hex") } };
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-");
@@ -1,165 +0,0 @@
"use strict";
const { randomUUID } = require("node:crypto");
const { PluginRpcError, RPC_ERRORS } = require("../rpcRouter.cjs");
const { assertPluginJsonValue } = require("../jsonBoundary.cjs");
const fail = (code, message) => new PluginRpcError(RPC_ERRORS[code], message);
const SCOPES = new Set(["workspace", "tab", "session", "host", "note"]);
const LIMITS = Object.freeze({ handles: 128, perRuntime: 8, contentBytes: 16_384, ttlMs: 300_000, readMs: 5_000 });
// Internal host authority. Only trusted selection/review UI may call share().
// A menu id, plugin supplied entity id or global inventory is never accepted.
class SelectedContextAuthority {
constructor({ resolveDataOwner, clock = Date.now, limits = {} }) {
if (typeof resolveDataOwner !== "function") throw new TypeError("Host data-owner resolver required");
this.resolveDataOwner = resolveDataOwner;
this.clock = clock;
this.limits = Object.freeze(Object.fromEntries(Object.entries(LIMITS).map(([key, max]) => {
const value = limits[key] ?? max;
if (!Number.isSafeInteger(value) || value < 1 || value > max) throw new TypeError(`Invalid ${key} limit`);
return [key, value];
})));
this.handles = new Map();
this.closed = false;
this.listeners = new Set();
}
async binding(context) {
context.signal?.throwIfAborted();
await context.assertActive();
const owner = await this.resolveDataOwner(context);
await context.assertActive();
if (!owner || (typeof owner !== "object" && typeof owner !== "string")) {
throw fail("permissionDenied", "Host data owner is unavailable");
}
context.signal?.throwIfAborted();
return { owner, runtimeId: context.runtimeId, pluginVersion: context.pluginVersion };
}
async #check(entry, context) {
const binding = await this.binding(context);
if (this.closed || this.handles.get(entry.handle) !== entry || entry.expiresAt <= this.clock()
|| binding.owner !== entry.owner || binding.runtimeId !== entry.runtimeId
|| binding.pluginVersion !== entry.pluginVersion) throw fail("permissionDenied", "Selected context is unavailable");
context.signal?.throwIfAborted();
// The trusted reader checks the current window/resource generation and owner.
// Recheck our map after awaiting it: close/revoke/version changes can race.
const current = await entry.isCurrent();
const currentOwner = await this.resolveDataOwner(context);
await context.assertActive();
context.signal?.throwIfAborted();
if (!current || currentOwner !== entry.owner || this.handles.get(entry.handle) !== entry || this.closed || entry.expiresAt <= this.clock()) {
throw fail("permissionDenied", "Selected context changed");
}
}
#prune() {
for (const [handle, entry] of this.handles) if (entry.expiresAt <= this.clock()) this.release(handle);
}
async share(selection, context) {
if (this.closed) throw fail("unavailable", "Selected context is closed");
assertPluginJsonValue({ scope: selection.scope, title: selection.title, version: selection.version }, { maxBytes: 1024 });
if (!SCOPES.has(selection.scope) || typeof selection.title !== "string" || selection.title.length > 160
|| typeof selection.version !== "string" || !selection.version || selection.version.length > 128
|| typeof selection.readText !== "function" || typeof selection.isCurrent !== "function") {
throw fail("invalidArgument", "Invalid selected context");
}
// This API accepts a curated text reader, never a Host/Vault DTO. Passwords,
// private keys and arbitrary entity fields have no place in the descriptor.
if (Object.keys(selection).some((key) => !["scope", "title", "version", "readText", "isCurrent"].includes(key))) {
throw fail("invalidArgument", "Selected context contains unsupported fields");
}
const binding = await this.binding(context);
if (!await selection.isCurrent()) throw fail("permissionDenied", "Selection is no longer current");
await context.assertActive();
this.#prune();
if (this.closed || this.handles.size >= this.limits.handles
|| [...this.handles.values()].filter((entry) => entry.runtimeId === context.runtimeId).length >= this.limits.perRuntime) {
throw fail("resourceExhausted", "Selected context handle limit reached");
}
const entry = { ...binding, ...selection, handle: randomUUID(), expiresAt: this.clock() + this.limits.ttlMs };
this.handles.set(entry.handle, entry);
return this.#summary(entry);
}
#summary(entry) {
return { handle: entry.handle, scope: entry.scope, title: entry.title, version: entry.version, expiresAt: entry.expiresAt };
}
async summaries(context) {
const binding = await this.binding(context);
this.#prune();
const summaries = [];
for (const entry of [...this.handles.values()]) {
if (entry.owner !== binding.owner || entry.runtimeId !== binding.runtimeId || entry.pluginVersion !== binding.pluginVersion) continue;
try { await this.#check(entry, context); summaries.push(this.#summary(entry)); }
catch (error) { if (context.signal?.aborted) throw error; this.release(entry.handle); }
}
await context.assertActive();
return summaries;
}
async #read({ handle, version }, context) {
const entry = this.handles.get(handle);
if (!entry || entry.version !== version) throw fail("permissionDenied", "Selected context is unavailable");
await this.#check(entry, context);
let text;
try { text = await entry.readText({ signal: context.signal, maxBytes: this.limits.contentBytes }); }
catch { throw fail("unavailable", "Selected context reader failed"); }
await this.#check(entry, context);
if (typeof text !== "string" || text.includes("\0") || Buffer.byteLength(text) > this.limits.contentBytes) {
throw fail("resourceExhausted", "Selected context exceeds its text limit");
}
// Do not disclose private key material even if a curated text reader errs.
if (/-----BEGIN (?:[A-Z0-9]+ )*PRIVATE KEY-----/.test(text)) {
throw fail("permissionDenied", "Private key material cannot be shared");
}
return { ...this.#summary(entry), text };
}
async read(reference, context) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(fail("deadlineExceeded", "Selected context read timed out")), this.limits.readMs);
const signal = context.signal ? AbortSignal.any([context.signal, controller.signal]) : controller.signal;
let abort;
try {
signal.throwIfAborted();
return await new Promise((resolve, reject) => {
abort = () => reject(signal.reason);
signal.addEventListener("abort", abort, { once: true });
this.#read(reference, { ...context, signal }).then(resolve, reject);
});
} finally {
clearTimeout(timeout);
if (abort) signal.removeEventListener("abort", abort);
}
}
async assertCurrent({ handle, version }, context) {
const entry = this.handles.get(handle);
if (!entry || entry.version !== version) throw fail("permissionDenied", "Selected context is unavailable");
await this.#check(entry, context);
}
onDidInvalidate(listener) {
this.listeners.add(listener);
return { dispose: () => this.listeners.delete(listener) };
}
release(handle) {
if (!this.handles.delete(handle)) return;
for (const listener of [...this.listeners]) {
// Revocation must finish even when an observer fails. Never log selected
// text or let an observer exception retain another runtime handle.
try { listener(handle); } catch {}
}
}
releaseRuntime(runtimeId) {
for (const [handle, entry] of this.handles) if (entry.runtimeId === runtimeId) this.release(handle);
}
releaseOwner(owner) {
for (const [handle, entry] of this.handles) if (entry.owner === owner) this.release(handle);
}
shutdown() { this.closed = true; for (const handle of [...this.handles.keys()]) this.release(handle); this.listeners.clear(); }
}
module.exports = { SelectedContextAuthority, SELECTED_CONTEXT_LIMITS: LIMITS };
@@ -1,87 +0,0 @@
"use strict";
const assert = require("node:assert/strict");
const test = require("node:test");
const { SelectedContextAuthority } = require("./selectedContextAuthority.cjs");
const deferred = () => { let resolve; const promise = new Promise((r) => { resolve = r; }); return { promise, resolve }; };
function fixture() {
const owner = {}; let currentOwner = owner; let current = true; let version = "1"; let reads = 0; let now = 0;
const context = { pluginId: "same", pluginVersion: "1", runtimeId: "r1", assertActive: async () => {} };
const authority = new SelectedContextAuthority({ resolveDataOwner: async () => currentOwner, clock: () => now });
const selection = { scope: "note", title: "Synthetic Note", version: "1", isCurrent: () => current && version === "1",
readText: () => { reads++; return "synthetic content"; } };
return { owner, context, authority, selection, reads: () => reads, ownerTo: (v) => { currentOwner = v; },
versionTo: (v) => { version = v; }, currentTo: (v) => { current = v; }, timeTo: (v) => { now = v; } };
}
test("summaries enumerate only explicitly shared selection and never read content", async () => {
const f = fixture(); assert.deepEqual(await f.authority.summaries(f.context), []);
const note = await f.authority.share(f.selection, f.context);
assert.deepEqual(await f.authority.summaries(f.context), [note]); assert.equal(f.reads(), 0);
assert.equal((await f.authority.read(note, f.context)).text, "synthetic content");
assert.equal(f.reads(), 1);
assert.deepEqual(await f.authority.summaries({ ...f.context, runtimeId: "r2" }), []);
f.ownerTo({}); assert.deepEqual(await f.authority.summaries(f.context), []);
await assert.rejects(f.authority.read(note, f.context));
f.ownerTo(f.owner); assert.equal((await f.authority.read(note, f.context)).text, "synthetic content");
});
test("scope, owner, expected version and resource lifetime are enforced before read", async () => {
for (const action of ["version", "owner", "closed", "expiry", "release", "runtime"]) {
const f = fixture(); const note = await f.authority.share(f.selection, f.context);
if (action === "version") f.versionTo("2");
if (action === "owner") f.currentTo(false);
if (action === "closed") f.authority.shutdown();
if (action === "expiry") f.timeTo(300_001);
if (action === "release") f.authority.release(note.handle);
if (action === "runtime") f.authority.releaseRuntime("r1");
await assert.rejects(f.authority.read(note, f.context)); assert.equal(f.reads(), 0);
}
const f = fixture(); const note = await f.authority.share(f.selection, f.context);
await assert.rejects(f.authority.read({ ...note, version: "2" }, f.context));
await assert.rejects(f.authority.read({ handle: "menu-id", version: "1" }, f.context));
await assert.rejects(f.authority.share({ ...f.selection, scope: "all" }, f.context));
});
test("revocation/version/owner replacement while reader awaits suppresses late content", async () => {
for (const action of ["release", "version", "owner", "source"]) {
const f = fixture(); const pending = deferred(); let started;
const entered = new Promise((r) => { started = r; });
const note = await f.authority.share({ ...f.selection, readText: () => { started(); return pending.promise; } }, f.context);
const reading = f.authority.read(note, f.context); await entered;
if (action === "release") f.authority.release(note.handle);
if (action === "version") f.versionTo("2");
if (action === "owner") f.currentTo(false);
if (action === "source") f.ownerTo({});
pending.resolve("late secret text"); await assert.rejects(reading);
}
});
test("unknown Host fields, private keys and oversized content are excluded", async () => {
const f = fixture();
await assert.rejects(f.authority.share({ ...f.selection, password: "forbidden" }, f.context));
await assert.rejects(f.authority.share({ ...f.selection, privateKey: "forbidden" }, f.context));
for (const text of ["-----BEGIN OPENSSH PRIVATE KEY-----\nforbidden", "X".repeat(16_385)]) {
const note = await f.authority.share({ ...f.selection, readText: () => text }, f.context);
await assert.rejects(f.authority.read(note, f.context));
}
});
test("content reader has a deadline and respects request cancellation", async () => {
const owner = {}; const context = { runtimeId: "r", pluginVersion: "1", assertActive: async () => {} };
const authority = new SelectedContextAuthority({ resolveDataOwner: () => owner, limits: { readMs: 20 } });
const note = await authority.share({ scope: "note", title: "Synthetic", version: "1", isCurrent: () => true,
readText: () => new Promise(() => {}) }, context);
await assert.rejects(authority.read(note, context), /timed out/);
const controller = new AbortController(); const reading = authority.read(note, { ...context, signal: controller.signal });
controller.abort(); await assert.rejects(reading); authority.shutdown();
});
test("one failing invalidation listener cannot interrupt other listeners or handle cleanup", async () => {
const f = fixture(); const first = await f.authority.share(f.selection, f.context); const second = await f.authority.share(f.selection, f.context);
const notified = [];
f.authority.onDidInvalidate(() => { throw Error("listener failed"); });
f.authority.onDidInvalidate((handle) => notified.push(handle));
assert.doesNotThrow(() => f.authority.releaseRuntime("r1"));
assert.equal(f.authority.handles.size, 0);
assert.deepEqual(notified, [first.handle, second.handle]);
});
+9 -127
View File
@@ -5,13 +5,10 @@ const fs = require("node:fs");
const path = require("node:path");
const { DatabaseSync } = require("node:sqlite");
const { LOCAL_SOURCE, assertSource, privateOwner } = require("./sourceIdentity.cjs");
const SCHEMA_VERSION = 7;
const SCHEMA_VERSION = 4;
const MAX_SECURITY_AUDIT_DETAILS_BYTES = 16 * 1024;
const MAX_SECURITY_AUDIT_RECORDS_PER_PLUGIN = 1_000;
const REQUIRED_SCHEMA_COLUMNS = Object.freeze({
plugin_source_bindings: ["plugin_id", "source_json"],
plugin_host_preferences: ["key", "value_json", "updated_at"],
plugins: ["id", "enabled", "active_version", "installed_at", "updated_at"],
plugin_versions: ["plugin_id", "version", "manifest_json", "archive_sha256", "package_relative_path", "installed_at"],
@@ -220,46 +217,7 @@ class PluginDatabase {
CREATE TABLE plugin_host_preferences (key TEXT PRIMARY KEY, value_json TEXT NOT NULL, updated_at INTEGER NOT NULL);
PRAGMA user_version = 4;
`));
if (version <= 4) this.transaction(() => this.db.exec(`
CREATE TABLE IF NOT EXISTS plugin_source_bindings (plugin_id TEXT PRIMARY KEY, source_json TEXT NOT NULL);
`));
this.#assertSchemaLayout();
if (version <= 5) this.transaction(() => {
// Upgraded installs and retained local private records must keep their
// provenance after uninstall. Never infer official ownership from names.
const rows = this.db.prepare(`
SELECT id AS plugin_id FROM plugins
UNION SELECT plugin_id FROM plugin_settings
UNION SELECT plugin_id FROM plugin_view_state
UNION SELECT plugin_id FROM plugin_permission_grants
UNION SELECT plugin_id FROM plugin_secrets
UNION SELECT plugin_id FROM plugin_sync_sidecars
`).all();
const insert = this.db.prepare("INSERT OR IGNORE INTO plugin_source_bindings(plugin_id,source_json) VALUES(?,?)");
for (const row of rows) {
if (/^[a-z0-9][a-z0-9.-]{1,127}$/u.test(row.plugin_id)) insert.run(row.plugin_id, JSON.stringify(LOCAL_SOURCE));
}
this.db.exec("PRAGMA user_version = 6");
});
if (version <= 6) this.transaction(() => {
// One-time conversion of existing local private rows; all sources now use
// immutable encoded owners. Never retain a raw-ID lookup fallback.
for (const table of ["plugin_settings", "plugin_view_state", "plugin_permission_grants", "plugin_secrets", "plugin_sync_sidecars"]) {
const rows = this.db.prepare(`SELECT DISTINCT plugin_id FROM ${table}`).all();
for (const { plugin_id: id } of rows) {
if (/^[a-z0-9][a-z0-9.-]{1,127}$/u.test(id)) {
this.db.prepare(`UPDATE ${table} SET plugin_id = ? WHERE plugin_id = ?`).run(privateOwner(LOCAL_SOURCE, id), id);
}
}
}
for (const row of this.db.prepare("SELECT plugin_id, key FROM plugin_kv").all()) {
if (this.getPluginSource(row.plugin_id).id === "local") {
const prefix = `${privateOwner(LOCAL_SOURCE, row.plugin_id)}:`;
if (!row.key.startsWith(prefix)) this.db.prepare("UPDATE plugin_kv SET key = ? WHERE plugin_id = ? AND key = ?").run(prefix + row.key, row.plugin_id, row.key);
}
}
this.db.exec("PRAGMA user_version = 7");
});
// Legacy-map backfill is deferred until PackageStore.recover() has finished
// (hostService seeds after package initialize). Running here would promote
// sole map candidates before recovered manifests are visible, then skip
@@ -292,64 +250,15 @@ class PluginDatabase {
}
}
getPluginSource(pluginId) {
const row = this.db.prepare("SELECT source_json FROM plugin_source_bindings WHERE plugin_id = ?").get(pluginId);
return row ? assertSource(parseJson(row.source_json, "source identity")) : { ...LOCAL_SOURCE };
}
getRetainedPluginSource(pluginId) {
const row = this.db.prepare("SELECT source_json FROM plugin_source_bindings WHERE plugin_id = ?").get(pluginId);
if (row) return assertSource(parseJson(row.source_json, "source identity"));
return this.db.prepare("SELECT id FROM plugins WHERE id = ?").get(pluginId) ? { ...LOCAL_SOURCE } : null;
}
assertInstallSource(pluginId, source = LOCAL_SOURCE) {
const trusted = assertSource(source);
const row = this.db.prepare("SELECT source_json FROM plugin_source_bindings WHERE plugin_id = ?").get(pluginId);
const existing = row ? assertSource(parseJson(row.source_json, "source identity"))
: this.getActivePlugin(pluginId) ? LOCAL_SOURCE : null;
if (existing && existing.id !== trusted.id) {
throw new Error("Plugin source differs. Uninstall and explicitly clear the old source's private data before reinstalling.");
}
return trusted;
}
privateOwner(pluginId) {
// Opaque owners enter only from host sync storage, never plugin RPC identity.
if (/^source:[a-f0-9]{64}:[a-z0-9][a-z0-9.-]{1,127}$/u.test(pluginId)) return pluginId;
const source = this.getPluginSource(pluginId);
return privateOwner(source, pluginId);
}
publicPluginId(owner) {
if (!/^source:[a-f0-9]{64}:[a-z0-9][a-z0-9.-]{1,127}$/u.test(owner)) return null;
const pluginId = owner.slice(owner.lastIndexOf(":") + 1);
return this.privateOwner(pluginId) === owner ? pluginId : null;
}
clearUninstalledSourceData(pluginId, expectedSourceId) {
if (this.getActivePlugin(pluginId)) throw new Error("Uninstall the plugin before changing its source");
if (this.getPluginSource(pluginId).id !== expectedSourceId) throw new Error("Plugin source changed; review the reset again");
const owner = this.privateOwner(pluginId);
this.transaction(() => {
for (const table of ["plugin_settings", "plugin_view_state", "plugin_permission_grants", "plugin_secrets", "plugin_sync_sidecars", "plugin_sync_provider_bindings"]) {
this.db.prepare(`DELETE FROM ${table} WHERE plugin_id = ? OR plugin_id = ?`).run(owner, pluginId);
}
this.db.prepare("DELETE FROM plugin_source_bindings WHERE plugin_id = ?").run(pluginId);
});
}
installVersion(record, options = {}) {
if (options.enable === true && options.forceDisabled === true) {
throw new TypeError("Plugin version cannot be enabled and force-disabled together");
}
const source = this.assertInstallSource(record.pluginId, record.source);
const now = this.clock();
const manifestJson = JSON.stringify(record.manifest);
const requestedEnabled = options.enable === true ? 1 : 0;
const overwriteEnabled = options.enable === true || options.forceDisabled === true;
this.transaction(() => {
this.db.prepare("INSERT INTO plugin_source_bindings(plugin_id,source_json) VALUES(?,?) ON CONFLICT(plugin_id) DO UPDATE SET source_json=excluded.source_json").run(record.pluginId, JSON.stringify(source));
this.db.prepare(`
INSERT INTO plugins(id, enabled, active_version, installed_at, updated_at)
VALUES (?, ?, ?, ?, ?)
@@ -456,7 +365,6 @@ class PluginDatabase {
#mapPlugin(row) {
return {
id: row.id,
source: this.getPluginSource(row.id),
enabled: row.enabled === 1,
activeVersion: row.active_version ?? null,
installedAt: Number(row.installed_at),
@@ -590,8 +498,6 @@ class PluginDatabase {
}
getValue(pluginId, key) {
const owner = this.privateOwner(pluginId);
if (owner !== pluginId) key = `${owner}:${key}`;
const row = this.db.prepare(
"SELECT value_json FROM plugin_kv WHERE plugin_id = ? AND key = ?",
).get(pluginId, key);
@@ -599,8 +505,6 @@ class PluginDatabase {
}
setValue(pluginId, key, value) {
const owner = this.privateOwner(pluginId);
if (owner !== pluginId) key = `${owner}:${key}`;
const serialized = JSON.stringify(value);
if (serialized === undefined) throw new TypeError("Plugin storage value must be JSON serializable");
this.db.prepare(`
@@ -613,22 +517,16 @@ class PluginDatabase {
}
deleteValue(pluginId, key) {
const owner = this.privateOwner(pluginId);
if (owner !== pluginId) key = `${owner}:${key}`;
this.db.prepare("DELETE FROM plugin_kv WHERE plugin_id = ? AND key = ?").run(pluginId, key);
}
listKeys(pluginId) {
return this.db.prepare(
"SELECT key FROM plugin_kv WHERE plugin_id = ? ORDER BY key COLLATE BINARY",
).all(pluginId).map((row) => row.key).filter((key) => {
const owner = this.privateOwner(pluginId);
return owner === pluginId || key.startsWith(`${owner}:`);
}).map((key) => this.privateOwner(pluginId) === pluginId ? key : key.slice(this.privateOwner(pluginId).length + 1));
).all(pluginId).map((row) => row.key);
}
getSetting(pluginId, settingId, scope, scopeId) {
pluginId = this.privateOwner(pluginId);
const row = this.db.prepare(`
SELECT value_json FROM plugin_settings
WHERE plugin_id = ? AND setting_id = ? AND scope = ? AND scope_id = ?
@@ -637,7 +535,6 @@ class PluginDatabase {
}
setSetting(pluginId, settingId, scope, scopeId, value, updatedAt = this.clock()) {
pluginId = this.privateOwner(pluginId);
const serialized = JSON.stringify(value);
if (serialized === undefined) throw new TypeError("Plugin setting value must be JSON serializable");
const stamp = Number.isFinite(Number(updatedAt)) ? Number(updatedAt) : this.clock();
@@ -651,7 +548,6 @@ class PluginDatabase {
}
deleteSetting(pluginId, settingId, scope, scopeId) {
pluginId = this.privateOwner(pluginId);
this.db.prepare(`
DELETE FROM plugin_settings
WHERE plugin_id = ? AND setting_id = ? AND scope = ? AND scope_id = ?
@@ -659,7 +555,6 @@ class PluginDatabase {
}
listSettings(pluginId) {
pluginId = this.privateOwner(pluginId);
return this.db.prepare(`
SELECT setting_id, scope, scope_id, value_json, updated_at
FROM plugin_settings WHERE plugin_id = ?
@@ -689,7 +584,6 @@ class PluginDatabase {
}
getSyncSidecar(pluginId, kind, key) {
pluginId = this.privateOwner(pluginId);
const row = this.db.prepare(`
SELECT value_json, updated_at FROM plugin_sync_sidecars
WHERE plugin_id = ? AND kind = ? AND key = ?
@@ -705,7 +599,6 @@ class PluginDatabase {
}
setSyncSidecar(pluginId, kind, key, value, updatedAt = this.clock()) {
pluginId = this.privateOwner(pluginId);
const serialized = JSON.stringify(value);
if (serialized === undefined) throw new TypeError("Plugin sync sidecar value must be JSON serializable");
this.db.prepare(`
@@ -718,7 +611,6 @@ class PluginDatabase {
}
deleteSyncSidecar(pluginId, kind, key) {
pluginId = this.privateOwner(pluginId);
this.db.prepare(`
DELETE FROM plugin_sync_sidecars
WHERE plugin_id = ? AND kind = ? AND key = ?
@@ -726,7 +618,6 @@ class PluginDatabase {
}
listSyncSidecars(pluginId) {
pluginId = this.privateOwner(pluginId);
return this.db.prepare(`
SELECT plugin_id, kind, CAST(key AS BLOB) AS key_bytes, value_json, updated_at
FROM plugin_sync_sidecars
@@ -781,7 +672,6 @@ class PluginDatabase {
}
getViewState(pluginId, viewId, scopeId) {
pluginId = this.privateOwner(pluginId);
const row = this.db.prepare(`
SELECT state_json FROM plugin_view_state
WHERE plugin_id = ? AND view_id = ? AND scope_id = ?
@@ -790,7 +680,6 @@ class PluginDatabase {
}
setViewState(pluginId, viewId, scopeId, state) {
pluginId = this.privateOwner(pluginId);
const serialized = JSON.stringify(state);
if (serialized === undefined) throw new TypeError("Plugin view state must be JSON serializable");
this.db.prepare(`
@@ -803,7 +692,6 @@ class PluginDatabase {
}
deleteViewState(pluginId, viewId, scopeId) {
pluginId = this.privateOwner(pluginId);
this.db.prepare(`
DELETE FROM plugin_view_state
WHERE plugin_id = ? AND view_id = ? AND scope_id = ?
@@ -816,8 +704,8 @@ class PluginDatabase {
FROM plugin_permission_grants
WHERE plugin_id = ?
ORDER BY permission COLLATE BINARY, resource COLLATE BINARY
`).all(this.privateOwner(pluginId)).map((row) => ({
pluginId,
`).all(pluginId).map((row) => ({
pluginId: row.plugin_id,
permission: row.permission,
resource: row.resource,
resourceKind: row.resource_kind,
@@ -836,7 +724,7 @@ class PluginDatabase {
declaration_hash = excluded.declaration_hash,
granted_at = excluded.granted_at
`).run(
this.privateOwner(record.pluginId),
record.pluginId,
record.permission,
record.resource,
record.resourceKind,
@@ -846,7 +734,6 @@ class PluginDatabase {
}
deletePermissionGrant(pluginId, permission, resource) {
pluginId = this.privateOwner(pluginId);
this.db.prepare(`
DELETE FROM plugin_permission_grants
WHERE plugin_id = ? AND permission = ? AND resource = ?
@@ -854,12 +741,10 @@ class PluginDatabase {
}
deleteAllPermissionGrants(pluginId) {
pluginId = this.privateOwner(pluginId);
this.db.prepare("DELETE FROM plugin_permission_grants WHERE plugin_id = ?").run(pluginId);
}
getSecretByKey(pluginId, key) {
pluginId = this.privateOwner(pluginId);
const row = this.db.prepare(`
SELECT plugin_id, key, secret_ref, ciphertext, created_at, updated_at
FROM plugin_secrets WHERE plugin_id = ? AND key = ?
@@ -868,7 +753,6 @@ class PluginDatabase {
}
getSecretByRef(pluginId, secretRef) {
pluginId = this.privateOwner(pluginId);
const row = this.db.prepare(`
SELECT plugin_id, key, secret_ref, ciphertext, created_at, updated_at
FROM plugin_secrets WHERE plugin_id = ? AND secret_ref = ?
@@ -878,7 +762,7 @@ class PluginDatabase {
#mapSecret(row) {
return {
pluginId: this.publicPluginId(row.plugin_id),
pluginId: row.plugin_id,
key: row.key,
secretRef: row.secret_ref,
ciphertext: Buffer.from(row.ciphertext),
@@ -898,7 +782,7 @@ class PluginDatabase {
ciphertext = excluded.ciphertext,
updated_at = excluded.updated_at
`).run(
this.privateOwner(record.pluginId),
record.pluginId,
record.key,
record.secretRef,
record.ciphertext,
@@ -908,14 +792,12 @@ class PluginDatabase {
}
deleteSecret(pluginId, key) {
pluginId = this.privateOwner(pluginId);
this.db.prepare("DELETE FROM plugin_secrets WHERE plugin_id = ? AND key = ?")
.run(pluginId, key);
}
/** Delete all secrets whose key equals prefix or starts with `${prefix}:`. */
deleteSecretsByKeyPrefix(pluginId, prefix) {
pluginId = this.privateOwner(pluginId);
const result = this.db.prepare(`
DELETE FROM plugin_secrets
WHERE plugin_id = ?
@@ -975,7 +857,7 @@ class PluginDatabase {
SELECT DISTINCT plugin_id
FROM plugin_secrets
WHERE key = ? OR key LIKE ? ESCAPE '\\'
`).all(prefix, `${escaped}:%`).map((row) => this.publicPluginId(row.plugin_id)).filter((id) => id !== null);
`).all(prefix, `${escaped}:%`).map((row) => row.plugin_id);
}
/**
@@ -1008,7 +890,7 @@ class PluginDatabase {
/** @type {Map<string, Array<{ pluginId: string, key: string }>>} */
const byProvider = new Map();
for (const row of legacyRows) {
const pluginId = this.publicPluginId(row.plugin_id);
const pluginId = row.plugin_id;
const key = row.key;
if (typeof pluginId !== "string" || typeof key !== "string") continue;
if (!key.startsWith(legacyPrefix)) continue;
+13 -14
View File
@@ -6,7 +6,6 @@ const os = require("node:os");
const path = require("node:path");
const test = require("node:test");
const { DatabaseSync } = require("node:sqlite");
const { privateOwner, LOCAL_SOURCE } = require("./sourceIdentity.cjs");
const {
MAX_SECURITY_AUDIT_DETAILS_BYTES,
@@ -489,7 +488,7 @@ test("schema upgrade backfills bindings from legacy sync-provider-map secrets",
INSERT INTO plugin_secrets(plugin_id, key, secret_ref, ciphertext, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?)
`).run(
privateOwner(LOCAL_SOURCE, "com.example"),
"com.example",
"sync-provider-map:com.example.sync",
"ref-legacy-map",
Buffer.from("sealed"),
@@ -500,7 +499,7 @@ test("schema upgrade backfills bindings from legacy sync-provider-map secrets",
INSERT INTO plugin_secrets(plugin_id, key, secret_ref, ciphertext, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?)
`).run(
privateOwner(LOCAL_SOURCE, "com.example"),
"com.example",
"sync-credential",
"ref-cred",
Buffer.from("sealed-cred"),
@@ -533,7 +532,7 @@ test("legacy map backfill does not overwrite an existing host binding", (context
INSERT INTO plugin_secrets(plugin_id, key, secret_ref, ciphertext, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?)
`).run(
privateOwner(LOCAL_SOURCE, "com.example"),
"com.example",
"sync-provider-map:com.example.sync.foo",
"ref-stale-parent-map",
Buffer.from("sealed"),
@@ -567,7 +566,7 @@ test("legacy map backfill skips when no candidate holds sync credentials", (cont
database.db.prepare(`
INSERT INTO plugin_secrets(plugin_id, key, secret_ref, ciphertext, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?)
`).run(privateOwner(LOCAL_SOURCE, pluginId), key, `ref-${pluginId}`, Buffer.from("sealed"), now, now);
`).run(pluginId, key, `ref-${pluginId}`, Buffer.from("sealed"), now, now);
}
const promoted = database.backfillSyncProviderBindingsFromLegacySecrets();
assert.equal(promoted, 0, "map-only candidates must not bind without credentials");
@@ -590,7 +589,7 @@ test("legacy map backfill binds sole credential-backed owner when maps conflict"
database.db.prepare(`
INSERT INTO plugin_secrets(plugin_id, key, secret_ref, ciphertext, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?)
`).run(privateOwner(LOCAL_SOURCE, pluginId), key, `ref-${pluginId}-${key}`, Buffer.from(ciphertext), now, now);
`).run(pluginId, key, `ref-${pluginId}-${key}`, Buffer.from(ciphertext), now, now);
}
const promoted = database.backfillSyncProviderBindingsFromLegacySecrets();
assert.equal(promoted, 1);
@@ -619,7 +618,7 @@ test("legacy map backfill does not let longest map override credential-backed pa
database.db.prepare(`
INSERT INTO plugin_secrets(plugin_id, key, secret_ref, ciphertext, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?)
`).run(privateOwner(LOCAL_SOURCE, pluginId), key, `ref-${pluginId}-${key}`, Buffer.from(ciphertext), now, now);
`).run(pluginId, key, `ref-${pluginId}-${key}`, Buffer.from(ciphertext), now, now);
}
const promoted = database.backfillSyncProviderBindingsFromLegacySecrets();
assert.equal(promoted, 1);
@@ -649,7 +648,7 @@ test("legacy map backfill skips when multiple credential-backed candidates exist
database.db.prepare(`
INSERT INTO plugin_secrets(plugin_id, key, secret_ref, ciphertext, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?)
`).run(privateOwner(LOCAL_SOURCE, pluginId), key, `ref-${pluginId}-${key}`, Buffer.from(ciphertext), now, now);
`).run(pluginId, key, `ref-${pluginId}-${key}`, Buffer.from(ciphertext), now, now);
}
const promoted = database.backfillSyncProviderBindingsFromLegacySecrets();
assert.equal(promoted, 0, "ambiguous credential-backed candidates must not auto-bind");
@@ -673,7 +672,7 @@ test("legacy map backfill still consumes maps after unbind tombstone blocks prom
INSERT INTO plugin_secrets(plugin_id, key, secret_ref, ciphertext, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?)
`).run(
privateOwner(LOCAL_SOURCE, "com.example"),
"com.example",
"sync-provider-map:com.example.sync.foo",
"ref-tombstone-map",
Buffer.from("sealed"),
@@ -684,7 +683,7 @@ test("legacy map backfill still consumes maps after unbind tombstone blocks prom
INSERT INTO plugin_secrets(plugin_id, key, secret_ref, ciphertext, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?)
`).run(
privateOwner(LOCAL_SOURCE, "com.example"),
"com.example",
"sync-credential",
"ref-cred",
Buffer.from("secret"),
@@ -712,7 +711,7 @@ test("legacy map backfill deletes promoted map markers so unbind cannot resurrec
INSERT INTO plugin_secrets(plugin_id, key, secret_ref, ciphertext, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?)
`).run(
privateOwner(LOCAL_SOURCE, "com.example"),
"com.example",
"sync-provider-map:com.example.custom",
"ref-plugin-owned",
Buffer.from("plugin-owned-payload"),
@@ -724,7 +723,7 @@ test("legacy map backfill deletes promoted map markers so unbind cannot resurrec
INSERT INTO plugin_secrets(plugin_id, key, secret_ref, ciphertext, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?)
`).run(
privateOwner(LOCAL_SOURCE, "com.example"),
"com.example",
"sync-credential",
"ref-cred-keep",
Buffer.from("cred"),
@@ -751,7 +750,7 @@ test("legacy map backfill does not resurrect after explicit unbind tombstone", (
INSERT INTO plugin_secrets(plugin_id, key, secret_ref, ciphertext, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?)
`).run(
privateOwner(LOCAL_SOURCE, "com.example"),
"com.example",
"sync-provider-map:com.example.sync",
"ref-leftover-map",
Buffer.from("sealed"),
@@ -824,7 +823,7 @@ test("inferPluginIdForSyncProvider never guesses from credential key prefixes",
database.db.prepare(`
INSERT INTO plugin_secrets(plugin_id, key, secret_ref, ciphertext, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?)
`).run(privateOwner(LOCAL_SOURCE, pluginId), key, `ref-${pluginId}`, Buffer.from("x"), now, now);
`).run(pluginId, key, `ref-${pluginId}`, Buffer.from("x"), now, now);
}
// Parent prefix alone is not enough: a removed plugin may have owned
// com.example.backup.sync while only com.example still has credentials.
-16
View File
@@ -1,16 +0,0 @@
# Pinned static distribution schemas
`delivery.schema.json` and `index.schema.json` are unmodified UTF-8 files from
binaricat/netcatty-plugins commit 7bfe23e5627e16ba63806de6dded5b2667c02984
(PR 3). The host compiles them with the existing local plugin-contract schema;
no schema reference causes network access.
`../fixtures/static-distribution/` contains that commit's actual
`distribution/bootstrap/v1/snapshot.json`, snapshot catalog/index/delivery and
four referenced README/PNG assets. Raw-byte SHA-256 checks in
`staticDistribution.test.cjs` verify the publisher's supplied pointer and delivery
hashes; every document and asset is checked against the pointer/sidecar.
The fixture includes no executable plugin package, avatar or credentials.
Schema revisions must be coordinated with the registry publisher. In particular,
artifact/avatar are deliberately null in v1; do not expand this consumer alone.
@@ -1,166 +0,0 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "Netcatty static delivery v1 (source-only bootstrap)",
"type": "object",
"additionalProperties": false,
"required": [
"schemaVersion",
"registryCommit",
"plugins",
"catalogSha256",
"indexSha256",
"sourceLockSha256",
"builderSha256"
],
"properties": {
"schemaVersion": {
"const": 1
},
"registryCommit": {
"type": "string",
"pattern": "^[a-f0-9]{40}$"
},
"plugins": {
"type": "array",
"maxItems": 1000,
"items": {
"type": "object",
"additionalProperties": false,
"required": [
"id",
"version",
"repository",
"commit",
"readme",
"iconLight",
"iconDark",
"avatar",
"artifact"
],
"properties": {
"id": {
"type": "string"
},
"version": {
"type": "string"
},
"repository": {
"type": "string"
},
"commit": {
"type": "string",
"pattern": "^[a-f0-9]{40}$"
},
"readme": {
"type": "object",
"additionalProperties": false,
"required": [
"path",
"sha256",
"size"
],
"properties": {
"path": {
"type": "string",
"pattern": "^/assets/[a-f0-9]{64}\\.(md|png|webp|ncpkg)$"
},
"sha256": {
"type": "string",
"pattern": "^[a-f0-9]{64}$"
},
"size": {
"type": "integer",
"minimum": 0,
"maximum": 262144
}
}
},
"iconLight": {
"oneOf": [
{
"type": "null"
},
{
"type": "object",
"additionalProperties": false,
"required": [
"path",
"sha256",
"size"
],
"properties": {
"path": {
"type": "string",
"pattern": "^/assets/[a-f0-9]{64}\\.(md|png|webp|ncpkg)$"
},
"sha256": {
"type": "string",
"pattern": "^[a-f0-9]{64}$"
},
"size": {
"type": "integer",
"minimum": 0,
"maximum": 262144
}
}
}
]
},
"iconDark": {
"oneOf": [
{
"type": "null"
},
{
"type": "object",
"additionalProperties": false,
"required": [
"path",
"sha256",
"size"
],
"properties": {
"path": {
"type": "string",
"pattern": "^/assets/[a-f0-9]{64}\\.(md|png|webp|ncpkg)$"
},
"sha256": {
"type": "string",
"pattern": "^[a-f0-9]{64}$"
},
"size": {
"type": "integer",
"minimum": 0,
"maximum": 262144
}
}
}
]
},
"avatar": {
"type": "null"
},
"artifact": {
"type": "null"
}
}
}
},
"catalogSha256": {
"type": "string",
"pattern": "^[a-f0-9]{64}$"
},
"indexSha256": {
"type": "string",
"pattern": "^[a-f0-9]{64}$"
},
"sourceLockSha256": {
"type": "string",
"pattern": "^[a-f0-9]{64}$"
},
"builderSha256": {
"type": "string",
"pattern": "^[a-f0-9]{64}$"
}
}
}
@@ -1,194 +0,0 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"additionalProperties": false,
"required": [
"schemaVersion",
"plugins"
],
"properties": {
"schemaVersion": {
"const": 1
},
"plugins": {
"type": "array",
"maxItems": 1000,
"items": {
"type": "object",
"additionalProperties": false,
"required": [
"id",
"current",
"versions"
],
"properties": {
"id": {
"type": "string",
"minLength": 1,
"maxLength": 4096,
"pattern": "^[a-z0-9][a-z0-9.-]+$"
},
"current": {
"oneOf": [
{
"type": "string",
"minLength": 1,
"maxLength": 4096,
"pattern": "^(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)(-[0-9A-Za-z.-]+)?(\\+[0-9A-Za-z.-]+)?$"
},
{
"type": "null"
}
]
},
"versions": {
"type": "array",
"minItems": 1,
"maxItems": 1000,
"items": {
"type": "object",
"additionalProperties": false,
"required": [
"entry",
"distribution",
"withdrawal"
],
"properties": {
"entry": {
"type": "object",
"additionalProperties": false,
"required": [
"id",
"version",
"name",
"description",
"author",
"repository",
"commit",
"license",
"readme",
"manifest"
],
"properties": {
"id": {
"type": "string",
"minLength": 1,
"maxLength": 4096,
"pattern": "^[a-z0-9][a-z0-9.-]+$"
},
"version": {
"type": "string",
"minLength": 1,
"maxLength": 4096,
"pattern": "^(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)(-[0-9A-Za-z.-]+)?(\\+[0-9A-Za-z.-]+)?$"
},
"name": {
"type": "string",
"minLength": 1,
"maxLength": 4096
},
"description": {
"type": "string",
"minLength": 1,
"maxLength": 4096
},
"author": {
"type": "string",
"minLength": 1,
"maxLength": 4096
},
"repository": {
"type": "string",
"minLength": 1,
"maxLength": 4096,
"pattern": "^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$"
},
"commit": {
"type": "string",
"minLength": 1,
"maxLength": 4096,
"pattern": "^[a-f0-9]{40}$"
},
"license": {
"type": "string",
"minLength": 1,
"maxLength": 4096
},
"readme": {
"type": "string",
"minLength": 1,
"maxLength": 4096
},
"manifest": {
"$ref": "https://netcatty.com/schemas/plugins/0.1.0-internal.1/plugin-contract.schema.json"
},
"artifact": {
"type": "object",
"additionalProperties": false,
"required": [
"url",
"sha256"
],
"properties": {
"url": {
"type": "string",
"minLength": 1,
"maxLength": 4096
},
"sha256": {
"type": "string",
"pattern": "^[a-f0-9]{64}$"
}
}
}
}
},
"distribution": {
"enum": [
"source-only",
"release"
]
},
"tag": {
"type": "string",
"pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$"
},
"withdrawal": {
"oneOf": [
{
"type": "null"
},
{
"type": "object",
"additionalProperties": false,
"required": [
"at",
"reason",
"advisory"
],
"properties": {
"at": {
"type": "string",
"format": "date-time"
},
"reason": {
"type": "string",
"minLength": 1,
"maxLength": 4096
},
"advisory": {
"type": "string",
"pattern": "^https://github.com/"
}
}
}
]
}
}
}
}
}
}
}
}
}
-122
View File
@@ -1,122 +0,0 @@
"use strict";
const { createHash } = require("node:crypto");
const { isDeepStrictEqual } = require("node:util");
const Ajv2020 = require("ajv/dist/2020").default;
const addFormats = require("ajv-formats").default;
const { assertPluginJsonValue } = require("./jsonBoundary.cjs");
const { fetchBounded, validateCatalog, DISTRIBUTION_ORIGIN, MAX_CATALOG_BYTES } = require("./catalogService.cjs");
const { formatValidationErrors } = require("./contractValidator.cjs");
const REGISTRY_URL = `${DISTRIBUTION_ORIGIN}/v1/snapshot.json`;
const SHA256 = /^[a-f0-9]{64}$/u;
const COMMIT = /^[a-f0-9]{40}$/u;
const ajv = new Ajv2020({ allErrors: true, strict: false, validateFormats: true });
addFormats(ajv);
ajv.addSchema(require("./generated/plugin-contract.schema.json"));
const validateIndex = ajv.compile(require("./distribution/index.schema.json"));
const deliverySchema = require("./distribution/delivery.schema.json");
const validateDelivery = ajv.compile(deliverySchema);
const deliveryV2 = structuredClone(deliverySchema);
deliveryV2.title = "Netcatty static delivery v2 (hash-bound packages)";
deliveryV2.properties.schemaVersion.const = 2;
const packageRef = structuredClone(deliveryV2.properties.plugins.items.properties.readme);
packageRef.properties.path.pattern = "^/assets/[a-f0-9]{64}\\.ncpkg$";
packageRef.properties.size.maximum = 32 * 1024 * 1024;
deliveryV2.properties.plugins.items.properties.artifact = { oneOf: [{ type: "null" }, packageRef] };
const validateDeliveryV2 = ajv.compile(deliveryV2);
const digest = (bytes) => createHash("sha256").update(bytes).digest("hex");
const tuple = (entry) => JSON.stringify([entry.id, entry.version, entry.repository, entry.commit]);
function exactKeys(value, keys) {
return value && typeof value === "object" && !Array.isArray(value)
&& Object.keys(value).length === keys.length && keys.every((key) => Object.hasOwn(value, key));
}
function assertReference(ref, path, limit) {
if (!exactKeys(ref, ["path", "sha256", "size"]) || typeof ref.sha256 !== "string" || !SHA256.test(ref.sha256)
|| ref.path !== path || !Number.isSafeInteger(ref.size) || ref.size < 0 || ref.size > limit) {
throw new Error("Invalid static snapshot resource reference");
}
}
function parseJson(bytes, label, limit = MAX_CATALOG_BYTES) {
const value = JSON.parse(bytes.toString("utf8"));
assertPluginJsonValue(value, { maxBytes: limit });
if (!value || typeof value !== "object") throw new Error(`Invalid ${label}`);
return value;
}
function assertPointer(pointer) {
if (!exactKeys(pointer, ["schemaVersion", "registryCommit", "snapshotId", "catalog", "index", "delivery"])
|| pointer.schemaVersion !== 1 || typeof pointer.registryCommit !== "string" || !COMMIT.test(pointer.registryCommit)
|| typeof pointer.snapshotId !== "string" || !SHA256.test(pointer.snapshotId)) {
throw new Error("Invalid static snapshot pointer");
}
for (const name of ["catalog", "index", "delivery"]) {
assertReference(pointer[name], `/snapshots/${pointer.snapshotId}/${name}.json`, MAX_CATALOG_BYTES);
}
if (pointer.snapshotId !== pointer.delivery.sha256) throw new Error("Snapshot identity does not match delivery hash");
}
function assertAsset(ref, extensions) {
if (!ref || typeof ref.path !== "string" || !extensions.some((extension) => ref.path === `/assets/${ref.sha256}.${extension}`)) {
throw new Error("Invalid static asset path or format");
}
assertReference(ref, ref.path, 256 * 1024);
}
async function fetchVerified(ref, fetchImpl, origin = DISTRIBUTION_ORIGIN) {
const bytes = await fetchBounded(`${origin}${ref.path}`, ref.size, fetchImpl, origin);
if (bytes.byteLength !== ref.size || digest(bytes) !== ref.sha256) throw new Error("Static resource size or SHA-256 mismatch");
return bytes;
}
function validateSet(pointer, entries, index, delivery, origin = DISTRIBUTION_ORIGIN) {
validateCatalog(entries, origin);
if (!validateIndex(index)) throw new Error(`Invalid distribution index: ${formatValidationErrors(validateIndex.errors)}`);
const validator = delivery.schemaVersion === 2 ? validateDeliveryV2 : validateDelivery;
if (!validator(delivery)) throw new Error(`Invalid static delivery: ${formatValidationErrors(validator.errors)}`);
if (delivery.registryCommit !== pointer.registryCommit || delivery.catalogSha256 !== pointer.catalog.sha256
|| delivery.indexSha256 !== pointer.index.sha256) throw new Error("Static snapshot documents do not match");
// v1 publishing deliberately refuses packages and avatars. Do not extend this
// consumer ahead of the reviewed publishing schema or invent installable code.
if (delivery.schemaVersion === 1 && entries.some((entry) => entry.artifact)) throw new Error("Static delivery v1 is source-only");
const current = new Map();
const indexIds = new Set();
for (const plugin of index.plugins) {
if (indexIds.has(plugin.id)) throw new Error("Duplicate distribution index plugin");
indexIds.add(plugin.id);
const versions = new Map();
for (const version of plugin.versions) {
if (version.entry.id !== plugin.id || versions.has(version.entry.version)) throw new Error("Invalid distribution version identity");
versions.set(version.entry.version, version);
}
if (plugin.current !== null) {
const version = versions.get(plugin.current);
if (!version || version.withdrawal !== null || (version.distribution !== (version.entry.artifact ? "release" : "source-only"))) throw new Error("Current distribution version is missing or withdrawn");
current.set(plugin.id, version.entry);
}
}
if (current.size !== entries.length || entries.some((entry) => !isDeepStrictEqual(current.get(entry.id), entry))) {
throw new Error("Catalog does not match the current non-withdrawn index");
}
const resources = new Map();
const catalogTuples = new Set(entries.map(tuple));
for (const plugin of delivery.plugins) {
const key = tuple(plugin);
if (!catalogTuples.has(key) || resources.has(key)) throw new Error("Delivery identity does not match catalog tuple");
assertAsset(plugin.readme, ["md"]);
for (const icon of [plugin.iconLight, plugin.iconDark]) if (icon !== null) assertAsset(icon, ["png", "webp"]);
const entry = entries.find((item) => tuple(item) === key);
if (plugin.artifact !== null) {
assertReference(plugin.artifact, `/assets/${plugin.artifact.sha256}.ncpkg`, 32 * 1024 * 1024);
if (!entry.artifact || entry.artifact.sha256 !== plugin.artifact.sha256 || entry.artifact.url !== `${origin}${plugin.artifact.path}`) throw new Error("Package delivery does not match catalog hash and source");
} else if (entry.artifact) throw new Error("Catalog package has no delivery resource");
resources.set(key, plugin);
}
if (resources.size !== entries.length) throw new Error("Missing delivery entry for catalog plugin");
return resources;
}
async function fetchDistribution(fetchImpl, origin = DISTRIBUTION_ORIGIN) {
const pointer = parseJson(await fetchBounded(`${origin}/v1/snapshot.json`, 16 * 1024, fetchImpl, origin), "snapshot pointer", 16 * 1024);
assertPointer(pointer);
const documents = await Promise.all(["catalog", "index", "delivery"].map(async (name) => parseJson(await fetchVerified(pointer[name], fetchImpl, origin), name)));
const [entries, index, delivery] = documents;
const resources = validateSet(pointer, entries, index, delivery, origin);
return { entries, resources, pointer };
}
module.exports = { REGISTRY_URL, fetchDistribution, fetchVerified, tuple, validateSet, assertPointer, assertAsset };
+26
View File
@@ -187,6 +187,8 @@ function assertSameOpenedDirectory(expectedStats, pathStats) {
class PluginFilesystemBroker {
constructor(options = {}) {
this.quotaManager = options.quotaManager ?? null;
this.protectedDirectories = options.protectedDirectories ?? [];
this.protectedFiles = options.protectedFiles ?? [];
this.fileSystem = options.fileSystem ?? fsp;
this.openDirectoryHandle = options.openDirectoryHandle ?? null;
if (this.openDirectoryHandle != null && typeof this.openDirectoryHandle !== "function") {
@@ -261,10 +263,33 @@ class PluginFilesystemBroker {
}
}
async #assertWritable(canonical) {
const variants = async (entry) => {
const lexical = path.resolve(entry);
try { return [lexical, await this.fileSystem.realpath(lexical)]; }
catch (error) { if (error?.code !== "ENOENT") throw error; return [lexical]; }
};
for (const file of this.protectedFiles) {
if ((await variants(file)).includes(canonical)) {
throw new PluginRpcError(RPC_ERRORS.permissionDenied, "Host audit storage is protected");
}
}
for (const directory of this.protectedDirectories) {
for (const parent of await variants(directory)) {
const relative = path.relative(parent, canonical);
if (relative === "" || (!path.isAbsolute(relative) && relative !== ".." && !relative.startsWith(`..${path.sep}`))) {
throw new PluginRpcError(RPC_ERRORS.permissionDenied, "Host audit storage is protected");
}
}
}
}
async writeFile(params, context) {
const value = assertSecureWriteMode(assertWriteParams(params));
await this.#assertWritable(path.resolve(value.path));
assertAuthorizedPath(context, "filesystem.write", value.path);
const { canonical, stats } = await resolveWritePath(value.path, this.fileSystem);
await this.#assertWritable(canonical);
assertAuthorizedPath(context, "filesystem.write", canonical);
this.quotaManager?.chargeBytes(context.runtimeId, "filesystem", value.bytes.byteLength);
await context.assertActive();
@@ -278,6 +303,7 @@ class PluginFilesystemBroker {
]);
assertSameOpenedFile(openedStats, stats);
assertSameOpenedFile(openedStats, pathStats);
await this.#assertWritable(canonical);
await context.assertActive();
if (value.overwrite) await handle.truncate(0);
await handle.writeFile(value.bytes);
@@ -390,3 +390,61 @@ test("filesystem broker rejects oversized and non-canonical payloads", async (co
(error) => error.code === RPC_ERRORS.resourceExhausted,
);
});
test("plugin writes cannot modify host audit files or preferences, including symlink aliases", async (t) => {
const root = createRoot(t);
const directory = path.join(root, "host-logs");
await fsp.mkdir(directory);
const audit = path.join(directory, "runtime.jsonl");
const preferences = path.join(root, "local-logging.json");
await fsp.writeFile(audit, "host audit");
await fsp.writeFile(preferences, "host preferences");
const alias = path.join(root, "alias");
await fsp.symlink(audit, alias);
const broker = new PluginFilesystemBroker({ protectedDirectories: [directory], protectedFiles: [preferences] });
for (const target of [audit, preferences, alias]) {
const authorization = broker.describeWriteAuthorization({ path: target, data: "overwrite", overwrite: true });
await assert.rejects(broker.writeFile({ path: target, data: "overwrite", overwrite: true }, runtimeContext(authorization)), { code: RPC_ERRORS.permissionDenied });
}
assert.equal(await fsp.readFile(audit, "utf8"), "host audit");
assert.equal(await fsp.readFile(preferences, "utf8"), "host preferences");
});
test("host audit protection resolves a symlinked userData directory", async (t) => {
const root = createRoot(t);
const actual = path.join(root, "actual-profile");
await fsp.mkdir(path.join(actual, "logs"), { recursive: true });
const profileAlias = path.join(root, "profile-alias");
await fsp.symlink(actual, profileAlias);
const target = path.join(actual, "logs", "runtime.jsonl");
await fsp.writeFile(target, "host audit");
const broker = new PluginFilesystemBroker({ protectedDirectories: [path.join(profileAlias, "logs")] });
const params = { path: target, data: "overwrite", overwrite: true };
const authorization = broker.describeWriteAuthorization(params);
await assert.rejects(broker.writeFile(params, runtimeContext(authorization)), { code: RPC_ERRORS.permissionDenied });
assert.equal(await fsp.readFile(target, "utf8"), "host audit");
});
test("revocation during the protected-storage check cannot write an unrelated granted file", async (t) => {
const root = createRoot(t);
const logs = path.join(root, "host-logs");
const target = path.join(root, "synthetic.txt");
await fsp.mkdir(logs);
await fsp.writeFile(target, "prior");
let checks = 0;
let active = true;
const broker = new PluginFilesystemBroker({ protectedDirectories: [logs], fileSystem: {
...fsp, async realpath(requested) {
const canonical = await fsp.realpath(requested);
if (requested === logs && ++checks === 3) active = false;
return canonical;
},
} });
const params = { path: target, data: "overwrite", overwrite: true };
const authorization = broker.describeWriteAuthorization(params);
const context = { ...runtimeContext(authorization), async assertActive() {
if (!active) throw Object.assign(new Error("synthetic revocation"), { code: RPC_ERRORS.permissionDenied });
} };
await assert.rejects(broker.writeFile(params, context), { code: RPC_ERRORS.permissionDenied });
assert.equal(await fsp.readFile(target, "utf8"), "prior");
});
@@ -1,45 +0,0 @@
"use strict";
// Test transport harness only. This is Node, not an OS/browser sandbox test.
// Production continues to use the sandboxed BrowserPluginRuntime unchanged.
const { MessageChannel } = require("node:worker_threads");
const { pathToFileURL } = require("node:url");
const path = require("node:path");
const { PluginRpcRouter } = require("../rpcRouter.cjs");
function inProcessBrowserRuntime(options) {
let host;
let peer;
let port;
return {
async start(config) {
const { startPluginRuntime } = await import("../runtime/runtimePeer.mjs");
const { port1, port2 } = new MessageChannel();
port = port1;
host = new PluginRpcRouter({ pluginId: config.pluginId,
send: (message, transfer) => port1.postMessage(message, transfer),
requestHandlers: options.requestHandlers, notificationHandlers: options.notificationHandlers,
onIncomingStream: options.onIncomingStream, onBeforeMessage: options.onBeforeMessage,
onProtocolError: options.onProtocolError,
});
port1.on("message", (message) => { void host.accept(message); });
peer = await startPluginRuntime({ port: port2, config,
loadPlugin: () => import(pathToFileURL(path.join(options.packageRoot, options.plugin.manifest.main.browser)).href),
});
const initialized = await host.request("plugin.initialize", {
netcattyVersion: config.netcattyVersion, apiVersion: config.apiVersion, supportedFeatures: config.supportedFeatures,
});
await host.request("plugin.activate", {});
return initialized;
},
request: (...args) => host.request(...args),
notify: (...args) => host.notify(...args),
openStream: (...args) => host.streams.openOutgoing(...args),
async stop() {
try { if (host) await host.request("plugin.deactivate", {}); }
finally { await peer?.dispose(); host?.close(); port?.close(); }
},
};
}
module.exports = { inProcessBrowserRuntime };
@@ -0,0 +1,38 @@
import { definePlugin } from "@netcatty/plugin-sdk";
export default definePlugin({
activate(context) {
const register = (suffix, handler) => context.subscriptions.add(context.commands.registerCommand(
`com.example.local-logging-fixture.${suffix}`, handler,
));
register("emit", () => {
context.logger.info("Synthetic diagnostic for local logging QA", {
apiKey: "SYNTHETIC-API-KEY-NOT-A-CREDENTIAL",
prompt: "SYNTHETIC-PROMPT-BODY",
Notes: "SYNTHETIC-NOTE-BODY",
SQL: "SYNTHETIC-SQL-BODY",
results: "SYNTHETIC-RESULT-BODY",
terminalContent: "SYNTHETIC-TERMINAL-BODY",
pluginId: "synthetic-spoofed-plugin",
sourceId: "official",
runtimeId: "synthetic-spoofed-runtime",
});
return { ok: true, synthetic: true };
});
register("noise", () => {
for (let index = 0; index < 400; index++) console.info(`Synthetic diagnostic burst ${index}`);
return { ok: true, synthetic: true, emitted: 400 };
});
register("denied", async () => {
// No network permission is declared. The host must reject before any
// network I/O or approval prompt; no body or credential is ever supplied.
try {
await context.network.request({ method: "GET", url: "https://logging-fixture.example.com/should-never-be-sent" });
throw new Error("Synthetic undeclared request unexpectedly succeeded");
} catch (error) {
if (error?.code !== "permission_denied" && error?.code !== -32007) throw error;
return { denied: true, synthetic: true };
}
});
},
});
@@ -0,0 +1,29 @@
{
"manifestVersion": 1,
"id": "com.example.local-logging-fixture",
"name": "local-logging-fixture",
"displayName": "Synthetic local logging fixture",
"description": "Synthetic diagnostics and denied-operation checks. No real data or credentials.",
"version": "0.1.0",
"publisher": "example",
"engines": { "netcatty": ">=0.0.0", "api": ">=0.1.0-internal <0.2.0" },
"main": { "browser": "index.js" },
"activationEvents": [
"onCommand:com.example.local-logging-fixture.emit",
"onCommand:com.example.local-logging-fixture.noise",
"onCommand:com.example.local-logging-fixture.denied"
],
"permissions": { "required": ["commands", "menus"], "optional": [] },
"contributes": {
"commands": [
{ "id": "com.example.local-logging-fixture.emit", "title": "Emit synthetic diagnostic", "category": "Logging QA" },
{ "id": "com.example.local-logging-fixture.noise", "title": "Emit synthetic diagnostic burst", "category": "Logging QA" },
{ "id": "com.example.local-logging-fixture.denied", "title": "Test undeclared network denial", "category": "Logging QA" }
],
"menus": [
{ "command": "com.example.local-logging-fixture.emit", "location": "commandPalette" },
{ "command": "com.example.local-logging-fixture.noise", "location": "commandPalette" },
{ "command": "com.example.local-logging-fixture.denied", "location": "commandPalette" }
]
}
}
@@ -1,53 +0,0 @@
# Hello Netcatty
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.
From the repository root:
```bash
npm run build:plugin-packages
node packages/plugin-cli/dist/cli.js validate examples/plugins/hello-netcatty
node packages/plugin-cli/dist/cli.js compatibility examples/plugins/hello-netcatty --netcatty 0.0.0
node packages/plugin-cli/dist/cli.js pack examples/plugins/hello-netcatty --out /tmp/hello-netcatty.ncpkg
```
After installation, change **Greeting** under **Settings → Plugins**, then run
**Examples: Say Hello** from the command palette. The setting is read through
the host settings broker and the command handler is registered inside the
sandboxed plugin runtime. Type `netc` at a shell prompt to exercise the bounded
completion Provider, and print `Hello from Netcatty` to exercise the declarative
decoration Provider. Neither Provider receives raw xterm objects or sensitive
terminal input/output streams.
## Import and inspect
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
permission prompts still mediate activation and sensitive operations.
Importing an update is a manual action and leaves it disabled, including when
the previous version was enabled. Review the new package before enabling it.
Changing unsigned package contents changes its security principal; previous
persistent grants are not transferred to different bytes.
An incompatible application/API upgrade disables installed plugins before
publishing their contributions. The reason remains in the installed list, and
package files and local data remain available. Returning to a compatible host
requires explicit re-enable. Uninstall is different: package code and local KV
are deleted; user-owned settings, secret storage and sync sidecars are retained.
For a synthetic demonstration, use only this first-party example, change the
Greeting, invoke **Examples: Say Hello**, disable/re-enable, restart the app and
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.
@@ -1,37 +0,0 @@
# 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 > Open plugin manager > Installed**, import the package, inspect its
permissions and enable it. Change **Greeting**, then find **Starter: Greeting Panel** (入门:问候面板)
in the command palette. Review the normal permission prompts, then click
**Show greeting** to see the exact preference in the sandboxed panel.
**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.
Version 0.1.2 adds light/dark PNG brand icons in `assets/`. Displaying these
images does not activate the plugin or request permissions. This example now
requires API `>=0.1.0-internal.1 <0.2.0` and `metadata.brand-icon`.
Older Starter 0.1.1 packages remain compatible with the new host.
See [brand icon author instructions](../../../docs/plugin-platform/plugin-icons.md).
@@ -1,195 +0,0 @@
[
{
"id": "com.netcatty.starter",
"version": "0.1.2",
"name": "Plugin Starter",
"description": "First-party greeting panel, local settings and commands, without terminal or secret access.",
"author": "Netcatty",
"repository": "binaricat/Netcatty",
"commit": "f4a6b205adca486d4e9cda0270e13a9ff0afd7be",
"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.2",
"publisher": "netcatty",
"license": "GPL-3.0-or-later",
"engines": {
"netcatty": ">=0.0.0",
"api": ">=0.1.0-internal.1 <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"
}
]
},
"features": {
"required": [
"metadata.brand-icon"
]
},
"icon": {
"kind": "package",
"light": "assets/logo-light.png",
"dark": "assets/logo-dark.png"
}
}
},
{
"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"
}
]
}
}
}
]
@@ -1,48 +0,0 @@
{
"builderSha256": "57199cc6a5d30f5f356adb072d51de9e2283dac6e26c647a4404e4c2261c4a70",
"catalogSha256": "47e967516437996e00eac8fbe97699baa72439ba1bf7ae791731e4964bb13774",
"indexSha256": "af2e953e3c631b36a0e712806d0dcfb0a058fd02f2051025eddded8cc115d461",
"plugins": [
{
"artifact": null,
"avatar": null,
"commit": "f4a6b205adca486d4e9cda0270e13a9ff0afd7be",
"iconDark": {
"path": "/assets/7a897a67ae6b652339c77a113d1c759919776fead82d2eefd0238d3c05ba87fe.png",
"sha256": "7a897a67ae6b652339c77a113d1c759919776fead82d2eefd0238d3c05ba87fe",
"size": 873
},
"iconLight": {
"path": "/assets/ec7bcd5e470ee3d3d9f7f77d120a4de7496a9c0ba984094f84e560d240bea947.png",
"sha256": "ec7bcd5e470ee3d3d9f7f77d120a4de7496a9c0ba984094f84e560d240bea947",
"size": 873
},
"id": "com.netcatty.starter",
"readme": {
"path": "/assets/d685ec68558c81705f03195ca35a0a62ea6ec75d8837d5ddb5b5a970890aae6e.md",
"sha256": "d685ec68558c81705f03195ca35a0a62ea6ec75d8837d5ddb5b5a970890aae6e",
"size": 2050
},
"repository": "binaricat/Netcatty",
"version": "0.1.2"
},
{
"artifact": null,
"avatar": null,
"commit": "4336f8fd53a05a48d6bbc9ad3ce0ca88b121862f",
"iconDark": null,
"iconLight": null,
"id": "com.netcatty.hello",
"readme": {
"path": "/assets/1692573445e3688b4fe3612282becc086b191a034c0609deeaaebf615627c209.md",
"sha256": "1692573445e3688b4fe3612282becc086b191a034c0609deeaaebf615627c209",
"size": 2951
},
"repository": "binaricat/Netcatty",
"version": "0.1.0"
}
],
"registryCommit": "32578ae27ff86f0151511478d698a1cdc12fb2e0",
"schemaVersion": 1,
"sourceLockSha256": "753711c1860ff0fcc10a558182f8c868d9d06b40e5c8de5522249d0d27b139d6"
}
@@ -1,218 +0,0 @@
{
"schemaVersion": 1,
"plugins": [
{
"id": "com.netcatty.starter",
"current": "0.1.2",
"versions": [
{
"entry": {
"id": "com.netcatty.starter",
"version": "0.1.2",
"name": "Plugin Starter",
"description": "First-party greeting panel, local settings and commands, without terminal or secret access.",
"author": "Netcatty",
"repository": "binaricat/Netcatty",
"commit": "f4a6b205adca486d4e9cda0270e13a9ff0afd7be",
"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.2",
"publisher": "netcatty",
"license": "GPL-3.0-or-later",
"engines": {
"netcatty": ">=0.0.0",
"api": ">=0.1.0-internal.1 <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"
}
]
},
"features": {
"required": [
"metadata.brand-icon"
]
},
"icon": {
"kind": "package",
"light": "assets/logo-light.png",
"dark": "assets/logo-dark.png"
}
}
},
"distribution": "source-only",
"withdrawal": null
}
]
},
{
"id": "com.netcatty.hello",
"current": "0.1.0",
"versions": [
{
"entry": {
"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"
}
]
}
}
},
"distribution": "source-only",
"withdrawal": null
}
]
}
]
}
@@ -1,20 +0,0 @@
{
"catalog": {
"path": "/snapshots/53e5189d2cb58a02826931d5d6321ad3e556b924ef80d38636dc9e66a7df8c55/catalog.json",
"sha256": "47e967516437996e00eac8fbe97699baa72439ba1bf7ae791731e4964bb13774",
"size": 5739
},
"delivery": {
"path": "/snapshots/53e5189d2cb58a02826931d5d6321ad3e556b924ef80d38636dc9e66a7df8c55/delivery.json",
"sha256": "53e5189d2cb58a02826931d5d6321ad3e556b924ef80d38636dc9e66a7df8c55",
"size": 1869
},
"index": {
"path": "/snapshots/53e5189d2cb58a02826931d5d6321ad3e556b924ef80d38636dc9e66a7df8c55/index.json",
"sha256": "af2e953e3c631b36a0e712806d0dcfb0a058fd02f2051025eddded8cc115d461",
"size": 7726
},
"registryCommit": "32578ae27ff86f0151511478d698a1cdc12fb2e0",
"schemaVersion": 1,
"snapshotId": "53e5189d2cb58a02826931d5d6321ad3e556b924ef80d38636dc9e66a7df8c55"
}
@@ -1,40 +0,0 @@
"use strict";
const fs = require("node:fs");
const path = require("node:path");
const { createHash } = require("node:crypto");
const root = path.join(__dirname, "static-distribution");
const digest = (bytes) => createHash("sha256").update(bytes).digest("hex");
const read = (name) => fs.readFileSync(path.join(root, name));
const catalog = JSON.parse(read("catalog.json"));
const delivery = JSON.parse(read("delivery.json"));
function realFiles() {
const pointer = JSON.parse(read("snapshot.json"));
const files = new Map([["/v1/snapshot.json", read("snapshot.json")]]);
for (const name of ["catalog", "index", "delivery"]) files.set(pointer[name].path, read(`${name}.json`));
for (const name of fs.readdirSync(path.join(root, "assets"))) files.set(`/assets/${name}`, read(`assets/${name}`));
return files;
}
function makeFiles(entries = catalog, mutate = () => {}) {
const index = { schemaVersion: 1, plugins: entries.map((entry) => ({ id: entry.id, current: entry.version, versions: [{ entry: structuredClone(entry), distribution: "source-only", withdrawal: null }] })) };
const sidecar = { ...structuredClone(delivery), plugins: entries.map((entry) => ({ ...structuredClone(delivery.plugins.find((item) => item.id === entry.id) ?? delivery.plugins[0]), id: entry.id, version: entry.version, repository: entry.repository, commit: entry.commit })) };
mutate(index, sidecar);
const catalogBytes = Buffer.from(JSON.stringify(entries));
const indexBytes = Buffer.from(JSON.stringify(index));
sidecar.catalogSha256 = digest(catalogBytes); sidecar.indexSha256 = digest(indexBytes);
const deliveryBytes = Buffer.from(JSON.stringify(sidecar));
const snapshotId = digest(deliveryBytes);
const pointer = { schemaVersion: 1, registryCommit: sidecar.registryCommit, snapshotId };
const files = realFiles();
for (const [name, bytes] of [["catalog", catalogBytes], ["index", indexBytes], ["delivery", deliveryBytes]]) {
const ref = { path: `/snapshots/${snapshotId}/${name}.json`, size: bytes.length, sha256: digest(bytes) };
pointer[name] = ref; files.set(ref.path, bytes);
}
files.set("/v1/snapshot.json", Buffer.from(JSON.stringify(pointer)));
return files;
}
function responseFor(files, url) {
if (new URL(url).origin !== "https://plugins.netcatty.app") throw new Error("Unexpected upstream origin");
const bytes = files.get(new URL(url).pathname);
return bytes ? new Response(bytes) : new Response("Missing fixture", { status: 404 });
}
module.exports = { root, read, digest, catalog, delivery, realFiles, makeFiles, responseFor };
File diff suppressed because it is too large. Load diff
+4 -37
View File
@@ -61,7 +61,6 @@ class PluginHostInteractionService {
this.admissionRevision = 0;
this.focusRestoreMs = Math.min(options.focusRestoreMs ?? 1_000, 1_000);
this.owner = null;
this.ownerGeneration = 0;
this.ownerCleanup = null;
this.closed = false;
this.revocation = this.permissionEngine.onDidRevoke((event) => this.releasePlugin(event.pluginId));
@@ -73,19 +72,15 @@ class PluginHostInteractionService {
metadata: { capability: "ui", mutating: true },
validateParams: assertInteractionRequest,
authorization: (params, context) => {
this.admitRequest(params, context);
const sender = this.#admit(params, context);
let guard = this.admissionGuards.get(context.runtimeId);
if (!guard) { guard = { active: true }; this.admissionGuards.set(context.runtimeId, guard); }
this.admissions.set(params, { sender, runtimeId: context.runtimeId, revision: this.admissionRevision, guard });
return interactionAuthorization(params);
},
});
}
admitRequest(params, context) {
const sender = this.#admit(params, context);
let guard = this.admissionGuards.get(context.runtimeId);
if (!guard) { guard = { active: true }; this.admissionGuards.set(context.runtimeId, guard); }
this.admissions.set(params, { sender, runtimeId: context.runtimeId, revision: this.admissionRevision, guard });
}
setReady(sender, ready) {
const window = this.getWindow();
if (!sender || window?.webContents !== sender || sender.isDestroyed?.()) {
@@ -97,7 +92,6 @@ class PluginHostInteractionService {
if (this.owner === sender) return true;
if (this.owner) this.releaseWindow(this.owner);
this.owner = sender;
this.ownerGeneration++;
const close = () => this.releaseWindow(sender);
// A native permission dialog can temporarily blur its parent. Already
// displayed interactions still cancel immediately; pre-prompt admission
@@ -150,8 +144,6 @@ class PluginHostInteractionService {
pluginId: entry.context.pluginId, pluginVersion: entry.context.pluginVersion,
expiresAt: entry.expiresAt, phase: entry.phase, request: entry.params,
...(entry.previewText === undefined ? {} : { previewText: entry.previewText }),
...(this.vaultService?.isRequest(entry.params) ? this.vaultService.describePrompt(entry) : {}),
...(entry.reviewedDraft === undefined ? {} : { reviewedDraft: entry.reviewedDraft }),
});
}
@@ -266,25 +258,6 @@ class PluginHostInteractionService {
this.#audit(entry, "cancelled");
return this.#finish(entry, { cancelled: true });
}
if (this.vaultService?.isRequest(entry.params)) {
const outcome = await this.vaultService.respond(entry, response);
await entry.context.assertActive();
this.#assertWindow(sender);
if (this.pending.get(entry.id) !== entry) return false;
if (outcome.result) {
this.#audit(entry, "completed");
return this.#finish(entry, outcome.result);
}
this.#audit(entry, outcome.phase === "commit" ? "action-confirmed" : "action-claimed");
entry.phase = outcome.phase;
if (outcome.reviewedDraft !== undefined) entry.reviewedDraft = outcome.reviewedDraft;
if (entry.phase === "executing") {
entry.actionToken = this.#token(entry);
return { completionToken: entry.actionToken };
}
this.#present(entry);
return true;
}
if (entry.params.kind === "clipboard.read" && entry.phase === "request") {
if (response.action !== "preview") throw fail("invalidArgument", "Clipboard sharing requires a preview action");
this.#audit(entry, "preview-confirmed");
@@ -325,8 +298,6 @@ class PluginHostInteractionService {
releaseWindow(sender) {
if (this.owner !== sender) return;
this.admissionRevision++;
this.ownerGeneration++;
this.vaultService?.clearSelections();
this.owner = null;
this.ownerCleanup?.(); this.ownerCleanup = null;
for (const entry of [...this.pending.values()]) {
@@ -335,7 +306,6 @@ class PluginHostInteractionService {
}
releasePlugin(pluginId) {
this.admissionRevision++;
this.vaultService?.releasePlugin(pluginId);
for (const entry of [...this.pending.values()]) {
if (entry.context.pluginId === pluginId) this.#finish(entry, null, fail("cancelled", "Plugin access was revoked"));
}
@@ -343,7 +313,6 @@ class PluginHostInteractionService {
releaseRuntime(runtimeId) {
this.cancelRuntimeRequests(runtimeId, "Plugin runtime stopped");
this.rates.delete(runtimeId);
this.vaultService?.releaseRuntime(runtimeId);
}
cancelRuntimeRequests(runtimeId, reason = "Host command finished") {
const guard = this.admissionGuards.get(runtimeId);
@@ -355,7 +324,6 @@ class PluginHostInteractionService {
}
cancelAll(invalidateAdmission = true) {
if (invalidateAdmission) this.admissionRevision++;
if (invalidateAdmission) this.vaultService?.clearSelections();
for (const entry of [...this.pending.values()]) this.#finish(entry, null, fail("cancelled", "Host interaction was cancelled"));
}
shutdown() {
@@ -365,7 +333,6 @@ class PluginHostInteractionService {
this.cancelAll(); this.rates.clear(); this.admissionGuards.clear();
this.revocation.dispose(); this.unsubscribeLock?.();
this.key.fill(0);
this.vaultService?.shutdown();
}
}
+66 -39
View File
@@ -79,7 +79,6 @@ function normalizeRuntimeIdentity(identity) {
runtimeKind: identity.runtimeKind,
...(identity.manifest === undefined ? {} : { manifest: identity.manifest }),
...(identity.securityPrincipal === undefined ? {} : { securityPrincipal: identity.securityPrincipal }),
...(identity.sourceOwner === undefined ? {} : { sourceOwner: identity.sourceOwner, sourceId: identity.sourceId }),
...(identity.packageRoot === undefined ? {} : { packageRoot: identity.packageRoot }),
...(identity.logger === undefined ? {} : { logger: identity.logger }),
});
@@ -176,45 +175,73 @@ class PluginHostRpcRegistry {
await assertCurrent?.();
transportContext.signal?.throwIfAborted();
};
await assertActive();
const validatedParams = registration.validateParams
? registration.validateParams(params)
: params;
if (validatedParams && typeof validatedParams.then === "function") {
throw new TypeError("Plugin host RPC parameter validators must be synchronous");
}
const authorizationContext = Object.freeze({
...transportContext,
...identity,
assertActive,
kind,
method,
metadata: registration.metadata,
params: validatedParams,
});
const resolvedAuthorization = typeof registration.authorization === "function"
? await registration.authorization(validatedParams, authorizationContext)
: registration.authorization;
const authorization = resolvedAuthorization == null
? null
: cloneAndFreezeMetadata(resolvedAuthorization);
await assertActive();
const context = Object.freeze({
...authorizationContext,
authorization,
});
let index = -1;
const dispatch = async (nextIndex) => {
if (nextIndex <= index) throw new Error("Plugin host RPC middleware called next() more than once");
index = nextIndex;
const current = middleware[nextIndex];
if (current) return current(context, () => dispatch(nextIndex + 1));
const startedAt = Date.now();
let authorizationOutcome = "not-reached";
let auditResources;
let auditResult;
let auditError;
try {
await assertActive();
return registration.handler(validatedParams, context);
};
const result = await dispatch(0);
await assertActive();
return result;
const validatedParams = registration.validateParams
? registration.validateParams(params)
: params;
if (validatedParams && typeof validatedParams.then === "function") {
throw new TypeError("Plugin host RPC parameter validators must be synchronous");
}
const authorizationContext = Object.freeze({
...transportContext,
...identity,
assertActive,
kind,
method,
metadata: registration.metadata,
recordAuthorization: (outcome) => {
if (["allowed", "denied", "not-required"].includes(outcome)) authorizationOutcome = outcome;
},
params: validatedParams,
});
const resolvedAuthorization = typeof registration.authorization === "function"
? await registration.authorization(validatedParams, authorizationContext)
: registration.authorization;
const authorization = resolvedAuthorization == null
? null
: cloneAndFreezeMetadata(resolvedAuthorization);
auditResources = authorization?.resources;
await assertActive();
const context = Object.freeze({
...authorizationContext,
authorization,
});
let index = -1;
const dispatch = async (nextIndex) => {
if (nextIndex <= index) throw new Error("Plugin host RPC middleware called next() more than once");
index = nextIndex;
const current = middleware[nextIndex];
if (current) return current(context, () => dispatch(nextIndex + 1));
await assertActive();
return registration.handler(validatedParams, context);
};
const result = await dispatch(0);
await assertActive();
auditResult = result;
return result;
} catch (error) {
auditError = error;
throw error;
} finally {
// Never record params, handler results, error messages or plugin text.
const dataBytes = (value) => {
if (typeof value === "string") return Buffer.byteLength(value);
if (Buffer.isBuffer(value) || value instanceof Uint8Array) return value.byteLength;
return null;
};
const field = (value, key) => value && typeof value === "object"
? Object.getOwnPropertyDescriptor(value, key)?.value : undefined;
try { identity.logger?.audit?.({ operation: method, resources: auditResources,
authorization: authorizationOutcome, result: auditError ? "error" : "ok",
errorCode: auditError?.code, durationMs: Date.now() - startedAt,
inputBytes: dataBytes(field(params, "data")), outputBytes: dataBytes(field(auditResult, "data")) }); } catch {}
}
};
const requestHandlers = Object.freeze(Object.fromEntries(
[...this.requests].map(([method, registration]) => [
+64 -71
View File
@@ -1,6 +1,8 @@
"use strict";
const path = require("node:path");
const { PluginLogService } = require("./pluginLogService.cjs");
const { createPluginLogProvenanceResolver } = require("./pluginLogProvenance.cjs");
const NETWORK_CREDENTIAL_LEASE_FEATURE = require("./generated/plugin-contract.schema.json").$defs.NetworkCredentialLeaseFeature.const;
const VAULT_PAGES_FEATURE = require("./generated/plugin-contract.schema.json").$defs.VaultPagesFeature.const;
const TERMINAL_SIDEBAR_FEATURE = require("./generated/plugin-contract.schema.json").$defs.TerminalSidebarFeature.const;
@@ -21,19 +23,13 @@ const {
createUtilityModuleMappings,
normalizePluginModuleResources,
} = require("./moduleResources.cjs");
const { MarketSourcesService } = require("./marketSourcesService.cjs");
const { RegistryCatalogService } = require("./registryCatalogService.cjs");
const { PluginThemeService } = require("./themeService.cjs");
const { PackageStore } = require("./packageStore.cjs");
const { createPluginPaths } = require("./paths.cjs");
const { PluginManager } = require("./pluginManager.cjs");
const { PluginNetworkBroker } = require("./networkBroker.cjs");
const { PluginByteStreamService, FEATURE: BYTE_STREAM_FEATURE } = require("./byteStreamService.cjs");
const { PluginAIContextService, PLUGIN_AI_CONTEXT_FEATURES } = require("./ai/aiContextService.cjs");
const { PluginRpcError, RPC_ERRORS } = require("./rpcRouter.cjs");
const { HOST_INTERACTION_FEATURE, PluginHostInteractionService } = require("./hostInteractionService.cjs");
const { PluginHostVaultService } = require("./hostVaultService.cjs");
const { PluginAISourceBinding } = require("./ai/sourceBinding.cjs");
const HOST_VAULT_FEATURE = require("./generated/plugin-contract.schema.json").$defs.HostVaultFeature.const;
const { PluginPermissionEngine } = require("./permissionEngine.cjs");
const { PluginProtocol } = require("./pluginProtocol.cjs");
const { PluginViewHost } = require("./pluginViewHost.cjs");
@@ -43,6 +39,7 @@ const {
resolveDefaultRuntimeKind,
} = require("./runtimeSupervisor.cjs");
const { PluginQuotaManager } = require("./quotaManager.cjs");
const { RPC_ERRORS } = require("./rpcRouter.cjs");
const { registerSecurePluginCapabilities } = require("./secureCapabilities.cjs");
const { PluginSecretStore } = require("./secretStore.cjs");
const { SecretLeaseStore } = require("./secretLease.cjs");
@@ -96,11 +93,9 @@ function createPluginHostService(options) {
const paths = createPluginPaths(options.app.getPath("userData"));
const appRoot = options.appRoot ?? options.app.getAppPath();
const runtimeDirectory = options.runtimeDirectory ?? path.join(__dirname, "runtime");
const supportedFeatures = [...new Set(["appearance.theme-packs", "metadata.brand-icon", BYTE_STREAM_FEATURE, ...PLUGIN_AI_CONTEXT_FEATURES, NETWORK_CREDENTIAL_LEASE_FEATURE, HOST_INTERACTION_FEATURE, HOST_VAULT_FEATURE, VAULT_PAGES_FEATURE, TERMINAL_SIDEBAR_FEATURE, INTERACTIVE_COMMAND_FEATURE, ...(options.supportedFeatures ?? [])])];
const database = new PluginDatabase(paths.database);
let interactionService;
let byteStreamService;
let aiContextService;
const logService = new PluginLogService({ paths, electron: options.electron, resolveProvenance: options.resolvePluginProvenance ?? createPluginLogProvenanceResolver(database) });
try {
const packageStore = new PackageStore({
allowAdvancedPlugins,
@@ -108,7 +103,7 @@ function createPluginHostService(options) {
database,
netcattyVersion: options.app.getVersion(),
apiVersion: PLUGIN_API_VERSION,
supportedFeatures,
supportedFeatures: [...new Set(["appearance.theme-packs", "metadata.brand-icon", NETWORK_CREDENTIAL_LEASE_FEATURE, HOST_INTERACTION_FEATURE, VAULT_PAGES_FEATURE, TERMINAL_SIDEBAR_FEATURE, INTERACTIVE_COMMAND_FEATURE, ...(options.supportedFeatures ?? [])])],
});
const contributionIconService = new PluginContributionIconService({
database,
@@ -148,6 +143,8 @@ function createPluginHostService(options) {
const filesystemBroker = new PluginFilesystemBroker({
quotaManager,
openDirectoryHandle: options.openPluginDirectoryHandle,
protectedDirectories: [paths.logs],
protectedFiles: [logService.preferencesPath, `${logService.preferencesPath}.tmp`],
});
const networkBroker = new PluginNetworkBroker({
fetch: options.fetch ?? (options.electron.net?.fetch
@@ -201,11 +198,6 @@ function createPluginHostService(options) {
subscribeLock: options.subscribeAppLock,
});
interactionService.registerRpcCapabilities(rpcRegistry);
const hostVaultService = new PluginHostVaultService({
interactionService,
sourceBinding: new PluginAISourceBinding({ database, getRuntimeIdentity: (pluginId) => runtimeSupervisor.getRuntimeIdentity(pluginId) }),
});
hostVaultService.registerRpcCapabilities(rpcRegistry);
const configuredRegistry = options.configureRpcRegistry?.(rpcRegistry);
if (configuredRegistry && typeof configuredRegistry.then === "function") {
throw new TypeError("Plugin host RPC registry configuration must be synchronous");
@@ -219,25 +211,36 @@ function createPluginHostService(options) {
|| !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,
skipPermissions: ["runtime.advanced"],
});
if (kind === "utility") {
await permissionEngine.authorize({
pluginId: context.plugin.id,
pluginVersion: context.plugin.activeVersion,
runtimeId: null,
manifest: context.plugin.manifest,
try {
await permissionEngine.authorizeRequired(context.plugin, {
securityPrincipal: context.securityPrincipal,
signal: context.signal,
}, {
permission: "runtime.advanced",
resources: ["*"],
reason: "Start an advanced runtime with full Node, filesystem, and network authority",
operationId: `runtime.advanced:${context.plugin.activeVersion}`,
skipPermissions: ["runtime.advanced"],
});
if (kind === "utility") {
await permissionEngine.authorize({
pluginId: context.plugin.id,
pluginVersion: context.plugin.activeVersion,
runtimeId: null,
manifest: context.plugin.manifest,
securityPrincipal: context.securityPrincipal,
signal: context.signal,
}, {
permission: "runtime.advanced",
resources: ["*"],
reason: "Start an advanced runtime with full Node, filesystem, and network authority",
operationId: `runtime.advanced:${context.plugin.activeVersion}`,
});
}
} catch (error) {
const identity = logService.identity({
pluginId: context.plugin.id, pluginVersion: context.plugin.activeVersion,
runtimeId: null, runtimeKind: kind, securityPrincipal: context.securityPrincipal,
}, context.plugin);
logService.audit(identity, { operation: "runtime.authorization", result: "error",
authorization: error?.code === RPC_ERRORS.permissionDenied ? "denied" : "unknown",
errorCode: error?.code });
throw error;
}
return kind;
};
@@ -247,13 +250,14 @@ function createPluginHostService(options) {
};
runtimeSupervisor = new RuntimeSupervisor({
electron: options.electron,
logService,
database,
packageStore,
protocol,
paths,
netcattyVersion: options.app.getVersion(),
apiVersion: PLUGIN_API_VERSION,
supportedFeatures,
supportedFeatures: [...new Set(["appearance.theme-packs", "metadata.brand-icon", NETWORK_CREDENTIAL_LEASE_FEATURE, HOST_INTERACTION_FEATURE, VAULT_PAGES_FEATURE, TERMINAL_SIDEBAR_FEATURE, INTERACTIVE_COMMAND_FEATURE, ...(options.supportedFeatures ?? [])])],
runtimeDirectory,
appRoot,
rpcRegistry,
@@ -263,32 +267,23 @@ function createPluginHostService(options) {
getInitialEnvironment: () => contributionService.getEnvironment(),
runtimeResourceMonitor: quotaManager,
runtimeCleanup: async (identity) => {
byteStreamService?.releaseRuntime(identity.runtimeId);
aiContextService?.releaseRuntime(identity.runtimeId);
interactionService?.releaseRuntime(identity.runtimeId);
leaseStore.revokeRuntime(identity.runtimeId);
await companionSupervisor.releaseRuntime(identity.runtimeId);
},
utilityModuleMappings: options.utilityModuleMappings ?? createUtilityModuleMappings(moduleResources),
});
byteStreamService = new PluginByteStreamService({ database, permissionEngine, quotaManager, runtimeSupervisor });
byteStreamService.register(rpcRegistry);
aiContextService = new PluginAIContextService({ database, permissionEngine,
getRuntimeIdentity: (pluginId) => runtimeSupervisor.getRuntimeIdentity(pluginId),
hostAdapter: options.pluginAIHostAdapter ?? {
assertActive: (context) => context.assertActive(),
selectModel: () => { throw new PluginRpcError(RPC_ERRORS.unavailable, "Plugin model adapter is unavailable"); },
listSelected: () => [],
reviewText: () => false,
},
});
aiContextService.registerRpcCapabilities(rpcRegistry);
const originalOnPluginDisabled = contributionService.onPluginDisabled.bind(contributionService);
contributionService.onPluginDisabled = (pluginId) => {
aiContextService.releasePlugin(pluginId);
return originalOnPluginDisabled(pluginId);
};
runtimeSupervisor.onDidChangeRuntime((event) => contributionService.onRuntimeStateChanged(event));
permissionEngine.onDidRevoke((event) => {
const plugin = database.getActivePlugin(event.pluginId);
const identity = runtimeSupervisor.getRuntimeIdentity(event.pluginId) ?? {
pluginId: event.pluginId, pluginVersion: plugin?.activeVersion,
};
logService.audit(logService.identity(identity, plugin), {
operation: "permission.revoked", authorization: "not-required", result: "ok",
resources: event.resource ? [event.resource] : [],
});
});
const terminalProviderService = new PluginTerminalProviderService({
contributionService,
permissionEngine,
@@ -406,32 +401,33 @@ function createPluginHostService(options) {
: null;
const themeService = new PluginThemeService({database,packageStore,contributionService});
const manager = new PluginManager({
secretStore,
permissionEngine,
database,
packageStore,
runtimeSupervisor,
contributionService,
beforeClose: async () => {
themeService.shutdown();
interactionService.shutdown();
byteStreamService.shutdown();
aiContextService.shutdown();
terminalDataPipelineService?.shutdown();
extensionProviderService.shutdown();
credentialResolver.shutdown?.();
await viewHost?.shutdown();
await companionSupervisor.shutdown();
leaseStore.shutdown();
permissionEngine.shutdown();
quotaManager.shutdown();
try {
themeService.shutdown();
interactionService.shutdown();
terminalDataPipelineService?.shutdown();
extensionProviderService.shutdown();
credentialResolver.shutdown?.();
await viewHost?.shutdown();
await companionSupervisor.shutdown();
leaseStore.shutdown();
permissionEngine.shutdown();
quotaManager.shutdown();
} finally {
await logService.close();
}
},
});
return {
logService,
themeService,
interactionService,
hostVaultService,
catalogService: new MarketSourcesService({ database, manager, fetch: options.electron.net?.fetch?.bind(options.electron.net) }),
catalogService: new RegistryCatalogService({ manager, fetch: options.electron.net?.fetch?.bind(options.electron.net) }),
companionSupervisor,
contributionIconService,
contributionService,
@@ -448,8 +444,6 @@ function createPluginHostService(options) {
paths,
protocol,
networkBroker,
byteStreamService,
aiContextService,
permissionEngine,
quotaManager,
rpcRegistry,
@@ -461,8 +455,7 @@ function createPluginHostService(options) {
};
} catch (error) {
try { interactionService?.shutdown(); } catch {}
try { byteStreamService?.shutdown(); } catch {}
try { aiContextService?.shutdown(); } catch {}
void logService.close();
database.close();
throw error;
}
+80
View File
@@ -401,3 +401,83 @@ test("normal release blocks a custom utility placement before permission prompts
}), /requires developer mode/);
assert.equal(prompts, 0);
});
test("host service logs revocation with injected provenance and drains through manager shutdown", async (t) => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-synthetic-log-host-"));
let seen;
const service = createPluginHostService({ ...createOptions(root), resolvePluginProvenance(input) {
seen = input;
return { sourceId: "synthetic-host-source", namespace: "test", sourceKind: "local" };
} });
t.after(async () => { await service.manager.shutdown(); fs.rmSync(root, { recursive: true, force: true }); });
const manifest = { manifestVersion: 1, id: "com.example.synthetic-audit", version: "1.0.0", name: "Synthetic", publisher: "example", engines: { netcatty: ">=0.0.0", api: ">=0.1.0-internal <0.2.0" }, main: { browser: "index.js" } };
service.database.installVersion({ pluginId: manifest.id, version: manifest.version, manifest, archiveSha256: "b".repeat(64), packageRelativePath: `${manifest.id}/${manifest.version}/package` }, { enable: true });
await service.logService.ready;
assert.equal(service.logService.status().saveToDisk, false);
await service.logService.configure({ saveToDisk: true, verbose: false });
service.permissionEngine.revokeAll(manifest.id);
await service.logService.sink.flush();
const record = JSON.parse(await fsp.readFile(service.logService.sink.file(0), "utf8"));
assert.equal(record.operation, "permission.revoked");
assert.equal(record.sourceId, "synthetic-host-source");
assert.equal(record.archiveSha256, "b".repeat(64));
assert.equal(record.pluginVersion, "1.0.0");
assert.equal(record.runtimeId, null);
assert.equal(seen.pluginId, manifest.id);
assert.equal(seen.archiveSha256, "b".repeat(64));
await service.manager.shutdown();
assert.equal(service.logService.sink.closed, true);
});
test("logging always drains before database close when another shutdown stage rejects", async (t) => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-synthetic-log-shutdown-failure-"));
const host = createPluginHostService(createOptions(root));
t.after(async () => { await host.manager.shutdown().catch(() => {}); fs.rmSync(root, { recursive: true, force: true }); });
await host.logService.ready;
await host.logService.configure({ saveToDisk: true, verbose: false });
let resume;
const gate = new Promise((resolve) => { resume = resolve; });
host.logService.sink.queue = host.logService.sink.queue.then(() => gate);
const logger = host.logService.createLogger({ pluginId: "com.example.synthetic", pluginVersion: "1.0.0", runtimeId: "host-shutdown", runtimeKind: "browser" }, {});
logger.audit({ operation: "synthetic.before-shutdown", result: "ok" });
host.companionSupervisor.shutdown = async () => { throw new Error("synthetic companion shutdown failure"); };
let settled = false;
const shutdown = host.manager.shutdown().finally(() => { settled = true; });
// Attach the rejection assertion before yielding so the expected failure is handled.
const rejected = assert.rejects(shutdown, /synthetic companion shutdown failure/);
await new Promise((resolve) => setImmediate(resolve));
assert.equal(settled, false);
assert.doesNotThrow(() => host.database.db.prepare("SELECT 1").get());
resume();
await rejected;
assert.equal(host.logService.sink.closed, true);
assert.equal(host.logService.retentionTimer._destroyed, true);
const record = JSON.parse(await fsp.readFile(host.logService.sink.file(0), "utf8"));
assert.equal(record.operation, "synthetic.before-shutdown");
});
test("activation permission denial is audited before a runtime logger exists", async (t) => {
const { defaultSecurityPrincipal } = require("./permissionResources.cjs");
const { RPC_ERRORS } = require("./rpcRouter.cjs");
const root = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-synthetic-activation-log-"));
const host = createPluginHostService(createOptions(root));
t.after(async () => { await host.manager.shutdown(); fs.rmSync(root, { recursive: true, force: true }); });
const manifest = { manifestVersion: 1, id: "com.example.synthetic-activation", version: "1.0.0", name: "Synthetic", publisher: "example", engines: { netcatty: ">=0.0.0", api: ">=0.1.0-internal <0.2.0" }, main: { browser: "index.js" }, permissions: { required: ["commands"] } };
const archiveSha256 = "a".repeat(64);
host.database.installVersion({ pluginId: manifest.id, version: manifest.version, manifest, archiveSha256, packageRelativePath: `${manifest.id}/${manifest.version}/package` }, { enable: true });
await host.logService.ready;
await host.logService.configure({ saveToDisk: true, verbose: false });
await assert.rejects(host.runtimeSupervisor.resolveRuntimeKind({
plugin: host.database.getActivePlugin(manifest.id), availableKinds: ["browser"], signal: new AbortController().signal,
securityPrincipal: defaultSecurityPrincipal(manifest, archiveSha256),
}), { code: RPC_ERRORS.permissionDenied });
await host.logService.sink.flush();
const record = JSON.parse(await fsp.readFile(host.logService.sink.file(0), "utf8"));
assert.equal(record.operation, "runtime.authorization");
assert.equal(record.authorization, "denied");
assert.equal(record.runtimeId, null);
assert.equal(record.errorCode, RPC_ERRORS.permissionDenied);
assert.equal(record.archiveSha256, archiveSha256);
assert.equal(Object.hasOwn(record, "message"), false);
assert.equal(host.runtimeSupervisor.getRuntimeIdentity(manifest.id), null);
});
-153
View File
@@ -1,153 +0,0 @@
"use strict";
const { createHmac, randomBytes } = require("node:crypto");
const { createDefinitionValidator } = require("./contractValidator.cjs");
const { PluginRpcError, RPC_ERRORS } = require("./rpcRouter.cjs");
const fail = (code, message) => new PluginRpcError(RPC_ERRORS[code], message);
const METHODS = ["vault.selectHosts", "vault.requestHostDraft", "vault.requestConnect"];
const MAX_SELECTED = 50;
const MAX_REFERENCES = 500;
const TARGET_LIFETIME_MS = 5 * 60_000;
/** Typed host selection only. Selection references identify a reviewed target;
* the interaction service owns separate operation-specific commit authority. */
class PluginHostVaultService {
constructor({ sourceBinding, interactionService, clock = Date.now }) {
this.sourceBinding = sourceBinding;
this.interactionService = interactionService;
this.clock = clock;
this.key = randomBytes(32);
this.targets = new Map();
this.admissions = new WeakMap();
this.validators = Object.fromEntries([
"VaultSelectHostsParams", "VaultHostDraftParams", "VaultHostConnectParams",
"VaultHostSelectedResources", "VaultHostCreateFields", "VaultHostEditFields",
].map((name) => [name, createDefinitionValidator(name)]));
interactionService.vaultService = this;
}
isRequest(params) { return METHODS.includes(params?.kind); }
#validate(name, value) {
if (!this.validators[name](value)) throw fail("invalidArgument", "Host action parameters are invalid");
if (Buffer.byteLength(JSON.stringify(value)) > 65_536) throw fail("resourceExhausted", "Host action exceeds its size limit");
return structuredClone(value);
}
registerRpcCapabilities(registry) {
const definitions = ["VaultSelectHostsParams", "VaultHostDraftParams", "VaultHostConnectParams"];
for (const [index, method] of METHODS.entries()) {
registry.registerRequest(method, (params, context) => this.request(params, context), {
metadata: { capability: "vault", mutating: method !== "vault.selectHosts" },
validateParams: (params) => Object.freeze({ ...this.#validate(definitions[index], params), kind: method }),
authorization: async (params, context) => {
const binding = await this.sourceBinding.resolve(context);
if (params.target) this.#resolveTarget(params.target, params.expectedRevision, binding);
this.interactionService.admitRequest(params, context);
this.admissions.set(params, binding);
return {
permission: method === "vault.requestHostDraft" ? "vault.write" : method === "vault.selectHosts" ? "vault.metadata" : "ui.interact",
resources: ["*"], allowedScopes: ["once"],
reason: method === "vault.selectHosts" ? "Choose the current host metadata to share" : method === "vault.requestHostDraft" ? "Review a host draft before saving" : "Review a saved host connection and its script disclosure",
operationId: method,
};
},
});
}
}
async request(params, originalContext) {
const binding = await this.sourceBinding.resolve(originalContext);
if (this.admissions.get(params) !== binding) throw fail("permissionDenied", "Host action admission is unavailable");
this.admissions.delete(params);
const assertActive = async () => {
if (await this.sourceBinding.resolve(originalContext) !== binding) throw fail("permissionDenied", "Host action source changed");
if (params.target) this.#resolveTarget(params.target, params.expectedRevision, binding);
};
await assertActive();
return this.interactionService.request(params, Object.freeze({ ...originalContext, assertActive, vaultBinding: binding }));
}
#resolveTarget(ref, expectedRevision, binding) {
const entry = this.targets.get(ref?.handle);
if (!entry || ref.kind !== "host.selection" || ref.scope !== "metadata" || entry.binding !== binding
|| ref.revision !== entry.revision || expectedRevision !== entry.revision || ref.expiresAt !== entry.expiresAt
|| this.clock() >= entry.expiresAt || entry.owner !== this.interactionService.owner
|| entry.generation !== this.interactionService.ownerGeneration) {
throw fail("permissionDenied", "Host selection expired or changed; select the host again");
}
return entry;
}
#issueTarget(resource, context) {
const entry = {
binding: context.vaultBinding, resourceId: resource.resourceId, revision: resource.revision,
owner: this.interactionService.owner, generation: this.interactionService.ownerGeneration,
expiresAt: this.clock() + TARGET_LIFETIME_MS,
};
const nonce = randomBytes(24).toString("base64url");
const signature = createHmac("sha256", this.key).update(JSON.stringify([
nonce, entry.binding, entry.owner.id, entry.generation, entry.resourceId,
entry.revision, entry.expiresAt, "host.selection", "metadata",
])).digest("base64url");
const handle = `${nonce}.${signature}`;
this.targets.set(handle, entry);
return { kind: "host.selection", scope: "metadata", handle, revision: entry.revision, expiresAt: entry.expiresAt };
}
describePrompt(entry) {
if (!entry.params.target) return {};
const target = this.#resolveTarget(entry.params.target, entry.params.expectedRevision, entry.context.vaultBinding);
return { vaultTarget: { resourceId: target.resourceId, revision: target.revision } };
}
async respond(entry, response) {
await entry.context.assertActive();
if (response.action === "fail") throw fail("cancelled", "Host resource changed or the action could not complete");
if (entry.phase === "request" && response.action === "submit") {
if (entry.params.kind === "vault.selectHosts") {
const resources = this.#validate("VaultHostSelectedResources", response.resources);
if (resources.length > MAX_SELECTED || new Set(resources.map((resource) => resource.resourceId)).size !== resources.length) {
throw fail("invalidArgument", "Host selection is invalid");
}
this.releaseSelections(entry.context.vaultBinding);
this.pruneExpired();
if (this.targets.size + resources.length > MAX_REFERENCES) throw fail("resourceExhausted", "Too many host selections");
const hosts = resources.map(({ resourceId, revision, ...metadata }) => ({
target: this.#issueTarget({ resourceId, revision }, entry.context), metadata,
}));
const groups = [...new Set(hosts.map((host) => host.metadata.group).filter(Boolean))].map((path) => ({
path, members: hosts.filter((host) => host.metadata.group === path).map((host) => host.target),
}));
return { result: { cancelled: false, hosts, groups } };
}
if (entry.params.kind === "vault.requestHostDraft") {
const draft = this.#validate(entry.params.target ? "VaultHostEditFields" : "VaultHostCreateFields", response.draft);
return { phase: "commit", reviewedDraft: draft };
}
if (typeof response.scriptsRun !== "boolean") throw fail("invalidArgument", "Connection script disclosure is required");
return { phase: "commit" };
}
if (entry.phase === "commit" && response.action === "commit") return { phase: "executing" };
if (entry.phase === "executing" && response.action === "complete") {
return { result: { cancelled: false, status: entry.params.kind === "vault.requestConnect" ? "requested" : "saved" } };
}
throw fail("invalidArgument", "Host action phase is invalid");
}
pruneExpired() { for (const [handle, entry] of this.targets) if (this.clock() >= entry.expiresAt) this.targets.delete(handle); }
releaseSelections(binding) { for (const [handle, entry] of this.targets) if (entry.binding === binding) this.targets.delete(handle); }
releaseRuntime(runtimeId) {
for (const [handle, entry] of this.targets) if (entry.binding.runtimeId === runtimeId) this.targets.delete(handle);
this.sourceBinding.releaseRuntime(runtimeId);
}
releasePlugin(pluginId) {
for (const [handle, entry] of this.targets) if (entry.binding.pluginId === pluginId) this.targets.delete(handle);
// Keep the source helper's pinned runtime binding until runtime stop. A
// revoked old runtime must never rebind itself to a replacement source.
}
clearSelections() { this.targets.clear(); }
shutdown() { this.targets.clear(); this.sourceBinding.shutdown(); this.key.fill(0); }
}
module.exports = { PluginHostVaultService, METHODS, MAX_SELECTED, TARGET_LIFETIME_MS };
@@ -0,0 +1,34 @@
"use strict";
const assert = require("node:assert/strict");
const fs = require("node:fs/promises");
const os = require("node:os");
const path = require("node:path");
const { pathToFileURL } = require("node:url");
const test = require("node:test");
test("native logging fixture packs reproducibly and exposes only synthetic commands", async (t) => {
const root = path.join(__dirname, "fixtures", "local-logging-plugin");
const output = await fs.mkdtemp(path.join(os.tmpdir(), "netcatty-synthetic-logging-package-"));
t.after(() => fs.rm(output, { recursive: true, force: true }));
const { buildPluginPackage } = await import("@netcatty/plugin-cli");
await buildPluginPackage(root, path.join(output, "one.ncpkg"));
await buildPluginPackage(root, path.join(output, "two.ncpkg"));
assert.deepEqual(await fs.readFile(path.join(output, "one.ncpkg")), await fs.readFile(path.join(output, "two.ncpkg")));
const manifest = JSON.parse(await fs.readFile(path.join(root, "netcatty.plugin.json"), "utf8"));
assert.deepEqual(manifest.permissions, { required: ["commands", "menus"], optional: [] });
const plugin = (await import(pathToFileURL(path.join(root, "index.js")).href)).default;
const commands = new Map();
const logs = [];
let deniedRequests = 0;
plugin.activate({
subscriptions: { add() {} },
commands: { registerCommand(id, handler) { commands.set(id, handler); } },
logger: { info(...args) { logs.push(args); } },
network: { async request(request) { assert.equal(request.method, "GET"); assert.equal(request.body, undefined); deniedRequests++; throw Object.assign(new Error("synthetic permission denial"), { code: "permission_denied" }); } },
});
assert.deepEqual(await commands.get(`${manifest.id}.emit`)(), { ok: true, synthetic: true });
assert.match(logs[0][0], /Synthetic/);
assert.equal(logs[0][1].sourceId, "official", "spoofed field is deliberately nested plugin text, never host provenance");
assert.deepEqual(await commands.get(`${manifest.id}.denied`)(), { denied: true, synthetic: true });
assert.equal(deniedRequests, 1);
});
-42
View File
@@ -1,42 +0,0 @@
"use strict";
const { PluginSourceRegistry } = require("./sourceRegistry.cjs");
const { RegistryCatalogService } = require("./registryCatalogService.cjs");
const { createSourceFetch } = require("./sourceFetch.cjs");
class MarketSourcesService {
constructor(options) {
this.options = options;
this.manager = options.manager;
this.registry = new PluginSourceRegistry(options.database);
this.catalogs = new Map();
}
#catalog(sourceId = "official") {
const source = this.registry.get(sourceId);
let catalog = this.catalogs.get(source.id);
if (!catalog) {
catalog = new RegistryCatalogService({ ...this.options, source,
fetch: source.id === "official" ? this.options.fetch : (this.options.createSourceFetch ?? createSourceFetch)(source.origin),
sourceGuard: () => this.registry.get(source.id),
});
this.catalogs.set(source.id, catalog);
}
return catalog;
}
list(sourceId) { return this.#catalog(sourceId).list(); }
readme(id, token, sourceId) { return this.#catalog(sourceId).readme(id, token); }
install(id, token, sourceId = "official", acceptedSourceId) {
if (sourceId !== "official" && acceptedSourceId !== sourceId) throw new Error("Review and confirm this third-party source before installing");
return this.#catalog(sourceId).install(id, token);
}
listSources() { return this.registry.list(); }
addSource(payload) { return this.manager.changeMarketSource(() => this.registry.add(payload?.name, payload?.address)); }
updateSource(payload) { return this.manager.changeMarketSource(() => this.registry.update(payload?.id, payload?.name, payload?.enabled)); }
async removeSource(payload) {
if (typeof payload?.disablePlugins !== "boolean") throw new Error("Choose whether to disable installed plugins");
await this.manager.changeMarketSource(() => {
this.registry.remove(payload.id);
this.catalogs.delete(payload.id);
}, payload.disablePlugins ? payload.id : undefined);
}
}
module.exports = { MarketSourcesService };
@@ -1,40 +0,0 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { PluginDatabase } = require("./database.cjs");
const { PluginManager } = require("./pluginManager.cjs");
const { MarketSourcesService } = require("./marketSourcesService.cjs");
const { realFiles } = require("./fixtures/staticDistributionFixture.cjs");
test("source removal retains installed plugins, optional disabling is serialized, and no source silently takes over", async (t) => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "market-sources-"));
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
const database = new PluginDatabase(path.join(root, "db.sqlite"));
t.after(() => database.close());
const stopped = [];
const manager = new PluginManager({ database, packageStore: { async initialize() {} }, runtimeSupervisor: {
async startEnabled() {}, async stop(id) { stopped.push(id); },
} });
const requests = [];
const service = new MarketSourcesService({ database, manager, createSourceFetch: (origin) => async (url) => {
assert.equal(new URL(url).origin, origin); requests.push(url);
return new Response(realFiles().get(new URL(url).pathname));
}, fetch: async () => { assert.fail("third-party browsing must not fetch official upstream"); } });
const source = await service.addSource({ name: "Community", address: "https://community.example.com" });
const entry = (await service.list(source.id)).entries[0];
database.installVersion({ pluginId: entry.id, version: entry.version, manifest: entry.manifest, source, archiveSha256: "a".repeat(64), packageRelativePath: "test" }, { enable: true });
await assert.rejects(async () => service.install(entry.id, entry.selectionToken, source.id), /confirm/);
await service.updateSource({ id: source.id, name: "Renamed", enabled: false });
const count = requests.length;
assert.throws(() => service.list(source.id), /disabled/);
assert.equal(database.getActivePlugin(entry.id).enabled, true);
await service.removeSource({ id: source.id, disablePlugins: true });
assert.equal(database.getActivePlugin(entry.id).enabled, false);
assert.equal(database.getActivePlugin(entry.id).source.id, source.id);
assert.deepEqual(stopped, [entry.id]);
assert.throws(() => service.readme(entry.id, entry.selectionToken, source.id), /removed/);
assert.equal(requests.length, count);
});
@@ -1,4 +0,0 @@
"use strict";
// Include the isolated domain suites in npm run test:plugin-runtime.
require("./ai/modelInvocationBroker.test.cjs");
require("./context/selectedContextAuthority.test.cjs");
-15
View File
@@ -1,7 +1,5 @@
"use strict";
const { LOCAL_SOURCE, assertSource } = require("./sourceIdentity.cjs");
const { createHash, randomUUID } = require("node:crypto");
const fs = require("node:fs");
const { constants } = fs;
@@ -129,7 +127,6 @@ function validateInstallMetadata(value) {
version,
archiveSha256: value.archiveSha256,
contentSha256: value.contentSha256,
source: assertSource(value.source ?? LOCAL_SOURCE),
};
}
@@ -229,7 +226,6 @@ class PackageStore {
const pluginId = assertPluginStorageSegment(manifest.id, "ID");
const version = assertPluginStorageSegment(manifest.version, "version");
const targetDirectory = resolveInstalledVersionDirectory(this.paths, pluginId, version);
const source = this.database.assertInstallSource(pluginId, options.source ?? LOCAL_SOURCE);
const previousPlugin = this.database.getActivePlugin(pluginId);
if (options.catalog) {
if (snapshot.sha256 !== options.catalog.artifact.sha256) throw new Error("Catalog package SHA-256 mismatch");
@@ -270,7 +266,6 @@ class PackageStore {
if (previousPlugin?.activeVersion !== version || options.enable === false) await prepareActivation("switch-active-version");
const existingPackage = path.join(targetDirectory, PACKAGE_DIRECTORY);
this.database.installVersion({
source,
pluginId,
version,
manifest: existingValidation.manifest,
@@ -287,7 +282,6 @@ class PackageStore {
if (!(error && error.code === "ENOENT")) throw error;
}
const metadata = {
source,
pluginId,
version,
archiveSha256: snapshot.sha256,
@@ -305,7 +299,6 @@ class PackageStore {
);
try {
this.database.installVersion({
source,
pluginId,
version,
manifest,
@@ -398,7 +391,6 @@ class PackageStore {
if (metadata.pluginId !== pluginEntry.name || metadata.version !== versionEntry.name) {
throw new Error("Plugin install metadata does not match its directory");
}
this.database.assertInstallSource(metadata.pluginId, metadata.source);
if (committedVersion && committedVersion.archiveSha256 !== metadata.archiveSha256) {
throw new Error("Plugin install archive hash does not match its database record");
}
@@ -414,7 +406,6 @@ class PackageStore {
if (!committedVersion) {
const packageDirectory = path.join(versionDirectory, PACKAGE_DIRECTORY);
this.database.installVersion({
source: metadata.source,
pluginId: metadata.pluginId,
version: metadata.version,
manifest: validation.manifest,
@@ -588,12 +579,6 @@ class PackageStore {
}
}
async clearUninstalledPrivateFiles(pluginId) {
assertPluginStorageSegment(pluginId, "ID");
if (this.database.getActivePlugin(pluginId)) throw new Error("Plugin is still installed");
await rm(path.join(this.paths.data, pluginId), { recursive: true, force: true });
}
async uninstall(pluginId) {
const plugin = this.database.getActivePlugin(pluginId);
if (!plugin) return false;
+13 -5
View File
@@ -201,7 +201,7 @@ class PluginPermissionEngine {
}
#grantKey(pluginId, permission, declarationHash, resource) {
return `${this.database.privateOwner?.(pluginId) ?? pluginId}\0${permission}\0${declarationHash}\0${resource}`;
return `${pluginId}\0${permission}\0${declarationHash}\0${resource}`;
}
#sessionGrantKey(sessionId, grantKey) {
@@ -219,7 +219,6 @@ class PluginPermissionEngine {
) {
return Object.freeze({
pluginId: context.pluginId,
sourceOwner: this.database.privateOwner?.(context.pluginId) ?? context.pluginId,
permission,
declarationHash,
resource,
@@ -240,7 +239,6 @@ class PluginPermissionEngine {
sessionId,
) {
return grant.pluginId === context.pluginId
&& grant.sourceOwner === (this.database.privateOwner?.(context.pluginId) ?? context.pluginId)
&& grant.permission === permission
&& grant.declarationHash === declarationHash
&& (grant.scope !== "session" || grant.sessionId === sessionId)
@@ -639,11 +637,21 @@ class PluginPermissionEngine {
createMiddleware() {
return async (context, next) => {
if (context.metadata.public === true) return next();
if (context.metadata.public === true) {
context.recordAuthorization?.("not-required");
return next();
}
if (!context.authorization) {
context.recordAuthorization?.("denied");
throw permissionDenied(`Plugin host method has no authorization policy: ${context.method}`);
}
await this.authorize(context, context.authorization);
try {
await this.authorize(context, context.authorization);
} catch (error) {
if (error?.code === RPC_ERRORS.permissionDenied) context.recordAuthorization?.("denied");
throw error;
}
context.recordAuthorization?.("allowed");
return next();
};
}
+1 -4
View File
@@ -2,7 +2,6 @@
const { createHash } = require("node:crypto");
const path = require("node:path");
const { canonicalizeStreamEndpoint } = require("./byteStreamEndpoint.cjs");
const contractSchema = require("./generated/plugin-contract.schema.json");
@@ -63,7 +62,6 @@ function canonicalizeFilesystemResource(value) {
function canonicalizePermissionResource(permission, resource) {
assertPluginPermission(permission);
if (permission === "network.stream") return canonicalizeStreamEndpoint(resource).endpoint;
if (resource === "*") return resource;
if (permission === "network") return canonicalizeNetworkOrigin(resource);
if (permission === "companion.execute") return canonicalizeCompanionResource(resource);
@@ -88,7 +86,6 @@ function permissionResourceCovers(
resourceKind = "exact",
requestedResourceKind = "exact",
) {
if (permission === "network.stream") return grantResource !== "*" && grantResource === requestedResource;
if (grantResource === "*") return true;
if (permission === "filesystem.read" || permission === "filesystem.write") {
if (requestedResourceKind === "directory" && resourceKind !== "directory") return false;
@@ -115,7 +112,7 @@ function normalizePermissionDeclarations(manifest) {
: [...new Set((value.resources ?? []).map((resource) => (
canonicalizePermissionResource(permission, resource)
)))].sort();
if ((required && RESOURCE_SCOPED_PERMISSIONS.has(permission)) || permission === "network.stream") {
if (required && RESOURCE_SCOPED_PERMISSIONS.has(permission)) {
if (resources.length === 0) {
throw new TypeError(`Required permission ${permission} must declare resources`);
}
+16 -15
View File
@@ -20,17 +20,16 @@ const CHANNELS = Object.freeze({
themeApply: "netcatty:plugins:theme-apply",
themesChanged: "netcatty:plugins:themes-changed",
status: "netcatty:plugins:status",
logStatus: "netcatty:plugins:log-status",
logConfigure: "netcatty:plugins:log-configure",
logOpen: "netcatty:plugins:log-open",
logExport: "netcatty:plugins:log-export",
logClear: "netcatty:plugins:log-clear",
interactionReady: "netcatty:plugins:interaction-ready",
interactionResponse: "netcatty:plugins:interaction-response",
list: "netcatty:plugins:list",
permissions: "netcatty:plugins:permissions",
revokePermissions: "netcatty:plugins:revoke-permissions",
resetSource: "netcatty:plugins:reset-source",
sourceBinding: "netcatty:plugins:source-binding",
sources: "netcatty:plugins:sources",
addSource: "netcatty:plugins:add-source",
updateSource: "netcatty:plugins:update-source",
removeSource: "netcatty:plugins:remove-source",
catalog: "netcatty:plugins:catalog",
catalogReadme: "netcatty:plugins:catalog-readme",
catalogInstall: "netcatty:plugins:catalog-install",
@@ -665,18 +664,14 @@ function registerPluginBridge(ipcMain, options) {
? { credentialStorageAvailable: credentialResolver.isAvailable() } : {}),
};
});
handle(CHANNELS.sources, async () => options.catalogService?.listSources() ?? []);
handle(CHANNELS.addSource, async (_manager, payload) => options.catalogService.addSource(payload));
handle(CHANNELS.updateSource, async (_manager, payload) => options.catalogService.updateSource(payload));
handle(CHANNELS.removeSource, async (_manager, payload) => options.catalogService.removeSource(payload));
handle(CHANNELS.catalog, async (_manager, payload) => options.catalogService?.list(payload?.sourceId) ?? { entries: [], source: "builtin", error: "Plugin catalog unavailable" });
handle(CHANNELS.catalog, async () => options.catalogService?.list() ?? { entries: [], source: "builtin", error: "Plugin catalog unavailable" });
handle(CHANNELS.catalogReadme, async (_manager, payload) => {
if (!options.catalogService) throw new Error("Plugin catalog unavailable");
return options.catalogService.readme(payload?.id, payload?.selectionToken, payload?.sourceId);
return options.catalogService.readme(payload?.id, payload?.selectionToken);
});
handle(CHANNELS.catalogInstall, async (_manager, payload) => {
if (!options.catalogService) throw new Error("Plugin catalog unavailable");
return options.catalogService.install(payload?.id, payload?.selectionToken, payload?.sourceId, payload?.acceptedSourceId);
return options.catalogService.install(payload?.id, payload?.selectionToken);
});
handle(CHANNELS.interactionReady, async (_manager, payload, event) => {
if (!options.interactionService) throw new Error("Host interactions are unavailable");
@@ -687,8 +682,6 @@ function registerPluginBridge(ipcMain, options) {
return options.interactionService.respond(event.sender, payload);
});
handle(CHANNELS.list, async (activeManager) => activeManager.list());
handle(CHANNELS.sourceBinding, async (activeManager, payload) => activeManager.database.getRetainedPluginSource(assertManagedPluginId(payload)));
handle(CHANNELS.resetSource, async (activeManager, payload) => activeManager.resetUninstalledSource(assertManagedPluginId(payload), payload?.expectedSourceId));
const assertManagedPluginId = (payload) => {
const id = payload?.pluginId;
if (!validateManagedPluginId(id)) {
@@ -696,6 +689,14 @@ function registerPluginBridge(ipcMain, options) {
}
return id;
};
for (const [channel, action] of [
[CHANNELS.logStatus, "status"], [CHANNELS.logConfigure, "configure"],
[CHANNELS.logOpen, "openFolder"], [CHANNELS.logExport, "exportLogs"], [CHANNELS.logClear, "clear"],
]) handle(channel, async (_manager, payload) => {
if (!options.logService) throw new Error("Local plugin logging is unavailable");
await options.logService.ready;
return options.logService[action](payload);
});
handle(CHANNELS.permissions, async (_manager, payload) => {
const pluginId = assertManagedPluginId(payload);
if (!options.permissionEngine || !options.database) throw new Error("Plugin permissions are unavailable");
+14
View File
@@ -1721,3 +1721,17 @@ test('theme catalog and selection remain trusted app management channels',async(
for(const channel of [CHANNELS.themeCatalog,CHANNELS.themeApply])await assert.rejects(ipc.handlers.get(channel)({trusted:false},{}),/Untrusted/);
assert.equal(calls,0);await ipc.handlers.get(CHANNELS.themeCatalog)({trusted:true});await ipc.handlers.get(CHANNELS.themeApply)({trusted:true},{});assert.equal(calls,2);
});
test("local log management rejects plugin/untrusted senders before touching the sink", async () => {
const ipcMain = createIpcMain();
const actions = [];
const logService = { ready: Promise.resolve() };
for (const action of ["status", "configure", "openFolder", "exportLogs", "clear"]) logService[action] = async () => { actions.push(action); return { saveToDisk: false }; };
registerPluginBridge(ipcMain, { manager: { async initialize() {} }, logService, isTrustedSender: (event) => event?.trusted === true });
for (const channel of [CHANNELS.logStatus, CHANNELS.logConfigure, CHANNELS.logOpen, CHANNELS.logExport, CHANNELS.logClear]) {
await assert.rejects(ipcMain.handlers.get(channel)({}, {}), /Untrusted/);
assert.equal(actions.length, 0);
}
for (const channel of [CHANNELS.logStatus, CHANNELS.logConfigure, CHANNELS.logOpen, CHANNELS.logExport, CHANNELS.logClear]) await ipcMain.handlers.get(channel)({ trusted: true }, {});
assert.deepEqual(actions, ["status", "configure", "openFolder", "exportLogs", "clear"]);
});
+36
View File
@@ -0,0 +1,36 @@
"use strict";
/**
* Host-only adapter. Call with the host-assigned activation identity after the
* caller guard; never pass plugin request params, manifest source claims or an
* owner supplied by a plugin. Missing source APIs mean unknown provenance.
*/
function createPluginLogProvenanceResolver(database) {
const methods = ["getActivePlugin", "getRetainedPluginSource", "privateOwner", "publicPluginId"];
const resolve = (identity) => {
if (!methods.every((method) => typeof database?.[method] === "function")) return null;
const plugin = database.getActivePlugin(identity.pluginId);
if (!plugin || plugin.id !== identity.pluginId
|| plugin.activeVersion !== identity.pluginVersion
|| plugin.archiveSha256 !== identity.archiveSha256) return null;
const source = plugin.source;
const retained = database.getRetainedPluginSource(identity.pluginId);
if (!source || !retained || source.id !== retained.id || source.kind !== retained.kind) return null;
const owner = database.privateOwner(identity.pluginId);
if (typeof owner !== "string" || !owner || owner === identity.pluginId || database.publicPluginId(owner) !== identity.pluginId) return null;
return { sourceId: source.id, sourceKind: source.kind, namespace: owner };
};
return (identity) => {
const initial = resolve(identity);
if (!initial) return null;
return Object.freeze({ ...initial, isCurrent: () => {
try {
const current = resolve(identity);
return current !== null && current.sourceId === initial.sourceId
&& current.sourceKind === initial.sourceKind && current.namespace === initial.namespace;
} catch { return false; }
} });
};
}
module.exports = { createPluginLogProvenanceResolver };
@@ -0,0 +1,70 @@
"use strict";
const assert = require("node:assert/strict");
const fs = require("node:fs/promises");
const os = require("node:os");
const path = require("node:path");
const test = require("node:test");
const { PluginDatabase } = require("./database.cjs");
const { createPluginHostService } = require("./hostService.cjs");
test("real source database replacement never reattributes old runtime log records", {
skip: typeof PluginDatabase.prototype.getRetainedPluginSource !== "function" && "requires the source API from PR #3981; run on the documented integration worktree",
}, async (t) => {
const { thirdPartySource, OFFICIAL_SOURCE } = require("./sourceIdentity.cjs");
const root = await fs.mkdtemp(path.join(os.tmpdir(), "netcatty-synthetic-source-integration-"));
const host = createPluginHostService({
app: { getPath: () => root, getAppPath: () => process.cwd(), getVersion: () => "0.0.0" },
electron: {}, env: {},
});
const database = host.database;
const pluginId = "com.example.source-log-fixture";
// The current source contract encodes every namespace, including local.
const { LOCAL_SOURCE } = require("./sourceIdentity.cjs");
database.installVersion({ pluginId: "com.example.local-source-fixture", version: "1.0.0", manifest: {}, archiveSha256: "b".repeat(64), packageRelativePath: "com.example.local-source-fixture/1.0.0", source: LOCAL_SOURCE });
const localOwner = database.privateOwner("com.example.local-source-fixture");
assert.notEqual(localOwner, "com.example.local-source-fixture");
assert.equal(database.publicPluginId(localOwner), "com.example.local-source-fixture");
const first = thirdPartySource("Synthetic first source", "https://synthetic-first.example.com");
const install = (source) => database.installVersion({ pluginId, version: "1.0.0", manifest: {}, archiveSha256: "a".repeat(64), packageRelativePath: `${pluginId}/1.0.0`, source }, { enable: true });
install(first);
const service = host.logService;
t.after(async () => { await host.manager.shutdown().catch(() => {}); await fs.rm(root, { recursive: true, force: true }); });
await service.ready;
await service.configure({ saveToDisk: true, verbose: true });
const activation = { pluginId, pluginVersion: "1.0.0", runtimeId: "source-fixture-A", runtimeKind: "browser" };
const firstLogger = service.createLogger(activation, database.getActivePlugin(pluginId));
firstLogger.audit({ operation: "runtime.running", result: "ok", authorization: "not-required" });
await service.sink.flush();
const oldOwner = database.privateOwner(pluginId);
let resume;
const queueGate = new Promise((resolve) => { resume = resolve; });
service.sink.queue = service.sink.queue.then(() => queueGate);
await firstLogger.write("info", "synthetic queued before replacement");
database.removePlugin(pluginId);
assert.equal(database.getRetainedPluginSource(pluginId).id, first.id);
database.clearUninstalledSourceData(pluginId, first.id);
install(OFFICIAL_SOURCE);
assert.equal(database.publicPluginId(oldOwner), null);
resume();
await service.sink.flush();
assert.equal(service.status().droppedRecords, 1);
const replacement = service.createLogger({ ...activation, runtimeId: "source-fixture-B" }, database.getActivePlugin(pluginId));
replacement.audit({ operation: "runtime.running", result: "ok", authorization: "not-required" });
await service.sink.flush();
const records = (await fs.readFile(service.sink.file(0), "utf8")).trim().split("\n").map(JSON.parse);
assert.equal(records.length, 2);
assert.equal(records[0].sourceId, first.id);
assert.equal(records[0].namespace, oldOwner);
assert.equal(records[1].sourceId, "official");
assert.equal(records[1].namespace, database.privateOwner(pluginId));
assert.equal(Object.hasOwn(records[1], "officiallyMaintained"), false);
assert.doesNotMatch(JSON.stringify(records), /queued before replacement|synthetic-first.example.com/);
replacement.audit({ operation: "synthetic.before-shutdown", result: "ok" });
host.companionSupervisor.shutdown = async () => { throw new Error("synthetic companion shutdown failure"); };
await assert.rejects(host.manager.shutdown(), /synthetic companion shutdown failure/);
assert.equal(service.sink.closed, true);
const drained = (await fs.readFile(service.sink.file(0), "utf8")).trim().split("\n").map(JSON.parse);
assert.equal(drained.at(-1).operation, "synthetic.before-shutdown");
assert.equal(drained.at(-1).sourceId, "official");
});
@@ -0,0 +1,149 @@
"use strict";
const assert = require("node:assert/strict");
const fs = require("node:fs/promises");
const os = require("node:os");
const path = require("node:path");
const test = require("node:test");
const { createPluginLogProvenanceResolver } = require("./pluginLogProvenance.cjs");
const { PluginLogService } = require("./pluginLogService.cjs");
function sourceFixture(pluginId = "com.example.synthetic-source-logging") {
const identity = { pluginId, pluginVersion: "1.0.0", archiveSha256: "a".repeat(64), runtimeId: "host-runtime-A", runtimeKind: "browser" };
let source = { id: `third-party:${"b".repeat(64)}`, kind: "third-party", name: "Synthetic source", origin: "https://synthetic.example.com" };
let active = { id: pluginId, activeVersion: identity.pluginVersion, archiveSha256: identity.archiveSha256, source };
let retained = source;
const owner = () => source ? `source:${source.id === "official" ? "c".repeat(64) : "d".repeat(64)}:${pluginId}` : null;
const database = {
getActivePlugin(id) { assert.equal(id, pluginId); return active; },
getRetainedPluginSource(id) { assert.equal(id, pluginId); return retained; },
privateOwner(id) { assert.equal(id, pluginId); return owner(); },
publicPluginId(encoded) { return encoded === owner() ? pluginId : null; },
};
return { identity, database, plugin: () => active, setActive(value) { active = value; },
setRetained(value) { retained = value; },
replace() { source = { id: "official", kind: "official", name: "Not a maintenance claim", origin: "https://plugins.netcatty.app" }; active = { ...active, source }; retained = source; },
uninstall() { active = null; },
};
}
test("host provenance uses the bound owner and ignores plugin-supplied source metadata", () => {
const f = sourceFixture();
const resolver = createPluginLogProvenanceResolver(f.database);
const result = resolver({ ...f.identity, sourceId: "official", source: { id: "official" }, owner: "spoof" });
assert.equal(result.sourceId, f.plugin().source.id);
assert.equal(result.sourceKind, "third-party");
assert.equal(result.namespace, f.database.privateOwner(f.identity.pluginId));
assert.equal(result.isCurrent(), true);
assert.equal(Object.hasOwn(result, "officiallyMaintained"), false);
assert.equal(Object.hasOwn(result, "origin"), false);
f.replace();
assert.equal(result.isCurrent(), false);
assert.equal(resolver(f.identity).sourceId, "official");
});
test("missing source APIs, retained mismatch, orphaned owner and changed package remain unknown", () => {
const f = sourceFixture();
const resolve = createPluginLogProvenanceResolver(f.database);
assert.equal(createPluginLogProvenanceResolver({ getActivePlugin: f.database.getActivePlugin })(f.identity), null);
assert.equal(resolve({ ...f.identity, pluginVersion: "2.0.0" }), null);
assert.equal(resolve({ ...f.identity, archiveSha256: "c".repeat(64) }), null);
f.database.privateOwner = () => null;
assert.equal(resolve(f.identity), null, "no bare pluginId fallback");
f.database.privateOwner = () => f.identity.pluginId;
f.database.publicPluginId = (owner) => owner;
assert.equal(resolve(f.identity), null, "a bare owner is not an authenticated namespace");
f.database.privateOwner = () => "source:orphan";
assert.equal(resolve(f.identity), null);
const g = sourceFixture();
g.setRetained({ id: "different", kind: "third-party" });
assert.equal(createPluginLogProvenanceResolver(g.database)(g.identity), null);
const h = sourceFixture();
const snapshot = createPluginLogProvenanceResolver(h.database)(h.identity);
h.uninstall();
assert.equal(snapshot.isCurrent(), false);
assert.equal(createPluginLogProvenanceResolver(h.database)(h.identity), null, "retained source alone cannot authenticate an installed activation");
});
test("queued diagnostics and host facts are revalidated after async I/O before commit", async (t) => {
const f = sourceFixture(`com.${"a".repeat(120)}`);
const root = await fs.mkdtemp(path.join(os.tmpdir(), "netcatty-synthetic-source-log-"));
const service = new PluginLogService({ paths: { root, logs: path.join(root, "logs") }, resolveProvenance: createPluginLogProvenanceResolver(f.database) });
t.after(async () => { await service.close(); await fs.rm(root, { recursive: true, force: true }); });
await service.ready;
assert.equal(service.status().saveToDisk, false);
await service.configure({ saveToDisk: true, verbose: true });
const loggerA = service.createLogger(f.identity, f.plugin());
loggerA.audit({ operation: "runtime.running", authorization: "not-required", result: "ok" });
await service.sink.flush();
let release;
let entered;
const paused = new Promise((resolve) => { entered = resolve; });
const gate = new Promise((resolve) => { release = resolve; });
const prune = service.sink.prune.bind(service.sink);
service.sink.prune = async () => { entered(); await gate; return prune(); };
loggerA.audit({ operation: "synthetic.pending-source-A", authorization: "allowed", result: "ok" });
await loggerA.write("info", "synthetic pending diagnostic A");
await paused;
f.replace();
release();
await service.sink.flush();
assert.equal(service.status().droppedRecords, 2);
const loggerB = service.createLogger({ ...f.identity, runtimeId: "host-runtime-B" }, f.plugin());
loggerB.audit({ operation: "runtime.running", authorization: "not-required", result: "ok" });
await service.sink.flush();
const records = (await fs.readFile(service.sink.file(0), "utf8")).trim().split("\n").map(JSON.parse);
assert.equal(records.length, 2);
assert.match(records[0].sourceId, /^third-party:/u);
assert.equal(records[1].sourceId, "official");
assert.notEqual(records[0].namespace, records[1].namespace);
assert.equal(records[0].runtimeId, "host-runtime-A");
assert.equal(records[1].runtimeId, "host-runtime-B");
assert.doesNotMatch(JSON.stringify(records), /synthetic.pending-source-A|pending diagnostic|example.com|maintenance/i);
f.uninstall();
await loggerB.write("info", "synthetic stale callback");
loggerB.audit({ operation: "synthetic.after-uninstall", result: "ok" });
assert.equal(service.status().droppedRecords, 4);
});
test("a thrown source revalidation fails closed without breaking plugin execution", async (t) => {
const f = sourceFixture();
const root = await fs.mkdtemp(path.join(os.tmpdir(), "netcatty-synthetic-source-failure-"));
const service = new PluginLogService({ paths: { root, logs: path.join(root, "logs") }, resolveProvenance: createPluginLogProvenanceResolver(f.database) });
t.after(async () => { await service.close(); await fs.rm(root, { recursive: true, force: true }); });
await service.ready;
await service.configure({ saveToDisk: true, verbose: true });
const logger = service.createLogger(f.identity, f.plugin());
f.database.getActivePlugin = () => { throw new Error("synthetic unavailable source database"); };
await logger.write("info", "synthetic ignored");
logger.audit({ operation: "storage.get", result: "ok" });
await service.sink.flush();
assert.equal(service.status().droppedRecords, 2);
assert.equal(service.status().writeErrors, 0);
});
test("a stale queued source cannot rotate away existing audit evidence", async (t) => {
const f = sourceFixture();
const root = await fs.mkdtemp(path.join(os.tmpdir(), "netcatty-synthetic-source-rotation-"));
const service = new PluginLogService({ paths: { root, logs: path.join(root, "logs") }, limits: { maxFiles: 1 }, resolveProvenance: createPluginLogProvenanceResolver(f.database) });
t.after(async () => { await service.close(); await fs.rm(root, { recursive: true, force: true }); });
await service.ready;
await service.configure({ saveToDisk: true, verbose: false });
const logger = service.createLogger(f.identity, f.plugin());
logger.audit({ operation: "runtime.running", authorization: "not-required", result: "ok" });
await service.sink.flush();
const before = await fs.readFile(service.sink.file(0), "utf8");
service.sink.maxBytes = Buffer.byteLength(before) + 16;
let resume;
let entered;
const paused = new Promise((resolve) => { entered = resolve; });
const gate = new Promise((resolve) => { resume = resolve; });
const prune = service.sink.prune.bind(service.sink);
service.sink.prune = async () => { entered(); await gate; return prune(); };
logger.audit({ operation: "runtime.running", authorization: "not-required", result: "ok" });
await paused;
f.replace();
resume();
await service.sink.flush();
assert.equal(service.status().droppedRecords, 1);
assert.equal(await fs.readFile(service.sink.file(0), "utf8"), before);
});
+150
View File
@@ -0,0 +1,150 @@
"use strict";
const fs = require("node:fs");
const fsp = require("node:fs/promises");
const path = require("node:path");
const { createHmac, randomBytes } = require("node:crypto");
const { PluginLogger, PluginLogSink } = require("./pluginLogger.cjs");
const { RPC_ERRORS } = require("./rpcRouter.cjs");
const bounded = (value, length = 128) => typeof value === "string"
? value.slice(0, length).replace(/[^a-zA-Z0-9._:@/-]/gu, "_") : null;
const sourceKey = (value) => typeof value === "string" && /^[a-zA-Z0-9._:-]{1,128}$/u.test(value) ? value : null;
const ownerKey = (value) => typeof value === "string" && /^[a-zA-Z0-9._:-]{1,256}$/u.test(value) ? value : null;
const count = (value) => Number.isSafeInteger(value) && value >= 0 ? value : null;
class PluginLogService {
constructor(options) {
this.directory = path.join(options.paths.logs, "host-v1");
this.preferencesPath = path.join(options.paths.root, "local-logging.json");
this.resolveProvenance = options.resolveProvenance ?? (() => null);
this.electron = options.electron;
this.provenanceChecks = new WeakMap();
this.salt = randomBytes(32);
this.sink = new PluginLogSink({ directory: this.directory, ...options.limits });
this.settings = { saveToDisk: false, verbose: false };
this.management = Promise.resolve();
try {
if (fs.statSync(this.preferencesPath).size <= 1024) {
const saved = JSON.parse(fs.readFileSync(this.preferencesPath, "utf8"));
this.settings.saveToDisk = saved.saveToDisk === true;
// Free-form diagnostics always require fresh consent after app restart.
}
} catch {}
this.ready = this.sink.setEnabled(this.settings.saveToDisk).then(async () => {
if (!this.sink.enabled && fs.existsSync(this.directory)) await this.sink.prune();
}).catch(() => {
this.sink.writeErrors++;
this.settings.saveToDisk = false;
this.sink.enabled = false;
});
this.retentionTimer = setInterval(() => {
if (!fs.existsSync(this.directory) || this.sink.closed) return;
this.sink.queue = this.sink.queue.then(() => this.sink.prune()).catch(() => { this.sink.writeErrors++; });
}, 60 * 60 * 1000);
this.retentionTimer.unref?.();
}
status() {
return { ...this.settings, ...this.sink.status(), maxBytes: this.sink.maxBytes * this.sink.maxFiles,
retentionDays: this.sink.retentionMs / 86400000 };
}
async configure(request) {
if (!request || typeof request.saveToDisk !== "boolean" || typeof request.verbose !== "boolean") throw new TypeError("Invalid plugin log settings");
return this.#manage(async () => {
if (this.sink.closed) throw new Error("Plugin logging is closed");
const settings = { saveToDisk: request.saveToDisk, verbose: request.saveToDisk && request.verbose };
// Stop ingress before changing preferences. Failure leaves persistence off.
this.settings = { saveToDisk: false, verbose: false };
await this.sink.setEnabled(false);
await fsp.mkdir(path.dirname(this.preferencesPath), { recursive: true, mode: 0o700 });
const temporary = `${this.preferencesPath}.tmp`;
await fsp.writeFile(temporary, JSON.stringify({ saveToDisk: settings.saveToDisk }), { mode: 0o600 });
await fsp.rename(temporary, this.preferencesPath);
await this.sink.setEnabled(settings.saveToDisk);
this.settings = settings;
return this.status();
});
}
#manage(action) {
const result = this.management.then(() => this.ready).then(action);
this.management = result.catch(() => {});
return result;
}
identity(identity, plugin) {
// Only the injected HOST resolver may assign source and namespace identity.
let source = null;
try { source = this.resolveProvenance(Object.freeze({ pluginId: identity.pluginId, pluginVersion: identity.pluginVersion,
archiveSha256: plugin?.archiveSha256 ?? null, securityPrincipal: identity.securityPrincipal })); } catch {}
const metadata = Object.freeze({ pluginId: bounded(identity.pluginId), pluginVersion: bounded(identity.pluginVersion),
runtimeId: bounded(identity.runtimeId), runtimeKind: bounded(identity.runtimeKind),
archiveSha256: /^[a-f0-9]{64}$/u.test(plugin?.archiveSha256 ?? "") ? plugin.archiveSha256 : null,
sourceId: sourceKey(source?.sourceId), sourceKind: sourceKey(source?.sourceKind), namespace: ownerKey(source?.namespace) });
if (source?.isCurrent && typeof source.isCurrent === "function") {
this.provenanceChecks.set(metadata, () => source.isCurrent() === true);
}
return metadata;
}
createLogger(identity, plugin) {
const provenance = this.identity(identity, plugin);
return new PluginLogger({ sink: this.sink, identity: provenance, verbose: () => this.settings.verbose, isCurrent: this.provenanceChecks.get(provenance), audit: (event) => this.audit(provenance, event) });
}
audit(identity, event) {
if (!this.sink.enabled) return;
// An allowlist, never spread request/response/error objects into an audit.
const resources = Array.isArray(event.resources) ? event.resources.slice(0, 16) : [];
this.sink.enqueue({ timestamp: new Date().toISOString(), recordType: "host-audit", trust: "host-observed",
...identity, operation: bounded(event.operation, 256),
targetIds: resources.filter((item) => typeof item === "string").map((item) =>
createHmac("sha256", this.salt).update(item.slice(0, 8192)).digest("hex")),
authorization: ["allowed", "denied", "not-required", "not-reached", "unknown"].includes(event.authorization) ? event.authorization : "unknown",
result: event.result === "ok" ? "ok" : "error",
errorCode: Object.values(RPC_ERRORS).includes(event.errorCode) ? event.errorCode : event.result === "ok" ? null : RPC_ERRORS.internal,
durationMs: count(event.durationMs), inputBytes: count(event.inputBytes), outputBytes: count(event.outputBytes) }, this.provenanceChecks.get(identity));
}
async openFolder() {
return this.#manage(async () => {
await fsp.mkdir(this.directory, { recursive: true, mode: 0o700 });
const error = await this.electron.shell.openPath(this.directory);
if (error) throw new Error("Could not open plugin log folder");
return this.status();
});
}
async exportLogs() {
return this.#manage(async () => {
const result = await this.electron.dialog.showSaveDialog({ title: "Export local plugin logs", defaultPath: "netcatty-plugin-logs.jsonl" });
if (result.canceled || !result.filePath) return this.status();
if (path.resolve(result.filePath).startsWith(path.resolve(this.directory) + path.sep)) throw new Error("Choose a destination outside the log folder");
const task = this.sink.queue.then(async () => {
const chunks = [];
for (let index = this.sink.maxFiles - 1; index >= 0; index--) {
try {
const stats = await fsp.lstat(this.sink.file(index));
if (!stats.isFile() || stats.isSymbolicLink() || stats.size > this.sink.maxBytes) throw new Error("Unsafe plugin log file");
if (await this.sink.retained(this.sink.file(index))) chunks.push(await fsp.readFile(this.sink.file(index)));
} catch (error) { if (error.code !== "ENOENT") throw error; }
}
await fsp.writeFile(result.filePath, Buffer.concat(chunks), { mode: 0o600 });
});
this.sink.queue = task.catch(() => {});
await task;
return this.status();
});
}
async clear() {
return this.#manage(async () => {
const result = await this.electron.dialog.showMessageBox({ type: "warning", title: "Clear local plugin logs?",
message: "Delete the saved local audit and diagnostic files? The bounded security history in plugin permissions is kept.",
buttons: ["Cancel", "Clear logs"], defaultId: 0, cancelId: 0, noLink: true });
if (result.response === 1) await this.sink.clear();
return this.status();
});
}
async close() {
clearInterval(this.retentionTimer);
await this.management;
await this.ready;
await this.sink.close();
}
}
module.exports = { PluginLogService };
+163 -73
View File
@@ -1,96 +1,186 @@
"use strict";
const fs = require("node:fs/promises");
const { constants } = require("node:fs");
const path = require("node:path");
const { PLUGIN_LOG_MAX_BYTES, PLUGIN_LOG_MAX_FILES } = require("./constants.cjs");
const { assertPluginStorageSegment } = require("./paths.cjs");
const { TokenBucket, DEFAULT_QUOTAS } = require("./quotaManager.cjs");
const SENSITIVE_KEY = /(?:password|passwd|token|secret|private.?key|credential|authorization|cookie|totp|otpauth)/iu;
const SENSITIVE_KEY = /(?:password|passwd|token|secret|api.?key|private.?key|credential|authorization|cookie|totp|otpauth|prompt|notes?|sql|results?|terminal|content|body|payload)/iu;
function redactLogValue(value, key = "", depth = 0) {
// Bounds apply to the entire traversal, including hostile, cyclic or wide input.
// This is damage limitation for opt-in diagnostics, not a guarantee of secrecy.
function redactLogValue(value, key = "", depth = 0, budget = { remaining: 4096 }) {
if (SENSITIVE_KEY.test(key)) return "[REDACTED]";
if (depth >= 8) return "[TRUNCATED]";
if (value == null || typeof value === "boolean" || typeof value === "number") return value;
if (typeof value === "string") return value.slice(0, 2_048);
if (Array.isArray(value)) {
const output = [];
for (let index = 0; index < Math.min(value.length, 64); index += 1) {
const descriptor = Object.getOwnPropertyDescriptor(value, String(index));
output.push(descriptor && "value" in descriptor
? redactLogValue(descriptor.value, "", depth + 1)
: "[UNAVAILABLE]");
}
return output;
if (depth >= 6 || budget.remaining <= 0) return "[TRUNCATED]";
budget.remaining -= 16;
if (value == null || typeof value === "boolean") return value;
if (typeof value === "number") return Number.isFinite(value) ? value : null;
if (typeof value === "string") {
const text = value.slice(0, Math.min(2048, Math.max(0, budget.remaining))).replace(/[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/gu, " ");
budget.remaining -= text.length;
return text;
}
if (typeof value === "object") {
const output = {};
for (const entryKey of Object.keys(value).slice(0, 64)) {
if (SENSITIVE_KEY.test(entryKey)) {
output[entryKey.slice(0, 128)] = "[REDACTED]";
continue;
}
if (typeof value !== "object") return "[UNSUPPORTED]";
try {
const output = Array.isArray(value) ? [] : Object.create(null);
// Stop enumeration as soon as the budget is exhausted.
let count = 0;
for (const entryKey in value) {
if (budget.remaining <= 0 || count++ >= 32) break;
const descriptor = Object.getOwnPropertyDescriptor(value, entryKey);
output[entryKey.slice(0, 128)] = descriptor && "value" in descriptor
? redactLogValue(descriptor.value, entryKey, depth + 1)
: "[UNAVAILABLE]";
if (!descriptor?.enumerable) continue;
const safeKey = entryKey.slice(0, 64).replace(/[\u0000-\u001f\u007f]/gu, " ");
budget.remaining -= safeKey.length;
const item = "value" in descriptor ? redactLogValue(descriptor.value, entryKey, depth + 1, budget) : "[UNAVAILABLE]";
if (Array.isArray(output)) output.push(item);
else output[safeKey] = item;
}
return output;
} catch { return "[UNAVAILABLE]"; }
}
/** One app-owned sink gives every plugin a shared disk and queue budget. */
class PluginLogSink {
constructor(options) {
this.directory = options.directory;
this.maxBytes = options.maxBytes ?? PLUGIN_LOG_MAX_BYTES;
this.maxFiles = options.maxFiles ?? PLUGIN_LOG_MAX_FILES;
this.maxPendingBytes = options.maxPendingBytes ?? 256 * 1024;
this.retentionMs = options.retentionMs ?? 7 * 24 * 60 * 60 * 1000;
for (const value of [this.maxBytes, this.maxFiles, this.maxPendingBytes, this.retentionMs]) {
if (!Number.isSafeInteger(value) || value <= 0) throw new TypeError("Invalid plugin log limit");
}
this.clock = options.clock ?? Date.now;
this.enabled = false;
this.closed = false;
this.pendingBytes = 0;
this.diagnosticPendingBytes = 0;
this.dropped = 0;
this.writeErrors = 0;
this.queue = Promise.resolve();
this.generation = 0;
}
return "[UNSUPPORTED]";
enqueue(record, isCurrent = () => true) {
if (!this.enabled || this.closed) return;
if (this.clearing) { this.dropped++; return; }
const current = () => { try { return isCurrent() === true; } catch { return false; } };
if (!current()) { this.dropped++; return; }
let line;
try { line = `${JSON.stringify({ ...record, timestamp: new Date(this.clock()).toISOString(), droppedRecords: this.dropped, writeErrors: this.writeErrors })}\n`; }
catch { this.dropped++; return; }
const bytes = Buffer.byteLength(line);
const diagnostic = record.recordType === "plugin-diagnostic";
if ((diagnostic && this.diagnosticPendingBytes + bytes > this.maxPendingBytes / 4) || bytes > Math.min(this.maxBytes, 16 * 1024) || this.pendingBytes + bytes > this.maxPendingBytes) {
this.dropped++; return;
}
const generation = this.generation;
this.pendingBytes += bytes;
if (diagnostic) this.diagnosticPendingBytes += bytes;
this.queue = this.queue.then(async () => {
if (generation !== this.generation || !current()) { this.dropped++; return; }
await this.prune();
const active = this.file(0);
let size = 0;
try { size = (await fs.stat(active)).size; } catch (error) { if (error.code !== "ENOENT") throw error; }
if (!current()) { this.dropped++; return; }
if (size + bytes > this.maxBytes) {
await fs.rm(this.file(this.maxFiles - 1), { force: true });
for (let index = this.maxFiles - 2; index >= 0; index--) {
if (!current()) { this.dropped++; return; }
try { await fs.rename(this.file(index), this.file(index + 1)); }
catch (error) { if (error.code !== "ENOENT") throw error; }
}
}
if (!current()) { this.dropped++; return; }
const handle = await fs.open(active, constants.O_WRONLY | constants.O_CREAT | constants.O_APPEND | (constants.O_NOFOLLOW ?? 0), 0o600);
try {
if (!(await handle.stat()).isFile()) throw new Error("Unsafe plugin log file");
if (!current()) { this.dropped++; return; }
await handle.writeFile(line);
} finally { await handle.close(); }
}).catch(() => { this.dropped++; this.writeErrors++; }).finally(() => {
this.pendingBytes -= bytes;
if (diagnostic) this.diagnosticPendingBytes -= bytes;
});
}
file(index) { return path.join(this.directory, index === 0 ? "runtime.jsonl" : `runtime.${index}.jsonl`); }
async retained(file) {
// Every host record is capped at 16 KiB. Read only the first line: append
// updates mtime, so mtime cannot enforce record retention for active files.
const handle = await fs.open(file, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
try {
const buffer = Buffer.alloc(16 * 1024);
const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
const newline = buffer.indexOf(10, 0);
if (newline < 0 || newline >= bytesRead) return false;
let first;
try { first = JSON.parse(buffer.subarray(0, newline).toString("utf8")); } catch { return false; }
const start = typeof first.timestamp === "string" ? Date.parse(first.timestamp) : NaN;
return Number.isFinite(start) && this.clock() - start < this.retentionMs;
} finally { await handle.close(); }
}
async prune() {
await fs.mkdir(this.directory, { recursive: true, mode: 0o700 });
for (let index = 0; index < this.maxFiles; index++) {
try {
const file = this.file(index);
const stats = await fs.lstat(file);
if (!stats.isFile() || stats.isSymbolicLink()) throw new Error("Unsafe plugin log file");
if (stats.size > this.maxBytes || !await this.retained(file)) await fs.rm(file);
} catch (error) { if (error.code !== "ENOENT") throw error; }
}
}
flush() { return this.queue; }
async setEnabled(enabled) {
this.enabled = false;
this.generation++;
await this.flush();
if (enabled === true && !this.closed) {
await this.prune();
this.enabled = true;
}
}
async clear() {
this.clearing = true;
this.generation++;
try {
await this.flush();
for (let index = 0; index < this.maxFiles; index++) await fs.rm(this.file(index), { force: true });
} finally { this.clearing = false; }
}
async close() { this.closed = true; this.enabled = false; await this.flush(); }
status() { return { droppedRecords: this.dropped, writeErrors: this.writeErrors, pendingBytes: this.pendingBytes }; }
}
class PluginLogger {
constructor(options) {
this.pluginId = assertPluginStorageSegment(options.pluginId, "ID");
this.directory = path.join(options.logsDirectory, this.pluginId);
this.maxBytes = options.maxBytes ?? PLUGIN_LOG_MAX_BYTES;
this.maxFiles = options.maxFiles ?? PLUGIN_LOG_MAX_FILES;
this.maxPendingBytes = options.maxPendingBytes ?? Math.min(this.maxBytes, 256 * 1024);
this.pendingBytes = 0;
this.queue = Promise.resolve();
constructor(options = {}) {
this.sink = options.sink;
this.identity = options.identity;
this.verbose = options.verbose ?? (() => false);
this.isCurrent = options.isCurrent;
this.closed = false;
this.audit = options.audit ?? (() => {});
this.bucket = new TokenBucket(DEFAULT_QUOTAS.logWritesPerMinute, DEFAULT_QUOTAS.logWritesPerMinute / 60000, options.clock ?? Date.now);
}
write(level, message, fields) {
const record = JSON.stringify({
if (this.closed || !this.sink?.enabled || !this.verbose()) return Promise.resolve();
if (!this.bucket.consume()) { this.sink.dropped++; return Promise.resolve(); }
this.sink.enqueue({
timestamp: new Date().toISOString(),
recordType: "plugin-diagnostic", trust: "plugin-authored-unverified",
...this.identity,
level: ["debug", "info", "warn", "error"].includes(level) ? level : "info",
message: String(message ?? "").slice(0, 2_048),
...(fields === undefined ? {} : { fields: redactLogValue(fields) }),
});
const line = `${record}\n`;
const bytes = Buffer.byteLength(line);
if (this.pendingBytes + bytes > this.maxPendingBytes) return Promise.resolve();
this.pendingBytes += bytes;
this.queue = this.queue
.then(() => this.#append(line))
.catch(() => {})
.finally(() => { this.pendingBytes -= bytes; });
return this.queue;
}
async #append(line) {
await fs.mkdir(this.directory, { recursive: true, mode: 0o700 });
const activePath = path.join(this.directory, "runtime.log");
let size = 0;
try { size = (await fs.stat(activePath)).size; } catch {}
if (size + Buffer.byteLength(line) > this.maxBytes) await this.#rotate();
await fs.appendFile(activePath, line, { encoding: "utf8", mode: 0o600 });
}
async #rotate() {
await fs.rm(path.join(this.directory, `runtime.${this.maxFiles}.log`), { force: true });
for (let index = this.maxFiles - 1; index >= 1; index -= 1) {
try {
await fs.rename(
path.join(this.directory, index === 1 ? "runtime.log" : `runtime.${index - 1}.log`),
path.join(this.directory, `runtime.${index}.log`),
);
} catch (error) {
if (error?.code !== "ENOENT") throw error;
}
}
diagnostic: {
message: redactLogValue(typeof message === "string" ? message : "[INVALID MESSAGE]"),
fields: redactLogValue(fields),
},
}, this.isCurrent);
return Promise.resolve();
}
close() { this.closed = true; }
}
module.exports = { PluginLogger, redactLogValue };
module.exports = { PluginLogger, PluginLogSink, redactLogValue };
Loaded 100 of 155 files, more files were not shown because too many files have changed in this diff. Show more