Compare commits

...
Author SHA1 Message Date
陈奇 37df67256e feat(examples): prepare fixed public Rfam acceptance candidate 2026-10-26 17:39:17 +00:00
陈奇 110448afb8 Merge late transport lifecycle and example checks into integration 2026-10-26 17:29:55 +00:00
陈大猫 f1410f8bb1 feat(plugins): validate installed stream example and integrate final source isolation 2026-10-27 01:19:32 +08:00
陈奇 6a4fb178cd docs(plugins): provide reproducible samples and native integration handoff 2026-10-26 17:04:42 +00:00
陈奇 e6f44460ac fix(plugins): normalize local peers and settle cancelled stream credit 2026-10-26 17:03:15 +00:00
陈奇 fffe5a9fca Merge connected plugin AI integration without reviewing its internals 2026-10-26 16:52:55 +00:00
陈奇 7189864c7c Merge source identity history into byte stream integration 2026-10-26 16:52:55 +00:00
陈奇 173ef5abbb docs(plugin-ai): retain red totals and record shared blocker repair 2026-10-26 16:25:16 +00:00
陈奇 0ba3ea6069 fix(plugin): describe scoped AI and stream permissions 2026-10-26 16:23:53 +00:00
陈奇 06dd46f94f docs(plugin-ai): record connected host QA and native handoff 2026-10-26 16:20:50 +00:00
陈奇 42a9826603 fix(plugin-ai): yield native modal views to host context selection 2026-10-26 16:15:14 +00:00
陈奇 da0c79ff0d fix(example-assistant): import URL in the package build tool 2026-10-26 16:03:01 +00:00
陈奇 a6e0cb8995 feat(plugin-ai): connect canonical trusted host IPC and app UI 2026-10-26 15:51:05 +00:00
陈奇 bb2fc729b8 Merge commit 'c4433d4961ecf4a81bea92b0a14f47b4ec12f679' into feat/plugin-ai-context 2026-10-26 15:35:13 +00:00
陈奇 c29fc95531 docs(plugin-ai): define read-only context authority boundary 2026-10-26 15:30:52 +00:00
陈奇 b0386a5769 test(plugin-ai): isolate the timeout fixture from normal reply tests 2026-10-26 15:29:04 +00:00
陈奇 dc45d03c7b test(plugin-ai): verify installed assistant and pending selection teardown 2026-10-26 15:21:35 +00:00
陈奇 9290debe91 fix(plugin-ai): cancel active resources when source or host guards fail 2026-10-26 15:09:15 +00:00
陈奇 6cf931ac4d feat(plugin-ai): implement trusted renderer selection and model lifecycle 2026-10-26 15:07:38 +00:00
陈大猫 c4433d4961 feat(plugins): integrate source-bound byte streams and additive AI contract 2026-10-26 23:04:54 +08:00
Codex 4213d8e672 fix(plugins): distinguish third-party catalog status copy 2026-10-26 14:48:46 +00:00
陈奇 264f1d983c feat(plugin-ai): define registered service and assistant runtime adapters 2026-10-26 14:47:58 +00:00
陈奇 35e3f9f99f Merge branch 'feat/plugin-ai-context-core' into feat/plugin-ai-context 2026-10-26 14:47:58 +00:00
陈奇 4882e0501c fix(plugin-ai): validate context references before publishing turns 2026-10-26 14:46:37 +00:00
陈奇 c3a5f810ce fix(plugin-ai): validate context references before publishing turns 2026-10-26 14:46:30 +00:00
Codex 59721f9717 fix(plugins): pin sync owners across source lifecycle awaits 2026-10-26 14:37:25 +00:00
陈大猫 dd41f9d8d0 feat(plugins): prepare bounded byte transport and database fixture 2026-10-26 22:28:28 +08:00
陈奇 6392ac9532 feat(plugin-ai): reuse host model transport and add assistant panel fixture 2026-10-26 14:25:34 +00:00
陈奇 afe831bfc5 feat(plugin-ai): add source-bound model and selected context brokers 2026-10-26 14:25:18 +00:00
Codex ab8764a317 Merge reviewed theme security color aliases into source integration 2026-10-26 14:06:02 +00:00
Codex 7e72ae65ad test(plugins): assert catalog operations bind official source 2026-10-26 14:02:00 +00:00
Codex f2e878858f test(plugins): drain dialog autofocus cleanup before restoring DOM globals 2026-10-26 13:49:24 +00:00
Codex 2ff86c2217 fix(plugins): restore opener focus after source and install dialogs 2026-10-26 13:42:31 +00:00
Codex 5e6278cbc7 fix(plugins): distinguish installed packages from mirrored source entries 2026-10-26 13:34:59 +00:00
Codex 83952aac0d fix(plugins): resolve source-scoped credential owners for provider recovery 2026-10-26 13:31:36 +00:00
Codex 7ec6473112 fix(plugins): retain local provenance through source schema upgrades 2026-10-26 13:28:34 +00:00
Codex 17e6834f74 test(plugins): include source binding in catalog interaction fixtures 2026-10-26 13:19:46 +00:00
Codex 9ed8bf76dd feat(plugins): bind third-party markets to isolated source identities 2026-10-26 13:15:52 +00:00
陈奇 91b8927c56 fix(plugin-themes): pin utility color aliases in security surfaces 2026-10-26 13:07:03 +00:00
Codex 1e5e77fb9c merge: integrate plugin static distribution and capability theme stack 2026-10-26 12:34:54 +00:00
陈奇 f868221b5f fix(plugin-themes): validate text across shared neutral surfaces 2026-10-26 12:07:46 +00:00
陈奇 fd45463d98 test(transfers): avoid assuming concurrent file-preflight order 2026-10-26 12:07:46 +00:00
陈奇 fdeeaf74bb feat(plugins): add validated data-only host color themes 2026-10-26 11:46:08 +00:00
陈奇 ffabb9e9c7 Merge terminal sidebar navigation fix and projection regression 2026-10-26 11:17:24 +00:00
陈奇 733f44cfdc test(plugin-sidebar): cover production bridge callback projection 2026-10-26 11:10:11 +00:00
陈奇 a8adeb86fe fix(plugin-sidebar): forward production terminal navigation actions 2026-10-26 11:06:50 +00:00
陈奇 0c097a1260 docs(plugins): record bounded host workflow native acceptance 2026-10-26 10:29:12 +00:00
陈奇 5c9286775e feat(plugins): host extension pages in terminal split panes 2026-10-26 10:27:00 +00:00
陈奇 618257e593 Merge branch 'integration/plugin-capabilities-ui' into feat/plugin-vault-pages 2026-10-26 09:59:53 +00:00
陈奇 cde2be236e Merge branch 'feat/plugin-host-interactions' into integration/plugin-capabilities-ui 2026-10-26 09:59:43 +00:00
陈奇 342941b618 Merge branch 'integration/plugin-capabilities-ui' into feat/plugin-vault-pages
# Conflicts:
#	electron/plugins/hostService.cjs
#	packages/plugin-cli/src/manifest.ts
#	packages/plugin-contract/src/index.ts
#	packages/plugin-sdk/src/index.ts
2026-10-26 09:59:42 +00:00
陈奇 8e4dd0c052 fix(plugins): pin every command to its activation runtime 2026-10-26 09:59:34 +00:00
陈奇 3a96cd2537 Merge branch 'feat/plugin-host-interactions' into integration/plugin-capabilities-ui 2026-10-26 09:56:42 +00:00
陈奇 0a692dfc72 fix(plugins): bound interactive commands and cancel orphan host prompts 2026-10-26 09:53:25 +00:00
陈奇 1d369bf322 fix(plugins): ignore withdrawn page layout failures 2026-10-26 09:45:04 +00:00
陈奇 0d77a2e56c feat(plugins): mount sandbox extension pages inside Vault 2026-10-26 09:32:00 +00:00
陈奇 5f06fd3cec Merge branch 'feat/plugin-host-interactions' into integration/plugin-capabilities-ui 2026-10-26 09:08:50 +00:00
陈奇 d4ca123999 fix(plugins): preserve input fidelity and native permission focus handoff 2026-10-26 09:06:53 +00:00
陈奇 3ac4772087 chore(plugins): integrate reviewed manager polish with capability layers 2026-10-26 08:54:08 +00:00
陈奇 71d455d5de feat(plugins): add host text dialogs and explicit clipboard sharing 2026-10-26 08:05:02 +00:00
陈奇 ff3734d161 fix(plugins): refresh actual state after failed grant revocation 2026-10-26 07:39:37 +00:00
陈奇 185eac3849 fix(plugins): finish revocation cleanup and identify inactive saved grants 2026-10-26 07:18:21 +00:00
陈奇 9af123e5d7 feat(plugins): inspect and revoke grants and align network lease contracts 2026-10-26 07:03:14 +00:00
260 changed files with 17984 additions and 294 deletions

No files matched your search

+1
View File
@@ -53,6 +53,7 @@ jobs:
run: |
npm run check:plugin-contract
npm run test:plugin-runtime
npm run test:plugin-examples
node --test --import tsx components/settings/tabs/Plugin*.test.tsx
- name: Test
+2
View File
@@ -12,6 +12,7 @@ import { ToastProvider } from './components/ui/toast';
import { TooltipProvider } from './components/ui/tooltip';
import { ScriptAutomationRoot } from './components/scripts/ScriptAutomationRoot';
import { ExternalMcpApprovalsHost } from './components/ai/ExternalMcpApprovalsHost';
import { PluginHostInteractionHost } from './components/plugins/PluginHostInteractionHost';
import { PluginAuthenticationHost } from './components/plugins/PluginAuthenticationHost';
import { MultilinePasteConfirmHost } from './components/terminal/MultilinePasteConfirmDialog';
import { useExternalMcpGrantPersister } from './components/ai/useExternalMcpGrantPersister';
@@ -86,6 +87,7 @@ function AppWithProviders({ settings, appLock }: { settings: SettingsState; appL
<ScriptAutomationRoot />
<ExternalMcpApprovalsHost />
<PluginAuthenticationHost />
<PluginHostInteractionHost />
<MultilinePasteConfirmHost />
<VaultPublisher>
<SessionPublisher persistSessionRestore={!isPeerSessionWindow}>
+4 -1
View File
@@ -26,6 +26,7 @@ import { openQuickConnectFromInput } from '../quickConnectBridge';
import { AppHostTreeLayer } from './AppHostTreeLayer';
import { AppHostEditorLayer } from './AppHostEditorLayer';
import { AppPluginKeybindingHost } from './AppPluginKeybindingHost';
import { PluginAIContextHost } from '../../components/plugins/PluginAIContextHost';
import { shouldOpenHostEditOnWorkSurface } from './workTabSurface';
import { useConnectionLogsStore } from '../state/connectionLogsStore';
import {
@@ -167,7 +168,8 @@ function AppPluginKeybindingThemeHost({
sessions: any[];
workspaces: any[];
}) {
const appThemeStyle = useAppThemeStyle();
// View environment is global in the current host: publish committed colors only.
const appThemeStyle = useAppThemeStyle(false);
const { resolvedTheme, uiLanguage } = useSettingsChromeStore();
const pluginThemeTokens = useMemo(
() => selectPluginThemeTokens(appThemeStyle as Record<string, unknown>),
@@ -855,6 +857,7 @@ function AppViewInner({ domains }: AppViewProps) {
sessions={sessions}
workspaces={workspaces}
/>
<PluginAIContextHost sessions={sessions} workspaces={workspaces} />
</div>
{/* Global "quick add / edit snippet" modal, triggered by the
+6 -2
View File
@@ -1,3 +1,4 @@
import { usePluginThemeRevision } from '../state/usePluginThemeRuntime';
import type React from 'react';
import { useMemo } from 'react';
@@ -12,17 +13,20 @@ import { getUiThemeById } from '../../infrastructure/config/uiThemes';
* settingsChromeStore so accent drags only re-render the leaf that applies the
* vars, never the App shell.
*/
export function useAppThemeStyle(): React.CSSProperties {
export function useAppThemeStyle(includePreview = true): React.CSSProperties {
const pluginThemeRevision = usePluginThemeRevision();
const { accentMode, customAccent } = useAppearanceChromeStore();
const { resolvedTheme, darkUiThemeId, lightUiThemeId } = useSettingsChromeStore();
return useMemo(() => {
void pluginThemeRevision; // getUiThemeById reads this external registry revision.
const tokens = getUiThemeById(
resolvedTheme,
resolvedTheme === 'dark' ? darkUiThemeId : lightUiThemeId,
includePreview,
).tokens;
return {
...buildAppThemeCssVars(tokens, accentMode, customAccent),
colorScheme: resolvedTheme,
} as React.CSSProperties;
}, [accentMode, customAccent, darkUiThemeId, lightUiThemeId, resolvedTheme]);
}, [accentMode, customAccent, darkUiThemeId, lightUiThemeId, resolvedTheme, pluginThemeRevision, includePreview]);
}
+65
View File
@@ -1245,16 +1245,62 @@ Highlight the focused split pane:
"settings.plugins.manager.disable": "Disable",
"settings.plugins.manager.remove": "Uninstall",
"settings.plugins.manager.removeNote": "Uninstall removes package code and local KV data. User-owned settings, secrets and sync sidecars are retained.",
"settings.plugins.permissions.statusUnknown": "Status unknown",
"plugins.sidebar.extensions": "Extension pages",
"plugins.sidebar.selectTerminal": "Select an open terminal first.",
"plugins.sidebar.loading": "Loading extension page…",
"plugins.vault.loading": "Loading plugin page…",
"plugins.vault.extensions": "Extensions",
"plugins.vault.back": "Back to hosts",
"plugins.interaction.title": "Plugin request",
"plugins.interaction.sharing": "Text you share becomes available to this plugin and its granted capabilities, including network access. Reading does not approve a new network destination.",
"plugins.interaction.input": "Input text",
"plugins.interaction.filter": "Filter choices",
"plugins.interaction.preview": "Preview clipboard",
"plugins.interaction.previewNote": "The clipboard is read only after you choose Preview. You can then inspect the text and separately confirm sharing it.",
"plugins.interaction.previewAction": "Preview clipboard",
"plugins.interaction.share": "Share with plugin",
"plugins.interaction.copy": "Copy to clipboard",
"plugins.interaction.failed": "This request expired or could not be completed.",
"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.refresh": "Refresh",
"settings.plugins.permissions.loading": "Loading grants...",
"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.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.noPermissions": "No privileged permissions declared.",
"settings.plugins.manager.required": "Required",
"settings.plugins.manager.optional": "Optional",
"settings.plugins.themes.title": "Plugin themes",
"settings.plugins.themes.description": "Preview changes only this window. Apply saves this device’s selection. Personal accent, fonts and terminal overrides stay in control.",
"settings.plugins.themes.empty": "No enabled plugin themes for this mode.",
"settings.plugins.themes.choose": "Choose a theme",
"settings.plugins.themes.unavailable": "Saved theme unavailable; using the host default",
"settings.plugins.themes.applied": "Applied",
"settings.plugins.themes.preview": "Preview",
"settings.plugins.themes.apply": "Apply",
"settings.plugins.themes.cancel": "Cancel preview",
"settings.plugins.themes.restore": "Restore default",
"settings.plugins.themes.invalid": "Some theme palettes were rejected or unavailable.",
"settings.plugins.themes.failed": "Could not apply theme.",
"settings.plugins.manager.dataTheme": "Theme pack: validated colors, no executable runtime.",
"settings.plugins.manager.browser": "Browser runtime: privileged access goes through host permission brokers.",
"settings.plugins.manager.advanced": "Advanced runtime: full Node filesystem and network authority. Broker permissions cannot restrict this access. Development preview only.",
"settings.plugins.manager.data": "Local KV stays on this device. Only non-secret settings explicitly marked for sync participate in encrypted cloud sidecars. Packages and permission grants do not sync.",
"settings.plugins.market.results": "{count} plugins",
"settings.plugins.manager.lastError": "Last recorded error",
"settings.plugins.sources.offline": "Source unavailable. If a previously validated catalog exists for this source, it remains available for browsing only. Installation is unavailable until refresh succeeds.",
"settings.plugins.sources.note": "This catalog is provided by the selected third party, not curated by Netcatty. Review the source domain and permissions before installing. Updates are manual.",
"settings.plugins.sources.sourceOnly": "Source-only entry from this provider. No downloadable package is available; review the author’s build and import instructions.",
"settings.plugins.market.offline": "Registry unavailable. Showing the last valid catalog or bundled examples. Installation is unavailable until refresh succeeds.",
"settings.plugins.manager.details": "View details",
"settings.plugins.manager.noDescription": "No description provided.",
@@ -1285,6 +1331,25 @@ Highlight the focused split pane:
"settings.plugins.manager.unbounded": "No narrower resource scope is declared. Check the actual scope in the permission prompt.",
"settings.plugins.openView": "Open",
"settings.plugins.openManager": "Open plugin manager",
"settings.plugins.sources.conflict": "This ID is installed from another source. Uninstall and explicitly reset its private data before switching. Existing source:",
"settings.plugins.sources.clearPrivate": "Also clear this source’s private data, grants and secrets to reinstall from another source. This cannot be undone.",
"settings.plugins.sources.official": "Official marketplace",
"settings.plugins.sources.thirdparty": "Third-party marketplace",
"settings.plugins.sources.manage": "Manage sources",
"settings.plugins.sources.select": "Select a source",
"settings.plugins.sources.empty": "Choose or add a third-party source.",
"settings.plugins.sources.add": "Add source",
"settings.plugins.sources.remove": "Remove source",
"settings.plugins.sources.name": "Source name",
"settings.plugins.sources.address": "HTTPS source address",
"settings.plugins.sources.risk": "Third-party sources are not maintained or endorsed by Netcatty. Review the real domain, package permissions and author before installing.",
"settings.plugins.sources.accept": "I trust this domain to supply plugin metadata and packages. This does not grant plugin permissions.",
"settings.plugins.sources.removeNote": "Installed plugins and their data remain. Updates from this source stop; another source will not replace it.",
"settings.plugins.sources.disablePlugins": "Also disable plugins installed from this source",
"settings.plugins.sources.installConfirm": "Review third-party installation",
"settings.plugins.sources.source": "Source",
"settings.plugins.sources.local": "Local import",
"settings.plugins.sources.all": "All sources",
"settings.plugins.title": "Plugins",
"settings.plugins.description": "Manage your plugins and explore the catalog.",
'settings.plugins.loading': 'Loading plugin settings…',
+65
View File
@@ -1245,16 +1245,62 @@ Resalta el panel dividido enfocado:
"settings.plugins.manager.disable": "Desactivar",
"settings.plugins.manager.remove": "Desinstalar",
"settings.plugins.manager.removeNote": "La desinstalación elimina el código y los datos KV locales. Se conservan los ajustes del usuario, los secretos y los datos auxiliares de sincronización.",
"settings.plugins.permissions.statusUnknown": "Estado desconocido",
"plugins.sidebar.extensions": "Páginas de extensiones",
"plugins.sidebar.selectTerminal": "Selecciona primero un terminal abierto.",
"plugins.sidebar.loading": "Cargando página de extensión…",
"plugins.vault.loading": "Cargando página del plugin…",
"plugins.vault.extensions": "Extensiones",
"plugins.vault.back": "Volver a hosts",
"plugins.interaction.title": "Solicitud de complemento",
"plugins.interaction.sharing": "El texto compartido estará disponible para este complemento y sus capacidades autorizadas, incluida la red. Leer no autoriza un nuevo destino de red.",
"plugins.interaction.input": "Introducir texto",
"plugins.interaction.filter": "Filtrar opciones",
"plugins.interaction.preview": "Vista previa del portapapeles",
"plugins.interaction.previewNote": "El portapapeles se lee al elegir Vista previa. Revise el texto y confirme por separado que desea compartirlo.",
"plugins.interaction.previewAction": "Vista previa del portapapeles",
"plugins.interaction.share": "Compartir con el complemento",
"plugins.interaction.copy": "Copiar al portapapeles",
"plugins.interaction.failed": "La solicitud caducó o no pudo completarse.",
"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.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.",
"settings.plugins.permissions.always": "Hasta su revocación",
"settings.plugins.permissions.application": "Hasta cerrar Netcatty",
"settings.plugins.permissions.session": "Hasta cerrar la sesión",
"settings.plugins.permissions.audit": "Eventos de seguridad recientes (hasta 100)",
"settings.plugins.permissions.revoke": "Desactivar y revocar todas las autorizaciones",
"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.noPermissions": "No se han declarado permisos privilegiados.",
"settings.plugins.manager.required": "Obligatorio",
"settings.plugins.manager.optional": "Opcional",
"settings.plugins.themes.title": "Temas de plugins",
"settings.plugins.themes.description": "La vista previa solo cambia esta ventana. Aplicar guarda la selección en este dispositivo. Se conservan los ajustes personales.",
"settings.plugins.themes.empty": "No hay temas habilitados para este modo.",
"settings.plugins.themes.choose": "Elegir tema",
"settings.plugins.themes.unavailable": "Tema guardado no disponible; se usa el predeterminado",
"settings.plugins.themes.applied": "Aplicado",
"settings.plugins.themes.preview": "Vista previa",
"settings.plugins.themes.apply": "Aplicar",
"settings.plugins.themes.cancel": "Cancelar vista previa",
"settings.plugins.themes.restore": "Restaurar predeterminado",
"settings.plugins.themes.invalid": "Algunas paletas no están disponibles o no son válidas.",
"settings.plugins.themes.failed": "No se pudo aplicar el tema.",
"settings.plugins.manager.dataTheme": "Paquete de temas: colores validados, sin ejecución de código.",
"settings.plugins.manager.browser": "Entorno de navegador: el acceso privilegiado pasa por los controles de permisos del anfitrión.",
"settings.plugins.manager.advanced": "Entorno avanzado: acceso completo de Node al sistema de archivos y a la red. Los permisos del intermediario no restringen este acceso. Solo para desarrollo.",
"settings.plugins.manager.data": "Los datos KV permanecen en este dispositivo. Solo los ajustes no secretos marcados para sincronización participan en los datos auxiliares cifrados. Los paquetes y las autorizaciones no se sincronizan.",
"settings.plugins.market.results": "{count} plugins",
"settings.plugins.manager.lastError": "Último error registrado",
"settings.plugins.sources.offline": "Fuente no disponible. Si existe un catálogo validado en caché de esta fuente, solo se permite consultarlo. La instalación no estará disponible hasta que se actualice correctamente.",
"settings.plugins.sources.note": "Este catálogo lo proporciona el tercero seleccionado; Netcatty no lo selecciona ni revisa. Comprueba el dominio y los permisos antes de instalar. Las actualizaciones son manuales.",
"settings.plugins.sources.sourceOnly": "Entrada de código fuente de este proveedor. No hay un paquete descargable; consulta las instrucciones de compilación e importación del autor.",
"settings.plugins.market.offline": "El registro no está disponible. Se muestra el último catálogo válido o los ejemplos incluidos. La instalación requiere una actualización correcta.",
"settings.plugins.manager.details": "Ver detalles",
"settings.plugins.manager.noDescription": "No se proporcionó una descripción.",
@@ -1285,6 +1331,25 @@ Resalta el panel dividido enfocado:
"settings.plugins.manager.unbounded": "No se declara un alcance de recursos más limitado. Revisa el alcance real en la solicitud de permiso.",
"settings.plugins.openView": "Abrir",
"settings.plugins.openManager": "Abrir gestor de plugins",
"settings.plugins.sources.conflict": "This ID is installed from another source. Uninstall and explicitly reset its private data before switching. Existing source:",
"settings.plugins.sources.clearPrivate": "Also clear this source’s private data, grants and secrets to reinstall from another source. This cannot be undone.",
"settings.plugins.sources.official": "Official marketplace",
"settings.plugins.sources.thirdparty": "Third-party marketplace",
"settings.plugins.sources.manage": "Manage sources",
"settings.plugins.sources.select": "Select a source",
"settings.plugins.sources.empty": "Choose or add a third-party source.",
"settings.plugins.sources.add": "Add source",
"settings.plugins.sources.remove": "Remove source",
"settings.plugins.sources.name": "Source name",
"settings.plugins.sources.address": "HTTPS source address",
"settings.plugins.sources.risk": "Third-party sources are not maintained or endorsed by Netcatty. Review the real domain, package permissions and author before installing.",
"settings.plugins.sources.accept": "I trust this domain to supply plugin metadata and packages. This does not grant plugin permissions.",
"settings.plugins.sources.removeNote": "Installed plugins and their data remain. Updates from this source stop; another source will not replace it.",
"settings.plugins.sources.disablePlugins": "Also disable plugins installed from this source",
"settings.plugins.sources.installConfirm": "Review third-party installation",
"settings.plugins.sources.source": "Source",
"settings.plugins.sources.local": "Local import",
"settings.plugins.sources.all": "All sources",
"settings.plugins.title": "Plugins",
"settings.plugins.description": "Gestiona tus plugins y explora el catálogo.",
'settings.plugins.loading': 'Cargando ajustes del plugin…',
+65
View File
@@ -1140,16 +1140,62 @@ export const ruCoreMessages: Messages = {
"settings.plugins.manager.disable": "Отключить",
"settings.plugins.manager.remove": "Удалить",
"settings.plugins.manager.removeNote": "Удаление очищает код пакета и локальные данные KV. Настройки пользователя, секреты и данные синхронизации сохраняются.",
"settings.plugins.permissions.statusUnknown": "Состояние неизвестно",
"plugins.sidebar.extensions": "Страницы расширений",
"plugins.sidebar.selectTerminal": "Сначала выберите открытый терминал.",
"plugins.sidebar.loading": "Загрузка страницы расширения…",
"plugins.vault.loading": "Загрузка страницы плагина…",
"plugins.vault.extensions": "Расширения",
"plugins.vault.back": "К хостам",
"plugins.interaction.title": "Запрос плагина",
"plugins.interaction.sharing": "Переданный текст доступен плагину и его разрешённым возможностям, включая сеть. Чтение не разрешает новый сетевой адрес.",
"plugins.interaction.input": "Ввести текст",
"plugins.interaction.filter": "Фильтр вариантов",
"plugins.interaction.preview": "Предпросмотр буфера обмена",
"plugins.interaction.previewNote": "Буфер читается только после нажатия предпросмотра. Затем проверьте текст и отдельно подтвердите передачу.",
"plugins.interaction.previewAction": "Предпросмотр буфера обмена",
"plugins.interaction.share": "Передать плагину",
"plugins.interaction.copy": "Копировать в буфер обмена",
"plugins.interaction.failed": "Запрос истёк или не выполнен.",
"settings.plugins.permissions.current": "Соответствует текущему запуску",
"settings.plugins.permissions.inactive": "Не действует для текущего запуска",
"settings.plugins.permissions.title": "Сохранённые разрешения",
"settings.plugins.permissions.refresh": "Обновить",
"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.unavailable": "Управление разрешениями недоступно",
"settings.plugins.manager.permissions": "Заявленные разрешения",
"settings.plugins.manager.permissionNote": "Из манифеста, а не предоставленные права. Включение допускает отложенную активацию; обязательные разрешения запрашиваются перед ней, необязательные — при использовании. Чувствительные операции проверяются приложением.",
"settings.plugins.manager.noPermissions": "Привилегированные разрешения не заявлены.",
"settings.plugins.manager.required": "Обязательно",
"settings.plugins.manager.optional": "Необязательно",
"settings.plugins.themes.title": "Темы плагинов",
"settings.plugins.themes.description": "Предпросмотр меняет только это окно. Применение сохраняет выбор на этом устройстве. Личные настройки сохраняются.",
"settings.plugins.themes.empty": "Нет включённых тем для этого режима.",
"settings.plugins.themes.choose": "Выберите тему",
"settings.plugins.themes.unavailable": "Сохранённая тема недоступна; используется стандартная палитра",
"settings.plugins.themes.applied": "Применено",
"settings.plugins.themes.preview": "Предпросмотр",
"settings.plugins.themes.apply": "Применить",
"settings.plugins.themes.cancel": "Отменить предпросмотр",
"settings.plugins.themes.restore": "Сбросить тему",
"settings.plugins.themes.invalid": "Некоторые палитры недоступны или отклонены.",
"settings.plugins.themes.failed": "Не удалось применить тему.",
"settings.plugins.manager.dataTheme": "Пакет тем: проверенные цвета без выполнения кода.",
"settings.plugins.manager.browser": "Браузерная среда: привилегированный доступ проходит через проверку разрешений приложения.",
"settings.plugins.manager.advanced": "Расширенная среда: полный доступ Node к файловой системе и сети. Разрешения посредника не ограничивают этот доступ. Только для разработки.",
"settings.plugins.manager.data": "Данные KV остаются на устройстве. Синхронизируются только явно отмеченные несекретные настройки в зашифрованных данных. Пакеты и разрешения не синхронизируются.",
"settings.plugins.market.results": "Плагинов: {count}",
"settings.plugins.manager.lastError": "Последняя записанная ошибка",
"settings.plugins.sources.offline": "Источник недоступен. Ранее проверенный каталог этого источника, если он есть в кэше, доступен только для просмотра. Установка недоступна до успешного обновления.",
"settings.plugins.sources.note": "Этот каталог предоставляет выбранная третья сторона; Netcatty не отбирает его содержимое. Перед установкой проверьте домен источника и разрешения. Обновления выполняются вручную.",
"settings.plugins.sources.sourceOnly": "Запись с исходным кодом от этого поставщика. Пакет для загрузки отсутствует; см. инструкции автора по сборке и импорту.",
"settings.plugins.market.offline": "Реестр недоступен. Показан последний корректный каталог или встроенные примеры. Для установки необходимо успешно обновить каталог.",
"settings.plugins.manager.details": "Подробнее",
"settings.plugins.manager.noDescription": "Описание не предоставлено.",
@@ -1180,6 +1226,25 @@ export const ruCoreMessages: Messages = {
"settings.plugins.manager.unbounded": "Более узкая область ресурсов не заявлена. Проверьте фактическую область в запросе разрешения.",
"settings.plugins.openView": "Открыть",
"settings.plugins.openManager": "Открыть управление плагинами",
"settings.plugins.sources.conflict": "This ID is installed from another source. Uninstall and explicitly reset its private data before switching. Existing source:",
"settings.plugins.sources.clearPrivate": "Also clear this source’s private data, grants and secrets to reinstall from another source. This cannot be undone.",
"settings.plugins.sources.official": "Official marketplace",
"settings.plugins.sources.thirdparty": "Third-party marketplace",
"settings.plugins.sources.manage": "Manage sources",
"settings.plugins.sources.select": "Select a source",
"settings.plugins.sources.empty": "Choose or add a third-party source.",
"settings.plugins.sources.add": "Add source",
"settings.plugins.sources.remove": "Remove source",
"settings.plugins.sources.name": "Source name",
"settings.plugins.sources.address": "HTTPS source address",
"settings.plugins.sources.risk": "Third-party sources are not maintained or endorsed by Netcatty. Review the real domain, package permissions and author before installing.",
"settings.plugins.sources.accept": "I trust this domain to supply plugin metadata and packages. This does not grant plugin permissions.",
"settings.plugins.sources.removeNote": "Installed plugins and their data remain. Updates from this source stop; another source will not replace it.",
"settings.plugins.sources.disablePlugins": "Also disable plugins installed from this source",
"settings.plugins.sources.installConfirm": "Review third-party installation",
"settings.plugins.sources.source": "Source",
"settings.plugins.sources.local": "Local import",
"settings.plugins.sources.all": "All sources",
"settings.plugins.title": "Плагины",
"settings.plugins.description": "Управляйте плагинами и просматривайте каталог.",
'settings.plugins.loading': 'Загрузка настроек плагинов…',
+65
View File
@@ -1160,16 +1160,62 @@ Vaults 标签保持扁平,不使用这块背景。
"settings.plugins.manager.disable": "停用",
"settings.plugins.manager.remove": "卸载",
"settings.plugins.manager.removeNote": "卸载会移除插件代码和本地 KV 数据,保留用户设置、秘密存储和同步 sidecar。",
"settings.plugins.permissions.statusUnknown": "状态未知",
"plugins.sidebar.extensions": "扩展页面",
"plugins.sidebar.selectTerminal": "请先选择已打开的终端。",
"plugins.sidebar.loading": "正在加载扩展页面…",
"plugins.vault.loading": "正在加载插件页面…",
"plugins.vault.extensions": "扩展",
"plugins.vault.back": "返回主机",
"plugins.interaction.title": "插件请求",
"plugins.interaction.sharing": "分享的文本将交给此插件及其已授权的能力(包括网络访问)。读取文本不代表批准新的网络目标。",
"plugins.interaction.input": "输入文本",
"plugins.interaction.filter": "筛选选项",
"plugins.interaction.preview": "剪贴板预览",
"plugins.interaction.previewNote": "仅在点击“预览剪贴板”后读取。检查文本后,还需单独确认才能分享给插件。",
"plugins.interaction.previewAction": "预览剪贴板",
"plugins.interaction.share": "分享给插件",
"plugins.interaction.copy": "复制到剪贴板",
"plugins.interaction.failed": "此请求已过期或无法完成。",
"settings.plugins.permissions.current": "匹配当前运行实例",
"settings.plugins.permissions.inactive": "对当前运行实例不生效",
"settings.plugins.permissions.title": "已保存的授权",
"settings.plugins.permissions.refresh": "刷新",
"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.unavailable": "插件授权管理不可用",
"settings.plugins.manager.permissions": "声明的权限",
"settings.plugins.manager.permissionNote": "来自 manifest,不代表已授权。启用表示允许按需激活;必需权限在首次激活前申请,可选权限在用到时申请。敏感操作仍受宿主检查。",
"settings.plugins.manager.noPermissions": "未声明特权权限。",
"settings.plugins.manager.required": "必需",
"settings.plugins.manager.optional": "可选",
"settings.plugins.themes.title": "插件主题",
"settings.plugins.themes.description": "预览仅影响当前窗口;应用后保存在本设备。个人强调色、字体和终端覆盖设置仍然优先。",
"settings.plugins.themes.empty": "当前模式没有已启用的插件主题。",
"settings.plugins.themes.choose": "选择主题",
"settings.plugins.themes.unavailable": "已保存的主题不可用,正在使用宿主默认配色",
"settings.plugins.themes.applied": "已应用",
"settings.plugins.themes.preview": "预览",
"settings.plugins.themes.apply": "应用",
"settings.plugins.themes.cancel": "取消预览",
"settings.plugins.themes.restore": "恢复默认",
"settings.plugins.themes.invalid": "部分主题配色未通过验证或不可用。",
"settings.plugins.themes.failed": "无法应用主题。",
"settings.plugins.manager.dataTheme": "主题包:已验证配色,不启动代码运行时。",
"settings.plugins.manager.browser": "浏览器运行时:特权访问通过宿主权限代理。",
"settings.plugins.manager.advanced": "高级运行时:拥有完整 Node 文件系统和网络能力,代理权限无法限制这些访问。仅供开发预览。",
"settings.plugins.manager.data": "本地 KV 仅保留在此设备。只有明确标记为同步的非秘密设置进入加密云 sidecar。插件包和权限授权不参与同步。",
"settings.plugins.market.results": "{count} 个插件",
"settings.plugins.manager.lastError": "上次记录的错误",
"settings.plugins.sources.offline": "来源暂不可用。如已缓存此来源通过校验的目录,仅可继续浏览;刷新成功前无法安装。",
"settings.plugins.sources.note": "此目录由所选第三方提供,不属于 Netcatty 精选目录。安装前请核对来源域名和权限,更新需手动确认。",
"settings.plugins.sources.sourceOnly": "此提供方仅提供源码条目,没有可下载插件包;请查看作者的构建和导入说明。",
"settings.plugins.market.offline": "官方目录暂时不可用,正在显示上次有效目录或内置示例。刷新成功前不能从市场安装。",
"settings.plugins.manager.details": "查看详情",
"settings.plugins.manager.noDescription": "作者暂未提供功能简介。",
@@ -1200,6 +1246,25 @@ Vaults 标签保持扁平,不使用这块背景。
"settings.plugins.manager.unbounded": "未声明更细的资源范围。请在权限提示中检查实际授权范围。",
"settings.plugins.openView": "打开",
"settings.plugins.openManager": "打开插件管理",
"settings.plugins.sources.conflict": "此 ID 已从其他来源安装。请先卸载并明确清理旧来源数据,再换源重装。原来源:",
"settings.plugins.sources.clearPrivate": "同时清除本来源的私有数据、授权和秘密,以便显式换源重装。此操作不可撤销。",
"settings.plugins.sources.official": "官方市场",
"settings.plugins.sources.thirdparty": "第三方市场",
"settings.plugins.sources.manage": "管理来源",
"settings.plugins.sources.select": "选择来源",
"settings.plugins.sources.empty": "选择或添加第三方来源。",
"settings.plugins.sources.add": "添加来源",
"settings.plugins.sources.remove": "移除来源",
"settings.plugins.sources.name": "来源名称",
"settings.plugins.sources.address": "HTTPS 来源地址",
"settings.plugins.sources.risk": "第三方来源不代表 Netcatty 维护或认可。安装前请核对真实域名、插件权限与作者。",
"settings.plugins.sources.accept": "我信任此域名提供插件目录和软件包。此操作不会授予插件权限。",
"settings.plugins.sources.removeNote": "保留已安装插件及数据,停止此来源更新,不会自动切换到其他来源。",
"settings.plugins.sources.disablePlugins": "同时停用来自该来源的插件",
"settings.plugins.sources.installConfirm": "确认第三方安装",
"settings.plugins.sources.source": "来源",
"settings.plugins.sources.local": "本地导入",
"settings.plugins.sources.all": "全部来源",
"settings.plugins.title": "插件",
"settings.plugins.description": "管理已安装插件,浏览精选目录。",
'settings.plugins.loading': '正在加载插件设置…',
+65
View File
@@ -1163,22 +1163,87 @@ Vaults 分頁維持扁平,不使用這塊背景。
"settings.plugins.manager.disable": "停用",
"settings.plugins.manager.remove": "卸載",
"settings.plugins.manager.removeNote": "卸載會移除插件代碼和本地 KV 數據,保留用戶設置、秘密存儲和同步 sidecar。",
"settings.plugins.permissions.statusUnknown": "狀態不明",
"plugins.sidebar.extensions": "擴充功能頁面",
"plugins.sidebar.selectTerminal": "請先選擇已開啟的終端。",
"plugins.sidebar.loading": "正在載入擴充功能頁面…",
"plugins.vault.loading": "正在載入擴充功能頁面…",
"plugins.vault.extensions": "擴充功能",
"plugins.vault.back": "返回主機",
"plugins.interaction.title": "外掛請求",
"plugins.interaction.sharing": "分享的文字會交給此外掛及其已授權能力(包括網路存取)。讀取文字不代表核准新的網路目的地。",
"plugins.interaction.input": "輸入文字",
"plugins.interaction.filter": "篩選選項",
"plugins.interaction.preview": "剪貼簿預覽",
"plugins.interaction.previewNote": "僅在點選「預覽剪貼簿」後讀取。檢查文字後,還需另外確認才能分享給外掛。",
"plugins.interaction.previewAction": "預覽剪貼簿",
"plugins.interaction.share": "分享給外掛",
"plugins.interaction.copy": "複製到剪貼簿",
"plugins.interaction.failed": "此請求已過期或無法完成。",
"settings.plugins.permissions.current": "符合目前執行個體",
"settings.plugins.permissions.inactive": "對目前執行個體不生效",
"settings.plugins.permissions.title": "已儲存的授權",
"settings.plugins.permissions.refresh": "重新整理",
"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.unavailable": "外掛授權管理無法使用",
"settings.plugins.manager.permissions": "聲明的權限",
"settings.plugins.manager.permissionNote": "來自 manifest,不代表已授權。啟用表示允許按需激活;必需權限在首次激活前申請,可選權限在用到時申請。敏感操作仍受宿主檢查。",
"settings.plugins.manager.noPermissions": "未聲明特權權限。",
"settings.plugins.manager.required": "必需",
"settings.plugins.manager.optional": "可選",
"settings.plugins.themes.title": "外掛主題",
"settings.plugins.themes.description": "預覽僅影響目前視窗;套用後儲存於本機。個人強調色、字型和終端覆寫設定仍然優先。",
"settings.plugins.themes.empty": "目前模式沒有已啟用的外掛主題。",
"settings.plugins.themes.choose": "選擇主題",
"settings.plugins.themes.unavailable": "已儲存的主題無法使用,正在使用宿主預設配色",
"settings.plugins.themes.applied": "已套用",
"settings.plugins.themes.preview": "預覽",
"settings.plugins.themes.apply": "套用",
"settings.plugins.themes.cancel": "取消預覽",
"settings.plugins.themes.restore": "恢復預設",
"settings.plugins.themes.invalid": "部分主題配色未通過驗證或無法使用。",
"settings.plugins.themes.failed": "無法套用主題。",
"settings.plugins.manager.dataTheme": "主題套件:已驗證配色,不啟動程式執行環境。",
"settings.plugins.manager.browser": "瀏覽器運行時:特權訪問通過宿主權限代理。",
"settings.plugins.manager.advanced": "高級運行時:擁有完整 Node 文件系統和網絡能力,代理權限無法限制這些訪問。僅供開發預覽。",
"settings.plugins.manager.data": "本地 KV 僅保留在此設備。只有明確標記為同步的非秘密設置進入加密雲 sidecar。插件包和權限授權不參與同步。",
"settings.plugins.manager.unbounded": "未宣告更細的資源範圍。請在權限提示中檢查實際授權範圍。",
"settings.plugins.openView": "開啟",
"settings.plugins.openManager": "開啟插件管理",
"settings.plugins.sources.conflict": "此 ID 已从其他来源安装。请先卸载并明确清理旧来源数据,再换源重装。原来源:",
"settings.plugins.sources.clearPrivate": "同时清除本来源的私有数据、授权和秘密,以便显式换源重装。此操作不可撤销。",
"settings.plugins.sources.official": "官方市场",
"settings.plugins.sources.thirdparty": "第三方市场",
"settings.plugins.sources.manage": "管理来源",
"settings.plugins.sources.select": "选择来源",
"settings.plugins.sources.empty": "选择或添加第三方来源。",
"settings.plugins.sources.add": "添加来源",
"settings.plugins.sources.remove": "移除来源",
"settings.plugins.sources.name": "来源名称",
"settings.plugins.sources.address": "HTTPS 来源地址",
"settings.plugins.sources.risk": "第三方来源不代表 Netcatty 维护或认可。安装前请核对真实域名、插件权限与作者。",
"settings.plugins.sources.accept": "我信任此域名提供插件目录和软件包。此操作不会授予插件权限。",
"settings.plugins.sources.removeNote": "保留已安装插件及数据,停止此来源更新,不会自动切换到其他来源。",
"settings.plugins.sources.disablePlugins": "同时停用来自该来源的插件",
"settings.plugins.sources.installConfirm": "确认第三方安装",
"settings.plugins.sources.source": "来源",
"settings.plugins.sources.local": "本地导入",
"settings.plugins.sources.all": "全部来源",
"settings.plugins.title": "插件",
"settings.plugins.description": "管理已安裝插件,瀏覽精選目錄。",
'settings.plugins.loading': '正在載入外掛程式設定…',
"settings.plugins.market.results": "{count} 個插件",
"settings.plugins.manager.lastError": "上次記錄的錯誤",
"settings.plugins.sources.offline": "來源暫不可用。如已快取此來源通過驗證的目錄,僅可繼續瀏覽;重新整理成功前無法安裝。",
"settings.plugins.sources.note": "此目錄由所選第三方提供,不屬於 Netcatty 精選目錄。安裝前請核對來源網域和權限,更新需手動確認。",
"settings.plugins.sources.sourceOnly": "此提供方僅提供原始碼項目,沒有可下載外掛套件;請查看作者的建置和匯入說明。",
"settings.plugins.market.offline": "官方目錄暫時無法使用,正在顯示上次有效目錄或內建範例。重新整理成功前無法從市場安裝。",
"settings.plugins.manager.details": "查看詳情",
"settings.plugins.manager.noDescription": "作者尚未提供功能簡介。",
@@ -0,0 +1,36 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { JSDOM } from 'jsdom';
import { observePluginHostSecurityVisibility } from './pluginHostSecurityVisibility';
test('native modal yields to the host chooser, restores on dismissal, and never restores after cleanup', async () => {
const dom = new JSDOM('<body><div role="dialog" aria-modal="true"><div id="mount"></div></div></body>');
try {
const { document } = dom.window;
const calls: boolean[] = [];
const stop = observePluginHostSecurityVisibility(document.getElementById('mount')!, (visible) => calls.push(visible));
const flush = () => new Promise<void>((resolve) => dom.window.queueMicrotask(resolve));
assert.deepEqual(calls, []); // Its own modal does not hide the plugin.
const chooser = document.createElement('div');
chooser.dataset.hostSecuritySurface = '';
document.body.append(chooser);
await flush(); assert.deepEqual(calls, [false]);
chooser.remove(); await flush(); assert.deepEqual(calls, [false, true]);
document.body.append(chooser); await flush();
assert.deepEqual(calls, [false, true, false]);
stop(); chooser.remove(); await flush();
assert.deepEqual(calls, [false, true, false]);
} finally { dom.window.close(); }
});
test('hidden security surfaces do not occlude a native view', async () => {
const dom = new JSDOM('<body><div id="mount"></div><div hidden><div data-host-security-surface></div></div></body>');
try {
const calls: boolean[] = [];
const stop = observePluginHostSecurityVisibility(dom.window.document.getElementById('mount')!, (visible) => calls.push(visible));
assert.deepEqual(calls, []);
dom.window.document.querySelector('[hidden]')!.removeAttribute('hidden');
await new Promise<void>((resolve) => dom.window.queueMicrotask(resolve));
assert.deepEqual(calls, [false]); stop();
} finally { dom.window.close(); }
});
@@ -0,0 +1,21 @@
/** Native plugin views must yield while a host-owned security dialog is open. */
export function observePluginHostSecurityVisibility(mount: HTMLElement, publish: (visible: boolean) => void): () => void {
const document = mount.ownerDocument;
let visible = true;
const update = () => {
const blocked = [...document.querySelectorAll('[data-host-security-surface]')].some((surface) => {
const style = document.defaultView!.getComputedStyle(surface);
return style.display !== 'none' && style.visibility !== 'hidden'
&& !surface.closest('[hidden], [inert], [aria-hidden="true"]');
});
if (visible === !blocked) return;
visible = !blocked;
publish(visible);
};
const observer = new document.defaultView!.MutationObserver(update);
observer.observe(document.body, { childList: true, subtree: true, attributes: true,
attributeFilter: ['class', 'style', 'hidden', 'inert', 'aria-hidden', 'data-host-security-surface'] });
update();
// The view lifecycle owns close/retention. Cleanup must never reveal an old view.
return () => observer.disconnect();
}
+27
View File
@@ -0,0 +1,27 @@
export const PLUGIN_VAULT_PAGE_PREFIX = 'plugin-vault:';
export interface PluginVaultPage {
key: `plugin-vault:${string}`;
pluginId: string;
pluginName: string;
version: string;
viewId: string;
title: string;
icon?: NetcattyPluginIconReference;
order: number;
}
export function collectPluginVaultPages(plugins: NetcattyPluginContributionSnapshot['plugins']): PluginVaultPage[] {
return plugins.flatMap((plugin) => plugin.views
.filter((view) => view.location === 'vault-page' && view.visible)
.map((view): PluginVaultPage => ({
key: `${PLUGIN_VAULT_PAGE_PREFIX}${view.id}`,
pluginId: plugin.id, pluginName: plugin.displayName, version: plugin.version,
viewId: view.id, title: view.title, icon: view.icon, order: view.order ?? 0,
})))
.sort((left, right) => left.order - right.order || left.viewId.localeCompare(right.viewId));
}
export function resolvePluginVaultPage(pages: readonly PluginVaultPage[], section: string): PluginVaultPage | null {
return pages.find((page) => page.key === section) ?? null;
}
+18
View File
@@ -0,0 +1,18 @@
/** Native child views must yield to host dialogs and overlapping portals. */
export function isPluginViewOccluded(mount: HTMLElement): boolean {
const rect = mount.getBoundingClientRect();
if (!mount.isConnected || mount.closest('[hidden], [inert], [aria-hidden="true"]')) return true;
const overlays = mount.ownerDocument.querySelectorAll(
'[data-app-lock-overlay], [role="dialog"], [role="alertdialog"], '
+ '[role="menu"][data-state="open"], [data-radix-popper-content-wrapper] > *, [data-native-view-occluder]',
);
return [...overlays].some((overlay) => {
if (mount.contains(overlay)) return false;
const style = getComputedStyle(overlay);
if (style.display === 'none' || style.visibility === 'hidden' || overlay.closest('[hidden], [inert]')) return false;
if (overlay.matches('[data-app-lock-overlay], [role="dialog"][aria-modal="true"], [role="alertdialog"]')) return true;
const other = overlay.getBoundingClientRect();
return other.width > 0 && other.height > 0 && other.x < rect.right && other.right > rect.x
&& other.y < rect.bottom && other.bottom > rect.y;
});
}
@@ -1,3 +1,4 @@
import { pluginThemeRegistry } from '../../infrastructure/config/pluginThemeRegistry';
import {
DEFAULT_SHIFT_ENTER_NEWLINE_TEXT,
isLegacyDefaultShiftEnterNewlineText,
@@ -252,6 +253,9 @@ export const applyThemeTokens = (
accentOverride: string,
) => {
const root = window.document.documentElement;
root.toggleAttribute('data-plugin-theme-active', pluginThemeRegistry.isActive(resolvedTheme));
const securityVars = buildAppThemeCssVars(pluginThemeRegistry.securityTokens(resolvedTheme), 'theme', '');
for (const [key, value] of Object.entries(securityVars)) root.style.setProperty('--host-security' + key.slice(1), value);
root.classList.remove('light', 'dark');
root.classList.add(resolvedTheme);
const cssVars = buildAppThemeCssVars(tokens, accentMode, accentOverride);
+2 -2
View File
@@ -13,11 +13,11 @@ import {
} from '../../infrastructure/config/storageKeys';
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
import { useToolbarItemLayout } from './useToolbarItemLayout';
import type { SidePanelTool } from '../../domain/sidePanelLayout';
import type { BuiltinSidePanelTool, SidePanelTool } from '../../domain/sidePanelLayout';
export type TerminalSidePanelTabId = SidePanelTool;
export const TERMINAL_SIDE_PANEL_TAB_DEFAULT_ORDER: TerminalSidePanelTabId[] = [
export const TERMINAL_SIDE_PANEL_TAB_DEFAULT_ORDER: BuiltinSidePanelTool[] = [
'sftp',
'browser',
'scripts',
+8 -2
View File
@@ -1,3 +1,6 @@
import { pluginThemeRegistry } from '../../infrastructure/config/pluginThemeRegistry';
import { usePluginThemeRevision } from './usePluginThemeRuntime';
import { useSettingsChromeStore } from './settingsChromeStore';
import { useLayoutEffect, useRef } from "react";
import type { TerminalTheme } from "../../domain/models";
import {
@@ -271,12 +274,15 @@ export function useActiveChromeTheme({
activeTheme: TerminalTheme | null;
applyAppTheme: () => void;
}) {
const pluginThemeRevision = usePluginThemeRevision();
const { resolvedTheme } = useSettingsChromeStore();
const effectiveTheme = pluginThemeRegistry.isActive(resolvedTheme) ? null : activeTheme;
const applyAppThemeRef = useRef(applyAppTheme);
applyAppThemeRef.current = applyAppTheme;
useLayoutEffect(() => {
syncActiveChromeTheme(activeTheme, applyAppTheme);
}, [activeTheme, applyAppTheme]);
syncActiveChromeTheme(effectiveTheme, applyAppTheme);
}, [effectiveTheme, applyAppTheme, pluginThemeRevision]);
useLayoutEffect(() => {
return () => {
+119
View File
@@ -0,0 +1,119 @@
import { useEffect, useRef, useState } from 'react';
import { PluginViewLifecycleController, type HostedPluginViewState } from './pluginViewLifecycle';
import { resolvePluginViewWindowScope } from './pluginViewScopes';
import { isPluginViewOccluded } from './pluginViewOcclusion';
import type { UsePluginContributionsResult } from './usePluginContributions';
type ViewAdapter = Pick<UsePluginContributionsResult, 'openView' | 'closeView' | 'focusView' | 'setViewBounds' | 'setViewVisibility' | 'onViewClosed' | 'onViewFocused'>;
/** Host-owned mounting only. No plugin router, DOM, session or vault data API. */
export function useEmbeddedPluginView({ viewId, version, slot, active, context, adapter, subscribeHidden, unavailable, focused, focusOnReveal = false }: {
viewId: string;
version: string;
slot: string;
active: boolean;
context: Record<string, unknown>;
adapter: ViewAdapter;
subscribeHidden(listener: () => void): () => void;
unavailable(): void;
focused?(): void;
focusOnReveal?: boolean;
}) {
const mountRef = useRef<HTMLDivElement>(null);
const [loading, setLoading] = useState(true);
const adapterRef = useRef(adapter);
const unavailableRef = useRef(unavailable);
const focusedRef = useRef(focused);
const revealFocusRef = useRef(focusOnReveal);
const requestFocusRef = useRef<() => void>(() => {});
revealFocusRef.current = focusOnReveal;
focusedRef.current = focused;
adapterRef.current = adapter;
unavailableRef.current = unavailable;
useEffect(() => {
const mount = mountRef.current;
if (!active || !mount) return;
let cancelled = false;
const lifecycle = new PluginViewLifecycleController<HostedPluginViewState>();
const instanceId = crypto.randomUUID();
const scopeId = resolvePluginViewWindowScope({ pathname: window.location.pathname,
search: window.location.search, hash: `${window.location.hash}|plugin-slot:${slot}` });
const { openView, closeView, focusView, setViewBounds, setViewVisibility, onViewClosed, onViewFocused } = adapterRef.current;
const close = () => {
if (cancelled) return;
cancelled = true;
lifecycle.markViewClosed(viewId);
void closeView(instanceId).catch(() => {});
};
const failCurrent = () => {
if (cancelled) return;
close();
unavailableRef.current();
};
const unsubscribe = onViewClosed((event) => {
lifecycle.handleHostClose(event.instanceId);
if (event.instanceId === instanceId && !cancelled) {
close();
unavailableRef.current();
}
});
const stopOnHide = subscribeHidden(close);
const stopOnFocus = onViewFocused((event) => {
if (!cancelled && event.instanceId === instanceId) focusedRef.current?.();
});
const bounds = () => {
const rect = mount.getBoundingClientRect();
return { x: Math.round(rect.x), y: Math.round(rect.y), width: Math.max(1, Math.round(rect.width)), height: Math.max(1, Math.round(rect.height)) };
};
let opened = false;
let lastBounds = '';
let visible = false;
const focus = () => {
if (!cancelled && opened && visible && revealFocusRef.current && !isPluginViewOccluded(mount)) void focusView(instanceId).catch(() => {});
};
requestFocusRef.current = focus;
const updateVisibility = () => {
if (!opened || cancelled) return;
const rect = mount.getBoundingClientRect();
const next = rect.width > 0 && rect.height > 0 && !isPluginViewOccluded(mount);
if (visible === next) return;
visible = next;
void setViewVisibility(instanceId, next).then(() => { if (next) focus(); }).catch(failCurrent);
};
const resize = () => {
if (opened && !cancelled) {
const next = bounds();
const key = JSON.stringify(next);
if (key !== lastBounds) { lastBounds = key; void setViewBounds(instanceId, next).catch(failCurrent); }
}
updateVisibility();
};
const observer = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(resize);
observer?.observe(mount);
const mutations = new MutationObserver(resize);
mutations.observe(document.body, { childList: true, subtree: true, attributes: true,
attributeFilter: ['class', 'style', 'hidden', 'inert', 'role', 'aria-modal', 'aria-hidden', 'data-state', 'data-app-lock-overlay', 'data-radix-popper-content-wrapper', 'data-native-view-occluder'] });
window.addEventListener('resize', resize);
document.addEventListener('scroll', resize, true);
setLoading(true);
const opening = lifecycle.beginOpen({ viewKey: viewId });
void openView({ viewId, instanceId, scopeId, bounds: bounds(), context }).then(async (result) => {
if (cancelled || lifecycle.shouldCloseOpen(opening) || lifecycle.consumeHostClose(result.instanceId)) {
await closeView(result.instanceId);
return;
}
opened = true;
lifecycle.setCurrent({ id: result.instanceId, viewId, scopeId, retainContextWhenHidden: false });
resize();
setLoading(false);
}).catch(() => {
if (!cancelled) { close(); unavailableRef.current(); }
}).finally(() => lifecycle.finishOpen({ token: opening, viewKey: viewId }));
return () => {
close(); unsubscribe(); stopOnHide(); stopOnFocus(); observer?.disconnect(); mutations.disconnect();
window.removeEventListener('resize', resize); document.removeEventListener('scroll', resize, true);
};
}, [active, context, slot, subscribeHidden, version, viewId]);
useEffect(() => { if (focusOnReveal) requestFocusRef.current(); }, [focusOnReveal]);
return { mountRef, loading };
}
@@ -0,0 +1,84 @@
import { useEffect, useRef, useState } from 'react';
import type { TerminalSession, Workspace } from '../../domain/models';
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 { buildSelectedPluginSources } from '../../infrastructure/ai/plugin/selectedSources';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
export function usePluginAIContextHost({ sessions, workspaces }: { sessions: TerminalSession[]; workspaces: Workspace[] }) {
const activeTabId = useActiveTabId();
const latest = useRef({ activeTabId, sessions, workspaces });
latest.current = { activeTabId, sessions, workspaces };
const [choice, setChoice] = useState<{ confirm: (id: string) => void; cancel: () => void } | null>(null);
const [noteId, setNoteId] = useState('');
const [noteChoices, setNoteChoices] = useState<Array<{ id: string; title: string }>>([]);
useEffect(() => {
const api = netcattyBridge.get();
if (!api?.onPluginAIHostRequest || !api.setPluginAIHostReady || !api.respondPluginAIHost) return;
const bridge = {
onPluginAIHostRequest: api.onPluginAIHostRequest.bind(api),
setPluginAIHostReady: api.setPluginAIHostReady.bind(api),
respondPluginAIHost: api.respondPluginAIHost.bind(api),
};
let selectedNoteId: string | null = null;
let mounted = true;
let chooser: { runtimeId: string; cancel: () => void } | null = null;
const requests = new Map<string, { runtimeId: string; controller: AbortController }>();
const runtime = createTrustedPluginAIHostRuntime({
getModel: getConfiguredPluginModel,
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; }
const finish = (id: string | null) => {
if (chooser?.cancel !== cancel) return;
chooser = null; signal.removeEventListener('abort', cancel); setChoice(null);
if (id === null || signal.aborted) { reject(Error('Selection cancelled')); return; }
selectedNoteId = id || null;
resolve(buildSelectedPluginSources(() => ({ ...latest.current,
note: getNotesSnapshot().notes.find((note) => note.id === selectedNoteId) ?? null })));
};
const cancel = () => finish(null);
chooser = { runtimeId, cancel };
signal.addEventListener('abort', cancel, { once: true });
setNoteId('');
// This inventory stays inside trusted host UI. The plugin receives
// summaries only for the Note chosen here and current selected scopes.
setNoteChoices(getNotesSnapshot().notes.map((note) => ({ id: note.id, title: note.title })));
setChoice({ confirm: (id) => finish(id), cancel });
if (signal.aborted) cancel();
}),
});
const unsubscribe = bridge.onPluginAIHostRequest((request) => {
if (request.kind === 'request.cancel') {
requests.get(String(request.payload.requestId))?.controller.abort();
return;
}
if (request.kind === 'runtime.release') {
for (const pending of requests.values()) if (pending.runtimeId === request.runtimeId) pending.controller.abort();
if (chooser?.runtimeId === request.runtimeId) chooser.cancel();
runtime.releaseRuntime(request.runtimeId);
return;
}
if (requests.has(request.requestId) || requests.size >= 64) return;
const controller = new AbortController();
requests.set(request.requestId, { runtimeId: request.runtimeId, controller });
void runtime.handle(request.kind, request.payload, request.runtimeId, controller.signal).then(
(result) => bridge.respondPluginAIHost({ requestId: request.requestId, actionToken: request.actionToken, result }),
() => bridge.respondPluginAIHost({ requestId: request.requestId, actionToken: request.actionToken, error: true }),
).catch(() => {}).finally(() => requests.delete(request.requestId));
});
const pageHide = () => { runtime.shutdown(); for (const pending of requests.values()) pending.controller.abort(); };
window.addEventListener('pagehide', pageHide);
void bridge.setPluginAIHostReady(true).catch(() => {});
return () => {
mounted = false; chooser?.cancel(); unsubscribe(); window.removeEventListener('pagehide', pageHide);
for (const pending of requests.values()) pending.controller.abort();
requests.clear(); runtime.shutdown();
void bridge.setPluginAIHostReady(false).catch(() => {});
};
}, []);
return { choice, noteId, setNoteId, noteChoices };
}
+2 -2
View File
@@ -77,8 +77,8 @@ test('offline snapshot stays browsable, retry clears warning, and operations bin
offline = false;
await act(async () => state.retryList());
assert.equal(state.listError, null); assert.equal(state.source, 'registry');
assert.deepEqual(reads.at(-1), [entry.id, 'second']);
assert.deepEqual(reads.at(-1), [entry.id, 'second', 'official']);
await act(async () => state.install(entry.id));
assert.deepEqual(installs, [[entry.id, 'second']]);
assert.deepEqual(installs, [[entry.id, 'second', 'official', undefined]]);
await act(async () => renderer.unmount());
});
+20 -8
View File
@@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from 'react';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
type ReadmeState = { id: string; token?: string; attempt: number; status: 'loading' | 'ready' | 'error'; text: string; error: string | null };
export function usePluginCatalog(selectedId: string | null) {
export function usePluginCatalog(selectedId: string | null, sourceId = 'official') {
const [entries, setEntries] = useState<NetcattyPluginCatalogEntry[]>([]);
const [listStatus, setListStatus] = useState<'loading' | 'ready' | 'error'>('loading');
const [source, setSource] = useState<NetcattyPluginCatalogSnapshot['source']>('builtin');
@@ -12,6 +12,7 @@ export function usePluginCatalog(selectedId: string | null) {
const [readmeAttempt, setReadmeAttempt] = useState(0);
const [installError, setInstallError] = useState<{ id: string; message: string } | null>(null);
const [busy, setBusy] = useState(false);
const [sourceBinding, setSourceBinding] = useState<NetcattyPluginSource | null>(null);
const installing = useRef(false);
const mounted = useRef(false);
useEffect(() => { mounted.current = true; return () => { mounted.current = false; }; }, []);
@@ -22,12 +23,18 @@ export function usePluginCatalog(selectedId: string | null) {
try {
const bridge = netcattyBridge.get();
if (!bridge?.listPluginCatalog) throw new Error('Catalog browsing unavailable');
const list = await bridge.listPluginCatalog();
const list = await bridge.listPluginCatalog(sourceId);
if (active) { setEntries(list.entries); setSource(list.source); setListError(list.error); setListStatus('ready'); }
} catch (cause) { if (active) { setListStatus('error'); setListError(String(cause)); } }
})();
return () => { active = false; };
}, [listAttempt]);
}, [listAttempt, sourceId]);
useEffect(() => {
let active = true;
setSourceBinding(null);
if (selectedId) void netcattyBridge.get()?.getPluginSourceBinding?.(selectedId).then((binding) => { if (active) setSourceBinding(binding); }).catch((cause) => { if (active) setInstallError({ id: selectedId, message: String(cause) }); });
return () => { active = false; };
}, [selectedId, sourceId]);
const selectionToken = entries.find((entry) => entry.id === selectedId)?.selectionToken;
useEffect(() => {
if (!selectedId) return;
@@ -38,15 +45,15 @@ export function usePluginCatalog(selectedId: string | null) {
try {
const bridge = netcattyBridge.get();
if (!bridge?.getPluginCatalogReadme) throw new Error('README retrieval unavailable');
const text = await bridge.getPluginCatalogReadme(selectedId, selectionToken);
const text = await bridge.getPluginCatalogReadme(selectedId, selectionToken, sourceId);
if (active) setReadmeState({ ...base, status: 'ready', text });
} catch (cause) { if (active) setReadmeState({ ...base, status: 'error', error: String(cause) }); }
})();
return () => { active = false; };
}, [selectedId, selectionToken, readmeAttempt]);
}, [selectedId, selectionToken, readmeAttempt, sourceId]);
const currentReadme = readmeState?.id === selectedId && readmeState.token === selectionToken && readmeState.attempt === readmeAttempt ? readmeState : null;
return {
entries, listStatus, listError, source,
entries, listStatus, listError, source, sourceBinding,
readme: currentReadme?.status === 'ready' ? currentReadme.text : null,
readmeStatus: currentReadme?.status ?? (selectedId ? 'loading' : 'idle'),
readmeError: currentReadme?.error ?? null,
@@ -54,14 +61,19 @@ export function usePluginCatalog(selectedId: string | null) {
busy,
retryList: () => setListAttempt((attempt) => attempt + 1),
retryReadme: () => setReadmeAttempt((attempt) => attempt + 1),
async install(id: string) {
async install(id: string, acceptedSourceId?: string, clearPreviousSource = false) {
if (installing.current) return;
installing.current = true;
setBusy(true); setInstallError(null);
try {
const bridge = netcattyBridge.require();
if (!bridge.installCatalogPlugin) throw new Error('Catalog installation unavailable');
await bridge.installCatalogPlugin(id, entries.find((entry) => entry.id === id)?.selectionToken);
if (clearPreviousSource && sourceBinding && sourceBinding.id !== sourceId) {
if (!bridge.resetPluginSource) throw new Error('Source reset unavailable');
await bridge.resetPluginSource(id, sourceBinding.id);
setSourceBinding(null);
}
await bridge.installCatalogPlugin(id, entries.find((entry) => entry.id === id)?.selectionToken, sourceId, acceptedSourceId);
window.dispatchEvent(new Event('netcatty:plugin-packages-changed'));
} catch (cause) { if (mounted.current) setInstallError({ id, message: String(cause) }); }
finally { installing.current = false; if (mounted.current) setBusy(false); }
@@ -88,10 +88,12 @@ export interface UsePluginContributionsResult {
selectSettingPath(kind: 'file' | 'directory', title: string, defaultPath?: string): Promise<string | null>;
openView(payload: NetcattyPluginViewOpenRequest): Promise<{ instanceId: string }>;
closeView(instanceId: string): Promise<void>;
focusView(instanceId: string): Promise<void>;
setViewBounds(instanceId: string, bounds: { x: number; y: number; width: number; height: number }): Promise<void>;
setViewVisibility(instanceId: string, visible: boolean): Promise<void>;
setEnvironment(environment: NetcattyPluginEnvironment): Promise<void>;
onViewClosed(callback: (event: NetcattyPluginViewClosedEvent) => void): () => void;
onViewFocused(callback: (event: import('@netcatty/plugin-contract').PluginViewFocusEvent) => void): () => void;
}
export function usePluginContributions(
@@ -196,6 +198,9 @@ export function usePluginContributions(
if (!bridge?.closePluginView) return;
await bridge.closePluginView(instanceId);
}, [bridge]);
const focusView = useCallback(async (instanceId: string) => {
await bridge?.focusPluginView?.({ instanceId });
}, [bridge]);
const setViewBounds = useCallback(async (
instanceId: string,
@@ -218,6 +223,9 @@ export function usePluginContributions(
const onViewClosed = useCallback((callback: (event: NetcattyPluginViewClosedEvent) => void) => (
bridge?.onPluginViewClosed?.(callback) ?? (() => {})
), [bridge]);
const onViewFocused = useCallback((callback: (event: import('@netcatty/plugin-contract').PluginViewFocusEvent) => void) => (
bridge?.onPluginViewFocused?.(callback) ?? (() => {})
), [bridge]);
const currentLoadState = resolvePluginContributionLoadState({
currentQueryKey: queryKey,
@@ -239,9 +247,11 @@ export function usePluginContributions(
selectSettingPath,
openView,
closeView,
focusView,
setViewBounds,
setViewVisibility,
setEnvironment,
onViewClosed,
onViewFocused,
};
}
@@ -0,0 +1,53 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import type { HostInteractionPrompt, HostInteractionResponse } from '@netcatty/plugin-contract';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
export function usePluginHostInteractions() {
const [prompt, setPrompt] = useState<HostInteractionPrompt | null>(null);
const [value, setValue] = useState('');
const [error, setError] = useState(false);
const [busy, setBusy] = useState(false);
const current = useRef<HostInteractionPrompt | null>(null);
const pending = useRef(false);
useEffect(() => {
const bridge = netcattyBridge.get();
if (!bridge?.onPluginInteraction || !bridge.setPluginInteractionReady) return;
const unsubscribe = bridge.onPluginInteraction((event) => {
if ('closed' in event) {
if (current.current?.requestId !== event.requestId) return;
current.current = null; setPrompt(null);
} else {
current.current = event; setPrompt(event);
setValue(event.request.kind === 'input' ? event.request.initialValue ?? '' : '');
}
pending.current = false; setBusy(false); setError(false);
});
void bridge.setPluginInteractionReady(true).catch(() => {});
return () => {
current.current = null;
unsubscribe();
void bridge.setPluginInteractionReady?.(false).catch(() => {});
};
}, []);
const respond = useCallback(async (action: HostInteractionResponse['action']) => {
const active = current.current;
if (!active || pending.current) return;
pending.current = true; setBusy(true); setError(false);
try {
const bridge = netcattyBridge.require();
if (!bridge.respondPluginInteraction) throw new Error('Host interactions unavailable');
const accepted = await bridge.respondPluginInteraction({
requestId: active.requestId, actionToken: active.actionToken, action,
...(action === 'submit' && (active.request.kind === 'input' || active.request.kind === 'quickPick') ? { value } : {}),
});
if (!accepted && current.current?.actionToken === active.actionToken) {
current.current = null; setPrompt(null);
}
} catch {
if (current.current?.actionToken === active.actionToken) setError(true);
} finally {
if (current.current?.actionToken === active.actionToken) { pending.current = false; setBusy(false); }
}
}, [value]);
return { prompt, value, setValue, error, busy, respond };
}
@@ -0,0 +1,39 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import React from 'react';
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
import { usePluginManager } from './usePluginManager';
test('failed manager refresh marks previous enabled state unknown until a successful read', async (context) => {
const previous = Object.getOwnPropertyDescriptor(globalThis, 'window');
const actPrevious = Object.getOwnPropertyDescriptor(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
let fail = false;
const plugin = { id: 'com.example.synthetic', enabled: true } as NetcattyInstalledPlugin;
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', { configurable: true, value: true });
Object.defineProperty(globalThis, 'window', { configurable: true, value: {
addEventListener() {}, removeEventListener() {}, netcatty: {
async getPluginRuntimeStatus() { return { available: true }; },
async listPlugins() { if (fail) throw new Error('synthetic status read failed'); return [plugin]; },
},
} });
context.after(() => {
if (previous) Object.defineProperty(globalThis, 'window', previous); else Reflect.deleteProperty(globalThis, 'window');
if (actPrevious) Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', actPrevious); else Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
});
let state!: ReturnType<typeof usePluginManager>;
function Probe() { state = usePluginManager(); return null; }
let renderer!: ReactTestRenderer;
await act(async () => { renderer = create(React.createElement(Probe)); });
try {
assert.equal(state.statusKnown, true);
assert.equal(state.plugins[0].enabled, true);
fail = true;
await act(async () => { await assert.rejects(state.refresh(), /synthetic status read failed/); });
assert.equal(state.statusKnown, false);
assert.match(state.error ?? '', /synthetic status read failed/);
fail = false; plugin.enabled = false;
await act(async () => { await state.refresh(); });
assert.equal(state.statusKnown, true);
assert.equal(state.plugins[0].enabled, false);
} finally { await act(async () => { renderer.unmount(); }); }
});
+25 -10
View File
@@ -4,6 +4,7 @@ import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
export function usePluginManager() {
const [plugins, setPlugins] = useState<NetcattyInstalledPlugin[]>([]);
const [available, setAvailable] = useState(false);
const [statusKnown, setStatusKnown] = useState(false);
const [credentialStorageAvailable, setCredentialStorageAvailable] = useState<boolean | undefined>();
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -11,13 +12,22 @@ export function usePluginManager() {
const mutation = useRef(false);
const refresh = useCallback(async () => {
const current = ++generation.current;
const bridge = netcattyBridge.get();
const status = await bridge?.getPluginRuntimeStatus?.();
const list = status?.available ? await bridge?.listPlugins?.() ?? [] : [];
if (current !== generation.current) return;
setAvailable(status?.available === true);
setCredentialStorageAvailable(status?.credentialStorageAvailable);
setPlugins(list);
try {
const bridge = netcattyBridge.get();
const status = await bridge?.getPluginRuntimeStatus?.();
const list = status?.available ? await bridge?.listPlugins?.() ?? [] : [];
if (current !== generation.current) return;
setAvailable(status?.available === true);
setStatusKnown(true);
setCredentialStorageAvailable(status?.credentialStorageAvailable);
setPlugins(list);
} catch (cause) {
if (current === generation.current) {
setStatusKnown(false);
setError(cause instanceof Error ? cause.message : String(cause));
}
throw cause;
}
}, []);
useEffect(() => {
const update = () => { void refresh().catch((cause) => setError(String(cause))); };
@@ -41,7 +51,7 @@ export function usePluginManager() {
}
}, [refresh]);
return {
plugins, available, busy, error, credentialStorageAvailable,
plugins, available, statusKnown, busy, error, credentialStorageAvailable, refresh,
importPackage: () => run(async (bridge) => {
if (!bridge.selectFile || !bridge.installPluginPackage) throw new Error('Plugin import unavailable');
const path = await bridge.selectFile('Import plugin package', undefined, [{ name: 'Netcatty plugin', extensions: ['ncpkg'] }]);
@@ -51,9 +61,14 @@ export function usePluginManager() {
if (!bridge.setPluginEnabled) throw new Error('Plugin management unavailable');
return bridge.setPluginEnabled(id, enabled);
}),
uninstall: (id: string) => run(async (bridge) => {
uninstall: (id: string, clearSource = false) => run(async (bridge) => {
if (!bridge.uninstallPlugin) throw new Error('Plugin management unavailable');
return bridge.uninstallPlugin(id);
const source = clearSource ? await bridge.getPluginSourceBinding?.(id) : null;
await bridge.uninstallPlugin(id);
if (clearSource && source) {
if (!bridge.resetPluginSource) throw new Error('Private source reset unavailable');
await bridge.resetPluginSource(id, source.id);
}
}),
};
}
+25
View File
@@ -0,0 +1,25 @@
import { useCallback, useEffect, useState } from 'react';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
export function usePluginSources() {
const [sources, setSources] = useState<NetcattyPluginSource[]>([]);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const reload = useCallback(async () => {
const values = await netcattyBridge.require().listPluginSources?.();
if (!values) throw new Error('Source management unavailable');
setSources(values);
}, []);
useEffect(() => { void reload().catch((cause) => setError(String(cause))); }, [reload]);
const mutate = async (operation: () => Promise<unknown>) => {
setBusy(true); setError(null);
try { await operation(); await reload(); window.dispatchEvent(new Event('netcatty:plugin-packages-changed')); return true; }
catch (cause) { setError(String(cause)); return false; }
finally { setBusy(false); }
};
return { sources, error, busy,
add: (name: string, address: string) => mutate(async () => netcattyBridge.require().addPluginSource?.({ name, address })),
update: (source: NetcattyPluginSource, name: string, enabled: boolean) => mutate(async () => netcattyBridge.require().updatePluginSource?.({ id: source.id, name, enabled })),
remove: (id: string, disablePlugins: boolean) => mutate(async () => netcattyBridge.require().removePluginSource?.(id, disablePlugins)),
};
}
@@ -0,0 +1,79 @@
import { useEffect, useMemo, useRef, useSyncExternalStore } from 'react';
import { useI18n } from '../i18n/I18nProvider';
import { toast } from '../../components/ui/toast';
import { collectSidePanelPanes, isPluginSidePanelTool, type SidePanelLayout } from '../../domain/sidePanelLayout';
import { activeTabStore, useActiveTabId } from './activeTabStore';
import { getSidePanelLiveSnapshot, subscribeSidePanelLiveSnapshot } from './sidePanelLiveStore';
import { usePluginContributions } from './usePluginContributions';
import { OPEN_PLUGIN_VIEW_EVENT, type OpenPluginViewDetail } from './usePluginViewLifecycle';
export const OPEN_PLUGIN_TERMINAL_SIDEBAR_EVENT = 'netcatty:open-plugin-terminal-sidebar';
export const PLUGIN_TERMINAL_SIDEBAR_CONTEXT = Object.freeze({ 'netcatty.surface': 'terminal-sidebar' });
/** Required production view-context projection, not a plugin-facing API. */
export interface PluginSidebarHostActions {
getActiveTerminalSessionId(): string | null;
handleSwitchSidePanelTab(tool: import('../../domain/sidePanelLayout').SidePanelTool): void;
handleWithdrawPluginSidebarPane(tabId: string, paneId: string, expectedTool: import('../../domain/sidePanelLayout').SidePanelTool): void;
}
export interface PluginTerminalSidebarPage {
tool: `plugin-view:${string}`;
viewId: string;
pluginId: string;
pluginName: string;
version: string;
title: string;
icon?: NetcattyPluginIconReference;
order: number;
}
export function collectPluginTerminalSidebarPages(plugins: NetcattyPluginContributionSnapshot['plugins']): PluginTerminalSidebarPage[] {
return plugins.flatMap((plugin) => plugin.views.filter((view) => view.location === 'terminal-sidebar' && view.visible)
.map((view): PluginTerminalSidebarPage => ({ tool: `plugin-view:${view.id}`, viewId: view.id, pluginId: plugin.id,
pluginName: plugin.displayName, version: plugin.version, title: view.title, icon: view.icon, order: view.order ?? 0 })))
.sort((a, b) => a.order - b.order || a.viewId.localeCompare(b.viewId));
}
export function requestOpenPluginTerminalSidebar(viewId: string, sessionId: string) {
window.dispatchEvent(new CustomEvent(OPEN_PLUGIN_TERMINAL_SIDEBAR_EVENT, { detail: { viewId, sessionId } }));
}
export function usePluginTerminalSidebar(host: {
layouts: ReadonlyMap<string, SidePanelLayout>;
getSession(): string | null;
isTerminalTab(tabId: string): boolean;
select(tool: `plugin-view:${string}`): void;
withdraw(tabId: string, paneId: string, tool: `plugin-view:${string}`): void;
}) {
const { locale, t } = useI18n();
const activeTabId = useActiveTabId();
// Follow workspace focus without subscribing the whole sidebar to cwd/title
// or forwarding the host's live session DTO to a plugin.
useSyncExternalStore((listener) => subscribeSidePanelLiveSnapshot(true, listener),
() => getSidePanelLiveSnapshot(true).focusedSessionId, () => null);
const hostRef = useRef(host); hostRef.current = host;
const contributions = usePluginContributions({ locale, context: PLUGIN_TERMINAL_SIDEBAR_CONTEXT });
const pages = useMemo(() => collectPluginTerminalSidebarPages(contributions.snapshot.plugins), [contributions.snapshot.plugins]);
const sessionId = activeTabId && host.isTerminalTab(activeTabId) ? host.getSession() : null;
useEffect(() => {
const open = (event: Event) => {
const detail = (event as CustomEvent<OpenPluginViewDetail & { sessionId?: string }>).detail;
const page = pages.find((item) => item.viewId === detail?.viewId);
if (!page) return;
const tabId = activeTabStore.getActiveTabId();
const current = hostRef.current.getSession();
if (!hostRef.current.isTerminalTab(tabId) || !current || (detail.sessionId && detail.sessionId !== current)) {
toast.info(t('plugins.sidebar.selectTerminal'));
return;
}
hostRef.current.select(page.tool);
};
window.addEventListener(OPEN_PLUGIN_TERMINAL_SIDEBAR_EVENT, open);
window.addEventListener(OPEN_PLUGIN_VIEW_EVENT, open);
return () => { window.removeEventListener(OPEN_PLUGIN_TERMINAL_SIDEBAR_EVENT, open); window.removeEventListener(OPEN_PLUGIN_VIEW_EVENT, open); };
}, [pages, t]);
useEffect(() => {
if (contributions.loading) return;
const allowed = new Set(pages.map((page) => page.tool));
for (const [tabId, layout] of host.layouts) for (const pane of collectSidePanelPanes(layout.root)) {
if (isPluginSidePanelTool(pane.tool) && !allowed.has(pane.tool)) host.withdraw(tabId, pane.id, pane.tool);
}
}, [contributions.loading, host, pages]);
return { pages, contributions, tabId: activeTabId, sessionId };
}
@@ -0,0 +1,77 @@
import { useEffect, useSyncExternalStore } from "react";
import { netcattyBridge } from "../../infrastructure/services/netcattyBridge";
import { pluginThemeRegistry } from "../../infrastructure/config/pluginThemeRegistry";
export function usePluginThemeRevision() {
return useSyncExternalStore(
pluginThemeRegistry.subscribe,
pluginThemeRegistry.getRevision,
pluginThemeRegistry.getRevision,
);
}
/** Host-owned selection; never stored in plugin KV or cloud sidecars. */
let runtimeUsers = 0;
let disconnectRuntime: (() => void) | undefined;
function connectRuntime() {
const bridge = netcattyBridge.get();
let active = true,
request = 0;
const refresh = async () => {
const current = ++request;
try {
const catalog = await bridge?.getPluginThemes?.();
if (active && current === request && catalog)
pluginThemeRegistry.setCatalog(catalog);
} catch {
/* Keep the last host-validated selection; never apply unvalidated data. */
}
};
void refresh();
const dispose = bridge?.onPluginThemesChanged?.(() => {
pluginThemeRegistry.invalidate();
void refresh();
});
const forced = window.matchMedia?.("(forced-colors: active)"),
contrast = window.matchMedia?.("(prefers-contrast: more)");
const update = () =>
pluginThemeRegistry.setHighContrast(
Boolean(forced?.matches || contrast?.matches),
);
update();
forced?.addEventListener?.("change", update);
contrast?.addEventListener?.("change", update);
return () => {
active = false;
request++;
dispose?.();
forced?.removeEventListener?.("change", update);
contrast?.removeEventListener?.("change", update);
};
}
export function usePluginThemeRuntime() {
const revision = usePluginThemeRevision();
useEffect(() => {
if (runtimeUsers++ === 0) disconnectRuntime = connectRuntime();
return () => {
if (--runtimeUsers === 0) {
disconnectRuntime?.();
disconnectRuntime = undefined;
}
};
}, []);
return revision;
}
export async function applyPluginThemeSelection(
mode: "light" | "dark",
choice: import("@netcatty/plugin-contract").ThemePackChoice | null,
) {
if (choice === null && !pluginThemeRegistry.isActive(mode))
return pluginThemeRegistry.getCatalog();
const bridge = netcattyBridge.require();
if (!bridge.applyPluginTheme) throw new Error("Plugin themes unavailable");
const next = await bridge.applyPluginTheme({
mode,
choice,
expectedRevision: pluginThemeRegistry.getCatalog().selection.revision,
});
return next; // The host change event refreshes every window; never publish a potentially stale apply response.
}
+47
View File
@@ -0,0 +1,47 @@
import { useCallback, useEffect, useMemo, useRef } from 'react';
import { activeTabStore, useActiveTabId } from './activeTabStore';
import { collectPluginVaultPages, PLUGIN_VAULT_PAGE_PREFIX, resolvePluginVaultPage } from './pluginVaultPages';
import { usePluginContributions } from './usePluginContributions';
import { OPEN_PLUGIN_VIEW_EVENT, type OpenPluginViewDetail } from './usePluginViewLifecycle';
const CONTEXT = Object.freeze({ 'netcatty.surface': 'vault-page' });
export function usePluginVaultPages({ locale, section, select }: {
locale: string;
section: string;
select(section: 'hosts' | `plugin-vault:${string}`): void;
}) {
const active = useActiveTabId() === 'vault';
const sectionRef = useRef(section);
const focusFrame = useRef<number | null>(null);
sectionRef.current = section;
useEffect(() => () => { if (focusFrame.current !== null) cancelAnimationFrame(focusFrame.current); }, []);
const contributions = usePluginContributions({ locale, context: CONTEXT });
const pages = useMemo(() => collectPluginVaultPages(contributions.snapshot.plugins), [contributions.snapshot.plugins]);
const selected = resolvePluginVaultPage(pages, section);
const fallback = useCallback(() => {
select('hosts');
if (focusFrame.current !== null) cancelAnimationFrame(focusFrame.current);
focusFrame.current = requestAnimationFrame(() => {
focusFrame.current = null;
if (sectionRef.current === 'hosts' && activeTabStore.getActiveTabId() === 'vault') {
document.querySelector<HTMLButtonElement>('[data-plugin-vault-fallback]')?.focus();
}
});
}, [select]);
useEffect(() => {
if (!contributions.loading && section.startsWith(PLUGIN_VAULT_PAGE_PREFIX) && !selected) fallback();
}, [contributions.loading, fallback, section, selected]);
useEffect(() => {
const open = (event: Event) => {
const detail = (event as CustomEvent<OpenPluginViewDetail>).detail;
const page = pages.find((item) => item.viewId === detail?.viewId);
if (!page) return;
activeTabStore.setActiveTabId('vault');
select(page.key);
};
window.addEventListener(OPEN_PLUGIN_VIEW_EVENT, open);
return () => window.removeEventListener(OPEN_PLUGIN_VIEW_EVENT, open);
}, [pages, select]);
return { pages, selected, active, select, fallback, contributions, context: CONTEXT };
}
+13 -1
View File
@@ -26,6 +26,7 @@ import {
resolvePluginViewWindowScope,
} from './pluginViewScopes';
import { usePluginContributions } from './usePluginContributions';
import { observePluginHostSecurityVisibility } from './pluginHostSecurityVisibility';
export const OPEN_PLUGIN_VIEW_EVENT = 'netcatty:open-plugin-view';
@@ -82,7 +83,7 @@ export function usePluginViewLifecycle({
const mountRef = useRef<HTMLDivElement>(null);
const resolvedActiveView = useMemo(() => viewContributions.snapshot.plugins
.flatMap((plugin) => plugin.views.map((view) => ({ plugin, view })))
.find(({ view }) => view.id === effectiveRequested?.viewId && view.visible) ?? null,
.find(({ view }) => view.id === effectiveRequested?.viewId && view.visible && !['vault-page', 'terminal-sidebar'].includes(view.location)) ?? null,
[effectiveRequested?.viewId, viewContributions.snapshot.plugins]);
const stableActiveViewRef = useRef<PluginViewSnapshotSelection<ResolvedPluginView> | null>(null);
const activeView = resolvePluginViewSnapshotSelection({
@@ -307,6 +308,17 @@ export function usePluginViewLifecycle({
viewScopeId,
]);
useEffect(() => {
if (!instance || !mountRef.current) return;
let disposed = false;
const stop = observePluginHostSecurityVisibility(mountRef.current, (visible) => {
void setViewVisibility(instance.id, visible).catch(() => {
if (!disposed) void closeView(instance.id).catch(() => {});
});
});
return () => { disposed = true; stop(); };
}, [closeView, instance, setViewVisibility]);
useEffect(() => {
if (!instance || !mountRef.current) return;
const update = () => {
+5 -2
View File
@@ -1,3 +1,4 @@
import { usePluginThemeRuntime } from './usePluginThemeRuntime';
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type SetStateAction } from 'react';
import { runThemeTransition, type ThemeTransitionMode } from './themeTransition';
@@ -217,6 +218,7 @@ import {
} from './appearanceSync';
export const useSettingsState = (options: { enableSettingsSync?: boolean; enableSystemEffects?: boolean } = {}) => {
const pluginThemeRevision = usePluginThemeRuntime();
const enableSettingsSync = options.enableSettingsSync !== false;
const enableSystemEffects = options.enableSystemEffects !== false;
const initialCustomKeyBindingsRecord =
@@ -1212,7 +1214,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
notifySettingsChanged(STORAGE_KEY_COLOR, customAccentRecord);
}
}
}, [theme, resolvedTheme, lightUiThemeId, darkUiThemeId, accentMode, customAccent, customAccentRecord, notifySettingsChanged]);
}, [theme, resolvedTheme, lightUiThemeId, darkUiThemeId, accentMode, customAccent, customAccentRecord, notifySettingsChanged, pluginThemeRevision]);
// Listen for OS color scheme changes to keep systemPreference in sync
useEffect(() => {
@@ -1979,9 +1981,10 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
}, [setTerminalSettings]);
const applyAppTheme = useCallback(() => {
void pluginThemeRevision; // Invalidate the closure when host theme data changes.
const tokens = getUiThemeById(resolvedTheme, resolvedTheme === 'dark' ? darkUiThemeId : lightUiThemeId).tokens;
applyThemeTokens(theme, resolvedTheme, tokens, accentMode, customAccent);
}, [theme, resolvedTheme, lightUiThemeId, darkUiThemeId, accentMode, customAccent]);
}, [theme, resolvedTheme, lightUiThemeId, darkUiThemeId, accentMode, customAccent, pluginThemeRevision]);
const setEditorWordWrap = useCallback((enabled: boolean) => {
setEditorWordWrapState(enabled);
@@ -0,0 +1,58 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import path from 'node:path';
import test from 'node:test';
import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { JSDOM } from 'jsdom';
import { compile } from '@tailwindcss/node';
import postcss from 'postcss';
import HostDetailsPanel from './HostDetailsPanel';
import { Card } from './ui/card';
import { TooltipProvider } from './ui/tooltip';
import { PluginPermissionPanel } from './settings/tabs/PluginPermissionPanel';
import { I18nProvider } from '../application/i18n/I18nProvider';
test('real Host form cards, inputs and Save consume scoped security aliases without freezing an ordinary card', async () => {
const markup = renderToStaticMarkup(<I18nProvider locale="en"><TooltipProvider>
<HostDetailsPanel availableKeys={[]} identities={[]} groups={[]} terminalThemeId="default" terminalFontSize={14} onSave={() => assert.fail('test never saves a host')} onCancel={() => {}} />
<PluginPermissionPanel pluginId="com.example.synthetic" onChanged={async () => {}} />
<Card id="ordinary-card">Outside the security surface</Card>
</TooltipProvider></I18nProvider>);
const dom = new JSDOM(`<!doctype html><html data-plugin-theme-active><body>${markup}</body></html>`);
try {
const panel = dom.window.document.querySelector('[data-section="host-details-panel"]')!;
assert.ok(panel, 'exercise the actual HostDetailsPanel and AsidePanel');
assert.ok(panel.querySelector('.bg-card'), 'General/Address/Credentials use real Card components');
assert.ok(panel.querySelector('input.bg-background'), 'exercise actual Input utilities');
assert.ok(panel.querySelector('button.bg-primary'), 'exercise the actual Save button');
const permissions = dom.window.document.querySelector('[data-host-security-surface]')!;
assert.ok(permissions.querySelector('[role="status"]'), 'exercise the real grant-panel security root without loading or changing any grants');
const source = await readFile(new URL('../index.css', import.meta.url), 'utf8');
const compiler = await compile(source, {base: path.resolve('.'), onDependency() {}});
const classes = [...new Set([...dom.window.document.querySelectorAll('[class]')].flatMap(el => [...el.classList]))];
const css = compiler.build(classes);
const parsed = postcss.parse(css);
const rules: postcss.Rule[] = [];
parsed.walkRules(rule => { rules.push(rule); });
const rule = rules.find(node => node.selector.startsWith(':root[data-plugin-theme-active]'));
assert.ok(rule && rule.type === 'rule');
const matches = [...dom.window.document.querySelectorAll(rule.selector)];
assert.ok(matches.includes(panel));
assert.ok(matches.includes(permissions));
assert.ok(!matches.includes(dom.window.document.getElementById('ordinary-card')!), 'security selector stays within the existing marked surfaces');
const declarations = new Map(rule.nodes.filter(node => node.type === 'decl').map(node => [node.prop, node]));
for (const name of ['background','foreground','card','card-foreground','popover','popover-foreground','primary','primary-foreground','secondary','secondary-foreground','muted','muted-foreground','accent','accent-foreground','destructive','destructive-foreground','border','input','ring']) {
const declaration = declarations.get(`--color-${name}`);
assert.equal(declaration?.value, `hsl(var(--host-security-${name}))`, `Tailwind alias ${name} cannot inherit the plugin value`);
assert.equal(declaration.important, true);
}
for (const name of ['card','background','primary']) {
const utility = rules.find(node => node.selector === `.bg-${name}`);
assert.ok(utility && utility.type === 'rule');
assert.ok(utility.nodes.some(node => node.type === 'decl' && node.prop === 'background-color' && node.value === `var(--color-${name})`), 'verify what the actual compiled utility consumes');
}
const mutedText = rules.find(node => node.selector === '.text-muted-foreground');
assert.ok(mutedText?.nodes.some(node => node.type === 'decl' && node.prop === 'color' && node.value === 'var(--color-muted-foreground)'));
} finally { dom.window.close(); }
});
+1
View File
@@ -882,6 +882,7 @@ echo $3 >> "$FILE"`);
{/* Slide-out Panel */}
{panel.type !== "closed" && (
<AsidePanel
dataSection="keychain-security-panel"
open={true}
onClose={closePanel}
title={panelTitle}
+161
View File
@@ -0,0 +1,161 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { JSDOM } from 'jsdom';
import { createRequire } from 'node:module';
import { EventEmitter, once } from 'node:events';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
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 () => {
// 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)), '..');
const fixtureRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'netcatty-ai-ui-'));
const oldTmp = process.env.TMPDIR; process.env.TMPDIR = fixtureRoot;
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', 'DocumentFragment']) {
install(key, key === 'window' ? dom.window : dom.window[key as keyof typeof dom.window]);
}
install('localStorage', dom.window.localStorage);
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 { PluginAIContextHost } = await import('./plugins/PluginAIContextHost');
const { I18nProvider } = await import('../application/i18n/I18nProvider');
const { publishNotesSnapshot, getNotesSnapshot } = await import('../application/state/notesStore');
const { registerAppAppLockRuntime, getAppAppLockRuntime } = await import('../application/state/appRuntimeBridge');
const { STORAGE_KEY_AI_PROVIDERS, STORAGE_KEY_AI_ACTIVE_PROVIDER, STORAGE_KEY_AI_ACTIVE_MODEL } = await import('../infrastructure/config/storageKeys');
const { createAssistantProviderFixture } = await import('../scripts/plugin-assistant-provider-fixture.mjs');
const { createPluginHostService } = require('../electron/plugins/hostService.cjs');
const { inProcessBrowserRuntime } = require('../electron/plugins/fixtures/inProcessBrowserRuntime.cjs');
const { createPreloadApi } = require('../electron/preload/api.cjs');
const aiBridge = require('../electron/bridges/aiBridge.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;
const lockListeners = new Set<(state: { locked: boolean }) => void>();
const reviews: Array<Record<string, unknown>> = [];
const trustedPayloads: unknown[] = [];
try {
server.listen(0, '127.0.0.1'); await once(server, 'listening');
const address = server.address(); assert.ok(address && typeof address !== 'string');
const endpoint = `http://127.0.0.1:${address.port}/v1`;
const ipcHandlers = new Map<string, (...args: unknown[]) => unknown>();
const ipcRenderer = new EventEmitter() as EventEmitter & { invoke: (channel: string, payload: unknown) => Promise<unknown> };
const sender = Object.assign(new EventEmitter(), { id: 1, isDestroyed: () => false, send: (channel: string, payload: unknown) => {
if (channel === 'netcatty:plugins:ai-host-request') trustedPayloads.push(structuredClone(payload));
ipcRenderer.emit(channel, {}, structuredClone(payload));
} });
const mainWindow = Object.assign(new EventEmitter(), { webContents: sender, isDestroyed: () => false, isVisible: () => true, isFocused: () => true });
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 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_ACTIVE_PROVIDER, 'qa-provider');
dom.window.localStorage.setItem(STORAGE_KEY_AI_ACTIVE_MODEL, 'deterministic-fixture');
service = createPluginHostService({
app: { getAppPath: () => rootPath, getPath: () => fixtureRoot, getVersion: () => '0.0.0' }, env: {},
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
? { requestId: request.requestId, decision: 'deny' }
: { requestId: request.requestId, decision: 'allow', scope: request.permission === 'context.selected.read' ? 'once' : 'application' },
});
service.runtimeSupervisor.runtimeFactories.browser = inProcessBrowserRuntime;
await service.manager.initialize();
const staging = path.join(fixtureRoot, 'package'); fs.mkdirSync(path.join(staging, 'dist'), { recursive: true });
const sample = path.join(rootPath, '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(staging, file));
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);
await service.manager.install(archive, { enable: true });
await act(async () => root.render(<I18nProvider locale="en"><PluginAIContextHost sessions={[]} workspaces={[]} /></I18nProvider>));
const waitFor = async (condition: () => boolean) => {
const deadline = Date.now() + 5000;
while (!condition()) {
if (Date.now() >= deadline) throw Error('Connected host UI state timed out');
await act(async () => { await new Promise((resolve) => setTimeout(resolve, 5)); });
}
};
panel = new JSDOM(fs.readFileSync(path.join(sample, 'panel.html'), 'utf8'), { runScripts: 'outside-only' });
Object.defineProperty(panel.window, 'netcattyView', { value: {
executeCommand: async (command: string, args: unknown) => structuredClone(await service!.contributionService.executeCommand(command, structuredClone(args))),
onDidReceiveMessage: () => ({ dispose() {} }),
} });
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;
assert.equal(noteChoice.options.length, 2);
await act(async () => { noteChoice.value = syntheticNote.id; noteChoice.dispatchEvent(new Event('change', { bubbles: true })); });
await act(async () => { Array.from(document.querySelectorAll('button')).find((button) => button.textContent === 'Share selected summaries')!.click(); });
const element = (id: string) => panel!.window.document.getElementById(id)!;
await waitFor(() => element('status').textContent === 'Ready');
assert.match(element('model').textContent!, /QA fixture/);
const reference = element('context') as HTMLSelectElement;
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 }));
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');
assert.equal(counts.completed, 1); assert.match(element('response').textContent!, /Synthetic plan/); assert.match(element('usage').textContent!, /12 input tokens/);
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);
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/);
} finally {
panel?.window.close();
await act(async () => root.unmount());
await service?.manager.shutdown(); aiBridge.cleanup();
publishNotesSnapshot(oldNotes); registerAppAppLockRuntime(oldLock);
server.closeAllConnections(); await new Promise<void>((resolve) => server.close(() => resolve()));
dom.window.close();
for (const [key, value] of previous) { if (value) Object.defineProperty(globalThis, key, value); else Reflect.deleteProperty(globalThis, key); }
if (oldTmp === undefined) delete process.env.TMPDIR; else process.env.TMPDIR = oldTmp;
fs.rmSync(fixtureRoot, { recursive: true, force: true });
}
});
@@ -0,0 +1,91 @@
import assert from 'node:assert/strict';
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 () => {
const dom = new JSDOM('<!doctype html><html><body><div id="root"></div></body></html>', { pretendToBeVisual: true, url: 'http://localhost' });
const previous = new Map<string, PropertyDescriptor | undefined>();
const install = (key: string, value: unknown) => {
previous.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
Object.defineProperty(globalThis, key, { configurable: true, writable: true, value });
};
for (const key of ['window', 'document', 'navigator', 'HTMLElement', 'HTMLInputElement', 'Element', 'Node', 'NodeFilter', 'MutationObserver', 'CustomEvent', 'Event', 'KeyboardEvent', 'MouseEvent']) {
install(key, key === 'window' ? dom.window : dom.window[key as keyof typeof dom.window]);
}
install('getComputedStyle', dom.window.getComputedStyle.bind(dom.window));
install('requestAnimationFrame', dom.window.requestAnimationFrame.bind(dom.window));
install('cancelAnimationFrame', dom.window.cancelAnimationFrame.bind(dom.window));
install('IS_REACT_ACT_ENVIRONMENT', true);
const { default: React, act } = await import('react');
const { createRoot } = await import('react-dom/client');
const { I18nProvider } = await import('../application/i18n/I18nProvider');
const { PluginHostInteractionHost } = await import('./plugins/PluginHostInteractionHost');
const { PluginHostInteractionService, assertInteractionRequest } = await import('../electron/plugins/hostInteractionService.cjs');
let listener: ((event: unknown) => void) | null = null;
let reads = 0;
const writes: string[] = [];
const audits: unknown[] = [];
const sender = Object.assign(new EventEmitter(), { id: 7, isDestroyed: () => false, send(_channel: string, payload: unknown) { listener?.(payload); } });
const owner = Object.assign(new EventEmitter(), { webContents: sender, isFocused: () => true, isVisible: () => true, isDestroyed: () => false });
const service = new PluginHostInteractionService({
getWindow: () => owner, isLocked: () => false,
permissionEngine: { onDidRevoke() { return { dispose() {} }; } },
database: { recordSecurityAudit(...args: unknown[]) { audits.push(args); } },
quotaManager: { chargeBytes() {} },
clipboard: { readText() { reads++; return 'synthetic private text'; }, writeText(value: string) { writes.push(value); } },
});
Object.assign(dom.window, { netcatty: {
onPluginInteraction(callback: (event: unknown) => void) { listener = callback; return () => { listener = null; }; },
async setPluginInteractionReady(ready: boolean) { return service.setReady(sender, ready); },
async respondPluginInteraction(response: unknown) { return service.respond(sender, response); },
} });
const context = { pluginId: 'com.example.synthetic', pluginVersion: '1.0.0', runtimeId: 'runtime-synthetic', signal: new AbortController().signal, async assertActive() {} };
const root = createRoot(document.getElementById('root')!);
const settle = () => new Promise((resolve) => setTimeout(resolve, 20));
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 (params: unknown) => {
let promise!: Promise<unknown>;
await act(async () => { promise = service.request(assertInteractionRequest(params), context); void promise.catch(() => {}); await settle(); });
return { promise };
};
try {
await act(async () => root.render(<I18nProvider locale="en"><PluginHostInteractionHost /></I18nProvider>));
const choice = await begin({ kind: 'quickPick', title: 'Synthetic choice', items: [{ id: 'compose', label: 'Compose' }] });
assert.match(document.querySelector('[role="dialog"]')?.textContent ?? '', /com.example.synthetic/);
assert.equal(button('Continue').disabled, true);
await click(document.querySelector<HTMLInputElement>('input[type="radio"]')!);
await click(button('Continue'));
assert.deepEqual(await choice.promise, { cancelled: false, value: 'compose' });
await assert.rejects(begin({ kind: 'input', title: 'Invalid hidden newline', initialValue: 'first\nsecond' }), /invalid/);
assert.equal(document.querySelector('[role="dialog"]'), null, 'invalid initial text never creates a misleading single-line dialog');
const input = await begin({ kind: 'input', title: 'Synthetic input', initialValue: 'synthetic output' });
assert.equal(document.querySelector<HTMLInputElement>('input')?.value, 'synthetic output');
await click(button('Continue'));
assert.deepEqual(await input.promise, { cancelled: false, value: 'synthetic output' });
const copy = await begin({ kind: 'clipboard.write', title: 'Copy result', text: 'synthetic output' });
assert.deepEqual(writes, []);
assert.equal(document.querySelector('textarea')?.value, 'synthetic output');
await click(button('Copy to clipboard'));
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'));
assert.deepEqual(await share.promise, { cancelled: false, value: 'synthetic private text' });
assert.equal(JSON.stringify(audits).includes('synthetic private text'), false);
const cancelled = await begin({ kind: 'clipboard.write', title: 'Cancelled copy', text: 'must not copy' });
await click(button('Cancel'));
assert.deepEqual(await cancelled.promise, { cancelled: true });
assert.deepEqual(writes, ['synthetic output']);
assert.equal(service.pending.size, 0);
} finally {
await act(async () => { root.unmount(); await settle(); }); service.shutdown(); dom.window.close();
for (const [key, descriptor] of previous) { if (descriptor) Object.defineProperty(globalThis, key, descriptor); else Reflect.deleteProperty(globalThis, key); }
}
});
+128
View File
@@ -0,0 +1,128 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { JSDOM } from 'jsdom';
test('terminal extension pages use actual split panes, focus and session ownership without sharing session data', async () => {
const dom = new JSDOM('<!doctype html><html><body><div id="root"></div></body></html>', { pretendToBeVisual: true, url: 'http://localhost' });
const previous = new Map<string, PropertyDescriptor | undefined>();
const install = (key: string, value: unknown) => {
previous.set(key, Object.getOwnPropertyDescriptor(globalThis, key)); Object.defineProperty(globalThis, key, { configurable: true, writable: true, value });
};
for (const key of ['window', 'document', 'navigator', 'HTMLElement', 'HTMLInputElement', 'Element', 'SVGElement', 'Node', 'NodeFilter', 'MutationObserver', 'CustomEvent', 'Event', 'KeyboardEvent', 'MouseEvent']) {
install(key, key === 'window' ? dom.window : dom.window[key as keyof typeof dom.window]);
}
install('PointerEvent', dom.window.MouseEvent);
install('getComputedStyle', dom.window.getComputedStyle.bind(dom.window));
install('requestAnimationFrame', dom.window.requestAnimationFrame.bind(dom.window)); install('cancelAnimationFrame', dom.window.cancelAnimationFrame.bind(dom.window)); install('IS_REACT_ACT_ENVIRONMENT', true);
let rectX = 200;
dom.window.HTMLElement.prototype.getBoundingClientRect = () => new dom.window.DOMRect(rectX, 100, 400, 300);
const { default: React, act, useCallback, useRef, useState } = await import('react');
const { createRoot } = await import('react-dom/client');
const { I18nProvider } = await import('../application/i18n/I18nProvider');
const { activeTabStore } = await import('../application/state/activeTabStore');
const { sidePanelLiveStore, SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT } = await import('../application/state/sidePanelLiveStore');
const { useTerminalSidePanelLayoutState } = await import('../application/state/useTerminalSidePanelLayoutState');
const { usePluginTerminalSidebar } = await import('../application/state/usePluginTerminalSidebar');
const { PluginTerminalSidebarLauncher, PluginTerminalSidebarPageHost } = await import('./plugins/PluginTerminalSidebar');
const { SidePanelLayoutTree } = await import('./terminalLayer/TerminalLayerSidePanelSection');
const { PersistentSidePanelPortal } = await import('./terminalLayer/terminalLayerSidePanelSlots');
const { collectSidePanelPanes } = await import('../domain/sidePanelLayout');
type Tool = import('../domain/sidePanelLayout').SidePanelTool;
let selectedSession = 'synthetic-session-a';
const getSession = () => selectedSession;
const a = { id: 'com.example.sidebar.first', title: 'First page', location: 'terminal-sidebar', entry: 'page.html', visible: true };
const b = { ...a, id: 'com.example.sidebar.second', title: 'Second page' };
let snapshot = { locale: 'en', plugins: [{ id: 'com.example.sidebar', displayName: 'Synthetic plugin', version: '1.0.0', commands: [], keybindings: [], menus: [], settings: [], views: [a, b] }] };
const changed = new Set<() => void>(), focusListeners = new Set<(event: { instanceId: string }) => void>();
const opens: NetcattyPluginViewOpenRequest[] = [], closes: string[] = [], focus: string[] = [], bounds: Array<{ instanceId: string; x: number }> = [];
const visible = new Map<string, boolean>();
Object.assign(dom.window, { netcatty: {
async getPluginRuntimeStatus() { return { available: true }; }, async getPluginContributions() { return snapshot; },
onPluginContributionsChanged(fn: () => void) { changed.add(fn); return () => { changed.delete(fn); }; },
onPluginViewClosed() { return () => {}; },
onPluginViewFocused(fn: (event: { instanceId: string }) => void) { focusListeners.add(fn); return () => { focusListeners.delete(fn); }; },
async openPluginView(payload: NetcattyPluginViewOpenRequest) { opens.push(payload); return { instanceId: payload.instanceId! }; },
async closePluginView(id: string) { closes.push(id); },
async setPluginViewBounds(id: string, rect: { x: number }) { bounds.push({ instanceId: id, x: rect.x }); },
async setPluginViewVisibility(id: string, value: boolean) { visible.set(id, value); },
async focusPluginView(event: { instanceId: string }) { focus.push(event.instanceId); for (const fn of focusListeners) fn(event); },
} });
let split!: () => void;
function Fixture() {
const state = useTerminalSidePanelLayoutState(); const latest = useRef(state); latest.current = state;
const model = usePluginTerminalSidebar({ layouts: state.sidePanelLayouts, getSession, isTerminalTab: (id) => id === 'workspace-synthetic' || id === 'synthetic-local-terminal',
select: (tool) => state.selectSidePanelToolForTab(activeTabStore.getActiveTabId(), tool),
withdraw: (tabId, paneId, tool) => {
const layout = state.sidePanelLayoutsRef.current.get(tabId);
if (layout && collectSidePanelPanes(layout.root).some((pane) => pane.id === paneId && pane.tool === tool)) state.closePane(tabId, paneId);
} });
const [hosts, setHosts] = useState(new Map<Tool, HTMLElement>()); const parking = useRef<HTMLDivElement>(null);
const register = useCallback((tool: Tool, host: HTMLElement | null) => setHosts((prior) => {
if ((prior.get(tool) ?? null) === host) return prior;
const next = new Map(prior); if (host) next.set(tool, host); else next.delete(tool); return next;
}), []);
const focusPane = useCallback((id: string) => latest.current.focusPane(activeTabStore.getActiveTabId(), id), []);
const closePane = useCallback((id: string) => { latest.current.closePane(activeTabStore.getActiveTabId(), id); }, []);
const resize = useCallback(() => {}, []);
const layout = model.tabId ? state.sidePanelLayouts.get(model.tabId) : null;
const panes = layout ? collectSidePanelPanes(layout.root) : [];
split = () => state.splitPane('workspace-synthetic', `plugin-view:${b.id}`, 'vertical', { paneId: 'second-pane', splitId: 'plugin-split' }, 600);
return <><PluginTerminalSidebarLauncher sessionId={selectedSession} />
{layout && <SidePanelLayoutTree node={layout.root} layout={layout} paneCount={panes.length} labels={new Map(model.pages.map((page) => [page.tool, page.title]))}
onClose={closePane} onFocus={focusPane} onHostChange={register} onResize={resize} separator="#777" accent="#4488cc" closePaneLabel="Close pane" resizeLabel="Resize" />}
<div ref={parking} hidden aria-hidden="true" />
{model.sessionId && panes.map((pane) => {
const page = model.pages.find((item) => item.tool === pane.tool); const target = hosts.get(pane.tool) ?? parking.current;
if (!page || !target) return null;
return <PersistentSidePanelPortal key={`${pane.id}:${model.sessionId}`} portalKey={`plugin-${pane.id}`} target={target}>
<PluginTerminalSidebarPageHost page={page} tabId={model.tabId!} sessionId={model.sessionId} getSession={getSession} adapter={model.contributions}
focusOnReveal={layout?.focusedPaneId === pane.id} focused={() => focusPane(pane.id)} unavailable={() => closePane(pane.id)} />
</PersistentSidePanelPortal>;
})}</>;
}
const root = createRoot(document.getElementById('root')!); const settle = () => new Promise((resolve) => setTimeout(resolve, 40));
try {
activeTabStore.setActiveTabId('workspace-synthetic'); sidePanelLiveStore.update({ ...SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT, focusedSessionId: selectedSession });
await act(async () => { root.render(<I18nProvider locale="en"><Fixture /></I18nProvider>); await settle(); });
await act(async () => { document.querySelector<HTMLButtonElement>('button[aria-label="Extension pages"]')!.click(); await settle(); });
const firstButton = [...document.querySelectorAll('button')].find((button) => button.textContent?.includes('First page'))!;
await act(async () => { firstButton.click(); await settle(); });
assert.equal(document.querySelectorAll('[data-section="terminal-side-panel-pane"]').length, 1);
assert.ok(document.querySelector('[data-pane-tool] [data-plugin-view-slot="terminal-sidebar"]'));
assert.equal(opens.length, 1); assert.deepEqual(opens[0].context, { 'netcatty.surface': 'terminal-sidebar' });
await act(async () => { split(); await settle(); });
assert.equal(document.querySelectorAll('[data-section="terminal-side-panel-pane"]').length, 2);
assert.equal(opens.length, 2);
const firstId = opens.find((item) => item.viewId === a.id)!.instanceId!;
await act(async () => { for (const fn of focusListeners) fn({ instanceId: firstId }); await settle(); });
assert.equal(document.querySelector(`[data-pane-tool="plugin-view:${a.id}"]`)?.getAttribute('data-focused'), 'true');
const dialog = document.createElement('div'); dialog.setAttribute('role', 'dialog'); dialog.setAttribute('aria-modal', 'true');
await act(async () => { document.body.append(dialog); await settle(); });
assert.ok(opens.every((item) => visible.get(item.instanceId!) === false));
focus.length = 0;
await act(async () => { dialog.remove(); await settle(); });
assert.ok(focus.length > 0); assert.ok(focus.every((id) => id === firstId), 'restoring panes focuses only the host-selected pane');
const old = opens.map((item) => item.instanceId!);
await act(async () => { selectedSession = 'synthetic-session-b'; sidePanelLiveStore.update({ ...SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT, focusedSessionId: selectedSession });
assert.ok(old.every((id) => closes.includes(id)), 'workspace focus cancels old native owners synchronously'); await settle(); });
assert.equal(opens.length, 4);
assert.notEqual(opens[0].scopeId, opens[2].scopeId);
assert.ok(opens.every((item) => JSON.stringify(item.context) === JSON.stringify({ 'netcatty.surface': 'terminal-sidebar' })));
await act(async () => { rectX = 260; snapshot = { ...snapshot, plugins: [{ ...snapshot.plugins[0], views: [{ ...a, visible: false }, b] }] }; for (const fn of changed) fn(); await settle(); });
assert.equal(document.querySelectorAll('[data-section="terminal-side-panel-pane"]').length, 1);
assert.equal(document.querySelector('[data-pane-tool]')?.getAttribute('data-pane-tool'), `plugin-view:${b.id}`);
assert.ok(bounds.some((item) => item.x === 260), 'portal relocation updates native geometry');
const last = opens.at(-1)!.instanceId!;
await act(async () => { activeTabStore.setActiveTabId('vault'); assert.ok(closes.includes(last)); await settle(); });
assert.equal(document.querySelector('[data-plugin-terminal-sidebar]'), null);
await act(async () => { selectedSession = 'synthetic-local-terminal'; activeTabStore.setActiveTabId(selectedSession); await settle(); });
await act(async () => { document.querySelector<HTMLButtonElement>('button[aria-label="Extension pages"]')!.click(); await settle(); });
const localPage = [...document.querySelectorAll('button')].find((button) => button.textContent?.includes('Second page'))!;
await act(async () => { localPage.click(); await settle(); });
assert.ok(document.querySelector('[data-plugin-view-slot="terminal-sidebar"]'), 'standalone Local Terminal is a valid navigation owner');
assert.deepEqual(opens.at(-1)!.context, { 'netcatty.surface': 'terminal-sidebar' });
} finally {
await act(async () => { root.unmount(); await settle(); }); dom.window.close();
for (const [key, descriptor] of previous) { if (descriptor) Object.defineProperty(globalThis, key, descriptor); else Reflect.deleteProperty(globalThis, key); }
}
});
+101
View File
@@ -0,0 +1,101 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { JSDOM } from 'jsdom';
test('Vault navigation mounts a real content slot, yields to host dialogs, and withdraws stale pages', 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>();
for (const key of ['window', 'document', 'navigator', 'HTMLElement', 'Element', 'Node', 'MutationObserver', 'CustomEvent', 'Event']) {
previous.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
Object.defineProperty(globalThis, key, { configurable: true, writable: true, value: key === 'window' ? dom.window : dom.window[key as keyof Window] });
}
for (const [key, value] of Object.entries({ getComputedStyle: dom.window.getComputedStyle.bind(dom.window),
requestAnimationFrame: dom.window.requestAnimationFrame.bind(dom.window), cancelAnimationFrame: dom.window.cancelAnimationFrame.bind(dom.window), IS_REACT_ACT_ENVIRONMENT: true })) {
previous.set(key, Object.getOwnPropertyDescriptor(globalThis, key)); Object.defineProperty(globalThis, key, { configurable: true, writable: true, value });
}
dom.window.HTMLElement.prototype.getBoundingClientRect = () => ({ x: 200, y: 100, width: 640, height: 480, top: 100, left: 200, right: 840, bottom: 580, toJSON() {} });
const { default: React, act, useState } = await import('react');
const { createRoot } = await import('react-dom/client');
const { usePluginVaultPages } = await import('../application/state/usePluginVaultPages');
const { PluginVaultNavigation, PluginVaultPage } = await import('./plugins/PluginVaultPages');
const { activeTabStore } = await import('../application/state/activeTabStore');
const { requestOpenPluginView } = await import('../application/state/usePluginViewLifecycle');
const { I18nProvider } = await import('../application/i18n/I18nProvider');
const page = { id: 'com.example.page.main', title: 'Synthetic page', location: 'vault-page', entry: 'page.html', visible: true };
let snapshot = { locale: 'en', plugins: [{ id: 'com.example.page', version: '1.0.0', displayName: 'Synthetic plugin', commands: [], keybindings: [], menus: [], settings: [], views: [page] }] };
let changed: (() => void) | undefined;
const listeners = new Set<(event: NetcattyPluginViewClosedEvent) => void>();
const opens: NetcattyPluginViewOpenRequest[] = [], closes: string[] = [], visibility: boolean[] = [];
let defer: ((value: { instanceId: string }) => void) | undefined;
let deferOpen = false;
let delayLayout = false;
const rejectOldLayout: Array<(error: Error) => void> = [];
const layoutResult = () => delayLayout ? new Promise<void>((_resolve, reject) => { rejectOldLayout.push(reject); }) : Promise.resolve();
Object.assign(dom.window, { netcatty: {
async getPluginRuntimeStatus() { return { available: true }; },
async getPluginContributions() { return snapshot; },
onPluginContributionsChanged(fn: () => void) { changed = fn; return () => { changed = undefined; }; },
onPluginViewClosed(fn: (event: NetcattyPluginViewClosedEvent) => void) { listeners.add(fn); return () => { listeners.delete(fn); }; },
async openPluginView(payload: NetcattyPluginViewOpenRequest) { opens.push(payload); return deferOpen ? new Promise<{ instanceId: string }>((resolve) => { defer = resolve; }) : { instanceId: payload.instanceId! }; },
async closePluginView(id: string) { closes.push(id); }, async setPluginViewBounds() { await layoutResult(); },
async setPluginViewVisibility(_id: string, visible: boolean) { visibility.push(visible); await layoutResult(); },
} });
let selectSection!: (section: 'hosts' | `plugin-vault:${string}`) => void;
function Fixture() {
const [section, select] = useState<'hosts' | `plugin-vault:${string}`>('hosts'); selectSection = select;
const model = usePluginVaultPages({ locale: 'en', section, select });
return <><aside><button data-plugin-vault-fallback onClick={() => select('hosts')}>Hosts</button><PluginVaultNavigation model={model} collapsed={false} /></aside>
<main data-section="vault-main">{model.selected ? <PluginVaultPage key={model.selected.key} model={model} /> : <p>Built-in hosts</p>}</main></>;
}
const root = createRoot(document.getElementById('root')!);
const settle = () => new Promise((resolve) => setTimeout(resolve, 35));
const clickPage = async () => { await act(async () => { document.querySelector<HTMLButtonElement>('button[aria-label="Synthetic page — Synthetic plugin"]')!.click(); await settle(); }); };
try {
activeTabStore.setActiveTabId('vault');
await act(async () => { root.render(<I18nProvider locale="en"><Fixture /></I18nProvider>); await settle(); });
assert.equal(opens.length, 0, 'catalog enumeration does not activate the plugin');
await clickPage();
assert.ok(document.querySelector('[data-section="vault-main"] [data-plugin-view-slot="vault-page"]'));
assert.equal(opens.length, 1);
assert.deepEqual(opens[0].context, { 'netcatty.surface': 'vault-page' }, 'no Vault/session data accompanies navigation');
assert.equal(visibility.at(-1), true);
const dialog = document.createElement('div'); dialog.setAttribute('role', 'dialog'); dialog.setAttribute('aria-modal', 'true');
await act(async () => { document.body.append(dialog); await settle(); });
assert.equal(visibility.at(-1), false, 'native content yields to host security/settings dialogs');
await act(async () => { dialog.remove(); await settle(); });
assert.equal(visibility.at(-1), true);
await act(async () => { activeTabStore.setActiveTabId('terminal:synthetic'); assert.ok(closes.includes(opens[0].instanceId!)); await settle(); });
await act(async () => { activeTabStore.setActiveTabId('vault'); await settle(); });
assert.equal(opens.length, 2);
await act(async () => { snapshot = { ...snapshot, plugins: [] }; changed?.(); await settle(); });
await act(async () => { await settle(); });
assert.ok(closes.includes(opens[1].instanceId!));
assert.equal(document.querySelector('[data-plugin-vault-page]'), null);
assert.equal(document.activeElement?.textContent, 'Hosts');
await act(async () => { selectSection('plugin-vault:missing'); await settle(); });
assert.match(document.querySelector('main')!.textContent!, /Built-in hosts/);
await act(async () => { snapshot = { ...snapshot, plugins: [{ id: 'com.example.page', version: '1.0.0', displayName: 'Synthetic plugin', commands: [], keybindings: [], menus: [], settings: [], views: [page] }] }; changed?.(); await settle(); });
deferOpen = true;
await act(async () => { requestOpenPluginView({ viewId: page.id }); await settle(); });
assert.equal(opens.length, 3);
await act(async () => { selectSection('hosts'); await settle(); });
assert.ok(closes.includes(opens[2].instanceId!), 'cancel reserved instance before its open response');
await act(async () => { defer!({ instanceId: opens[2].instanceId! }); await settle(); });
assert.equal(document.querySelector('[data-plugin-vault-page]'), null, 'late open cannot restore withdrawn page');
assert.ok(closes.filter((id) => id === opens[2].instanceId).length >= 2, 'late native response is also closed');
deferOpen = false;
const second = { ...page, id: 'com.example.page.second', title: 'Second page' };
await act(async () => { snapshot = { ...snapshot, plugins: [{ ...snapshot.plugins[0], views: [page, second] }] }; changed?.(); await settle(); });
delayLayout = true;
await clickPage();
assert.ok(rejectOldLayout.length >= 2, 'old page has both bounds and visibility IPC in flight');
await act(async () => { selectSection('hosts'); await settle(); });
delayLayout = false;
await act(async () => { document.querySelector<HTMLButtonElement>('button[aria-label="Second page — Synthetic plugin"]')!.click(); await settle(); });
await act(async () => { for (const reject of rejectOldLayout) reject(new Error('old page layout failed')); await settle(); });
assert.equal(document.querySelector('[data-plugin-vault-page]')?.getAttribute('aria-label'), 'Second page', 'old bounds/visibility failures cannot withdraw the newly selected page');
} finally {
await act(async () => { root.unmount(); await settle(); }); dom.window.close();
for (const [key, descriptor] of previous) { if (descriptor) Object.defineProperty(globalThis, key, descriptor); else Reflect.deleteProperty(globalThis, key); }
}
});
+6 -1
View File
@@ -1,3 +1,5 @@
import { useAppThemeStyle } from "../application/app/useAppThemeStyle";
import { selectPluginThemeTokens } from "../application/state/pluginContributionEnvironment";
/**
* Settings Page - Standalone settings window content
* This component is rendered in a separate Electron window
@@ -322,6 +324,9 @@ const SettingsSyncTabWithVault: React.FC<{ onSettingsApplied?: () => void }> = (
};
const SettingsPageContent: React.FC<{ settings: SettingsState; appLock?: AppLockState }> = ({ settings, appLock }) => {
// This publisher also runs in detached Settings: never broadcast a local preview.
const committedAppThemeStyle = useAppThemeStyle(false);
const pluginThemeTokens = useMemo(() => selectPluginThemeTokens(committedAppThemeStyle as Record<string, unknown>), [committedAppThemeStyle]);
const { t } = useI18n();
const { request, clearFocus, openSearch } = useSettingsFocus();
const { notifyRendererReady, closeSettingsWindow, onWindowCommandCloseRequested } = useWindowControls();
@@ -715,7 +720,7 @@ const SettingsPageContent: React.FC<{ settings: SettingsState; appLock?: AppLock
</Tabs>
</div>
<ExternalMcpApprovalsHost />
<PluginContributionHost locale={settings.uiLanguage} theme={settings.resolvedTheme} />
<PluginContributionHost locale={settings.uiLanguage} theme={settings.resolvedTheme} themeTokens={pluginThemeTokens} />
</>
);
};
+14
View File
@@ -123,6 +123,7 @@ import {
import { classifyDistroId, hostRestrictsExtraSshChannels, shouldProbeSessionCwd } from '../domain/host';
import {
collectSidePanelPanes,
isPluginSidePanelTool,
sidePanelLayoutHasTool,
type SidePanelLayout,
type SidePanelSplitDirection,
@@ -1933,6 +1934,16 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
resizeSidePanelSplitForTab(tabId, splitId, sizes);
}, [resizeSidePanelSplitForTab]);
const handleWithdrawPluginSidebarPane = useCallback((tabId: string, paneId: string, expectedTool: SidePanelTab) => {
if (!isPluginSidePanelTool(expectedTool)) return;
const layout = sidePanelLayoutsRef.current.get(tabId);
const pane = layout && collectSidePanelPanes(layout.root).find((item) => item.id === paneId);
if (pane?.tool !== expectedTool) return;
setMagnifiedPane((current) => current?.tabId === tabId && current.target.kind === 'side-panel'
&& current.target.paneId === paneId ? null : current);
closeSidePanelPaneForTab(tabId, paneId);
}, [closeSidePanelPaneForTab, sidePanelLayoutsRef]);
// Toggle SFTP from activity bar header
const handleToggleSftpFromBar = useCallback(() => {
handleSwitchSidePanelTab('sftp');
@@ -2617,6 +2628,9 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
handleOpenAI,
handleOpenSystem,
handleOpenNotes,
getActiveTerminalSessionId,
handleSwitchSidePanelTab,
handleWithdrawPluginSidebarPane,
handleOpenBrowser,
handleFocusSidePanelPane,
handleMagnifySidePanelPane,
+6 -2
View File
@@ -161,6 +161,7 @@ import { VaultViewLayout } from "./vault/VaultViewLayout";
import { useVaultHostCollections } from "./vault/useVaultHostCollections";
import { useVaultImportHandlers } from "../application/state/useVaultImportHandlers";
import { useVaultGroupDragHandlers } from "./vault/useVaultGroupDragHandlers";
import { usePluginVaultPages } from "../application/state/usePluginVaultPages";
const LazyProtocolSelectDialog = lazy(() => import("./ProtocolSelectDialog"));
const LazyConnectionLogsManager = lazy(() => import("./ConnectionLogsManager"));
@@ -178,7 +179,8 @@ export type VaultSection =
| "notes"
| "port"
| "knownhosts"
| "logs";
| "logs"
| `plugin-vault:${string}`;
const haveSameHostOrderResult = (previous: Host[], next: Host[]) => {
if (previous.length !== next.length) return false;
@@ -365,11 +367,12 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
onVaultFocusRequestHandled,
terminalSettings,
}) => {
const { t } = useI18n();
const { t, locale } = useI18n();
const rootRef = useRef<HTMLDivElement>(null);
const hostsRef = useRef(hosts);
hostsRef.current = hosts;
const [currentSection, setCurrentSection] = useState<VaultSection>("hosts");
const pluginVault = usePluginVaultPages({ locale, section: currentSection, select: setCurrentSection });
const [search, setSearch] = useState("");
const [selectedGroupPath, setSelectedGroupPath] = useState<string | null>(
null,
@@ -1522,6 +1525,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
ContextMenuTrigger,
Copy,
currentSection,
pluginVault,
customGroups,
deleteGroupPath,
deleteGroupPaths,
@@ -0,0 +1,22 @@
import React from 'react';
import type { TerminalSession, Workspace } from '../../domain/models';
import { usePluginAIContextHost } from '../../application/state/usePluginAIContextHost';
import { Button } from '../ui/button';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '../ui/dialog';
export function PluginAIContextHost({ sessions, workspaces }: { sessions: TerminalSession[]; workspaces: Workspace[] }) {
const { choice, noteId, setNoteId, noteChoices } = usePluginAIContextHost({ sessions, workspaces });
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>
<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>
{noteChoices.map((note) => <option key={note.id} value={note.id}>{note.title}</option>)}
</select>
<DialogFooter><Button variant="outline" onClick={() => choice?.cancel()}>Cancel</Button>
<Button onClick={() => choice?.confirm(noteId)}>Share selected summaries</Button></DialogFooter>
</DialogContent>
</Dialog>;
}
@@ -0,0 +1,54 @@
import React, { useState } from 'react';
import { PLUGIN_HOST_INTERACTION_LIMITS } from '@netcatty/plugin-contract';
import { useI18n } from '../../application/i18n/I18nProvider';
import { usePluginHostInteractions } from '../../application/state/usePluginHostInteractions';
import { Button } from '../ui/button';
import { Input } from '../ui/input';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '../ui/dialog';
export function PluginHostInteractionHost() {
const state = usePluginHostInteractions();
if (!state.prompt) return null;
return <HostInteractionDialog key={state.prompt.requestId} state={state} prompt={state.prompt} />;
}
function HostInteractionDialog({ state, prompt }: {
state: ReturnType<typeof usePluginHostInteractions>;
prompt: NonNullable<ReturnType<typeof usePluginHostInteractions>['prompt']>;
}) {
const { t } = useI18n();
const [search, setSearch] = useState('');
const request = prompt.request;
const previewClipboard = request.kind === 'clipboard.read' && prompt.phase === 'request';
const textBytes = new TextEncoder().encode(state.value).length;
const canSubmit = request.kind === 'quickPick' ? request.items.some((item) => item.id === state.value) : textBytes <= PLUGIN_HOST_INTERACTION_LIMITS.textBytes;
return <Dialog open onOpenChange={(open) => { if (!open) void state.respond('cancel'); }}>
<DialogContent className="sm:max-w-[520px]" hideCloseButton>
<DialogHeader>
<DialogTitle>{t('plugins.interaction.title')}</DialogTitle>
<DialogDescription className="break-all">{prompt.pluginId} · {prompt.pluginVersion}</DialogDescription>
</DialogHeader>
<p className="break-words text-sm font-medium">{request.title}</p>
<p className="text-xs text-muted-foreground">{t('plugins.interaction.sharing')}</p>
{request.kind === 'input' && <Input aria-label={t('plugins.interaction.input')} value={state.value} maxLength={PLUGIN_HOST_INTERACTION_LIMITS.textBytes} autoComplete="off" autoFocus disabled={state.busy} onChange={(event) => state.setValue(event.target.value)} />}
{request.kind === 'quickPick' && <>
<Input aria-label={t('plugins.interaction.filter')} value={search} onChange={(event) => setSearch(event.target.value)} autoFocus disabled={state.busy} />
<div className="max-h-64 space-y-2 overflow-y-auto" role="radiogroup" aria-label={request.title}>
{request.items.filter((item) => `${item.label} ${item.description ?? ''}`.toLocaleLowerCase().includes(search.toLocaleLowerCase())).map((item) => <label key={item.id} className="flex items-start gap-3 rounded-md border p-3">
<input type="radio" name="plugin-host-choice" value={item.id} checked={state.value === item.id} disabled={state.busy} onChange={() => state.setValue(item.id)} />
<span className="min-w-0 break-words text-sm">{item.label}{item.description && <span className="block text-xs text-muted-foreground">{item.description}</span>}</span>
</label>)}
</div>
</>}
{previewClipboard && <p className="text-sm">{t('plugins.interaction.previewNote')}</p>}
{(request.kind === 'clipboard.write' || prompt.phase === 'preview') && <textarea aria-label={t('plugins.interaction.preview')} readOnly className="min-h-28 max-h-64 w-full resize-none rounded-md border bg-muted/30 p-3 font-mono text-xs" value={request.kind === 'clipboard.write' ? request.text : prompt.previewText ?? ''} />}
{state.error && <p role="alert" className="text-sm text-destructive">{t('plugins.interaction.failed')}</p>}
<DialogFooter>
<Button variant="outline" disabled={state.busy} onClick={() => void state.respond('cancel')}>{t('common.cancel')}</Button>
<Button disabled={state.busy || !canSubmit} onClick={() => void state.respond(previewClipboard ? 'preview' : 'submit')}>
{t(previewClipboard ? 'plugins.interaction.previewAction' : request.kind === 'clipboard.read' ? 'plugins.interaction.share' : request.kind === 'clipboard.write' ? 'plugins.interaction.copy' : 'common.continue')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>;
}
@@ -0,0 +1,56 @@
import { useMemo } from 'react';
import { Blocks } from 'lucide-react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { activeTabStore } from '../../application/state/activeTabStore';
import { subscribeSidePanelLiveSnapshot } from '../../application/state/sidePanelLiveStore';
import { useEmbeddedPluginView } from '../../application/state/useEmbeddedPluginView';
import { usePluginContributions } from '../../application/state/usePluginContributions';
import { collectPluginTerminalSidebarPages, PLUGIN_TERMINAL_SIDEBAR_CONTEXT, requestOpenPluginTerminalSidebar,
type PluginTerminalSidebarPage } from '../../application/state/usePluginTerminalSidebar';
import { PluginContributionIcon } from './PluginContributionIcon';
import { Button } from '../ui/button';
import { Popover, PopoverClose, PopoverContent, PopoverTrigger } from '../ui/popover';
export function PluginTerminalSidebarLauncher({ sessionId }: { sessionId: string }) {
const { locale, t } = useI18n();
const contributions = usePluginContributions({ locale, context: PLUGIN_TERMINAL_SIDEBAR_CONTEXT });
const pages = collectPluginTerminalSidebarPages(contributions.snapshot.plugins);
if (!pages.length) return null;
return <Popover><PopoverTrigger asChild><Button variant="ghost" size="icon" className="h-6 w-6" aria-label={t('plugins.sidebar.extensions')}><Blocks size={14} /></Button></PopoverTrigger>
<PopoverContent align="end" className="max-h-80 w-64 overflow-y-auto p-1">
<p className="px-2 py-1 text-xs text-muted-foreground">{t('plugins.sidebar.extensions')}</p>
{pages.map((page) => <PopoverClose asChild key={page.tool}><button type="button" className="flex w-full items-center gap-2 rounded px-2 py-2 text-left text-sm hover:bg-accent"
onClick={() => requestOpenPluginTerminalSidebar(page.viewId, sessionId)}>
<PluginContributionIcon pluginId={page.pluginId} icon={page.icon} /><span className="min-w-0"><span className="block truncate">{page.title}</span><span className="block truncate text-xs text-muted-foreground">{page.pluginName}</span></span>
</button></PopoverClose>)}
</PopoverContent></Popover>;
}
export function PluginTerminalSidebarPageHost({ page, tabId, sessionId, adapter, getSession, unavailable, focused, focusOnReveal }: {
page: PluginTerminalSidebarPage;
tabId: string;
sessionId: string;
adapter: ReturnType<typeof usePluginContributions>;
getSession(): string | null;
unavailable(): void;
focused(): void;
focusOnReveal: boolean;
}) {
const { t } = useI18n();
const subscribeHidden = useMemo(() => (hide: () => void) => {
const stopTab = activeTabStore.subscribeSync((id) => { if (id !== tabId) hide(); });
const stopSession = subscribeSidePanelLiveSnapshot(true, () => { if (getSession() !== sessionId) hide(); });
return () => { stopTab(); stopSession(); };
}, [getSession, sessionId, tabId]);
const { mountRef, loading } = useEmbeddedPluginView({ viewId: page.viewId, version: page.version,
slot: `terminal-sidebar:${tabId}:${sessionId}`, active: true, context: PLUGIN_TERMINAL_SIDEBAR_CONTEXT,
adapter, subscribeHidden, unavailable, focused, focusOnReveal });
// The native page never receives these internal ownership IDs. Its state is
// scoped by the host; this does not authorize reading the terminal session.
return <section className="flex h-full min-h-0 flex-col overflow-hidden" aria-label={`${page.title} — ${page.pluginName}`} data-plugin-terminal-sidebar>
<header className="flex h-7 shrink-0 items-center gap-2 border-b border-border px-2 text-xs"><PluginContributionIcon pluginId={page.pluginId} icon={page.icon} size={12} /><span className="truncate">{page.title} · {page.pluginName}</span></header>
<div ref={mountRef} className="relative min-h-0 flex-1 overflow-hidden" data-plugin-view-slot="terminal-sidebar">
{loading && <p role="status" className="p-3 text-xs text-muted-foreground">{t('plugins.sidebar.loading')}</p>}
</div>
</section>;
}
+44
View File
@@ -0,0 +1,44 @@
import { activeTabStore } from '../../application/state/activeTabStore';
import { useI18n } from '../../application/i18n/I18nProvider';
import { useEmbeddedPluginView } from '../../application/state/useEmbeddedPluginView';
import type { usePluginVaultPages } from '../../application/state/usePluginVaultPages';
import { PluginContributionIcon } from './PluginContributionIcon';
import { Button } from '../ui/button';
type VaultPages = ReturnType<typeof usePluginVaultPages>;
const subscribeHidden = (hide: () => void) => activeTabStore.subscribeSync((id) => { if (id !== 'vault') hide(); });
export function PluginVaultNavigation({ model, collapsed }: { model: VaultPages; collapsed: boolean }) {
const { t } = useI18n();
if (!model.pages.length) return null;
return <div role="group" aria-label={t('plugins.vault.extensions')} className="mt-3 space-y-1 border-t border-border/50 pt-3">
{!collapsed && <p className="px-2 text-xs text-muted-foreground">{t('plugins.vault.extensions')}</p>}
{model.pages.map((page) => <Button key={page.key} type="button"
variant={model.selected?.key === page.key ? 'secondary' : 'ghost'}
className={`w-full ${collapsed ? 'justify-center px-0' : 'justify-start gap-3'}`}
title={`${page.title} — ${page.pluginName}`}
aria-label={`${page.title} — ${page.pluginName}`}
aria-current={model.selected?.key === page.key ? 'page' : undefined}
onClick={() => model.select(page.key)}>
<PluginContributionIcon pluginId={page.pluginId} icon={page.icon} className="shrink-0" />
{!collapsed && <span className="truncate">{page.title}</span>}
</Button>)}
</div>;
}
export function PluginVaultPage({ model }: { model: VaultPages }) {
const { t } = useI18n();
const page = model.selected!;
const { mountRef, loading } = useEmbeddedPluginView({ viewId: page.viewId, version: page.version, slot: 'vault-page',
active: model.active, context: model.context, adapter: model.contributions, subscribeHidden, unavailable: model.fallback });
return <section className="flex min-h-0 flex-1 flex-col" aria-label={page.title} data-plugin-vault-page={page.key}>
<header className="app-no-drag flex h-12 shrink-0 items-center justify-between gap-3 border-b border-border px-4">
<div className="min-w-0"><h2 className="truncate text-sm font-medium">{page.title}</h2>
<p className="truncate text-xs text-muted-foreground">{page.pluginName} · {page.version}</p></div>
<Button variant="ghost" onClick={model.fallback}>{t('plugins.vault.back')}</Button>
</header>
<div ref={mountRef} className="relative min-h-0 flex-1 overflow-hidden" data-plugin-view-slot="vault-page">
{loading && <p role="status" className="p-4 text-sm text-muted-foreground">{t('plugins.vault.loading')}</p>}
</div>
</section>;
}
@@ -52,7 +52,7 @@ test('information previews on hover/focus, pins on click and Escape returns focu
const entry = { id: 'com.example.guide', name: 'Guide', author: 'Example', version: '1.0.0', license: 'MIT', repository: 'example/plugin', commit: 'a'.repeat(40), description: 'Synthetic metadata',
manifest: { id: 'com.example.guide', name: 'Guide', publisher: 'Example', main: { browser: 'index.js' }, engines: { netcatty: '*', api: '0.1.0-internal' } },
} as NetcattyPluginCatalogEntry;
const catalog = { entries: [entry], source: 'registry', listStatus: 'ready', listError: null, readme: '# Guide', readmeStatus: 'ready', readmeError: null, error: null, busy: false, retryList() {}, retryReadme() {}, async install() {} } as ReturnType<typeof import('../../../application/state/usePluginCatalog').usePluginCatalog>;
const catalog = { entries: [entry], source: 'registry', sourceBinding: null, listStatus: 'ready', listError: null, readme: '# Guide', readmeStatus: 'ready', readmeError: null, error: null, busy: false, retryList() {}, retryReadme() {}, async install() {} } as ReturnType<typeof import('../../../application/state/usePluginCatalog').usePluginCatalog>;
function Browser() {
const [selected, setSelected] = useState<string | null>(null);
const [toolbar, setToolbar] = useState<HTMLDivElement | null>(null);
@@ -104,8 +104,28 @@ test('information previews on hover/focus, pins on click and Escape returns focu
await act(async () => { healthy.click(); await settle(); });
assert.match(document.querySelector('[role="dialog"]')?.textContent ?? '', /Updates are manual/);
await act(async () => { document.querySelector('[role="dialog"]')!.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true })); await settle(); });
const thirdParty: NetcattyPluginSource = { id: 'third-party:synthetic', kind: 'third-party', name: 'Independent source', origin: 'https://example.com' };
await act(async () => { root.render(<I18nProvider locale="en"><PluginMarketplaceContent marketSource={thirdParty} catalog={{ ...catalog, entries: [], listError: 'ECONNREFUSED', source: 'cached' }} installed={[]} selected={null} onSelect={() => {}} /></I18nProvider>); await settle(); });
const thirdStatus = document.querySelector<HTMLButtonElement>('button[aria-label$="Notices: 1"]')!;
await act(async () => { thirdStatus.focus(); await settle(); });
const thirdPreview = document.querySelector('[role="tooltip"]')?.textContent ?? '';
assert.match(thirdPreview, /provided by the selected third party/);
assert.match(thirdPreview, /If a previously validated catalog exists for this source/);
assert.doesNotMatch(thirdPreview, /bundled examples|Curated open-source|First-party/);
assert.equal(document.querySelector('[role="tooltip"] button'), null);
await act(async () => { thirdStatus.click(); await settle(); });
assert.match(document.querySelector('[role="dialog"]')?.textContent ?? '', /ECONNREFUSED/);
assert.match(document.querySelector('[role="dialog"]')?.textContent ?? '', /Retry/);
assert.doesNotMatch(document.querySelector('[role="dialog"]')?.textContent ?? '', /bundled examples|Curated open-source/);
await act(async () => { document.querySelector('[role="dialog"]')!.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true })); await settle(); });
await act(async () => { root.render(<I18nProvider locale="en"><PluginMarketplaceContent marketSource={thirdParty} catalog={catalog} installed={[]} selected={entry.id} onSelect={() => {}} /></I18nProvider>); await settle(); });
const sourceInfo = document.querySelector<HTMLButtonElement>('button[aria-label="Source example"]')!;
await act(async () => { sourceInfo.click(); await settle(); });
assert.match(document.querySelector('[role="dialog"]')?.textContent ?? '', /Source-only entry from this provider/);
assert.doesNotMatch(document.querySelector('[role="dialog"]')?.textContent ?? '', /First-party/);
await act(async () => { document.querySelector('[role="dialog"]')!.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true })); await settle(); });
} finally {
await act(async () => root.unmount());
await act(async () => { root.unmount(); await settle(); });
dom.window.close();
for (const [key, descriptor] of previous) {
if (descriptor) Object.defineProperty(globalThis, key, descriptor); else Reflect.deleteProperty(globalThis, key);
@@ -53,6 +53,11 @@ test('installed grid filters, opens one detail and restores search, scroll and c
assert.equal(pane.scrollTop, 160); assert.equal(focus.at(-1), plugins[0].id);
await act(async () => renderer.root.findByType('input').props.onChange({ target: { value: 'none' } }));
assert.equal(cards().length, 0);
await act(async () => renderer.root.findByType('input').props.onChange({ target: { value: '' } }));
await act(async () => renderer.update(<I18nProvider locale="en"><PluginPackageManager manager={{ ...manager, statusKnown: false, error: 'synthetic state unavailable' }} onSelectionChange={(id) => selections.push(id)} /></I18nProvider>));
const unknown = renderer.root.findAllByType('button').filter((node) => node.children.includes('Status unknown'));
assert.equal(unknown.length, 2);
assert.ok(unknown.every((node) => node.props.disabled === true && node.props['aria-pressed'] === undefined));
await act(async () => renderer.unmount());
} finally { globals.IS_REACT_ACT_ENVIRONMENT = previous; globalThis.requestAnimationFrame = raf; }
});
@@ -28,7 +28,7 @@ const entries = ['Alpha', 'Beta'].map((name) => ({
manifest: { manifestVersion: 1, id: `com.example.${name.toLowerCase()}`, name, version: '1.0.0', publisher: 'Example', engines: { netcatty: '>=1.0.0', api: '0.1.0-internal' }, main: { browser: 'index.js' }, permissions: { required: ['settings.read'] } },
})) as NetcattyPluginCatalogEntry[];
const catalog = {
entries, source: 'registry', listStatus: 'ready', listError: null, readme: '# Author documentation', readmeStatus: 'ready', readmeError: null,
entries, source: 'registry', sourceBinding: null, listStatus: 'ready', listError: null, readme: '# Author documentation', readmeStatus: 'ready', readmeError: null,
error: null, busy: false, retryList() {}, retryReadme() {}, async install() {},
} as ReturnType<typeof usePluginCatalog>;
@@ -42,7 +42,7 @@ test('catalog keeps search during selection/back, switches detail, and displays
function Harness() {
const [selected, setSelected] = useState<string | null>(null);
return <I18nProvider locale="en"><PluginMarketplaceContent catalog={catalog} selected={selected} onSelect={setSelected}
installed={[{ id: entries[0].id, activeVersion: '0.9.0', enabled: false, runtime: { lastError: 'Incompatible with this host' } } as NetcattyInstalledPlugin]} /></I18nProvider>;
installed={[{ id: entries[0].id, activeVersion: '0.9.0', enabled: false, source: { id: 'official', kind: 'official', name: 'Official', origin: 'https://plugins.netcatty.app' }, runtime: { lastError: 'Incompatible with this host' } } as NetcattyInstalledPlugin]} /></I18nProvider>;
}
try {
await act(async () => { renderer = create(<Harness />); });
@@ -158,7 +158,21 @@ test('replacing an installed plugin discloses that its running version will stop
const downloadable = { ...entries[0], artifact: { url: 'https://github.com/example/plugin/releases/download/v1/plugin.ncpkg', sha256: 'a'.repeat(64) } };
const html = renderToStaticMarkup(<I18nProvider locale="en"><PluginMarketplaceContent
catalog={{ ...catalog, entries: [downloadable] }} selected={downloadable.id} onSelect={() => {}}
installed={[{ id: downloadable.id, activeVersion: '0.9.0', enabled: true } as NetcattyInstalledPlugin]} /></I18nProvider>);
installed={[{ id: downloadable.id, activeVersion: '0.9.0', enabled: true, source: { id: 'official', kind: 'official', name: 'Official', origin: 'https://plugins.netcatty.app' } } as NetcattyInstalledPlugin]} /></I18nProvider>);
assert.match(html, /<p[^>]*>This replaces and stops the installed version\./);
assert.ok(html.indexOf('This replaces and stops') < html.indexOf('Install this version (disabled)'));
});
test('a mirror cannot claim another source installation or offer cross-source replacement', () => {
const mirror = { id: 'third-party:mirror', kind: 'third-party' as const, name: 'Netcatty', origin: 'https://mirror.example.com' };
const existing = { id: entries[0].id, activeVersion: '1.0.0', enabled: true, source: { id: 'official', kind: 'official', name: 'Official', origin: 'https://plugins.netcatty.app' } } as NetcattyInstalledPlugin;
const list = renderToStaticMarkup(<I18nProvider locale="en"><PluginMarketplaceContent catalog={catalog}
marketSource={mirror} selected={null} onSelect={() => {}} installed={[existing]} /></I18nProvider>);
assert.doesNotMatch(list, />Installed</);
const downloadable = { ...entries[0], artifact: { url: `${mirror.origin}/assets/${'a'.repeat(64)}.ncpkg`, sha256: 'a'.repeat(64) } };
const detail = renderToStaticMarkup(<I18nProvider locale="en"><PluginMarketplaceContent catalog={{ ...catalog, entries: [downloadable] }}
marketSource={mirror} selected={downloadable.id} onSelect={() => {}} installed={[existing]} /></I18nProvider>);
assert.match(detail, /installed from another source/);
assert.match(detail, /https:\/\/plugins.netcatty.app/);
assert.match(detail, /disabled=""[^>]*>Install this version/);
});
+24 -8
View File
@@ -1,5 +1,6 @@
import { createPortal } from 'react-dom';
import { PluginStatus } from './PluginStatus';
import { Dialog, DialogContent, DialogDescription, DialogTitle } from '../../ui/dialog';
import { PluginInfo } from './PluginInfo';
import React, { useRef, useState } from 'react';
import { PluginBrandIcon } from '../../plugins/PluginBrandIcon';
@@ -11,14 +12,15 @@ import { Button } from '../../ui/button';
import { PluginPackageDetails } from './PluginPackageManager';
import { PluginReadme } from './PluginReadme';
export function PluginMarketplace({ installed = [], toolbarContainer, noVault }: { noVault?: boolean; installed?: NetcattyInstalledPlugin[]; toolbarContainer?: HTMLElement | null }) {
export function PluginMarketplace({ installed = [], toolbarContainer, noVault, source }: { source?: NetcattyPluginSource; noVault?: boolean; installed?: NetcattyInstalledPlugin[]; toolbarContainer?: HTMLElement | null }) {
const [selected, setSelected] = useState<string | null>(null);
const catalog = usePluginCatalog(selected);
return <PluginMarketplaceContent catalog={catalog} installed={installed} selected={selected} onSelect={setSelected} toolbarContainer={toolbarContainer} noVault={noVault} />;
const catalog = usePluginCatalog(selected, source?.id);
return <PluginMarketplaceContent catalog={catalog} marketSource={source} installed={installed} selected={selected} onSelect={setSelected} toolbarContainer={toolbarContainer} noVault={noVault} />;
}
// The browser owns only presentation state; installation and README policy stay in the existing hook.
export function PluginMarketplaceContent({ catalog, installed, selected, onSelect, toolbarContainer, noVault }: {
export function PluginMarketplaceContent({ catalog, installed, selected, onSelect, toolbarContainer, noVault, marketSource }: {
marketSource?: NetcattyPluginSource;
noVault?: boolean;
toolbarContainer?: HTMLElement | null;
catalog: ReturnType<typeof usePluginCatalog>;
@@ -28,13 +30,19 @@ export function PluginMarketplaceContent({ catalog, installed, selected, onSelec
}) {
const { t } = useI18n();
const [search, setSearch] = useState('');
const [confirmInstall, setConfirmInstall] = useState(false);
const [clearPreviousSource, setClearPreviousSource] = useState(false);
const listRef = useRef<HTMLDivElement>(null);
const detailRef = useRef<HTMLHeadingElement>(null);
const installButtonRef = useRef<HTMLButtonElement>(null);
const lastSelection = useRef<string | null>(null);
const pageScroll = useRef(0);
const entry = catalog.entries.find((item) => item.id === selected);
const filtered = filterPluginCatalog(catalog.entries, search);
const installedEntry = installed.find((item) => item.id === entry?.id);
const sourceId = marketSource?.id ?? 'official';
const sameSource = (plugin: NetcattyInstalledPlugin) => (plugin.source?.id ?? 'local') === sourceId;
const installedEntry = installed.find((item) => item.id === entry?.id && sameSource(item));
const conflictingInstall = installed.find((item) => item.id === entry?.id && !sameSource(item));
const choose = (id: string) => {
const pane = listRef.current?.closest<HTMLElement>('[data-settings-scroll-pane]');
pageScroll.current = pane?.scrollTop ?? 0;
@@ -59,7 +67,7 @@ export function PluginMarketplaceContent({ catalog, installed, selected, onSelec
};
const refresh = !entry && <Button variant="outline" size="sm" disabled={catalog.listStatus === 'loading'} onClick={catalog.retryList}>{t('common.refresh')}</Button>;
return <section className="plugin-marketplace space-y-4" aria-label={t('settings.plugins.market.title')}>
{toolbarContainer ? createPortal(<><PluginStatus noVault={noVault} offlineError={catalog.listError} retry={catalog.retryList} />{refresh}</>, toolbarContainer) : <div className="flex items-center gap-2"><PluginStatus noVault={noVault} offlineError={catalog.listError} retry={catalog.retryList} />{refresh}</div>}
{toolbarContainer ? createPortal(<><PluginStatus sourceKind={marketSource?.kind} noVault={noVault} offlineError={catalog.listError} retry={catalog.retryList} />{refresh}</>, toolbarContainer) : <div className="flex items-center gap-2"><PluginStatus sourceKind={marketSource?.kind} noVault={noVault} offlineError={catalog.listError} retry={catalog.retryList} />{refresh}</div>}
{catalog.error && <p role="alert" className="text-xs text-destructive">{catalog.error}</p>}
<div className="plugin-marketplace-browser">
{!entry && <div className="plugin-marketplace-catalog space-y-3" ref={listRef}>
@@ -70,7 +78,7 @@ export function PluginMarketplaceContent({ catalog, installed, selected, onSelec
{catalog.listStatus === 'error' && <div role="alert" className="space-y-2 text-xs text-destructive"><p>{catalog.listError}</p><Button variant="outline" size="sm" onClick={catalog.retryList}>{t('settings.plugins.market.retry')}</Button></div>}
{catalog.listStatus === 'ready' && catalog.entries.length === 0 && <p className="text-xs text-muted-foreground">{t('settings.plugins.market.empty')}</p>}
<div className="plugin-marketplace-grid">{filtered.map((item) => {
const isInstalled = installed.some((plugin) => plugin.id === item.id);
const isInstalled = installed.some((plugin) => plugin.id === item.id && sameSource(plugin));
return <button key={item.id} type="button" data-plugin-id={item.id} aria-current={selected === item.id ? 'true' : undefined} onClick={() => choose(item.id)}
className="plugin-card min-w-0 flex min-h-[200px] flex-col gap-3 rounded-lg bg-card p-4 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
<div className="flex items-center gap-3"><span className="flex h-8 w-8 shrink-0 items-center justify-center"><PluginBrandIcon entry={item} size={32} className="text-muted-foreground" /></span><span className="min-w-0 break-words text-sm font-semibold leading-5">{item.name}</span></div>
@@ -89,7 +97,8 @@ export function PluginMarketplaceContent({ catalog, installed, selected, onSelec
{installedEntry?.runtime?.lastError && <p role="status" className="break-words text-xs text-destructive">{t('settings.plugins.manager.lastError')}: {installedEntry.runtime.lastError}</p>}
<p className="text-sm leading-6 text-muted-foreground">{entry.description}</p>
<a className="block break-all text-xs text-primary underline underline-offset-4" href={`https://github.com/${entry.repository}/tree/${entry.commit}`} target="_blank" rel="noopener noreferrer">{entry.repository} @ {entry.commit.slice(0, 12)}</a>
{entry.artifact ? <div className="space-y-2"><p className="text-xs text-muted-foreground">{t('settings.plugins.market.installNote')}</p>{catalog.listError && <p role="status" className="text-xs text-muted-foreground">{t('settings.plugins.market.offlineShort')}</p>}<Button size="sm" disabled={catalog.busy || Boolean(catalog.listError)} onClick={() => void catalog.install(entry.id)}>{t('settings.plugins.market.install')}</Button></div> : <div className="flex items-center gap-1 text-xs text-muted-foreground"><span>{t('settings.plugins.market.sourceBadge')}</span><PluginInfo label={t('settings.plugins.market.sourceBadge')}>{t('settings.plugins.market.sourceOnly')}</PluginInfo></div>}
{conflictingInstall && <p role="status" className="text-xs text-muted-foreground">{t('settings.plugins.sources.conflict')} {conflictingInstall.source?.origin ?? t('settings.plugins.sources.local')}</p>}
{entry.artifact ? <div className="space-y-2"><p className="text-xs text-muted-foreground">{t('settings.plugins.market.installNote')}</p>{catalog.listError && <p role="status" className="text-xs text-muted-foreground">{t('settings.plugins.market.offlineShort')}</p>}<Button ref={installButtonRef} size="sm" disabled={catalog.busy || Boolean(catalog.listError) || Boolean(conflictingInstall)} onClick={() => { if (marketSource?.kind === 'third-party' || (catalog.sourceBinding && catalog.sourceBinding.id !== (marketSource?.id ?? 'official'))) { setClearPreviousSource(false); setConfirmInstall(true); } else void catalog.install(entry.id); }}>{t('settings.plugins.market.install')}</Button></div> : <div className="flex items-center gap-1 text-xs text-muted-foreground"><span>{t('settings.plugins.market.sourceBadge')}</span><PluginInfo label={t('settings.plugins.market.sourceBadge')}>{t(marketSource?.kind === 'third-party' ? 'settings.plugins.sources.sourceOnly' : 'settings.plugins.market.sourceOnly')}</PluginInfo></div>}
<section><h4 className="mb-3 text-sm font-semibold">{t('settings.plugins.market.manifest')}</h4><PluginPackageDetails plugin={{ id: entry.id, activeVersion: entry.version, enabled: false, manifest: entry.manifest, archiveSha256: entry.artifact?.sha256, runtime: { status: 'stopped', kind: null, lastError: null, quarantinedAt: null } }} /></section>
<div className="min-w-0 border-t pt-4"><div className="flex items-center gap-1"><h4 className="text-sm font-semibold">README</h4><PluginInfo label={t('settings.plugins.market.readmeInfo')}>{t('settings.plugins.market.readmeNote')}</PluginInfo></div>{catalog.readmeStatus === 'loading' && <p className="mt-3 text-xs text-muted-foreground">{t('settings.plugins.loading')}</p>}
{catalog.readmeStatus === 'error' && <div role="alert" className="mt-3 space-y-2 text-xs text-destructive"><p>{catalog.readmeError}</p><Button variant="outline" size="sm" onClick={catalog.retryReadme}>{t('settings.plugins.market.retry')}</Button></div>}
@@ -97,6 +106,13 @@ export function PluginMarketplaceContent({ catalog, installed, selected, onSelec
</div>
</article>}
</div>
<Dialog open={confirmInstall && Boolean(entry)} onOpenChange={setConfirmInstall}><DialogContent className="max-h-[85vh] overflow-y-auto" onCloseAutoFocus={(event) => { event.preventDefault(); installButtonRef.current?.focus(); }}>
<DialogTitle>{t('settings.plugins.sources.installConfirm')}</DialogTitle><DialogDescription>{t('settings.plugins.sources.risk')}</DialogDescription>
<p className="break-all text-sm font-medium">{marketSource?.origin}</p>
{entry && <PluginPackageDetails plugin={{ id: entry.id, activeVersion: entry.version, enabled: false, manifest: entry.manifest, source: marketSource, archiveSha256: entry.artifact?.sha256, runtime: { status: 'stopped', kind: null, lastError: null, quarantinedAt: null } }} />}
{catalog.sourceBinding && catalog.sourceBinding.id !== (marketSource?.id ?? 'official') && <label className="flex items-start gap-2 text-xs"><input type="checkbox" checked={clearPreviousSource} onChange={(event) => setClearPreviousSource(event.target.checked)} />{t('settings.plugins.sources.clearPrivate')}<span className="break-all">{catalog.sourceBinding.origin ?? t('settings.plugins.sources.local')}</span></label>}
<Button disabled={catalog.busy || Boolean(catalog.listError) || Boolean(catalog.sourceBinding && catalog.sourceBinding.id !== (marketSource?.id ?? 'official') && !clearPreviousSource)} onClick={() => { if (entry) void catalog.install(entry.id, marketSource?.id, clearPreviousSource).then(() => setConfirmInstall(false)); }}>{t('settings.plugins.market.install')}</Button>
</DialogContent></Dialog>
</section>;
}
@@ -1,6 +1,7 @@
import { describePluginPermission } from '@/domain/pluginPermissionDescriptions.cjs';
import { describePluginRuntime } from '@/domain/pluginRuntimePolicy.cjs';
import React, { useRef, useState } from 'react';
import { PluginPermissionPanel } from './PluginPermissionPanel';
import { PluginBrandIcon } from '../../plugins/PluginBrandIcon';
import { ArrowLeft } from 'lucide-react';
import { usePluginManager } from '../../../application/state/usePluginManager';
@@ -27,7 +28,7 @@ export function PluginPackageDetails({ plugin }: { plugin: NetcattyInstalledPlug
<dt className="text-muted-foreground">API</dt><dd>{manifest.engines.api}</dd>
<dt className="text-muted-foreground">SHA-256</dt><dd className="break-all font-mono">{plugin.archiveSha256 ?? '-'}</dd>
</dl>
<p className={!policy.browserOnly ? 'text-destructive' : 'text-muted-foreground'}>{t(`settings.plugins.manager.${runtimeLabel}`)}</p>
<p className={!policy.browserOnly && !policy.dataOnly ? 'text-destructive' : 'text-muted-foreground'}>{policy.dataOnly ? t('settings.plugins.manager.dataTheme') : t(`settings.plugins.manager.${runtimeLabel}`)}</p>
<div className="space-y-2">
<h4 className="font-medium">{t('settings.plugins.manager.permissions')}</h4>
<p className="text-xs text-muted-foreground">{t('settings.plugins.manager.permissionNote')}</p>
@@ -52,6 +53,7 @@ export function PluginPackageManager({ manager, onSelectionChange }: { manager:
const { t, resolvedLocale } = useI18n();
const [selected, setSelected] = useState<string | null>(null);
const [search, setSearch] = useState('');
const [sourceFilter, setSourceFilter] = useState('');
const rootRef = useRef<HTMLElement>(null);
const scrollTop = useRef(0);
const lastSelection = useRef<string | null>(null);
@@ -71,11 +73,13 @@ export function PluginPackageManager({ manager, onSelectionChange }: { manager:
else Array.from(rootRef.current?.querySelectorAll<HTMLButtonElement>('[data-plugin-card]') ?? []).find((button) => button.dataset.pluginCard === lastSelection.current)?.focus({ preventScroll: true });
});
};
const visible = selectedPlugin ? [selectedPlugin] : manager.plugins.filter((plugin) => [nameOf(plugin), plugin.manifest?.publisher, plugin.manifest?.description, plugin.id].some((value) => JSON.stringify(value ?? '').toLocaleLowerCase().includes(search.trim().toLocaleLowerCase())));
const visible = selectedPlugin ? [selectedPlugin] : manager.plugins.filter((plugin) => (!sourceFilter || (plugin.source?.id ?? 'local') === sourceFilter) && [nameOf(plugin), plugin.manifest?.publisher, plugin.manifest?.description, plugin.id].some((value) => JSON.stringify(value ?? '').toLocaleLowerCase().includes(search.trim().toLocaleLowerCase())));
const [removing, setRemoving] = useState<string | null>(null);
const [clearSource, setClearSource] = useState(false);
if (!manager.available) return null;
return <section ref={rootRef} className="space-y-4" aria-label={t('settings.plugins.manager.installed')}>
{selectedPlugin && <Button variant="ghost" size="sm" onClick={() => choose(null)}><ArrowLeft size={14} className="mr-2" />{t('settings.plugins.manager.installed')}</Button>}
{!selectedPlugin && manager.plugins.length > 0 && <select aria-label={t('settings.plugins.sources.source')} value={sourceFilter} onChange={(event) => setSourceFilter(event.target.value)} className="h-9 rounded-md border border-input bg-background px-3 text-sm"><option value="">{t('settings.plugins.sources.all')}</option>{[...new Map(manager.plugins.map((plugin) => [plugin.source?.id ?? 'local', plugin.source])).entries()].map(([id, source]) => <option key={id} value={id}>{source?.origin ?? t('settings.plugins.sources.local')}</option>)}</select>}
{!selectedPlugin && manager.plugins.length > 0 && <Input value={search} onChange={(event) => setSearch(event.target.value)} placeholder={t('settings.plugins.market.search')} aria-label={t('settings.plugins.market.search')} />}
{!selectedPlugin && manager.plugins.length > 0 && visible.length === 0 && <p className="p-6 text-center text-sm text-muted-foreground">{t('settings.plugins.market.noResults')}</p>}
{manager.error && <p role="alert" className="text-sm text-destructive">{manager.error}</p>}
@@ -89,15 +93,16 @@ export function PluginPackageManager({ manager, onSelectionChange }: { manager:
</button>
<Button variant="ghost" size="sm" type="button"
className={`h-7 shrink-0 rounded-full border-0 px-3 text-xs focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background ${plugin.enabled ? 'bg-primary/10 hover:bg-primary/15' : 'bg-muted/60 text-muted-foreground hover:bg-muted hover:text-foreground'}`}
style={plugin.enabled ? { color: 'color-mix(in srgb, hsl(var(--primary)) 40%, hsl(var(--foreground)))' } : undefined}
aria-pressed={plugin.enabled} aria-busy={manager.busy}
aria-label={`${t(plugin.enabled ? 'settings.plugins.manager.disable' : 'settings.plugins.manager.enable')} ${plugin.manifest?.name ?? plugin.id}`}
title={t(plugin.enabled ? 'settings.plugins.manager.disable' : 'settings.plugins.manager.enable')}
disabled={manager.busy} onClick={() => void manager.setEnabled(plugin.id, !plugin.enabled)}>
{t(plugin.enabled ? 'settings.plugins.manager.enabled' : 'settings.plugins.manager.disabled')}
className={`h-7 shrink-0 rounded-full border-0 px-3 text-xs focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background ${manager.statusKnown !== false && plugin.enabled ? 'bg-primary/10 hover:bg-primary/15' : 'bg-muted/60 text-muted-foreground hover:bg-muted hover:text-foreground'}`}
style={manager.statusKnown !== false && plugin.enabled ? { color: 'color-mix(in srgb, hsl(var(--primary)) 40%, hsl(var(--foreground)))' } : undefined}
aria-pressed={manager.statusKnown === false ? undefined : plugin.enabled} aria-busy={manager.busy}
aria-label={`${t(manager.statusKnown === false ? 'settings.plugins.permissions.statusUnknown' : plugin.enabled ? 'settings.plugins.manager.disable' : 'settings.plugins.manager.enable')} ${plugin.manifest?.name ?? plugin.id}`}
title={t(manager.statusKnown === false ? 'settings.plugins.permissions.statusUnknown' : plugin.enabled ? 'settings.plugins.manager.disable' : 'settings.plugins.manager.enable')}
disabled={manager.busy || manager.statusKnown === false} onClick={() => void manager.setEnabled(plugin.id, !plugin.enabled)}>
{t(manager.statusKnown === false ? 'settings.plugins.permissions.statusUnknown' : plugin.enabled ? 'settings.plugins.manager.enabled' : 'settings.plugins.manager.disabled')}
</Button>
</div>
<p className="break-all text-xs text-muted-foreground">{t('settings.plugins.sources.source')}: {t(plugin.source?.kind === 'official' ? 'settings.plugins.sources.official' : plugin.source?.kind === 'third-party' ? 'settings.plugins.sources.thirdparty' : 'settings.plugins.sources.local')}{plugin.source?.origin ? ` · ${plugin.source.origin}` : ''}</p>
{plugin.manifest?.publisher && <p className="break-words text-xs text-muted-foreground">{plugin.manifest.publisher}</p>}
<p className={`break-words text-sm leading-5 text-muted-foreground ${selectedPlugin ? 'whitespace-pre-wrap' : 'line-clamp-3'}`}>{descriptionOf(plugin) || t('settings.plugins.manager.noDescription')}</p>
{!selectedPlugin ? <div className="mt-auto flex flex-wrap items-center justify-between gap-2 pt-1">
@@ -107,11 +112,13 @@ export function PluginPackageManager({ manager, onSelectionChange }: { manager:
{plugin.runtime.lastError && <p role="status" className="break-words text-xs text-destructive">{t('settings.plugins.manager.lastError')}: {plugin.runtime.lastError}</p>}
{selected === plugin.id && <>
<PluginPackageDetails plugin={plugin} />
<PluginPermissionPanel key={plugin.id} pluginId={plugin.id} onChanged={async () => { await manager.refresh(); }} />
{removing === plugin.id ? <div className="space-y-2 rounded-lg border border-destructive/30 p-3">
<p className="text-xs">{t('settings.plugins.manager.removeNote')}</p>
<Button variant="destructive" size="sm" disabled={manager.busy} onClick={() => { void manager.uninstall(plugin.id); setRemoving(null); }}>{t('settings.plugins.manager.remove')}</Button>
<label className="flex items-start gap-2 text-xs"><input type="checkbox" checked={clearSource} onChange={(event) => setClearSource(event.target.checked)} />{t('settings.plugins.sources.clearPrivate')}</label>
<Button variant="destructive" size="sm" disabled={manager.busy} onClick={() => { void manager.uninstall(plugin.id, clearSource); setRemoving(null); }}>{t('settings.plugins.manager.remove')}</Button>
<Button variant="ghost" size="sm" onClick={() => setRemoving(null)}>{t('common.cancel')}</Button>
</div> : <Button variant="ghost" size="sm" disabled={manager.busy} onClick={() => setRemoving(plugin.id)}>{t('settings.plugins.manager.remove')}</Button>}
</div> : <Button variant="ghost" size="sm" disabled={manager.busy} onClick={() => { setClearSource(false); setRemoving(plugin.id); }}>{t('settings.plugins.manager.remove')}</Button>}
</>}
</article>)}
</div>
@@ -0,0 +1,145 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { JSDOM } from 'jsdom';
test('permission panel refreshes real grants, revokes once, and keeps stale reads from replacing new results', 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 { PluginPermissionPanel } = await import('./PluginPermissionPanel');
const root = createRoot(document.getElementById('root')!);
const initial = {
grants: [{ permission: 'network', resource: 'https://synthetic.invalid/<text>', resourceKind: 'exact', scope: 'always' as const, sessionId: null, grantedAt: 1, matchesCurrentActivation: false }],
audit: [{ event: 'permission.used', createdAt: 2, permission: 'network' }],
};
let reads = 0;
let revokes = 0;
let changed = 0;
let staleRead: (value: typeof initial) => void = () => {};
let releaseRevoke: () => void = () => {};
const revokePending = new Promise<void>((resolve) => { releaseRevoke = resolve; });
Object.assign(dom.window, { netcatty: {
async getPluginPermissions(id: string) {
assert.equal(id, 'com.example.synthetic');
reads++;
if (reads === 2) return new Promise<typeof initial>((resolve) => { staleRead = resolve; });
return reads > 2 ? { grants: [], audit: [] } : initial;
},
async revokePluginPermissions(id: string) { assert.equal(id, 'com.example.synthetic'); revokes++; await revokePending; },
} });
const button = (text: string) => Array.from(document.querySelectorAll<HTMLButtonElement>('button')).find((item) => item.textContent === text)!;
try {
await act(async () => root.render(<I18nProvider locale="en"><PluginPermissionPanel pluginId="com.example.synthetic" onChanged={async () => { changed++; }} /></I18nProvider>));
assert.match(document.body.textContent!, /synthetic.invalid/);
assert.equal(document.querySelector('text'), null, 'resource is rendered as plain text');
assert.match(document.body.textContent!, /Until revoked/);
assert.match(document.body.textContent!, /Inactive for the current activation/);
await act(async () => { button('Refresh').click(); });
await act(async () => {
button('Disable and revoke all grants').click();
button('Disable and revoke all grants').click();
});
assert.equal(revokes, 1);
assert.equal(button('Refresh').disabled, true);
await act(async () => { releaseRevoke(); });
assert.equal(changed, 1);
assert.match(document.body.textContent!, /No saved grants/);
await act(async () => { staleRead(initial); });
assert.doesNotMatch(document.body.textContent!, /synthetic.invalid/);
Object.assign(dom.window, { netcatty: { async getPluginPermissions() { throw new Error('synthetic read failed'); } } });
await act(async () => { button('Refresh').click(); });
assert.match(document.querySelector('[role="alert"]')?.textContent ?? '', /synthetic read failed/);
} finally {
await act(async () => root.unmount());
dom.window.close();
for (const [key, descriptor] of previous) {
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
else Reflect.deleteProperty(globalThis, key);
}
}
});
test('partial revocation failure refreshes actual manager and engine state without clearing the original error', 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>();
for (const key of ['window', 'document', 'navigator', 'HTMLElement', 'Element', 'Node', 'Event', 'MouseEvent', 'IS_REACT_ACT_ENVIRONMENT']) {
previous.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
Object.defineProperty(globalThis, key, { configurable: true, writable: true, value: key === 'window' ? dom.window : key === 'IS_REACT_ACT_ENVIRONMENT' ? true : dom.window[key as keyof typeof dom.window] });
}
const { act } = await import('react');
const { createRoot } = await import('react-dom/client');
const { I18nProvider } = await import('../../../application/i18n/I18nProvider');
const { PluginPermissionPanel } = await import('./PluginPermissionPanel');
const { PluginManager } = await import('../../../electron/plugins/pluginManager.cjs');
const { PluginPermissionEngine } = await import('../../../electron/plugins/permissionEngine.cjs');
const { defaultSecurityPrincipal, normalizePermissionDeclarations, permissionDeclarationHash } = await import('../../../electron/plugins/permissionResources.cjs');
const manifest = { id: 'com.example.synthetic', publisher: 'example', permissions: { optional: ['storage'] } };
const principal = defaultSecurityPrincipal(manifest);
const grant = { pluginId: manifest.id, permission: 'storage', resource: '*', resourceKind: 'exact', grantedAt: 1,
declarationHash: permissionDeclarationHash(normalizePermissionDeclarations(manifest).get('storage'), principal) };
let enabled = true, running = true, stopped = false, readFails = false;
let grants = [grant];
let reads = 0, changed = 0;
const database = {
getActivePlugin: () => ({ id: manifest.id, enabled, manifest }),
listPlugins: () => [{ id: manifest.id, enabled, manifest }],
setEnabled(_id: string, value: boolean) { enabled = value; },
listPermissionGrants: () => grants,
deleteAllPermissionGrants() { grants = []; },
recordSecurityAudit() { throw new Error('synthetic audit write failed'); },
};
const engine = new PluginPermissionEngine({ database });
const manager = new PluginManager({ database, permissionEngine: engine,
packageStore: { async initialize() {} },
contributionService: { async initialize() {}, onPluginDisabled() {} },
runtimeSupervisor: { async stop() { running = false; stopped = true; } },
});
Object.assign(dom.window, { netcatty: {
async getPluginPermissions() {
reads++;
if (readFails) throw new Error('synthetic grants refresh failed');
const groups = engine.describeGrants(manifest.id, running ? { pluginId: manifest.id, manifest, securityPrincipal: principal } : null);
return { grants: groups.always, audit: [] };
},
async revokePluginPermissions(id: string) { return manager.revokePermissions(id); },
} });
const root = createRoot(document.getElementById('root')!);
const refreshManager = async () => { changed++; assert.equal((await manager.list())[0].enabled, false); };
const revoke = () => Array.from(document.querySelectorAll<HTMLButtonElement>('button')).find((item) => item.textContent === 'Disable and revoke all grants')!;
try {
await act(async () => root.render(<I18nProvider locale="en"><PluginPermissionPanel key="first" pluginId={manifest.id} onChanged={refreshManager} /></I18nProvider>));
assert.match(document.body.textContent!, /Matches the current activation/);
await act(async () => { revoke().click(); });
assert.equal(enabled, false); assert.equal(stopped, true);
assert.equal(changed, 1); assert.equal(reads, 2);
assert.match(document.querySelector('[role="alert"]')?.textContent ?? '', /synthetic audit write failed/);
assert.match(document.body.textContent!, /No saved grants/);
assert.doesNotMatch(document.body.textContent!, /Matches the current activation/);
// A failed state read must also remove the old grant snapshot while
// retaining the mutation error and reporting the refresh failure.
enabled = true; running = true; stopped = false; grants = [grant];
await act(async () => root.render(<I18nProvider locale="en"><PluginPermissionPanel key="second" pluginId={manifest.id} onChanged={async () => { await refreshManager(); readFails = true; throw new Error('synthetic manager refresh failed'); }} /></I18nProvider>));
assert.match(document.body.textContent!, /Matches the current activation/);
await act(async () => { revoke().click(); });
assert.equal(enabled, false); assert.equal(stopped, true);
assert.equal(changed, 2); assert.equal(reads, 4);
const error = document.querySelector('[role="alert"]')?.textContent ?? '';
assert.match(error, /synthetic audit write failed/);
assert.match(error, /synthetic manager refresh failed/);
assert.match(error, /synthetic grants refresh failed/);
assert.doesNotMatch(document.body.textContent!, /Matches the current activation/);
} finally {
await act(async () => root.unmount()); 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,86 @@
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 { Button } from '../../ui/button';
/** Host UI only. Plugin views cannot query or mutate this security state. */
export function PluginPermissionPanel({ pluginId, onChanged }: {
pluginId: string;
onChanged: () => Promise<void>;
}) {
const { t, resolvedLocale } = useI18n();
const [snapshot, setSnapshot] = useState<NetcattyPluginPermissionSnapshot | null>(null);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const generation = useRef(0);
const pending = useRef(false);
const refresh = useCallback(async (preserveError = false) => {
const current = ++generation.current;
if (!preserveError) setError(null);
try {
const bridge = netcattyBridge.require();
if (!bridge.getPluginPermissions) throw new Error(t('settings.plugins.permissions.unavailable'));
const result = await bridge.getPluginPermissions(pluginId);
if (current === generation.current) setSnapshot(result);
} catch (cause) {
if (current === generation.current) {
setSnapshot(null);
const message = cause instanceof Error ? cause.message : String(cause);
setError((previous) => preserveError && previous ? `${previous}\n${message}` : message);
}
}
}, [pluginId, t]);
useEffect(() => {
const requestGeneration = generation;
void refresh();
return () => { requestGeneration.current++; };
}, [refresh]);
const revoke = async () => {
if (pending.current) return;
pending.current = true;
setBusy(true);
setError(null);
try {
const bridge = netcattyBridge.require();
if (!bridge.revokePluginPermissions) throw new Error(t('settings.plugins.permissions.unavailable'));
await bridge.revokePluginPermissions(pluginId);
} catch (cause) { setError(cause instanceof Error ? cause.message : String(cause)); }
finally {
try { await onChanged(); }
catch (cause) {
const message = cause instanceof Error ? cause.message : String(cause);
setError((previous) => previous ? `${previous}\n${message}` : message);
}
await refresh(true);
pending.current = false; setBusy(false);
}
};
return <section data-host-security-surface="" className="space-y-3 border-t border-border/60 pt-4 text-sm" aria-label={t('settings.plugins.permissions.title')}>
<div className="flex flex-wrap items-center justify-between gap-2">
<h4 className="font-medium">{t('settings.plugins.permissions.title')}</h4>
<Button variant="ghost" size="sm" disabled={busy} onClick={() => void refresh()}>{t('settings.plugins.permissions.refresh')}</Button>
</div>
{error && <p role="alert" className="text-xs text-destructive">{error}</p>}
{!snapshot && !error && <p role="status">{t('settings.plugins.permissions.loading')}</p>}
{snapshot && <>
{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>
<p className="whitespace-pre-wrap break-all font-mono">{grant.resource}</p>
<p>{t(grant.matchesCurrentActivation ? 'settings.plugins.permissions.current' : 'settings.plugins.permissions.inactive')}</p>
<p>{t(`settings.plugins.permissions.${grant.scope}`)}{grant.sessionId ? ` · ${grant.sessionId}` : ''}</p>
<time dateTime={new Date(grant.grantedAt).toISOString()}>{new Date(grant.grantedAt).toLocaleString(resolvedLocale)}</time>
</div>)}
<details className="text-xs">
<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}` : ''}
</li>)}</ol>
</details>
<p className="text-xs text-muted-foreground">{t('settings.plugins.permissions.revokeNote')}</p>
<Button variant="destructive" size="sm" disabled={busy} onClick={() => void revoke()}>{t('settings.plugins.permissions.revoke')}</Button>
</>}
</section>;
}
+3 -3
View File
@@ -3,12 +3,12 @@ import { useI18n } from '../../../application/i18n/I18nProvider';
import { Button } from '../../ui/button';
import { PluginInfo } from './PluginInfo';
export function PluginStatus({ noVault, offlineError, retry }: { noVault?: boolean; offlineError?: string | null; retry?: () => void }) {
export function PluginStatus({ noVault, offlineError, retry, sourceKind }: { sourceKind?: NetcattyPluginSource['kind']; noVault?: boolean; offlineError?: string | null; retry?: () => void }) {
const { t } = useI18n();
const count = Number(Boolean(noVault)) + Number(Boolean(offlineError));
const explanation = <>{noVault && <p>{t('settings.plugins.manager.noVault')}</p>}{offlineError && <><p>{t('settings.plugins.market.offline')}</p><p className="break-words">{offlineError}</p></>}</>;
const explanation = <>{noVault && <p>{t('settings.plugins.manager.noVault')}</p>}{offlineError && <><p>{t(sourceKind === 'third-party' ? 'settings.plugins.sources.offline' : 'settings.plugins.market.offline')}</p><p className="break-words">{offlineError}</p></>}</>;
const label = count ? `${t('settings.plugins.info')} · ${t('settings.plugins.status', { count })}` : t('settings.plugins.info');
const help = <><p>{t('settings.plugins.manager.preview')}</p><p>{t('settings.plugins.market.note')}</p></>;
const help = <><p>{t('settings.plugins.manager.preview')}</p><p>{t(sourceKind === 'third-party' ? 'settings.plugins.sources.note' : 'settings.plugins.market.note')}</p></>;
const preview = <>{count > 0 && <p>{t('settings.plugins.status', { count })}</p>}{explanation}{help}</>;
return <PluginInfo label={label} badgeCount={count} previewContent={preview}>{explanation}{offlineError && retry && <Button variant="outline" size="sm" onClick={retry}>{t('settings.plugins.market.retry')}</Button>}{count > 0 && <hr className="border-border/60" />}{help}</PluginInfo>;
}
@@ -0,0 +1,230 @@
import assert from "node:assert/strict";
import test from "node:test";
import { JSDOM } from "jsdom";
import type {
ThemePackCatalog,
ThemePackEntry,
} from "@netcatty/plugin-contract";
test("host theme picker previews without persistence, applies, restores, and cancels stale owners safely", async () => {
const dom = new JSDOM(
'<!doctype html><html><body><div id="root"></div></body></html>',
{ url: "http://localhost", pretendToBeVisual: true },
);
const previous = new Map<string, PropertyDescriptor | undefined>();
for (const key of [
"window",
"document",
"navigator",
"HTMLElement",
"Element",
"Node",
"Event",
"MouseEvent",
"IS_REACT_ACT_ENVIRONMENT",
]) {
previous.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
Object.defineProperty(globalThis, key, {
configurable: true,
writable: true,
value:
key === "IS_REACT_ACT_ENVIRONMENT"
? true
: key === "window"
? dom.window
: dom.window[key as keyof typeof dom.window],
});
}
const { default: React, act, useLayoutEffect } = await import("react");
const { createRoot } = await import("react-dom/client");
const { I18nProvider } = await import(
"../../../application/i18n/I18nProvider"
);
const { PluginThemePicker } = await import("./PluginThemePicker");
const { pluginThemeRegistry: registry } = await import(
"../../../infrastructure/config/pluginThemeRegistry"
);
const { usePluginThemeRevision } = await import(
"../../../application/state/usePluginThemeRuntime"
);
const { getUiThemeById } = await import(
"../../../infrastructure/config/uiThemes"
);
const { applyThemeTokens } = await import(
"../../../application/state/settingsStateDefaults"
);
const entry: ThemePackEntry = {
ref: {
pluginId: "com.example.theme",
themeId: "com.example.theme.forest",
version: "1.0.0",
digest: "a".repeat(64),
variant: "light",
},
label: "Forest",
pluginName: "Synthetic Theme",
dataOnly: true,
palette: { formatVersion: 1, base: "light", colors: { accent: "#166534" } },
};
let writes = 0,
restores = 0;
let catalog: ThemePackCatalog = {
selection: {
formatVersion: 1,
revision: registry.getCatalog().selection.revision + 1,
light: null,
dark: null,
},
entries: [entry],
diagnostics: [],
};
registry.setCatalog(catalog);
Object.assign(dom.window, {
netcatty: {
async applyPluginTheme(request: {
expectedRevision: number;
choice: ThemePackCatalog["selection"]["light"];
}) {
assert.equal(request.expectedRevision, catalog.selection.revision);
writes++;
catalog = {
...catalog,
selection: {
...catalog.selection,
revision: catalog.selection.revision + 1,
light: request.choice,
},
};
registry.setCatalog(catalog);
return catalog;
},
},
});
function Fixture() {
const revision = usePluginThemeRevision();
useLayoutEffect(
() =>
applyThemeTokens(
"light",
"light",
getUiThemeById("light", "snow").tokens,
"theme",
"",
),
[revision],
);
return (
<I18nProvider locale="en">
<PluginThemePicker mode="light" onRestore={() => restores++} />
</I18nProvider>
);
}
const root = createRoot(document.getElementById("root")!);
const button = (label: string) =>
[...document.querySelectorAll<HTMLButtonElement>("button")].find(
(b) => b.textContent === label,
)!;
try {
await act(async () => root.render(<Fixture />));
const original =
document.documentElement.style.getPropertyValue("--primary");
await act(async () => {
const select = document.querySelector("select")!;
select.value = `${entry.ref.pluginId}/${entry.ref.themeId}`;
select.dispatchEvent(new Event("change", { bubbles: true }));
});
await act(async () => button("Preview").click());
assert.equal(writes, 0);
assert.equal(
getUiThemeById("light", "snow", false).tokens.primary,
original,
"local preview does not enter the global plugin view environment",
);
assert.notEqual(
document.documentElement.style.getPropertyValue("--primary"),
original,
);
assert.ok(
document.documentElement.hasAttribute("data-plugin-theme-active"),
);
assert.ok(
document.documentElement.style.getPropertyValue(
"--host-security-primary",
),
);
await act(async () => button("Cancel preview").click());
assert.equal(
document.documentElement.style.getPropertyValue("--primary"),
original,
);
assert.equal(writes, 0);
await act(async () => {
button("Preview").click();
button("Apply").click();
});
assert.equal(writes, 1);
assert.equal(catalog.selection.light?.ref.themeId, entry.ref.themeId);
await act(async () => button("Restore default").click());
assert.equal(writes, 2);
assert.equal(restores, 1);
assert.equal(catalog.selection.light, null);
await act(async () => {
registry.startPreview("old-owner", entry);
registry.startPreview("new-owner", entry);
registry.cancelPreview("old-owner");
});
assert.notEqual(
document.documentElement.style.getPropertyValue("--primary"),
original,
);
await act(async () =>
registry.setCatalog({
...catalog,
selection: {
...catalog.selection,
revision: catalog.selection.revision + 1,
},
entries: [],
}),
);
assert.equal(
document.documentElement.style.getPropertyValue("--primary"),
original,
"package withdrawal cancels previews",
);
await act(async () =>
registry.setCatalog({
...catalog,
selection: {
...catalog.selection,
revision: catalog.selection.revision + 2,
light: { ref: entry.ref },
},
entries: [],
}),
);
assert.equal(getUiThemeById("light", "snow").id, "host-plugin-fallback");
await act(async () => registry.setHighContrast(true));
assert.equal(getUiThemeById("light", "snow").id, "host-high-contrast");
} finally {
await act(async () => {
root.unmount();
registry.setHighContrast(false);
registry.setCatalog({
...catalog,
selection: {
formatVersion: 1,
revision: registry.getCatalog().selection.revision + 1,
light: null,
dark: null,
},
entries: [],
});
});
dom.window.close();
for (const [key, descriptor] of previous) {
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
else Reflect.deleteProperty(globalThis, key);
}
}
});
@@ -0,0 +1,166 @@
import { useEffect, useId, useState } from "react";
import { useI18n } from "../../../application/i18n/I18nProvider";
import {
applyPluginThemeSelection,
usePluginThemeRevision,
} from "../../../application/state/usePluginThemeRuntime";
import { pluginThemeRegistry } from "../../../infrastructure/config/pluginThemeRegistry";
import { Button } from "../../ui/button";
export function PluginThemePicker({
mode,
onRestore,
}: {
mode: "light" | "dark";
onRestore: () => void;
}) {
const { t } = useI18n();
usePluginThemeRevision();
const owner = useId();
const [selected, setSelected] = useState(""),
[pending, setPending] = useState(false),
[error, setError] = useState("");
const catalog = pluginThemeRegistry.getCatalog();
const entries = catalog.entries.filter((e) => e.palette.base === mode);
const applied = catalog.selection[mode];
const appliedAvailable =
applied &&
entries.some(
(e) =>
e.ref.pluginId === applied.ref.pluginId &&
e.ref.themeId === applied.ref.themeId &&
e.ref.version === applied.ref.version &&
e.ref.digest === applied.ref.digest,
);
const entry = entries.find(
(e) => `${e.ref.pluginId}/${e.ref.themeId}` === selected,
);
useEffect(
() => () => pluginThemeRegistry.cancelPreview(owner),
[owner, mode],
);
async function apply(restore = false) {
if (pending || (!restore && !entry)) return;
setPending(true);
setError("");
try {
await applyPluginThemeSelection(
mode,
restore ? null : { ref: entry!.ref },
);
pluginThemeRegistry.cancelPreview(owner);
if (restore) {
setSelected("");
onRestore();
}
} catch (e) {
setError(
e instanceof Error ? e.message : t("settings.plugins.themes.failed"),
);
} finally {
setPending(false);
}
}
return (
<section
className="space-y-3 py-3"
aria-label={t("settings.plugins.themes.title")}
>
<div>
<h4 className="text-sm font-medium">
{t("settings.plugins.themes.title")}
</h4>
<p className="text-xs text-muted-foreground">
{t("settings.plugins.themes.description")}
</p>
</div>
{entries.length === 0 ? (
<p className="text-xs text-muted-foreground">
{t("settings.plugins.themes.empty")}
</p>
) : (
<select
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
aria-label={t("settings.plugins.themes.choose")}
value={entry ? selected : ""}
disabled={pending}
onChange={(e) => {
pluginThemeRegistry.cancelPreview(owner);
setSelected(e.target.value);
setError("");
}}
>
<option value="">{t("settings.plugins.themes.choose")}</option>
{entries.map((e) => (
<option
key={`${e.ref.pluginId}/${e.ref.themeId}`}
value={`${e.ref.pluginId}/${e.ref.themeId}`}
>
{e.label} — {e.pluginName} {e.ref.version}
</option>
))}
</select>
)}
{catalog.selection[mode] && (
<p className="text-xs text-muted-foreground">
{t(
appliedAvailable
? "settings.plugins.themes.applied"
: "settings.plugins.themes.unavailable",
)}
: {catalog.selection[mode]!.ref.themeId} ·{" "}
{catalog.selection[mode]!.ref.version}
</p>
)}
<div className="flex flex-wrap gap-2">
<Button
size="sm"
variant="outline"
disabled={!entry || pending}
onClick={() => {
if (entry) pluginThemeRegistry.startPreview(owner, entry);
}}
>
{t("settings.plugins.themes.preview")}
</Button>
<Button
size="sm"
disabled={!entry || pending}
onClick={() => {
void apply();
}}
>
{t("settings.plugins.themes.apply")}
</Button>
<Button
size="sm"
variant="ghost"
disabled={pending}
onClick={() => pluginThemeRegistry.cancelPreview(owner)}
>
{t("settings.plugins.themes.cancel")}
</Button>
<Button
size="sm"
variant="outline"
disabled={pending}
onClick={() => {
void apply(true);
}}
>
{t("settings.plugins.themes.restore")}
</Button>
</div>
{catalog.diagnostics.length > 0 && (
<p className="text-xs text-muted-foreground">
{t("settings.plugins.themes.invalid")}
</p>
)}
{error && (
<p role="alert" className="text-xs text-destructive">
{error}
</p>
)}
</section>
);
}
@@ -0,0 +1,55 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { JSDOM } from 'jsdom';
test('source management requires consent and Escape restores either opener inside the plugin window', async () => {
const dom = new JSDOM('<!doctype html><html><body><div id="root"></div></body></html>', { pretendToBeVisual: true, url: 'http://localhost' });
const previous = new Map<string, PropertyDescriptor | undefined>();
const install = (key: string, value: unknown) => {
previous.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
Object.defineProperty(globalThis, key, { configurable: true, writable: true, value });
};
for (const key of ['window', 'document', 'navigator', 'HTMLElement', 'HTMLInputElement', 'Element', 'Node', 'NodeFilter', 'MutationObserver', 'CustomEvent', 'Event', 'KeyboardEvent', 'MouseEvent']) {
install(key, key === 'window' ? dom.window : dom.window[key as keyof typeof dom.window]);
}
install('getComputedStyle', dom.window.getComputedStyle.bind(dom.window));
install('requestAnimationFrame', dom.window.requestAnimationFrame.bind(dom.window));
install('cancelAnimationFrame', dom.window.cancelAnimationFrame.bind(dom.window));
install('IS_REACT_ACT_ENVIRONMENT', true);
// Install a synthetic preload surface on the isolated DOM window.
Object.defineProperty(dom.window, 'netcatty', { value: { listPluginSources: async () => [] }, configurable: true });
const { default: React, act } = await import('react');
const { createRoot } = await import('react-dom/client');
const { PluginThirdPartyMarket } = await import('./PluginThirdPartyMarket');
const { PluginManagementDialog } = await import('./PluginManagementDialog');
const { I18nProvider } = await import('../../../application/i18n/I18nProvider');
const root = createRoot(document.getElementById('root')!);
const settle = () => new Promise((resolve) => setTimeout(resolve, 60));
try {
await act(async () => root.render(<I18nProvider locale="en"><PluginManagementDialog><PluginThirdPartyMarket installed={[]} /></PluginManagementDialog></I18nProvider>));
await act(async () => { document.querySelector<HTMLButtonElement>('button')!.click(); await settle(); });
for (const label of ['Manage sources', 'Add source']) {
const opener = [...document.querySelectorAll<HTMLButtonElement>('button')].find((button) => button.textContent === label)!;
assert.ok(opener);
await act(async () => { opener.focus(); opener.click(); await settle(); });
assert.equal(document.querySelectorAll('[role="dialog"]').length, 2);
const dialogs = document.querySelectorAll<HTMLElement>('[role="dialog"]');
const dialog = dialogs[dialogs.length - 1];
assert.match(dialog.textContent ?? '', /real domain/);
assert.equal(dialog.querySelector<HTMLInputElement>('input[type="checkbox"]')?.checked, false);
assert.equal(dialog.querySelector<HTMLButtonElement>('button[type="submit"]')?.disabled, true);
await act(async () => { dialog.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true })); await settle(); });
assert.equal(document.querySelectorAll('[role="dialog"]').length, 1);
assert.equal(document.activeElement, opener);
}
} finally {
// Radix schedules unmount autofocus on a timer. Drain it while the DOM's
// Event constructor is still installed (Node 22 has a distinct global Event).
await act(async () => { root.unmount(); await settle(); });
dom.window.close();
for (const [key, descriptor] of previous) {
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
else Reflect.deleteProperty(globalThis, key);
}
}
});
@@ -0,0 +1,59 @@
import React, { useRef, useState } from 'react';
import { usePluginSources } from '../../../application/state/usePluginSources';
import { useI18n } from '../../../application/i18n/I18nProvider';
import { Button } from '../../ui/button';
import { Input } from '../../ui/input';
import { Dialog, DialogContent, DialogDescription, DialogTitle } from '../../ui/dialog';
import { PluginMarketplace } from './PluginMarketplace';
export function PluginThirdPartyMarket({ installed, toolbarContainer, noVault }: {
installed: NetcattyInstalledPlugin[]; toolbarContainer?: HTMLElement | null; noVault?: boolean;
}) {
const { t } = useI18n();
const model = usePluginSources();
const [selected, setSelected] = useState('');
const [visited, setVisited] = useState<string[]>([]);
const [managing, setManaging] = useState(false);
const opener = useRef<HTMLButtonElement | null>(null);
const openManager = (event: React.MouseEvent<HTMLButtonElement>) => { opener.current = event.currentTarget; setManaging(true); };
const [name, setName] = useState('');
const [address, setAddress] = useState('');
const [accepted, setAccepted] = useState(false);
const [removing, setRemoving] = useState<string | null>(null);
const [disablePlugins, setDisablePlugins] = useState(false);
const sources = model.sources.filter((source) => source.kind === 'third-party');
const source = sources.find((item) => item.id === selected && item.enabled);
let domain = '';
try { domain = new URL(address).host; } catch { /* Host validates before saving. */ }
return <div className="space-y-4">
<div className="flex flex-wrap items-center gap-3">
<select aria-label={t('settings.plugins.sources.select')} value={selected} onChange={(event) => { setSelected(event.target.value); setVisited((items) => [...new Set([...items, event.target.value])]); }} className="h-9 min-w-0 rounded-md border border-input bg-background px-3 text-sm">
<option value="">{t('settings.plugins.sources.select')}</option>
{sources.filter((item) => item.enabled).map((item) => <option key={item.id} value={item.id}>{item.name} — {new URL(item.origin!).hostname}</option>)}
</select>
<Button variant="outline" size="sm" onClick={openManager}>{t('settings.plugins.sources.manage')}</Button>
</div>
{!source && <div className="py-8 text-center text-sm text-muted-foreground"><p>{t('settings.plugins.sources.empty')}</p><Button className="mt-3" variant="outline" onClick={openManager}>{t('settings.plugins.sources.add')}</Button></div>}
{/* Keep each visited source mounted to preserve its own selection/search/scroll. */}
{sources.filter((item) => item.enabled && (item.id === selected || visited.includes(item.id))).map((item) => {
return <div key={item.id} hidden={item.id !== source?.id}><PluginMarketplace source={item} installed={installed} toolbarContainer={item.id === source?.id ? toolbarContainer : undefined} noVault={noVault} /></div>;
})}
<Dialog open={managing} onOpenChange={setManaging}><DialogContent className="max-h-[85vh] overflow-y-auto" onCloseAutoFocus={(event) => { event.preventDefault(); opener.current?.focus(); }}>
<DialogTitle>{t('settings.plugins.sources.manage')}</DialogTitle><DialogDescription>{t('settings.plugins.sources.risk')}</DialogDescription>
{model.error && <p role="alert" className="text-sm text-destructive">{model.error}</p>}
<div className="space-y-3">{sources.map((item) => <div key={item.id} className="space-y-2 rounded-lg bg-muted/30 p-3">
<Input aria-label={t('settings.plugins.sources.name')} defaultValue={item.name} onBlur={(event) => { if (event.target.value !== item.name) void model.update(item, event.target.value, Boolean(item.enabled)); }} disabled={model.busy} />
<p className="break-all text-xs text-muted-foreground">{item.origin}</p>
<div className="flex items-center justify-between"><label className="flex items-center gap-2 text-sm"><input type="checkbox" checked={Boolean(item.enabled)} disabled={model.busy} onChange={(event) => void model.update(item, item.name, event.target.checked)} />{t('settings.plugins.manager.enabled')}</label><Button variant="ghost" size="sm" disabled={model.busy} onClick={() => { setRemoving(item.id); setDisablePlugins(false); }}>{t('settings.plugins.sources.remove')}</Button></div>
{removing === item.id && <div className="space-y-2 text-sm"><p>{t('settings.plugins.sources.removeNote')}</p><label className="flex items-center gap-2"><input type="checkbox" checked={disablePlugins} onChange={(event) => setDisablePlugins(event.target.checked)} />{t('settings.plugins.sources.disablePlugins')}</label><Button size="sm" variant="destructive" disabled={model.busy} onClick={() => void model.remove(item.id, disablePlugins).then((ok) => { if (ok) setRemoving(null); })}>{t('settings.plugins.sources.remove')}</Button></div>}
</div>)}</div>
<form className="space-y-3" onSubmit={(event) => { event.preventDefault(); if (accepted) void model.add(name, address).then((ok) => { if (ok) { setName(''); setAddress(''); setAccepted(false); } }); }}>
<Input required value={name} onChange={(event) => setName(event.target.value)} placeholder={t('settings.plugins.sources.name')} aria-label={t('settings.plugins.sources.name')} />
<Input required type="url" value={address} onChange={(event) => { setAddress(event.target.value); setAccepted(false); }} placeholder="https://plugins.example.com" aria-label={t('settings.plugins.sources.address')} />
{domain && <p className="break-all text-sm font-medium">{domain}</p>}
<label className="flex items-start gap-2 text-xs"><input type="checkbox" checked={accepted} onChange={(event) => setAccepted(event.target.checked)} />{t('settings.plugins.sources.accept')}</label>
<Button type="submit" disabled={model.busy || !accepted}>{t('settings.plugins.sources.add')}</Button>
</form>
</DialogContent></Dialog>
</div>;
}
@@ -1,3 +1,6 @@
import { PluginThemePicker } from './PluginThemePicker';
import { applyPluginThemeSelection, usePluginThemeRevision } from '../../../application/state/usePluginThemeRuntime';
import { pluginThemeRegistry } from '../../../infrastructure/config/pluginThemeRegistry';
import React, { memo, useCallback, useMemo, useState } from "react";
import { applyCustomCssToDocument } from "../../../lib/customCss";
import { DebouncedTextarea } from "../DebouncedTextarea";
@@ -70,6 +73,8 @@ function SettingsAppearanceTab(props: {
setAppIconVariant: (variant: AppIconVariant) => void;
}) {
const { t } = useI18n();
usePluginThemeRevision();
const [pluginThemeError, setPluginThemeError] = useState('');
const availableUIFonts = useAvailableUIFonts();
// Note code fonts come from the monospace-only store (fontStore); the note
// body font follows the UI font setting instead.
@@ -231,8 +236,12 @@ function SettingsAppearanceTab(props: {
);
const visibleUiThemes = resolvedTheme === "dark" ? DARK_UI_THEMES : LIGHT_UI_THEMES;
const visibleUiThemeId = resolvedTheme === "dark" ? darkUiThemeId : lightUiThemeId;
const setVisibleUiThemeId = resolvedTheme === "dark" ? setDarkUiThemeId : setLightUiThemeId;
const visibleUiThemeId = pluginThemeRegistry.isActive(resolvedTheme) ? "" : resolvedTheme === "dark" ? darkUiThemeId : lightUiThemeId;
const setVisibleUiThemeId = (id: string) => {
void applyPluginThemeSelection(resolvedTheme, null).then(() => {
(resolvedTheme === "dark" ? setDarkUiThemeId : setLightUiThemeId)(id);setPluginThemeError('');
}).catch((error: unknown) => setPluginThemeError(error instanceof Error ? error.message : t('settings.plugins.themes.failed')));
};
return (
<SettingsTabContent value="appearance">
@@ -339,6 +348,8 @@ function SettingsAppearanceTab(props: {
{renderThemeSwatches(visibleUiThemes, visibleUiThemeId, setVisibleUiThemeId)}
</div>
</SettingsAnchor>
<PluginThemePicker mode={resolvedTheme} onRestore={() => (resolvedTheme === "dark" ? setDarkUiThemeId : setLightUiThemeId)(resolvedTheme === "dark" ? "midnight" : "snow")} />
{pluginThemeError && <p role="alert" className="text-xs text-destructive">{pluginThemeError}</p>}
<SettingRow
anchorId="appearance-accent-mode"
label={t("settings.appearance.accentColor.mode")}
@@ -1,3 +1,4 @@
import { PluginThirdPartyMarket } from './PluginThirdPartyMarket';
import { usePluginManager } from '../../../application/state/usePluginManager';
import { PluginManagementDialog } from './PluginManagementDialog';
import { PluginMarketplace } from './PluginMarketplace';
@@ -289,7 +290,7 @@ function PluginManagementContent({ handoff }: { handoff: (action: () => void) =>
const manager = usePluginManager();
const [catalogToolbar, setCatalogToolbar] = useState<HTMLDivElement | null>(null);
const [selectedInstalled, setSelectedInstalled] = useState<string | null>(null);
const [pluginView, setPluginView] = useState<'installed' | 'marketplace'>('installed');
const [pluginView, setPluginView] = useState<'installed' | 'marketplace' | 'thirdparty'>('installed');
const availableFonts = useAvailableFonts();
const scopeCatalog = usePluginSettingScopeCatalog();
const [scopeIds, setScopeIds] = useState<Partial<Record<NetcattyPluginSettingScopeKind, string>>>({});
@@ -310,11 +311,12 @@ function PluginManagementContent({ handoff }: { handoff: (action: () => void) =>
return (
<div className="space-y-5">
<Tabs value={pluginView} onValueChange={(value) => { setSelectedInstalled(null); setPluginView(value as 'installed' | 'marketplace'); }} className="space-y-5">
<Tabs value={pluginView} onValueChange={(value) => { setSelectedInstalled(null); setPluginView(value as 'installed' | 'marketplace' | 'thirdparty'); }} className="space-y-5">
<div className="flex flex-wrap items-center justify-between gap-3">
<TabsList className="h-auto flex-wrap justify-start bg-muted/50">
<TabsTrigger value="installed">{t('settings.plugins.manager.installed')} <span className="ml-2 text-muted-foreground">{manager.plugins.length}</span></TabsTrigger>
<TabsTrigger value="marketplace">{t('settings.plugins.market.title')}</TabsTrigger>
<TabsTrigger value="marketplace">{t('settings.plugins.sources.official')}</TabsTrigger>
<TabsTrigger value="thirdparty">{t('settings.plugins.sources.thirdparty')}</TabsTrigger>
</TabsList>
<div className="flex items-center gap-2">
{pluginView === 'installed' && <PluginStatus noVault={manager.credentialStorageAvailable === false} />}
@@ -322,7 +324,8 @@ function PluginManagementContent({ handoff }: { handoff: (action: () => void) =>
<Button variant="outline" size="sm" disabled={!manager.available || manager.busy} onClick={() => { setPluginView('installed'); void manager.importPackage(); }}><FolderOpen size={14} className="mr-2" />{t('settings.plugins.manager.import')}</Button>
</div>
</div>
<TabsContent value="marketplace" className="m-0 space-y-6"><PluginMarketplace installed={manager.plugins} toolbarContainer={catalogToolbar} noVault={manager.credentialStorageAvailable === false} /></TabsContent>
<TabsContent forceMount hidden={pluginView !== 'marketplace'} value="marketplace" className="m-0 space-y-6"><PluginMarketplace installed={manager.plugins} toolbarContainer={pluginView === 'marketplace' ? catalogToolbar : undefined} noVault={manager.credentialStorageAvailable === false} /></TabsContent>
<TabsContent forceMount hidden={pluginView !== 'thirdparty'} value="thirdparty" className="m-0 space-y-6"><PluginThirdPartyMarket installed={manager.plugins} toolbarContainer={pluginView === 'thirdparty' ? catalogToolbar : undefined} noVault={manager.credentialStorageAvailable === false} /></TabsContent>
<TabsContent value="installed" className="m-0 space-y-6">
<PluginPackageManager manager={manager} onSelectionChange={setSelectedInstalled} />
{contextualScopes.size > 0 && (
@@ -30,7 +30,7 @@ export const TerminalHostKeyVerification: React.FC<TerminalHostKeyVerificationPr
const Icon = isChanged ? AlertTriangle : Fingerprint;
return (
<div className="space-y-3 animate-in fade-in-0 slide-in-from-bottom-1 duration-200">
<div data-host-security-surface="" className="space-y-3 animate-in fade-in-0 slide-in-from-bottom-1 duration-200">
<div
className={cn(
"rounded-xl border px-3 py-2.5",
+2
View File
@@ -44,6 +44,7 @@ import { collectOwnedPluginMenus, comparePluginMenus, usePluginContributions } f
import { buildTerminalPluginContributionContext } from '../../application/state/pluginContributionContexts';
import { PluginContributionIcon } from '../plugins/PluginContributionIcon';
import { isPluginHostProtocol } from '../../domain/pluginConnection';
import { PluginTerminalSidebarLauncher } from '../plugins/PluginTerminalSidebar';
export const TERMINAL_TOOLBAR_ITEM_IDS = [
'highlight',
@@ -1158,6 +1159,7 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
)}
</ToolbarCustomizeContextMenu>
{!compactToolbar && <PluginTerminalSidebarLauncher sessionId={sessionId} />}
{pluginToolbarMenus.map((menu) => (
<Tooltip key={menu.id}>
<TooltipTrigger asChild>
@@ -9,7 +9,7 @@ import React, { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef,
import { createPortal } from 'react-dom';
import * as SelectPrimitive from '@radix-ui/react-select';
import { useActiveTabId } from '../../application/state/activeTabStore';
import { activeTabStore, useActiveTabId } from '../../application/state/activeTabStore';
import {
getSidePanelLiveSnapshot,
subscribeSidePanelLiveSnapshot,
@@ -48,6 +48,7 @@ import {
MAX_SIDE_PANEL_PANES,
canSplitSidePanelPaneAtSize,
collectSidePanelPanes,
isPluginSidePanelTool,
getFocusedSidePanelPane,
getSidePanelNodeMinimumPixels,
getSidePanelSplitResizeBounds,
@@ -60,7 +61,10 @@ import {
type SidePanelSplitNode,
} from '../../domain/sidePanelLayout';
import { terminalLayerSidePanelStableCtxEqual } from './terminalLayerViewMemo';
import { SidePanelMountedContent } from './terminalLayerSidePanelSlots';
import { PersistentSidePanelPortal, SidePanelMountedContent } from './terminalLayerSidePanelSlots';
import { usePluginTerminalSidebar } from '../../application/state/usePluginTerminalSidebar';
import { PluginTerminalSidebarPageHost } from '../plugins/PluginTerminalSidebar';
import { PluginContributionIcon } from '../plugins/PluginContributionIcon';
import { getPaneMagnificationShortcutLabel } from '../../domain/paneMagnification';
const MemoizedSidePanelMountedContent = memo(
@@ -358,7 +362,7 @@ function SidePanelSplitView({
);
}
function SidePanelLayoutTree({
export function SidePanelLayoutTree({
node,
layout,
paneCount,
@@ -568,6 +572,9 @@ function hasMountedSidePanelContent(ctx: SidePanelContext): boolean {
&& Array.from((sidePanelOpenTabs as Map<string, SidePanelTab>).values()).includes('history');
const anyNotesOpen = sidePanelOpenTabs instanceof Map
&& Array.from((sidePanelOpenTabs as Map<string, SidePanelTab>).values()).includes('notes');
const anyPluginOpen = ctx.sidePanelLayouts instanceof Map
&& [...(ctx.sidePanelLayouts as Map<string, SidePanelLayout>).values()]
.some((layout) => collectSidePanelPanes(layout.root).some((pane) => isPluginSidePanelTool(pane.tool)));
return !(
browserMountedTabIds.length === 0
@@ -579,10 +586,16 @@ function hasMountedSidePanelContent(ctx: SidePanelContext): boolean {
&& themeMountedTabIds.length === 0
&& !anyHistoryOpen
&& !anyNotesOpen
&& !anyPluginOpen
);
}
function TerminalLayerSidePanelSectionInner({ ctx }: { ctx: SidePanelContext }) {
const pluginSidebar = usePluginTerminalSidebar({ layouts: ctx.sidePanelLayouts ?? new Map(),
getSession: ctx.getActiveTerminalSessionId ?? (() => null),
isTerminalTab: (tabId) => (ctx.sessions ?? []).some((session: { id: string }) => session.id === tabId) || !!ctx.workspaceById?.has(tabId),
select: (tool) => ctx.handleSwitchSidePanelTab?.(tool),
withdraw: (tabId, paneId, tool) => ctx.handleWithdrawPluginSidebarPane?.(tabId, paneId, tool) });
if (!hasMountedSidePanelContent(ctx)) {
// Keep AI scope maintenance alive even with zero mounted tool panels so
// merge/dissolve handoff still runs when the user closed AI before the
@@ -604,7 +617,7 @@ function TerminalLayerSidePanelSectionInner({ ctx }: { ctx: SidePanelContext })
</AISidePanelStateRoot>
);
}
return <TerminalLayerSidePanelInner ctx={ctx} />;
return <TerminalLayerSidePanelInner ctx={ctx} pluginSidebar={pluginSidebar} />;
}
/** Skip chrome rebuilds when only live/workspace-focus ticks change. */
@@ -614,7 +627,7 @@ export const TerminalLayerSidePanelSection = memo(
);
TerminalLayerSidePanelSection.displayName = 'TerminalLayerSidePanelSection';
function TerminalLayerSidePanelInner({ ctx }: { ctx: SidePanelContext }) {
function TerminalLayerSidePanelInner({ ctx, pluginSidebar }: { ctx: SidePanelContext; pluginSidebar: ReturnType<typeof usePluginTerminalSidebar> }) {
const activeTabId = useActiveTabId();
const sidePanelOpenTabs = ctx.sidePanelOpenTabs as Map<string, SidePanelTab>;
const sidePanelLayouts = ctx.sidePanelLayouts as Map<string, SidePanelLayout>;
@@ -630,6 +643,7 @@ function TerminalLayerSidePanelInner({ ctx }: { ctx: SidePanelContext }) {
handleOpenAI,
handleOpenHistory,
handleOpenNotes,
handleSwitchSidePanelTab,
handleOpenBrowser,
handleOpenScripts,
handleOpenSystem,
@@ -1046,6 +1060,9 @@ function TerminalLayerSidePanelInner({ ctx }: { ctx: SidePanelContext }) {
{ id: 'system' as const, label: t('terminal.layer.system'), icon: <Activity size={15} />, onClick: handleOpenSystem },
{ id: 'notes' as const, label: t('terminal.layer.notes'), icon: <NotebookText size={15} />, onClick: handleOpenNotes },
{ id: 'ai' as const, label: t('terminal.layer.aiChat'), icon: <MessageSquare size={15} />, onClick: handleOpenAI },
...pluginSidebar.pages.map((page) => ({ id: page.tool, label: `${page.title} · ${page.pluginName}`,
icon: <PluginContributionIcon pluginId={page.pluginId} icon={page.icon} size={15} />,
onClick: () => handleSwitchSidePanelTab?.(page.tool) })),
], [
handleOpenAI,
handleOpenHistory,
@@ -1056,6 +1073,8 @@ function TerminalLayerSidePanelInner({ ctx }: { ctx: SidePanelContext }) {
handleOpenTheme,
handleToggleSftpFromBar,
t,
handleSwitchSidePanelTab,
pluginSidebar.pages,
]);
const sidePanelTabItemById = useMemo(
() => new Map(sidePanelTabItems.map((item) => [item.id, item])),
@@ -1491,6 +1510,19 @@ function TerminalLayerSidePanelInner({ ctx }: { ctx: SidePanelContext }) {
paneHosts={panePortalHosts}
parkingHost={parkingHost}
/>
{activeTabId && pluginSidebar.sessionId && activeSidePanelPanes.map((pane) => {
const page = pluginSidebar.pages.find((item) => item.tool === pane.tool);
const target = panePortalHosts.get(pane.tool) ?? parkingHost;
if (!page || !target) return null;
const sessionId = pluginSidebar.sessionId!;
return <PersistentSidePanelPortal key={`${activeTabId}:${pane.id}:${sessionId}`} portalKey={`plugin-${pane.id}`} target={target}>
<PluginTerminalSidebarPageHost page={page} tabId={activeTabId} sessionId={sessionId}
focusOnReveal={activeSidePanelLayout?.focusedPaneId === pane.id}
adapter={pluginSidebar.contributions} getSession={ctx.getActiveTerminalSessionId}
unavailable={() => ctx.handleWithdrawPluginSidebarPane?.(activeTabId, pane.id, page.tool)}
focused={() => { if (activeTabStore.getActiveTabId() === activeTabId) ctx.handleFocusSidePanelPane?.(pane.id); }} />
</PersistentSidePanelPortal>;
})}
</div>
</div>
</div>
@@ -32,6 +32,13 @@ test('terminal layer bridge passes vault open callbacks into the side panel cont
assert.match(source, /onOpenVaultSectionFromChat: s\.onOpenVaultSectionFromChat/);
});
test('production terminal layer context forwards extension sidebar owner and pane callbacks', () => {
const contextProjection = source.slice(source.indexOf('const ctx = useMemo('));
for (const callback of ['getActiveTerminalSessionId', 'handleSwitchSidePanelTab', 'handleWithdrawPluginSidebarPane']) {
assert.match(contextProjection, new RegExp(`${callback}: s\\.${callback}\\b`));
}
});
test('terminal layer bridge updates side panel live state after render', () => {
assert.match(source, /const sidePanelLiveSnapshot = useMemo<SidePanelLiveSnapshot>/);
assert.match(
@@ -489,6 +489,9 @@ export function TerminalLayerTabBridge({ stableRef }: { stableRef: StableRef })
handleFontWeightResetForFocusedSession: themeState.handleFontWeightResetForFocusedSession,
handleOpenAI: s.handleOpenAI,
handleOpenNotes: s.handleOpenNotes,
getActiveTerminalSessionId: s.getActiveTerminalSessionId,
handleSwitchSidePanelTab: s.handleSwitchSidePanelTab,
handleWithdrawPluginSidebarPane: s.handleWithdrawPluginSidebarPane,
handleOpenBrowser: s.handleOpenBrowser,
handleOpenSystem: s.handleOpenSystem,
handleOpenHistory: s.handleOpenHistory,
@@ -7,8 +7,9 @@ import { TerminalLayerWorkspaceSection } from './TerminalLayerWorkspaceSection';
import { terminalLayerViewCtxEqual } from './terminalLayerViewMemo';
import { useTerminalHostTreeLayoutWidth } from '../../application/state/terminalHostTreeStore';
import { resolveTerminalLayerSurfaceStyle } from '../terminalPaneVisibility';
import type { PluginSidebarHostActions } from '../../application/state/usePluginTerminalSidebar';
type TerminalLayerViewContext = Record<string, any>;
type TerminalLayerViewContext = Record<string, any> & PluginSidebarHostActions;
function TerminalLayerViewInner({ ctx }: { ctx: TerminalLayerViewContext }) {
const hostTreeLayoutWidth = useTerminalHostTreeLayoutWidth();
@@ -693,7 +693,7 @@ export function movePersistentPortalNode(
}
}
function PersistentSidePanelPortal({
export function PersistentSidePanelPortal({
portalKey,
target,
children,
@@ -316,6 +316,9 @@ const SIDE_PANEL_STABLE_CTX_KEYS = [
'handleOpenTheme',
'handleOpenAI',
'handleOpenNotes',
'getActiveTerminalSessionId',
'handleSwitchSidePanelTab',
'handleWithdrawPluginSidebarPane',
'handleOpenBrowser',
'handleBackFromNotes',
'handleOpenHostFromNotes',
+7 -2
View File
@@ -29,6 +29,7 @@ import { LazyLoadBoundary } from "../ui/lazy-load-boundary";
import { toast } from "../ui/toast";
import { AppWordmark } from "../AppWordmark";
import { openHostBrowser } from "../../application/hostBrowser";
import { PluginVaultNavigation, PluginVaultPage } from '../plugins/PluginVaultPages';
type VaultViewLayoutContext = Record<string, any>;
@@ -156,6 +157,7 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
ContextMenuTrigger,
Copy,
currentSection,
pluginVault,
customGroups,
deleteGroupPaths,
deleteGroupWithHosts,
@@ -595,12 +597,13 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
</div>
<div
className={cn("space-y-1", sidebarCollapsed ? "px-1.5" : "px-2.5")}
className={cn("min-h-0 flex-1 overflow-y-auto space-y-1", sidebarCollapsed ? "px-1.5" : "px-2.5")}
>
<Tooltip>
<TooltipTrigger asChild>
<RippleButton
variant={currentSection === "hosts" ? "secondary" : "ghost"}
data-plugin-vault-fallback
className={cn(
"w-full h-10",
sidebarCollapsed
@@ -805,11 +808,12 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
</TooltipContent>
)}
</Tooltip>
{pluginVault && <PluginVaultNavigation model={pluginVault} collapsed={sidebarCollapsed} />}
</div>
<div
className={cn(
"mt-auto pb-4 space-y-2",
"mt-auto shrink-0 pb-4 space-y-2",
sidebarCollapsed ? "px-1.5" : "px-2.5",
)}
>
@@ -864,6 +868,7 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
className="flex-1 min-w-0 flex flex-col min-h-0 relative"
data-section="vault-main"
>
{pluginVault?.selected && <PluginVaultPage key={pluginVault.selected.key} model={pluginVault} />}
<VaultPageHeader
className={cn(!isHostsSectionActive && "hidden")}
dataSection="vault-hosts-header"
+239
View File
@@ -0,0 +1,239 @@
# Plugin AI and selected context: implementation checkpoint
Base: ThemePack #3978, `91b8927c56314e68c06e2c008e84559ddfb96d78`.
Branch: `feat/plugin-ai-context`.
Status: source-bound services, default trusted main/renderer adapters, app preload,
the AppView host chooser, canonical private IPC types, and an installable minimal
example are connected. Complete independent review and native acceptance remain
pending. This source is not ready to merge or release.
## Public boundaries
The parent approved `ai.model-invocation` and `context.selected-read`. The
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 model API uses a host-selected configured model. Plugins receive a chat
handle, a display label, an expiry, turn handles, text chunks, and usage totals.
Plugins do not supply provider IDs, URLs, credentials, tools, or Agent policies.
The internal operations are `open`, `start`, `read`, `stop`, and `close`.
`read` pulls one chunk with one pending read per turn through the registered SDK
and runtime routes.
Context summary enumeration returns only references explicitly shared with the
current runtime and source owner. Content reads require an opaque handle and an
expected version. Summary enumeration never calls the content reader. Supported
selected scopes are workspace, tab, session, host, and Note. There is no inventory
or arbitrary entity-ID lookup.
## File ownership
| Owner | Files or responsibility |
| --- | --- |
| This task | `electron/plugins/ai/`, `electron/plugins/context/`, `infrastructure/ai/plugin/`, `examples/plugins/assistant/`, this document |
| This task | Opt-in `requireBridge` and `maxStreamBytes` request context in `infrastructure/ai/sdk/providers.ts` |
| This task | Additive trusted-host IPC types and generation, app preload methods/types, AppView host mount, AI-only hostService adapter construction/registration |
| This task | Host security-dialog visibility hook in the modal/tab plugin view lifecycle |
| This task, reassigned by parent | Four permission descriptions and the bounded `network.stream` contract test expectation |
| Transport task | Public canonical schema, generated SDK, runtime forwarding, generic stream registration |
| Market task | Source identity, private data-owner namespace, installer database, marketplace UI |
| Transport task | Generic TCP and TLS streams |
| Logging task | Optional local plugin logs |
The existing `feat/plugin-terminal-sharing` commit
`3cdc97782ef3262969eee9d540e03ebe6fdee210` is preserved. Its
`terminal-sharing-wip.md` describes incomplete selection publication and review.
This branch does not claim that workflow is complete.
## Implemented authority
`PluginAISourceBinding` authenticates the current browser runtime before reading
`getActivePlugin().source`, `getRetainedPluginSource`, `privateOwner`, and
`publicPluginId`. Source binding is rechecked after asynchronous work. The source
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
history and makes no database changes. Data owners are opaque host identities;
no private storage is keyed by `pluginId`.
Each handle is bound to a data owner, a runtime ID, and a plugin version.
Every read rechecks runtime authority, the current model, handle lifetime, and
selected context versions. Revocation aborts pending turns. Late provider results
are discarded. Another source with the same plugin ID cannot read, cancel, or
close the original source's handles.
`SelectedContextAuthority.share` is a trusted host operation. A plugin must never
call it directly. Its descriptor accepts only a scope, a title, a version, a text
reader, and a current-selection guard. Unknown Host fields are rejected. Content
has a 16 KiB cap, excludes NUL, and rejects PEM private keys. The production host
reader must project safe fields explicitly and exclude passwords, private keys,
TOTP values, identity credentials, startup commands, and environment variables.
Free-form Notes can contain secrets that field projection cannot detect. The
trusted sharing UI must preview the selected text before sharing it.
`createPluginModelAdapter` calls the existing `createModelFromConfig` and
`streamText`, through the existing host provider transport. Credential injection
stays in Netcatty. Direct-fetch fallback is disabled for this adapter. Incoming
SSE payloads have an aggregate 128 KiB cap before SDK buffering. The main
transport also enforces a plugin-specific 128 KiB response cap and a 60-second
total deadline. Host-owned request tags allow sender/runtime cancellation
without cancelling the internal Agent's ordinary streams. SDK retries are
disabled. Only text and allowlisted token totals reach the broker. Provider
errors, raw chunks, reasoning, headers, and tool payloads do not cross it.
No AgentRuntime, internal prompt, full MCP catalog, tool executor, Node runtime,
raw host IPC, or global renderer store is exposed to plugins. A model tool request
ends the turn without executing an action. Notes edits and terminal input are
outside this checkpoint. No command is written to a terminal or followed by Enter.
The default broker limits are defined in `MODEL_INVOCATION_LIMITS` and
`SELECTED_CONTEXT_LIMITS`. They include concurrent handles, turn and chat lifetime,
input and output bytes, output token caps, event count, requests per minute, and
reserved tokens per minute. Input token reservation uses UTF-8 bytes plus 1,024
bytes for framing and the output token cap. This is a conservative estimate, not
a tokenizer or a monetary billing cap. Provider usage is reported separately.
Reservations are not refunded after cancellation. Rate limits survive runtime
restart within the broker's lifetime. Host shutdown clears this in-memory state.
## Connected host path
`createPluginHostService` constructs `PluginAITrustedHostAdapter` by default from
the existing interaction window, app-lock subscription, and native dialog. It
registers the trusted ready/response IPC handlers. App preload exposes the request
subscription and validated responses only to the trusted application renderer;
the sandbox view preload does not expose these methods.
`AppView` mounts `PluginAIContextHost`. Its hook chooses the currently configured
host model and opens an app-owned Note chooser. `trustedHostRuntime` keeps the
provider adapter and selected readers in renderer-local maps; only canonical,
validated request/results cross the main/renderer IPC boundary. The default
adapter authenticates the current main-window sender, uses one-use nonces,
rechecks the current owner after awaits, and invalidates on lock, navigation,
hide, destruction, or renderer replacement. Plugin permission revocation,
disable, and runtime release abort that runtime's turns and transport requests.
Summary enumeration, content reads, and model invocation require separate
permissions. Before selected text becomes model input, a native host dialog
shows that exact text and version with Cancel as the default. Model invocation
permission grants neither context nor action access. Menu IDs are never data
authority. While the app-owned chooser is present, the active native modal/tab
plugin view yields so it cannot cover the chooser. Existing embedded views use
their host-overlay occlusion observer. Native stacking still requires desktop QA.
The selected-context authority is read-only text. Its constructor and lifecycle
API are documented in [Selected context authority](selected-context-authority.md).
Host metadata handles cannot be promoted into edit/connect authority; those
actions require separate typed host targets, grants, and confirmations.
## QA evidence
The domain suites and transport suite use synthetic data only. The transport
suite starts a deterministic OpenAI-compatible HTTP server on loopback and
checks this path:
`ModelInvocationBroker` -> `createPluginModelAdapter` -> `createModelFromConfig`
-> `createBridgeFetchForSDK` -> existing `_streamRequestForTests` main transport
-> local fixture -> text and usage.
This is not real model inference or paid API testing. It uses no paid keys, real
Notes, hosts, or chat history. The credential value is a fixed synthetic marker;
the existing model factory replaces it with its IPC placeholder. The test does
not exercise native credential storage or production credential injection.
The tests cover source, runtime, and version denial; separate summaries and
content reads; context invalidation; provider cancellation and late results;
deadlines; request, token, event, input, output, and stream-byte limits; and
allowlisted response serialization. Raw provider diagnostics remain inside the
host.
Commands:
```sh
node --test electron/plugins/modelInvocation.test.cjs
node --test --import tsx infrastructure/ai/plugin/modelInvocationAdapter.test.ts
npx eslint infrastructure/ai/plugin/*.ts infrastructure/ai/sdk/providers.ts examples/plugins/assistant/panel.js
```
The installed assistant command test passes on this branch's ordinary merge with
transport head `c4433d496`. It installs and
activates the six-file sample through the actual package manager and runtime peer,
then checks canonical SDK/RPC, independent model/content denial, text-review
cancellation, text/usage streaming, spoofed fields, revocation, and stale versions.
The in-process browser harness is a Node test transport, not native sandbox proof.
`components/PluginAIContextHost.test.tsx` connects the default main adapter, actual
app preload factory, mounted host chooser, installed sample command/runtime peer,
existing provider factory, and existing main HTTP transport to the loopback
fixture. It checks content denial, review cancellation, streaming usage, Stop,
lock revocation, and absence of keys/configuration/policy on the trusted wire.
The view-visibility tests cover chooser appearance, dismissal, hidden surfaces,
and teardown without restoring an old view. DOM tests do not establish native
WebContentsView stacking or Chromium sandbox behavior.
The trusted main/renderer suites cover nonce replay, sender substitution, owner
replacement, navigation, hide, runtime release, lock, cancellation, focus recovery,
safe field projection, and late selected-content results. The main transport test
also verifies targeted plugin cancellation leaves an ordinary synthetic Agent
stream running and aborts an oversized plugin stream.
Failed aggregate runs remain evidence, not passing acceptance records:
| Log in `/tmp` | Result |
| --- | --- |
| `netcatty-ai-qa-tests-full.log` | 14,912 tests: 14,783 passed, 85 failed, 1 cancelled, 43 skipped; a stuck FIDO child was terminated |
| `netcatty-ai-final-tests.log` | After standard dependency patches: 15,246 tests, 15,192 passed, 11 failed, 43 skipped |
| `netcatty-ai-final-runtime.log` | 610 tests: 607 passed, 1 failed, 2 skipped |
| `netcatty-ai-connected-final-tests.log` | Connected code `42a982660`: 15,248 tests, 15,204 passed, 1 failed, 0 cancelled, 43 skipped |
| `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 |
The ten Cursor failures in the second full run came from its missing SQLite
native dependency. Rebuilding the existing dependency with the standard
`node-gyp --devdir` option repaired them; the focused rerun passed 42 with 3
skipped. The sole connected full-run failure was a test expectation missing
`network.stream` after `network`. The runtime failure was permission-copy coverage
missing descriptions for `ai.invoke`, `context.selected.list`,
`context.selected.read`, and `network.stream`. The parent reassigned these two
hunks here. Commit `0ba3ea606988e95df256d449395adb7190b46fa0` adds accurate English
and Chinese descriptions and updates the resource-scope test. The test checks
both bounded optional stream declarations and rejection of the unbounded stream
shorthand. Transport can consume this isolated two-file commit.
The permission-copy test, complete 88-test plugin contract/SDK/CLI suite, and
610-test runtime aggregate now pass (with 2 existing runtime skips). The earlier
whole-repository red totals remain historical records; no passing whole-repository
rerun is claimed from these focused repairs. Exact-head CI is recorded on the PR.
The final connected UI/visibility run passes all 3 tests. The 47 focused
service/preload/runtime checks and 7 connected provider/renderer checks also pass.
`npm run check:plugin-contract` passes. The six-file package validates and retains
the SHA-256 recorded in the native recipe.
The connected AppView production build passes. Full lint after the permission
repair reports no errors and
seven existing warnings. The repository typecheck remains red with 773 diagnostics;
the new adapter, component, hook, and visibility modules have no diagnostics. Native packaging's
standard Electron rebuild stops at its hardcoded `/home/agent/.electron-gyp`
cache outside this executor's writable roots. No dependency patch or home-directory
override was used to bypass it.
The package build validates six files. The final artifact and native procedure
are recorded in [the native QA recipe](assistant-native-qa.md). The source staging
excludes build tooling and has no Node entry, network, filesystem, secret, storage,
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.
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
screenshots, video, and installed UI acceptance belong to the parent's working
cloud GUI machine on the integrated exact head. No native acceptance is claimed here.
+120
View File
@@ -0,0 +1,120 @@
# Test the selected Note assistant on an isolated desktop
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
cloud GUI machine. The local executor's sandbox-helper failure remains a separate
failed launch record.
## Prepare the integrated head
Use a head containing the market source API, #3985's malformed-context repair,
#3986, and the transport task's canonical AI/context registration. Record the exact
commit and artifact hashes. Keep all PRs Draft. Do not merge or release them.
Run the build and tests with the repository's supported Node version:
```sh
git rev-parse HEAD
npm run build
npm run build:plugin-runtime-deps
node examples/plugins/assistant/build.mjs
node packages/plugin-cli/dist/cli.js pack examples/plugins/assistant/.package --out /tmp/assistant-example.ncpkg
node packages/plugin-cli/dist/cli.js validate /tmp/assistant-example.ncpkg
sha256sum /tmp/assistant-example.ncpkg
```
The package contains six files: its manifest, README, three panel files, and one
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`.
## Start the deterministic fixture
Start `node scripts/plugin-assistant-provider-fixture.mjs` from the repository.
The server prints its loopback URL. It listens only on `127.0.0.1`; use a fresh
unused port. Its response is fixed synthetic text, split into delayed SSE chunks.
The response includes fixed usage metadata. It accepts no request tools or system
messages. It keeps only request counts and prints no request content or headers.
Launch the exact integrated app on the working desktop with a fresh disposable
user-data directory. Preserve Chromium sandboxing. Do not use a real app profile,
cloud account, credential, host, Note, or chat history.
On that working desktop, use a separate shell for each long-running process:
```sh
node scripts/plugin-assistant-provider-fixture.mjs --port 9217
```
```sh
netcattyAssistantQaProfile=$(mktemp -d /tmp/netcatty-assistant-qa.XXXXXX)
env -u ELECTRON_RUN_AS_NODE node node_modules/electron/cli.js . --user-data-dir="$netcattyAssistantQaProfile"
```
The fixture's counts are available from `http://127.0.0.1:9217/fixture/status`.
Use a different unused loopback port if 9217 is occupied and update the provider
URL accordingly. These launch commands are for the working GUI machine; they do
not override this executor's failed sandbox-helper check.
In AI Settings, create a custom OpenAI-compatible provider with the fixture's
`/v1` URL, model `deterministic-fixture`, and the literal synthetic credential marker
`fixture-only`. This marker exercises the existing credential injection path
without a real API credential. Select that
provider and model through the host UI. Do not enable an external Agent or tools.
Create one Note named `Synthetic retry plan` containing:
```text
Synthetic retry plan: add a retry button and show a connection status label.
```
## Record the installed-plugin path
1. Import the package. Record its ID `com.netcatty.assistant`, version `0.1.0`,
source, SHA-256, browser runtime, required permissions, and optional permissions.
Installation must not grant optional access. Source labels do not imply that
an author or package is officially maintained.
2. Enable the plugin and open its `Selected Note Assistant` modal from the plugin
view catalog. Allow only its required view and command registration permissions.
3. Grant `ai.invoke` and `context.selected.list` when the host prompts. Record the
host-selected model label. The host's Note chooser must show app-owned chrome.
Verify the native plugin modal yields while this chooser is open and restores
after dismissal; the plugin must not cover the host's selection controls.
Select only `Synthetic retry plan` and click **Share selected summaries**.
4. Verify that the plugin reference dropdown starts at **No reference**. Select
`Synthetic retry plan` explicitly, then click **Send**.
5. Deny `context.selected.read`. No fixture request may be sent. The optional
model permission must not authorize Note access.
6. Retry. Allow the single selected-context read. The separate host text dialog
must show the exact synthetic text, scope, and version, with **Cancel** selected
by default. Cancel once and confirm that no fixture request is sent.
7. Retry and click **Share this text**. Record streamed text, completion, and token
usage. Capture the full host window, not only the plugin iframe.
8. Click **Send** again, share the same synthetic text, and click **Stop** while
the delayed fixture is streaming. The text must stop changing; the fixture's
aborted-connection counter must increase. Record this sequence as video.
9. Change the Note during a pending review and attempt to share the old dialog.
The expected-version check must reject the result. Do not send stale content.
10. Revoke the plugin's AI/context permission while a turn is active. Disable,
re-enable, and close its view. Old handles and late results must not resume.
Lock/unlock the app and navigate or reload the main window in the isolated
profile. Record a fresh successful run after reauthorization.
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.
The automated suites cover source substitution, runtime identity, two-handle
revocation after malformed input, late replies, byte/token/time bounds, and
no-key serialization. Native execution must still verify the real prompts,
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.
@@ -0,0 +1,94 @@
# Byte stream acceptance evidence
This is the historical report published by the transport owner at #3988
`f1410f8bb172ef2c2069cc7d55a463f7cb127c0b`. Its local failures, package hash,
and missing AI wiring describe that branch and environment. The current owner
is #3993 on `integration/plugin-byte-stream-handoff`. Follow
[the integration handoff](byte-stream-native-qa.md) for current checks, the
connected AI checkpoint, and native prerequisites. The old GUI-local Docker
recipe is not applicable to the occupied GUI machine without Docker.
The integration retains #3978 `91b8927c56314e68c06e2c008e84559ddfb96d78`,
source fixes through #3981 `4213d8e6726c44e7a479418ece3adf16ea494782`, and
AI service checkpoint #3986 `264f1d983cd1ed83fe4873974a614fff2f47f989`.
The transport delta does not edit the source database or sync implementation.
Draft PR #3988 records the published review head. No merge or release is authorized.
## Completed local checks
The delegated environment uses Node 24.19.0 on Linux.
- `npm run test:plugin-contract`: 90 passed, zero skipped.
- `npm run test:plugin-runtime`: 604 passed, one native Electron test skipped.
Database-specific testing is now entirely in the example directory.
- Final focused transport, source-isolation, installed entrypoint and disposable
database run before the test-file move: 32 passed, zero skipped. After the
move, the same installed database test passed again at its example path.
- `NETCATTY_BYTE_STREAM_DATABASE_TEST=1 node --test examples/plugins/database-minimal/protocol.live.test.cjs`:
real MariaDB result and server-side INSERT denial passed through both the
core and the ordinary installed example entrypoint. No host ports, fixture
override, real hosts, or real credentials were used by this database test.
Its container, network, package and profile were removed.
- `node --test examples/plugins/database-minimal/panel.test.cjs`: password
clearing before dispatch and safe text rendering passed.
- Production `npm run build`, generated contract drift, changed-source lint,
and whitespace checks passed.
Real broker TLS tests validate trust and the original requested hostname using
a disposable fixture CA. Database traffic is plain TCP inside an internal
Docker network. Neither check proves native BrowserWindow sandbox behavior,
database TLS, device testing, or real model inference. The registered AI/context
entrypoint test uses a synthetic trusted adapter, not the outstanding renderer
wiring owned by the AI task.
The EOF and last-await cancellation regressions each failed before their fix
and passed after it with real loopback sockets. Source changes before grant
publication, source changes during a pending read, and a stream returned after
runtime release have separate passing regressions.
## Reproducible native sample
Run the preparation tool for `tcp://172.30.247.2:3306` as described in
[`examples/plugins/database-minimal/README.md`](../../examples/plugins/database-minimal/README.md).
Two separate staging directories produced the same five-file package:
- Archive bytes: 11,621.
- SHA256: `53b1aebae27d0681d0e93b8f7529f574cc2f176f3ca34eb2eabd1c35cdfb3deb`.
- Content SHA256: `c83c6913f4ea40eae80d7042231b083be7d35b27022d6df24de9ce09022f0016`.
- Delegated artifact: `/workspace/artifacts/plugin-byte-stream/database-minimal.ncpkg`.
The artifact contains no credentials or server setup. Changing its approved
fixture endpoint requires a newly prepared package and a recorded new hash.
## Checks that did not pass locally
`npm run pack:dir` first failed in the existing Cursor SDK preparation step
because npm could not create `/home/agent/.npm/_cacache`. Retrying with npm and
Electron build caches under `/workspace` passed that step, then failed while
rebuilding `@serialport/bindings-cpp`: `@electron/rebuild` hard-codes the
unwritable `/home/agent/.electron-gyp` directory. The native rebuild was not
disabled, HOME was not changed, and no sandbox setting was weakened. Full app
directory packaging remains failed locally and needs the normal CI or native
QA build environment.
The broad `npm test` run reported failures in existing transfer-resume,
Markdown editor, SSH algorithm, SDK/CLI platform, terminal paste, and updater
tests, then stalled without a final aggregate. Its owned process tree was
terminated. It is not reported as a passing full test run.
An independent source-baseline worktree at `4213d8e` ran five affected files in
the same environment with shared dependencies: 145 tests, 118 passed,
24 failed, three skipped. It reproduced failures in transfer-resume,
Markdown editor, SSH algorithms, AI bridge resolution, and updater tests;
updater errors include the unavailable `/home/agent/.netcatty/tmp/Netcatty`.
This diagnoses that tested subset only. It does not classify every broad-suite
failure, replace exact-head CI, or make any failed result successful.
## Pending acceptance
Exact published-head CI and independent review remain pending. The parent owns
sequential native GUI acceptance for streams and the database example, then AI,
then logging integration. The native recipe covers ordinary install/source and
endpoint review, deny/retry/query, cancel/revoke/disable, restart with no saved
password, audit inspection, screenshots, and a short recording. No native GUI
run or screenshot/video result is claimed by this delegated executor.
@@ -0,0 +1,129 @@
# Byte stream integration checkpoint
The original transport checkpoint is #3988
`c4433d4961ecf4a81bea92b0a14f47b4ec12f679`. The separate integration branch
`integration/plugin-byte-stream-handoff` preserves that history and ordinarily
merges source identity #3981 `4213d8e6726c44e7a479418ece3adf16ea494782`
and connected AI #3986 `173ef5abbb1f8cea4e2c409c5791186926b778e1`.
The original transport branch remains untouched. Follow the
[current native handoff](byte-stream-native-qa.md) for the integrated package.
The late transport checkpoint `f1410f8` is subsequently preserved through an
ordinary merge. [The comparison record](late-transport-integration.md) lists its
remaining changes and the conflict resolutions that preserve the two P2 fixes
and newer connected AI files.
## Registered transport
Canonical definitions are in `packages/plugin-contract/schema/plugin-contract.schema.json`.
`network.byte-streams` negotiates the capability. The separate `network.stream`
permission requires explicit `tcp://host:port` or `tls://host:port` resources.
Wildcard declarations and grants cannot authorize raw streams. HTTP grants and
payload limits are unchanged.
The SDK exposes `context.network.connect({ endpoint }, cancellationToken?)`.
The returned connection has `endpoint`, `readable`, `writable`, `close()`, and
`dispose()`. The control methods are `network.stream.connect`,
`network.stream.attach`, and `network.stream.close`. Descriptors, request
shapes, feature names, limits, generated SDK types, and host validators share
the canonical schema. Unknown request properties are rejected.
The registered service authenticates the host runtime before source lookup.
Runtime startup captures the existing opaque source owner. Every asynchronous
commit revalidates the runtime, active and retained source, and opaque owner
through the source task's APIs. Caller-supplied owner/source parameters are
rejected. Audit commits also verify the source binding.
Connection and stream IDs are host-issued and bound to that runtime and owner.
The SDK prepares its incoming waiter before attach starts the host producer.
Both directions reuse the existing binary stream router and credit windows.
Input credit returns after the socket write callback. Output backpressure
bounds the producer. The router has one optional producer `onClose` hook;
peer cancellation releases the socket even while it is idle.
The host validates all DNS answers and pins a literal IP. Production rejects
loopback, link-local, unspecified, multicast, mapped local addresses, selected
IPv6 tunnel forms, and the machine's own interface addresses. The production
host constructor never forwards the core's explicit local-test override.
There is no proxy discovery, reconnect, UDP, listen, generic IPC, shell, Node
plugin runtime, or filesystem escape.
Limits are four connections per runtime, 64 globally, 64 KiB chunks and socket
buffer thresholds, and 8 MiB combined bytes per connection, plus the existing
runtime byte quota. Connect, idle, and lifetime deadlines are 10, 30, and 120
seconds. Close tombstones are capped at 256 small identity records. Revocation,
runtime stop, disable, shutdown, and stream cancellation release resources.
TLS requires trust and original-hostname validation with TLS 1.2 or later.
Plugins cannot set custom CAs, SNI, or validation exceptions. STARTTLS is not
implemented. Generic audit records contain endpoint, runtime, handle, and byte
counts. The host does not inspect SQL or record payloads or credentials.
## Integrated AI interface
The same canonical schema defines the relayed `AI_CONTEXT_ROUTES` types.
The SDK/runtime expose `context.ai.openChat/startTurn/readTurn/stopTurn/closeChat`
and `context.selectedContext.list/read`. Host registration advertises
`ai.model-invocation` and `context.selected-read`, and wires runtime cleanup,
plugin disable, revocation through the service, and shutdown.
The 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.
## Database example
`examples/plugins/database-minimal` is an ordinary browser plugin. Its developer
preparation tool creates an `.ncpkg` for one exact disposable private IPv4
endpoint. The output contains a browser bundle, a normal manifest, and view
assets. The preparation tool and its Node dependencies are not in the plugin.
Only the example contains the database protocol: common protocol 4.1,
`mysql_native_password`, and
`SELECT value FROM plugin_demo.example_result LIMIT 1`.
The view accepts session-only fixture credentials and clears its password
field before dispatch. It does not save view state, settings, logs, or secrets.
The host never exposes SSH or AI credentials. JavaScript does not promise
memory zeroization. The account's server-side SELECT grant enforces read-only
access. The fixed query is not a SQL security classifier.
The example has no query editor, schema browser, pool, arbitrary SQL, or host
connection-management subsystem. See the example README for native setup.
## Evidence and limits
- Both source-reviewed races have executable real-loopback regressions here.
Delayed final-byte consumption previously lost EOF after the socket closed.
Double-microtask cancellation previously created an untracked late socket.
Each regression failed before its respective fix and passed afterward.
- The broker tests use real TCP/TLS sockets, including trusted fixture CA,
untrusted certificate and wrong-hostname denial. Connect and idle deadline
cases use mock clocks around real sockets or delayed DNS.
- The installed entrypoint test builds and installs a normal package, executes
its actual browser entry module through SDK/RPC/binary streams, and exercises
grant denial, exact resources, unknown-field rejection, close, revoke, and
disable. It also exercises registered AI/context SDK routes with a synthetic
trusted adapter. Its runtime harness is Node and is explicitly not a browser
sandbox or OS sandbox test.
- The disposable MariaDB test checks a synthetic SELECT result and server-side
INSERT denial. It creates an internal network, publishes no host ports, and
removes the container and network. Database transport is plain TCP inside
that disposable network, not database TLS.
The integration fixes own-machine comparison across IPv4 and mapped IPv6.
Its regression mocks interface lookup and uses a fake dialer. It does not retry
the reviewer's failed EPERM interface lookup. Other private peers remain allowed.
Cancellation now discards buffered browser bytes, settles producer-cancelled
credit once, and retains terminal producer state only until outstanding credit
arrives. Existing sequence, schema, and negotiated-window checks still reject
unknown, duplicate, out-of-order, and excess credit. Incoming cleanup runs even
if a cancellation refund cannot be sent. This uses the existing stream lifecycle
and runtime shutdown, without another scheduler.
Normal native BrowserWindow and screenshot/video acceptance remain pending.
The native GUI machine lacks Docker and is occupied with SSH acceptance. See
the handoff for fixture prerequisites. Do not disable the Chromium sandbox or
relax endpoint restrictions to obtain that evidence.
@@ -0,0 +1,176 @@
# Test integrated byte streams on the native desktop
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
(`173ef5abbb1f8cea4e2c409c5791186926b778e1`), and source identity #3981
(`4213d8e6726c44e7a479418ece3adf16ea494782`) through ordinary merges.
## Prepare the desktop and fixture
Wait until the approved native GUI machine is free from SSH acceptance work.
Use a fresh disposable app profile, a working display, and the platform's normal
Electron sandbox. Keep the existing endpoint policy and system security settings.
The database pass requires a disposable MariaDB fixture at a reachable private
IPv4 address. The address must not belong to the GUI machine. The current native
GUI machine lacks Docker, so fixture provisioning is a prerequisite. Use an
already approved separate Docker host or disposable database machine with an
existing permitted network path. Do not install new software or create local
forwarding exceptions as part of this recipe. If no fixture is available, record
the database pass as pending.
There is also a separately labeled
[public Rfam candidate](../../examples/plugins/database-rfam-readonly/README.md).
It fixes one public hostname, public account, database, and `SELECT 1 AS value`.
It has only been prepared and checked offline. The parent must obtain exact
package and endpoint authorization before any install, permission activation,
or connection. Its authentication and TLS requirements remain unverified. This
candidate does not replace the owned fixture regression or prove that the GUI
can reach the coding container's private database.
The registered automated fixture uses
`mariadb:11.4@sha256:250e1609e93c0e6aecf64bfbab2d84d5e89d2481540fe77447ff6c855c3b1873`.
It runs on an internal Docker network, publishes no host ports, generates a
disposable password, and removes its container and network. On a prepared Linux
executor with that recognized image, run:
```sh
NETCATTY_BYTE_STREAM_DATABASE_TEST=1 node --test examples/plugins/database-minimal/protocol.live.test.cjs
```
That test exercises the protocol, broker, and installed package's SDK/RPC path
in a Node harness. It does not prove native GUI behavior. For the GUI fixture,
have its operator create only
synthetic data and a dedicated SELECT-only account. Run this SQL using the
fixture's administration channel, replacing the password placeholder with a
fresh disposable password that is never included in screenshots or logs:
```sql
CREATE DATABASE plugin_demo;
CREATE TABLE plugin_demo.example_result (value VARCHAR(64));
INSERT INTO plugin_demo.example_result VALUES ('synthetic stream result');
CREATE USER 'plugin_demo'@'%' IDENTIFIED BY '<fresh-disposable-password>';
GRANT SELECT ON plugin_demo.example_result TO 'plugin_demo'@'%';
```
Use protocol 4.1 with `mysql_native_password`. The example uses plain TCP only
within the approved isolated fixture network. Verify INSERT denial independently
through the fixture's client. The plugin intentionally offers only its fixed read.
## Build and identify the packages
Use Node 22 or later and the lockfile. Run these commands in sequence:
```sh
npm ci
npm run test:plugin-runtime
npm run test:plugin-contract
npm run test:plugin-examples
npm run generate:plugin-contract
npm run check:plugin-contract
npm run generate:capability-tools
npm run check:codex-app-server-schema
npm run lint
npm run build
git diff --exit-code
```
For the reproducible packaging-only example, use the fixed synthetic endpoint:
```sh
node examples/plugins/database-minimal/prepare.mjs tcp://10.77.0.20:3306 /tmp/database-example.ncpkg
node examples/plugins/database-minimal/prepare.mjs tcp://10.77.0.20:3306 /tmp/database-example-repeat.ncpkg
cmp /tmp/database-example.ncpkg /tmp/database-example-repeat.ncpkg
node packages/plugin-cli/dist/cli.js validate /tmp/database-example.ncpkg
sha256sum /tmp/database-example.ncpkg
```
The two five-file packages built on Node 24.19.0 with the locked dependencies
match byte for byte. The current owned-fixture package SHA-256 is
`588cb6766616c2f3057c7c8df1c20dbac947e0d5e9d01b2a928c7581b00a69c5`.
The earlier `6a4fb178` package used hash `88c14283…dbc1af4`. The example-only
protocol now has separate fixed owned-fixture and Rfam entrypoints; the owned
fixture database, query, and opt-in live regression are retained.
The six-file assistant package retains SHA-256
`8ef162d13d0a16878fb817462759c9221352d0565dcefebeb102ea3575a918e8`.
The preparation tool removes temporary build paths from its minified browser
bundle. Rebuild with the actual fixture endpoint before native testing. Validate
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.
## Record the installed browser path
1. Launch the integrated app on the approved desktop with a new disposable
`--user-data-dir`. Capture the full app window and record the exact commit.
2. In Settings > Plugins, import the database package. Capture its ID, version,
package hash, source identity, browser entry, and exact endpoint permission.
The import must leave the plugin disabled and optional access ungranted.
3. Enable it and open **Disposable database example**. Deny the first endpoint
request. Capture the failure and verify the runtime remains usable.
4. Retry with only the disposable fixture credentials and allow the exact
endpoint. Capture the one-row result and the source-aware permission prompt.
The password field must clear when the request starts.
5. Record a video of a delayed fixture operation followed by **Cancel**. Use a
delay offered by the fixture operator. Do not add a host protocol scheduler
or change the query. Confirm the request stops and a fresh read still works.
6. Revoke `network.stream` while a connection is active. Confirm the panel reports
failure and a new request prompts again. Disable, re-enable, close the view,
and restart the app. No old connection or result may resume.
7. Record the assistant's denial, exact-text review, streamed result, usage,
Stop, and post-revocation recovery through its linked recipe. Use the same
integrated head and preserve its separate permission descriptions.
8. Remove only the synthetic plugins and profile. Have the fixture operator
remove the disposable account, data, container, and network. Stop the synthetic
model provider.
## Record the remaining evidence
Save the exact head, app platform/version, package hashes, ordered commands and
exit codes, screenshots, and video paths. Distinguish a skipped Docker test,
synthetic Node runtime integration, and actual native sandbox acceptance.
The address regression uses a mocked `os.networkInterfaces()` result and a fake
dialer. It never retries the reviewer's EPERM interface lookup. The cancellation
regression uses the registered package, host service, SDK, RPC, and real loopback
echo fixture under the existing test-only override. This override is unavailable
through the production service constructor. Neither test certifies the native
BrowserWindow sandbox or a live database pass on the GUI machine.
The integration executor completed these checks in order on 2026-10-26:
| Check | Result |
| --- | --- |
| `npm run test:plugin-runtime` | 627 tests: 626 passed, 1 native skip, 0 failed |
| `npm run test:plugin-contract` | 90 passed, 0 skipped, 0 failed |
| `npm run test:plugin-examples` | 4 passed, 1 opt-in Docker skip, 0 failed |
| Plugin contract generate and drift check | Passed, no generated changes |
| Capability tool generation and drift check | Passed, no generated changes |
| Codex App Server schema check | Passed |
| `npm run lint` | Passed, 0 errors and 7 warnings outside the transport changes |
| `npm run build` | Passed, Vite production build completed in 40.23 seconds |
| Database repeat packaging and both package validations | Passed |
The late transport merge moves the opt-in database test into the example directory.
Run its panel and protocol tests explicitly with
`node --test examples/plugins/database-minimal/*.test.cjs`. The database test
still requires explicit opt-in and the native Electron suite remains separate.
This executor has Docker, but the pinned MariaDB image
is not cached. No image was installed and no new live database run is claimed.
Native GUI acceptance remains pending on the occupied desktop. Full repository
tests and platform packages run in the integration PR's exact-head CI. Record
their final workflow conclusions separately from these local results.
After the late transport merge and Rfam candidate preparation, the registered
runtime suite has 626 passed and one native skip, contract/SDK/CLI has 90 passed,
and `npm run test:plugin-examples` has four passed and one Docker skip. The
example suite is now registered in CI. Public protocol checks use only synthetic
packet bytes and package preparation; no Rfam connection or permission was
activated. Final CI belongs to the current #3993 head, not the older `6a4fb178`
head whose two workflows already succeeded.
+166
View File
@@ -0,0 +1,166 @@
# Sandbox capability coverage
This matrix tracks expansion from Draft #3951 at
`e5bd294cddbf58dfe04c646b6291933d980498fd`. Each accepted item requires a
canonical schema, generated SDK, broker, permission policy, quota and audit,
host adapter, lifecycle cleanup, synthetic boundary tests, review, and exact-SHA
CI. Enumeration in a manifest is not a working API. Status `planned` does not
mean complete. Existing APIs remain available.
## Coverage matrix
| Subsystem | Working API at baseline | Enumeration or missing adapter | Accepted bounded entry | Data and action risk | Authorization resource | Revocation, audit, quota | Verification and disposition |
| --- | --- | --- | --- | --- | --- | --- | --- |
| Authorization management | Engine `listGrants`, `revokeAll`, database audit | Manager shows declarations only | Host-only grant list, bounded audit summary, disable and revoke | Paths, session IDs, grant metadata are sensitive | Exact plugin ID, trusted app sender | Serialized manager mutation, disable before revoke, runtime teardown, retained audit | Batch 1 limited acceptance complete on ff3734d: independent review fixes, host/manager/UI tests, exact test/packaging CI, parent native own-views Application grant/revoke/disable/runtime-stop check. Visible view was manually closed beforehand. No plugin RPC for managing grants |
| Network and credential leases | `network.request`, `credentials.createLease`, single-use host redemption | SDK request omits lease and authorization fields | Canonical request types matching broker | Network mutation and credentials | Exact origin plus secret reference and operation | Existing timeout, redirects, byte quotas, runtime checks, lease TTL | Batch 1 contract/SDK consistency complete: canonical types, synthetic fixture/lease tests, feature discovery and exact CI. No live network or credential acceptance claimed. No plaintext secret API |
| Commands, menus, toolbar, status bar, views | `commands.execute`, settings, context keys, view state and messages, UI contributions | No editor or SFTP menu slots | Existing contribution system | Plugin text and arguments may be sensitive | Namespaced contributions and permissions | Existing lifecycle, rate and message limits | Existing tests. New context menu slots planned with bounded context DTOs |
| Terminal providers | Decorations, links, completion, snapshots through provider requests | Session change delivery follows prior provider request and non-once grant | Preserve providers. Standalone selected-session watch planned | Hostnames, session state, endpoint identity | Host-selected session, runtime identity | Session-close and revoke cleanup, bounded event rate | Existing provider tests. Ordinary views cannot independently subscribe today |
| Current context and host picker | Host context keys are boolean state | `vault.metadata` enumeration is not generic SDK | Selected host/session DTO from an explicit whitelist | Labels and endpoint metadata are sensitive | Host-owned user-action context, selected host/session | TTL, runtime and session invalidation, redacted audit | Planned. Never reuse agent `sanitizeHostForAgent` as plugin DTO |
| Terminal selection and input draft | Provider-supplied snapshots, completion suggestions | No explicit selection-share or draft API | User shares selection, plugin proposes a visible editable draft | Terminal plaintext, accidental execution | Host action token plus exact live session/endpoint | Single-use token, size cap, no plaintext audit, active identity before commit | Planned. Reject Enter/control characters for draft insertion. No full history or direct shell |
| Native interactions and clipboard | Native permission dialogs; new browser SDK host choice/input and explicit copy/share workflow | Progress/toast remain unimplemented | Host-owned prompts, two-phase clipboard snapshot/share, exact copy preview | Clipboard and input may contain secrets; UI discloses granted network capability; prompt spam | `ui.interact`, clipboard direction, host-signed window/runtime/version/phase/TTL action | Admission before permission prompt, byte/rate/concurrency limits, lock/blur/close/revoke cleanup, active guard, content-free audit | [Host interaction slice](./host-interactions.md) implemented and synthetic-tested. 8e4dd0c exact test/packaging CI and independent delta review passed. Parent native compose and explicit Preview/Share/copy succeeded; a separately approved 0.1.1 compose ran about 86 seconds, beyond the old 30-second deadline and within the declared 180-second budget, with no fresh command-timeout error. All five permissions were Once; cleanup is parent-owned. New navigation slices retain separate acceptance gates. Progress/toast still planned. No background reads or trusted plugin `userGesture` boolean |
| Local filesystem | Read file, stat, overwrite existing regular file | Directory listing lacks same-handle native adapter | Keep current boundary, evaluate file picker handles | Arbitrary local file content and writes | Canonical exact path or selected file handle | Inode checks, no symlink alias, bounded bytes, active runtime | Existing filesystem tests. Directory enumeration and new arbitrary-path creation deferred until portable safe native adapter exists |
| SFTP and remote files | No generic plugin API | `sftp.read` and `sftp.write` enumerated only | Host-selected session/path/mode/TTL handles, read and explicit save/upload | Remote secrets and destructive overwrite | Live endpoint identity, exact path, read/write split | Cancel streams, byte quota, overwrite confirmation, close/revoke cleanup | Batch 2 planned. Never forward arbitrary MCP catalog or session ID alone |
| Editor and Notes | No plugin document API | No versioned snapshot or edit preview | Explicit snapshot and versioned proposed edit | Document secrets, lost updates, executable files | Selected document plus revision and action token | Version conflict check, preview, bounded bytes, revocation | Planned evaluation after shared action context. No unattended edits |
| Snippets and scripts | Provider import drafts only | No generic read/run API | Explicit selection and create draft | Script contents may contain secrets. Running executes commands | Selected item, read versus draft write | Preview, byte cap, no automatic execution | Planned evaluation. Script `run` and onConnect modification deferred |
| Connections and authentication | Connection/authentication providers, opaque credentials, streams | Generic host open/connect absent | Existing providers remain | Connect can execute saved onConnect scripts | Provider operation plus live endpoint and credential lease | Existing provider lifecycle, streams and quotas | Existing tests. Generic connect is a write/execution capability, deferred pending explicit execution design |
| Sync and storage | Local namespaced KV, encrypted-object sync providers, opt-in setting sidecars | No general KV cloud sync | Existing opt-in provider protocol | Conflicts, deletes, credential access | Provider/account/object, settings scope | Existing limits and lifecycle, non-cascade metadata | Existing sync tests. General KV sync deferred until conflict/delete/quota/migration policy is defined |
| Vault and keychain | Credential references and import preview/commit | Metadata/write enums do not grant a generic Vault SDK | Only user-selected whitelist DTOs and drafts | Notes, environmentVariables, startupCommand, keys and credentials | Selected object plus field/direction | Explicit preview, identity checks, redacted audit | Full Vault export and secret reads deferred indefinitely. No agent field-exclusion DTO reuse |
| System manager and Docker | No plugin system API | Agent/MCP adapters are separate authority | Typed read-only summaries excluding args/env/inspect | Process command lines and Docker inspect can expose secrets | Selected live session and summary kind | Bounded result, endpoint identity, cancellation | Planned evaluation. Arbitrary process/inspect forwarding deferred |
| Port forwarding | No generic plugin API | Existing agent start is write-sensitive | Selected rule status and open host form | Rule endpoints and local exposure | Selected rule plus host action | Bounded DTO, session close and revoke | Planned evaluation. Direct start deferred until binding/listener approval design |
| Themes, settings, appearance, i18n | Terminal theme/background Providers, isolated view CSS, scoped settings and environment updates | No host global theme contribution. Personal customCSS is not plugin authority | Accepted ThemePack stage: static palette registry, selector, local preview, revision-safe apply/cancel/reset, terminal linkage | Unreadable palettes and misleading security UI. Nineteen UiThemeTokens fields are not independent: accent derives primary/ring/foregrounds | User explicitly selects a theme; static contribution enablement separate from code permissions | Immutable version/hash, bounded JSON, contrast/security-surface gates, preview owner/revision and safe fallback | First host color layer implemented; [contract/lifecycle/evidence](./theme-packs.md). Exact-SHA CI, independent review and native acceptance pending. Static terminal linkage not yet accepted; no arbitrary DOM/CSS, remote assets, layout replacement or security UI customization |
| Transfer center | Host transfer queue exists, no plugin entry | No ownership-bound task API | Observe and cancel only plugin-created tasks | Paths and host endpoints reveal user data. Cancel may leave partial files | Own transfer handle plus source/destination authority | Shared backpressure, cancel/close cleanup, honest partial status | Planned with batch 2. Do not broadcast or cancel other tasks |
| AI, MCP, automation | Host agent catalog and harness exist separately | No generic plugin AI or tool forwarding | Selection-to-proposal workflow through the same controlled primitives | Content disclosure, account authority and model cost | Explicit content sharing plus service origin/budget | No inherited history, tools, account, or hidden cron | Controlled workflow planned. Generic host AI and visible schedules deferred to distinct product decision |
| Known hosts, app lock and global security | Host security management UI | No plugin management API | Future allowlisted security-page navigation | Host-key trust, unlock, keys and security policy affect all sessions | Host navigation action only | No mutation or sensitive DTO | Security policy mutation, private-key reads, auto-trust and unlocking deferred indefinitely |
| Plugin blob/cache | Local KV exists | No exclusive blob cache | Evaluate bounded plugin-owned cache when a real workflow needs it | Disk growth and lifecycle ambiguity | Plugin namespace, no arbitrary path | Size quota, eviction and uninstall policy required | Deferred until a concrete larger-than-KV workflow establishes value and retention policy |
| Vault and terminal navigation | `tab` mounts a sandbox page through TopTabs/QuickSwitcher; aside/panel are overlays | No Vault subpage registry or terminal side-panel plugin tool. `settings` is a detail entry, not a new settings category | Accepted independent slice: host-owned namespaced Vault pages in an Extensions group, mounted in the Vault content area through existing ViewHost; then terminal pane/split pages for the selected session | Navigation cannot disclose session contents or replace built-in/security navigation | Declared page contribution plus host selection. Content reads have independent permissions | Host order/when/focus, cancellation of late open, view cleanup on hide/update/disable/uninstall, safe invalid-link fallback | [Vault page slice](./vault-pages.md) implemented and synthetic-tested: actual Vault content slot, private Scratchpad, catalog withdrawal, host occlusion, reserved-open cancellation, owner/crash/lock cleanup. Exact CI/independent/native acceptance pending. [Terminal sidebar slice](./terminal-sidebar.md) also implemented with actual pane/split mounting, owner focus and session-change cleanup; no automatic session data. Exact CI/independent/native acceptance pending for the new stack. No arbitrary router/DOM API |
| Distribution and runtimes | Browser-only public catalog, immutable packages, advanced developer gate | Signing/trust and release remain separate phase | Retain existing boundary | Package execution and publisher trust | Pinned catalog selection, hash, engine/features | Existing install transaction, quarantine and cleanup | No merge/release. Do not extend to arbitrary Node, IPC, shell, or global capabilities |
## Delivery order and stopping rule
1. Batch 1: actual authorization management plus network contract consistency.
Keep the existing manager layout. Add one independent permission panel.
2. Batch 1b: the host text interaction slice, then selected context and terminal
sharing/drafts. A plugin-supplied gesture is never authority.
3. A separate navigation slice mounts actual Vault extension pages, then terminal
sidebar panes, reusing the isolated ViewHost and keeping content permissions separate.
4. Deliver the declarative ThemePack stage with security appearance and contrast gates.
5. Batch 2: SFTP selected-file handles and explicit transfer destinations.
6. Reassess editor, Notes, snippets, menus, system summaries, and port forwarding
using the tested host-action and resource primitives.
Stop only when every row is implemented and tested, reasonably deferred with a
specific boundary and rationale, or awaiting a concrete user decision. A
planned row remains unfinished. Native screenshots and recordings belong to
the parent thread. Real credentials, hosts, or transfers require approval for a
specific target there. Synthetic checks do not establish native acceptance.
## Batch 1 evidence and next workflow
The independent permission panel adds only an import and a detail mount to
`components/settings/tabs/PluginPackageManager.tsx`. It uses the manager hook's
new `refresh()` after host revocation. Marketplace layout remains owned by the
parallel UI layer. Five locale files contain the panel strings.
The next usable workflow is a browser plugin that asks a host quick-pick/input
question, then explicitly copies its result or asks the user to share clipboard
text. The host displays the plugin source and action before reading or writing.
A canonical `HostInteractionRequest` and bounded result carry only text/choice
IDs. The host owns request IDs, requester webContents, runtime/version, TTL,
cancellation, and the consent result. The renderer cannot reply for another
window. Plugin `userGesture` values are ignored. Runtime/revoke shutdown aborts
all outstanding prompts and ignores late replies. A shared prompt primitive
then supports selected host/session context, selection share, and input drafts.
Before proceeding to the sensitive context/terminal and SFTP entries, the
native path must prove consent, cancellation, quota, and identity binding with
synthetic data. UI screenshots and real-resource approval remain parent-owned.
Baseline code references: `electron/plugins/hostService.cjs`,
`secureCapabilities.cjs`, `permissionEngine.cjs`, `terminalProviderService.cjs`,
`filesystemBroker.cjs`, `extensionProviderService.cjs`,
`packages/plugin-sdk/src/index.ts`, and
`packages/plugin-contract/schema/plugin-contract.schema.json`. Host business
implementations under `electron/capabilities/catalog/` are reference material,
not plugin authority. `infrastructure/ai/vaultAgentBridgeClient.ts` is not a
plugin DTO sanitizer.
## Navigation slice checkpoint
The Vault stage adds `views.vault-pages`, the `vault-page` location, and the
existing `views` permission. It grants no Vault/session data. The ordinary
browser Scratchpad example uses only its own bounded view-state API and
explicit user input. It is not a Notes API or automatic sync feature.
The independent integration base includes #3956 UI polish and #3960 d4ca fixes;
frozen evidence heads and prior PR bases are unchanged. Related UI changes in
this stage are limited to VaultView/VaultViewLayout, new independent plugin
components/hooks, and localized strings. PluginMarketplace/PluginPackageManager
layouts are not rewritten. Native acceptance belongs to the parent with a new
fixture-specific own-views approval. Sidebar, ThemePack, selected context and
SFTP remain unfinished; this checkpoint is not overall completion.
## Terminal sidebar checkpoint
`views.terminal-sidebar` exposes one more existing sandbox view placement,
using only the existing own-views permission. The host owns navigation,
selection, split/close and focus. Its context is a surface string only; no
terminal DTO or resource authority is implied. The Session Scratchpad fixture
uses explicitly typed synthetic text and its own view state.
This stage builds on ordinary merges containing #3960 8e4dd0c and the #3971
stale-layout callback fix. Prior QA heads and PR bases are not rewritten. The
ThemePack implementation, selected-context/drafts and file handles remain
unfinished; parent review/native gates still apply to each delivered head.
## Color-pack and remaining workflow checkpoint
The first [ThemePack color layer](./theme-packs.md) now has an implemented host
catalog, data-only admission, Appearance selector, local preview, revision-safe
application/reset, missing-version fallback and host security palette boundary.
It is not native-accepted until the new exact head is independently reviewed,
built by CI and exercised through normal Settings. Terminal palette linkage is
not accepted by this contract yet, so a declared but ignored linkage cannot be
counted as coverage. Font/density/icon/background packs remain separate product
and accessibility decisions, rather than arbitrary CSS permissions.
Parent native evidence for sidebar `a8adeb86` now covers the original Local
Terminal blocker, own-views Once, save, magnify/restore, a split with empty Notes,
and side/bottom docking. `733f44c` adds only the production bridge projection
regression. The older `0c097a1` CI run `40308057623` remains **failed and
unattributed**: 9447 top-level TAP results are `ok`, the aggregate footer is
missing and the process exits 1. A newer passing run does not change that record.
The native Vault success retains its original `0c097a1` evidence label.
The next implementation stages are bounded by useful workflows:
1. **Selected terminal sharing and drafts (A).** Reuse host-signed action
contexts for an explicit selection preview/share, then let the plugin submit
a bounded input draft to the host compose surface. No Enter, raw PTY, history
enumeration or background terminal listener. First add a host-selected
session handle with a minimal from-scratch DTO and independent lifecycle
watch; navigation alone grants no content. Bind handles to actual session and
connection identity, runtime/version, owner, mode and TTL. Cover cancel,
reconnect, close, revoke and late draft results with synthetic terminals.
2. **Selected file conversion/transfer (A).** The host picker issues read or
explicit-destination write handles. Start with one file, stat/read and saveAs
or upload, not recursive traversal or arbitrary paths. Require destination
preview/overwrite confirmation, byte/concurrency limits, cancellation,
session/endpoint identity and final active checks. Observe/cancel only the
plugin's own transfer. Real native SFTP targets require parent-coordinated
concrete approval; local tests use isolated fixtures.
3. **Versioned document proposals (selected B).** Reuse snapshot/handle and host
review primitives for one explicitly shared editor document or Note. Apply
only an expected-version patch to its buffer after a visible diff; saving a
remote file remains a separate file operation. Reuse the draft path for a
selected snippet or new snippet template; do not run scripts or add onConnect
bindings. Cover edits after snapshot, detached ownership, closure and revoke.
4. **Final reassessment.** Recheck the matrix by actual workflow and eight gates.
Read-only system aggregates, selected port-rule status and bounded navigation
may be added only where the shared primitives leave a concrete useful gap.
Full process arguments/env/inspect, tunnel start, arbitrary AI/MCP tools,
background jobs, general KV sync and raw high-privilege access remain deferred
or retained boundaries. Do not invent new C work to prolong the task.
These stages remain unfinished work, not API-name coverage. Exact schemas and
ordering will follow the existing brokers/adapters rather than a parallel RPC
stack or one namespace per product module.
+10
View File
@@ -630,3 +630,13 @@ negotiated API version and runtime initialization uses and verifies that exact
version. Host version bounds remain independently enforced; unknown required
features still fail closed. No old manifest is rewritten and no permission grant
is migrated by this revision.
## Declarative host color contributions
`appearance.theme-packs` adds `contributes.themes` and generated palette/reference
schemas. A pure package uses `main: { "data": "theme" }`, contributes only themes
and omits permissions/activation/companions. It has no executable runtime. Normal
admission accepts this data-only form alongside browser-only code packages; Node
and arbitrary IPC remain advanced/unsupported public boundaries. Selection is a
trusted host Appearance action, not an SDK setter or `appearance.write` grant.
See [Theme packs](./theme-packs.md) for limits, preview, persistence and fallback.
+130
View File
@@ -0,0 +1,130 @@
# Host text interactions
Status: internal preview. This layer builds on the grant management in Draft
#3955. It adds `host.interactions` feature discovery, a generated request/result
contract, and an ordinary browser SDK workflow. Native acceptance remains a
separate parent-owned gate.
## SDK and authorization
| SDK call | Permission | Result |
| --- | --- | --- |
| `ui.quickPick(title, items)` | `ui.interact` | Approved item ID or `undefined` on cancel |
| `ui.input(title, initialValue?)` | `ui.interact` | Approved single-line text or `undefined` on cancel |
| `clipboard.readText(title)` | `clipboard.read` | Explicitly shared snapshot or `undefined` on cancel |
| `clipboard.writeText(text, title)` | `clipboard.write` | `true` after copy, `false` on cancel |
`ui.input` rejects CR and LF in initial values before any dialog is displayed,
and rejects line breaks on submission. It does not silently remove hidden
newlines. The displayed single-line value is the value submitted to the plugin.
Clipboard text continues to support multiple lines.
All calls use the canonical `ui.interact` RPC. No host DOM, native object,
router, general command ID, or arbitrary IPC channel enters the SDK. Existing
API versions remain offered. A plugin requiring the new workflow declares
`features.required: ["host.interactions"]`; old hosts reject unsupported
required features. Old plugins keep their existing APIs and permissions.
Clipboard prompts restrict new permission decisions to once. Required or
previously saved grants never skip the host action confirmation. Input and
choice dialogs need their own declaration; a `views` or `menus` declaration is
not permission to open them. Direction-specific clipboard permissions remain
separate. The normal sample uses no network, host/session, filesystem, secret,
Node, or shell capability.
## User action and text sharing
The host always displays plugin ID/version and a fixed source heading. Titles,
choices, and text render as plain text. No plugin-supplied `userGesture` flag is
accepted. The host creates an HMAC action token bound to request ID, runtime,
plugin version, owner window, action kind, phase, nonce, and expiry. It sends
that token only to the trusted host renderer. A response must come from the
same WebContents and present the current token. Tokens are consumed before the
first asynchronous action guard, so old-phase and duplicate responses cannot
commit an action.
Clipboard reading has two steps. **Preview clipboard** authorizes a host read
into a bounded in-memory snapshot. The plugin receives nothing yet. **Share
with plugin** sends exactly that snapshot after another active-identity check.
Changes to the system clipboard after preview do not replace it. Cancel, lock,
window loss, or revoke discards the snapshot. Copy shows the exact replacement
text and requires its own confirmation before a native write.
Shared text can be sensitive. The host explains that it becomes available to
the plugin and its granted capabilities, including network access. Reading
text does not approve a new network destination. This UI does not claim to
prevent a plugin from transmitting derived text through an already granted
capability. The example avoids network permission entirely.
## Availability, limits, and cleanup
The first slice supports the focused, visible, unlocked main window only.
The host renderer registers readiness before any prompt can be presented.
Other or unready windows return unavailable instead of inheriting an owner's
authority. One interaction occupies an owner window at a time; there is no
hidden prompt queue.
`HostInteractionLimits` is the shared canonical source for 4,096 UTF-8 text
bytes, 100 choices, 64 KiB serialized requests, one pending request per runtime,
eight total, ten attempts per minute per runtime, and a 60-second lifetime.
The current main-window-only adapter is narrower than the total pending bound.
Admission runs before the permission prompt. The generic capability quotas and
byte windows still apply. Text exceeding its cap is rejected without silent
truncation. RPC uses the same generated deadline.
After a native permission decision, an originally foreground request can wait
at most one second for the same owner to regain focus. The host never focuses
the window itself, presents content in the background, or postpones admission
for an originally background request. Hide, lock, revoke, runtime stop, owner
replacement and reload invalidate this admission, even during the permission
dialog. Runtime activity and foreground state are checked again after waiting.
Input cancellation, timeout, disable/update/crash/revoke, owner blur/hide/close,
main-document reload, renderer exit, and app lock invalidate pending tokens.
Same-document or subframe navigation does not permanently unregister a live
host UI. Runtime teardown clears rate accounting. The broker checks runtime
activity, owner readiness/focus, and app lock again immediately before native
read/write or disclosure. A revoked operation cannot resume from a late reply.
Commands normally retain the existing 30-second RPC deadline. A command that
declares `interactive: true` must require `commands.interactive`; the host gives
only that command a fixed 180-second total budget. This is not a global timeout
increase or a UI/clipboard permission. Each individual host interaction still
has its own 60-second deadline, including permission approval time.
When an outer command fails, times out or is cancelled, the host cancels pending
interactions and pre-prompt admissions for its captured runtime. On successful
completion it closes orphan interactions once the last concurrent command ends.
This cleanup does not reset rate/byte quotas and cannot affect a replacement
runtime. A failure conservatively closes that runtime's single shared pending
dialog even if another command is active. Late action tokens cannot commit a
clipboard effect. Cancellation does not forcibly terminate arbitrary plugin
JavaScript; ignored errors remain subject to permission, focus, rate and runtime
limits. Do not start independent interactive workflows concurrently.
Audit records contain only fixed event/action/kind fields. They contain no
prompt, choices, user text, clipboard bytes, action tokens, or hashes derived
from those values. A failed consent-audit write prevents the native action;
no successful operation is reported for that failure.
## Verification and native acceptance
The [browser fixture](../../examples/plugins/host-interactions/README.md)
exercises choice, input, copy, and clipboard transformation without a plugin
view. Tests cover the actual runtime/SDK RPC path through permission middleware,
actual host React dialog events against service-signed actions, denial, cancel,
quota before prompt, overlong text, duplicate/forged actions, wrong owners,
stale runtime, revoke during an asynchronous guard, audit failure, deadline,
lock, blur, document navigation, and renderer teardown.
Parent native acceptance uses a separately approved temporary example-plugin
session and synthetic clipboard text. It needs no real hosts, credentials,
network destination, shell, or persistent grant. Check both complete workflows,
cancel before reading, cancel after preview, cancel copy, and disable/revoke
with a pending dialog. Record the exact SHA and restore the test clipboard.
Unit tests are not evidence of native Electron acceptance.
Progress/toast, selected context and terminal sharing/drafts remain later slices.
Navigation uses the existing sandbox ViewHost and adds no content permission.
ThemePack and SFTP remain separate stages. This layer alone does not finish the
capability coverage matrix.
+5
View File
@@ -300,3 +300,8 @@ installs plugins but fails to start them outside the repository checkout.
separate `npm run test:plugin-runtime:electron` smoke launches both a real
sandboxed BrowserWindow plugin and a real utilityProcess plugin, verifies
bidirectional storage RPC, and checks the recorded runtime ownership.
Static `appearance.theme-packs` packages are an explicit non-executable admission
form: `main.data = "theme"`, theme-only contributions and no permissions,
activation events or companions. The runtime supervisor never starts them. This
exception does not admit Node code or arbitrary host access in normal Settings.
@@ -0,0 +1,70 @@
# Reconcile the late transport checkpoint
The integration owner is #3993, branch
`integration/plugin-byte-stream-handoff`. Its independently rechecked head
`6a4fb178cde73b57f78b1711a4e1f9a0f394b4fb` passed both
[test CI](https://github.com/binaricat/Netcatty/actions/runs/40365457418) and
[four-platform package CI](https://github.com/binaricat/Netcatty/actions/runs/40365457024).
The original transport owner later published #3988 at
`f1410f8bb172ef2c2069cc7d55a463f7cb127c0b`. Its common baseline with #3993 is
`c4433d4961ecf4a81bea92b0a14f47b4ec12f679`. The late commit has that baseline
and source checkpoint `4213d8e6726c44e7a479418ece3adf16ea494782` as parents.
The source checkpoint is already an ancestor of #3993.
## Keep the remaining transport changes
The late delta is not entirely superseded. An ordinary merge retains its
published history and these useful changes:
- Attach cleanup cancels a producer returned after runtime release exactly
once. The new regression fails against the old #3993 service and passes
with the late service change. The test uses a loopback fixture and a fake
stream producer. It does not enumerate real interfaces.
- Service tests cover source replacement before permission publication and
during a pending read. A router test checks one close notification for idle
peer cancellation. The installed entrypoint also checks consumer cancellation.
- CLI and SDK tests cover the public canonical byte-stream requests. Shared
manifest checks require the corresponding AI/context feature declarations.
No AI runtime, broker, adapter, or renderer implementation is reviewed here.
- Database-specific testing moves out of `electron/plugins` into the minimal
example. The opt-in test also installs its real package and checks the
SDK/RPC path and audit omission. Its live pass at `f1410f8` remains historical
evidence, not a new live pass on the current integration head.
- The example panel test checks password clearing and text-only result output.
## Resolve overlap in favor of the integrated behavior
| Overlap | Resolution |
| --- | --- |
| Marketplace source files and lockfile | Already identical to the late checkpoint |
| Own-machine address comparison | Retain #3993's IPv4 and mapped IPv6 normalization and regression |
| Cancelled buffered and in-flight credit | Retain #3993's stream lifecycle fix and regressions |
| Service test file | Combine the normalization test with all three late lifecycle/source tests |
| Installed entrypoint test | Keep retained-byte revocation coverage and add the late consumer-cancel case |
| Shared permission descriptions | Retain the newer connected AI checkpoint's descriptions |
| Assistant preparation | Retain the newer six-file staging build |
| Contract test | Retain the repaired optional-stream expectation and positive exact-resource assertion |
| Database preparation | Both sides minify the bundle. Keep the existing deterministic preparation |
| Native instructions | Keep the GUI's actual prerequisites. Its missing Docker and unsupported path to the coding container are not resolved by the late branch's local Docker recipe |
The original reports are retained in
[the historical acceptance record](byte-stream-acceptance.md). Neither their old
AI wiring status nor their environment-specific broad-test failures replace the
connected implementation and exact-head CI evidence on #3993. No main merge,
force-push, endpoint-policy relaxation, AI internal review retry, or release is
part of this reconciliation.
## Check the reconciled source
The merged transport source passed 627 registered runtime tests: 626 passed and
one native Electron test skipped. Contract/SDK/CLI tests passed 90 of 90. The
example panel passed; its opt-in database test remained skipped. The late attach
regression failed against the old #3993 service with two cancel calls instead
of one, then passed after the merge.
A subsequent example-only Rfam candidate reuses the limited browser protocol
through two fixed entrypoints. Its offline tests check the original owned
fixture database/query and the fixed public database/query, plus rejection of
an unsupported auth plugin. No general query API is exported and no host
database code is introduced. The owned MariaDB live regression remains opt-in.
+101
View File
@@ -0,0 +1,101 @@
# Market sources (unreleased)
The plugin manager has Installed, Official marketplace and Third-party marketplace
pages. Listing a plugin in the official market does not certify it or establish
that Netcatty maintains it. Remote manifests, publisher names, source names and
README content cannot create a host-issued official badge. This revision does not
issue maintainer badges. README text and images remain author content.
## Add a source
Enter a public HTTPS origin, for example `https://plugins.example.com`, or that
origin's `/v1/snapshot.json`. Names can change; identity is derived by the host
from the canonical origin. Credentials, query strings, fragments, custom ports,
IP literals and local hostnames are rejected. The initial third-party transport
requires public IPv4 DNS records, pins the checked address to the TLS connection,
and rejects private/reserved destinations. IPv6-only sources are not supported
in this revision. No cookies, login tokens, redirects or GitHub fallback are used.
Third-party sources begin empty. A failed request never substitutes the official
catalog. Each source has its own in-memory last-known-good snapshot and selection
tokens; an offline snapshot is browse-only. A process restart requires a fresh
third-party fetch. The official source remains fixed to `plugins.netcatty.app`
and retains its bundled source examples when unavailable.
## Publish a static source
Use the existing snapshot protocol in the registry project's
`docs/STATIC-DISTRIBUTION.md`. Every source
serves `/v1/snapshot.json`, pointing to hash/size-bound catalog, index and delivery
JSON under `/snapshots/<delivery-sha256>/`. All resources remain on the source's
origin under content-addressed `/assets/` paths. Whole-JSON budgets, schema,
manifest, compatibility, browser-runtime admission and archive validation still
apply. Third-party source consent is not permission consent.
Delivery v1 is unchanged and source-only. Delivery **v2** has the same fields and
strict schema as v1, with `schemaVersion: 2` and one extension: each plugin's
`artifact` can be null or a package reference:
```json
{"path":"/assets/<64-lowercase-hex-sha256>.ncpkg","sha256":"<same-sha256>","size":12345}
```
Packages are at most 32 MiB. The catalog entry must contain
`artifact: {url: "https://plugins.example.com/assets/<same-sha256>.ncpkg", sha256}`.
The index's current, non-withdrawn version must exactly equal the catalog entry
and use `distribution: "release"`. Source-only entries retain null delivery
artifacts and `distribution: "source-only"`. Avatars remain null; remote README
embeds are not loaded. README and PNG/WebP references retain the v1 bounds.
Existing official bootstrap entries are not changed or made installable.
Before every install, refresh and validate the entire snapshot again. A changed
selection, withdrawal, unavailable source, altered hash or size prevents install;
there is no fallback to another source or latest release. Packages install disabled.
## Ownership and changing source
A public plugin ID can have only one installed source. The package's host install
metadata and durable database binding record that source, including across
recovery and uninstall. Cross-source install or update is rejected, even when
version and package bytes match. Local imports are a separate source.
Private settings, view state, persistent and in-memory grants, secrets, and cloud
sidecars are namespaced by host-derived source identity. Every source, including local imports, uses an opaque encoded owner; public plugin IDs are never storage-owner aliases in sync payloads. Before each sync entry and after every awaited contribution update/reset, the host rechecks that owner against the live source and contributions. Hydration captures its owner before yielding and never remaps a public ID for a later write. KV keys are qualified
inside the existing per-plugin FK. Code, permissions and source configuration do
not participate in cloud sync. Old-source cloud settings may remain orphaned;
they cannot hydrate the same plugin ID from a different source. Generic KV still
is not cloud-synced. No host/Vault/input migration changes are made.
To change source, uninstall and explicitly select **clear this source's private
data, grants and secrets**. The host serializes shutdown, revocation, private-file
cleanup and binding reset. This is destructive and is never selected by default.
Then install from the new source and review permissions again. An uninstall that
retains data deliberately retains the binding and continues to reject other sources.
Removing a market keeps its installed plugins and data. Updates stop; optionally
disable its plugins. No other market silently takes over. Disabling a source only
stops browsing/install/update access; it does not silently revoke existing plugins.
## Validation limits
Tests use synthetic source responses and first-party packages. Passing tests is
not a sandbox certification. Native UI, real third-party hosting, network
availability and multi-device encrypted-sync acceptance must be reported
separately. No real credentials or untrusted community code are needed for tests.
## Source-owner integration
Use `PluginDatabase.getActivePlugin(context.pluginId)` to require a current host
installation, then `database.privateOwner(plugin.id)` for an opaque data partition.
`plugin.source` is host-owned `{id, kind, name, origin}`.
`getRetainedPluginSource(pluginId)` returns a retained uninstall binding or null.
`publicPluginId(owner)` resolves only an encoded owner matching the current source;
it rejects raw public IDs and stale owners. Never accept owner/source from a plugin
request or invent a `pluginId` fallback. Capture owner/source at async operation
start and revalidate before committing; follow runtime lifecycle cancellation.
Plugin database schema 7 atomically converts existing raw local private rows and
KV keys to the same encoded-owner model without deleting them. This is a one-time
conversion, not a parallel runtime compatibility path. A conflicting conversion
fails without publishing schema 7. Old raw-ID cloud entries remain orphaned and
cannot hydrate an installed plugin. Host/Vault/input schemas are unchanged.
@@ -226,3 +226,33 @@ after durable user data can exist.
- PR 9 supplies signed publisher principals and trust policy through placement;
signed identity changes force a fresh grant instead of widening an unsigned
grant silently.
## 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),
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
reads the engine again. Recent security events include at most 100 entries and
project only event, time, permission, and scope. Arbitrary database details,
operation IDs, terminal content, and credential material never cross this UI
boundary.
**Disable and revoke all grants** runs through the manager mutation queue. It
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.
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.
`PluginNetworkRequest` and `PluginNetworkResponse` now derive from the canonical
schema. `credentialLease` and `authorization` describe the existing host lease
injection path. Hosts advertise `network.credential-lease` for discovery without
changing existing API versions or disabling legacy requests. Plugins may
require that feature or handle its absence. Optional authorization defaults to
Bearer. Basic authorization accepts a bounded username. Plaintext is redeemed
only inside the host after origin permission and operation binding checks.
See the [synthetic fixture](../../examples/plugins/lease-http/README.md).
@@ -0,0 +1,44 @@
# Selected-context authority reference
`electron/plugins/context/selectedContextAuthority.cjs` implements text disclosure.
It does not issue typed Host targets or authorize edit, connect, or command actions.
The constructor is `new SelectedContextAuthority({ resolveDataOwner, clock?,
limits? })`. `resolveDataOwner(context)` returns the authenticated host-owned source
binding. Runtime identity comes from the host RPC context, never plugin arguments.
| Method | Meaning |
| --- | --- |
| `share({scope,title,version,readText,isCurrent}, context)` | Trusted host issues an opaque, expiring text handle. Returns a summary. |
| `summaries(context)` | Enumerates only this source/runtime's shared summaries. Does not call `readText`. |
| `read({handle,version}, context)` | Checks identity, expected version, current owner, lifetime, and resource currency before and after the bounded read. |
| `assertCurrent({handle,version}, context)` | Checks currency without reading content. |
| `onDidInvalidate(listener)` | Observes handle revocation. One failed listener cannot interrupt cleanup. |
| `release(handle)` | Revokes one text handle. |
| `releaseRuntime(runtimeId)` | Revokes that runtime's text handles. |
| `releaseOwner(owner)` | Revokes that exact owner binding's text handles. |
| `shutdown()` | Revokes all handles. |
The summary contains `handle`, `scope`, `title`, `version`, and `expiresAt`.
`scope: "host"` means curated host-related text. It is not a Host entity reference.
A summary, menu ID, title, or host-related text cannot become an action target.
`PluginAISourceBinding` in `electron/plugins/ai/sourceBinding.cjs` consumes the
market owner's source API. `resolve(context)` first authenticates the current
browser runtime and then returns a stable frozen object containing `runtimeId`,
`pluginId`, `pluginVersion`, `sourceKey`, and `privateOwner`. It revalidates source
binding after asynchronous authentication. The object never crosses plugin RPC.
`sourceKey` binds the source ID, kind, and origin. It is not a trust endorsement.
This helper supplies neither resource selection nor action authorization.
Resource currency remains a trusted `isCurrent()` callback. The callback binds
the selected resource, expected revision, current host window, and its lifecycle.
The authority stores the issued version separately and never accepts a later
plugin-supplied revision as authoritative.
The Host/Vault action task requires a separate typed selection authority. Such a
target binds the host-owned source identity, runtime, plugin version, window owner
and generation, exact Host ID, expected Host revision, expiry, and allowed action.
Each action needs its own grant and host confirmation. Text-read handles must not
implicitly gain edit or connect authority. Host draft review and connection
confirmation remain separate workflows owned by that task.
+74
View File
@@ -0,0 +1,74 @@
# Terminal sidebar pages
The `terminal-sidebar` view location requires `views.terminal-sidebar` and the
existing `views` permission. This adds navigation and plugin-owned content to
the existing terminal pane/split system. It is not a session-data capability,
Provider subscription, terminal history reader or command execution API.
## Host-owned navigation
An Extension pages menu in the normal terminal toolbar lists visible pages
with their plugin source. Selecting one switches the existing focused pane;
selecting an already-open tool focuses that pane. Existing split menus include
the same contributions and enforce unique tools and the eight-pane limit.
Host-generated `plugin-view:<namespaced-view-id>` keys cannot replace built-in
tools. Declared order and ID determine listing order; existing host docking,
split resizing, magnification and close controls remain authoritative.
The manager's internal Open event can select a page only when an open terminal
is already selected. Otherwise the host asks the user to select a terminal; it
does not connect or open a saved host. Launcher events target the session from
the actual host terminal toolbar and are rechecked against current selection.
These are internal host UI events, not public SDK router methods or trusted
plugin `userGesture` flags.
## Session ownership without data sharing
The renderer binds each page to the actual terminal tab/workspace and current
focused child session. It passes only `netcatty.surface: terminal-sidebar` as
the view context. Internal ownership IDs are used for host state scoping, not
returned to the page or treated as resource grants. A future selected-session
data API must establish its own live endpoint authority and permissions.
Changing terminal tabs or workspace focus closes the old native view before
the new one can receive focus. Session removal, pane close, withdrawal of a
visible contribution, disable/update/uninstall and runtime loss remove the
affected page. Unknown saved routes are pruned without opening them; restoring
a layout never grants the page or any session-data permission. State buckets
are for the plugin's own UI data, not an isolation boundary against that same
plugin's already-authorized own-state API. Ordinary KV does not become synced.
PersistentSidePanelPortal moves a stable mount between the actual pane,
magnification host and hidden parking host. There is no fallback native view
over the entire terminal layer. Native geometry follows host DOM relocation,
resize and scrolling; duplicate geometry updates are suppressed. Hidden/inert
or parked mounts cannot reveal a native view.
## Focus and security surfaces
The existing sandbox ViewHost, immutable package/protocol checks, own-command
and view-state boundaries are reused. Embedded pages start hidden. Host
dialogs and overlapping portals hide them, and main-process App Lock closes
them. Escape is host-owned and closes the plugin pane.
Native WebContents focus is reported only to its actual owner's trusted
renderer using a canonical instance-ID event. It updates the selected pane;
it does not expose an Electron object to the plugin. Revealing multiple panes
does not focus all of them: only the host-selected pane can request focus.
The main process verifies actual owner, visibility, foreground and unlocked
state before honoring that request. A plugin has no SDK method to invoke the
host focus bridge. No session or page text is added to focus/audit logs.
## Example and evidence
`com.netcatty.session-scratchpad` 0.1.0 asks only for its own `views`. It saves,
reopens and clears explicitly typed synthetic text. Package instructions and
minimal native actions are in its README. Repository tests use real split
tree, layout state, persistent portal, launcher and embedded-view components;
host tests cover owner-bound focus, hidden/locked refusal and withdrawal.
Feature and permission admission, limits, old APIs and previous Vault/command
fixes are checked on the same stack. Independent review, exact-SHA CI and
parent native QA remain separate acceptance gates recorded on the Draft PR.
ThemePack, selected context/terminal drafts and SFTP handles remain subsequent
bounded stages. Adding a sidebar page does not count as implementing them.
+159
View File
@@ -0,0 +1,159 @@
# Host color theme packs
Theme packs contribute validated light and dark host colors. They cannot inject
CSS, access host DOM, change layout, load remote resources, replace security
controls, or apply themselves. The feature is `appearance.theme-packs`.
## Install and select
Use normal Settings > Plugins to install and enable a package. In Appearance,
choose a plugin theme for the current light or dark mode, then select Preview or
Apply. Preview changes only the current window and writes no preference. Cancel,
closing Appearance, changing mode, package withdrawal, or a newer committed
revision ends that preview. Apply saves a host-owned, device-local choice and
refreshes other application windows. Restore default clears the plugin choice
for that mode and selects Snow or Midnight. Selecting a built-in swatch also
clears that mode's plugin choice. Failures remain visible and are not reported as
successful applications.
The current view environment is globally broadcast. To keep temporary previews
local, it publishes **committed** theme tokens only. Plugin pages follow an
applied theme through their existing environment subscription; they do not
receive another window's temporary preview. This does not add an owner-local
view environment API.
## Data-only package
A pure theme uses `main: { "data": "theme" }`, contributes only `themes`, and
omits permissions, activation events and companions. Its code runtime is never
started, including by the runtime supervisor's startup or explicit-start path.
Browser code plugins may also declare themes; choosing a theme does not start a
runtime or grant their functional permissions. Only enabled packages appear.
```json
{
"features": { "required": ["appearance.theme-packs"] },
"main": { "data": "theme" },
"contributes": {
"themes": [{
"id": "com.example.colors.forest",
"label": "Forest",
"variant": "light",
"entry": "forest.json"
}]
}
}
```
This fragment belongs in an otherwise complete manifest. Contribution IDs use
the package namespace. Palette paths are package-relative JSON files, included
and checked by the normal CLI/package store. Hosts lacking the required feature
reject compatibility deterministically.
```json
{ "formatVersion": 1, "base": "light", "colors": { "accent": "#166534" } }
```
Colors merge onto a fixed accessible light/dark host base. Only opaque `#RRGGBB`
values and canonical semantic keys are accepted. The 15 editable keys cover
background/foreground, card and popover pairs, secondary and muted pairs, accent,
destructive and its foreground, border and input. Primary, ring and the accent
foregrounds are derived together, matching the existing theme conversion.
Nineteen output fields are not nineteen independent controls.
Each package has at most 32 themes; each palette is at most 64 KiB; the catalog
and validated cache contain at most 256 entries. Schema and contrast checks run
before catalog publication. Text pairs and state colors require at least 4.5:1;
control borders and focus information require at least 3:1 against their
surfaces. Invalid palettes produce bounded, content-free diagnostics and cannot
be previewed or applied. Files are read from verified immutable package roots,
with bounded reads and a no-follow final file open.
## Ownership, recovery and security appearance
The host stores selections separately from plugin KV, settings sidecars and
cloud sync. A reference binds plugin ID, theme ID, exact version, archive digest
and variant. Application uses a revision check plus an active-package check just
before the atomic preference/audit transaction. An audit write failure rolls
back the selection. Audit records contain revision/action metadata, not palette
or user content.
Disable, uninstall, update, missing entries and integrity failures withdraw the
palette. A saved missing reference remains visible as unavailable while the
same-mode safe host palette is used; a changed package is not automatically
applied. Preview owner and committed revision checks prevent a late cancellation
from replacing another preview or a newer application.
Host dialogs, App Lock, grant/revoke, host details, keychain panels, host-key
prompts and the credential picker use fixed host security variables while a
plugin theme is active. The pack controls no font, icon, opacity, CSS selector,
button text or ordering. System forced-colors/high-contrast preferences use a
fixed host high-contrast fallback. Personal accent, fonts, terminal choices and
accessibility preferences retain their authority. Existing personal custom CSS
remains a user setting and is not exposed to plugins.
This first layer changes host semantic colors. Static terminal palette linkage,
custom high-contrast variants, fonts, density, icons and backgrounds remain
separate bounded follow-ups; their fields are not silently accepted here.
## Example and validation
`examples/plugins/forest-theme-pack` is a four-file data package with light and
dark Forest accents. It requests no permissions and contains no JavaScript.
The starter, Vault Scratchpad and Session Scratchpad pages now subscribe to the
existing seven public HSL tokens through `theme.js`, dispose on page hide, and
use responsive host-colored controls. No new styling RPC is needed.
Local evidence includes normal CLI validation/packing, schema/admission and
no-runtime checks; host catalog/identity/size/contrast/CAS/migration/atomic-audit
checks; picker preview/apply/cancel/reset and stale-owner/withdrawal/high-contrast
checks; and view-template initial/live environment/disposal checks. Exact-SHA
CI, independent review and parent native multi-window/security-surface acceptance
remain separate delivery gates. Local production builds do not establish them.
### First review / CI delta
The first review found that muted text was validated only against its namesake
muted surface, although host descriptions and statuses also use it on background,
card and popover surfaces. The validator now checks generic and muted text on
all neutral surfaces that reuse those tokens. The white-on-white counterexample
is rejected both by the pure validator and the host catalog. Forest light/dark
palettes remain valid.
The first exact-head test run `40317690002` failed with 15187 passing tests,
one failure and 50 skips. Its single failure was the unchanged TERM-SSHD
concurrent failure-release test expecting a particular invocation to reach OPEN
first. Both calls perform asynchronous local-file preparation. The test now
requires exactly one explicit Permission denied result, one successful result
with matching bytes, and no rejected concurrent handle; it retains concurrency
without assuming filesystem completion order. No transfer implementation changed.
The test file's original SHA256 was identical in e5bd294c, 733f44c and fdeeaf74:
`8b200d9f6a05ac5cd91fe4e0a95ea36a770d22267a6a0162b4c9fc927beb52a7`.
This is source identity and a source-based ordering diagnosis, not a claim that a
baseline runtime failure was reproduced locally. The updated test needs the new
exact-head CI result. That same first run passed the added Vite browser-import
smoke, resolving the earlier local temp-directory limitation for that head.
### Native security-color delta
Parent native acceptance at f868 verified zero-permission installation/enabling,
light/dark Preview and Cancel, Apply across main/Settings windows, Restore
default, restart persistence, and Disable fallback with the unavailable notice.
The isolated schema-3 profile was not touched. These core results remain valid.
Native QA also found Forest-colored inner New Host cards/inputs/Save and some
grant-panel text inside an otherwise fixed security container. The cause was
Tailwind's derived `--color-*` aliases: changing only raw semantic tokens at the
boundary does not recompute aliases already resolved on an ancestor. The same
existing security rule now fixes both raw tokens and those color aliases. The
selector and protected regions are unchanged; ordinary application cards remain
theme-controlled. No host, credential or grant operation changed.
The regression renders the actual HostDetailsPanel (including General, Address,
Credentials cards, real Inputs and Save), plus the real grant-panel root. It
compiles the production Tailwind source, verifies which aliases those utilities
consume, checks the fixed declarations, and verifies an ordinary card is outside
the security selector. This is component/compiled-CSS coverage, not a claim of
native computed-color acceptance. The parent's native color delta remains the
final visual check. A local Chromium probe aborted because its SUID sandbox was
not configured; sandboxing was not disabled to retry.
+80
View File
@@ -0,0 +1,80 @@
# Vault extension pages
`views.vault-pages` adds the `vault-page` view location. A package must require
the feature and declare the existing `views` permission. Hosts without the
feature reject the package deterministically. Existing view locations and API
versions keep their behavior; there is no public plugin router or new Vault
read/write permission.
## User workflow
Visible, enabled contributions appear in Vault's **Extensions** group. The
host generates a namespaced route from the declared view ID, orders by declared
order and ID, localizes the label, and resolves the declared icon through the
existing safe icon pipeline. Built-in navigation and security entry points
remain host-owned. Collapsed navigation retains source-labelled accessible
buttons.
Selecting a page mounts its existing sandbox WebContentsView in the Vault main
content area. It does not create a top tab, use an aside overlay, open a
connection, or share host/session/Vault records. The trusted host context
contains only `netcatty.surface: vault-page`. The manager's existing Open action
can select the same page through an internal host event; this is not exposed
as a generic SDK navigation capability.
The `vault-scratchpad` browser example lets the user type synthetic text, save
it in the plugin's own view state, return to Hosts, reopen, and clear it. It
does not create a Vault Note or enable automatic cloud sync. The fixture asks
for only `views`; granting it does not approve another plugin or data access.
## Ownership and lifecycle
The host owns the route and validates the selected contribution against the
current visible catalog. Missing IDs, disable/uninstall/update, a false `when`,
failed activation or a host close return to Hosts. Focus restoration checks
the current section and active tab so an old fallback cannot steal focus after
a newer selection. Catalog listing does not activate a plugin.
The embedded host reuses PluginViewHost, its ephemeral session, CSP, no-Node
preload, protocol validation, own-command checks, and bounded own view state.
No DOM or Electron object enters the SDK. A window-route-and-slot scope separates
this page's state from other view placements; actual BrowserWindow ownership is
validated separately and does not rely on that state key. This initial embedded slice
closes on navigation away even if `retainContextWhenHidden` is declared.
Each opening reserves an instance ID owned by its actual BrowserWindow before
asynchronous activation. Close can cancel that reservation before native
construction. Owner closure, shutdown and renderer cleanup prevent a late open
from attaching; late responses are also closed by the renderer lifecycle
controller. Current plugin version/runtime and `when` are checked before
attachment and on subsequent contribution changes. Existing owner checks apply
to bounds, visibility, close and view-state IPC.
Host reload/renderer exit and a view renderer crash close the associated native
page. Plain Escape in a Vault page is handled by the host and returns to Hosts;
it does not dispatch a plugin command or expose a general navigation API.
Native child views sit above React portals. A Vault page starts hidden; the
trusted renderer reveals it only after checking the mount and host dialogs,
menus, popovers, overlay panels and lock surfaces. Overlapping host surfaces
hide the page. App Lock additionally closes all hosted plugin views and
cancels pending opens in the main process; showing a view while locked fails.
The shared manifest bound remains 128 views per package. Only one Vault page is
mounted per owner; listing and routing do not create a background view queue.
View-state JSON size limits and existing runtime/permission quotas remain in
force. No page text or state is added to audit logs by this adapter.
## Validation status
Source tests cover normal catalog selection, the actual embedded content slot,
host-modal occlusion, synchronous tab-away cleanup, withdrawn and invalid
routes, internal host selection, delayed open cancellation, feature/permission
admission, host ownership, `when` withdrawal and App Lock. Production build,
aggregate host tests and precise-SHA CI are reported on the accompanying Draft
PR. Parent native acceptance must use the packed browser example through
normal Settings and record its exact source SHA/package hash. Unit tests do
not substitute for that native acceptance.
Terminal sidebar pages and selected-session DTOs remain separate stages. This
page location does not make those capabilities available.
+5
View File
@@ -9,7 +9,11 @@ const PERMISSION_DESCRIPTIONS = {
"settings.write": [false, "Change plugin settings", "Update this plugin's preferences.", "修改插件设置", "修改此插件自己的设置。"],
commands: [false, "Plugin commands", "Register and run this plugin's own commands.", "插件命令", "注册和执行此插件自己的命令。"],
menus: [false, "Menu entries", "Add this plugin's actions to host menus.", "菜单入口", "将此插件的操作加入应用菜单。"],
"ui.interact": [false, "Host prompts", "Ask the host to show bounded input and choice dialogs.", "宿主交互", "请求宿主显示有界的输入和选择对话框。"],
views: [false, "Plugin panels", "Display plugin-owned panels inside the app.", "插件面板", "在应用中显示插件自己的面板。"],
"ai.invoke": [true, "Use configured AI model", "Send plugin prompts and separately approved selected text to the configured model. Provider charges may apply. Does not grant tools or host actions.", "使用已配置的 AI 模型", "向已配置的模型发送插件提示和另行批准的所选文本。模型提供商可能收费。此权限不授予工具或宿主操作权限。"],
"context.selected.list": [true, "Selected context summaries", "Ask you to share summaries of selected workspace, tab, session, host, or Note references. Does not read their text.", "所选上下文摘要", "请求你分享所选工作区、标签页、会话、主机或笔记引用的摘要。此权限不会读取其文本。"],
"context.selected.read": [true, "Read selected context", "Read text from references you explicitly select and approve. It may contain private data.", "读取所选上下文", "读取你明确选择并批准的引用中的文本,其中可能包含隐私信息。"],
"clipboard.read": [true, "Read clipboard", "Read copied text, which may contain passwords or other sensitive data.", "读取剪贴板", "读取复制的文本,其中可能包含密码或其他敏感数据。"],
"clipboard.write": [true, "Write clipboard", "Replace clipboard content that you may paste elsewhere.", "写入剪贴板", "替换剪贴板内容,可能影响你随后粘贴的数据。"],
"terminal.metadata": [true, "Terminal metadata", "Read terminal/session details that may identify hosts and activity.", "终端元数据", "读取终端和会话信息,可能暴露主机及操作活动。"],
@@ -25,6 +29,7 @@ const PERMISSION_DESCRIPTIONS = {
"sftp.read": [true, "Read remote files", "Read remote file content through supported host APIs.", "读取远程文件", "通过支持的宿主 API 读取远程文件内容。"],
"sftp.write": [true, "Change remote files", "Write or change remote files through supported host APIs.", "修改远程文件", "通过支持的宿主 API 写入或修改远程文件。"],
network: [true, "Network requests", "Contact approved origins. Data sent to them leaves this device.", "网络请求", "访问已批准的网络来源。发送的数据会离开此设备。"],
"network.stream": [true, "TCP and TLS connections", "Send and receive data over TCP or TLS at approved endpoints. Data may leave this device.", "TCP 和 TLS 连接", "通过 TCP 或 TLS 与已批准的端点收发数据。数据可能离开此设备。"],
"filesystem.read": [true, "Read local files", "Read approved local paths, which may contain sensitive files.", "读取本地文件", "读取已批准的本地路径,其中可能包含敏感文件。"],
"filesystem.write": [true, "Change local files", "Write to approved local paths; existing content may be changed.", "修改本地文件", "写入已批准的本地路径,可能改变现有文件内容。"],
secrets: [true, "Plugin secrets", "Store and use this plugin's secrets through OS-backed storage and controlled leases.", "插件秘密", "通过系统密钥存储与受控租约保存和使用此插件的秘密。"],
+13 -4
View File
@@ -3,6 +3,14 @@
// Shared by host placement, release admission, and the installed-package UI.
// A Node entrypoint does not change default placement, but is not admitted to
// the normal browser-only product path.
function isStaticThemePack(manifest) {
return manifest?.main?.data === "theme" && Object.keys(manifest.main).length === 1
&& Array.isArray(manifest.contributes?.themes) && manifest.contributes.themes.length > 0
&& Object.keys(manifest.contributes).every((key) => key === "themes")
&& !Object.keys(manifest.permissions ?? {}).length && !manifest.activationEvents?.length
&& !manifest.companionExecutables?.length
&& manifest.features?.required?.includes("appearance.theme-packs");
}
function describePluginRuntime(manifest) {
const providers = manifest?.contributes?.providers ?? [];
const permissions = [...(manifest?.permissions?.required ?? []), ...(manifest?.permissions?.optional ?? [])];
@@ -18,19 +26,20 @@ function describePluginRuntime(manifest) {
const hasNode = Boolean(manifest?.main?.node);
const defaultRuntimeKind = hasBrowser && !hasCompanions && !hasInterceptors ? "browser" : "utility";
return {
defaultRuntimeKind,
defaultRuntimeKind: isStaticThemePack(manifest) ? "data" : defaultRuntimeKind,
dataOnly: isStaticThemePack(manifest),
hasNode,
declaresAdvanced,
browserOnly: hasBrowser && !hasNode && !hasCompanions && !hasInterceptors && !declaresAdvanced,
browserOnly: !manifest?.main?.data && hasBrowser && !hasNode && !hasCompanions && !hasInterceptors && !declaresAdvanced,
};
}
function assertBrowserOnlyPlugin(manifest) {
if (!describePluginRuntime(manifest).browserOnly) {
if (!describePluginRuntime(manifest).browserOnly && !isStaticThemePack(manifest)) {
const error = new Error("This package requires advanced developer mode; normal plugin management accepts browser-only packages");
error.code = "PLUGIN_UNSUPPORTED_RUNTIME";
throw error;
}
}
module.exports = { describePluginRuntime, assertBrowserOnlyPlugin };
module.exports = { describePluginRuntime, assertBrowserOnlyPlugin, isStaticThemePack };
+6
View File
@@ -19,3 +19,9 @@ test('normal release admission rejects advanced declarations and privileged prov
assert.equal(describePluginRuntime(manifest).defaultRuntimeKind, 'utility');
assert.throws(() => assertBrowserOnlyPlugin(manifest), /developer mode/);
});
test('normal admission accepts only explicit non-executable theme-only data packages',()=>{
const manifest={main:{data:'theme'},features:{required:['appearance.theme-packs']},contributes:{themes:[{id:'com.example.theme.light'}]}};
assert.doesNotThrow(()=>assertBrowserOnlyPlugin(manifest));assert.equal(describePluginRuntime(manifest).dataOnly,true);assert.equal(describePluginRuntime(manifest).defaultRuntimeKind,'data');
for(const change of [{main:{data:'theme',browser:'index.js'}},{permissions:{required:['network']}},{activationEvents:['onStartupFinished']},{contributes:{themes:[],commands:[]}}])assert.throws(()=>assertBrowserOnlyPlugin({...manifest,...change}),/developer mode/);
});
+145
View File
@@ -0,0 +1,145 @@
"use strict";
// Pure color projection. Package/wire schema validation belongs to the host.
const LIGHT = Object.freeze({
background: "#ffffff",
foreground: "#161a1d",
card: "#ffffff",
cardForeground: "#161a1d",
popover: "#ffffff",
popoverForeground: "#161a1d",
secondary: "#e5e7eb",
secondaryForeground: "#161a1d",
muted: "#f0f2f4",
mutedForeground: "#374151",
accent: "#1d4ed8",
destructive: "#b91c1c",
destructiveForeground: "#ffffff",
border: "#6b7280",
input: "#6b7280",
});
const DARK = Object.freeze({
background: "#111827",
foreground: "#f9fafb",
card: "#1f2937",
cardForeground: "#f9fafb",
popover: "#1f2937",
popoverForeground: "#f9fafb",
secondary: "#374151",
secondaryForeground: "#ffffff",
muted: "#273244",
mutedForeground: "#e5e7eb",
accent: "#93c5fd",
destructive: "#fca5a5",
destructiveForeground: "#111827",
border: "#9ca3af",
input: "#9ca3af",
});
function rgb(hex) {
return [1, 3, 5].map(
(offset) => parseInt(hex.slice(offset, offset + 2), 16) / 255,
);
}
function luminance(hex) {
return rgb(hex)
.map((v) => (v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4))
.reduce((s, v, i) => s + v * [0.2126, 0.7152, 0.0722][i], 0);
}
function contrast(a, b) {
const x = luminance(a),
y = luminance(b);
return (Math.max(x, y) + 0.05) / (Math.min(x, y) + 0.05);
}
function toHsl(hex) {
const [r, g, b] = rgb(hex),
max = Math.max(r, g, b),
min = Math.min(r, g, b),
delta = max - min,
l = (max + min) / 2;
let h = 0;
if (delta)
h =
(max === r
? ((g - b) / delta + 6) % 6
: max === g
? (b - r) / delta + 2
: (r - g) / delta + 4) * 60;
const s = delta ? delta / (1 - Math.abs(2 * l - 1)) : 0;
return `${h.toFixed(3)} ${(s * 100).toFixed(3)}% ${(l * 100).toFixed(3)}%`;
}
// A small margin covers rounding when hex colors become HSL CSS components.
function resolveThemePalette(palette) {
const colors = {
...(palette.base === "dark" ? DARK : LIGHT),
...palette.colors,
};
for (const value of Object.values(colors))
if (!/^#[0-9a-f]{6}$/i.test(value)) throw new Error("Invalid theme color");
const pairs = [
["foreground", "background"],
["cardForeground", "card"],
["popoverForeground", "popover"],
["secondaryForeground", "secondary"],
["mutedForeground", "muted"],
["destructiveForeground", "destructive"],
];
for (const [a, b] of pairs)
if (contrast(colors[a], colors[b]) < 4.51)
throw new Error("Theme text contrast is below 4.5:1");
// Host labels and descriptions reuse these text tokens across neutral
// surfaces; they do not always sit on their namesake background token.
for (const surface of ["background", "card", "popover", "secondary", "muted"])
for (const key of ["foreground", "mutedForeground"])
if (contrast(colors[key], colors[surface]) < 4.51)
throw new Error("Theme neutral-surface text contrast is below 4.5:1");
for (const surface of ["background", "card", "popover"]) {
for (const key of ["accent", "destructive"])
if (contrast(colors[key], colors[surface]) < 4.51)
throw new Error("Theme state contrast is below 4.5:1");
for (const key of ["border", "input"])
if (contrast(colors[key], colors[surface]) < 3.01)
throw new Error("Theme control contrast is below 3:1");
}
const foreground =
contrast(colors.accent, "#000000") >= contrast(colors.accent, "#ffffff")
? "#000000"
: "#ffffff";
const tokens = Object.fromEntries(
Object.entries(colors).map(([k, v]) => [k, toHsl(v)]),
);
return Object.freeze({
...tokens,
primary: tokens.accent,
primaryForeground: toHsl(foreground),
accentForeground: toHsl(foreground),
ring: tokens.accent,
});
}
function securityThemeTokens(mode, highContrast = false) {
const colors = highContrast
? mode === "dark"
? {
background: "#000000",
card: "#000000",
popover: "#000000",
foreground: "#ffffff",
cardForeground: "#ffffff",
popoverForeground: "#ffffff",
accent: "#ffff00",
border: "#ffffff",
input: "#ffffff",
}
: {
background: "#ffffff",
card: "#ffffff",
popover: "#ffffff",
foreground: "#000000",
cardForeground: "#000000",
popoverForeground: "#000000",
accent: "#000080",
border: "#000000",
input: "#000000",
}
: {};
return resolveThemePalette({ base: mode, colors });
}
module.exports = { resolveThemePalette, securityThemeTokens, contrast, toHsl };
+33
View File
@@ -0,0 +1,33 @@
const assert = require("node:assert/strict");
const test = require("node:test");
const {
resolveThemePalette,
securityThemeTokens,
contrast,
} = require("./pluginThemePack.cjs");
test("opaque accessible palettes derive coupled accent tokens and fixed high contrast fallback", () => {
for (const mode of ["light", "dark"]) {
const tokens = resolveThemePalette({ base: mode, colors: {} });
assert.equal(tokens.primary, tokens.accent);
assert.equal(tokens.ring, tokens.accent);
assert.equal(tokens.primaryForeground, tokens.accentForeground);
assert.ok(securityThemeTokens(mode, true));
}
assert.ok(contrast("#000000", "#ffffff") > 20);
assert.throws(
() =>
resolveThemePalette({ base: "light", colors: { foreground: "#ffffff" } }),
/contrast/,
);
});
test("generic and muted text stay readable on the neutral surfaces that use them", () => {
for (const colors of [
{ muted: "#000000", mutedForeground: "#ffffff" },
{ card: "#000000", cardForeground: "#ffffff" },
{ popover: "#000000", popoverForeground: "#ffffff" },
{ secondary: "#000000", secondaryForeground: "#ffffff" },
]) {
assert.throws(() => resolveThemePalette({ base: "light", colors }), /contrast/);
}
});
+7 -1
View File
@@ -1,4 +1,4 @@
export type SidePanelTool =
export type BuiltinSidePanelTool =
| 'sftp'
| 'browser'
| 'scripts'
@@ -8,6 +8,12 @@ export type SidePanelTool =
| 'system'
| 'notes';
export type SidePanelTool = BuiltinSidePanelTool | `plugin-view:${string}`;
export function isPluginSidePanelTool(value: unknown): value is `plugin-view:${string}` {
return typeof value === 'string' && value.length <= 256 && /^plugin-view:[A-Za-z0-9_.-]+$/.test(value);
}
export type SidePanelSplitDirection = 'horizontal' | 'vertical';
export type SidePanelPaneNode = {
+15
View File
@@ -90,6 +90,21 @@ test('sanitize accepts a valid saved preset and rejects malformed data', () => {
);
});
test('plugin panes keep split uniqueness and limits; saved routes grant no availability and can be pruned', () => {
const tool = 'plugin-view:com.example.plugin.page' as const;
let layout = createSidePanelLayout('notes', 'notes');
layout = splitSidePanelPane(layout, 'notes', tool, 'vertical', { paneId: 'plugin', splitId: 'root' }, 600);
const unique = splitSidePanelPane(layout, 'notes', tool, 'vertical', { paneId: 'duplicate', splitId: 'unused' }, 600);
assert.equal(collectSidePanelPanes(unique.root).length, 2);
assert.equal(getFocusedSidePanelPane(unique).tool, tool);
assert.ok(createWorkspaceLayoutPreset(layout));
assert.equal(withoutUnavailableSidePanelTools(layout, (item) => item === 'notes')?.root.type, 'pane');
for (let i = 0; i < 10; i++) layout = splitSidePanelPane(layout, getFocusedSidePanelPane(layout).id,
`plugin-view:com.example.plugin.page${i}`, 'horizontal', { paneId: `p${i}`, splitId: `s${i}` }, 600);
assert.equal(collectSidePanelPanes(layout.root).length, 8);
assert.equal(sanitizeWorkspaceLayoutPreset({ version: 1, layout: { root: { id: 'invalid', type: 'pane', tool: 'plugin-view:../outside' }, focusedPaneId: 'invalid' } }), null);
});
test('sanitize rejects duplicate node ids', () => {
assert.equal(
sanitizeWorkspaceLayoutPreset({
+2 -1
View File
@@ -3,6 +3,7 @@ import {
closeSidePanelPane,
collectSidePanelPanes,
getFocusedSidePanelPane,
isPluginSidePanelTool,
type SidePanelLayout,
type SidePanelLayoutNode,
type SidePanelTool,
@@ -32,7 +33,7 @@ const SIDE_PANEL_TOOLS: ReadonlySet<SidePanelTool> = new Set([
]);
function isSidePanelTool(value: unknown): value is SidePanelTool {
return typeof value === 'string' && SIDE_PANEL_TOOLS.has(value as SidePanelTool);
return typeof value === 'string' && (SIDE_PANEL_TOOLS.has(value as SidePanelTool) || isPluginSidePanelTool(value));
}
function sanitizeSidePanelLayoutNode(
+1
View File
@@ -70,6 +70,7 @@ module.exports = {
'domain/terminalPromptSecurity.shared.cjs',
'domain/pluginRuntimePolicy.cjs',
'domain/pluginPermissionDescriptions.cjs',
'domain/pluginThemePack.cjs',
// Main-process SSH, SFTP, and port-forward bridges share this matcher.
// TypeScript under domain/ is excluded below, so the .cjs must be listed.
'domain/systemManager/containerRuntime.shared.cjs',
+31 -1
View File
@@ -241,6 +241,7 @@ let registeredVaultAgentBridge = null;
// Active streaming requests (for cancellation)
const activeStreams = new Map();
const pluginModelStreamOwners = new Map();
// ── Provider registry (synced from renderer, keys stay encrypted) ──
const ENC_PREFIX = "enc:v1:";
@@ -613,7 +614,11 @@ async function streamRequest(url, options, event, requestId, skipTLS) {
const parsedUrl = new URL(url);
// Register cancellation before any await so Stop during PAC/proxy lookup works.
const controller = new AbortController();
const { idleTimeoutMs, totalTimeoutMs } = resolveAIStreamTimeouts(options);
const pluginRuntimeId = /^plugin-model_([A-Za-z0-9-]{1,128})_/.exec(requestId)?.[1];
const { idleTimeoutMs, totalTimeoutMs } = pluginRuntimeId
? { idleTimeoutMs: Math.min(15_000, Math.max(1, Number(options.idleTimeoutMs) || 15_000)),
totalTimeoutMs: Math.min(60_000, Math.max(1, Number(options.totalTimeoutMs) || 60_000)) }
: resolveAIStreamTimeouts(options);
const maxErrorBodyBytes = Math.max(1, Number(options.maxErrorBodyBytes) || 64 * 1024);
let lifecycleFinished = false;
let idleTimer;
@@ -623,6 +628,7 @@ async function streamRequest(url, options, event, requestId, skipTLS) {
clearTimeout(totalTimer);
clearTimeout(idleTimer);
activeStreams.delete(requestId);
pluginModelStreamOwners.delete(requestId);
};
const noteStreamActivity = () => {
if (lifecycleFinished) return;
@@ -636,6 +642,7 @@ async function streamRequest(url, options, event, requestId, skipTLS) {
}, totalTimeoutMs);
noteStreamActivity();
activeStreams.set(requestId, controller);
if (pluginRuntimeId) pluginModelStreamOwners.set(requestId, { sender: event.sender, runtimeId: pluginRuntimeId });
if (controller.signal.aborted) {
finishLifecycle();
throw controller.signal.reason instanceof Error ? controller.signal.reason : createAbortError();
@@ -782,10 +789,19 @@ async function streamRequest(url, options, event, requestId, skipTLS) {
settleResolve({ statusCode, statusText });
let buffer = "";
let pluginResponseBytes = 0;
const MAX_BUFFER_SIZE = 10 * 1024 * 1024; // 10MB safety limit
res.on("data", (chunk) => {
if (streamFinished) return;
noteStreamActivity();
if (pluginRuntimeId) {
pluginResponseBytes += Buffer.byteLength(chunk);
if (pluginResponseBytes > 131_072) {
failStream(new Error("Plugin model stream byte budget exceeded"));
return;
}
}
const previousBufferLength = buffer.length;
buffer += chunk.toString();
// Guard against unbounded buffer growth
@@ -1005,12 +1021,25 @@ function registerHandlers(ipcMain) {
}
}
// This entry point is main-process only. Plugin views cannot choose a sender or
// access it through IPC. Internal Agent streams have no plugin request tag.
function cancelPluginAIStreamsForSender(sender, runtimeId) {
let cancelled = 0;
for (const [requestId, owner] of [...pluginModelStreamOwners]) {
if (owner.sender !== sender || (runtimeId !== undefined && owner.runtimeId !== runtimeId)) continue;
activeStreams.get(requestId)?.abort(createAbortError());
cancelled++;
}
return cancelled;
}
// Abort active streams and child processes on shutdown
function cleanup() {
for (const [id, controller] of activeStreams) {
try { controller.abort(); } catch {}
}
activeStreams.clear();
pluginModelStreamOwners.clear();
// Abort active SDK agent streams (set by registerSdkStreamHandlers on ctx).
if (registeredContext && registeredContext.sdkActiveStreams) {
@@ -1056,6 +1085,7 @@ module.exports = {
buildExternalAgentSystemContext,
buildExternalAgentContextualPrompt,
_streamRequestForTests: streamRequest,
cancelPluginAIStreamsForSender,
_resolveAIStreamTimeoutsForTests: resolveAIStreamTimeouts,
_getActiveStreamCountForTests: () => activeStreams.size,
DEFAULT_AI_STREAM_TOTAL_TIMEOUT_MS,
@@ -311,7 +311,7 @@ test("TERM-SSHD queued cancellation releases its turn without closing the owner
assert.equal(transferBridge._getSessionFastDownloadWaiterCountForTests(fixture.sftp), 0);
});
test("TERM-SSHD failed transfer releases the physical channel for the next alias", async (t) => {
test("TERM-SSHD failed transfer releases the physical channel for the next alias", { timeout: 5_000 }, async (t) => {
const fixture = await createSequentialTransferFixture(t);
const originalOpen = fixture.sftp.open;
let rejectFirst = true;
@@ -321,10 +321,16 @@ test("TERM-SSHD failed transfer releases the physical channel for the next alias
args.at(-1)(Object.assign(new Error("Permission denied"), { code: 3 }));
} else originalOpen(...args);
};
const [first, next] = await Promise.all([fixture.start("download"), fixture.start("download", "alias")]);
assert.match(first.result.error || "", /Permission denied/);
assert.equal(next.result.error, undefined);
assert.deepEqual(await fs.readFile(next.targetPath), fixture.payload);
const transfers = await Promise.all([fixture.start("download"), fixture.start("download", "alias")]);
// Both calls await local file preparation before acquiring the shared channel.
// Either invocation may reach the injected first OPEN failure; retain the
// concurrent release check without assuming filesystem completion order.
const failed = transfers.filter((transfer) => transfer.result.error !== undefined);
const succeeded = transfers.filter((transfer) => transfer.result.error === undefined);
assert.equal(failed.length, 1);
assert.equal(succeeded.length, 1);
assert.match(failed[0].result.error, /Permission denied/);
assert.deepEqual(await fs.readFile(succeeded[0].targetPath), fixture.payload);
assert.equal(fixture.rejectedOpens(), 0);
});
+8
View File
@@ -165,6 +165,9 @@ function createBridgeRegistrar(context) {
app,
electron: electronModule,
safeStorage,
getInteractionWindow: () => win,
isAppLocked: () => getAppLockController?.()?.getRuntimeState?.()?.locked !== false,
subscribeAppLock: (listener) => getAppLockController?.()?.subscribe?.(listener),
requestPermissionDecision: createNativePermissionDecisionProvider({
getLocale: () => getWindowManager().getCurrentLanguage?.() ?? "en",
dialog: electronModule.dialog,
@@ -217,6 +220,10 @@ function createBridgeRegistrar(context) {
});
registerPluginBridge(ipcMain, {
manager: pluginHostService?.manager,
interactionService: pluginHostService?.interactionService,
permissionEngine: pluginHostService?.permissionEngine,
runtimeSupervisor: pluginHostService?.runtimeSupervisor,
database: pluginHostService?.database,
catalogService: pluginHostService?.catalogService,
contributionService: pluginHostService?.contributionService,
terminalProviderService: pluginHostService?.terminalProviderService,
@@ -237,6 +244,7 @@ function createBridgeRegistrar(context) {
},
resolveBrandIcon: (pluginId) => pluginHostService?.contributionIconService?.resolveBrand(pluginId),
resolveContributionIcon: (payload) => pluginHostService?.contributionIconService?.resolve(payload),
themeService: pluginHostService?.themeService,
viewHost: pluginHostService?.viewHost,
env: process.env,
isTrustedSender: createTrustedPluginBridgeSender({ devServerUrl: effectiveDevServerUrl }),
@@ -0,0 +1,73 @@
"use strict";
const assert = require("node:assert/strict");
const test = require("node:test");
const fs = require("node:fs/promises");
const path = require("node:path");
const os = require("node:os");
const schema = require("../generated/plugin-contract.schema.json");
if (!schema.$defs.PluginAIChatResult) {
test("assistant installed runtime requires the shared canonical AI/context contract", { skip: "Run on the parent-coordinated integration head" }, () => {});
} else {
test("installed assistant uses canonical SDK/RPC, independent content grants, reviewed version and revocation", { timeout: 15_000 }, async (t) => {
const { createPluginHostService } = require("../hostService.cjs");
const { inProcessBrowserRuntime } = require("../fixtures/inProcessBrowserRuntime.cjs");
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 = [];
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: {},
pluginAIHostAdapter: { assertActive: (context) => context.assertActive(),
selectModel: async () => ({ label: "Synthetic fixture", isCurrent: () => true, invoke: async () => {
invoked++; return (async function* () { yield { kind: "text", text: "Synthetic summary" }; yield { kind: "usage", inputTokens: 12, outputTokens: 6, totalTokens: 18 }; })();
} }),
listSelected: async () => [{ key: "synthetic-note", scope: "note", title: "Synthetic retry plan", version: "v1",
readText: () => "Synthetic retry plan: add a retry button", isCurrent: () => versionCurrent }],
reviewText: async () => reviewed,
},
requestPermissionDecision: async (request) => {
decisions.push(request);
if ((request.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" };
},
});
service.runtimeSupervisor.runtimeFactories.browser = inProcessBrowserRuntime;
await service.manager.initialize();
const sample = path.resolve(__dirname, "../../../examples/plugins/assistant");
// Use an explicit six-file fixture snapshot, not source/build tooling.
const staging = path.join(root, "package"); await fs.mkdir(path.join(staging, "dist"), { recursive: true });
for (const file of ["netcatty.plugin.json", "panel.html", "panel.css", "panel.js", "README.md"]) await fs.copyFile(path.join(sample, file), path.join(staging, file));
await fs.copyFile(path.join(sample, "src/index.js"), path.join(staging, "dist/index.js"));
const { buildPluginPackage, validatePluginPackage } = await import("@netcatty/plugin-cli");
const archive = path.join(root, "assistant.ncpkg"); const built = await buildPluginPackage(staging, archive);
assert.equal(built.fileCount, 6); assert.equal((await validatePluginPackage(archive)).manifest.id, "com.netcatty.assistant");
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;
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 }] };
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;
const turn = await run("send", input);
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, 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:")));
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 }] }));
await service.manager.setEnabled(id, false); assert.equal(service.aiContextService.broker.chats.size, 0);
});
}
+175
View File
@@ -0,0 +1,175 @@
"use strict";
const { PluginRpcError, RPC_ERRORS } = require("../rpcRouter.cjs");
const { createDefinitionValidator } = require("../contractValidator.cjs");
const { ModelInvocationBroker } = require("./modelInvocationBroker.cjs");
const { SelectedContextAuthority } = require("../context/selectedContextAuthority.cjs");
const { PluginAISourceBinding } = require("./sourceBinding.cjs");
const fail = (code, message) => new PluginRpcError(RPC_ERRORS[code], message);
const FEATURES = Object.freeze(["ai.model-invocation", "context.selected-read"]);
const ROUTES = Object.freeze([
["ai.openChat", "PluginAIEmptyParams", "PluginAIChatResult", "ai.invoke", "open"],
["ai.startTurn", "PluginAIStartTurnParams", "PluginAITurnResult", "ai.invoke", "start"],
["ai.readTurn", "PluginAITurnRef", "PluginAIStreamEvent", "ai.invoke", "read"],
["ai.stopTurn", "PluginAITurnRef", "PluginAIBooleanResult", "ai.invoke", "stop"],
["ai.closeChat", "PluginAIChatRef", "PluginAIBooleanResult", "ai.invoke", "close"],
["context.selected.list", "PluginAIEmptyParams", "PluginSelectedContextListResult", "context.selected.list", "list"],
["context.selected.read", "PluginSelectedContextRef", "PluginSelectedContextContent", "context.selected.read", "readSelected"],
]);
class PluginAIContextService {
constructor(options) {
this.host = options.hostAdapter;
for (const method of ["selectModel", "listSelected", "reviewText", "assertActive"]) {
if (typeof this.host?.[method] !== "function") throw new TypeError(`AI host adapter ${method} required`);
}
this.source = new PluginAISourceBinding(options);
this.permissionEngine = options.permissionEngine;
this.resolveOwner = (context) => this.source.resolve(context);
this.selected = new SelectedContextAuthority({ resolveDataOwner: this.resolveOwner });
this.runtimeContexts = new Map();
this.registrations = [];
this.broker = new ModelInvocationBroker({ resolveDataOwner: this.resolveOwner,
selectModel: async (context) => {
await this.#guard(context);
const model = await this.host.selectModel(context);
await this.#guard(context);
return model;
},
contexts: {
read: (reference, context) => this.readSelected(reference, context),
assertCurrent: (reference, context) => this.selected.assertCurrent(reference, context),
onDidInvalidate: (listener) => this.selected.onDidInvalidate(listener),
releaseRuntime: (runtimeId) => this.selected.releaseRuntime(runtimeId),
releaseOwner: (owner) => this.selected.releaseOwner(owner),
},
});
this.revocation = this.permissionEngine.onDidRevoke((event) => this.releasePlugin(event.pluginId));
this.unsubscribeHost = this.host.onDidInvalidate?.(() => this.releaseAll());
}
async #guard(context) {
try {
const owner = await this.resolveOwner(context);
await this.host.assertActive(context);
if (await this.resolveOwner(context) !== owner) throw fail("permissionDenied", "Plugin source changed");
return owner;
} catch (error) {
const binding = this.source.bindings.get(context.runtimeId);
if (!context.signal?.aborted && binding?.pluginId === context.pluginId && binding.pluginVersion === context.pluginVersion) this.releaseRuntime(context.runtimeId);
throw error;
}
}
#authorization(permission, params) {
const resource = permission === "context.selected.read" ? `selected:${params.handle}:${params.version}` : "*";
return { permission, resources: [resource],
reason: permission === "ai.invoke" ? "Use the model selected in Netcatty without tools"
: permission === "context.selected.list" ? "List summaries of the current selected context"
: "Review and share this selected context version",
...(permission === "context.selected.read" ? { allowedScopes: ["once"] } : {}),
};
}
registerRpcCapabilities(registry) {
if (this.registrations.length) throw new Error("AI/context routes are already registered");
// Canonical validators are created only when transport integration registers
// this service. There is no second plugin wire schema in these modules.
const prepared = ROUTES.map(([method, paramsType, resultType, permission, operation]) => ({
method, permission, operation,
validateParams: createDefinitionValidator(paramsType),
validateResult: createDefinitionValidator(resultType),
}));
for (const route of prepared) {
this.registrations.push(registry.registerRequest(route.method, async (params, context) => {
await this.#guard(context);
const result = await this[route.operation](params, context);
await this.#guard(context);
if (!route.validateResult(result)) throw fail("internal", "AI/context response is invalid");
return result;
}, {
metadata: { capability: "plugin-ai-context", mutating: false, permission: route.permission },
validateParams: (params) => {
if (!route.validateParams(params)) throw fail("invalidArgument", "AI/context request is invalid");
return Object.freeze(structuredClone(params));
},
authorization: async (params, context) => {
await this.#guard(context);
if (route.operation === "readSelected") await this.selected.assertCurrent(params, context);
return this.#authorization(route.permission, params);
},
}));
}
}
open(_params, context) { return this.broker.open(context); }
start(params, context) { return this.broker.start(params, context); }
read({ turnHandle }, context) { return this.broker.read(turnHandle, context); }
async stop({ turnHandle }, context) { return { ok: await this.broker.stop(turnHandle, context) }; }
async close({ chatHandle }, context) { return { ok: await this.broker.close(chatHandle, context) }; }
async list(_params, context) {
const owner = await this.#guard(context);
const selections = await this.host.listSelected(context);
await this.#guard(context);
if (!Array.isArray(selections) || selections.length > 5 || new Set(selections.map((item) => item.scope)).size !== selections.length) {
throw fail("resourceExhausted", "Selected scope limit reached");
}
const previous = this.runtimeContexts.get(context.runtimeId);
if (previous?.owner === owner && previous.keys === JSON.stringify(selections.map(({ key, version }) => [key, version]))) {
return { items: await this.selected.summaries(context) };
}
this.selected.releaseRuntime(context.runtimeId);
const created = [];
try {
for (const { key, scope, title, version, readText, isCurrent } of selections) {
if (typeof key !== "string" || !key || key.length > 256) throw fail("invalidArgument", "Host selection key is invalid");
const summary = await this.selected.share({ scope, title, version, readText, isCurrent }, context);
created.push(summary.handle);
}
await this.#guard(context);
this.runtimeContexts.set(context.runtimeId, { owner, keys: JSON.stringify(selections.map(({ key, version }) => [key, version])) });
return { items: await this.selected.summaries(context) };
} catch (error) { for (const handle of created) this.selected.release(handle); throw error; }
}
async readSelected(reference, context) {
await this.#guard(context);
await this.selected.assertCurrent(reference, context);
const authorization = this.#authorization("context.selected.read", reference);
// Direct RPC reads have already passed the registry permission middleware.
// A model turn must obtain its separate content grant here.
if (context.method !== "context.selected.read" || context.authorization?.permission !== authorization.permission
|| context.authorization.resources?.[0] !== authorization.resources[0]) {
await this.permissionEngine.authorize(context, authorization);
}
await this.#guard(context);
const selected = await this.selected.read(reference, context);
await this.#guard(context);
if (!await this.host.reviewText({ scope: selected.scope, title: selected.title, version: selected.version,
text: selected.text, pluginId: context.pluginId }, context)) throw fail("cancelled", "Selected context sharing was cancelled");
await this.#guard(context);
await this.selected.assertCurrent(reference, context);
return selected;
}
releaseRuntime(runtimeId) {
this.broker.releaseRuntime(runtimeId);
this.runtimeContexts.delete(runtimeId);
this.source.releaseRuntime(runtimeId);
this.host.releaseRuntime?.(runtimeId);
}
releasePlugin(pluginId) {
for (const [runtimeId, { owner }] of [...this.runtimeContexts]) if (owner.pluginId === pluginId) this.releaseRuntime(runtimeId);
for (const binding of [...this.source.bindings.values()]) if (binding.pluginId === pluginId) this.releaseRuntime(binding.runtimeId);
this.source.releasePlugin(pluginId);
}
releaseAll() { for (const binding of [...this.source.bindings.values()]) this.releaseRuntime(binding.runtimeId); }
shutdown() {
for (const registration of this.registrations.splice(0)) registration.dispose();
this.releaseAll(); this.broker.shutdown(); this.selected.shutdown(); this.source.shutdown();
this.revocation.dispose(); this.unsubscribeHost?.(); this.host.shutdown?.();
}
}
module.exports = { PluginAIContextService, PLUGIN_AI_CONTEXT_FEATURES: FEATURES, AI_CONTEXT_ROUTES: ROUTES };
@@ -0,0 +1,66 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const { PluginAIContextService } = require("./aiContextService.cjs");
function fixture() {
let current = true; let allowed = true; let reviewed = true; let sourceId = "source-a"; let revoke; let invoked = 0; let reads = 0;
const authorizations = [];
const context = { pluginId: "com.example.ai", runtimeId: "r", pluginVersion: "1", assertActive: async () => {} };
const source = () => ({ id: sourceId, kind: "third-party", origin: "https://source.example" });
const database = { getActivePlugin: () => ({ source: source(), enabled: true, activeVersion: "1", runtime: { status: "running" } }),
getRetainedPluginSource: source, privateOwner: () => `opaque-${sourceId}`, publicPluginId: () => context.pluginId };
const hostAdapter = { assertActive: async () => {},
selectModel: async () => ({ label: "Synthetic", isCurrent: () => current, invoke: () => { invoked++; return (async function* () { yield { kind: "text", text: "Result" }; })(); } }),
listSelected: async () => [{ key: "selected-note", scope: "note", title: "Synthetic", version: "1", isCurrent: () => current, readText: () => { reads++; return "Synthetic body"; } }],
reviewText: async () => reviewed,
};
const permissionEngine = { onDidRevoke: (listener) => { revoke = listener; return { dispose() {} }; },
authorize: async (_context, authorization) => { authorizations.push(authorization); if (!allowed) throw Error("Denied"); } };
const service = new PluginAIContextService({ database, permissionEngine, hostAdapter, getRuntimeIdentity: () => ({ ...context, runtimeKind: "browser" }) });
return { service, context, hostAdapter, authorizations, invoked: () => invoked, reads: () => reads,
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) => {
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);
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) => {
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; };
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);
});
test("permission revocation clears all handles for the plugin, including chats without context", async (t) => {
const f = fixture(); t.after(() => f.service.shutdown());
await f.service.list({}, f.context); const chat = await f.service.open({}, f.context);
const turn = await f.service.start({ chatHandle: chat.chatHandle, prompt: "Hi" }, f.context);
f.revoke(); assert.equal(f.service.broker.turns.size, 0); assert.equal(f.service.selected.handles.size, 0);
await assert.rejects(f.service.read(turn, f.context));
});
test("cancelling one host request does not revoke another chat in the same runtime", async (t) => {
const f = fixture(); t.after(() => f.service.shutdown());
const chat = await f.service.open({}, f.context);
let resolve; f.hostAdapter.listSelected = () => new Promise((done) => { resolve = done; });
const controller = new AbortController();
const listing = f.service.list({}, { ...f.context, signal: controller.signal });
const cancelled = assert.rejects(listing);
await new Promise(setImmediate); controller.abort(); resolve([]); await cancelled;
assert.equal(f.service.broker.chats.has(chat.chatHandle), true);
});
@@ -0,0 +1,278 @@
"use strict";
const { randomUUID } = require("node:crypto");
const { PluginRpcError, RPC_ERRORS } = require("../rpcRouter.cjs");
const { assertPluginJsonValue } = require("../jsonBoundary.cjs");
const fail = (code, message) => new PluginRpcError(RPC_ERRORS[code], message);
const LIMITS = Object.freeze({ chats: 32, perRuntime: 2, turnMs: 60_000, chatMs: 300_000,
inputBytes: 16_384, outputBytes: 32_768, outputTokens: 2_048, requestsPerMinute: 8,
reservedTokensPerMinute: 65_536, contexts: 4, chunkBytes: 4_096, events: 8_192, rateOwners: 128 });
function boundedLimits(overrides) {
return Object.freeze(Object.fromEntries(Object.entries(LIMITS).map(([key, max]) => {
const value = overrides[key] ?? max;
if (!Number.isSafeInteger(value) || value < 1 || value > max) throw new TypeError(`Invalid ${key} limit`);
return [key, value];
})));
}
function abortable(promise, signal) {
signal.throwIfAborted();
return new Promise((resolve, reject) => {
const abort = () => reject(signal.reason);
signal.addEventListener("abort", abort, { once: true });
Promise.resolve(promise).then(resolve, reject).finally(() => signal.removeEventListener("abort", abort));
});
}
// In-memory, source-isolated handles. No AgentRuntime, tools, chat persistence,
// provider configuration, URLs, credentials or arbitrary system prompts cross it.
class ModelInvocationBroker {
constructor({ resolveDataOwner, selectModel, contexts, clock = Date.now, limits = {} }) {
if (typeof resolveDataOwner !== "function" || typeof selectModel !== "function") throw new TypeError("Host adapters required");
this.resolveDataOwner = resolveDataOwner;
this.selectModel = selectModel;
this.contexts = contexts;
this.clock = clock;
this.limits = boundedLimits(limits);
this.chats = new Map();
this.openings = new Set();
this.turns = new Map();
this.rates = new Map();
this.closed = false;
this.contextSubscription = contexts?.onDidInvalidate((handle) => {
for (const turn of [...this.turns.values()]) {
if (turn.references.some((reference) => reference.handle === handle)) {
this.#end(turn, fail("cancelled", "Selected context was revoked"));
}
}
});
}
async #binding(context) {
await context.assertActive();
const owner = await this.resolveDataOwner(context);
await context.assertActive();
if (!owner || (typeof owner !== "object" && typeof owner !== "string")) throw fail("permissionDenied", "Host data owner is unavailable");
return { owner, runtimeId: context.runtimeId, pluginVersion: context.pluginVersion };
}
async #ownedChat(chat, context) {
const binding = await this.#binding(context);
if (this.closed || this.chats.get(chat.handle) !== chat
|| chat.owner !== binding.owner || chat.runtimeId !== binding.runtimeId || chat.pluginVersion !== binding.pluginVersion) {
throw fail("permissionDenied", "Plugin chat is unavailable");
}
}
async #check(chat, context) {
await this.#ownedChat(chat, context);
if (!await chat.model.isCurrent()) throw fail("permissionDenied", "Selected model changed");
const currentOwner = await this.resolveDataOwner(context);
await context.assertActive();
if (currentOwner !== chat.owner || this.closed || this.chats.get(chat.handle) !== chat || chat.expiresAt <= this.clock()) {
throw fail("permissionDenied", "Plugin chat is unavailable");
}
}
#prune() {
for (const chat of [...this.chats.values()]) if (chat.expiresAt <= this.clock()) this.#close(chat);
for (const [owner, rate] of this.rates) if (rate.startedAt + 60_000 <= this.clock()) this.rates.delete(owner);
}
async open(context) {
const binding = await this.#binding(context);
this.#prune();
if (this.closed || this.chats.size + this.openings.size >= this.limits.chats
|| [...this.chats.values(), ...this.openings].filter((chat) => chat.runtimeId === context.runtimeId).length >= this.limits.perRuntime) {
throw fail("resourceExhausted", "Plugin chat limit reached");
}
// Reserve before awaiting the host picker. Revoke/close can invalidate this
// admission even if a host callback ignores cancellation and returns late.
const opening = { ...binding, active: true };
this.openings.add(opening);
let model;
try {
model = await this.selectModel(context);
const current = await this.#binding(context);
if (!opening.active || this.closed || current.owner !== binding.owner) throw fail("cancelled", "Model selection was revoked");
if (!model || typeof model.invoke !== "function" || typeof model.isCurrent !== "function"
|| typeof model.label !== "string" || model.label.length > 160 || !await model.isCurrent()) {
throw fail("unavailable", "Choose a configured model in the host first");
}
await context.assertActive();
if (!opening.active || this.closed || (await this.#binding(context)).owner !== binding.owner) throw fail("cancelled", "Model selection was revoked");
const chat = { ...binding, handle: randomUUID(), model, expiresAt: this.clock() + this.limits.chatMs, turn: null };
this.chats.set(chat.handle, chat);
return { chatHandle: chat.handle, model: model.label, expiresAt: chat.expiresAt };
} catch (error) {
try { Promise.resolve(model?.dispose?.()).catch(() => {}); } catch {}
throw this.#safe(error);
} finally { this.openings.delete(opening); }
}
async start({ chatHandle, prompt, contexts = [] }, context) {
assertPluginJsonValue({ chatHandle, prompt, contexts }, { maxBytes: this.limits.inputBytes + 2048 });
if (typeof prompt !== "string" || !prompt.trim() || prompt.includes("\0") || !Array.isArray(contexts)
|| contexts.length > this.limits.contexts || Buffer.byteLength(prompt) > this.limits.inputBytes) {
throw fail("invalidArgument", "Invalid model input");
}
const references = contexts.map((reference) => {
if (!reference || Array.isArray(reference) || Object.keys(reference).some((key) => !["handle", "version"].includes(key))
|| typeof reference.handle !== "string" || !reference.handle || reference.handle.length > 128
|| typeof reference.version !== "string" || !reference.version || reference.version.length > 128) {
throw fail("invalidArgument", "Invalid context reference");
}
return Object.freeze({ handle: reference.handle, version: reference.version });
});
const chat = this.chats.get(chatHandle);
if (!chat) throw fail("permissionDenied", "Plugin chat is unavailable");
await this.#check(chat, context);
if (chat.turn) throw fail("resourceExhausted", "A turn is already active");
const controller = new AbortController();
const turn = { handle: randomUUID(), chat, controller, reading: false, bytes: 0, sequence: 0, iterator: null, references: Object.freeze(references) };
chat.turn = turn;
this.turns.set(turn.handle, turn);
const abort = () => this.#end(turn, fail("cancelled", "Model turn cancelled"));
context.signal?.addEventListener("abort", abort, { once: true });
turn.cleanup = () => context.signal?.removeEventListener("abort", abort);
turn.timer = setTimeout(() => this.#end(turn, fail("deadlineExceeded", "Model turn timed out")), this.limits.turnMs);
try {
await this.#guard(turn, context);
const selected = [];
let bytes = Buffer.byteLength(prompt);
for (const reference of references) {
const result = await abortable(this.contexts.read(reference, { ...context, signal: controller.signal }), controller.signal);
bytes += Buffer.byteLength(result.text);
if (bytes > this.limits.inputBytes) throw fail("resourceExhausted", "Model input byte budget exceeded");
selected.push(result.text);
}
await this.#guard(turn, context);
this.#prune();
const rate = this.rates.get(chat.owner) ?? { startedAt: this.clock(), count: 0, tokens: 0 };
// Reserve conservatively using UTF-8 input bytes plus the output cap. Do
// not refund unknown provider usage after cancellation or disconnect.
const reserved = bytes + 1_024 + this.limits.outputTokens;
if ((!this.rates.has(chat.owner) && this.rates.size >= this.limits.rateOwners) || rate.count >= this.limits.requestsPerMinute || rate.tokens + reserved > this.limits.reservedTokensPerMinute) {
throw fail("resourceExhausted", "Model request/token budget reached");
}
rate.count++; rate.tokens += reserved; this.rates.set(chat.owner, rate);
await this.#guard(turn, context);
const iterator = await abortable(chat.model.invoke({ prompt, contexts: selected, signal: controller.signal,
maxOutputTokens: this.limits.outputTokens, maxChunkBytes: this.limits.chunkBytes }), controller.signal);
await this.#guard(turn, context);
if (!iterator || typeof iterator.next !== "function") throw fail("internal", "Model adapter failed");
turn.iterator = iterator;
return { turnHandle: turn.handle, maxOutputTokens: this.limits.outputTokens, reservedTokens: reserved };
} catch (error) {
this.#end(turn, error);
throw this.#safe(error);
}
}
async #guard(turn, context) {
turn.controller.signal.throwIfAborted();
await abortable(this.#check(turn.chat, context), turn.controller.signal);
for (const reference of turn.references) {
await abortable(this.contexts.assertCurrent(reference, { ...context, signal: turn.controller.signal }), turn.controller.signal);
}
turn.controller.signal.throwIfAborted();
if (this.turns.get(turn.handle) !== turn || turn.chat.turn !== turn) throw fail("cancelled", "Model turn ended");
}
#safe(error) {
// SDK/provider errors may contain headers, URLs or echoed request content.
return error instanceof PluginRpcError ? error : fail("unavailable", "Model invocation failed");
}
async read(turnHandle, context) {
const turn = this.turns.get(turnHandle);
if (!turn) throw fail("permissionDenied", "Model turn is unavailable");
// Validate owner before mutating/aborting another caller's turn.
await this.#ownedChat(turn.chat, context);
if (turn.reading || !turn.iterator) throw fail("resourceExhausted", "A model read is already pending");
turn.reading = true;
const abort = () => this.#end(turn, fail("cancelled", "Model read cancelled"));
context.signal?.addEventListener("abort", abort, { once: true });
try {
if (context.signal?.aborted) abort();
await this.#guard(turn, context);
if (turn.sequence >= this.limits.events) throw fail("resourceExhausted", "Model event budget exceeded");
const next = await abortable(turn.iterator.next(), turn.controller.signal);
await this.#guard(turn, context);
if (next.done) {
this.#end(turn);
return { kind: "end", sequence: turn.sequence++ };
}
const event = next.value;
if (event?.kind === "text" && typeof event.text === "string") {
const bytes = Buffer.byteLength(event.text);
if (bytes > this.limits.chunkBytes || turn.bytes + bytes > this.limits.outputBytes) {
throw fail("resourceExhausted", "Model output byte budget exceeded");
}
turn.bytes += bytes;
return { kind: "text", sequence: turn.sequence++, text: event.text };
}
if (event?.kind === "usage") {
const usage = {};
for (const key of ["inputTokens", "outputTokens", "totalTokens"]) {
if (event[key] !== undefined) {
if (!Number.isSafeInteger(event[key]) || event[key] < 0) throw fail("internal", "Invalid model usage");
usage[key] = event[key];
}
}
if ((usage.outputTokens ?? 0) > this.limits.outputTokens) throw fail("resourceExhausted", "Model output token budget exceeded");
return { kind: "usage", sequence: turn.sequence++, ...usage };
}
// A model's tool request is data, never host authorization. This phase
// exposes no execution path, and drops raw tool payloads/reasoning/errors.
throw fail("permissionDenied", "Model tools and unsupported events are disabled");
} catch (error) {
this.#end(turn, error);
throw this.#safe(error);
} finally { turn.reading = false; context.signal?.removeEventListener("abort", abort); }
}
async stop(turnHandle, context) {
const turn = this.turns.get(turnHandle);
if (!turn) return false;
await this.#ownedChat(turn.chat, context);
this.#end(turn, fail("cancelled", "Model turn cancelled"));
return true;
}
#end(turn, reason = fail("cancelled", "Model turn ended")) {
if (this.turns.get(turn.handle) !== turn) return;
this.turns.delete(turn.handle);
if (turn.chat.turn === turn) turn.chat.turn = null;
clearTimeout(turn.timer); turn.cleanup?.(); turn.controller.abort(reason);
// Never wait for a provider which ignores cancellation. Its late next()
// result is observed by abortable() and cannot re-enter the output path.
try { Promise.resolve(turn.iterator?.return?.()).catch(() => {}); } catch {}
turn.iterator = null;
}
#close(chat) {
if (chat.turn) this.#end(chat.turn);
this.chats.delete(chat.handle);
try { Promise.resolve(chat.model.dispose?.()).catch(() => {}); } catch {}
}
async close(chatHandle, context) {
const chat = this.chats.get(chatHandle);
if (!chat) return false;
await this.#ownedChat(chat, context); this.#close(chat); return true;
}
releaseRuntime(runtimeId) {
for (const opening of [...this.openings]) if (opening.runtimeId === runtimeId) { opening.active = false; this.openings.delete(opening); }
for (const chat of [...this.chats.values()]) if (chat.runtimeId === runtimeId) this.#close(chat);
this.contexts?.releaseRuntime(runtimeId);
}
releaseOwner(owner) {
for (const opening of [...this.openings]) if (opening.owner === owner) { opening.active = false; this.openings.delete(opening); }
for (const chat of [...this.chats.values()]) if (chat.owner === owner) this.#close(chat);
this.contexts?.releaseOwner(owner);
}
shutdown() { this.closed = true; for (const opening of this.openings) opening.active = false; this.openings.clear(); for (const chat of [...this.chats.values()]) this.#close(chat); this.rates.clear(); this.contextSubscription?.dispose(); }
}
module.exports = { ModelInvocationBroker, MODEL_INVOCATION_LIMITS: LIMITS };
@@ -0,0 +1,222 @@
"use strict";
const assert = require("node:assert/strict");
const test = require("node:test");
const { ModelInvocationBroker } = require("./modelInvocationBroker.cjs");
const { SelectedContextAuthority } = require("../context/selectedContextAuthority.cjs");
const { RPC_ERRORS } = require("../rpcRouter.cjs");
const deferred = () => { let resolve; const promise = new Promise((r) => { resolve = r; }); return { promise, resolve }; };
const denied = (error) => error.code === RPC_ERRORS.permissionDenied;
function fixture(options = {}) {
const owner = {}; let currentOwner = owner; let current = true; let active = true; let now = 0;
const request = new AbortController();
const context = { pluginId: "same-id", pluginVersion: "1", runtimeId: "r1", signal: request.signal,
assertActive: async () => { request.signal.throwIfAborted(); if (!active) throw Error("stopped"); } };
const calls = [];
const model = { label: "Host selected model", isCurrent: () => current,
invoke: (input) => { calls.push(input); return (async function* () { yield { kind: "text", text: "Summary" };
yield { kind: "usage", inputTokens: 7, outputTokens: 2, totalTokens: 9, secret: "DO_NOT_SERIALIZE" }; })(); }, ...options.model };
const resolveDataOwner = async () => currentOwner;
const contexts = new SelectedContextAuthority({ resolveDataOwner, clock: () => now });
const broker = new ModelInvocationBroker({ resolveDataOwner, selectModel: async () => model, contexts,
clock: () => now, limits: options.limits });
return { broker, context, contexts, calls, request, owner, model, setOwner: (value) => { currentOwner = value; },
setCurrent: (value) => { current = value; }, setActive: (value) => { active = value; }, setTime: (value) => { now = value; } };
}
test("selected synthetic Note traverses actual broker; text/usage are projected without configuration or secrets", async (t) => {
const f = fixture(); t.after(() => f.broker.shutdown());
const note = await f.contexts.share({ scope: "note", title: "Synthetic note", version: "v1",
readText: () => "Synthetic release: add retry button", isCurrent: () => true }, f.context);
assert.equal(f.calls.length, 0);
const chat = await f.broker.open(f.context);
const turn = await f.broker.start({ chatHandle: chat.chatHandle, prompt: "Summarize", contexts: [{ handle: note.handle, version: note.version }] }, f.context);
assert.deepEqual(f.calls[0].contexts, ["Synthetic release: add retry button"]);
assert.equal(f.calls[0].tools, undefined);
const events = [await f.broker.read(turn.turnHandle, f.context), await f.broker.read(turn.turnHandle, f.context), await f.broker.read(turn.turnHandle, f.context)];
assert.equal(events[0].text, "Summary");
assert.deepEqual(events[1], { kind: "usage", sequence: 1, inputTokens: 7, outputTokens: 2, totalTokens: 9 });
assert.equal(events[2].kind, "end");
assert.doesNotMatch(JSON.stringify({ chat, turn, events, note }), /apiKey|config|DO_NOT_SERIALIZE/);
});
test("same pluginId with another source owner, runtime or version cannot use handles or stop victim turns", async (t) => {
const f = fixture(); t.after(() => f.broker.shutdown());
const chat = await f.broker.open(f.context);
const turn = await f.broker.start({ chatHandle: chat.chatHandle, prompt: "Hi" }, f.context);
for (const changed of [{ runtimeId: "r2" }, { pluginVersion: "2" }]) {
await assert.rejects(f.broker.read(turn.turnHandle, { ...f.context, ...changed }), denied);
await assert.rejects(f.broker.stop(turn.turnHandle, { ...f.context, ...changed }), denied);
}
f.setOwner({});
await assert.rejects(f.broker.read(turn.turnHandle, f.context), denied);
await assert.rejects(f.broker.close(chat.chatHandle, f.context), denied);
f.setOwner(f.owner);
assert.equal((await f.broker.read(turn.turnHandle, f.context)).text, "Summary");
});
test("pending read cancels promptly and a provider's late result never escapes", async (t) => {
const pending = deferred(); let signal;
const f = fixture({ model: { invoke: (input) => { signal = input.signal; return { next: () => pending.promise }; } } });
t.after(() => f.broker.shutdown());
const chat = await f.broker.open(f.context);
const turn = await f.broker.start({ chatHandle: chat.chatHandle, prompt: "Hi" }, f.context);
const reading = f.broker.read(turn.turnHandle, f.context);
await new Promise(setImmediate);
assert.equal(await f.broker.stop(turn.turnHandle, f.context), true);
await assert.rejects(reading, (e) => e.code === RPC_ERRORS.cancelled);
assert.equal(signal.aborted, true);
pending.resolve({ value: { kind: "text", text: "Late output" } });
await assert.rejects(f.broker.read(turn.turnHandle, f.context), denied);
});
test("runtime teardown, owner revoke and request abort discard pending results", async () => {
for (const action of ["runtime", "owner", "abort"]) {
const pending = deferred();
const f = fixture({ model: { invoke: () => ({ next: () => pending.promise }) } });
const chat = await f.broker.open(f.context);
const turn = await f.broker.start({ chatHandle: chat.chatHandle, prompt: "Hi" }, f.context);
const reading = f.broker.read(turn.turnHandle, f.context);
await new Promise(setImmediate);
if (action === "runtime") f.broker.releaseRuntime("r1");
if (action === "owner") f.broker.releaseOwner(f.owner);
if (action === "abort") f.request.abort();
await assert.rejects(reading);
pending.resolve({ value: { kind: "text", text: "Late" } });
assert.equal(f.broker.turns.size, 0); f.broker.shutdown();
}
});
test("deadline also aborts providers that ignore AbortSignal", async () => {
const f = fixture({ model: { invoke: () => ({ next: () => new Promise(() => {}) }) }, limits: { turnMs: 20 } });
const chat = await f.broker.open(f.context);
const turn = await f.broker.start({ chatHandle: chat.chatHandle, prompt: "Hi" }, f.context);
await assert.rejects(f.broker.read(turn.turnHandle, f.context), (e) => e.code === RPC_ERRORS.deadlineExceeded);
assert.equal(f.broker.turns.size, 0); f.broker.shutdown();
});
test("model switch and chat expiration reject results", async () => {
for (const action of ["model", "expiry"]) {
const pending = deferred(); const f = fixture({ model: { invoke: () => ({ next: () => pending.promise }) } });
const chat = await f.broker.open(f.context);
const turn = await f.broker.start({ chatHandle: chat.chatHandle, prompt: "Hi" }, f.context);
const reading = f.broker.read(turn.turnHandle, f.context); await new Promise(setImmediate);
if (action === "model") f.setCurrent(false); else f.setTime(300_001);
pending.resolve({ value: { kind: "text", text: "Late" } });
await assert.rejects(reading, denied); f.broker.shutdown();
}
});
test("bounds cover request rate, output bytes/tokens, concurrent read and raw error serialization", async () => {
const f = fixture({ limits: { requestsPerMinute: 1 } });
const chat = await f.broker.open(f.context);
const turn = await f.broker.start({ chatHandle: chat.chatHandle, prompt: "Hi" }, f.context);
await f.broker.stop(turn.turnHandle, f.context);
await assert.rejects(f.broker.start({ chatHandle: chat.chatHandle, prompt: "Hi" }, f.context), (e) => e.code === RPC_ERRORS.resourceExhausted);
f.broker.releaseRuntime("r1");
const again = await f.broker.open(f.context);
await assert.rejects(f.broker.start({ chatHandle: again.chatHandle, prompt: "Hi" }, f.context), (e) => e.code === RPC_ERRORS.resourceExhausted);
f.broker.shutdown();
for (const event of [{ kind: "text", text: "X".repeat(4097) }, { kind: "usage", outputTokens: 2049 }, { kind: "tool-call", command: "rm -rf /" }]) {
const instance = fixture({ model: { invoke: () => (async function* () { yield event; })() } });
const selected = await instance.broker.open(instance.context);
const started = await instance.broker.start({ chatHandle: selected.chatHandle, prompt: "Hi" }, instance.context);
await assert.rejects(instance.broker.read(started.turnHandle, instance.context));
assert.equal(instance.broker.turns.size, 0); instance.broker.shutdown();
}
const broken = fixture({ model: { invoke: () => { throw Error("api-key secret request body"); } } });
const selected = await broken.broker.open(broken.context);
await assert.rejects(broken.broker.start({ chatHandle: selected.chatHandle, prompt: "Hi" }, broken.context), (e) => !/secret|api-key/.test(e.message));
broken.broker.shutdown();
});
test("revoking selected context aborts an in-flight turn; changing its version suppresses late output", async () => {
for (const action of ["revoke", "version"]) {
let current = true; const pending = deferred();
const f = fixture({ model: { invoke: () => ({ next: () => pending.promise }) } });
const note = await f.contexts.share({ scope: "note", title: "Synthetic", version: "1",
readText: () => "Synthetic", isCurrent: () => current }, f.context);
const chat = await f.broker.open(f.context);
const turn = await f.broker.start({ chatHandle: chat.chatHandle, prompt: "Hi", contexts: [{ handle: note.handle, version: note.version }] }, f.context);
const reading = f.broker.read(turn.turnHandle, f.context); await new Promise(setImmediate);
if (action === "revoke") f.contexts.release(note.handle); else current = false;
pending.resolve({ value: { kind: "text", text: "Late" } });
await assert.rejects(reading); f.broker.shutdown();
}
});
test("input, aggregate output, reserved token and concurrent read bounds", async () => {
const cases = [
{ limits: { inputBytes: 2 }, prompt: "too big", start: true },
{ limits: { reservedTokensPerMinute: 1 }, prompt: "Hi", start: true },
{ limits: { outputBytes: 2 }, prompt: "Hi", start: false },
];
for (const scenario of cases) {
const f = fixture({ limits: scenario.limits }); const chat = await f.broker.open(f.context);
const start = () => f.broker.start({ chatHandle: chat.chatHandle, prompt: scenario.prompt }, f.context);
if (scenario.start) await assert.rejects(start());
else { const turn = await start(); await assert.rejects(f.broker.read(turn.turnHandle, f.context)); }
assert.equal(f.broker.turns.size, 0); f.broker.shutdown();
}
const pending = deferred(); const f = fixture({ model: { invoke: () => ({ next: () => pending.promise }) } });
const chat = await f.broker.open(f.context); const turn = await f.broker.start({ chatHandle: chat.chatHandle, prompt: "Hi" }, f.context);
const reading = f.broker.read(turn.turnHandle, f.context); await new Promise(setImmediate);
await assert.rejects(f.broker.read(turn.turnHandle, f.context), (e) => e.code === RPC_ERRORS.resourceExhausted);
pending.resolve({ value: { kind: "text", text: "OK" } }); assert.equal((await reading).text, "OK"); f.broker.shutdown();
});
test("the owner can stop or close after a model switch; read request cancellation aborts the provider", async () => {
const f = fixture(); const chat = await f.broker.open(f.context);
const turn = await f.broker.start({ chatHandle: chat.chatHandle, prompt: "Hi" }, f.context);
f.setCurrent(false); assert.equal(await f.broker.stop(turn.turnHandle, f.context), true);
assert.equal(await f.broker.close(chat.chatHandle, f.context), true); f.broker.shutdown();
const pending = deferred(); const instance = fixture({ model: { invoke: () => ({ next: () => pending.promise }) } });
const selected = await instance.broker.open(instance.context);
const started = await instance.broker.start({ chatHandle: selected.chatHandle, prompt: "Hi" }, instance.context);
const controller = new AbortController();
const reading = instance.broker.read(started.turnHandle, { ...instance.context, signal: controller.signal });
await new Promise(setImmediate); controller.abort(); await assert.rejects(reading);
assert.equal(instance.broker.turns.size, 0); pending.resolve({ done: true }); instance.broker.shutdown();
});
test("event and handle caps prevent cheap empty streams and unbounded allocation", async () => {
const f = fixture({ limits: { perRuntime: 1, events: 1 }, model: { invoke: () => (async function* () {
yield { kind: "text", text: "" }; yield { kind: "text", text: "" };
})() } });
const chat = await f.broker.open(f.context); await assert.rejects(f.broker.open(f.context));
const turn = await f.broker.start({ chatHandle: chat.chatHandle, prompt: "Hi" }, f.context);
assert.equal((await f.broker.read(turn.turnHandle, f.context)).text, "");
await assert.rejects(f.broker.read(turn.turnHandle, f.context), (e) => e.code === RPC_ERRORS.resourceExhausted); f.broker.shutdown();
});
test("malformed context cannot publish a turn or interrupt two-handle runtime revocation", async (t) => {
const entered = deferred(); const resume = deferred(); let checks = 0;
const f = fixture({ model: { isCurrent: async () => { if (++checks === 3) { entered.resolve(); await resume.promise; } return true; } } });
t.after(() => { resume.resolve(); f.broker.shutdown(); });
const selection = { scope: "note", title: "Synthetic", version: "1", readText: () => "Synthetic", isCurrent: () => true };
await f.contexts.share(selection, f.context); await f.contexts.share(selection, f.context);
const chat = await f.broker.open(f.context);
const start = f.broker.start({ chatHandle: chat.chatHandle, prompt: "Hi", contexts: [null] }, f.context);
const outcome = start.then(() => "accepted", (error) => error);
// On the old implementation, reach the reported post-publication model guard.
// On the fixed implementation, validation rejects before that await exists.
await Promise.race([entered.promise, outcome]);
assert.doesNotThrow(() => f.contexts.releaseRuntime("r1"));
assert.equal(f.contexts.handles.size, 0);
resume.resolve();
const error = await outcome;
assert.equal(error.code, RPC_ERRORS.invalidArgument);
assert.equal(f.broker.turns.size, 0);
assert.equal(f.calls.length, 0);
f.broker.shutdown();
});
test("pending model selections reserve capacity and cannot publish after revocation", async () => {
const f = fixture({ limits: { perRuntime: 1 } }); const pending = deferred(); let disposed = 0;
f.broker.selectModel = () => pending.promise;
const opening = f.broker.open(f.context); const rejected = assert.rejects(opening);
await new Promise(setImmediate);
await assert.rejects(f.broker.open(f.context), (error) => error.code === RPC_ERRORS.resourceExhausted);
f.broker.releaseRuntime("r1");
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();
});
+61
View File
@@ -0,0 +1,61 @@
"use strict";
const { PluginRpcError, RPC_ERRORS } = require("../rpcRouter.cjs");
const denied = () => new PluginRpcError(RPC_ERRORS.permissionDenied, "Plugin source binding is unavailable");
// Consume the market task's host-owned API. No source or owner is read from RPC
// parameters, and a missing API/retained source fails closed.
class PluginAISourceBinding {
constructor({ database, getRuntimeIdentity }) {
for (const method of ["getActivePlugin", "getRetainedPluginSource", "privateOwner", "publicPluginId"]) {
if (typeof database?.[method] !== "function") throw new TypeError(`Source database ${method} is required`);
}
if (typeof getRuntimeIdentity !== "function") throw new TypeError("Runtime identity resolver required");
this.database = database;
this.getRuntimeIdentity = getRuntimeIdentity;
this.bindings = new Map();
}
#snapshot(context) {
const identity = this.getRuntimeIdentity(context.pluginId);
if (!identity || identity.pluginId !== context.pluginId || identity.runtimeId !== context.runtimeId
|| identity.pluginVersion !== context.pluginVersion || identity.runtimeKind !== "browser") throw denied();
const plugin = this.database.getActivePlugin(identity.pluginId);
const source = plugin?.source;
const retained = this.database.getRetainedPluginSource(identity.pluginId);
if (!plugin?.enabled || plugin.activeVersion !== identity.pluginVersion || !source || !retained
|| !["starting", "running"].includes(plugin.runtime?.status)
|| typeof source.id !== "string" || typeof source.kind !== "string"
|| source.id !== retained.id || source.kind !== retained.kind || source.origin !== retained.origin) throw denied();
const privateOwner = this.database.privateOwner(identity.pluginId);
if (typeof privateOwner !== "string" || !privateOwner || this.database.publicPluginId(privateOwner) !== identity.pluginId) throw denied();
return { runtimeId: identity.runtimeId, pluginId: identity.pluginId, pluginVersion: identity.pluginVersion,
sourceKey: JSON.stringify([source.id, source.kind, source.origin ?? null]), privateOwner };
}
async resolve(context) {
// Authenticate first. Even the database source lookup follows this guard.
await context.assertActive();
context.signal?.throwIfAborted();
const snapshot = this.#snapshot(context);
const previous = this.bindings.get(snapshot.runtimeId);
if (previous && (previous.sourceKey !== snapshot.sourceKey || previous.privateOwner !== snapshot.privateOwner
|| previous.pluginId !== snapshot.pluginId || previous.pluginVersion !== snapshot.pluginVersion)) throw denied();
await context.assertActive();
context.signal?.throwIfAborted();
const current = this.#snapshot(context);
if (current.sourceKey !== snapshot.sourceKey || current.privateOwner !== snapshot.privateOwner) throw denied();
if (previous) return previous;
const binding = Object.freeze(snapshot);
this.bindings.set(binding.runtimeId, binding);
return binding;
}
releaseRuntime(runtimeId) { this.bindings.delete(runtimeId); }
releasePlugin(pluginId) {
for (const [runtimeId, binding] of this.bindings) if (binding.pluginId === pluginId) this.bindings.delete(runtimeId);
}
shutdown() { this.bindings.clear(); }
}
module.exports = { PluginAISourceBinding };
Loaded 100 of 260 files, more files were not shown because too many files have changed in this diff. Show more