Compare commits

..
Author SHA1 Message Date
陈奇 a36a906292 fix(plugins): negotiate directory enumeration only with a secure adapter 2026-10-27 12:23:20 +00:00
陈奇 05a773b426 docs(plugins): clarify default directory enumeration boundary 2026-10-27 11:25:54 +00:00
陈奇 d40b0d3c25 docs(plugins): prepare Settings visibility and current-main acceptance handoff 2026-10-27 07:08:50 +00:00
陈奇 714e2397a4 Merge reviewed Settings native-view visibility correction 2026-10-27 06:59:39 +00:00
陈奇 c4aedfa9ed Merge current main owner handoff and prompt fixes into isolated plugin acceptance 2026-10-27 06:59:39 +00:00
陈奇 1551fa8355 fix(plugins): suspend native views only for active log dialogs 2026-10-27 06:55:01 +00:00
a0cf2b487c fix(mcp): recognize Oh My Zsh ys two-line idle prompts (#4008)
The two-line idle prompt check only accepted a classic user@host host
line, so Oh My Zsh ys (`# user @ host ...` then a bare $/#) never became
ready and terminal_execute sent nothing. Match that host line narrowly
and still reject a lone marker.

Fixes #4007

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-27 14:31:21 +08:00
陈大猫 e3e2dcfe02 fix(ai): keep dissolved workspace chat on its surviving owner (#4003)
Fixes #3998

Keep terminal-scoped AI chat on its surviving owner pane when dissolving a workspace; remint onto preferred only when the owner is gone.
2026-10-27 13:58:44 +08:00
陈奇 2433cf0130 docs(plugins): prepare isolated Ask routing QA and preserve 020 results 2026-10-27 05:46:36 +00:00
陈奇 1b21c41fa2 Merge reviewed managed-window Ask routing into isolated acceptance candidate 2026-10-27 05:31:36 +00:00
陈奇 89beeb9374 fix(plugins): route Ask to focused managed app windows 2026-10-27 05:06:38 +00:00
陈奇 02049102b6 docs(plugins): refresh final policy candidate provenance and native limits 2026-10-27 04:07:45 +00:00
陈奇 cfa13680e6 Merge authoritative policy disclosure correction preserving all assertions 2026-10-27 04:01:44 +00:00
陈奇 79a9bd6df1 fix(plugins): describe policy modes without mandatory consent copy 2026-10-27 03:51:51 +00:00
陈奇 7856c664c3 docs(plugins): record isolated policy and AI candidate with exact sample provenance 2026-10-27 03:43:01 +00:00
陈奇 7d8a354404 test(plugins): align package disclosure assertions with declared capability policy 2026-10-27 03:34:50 +00:00
陈奇 da4e80946d test(plugins): align package details with default Allow policy 2026-10-27 03:34:32 +00:00
陈奇 5d2ff1cb86 Merge reviewed AI startup and themed sample while preserving permission policy assertions 2026-10-27 03:25:05 +00:00
陈奇 9aa899059f Merge declared capability policy corrections into isolated acceptance candidate 2026-10-27 03:24:18 +00:00
陈奇 2eee561847 fix(plugins): bind scoped Ask receipts and revoke advanced activations 2026-10-27 03:17:53 +00:00
陈奇 d33c0193cc test(plugin-ai): drain Node module warnings before observing SDK errors 2026-10-27 02:33:17 +00:00
陈奇 643e71b17b feat(plugins): default declared capabilities to allow with scoped policy controls 2026-10-27 02:28:08 +00:00
陈奇 e325742abf fix(example-assistant): follow host theme and accent environment 2026-10-27 02:22:20 +00:00
陈奇 9ca70f3290 docs(plugin-ai): record cold-start evidence and native verification limits 2026-10-27 02:03:21 +00:00
陈奇 092438803b fix(plugin-ai): prepare host provider transport independently of the AI sidebar 2026-10-27 02:00:37 +00:00
陈奇 677aac33b1 docs(plugins): hand off verified correction candidate and rebuilt sample hashes 2026-10-26 20:15:22 +00:00
陈奇 24254ed737 Merge verified assistant selected-Note recovery into acceptance 2026-10-26 20:12:05 +00:00
陈奇 bbcf9c01c3 Merge verified terminal selection and inert draft correction into acceptance 2026-10-26 20:08:08 +00:00
陈奇 ba09461676 test(plugin-ai): verify repeated selected Note expiry recovery and update QA 2026-10-26 19:55:58 +00:00
陈奇 54b6cd401f docs(plugins): record completed AI review and active native holds 2026-10-26 19:55:55 +00:00
陈奇 9e7c9df58e fix(example-assistant): explicitly recover expired selected Note references 2026-10-26 19:53:18 +00:00
陈奇 824c3838e9 Merge published AI source-budget and model-session fixes provisionally 2026-10-26 19:50:06 +00:00
陈奇 35b65ef1d7 Merge verified settings gear focus correction into acceptance 2026-10-26 19:46:41 +00:00
陈奇 07eb68c5e7 Merge current source and stream base into plugin AI corrections 2026-10-26 19:34:20 +00:00
陈奇 d81896b0d3 docs(plugin-ai): record bounded review repairs and replacement sample hash 2026-10-26 19:25:57 +00:00
陈奇 e1cb95ee87 fix(example-assistant): recover expired model sessions without inference retries 2026-10-26 19:23:22 +00:00
陈奇 ee5a4a4067 fix(plugin-ai): preserve source budgets across runtime replacement 2026-10-26 19:23:22 +00:00
陈奇 823db94cf3 fix(plugins): focus settings after direct gear navigation 2026-10-26 18:56:13 +00:00
陈奇 174c525cd4 test(plugins): validate unified acceptance entrypoints and record gates 2026-10-26 18:51:33 +00:00
陈奇 2278681787 Merge verified settings and logging UI into acceptance candidate 2026-10-26 18:34:59 +00:00
陈奇 31d61e3040 Merge verified host and Vault actions into acceptance candidate 2026-10-26 18:34:25 +00:00
陈奇 52d4af2a86 Merge verified plugin byte stream integration onto acceptance main 2026-10-26 18:34:02 +00:00
陈奇 dc6e645fab fix(plugins): prebundle permission panel helper in Vite development 2026-10-26 18:18:52 +00:00
陈奇 41313293e1 fix(i18n): preserve Spanish parity for plugin settings 2026-10-26 17:11:19 +00:00
binaricat 730fa643ba fix(plugins): align host confirmation and refuse managed drafts 2026-10-26 17:07:41 +00:00
陈奇 d16e21c848 docs(plugins): describe compact settings and manual update checks 2026-10-26 17:06:45 +00:00
陈奇 e6dd68b209 feat(plugins): check published updates from bound source catalogs 2026-10-26 17:00:56 +00:00
陈奇 eb6772355e fix(plugins): preserve settings navigation and accessible dialog controls 2026-10-26 16:42:16 +00:00
binaricat 267bcbfb8b fix(plugins): reconcile shared permission descriptions 2026-10-26 16:37:40 +00:00
陈奇 17c48b4d79 fix(plugin): describe scoped AI and stream permissions 2026-10-26 16:36:39 +00:00
陈奇 8eefd9de96 feat(plugins): use compact settings rows and installed quick controls 2026-10-26 16:29:18 +00:00
binaricat 739cab2965 fix(plugins): lock inherited assistant example workspace 2026-10-26 16:18:09 +00:00
binaricat cecfa3a47e fix(plugins): complete host action dialog locale parity 2026-10-26 16:11:33 +00:00
binaricat 0a590b4ebc feat(plugins): wire confirmed host and Vault actions 2026-10-26 16:07:10 +00:00
binaricat 69c2cc8d1f Merge commit 'c4433d4961ecf4a81bea92b0a14f47b4ec12f679' into codex/plugin-host-vault 2026-10-26 15:41:43 +00:00
binaricat ccfe41f39a feat(plugins): prepare host action service and renderer execution 2026-10-26 15:41:42 +00:00
binaricat d5cb8cfd3b feat(plugins): prepare safe host draft review boundaries 2026-10-26 15:24:10 +00:00
陈奇 2a9c310498 Merge frozen plugin sources and logging for native QA
Source parent: 4213d8e672
Logging parent: 9b853130ca

Ordinary merge with no manual conflicts or additional source edits.
2026-10-26 15:14:08 +00:00
陈奇 9b853130ca fix(plugins): bind log provenance and enforce segment retention 2026-10-26 15:00:54 +00:00
陈奇 e3cc23bca8 feat(plugins): add opt-in local diagnostics and host audit logs 2026-10-26 14:26:41 +00:00
151 changed files with 9965 additions and 556 deletions

No files matched your search

+4
View File
@@ -12,7 +12,9 @@ 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';
import { MultilinePasteConfirmHost } from './components/terminal/MultilinePasteConfirmDialog';
import { useExternalMcpGrantPersister } from './components/ai/useExternalMcpGrantPersister';
@@ -87,11 +89,13 @@ function AppWithProviders({ settings, appLock }: { settings: SettingsState; appL
<ScriptAutomationRoot />
<ExternalMcpApprovalsHost />
<PluginAuthenticationHost />
<PluginPermissionHost />
<PluginHostInteractionHost />
<MultilinePasteConfirmHost />
<VaultPublisher>
<SessionPublisher persistSessionRestore={!isPeerSessionWindow}>
<App />
<PluginHostActionHost />
</SessionPublisher>
</VaultPublisher>
</TooltipProvider>
+51 -6
View File
@@ -1,6 +1,32 @@
import type { Messages } from '../types';
export const enCoreMessages: Messages = {
"settings.plugins.landing.checkUpdates": "Check for updates",
"settings.plugins.landing.checkingUpdates": "Checking…",
"settings.plugins.landing.updateSummary": "Checked: {checked} · Published updates: {updates} · Not checked: {skipped}.",
"settings.plugins.landing.updateSourcesFailed": "Unavailable sources: {count}.",
"settings.plugins.landing.updateReview": "Review updates in the plugin manager before installing.",
"settings.plugins.landing.updateAvailable": "Version {version} available",
"settings.plugins.landing.loading": "Loading installed plugins…",
"settings.plugins.landing.settingsUnavailable": "No plugin settings are available in this context.",
"settings.plugins.landing.options": "Plugin options",
"settings.plugins.landing.manager": "Plugin manager",
"settings.plugins.landing.managerDescription": "Browse plugins and manage installed packages and sources.",
"settings.plugins.landing.browse": "Browse",
"settings.plugins.landing.refresh": "Refresh installed plugins",
"settings.plugins.landing.unavailable": "Plugin management is unavailable in this session.",
"settings.plugins.landing.empty": "No plugins installed. Browse the plugin manager to get started.",
"settings.plugins.landing.search": "Search installed plugins",
"settings.plugins.landing.settingsFor": "Settings for {name}",
"settings.plugins.landing.enableSettings": "Enable this plugin to open its available settings.",
"settings.plugins.landing.sourceUnknown": "Source unknown",
"settings.plugins.landing.status.running": "Running",
"settings.plugins.landing.status.starting": "Starting",
"settings.plugins.landing.status.stopping": "Stopping",
"settings.plugins.landing.status.error": "Error",
"settings.plugins.landing.status.quarantined": "Quarantined",
"settings.plugins.landing.status.unknown": "Status unknown",
"settings.plugins.landing.status.idle": "Enabled, idle",
// Common
'common.save': 'Save',
'common.cancel': 'Cancel',
@@ -1272,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",
@@ -1327,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",
@@ -1336,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:",
@@ -1388,5 +1418,20 @@ Highlight the focused split pane:
'plugins.authentication.responseFailed': 'Could not send the authentication response. Try again or cancel the request.',
'plugins.authentication.responseFailedWithMessage': 'Could not send the authentication response: {message}. Try again or cancel the request.',
'plugins.authentication.continue': 'Continue',
'plugins.hostActions.select': 'Share selected host metadata',
'plugins.hostActions.selectionNote': 'Share saved labels, addresses, usernames, protocols, groups and tags with this plugin. Group selection includes only the hosts selected now. Connecting requires a separate confirmation.',
'plugins.hostActions.create': 'Review host draft',
'plugins.hostActions.edit': 'Review host changes',
'plugins.hostActions.connect': 'Confirm connection',
'plugins.hostActions.fieldDiff': 'Changes to save',
'plugins.hostActions.defaultsWarning': 'Saved connection defaults apply. Changing a group can change inherited authentication, proxy settings and scripts.',
'plugins.hostActions.invalidDraft': 'Enter a valid host draft.',
'plugins.hostActions.scriptsRun': 'Saved startup, connect or output scripts may run with this connection.',
'plugins.hostActions.noScripts': 'No saved startup, connect or output scripts are configured for this host.',
'plugins.hostActions.connectionNote': 'Netcatty will open this connection using the saved settings. This does not share session content with the plugin.',
'plugins.hostActions.localExecution': 'Local execution: this opens a shell on this device.',
'plugins.hostActions.managedUnsupported': 'Managed SSH config changes require the Vault host editor.',
'plugins.hostActions.failed': 'The request expired, was revoked, or the host changed. Select the host again.',
'plugins.hostActions.selectionLimit': 'Select up to 50 current hosts.',
};
+49 -4
View File
@@ -1,6 +1,32 @@
import type { Messages } from '../types';
export const esCoreMessages: Messages = {
"settings.plugins.landing.checkUpdates": "Buscar actualizaciones",
"settings.plugins.landing.checkingUpdates": "Buscando…",
"settings.plugins.landing.updateSummary": "Comprobados: {checked} · Actualizaciones publicadas: {updates} · Sin comprobar: {skipped}.",
"settings.plugins.landing.updateSourcesFailed": "Fuentes no disponibles: {count}.",
"settings.plugins.landing.updateReview": "Revisa las actualizaciones en el gestor de plugins antes de instalarlas.",
"settings.plugins.landing.updateAvailable": "Versión {version} disponible",
"settings.plugins.landing.loading": "Cargando plugins instalados…",
"settings.plugins.landing.settingsUnavailable": "No hay ajustes del plugin disponibles en este contexto.",
"settings.plugins.landing.options": "Opciones de plugins",
"settings.plugins.landing.manager": "Gestor de plugins",
"settings.plugins.landing.managerDescription": "Explora plugins y gestiona los paquetes instalados y sus fuentes.",
"settings.plugins.landing.browse": "Explorar",
"settings.plugins.landing.refresh": "Actualizar la lista de plugins instalados",
"settings.plugins.landing.unavailable": "La gestión de plugins no está disponible en esta sesión.",
"settings.plugins.landing.empty": "No hay plugins instalados. Abre el gestor de plugins para empezar.",
"settings.plugins.landing.search": "Buscar plugins instalados",
"settings.plugins.landing.settingsFor": "Ajustes de {name}",
"settings.plugins.landing.enableSettings": "Activa este plugin para abrir sus ajustes disponibles.",
"settings.plugins.landing.sourceUnknown": "Fuente desconocida",
"settings.plugins.landing.status.running": "En ejecución",
"settings.plugins.landing.status.starting": "Iniciando",
"settings.plugins.landing.status.stopping": "Deteniendo",
"settings.plugins.landing.status.error": "Error",
"settings.plugins.landing.status.quarantined": "En cuarentena",
"settings.plugins.landing.status.unknown": "Estado desconocido",
"settings.plugins.landing.status.idle": "Activado, en espera",
// Common
'common.save': 'Guardar',
'common.cancel': 'Cancelar',
@@ -1272,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.",
@@ -1284,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",
@@ -1327,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",
@@ -1336,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:",
@@ -1388,5 +1418,20 @@ Resalta el panel dividido enfocado:
'plugins.authentication.responseFailed': 'No se pudo enviar la respuesta de autenticación. Inténtalo de nuevo o cancela la solicitud.',
'plugins.authentication.responseFailedWithMessage': 'No se pudo enviar la respuesta de autenticación: {message}. Inténtalo de nuevo o cancela la solicitud.',
'plugins.authentication.continue': 'Continuar',
'plugins.hostActions.select': 'Compartir metadatos de los equipos seleccionados',
'plugins.hostActions.selectionNote': 'Comparte nombres, direcciones, usuarios, protocolos, grupos y etiquetas guardados con este complemento. Los grupos solo incluyen los equipos seleccionados ahora. Conectar requiere otra confirmación.',
'plugins.hostActions.create': 'Revisar borrador de equipo',
'plugins.hostActions.edit': 'Revisar cambios del equipo',
'plugins.hostActions.connect': 'Confirmar conexión',
'plugins.hostActions.fieldDiff': 'Cambios que se guardarán',
'plugins.hostActions.defaultsWarning': 'Se aplican los ajustes de conexión guardados. Cambiar el grupo puede cambiar la autenticación, el proxy y los scripts heredados.',
'plugins.hostActions.invalidDraft': 'Introduce un borrador de equipo válido.',
'plugins.hostActions.scriptsRun': 'Esta conexión puede ejecutar scripts guardados de inicio, conexión o salida.',
'plugins.hostActions.noScripts': 'Este equipo no tiene scripts guardados de inicio, conexión o salida.',
'plugins.hostActions.connectionNote': 'Netcatty abrirá la conexión con los ajustes guardados. Esto no comparte el contenido de la sesión con el complemento.',
'plugins.hostActions.localExecution': 'Ejecución local: esto abre una shell en este dispositivo.',
'plugins.hostActions.managedUnsupported': 'Los cambios de configuración SSH administrada requieren el editor de equipos de Vault.',
'plugins.hostActions.failed': 'La solicitud caducó, se revocó o el equipo cambió. Selecciona el equipo de nuevo.',
'plugins.hostActions.selectionLimit': 'Selecciona hasta 50 equipos actuales.',
};
+18 -3
View File
@@ -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:",
@@ -1283,4 +1283,19 @@ export const ruCoreMessages: Messages = {
'plugins.authentication.responseFailed': 'Не удалось отправить ответ аутентификации. Повторите попытку или отмените запрос.',
'plugins.authentication.responseFailedWithMessage': 'Не удалось отправить ответ аутентификации: {message}. Повторите попытку или отмените запрос.',
'plugins.authentication.continue': 'Продолжить',
'plugins.hostActions.select': 'Передать метаданные выбранных хостов',
'plugins.hostActions.selectionNote': 'Плагину будут переданы сохранённые имена, адреса, пользователи, протоколы, группы и теги. Группа включает только выбранные сейчас хосты. Подключение требует отдельного подтверждения.',
'plugins.hostActions.create': 'Проверить черновик хоста',
'plugins.hostActions.edit': 'Проверить изменения хоста',
'plugins.hostActions.connect': 'Подтвердить подключение',
'plugins.hostActions.fieldDiff': 'Изменения для сохранения',
'plugins.hostActions.defaultsWarning': 'Применяются сохранённые настройки подключения. Смена группы может изменить унаследованные настройки входа, прокси и скрипты.',
'plugins.hostActions.invalidDraft': 'Введите корректные данные хоста.',
'plugins.hostActions.scriptsRun': 'При подключении могут выполняться сохранённые скрипты запуска, подключения или вывода.',
'plugins.hostActions.noScripts': 'Для этого хоста не настроены сохранённые скрипты запуска, подключения или вывода.',
'plugins.hostActions.connectionNote': 'Netcatty откроет подключение с сохранёнными настройками. Содержимое сеанса не передаётся плагину.',
'plugins.hostActions.localExecution': 'Локальное выполнение: откроется оболочка на этом устройстве.',
'plugins.hostActions.managedUnsupported': 'Изменяйте управляемые настройки SSH в редакторе хостов Vault.',
'plugins.hostActions.failed': 'Запрос истёк, был отозван или хост изменился. Выберите хост заново.',
'plugins.hostActions.selectionLimit': 'Выберите не более 50 текущих хостов.',
};
+51 -6
View File
@@ -1,6 +1,32 @@
import type { Messages } from '../types';
export const zhCNCoreMessages: Messages = {
"settings.plugins.landing.checkUpdates": "检查更新",
"settings.plugins.landing.checkingUpdates": "正在检查…",
"settings.plugins.landing.updateSummary": "已检查:{checked} · 已发布更新:{updates} · 未检查:{skipped}。",
"settings.plugins.landing.updateSourcesFailed": "不可用来源:{count}。",
"settings.plugins.landing.updateReview": "安装前请在插件管理器中查看更新详情。",
"settings.plugins.landing.updateAvailable": "可更新至 {version}",
"settings.plugins.landing.loading": "正在载入已安装插件…",
"settings.plugins.landing.settingsUnavailable": "当前上下文中没有可用的插件设置。",
"settings.plugins.landing.options": "插件选项",
"settings.plugins.landing.manager": "插件管理器",
"settings.plugins.landing.managerDescription": "浏览插件,管理已安装的插件包及来源。",
"settings.plugins.landing.browse": "浏览",
"settings.plugins.landing.refresh": "刷新已安装插件",
"settings.plugins.landing.unavailable": "当前会话无法使用插件管理。",
"settings.plugins.landing.empty": "尚未安装插件。可在插件管理器中浏览或导入插件。",
"settings.plugins.landing.search": "搜索已安装插件",
"settings.plugins.landing.settingsFor": "{name} 的设置",
"settings.plugins.landing.enableSettings": "启用此插件后可打开其可用设置。",
"settings.plugins.landing.sourceUnknown": "来源未知",
"settings.plugins.landing.status.running": "运行中",
"settings.plugins.landing.status.starting": "启动中",
"settings.plugins.landing.status.stopping": "停止中",
"settings.plugins.landing.status.error": "错误",
"settings.plugins.landing.status.quarantined": "已隔离",
"settings.plugins.landing.status.unknown": "状态未知",
"settings.plugins.landing.status.idle": "已启用,空闲",
// Common
'common.save': '保存',
'common.cancel': '取消',
@@ -1187,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": "可选",
@@ -1242,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": "重试",
@@ -1251,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 已从其他来源安装。请先卸载并明确清理旧来源数据,再换源重装。原来源:",
@@ -1303,5 +1333,20 @@ Vaults 标签保持扁平,不使用这块背景。
'plugins.authentication.responseFailed': '无法发送认证响应。请重试或取消此请求。',
'plugins.authentication.responseFailedWithMessage': '无法发送认证响应:{message}。请重试或取消此请求。',
'plugins.authentication.continue': '继续',
'plugins.hostActions.select': '分享所选主机的元数据',
'plugins.hostActions.selectionNote': '向此插件分享已保存的名称、地址、用户名、协议、分组和标签。选择分组仅包含本次选中的成员。连接主机需要单独确认。',
'plugins.hostActions.create': '预览主机草稿',
'plugins.hostActions.edit': '预览主机修改',
'plugins.hostActions.connect': '确认连接',
'plugins.hostActions.fieldDiff': '即将保存的修改',
'plugins.hostActions.defaultsWarning': '将应用已保存的连接默认设置。修改分组可能改变继承的认证、代理和脚本设置。',
'plugins.hostActions.invalidDraft': '请输入有效的主机草稿。',
'plugins.hostActions.scriptsRun': '此连接可能运行已保存的启动、连接或输出触发脚本。',
'plugins.hostActions.noScripts': '此主机未配置已保存的启动、连接或输出触发脚本。',
'plugins.hostActions.connectionNote': 'Netcatty 将使用已保存的设置打开连接。这不会向插件分享会话内容。',
'plugins.hostActions.localExecution': '本地执行:这将在此设备上打开一个 Shell。',
'plugins.hostActions.managedUnsupported': '受管理的 SSH 配置变更需在 Vault 主机编辑器中完成。',
'plugins.hostActions.failed': '请求已过期、被撤销或主机已改变。请重新选择主机。',
'plugins.hostActions.selectionLimit': '最多选择 50 个当前主机。',
};
+51 -6
View File
@@ -1,6 +1,32 @@
import type { Messages } from '../types';
export const zhTWCoreMessages: Messages = {
"settings.plugins.landing.checkUpdates": "檢查更新",
"settings.plugins.landing.checkingUpdates": "正在檢查…",
"settings.plugins.landing.updateSummary": "已檢查:{checked} · 已發佈更新:{updates} · 未檢查:{skipped}。",
"settings.plugins.landing.updateSourcesFailed": "無法使用的來源:{count}。",
"settings.plugins.landing.updateReview": "安裝前請在外掛管理器中查看更新詳情。",
"settings.plugins.landing.updateAvailable": "可更新至 {version}",
"settings.plugins.landing.loading": "正在載入已安裝外掛…",
"settings.plugins.landing.settingsUnavailable": "目前內容中沒有可用的外掛設定。",
"settings.plugins.landing.options": "外掛選項",
"settings.plugins.landing.manager": "外掛管理器",
"settings.plugins.landing.managerDescription": "瀏覽外掛,管理已安裝的外掛套件及來源。",
"settings.plugins.landing.browse": "瀏覽",
"settings.plugins.landing.refresh": "重新整理已安裝外掛",
"settings.plugins.landing.unavailable": "目前工作階段無法使用外掛管理。",
"settings.plugins.landing.empty": "尚未安裝外掛。可在外掛管理器中瀏覽或匯入外掛。",
"settings.plugins.landing.search": "搜尋已安裝外掛",
"settings.plugins.landing.settingsFor": "{name} 的設定",
"settings.plugins.landing.enableSettings": "啟用此外掛後可開啟其可用設定。",
"settings.plugins.landing.sourceUnknown": "來源不明",
"settings.plugins.landing.status.running": "執行中",
"settings.plugins.landing.status.starting": "啟動中",
"settings.plugins.landing.status.stopping": "停止中",
"settings.plugins.landing.status.error": "錯誤",
"settings.plugins.landing.status.quarantined": "已隔離",
"settings.plugins.landing.status.unknown": "狀態不明",
"settings.plugins.landing.status.idle": "已啟用,閒置",
// Common
'common.save': '儲存',
'common.cancel': '取消',
@@ -1190,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": "可選",
@@ -1222,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 已从其他来源安装。请先卸载并明确清理旧来源数据,再换源重装。原来源:",
@@ -1270,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": "重試",
@@ -1306,4 +1336,19 @@ Vaults 分頁維持扁平,不使用這塊背景。
'plugins.authentication.responseFailed': '無法送出驗證回應。請重試或取消此要求。',
'plugins.authentication.responseFailedWithMessage': '無法送出驗證回應:{message}。請重試或取消此要求。',
'plugins.authentication.continue': '繼續',
'plugins.hostActions.select': '分享所選主機的中繼資料',
'plugins.hostActions.selectionNote': '向此外掛分享已儲存的名稱、位址、使用者名稱、協定、群組和標籤。選擇群組僅包含本次選取的成員。連線需要另外確認。',
'plugins.hostActions.create': '預覽主機草稿',
'plugins.hostActions.edit': '預覽主機修改',
'plugins.hostActions.connect': '確認連線',
'plugins.hostActions.fieldDiff': '即將儲存的修改',
'plugins.hostActions.defaultsWarning': '將套用已儲存的連線預設設定。修改群組可能改變繼承的驗證、代理和指令碼設定。',
'plugins.hostActions.invalidDraft': '請輸入有效的主機草稿。',
'plugins.hostActions.scriptsRun': '此連線可能執行已儲存的啟動、連線或輸出觸發指令碼。',
'plugins.hostActions.noScripts': '此主機未設定已儲存的啟動、連線或輸出觸發指令碼。',
'plugins.hostActions.connectionNote': 'Netcatty 將使用已儲存的設定開啟連線。這不會向外掛分享工作階段內容。',
'plugins.hostActions.localExecution': '本機執行:這會在此裝置上開啟 Shell。',
'plugins.hostActions.managedUnsupported': '受管理的 SSH 設定變更需在 Vault 主機編輯器中完成。',
'plugins.hostActions.failed': '請求已過期、被撤銷或主機已變更。請重新選擇主機。',
'plugins.hostActions.selectionLimit': '最多選擇 50 部目前的主機。',
};
@@ -0,0 +1,128 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { executePluginHostAction, type PluginHostAction, type PluginHostActionVault } from './pluginHostActionExecution';
import { PluginHostActionResources } from './pluginHostActionResources';
import type { Host, ManagedSource } from '../../domain/models';
const synthetic: Host = { id: 'synthetic', label: 'Synthetic', hostname: 'synthetic.invalid', username: 'test', tags: [], os: 'linux', password: 'synthetic-only', startupCommand: 'synthetic-script' };
function fixture() {
const resources = new PluginHostActionResources();
let active = true, authorized = 0, connections = 0, writes = 0;
let beforeRead: (() => void) | undefined;
let beforeCommit: (() => void) | undefined;
let duringAuthorize: (() => void) | undefined;
let storedSources: ManagedSource[] | undefined;
const vault: PluginHostActionVault = {
hosts: [synthetic], customGroups: [], groupConfigs: [], snippets: [], managedSources: [], isInitialized: true,
async readPersistedHosts() { beforeRead?.(); return [structuredClone(synthetic)]; },
async commitVaultImportTransaction(next, groups, sources, _configs, _expected, _lock, authorize) {
const nextSources = sources(storedSources ?? vault.managedSources);
beforeCommit?.();
await authorize?.();
writes++; vault.hosts = next;
return { status: 'persisted', groups: groups([]), sources: nextSources, groupConfigs: [] };
},
};
resources.observe(vault.hosts);
const target = resources.metadata(synthetic.id);
const options = {
getVault: () => vault, resources,
assertCurrent() { if (!active) throw new Error('Action cancelled'); },
async authorizeCommit() { duringAuthorize?.(); if (!active) throw new Error('Action revoked'); authorized++; },
connect(host: Host) { assert.equal(host, synthetic); connections++; return 'private-session-id'; },
};
return { vault, target, options, get authorized() { return authorized; }, get connections() { return connections; }, get writes() { return writes; },
cancel() { active = false; }, beforeRead(fn: () => void) { beforeRead = fn; }, beforeCommit(fn: () => void) { beforeCommit = fn; }, duringAuthorize(fn: () => void) { duringAuthorize = fn; },
storedSources(value: ManagedSource[]) { storedSources = value; },
};
}
test('reviewed connection dispatches through existing handler and returns no session data', async () => {
const f = fixture();
assert.equal(await executePluginHostAction({ kind: 'connect', target: f.target }, f.options), 'requested');
assert.equal(f.authorized, 1); assert.equal(f.connections, 1); assert.equal(f.writes, 0);
});
test('host edit preserves credentials and scripts and claims action authority after preparation', async () => {
const f = fixture();
assert.equal(await executePluginHostAction({ kind: 'edit', target: f.target, draft: { label: 'Reviewed' } }, f.options), 'saved');
assert.equal(f.vault.hosts[0].password, synthetic.password);
assert.equal(f.vault.hosts[0].startupCommand, synthetic.startupCommand);
assert.equal(f.vault.hosts[0].label, 'Reviewed');
assert.equal(f.authorized, 1); assert.equal(f.writes, 1); assert.equal(f.connections, 0);
});
for (const point of ['beforeRead', 'beforeCommit', 'duringAuthorize'] as const) test(`cancel/revoke at ${point} prevents the Vault write`, async () => {
const f = fixture(); f[point](() => f.cancel());
await assert.rejects(executePluginHostAction({ kind: 'edit', target: f.target, draft: { label: 'Never saved' } }, f.options), /cancelled|revoked/);
assert.equal(f.writes, 0); assert.equal(f.connections, 0);
});
for (const change of ['remove', 'replace', 'scripts', 'locked'] as const) test(`${change} during awaited connection preparation prevents dispatch`, async () => {
const f = fixture();
f.beforeRead(() => {
if (change === 'remove') f.vault.hosts = [];
if (change === 'replace') f.vault.hosts = [{ ...synthetic }];
if (change === 'scripts') f.vault.snippets = [];
if (change === 'locked') f.cancel();
});
await assert.rejects(executePluginHostAction({ kind: 'connect', target: f.target }, f.options));
assert.equal(f.authorized, 0); assert.equal(f.connections, 0);
});
test('create produces a saved SSH draft without executable or credential injection', async () => {
const f = fixture();
const action: PluginHostAction = { kind: 'create', draft: { hostname: 'new.invalid', label: 'New', group: 'Synthetic', password: 'untrusted', startupCommand: 'untrusted' } as never };
assert.equal(await executePluginHostAction(action, f.options), 'saved');
const created = f.vault.hosts[1];
assert.notEqual(created.id, synthetic.id);
assert.equal(created.password, undefined); assert.equal(created.startupCommand, undefined);
});
const managedSources: ManagedSource[] = [
{ id: 'synthetic-source-a', type: 'ssh_config', filePath: '/synthetic/a.conf', groupName: 'A', lastSyncedAt: 0 },
{ id: 'synthetic-source-b', type: 'ssh_config', filePath: '/synthetic/b.conf', groupName: 'B', lastSyncedAt: 0 },
];
for (const destination of ['B', 'Unmanaged', ''] as const) test(`managed A to ${destination || 'no group'} is refused before action authority or persistence`, async () => {
const f = fixture();
const host = { ...synthetic, group: 'A', managedSourceId: managedSources[0].id };
f.vault.hosts = [host]; f.vault.managedSources = managedSources;
f.vault.readPersistedHosts = async () => [structuredClone(host)];
f.options.resources.observe(f.vault.hosts);
const target = f.options.resources.metadata(host.id);
await assert.rejects(executePluginHostAction({ kind: 'edit', target, draft: { group: destination } }, f.options), /Managed.*Vault/);
assert.equal(f.writes, 0); assert.equal(f.authorized, 0);
assert.equal(f.vault.hosts[0].managedSourceId, managedSources[0].id);
});
for (const kind of ['create', 'edit'] as const) test(`${kind} into a managed destination is refused`, async () => {
const f = fixture(); f.vault.managedSources = managedSources;
const action: PluginHostAction = kind === 'create'
? { kind, draft: { hostname: 'new.invalid', group: 'B/Child' } }
: { kind, target: f.target, draft: { group: 'B/Child' } };
await assert.rejects(executePluginHostAction(action, f.options), /Managed.*Vault/);
assert.equal(f.writes, 0); assert.equal(f.authorized, 0);
});
test('ordinary unmanaged moves preserve the managed-source catalog without taking ownership', async () => {
const f = fixture(); f.vault.managedSources = managedSources;
assert.equal(await executePluginHostAction({ kind: 'edit', target: f.target, draft: { group: 'Unmanaged' } }, f.options), 'saved');
assert.equal(f.vault.hosts[0].managedSourceId, undefined);
assert.equal(f.vault.hosts[0].group, 'Unmanaged');
assert.equal(f.vault.managedSources, managedSources);
});
test('a managed-source catalog replacement during preparation prevents the write', async () => {
const f = fixture();
f.beforeCommit(() => { f.vault.managedSources = managedSources; });
await assert.rejects(executePluginHostAction({ kind: 'create', draft: { hostname: 'new.invalid', group: 'B' } }, f.options), /Managed sources changed/);
assert.equal(f.writes, 0); assert.equal(f.authorized, 0);
});
test('a newer persisted managed-source catalog rejects a stale unmanaged preview before the claim', async () => {
const f = fixture(); f.storedSources(managedSources);
await assert.rejects(executePluginHostAction({ kind: 'create', draft: { hostname: 'new.invalid', group: 'B' } }, f.options), /Managed sources changed/);
assert.equal(f.writes, 0); assert.equal(f.authorized, 0);
});
@@ -0,0 +1,102 @@
import type { Host, GroupConfig, Snippet, ManagedSource } from '../../domain/models';
import { canonicalJsonString, normalizeJsonValue } from '../../domain/convergentSync/json';
import { applyPluginHostEdit, normalizePluginHostDraft, pluginHostDraftTouchesManagedSource, type PluginHostDraftFields } from '../../domain/pluginHostActions';
import { applyVaultHostCreates } from '../../domain/vaultHostCreate';
import type { PluginHostActionResources } from './pluginHostActionResources';
import type { AppVaultRuntime } from './appRuntimeBridge';
// Application action model. Public RPC types live only in plugin-contract.
export type PluginHostAction =
| { kind: 'create'; draft: PluginHostDraftFields }
| { kind: 'edit'; target: { resourceId: string; revision: string }; draft: PluginHostDraftFields }
| { kind: 'connect'; target: { resourceId: string; revision: string } };
export interface PluginHostActionVault {
hosts: Host[];
customGroups: string[];
groupConfigs: GroupConfig[];
snippets: Snippet[];
managedSources: ManagedSource[];
isInitialized: boolean;
readPersistedHosts: () => Promise<Host[]>;
commitVaultImportTransaction: AppVaultRuntime['commitVaultImportTransaction'];
}
export interface PluginHostActionExecution {
getVault: () => PluginHostActionVault;
resources: PluginHostActionResources;
assertCurrent: () => void;
authorizeCommit: () => Promise<void>;
connect: (host: Host) => unknown;
}
const sameHost = (left: Host, right: Host) => (
canonicalJsonString(normalizeJsonValue(left)) === canonicalJsonString(normalizeJsonValue(right))
);
/** Execute only a host-reviewed action. authorizeCommit claims separate,
* one-use main-process action authority after the final asynchronous work. */
export async function executePluginHostAction(action: PluginHostAction, options: PluginHostActionExecution): Promise<'saved' | 'requested'> {
const current = () => {
options.assertCurrent();
const vault = options.getVault();
if (!vault.isInitialized) throw new Error('Vault is unavailable');
options.resources.observe(vault.hosts);
return vault;
};
const initialVault = current();
const reviewedGroups = initialVault.groupConfigs;
const reviewedSnippets = initialVault.snippets;
const reviewedSources = initialVault.managedSources;
const selected = action.kind === 'create' ? undefined : options.resources.resolve(action.target.resourceId, action.target.revision);
const assertSelected = () => {
const vault = current();
if (action.kind !== 'create' && options.resources.resolve(action.target.resourceId, action.target.revision) !== selected) {
throw new Error('Selected host changed');
}
// A connection preview includes inherited and global automation. Any
// settings change while the preview is open requires fresh confirmation.
if (action.kind === 'connect' && (vault.groupConfigs !== reviewedGroups || vault.snippets !== reviewedSnippets)) {
throw new Error('Saved connection settings changed');
}
if (action.kind !== 'connect' && vault.managedSources !== reviewedSources) throw new Error('Managed sources changed during draft review');
return vault;
};
const persisted = await initialVault.readPersistedHosts();
assertSelected();
if (selected) {
const persistedHost = persisted.find((host) => host.id === selected.id);
if (!persistedHost || !sameHost(selected, persistedHost)) throw new Error('Selected host changed');
}
if (action.kind === 'connect') {
await options.authorizeCommit();
assertSelected();
// The existing host connection handler owns authentication, saved scripts,
// and sessions. Its return value never reaches the sandboxed plugin.
options.connect(selected!);
return 'requested';
}
const nextHost = action.kind === 'create'
? normalizePluginHostDraft(action.draft, crypto.randomUUID())
: applyPluginHostEdit(selected!, action.draft);
if (pluginHostDraftTouchesManagedSource(nextHost, reviewedSources)) {
throw new Error('Managed SSH config changes require the Vault host editor');
}
const nextHosts = action.kind === 'create'
? applyVaultHostCreates(persisted, [], [nextHost]).hosts
: persisted.map((host) => host.id === nextHost.id ? nextHost : host);
if (action.kind === 'create' && nextHosts.length !== persisted.length + 1) throw new Error('Host already exists');
const updateGroups = (groups: string[]) => nextHost.group && !groups.includes(nextHost.group) ? [...groups, nextHost.group] : groups;
const sourceSnapshot = canonicalJsonString(normalizeJsonValue(reviewedSources));
const keepSources = (sources: ManagedSource[]) => {
if (canonicalJsonString(normalizeJsonValue(sources)) !== sourceSnapshot) throw new Error('Managed sources changed during draft review');
return sources;
};
const result = await initialVault.commitVaultImportTransaction(
nextHosts, updateGroups, keepSources,
undefined, persisted, undefined,
async () => { assertSelected(); await options.authorizeCommit(); assertSelected(); },
);
if (result.status !== 'persisted') throw new Error('Vault changed before the draft was saved');
return 'saved';
}
@@ -0,0 +1,32 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { PluginHostActionResources } from './pluginHostActionResources';
import type { Host } from '../../domain/models';
const first: Host = { id: 'synthetic-1', label: 'First', hostname: 'first.invalid', username: 'test', tags: [], os: 'linux', group: 'Synthetic', password: 'synthetic-password', startupCommand: 'synthetic-script', totp: { uri: 'synthetic-totp' } as never };
test('selected metadata projects only the allowlist and replacement invalidates revision', () => {
const resources = new PluginHostActionResources();
resources.observe([first]);
const metadata = resources.metadata(first.id);
assert.deepEqual(Object.keys(metadata), ['resourceId', 'revision', 'label', 'hostname', 'username', 'protocol', 'group', 'tags']);
assert.equal(JSON.stringify(metadata).includes('synthetic-password'), false);
resources.observe([first]);
assert.equal(resources.resolve(first.id, metadata.revision), first);
resources.observe([{ ...first }]);
assert.throws(() => resources.resolve(first.id, metadata.revision), /changed/);
const replaced = resources.metadata(first.id);
resources.observe([]);
assert.throws(() => resources.resolve(first.id, replaced.revision), /changed/);
resources.observe([first]);
assert.throws(() => resources.resolve(first.id, replaced.revision), /changed/);
});
test('group choices are explicit current members and never expand to later members', () => {
const resources = new PluginHostActionResources();
resources.observe([first]);
const selection = [resources.metadata(first.id)];
resources.observe([first, { ...first, id: 'synthetic-2' }]);
assert.deepEqual(selection.map((item) => item.resourceId), [first.id]);
assert.equal(resources.resolve(first.id, selection[0].revision), first);
});
@@ -0,0 +1,34 @@
import type { Host } from '../../domain/models';
/** Renderer-local revisions track replacement as well as visible edits. They
* are never authorization handles; only the main-process authority issues one. */
export class PluginHostActionResources {
private entries = new Map<string, { host: Host; revision: string }>();
observe(hosts: readonly Host[]) {
const next = new Map<string, { host: Host; revision: string }>();
for (const host of hosts) {
const existing = this.entries.get(host.id);
next.set(host.id, existing?.host === host ? existing : { host, revision: crypto.randomUUID() });
}
this.entries = next;
}
resolve(id: string, expectedRevision: string): Host {
const entry = this.entries.get(id);
if (!entry || entry.revision !== expectedRevision) throw new Error('Selected host changed; select it again');
return entry.host;
}
metadata(id: string) {
const entry = this.entries.get(id);
if (!entry) throw new Error('Selected host is unavailable');
const host = entry.host;
return {
resourceId: host.id, revision: entry.revision,
label: host.label, hostname: host.hostname, username: host.username,
...(host.port === undefined ? {} : { port: host.port }), protocol: host.protocol ?? 'ssh',
group: host.group ?? '', tags: [...host.tags],
};
}
}
+2 -1
View File
@@ -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; }
+173
View File
@@ -0,0 +1,173 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import type { HostInteractionPrompt, VaultHostActionRequest } from '@netcatty/plugin-contract';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
import { getAppHandlers } from '../app/appHandlersBridge';
import { getAppAppLockRuntime, getAppVaultRuntime } from './appRuntimeBridge';
import { PluginHostActionResources } from './pluginHostActionResources';
import { executePluginHostAction, type PluginHostAction } from './pluginHostActionExecution';
import { pluginHostRunsSavedScripts, type PluginHostDraftFields } from '../../domain/pluginHostActions';
import type { Host, GroupConfig, ManagedSource, Snippet } from '../../domain/models';
export type VaultHostPrompt = Omit<HostInteractionPrompt, 'request'> & { request: VaultHostActionRequest };
type Review = {
prompt: VaultHostPrompt;
candidates: Host[];
candidateRevisions: Map<string, string>;
customGroups: string[];
host?: Host;
groups: GroupConfig[];
snippets: Snippet[];
managedSources: ManagedSource[];
scriptsRun: boolean;
claimed: boolean;
completionToken?: string;
};
export function usePluginHostActions() {
const [review, setReview] = useState<Review | null>(null);
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const [busy, setBusy] = useState(false);
const [error, setError] = useState(false);
const current = useRef<Review | null>(null);
const resources = useRef(new PluginHostActionResources());
const responding = useRef(false);
const getVault = useCallback(() => {
const vault = getAppVaultRuntime();
const lock = getAppAppLockRuntime();
if (!vault?.isInitialized || !lock?.initialized || lock.locked) throw new Error('Vault is locked or unavailable');
const actionState = vault.readCurrentHostActionState();
resources.current.observe(actionState.hosts);
return { ...vault, ...actionState };
}, []);
const assertCurrent = useCallback((entry: Review) => {
if (current.current !== entry || Date.now() >= entry.prompt.expiresAt) throw new Error('Host action expired');
const vault = getVault();
const target = entry.prompt.vaultTarget;
if (target && resources.current.resolve(target.resourceId, target.revision) !== entry.host) throw new Error('Selected host changed');
if (entry.prompt.request.kind === 'vault.requestConnect' && (vault.groupConfigs !== entry.groups || vault.snippets !== entry.snippets)) {
throw new Error('Saved connection settings changed');
}
if (entry.prompt.request.kind === 'vault.requestHostDraft' && vault.managedSources !== entry.managedSources) throw new Error('Managed sources changed during draft review');
}, [getVault]);
const fail = useCallback(async (entry: Review) => {
if (current.current !== entry) return;
setError(true);
try {
await netcattyBridge.get()?.respondPluginInteraction?.({
requestId: entry.prompt.requestId, actionToken: entry.completionToken ?? entry.prompt.actionToken, action: 'fail',
});
} catch { /* A stopped runtime or closed window already invalidates it. */ }
}, []);
const execute = useCallback(async (entry: Review) => {
const bridge = netcattyBridge.get();
if (!bridge?.respondPluginInteraction) return fail(entry);
setBusy(true);
try {
assertCurrent(entry);
const request = entry.prompt.request;
const target = entry.prompt.vaultTarget;
let action: PluginHostAction;
if (request.kind === 'vault.requestConnect' && target) action = { kind: 'connect', target };
else if (request.kind === 'vault.requestHostDraft' && entry.prompt.reviewedDraft) {
action = target ? { kind: 'edit', target, draft: entry.prompt.reviewedDraft } : { kind: 'create', draft: entry.prompt.reviewedDraft };
} else throw new Error('Host action is invalid');
await executePluginHostAction(action, {
getVault, resources: resources.current,
assertCurrent: () => assertCurrent(entry),
authorizeCommit: async () => {
assertCurrent(entry);
const receipt = await bridge.respondPluginInteraction!({ requestId: entry.prompt.requestId, actionToken: entry.prompt.actionToken, action: 'commit' });
if (!receipt || typeof receipt !== 'object' || !receipt.completionToken) throw new Error('Host action was revoked');
entry.completionToken = receipt.completionToken;
entry.claimed = true;
assertCurrent(entry);
},
connect: (host) => {
const connect = getAppHandlers()?.handleConnectToHost;
if (typeof connect !== 'function') throw new Error('Host connection flow unavailable');
return connect(host);
},
});
await bridge.respondPluginInteraction({ requestId: entry.prompt.requestId, actionToken: entry.completionToken!, action: 'complete' });
} catch { await fail(entry); }
}, [assertCurrent, fail, getVault]);
useEffect(() => {
const bridge = netcattyBridge.get();
if (!bridge?.onPluginInteraction) return;
const unsubscribe = bridge.onPluginInteraction((event) => {
if ('closed' in event) {
if (current.current?.prompt.requestId !== event.requestId) return;
current.current = null; setReview(null); setBusy(false); responding.current = false;
return;
}
if (!event.request.kind.startsWith('vault.')) return;
const prompt = event as VaultHostPrompt;
try {
if (prompt.phase === 'commit') {
const entry = current.current;
if (!entry || entry.prompt.requestId !== prompt.requestId || entry.prompt.request.kind !== prompt.request.kind) throw new Error('Host review is unavailable');
entry.prompt = prompt;
if (!(prompt.request.kind === 'vault.requestConnect' && prompt.permissionMode === 'allow')) setReview({ ...entry });
void execute(entry);
return;
}
const vault = getVault();
const candidates = [...vault.hosts];
const host = prompt.vaultTarget ? resources.current.resolve(prompt.vaultTarget.resourceId, prompt.vaultTarget.revision) : undefined;
const entry: Review = {
prompt, candidates, candidateRevisions: new Map(candidates.map((candidate) => [candidate.id, resources.current.metadata(candidate.id).revision])),
customGroups: vault.customGroups, host, groups: vault.groupConfigs, snippets: vault.snippets,
managedSources: vault.managedSources,
scriptsRun: host ? pluginHostRunsSavedScripts(host, vault.groupConfigs, vault.snippets) : false,
claimed: false,
};
current.current = entry; 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(() => {});
}
});
return () => {
const entry = current.current;
current.current = null;
unsubscribe();
if (entry && !entry.claimed) void bridge.respondPluginInteraction?.({ requestId: entry.prompt.requestId, actionToken: entry.prompt.actionToken, action: 'cancel' }).catch(() => {});
};
}, [execute, fail, getVault]);
const respond = useCallback(async (action: 'cancel' | 'submit', draft?: PluginHostDraftFields) => {
const entry = current.current;
if (!entry || responding.current || (action === 'cancel' && entry.claimed)) return;
responding.current = true; setBusy(true); setError(false);
try {
assertCurrent(entry);
const request = entry.prompt.request;
const selected = request.kind === 'vault.selectHosts' && action === 'submit' ? selectedIds.map((id) => {
const revision = entry.candidateRevisions.get(id);
if (!revision) throw new Error('Host was not part of the current selection');
resources.current.resolve(id, revision);
return resources.current.metadata(id);
}) : undefined;
await netcattyBridge.require().respondPluginInteraction?.({
requestId: entry.prompt.requestId, actionToken: entry.prompt.actionToken, action,
...(selected === undefined ? {} : { resources: selected }),
...(draft === undefined ? {} : { draft }),
...(request.kind === 'vault.requestConnect' && action === 'submit' ? { scriptsRun: entry.scriptsRun } : {}),
});
} catch { await fail(entry); }
finally {
if (current.current === entry && entry.prompt.phase === 'request') { responding.current = false; setBusy(false); }
}
}, [assertCurrent, fail, selectedIds]);
return { review, selectedIds, setSelectedIds, busy, error, respond };
}
+15 -4
View File
@@ -1,14 +1,15 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import type { HostInteractionPrompt, HostInteractionResponse } from '@netcatty/plugin-contract';
import type { HostInteractionPrompt, HostInteractionRequest, HostInteractionResponse } from '@netcatty/plugin-contract';
import { readPluginTerminalSelection } from '../../infrastructure/plugins/terminalSelectionRegistry';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
export function usePluginHostInteractions() {
const [prompt, setPrompt] = useState<HostInteractionPrompt | null>(null);
type UiPrompt = Omit<HostInteractionPrompt, 'request'> & { request: HostInteractionRequest };
const [prompt, setPrompt] = useState<UiPrompt | null>(null);
const [value, setValue] = useState('');
const [error, setError] = useState(false);
const [busy, setBusy] = useState(false);
const current = useRef<HostInteractionPrompt | null>(null);
const current = useRef<UiPrompt | null>(null);
const pending = useRef(false);
useEffect(() => {
const bridge = netcattyBridge.get();
@@ -18,7 +19,17 @@ export function usePluginHostInteractions() {
if (current.current?.requestId !== event.requestId) return;
current.current = null; setPrompt(null);
} else {
current.current = event; setPrompt(event);
if (event.request.kind.startsWith('vault.')) return;
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);
+49
View File
@@ -0,0 +1,49 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
export function usePluginLogs() {
const [status, setStatus] = useState<NetcattyPluginLogStatus | null>(null);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const pending = useRef(false);
const mounted = useRef(true);
const run = useCallback(async (action: 'status' | 'configure' | 'open' | 'export' | 'clear', settings?: { saveToDisk: boolean; verbose: boolean }) => {
if (pending.current) return;
pending.current = true;
setBusy(true);
setError(null);
try {
const bridge = netcattyBridge.require();
const result = await (action === 'configure' ? bridge.configurePluginLogs?.(settings!)
: action === 'open' ? bridge.openPluginLogFolder?.()
: action === 'export' ? bridge.exportPluginLogs?.()
: action === 'clear' ? bridge.clearPluginLogs?.() : bridge.getPluginLogStatus?.());
if (!result) throw new Error('Local plugin logging is unavailable');
if (mounted.current) setStatus(result);
} catch (cause) {
let message = cause instanceof Error ? cause.message : String(cause);
if (action === 'configure') {
// Configuration fails closed before disk I/O. Show actual host state,
// even when persisting a setting failed, rather than keeping stale ON.
try {
const actual = await netcattyBridge.require().getPluginLogStatus?.();
if (!actual) throw new Error('Local plugin log status is unavailable');
if (mounted.current) setStatus(actual);
} catch (refreshError) {
if (mounted.current) setStatus(null);
message += `\n${refreshError instanceof Error ? refreshError.message : String(refreshError)}`;
}
}
if (mounted.current) setError(message);
} finally {
pending.current = false;
if (mounted.current) setBusy(false);
}
}, []);
useEffect(() => {
mounted.current = true;
void run('status');
return () => { mounted.current = false; };
}, [run]);
return { status, error, busy, run };
}
+2 -1
View File
@@ -10,7 +10,8 @@ export function usePluginManager() {
const [error, setError] = useState<string | null>(null);
const generation = useRef(0);
const mutation = useRef(false);
const refresh = useCallback(async () => {
const refresh = useCallback(async (options?: { clearError?: boolean }) => {
if (options?.clearError) setError(null);
const current = ++generation.current;
try {
const bridge = netcattyBridge.get();
@@ -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 };
}
+52
View File
@@ -0,0 +1,52 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { checkPluginUpdates, type PluginUpdateCheckResult } from '../../infrastructure/services/pluginUpdateCheck';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
/** Manual catalog checks only; no scheduler, install or permission mutation. */
export function usePluginUpdateCheck(plugins: readonly NetcattyInstalledPlugin[]) {
const [result, setResult] = useState<PluginUpdateCheckResult | null>(null);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const pending = useRef(false);
const mounted = useRef(false);
useEffect(() => { mounted.current = true; return () => { mounted.current = false; }; }, []);
const generation = useRef(0);
const identityKey = useMemo(() => JSON.stringify(plugins.map((plugin) => [
plugin.id, plugin.activeVersion, plugin.archiveSha256, plugin.source?.id,
plugin.source?.kind, plugin.source?.origin,
])), [plugins]);
useEffect(() => {
generation.current++;
setResult(null);
setError(null);
const requests = generation;
return () => { requests.current++; };
}, [identityKey]);
useEffect(() => {
const invalidate = () => {
generation.current++;
setResult(null);
setError(null);
};
const unsubscribe = netcattyBridge.get()?.onPluginContributionsChanged?.(invalidate);
window.addEventListener('netcatty:plugin-packages-changed', invalidate);
return () => { unsubscribe?.(); window.removeEventListener('netcatty:plugin-packages-changed', invalidate); };
}, []);
const check = useCallback(async () => {
if (pending.current) return;
pending.current = true;
const current = generation.current;
setBusy(true); setError(null); setResult(null);
try {
const value = await checkPluginUpdates(netcattyBridge.require());
if (mounted.current && current === generation.current) setResult(value);
} catch (cause) {
if (mounted.current && current === generation.current) setError(cause instanceof Error ? cause.message : String(cause));
} finally {
pending.current = false;
// This component may still be mounted with a refreshed package list.
if (mounted.current) setBusy(false);
}
}, []);
return { result, error, busy, check };
}
+13
View File
@@ -776,6 +776,13 @@ export const useVaultState = (options?: UseVaultStateOptions) => {
}
}, [waitForPendingVaultWrites]);
// Host-owned action guards need the synchronous mutation snapshot, including
// edits that React has not published yet. This accessor never crosses RPC.
const readCurrentHostActionState = useCallback(() => ({
hosts: hostsRef.current, groupConfigs: groupConfigsRef.current, snippets: snippetsRef.current,
managedSources: managedSourcesRef.current,
}), []);
const updateKeys = useCallback((data: SSHKey[]) => {
const cleaned = normalizeVaultOrder(data);
setKeys(cleaned);
@@ -1248,6 +1255,7 @@ export const useVaultState = (options?: UseVaultStateOptions) => {
updateGroupConfigs?: (current: GroupConfig[]) => GroupConfig[],
expectedHosts?: Host[],
lock?: VaultLockHandle | null,
assertAuthorized?: () => void | Promise<void>,
): Promise<
| {
status: "persisted";
@@ -1322,6 +1330,10 @@ export const useVaultState = (options?: UseVaultStateOptions) => {
encryptHosts(cleanedHosts),
encryptGroupConfigs(nextGroupConfigs),
]);
// Optional plugin draft guard: runtime/source/revoke/lock authority must
// still hold after encryption, immediately before the existing atomic
// persistence boundary. Ordinary imports retain their existing behavior.
if (assertAuthorized) await assertAuthorized();
if (
version !== hostsWriteVersion.current
|| groupConfigsVersion !== groupConfigsWriteVersion.current
@@ -2520,6 +2532,7 @@ export const useVaultState = (options?: UseVaultStateOptions) => {
groupConfigs,
updateHosts,
readPersistedHosts,
readCurrentHostActionState,
updateKeys,
importOrReuseKey,
updateIdentities,
+73 -14
View File
@@ -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();
@@ -0,0 +1,322 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { EventEmitter } from 'node:events';
import { createRequire } from 'node:module';
import { mkdtemp, cp, rm } from 'node:fs/promises';
import path from 'node:path';
import { tmpdir } from 'node:os';
import { JSDOM } from 'jsdom';
import { sanitizeHost } from '../domain/host';
import type { ConnectionLog, Host, ManagedSource } from '../domain/models';
import { applyGroupDefaults, resolveGroupDefaults } from '../domain/groupConfig';
import type { HostInteractionEvent, HostInteractionPrompt, VaultHostSelectionResult, VaultHostActionResult, VaultHostRef } from '@netcatty/plugin-contract';
const require = createRequire(import.meta.url);
test('installed sample entrypoint uses real SDK, source authority, signed host dialogs and renderer action guards', { timeout: 45_000 }, async (t) => {
const dom = new JSDOM('<!doctype html><html><body><div id="root"></div></body></html>', { pretendToBeVisual: true, url: 'http://localhost' });
const previous = new Map<string, PropertyDescriptor | undefined>();
const install = (key: string, value: unknown) => {
previous.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
Object.defineProperty(globalThis, key, { configurable: true, writable: true, value });
};
for (const key of ['window', 'document', 'navigator', 'HTMLElement', 'HTMLInputElement', 'HTMLButtonElement', 'Element', 'SVGElement', 'Node', 'NodeFilter', 'MutationObserver', 'CustomEvent', 'Event', 'KeyboardEvent', 'MouseEvent']) {
install(key, key === 'window' ? dom.window : dom.window[key as keyof typeof dom.window]);
}
install('getComputedStyle', dom.window.getComputedStyle.bind(dom.window));
install('requestAnimationFrame', dom.window.requestAnimationFrame.bind(dom.window));
install('cancelAnimationFrame', dom.window.cancelAnimationFrame.bind(dom.window));
install('IS_REACT_ACT_ENVIRONMENT', true);
install('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} });
const { default: React, act } = await import('react');
const { createRoot } = await import('react-dom/client');
const { I18nProvider } = await import('../application/i18n/I18nProvider');
const { PluginHostActionHost } = await import('./plugins/PluginHostActionHost');
const { handleConnectToHostImpl } = await import('../application/app/AppHandlers');
const runtime = await import('../application/state/appRuntimeBridge');
const { registerAppHandlers } = await import('../application/app/appHandlersBridge');
const { createPluginHostService } = require('../electron/plugins/hostService.cjs');
const { inProcessBrowserRuntime } = require('../electron/plugins/fixtures/inProcessBrowserRuntime.cjs');
const directory = await mkdtemp(path.join(tmpdir(), 'netcatty-host-entry-'));
const listeners = new Set<(event: unknown) => void>();
const prompts: HostInteractionPrompt[] = [];
const sender = Object.assign(new EventEmitter(), { id: 77, isDestroyed: () => false, send(_channel: string, event: HostInteractionEvent) { if (!('closed' in event)) prompts.push(event); for (const listener of listeners) listener(event); } });
const owner = Object.assign(new EventEmitter(), { webContents: sender, isFocused: () => true, isVisible: () => true, isDestroyed: () => false });
let locked = false;
const lockListeners = new Set<(state: { locked: boolean }) => void>();
const service = createPluginHostService({
app: { getAppPath: () => process.cwd(), getPath: () => directory, getVersion: () => '0.0.0' }, electron: {}, env: {},
getInteractionWindow: () => owner, isAppLocked: () => locked,
subscribeAppLock: (callback: (state: { locked: boolean }) => void) => { lockListeners.add(callback); return () => lockListeners.delete(callback); },
requestPermissionDecision: async (request: { requestId: string; permission: string }) => ({ requestId: request.requestId, decision: 'allow', scope: ['vault.metadata', 'vault.write', 'ui.interact'].includes(request.permission) ? 'once' : 'application' }),
});
let requestClock = Date.now();
service.interactionService.clock = () => requestClock;
service.runtimeSupervisor.runtimeFactories.browser = inProcessBrowserRuntime;
Object.assign(dom.window, { netcatty: {
onPluginInteraction(callback: (event: unknown) => void) { listeners.add(callback); return () => listeners.delete(callback); },
respondPluginInteraction: (response: unknown) => service.interactionService.respond(sender, response),
} });
const first = sanitizeHost({ id: 'synthetic-first', label: 'Synthetic first', hostname: 'first.invalid', username: 'test', port: 22, protocol: 'ssh', os: 'linux', tags: ['synthetic'], group: 'Synthetic', password: 'synthetic-password', startupCommand: 'synthetic-script' });
const second = sanitizeHost({ ...first, id: 'synthetic-second', label: 'Synthetic second', hostname: 'second.invalid', password: undefined, startupCommand: undefined });
let hosts: Host[] = [first, second];
let persisted = hosts.map((host) => structuredClone(host));
let beforeCommit: (() => Promise<void>) | undefined;
let saves = 0;
const connections: Host[] = [];
const vault = {
hosts, customGroups: ['Synthetic'], groupConfigs: [], snippets: [], managedSources: [] as ManagedSource[], isInitialized: true,
readCurrentHostActionState: () => ({ hosts, groupConfigs: vault.groupConfigs, snippets: vault.snippets, managedSources: vault.managedSources }),
async readPersistedHosts() { return persisted.map((host) => structuredClone(host)); },
async commitVaultImportTransaction(next: Host[], groups: (groups: string[]) => string[], sources: (sources: ManagedSource[]) => ManagedSource[], _configs: unknown, _expected: unknown, _lock: unknown, authorize: () => Promise<void>) {
const nextSources = sources(vault.managedSources);
await beforeCommit?.();
await authorize();
saves++; hosts = next; persisted = next.map((host) => structuredClone(host));
return { status: 'persisted', groups: groups(vault.customGroups), sources: nextSources, groupConfigs: [] };
},
};
const publish = () => {
runtime.registerAppVaultRuntime(vault as never);
runtime.registerAppAppLockRuntime({ initialized: true, locked } as never);
};
publish();
registerAppHandlers({ handleConnectToHost: (host: Host) => { connections.push(host); return 'private-session-id'; } });
const root = createRoot(document.getElementById('root')!);
const settle = () => new Promise((resolve) => setTimeout(resolve, 20));
const waitFor = async (predicate: () => boolean) => {
for (let count = 0; count < 80; count++) { await act(async () => { await settle(); }); if (predicate()) return; }
assert.fail(`Expected host UI state; current text: ${document.body.textContent}`);
};
const button = (text: string) => Array.from(document.querySelectorAll<HTMLButtonElement>('button')).find((item) => item.textContent === text)!;
const click = async (element: HTMLElement) => { assert.ok(element); await act(async () => { element.click(); await settle(); }); };
const begin = async (name: string) => {
requestClock += 60_001;
let promise!: Promise<VaultHostSelectionResult | VaultHostActionResult>;
await act(async () => { promise = service.contributionService.executeCommand(`com.netcatty.host-actions.${name}`); void promise.catch(() => {}); await settle(); });
await waitFor(() => document.querySelector('[role="dialog"]') !== null);
return { promise };
};
const chooseGroup = async () => {
await click(document.querySelector<HTMLElement>('[role="option"] button[aria-pressed]')!);
await click(Array.from(document.querySelectorAll<HTMLButtonElement>('button')).find((item) => item.textContent?.startsWith('Continue ('))!);
};
try {
await service.manager.initialize();
const packageRoot = path.join(directory, 'source');
await cp(new URL('../examples/plugins/host-actions/', import.meta.url), packageRoot, { recursive: true });
const { buildPluginPackage } = await import('@netcatty/plugin-cli');
const archive = path.join(directory, 'fixture.ncpkg');
await buildPluginPackage(packageRoot, archive);
await service.manager.install(archive, { enable: true });
service.database.setPermissionMode('com.netcatty.host-actions', 'ui.interact', 'ask');
service.interactionService.setReady(sender, true);
await act(async () => root.render(<I18nProvider locale="en"><PluginHostActionHost /></I18nProvider>));
let selectedTarget: VaultHostRef;
await t.test('group selection shares only explicit current members and grants no connection', async () => {
const run = await begin('select');
await click(document.querySelector<HTMLElement>('[role="option"] button[aria-pressed]')!);
hosts = [...hosts, sanitizeHost({ ...second, id: 'synthetic-future', hostname: 'future.invalid' })];
persisted = hosts.map((host) => structuredClone(host));
await click(Array.from(document.querySelectorAll<HTMLButtonElement>('button')).find((item) => item.textContent?.startsWith('Continue ('))!);
const result = await run.promise;
assert.ok('hosts' in result);
assert.equal(result.hosts.length, 2); assert.equal(result.groups[0].members.length, 2);
assert.equal(result.hosts.some((host) => host.metadata.hostname === 'future.invalid'), false);
assert.equal(result.hosts[0].target.scope, 'metadata');
selectedTarget = result.hosts[0].target;
for (const text of ['synthetic-password', 'synthetic-script', 'resourceId', 'private-session-id']) assert.equal(JSON.stringify(result).includes(text), false);
assert.equal(connections.length, 0); assert.equal(saves, 0);
});
await t.test('registered boundaries reject AI text, caller owners, tampered targets and stale expected revisions', async () => {
const identity = service.runtimeSupervisor.getRuntimeIdentity('com.netcatty.host-actions');
const routes = service.rpcRegistry.createRoutes({ ...identity, manifest: service.database.getActivePlugin(identity.pluginId).manifest });
const transport = { signal: new AbortController().signal };
for (const target of [
{ handle: 'text-only', version: 'v1', scope: 'host' },
{ ...selectedTarget, handle: 'synthetic-tampered-selection-handle-0000' },
]) await assert.rejects(routes.requestHandlers['vault.requestConnect']({ target, expectedRevision: selectedTarget.revision }, transport), /invalid|expired|changed/);
await assert.rejects(routes.requestHandlers['vault.requestConnect']({ target: selectedTarget, expectedRevision: 'stale' }, transport), /expired|changed/);
await assert.rejects(routes.requestHandlers['vault.requestHostDraft']({ privateOwner: 'spoof', draft: { hostname: 'synthetic.invalid' } }, transport), /invalid/);
assert.equal(service.interactionService.pending.size, 0); assert.equal(connections.length, 0);
});
await t.test('cancel creates no draft or connection', async () => {
const run = await begin('create'); await click(button('Cancel'));
assert.deepEqual(await run.promise, { cancelled: true }); assert.equal(saves, 0); assert.equal(connections.length, 0);
});
await t.test('reviewed create saves only the synthetic SSH draft', async () => {
const run = await begin('create');
assert.match(document.body.textContent ?? '', /Changes to save/);
await click(button('Save Host'));
assert.deepEqual(await run.promise, { cancelled: false, status: 'saved' });
assert.equal(saves, 1);
const saved = hosts.find((host) => host.hostname === 'synthetic.invalid')!;
assert.equal(saved.protocol, 'ssh'); assert.equal(saved.password, undefined); assert.equal(saved.startupCommand, undefined);
});
for (const change of ['remove', 'replace'] as const) await t.test(`${change} after selection prevents a stale edit`, async () => {
hosts = [first, second]; persisted = hosts.map((host) => structuredClone(host));
const run = await begin('edit'); await chooseGroup();
await waitFor(() => document.body.textContent?.includes('Changes to save') === true);
hosts = change === 'remove' ? [second] : [{ ...first }, second];
await click(button('Save Host'));
await assert.rejects(run.promise, /changed|cancelled|complete/);
assert.equal(saves, 1); assert.equal(connections.length, 0);
});
await t.test('connection confirmation discloses saved scripts before dispatch and returns no session', async () => {
hosts = [first, second]; persisted = hosts.map((host) => structuredClone(host));
const run = await begin('connect'); await chooseGroup();
await waitFor(() => document.body.textContent?.includes('Saved startup, connect or output scripts may run') === true);
assert.equal(connections.length, 0); assert.equal(document.body.textContent?.includes('synthetic-script'), false);
await click(button('Connect'));
assert.deepEqual(await run.promise, { cancelled: false, status: 'requested' });
assert.equal(connections.length, 1); assert.equal(connections[0], first);
});
await t.test('Vault lock cancels a pending connection', async () => {
const run = await begin('connect'); await chooseGroup();
await waitFor(() => button('Connect') !== undefined);
await act(async () => { locked = true; publish(); for (const listener of lockListeners) listener({ locked: true }); await settle(); });
await assert.rejects(run.promise, /cancelled/); assert.equal(connections.length, 1);
locked = false; publish();
});
await t.test('revoke during asynchronous preparation prevents the write', async () => {
hosts = [first, second]; persisted = hosts.map((host) => structuredClone(host));
let release!: () => void; let preparing = false;
beforeCommit = async () => { preparing = true; await new Promise<void>((resolve) => { release = resolve; }); };
const run = await begin('edit'); await chooseGroup();
await waitFor(() => button('Save Host') !== undefined); await click(button('Save Host'));
await waitFor(() => preparing);
await act(async () => { service.permissionEngine.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 () => {
const run = await begin('create');
const original = service.database.getRetainedPluginSource;
service.database.getRetainedPluginSource = () => ({ id: 'changed-source', kind: 'local', origin: null });
try { await click(button('Save Host')); await assert.rejects(run.promise, /source binding/); }
finally { service.database.getRetainedPluginSource = original; }
assert.equal(saves, 1); assert.equal(connections.length, 1);
});
await t.test('managed A to B stays in the Vault editor and cannot acquire file-write effects', async () => {
const candidate = sanitizeHost({ ...first, group: 'A', managedSourceId: 'synthetic-source-a' });
hosts = [candidate]; persisted = hosts.map((host) => structuredClone(host));
vault.managedSources = [
{ id: 'synthetic-source-a', type: 'ssh_config', filePath: '/synthetic/a.conf', groupName: 'A', lastSyncedAt: 0 },
{ id: 'synthetic-source-b', type: 'ssh_config', filePath: '/synthetic/b.conf', groupName: 'B', lastSyncedAt: 0 },
];
const run = await begin('edit'); await chooseGroup();
await waitFor(() => button('Save Host') !== undefined);
const group = Array.from(document.querySelectorAll('label')).find((label) => label.querySelector('span')?.textContent === 'Group')?.querySelector('input');
assert.ok(group);
const setValue = Object.getOwnPropertyDescriptor(dom.window.HTMLInputElement.prototype, 'value')!.set!;
await act(async () => { setValue.call(group, 'B'); group.dispatchEvent(new dom.window.Event('input', { bubbles: true })); await settle(); });
assert.match(document.querySelector('[role="dialog"]')?.textContent ?? '', /"A"\s*\u2192\s*"B"/u);
assert.match(document.querySelector('[role="alert"]')?.textContent ?? '', /Managed SSH config changes require the Vault host editor/);
assert.equal(button('Save Host').disabled, true);
await click(button('Save Host')); assert.equal(saves, 1);
await click(button('Cancel')); assert.deepEqual(await run.promise, { cancelled: true });
assert.equal(hosts[0].group, 'A'); assert.equal(hosts[0].managedSourceId, 'synthetic-source-a');
vault.managedSources = [];
});
await t.test('creating into a managed group is explicitly unavailable in the small draft UI', async () => {
vault.managedSources = [{ id: 'synthetic-source-b', type: 'ssh_config', filePath: '/synthetic/b.conf', groupName: 'Samples', lastSyncedAt: 0 }];
const run = await begin('create');
assert.match(document.querySelector('[role="alert"]')?.textContent ?? '', /Managed SSH config changes require the Vault host editor/);
assert.equal(button('Save Host').disabled, true);
await click(button('Cancel')); assert.deepEqual(await run.promise, { cancelled: true });
assert.equal(saves, 1); vault.managedSources = [];
});
for (const transport of [
{ name: 'Mosh', expected: 'mosh', overrides: { moshEnabled: true } },
{ name: 'ET', expected: 'et', overrides: { etEnabled: true } },
{ name: 'implicit localhost shell', expected: 'local', overrides: { hostname: 'localhost' } },
] as const) await t.test(`${transport.name} confirmation matches the real connection handler`, async () => {
const candidate = sanitizeHost({ ...second, id: `synthetic-${transport.expected}`, label: `Synthetic ${transport.expected}`, ...transport.overrides });
hosts = [candidate]; persisted = hosts.map((host) => structuredClone(host));
const dispatched: Host[] = [];
const logs: Array<Pick<ConnectionLog, 'protocol'>> = [];
registerAppHandlers({ handleConnectToHost: (host: Host) => handleConnectToHostImpl(() => ({
resolveEffectiveHost: (saved: Host) => applyGroupDefaults(saved, saved.group ? resolveGroupDefaults(saved.group, vault.groupConfigs) : {}),
discoveredShells: [], terminalSettings: {}, resolveShellSetting: () => null,
systemInfoRef: { current: { username: 'synthetic-local-user', hostname: 'synthetic-device' } },
keys: [], identities: [], resolveHostAuth: () => ({ username: 'synthetic-user' }),
connectToHost: (resolved: Host) => { dispatched.push(resolved); return 'synthetic-transport-session'; },
addConnectionLog: (entry: Pick<ConnectionLog, 'protocol'>) => logs.push(entry),
}), host) });
const run = await begin('connect');
try {
await chooseGroup(); await waitFor(() => button('Connect') !== undefined);
const confirmation = document.querySelector('[role="dialog"]')?.textContent ?? '';
assert.match(confirmation, new RegExp(`\\(${transport.expected}\\)`));
assert.equal(confirmation.includes('Local execution: this opens a shell on this device.'), transport.expected === 'local');
if (transport.expected === 'local') assert.doesNotMatch(confirmation, /test@localhost:22/u);
assert.equal(dispatched.length, 0);
await click(button('Connect'));
assert.deepEqual(await run.promise, { cancelled: false, status: 'requested' });
assert.equal(dispatched.length, 1);
assert.equal(logs[0].protocol, transport.expected);
} finally {
if (button('Cancel')) await click(button('Cancel'));
await run.promise.catch(() => {});
registerAppHandlers({ handleConnectToHost: (host: Host) => { connections.push(host); return 'private-session-id'; } });
}
});
await t.test('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, 2);
});
} finally {
await act(async () => { root.unmount(); await settle(); });
await service.manager.shutdown();
runtime.registerAppVaultRuntime(null); runtime.registerAppAppLockRuntime(null); registerAppHandlers(null);
dom.window.close(); await rm(directory, { recursive: true, force: true });
for (const [key, descriptor] of previous) { if (descriptor) Object.defineProperty(globalThis, key, descriptor); else Reflect.deleteProperty(globalThis, key); }
}
});
@@ -0,0 +1,38 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import React from 'react';
import { I18nProvider } from '../application/i18n/I18nProvider';
import { PluginHostDraftPreview } from './plugins/PluginHostDraftPreview';
import { createDomRenderer, dispatchDomEvent, installDomEnvironment } from './test-support/renderReactDom';
import type { Host } from '../domain/models';
import type { PluginHostDraftFields } from '../domain/pluginHostActions';
test('host-owned draft preview shows a field diff and submits only reviewed editable fields', async () => {
const dom = installDomEnvironment();
const renderer = await createDomRenderer(dom.document);
const host: Host = { id: 'synthetic', label: 'Before', hostname: 'host.invalid', username: 'test', tags: [], os: 'linux', password: 'synthetic-password', startupCommand: 'synthetic-script' };
const submitted: PluginHostDraftFields[] = [];
try {
await renderer.render(<I18nProvider locale="en"><PluginHostDraftPreview before={host} draft={{ label: 'After' }} busy={false} onConfirm={(draft) => submitted.push(draft)} /></I18nProvider>);
assert.equal(dom.document.querySelectorAll('input').length, 3);
assert.match(renderer.container.textContent ?? '', /Before.*After/u);
assert.equal(renderer.container.innerHTML.includes('synthetic-password'), false);
assert.equal(renderer.container.innerHTML.includes('synthetic-script'), false);
const save = dom.document.querySelector('button');
assert.ok(save);
await dispatchDomEvent(save, new dom.window.MouseEvent('click', { bubbles: true }));
assert.deepEqual(submitted, [{ label: 'After', group: '', tags: [] }]);
} finally { await renderer.unmount(); dom.cleanup(); }
});
test('invalid create draft cannot be confirmed', async () => {
const dom = installDomEnvironment();
const renderer = await createDomRenderer(dom.document);
try {
await renderer.render(<I18nProvider locale="en"><PluginHostDraftPreview draft={{ hostname: 'invalid host' }} busy={false} onConfirm={() => { throw new Error('Invalid draft was submitted'); }} /></I18nProvider>);
assert.ok(dom.document.querySelector('[role="alert"]'));
const save = dom.document.querySelector('button');
assert.ok(save?.disabled);
await dispatchDomEvent(save, new dom.window.MouseEvent('click', { bubbles: true }));
} finally { await renderer.unmount(); dom.cleanup(); }
});
+6 -13
View File
@@ -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'));
+107
View File
@@ -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); }
}
});
+3 -1
View File
@@ -50,6 +50,7 @@ export interface SelectHostPanelContentProps {
/** Preferred multi-select path for host/group toggles (selection resolves to host ids). */
onSelectionChange?: (selectedHostIds: string[]) => void;
onConfirm: () => void;
confirmDisabled?: boolean;
onNewHost?: () => void;
availableKeys?: SSHKey[];
identities?: import('../domain/models').Identity[];
@@ -79,6 +80,7 @@ export const SelectHostPanelContent: React.FC<SelectHostPanelContentProps> = ({
onSelect,
onSelectionChange,
onConfirm,
confirmDisabled = false,
onNewHost,
availableKeys = [],
identities = [],
@@ -643,7 +645,7 @@ export const SelectHostPanelContent: React.FC<SelectHostPanelContentProps> = ({
<div className="px-4 py-3 border-t border-border/60 shrink-0">
<Button
className="w-full"
disabled={selectedHostIds.length === 0}
disabled={confirmDisabled || selectedHostIds.length === 0}
onClick={onConfirm}
>
{multiSelect
+2
View File
@@ -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} />
</>
);
+1 -1
View File
@@ -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,45 @@
import { useI18n } from '../../application/i18n/I18nProvider';
import { usePluginHostActions } from '../../application/state/usePluginHostActions';
import { applyGroupDefaults, resolveGroupDefaults } from '../../domain/groupConfig';
import { resolveEffectiveTerminalProtocol } from '../../domain/terminalProtocol';
import { SelectHostPanelContent } from '../SelectHostPanelContent';
import { Button } from '../ui/button';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '../ui/dialog';
import { PluginHostDraftPreview } from './PluginHostDraftPreview';
export function PluginHostActionHost() {
const { t } = useI18n();
const state = usePluginHostActions();
const entry = state.review;
if (!entry) return null;
const request = entry.prompt.request;
const selection = request.kind === 'vault.selectHosts';
const connection = request.kind === 'vault.requestConnect';
const effective = entry.host ? applyGroupDefaults(entry.host, entry.host.group ? resolveGroupDefaults(entry.host.group, entry.groups) : {}) : undefined;
const protocol = effective ? resolveEffectiveTerminalProtocol(effective) : undefined;
return <Dialog open onOpenChange={(open) => { if (!open) void state.respond('cancel'); }}>
<DialogContent className={selection ? 'max-w-md h-[min(85vh,640px)] flex flex-col p-0 gap-0 overflow-hidden' : 'sm:max-w-[560px] max-h-[85vh] overflow-y-auto'} hideCloseButton>
<DialogHeader className={selection ? 'px-4 pt-4 pb-2 shrink-0 border-b border-border/60' : undefined}>
<DialogTitle>{t(selection ? 'plugins.hostActions.select' : connection ? 'plugins.hostActions.connect' : entry.host ? 'plugins.hostActions.edit' : 'plugins.hostActions.create')}</DialogTitle>
<DialogDescription className="break-all">{entry.prompt.pluginId} · {entry.prompt.pluginVersion}</DialogDescription>
</DialogHeader>
{selection ? <>
<p className="px-4 pt-3 text-xs text-muted-foreground">{t('plugins.hostActions.selectionNote')}</p>
<p className="px-4 pt-2 text-sm font-medium break-words">{request.title}</p>
{state.selectedIds.length > 50 && <p role="alert" className="px-4 text-sm text-destructive">{t('plugins.hostActions.selectionLimit')}</p>}
<SelectHostPanelContent hosts={entry.candidates} customGroups={entry.customGroups} selectedHostIds={state.selectedIds} multiSelect className="flex-1 min-h-0" confirmDisabled={state.busy || state.selectedIds.length > 50} onSelect={(host) => state.setSelectedIds((ids) => ids.includes(host.id) ? ids.filter((id) => id !== host.id) : [...ids, host.id])} onSelectionChange={state.setSelectedIds} onConfirm={() => { if (!state.busy && state.selectedIds.length <= 50) void state.respond('submit'); }} />
</> : connection && effective ? <>
<p className="break-all text-sm font-medium">{effective.label}</p>
<p className="break-all text-sm">{protocol !== 'local' && effective.username ? `${effective.username}@` : ''}{effective.hostname}{protocol !== 'local' && effective.port ? `:${effective.port}` : ''} ({protocol})</p>
{protocol === 'local' && <p className="text-sm font-medium" role="note">{t('plugins.hostActions.localExecution')}</p>}
<p className="text-sm" role="note">{t(entry.scriptsRun ? 'plugins.hostActions.scriptsRun' : 'plugins.hostActions.noScripts')}</p>
<p className="text-xs text-muted-foreground">{t('plugins.hostActions.connectionNote')}</p>
</> : request.kind === 'vault.requestHostDraft' ? <PluginHostDraftPreview key={entry.prompt.requestId} before={entry.host} draft={request.draft} managedSources={entry.managedSources} busy={state.busy} onConfirm={(draft) => void state.respond('submit', draft)} /> : null}
{state.error && <p role="alert" className="text-sm text-destructive">{t('plugins.hostActions.failed')}</p>}
<DialogFooter className={selection ? 'p-3 border-t' : undefined}>
<Button variant="outline" disabled={state.busy} onClick={() => void state.respond('cancel')}>{t('common.cancel')}</Button>
{connection && <Button disabled={state.busy} onClick={() => void state.respond('submit')}>{t('common.connect')}</Button>}
</DialogFooter>
</DialogContent>
</Dialog>;
}
@@ -0,0 +1,58 @@
import { useMemo, useState } from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { applyPluginHostEdit, normalizePluginHostDraft, pluginHostDraftTouchesManagedSource, pluginHostFieldDiff, PLUGIN_HOST_CREATE_FIELDS, PLUGIN_HOST_EDIT_FIELDS, type PluginHostDraftFields } from '../../domain/pluginHostActions';
import type { Host, ManagedSource } from '../../domain/models';
import { Button } from '../ui/button';
import { Input } from '../ui/input';
/** Host-owned editable draft and exact normalized field diff. The containing
* dialog owns confirmation, expiry and cancellation; this body has no IPC. */
export function PluginHostDraftPreview({ before, draft, busy, onConfirm, managedSources = [] }: {
before?: Host;
draft: PluginHostDraftFields;
busy: boolean;
onConfirm: (draft: PluginHostDraftFields) => void;
managedSources?: ManagedSource[];
}) {
const { t } = useI18n();
const [fields, setFields] = useState<Record<string, string>>(() => ({
label: draft.label ?? before?.label ?? '',
hostname: draft.hostname ?? '', username: draft.username ?? '',
port: String(draft.port ?? 22), group: draft.group ?? before?.group ?? '',
tags: (draft.tags ?? before?.tags ?? []).join(', '),
}));
const preview = useMemo(() => {
const value = {
label: fields.label, group: fields.group,
tags: fields.tags.split(',').map((tag) => tag.trim()).filter(Boolean),
...(!before ? { hostname: fields.hostname, username: fields.username, port: Number(fields.port) } : {}),
};
try {
const after = before ? applyPluginHostEdit(before, value) : normalizePluginHostDraft(value, 'preview');
return { value: {
label: after.label, group: after.group ?? '', tags: after.tags,
...(!before ? { hostname: after.hostname, username: after.username, port: after.port } : {}),
}, diff: pluginHostFieldDiff(before, after), managed: pluginHostDraftTouchesManagedSource(after, managedSources) };
} catch { return null; }
}, [before, fields, managedSources]);
return <div className="space-y-4">
<div className="grid grid-cols-2 gap-3">
{(before ? PLUGIN_HOST_EDIT_FIELDS : PLUGIN_HOST_CREATE_FIELDS).map((field) => <label key={field} className="space-y-1 text-sm">
<span>{t(`hostForm.field.${field}`)}</span>
<Input value={fields[field]} type={field === 'port' ? 'number' : 'text'} min={field === 'port' ? 1 : undefined} max={field === 'port' ? 65535 : undefined} maxLength={field === 'tags' ? 32_896 : 1024} autoComplete="off" disabled={busy} onChange={(event) => setFields((previous) => ({ ...previous, [field]: event.target.value }))} />
</label>)}
</div>
<p className="text-xs text-muted-foreground">{t('plugins.hostActions.defaultsWarning')}</p>
{preview?.managed && <p role="alert" className="text-sm text-destructive">{t('plugins.hostActions.managedUnsupported')}</p>}
{preview ? <div className="max-h-48 overflow-auto rounded-md border p-3">
<p className="mb-2 text-sm font-medium">{t('plugins.hostActions.fieldDiff')}</p>
<dl className="space-y-2 text-xs">
{preview.diff.map((change) => <div key={change.field} className="break-all">
<dt className="font-medium">{t(`hostForm.field.${change.field}`)}</dt>
<dd>{JSON.stringify(change.before)} &rarr; {JSON.stringify(change.after)}</dd>
</div>)}
</dl>
</div> : <p role="alert" className="text-sm text-destructive">{t('plugins.hostActions.invalidDraft')}</p>}
<Button className="w-full" disabled={busy || !preview || preview.managed || preview.diff.length === 0} onClick={() => { if (preview && !preview.managed) onConfirm(preview.value); }}>{t('hostForm.saveHost')}</Button>
</div>;
}
@@ -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>;
}
+5 -2
View File
@@ -201,8 +201,9 @@ export const SettingCard: React.FC<SettingCardProps> = ({
);
interface SettingRowProps {
label?: string;
description?: string;
leading?: React.ReactNode;
label?: React.ReactNode;
description?: React.ReactNode;
children: React.ReactNode;
align?: "center" | "start";
/** Stable catalog id for settings search jump targets. */
@@ -210,6 +211,7 @@ interface SettingRowProps {
}
export const SettingRow: React.FC<SettingRowProps> = ({
leading,
label,
description,
children,
@@ -225,6 +227,7 @@ export const SettingRow: React.FC<SettingRowProps> = ({
align === "start" ? "items-start" : "items-center",
)}
>
{leading && <div className="shrink-0">{leading}</div>}
<div className="flex-1 min-w-0">
{label && <div className="text-sm font-medium">{label}</div>}
{description && (
@@ -0,0 +1,78 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { JSDOM } from 'jsdom';
test('local logging controls default off, disclose verbose risk, and call host-only management actions', async () => {
const dom = new JSDOM('<!doctype html><html><body><div id="root"></div></body></html>', { pretendToBeVisual: true, url: 'http://localhost' });
const previous = new Map<string, PropertyDescriptor | undefined>();
const install = (key: string, value: unknown) => {
previous.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
Object.defineProperty(globalThis, key, { configurable: true, writable: true, value });
};
for (const key of ['window', 'document', 'navigator', 'HTMLElement', 'HTMLInputElement', 'Element', 'Node', 'Event', 'MouseEvent', 'KeyboardEvent', 'MutationObserver', 'NodeFilter', 'DocumentFragment', 'CustomEvent', 'getComputedStyle']) {
install(key, key === 'window' ? dom.window : dom.window[key as keyof typeof dom.window]);
}
install('IS_REACT_ACT_ENVIRONMENT', true);
const { default: React, act } = await import('react');
const { createRoot } = await import('react-dom/client');
const { I18nProvider } = await import('../../../application/i18n/I18nProvider');
const { PluginLogSettings } = await import('./PluginLogSettings');
const root = createRoot(document.getElementById('root')!);
let status = { saveToDisk: false, verbose: false, droppedRecords: 2, writeErrors: 0, pendingBytes: 0, maxBytes: 3145728, retentionDays: 7 };
const settings: Array<{ saveToDisk: boolean; verbose: boolean }> = [];
const actions: string[] = [];
let failConfigure = false;
Object.assign(dom.window, { netcatty: {
async getPluginLogStatus() { return status; },
async configurePluginLogs(value: { saveToDisk: boolean; verbose: boolean }) { settings.push(value); status = { ...status, ...value }; if (failConfigure) throw new Error('synthetic preferences write failed'); return status; },
async openPluginLogFolder() { actions.push('open'); return status; },
async exportPluginLogs() { actions.push('export'); return status; },
async clearPluginLogs() { actions.push('clear-confirmed-by-native-host'); return status; },
} });
const button = (label: string) => [...document.querySelectorAll<HTMLButtonElement>('button')].find((item) => item.textContent === label)!;
const switches = () => [...document.querySelectorAll<HTMLButtonElement>('[role="switch"]')];
try {
await act(async () => root.render(<React.StrictMode><I18nProvider locale="en"><PluginLogSettings /></I18nProvider></React.StrictMode>));
assert.equal(document.querySelector('[role=dialog]'), null);
assert.equal(document.querySelector('input[type=checkbox]'), null);
const configure = button('Configure');
configure.focus();
await act(async () => configure.click());
assert.equal(document.querySelectorAll('[role=dialog]').length, 1);
assert.equal(switches()[0].getAttribute('aria-checked'), 'false');
assert.equal(switches()[0].disabled, false, 'StrictMode initialization completes');
assert.equal(switches()[1].disabled, true);
assert.match(document.body.textContent!, /no upload or sync/);
assert.match(document.body.textContent!, /Dropped: 2/);
await act(async () => switches()[0].click());
assert.deepEqual(settings, [{ saveToDisk: true, verbose: false }]);
await act(async () => switches()[1].click());
assert.equal(settings.length, 1, 'opening disclosure does not enable verbose logs');
assert.match(document.body.textContent!, /cannot make arbitrary text safe/);
await act(async () => button('Cancel').click());
assert.equal(settings.length, 1, 'cancelling risk consent leaves verbose off');
await act(async () => switches()[1].click());
await act(async () => button('Enable temporarily').click());
assert.deepEqual(settings[1], { saveToDisk: true, verbose: true });
await act(async () => button('Open folder').click());
await act(async () => button('Export').click());
await act(async () => button('Clear logs…').click());
assert.deepEqual(actions, ['open', 'export', 'clear-confirmed-by-native-host']);
failConfigure = true;
await act(async () => switches()[0].click());
assert.deepEqual(settings.at(-1), { saveToDisk: false, verbose: false });
assert.equal(switches()[0].getAttribute('aria-checked'), 'false', 'failed configuration refreshes actual OFF host state');
assert.equal(switches()[1].getAttribute('aria-checked'), 'false');
assert.match(document.querySelector('[role=alert]')!.textContent!, /synthetic preferences write failed/);
await act(async () => button('Close').click());
assert.equal(document.querySelector('[role=dialog]'), null);
assert.equal(document.activeElement, configure);
} finally {
await act(async () => root.unmount());
dom.window.close();
for (const [key, descriptor] of previous) {
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
else Reflect.deleteProperty(globalThis, key);
}
}
});
@@ -0,0 +1,58 @@
import React, { useState } from 'react';
import { usePluginLogs } from '../../../application/state/usePluginLogs';
import { useI18n } from '../../../application/i18n/I18nProvider';
import { Button } from '../../ui/button';
import { ConfirmDialog } from '../../ui/confirm-dialog';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from '../../ui/dialog';
import { SettingRow, Toggle } from '../settings-ui';
/** Host settings row; detailed controls stay inside the standard host dialog. */
export function PluginLogSettings() {
const { t, resolvedLocale } = useI18n();
const zh = resolvedLocale.startsWith('zh');
const { status, error, busy, run } = usePluginLogs();
const [open, setOpen] = useState(false);
const [confirmVerbose, setConfirmVerbose] = useState(false);
const title = zh ? '本地插件日志' : 'Local plugin logs';
const saveLabel = zh ? '将宿主审计日志保存到本地' : 'Save host audit logs locally';
const verboseLabel = zh ? '包含插件详细诊断文字' : 'Include verbose plugin text';
const disclosure = zh
? '插件编写的日志文字可能包含敏感数据。截断和字段过滤不能保证任意文字安全。只为排查问题临时开启,导出前请检查。重启应用后自动关闭详细日志。'
: 'Plugin-authored text may contain sensitive data. Truncation and field filtering cannot make arbitrary text safe. Enable temporarily for troubleshooting and review files before sharing. Verbose diagnostics turn off after app restart.';
return <div>
<Dialog open={open} onOpenChange={setOpen}>
<SettingRow label={title} description={zh ? '选择是否在此设备保存审计记录和诊断日志。' : 'Choose whether to keep audit records and diagnostics on this device.'}>
<div className="flex items-center gap-3">
<span className="text-xs text-muted-foreground" role="status">{status ? (status.saveToDisk ? t('common.enabled') : t('common.disabled')) : error ? (zh ? '不可用' : 'Unavailable') : (zh ? '载入中' : 'Loading')}</span>
<DialogTrigger asChild><Button className="focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2" variant="outline" size="sm">{zh ? '配置' : 'Configure'}</Button></DialogTrigger>
</div>
</SettingRow>
<DialogContent data-host-security-surface="" className="max-h-[85vh] overflow-y-auto sm:max-w-xl">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{zh ? '默认不保存。仅保存在此设备,不上传或同步。权限面板中的有限安全历史独立保留。' : 'Saving is off by default. Files stay on this device with no upload or sync. The bounded security history in plugin permissions is separate.'}</DialogDescription>
</DialogHeader>
{error && <p role="alert" className="text-sm text-destructive">{error}</p>}
<div className="divide-y divide-border">
<SettingRow label={saveLabel} description={zh ? '保存宿主确认的操作和授权结果,不保存敏感正文。' : 'Save host-observed operations and authorization outcomes without sensitive bodies.'}>
<Toggle ariaLabel={saveLabel} checked={status?.saveToDisk ?? false} disabled={busy || !status} onChange={(saveToDisk) => void run('configure', { saveToDisk, verbose: false })} />
</SettingRow>
<SettingRow label={verboseLabel} description={zh ? '插件文字可能包含敏感数据;只为排查问题临时开启。' : 'Plugin text may contain sensitive data. Enable temporarily for troubleshooting.'}>
<Toggle ariaLabel={verboseLabel} checked={status?.verbose ?? false} disabled={busy || !status?.saveToDisk} onChange={(verbose) => verbose ? setConfirmVerbose(true) : void run('configure', { saveToDisk: true, verbose: false })} />
</SettingRow>
</div>
{status && <p className="text-xs text-muted-foreground">{zh ? `最多 ${status.maxBytes / 1048576} MiB,最长保留 ${status.retentionDays} 天。丢弃 ${status.droppedRecords} 条,写入错误 ${status.writeErrors} 次。` : `Up to ${status.maxBytes / 1048576} MiB, up to ${status.retentionDays} days. Dropped: ${status.droppedRecords}. Write errors: ${status.writeErrors}.`}</p>}
<DialogFooter className="gap-2 sm:justify-between">
<div className="flex flex-wrap gap-2">
<Button className="focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2" variant="outline" size="sm" disabled={busy || !status} onClick={() => void run('open')}>{zh ? '打开文件夹' : 'Open folder'}</Button>
<Button className="focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2" variant="outline" size="sm" disabled={busy || !status} onClick={() => void run('export')}>{zh ? '导出' : 'Export'}</Button>
<Button className="focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2" variant="ghost" size="sm" disabled={busy || !status} onClick={() => void run('clear')}>{zh ? '清除日志…' : 'Clear logs…'}</Button>
<Button className="focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2" variant="ghost" size="sm" disabled={busy} onClick={() => void run('status')}>{zh ? '刷新' : 'Refresh'}</Button>
</div>
<Button className="focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2" variant="outline" size="sm" onClick={() => setOpen(false)}>{t('common.close')}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<ConfirmDialog hostSecuritySurface open={confirmVerbose} title={zh ? '开启插件详细诊断?' : 'Enable verbose plugin diagnostics?'} message={disclosure} confirmLabel={zh ? '临时开启' : 'Enable temporarily'} onOpenChange={setConfirmVerbose} onConfirm={() => { setConfirmVerbose(false); void run('configure', { saveToDisk: true, verbose: true }); }} />
</div>;
}
@@ -17,7 +17,7 @@ test('plugin manager is single-instance, closes with Escape, restores focus and
install('requestAnimationFrame', dom.window.requestAnimationFrame.bind(dom.window));
install('cancelAnimationFrame', dom.window.cancelAnimationFrame.bind(dom.window));
install('IS_REACT_ACT_ENVIRONMENT', true);
const { default: React, act, useEffect } = await import('react');
const { default: React, act, useEffect, useRef, useState } = await import('react');
const { createRoot } = await import('react-dom/client');
const { I18nProvider } = await import('../../../application/i18n/I18nProvider');
const { PluginManagementDialog } = await import('./PluginManagementDialog');
@@ -69,6 +69,28 @@ test('plugin manager is single-instance, closes with Escape, restores focus and
await click(trigger);
await click(document.querySelector<HTMLButtonElement>('[role="dialog"] button:not([data-dialog-close])')!);
assert.equal(handedOff, true, 'external view opens only after modal ownership is released');
function ControlledEntry() {
const [open, setOpen] = useState(false);
const browse = useRef<HTMLButtonElement | null>(null);
const opener = useRef<HTMLElement | null>(null);
return <>
<button ref={browse}>Fallback browse</button>
<button onClick={(event) => { opener.current = event.currentTarget; setOpen(true); }}>External plugin settings</button>
<PluginManagementDialog open={open} onOpenChange={setOpen} trigger={null} restoreFocusTo={opener} fallbackFocusTo={browse}><input aria-label="Controlled search" /></PluginManagementDialog>
</>;
}
await act(async () => root.render(<I18nProvider locale="en"><ControlledEntry /></I18nProvider>));
const external = [...document.querySelectorAll<HTMLButtonElement>('button')].find((item) => item.textContent === 'External plugin settings')!;
external.focus();
await click(external);
assert.equal(document.querySelectorAll('[role=dialog]').length, 1);
await act(async () => { document.activeElement!.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); await settle(); });
assert.equal(document.activeElement, external, 'controlled entry restores the actual row opener');
await click(external);
external.disabled = true;
await act(async () => { document.activeElement!.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); await settle(); });
assert.equal(document.activeElement?.textContent, 'Fallback browse', 'a disabled or removed opener uses the stable browse action');
} finally {
await act(async () => root.unmount());
dom.window.close();
@@ -4,18 +4,25 @@ import { Button } from '../../ui/button';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '../../ui/dialog';
/** Uses the existing renderer and host bridge; opening the manager never starts a plugin. */
export function PluginManagementDialog({ children }: {
export function PluginManagementDialog({ children, trigger, open: controlledOpen, onOpenChange, restoreFocusTo, fallbackFocusTo }: {
trigger?: React.ReactNode | null;
open?: boolean;
onOpenChange?: (open: boolean) => void;
restoreFocusTo?: React.RefObject<HTMLElement | null>;
fallbackFocusTo?: React.RefObject<HTMLElement | null>;
children: React.ReactNode | ((handoff: (action: () => void) => void) => React.ReactNode);
}) {
const { t } = useI18n();
const [open, setOpen] = useState(false);
const [internalOpen, setInternalOpen] = useState(false);
const open = controlledOpen ?? internalOpen;
const setOpen = (value: boolean) => { setInternalOpen(value); onOpenChange?.(value); };
const pendingHandoff = useRef<(() => void) | null>(null);
const handoff = (action: () => void) => { pendingHandoff.current = action; setOpen(false); };
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="outline">{t('settings.plugins.openManager')}</Button>
</DialogTrigger>
{trigger !== null && <DialogTrigger asChild>
{trigger ?? <Button variant="outline">{t('settings.plugins.openManager')}</Button>}
</DialogTrigger>}
<DialogContent onCloseAutoFocus={(event) => {
const action = pendingHandoff.current;
pendingHandoff.current = null;
@@ -24,6 +31,13 @@ export function PluginManagementDialog({ children }: {
// Wait for Radix to release modal pointer/focus ownership before opening
// the existing external plugin view host (including exit animations).
queueMicrotask(action);
} else {
const previous = restoreFocusTo?.current;
const target = previous?.isConnected && !previous.matches(':disabled') ? previous : fallbackFocusTo?.current;
if (target?.isConnected) {
event.preventDefault();
target.focus({ preventScroll: true });
}
}
}} className="flex h-[min(88dvh,900px)] w-[calc(100vw-2rem)] max-w-[1180px] flex-col gap-0 overflow-hidden p-0">
<DialogHeader className="shrink-0 border-b px-6 py-4 pr-12 text-left">
@@ -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, /&lt;script&gt;/);
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/);
});
@@ -49,9 +49,9 @@ export function PluginPackageDetails({ plugin }: { plugin: NetcattyInstalledPlug
</div>;
}
export function PluginPackageManager({ manager, onSelectionChange }: { manager: ReturnType<typeof usePluginManager>; onSelectionChange?: (id: string | null) => void }) {
export function PluginPackageManager({ manager, onSelectionChange, initialSelectedId }: { initialSelectedId?: string | null; manager: ReturnType<typeof usePluginManager>; onSelectionChange?: (id: string | null) => void }) {
const { t, resolvedLocale } = useI18n();
const [selected, setSelected] = useState<string | null>(null);
const [selected, setSelected] = useState<string | null>(initialSelectedId ?? null);
const [search, setSearch] = useState('');
const [sourceFilter, setSourceFilter] = useState('');
const rootRef = useRef<HTMLElement>(null);
@@ -1,8 +1,10 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { netcattyBridge } from '../../../infrastructure/services/netcattyBridge';
import { useI18n } from '../../../application/i18n/I18nProvider';
import { describePluginPermission } from '../../../domain/pluginPermissionDescriptions.cjs';
import { 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>
@@ -0,0 +1,149 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { JSDOM } from 'jsdom';
async function fixture() {
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', 'DocumentFragment', '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('IS_REACT_ACT_ENVIRONMENT', true);
const frames = new Map<number, FrameRequestCallback>();
let frameId = 0;
install('requestAnimationFrame', (callback: FrameRequestCallback) => { frames.set(++frameId, callback); return frameId; });
install('cancelAnimationFrame', (id: number) => frames.delete(id));
const scrolled: HTMLElement[] = [];
dom.window.HTMLElement.prototype.scrollIntoView = function () { scrolled.push(this); };
const { default: React, act } = await import('react');
const { createRoot } = await import('react-dom/client');
const { I18nProvider } = await import('../../../application/i18n/I18nProvider');
const { Tabs } = await import('../../ui/tabs');
const { default: SettingsPluginsTab } = await import('./SettingsPluginsTab');
const plugin = { id: 'com.example.alpha', enabled: true, activeVersion: '1.0.0', archiveSha256: 'a'.repeat(64),
source: { id: 'local', kind: 'local', name: 'Local import', origin: null },
manifest: { manifestVersion: 1, id: 'com.example.alpha', name: 'Alpha', version: '1.0.0', publisher: 'example', engines: { netcatty: '>=0.0.0', api: '>=0.1.0-internal <0.2.0' }, main: { browser: 'index.js' }, contributes: { settings: [{ id: 'com.example.alpha.setting', label: 'Alpha setting', control: 'text', scope: 'application' }] } },
runtime: { status: 'stopped', kind: null, lastError: null, quarantinedAt: null } };
let installed = plugin;
let delayed = false;
const pending: (() => void)[] = [];
const listeners = new Set<() => void>();
Object.assign(dom.window, { netcatty: {
async getPluginRuntimeStatus() { return { available: true, experimental: true, credentialStorageAvailable: true }; },
async listPlugins() { return [installed]; },
onPluginContributionsChanged(callback: () => void) { listeners.add(callback); return () => listeners.delete(callback); },
async getPluginLogStatus() { return { saveToDisk: false, verbose: false, droppedRecords: 0, writeErrors: 0, pendingBytes: 0, maxBytes: 3145728, retentionDays: 7 }; },
async getPluginContributions(query: NetcattyPluginContributionQuery) {
if (delayed && query.scopeIds) await new Promise<void>((resolve) => pending.push(resolve));
return { locale: 'en', plugins: [{ id: plugin.id, version: '1.0.0', displayName: 'Alpha', commands: [], keybindings: [], menus: [], views: [], settings: [{ id: 'com.example.alpha.setting', label: 'Alpha setting', control: 'text', scope: 'application', scopeId: 'application', value: 'synthetic', visible: true, configured: true }] }] };
},
async getPluginScopeCatalog() { return { workspace: [], host: [], session: [], device: [] }; },
async listPluginSources() { return []; },
async listPluginCatalog() { return { entries: [], source: 'builtin', error: null }; },
async getPluginPermissions() { return { grants: [], audit: [] }; },
} });
const root = createRoot(document.getElementById('root')!);
const settle = () => new Promise((resolve) => setTimeout(resolve, 25));
const perform = async (action: () => void) => { await act(async () => { action(); await settle(); }); };
await perform(() => root.render(<I18nProvider locale="en"><Tabs defaultValue="plugins"><SettingsPluginsTab /></Tabs></I18nProvider>));
const gear = document.querySelector<HTMLButtonElement>('button[aria-label="Settings for Alpha"]')!;
assert.ok(gear && !gear.disabled);
const flushFrames = async (count = 3) => {
for (let i = 0; i < count; i++) await act(async () => {
const current = [...frames.values()]; frames.clear();
for (const callback of current) callback(i * 16);
});
};
return {
gear, scrolled, perform, flushFrames,
section: () => document.querySelector<HTMLElement>('section[aria-labelledby="plugin-settings-com.example.alpha"]'),
open: async (slow = false) => { delayed = slow; await perform(() => { gear.focus(); gear.click(); }); },
release: async () => { delayed = false; await perform(() => { for (const resolve of pending.splice(0)) resolve(); }); },
selectTab: async (name: string) => {
const tab = [...document.querySelectorAll<HTMLElement>('[role=dialog] [role=tab]')].find((element) => element.textContent?.startsWith(name))!;
await perform(() => { tab.focus(); tab.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); });
},
close: async () => { await perform(() => document.activeElement!.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))); },
replaceOwner: async () => { installed = { ...plugin, source: { ...plugin.source, id: 'third-party:replacement', kind: 'third-party' } }; await perform(() => window.dispatchEvent(new Event('netcatty:plugin-packages-changed'))); },
refresh: async () => { await perform(() => { for (const callback of [...listeners]) callback(); }); },
dispose: async () => {
await act(async () => root.unmount()); dom.window.close();
for (const [key, descriptor] of previous) { if (descriptor) Object.defineProperty(globalThis, key, descriptor); else Reflect.deleteProperty(globalThis, key); }
},
};
}
test('gear entry scrolls and focuses its settings once, after dialog autofocus', async () => {
const f = await fixture();
try {
await f.open();
assert.notEqual(document.activeElement, f.section());
await f.flushFrames();
assert.deepEqual(f.scrolled, [f.section()]);
assert.equal(document.activeElement, f.section());
const input = document.querySelector<HTMLInputElement>('input[aria-label="Alpha setting"]')!;
input.focus();
await f.refresh(); await f.flushFrames();
assert.equal(document.activeElement, input, 'later contribution refresh cannot steal focus');
assert.equal(f.scrolled.length, 1);
await f.close();
assert.equal(document.activeElement, f.gear);
} finally { await f.dispose(); }
});
test('gear entry waits for slow contributions before targeting the existing settings section', async () => {
const f = await fixture();
try {
await f.open(true); await f.flushFrames();
assert.equal(f.section(), null); assert.equal(f.scrolled.length, 0);
await f.release(); await f.flushFrames();
assert.equal(f.scrolled.length, 1); assert.equal(document.activeElement, f.section());
} finally { await f.dispose(); }
});
test('closing between animation frames cancels an already scheduled gear focus', async () => {
const f = await fixture();
try {
await f.open(); await f.flushFrames(1);
await f.close(); await f.flushFrames();
assert.equal(f.scrolled.length, 0);
assert.equal(document.querySelector('[role=dialog]'), null);
assert.equal(document.activeElement, f.gear);
} finally { await f.dispose(); }
});
for (const cancellation of ['tab', 'plugin', 'owner', 'close']) test(`pending gear entry cancels on ${cancellation} change`, async () => {
const f = await fixture();
try {
await f.open(true);
if (cancellation === 'tab') await f.selectTab('Official');
if (cancellation === 'plugin') await f.perform(() => document.querySelector<HTMLButtonElement>('[data-plugin-card="com.example.alpha"]')!.click());
if (cancellation === 'owner') await f.replaceOwner();
if (cancellation === 'close') await f.close();
await f.release();
if (cancellation === 'tab') await f.selectTab('Installed');
if (cancellation === 'tab' || cancellation === 'plugin') await f.perform(() => document.querySelector<HTMLButtonElement>('[data-plugin-card="com.example.alpha"]')!.click());
await f.flushFrames();
assert.equal(f.scrolled.length, 0, 'late data or returning to the plugin cannot revive the cancelled intent');
if (cancellation === 'close') {
assert.equal(document.querySelector('[role=dialog]'), null);
assert.equal(document.activeElement, f.gear);
}
} finally { await f.dispose(); }
});
test('normal Browse and detail entry retain their existing detail focus', async () => {
const f = await fixture();
try {
const browse = [...document.querySelectorAll<HTMLButtonElement>('button')].find((button) => button.textContent === 'Browse')!;
await f.perform(() => { browse.focus(); browse.click(); });
await f.perform(() => document.querySelector<HTMLButtonElement>('[data-plugin-card="com.example.alpha"]')!.click());
await f.flushFrames();
assert.ok(f.section()); assert.equal(f.scrolled.length, 0);
assert.equal(document.activeElement, document.querySelector('[data-plugin-detail]'));
await f.close(); assert.equal(document.activeElement, browse);
} finally { await f.dispose(); }
});
@@ -0,0 +1,79 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { JSDOM } from 'jsdom';
// Uses the real settings primitives and verifies that the host manager owns state.
test('landing rows expose real sources/status/settings and quick toggles preserve host decisions', async () => {
const dom = new JSDOM('<!doctype html><html><body><div id="root"></div></body></html>', { pretendToBeVisual: true, url: 'http://localhost' });
const previous = new Map<string, PropertyDescriptor | undefined>();
const install = (key: string, value: unknown) => {
previous.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
Object.defineProperty(globalThis, key, { configurable: true, writable: true, value });
};
for (const key of ['window', 'document', 'navigator', 'HTMLElement', 'Element', 'Node', 'Event', 'MouseEvent']) install(key, key === 'window' ? dom.window : dom.window[key as keyof typeof dom.window]);
install('IS_REACT_ACT_ENVIRONMENT', true);
const { default: React, act } = await import('react');
const { createRoot } = await import('react-dom/client');
const { I18nProvider } = await import('../../../application/i18n/I18nProvider');
const { PluginSettingsLanding } = await import('./PluginSettingsLanding');
const root = createRoot(document.getElementById('root')!);
const plugins = [{
id: 'com.example.alpha', enabled: true, activeVersion: '1.0.0',
source: { id: 'official', kind: 'official', name: 'Official source', origin: 'https://plugins.example.com' },
manifest: { manifestVersion: 1, id: 'com.example.alpha', name: 'Alpha', version: '1.0.0', publisher: 'example', engines: { netcatty: '>=0.0.0', api: '>=0.1.0-internal <0.2.0' }, main: { browser: 'index.js' }, description: 'Synthetic description', contributes: { settings: [{ id: 'com.example.alpha.value', label: 'Value', control: 'text', scope: 'application' }] } },
runtime: { status: 'running', kind: 'browser', lastError: null, quarantinedAt: null },
}, {
id: 'com.example.beta', enabled: false, activeVersion: '2.0.0',
source: { id: 'third-party:synthetic', kind: 'third-party', name: 'Synthetic source', origin: 'https://synthetic.example.com' },
manifest: { manifestVersion: 1, id: 'com.example.beta', name: 'Beta', version: '2.0.0', publisher: 'example', engines: { netcatty: '>=0.0.0', api: '>=0.1.0-internal <0.2.0' }, main: { browser: 'index.js' } },
runtime: { status: 'stopped', kind: null, lastError: null, quarantinedAt: null },
}] as NetcattyInstalledPlugin[];
const changes: Array<[string, boolean]> = [];
const opened: Array<string | null> = [];
let refreshes = 0;
const manager = { plugins, available: true, statusKnown: true, busy: false, error: null as string | null, credentialStorageAvailable: true,
async refresh() { refreshes++; }, async setEnabled(id: string, enabled: boolean) { changes.push([id, enabled]); }, async importPackage() {}, async uninstall() {} };
Object.assign(dom.window, { netcatty: { async getPluginLogStatus() { return { saveToDisk: false, verbose: false, droppedRecords: 0, writeErrors: 0, pendingBytes: 0, maxBytes: 3145728, retentionDays: 7 }; } } });
const render = async (value = manager, settingsAvailable = new Set(['com.example.alpha'])) => act(async () => root.render(<I18nProvider locale="en"><PluginSettingsLanding manager={value} settingsAvailable={settingsAvailable} onManage={(id) => opened.push(id)} /></I18nProvider>));
const button = (label: string) => [...document.querySelectorAll<HTMLButtonElement>('button')].find((item) => item.textContent === label)!;
const byLabel = (label: string) => document.querySelector<HTMLButtonElement>(`button[aria-label="${label}"]`)!;
try {
await render();
assert.equal(document.querySelector('[role=dialog]'), null);
assert.equal(document.querySelector('input[type=checkbox]'), null);
assert.equal(document.querySelectorAll('[role=switch]').length, 2, 'only real plugin toggles appear on landing');
assert.match(document.body.textContent!, /Running/);
assert.match(document.body.textContent!, /Source: Official/);
assert.match(document.body.textContent!, /Synthetic source/);
assert.doesNotMatch(document.body.textContent!, /Save host audit logs locally|trust all|safe mode|telemetry/i);
await act(async () => button('Browse').click());
await act(async () => byLabel('Settings for Alpha').click());
assert.deepEqual(opened, [null, 'com.example.alpha']);
await act(async () => byLabel('Disable Alpha').click());
assert.deepEqual(changes, [['com.example.alpha', false]]);
assert.equal(byLabel('Disable Alpha').getAttribute('aria-checked'), 'true', 'no optimistic flip before host approval');
await render({ ...manager, error: 'Synthetic permission denied' });
assert.match(document.querySelector('[role=alert]')!.textContent!, /Synthetic permission denied/);
assert.equal(byLabel('Disable Alpha').getAttribute('aria-checked'), 'true');
await act(async () => byLabel('Refresh installed plugins').click());
assert.equal(refreshes, 1);
await render({ ...manager, statusKnown: false });
assert.equal(byLabel('Disable Alpha').disabled, true);
await act(async () => byLabel('Disable Alpha').click());
assert.equal(changes.length, 1);
await render(manager, new Set());
assert.equal(byLabel('Settings for Alpha').disabled, true, 'unavailable settings cannot open an empty destination');
await render({ ...manager, plugins: [] });
assert.match(document.body.textContent!, /No plugins installed/);
await render({ ...manager, plugins: [], statusKnown: false });
assert.match(document.body.textContent!, /Loading/);
await render({ ...manager, plugins: [], available: false });
assert.match(document.body.textContent!, /unavailable in this session/);
await render({ ...manager, plugins: [{ ...plugins[0], source: undefined }] });
assert.match(document.body.textContent!, /Source unknown/);
assert.doesNotMatch(document.body.textContent!, /Source: Local/);
} finally {
await act(async () => root.unmount()); dom.window.close();
for (const [key, descriptor] of previous) { if (descriptor) Object.defineProperty(globalThis, key, descriptor); else Reflect.deleteProperty(globalThis, key); }
}
});
@@ -0,0 +1,89 @@
import React, { useCallback, useMemo, useState } from 'react';
import { RefreshCw, Settings2 } from 'lucide-react';
import { useI18n } from '../../../application/i18n/I18nProvider';
import type { usePluginUpdateCheck } from '../../../application/state/usePluginUpdateCheck';
import type { usePluginManager } from '../../../application/state/usePluginManager';
import { Button } from '../../ui/button';
import { Input } from '../../ui/input';
import { PluginBrandIcon } from '../../plugins/PluginBrandIcon';
import { SettingCard, SettingRow, SettingsSection, Toggle } from '../settings-ui';
import { PluginLogSettings } from './PluginLogSettings';
type Manager = ReturnType<typeof usePluginManager>;
export function PluginSettingsLanding({ manager, settingsAvailable, settingsError, managerButtonRef, updates, onManage }: {
manager: Manager;
settingsAvailable: ReadonlySet<string>;
settingsError?: string;
managerButtonRef?: React.Ref<HTMLButtonElement>;
updates?: ReturnType<typeof usePluginUpdateCheck>;
onManage: (pluginId: string | null, opener: HTMLElement) => void;
}) {
const { t, resolvedLocale } = useI18n();
const [search, setSearch] = useState('');
const localized = useCallback((value: string | Record<string, string> | undefined) => typeof value === 'string' ? value : value?.[resolvedLocale] ?? value?.en ?? '', [resolvedLocale]);
const nameOf = (plugin: NetcattyInstalledPlugin) => localized(plugin.manifest?.displayName) || plugin.manifest?.name || plugin.id;
const visible = useMemo(() => {
const term = search.trim().toLocaleLowerCase();
return manager.plugins.filter((plugin) => [localized(plugin.manifest?.displayName), plugin.manifest?.name, plugin.id, plugin.manifest?.publisher, localized(plugin.manifest?.description), plugin.source?.name].some((value) => String(value ?? '').toLocaleLowerCase().includes(term)));
}, [manager.plugins, search, localized]);
const statusOf = (plugin: NetcattyInstalledPlugin) => {
if (!manager.statusKnown) return t('settings.plugins.permissions.statusUnknown');
const state = plugin.runtime.status;
if (['running', 'starting', 'stopping', 'error', 'quarantined'].includes(state)) return t(`settings.plugins.landing.status.${state}`);
if (state !== 'stopped') return t('settings.plugins.landing.status.unknown');
return t(plugin.enabled ? 'settings.plugins.landing.status.idle' : 'settings.plugins.manager.disabled');
};
const sourceOf = (plugin: NetcattyInstalledPlugin) => {
const source = plugin.source;
if (!source) return t('settings.plugins.landing.sourceUnknown');
const kind = t(source.kind === 'official' ? 'settings.plugins.sources.official' : source.kind === 'third-party' ? 'settings.plugins.sources.thirdparty' : 'settings.plugins.sources.local');
return `${t('settings.plugins.sources.source')}: ${kind}${source.kind === 'third-party' ? ` · ${source.name}` : ''}`;
};
return <>
<SettingsSection title={t('settings.plugins.landing.options')}>
<SettingCard divided>
<SettingRow label={t('settings.plugins.landing.manager')} description={t('settings.plugins.landing.managerDescription')}>
<Button ref={managerButtonRef} className="focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2" variant="outline" size="sm" onClick={(event) => onManage(null, event.currentTarget)}>{t('settings.plugins.landing.browse')}</Button>
</SettingRow>
<PluginLogSettings />
</SettingCard>
</SettingsSection>
<SettingsSection title={`${t('settings.plugins.manager.installed')} · ${manager.plugins.length}`} actions={<>
{updates && <Button className="focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2" variant="outline" size="sm" disabled={updates.busy || manager.busy || !manager.available || !manager.statusKnown || manager.plugins.length === 0} onClick={() => void updates.check()}>{t(updates.busy ? 'settings.plugins.landing.checkingUpdates' : 'settings.plugins.landing.checkUpdates')}</Button>}
<Button className="focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2" variant="ghost" size="icon" disabled={manager.busy} aria-label={t('settings.plugins.landing.refresh')} onClick={() => void manager.refresh({ clearError: true }).catch(() => {})}><RefreshCw size={16} /></Button>
</>}>
{updates?.error && <p role="alert" className="text-sm text-destructive">{updates.error}</p>}
{updates?.result && <p role="status" className="text-xs text-muted-foreground">{t('settings.plugins.landing.updateSummary').replace('{checked}', String(updates.result.checked)).replace('{updates}', String(updates.result.updates.length)).replace('{skipped}', String(updates.result.skipped))}{updates.result.failedSources > 0 && ` ${t('settings.plugins.landing.updateSourcesFailed').replace('{count}', String(updates.result.failedSources))}`} {t('settings.plugins.landing.updateReview')}</p>}
{settingsError && <p role="alert" className="text-sm text-destructive">{settingsError}</p>}
{manager.error && <p role="alert" className="text-sm text-destructive">{manager.error}</p>}
{!manager.statusKnown && !manager.error && <p role="status" className="text-sm text-muted-foreground">{t('settings.plugins.landing.loading')}</p>}
{manager.statusKnown && !manager.available && <p role="status" className="text-sm text-muted-foreground">{t('settings.plugins.landing.unavailable')}</p>}
{manager.statusKnown && manager.available && manager.plugins.length === 0 && <p className="text-sm text-muted-foreground">{t('settings.plugins.landing.empty')}</p>}
{manager.plugins.length > 0 && <>
<Input aria-label={t('settings.plugins.landing.search')} placeholder={t('settings.plugins.landing.search')} value={search} onChange={(event) => setSearch(event.target.value)} />
{visible.length === 0 ? <p role="status" className="text-sm text-muted-foreground">{t('settings.plugins.market.noResults')}</p> : <SettingCard divided>
{visible.map((plugin) => {
const name = nameOf(plugin);
const description = localized(plugin.manifest?.description);
const availableUpdate = updates?.result?.updates.find((item) => item.pluginId === plugin.id && item.sourceId === plugin.source?.id);
const declaredSettings = Boolean(plugin.manifest?.contributes?.settings?.length || plugin.manifest?.contributes?.views?.some((view) => view.location === 'settings'));
return <SettingRow key={plugin.id} leading={<PluginBrandIcon plugin={plugin} size={24} />} label={<span className="flex min-w-0 items-baseline gap-2">
<span className="truncate" title={name}>{name}</span>
{plugin.activeVersion && <span className="shrink-0 text-xs font-normal text-muted-foreground">{plugin.activeVersion}</span>}
</span>} description={<>
<span className="block break-words">{[plugin.manifest?.publisher, sourceOf(plugin), statusOf(plugin)].filter(Boolean).join(' · ')}</span>
{availableUpdate && <span className="block text-primary">{t('settings.plugins.landing.updateAvailable').replace('{version}', availableUpdate.availableVersion)}</span>}
{description && <span className="line-clamp-2 break-words">{description}</span>}
</>}>
<div className="flex items-center gap-3">
{declaredSettings && <Button className="focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2" variant="ghost" size="icon" disabled={manager.busy || !manager.statusKnown || !settingsAvailable.has(plugin.id)} aria-label={t('settings.plugins.landing.settingsFor').replace('{name}', name)} title={t(settingsAvailable.has(plugin.id) ? 'common.settings' : !plugin.enabled ? 'settings.plugins.landing.enableSettings' : 'settings.plugins.landing.settingsUnavailable')} onClick={(event) => onManage(plugin.id, event.currentTarget)}><Settings2 size={16} /></Button>}
<Toggle checked={plugin.enabled} disabled={manager.busy || !manager.available || !manager.statusKnown} ariaLabel={`${t(plugin.enabled ? 'settings.plugins.manager.disable' : 'settings.plugins.manager.enable')} ${name}`} onChange={(enabled) => void manager.setEnabled(plugin.id, enabled)} />
</div>
</SettingRow>;
})}
</SettingCard>}
</>}
</SettingsSection>
</>;
}
@@ -0,0 +1,88 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { EventEmitter } from 'node:events';
import { createRequire } from 'node:module';
import { JSDOM } from 'jsdom';
const require = createRequire(import.meta.url);
test('real Settings landing leaves views visible while idle; log and Ask dialogs suspend and restore them', async () => {
const dom = new JSDOM('<!doctype html><html><body><div id="root"></div><div id="native-view-mount"></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', 'Event', 'MouseEvent', 'KeyboardEvent', 'MutationObserver', 'DocumentFragment', 'CustomEvent']) 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 { PluginSettingsLanding } = await import('./PluginSettingsLanding');
const { PluginPermissionHost } = await import('../../plugins/PluginPermissionHost');
const { observePluginHostSecurityVisibility } = await import('../../../application/state/pluginHostSecurityVisibility');
const { PluginPermissionDecisionHost } = require('../../../electron/plugins/permissionDecisionHost.cjs');
type Prompt = NetcattyPluginPermissionPrompt | { requestId: string; closed: true };
const listeners = new Set<(event: Prompt) => void>();
const sender = Object.assign(new EventEmitter(), { isDestroyed: () => false, send: (_channel: string, event: Prompt) => { for (const listener of listeners) listener(event); } });
const owner = Object.assign(new EventEmitter(), { webContents: sender, isFocused: () => true, isVisible: () => true, isDestroyed: () => false });
const host = new PluginPermissionDecisionHost({ getWindows: () => [owner], isLocked: () => false });
let logStatus = { saveToDisk: false, verbose: false, droppedRecords: 0, writeErrors: 0, pendingBytes: 0, maxBytes: 3145728, retentionDays: 7 };
Object.assign(dom.window, { netcatty: {
async getPluginLogStatus() { return logStatus; },
async configurePluginLogs(value: { saveToDisk: boolean; verbose: boolean }) { logStatus = { ...logStatus, ...value }; return logStatus; },
onPluginPermission(listener: (event: Prompt) => void) { listeners.add(listener); return () => listeners.delete(listener); },
setPluginPermissionReady: async (ready: boolean) => host.setReady(sender, ready),
respondPluginPermission: async (response: unknown) => host.respond(sender, response),
} });
const manager = { plugins: [], available: true, statusKnown: true, busy: false, error: null, credentialStorageAvailable: true,
async refresh() {}, async setEnabled() {}, async importPackage() {}, async uninstall() {} };
let visible = true;
const transitions: boolean[] = [];
const stopVisibility = observePluginHostSecurityVisibility(document.getElementById('native-view-mount')!, (next) => { visible = next; transitions.push(next); });
const root = createRoot(document.getElementById('root')!);
const button = (label: string) => [...document.querySelectorAll<HTMLButtonElement>('button')].find((element) => element.textContent === label)!;
const flushVisibility = () => new Promise<void>((resolve) => dom.window.queueMicrotask(resolve));
try {
await act(async () => root.render(<I18nProvider locale="en"><PluginSettingsLanding manager={manager} settingsAvailable={new Set()} onManage={() => {}} /><PluginPermissionHost /></I18nProvider>));
await flushVisibility();
assert.equal(document.querySelector('[role="dialog"]'), null);
assert.ok(button('Configure'), 'real idle logging row is present');
assert.equal(visible, true, 'idle Settings landing must not hide the native child');
assert.deepEqual(transitions, []);
await act(async () => button('Configure').click());
await flushVisibility();
assert.ok(document.querySelector('[role="dialog"][data-host-security-surface]'));
assert.equal(visible, false, 'actual logging security dialog must hide the child');
await act(async () => document.querySelectorAll<HTMLButtonElement>('[role="switch"]')[0].click());
await act(async () => document.querySelectorAll<HTMLButtonElement>('[role="switch"]')[1].click());
await flushVisibility();
assert.equal(document.querySelectorAll('[role="dialog"]').length, 2);
assert.equal(visible, false, 'the verbose security confirmation must also keep the child hidden');
await act(async () => button('Cancel').click());
await flushVisibility();
assert.equal(visible, false, 'cancelling verbose returns to the protected logging dialog');
await act(async () => button('Close').click());
await flushVisibility();
assert.equal(document.querySelector('[role="dialog"]'), null);
assert.equal(visible, true, 'closing logs must restore the child while its idle row remains');
let pending!: Promise<{ decision: string }>;
await act(async () => {
pending = host.request({ requestId: 'settings-visibility-ask', pluginId: 'com.example.settings-visibility', pluginName: 'Settings visibility fixture', pluginVersion: '1.0.0',
permission: 'settings.read', resources: ['*'], reason: 'Read own synthetic setting' });
});
await flushVisibility();
assert.ok(document.querySelector('[role="dialog"][data-host-security-surface]'));
assert.equal(visible, false, 'Ask must still hide the child above the real landing');
await act(async () => { button('Deny').click(); await pending; });
await flushVisibility();
assert.equal((await pending).decision, 'deny');
assert.equal(visible, true, 'dismissing Ask must restore the same child');
assert.deepEqual(transitions, [false, true, false, true]);
} finally {
await act(async () => root.unmount()); stopVisibility(); host.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,60 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { JSDOM } from 'jsdom';
test('manual update action coalesces clicks, never runs on mount, and reports incomplete source checks honestly', async () => {
const dom = new JSDOM('<!doctype html><html><body><div id="root"></div></body></html>', { pretendToBeVisual: true, url: 'http://localhost' });
const previous = new Map<string, PropertyDescriptor | undefined>();
const install = (key: string, value: unknown) => {
previous.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
Object.defineProperty(globalThis, key, { configurable: true, writable: true, value });
};
for (const key of ['window', 'document', 'navigator', 'HTMLElement', 'Element', 'Node', 'Event', 'MouseEvent']) install(key, key === 'window' ? dom.window : dom.window[key as keyof typeof dom.window]);
install('IS_REACT_ACT_ENVIRONMENT', true);
const { default: React, act } = await import('react');
const { createRoot } = await import('react-dom/client');
const { I18nProvider } = await import('../../../application/i18n/I18nProvider');
const { PluginSettingsLanding } = await import('./PluginSettingsLanding');
const { usePluginUpdateCheck } = await import('../../../application/state/usePluginUpdateCheck');
const root = createRoot(document.getElementById('root')!);
const source: NetcattyPluginSource = { id: 'official', kind: 'official', name: 'Official source', origin: 'https://plugins.example.com', enabled: true };
const plugin = { id: 'com.example.alpha', enabled: false, activeVersion: '1.0.0', archiveSha256: 'a'.repeat(64), source,
manifest: { manifestVersion: 1, id: 'com.example.alpha', name: 'Alpha', version: '1.0.0', publisher: 'example', engines: { netcatty: '>=0.0.0', api: '>=0.1.0-internal <0.2.0' }, main: { browser: 'index.js' } },
runtime: { status: 'stopped', kind: null, lastError: null, quarantinedAt: null } } as NetcattyInstalledPlugin;
const plugins = [plugin];
const manager = { plugins, available: true, statusKnown: true, busy: false, error: null, credentialStorageAvailable: true, async refresh() {}, async setEnabled() {}, async importPackage() {}, async uninstall() {} };
let catalogReads = 0; let mutations = 0; let offline = false;
let release: () => void;
const gate = new Promise<void>((resolve) => { release = resolve; });
Object.assign(dom.window, { netcatty: {
async listPlugins() { return plugins; }, async listPluginSources() { return [source]; },
async listPluginCatalog(id: string) { assert.equal(id, source.id); catalogReads++; await gate; return { entries: [{ id: plugin.id, version: '1.1.0', artifact: { url: 'https://plugins.example.com/synthetic.ncpkg', sha256: 'b'.repeat(64) } }], source: offline ? 'cached' : 'registry', error: offline ? 'synthetic offline' : null }; },
async getPluginLogStatus() { return { saveToDisk: false, verbose: false, droppedRecords: 0, writeErrors: 0, pendingBytes: 0, maxBytes: 3145728, retentionDays: 7 }; },
async installCatalogPlugin() { mutations++; }, async setPluginEnabled() { mutations++; }, async checkForUpdates() { mutations++; },
} });
function Page() { const updates = usePluginUpdateCheck(plugins); return <PluginSettingsLanding manager={manager} updates={updates} settingsAvailable={new Set()} onManage={() => {}} />; }
const check = () => [...document.querySelectorAll<HTMLButtonElement>('button')].find((button) => button.textContent === 'Check for updates')!;
try {
await act(async () => root.render(<I18nProvider locale="en"><Page /></I18nProvider>));
assert.equal(catalogReads, 0, 'rendering settings does not schedule checks');
await act(async () => { const button = check(); button.click(); button.click(); });
assert.equal(catalogReads, 1);
assert.equal([...document.querySelectorAll<HTMLButtonElement>('button')].find((button) => button.textContent === 'Checking…')!.disabled, true);
await act(async () => release());
assert.match(document.body.textContent!, /Published updates: 1/);
assert.match(document.body.textContent!, /Version 1.1.0 available/);
assert.equal(mutations, 0);
await act(async () => window.dispatchEvent(new Event('netcatty:plugin-packages-changed')));
assert.doesNotMatch(document.body.textContent!, /Version 1.1.0 available/, 'source/package changes invalidate prior results');
offline = true;
await act(async () => check().click());
assert.match(document.body.textContent!, /Checked: 0/);
assert.match(document.body.textContent!, /Not checked: 1/);
assert.match(document.body.textContent!, /Unavailable sources: 1/);
assert.doesNotMatch(document.body.textContent!, /Version 1.1.0 available/);
assert.equal(mutations, 0);
} finally {
await act(async () => root.unmount()); dom.window.close();
for (const [key, descriptor] of previous) { if (descriptor) Object.defineProperty(globalThis, key, descriptor); else Reflect.deleteProperty(globalThis, key); }
}
});
@@ -0,0 +1,74 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { JSDOM } from 'jsdom';
test('landing settings entry targets the declared plugin and a manager tab roundtrip clears selection consistently', 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', 'DocumentFragment', '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);
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 { Tabs } = await import('../../ui/tabs');
const { default: SettingsPluginsTab } = await import('./SettingsPluginsTab');
const root = createRoot(document.getElementById('root')!);
const plugin = { id: 'com.example.alpha', enabled: true, activeVersion: '1.0.0', archiveSha256: 'a'.repeat(64),
source: { id: 'local', kind: 'local', name: 'Local import', origin: null },
manifest: { manifestVersion: 1, id: 'com.example.alpha', name: 'Alpha', version: '1.0.0', publisher: 'example', engines: { netcatty: '>=0.0.0', api: '>=0.1.0-internal <0.2.0' }, main: { browser: 'index.js' }, contributes: { settings: [{ id: 'com.example.alpha.setting', label: 'Alpha setting', control: 'text', scope: 'application' }] } },
runtime: { status: 'stopped', kind: null, lastError: null, quarantinedAt: null } };
Object.assign(dom.window, { netcatty: {
async getPluginRuntimeStatus() { return { available: true, experimental: true, credentialStorageAvailable: true }; },
async listPlugins() { return [plugin]; },
async setPluginEnabled() { throw new Error('Synthetic management failure'); },
async getPluginLogStatus() { return { saveToDisk: false, verbose: false, droppedRecords: 0, writeErrors: 0, pendingBytes: 0, maxBytes: 3145728, retentionDays: 7 }; },
async getPluginContributions() { return { locale: 'en', plugins: [{ id: plugin.id, version: '1.0.0', displayName: 'Alpha', description: 'Synthetic plugin', commands: [], keybindings: [], menus: [], views: [], settings: [{ id: 'com.example.alpha.setting', label: 'Alpha setting', control: 'text', scope: 'application', scopeId: 'application', value: 'synthetic', visible: true, configured: true }] }] }; },
async getPluginScopeCatalog() { return { workspace: [], host: [], session: [], device: [] }; },
async listPluginSources() { return []; },
async listPluginCatalog() { return { entries: [], source: 'builtin', error: null }; },
async getPluginPermissions() { return { grants: [], audit: [] }; },
} });
const settle = () => new Promise((resolve) => setTimeout(resolve, 25));
const selectTab = async (name: string) => {
const tab = [...document.querySelectorAll<HTMLElement>('[role=dialog] [role=tab]')].find((element) => element.textContent?.startsWith(name))!;
assert.ok(tab, name);
await act(async () => { tab.focus(); tab.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); await settle(); });
};
try {
await act(async () => { root.render(<I18nProvider locale="en"><Tabs defaultValue="plugins"><SettingsPluginsTab /></Tabs></I18nProvider>); await settle(); });
const settings = document.querySelector<HTMLButtonElement>('button[aria-label="Settings for Alpha"]')!;
assert.ok(settings);
assert.equal(settings.disabled, false);
await act(async () => { settings.focus(); settings.click(); await settle(); });
assert.ok(document.querySelector('[data-plugin-detail]'));
assert.ok(document.querySelector('input[aria-label="Alpha setting"]'));
await selectTab('Official');
await selectTab('Installed');
assert.equal(document.querySelector('[data-plugin-detail]'), null, 'returning to Installed shows the grid, not a stale deep-link detail');
assert.equal(document.querySelector('input[aria-label="Alpha setting"]'), null);
const card = document.querySelector<HTMLButtonElement>('[data-plugin-card="com.example.alpha"]')!;
await act(async () => { card.click(); await settle(); });
assert.ok(document.querySelector('input[aria-label="Alpha setting"]'), 'choosing the plugin again restores its real settings');
await act(async () => { document.activeElement!.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); await settle(); });
assert.equal(document.querySelector('[role=dialog]'), null);
assert.equal(document.activeElement, settings);
const toggle = document.querySelector<HTMLButtonElement>('button[aria-label="Disable Alpha"]')!;
await act(async () => { toggle.click(); await settle(); });
assert.match(document.querySelector('[role=alert]')!.textContent!, /Synthetic management failure/);
assert.equal(toggle.getAttribute('aria-checked'), 'true', 'failed mutation retains actual host state');
const refresh = document.querySelector<HTMLButtonElement>('button[aria-label="Refresh installed plugins"]')!;
await act(async () => { refresh.click(); await settle(); });
assert.equal(document.querySelector('[role=alert]'), null, 'explicit successful refresh clears an acknowledged manager error');
} finally {
await act(async () => root.unmount()); dom.window.close();
for (const [key, descriptor] of previous) { if (descriptor) Object.defineProperty(globalThis, key, descriptor); else Reflect.deleteProperty(globalThis, key); }
}
});
+57 -14
View File
@@ -1,11 +1,13 @@
import { PluginThirdPartyMarket } from './PluginThirdPartyMarket';
import { usePluginUpdateCheck } from '../../../application/state/usePluginUpdateCheck';
import { usePluginManager } from '../../../application/state/usePluginManager';
import { PluginManagementDialog } from './PluginManagementDialog';
import { PluginMarketplace } from './PluginMarketplace';
import { PluginPackageManager } from './PluginPackageManager';
import { FolderOpen } from 'lucide-react';
import { PluginStatus } from './PluginStatus';
import React, { useEffect, useMemo, useState } from 'react';
import { PluginSettingsLanding } from './PluginSettingsLanding';
import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { normalizePluginKeyboardEvent } from '../../../application/state/pluginKeybindings';
import { usePluginContributions } from '../../../application/state/usePluginContributions';
@@ -285,11 +287,15 @@ export function PluginSettingField({
);
}
function PluginManagementContent({ handoff }: { handoff: (action: () => void) => void }) {
function PluginManagementContent({ handoff, manager, initialSelectedId, active }: {
handoff: (action: () => void) => void;
manager: ReturnType<typeof usePluginManager>;
initialSelectedId: string | null;
active: boolean;
}) {
const { t } = useI18n();
const manager = usePluginManager();
const [catalogToolbar, setCatalogToolbar] = useState<HTMLDivElement | null>(null);
const [selectedInstalled, setSelectedInstalled] = useState<string | null>(null);
const [selectedInstalled, setSelectedInstalled] = useState<string | null>(initialSelectedId);
const [pluginView, setPluginView] = useState<'installed' | 'marketplace' | 'thirdparty'>('installed');
const availableFonts = useAvailableFonts();
const scopeCatalog = usePluginSettingScopeCatalog();
@@ -302,6 +308,31 @@ function PluginManagementContent({ handoff }: { handoff: (action: () => void) =>
scopeIds,
}), [scopeIds]);
const contributions = usePluginContributions(query);
const settingsSection = useRef<HTMLElement | null>(null);
const installed = manager.plugins.find((plugin) => plugin.id === selectedInstalled);
const ownerKey = JSON.stringify(installed && [installed.id, installed.activeVersion, installed.archiveSha256,
installed.source?.id, installed.source?.kind, installed.source?.origin]);
const gearEntry = useRef(initialSelectedId ? { pluginId: initialSelectedId, ownerKey } : null);
useLayoutEffect(() => {
const intent = gearEntry.current;
if (!intent) return;
if (!active || pluginView !== 'installed' || selectedInstalled !== intent.pluginId || ownerKey !== intent.ownerKey) {
gearEntry.current = null;
return;
}
const section = settingsSection.current;
if (contributions.loading || contributions.error || !contributions.available || !section) return;
// Let dialog autofocus settle first, including when contributions arrive late.
let frame = requestAnimationFrame(() => {
frame = requestAnimationFrame(() => {
if (gearEntry.current !== intent || !section.isConnected) return;
gearEntry.current = null;
section.scrollIntoView({ block: 'start' });
section.focus({ preventScroll: true });
});
});
return () => cancelAnimationFrame(frame);
}, [active, pluginView, selectedInstalled, ownerKey, contributions.loading, contributions.error, contributions.available, contributions.snapshot]);
const contextualScopes = useMemo(() => new Set(contributions.snapshot.plugins
.filter((plugin) => plugin.id === selectedInstalled)
.flatMap((plugin) => plugin.settings.map((setting) => setting.scope))
@@ -311,7 +342,7 @@ function PluginManagementContent({ handoff }: { handoff: (action: () => void) =>
return (
<div className="space-y-5">
<Tabs value={pluginView} onValueChange={(value) => { setSelectedInstalled(null); setPluginView(value as 'installed' | 'marketplace' | 'thirdparty'); }} className="space-y-5">
<Tabs value={pluginView} onValueChange={(value) => { gearEntry.current = null; setSelectedInstalled(null); setPluginView(value as 'installed' | 'marketplace' | 'thirdparty'); }} className="space-y-5">
<div className="flex flex-wrap items-center justify-between gap-3">
<TabsList className="h-auto flex-wrap justify-start bg-muted/50">
<TabsTrigger value="installed">{t('settings.plugins.manager.installed')} <span className="ml-2 text-muted-foreground">{manager.plugins.length}</span></TabsTrigger>
@@ -327,7 +358,7 @@ function PluginManagementContent({ handoff }: { handoff: (action: () => void) =>
<TabsContent forceMount hidden={pluginView !== 'marketplace'} value="marketplace" className="m-0 space-y-6"><PluginMarketplace installed={manager.plugins} toolbarContainer={pluginView === 'marketplace' ? catalogToolbar : undefined} noVault={manager.credentialStorageAvailable === false} /></TabsContent>
<TabsContent forceMount hidden={pluginView !== 'thirdparty'} value="thirdparty" className="m-0 space-y-6"><PluginThirdPartyMarket installed={manager.plugins} toolbarContainer={pluginView === 'thirdparty' ? catalogToolbar : undefined} noVault={manager.credentialStorageAvailable === false} /></TabsContent>
<TabsContent value="installed" className="m-0 space-y-6">
<PluginPackageManager manager={manager} onSelectionChange={setSelectedInstalled} />
<PluginPackageManager manager={manager} initialSelectedId={selectedInstalled} onSelectionChange={(id) => { gearEntry.current = null; setSelectedInstalled(id); }} />
{contextualScopes.size > 0 && (
<section className="grid gap-3 rounded-lg border border-border/70 bg-muted/10 p-4 sm:grid-cols-2" aria-label={t('settings.plugins.scopeTargets')}>
{[...contextualScopes].map((kind) => (
@@ -352,7 +383,7 @@ function PluginManagementContent({ handoff }: { handoff: (action: () => void) =>
const views = plugin.views.filter((view) => view.visible && view.location === 'settings');
if (!settings.length && !views.length) return null;
return (
<section key={plugin.id} className="space-y-3" aria-labelledby={`plugin-settings-${plugin.id}`}>
<section key={plugin.id} ref={settingsSection} tabIndex={-1} className="space-y-3 scroll-mt-4 focus:outline-none" aria-labelledby={`plugin-settings-${plugin.id}`}>
<div>
<h3 id={`plugin-settings-${plugin.id}`} className="text-base font-semibold">{plugin.displayName}</h3>
{plugin.description && <p className="text-xs text-muted-foreground">{plugin.description}</p>}
@@ -394,15 +425,27 @@ function PluginManagementContent({ handoff }: { handoff: (action: () => void) =>
export default function SettingsPluginsTab() {
const { t } = useI18n();
const manager = usePluginManager();
const updates = usePluginUpdateCheck(manager.plugins);
const [open, setOpen] = useState(false);
const [initialSelectedId, setInitialSelectedId] = useState<string | null>(null);
const opener = useRef<HTMLElement | null>(null);
const managerButton = useRef<HTMLButtonElement | null>(null);
const contributions = usePluginContributions({ context: { 'netcatty.surface': 'settings' } });
const settingsAvailable = useMemo(() => new Set(contributions.snapshot.plugins
.filter((plugin) => plugin.settings.some((setting) => setting.visible) || plugin.views.some((view) => view.location === 'settings' && view.visible))
.map((plugin) => plugin.id)), [contributions.snapshot]);
return (
<SettingsTabContent value="plugins">
<SettingsAnchor anchorId="plugins-root" className="space-y-5">
<div>
<h2 className="text-xl font-semibold">{t('settings.plugins.title')}</h2>
<p className="mt-1 text-sm text-muted-foreground">{t('settings.plugins.description')}</p>
</div>
<PluginManagementDialog>{(handoff) => <PluginManagementContent handoff={handoff} />}</PluginManagementDialog>
<SettingsAnchor anchorId="plugins-root" className="space-y-6">
<PluginSettingsLanding manager={manager} updates={updates} managerButtonRef={managerButton} settingsAvailable={settingsAvailable} settingsError={contributions.error?.message} onManage={(pluginId, element) => {
opener.current = element;
setInitialSelectedId(pluginId);
setOpen(true);
}} />
<PluginManagementDialog open={open} onOpenChange={setOpen} trigger={null} restoreFocusTo={opener} fallbackFocusTo={managerButton}>
{(handoff) => <PluginManagementContent handoff={handoff} manager={manager} initialSelectedId={initialSelectedId} active={open} />}
</PluginManagementDialog>
</SettingsAnchor>
</SettingsTabContent>
);
+3 -1
View File
@@ -17,6 +17,7 @@ interface ConfirmDialogProps {
confirmLabel: string;
busy?: boolean;
destructive?: boolean;
hostSecuritySurface?: boolean;
onOpenChange: (open: boolean) => void;
onConfirm: () => void;
}
@@ -28,6 +29,7 @@ export const ConfirmDialog = memo(function ConfirmDialog({
confirmLabel,
busy = false,
destructive = false,
hostSecuritySurface = false,
onOpenChange,
onConfirm,
}: ConfirmDialogProps) {
@@ -35,7 +37,7 @@ export const ConfirmDialog = memo(function ConfirmDialog({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-[calc(100vw-2rem)] overflow-hidden sm:max-w-[380px]">
<DialogContent data-host-security-surface={hostSecuritySurface ? "" : undefined} className="max-w-[calc(100vw-2rem)] overflow-hidden sm:max-w-[380px]">
<DialogHeader className="min-w-0 pr-6">
<DialogTitle className="truncate">{title}</DialogTitle>
</DialogHeader>
@@ -0,0 +1,189 @@
# 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.
Only the dedicated acceptance branch is published by this integration task;
the reported feature checkpoints are preserved through ordinary merges.
The previous candidate `174c525cd45bdc1152e9feb21839a288706d3bd1` passed
[test CI](https://github.com/binaricat/Netcatty/actions/runs/40381685282),
[four-platform packages](https://github.com/binaricat/Netcatty/actions/runs/40381685946),
and [ET CI](https://github.com/binaricat/Netcatty/actions/runs/40381684810).
No actionable CI regression was found. Its full test suite records 15,375 tests:
15,325 passed, 50 skipped, zero failed or cancelled.
The next candidate includes the published settings focus, AI budget/session/
selected-Note recovery, and terminal Solo/Focus ownership corrections. The parent
reports both correction reviews scoped clean at their exact source heads. Those
reviews do not establish aggregate package verification or native acceptance.
No uncommitted owner work is imported, and native activation is not performed
by this coding task.
## Included source checkpoints
| Slice | Frozen checkpoint | Integration evidence |
| --- | --- | --- |
| Main | `f0897c3c1711f60350f8d613696d387bcac464ed` | Includes #3991, #3983, #3992, and #3995 |
| Byte streams, source identity, connected AI, examples | #3993 `37df67256e7eedc1050f186bcf401dc376cb5e71` | Ordinary merge; both transport P2 fixes retained |
| Host/Vault actions | #3990 `730fa643ba0589b8919368657dabc83428f577be` | Ordinary merge; metadata/action authority stays separate |
| Settings and logging UI | #3994 `823db94cf34503417e3b7c19c6daab379f60530f` | Ordinary merge; retains dc6 alias repair and adds the natively verified gear-to-settings focus path |
| AI corrections | #3986 `ba09461676f52b6b1853385ba61845b48cf5f5ac` | Ordinary merges preserve budget/model recovery and final `9e7c9df58` explicit Note re-selection; same reviewer reports the bounded correction clean |
| Selected-terminal continuation | #3996 `8e7c28fe1bdbcdd76816d33ec926ba8e60eefe64` | Ordinary merge; independent correction review reports Solo/Focus ownership resolved; native acceptance pending |
#3995 was squash-merged into main as `f0897c3c`. Its PR tip was rechecked at
`921f51756eca564cef3d30152807a4649c2a0d3b`; both contain the same TERM-SSHD test
blob `cdfc067e6ca3b4f6b7b3d6e78834072ed84837b4`. The candidate keeps main's
deterministic first/alias OPEN-order cases instead of the older plugin-branch
test-only repair. No production transfer cancellation change is introduced.
## Resolve the shared integration files
- Root package and lockfile retain both the plugin workspace versions and
main's pinned `@noble/ciphers` dependency.
- The assistant retains its six-file staging build. The Host/Vault branch's
older assistant preparation does not replace it.
- `hostService.cjs` retains byte-stream, AI/context, Host/Vault, and logging
construction and registration. Shutdown cleans up the existing services and
closes logs in `finally`. Constructor-failure cleanup retains each service.
- The AI host remains mounted in `application/app/AppView.tsx`. The Host action
host remains inside the Vault/session providers in `App.tsx`. Trusted preload
methods, source-bound authority, and canonical schema definitions are retained.
- Canonical contract generation produces no drift after merging the shared
schema. This worktree's `@netcatty/plugin-contract` and `@netcatty/plugin-sdk`
resolve to its own `packages/` directories, not an older worktree.
- The terminal merge unions canonical terminal and Vault prompt fields, trusted
preload methods, feature registration, and lifecycle cleanup. Terminal target
capture reuses the existing admission method and authorization guard. The
generic interaction UI continues to ignore Vault-only requests; Vault retains
its own handler. Inert terminal draft review returns a boolean receipt, never
edited text or terminal input.
- The installed Host test fixture now supports multiple lock listeners, matching
the production app-lock bridge's `Set` subscription contract. The old fixture
stored one callback and timed out after both Host and AI services subscribed.
Production lock behavior is unchanged.
## Local evidence
Checks use Node 24.19.0 and a private task temp root through the existing temp
bridge. HOME, endpoint policy, and sandbox settings are unchanged.
| Check | Local result |
| --- | --- |
| Registered plugin runtime | 676 tests: 674 passed, one missing-Electron failure, one native skip |
| Final AI broker/context and real-handle sample panel | 27 passed, including explicit Note re-selection |
| Contract/SDK/CLI | 91 passed, zero failures |
| Host installed entrypoint/action isolation after terminal merge | 40 passed; earlier baseline Host/action/lock group passed 82 after correcting the subscription fixture |
| Terminal selection, pane ownership/lifecycle, draft UI, palette failure | 19 passed |
| Plugin settings/market/logging UI after gear correction | 36 passed, zero failures |
| Registered database/terminal examples | Five passed, one opt-in Docker skip |
| Vite lazy-settings HTTP/native-ESM probe | Passed after settings focus correction; dc6 alias repair retained |
| Main TERM-SSHD file | All 32 cases passed, including both controlled arrival orders |
| Contract/capability generation and drift, Codex schema | Passed |
| Full lint | Zero errors, seven existing warnings |
| Production build | Passed on the assembled source tree |
| Five sample packages | Validated; two builds of each match byte for byte |
The initial connected group is retained as a failed run: 39 passed, nine failed,
and two cancelled. The mounted AI chooser test failed because this local
`--ignore-scripts` dependency installation lacks the Electron binary. The Host
lock case timed out because of the single-listener test fixture; later subtests
were cancelled. The corrected Host group passes as recorded above.
The combined Vite/main regression run records 33 passed and seven failed. Its
Vite probe and all TERM-SSHD cases passed. The SSH algorithm import and six
ChaCha/Electron cases could not load the same missing Electron binary. The
normal pinned Electron installer was attempted with a writable task-local cache
and failed with `TypeError: fetch failed`. No alternate download route, native
rebuild bypass, Electron shim, or sandbox change was used. These local failures
remain separate from the exact-head CI results recorded in the Draft PR.
The final registered runtime failure is the installed terminal reference's
worker-reconnect case: the real worker imports the unavailable Electron binary.
The same case failed for this prerequisite before the final AI merge. The other
674 registered cases pass. The final focused AI suite passes all 27 cases; the
earlier connected adapter run retains two passes and one missing-Electron
failure. These failures are not relabeled as passing or suppressed.
## Reproducible packages and exact permissions
All package versions below are `0.1.0`. These hashes are for this candidate's
SDK and source tree. Older #3993 database archives have different hashes and
must not be substituted. The assistant and Host samples contain their READMEs;
the database packages contain only manifest, browser bundle, and three panel
assets. These handoff archives were not installed or activated in the native
GUI. The automated installed-entrypoint tests use temporary synthetic fixtures;
the Rfam candidate was not installed or granted permissions in any runtime.
| Package ID | Files | SHA-256 |
| --- | --- | --- |
| `com.netcatty.assistant` | 6 | `c7d784d5c3bd2feb463afd58eae3a9594615395b168174cb8a3d0677584ae395` |
| `com.netcatty.database-minimal` | 5 | `f01c6a24382897d4b0bf4130941274abacb9a9a963f9b56ecaceb2c020f36874` |
| `com.netcatty.database-rfam-readonly` | 5 | `aaca586669db454e91d61487e576ed524d1b7190eed182bc2cd0035e59caa0d3` |
| `com.netcatty.host-actions` | 3 | `cf04f82dfe25ab5cda6f5f3dad0a8fb06c37bedf9924f556bee2b8402e542ef2` |
| `com.netcatty.terminal-selection-draft` | 2 | `4c9c2388b7f891156192a0b5ab7b23291beba3555e7ec8cf24fbe2dfe1242d5e` |
The assistant matches the final owner's reported archive hash. Both database
archives and the terminal sample changed after rebuilding against the combined
SDK. The terminal source and CLI are unchanged from `8e7c28fe`; the integrated
SDK has additional exports. The measured terminal content hash is
`99797d8679f34b96580076f922cbec067e511adc0f4ccf396ed129206acb6c4c`.
These archives replace earlier acceptance artifacts. Repeat-build equality and
CLI validation do not grant native permissions or establish independent
aggregate/package security verification. Host activation remains blocked by
the separate automatic-review denial.
| Package | Required feature | Required permissions | Optional permissions/resources |
| --- | --- | --- | --- |
| Assistant | `ai.model-invocation`, `context.selected-read` | `commands`, `views` | `ai.invoke`, `context.selected.list`, `context.selected.read` |
| Owned database | `network.byte-streams` | `commands`, `views` | `network.stream`: exactly `tcp://10.77.0.20:3306` |
| Public Rfam | `network.byte-streams` | `commands`, `views` | `network.stream`: exactly `tcp://mysql-rfam-public.ebi.ac.uk:4497` |
| Host actions | `vault.host-actions`, `commands.interactive` | `commands`, `menus` | `vault.metadata`, `vault.write`, `ui.interact` |
| Terminal draft | `terminal.selection-sharing`, `terminal.input-drafts`, `commands.interactive` | `commands`, `menus` | `terminal.selection.read`, `terminal.input.propose`, each for one explicit operation |
The owned database address is packaging-only. A different approved fixture
endpoint requires a new package and hash. No route from the GUI to the coding
container's private MariaDB is assumed. The GUI's missing Docker runtime remains
a fixture prerequisite, not a reason to change loopback/own-interface checks.
Reproduction commands after building the plugin packages:
```sh
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
```
## Native, review, and deployment gates
| Gate | Status and next evidence |
| --- | --- |
| Exact-head test/build CI | Recorded in the dedicated Draft PR; earlier feature-head CI is historical evidence |
| Native settings UI | Parent reports dc6 layout/controls and 823 gear-to-settings focus passed; those source-head results do not certify every flow on the integrated head |
| Native AI sample | Final source correction review is scoped clean at `ba094616`; integrated native acceptance remains pending and activation is not performed here. Expiry requires fresh host selection, explicit dropdown choice, content grant and text review; no silent re-share or inference retry |
| Native owned database | Pending a supported disposable fixture path; owned live regression remains opt-in and was not run here |
| Public Rfam | Prepared only; no service DNS lookup, connection, query, installation, or permission activation. Parent coordinates exact hash/endpoint authorization. Only protocol 4.1/native-password is supported; auth/TLS compatibility is unverified and no fallback is added |
| Native Host/Vault | Parent reports installation/details passed. Activation consent was denied by automatic review. Functional Host flows remain unrun pending specific user approval. Do not retry that activation or change permissions to bypass it |
| Selected-terminal continuation | Correction review scoped clean at `8e7c28fe`; integrated checks recorded above; native acceptance pending. Input review remains inert and returns only a boolean receipt |
| AI independent security review | The same worker reconsidered the earlier restriction once with newer maintainer evidence and completed review without recurrence. Parent reports the final bounded correction review clean at `ba094616`, including generation guards and old-handle invalidity. This task does not repeat that review or claim independent aggregate/package verification |
| Main merge/release/deployment | Not authorized by this task; candidate remains Draft |
## Native handoff
Use the exact head from the Draft PR and the current five-package manifest/hash
record. Source-head review and native settings evidence remain separately
attributed. Do not substitute earlier assistant or database archives. No native
Host activation retry, automatic re-sharing, or inference retry is part of this
integration work.
Native evidence records the exact candidate commit, package hashes, source and
permission prompts, denial/retry/cancel/revoke behavior, screenshots, and video.
The coding executor's Node/DOM harnesses and Vite probe are not native sandbox
certification. Database protocol and fixed query code remain in the examples.
@@ -0,0 +1,153 @@
[
{
"name": "assistant",
"id": "com.netcatty.assistant",
"version": "0.1.0",
"fileCount": 6,
"bytes": 14638,
"sha256": "c7d784d5c3bd2feb463afd58eae3a9594615395b168174cb8a3d0677584ae395",
"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,
"preparedOnly": true,
"contentSha256": "9a4efe442cd1c35ec8f61e3e2e4ccbb4fa97addf168b0b8b6ac85bac79c39ca9"
},
{
"name": "database-owned",
"id": "com.netcatty.database-minimal",
"version": "0.1.0",
"fileCount": 5,
"bytes": 11660,
"sha256": "f01c6a24382897d4b0bf4130941274abacb9a9a963f9b56ecaceb2c020f36874",
"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,
"preparedOnly": true,
"contentSha256": "4d628c41c7a231e31cd56dacb822acaaa37f61e1e98b285b0086a4f783be199d"
},
{
"name": "database-rfam-readonly",
"id": "com.netcatty.database-rfam-readonly",
"version": "0.1.0",
"fileCount": 5,
"bytes": 11003,
"sha256": "aaca586669db454e91d61487e576ed524d1b7190eed182bc2cd0035e59caa0d3",
"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,
"preparedOnly": true,
"contentSha256": "f542ca7b8e821d684cf7f71f0930458bd3ad97bc94bf654c88b0f353111e9162"
},
{
"name": "host-actions",
"id": "com.netcatty.host-actions",
"version": "0.1.0",
"fileCount": 3,
"bytes": 4633,
"sha256": "cf04f82dfe25ab5cda6f5f3dad0a8fb06c37bedf9924f556bee2b8402e542ef2",
"features": {
"required": [
"vault.host-actions",
"commands.interactive"
]
},
"permissions": {
"required": [
"commands",
"menus"
],
"optional": [
"vault.metadata",
"vault.write",
"ui.interact"
]
},
"package": "host-actions.ncpkg",
"repeatIdentical": true,
"preparedOnly": true,
"contentSha256": "d6f8bf3ae9dc417ea838f673966f2e1075fc8f9ef1d939ca85b476b1bb007e6c"
},
{
"name": "terminal-selection-draft",
"id": "com.netcatty.terminal-selection-draft",
"version": "0.1.0",
"fileCount": 2,
"bytes": 2954,
"sha256": "4c9c2388b7f891156192a0b5ab7b23291beba3555e7ec8cf24fbe2dfe1242d5e",
"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,
"preparedOnly": true,
"contentSha256": "99797d8679f34b96580076f922cbec067e511adc0f4ccf396ed129206acb6c4c"
}
]
+75 -5
View File
@@ -14,6 +14,10 @@ transport task owns the public schema, SDK, and runtime peer. Its
`c4433d4961ecf4a81bea92b0a14f47b4ec12f679` head contains the public AI/context
routes and is merged into this branch. The parent assigned this task the additive
trusted-host IPC schema, preload, AppView mount, and AI-only hostService wiring.
The base later advanced to `f1410f8bb`; its source/stream changes are incorporated
by ordinary merge. Three overlaps retain this branch's permission explanations,
bounded-stream assertions, and six-file assistant staging. The source database
and sidecar changes come from their owning task, without local modifications.
The model API uses a host-selected configured model. Plugins receive a chat
handle, a display label, an expiry, turn handles, text chunks, and usage totals.
@@ -55,8 +59,12 @@ This branch does not claim that workflow is complete.
database and sync implementation are unchanged. Source origin does not imply
that a package is officially maintained.
`ModelInvocationBroker` requires host-provided `resolveDataOwner` and
`selectModel` adapters. It keeps chats and turns in memory. It stores no chat
`ModelInvocationBroker` requires host-provided `resolveDataOwner`,
`resolveBudgetOwner`, and `selectModel` adapters. The service derives the stable
budget owner from the authenticated source key and opaque private owner. It never
accepts a plugin-supplied budget key or falls back to a bare plugin ID. Strict
runtime binding objects still govern handle access; their replacement does not
reset request or token budgets. It keeps chats and turns in memory. It stores no chat
history and makes no database changes. Data owners are opaque host identities;
no private storage is keyed by `pluginId`.
@@ -191,6 +199,11 @@ Failed aggregate runs remain evidence, not passing acceptance records:
| `netcatty-ai-connected-final-runtime.log` | Connected code: 610 tests, 607 passed, 1 failed, 0 cancelled, 2 skipped |
| `netcatty-ai-fixed-runtime.log` | After `0ba3ea606`: 610 tests, 608 passed, 0 failed, 0 cancelled, 2 skipped |
| `netcatty-ai-fixed-contract-suite.log` | After `0ba3ea606`: all 88 contract/SDK/CLI tests passed |
| `netcatty-ai-fixed-final-tests.log` | Before the later review corrections, `173ef5abb`: 15,248 tests, 15,205 passed, 0 failed, 0 cancelled, 43 skipped |
| `netcatty-ai-review-final-runtime.log` | After both review corrections: 614 tests, 612 passed, 0 failed, 0 cancelled, 2 skipped |
| `netcatty-ai-review-final-tests.log` | Review-corrected head `d81896b0d`: 15,252 tests, 15,209 passed, 0 failed, 0 cancelled, 43 skipped, before the later base merge |
| `netcatty-ai-review-merged-tests.log` | Integrated head `07eb68c5e`: 15,255 tests, 15,212 passed, 0 failed, 0 cancelled, 43 skipped, before the selected-Note expiry correction |
| `netcatty-ai-note-expiry-runtime.log` | Selected-Note expiry correction: 625 tests, 624 passed, 0 failed, 0 cancelled, 1 skipped |
The ten Cursor failures in the second full run came from its missing SQLite
native dependency. Rebuilding the existing dependency with the standard
@@ -229,9 +242,66 @@ or terminal permission.
Independent review confirmed the malformed-reference revocation defect on
`afe831bfc`. The two regression tests failed before the repair and pass afterward.
The repair is retained in #3985 head `4882e0501`. Further independent review was
blocked by the review tool/model restriction. That review is incomplete and has
not been retried through another route. CI is not a substitute for that review.
The repair is retained in #3985 head `4882e0501`. Further review was initially
blocked. After receiving newer exact maintainer security-task evidence, the same
worker completed its bounded read-only review of `173ef5abb` without a repeated
restriction or alternate route/model. It confirmed the earlier repair and found
two additional P2 defects: runtime-bound budget keys and the sample's expired
chat recovery.
Commit `ee5a4a406` separates stable source budgets from strict runtime handle
identity. Its fake-clock regression uses the actual source-binding class,
replaces runtime binding objects, tests request and reserved-token budgets,
isolates another source with the same public plugin ID, and checks the minute
boundary. Both budget subtests fail on the reviewed source and pass after repair.
Missing or invalid host budget identities fail closed.
Commit `e1cb95ee8` reopens an expired sample chat before an explicit Send and adds
**Refresh model** for changed-model or failed sessions. Refresh preserves the
draft and does not send inference. A failed send/read is never automatically
retried. Stop or page teardown invalidates pending opens and closes late sessions.
Four controlled-time DOM regressions fail before the repair and pass afterward,
including an uncertain inference outcome and Stop during reopening. They use no
network or paid provider. Failed-before logs are
`/tmp/netcatty-ai-budget-review-before.log` and
`/tmp/netcatty-ai-panel-review-before.log`; passing logs use the corresponding
`-after.log` names. Correction review independently confirmed the stable budget
fix, but found the remaining selected-Note expiry path at `07eb68c5e`.
The corrected broker suite passes all 17 tests, the sample's controlled-time
panel suite passes all 4, and the connected provider/UI suite passes all 4. Full
lint still reports zero errors and seven existing warnings; codegen drift and
the production build pass. Two independent package builds reproduce SHA-256
`c0f733697b8f43a8591f39c02aeea5d8b7b4bd544b642ed87e758e62d98696a5`
for that earlier sample checkpoint.
Commit `9e7c9df58` adds explicit **Choose reference** recovery. The panel retains
summary expiry locally, blocks an expired selection before Send, and never sends
that local metadata as an authorization field. Refresh model never re-shares a
Note. Re-selection opens the existing host chooser and resets the dropdown to
No reference; the user selects the fresh handle and clicks Send explicitly.
The service's identical-selection cache now reuses only complete live summaries.
After an explicit host re-selection, expired/revoked handles remain invalid and
fresh eligible handles are issued. No authority lifetime or revocation rule is
changed. Summary selection does not read text. Every new content handle still
requires the existing per-context grant and exact-text review.
The new fake-clock panel regression uses the actual host service and selected
context authority with a real synthetic Note handle. It covers expiry, model
refresh without re-sharing, explicit re-selection, denial of the fresh content
grant, cancellation of text review, and one successful explicit send with the
draft preserved. It repeats the expiry/re-selection with both chat and Note
lifetimes expired, then succeeds without a manual model refresh. A separate
service regression reproduces the formerly empty
same-Note cache after expiry. Both fail on `07eb68c5e`; failed-before evidence is
`/tmp/netcatty-ai-note-expiry-panel-before.log` and
`/tmp/netcatty-ai-note-expiry-service-before.log`. The combined panel/service
suite now passes all 10 tests; the connected installed UI/provider suite passes
all 4. The replacement six-file artifact has SHA-256
`c7d784d5c3bd2feb463afd58eae3a9594615395b168174cb8a3d0677584ae395`.
Independent verification of the latest correction and native acceptance remain
pending. CI is not a substitute for either.
Native Electron launch on this executor aborts because `chrome-sandbox` is not
root-owned mode 4755. No sandbox bypass or native retry was attempted. Native
+108
View File
@@ -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": []
}
]
+45 -5
View File
@@ -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,7 +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
`8ef162d13d0a16878fb817462759c9221352d0565dcefebeb102ea3575a918e8`.
`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
@@ -105,6 +112,32 @@ Synthetic retry plan: add a retry button and show a connection status label.
11. Keep the fixture request counter and aborted-connection counter in the
evidence. Do not include prompt bodies, headers, credentials, or Note content
other than the synthetic text above in logs.
12. Type a synthetic draft and click **Refresh model** while idle. The model
session must reopen, the draft must remain, and the fixture request count
must not increase. Change the selected configured model in host Settings,
refresh again, and check the displayed model before explicitly sending.
Refresh and failed requests must never automatically resend inference.
13. Keep a selected synthetic Note until its five-minute handle expires. Send
must explain that the reference expired without reading text or calling the
fixture. Refresh model must not share the Note again. Click **Choose
reference**, confirm the Note in the host chooser, select its fresh reference
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
300,000 ms using a real selected Note handle, without network calls. It checks
fresh host selection, old-handle rejection, independent content permission and
exact-text review, and a successful explicit send. The broker budget
regression controls its clock across runtime replacement and the minute boundary.
Do not change the host clock or production limits to reproduce those cases in
native QA.
The automated suites cover source substitution, runtime identity, two-handle
revocation after malformed input, late replies, byte/token/time bounds, and
@@ -114,7 +147,14 @@ installed runtime, configured provider transport, and UI lifecycle.
## Record remaining limits
Independent review found the malformed-context revocation bug. The regression
reproduced and the repair passed focused tests. Further independent review was
blocked by the review tool/model restriction. That review remains incomplete and
has not been retried through another route. CI and native evidence do not replace
that missing review.
reproduced and the repair passed focused tests. Further review was initially
blocked; after newer exact maintainer security-task evidence, the same worker
completed its bounded read-only review at `173ef5abb`, with no repeated restriction
or alternate route/model. Two additional P2 findings were reproduced and repaired
in `ee5a4a406` (stable source budgets) and `e1cb95ee8` (sample session recovery).
Correction review independently confirmed the stable source-budget fix, then
identified the remaining selected-Note expiry path at `07eb68c5e`. Commit
`9e7c9df58` repairs that path with explicit reference re-selection and fresh
handles after host confirmation. The real-Note panel and service regressions
failed before repair and pass afterward. Independent verification of this latest
correction remains pending. CI and native evidence do not replace it.
@@ -69,9 +69,12 @@ plugin disable, revocation through the service, and shutdown.
The connected host renderer and shared permission descriptions from #3986 are
preserved. Its owner documents the adapter and existing configured model path in
[the AI checkpoint](ai-context-wip.md), with a
[synthetic native assistant recipe](assistant-native-qa.md). No further
independent review of AI internals was performed. The prior review denial remains
unresolved and has not been retried through another route.
[synthetic native assistant recipe](assistant-native-qa.md). The original AI
worker later reconsidered the earlier review restriction once with newer
maintainer evidence and completed the review. Published fixes and remaining
verification/native holds are tracked in the current
[acceptance matrix](acceptance-candidate.md). This integration task does not
repeat that review.
## Database example
+11 -2
View File
@@ -1,5 +1,10 @@
# Test integrated byte streams on the native desktop
This record describes #3993 and its earlier package hashes. For the unified
main, transport/AI, Host/Vault, and settings/logging candidate, use
[the dedicated acceptance matrix](acceptance-candidate.md). Rebuild and verify
that candidate's package hashes before native testing.
Use `integration/plugin-byte-stream-handoff` from its integration PR. Record the
exact commit with `git rev-parse HEAD`. The branch preserves transport #3988
(`c4433d4961ecf4a81bea92b0a14f47b4ec12f679`), AI integration #3986
@@ -102,8 +107,12 @@ and record that package's new hash. Preparation does not dial the endpoint.
Build the assistant and follow its existing
[native QA recipe](assistant-native-qa.md). Use its deterministic local provider,
synthetic credential marker, and synthetic Note. No paid model call is required.
The AI internals were integrated without another independent review. The earlier
review denial remains in effect and is not replaced by these tests.
The original AI worker subsequently completed the bounded review after a single
reconsideration using newer maintainer evidence. The parent reports its final
bounded correction review clean at `ba094616`: source budgets and explicit
selected-Note re-selection are repaired. The [acceptance matrix](acceptance-candidate.md)
records aggregate checks and remaining native gates. These transport tests do
not substitute for native consent or acceptance.
## Record the installed browser path
+7
View File
@@ -450,6 +450,13 @@ new child to an opened parent directory without a path race.
`readDirectory()` likewise keeps its stable SDK/RPC method but fails closed
unless the main process supplies a native adapter whose inode checks and entry
enumeration are bound to the same directory handle.
The host advertises `filesystem.directory-enumeration` only when it supplies that
adapter. Declare the feature as required, or optional and check
`context.enabledFeatures.has(PLUGIN_FILESYSTEM_DIRECTORY_ENUMERATION_FEATURE)`.
The default host supplies no adapter and does not enable this feature. Both the
SDK and direct RPC return `unsupported` before a permission request or path probe.
An enabled feature still requires the declared `filesystem.read` directory
resource and the normal permission, root, symlink and same-handle checks.
Permission names already use the phase-3 enforcement boundaries: clipboard
read/write, terminal metadata/output/input and input/output interception, Vault
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,125 @@
# Host and Vault actions
Feature: `vault.host-actions`. The SDK registers three methods under
`context.vault`. Netcatty owns the picker, field-diff review, and connection
confirmation. Plugin business logic stays in the offline browser sandbox.
Base: source integration #3981 at
`4213d8e6726c44e7a479418ece3adf16ea494782`.
The published stream/AI branch point `c4433d496` is preserved through an ordinary
merge. Its owner was still completing integration checks when it was published.
## Reusable boundaries
| Concern | Existing contract | Reuse |
| --- | --- | --- |
| Host picker | `components/SelectHostPanelContent.tsx` | Controlled `selectedHostIds` and `onSelectionChange`; group toggles resolve to explicit host IDs. |
| Typed drafts | `ImporterHostDraft` in `packages/plugin-contract/schema/plugin-contract.schema.json` | Create allowlist: label, hostname, username, port, group, tags. Edit allowlist: label, group, tags. |
| Validation and preview | `normalizePluginImporterRecords`, `buildVaultHostFromDraft` | Validate the raw address, normalize the importer draft, validate the normalized result, and show the normalized field diff. |
| Vault writes | `useVaultState.commitVaultImportTransaction` | Existing shared Vault lock, expected host snapshot, encryption, stale-write checks, and journaled persistence. Optional authorization guard runs after encryption. |
| Connection | `AppSideEffects.handleConnectToHost` | Existing saved-settings, group defaults, authentication, logging, and session creation flow. |
| Script disclosure | `applyGroupDefaults`, `hasHostConnectAutomation`, `snippetAppliesToOutputTrigger` | Boolean disclosure covers inherited startup commands, global/group/host connect scripts, unresolved bindings, host output triggers, and applicable global output scripts. Script contents remain host-owned. |
| Lifecycle | `PluginHostInteractionService`, `PluginPermissionEngine`, runtime route `assertActive` | Existing window ownership, focus, lock, signed replies, cancellation, and revocation patterns. |
| Source identity | `PluginDatabase` | `getActivePlugin`, `getRetainedPluginSource`, `privateOwner`, and `publicPluginId`; no caller-supplied owner or bare-ID fallback. |
## Public methods
| Method | Parameters | Result |
| --- | --- | --- |
| `vault.selectHosts` | `{ title: string }` | `{ cancelled: true }`, or `{ cancelled: false, hosts, groups }`. |
| `vault.requestHostDraft` | Create: `{ draft: { label?, hostname, username?, port?, group?, tags? } }`. Edit: `{ target, expectedRevision, draft: { label?, group?, tags? } }`. | Cancelled, or `{ cancelled: false, status: "saved" }`. |
| `vault.requestConnect` | `{ target, expectedRevision }` | Cancelled, or `{ cancelled: false, status: "requested" }`; no session identifier or content. |
Selected metadata contains label, hostname, username, optional port, protocol,
group path, and tags. Each host result contains `{ target, metadata }`. Host IDs
remain behind the typed target reference; its revision is the expected revision.
Group metadata describes the explicitly selected current member handles. The
grant never expands when another host joins a group.
Selection grants metadata access only. Each draft save and each connection
requires its own host-owned confirmation. A connection confirmation displays the
saved target and whether saved scripts can run. It does not grant terminal input,
terminal output, or session access.
All requests are bounded and cancel on runtime stop, source replacement, revoke,
Vault lock, owner window replacement, expiry, or user cancellation. The host
revalidates authority and expected revision after asynchronous work and before
the Vault write or connection dispatch.
Bounds reuse `HostInteractionLimits`: one pending request per runtime,
eight total, ten per minute, a 60-second confirmation deadline, and a 64-KiB
request ceiling. Selection adds a maximum of 50 current hosts. Titles use the
existing 128-character single-line limit. Draft field lengths use
`ImporterHostDraft`. Unknown properties are rejected by the shared schema.
Creates produce saved SSH drafts. An empty group clears the saved group. Edits
preserve connection endpoints and credentials. Changing a group can change
inherited authentication, proxies, and scripts; the draft review states this.
Drafts involving managed SSH-config hosts or SSH destinations inside managed
groups are explicitly unavailable in this small API. The review disables saving
and directs the user to the Vault host editor. The execution adapter enforces the
same restriction before claiming action authority, using the existing
`applyVaultHostUpdate` ownership rules. It also checks the live and persisted
managed-source catalog for changes before writing. This API does not initiate
managed-file sync or expose plugin file access.
The API excludes Vault and renderer stores, the internal MCP catalog, plaintext
credentials, private keys, TOTP configuration, startup scripts, and arbitrary
execution. Host selection and action UI live in Netcatty. Plugin business logic
stays in the sandboxed plugin.
## Typed target and action authority
`VaultHostRef` is a signed `host.selection` reference with `scope: "metadata"`,
an opaque handle, a revision, and an expiry. It identifies an exact chosen host.
It does not authorize editing, connecting, terminal input, or terminal output.
References bind source, private owner, plugin version, runtime, window owner and
generation, exact host ID, revision, and expiry. They live for five minutes at
most, with at most 500 retained references. Another selection replaces that
runtime's previous references. Lock, revoke, window replacement, and runtime stop
invalidate the affected references. Removal or replacement of a host makes its
renderer revision stale, even if its visible fields are identical.
`PluginAISourceBinding` supplies the existing host-only source identity primitive.
It revalidates after asynchronous authentication. AI selected-context handles are
text-disclosure handles and cannot target these actions.
The permission engine gates selection with `vault.metadata`, drafts with
`vault.write`, and connection requests with `ui.interact`. Every save or connection
also requires a separate host-owned confirmation and a signed one-use action
claim. A stored permission grant cannot bypass this review.
After review, the renderer prepares the existing transaction or connection
request. It claims action authority after the final asynchronous preparation,
immediately before persistence or dispatch. This claim is the action's commit
point. Completion only acknowledges the committed action. Automation changes
during connection review require fresh confirmation.
The connection label uses the same `resolveEffectiveTerminalProtocol` helper as
the launcher, including Mosh/ET flags and implicit localhost shells. Local
execution displays an explicit on-device shell notice and omits the remote SSH
username and port.
## Sample and validation
`examples/plugins/host-actions` adds four commands through the existing command
palette slot. Its create draft uses `synthetic.invalid`; it requests no network,
filesystem, terminal-content, or credential permissions.
`components/PluginHostActions.entrypoint.test.tsx` installs that sample and drives
its actual entrypoint, SDK, RPC registry, source binding, signed replies,
production React dialog, and renderer execution adapter. It covers explicit
selection, cancel, stale revisions, locked Vault, host removal and replacement,
revoke, source replacement, runtime removal, and connection confirmation. Registry
checks reject text handles, tampered references, caller owners, and mismatched
expected revisions. Only synthetic resources are used. The Node transport fixture
does not certify the operating-system sandbox.
Focused action tests compare Mosh, ET, and localhost confirmations with the real
`handleConnectToHostImpl` dispatcher while replacing terminal creation with a
synthetic recorder. Managed A-to-B/outside moves, managed destinations, ordinary
unmanaged saves, and catalog changes use synthetic source records; no SSH config
files are read or written.
Native screenshots and video run on the parent's existing cloud GUI. This branch
does not change Electron sandbox or helper permissions. Tests use synthetic hosts
and credentials. No real host connection or credential access is authorized.
@@ -0,0 +1,87 @@
# Local logging validation
Base: PR #3978 at `91b8927c56314e68c06e2c008e84559ddfb96d78`.
All data used in these checks is synthetic and each persistence test creates its
own temporary profile. No real logs or credentials were opened.
| Check | Result |
| --- | --- |
| `node --test electron/plugins/*.test.cjs` | 561 passed, 2 expected skips (Electron live test and the #3981 database integration test on this older base) |
| New logging settings, existing settings and permission-panel interaction tests | 4 passed |
| `npm run build` | Passed; existing large-chunk warning |
| `npm run lint` | No errors, 7 existing warnings; final changed-source lint also passed |
| `npm run check:plugin-contract` | Passed, no generated drift |
| `npx tsc --noEmit` against this change and the exact base | 773 diagnostics in each, zero added or removed after normalizing worktree paths |
| `git diff --check` | Passed |
Coverage includes disabled and enabled persistence, opt-in plugin text, sensitive
field exclusion, host source attribution, unknown-source fallback, URL rejection,
spoofed metadata, cyclic/accessor/proxy input, noisy writers, shared queue and disk
limits, rotation, expiration, drop/error counts, export, clear confirmation,
disable/revoke, stale runtime callbacks, and manager shutdown. The filesystem
regressions cover direct paths, symlink aliases, a symlinked profile directory,
and revocation during the final protected-storage check.
## Remaining native checks
Electron launch failed before application/profile startup. The executable at
`node_modules/electron/dist/electron --version` aborted with:
> The SUID sandbox helper binary was found, but is not configured correctly.
Its message requires `chrome-sandbox` to be root-owned with mode 4755. No sandbox
switch, privilege escalation, permission repair, or native retry followed. Native
smoke execution, screenshots, and a short recording remain pending. The acceptance
flow is in [Local plugin logging](local-logging.md#native-qa-with-synthetic-data).
`npm run pack:dir` completed its production build, then failed during serialport
native rebuilding: node-gyp returned ENOENT for mkdir
`/home/agent/.electron-gyp`. No system-directory change or packaging retry followed.
A native package was not produced.
## Parent integration
The only shared settings-page change is one import and one `<PluginLogSettings />`
element. Preserve that element when integrating the marketplace task's page.
There are no Installed, Official, Third-party, source-database, or unrelated host
data changes.
The default host-owned resolver is wired through `createPluginHostService`. It
consumes #3981's confirmed API without editing its database or sync implementation.
On the older #3987 base, missing source APIs remain explicitly unknown. No shared
transport-registration change is needed beyond the existing logging registration.
## Source and review follow-up
A separate worktree at `59721f97172acffffe960a6268a2916a90a6c554` received only
the logging backend overlay. Its logging/source/host regression command passed
33 tests with no skips, including the real database replacement, encoded local-owner roundtrip and
shutdown-drain case. The product branch does not import marketplace changes or bump its database
schema.
Independent review of the implementation follow-up found no remaining blocking
findings. Independent runs passed 50 focused regressions with one expected source
skip, 20 real-source integration regressions with no skips, and the logging UI
interaction test. The reviewer made no edits and did not launch Electron. A further independent
review of the newer encoded-local-owner contract found no issues; all six
provenance tests passed on exact `59721f97172acffffe960a6268a2916a90a6c554`,
with an additional probe confirming the local encoded owner.
Fixed review findings have deterministic regressions: 31 days of low-volume
appends and export cannot extend segment retention via mtime; stale sources cannot
rotate away existing evidence; settings-write failure shows actual host state;
log draining still completes when another shutdown stage rejects; and activation
permission denial produces a fixed host record before runtime creation.
Exact prior head `e3cc23bca81a4187ccc13b90d3882889d524ca3b` passed remote
[test 40339839263](https://github.com/binaricat/Netcatty/actions/runs/40339839263)
and [build-packages 40339839660](https://github.com/binaricat/Netcatty/actions/runs/40339839660).
Those runs do not validate the follow-up head. Its exact-head CI is reported in
the PR after pushing.
The native fixture build is reproducible: 3,270 bytes, SHA-256
`c3770a55676ed708f01ec1e0e0b9968ffb5e03c02303ee7cb9f739bde27c9de0`.
The concise flow and commands are in [Local plugin logging](local-logging.md#native-qa-with-synthetic-data).
Native screenshots and recording remain assigned to the parent's working cloud
GUI machine. No local sandbox bypass or packaging retry is authorized by this
handoff.
+181
View File
@@ -0,0 +1,181 @@
# Local plugin logging
Local file persistence defaults to off. The existing permission panel keeps its
bounded security history independently. Changing or clearing local log files does
not clear that history, grant permissions, or change a plugin's source.
## Saved records
`PluginLogService` extends the existing `PluginLogger`. It owns one shared sink at
`<userData>/plugins/logs/host-v1`, outside plugin storage. Files contain JSON Lines.
They stay on the device. No cloud upload or sync path includes these files or the
local preference at `<userData>/plugins/local-logging.json`.
Host audit records contain these facts:
- Host-assigned plugin ID, installed version, archive SHA-256, runtime ID and kind.
- Source ID, source kind, and namespace from an injected host provenance resolver.
Missing provenance is `null`. Manifest claims never imply an official source.
- Registered host RPC operation, authorization outcome, result, numeric error code,
elapsed milliseconds, and available input/output `data` field byte counts.
- Target identifiers from host authorization descriptors, replaced with a keyed
SHA-256 digest. The random key stays in memory, so target correlation lasts only
for the current app process.
- Cumulative dropped-record and write-error counts at enqueue time.
Runtime start, running, stop, failure, protocol rejection, and permission revocation
produce separate host facts. Required and advanced activation permission failures
produce a fixed `runtime.authorization` event even before a runtime ID exists. The registry records known RPC attempts even when
validation or authorization fails. Authorization has separate `allowed`, `denied`,
`not-required`, and `not-reached` states. A handler error after approval preserves
`allowed` and records the failed result. Unknown values remain explicit.
The byte counts measure UTF-8 bytes in a string `data` field, or byte length for a
binary `data` field. They are not total wire traffic or decoded base64 size.
Missing measurements are `null`. Streaming traffic outside these calls does not
become a complete per-byte trace. Host facts describe what the host observed;
plugin-returned success cannot prove an external side effect occurred.
Host audit records never copy request bodies, response bodies, error messages,
credentials, API keys, passwords, prompts, Notes, SQL, query results, or terminal
content. Error codes come from the host RPC error set. No audit field accepts
plugin-supplied source identity.
## Optional plugin text
Verbose diagnostics require a separate confirmation and turn off after app
restart. Console messages, utility stdout/stderr, and `logger.write` text use
`recordType: plugin-diagnostic` and `trust: plugin-authored-unverified`. The host
adds identity outside the nested `diagnostic` object. Plugin fields named
`pluginId`, `sourceId`, or `recordType` cannot replace reserved metadata.
Text and structured fields have traversal, length, depth and total-record limits.
Control characters are removed and known sensitive field names are replaced.
These bounds do **not** make arbitrary plugin text safe. A plugin can put a secret
under an unexpected key or inside a sentence. Review files before sharing them.
No plugin text is persisted when verbose diagnostics are off.
## Limits and lifecycle
The shared sink retains at most three files of 1 MiB each, for at most seven days.
Expiry uses the first host timestamp in each segment, read within the existing
16 KiB record bound. Appending cannot extend a segment's lifetime. Export uses
the same cutoff. A segment is removed when its first record expires; capacity
rotation or segment expiry can remove later records sooner. Retention runs on
startup, writes and hourly while the app runs, including after saving is disabled. The app cannot delete expired files while it is closed.
The asynchronous queue accepts at most 256 KiB and records drops when full.
Plugin text can occupy only a quarter of that queue, leaving capacity for host
facts. Diagnostics also reuse the existing per-runtime log token bucket (240
records per minute, with a 240-record initial burst), including console output. Each
record is limited to 16 KiB. Oversized records are dropped. Write failures increase
both drop and error counts and never fail the plugin operation.
Turning saving off stops ingress, discards queued records that have not started,
and waits for any current write. Runtime cleanup closes its diagnostic logger.
App shutdown drains accepted writes through the existing manager shutdown path,
including when another cleanup stage rejects. A failed settings write refreshes
the checkbox from actual host state while retaining the original error.
Clearing pauses ingress, discards queued records, waits for a current write and
removes only the sink's known files. Cancellation leaves them intact. Export takes
a bounded snapshot on the write queue and uses the native Save dialog.
Files use mode 0600 and the directory uses 0700 on systems that support them.
The host filesystem broker rejects writes to the audit directory and preferences,
including canonical paths reached through symlinks. Plugin views cannot invoke
log management IPC. No plugin API exposes audit mutation. These local files are
not a tamper-evident ledger against the OS account or the development-only utility
runtime, which already has full Node and filesystem authority.
## Integration boundaries
`PluginLogSettings` presents a compact settings row on `PluginSettingsLanding`.
Its standard host dialog contains the save and verbose switches plus local file
actions. The landing page also shows installed-plugin quick controls using the
existing host manager. The Installed, Official and Third-party manager pages keep
their existing layout; declared settings links open the relevant installed detail.
The logging backend and source database are unchanged by this UI restructuring.
`createPluginHostService` now injects the host-owned
`createPluginLogProvenanceResolver(database)` by default. It consumes the source
API confirmed on #3981 at `59721f97172acffffe960a6268a2916a90a6c554`:
- `getActivePlugin(pluginId)` supplies the installed version, hash and source.
- `getRetainedPluginSource(pluginId)` must agree with the installed source.
- `privateOwner(pluginId)` supplies the opaque namespace.
- `publicPluginId(owner)` must round-trip to the host-assigned public ID.
The adapter receives host activation identity only, after the existing caller
guards. It never accepts source or owner fields from plugin request parameters.
Missing APIs, an absent installation, changed version/hash, a retained-source
mismatch or an orphaned owner leave provenance explicitly unknown. There is no
bare-plugin-ID fallback. The current host API encodes every owner, including
local imports; a bare ID returned as an owner remains unknown.
Each logger snapshots the source binding and supplies a host-only `isCurrent`
check. The sink checks it at enqueue, after asynchronous preflight, before
rotation mutations and immediately before appending. Records from a replaced or
uninstalled source are dropped and counted. They are never relabelled as the new
source. Already saved records keep their original identity.
The optional `resolvePluginProvenance` override remains host-owned. Its result
contains `sourceId`, `sourceKind`, `namespace`, and an optional synchronous
`isCurrent` function. Source IDs and kinds accept 1-128 ASCII letters, digits,
periods, underscores, colons and hyphens. Opaque namespaces accept the same
characters up to 256 bytes. URLs, display names and plugin claims are not source
identity. `official` describes the host-bound source, not official maintenance.
The source database, marketplace page and sync implementation are unchanged by
this adapter. It also needs no new shared host-registration change for transport.
## Native QA with synthetic data
Use the parent's working cloud GUI machine, a fresh isolated profile, and the
synthetic fixture below. Do not retry the blocked local Electron launch.
Do not open real logs or credentials. Capture screenshots of the settings card,
verbose disclosure, and native Clear confirmation. Record a short flow:
1. Open Plugins settings and choose **Configure** in the local logging row.
Confirm both options are off and no host-v1 files exist.
2. Enable local saving. Invoke a synthetic plugin host operation and deny another.
3. Open the folder and export to a synthetic destination. Inspect parsed JSONL for
host identity, outcomes, target digests, and absence of synthetic secret bodies.
4. Open verbose disclosure and cancel. Confirm no plugin text is saved. Confirm
the disclosure, write synthetic text, and verify the unverified record type.
5. Disable/revoke the plugin. Confirm its later text cannot append. Restart the
app and verify that local saving remains selected but verbose text is off.
6. Cancel Clear, then confirm it. Confirm the files are removed and the permission
panel's separate security history remains. Turn local saving off.
The fixture is `electron/plugins/fixtures/local-logging-plugin`. Build it with:
```sh
node --input-type=module -e 'import { buildPluginPackage } from "@netcatty/plugin-cli"; await buildPluginPackage("electron/plugins/fixtures/local-logging-plugin", "/tmp/netcatty-local-logging-fixture.ncpkg");'
```
Package: 3,270 bytes. SHA-256:
`c3770a55676ed708f01ec1e0e0b9968ffb5e03c02303ee7cb9f739bde27c9de0`.
It declares only `commands` and `menus`; its command-palette entries are under
**Logging QA**. **Emit synthetic diagnostic** tests redaction and nested spoofed
identity fields. **Emit synthetic diagnostic burst** emits 400 console messages
to exercise drop accounting. **Test undeclared network denial** must return denial
without a prompt or network I/O because the manifest declares no network access.
No request body or credential is supplied. Do not grant additional permissions.
Capture the compact settings card, verbose disclosure and native Clear dialog,
plus a short recording of the enable/emit/export/clear flow. On a #3981 integration
build, a normal local import must log the source and opaque owner returned by its
host database. On the older base, both stay explicitly unknown. Do not forge an
Official source through fixture metadata. The real-source replacement case is
covered by `pluginLogProvenance.integration.test.cjs` on the documented source
integration worktree; it uses only temporary synthetic database state.
The execution environment for this change could not launch Electron: its SUID
sandbox helper is not root-owned with mode 4755. Electron aborted before opening
any profile. No sandbox-disable retry or system-permission change was attempted.
`npm run pack:dir` also failed while rebuilding the serialport native dependency:
node-gyp tried to create `/home/agent/.electron-gyp` and received ENOENT. No
system-directory change or packaging retry was attempted.
Native screenshots and recording therefore remain pending in the parent desktop
QA queue. Component interaction tests are separate evidence and do not replace
that acceptance.
@@ -0,0 +1,286 @@
# 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 |
| --- | --- |
| ![Model and Note labels in light Ask](evidence/permission-modes/plugin-permission-model-ask-light.png) | ![Model and Note labels in dark Ask](evidence/permission-modes/plugin-permission-model-ask-dark.png) |
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
- The native candidate `2433cf0130d1c3274c56c39e5765349c2945de01` retains the
same idle logging-row marker as `89beeb9` (source blob
`4dd5a7cea4ae5a6addc5db5c162038d8fa44075c`). Actual Settings landing plus the
existing observer reproduces native-child suppression with no dialog open.
This source defect is separate from the worker's earlier GPU exit and does
not alone establish the cause of the recorded blank native body.
- The scoped correction removes only the idle-row marker. Actual logging and
Ask dialogs remain marked. A real nested verbose confirmation test also
reproduced premature restoration when its parent became accessibility-hidden;
that confirmation now opts into the existing marker on its own dialog content.
The observer, window trust, permissions and data access are unchanged. The real
landing regression verifies idle visible, each security dialog hidden, and
restored on dismissal. Focused group **six passed**; Settings boundary group
**36 passed**. Both pre-fix failures are retained outside the source tree.
Production build and full lint pass (zero lint errors/seven existing warnings).
Full TypeScript remains baseline-equivalent at 774 diagnostics with zero
added/removed. Exact-head CI and the next frozen native recheck are separate
gates; neither is inferred from these local tests.
- QA-only **Settings Ask QA 0.1.0** uses its supported `location: settings` entry
and public SDK to read only its synthetic own setting after an explicit click.
Exact archive SHA-256:
`9502a58a4804862e4b13de9fc0d1baf6ad8c2bc681c08b5c0664b552ef0aafc4`.
Its HTML/CSS/JS each return 200 with proper MIME/CSP through the actual plugin
protocol. It contains no raw IPC/test hook or external access. No GUI or running
app was changed by this correction; parent will build a new frozen candidate
after current cleanup and repeat native Settings acceptance.
- 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 |
| --- | --- |
| ![Light controls](evidence/permission-modes/plugin-permission-controls-light.png) | ![Light Ask](evidence/permission-modes/plugin-permission-ask-light.png) |
| ![Dark controls](evidence/permission-modes/plugin-permission-controls-dark.png) | ![Dark Ask](evidence/permission-modes/plugin-permission-ask-dark.png) |
## 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,67 @@
# 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.
Ordinary landing rows do not suspend native views. The logging dialog and its
verbose confirmation are marked security surfaces; the confirmation opts into
the existing marker because it temporarily accessibility-hides its parent dialog.
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 +95,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 +117,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 +144,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 +289,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 +304,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.
@@ -0,0 +1,140 @@
# Settings visibility and current-main acceptance candidate
This isolated branch, `integration/plugin-settings-main-acceptance`, starts at
frozen #4004 `2433cf0130d1c3274c56c39e5765349c2945de01`. Its Draft PR and private
renderer provenance record the final exact head and CI. Frozen2433 source,
artifact, QA workspace/profile and earlier evidence remain intact.
## Inputs and production ownership
Both inputs were ordinarily merged, without conflicts or manual production edits:
| Input | Import | Production files and behavior |
| --- | --- | --- |
| Current main `a0cf2b487c9ff0b0472d622e66bc5ee20de32c71` | Merge `c4aedfa9ed8c9f2647ac26f0e6e144edab341c2d` | `domain/workspaceAiScopeHandoff.ts` keeps inherited AI chat on a surviving owner when a workspace dissolves (#4003/e3e2dcfe); `electron/bridges/ai/shellUtils.cjs` narrowly recognizes Oh My Zsh ys two-line idle prompts (#4008/a0cf2b48) |
| Policy `1551fa8355021b5a811d737d16f81e8070f99f5d` | Merge `714e2397a43dc6c083dce8afeeb7a0475de11384` | `PluginLogSettings.tsx` removes the idle-row security marker and opts the nested verbose confirmation into it; `confirm-dialog.tsx` adds the optional host-only marker with default false |
All four production files exactly match their respective owners. Main has only
those two new commits since common base f0897c3c. Neither changed file overlaps
the prior plugin changes. The new prompt utility is not imported by plugin
production modules; plugin sensitive-input, selection and source guards remain
unchanged. Existing prompt and live-PTY-byte suffix tests still reject bare
markers and pending input. These are synthetic tests, not a new native SSH pass.
The input also owns its real Settings landing regression and documentation.
The existing observer, Ask marker, IPC/window authority, sandbox, resource/source
controls, AI d33 wiring, schemas, sample bytes and package/lock inputs are
unchanged. `ModelSelector.tsx` equals2433: separate #4002 is not included. Global
Agent/custom Chat #4006 and terminal diagnostic #3999 are excluded. No main merge,
Ready transition, original-PR closure or release is part of this handoff.
The parent reports the1551-vs89 independent delta review scoped clean: idle
Settings no longer requests suspension, the real logging dialog remains marked,
its nested verbose portal protects itself while the parent is accessibility-
hidden, closing the nested dialog leaves the parent protected, and closing all
dialogs permits restoration. This source-only result is not native resolution
of the recorded Settings blank body.
## Frozen2433 native results
The parent supplied these results; this coding executor did not run the GUI:
| Case | Result on2433 |
| --- | --- |
| Main Ask visible | Passed |
| Cancel | Passed; zero provider POSTs |
| Accept | Passed; one provider POST |
| Stream chunks | Passed; no repeated permission prompts |
| Content Deny | Passed |
| Real five-minute chat expiry | Passed |
| Settings sample body | Blank; not accepted |
The idle-row suppression defect is source-confirmed. The correction requires a
new native Settings recheck; no native success is inferred from the DOM regression.
A later GPU exit9 and incomplete cleanup were reported separately. Its cause is
unclassified; no source hotpatch or sandbox/GPU workaround was used here.
Consumer verification of2433's Library renderer is complete: the parent checked
the matching full source/backend and all1001 renderer hashes, with native inputs
unchanged. The earlier producer download failure remains historical evidence,
not an outstanding consumer transfer blocker. That verified artifact still
certifies2433 only; it does not include1551 or the new main renderer behavior.
Earlier020 Default Allow cold Send/completion, Stop/Deny/restore and themes,
677 Host core, and corrected-fixture terminal solo/split results remain scoped
historical evidence. Terminal Focus is unreliable/unverified and QA sent one
Enter inA during a palette attempt: no global zero-input/full-terminal-pass claim.
The historical Rfam attempt failed socket transport before connect resolved;
authentication/SELECT were not reached and cause/auth/TLS compatibility remain
unverified. Final source-policy, DB, placement/lifecycle and actual hosting/CDN
gates remain separately reconciled by the parent.
## Local checks and sample provenance
| Check | Result |
| --- | --- |
| Registered plugin runtime | 695 passed, one native skip; zero failed/cancelled |
| Contract/SDK/CLI | 91 passed |
| Offline examples | Five passed, one Docker opt-in skip |
| Changed main owner/scope/prompt/suffix and terminal lifecycle group | 139 passed |
| Actual mounted AI/permission/selected-context/Host group | 21 passed |
| Settings boundary and security visibility group | 38 passed |
| Contract/capability/Codex generation and committed drift | Clean |
| Lint | Zero errors, seven existing warnings |
| Five sample archives | Validated, built twice and byte-identical to2433 |
The final exact-head normal production build, renderer inventory, bounded
credential check, CI and Codex review are recorded in the Draft PR/artifact
handoff. Local full-suite, native packaging and GUI success are not claimed.
Locked dependencies, standard patches and the official pinned Electron binary
were prepared normally; no header, HOME, sandbox or native shim workaround.
[Exact sample IDs, permissions/resources and hashes](settings-main-packages.json)
preserve all prior provenance, including Assistant7943d4c3, ownedDBf01c6a24,
Rfamaaca5866, Hostcf04f82d and terminal4c9c2388. Whole archive equality was
asserted, rather than inferred from unchanged source. The owned10.77.0.20 endpoint
is packaging-only, not a running fixture. No package was activated or service
queried by this executor.
The owner's separate QA-only Settings Ask sample has reported SHA-256
`9502a58a4804862e4b13de9fc0d1baf6ad8c2bc681c08b5c0664b552ef0aafc4`.
It is not one of these five repository examples and was not rebuilt here. The
native owner must retain its exact verified manifest/source/permissions and
fixture provenance; no package ID, permission expansion or hash equivalence is
invented by this integration.
## Native preparation and bounded recheck
1. After the parent's GUI/Nginx slot is released, use a NEW isolated checkout of
the final exact head and a separate synthetic QA profile. Preserve the old2433
workspace/profile and recordings. Materialize the new private Library artifact
in the consumer's own workspace and verify archive and every renderer hash.
2. Use the complete matching backend checkout: frontend-only dist does not carry
the CJS prompt helper or native dependencies. Match package/lock hashes to
independently normally rebuilt native inputs and prepare workspace packages
normally. Install dist as a whole; use the ordinary Electron launch without
Vite and with normal sandbox/helper settings. This is unpackaged native
production-renderer QA, not packaged-binary certification.
3. Open the verified Settings sample through its existing supported settings
entry. Check the idle body is visible/clickable with the logging row present.
Open logging and the nested verbose confirmation separately; native content
must yield to each, remain hidden after cancelling the nested confirmation,
and restore only after the parent closes. Do not enable real verbose logging
or collect real content for this check.
4. In the ready/focused/visible/unlocked Settings owner, exercise its synthetic
own-setting Ask operation. Record visible Ask, explicit cancel/accept, exact
current operation outcome and restoration. Recheck main Ask briefly. Preserve
the2433 main/content/expiry counters and any unrun matrix cases separately.
5. Use owned synthetic A/B AI chats: form a workspace, prefer B, dissolve while A
survives and confirm both histories stay with their owners. Remove A and
check bounded fallback to B. Confirm handoff alone does not invoke a model or
re-share content. Exercise selected-target invalidation across the transition
with inert drafts and no PTY input. Do not treat earlier Focus uncertainty as
resolved by this main domain fix.
6. Record exact source/artifact/sample identities, screenshots/video, request
counters, cleanup and unrun cases. No fallback endpoint/probe, real credentials,
paid API, security relaxation or unrecognized installation is authorized here.
If main advances after this pinned a0cf snapshot, compare the new delta and assess
its affected checks before landing. The current candidate deliberately does not
chase unrelated changes while native preparation is in progress.
@@ -0,0 +1,217 @@
[
{
"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": "1551fa8355021b5a811d737d16f81e8070f99f5d",
"rebuiltAgainstAI": "d33c0193ccaedc1a10aed9da495f040250121bef",
"previous020Sha256": "7943d4c3800914fe0acd3d0f3a3d7f7c1910c212a5dc58a044871a6a82485274",
"changedEntriesFrom020": [],
"rebuiltAgainstMain": "a0cf2b487c9ff0b0472d622e66bc5ee20de32c71",
"productionMerge": "714e2397a43dc6c083dce8afeeb7a0475de11384",
"previous2433Sha256": "7943d4c3800914fe0acd3d0f3a3d7f7c1910c212a5dc58a044871a6a82485274",
"changedEntriesFrom2433": []
},
{
"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": "1551fa8355021b5a811d737d16f81e8070f99f5d",
"rebuiltAgainstAI": "d33c0193ccaedc1a10aed9da495f040250121bef",
"previous020Sha256": "f01c6a24382897d4b0bf4130941274abacb9a9a963f9b56ecaceb2c020f36874",
"changedEntriesFrom020": [],
"rebuiltAgainstMain": "a0cf2b487c9ff0b0472d622e66bc5ee20de32c71",
"productionMerge": "714e2397a43dc6c083dce8afeeb7a0475de11384",
"previous2433Sha256": "f01c6a24382897d4b0bf4130941274abacb9a9a963f9b56ecaceb2c020f36874",
"changedEntriesFrom2433": []
},
{
"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": "1551fa8355021b5a811d737d16f81e8070f99f5d",
"rebuiltAgainstAI": "d33c0193ccaedc1a10aed9da495f040250121bef",
"previous020Sha256": "aaca586669db454e91d61487e576ed524d1b7190eed182bc2cd0035e59caa0d3",
"changedEntriesFrom020": [],
"rebuiltAgainstMain": "a0cf2b487c9ff0b0472d622e66bc5ee20de32c71",
"productionMerge": "714e2397a43dc6c083dce8afeeb7a0475de11384",
"previous2433Sha256": "aaca586669db454e91d61487e576ed524d1b7190eed182bc2cd0035e59caa0d3",
"changedEntriesFrom2433": []
},
{
"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": "1551fa8355021b5a811d737d16f81e8070f99f5d",
"rebuiltAgainstAI": "d33c0193ccaedc1a10aed9da495f040250121bef",
"previous020Sha256": "cf04f82dfe25ab5cda6f5f3dad0a8fb06c37bedf9924f556bee2b8402e542ef2",
"changedEntriesFrom020": [],
"rebuiltAgainstMain": "a0cf2b487c9ff0b0472d622e66bc5ee20de32c71",
"productionMerge": "714e2397a43dc6c083dce8afeeb7a0475de11384",
"previous2433Sha256": "cf04f82dfe25ab5cda6f5f3dad0a8fb06c37bedf9924f556bee2b8402e542ef2",
"changedEntriesFrom2433": []
},
{
"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": "1551fa8355021b5a811d737d16f81e8070f99f5d",
"rebuiltAgainstAI": "d33c0193ccaedc1a10aed9da495f040250121bef",
"previous020Sha256": "4c9c2388b7f891156192a0b5ab7b23291beba3555e7ec8cf24fbe2dfe1242d5e",
"changedEntriesFrom020": [],
"rebuiltAgainstMain": "a0cf2b487c9ff0b0472d622e66bc5ee20de32c71",
"productionMerge": "714e2397a43dc6c083dce8afeeb7a0475de11384",
"previous2433Sha256": "4c9c2388b7f891156192a0b5ab7b23291beba3555e7ec8cf24fbe2dfe1242d5e",
"changedEntriesFrom2433": []
}
]
+46
View File
@@ -0,0 +1,46 @@
# Plugin settings
Settings > Plugins uses the same sections, divided cards, rows and toggles as
other Netcatty settings. The first two rows open the plugin manager and configure
[local logging](local-logging.md). Logging details live in a standard host dialog;
opening it does not enable saving or verbose diagnostics.
The installed list shows each plugin's icon, name, version, publisher, description,
host-bound source and runtime status. Search filters this metadata. Quick toggles
call the existing host manager, which retains compatibility checks, permission
prompts and runtime lifecycle rules. Failed mutations leave the actual enabled
state visible. A settings button appears only for declared settings or settings
views, and is enabled only when the host exposes those contributions.
## Manual update checks
**Check for updates** reads installed packages, registered sources and each bound
source's catalog through the existing host bridge. It never installs code or
changes permissions. Open **Browse** to review and install an update through the
existing manager flow.
Only a fresh registry response with a published artifact and valid semantic
version counts as checked. Cached, built-in, failed and incomplete responses do
not imply that installed plugins are current. Local, unknown, removed and disabled
sources are not checked. The summary reports checked packages, available updates,
unchecked packages and unavailable sources separately.
The checker matches source ID, kind and origin, then revalidates source, installed
version and archive hash after asynchronous reads. Source or package changes
invalidate displayed results. Checks run only when requested; there is no automatic
checking preference, automatic installation or global pause switch.
## Native acceptance
Use a fresh isolated profile and synthetic plugins. Capture the compact landing
page and logging dialog at normal and narrow window sizes, including keyboard
focus, scrolling, loading, empty and error states. Verify a denied quick-enable
request leaves the switch off, and a successful request shows the real host state.
Open declared plugin settings, switch manager tabs, return to Installed and reopen
the settings. Close the dialog and verify focus returns to its opener.
Run a manual update check against a synthetic source, then an unavailable source;
verify the summary distinguishes unchecked packages and never installs anything.
Complete the separate local-logging acceptance flow, including verbose consent,
native Clear cancellation/confirmation and export. Do not use real logs or
credentials.
@@ -80,8 +80,12 @@ HTTP 403 for the configured Electron header download under
The download was not retried through a mirror. Exact-head platform CI remains the
packaging verification surface; its result is recorded on the Draft PR.
The sample archive is
The original owner-head sample archive is
`f3b8ac268edbf616effe76110f372d2249e414675d877020a074c89f03c65950`.
The unified candidate rebuild has a different SDK and archive hash; its current
hash, source checkpoints and review/native gates are in the
[acceptance record](acceptance-candidate.md). Do not substitute the owner archive
for that candidate's package.
The parent-coordinated [native recipe](terminal-sharing-native-qa.md) remains
pending on the reserved GUI machine. No GUI launch, sandbox bypass, helper
permission change, paid provider call, or real-user resource was used.
+40
View File
@@ -0,0 +1,40 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyPluginHostEdit, normalizePluginHostDraft, pluginHostFieldDiff, pluginHostRunsSavedScripts } from './pluginHostActions';
import type { Host, Snippet } from './models';
const host: Host = { id: 'synthetic-host', label: 'Synthetic', hostname: 'host.invalid', username: 'test', os: 'linux', tags: [], password: 'synthetic-secret', startupCommand: 'synthetic-script' };
test('host action drafts reuse importer normalization and discard credential/executable fields', () => {
const created = normalizePluginHostDraft({ ...host, password: 'untrusted', protocol: 'local' } as never, 'host-owned-id');
assert.equal(created.id, 'host-owned-id');
assert.equal(created.protocol, 'ssh');
assert.equal(created.password, undefined);
assert.equal(created.startupCommand, undefined);
assert.throws(() => normalizePluginHostDraft({ hostname: 'host.invalid', port: 0 }, 'id'));
assert.throws(() => normalizePluginHostDraft({ hostname: 'host.invalid\ninvalid' }, 'id'));
assert.throws(() => normalizePluginHostDraft({ hostname: 'host.invalid other.invalid' }, 'id'));
});
test('edit draft preserves saved connection and credentials and diff includes only approved metadata', () => {
const edited = applyPluginHostEdit(host, { label: ' Renamed ', tags: ['new'], hostname: 'attacker.invalid', password: 'untrusted' } as never);
assert.equal(edited.password, host.password);
assert.equal(edited.hostname, host.hostname);
assert.equal(edited.startupCommand, host.startupCommand);
assert.throws(() => applyPluginHostEdit(host, { label: ' ' }), /required/);
assert.deepEqual(pluginHostFieldDiff(host, edited), [
{ field: 'label', before: 'Synthetic', after: 'Renamed' },
{ field: 'tags', before: [], after: ['new'] },
]);
assert.equal(JSON.stringify(pluginHostFieldDiff(undefined, normalizePluginHostDraft(host, 'id'))).includes('synthetic-secret'), false);
});
test('connection warning covers host, inherited, global and output automation without returning script content', () => {
const clean = { ...host, startupCommand: undefined };
assert.equal(pluginHostRunsSavedScripts(clean, [], []), false);
assert.equal(pluginHostRunsSavedScripts(host, [], []), true);
assert.equal(pluginHostRunsSavedScripts({ ...clean, group: 'Synthetic' }, [{ path: 'Synthetic', startupCommand: 'synthetic-group-script' }], []), true);
assert.equal(pluginHostRunsSavedScripts(clean, [], [{ id: 'global', label: 'Global', command: 'synthetic', kind: 'script', trigger: 'onConnect', targetsAllHosts: true } as Snippet]), true);
assert.equal(pluginHostRunsSavedScripts(clean, [], [{ id: 'output', label: 'Output', command: 'synthetic', kind: 'script', trigger: 'onOutput', triggerPattern: 'ready' } as Snippet]), true);
assert.equal(pluginHostRunsSavedScripts({ ...clean, outputTriggers: [{ id: 't', pattern: 'ready', scriptId: 'synthetic' }] }, [], []), true);
});
+77
View File
@@ -0,0 +1,77 @@
import type { ImporterHostDraft } from '@netcatty/plugin-contract';
import type { GroupConfig, Host, ManagedSource, Snippet } from './models';
import { applyGroupDefaults, resolveGroupDefaults } from './groupConfig';
import { hasHostConnectAutomation } from './hostConnectScripts';
import { normalizePluginImporterRecords } from './pluginImporter';
import { applyVaultHostUpdate, buildVaultHostFromDraft } from './vaultHostCreate';
import { isScriptSnippet } from './snippetScript';
import { snippetAppliesToOutputTrigger } from './snippetTargets';
// Keep the public host draft a subset of the existing typed importer. In
// particular, references to credentials, plugin configuration and executable
// settings are never accepted by this surface.
export type PluginHostDraftFields = Pick<ImporterHostDraft,
'label' | 'hostname' | 'username' | 'port' | 'group' | 'tags'>;
export type PluginHostEditFields = Pick<PluginHostDraftFields, 'label' | 'group' | 'tags'>;
export const PLUGIN_HOST_CREATE_FIELDS = ['label', 'hostname', 'username', 'port', 'group', 'tags'] as const;
export const PLUGIN_HOST_EDIT_FIELDS = ['label', 'group', 'tags'] as const;
export function normalizePluginHostDraft(draft: PluginHostDraftFields, id: string): Host {
const fields = Object.fromEntries(PLUGIN_HOST_CREATE_FIELDS.flatMap((field) => (
draft[field] === undefined ? [] : [[field, draft[field]]]
)));
// Validate before sanitization as well: an invalid address must not become
// a different, valid endpoint when the importer removes control characters.
const raw = { ...fields, hostname: draft.hostname ?? '', protocol: 'ssh' as const };
if (!buildVaultHostFromDraft(raw).ok) throw new Error('Host draft is invalid');
const normalized = normalizePluginImporterRecords([{
type: 'draft', draft: { kind: 'host', value: raw },
}]);
if (normalized.errors.length || normalized.hosts.length !== 1) {
throw new Error('Host draft is invalid');
}
const validated = buildVaultHostFromDraft(normalized.hosts[0]);
if (!validated.ok) throw new Error('Host draft is invalid');
return { ...validated.host, id };
}
export function applyPluginHostEdit(host: Host, draft: PluginHostEditFields): Host {
if (draft.label !== undefined && !draft.label.trim()) throw new Error('Host label is required');
const normalized = normalizePluginHostDraft({
label: draft.label ?? host.label,
hostname: 'draft.invalid', username: '',
group: draft.group ?? host.group, tags: draft.tags ?? host.tags,
}, host.id);
return { ...host, label: normalized.label, group: normalized.group, tags: normalized.tags };
}
export function pluginHostFieldDiff(before: Host | undefined, after: Host) {
const fields = before ? PLUGIN_HOST_EDIT_FIELDS : PLUGIN_HOST_CREATE_FIELDS;
return fields.flatMap((field) => {
const previous = before?.[field];
const value = after[field];
return JSON.stringify(previous) === JSON.stringify(value) ? [] : [{
field, before: previous ?? null, after: value ?? null,
}];
});
}
/** Managed SSH-config writes stay in the full Vault editor. Reuse its group
* ownership rules without opening or syncing any managed file here. */
export function pluginHostDraftTouchesManagedSource(host: Host, managedSources: ManagedSource[]): boolean {
if (host.managedSourceId) return true;
if (managedSources.length === 0) return false;
const reconciled = applyVaultHostUpdate([host], [], host.id, { group: host.group ?? '' }, { managedSources });
if (!reconciled.ok) throw new Error('Host draft is invalid');
return Boolean(reconciled.updatedHost.managedSourceId);
}
/** Only the host UI consumes this disclosure; script text never crosses RPC. */
export function pluginHostRunsSavedScripts(host: Host, groups: GroupConfig[], snippets: Snippet[]): boolean {
const effective = applyGroupDefaults(host, host.group ? resolveGroupDefaults(host.group, groups) : {});
return Boolean(effective.startupCommand?.trim())
|| hasHostConnectAutomation(effective, snippets)
|| snippets.some((snippet) => isScriptSnippet(snippet) && snippetAppliesToOutputTrigger(snippet, effective))
|| Boolean(effective.outputTriggers?.length);
}
+3 -3
View File
@@ -11,12 +11,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.", "读取终端输出", "通过支持的提供器接收终端输出,其中可能包含凭据或隐私信息。"],
+46 -6
View File
@@ -108,7 +108,7 @@ test('handoffDissolvedWorkspaceAIScope retargets inherited chat when its pane is
});
});
test('handoffDissolvedWorkspaceAIScope retargets inherited chat when original pane also survives', () => {
test('handoffDissolvedWorkspaceAIScope keeps inherited chat on its surviving owner pane', () => {
const result = handoffDissolvedWorkspaceAIScope({
activeSessionIdMap: {
'workspace:ws-1': 'chat-a',
@@ -132,12 +132,52 @@ test('handoffDissolvedWorkspaceAIScope retargets inherited chat when original pa
preferredTerminalId: 'term-b',
});
assert.equal(result.changed, true);
assert.equal(result.activeSessionIdMap['terminal:term-b'], 'chat-a');
assert.equal(result.activeSessionIdMap['terminal:term-a'], null);
assert.equal(result.sessions[0]?.scope.targetId, 'term-b');
assert.equal(result.activeSessionIdMap['terminal:term-a'], 'chat-a');
assert.equal(result.activeSessionIdMap['terminal:term-b'], 'chat-b');
assert.equal(result.sessions[0]?.scope.type, 'terminal');
assert.equal(result.sessions[0]?.scope.targetId, 'term-a');
assert.equal(result.sessions[0]?.updatedAt, 1);
assert.equal(result.sessions[1]?.scope.targetId, 'term-b');
assert.deepEqual(result.panelViewByScope['terminal:term-b'], {
assert.deepEqual(result.panelViewByScope['terminal:term-a'], {
mode: 'session',
sessionId: 'chat-a',
});
assert.equal(result.panelViewByScope['terminal:term-b'], undefined);
});
test('handoffDissolvedWorkspaceAIScope keeps owner history when the other pane is preferred', () => {
const result = handoffDissolvedWorkspaceAIScope({
activeSessionIdMap: {
'workspace:ws-1': 'chat-a',
'terminal:term-b': 'chat-b',
},
sessions: [
{
id: 'chat-a',
scope: { type: 'terminal', targetId: 'term-a', hostIds: ['host-a'] },
updatedAt: 1,
},
{
id: 'chat-b',
scope: { type: 'terminal', targetId: 'term-b', hostIds: ['host-b'] },
updatedAt: 1,
},
],
workspaceId: 'ws-1',
terminalIds: ['term-a', 'term-b'],
preferredTerminalId: 'term-b',
panelViewByScope: {
'workspace:ws-1': { mode: 'session', sessionId: 'chat-a' },
},
});
assert.equal(result.changed, true);
assert.equal(result.activeSessionIdMap['terminal:term-a'], 'chat-a');
assert.equal(result.activeSessionIdMap['terminal:term-b'], 'chat-b');
assert.equal(result.sessions[0]?.scope.targetId, 'term-a');
assert.equal(result.sessions[1]?.id, 'chat-b');
assert.equal(result.sessions[1]?.scope.targetId, 'term-b');
assert.deepEqual(result.panelViewByScope['terminal:term-a'], {
mode: 'session',
sessionId: 'chat-a',
});
+31 -19
View File
@@ -78,13 +78,15 @@ export function seedWorkspaceAIActiveSessionFromMembers(input: {
}
/**
* When a workspace tab dissolves, copy its active chat onto the preferred
* surviving terminal before the workspace scope key is pruned. Also remint
* workspace-scoped chats and the handed-off active chat when it still lives
* under a different terminal (including one that also survives dissolve),
* clear that previous terminal selection so ownership filters do not hide the
* chat, and seed a session panel view so terminal scopes do not fall back to
* a blank draft.
* When a workspace tab dissolves, copy its active chat onto a surviving
* terminal before the workspace scope key is pruned. An inherited chat that
* is already terminal-scoped to a pane still in `terminalIds` stays on that
* owner, even when focus/preferred is a different survivor. Otherwise the
* target is the preferred survivor, or the first remaining terminal when the
* original pane is gone. Workspace-scoped chats are reminted onto that
* target, duplicate terminal map entries for the same chat are cleared, and
* a session panel view is seeded so terminal scopes do not fall back to a
* blank draft.
*/
export function handoffDissolvedWorkspaceAIScope<T extends AISessionScopeHandoffLike>(input: {
activeSessionIdMap: AIActiveSessionIdMap;
@@ -99,7 +101,7 @@ export function handoffDissolvedWorkspaceAIScope<T extends AISessionScopeHandoff
panelViewByScope: Record<string, AIPanelViewHandoffLike>;
changed: boolean;
} {
const preferredTerminalId = (
const fallbackTerminalId = (
input.preferredTerminalId
&& input.terminalIds.includes(input.preferredTerminalId)
? input.preferredTerminalId
@@ -109,6 +111,17 @@ export function handoffDissolvedWorkspaceAIScope<T extends AISessionScopeHandoff
const workspaceKey = buildAIScopeKey('workspace', input.workspaceId);
const workspaceActive = input.activeSessionIdMap[workspaceKey];
const hasWorkspaceActive = typeof workspaceActive === 'string' && workspaceActive.length > 0;
const activeSession = hasWorkspaceActive
? input.sessions.find((session) => session.id === workspaceActive)
: undefined;
const survivingOwnerTerminalId = (
activeSession?.scope.type === 'terminal'
&& activeSession.scope.targetId
&& input.terminalIds.includes(activeSession.scope.targetId)
? activeSession.scope.targetId
: null
);
const handoffTargetTerminalId = survivingOwnerTerminalId ?? fallbackTerminalId;
const previousPanelViewByScope = (
input.panelViewByScope as Record<string, AIPanelViewHandoffLike> | undefined
) ?? {};
@@ -118,8 +131,8 @@ export function handoffDissolvedWorkspaceAIScope<T extends AISessionScopeHandoff
};
let mapChanged = false;
if (preferredTerminalId && hasWorkspaceActive) {
const terminalKey = buildAIScopeKey('terminal', preferredTerminalId);
if (handoffTargetTerminalId && hasWorkspaceActive) {
const terminalKey = buildAIScopeKey('terminal', handoffTargetTerminalId);
let map = nextMap;
let changed = false;
if (map[terminalKey] !== workspaceActive) {
@@ -145,24 +158,23 @@ export function handoffDissolvedWorkspaceAIScope<T extends AISessionScopeHandoff
let sessionsChanged = false;
const nextSessions = input.sessions.map((session) => {
if (!preferredTerminalId) return session;
if (!handoffTargetTerminalId) return session;
const isWorkspaceScoped = (
session.scope.type === 'workspace'
&& session.scope.targetId === input.workspaceId
);
// Inherited member chats stay terminal-scoped under the original pane.
// Prefer the focused survivor even when that original pane also survives
// dissolve, otherwise B's history ownership filter excludes A's chat.
// Keep an inherited chat on its owner when that pane survives. Remint
// only when the handoff target is a different terminal (original pane gone).
const isInheritedActiveChatOnOtherTerminal = (
hasWorkspaceActive
&& session.id === workspaceActive
&& session.scope.type === 'terminal'
&& Boolean(session.scope.targetId)
&& session.scope.targetId !== preferredTerminalId
&& session.scope.targetId !== handoffTargetTerminalId
);
if (!isWorkspaceScoped && !isInheritedActiveChatOnOtherTerminal) return session;
if (session.scope.type === 'terminal' && session.scope.targetId === preferredTerminalId) {
if (session.scope.type === 'terminal' && session.scope.targetId === handoffTargetTerminalId) {
return session;
}
@@ -172,7 +184,7 @@ export function handoffDissolvedWorkspaceAIScope<T extends AISessionScopeHandoff
scope: {
...session.scope,
type: 'terminal',
targetId: preferredTerminalId,
targetId: handoffTargetTerminalId,
},
updatedAt: Date.now(),
};
@@ -182,8 +194,8 @@ export function handoffDissolvedWorkspaceAIScope<T extends AISessionScopeHandoff
const nextPanelViewByScope: Record<string, AIPanelViewHandoffLike> = {
...previousPanelViewByScope,
};
if (preferredTerminalId && hasWorkspaceActive) {
const terminalKey = buildAIScopeKey('terminal', preferredTerminalId);
if (handoffTargetTerminalId && hasWorkspaceActive) {
const terminalKey = buildAIScopeKey('terminal', handoffTargetTerminalId);
const workspacePanelView = previousPanelViewByScope[workspaceKey];
const terminalPanelView = previousPanelViewByScope[terminalKey];
const nextView: AIPanelViewHandoffLike = (
+28 -10
View File
@@ -71,9 +71,17 @@ const POSIX_PROMPT_PATTERN = /^(?:[^\s@]+@[^\s:]+(?::[^\n\r]*)?|\[[^\s@\]]+@[^\s
// Host line of a two-line bash PS1 (`\u@\h: \w` then a following `$` or `#`).
// Same user@host[:path] class as POSIX_PROMPT_PATTERN's first alternative,
// without a prompt marker on this line. A bare `$`/`#` alone is not a prompt.
// without a prompt marker on this line. Oh My Zsh `ys` uses a different host
// line (`# user @ host ...`), matched separately below. A bare `$`/`#` alone
// is not a prompt.
const POSIX_PROMPT_HOST_LINE_PATTERN = /^[^\s@]+@[^\s:]+(?::[^\n\r]*)?$/;
// Oh My Zsh `ys` status line: a decorative `#`, then `user @ host` with
// spaces around `@`, then an optional remainder (directory, git, venv, time,
// exit code). Example: `# user @ host in ~ [12:34:56]`. Arbitrary text before
// a bare `$`/`#` still does not match.
const OH_MY_ZSH_YS_HOST_LINE_PATTERN = /^#\s+[^\s@]+\s+@\s+[^\s]+(?:\s+[^\n\r]+)?$/;
// Alpine's default ash PS1 omits the username: `\h:\w\$ ` -> `localhost:~# `.
// Require a hostname and a home/absolute path, excluding prompt delimiters
// within the path so partially entered commands do not count as idle prompts.
@@ -124,12 +132,15 @@ function extractTrailingIdlePrompt(output) {
return lastLine;
}
// Two-line PS1 (`export PS1="\u@\h: \w\n$ "`). Bash emits the newline as
// CR and the PTY's ONLCR adds another, so the bytes are `\r\r\n`. The
// CR-to-LF normalize above turns that into blank lines between the host
// line and `$`/`#`. Keep those blanks in the cache: getFreshIdlePrompt
// uses the same normalize, and hasExpectedPromptSuffix compares this
// CR-as-LF form against live PTY bytes. A lone `$` must not satisfy endsWith.
// Two-line PS1. Classic bash is `export PS1="\u@\h: \w\n$ "`. Oh My Zsh
// `ys` paints `# user @ host ...` and then a bare `$` or `#`. Bash emits
// the newline as CR and the PTY's ONLCR adds another, so the bytes are
// `\r\r\n`. The CR-to-LF normalize above turns that into blank lines
// between the host line and `$`/`#`. Keep those blanks in the cache:
// getFreshIdlePrompt uses the same normalize, and hasExpectedPromptSuffix
// compares this CR-as-LF form against live PTY bytes. A lone `$`/`#` must
// not satisfy endsWith; the previous non-empty line has to be a recognized
// host line (classic `user@host[:path]` or Oh My Zsh `ys`).
if (rightTrimmed === "$" || rightTrimmed === "#") {
let hostIndex = lines.length - 1;
while (hostIndex >= 0 && lines[hostIndex].replace(/\s+$/, "") === "") {
@@ -137,7 +148,13 @@ function extractTrailingIdlePrompt(output) {
}
const previousLine = hostIndex >= 0 ? lines[hostIndex] : "";
const previousRightTrimmed = previousLine.replace(/\s+$/, "");
if (POSIX_PROMPT_HOST_LINE_PATTERN.test(previousRightTrimmed)) {
// Same optional "(name) " env prefix as isDefaultPosixPromptLine (#3947).
// Recognition only; the returned prompt keeps the visible prefix.
const previousHostLine = previousRightTrimmed.replace(/^\([^()\n\r]+\)\s+/, "");
if (
POSIX_PROMPT_HOST_LINE_PATTERN.test(previousHostLine)
|| OH_MY_ZSH_YS_HOST_LINE_PATTERN.test(previousHostLine)
) {
return `${lines.slice(hostIndex).join("\n")}\n${lastLine}`;
}
}
@@ -202,8 +219,9 @@ function trackSessionIdlePrompt(session, chunk) {
// Return `session.lastIdlePrompt` only if the PTY's recent rolling tail
// still ends with it. The cached prompt is updated only when
// extractTrailingIdlePrompt recognizes a known shape (PowerShell, cmd.exe,
// `user@host[:path][#$]`, two-line `user@host[:path]` + `$`/`#`,
// RHEL `[user@host path][#$]`, or Alpine `host:path[#$]`);
// `user@host[:path][#$]`, two-line classic `user@host[:path]` or Oh My Zsh
// `ys` (`# user @ host ...`) + `$`/`#`, RHEL `[user@host path][#$]`, or
// Alpine `host:path[#$]`);
// a remote shell switch into another shell, an oh-my-posh / starship / custom
// PS1, or any unrecognized prompt would
// otherwise leave a stale value behind, which `resolveEffectiveShellKind`
+77
View File
@@ -262,6 +262,83 @@ test("trackSessionIdlePrompt and getFreshIdlePrompt accept a two-line user@host
assert.equal(getFreshIdlePrompt(ptySession), "user@host: /home/foo\n\n\n$ ");
});
test("extractTrailingIdlePrompt recognizes an Oh My Zsh ys two-line idle prompt", () => {
// ys paints `# user @ host ...` then a bare `$` or `#`. Spaces around `@`
// and the leading decorative `#` are required; a lone marker is not a prompt.
assert.equal(
extractTrailingIdlePrompt("# user @ host in ~ [12:34:56]\n$ "),
"# user @ host in ~ [12:34:56]\n$ ",
);
assert.equal(
extractTrailingIdlePrompt("welcome\r\n# user @ host in ~ [12:34:56]\n# "),
"# user @ host in ~ [12:34:56]\n# ",
);
assert.equal(
extractTrailingIdlePrompt("# user @ host in ~/proj on git:main [12:34:56]\n$ "),
"# user @ host in ~/proj on git:main [12:34:56]\n$ ",
);
assert.equal(
extractTrailingIdlePrompt("welcome\r\n# ys @ ys-mbp in ~/.oh-my-zsh on git:master x [21:47:42] C:127\n$ "),
"# ys @ ys-mbp in ~/.oh-my-zsh on git:master x [21:47:42] C:127\n$ ",
);
// Same CR / CRLF blank lines as the classic two-line PS1.
assert.equal(
extractTrailingIdlePrompt("# user @ host in ~ [12:34:56]\r\r\n$ "),
"# user @ host in ~ [12:34:56]\n\n\n$ ",
);
assert.equal(
extractTrailingIdlePrompt("# user @ host in ~ [12:34:56]\r\n# "),
"# user @ host in ~ [12:34:56]\n\n# ",
);
// Optional "(name) " env prefix is stripped only for recognition (#3947).
assert.equal(
extractTrailingIdlePrompt("(venv) # user @ host in ~ [12:34:56]\n$ "),
"(venv) # user @ host in ~ [12:34:56]\n$ ",
);
assert.equal(
extractTrailingIdlePrompt("(venv) user@host: /home/foo\n$ "),
"(venv) user@host: /home/foo\n$ ",
);
assert.equal(extractTrailingIdlePrompt("$ "), "");
assert.equal(extractTrailingIdlePrompt("# "), "");
assert.equal(extractTrailingIdlePrompt("welcome\r\n$ "), "");
assert.equal(extractTrailingIdlePrompt("not a host line\n$ "), "");
assert.equal(extractTrailingIdlePrompt("user @ host in ~\n$ "), "");
assert.equal(extractTrailingIdlePrompt("# user host in ~ [12:34:56]\n$ "), "");
assert.equal(extractTrailingIdlePrompt("# user@host in ~ [12:34:56]\n$ "), "");
assert.equal(extractTrailingIdlePrompt("copied output\n# "), "");
assert.equal(extractTrailingIdlePrompt("# user @ host in ~ [12:34:56]\n$ ls"), "");
assert.equal(extractTrailingIdlePrompt("# user @ host in ~ [12:34:56] $"), "");
assert.equal(extractTrailingIdlePrompt("echo $"), "");
assert.equal(extractTrailingIdlePrompt("price is 5$"), "");
assert.equal(extractTrailingIdlePrompt("(venv) not a host line\n$ "), "");
});
test("trackSessionIdlePrompt and getFreshIdlePrompt accept an Oh My Zsh ys two-line prompt", () => {
const session = {};
const prompt = "# user @ host in ~ [12:34:56]\n$ ";
trackSessionIdlePrompt(session, `welcome\r\n${prompt}`);
assert.equal(getFreshIdlePrompt(session), prompt);
const extra = {};
trackSessionIdlePrompt(extra, "# user @ host in ~/proj on git:main [12:34:56]\r\r\n$ ");
assert.equal(
getFreshIdlePrompt(extra),
"# user @ host in ~/proj on git:main [12:34:56]\n\n\n$ ",
);
const root = {};
trackSessionIdlePrompt(root, "# user @ host in ~ [12:34:56]\n# ");
assert.equal(getFreshIdlePrompt(root), "# user @ host in ~ [12:34:56]\n# ");
const bare = {};
assert.equal(trackSessionIdlePrompt(bare, "$ "), "");
assert.equal(getFreshIdlePrompt(bare), "");
assert.equal(trackSessionIdlePrompt(bare, "not a host line\n$ "), "");
assert.equal(getFreshIdlePrompt(bare), "");
});
test("extractTrailingIdlePrompt still recognizes a classic single-line POSIX prompt", () => {
assert.equal(extractTrailingIdlePrompt("welcome\r\nuser@host:~$ "), "user@host:~$ ");
});
+7
View File
@@ -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,
+3 -14
View File
@@ -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,
@@ -246,6 +234,7 @@ function createBridgeRegistrar(context) {
resolveBrandIcon: (pluginId) => pluginHostService?.contributionIconService?.resolveBrand(pluginId),
resolveContributionIcon: (payload) => pluginHostService?.contributionIconService?.resolve(payload),
themeService: pluginHostService?.themeService,
logService: pluginHostService?.logService,
viewHost: pluginHostService?.viewHost,
env: process.env,
isTrustedSender: createTrustedPluginBridgeSender({ devServerUrl: effectiveDevServerUrl }),
@@ -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 }] }));
+28 -5
View File
@@ -30,6 +30,7 @@ class PluginAIContextService {
this.runtimeContexts = new Map();
this.registrations = [];
this.broker = new ModelInvocationBroker({ resolveDataOwner: this.resolveOwner,
resolveBudgetOwner: (binding) => JSON.stringify([binding.sourceKey, binding.privateOwner]),
selectModel: async (context) => {
await this.#guard(context);
const model = await this.host.selectModel(context);
@@ -95,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 } : {}),
};
},
}));
}
@@ -117,7 +137,10 @@ class PluginAIContextService {
}
const previous = this.runtimeContexts.get(context.runtimeId);
if (previous?.owner === owner && previous.keys === JSON.stringify(selections.map(({ key, version }) => [key, version]))) {
return { items: await this.selected.summaries(context) };
const items = await this.selected.summaries(context);
if (items.length === selections.length) return { items };
// The host has just explicitly selected these resources again. Expired
// or revoked handles stay invalid; issue fresh handles for this selection.
}
this.selected.releaseRuntime(context.runtimeId);
const created = [];
@@ -146,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;
+17 -7
View File
@@ -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);
});
@@ -64,3 +61,16 @@ test("cancelling one host request does not revoke another chat in the same runti
await new Promise(setImmediate); controller.abort(); resolve([]); await cancelled;
assert.equal(f.service.broker.chats.has(chat.chatHandle), true);
});
test("an explicit host re-selection issues fresh handles after identical selected Note summaries expire", async (t) => {
const f = fixture(); t.after(() => f.service.shutdown());
let now = 0; f.service.selected.clock = () => now;
const { items: [old] } = await f.service.list({}, f.context);
now = 300_001;
await assert.rejects(f.service.selected.assertCurrent({ handle: old.handle, version: old.version }, f.context));
const { items } = await f.service.list({}, f.context);
assert.equal(items.length, 1);
assert.notEqual(items[0].handle, old.handle); assert.equal(items[0].expiresAt, 600_001);
await assert.rejects(f.service.selected.assertCurrent({ handle: old.handle, version: old.version }, f.context));
assert.equal(f.reads(), 0); assert.equal(f.authorizations.length, 0); assert.equal(f.invoked(), 0);
});
+23 -7
View File
@@ -28,9 +28,10 @@ function abortable(promise, signal) {
// In-memory, source-isolated handles. No AgentRuntime, tools, chat persistence,
// provider configuration, URLs, credentials or arbitrary system prompts cross it.
class ModelInvocationBroker {
constructor({ resolveDataOwner, selectModel, contexts, clock = Date.now, limits = {} }) {
if (typeof resolveDataOwner !== "function" || typeof selectModel !== "function") throw new TypeError("Host adapters required");
constructor({ resolveDataOwner, resolveBudgetOwner, selectModel, contexts, clock = Date.now, limits = {} }) {
if (typeof resolveDataOwner !== "function" || typeof resolveBudgetOwner !== "function" || typeof selectModel !== "function") throw new TypeError("Host adapters required");
this.resolveDataOwner = resolveDataOwner;
this.resolveBudgetOwner = resolveBudgetOwner;
this.selectModel = selectModel;
this.contexts = contexts;
this.clock = clock;
@@ -54,13 +55,21 @@ class ModelInvocationBroker {
const owner = await this.resolveDataOwner(context);
await context.assertActive();
if (!owner || (typeof owner !== "object" && typeof owner !== "string")) throw fail("permissionDenied", "Host data owner is unavailable");
return { owner, runtimeId: context.runtimeId, pluginVersion: context.pluginVersion };
// Handle identity remains runtime-specific. Budget identity is separately
// supplied by the host and must survive runtime replacement/regrant.
const budgetOwner = await this.resolveBudgetOwner(owner, context);
await context.assertActive();
if (typeof budgetOwner !== "string" || !budgetOwner || Buffer.byteLength(budgetOwner) > 8_192) {
throw fail("permissionDenied", "Host budget owner is unavailable");
}
return { owner, budgetOwner, runtimeId: context.runtimeId, pluginVersion: context.pluginVersion };
}
async #ownedChat(chat, context) {
const binding = await this.#binding(context);
if (this.closed || this.chats.get(chat.handle) !== chat
|| chat.owner !== binding.owner || chat.runtimeId !== binding.runtimeId || chat.pluginVersion !== binding.pluginVersion) {
|| chat.owner !== binding.owner || chat.budgetOwner !== binding.budgetOwner
|| chat.runtimeId !== binding.runtimeId || chat.pluginVersion !== binding.pluginVersion) {
throw fail("permissionDenied", "Plugin chat is unavailable");
}
}
@@ -111,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)
@@ -149,14 +165,14 @@ class ModelInvocationBroker {
}
await this.#guard(turn, context);
this.#prune();
const rate = this.rates.get(chat.owner) ?? { startedAt: this.clock(), count: 0, tokens: 0 };
const rate = this.rates.get(chat.budgetOwner) ?? { startedAt: this.clock(), count: 0, tokens: 0 };
// Reserve conservatively using UTF-8 input bytes plus the output cap. Do
// not refund unknown provider usage after cancellation or disconnect.
const reserved = bytes + 1_024 + this.limits.outputTokens;
if ((!this.rates.has(chat.owner) && this.rates.size >= this.limits.rateOwners) || rate.count >= this.limits.requestsPerMinute || rate.tokens + reserved > this.limits.reservedTokensPerMinute) {
if ((!this.rates.has(chat.budgetOwner) && this.rates.size >= this.limits.rateOwners) || rate.count >= this.limits.requestsPerMinute || rate.tokens + reserved > this.limits.reservedTokensPerMinute) {
throw fail("resourceExhausted", "Model request/token budget reached");
}
rate.count++; rate.tokens += reserved; this.rates.set(chat.owner, rate);
rate.count++; rate.tokens += reserved; this.rates.set(chat.budgetOwner, rate);
await this.#guard(turn, context);
const iterator = await abortable(chat.model.invoke({ prompt, contexts: selected, signal: controller.signal,
maxOutputTokens: this.limits.outputTokens, maxChunkBytes: this.limits.chunkBytes }), controller.signal);
@@ -3,11 +3,12 @@ const assert = require("node:assert/strict");
const test = require("node:test");
const { ModelInvocationBroker } = require("./modelInvocationBroker.cjs");
const { SelectedContextAuthority } = require("../context/selectedContextAuthority.cjs");
const { PluginAISourceBinding } = require("./sourceBinding.cjs");
const { RPC_ERRORS } = require("../rpcRouter.cjs");
const deferred = () => { let resolve; const promise = new Promise((r) => { resolve = r; }); return { promise, resolve }; };
const denied = (error) => error.code === RPC_ERRORS.permissionDenied;
function fixture(options = {}) {
const owner = {}; let currentOwner = owner; let current = true; let active = true; let now = 0;
const owner = { budgetOwner: "synthetic-source-a" }; let currentOwner = owner; let current = true; let active = true; let now = 0;
const request = new AbortController();
const context = { pluginId: "same-id", pluginVersion: "1", runtimeId: "r1", signal: request.signal,
assertActive: async () => { request.signal.throwIfAborted(); if (!active) throw Error("stopped"); } };
@@ -17,7 +18,8 @@ function fixture(options = {}) {
yield { kind: "usage", inputTokens: 7, outputTokens: 2, totalTokens: 9, secret: "DO_NOT_SERIALIZE" }; })(); }, ...options.model };
const resolveDataOwner = async () => currentOwner;
const contexts = new SelectedContextAuthority({ resolveDataOwner, clock: () => now });
const broker = new ModelInvocationBroker({ resolveDataOwner, selectModel: async () => model, contexts,
const broker = new ModelInvocationBroker({ resolveDataOwner, resolveBudgetOwner: (binding) => binding.budgetOwner,
selectModel: async () => model, contexts,
clock: () => now, limits: options.limits });
return { broker, context, contexts, calls, request, owner, model, setOwner: (value) => { currentOwner = value; },
setCurrent: (value) => { current = value; }, setActive: (value) => { active = value; }, setTime: (value) => { now = value; } };
@@ -220,3 +222,65 @@ test("pending model selections reserve capacity and cannot publish after revocat
pending.resolve({ ...f.model, dispose: () => { disposed++; } });
await rejected; assert.equal(f.broker.chats.size, 0); assert.equal(f.broker.openings.size, 0); assert.equal(disposed, 1); f.broker.shutdown();
});
test("same-source runtime replacement preserves minute budgets while distinct sources stay isolated", async (t) => {
for (const limits of [{ requestsPerMinute: 1 }, { reservedTokensPerMinute: 3_074 }]) {
await t.test(Object.keys(limits)[0], async (t) => {
let now = 1_000; let sourceId = "source-a"; let runtimeId = "runtime-1"; let invocations = 0;
const pluginId = "com.example.assistant";
const context = () => ({ pluginId, pluginVersion: "1", runtimeId, assertActive: async () => {} });
const source = () => ({ id: sourceId, kind: "third-party", origin: `https://${sourceId}.example` });
const binding = new PluginAISourceBinding({
database: {
getActivePlugin: () => ({ enabled: true, activeVersion: "1", runtime: { status: "running" }, source: source() }),
getRetainedPluginSource: source,
privateOwner: () => `opaque:${sourceId}:assistant`,
publicPluginId: (owner) => owner === `opaque:${sourceId}:assistant` ? pluginId : null,
},
getRuntimeIdentity: () => ({ ...context(), runtimeKind: "browser" }),
});
const broker = new ModelInvocationBroker({
resolveDataOwner: (caller) => binding.resolve(caller),
resolveBudgetOwner: (owner) => JSON.stringify([owner.sourceKey, owner.privateOwner]),
selectModel: () => ({ label: "Synthetic model", isCurrent: () => true, invoke: () => {
invocations++; return (async function* () {})();
} }), clock: () => now, limits,
});
t.after(() => { broker.shutdown(); binding.shutdown(); });
const start = (chat) => broker.start({ chatHandle: chat.chatHandle, prompt: "Hi" }, context());
const replace = (nextSource) => {
broker.releaseRuntime(runtimeId); binding.releaseRuntime(runtimeId);
sourceId = nextSource; runtimeId = `${runtimeId}-next`;
};
const firstOwner = await binding.resolve(context());
const first = await broker.open(context()); const firstTurn = await start(first);
await broker.stop(firstTurn.turnHandle, context());
replace("source-a");
assert.notEqual(await binding.resolve(context()), firstOwner);
const restarted = await broker.open(context());
await assert.rejects(start(restarted), (error) => error.code === RPC_ERRORS.resourceExhausted);
assert.equal(invocations, 1);
assert.equal(await broker.close(first.chatHandle, context()), false);
replace("source-b");
const other = await broker.open(context()); const otherTurn = await start(other);
await broker.stop(otherTurn.turnHandle, context()); assert.equal(invocations, 2);
replace("source-a");
const returned = await broker.open(context());
now = 60_999;
await assert.rejects(start(returned), (error) => error.code === RPC_ERRORS.resourceExhausted);
now = 61_000;
const afterWindow = await start(returned); assert.equal(invocations, 3);
await broker.stop(afterWindow.turnHandle, context());
});
}
});
test("budget ownership requires an explicit host adapter and never falls back to the plugin ID", async (t) => {
assert.throws(() => new ModelInvocationBroker({ resolveDataOwner: () => ({}), selectModel: () => ({}) }), /Host adapters required/);
for (const budgetOwner of [undefined, "", {}, "x".repeat(8_193)]) {
const f = fixture(); t.after(() => f.broker.shutdown());
f.broker.resolveBudgetOwner = () => budgetOwner;
await assert.rejects(f.broker.open(f.context), denied);
assert.equal(f.calls.length, 0); assert.equal(f.broker.chats.size, 0);
}
});
@@ -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;
+20
View File
@@ -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};
+33 -7
View File
@@ -187,6 +187,8 @@ function assertSameOpenedDirectory(expectedStats, pathStats) {
class PluginFilesystemBroker {
constructor(options = {}) {
this.quotaManager = options.quotaManager ?? null;
this.protectedDirectories = options.protectedDirectories ?? [];
this.protectedFiles = options.protectedFiles ?? [];
this.fileSystem = options.fileSystem ?? fsp;
this.openDirectoryHandle = options.openDirectoryHandle ?? null;
if (this.openDirectoryHandle != null && typeof this.openDirectoryHandle !== "function") {
@@ -205,6 +207,12 @@ class PluginFilesystemBroker {
};
}
validatePath(params) { return assertPathParams(params); }
validateDirectory(params) {
if (typeof this.openDirectoryHandle !== "function") {
throw new PluginRpcError(RPC_ERRORS.unsupported, "Secure plugin directory enumeration is unavailable on this runtime");
}
return assertPathParams(params);
}
describeReadAuthorization(params, resourceKind = "exact") {
const value = assertPathParams(params);
@@ -261,10 +269,33 @@ class PluginFilesystemBroker {
}
}
async #assertWritable(canonical) {
const variants = async (entry) => {
const lexical = path.resolve(entry);
try { return [lexical, await this.fileSystem.realpath(lexical)]; }
catch (error) { if (error?.code !== "ENOENT") throw error; return [lexical]; }
};
for (const file of this.protectedFiles) {
if ((await variants(file)).includes(canonical)) {
throw new PluginRpcError(RPC_ERRORS.permissionDenied, "Host audit storage is protected");
}
}
for (const directory of this.protectedDirectories) {
for (const parent of await variants(directory)) {
const relative = path.relative(parent, canonical);
if (relative === "" || (!path.isAbsolute(relative) && relative !== ".." && !relative.startsWith(`..${path.sep}`))) {
throw new PluginRpcError(RPC_ERRORS.permissionDenied, "Host audit storage is protected");
}
}
}
}
async writeFile(params, context) {
const value = assertSecureWriteMode(assertWriteParams(params));
await this.#assertWritable(path.resolve(value.path));
assertAuthorizedPath(context, "filesystem.write", value.path);
const { canonical, stats } = await resolveWritePath(value.path, this.fileSystem);
await this.#assertWritable(canonical);
assertAuthorizedPath(context, "filesystem.write", canonical);
this.quotaManager?.chargeBytes(context.runtimeId, "filesystem", value.bytes.byteLength);
await context.assertActive();
@@ -278,6 +309,7 @@ class PluginFilesystemBroker {
]);
assertSameOpenedFile(openedStats, stats);
assertSameOpenedFile(openedStats, pathStats);
await this.#assertWritable(canonical);
await context.assertActive();
if (value.overwrite) await handle.truncate(0);
await handle.writeFile(value.bytes);
@@ -303,17 +335,11 @@ class PluginFilesystemBroker {
}
async readDirectory(params, context) {
const value = assertPathParams(params);
const value = this.validateDirectory(params);
assertAuthorizedPath(context, "filesystem.read", value.path);
const { canonical, stats } = await resolveExistingPath(value.path, this.fileSystem);
assertAuthorizedPath(context, "filesystem.read", canonical);
if (!stats.isDirectory()) throw invalidArgument("Plugin filesystem directory target is not a directory");
if (!this.openDirectoryHandle) {
throw new PluginRpcError(
RPC_ERRORS.unsupported,
"Secure plugin directory enumeration is unavailable on this runtime",
);
}
const directory = await this.openDirectoryHandle(canonical, { signal: context.signal });
if (
!directory
@@ -390,3 +390,61 @@ test("filesystem broker rejects oversized and non-canonical payloads", async (co
(error) => error.code === RPC_ERRORS.resourceExhausted,
);
});
test("plugin writes cannot modify host audit files or preferences, including symlink aliases", async (t) => {
const root = createRoot(t);
const directory = path.join(root, "host-logs");
await fsp.mkdir(directory);
const audit = path.join(directory, "runtime.jsonl");
const preferences = path.join(root, "local-logging.json");
await fsp.writeFile(audit, "host audit");
await fsp.writeFile(preferences, "host preferences");
const alias = path.join(root, "alias");
await fsp.symlink(audit, alias);
const broker = new PluginFilesystemBroker({ protectedDirectories: [directory], protectedFiles: [preferences] });
for (const target of [audit, preferences, alias]) {
const authorization = broker.describeWriteAuthorization({ path: target, data: "overwrite", overwrite: true });
await assert.rejects(broker.writeFile({ path: target, data: "overwrite", overwrite: true }, runtimeContext(authorization)), { code: RPC_ERRORS.permissionDenied });
}
assert.equal(await fsp.readFile(audit, "utf8"), "host audit");
assert.equal(await fsp.readFile(preferences, "utf8"), "host preferences");
});
test("host audit protection resolves a symlinked userData directory", async (t) => {
const root = createRoot(t);
const actual = path.join(root, "actual-profile");
await fsp.mkdir(path.join(actual, "logs"), { recursive: true });
const profileAlias = path.join(root, "profile-alias");
await fsp.symlink(actual, profileAlias);
const target = path.join(actual, "logs", "runtime.jsonl");
await fsp.writeFile(target, "host audit");
const broker = new PluginFilesystemBroker({ protectedDirectories: [path.join(profileAlias, "logs")] });
const params = { path: target, data: "overwrite", overwrite: true };
const authorization = broker.describeWriteAuthorization(params);
await assert.rejects(broker.writeFile(params, runtimeContext(authorization)), { code: RPC_ERRORS.permissionDenied });
assert.equal(await fsp.readFile(target, "utf8"), "host audit");
});
test("revocation during the protected-storage check cannot write an unrelated granted file", async (t) => {
const root = createRoot(t);
const logs = path.join(root, "host-logs");
const target = path.join(root, "synthetic.txt");
await fsp.mkdir(logs);
await fsp.writeFile(target, "prior");
let checks = 0;
let active = true;
const broker = new PluginFilesystemBroker({ protectedDirectories: [logs], fileSystem: {
...fsp, async realpath(requested) {
const canonical = await fsp.realpath(requested);
if (requested === logs && ++checks === 3) active = false;
return canonical;
},
} });
const params = { path: target, data: "overwrite", overwrite: true };
const authorization = broker.describeWriteAuthorization(params);
const context = { ...runtimeContext(authorization), async assertActive() {
if (!active) throw Object.assign(new Error("synthetic revocation"), { code: RPC_ERRORS.permissionDenied });
} };
await assert.rejects(broker.writeFile(params, context), { code: RPC_ERRORS.permissionDenied });
assert.equal(await fsp.readFile(target, "utf8"), "prior");
});
@@ -0,0 +1,38 @@
import { definePlugin } from "@netcatty/plugin-sdk";
export default definePlugin({
activate(context) {
const register = (suffix, handler) => context.subscriptions.add(context.commands.registerCommand(
`com.example.local-logging-fixture.${suffix}`, handler,
));
register("emit", () => {
context.logger.info("Synthetic diagnostic for local logging QA", {
apiKey: "SYNTHETIC-API-KEY-NOT-A-CREDENTIAL",
prompt: "SYNTHETIC-PROMPT-BODY",
Notes: "SYNTHETIC-NOTE-BODY",
SQL: "SYNTHETIC-SQL-BODY",
results: "SYNTHETIC-RESULT-BODY",
terminalContent: "SYNTHETIC-TERMINAL-BODY",
pluginId: "synthetic-spoofed-plugin",
sourceId: "official",
runtimeId: "synthetic-spoofed-runtime",
});
return { ok: true, synthetic: true };
});
register("noise", () => {
for (let index = 0; index < 400; index++) console.info(`Synthetic diagnostic burst ${index}`);
return { ok: true, synthetic: true, emitted: 400 };
});
register("denied", async () => {
// No network permission is declared. The host must reject before any
// network I/O or approval prompt; no body or credential is ever supplied.
try {
await context.network.request({ method: "GET", url: "https://logging-fixture.example.com/should-never-be-sent" });
throw new Error("Synthetic undeclared request unexpectedly succeeded");
} catch (error) {
if (error?.code !== "permission_denied" && error?.code !== -32007) throw error;
return { denied: true, synthetic: true };
}
});
},
});
@@ -0,0 +1,29 @@
{
"manifestVersion": 1,
"id": "com.example.local-logging-fixture",
"name": "local-logging-fixture",
"displayName": "Synthetic local logging fixture",
"description": "Synthetic diagnostics and denied-operation checks. No real data or credentials.",
"version": "0.1.0",
"publisher": "example",
"engines": { "netcatty": ">=0.0.0", "api": ">=0.1.0-internal <0.2.0" },
"main": { "browser": "index.js" },
"activationEvents": [
"onCommand:com.example.local-logging-fixture.emit",
"onCommand:com.example.local-logging-fixture.noise",
"onCommand:com.example.local-logging-fixture.denied"
],
"permissions": { "required": ["commands", "menus"], "optional": [] },
"contributes": {
"commands": [
{ "id": "com.example.local-logging-fixture.emit", "title": "Emit synthetic diagnostic", "category": "Logging QA" },
{ "id": "com.example.local-logging-fixture.noise", "title": "Emit synthetic diagnostic burst", "category": "Logging QA" },
{ "id": "com.example.local-logging-fixture.denied", "title": "Test undeclared network denial", "category": "Logging QA" }
],
"menus": [
{ "command": "com.example.local-logging-fixture.emit", "location": "commandPalette" },
{ "command": "com.example.local-logging-fixture.noise", "location": "commandPalette" },
{ "command": "com.example.local-logging-fixture.denied", "location": "commandPalette" }
]
}
}
@@ -231,6 +231,565 @@
},
"additionalProperties": false
},
"HostVaultFeature": {
"const": "vault.host-actions"
},
"VaultHostRef": {
"type": "object",
"required": [
"kind",
"scope",
"handle",
"revision",
"expiresAt"
],
"properties": {
"kind": {
"const": "host.selection"
},
"scope": {
"const": "metadata"
},
"handle": {
"type": "string",
"minLength": 32,
"maxLength": 256
},
"revision": {
"type": "string",
"maxLength": 128,
"pattern": "^[^\\u0000-\\u001f\\u007f]*$",
"minLength": 1
},
"expiresAt": {
"$ref": "#/$defs/SafePositiveInteger"
}
},
"additionalProperties": false,
"description": "Host-signed source/runtime/window-bound metadata selection. Identifies an exact saved host and revision; edit and connect require separate host confirmation and one-use action authority. AI text handles are not Host targets."
},
"VaultHostMetadata": {
"type": "object",
"required": [
"label",
"hostname",
"username",
"group",
"tags",
"protocol"
],
"properties": {
"label": {
"type": "string",
"minLength": 1,
"maxLength": 512
},
"hostname": {
"type": "string",
"minLength": 1,
"maxLength": 1024
},
"username": {
"type": "string",
"maxLength": 512
},
"port": {
"type": "integer",
"minimum": 1,
"maximum": 65535
},
"group": {
"type": "string",
"minLength": 0,
"maxLength": 512
},
"tags": {
"type": "array",
"maxItems": 128,
"uniqueItems": true,
"items": {
"type": "string",
"minLength": 1,
"maxLength": 256
}
},
"protocol": {
"type": "string",
"maxLength": 199,
"pattern": "^[^\\u0000-\\u001f\\u007f]*$",
"minLength": 1
}
},
"additionalProperties": false
},
"VaultHostCreateFields": {
"type": "object",
"required": [
"hostname"
],
"properties": {
"label": {
"type": "string",
"minLength": 1,
"maxLength": 512
},
"hostname": {
"type": "string",
"minLength": 1,
"maxLength": 1024
},
"username": {
"type": "string",
"maxLength": 512
},
"port": {
"type": "integer",
"minimum": 1,
"maximum": 65535
},
"group": {
"type": "string",
"minLength": 0,
"maxLength": 512
},
"tags": {
"type": "array",
"maxItems": 128,
"uniqueItems": true,
"items": {
"type": "string",
"minLength": 1,
"maxLength": 256
}
}
},
"additionalProperties": false
},
"VaultHostEditFields": {
"type": "object",
"required": [],
"properties": {
"label": {
"type": "string",
"minLength": 1,
"maxLength": 512
},
"group": {
"type": "string",
"minLength": 0,
"maxLength": 512
},
"tags": {
"type": "array",
"maxItems": 128,
"uniqueItems": true,
"items": {
"type": "string",
"minLength": 1,
"maxLength": 256
}
}
},
"additionalProperties": false,
"minProperties": 1
},
"VaultSelectHostsParams": {
"type": "object",
"required": [
"title"
],
"properties": {
"title": {
"type": "string",
"maxLength": 128,
"pattern": "^[^\\u0000-\\u001f\\u007f]*$",
"minLength": 1
}
},
"additionalProperties": false
},
"VaultHostDraftParams": {
"oneOf": [
{
"type": "object",
"required": [
"draft"
],
"properties": {
"draft": {
"$ref": "#/$defs/VaultHostCreateFields"
}
},
"additionalProperties": false
},
{
"type": "object",
"required": [
"target",
"expectedRevision",
"draft"
],
"properties": {
"target": {
"$ref": "#/$defs/VaultHostRef"
},
"expectedRevision": {
"type": "string",
"maxLength": 128,
"pattern": "^[^\\u0000-\\u001f\\u007f]*$",
"minLength": 1
},
"draft": {
"$ref": "#/$defs/VaultHostEditFields"
}
},
"additionalProperties": false
}
]
},
"VaultHostConnectParams": {
"type": "object",
"required": [
"target",
"expectedRevision"
],
"properties": {
"target": {
"$ref": "#/$defs/VaultHostRef"
},
"expectedRevision": {
"type": "string",
"maxLength": 128,
"pattern": "^[^\\u0000-\\u001f\\u007f]*$",
"minLength": 1
}
},
"additionalProperties": false
},
"VaultHostActionRequest": {
"oneOf": [
{
"type": "object",
"required": [
"kind",
"title"
],
"properties": {
"kind": {
"const": "vault.selectHosts"
},
"title": {
"type": "string",
"maxLength": 128,
"pattern": "^[^\\u0000-\\u001f\\u007f]*$",
"minLength": 1
}
},
"additionalProperties": false
},
{
"type": "object",
"required": [
"kind",
"draft"
],
"properties": {
"kind": {
"const": "vault.requestHostDraft"
},
"draft": {
"$ref": "#/$defs/VaultHostCreateFields"
}
},
"additionalProperties": false
},
{
"type": "object",
"required": [
"kind",
"target",
"expectedRevision",
"draft"
],
"properties": {
"kind": {
"const": "vault.requestHostDraft"
},
"target": {
"$ref": "#/$defs/VaultHostRef"
},
"expectedRevision": {
"type": "string",
"maxLength": 128,
"pattern": "^[^\\u0000-\\u001f\\u007f]*$",
"minLength": 1
},
"draft": {
"$ref": "#/$defs/VaultHostEditFields"
}
},
"additionalProperties": false
},
{
"type": "object",
"required": [
"kind",
"target",
"expectedRevision"
],
"properties": {
"kind": {
"const": "vault.requestConnect"
},
"target": {
"$ref": "#/$defs/VaultHostRef"
},
"expectedRevision": {
"type": "string",
"maxLength": 128,
"pattern": "^[^\\u0000-\\u001f\\u007f]*$",
"minLength": 1
}
},
"additionalProperties": false
}
]
},
"VaultHostSelectedResources": {
"description": "Trusted host-renderer reply only. Never accepted from a plugin request.",
"type": "array",
"maxItems": 50,
"items": {
"type": "object",
"required": [
"resourceId",
"revision",
"label",
"hostname",
"username",
"group",
"tags",
"protocol"
],
"properties": {
"resourceId": {
"type": "string",
"maxLength": 256,
"pattern": "^[^\\u0000-\\u001f\\u007f]*$",
"minLength": 1
},
"revision": {
"type": "string",
"maxLength": 128,
"pattern": "^[^\\u0000-\\u001f\\u007f]*$",
"minLength": 1
},
"label": {
"type": "string",
"minLength": 1,
"maxLength": 512
},
"hostname": {
"type": "string",
"minLength": 1,
"maxLength": 1024
},
"username": {
"type": "string",
"maxLength": 512
},
"port": {
"type": "integer",
"minimum": 1,
"maximum": 65535
},
"group": {
"type": "string",
"minLength": 0,
"maxLength": 512
},
"tags": {
"type": "array",
"maxItems": 128,
"uniqueItems": true,
"items": {
"type": "string",
"minLength": 1,
"maxLength": 256
}
},
"protocol": {
"type": "string",
"maxLength": 199,
"pattern": "^[^\\u0000-\\u001f\\u007f]*$",
"minLength": 1
}
},
"additionalProperties": false
}
},
"VaultHostSelectedResult": {
"type": "object",
"required": [
"cancelled",
"hosts",
"groups"
],
"properties": {
"cancelled": {
"const": false
},
"hosts": {
"type": "array",
"maxItems": 50,
"items": {
"type": "object",
"required": [
"target",
"metadata"
],
"properties": {
"target": {
"$ref": "#/$defs/VaultHostRef"
},
"metadata": {
"$ref": "#/$defs/VaultHostMetadata"
}
},
"additionalProperties": false
}
},
"groups": {
"type": "array",
"maxItems": 50,
"items": {
"type": "object",
"required": [
"path",
"members"
],
"properties": {
"path": {
"type": "string",
"maxLength": 512,
"pattern": "^[^\\u0000-\\u001f\\u007f]*$",
"minLength": 1
},
"members": {
"type": "array",
"maxItems": 50,
"items": {
"$ref": "#/$defs/VaultHostRef"
}
}
},
"additionalProperties": false
}
}
},
"additionalProperties": false
},
"VaultHostSelectionResult": {
"oneOf": [
{
"type": "object",
"required": [
"cancelled"
],
"properties": {
"cancelled": {
"const": true
}
},
"additionalProperties": false
},
{
"$ref": "#/$defs/VaultHostSelectedResult"
}
]
},
"VaultHostActionAccepted": {
"type": "object",
"required": [
"cancelled",
"status"
],
"properties": {
"cancelled": {
"const": false
},
"status": {
"enum": [
"saved",
"requested"
]
}
},
"additionalProperties": false
},
"VaultHostActionResult": {
"oneOf": [
{
"type": "object",
"required": [
"cancelled"
],
"properties": {
"cancelled": {
"const": true
}
},
"additionalProperties": false
},
{
"$ref": "#/$defs/VaultHostActionAccepted"
}
]
},
"VaultHostLocalTarget": {
"description": "Trusted host renderer target only; resource IDs never cross the plugin API.",
"type": "object",
"required": [
"resourceId",
"revision"
],
"properties": {
"resourceId": {
"type": "string",
"maxLength": 256,
"pattern": "^[^\\u0000-\\u001f\\u007f]*$",
"minLength": 1
},
"revision": {
"type": "string",
"maxLength": 128,
"pattern": "^[^\\u0000-\\u001f\\u007f]*$",
"minLength": 1
}
},
"additionalProperties": false
},
"HostInteractionReceipt": {
"oneOf": [
{
"type": "boolean"
},
{
"type": "object",
"required": [
"completionToken"
],
"properties": {
"completionToken": {
"type": "string",
"minLength": 32,
"maxLength": 256
}
},
"additionalProperties": false
}
]
},
"HostInteractionLimits": {
"const": {
"textBytes": 4096,
@@ -440,6 +999,12 @@
}
},
"additionalProperties": false
},
{
"$ref": "#/$defs/VaultHostSelectedResult"
},
{
"$ref": "#/$defs/VaultHostActionAccepted"
}
]
},
@@ -465,13 +1030,32 @@
"enum": [
"cancel",
"preview",
"submit"
"submit",
"commit",
"complete",
"fail"
]
},
"value": {
"type": "string",
"maxLength": 4096,
"pattern": "^[^\\u0000]*$"
},
"resources": {
"$ref": "#/$defs/VaultHostSelectedResources"
},
"draft": {
"oneOf": [
{
"$ref": "#/$defs/VaultHostCreateFields"
},
{
"$ref": "#/$defs/VaultHostEditFields"
}
]
},
"scriptsRun": {
"type": "boolean"
}
},
"additionalProperties": false
@@ -515,6 +1099,13 @@
"request"
],
"properties": {
"permissionMode": {
"enum": [
"allow",
"ask",
"deny"
]
},
"requestId": {
"type": "string",
"maxLength": 128
@@ -535,11 +1126,19 @@
"phase": {
"enum": [
"request",
"preview"
"preview",
"commit"
]
},
"request": {
"$ref": "#/$defs/HostInteractionRequest"
"oneOf": [
{
"$ref": "#/$defs/HostInteractionRequest"
},
{
"$ref": "#/$defs/VaultHostActionRequest"
}
]
},
"previewText": {
"type": "string",
@@ -560,6 +1159,19 @@
"type": "string",
"minLength": 1,
"maxLength": 160
},
"vaultTarget": {
"$ref": "#/$defs/VaultHostLocalTarget"
},
"reviewedDraft": {
"oneOf": [
{
"$ref": "#/$defs/VaultHostCreateFields"
},
{
"$ref": "#/$defs/VaultHostEditFields"
}
]
}
},
"additionalProperties": false
@@ -5169,6 +5781,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,
@@ -6476,6 +7158,9 @@
"TerminalInputDraftFeature": {
"const": "terminal.input-drafts"
},
"FilesystemDirectoryEnumerationFeature": {
"const": "filesystem.directory-enumeration"
},
"HostTerminalInputProposalRequest": {
"type": "object",
"required": [
@@ -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" }) });
+78 -12
View File
@@ -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"] } : {}),
};
}
@@ -74,6 +75,7 @@ class PluginHostInteractionService {
this.admissionRevision = 0;
this.focusRestoreMs = Math.min(options.focusRestoreMs ?? 1_000, 1_000);
this.owner = null;
this.ownerGeneration = 0;
this.ownerCleanup = null;
this.closed = false;
this.revocation = this.permissionEngine.onDidRevoke((event) => this.releasePlugin(event.pluginId));
@@ -93,17 +95,23 @@ class PluginHostInteractionService {
metadata: { capability: "ui", mutating: true },
validateParams: assertInteractionRequest,
authorization: (params, context) => {
const sender = this.#admit(params, context);
let guard = this.admissionGuards.get(context.runtimeId);
if (!guard) { guard = { active: true }; this.admissionGuards.set(context.runtimeId, guard); }
const terminalTarget = params.kind === "terminal.selection.read" ? this.terminalSelection.capture(sender, context)
: params.kind === "terminal.input.propose" ? this.terminalSelection.resolve(params.handle, context) : undefined;
this.admissions.set(params, { sender, runtimeId: context.runtimeId, revision: this.admissionRevision, guard, terminalTarget });
return interactionAuthorization(params, terminalTarget);
const admission = this.admitRequest(params, context);
return interactionAuthorization(params, admission.terminalTarget);
},
});
}
admitRequest(params, context) {
const sender = this.#admit(params, context);
let guard = this.admissionGuards.get(context.runtimeId);
if (!guard) { guard = { active: true }; this.admissionGuards.set(context.runtimeId, guard); }
const terminalTarget = params.kind === "terminal.selection.read" ? this.terminalSelection.capture(sender, context)
: params.kind === "terminal.input.propose" ? this.terminalSelection.resolve(params.handle, context) : undefined;
const admission = { sender, runtimeId: context.runtimeId, revision: this.admissionRevision, guard, terminalTarget };
this.admissions.set(params, admission);
return admission;
}
setReady(sender, ready) {
const window = this.getWindow();
if (!sender || window?.webContents !== sender || sender.isDestroyed?.()) {
@@ -115,6 +123,7 @@ class PluginHostInteractionService {
if (this.owner === sender) return true;
if (this.owner) this.releaseWindow(this.owner);
this.owner = sender;
this.ownerGeneration++;
const close = () => this.releaseWindow(sender);
// A native permission dialog can temporarily blur its parent. Already
// displayed interactions still cancel immediately; pre-prompt admission
@@ -166,8 +175,11 @@ 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) : {}),
...(entry.reviewedDraft === undefined ? {} : { reviewedDraft: entry.reviewedDraft }),
});
}
@@ -251,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"));
@@ -263,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")); }
});
}
@@ -289,12 +322,36 @@ class PluginHostInteractionService {
this.#audit(entry, "cancelled");
return this.#finish(entry, { cancelled: true });
}
if (this.vaultService?.isRequest(entry.params)) {
const outcome = await this.vaultService.respond(entry, response);
await entry.context.assertActive();
this.#assertWindow(sender);
if (this.pending.get(entry.id) !== entry) return false;
if (outcome.result) {
this.#audit(entry, "completed");
return this.#finish(entry, outcome.result);
}
this.#audit(entry, outcome.phase === "commit" ? "action-confirmed" : "action-claimed");
entry.phase = outcome.phase;
if (outcome.reviewedDraft !== undefined) entry.reviewedDraft = outcome.reviewedDraft;
if (entry.phase === "executing") {
entry.actionToken = this.#token(entry);
return { completionToken: entry.actionToken };
}
this.#present(entry);
return true;
}
if (entry.params.kind === "terminal.selection.read" && entry.phase === "request") {
if (response.action !== "preview") throw fail("invalidArgument", "Terminal sharing requires a preview action");
const text = assertTextBytes(response.value);
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") {
@@ -345,6 +402,8 @@ class PluginHostInteractionService {
if (this.owner !== sender) return;
this.terminalSelection.releaseWindow(sender);
this.admissionRevision++;
this.ownerGeneration++;
this.vaultService?.clearSelections();
this.owner = null;
this.ownerCleanup?.(); this.ownerCleanup = null;
for (const entry of [...this.pending.values()]) {
@@ -354,6 +413,7 @@ class PluginHostInteractionService {
releasePlugin(pluginId) {
this.terminalSelection.releasePlugin(pluginId);
this.admissionRevision++;
this.vaultService?.releasePlugin(pluginId);
for (const entry of [...this.pending.values()]) {
if (entry.context.pluginId === pluginId) this.#finish(entry, null, fail("cancelled", "Plugin access was revoked"));
}
@@ -362,6 +422,7 @@ class PluginHostInteractionService {
this.terminalSelection.releaseRuntime(runtimeId);
this.cancelRuntimeRequests(runtimeId, "Plugin runtime stopped");
this.rates.delete(runtimeId);
this.vaultService?.releaseRuntime(runtimeId);
}
cancelRuntimeRequests(runtimeId, reason = "Host command finished") {
const guard = this.admissionGuards.get(runtimeId);
@@ -372,7 +433,11 @@ class PluginHostInteractionService {
}
}
cancelAll(invalidateAdmission = true) {
if (invalidateAdmission) { this.admissionRevision++; this.terminalSelection.clearHandles(); }
if (invalidateAdmission) {
this.admissionRevision++;
this.terminalSelection.clearHandles();
this.vaultService?.clearSelections();
}
for (const entry of [...this.pending.values()]) this.#finish(entry, null, fail("cancelled", "Host interaction was cancelled"));
}
shutdown() {
@@ -383,6 +448,7 @@ class PluginHostInteractionService {
this.revocation.dispose(); this.unsubscribeLock?.();
this.terminalInvalidation.dispose(); this.terminalSelection.shutdown();
this.key.fill(0);
this.vaultService?.shutdown();
}
}
@@ -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/);
+84 -38
View File
@@ -171,50 +171,96 @@ 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();
};
await assertActive();
const validatedParams = registration.validateParams
? registration.validateParams(params)
: params;
if (validatedParams && typeof validatedParams.then === "function") {
throw new TypeError("Plugin host RPC parameter validators must be synchronous");
}
const authorizationContext = Object.freeze({
...transportContext,
...identity,
assertActive,
kind,
method,
metadata: registration.metadata,
params: validatedParams,
});
const resolvedAuthorization = typeof registration.authorization === "function"
? await registration.authorization(validatedParams, authorizationContext)
: registration.authorization;
const authorization = resolvedAuthorization == null
? null
: cloneAndFreezeMetadata(resolvedAuthorization);
await assertActive();
const context = Object.freeze({
...authorizationContext,
authorization,
});
let index = -1;
const dispatch = async (nextIndex) => {
if (nextIndex <= index) throw new Error("Plugin host RPC middleware called next() more than once");
index = nextIndex;
const current = middleware[nextIndex];
if (current) return current(context, () => dispatch(nextIndex + 1));
const startedAt = Date.now();
let authorizationOutcome = "not-reached";
let auditResources;
let auditResult;
let auditError;
const authorizationCleanup = [];
try {
await assertActive();
return registration.handler(validatedParams, context);
};
const result = await dispatch(0);
await assertActive();
return result;
const validatedParams = registration.validateParams
? registration.validateParams(params)
: params;
if (validatedParams && typeof validatedParams.then === "function") {
throw new TypeError("Plugin host RPC parameter validators must be synchronous");
}
const authorizationContext = Object.freeze({
...transportContext,
...identity,
signal,
permissionOperation,
addAuthorizationGuard: (guard) => authorizationGuards.push(guard),
cancelAuthorization: (reason) => operationController.abort(reason),
assertActive,
kind,
method,
metadata: registration.metadata,
recordAuthorization: (outcome) => {
if (["allowed", "denied", "not-required"].includes(outcome)) authorizationOutcome = outcome;
},
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;
const authorization = resolvedAuthorization == null
? null
: cloneAndFreezeMetadata(resolvedAuthorization);
auditResources = authorization?.resources;
await assertActive();
const context = Object.freeze({
...authorizationContext,
authorization,
});
let index = -1;
const dispatch = async (nextIndex) => {
if (nextIndex <= index) throw new Error("Plugin host RPC middleware called next() more than once");
index = nextIndex;
const current = middleware[nextIndex];
if (current) return current(context, () => dispatch(nextIndex + 1));
await assertActive();
return registration.handler(validatedParams, context);
};
const result = await dispatch(0);
await assertActive();
auditResult = result;
return result;
} catch (error) {
auditError = error;
throw error;
} finally {
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);
if (Buffer.isBuffer(value) || value instanceof Uint8Array) return value.byteLength;
return null;
};
const field = (value, key) => value && typeof value === "object"
? Object.getOwnPropertyDescriptor(value, key)?.value : undefined;
try { identity.logger?.audit?.({ operation: method, resources: auditResources,
authorization: authorizationOutcome, result: auditError ? "error" : "ok",
errorCode: auditError?.code, durationMs: Date.now() - startedAt,
inputBytes: dataBytes(field(params, "data")), outputBytes: dataBytes(field(auditResult, "data")) }); } catch {}
}
};
const requestHandlers = Object.freeze(Object.fromEntries(
[...this.requests].map(([method, registration]) => [
+2 -1
View File
@@ -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");
});
Loaded 100 of 151 files, more files were not shown because too many files have changed in this diff. Show more