mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-10-30 10:22:33 +00:00
Compare commits
32
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f1410f8bb1 | ||
|
|
c4433d4961 | ||
|
|
4213d8e672 | ||
|
|
264f1d983c | ||
|
|
35e3f9f99f | ||
|
|
4882e0501c | ||
|
|
c3a5f810ce | ||
|
|
59721f9717 | ||
|
|
dd41f9d8d0 | ||
|
|
6392ac9532 | ||
|
|
afe831bfc5 | ||
|
|
ab8764a317 | ||
|
|
7e72ae65ad | ||
|
|
f2e878858f | ||
|
|
2ff86c2217 | ||
|
|
5e6278cbc7 | ||
|
|
83952aac0d | ||
|
|
7ec6473112 | ||
|
|
17e6834f74 | ||
|
|
9ed8bf76dd | ||
|
|
91b8927c56 | ||
|
|
1e5e77fb9c | ||
|
|
f868221b5f | ||
|
|
fd45463d98 | ||
|
|
fdeeaf74bb | ||
|
|
ffabb9e9c7 | ||
|
|
733f44cfdc | ||
|
|
a8adeb86fe | ||
|
|
0c097a1260 | ||
|
|
5c9286775e | ||
|
|
d0e2c905cb | ||
|
|
c11cde9bb2 |
No files matched your search
@@ -167,7 +167,8 @@ function AppPluginKeybindingThemeHost({
|
||||
sessions: any[];
|
||||
workspaces: any[];
|
||||
}) {
|
||||
const appThemeStyle = useAppThemeStyle();
|
||||
// View environment is global in the current host: publish committed colors only.
|
||||
const appThemeStyle = useAppThemeStyle(false);
|
||||
const { resolvedTheme, uiLanguage } = useSettingsChromeStore();
|
||||
const pluginThemeTokens = useMemo(
|
||||
() => selectPluginThemeTokens(appThemeStyle as Record<string, unknown>),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { usePluginThemeRevision } from '../state/usePluginThemeRuntime';
|
||||
import type React from 'react';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
@@ -12,17 +13,20 @@ import { getUiThemeById } from '../../infrastructure/config/uiThemes';
|
||||
* settingsChromeStore so accent drags only re-render the leaf that applies the
|
||||
* vars, never the App shell.
|
||||
*/
|
||||
export function useAppThemeStyle(): React.CSSProperties {
|
||||
export function useAppThemeStyle(includePreview = true): React.CSSProperties {
|
||||
const pluginThemeRevision = usePluginThemeRevision();
|
||||
const { accentMode, customAccent } = useAppearanceChromeStore();
|
||||
const { resolvedTheme, darkUiThemeId, lightUiThemeId } = useSettingsChromeStore();
|
||||
return useMemo(() => {
|
||||
void pluginThemeRevision; // getUiThemeById reads this external registry revision.
|
||||
const tokens = getUiThemeById(
|
||||
resolvedTheme,
|
||||
resolvedTheme === 'dark' ? darkUiThemeId : lightUiThemeId,
|
||||
includePreview,
|
||||
).tokens;
|
||||
return {
|
||||
...buildAppThemeCssVars(tokens, accentMode, customAccent),
|
||||
colorScheme: resolvedTheme,
|
||||
} as React.CSSProperties;
|
||||
}, [accentMode, customAccent, darkUiThemeId, lightUiThemeId, resolvedTheme]);
|
||||
}, [accentMode, customAccent, darkUiThemeId, lightUiThemeId, resolvedTheme, pluginThemeRevision, includePreview]);
|
||||
}
|
||||
@@ -1246,6 +1246,9 @@ Highlight the focused split pane:
|
||||
"settings.plugins.manager.remove": "Uninstall",
|
||||
"settings.plugins.manager.removeNote": "Uninstall removes package code and local KV data. User-owned settings, secrets and sync sidecars are retained.",
|
||||
"settings.plugins.permissions.statusUnknown": "Status unknown",
|
||||
"plugins.sidebar.extensions": "Extension pages",
|
||||
"plugins.sidebar.selectTerminal": "Select an open terminal first.",
|
||||
"plugins.sidebar.loading": "Loading extension page…",
|
||||
"plugins.vault.loading": "Loading plugin page…",
|
||||
"plugins.vault.extensions": "Extensions",
|
||||
"plugins.vault.back": "Back to hosts",
|
||||
@@ -1277,11 +1280,27 @@ Highlight the focused split pane:
|
||||
"settings.plugins.manager.noPermissions": "No privileged permissions declared.",
|
||||
"settings.plugins.manager.required": "Required",
|
||||
"settings.plugins.manager.optional": "Optional",
|
||||
"settings.plugins.themes.title": "Plugin themes",
|
||||
"settings.plugins.themes.description": "Preview changes only this window. Apply saves this device’s selection. Personal accent, fonts and terminal overrides stay in control.",
|
||||
"settings.plugins.themes.empty": "No enabled plugin themes for this mode.",
|
||||
"settings.plugins.themes.choose": "Choose a theme",
|
||||
"settings.plugins.themes.unavailable": "Saved theme unavailable; using the host default",
|
||||
"settings.plugins.themes.applied": "Applied",
|
||||
"settings.plugins.themes.preview": "Preview",
|
||||
"settings.plugins.themes.apply": "Apply",
|
||||
"settings.plugins.themes.cancel": "Cancel preview",
|
||||
"settings.plugins.themes.restore": "Restore default",
|
||||
"settings.plugins.themes.invalid": "Some theme palettes were rejected or unavailable.",
|
||||
"settings.plugins.themes.failed": "Could not apply theme.",
|
||||
"settings.plugins.manager.dataTheme": "Theme pack: validated colors, no executable runtime.",
|
||||
"settings.plugins.manager.browser": "Browser runtime: privileged access goes through host permission brokers.",
|
||||
"settings.plugins.manager.advanced": "Advanced runtime: full Node filesystem and network authority. Broker permissions cannot restrict this access. Development preview only.",
|
||||
"settings.plugins.manager.data": "Local KV stays on this device. Only non-secret settings explicitly marked for sync participate in encrypted cloud sidecars. Packages and permission grants do not sync.",
|
||||
"settings.plugins.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.",
|
||||
@@ -1312,6 +1331,25 @@ 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…',
|
||||
|
||||
@@ -1246,6 +1246,9 @@ Resalta el panel dividido enfocado:
|
||||
"settings.plugins.manager.remove": "Desinstalar",
|
||||
"settings.plugins.manager.removeNote": "La desinstalación elimina el código y los datos KV locales. Se conservan los ajustes del usuario, los secretos y los datos auxiliares de sincronización.",
|
||||
"settings.plugins.permissions.statusUnknown": "Estado desconocido",
|
||||
"plugins.sidebar.extensions": "Páginas de extensiones",
|
||||
"plugins.sidebar.selectTerminal": "Selecciona primero un terminal abierto.",
|
||||
"plugins.sidebar.loading": "Cargando página de extensión…",
|
||||
"plugins.vault.loading": "Cargando página del plugin…",
|
||||
"plugins.vault.extensions": "Extensiones",
|
||||
"plugins.vault.back": "Volver a hosts",
|
||||
@@ -1277,11 +1280,27 @@ Resalta el panel dividido enfocado:
|
||||
"settings.plugins.manager.noPermissions": "No se han declarado permisos privilegiados.",
|
||||
"settings.plugins.manager.required": "Obligatorio",
|
||||
"settings.plugins.manager.optional": "Opcional",
|
||||
"settings.plugins.themes.title": "Temas de plugins",
|
||||
"settings.plugins.themes.description": "La vista previa solo cambia esta ventana. Aplicar guarda la selección en este dispositivo. Se conservan los ajustes personales.",
|
||||
"settings.plugins.themes.empty": "No hay temas habilitados para este modo.",
|
||||
"settings.plugins.themes.choose": "Elegir tema",
|
||||
"settings.plugins.themes.unavailable": "Tema guardado no disponible; se usa el predeterminado",
|
||||
"settings.plugins.themes.applied": "Aplicado",
|
||||
"settings.plugins.themes.preview": "Vista previa",
|
||||
"settings.plugins.themes.apply": "Aplicar",
|
||||
"settings.plugins.themes.cancel": "Cancelar vista previa",
|
||||
"settings.plugins.themes.restore": "Restaurar predeterminado",
|
||||
"settings.plugins.themes.invalid": "Algunas paletas no están disponibles o no son válidas.",
|
||||
"settings.plugins.themes.failed": "No se pudo aplicar el tema.",
|
||||
"settings.plugins.manager.dataTheme": "Paquete de temas: colores validados, sin ejecución de código.",
|
||||
"settings.plugins.manager.browser": "Entorno de navegador: el acceso privilegiado pasa por los controles de permisos del anfitrión.",
|
||||
"settings.plugins.manager.advanced": "Entorno avanzado: acceso completo de Node al sistema de archivos y a la red. Los permisos del intermediario no restringen este acceso. Solo para desarrollo.",
|
||||
"settings.plugins.manager.data": "Los datos KV permanecen en este dispositivo. Solo los ajustes no secretos marcados para sincronización participan en los datos auxiliares cifrados. Los paquetes y las autorizaciones no se sincronizan.",
|
||||
"settings.plugins.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.",
|
||||
@@ -1312,6 +1331,25 @@ 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…',
|
||||
|
||||
@@ -1141,6 +1141,9 @@ export const ruCoreMessages: Messages = {
|
||||
"settings.plugins.manager.remove": "Удалить",
|
||||
"settings.plugins.manager.removeNote": "Удаление очищает код пакета и локальные данные KV. Настройки пользователя, секреты и данные синхронизации сохраняются.",
|
||||
"settings.plugins.permissions.statusUnknown": "Состояние неизвестно",
|
||||
"plugins.sidebar.extensions": "Страницы расширений",
|
||||
"plugins.sidebar.selectTerminal": "Сначала выберите открытый терминал.",
|
||||
"plugins.sidebar.loading": "Загрузка страницы расширения…",
|
||||
"plugins.vault.loading": "Загрузка страницы плагина…",
|
||||
"plugins.vault.extensions": "Расширения",
|
||||
"plugins.vault.back": "К хостам",
|
||||
@@ -1172,11 +1175,27 @@ export const ruCoreMessages: Messages = {
|
||||
"settings.plugins.manager.noPermissions": "Привилегированные разрешения не заявлены.",
|
||||
"settings.plugins.manager.required": "Обязательно",
|
||||
"settings.plugins.manager.optional": "Необязательно",
|
||||
"settings.plugins.themes.title": "Темы плагинов",
|
||||
"settings.plugins.themes.description": "Предпросмотр меняет только это окно. Применение сохраняет выбор на этом устройстве. Личные настройки сохраняются.",
|
||||
"settings.plugins.themes.empty": "Нет включённых тем для этого режима.",
|
||||
"settings.plugins.themes.choose": "Выберите тему",
|
||||
"settings.plugins.themes.unavailable": "Сохранённая тема недоступна; используется стандартная палитра",
|
||||
"settings.plugins.themes.applied": "Применено",
|
||||
"settings.plugins.themes.preview": "Предпросмотр",
|
||||
"settings.plugins.themes.apply": "Применить",
|
||||
"settings.plugins.themes.cancel": "Отменить предпросмотр",
|
||||
"settings.plugins.themes.restore": "Сбросить тему",
|
||||
"settings.plugins.themes.invalid": "Некоторые палитры недоступны или отклонены.",
|
||||
"settings.plugins.themes.failed": "Не удалось применить тему.",
|
||||
"settings.plugins.manager.dataTheme": "Пакет тем: проверенные цвета без выполнения кода.",
|
||||
"settings.plugins.manager.browser": "Браузерная среда: привилегированный доступ проходит через проверку разрешений приложения.",
|
||||
"settings.plugins.manager.advanced": "Расширенная среда: полный доступ Node к файловой системе и сети. Разрешения посредника не ограничивают этот доступ. Только для разработки.",
|
||||
"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": "Описание не предоставлено.",
|
||||
@@ -1207,6 +1226,25 @@ 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': 'Загрузка настроек плагинов…',
|
||||
|
||||
@@ -1161,6 +1161,9 @@ Vaults 标签保持扁平,不使用这块背景。
|
||||
"settings.plugins.manager.remove": "卸载",
|
||||
"settings.plugins.manager.removeNote": "卸载会移除插件代码和本地 KV 数据,保留用户设置、秘密存储和同步 sidecar。",
|
||||
"settings.plugins.permissions.statusUnknown": "状态未知",
|
||||
"plugins.sidebar.extensions": "扩展页面",
|
||||
"plugins.sidebar.selectTerminal": "请先选择已打开的终端。",
|
||||
"plugins.sidebar.loading": "正在加载扩展页面…",
|
||||
"plugins.vault.loading": "正在加载插件页面…",
|
||||
"plugins.vault.extensions": "扩展",
|
||||
"plugins.vault.back": "返回主机",
|
||||
@@ -1192,11 +1195,27 @@ Vaults 标签保持扁平,不使用这块背景。
|
||||
"settings.plugins.manager.noPermissions": "未声明特权权限。",
|
||||
"settings.plugins.manager.required": "必需",
|
||||
"settings.plugins.manager.optional": "可选",
|
||||
"settings.plugins.themes.title": "插件主题",
|
||||
"settings.plugins.themes.description": "预览仅影响当前窗口;应用后保存在本设备。个人强调色、字体和终端覆盖设置仍然优先。",
|
||||
"settings.plugins.themes.empty": "当前模式没有已启用的插件主题。",
|
||||
"settings.plugins.themes.choose": "选择主题",
|
||||
"settings.plugins.themes.unavailable": "已保存的主题不可用,正在使用宿主默认配色",
|
||||
"settings.plugins.themes.applied": "已应用",
|
||||
"settings.plugins.themes.preview": "预览",
|
||||
"settings.plugins.themes.apply": "应用",
|
||||
"settings.plugins.themes.cancel": "取消预览",
|
||||
"settings.plugins.themes.restore": "恢复默认",
|
||||
"settings.plugins.themes.invalid": "部分主题配色未通过验证或不可用。",
|
||||
"settings.plugins.themes.failed": "无法应用主题。",
|
||||
"settings.plugins.manager.dataTheme": "主题包:已验证配色,不启动代码运行时。",
|
||||
"settings.plugins.manager.browser": "浏览器运行时:特权访问通过宿主权限代理。",
|
||||
"settings.plugins.manager.advanced": "高级运行时:拥有完整 Node 文件系统和网络能力,代理权限无法限制这些访问。仅供开发预览。",
|
||||
"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": "作者暂未提供功能简介。",
|
||||
@@ -1227,6 +1246,25 @@ 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': '正在加载插件设置…',
|
||||
|
||||
@@ -1164,6 +1164,9 @@ Vaults 分頁維持扁平,不使用這塊背景。
|
||||
"settings.plugins.manager.remove": "卸載",
|
||||
"settings.plugins.manager.removeNote": "卸載會移除插件代碼和本地 KV 數據,保留用戶設置、秘密存儲和同步 sidecar。",
|
||||
"settings.plugins.permissions.statusUnknown": "狀態不明",
|
||||
"plugins.sidebar.extensions": "擴充功能頁面",
|
||||
"plugins.sidebar.selectTerminal": "請先選擇已開啟的終端。",
|
||||
"plugins.sidebar.loading": "正在載入擴充功能頁面…",
|
||||
"plugins.vault.loading": "正在載入擴充功能頁面…",
|
||||
"plugins.vault.extensions": "擴充功能",
|
||||
"plugins.vault.back": "返回主機",
|
||||
@@ -1195,17 +1198,52 @@ Vaults 分頁維持扁平,不使用這塊背景。
|
||||
"settings.plugins.manager.noPermissions": "未聲明特權權限。",
|
||||
"settings.plugins.manager.required": "必需",
|
||||
"settings.plugins.manager.optional": "可選",
|
||||
"settings.plugins.themes.title": "外掛主題",
|
||||
"settings.plugins.themes.description": "預覽僅影響目前視窗;套用後儲存於本機。個人強調色、字型和終端覆寫設定仍然優先。",
|
||||
"settings.plugins.themes.empty": "目前模式沒有已啟用的外掛主題。",
|
||||
"settings.plugins.themes.choose": "選擇主題",
|
||||
"settings.plugins.themes.unavailable": "已儲存的主題無法使用,正在使用宿主預設配色",
|
||||
"settings.plugins.themes.applied": "已套用",
|
||||
"settings.plugins.themes.preview": "預覽",
|
||||
"settings.plugins.themes.apply": "套用",
|
||||
"settings.plugins.themes.cancel": "取消預覽",
|
||||
"settings.plugins.themes.restore": "恢復預設",
|
||||
"settings.plugins.themes.invalid": "部分主題配色未通過驗證或無法使用。",
|
||||
"settings.plugins.themes.failed": "無法套用主題。",
|
||||
"settings.plugins.manager.dataTheme": "主題套件:已驗證配色,不啟動程式執行環境。",
|
||||
"settings.plugins.manager.browser": "瀏覽器運行時:特權訪問通過宿主權限代理。",
|
||||
"settings.plugins.manager.advanced": "高級運行時:擁有完整 Node 文件系統和網絡能力,代理權限無法限制這些訪問。僅供開發預覽。",
|
||||
"settings.plugins.manager.data": "本地 KV 僅保留在此設備。只有明確標記為同步的非秘密設置進入加密雲 sidecar。插件包和權限授權不參與同步。",
|
||||
"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": "作者尚未提供功能簡介。",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/** Native child views must yield to host dialogs and overlapping portals. */
|
||||
export function isPluginViewOccluded(mount: HTMLElement): boolean {
|
||||
const rect = mount.getBoundingClientRect();
|
||||
if (!mount.isConnected || mount.closest('[hidden], [inert]')) return true;
|
||||
if (!mount.isConnected || mount.closest('[hidden], [inert], [aria-hidden="true"]')) return true;
|
||||
const overlays = mount.ownerDocument.querySelectorAll(
|
||||
'[data-app-lock-overlay], [role="dialog"], [role="alertdialog"], '
|
||||
+ '[role="menu"][data-state="open"], [data-radix-popper-content-wrapper] > *, [data-native-view-occluder]',
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { pluginThemeRegistry } from '../../infrastructure/config/pluginThemeRegistry';
|
||||
import {
|
||||
DEFAULT_SHIFT_ENTER_NEWLINE_TEXT,
|
||||
isLegacyDefaultShiftEnterNewlineText,
|
||||
@@ -252,6 +253,9 @@ export const applyThemeTokens = (
|
||||
accentOverride: string,
|
||||
) => {
|
||||
const root = window.document.documentElement;
|
||||
root.toggleAttribute('data-plugin-theme-active', pluginThemeRegistry.isActive(resolvedTheme));
|
||||
const securityVars = buildAppThemeCssVars(pluginThemeRegistry.securityTokens(resolvedTheme), 'theme', '');
|
||||
for (const [key, value] of Object.entries(securityVars)) root.style.setProperty('--host-security' + key.slice(1), value);
|
||||
root.classList.remove('light', 'dark');
|
||||
root.classList.add(resolvedTheme);
|
||||
const cssVars = buildAppThemeCssVars(tokens, accentMode, accentOverride);
|
||||
|
||||
@@ -13,11 +13,11 @@ import {
|
||||
} from '../../infrastructure/config/storageKeys';
|
||||
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
|
||||
import { useToolbarItemLayout } from './useToolbarItemLayout';
|
||||
import type { SidePanelTool } from '../../domain/sidePanelLayout';
|
||||
import type { BuiltinSidePanelTool, SidePanelTool } from '../../domain/sidePanelLayout';
|
||||
|
||||
export type TerminalSidePanelTabId = SidePanelTool;
|
||||
|
||||
export const TERMINAL_SIDE_PANEL_TAB_DEFAULT_ORDER: TerminalSidePanelTabId[] = [
|
||||
export const TERMINAL_SIDE_PANEL_TAB_DEFAULT_ORDER: BuiltinSidePanelTool[] = [
|
||||
'sftp',
|
||||
'browser',
|
||||
'scripts',
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { pluginThemeRegistry } from '../../infrastructure/config/pluginThemeRegistry';
|
||||
import { usePluginThemeRevision } from './usePluginThemeRuntime';
|
||||
import { useSettingsChromeStore } from './settingsChromeStore';
|
||||
import { useLayoutEffect, useRef } from "react";
|
||||
import type { TerminalTheme } from "../../domain/models";
|
||||
import {
|
||||
@@ -271,12 +274,15 @@ export function useActiveChromeTheme({
|
||||
activeTheme: TerminalTheme | null;
|
||||
applyAppTheme: () => void;
|
||||
}) {
|
||||
const pluginThemeRevision = usePluginThemeRevision();
|
||||
const { resolvedTheme } = useSettingsChromeStore();
|
||||
const effectiveTheme = pluginThemeRegistry.isActive(resolvedTheme) ? null : activeTheme;
|
||||
const applyAppThemeRef = useRef(applyAppTheme);
|
||||
applyAppThemeRef.current = applyAppTheme;
|
||||
|
||||
useLayoutEffect(() => {
|
||||
syncActiveChromeTheme(activeTheme, applyAppTheme);
|
||||
}, [activeTheme, applyAppTheme]);
|
||||
syncActiveChromeTheme(effectiveTheme, applyAppTheme);
|
||||
}, [effectiveTheme, applyAppTheme, pluginThemeRevision]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
return () => {
|
||||
|
||||
@@ -4,10 +4,10 @@ import { resolvePluginViewWindowScope } from './pluginViewScopes';
|
||||
import { isPluginViewOccluded } from './pluginViewOcclusion';
|
||||
import type { UsePluginContributionsResult } from './usePluginContributions';
|
||||
|
||||
type ViewAdapter = Pick<UsePluginContributionsResult, 'openView' | 'closeView' | 'setViewBounds' | 'setViewVisibility' | 'onViewClosed'>;
|
||||
type ViewAdapter = Pick<UsePluginContributionsResult, 'openView' | 'closeView' | 'focusView' | 'setViewBounds' | 'setViewVisibility' | 'onViewClosed' | 'onViewFocused'>;
|
||||
|
||||
/** Host-owned mounting only. No plugin router, DOM, session or vault data API. */
|
||||
export function useEmbeddedPluginView({ viewId, version, slot, active, context, adapter, subscribeHidden, unavailable }: {
|
||||
export function useEmbeddedPluginView({ viewId, version, slot, active, context, adapter, subscribeHidden, unavailable, focused, focusOnReveal = false }: {
|
||||
viewId: string;
|
||||
version: string;
|
||||
slot: string;
|
||||
@@ -16,11 +16,18 @@ export function useEmbeddedPluginView({ viewId, version, slot, active, context,
|
||||
adapter: ViewAdapter;
|
||||
subscribeHidden(listener: () => void): () => void;
|
||||
unavailable(): void;
|
||||
focused?(): void;
|
||||
focusOnReveal?: boolean;
|
||||
}) {
|
||||
const mountRef = useRef<HTMLDivElement>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const adapterRef = useRef(adapter);
|
||||
const unavailableRef = useRef(unavailable);
|
||||
const focusedRef = useRef(focused);
|
||||
const revealFocusRef = useRef(focusOnReveal);
|
||||
const requestFocusRef = useRef<() => void>(() => {});
|
||||
revealFocusRef.current = focusOnReveal;
|
||||
focusedRef.current = focused;
|
||||
adapterRef.current = adapter;
|
||||
unavailableRef.current = unavailable;
|
||||
useEffect(() => {
|
||||
@@ -31,7 +38,7 @@ export function useEmbeddedPluginView({ viewId, version, slot, active, context,
|
||||
const instanceId = crypto.randomUUID();
|
||||
const scopeId = resolvePluginViewWindowScope({ pathname: window.location.pathname,
|
||||
search: window.location.search, hash: `${window.location.hash}|plugin-slot:${slot}` });
|
||||
const { openView, closeView, setViewBounds, setViewVisibility, onViewClosed } = adapterRef.current;
|
||||
const { openView, closeView, focusView, setViewBounds, setViewVisibility, onViewClosed, onViewFocused } = adapterRef.current;
|
||||
const close = () => {
|
||||
if (cancelled) return;
|
||||
cancelled = true;
|
||||
@@ -51,29 +58,41 @@ export function useEmbeddedPluginView({ viewId, version, slot, active, context,
|
||||
}
|
||||
});
|
||||
const stopOnHide = subscribeHidden(close);
|
||||
const stopOnFocus = onViewFocused((event) => {
|
||||
if (!cancelled && event.instanceId === instanceId) focusedRef.current?.();
|
||||
});
|
||||
const bounds = () => {
|
||||
const rect = mount.getBoundingClientRect();
|
||||
return { x: Math.round(rect.x), y: Math.round(rect.y), width: Math.max(1, Math.round(rect.width)), height: Math.max(1, Math.round(rect.height)) };
|
||||
};
|
||||
let opened = false;
|
||||
let lastBounds = '';
|
||||
let visible = false;
|
||||
const focus = () => {
|
||||
if (!cancelled && opened && visible && revealFocusRef.current && !isPluginViewOccluded(mount)) void focusView(instanceId).catch(() => {});
|
||||
};
|
||||
requestFocusRef.current = focus;
|
||||
const updateVisibility = () => {
|
||||
if (!opened || cancelled) return;
|
||||
const rect = mount.getBoundingClientRect();
|
||||
const next = rect.width > 0 && rect.height > 0 && !isPluginViewOccluded(mount);
|
||||
if (visible === next) return;
|
||||
visible = next;
|
||||
void setViewVisibility(instanceId, next).catch(failCurrent);
|
||||
void setViewVisibility(instanceId, next).then(() => { if (next) focus(); }).catch(failCurrent);
|
||||
};
|
||||
const resize = () => {
|
||||
if (opened && !cancelled) void setViewBounds(instanceId, bounds()).catch(failCurrent);
|
||||
if (opened && !cancelled) {
|
||||
const next = bounds();
|
||||
const key = JSON.stringify(next);
|
||||
if (key !== lastBounds) { lastBounds = key; void setViewBounds(instanceId, next).catch(failCurrent); }
|
||||
}
|
||||
updateVisibility();
|
||||
};
|
||||
const observer = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(resize);
|
||||
observer?.observe(mount);
|
||||
const mutations = new MutationObserver(updateVisibility);
|
||||
const mutations = new MutationObserver(resize);
|
||||
mutations.observe(document.body, { childList: true, subtree: true, attributes: true,
|
||||
attributeFilter: ['class', 'style', 'hidden', 'inert', 'role', 'aria-modal', 'data-state', 'data-app-lock-overlay', 'data-native-view-occluder'] });
|
||||
attributeFilter: ['class', 'style', 'hidden', 'inert', 'role', 'aria-modal', 'aria-hidden', 'data-state', 'data-app-lock-overlay', 'data-radix-popper-content-wrapper', 'data-native-view-occluder'] });
|
||||
window.addEventListener('resize', resize);
|
||||
document.addEventListener('scroll', resize, true);
|
||||
setLoading(true);
|
||||
@@ -91,9 +110,10 @@ export function useEmbeddedPluginView({ viewId, version, slot, active, context,
|
||||
if (!cancelled) { close(); unavailableRef.current(); }
|
||||
}).finally(() => lifecycle.finishOpen({ token: opening, viewKey: viewId }));
|
||||
return () => {
|
||||
close(); unsubscribe(); stopOnHide(); observer?.disconnect(); mutations.disconnect();
|
||||
close(); unsubscribe(); stopOnHide(); stopOnFocus(); observer?.disconnect(); mutations.disconnect();
|
||||
window.removeEventListener('resize', resize); document.removeEventListener('scroll', resize, true);
|
||||
};
|
||||
}, [active, context, slot, subscribeHidden, version, viewId]);
|
||||
useEffect(() => { if (focusOnReveal) requestFocusRef.current(); }, [focusOnReveal]);
|
||||
return { mountRef, loading };
|
||||
}
|
||||
@@ -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']);
|
||||
assert.deepEqual(reads.at(-1), [entry.id, 'second', 'official']);
|
||||
await act(async () => state.install(entry.id));
|
||||
assert.deepEqual(installs, [[entry.id, 'second']]);
|
||||
assert.deepEqual(installs, [[entry.id, 'second', 'official', undefined]]);
|
||||
await act(async () => renderer.unmount());
|
||||
});
|
||||
@@ -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) {
|
||||
export function usePluginCatalog(selectedId: string | null, sourceId = 'official') {
|
||||
const [entries, setEntries] = useState<NetcattyPluginCatalogEntry[]>([]);
|
||||
const [listStatus, setListStatus] = useState<'loading' | 'ready' | 'error'>('loading');
|
||||
const [source, setSource] = useState<NetcattyPluginCatalogSnapshot['source']>('builtin');
|
||||
@@ -12,6 +12,7 @@ export function usePluginCatalog(selectedId: string | null) {
|
||||
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; }; }, []);
|
||||
@@ -22,12 +23,18 @@ export function usePluginCatalog(selectedId: string | null) {
|
||||
try {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.listPluginCatalog) throw new Error('Catalog browsing unavailable');
|
||||
const list = await bridge.listPluginCatalog();
|
||||
const list = await bridge.listPluginCatalog(sourceId);
|
||||
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]);
|
||||
}, [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]);
|
||||
const selectionToken = entries.find((entry) => entry.id === selectedId)?.selectionToken;
|
||||
useEffect(() => {
|
||||
if (!selectedId) return;
|
||||
@@ -38,15 +45,15 @@ export function usePluginCatalog(selectedId: string | null) {
|
||||
try {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.getPluginCatalogReadme) throw new Error('README retrieval unavailable');
|
||||
const text = await bridge.getPluginCatalogReadme(selectedId, selectionToken);
|
||||
const text = await bridge.getPluginCatalogReadme(selectedId, selectionToken, sourceId);
|
||||
if (active) setReadmeState({ ...base, status: 'ready', text });
|
||||
} catch (cause) { if (active) setReadmeState({ ...base, status: 'error', error: String(cause) }); }
|
||||
})();
|
||||
return () => { active = false; };
|
||||
}, [selectedId, selectionToken, readmeAttempt]);
|
||||
}, [selectedId, selectionToken, readmeAttempt, sourceId]);
|
||||
const currentReadme = readmeState?.id === selectedId && readmeState.token === selectionToken && readmeState.attempt === readmeAttempt ? readmeState : null;
|
||||
return {
|
||||
entries, listStatus, listError, source,
|
||||
entries, listStatus, listError, source, sourceBinding,
|
||||
readme: currentReadme?.status === 'ready' ? currentReadme.text : null,
|
||||
readmeStatus: currentReadme?.status ?? (selectedId ? 'loading' : 'idle'),
|
||||
readmeError: currentReadme?.error ?? null,
|
||||
@@ -54,14 +61,19 @@ export function usePluginCatalog(selectedId: string | null) {
|
||||
busy,
|
||||
retryList: () => setListAttempt((attempt) => attempt + 1),
|
||||
retryReadme: () => setReadmeAttempt((attempt) => attempt + 1),
|
||||
async install(id: string) {
|
||||
async install(id: string, acceptedSourceId?: string, clearPreviousSource = false) {
|
||||
if (installing.current) return;
|
||||
installing.current = true;
|
||||
setBusy(true); setInstallError(null);
|
||||
try {
|
||||
const bridge = netcattyBridge.require();
|
||||
if (!bridge.installCatalogPlugin) throw new Error('Catalog installation unavailable');
|
||||
await bridge.installCatalogPlugin(id, entries.find((entry) => entry.id === id)?.selectionToken);
|
||||
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);
|
||||
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); }
|
||||
|
||||
@@ -88,10 +88,12 @@ export interface UsePluginContributionsResult {
|
||||
selectSettingPath(kind: 'file' | 'directory', title: string, defaultPath?: string): Promise<string | null>;
|
||||
openView(payload: NetcattyPluginViewOpenRequest): Promise<{ instanceId: string }>;
|
||||
closeView(instanceId: string): Promise<void>;
|
||||
focusView(instanceId: string): Promise<void>;
|
||||
setViewBounds(instanceId: string, bounds: { x: number; y: number; width: number; height: number }): Promise<void>;
|
||||
setViewVisibility(instanceId: string, visible: boolean): Promise<void>;
|
||||
setEnvironment(environment: NetcattyPluginEnvironment): Promise<void>;
|
||||
onViewClosed(callback: (event: NetcattyPluginViewClosedEvent) => void): () => void;
|
||||
onViewFocused(callback: (event: import('@netcatty/plugin-contract').PluginViewFocusEvent) => void): () => void;
|
||||
}
|
||||
|
||||
export function usePluginContributions(
|
||||
@@ -196,6 +198,9 @@ export function usePluginContributions(
|
||||
if (!bridge?.closePluginView) return;
|
||||
await bridge.closePluginView(instanceId);
|
||||
}, [bridge]);
|
||||
const focusView = useCallback(async (instanceId: string) => {
|
||||
await bridge?.focusPluginView?.({ instanceId });
|
||||
}, [bridge]);
|
||||
|
||||
const setViewBounds = useCallback(async (
|
||||
instanceId: string,
|
||||
@@ -218,6 +223,9 @@ export function usePluginContributions(
|
||||
const onViewClosed = useCallback((callback: (event: NetcattyPluginViewClosedEvent) => void) => (
|
||||
bridge?.onPluginViewClosed?.(callback) ?? (() => {})
|
||||
), [bridge]);
|
||||
const onViewFocused = useCallback((callback: (event: import('@netcatty/plugin-contract').PluginViewFocusEvent) => void) => (
|
||||
bridge?.onPluginViewFocused?.(callback) ?? (() => {})
|
||||
), [bridge]);
|
||||
|
||||
const currentLoadState = resolvePluginContributionLoadState({
|
||||
currentQueryKey: queryKey,
|
||||
@@ -239,9 +247,11 @@ export function usePluginContributions(
|
||||
selectSettingPath,
|
||||
openView,
|
||||
closeView,
|
||||
focusView,
|
||||
setViewBounds,
|
||||
setViewVisibility,
|
||||
setEnvironment,
|
||||
onViewClosed,
|
||||
onViewFocused,
|
||||
};
|
||||
}
|
||||
@@ -61,9 +61,14 @@ export function usePluginManager() {
|
||||
if (!bridge.setPluginEnabled) throw new Error('Plugin management unavailable');
|
||||
return bridge.setPluginEnabled(id, enabled);
|
||||
}),
|
||||
uninstall: (id: string) => run(async (bridge) => {
|
||||
uninstall: (id: string, clearSource = false) => run(async (bridge) => {
|
||||
if (!bridge.uninstallPlugin) throw new Error('Plugin management unavailable');
|
||||
return bridge.uninstallPlugin(id);
|
||||
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);
|
||||
}
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
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)),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { useEffect, useMemo, useRef, useSyncExternalStore } from 'react';
|
||||
import { useI18n } from '../i18n/I18nProvider';
|
||||
import { toast } from '../../components/ui/toast';
|
||||
import { collectSidePanelPanes, isPluginSidePanelTool, type SidePanelLayout } from '../../domain/sidePanelLayout';
|
||||
import { activeTabStore, useActiveTabId } from './activeTabStore';
|
||||
import { getSidePanelLiveSnapshot, subscribeSidePanelLiveSnapshot } from './sidePanelLiveStore';
|
||||
import { usePluginContributions } from './usePluginContributions';
|
||||
import { OPEN_PLUGIN_VIEW_EVENT, type OpenPluginViewDetail } from './usePluginViewLifecycle';
|
||||
|
||||
export const OPEN_PLUGIN_TERMINAL_SIDEBAR_EVENT = 'netcatty:open-plugin-terminal-sidebar';
|
||||
export const PLUGIN_TERMINAL_SIDEBAR_CONTEXT = Object.freeze({ 'netcatty.surface': 'terminal-sidebar' });
|
||||
/** Required production view-context projection, not a plugin-facing API. */
|
||||
export interface PluginSidebarHostActions {
|
||||
getActiveTerminalSessionId(): string | null;
|
||||
handleSwitchSidePanelTab(tool: import('../../domain/sidePanelLayout').SidePanelTool): void;
|
||||
handleWithdrawPluginSidebarPane(tabId: string, paneId: string, expectedTool: import('../../domain/sidePanelLayout').SidePanelTool): void;
|
||||
}
|
||||
export interface PluginTerminalSidebarPage {
|
||||
tool: `plugin-view:${string}`;
|
||||
viewId: string;
|
||||
pluginId: string;
|
||||
pluginName: string;
|
||||
version: string;
|
||||
title: string;
|
||||
icon?: NetcattyPluginIconReference;
|
||||
order: number;
|
||||
}
|
||||
export function collectPluginTerminalSidebarPages(plugins: NetcattyPluginContributionSnapshot['plugins']): PluginTerminalSidebarPage[] {
|
||||
return plugins.flatMap((plugin) => plugin.views.filter((view) => view.location === 'terminal-sidebar' && view.visible)
|
||||
.map((view): PluginTerminalSidebarPage => ({ tool: `plugin-view:${view.id}`, viewId: view.id, pluginId: plugin.id,
|
||||
pluginName: plugin.displayName, version: plugin.version, title: view.title, icon: view.icon, order: view.order ?? 0 })))
|
||||
.sort((a, b) => a.order - b.order || a.viewId.localeCompare(b.viewId));
|
||||
}
|
||||
export function requestOpenPluginTerminalSidebar(viewId: string, sessionId: string) {
|
||||
window.dispatchEvent(new CustomEvent(OPEN_PLUGIN_TERMINAL_SIDEBAR_EVENT, { detail: { viewId, sessionId } }));
|
||||
}
|
||||
export function usePluginTerminalSidebar(host: {
|
||||
layouts: ReadonlyMap<string, SidePanelLayout>;
|
||||
getSession(): string | null;
|
||||
isTerminalTab(tabId: string): boolean;
|
||||
select(tool: `plugin-view:${string}`): void;
|
||||
withdraw(tabId: string, paneId: string, tool: `plugin-view:${string}`): void;
|
||||
}) {
|
||||
const { locale, t } = useI18n();
|
||||
const activeTabId = useActiveTabId();
|
||||
// Follow workspace focus without subscribing the whole sidebar to cwd/title
|
||||
// or forwarding the host's live session DTO to a plugin.
|
||||
useSyncExternalStore((listener) => subscribeSidePanelLiveSnapshot(true, listener),
|
||||
() => getSidePanelLiveSnapshot(true).focusedSessionId, () => null);
|
||||
const hostRef = useRef(host); hostRef.current = host;
|
||||
const contributions = usePluginContributions({ locale, context: PLUGIN_TERMINAL_SIDEBAR_CONTEXT });
|
||||
const pages = useMemo(() => collectPluginTerminalSidebarPages(contributions.snapshot.plugins), [contributions.snapshot.plugins]);
|
||||
const sessionId = activeTabId && host.isTerminalTab(activeTabId) ? host.getSession() : null;
|
||||
useEffect(() => {
|
||||
const open = (event: Event) => {
|
||||
const detail = (event as CustomEvent<OpenPluginViewDetail & { sessionId?: string }>).detail;
|
||||
const page = pages.find((item) => item.viewId === detail?.viewId);
|
||||
if (!page) return;
|
||||
const tabId = activeTabStore.getActiveTabId();
|
||||
const current = hostRef.current.getSession();
|
||||
if (!hostRef.current.isTerminalTab(tabId) || !current || (detail.sessionId && detail.sessionId !== current)) {
|
||||
toast.info(t('plugins.sidebar.selectTerminal'));
|
||||
return;
|
||||
}
|
||||
hostRef.current.select(page.tool);
|
||||
};
|
||||
window.addEventListener(OPEN_PLUGIN_TERMINAL_SIDEBAR_EVENT, open);
|
||||
window.addEventListener(OPEN_PLUGIN_VIEW_EVENT, open);
|
||||
return () => { window.removeEventListener(OPEN_PLUGIN_TERMINAL_SIDEBAR_EVENT, open); window.removeEventListener(OPEN_PLUGIN_VIEW_EVENT, open); };
|
||||
}, [pages, t]);
|
||||
useEffect(() => {
|
||||
if (contributions.loading) return;
|
||||
const allowed = new Set(pages.map((page) => page.tool));
|
||||
for (const [tabId, layout] of host.layouts) for (const pane of collectSidePanelPanes(layout.root)) {
|
||||
if (isPluginSidePanelTool(pane.tool) && !allowed.has(pane.tool)) host.withdraw(tabId, pane.id, pane.tool);
|
||||
}
|
||||
}, [contributions.loading, host, pages]);
|
||||
return { pages, contributions, tabId: activeTabId, sessionId };
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { useEffect, useSyncExternalStore } from "react";
|
||||
import { netcattyBridge } from "../../infrastructure/services/netcattyBridge";
|
||||
import { pluginThemeRegistry } from "../../infrastructure/config/pluginThemeRegistry";
|
||||
export function usePluginThemeRevision() {
|
||||
return useSyncExternalStore(
|
||||
pluginThemeRegistry.subscribe,
|
||||
pluginThemeRegistry.getRevision,
|
||||
pluginThemeRegistry.getRevision,
|
||||
);
|
||||
}
|
||||
/** Host-owned selection; never stored in plugin KV or cloud sidecars. */
|
||||
let runtimeUsers = 0;
|
||||
let disconnectRuntime: (() => void) | undefined;
|
||||
function connectRuntime() {
|
||||
const bridge = netcattyBridge.get();
|
||||
let active = true,
|
||||
request = 0;
|
||||
const refresh = async () => {
|
||||
const current = ++request;
|
||||
try {
|
||||
const catalog = await bridge?.getPluginThemes?.();
|
||||
if (active && current === request && catalog)
|
||||
pluginThemeRegistry.setCatalog(catalog);
|
||||
} catch {
|
||||
/* Keep the last host-validated selection; never apply unvalidated data. */
|
||||
}
|
||||
};
|
||||
void refresh();
|
||||
const dispose = bridge?.onPluginThemesChanged?.(() => {
|
||||
pluginThemeRegistry.invalidate();
|
||||
void refresh();
|
||||
});
|
||||
const forced = window.matchMedia?.("(forced-colors: active)"),
|
||||
contrast = window.matchMedia?.("(prefers-contrast: more)");
|
||||
const update = () =>
|
||||
pluginThemeRegistry.setHighContrast(
|
||||
Boolean(forced?.matches || contrast?.matches),
|
||||
);
|
||||
update();
|
||||
forced?.addEventListener?.("change", update);
|
||||
contrast?.addEventListener?.("change", update);
|
||||
return () => {
|
||||
active = false;
|
||||
request++;
|
||||
dispose?.();
|
||||
forced?.removeEventListener?.("change", update);
|
||||
contrast?.removeEventListener?.("change", update);
|
||||
};
|
||||
}
|
||||
export function usePluginThemeRuntime() {
|
||||
const revision = usePluginThemeRevision();
|
||||
useEffect(() => {
|
||||
if (runtimeUsers++ === 0) disconnectRuntime = connectRuntime();
|
||||
return () => {
|
||||
if (--runtimeUsers === 0) {
|
||||
disconnectRuntime?.();
|
||||
disconnectRuntime = undefined;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
return revision;
|
||||
}
|
||||
export async function applyPluginThemeSelection(
|
||||
mode: "light" | "dark",
|
||||
choice: import("@netcatty/plugin-contract").ThemePackChoice | null,
|
||||
) {
|
||||
if (choice === null && !pluginThemeRegistry.isActive(mode))
|
||||
return pluginThemeRegistry.getCatalog();
|
||||
const bridge = netcattyBridge.require();
|
||||
if (!bridge.applyPluginTheme) throw new Error("Plugin themes unavailable");
|
||||
const next = await bridge.applyPluginTheme({
|
||||
mode,
|
||||
choice,
|
||||
expectedRevision: pluginThemeRegistry.getCatalog().selection.revision,
|
||||
});
|
||||
return next; // The host change event refreshes every window; never publish a potentially stale apply response.
|
||||
}
|
||||
@@ -82,7 +82,7 @@ export function usePluginViewLifecycle({
|
||||
const mountRef = useRef<HTMLDivElement>(null);
|
||||
const resolvedActiveView = useMemo(() => viewContributions.snapshot.plugins
|
||||
.flatMap((plugin) => plugin.views.map((view) => ({ plugin, view })))
|
||||
.find(({ view }) => view.id === effectiveRequested?.viewId && view.visible && view.location !== 'vault-page') ?? null,
|
||||
.find(({ view }) => view.id === effectiveRequested?.viewId && view.visible && !['vault-page', 'terminal-sidebar'].includes(view.location)) ?? null,
|
||||
[effectiveRequested?.viewId, viewContributions.snapshot.plugins]);
|
||||
const stableActiveViewRef = useRef<PluginViewSnapshotSelection<ResolvedPluginView> | null>(null);
|
||||
const activeView = resolvePluginViewSnapshotSelection({
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { usePluginThemeRuntime } from './usePluginThemeRuntime';
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type SetStateAction } from 'react';
|
||||
|
||||
import { runThemeTransition, type ThemeTransitionMode } from './themeTransition';
|
||||
@@ -217,6 +218,7 @@ import {
|
||||
} from './appearanceSync';
|
||||
|
||||
export const useSettingsState = (options: { enableSettingsSync?: boolean; enableSystemEffects?: boolean } = {}) => {
|
||||
const pluginThemeRevision = usePluginThemeRuntime();
|
||||
const enableSettingsSync = options.enableSettingsSync !== false;
|
||||
const enableSystemEffects = options.enableSystemEffects !== false;
|
||||
const initialCustomKeyBindingsRecord =
|
||||
@@ -1212,7 +1214,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
notifySettingsChanged(STORAGE_KEY_COLOR, customAccentRecord);
|
||||
}
|
||||
}
|
||||
}, [theme, resolvedTheme, lightUiThemeId, darkUiThemeId, accentMode, customAccent, customAccentRecord, notifySettingsChanged]);
|
||||
}, [theme, resolvedTheme, lightUiThemeId, darkUiThemeId, accentMode, customAccent, customAccentRecord, notifySettingsChanged, pluginThemeRevision]);
|
||||
|
||||
// Listen for OS color scheme changes to keep systemPreference in sync
|
||||
useEffect(() => {
|
||||
@@ -1979,9 +1981,10 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
}, [setTerminalSettings]);
|
||||
|
||||
const applyAppTheme = useCallback(() => {
|
||||
void pluginThemeRevision; // Invalidate the closure when host theme data changes.
|
||||
const tokens = getUiThemeById(resolvedTheme, resolvedTheme === 'dark' ? darkUiThemeId : lightUiThemeId).tokens;
|
||||
applyThemeTokens(theme, resolvedTheme, tokens, accentMode, customAccent);
|
||||
}, [theme, resolvedTheme, lightUiThemeId, darkUiThemeId, accentMode, customAccent]);
|
||||
}, [theme, resolvedTheme, lightUiThemeId, darkUiThemeId, accentMode, customAccent, pluginThemeRevision]);
|
||||
|
||||
const setEditorWordWrap = useCallback((enabled: boolean) => {
|
||||
setEditorWordWrapState(enabled);
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import React from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import { compile } from '@tailwindcss/node';
|
||||
import postcss from 'postcss';
|
||||
import HostDetailsPanel from './HostDetailsPanel';
|
||||
import { Card } from './ui/card';
|
||||
import { TooltipProvider } from './ui/tooltip';
|
||||
import { PluginPermissionPanel } from './settings/tabs/PluginPermissionPanel';
|
||||
import { I18nProvider } from '../application/i18n/I18nProvider';
|
||||
|
||||
test('real Host form cards, inputs and Save consume scoped security aliases without freezing an ordinary card', async () => {
|
||||
const markup = renderToStaticMarkup(<I18nProvider locale="en"><TooltipProvider>
|
||||
<HostDetailsPanel availableKeys={[]} identities={[]} groups={[]} terminalThemeId="default" terminalFontSize={14} onSave={() => assert.fail('test never saves a host')} onCancel={() => {}} />
|
||||
<PluginPermissionPanel pluginId="com.example.synthetic" onChanged={async () => {}} />
|
||||
<Card id="ordinary-card">Outside the security surface</Card>
|
||||
</TooltipProvider></I18nProvider>);
|
||||
const dom = new JSDOM(`<!doctype html><html data-plugin-theme-active><body>${markup}</body></html>`);
|
||||
try {
|
||||
const panel = dom.window.document.querySelector('[data-section="host-details-panel"]')!;
|
||||
assert.ok(panel, 'exercise the actual HostDetailsPanel and AsidePanel');
|
||||
assert.ok(panel.querySelector('.bg-card'), 'General/Address/Credentials use real Card components');
|
||||
assert.ok(panel.querySelector('input.bg-background'), 'exercise actual Input utilities');
|
||||
assert.ok(panel.querySelector('button.bg-primary'), 'exercise the actual Save button');
|
||||
const permissions = dom.window.document.querySelector('[data-host-security-surface]')!;
|
||||
assert.ok(permissions.querySelector('[role="status"]'), 'exercise the real grant-panel security root without loading or changing any grants');
|
||||
const source = await readFile(new URL('../index.css', import.meta.url), 'utf8');
|
||||
const compiler = await compile(source, {base: path.resolve('.'), onDependency() {}});
|
||||
const classes = [...new Set([...dom.window.document.querySelectorAll('[class]')].flatMap(el => [...el.classList]))];
|
||||
const css = compiler.build(classes);
|
||||
const parsed = postcss.parse(css);
|
||||
const rules: postcss.Rule[] = [];
|
||||
parsed.walkRules(rule => { rules.push(rule); });
|
||||
const rule = rules.find(node => node.selector.startsWith(':root[data-plugin-theme-active]'));
|
||||
assert.ok(rule && rule.type === 'rule');
|
||||
const matches = [...dom.window.document.querySelectorAll(rule.selector)];
|
||||
assert.ok(matches.includes(panel));
|
||||
assert.ok(matches.includes(permissions));
|
||||
assert.ok(!matches.includes(dom.window.document.getElementById('ordinary-card')!), 'security selector stays within the existing marked surfaces');
|
||||
const declarations = new Map(rule.nodes.filter(node => node.type === 'decl').map(node => [node.prop, node]));
|
||||
for (const name of ['background','foreground','card','card-foreground','popover','popover-foreground','primary','primary-foreground','secondary','secondary-foreground','muted','muted-foreground','accent','accent-foreground','destructive','destructive-foreground','border','input','ring']) {
|
||||
const declaration = declarations.get(`--color-${name}`);
|
||||
assert.equal(declaration?.value, `hsl(var(--host-security-${name}))`, `Tailwind alias ${name} cannot inherit the plugin value`);
|
||||
assert.equal(declaration.important, true);
|
||||
}
|
||||
for (const name of ['card','background','primary']) {
|
||||
const utility = rules.find(node => node.selector === `.bg-${name}`);
|
||||
assert.ok(utility && utility.type === 'rule');
|
||||
assert.ok(utility.nodes.some(node => node.type === 'decl' && node.prop === 'background-color' && node.value === `var(--color-${name})`), 'verify what the actual compiled utility consumes');
|
||||
}
|
||||
const mutedText = rules.find(node => node.selector === '.text-muted-foreground');
|
||||
assert.ok(mutedText?.nodes.some(node => node.type === 'decl' && node.prop === 'color' && node.value === 'var(--color-muted-foreground)'));
|
||||
} finally { dom.window.close(); }
|
||||
});
|
||||
@@ -882,6 +882,7 @@ echo $3 >> "$FILE"`);
|
||||
{/* Slide-out Panel */}
|
||||
{panel.type !== "closed" && (
|
||||
<AsidePanel
|
||||
dataSection="keychain-security-panel"
|
||||
open={true}
|
||||
onClose={closePanel}
|
||||
title={panelTitle}
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { JSDOM } from 'jsdom';
|
||||
|
||||
test('terminal extension pages use actual split panes, focus and session ownership without sharing session data', 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', 'SVGElement', 'Node', 'NodeFilter', 'MutationObserver', 'CustomEvent', 'Event', 'KeyboardEvent', 'MouseEvent']) {
|
||||
install(key, key === 'window' ? dom.window : dom.window[key as keyof typeof dom.window]);
|
||||
}
|
||||
install('PointerEvent', dom.window.MouseEvent);
|
||||
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);
|
||||
let rectX = 200;
|
||||
dom.window.HTMLElement.prototype.getBoundingClientRect = () => new dom.window.DOMRect(rectX, 100, 400, 300);
|
||||
const { default: React, act, useCallback, useRef, useState } = await import('react');
|
||||
const { createRoot } = await import('react-dom/client');
|
||||
const { I18nProvider } = await import('../application/i18n/I18nProvider');
|
||||
const { activeTabStore } = await import('../application/state/activeTabStore');
|
||||
const { sidePanelLiveStore, SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT } = await import('../application/state/sidePanelLiveStore');
|
||||
const { useTerminalSidePanelLayoutState } = await import('../application/state/useTerminalSidePanelLayoutState');
|
||||
const { usePluginTerminalSidebar } = await import('../application/state/usePluginTerminalSidebar');
|
||||
const { PluginTerminalSidebarLauncher, PluginTerminalSidebarPageHost } = await import('./plugins/PluginTerminalSidebar');
|
||||
const { SidePanelLayoutTree } = await import('./terminalLayer/TerminalLayerSidePanelSection');
|
||||
const { PersistentSidePanelPortal } = await import('./terminalLayer/terminalLayerSidePanelSlots');
|
||||
const { collectSidePanelPanes } = await import('../domain/sidePanelLayout');
|
||||
type Tool = import('../domain/sidePanelLayout').SidePanelTool;
|
||||
let selectedSession = 'synthetic-session-a';
|
||||
const getSession = () => selectedSession;
|
||||
const a = { id: 'com.example.sidebar.first', title: 'First page', location: 'terminal-sidebar', entry: 'page.html', visible: true };
|
||||
const b = { ...a, id: 'com.example.sidebar.second', title: 'Second page' };
|
||||
let snapshot = { locale: 'en', plugins: [{ id: 'com.example.sidebar', displayName: 'Synthetic plugin', version: '1.0.0', commands: [], keybindings: [], menus: [], settings: [], views: [a, b] }] };
|
||||
const changed = new Set<() => void>(), focusListeners = new Set<(event: { instanceId: string }) => void>();
|
||||
const opens: NetcattyPluginViewOpenRequest[] = [], closes: string[] = [], focus: string[] = [], bounds: Array<{ instanceId: string; x: number }> = [];
|
||||
const visible = new Map<string, boolean>();
|
||||
Object.assign(dom.window, { netcatty: {
|
||||
async getPluginRuntimeStatus() { return { available: true }; }, async getPluginContributions() { return snapshot; },
|
||||
onPluginContributionsChanged(fn: () => void) { changed.add(fn); return () => { changed.delete(fn); }; },
|
||||
onPluginViewClosed() { return () => {}; },
|
||||
onPluginViewFocused(fn: (event: { instanceId: string }) => void) { focusListeners.add(fn); return () => { focusListeners.delete(fn); }; },
|
||||
async openPluginView(payload: NetcattyPluginViewOpenRequest) { opens.push(payload); return { instanceId: payload.instanceId! }; },
|
||||
async closePluginView(id: string) { closes.push(id); },
|
||||
async setPluginViewBounds(id: string, rect: { x: number }) { bounds.push({ instanceId: id, x: rect.x }); },
|
||||
async setPluginViewVisibility(id: string, value: boolean) { visible.set(id, value); },
|
||||
async focusPluginView(event: { instanceId: string }) { focus.push(event.instanceId); for (const fn of focusListeners) fn(event); },
|
||||
} });
|
||||
let split!: () => void;
|
||||
function Fixture() {
|
||||
const state = useTerminalSidePanelLayoutState(); const latest = useRef(state); latest.current = state;
|
||||
const model = usePluginTerminalSidebar({ layouts: state.sidePanelLayouts, getSession, isTerminalTab: (id) => id === 'workspace-synthetic' || id === 'synthetic-local-terminal',
|
||||
select: (tool) => state.selectSidePanelToolForTab(activeTabStore.getActiveTabId(), tool),
|
||||
withdraw: (tabId, paneId, tool) => {
|
||||
const layout = state.sidePanelLayoutsRef.current.get(tabId);
|
||||
if (layout && collectSidePanelPanes(layout.root).some((pane) => pane.id === paneId && pane.tool === tool)) state.closePane(tabId, paneId);
|
||||
} });
|
||||
const [hosts, setHosts] = useState(new Map<Tool, HTMLElement>()); const parking = useRef<HTMLDivElement>(null);
|
||||
const register = useCallback((tool: Tool, host: HTMLElement | null) => setHosts((prior) => {
|
||||
if ((prior.get(tool) ?? null) === host) return prior;
|
||||
const next = new Map(prior); if (host) next.set(tool, host); else next.delete(tool); return next;
|
||||
}), []);
|
||||
const focusPane = useCallback((id: string) => latest.current.focusPane(activeTabStore.getActiveTabId(), id), []);
|
||||
const closePane = useCallback((id: string) => { latest.current.closePane(activeTabStore.getActiveTabId(), id); }, []);
|
||||
const resize = useCallback(() => {}, []);
|
||||
const layout = model.tabId ? state.sidePanelLayouts.get(model.tabId) : null;
|
||||
const panes = layout ? collectSidePanelPanes(layout.root) : [];
|
||||
split = () => state.splitPane('workspace-synthetic', `plugin-view:${b.id}`, 'vertical', { paneId: 'second-pane', splitId: 'plugin-split' }, 600);
|
||||
return <><PluginTerminalSidebarLauncher sessionId={selectedSession} />
|
||||
{layout && <SidePanelLayoutTree node={layout.root} layout={layout} paneCount={panes.length} labels={new Map(model.pages.map((page) => [page.tool, page.title]))}
|
||||
onClose={closePane} onFocus={focusPane} onHostChange={register} onResize={resize} separator="#777" accent="#4488cc" closePaneLabel="Close pane" resizeLabel="Resize" />}
|
||||
<div ref={parking} hidden aria-hidden="true" />
|
||||
{model.sessionId && panes.map((pane) => {
|
||||
const page = model.pages.find((item) => item.tool === pane.tool); const target = hosts.get(pane.tool) ?? parking.current;
|
||||
if (!page || !target) return null;
|
||||
return <PersistentSidePanelPortal key={`${pane.id}:${model.sessionId}`} portalKey={`plugin-${pane.id}`} target={target}>
|
||||
<PluginTerminalSidebarPageHost page={page} tabId={model.tabId!} sessionId={model.sessionId} getSession={getSession} adapter={model.contributions}
|
||||
focusOnReveal={layout?.focusedPaneId === pane.id} focused={() => focusPane(pane.id)} unavailable={() => closePane(pane.id)} />
|
||||
</PersistentSidePanelPortal>;
|
||||
})}</>;
|
||||
}
|
||||
const root = createRoot(document.getElementById('root')!); const settle = () => new Promise((resolve) => setTimeout(resolve, 40));
|
||||
try {
|
||||
activeTabStore.setActiveTabId('workspace-synthetic'); sidePanelLiveStore.update({ ...SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT, focusedSessionId: selectedSession });
|
||||
await act(async () => { root.render(<I18nProvider locale="en"><Fixture /></I18nProvider>); await settle(); });
|
||||
await act(async () => { document.querySelector<HTMLButtonElement>('button[aria-label="Extension pages"]')!.click(); await settle(); });
|
||||
const firstButton = [...document.querySelectorAll('button')].find((button) => button.textContent?.includes('First page'))!;
|
||||
await act(async () => { firstButton.click(); await settle(); });
|
||||
assert.equal(document.querySelectorAll('[data-section="terminal-side-panel-pane"]').length, 1);
|
||||
assert.ok(document.querySelector('[data-pane-tool] [data-plugin-view-slot="terminal-sidebar"]'));
|
||||
assert.equal(opens.length, 1); assert.deepEqual(opens[0].context, { 'netcatty.surface': 'terminal-sidebar' });
|
||||
await act(async () => { split(); await settle(); });
|
||||
assert.equal(document.querySelectorAll('[data-section="terminal-side-panel-pane"]').length, 2);
|
||||
assert.equal(opens.length, 2);
|
||||
const firstId = opens.find((item) => item.viewId === a.id)!.instanceId!;
|
||||
await act(async () => { for (const fn of focusListeners) fn({ instanceId: firstId }); await settle(); });
|
||||
assert.equal(document.querySelector(`[data-pane-tool="plugin-view:${a.id}"]`)?.getAttribute('data-focused'), 'true');
|
||||
const dialog = document.createElement('div'); dialog.setAttribute('role', 'dialog'); dialog.setAttribute('aria-modal', 'true');
|
||||
await act(async () => { document.body.append(dialog); await settle(); });
|
||||
assert.ok(opens.every((item) => visible.get(item.instanceId!) === false));
|
||||
focus.length = 0;
|
||||
await act(async () => { dialog.remove(); await settle(); });
|
||||
assert.ok(focus.length > 0); assert.ok(focus.every((id) => id === firstId), 'restoring panes focuses only the host-selected pane');
|
||||
const old = opens.map((item) => item.instanceId!);
|
||||
await act(async () => { selectedSession = 'synthetic-session-b'; sidePanelLiveStore.update({ ...SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT, focusedSessionId: selectedSession });
|
||||
assert.ok(old.every((id) => closes.includes(id)), 'workspace focus cancels old native owners synchronously'); await settle(); });
|
||||
assert.equal(opens.length, 4);
|
||||
assert.notEqual(opens[0].scopeId, opens[2].scopeId);
|
||||
assert.ok(opens.every((item) => JSON.stringify(item.context) === JSON.stringify({ 'netcatty.surface': 'terminal-sidebar' })));
|
||||
await act(async () => { rectX = 260; snapshot = { ...snapshot, plugins: [{ ...snapshot.plugins[0], views: [{ ...a, visible: false }, b] }] }; for (const fn of changed) fn(); await settle(); });
|
||||
assert.equal(document.querySelectorAll('[data-section="terminal-side-panel-pane"]').length, 1);
|
||||
assert.equal(document.querySelector('[data-pane-tool]')?.getAttribute('data-pane-tool'), `plugin-view:${b.id}`);
|
||||
assert.ok(bounds.some((item) => item.x === 260), 'portal relocation updates native geometry');
|
||||
const last = opens.at(-1)!.instanceId!;
|
||||
await act(async () => { activeTabStore.setActiveTabId('vault'); assert.ok(closes.includes(last)); await settle(); });
|
||||
assert.equal(document.querySelector('[data-plugin-terminal-sidebar]'), null);
|
||||
await act(async () => { selectedSession = 'synthetic-local-terminal'; activeTabStore.setActiveTabId(selectedSession); await settle(); });
|
||||
await act(async () => { document.querySelector<HTMLButtonElement>('button[aria-label="Extension pages"]')!.click(); await settle(); });
|
||||
const localPage = [...document.querySelectorAll('button')].find((button) => button.textContent?.includes('Second page'))!;
|
||||
await act(async () => { localPage.click(); await settle(); });
|
||||
assert.ok(document.querySelector('[data-plugin-view-slot="terminal-sidebar"]'), 'standalone Local Terminal is a valid navigation owner');
|
||||
assert.deepEqual(opens.at(-1)!.context, { 'netcatty.surface': 'terminal-sidebar' });
|
||||
} finally {
|
||||
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,3 +1,5 @@
|
||||
import { useAppThemeStyle } from "../application/app/useAppThemeStyle";
|
||||
import { selectPluginThemeTokens } from "../application/state/pluginContributionEnvironment";
|
||||
/**
|
||||
* Settings Page - Standalone settings window content
|
||||
* This component is rendered in a separate Electron window
|
||||
@@ -322,6 +324,9 @@ const SettingsSyncTabWithVault: React.FC<{ onSettingsApplied?: () => void }> = (
|
||||
};
|
||||
|
||||
const SettingsPageContent: React.FC<{ settings: SettingsState; appLock?: AppLockState }> = ({ settings, appLock }) => {
|
||||
// This publisher also runs in detached Settings: never broadcast a local preview.
|
||||
const committedAppThemeStyle = useAppThemeStyle(false);
|
||||
const pluginThemeTokens = useMemo(() => selectPluginThemeTokens(committedAppThemeStyle as Record<string, unknown>), [committedAppThemeStyle]);
|
||||
const { t } = useI18n();
|
||||
const { request, clearFocus, openSearch } = useSettingsFocus();
|
||||
const { notifyRendererReady, closeSettingsWindow, onWindowCommandCloseRequested } = useWindowControls();
|
||||
@@ -715,7 +720,7 @@ const SettingsPageContent: React.FC<{ settings: SettingsState; appLock?: AppLock
|
||||
</Tabs>
|
||||
</div>
|
||||
<ExternalMcpApprovalsHost />
|
||||
<PluginContributionHost locale={settings.uiLanguage} theme={settings.resolvedTheme} />
|
||||
<PluginContributionHost locale={settings.uiLanguage} theme={settings.resolvedTheme} themeTokens={pluginThemeTokens} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -123,6 +123,7 @@ import {
|
||||
import { classifyDistroId, hostRestrictsExtraSshChannels, shouldProbeSessionCwd } from '../domain/host';
|
||||
import {
|
||||
collectSidePanelPanes,
|
||||
isPluginSidePanelTool,
|
||||
sidePanelLayoutHasTool,
|
||||
type SidePanelLayout,
|
||||
type SidePanelSplitDirection,
|
||||
@@ -1933,6 +1934,16 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
resizeSidePanelSplitForTab(tabId, splitId, sizes);
|
||||
}, [resizeSidePanelSplitForTab]);
|
||||
|
||||
const handleWithdrawPluginSidebarPane = useCallback((tabId: string, paneId: string, expectedTool: SidePanelTab) => {
|
||||
if (!isPluginSidePanelTool(expectedTool)) return;
|
||||
const layout = sidePanelLayoutsRef.current.get(tabId);
|
||||
const pane = layout && collectSidePanelPanes(layout.root).find((item) => item.id === paneId);
|
||||
if (pane?.tool !== expectedTool) return;
|
||||
setMagnifiedPane((current) => current?.tabId === tabId && current.target.kind === 'side-panel'
|
||||
&& current.target.paneId === paneId ? null : current);
|
||||
closeSidePanelPaneForTab(tabId, paneId);
|
||||
}, [closeSidePanelPaneForTab, sidePanelLayoutsRef]);
|
||||
|
||||
// Toggle SFTP from activity bar header
|
||||
const handleToggleSftpFromBar = useCallback(() => {
|
||||
handleSwitchSidePanelTab('sftp');
|
||||
@@ -2617,6 +2628,9 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
handleOpenAI,
|
||||
handleOpenSystem,
|
||||
handleOpenNotes,
|
||||
getActiveTerminalSessionId,
|
||||
handleSwitchSidePanelTab,
|
||||
handleWithdrawPluginSidebarPane,
|
||||
handleOpenBrowser,
|
||||
handleFocusSidePanelPane,
|
||||
handleMagnifySidePanelPane,
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Blocks } from 'lucide-react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { activeTabStore } from '../../application/state/activeTabStore';
|
||||
import { subscribeSidePanelLiveSnapshot } from '../../application/state/sidePanelLiveStore';
|
||||
import { useEmbeddedPluginView } from '../../application/state/useEmbeddedPluginView';
|
||||
import { usePluginContributions } from '../../application/state/usePluginContributions';
|
||||
import { collectPluginTerminalSidebarPages, PLUGIN_TERMINAL_SIDEBAR_CONTEXT, requestOpenPluginTerminalSidebar,
|
||||
type PluginTerminalSidebarPage } from '../../application/state/usePluginTerminalSidebar';
|
||||
import { PluginContributionIcon } from './PluginContributionIcon';
|
||||
import { Button } from '../ui/button';
|
||||
import { Popover, PopoverClose, PopoverContent, PopoverTrigger } from '../ui/popover';
|
||||
|
||||
export function PluginTerminalSidebarLauncher({ sessionId }: { sessionId: string }) {
|
||||
const { locale, t } = useI18n();
|
||||
const contributions = usePluginContributions({ locale, context: PLUGIN_TERMINAL_SIDEBAR_CONTEXT });
|
||||
const pages = collectPluginTerminalSidebarPages(contributions.snapshot.plugins);
|
||||
if (!pages.length) return null;
|
||||
return <Popover><PopoverTrigger asChild><Button variant="ghost" size="icon" className="h-6 w-6" aria-label={t('plugins.sidebar.extensions')}><Blocks size={14} /></Button></PopoverTrigger>
|
||||
<PopoverContent align="end" className="max-h-80 w-64 overflow-y-auto p-1">
|
||||
<p className="px-2 py-1 text-xs text-muted-foreground">{t('plugins.sidebar.extensions')}</p>
|
||||
{pages.map((page) => <PopoverClose asChild key={page.tool}><button type="button" className="flex w-full items-center gap-2 rounded px-2 py-2 text-left text-sm hover:bg-accent"
|
||||
onClick={() => requestOpenPluginTerminalSidebar(page.viewId, sessionId)}>
|
||||
<PluginContributionIcon pluginId={page.pluginId} icon={page.icon} /><span className="min-w-0"><span className="block truncate">{page.title}</span><span className="block truncate text-xs text-muted-foreground">{page.pluginName}</span></span>
|
||||
</button></PopoverClose>)}
|
||||
</PopoverContent></Popover>;
|
||||
}
|
||||
|
||||
export function PluginTerminalSidebarPageHost({ page, tabId, sessionId, adapter, getSession, unavailable, focused, focusOnReveal }: {
|
||||
page: PluginTerminalSidebarPage;
|
||||
tabId: string;
|
||||
sessionId: string;
|
||||
adapter: ReturnType<typeof usePluginContributions>;
|
||||
getSession(): string | null;
|
||||
unavailable(): void;
|
||||
focused(): void;
|
||||
focusOnReveal: boolean;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const subscribeHidden = useMemo(() => (hide: () => void) => {
|
||||
const stopTab = activeTabStore.subscribeSync((id) => { if (id !== tabId) hide(); });
|
||||
const stopSession = subscribeSidePanelLiveSnapshot(true, () => { if (getSession() !== sessionId) hide(); });
|
||||
return () => { stopTab(); stopSession(); };
|
||||
}, [getSession, sessionId, tabId]);
|
||||
const { mountRef, loading } = useEmbeddedPluginView({ viewId: page.viewId, version: page.version,
|
||||
slot: `terminal-sidebar:${tabId}:${sessionId}`, active: true, context: PLUGIN_TERMINAL_SIDEBAR_CONTEXT,
|
||||
adapter, subscribeHidden, unavailable, focused, focusOnReveal });
|
||||
// The native page never receives these internal ownership IDs. Its state is
|
||||
// scoped by the host; this does not authorize reading the terminal session.
|
||||
return <section className="flex h-full min-h-0 flex-col overflow-hidden" aria-label={`${page.title} — ${page.pluginName}`} data-plugin-terminal-sidebar>
|
||||
<header className="flex h-7 shrink-0 items-center gap-2 border-b border-border px-2 text-xs"><PluginContributionIcon pluginId={page.pluginId} icon={page.icon} size={12} /><span className="truncate">{page.title} · {page.pluginName}</span></header>
|
||||
<div ref={mountRef} className="relative min-h-0 flex-1 overflow-hidden" data-plugin-view-slot="terminal-sidebar">
|
||||
{loading && <p role="status" className="p-3 text-xs text-muted-foreground">{t('plugins.sidebar.loading')}</p>}
|
||||
</div>
|
||||
</section>;
|
||||
}
|
||||
@@ -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', 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', 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>;
|
||||
function Browser() {
|
||||
const [selected, setSelected] = useState<string | null>(null);
|
||||
const [toolbar, setToolbar] = useState<HTMLDivElement | null>(null);
|
||||
@@ -104,8 +104,28 @@ 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 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);
|
||||
|
||||
@@ -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', listStatus: 'ready', listError: null, readme: '# Author documentation', readmeStatus: 'ready', readmeError: null,
|
||||
entries, source: 'registry', sourceBinding: null, 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, runtime: { lastError: 'Incompatible with this host' } } as NetcattyInstalledPlugin]} /></I18nProvider>;
|
||||
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>;
|
||||
}
|
||||
try {
|
||||
await act(async () => { renderer = create(<Harness />); });
|
||||
@@ -158,7 +158,21 @@ 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 } as NetcattyInstalledPlugin]} /></I18nProvider>);
|
||||
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>);
|
||||
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/);
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
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';
|
||||
@@ -11,14 +12,15 @@ import { Button } from '../../ui/button';
|
||||
import { PluginPackageDetails } from './PluginPackageManager';
|
||||
import { PluginReadme } from './PluginReadme';
|
||||
|
||||
export function PluginMarketplace({ installed = [], toolbarContainer, noVault }: { noVault?: boolean; installed?: NetcattyInstalledPlugin[]; toolbarContainer?: HTMLElement | null }) {
|
||||
export function PluginMarketplace({ installed = [], toolbarContainer, noVault, source }: { source?: NetcattyPluginSource; noVault?: boolean; installed?: NetcattyInstalledPlugin[]; toolbarContainer?: HTMLElement | null }) {
|
||||
const [selected, setSelected] = useState<string | null>(null);
|
||||
const catalog = usePluginCatalog(selected);
|
||||
return <PluginMarketplaceContent catalog={catalog} installed={installed} selected={selected} onSelect={setSelected} toolbarContainer={toolbarContainer} noVault={noVault} />;
|
||||
const catalog = usePluginCatalog(selected, source?.id);
|
||||
return <PluginMarketplaceContent catalog={catalog} marketSource={source} 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 }: {
|
||||
export function PluginMarketplaceContent({ catalog, installed, selected, onSelect, toolbarContainer, noVault, marketSource }: {
|
||||
marketSource?: NetcattyPluginSource;
|
||||
noVault?: boolean;
|
||||
toolbarContainer?: HTMLElement | null;
|
||||
catalog: ReturnType<typeof usePluginCatalog>;
|
||||
@@ -28,13 +30,19 @@ 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 installedEntry = installed.find((item) => item.id === entry?.id);
|
||||
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 choose = (id: string) => {
|
||||
const pane = listRef.current?.closest<HTMLElement>('[data-settings-scroll-pane]');
|
||||
pageScroll.current = pane?.scrollTop ?? 0;
|
||||
@@ -59,7 +67,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 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>}
|
||||
{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>}
|
||||
{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}>
|
||||
@@ -70,7 +78,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);
|
||||
const isInstalled = installed.some((plugin) => plugin.id === item.id && sameSource(plugin));
|
||||
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>
|
||||
@@ -89,7 +97,8 @@ 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>
|
||||
{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>}
|
||||
{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>}
|
||||
<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>}
|
||||
@@ -97,6 +106,13 @@ 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>;
|
||||
}
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ export function PluginPackageDetails({ plugin }: { plugin: NetcattyInstalledPlug
|
||||
<dt className="text-muted-foreground">API</dt><dd>{manifest.engines.api}</dd>
|
||||
<dt className="text-muted-foreground">SHA-256</dt><dd className="break-all font-mono">{plugin.archiveSha256 ?? '-'}</dd>
|
||||
</dl>
|
||||
<p className={!policy.browserOnly ? 'text-destructive' : 'text-muted-foreground'}>{t(`settings.plugins.manager.${runtimeLabel}`)}</p>
|
||||
<p className={!policy.browserOnly && !policy.dataOnly ? 'text-destructive' : 'text-muted-foreground'}>{policy.dataOnly ? t('settings.plugins.manager.dataTheme') : t(`settings.plugins.manager.${runtimeLabel}`)}</p>
|
||||
<div className="space-y-2">
|
||||
<h4 className="font-medium">{t('settings.plugins.manager.permissions')}</h4>
|
||||
<p className="text-xs text-muted-foreground">{t('settings.plugins.manager.permissionNote')}</p>
|
||||
@@ -53,6 +53,7 @@ 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);
|
||||
@@ -72,11 +73,13 @@ 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) => [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) => (!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 [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>}
|
||||
@@ -99,6 +102,7 @@ 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">
|
||||
@@ -111,9 +115,10 @@ 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>
|
||||
<Button variant="destructive" size="sm" disabled={manager.busy} onClick={() => { void manager.uninstall(plugin.id); setRemoving(null); }}>{t('settings.plugins.manager.remove')}</Button>
|
||||
<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="ghost" size="sm" onClick={() => setRemoving(null)}>{t('common.cancel')}</Button>
|
||||
</div> : <Button variant="ghost" size="sm" disabled={manager.busy} onClick={() => setRemoving(plugin.id)}>{t('settings.plugins.manager.remove')}</Button>}
|
||||
</div> : <Button variant="ghost" size="sm" disabled={manager.busy} onClick={() => { setClearSource(false); setRemoving(plugin.id); }}>{t('settings.plugins.manager.remove')}</Button>}
|
||||
</>}
|
||||
</article>)}
|
||||
</div>
|
||||
|
||||
@@ -56,7 +56,7 @@ export function PluginPermissionPanel({ pluginId, onChanged }: {
|
||||
pending.current = false; setBusy(false);
|
||||
}
|
||||
};
|
||||
return <section className="space-y-3 border-t border-border/60 pt-4 text-sm" aria-label={t('settings.plugins.permissions.title')}>
|
||||
return <section data-host-security-surface="" className="space-y-3 border-t border-border/60 pt-4 text-sm" aria-label={t('settings.plugins.permissions.title')}>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<h4 className="font-medium">{t('settings.plugins.permissions.title')}</h4>
|
||||
<Button variant="ghost" size="sm" disabled={busy} onClick={() => void refresh()}>{t('settings.plugins.permissions.refresh')}</Button>
|
||||
|
||||
@@ -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 }: { noVault?: boolean; offlineError?: string | null; retry?: () => void }) {
|
||||
export function PluginStatus({ noVault, offlineError, retry, sourceKind }: { sourceKind?: NetcattyPluginSource['kind']; 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('settings.plugins.market.offline')}</p><p className="break-words">{offlineError}</p></>}</>;
|
||||
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 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('settings.plugins.market.note')}</p></>;
|
||||
const help = <><p>{t('settings.plugins.manager.preview')}</p><p>{t(sourceKind === 'third-party' ? 'settings.plugins.sources.note' : '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>;
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { JSDOM } from "jsdom";
|
||||
import type {
|
||||
ThemePackCatalog,
|
||||
ThemePackEntry,
|
||||
} from "@netcatty/plugin-contract";
|
||||
|
||||
test("host theme picker previews without persistence, applies, restores, and cancels stale owners safely", async () => {
|
||||
const dom = new JSDOM(
|
||||
'<!doctype html><html><body><div id="root"></div></body></html>',
|
||||
{ url: "http://localhost", pretendToBeVisual: true },
|
||||
);
|
||||
const previous = new Map<string, PropertyDescriptor | undefined>();
|
||||
for (const key of [
|
||||
"window",
|
||||
"document",
|
||||
"navigator",
|
||||
"HTMLElement",
|
||||
"Element",
|
||||
"Node",
|
||||
"Event",
|
||||
"MouseEvent",
|
||||
"IS_REACT_ACT_ENVIRONMENT",
|
||||
]) {
|
||||
previous.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
|
||||
Object.defineProperty(globalThis, key, {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value:
|
||||
key === "IS_REACT_ACT_ENVIRONMENT"
|
||||
? true
|
||||
: key === "window"
|
||||
? dom.window
|
||||
: dom.window[key as keyof typeof dom.window],
|
||||
});
|
||||
}
|
||||
const { default: React, act, useLayoutEffect } = await import("react");
|
||||
const { createRoot } = await import("react-dom/client");
|
||||
const { I18nProvider } = await import(
|
||||
"../../../application/i18n/I18nProvider"
|
||||
);
|
||||
const { PluginThemePicker } = await import("./PluginThemePicker");
|
||||
const { pluginThemeRegistry: registry } = await import(
|
||||
"../../../infrastructure/config/pluginThemeRegistry"
|
||||
);
|
||||
const { usePluginThemeRevision } = await import(
|
||||
"../../../application/state/usePluginThemeRuntime"
|
||||
);
|
||||
const { getUiThemeById } = await import(
|
||||
"../../../infrastructure/config/uiThemes"
|
||||
);
|
||||
const { applyThemeTokens } = await import(
|
||||
"../../../application/state/settingsStateDefaults"
|
||||
);
|
||||
const entry: ThemePackEntry = {
|
||||
ref: {
|
||||
pluginId: "com.example.theme",
|
||||
themeId: "com.example.theme.forest",
|
||||
version: "1.0.0",
|
||||
digest: "a".repeat(64),
|
||||
variant: "light",
|
||||
},
|
||||
label: "Forest",
|
||||
pluginName: "Synthetic Theme",
|
||||
dataOnly: true,
|
||||
palette: { formatVersion: 1, base: "light", colors: { accent: "#166534" } },
|
||||
};
|
||||
let writes = 0,
|
||||
restores = 0;
|
||||
let catalog: ThemePackCatalog = {
|
||||
selection: {
|
||||
formatVersion: 1,
|
||||
revision: registry.getCatalog().selection.revision + 1,
|
||||
light: null,
|
||||
dark: null,
|
||||
},
|
||||
entries: [entry],
|
||||
diagnostics: [],
|
||||
};
|
||||
registry.setCatalog(catalog);
|
||||
Object.assign(dom.window, {
|
||||
netcatty: {
|
||||
async applyPluginTheme(request: {
|
||||
expectedRevision: number;
|
||||
choice: ThemePackCatalog["selection"]["light"];
|
||||
}) {
|
||||
assert.equal(request.expectedRevision, catalog.selection.revision);
|
||||
writes++;
|
||||
catalog = {
|
||||
...catalog,
|
||||
selection: {
|
||||
...catalog.selection,
|
||||
revision: catalog.selection.revision + 1,
|
||||
light: request.choice,
|
||||
},
|
||||
};
|
||||
registry.setCatalog(catalog);
|
||||
return catalog;
|
||||
},
|
||||
},
|
||||
});
|
||||
function Fixture() {
|
||||
const revision = usePluginThemeRevision();
|
||||
useLayoutEffect(
|
||||
() =>
|
||||
applyThemeTokens(
|
||||
"light",
|
||||
"light",
|
||||
getUiThemeById("light", "snow").tokens,
|
||||
"theme",
|
||||
"",
|
||||
),
|
||||
[revision],
|
||||
);
|
||||
return (
|
||||
<I18nProvider locale="en">
|
||||
<PluginThemePicker mode="light" onRestore={() => restores++} />
|
||||
</I18nProvider>
|
||||
);
|
||||
}
|
||||
const root = createRoot(document.getElementById("root")!);
|
||||
const button = (label: string) =>
|
||||
[...document.querySelectorAll<HTMLButtonElement>("button")].find(
|
||||
(b) => b.textContent === label,
|
||||
)!;
|
||||
try {
|
||||
await act(async () => root.render(<Fixture />));
|
||||
const original =
|
||||
document.documentElement.style.getPropertyValue("--primary");
|
||||
await act(async () => {
|
||||
const select = document.querySelector("select")!;
|
||||
select.value = `${entry.ref.pluginId}/${entry.ref.themeId}`;
|
||||
select.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
});
|
||||
await act(async () => button("Preview").click());
|
||||
assert.equal(writes, 0);
|
||||
assert.equal(
|
||||
getUiThemeById("light", "snow", false).tokens.primary,
|
||||
original,
|
||||
"local preview does not enter the global plugin view environment",
|
||||
);
|
||||
assert.notEqual(
|
||||
document.documentElement.style.getPropertyValue("--primary"),
|
||||
original,
|
||||
);
|
||||
assert.ok(
|
||||
document.documentElement.hasAttribute("data-plugin-theme-active"),
|
||||
);
|
||||
assert.ok(
|
||||
document.documentElement.style.getPropertyValue(
|
||||
"--host-security-primary",
|
||||
),
|
||||
);
|
||||
await act(async () => button("Cancel preview").click());
|
||||
assert.equal(
|
||||
document.documentElement.style.getPropertyValue("--primary"),
|
||||
original,
|
||||
);
|
||||
assert.equal(writes, 0);
|
||||
await act(async () => {
|
||||
button("Preview").click();
|
||||
button("Apply").click();
|
||||
});
|
||||
assert.equal(writes, 1);
|
||||
assert.equal(catalog.selection.light?.ref.themeId, entry.ref.themeId);
|
||||
await act(async () => button("Restore default").click());
|
||||
assert.equal(writes, 2);
|
||||
assert.equal(restores, 1);
|
||||
assert.equal(catalog.selection.light, null);
|
||||
await act(async () => {
|
||||
registry.startPreview("old-owner", entry);
|
||||
registry.startPreview("new-owner", entry);
|
||||
registry.cancelPreview("old-owner");
|
||||
});
|
||||
assert.notEqual(
|
||||
document.documentElement.style.getPropertyValue("--primary"),
|
||||
original,
|
||||
);
|
||||
await act(async () =>
|
||||
registry.setCatalog({
|
||||
...catalog,
|
||||
selection: {
|
||||
...catalog.selection,
|
||||
revision: catalog.selection.revision + 1,
|
||||
},
|
||||
entries: [],
|
||||
}),
|
||||
);
|
||||
assert.equal(
|
||||
document.documentElement.style.getPropertyValue("--primary"),
|
||||
original,
|
||||
"package withdrawal cancels previews",
|
||||
);
|
||||
await act(async () =>
|
||||
registry.setCatalog({
|
||||
...catalog,
|
||||
selection: {
|
||||
...catalog.selection,
|
||||
revision: catalog.selection.revision + 2,
|
||||
light: { ref: entry.ref },
|
||||
},
|
||||
entries: [],
|
||||
}),
|
||||
);
|
||||
assert.equal(getUiThemeById("light", "snow").id, "host-plugin-fallback");
|
||||
await act(async () => registry.setHighContrast(true));
|
||||
assert.equal(getUiThemeById("light", "snow").id, "host-high-contrast");
|
||||
} finally {
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
registry.setHighContrast(false);
|
||||
registry.setCatalog({
|
||||
...catalog,
|
||||
selection: {
|
||||
formatVersion: 1,
|
||||
revision: registry.getCatalog().selection.revision + 1,
|
||||
light: null,
|
||||
dark: null,
|
||||
},
|
||||
entries: [],
|
||||
});
|
||||
});
|
||||
dom.window.close();
|
||||
for (const [key, descriptor] of previous) {
|
||||
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
|
||||
else Reflect.deleteProperty(globalThis, key);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,166 @@
|
||||
import { useEffect, useId, useState } from "react";
|
||||
import { useI18n } from "../../../application/i18n/I18nProvider";
|
||||
import {
|
||||
applyPluginThemeSelection,
|
||||
usePluginThemeRevision,
|
||||
} from "../../../application/state/usePluginThemeRuntime";
|
||||
import { pluginThemeRegistry } from "../../../infrastructure/config/pluginThemeRegistry";
|
||||
import { Button } from "../../ui/button";
|
||||
|
||||
export function PluginThemePicker({
|
||||
mode,
|
||||
onRestore,
|
||||
}: {
|
||||
mode: "light" | "dark";
|
||||
onRestore: () => void;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
usePluginThemeRevision();
|
||||
const owner = useId();
|
||||
const [selected, setSelected] = useState(""),
|
||||
[pending, setPending] = useState(false),
|
||||
[error, setError] = useState("");
|
||||
const catalog = pluginThemeRegistry.getCatalog();
|
||||
const entries = catalog.entries.filter((e) => e.palette.base === mode);
|
||||
const applied = catalog.selection[mode];
|
||||
const appliedAvailable =
|
||||
applied &&
|
||||
entries.some(
|
||||
(e) =>
|
||||
e.ref.pluginId === applied.ref.pluginId &&
|
||||
e.ref.themeId === applied.ref.themeId &&
|
||||
e.ref.version === applied.ref.version &&
|
||||
e.ref.digest === applied.ref.digest,
|
||||
);
|
||||
const entry = entries.find(
|
||||
(e) => `${e.ref.pluginId}/${e.ref.themeId}` === selected,
|
||||
);
|
||||
useEffect(
|
||||
() => () => pluginThemeRegistry.cancelPreview(owner),
|
||||
[owner, mode],
|
||||
);
|
||||
async function apply(restore = false) {
|
||||
if (pending || (!restore && !entry)) return;
|
||||
setPending(true);
|
||||
setError("");
|
||||
try {
|
||||
await applyPluginThemeSelection(
|
||||
mode,
|
||||
restore ? null : { ref: entry!.ref },
|
||||
);
|
||||
pluginThemeRegistry.cancelPreview(owner);
|
||||
if (restore) {
|
||||
setSelected("");
|
||||
onRestore();
|
||||
}
|
||||
} catch (e) {
|
||||
setError(
|
||||
e instanceof Error ? e.message : t("settings.plugins.themes.failed"),
|
||||
);
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
}
|
||||
return (
|
||||
<section
|
||||
className="space-y-3 py-3"
|
||||
aria-label={t("settings.plugins.themes.title")}
|
||||
>
|
||||
<div>
|
||||
<h4 className="text-sm font-medium">
|
||||
{t("settings.plugins.themes.title")}
|
||||
</h4>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("settings.plugins.themes.description")}
|
||||
</p>
|
||||
</div>
|
||||
{entries.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("settings.plugins.themes.empty")}
|
||||
</p>
|
||||
) : (
|
||||
<select
|
||||
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
|
||||
aria-label={t("settings.plugins.themes.choose")}
|
||||
value={entry ? selected : ""}
|
||||
disabled={pending}
|
||||
onChange={(e) => {
|
||||
pluginThemeRegistry.cancelPreview(owner);
|
||||
setSelected(e.target.value);
|
||||
setError("");
|
||||
}}
|
||||
>
|
||||
<option value="">{t("settings.plugins.themes.choose")}</option>
|
||||
{entries.map((e) => (
|
||||
<option
|
||||
key={`${e.ref.pluginId}/${e.ref.themeId}`}
|
||||
value={`${e.ref.pluginId}/${e.ref.themeId}`}
|
||||
>
|
||||
{e.label} — {e.pluginName} {e.ref.version}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
{catalog.selection[mode] && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(
|
||||
appliedAvailable
|
||||
? "settings.plugins.themes.applied"
|
||||
: "settings.plugins.themes.unavailable",
|
||||
)}
|
||||
: {catalog.selection[mode]!.ref.themeId} ·{" "}
|
||||
{catalog.selection[mode]!.ref.version}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={!entry || pending}
|
||||
onClick={() => {
|
||||
if (entry) pluginThemeRegistry.startPreview(owner, entry);
|
||||
}}
|
||||
>
|
||||
{t("settings.plugins.themes.preview")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={!entry || pending}
|
||||
onClick={() => {
|
||||
void apply();
|
||||
}}
|
||||
>
|
||||
{t("settings.plugins.themes.apply")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={pending}
|
||||
onClick={() => pluginThemeRegistry.cancelPreview(owner)}
|
||||
>
|
||||
{t("settings.plugins.themes.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={pending}
|
||||
onClick={() => {
|
||||
void apply(true);
|
||||
}}
|
||||
>
|
||||
{t("settings.plugins.themes.restore")}
|
||||
</Button>
|
||||
</div>
|
||||
{catalog.diagnostics.length > 0 && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("settings.plugins.themes.invalid")}
|
||||
</p>
|
||||
)}
|
||||
{error && (
|
||||
<p role="alert" className="text-xs text-destructive">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
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,3 +1,6 @@
|
||||
import { PluginThemePicker } from './PluginThemePicker';
|
||||
import { applyPluginThemeSelection, usePluginThemeRevision } from '../../../application/state/usePluginThemeRuntime';
|
||||
import { pluginThemeRegistry } from '../../../infrastructure/config/pluginThemeRegistry';
|
||||
import React, { memo, useCallback, useMemo, useState } from "react";
|
||||
import { applyCustomCssToDocument } from "../../../lib/customCss";
|
||||
import { DebouncedTextarea } from "../DebouncedTextarea";
|
||||
@@ -70,6 +73,8 @@ function SettingsAppearanceTab(props: {
|
||||
setAppIconVariant: (variant: AppIconVariant) => void;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
usePluginThemeRevision();
|
||||
const [pluginThemeError, setPluginThemeError] = useState('');
|
||||
const availableUIFonts = useAvailableUIFonts();
|
||||
// Note code fonts come from the monospace-only store (fontStore); the note
|
||||
// body font follows the UI font setting instead.
|
||||
@@ -231,8 +236,12 @@ function SettingsAppearanceTab(props: {
|
||||
);
|
||||
|
||||
const visibleUiThemes = resolvedTheme === "dark" ? DARK_UI_THEMES : LIGHT_UI_THEMES;
|
||||
const visibleUiThemeId = resolvedTheme === "dark" ? darkUiThemeId : lightUiThemeId;
|
||||
const setVisibleUiThemeId = resolvedTheme === "dark" ? setDarkUiThemeId : setLightUiThemeId;
|
||||
const visibleUiThemeId = pluginThemeRegistry.isActive(resolvedTheme) ? "" : resolvedTheme === "dark" ? darkUiThemeId : lightUiThemeId;
|
||||
const setVisibleUiThemeId = (id: string) => {
|
||||
void applyPluginThemeSelection(resolvedTheme, null).then(() => {
|
||||
(resolvedTheme === "dark" ? setDarkUiThemeId : setLightUiThemeId)(id);setPluginThemeError('');
|
||||
}).catch((error: unknown) => setPluginThemeError(error instanceof Error ? error.message : t('settings.plugins.themes.failed')));
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsTabContent value="appearance">
|
||||
@@ -339,6 +348,8 @@ function SettingsAppearanceTab(props: {
|
||||
{renderThemeSwatches(visibleUiThemes, visibleUiThemeId, setVisibleUiThemeId)}
|
||||
</div>
|
||||
</SettingsAnchor>
|
||||
<PluginThemePicker mode={resolvedTheme} onRestore={() => (resolvedTheme === "dark" ? setDarkUiThemeId : setLightUiThemeId)(resolvedTheme === "dark" ? "midnight" : "snow")} />
|
||||
{pluginThemeError && <p role="alert" className="text-xs text-destructive">{pluginThemeError}</p>}
|
||||
<SettingRow
|
||||
anchorId="appearance-accent-mode"
|
||||
label={t("settings.appearance.accentColor.mode")}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { PluginThirdPartyMarket } from './PluginThirdPartyMarket';
|
||||
import { usePluginManager } from '../../../application/state/usePluginManager';
|
||||
import { PluginManagementDialog } from './PluginManagementDialog';
|
||||
import { PluginMarketplace } from './PluginMarketplace';
|
||||
@@ -289,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'>('installed');
|
||||
const [pluginView, setPluginView] = useState<'installed' | 'marketplace' | 'thirdparty'>('installed');
|
||||
const availableFonts = useAvailableFonts();
|
||||
const scopeCatalog = usePluginSettingScopeCatalog();
|
||||
const [scopeIds, setScopeIds] = useState<Partial<Record<NetcattyPluginSettingScopeKind, string>>>({});
|
||||
@@ -310,11 +311,12 @@ function PluginManagementContent({ handoff }: { handoff: (action: () => void) =>
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<Tabs value={pluginView} onValueChange={(value) => { setSelectedInstalled(null); setPluginView(value as 'installed' | 'marketplace'); }} className="space-y-5">
|
||||
<Tabs value={pluginView} onValueChange={(value) => { setSelectedInstalled(null); setPluginView(value as 'installed' | 'marketplace' | 'thirdparty'); }} className="space-y-5">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<TabsList className="h-auto flex-wrap justify-start bg-muted/50">
|
||||
<TabsTrigger value="installed">{t('settings.plugins.manager.installed')} <span className="ml-2 text-muted-foreground">{manager.plugins.length}</span></TabsTrigger>
|
||||
<TabsTrigger value="marketplace">{t('settings.plugins.market.title')}</TabsTrigger>
|
||||
<TabsTrigger value="marketplace">{t('settings.plugins.sources.official')}</TabsTrigger>
|
||||
<TabsTrigger value="thirdparty">{t('settings.plugins.sources.thirdparty')}</TabsTrigger>
|
||||
</TabsList>
|
||||
<div className="flex items-center gap-2">
|
||||
{pluginView === 'installed' && <PluginStatus noVault={manager.credentialStorageAvailable === false} />}
|
||||
@@ -322,7 +324,8 @@ 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 value="marketplace" className="m-0 space-y-6"><PluginMarketplace installed={manager.plugins} toolbarContainer={catalogToolbar} noVault={manager.credentialStorageAvailable === false} /></TabsContent>
|
||||
<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="installed" className="m-0 space-y-6">
|
||||
<PluginPackageManager manager={manager} onSelectionChange={setSelectedInstalled} />
|
||||
{contextualScopes.size > 0 && (
|
||||
|
||||
@@ -30,7 +30,7 @@ export const TerminalHostKeyVerification: React.FC<TerminalHostKeyVerificationPr
|
||||
const Icon = isChanged ? AlertTriangle : Fingerprint;
|
||||
|
||||
return (
|
||||
<div className="space-y-3 animate-in fade-in-0 slide-in-from-bottom-1 duration-200">
|
||||
<div data-host-security-surface="" className="space-y-3 animate-in fade-in-0 slide-in-from-bottom-1 duration-200">
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-xl border px-3 py-2.5",
|
||||
|
||||
@@ -44,6 +44,7 @@ import { collectOwnedPluginMenus, comparePluginMenus, usePluginContributions } f
|
||||
import { buildTerminalPluginContributionContext } from '../../application/state/pluginContributionContexts';
|
||||
import { PluginContributionIcon } from '../plugins/PluginContributionIcon';
|
||||
import { isPluginHostProtocol } from '../../domain/pluginConnection';
|
||||
import { PluginTerminalSidebarLauncher } from '../plugins/PluginTerminalSidebar';
|
||||
|
||||
export const TERMINAL_TOOLBAR_ITEM_IDS = [
|
||||
'highlight',
|
||||
@@ -1158,6 +1159,7 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
|
||||
)}
|
||||
</ToolbarCustomizeContextMenu>
|
||||
|
||||
{!compactToolbar && <PluginTerminalSidebarLauncher sessionId={sessionId} />}
|
||||
{pluginToolbarMenus.map((menu) => (
|
||||
<Tooltip key={menu.id}>
|
||||
<TooltipTrigger asChild>
|
||||
|
||||
@@ -9,7 +9,7 @@ import React, { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef,
|
||||
import { createPortal } from 'react-dom';
|
||||
import * as SelectPrimitive from '@radix-ui/react-select';
|
||||
|
||||
import { useActiveTabId } from '../../application/state/activeTabStore';
|
||||
import { activeTabStore, useActiveTabId } from '../../application/state/activeTabStore';
|
||||
import {
|
||||
getSidePanelLiveSnapshot,
|
||||
subscribeSidePanelLiveSnapshot,
|
||||
@@ -48,6 +48,7 @@ import {
|
||||
MAX_SIDE_PANEL_PANES,
|
||||
canSplitSidePanelPaneAtSize,
|
||||
collectSidePanelPanes,
|
||||
isPluginSidePanelTool,
|
||||
getFocusedSidePanelPane,
|
||||
getSidePanelNodeMinimumPixels,
|
||||
getSidePanelSplitResizeBounds,
|
||||
@@ -60,7 +61,10 @@ import {
|
||||
type SidePanelSplitNode,
|
||||
} from '../../domain/sidePanelLayout';
|
||||
import { terminalLayerSidePanelStableCtxEqual } from './terminalLayerViewMemo';
|
||||
import { SidePanelMountedContent } from './terminalLayerSidePanelSlots';
|
||||
import { PersistentSidePanelPortal, SidePanelMountedContent } from './terminalLayerSidePanelSlots';
|
||||
import { usePluginTerminalSidebar } from '../../application/state/usePluginTerminalSidebar';
|
||||
import { PluginTerminalSidebarPageHost } from '../plugins/PluginTerminalSidebar';
|
||||
import { PluginContributionIcon } from '../plugins/PluginContributionIcon';
|
||||
import { getPaneMagnificationShortcutLabel } from '../../domain/paneMagnification';
|
||||
|
||||
const MemoizedSidePanelMountedContent = memo(
|
||||
@@ -358,7 +362,7 @@ function SidePanelSplitView({
|
||||
);
|
||||
}
|
||||
|
||||
function SidePanelLayoutTree({
|
||||
export function SidePanelLayoutTree({
|
||||
node,
|
||||
layout,
|
||||
paneCount,
|
||||
@@ -568,6 +572,9 @@ function hasMountedSidePanelContent(ctx: SidePanelContext): boolean {
|
||||
&& Array.from((sidePanelOpenTabs as Map<string, SidePanelTab>).values()).includes('history');
|
||||
const anyNotesOpen = sidePanelOpenTabs instanceof Map
|
||||
&& Array.from((sidePanelOpenTabs as Map<string, SidePanelTab>).values()).includes('notes');
|
||||
const anyPluginOpen = ctx.sidePanelLayouts instanceof Map
|
||||
&& [...(ctx.sidePanelLayouts as Map<string, SidePanelLayout>).values()]
|
||||
.some((layout) => collectSidePanelPanes(layout.root).some((pane) => isPluginSidePanelTool(pane.tool)));
|
||||
|
||||
return !(
|
||||
browserMountedTabIds.length === 0
|
||||
@@ -579,10 +586,16 @@ function hasMountedSidePanelContent(ctx: SidePanelContext): boolean {
|
||||
&& themeMountedTabIds.length === 0
|
||||
&& !anyHistoryOpen
|
||||
&& !anyNotesOpen
|
||||
&& !anyPluginOpen
|
||||
);
|
||||
}
|
||||
|
||||
function TerminalLayerSidePanelSectionInner({ ctx }: { ctx: SidePanelContext }) {
|
||||
const pluginSidebar = usePluginTerminalSidebar({ layouts: ctx.sidePanelLayouts ?? new Map(),
|
||||
getSession: ctx.getActiveTerminalSessionId ?? (() => null),
|
||||
isTerminalTab: (tabId) => (ctx.sessions ?? []).some((session: { id: string }) => session.id === tabId) || !!ctx.workspaceById?.has(tabId),
|
||||
select: (tool) => ctx.handleSwitchSidePanelTab?.(tool),
|
||||
withdraw: (tabId, paneId, tool) => ctx.handleWithdrawPluginSidebarPane?.(tabId, paneId, tool) });
|
||||
if (!hasMountedSidePanelContent(ctx)) {
|
||||
// Keep AI scope maintenance alive even with zero mounted tool panels so
|
||||
// merge/dissolve handoff still runs when the user closed AI before the
|
||||
@@ -604,7 +617,7 @@ function TerminalLayerSidePanelSectionInner({ ctx }: { ctx: SidePanelContext })
|
||||
</AISidePanelStateRoot>
|
||||
);
|
||||
}
|
||||
return <TerminalLayerSidePanelInner ctx={ctx} />;
|
||||
return <TerminalLayerSidePanelInner ctx={ctx} pluginSidebar={pluginSidebar} />;
|
||||
}
|
||||
|
||||
/** Skip chrome rebuilds when only live/workspace-focus ticks change. */
|
||||
@@ -614,7 +627,7 @@ export const TerminalLayerSidePanelSection = memo(
|
||||
);
|
||||
TerminalLayerSidePanelSection.displayName = 'TerminalLayerSidePanelSection';
|
||||
|
||||
function TerminalLayerSidePanelInner({ ctx }: { ctx: SidePanelContext }) {
|
||||
function TerminalLayerSidePanelInner({ ctx, pluginSidebar }: { ctx: SidePanelContext; pluginSidebar: ReturnType<typeof usePluginTerminalSidebar> }) {
|
||||
const activeTabId = useActiveTabId();
|
||||
const sidePanelOpenTabs = ctx.sidePanelOpenTabs as Map<string, SidePanelTab>;
|
||||
const sidePanelLayouts = ctx.sidePanelLayouts as Map<string, SidePanelLayout>;
|
||||
@@ -630,6 +643,7 @@ function TerminalLayerSidePanelInner({ ctx }: { ctx: SidePanelContext }) {
|
||||
handleOpenAI,
|
||||
handleOpenHistory,
|
||||
handleOpenNotes,
|
||||
handleSwitchSidePanelTab,
|
||||
handleOpenBrowser,
|
||||
handleOpenScripts,
|
||||
handleOpenSystem,
|
||||
@@ -1046,6 +1060,9 @@ function TerminalLayerSidePanelInner({ ctx }: { ctx: SidePanelContext }) {
|
||||
{ id: 'system' as const, label: t('terminal.layer.system'), icon: <Activity size={15} />, onClick: handleOpenSystem },
|
||||
{ id: 'notes' as const, label: t('terminal.layer.notes'), icon: <NotebookText size={15} />, onClick: handleOpenNotes },
|
||||
{ id: 'ai' as const, label: t('terminal.layer.aiChat'), icon: <MessageSquare size={15} />, onClick: handleOpenAI },
|
||||
...pluginSidebar.pages.map((page) => ({ id: page.tool, label: `${page.title} · ${page.pluginName}`,
|
||||
icon: <PluginContributionIcon pluginId={page.pluginId} icon={page.icon} size={15} />,
|
||||
onClick: () => handleSwitchSidePanelTab?.(page.tool) })),
|
||||
], [
|
||||
handleOpenAI,
|
||||
handleOpenHistory,
|
||||
@@ -1056,6 +1073,8 @@ function TerminalLayerSidePanelInner({ ctx }: { ctx: SidePanelContext }) {
|
||||
handleOpenTheme,
|
||||
handleToggleSftpFromBar,
|
||||
t,
|
||||
handleSwitchSidePanelTab,
|
||||
pluginSidebar.pages,
|
||||
]);
|
||||
const sidePanelTabItemById = useMemo(
|
||||
() => new Map(sidePanelTabItems.map((item) => [item.id, item])),
|
||||
@@ -1491,6 +1510,19 @@ function TerminalLayerSidePanelInner({ ctx }: { ctx: SidePanelContext }) {
|
||||
paneHosts={panePortalHosts}
|
||||
parkingHost={parkingHost}
|
||||
/>
|
||||
{activeTabId && pluginSidebar.sessionId && activeSidePanelPanes.map((pane) => {
|
||||
const page = pluginSidebar.pages.find((item) => item.tool === pane.tool);
|
||||
const target = panePortalHosts.get(pane.tool) ?? parkingHost;
|
||||
if (!page || !target) return null;
|
||||
const sessionId = pluginSidebar.sessionId!;
|
||||
return <PersistentSidePanelPortal key={`${activeTabId}:${pane.id}:${sessionId}`} portalKey={`plugin-${pane.id}`} target={target}>
|
||||
<PluginTerminalSidebarPageHost page={page} tabId={activeTabId} sessionId={sessionId}
|
||||
focusOnReveal={activeSidePanelLayout?.focusedPaneId === pane.id}
|
||||
adapter={pluginSidebar.contributions} getSession={ctx.getActiveTerminalSessionId}
|
||||
unavailable={() => ctx.handleWithdrawPluginSidebarPane?.(activeTabId, pane.id, page.tool)}
|
||||
focused={() => { if (activeTabStore.getActiveTabId() === activeTabId) ctx.handleFocusSidePanelPane?.(pane.id); }} />
|
||||
</PersistentSidePanelPortal>;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -32,6 +32,13 @@ test('terminal layer bridge passes vault open callbacks into the side panel cont
|
||||
assert.match(source, /onOpenVaultSectionFromChat: s\.onOpenVaultSectionFromChat/);
|
||||
});
|
||||
|
||||
test('production terminal layer context forwards extension sidebar owner and pane callbacks', () => {
|
||||
const contextProjection = source.slice(source.indexOf('const ctx = useMemo('));
|
||||
for (const callback of ['getActiveTerminalSessionId', 'handleSwitchSidePanelTab', 'handleWithdrawPluginSidebarPane']) {
|
||||
assert.match(contextProjection, new RegExp(`${callback}: s\\.${callback}\\b`));
|
||||
}
|
||||
});
|
||||
|
||||
test('terminal layer bridge updates side panel live state after render', () => {
|
||||
assert.match(source, /const sidePanelLiveSnapshot = useMemo<SidePanelLiveSnapshot>/);
|
||||
assert.match(
|
||||
|
||||
@@ -489,6 +489,9 @@ export function TerminalLayerTabBridge({ stableRef }: { stableRef: StableRef })
|
||||
handleFontWeightResetForFocusedSession: themeState.handleFontWeightResetForFocusedSession,
|
||||
handleOpenAI: s.handleOpenAI,
|
||||
handleOpenNotes: s.handleOpenNotes,
|
||||
getActiveTerminalSessionId: s.getActiveTerminalSessionId,
|
||||
handleSwitchSidePanelTab: s.handleSwitchSidePanelTab,
|
||||
handleWithdrawPluginSidebarPane: s.handleWithdrawPluginSidebarPane,
|
||||
handleOpenBrowser: s.handleOpenBrowser,
|
||||
handleOpenSystem: s.handleOpenSystem,
|
||||
handleOpenHistory: s.handleOpenHistory,
|
||||
|
||||
@@ -7,8 +7,9 @@ import { TerminalLayerWorkspaceSection } from './TerminalLayerWorkspaceSection';
|
||||
import { terminalLayerViewCtxEqual } from './terminalLayerViewMemo';
|
||||
import { useTerminalHostTreeLayoutWidth } from '../../application/state/terminalHostTreeStore';
|
||||
import { resolveTerminalLayerSurfaceStyle } from '../terminalPaneVisibility';
|
||||
import type { PluginSidebarHostActions } from '../../application/state/usePluginTerminalSidebar';
|
||||
|
||||
type TerminalLayerViewContext = Record<string, any>;
|
||||
type TerminalLayerViewContext = Record<string, any> & PluginSidebarHostActions;
|
||||
|
||||
function TerminalLayerViewInner({ ctx }: { ctx: TerminalLayerViewContext }) {
|
||||
const hostTreeLayoutWidth = useTerminalHostTreeLayoutWidth();
|
||||
|
||||
@@ -693,7 +693,7 @@ export function movePersistentPortalNode(
|
||||
}
|
||||
}
|
||||
|
||||
function PersistentSidePanelPortal({
|
||||
export function PersistentSidePanelPortal({
|
||||
portalKey,
|
||||
target,
|
||||
children,
|
||||
|
||||
@@ -316,6 +316,9 @@ const SIDE_PANEL_STABLE_CTX_KEYS = [
|
||||
'handleOpenTheme',
|
||||
'handleOpenAI',
|
||||
'handleOpenNotes',
|
||||
'getActiveTerminalSessionId',
|
||||
'handleSwitchSidePanelTab',
|
||||
'handleWithdrawPluginSidebarPane',
|
||||
'handleOpenBrowser',
|
||||
'handleBackFromNotes',
|
||||
'handleOpenHostFromNotes',
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,86 @@
|
||||
# Byte stream acceptance evidence
|
||||
|
||||
The integration retains #3978 `91b8927c56314e68c06e2c008e84559ddfb96d78`,
|
||||
source fixes through #3981 `4213d8e6726c44e7a479418ece3adf16ea494782`, and
|
||||
AI service checkpoint #3986 `264f1d983cd1ed83fe4873974a614fff2f47f989`.
|
||||
The transport delta does not edit the source database or sync implementation.
|
||||
Draft PR #3988 records the published review head. No merge or release is authorized.
|
||||
|
||||
## Completed local checks
|
||||
|
||||
The delegated environment uses Node 24.19.0 on Linux.
|
||||
|
||||
- `npm run test:plugin-contract`: 90 passed, zero skipped.
|
||||
- `npm run test:plugin-runtime`: 604 passed, one native Electron test skipped.
|
||||
Database-specific testing is now entirely in the example directory.
|
||||
- Final focused transport, source-isolation, installed entrypoint and disposable
|
||||
database run before the test-file move: 32 passed, zero skipped. After the
|
||||
move, the same installed database test passed again at its example path.
|
||||
- `NETCATTY_BYTE_STREAM_DATABASE_TEST=1 node --test examples/plugins/database-minimal/protocol.live.test.cjs`:
|
||||
real MariaDB result and server-side INSERT denial passed through both the
|
||||
core and the ordinary installed example entrypoint. No host ports, fixture
|
||||
override, real hosts, or real credentials were used by this database test.
|
||||
Its container, network, package and profile were removed.
|
||||
- `node --test examples/plugins/database-minimal/panel.test.cjs`: password
|
||||
clearing before dispatch and safe text rendering passed.
|
||||
- Production `npm run build`, generated contract drift, changed-source lint,
|
||||
and whitespace checks passed.
|
||||
|
||||
Real broker TLS tests validate trust and the original requested hostname using
|
||||
a disposable fixture CA. Database traffic is plain TCP inside an internal
|
||||
Docker network. Neither check proves native BrowserWindow sandbox behavior,
|
||||
database TLS, device testing, or real model inference. The registered AI/context
|
||||
entrypoint test uses a synthetic trusted adapter, not the outstanding renderer
|
||||
wiring owned by the AI task.
|
||||
|
||||
The EOF and last-await cancellation regressions each failed before their fix
|
||||
and passed after it with real loopback sockets. Source changes before grant
|
||||
publication, source changes during a pending read, and a stream returned after
|
||||
runtime release have separate passing regressions.
|
||||
|
||||
## Reproducible native sample
|
||||
|
||||
Run the preparation tool for `tcp://172.30.247.2:3306` as described in
|
||||
[`examples/plugins/database-minimal/README.md`](../../examples/plugins/database-minimal/README.md).
|
||||
Two separate staging directories produced the same five-file package:
|
||||
|
||||
- Archive bytes: 11,621.
|
||||
- SHA256: `53b1aebae27d0681d0e93b8f7529f574cc2f176f3ca34eb2eabd1c35cdfb3deb`.
|
||||
- Content SHA256: `c83c6913f4ea40eae80d7042231b083be7d35b27022d6df24de9ce09022f0016`.
|
||||
- Delegated artifact: `/workspace/artifacts/plugin-byte-stream/database-minimal.ncpkg`.
|
||||
|
||||
The artifact contains no credentials or server setup. Changing its approved
|
||||
fixture endpoint requires a newly prepared package and a recorded new hash.
|
||||
|
||||
## Checks that did not pass locally
|
||||
|
||||
`npm run pack:dir` first failed in the existing Cursor SDK preparation step
|
||||
because npm could not create `/home/agent/.npm/_cacache`. Retrying with npm and
|
||||
Electron build caches under `/workspace` passed that step, then failed while
|
||||
rebuilding `@serialport/bindings-cpp`: `@electron/rebuild` hard-codes the
|
||||
unwritable `/home/agent/.electron-gyp` directory. The native rebuild was not
|
||||
disabled, HOME was not changed, and no sandbox setting was weakened. Full app
|
||||
directory packaging remains failed locally and needs the normal CI or native
|
||||
QA build environment.
|
||||
|
||||
The broad `npm test` run reported failures in existing transfer-resume,
|
||||
Markdown editor, SSH algorithm, SDK/CLI platform, terminal paste, and updater
|
||||
tests, then stalled without a final aggregate. Its owned process tree was
|
||||
terminated. It is not reported as a passing full test run.
|
||||
|
||||
An independent source-baseline worktree at `4213d8e` ran five affected files in
|
||||
the same environment with shared dependencies: 145 tests, 118 passed,
|
||||
24 failed, three skipped. It reproduced failures in transfer-resume,
|
||||
Markdown editor, SSH algorithms, AI bridge resolution, and updater tests;
|
||||
updater errors include the unavailable `/home/agent/.netcatty/tmp/Netcatty`.
|
||||
This diagnoses that tested subset only. It does not classify every broad-suite
|
||||
failure, replace exact-head CI, or make any failed result successful.
|
||||
|
||||
## Pending acceptance
|
||||
|
||||
Exact published-head CI and independent review remain pending. The parent owns
|
||||
sequential native GUI acceptance for streams and the database example, then AI,
|
||||
then logging integration. The native recipe covers ordinary install/source and
|
||||
endpoint review, deny/retry/query, cancel/revoke/disable, restart with no saved
|
||||
password, audit inspection, screenshots, and a short recording. No native GUI
|
||||
run or screenshot/video result is claimed by this delegated executor.
|
||||
@@ -0,0 +1,111 @@
|
||||
# Byte stream integration checkpoint
|
||||
|
||||
The branch starts at #3978 `91b8927c56314e68c06e2c008e84559ddfb96d78`.
|
||||
It integrates source APIs from #3981 `4213d8e6726c44e7a479418ece3adf16ea494782`
|
||||
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.
|
||||
|
||||
See `byte-stream-acceptance.md` for the current checks, local packaging blocker,
|
||||
sample hash, and remaining native/exact-head review work. Do not disable the
|
||||
Chromium sandbox to obtain that evidence.
|
||||
@@ -17,7 +17,7 @@ mean complete. Existing APIs remain available.
|
||||
| Terminal providers | Decorations, links, completion, snapshots through provider requests | Session change delivery follows prior provider request and non-once grant | Preserve providers. Standalone selected-session watch planned | Hostnames, session state, endpoint identity | Host-selected session, runtime identity | Session-close and revoke cleanup, bounded event rate | Existing provider tests. Ordinary views cannot independently subscribe today |
|
||||
| Current context and host picker | Host context keys are boolean state | `vault.metadata` enumeration is not generic SDK | Selected host/session DTO from an explicit whitelist | Labels and endpoint metadata are sensitive | Host-owned user-action context, selected host/session | TTL, runtime and session invalidation, redacted audit | Planned. Never reuse agent `sanitizeHostForAgent` as plugin DTO |
|
||||
| Terminal selection and input draft | Provider-supplied snapshots, completion suggestions | No explicit selection-share or draft API | User shares selection, plugin proposes a visible editable draft | Terminal plaintext, accidental execution | Host action token plus exact live session/endpoint | Single-use token, size cap, no plaintext audit, active identity before commit | Planned. Reject Enter/control characters for draft insertion. No full history or direct shell |
|
||||
| Native interactions and clipboard | Native permission dialogs; new browser SDK host choice/input and explicit copy/share workflow | Progress/toast remain unimplemented | Host-owned prompts, two-phase clipboard snapshot/share, exact copy preview | Clipboard and input may contain secrets; UI discloses granted network capability; prompt spam | `ui.interact`, clipboard direction, host-signed window/runtime/version/phase/TTL action | Admission before permission prompt, byte/rate/concurrency limits, lock/blur/close/revoke cleanup, active guard, content-free audit | [Host interaction slice](./host-interactions.md) implemented and synthetic-tested. d4ca123 exact test/packaging CI and parent independent delta review passed; parent native rerun pending after the permission-modal focus fix. Progress/toast still planned. No background reads or trusted plugin `userGesture` boolean |
|
||||
| Native interactions and clipboard | Native permission dialogs; new browser SDK host choice/input and explicit copy/share workflow | Progress/toast remain unimplemented | Host-owned prompts, two-phase clipboard snapshot/share, exact copy preview | Clipboard and input may contain secrets; UI discloses granted network capability; prompt spam | `ui.interact`, clipboard direction, host-signed window/runtime/version/phase/TTL action | Admission before permission prompt, byte/rate/concurrency limits, lock/blur/close/revoke cleanup, active guard, content-free audit | [Host interaction slice](./host-interactions.md) implemented and synthetic-tested. 8e4dd0c exact test/packaging CI and independent delta review passed. Parent native compose and explicit Preview/Share/copy succeeded; a separately approved 0.1.1 compose ran about 86 seconds, beyond the old 30-second deadline and within the declared 180-second budget, with no fresh command-timeout error. All five permissions were Once; cleanup is parent-owned. New navigation slices retain separate acceptance gates. Progress/toast still planned. No background reads or trusted plugin `userGesture` boolean |
|
||||
| Local filesystem | Read file, stat, overwrite existing regular file | Directory listing lacks same-handle native adapter | Keep current boundary, evaluate file picker handles | Arbitrary local file content and writes | Canonical exact path or selected file handle | Inode checks, no symlink alias, bounded bytes, active runtime | Existing filesystem tests. Directory enumeration and new arbitrary-path creation deferred until portable safe native adapter exists |
|
||||
| SFTP and remote files | No generic plugin API | `sftp.read` and `sftp.write` enumerated only | Host-selected session/path/mode/TTL handles, read and explicit save/upload | Remote secrets and destructive overwrite | Live endpoint identity, exact path, read/write split | Cancel streams, byte quota, overwrite confirmation, close/revoke cleanup | Batch 2 planned. Never forward arbitrary MCP catalog or session ID alone |
|
||||
| Editor and Notes | No plugin document API | No versioned snapshot or edit preview | Explicit snapshot and versioned proposed edit | Document secrets, lost updates, executable files | Selected document plus revision and action token | Version conflict check, preview, bounded bytes, revocation | Planned evaluation after shared action context. No unattended edits |
|
||||
@@ -27,12 +27,12 @@ mean complete. Existing APIs remain available.
|
||||
| Vault and keychain | Credential references and import preview/commit | Metadata/write enums do not grant a generic Vault SDK | Only user-selected whitelist DTOs and drafts | Notes, environmentVariables, startupCommand, keys and credentials | Selected object plus field/direction | Explicit preview, identity checks, redacted audit | Full Vault export and secret reads deferred indefinitely. No agent field-exclusion DTO reuse |
|
||||
| System manager and Docker | No plugin system API | Agent/MCP adapters are separate authority | Typed read-only summaries excluding args/env/inspect | Process command lines and Docker inspect can expose secrets | Selected live session and summary kind | Bounded result, endpoint identity, cancellation | Planned evaluation. Arbitrary process/inspect forwarding deferred |
|
||||
| Port forwarding | No generic plugin API | Existing agent start is write-sensitive | Selected rule status and open host form | Rule endpoints and local exposure | Selected rule plus host action | Bounded DTO, session close and revoke | Planned evaluation. Direct start deferred until binding/listener approval design |
|
||||
| Themes, settings, appearance, i18n | Terminal theme/background Providers, isolated view CSS, scoped settings and environment updates | No host global theme contribution. Personal customCSS is not plugin authority | Accepted ThemePack stage: static palette registry, selector, local preview, revision-safe apply/cancel/reset, terminal linkage | Unreadable palettes and misleading security UI. Nineteen UiThemeTokens fields are not independent: accent derives primary/ring/foregrounds | User explicitly selects a theme; static contribution enablement separate from code permissions | Immutable version/hash, bounded JSON, contrast/security-surface gates, preview owner/revision and safe fallback | Planned independent implementation after current controlled workflow/navigation. No arbitrary DOM/CSS, remote assets, layout replacement or security UI customization |
|
||||
| Themes, settings, appearance, i18n | Terminal theme/background Providers, isolated view CSS, scoped settings and environment updates | No host global theme contribution. Personal customCSS is not plugin authority | Accepted ThemePack stage: static palette registry, selector, local preview, revision-safe apply/cancel/reset, terminal linkage | Unreadable palettes and misleading security UI. Nineteen UiThemeTokens fields are not independent: accent derives primary/ring/foregrounds | User explicitly selects a theme; static contribution enablement separate from code permissions | Immutable version/hash, bounded JSON, contrast/security-surface gates, preview owner/revision and safe fallback | First host color layer implemented; [contract/lifecycle/evidence](./theme-packs.md). Exact-SHA CI, independent review and native acceptance pending. Static terminal linkage not yet accepted; no arbitrary DOM/CSS, remote assets, layout replacement or security UI customization |
|
||||
| Transfer center | Host transfer queue exists, no plugin entry | No ownership-bound task API | Observe and cancel only plugin-created tasks | Paths and host endpoints reveal user data. Cancel may leave partial files | Own transfer handle plus source/destination authority | Shared backpressure, cancel/close cleanup, honest partial status | Planned with batch 2. Do not broadcast or cancel other tasks |
|
||||
| AI, MCP, automation | Host agent catalog and harness exist separately | No generic plugin AI or tool forwarding | Selection-to-proposal workflow through the same controlled primitives | Content disclosure, account authority and model cost | Explicit content sharing plus service origin/budget | No inherited history, tools, account, or hidden cron | Controlled workflow planned. Generic host AI and visible schedules deferred to distinct product decision |
|
||||
| Known hosts, app lock and global security | Host security management UI | No plugin management API | Future allowlisted security-page navigation | Host-key trust, unlock, keys and security policy affect all sessions | Host navigation action only | No mutation or sensitive DTO | Security policy mutation, private-key reads, auto-trust and unlocking deferred indefinitely |
|
||||
| Plugin blob/cache | Local KV exists | No exclusive blob cache | Evaluate bounded plugin-owned cache when a real workflow needs it | Disk growth and lifecycle ambiguity | Plugin namespace, no arbitrary path | Size quota, eviction and uninstall policy required | Deferred until a concrete larger-than-KV workflow establishes value and retention policy |
|
||||
| Vault and terminal navigation | `tab` mounts a sandbox page through TopTabs/QuickSwitcher; aside/panel are overlays | No Vault subpage registry or terminal side-panel plugin tool. `settings` is a detail entry, not a new settings category | Accepted independent slice: host-owned namespaced Vault pages in an Extensions group, mounted in the Vault content area through existing ViewHost; then terminal pane/split pages for the selected session | Navigation cannot disclose session contents or replace built-in/security navigation | Declared page contribution plus host selection. Content reads have independent permissions | Host order/when/focus, cancellation of late open, view cleanup on hide/update/disable/uninstall, safe invalid-link fallback | [Vault page slice](./vault-pages.md) implemented and synthetic-tested: actual Vault content slot, private Scratchpad, catalog withdrawal, host occlusion, reserved-open cancellation, owner/crash/lock cleanup. Exact CI/independent/native acceptance pending. Terminal pane/split pages remain the next separate stage. No arbitrary router/DOM API |
|
||||
| Vault and terminal navigation | `tab` mounts a sandbox page through TopTabs/QuickSwitcher; aside/panel are overlays | No Vault subpage registry or terminal side-panel plugin tool. `settings` is a detail entry, not a new settings category | Accepted independent slice: host-owned namespaced Vault pages in an Extensions group, mounted in the Vault content area through existing ViewHost; then terminal pane/split pages for the selected session | Navigation cannot disclose session contents or replace built-in/security navigation | Declared page contribution plus host selection. Content reads have independent permissions | Host order/when/focus, cancellation of late open, view cleanup on hide/update/disable/uninstall, safe invalid-link fallback | [Vault page slice](./vault-pages.md) implemented and synthetic-tested: actual Vault content slot, private Scratchpad, catalog withdrawal, host occlusion, reserved-open cancellation, owner/crash/lock cleanup. Exact CI/independent/native acceptance pending. [Terminal sidebar slice](./terminal-sidebar.md) also implemented with actual pane/split mounting, owner focus and session-change cleanup; no automatic session data. Exact CI/independent/native acceptance pending for the new stack. No arbitrary router/DOM API |
|
||||
| Distribution and runtimes | Browser-only public catalog, immutable packages, advanced developer gate | Signing/trust and release remain separate phase | Retain existing boundary | Package execution and publisher trust | Pinned catalog selection, hash, engine/features | Existing install transaction, quarantine and cleanup | No merge/release. Do not extend to arbitrary Node, IPC, shell, or global capabilities |
|
||||
|
||||
## Delivery order and stopping rule
|
||||
@@ -98,3 +98,69 @@ components/hooks, and localized strings. PluginMarketplace/PluginPackageManager
|
||||
layouts are not rewritten. Native acceptance belongs to the parent with a new
|
||||
fixture-specific own-views approval. Sidebar, ThemePack, selected context and
|
||||
SFTP remain unfinished; this checkpoint is not overall completion.
|
||||
|
||||
## Terminal sidebar checkpoint
|
||||
|
||||
`views.terminal-sidebar` exposes one more existing sandbox view placement,
|
||||
using only the existing own-views permission. The host owns navigation,
|
||||
selection, split/close and focus. Its context is a surface string only; no
|
||||
terminal DTO or resource authority is implied. The Session Scratchpad fixture
|
||||
uses explicitly typed synthetic text and its own view state.
|
||||
|
||||
This stage builds on ordinary merges containing #3960 8e4dd0c and the #3971
|
||||
stale-layout callback fix. Prior QA heads and PR bases are not rewritten. The
|
||||
ThemePack implementation, selected-context/drafts and file handles remain
|
||||
unfinished; parent review/native gates still apply to each delivered head.
|
||||
|
||||
## Color-pack and remaining workflow checkpoint
|
||||
|
||||
The first [ThemePack color layer](./theme-packs.md) now has an implemented host
|
||||
catalog, data-only admission, Appearance selector, local preview, revision-safe
|
||||
application/reset, missing-version fallback and host security palette boundary.
|
||||
It is not native-accepted until the new exact head is independently reviewed,
|
||||
built by CI and exercised through normal Settings. Terminal palette linkage is
|
||||
not accepted by this contract yet, so a declared but ignored linkage cannot be
|
||||
counted as coverage. Font/density/icon/background packs remain separate product
|
||||
and accessibility decisions, rather than arbitrary CSS permissions.
|
||||
|
||||
Parent native evidence for sidebar `a8adeb86` now covers the original Local
|
||||
Terminal blocker, own-views Once, save, magnify/restore, a split with empty Notes,
|
||||
and side/bottom docking. `733f44c` adds only the production bridge projection
|
||||
regression. The older `0c097a1` CI run `40308057623` remains **failed and
|
||||
unattributed**: 9447 top-level TAP results are `ok`, the aggregate footer is
|
||||
missing and the process exits 1. A newer passing run does not change that record.
|
||||
The native Vault success retains its original `0c097a1` evidence label.
|
||||
|
||||
The next implementation stages are bounded by useful workflows:
|
||||
|
||||
1. **Selected terminal sharing and drafts (A).** Reuse host-signed action
|
||||
contexts for an explicit selection preview/share, then let the plugin submit
|
||||
a bounded input draft to the host compose surface. No Enter, raw PTY, history
|
||||
enumeration or background terminal listener. First add a host-selected
|
||||
session handle with a minimal from-scratch DTO and independent lifecycle
|
||||
watch; navigation alone grants no content. Bind handles to actual session and
|
||||
connection identity, runtime/version, owner, mode and TTL. Cover cancel,
|
||||
reconnect, close, revoke and late draft results with synthetic terminals.
|
||||
2. **Selected file conversion/transfer (A).** The host picker issues read or
|
||||
explicit-destination write handles. Start with one file, stat/read and saveAs
|
||||
or upload, not recursive traversal or arbitrary paths. Require destination
|
||||
preview/overwrite confirmation, byte/concurrency limits, cancellation,
|
||||
session/endpoint identity and final active checks. Observe/cancel only the
|
||||
plugin's own transfer. Real native SFTP targets require parent-coordinated
|
||||
concrete approval; local tests use isolated fixtures.
|
||||
3. **Versioned document proposals (selected B).** Reuse snapshot/handle and host
|
||||
review primitives for one explicitly shared editor document or Note. Apply
|
||||
only an expected-version patch to its buffer after a visible diff; saving a
|
||||
remote file remains a separate file operation. Reuse the draft path for a
|
||||
selected snippet or new snippet template; do not run scripts or add onConnect
|
||||
bindings. Cover edits after snapshot, detached ownership, closure and revoke.
|
||||
4. **Final reassessment.** Recheck the matrix by actual workflow and eight gates.
|
||||
Read-only system aggregates, selected port-rule status and bounded navigation
|
||||
may be added only where the shared primitives leave a concrete useful gap.
|
||||
Full process arguments/env/inspect, tunnel start, arbitrary AI/MCP tools,
|
||||
background jobs, general KV sync and raw high-privilege access remain deferred
|
||||
or retained boundaries. Do not invent new C work to prolong the task.
|
||||
|
||||
These stages remain unfinished work, not API-name coverage. Exact schemas and
|
||||
ordering will follow the existing brokers/adapters rather than a parallel RPC
|
||||
stack or one namespace per product module.
|
||||
@@ -630,3 +630,13 @@ negotiated API version and runtime initialization uses and verifies that exact
|
||||
version. Host version bounds remain independently enforced; unknown required
|
||||
features still fail closed. No old manifest is rewritten and no permission grant
|
||||
is migrated by this revision.
|
||||
|
||||
## Declarative host color contributions
|
||||
|
||||
`appearance.theme-packs` adds `contributes.themes` and generated palette/reference
|
||||
schemas. A pure package uses `main: { "data": "theme" }`, contributes only themes
|
||||
and omits permissions/activation/companions. It has no executable runtime. Normal
|
||||
admission accepts this data-only form alongside browser-only code packages; Node
|
||||
and arbitrary IPC remain advanced/unsupported public boundaries. Selection is a
|
||||
trusted host Appearance action, not an SDK setter or `appearance.write` grant.
|
||||
See [Theme packs](./theme-packs.md) for limits, preview, persistence and fallback.
|
||||
@@ -300,3 +300,8 @@ installs plugins but fails to start them outside the repository checkout.
|
||||
separate `npm run test:plugin-runtime:electron` smoke launches both a real
|
||||
sandboxed BrowserWindow plugin and a real utilityProcess plugin, verifies
|
||||
bidirectional storage RPC, and checks the recorded runtime ownership.
|
||||
|
||||
Static `appearance.theme-packs` packages are an explicit non-executable admission
|
||||
form: `main.data = "theme"`, theme-only contributions and no permissions,
|
||||
activation events or companions. The runtime supervisor never starts them. This
|
||||
exception does not admit Node code or arbitrary host access in normal Settings.
|
||||
@@ -0,0 +1,101 @@
|
||||
# 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.
|
||||
@@ -1,56 +1,84 @@
|
||||
# Official registry client
|
||||
# Official static distribution client
|
||||
|
||||
The Community page refreshes the fixed endpoint
|
||||
`https://raw.githubusercontent.com/binaricat/netcatty-plugins/main/catalog.json`
|
||||
in the main process. The renderer cannot choose a registry URL. This endpoint
|
||||
must be published separately; client implementation and synthetic tests do not
|
||||
prove the repository or an online artifact is available.
|
||||
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.
|
||||
|
||||
## Catalog contract
|
||||
## Snapshot contract
|
||||
|
||||
`catalog.json` is an array of at most 1,000 current entries. Each entry supplies
|
||||
`id`, `version`, `name`, `description`, `author`, `repository` (`owner/repository`),
|
||||
`commit` (full 40-character SHA), `license`, `readme` (relative Markdown path),
|
||||
and `manifest`. Optional `artifact` contains `url` and SHA-256 `sha256`.
|
||||
Unknown catalog-entry and artifact fields are rejected. The whole JSON value,
|
||||
including manifest defaults, uses the existing 128-level / 100,000-value budget
|
||||
and finite-number validation. The existing manifest schema, browser-only distribution policy, identity/version
|
||||
and license checks apply. Source-only entries remain build-and-import examples.
|
||||
No download counts, ratings, or timestamps are invented. Brand previews remain
|
||||
bundled and reviewed; arbitrary author image URLs are never loaded.
|
||||
This client implements the version-1 contract from
|
||||
`binaricat/netcatty-plugins@7bfe23e5627e16ba63806de6dded5b2667c02984`.
|
||||
The source catalog/index remain unchanged; **no `readmeAsset` field is added**.
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
## Selection and installation
|
||||
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.
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
`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.
|
||||
## Bounds, cache and selection
|
||||
|
||||
## Verification boundary
|
||||
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.
|
||||
|
||||
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.
|
||||
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.
|
||||
@@ -0,0 +1,74 @@
|
||||
# Terminal sidebar pages
|
||||
|
||||
The `terminal-sidebar` view location requires `views.terminal-sidebar` and the
|
||||
existing `views` permission. This adds navigation and plugin-owned content to
|
||||
the existing terminal pane/split system. It is not a session-data capability,
|
||||
Provider subscription, terminal history reader or command execution API.
|
||||
|
||||
## Host-owned navigation
|
||||
|
||||
An Extension pages menu in the normal terminal toolbar lists visible pages
|
||||
with their plugin source. Selecting one switches the existing focused pane;
|
||||
selecting an already-open tool focuses that pane. Existing split menus include
|
||||
the same contributions and enforce unique tools and the eight-pane limit.
|
||||
Host-generated `plugin-view:<namespaced-view-id>` keys cannot replace built-in
|
||||
tools. Declared order and ID determine listing order; existing host docking,
|
||||
split resizing, magnification and close controls remain authoritative.
|
||||
|
||||
The manager's internal Open event can select a page only when an open terminal
|
||||
is already selected. Otherwise the host asks the user to select a terminal; it
|
||||
does not connect or open a saved host. Launcher events target the session from
|
||||
the actual host terminal toolbar and are rechecked against current selection.
|
||||
These are internal host UI events, not public SDK router methods or trusted
|
||||
plugin `userGesture` flags.
|
||||
|
||||
## Session ownership without data sharing
|
||||
|
||||
The renderer binds each page to the actual terminal tab/workspace and current
|
||||
focused child session. It passes only `netcatty.surface: terminal-sidebar` as
|
||||
the view context. Internal ownership IDs are used for host state scoping, not
|
||||
returned to the page or treated as resource grants. A future selected-session
|
||||
data API must establish its own live endpoint authority and permissions.
|
||||
|
||||
Changing terminal tabs or workspace focus closes the old native view before
|
||||
the new one can receive focus. Session removal, pane close, withdrawal of a
|
||||
visible contribution, disable/update/uninstall and runtime loss remove the
|
||||
affected page. Unknown saved routes are pruned without opening them; restoring
|
||||
a layout never grants the page or any session-data permission. State buckets
|
||||
are for the plugin's own UI data, not an isolation boundary against that same
|
||||
plugin's already-authorized own-state API. Ordinary KV does not become synced.
|
||||
|
||||
PersistentSidePanelPortal moves a stable mount between the actual pane,
|
||||
magnification host and hidden parking host. There is no fallback native view
|
||||
over the entire terminal layer. Native geometry follows host DOM relocation,
|
||||
resize and scrolling; duplicate geometry updates are suppressed. Hidden/inert
|
||||
or parked mounts cannot reveal a native view.
|
||||
|
||||
## Focus and security surfaces
|
||||
|
||||
The existing sandbox ViewHost, immutable package/protocol checks, own-command
|
||||
and view-state boundaries are reused. Embedded pages start hidden. Host
|
||||
dialogs and overlapping portals hide them, and main-process App Lock closes
|
||||
them. Escape is host-owned and closes the plugin pane.
|
||||
|
||||
Native WebContents focus is reported only to its actual owner's trusted
|
||||
renderer using a canonical instance-ID event. It updates the selected pane;
|
||||
it does not expose an Electron object to the plugin. Revealing multiple panes
|
||||
does not focus all of them: only the host-selected pane can request focus.
|
||||
The main process verifies actual owner, visibility, foreground and unlocked
|
||||
state before honoring that request. A plugin has no SDK method to invoke the
|
||||
host focus bridge. No session or page text is added to focus/audit logs.
|
||||
|
||||
## Example and evidence
|
||||
|
||||
`com.netcatty.session-scratchpad` 0.1.0 asks only for its own `views`. It saves,
|
||||
reopens and clears explicitly typed synthetic text. Package instructions and
|
||||
minimal native actions are in its README. Repository tests use real split
|
||||
tree, layout state, persistent portal, launcher and embedded-view components;
|
||||
host tests cover owner-bound focus, hidden/locked refusal and withdrawal.
|
||||
Feature and permission admission, limits, old APIs and previous Vault/command
|
||||
fixes are checked on the same stack. Independent review, exact-SHA CI and
|
||||
parent native QA remain separate acceptance gates recorded on the Draft PR.
|
||||
|
||||
ThemePack, selected context/terminal drafts and SFTP handles remain subsequent
|
||||
bounded stages. Adding a sidebar page does not count as implementing them.
|
||||
@@ -0,0 +1,159 @@
|
||||
# Host color theme packs
|
||||
|
||||
Theme packs contribute validated light and dark host colors. They cannot inject
|
||||
CSS, access host DOM, change layout, load remote resources, replace security
|
||||
controls, or apply themselves. The feature is `appearance.theme-packs`.
|
||||
|
||||
## Install and select
|
||||
|
||||
Use normal Settings > Plugins to install and enable a package. In Appearance,
|
||||
choose a plugin theme for the current light or dark mode, then select Preview or
|
||||
Apply. Preview changes only the current window and writes no preference. Cancel,
|
||||
closing Appearance, changing mode, package withdrawal, or a newer committed
|
||||
revision ends that preview. Apply saves a host-owned, device-local choice and
|
||||
refreshes other application windows. Restore default clears the plugin choice
|
||||
for that mode and selects Snow or Midnight. Selecting a built-in swatch also
|
||||
clears that mode's plugin choice. Failures remain visible and are not reported as
|
||||
successful applications.
|
||||
|
||||
The current view environment is globally broadcast. To keep temporary previews
|
||||
local, it publishes **committed** theme tokens only. Plugin pages follow an
|
||||
applied theme through their existing environment subscription; they do not
|
||||
receive another window's temporary preview. This does not add an owner-local
|
||||
view environment API.
|
||||
|
||||
## Data-only package
|
||||
|
||||
A pure theme uses `main: { "data": "theme" }`, contributes only `themes`, and
|
||||
omits permissions, activation events and companions. Its code runtime is never
|
||||
started, including by the runtime supervisor's startup or explicit-start path.
|
||||
Browser code plugins may also declare themes; choosing a theme does not start a
|
||||
runtime or grant their functional permissions. Only enabled packages appear.
|
||||
|
||||
```json
|
||||
{
|
||||
"features": { "required": ["appearance.theme-packs"] },
|
||||
"main": { "data": "theme" },
|
||||
"contributes": {
|
||||
"themes": [{
|
||||
"id": "com.example.colors.forest",
|
||||
"label": "Forest",
|
||||
"variant": "light",
|
||||
"entry": "forest.json"
|
||||
}]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This fragment belongs in an otherwise complete manifest. Contribution IDs use
|
||||
the package namespace. Palette paths are package-relative JSON files, included
|
||||
and checked by the normal CLI/package store. Hosts lacking the required feature
|
||||
reject compatibility deterministically.
|
||||
|
||||
```json
|
||||
{ "formatVersion": 1, "base": "light", "colors": { "accent": "#166534" } }
|
||||
```
|
||||
|
||||
Colors merge onto a fixed accessible light/dark host base. Only opaque `#RRGGBB`
|
||||
values and canonical semantic keys are accepted. The 15 editable keys cover
|
||||
background/foreground, card and popover pairs, secondary and muted pairs, accent,
|
||||
destructive and its foreground, border and input. Primary, ring and the accent
|
||||
foregrounds are derived together, matching the existing theme conversion.
|
||||
Nineteen output fields are not nineteen independent controls.
|
||||
|
||||
Each package has at most 32 themes; each palette is at most 64 KiB; the catalog
|
||||
and validated cache contain at most 256 entries. Schema and contrast checks run
|
||||
before catalog publication. Text pairs and state colors require at least 4.5:1;
|
||||
control borders and focus information require at least 3:1 against their
|
||||
surfaces. Invalid palettes produce bounded, content-free diagnostics and cannot
|
||||
be previewed or applied. Files are read from verified immutable package roots,
|
||||
with bounded reads and a no-follow final file open.
|
||||
|
||||
## Ownership, recovery and security appearance
|
||||
|
||||
The host stores selections separately from plugin KV, settings sidecars and
|
||||
cloud sync. A reference binds plugin ID, theme ID, exact version, archive digest
|
||||
and variant. Application uses a revision check plus an active-package check just
|
||||
before the atomic preference/audit transaction. An audit write failure rolls
|
||||
back the selection. Audit records contain revision/action metadata, not palette
|
||||
or user content.
|
||||
|
||||
Disable, uninstall, update, missing entries and integrity failures withdraw the
|
||||
palette. A saved missing reference remains visible as unavailable while the
|
||||
same-mode safe host palette is used; a changed package is not automatically
|
||||
applied. Preview owner and committed revision checks prevent a late cancellation
|
||||
from replacing another preview or a newer application.
|
||||
|
||||
Host dialogs, App Lock, grant/revoke, host details, keychain panels, host-key
|
||||
prompts and the credential picker use fixed host security variables while a
|
||||
plugin theme is active. The pack controls no font, icon, opacity, CSS selector,
|
||||
button text or ordering. System forced-colors/high-contrast preferences use a
|
||||
fixed host high-contrast fallback. Personal accent, fonts, terminal choices and
|
||||
accessibility preferences retain their authority. Existing personal custom CSS
|
||||
remains a user setting and is not exposed to plugins.
|
||||
|
||||
This first layer changes host semantic colors. Static terminal palette linkage,
|
||||
custom high-contrast variants, fonts, density, icons and backgrounds remain
|
||||
separate bounded follow-ups; their fields are not silently accepted here.
|
||||
|
||||
## Example and validation
|
||||
|
||||
`examples/plugins/forest-theme-pack` is a four-file data package with light and
|
||||
dark Forest accents. It requests no permissions and contains no JavaScript.
|
||||
The starter, Vault Scratchpad and Session Scratchpad pages now subscribe to the
|
||||
existing seven public HSL tokens through `theme.js`, dispose on page hide, and
|
||||
use responsive host-colored controls. No new styling RPC is needed.
|
||||
|
||||
Local evidence includes normal CLI validation/packing, schema/admission and
|
||||
no-runtime checks; host catalog/identity/size/contrast/CAS/migration/atomic-audit
|
||||
checks; picker preview/apply/cancel/reset and stale-owner/withdrawal/high-contrast
|
||||
checks; and view-template initial/live environment/disposal checks. Exact-SHA
|
||||
CI, independent review and parent native multi-window/security-surface acceptance
|
||||
remain separate delivery gates. Local production builds do not establish them.
|
||||
|
||||
### First review / CI delta
|
||||
|
||||
The first review found that muted text was validated only against its namesake
|
||||
muted surface, although host descriptions and statuses also use it on background,
|
||||
card and popover surfaces. The validator now checks generic and muted text on
|
||||
all neutral surfaces that reuse those tokens. The white-on-white counterexample
|
||||
is rejected both by the pure validator and the host catalog. Forest light/dark
|
||||
palettes remain valid.
|
||||
|
||||
The first exact-head test run `40317690002` failed with 15187 passing tests,
|
||||
one failure and 50 skips. Its single failure was the unchanged TERM-SSHD
|
||||
concurrent failure-release test expecting a particular invocation to reach OPEN
|
||||
first. Both calls perform asynchronous local-file preparation. The test now
|
||||
requires exactly one explicit Permission denied result, one successful result
|
||||
with matching bytes, and no rejected concurrent handle; it retains concurrency
|
||||
without assuming filesystem completion order. No transfer implementation changed.
|
||||
The test file's original SHA256 was identical in e5bd294c, 733f44c and fdeeaf74:
|
||||
`8b200d9f6a05ac5cd91fe4e0a95ea36a770d22267a6a0162b4c9fc927beb52a7`.
|
||||
This is source identity and a source-based ordering diagnosis, not a claim that a
|
||||
baseline runtime failure was reproduced locally. The updated test needs the new
|
||||
exact-head CI result. That same first run passed the added Vite browser-import
|
||||
smoke, resolving the earlier local temp-directory limitation for that head.
|
||||
|
||||
### Native security-color delta
|
||||
|
||||
Parent native acceptance at f868 verified zero-permission installation/enabling,
|
||||
light/dark Preview and Cancel, Apply across main/Settings windows, Restore
|
||||
default, restart persistence, and Disable fallback with the unavailable notice.
|
||||
The isolated schema-3 profile was not touched. These core results remain valid.
|
||||
|
||||
Native QA also found Forest-colored inner New Host cards/inputs/Save and some
|
||||
grant-panel text inside an otherwise fixed security container. The cause was
|
||||
Tailwind's derived `--color-*` aliases: changing only raw semantic tokens at the
|
||||
boundary does not recompute aliases already resolved on an ancestor. The same
|
||||
existing security rule now fixes both raw tokens and those color aliases. The
|
||||
selector and protected regions are unchanged; ordinary application cards remain
|
||||
theme-controlled. No host, credential or grant operation changed.
|
||||
|
||||
The regression renders the actual HostDetailsPanel (including General, Address,
|
||||
Credentials cards, real Inputs and Save), plus the real grant-panel root. It
|
||||
compiles the production Tailwind source, verifies which aliases those utilities
|
||||
consume, checks the fixed declarations, and verifies an ordinary card is outside
|
||||
the security selector. This is component/compiled-CSS coverage, not a claim of
|
||||
native computed-color acceptance. The parent's native color delta remains the
|
||||
final visual check. A local Chromium probe aborted because its SUID sandbox was
|
||||
not configured; sandboxing was not disabled to retry.
|
||||
@@ -26,6 +26,10 @@ 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, "Outgoing byte connections", "Exchange bytes with explicitly approved TCP or TLS endpoints. Sent data leaves this device.", "传出字节连接", "与明确批准的 TCP 或 TLS 端点交换字节。发送的数据会离开此设备。"],
|
||||
"ai.invoke": [true, "Use the selected AI model", "Send plugin prompts to the model selected in Netcatty. Model usage may incur charges.", "使用选定的 AI 模型", "向 Netcatty 中选定的模型发送插件提示词。模型用量可能产生费用。"],
|
||||
"context.selected.list": [true, "Selected item summaries", "Read titles and summaries of items currently selected in the host.", "选中项目摘要", "读取宿主当前选中项目的标题和摘要。"],
|
||||
"context.selected.read": [true, "Read selected text", "Request selected text for review before sharing it with the plugin.", "读取选中文本", "请求选中文本,在分享给插件前由你确认。"],
|
||||
"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.", "插件秘密", "通过系统密钥存储与受控租约保存和使用此插件的秘密。"],
|
||||
|
||||
@@ -3,6 +3,14 @@
|
||||
// Shared by host placement, release admission, and the installed-package UI.
|
||||
// A Node entrypoint does not change default placement, but is not admitted to
|
||||
// the normal browser-only product path.
|
||||
function isStaticThemePack(manifest) {
|
||||
return manifest?.main?.data === "theme" && Object.keys(manifest.main).length === 1
|
||||
&& Array.isArray(manifest.contributes?.themes) && manifest.contributes.themes.length > 0
|
||||
&& Object.keys(manifest.contributes).every((key) => key === "themes")
|
||||
&& !Object.keys(manifest.permissions ?? {}).length && !manifest.activationEvents?.length
|
||||
&& !manifest.companionExecutables?.length
|
||||
&& manifest.features?.required?.includes("appearance.theme-packs");
|
||||
}
|
||||
function describePluginRuntime(manifest) {
|
||||
const providers = manifest?.contributes?.providers ?? [];
|
||||
const permissions = [...(manifest?.permissions?.required ?? []), ...(manifest?.permissions?.optional ?? [])];
|
||||
@@ -18,19 +26,20 @@ function describePluginRuntime(manifest) {
|
||||
const hasNode = Boolean(manifest?.main?.node);
|
||||
const defaultRuntimeKind = hasBrowser && !hasCompanions && !hasInterceptors ? "browser" : "utility";
|
||||
return {
|
||||
defaultRuntimeKind,
|
||||
defaultRuntimeKind: isStaticThemePack(manifest) ? "data" : defaultRuntimeKind,
|
||||
dataOnly: isStaticThemePack(manifest),
|
||||
hasNode,
|
||||
declaresAdvanced,
|
||||
browserOnly: hasBrowser && !hasNode && !hasCompanions && !hasInterceptors && !declaresAdvanced,
|
||||
browserOnly: !manifest?.main?.data && hasBrowser && !hasNode && !hasCompanions && !hasInterceptors && !declaresAdvanced,
|
||||
};
|
||||
}
|
||||
|
||||
function assertBrowserOnlyPlugin(manifest) {
|
||||
if (!describePluginRuntime(manifest).browserOnly) {
|
||||
if (!describePluginRuntime(manifest).browserOnly && !isStaticThemePack(manifest)) {
|
||||
const error = new Error("This package requires advanced developer mode; normal plugin management accepts browser-only packages");
|
||||
error.code = "PLUGIN_UNSUPPORTED_RUNTIME";
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { describePluginRuntime, assertBrowserOnlyPlugin };
|
||||
module.exports = { describePluginRuntime, assertBrowserOnlyPlugin, isStaticThemePack };
|
||||
@@ -19,3 +19,9 @@ test('normal release admission rejects advanced declarations and privileged prov
|
||||
assert.equal(describePluginRuntime(manifest).defaultRuntimeKind, 'utility');
|
||||
assert.throws(() => assertBrowserOnlyPlugin(manifest), /developer mode/);
|
||||
});
|
||||
|
||||
test('normal admission accepts only explicit non-executable theme-only data packages',()=>{
|
||||
const manifest={main:{data:'theme'},features:{required:['appearance.theme-packs']},contributes:{themes:[{id:'com.example.theme.light'}]}};
|
||||
assert.doesNotThrow(()=>assertBrowserOnlyPlugin(manifest));assert.equal(describePluginRuntime(manifest).dataOnly,true);assert.equal(describePluginRuntime(manifest).defaultRuntimeKind,'data');
|
||||
for(const change of [{main:{data:'theme',browser:'index.js'}},{permissions:{required:['network']}},{activationEvents:['onStartupFinished']},{contributes:{themes:[],commands:[]}}])assert.throws(()=>assertBrowserOnlyPlugin({...manifest,...change}),/developer mode/);
|
||||
});
|
||||
@@ -0,0 +1,145 @@
|
||||
"use strict";
|
||||
// Pure color projection. Package/wire schema validation belongs to the host.
|
||||
const LIGHT = Object.freeze({
|
||||
background: "#ffffff",
|
||||
foreground: "#161a1d",
|
||||
card: "#ffffff",
|
||||
cardForeground: "#161a1d",
|
||||
popover: "#ffffff",
|
||||
popoverForeground: "#161a1d",
|
||||
secondary: "#e5e7eb",
|
||||
secondaryForeground: "#161a1d",
|
||||
muted: "#f0f2f4",
|
||||
mutedForeground: "#374151",
|
||||
accent: "#1d4ed8",
|
||||
destructive: "#b91c1c",
|
||||
destructiveForeground: "#ffffff",
|
||||
border: "#6b7280",
|
||||
input: "#6b7280",
|
||||
});
|
||||
const DARK = Object.freeze({
|
||||
background: "#111827",
|
||||
foreground: "#f9fafb",
|
||||
card: "#1f2937",
|
||||
cardForeground: "#f9fafb",
|
||||
popover: "#1f2937",
|
||||
popoverForeground: "#f9fafb",
|
||||
secondary: "#374151",
|
||||
secondaryForeground: "#ffffff",
|
||||
muted: "#273244",
|
||||
mutedForeground: "#e5e7eb",
|
||||
accent: "#93c5fd",
|
||||
destructive: "#fca5a5",
|
||||
destructiveForeground: "#111827",
|
||||
border: "#9ca3af",
|
||||
input: "#9ca3af",
|
||||
});
|
||||
function rgb(hex) {
|
||||
return [1, 3, 5].map(
|
||||
(offset) => parseInt(hex.slice(offset, offset + 2), 16) / 255,
|
||||
);
|
||||
}
|
||||
function luminance(hex) {
|
||||
return rgb(hex)
|
||||
.map((v) => (v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4))
|
||||
.reduce((s, v, i) => s + v * [0.2126, 0.7152, 0.0722][i], 0);
|
||||
}
|
||||
function contrast(a, b) {
|
||||
const x = luminance(a),
|
||||
y = luminance(b);
|
||||
return (Math.max(x, y) + 0.05) / (Math.min(x, y) + 0.05);
|
||||
}
|
||||
function toHsl(hex) {
|
||||
const [r, g, b] = rgb(hex),
|
||||
max = Math.max(r, g, b),
|
||||
min = Math.min(r, g, b),
|
||||
delta = max - min,
|
||||
l = (max + min) / 2;
|
||||
let h = 0;
|
||||
if (delta)
|
||||
h =
|
||||
(max === r
|
||||
? ((g - b) / delta + 6) % 6
|
||||
: max === g
|
||||
? (b - r) / delta + 2
|
||||
: (r - g) / delta + 4) * 60;
|
||||
const s = delta ? delta / (1 - Math.abs(2 * l - 1)) : 0;
|
||||
return `${h.toFixed(3)} ${(s * 100).toFixed(3)}% ${(l * 100).toFixed(3)}%`;
|
||||
}
|
||||
// A small margin covers rounding when hex colors become HSL CSS components.
|
||||
function resolveThemePalette(palette) {
|
||||
const colors = {
|
||||
...(palette.base === "dark" ? DARK : LIGHT),
|
||||
...palette.colors,
|
||||
};
|
||||
for (const value of Object.values(colors))
|
||||
if (!/^#[0-9a-f]{6}$/i.test(value)) throw new Error("Invalid theme color");
|
||||
const pairs = [
|
||||
["foreground", "background"],
|
||||
["cardForeground", "card"],
|
||||
["popoverForeground", "popover"],
|
||||
["secondaryForeground", "secondary"],
|
||||
["mutedForeground", "muted"],
|
||||
["destructiveForeground", "destructive"],
|
||||
];
|
||||
for (const [a, b] of pairs)
|
||||
if (contrast(colors[a], colors[b]) < 4.51)
|
||||
throw new Error("Theme text contrast is below 4.5:1");
|
||||
// Host labels and descriptions reuse these text tokens across neutral
|
||||
// surfaces; they do not always sit on their namesake background token.
|
||||
for (const surface of ["background", "card", "popover", "secondary", "muted"])
|
||||
for (const key of ["foreground", "mutedForeground"])
|
||||
if (contrast(colors[key], colors[surface]) < 4.51)
|
||||
throw new Error("Theme neutral-surface text contrast is below 4.5:1");
|
||||
for (const surface of ["background", "card", "popover"]) {
|
||||
for (const key of ["accent", "destructive"])
|
||||
if (contrast(colors[key], colors[surface]) < 4.51)
|
||||
throw new Error("Theme state contrast is below 4.5:1");
|
||||
for (const key of ["border", "input"])
|
||||
if (contrast(colors[key], colors[surface]) < 3.01)
|
||||
throw new Error("Theme control contrast is below 3:1");
|
||||
}
|
||||
const foreground =
|
||||
contrast(colors.accent, "#000000") >= contrast(colors.accent, "#ffffff")
|
||||
? "#000000"
|
||||
: "#ffffff";
|
||||
const tokens = Object.fromEntries(
|
||||
Object.entries(colors).map(([k, v]) => [k, toHsl(v)]),
|
||||
);
|
||||
return Object.freeze({
|
||||
...tokens,
|
||||
primary: tokens.accent,
|
||||
primaryForeground: toHsl(foreground),
|
||||
accentForeground: toHsl(foreground),
|
||||
ring: tokens.accent,
|
||||
});
|
||||
}
|
||||
function securityThemeTokens(mode, highContrast = false) {
|
||||
const colors = highContrast
|
||||
? mode === "dark"
|
||||
? {
|
||||
background: "#000000",
|
||||
card: "#000000",
|
||||
popover: "#000000",
|
||||
foreground: "#ffffff",
|
||||
cardForeground: "#ffffff",
|
||||
popoverForeground: "#ffffff",
|
||||
accent: "#ffff00",
|
||||
border: "#ffffff",
|
||||
input: "#ffffff",
|
||||
}
|
||||
: {
|
||||
background: "#ffffff",
|
||||
card: "#ffffff",
|
||||
popover: "#ffffff",
|
||||
foreground: "#000000",
|
||||
cardForeground: "#000000",
|
||||
popoverForeground: "#000000",
|
||||
accent: "#000080",
|
||||
border: "#000000",
|
||||
input: "#000000",
|
||||
}
|
||||
: {};
|
||||
return resolveThemePalette({ base: mode, colors });
|
||||
}
|
||||
module.exports = { resolveThemePalette, securityThemeTokens, contrast, toHsl };
|
||||
@@ -0,0 +1,33 @@
|
||||
const assert = require("node:assert/strict");
|
||||
const test = require("node:test");
|
||||
const {
|
||||
resolveThemePalette,
|
||||
securityThemeTokens,
|
||||
contrast,
|
||||
} = require("./pluginThemePack.cjs");
|
||||
test("opaque accessible palettes derive coupled accent tokens and fixed high contrast fallback", () => {
|
||||
for (const mode of ["light", "dark"]) {
|
||||
const tokens = resolveThemePalette({ base: mode, colors: {} });
|
||||
assert.equal(tokens.primary, tokens.accent);
|
||||
assert.equal(tokens.ring, tokens.accent);
|
||||
assert.equal(tokens.primaryForeground, tokens.accentForeground);
|
||||
assert.ok(securityThemeTokens(mode, true));
|
||||
}
|
||||
assert.ok(contrast("#000000", "#ffffff") > 20);
|
||||
assert.throws(
|
||||
() =>
|
||||
resolveThemePalette({ base: "light", colors: { foreground: "#ffffff" } }),
|
||||
/contrast/,
|
||||
);
|
||||
});
|
||||
|
||||
test("generic and muted text stay readable on the neutral surfaces that use them", () => {
|
||||
for (const colors of [
|
||||
{ muted: "#000000", mutedForeground: "#ffffff" },
|
||||
{ card: "#000000", cardForeground: "#ffffff" },
|
||||
{ popover: "#000000", popoverForeground: "#ffffff" },
|
||||
{ secondary: "#000000", secondaryForeground: "#ffffff" },
|
||||
]) {
|
||||
assert.throws(() => resolveThemePalette({ base: "light", colors }), /contrast/);
|
||||
}
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
export type SidePanelTool =
|
||||
export type BuiltinSidePanelTool =
|
||||
| 'sftp'
|
||||
| 'browser'
|
||||
| 'scripts'
|
||||
@@ -8,6 +8,12 @@ export type SidePanelTool =
|
||||
| 'system'
|
||||
| 'notes';
|
||||
|
||||
export type SidePanelTool = BuiltinSidePanelTool | `plugin-view:${string}`;
|
||||
|
||||
export function isPluginSidePanelTool(value: unknown): value is `plugin-view:${string}` {
|
||||
return typeof value === 'string' && value.length <= 256 && /^plugin-view:[A-Za-z0-9_.-]+$/.test(value);
|
||||
}
|
||||
|
||||
export type SidePanelSplitDirection = 'horizontal' | 'vertical';
|
||||
|
||||
export type SidePanelPaneNode = {
|
||||
|
||||
@@ -90,6 +90,21 @@ test('sanitize accepts a valid saved preset and rejects malformed data', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('plugin panes keep split uniqueness and limits; saved routes grant no availability and can be pruned', () => {
|
||||
const tool = 'plugin-view:com.example.plugin.page' as const;
|
||||
let layout = createSidePanelLayout('notes', 'notes');
|
||||
layout = splitSidePanelPane(layout, 'notes', tool, 'vertical', { paneId: 'plugin', splitId: 'root' }, 600);
|
||||
const unique = splitSidePanelPane(layout, 'notes', tool, 'vertical', { paneId: 'duplicate', splitId: 'unused' }, 600);
|
||||
assert.equal(collectSidePanelPanes(unique.root).length, 2);
|
||||
assert.equal(getFocusedSidePanelPane(unique).tool, tool);
|
||||
assert.ok(createWorkspaceLayoutPreset(layout));
|
||||
assert.equal(withoutUnavailableSidePanelTools(layout, (item) => item === 'notes')?.root.type, 'pane');
|
||||
for (let i = 0; i < 10; i++) layout = splitSidePanelPane(layout, getFocusedSidePanelPane(layout).id,
|
||||
`plugin-view:com.example.plugin.page${i}`, 'horizontal', { paneId: `p${i}`, splitId: `s${i}` }, 600);
|
||||
assert.equal(collectSidePanelPanes(layout.root).length, 8);
|
||||
assert.equal(sanitizeWorkspaceLayoutPreset({ version: 1, layout: { root: { id: 'invalid', type: 'pane', tool: 'plugin-view:../outside' }, focusedPaneId: 'invalid' } }), null);
|
||||
});
|
||||
|
||||
test('sanitize rejects duplicate node ids', () => {
|
||||
assert.equal(
|
||||
sanitizeWorkspaceLayoutPreset({
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
closeSidePanelPane,
|
||||
collectSidePanelPanes,
|
||||
getFocusedSidePanelPane,
|
||||
isPluginSidePanelTool,
|
||||
type SidePanelLayout,
|
||||
type SidePanelLayoutNode,
|
||||
type SidePanelTool,
|
||||
@@ -32,7 +33,7 @@ const SIDE_PANEL_TOOLS: ReadonlySet<SidePanelTool> = new Set([
|
||||
]);
|
||||
|
||||
function isSidePanelTool(value: unknown): value is SidePanelTool {
|
||||
return typeof value === 'string' && SIDE_PANEL_TOOLS.has(value as SidePanelTool);
|
||||
return typeof value === 'string' && (SIDE_PANEL_TOOLS.has(value as SidePanelTool) || isPluginSidePanelTool(value));
|
||||
}
|
||||
|
||||
function sanitizeSidePanelLayoutNode(
|
||||
|
||||
@@ -70,6 +70,7 @@ module.exports = {
|
||||
'domain/terminalPromptSecurity.shared.cjs',
|
||||
'domain/pluginRuntimePolicy.cjs',
|
||||
'domain/pluginPermissionDescriptions.cjs',
|
||||
'domain/pluginThemePack.cjs',
|
||||
// Main-process SSH, SFTP, and port-forward bridges share this matcher.
|
||||
// TypeScript under domain/ is excluded below, so the .cjs must be listed.
|
||||
'domain/systemManager/containerRuntime.shared.cjs',
|
||||
|
||||
@@ -311,7 +311,7 @@ test("TERM-SSHD queued cancellation releases its turn without closing the owner
|
||||
assert.equal(transferBridge._getSessionFastDownloadWaiterCountForTests(fixture.sftp), 0);
|
||||
});
|
||||
|
||||
test("TERM-SSHD failed transfer releases the physical channel for the next alias", async (t) => {
|
||||
test("TERM-SSHD failed transfer releases the physical channel for the next alias", { timeout: 5_000 }, async (t) => {
|
||||
const fixture = await createSequentialTransferFixture(t);
|
||||
const originalOpen = fixture.sftp.open;
|
||||
let rejectFirst = true;
|
||||
@@ -321,10 +321,16 @@ test("TERM-SSHD failed transfer releases the physical channel for the next alias
|
||||
args.at(-1)(Object.assign(new Error("Permission denied"), { code: 3 }));
|
||||
} else originalOpen(...args);
|
||||
};
|
||||
const [first, next] = await Promise.all([fixture.start("download"), fixture.start("download", "alias")]);
|
||||
assert.match(first.result.error || "", /Permission denied/);
|
||||
assert.equal(next.result.error, undefined);
|
||||
assert.deepEqual(await fs.readFile(next.targetPath), fixture.payload);
|
||||
const transfers = await Promise.all([fixture.start("download"), fixture.start("download", "alias")]);
|
||||
// Both calls await local file preparation before acquiring the shared channel.
|
||||
// Either invocation may reach the injected first OPEN failure; retain the
|
||||
// concurrent release check without assuming filesystem completion order.
|
||||
const failed = transfers.filter((transfer) => transfer.result.error !== undefined);
|
||||
const succeeded = transfers.filter((transfer) => transfer.result.error === undefined);
|
||||
assert.equal(failed.length, 1);
|
||||
assert.equal(succeeded.length, 1);
|
||||
assert.match(failed[0].result.error, /Permission denied/);
|
||||
assert.deepEqual(await fs.readFile(succeeded[0].targetPath), fixture.payload);
|
||||
assert.equal(fixture.rejectedOpens(), 0);
|
||||
});
|
||||
|
||||
|
||||
@@ -244,6 +244,7 @@ function createBridgeRegistrar(context) {
|
||||
},
|
||||
resolveBrandIcon: (pluginId) => pluginHostService?.contributionIconService?.resolveBrand(pluginId),
|
||||
resolveContributionIcon: (payload) => pluginHostService?.contributionIconService?.resolve(payload),
|
||||
themeService: pluginHostService?.themeService,
|
||||
viewHost: pluginHostService?.viewHost,
|
||||
env: process.env,
|
||||
isTrustedSender: createTrustedPluginBridgeSender({ devServerUrl: effectiveDevServerUrl }),
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
"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 };
|
||||
@@ -0,0 +1,263 @@
|
||||
"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 };
|
||||
@@ -0,0 +1,211 @@
|
||||
"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();
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
"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 };
|
||||
@@ -0,0 +1,41 @@
|
||||
"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);
|
||||
});
|
||||
@@ -288,9 +288,9 @@ class BrowserPluginRuntime {
|
||||
this.router.notify(method, params);
|
||||
}
|
||||
|
||||
openStream(streamId, windowBytes) {
|
||||
openStream(streamId, windowBytes, options) {
|
||||
if (!this.router) return Promise.reject(new Error("Plugin browser runtime is not connected"));
|
||||
return this.router.streams.openOutgoing(streamId, windowBytes);
|
||||
return this.router.streams.openOutgoing(streamId, windowBytes, options);
|
||||
}
|
||||
|
||||
getProcessId() {
|
||||
|
||||
@@ -0,0 +1,297 @@
|
||||
"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 };
|
||||
@@ -0,0 +1,330 @@
|
||||
"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 });
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
"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 };
|
||||
@@ -0,0 +1,132 @@
|
||||
"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", "cancelRead", "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", "cancelRead", "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 };
|
||||
if (name === "cancelRead") {
|
||||
const reading = connection.readable.read();
|
||||
connection.readable.cancel();
|
||||
try { await reading; return false; } catch { return true; } finally { await connection.close(); }
|
||||
}
|
||||
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(await run("cancelRead"), true);
|
||||
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);
|
||||
});
|
||||
@@ -0,0 +1,207 @@
|
||||
"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) 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) {
|
||||
const alreadyClosing = record.closing;
|
||||
this.closeRecord(record, error);
|
||||
if (alreadyClosing) { try { record.output?.cancel(); } catch {} }
|
||||
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 };
|
||||
@@ -0,0 +1,97 @@
|
||||
"use strict";
|
||||
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const net = require("node:net");
|
||||
const { once } = require("node:events");
|
||||
const { PluginByteStreamService } = require("./byteStreamService.cjs");
|
||||
const { PluginHostRpcRegistry } = require("./hostRpcRegistry.cjs");
|
||||
|
||||
async function fixture(t, options = {}) {
|
||||
let peer;
|
||||
const sockets = new Set();
|
||||
const server = net.createServer((socket) => { peer = socket; sockets.add(socket); socket.on("error", () => {}); });
|
||||
server.listen(0, "127.0.0.1");
|
||||
await once(server, "listening");
|
||||
let service;
|
||||
t.after(async () => {
|
||||
service?.shutdown();
|
||||
for (const socket of sockets) socket.destroy();
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
});
|
||||
let source = "first";
|
||||
const endpoint = `tcp://127.0.0.1:${server.address().port}`;
|
||||
const identity = { pluginId: "com.netcatty.stream-source-test", pluginVersion: "0.1.0", runtimeId: "runtime-one",
|
||||
runtimeKind: "browser", sourceOwner: "opaque-first", sourceId: "first", securityPrincipal: "host-principal",
|
||||
manifest: { features: { required: ["network.byte-streams"] } },
|
||||
};
|
||||
const audit = [];
|
||||
const writes = [];
|
||||
const registry = new PluginHostRpcRegistry();
|
||||
service = new PluginByteStreamService({
|
||||
database: {
|
||||
getActivePlugin: () => ({ enabled: true, source: { id: source } }),
|
||||
getRetainedPluginSource: () => ({ id: source }),
|
||||
privateOwner: () => `opaque-${source}`,
|
||||
publicPluginId: (owner) => owner === `opaque-${source}` ? identity.pluginId : null,
|
||||
recordSecurityAudit: (...args) => audit.push(args),
|
||||
},
|
||||
permissionEngine: { onDidRevoke: () => ({ dispose() {} }), authorize: options.authorize ?? (async () => {}) },
|
||||
runtimeSupervisor: { getRuntimeIdentity: () => identity, openStream: options.openStream ?? (async () => ({
|
||||
write: async (bytes) => writes.push(bytes), end() {}, cancel() {},
|
||||
})) },
|
||||
});
|
||||
service.broker.testLocalEndpoints.add(endpoint);
|
||||
service.broker.isControlEndpoint = () => false;
|
||||
service.register(registry);
|
||||
const routes = registry.createRoutes({ ...identity, assertCurrent() {} });
|
||||
const call = (method, params) => routes.requestHandlers[method](params, { signal: new AbortController().signal });
|
||||
return { service, call, endpoint, audit, writes, swap: () => { source = "second"; }, peer: () => peer };
|
||||
}
|
||||
|
||||
test("source changes before grant publication cannot create a socket or grant", async (t) => {
|
||||
let approve;
|
||||
let grants = 0;
|
||||
const f = await fixture(t, { authorize: async (_context, descriptor) => {
|
||||
await new Promise((resolve) => { approve = resolve; });
|
||||
descriptor.validateBeforeGrant();
|
||||
grants++;
|
||||
} });
|
||||
const pending = f.call("network.stream.connect", { endpoint: f.endpoint });
|
||||
while (!approve) await new Promise((resolve) => setImmediate(resolve));
|
||||
f.swap(); approve();
|
||||
await assert.rejects(pending, /source binding/u);
|
||||
assert.equal(grants, 0);
|
||||
assert.equal(f.peer(), undefined);
|
||||
assert.equal(f.service.broker.connections.size, 0);
|
||||
assert.equal(f.audit.length, 0);
|
||||
});
|
||||
|
||||
test("source changes during a real pending read discard bytes and skip the new source audit", async (t) => {
|
||||
const f = await fixture(t);
|
||||
const descriptor = await f.call("network.stream.connect", { endpoint: f.endpoint });
|
||||
await f.call("network.stream.attach", { handleId: descriptor.handleId });
|
||||
f.swap();
|
||||
f.peer().write("synthetic old-source bytes");
|
||||
for (let attempt = 0; attempt < 100 && f.service.records.size; attempt++) await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(f.writes.length, 0);
|
||||
assert.equal(f.service.records.size, 0);
|
||||
assert.equal(f.service.broker.connections.size, 0);
|
||||
assert.deepEqual(f.audit.map((entry) => entry[1]), ["network.stream.open"]);
|
||||
await assert.rejects(f.call("network.stream.close", { handleId: descriptor.handleId }), /source binding/u);
|
||||
});
|
||||
|
||||
test("a stream opened after runtime release is cancelled before attach can succeed", async (t) => {
|
||||
let opened;
|
||||
let cancelled = 0;
|
||||
const f = await fixture(t, { openStream: () => new Promise((resolve) => { opened = resolve; }) });
|
||||
const descriptor = await f.call("network.stream.connect", { endpoint: f.endpoint });
|
||||
const attaching = f.call("network.stream.attach", { handleId: descriptor.handleId });
|
||||
while (!opened) await new Promise((resolve) => setImmediate(resolve));
|
||||
f.service.releaseRuntime("runtime-one");
|
||||
opened({ cancel() { cancelled++; }, write: async () => {}, end() {} });
|
||||
await assert.rejects(attaching, /cancelled/u);
|
||||
assert.equal(cancelled, 1);
|
||||
assert.equal(f.service.records.size, 0);
|
||||
assert.equal(f.service.broker.connections.size, 0);
|
||||
});
|
||||
@@ -12,25 +12,29 @@ const { assertPluginJsonValue } = require("./jsonBoundary.cjs");
|
||||
const MAX_CATALOG_BYTES = 2 * 1024 * 1024;
|
||||
const CATALOG_FIELDS = new Set(["id", "version", "name", "description", "author", "repository", "commit", "license", "readme", "manifest", "artifact"]);
|
||||
|
||||
const DOWNLOAD_HOSTS = new Set(["github.com", "raw.githubusercontent.com", "release-assets.githubusercontent.com", "objects.githubusercontent.com"]);
|
||||
function assertDownloadUrl(value) {
|
||||
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 url = new URL(value);
|
||||
if (url.protocol !== "https:" || url.username || url.password || url.port || !DOWNLOAD_HOSTS.has(url.hostname)) {
|
||||
throw new Error("Catalog download URL is not an approved HTTPS GitHub source");
|
||||
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");
|
||||
}
|
||||
return url.href;
|
||||
}
|
||||
function pinnedReadmeUrl(entry) {
|
||||
function assertReadmeSource(entry) {
|
||||
if (typeof entry.readme !== "string" || /[\u0000-\u001f\u007f\\%?#]/u.test(entry.readme)
|
||||
|| entry.readme.split("/").some((segment) => !segment || segment === "." || segment === "..")) {
|
||||
throw new Error("Invalid or unpinned README path");
|
||||
}
|
||||
const prefix = `/${entry.repository}/${entry.commit}/`;
|
||||
const url = new URL(`https://raw.githubusercontent.com${prefix}${entry.readme.split("/").map(encodeURIComponent).join("/")}`);
|
||||
if (!url.pathname.startsWith(prefix)) throw new Error("README URL escapes the pinned commit");
|
||||
return url.href;
|
||||
if (entry.repository.split("/").some((part) => part === "." || part === "..")) {
|
||||
throw new Error("README source escapes the pinned commit");
|
||||
}
|
||||
}
|
||||
function validateCatalog(entries) {
|
||||
function validateCatalog(entries, origin = DISTRIBUTION_ORIGIN) {
|
||||
assertPluginJsonValue(entries, { maxBytes: MAX_CATALOG_BYTES });
|
||||
if (!Array.isArray(entries) || entries.length > 1000) throw new Error("Invalid plugin catalog");
|
||||
const ids = new Set();
|
||||
@@ -43,71 +47,68 @@ function validateCatalog(entries) {
|
||||
|| entry.readme.split("/").some((p) => !p || p === "." || p === "..") || /[\\%?#\0]/u.test(entry.readme)) {
|
||||
throw new Error("Invalid or unpinned plugin catalog entry");
|
||||
}
|
||||
pinnedReadmeUrl(entry);
|
||||
assertReadmeSource(entry);
|
||||
if (!validateManifest(entry.manifest)) throw new Error(`Catalog manifest is invalid: ${formatValidationErrors(validateManifest.errors)}`);
|
||||
assertCatalogManifest(entry.manifest, entry);
|
||||
ids.add(entry.id);
|
||||
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");
|
||||
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`);
|
||||
}
|
||||
assertDownloadUrl(entry.artifact.url);
|
||||
if (!/^[a-f0-9]{64}$/u.test(entry.artifact.sha256)) throw new Error("Catalog artifact requires SHA-256");
|
||||
assertDownloadUrl(asset.url, origin);
|
||||
if (!/^[a-f0-9]{64}$/u.test(asset.sha256)) throw new Error(`Catalog ${field} requires SHA-256`);
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
async function fetchBounded(url, limit, fetchImpl = fetch, maxRedirects = 4) {
|
||||
async function fetchBounded(url, limit, fetchImpl = fetch, origin = DISTRIBUTION_ORIGIN) {
|
||||
const signal = AbortSignal.timeout(30_000);
|
||||
for (let redirects = 0; redirects <= maxRedirects; redirects++) {
|
||||
const response = await fetchImpl(assertDownloadUrl(url), { redirect: "manual", signal, credentials: "omit", referrerPolicy: "no-referrer" });
|
||||
if ([301, 302, 303, 307, 308].includes(response.status)) {
|
||||
await response.body?.cancel();
|
||||
url = new URL(response.headers.get("location"), url).href;
|
||||
continue;
|
||||
}
|
||||
if (!response.ok) { await response.body?.cancel(); throw new Error(`Catalog download failed (${response.status})`); }
|
||||
if (Number(response.headers.get("content-length")) > limit) {
|
||||
await response.body?.cancel(); throw new Error("Catalog download exceeds size limit");
|
||||
}
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) throw new Error("Catalog download has no body");
|
||||
const chunks = [];
|
||||
let size = 0;
|
||||
try {
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
size += value.byteLength;
|
||||
if (size > limit) throw new Error("Catalog download exceeds size limit");
|
||||
chunks.push(Buffer.from(value));
|
||||
}
|
||||
return Buffer.concat(chunks, size);
|
||||
} finally { await reader.cancel(); }
|
||||
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");
|
||||
}
|
||||
throw new Error("Too many catalog redirects");
|
||||
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(); }
|
||||
}
|
||||
class PluginCatalogService {
|
||||
constructor(options) {
|
||||
this.entries = structuredClone(validateCatalog(options.entries ?? catalog));
|
||||
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.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");
|
||||
@@ -115,16 +116,18 @@ class PluginCatalogService {
|
||||
this.installing = true;
|
||||
let directory;
|
||||
try {
|
||||
const bytes = await fetchBounded(entry.artifact.url, 32 * 1024 * 1024, this.fetch);
|
||||
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");
|
||||
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 });
|
||||
return await this.manager.install(archive, { enable: false, catalog: entry, source: this.marketSource, sourceGuard: this.sourceGuard });
|
||||
} finally {
|
||||
this.installing = false;
|
||||
if (directory) await fs.rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
module.exports = { PluginCatalogService, assertDownloadUrl, fetchBounded, validateCatalog, pinnedReadmeUrl, MAX_CATALOG_BYTES };
|
||||
module.exports = { PluginCatalogService, assertDownloadUrl, fetchBounded, validateCatalog, assertReadmeSource, MAX_CATALOG_BYTES, DISTRIBUTION_ORIGIN };
|
||||
@@ -24,25 +24,17 @@ test("curated catalog uses pinned public first-party source and rejects advanced
|
||||
assert.throws(() => validateCatalog([{ ...entries[0], commit: "main" }]), /unpinned/);
|
||||
});
|
||||
|
||||
test("README retrieval is bounded and pinned, never follows a redirect to latest", async () => {
|
||||
const requested = [];
|
||||
const service = new PluginCatalogService({ entries, fetch: async (url) => {
|
||||
requested.push(url); return new Response("# First-party example");
|
||||
} });
|
||||
assert.equal(await service.readme(entries[0].id), "# First-party example");
|
||||
assert.equal(requested[0], `https://raw.githubusercontent.com/${entries[0].repository}/${entries[0].commit}/${entries[0].readme}`);
|
||||
service.fetch = async () => new Response(null, { status: 302, headers: { location: "https://raw.githubusercontent.com/a/b/main/README.md" } });
|
||||
await assert.rejects(service.readme(entries[0].id), /redirects/);
|
||||
await assert.rejects(fetchBounded("https://raw.githubusercontent.com/a/b/c", 3, async () => new Response("1234")), /size limit/);
|
||||
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/);
|
||||
await assert.rejects(fetchBounded("https://127.0.0.1/a", 10), /approved HTTPS/);
|
||||
await assert.rejects(fetchBounded("https://github.com/a", 10, async () => new Response(null, { status: 302, headers: { location: "http://localhost/secret" } })), /approved HTTPS/);
|
||||
await assert.rejects(fetchBounded("https://plugins.netcatty.app/a", 10, async () => new Response(null, { status: 302, headers: { location: "https://github.com/a" } })), /redirects/);
|
||||
});
|
||||
|
||||
test("catalog install verifies the digest, shares manager installation, and never auto-enables", async (context) => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "netcatty-catalog-test-"));
|
||||
context.after(() => fs.rm(root, { recursive: true, force: true }));
|
||||
const bytes = Buffer.from("synthetic bytes: package validation belongs to PackageStore");
|
||||
const entry = { ...structuredClone(entries[0]), artifact: { url: "https://github.com/example/repo/releases/download/v1/plugin.ncpkg", sha256: createHash("sha256").update(bytes).digest("hex") } };
|
||||
const entry = { ...structuredClone(entries[0]), artifact: { url: "https://plugins.netcatty.app/packages/synthetic.ncpkg", sha256: createHash("sha256").update(bytes).digest("hex") } };
|
||||
let installs = 0;
|
||||
const service = new PluginCatalogService({ entries: [entry], getTempFilePath: (name) => {
|
||||
assert.equal(name, "plugin-catalog-");
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
"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 };
|
||||
@@ -0,0 +1,87 @@
|
||||
"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]);
|
||||
});
|
||||
+159
-20
@@ -5,10 +5,14 @@ const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { DatabaseSync } = require("node:sqlite");
|
||||
|
||||
const SCHEMA_VERSION = 3;
|
||||
const { LOCAL_SOURCE, assertSource, privateOwner } = require("./sourceIdentity.cjs");
|
||||
|
||||
const SCHEMA_VERSION = 7;
|
||||
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"],
|
||||
plugin_runtime_state: ["plugin_id", "plugin_version", "status", "runtime_kind", "last_error", "quarantined_at", "updated_at"],
|
||||
@@ -212,7 +216,50 @@ class PluginDatabase {
|
||||
`);
|
||||
});
|
||||
}
|
||||
if (version <= 3) this.transaction(() => this.db.exec(`
|
||||
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
|
||||
@@ -245,15 +292,64 @@ 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 (?, ?, ?, ?, ?)
|
||||
@@ -360,6 +456,7 @@ 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),
|
||||
@@ -493,6 +590,8 @@ 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);
|
||||
@@ -500,6 +599,8 @@ 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(`
|
||||
@@ -512,16 +613,22 @@ 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);
|
||||
).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));
|
||||
}
|
||||
|
||||
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 = ?
|
||||
@@ -530,6 +637,7 @@ 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();
|
||||
@@ -543,6 +651,7 @@ 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 = ?
|
||||
@@ -550,6 +659,7 @@ 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 = ?
|
||||
@@ -579,6 +689,7 @@ 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 = ?
|
||||
@@ -594,6 +705,7 @@ 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(`
|
||||
@@ -606,6 +718,7 @@ 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 = ?
|
||||
@@ -613,6 +726,7 @@ 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
|
||||
@@ -667,6 +781,7 @@ 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 = ?
|
||||
@@ -675,6 +790,7 @@ 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(`
|
||||
@@ -687,6 +803,7 @@ 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 = ?
|
||||
@@ -699,8 +816,8 @@ class PluginDatabase {
|
||||
FROM plugin_permission_grants
|
||||
WHERE plugin_id = ?
|
||||
ORDER BY permission COLLATE BINARY, resource COLLATE BINARY
|
||||
`).all(pluginId).map((row) => ({
|
||||
pluginId: row.plugin_id,
|
||||
`).all(this.privateOwner(pluginId)).map((row) => ({
|
||||
pluginId,
|
||||
permission: row.permission,
|
||||
resource: row.resource,
|
||||
resourceKind: row.resource_kind,
|
||||
@@ -719,7 +836,7 @@ class PluginDatabase {
|
||||
declaration_hash = excluded.declaration_hash,
|
||||
granted_at = excluded.granted_at
|
||||
`).run(
|
||||
record.pluginId,
|
||||
this.privateOwner(record.pluginId),
|
||||
record.permission,
|
||||
record.resource,
|
||||
record.resourceKind,
|
||||
@@ -729,6 +846,7 @@ 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 = ?
|
||||
@@ -736,10 +854,12 @@ 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 = ?
|
||||
@@ -748,6 +868,7 @@ 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 = ?
|
||||
@@ -757,7 +878,7 @@ class PluginDatabase {
|
||||
|
||||
#mapSecret(row) {
|
||||
return {
|
||||
pluginId: row.plugin_id,
|
||||
pluginId: this.publicPluginId(row.plugin_id),
|
||||
key: row.key,
|
||||
secretRef: row.secret_ref,
|
||||
ciphertext: Buffer.from(row.ciphertext),
|
||||
@@ -777,7 +898,7 @@ class PluginDatabase {
|
||||
ciphertext = excluded.ciphertext,
|
||||
updated_at = excluded.updated_at
|
||||
`).run(
|
||||
record.pluginId,
|
||||
this.privateOwner(record.pluginId),
|
||||
record.key,
|
||||
record.secretRef,
|
||||
record.ciphertext,
|
||||
@@ -787,12 +908,14 @@ 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 = ?
|
||||
@@ -852,7 +975,7 @@ class PluginDatabase {
|
||||
SELECT DISTINCT plugin_id
|
||||
FROM plugin_secrets
|
||||
WHERE key = ? OR key LIKE ? ESCAPE '\\'
|
||||
`).all(prefix, `${escaped}:%`).map((row) => row.plugin_id);
|
||||
`).all(prefix, `${escaped}:%`).map((row) => this.publicPluginId(row.plugin_id)).filter((id) => id !== null);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -885,7 +1008,7 @@ class PluginDatabase {
|
||||
/** @type {Map<string, Array<{ pluginId: string, key: string }>>} */
|
||||
const byProvider = new Map();
|
||||
for (const row of legacyRows) {
|
||||
const pluginId = row.plugin_id;
|
||||
const pluginId = this.publicPluginId(row.plugin_id);
|
||||
const key = row.key;
|
||||
if (typeof pluginId !== "string" || typeof key !== "string") continue;
|
||||
if (!key.startsWith(legacyPrefix)) continue;
|
||||
@@ -1030,18 +1153,34 @@ class PluginDatabase {
|
||||
sha256: createHash("sha256").update(detailsJson).digest("hex"),
|
||||
});
|
||||
}
|
||||
this.transaction(() => this.#appendSecurityAudit(pluginId, event, detailsJson));
|
||||
}
|
||||
|
||||
#appendSecurityAudit(pluginId, event, detailsJson) {
|
||||
this.db.prepare(`
|
||||
INSERT INTO plugin_security_audit(plugin_id, event, details_json, created_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`).run(pluginId, event, detailsJson, this.clock());
|
||||
this.db.prepare(`
|
||||
DELETE FROM plugin_security_audit
|
||||
WHERE plugin_id = ? AND id NOT IN (
|
||||
SELECT id FROM plugin_security_audit
|
||||
WHERE plugin_id = ? ORDER BY id DESC LIMIT ${MAX_SECURITY_AUDIT_RECORDS_PER_PLUGIN}
|
||||
)
|
||||
`).run(pluginId, pluginId);
|
||||
}
|
||||
|
||||
getHostThemeSelection() {
|
||||
const row = this.db.prepare("SELECT value_json FROM plugin_host_preferences WHERE key = 'theme'").get();
|
||||
return row ? parseJson(row.value_json, "host theme selection") : {formatVersion: 1, revision: 0, light: null, dark: null};
|
||||
}
|
||||
|
||||
commitHostThemeSelection(expectedRevision, selection, pluginId) {
|
||||
this.transaction(() => {
|
||||
this.db.prepare(`
|
||||
INSERT INTO plugin_security_audit(plugin_id, event, details_json, created_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`).run(pluginId, event, detailsJson, this.clock());
|
||||
this.db.prepare(`
|
||||
DELETE FROM plugin_security_audit
|
||||
WHERE plugin_id = ? AND id NOT IN (
|
||||
SELECT id FROM plugin_security_audit
|
||||
WHERE plugin_id = ? ORDER BY id DESC LIMIT ${MAX_SECURITY_AUDIT_RECORDS_PER_PLUGIN}
|
||||
)
|
||||
`).run(pluginId, pluginId);
|
||||
if (this.getHostThemeSelection().revision !== expectedRevision) throw new Error("Theme selection changed; refresh before applying");
|
||||
this.db.prepare("INSERT INTO plugin_host_preferences(key,value_json,updated_at) VALUES('theme',?,?) ON CONFLICT(key) DO UPDATE SET value_json=excluded.value_json,updated_at=excluded.updated_at")
|
||||
.run(JSON.stringify(selection), this.clock());
|
||||
this.#appendSecurityAudit(pluginId ?? "$host.appearance", "theme-selection", JSON.stringify({revision:selection.revision, action:pluginId ? "apply" : "restore"}));
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ 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,
|
||||
@@ -488,7 +489,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(
|
||||
"com.example",
|
||||
privateOwner(LOCAL_SOURCE, "com.example"),
|
||||
"sync-provider-map:com.example.sync",
|
||||
"ref-legacy-map",
|
||||
Buffer.from("sealed"),
|
||||
@@ -499,7 +500,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(
|
||||
"com.example",
|
||||
privateOwner(LOCAL_SOURCE, "com.example"),
|
||||
"sync-credential",
|
||||
"ref-cred",
|
||||
Buffer.from("sealed-cred"),
|
||||
@@ -532,7 +533,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(
|
||||
"com.example",
|
||||
privateOwner(LOCAL_SOURCE, "com.example"),
|
||||
"sync-provider-map:com.example.sync.foo",
|
||||
"ref-stale-parent-map",
|
||||
Buffer.from("sealed"),
|
||||
@@ -566,7 +567,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(pluginId, key, `ref-${pluginId}`, Buffer.from("sealed"), now, now);
|
||||
`).run(privateOwner(LOCAL_SOURCE, 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");
|
||||
@@ -589,7 +590,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(pluginId, key, `ref-${pluginId}-${key}`, Buffer.from(ciphertext), now, now);
|
||||
`).run(privateOwner(LOCAL_SOURCE, pluginId), key, `ref-${pluginId}-${key}`, Buffer.from(ciphertext), now, now);
|
||||
}
|
||||
const promoted = database.backfillSyncProviderBindingsFromLegacySecrets();
|
||||
assert.equal(promoted, 1);
|
||||
@@ -618,7 +619,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(pluginId, key, `ref-${pluginId}-${key}`, Buffer.from(ciphertext), now, now);
|
||||
`).run(privateOwner(LOCAL_SOURCE, pluginId), key, `ref-${pluginId}-${key}`, Buffer.from(ciphertext), now, now);
|
||||
}
|
||||
const promoted = database.backfillSyncProviderBindingsFromLegacySecrets();
|
||||
assert.equal(promoted, 1);
|
||||
@@ -648,7 +649,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(pluginId, key, `ref-${pluginId}-${key}`, Buffer.from(ciphertext), now, now);
|
||||
`).run(privateOwner(LOCAL_SOURCE, 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");
|
||||
@@ -672,7 +673,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(
|
||||
"com.example",
|
||||
privateOwner(LOCAL_SOURCE, "com.example"),
|
||||
"sync-provider-map:com.example.sync.foo",
|
||||
"ref-tombstone-map",
|
||||
Buffer.from("sealed"),
|
||||
@@ -683,7 +684,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(
|
||||
"com.example",
|
||||
privateOwner(LOCAL_SOURCE, "com.example"),
|
||||
"sync-credential",
|
||||
"ref-cred",
|
||||
Buffer.from("secret"),
|
||||
@@ -711,7 +712,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(
|
||||
"com.example",
|
||||
privateOwner(LOCAL_SOURCE, "com.example"),
|
||||
"sync-provider-map:com.example.custom",
|
||||
"ref-plugin-owned",
|
||||
Buffer.from("plugin-owned-payload"),
|
||||
@@ -723,7 +724,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(
|
||||
"com.example",
|
||||
privateOwner(LOCAL_SOURCE, "com.example"),
|
||||
"sync-credential",
|
||||
"ref-cred-keep",
|
||||
Buffer.from("cred"),
|
||||
@@ -750,7 +751,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(
|
||||
"com.example",
|
||||
privateOwner(LOCAL_SOURCE, "com.example"),
|
||||
"sync-provider-map:com.example.sync",
|
||||
"ref-leftover-map",
|
||||
Buffer.from("sealed"),
|
||||
@@ -823,7 +824,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(pluginId, key, `ref-${pluginId}`, Buffer.from("x"), now, now);
|
||||
`).run(privateOwner(LOCAL_SOURCE, 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.
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,166 @@
|
||||
{
|
||||
"$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}$"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
{
|
||||
"$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/"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
"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 };
|
||||
@@ -0,0 +1,45 @@
|
||||
"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 };
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
# 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.
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 873 B |
+37
@@ -0,0 +1,37 @@
|
||||
# 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).
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 873 B |
@@ -0,0 +1,195 @@
|
||||
[
|
||||
{
|
||||
"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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
"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 };
|
||||
Loaded 100 of 205 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user