mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-10-29 18:11:40 +00:00
Compare commits
21
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5db0f90751 | ||
|
|
ba4d3fc223 | ||
|
|
18377a1fd9 | ||
|
|
056164157f | ||
|
|
2433cf0130 | ||
|
|
1b21c41fa2 | ||
|
|
89beeb9374 | ||
|
|
02049102b6 | ||
|
|
cfa13680e6 | ||
|
|
79a9bd6df1 | ||
|
|
7856c664c3 | ||
|
|
7d8a354404 | ||
|
|
da4e80946d | ||
|
|
5d2ff1cb86 | ||
|
|
9aa899059f | ||
|
|
2eee561847 | ||
|
|
d33c0193cc | ||
|
|
643e71b17b | ||
|
|
e325742abf | ||
|
|
9ca70f3290 | ||
|
|
092438803b |
No files matched your search
@@ -12,6 +12,7 @@ import { ToastProvider } from './components/ui/toast';
|
||||
import { TooltipProvider } from './components/ui/tooltip';
|
||||
import { ScriptAutomationRoot } from './components/scripts/ScriptAutomationRoot';
|
||||
import { ExternalMcpApprovalsHost } from './components/ai/ExternalMcpApprovalsHost';
|
||||
import { PluginPermissionHost } from './components/plugins/PluginPermissionHost';
|
||||
import { PluginHostInteractionHost } from './components/plugins/PluginHostInteractionHost';
|
||||
import { PluginHostActionHost } from './components/plugins/PluginHostActionHost';
|
||||
import { PluginAuthenticationHost } from './components/plugins/PluginAuthenticationHost';
|
||||
@@ -88,6 +89,7 @@ function AppWithProviders({ settings, appLock }: { settings: SettingsState; appL
|
||||
<ScriptAutomationRoot />
|
||||
<ExternalMcpApprovalsHost />
|
||||
<PluginAuthenticationHost />
|
||||
<PluginPermissionHost />
|
||||
<PluginHostInteractionHost />
|
||||
<MultilinePasteConfirmHost />
|
||||
<VaultPublisher>
|
||||
|
||||
@@ -1298,19 +1298,23 @@ Highlight the focused split pane:
|
||||
"plugins.interaction.closeDraft": "Close draft",
|
||||
"settings.plugins.permissions.current": "Matches the current activation",
|
||||
"settings.plugins.permissions.inactive": "Inactive for the current activation",
|
||||
"settings.plugins.permissions.title": "Saved grants",
|
||||
"settings.plugins.permissions.note": "Declared capabilities default to Allow. Ask requests a scoped decision; Deny blocks access immediately. Target selection and draft editing still use the host UI.",
|
||||
"settings.plugins.permissions.allow": "Allow",
|
||||
"settings.plugins.permissions.ask": "Ask",
|
||||
"settings.plugins.permissions.deny": "Deny",
|
||||
"settings.plugins.permissions.title": "Permissions",
|
||||
"settings.plugins.permissions.refresh": "Refresh",
|
||||
"settings.plugins.permissions.loading": "Loading grants...",
|
||||
"settings.plugins.permissions.loading": "Loading permissions...",
|
||||
"settings.plugins.permissions.empty": "No saved grants. One-use decisions are not retained.",
|
||||
"settings.plugins.permissions.always": "Until revoked",
|
||||
"settings.plugins.permissions.application": "Until Netcatty exits",
|
||||
"settings.plugins.permissions.session": "Until the session closes",
|
||||
"settings.plugins.permissions.audit": "Recent security events (up to 100)",
|
||||
"settings.plugins.permissions.revoke": "Disable and revoke all grants",
|
||||
"settings.plugins.permissions.revokeNote": "This disables the plugin, cancels its work, and removes saved grants. Enable it again to request new access.",
|
||||
"settings.plugins.permissions.revokeNote": "This disables the plugin, cancels its work, and removes saved grants. Your Allow / Ask / Deny settings are retained.",
|
||||
"settings.plugins.permissions.unavailable": "Plugin permission management is unavailable",
|
||||
"settings.plugins.manager.permissions": "Declared permissions",
|
||||
"settings.plugins.manager.permissionNote": "From the manifest, not a grant. Enabled allows lazy activation; required permissions are requested before first activation and optional permissions when used. Sensitive operations still pass host checks.",
|
||||
"settings.plugins.manager.permissionNote": "Declared supported capabilities default to Allow. Change each capability to Ask or Deny in the permission controls. Host validation and resource scope always apply.",
|
||||
"settings.plugins.manager.noPermissions": "No privileged permissions declared.",
|
||||
"settings.plugins.manager.required": "Required",
|
||||
"settings.plugins.manager.optional": "Optional",
|
||||
@@ -1353,7 +1357,7 @@ Highlight the focused split pane:
|
||||
"settings.plugins.market.sourceOnly": "First-party source example. Build with the local CLI and import the .ncpkg; no downloadable release has been published.",
|
||||
"settings.plugins.market.readmeNote": "Pinned to the commit above. HTML is inert and remote images are not loaded. Written by the plugin author.",
|
||||
"settings.plugins.manager.noVault": "System key storage is unavailable. Secret and credential operations are blocked; browsing and non-secret plugins still work.",
|
||||
"settings.plugins.manager.sensitive": "Sensitive access: review the data and resource scope before granting.",
|
||||
"settings.plugins.manager.sensitive": "Sensitive access: this capability may expose private data. Its mode can be changed in permission settings.",
|
||||
"settings.plugins.manager.dual": "Browser is the default runtime. This package also includes a Node entrypoint with full device authority; developer mode only.",
|
||||
"settings.plugins.manager.advancedDeclaration": "Browser entrypoint with advanced permission declarations. Not eligible for normal browser-only mode; review every declared permission.",
|
||||
"settings.plugins.market.retry": "Retry",
|
||||
@@ -1362,7 +1366,7 @@ Highlight the focused split pane:
|
||||
"settings.plugins.market.search": "Search name, author or description",
|
||||
"settings.plugins.market.noResults": "No matching plugins. Try another search.",
|
||||
"settings.plugins.market.manifest": "Compatibility and permissions",
|
||||
"settings.plugins.manager.unbounded": "No narrower resource scope is declared. Check the actual scope in the permission prompt.",
|
||||
"settings.plugins.manager.unbounded": "No narrower resource scope is declared. Host validation and the selected permission mode still apply.",
|
||||
"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:",
|
||||
|
||||
@@ -1298,7 +1298,11 @@ Resalta el panel dividido enfocado:
|
||||
"plugins.interaction.closeDraft": "Close draft",
|
||||
"settings.plugins.permissions.current": "Coincide con la ejecución actual",
|
||||
"settings.plugins.permissions.inactive": "No activa para la ejecución actual",
|
||||
"settings.plugins.permissions.title": "Autorizaciones guardadas",
|
||||
"settings.plugins.permissions.note": "Las capacidades declaradas se permiten de forma predeterminada. Preguntar solicita una decisión con un alcance concreto; Denegar bloquea el acceso de inmediato. La selección de destinos y la edición de borradores siguen usando la interfaz de la aplicación.",
|
||||
"settings.plugins.permissions.allow": "Permitir",
|
||||
"settings.plugins.permissions.ask": "Preguntar",
|
||||
"settings.plugins.permissions.deny": "Denegar",
|
||||
"settings.plugins.permissions.title": "Permisos",
|
||||
"settings.plugins.permissions.refresh": "Actualizar",
|
||||
"settings.plugins.permissions.loading": "Cargando autorizaciones...",
|
||||
"settings.plugins.permissions.empty": "No hay autorizaciones guardadas. Las decisiones de un solo uso no se conservan.",
|
||||
@@ -1310,7 +1314,7 @@ Resalta el panel dividido enfocado:
|
||||
"settings.plugins.permissions.revokeNote": "Desactiva el complemento, cancela su trabajo y elimina las autorizaciones guardadas. Actívelo para solicitar acceso de nuevo.",
|
||||
"settings.plugins.permissions.unavailable": "La gestión de permisos no está disponible",
|
||||
"settings.plugins.manager.permissions": "Permisos declarados",
|
||||
"settings.plugins.manager.permissionNote": "Del manifiesto, no es una autorización. Activado permite la activación diferida; los permisos obligatorios se solicitan antes del primer uso y los opcionales cuando se necesitan. Las operaciones sensibles siguen verificándose.",
|
||||
"settings.plugins.manager.permissionNote": "Las capacidades declaradas y compatibles se permiten de forma predeterminada. Puede cambiar cada capacidad a Preguntar o Denegar en los controles de permisos. Siempre se verifican los destinos y el alcance de los recursos.",
|
||||
"settings.plugins.manager.noPermissions": "No se han declarado permisos privilegiados.",
|
||||
"settings.plugins.manager.required": "Obligatorio",
|
||||
"settings.plugins.manager.optional": "Opcional",
|
||||
@@ -1353,7 +1357,7 @@ Resalta el panel dividido enfocado:
|
||||
"settings.plugins.market.sourceOnly": "Ejemplo propio con código fuente. Compílalo con la CLI local e importa el .ncpkg; no hay versión publicada para descargar.",
|
||||
"settings.plugins.market.readmeNote": "Fijado al commit indicado. El HTML no se ejecuta y las imágenes remotas no se cargan. Contenido del autor.",
|
||||
"settings.plugins.manager.noVault": "El almacén de claves del sistema no está disponible. Se bloquean secretos y credenciales; el catálogo y los plugins que no los usan siguen disponibles.",
|
||||
"settings.plugins.manager.sensitive": "Acceso sensible: revisa los datos y el alcance antes de autorizar.",
|
||||
"settings.plugins.manager.sensitive": "Acceso sensible: esta capacidad puede exponer datos privados. Su modo se puede cambiar en los ajustes de permisos.",
|
||||
"settings.plugins.manager.dual": "El navegador es la opción predeterminada. El paquete también incluye una entrada Node con acceso completo al dispositivo; solo para desarrollo.",
|
||||
"settings.plugins.manager.advancedDeclaration": "Entrada de navegador con permisos avanzados declarados. No se admite en el modo normal; revisa cada permiso.",
|
||||
"settings.plugins.market.retry": "Reintentar",
|
||||
@@ -1362,7 +1366,7 @@ Resalta el panel dividido enfocado:
|
||||
"settings.plugins.market.search": "Buscar nombre, autor o descripción",
|
||||
"settings.plugins.market.noResults": "No hay plugins coincidentes. Prueba otra búsqueda.",
|
||||
"settings.plugins.market.manifest": "Compatibilidad y permisos",
|
||||
"settings.plugins.manager.unbounded": "No se declara un alcance de recursos más limitado. Revisa el alcance real en la solicitud de permiso.",
|
||||
"settings.plugins.manager.unbounded": "No se declara un alcance de recursos más limitado. Se siguen aplicando la validación del anfitrión y el modo de permiso seleccionado.",
|
||||
"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:",
|
||||
|
||||
@@ -1179,7 +1179,7 @@ export const ruCoreMessages: Messages = {
|
||||
"settings.plugins.permissions.revokeNote": "Плагин будет отключён, его задачи отменены, а сохранённые разрешения удалены. Включите его для нового запроса доступа.",
|
||||
"settings.plugins.permissions.unavailable": "Управление разрешениями недоступно",
|
||||
"settings.plugins.manager.permissions": "Заявленные разрешения",
|
||||
"settings.plugins.manager.permissionNote": "Из манифеста, а не предоставленные права. Включение допускает отложенную активацию; обязательные разрешения запрашиваются перед ней, необязательные — при использовании. Чувствительные операции проверяются приложением.",
|
||||
"settings.plugins.manager.permissionNote": "Заявленные поддерживаемые возможности по умолчанию разрешены. Для каждой можно выбрать запрос или запрет в настройках разрешений. Проверки приложения и область ресурсов действуют всегда.",
|
||||
"settings.plugins.manager.noPermissions": "Привилегированные разрешения не заявлены.",
|
||||
"settings.plugins.manager.required": "Обязательно",
|
||||
"settings.plugins.manager.optional": "Необязательно",
|
||||
@@ -1222,7 +1222,7 @@ export const ruCoreMessages: Messages = {
|
||||
"settings.plugins.market.sourceOnly": "Пример из этого проекта. Соберите локальной CLI и импортируйте .ncpkg; готовый пакет для скачивания не опубликован.",
|
||||
"settings.plugins.market.readmeNote": "Зафиксирован указанный коммит. HTML не выполняется, внешние изображения не загружаются. Текст автора плагина.",
|
||||
"settings.plugins.manager.noVault": "Системное хранилище ключей недоступно. Операции с секретами и учётными данными заблокированы; каталог и плагины без секретов доступны.",
|
||||
"settings.plugins.manager.sensitive": "Чувствительный доступ: проверьте данные и область ресурсов перед разрешением.",
|
||||
"settings.plugins.manager.sensitive": "Чувствительный доступ: эта возможность может раскрыть личные данные. Её режим можно изменить в настройках разрешений.",
|
||||
"settings.plugins.manager.dual": "По умолчанию используется браузерная среда. Пакет также содержит вход Node с полным доступом к устройству; только для разработчиков.",
|
||||
"settings.plugins.manager.advancedDeclaration": "Браузерная точка входа с заявленными расширенными разрешениями. Не допускается в обычном режиме; проверьте все разрешения.",
|
||||
"settings.plugins.market.retry": "Повторить",
|
||||
@@ -1231,7 +1231,7 @@ export const ruCoreMessages: Messages = {
|
||||
"settings.plugins.market.search": "Поиск по имени, автору или описанию",
|
||||
"settings.plugins.market.noResults": "Совпадений нет. Измените запрос.",
|
||||
"settings.plugins.market.manifest": "Совместимость и разрешения",
|
||||
"settings.plugins.manager.unbounded": "Более узкая область ресурсов не заявлена. Проверьте фактическую область в запросе разрешения.",
|
||||
"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:",
|
||||
|
||||
@@ -1213,19 +1213,23 @@ Vaults 标签保持扁平,不使用这块背景。
|
||||
"plugins.interaction.closeDraft": "关闭草稿",
|
||||
"settings.plugins.permissions.current": "匹配当前运行实例",
|
||||
"settings.plugins.permissions.inactive": "对当前运行实例不生效",
|
||||
"settings.plugins.permissions.title": "已保存的授权",
|
||||
"settings.plugins.permissions.note": "已声明且受支持的权限默认允许。询问时显示一次范围明确的确认;拒绝会立即阻止访问。选择目标和编辑草稿仍使用宿主界面。",
|
||||
"settings.plugins.permissions.allow": "允许",
|
||||
"settings.plugins.permissions.ask": "询问",
|
||||
"settings.plugins.permissions.deny": "拒绝",
|
||||
"settings.plugins.permissions.title": "权限",
|
||||
"settings.plugins.permissions.refresh": "刷新",
|
||||
"settings.plugins.permissions.loading": "正在读取授权...",
|
||||
"settings.plugins.permissions.loading": "正在加载权限...",
|
||||
"settings.plugins.permissions.empty": "没有已保存的授权。单次决定不会保留。",
|
||||
"settings.plugins.permissions.always": "直到撤销",
|
||||
"settings.plugins.permissions.application": "直到退出 Netcatty",
|
||||
"settings.plugins.permissions.session": "直到会话关闭",
|
||||
"settings.plugins.permissions.audit": "最近的安全事件(最多 100 条)",
|
||||
"settings.plugins.permissions.revoke": "停用并撤销全部授权",
|
||||
"settings.plugins.permissions.revokeNote": "此操作会停用插件、取消其工作并删除已保存的授权。重新启用后可申请新授权。",
|
||||
"settings.plugins.permissions.revokeNote": "此操作会停用插件、取消其工作并删除已保存的授权。允许 / 询问 / 拒绝设置会保留。",
|
||||
"settings.plugins.permissions.unavailable": "插件授权管理不可用",
|
||||
"settings.plugins.manager.permissions": "声明的权限",
|
||||
"settings.plugins.manager.permissionNote": "来自 manifest,不代表已授权。启用表示允许按需激活;必需权限在首次激活前申请,可选权限在用到时申请。敏感操作仍受宿主检查。",
|
||||
"settings.plugins.manager.permissionNote": "已声明且受支持的权限默认允许。可以在权限设置中逐项改为询问或拒绝;宿主验证和资源范围限制始终生效。",
|
||||
"settings.plugins.manager.noPermissions": "未声明特权权限。",
|
||||
"settings.plugins.manager.required": "必需",
|
||||
"settings.plugins.manager.optional": "可选",
|
||||
@@ -1268,7 +1272,7 @@ Vaults 标签保持扁平,不使用这块背景。
|
||||
"settings.plugins.market.sourceOnly": "第一方源码示例。请用本地 CLI 构建并导入 .ncpkg;尚未发布可下载发行包。",
|
||||
"settings.plugins.market.readmeNote": "固定到上面的提交。HTML 不执行,远程图片不加载。内容由插件作者提供。",
|
||||
"settings.plugins.manager.noVault": "系统密钥存储不可用。秘密和凭据操作被阻止;浏览市场和不使用秘密的插件仍可使用。",
|
||||
"settings.plugins.manager.sensitive": "敏感访问:授权前请检查涉及的数据和资源范围。",
|
||||
"settings.plugins.manager.sensitive": "敏感访问:此能力可能涉及私密数据,可在权限设置中调整其模式。",
|
||||
"settings.plugins.manager.dual": "默认使用浏览器运行时,但包中还包含拥有完整设备权限的 Node 入口;仅限开发者模式。",
|
||||
"settings.plugins.manager.advancedDeclaration": "浏览器入口声明了高级权限。不适用于普通浏览器插件模式;请逐项检查声明权限。",
|
||||
"settings.plugins.market.retry": "重试",
|
||||
@@ -1277,7 +1281,7 @@ Vaults 标签保持扁平,不使用这块背景。
|
||||
"settings.plugins.market.search": "搜索名称、作者或描述",
|
||||
"settings.plugins.market.noResults": "没有匹配插件,请尝试其他关键词。",
|
||||
"settings.plugins.market.manifest": "兼容性与权限",
|
||||
"settings.plugins.manager.unbounded": "未声明更细的资源范围。请在权限提示中检查实际授权范围。",
|
||||
"settings.plugins.manager.unbounded": "未声明更细的资源范围,仍受应用校验和所选权限模式约束。",
|
||||
"settings.plugins.openView": "打开",
|
||||
"settings.plugins.openManager": "打开插件管理",
|
||||
"settings.plugins.sources.conflict": "此 ID 已从其他来源安装。请先卸载并明确清理旧来源数据,再换源重装。原来源:",
|
||||
|
||||
@@ -1216,19 +1216,23 @@ Vaults 分頁維持扁平,不使用這塊背景。
|
||||
"plugins.interaction.closeDraft": "關閉草稿",
|
||||
"settings.plugins.permissions.current": "符合目前執行個體",
|
||||
"settings.plugins.permissions.inactive": "對目前執行個體不生效",
|
||||
"settings.plugins.permissions.title": "已儲存的授權",
|
||||
"settings.plugins.permissions.note": "已宣告且受支援的權限預設允許。詢問時顯示一次範圍明確的確認;拒絕會立即阻止存取。選擇目標和編輯草稿仍使用宿主介面。",
|
||||
"settings.plugins.permissions.allow": "允許",
|
||||
"settings.plugins.permissions.ask": "詢問",
|
||||
"settings.plugins.permissions.deny": "拒絕",
|
||||
"settings.plugins.permissions.title": "權限",
|
||||
"settings.plugins.permissions.refresh": "重新整理",
|
||||
"settings.plugins.permissions.loading": "正在讀取授權...",
|
||||
"settings.plugins.permissions.loading": "正在載入權限...",
|
||||
"settings.plugins.permissions.empty": "沒有已儲存的授權。單次決定不會保留。",
|
||||
"settings.plugins.permissions.always": "直到撤銷",
|
||||
"settings.plugins.permissions.application": "直到退出 Netcatty",
|
||||
"settings.plugins.permissions.session": "直到工作階段關閉",
|
||||
"settings.plugins.permissions.audit": "最近的安全事件(最多 100 筆)",
|
||||
"settings.plugins.permissions.revoke": "停用並撤銷全部授權",
|
||||
"settings.plugins.permissions.revokeNote": "此操作會停用外掛、取消其工作並刪除已儲存的授權。重新啟用後可申請新授權。",
|
||||
"settings.plugins.permissions.revokeNote": "此操作會停用外掛、取消其工作並刪除已儲存的授權。允許 / 詢問 / 拒絕設定會保留。",
|
||||
"settings.plugins.permissions.unavailable": "外掛授權管理無法使用",
|
||||
"settings.plugins.manager.permissions": "聲明的權限",
|
||||
"settings.plugins.manager.permissionNote": "來自 manifest,不代表已授權。啟用表示允許按需激活;必需權限在首次激活前申請,可選權限在用到時申請。敏感操作仍受宿主檢查。",
|
||||
"settings.plugins.manager.permissionNote": "已宣告且受支援的權限預設允許。可以在權限設定中逐項改為詢問或拒絕;宿主驗證和資源範圍限制始終生效。",
|
||||
"settings.plugins.manager.noPermissions": "未聲明特權權限。",
|
||||
"settings.plugins.manager.required": "必需",
|
||||
"settings.plugins.manager.optional": "可選",
|
||||
@@ -1248,7 +1252,7 @@ Vaults 分頁維持扁平,不使用這塊背景。
|
||||
"settings.plugins.manager.browser": "瀏覽器運行時:特權訪問通過宿主權限代理。",
|
||||
"settings.plugins.manager.advanced": "高級運行時:擁有完整 Node 文件系統和網絡能力,代理權限無法限制這些訪問。僅供開發預覽。",
|
||||
"settings.plugins.manager.data": "本地 KV 僅保留在此設備。只有明確標記為同步的非秘密設置進入加密雲 sidecar。插件包和權限授權不參與同步。",
|
||||
"settings.plugins.manager.unbounded": "未宣告更細的資源範圍。請在權限提示中檢查實際授權範圍。",
|
||||
"settings.plugins.manager.unbounded": "未宣告更細的資源範圍,仍受應用程式驗證和所選權限模式約束。",
|
||||
"settings.plugins.openView": "開啟",
|
||||
"settings.plugins.openManager": "開啟插件管理",
|
||||
"settings.plugins.sources.conflict": "此 ID 已从其他来源安装。请先卸载并明确清理旧来源数据,再换源重装。原来源:",
|
||||
@@ -1296,7 +1300,7 @@ Vaults 分頁維持扁平,不使用這塊背景。
|
||||
"settings.plugins.market.sourceOnly": "第一方源碼示例。請用本地 CLI 構建並導入 .ncpkg;尚未發佈可下載發行包。",
|
||||
"settings.plugins.market.readmeNote": "固定到上面的提交。HTML 不執行,遠程圖片不加載。內容由插件作者提供。",
|
||||
"settings.plugins.manager.noVault": "系統密鑰存儲不可用。秘密和憑據操作被阻止;瀏覽市場和不使用秘密的插件仍可使用。",
|
||||
"settings.plugins.manager.sensitive": "敏感訪問:授權前請檢查涉及的數據和資源範圍。",
|
||||
"settings.plugins.manager.sensitive": "敏感存取:此能力可能涉及私密資料,可在權限設定中調整其模式。",
|
||||
"settings.plugins.manager.dual": "默認使用瀏覽器運行時,但包中還包含擁有完整設備權限的 Node 入口;僅限開發者模式。",
|
||||
"settings.plugins.manager.advancedDeclaration": "瀏覽器入口聲明瞭高級權限。不適用於普通瀏覽器插件模式;請逐項檢查聲明權限。",
|
||||
"settings.plugins.market.retry": "重試",
|
||||
|
||||
@@ -4,7 +4,7 @@ import { getNotesSnapshot } from './notesStore';
|
||||
import { useActiveTabId } from './activeTabStore';
|
||||
import { getAppAppLockRuntime } from './appRuntimeBridge';
|
||||
import { createTrustedPluginAIHostRuntime } from '../../infrastructure/ai/plugin/trustedHostRuntime';
|
||||
import { getConfiguredPluginModel } from '../../infrastructure/ai/plugin/configuredModel';
|
||||
import { getConfiguredPluginModel, prepareConfiguredPluginModel } from '../../infrastructure/ai/plugin/configuredModel';
|
||||
import { buildSelectedPluginSources } from '../../infrastructure/ai/plugin/selectedSources';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
|
||||
@@ -29,6 +29,7 @@ export function usePluginAIContextHost({ sessions, workspaces }: { sessions: Ter
|
||||
const requests = new Map<string, { runtimeId: string; controller: AbortController }>();
|
||||
const runtime = createTrustedPluginAIHostRuntime({
|
||||
getModel: getConfiguredPluginModel,
|
||||
prepareModel: prepareConfiguredPluginModel,
|
||||
isActive: () => mounted && document.visibilityState === 'visible' && getAppAppLockRuntime()?.locked === false,
|
||||
chooseSelected: (signal, runtimeId) => new Promise((resolve, reject) => {
|
||||
if (chooser) { reject(Error('A host selection is already pending')); return; }
|
||||
|
||||
@@ -112,7 +112,7 @@ export function usePluginHostActions() {
|
||||
const entry = current.current;
|
||||
if (!entry || entry.prompt.requestId !== prompt.requestId || entry.prompt.request.kind !== prompt.request.kind) throw new Error('Host review is unavailable');
|
||||
entry.prompt = prompt;
|
||||
setReview({ ...entry });
|
||||
if (!(prompt.request.kind === 'vault.requestConnect' && prompt.permissionMode === 'allow')) setReview({ ...entry });
|
||||
void execute(entry);
|
||||
return;
|
||||
}
|
||||
@@ -126,7 +126,12 @@ export function usePluginHostActions() {
|
||||
scriptsRun: host ? pluginHostRunsSavedScripts(host, vault.groupConfigs, vault.snippets) : false,
|
||||
claimed: false,
|
||||
};
|
||||
current.current = entry; setReview(entry); setSelectedIds([]); setBusy(false); setError(false); responding.current = false;
|
||||
current.current = entry; setSelectedIds([]); setBusy(false); setError(false); responding.current = false;
|
||||
if (prompt.request.kind === 'vault.requestConnect' && prompt.permissionMode === 'allow') {
|
||||
setReview(null);
|
||||
void bridge.respondPluginInteraction?.({ requestId: prompt.requestId, actionToken: prompt.actionToken,
|
||||
action: 'submit', scriptsRun: entry.scriptsRun }).catch(() => void fail(entry));
|
||||
} else setReview(entry);
|
||||
} catch {
|
||||
void bridge.respondPluginInteraction?.({ requestId: prompt.requestId, actionToken: prompt.actionToken, action: 'fail' }).catch(() => {});
|
||||
}
|
||||
@@ -137,7 +142,7 @@ export function usePluginHostActions() {
|
||||
unsubscribe();
|
||||
if (entry && !entry.claimed) void bridge.respondPluginInteraction?.({ requestId: entry.prompt.requestId, actionToken: entry.prompt.actionToken, action: 'cancel' }).catch(() => {});
|
||||
};
|
||||
}, [execute, getVault]);
|
||||
}, [execute, fail, getVault]);
|
||||
|
||||
const respond = useCallback(async (action: 'cancel' | 'submit', draft?: PluginHostDraftFields) => {
|
||||
const entry = current.current;
|
||||
|
||||
@@ -20,7 +20,16 @@ export function usePluginHostInteractions() {
|
||||
current.current = null; setPrompt(null);
|
||||
} else {
|
||||
if (event.request.kind.startsWith('vault.')) return;
|
||||
current.current = event as UiPrompt; setPrompt(event as UiPrompt);
|
||||
current.current = event as UiPrompt;
|
||||
if (event.request.kind === 'terminal.selection.read' && event.phase === 'request') {
|
||||
setPrompt(null);
|
||||
try {
|
||||
const selection = readPluginTerminalSelection(event.terminalSessionId ?? '', event.terminalRendererId ?? '');
|
||||
void bridge.respondPluginInteraction?.({ requestId: event.requestId, actionToken: event.actionToken, action: 'preview', value: selection }).catch(() => {
|
||||
void bridge.respondPluginInteraction?.({ requestId: event.requestId, actionToken: event.actionToken, action: 'cancel' }).catch(() => {});
|
||||
});
|
||||
} catch { void bridge.respondPluginInteraction?.({ requestId: event.requestId, actionToken: event.actionToken, action: 'cancel' }).catch(() => {}); }
|
||||
} else setPrompt(event as UiPrompt);
|
||||
setValue(event.request.kind === 'input' ? event.request.initialValue ?? '' : event.request.kind === 'terminal.input.propose' ? event.request.text : '');
|
||||
}
|
||||
pending.current = false; setBusy(false); setError(false);
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
|
||||
export function usePluginPermissionPrompt() {
|
||||
const [prompts, setPrompts] = useState<NetcattyPluginPermissionPrompt[]>([]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const active = useRef<NetcattyPluginPermissionPrompt | null>(null);
|
||||
const prompt = prompts[0];
|
||||
active.current = prompt ?? null;
|
||||
useEffect(() => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.onPluginPermission || !bridge.setPluginPermissionReady) return;
|
||||
const unsubscribe = bridge.onPluginPermission((event) => {
|
||||
setPrompts((previous) => 'closed' in event ? previous.filter((item) => item.requestId !== event.requestId)
|
||||
: [...previous.filter((item) => item.requestId !== event.requestId), event]);
|
||||
setBusy(false);
|
||||
});
|
||||
void bridge.setPluginPermissionReady(true).catch(() => {});
|
||||
return () => { unsubscribe(); void bridge.setPluginPermissionReady?.(false).catch(() => {}); };
|
||||
}, []);
|
||||
const respond = async (decision: 'allow' | 'deny' | 'cancel') => {
|
||||
if (!prompt || busy || active.current !== prompt) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const accepted = await netcattyBridge.require().respondPluginPermission?.({ requestId: prompt.requestId, actionToken: prompt.actionToken, decision });
|
||||
if (!accepted) setPrompts((previous) => previous.filter((item) => item.requestId !== prompt.requestId));
|
||||
} catch { setPrompts((previous) => previous.filter((item) => item.requestId !== prompt.requestId)); }
|
||||
finally { setBusy(false); }
|
||||
};
|
||||
return { prompt, busy, respond };
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import { fileURLToPath } from 'node:url';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
test('default host adapter, real preload, mounted host chooser and installed assistant reach only the local fixture', { timeout: 20_000 }, async () => {
|
||||
test('cold plugin host reaches the local fixture through the actual main provider allowlist without the AI sidebar', { timeout: 20_000 }, async (t) => {
|
||||
// JSDOM + the in-process runtime prove app wiring; they are not native sandbox
|
||||
// evidence. All state is synthetic, with no real provider, key, Note or host.
|
||||
const rootPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
@@ -42,15 +42,40 @@ test('default host adapter, real preload, mounted host chooser and installed ass
|
||||
const { inProcessBrowserRuntime } = require('../electron/plugins/fixtures/inProcessBrowserRuntime.cjs');
|
||||
const { createPreloadApi } = require('../electron/preload/api.cjs');
|
||||
const aiBridge = require('../electron/bridges/aiBridge.cjs');
|
||||
const { registerProviderHandlers } = require('../electron/bridges/aiBridge/providerHandlers.cjs');
|
||||
const { server, counts } = createAssistantProviderFixture({ delayMs: 25 });
|
||||
const oldNotes = getNotesSnapshot(); const oldLock = getAppAppLockRuntime();
|
||||
let service: ReturnType<typeof createPluginHostService> | undefined;
|
||||
let panel: JSDOM | undefined;
|
||||
const root = createRoot(document.getElementById('root')!);
|
||||
let locked = false; let contentAllowed = false; let reviewAllowed = false;
|
||||
let locked = false; let contentAllowed = false;
|
||||
const lockListeners = new Set<(state: { locked: boolean }) => void>();
|
||||
const reviews: Array<Record<string, unknown>> = [];
|
||||
const trustedPayloads: unknown[] = [];
|
||||
const streamResults: Array<{ ok: boolean; statusCode?: number; allowlistRejected: boolean }> = [];
|
||||
const providerSyncs: Array<{ count: number; selectedPresent: boolean }> = [];
|
||||
const sdkErrorNames: string[] = [];
|
||||
let diagnosticPhase = 'setup';
|
||||
const originalConsoleError = console.error;
|
||||
// node:sqlite was imported above. Node 22 delivers its ExperimentalWarning
|
||||
// asynchronously through console.error. Let module warnings reach normal
|
||||
// stderr before observing SDK/renderer errors; do not suppress that warning.
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
// The expected pre-fix SDK error contains request details. Keep diagnostics
|
||||
// categorical even on the failing-before path; never print payloads/headers.
|
||||
console.error = (value: unknown) => {
|
||||
let category = 'unknown';
|
||||
if (typeof value === 'string' && /not wrapped in act/.test(value)) category = 'react-act-warning';
|
||||
else if (typeof value === 'string' && /act.*environment|environment.*act/.test(value)) category = 'react-act-environment';
|
||||
else if (typeof value === 'string' && /ExperimentalWarning: SQLite is an experimental feature/.test(value)) category = 'node-sqlite-experimental';
|
||||
else if (typeof value === 'string') category = 'other-string';
|
||||
else if (value && typeof value === 'object') {
|
||||
const name = (value as { name?: unknown }).name;
|
||||
if (typeof name === 'string' && ['AbortError', 'AI_APICallError', 'AI_LoadAPIKeyError', 'TypeError', 'Error'].includes(name)) category = name;
|
||||
}
|
||||
sdkErrorNames.push(`${diagnosticPhase}:${category}`);
|
||||
};
|
||||
t.after(() => { console.error = originalConsoleError; });
|
||||
try {
|
||||
server.listen(0, '127.0.0.1'); await once(server, 'listening');
|
||||
const address = server.address(); assert.ok(address && typeof address !== 'string');
|
||||
@@ -62,25 +87,50 @@ test('default host adapter, real preload, mounted host chooser and installed ass
|
||||
ipcRenderer.emit(channel, {}, structuredClone(payload));
|
||||
} });
|
||||
const mainWindow = Object.assign(new EventEmitter(), { webContents: sender, isDestroyed: () => false, isVisible: () => true, isFocused: () => true });
|
||||
const providerContext = {
|
||||
ipcMain: { handle: (channel: string, handler: (...args: unknown[]) => unknown) => ipcHandlers.set(channel, handler) },
|
||||
providerConfigs: [] as Array<{ id: string; baseURL?: string; apiKey?: string }>, webSearchApiHost: null,
|
||||
validateSender: (event: { sender: unknown }) => event.sender === sender,
|
||||
validateSenderOrSettings: (event: { sender: unknown }) => event.sender === sender,
|
||||
// Only credential injection is synthetic. Admission, sync and the HTTP
|
||||
// transport are the production implementations, not a direct-fetch seam.
|
||||
injectApiKeyIntoRequest: (url: string, headers: Record<string, string>, providerId: string) => {
|
||||
const config = providerContext.providerConfigs.find((item) => item.id === providerId);
|
||||
return { url, headers: Object.fromEntries(Object.entries(headers).map(([key, value]) =>
|
||||
[key, config?.apiKey ? value.replace('__IPC_SECURED__', config.apiKey) : value])) };
|
||||
},
|
||||
shouldSkipTLSVerify: () => false,
|
||||
streamRequest: aiBridge._streamRequestForTests,
|
||||
};
|
||||
registerProviderHandlers(providerContext);
|
||||
assert.equal(providerContext.providerConfigs.length, 0);
|
||||
ipcRenderer.invoke = async (channel, payload) => {
|
||||
if (channel === 'netcatty:ai:chat:stream') {
|
||||
const request = payload as { url: string; headers: Record<string, string>; body: string; requestId: string };
|
||||
assert.equal(new URL(request.url).origin, new URL(endpoint).origin);
|
||||
assert.doesNotMatch(JSON.stringify(request), /fixture-only/);
|
||||
const status = await aiBridge._streamRequestForTests(request.url, { headers: request.headers, body: request.body }, { sender }, request.requestId, false);
|
||||
return { ok: status.statusCode === 200, ...status };
|
||||
}
|
||||
if (channel === 'netcatty:ai:chat:cancel') return aiBridge.cancelPluginAIStreamsForSender(sender) > 0;
|
||||
const handler = ipcHandlers.get(channel);
|
||||
assert.ok(handler, `Unexpected trusted IPC: ${channel}`);
|
||||
return handler({ sender }, structuredClone(payload));
|
||||
const result = await handler({ sender }, structuredClone(payload));
|
||||
if (channel === 'netcatty:ai:sync-providers') providerSyncs.push({ count: providerContext.providerConfigs.length,
|
||||
selectedPresent: providerContext.providerConfigs.some((item) => item.id === 'qa-provider') });
|
||||
if (channel === 'netcatty:ai:chat:stream') {
|
||||
const status = result as { ok: boolean; statusCode?: number; error?: string };
|
||||
streamResults.push({ ok: status.ok, statusCode: status.statusCode, allowlistRejected: status.error === 'URL host is not in the allowed list' });
|
||||
}
|
||||
return result;
|
||||
};
|
||||
const api = createPreloadApi({ ipcRenderer, webUtils: {} });
|
||||
Object.defineProperty(dom.window, 'netcatty', { value: api });
|
||||
const syntheticNote = { id: 'synthetic-note', title: 'Synthetic retry plan', content: 'Synthetic retry plan: add a retry button.', createdAt: 1, updatedAt: 1 };
|
||||
publishNotesSnapshot({ notes: [syntheticNote], noteGroups: [] });
|
||||
registerAppAppLockRuntime({ locked: false } as NonNullable<ReturnType<typeof getAppAppLockRuntime>>);
|
||||
dom.window.localStorage.setItem(STORAGE_KEY_AI_PROVIDERS, JSON.stringify([{ id: 'qa-provider', name: 'QA fixture', providerId: 'custom', enabled: true, baseURL: endpoint, defaultModel: 'deterministic-fixture', apiKey: 'fixture-only' }]));
|
||||
dom.window.localStorage.setItem(STORAGE_KEY_AI_PROVIDERS, JSON.stringify([
|
||||
{ id: 'qa-provider', name: 'QA fixture', providerId: 'custom', enabled: true, baseURL: endpoint, defaultModel: 'deterministic-fixture', apiKey: 'fixture-only' },
|
||||
{ id: 'qa-secondary', name: 'Synthetic secondary', providerId: 'custom', enabled: true, baseURL: 'https://secondary.invalid/v1', defaultModel: 'synthetic', apiKey: 'secondary-fixture-marker' },
|
||||
]));
|
||||
dom.window.localStorage.setItem(STORAGE_KEY_AI_ACTIVE_PROVIDER, 'qa-provider');
|
||||
dom.window.localStorage.setItem(STORAGE_KEY_AI_ACTIVE_MODEL, 'deterministic-fixture');
|
||||
service = createPluginHostService({
|
||||
@@ -88,8 +138,8 @@ test('default host adapter, real preload, mounted host chooser and installed ass
|
||||
getInteractionWindow: () => mainWindow, isAppLocked: () => locked,
|
||||
subscribeAppLock: (listener: (state: { locked: boolean }) => void) => { lockListeners.add(listener); return () => lockListeners.delete(listener); },
|
||||
electron: { ipcMain: { handle: (name: string, handler: (...args: unknown[]) => unknown) => ipcHandlers.set(name, handler), removeHandler: (name: string) => ipcHandlers.delete(name) },
|
||||
dialog: { showMessageBox: async (_window: unknown, options: Record<string, unknown>) => { reviews.push(options); return { response: reviewAllowed ? 0 : 1 }; } } },
|
||||
requestPermissionDecision: async (request: { requestId: string; permission: string }) => request.permission === 'context.selected.read' && !contentAllowed
|
||||
dialog: { showMessageBox: async (_window: unknown, options: Record<string, unknown>) => { reviews.push(options); return { response: 1 }; } } },
|
||||
requestPermissionDecision: async (request: { requestId: string; permission: string; relatedPermissions?: Array<{ permission: string }> }) => (request.permission === 'context.selected.read' || request.relatedPermissions?.some((item) => item.permission === 'context.selected.read')) && !contentAllowed
|
||||
? { requestId: request.requestId, decision: 'deny' }
|
||||
: { requestId: request.requestId, decision: 'allow', scope: request.permission === 'context.selected.read' ? 'once' : 'application' },
|
||||
});
|
||||
@@ -101,6 +151,7 @@ test('default host adapter, real preload, mounted host chooser and installed ass
|
||||
fs.copyFileSync(path.join(sample, 'src/index.js'), path.join(staging, 'dist/index.js'));
|
||||
const { buildPluginPackage } = await import('@netcatty/plugin-cli');
|
||||
const archive = path.join(fixtureRoot, 'assistant.ncpkg'); await buildPluginPackage(staging, archive);
|
||||
service.database.setPermissionMode('com.netcatty.assistant', 'context.selected.read', 'ask');
|
||||
await service.manager.install(archive, { enable: true });
|
||||
await act(async () => root.render(<I18nProvider locale="en"><PluginAIContextHost sessions={[]} workspaces={[]} /></I18nProvider>));
|
||||
const waitFor = async (condition: () => boolean) => {
|
||||
@@ -115,6 +166,7 @@ test('default host adapter, real preload, mounted host chooser and installed ass
|
||||
executeCommand: async (command: string, args: unknown) => structuredClone(await service!.contributionService.executeCommand(command, structuredClone(args))),
|
||||
onDidReceiveMessage: () => ({ dispose() {} }),
|
||||
} });
|
||||
diagnosticPhase = 'initial-selection';
|
||||
await act(async () => panel!.window.eval(fs.readFileSync(path.join(sample, 'panel.js'), 'utf8')));
|
||||
await waitFor(() => document.getElementById('plugin-ai-note') !== null);
|
||||
const noteChoice = document.getElementById('plugin-ai-note') as HTMLSelectElement;
|
||||
@@ -128,26 +180,33 @@ test('default host adapter, real preload, mounted host chooser and installed ass
|
||||
assert.equal(reference.value, ''); assert.equal(reference.options.length, 2);
|
||||
assert.notEqual(reference.options[1].value, syntheticNote.id); reference.value = reference.options[1].value;
|
||||
const send = () => element('compose').dispatchEvent(new panel!.window.Event('submit', { cancelable: true }));
|
||||
diagnosticPhase = 'deny-content';
|
||||
send(); await waitFor(() => element('status').textContent!.startsWith('Request ended'));
|
||||
assert.equal(counts.requests, 0); assert.equal(reviews.length, 0);
|
||||
contentAllowed = true;
|
||||
send(); await waitFor(() => element('status').textContent!.startsWith('Request ended'));
|
||||
assert.equal(counts.requests, 0); assert.equal(reviews.length, 1);
|
||||
assert.equal(reviews[0].defaultId, reviews[0].cancelId); assert.match(String(reviews[0].detail), /Synthetic retry plan: add a retry button/);
|
||||
reviewAllowed = true;
|
||||
send(); await waitFor(() => element('status').textContent === 'Complete');
|
||||
diagnosticPhase = 'complete-stream';
|
||||
send(); await waitFor(() => counts.completed === 1 || streamResults.some((result) => !result.ok));
|
||||
assert.deepEqual(streamResults.at(-1), { ok: true, statusCode: 200, allowlistRejected: false },
|
||||
'Cold plugin startup must synchronize host providers before the main allowlist admits transport');
|
||||
assert.ok(providerSyncs.some((sync) => sync.selectedPresent && sync.count === 2), 'Preserve other host-configured providers in the shared registry');
|
||||
await waitFor(() => element('status').textContent === 'Complete');
|
||||
assert.equal(reviews.length, 0, 'content policy must not open a second native sharing review');
|
||||
assert.equal(counts.completed, 1); assert.match(element('response').textContent!, /Synthetic plan/); assert.match(element('usage').textContent!, /12 input tokens/);
|
||||
diagnosticPhase = 'stop-stream';
|
||||
send(); await waitFor(() => element('response').textContent!.startsWith('Synthetic plan: '));
|
||||
element('stop').dispatchEvent(new panel.window.Event('click'));
|
||||
await waitFor(() => element('status').textContent === 'Stopped' && counts.aborted === 1);
|
||||
const stoppedText = element('response').textContent; await new Promise((resolve) => setTimeout(resolve, 50)); assert.equal(element('response').textContent, stoppedText);
|
||||
diagnosticPhase = 'lock-stream';
|
||||
send(); await waitFor(() => element('response').textContent!.startsWith('Synthetic plan: '));
|
||||
locked = true; registerAppAppLockRuntime({ locked: true } as NonNullable<ReturnType<typeof getAppAppLockRuntime>>);
|
||||
for (const listener of lockListeners) listener({ locked: true });
|
||||
await waitFor(() => counts.aborted === 2 && service!.aiContextService.broker.turns.size === 0);
|
||||
assert.equal(aiBridge._getActiveStreamCountForTests(), 0);
|
||||
assert.doesNotMatch(JSON.stringify(trustedPayloads), /fixture-only|apiKey|privateOwner|permissionMode|mcp/);
|
||||
assert.doesNotMatch(JSON.stringify(trustedPayloads), /fixture-only|secondary-fixture-marker|apiKey|privateOwner|permissionMode|mcp/);
|
||||
assert.deepEqual(sdkErrorNames, []);
|
||||
} finally {
|
||||
console.error = originalConsoleError;
|
||||
panel?.window.close();
|
||||
await act(async () => root.unmount());
|
||||
await service?.manager.shutdown(); aiBridge.cleanup();
|
||||
|
||||
@@ -109,6 +109,7 @@ test('installed sample entrypoint uses real SDK, source authority, signed host d
|
||||
const archive = path.join(directory, 'fixture.ncpkg');
|
||||
await buildPluginPackage(packageRoot, archive);
|
||||
await service.manager.install(archive, { enable: true });
|
||||
service.database.setPermissionMode('com.netcatty.host-actions', 'ui.interact', 'ask');
|
||||
service.interactionService.setReady(sender, true);
|
||||
await act(async () => root.render(<I18nProvider locale="en"><PluginHostActionHost /></I18nProvider>));
|
||||
|
||||
@@ -192,8 +193,8 @@ test('installed sample entrypoint uses real SDK, source authority, signed host d
|
||||
const run = await begin('edit'); await chooseGroup();
|
||||
await waitFor(() => button('Save Host') !== undefined); await click(button('Save Host'));
|
||||
await waitFor(() => preparing);
|
||||
await act(async () => { service.permissionEngine.revokeApplication('com.netcatty.host-actions', 'vault.write'); release(); await settle(); });
|
||||
await assert.rejects(run.promise, /revoked|cancelled/); assert.equal(saves, 1); beforeCommit = undefined;
|
||||
await act(async () => { service.permissionEngine.setMode(service.database.getActivePlugin('com.netcatty.host-actions'), 'vault.write', 'deny'); release(); await settle(); });
|
||||
await assert.rejects(run.promise, /revoked|cancelled/); assert.equal(saves, 1); beforeCommit = undefined; service.permissionEngine.setMode(service.database.getActivePlugin('com.netcatty.host-actions'), 'vault.write', 'allow');
|
||||
});
|
||||
|
||||
await t.test('source replacement during review cannot use the old action authority', async () => {
|
||||
@@ -272,13 +273,44 @@ test('installed sample entrypoint uses real SDK, source authority, signed host d
|
||||
}
|
||||
});
|
||||
|
||||
await t.test('Allow connects through the selected target without a confirmation dialog; Deny blocks every Host action', async () => {
|
||||
const id = 'com.netcatty.host-actions';
|
||||
const plugin = service.database.getActivePlugin(id);
|
||||
const set = (permission: string, mode: string) => service.permissionEngine.setMode(plugin, permission, mode);
|
||||
set('ui.interact', 'allow');
|
||||
const allowed = await begin('connect'); await chooseGroup();
|
||||
await act(async () => { await allowed.promise; await settle(); });
|
||||
assert.equal(document.querySelector('[role="dialog"]'), null);
|
||||
assert.equal(connections.length, 2);
|
||||
for (const [permission, command] of [['vault.metadata', 'select'], ['vault.write', 'create']] as const) {
|
||||
set(permission, 'deny'); requestClock += 60_001;
|
||||
await assert.rejects(service.contributionService.executeCommand(`${id}.${command}`), /disabled/);
|
||||
assert.equal(document.querySelector('[role="dialog"]'), null);
|
||||
set(permission, 'allow');
|
||||
}
|
||||
set('ui.interact', 'deny');
|
||||
const denied = await begin('connect'); await chooseGroup();
|
||||
await assert.rejects(denied.promise, /disabled/);
|
||||
assert.equal(connections.length, 2); assert.equal(saves, 1);
|
||||
set('ui.interact', 'ask'); set('vault.write', 'ask');
|
||||
const draft = await begin('create');
|
||||
assert.equal(document.querySelectorAll('[role="dialog"]').length, 1);
|
||||
assert.ok(button('Save Host')); await click(button('Cancel'));
|
||||
assert.deepEqual(await draft.promise, { cancelled: true });
|
||||
set('vault.write', 'allow'); set('vault.metadata', 'ask');
|
||||
const selection = await begin('select'); await chooseGroup();
|
||||
assert.ok('hosts' in await selection.promise);
|
||||
assert.equal(document.querySelector('[role="dialog"]'), null);
|
||||
set('vault.metadata', 'allow');
|
||||
});
|
||||
|
||||
await t.test('runtime removal rejects old signed replies', async () => {
|
||||
const run = await begin('create');
|
||||
const old = prompts.at(-1)!;
|
||||
await act(async () => { await service.manager.setEnabled('com.netcatty.host-actions', false); await settle(); });
|
||||
await assert.rejects(run.promise);
|
||||
assert.equal(await service.interactionService.respond(sender, { requestId: old.requestId, actionToken: old.actionToken, action: 'submit', draft: { hostname: 'late.invalid' } }), false);
|
||||
assert.equal(saves, 1); assert.equal(connections.length, 1);
|
||||
assert.equal(saves, 1); assert.equal(connections.length, 2);
|
||||
});
|
||||
} finally {
|
||||
await act(async () => { root.unmount(); await settle(); });
|
||||
|
||||
@@ -3,7 +3,7 @@ import test from 'node:test';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { JSDOM } from 'jsdom';
|
||||
|
||||
test('host dialog drives choice/input/copy and the two-phase clipboard share using real service action tokens', async () => {
|
||||
test('host dialog drives choice/input/copy and a single scoped clipboard share using real service action tokens', async () => {
|
||||
const dom = new JSDOM('<!doctype html><html><body><div id="root"></div></body></html>', { pretendToBeVisual: true, url: 'http://localhost' });
|
||||
const previous = new Map<string, PropertyDescriptor | undefined>();
|
||||
const install = (key: string, value: unknown) => {
|
||||
@@ -80,9 +80,6 @@ test('host dialog drives choice/input/copy and the two-phase clipboard share usi
|
||||
assert.deepEqual(await copy.promise, { cancelled: false, value: true });
|
||||
assert.deepEqual(writes, ['synthetic output']);
|
||||
const share = await begin({ kind: 'clipboard.read', title: 'Share clipboard' });
|
||||
assert.equal(reads, 0);
|
||||
assert.equal(document.querySelector('textarea'), null);
|
||||
await click(button('Preview clipboard'));
|
||||
assert.equal(reads, 1);
|
||||
assert.equal(document.querySelector('textarea')?.value, 'synthetic private text');
|
||||
await click(button('Share with plugin'));
|
||||
@@ -105,17 +102,15 @@ test('host dialog drives choice/input/copy and the two-phase clipboard share usi
|
||||
assert.match(document.querySelector('[role="dialog"]')?.textContent ?? '', /Synthetic selected terminal/);
|
||||
await click(button('Cancel'));
|
||||
assert.deepEqual(await noShare.promise, { cancelled: true });
|
||||
assert.equal(selectionReads, 0);
|
||||
const selected = await begin({ kind: 'terminal.selection.read', title: 'Synthetic terminal share' });
|
||||
assert.equal(selectionReads, 0);
|
||||
await click(button('Preview terminal selection'));
|
||||
assert.equal(selectionReads, 1);
|
||||
const selected = await begin({ kind: 'terminal.selection.read', title: 'Synthetic terminal share' });
|
||||
assert.equal(selectionReads, 2);
|
||||
assert.equal(document.querySelector('textarea')?.value, selectionText);
|
||||
selectionText = 'changed after preview';
|
||||
await click(button('Share with plugin'));
|
||||
const result = await selected.promise as { cancelled: false; value: { text: string; handle: string } };
|
||||
assert.equal(result.value.text, 'synthetic terminal selection\nsecond line');
|
||||
assert.equal(selectionReads, 1);
|
||||
assert.equal(selectionReads, 2);
|
||||
const draft = await begin({ kind: 'terminal.input.propose', handle: result.value.handle, title: 'Synthetic editable review', text: 'first\nsecond\r\u0003\u001b[31m\u0000' });
|
||||
assert.match(document.querySelector('[role="dialog"]')?.textContent ?? '', /sends no terminal input or Enter/);
|
||||
const textarea = document.querySelector('textarea')!;
|
||||
@@ -135,11 +130,9 @@ test('host dialog drives choice/input/copy and the two-phase clipboard share usi
|
||||
assert.deepEqual(writes, ['synthetic output']);
|
||||
selectionState.visible = false;
|
||||
const hidden = await begin({ kind: 'terminal.selection.read', title: 'Hidden terminal' });
|
||||
await click(button('Preview terminal selection'));
|
||||
assert.ok(document.querySelector('[role="alert"]'));
|
||||
assert.equal(selectionReads, 1);
|
||||
await click(button('Cancel'));
|
||||
assert.deepEqual(await hidden.promise, { cancelled: true });
|
||||
assert.equal(selectionReads, 2);
|
||||
assert.equal(document.querySelector('[role="dialog"]'), null);
|
||||
} finally { cleanupSelection(); terminalElement.remove(); }
|
||||
const cancelled = await begin({ kind: 'clipboard.write', title: 'Cancelled copy', text: 'must not copy' });
|
||||
await click(button('Cancel'));
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import { createRequire } from 'node:module';
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
test('registered Ask uses one in-app dialog; policy controls apply Allow and Deny with no hidden prompt', 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', 'DocumentFragment']) {
|
||||
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);
|
||||
dom.window.HTMLElement.prototype.scrollIntoView = () => {};
|
||||
const { default: React, act } = await import('react');
|
||||
const { createRoot } = await import('react-dom/client');
|
||||
const { I18nProvider } = await import('../application/i18n/I18nProvider');
|
||||
const { PluginPermissionHost } = await import('./plugins/PluginPermissionHost');
|
||||
const { PluginPermissionPanel } = await import('./settings/tabs/PluginPermissionPanel');
|
||||
const { observePluginHostSecurityVisibility } = await import('../application/state/pluginHostSecurityVisibility');
|
||||
const { PluginPermissionDecisionHost } = require('../electron/plugins/permissionDecisionHost.cjs');
|
||||
const { PluginPermissionEngine } = require('../electron/plugins/permissionEngine.cjs');
|
||||
const { PluginHostRpcRegistry } = require('../electron/plugins/hostRpcRegistry.cjs');
|
||||
const listeners = new Set<(event: unknown) => void>();
|
||||
const sender = Object.assign(new EventEmitter(), { isDestroyed: () => false, send: (_channel: string, event: unknown) => { for (const listener of listeners) listener(event); } });
|
||||
const owner = Object.assign(new EventEmitter(), { webContents: sender, isDestroyed: () => false, isFocused: () => true, isVisible: () => true });
|
||||
const host = new PluginPermissionDecisionHost({ getWindow: () => owner, isLocked: () => false });
|
||||
let mode = 'allow', effects = 0;
|
||||
const plugin = { id: 'com.example.permission-ui', activeVersion: '1.0.0', manifest: { id: 'com.example.permission-ui', publisher: 'example', permissions: { optional: [{ permission: 'network', resources: ['https://example.com'] }] } } };
|
||||
const engine = new PluginPermissionEngine({ database: { getPermissionMode: () => mode, privateOwner: () => 'synthetic-source-owner',
|
||||
setPermissionMode: (_id: string, _permission: string, value: string) => { mode = value; }, recordSecurityAudit() {}, deleteAllPermissionGrants() {}, listPermissionGrants: () => [] },
|
||||
requestDecision: (...args: unknown[]) => host.request(...args) });
|
||||
const registry = new PluginHostRpcRegistry(); registry.use(engine.createMiddleware());
|
||||
registry.registerRequest('network.fixture', async () => { effects++; return 'synthetic result'; }, { authorization: { permission: 'network', resources: ['https://example.com'] } });
|
||||
const route = registry.createRoutes({ pluginId: plugin.id, pluginVersion: '1.0.0', runtimeId: 'runtime-ui', runtimeKind: 'browser', manifest: plugin.manifest }).requestHandlers['network.fixture'];
|
||||
Object.assign(dom.window, { netcatty: {
|
||||
onPluginPermission(callback: (event: unknown) => void) { listeners.add(callback); return () => listeners.delete(callback); },
|
||||
setPluginPermissionReady: async (ready: boolean) => host.setReady(sender, ready),
|
||||
respondPluginPermission: async (response: unknown) => host.respond(sender, response),
|
||||
getPluginPermissions: async () => ({ grants: [], audit: [], policies: engine.describePolicies(plugin), policyRevision: engine.getPolicyRevision(plugin) }),
|
||||
setPluginPermissionMode: async (_id: string, permission: string, value: string, revision: string) => {
|
||||
assert.equal(revision, engine.getPolicyRevision(plugin)); engine.setMode(plugin, permission, value); return true;
|
||||
},
|
||||
} });
|
||||
const root = createRoot(document.getElementById('root')!);
|
||||
const nativeMount = document.createElement('div'); document.body.append(nativeMount);
|
||||
const nativeVisibility: boolean[] = [];
|
||||
const stopVisibility = observePluginHostSecurityVisibility(nativeMount, (visible) => nativeVisibility.push(visible));
|
||||
const settle = () => new Promise((resolve) => setTimeout(resolve, 20));
|
||||
const button = (text: string) => Array.from(document.querySelectorAll<HTMLButtonElement>('button')).find((element) => element.textContent === text)!;
|
||||
const choose = async (text: string) => {
|
||||
await act(async () => { document.querySelector('[role="combobox"]')!.dispatchEvent(new dom.window.KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })); await settle(); });
|
||||
const option = Array.from(document.querySelectorAll<HTMLElement>('[role="option"]')).find((element) => element.textContent === text)!;
|
||||
assert.ok(option);
|
||||
await act(async () => { option.click(); await settle(); });
|
||||
};
|
||||
try {
|
||||
await act(async () => root.render(<I18nProvider locale="en"><PluginPermissionPanel pluginId={plugin.id} onChanged={async () => {}} /><PluginPermissionHost /></I18nProvider>));
|
||||
await route({}, {}); assert.equal(effects, 1); assert.equal(document.querySelector('[role="dialog"]'), null);
|
||||
await choose('Ask'); assert.equal(mode, 'ask');
|
||||
let operation!: Promise<string>;
|
||||
await act(async () => { operation = route({}, {}); await settle(); });
|
||||
assert.equal(document.querySelectorAll('[role="dialog"]').length, 1);
|
||||
assert.ok(document.querySelector('[role="dialog"][data-host-security-surface]'));
|
||||
assert.equal(nativeVisibility.at(-1), false, 'the existing native-view lifecycle must yield to the trusted Ask');
|
||||
assert.match(document.querySelector('[role="dialog"]')!.textContent!, /https:\/\/example.com/);
|
||||
assert.equal(effects, 1);
|
||||
await act(async () => { button('Allow this operation').click(); await operation; await settle(); });
|
||||
assert.equal(effects, 2); assert.equal(document.querySelector('[role="dialog"]'), null);
|
||||
await new Promise<void>((resolve) => dom.window.queueMicrotask(resolve));
|
||||
assert.equal(nativeVisibility.at(-1), false, 'visible permission settings remain a security surface after Ask closes');
|
||||
await choose('Deny'); assert.equal(mode, 'deny');
|
||||
await assert.rejects(route({}, {}), /disabled/); assert.equal(effects, 2); assert.equal(document.querySelector('[role="dialog"]'), null);
|
||||
await choose('Allow'); assert.equal(mode, 'allow');
|
||||
await route({}, {}); assert.equal(effects, 3); assert.equal(document.querySelector('[role="dialog"]'), null);
|
||||
// Model the settings editor closing before interacting with a native view.
|
||||
document.querySelector('section[data-host-security-surface]')!.setAttribute('hidden', '');
|
||||
await new Promise<void>((resolve) => dom.window.queueMicrotask(resolve));
|
||||
assert.equal(nativeVisibility.at(-1), true);
|
||||
let displayed!: Promise<unknown>;
|
||||
const opaque = 'selected:0cfc16d6-3448-4333-82ef-dd4ffbddfdab:v1';
|
||||
await act(async () => {
|
||||
displayed = host.request({ requestId: 'host-display-fixture', pluginId: plugin.id, permission: 'ai.invoke', resources: ['*'], reason: 'Host model invocation',
|
||||
relatedPermissions: [{ permission: 'context.selected.read', resources: [opaque] }],
|
||||
displayContext: { modelLabel: 'QA fixture · deterministic-fixture', selections: [{ title: 'Synthetic retry plan', scope: 'note', version: 'bf304994-039a-4884-a707-3a1a40a5d3a9' }] },
|
||||
}); await settle();
|
||||
});
|
||||
const text = document.querySelector('[role="dialog"]')!.textContent!;
|
||||
assert.equal(nativeVisibility.at(-1), false, 'Ask alone must hide the attached plugin view');
|
||||
assert.match(text, /QA fixture · deterministic-fixture/); assert.match(text, /Synthetic retry plan/);
|
||||
assert.doesNotMatch(text, /selected:|0cfc16d6|bf304994/);
|
||||
await act(async () => { button('Deny').click(); await displayed; await settle(); });
|
||||
await new Promise<void>((resolve) => dom.window.queueMicrotask(resolve));
|
||||
assert.equal(nativeVisibility.at(-1), true, 'dismissing Ask restores the existing view without closing it');
|
||||
} finally {
|
||||
await act(async () => root.unmount()); stopVisibility(); host.shutdown(); engine.shutdown(); dom.window.close();
|
||||
for (const [key, descriptor] of previous) { if (descriptor) Object.defineProperty(globalThis, key, descriptor); else Reflect.deleteProperty(globalThis, key); }
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { createRequire } from 'node:module';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { JSDOM } from 'jsdom';
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
test('registered selected-summary Ask is one host chooser; Allow still selects and Deny releases nothing', { timeout: 45_000 }, 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', 'HTMLSelectElement', 'Element', 'Node', 'NodeFilter', 'MutationObserver', 'CustomEvent', 'Event', 'KeyboardEvent', 'MouseEvent']) install(key, key === 'window' ? dom.window : dom.window[key as keyof typeof dom.window]);
|
||||
install('getComputedStyle', dom.window.getComputedStyle.bind(dom.window));
|
||||
install('requestAnimationFrame', dom.window.requestAnimationFrame.bind(dom.window));
|
||||
install('cancelAnimationFrame', dom.window.cancelAnimationFrame.bind(dom.window));
|
||||
install('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
const { default: React, act } = await import('react');
|
||||
const { createRoot } = await import('react-dom/client');
|
||||
const { I18nProvider } = await import('../application/i18n/I18nProvider');
|
||||
const { PluginAIContextHost } = await import('./plugins/PluginAIContextHost');
|
||||
const { PluginPermissionHost } = await import('./plugins/PluginPermissionHost');
|
||||
const { publishNotesSnapshot, getNotesSnapshot } = await import('../application/state/notesStore');
|
||||
const { registerAppAppLockRuntime, getAppAppLockRuntime } = await import('../application/state/appRuntimeBridge');
|
||||
const { createPluginHostService } = require('../electron/plugins/hostService.cjs');
|
||||
const { inProcessBrowserRuntime } = require('../electron/plugins/fixtures/inProcessBrowserRuntime.cjs');
|
||||
const { registerPluginBridge } = require('../electron/plugins/pluginBridge.cjs');
|
||||
const { createPreloadApi } = require('../electron/preload/api.cjs');
|
||||
const fixtureRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'netcatty-selected-policy-'));
|
||||
const oldNotes = getNotesSnapshot(); const oldLock = getAppAppLockRuntime();
|
||||
const handlers = new Map<string, (...args: unknown[]) => unknown>();
|
||||
const ipcMain = Object.assign(new EventEmitter(), { handle: (channel: string, handler: (...args: unknown[]) => unknown) => handlers.set(channel, handler), removeHandler: (channel: string) => handlers.delete(channel) });
|
||||
const ipcRenderer = Object.assign(new EventEmitter(), { invoke: async (channel: string, payload: unknown) => {
|
||||
const handler = handlers.get(channel); assert.ok(handler, channel); return handler({ sender }, structuredClone(payload));
|
||||
} });
|
||||
const prompts: Array<{ channel: string; kind?: string; closed?: boolean }> = [];
|
||||
const sender = Object.assign(new EventEmitter(), { id: 42, isDestroyed: () => false, send: (channel: string, payload: { kind?: string; closed?: boolean }) => {
|
||||
prompts.push({ channel, kind: payload.kind, closed: payload.closed }); ipcRenderer.emit(channel, {}, structuredClone(payload));
|
||||
} });
|
||||
const owner = Object.assign(new EventEmitter(), { webContents: sender, isFocused: () => true, isVisible: () => true, isDestroyed: () => false });
|
||||
const service = createPluginHostService({ app: { getAppPath: () => process.cwd(), getPath: () => fixtureRoot, getVersion: () => '0.0.0' },
|
||||
electron: { ipcMain }, env: {}, getInteractionWindow: () => owner, isAppLocked: () => false });
|
||||
service.runtimeSupervisor.runtimeFactories.browser = inProcessBrowserRuntime;
|
||||
registerPluginBridge(ipcMain, { ...service, isTrustedSender: (event: { sender: unknown }) => event.sender === sender });
|
||||
Object.assign(dom.window, { netcatty: createPreloadApi({ ipcRenderer, webUtils: {} }) });
|
||||
const note = { id: 'synthetic-selected-note', title: 'Synthetic selection', content: 'BODY MUST NOT BE SHARED', createdAt: 1, updatedAt: 1 };
|
||||
publishNotesSnapshot({ notes: [note], noteGroups: [] }); registerAppAppLockRuntime({ locked: false } as never);
|
||||
const root = createRoot(document.getElementById('root')!);
|
||||
const settle = () => new Promise((resolve) => setTimeout(resolve, 20));
|
||||
const waitForDialog = async () => {
|
||||
const deadline = Date.now() + 5_000;
|
||||
while (!document.querySelector('[role="dialog"]')) {
|
||||
assert.ok(Date.now() < deadline, 'registered route did not present a host interaction');
|
||||
await act(async () => { await settle(); });
|
||||
}
|
||||
};
|
||||
const button = (label: string) => Array.from(document.querySelectorAll<HTMLButtonElement>('button')).find((element) => element.textContent === label)!;
|
||||
const begin = () => {
|
||||
const result = service.contributionService.executeCommand('com.netcatty.assistant.contexts') as Promise<Array<{ handle: string; version: string; title: string }>>;
|
||||
void result.catch(() => {});
|
||||
return result;
|
||||
};
|
||||
try {
|
||||
await service.manager.initialize();
|
||||
const stage = path.join(fixtureRoot, 'package'); fs.mkdirSync(path.join(stage, 'dist'), { recursive: true });
|
||||
const sample = path.resolve('examples/plugins/assistant');
|
||||
for (const file of ['netcatty.plugin.json', 'panel.html', 'panel.css', 'panel.js', 'README.md']) fs.copyFileSync(path.join(sample, file), path.join(stage, file));
|
||||
fs.copyFileSync(path.join(sample, 'src/index.js'), path.join(stage, 'dist/index.js'));
|
||||
const { buildPluginPackage } = await import('@netcatty/plugin-cli');
|
||||
const archive = path.join(fixtureRoot, 'assistant.ncpkg'); await buildPluginPackage(stage, archive); await service.manager.install(archive, { enable: true });
|
||||
await act(async () => root.render(<I18nProvider locale="en"><PluginPermissionHost /><PluginAIContextHost sessions={[]} workspaces={[]} /></I18nProvider>));
|
||||
const plugin = service.database.getActivePlugin('com.netcatty.assistant');
|
||||
for (const mode of ['ask', 'allow']) {
|
||||
service.permissionEngine.setMode(plugin, 'context.selected.list', mode);
|
||||
let result!: ReturnType<typeof begin>;
|
||||
await act(async () => { result = begin(); await settle(); });
|
||||
await waitForDialog();
|
||||
assert.ok(document.getElementById('plugin-ai-note'), 'the first interaction must be the real host chooser');
|
||||
assert.equal(document.querySelectorAll('[role="dialog"]').length, 1);
|
||||
assert.equal(prompts.filter((prompt) => prompt.channel === 'netcatty:plugins:permission-prompt' && !prompt.closed).length, 0, 'no capability dialog before the chooser');
|
||||
await act(async () => {
|
||||
const select = document.getElementById('plugin-ai-note') as HTMLSelectElement; select.value = note.id; select.dispatchEvent(new dom.window.Event('change', { bubbles: true })); await settle();
|
||||
});
|
||||
await act(async () => { button('Share selected summaries').click(); await settle(); });
|
||||
const summaries = await result;
|
||||
assert.ok(summaries.some((item) => item.title === note.title));
|
||||
assert.doesNotMatch(JSON.stringify(summaries), /BODY MUST NOT BE SHARED/);
|
||||
assert.equal(document.querySelector('[role="dialog"]'), null);
|
||||
}
|
||||
service.permissionEngine.setMode(plugin, 'context.selected.list', 'deny');
|
||||
const count = prompts.filter((prompt) => prompt.kind === 'context.select').length;
|
||||
await assert.rejects(service.contributionService.executeCommand('com.netcatty.assistant.contexts'), /disabled/);
|
||||
assert.equal(prompts.filter((prompt) => prompt.kind === 'context.select').length, count); assert.equal(document.querySelector('[role="dialog"]'), null);
|
||||
service.permissionEngine.setMode(plugin, 'context.selected.list', 'ask');
|
||||
let cancelled!: ReturnType<typeof begin>;
|
||||
await act(async () => { cancelled = begin(); await settle(); });
|
||||
await waitForDialog();
|
||||
await act(async () => { button('Cancel').click(); await settle(); });
|
||||
await assert.rejects(cancelled); assert.equal(service.aiContextService.selected.handles.size, 0);
|
||||
let revoked!: ReturnType<typeof begin>;
|
||||
await act(async () => { revoked = begin(); await settle(); });
|
||||
await waitForDialog();
|
||||
await act(async () => { service.permissionEngine.setMode(plugin, 'context.selected.list', 'deny'); await settle(); });
|
||||
await assert.rejects(revoked); assert.equal(document.querySelector('[role="dialog"]'), null); assert.equal(service.aiContextService.selected.handles.size, 0);
|
||||
} finally {
|
||||
await act(async () => root.unmount()); await service.manager.shutdown();
|
||||
publishNotesSnapshot(oldNotes); registerAppAppLockRuntime(oldLock); dom.window.close(); fs.rmSync(fixtureRoot, { recursive: true, force: true });
|
||||
for (const [key, descriptor] of previous) { if (descriptor) Object.defineProperty(globalThis, key, descriptor); else Reflect.deleteProperty(globalThis, key); }
|
||||
}
|
||||
});
|
||||
@@ -27,6 +27,7 @@ import { useExternalMcpGrantPersister } from "./ai/useExternalMcpGrantPersister"
|
||||
import { setupMcpApprovalBridge } from "../infrastructure/ai/shared/approvalGate";
|
||||
import { usePluginContributions } from "../application/state/usePluginContributions";
|
||||
import { PluginContributionHost } from "./plugins/PluginContributionHost";
|
||||
import { PluginPermissionHost } from "./plugins/PluginPermissionHost";
|
||||
import { matchesKeyBinding } from "../domain/models";
|
||||
import { isPrimaryModifierWBinding } from "../application/state/windowCommandClose";
|
||||
|
||||
@@ -720,6 +721,7 @@ const SettingsPageContent: React.FC<{ settings: SettingsState; appLock?: AppLock
|
||||
</Tabs>
|
||||
</div>
|
||||
<ExternalMcpApprovalsHost />
|
||||
<PluginPermissionHost />
|
||||
<PluginContributionHost locale={settings.uiLanguage} theme={settings.resolvedTheme} themeTokens={pluginThemeTokens} />
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -9,7 +9,7 @@ export function PluginAIContextHost({ sessions, workspaces }: { sessions: Termin
|
||||
return <Dialog open={choice !== null} onOpenChange={(open) => { if (!open) choice?.cancel(); }}>
|
||||
<DialogContent data-host-security-surface="">
|
||||
<DialogHeader><DialogTitle>Choose a reference for the plugin</DialogTitle>
|
||||
<DialogDescription>Share a Note summary and the current selected workspace, tab, session, and host summaries. Reading content requires a separate permission and text review.</DialogDescription></DialogHeader>
|
||||
<DialogDescription>Share a Note summary and the current selected workspace, tab, session, and host summaries. Content access follows this plugin's Allow / Ask / Deny settings; this chooser shares summaries only.</DialogDescription></DialogHeader>
|
||||
<label className="text-sm" htmlFor="plugin-ai-note">Note</label>
|
||||
<select id="plugin-ai-note" value={noteId} onChange={(event) => setNoteId(event.target.value)} className="rounded border border-border bg-background p-2 text-foreground">
|
||||
<option value="">No Note</option>
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { usePluginPermissionPrompt } from '../../application/state/usePluginPermissionPrompt';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { describePluginPermission } from '@/domain/pluginPermissionDescriptions.cjs';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '../ui/dialog';
|
||||
import { Button } from '../ui/button';
|
||||
|
||||
/** Rendered only by the trusted app, never inside a plugin view. */
|
||||
export function PluginPermissionHost() {
|
||||
const { resolvedLocale } = useI18n();
|
||||
const zh = resolvedLocale.startsWith('zh');
|
||||
const { prompt, busy, respond } = usePluginPermissionPrompt();
|
||||
if (!prompt) return null;
|
||||
const description = describePluginPermission(prompt.permission, resolvedLocale);
|
||||
const resources = prompt.resources.filter((resource) => resource !== '*' && !resource.startsWith('selected:'));
|
||||
return <Dialog open onOpenChange={(open) => { if (!open) void respond('cancel'); }}>
|
||||
<DialogContent data-host-security-surface="" className="max-h-[85vh] overflow-y-auto sm:max-w-[480px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{description.label}</DialogTitle>
|
||||
<DialogDescription className="break-words">{prompt.pluginName ?? prompt.pluginId} · {prompt.pluginVersion}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<p className="text-sm">{description.description}</p>
|
||||
{prompt.displayContext?.modelLabel && <p className="text-sm break-words">{zh ? '模型' : 'Model'}: {prompt.displayContext.modelLabel}</p>}
|
||||
{(prompt.displayContext?.selections ?? []).map((item, index) => <p key={index} className="text-sm break-words">
|
||||
{item.title} <span className="text-xs text-muted-foreground">· {item.scope}</span>
|
||||
</p>)}
|
||||
{(prompt.relatedPermissions ?? []).filter((item, index, items) => items.findIndex((other) => other.permission === item.permission) === index).map((item, index) => <div key={index} className="space-y-1 text-sm">
|
||||
<p>{describePluginPermission(item.permission, resolvedLocale).description}</p>
|
||||
<p className="text-xs text-muted-foreground break-all">{item.resources.filter((resource) => !resource.startsWith('selected:')).join(', ')}</p>
|
||||
</div>)}
|
||||
<p className="text-xs text-muted-foreground break-all">{prompt.pluginId}</p>
|
||||
{resources.length > 0 && <ul className="max-h-44 overflow-auto space-y-1 text-xs text-muted-foreground">{resources.map((resource) => <li key={resource} className="break-all">{resource}</li>)}</ul>}
|
||||
<DialogFooter>
|
||||
<Button variant="outline" disabled={busy} onClick={() => void respond('deny')}>{zh ? '拒绝' : 'Deny'}</Button>
|
||||
<Button disabled={busy} onClick={() => void respond('allow')}>{prompt.sessionId && prompt.allowedScopes && !prompt.allowedScopes.includes('once') ? zh ? '允许此会话' : 'Allow for this session' : zh ? '允许此次操作' : 'Allow this operation'}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>;
|
||||
}
|
||||
@@ -68,6 +68,11 @@ test('plugin manager is single-instance, closes with Escape, restores focus and
|
||||
})}>Open contributed settings view</button>}</PluginManagementDialog></I18nProvider>));
|
||||
await click(trigger);
|
||||
await click(document.querySelector<HTMLButtonElement>('[role="dialog"] button:not([data-dialog-close])')!);
|
||||
// Radix schedules close-focus cleanup after the React close commit. The
|
||||
// settle inside click's act() can precede that commit; observe the actual
|
||||
// handoff instead of assuming the cleanup timer already ran.
|
||||
const handoffDeadline = Date.now() + 2_000;
|
||||
while (!handedOff && Date.now() < handoffDeadline) await act(async () => { await settle(); });
|
||||
assert.equal(handedOff, true, 'external view opens only after modal ownership is released');
|
||||
function ControlledEntry() {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
@@ -88,7 +88,11 @@ test('README failure leaves the permissions and source visible, before the failu
|
||||
selected={entries[0].id} onSelect={() => {}} installed={[]} /></I18nProvider>);
|
||||
assert.match(html, /Compatibility and permissions/);
|
||||
assert.match(html, /settings.read/);
|
||||
assert.match(html, /not a grant/);
|
||||
assert.match(html, /Declared supported capabilities default to Allow/);
|
||||
assert.match(html, /Ask or Deny/);
|
||||
assert.match(html, /Host validation and resource scope always apply/);
|
||||
assert.match(html, /Host validation and the selected permission mode still apply/);
|
||||
assert.doesNotMatch(html, /Check the actual scope in the permission prompt/);
|
||||
assert.match(html, /Source example/);
|
||||
assert.ok(html.indexOf('settings.read') < html.indexOf('Network unavailable'));
|
||||
assert.match(html, /Retry/);
|
||||
|
||||
@@ -19,9 +19,16 @@ test('package detail renders manifest resources as text and explains advanced au
|
||||
} as NetcattyInstalledPlugin;
|
||||
const html = renderToStaticMarkup(<I18nProvider locale="en"><PluginPackageDetails plugin={plugin} /></I18nProvider>);
|
||||
assert.match(html, /full Node filesystem and network authority/);
|
||||
assert.match(html, /Broker permissions cannot restrict this access/);
|
||||
assert.match(html, /Development preview only/);
|
||||
assert.match(html, /https:\/\/example.com/);
|
||||
assert.match(html, /<script>/);
|
||||
assert.doesNotMatch(html, /<script>/);
|
||||
assert.match(html, /not a grant/);
|
||||
assert.match(html, /Declared supported capabilities default to Allow/);
|
||||
assert.match(html, /Ask or Deny/);
|
||||
assert.match(html, /Host validation and resource scope always apply/);
|
||||
assert.match(html, /this capability may expose private data/);
|
||||
assert.match(html, /mode can be changed in permission settings/);
|
||||
assert.doesNotMatch(html, /before granting/);
|
||||
assert.match(html, /aaaaaaaaaaaaaaaa/);
|
||||
});
|
||||
@@ -3,6 +3,8 @@ import { netcattyBridge } from '../../../infrastructure/services/netcattyBridge'
|
||||
import { useI18n } from '../../../application/i18n/I18nProvider';
|
||||
import { describePluginPermission } from '@/domain/pluginPermissionDescriptions.cjs';
|
||||
import { Button } from '../../ui/button';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../../ui/select';
|
||||
import { SettingRow } from '../settings-ui';
|
||||
|
||||
/** Host UI only. Plugin views cannot query or mutate this security state. */
|
||||
export function PluginPermissionPanel({ pluginId, onChanged }: {
|
||||
@@ -36,6 +38,17 @@ export function PluginPermissionPanel({ pluginId, onChanged }: {
|
||||
void refresh();
|
||||
return () => { requestGeneration.current++; };
|
||||
}, [refresh]);
|
||||
const changeMode = async (permission: string, mode: PluginPermissionMode) => {
|
||||
if (pending.current) return;
|
||||
pending.current = true; setBusy(true); setError(null);
|
||||
try {
|
||||
const bridge = netcattyBridge.require();
|
||||
if (!bridge.setPluginPermissionMode || !snapshot?.policyRevision) throw new Error(t('settings.plugins.permissions.unavailable'));
|
||||
await bridge.setPluginPermissionMode(pluginId, permission, mode, snapshot.policyRevision);
|
||||
await onChanged();
|
||||
} catch (cause) { setError(cause instanceof Error ? cause.message : String(cause)); }
|
||||
finally { await refresh(true); pending.current = false; setBusy(false); }
|
||||
};
|
||||
const revoke = async () => {
|
||||
if (pending.current) return;
|
||||
pending.current = true;
|
||||
@@ -61,9 +74,18 @@ export function PluginPermissionPanel({ pluginId, onChanged }: {
|
||||
<h4 className="font-medium">{t('settings.plugins.permissions.title')}</h4>
|
||||
<Button variant="ghost" size="sm" disabled={busy} onClick={() => void refresh()}>{t('settings.plugins.permissions.refresh')}</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{t('settings.plugins.permissions.note')}</p>
|
||||
{error && <p role="alert" className="text-xs text-destructive">{error}</p>}
|
||||
{!snapshot && !error && <p role="status">{t('settings.plugins.permissions.loading')}</p>}
|
||||
{snapshot && <>
|
||||
{(snapshot.policies ?? []).map((policy) => <SettingRow key={policy.permission}
|
||||
label={describePluginPermission(policy.permission, resolvedLocale).label}
|
||||
description={<span className="whitespace-pre-wrap break-all">{describePluginPermission(policy.permission, resolvedLocale).description}{' · '}{policy.resources.join(', ')}</span>}>
|
||||
<Select value={policy.mode} disabled={busy} onValueChange={(mode) => void changeMode(policy.permission, mode as PluginPermissionMode)}>
|
||||
<SelectTrigger className="w-28" aria-label={describePluginPermission(policy.permission, resolvedLocale).label}><SelectValue /></SelectTrigger>
|
||||
<SelectContent>{(['allow', 'ask', 'deny'] as const).map((mode) => <SelectItem key={mode} value={mode}>{t(`settings.plugins.permissions.${mode}`)}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
</SettingRow>)}
|
||||
{snapshot.grants.length === 0 && <p className="text-xs text-muted-foreground">{t('settings.plugins.permissions.empty')}</p>}
|
||||
{snapshot.grants.map((grant, index) => <div key={index} className="rounded-md bg-muted/40 p-3 text-xs">
|
||||
<p className="font-medium">{describePluginPermission(grant.permission, resolvedLocale).label}</p>
|
||||
@@ -76,7 +98,7 @@ export function PluginPermissionPanel({ pluginId, onChanged }: {
|
||||
<summary className="cursor-pointer">{t('settings.plugins.permissions.audit')}</summary>
|
||||
<ol className="mt-2 space-y-1">{snapshot.audit.map((entry, index) => <li key={index} className="break-words">
|
||||
<time dateTime={new Date(entry.createdAt).toISOString()}>{new Date(entry.createdAt).toLocaleString(resolvedLocale)}</time>{' · '}
|
||||
{entry.event}{entry.permission ? ` · ${entry.permission}` : ''}{entry.scope ? ` · ${entry.scope}` : ''}
|
||||
{entry.event}{entry.permission ? ` · ${entry.permission}` : ''}{entry.scope ? ` · ${entry.scope}` : ''}{entry.mode ? ` · ${t(`settings.plugins.permissions.${entry.mode}`)}` : ''}{entry.action ? ` · ${entry.action}` : ''}
|
||||
</li>)}</ol>
|
||||
</details>
|
||||
<p className="text-xs text-muted-foreground">{t('settings.plugins.permissions.revokeNote')}</p>
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# Unified plugin acceptance candidate
|
||||
|
||||
This file records the frozen 677 native baseline. For the subsequent permission
|
||||
policy and AI candidate, use [the new integration record](policy-ai-integration.md)
|
||||
and its package manifest. Historical consent flows below are not the revised UX.
|
||||
|
||||
The dedicated branch is `integration/plugin-native-acceptance`. Its Draft PR
|
||||
records the exact current head and CI runs. Native AI, database, Host/Vault, and
|
||||
settings/logging checks use this candidate rather than separate feature trees.
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
# Plugin model transport cold start
|
||||
|
||||
Commit `092438803bed96753bfe68cb78da9ca1137dee25` removes an independent startup
|
||||
dependency on the internal AI sidebar. It does not establish the cause or outcome
|
||||
of the original native failure; that still requires an authorized native retest.
|
||||
|
||||
## Reproduced boundary
|
||||
|
||||
At native candidate `677aac33b1eec9dbd4a367417a49c964cdea63e7`, the plugin model
|
||||
selector reads the saved host provider, while main's provider registry starts
|
||||
empty. The existing sidebar synchronizes that registry when visible or sending.
|
||||
The plugin startup path did not. A nondefault configured loopback port can
|
||||
therefore fail main's provider allowlist before HTTP transport starts.
|
||||
|
||||
The revised `components/PluginAIContextHost.test.tsx` installs the browser sample,
|
||||
mounts the trusted host component, uses the real application preload, and routes
|
||||
model requests through `registerProviderHandlers` and its actual allowlist. It
|
||||
does not mount the internal sidebar or manually alter the allowlist. Credential
|
||||
injection uses synthetic fixture markers; the HTTP edge and deterministic local
|
||||
fixture use the existing transport. No paid service, real key, Note, or host is
|
||||
used. This remains DOM/in-process evidence, not native sandbox acceptance.
|
||||
|
||||
Failed-before evidence is `/tmp/netcatty-ai-cold-provider-before-final.log`:
|
||||
|
||||
```text
|
||||
ok: false
|
||||
allowlistRejected: true
|
||||
statusCode: undefined
|
||||
```
|
||||
|
||||
The corrected regression passes and preserves both the selected fixture provider
|
||||
and a second host-configured synthetic provider. It checks that provider markers
|
||||
and configuration do not enter plugin-facing model/context envelopes. Expected
|
||||
SDK error output is suppressed during this diagnostic so request payloads and
|
||||
headers are not logged.
|
||||
|
||||
## Correction
|
||||
|
||||
Before publishing a selected model handle, the trusted renderer synchronizes the
|
||||
current host-owned provider settings through the existing `aiSyncProviders`
|
||||
application API. It checks that the selected model revision matches the snapshot
|
||||
being synchronized. The runtime rechecks activity, cancellation, model revision,
|
||||
and pending-selection ownership after the await. Pending selections count toward
|
||||
the model-handle cap; release and shutdown invalidate late results.
|
||||
|
||||
Plugin requests supply no URL, credential, or provider configuration. The existing
|
||||
main sender validation, endpoint allowlist, source binding, and model invocation
|
||||
budgets are unchanged. No new schema feature, plugin permission, or consent
|
||||
dialog is added. The assistant package is unchanged and retains SHA-256
|
||||
`c7d784d5c3bd2feb463afd58eae3a9594615395b168174cb8a3d0677584ae395`.
|
||||
|
||||
Production integration touches `configuredModel.ts`, `trustedHostRuntime.ts`, and
|
||||
`usePluginAIContextHost.ts`. The hook's import and `prepareModel` option may
|
||||
overlap host-selection work; preserve both when integrating the separate policy
|
||||
task. Permission-engine, consent-dialog, and sample-UI changes are outside this
|
||||
correction.
|
||||
|
||||
## Native and policy status
|
||||
|
||||
The native open/close sidebar comparison was incomplete. Execution auto-review
|
||||
denied the Application-scope `context.selected.list` grant; that action did not
|
||||
execute. No post-sidebar inference was performed, the prompt expired, and the
|
||||
sample was disabled while the worker cleaned up. This is not native confirmation
|
||||
of the startup cause. The original `677aac33` app remains frozen for comparison;
|
||||
this fix does not authorize retrying the denied execution action.
|
||||
|
||||
In the earlier permission engine, **Application** grants are memory-only for the
|
||||
application lifetime; **Always** grants are persisted. An explicitly authorized
|
||||
Application grant can fit bounded temporary QA. A one-use prompt on every stream
|
||||
read is not an unavoidable target UX. The separate policy task implements the
|
||||
new direction: default Allow for declared supported capabilities, per-plugin
|
||||
Allow/Ask/Deny settings, audit, and in-app Ask dialogs. That product change is
|
||||
separate from execution-tool authorization and must not be used to evade a tool
|
||||
denial. This transport correction does not redesign that policy.
|
||||
|
||||
Native outcome, independent verification, and the sample's light-theme appearance
|
||||
follow-up remain separate acceptance work. No GUI launch, sandbox bypass, paid
|
||||
model invocation, main merge, or release was performed for this correction.
|
||||
|
||||
## Node 22 CI observer correction
|
||||
|
||||
The new cold-start test failed on exact head `9ca70f329` in test run
|
||||
`40436198585`, Linux job `121965743101`: 15,206 passed, 1 failed, 50 skipped.
|
||||
The sole failure was the empty SDK/renderer-error assertion receiving one
|
||||
`SDK_OR_RENDERER_ERROR` marker. This red result is retained as evidence; it is
|
||||
not an unrelated baseline failure.
|
||||
|
||||
The CI runtime was Node `22.23.3`; local validation had used Node `24.19.0`.
|
||||
Reproduction on the exact CI version identified the safe category
|
||||
`setup:node-sqlite-experimental`, before any model operation. The existing
|
||||
`node:sqlite` import schedules its ExperimentalWarning on the next tick via
|
||||
`console.error`. The newly added observer was installed before that warning
|
||||
drained. The same test passed on Node 24, which did not emit that warning.
|
||||
|
||||
The test now lets pending module warnings reach normal stderr before installing
|
||||
the SDK/renderer observer. The warning remains visible, and the zero-runtime-error
|
||||
assertion is unchanged. Safe phase/error categories make future failures
|
||||
diagnosable without request payloads or headers. No production behavior, timeout,
|
||||
permission, or cancellation assertion changed. Both Node versions pass the
|
||||
focused test; the Node 22 parallel fixture/UI/provider/preload group passes all
|
||||
16 tests with concurrency 4.
|
||||
|
||||
Diagnostic evidence: `/tmp/netcatty-ai-cold-ci-node22-sqlite-category.log` fails
|
||||
before correction, `/tmp/netcatty-ai-cold-ci-node22-after.log` passes with the
|
||||
SQLite warning still visible, and `/tmp/netcatty-ai-cold-ci-node22-parallel.log`
|
||||
records the parallel check. The appearance-only sample change is independently
|
||||
published as `e325742abf9fd329f1f7858eda66367e7a62357c`; its package hash is
|
||||
`7943d4c3800914fe0acd3d0f3a3d7f7c1910c212a5dc58a044871a6a82485274`.
|
||||
@@ -0,0 +1,125 @@
|
||||
# Managed-window Ask acceptance candidate
|
||||
|
||||
The dedicated branch is `integration/plugin-ask-routing-acceptance`. Its Draft
|
||||
PR records the exact frozen integration head, CI and private renderer artifact
|
||||
handoff. It starts from frozen #4001 at
|
||||
`02049102b6732778adcd0f33b0a0ade6b4752327`; the old source checkout, QA workspace,
|
||||
profile and renderer artifact remain unchanged.
|
||||
|
||||
## Exact inputs and ownership
|
||||
|
||||
The only new input is policy owner's reviewed Ask correction
|
||||
`89beeb93749c751f255beb6d0e0f95d23bdd9a7b`, imported by ordinary merge
|
||||
`1b21c41fa26c0173084c7ae75e640ef3274285dd`. There were no merge conflicts or
|
||||
manual production edits. These seven production files exactly match that input:
|
||||
|
||||
| File | Owned production delta |
|
||||
| --- | --- |
|
||||
| `electron/bridges/windowManager.cjs` | Enumerate managed app windows, including Settings |
|
||||
| `electron/main/registerBridges.cjs` | Supply the managed permission-window getter |
|
||||
| `electron/plugins/hostService.cjs` | Pass that getter to the existing decision host |
|
||||
| `electron/plugins/permissionDecisionHost.cjs` | Bind ready/focused/visible/unlocked managed renderer selection and request lifecycle to the chosen receiver |
|
||||
| `electron/plugins/pluginBridge.cjs` | Guard permission readiness/replies by managed app window and main frame; reject plugin/foreign/subframe senders |
|
||||
| `components/SettingsPage.tsx` | Mount the existing permission host for standalone Settings |
|
||||
| `components/plugins/PluginPermissionHost.tsx` | Mark Ask with the existing host-security surface so attached native views yield |
|
||||
|
||||
The input also owns the Settings-installed-entrypoint regression, renderer test
|
||||
updates and policy/security documentation. AI d33 startup wiring, sample assets,
|
||||
the combined cold-start assertions and all earlier policy/transport history are
|
||||
preserved from 020. `ModelSelector.tsx` is unchanged from 020; the separate
|
||||
model-selector failure-loop repair is not imported. Diagnostic #3999 remains
|
||||
excluded. No broker resource/source rules, stream restrictions, package/lock
|
||||
inputs, native dependencies or sandbox configuration change in this integration.
|
||||
|
||||
## Frozen 020 native evidence
|
||||
|
||||
The parent closed that QA session and released the GUI. Its partial outcome is:
|
||||
|
||||
| Native case on 020 | Result |
|
||||
| --- | --- |
|
||||
| Default Allow cold Send and completion | Passed |
|
||||
| Stop, AI Deny, restore without automatic replay | Passed |
|
||||
| Themes in new views | Passed |
|
||||
| Ask visible dialog | Failed: no visible dialog, timeout, zero new model requests |
|
||||
| Ask acceptance/cancel and content-read matrix | Not run; remain pending |
|
||||
|
||||
The input's independent source-only delta review is scoped clean. Its installed
|
||||
SDK/registered IPC test reproduces the old Settings-owned failure and passes with
|
||||
the correction using represented Electron objects. That is not native Ask
|
||||
success. No new native acceptance is claimed by this coding executor.
|
||||
|
||||
Earlier terminal solo/split core passes with corrected fixtures, unreliable/
|
||||
unverified Focus and one QA Enter in A during a palette attempt remain historical
|
||||
evidence. There is no global zero-input or full terminal native-pass claim. The
|
||||
old Rfam run failed socket transport before connect resolved, without reaching
|
||||
authentication/SELECT; its cause and auth/TLS compatibility remain unverified.
|
||||
The next native priority is the new Ask path, not more old-baseline loops.
|
||||
|
||||
## Checks and retained CI history
|
||||
|
||||
| Local check | Result |
|
||||
| --- | --- |
|
||||
| Registered runtime | 696 tests: 695 passed, one native skip, zero failed/cancelled |
|
||||
| Contract/SDK/CLI | 91 passed |
|
||||
| Offline examples | Five passed, one Docker opt-in skip |
|
||||
| Mounted cold-start AI/permission/selected-summary/Host group | 21 passed |
|
||||
| Settings and security-surface visibility group | 37 passed |
|
||||
| Contract/capability/Codex generation and drift | Clean |
|
||||
| Lint | Zero errors, seven existing warnings |
|
||||
| Five sample archives | Validated; two builds each are identical and match frozen 020 |
|
||||
|
||||
The exact integration production build, renderer inventory and final CI are
|
||||
recorded in the Draft PR and artifact provenance. Local native packaging and
|
||||
unpackaged GUI acceptance are not claimed. This environment uses locked
|
||||
dependencies and standard patches; the official pinned Electron binary uses its
|
||||
documented cache/proxy support. No header workaround, native shim, HOME change
|
||||
or sandbox change is introduced.
|
||||
|
||||
Input89 [test CI 40453446775](https://github.com/binaricat/Netcatty/actions/runs/40453446775)
|
||||
succeeded on the parent's single infrastructure-only rerun: 15,342 passed, 50
|
||||
skipped, zero failed/cancelled. The original runner interruption remains evidence;
|
||||
this worker did not duplicate its retry. Input89
|
||||
[four-platform packages](https://github.com/binaricat/Netcatty/actions/runs/40453446781)
|
||||
passed with release jobs skipped. Frozen 020's successful full/platform CI and
|
||||
all earlier diagnosed/unexplained failures remain separate predecessor evidence.
|
||||
|
||||
The [sample manifest](ask-routing-packages.json) records exact IDs, versions,
|
||||
features, permissions/resources and archive/content hashes. All five archives
|
||||
are byte-identical to 020, including Assistant
|
||||
`7943d4c3800914fe0acd3d0f3a3d7f7c1910c212a5dc58a044871a6a82485274`.
|
||||
No archive was activated by this executor. Its unchanged owner README retains
|
||||
old review wording; the current Allow/Ask/Deny procedure governs acceptance.
|
||||
|
||||
## Production-renderer preparation and native Ask recipe
|
||||
|
||||
1. Create a new isolated checkout at this Draft's exact head and a separate
|
||||
disposable synthetic QA profile. Keep the old 020 workspace/profile intact.
|
||||
2. Match `package.json` and `package-lock.json` hashes to the renderer artifact's
|
||||
provenance and the independently normally rebuilt native dependencies. They
|
||||
are unchanged from 020. Prepare matching host workspace packages normally.
|
||||
**The complete backend checkout must match the new head:** the frontend-only
|
||||
bundle does not include the new CJS window-routing code or native binaries.
|
||||
3. Materialize the exact private Library file/version in the consumer's own
|
||||
environment. Verify its archive SHA-256 and every dist file with the supplied
|
||||
inventory/verification script. Producer-local paths are not portable.
|
||||
4. Use those verified production assets as a whole `dist/` in the matching
|
||||
checkout, with the ordinary Electron entrypoint, no Vite environment and
|
||||
normal sandbox/helper settings. This is native **unpackaged production-
|
||||
renderer QA**, not packaged-binary certification. Process-loss causality
|
||||
remains unclassified; no native build/header workaround is supplied.
|
||||
5. Recreate only the synthetic provider/Note/sample setup. Verify Default Allow
|
||||
remains functional; then set the plugin operation to Ask in the active main
|
||||
or standalone Settings window. Keep the selected receiver visible, focused,
|
||||
ready and unlocked; opening Ask must not force focus back to main.
|
||||
6. Record visible readable Ask and native-view yielding. Check cancellation and
|
||||
acceptance as separate explicit operations: cancel must send no model request;
|
||||
accept must authorize only the current bounded operation. Complete the
|
||||
content-read Allow/Ask/Deny matrix and lifecycle invalidation cases. Do not
|
||||
treat the earlier zero-request timeout as an Ask pass.
|
||||
7. Record exact head, archive/sample hashes, request counters, prompt ownership,
|
||||
screenshots/video and any unrun cases. Report a model-selector failure
|
||||
separately rather than folding its other owner's repair into this candidate.
|
||||
|
||||
Hosting/distribution/deployment and remaining DB/terminal acceptance gates stay
|
||||
parent-coordinated. No main merge, Ready transition or release is part of this
|
||||
handoff.
|
||||
@@ -0,0 +1,197 @@
|
||||
[
|
||||
{
|
||||
"name": "assistant",
|
||||
"id": "com.netcatty.assistant",
|
||||
"version": "0.1.0",
|
||||
"fileCount": 6,
|
||||
"bytes": 17214,
|
||||
"sha256": "7943d4c3800914fe0acd3d0f3a3d7f7c1910c212a5dc58a044871a6a82485274",
|
||||
"contentSha256": "c3e31e9613422bf9a05ccaa5b6f234304208f1a8298b1c1ee19262ba88af6cd0",
|
||||
"features": {
|
||||
"required": [
|
||||
"ai.model-invocation",
|
||||
"context.selected-read"
|
||||
]
|
||||
},
|
||||
"permissions": {
|
||||
"required": [
|
||||
"commands",
|
||||
"views"
|
||||
],
|
||||
"optional": [
|
||||
"ai.invoke",
|
||||
"context.selected.list",
|
||||
"context.selected.read"
|
||||
]
|
||||
},
|
||||
"package": "assistant.ncpkg",
|
||||
"repeatIdentical": true,
|
||||
"preparedOnlyInThisExecutor": true,
|
||||
"previousFrozen677Sha256": "c7d784d5c3bd2feb463afd58eae3a9594615395b168174cb8a3d0677584ae395",
|
||||
"changedEntriesFromFrozen677": [
|
||||
"README.md",
|
||||
"panel.css",
|
||||
"panel.js"
|
||||
],
|
||||
"previous7856Sha256": "7943d4c3800914fe0acd3d0f3a3d7f7c1910c212a5dc58a044871a6a82485274",
|
||||
"changedEntriesFrom7856": [],
|
||||
"rebuiltAgainstPolicy": "89beeb93749c751f255beb6d0e0f95d23bdd9a7b",
|
||||
"rebuiltAgainstAI": "d33c0193ccaedc1a10aed9da495f040250121bef",
|
||||
"previous020Sha256": "7943d4c3800914fe0acd3d0f3a3d7f7c1910c212a5dc58a044871a6a82485274",
|
||||
"changedEntriesFrom020": []
|
||||
},
|
||||
{
|
||||
"name": "database-owned",
|
||||
"id": "com.netcatty.database-minimal",
|
||||
"version": "0.1.0",
|
||||
"fileCount": 5,
|
||||
"bytes": 11660,
|
||||
"sha256": "f01c6a24382897d4b0bf4130941274abacb9a9a963f9b56ecaceb2c020f36874",
|
||||
"contentSha256": "4d628c41c7a231e31cd56dacb822acaaa37f61e1e98b285b0086a4f783be199d",
|
||||
"features": {
|
||||
"required": [
|
||||
"network.byte-streams"
|
||||
]
|
||||
},
|
||||
"permissions": {
|
||||
"required": [
|
||||
"commands",
|
||||
"views"
|
||||
],
|
||||
"optional": [
|
||||
{
|
||||
"permission": "network.stream",
|
||||
"resources": [
|
||||
"tcp://10.77.0.20:3306"
|
||||
],
|
||||
"reason": "Connect only to the prepared disposable fixture."
|
||||
}
|
||||
]
|
||||
},
|
||||
"package": "database-owned.ncpkg",
|
||||
"repeatIdentical": true,
|
||||
"preparedOnlyInThisExecutor": true,
|
||||
"previousFrozen677Sha256": "f01c6a24382897d4b0bf4130941274abacb9a9a963f9b56ecaceb2c020f36874",
|
||||
"changedEntriesFromFrozen677": [],
|
||||
"previous7856Sha256": "f01c6a24382897d4b0bf4130941274abacb9a9a963f9b56ecaceb2c020f36874",
|
||||
"changedEntriesFrom7856": [],
|
||||
"rebuiltAgainstPolicy": "89beeb93749c751f255beb6d0e0f95d23bdd9a7b",
|
||||
"rebuiltAgainstAI": "d33c0193ccaedc1a10aed9da495f040250121bef",
|
||||
"previous020Sha256": "f01c6a24382897d4b0bf4130941274abacb9a9a963f9b56ecaceb2c020f36874",
|
||||
"changedEntriesFrom020": []
|
||||
},
|
||||
{
|
||||
"name": "database-rfam-readonly",
|
||||
"id": "com.netcatty.database-rfam-readonly",
|
||||
"version": "0.1.0",
|
||||
"fileCount": 5,
|
||||
"bytes": 11003,
|
||||
"sha256": "aaca586669db454e91d61487e576ed524d1b7190eed182bc2cd0035e59caa0d3",
|
||||
"contentSha256": "f542ca7b8e821d684cf7f71f0930458bd3ad97bc94bf654c88b0f353111e9162",
|
||||
"features": {
|
||||
"required": [
|
||||
"network.byte-streams"
|
||||
]
|
||||
},
|
||||
"permissions": {
|
||||
"required": [
|
||||
"commands",
|
||||
"views"
|
||||
],
|
||||
"optional": [
|
||||
{
|
||||
"permission": "network.stream",
|
||||
"resources": [
|
||||
"tcp://mysql-rfam-public.ebi.ac.uk:4497"
|
||||
],
|
||||
"reason": "Run the fixed public Rfam acceptance query only after explicit authorization."
|
||||
}
|
||||
]
|
||||
},
|
||||
"package": "database-rfam-readonly.ncpkg",
|
||||
"repeatIdentical": true,
|
||||
"preparedOnlyInThisExecutor": true,
|
||||
"previousFrozen677Sha256": "aaca586669db454e91d61487e576ed524d1b7190eed182bc2cd0035e59caa0d3",
|
||||
"changedEntriesFromFrozen677": [],
|
||||
"previous7856Sha256": "aaca586669db454e91d61487e576ed524d1b7190eed182bc2cd0035e59caa0d3",
|
||||
"changedEntriesFrom7856": [],
|
||||
"rebuiltAgainstPolicy": "89beeb93749c751f255beb6d0e0f95d23bdd9a7b",
|
||||
"rebuiltAgainstAI": "d33c0193ccaedc1a10aed9da495f040250121bef",
|
||||
"previous020Sha256": "aaca586669db454e91d61487e576ed524d1b7190eed182bc2cd0035e59caa0d3",
|
||||
"changedEntriesFrom020": []
|
||||
},
|
||||
{
|
||||
"name": "host-actions",
|
||||
"id": "com.netcatty.host-actions",
|
||||
"version": "0.1.0",
|
||||
"fileCount": 3,
|
||||
"bytes": 4633,
|
||||
"sha256": "cf04f82dfe25ab5cda6f5f3dad0a8fb06c37bedf9924f556bee2b8402e542ef2",
|
||||
"contentSha256": "d6f8bf3ae9dc417ea838f673966f2e1075fc8f9ef1d939ca85b476b1bb007e6c",
|
||||
"features": {
|
||||
"required": [
|
||||
"vault.host-actions",
|
||||
"commands.interactive"
|
||||
]
|
||||
},
|
||||
"permissions": {
|
||||
"required": [
|
||||
"commands",
|
||||
"menus"
|
||||
],
|
||||
"optional": [
|
||||
"vault.metadata",
|
||||
"vault.write",
|
||||
"ui.interact"
|
||||
]
|
||||
},
|
||||
"package": "host-actions.ncpkg",
|
||||
"repeatIdentical": true,
|
||||
"preparedOnlyInThisExecutor": true,
|
||||
"previousFrozen677Sha256": "cf04f82dfe25ab5cda6f5f3dad0a8fb06c37bedf9924f556bee2b8402e542ef2",
|
||||
"changedEntriesFromFrozen677": [],
|
||||
"previous7856Sha256": "cf04f82dfe25ab5cda6f5f3dad0a8fb06c37bedf9924f556bee2b8402e542ef2",
|
||||
"changedEntriesFrom7856": [],
|
||||
"rebuiltAgainstPolicy": "89beeb93749c751f255beb6d0e0f95d23bdd9a7b",
|
||||
"rebuiltAgainstAI": "d33c0193ccaedc1a10aed9da495f040250121bef",
|
||||
"previous020Sha256": "cf04f82dfe25ab5cda6f5f3dad0a8fb06c37bedf9924f556bee2b8402e542ef2",
|
||||
"changedEntriesFrom020": []
|
||||
},
|
||||
{
|
||||
"name": "terminal-selection-draft",
|
||||
"id": "com.netcatty.terminal-selection-draft",
|
||||
"version": "0.1.0",
|
||||
"fileCount": 2,
|
||||
"bytes": 2954,
|
||||
"sha256": "4c9c2388b7f891156192a0b5ab7b23291beba3555e7ec8cf24fbe2dfe1242d5e",
|
||||
"contentSha256": "99797d8679f34b96580076f922cbec067e511adc0f4ccf396ed129206acb6c4c",
|
||||
"features": {
|
||||
"required": [
|
||||
"terminal.selection-sharing",
|
||||
"terminal.input-drafts",
|
||||
"commands.interactive"
|
||||
]
|
||||
},
|
||||
"permissions": {
|
||||
"required": [
|
||||
"commands",
|
||||
"menus"
|
||||
],
|
||||
"optional": [
|
||||
"terminal.selection.read",
|
||||
"terminal.input.propose"
|
||||
]
|
||||
},
|
||||
"package": "terminal-selection-draft.ncpkg",
|
||||
"repeatIdentical": true,
|
||||
"preparedOnlyInThisExecutor": true,
|
||||
"previousFrozen677Sha256": "4c9c2388b7f891156192a0b5ab7b23291beba3555e7ec8cf24fbe2dfe1242d5e",
|
||||
"changedEntriesFromFrozen677": [],
|
||||
"previous7856Sha256": "4c9c2388b7f891156192a0b5ab7b23291beba3555e7ec8cf24fbe2dfe1242d5e",
|
||||
"changedEntriesFrom7856": [],
|
||||
"rebuiltAgainstPolicy": "89beeb93749c751f255beb6d0e0f95d23bdd9a7b",
|
||||
"rebuiltAgainstAI": "d33c0193ccaedc1a10aed9da495f040250121bef",
|
||||
"previous020Sha256": "4c9c2388b7f891156192a0b5ab7b23291beba3555e7ec8cf24fbe2dfe1242d5e",
|
||||
"changedEntriesFrom020": []
|
||||
}
|
||||
]
|
||||
@@ -1,5 +1,10 @@
|
||||
# Test the selected Note assistant on an isolated desktop
|
||||
|
||||
For the permission-policy/AI acceptance candidate, first use
|
||||
[its integration record](policy-ai-integration.md) and exact package manifest.
|
||||
The older permission-dialog sequence below is historical baseline evidence;
|
||||
apply the current Allow/Ask/Deny semantics when testing the new candidate.
|
||||
|
||||
This recipe tests a sandbox plugin against a deterministic loopback provider.
|
||||
The provider does not run a model or contact a paid service. The profile contains
|
||||
only synthetic data. The parent owns native screenshots and video on the working
|
||||
@@ -29,9 +34,9 @@ browser runtime entry. It has no Node entry, networking, filesystem, secrets,
|
||||
storage, terminal, or command-execution permission. Its `commands` permission
|
||||
registers its own view callbacks. It cannot execute terminal commands.
|
||||
The six-file artifact at this checkpoint has SHA-256
|
||||
`c7d784d5c3bd2feb463afd58eae3a9594615395b168174cb8a3d0677584ae395`.
|
||||
This replaces the earlier `c0f73369...` artifact after the selected-Note expiry
|
||||
recovery repair.
|
||||
`7943d4c3800914fe0acd3d0f3a3d7f7c1910c212a5dc58a044871a6a82485274`.
|
||||
This replaces the earlier `c7d784d5...` artifact with the sample-only appearance
|
||||
update. The host provider cold-start correction remains a separate app change.
|
||||
|
||||
## Start the deterministic fixture
|
||||
|
||||
@@ -119,6 +124,11 @@ Synthetic retry plan: add a retry button and show a connection status label.
|
||||
in the dropdown, and click Send yourself. Deny the fresh content permission
|
||||
once and cancel text review once before allowing a successful fixture run.
|
||||
The draft must survive all steps, and no request may be silently retried.
|
||||
14. With the sample idle, switch the host between light and dark and change its
|
||||
accent. The page, controls, muted surfaces, text, focus ring, and Send button
|
||||
must follow the existing view environment tokens without model requests or
|
||||
new permission prompts. Capture both appearances on the same host window.
|
||||
This check does not change or bypass the separately owned permission policy.
|
||||
|
||||
The expiry regression in `scripts/plugin-assistant-panel.test.cjs` controls the
|
||||
panel and actual host service/selected-context authority clocks and advances past
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 69 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 82 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 58 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 67 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 76 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 95 KiB |
@@ -0,0 +1,161 @@
|
||||
# Global Agent plugin bridge (work in progress)
|
||||
|
||||
This stack starts from `integration/plugin-ask-routing-acceptance` at
|
||||
`2433cf0130d1c3274c56c39e5765349c2945de01`. The parent confirmed this dependency;
|
||||
its native Ask acceptance and integrated CI are still pending. This branch does
|
||||
not modify the frozen candidate, #3986, #3998, or #4002.
|
||||
|
||||
The user approved a global conversation independent of a terminal or workspace,
|
||||
with a plugin-provided chat UI and selectable existing Agent backends. Core owns
|
||||
Agent execution, history, target authority, native approvals, credentials and
|
||||
audit. Global is not unrestricted inventory access or a background service.
|
||||
|
||||
## Stage 1: dispatch authority
|
||||
|
||||
`electron/plugins/agent/delegatedScope.cjs` is a host-only authority. It does not
|
||||
register plugin APIs, start an Agent or implement an orchestration loop.
|
||||
|
||||
- `issue(context, hostSnapshot)` authenticates with the existing source binding.
|
||||
Host snapshots contain backend identity/revision, target kind/id/owner/revision,
|
||||
synchronous current-owner checks, and a supported capability subset. The public
|
||||
result contains only an opaque handle and expiry. Plugin parameters must never
|
||||
be used as `hostSnapshot`.
|
||||
- `begin(handle, context)` returns an internal turn ticket and AbortController.
|
||||
- `dispatch(ticket, context, resolvedAction, execute)` runs the common host policy
|
||||
adapter once, checks current target revisions after any approval wait, invokes
|
||||
the real dispatcher, and rejects late results. `resolvedAction` must come from
|
||||
canonical host capability/argument resolution, not an unchecked plugin object.
|
||||
- `assertTurn` guards outgoing events. `finish` and `stop` invalidate the exact
|
||||
ticket. Runtime/plugin release, lock/hide invalidation and shutdown abort work.
|
||||
- Handles expire in five minutes. There are at most eight per runtime owner and
|
||||
64 overall, with at most 32 explicit resources per scope. Expiry never silently
|
||||
refreshes a selection or revives an old turn.
|
||||
|
||||
The focused tests use the actual `PluginAISourceBinding` with synthetic host
|
||||
registries, two terminal owners representing different workspaces, controlled
|
||||
time, and deferred approval/native completion. They cover source/runtime/target
|
||||
substitution, focus changes, revision changes across approval, policy denial,
|
||||
Stop, release, lock, late completion and bounds. They do not yet prove actual MCP
|
||||
or AgentRuntime dispatch; those remain integration gates.
|
||||
|
||||
Initial execution failed before test discovery because the new worktree has no
|
||||
dependencies (`ajv/dist/2020` missing). Running with the existing local worktree's
|
||||
read-only CommonJS dependency path passes all 12 cases. No provider, CLI Agent,
|
||||
paid credentials or GUI was used.
|
||||
|
||||
The adjacent run then passed 49 tests and failed one installed-entrypoint test
|
||||
because ESM could not resolve `@netcatty/plugin-cli` from the new worktree. Standard
|
||||
`npm ci --ignore-scripts` and `build:plugin-runtime-deps` repaired the setup. These
|
||||
failures are setup evidence, not failed-before product regressions.
|
||||
|
||||
The core conversation repository stores the existing global `AISession` document
|
||||
shape in a host-only directory, namespaced by the hash of source/private-owner.
|
||||
Native session identity and compaction remain private. Commits serialize a cloned
|
||||
snapshot, revalidate provenance before atomic rename, and retain previous history
|
||||
when that check fails. Files use mode 0600 and no-follow reads. The repository
|
||||
does not register a plugin storage API or change the source database schema.
|
||||
|
||||
The canonical schema and SDK now declare the proposed session interface and
|
||||
permissions. Runtime methods and the supported-feature advertisement are not yet
|
||||
registered. This stage must not be described as an installable working Agent
|
||||
bridge. Focused scope/history/wire and permission-copy tests pass 20 cases;
|
||||
contract/SDK/CLI tests pass 91. Focused lint and generated drift checks pass.
|
||||
|
||||
## Independent stage-1 correction
|
||||
|
||||
The independent review of exact `056164157f34ea779ed6cf51199909082345eca4`
|
||||
confirmed two helper defects before any runtime acceptance claim: source failure
|
||||
during the initial owner check rejected late work without aborting the active
|
||||
native signal; Stop could acquire a replacement turn after awaiting identity.
|
||||
|
||||
Both reproduce against that exact helper using the real `PluginAISourceBinding`:
|
||||
the focused before run is 13 passed / 2 failed. The correction revokes the captured
|
||||
scope when its own host-authenticated identity fails, and captures the intended
|
||||
turn before Stop's first await. A foreign runtime cannot use a failed lookup to
|
||||
revoke another caller's scope. Focused after: 15 passed / 0 failed. The complete
|
||||
foundation/permission group passes 23 on Node 24.19.0 and Node 22.23.3; focused lint
|
||||
and diff checks pass. Logs: `/tmp/netcatty-global-agent-review-before.log`,
|
||||
`/tmp/netcatty-global-agent-review-after.log`,
|
||||
`/tmp/netcatty-global-agent-review-node22.log`.
|
||||
|
||||
The first CI run at 0561641, test `40459522087`, Linux job `122040565759`, failed
|
||||
after an explicit runner shutdown and operation cancellation, with no assertion
|
||||
failure in its log and no complete repository test total. Package workflow
|
||||
`40459521971` and both Windows jobs passed. The parent authorized one job rerun;
|
||||
it was requested once, producing Linux job `122048349748`, still running at this
|
||||
checkpoint. Original runner failure remains historical evidence. Neither that
|
||||
rerun nor the helper fix establishes runtime, native or independent-review
|
||||
acceptance; the correction still needs reviewer recheck.
|
||||
|
||||
### Follow-up: cancellation is not source revocation
|
||||
|
||||
The independent recheck confirmed the first two repairs and found a remaining
|
||||
Stop race: an aborted Stop(A) RPC could enter the generic owner-failure cleanup
|
||||
after B had started, revoking B's scope. The same failure was reproduced at both
|
||||
owner-validation await paths using the actual source binding. Exact-18377 source
|
||||
with the new 18-case scope suite yields 16 passed / 2 failed; evidence is retained
|
||||
in `/tmp/netcatty-global-agent-stop-rpc-exact183-before.log`.
|
||||
|
||||
The minimal follow-up distinguishes this request's abort/deadline from verified
|
||||
owner or resource failure, rethrows the access failure, and leaves the existing
|
||||
turn-capture rule intact. Cancellation of Stop(A) cannot revoke B. A separate
|
||||
regression confirms that a verified source mismatch still aborts native work
|
||||
even when the RPC is concurrently cancelled. Foreign caller protections remain.
|
||||
The full foundation/permission group passes 26 tests on Node 22.23.3 and Node
|
||||
24.19.0; focused lint and diff checks pass. Logs:
|
||||
`/tmp/netcatty-global-agent-stop-rpc-final.log` and
|
||||
`/tmp/netcatty-global-agent-stop-rpc-node22.log`. Independent recheck is pending.
|
||||
|
||||
The authorized original-head Linux rerun `122048349748` ended cancelled, with an
|
||||
operation-cancelled log and no assertion failure. No second rerun was requested.
|
||||
At 18377, test workflow `40462595842`, Linux job `122050816182`, also received an
|
||||
explicit runner shutdown; `PluginLogSettings.test.tsx` was SIGKILLed immediately
|
||||
afterward. That interrupted stage reports 34 passed / 1 failed, not a complete
|
||||
repository total or an assertion failure. Both Windows jobs passed. No blind
|
||||
rerun of that job was made. Package workflow `40462595846` was still running when
|
||||
checked. These records are not substituted for exact-head green CI.
|
||||
|
||||
## Planned public contract and adapters
|
||||
|
||||
The proposed feature is `ai.agent-sessions`, with `ai.agent.use` for conversation
|
||||
operations and `ai.agent.actions` for actual Agent tool actions. Permission mode
|
||||
comes from the existing source-bound Allow/Ask/Deny engine. These are additive
|
||||
names, separate from the existing tools-disabled `ai.invoke` broker.
|
||||
|
||||
The API will provide backend listing, explicit target selection, owned chat
|
||||
listing/opening, sending, bounded event reads, history, Stop and close. Backend
|
||||
references are opaque host-issued configuration revisions. Conversation references
|
||||
identify only histories owned by the calling source/private-owner; runtime handles
|
||||
are separate and cannot substitute builtin chat IDs or native SDK resume IDs.
|
||||
Messages cannot supply permissionMode, CLI/env/MCP configuration or owner fields.
|
||||
|
||||
Core will reuse `AISession`, `AgentRuntime`, both existing turn drivers, event and
|
||||
compaction handling, SDK resume identity validation, and `stopAgentTurn`. The first
|
||||
eligible backends are Catty and configured Claude Code in MCP mode. Other backend
|
||||
entries must return explicit unsupported/unconfigured status until their native
|
||||
tool and approval boundaries are verified. Codex SDK's current unrestricted native
|
||||
configuration must not be treated as eligible merely because it has a driver.
|
||||
|
||||
The terminal worker already exposes a suitable main-owned resource binding:
|
||||
`getPluginSessionBinding(sessionId, ownerWebContentsId)` returns worker epoch and
|
||||
session generation. Use this for dispatch-time targets; do not use menu IDs,
|
||||
focused tab, display labels, or a renderer-provided session ID as authority.
|
||||
|
||||
The MCP dispatcher and Catty direct execution route need the same delegated
|
||||
session guard. A delegated call must not update process-global permission mode,
|
||||
or request both an Agent approval and a plugin approval for the same action.
|
||||
Policy changes, disable/restart/source replacement, lock and host disconnection
|
||||
must cancel scoped native work and invalidate outgoing event tickets. Existing
|
||||
Agent trace and plugin audit/log facilities will receive redacted correlation
|
||||
metadata, not credentials, provider diagnostics or full unreviewed tool payloads.
|
||||
|
||||
## Remaining acceptance gates
|
||||
|
||||
- Canonical schema/SDK/runtime registration and the installed default host path.
|
||||
- Host-owned durable history and backend references, including backend switching.
|
||||
- Actual Catty and Claude MCP dispatch, policy and worker lifecycle regressions.
|
||||
- Synthetic streaming/cancellation fixtures; these are not real inference or
|
||||
actual authenticated Claude service acceptance.
|
||||
- Installable minimal chat UI with backend and explicit target choice.
|
||||
- Standard CI, independent review and parent-scheduled native evidence. No GUI
|
||||
ownership, merge or release is granted to this executor.
|
||||
@@ -0,0 +1,260 @@
|
||||
# Plugin Allow / Ask / Deny handoff
|
||||
|
||||
This change is stacked on frozen PR #3997 at
|
||||
`677aac33b1eec9dbd4a367417a49c964cdea63e7`. Its source checkout and native QA
|
||||
checkout were not changed. No merge, release, live database connection, native
|
||||
activation or real user configuration change was performed here.
|
||||
|
||||
## Behavior and storage
|
||||
|
||||
- Declared supported capabilities default to Allow, with no installation
|
||||
checklist or required-permission preflight dialogs.
|
||||
- Settings uses existing `SettingRow` and Select components. It shows each
|
||||
declaration's resource scope and effective Allow / Ask / Deny mode.
|
||||
- Policies use `plugin_host_preferences`, keyed by host source owner and
|
||||
capability. They survive restart and updates within the same source, including
|
||||
reintroduced capabilities. New capabilities default to Allow. Existing grants
|
||||
still use the original package/declaration principal; they were not widened.
|
||||
- Explicit source reset clears that source's policies and private plugin data.
|
||||
Another source receives independent defaults. Unrelated Vault, host, settings
|
||||
and user data are preserved. No plugin-policy migration was added.
|
||||
- Policy edits clear grants, cancel pending/in-flight work and invalidate
|
||||
handles. Settings edits require the exact host-issued source/version/policy
|
||||
revision, so stale controls cannot change a replacement source or override a
|
||||
newer Deny.
|
||||
- Ask decisions use the existing in-app Dialog/Button components, reached only
|
||||
through trusted managed-app-window preload IPC. A one-use response token binds the
|
||||
request; close, cancel, lock, blur/hide, reload, renderer crash and shutdown
|
||||
invalidate it. Plugin DOM clicks confer no host gesture authority.
|
||||
- Host selection, model/context selection and draft editing remain workflows.
|
||||
Ask is integrated into their existing host UI where applicable. Allow removes
|
||||
permission-only sharing and connection confirmations while retaining exact
|
||||
target validation and action tokens. Terminal drafts never send PTY input.
|
||||
- AI start combines model permission and exact selected-content scopes into one
|
||||
Ask decision. Its receipt cannot cross an RPC operation. Broker-bound stream
|
||||
reads/stop/close do not ask per chunk. Same-origin network redirects reuse the
|
||||
current operation decision; different origins still require declared bounds
|
||||
and their own authorization.
|
||||
- Policy edits, decisions, use/revoke and operation outcomes remain auditable.
|
||||
Audit records omit note/terminal bodies, model payloads, credentials and tokens.
|
||||
Optional local log persistence remains user-controlled.
|
||||
|
||||
See [the security boundary](security-and-permissions.md) for the storage and
|
||||
update rules. The separate AI cold-start provider-sync fix is not included;
|
||||
`configuredModel.ts`, `trustedHostRuntime.ts` and `usePluginAIContextHost.ts`
|
||||
were left untouched for parent integration.
|
||||
|
||||
## Review corrections after the initial 643e71b checkpoint
|
||||
|
||||
The initial commit and its screenshots remain in history. Registered regressions
|
||||
reproduce three concrete issues on that exact tree and pass with these repairs:
|
||||
|
||||
- Selected-summary Ask now delegates its decision to the existing host chooser.
|
||||
The first and only interaction is target selection; Allow uses that same
|
||||
necessary chooser, while Deny opens none and releases no summaries.
|
||||
- The registry creates a fresh host-only operation identity for every RPC.
|
||||
Internal model contexts retain it when replacing a cancellation signal, so a
|
||||
combined model/content Ask receipt survives that derivation. Receipts also
|
||||
bind plugin, runtime, source, principal and policy revision, and cannot cross
|
||||
operations. Current declaration, policy and selection checks still run.
|
||||
- Changing the privileged development-only `runtime.advanced` policy to Ask or
|
||||
Deny disables/stops the activation through the existing manager mutation queue
|
||||
before the policy IPC completes. A source-owner check inside that queue prevents
|
||||
an old source's teardown from disabling a replacement. Browser sandbox policy
|
||||
and development admission guards remain unchanged.
|
||||
|
||||
Ask now presents the current host-selected model label and Note title/scope.
|
||||
Exact versions remain bound internally. Labels come from the checked broker chat
|
||||
and owned selected summaries;
|
||||
plugins cannot supply operation identities or display fields through RPC params.
|
||||
Opaque selected handles remain internal, and display metadata is not added to
|
||||
audit records. The chooser copy reflects Allow / Ask / Deny without claiming a
|
||||
mandatory second text review. Spanish keys preserve the existing locale parity
|
||||
contract. The quota and expiry regression now uses independent requests to avoid
|
||||
its former 20ms timer race.
|
||||
|
||||
Updated component previews use a realistic opaque selected handle internally:
|
||||
|
||||
| Light Ask | Dark Ask |
|
||||
| --- | --- |
|
||||
|  |  |
|
||||
|
||||
These remain synthetic Chromium component previews, not native QA or a recording.
|
||||
No Library item was created. The original four screenshots below are retained as
|
||||
initial-checkpoint evidence.
|
||||
|
||||
## Latest checks and remaining environment blockers
|
||||
|
||||
- A registered regression reproduces `Plugin permission decision provider failed`
|
||||
on exact `79a9bd6` when a Settings-owned view activates a real `settings.get`
|
||||
API configured Ask while main is unfocused. The follow-up uses the existing
|
||||
managed-window registry (including Settings), per-renderer readiness and the
|
||||
same in-app component. It does not focus main or expand plugin-frame trust.
|
||||
The actual Settings factory, installed package/SDK activation, view command
|
||||
IPC and permission IPC now pass main/Settings/foreign/background, subframe,
|
||||
independent readiness and hide/close/stale-token checks. Native Electron
|
||||
objects are represented in this regression; this is not native GUI acceptance.
|
||||
Focused boundary group: **67 passed**; renderer/native-view visibility group:
|
||||
**three passed**; runtime aggregate: **695 passed/one native skip**. The initial
|
||||
renderer assertion incorrectly expected a view to reappear while the separate
|
||||
permission-settings security surface remained visible; the corrected test
|
||||
checks both overlapping surfaces and restoration after both close.
|
||||
Production build passed (69 seconds). Full lint passes with the same seven
|
||||
warnings after moving a leftover screenshot harness out of the source tree;
|
||||
its initial lint failure is retained. TypeScript remains baseline-equivalent
|
||||
at 774 diagnostics, with zero added/removed after path normalization.
|
||||
- Subsequent exact `da4e809` workflows **40444934631** (tests) and
|
||||
**40444934738** (packages) passed. The full suite reports **15,392 tests:
|
||||
15,342 passed, 50 skipped, zero failures/cancellations**. The runtime group
|
||||
reports 694 passed/one native skip, and package settings 35 passed. All four
|
||||
platform package jobs passed; release jobs were skipped. This does not replace
|
||||
manual plugin native acceptance or erase the local failures below.
|
||||
- A focused wording follow-up removes the implication of mandatory prompts from
|
||||
the package detail's sensitive/unbounded scope text in English, Spanish,
|
||||
Russian and both Chinese locales. The Russian declaration note now also
|
||||
reflects default Allow instead of pre-activation permission ceremonies.
|
||||
The complete local CI boundary command passes after this copy change:
|
||||
contract generation is current, runtime 694 passed/one skip, examples five
|
||||
passed/one skip, and package settings 35 passed. Locale parity six passed;
|
||||
changed-file lint has no errors/warnings.
|
||||
- Exact `2eee561` packaging workflow **40443396402 passed** on Linux x64,
|
||||
Linux arm64, macOS and Windows; release jobs were skipped. Its first Linux
|
||||
test job **121988800130** stopped on a runner shutdown signal, with no observed
|
||||
assertion failure. Parent requested one retry. Fresh job **121992430185**
|
||||
passed the 694-test runtime aggregate (one native skip) and failed two
|
||||
package-detail assertions still expecting the superseded "not a grant" copy.
|
||||
The full suite was skipped. Those assertions now verify default Allow,
|
||||
adjustable Ask/Deny and retained host/resource validation; development-runtime
|
||||
disclosure and HTML escaping remain checked. No production behavior changes
|
||||
accompany this test correction. Original interruption/failure logs are kept.
|
||||
- Registered runtime recheck: **695 tests, 694 passed, zero failed/cancelled, one
|
||||
native skip**. Separate final registered receipt/authority group: **21 passed**.
|
||||
- Serial mounted renderer/Host/selected-summary/panel/locale group: **35 passed**.
|
||||
A preceding concurrent run timed out during build/lint load; its failure log is
|
||||
retained. No timeout result is reclassified as a pass.
|
||||
- Advanced-runtime manager/source lifecycle and AI group: **35 passed**.
|
||||
Receipt/declaration group: **32 passed**. Final engine/registry/manager/host
|
||||
boundary group: **71 passed**. Final registered AI audit/receipt check: **one
|
||||
passed**; an earlier 15-second timeout while the preview was active is retained.
|
||||
The serial rerun passed in five seconds. Contract/SDK/CLI: **91 passed**.
|
||||
- Final production build passed in **45 seconds**. Full lint has zero errors/seven
|
||||
existing warnings. Full TypeScript remains **774 diagnostics**, identical to
|
||||
frozen 677 after path normalization, with zero added/removed diagnostics.
|
||||
- The normal official Electron installer succeeded using its documented proxy
|
||||
support and a task-local cache. Exact **42.11.12** executes in Node mode; no
|
||||
shared libraries are missing. This is not native GUI acceptance.
|
||||
- Exact-header setup via the installed node-gyp 12.4.0 and the unchanged rebuild
|
||||
tool's official URL failed. The proxy returns **403 Forbidden** for
|
||||
`https://www.electronjs.org/headers/v42.11.12/node-v42.11.12-headers.tar.gz`.
|
||||
That download stopped; no mirror, alternate host, checksum bypass, dependency
|
||||
shim, native-rebuild skip, HOME change or sandbox change was used. Normal
|
||||
postinstall/native rebuild and desktop packaging remain incomplete.
|
||||
- A private task-local `TMPDIR` uses Netcatty's existing safe-temp resolution to
|
||||
its normal `Netcatty` subdirectory; HOME remains unchanged. This fixes the
|
||||
earlier fallback-to-unwritable-home prerequisite without changing app code.
|
||||
- A **diagnostic full-suite run on the evolving worktree**, after those environment
|
||||
repairs, reported **15,059 tests: 14,922 passed, 92 failed, two cancelled, 43
|
||||
skipped**. A stuck FIDO child was terminated after its timeout; this is not a
|
||||
successful completed exact-head aggregate. Frozen-baseline focused runs
|
||||
reproduce its FIDO timeout and **28 failures** across native imports, Cursor
|
||||
fixtures and repository postinstall patch checks, plus the earlier six
|
||||
Markdown comparison assertions. Other failures remain unclassified. The
|
||||
permission locale parity and obsolete forced-review expectations found by that
|
||||
run were corrected and pass focused checks. Original failed/incomplete runs
|
||||
below are preserved.
|
||||
|
||||
The independent AI cold-start production files remain untouched. This branch
|
||||
updates only policy expectations in `components/PluginAIContextHost.test.tsx`;
|
||||
parent integration must retain the AI owner's actual-main-allowlist and provider
|
||||
sync assertions when merging that test file.
|
||||
|
||||
## Local evidence
|
||||
|
||||
These are local code/test results, not native acceptance:
|
||||
|
||||
| Check | Result |
|
||||
| --- | --- |
|
||||
| Registered runtime aggregate, concurrency 2 | 693 tests: 691 passed, one failed, one native skip; no cancellations. Sole failure imports the missing Electron binary on real worker reconnect. |
|
||||
| Engine, policy matrix, trusted prompt and policy IPC group | 79 passed; subsequent shutdown guard test: 9 passed. |
|
||||
| Mounted in-app permission controls and Host/terminal workflows | 22 passed. Includes Host Allow/Ask/Deny, policy change during save preparation, exact targets and stale replies. |
|
||||
| Registered AI/context + policy group | 7 passed, including normal Allow, combined Ask, Deny and revocation. |
|
||||
| Source/version/revision edit guards and registered byte-stream group | 47 passed. |
|
||||
| Contract/SDK/CLI suite | 91 passed. Four prepared sample archives validate. |
|
||||
| Lint | Zero errors, seven existing warnings. |
|
||||
| Production build | Passed; final `pack:dir` build also passed in 76 seconds. |
|
||||
| Contract/capability/Codex generation | Regenerated/checks clean; no capability-tool diff. |
|
||||
| Full TypeScript check | Frozen baseline and implementation each have 774 diagnostics; zero added/removed after normalizing worktree paths. |
|
||||
| Full `npm test` | Incomplete: stopped after three unchanged bridge child processes remained pending. Reported 9,891 tests, 9,359 passed, 40 failed, 478 cancelled, 14 skipped. This is not a passing aggregate. |
|
||||
| Desktop `pack:dir` | Unpassed: native rebuild attempts `/home/agent/.electron-gyp`, outside this workspace's writable roots. Earlier pinned Electron installation failed with `fetch failed`. No shim, alternate runtime or sandbox change was used. |
|
||||
|
||||
Frozen-baseline focused runs reproduce the two large-history SFTP recovery
|
||||
failures, six Markdown comparison failures, and missing-Electron adapter/serial
|
||||
failures observed in the broad run. Other broad-run failures remain unclassified;
|
||||
unchanged code alone does not prove a baseline failure. One earlier runtime
|
||||
aggregate hit the existing 15-second byte-stream fixture timeout during concurrent
|
||||
builds; its focused run and the final aggregate above pass that case.
|
||||
|
||||
Logs and archives are in `/workspace/permission-evidence/` in this execution
|
||||
environment. The final task report identifies any remaining blockers and checks.
|
||||
Exact-head CI and native acceptance remain pending parent coordination.
|
||||
|
||||
## Component screenshots
|
||||
|
||||
Chromium rendered the actual React components with built-in light/dark tokens
|
||||
and synthetic host bridge data. These images demonstrate layout and state only;
|
||||
they do not certify Electron behavior.
|
||||
|
||||
| Controls | Ask |
|
||||
| --- | --- |
|
||||
|  |  |
|
||||
|  |  |
|
||||
|
||||
## Prepared sample packages
|
||||
|
||||
All packages were built locally; none was activated in a native app. No owned or
|
||||
public database service was contacted. The owned database package is scoped to
|
||||
the previously specified disposable fixture endpoint `tcp://10.77.0.20:3306`.
|
||||
|
||||
| Archive | SHA-256 |
|
||||
| --- | --- |
|
||||
| assistant.ncpkg | `c7d784d5c3bd2feb463afd58eae3a9594615395b168174cb8a3d0677584ae395` |
|
||||
| host-actions.ncpkg | `cf04f82dfe25ab5cda6f5f3dad0a8fb06c37bedf9924f556bee2b8402e542ef2` |
|
||||
| terminal-selection-draft.ncpkg | `4c9c2388b7f891156192a0b5ab7b23291beba3555e7ec8cf24fbe2dfe1242d5e` |
|
||||
| database-owned.ncpkg | `f01c6a24382897d4b0bf4130941274abacb9a9a963f9b56ecaceb2c020f36874` |
|
||||
|
||||
Reproduction uses the unchanged sample build tools:
|
||||
|
||||
```sh
|
||||
node examples/plugins/assistant/build.mjs
|
||||
node packages/plugin-cli/dist/cli.js pack examples/plugins/assistant/.package --out /tmp/assistant.ncpkg
|
||||
node packages/plugin-cli/dist/cli.js pack examples/plugins/host-actions --out /tmp/host-actions.ncpkg
|
||||
node examples/plugins/terminal-selection-draft/prepare.mjs /tmp/terminal-selection-draft.ncpkg
|
||||
node examples/plugins/database-minimal/prepare.mjs tcp://10.77.0.20:3306 /tmp/database-owned.ncpkg
|
||||
```
|
||||
|
||||
## Parent-serialized native recheck
|
||||
|
||||
1. Use a fresh isolated app profile and the exact next integrated head. Import and
|
||||
enable a sample: no installation permission ceremony should appear.
|
||||
2. In Allow, select hosts in the host chooser, create/edit via the normal draft
|
||||
editor, and connect using a current selected reference without a separate
|
||||
plugin confirmation. Ordinary SSH authentication and host-key checks remain.
|
||||
3. Select a Note/context and model, start and read a turn in Allow. Then switch
|
||||
AI/content to Ask: one scoped decision covers the turn and exact references,
|
||||
with no per-chunk or second content-sharing dialog.
|
||||
4. Check terminal Allow sharing and the editable inert draft; Ask shows one share
|
||||
preview; Deny releases no selection and cannot reopen old handles.
|
||||
5. Check network Allow/Ask/Deny at the declared endpoint. Preserve all endpoint,
|
||||
DNS, redirect, private-network and control-plane rejection checks.
|
||||
6. Flip policy while a chooser, draft, model read or network operation awaits.
|
||||
Old results/tokens must fail. Switch back to Allow and start a fresh operation.
|
||||
7. Restart/update the same source with an explicit Deny; it must remain Deny.
|
||||
Check distinct plugins and sources, stale settings edits and explicit source
|
||||
reset. Review bounded audit events and optional local logging for redaction.
|
||||
8. Open a plugin view from standalone Settings and exercise an Ask-gated API
|
||||
during activation and from its own view command. Ask must be reachable in
|
||||
Settings without focusing main or losing the view. Close/hide/reload that
|
||||
window, switch to an unrelated window, and try a stale response: old authority
|
||||
must be cancelled, with no background-window fallback or plugin-frame reply.
|
||||
|
||||
No merge, release or native acceptance is authorized by this handoff.
|
||||
@@ -0,0 +1,189 @@
|
||||
# Permission-policy and AI acceptance candidate
|
||||
|
||||
This records frozen 020 and its predecessor evidence. For the later managed-
|
||||
window Ask correction, use [the isolated Ask integration record](ask-routing-integration.md).
|
||||
020 native Default Allow/Stop/Deny/restore/themes passed; Ask had no visible
|
||||
dialog and timed out with zero new model requests. Ask acceptance/cancel and
|
||||
the content-read matrix remain unrun until that new candidate is checked.
|
||||
|
||||
Use the dedicated `integration/plugin-policy-ai-acceptance` branch. Its Draft PR
|
||||
records the exact published head and aggregate CI. Frozen #3997 at
|
||||
`677aac33b1eec9dbd4a367417a49c964cdea63e7` remains the historical native baseline;
|
||||
neither that branch nor main is changed by this integration.
|
||||
|
||||
This is a candidate for parent-coordinated acceptance. It is not a claim of
|
||||
completed native acceptance, deployment readiness or permission activation.
|
||||
|
||||
## Preserved inputs
|
||||
|
||||
| Input | Exact checkpoint | Integration |
|
||||
| --- | --- | --- |
|
||||
| Frozen integration | `677aac33b1eec9dbd4a367417a49c964cdea63e7` | Starting point; includes stream/source/Host/settings and prior terminal history |
|
||||
| Permission policy #4000 | `79a9bd6df1c17fd8a5238aba31d7a8b4cda184b9` | Initial ordinary merge `9aa899059` preserved 2eee561; follow-up ordinary merge `cfa13680e` preserves the authoritative scope/sensitivity copy and tests |
|
||||
| AI #3986 | `d33c0193ccaedc1a10aed9da495f040250121bef` | Ordinary merge `5d2ff1cb8`; cold-start wiring, themed sample and Node 22 warning-drain test correction |
|
||||
| Terminal production | #3996 `8e7c28fe1bdbcdd76816d33ec926ba8e60eefe64` | Preserve the prior renderer eligibility/publication path; policy-owned interaction changes remain from #4000 |
|
||||
|
||||
Diagnostic #3999 and its tests/docs-only `3fdaa357` continuation are not merged.
|
||||
No terminal runtime fix is required by the fixture-classification finding.
|
||||
Database protocol and fixed SQL remain solely in the minimal examples.
|
||||
|
||||
## Direct integration decisions
|
||||
|
||||
The initial merge conflict was `components/PluginAIContextHost.test.tsx`. The
|
||||
combined test keeps policy's absence of a second native content-sharing review
|
||||
and AI's actual main allowlist, two-provider synchronization, streamed result,
|
||||
Stop/lock cleanup and empty SDK-error assertion. The Node 22 warning drain is
|
||||
retained. The obsolete forced-review cancellation branch is superseded by the
|
||||
current in-app Ask/cancellation tests; no transport assertion is dropped.
|
||||
|
||||
No production file needed a manual conflict resolution. Policy production files
|
||||
remain identical to the policy input; the three AI startup wiring files and
|
||||
sample assets remain identical to d33c0193. The stream service/router and prior
|
||||
terminal renderer eligibility/publication files are unchanged from frozen 677.
|
||||
|
||||
Integration commit `7d8a354404d68071d0924532ffe74c713ace6cb9` updates two stale
|
||||
disclosure assertions in `PluginPackageManager.test.tsx` and
|
||||
`PluginMarketplace.interaction.test.tsx`. They expected the old words
|
||||
`not a grant`; the new assertions require default Allow, Ask/Deny controls and
|
||||
retained host validation/resource scope. Existing escaping, advanced-authority,
|
||||
source and README-failure assertions remain intact. Production copy is unchanged.
|
||||
|
||||
The later authoritative 79a9bd6 policy merge overlapped those two tests. Their
|
||||
final contents are byte-identical to 79a9bd6: all three earlier policy assertions
|
||||
remain, together with the owner's additional unbounded-resource, sensitivity,
|
||||
escaping/source-order and advanced-authority checks. No assertion was dropped.
|
||||
This follow-up imports the owner's five-locale wording changes; it makes no
|
||||
manual production conflict edit and leaves the shared AI test intact.
|
||||
|
||||
## Checks and retained failures
|
||||
|
||||
| Local check | Result |
|
||||
| --- | --- |
|
||||
| Registered runtime | 695 tests: 694 passed, one native skip, zero failed/cancelled |
|
||||
| Contract/SDK/CLI | 91 passed |
|
||||
| Offline examples | Five passed, one explicit Docker opt-in skip |
|
||||
| Serial mounted AI/permission/selected-summary/Host/provider/panel group at 7856 | 31 passed |
|
||||
| Final settings, terminal interaction, cold-start host and locale group | 90 passed |
|
||||
| Contract/capability/Codex generation and drift | Passed |
|
||||
| Full lint | Zero errors, seven existing warnings |
|
||||
| Production build | Passed; Vite 35.50 seconds |
|
||||
| Five sample archives | Validated; repeat builds byte-identical |
|
||||
|
||||
The initial settings group records 34 passes and two failures on the stale copy
|
||||
assertions. That run is retained separately from the corrected 36/36 result.
|
||||
|
||||
Policy input [test CI 40443396430](https://github.com/binaricat/Netcatty/actions/runs/40443396430)
|
||||
first received an explicit runner shutdown. The parent requested one retry;
|
||||
this integration task did not duplicate it. Retry Linux job `121992430185`
|
||||
passed registered runtime (694/one skip) and examples (five/one skip), then
|
||||
failed those same two `not a grant` assertions: settings group 33 passed/two
|
||||
failed. It did not reach the full suite. The candidate includes the isolated
|
||||
test correction above; do not relabel the input CI as passing. Both input Windows
|
||||
jobs and [policy package CI](https://github.com/binaricat/Netcatty/actions/runs/40443396402)
|
||||
passed. Parent reports the independent policy correction reviews scoped clean,
|
||||
including both P2s and the three UI findings, with 71 focused/synthetic checks.
|
||||
|
||||
The policy owner subsequently corrected these tests in predecessor
|
||||
`da4e80946d2f3fb48f205aae2a732252b2f51fca`. Its
|
||||
[test CI](https://github.com/binaricat/Netcatty/actions/runs/40444934631) passed
|
||||
15,342 cases with 50 skipped and no failures/cancellations, and its
|
||||
[four-platform package CI](https://github.com/binaricat/Netcatty/actions/runs/40444934738)
|
||||
passed. The final 79a9bd6 wording head's
|
||||
[test CI](https://github.com/binaricat/Netcatty/actions/runs/40446529092) passed;
|
||||
its [package workflow](https://github.com/binaricat/Netcatty/actions/runs/40446529100)
|
||||
is tracked separately in the Draft PR. No input retry was duplicated here.
|
||||
|
||||
Integration predecessor `7856c664c3c2255a21badef2758bb29311b27a01` passed its
|
||||
[test CI](https://github.com/binaricat/Netcatty/actions/runs/40445809589): 15,344
|
||||
passed, 50 skipped, zero failed/cancelled, plus both Windows jobs and native
|
||||
automated checks. Its [four-platform packages](https://github.com/binaricat/Netcatty/actions/runs/40445809590)
|
||||
also passed. These are retained predecessor results; the new exact head needs
|
||||
its own aggregate CI.
|
||||
|
||||
AI input [test CI](https://github.com/binaricat/Netcatty/actions/runs/40439111241)
|
||||
and [package CI](https://github.com/binaricat/Netcatty/actions/runs/40439111215)
|
||||
passed. Parent reports its three isolated changes scoped clean. Its prior 9ca
|
||||
Node 22 warning-observer failure remains documented in `ai-provider-startup.md`;
|
||||
the warning drain does not ignore errors or remove the empty-error assertion.
|
||||
Frozen 677's earlier unexplained MCP assertion remains separate history.
|
||||
|
||||
This executor used locked dependencies with install scripts disabled, then the
|
||||
standard repository dependency patches. The official pinned Electron binary
|
||||
installed through its documented proxy support and executes in Node mode.
|
||||
No native-header download, alternate mirror, native shim, HOME change or
|
||||
sandbox change was used. Normal native postinstall/rebuild, local full-suite
|
||||
success and local desktop packaging are not claimed. Exact candidate CI is the
|
||||
aggregate full-suite and platform-packaging verification surface.
|
||||
|
||||
## Exact packages and provenance
|
||||
|
||||
The machine-readable [package record](policy-ai-packages.json) contains complete
|
||||
IDs, versions, required features, permission/resource declarations, archive and
|
||||
content hashes, and frozen-baseline comparisons. All five versions are 0.1.0.
|
||||
|
||||
| Sample | SHA-256 | Comparison with frozen 677 |
|
||||
| --- | --- | --- |
|
||||
| Assistant | `7943d4c3800914fe0acd3d0f3a3d7f7c1910c212a5dc58a044871a6a82485274` | Matches the AI owner's new themed archive; only README.md, panel.css and panel.js differ from 677 |
|
||||
| Owned database | `f01c6a24382897d4b0bf4130941274abacb9a9a963f9b56ecaceb2c020f36874` | Byte-identical |
|
||||
| Rfam | `aaca586669db454e91d61487e576ed524d1b7190eed182bc2cd0035e59caa0d3` | Byte-identical |
|
||||
| Host actions | `cf04f82dfe25ab5cda6f5f3dad0a8fb06c37bedf9924f556bee2b8402e542ef2` | Byte-identical |
|
||||
| Terminal draft | `4c9c2388b7f891156192a0b5ab7b23291beba3555e7ec8cf24fbe2dfe1242d5e` | Byte-identical |
|
||||
|
||||
After the authoritative 79 follow-up, all five archives were rebuilt twice,
|
||||
validated and compared byte for byte with the 7856 candidate: all five remain
|
||||
identical. No sample permission/resource declaration changed. The host's new policy changes
|
||||
effective authorization behavior; identical package bytes do not certify that
|
||||
new host behavior. The old Assistant c7d784d5 archive remains historical evidence,
|
||||
not the themed archive. No handoff archive was activated by this coding executor.
|
||||
The owner-identical Assistant README retains earlier permission-review wording;
|
||||
use this candidate's Allow/Ask/Deny procedure below for acceptance. That packaged
|
||||
copy is a documentation limitation, not a requirement for repeated dialogs.
|
||||
|
||||
## Native preparation and remaining gates
|
||||
|
||||
Use the exact candidate head from its Draft PR in a separate disposable native
|
||||
checkout/profile. Keep normal platform sandbox/helper settings. Build and hash:
|
||||
|
||||
```sh
|
||||
npm ci
|
||||
npm run build
|
||||
node examples/plugins/assistant/build.mjs
|
||||
node packages/plugin-cli/dist/cli.js pack examples/plugins/assistant/.package --out /tmp/assistant.ncpkg
|
||||
node examples/plugins/database-minimal/prepare.mjs tcp://10.77.0.20:3306 /tmp/database-owned.ncpkg
|
||||
node examples/plugins/database-rfam-readonly/prepare.mjs tcp://mysql-rfam-public.ebi.ac.uk:4497 /tmp/database-rfam-readonly.ncpkg
|
||||
node packages/plugin-cli/dist/cli.js pack examples/plugins/host-actions --out /tmp/host-actions.ncpkg
|
||||
node examples/plugins/terminal-selection-draft/prepare.mjs /tmp/terminal-selection-draft.ncpkg
|
||||
```
|
||||
|
||||
Validate each archive with the plugin CLI and compare against the machine record.
|
||||
The owned DB address is packaging-only. A different approved fixture endpoint
|
||||
requires a new archive and hash. Preparation does not dial either endpoint.
|
||||
|
||||
- Verify default Allow for declared supported capabilities without a mandatory
|
||||
first-install permission checklist or repeated permission-only dialogs. Verify
|
||||
per-plugin Ask/Deny, one scoped Ask receipt, readable host-selected labels,
|
||||
lifecycle invalidation and redacted audit. Preserve necessary target selection
|
||||
and inert draft editing, exact resources, source isolation and sandboxing.
|
||||
- Verify the assistant's cold startup through the normal host without opening
|
||||
the AI sidebar. Native inference on frozen 677 returned unavailable; the
|
||||
independent startup regression did not establish that native error's cause.
|
||||
Use only the deterministic synthetic provider and Note recipe, with current
|
||||
policy semantics. Native success is not claimed before that recheck.
|
||||
- Frozen 677 Host core passed per parent; old consent recordings describe the
|
||||
prior policy. They do not certify the new permission implementation.
|
||||
- Terminal's source-level finding is fixture-sensitive-input fail-closed:
|
||||
`fixture-A> ` is untrusted; `fixture@owned-A:~$ ` is confirmed shell. Parent
|
||||
reports corrected-fixture solo/split core passed on unmodified 677. Focus
|
||||
remained unreliable/unverified, and QA sent one Enter in A during a palette
|
||||
attempt. Do not claim global zero input or full terminal native acceptance.
|
||||
Keep the inert draft's no-PTY-dispatch property distinct from that QA action.
|
||||
Exclude diagnostic instrumentation. Prior-policy results do not certify the
|
||||
new policy; the next native priority is final-policy and cold-start AI QA.
|
||||
- Native Rfam's single baseline run failed with sanitized -32014 socket error
|
||||
before connect resolved. Auth/SELECT were not reached; transport causality and
|
||||
auth/TLS compatibility remain unverified. No probe/fallback or restriction
|
||||
change is authorized by this coding task. Owned DB fixture prerequisites and
|
||||
parent coordination remain separate.
|
||||
- Record exact head, hashes, ordered prompt/operation outcomes, screenshots and
|
||||
video for the new candidate. Hosting/distribution/deployment remain pending;
|
||||
no main merge, Ready transition or release is part of this handoff.
|
||||
@@ -0,0 +1,187 @@
|
||||
[
|
||||
{
|
||||
"name": "assistant",
|
||||
"id": "com.netcatty.assistant",
|
||||
"version": "0.1.0",
|
||||
"fileCount": 6,
|
||||
"bytes": 17214,
|
||||
"sha256": "7943d4c3800914fe0acd3d0f3a3d7f7c1910c212a5dc58a044871a6a82485274",
|
||||
"contentSha256": "c3e31e9613422bf9a05ccaa5b6f234304208f1a8298b1c1ee19262ba88af6cd0",
|
||||
"features": {
|
||||
"required": [
|
||||
"ai.model-invocation",
|
||||
"context.selected-read"
|
||||
]
|
||||
},
|
||||
"permissions": {
|
||||
"required": [
|
||||
"commands",
|
||||
"views"
|
||||
],
|
||||
"optional": [
|
||||
"ai.invoke",
|
||||
"context.selected.list",
|
||||
"context.selected.read"
|
||||
]
|
||||
},
|
||||
"package": "assistant.ncpkg",
|
||||
"repeatIdentical": true,
|
||||
"preparedOnlyInThisExecutor": true,
|
||||
"previousFrozen677Sha256": "c7d784d5c3bd2feb463afd58eae3a9594615395b168174cb8a3d0677584ae395",
|
||||
"changedEntriesFromFrozen677": [
|
||||
"README.md",
|
||||
"panel.css",
|
||||
"panel.js"
|
||||
],
|
||||
"previous7856Sha256": "7943d4c3800914fe0acd3d0f3a3d7f7c1910c212a5dc58a044871a6a82485274",
|
||||
"changedEntriesFrom7856": [],
|
||||
"rebuiltAgainstPolicy": "79a9bd6df1c17fd8a5238aba31d7a8b4cda184b9",
|
||||
"rebuiltAgainstAI": "d33c0193ccaedc1a10aed9da495f040250121bef"
|
||||
},
|
||||
{
|
||||
"name": "database-owned",
|
||||
"id": "com.netcatty.database-minimal",
|
||||
"version": "0.1.0",
|
||||
"fileCount": 5,
|
||||
"bytes": 11660,
|
||||
"sha256": "f01c6a24382897d4b0bf4130941274abacb9a9a963f9b56ecaceb2c020f36874",
|
||||
"contentSha256": "4d628c41c7a231e31cd56dacb822acaaa37f61e1e98b285b0086a4f783be199d",
|
||||
"features": {
|
||||
"required": [
|
||||
"network.byte-streams"
|
||||
]
|
||||
},
|
||||
"permissions": {
|
||||
"required": [
|
||||
"commands",
|
||||
"views"
|
||||
],
|
||||
"optional": [
|
||||
{
|
||||
"permission": "network.stream",
|
||||
"resources": [
|
||||
"tcp://10.77.0.20:3306"
|
||||
],
|
||||
"reason": "Connect only to the prepared disposable fixture."
|
||||
}
|
||||
]
|
||||
},
|
||||
"package": "database-owned.ncpkg",
|
||||
"repeatIdentical": true,
|
||||
"preparedOnlyInThisExecutor": true,
|
||||
"previousFrozen677Sha256": "f01c6a24382897d4b0bf4130941274abacb9a9a963f9b56ecaceb2c020f36874",
|
||||
"changedEntriesFromFrozen677": [],
|
||||
"previous7856Sha256": "f01c6a24382897d4b0bf4130941274abacb9a9a963f9b56ecaceb2c020f36874",
|
||||
"changedEntriesFrom7856": [],
|
||||
"rebuiltAgainstPolicy": "79a9bd6df1c17fd8a5238aba31d7a8b4cda184b9",
|
||||
"rebuiltAgainstAI": "d33c0193ccaedc1a10aed9da495f040250121bef"
|
||||
},
|
||||
{
|
||||
"name": "database-rfam-readonly",
|
||||
"id": "com.netcatty.database-rfam-readonly",
|
||||
"version": "0.1.0",
|
||||
"fileCount": 5,
|
||||
"bytes": 11003,
|
||||
"sha256": "aaca586669db454e91d61487e576ed524d1b7190eed182bc2cd0035e59caa0d3",
|
||||
"contentSha256": "f542ca7b8e821d684cf7f71f0930458bd3ad97bc94bf654c88b0f353111e9162",
|
||||
"features": {
|
||||
"required": [
|
||||
"network.byte-streams"
|
||||
]
|
||||
},
|
||||
"permissions": {
|
||||
"required": [
|
||||
"commands",
|
||||
"views"
|
||||
],
|
||||
"optional": [
|
||||
{
|
||||
"permission": "network.stream",
|
||||
"resources": [
|
||||
"tcp://mysql-rfam-public.ebi.ac.uk:4497"
|
||||
],
|
||||
"reason": "Run the fixed public Rfam acceptance query only after explicit authorization."
|
||||
}
|
||||
]
|
||||
},
|
||||
"package": "database-rfam-readonly.ncpkg",
|
||||
"repeatIdentical": true,
|
||||
"preparedOnlyInThisExecutor": true,
|
||||
"previousFrozen677Sha256": "aaca586669db454e91d61487e576ed524d1b7190eed182bc2cd0035e59caa0d3",
|
||||
"changedEntriesFromFrozen677": [],
|
||||
"previous7856Sha256": "aaca586669db454e91d61487e576ed524d1b7190eed182bc2cd0035e59caa0d3",
|
||||
"changedEntriesFrom7856": [],
|
||||
"rebuiltAgainstPolicy": "79a9bd6df1c17fd8a5238aba31d7a8b4cda184b9",
|
||||
"rebuiltAgainstAI": "d33c0193ccaedc1a10aed9da495f040250121bef"
|
||||
},
|
||||
{
|
||||
"name": "host-actions",
|
||||
"id": "com.netcatty.host-actions",
|
||||
"version": "0.1.0",
|
||||
"fileCount": 3,
|
||||
"bytes": 4633,
|
||||
"sha256": "cf04f82dfe25ab5cda6f5f3dad0a8fb06c37bedf9924f556bee2b8402e542ef2",
|
||||
"contentSha256": "d6f8bf3ae9dc417ea838f673966f2e1075fc8f9ef1d939ca85b476b1bb007e6c",
|
||||
"features": {
|
||||
"required": [
|
||||
"vault.host-actions",
|
||||
"commands.interactive"
|
||||
]
|
||||
},
|
||||
"permissions": {
|
||||
"required": [
|
||||
"commands",
|
||||
"menus"
|
||||
],
|
||||
"optional": [
|
||||
"vault.metadata",
|
||||
"vault.write",
|
||||
"ui.interact"
|
||||
]
|
||||
},
|
||||
"package": "host-actions.ncpkg",
|
||||
"repeatIdentical": true,
|
||||
"preparedOnlyInThisExecutor": true,
|
||||
"previousFrozen677Sha256": "cf04f82dfe25ab5cda6f5f3dad0a8fb06c37bedf9924f556bee2b8402e542ef2",
|
||||
"changedEntriesFromFrozen677": [],
|
||||
"previous7856Sha256": "cf04f82dfe25ab5cda6f5f3dad0a8fb06c37bedf9924f556bee2b8402e542ef2",
|
||||
"changedEntriesFrom7856": [],
|
||||
"rebuiltAgainstPolicy": "79a9bd6df1c17fd8a5238aba31d7a8b4cda184b9",
|
||||
"rebuiltAgainstAI": "d33c0193ccaedc1a10aed9da495f040250121bef"
|
||||
},
|
||||
{
|
||||
"name": "terminal-selection-draft",
|
||||
"id": "com.netcatty.terminal-selection-draft",
|
||||
"version": "0.1.0",
|
||||
"fileCount": 2,
|
||||
"bytes": 2954,
|
||||
"sha256": "4c9c2388b7f891156192a0b5ab7b23291beba3555e7ec8cf24fbe2dfe1242d5e",
|
||||
"contentSha256": "99797d8679f34b96580076f922cbec067e511adc0f4ccf396ed129206acb6c4c",
|
||||
"features": {
|
||||
"required": [
|
||||
"terminal.selection-sharing",
|
||||
"terminal.input-drafts",
|
||||
"commands.interactive"
|
||||
]
|
||||
},
|
||||
"permissions": {
|
||||
"required": [
|
||||
"commands",
|
||||
"menus"
|
||||
],
|
||||
"optional": [
|
||||
"terminal.selection.read",
|
||||
"terminal.input.propose"
|
||||
]
|
||||
},
|
||||
"package": "terminal-selection-draft.ncpkg",
|
||||
"repeatIdentical": true,
|
||||
"preparedOnlyInThisExecutor": true,
|
||||
"previousFrozen677Sha256": "4c9c2388b7f891156192a0b5ab7b23291beba3555e7ec8cf24fbe2dfe1242d5e",
|
||||
"changedEntriesFromFrozen677": [],
|
||||
"previous7856Sha256": "4c9c2388b7f891156192a0b5ab7b23291beba3555e7ec8cf24fbe2dfe1242d5e",
|
||||
"changedEntriesFrom7856": [],
|
||||
"rebuiltAgainstPolicy": "79a9bd6df1c17fd8a5238aba31d7a8b4cda184b9",
|
||||
"rebuiltAgainstAI": "d33c0193ccaedc1a10aed9da495f040250121bef"
|
||||
}
|
||||
]
|
||||
@@ -1,12 +1,64 @@
|
||||
# Plugin security and permission boundary
|
||||
|
||||
Status: phase 3 internal preview (`0.1.0-internal`)
|
||||
Status: unreleased plugin platform; browser plugins remain sandboxed. Advanced
|
||||
utility packages remain restricted to developer mode.
|
||||
|
||||
This phase is available only with `NETCATTY_PLUGIN_DEV=1`. It is deliberately
|
||||
usable by later contribution and Provider phases, but it is not a public plugin
|
||||
release. There is no renderer permission UI yet. The first-party development
|
||||
bootstrap injects a native Electron confirmation dialog; embedders that do not
|
||||
inject a decision provider still fail closed.
|
||||
Declared capabilities that the platform exposes default to **Allow**. Installing
|
||||
or enabling a plugin does not open a permission checklist. In Settings, each
|
||||
plugin has host-owned **Allow / Ask / Deny** controls with its declared resource
|
||||
scope. Allow skips permission dialogs; Ask requests one scoped decision; Deny
|
||||
rejects before side effects or content release. Target selection, model selection,
|
||||
and editing/saving drafts remain normal host workflows. SSH authentication and
|
||||
host-key checks are unchanged.
|
||||
|
||||
## Policy storage, updates and source changes
|
||||
|
||||
Policies live in `plugin_host_preferences`, separate from plugin-writable
|
||||
settings, under `permission:<host source owner>:<capability>`. Missing entries mean
|
||||
Allow. No historical plugin-policy migration or unrelated user-data reset runs.
|
||||
Policies survive restart, disable/re-enable, uninstall/reinstall from the same
|
||||
source, and package/version/publisher/declaration changes within that source.
|
||||
An explicit Deny therefore survives an update, including removal and later
|
||||
reintroduction of that capability. New declared capabilities default to Allow;
|
||||
existing Ask/Deny modes apply to the current declared scope. Undeclared
|
||||
permissions, unsupported methods and out-of-scope resources still fail closed.
|
||||
|
||||
Changing a source requires the existing explicit uninstall/private-data reset.
|
||||
That reset removes only the old source's plugin policies and private records.
|
||||
A different source receives independent defaults. Policies never come from a
|
||||
manifest, plugin setting, RPC payload or imported grant. Existing grant hashes
|
||||
still include the immutable package principal and declaration boundaries; this
|
||||
change does not turn an old package grant into a cross-version grant.
|
||||
|
||||
Every policy edit clears saved grants, cancels pending decisions and active RPCs,
|
||||
and invalidates host resource handles and model/stream state. Guards bind the
|
||||
source and policy revision before asynchronous authorization resolution, recheck
|
||||
before commits and content delivery, and remain invalid after a Deny-to-Allow
|
||||
flip. A late prompt reply cannot revive old work. The user must start a fresh
|
||||
operation after changing policy.
|
||||
|
||||
Ask uses Netcatty's existing in-app Dialog, typography and theme. Its recipient
|
||||
must be a ready, focused, visible, unlocked top-level app renderer in the managed
|
||||
window registry, including the standalone Settings window. Arbitrary windows,
|
||||
plugin view contents and subframes cannot register or respond. Each single-use
|
||||
token and cancellation lifecycle remains bound to the receiving window; another
|
||||
renderer's readiness changes cannot cancel or inherit that request. No main-window
|
||||
focus handoff is needed. Close, cancel, timeout, lock, hide/blur, reload, crash
|
||||
and stale runtime/source fail closed. The dialog uses the existing host security
|
||||
surface marker so attached native plugin views yield while it is visible and
|
||||
restore through their existing lifecycle on dismissal.
|
||||
A plugin DOM click never becomes a host gesture. Choosers, drafts, terminal and
|
||||
clipboard sharing use their existing host-owned action tokens. Ask integrates
|
||||
with that workflow instead of opening a second capability dialog. Allow skips
|
||||
sharing and connection permission confirmations while retaining validation and
|
||||
operation/resource binding. AI start combines model access and exact selected
|
||||
context handles into one Ask decision; its receipt is limited to that operation.
|
||||
Stream reads, stop and close validate broker handles without prompting per chunk. The receipt uses a host-created RPC operation identity retained by derived
|
||||
model contexts, with source/runtime/principal/revision binding. It is never a
|
||||
plugin-provided field. The selected-summary chooser owns its Ask interaction;
|
||||
there is no preceding generic capability prompt. Ask displays checked host
|
||||
model/selection labels rather than opaque selected handles.
|
||||
Selected-context reads remain selected-only and never enumerate the whole Vault.
|
||||
|
||||
## Authority model
|
||||
|
||||
@@ -40,9 +92,9 @@ Permission prompts use the canonical contract directly: absent operation and
|
||||
session IDs are omitted, long host-generated operation IDs become stable
|
||||
SHA-256 identifiers, reasons are bounded, and no request can carry more than
|
||||
128 canonical resources.
|
||||
Runtime trust/placement resolves before permission prompts. The special
|
||||
`runtime.advanced` permission is excluded from generic required-permission
|
||||
preflight and requested exactly once only when the host actually selects the
|
||||
Runtime trust/placement resolves before advanced-runtime authorization. Required
|
||||
preflight validates declarations without asking for capability grants. The
|
||||
`runtime.advanced` policy is checked only if the host selects the developer-only
|
||||
utility runtime.
|
||||
|
||||
## Grant lifetimes
|
||||
@@ -62,19 +114,25 @@ identical prompts coalesce. Prompt timeout, runtime abort,
|
||||
cancel, denial, absence of a decision provider, and stale activation all fail
|
||||
closed. Grant/use/deny/revoke events enter the bounded security audit.
|
||||
|
||||
`PermissionRequest` is part of the canonical Schema and carries plugin display
|
||||
identity, version, runtime placement, permission, canonical resources and their
|
||||
aligned resource kinds, reason, operation and optional host session. This is
|
||||
the complete PR-4 UI handoff; the renderer must return the same request ID and
|
||||
one canonical lifetime decision. The native fallback visibly escapes control,
|
||||
line-separator, and bidirectional-control characters in every plugin-controlled
|
||||
display field so untrusted text cannot forge labels or resource lines.
|
||||
`PermissionRequest` remains part of the canonical schema. It carries host-bound
|
||||
identity, capability, canonical resource scope and optional related capabilities
|
||||
for one combined operation. The trusted host UI renders text, never plugin HTML
|
||||
or body payloads. A response must match its request and action token. Ordinary
|
||||
Ask decisions apply once; long-lived stream attachment may use a host session.
|
||||
The older grant lifetimes remain supported by the engine, not exposed as a
|
||||
mandatory install flow.
|
||||
|
||||
Changing `runtime.advanced` to Ask or Deny disables and stops the privileged
|
||||
development activation through the existing manager queue. Policy IPC awaits
|
||||
that teardown. Queued lifecycle updates check the original source owner, so a
|
||||
replacement source cannot be stopped by a stale edit. Ordinary browser sandboxes
|
||||
are unchanged.
|
||||
|
||||
## Host-mediated capabilities
|
||||
|
||||
These brokers are the only authority path for ordinary browser plugins. An
|
||||
advanced utility entrypoint is intentionally different: `runtime.advanced`
|
||||
means explicit consent to ambient Node, filesystem and network APIs in its
|
||||
exposes ambient Node, filesystem and network APIs in its
|
||||
contained process. Fine-grained broker grants do not sandbox that ambient Node
|
||||
authority. Phase 9 must also require a verified publisher principal before the
|
||||
advanced path can be publicly enabled.
|
||||
@@ -83,8 +141,7 @@ Required resource-scoped permissions (`network`, filesystem read/write, and
|
||||
companion execution) must declare non-empty activation-time resource bounds;
|
||||
the all-resources `*` wildcard is not a valid bound.
|
||||
The string shorthand remains available only for optional declarations, whose
|
||||
concrete resource is approved on first use. A package update therefore cannot
|
||||
activate first and defer a newly required resource decision until later.
|
||||
concrete resource is checked against its policy and scope on use.
|
||||
|
||||
Native companions are an advanced-runtime capability. Their manifests require
|
||||
a Node utility entrypoint plus `runtime.advanced`, and `companion.start` rejects
|
||||
@@ -229,8 +286,8 @@ after durable user data can exist.
|
||||
|
||||
## Inspect and revoke actual grants
|
||||
|
||||
The installed package detail separates declared permissions from **Saved
|
||||
grants**. The host-only panel lists resource, scope, session (when applicable),
|
||||
The installed package detail shows declared capabilities, resources and effective
|
||||
Allow / Ask / Deny modes alongside any saved grants. The host-only panel lists resource, scope, session (when applicable),
|
||||
and grant time. Each row indicates whether its declaration hash and security
|
||||
principal match the host-owned current activation. Saved grants from an older
|
||||
package or a stopped/disabled activation remain visible as inactive. One-use decisions do not appear as retained grants. Refresh
|
||||
@@ -244,7 +301,7 @@ persists disabled state before revoking grants, invalidates contributions, and
|
||||
waits for runtime shutdown. Existing runtime teardown cancels RPC, streams,
|
||||
leases, providers, and companions. Every cleanup stage is attempted if grant deletion, audit persistence, or
|
||||
contribution invalidation fails. Multiple errors are aggregated and reported;
|
||||
the UI never claims successful revocation when persistence or cleanup failed. Explicit enable requests fresh access.
|
||||
the UI never claims successful revocation when persistence or cleanup failed. Explicit enable retains the saved policy; Deny remains in force.
|
||||
This management bridge is available only to the trusted app renderer. It is
|
||||
not a plugin RPC method and grants no plugin authority over another package.
|
||||
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
// Presentation only. Admission and grants remain owned by the canonical schema
|
||||
// and permission engine. Tests require exact coverage of its permission enum.
|
||||
const PERMISSION_DESCRIPTIONS = {
|
||||
"ai.agent.use": [true, "Agent conversations", "Use configured host Agents and read this plugin's own chat history. Model or Agent service usage may incur costs.", "Agent 会话", "使用宿主已配置的 Agent,并读取此插件自己的聊天历史;模型或 Agent 服务可能产生费用。"],
|
||||
"ai.agent.actions": [true, "Agent actions", "Authorize Agent tools against explicitly selected targets under this plugin's policy. Commands can change remote systems.", "Agent 操作", "根据插件策略授权 Agent 工具操作明确选中的目标;命令可能更改远程系统。"],
|
||||
storage: [false, "Plugin storage", "Save and read this plugin's local data.", "插件存储", "保存和读取此插件自己的本地数据。"],
|
||||
"runtime.advanced": [true, "Full device access", "Run Node code with filesystem and network access outside broker restrictions. Developer mode only.", "完整设备访问", "运行 Node 代码,可直接访问文件系统和网络,不受代理权限限制。仅限开发者模式。"],
|
||||
"settings.read": [false, "Read plugin settings", "Read this plugin's configured preferences.", "读取插件设置", "读取此插件自己的设置。"],
|
||||
@@ -11,12 +13,12 @@ const PERMISSION_DESCRIPTIONS = {
|
||||
menus: [false, "Menu entries", "Add this plugin's actions to host menus.", "菜单入口", "将此插件的操作加入应用菜单。"],
|
||||
"ui.interact": [false, "Host prompts", "Ask the host to show bounded input and choice dialogs.", "宿主交互", "请求宿主显示有界的输入和选择对话框。"],
|
||||
views: [false, "Plugin panels", "Display plugin-owned panels inside the app.", "插件面板", "在应用中显示插件自己的面板。"],
|
||||
"ai.invoke": [true, "Use configured AI model", "Send plugin prompts and separately approved selected text to the configured model. Provider charges may apply. Does not grant tools or host actions.", "使用已配置的 AI 模型", "向已配置的模型发送插件提示和另行批准的所选文本。模型提供商可能收费。此权限不授予工具或宿主操作权限。"],
|
||||
"ai.invoke": [true, "Use configured AI model", "Send plugin prompts and permitted selected text to the configured model. Provider charges may apply. Does not grant tools or host actions.", "使用已配置的 AI 模型", "向已配置的模型发送插件提示和权限允许的所选文本。模型提供商可能收费。此权限不授予工具或宿主操作权限。"],
|
||||
"context.selected.list": [true, "Selected context summaries", "Ask you to share summaries of selected workspace, tab, session, host, or Note references. Does not read their text.", "所选上下文摘要", "请求你分享所选工作区、标签页、会话、主机或笔记引用的摘要。此权限不会读取其文本。"],
|
||||
"context.selected.read": [true, "Read selected context", "Read text from references you explicitly select and approve. It may contain private data.", "读取所选上下文", "读取你明确选择并批准的引用中的文本,其中可能包含隐私信息。"],
|
||||
"context.selected.read": [true, "Read selected context", "Read text from host-selected references under this plugin's permission setting. It may contain private data.", "读取所选上下文", "根据插件权限设置读取宿主所选引用中的文本,其中可能包含隐私信息。"],
|
||||
"clipboard.read": [true, "Read clipboard", "Read copied text, which may contain passwords or other sensitive data.", "读取剪贴板", "读取复制的文本,其中可能包含密码或其他敏感数据。"],
|
||||
"clipboard.write": [true, "Write clipboard", "Replace clipboard content that you may paste elsewhere.", "写入剪贴板", "替换剪贴板内容,可能影响你随后粘贴的数据。"],
|
||||
"terminal.selection.read": [true, "Share terminal selection", "Read only text explicitly previewed and shared from the selected live terminal. It may contain secrets. Each action requires approval.", "分享终端选区", "仅读取用户从当前活跃终端明确预览并分享的文本,可能包含秘密;每次操作均需确认。"],
|
||||
"terminal.selection.read": [true, "Share terminal selection", "Read only the current selection from the host-selected live terminal. It may contain secrets. Ask mode shows one sharing preview.", "分享终端选区", "仅读取宿主所选活跃终端的当前选区,可能包含秘密;询问模式显示一次分享预览。"],
|
||||
"terminal.input.propose": [true, "Review terminal input draft", "Open an editable host draft for one selected terminal. Does not send input or Enter.", "审阅终端输入草稿", "打开所选终端的可编辑宿主草稿,不发送输入或回车。"],
|
||||
"terminal.metadata": [true, "Terminal metadata", "Read terminal/session details that may identify hosts and activity.", "终端元数据", "读取终端和会话信息,可能暴露主机及操作活动。"],
|
||||
"terminal.output": [true, "Read terminal output", "Receive terminal output through supported providers; it may contain credentials or private data.", "读取终端输出", "通过支持的提供器接收终端输出,其中可能包含凭据或隐私信息。"],
|
||||
|
||||
@@ -342,6 +342,12 @@ function getAppContentWindowList() {
|
||||
return Array.from(appContentWindows).filter((win) => isWindowUsable(win));
|
||||
}
|
||||
|
||||
// Settings has its own reusable window lifecycle, outside app-content editors.
|
||||
// Enumerate only managed app renderers; remote/pop-up/plugin contents are absent.
|
||||
function getManagedAppWindowList() {
|
||||
return [...new Set([...getAppContentWindowList(), getSettingsWindow()])].filter((win) => isWindowUsable(win));
|
||||
}
|
||||
|
||||
function getDirtyEditorWindowList() {
|
||||
pruneAppContentWindows();
|
||||
return Array.from(dirtyEditorWindows).filter((win) => isWindowUsable(win));
|
||||
@@ -1555,6 +1561,7 @@ module.exports = {
|
||||
getMainWindow,
|
||||
getMainWindows: getMainWindowList,
|
||||
getAppContentWindows: getAppContentWindowList,
|
||||
getManagedAppWindows: getManagedAppWindowList,
|
||||
getDirtyEditorWindows: getDirtyEditorWindowList,
|
||||
getMainWindowCount,
|
||||
isMainWindow,
|
||||
|
||||
@@ -153,9 +153,6 @@ function createBridgeRegistrar(context) {
|
||||
env: process.env,
|
||||
createService: () => {
|
||||
const { createPluginHostService } = require("../plugins/hostService.cjs");
|
||||
const {
|
||||
createNativePermissionDecisionProvider,
|
||||
} = require("../plugins/nativePermissionDecision.cjs");
|
||||
const {
|
||||
terminalInterceptorChoiceLabel,
|
||||
terminalInterceptorIdentifier,
|
||||
@@ -166,20 +163,10 @@ function createBridgeRegistrar(context) {
|
||||
electron: electronModule,
|
||||
safeStorage,
|
||||
getInteractionWindow: () => win,
|
||||
getPermissionWindows: () => getWindowManager().getManagedAppWindows(),
|
||||
getTerminalWorkerManager: () => terminalWorkerManager,
|
||||
isAppLocked: () => getAppLockController?.()?.getRuntimeState?.()?.locked !== false,
|
||||
subscribeAppLock: (listener) => getAppLockController?.()?.subscribe?.(listener),
|
||||
requestPermissionDecision: createNativePermissionDecisionProvider({
|
||||
getLocale: () => getWindowManager().getCurrentLanguage?.() ?? "en",
|
||||
dialog: electronModule.dialog,
|
||||
window: win,
|
||||
getWindow: () => {
|
||||
const settingsWindow = getWindowManager().getSettingsWindow?.();
|
||||
return settingsWindow && !settingsWindow.isDestroyed() && settingsWindow.isFocused()
|
||||
? settingsWindow
|
||||
: win;
|
||||
},
|
||||
}),
|
||||
getLocale: () => getWindowManager().getCurrentLanguage?.() ?? "en",
|
||||
requestTerminalInterceptorSelection: async ({ direction, providers }) => {
|
||||
const messages = terminalInterceptorMessages(getWindowManager().getCurrentLanguage?.() ?? "en");
|
||||
@@ -223,6 +210,7 @@ function createBridgeRegistrar(context) {
|
||||
manager: pluginHostService?.manager,
|
||||
interactionService: pluginHostService?.interactionService,
|
||||
permissionEngine: pluginHostService?.permissionEngine,
|
||||
permissionDecisionHost: pluginHostService?.permissionDecisionHost,
|
||||
runtimeSupervisor: pluginHostService?.runtimeSupervisor,
|
||||
database: pluginHostService?.database,
|
||||
catalogService: pluginHostService?.catalogService,
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
"use strict";
|
||||
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const { randomUUID } = require("node:crypto");
|
||||
const { createDefinitionValidator } = require("../contractValidator.cjs");
|
||||
|
||||
test("Agent wire accepts only opaque host configuration/scope refs, never raw backend authority", () => {
|
||||
const open = createDefinitionValidator("PluginAgentOpenParams");
|
||||
const params = { backendRef: randomUUID(), selectionHandle: randomUUID() };
|
||||
assert.equal(open(params), true);
|
||||
for (const [key, value] of Object.entries({ permissionMode: "auto", owner: "source:forged", privateOwner: "forged",
|
||||
source: "official", model: "chosen", agentId: "claude", apiKey: "synthetic-not-a-key", cli: "node",
|
||||
env: {}, mcpServers: {}, sessionIds: ["focused-session"], externalSessionId: "native-session" })) {
|
||||
assert.equal(open({ ...params, [key]: value }), false, key);
|
||||
}
|
||||
for (const key of ["backendRef", "selectionHandle", "conversationRef"]) {
|
||||
assert.equal(open({ ...params, [key]: "internal-or-path-id" }), false, key);
|
||||
}
|
||||
});
|
||||
|
||||
test("Agent messages and events are bounded and do not serialize raw tools/configuration", () => {
|
||||
const send = createDefinitionValidator("PluginAgentSendParams");
|
||||
const params = { chatHandle: randomUUID(), text: "Synthetic user instruction" };
|
||||
assert.equal(send(params), true);
|
||||
assert.equal(send({ ...params, text: "x".repeat(16_385) }), false);
|
||||
assert.equal(send({ ...params, text: "a\0b" }), false);
|
||||
assert.equal(send({ ...params, turnId: "builtin" }), false);
|
||||
const event = createDefinitionValidator("PluginAgentEvent");
|
||||
assert.equal(event({ kind: "activity", capabilityId: "terminal.execute", state: "started" }), true);
|
||||
assert.equal(event({ kind: "activity", capabilityId: "terminal.execute", state: "started", args: { command: "raw" } }), false);
|
||||
assert.equal(event({ kind: "usage", inputTokens: 2, outputTokens: 4 }), true);
|
||||
assert.equal(event({ kind: "usage", inputTokens: -1, outputTokens: 4 }), false);
|
||||
assert.equal(event({ kind: "text", text: "x".repeat(4097) }), false);
|
||||
assert.equal(event({ kind: "provider-error", message: "private diagnostics" }), false);
|
||||
});
|
||||
|
||||
test("public chat/history summaries do not contain backend/native identifiers, prompts or tool results", () => {
|
||||
const chats = createDefinitionValidator("PluginAgentChatsResult");
|
||||
const chat = { conversationRef: randomUUID(), title: "Synthetic chat", backend: "claude", updatedAt: 1 };
|
||||
assert.equal(chats({ items: [chat] }), true);
|
||||
assert.equal(chats({ items: [{ ...chat, externalSessionId: "native" }] }), false);
|
||||
const history = createDefinitionValidator("PluginAgentHistoryResult");
|
||||
assert.equal(history({ items: [{ role: "assistant", text: "Summary" }] }), true);
|
||||
assert.equal(history({ items: [{ role: "tool", text: "Raw tool result" }] }), false);
|
||||
assert.equal(history({ items: [{ role: "assistant", text: "Summary", apiKey: "synthetic" }] }), false);
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
"use strict";
|
||||
|
||||
const fs = require("node:fs/promises");
|
||||
const path = require("node:path");
|
||||
const { createHash, randomUUID } = require("node:crypto");
|
||||
|
||||
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u;
|
||||
const MAX_BYTES = 1_048_576;
|
||||
const MAX_CONVERSATIONS = 64;
|
||||
|
||||
// Core-owned AISession documents, outside plugin filesystem/storage roots.
|
||||
// The caller must authenticate provenance before supplying the opaque owner.
|
||||
// Native resume identity, compaction and tool history stay in this repository;
|
||||
// only a separate public projection is returned through the plugin API.
|
||||
class PluginAgentConversationStore {
|
||||
constructor(directory) {
|
||||
if (!path.isAbsolute(directory)) throw new TypeError("Absolute host history directory required");
|
||||
this.directory = directory;
|
||||
this.pending = new Map();
|
||||
}
|
||||
|
||||
#ownerDirectory(owner) {
|
||||
if (!owner || typeof owner.privateOwner !== "string" || !owner.privateOwner
|
||||
|| typeof owner.sourceKey !== "string" || !owner.sourceKey) throw new TypeError("Host provenance required");
|
||||
return path.join(this.directory, createHash("sha256").update(JSON.stringify([owner.sourceKey, owner.privateOwner])).digest("hex"));
|
||||
}
|
||||
|
||||
#filename(owner, id) {
|
||||
if (typeof id !== "string" || !UUID.test(id)) throw new TypeError("Invalid owned conversation reference");
|
||||
return path.join(this.#ownerDirectory(owner), `${id}.json`);
|
||||
}
|
||||
|
||||
async #prepare(owner) {
|
||||
await fs.mkdir(this.directory, { recursive: true, mode: 0o700 });
|
||||
if (!(await fs.lstat(this.directory)).isDirectory() || (await fs.lstat(this.directory)).isSymbolicLink()) throw Error("History root unavailable");
|
||||
const directory = this.#ownerDirectory(owner);
|
||||
await fs.mkdir(directory, { recursive: true, mode: 0o700 });
|
||||
const stat = await fs.lstat(directory);
|
||||
if (!stat.isDirectory() || stat.isSymbolicLink()) throw Error("History owner unavailable");
|
||||
return directory;
|
||||
}
|
||||
|
||||
#validate(record) {
|
||||
if (!record || typeof record !== "object" || !UUID.test(record.conversationRef)
|
||||
|| typeof record.id !== "string" || !record.id.startsWith("plugin-agent:")
|
||||
|| record.scope?.type !== "global" || record.scope.targetId !== undefined
|
||||
|| typeof record.agentId !== "string" || typeof record.title !== "string" || record.title.length > 160
|
||||
|| !Array.isArray(record.messages) || record.messages.length > 200
|
||||
|| !Number.isSafeInteger(record.createdAt) || !Number.isSafeInteger(record.updatedAt)) throw new TypeError("Invalid core Agent history");
|
||||
const text = JSON.stringify(record);
|
||||
if (Buffer.byteLength(text) > MAX_BYTES) throw new RangeError("Agent history budget exceeded");
|
||||
return text;
|
||||
}
|
||||
|
||||
async #serial(owner, operation) {
|
||||
const key = this.#ownerDirectory(owner);
|
||||
const previous = this.pending.get(key) ?? Promise.resolve();
|
||||
const current = previous.catch(() => {}).then(operation);
|
||||
this.pending.set(key, current);
|
||||
try { return await current; }
|
||||
finally { if (this.pending.get(key) === current) this.pending.delete(key); }
|
||||
}
|
||||
|
||||
async list(owner, assertCurrent) {
|
||||
await assertCurrent();
|
||||
const directory = await this.#prepare(owner);
|
||||
const files = await fs.readdir(directory);
|
||||
const records = [];
|
||||
for (const file of files) {
|
||||
const id = file.slice(0, -5);
|
||||
if (!file.endsWith(".json") || !UUID.test(id)) continue;
|
||||
if (records.length >= MAX_CONVERSATIONS) break;
|
||||
const record = await this.read(owner, id, assertCurrent);
|
||||
if (record) records.push(record);
|
||||
}
|
||||
await assertCurrent();
|
||||
return records.sort((a, b) => b.updatedAt - a.updatedAt);
|
||||
}
|
||||
|
||||
async read(owner, id, assertCurrent) {
|
||||
await assertCurrent();
|
||||
const filename = this.#filename(owner, id);
|
||||
await this.#prepare(owner);
|
||||
let file;
|
||||
try {
|
||||
// O_NOFOLLOW avoids following a replaced final path. Core owns ancestors.
|
||||
const { constants } = require("node:fs");
|
||||
file = await fs.open(filename, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
|
||||
const stat = await file.stat();
|
||||
if (!stat.isFile() || stat.size > MAX_BYTES) throw Error("Agent history unavailable");
|
||||
const record = JSON.parse(await file.readFile("utf8"));
|
||||
this.#validate(record);
|
||||
if (record.conversationRef !== id) throw Error("Agent history identity mismatch");
|
||||
await assertCurrent();
|
||||
return record;
|
||||
} catch (error) {
|
||||
if (error.code === "ENOENT") { await assertCurrent(); return null; }
|
||||
throw error;
|
||||
} finally { await file?.close(); }
|
||||
}
|
||||
|
||||
async save(owner, record, assertCurrent) {
|
||||
// Clone/serialize before any await so caller mutation cannot change commit.
|
||||
const text = this.#validate(record);
|
||||
const id = record.conversationRef;
|
||||
return this.#serial(owner, async () => {
|
||||
await assertCurrent();
|
||||
const directory = await this.#prepare(owner);
|
||||
const filename = this.#filename(owner, id);
|
||||
const entries = (await fs.readdir(directory)).filter((name) => name.endsWith(".json"));
|
||||
if (!entries.includes(`${id}.json`) && entries.length >= MAX_CONVERSATIONS) throw new RangeError("Agent conversation budget exceeded");
|
||||
const temporary = path.join(directory, `.${randomUUID()}.tmp`);
|
||||
try {
|
||||
await fs.writeFile(temporary, text, { encoding: "utf8", mode: 0o600, flag: "wx" });
|
||||
await assertCurrent();
|
||||
await fs.rename(temporary, filename);
|
||||
} finally { await fs.rm(temporary, { force: true }); }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { PluginAgentConversationStore };
|
||||
@@ -0,0 +1,68 @@
|
||||
"use strict";
|
||||
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs/promises");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { randomUUID } = require("node:crypto");
|
||||
const { PluginAgentConversationStore } = require("./conversationStore.cjs");
|
||||
|
||||
const owner = (source) => ({ privateOwner: `opaque:${source}:same-plugin-id`, sourceKey: `source:${source}` });
|
||||
const current = async () => {};
|
||||
function record() {
|
||||
const conversationRef = randomUUID();
|
||||
return { conversationRef, id: `plugin-agent:${randomUUID()}`, title: "Synthetic chat", agentId: "catty",
|
||||
scope: { type: "global" }, messages: [{ id: randomUUID(), role: "user", content: "Synthetic only", timestamp: 1 }], createdAt: 1, updatedAt: 1 };
|
||||
}
|
||||
async function fixture(t) {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "netcatty-agent-history-test-"));
|
||||
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
||||
return { root, store: new PluginAgentConversationStore(path.join(root, "core-history")) };
|
||||
}
|
||||
|
||||
test("core history survives reopening, uses existing AISession shape and isolates the same plugin ID across sources", async (t) => {
|
||||
const { root, store } = await fixture(t); const chat = record();
|
||||
await store.save(owner("a"), chat, current);
|
||||
assert.deepEqual(await store.read(owner("a"), chat.conversationRef, current), chat);
|
||||
const [directory] = await fs.readdir(path.join(root, "core-history"));
|
||||
assert.doesNotMatch(directory, /opaque|same-plugin/);
|
||||
const mode = (await fs.stat(path.join(root, "core-history", directory, `${chat.conversationRef}.json`))).mode;
|
||||
if (process.platform !== "win32") assert.equal(mode & 0o777, 0o600);
|
||||
assert.equal(await store.read(owner("b"), chat.conversationRef, current), null);
|
||||
assert.deepEqual(await store.list(owner("b"), current), []);
|
||||
const reopened = new PluginAgentConversationStore(path.join(root, "core-history"));
|
||||
assert.deepEqual(await reopened.list(owner("a"), current), [chat]);
|
||||
});
|
||||
|
||||
test("source revoked before async rename leaves previous history intact and cleans temporary files", async (t) => {
|
||||
const { store, root } = await fixture(t); const chat = record(); await store.save(owner("a"), chat, current);
|
||||
const next = { ...chat, title: "Must not commit" }; let checks = 0;
|
||||
await assert.rejects(store.save(owner("a"), next, async () => { if (++checks === 2) throw Error("Revoked"); }));
|
||||
assert.equal((await store.read(owner("a"), chat.conversationRef, current)).title, chat.title);
|
||||
const [directory] = await fs.readdir(path.join(root, "core-history"));
|
||||
assert.deepEqual(await fs.readdir(path.join(root, "core-history", directory)), [`${chat.conversationRef}.json`]);
|
||||
});
|
||||
|
||||
test("history snapshots cannot mutate across await, and traversal/builtin IDs/oversized records are rejected", async (t) => {
|
||||
const { store } = await fixture(t); const chat = record(); const savedTitle = chat.title;
|
||||
const saved = store.save(owner("a"), chat, current); chat.title = "Changed after call"; await saved;
|
||||
assert.equal((await store.read(owner("a"), chat.conversationRef, current)).title, savedTitle);
|
||||
for (const id of ["../outside", "builtin-chat", "source:owner", ""]) await assert.rejects(store.read(owner("a"), id, current));
|
||||
await assert.rejects(store.save({ privateOwner: "bare-plugin" }, record(), current));
|
||||
await assert.rejects(store.save(owner("a"), { ...record(), id: "builtin-chat" }, current));
|
||||
await assert.rejects(store.save(owner("a"), { ...record(), scope: { type: "workspace", targetId: "active" } }, current));
|
||||
await assert.rejects(store.save(owner("a"), { ...record(), messages: Array(201).fill({}) }, current));
|
||||
await assert.rejects(store.save(owner("a"), { ...record(), messages: [{ content: "x".repeat(1_048_576) }] }, current));
|
||||
});
|
||||
|
||||
test("symlinked history roots and files do not become history authority", async (t) => {
|
||||
const { root, store } = await fixture(t); const chat = record(); await store.save(owner("a"), chat, current);
|
||||
const history = path.join(root, "core-history"); const [directory] = await fs.readdir(history);
|
||||
const filename = path.join(history, directory, `${chat.conversationRef}.json`);
|
||||
const unrelated = path.join(root, "unrelated.json"); await fs.writeFile(unrelated, JSON.stringify(chat));
|
||||
await fs.rm(filename); await fs.symlink(unrelated, filename);
|
||||
await assert.rejects(store.read(owner("a"), chat.conversationRef, current));
|
||||
const linked = path.join(root, "linked"); await fs.symlink(history, linked, "dir");
|
||||
await assert.rejects(new PluginAgentConversationStore(linked).list(owner("a"), current));
|
||||
});
|
||||
@@ -0,0 +1,180 @@
|
||||
"use strict";
|
||||
|
||||
const { randomUUID } = require("node:crypto");
|
||||
const { PluginRpcError, RPC_ERRORS } = require("../rpcRouter.cjs");
|
||||
|
||||
const fail = (code) => new PluginRpcError(RPC_ERRORS[code], "Delegated Agent scope is unavailable");
|
||||
const key = (target) => JSON.stringify([target.kind, target.id]);
|
||||
const isRequestCancellation = (error, context) => context.signal?.aborted === true
|
||||
&& (error === context.signal.reason || error?.name === "AbortError" || error?.name === "TimeoutError"
|
||||
|| error?.code === RPC_ERRORS.cancelled || error?.code === RPC_ERRORS.deadlineExceeded);
|
||||
|
||||
// Host-only authority, not a plugin RPC interface or an Agent engine. The host
|
||||
// supplies snapshots after resolving configured backends and choosing targets.
|
||||
// Plugins never supply owner, revision, capability policy or executable adapters.
|
||||
class DelegatedAgentScopes {
|
||||
constructor({ resolveOwner, assertHostActive, authorizeAction, clock = Date.now }) {
|
||||
for (const fn of [resolveOwner, assertHostActive, authorizeAction, clock]) {
|
||||
if (typeof fn !== "function") throw new TypeError("Host scope adapters are required");
|
||||
}
|
||||
this.resolveOwner = resolveOwner;
|
||||
this.assertHostActive = assertHostActive;
|
||||
this.authorizeAction = authorizeAction;
|
||||
this.clock = clock;
|
||||
this.scopes = new Map();
|
||||
this.closed = false;
|
||||
}
|
||||
|
||||
async #owner(context) {
|
||||
if (this.closed) throw fail("unavailable");
|
||||
const owner = await this.resolveOwner(context);
|
||||
await this.assertHostActive(context);
|
||||
context.signal?.throwIfAborted();
|
||||
if (this.closed || await this.resolveOwner(context) !== owner) throw fail("permissionDenied");
|
||||
return owner;
|
||||
}
|
||||
|
||||
// The snapshot is host-owned. A public schema must only accept returned handles.
|
||||
async issue(context, { backend, targets, capabilities }) {
|
||||
const owner = await this.#owner(context);
|
||||
if (!backend || typeof backend.key !== "string" || typeof backend.revision !== "string"
|
||||
|| typeof backend.isCurrent !== "function" || !Array.isArray(targets) || targets.length > 32
|
||||
|| !Array.isArray(capabilities) || capabilities.length > 128
|
||||
|| capabilities.some((id) => typeof id !== "string" || !id || id.length > 160)) throw fail("invalidArgument");
|
||||
const captured = new Map();
|
||||
for (const target of targets) {
|
||||
if (!target || !["terminal", "host", "note"].includes(target.kind)
|
||||
|| typeof target.id !== "string" || !target.id || target.id.length > 256
|
||||
|| typeof target.revision !== "string" || !target.revision
|
||||
|| typeof target.owner !== "string" || !target.owner
|
||||
|| typeof target.isCurrent !== "function" || captured.has(key(target))) throw fail("invalidArgument");
|
||||
captured.set(key(target), Object.freeze({ ...target }));
|
||||
}
|
||||
const scope = { handle: randomUUID(), owner, backend: Object.freeze({ ...backend }), targets: captured,
|
||||
capabilities: new Set(capabilities), expiresAt: this.clock() + 300_000, activeTurn: null };
|
||||
this.#assertSnapshots(scope);
|
||||
if (await this.#owner(context) !== owner) throw fail("permissionDenied");
|
||||
this.#assertSnapshots(scope);
|
||||
for (const entry of this.scopes.values()) if (entry.expiresAt <= this.clock()) this.#revoke(entry);
|
||||
const existing = [...this.scopes.values()].filter((entry) => entry.owner === owner);
|
||||
if (existing.length >= 8 || this.scopes.size >= 64) throw fail("resourceExhausted");
|
||||
this.scopes.set(scope.handle, scope);
|
||||
return Object.freeze({ handle: scope.handle, expiresAt: scope.expiresAt });
|
||||
}
|
||||
|
||||
#assertSnapshots(scope) {
|
||||
// Current-owner/revision checks are synchronous registry lookups, so the
|
||||
// final dispatch check and native call have no asynchronous focus gap.
|
||||
if (scope.backend.isCurrent(scope.backend) !== true) throw fail("failedPrecondition");
|
||||
for (const target of scope.targets.values()) {
|
||||
if (target.isCurrent(target) !== true) throw fail("failedPrecondition");
|
||||
}
|
||||
}
|
||||
|
||||
async #resolve(handle, context) {
|
||||
const scope = this.scopes.get(handle);
|
||||
let owner;
|
||||
try { owner = await this.#owner(context); }
|
||||
catch (error) {
|
||||
// Context identity comes from the authenticated host transport. Revoke
|
||||
// only its own captured scope, never a victim referenced by another caller.
|
||||
if (!isRequestCancellation(error, context) && scope && scope.owner.runtimeId === context.runtimeId && scope.owner.pluginId === context.pluginId
|
||||
&& scope.owner.pluginVersion === context.pluginVersion) this.#revoke(scope);
|
||||
throw error;
|
||||
}
|
||||
if (!scope || scope.owner !== owner) throw fail("permissionDenied");
|
||||
if (scope.expiresAt <= this.clock()) { this.#revoke(scope); throw fail("permissionDenied"); }
|
||||
try {
|
||||
if (await this.#owner(context) !== owner || this.scopes.get(handle) !== scope
|
||||
|| scope.expiresAt <= this.clock()) throw fail("permissionDenied");
|
||||
this.#assertSnapshots(scope);
|
||||
} catch (error) {
|
||||
// A failed individual RPC has no authority to revoke another turn. Real
|
||||
// owner/target failure still invalidates the scope, even if a request also
|
||||
// happens to be cancelled at that time.
|
||||
if (!isRequestCancellation(error, context)) this.#revoke(scope);
|
||||
throw error;
|
||||
}
|
||||
return scope;
|
||||
}
|
||||
|
||||
async begin(handle, context) {
|
||||
const scope = await this.#resolve(handle, context);
|
||||
if (scope.activeTurn) throw fail("alreadyExists");
|
||||
const ticket = Object.freeze({ scope, controller: new AbortController(), id: randomUUID() });
|
||||
scope.activeTurn = ticket;
|
||||
return ticket;
|
||||
}
|
||||
|
||||
async assertTurn(ticket, context) {
|
||||
if (!ticket?.scope || ticket.controller.signal.aborted) throw fail("cancelled");
|
||||
const scope = await this.#resolve(ticket.scope.handle, context);
|
||||
if (scope.activeTurn !== ticket) throw fail("cancelled");
|
||||
ticket.controller.signal.throwIfAborted();
|
||||
return scope;
|
||||
}
|
||||
|
||||
// Called inside the actual tool dispatcher, after canonical argument/target
|
||||
// resolution. It must not be used merely when displaying the model tool list.
|
||||
async dispatch(ticket, context, action, execute) {
|
||||
const scope = await this.assertTurn(ticket, context);
|
||||
if (!action || !scope.capabilities.has(action.capabilityId) || !Array.isArray(action.targets)
|
||||
|| action.targets.length > 32 || typeof execute !== "function") throw fail("permissionDenied");
|
||||
const targets = action.targets.map((target) => {
|
||||
const captured = target && scope.targets.get(key(target));
|
||||
if (!captured || target.revision !== captured.revision || target.owner !== captured.owner) throw fail("permissionDenied");
|
||||
return captured;
|
||||
});
|
||||
const request = Object.freeze({ capabilityId: action.capabilityId, targets: Object.freeze(targets) });
|
||||
// The adapter combines plugin policy and native Agent restrictions once.
|
||||
// No caller can select a process-global permission mode through this API.
|
||||
await this.authorizeAction(context, scope.owner, request, ticket.controller.signal);
|
||||
await this.assertTurn(ticket, context);
|
||||
this.#assertSnapshots(scope);
|
||||
ticket.controller.signal.throwIfAborted();
|
||||
const result = await execute(Object.freeze({ signal: ticket.controller.signal,
|
||||
assertCurrent: () => this.assertTurn(ticket, context) }));
|
||||
await this.assertTurn(ticket, context);
|
||||
return result;
|
||||
}
|
||||
|
||||
finish(ticket) {
|
||||
if (!ticket || ticket.scope?.activeTurn !== ticket) return false;
|
||||
ticket.scope.activeTurn = null;
|
||||
ticket.controller.abort();
|
||||
return true;
|
||||
}
|
||||
|
||||
async stop(handle, context) {
|
||||
// Capture before the first await. A prior Stop cannot acquire authority over
|
||||
// a turn that replaces the one the caller intended to stop.
|
||||
const candidate = this.scopes.get(handle);
|
||||
const ticket = candidate?.activeTurn;
|
||||
const scope = await this.#resolve(handle, context);
|
||||
return scope === candidate ? this.finish(ticket) : false;
|
||||
}
|
||||
|
||||
#revoke(scope) {
|
||||
this.finish(scope.activeTurn);
|
||||
this.scopes.delete(scope.handle);
|
||||
}
|
||||
|
||||
releaseRuntime(runtimeId) {
|
||||
for (const scope of this.scopes.values()) if (scope.owner.runtimeId === runtimeId) this.#revoke(scope);
|
||||
}
|
||||
|
||||
releasePlugin(pluginId) {
|
||||
for (const scope of this.scopes.values()) if (scope.owner.pluginId === pluginId) this.#revoke(scope);
|
||||
}
|
||||
|
||||
releaseAll() {
|
||||
for (const scope of this.scopes.values()) this.#revoke(scope);
|
||||
}
|
||||
|
||||
shutdown() {
|
||||
this.closed = true;
|
||||
this.releaseAll();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { DelegatedAgentScopes };
|
||||
@@ -0,0 +1,229 @@
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert/strict");
|
||||
const test = require("node:test");
|
||||
const { DelegatedAgentScopes } = require("./delegatedScope.cjs");
|
||||
const { PluginAISourceBinding } = require("../ai/sourceBinding.cjs");
|
||||
|
||||
function deferred() {
|
||||
let resolve;
|
||||
const promise = new Promise((finish) => { resolve = finish; });
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
function fixture() {
|
||||
let time = 1; let locked = false; let focus = "terminal-a";
|
||||
let source = { id: "source-a", kind: "third-party", origin: "https://example.invalid" };
|
||||
let privateOwner = "source:opaque-owner-a:com.example.assistant";
|
||||
let identity = { pluginId: "com.example.assistant", pluginVersion: "1", runtimeId: "r1", runtimeKind: "browser" };
|
||||
let authorize = async () => {};
|
||||
const context = { ...identity, assertActive: async () => {} };
|
||||
const database = {
|
||||
getActivePlugin: () => ({ source, enabled: true, activeVersion: "1", runtime: { status: "running" } }),
|
||||
getRetainedPluginSource: () => source,
|
||||
privateOwner: () => privateOwner,
|
||||
publicPluginId: (value) => value === privateOwner ? identity.pluginId : null,
|
||||
};
|
||||
const binding = new PluginAISourceBinding({ database, getRuntimeIdentity: () => identity });
|
||||
let backendRevision = "configured-1";
|
||||
const currentTargets = new Map([
|
||||
["terminal-a", { owner: "window-1", revision: "session-a-v1" }],
|
||||
["terminal-b", { owner: "window-2", revision: "session-b-v1" }],
|
||||
]);
|
||||
const backend = { key: "host-configured-claude", revision: backendRevision,
|
||||
isCurrent: (snapshot) => snapshot.revision === backendRevision };
|
||||
const target = (id) => ({ kind: "terminal", id, ...currentTargets.get(id),
|
||||
isCurrent: (snapshot) => { const current = currentTargets.get(snapshot.id);
|
||||
return current?.owner === snapshot.owner && current?.revision === snapshot.revision; } });
|
||||
const scopes = new DelegatedAgentScopes({ resolveOwner: (ctx) => binding.resolve(ctx),
|
||||
assertHostActive: async () => { if (locked) throw Error("Locked"); },
|
||||
authorizeAction: (...args) => authorize(...args), clock: () => time });
|
||||
return { scopes, context, target, targets: currentTargets, database,
|
||||
issue: (targets = [target("terminal-a"), target("terminal-b")]) => scopes.issue(context,
|
||||
{ backend, targets, capabilities: ["terminal.execute", "session.environment"] }),
|
||||
action: (id = "terminal-a") => ({ capabilityId: "terminal.execute", targets: [target(id)] }),
|
||||
tick: (ms) => { time += ms; }, lock: () => { locked = true; scopes.releaseAll(); },
|
||||
focusTo: (id) => { focus = id; }, focus: () => focus,
|
||||
authorize: (fn) => { authorize = fn; }, backendTo: (value) => { backendRevision = value; },
|
||||
replaceSource: () => { source = { ...source, id: "source-b" }; privateOwner = "source:opaque-owner-b:com.example.assistant"; },
|
||||
runtimeTo: (id) => { identity = { ...identity, runtimeId: id }; },
|
||||
};
|
||||
}
|
||||
|
||||
test("host-issued scope exposes only opaque identity and expiry; cross-workspace focus does not change explicit targets", async () => {
|
||||
const f = fixture(); const ref = await f.issue();
|
||||
assert.deepEqual(Object.keys(ref).sort(), ["expiresAt", "handle"]);
|
||||
assert.doesNotMatch(JSON.stringify(ref), /window-|terminal-|source:|configured/);
|
||||
const ticket = await f.scopes.begin(ref.handle, f.context);
|
||||
f.focusTo("unselected-terminal");
|
||||
let calls = 0;
|
||||
assert.equal(await f.scopes.dispatch(ticket, f.context, f.action("terminal-b"), async () => ++calls), 1);
|
||||
assert.equal(f.focus(), "unselected-terminal");
|
||||
await assert.rejects(f.scopes.dispatch(ticket, f.context,
|
||||
{ capabilityId: "terminal.execute", targets: [{ kind: "terminal", id: f.focus(), revision: "1", owner: "window-1" }] }, async () => ++calls));
|
||||
assert.equal(calls, 1);
|
||||
});
|
||||
|
||||
test("forged runtime, cross-runtime session and source replacement cannot use a scope", async () => {
|
||||
const f = fixture(); const ref = await f.issue();
|
||||
await assert.rejects(f.scopes.begin(ref.handle, { ...f.context, runtimeId: "forged" }));
|
||||
f.runtimeTo("r2");
|
||||
await assert.rejects(f.scopes.begin(ref.handle, { ...f.context, runtimeId: "r2" }));
|
||||
f.runtimeTo("r1"); f.replaceSource();
|
||||
await assert.rejects(f.scopes.begin(ref.handle, f.context));
|
||||
});
|
||||
|
||||
test("one host policy decision; target owner/revision is revalidated after approval waits", async () => {
|
||||
const f = fixture(); const ref = await f.issue(); const ticket = await f.scopes.begin(ref.handle, f.context);
|
||||
const entered = deferred(); const release = deferred(); let decisions = 0; let effects = 0;
|
||||
f.authorize(async (_context, owner, action) => {
|
||||
decisions++; assert.equal(owner.privateOwner, "source:opaque-owner-a:com.example.assistant");
|
||||
assert.equal(action.targets[0].id, "terminal-a"); entered.resolve(); await release.promise;
|
||||
});
|
||||
const result = f.scopes.dispatch(ticket, f.context, f.action(), async () => effects++);
|
||||
await entered.promise;
|
||||
f.targets.set("terminal-a", { owner: "new-window", revision: "session-a-v2" });
|
||||
release.resolve(); await assert.rejects(result);
|
||||
assert.equal(decisions, 1); assert.equal(effects, 0); assert.equal(ticket.controller.signal.aborted, true);
|
||||
});
|
||||
|
||||
test("copied target ID with substituted owner or revision and undelegated capabilities fail before policy/execute", async () => {
|
||||
const f = fixture(); const ref = await f.issue(); const ticket = await f.scopes.begin(ref.handle, f.context);
|
||||
let decisions = 0; let effects = 0; f.authorize(async () => { decisions++; });
|
||||
for (const patch of [{ owner: "attacker" }, { revision: "old" }]) {
|
||||
await assert.rejects(f.scopes.dispatch(ticket, f.context,
|
||||
{ capabilityId: "terminal.execute", targets: [{ ...f.target("terminal-a"), ...patch }] }, async () => effects++));
|
||||
}
|
||||
await assert.rejects(f.scopes.dispatch(ticket, f.context,
|
||||
{ capabilityId: "vault.host.delete", targets: [] }, async () => effects++));
|
||||
assert.equal(decisions, 0); assert.equal(effects, 0);
|
||||
});
|
||||
|
||||
test("Deny and revoked source during authorization never dispatch a tool", async () => {
|
||||
const f = fixture(); const ref = await f.issue(); const ticket = await f.scopes.begin(ref.handle, f.context);
|
||||
let effects = 0;
|
||||
f.authorize(async () => { throw Error("Denied"); });
|
||||
await assert.rejects(f.scopes.dispatch(ticket, f.context, f.action(), async () => effects++));
|
||||
f.authorize(async () => { f.replaceSource(); });
|
||||
await assert.rejects(f.scopes.dispatch(ticket, f.context, f.action(), async () => effects++));
|
||||
assert.equal(effects, 0);
|
||||
});
|
||||
|
||||
test("Stop while approval waits cancels the exact turn and prevents invocation", async () => {
|
||||
const f = fixture(); const ref = await f.issue(); const ticket = await f.scopes.begin(ref.handle, f.context);
|
||||
const entered = deferred(); const release = deferred(); let effects = 0;
|
||||
f.authorize(async (_context, _owner, _action, signal) => {
|
||||
assert.equal(signal, ticket.controller.signal); entered.resolve(); await release.promise;
|
||||
});
|
||||
const result = f.scopes.dispatch(ticket, f.context, f.action(), async () => effects++);
|
||||
await entered.promise; assert.equal(await f.scopes.stop(ref.handle, f.context), true);
|
||||
release.resolve(); await assert.rejects(result); assert.equal(effects, 0);
|
||||
const next = await f.scopes.begin(ref.handle, f.context);
|
||||
assert.equal(f.scopes.finish(ticket), false); assert.equal(next.controller.signal.aborted, false);
|
||||
});
|
||||
|
||||
for (const reason of ["runtime", "plugin", "lock", "stop"]) {
|
||||
test(`${reason} cancellation drops late native results and invalidates the old event ticket`, async () => {
|
||||
const f = fixture(); const ref = await f.issue(); const ticket = await f.scopes.begin(ref.handle, f.context);
|
||||
const entered = deferred(); const release = deferred();
|
||||
const result = f.scopes.dispatch(ticket, f.context, f.action(), async ({ signal }) => {
|
||||
assert.equal(signal, ticket.controller.signal); entered.resolve(); await release.promise; return "late";
|
||||
});
|
||||
await entered.promise;
|
||||
if (reason === "runtime") f.scopes.releaseRuntime(f.context.runtimeId);
|
||||
else if (reason === "plugin") f.scopes.releasePlugin(f.context.pluginId);
|
||||
else if (reason === "lock") f.lock();
|
||||
else await f.scopes.stop(ref.handle, f.context);
|
||||
release.resolve(); await assert.rejects(result); await assert.rejects(f.scopes.assertTurn(ticket, f.context));
|
||||
assert.equal(ticket.controller.signal.aborted, true);
|
||||
});
|
||||
}
|
||||
|
||||
test("backend config changes and expiry invalidate scopes; host snapshots cannot silently refresh them", async () => {
|
||||
const f = fixture(); const ref = await f.issue(); const ticket = await f.scopes.begin(ref.handle, f.context);
|
||||
f.backendTo("new-config"); await assert.rejects(f.scopes.assertTurn(ticket, f.context));
|
||||
assert.equal(ticket.controller.signal.aborted, true);
|
||||
const g = fixture(); const next = await g.issue(); const active = await g.scopes.begin(next.handle, g.context);
|
||||
g.tick(300_000); await assert.rejects(g.scopes.assertTurn(active, g.context));
|
||||
assert.equal(active.controller.signal.aborted, true); assert.equal(g.scopes.scopes.size, 0);
|
||||
});
|
||||
|
||||
test("source replacement detected during owner authentication aborts already-running native work", async () => {
|
||||
const f = fixture(); const ref = await f.issue(); const ticket = await f.scopes.begin(ref.handle, f.context);
|
||||
const entered = deferred(); const release = deferred();
|
||||
const result = f.scopes.dispatch(ticket, f.context, f.action(), async ({ signal }) => {
|
||||
entered.resolve(signal); await release.promise; return "late native result";
|
||||
});
|
||||
const signal = await entered.promise;
|
||||
try {
|
||||
f.replaceSource();
|
||||
await assert.rejects(f.scopes.assertTurn(ticket, f.context));
|
||||
assert.equal(signal.aborted, true, "source detection must abort native work, not only reject its result");
|
||||
assert.equal(f.scopes.scopes.size, 0);
|
||||
} finally { release.resolve(); await assert.rejects(result); }
|
||||
});
|
||||
|
||||
test("Stop captures turn A before owner validation and cannot cancel replacement turn B", async () => {
|
||||
const f = fixture(); const ref = await f.issue(); const first = await f.scopes.begin(ref.handle, f.context);
|
||||
const entered = deferred(); const release = deferred(); let blocked = false;
|
||||
const stopContext = { ...f.context, assertActive: async () => {
|
||||
if (!blocked) { blocked = true; entered.resolve(); await release.promise; }
|
||||
} };
|
||||
const stopping = f.scopes.stop(ref.handle, stopContext);
|
||||
await entered.promise; assert.equal(f.scopes.finish(first), true);
|
||||
const second = await f.scopes.begin(ref.handle, f.context);
|
||||
release.resolve();
|
||||
assert.equal(await stopping, false);
|
||||
assert.equal(second.controller.signal.aborted, false, "an earlier Stop must not cancel a later turn");
|
||||
assert.equal(await f.scopes.assertTurn(second, f.context), second.scope);
|
||||
});
|
||||
|
||||
for (const phase of [1, 5]) {
|
||||
test(`cancelled Stop(A) during owner check ${phase} cannot revoke replacement turn B`, { timeout: 2_000 }, async () => {
|
||||
const f = fixture(); const ref = await f.issue(); const first = await f.scopes.begin(ref.handle, f.context);
|
||||
const entered = deferred(); const release = deferred(); const rpc = new AbortController(); let checks = 0;
|
||||
const stopContext = { ...f.context, signal: rpc.signal, assertActive: async () => {
|
||||
if (++checks === phase) { entered.resolve(); await release.promise; }
|
||||
rpc.signal.throwIfAborted();
|
||||
} };
|
||||
const stopping = f.scopes.stop(ref.handle, stopContext);
|
||||
await entered.promise; f.scopes.finish(first);
|
||||
const second = await f.scopes.begin(ref.handle, f.context);
|
||||
rpc.abort(phase === 1 ? new DOMException("Stop cancelled", "AbortError") : new DOMException("Stop deadline", "TimeoutError"));
|
||||
release.resolve();
|
||||
await assert.rejects(stopping, (error) => error === rpc.signal.reason);
|
||||
assert.equal(second.controller.signal.aborted, false, "a cancelled RPC does not revoke source authority or stop a later turn");
|
||||
assert.equal(f.scopes.scopes.size, 1);
|
||||
assert.equal(await f.scopes.assertTurn(second, f.context), second.scope);
|
||||
});
|
||||
}
|
||||
|
||||
test("verified source failure still aborts native work when the RPC is concurrently cancelled", async () => {
|
||||
const f = fixture(); const ref = await f.issue(); const ticket = await f.scopes.begin(ref.handle, f.context);
|
||||
const rpc = new AbortController(); const retained = f.database.getRetainedPluginSource;
|
||||
f.database.getRetainedPluginSource = () => {
|
||||
rpc.abort(new DOMException("Concurrent RPC cancellation", "AbortError"));
|
||||
return { ...retained(), id: "different-retained-source" };
|
||||
};
|
||||
await assert.rejects(f.scopes.assertTurn(ticket, { ...f.context, signal: rpc.signal }),
|
||||
(error) => error !== rpc.signal.reason && error.code === -32007);
|
||||
assert.equal(ticket.controller.signal.aborted, true);
|
||||
assert.equal(f.scopes.scopes.size, 0);
|
||||
});
|
||||
|
||||
test("a foreign runtime cannot revoke a victim scope by failing authentication with its handle", async () => {
|
||||
const f = fixture(); const ref = await f.issue(); const ticket = await f.scopes.begin(ref.handle, f.context);
|
||||
await assert.rejects(f.scopes.stop(ref.handle, { ...f.context, runtimeId: "foreign-runtime" }));
|
||||
assert.equal(ticket.controller.signal.aborted, false);
|
||||
assert.equal(f.scopes.scopes.size, 1);
|
||||
assert.equal(await f.scopes.assertTurn(ticket, f.context), ticket.scope);
|
||||
});
|
||||
|
||||
test("bounded host scopes reject malformed snapshots without registering partial targets", async () => {
|
||||
const f = fixture(); await assert.rejects(f.issue([null])); assert.equal(f.scopes.scopes.size, 0);
|
||||
await assert.rejects(f.issue([f.target("terminal-a"), f.target("terminal-a")]));
|
||||
for (let i = 0; i < 8; i++) await f.issue();
|
||||
await assert.rejects(f.issue());
|
||||
f.tick(300_000); await f.issue(); assert.equal(f.scopes.scopes.size, 1);
|
||||
f.scopes.shutdown(); await assert.rejects(f.issue());
|
||||
});
|
||||
@@ -15,6 +15,7 @@ if (!schema.$defs.PluginAIChatResult) {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "netcatty-assistant-entry-"));
|
||||
let service; let contentAllowed = false; let modelAllowed = true; let reviewed = true; let invoked = 0; let versionCurrent = true;
|
||||
const decisions = [];
|
||||
let holdDecision = false, decisionOpened, completeDecision;
|
||||
t.after(async () => { await service?.manager.shutdown(); await fs.rm(root, { recursive: true, force: true }); });
|
||||
service = createPluginHostService({
|
||||
app: { getAppPath: () => path.resolve(__dirname, "../../.."), getPath: () => root, getVersion: () => "0.0.0" }, electron: {}, env: {},
|
||||
@@ -28,7 +29,11 @@ if (!schema.$defs.PluginAIChatResult) {
|
||||
},
|
||||
requestPermissionDecision: async (request) => {
|
||||
decisions.push(request);
|
||||
if ((request.permission === "context.selected.read" && !contentAllowed) || (request.permission === "ai.invoke" && !modelAllowed)) return { requestId: request.requestId, decision: "deny" };
|
||||
if (holdDecision) {
|
||||
decisionOpened();
|
||||
return new Promise((resolve) => { completeDecision = () => resolve({ requestId: request.requestId, decision: "allow", scope: "once" }); });
|
||||
}
|
||||
if (((request.permission === "context.selected.read" || request.relatedPermissions?.some((item) => item.permission === "context.selected.read")) && !contentAllowed) || (request.permission === "ai.invoke" && !modelAllowed)) return { requestId: request.requestId, decision: "deny" };
|
||||
return { requestId: request.requestId, decision: "allow", scope: request.permission === "context.selected.read" ? "once" : "application" };
|
||||
},
|
||||
});
|
||||
@@ -45,26 +50,61 @@ if (!schema.$defs.PluginAIChatResult) {
|
||||
await service.manager.install(archive, { enable: true });
|
||||
const id = "com.netcatty.assistant";
|
||||
const run = (name, args) => service.contributionService.executeCommand(`${id}.${name}`, args);
|
||||
modelAllowed = false; await assert.rejects(run("open")); assert.equal(invoked, 0);
|
||||
modelAllowed = true;
|
||||
// Normal installed Allow flow has only host model/context selection, with
|
||||
// no permission or body-sharing ceremony at any stream stage.
|
||||
const defaultChat = await run("open"); const [defaultReference] = await run("contexts");
|
||||
const defaultTurn = await run("send", { chatHandle: defaultChat.chatHandle, prompt: "Summarize", contexts: [{ handle: defaultReference.handle, version: defaultReference.version }] });
|
||||
await run("read", { turnHandle: defaultTurn.turnHandle }); await run("read", { turnHandle: defaultTurn.turnHandle }); await run("read", { turnHandle: defaultTurn.turnHandle });
|
||||
assert.equal(decisions.length, 0); assert.equal(invoked, 1);
|
||||
invoked = 0;
|
||||
service.permissionEngine.setMode({ id, manifest: service.database.getActivePlugin(id).manifest }, "ai.invoke", "deny");
|
||||
await assert.rejects(run("open")); assert.equal(invoked, 0);
|
||||
service.permissionEngine.setMode({ id, manifest: service.database.getActivePlugin(id).manifest }, "ai.invoke", "ask");
|
||||
const chat = await run("open"); const references = await run("contexts"); assert.equal(references.length, 1);
|
||||
assert.equal(references[0].scope, "note"); assert.equal(references[0].text, undefined);
|
||||
const input = { chatHandle: chat.chatHandle, prompt: "Summarize", contexts: [{ handle: references[0].handle, version: references[0].version }] };
|
||||
service.database.setPermissionMode(id, "context.selected.read", "ask");
|
||||
await assert.rejects(run("send", input)); assert.equal(invoked, 0);
|
||||
contentAllowed = true; reviewed = false; await assert.rejects(run("send", input)); assert.equal(invoked, 0);
|
||||
reviewed = true;
|
||||
contentAllowed = true; reviewed = false; // No separate body-sharing ceremony.
|
||||
const beforeStart = decisions.length;
|
||||
const turn = await run("send", input);
|
||||
assert.equal(decisions.length, beforeStart + 1, "one registered AI turn must not lose its content receipt in a derived context");
|
||||
assert.deepEqual(decisions.at(-1).displayContext, { modelLabel: "Synthetic fixture", selections: [{ title: "Synthetic retry plan", scope: "note", version: "v1" }] });
|
||||
const { createDefinitionValidator } = require("../contractValidator.cjs");
|
||||
assert.equal(createDefinitionValidator("PermissionRequest")(decisions.at(-1)), true);
|
||||
const auditMetadata = JSON.stringify(service.database.listSecurityAudit(id, 1000));
|
||||
assert.equal(auditMetadata.includes("Synthetic retry plan"), false);
|
||||
assert.equal(auditMetadata.includes("Synthetic fixture"), false);
|
||||
assert.deepEqual(await run("read", { turnHandle: turn.turnHandle }), { kind: "text", sequence: 0, text: "Synthetic summary" });
|
||||
assert.deepEqual(await run("read", { turnHandle: turn.turnHandle }), { kind: "usage", sequence: 1, inputTokens: 12, outputTokens: 6, totalTokens: 18 });
|
||||
assert.deepEqual(await run("read", { turnHandle: turn.turnHandle }), { kind: "end", sequence: 2 });
|
||||
await assert.rejects(run("send", { ...input, privateOwner: "spoofed" }));
|
||||
await assert.rejects(run("send", { ...input, permissionOperation: {} }));
|
||||
await assert.rejects(run("send", { ...input, displayContext: { modelLabel: "Plugin spoof" } }));
|
||||
await assert.rejects(run("send", { ...input, contexts: [null] }));
|
||||
await assert.rejects(run("read", { turnHandle: turn.turnHandle, source: "spoofed" }));
|
||||
const pending = await run("send", input);
|
||||
service.permissionEngine.revokeApplication(id, "ai.invoke", "*");
|
||||
assert.equal(service.aiContextService.broker.turns.size, 0);
|
||||
await assert.rejects(run("read", { turnHandle: pending.turnHandle }));
|
||||
assert.ok(decisions.some((decision) => decision.permission === "context.selected.read" && decision.resources[0].startsWith("selected:")));
|
||||
assert.ok(decisions.some((decision) => decision.relatedPermissions?.some((item) => item.permission === "context.selected.read" && item.resources[0].startsWith("selected:"))));
|
||||
const owned = { id, manifest: service.database.getActivePlugin(id).manifest };
|
||||
const queuedChat = await run("open"); const [queuedReference] = await run("contexts");
|
||||
holdDecision = true;
|
||||
const promptOpened = new Promise((resolve) => { decisionOpened = resolve; });
|
||||
const beforeRevocation = invoked;
|
||||
const queued = run("send", { chatHandle: queuedChat.chatHandle, prompt: "Do not invoke", contexts: [{ handle: queuedReference.handle, version: queuedReference.version }] });
|
||||
const rejected = assert.rejects(queued, /changed|revoked|cancel/);
|
||||
await promptOpened;
|
||||
service.permissionEngine.setMode(owned, "context.selected.read", "deny");
|
||||
completeDecision(); await rejected;
|
||||
assert.equal(invoked, beforeRevocation);
|
||||
holdDecision = false;
|
||||
const deniedChat = await run("open"); const [deniedReference] = await run("contexts");
|
||||
const beforeDeny = decisions.length;
|
||||
await assert.rejects(run("send", { chatHandle: deniedChat.chatHandle, prompt: "Still denied", contexts: [{ handle: deniedReference.handle, version: deniedReference.version }] }), /disabled/);
|
||||
assert.equal(decisions.length, beforeDeny); assert.equal(invoked, beforeRevocation);
|
||||
service.permissionEngine.setMode(owned, "context.selected.read", "allow");
|
||||
const fresh = await run("open"); const [reference] = await run("contexts");
|
||||
versionCurrent = false;
|
||||
await assert.rejects(run("send", { chatHandle: fresh.chatHandle, prompt: "Hi", contexts: [{ handle: reference.handle, version: reference.version }] }));
|
||||
|
||||
@@ -96,8 +96,27 @@ class PluginAIContextService {
|
||||
},
|
||||
authorization: async (params, context) => {
|
||||
await this.#guard(context);
|
||||
if (route.operation === "readSelected") await this.selected.assertCurrent(params, context);
|
||||
return this.#authorization(route.permission, params);
|
||||
const references = route.operation === "readSelected" ? [params] : route.operation === "start" ? params.contexts ?? [] : [];
|
||||
let displayContext;
|
||||
if (route.operation === "start" || references.length) {
|
||||
for (const reference of references) await this.selected.assertCurrent(reference, context);
|
||||
const summaries = references.length ? await this.selected.summaries(context) : [];
|
||||
const selections = references.map((reference) => {
|
||||
const summary = summaries.find((item) => item.handle === reference.handle && item.version === reference.version);
|
||||
if (!summary) throw fail("permissionDenied", "Selected context changed");
|
||||
return { title: summary.title, scope: summary.scope, version: summary.version };
|
||||
});
|
||||
displayContext = { ...(route.operation === "start" ? await this.broker.describeChat(params.chatHandle, context) : {}), selections };
|
||||
}
|
||||
return { ...this.#authorization(route.permission, params),
|
||||
...(displayContext ? { displayContext } : {}),
|
||||
...(route.operation === "start" && params.contexts?.length ? { relatedPermissions: params.contexts.map((reference) => (
|
||||
this.#authorization("context.selected.read", reference)
|
||||
)) } : {}),
|
||||
// Open selects a model; start is the meaningful invocation. The
|
||||
// broker's source-bound handles authorize subsequent stream work.
|
||||
...(["open", "list", "read", "stop", "close"].includes(route.operation) ? { deferDecision: true } : {}),
|
||||
};
|
||||
},
|
||||
}));
|
||||
}
|
||||
@@ -150,8 +169,8 @@ class PluginAIContextService {
|
||||
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");
|
||||
// The selected handle and version are checked on both sides of the read.
|
||||
// Permission middleware (or the model-turn content check above) owns Ask.
|
||||
await this.#guard(context);
|
||||
await this.selected.assertCurrent(reference, context);
|
||||
return selected;
|
||||
|
||||
@@ -22,26 +22,23 @@ function fixture() {
|
||||
allow: (v) => { allowed = v; }, review: (v) => { reviewed = v; }, current: (v) => { current = v; }, source: (v) => { sourceId = v; }, revoke: () => revoke({ pluginId: context.pluginId }) };
|
||||
}
|
||||
|
||||
test("model invocation independently requests content permission and reviewed text", async (t) => {
|
||||
test("model invocation independently checks content permission without a duplicate sharing prompt", async (t) => {
|
||||
const f = fixture(); t.after(() => f.service.shutdown());
|
||||
const { items: [reference] } = await f.service.list({}, f.context); assert.equal(f.reads(), 0);
|
||||
const chat = await f.service.open({}, f.context);
|
||||
f.allow(false);
|
||||
await assert.rejects(f.service.start({ chatHandle: chat.chatHandle, prompt: "Hi", contexts: [{ handle: reference.handle, version: reference.version }] }, f.context));
|
||||
assert.equal(f.reads(), 0); assert.equal(f.invoked(), 0);
|
||||
f.allow(true); f.review(false);
|
||||
await assert.rejects(f.service.start({ chatHandle: chat.chatHandle, prompt: "Hi", contexts: [{ handle: reference.handle, version: reference.version }] }, f.context));
|
||||
assert.equal(f.invoked(), 0);
|
||||
f.review(true);
|
||||
f.allow(true); f.hostAdapter.reviewText = () => { throw new Error("Duplicate sharing prompt"); };
|
||||
const turn = await f.service.start({ chatHandle: chat.chatHandle, prompt: "Hi", contexts: [{ handle: reference.handle, version: reference.version }] }, f.context);
|
||||
assert.equal((await f.service.read(turn, f.context)).text, "Result");
|
||||
assert.ok(f.authorizations.every((authorization) => authorization.permission === "context.selected.read" && authorization.allowedScopes[0] === "once"));
|
||||
});
|
||||
|
||||
test("source change during host review suppresses text and inference", async (t) => {
|
||||
test("source change during selected-content read suppresses text and inference", async (t) => {
|
||||
const f = fixture(); t.after(() => f.service.shutdown());
|
||||
const { items: [reference] } = await f.service.list({}, f.context); const chat = await f.service.open({}, f.context);
|
||||
f.hostAdapter.reviewText = async () => { f.source("source-b"); return true; };
|
||||
f.service.selected.handles.get(reference.handle).readText = async () => { f.source("source-b"); return "Never release"; };
|
||||
await assert.rejects(f.service.start({ chatHandle: chat.chatHandle, prompt: "Hi", contexts: [{ handle: reference.handle, version: reference.version }] }, f.context));
|
||||
assert.equal(f.invoked(), 0);
|
||||
});
|
||||
|
||||
@@ -120,6 +120,13 @@ class ModelInvocationBroker {
|
||||
} finally { this.openings.delete(opening); }
|
||||
}
|
||||
|
||||
async describeChat(chatHandle, context) {
|
||||
const chat = this.chats.get(chatHandle);
|
||||
if (!chat) throw fail("permissionDenied", "Plugin chat is unavailable");
|
||||
await this.#check(chat, context);
|
||||
return { modelLabel: chat.model.label };
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
@@ -110,6 +110,7 @@ export default { activate(context) {
|
||||
await buildPluginPackage(packageRoot, archive);
|
||||
await service.manager.install(archive, { enable: true });
|
||||
const run = (name) => service.contributionService.executeCommand(`${id}.${name}`);
|
||||
service.permissionEngine.setMode({ id, manifest }, "network.stream", "ask");
|
||||
await assert.rejects(run("run"), /denied/u);
|
||||
assert.equal(service.byteStreamService.broker.connections.size, 0);
|
||||
allow = true;
|
||||
|
||||
@@ -335,6 +335,7 @@ class PluginDatabase {
|
||||
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_host_preferences WHERE key LIKE ?").run(`permission:${owner}:%`);
|
||||
this.db.prepare("DELETE FROM plugin_source_bindings WHERE plugin_id = ?").run(pluginId);
|
||||
});
|
||||
}
|
||||
@@ -1170,6 +1171,25 @@ class PluginDatabase {
|
||||
`).run(pluginId, pluginId);
|
||||
}
|
||||
|
||||
// Host-owned policy preferences are deliberately separate from plugin-writable
|
||||
// settings. Source ownership is stable across package versions; grants are not.
|
||||
getPermissionMode(pluginId, permission) {
|
||||
const key = `permission:${this.privateOwner(pluginId)}:${permission}`;
|
||||
const row = this.db.prepare("SELECT value_json FROM plugin_host_preferences WHERE key = ?").get(key);
|
||||
const mode = row ? parseJson(row.value_json, "permission mode") : "allow";
|
||||
if (!["allow", "ask", "deny"].includes(mode)) throw new Error("Corrupt plugin permission mode");
|
||||
return mode;
|
||||
}
|
||||
|
||||
setPermissionMode(pluginId, permission, mode) {
|
||||
const key = `permission:${this.privateOwner(pluginId)}:${permission}`;
|
||||
this.transaction(() => {
|
||||
this.db.prepare("INSERT INTO plugin_host_preferences(key,value_json,updated_at) VALUES(?,?,?) ON CONFLICT(key) DO UPDATE SET value_json=excluded.value_json,updated_at=excluded.updated_at")
|
||||
.run(key, JSON.stringify(mode), this.clock());
|
||||
this.#appendSecurityAudit(pluginId, "permission.policy-edited", JSON.stringify({ permission, mode, sourceOwner: this.privateOwner(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};
|
||||
|
||||
@@ -1099,6 +1099,13 @@
|
||||
"request"
|
||||
],
|
||||
"properties": {
|
||||
"permissionMode": {
|
||||
"enum": [
|
||||
"allow",
|
||||
"ask",
|
||||
"deny"
|
||||
]
|
||||
},
|
||||
"requestId": {
|
||||
"type": "string",
|
||||
"maxLength": 128
|
||||
@@ -1764,6 +1771,8 @@
|
||||
"enum": [
|
||||
"storage",
|
||||
"ai.invoke",
|
||||
"ai.agent.use",
|
||||
"ai.agent.actions",
|
||||
"context.selected.list",
|
||||
"context.selected.read",
|
||||
"runtime.advanced",
|
||||
@@ -1808,6 +1817,8 @@
|
||||
"enum": [
|
||||
"storage",
|
||||
"ai.invoke",
|
||||
"ai.agent.use",
|
||||
"ai.agent.actions",
|
||||
"context.selected.list",
|
||||
"context.selected.read",
|
||||
"runtime.advanced",
|
||||
@@ -5774,6 +5785,76 @@
|
||||
"$ref": "#/$defs/PermissionResourceKind"
|
||||
}
|
||||
},
|
||||
"displayContext": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"modelLabel": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 160
|
||||
},
|
||||
"selections": {
|
||||
"type": "array",
|
||||
"maxItems": 5,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"title",
|
||||
"scope",
|
||||
"version"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"title": {
|
||||
"type": "string",
|
||||
"maxLength": 160
|
||||
},
|
||||
"scope": {
|
||||
"enum": [
|
||||
"workspace",
|
||||
"tab",
|
||||
"session",
|
||||
"host",
|
||||
"note"
|
||||
]
|
||||
},
|
||||
"version": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 128
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"relatedPermissions": {
|
||||
"type": "array",
|
||||
"maxItems": 5,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"permission",
|
||||
"resources"
|
||||
],
|
||||
"properties": {
|
||||
"permission": {
|
||||
"$ref": "#/$defs/PluginPermission"
|
||||
},
|
||||
"resources": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"maxItems": 128,
|
||||
"uniqueItems": true,
|
||||
"items": {
|
||||
"$ref": "#/$defs/PermissionResource"
|
||||
}
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"reason": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
@@ -7109,6 +7190,402 @@
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"PluginAgentFeature": {
|
||||
"const": "ai.agent-sessions"
|
||||
},
|
||||
"PluginAgentBackendKind": {
|
||||
"enum": [
|
||||
"catty",
|
||||
"claude",
|
||||
"codex",
|
||||
"copilot",
|
||||
"cursor",
|
||||
"codebuddy",
|
||||
"opencode",
|
||||
"grok",
|
||||
"mimo"
|
||||
]
|
||||
},
|
||||
"PluginAgentBackend": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"backendRef",
|
||||
"label",
|
||||
"backend",
|
||||
"status",
|
||||
"reason"
|
||||
],
|
||||
"properties": {
|
||||
"backendRef": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
},
|
||||
"label": {
|
||||
"type": "string",
|
||||
"maxLength": 160
|
||||
},
|
||||
"backend": {
|
||||
"$ref": "#/$defs/PluginAgentBackendKind"
|
||||
},
|
||||
"status": {
|
||||
"enum": [
|
||||
"supported",
|
||||
"unsupported",
|
||||
"unavailable"
|
||||
]
|
||||
},
|
||||
"reason": {
|
||||
"enum": [
|
||||
"ready",
|
||||
"not-configured",
|
||||
"native-boundary-unverified",
|
||||
"configuration-unavailable"
|
||||
]
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"PluginAgentBackendsResult": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"items"
|
||||
],
|
||||
"properties": {
|
||||
"items": {
|
||||
"type": "array",
|
||||
"maxItems": 32,
|
||||
"items": {
|
||||
"$ref": "#/$defs/PluginAgentBackend"
|
||||
}
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"PluginAgentTargetSummary": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"title",
|
||||
"kind"
|
||||
],
|
||||
"properties": {
|
||||
"title": {
|
||||
"type": "string",
|
||||
"maxLength": 160
|
||||
},
|
||||
"kind": {
|
||||
"const": "terminal"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"PluginAgentTargetsResult": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"selectionHandle",
|
||||
"expiresAt",
|
||||
"items"
|
||||
],
|
||||
"properties": {
|
||||
"selectionHandle": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
},
|
||||
"expiresAt": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
},
|
||||
"items": {
|
||||
"type": "array",
|
||||
"maxItems": 32,
|
||||
"items": {
|
||||
"$ref": "#/$defs/PluginAgentTargetSummary"
|
||||
}
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"PluginAgentOpenParams": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"backendRef",
|
||||
"selectionHandle"
|
||||
],
|
||||
"properties": {
|
||||
"backendRef": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
},
|
||||
"selectionHandle": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
},
|
||||
"conversationRef": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"PluginAgentChatRef": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"chatHandle"
|
||||
],
|
||||
"properties": {
|
||||
"chatHandle": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"PluginAgentChatResult": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"chatHandle",
|
||||
"conversationRef",
|
||||
"backendLabel",
|
||||
"expiresAt"
|
||||
],
|
||||
"properties": {
|
||||
"chatHandle": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
},
|
||||
"conversationRef": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
},
|
||||
"backendLabel": {
|
||||
"type": "string",
|
||||
"maxLength": 160
|
||||
},
|
||||
"expiresAt": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"PluginAgentChatSummary": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"conversationRef",
|
||||
"title",
|
||||
"backend",
|
||||
"updatedAt"
|
||||
],
|
||||
"properties": {
|
||||
"conversationRef": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
},
|
||||
"title": {
|
||||
"type": "string",
|
||||
"maxLength": 160
|
||||
},
|
||||
"backend": {
|
||||
"$ref": "#/$defs/PluginAgentBackendKind"
|
||||
},
|
||||
"updatedAt": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"PluginAgentChatsResult": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"items"
|
||||
],
|
||||
"properties": {
|
||||
"items": {
|
||||
"type": "array",
|
||||
"maxItems": 64,
|
||||
"items": {
|
||||
"$ref": "#/$defs/PluginAgentChatSummary"
|
||||
}
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"PluginAgentSendParams": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"chatHandle",
|
||||
"text"
|
||||
],
|
||||
"properties": {
|
||||
"chatHandle": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
},
|
||||
"text": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 16384,
|
||||
"pattern": "^[^\\u0000]*$"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"PluginAgentTurnRef": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"turnHandle"
|
||||
],
|
||||
"properties": {
|
||||
"turnHandle": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"PluginAgentTurnResult": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"turnHandle",
|
||||
"expiresAt"
|
||||
],
|
||||
"properties": {
|
||||
"turnHandle": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
},
|
||||
"expiresAt": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"PluginAgentMessage": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"role",
|
||||
"text"
|
||||
],
|
||||
"properties": {
|
||||
"role": {
|
||||
"enum": [
|
||||
"user",
|
||||
"assistant"
|
||||
]
|
||||
},
|
||||
"text": {
|
||||
"type": "string",
|
||||
"maxLength": 32768
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"PluginAgentHistoryResult": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"items"
|
||||
],
|
||||
"properties": {
|
||||
"items": {
|
||||
"type": "array",
|
||||
"maxItems": 200,
|
||||
"items": {
|
||||
"$ref": "#/$defs/PluginAgentMessage"
|
||||
}
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"PluginAgentEvent": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"required": [
|
||||
"kind",
|
||||
"text"
|
||||
],
|
||||
"properties": {
|
||||
"kind": {
|
||||
"const": "text"
|
||||
},
|
||||
"text": {
|
||||
"type": "string",
|
||||
"maxLength": 4096
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"required": [
|
||||
"kind",
|
||||
"capabilityId",
|
||||
"state"
|
||||
],
|
||||
"properties": {
|
||||
"kind": {
|
||||
"const": "activity"
|
||||
},
|
||||
"capabilityId": {
|
||||
"type": "string",
|
||||
"maxLength": 160
|
||||
},
|
||||
"state": {
|
||||
"enum": [
|
||||
"started",
|
||||
"completed",
|
||||
"denied"
|
||||
]
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"required": [
|
||||
"kind",
|
||||
"inputTokens",
|
||||
"outputTokens"
|
||||
],
|
||||
"properties": {
|
||||
"kind": {
|
||||
"const": "usage"
|
||||
},
|
||||
"inputTokens": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
},
|
||||
"outputTokens": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"required": [
|
||||
"kind",
|
||||
"reason"
|
||||
],
|
||||
"properties": {
|
||||
"kind": {
|
||||
"const": "end"
|
||||
},
|
||||
"reason": {
|
||||
"enum": [
|
||||
"completed",
|
||||
"cancelled",
|
||||
"unavailable",
|
||||
"budget-exceeded"
|
||||
]
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
"use strict";
|
||||
require("./agent/delegatedScope.test.cjs");
|
||||
require("./agent/conversationStore.test.cjs");
|
||||
require("./agent/contract.test.cjs");
|
||||
@@ -57,8 +57,7 @@ for (const source of ["compose", "share"]) test(`normal browser example complete
|
||||
const output = source === "compose" ? "synthetic output" : "SYNTHETIC PRIVATE TEXT";
|
||||
assert.deepEqual(result, { copied: true, characters: output.length });
|
||||
assert.deepEqual(copied, [output]); assert.equal(reads, source === "share" ? 1 : 0);
|
||||
assert.ok(requests.includes("ui.interact")); assert.ok(requests.includes("clipboard.write"));
|
||||
assert.equal(requests.includes("clipboard.read"), source === "share");
|
||||
assert.deepEqual(requests, [], "Allow mode has only the real choice/input workflows");
|
||||
assert.equal(JSON.stringify(audit).includes("synthetic private text"), false);
|
||||
assert.equal(JSON.stringify(audit).includes("synthetic output"), false);
|
||||
assert.equal(service.pending.size, 0);
|
||||
@@ -71,7 +70,7 @@ test("outer command deadline closes its real browser clipboard confirmation and
|
||||
const manifest = JSON.parse(await readFile(new URL("../../examples/plugins/host-interactions/netcatty.plugin.json", pathToFileURL(__filename)), "utf8"));
|
||||
const identity = { pluginId: manifest.id, pluginVersion: manifest.version, runtimeId: "runtime-deadline", runtimeKind: "browser" };
|
||||
const writes = [], messages = [];
|
||||
const database = { listPermissionGrants: () => [], recordSecurityAudit() {},
|
||||
const database = { getPermissionMode: () => "ask", listPermissionGrants: () => [], recordSecurityAudit() {},
|
||||
listPlugins: () => [{ id: manifest.id, activeVersion: manifest.version, manifest, enabled: true, runtime: { status: "running" } }],
|
||||
};
|
||||
const engine = new PluginPermissionEngine({ database, requestDecision: async (request) => ({ requestId: request.requestId, decision: "allow", scope: "once" }) });
|
||||
|
||||
@@ -37,7 +37,7 @@ function interactionAuthorization(params, terminalTarget) {
|
||||
if (params.kind === "terminal.selection.read" || params.kind === "terminal.input.propose") {
|
||||
if (!terminalTarget) throw fail("failedPrecondition", "A host-selected terminal is required");
|
||||
return { permission: params.kind, resources: [terminalTarget.resource],
|
||||
reason: params.kind === "terminal.selection.read" ? "Preview and explicitly share the selected terminal text" : "Open an editable host draft without sending terminal input", operationId: params.kind, allowedScopes: ["once"] };
|
||||
reason: params.kind === "terminal.selection.read" ? "Preview and explicitly share the selected terminal text" : "Open an editable host draft without sending terminal input", operationId: params.kind, allowedScopes: ["once"], deferDecision: true };
|
||||
}
|
||||
const permission = params.kind === "clipboard.read" ? "clipboard.read"
|
||||
: params.kind === "clipboard.write" ? "clipboard.write" : "ui.interact";
|
||||
@@ -46,6 +46,7 @@ function interactionAuthorization(params, terminalTarget) {
|
||||
resources: [permission.startsWith("clipboard.") ? "clipboard:text" : "*"],
|
||||
reason: permission.startsWith("clipboard.") ? "Explicitly share or replace clipboard text" : "Show a host input or choice dialog",
|
||||
operationId: `ui:${params.kind}`,
|
||||
deferDecision: true,
|
||||
...(permission.startsWith("clipboard.") ? { allowedScopes: ["once"] } : {}),
|
||||
};
|
||||
}
|
||||
@@ -174,6 +175,7 @@ class PluginHostInteractionService {
|
||||
requestId: entry.id, actionToken: entry.actionToken,
|
||||
pluginId: entry.context.pluginId, pluginVersion: entry.context.pluginVersion,
|
||||
expiresAt: entry.expiresAt, phase: entry.phase, request: entry.params,
|
||||
permissionMode: entry.permissionMode,
|
||||
...(entry.terminalTarget ? { terminalSessionId: entry.terminalTarget.sessionId, terminalRendererId: entry.terminalTarget.rendererId, terminalLabel: entry.terminalTarget.label } : {}),
|
||||
...(entry.previewText === undefined ? {} : { previewText: entry.previewText }),
|
||||
...(this.vaultService?.isRequest(entry.params) ? this.vaultService.describePrompt(entry) : {}),
|
||||
@@ -261,10 +263,25 @@ class PluginHostInteractionService {
|
||||
? this.terminalSelection.resolve(params.handle, context) : undefined);
|
||||
if (terminalTarget) this.terminalSelection.assertCurrent(terminalTarget);
|
||||
if (params.kind === "terminal.input.propose") this.terminalSelection.resolve(params.handle, context);
|
||||
const authorization = context.authorization ?? interactionAuthorization(params, terminalTarget);
|
||||
const permissionMode = this.permissionEngine.getMode?.(context, authorization.permission) ?? "ask";
|
||||
if (permissionMode === "deny") throw fail("permissionDenied", "Plugin permission is disabled");
|
||||
// These actions need no target/data-input UI. The main process still owns
|
||||
// admission, quotas, audit and precise operation/resource validation.
|
||||
if (permissionMode === "allow" && params.kind.startsWith("clipboard.")) {
|
||||
await context.assertActive(); this.#assertWindow(sender);
|
||||
const entry = { context, params };
|
||||
this.#audit(entry, "completed");
|
||||
if (params.kind === "clipboard.write") { this.clipboard.writeText(params.text); return { cancelled: false, value: true }; }
|
||||
const text = assertTextBytes(this.clipboard.readText());
|
||||
this.quotaManager.chargeBytes(context.runtimeId, "clipboard", Buffer.byteLength(text));
|
||||
await context.assertActive();
|
||||
return { cancelled: false, value: text };
|
||||
}
|
||||
const now = this.clock();
|
||||
return new Promise((resolve, reject) => {
|
||||
const entry = {
|
||||
id: randomUUID(), context, params, sender, resolve, reject, terminalTarget,
|
||||
id: randomUUID(), context, params, sender, resolve, reject, terminalTarget, permissionMode,
|
||||
expiresAt: now + this.timeoutMs, phase: "request", actionToken: null,
|
||||
};
|
||||
entry.onAbort = () => this.#finish(entry, null, fail("cancelled", "Host interaction was cancelled"));
|
||||
@@ -273,8 +290,14 @@ class PluginHostInteractionService {
|
||||
this.pending.set(entry.id, entry);
|
||||
context.signal?.addEventListener("abort", entry.onAbort, { once: true });
|
||||
if (context.signal?.aborted) { entry.onAbort(); return; }
|
||||
try { this.#present(entry); }
|
||||
catch { this.#finish(entry, null, fail("unavailable", "Host interaction window is unavailable")); }
|
||||
try {
|
||||
if (params.kind === "clipboard.read") {
|
||||
entry.previewText = assertTextBytes(this.clipboard.readText());
|
||||
this.quotaManager.chargeBytes(context.runtimeId, "clipboard", Buffer.byteLength(entry.previewText));
|
||||
entry.phase = "preview";
|
||||
}
|
||||
this.#present(entry);
|
||||
} catch { this.#finish(entry, null, fail("unavailable", "Host interaction window is unavailable")); }
|
||||
});
|
||||
}
|
||||
|
||||
@@ -324,6 +347,11 @@ class PluginHostInteractionService {
|
||||
if (!text.length) throw fail("failedPrecondition", "Select terminal text before requesting a share");
|
||||
this.#audit(entry, "preview-confirmed");
|
||||
this.quotaManager.chargeBytes(entry.context.runtimeId, "terminal", Buffer.byteLength(text));
|
||||
if (entry.permissionMode === "allow") {
|
||||
this.#audit(entry, "completed");
|
||||
return this.#finish(entry, { cancelled: false, value: Object.freeze({ text,
|
||||
...this.terminalSelection.issue(entry.terminalTarget, entry.context) }) });
|
||||
}
|
||||
entry.previewText = text; entry.phase = "preview"; this.#present(entry); return true;
|
||||
}
|
||||
if (entry.params.kind === "clipboard.read" && entry.phase === "request") {
|
||||
|
||||
@@ -81,7 +81,7 @@ test("old command completion cannot invalidate a replacement runtime's host-owne
|
||||
assert.deepEqual(await request, { cancelled: false, value: "synthetic new input" });
|
||||
});
|
||||
|
||||
test("Once permission dialog may restore the same owner's focus after its decision resolves", async (t) => {
|
||||
test("input workflow is shown directly without a separate capability prompt", async (t) => {
|
||||
const { PluginHostRpcRegistry } = require("./hostRpcRegistry.cjs");
|
||||
const { PluginPermissionEngine } = require("./permissionEngine.cjs");
|
||||
const f = fixture(t);
|
||||
@@ -104,13 +104,13 @@ test("Once permission dialog may restore the same owner's focus after its decisi
|
||||
}).requestHandlers["ui.interact"];
|
||||
const result = route({ kind: "input", title: "Synthetic input", initialValue: "visible" }, { signal: f.context.signal });
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
assert.equal(f.messages.length, 0, "no dialog is delivered while the owner is backgrounded");
|
||||
assert.equal(f.messages.filter((message) => !message.closed).length, 1);
|
||||
await new Promise((resolve) => setTimeout(resolve, 45));
|
||||
const prompt = f.messages.find((message) => !message.closed);
|
||||
assert.ok(prompt);
|
||||
await f.respond(prompt, "submit", "visible");
|
||||
assert.deepEqual(await result, { cancelled: false, value: "visible" });
|
||||
assert.equal(decisions, 1);
|
||||
assert.equal(decisions, 0);
|
||||
assert.equal(f.reads, 0); assert.deepEqual(f.writes, []);
|
||||
});
|
||||
|
||||
@@ -142,17 +142,12 @@ test("focus restoration is bounded and admission cannot survive owner or access
|
||||
await assert.rejects(background.service.request(assertInteractionRequest({ kind: "input", title: "Background" }), background.context), /focused unlocked/);
|
||||
});
|
||||
|
||||
test("clipboard read waits for preview action and a separate single-use sharing confirmation", async (t) => {
|
||||
test("Ask clipboard read has one scoped preview and a single-use sharing decision", async (t) => {
|
||||
const f = fixture(t);
|
||||
const request = await f.begin({ kind: "clipboard.read", title: "Share text" });
|
||||
let done = false; void request.promise.then(() => { done = true; });
|
||||
assert.equal(f.reads, 0);
|
||||
await f.respond(request.prompt, "preview");
|
||||
const preview = f.messages.filter((message) => !message.closed).at(-1);
|
||||
const preview = request.prompt;
|
||||
assert.equal(preview.phase, "preview"); assert.equal(preview.previewText, "synthetic clipboard");
|
||||
assert.notEqual(preview.actionToken, request.prompt.actionToken);
|
||||
assert.equal(f.reads, 1); assert.equal(done, false);
|
||||
assert.equal(await f.respond(request.prompt, "submit"), false, "old-phase action cannot share text");
|
||||
assert.equal(f.reads, 1);
|
||||
assert.equal(await f.respond(preview, "submit"), true);
|
||||
assert.deepEqual(await request.promise, { cancelled: false, value: "synthetic clipboard" });
|
||||
assert.equal(await f.respond(preview, "submit"), false, "no replay");
|
||||
@@ -200,9 +195,15 @@ test("wrong windows, forged tokens and stale runtimes cannot commit a native act
|
||||
});
|
||||
|
||||
test("quota, deadline, locked host, unknown fields, duplicate choices and text bounds fail closed", async (t) => {
|
||||
// Keep quota ownership independent from the short expiry timer. Under load,
|
||||
// the original 20ms request could expire before the quota assertion ran.
|
||||
const quota = fixture(t);
|
||||
const held = await quota.begin({ kind: "input", title: "Input" });
|
||||
await assert.rejects(quota.service.request(assertInteractionRequest({ kind: "input", title: "Another" }), quota.context), /limit/);
|
||||
await quota.respond(held.prompt, "cancel");
|
||||
await held.promise;
|
||||
const f = fixture(t, { timeoutMs: 20 });
|
||||
const request = await f.begin({ kind: "input", title: "Input" });
|
||||
await assert.rejects(f.service.request(assertInteractionRequest({ kind: "input", title: "Another" }), f.context), /limit/);
|
||||
const keepAlive = setTimeout(() => {}, 100);
|
||||
await assert.rejects(request.promise, /expired/); clearTimeout(keepAlive);
|
||||
f.lock();
|
||||
@@ -221,7 +222,7 @@ test("manifest permission checks and interaction quota run before any host promp
|
||||
const f = fixture(t);
|
||||
let decisions = 0;
|
||||
const engine = new PluginPermissionEngine({
|
||||
database: { listPermissionGrants: () => [], recordSecurityAudit() {} },
|
||||
database: { getPermissionMode: () => "deny", listPermissionGrants: () => [], recordSecurityAudit() {} },
|
||||
requestDecision: async (request) => { decisions++; return { requestId: request.requestId, decision: "deny" }; },
|
||||
});
|
||||
t.after(() => engine.shutdown());
|
||||
@@ -234,7 +235,7 @@ test("manifest permission checks and interaction quota run before any host promp
|
||||
assert.equal(decisions, 0); assert.equal(f.messages.length, 0);
|
||||
const route = registry.createRoutes({ ...base, manifest: { ...base.manifest, permissions: { optional: ["ui.interact"] } } }).requestHandlers["ui.interact"];
|
||||
for (let count = 1; count < 10; count++) {
|
||||
await assert.rejects(route({ kind: "input", title: "Denied" }, { signal: f.context.signal }), /denied/);
|
||||
await assert.rejects(route({ kind: "input", title: "Denied" }, { signal: f.context.signal }), /disabled/);
|
||||
}
|
||||
const before = decisions;
|
||||
await assert.rejects(route({ kind: "input", title: "Over quota" }, { signal: f.context.signal }), /limit/);
|
||||
@@ -270,7 +271,7 @@ test("same-document navigation keeps ownership, while document reload and render
|
||||
if (event === "reload") f.sender.emit("did-start-navigation", { isMainFrame: true, isSameDocument: false });
|
||||
else f.sender.emit("render-process-gone");
|
||||
await assert.rejects(request.promise, /window closed/);
|
||||
assert.equal(f.reads, 0); assert.equal(f.service.owner, null);
|
||||
assert.equal(f.reads, 1); assert.equal(f.service.owner, null);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -278,7 +279,6 @@ test("host lock subscription cancels previewed text before it can be shared", as
|
||||
let changed;
|
||||
const f = fixture(t, { subscribeLock(listener) { changed = listener; return () => {}; } });
|
||||
const request = await f.begin({ kind: "clipboard.read", title: "Share" });
|
||||
await f.respond(request.prompt, "preview");
|
||||
const preview = f.messages.filter((message) => !message.closed).at(-1);
|
||||
changed({ locked: true });
|
||||
await assert.rejects(request.promise, /cancelled/);
|
||||
|
||||
@@ -171,7 +171,15 @@ class PluginHostRpcRegistry {
|
||||
const middleware = [...this.middleware];
|
||||
const streamHandlers = [...this.incomingStreams];
|
||||
const createHandler = (kind, method, registration) => async (params, transportContext) => {
|
||||
const operationController = new AbortController();
|
||||
const signal = transportContext.signal ? AbortSignal.any([transportContext.signal, operationController.signal]) : operationController.signal;
|
||||
const authorizationGuards = [];
|
||||
// Host-only identity shared by internal derived contexts, never a wire
|
||||
// field or a plugin-provided token. Every RPC receives a fresh object.
|
||||
const permissionOperation = Object.freeze({});
|
||||
const assertActive = async () => {
|
||||
signal.throwIfAborted();
|
||||
for (const guard of authorizationGuards) guard();
|
||||
transportContext.signal?.throwIfAborted();
|
||||
await assertCurrent?.();
|
||||
transportContext.signal?.throwIfAborted();
|
||||
@@ -181,6 +189,7 @@ class PluginHostRpcRegistry {
|
||||
let auditResources;
|
||||
let auditResult;
|
||||
let auditError;
|
||||
const authorizationCleanup = [];
|
||||
try {
|
||||
await assertActive();
|
||||
const validatedParams = registration.validateParams
|
||||
@@ -192,6 +201,10 @@ class PluginHostRpcRegistry {
|
||||
const authorizationContext = Object.freeze({
|
||||
...transportContext,
|
||||
...identity,
|
||||
signal,
|
||||
permissionOperation,
|
||||
addAuthorizationGuard: (guard) => authorizationGuards.push(guard),
|
||||
cancelAuthorization: (reason) => operationController.abort(reason),
|
||||
assertActive,
|
||||
kind,
|
||||
method,
|
||||
@@ -201,6 +214,10 @@ class PluginHostRpcRegistry {
|
||||
},
|
||||
params: validatedParams,
|
||||
});
|
||||
for (const current of middleware) {
|
||||
const cleanup = current.prepareAuthorization?.(authorizationContext);
|
||||
if (typeof cleanup === "function") authorizationCleanup.push(cleanup);
|
||||
}
|
||||
const resolvedAuthorization = typeof registration.authorization === "function"
|
||||
? await registration.authorization(validatedParams, authorizationContext)
|
||||
: registration.authorization;
|
||||
@@ -230,6 +247,7 @@ class PluginHostRpcRegistry {
|
||||
auditError = error;
|
||||
throw error;
|
||||
} finally {
|
||||
for (const cleanup of authorizationCleanup) cleanup();
|
||||
// Never record params, handler results, error messages or plugin text.
|
||||
const dataBytes = (value) => {
|
||||
if (typeof value === "string") return Buffer.byteLength(value);
|
||||
|
||||
@@ -76,7 +76,8 @@ test("host RPC middleware receives immutable host identity, method metadata, and
|
||||
assert.equal(Object.isFrozen(seen[0].metadata.permission.resources), true);
|
||||
assert.deepEqual(seen[0].params, { hostId: "host-1" });
|
||||
assert.equal(seen[0].requestId, 7);
|
||||
assert.equal(seen[0].signal, signal);
|
||||
assert.equal(seen[0].signal.aborted, signal.aborted);
|
||||
assert.equal(typeof seen[0].addAuthorizationGuard, "function");
|
||||
assert.equal(typeof seen[0].assertActive, "function");
|
||||
});
|
||||
|
||||
|
||||
@@ -105,6 +105,8 @@ function createPluginHostService(options) {
|
||||
let byteStreamService;
|
||||
let aiContextService;
|
||||
let aiHostAdapter;
|
||||
let permissionDecisionHost;
|
||||
let manager;
|
||||
const logService = new PluginLogService({ paths, electron: options.electron, resolveProvenance: options.resolvePluginProvenance ?? createPluginLogProvenanceResolver(database) });
|
||||
try {
|
||||
const packageStore = new PackageStore({
|
||||
@@ -128,9 +130,16 @@ function createPluginHostService(options) {
|
||||
runtimeDirectory,
|
||||
moduleResources,
|
||||
});
|
||||
const { PluginPermissionDecisionHost } = require("./permissionDecisionHost.cjs");
|
||||
permissionDecisionHost = new PluginPermissionDecisionHost({ getWindows: options.getPermissionWindows, getWindow: options.getInteractionWindow,
|
||||
isLocked: options.isAppLocked, subscribeLock: options.subscribeAppLock });
|
||||
const permissionEngine = new PluginPermissionEngine({
|
||||
database,
|
||||
requestDecision: options.requestPermissionDecision,
|
||||
requestDecision: options.requestPermissionDecision ?? ((...args) => permissionDecisionHost.request(...args)),
|
||||
// Ambient Node authority cannot be revoked by broker guards. Stop the
|
||||
// privileged development activation through the existing manager queue.
|
||||
onPolicyChanged: (plugin, permission, mode) => permission === "runtime.advanced" && mode !== "allow"
|
||||
? manager.setEnabled(plugin.id, false, { expectedSourceOwner: database.privateOwner(plugin.id) }) : undefined,
|
||||
});
|
||||
const quotaManager = new PluginQuotaManager({
|
||||
getProcessMetrics: options.getProcessMetrics
|
||||
@@ -437,7 +446,7 @@ function createPluginHostService(options) {
|
||||
})
|
||||
: null;
|
||||
const themeService = new PluginThemeService({database,packageStore,contributionService});
|
||||
const manager = new PluginManager({
|
||||
manager = new PluginManager({
|
||||
secretStore,
|
||||
permissionEngine,
|
||||
database,
|
||||
@@ -456,6 +465,7 @@ function createPluginHostService(options) {
|
||||
await viewHost?.shutdown();
|
||||
await companionSupervisor.shutdown();
|
||||
leaseStore.shutdown();
|
||||
permissionDecisionHost.shutdown();
|
||||
permissionEngine.shutdown();
|
||||
quotaManager.shutdown();
|
||||
} finally {
|
||||
@@ -488,6 +498,7 @@ function createPluginHostService(options) {
|
||||
byteStreamService,
|
||||
aiContextService,
|
||||
permissionEngine,
|
||||
permissionDecisionHost,
|
||||
quotaManager,
|
||||
rpcRegistry,
|
||||
runtimeSupervisor,
|
||||
@@ -497,6 +508,7 @@ function createPluginHostService(options) {
|
||||
viewHost,
|
||||
};
|
||||
} catch (error) {
|
||||
try { permissionDecisionHost?.shutdown(); } catch {}
|
||||
try { interactionService?.shutdown(); } catch {}
|
||||
try { byteStreamService?.shutdown(); } catch {}
|
||||
try { aiContextService?.shutdown(); } catch {}
|
||||
|
||||
@@ -114,7 +114,7 @@ test("runtime cleanup revokes leases before awaiting companion teardown", async
|
||||
assert.equal(settled, true);
|
||||
});
|
||||
|
||||
test("runtime trust placement precedes required and advanced permission prompts", async (context) => {
|
||||
test("runtime trust placement and default Allow require no install permission ceremony", async (context) => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-plugin-host-service-"));
|
||||
context.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const events = [];
|
||||
@@ -146,11 +146,7 @@ test("runtime trust placement precedes required and advanced permission prompts"
|
||||
securityPrincipal: "verified:test-publisher-key",
|
||||
signal: new AbortController().signal,
|
||||
}), "utility");
|
||||
assert.deepEqual(events, [
|
||||
"placement",
|
||||
"permission:storage",
|
||||
"permission:runtime.advanced",
|
||||
]);
|
||||
assert.deepEqual(events, ["placement"]);
|
||||
});
|
||||
|
||||
test("first-party placement selects the utility runtime for companion manifests", async (context) => {
|
||||
@@ -198,7 +194,7 @@ test("first-party placement selects the utility runtime for companion manifests"
|
||||
securityPrincipal: "unsigned-package:companion-placement",
|
||||
signal: new AbortController().signal,
|
||||
}), "utility");
|
||||
assert.deepEqual(requested, ["companion.execute", "runtime.advanced"]);
|
||||
assert.deepEqual(requested, []);
|
||||
});
|
||||
|
||||
test("first-party placement selects utility for dual-entrypoint terminal interceptors", async (context) => {
|
||||
@@ -238,15 +234,7 @@ test("first-party placement selects utility for dual-entrypoint terminal interce
|
||||
securityPrincipal: "unsigned-package:interceptor-placement",
|
||||
signal: new AbortController().signal,
|
||||
}), "utility");
|
||||
assert.deepEqual(requested.map((request) => request.permission), [
|
||||
"provider.terminal",
|
||||
"terminal.intercept.input",
|
||||
"runtime.advanced",
|
||||
]);
|
||||
assert.match(
|
||||
requested.find((request) => request.permission === "terminal.intercept.input")?.reason ?? "",
|
||||
/arbitrary no-echo input may not be detectable/u,
|
||||
);
|
||||
assert.deepEqual(requested, []);
|
||||
});
|
||||
|
||||
test("secure host methods fail closed without an approver while public logging remains available", async (context) => {
|
||||
@@ -254,6 +242,7 @@ test("secure host methods fail closed without an approver while public logging r
|
||||
context.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const service = createPluginHostService(createOptions(root));
|
||||
context.after(() => service.manager.shutdown());
|
||||
service.database.getPermissionMode = () => "ask";
|
||||
const routes = service.rpcRegistry.createRoutes({
|
||||
pluginId: "com.example.service",
|
||||
pluginVersion: "1.0.0",
|
||||
@@ -268,7 +257,7 @@ test("secure host methods fail closed without an approver while public logging r
|
||||
});
|
||||
await assert.rejects(
|
||||
routes.requestHandlers["storage.set"]({ key: "answer", value: 42 }, transportContext()),
|
||||
/not granted/,
|
||||
/decision provider failed/,
|
||||
);
|
||||
await routes.notificationHandlers["log.write"]({ level: "info", message: "hello" }, transportContext());
|
||||
assert.equal(service.database.getValue("com.example.service", "answer"), undefined);
|
||||
@@ -303,6 +292,7 @@ test("approved host capabilities reuse application grants through the registry",
|
||||
archiveSha256: "a".repeat(64),
|
||||
packageRelativePath: "com.example.service/1.0.0/package",
|
||||
});
|
||||
service.permissionEngine.setMode({ id: pluginManifest.id, manifest: pluginManifest }, "storage", "ask");
|
||||
const routes = service.rpcRegistry.createRoutes({
|
||||
pluginId: "com.example.service",
|
||||
pluginVersion: "1.0.0",
|
||||
@@ -456,28 +446,38 @@ test("logging always drains before database close when another shutdown stage re
|
||||
assert.equal(record.operation, "synthetic.before-shutdown");
|
||||
});
|
||||
|
||||
test("activation permission denial is audited before a runtime logger exists", async (t) => {
|
||||
const { defaultSecurityPrincipal } = require("./permissionResources.cjs");
|
||||
const { RPC_ERRORS } = require("./rpcRouter.cjs");
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-synthetic-activation-log-"));
|
||||
const host = createPluginHostService(createOptions(root));
|
||||
test("required browser capabilities activate without a permission ceremony", async (t) => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-synthetic-activation-"));
|
||||
let prompts = 0;
|
||||
const host = createPluginHostService({ ...createOptions(root), requestPermissionDecision: () => { prompts++; throw new Error("Unexpected install prompt"); } });
|
||||
t.after(async () => { await host.manager.shutdown(); fs.rmSync(root, { recursive: true, force: true }); });
|
||||
const manifest = { manifestVersion: 1, id: "com.example.synthetic-activation", version: "1.0.0", name: "Synthetic", publisher: "example", engines: { netcatty: ">=0.0.0", api: ">=0.1.0-internal <0.2.0" }, main: { browser: "index.js" }, permissions: { required: ["commands"] } };
|
||||
const archiveSha256 = "a".repeat(64);
|
||||
host.database.installVersion({ pluginId: manifest.id, version: manifest.version, manifest, archiveSha256, packageRelativePath: `${manifest.id}/${manifest.version}/package` }, { enable: true });
|
||||
await host.logService.ready;
|
||||
await host.logService.configure({ saveToDisk: true, verbose: false });
|
||||
await assert.rejects(host.runtimeSupervisor.resolveRuntimeKind({
|
||||
plugin: host.database.getActivePlugin(manifest.id), availableKinds: ["browser"], signal: new AbortController().signal,
|
||||
securityPrincipal: defaultSecurityPrincipal(manifest, archiveSha256),
|
||||
}), { code: RPC_ERRORS.permissionDenied });
|
||||
await host.logService.sink.flush();
|
||||
const record = JSON.parse(await fsp.readFile(host.logService.sink.file(0), "utf8"));
|
||||
assert.equal(record.operation, "runtime.authorization");
|
||||
assert.equal(record.authorization, "denied");
|
||||
assert.equal(record.runtimeId, null);
|
||||
assert.equal(record.errorCode, RPC_ERRORS.permissionDenied);
|
||||
assert.equal(record.archiveSha256, archiveSha256);
|
||||
assert.equal(Object.hasOwn(record, "message"), false);
|
||||
assert.equal(host.runtimeSupervisor.getRuntimeIdentity(manifest.id), null);
|
||||
const manifest = { id: "com.example.synthetic-activation", version: "1.0.0", publisher: "example", main: { browser: "index.js" }, permissions: { required: ["commands"] } };
|
||||
assert.equal(await host.runtimeSupervisor.resolveRuntimeKind({ plugin: { id: manifest.id, activeVersion: manifest.version, manifest }, availableKinds: ["browser"], signal: new AbortController().signal }), "browser");
|
||||
assert.equal(prompts, 0);
|
||||
});
|
||||
|
||||
test("Deny for the privileged development runtime stops its activation through the manager queue", async (t) => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-advanced-policy-"));
|
||||
const service = createPluginHostService(createOptions(root));
|
||||
t.after(async () => { await service.manager.shutdown(); fs.rmSync(root, { recursive: true, force: true }); });
|
||||
await service.manager.initialize();
|
||||
const manifest = { id: "com.example.advanced-policy", version: "1.0.0", publisher: "example", main: { node: "index.js" }, permissions: { required: ["runtime.advanced"] } };
|
||||
service.database.installVersion({ pluginId: manifest.id, version: manifest.version, manifest, archiveSha256: "a".repeat(64), packageRelativePath: "synthetic/package" }, { enable: true });
|
||||
const identity = { pluginId: manifest.id, pluginVersion: manifest.version, runtimeId: "synthetic-utility", runtimeKind: "utility" };
|
||||
let current = identity, stops = 0;
|
||||
service.runtimeSupervisor.getRuntimeIdentity = () => current;
|
||||
service.runtimeSupervisor.stop = async (pluginId) => { assert.equal(pluginId, manifest.id); stops++; current = null; };
|
||||
await service.permissionEngine.setMode(service.database.getActivePlugin(manifest.id), "runtime.advanced", "deny");
|
||||
assert.equal(stops, 1); assert.equal(current, null);
|
||||
assert.equal(service.database.getActivePlugin(manifest.id).enabled, false);
|
||||
assert.equal(service.database.getPermissionMode(manifest.id, "runtime.advanced"), "deny");
|
||||
await assert.rejects(service.permissionEngine.authorize({ ...identity, manifest }, { permission: "runtime.advanced" }), /disabled/);
|
||||
// A queued teardown for the old source must not disable its replacement.
|
||||
service.database.setEnabled(manifest.id, true); current = identity;
|
||||
const privateOwner = service.database.privateOwner.bind(service.database);
|
||||
const pending = service.permissionEngine.setMode(service.database.getActivePlugin(manifest.id), "runtime.advanced", "deny");
|
||||
service.database.privateOwner = () => "synthetic-replacement-owner";
|
||||
await assert.rejects(pending, /source changed/);
|
||||
assert.equal(stops, 1); assert.equal(current, identity); assert.equal(service.database.getActivePlugin(manifest.id).enabled, true);
|
||||
service.database.privateOwner = privateOwner;
|
||||
});
|
||||
@@ -47,7 +47,7 @@ class PluginHostVaultService {
|
||||
this.admissions.set(params, binding);
|
||||
return {
|
||||
permission: method === "vault.requestHostDraft" ? "vault.write" : method === "vault.selectHosts" ? "vault.metadata" : "ui.interact",
|
||||
resources: ["*"], allowedScopes: ["once"],
|
||||
resources: ["*"], allowedScopes: ["once"], deferDecision: true,
|
||||
reason: method === "vault.selectHosts" ? "Choose the current host metadata to share" : method === "vault.requestHostDraft" ? "Review a host draft before saving" : "Review a saved host connection and its script disclosure",
|
||||
operationId: method,
|
||||
};
|
||||
@@ -61,6 +61,7 @@ class PluginHostVaultService {
|
||||
if (this.admissions.get(params) !== binding) throw fail("permissionDenied", "Host action admission is unavailable");
|
||||
this.admissions.delete(params);
|
||||
const assertActive = async () => {
|
||||
await originalContext.assertActive();
|
||||
if (await this.sourceBinding.resolve(originalContext) !== binding) throw fail("permissionDenied", "Host action source changed");
|
||||
if (params.target) this.#resolveTarget(params.target, params.expectedRevision, binding);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
"use strict";
|
||||
|
||||
const { randomBytes, timingSafeEqual } = require("node:crypto");
|
||||
const { permissionDenied } = require("./permissionEngine.cjs");
|
||||
const { describePluginPermission } = require("../../domain/pluginPermissionDescriptions.cjs");
|
||||
const { escapePermissionText } = require("./nativePermissionDecision.cjs");
|
||||
const CHANNEL = "netcatty:plugins:permission-prompt";
|
||||
|
||||
/** Ask belongs to a ready, focused managed app renderer. Plugin views never
|
||||
* own this link. Each token and cancellation lifecycle stays with its window. */
|
||||
class PluginPermissionDecisionHost {
|
||||
constructor(options) {
|
||||
this.getWindows = options.getWindows ?? (() => [options.getWindow?.()].filter(Boolean));
|
||||
this.isLocked = options.isLocked ?? (() => true);
|
||||
this.pending = new Map();
|
||||
this.owners = new Map();
|
||||
this.closed = false;
|
||||
this.unsubscribeLock = options.subscribeLock?.((state) => { if (state.locked) this.cancelAll(); });
|
||||
}
|
||||
|
||||
setReady(sender, ready) {
|
||||
if (this.closed) throw permissionDenied("Permission UI is closed");
|
||||
const window = this.getWindows().find((candidate) => candidate?.webContents === sender);
|
||||
if (!window || window.isDestroyed?.() || sender.isDestroyed?.() || typeof ready !== "boolean") {
|
||||
throw permissionDenied("Permission UI sender is invalid");
|
||||
}
|
||||
this.releaseOwner(sender);
|
||||
if (ready) {
|
||||
const cancel = () => this.cancelOwner(sender);
|
||||
const release = () => this.releaseOwner(sender);
|
||||
const navigate = (details, _url, inPlace, mainFrame) => {
|
||||
if ((details?.isMainFrame ?? mainFrame) !== false && (details?.isSameDocument ?? inPlace) !== true) release();
|
||||
};
|
||||
sender.on?.("destroyed", release); sender.on?.("render-process-gone", release); sender.on?.("did-start-navigation", navigate);
|
||||
window.on?.("hide", cancel); window.on?.("blur", cancel); window.on?.("closed", release);
|
||||
this.owners.set(sender, { window, cleanup: () => {
|
||||
sender.removeListener?.("destroyed", release); sender.removeListener?.("render-process-gone", release); sender.removeListener?.("did-start-navigation", navigate);
|
||||
window.removeListener?.("hide", cancel); window.removeListener?.("blur", cancel); window.removeListener?.("closed", release);
|
||||
} });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
get owner() {
|
||||
return this.getWindows().find((window) => this.owners.has(window?.webContents)
|
||||
&& !window.isDestroyed?.() && window.isFocused?.() && window.isVisible?.())?.webContents ?? null;
|
||||
}
|
||||
|
||||
assertOwner(sender = this.owner) {
|
||||
const window = this.owners.get(sender)?.window;
|
||||
if (this.closed || !sender || !window || !this.getWindows().includes(window) || window.webContents !== sender || sender.isDestroyed?.()
|
||||
|| window.isDestroyed?.() || !window.isFocused?.() || !window.isVisible?.() || this.isLocked()) {
|
||||
throw permissionDenied("Permission UI requires a focused unlocked app window");
|
||||
}
|
||||
}
|
||||
|
||||
request(request, { signal } = {}) {
|
||||
signal?.throwIfAborted(); this.assertOwner();
|
||||
if (!describePluginPermission(request.permission).known || this.pending.size >= 16) {
|
||||
throw permissionDenied("Permission prompt is unavailable");
|
||||
}
|
||||
const sender = this.owner;
|
||||
return new Promise((resolve, reject) => {
|
||||
const actionToken = randomBytes(32).toString("base64url");
|
||||
const finish = (decision, error) => {
|
||||
if (!this.pending.delete(request.requestId)) return;
|
||||
signal?.removeEventListener("abort", abort);
|
||||
try { sender.send(CHANNEL, { requestId: request.requestId, closed: true }); } catch {}
|
||||
if (error) reject(error); else resolve({ requestId: request.requestId, decision, ...(decision === "allow" ? { scope: request.allowedScopes && !request.allowedScopes.includes("once") && request.sessionId && request.allowedScopes.includes("session") ? "session" : "once" } : {}) });
|
||||
};
|
||||
const abort = () => finish(null, permissionDenied("Permission prompt was cancelled"));
|
||||
this.pending.set(request.requestId, { sender, actionToken, finish });
|
||||
signal?.addEventListener("abort", abort, { once: true });
|
||||
if (signal?.aborted) { abort(); return; }
|
||||
try {
|
||||
sender.send(CHANNEL, { ...request, actionToken,
|
||||
...(request.pluginName ? { pluginName: escapePermissionText(request.pluginName) } : {}),
|
||||
resources: (request.resources ?? []).map((resource) => escapePermissionText(resource)),
|
||||
...(request.displayContext ? { displayContext: {
|
||||
...(request.displayContext.modelLabel ? { modelLabel: escapePermissionText(request.displayContext.modelLabel) } : {}),
|
||||
selections: (request.displayContext.selections ?? []).map((item) => ({
|
||||
scope: item.scope, title: escapePermissionText(item.title), version: escapePermissionText(item.version),
|
||||
})),
|
||||
} } : {}),
|
||||
...(request.relatedPermissions ? { relatedPermissions: request.relatedPermissions.map((item) => ({
|
||||
permission: item.permission, resources: item.resources.map((resource) => escapePermissionText(resource)),
|
||||
})) } : {}),
|
||||
});
|
||||
} catch { abort(); }
|
||||
});
|
||||
}
|
||||
|
||||
respond(sender, response) {
|
||||
this.assertOwner(sender);
|
||||
if (!response || Object.keys(response).some((key) => !["requestId", "actionToken", "decision"].includes(key))
|
||||
|| !["allow", "deny", "cancel"].includes(response.decision) || typeof response.actionToken !== "string") {
|
||||
throw permissionDenied("Permission response is invalid");
|
||||
}
|
||||
const entry = this.pending.get(response.requestId);
|
||||
if (!entry || entry.sender !== sender) return false;
|
||||
const expected = Buffer.from(entry.actionToken); const actual = Buffer.from(response.actionToken);
|
||||
if (expected.length !== actual.length || !timingSafeEqual(expected, actual)) return false;
|
||||
entry.actionToken = "";
|
||||
entry.finish(response.decision);
|
||||
return true;
|
||||
}
|
||||
|
||||
cancelOwner(sender) { for (const entry of [...this.pending.values()]) if (entry.sender === sender) entry.finish("cancel"); }
|
||||
releaseOwner(sender) { this.cancelOwner(sender); this.owners.get(sender)?.cleanup(); this.owners.delete(sender); }
|
||||
cancelAll() { for (const entry of [...this.pending.values()]) entry.finish("cancel"); }
|
||||
shutdown() { if (this.closed) return; this.closed = true; for (const sender of [...this.owners.keys()]) this.releaseOwner(sender); this.unsubscribeLock?.(); }
|
||||
}
|
||||
module.exports = { PluginPermissionDecisionHost, PLUGIN_PERMISSION_PROMPT_CHANNEL: CHANNEL };
|
||||
@@ -0,0 +1,52 @@
|
||||
"use strict";
|
||||
const assert = require("node:assert/strict");
|
||||
const test = require("node:test");
|
||||
const { EventEmitter } = require("node:events");
|
||||
const { PluginPermissionDecisionHost } = require("./permissionDecisionHost.cjs");
|
||||
function fixture(t) {
|
||||
const messages = [];
|
||||
let locked = false;
|
||||
const sender = Object.assign(new EventEmitter(), { id: 1, isDestroyed: () => false, send: (_channel, event) => messages.push(event) });
|
||||
const window = Object.assign(new EventEmitter(), { webContents: sender, isVisible: () => true, isFocused: () => true, isDestroyed: () => false });
|
||||
const host = new PluginPermissionDecisionHost({ getWindow: () => window, isLocked: () => locked });
|
||||
host.setReady(sender, true); t.after(() => host.shutdown());
|
||||
const request = { requestId: "host-request", pluginId: "com.example.ask", permission: "network", resources: ["https://example.com"], reason: "Fixed host reason" };
|
||||
return { host, window, sender, messages, request, lock: () => { locked = true; } };
|
||||
}
|
||||
test("in-app Ask is owned by the main window with one scoped, single-use response token", async (t) => {
|
||||
const f = fixture(t); const pending = f.host.request(f.request);
|
||||
const prompt = f.messages[0];
|
||||
assert.throws(() => f.host.respond({ id: 2 }, { requestId: prompt.requestId, actionToken: prompt.actionToken, decision: "allow" }), /focused unlocked/);
|
||||
assert.equal(f.host.respond(f.sender, { requestId: prompt.requestId, actionToken: "forged", decision: "allow" }), false);
|
||||
const response = { requestId: prompt.requestId, actionToken: prompt.actionToken, decision: "allow" };
|
||||
assert.equal(f.host.respond(f.sender, response), true);
|
||||
assert.deepEqual(await pending, { requestId: prompt.requestId, decision: "allow", scope: "once" });
|
||||
assert.equal(f.host.respond(f.sender, response), false);
|
||||
});
|
||||
for (const lifecycle of ["abort", "hide", "blur", "reload", "crash", "unmount"]) test(`Ask ${lifecycle} closes pending authority and stale replies fail`, async (t) => {
|
||||
const f = fixture(t); const controller = new AbortController();
|
||||
const pending = f.host.request(f.request, { signal: controller.signal });
|
||||
const prompt = f.messages[0]; const outcome = pending.catch(() => ({ decision: "cancel" }));
|
||||
if (lifecycle === "abort") controller.abort();
|
||||
if (lifecycle === "hide" || lifecycle === "blur") f.window.emit(lifecycle);
|
||||
if (lifecycle === "reload") f.sender.emit("did-start-navigation", { isMainFrame: true, isSameDocument: false });
|
||||
if (lifecycle === "crash") f.sender.emit("render-process-gone");
|
||||
if (lifecycle === "unmount") f.host.setReady(f.sender, false);
|
||||
assert.equal((await outcome).decision, "cancel"); assert.equal(f.host.pending.size, 0);
|
||||
assert.equal(f.messages.at(-1).closed, true);
|
||||
const respond = () => f.host.respond(f.sender, { requestId: prompt.requestId, actionToken: prompt.actionToken, decision: "allow" });
|
||||
if (f.host.owner) assert.equal(respond(), false); else assert.throws(respond);
|
||||
});
|
||||
test("unsupported permission and locked host cannot open an Ask dialog", (t) => {
|
||||
const f = fixture(t);
|
||||
assert.throws(() => f.host.request({ ...f.request, permission: "unknown" }), /unavailable/);
|
||||
f.lock(); assert.throws(() => f.host.request(f.request), /focused unlocked/);
|
||||
assert.equal(f.messages.length, 0);
|
||||
});
|
||||
|
||||
test("shutdown cannot be reopened by a late host-ready message", (t) => {
|
||||
const f = fixture(t); f.host.shutdown();
|
||||
assert.throws(() => f.host.setReady(f.sender, true), /closed/);
|
||||
assert.throws(() => f.host.request(f.request), /focused unlocked/);
|
||||
assert.equal(f.messages.length, 0);
|
||||
});
|
||||
@@ -156,6 +156,9 @@ function immutableRequest(request) {
|
||||
...request,
|
||||
resources: Object.freeze([...request.resources]),
|
||||
resourceKinds: Object.freeze([...request.resourceKinds]),
|
||||
...(request.relatedPermissions ? { relatedPermissions: Object.freeze(request.relatedPermissions.map((item) => Object.freeze({
|
||||
...item, resources: Object.freeze([...item.resources]),
|
||||
}))) } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -163,6 +166,7 @@ class PluginPermissionEngine {
|
||||
constructor(options) {
|
||||
this.database = options.database;
|
||||
this.requestDecision = options.requestDecision ?? null;
|
||||
this.onPolicyChanged = options.onPolicyChanged ?? null;
|
||||
if (this.requestDecision != null && typeof this.requestDecision !== "function") {
|
||||
throw new TypeError("Plugin permission decision provider must be a function");
|
||||
}
|
||||
@@ -174,6 +178,9 @@ class PluginPermissionEngine {
|
||||
this.sessionPromptControllers = new Map();
|
||||
this.promptControllers = new Set();
|
||||
this.revocationListeners = new Set();
|
||||
this.policyRevisions = new Map();
|
||||
this.policyInstanceId = randomUUID();
|
||||
this.operationDecisions = new WeakMap();
|
||||
this.closed = false;
|
||||
this.shutdownController = new AbortController();
|
||||
}
|
||||
@@ -185,6 +192,7 @@ class PluginPermissionEngine {
|
||||
}
|
||||
|
||||
#emitRevocation(event) {
|
||||
this.policyRevisions.set(event.pluginId, (this.policyRevisions.get(event.pluginId) ?? 0) + 1);
|
||||
const frozen = Object.freeze({ ...event });
|
||||
for (const listener of this.revocationListeners) {
|
||||
try { listener(frozen); } catch {}
|
||||
@@ -322,6 +330,40 @@ class PluginPermissionEngine {
|
||||
));
|
||||
}
|
||||
|
||||
getMode(context, permission) {
|
||||
assertPluginPermission(permission);
|
||||
if (context.sourceOwner && this.database.privateOwner(context.pluginId) !== context.sourceOwner) {
|
||||
throw permissionDenied("Plugin permission source changed");
|
||||
}
|
||||
return this.database.getPermissionMode?.(context.pluginId, permission) ?? "allow";
|
||||
}
|
||||
|
||||
getPolicyRevision(plugin) {
|
||||
return createHash("sha256").update(JSON.stringify([
|
||||
this.policyInstanceId, this.database.privateOwner?.(plugin.id) ?? plugin.id,
|
||||
plugin.activeVersion ?? null, this.policyRevisions.get(plugin.id) ?? 0,
|
||||
])).digest("hex");
|
||||
}
|
||||
|
||||
describePolicies(plugin) {
|
||||
return [...normalizePermissionDeclarations(plugin.manifest).values()].map((declaration) => ({
|
||||
permission: declaration.permission,
|
||||
resources: declaration.resources.length ? [...declaration.resources] : ["*"],
|
||||
mode: this.getMode({ pluginId: plugin.id }, declaration.permission),
|
||||
}));
|
||||
}
|
||||
|
||||
setMode(plugin, permission, mode) {
|
||||
assertPluginPermission(permission);
|
||||
if (!["allow", "ask", "deny"].includes(mode)) throw new TypeError("Plugin permission mode is invalid");
|
||||
if (!normalizePermissionDeclarations(plugin.manifest).has(permission)) throw permissionDenied("Permission is undeclared");
|
||||
this.database.setPermissionMode(plugin.id, permission, mode);
|
||||
// Clear every old grant and invalidate pending/in-flight work before the
|
||||
// next operation. A later Allow is a new operation, never a revived prompt.
|
||||
this.revokeAll(plugin.id);
|
||||
return this.onPolicyChanged?.(plugin, permission, mode);
|
||||
}
|
||||
|
||||
async authorize(context, descriptor) {
|
||||
if (this.closed) throw permissionDenied("Plugin permission engine is unavailable");
|
||||
context.signal?.throwIfAborted();
|
||||
@@ -356,8 +398,43 @@ class PluginPermissionEngine {
|
||||
);
|
||||
const resources = resourceDescriptors.map(({ resource }) => resource);
|
||||
const resourceKinds = resourceDescriptors.map(({ resourceKind }) => resourceKind);
|
||||
const { declaration, declarationHash } = this.#assertDeclared(context, permission, resources);
|
||||
if (this.#hasAllGrants(
|
||||
const { declaration, declarationHash, securityPrincipal } = this.#assertDeclared(context, permission, resources);
|
||||
const revision = this.policyRevisions.get(context.pluginId) ?? 0;
|
||||
const mode = this.getMode(context, permission);
|
||||
if (mode === "deny") {
|
||||
this.database.recordSecurityAudit(context.pluginId, "permission.denied", { permission, resources, mode });
|
||||
throw permissionDenied(`Plugin permission is disabled: ${permission}`);
|
||||
}
|
||||
if (descriptor.relatedPermissions !== undefined && (!Array.isArray(descriptor.relatedPermissions) || descriptor.relatedPermissions.length > 5)) {
|
||||
throw permissionDenied("Related permission scope is invalid");
|
||||
}
|
||||
const related = (descriptor.relatedPermissions ?? []).map((item) => {
|
||||
const relatedResources = normalizePermissionResources(item.permission, item.resources);
|
||||
this.#assertDeclared(context, item.permission, relatedResources);
|
||||
const relatedMode = this.getMode(context, item.permission);
|
||||
if (relatedMode === "deny") {
|
||||
this.database.recordSecurityAudit(context.pluginId, "permission.denied", { permission: item.permission, mode: relatedMode });
|
||||
throw permissionDenied(`Plugin permission is disabled: ${item.permission}`);
|
||||
}
|
||||
return { permission: item.permission, resources: relatedResources, mode: relatedMode };
|
||||
});
|
||||
const relatedAsk = related.some((item) => item.mode === "ask");
|
||||
const operation = context.permissionOperation ?? context;
|
||||
const receipt = this.operationDecisions.get(operation);
|
||||
const sourceOwner = context.sourceOwner ?? this.database.privateOwner?.(context.pluginId) ?? null;
|
||||
const operationApproved = receipt?.revision === revision
|
||||
&& receipt.pluginId === context.pluginId && receipt.runtimeId === (context.runtimeId ?? null)
|
||||
&& receipt.securityPrincipal === securityPrincipal && receipt.sourceOwner === sourceOwner
|
||||
&& receipt.permissions.some((item) => (
|
||||
item.permission === permission && resources.every((resource) => item.resources.includes(resource))
|
||||
));
|
||||
if ((mode === "allow" && !relatedAsk) || descriptor.deferDecision === true || operationApproved) {
|
||||
this.database.recordSecurityAudit(context.pluginId, "permission.used", { permission, resources, mode,
|
||||
runtimeId: context.runtimeId ?? null });
|
||||
descriptor.validateBeforeGrant?.();
|
||||
return Object.freeze({ declaration, resources, scope: "policy", mode });
|
||||
}
|
||||
if (!relatedAsk && this.#hasAllGrants(
|
||||
context,
|
||||
permission,
|
||||
declarationHash,
|
||||
@@ -397,6 +474,7 @@ class PluginPermissionEngine {
|
||||
descriptor.operationId ?? null,
|
||||
descriptor.sessionId ?? null,
|
||||
descriptor.allowedScopes ?? null,
|
||||
related,
|
||||
]);
|
||||
let pending = this.pending.get(pendingKey);
|
||||
let ownsPrompt = false;
|
||||
@@ -404,7 +482,7 @@ class PluginPermissionEngine {
|
||||
ownsPrompt = true;
|
||||
pending = this.#requestGrant(
|
||||
context,
|
||||
descriptor,
|
||||
{ ...descriptor, relatedPermissions: related },
|
||||
resources,
|
||||
resourceKinds,
|
||||
declaration,
|
||||
@@ -415,9 +493,16 @@ class PluginPermissionEngine {
|
||||
}
|
||||
const grant = await pending;
|
||||
context.signal?.throwIfAborted();
|
||||
if ((this.policyRevisions.get(context.pluginId) ?? 0) !== revision || this.getMode(context, permission) !== mode) {
|
||||
throw permissionDenied("Plugin permission policy changed");
|
||||
}
|
||||
if (!ownsPrompt && grant.scope === "once") {
|
||||
return this.authorize(context, descriptor);
|
||||
}
|
||||
if (related.length || context.permissionOperation) this.operationDecisions.set(operation, {
|
||||
revision, pluginId: context.pluginId, runtimeId: context.runtimeId ?? null, securityPrincipal, sourceOwner,
|
||||
permissions: [{ permission, resources }, ...related],
|
||||
});
|
||||
return Object.freeze({ declaration, resources, scope: grant.scope });
|
||||
}
|
||||
|
||||
@@ -441,6 +526,8 @@ class PluginPermissionEngine {
|
||||
permission: descriptor.permission,
|
||||
resources,
|
||||
resourceKinds,
|
||||
...(descriptor.displayContext ? { displayContext: descriptor.displayContext } : {}),
|
||||
...(descriptor.relatedPermissions?.length ? { relatedPermissions: descriptor.relatedPermissions.map(({ permission, resources }) => ({ permission, resources })) } : {}),
|
||||
reason: descriptor.reason,
|
||||
...(descriptor.operationId === undefined ? {} : { operationId: descriptor.operationId }),
|
||||
...(descriptor.sessionId === undefined ? {} : { sessionId: descriptor.sessionId }),
|
||||
@@ -611,34 +698,12 @@ class PluginPermissionEngine {
|
||||
}
|
||||
|
||||
async authorizeRequired(plugin, options = {}) {
|
||||
const declarations = normalizePermissionDeclarations(plugin.manifest);
|
||||
const skipPermissions = new Set(options.skipPermissions ?? []);
|
||||
const context = {
|
||||
pluginId: plugin.id,
|
||||
pluginVersion: plugin.activeVersion,
|
||||
runtimeId: options.runtimeId ?? null,
|
||||
manifest: plugin.manifest,
|
||||
securityPrincipal: options.securityPrincipal,
|
||||
signal: options.signal,
|
||||
};
|
||||
for (const declaration of declarations.values()) {
|
||||
if (!declaration.required || skipPermissions.has(declaration.permission)) continue;
|
||||
await this.authorize(context, {
|
||||
permission: declaration.permission,
|
||||
resources: declaration.resources.length ? declaration.resources : ["*"],
|
||||
...(declaration.resources.length
|
||||
&& (declaration.permission === "filesystem.read"
|
||||
|| declaration.permission === "filesystem.write")
|
||||
? { resourceKinds: declaration.resources.map(() => "directory") }
|
||||
: {}),
|
||||
reason: declaration.reason || `Required by ${plugin.id}`,
|
||||
operationId: `activation:${plugin.activeVersion}`,
|
||||
});
|
||||
}
|
||||
options.signal?.throwIfAborted();
|
||||
normalizePermissionDeclarations(plugin.manifest);
|
||||
}
|
||||
|
||||
createMiddleware() {
|
||||
return async (context, next) => {
|
||||
const middleware = async (context, next) => {
|
||||
if (context.metadata.public === true) {
|
||||
context.recordAuthorization?.("not-required");
|
||||
return next();
|
||||
@@ -649,13 +714,32 @@ class PluginPermissionEngine {
|
||||
}
|
||||
try {
|
||||
await this.authorize(context, context.authorization);
|
||||
await context.assertActive();
|
||||
context.recordAuthorization?.("allowed");
|
||||
const result = await next();
|
||||
await context.assertActive();
|
||||
return result;
|
||||
} catch (error) {
|
||||
if (error?.code === RPC_ERRORS.permissionDenied) context.recordAuthorization?.("denied");
|
||||
throw error;
|
||||
}
|
||||
context.recordAuthorization?.("allowed");
|
||||
return next();
|
||||
};
|
||||
middleware.prepareAuthorization = (context) => {
|
||||
if (context.metadata.public === true) return undefined;
|
||||
const revision = this.policyRevisions.get(context.pluginId) ?? 0;
|
||||
const owner = this.database.privateOwner?.(context.pluginId);
|
||||
const subscription = this.onDidRevoke((event) => {
|
||||
if (event.pluginId === context.pluginId) context.cancelAuthorization?.(permissionDenied("Plugin access changed"));
|
||||
});
|
||||
context.addAuthorizationGuard?.(() => {
|
||||
if ((this.policyRevisions.get(context.pluginId) ?? 0) !== revision
|
||||
|| (owner !== undefined && this.database.privateOwner(context.pluginId) !== owner)) {
|
||||
throw permissionDenied("Plugin permission policy or source changed");
|
||||
}
|
||||
});
|
||||
return () => subscription.dispose();
|
||||
};
|
||||
return middleware;
|
||||
}
|
||||
|
||||
listGrants(pluginId) {
|
||||
|
||||
@@ -19,7 +19,11 @@ const { RPC_ERRORS } = require("./rpcRouter.cjs");
|
||||
function createDatabase(context) {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-plugin-permissions-"));
|
||||
context.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
return new PluginDatabase(path.join(root, "plugins.sqlite"));
|
||||
const database = new PluginDatabase(path.join(root, "plugins.sqlite"));
|
||||
// These tests exercise explicit Ask decisions and grant semantics. Default
|
||||
// Allow and persisted policy behavior are covered in permissionModes.test.cjs.
|
||||
database.getPermissionMode = () => "ask";
|
||||
return database;
|
||||
}
|
||||
|
||||
function manifest(permissions) {
|
||||
@@ -47,7 +51,7 @@ function runtimeContext(pluginManifest, overrides = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
test("permission declarations are not grants and no renderer fails closed", async (context) => {
|
||||
test("Ask mode without a renderer fails closed", async (context) => {
|
||||
const database = createDatabase(context);
|
||||
const engine = new PluginPermissionEngine({ database });
|
||||
const pluginManifest = manifest({ required: ["storage"] });
|
||||
@@ -551,7 +555,7 @@ test("required preflight rejects missing or wildcard resource-scoped activation
|
||||
database.close();
|
||||
});
|
||||
|
||||
test("required preflight prompts for non-resource permissions and bounded resources", async (context) => {
|
||||
test("required preflight validates declarations without permission ceremonies", async (context) => {
|
||||
const database = createDatabase(context);
|
||||
const requested = [];
|
||||
const engine = new PluginPermissionEngine({
|
||||
@@ -573,10 +577,7 @@ test("required preflight prompts for non-resource permissions and bounded resour
|
||||
activeVersion: pluginManifest.version,
|
||||
manifest: pluginManifest,
|
||||
});
|
||||
assert.deepEqual(requested, [
|
||||
{ permission: "runtime.advanced", resources: ["*"] },
|
||||
{ permission: "network", resources: ["https://example.com"] },
|
||||
]);
|
||||
assert.deepEqual(requested, []);
|
||||
database.close();
|
||||
});
|
||||
|
||||
@@ -604,7 +605,7 @@ test("required filesystem preflight preserves declared directory scope", async (
|
||||
activeVersion: pluginManifest.version,
|
||||
manifest: pluginManifest,
|
||||
});
|
||||
assert.deepEqual(requests[0].resourceKinds, ["directory"]);
|
||||
assert.deepEqual(requests, []);
|
||||
await engine.authorize(runtimeContext(pluginManifest), {
|
||||
permission: "filesystem.read",
|
||||
resources: [child],
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
"use strict";
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { EventEmitter } = require("node:events");
|
||||
const { PluginDatabase } = require("./database.cjs");
|
||||
const { PluginPermissionEngine } = require("./permissionEngine.cjs");
|
||||
const { PluginHostRpcRegistry } = require("./hostRpcRegistry.cjs");
|
||||
const { PluginHostInteractionService } = require("./hostInteractionService.cjs");
|
||||
const { PluginNetworkBroker } = require("./networkBroker.cjs");
|
||||
const { registerSecurePluginCapabilities } = require("./secureCapabilities.cjs");
|
||||
const { OFFICIAL_SOURCE } = require("./sourceIdentity.cjs");
|
||||
|
||||
const manifest = { id: "com.example.policy", publisher: "example", version: "1.0.0",
|
||||
permissions: { optional: ["storage", "ui.interact", "clipboard.read", "clipboard.write", { permission: "network", resources: ["https://example.com"] }] } };
|
||||
function fixture(t) {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-policy-"));
|
||||
const filename = path.join(root, "plugins.sqlite");
|
||||
const database = new PluginDatabase(filename);
|
||||
const decisions = [], prompts = [], writes = [];
|
||||
let approve = true;
|
||||
const engine = new PluginPermissionEngine({ database, requestDecision: async (request) => {
|
||||
decisions.push(request); return { requestId: request.requestId, decision: approve ? "allow" : "deny", ...(approve ? { scope: "once" } : {}) };
|
||||
} });
|
||||
const sender = Object.assign(new EventEmitter(), { id: 1, isDestroyed: () => false, send: (_channel, prompt) => prompts.push(prompt) });
|
||||
const window = Object.assign(new EventEmitter(), { webContents: sender, isFocused: () => true, isVisible: () => true, isDestroyed: () => false });
|
||||
const interactions = new PluginHostInteractionService({ database, permissionEngine: engine, getWindow: () => window, isLocked: () => false,
|
||||
clipboard: { readText: () => "SYNTHETIC PRIVATE BODY", writeText: (text) => writes.push(text) }, quotaManager: { chargeBytes() {} } });
|
||||
interactions.setReady(sender, true);
|
||||
const registry = new PluginHostRpcRegistry(); registry.use(engine.createMiddleware()); interactions.registerRpcCapabilities(registry);
|
||||
const context = { pluginId: manifest.id, pluginVersion: manifest.version, runtimeId: "runtime-policy", runtimeKind: "browser", manifest };
|
||||
const handlers = registry.createRoutes(context).requestHandlers;
|
||||
const call = (method, params) => handlers[method](params, {});
|
||||
const set = (permission, mode) => engine.setMode({ id: manifest.id, manifest }, permission, mode);
|
||||
t.after(() => { interactions.shutdown(); engine.shutdown(); database.close(); fs.rmSync(root, { recursive: true, force: true }); });
|
||||
return { database, filename, engine, registry, context, call, set, decisions, prompts, writes, interactions, sender, approve: (v) => { approve = v; } };
|
||||
}
|
||||
|
||||
test("registered clipboard API defaults Allow with no permission UI; Ask has one scoped host dialog; Deny has no side effects", async (t) => {
|
||||
const f = fixture(t);
|
||||
assert.deepEqual(await f.call("ui.interact", { kind: "clipboard.read", title: "Read" }), { cancelled: false, value: "SYNTHETIC PRIVATE BODY" });
|
||||
await f.call("ui.interact", { kind: "clipboard.write", title: "Copy", text: "SYNTHETIC PRIVATE BODY" });
|
||||
assert.equal(f.decisions.length, 0); assert.equal(f.prompts.length, 0); assert.equal(f.writes.length, 1);
|
||||
f.set("clipboard.read", "ask");
|
||||
const result = f.call("ui.interact", { kind: "clipboard.read", title: "Read" });
|
||||
await new Promise(setImmediate);
|
||||
const prompt = f.prompts.find((p) => !p.closed);
|
||||
assert.equal(prompt.phase, "preview"); assert.equal(prompt.permissionMode, "ask"); assert.equal(f.decisions.length, 0);
|
||||
await f.interactions.respond(f.sender, { requestId: prompt.requestId, actionToken: prompt.actionToken, action: "submit" });
|
||||
assert.equal((await result).value, "SYNTHETIC PRIVATE BODY");
|
||||
assert.equal(f.prompts.filter((p) => !p.closed).length, 1);
|
||||
f.set("clipboard.write", "deny");
|
||||
await assert.rejects(f.call("ui.interact", { kind: "clipboard.write", title: "Copy", text: "NEVER COPY" }), /disabled/);
|
||||
assert.equal(f.writes.length, 1);
|
||||
assert.equal(JSON.stringify(f.database.listSecurityAudit(manifest.id, 1000)).includes("SYNTHETIC PRIVATE BODY"), false);
|
||||
});
|
||||
|
||||
test("pending workflow tokens and in-flight resource work cannot survive a Deny flip", async (t) => {
|
||||
const f = fixture(t); f.set("clipboard.write", "ask");
|
||||
const operation = f.call("ui.interact", { kind: "clipboard.write", title: "Copy", text: "NEVER COPY" });
|
||||
const rejected = assert.rejects(operation, /changed|revoked/);
|
||||
await new Promise(setImmediate);
|
||||
const prompt = f.prompts.find((p) => !p.closed);
|
||||
f.set("clipboard.write", "deny"); await rejected;
|
||||
assert.equal(await f.interactions.respond(f.sender, { requestId: prompt.requestId, actionToken: prompt.actionToken, action: "submit" }), false);
|
||||
assert.deepEqual(f.writes, []);
|
||||
let release; const gate = new Promise((resolve) => { release = resolve; });
|
||||
f.registry.registerRequest("test.await", async (_params, context) => { await gate; await context.assertActive(); return "SECRET"; }, { authorization: { permission: "storage", resources: ["*"] } });
|
||||
const work = f.registry.createRoutes(f.context).requestHandlers["test.await"]({}, {});
|
||||
const stopped = assert.rejects(work, /changed/); await new Promise(setImmediate); f.set("storage", "deny"); release(); await stopped;
|
||||
});
|
||||
|
||||
test("Deny beats saved grants; policy persists across restart and package changes while sources and plugins stay isolated", async (t) => {
|
||||
const f = fixture(t);
|
||||
const oldOwner = f.database.privateOwner(manifest.id);
|
||||
f.set("storage", "deny");
|
||||
f.database.upsertPermissionGrant({ pluginId: manifest.id, permission: "storage", resource: "*", resourceKind: "exact", declarationHash: "old-grant" });
|
||||
const restarted = new PluginDatabase(f.filename); t.after(() => restarted.close());
|
||||
assert.equal(restarted.getPermissionMode(manifest.id, "storage"), "deny");
|
||||
const engine = new PluginPermissionEngine({ database: restarted }); t.after(() => engine.shutdown());
|
||||
await assert.rejects(engine.authorize({ ...f.context, pluginVersion: "2.0.0", securityPrincipal: "unsigned-package:new-package-principal" }, { permission: "storage" }), /disabled/);
|
||||
assert.equal(restarted.getPermissionMode("com.example.another", "storage"), "allow");
|
||||
// Source replacement is an explicit host reset. The new source receives none
|
||||
// of the old source's policies, grants or settings.
|
||||
restarted.clearUninstalledSourceData(manifest.id, "local");
|
||||
restarted.installVersion({ pluginId: manifest.id, version: "2.0.0", manifest, archiveSha256: "a".repeat(64), packageRelativePath: "test/package", source: OFFICIAL_SOURCE });
|
||||
assert.equal(restarted.getPermissionMode(manifest.id, "storage"), "allow");
|
||||
await assert.rejects(engine.authorize({ ...f.context, sourceOwner: oldOwner }, { permission: "storage" }), /source changed/);
|
||||
});
|
||||
|
||||
test("registered network API enforces all modes and declared endpoint scope before fetch", async (t) => {
|
||||
const f = fixture(t); let fetched = 0;
|
||||
const broker = new PluginNetworkBroker({ permissionEngine: f.engine, fetch: async () => { fetched++; return new Response("ok"); } });
|
||||
const registry = new PluginHostRpcRegistry();
|
||||
registerSecurePluginCapabilities(registry, { permissionEngine: f.engine, quotaManager: { createMiddleware: () => (_context, next) => next() }, networkBroker: broker });
|
||||
const call = (url) => registry.createRoutes(f.context).requestHandlers["network.request"]({ url }, {});
|
||||
await call("https://example.com/path"); assert.equal(fetched, 1); assert.equal(f.decisions.length, 0);
|
||||
f.set("network", "ask"); await call("https://example.com/path"); assert.equal(f.decisions.length, 1); assert.equal(fetched, 2);
|
||||
f.approve(false); await assert.rejects(call("https://example.com/path"), /denied/); assert.equal(fetched, 2);
|
||||
f.set("network", "deny"); await assert.rejects(call("https://example.com/path"), /disabled/); assert.equal(fetched, 2);
|
||||
f.set("network", "allow"); await assert.rejects(call("https://other.example/path"), /did not declare/); assert.equal(fetched, 2);
|
||||
assert.equal(registry.createRoutes(f.context).requestHandlers["unknown.method"], undefined);
|
||||
});
|
||||
|
||||
test("one Ask decision covers model invocation and exact selected handles only for that operation", async (t) => {
|
||||
const f = fixture(t);
|
||||
const currentManifest = { ...manifest, permissions: { optional: ["ai.invoke", "context.selected.read"] } };
|
||||
const context = { ...f.context, manifest: currentManifest };
|
||||
const plugin = { id: manifest.id, manifest: currentManifest };
|
||||
f.engine.setMode(plugin, "ai.invoke", "ask"); f.engine.setMode(plugin, "context.selected.read", "ask");
|
||||
const selected = { permission: "context.selected.read", resources: ["selected:host-handle:v1"] };
|
||||
await f.engine.authorize(context, { permission: "ai.invoke", relatedPermissions: [selected] });
|
||||
await f.engine.authorize(context, selected);
|
||||
assert.equal(f.decisions.length, 1);
|
||||
assert.deepEqual(f.decisions[0].relatedPermissions, [{ permission: selected.permission, resources: selected.resources }]);
|
||||
await f.engine.authorize({ ...context }, selected); assert.equal(f.decisions.length, 2, "receipt cannot cross RPC operations");
|
||||
f.engine.setMode(plugin, "context.selected.read", "deny");
|
||||
await assert.rejects(f.engine.authorize(context, selected), /disabled/);
|
||||
});
|
||||
|
||||
test("Deny during an asynchronous authorization resolver invalidates the old call even after Allow", async (t) => {
|
||||
const f = fixture(t); let release;
|
||||
const pending = new Promise((resolve) => { release = resolve; });
|
||||
let effects = 0;
|
||||
f.registry.registerRequest("test.resolve", async () => { effects++; return "SECRET"; }, {
|
||||
authorization: async () => { await pending; return { permission: "storage" }; },
|
||||
});
|
||||
const operation = f.registry.createRoutes(f.context).requestHandlers["test.resolve"]({}, {});
|
||||
const rejected = assert.rejects(operation, /changed/);
|
||||
await new Promise(setImmediate); f.set("storage", "deny"); f.set("storage", "allow"); release();
|
||||
await rejected; assert.equal(effects, 0);
|
||||
});
|
||||
|
||||
test("Ask does not prompt twice for a same-origin redirect within one registered network request", async (t) => {
|
||||
const f = fixture(t); f.set("network", "ask"); let fetched = 0;
|
||||
const broker = new PluginNetworkBroker({ permissionEngine: f.engine, fetch: async () => ++fetched === 1
|
||||
? new Response(null, { status: 302, headers: { location: "/final" } }) : new Response("ok") });
|
||||
const registry = new PluginHostRpcRegistry();
|
||||
registerSecurePluginCapabilities(registry, { permissionEngine: f.engine, quotaManager: { createMiddleware: () => (_context, next) => next() }, networkBroker: broker });
|
||||
const call = () => registry.createRoutes(f.context).requestHandlers["network.request"]({ url: "https://example.com/start" }, {});
|
||||
await call(); assert.equal(fetched, 2); assert.equal(f.decisions.length, 1);
|
||||
await call(); assert.equal(f.decisions.length, 2, "the decision does not leak to the next RPC operation");
|
||||
});
|
||||
@@ -0,0 +1,141 @@
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert/strict");
|
||||
const { EventEmitter } = require("node:events");
|
||||
const fs = require("node:fs/promises");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const test = require("node:test");
|
||||
const windowManager = require("../bridges/windowManager.cjs");
|
||||
const { createPluginHostService } = require("./hostService.cjs");
|
||||
const { registerPluginBridge, createTrustedPluginBridgeSender } = require("./pluginBridge.cjs");
|
||||
const { inProcessBrowserRuntime } = require("./fixtures/inProcessBrowserRuntime.cjs");
|
||||
|
||||
// Real manager, registered host/view RPCs, SDK activation and managed Settings
|
||||
// creation. Only Electron's native window/contents boundary is represented here.
|
||||
test("Settings-owned installed view can Ask on activation and commands without a main-window focus handoff", { timeout: 45_000 }, async (t) => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "netcatty-permission-windows-"));
|
||||
const handlers = new Map(), windows = [], owners = new Map();
|
||||
let nextId = 1, service;
|
||||
class Contents extends EventEmitter {
|
||||
constructor() { super(); this.id = nextId++; this.messages = []; this.url = "app://netcatty/index.html"; this.mainFrame = { url: this.url, parent: null }; this.destroyed = false; }
|
||||
isDestroyed() { return this.destroyed; }
|
||||
isCrashed() { return false; }
|
||||
getURL() { return this.url; }
|
||||
setWindowOpenHandler() {}
|
||||
async loadURL(url) { this.url = url; this.mainFrame.url = url; }
|
||||
send(channel, payload) { this.messages.push({ channel, payload }); this.emit(channel, payload); }
|
||||
focus() {}
|
||||
close() { this.destroyed = true; this.emit("destroyed"); }
|
||||
}
|
||||
class Window extends EventEmitter {
|
||||
constructor() {
|
||||
super(); this.webContents = new Contents(); owners.set(this.webContents, this); windows.push(this);
|
||||
this.destroyed = false; this.visible = false; this.focused = false; this.children = [];
|
||||
this.contentView = { addChildView: (view) => this.children.push(view), removeChildView: (view) => { this.children = this.children.filter((item) => item !== view); } };
|
||||
}
|
||||
static fromWebContents(sender) { return owners.get(sender) ?? null; }
|
||||
isDestroyed() { return this.destroyed; }
|
||||
isVisible() { return this.visible; }
|
||||
isFocused() { return this.focused; }
|
||||
show() { this.visible = true; }
|
||||
focus() { for (const window of windows) if (window !== this && window.focused) { window.focused = false; window.emit("blur"); } this.focused = true; this.emit("focus"); }
|
||||
hide() { this.visible = false; this.focused = false; this.emit("hide"); }
|
||||
destroy() { this.destroyed = true; this.focused = false; this.emit("closed"); this.webContents.close(); }
|
||||
async loadURL(url) { await this.webContents.loadURL(url); }
|
||||
getBounds() { return { x: 0, y: 0, width: 980, height: 720 }; }
|
||||
setBackgroundColor() {}
|
||||
setOpacity() {}
|
||||
}
|
||||
class ContentsView {
|
||||
constructor() { this.webContents = new Contents(); }
|
||||
setBounds() {}
|
||||
setVisible(visible) { this.visible = visible; }
|
||||
}
|
||||
const ipcMain = Object.assign(new EventEmitter(), { handle: (channel, handler) => handlers.set(channel, handler), removeHandler: (channel) => handlers.delete(channel) });
|
||||
const electron = { ipcMain, BrowserWindow: Window, WebContentsView: ContentsView, shell: {},
|
||||
session: { fromPartition: () => Object.assign(new EventEmitter(), { protocol: { handle() {}, unhandle() {} }, setProxy: async () => {}, setPermissionCheckHandler() {}, setPermissionRequestHandler() {}, setDevicePermissionHandler() {}, webRequest: { onBeforeRequest() {} } }) } };
|
||||
const main = new Window(); main.show(); main.focus(); windowManager.registerMainWindow(main);
|
||||
const settings = await windowManager.openSettingsWindow(electron, { isDev: false, isMac: false, preload: "synthetic-preload.cjs", sourceWindow: main });
|
||||
assert.equal(windowManager.getSettingsWindow(), settings); assert.equal(settings.isFocused(), true); assert.equal(main.isFocused(), false);
|
||||
// The fallback permits running this regression on the original 79 tree;
|
||||
// production uses the managed registry's new combined getter.
|
||||
const getWindows = () => windowManager.getManagedAppWindows?.() ?? [...windowManager.getAppContentWindows(), windowManager.getSettingsWindow()].filter(Boolean);
|
||||
service = createPluginHostService({ app: { getAppPath: () => process.cwd(), getPath: () => root, getVersion: () => "0.0.0" }, electron, env: {},
|
||||
getInteractionWindow: () => main, getPermissionWindows: getWindows, isAppLocked: () => false });
|
||||
service.runtimeSupervisor.runtimeFactories.browser = inProcessBrowserRuntime;
|
||||
registerPluginBridge(ipcMain, { ...service, isTrustedSender: createTrustedPluginBridgeSender() });
|
||||
const event = (sender) => ({ sender, senderFrame: sender.mainFrame });
|
||||
const call = (sender, name, payload) => handlers.get(`netcatty:plugins:${name}`)(event(sender), payload);
|
||||
const ready = (window, value = true) => call(window.webContents, "permission-ready", { ready: value });
|
||||
const promptCount = (window) => window.webContents.messages.filter((item) => item.channel === "netcatty:plugins:permission-prompt" && !item.payload.closed).length;
|
||||
const nextPrompt = (window) => new Promise((resolve) => {
|
||||
const listener = (prompt) => { if (!prompt.closed) { window.webContents.removeListener("netcatty:plugins:permission-prompt", listener); resolve(prompt); } };
|
||||
window.webContents.on("netcatty:plugins:permission-prompt", listener);
|
||||
});
|
||||
const response = (prompt, decision = "allow") => ({ requestId: prompt.requestId, actionToken: prompt.actionToken, decision });
|
||||
const id = "com.netcatty.permission-window-test";
|
||||
t.after(async () => {
|
||||
await service.manager.shutdown(); windowManager.unregisterMainWindow(main); windowManager.closeSettingsWindow();
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
});
|
||||
await service.manager.initialize(); await ready(main);
|
||||
// Original implementation rejects this registration and subsequently fails
|
||||
// the activation with provider-failed while Settings owns focus.
|
||||
await ready(settings).catch(() => {});
|
||||
const manifest = { manifestVersion: 1, id, name: "permission-window-test", version: "1.0.0", publisher: "netcatty",
|
||||
engines: { netcatty: ">=0.0.0", api: ">=0.1.0-internal <0.2.0" }, main: { browser: "browser.js" },
|
||||
activationEvents: [`onView:${id}.panel`], permissions: { required: ["settings.read", "commands", "views"] },
|
||||
contributes: { settings: [{ id: `${id}.greeting`, label: "Greeting", control: "text", scope: "application", default: "synthetic greeting" }],
|
||||
commands: [{ id: `${id}.read`, title: "Read own setting" }], views: [{ id: `${id}.panel`, title: "Settings-owned panel", location: "panel", entry: "panel.html" }] } };
|
||||
const staging = path.join(root, "package"); await fs.mkdir(staging);
|
||||
await fs.writeFile(path.join(staging, "netcatty.plugin.json"), JSON.stringify(manifest));
|
||||
await fs.writeFile(path.join(staging, "panel.html"), "<!doctype html><p>Synthetic view</p>");
|
||||
await fs.writeFile(path.join(staging, "browser.js"), `export default { async activate(context) {
|
||||
await context.settings.get(${JSON.stringify(`${id}.greeting`)});
|
||||
context.subscriptions.add(context.commands.registerCommand(${JSON.stringify(`${id}.read`)}, () => context.settings.get(${JSON.stringify(`${id}.greeting`)})));
|
||||
} };`);
|
||||
const { buildPluginPackage } = await import("@netcatty/plugin-cli");
|
||||
const archive = path.join(root, "fixture.ncpkg"); await buildPluginPackage(staging, archive); await service.manager.install(archive, { enable: true });
|
||||
const plugin = service.database.getActivePlugin(id); await service.permissionEngine.setMode(plugin, "settings.read", "ask");
|
||||
const openedPrompt = nextPrompt(settings);
|
||||
const opening = call(settings.webContents, "open-view", { viewId: `${id}.panel`, scopeId: "window:settings", bounds: { x: 0, y: 0, width: 300, height: 200 } });
|
||||
const prompt = await Promise.race([openedPrompt, opening.then(() => { throw new Error("Activation skipped its settings Ask"); })]);
|
||||
assert.equal(prompt.permission, "settings.read"); assert.equal(promptCount(main), 0);
|
||||
assert.equal(main.isFocused(), false, "Ask must not steal focus or close the originating view");
|
||||
await call(settings.webContents, "permission-response", response(prompt));
|
||||
const opened = await opening;
|
||||
const instance = service.viewHost.instances.get(opened.instanceId);
|
||||
assert.equal(instance.owner, settings); assert.equal(instance.state, "open");
|
||||
const read = () => handlers.get("netcatty-plugin-view:execute-command")({ sender: instance.contents }, { command: `${id}.read` });
|
||||
const asked = nextPrompt(settings); const reading = read(); const commandPrompt = await asked;
|
||||
await ready(main, false); // Another renderer's teardown cannot cancel Settings.
|
||||
assert.equal(service.permissionDecisionHost.pending.size, 1);
|
||||
const foreign = new Window(); foreign.show(); // Same app URL, but unmanaged.
|
||||
await assert.rejects(ready(foreign), /sender is invalid/);
|
||||
await assert.rejects(call(instance.contents, "permission-response", response(commandPrompt)), /Untrusted/);
|
||||
const childFrame = { url: settings.webContents.getURL(), parent: settings.webContents.mainFrame };
|
||||
await assert.rejects(handlers.get("netcatty:plugins:permission-ready")({ sender: settings.webContents, senderFrame: childFrame }, { ready: true }), /frame/);
|
||||
await assert.rejects(handlers.get("netcatty:plugins:permission-ready")({ sender: settings.webContents, senderFrame: null }, { ready: true }), /frame/);
|
||||
await assert.rejects(handlers.get("netcatty:plugins:permission-response")({ sender: settings.webContents, senderFrame: childFrame }, response(commandPrompt)), /frame/);
|
||||
await call(settings.webContents, "permission-response", response(commandPrompt)); assert.equal(await reading, "synthetic greeting");
|
||||
await ready(main); main.show(); main.focus();
|
||||
const mainPrompt = nextPrompt(main); const mainRead = call(main.webContents, "execute-command", { command: `${id}.read` });
|
||||
await call(main.webContents, "permission-response", response(await mainPrompt)); assert.equal(await mainRead, "synthetic greeting");
|
||||
await ready(main, false);
|
||||
await assert.rejects(read(), /permission|decision/i); // Never fall back to a ready background Settings window.
|
||||
await ready(main);
|
||||
main.hide(); foreign.focus();
|
||||
const before = promptCount(main) + promptCount(settings);
|
||||
await assert.rejects(read(), /permission|decision/i);
|
||||
assert.equal(promptCount(main) + promptCount(settings), before, "foreign/background focus cannot receive or redirect a prompt");
|
||||
settings.show(); settings.focus();
|
||||
const hidingPrompt = nextPrompt(settings); const hiddenRead = read(); const hiddenRejected = assert.rejects(hiddenRead, /denied|cancel/i);
|
||||
const hiddenToken = response(await hidingPrompt); settings.hide(); await hiddenRejected;
|
||||
settings.show(); settings.focus();
|
||||
assert.equal(await call(settings.webContents, "permission-response", hiddenToken), false, "reopening Settings cannot revive an old decision");
|
||||
const closingPrompt = nextPrompt(settings); const cancelled = read(); const rejected = assert.rejects(cancelled, /denied|cancel/i);
|
||||
const stale = response(await closingPrompt); settings.destroy(); await rejected;
|
||||
assert.equal(service.permissionDecisionHost.pending.size, 0); assert.equal(service.viewHost.instances.size, 0);
|
||||
await assert.rejects(call(settings.webContents, "permission-response", stale), /focused unlocked|Untrusted/);
|
||||
});
|
||||
@@ -30,6 +30,9 @@ const CHANNELS = Object.freeze({
|
||||
interactionResponse: "netcatty:plugins:interaction-response",
|
||||
list: "netcatty:plugins:list",
|
||||
permissions: "netcatty:plugins:permissions",
|
||||
setPermissionMode: "netcatty:plugins:set-permission-mode",
|
||||
permissionReady: "netcatty:plugins:permission-ready",
|
||||
permissionResponse: "netcatty:plugins:permission-response",
|
||||
revokePermissions: "netcatty:plugins:revoke-permissions",
|
||||
resetSource: "netcatty:plugins:reset-source",
|
||||
sourceBinding: "netcatty:plugins:source-binding",
|
||||
@@ -710,6 +713,22 @@ function registerPluginBridge(ipcMain, options) {
|
||||
await options.logService.ready;
|
||||
return options.logService[action](payload);
|
||||
});
|
||||
const permissionSender = (event) => {
|
||||
if ("senderFrame" in event && (!event.senderFrame || event.senderFrame !== event.sender?.mainFrame)) throw new Error("Untrusted permission UI frame");
|
||||
return event.sender;
|
||||
};
|
||||
handle(CHANNELS.permissionReady, async (_manager, payload, event) => options.permissionDecisionHost?.setReady(permissionSender(event), payload?.ready));
|
||||
handle(CHANNELS.permissionResponse, async (_manager, payload, event) => options.permissionDecisionHost?.respond(permissionSender(event), payload));
|
||||
handle(CHANNELS.setPermissionMode, async (_manager, payload) => {
|
||||
const pluginId = assertManagedPluginId(payload);
|
||||
const plugin = options.database?.getActivePlugin(pluginId);
|
||||
if (!plugin || !options.permissionEngine) throw new Error("Plugin permissions are unavailable");
|
||||
if (typeof payload?.policyRevision !== "string" || payload.policyRevision !== options.permissionEngine.getPolicyRevision(plugin)) {
|
||||
throw new Error("Plugin permissions changed; refresh before editing");
|
||||
}
|
||||
await options.permissionEngine.setMode(plugin, payload?.permission, payload?.mode);
|
||||
return true;
|
||||
});
|
||||
handle(CHANNELS.permissions, async (_manager, payload) => {
|
||||
const pluginId = assertManagedPluginId(payload);
|
||||
if (!options.permissionEngine || !options.database) throw new Error("Plugin permissions are unavailable");
|
||||
@@ -736,8 +755,12 @@ function registerPluginBridge(ipcMain, options) {
|
||||
event: entry.event, createdAt: entry.createdAt,
|
||||
...(typeof entry.details?.permission === "string" ? { permission: entry.details.permission } : {}),
|
||||
...(typeof entry.details?.scope === "string" ? { scope: entry.details.scope } : {}),
|
||||
...(["allow", "ask", "deny"].includes(entry.details?.mode) ? { mode: entry.details.mode } : {}),
|
||||
...(["completed", "cancelled", "preview-confirmed", "share-confirmed", "copy-confirmed", "draft-reviewed", "action-confirmed", "action-claimed"].includes(entry.details?.action)
|
||||
? { action: entry.details.action } : {}),
|
||||
}));
|
||||
return { grants, audit };
|
||||
return { grants, audit, ...(options.permissionEngine.describePolicies && plugin
|
||||
? { policies: options.permissionEngine.describePolicies(plugin), policyRevision: options.permissionEngine.getPolicyRevision(plugin) } : {}) };
|
||||
});
|
||||
handle(CHANNELS.revokePermissions, async (activeManager, payload) => (
|
||||
activeManager.revokePermissions(assertManagedPluginId(payload))
|
||||
|
||||
@@ -1735,3 +1735,27 @@ test("local log management rejects plugin/untrusted senders before touching the
|
||||
for (const channel of [CHANNELS.logStatus, CHANNELS.logConfigure, CHANNELS.logOpen, CHANNELS.logExport, CHANNELS.logClear]) await ipcMain.handlers.get(channel)({ trusted: true }, {});
|
||||
assert.deepEqual(actions, ["status", "configure", "openFolder", "exportLogs", "clear"]);
|
||||
});
|
||||
|
||||
test("policy edits require trusted UI and the exact source/version/policy revision", async () => {
|
||||
const { PluginPermissionEngine } = require("./permissionEngine.cjs");
|
||||
const ipcMain = createIpcMain();
|
||||
let sourceOwner = "source-a", mode = "deny";
|
||||
const plugin = { id: "com.example.policy", activeVersion: "1.0.0", manifest: { permissions: { optional: ["storage"] } } };
|
||||
const engine = new PluginPermissionEngine({ database: { privateOwner: () => sourceOwner,
|
||||
getPermissionMode: () => mode, setPermissionMode: (_id, _permission, value) => { mode = value; },
|
||||
deleteAllPermissionGrants() {}, recordSecurityAudit() {},
|
||||
} });
|
||||
registerPluginBridge(ipcMain, { manager: { async initialize() {} }, isTrustedSender: (event) => event?.trusted === true,
|
||||
permissionEngine: engine, database: { getActivePlugin: () => plugin } });
|
||||
const edit = ipcMain.handlers.get(CHANNELS.setPermissionMode);
|
||||
const payload = { pluginId: plugin.id, permission: "storage", mode: "allow", policyRevision: engine.getPolicyRevision(plugin) };
|
||||
await assert.rejects(edit({}, payload), /Untrusted/);
|
||||
for (const change of [() => { sourceOwner = "source-b"; }, () => { plugin.activeVersion = "2.0.0"; }, () => engine.revokeAll(plugin.id)]) {
|
||||
const stale = { ...payload, policyRevision: engine.getPolicyRevision(plugin) }; change();
|
||||
await assert.rejects(edit({ trusted: true }, stale), /refresh/);
|
||||
assert.equal(mode, "deny");
|
||||
}
|
||||
assert.equal(await edit({ trusted: true }, { ...payload, policyRevision: engine.getPolicyRevision(plugin) }), true);
|
||||
assert.equal(mode, "allow");
|
||||
engine.shutdown();
|
||||
});
|
||||
@@ -126,9 +126,12 @@ class PluginManager {
|
||||
});
|
||||
}
|
||||
|
||||
setEnabled(pluginId, enabled) {
|
||||
setEnabled(pluginId, enabled, options = {}) {
|
||||
return this.#mutate(async () => {
|
||||
await this.#ready();
|
||||
if (options.expectedSourceOwner !== undefined && this.database.privateOwner(pluginId) !== options.expectedSourceOwner) {
|
||||
throw new Error("Plugin source changed before its lifecycle update");
|
||||
}
|
||||
if (enabled) {
|
||||
const plugin = this.database.getActivePlugin(pluginId);
|
||||
if (plugin) await this.packageStore.assertCompatible?.(plugin);
|
||||
|
||||
@@ -516,7 +516,7 @@ class PluginTerminalDataPipelineService {
|
||||
this.#assertAttachmentCurrent(session, direction, providerId, identity, options);
|
||||
},
|
||||
});
|
||||
if (!["existing", "session", "application", "always"].includes(grant?.scope)) {
|
||||
if (!["policy", "existing", "session", "application", "always"].includes(grant?.scope)) {
|
||||
throw new PluginRpcError(
|
||||
RPC_ERRORS.permissionDenied,
|
||||
"Terminal interceptor streams require a session, application, or persistent permission grant",
|
||||
|
||||
@@ -76,9 +76,11 @@ async function fixture(t) {
|
||||
const archive = path.join(root, 'fixture.ncpkg');
|
||||
await buildPluginPackage(sourceRoot, archive);
|
||||
await service.manager.install(archive, { enable: true });
|
||||
service.database.setPermissionMode(manifest.id, "terminal.selection.read", "ask");
|
||||
service.database.setPermissionMode(manifest.id, "terminal.input.propose", "ask");
|
||||
};
|
||||
return { root, service, sender, window, manager, runtime, select, install, writes, prompts, decisions, responses,
|
||||
mode(value) { mode = value; }, selection(value) { selection = value; }, allow(value) { allow = value; },
|
||||
mode(value) { mode = value; }, selection(value) { selection = value; }, allow(value) { allow = value; service.permissionEngine.setMode({ id, manifest }, "terminal.selection.read", value ? "ask" : "deny"); },
|
||||
async startB() { nextSession = 'terminal-b'; await manager.request('netcatty:local:start', {}, { webContentsId: 7 }); select('terminal-b', 'renderer-b'); },
|
||||
};
|
||||
}
|
||||
@@ -111,7 +113,7 @@ test('installed entrypoint keeps consent, exact snapshots, separate references a
|
||||
await f.install(source, manifest);
|
||||
const run = (name) => f.service.contributionService.executeCommand(`${id}.${name}`);
|
||||
f.allow(false);
|
||||
await assert.rejects(run('share'), /denied/);
|
||||
await assert.rejects(run('share'), /disabled/);
|
||||
assert.equal(f.prompts.filter((p) => !p.closed).length, 0);
|
||||
f.allow(true); f.mode('cancel');
|
||||
assert.deepEqual(await run('share'), { cancelled: true });
|
||||
@@ -137,11 +139,10 @@ test('installed entrypoint keeps consent, exact snapshots, separate references a
|
||||
assert.equal(f.prompts.filter((p) => p.request?.kind === 'terminal.input.propose').at(-1).terminalSessionId, 'terminal-b');
|
||||
await assert.rejects(run('spoof'), /unavailable/);
|
||||
assert.deepEqual(f.writes, []);
|
||||
const selectionDecision = f.decisions.find((d) => d.permission === 'terminal.selection.read');
|
||||
const draftDecision = f.decisions.find((d) => d.permission === 'terminal.input.propose');
|
||||
assert.deepEqual(selectionDecision.allowedScopes, ['once']);
|
||||
assert.deepEqual(draftDecision.allowedScopes, ['once']);
|
||||
assert.match(selectionDecision.resources[0], /^terminal-session:terminal-a:window:7:/);
|
||||
assert.equal(f.decisions.length, 0, 'Ask is integrated into host sharing and draft UI');
|
||||
assert.ok(f.prompts.some((p) => p.permissionMode === 'ask'));
|
||||
assert.match(f.prompts.find((p) => p.request?.kind === 'terminal.selection.read').terminalSessionId, /terminal-a/);
|
||||
f.service.interactionService.rates.clear();
|
||||
f.service.permissionEngine.revokeApplication(id, 'terminal.input.propose');
|
||||
await assert.rejects(run('draftLast'), /unavailable/);
|
||||
const audit = JSON.stringify(f.service.database.listSecurityAudit?.(id) ?? []);
|
||||
@@ -224,3 +225,23 @@ test('changing the selected renderer cancels an installed preview and rejects la
|
||||
assert.equal(f.service.interactionService.terminalSelection.handles.size, 0);
|
||||
assert.deepEqual(f.writes, []);
|
||||
});
|
||||
|
||||
test('installed Allow shares the host-selected snapshot without confirmation; Deny releases no text or draft', { timeout: 15_000 }, async (t) => {
|
||||
const f = await fixture(t); await f.install(source, manifest);
|
||||
const plugin = f.service.database.getActivePlugin(id);
|
||||
f.service.permissionEngine.setMode(plugin, 'terminal.selection.read', 'allow');
|
||||
f.service.permissionEngine.setMode(plugin, 'terminal.input.propose', 'allow');
|
||||
const run = (name) => f.service.contributionService.executeCommand(`${id}.${name}`);
|
||||
const shared = await run('share'); assert.equal(shared.text, 'synthetic owned selection\nsecond line');
|
||||
assert.equal(f.prompts.filter((p) => p.phase === 'preview').length, 0, 'no sharing confirmation in Allow');
|
||||
assert.equal(f.decisions.length, 0);
|
||||
assert.equal(await run('draftLast'), true, 'editable draft remains a real workflow');
|
||||
assert.deepEqual(f.writes, []);
|
||||
f.service.permissionEngine.setMode(plugin, 'terminal.selection.read', 'deny');
|
||||
const count = f.prompts.length;
|
||||
await assert.rejects(run('share'), /disabled/);
|
||||
assert.equal(f.prompts.length, count);
|
||||
assert.equal(f.service.interactionService.terminalSelection.handles.size, 0);
|
||||
await assert.rejects(run('draftLast'), /unavailable/);
|
||||
assert.deepEqual(f.writes, []);
|
||||
});
|
||||
@@ -123,6 +123,14 @@ function createPreloadApi(ctx) {
|
||||
return () => ipcRenderer.removeListener("netcatty:plugins:host-interaction", listener);
|
||||
},
|
||||
listPlugins: () => ipcRenderer.invoke("netcatty:plugins:list"),
|
||||
setPluginPermissionMode: (pluginId, permission, mode, policyRevision) => ipcRenderer.invoke("netcatty:plugins:set-permission-mode", { pluginId, permission, mode, policyRevision }),
|
||||
setPluginPermissionReady: (ready) => ipcRenderer.invoke("netcatty:plugins:permission-ready", { ready }),
|
||||
respondPluginPermission: (response) => ipcRenderer.invoke("netcatty:plugins:permission-response", response),
|
||||
onPluginPermission: (callback) => {
|
||||
const listener = (_event, prompt) => callback(prompt);
|
||||
ipcRenderer.on("netcatty:plugins:permission-prompt", listener);
|
||||
return () => ipcRenderer.removeListener("netcatty:plugins:permission-prompt", listener);
|
||||
},
|
||||
getPluginPermissions: (pluginId) => ipcRenderer.invoke("netcatty:plugins:permissions", { pluginId }),
|
||||
revokePluginPermissions: (pluginId) => ipcRenderer.invoke("netcatty:plugins:revoke-permissions", { pluginId }),
|
||||
installPluginPackage: (archivePath, options) => ipcRenderer.invoke("netcatty:plugins:install", {
|
||||
|
||||
@@ -4,6 +4,11 @@ A minimal browser plugin that uses Netcatty's selected configured model and one
|
||||
explicitly selected reference. It provides Send, streamed text, Stop, and usage.
|
||||
It has no tools, terminal commands, direct network access, Node runtime, or storage.
|
||||
|
||||
The page follows light/dark mode and accent updates through the existing
|
||||
`netcattyView.onDidChangeEnvironment()` API and seven public HSL theme tokens.
|
||||
Missing tokens fall back to the standard sample palettes. Its theme subscription
|
||||
is disposed on page hide and restored on page show.
|
||||
|
||||
An expired chat is reopened before the next explicit Send. If the configured
|
||||
model changes or a request fails, use **Refresh model** to reopen the session,
|
||||
then click **Send** yourself. Refresh keeps your draft and never retries model
|
||||
|
||||
@@ -1,26 +1,29 @@
|
||||
:root { color-scheme: dark; font: 15px/1.5 system-ui,sans-serif; color:#e9edf6; background:#10141c; }
|
||||
:root { color-scheme: light dark; --background:0 0% 100%; --foreground:222 47% 12%; --muted:210 20% 96%; --muted-foreground:215 20% 35%; --border:215 20% 75%; --primary:221 83% 45%; --primary-foreground:0 0% 100%; font: 15px/1.5 system-ui,sans-serif; color:hsl(var(--foreground)); background:hsl(var(--background)); }
|
||||
:root[data-theme="dark"] { --background:221 39% 11%; --foreground:210 20% 98%; --muted:219 27% 21%; --muted-foreground:220 13% 91%; --border:218 11% 65%; --primary:213 97% 87%; --primary-foreground:221 39% 11%; }
|
||||
@media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) { --background:221 39% 11%; --foreground:210 20% 98%; --muted:219 27% 21%; --muted-foreground:220 13% 91%; --border:218 11% 65%; --primary:213 97% 87%; --primary-foreground:221 39% 11%; } }
|
||||
* { box-sizing:border-box; }
|
||||
body { margin:0; }
|
||||
main { max-width:760px; padding:36px; margin:auto; }
|
||||
header { margin-bottom:26px; }
|
||||
.badge { color:#8bb9ff; font-size:11px; font-weight:700; letter-spacing:1.6px; }
|
||||
.badge { color:hsl(var(--primary)); font-size:11px; font-weight:700; letter-spacing:1.6px; }
|
||||
h1 { font-size:27px; font-weight:650; letter-spacing:-.6px; margin:8px 0; }
|
||||
p { color:#a9b4c7; margin:4px 0; }
|
||||
.card { border:1px solid #303c4f; border-radius:10px; padding:16px 18px; margin:14px 0; background:#181f2a; }
|
||||
label { display:block; font-size:12px; font-weight:600; color:#aabbd5; margin-bottom:8px; }
|
||||
p { color:hsl(var(--muted-foreground)); margin:4px 0; }
|
||||
.card { border:1px solid hsl(var(--border)); border-radius:10px; padding:16px 18px; margin:14px 0; background:hsl(var(--muted)); }
|
||||
label { display:block; font-size:12px; font-weight:600; color:hsl(var(--muted-foreground)); margin-bottom:8px; }
|
||||
#model { font-weight:600; }
|
||||
select, textarea { width:100%; background:#10161f; color:#e9edf6; border:1px solid #455570; padding:10px 12px; border-radius:7px; font:inherit; }
|
||||
select:focus,textarea:focus,button:focus-visible { outline:2px solid #8bb9ff; outline-offset:2px; }
|
||||
select, textarea { width:100%; background:hsl(var(--background)); color:inherit; border:1px solid hsl(var(--border)); padding:10px 12px; border-radius:7px; font:inherit; }
|
||||
select:focus,textarea:focus,button:focus-visible { outline:2px solid hsl(var(--primary)); outline-offset:2px; }
|
||||
textarea { resize:vertical; min-height:84px; }
|
||||
form { margin:22px 0; }
|
||||
.actions { display:flex; flex-wrap:wrap; align-items:center; gap:10px; margin-top:12px; }
|
||||
button { border:1px solid #4e8bdf; border-radius:7px; background:#3274c6; color:white; padding:9px 24px; font:inherit; font-weight:600; cursor:pointer; }
|
||||
button { border:1px solid hsl(var(--border)); border-radius:7px; background:transparent; color:inherit; padding:9px 24px; font:inherit; font-weight:600; cursor:pointer; }
|
||||
#send { border-color:transparent; background:hsl(var(--primary)); color:hsl(var(--primary-foreground)); }
|
||||
button:disabled { opacity:.42; cursor:default; }
|
||||
#stop,#refresh,#choose-reference { background:transparent; border-color:#56657b; }
|
||||
#choose-reference { margin-top:10px; }
|
||||
#status { margin-left:4px; color:#b6c6dc; font-size:12px; }
|
||||
#status { margin-left:4px; color:hsl(var(--muted-foreground)); font-size:12px; }
|
||||
.response { min-height:160px; }
|
||||
pre { white-space:pre-wrap; overflow-wrap:anywhere; font:16px/1.7 system-ui,sans-serif; margin:0 0 12px; }
|
||||
.hint,footer { font-size:12px; color:#9aabc3; margin-top:8px; }
|
||||
.hint,footer { font-size:12px; color:hsl(var(--muted-foreground)); margin-top:8px; }
|
||||
footer { padding-top:8px; }
|
||||
@media(max-width:520px) { main { padding:22px; } h1 { font-size:23px; } }
|
||||
@media (forced-colors: active) { button, select, textarea { border:1px solid ButtonText; } }
|
||||
@@ -2,6 +2,31 @@
|
||||
'use strict';
|
||||
const element = (id) => document.getElementById(id);
|
||||
const invoke = (operation, params = {}) => window.netcattyView.executeCommand(`com.netcatty.assistant.${operation}`, params);
|
||||
// Follow the same seven public semantic tokens as the other browser samples.
|
||||
// Only this page's own DOM is updated; no host settings or DOM access is used.
|
||||
(() => {
|
||||
const names = ['--background', '--foreground', '--muted', '--muted-foreground', '--border', '--primary', '--primary-foreground'];
|
||||
let subscription;
|
||||
const subscribe = () => {
|
||||
if (subscription) return;
|
||||
subscription = window.netcattyView.onDidChangeEnvironment?.((environment) => {
|
||||
const root = document.documentElement;
|
||||
const theme = ['light', 'dark'].includes(environment.theme) ? environment.theme : 'system';
|
||||
root.dataset.theme = theme;
|
||||
root.style.colorScheme = theme === 'system' ? 'light dark' : theme;
|
||||
root.dataset.reducedMotion = String(Boolean(environment.reducedMotion));
|
||||
root.dataset.highContrast = String(Boolean(environment.highContrast));
|
||||
for (const name of names) {
|
||||
const value = environment.themeTokens?.[name];
|
||||
if (typeof value === 'string' && value.length > 0 && value.length <= 96) root.style.setProperty(name, value);
|
||||
else root.style.removeProperty(name);
|
||||
}
|
||||
});
|
||||
};
|
||||
subscribe();
|
||||
window.addEventListener('pagehide', () => { subscription?.dispose(); subscription = undefined; });
|
||||
window.addEventListener('pageshow', subscribe);
|
||||
})();
|
||||
let chat;
|
||||
let turn;
|
||||
let generation = 0;
|
||||
|
||||
Vendored
+11
-1
@@ -355,13 +355,19 @@ declare global {
|
||||
artifact?: { url: string; sha256: string };
|
||||
}
|
||||
|
||||
type PluginPermissionMode = 'allow' | 'ask' | 'deny';
|
||||
type NetcattyPluginPermissionPrompt = import('@netcatty/plugin-contract').PermissionRequest & {
|
||||
actionToken: string; resources: string[];
|
||||
};
|
||||
interface NetcattyPluginPermissionSnapshot {
|
||||
policyRevision?: string;
|
||||
policies?: Array<{ permission: string; resources: string[]; mode: PluginPermissionMode }>;
|
||||
grants: Array<{
|
||||
permission: string; resource: string; resourceKind: string;
|
||||
scope: 'always' | 'application' | 'session'; sessionId: string | null; grantedAt: number;
|
||||
matchesCurrentActivation: boolean;
|
||||
}>;
|
||||
audit: Array<{ event: string; createdAt: number; permission?: string; scope?: string }>;
|
||||
audit: Array<{ event: string; createdAt: number; permission?: string; scope?: string; mode?: PluginPermissionMode; action?: string }>;
|
||||
}
|
||||
|
||||
interface NetcattyPluginSource {
|
||||
@@ -480,6 +486,10 @@ declare global {
|
||||
respondPluginInteraction?(response: import("@netcatty/plugin-contract").HostInteractionResponse): Promise<import("@netcatty/plugin-contract").HostInteractionReceipt>;
|
||||
onPluginInteraction?(callback: (event: import("@netcatty/plugin-contract").HostInteractionEvent) => void): () => void;
|
||||
listPlugins?(): Promise<NetcattyInstalledPlugin[]>;
|
||||
setPluginPermissionMode?(pluginId: string, permission: string, mode: PluginPermissionMode, policyRevision: string): Promise<boolean>;
|
||||
setPluginPermissionReady?(ready: boolean): Promise<boolean>;
|
||||
respondPluginPermission?(response: { requestId: string; actionToken: string; decision: 'allow' | 'deny' | 'cancel' }): Promise<boolean>;
|
||||
onPluginPermission?(callback: (event: NetcattyPluginPermissionPrompt | { requestId: string; closed: true }) => void): () => void;
|
||||
getPluginPermissions?(pluginId: string): Promise<NetcattyPluginPermissionSnapshot>;
|
||||
revokePluginPermissions?(pluginId: string): Promise<NetcattyInstalledPlugin>;
|
||||
installPluginPackage?(archivePath: string, options?: { enable?: boolean }): Promise<NetcattyInstalledPlugin>;
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
import { localStorageAdapter } from '../../persistence/localStorageAdapter';
|
||||
import { STORAGE_KEY_AI_ACTIVE_MODEL, STORAGE_KEY_AI_ACTIVE_PROVIDER, STORAGE_KEY_AI_PROVIDERS } from '../../config/storageKeys';
|
||||
import type { ProviderConfig } from '../types';
|
||||
import { netcattyBridge } from '../../services/netcattyBridge';
|
||||
|
||||
// Host renderer only. Read the existing configured provider/model without
|
||||
// loading AgentRuntime or syncing/inheriting the Agent's permission policy.
|
||||
export function getConfiguredPluginModel() {
|
||||
const providers = localStorageAdapter.read<ProviderConfig[]>(STORAGE_KEY_AI_PROVIDERS) ?? [];
|
||||
return selectedModel(providers);
|
||||
}
|
||||
|
||||
function selectedModel(providers: ProviderConfig[]) {
|
||||
const activeProviderId = localStorageAdapter.readString(STORAGE_KEY_AI_ACTIVE_PROVIDER);
|
||||
const activeModelId = localStorageAdapter.readString(STORAGE_KEY_AI_ACTIVE_MODEL);
|
||||
const provider = providers.find((item) => item.id === activeProviderId && item.enabled);
|
||||
@@ -14,3 +19,15 @@ export function getConfiguredPluginModel() {
|
||||
if (!config.defaultModel) return null;
|
||||
return { config, label: `${config.name} · ${config.defaultModel}`.slice(0, 160), revision: JSON.stringify(config) };
|
||||
}
|
||||
|
||||
// The existing main provider transport needs its host-owned registry for both
|
||||
// credentials and endpoint admission. Do not depend on mounting the AI sidebar,
|
||||
// or replace the registry with a plugin-supplied URL / just one selected entry.
|
||||
export async function prepareConfiguredPluginModel(revision: string) {
|
||||
const providers = localStorageAdapter.read<ProviderConfig[]>(STORAGE_KEY_AI_PROVIDERS) ?? [];
|
||||
if (selectedModel(providers)?.revision !== revision) throw Error('Selected model changed');
|
||||
const bridge = netcattyBridge.require();
|
||||
if (!bridge.aiSyncProviders || !(await bridge.aiSyncProviders(providers)).ok) {
|
||||
throw Error('Host provider configuration is unavailable');
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@ const syntheticModel = { config: { id: 'synthetic', name: 'Synthetic', providerI
|
||||
|
||||
test('renderer-owned model handles never serialize provider config and cannot cross runtimes', async () => {
|
||||
let active = true;
|
||||
const runtime = createTrustedPluginAIHostRuntime({ getModel: () => syntheticModel, chooseSelected: async () => [], isActive: () => active });
|
||||
const runtime = createTrustedPluginAIHostRuntime({ getModel: () => syntheticModel, prepareModel: async () => {}, chooseSelected: async () => [], isActive: () => active });
|
||||
const model = await runtime.handle('model.select', {}, 'runtime-a') as { hostHandle: string; label: string };
|
||||
assert.doesNotMatch(JSON.stringify(model), /synthetic-not-a-secret|apiKey|providerId|config/);
|
||||
await assert.rejects(runtime.handle('model.current', model, 'runtime-b'));
|
||||
@@ -36,7 +36,7 @@ test('selected metadata uses explicit safe projections and current resource iden
|
||||
|
||||
test('selected summaries never read content; late resource changes and runtime release deny content', async () => {
|
||||
let current = true; let reads = 0; let resolve: (text: string) => void;
|
||||
const runtime = createTrustedPluginAIHostRuntime({ getModel: () => syntheticModel, isActive: () => true,
|
||||
const runtime = createTrustedPluginAIHostRuntime({ getModel: () => syntheticModel, prepareModel: async () => {}, isActive: () => true,
|
||||
chooseSelected: async () => [{ key: 'note', scope: 'note', title: 'Synthetic', version: '1', isCurrent: () => current,
|
||||
readText: () => { reads++; return new Promise<string>((r) => { resolve = r; }); } }] });
|
||||
const result = await runtime.handle('context.select', {}, 'runtime-a') as { items: Array<{ hostHandle: string; version: string }> };
|
||||
@@ -46,3 +46,22 @@ test('selected summaries never read content; late resource changes and runtime r
|
||||
await new Promise(setImmediate); current = false; resolve!('Late synthetic text'); await assert.rejects(reading);
|
||||
runtime.releaseRuntime('runtime-a'); await assert.rejects(runtime.handle('context.current', reference, 'runtime-a')); runtime.shutdown();
|
||||
});
|
||||
|
||||
test('provider preparation failures and late results cannot publish a model handle after release, cancellation or model change', async () => {
|
||||
for (const action of ['failure', 'release', 'cancel', 'change', 'shutdown']) {
|
||||
let resume!: () => void; let current = syntheticModel;
|
||||
const waiting = new Promise<void>((resolve) => { resume = resolve; });
|
||||
const controller = new AbortController();
|
||||
const runtime = createTrustedPluginAIHostRuntime({ getModel: () => current, isActive: () => true, chooseSelected: async () => [],
|
||||
prepareModel: async () => { await waiting; if (action === 'failure') throw Error('Synthetic sync denied'); } });
|
||||
const selecting = runtime.handle('model.select', {}, 'runtime-a', controller.signal);
|
||||
const rejection = assert.rejects(selecting);
|
||||
if (action === 'release') runtime.releaseRuntime('runtime-a');
|
||||
if (action === 'cancel') controller.abort();
|
||||
if (action === 'change') current = { ...syntheticModel, revision: '2' };
|
||||
if (action === 'shutdown') runtime.shutdown();
|
||||
resume(); await rejection;
|
||||
await assert.rejects(runtime.handle('model.current', { hostHandle: 'not-published' }, 'runtime-a'));
|
||||
runtime.shutdown();
|
||||
}
|
||||
});
|
||||
@@ -17,10 +17,12 @@ type Owned<T> = T & { runtimeId: string };
|
||||
|
||||
export function createTrustedPluginAIHostRuntime(options: {
|
||||
getModel: () => { config: ProviderConfig; label: string; revision: string } | null;
|
||||
prepareModel: (revision: string) => Promise<void>;
|
||||
chooseSelected: (signal: AbortSignal, runtimeId: string) => Promise<HostSelectedSource[]>;
|
||||
isActive: () => boolean;
|
||||
}) {
|
||||
const models = new Map<string, Owned<{ model: Model }>>();
|
||||
const selections = new Set<{ runtimeId: string; active: boolean }>();
|
||||
const turns = new Map<string, Owned<{ iterator: Iterator; controller: AbortController; reading: boolean }>>();
|
||||
const sources = new Map<string, Owned<{ source: HostSelectedSource }>>();
|
||||
let closed = false;
|
||||
@@ -37,6 +39,7 @@ export function createTrustedPluginAIHostRuntime(options: {
|
||||
void turn.iterator.return(undefined).catch(() => {});
|
||||
};
|
||||
const releaseRuntime = (runtimeId: string) => {
|
||||
for (const selection of selections) if (selection.runtimeId === runtimeId) { selection.active = false; selections.delete(selection); }
|
||||
for (const [handle, turn] of turns) if (turn.runtimeId === runtimeId) cancel(handle);
|
||||
for (const [handle, model] of models) if (model.runtimeId === runtimeId) models.delete(handle);
|
||||
for (const [handle, source] of sources) if (source.runtimeId === runtimeId) sources.delete(handle);
|
||||
@@ -50,12 +53,19 @@ export function createTrustedPluginAIHostRuntime(options: {
|
||||
if (kind === 'model.select') {
|
||||
const selected = options.getModel();
|
||||
if (!selected || !selected.config.enabled || !selected.config.defaultModel) throw Error('Choose a configured model first');
|
||||
if (models.size >= 32) throw Error('Model handle limit reached');
|
||||
const handle = crypto.randomUUID();
|
||||
const model = createPluginModelAdapter({ config: selected.config, label: selected.label, transportTag: runtimeId,
|
||||
isCurrent: () => !closed && options.isActive() && options.getModel()?.revision === selected.revision });
|
||||
models.set(handle, { runtimeId, model });
|
||||
return { hostHandle: handle, label: selected.label };
|
||||
if (models.size + selections.size >= 32) throw Error('Model handle limit reached');
|
||||
const selection = { runtimeId, active: true };
|
||||
selections.add(selection);
|
||||
try {
|
||||
await options.prepareModel(selected.revision);
|
||||
signal.throwIfAborted(); assertActive();
|
||||
if (!selection.active || options.getModel()?.revision !== selected.revision) throw Error('Selected model changed');
|
||||
const handle = crypto.randomUUID();
|
||||
const model = createPluginModelAdapter({ config: selected.config, label: selected.label, transportTag: runtimeId,
|
||||
isCurrent: () => !closed && options.isActive() && options.getModel()?.revision === selected.revision });
|
||||
models.set(handle, { runtimeId, model });
|
||||
return { hostHandle: handle, label: selected.label };
|
||||
} finally { selections.delete(selection); }
|
||||
}
|
||||
if (kind === 'model.release') { owned(models, payload.hostHandle, runtimeId); models.delete(String(payload.hostHandle)); return { ok: true }; }
|
||||
if (kind === 'model.current') return { current: await owned(models, payload.hostHandle, runtimeId).model.isCurrent() };
|
||||
@@ -138,6 +148,6 @@ export function createTrustedPluginAIHostRuntime(options: {
|
||||
throw Error('Unsupported host operation');
|
||||
},
|
||||
releaseRuntime,
|
||||
shutdown() { closed = true; for (const handle of [...turns.keys()]) cancel(handle); models.clear(); sources.clear(); },
|
||||
shutdown() { closed = true; for (const selection of selections) selection.active = false; selections.clear(); for (const handle of [...turns.keys()]) cancel(handle); models.clear(); sources.clear(); },
|
||||
};
|
||||
}
|
||||
@@ -1042,6 +1042,7 @@
|
||||
"request"
|
||||
],
|
||||
"properties": {
|
||||
"permissionMode": { "enum": ["allow", "ask", "deny"] },
|
||||
"requestId": {
|
||||
"type": "string",
|
||||
"maxLength": 128
|
||||
@@ -1697,6 +1698,8 @@
|
||||
"enum": [
|
||||
"storage",
|
||||
"ai.invoke",
|
||||
"ai.agent.use",
|
||||
"ai.agent.actions",
|
||||
"context.selected.list",
|
||||
"context.selected.read",
|
||||
"runtime.advanced",
|
||||
@@ -1741,6 +1744,8 @@
|
||||
"enum": [
|
||||
"storage",
|
||||
"ai.invoke",
|
||||
"ai.agent.use",
|
||||
"ai.agent.actions",
|
||||
"context.selected.list",
|
||||
"context.selected.read",
|
||||
"runtime.advanced",
|
||||
@@ -3737,6 +3742,34 @@
|
||||
"maxItems": 128,
|
||||
"items": { "$ref": "#/$defs/PermissionResourceKind" }
|
||||
},
|
||||
"displayContext": {
|
||||
"type": "object", "additionalProperties": false,
|
||||
"properties": {
|
||||
"modelLabel": { "type": "string", "minLength": 1, "maxLength": 160 },
|
||||
"selections": {
|
||||
"type": "array", "maxItems": 5,
|
||||
"items": {
|
||||
"type": "object", "required": ["title", "scope", "version"], "additionalProperties": false,
|
||||
"properties": {
|
||||
"title": { "type": "string", "maxLength": 160 },
|
||||
"scope": { "enum": ["workspace", "tab", "session", "host", "note"] },
|
||||
"version": { "type": "string", "minLength": 1, "maxLength": 128 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"relatedPermissions": {
|
||||
"type": "array", "maxItems": 5,
|
||||
"items": {
|
||||
"type": "object", "required": ["permission", "resources"],
|
||||
"properties": {
|
||||
"permission": { "$ref": "#/$defs/PluginPermission" },
|
||||
"resources": { "type": "array", "minItems": 1, "maxItems": 128, "uniqueItems": true, "items": { "$ref": "#/$defs/PermissionResource" } }
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"reason": { "type": "string", "minLength": 1, "maxLength": 1024 },
|
||||
"operationId": { "type": "string", "minLength": 1, "maxLength": 128 },
|
||||
"sessionId": { "type": "string", "minLength": 1, "maxLength": 128 },
|
||||
@@ -4975,6 +5008,402 @@
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"PluginAgentFeature": {
|
||||
"const": "ai.agent-sessions"
|
||||
},
|
||||
"PluginAgentBackendKind": {
|
||||
"enum": [
|
||||
"catty",
|
||||
"claude",
|
||||
"codex",
|
||||
"copilot",
|
||||
"cursor",
|
||||
"codebuddy",
|
||||
"opencode",
|
||||
"grok",
|
||||
"mimo"
|
||||
]
|
||||
},
|
||||
"PluginAgentBackend": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"backendRef",
|
||||
"label",
|
||||
"backend",
|
||||
"status",
|
||||
"reason"
|
||||
],
|
||||
"properties": {
|
||||
"backendRef": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
},
|
||||
"label": {
|
||||
"type": "string",
|
||||
"maxLength": 160
|
||||
},
|
||||
"backend": {
|
||||
"$ref": "#/$defs/PluginAgentBackendKind"
|
||||
},
|
||||
"status": {
|
||||
"enum": [
|
||||
"supported",
|
||||
"unsupported",
|
||||
"unavailable"
|
||||
]
|
||||
},
|
||||
"reason": {
|
||||
"enum": [
|
||||
"ready",
|
||||
"not-configured",
|
||||
"native-boundary-unverified",
|
||||
"configuration-unavailable"
|
||||
]
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"PluginAgentBackendsResult": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"items"
|
||||
],
|
||||
"properties": {
|
||||
"items": {
|
||||
"type": "array",
|
||||
"maxItems": 32,
|
||||
"items": {
|
||||
"$ref": "#/$defs/PluginAgentBackend"
|
||||
}
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"PluginAgentTargetSummary": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"title",
|
||||
"kind"
|
||||
],
|
||||
"properties": {
|
||||
"title": {
|
||||
"type": "string",
|
||||
"maxLength": 160
|
||||
},
|
||||
"kind": {
|
||||
"const": "terminal"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"PluginAgentTargetsResult": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"selectionHandle",
|
||||
"expiresAt",
|
||||
"items"
|
||||
],
|
||||
"properties": {
|
||||
"selectionHandle": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
},
|
||||
"expiresAt": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
},
|
||||
"items": {
|
||||
"type": "array",
|
||||
"maxItems": 32,
|
||||
"items": {
|
||||
"$ref": "#/$defs/PluginAgentTargetSummary"
|
||||
}
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"PluginAgentOpenParams": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"backendRef",
|
||||
"selectionHandle"
|
||||
],
|
||||
"properties": {
|
||||
"backendRef": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
},
|
||||
"selectionHandle": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
},
|
||||
"conversationRef": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"PluginAgentChatRef": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"chatHandle"
|
||||
],
|
||||
"properties": {
|
||||
"chatHandle": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"PluginAgentChatResult": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"chatHandle",
|
||||
"conversationRef",
|
||||
"backendLabel",
|
||||
"expiresAt"
|
||||
],
|
||||
"properties": {
|
||||
"chatHandle": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
},
|
||||
"conversationRef": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
},
|
||||
"backendLabel": {
|
||||
"type": "string",
|
||||
"maxLength": 160
|
||||
},
|
||||
"expiresAt": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"PluginAgentChatSummary": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"conversationRef",
|
||||
"title",
|
||||
"backend",
|
||||
"updatedAt"
|
||||
],
|
||||
"properties": {
|
||||
"conversationRef": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
},
|
||||
"title": {
|
||||
"type": "string",
|
||||
"maxLength": 160
|
||||
},
|
||||
"backend": {
|
||||
"$ref": "#/$defs/PluginAgentBackendKind"
|
||||
},
|
||||
"updatedAt": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"PluginAgentChatsResult": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"items"
|
||||
],
|
||||
"properties": {
|
||||
"items": {
|
||||
"type": "array",
|
||||
"maxItems": 64,
|
||||
"items": {
|
||||
"$ref": "#/$defs/PluginAgentChatSummary"
|
||||
}
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"PluginAgentSendParams": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"chatHandle",
|
||||
"text"
|
||||
],
|
||||
"properties": {
|
||||
"chatHandle": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
},
|
||||
"text": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 16384,
|
||||
"pattern": "^[^\\u0000]*$"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"PluginAgentTurnRef": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"turnHandle"
|
||||
],
|
||||
"properties": {
|
||||
"turnHandle": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"PluginAgentTurnResult": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"turnHandle",
|
||||
"expiresAt"
|
||||
],
|
||||
"properties": {
|
||||
"turnHandle": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
},
|
||||
"expiresAt": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"PluginAgentMessage": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"role",
|
||||
"text"
|
||||
],
|
||||
"properties": {
|
||||
"role": {
|
||||
"enum": [
|
||||
"user",
|
||||
"assistant"
|
||||
]
|
||||
},
|
||||
"text": {
|
||||
"type": "string",
|
||||
"maxLength": 32768
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"PluginAgentHistoryResult": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"items"
|
||||
],
|
||||
"properties": {
|
||||
"items": {
|
||||
"type": "array",
|
||||
"maxItems": 200,
|
||||
"items": {
|
||||
"$ref": "#/$defs/PluginAgentMessage"
|
||||
}
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"PluginAgentEvent": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"required": [
|
||||
"kind",
|
||||
"text"
|
||||
],
|
||||
"properties": {
|
||||
"kind": {
|
||||
"const": "text"
|
||||
},
|
||||
"text": {
|
||||
"type": "string",
|
||||
"maxLength": 4096
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"required": [
|
||||
"kind",
|
||||
"capabilityId",
|
||||
"state"
|
||||
],
|
||||
"properties": {
|
||||
"kind": {
|
||||
"const": "activity"
|
||||
},
|
||||
"capabilityId": {
|
||||
"type": "string",
|
||||
"maxLength": 160
|
||||
},
|
||||
"state": {
|
||||
"enum": [
|
||||
"started",
|
||||
"completed",
|
||||
"denied"
|
||||
]
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"required": [
|
||||
"kind",
|
||||
"inputTokens",
|
||||
"outputTokens"
|
||||
],
|
||||
"properties": {
|
||||
"kind": {
|
||||
"const": "usage"
|
||||
},
|
||||
"inputTokens": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
},
|
||||
"outputTokens": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"required": [
|
||||
"kind",
|
||||
"reason"
|
||||
],
|
||||
"properties": {
|
||||
"kind": {
|
||||
"const": "end"
|
||||
},
|
||||
"reason": {
|
||||
"enum": [
|
||||
"completed",
|
||||
"cancelled",
|
||||
"unavailable",
|
||||
"budget-exceeded"
|
||||
]
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -223,6 +223,7 @@ export type HostInteractionFeature = "host.interactions";
|
||||
export type HostInteractionLimits = {"textBytes":4096,"requestBytes":65536,"items":100,"pendingPerRuntime":1,"pendingTotal":8,"requestsPerMinute":10,"timeoutMs":60000};
|
||||
|
||||
export type HostInteractionPrompt = {
|
||||
permissionMode?: "allow" | "ask" | "deny";
|
||||
requestId: string;
|
||||
actionToken: string;
|
||||
pluginId: PluginId;
|
||||
@@ -485,7 +486,7 @@ export type MenuLocation = "commandPalette" | "application" | "host/context" | "
|
||||
|
||||
export type NetworkCredentialLeaseFeature = "network.credential-lease";
|
||||
|
||||
export type NonResourceScopedPermission = "storage" | "ai.invoke" | "context.selected.list" | "context.selected.read" | "runtime.advanced" | "settings.read" | "settings.write" | "commands" | "menus" | "views" | "ui.interact" | "clipboard.read" | "clipboard.write" | "terminal.metadata" | "terminal.output" | "terminal.input" | "terminal.decorate" | "terminal.complete" | "terminal.intercept.input" | "terminal.intercept.output" | "vault.metadata" | "vault.write" | "vault.credentials" | "sftp.read" | "sftp.write" | "secrets" | "provider.terminal" | "provider.connection" | "provider.authentication" | "provider.sync" | "provider.importer" | "terminal.selection.read" | "terminal.input.propose";
|
||||
export type NonResourceScopedPermission = "storage" | "ai.invoke" | "ai.agent.use" | "ai.agent.actions" | "context.selected.list" | "context.selected.read" | "runtime.advanced" | "settings.read" | "settings.write" | "commands" | "menus" | "views" | "ui.interact" | "clipboard.read" | "clipboard.write" | "terminal.metadata" | "terminal.output" | "terminal.input" | "terminal.decorate" | "terminal.complete" | "terminal.intercept.input" | "terminal.intercept.output" | "vault.metadata" | "vault.write" | "vault.credentials" | "sftp.read" | "sftp.write" | "secrets" | "provider.terminal" | "provider.connection" | "provider.authentication" | "provider.sync" | "provider.importer" | "terminal.selection.read" | "terminal.input.propose";
|
||||
|
||||
export type NullableRpcId = (RpcId) | (null);
|
||||
|
||||
@@ -520,6 +521,18 @@ export type PermissionRequest = {
|
||||
permission: PluginPermission;
|
||||
resources?: Array<PermissionResource>;
|
||||
resourceKinds?: Array<PermissionResourceKind>;
|
||||
displayContext?: {
|
||||
modelLabel?: string;
|
||||
selections?: Array<{
|
||||
title: string;
|
||||
scope: "workspace" | "tab" | "session" | "host" | "note";
|
||||
version: string;
|
||||
}>;
|
||||
};
|
||||
relatedPermissions?: Array<{
|
||||
permission: PluginPermission;
|
||||
resources: Array<PermissionResource>;
|
||||
}>;
|
||||
reason: string;
|
||||
operationId?: string;
|
||||
sessionId?: string;
|
||||
@@ -541,6 +554,100 @@ export type PermissionSet = {
|
||||
optional?: Array<PermissionDeclaration>;
|
||||
};
|
||||
|
||||
export type PluginAgentBackend = {
|
||||
backendRef: string;
|
||||
label: string;
|
||||
backend: PluginAgentBackendKind;
|
||||
status: "supported" | "unsupported" | "unavailable";
|
||||
reason: "ready" | "not-configured" | "native-boundary-unverified" | "configuration-unavailable";
|
||||
};
|
||||
|
||||
export type PluginAgentBackendKind = "catty" | "claude" | "codex" | "copilot" | "cursor" | "codebuddy" | "opencode" | "grok" | "mimo";
|
||||
|
||||
export type PluginAgentBackendsResult = {
|
||||
items: Array<PluginAgentBackend>;
|
||||
};
|
||||
|
||||
export type PluginAgentChatRef = {
|
||||
chatHandle: string;
|
||||
};
|
||||
|
||||
export type PluginAgentChatResult = {
|
||||
chatHandle: string;
|
||||
conversationRef: string;
|
||||
backendLabel: string;
|
||||
expiresAt: number;
|
||||
};
|
||||
|
||||
export type PluginAgentChatsResult = {
|
||||
items: Array<PluginAgentChatSummary>;
|
||||
};
|
||||
|
||||
export type PluginAgentChatSummary = {
|
||||
conversationRef: string;
|
||||
title: string;
|
||||
backend: PluginAgentBackendKind;
|
||||
updatedAt: number;
|
||||
};
|
||||
|
||||
export type PluginAgentEvent = ({
|
||||
kind: "text";
|
||||
text: string;
|
||||
}) | ({
|
||||
kind: "activity";
|
||||
capabilityId: string;
|
||||
state: "started" | "completed" | "denied";
|
||||
}) | ({
|
||||
kind: "usage";
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
}) | ({
|
||||
kind: "end";
|
||||
reason: "completed" | "cancelled" | "unavailable" | "budget-exceeded";
|
||||
});
|
||||
|
||||
export type PluginAgentFeature = "ai.agent-sessions";
|
||||
|
||||
export type PluginAgentHistoryResult = {
|
||||
items: Array<PluginAgentMessage>;
|
||||
};
|
||||
|
||||
export type PluginAgentMessage = {
|
||||
role: "user" | "assistant";
|
||||
text: string;
|
||||
};
|
||||
|
||||
export type PluginAgentOpenParams = {
|
||||
backendRef: string;
|
||||
selectionHandle: string;
|
||||
conversationRef?: string;
|
||||
};
|
||||
|
||||
export type PluginAgentSendParams = {
|
||||
chatHandle: string;
|
||||
text: string;
|
||||
};
|
||||
|
||||
export type PluginAgentTargetsResult = {
|
||||
selectionHandle: string;
|
||||
expiresAt: number;
|
||||
items: Array<PluginAgentTargetSummary>;
|
||||
};
|
||||
|
||||
export type PluginAgentTargetSummary = {
|
||||
title: string;
|
||||
kind: "terminal";
|
||||
};
|
||||
|
||||
export type PluginAgentTurnRef = {
|
||||
turnHandle: string;
|
||||
};
|
||||
|
||||
export type PluginAgentTurnResult = {
|
||||
turnHandle: string;
|
||||
expiresAt: number;
|
||||
};
|
||||
|
||||
export type PluginAIBooleanResult = {
|
||||
ok: boolean;
|
||||
};
|
||||
@@ -826,7 +933,7 @@ export type PluginNetworkResponse = {
|
||||
};
|
||||
};
|
||||
|
||||
export type PluginPermission = "storage" | "ai.invoke" | "context.selected.list" | "context.selected.read" | "runtime.advanced" | "settings.read" | "settings.write" | "commands" | "menus" | "views" | "ui.interact" | "clipboard.read" | "clipboard.write" | "terminal.metadata" | "terminal.output" | "terminal.input" | "terminal.decorate" | "terminal.complete" | "terminal.intercept.input" | "terminal.intercept.output" | "vault.metadata" | "vault.write" | "vault.credentials" | "sftp.read" | "sftp.write" | "network" | "network.stream" | "filesystem.read" | "filesystem.write" | "secrets" | "companion.execute" | "provider.terminal" | "provider.connection" | "provider.authentication" | "provider.sync" | "provider.importer" | "terminal.selection.read" | "terminal.input.propose";
|
||||
export type PluginPermission = "storage" | "ai.invoke" | "ai.agent.use" | "ai.agent.actions" | "context.selected.list" | "context.selected.read" | "runtime.advanced" | "settings.read" | "settings.write" | "commands" | "menus" | "views" | "ui.interact" | "clipboard.read" | "clipboard.write" | "terminal.metadata" | "terminal.output" | "terminal.input" | "terminal.decorate" | "terminal.complete" | "terminal.intercept.input" | "terminal.intercept.output" | "vault.metadata" | "vault.write" | "vault.credentials" | "sftp.read" | "sftp.write" | "network" | "network.stream" | "filesystem.read" | "filesystem.write" | "secrets" | "companion.execute" | "provider.terminal" | "provider.connection" | "provider.authentication" | "provider.sync" | "provider.importer" | "terminal.selection.read" | "terminal.input.propose";
|
||||
|
||||
export type PluginSelectedContextContent = {
|
||||
handle: string;
|
||||
|
||||
@@ -55,6 +55,7 @@ export { inspectPluginBrandIcon, PLUGIN_BRAND_ICON_FEATURE, PLUGIN_BRAND_ICON_MA
|
||||
export const PLUGIN_NETWORK_CREDENTIAL_LEASE_FEATURE = "network.credential-lease" as const satisfies import("./generated/plugin-contract.js").NetworkCredentialLeaseFeature;
|
||||
export const PLUGIN_BYTE_STREAM_FEATURE = "network.byte-streams" as const satisfies import("./generated/plugin-contract.js").ByteStreamFeature;
|
||||
export const PLUGIN_AI_MODEL_INVOCATION_FEATURE = "ai.model-invocation" as const satisfies import("./generated/plugin-contract.js").PluginAIModelInvocationFeature;
|
||||
export const PLUGIN_AGENT_SESSIONS_FEATURE = "ai.agent-sessions" as const satisfies import("./generated/plugin-contract.js").PluginAgentFeature;
|
||||
export const PLUGIN_SELECTED_CONTEXT_FEATURE = "context.selected-read" as const satisfies import("./generated/plugin-contract.js").PluginSelectedContextFeature;
|
||||
|
||||
export const PLUGIN_HOST_INTERACTION_FEATURE = "host.interactions" as const satisfies import("./generated/plugin-contract.js").HostInteractionFeature;
|
||||
|
||||
@@ -685,6 +685,19 @@ export interface PluginSelectedContextClient {
|
||||
read(params: import("@netcatty/plugin-contract").PluginSelectedContextRef): Promise<import("@netcatty/plugin-contract").PluginSelectedContextContent>;
|
||||
}
|
||||
|
||||
/** Configurations, targets and native resume IDs stay in the trusted host. */
|
||||
export interface PluginAgentClient {
|
||||
listBackends(params: import("@netcatty/plugin-contract").PluginAIEmptyParams): Promise<import("@netcatty/plugin-contract").PluginAgentBackendsResult>;
|
||||
selectTargets(params: import("@netcatty/plugin-contract").PluginAIEmptyParams): Promise<import("@netcatty/plugin-contract").PluginAgentTargetsResult>;
|
||||
listChats(params: import("@netcatty/plugin-contract").PluginAIEmptyParams): Promise<import("@netcatty/plugin-contract").PluginAgentChatsResult>;
|
||||
openChat(params: import("@netcatty/plugin-contract").PluginAgentOpenParams): Promise<import("@netcatty/plugin-contract").PluginAgentChatResult>;
|
||||
send(params: import("@netcatty/plugin-contract").PluginAgentSendParams): Promise<import("@netcatty/plugin-contract").PluginAgentTurnResult>;
|
||||
read(params: import("@netcatty/plugin-contract").PluginAgentTurnRef): Promise<import("@netcatty/plugin-contract").PluginAgentEvent>;
|
||||
history(params: import("@netcatty/plugin-contract").PluginAgentChatRef): Promise<import("@netcatty/plugin-contract").PluginAgentHistoryResult>;
|
||||
stop(params: import("@netcatty/plugin-contract").PluginAgentTurnRef): Promise<import("@netcatty/plugin-contract").PluginAIBooleanResult>;
|
||||
closeChat(params: import("@netcatty/plugin-contract").PluginAgentChatRef): Promise<import("@netcatty/plugin-contract").PluginAIBooleanResult>;
|
||||
}
|
||||
|
||||
export interface PluginByteConnection extends Disposable {
|
||||
readonly endpoint: string;
|
||||
readonly readable: PluginReadableByteStream;
|
||||
@@ -784,6 +797,7 @@ export interface PluginContext {
|
||||
readonly credentials: PluginCredentialBroker;
|
||||
readonly network: PluginNetworkClient;
|
||||
readonly ai: PluginAIClient;
|
||||
readonly agent: PluginAgentClient;
|
||||
readonly selectedContext: PluginSelectedContextClient;
|
||||
readonly filesystem: PluginFilesystemClient;
|
||||
readonly companions: PluginCompanionService;
|
||||
|
||||
@@ -9,12 +9,24 @@ async function fixture(t, options = {}) {
|
||||
const root = path.resolve(__dirname, "../examples/plugins/assistant");
|
||||
const dom = new JSDOM(fs.readFileSync(path.join(root, "panel.html"), "utf8"), { runScripts: "outside-only" });
|
||||
t.after(() => dom.window.close());
|
||||
const stylesheet = dom.window.document.createElement("style");
|
||||
stylesheet.textContent = fs.readFileSync(path.join(root, "panel.css"), "utf8");
|
||||
dom.window.document.head.append(stylesheet);
|
||||
let now = 0; let opens = 0; let modelCalls = 0; let changed = false;
|
||||
const calls = [];
|
||||
const environmentListeners = new Set(); let environment;
|
||||
const element = (id) => dom.window.document.getElementById(id);
|
||||
dom.window.Date.now = options.clock ?? (() => now);
|
||||
Object.defineProperty(dom.window, "netcattyView", { value: {
|
||||
onDidReceiveMessage: () => () => {},
|
||||
onDidChangeEnvironment: (listener) => {
|
||||
environmentListeners.add(listener);
|
||||
if (environment) {
|
||||
const replay = environment;
|
||||
queueMicrotask(() => { if (environmentListeners.has(listener) && replay === environment) listener(replay); });
|
||||
}
|
||||
return { dispose: () => environmentListeners.delete(listener) };
|
||||
},
|
||||
executeCommand: async (id, params) => {
|
||||
const operation = id.replace("com.netcatty.assistant.", ""); calls.push({ operation, params });
|
||||
if (options.commands) return options.commands(operation, structuredClone(params));
|
||||
@@ -45,11 +57,43 @@ async function fixture(t, options = {}) {
|
||||
dom.window.eval(fs.readFileSync(path.join(root, "panel.js"), "utf8"));
|
||||
await waitFor(() => element("status").textContent === "Ready");
|
||||
element("prompt").value = "Synthetic draft must survive session recovery";
|
||||
return { element, calls, waitFor, opens: () => opens, modelCalls: () => modelCalls,
|
||||
return { element, calls, waitFor, window: dom.window, opens: () => opens, modelCalls: () => modelCalls,
|
||||
emitEnvironment: (value) => { environment = value; for (const listener of environmentListeners) listener(value); },
|
||||
environmentListeners: () => environmentListeners.size,
|
||||
timeTo: (value) => { now = value; }, changeModel: () => { changed = true; },
|
||||
send: () => element("compose").dispatchEvent(new dom.window.Event("submit", { cancelable: true })) };
|
||||
}
|
||||
|
||||
test("assistant follows host light/dark and accent updates with safe fallbacks and subscription cleanup", async (t) => {
|
||||
const f = await fixture(t); const root = f.window.document.documentElement;
|
||||
const computed = () => f.window.getComputedStyle(root);
|
||||
const calls = f.calls.length;
|
||||
assert.equal(computed().colorScheme, "light dark");
|
||||
assert.equal(computed().getPropertyValue("--background").trim(), "0 0% 100%");
|
||||
assert.equal(f.environmentListeners(), 1);
|
||||
f.emitEnvironment({ theme: "light", themeTokens: { "--background": "20 20% 98%", "--primary": "145 60% 35%", "--muted": "30 10% 90%", "--private-token": "ignored" } });
|
||||
assert.equal(root.style.colorScheme, "light"); assert.equal(root.dataset.theme, "light");
|
||||
assert.equal(root.style.getPropertyValue("--primary"), "145 60% 35%");
|
||||
assert.equal(root.style.getPropertyValue("--private-token"), "");
|
||||
f.emitEnvironment({ theme: "dark", themeTokens: { "--background": "220 20% 10%", "--primary": "280 80% 75%" } });
|
||||
assert.equal(root.style.colorScheme, "dark");
|
||||
assert.equal(root.style.getPropertyValue("--primary"), "280 80% 75%");
|
||||
assert.equal(root.style.getPropertyValue("--muted"), "");
|
||||
assert.equal(computed().getPropertyValue("--muted").trim(), "219 27% 21%");
|
||||
f.emitEnvironment({ theme: "light", themeTokens: { "--primary": "x".repeat(97) } });
|
||||
assert.equal(root.style.colorScheme, "light"); assert.equal(root.style.getPropertyValue("--primary"), "");
|
||||
assert.equal(computed().getPropertyValue("--background").trim(), "0 0% 100%");
|
||||
f.emitEnvironment({ theme: "system" }); assert.equal(root.style.colorScheme, "light dark");
|
||||
assert.equal(f.calls.length, calls); assert.equal(f.modelCalls(), 0);
|
||||
f.window.dispatchEvent(new f.window.Event("pagehide")); assert.equal(f.environmentListeners(), 0);
|
||||
f.emitEnvironment({ theme: "dark", themeTokens: { "--primary": "210 90% 70%" } });
|
||||
assert.equal(root.style.colorScheme, "light dark");
|
||||
f.window.dispatchEvent(new f.window.Event("pageshow"));
|
||||
await new Promise(setImmediate);
|
||||
assert.equal(f.environmentListeners(), 1); assert.equal(root.style.colorScheme, "dark");
|
||||
assert.equal(root.style.getPropertyValue("--primary"), "210 90% 70%");
|
||||
});
|
||||
|
||||
test("expired sample chat reopens before one explicit send and preserves the draft", async (t) => {
|
||||
const f = await fixture(t); f.timeTo(300_001); f.send();
|
||||
await f.waitFor(() => !f.element("prompt").disabled);
|
||||
@@ -94,10 +138,10 @@ test("Stop during expired-session reopening discards the late session without se
|
||||
assert.match(f.element("prompt").value, /must survive/);
|
||||
});
|
||||
|
||||
test("expired real selected Note requires explicit re-selection, then fresh content permission and review", async (t) => {
|
||||
test("expired real selected Note requires explicit re-selection and fresh content permission without duplicate review", async (t) => {
|
||||
const { PluginAIContextService } = require("../electron/plugins/ai/aiContextService.cjs");
|
||||
let now = 0; let selections = 0; let reads = 0; let modelCalls = 0;
|
||||
let contentAllowed = false; let reviewAllowed = false;
|
||||
let contentAllowed = false;
|
||||
const grants = []; const reviews = [];
|
||||
const caller = { pluginId: "com.netcatty.assistant", runtimeId: "selected-note-panel", pluginVersion: "0.1.0", assertActive: async () => {} };
|
||||
const source = { id: "synthetic-source", kind: "third-party", origin: "https://synthetic.example" };
|
||||
@@ -120,7 +164,7 @@ test("expired real selected Note requires explicit re-selection, then fresh cont
|
||||
return [{ key: "synthetic-note", scope: "note", title: "Synthetic selected Note", version: "v1", isCurrent: () => true,
|
||||
readText: () => { reads++; return "Synthetic selected Note body"; } }];
|
||||
},
|
||||
reviewText: async (text) => { reviews.push(text); return reviewAllowed; },
|
||||
reviewText: async (text) => { reviews.push(text); throw Error("Unexpected duplicate content review"); },
|
||||
},
|
||||
});
|
||||
service.selected.clock = () => now; service.broker.clock = () => now;
|
||||
@@ -156,11 +200,8 @@ test("expired real selected Note requires explicit re-selection, then fresh cont
|
||||
f.send(); await f.waitFor(() => f.element("status").textContent.startsWith("Request ended"));
|
||||
assert.equal(grants.length, 1); assert.equal(reads, 0); assert.equal(modelCalls, 0);
|
||||
contentAllowed = true;
|
||||
f.send(); await f.waitFor(() => f.element("status").textContent.startsWith("Request ended"));
|
||||
assert.equal(reviews.length, 1); assert.equal(reviews[0].text, "Synthetic selected Note body"); assert.equal(modelCalls, 0);
|
||||
reviewAllowed = true;
|
||||
f.send(); await f.waitFor(() => f.element("status").textContent === "Complete");
|
||||
assert.equal(modelCalls, 1); assert.equal(reviews.length, 2);
|
||||
assert.equal(modelCalls, 1); assert.equal(reviews.length, 0);
|
||||
assert.ok(grants.every((grant) => grant.permission === "context.selected.read" && grant.allowedScopes[0] === "once"
|
||||
&& grant.resources[0] === `selected:${newHandle}:v1`));
|
||||
assert.match(f.element("prompt").value, /must survive/);
|
||||
|
||||
Reference in new issue
Block a user