mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-10-29 18:11:40 +00:00
Compare commits
19
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
733f44cfdc | ||
|
|
a8adeb86fe | ||
|
|
0c097a1260 | ||
|
|
5c9286775e | ||
|
|
618257e593 | ||
|
|
cde2be236e | ||
|
|
342941b618 | ||
|
|
8e4dd0c052 | ||
|
|
3a96cd2537 | ||
|
|
0a692dfc72 | ||
|
|
1d369bf322 | ||
|
|
0d77a2e56c | ||
|
|
5f06fd3cec | ||
|
|
d4ca123999 | ||
|
|
3ac4772087 | ||
|
|
71d455d5de | ||
|
|
ff3734d161 | ||
|
|
185eac3849 | ||
|
|
9af123e5d7 |
No files matched your search
@@ -12,6 +12,7 @@ import { ToastProvider } from './components/ui/toast';
|
||||
import { TooltipProvider } from './components/ui/tooltip';
|
||||
import { ScriptAutomationRoot } from './components/scripts/ScriptAutomationRoot';
|
||||
import { ExternalMcpApprovalsHost } from './components/ai/ExternalMcpApprovalsHost';
|
||||
import { 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}>
|
||||
|
||||
@@ -1245,6 +1245,36 @@ 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.",
|
||||
|
||||
@@ -1245,6 +1245,36 @@ 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.",
|
||||
|
||||
@@ -1140,6 +1140,36 @@ 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": "Привилегированные разрешения не заявлены.",
|
||||
|
||||
@@ -1160,6 +1160,36 @@ 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": "未声明特权权限。",
|
||||
|
||||
@@ -1163,6 +1163,36 @@ 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": "未聲明特權權限。",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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(); }); }
|
||||
});
|
||||
@@ -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'] }]);
|
||||
|
||||
@@ -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,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 };
|
||||
}
|
||||
@@ -82,7 +82,7 @@ export function usePluginViewLifecycle({
|
||||
const mountRef = useRef<HTMLDivElement>(null);
|
||||
const resolvedActiveView = useMemo(() => viewContributions.snapshot.plugins
|
||||
.flatMap((plugin) => plugin.views.map((view) => ({ plugin, view })))
|
||||
.find(({ view }) => view.id === effectiveRequested?.viewId && view.visible) ?? 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({
|
||||
|
||||
@@ -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); }
|
||||
}
|
||||
});
|
||||
@@ -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); }
|
||||
}
|
||||
});
|
||||
@@ -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); }
|
||||
}
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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>;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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; }
|
||||
});
|
||||
@@ -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';
|
||||
@@ -89,13 +90,13 @@ 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>
|
||||
{plugin.manifest?.publisher && <p className="break-words text-xs text-muted-foreground">{plugin.manifest.publisher}</p>}
|
||||
@@ -107,6 +108,7 @@ 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>
|
||||
|
||||
@@ -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 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>;
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
# 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 | Planned independent implementation after current controlled workflow/navigation. 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.
|
||||
@@ -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.
|
||||
@@ -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,74 @@
|
||||
# Terminal sidebar pages
|
||||
|
||||
The `terminal-sidebar` view location requires `views.terminal-sidebar` and the
|
||||
existing `views` permission. This adds navigation and plugin-owned content to
|
||||
the existing terminal pane/split system. It is not a session-data capability,
|
||||
Provider subscription, terminal history reader or command execution API.
|
||||
|
||||
## Host-owned navigation
|
||||
|
||||
An Extension pages menu in the normal terminal toolbar lists visible pages
|
||||
with their plugin source. Selecting one switches the existing focused pane;
|
||||
selecting an already-open tool focuses that pane. Existing split menus include
|
||||
the same contributions and enforce unique tools and the eight-pane limit.
|
||||
Host-generated `plugin-view:<namespaced-view-id>` keys cannot replace built-in
|
||||
tools. Declared order and ID determine listing order; existing host docking,
|
||||
split resizing, magnification and close controls remain authoritative.
|
||||
|
||||
The manager's internal Open event can select a page only when an open terminal
|
||||
is already selected. Otherwise the host asks the user to select a terminal; it
|
||||
does not connect or open a saved host. Launcher events target the session from
|
||||
the actual host terminal toolbar and are rechecked against current selection.
|
||||
These are internal host UI events, not public SDK router methods or trusted
|
||||
plugin `userGesture` flags.
|
||||
|
||||
## Session ownership without data sharing
|
||||
|
||||
The renderer binds each page to the actual terminal tab/workspace and current
|
||||
focused child session. It passes only `netcatty.surface: terminal-sidebar` as
|
||||
the view context. Internal ownership IDs are used for host state scoping, not
|
||||
returned to the page or treated as resource grants. A future selected-session
|
||||
data API must establish its own live endpoint authority and permissions.
|
||||
|
||||
Changing terminal tabs or workspace focus closes the old native view before
|
||||
the new one can receive focus. Session removal, pane close, withdrawal of a
|
||||
visible contribution, disable/update/uninstall and runtime loss remove the
|
||||
affected page. Unknown saved routes are pruned without opening them; restoring
|
||||
a layout never grants the page or any session-data permission. State buckets
|
||||
are for the plugin's own UI data, not an isolation boundary against that same
|
||||
plugin's already-authorized own-state API. Ordinary KV does not become synced.
|
||||
|
||||
PersistentSidePanelPortal moves a stable mount between the actual pane,
|
||||
magnification host and hidden parking host. There is no fallback native view
|
||||
over the entire terminal layer. Native geometry follows host DOM relocation,
|
||||
resize and scrolling; duplicate geometry updates are suppressed. Hidden/inert
|
||||
or parked mounts cannot reveal a native view.
|
||||
|
||||
## Focus and security surfaces
|
||||
|
||||
The existing sandbox ViewHost, immutable package/protocol checks, own-command
|
||||
and view-state boundaries are reused. Embedded pages start hidden. Host
|
||||
dialogs and overlapping portals hide them, and main-process App Lock closes
|
||||
them. Escape is host-owned and closes the plugin pane.
|
||||
|
||||
Native WebContents focus is reported only to its actual owner's trusted
|
||||
renderer using a canonical instance-ID event. It updates the selected pane;
|
||||
it does not expose an Electron object to the plugin. Revealing multiple panes
|
||||
does not focus all of them: only the host-selected pane can request focus.
|
||||
The main process verifies actual owner, visibility, foreground and unlocked
|
||||
state before honoring that request. A plugin has no SDK method to invoke the
|
||||
host focus bridge. No session or page text is added to focus/audit logs.
|
||||
|
||||
## Example and evidence
|
||||
|
||||
`com.netcatty.session-scratchpad` 0.1.0 asks only for its own `views`. It saves,
|
||||
reopens and clears explicitly typed synthetic text. Package instructions and
|
||||
minimal native actions are in its README. Repository tests use real split
|
||||
tree, layout state, persistent portal, launcher and embedded-view components;
|
||||
host tests cover owner-bound focus, hidden/locked refusal and withdrawal.
|
||||
Feature and permission admission, limits, old APIs and previous Vault/command
|
||||
fixes are checked on the same stack. Independent review, exact-SHA CI and
|
||||
parent native QA remain separate acceptance gates recorded on the Draft PR.
|
||||
|
||||
ThemePack, selected context/terminal drafts and SFTP handles remain subsequent
|
||||
bounded stages. Adding a sidebar page does not count as implementing them.
|
||||
@@ -0,0 +1,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.
|
||||
@@ -9,6 +9,7 @@ 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.", "插件面板", "在应用中显示插件自己的面板。"],
|
||||
"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.", "写入剪贴板", "替换剪贴板内容,可能影响你随后粘贴的数据。"],
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export type SidePanelTool =
|
||||
export type BuiltinSidePanelTool =
|
||||
| 'sftp'
|
||||
| 'browser'
|
||||
| 'scripts'
|
||||
@@ -8,6 +8,12 @@ export type SidePanelTool =
|
||||
| 'system'
|
||||
| 'notes';
|
||||
|
||||
export type SidePanelTool = BuiltinSidePanelTool | `plugin-view:${string}`;
|
||||
|
||||
export function isPluginSidePanelTool(value: unknown): value is `plugin-view:${string}` {
|
||||
return typeof value === 'string' && value.length <= 256 && /^plugin-view:[A-Za-z0-9_.-]+$/.test(value);
|
||||
}
|
||||
|
||||
export type SidePanelSplitDirection = 'horizontal' | 'vertical';
|
||||
|
||||
export type SidePanelPaneNode = {
|
||||
|
||||
@@ -90,6 +90,21 @@ test('sanitize accepts a valid saved preset and rejects malformed data', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('plugin panes keep split uniqueness and limits; saved routes grant no availability and can be pruned', () => {
|
||||
const tool = 'plugin-view:com.example.plugin.page' as const;
|
||||
let layout = createSidePanelLayout('notes', 'notes');
|
||||
layout = splitSidePanelPane(layout, 'notes', tool, 'vertical', { paneId: 'plugin', splitId: 'root' }, 600);
|
||||
const unique = splitSidePanelPane(layout, 'notes', tool, 'vertical', { paneId: 'duplicate', splitId: 'unused' }, 600);
|
||||
assert.equal(collectSidePanelPanes(unique.root).length, 2);
|
||||
assert.equal(getFocusedSidePanelPane(unique).tool, tool);
|
||||
assert.ok(createWorkspaceLayoutPreset(layout));
|
||||
assert.equal(withoutUnavailableSidePanelTools(layout, (item) => item === 'notes')?.root.type, 'pane');
|
||||
for (let i = 0; i < 10; i++) layout = splitSidePanelPane(layout, getFocusedSidePanelPane(layout).id,
|
||||
`plugin-view:com.example.plugin.page${i}`, 'horizontal', { paneId: `p${i}`, splitId: `s${i}` }, 600);
|
||||
assert.equal(collectSidePanelPanes(layout.root).length, 8);
|
||||
assert.equal(sanitizeWorkspaceLayoutPreset({ version: 1, layout: { root: { id: 'invalid', type: 'pane', tool: 'plugin-view:../outside' }, focusedPaneId: 'invalid' } }), null);
|
||||
});
|
||||
|
||||
test('sanitize rejects duplicate node ids', () => {
|
||||
assert.equal(
|
||||
sanitizeWorkspaceLayoutPreset({
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
closeSidePanelPane,
|
||||
collectSidePanelPanes,
|
||||
getFocusedSidePanelPane,
|
||||
isPluginSidePanelTool,
|
||||
type SidePanelLayout,
|
||||
type SidePanelLayoutNode,
|
||||
type SidePanelTool,
|
||||
@@ -32,7 +33,7 @@ const SIDE_PANEL_TOOLS: ReadonlySet<SidePanelTool> = new Set([
|
||||
]);
|
||||
|
||||
function isSidePanelTool(value: unknown): value is SidePanelTool {
|
||||
return typeof value === 'string' && SIDE_PANEL_TOOLS.has(value as SidePanelTool);
|
||||
return typeof value === 'string' && (SIDE_PANEL_TOOLS.has(value as SidePanelTool) || isPluginSidePanelTool(value));
|
||||
}
|
||||
|
||||
function sanitizeSidePanelLayoutNode(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -6,6 +6,7 @@ const { assertPluginContextKey, evaluateContextKeyExpression } = require("./cont
|
||||
const { assertPluginJsonValue } = require("./jsonBoundary.cjs");
|
||||
const { compileRestrictedJsonSchema } = require("./restrictedJsonSchema.cjs");
|
||||
const { PluginRpcError, RPC_ERRORS } = require("./rpcRouter.cjs");
|
||||
const { InteractiveCommandFeature, InteractiveCommandLimits } = require("./generated/plugin-contract.schema.json").$defs;
|
||||
|
||||
const MAX_SETTING_BYTES = 128 * 1024;
|
||||
const MAX_PATTERN_LENGTH = 512;
|
||||
@@ -149,6 +150,8 @@ class PluginContributionService {
|
||||
constructor(options) {
|
||||
this.database = options.database;
|
||||
this.runtimeSupervisor = options.runtimeSupervisor;
|
||||
this.onCommandFinished = options.onCommandFinished;
|
||||
this.activeCommandCalls = new Map();
|
||||
this.secretStore = options.secretStore;
|
||||
this.getLocale = options.getLocale ?? (() => "en");
|
||||
this.contextKeys = new Map();
|
||||
@@ -336,12 +339,31 @@ class PluginContributionService {
|
||||
throw new PluginRpcError(RPC_ERRORS.failedPrecondition, `Plugin command is disabled: ${commandId}`);
|
||||
}
|
||||
assertJsonValue(args ?? null, "command arguments");
|
||||
await this.#ensureActivated(plugin, `onCommand:${commandId}`);
|
||||
return this.runtimeSupervisor.request(plugin.id, "plugin.command.execute", {
|
||||
command: commandId,
|
||||
...(args === undefined ? {} : { args }),
|
||||
invocation: freezeJson({ source: invocation.source ?? "host", context: invocation.context ?? {} }),
|
||||
});
|
||||
if (contribution.interactive && !plugin.manifest.features?.required?.includes(InteractiveCommandFeature.const)) {
|
||||
throw new PluginRpcError(RPC_ERRORS.failedPrecondition, "Interactive command feature is required");
|
||||
}
|
||||
const identity = await this.#ensureActivated(plugin, `onCommand:${commandId}`);
|
||||
const runtimeId = identity?.runtimeId;
|
||||
if (runtimeId) this.activeCommandCalls.set(runtimeId, (this.activeCommandCalls.get(runtimeId) ?? 0) + 1);
|
||||
let failed = true;
|
||||
try {
|
||||
const result = await this.runtimeSupervisor.request(plugin.id, "plugin.command.execute", {
|
||||
command: commandId,
|
||||
...(args === undefined ? {} : { args }),
|
||||
invocation: freezeJson({ source: invocation.source ?? "host", context: invocation.context ?? {} }),
|
||||
}, { expectedIdentity: identity, ...(contribution.interactive ? { timeoutMs: InteractiveCommandLimits.const.timeoutMs } : {}) });
|
||||
failed = false;
|
||||
return result;
|
||||
} finally {
|
||||
if (runtimeId) {
|
||||
const remaining = Math.max(0, (this.activeCommandCalls.get(runtimeId) ?? 1) - 1);
|
||||
if (remaining) this.activeCommandCalls.set(runtimeId, remaining);
|
||||
else this.activeCommandCalls.delete(runtimeId);
|
||||
// A failed command must not leave a live native consent prompt. On
|
||||
// success wait for other concurrent commands before closing orphans.
|
||||
if (failed || remaining === 0) this.onCommandFinished?.(runtimeId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async activateView(viewId, params = {}) {
|
||||
|
||||
@@ -87,6 +87,7 @@ function setup(context, pluginManifest, options = {}) {
|
||||
runtimeSupervisor,
|
||||
secretStore,
|
||||
getLocale: () => "zh-CN",
|
||||
onCommandFinished: options.onCommandFinished,
|
||||
});
|
||||
return { calls, database, secretStore, service, secrets };
|
||||
}
|
||||
@@ -100,6 +101,48 @@ test("plugin contribution values enforce the exact JSON value boundary", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("only explicitly declared interactive commands receive the fixed budget and command completion is runtime-bound", async (t) => {
|
||||
const pluginManifest = manifest();
|
||||
delete pluginManifest.contributes.commands[0].enablement;
|
||||
pluginManifest.features = { required: ["commands.interactive"] };
|
||||
pluginManifest.contributes.commands.push({ id: `${pluginManifest.id}.interactive`, title: "Interactive", interactive: true });
|
||||
const optionsSeen = [], finished = [];
|
||||
const runtimeId = "runtime-budget";
|
||||
const { service } = setup(t, pluginManifest, {
|
||||
onCommandFinished: (id) => finished.push(id),
|
||||
runtimeSupervisor: {
|
||||
async start() { return { runtimeId, pluginVersion: pluginManifest.version }; },
|
||||
async request(_id, _method, _params, options) { optionsSeen.push(options); return null; },
|
||||
},
|
||||
});
|
||||
await service.executeCommand(`${pluginManifest.id}.hello`);
|
||||
await service.executeCommand(`${pluginManifest.id}.interactive`);
|
||||
assert.equal(Object.hasOwn(optionsSeen[0], "timeoutMs"), false, "ordinary commands retain the existing router deadline");
|
||||
assert.equal(optionsSeen[0].expectedIdentity.runtimeId, runtimeId);
|
||||
assert.equal(optionsSeen[1].timeoutMs, 180_000);
|
||||
assert.equal(optionsSeen[1].expectedIdentity.runtimeId, runtimeId);
|
||||
assert.deepEqual(finished, [runtimeId, runtimeId]);
|
||||
assert.equal(service.activeCommandCalls.size, 0);
|
||||
});
|
||||
|
||||
test("concurrent successful commands retain the runtime interaction until the last ends, while a failed command cancels it", async (t) => {
|
||||
const pluginManifest = manifest(); delete pluginManifest.contributes.commands[0].enablement;
|
||||
const pending = [], finished = [];
|
||||
const { service } = setup(t, pluginManifest, { onCommandFinished: (id) => finished.push(id),
|
||||
runtimeSupervisor: { async start() { return { runtimeId: "runtime-concurrent" }; },
|
||||
request() { return new Promise((resolve, reject) => pending.push({ resolve, reject })); } },
|
||||
});
|
||||
const first = service.executeCommand(`${pluginManifest.id}.hello`);
|
||||
const second = service.executeCommand(`${pluginManifest.id}.hello`);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
pending[0].resolve(null); await first;
|
||||
assert.deepEqual(finished, []);
|
||||
pending[1].reject(new Error("synthetic timeout"));
|
||||
await assert.rejects(second, /synthetic timeout/);
|
||||
assert.deepEqual(finished, ["runtime-concurrent"]);
|
||||
assert.equal(service.activeCommandCalls.size, 0);
|
||||
});
|
||||
|
||||
test("only onStartupFinished plugins activate during contribution initialization", async (context) => {
|
||||
const pluginManifest = manifest("com.example.startup", ["onStartupFinished"]);
|
||||
const { calls, service } = setup(context, pluginManifest);
|
||||
|
||||
@@ -231,6 +231,440 @@
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"HostInteractionLimits": {
|
||||
"const": {
|
||||
"textBytes": 4096,
|
||||
"requestBytes": 65536,
|
||||
"items": 100,
|
||||
"pendingPerRuntime": 1,
|
||||
"pendingTotal": 8,
|
||||
"requestsPerMinute": 10,
|
||||
"timeoutMs": 60000
|
||||
}
|
||||
},
|
||||
"HostInteractionFeature": {
|
||||
"const": "host.interactions"
|
||||
},
|
||||
"InteractiveCommandFeature": {
|
||||
"const": "commands.interactive"
|
||||
},
|
||||
"InteractiveCommandLimits": {
|
||||
"const": {
|
||||
"timeoutMs": 180000
|
||||
}
|
||||
},
|
||||
"HostQuickPickItem": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"id",
|
||||
"label"
|
||||
],
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 128
|
||||
},
|
||||
"label": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 512
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"maxLength": 512
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"HostQuickPickRequest": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"kind",
|
||||
"title",
|
||||
"items"
|
||||
],
|
||||
"properties": {
|
||||
"kind": {
|
||||
"const": "quickPick"
|
||||
},
|
||||
"title": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 128,
|
||||
"pattern": "^[^\\u0000-\\u001f\\u007f]*$"
|
||||
},
|
||||
"items": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"maxItems": 100,
|
||||
"items": {
|
||||
"$ref": "#/$defs/HostQuickPickItem"
|
||||
}
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"HostSingleLineInputText": {
|
||||
"description": "Single-line host input. CR and LF are rejected before display and on submission; clipboard text remains multiline.",
|
||||
"type": "string",
|
||||
"maxLength": 4096,
|
||||
"pattern": "^[^\\u0000\\r\\n]*$"
|
||||
},
|
||||
"HostInputRequest": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"kind",
|
||||
"title"
|
||||
],
|
||||
"properties": {
|
||||
"kind": {
|
||||
"const": "input"
|
||||
},
|
||||
"title": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 128,
|
||||
"pattern": "^[^\\u0000-\\u001f\\u007f]*$"
|
||||
},
|
||||
"initialValue": {
|
||||
"$ref": "#/$defs/HostSingleLineInputText"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"HostClipboardReadRequest": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"kind",
|
||||
"title"
|
||||
],
|
||||
"properties": {
|
||||
"kind": {
|
||||
"const": "clipboard.read"
|
||||
},
|
||||
"title": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 128,
|
||||
"pattern": "^[^\\u0000-\\u001f\\u007f]*$"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"HostClipboardWriteRequest": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"kind",
|
||||
"title",
|
||||
"text"
|
||||
],
|
||||
"properties": {
|
||||
"kind": {
|
||||
"const": "clipboard.write"
|
||||
},
|
||||
"title": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 128,
|
||||
"pattern": "^[^\\u0000-\\u001f\\u007f]*$"
|
||||
},
|
||||
"text": {
|
||||
"type": "string",
|
||||
"maxLength": 4096,
|
||||
"pattern": "^[^\\u0000]*$"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"HostInteractionRequest": {
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/$defs/HostQuickPickRequest"
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/HostInputRequest"
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/HostClipboardReadRequest"
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/HostClipboardWriteRequest"
|
||||
}
|
||||
]
|
||||
},
|
||||
"HostInteractionResult": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"required": [
|
||||
"cancelled"
|
||||
],
|
||||
"properties": {
|
||||
"cancelled": {
|
||||
"const": true
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"required": [
|
||||
"cancelled",
|
||||
"value"
|
||||
],
|
||||
"properties": {
|
||||
"cancelled": {
|
||||
"const": false
|
||||
},
|
||||
"value": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"maxLength": 4096,
|
||||
"pattern": "^[^\\u0000]*$"
|
||||
},
|
||||
{
|
||||
"const": true
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
]
|
||||
},
|
||||
"HostInteractionResponse": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"requestId",
|
||||
"actionToken",
|
||||
"action"
|
||||
],
|
||||
"properties": {
|
||||
"requestId": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 128
|
||||
},
|
||||
"actionToken": {
|
||||
"type": "string",
|
||||
"minLength": 32,
|
||||
"maxLength": 256
|
||||
},
|
||||
"action": {
|
||||
"enum": [
|
||||
"cancel",
|
||||
"preview",
|
||||
"submit"
|
||||
]
|
||||
},
|
||||
"value": {
|
||||
"type": "string",
|
||||
"maxLength": 4096,
|
||||
"pattern": "^[^\\u0000]*$"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"HostInteractionClosed": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"requestId",
|
||||
"closed"
|
||||
],
|
||||
"properties": {
|
||||
"requestId": {
|
||||
"type": "string",
|
||||
"maxLength": 128
|
||||
},
|
||||
"closed": {
|
||||
"const": true
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"HostInteractionEvent": {
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/$defs/HostInteractionPrompt"
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/HostInteractionClosed"
|
||||
}
|
||||
]
|
||||
},
|
||||
"HostInteractionPrompt": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"requestId",
|
||||
"actionToken",
|
||||
"pluginId",
|
||||
"pluginVersion",
|
||||
"expiresAt",
|
||||
"phase",
|
||||
"request"
|
||||
],
|
||||
"properties": {
|
||||
"requestId": {
|
||||
"type": "string",
|
||||
"maxLength": 128
|
||||
},
|
||||
"actionToken": {
|
||||
"type": "string",
|
||||
"maxLength": 256
|
||||
},
|
||||
"pluginId": {
|
||||
"$ref": "#/$defs/PluginId"
|
||||
},
|
||||
"pluginVersion": {
|
||||
"$ref": "#/$defs/SemanticVersion"
|
||||
},
|
||||
"expiresAt": {
|
||||
"$ref": "#/$defs/SafePositiveInteger"
|
||||
},
|
||||
"phase": {
|
||||
"enum": [
|
||||
"request",
|
||||
"preview"
|
||||
]
|
||||
},
|
||||
"request": {
|
||||
"$ref": "#/$defs/HostInteractionRequest"
|
||||
},
|
||||
"previewText": {
|
||||
"type": "string",
|
||||
"maxLength": 4096,
|
||||
"pattern": "^[^\\u0000]*$"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"NetworkCredentialLeaseFeature": {
|
||||
"const": "network.credential-lease"
|
||||
},
|
||||
"PluginNetworkAuthorization": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"scheme": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"Basic",
|
||||
"Bearer"
|
||||
]
|
||||
},
|
||||
"username": {
|
||||
"type": "string",
|
||||
"maxLength": 8192,
|
||||
"pattern": "^[^\\u0000-\\u001f\\u007f]*$"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"PluginNetworkRequest": {
|
||||
"description": "Origin-authorized HTTP request. The host consumes a one-use credentialLease for network:<canonical origin>; plaintext credentials never return to the plugin.",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"url"
|
||||
],
|
||||
"properties": {
|
||||
"url": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 8192
|
||||
},
|
||||
"method": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"GET",
|
||||
"POST",
|
||||
"PUT",
|
||||
"PATCH",
|
||||
"DELETE",
|
||||
"HEAD"
|
||||
]
|
||||
},
|
||||
"headers": {
|
||||
"type": "object",
|
||||
"maxProperties": 64,
|
||||
"additionalProperties": {
|
||||
"type": "string",
|
||||
"maxLength": 8192
|
||||
}
|
||||
},
|
||||
"body": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"encoding",
|
||||
"data"
|
||||
],
|
||||
"properties": {
|
||||
"encoding": {
|
||||
"enum": [
|
||||
"utf8",
|
||||
"base64"
|
||||
]
|
||||
},
|
||||
"data": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"timeoutMs": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 300000
|
||||
},
|
||||
"credentialLease": {
|
||||
"$ref": "#/$defs/SecretLeaseRef"
|
||||
},
|
||||
"authorization": {
|
||||
"$ref": "#/$defs/PluginNetworkAuthorization"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"PluginNetworkResponse": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"url",
|
||||
"status",
|
||||
"headers",
|
||||
"body"
|
||||
],
|
||||
"properties": {
|
||||
"url": {
|
||||
"type": "string"
|
||||
},
|
||||
"status": {
|
||||
"type": "integer"
|
||||
},
|
||||
"headers": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"body": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"encoding",
|
||||
"data"
|
||||
],
|
||||
"properties": {
|
||||
"encoding": {
|
||||
"const": "base64"
|
||||
},
|
||||
"data": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"SemanticVersion": {
|
||||
"type": "string",
|
||||
"pattern": "^(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)(?:-(?:0|[1-9]\\d*|\\d*[A-Za-z-][0-9A-Za-z-]*)(?:\\.(?:0|[1-9]\\d*|\\d*[A-Za-z-][0-9A-Za-z-]*))*)?(?:\\+[0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*)?$"
|
||||
@@ -273,6 +707,7 @@
|
||||
"commands",
|
||||
"menus",
|
||||
"views",
|
||||
"ui.interact",
|
||||
"clipboard.read",
|
||||
"clipboard.write",
|
||||
"terminal.metadata",
|
||||
@@ -310,6 +745,7 @@
|
||||
"commands",
|
||||
"menus",
|
||||
"views",
|
||||
"ui.interact",
|
||||
"clipboard.read",
|
||||
"clipboard.write",
|
||||
"terminal.metadata",
|
||||
@@ -746,6 +1182,11 @@
|
||||
},
|
||||
"enablement": {
|
||||
"$ref": "#/$defs/ContextKeyExpression"
|
||||
},
|
||||
"interactive": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "Require commands.interactive for this command's fixed bounded host interaction budget. Does not grant UI or clipboard permissions."
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
@@ -852,9 +1293,34 @@
|
||||
"panel",
|
||||
"tab",
|
||||
"modal",
|
||||
"settings"
|
||||
"settings",
|
||||
"vault-page",
|
||||
"terminal-sidebar"
|
||||
]
|
||||
},
|
||||
"VaultPagesFeature": {
|
||||
"const": "views.vault-pages"
|
||||
},
|
||||
"TerminalSidebarFeature": {
|
||||
"const": "views.terminal-sidebar"
|
||||
},
|
||||
"PluginViewFocusEvent": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"instanceId"
|
||||
],
|
||||
"properties": {
|
||||
"instanceId": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 128
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"PluginViewFocusRequest": {
|
||||
"$ref": "#/$defs/PluginViewFocusEvent"
|
||||
},
|
||||
"ViewContribution": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
"use strict";
|
||||
const assert = require("node:assert/strict");
|
||||
const { readFile } = require("node:fs/promises");
|
||||
const { EventEmitter } = require("node:events");
|
||||
const { pathToFileURL } = require("node:url");
|
||||
const test = require("node:test");
|
||||
const { MessageChannel } = require("node:worker_threads");
|
||||
const { PluginRpcRouter } = require("./rpcRouter.cjs");
|
||||
const { PluginHostRpcRegistry } = require("./hostRpcRegistry.cjs");
|
||||
const { PluginPermissionEngine } = require("./permissionEngine.cjs");
|
||||
const { PluginHostInteractionService } = require("./hostInteractionService.cjs");
|
||||
const { PluginContributionService } = require("./contributionService.cjs");
|
||||
|
||||
for (const source of ["compose", "share"]) test(`normal browser example completes the ${source} text workflow through permission middleware`, async () => {
|
||||
const { startPluginRuntime } = await import("./runtime/runtimePeer.mjs");
|
||||
const plugin = await import("../../examples/plugins/host-interactions/browser.js");
|
||||
const manifest = JSON.parse(await readFile(new URL("../../examples/plugins/host-interactions/netcatty.plugin.json", pathToFileURL(__filename)), "utf8"));
|
||||
const audit = [], copied = [], requests = [];
|
||||
let reads = 0, active = true;
|
||||
const database = { listPermissionGrants: () => [], recordSecurityAudit(...args) { audit.push(args); } };
|
||||
const permissionEngine = new PluginPermissionEngine({ database,
|
||||
requestDecision: async (request) => { requests.push(request.permission); return { requestId: request.requestId, decision: "allow", scope: "once" }; },
|
||||
});
|
||||
let service;
|
||||
const sender = Object.assign(new EventEmitter(), { id: 7, isDestroyed: () => false,
|
||||
send(_channel, message) {
|
||||
if (message.closed) return;
|
||||
queueMicrotask(() => {
|
||||
const action = message.request.kind === "clipboard.read" && message.phase === "request" ? "preview" : "submit";
|
||||
const value = message.request.kind === "quickPick" ? source : message.request.kind === "input" ? "synthetic output" : undefined;
|
||||
void service.respond(sender, { requestId: message.requestId, actionToken: message.actionToken, action, ...(value === undefined ? {} : { value }) }).catch(() => {});
|
||||
});
|
||||
},
|
||||
});
|
||||
const window = Object.assign(new EventEmitter(), { webContents: sender, isFocused: () => true, isVisible: () => true, isDestroyed: () => false });
|
||||
service = new PluginHostInteractionService({ database, permissionEngine, getWindow: () => window, isLocked: () => false,
|
||||
quotaManager: { chargeBytes() {} },
|
||||
clipboard: { readText() { reads++; return "synthetic private text"; }, writeText(text) { copied.push(text); } },
|
||||
});
|
||||
service.setReady(sender, true);
|
||||
const registry = new PluginHostRpcRegistry();
|
||||
registry.use(permissionEngine.createMiddleware()); service.registerRpcCapabilities(registry);
|
||||
const routes = registry.createRoutes({ pluginId: manifest.id, pluginVersion: manifest.version, runtimeId: "runtime-synthetic", runtimeKind: "browser", manifest,
|
||||
assertCurrent() { if (!active) throw new Error("stale runtime"); },
|
||||
});
|
||||
const { port1, port2 } = new MessageChannel();
|
||||
const host = new PluginRpcRouter({ pluginId: manifest.id, send: (message) => port1.postMessage(message), handlers: routes.requestHandlers });
|
||||
port1.on("message", (message) => { void host.accept(message); });
|
||||
const peer = await startPluginRuntime({ port: port2,
|
||||
config: { pluginId: manifest.id, pluginVersion: manifest.version, netcattyVersion: "0.0.0", apiVersion: "0.1.0-internal.1", enabledFeatures: ["host.interactions"] },
|
||||
loadPlugin: async () => plugin,
|
||||
});
|
||||
try {
|
||||
await host.request("plugin.initialize", { netcattyVersion: "0.0.0", apiVersion: "0.1.0-internal.1", supportedFeatures: ["host.interactions"] });
|
||||
await host.request("plugin.activate", {});
|
||||
const result = await host.request("plugin.command.execute", { command: "com.netcatty.host-interactions.text", invocation: { source: "palette" } });
|
||||
const output = source === "compose" ? "synthetic output" : "SYNTHETIC PRIVATE TEXT";
|
||||
assert.deepEqual(result, { copied: true, characters: output.length });
|
||||
assert.deepEqual(copied, [output]); assert.equal(reads, source === "share" ? 1 : 0);
|
||||
assert.ok(requests.includes("ui.interact")); assert.ok(requests.includes("clipboard.write"));
|
||||
assert.equal(requests.includes("clipboard.read"), source === "share");
|
||||
assert.equal(JSON.stringify(audit).includes("synthetic private text"), false);
|
||||
assert.equal(JSON.stringify(audit).includes("synthetic output"), false);
|
||||
assert.equal(service.pending.size, 0);
|
||||
} finally { active = false; await peer.dispose(); host.close(); port1.close(); service.shutdown(); permissionEngine.shutdown(); }
|
||||
});
|
||||
|
||||
test("outer command deadline closes its real browser clipboard confirmation and rejects late commit", async () => {
|
||||
const { startPluginRuntime } = await import("./runtime/runtimePeer.mjs");
|
||||
const plugin = await import("../../examples/plugins/host-interactions/browser.js");
|
||||
const manifest = JSON.parse(await readFile(new URL("../../examples/plugins/host-interactions/netcatty.plugin.json", pathToFileURL(__filename)), "utf8"));
|
||||
const identity = { pluginId: manifest.id, pluginVersion: manifest.version, runtimeId: "runtime-deadline", runtimeKind: "browser" };
|
||||
const writes = [], messages = [];
|
||||
const database = { listPermissionGrants: () => [], recordSecurityAudit() {},
|
||||
listPlugins: () => [{ id: manifest.id, activeVersion: manifest.version, manifest, enabled: true, runtime: { status: "running" } }],
|
||||
};
|
||||
const engine = new PluginPermissionEngine({ database, requestDecision: async (request) => ({ requestId: request.requestId, decision: "allow", scope: "once" }) });
|
||||
let service, confirm;
|
||||
const copyPrompt = new Promise((resolve) => { confirm = resolve; });
|
||||
const sender = Object.assign(new EventEmitter(), { id: 8, isDestroyed: () => false, send(_channel, message) {
|
||||
messages.push(message); if (message.closed) return;
|
||||
if (message.request.kind === "clipboard.write") { confirm(message); return; }
|
||||
const value = message.request.kind === "quickPick" ? "compose" : "synthetic pending copy";
|
||||
queueMicrotask(() => { void service.respond(sender, { requestId: message.requestId, actionToken: message.actionToken, action: "submit", value }).catch(() => {}); });
|
||||
} });
|
||||
const owner = Object.assign(new EventEmitter(), { webContents: sender, isFocused: () => true, isVisible: () => true, isDestroyed: () => false });
|
||||
service = new PluginHostInteractionService({ database, permissionEngine: engine, getWindow: () => owner, isLocked: () => false,
|
||||
quotaManager: { chargeBytes() {} }, clipboard: { writeText(text) { writes.push(text); } } });
|
||||
service.setReady(sender, true);
|
||||
const registry = new PluginHostRpcRegistry(); registry.use(engine.createMiddleware()); service.registerRpcCapabilities(registry);
|
||||
const routes = registry.createRoutes({ ...identity, manifest, assertCurrent() {} });
|
||||
const { port1, port2 } = new MessageChannel();
|
||||
const host = new PluginRpcRouter({ pluginId: manifest.id, send: (message) => port1.postMessage(message), handlers: routes.requestHandlers });
|
||||
port1.on("message", (message) => { void host.accept(message); });
|
||||
const features = manifest.features.required;
|
||||
const peer = await startPluginRuntime({ port: port2, config: { ...identity, netcattyVersion: "0.0.0", apiVersion: "0.1.0-internal.1", enabledFeatures: features }, loadPlugin: async () => plugin });
|
||||
try {
|
||||
await host.request("plugin.initialize", { netcattyVersion: "0.0.0", apiVersion: "0.1.0-internal.1", supportedFeatures: features });
|
||||
await host.request("plugin.activate", {});
|
||||
const contributions = new PluginContributionService({ database, onCommandFinished: (runtimeId) => service.cancelRuntimeRequests(runtimeId),
|
||||
runtimeSupervisor: { async start() { return identity; }, request(_pluginId, method, params, options) {
|
||||
assert.equal(options.timeoutMs, 180_000, "host chooses the fixed declared budget");
|
||||
return host.request(method, params, { timeoutMs: 500 }); // bounded synthetic timer, no production timeout change
|
||||
} },
|
||||
});
|
||||
const command = contributions.executeCommand("com.netcatty.host-interactions.text");
|
||||
const prompt = await Promise.race([copyPrompt, command.then(() => { throw new Error("command finished before confirmation"); })]);
|
||||
const attempts = service.rates.get(identity.runtimeId).count;
|
||||
await assert.rejects(command, /timed out/);
|
||||
assert.equal(service.pending.size, 0);
|
||||
assert.equal(service.rates.get(identity.runtimeId).count, attempts);
|
||||
assert.ok(messages.some((message) => message.closed && message.requestId === prompt.requestId));
|
||||
assert.equal(await service.respond(sender, { requestId: prompt.requestId, actionToken: prompt.actionToken, action: "submit" }), false);
|
||||
assert.deepEqual(writes, []);
|
||||
assert.equal(contributions.activeCommandCalls.size, 0);
|
||||
} finally { await peer.dispose(); host.close(); port1.close(); service.shutdown(); engine.shutdown(); }
|
||||
});
|
||||
@@ -0,0 +1,339 @@
|
||||
"use strict";
|
||||
|
||||
const { createHmac, randomBytes, randomUUID, timingSafeEqual } = require("node:crypto");
|
||||
const { createDefinitionValidator } = require("./contractValidator.cjs");
|
||||
const { PluginRpcError, RPC_ERRORS } = require("./rpcRouter.cjs");
|
||||
const schema = require("./generated/plugin-contract.schema.json");
|
||||
const LIMITS = schema.$defs.HostInteractionLimits.const;
|
||||
const HOST_INTERACTION_FEATURE = schema.$defs.HostInteractionFeature.const;
|
||||
const validateRequest = createDefinitionValidator("HostInteractionRequest");
|
||||
const validateResponse = createDefinitionValidator("HostInteractionResponse");
|
||||
const validateResult = createDefinitionValidator("HostInteractionResult");
|
||||
const validateSingleLineInput = createDefinitionValidator("HostSingleLineInputText");
|
||||
const PROMPT_CHANNEL = "netcatty:plugins:host-interaction";
|
||||
|
||||
function fail(code, message) { return new PluginRpcError(RPC_ERRORS[code], message); }
|
||||
function assertTextBytes(text) {
|
||||
if (typeof text !== "string" || Buffer.byteLength(text, "utf8") > LIMITS.textBytes || text.includes("\0")) {
|
||||
throw fail("resourceExhausted", "Host interaction text exceeds its limit");
|
||||
}
|
||||
return text;
|
||||
}
|
||||
function assertInteractionRequest(params) {
|
||||
if (!validateRequest(params)) throw fail("invalidArgument", "Host interaction request is invalid");
|
||||
if (Buffer.byteLength(JSON.stringify(params)) > LIMITS.requestBytes) {
|
||||
throw fail("resourceExhausted", "Host interaction request exceeds its limit");
|
||||
}
|
||||
if (params.kind === "input") assertTextBytes(params.initialValue ?? "");
|
||||
if (params.kind === "clipboard.write") assertTextBytes(params.text);
|
||||
if (params.kind === "quickPick" && new Set(params.items.map((item) => item.id)).size !== params.items.length) {
|
||||
throw fail("invalidArgument", "Host interaction choice IDs must be unique");
|
||||
}
|
||||
return Object.freeze(structuredClone(params));
|
||||
}
|
||||
function interactionAuthorization(params) {
|
||||
const permission = params.kind === "clipboard.read" ? "clipboard.read"
|
||||
: params.kind === "clipboard.write" ? "clipboard.write" : "ui.interact";
|
||||
return {
|
||||
permission,
|
||||
resources: [permission.startsWith("clipboard.") ? "clipboard:text" : "*"],
|
||||
reason: permission.startsWith("clipboard.") ? "Explicitly share or replace clipboard text" : "Show a host input or choice dialog",
|
||||
operationId: `ui:${params.kind}`,
|
||||
...(permission.startsWith("clipboard.") ? { allowedScopes: ["once"] } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
class PluginHostInteractionService {
|
||||
constructor(options) {
|
||||
this.database = options.database;
|
||||
this.permissionEngine = options.permissionEngine;
|
||||
this.quotaManager = options.quotaManager;
|
||||
this.clipboard = options.clipboard;
|
||||
this.getWindow = options.getWindow ?? (() => null);
|
||||
this.isLocked = options.isLocked ?? (() => true);
|
||||
this.clock = options.clock ?? Date.now;
|
||||
this.timeoutMs = Math.min(options.timeoutMs ?? LIMITS.timeoutMs, LIMITS.timeoutMs);
|
||||
this.key = randomBytes(32);
|
||||
this.pending = new Map();
|
||||
this.rates = new Map();
|
||||
this.admissions = new WeakMap();
|
||||
this.admissionGuards = new Map();
|
||||
this.admissionRevision = 0;
|
||||
this.focusRestoreMs = Math.min(options.focusRestoreMs ?? 1_000, 1_000);
|
||||
this.owner = null;
|
||||
this.ownerCleanup = null;
|
||||
this.closed = false;
|
||||
this.revocation = this.permissionEngine.onDidRevoke((event) => this.releasePlugin(event.pluginId));
|
||||
this.unsubscribeLock = options.subscribeLock?.((state) => { if (state.locked) this.cancelAll(); });
|
||||
}
|
||||
|
||||
registerRpcCapabilities(registry) {
|
||||
registry.registerRequest("ui.interact", (params, context) => this.request(params, context), {
|
||||
metadata: { capability: "ui", mutating: true },
|
||||
validateParams: assertInteractionRequest,
|
||||
authorization: (params, context) => {
|
||||
const sender = this.#admit(params, context);
|
||||
let guard = this.admissionGuards.get(context.runtimeId);
|
||||
if (!guard) { guard = { active: true }; this.admissionGuards.set(context.runtimeId, guard); }
|
||||
this.admissions.set(params, { sender, runtimeId: context.runtimeId, revision: this.admissionRevision, guard });
|
||||
return interactionAuthorization(params);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
setReady(sender, ready) {
|
||||
const window = this.getWindow();
|
||||
if (!sender || window?.webContents !== sender || sender.isDestroyed?.()) {
|
||||
throw fail("permissionDenied", "Host interaction window is not the owner");
|
||||
}
|
||||
if (typeof ready !== "boolean") throw fail("invalidArgument", "Host interaction readiness is invalid");
|
||||
if (!ready) { this.releaseWindow(sender); return true; }
|
||||
if (this.closed) throw fail("unavailable", "Host interactions are unavailable");
|
||||
if (this.owner === sender) return true;
|
||||
if (this.owner) this.releaseWindow(this.owner);
|
||||
this.owner = sender;
|
||||
const close = () => this.releaseWindow(sender);
|
||||
// A native permission dialog can temporarily blur its parent. Already
|
||||
// displayed interactions still cancel immediately; pre-prompt admission
|
||||
// can only resume once this same owner actually regains focus.
|
||||
const blur = () => this.cancelAll(false);
|
||||
const hide = () => this.cancelAll();
|
||||
const navigate = (details, _url, isInPlace, isMainFrame) => {
|
||||
const main = details?.isMainFrame ?? isMainFrame;
|
||||
const sameDocument = details?.isSameDocument ?? isInPlace;
|
||||
if (main !== false && sameDocument !== true) close();
|
||||
};
|
||||
sender.once?.("destroyed", close);
|
||||
sender.on?.("did-start-navigation", navigate);
|
||||
sender.on?.("render-process-gone", close);
|
||||
window.on?.("blur", blur);
|
||||
window.on?.("hide", hide);
|
||||
this.ownerCleanup = () => {
|
||||
sender.removeListener?.("destroyed", close);
|
||||
sender.removeListener?.("did-start-navigation", navigate);
|
||||
sender.removeListener?.("render-process-gone", close);
|
||||
window.removeListener?.("blur", blur);
|
||||
window.removeListener?.("hide", hide);
|
||||
};
|
||||
return true;
|
||||
}
|
||||
|
||||
#assertWindow(sender = this.owner) {
|
||||
const window = this.getWindow();
|
||||
if (this.closed || this.isLocked() || !sender || sender !== this.owner
|
||||
|| window?.webContents !== sender || window.isDestroyed?.()
|
||||
|| sender.isDestroyed?.() || window.isVisible?.() === false || window.isFocused?.() !== true) {
|
||||
throw fail("unavailable", "Host interactions require the focused unlocked main window");
|
||||
}
|
||||
return sender;
|
||||
}
|
||||
|
||||
#token(entry) {
|
||||
const nonce = randomBytes(24).toString("base64url");
|
||||
const binding = JSON.stringify([
|
||||
nonce, entry.id, entry.context.pluginId, entry.context.pluginVersion,
|
||||
entry.context.runtimeId, entry.sender.id, entry.params.kind, entry.phase, entry.expiresAt,
|
||||
]);
|
||||
return `${nonce}.${createHmac("sha256", this.key).update(binding).digest("base64url")}`;
|
||||
}
|
||||
|
||||
#present(entry) {
|
||||
entry.actionToken = this.#token(entry);
|
||||
entry.sender.send(PROMPT_CHANNEL, {
|
||||
requestId: entry.id, actionToken: entry.actionToken,
|
||||
pluginId: entry.context.pluginId, pluginVersion: entry.context.pluginVersion,
|
||||
expiresAt: entry.expiresAt, phase: entry.phase, request: entry.params,
|
||||
...(entry.previewText === undefined ? {} : { previewText: entry.previewText }),
|
||||
});
|
||||
}
|
||||
|
||||
#finish(entry, result, error) {
|
||||
if (this.pending.get(entry.id) !== entry) return false;
|
||||
this.pending.delete(entry.id);
|
||||
entry.actionToken = null;
|
||||
entry.previewText = undefined;
|
||||
clearTimeout(entry.timer);
|
||||
entry.context.signal?.removeEventListener("abort", entry.onAbort);
|
||||
try { entry.sender.send(PROMPT_CHANNEL, { requestId: entry.id, closed: true }); } catch {}
|
||||
if (error) entry.reject(error);
|
||||
else if (!validateResult(result)) entry.reject(fail("internal", "Host interaction result is invalid"));
|
||||
else entry.resolve(result);
|
||||
return true;
|
||||
}
|
||||
|
||||
#audit(entry, action) {
|
||||
// Fixed metadata only. Never record title, choices, user input, clipboard
|
||||
// bytes, action token or a hash derived from user content.
|
||||
try {
|
||||
this.database.recordSecurityAudit(entry.context.pluginId, "ui.interaction", { kind: entry.params.kind, action });
|
||||
} catch {
|
||||
throw fail("unavailable", "Host interaction audit is unavailable");
|
||||
}
|
||||
}
|
||||
|
||||
#admit(params, context) {
|
||||
const sender = this.#assertWindow();
|
||||
if ((params.kind === "clipboard.read" && typeof this.clipboard?.readText !== "function")
|
||||
|| (params.kind === "clipboard.write" && typeof this.clipboard?.writeText !== "function")) {
|
||||
throw fail("unavailable", "Host clipboard text is unavailable");
|
||||
}
|
||||
const now = this.clock();
|
||||
const rate = this.rates.get(context.runtimeId);
|
||||
const nextRate = !rate || now - rate.startedAt >= 60_000 ? { startedAt: now, count: 0 } : rate;
|
||||
if (nextRate.count >= LIMITS.requestsPerMinute || this.pending.size >= LIMITS.pendingTotal
|
||||
|| [...this.pending.values()].some((entry) => entry.context.runtimeId === context.runtimeId || entry.sender === sender)) {
|
||||
throw fail("resourceExhausted", "Host interaction limit reached");
|
||||
}
|
||||
nextRate.count++;
|
||||
this.rates.set(context.runtimeId, nextRate);
|
||||
this.quotaManager.chargeBytes(context.runtimeId, "ui", Buffer.byteLength(JSON.stringify(params)));
|
||||
return sender;
|
||||
}
|
||||
|
||||
async request(params, context) {
|
||||
await context.assertActive();
|
||||
const admission = this.admissions.get(params);
|
||||
this.admissions.delete(params);
|
||||
let sender;
|
||||
if (admission?.runtimeId === context.runtimeId) {
|
||||
// Electron may resolve showMessageBox before restoring parent focus.
|
||||
// Never focus it ourselves or defer an originally background request.
|
||||
// Owner replacement, lock, hide, revoke and runtime stop invalidate this
|
||||
// host-owned admission, including changes during the permission prompt.
|
||||
const deadline = Date.now() + this.focusRestoreMs;
|
||||
for (;;) {
|
||||
context.signal?.throwIfAborted();
|
||||
if (!admission.guard.active || admission.revision !== this.admissionRevision || admission.sender !== this.owner) {
|
||||
throw fail("cancelled", "Host interaction admission was revoked");
|
||||
}
|
||||
const window = this.getWindow();
|
||||
if (this.closed || this.isLocked() || window?.webContents !== admission.sender
|
||||
|| window.isDestroyed?.() || admission.sender.isDestroyed?.() || window.isVisible?.() === false) {
|
||||
throw fail("unavailable", "Host interaction owner is unavailable");
|
||||
}
|
||||
if (window.isFocused?.() === true) break;
|
||||
if (Date.now() >= deadline) throw fail("unavailable", "Host interactions require the focused unlocked main window");
|
||||
await new Promise((resolve) => setTimeout(resolve, Math.min(20, Math.max(1, deadline - Date.now()))));
|
||||
}
|
||||
await context.assertActive();
|
||||
if (!admission.guard.active || admission.revision !== this.admissionRevision) throw fail("cancelled", "Host interaction admission was revoked");
|
||||
sender = this.#assertWindow(admission.sender);
|
||||
} else sender = this.#admit(params, context);
|
||||
if (this.pending.size >= LIMITS.pendingTotal || [...this.pending.values()].some((entry) => entry.context.runtimeId === context.runtimeId || entry.sender === sender)) {
|
||||
throw fail("resourceExhausted", "Host interaction limit reached");
|
||||
}
|
||||
const now = this.clock();
|
||||
return new Promise((resolve, reject) => {
|
||||
const entry = {
|
||||
id: randomUUID(), context, params, sender, resolve, reject,
|
||||
expiresAt: now + this.timeoutMs, phase: "request", actionToken: null,
|
||||
};
|
||||
entry.onAbort = () => this.#finish(entry, null, fail("cancelled", "Host interaction was cancelled"));
|
||||
entry.timer = setTimeout(() => this.#finish(entry, null, fail("deadlineExceeded", "Host interaction expired")), this.timeoutMs);
|
||||
entry.timer.unref?.();
|
||||
this.pending.set(entry.id, entry);
|
||||
context.signal?.addEventListener("abort", entry.onAbort, { once: true });
|
||||
if (context.signal?.aborted) { entry.onAbort(); return; }
|
||||
try { this.#present(entry); }
|
||||
catch { this.#finish(entry, null, fail("unavailable", "Host interaction window is unavailable")); }
|
||||
});
|
||||
}
|
||||
|
||||
async respond(sender, response) {
|
||||
this.#assertWindow(sender);
|
||||
if (!validateResponse(response)) throw fail("invalidArgument", "Host interaction response is invalid");
|
||||
const entry = this.pending.get(response.requestId);
|
||||
if (!entry || entry.sender !== sender || !entry.actionToken || this.clock() >= entry.expiresAt) return false;
|
||||
const expected = Buffer.from(entry.actionToken);
|
||||
const supplied = Buffer.from(response.actionToken);
|
||||
if (expected.length !== supplied.length || !timingSafeEqual(expected, supplied)) return false;
|
||||
// Consume before the first asynchronous boundary. Duplicate and old-phase
|
||||
// replies cannot race the same native action.
|
||||
entry.actionToken = null;
|
||||
try {
|
||||
await entry.context.assertActive();
|
||||
this.#assertWindow(sender);
|
||||
if (this.pending.get(entry.id) !== entry) return false;
|
||||
if (response.action === "cancel") {
|
||||
this.#audit(entry, "cancelled");
|
||||
return this.#finish(entry, { cancelled: true });
|
||||
}
|
||||
if (entry.params.kind === "clipboard.read" && entry.phase === "request") {
|
||||
if (response.action !== "preview") throw fail("invalidArgument", "Clipboard sharing requires a preview action");
|
||||
this.#audit(entry, "preview-confirmed");
|
||||
let rawText;
|
||||
try { rawText = this.clipboard.readText(); }
|
||||
catch { throw fail("unavailable", "Host clipboard text is unavailable"); }
|
||||
const text = assertTextBytes(rawText);
|
||||
this.quotaManager.chargeBytes(entry.context.runtimeId, "clipboard", Buffer.byteLength(text));
|
||||
entry.previewText = text;
|
||||
entry.phase = "preview";
|
||||
try { this.#present(entry); }
|
||||
catch { throw fail("unavailable", "Host interaction window is unavailable"); }
|
||||
return true;
|
||||
}
|
||||
if (response.action !== "submit") throw fail("invalidArgument", "Host interaction action is invalid");
|
||||
let value;
|
||||
if (entry.params.kind === "input") {
|
||||
if (!validateSingleLineInput(response.value)) throw fail("invalidArgument", "Host input must be a single line");
|
||||
value = assertTextBytes(response.value);
|
||||
}
|
||||
else if (entry.params.kind === "quickPick") {
|
||||
if (!entry.params.items.some((item) => item.id === response.value)) throw fail("invalidArgument", "Host interaction choice is invalid");
|
||||
value = response.value;
|
||||
} else if (entry.params.kind === "clipboard.read") value = entry.previewText;
|
||||
else value = true;
|
||||
this.#audit(entry, entry.params.kind === "clipboard.write" ? "copy-confirmed" : "share-confirmed");
|
||||
if (entry.params.kind === "clipboard.write") {
|
||||
try { this.clipboard.writeText(entry.params.text); }
|
||||
catch { throw fail("unavailable", "Host clipboard write failed"); }
|
||||
}
|
||||
return this.#finish(entry, { cancelled: false, value });
|
||||
} catch (error) {
|
||||
this.#finish(entry, null, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
releaseWindow(sender) {
|
||||
if (this.owner !== sender) return;
|
||||
this.admissionRevision++;
|
||||
this.owner = null;
|
||||
this.ownerCleanup?.(); this.ownerCleanup = null;
|
||||
for (const entry of [...this.pending.values()]) {
|
||||
if (entry.sender === sender) this.#finish(entry, null, fail("cancelled", "Host interaction window closed"));
|
||||
}
|
||||
}
|
||||
releasePlugin(pluginId) {
|
||||
this.admissionRevision++;
|
||||
for (const entry of [...this.pending.values()]) {
|
||||
if (entry.context.pluginId === pluginId) this.#finish(entry, null, fail("cancelled", "Plugin access was revoked"));
|
||||
}
|
||||
}
|
||||
releaseRuntime(runtimeId) {
|
||||
this.cancelRuntimeRequests(runtimeId, "Plugin runtime stopped");
|
||||
this.rates.delete(runtimeId);
|
||||
}
|
||||
cancelRuntimeRequests(runtimeId, reason = "Host command finished") {
|
||||
const guard = this.admissionGuards.get(runtimeId);
|
||||
if (guard) guard.active = false;
|
||||
this.admissionGuards.delete(runtimeId);
|
||||
for (const entry of [...this.pending.values()]) {
|
||||
if (entry.context.runtimeId === runtimeId) this.#finish(entry, null, fail("cancelled", reason));
|
||||
}
|
||||
}
|
||||
cancelAll(invalidateAdmission = true) {
|
||||
if (invalidateAdmission) this.admissionRevision++;
|
||||
for (const entry of [...this.pending.values()]) this.#finish(entry, null, fail("cancelled", "Host interaction was cancelled"));
|
||||
}
|
||||
shutdown() {
|
||||
if (this.closed) return;
|
||||
this.closed = true;
|
||||
if (this.owner) this.releaseWindow(this.owner);
|
||||
this.cancelAll(); this.rates.clear(); this.admissionGuards.clear();
|
||||
this.revocation.dispose(); this.unsubscribeLock?.();
|
||||
this.key.fill(0);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { HOST_INTERACTION_FEATURE, LIMITS, PROMPT_CHANNEL, PluginHostInteractionService, assertInteractionRequest, interactionAuthorization };
|
||||
@@ -0,0 +1,301 @@
|
||||
"use strict";
|
||||
const assert = require("node:assert/strict");
|
||||
const { EventEmitter } = require("node:events");
|
||||
const test = require("node:test");
|
||||
const { PluginHostInteractionService, assertInteractionRequest, interactionAuthorization, PROMPT_CHANNEL } = require("./hostInteractionService.cjs");
|
||||
|
||||
function fixture(t, options = {}) {
|
||||
const messages = [], audit = [], writes = [], charges = [];
|
||||
let reads = 0, locked = false, focused = true, active = true;
|
||||
const sender = Object.assign(new EventEmitter(), { id: 7, isDestroyed: () => false, send: (channel, message) => { assert.equal(channel, PROMPT_CHANNEL); messages.push(message); } });
|
||||
const window = Object.assign(new EventEmitter(), { webContents: sender, isFocused: () => focused, isVisible: () => true, isDestroyed: () => false });
|
||||
const signal = new AbortController();
|
||||
let onRevoke;
|
||||
const service = new PluginHostInteractionService({
|
||||
getWindow: () => window, isLocked: () => locked,
|
||||
permissionEngine: { onDidRevoke(fn) { onRevoke = fn; return { dispose() {} }; } },
|
||||
database: { recordSecurityAudit(...args) { audit.push(args); } },
|
||||
quotaManager: { chargeBytes(...args) { charges.push(args); } },
|
||||
clipboard: { readText() { reads++; return "synthetic clipboard"; }, writeText(text) { writes.push(text); } },
|
||||
...options,
|
||||
});
|
||||
service.setReady(sender, true);
|
||||
t.after(() => service.shutdown());
|
||||
const context = {
|
||||
pluginId: "com.example.synthetic", pluginVersion: "1.0.0", runtimeId: "runtime-synthetic", signal: signal.signal,
|
||||
async assertActive() { signal.signal.throwIfAborted(); if (!active) throw new Error("stale runtime"); },
|
||||
};
|
||||
const begin = async (params) => {
|
||||
const promise = service.request(assertInteractionRequest(params), context);
|
||||
void promise.catch(() => {});
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
return { promise, prompt: messages.filter((message) => !message.closed).at(-1) };
|
||||
};
|
||||
const respond = (prompt, action, value) => service.respond(sender, { requestId: prompt.requestId, actionToken: prompt.actionToken, action, ...(value === undefined ? {} : { value }) });
|
||||
return { service, sender, window, context, messages, audit, writes, charges, begin, respond, signal,
|
||||
get reads() { return reads; }, revoke() { onRevoke({ pluginId: context.pluginId }); },
|
||||
lock() { locked = true; }, blur() { focused = false; window.emit("blur"); }, focus() { focused = true; window.emit("focus"); }, stale() { active = false; },
|
||||
};
|
||||
}
|
||||
|
||||
test("normal input and quick pick return only an approved bounded value", async (t) => {
|
||||
const f = fixture(t);
|
||||
const input = await f.begin({ kind: "input", title: "Synthetic input", initialValue: "draft" });
|
||||
assert.equal(await f.respond(input.prompt, "submit", "approved"), true);
|
||||
assert.deepEqual(await input.promise, { cancelled: false, value: "approved" });
|
||||
const choice = await f.begin({ kind: "quickPick", title: "Pick", items: [{ id: "one", label: "One" }] });
|
||||
assert.equal(await f.respond(choice.prompt, "submit", "one"), true);
|
||||
assert.deepEqual(await choice.promise, { cancelled: false, value: "one" });
|
||||
assert.equal(JSON.stringify(f.audit).includes("approved"), false);
|
||||
assert.equal(f.reads, 0); assert.deepEqual(f.writes, []);
|
||||
});
|
||||
|
||||
test("ending a host command cancels pending native actions without resetting its quota", async (t) => {
|
||||
const f = fixture(t);
|
||||
const request = await f.begin({ kind: "clipboard.write", title: "Synthetic", text: "must not copy" });
|
||||
const count = f.service.rates.get(f.context.runtimeId).count;
|
||||
f.service.cancelRuntimeRequests(f.context.runtimeId);
|
||||
await assert.rejects(request.promise, /command finished/);
|
||||
assert.equal(await f.respond(request.prompt, "submit"), false);
|
||||
assert.deepEqual(f.writes, []);
|
||||
assert.equal(f.service.rates.get(f.context.runtimeId).count, count);
|
||||
});
|
||||
|
||||
test("old command completion cannot invalidate a replacement runtime's host-owned admission", async (t) => {
|
||||
const f = fixture(t);
|
||||
let authorize;
|
||||
f.service.registerRpcCapabilities({ registerRequest(_name, _handler, options) { authorize = options.authorization; } });
|
||||
const old = assertInteractionRequest({ kind: "input", title: "Old" });
|
||||
authorize(old, f.context);
|
||||
f.service.cancelRuntimeRequests(f.context.runtimeId);
|
||||
await assert.rejects(f.service.request(old, f.context), /revoked/);
|
||||
const nextContext = { ...f.context, runtimeId: "runtime-replacement" };
|
||||
const next = assertInteractionRequest({ kind: "input", title: "New" });
|
||||
authorize(next, nextContext);
|
||||
f.service.cancelRuntimeRequests(f.context.runtimeId);
|
||||
const request = f.service.request(next, nextContext);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
const prompt = f.messages.find((message) => !message.closed);
|
||||
assert.ok(prompt);
|
||||
await f.respond(prompt, "submit", "synthetic new input");
|
||||
assert.deepEqual(await request, { cancelled: false, value: "synthetic new input" });
|
||||
});
|
||||
|
||||
test("Once permission dialog may restore the same owner's focus after its decision resolves", async (t) => {
|
||||
const { PluginHostRpcRegistry } = require("./hostRpcRegistry.cjs");
|
||||
const { PluginPermissionEngine } = require("./permissionEngine.cjs");
|
||||
const f = fixture(t);
|
||||
let decisions = 0;
|
||||
const engine = new PluginPermissionEngine({
|
||||
database: { listPermissionGrants: () => [], recordSecurityAudit() {} },
|
||||
requestDecision: async (request) => {
|
||||
decisions++;
|
||||
f.blur();
|
||||
setTimeout(() => f.focus(), 30);
|
||||
return { requestId: request.requestId, decision: "allow", scope: "once" };
|
||||
},
|
||||
});
|
||||
t.after(() => engine.shutdown());
|
||||
const registry = new PluginHostRpcRegistry();
|
||||
registry.use(engine.createMiddleware());
|
||||
f.service.registerRpcCapabilities(registry);
|
||||
const route = registry.createRoutes({ ...f.context, runtimeKind: "browser", assertCurrent() {},
|
||||
manifest: { id: f.context.pluginId, publisher: "example", permissions: { optional: ["ui.interact"] } },
|
||||
}).requestHandlers["ui.interact"];
|
||||
const result = route({ kind: "input", title: "Synthetic input", initialValue: "visible" }, { signal: f.context.signal });
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
assert.equal(f.messages.length, 0, "no dialog is delivered while the owner is backgrounded");
|
||||
await new Promise((resolve) => setTimeout(resolve, 45));
|
||||
const prompt = f.messages.find((message) => !message.closed);
|
||||
assert.ok(prompt);
|
||||
await f.respond(prompt, "submit", "visible");
|
||||
assert.deepEqual(await result, { cancelled: false, value: "visible" });
|
||||
assert.equal(decisions, 1);
|
||||
assert.equal(f.reads, 0); assert.deepEqual(f.writes, []);
|
||||
});
|
||||
|
||||
test("focus restoration is bounded and admission cannot survive owner or access lifecycle changes", async (t) => {
|
||||
for (const action of ["timeout", "hide", "revoke", "runtime", "window", "lock", "signal", "stale"]) {
|
||||
const f = fixture(t, { focusRestoreMs: 35 });
|
||||
let authorize;
|
||||
f.service.registerRpcCapabilities({ registerRequest(_name, _handler, options) { authorize = options.authorization; } });
|
||||
const params = assertInteractionRequest({ kind: "clipboard.write", title: "Never copied", text: "synthetic" });
|
||||
authorize(params, f.context); // admission is host-owned and originally foregrounded
|
||||
f.blur();
|
||||
const result = f.service.request(params, f.context);
|
||||
void result.catch(() => {});
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
if (action === "hide") f.window.emit("hide");
|
||||
if (action === "revoke") f.revoke();
|
||||
if (action === "runtime") f.service.releaseRuntime(f.context.runtimeId);
|
||||
if (action === "window") { f.sender.emit("did-start-navigation", { isMainFrame: true, isSameDocument: false }); f.service.setReady(f.sender, true); }
|
||||
if (action === "lock") f.lock();
|
||||
if (action === "signal") f.signal.abort();
|
||||
if (action === "stale") f.stale();
|
||||
if (action !== "timeout") f.focus();
|
||||
await assert.rejects(result, /focused unlocked|revoked|unavailable|abort|stale/i, action);
|
||||
assert.equal(f.messages.length, 0, action);
|
||||
assert.deepEqual(f.writes, []); assert.equal(f.reads, 0);
|
||||
}
|
||||
const background = fixture(t);
|
||||
background.blur();
|
||||
await assert.rejects(background.service.request(assertInteractionRequest({ kind: "input", title: "Background" }), background.context), /focused unlocked/);
|
||||
});
|
||||
|
||||
test("clipboard read waits for preview action and a separate single-use sharing confirmation", async (t) => {
|
||||
const f = fixture(t);
|
||||
const request = await f.begin({ kind: "clipboard.read", title: "Share text" });
|
||||
let done = false; void request.promise.then(() => { done = true; });
|
||||
assert.equal(f.reads, 0);
|
||||
await f.respond(request.prompt, "preview");
|
||||
const preview = f.messages.filter((message) => !message.closed).at(-1);
|
||||
assert.equal(preview.phase, "preview"); assert.equal(preview.previewText, "synthetic clipboard");
|
||||
assert.notEqual(preview.actionToken, request.prompt.actionToken);
|
||||
assert.equal(f.reads, 1); assert.equal(done, false);
|
||||
assert.equal(await f.respond(request.prompt, "submit"), false, "old-phase action cannot share text");
|
||||
assert.equal(await f.respond(preview, "submit"), true);
|
||||
assert.deepEqual(await request.promise, { cancelled: false, value: "synthetic clipboard" });
|
||||
assert.equal(await f.respond(preview, "submit"), false, "no replay");
|
||||
assert.equal(f.reads, 1); assert.equal(JSON.stringify(f.audit).includes("synthetic clipboard"), false);
|
||||
});
|
||||
|
||||
test("clipboard copy is explicit and duplicate replies write once", async (t) => {
|
||||
const f = fixture(t);
|
||||
const request = await f.begin({ kind: "clipboard.write", title: "Copy", text: "synthetic output" });
|
||||
assert.deepEqual(f.writes, []);
|
||||
const first = f.respond(request.prompt, "submit");
|
||||
assert.equal(await f.respond(request.prompt, "submit"), false);
|
||||
assert.equal(await first, true);
|
||||
assert.deepEqual(await request.promise, { cancelled: false, value: true });
|
||||
assert.deepEqual(f.writes, ["synthetic output"]);
|
||||
});
|
||||
|
||||
test("cancel, blur, revoke, runtime release, and incoming cancellation revoke pending action tokens", async (t) => {
|
||||
for (const action of ["cancel", "blur", "revoke", "runtime", "signal", "window"]) {
|
||||
const f = fixture(t);
|
||||
const request = await f.begin({ kind: "clipboard.write", title: "Copy", text: "never written" });
|
||||
if (action === "cancel") { await f.respond(request.prompt, "cancel"); assert.deepEqual(await request.promise, { cancelled: true }); }
|
||||
else {
|
||||
if (action === "blur") f.blur();
|
||||
if (action === "revoke") f.revoke();
|
||||
if (action === "runtime") f.service.releaseRuntime(f.context.runtimeId);
|
||||
if (action === "signal") f.signal.abort();
|
||||
if (action === "window") f.sender.emit("destroyed");
|
||||
await assert.rejects(request.promise, /cancelled|revoked|stopped|closed/);
|
||||
}
|
||||
assert.deepEqual(f.writes, []);
|
||||
assert.equal(f.service.pending.size, 0);
|
||||
}
|
||||
});
|
||||
|
||||
test("wrong windows, forged tokens and stale runtimes cannot commit a native action", async (t) => {
|
||||
const f = fixture(t);
|
||||
const request = await f.begin({ kind: "clipboard.write", title: "Copy", text: "never written" });
|
||||
await assert.rejects(f.service.respond({ id: 9 }, { requestId: request.prompt.requestId, actionToken: request.prompt.actionToken, action: "submit" }), /focused unlocked/);
|
||||
assert.equal(await f.respond({ ...request.prompt, actionToken: "x".repeat(64) }, "submit"), false);
|
||||
f.stale();
|
||||
await assert.rejects(f.respond(request.prompt, "submit"), /stale runtime/);
|
||||
await assert.rejects(request.promise, /stale runtime/);
|
||||
assert.deepEqual(f.writes, []);
|
||||
});
|
||||
|
||||
test("quota, deadline, locked host, unknown fields, duplicate choices and text bounds fail closed", async (t) => {
|
||||
const f = fixture(t, { timeoutMs: 20 });
|
||||
const request = await f.begin({ kind: "input", title: "Input" });
|
||||
await assert.rejects(f.service.request(assertInteractionRequest({ kind: "input", title: "Another" }), f.context), /limit/);
|
||||
const keepAlive = setTimeout(() => {}, 100);
|
||||
await assert.rejects(request.promise, /expired/); clearTimeout(keepAlive);
|
||||
f.lock();
|
||||
await assert.rejects(f.service.request(assertInteractionRequest({ kind: "input", title: "Locked" }), f.context), /focused unlocked/);
|
||||
assert.throws(() => assertInteractionRequest({ kind: "clipboard.read", title: "Forged", userGesture: true }), /invalid/);
|
||||
assert.throws(() => assertInteractionRequest({ kind: "quickPick", title: "Duplicate", items: [{ id: "same", label: "A" }, { id: "same", label: "B" }] }), /unique/);
|
||||
assert.throws(() => assertInteractionRequest({ kind: "clipboard.write", title: "Too long", text: "文".repeat(1500) }), /limit/);
|
||||
assert.equal(interactionAuthorization({ kind: "clipboard.read" }).permission, "clipboard.read");
|
||||
assert.equal(interactionAuthorization({ kind: "clipboard.write" }).permission, "clipboard.write");
|
||||
assert.equal(interactionAuthorization({ kind: "input" }).permission, "ui.interact");
|
||||
});
|
||||
|
||||
test("manifest permission checks and interaction quota run before any host prompt", async (t) => {
|
||||
const { PluginHostRpcRegistry } = require("./hostRpcRegistry.cjs");
|
||||
const { PluginPermissionEngine } = require("./permissionEngine.cjs");
|
||||
const f = fixture(t);
|
||||
let decisions = 0;
|
||||
const engine = new PluginPermissionEngine({
|
||||
database: { listPermissionGrants: () => [], recordSecurityAudit() {} },
|
||||
requestDecision: async (request) => { decisions++; return { requestId: request.requestId, decision: "deny" }; },
|
||||
});
|
||||
t.after(() => engine.shutdown());
|
||||
const registry = new PluginHostRpcRegistry();
|
||||
registry.use(engine.createMiddleware());
|
||||
f.service.registerRpcCapabilities(registry);
|
||||
const base = { ...f.context, runtimeKind: "browser", manifest: { id: f.context.pluginId, publisher: "example", permissions: {} }, assertCurrent() {} };
|
||||
const denied = registry.createRoutes(base).requestHandlers["ui.interact"];
|
||||
await assert.rejects(denied({ kind: "input", title: "Undeclared" }, { signal: f.context.signal }), /declare/);
|
||||
assert.equal(decisions, 0); assert.equal(f.messages.length, 0);
|
||||
const route = registry.createRoutes({ ...base, manifest: { ...base.manifest, permissions: { optional: ["ui.interact"] } } }).requestHandlers["ui.interact"];
|
||||
for (let count = 1; count < 10; count++) {
|
||||
await assert.rejects(route({ kind: "input", title: "Denied" }, { signal: f.context.signal }), /denied/);
|
||||
}
|
||||
const before = decisions;
|
||||
await assert.rejects(route({ kind: "input", title: "Over quota" }, { signal: f.context.signal }), /limit/);
|
||||
assert.equal(decisions, before, "over-quota work cannot open another permission prompt");
|
||||
assert.equal(f.messages.length, 0);
|
||||
});
|
||||
|
||||
test("revoke during an asynchronous active guard and audit failure cannot write clipboard", async (t) => {
|
||||
const f = fixture(t);
|
||||
const request = await f.begin({ kind: "clipboard.write", title: "Copy", text: "never copied" });
|
||||
let resume;
|
||||
f.context.assertActive = () => new Promise((resolve) => { resume = resolve; });
|
||||
const reply = f.respond(request.prompt, "submit");
|
||||
f.revoke();
|
||||
resume();
|
||||
assert.equal(await reply, false);
|
||||
await assert.rejects(request.promise, /revoked/);
|
||||
assert.deepEqual(f.writes, []);
|
||||
const g = fixture(t, { database: { recordSecurityAudit() { throw new Error("synthetic audit failure"); } } });
|
||||
const failed = await g.begin({ kind: "clipboard.write", title: "Copy", text: "never copied" });
|
||||
await assert.rejects(g.respond(failed.prompt, "submit"), /audit is unavailable/);
|
||||
await assert.rejects(failed.promise, /audit is unavailable/);
|
||||
assert.deepEqual(g.writes, []);
|
||||
});
|
||||
|
||||
test("same-document navigation keeps ownership, while document reload and renderer exit cancel it", async (t) => {
|
||||
for (const event of ["reload", "renderer"]) {
|
||||
const f = fixture(t);
|
||||
const request = await f.begin({ kind: "clipboard.read", title: "Share" });
|
||||
f.sender.emit("did-start-navigation", { isMainFrame: false, isSameDocument: false });
|
||||
f.sender.emit("did-start-navigation", { isMainFrame: true, isSameDocument: true });
|
||||
assert.equal(f.service.pending.size, 1);
|
||||
if (event === "reload") f.sender.emit("did-start-navigation", { isMainFrame: true, isSameDocument: false });
|
||||
else f.sender.emit("render-process-gone");
|
||||
await assert.rejects(request.promise, /window closed/);
|
||||
assert.equal(f.reads, 0); assert.equal(f.service.owner, null);
|
||||
}
|
||||
});
|
||||
|
||||
test("host lock subscription cancels previewed text before it can be shared", async (t) => {
|
||||
let changed;
|
||||
const f = fixture(t, { subscribeLock(listener) { changed = listener; return () => {}; } });
|
||||
const request = await f.begin({ kind: "clipboard.read", title: "Share" });
|
||||
await f.respond(request.prompt, "preview");
|
||||
const preview = f.messages.filter((message) => !message.closed).at(-1);
|
||||
changed({ locked: true });
|
||||
await assert.rejects(request.promise, /cancelled/);
|
||||
assert.equal(await f.respond(preview, "submit"), false);
|
||||
assert.equal(f.reads, 1); assert.equal(f.service.pending.size, 0);
|
||||
});
|
||||
|
||||
test("single-line inputs reject CR/LF before display and submission while clipboard text stays multiline", async (t) => {
|
||||
const f = fixture(t);
|
||||
for (const text of ["first\nsecond", "first\rsecond", "first\r\nsecond"]) {
|
||||
assert.throws(() => assertInteractionRequest({ kind: "input", title: "Single line", initialValue: text }), /invalid/);
|
||||
const request = await f.begin({ kind: "input", title: "Single line", initialValue: "first second" });
|
||||
await assert.rejects(f.respond(request.prompt, "submit", text), /single line/);
|
||||
await assert.rejects(request.promise, /single line/);
|
||||
}
|
||||
const copy = await f.begin({ kind: "clipboard.write", title: "Multiline clipboard", text: "first\nsecond" });
|
||||
await f.respond(copy.prompt, "submit");
|
||||
assert.deepEqual(await copy.promise, { cancelled: false, value: true });
|
||||
assert.deepEqual(f.writes, ["first\nsecond"]);
|
||||
});
|
||||
@@ -1,6 +1,10 @@
|
||||
"use strict";
|
||||
|
||||
const path = require("node:path");
|
||||
const NETWORK_CREDENTIAL_LEASE_FEATURE = require("./generated/plugin-contract.schema.json").$defs.NetworkCredentialLeaseFeature.const;
|
||||
const VAULT_PAGES_FEATURE = require("./generated/plugin-contract.schema.json").$defs.VaultPagesFeature.const;
|
||||
const TERMINAL_SIDEBAR_FEATURE = require("./generated/plugin-contract.schema.json").$defs.TerminalSidebarFeature.const;
|
||||
const INTERACTIVE_COMMAND_FEATURE = require("./generated/plugin-contract.schema.json").$defs.InteractiveCommandFeature.const;
|
||||
|
||||
const { assertBrowserOnlyPlugin } = require("../../domain/pluginRuntimePolicy.cjs");
|
||||
const { isPluginDevelopmentEnabled, PLUGIN_API_VERSION } = require("./constants.cjs");
|
||||
@@ -22,6 +26,7 @@ const { PackageStore } = require("./packageStore.cjs");
|
||||
const { createPluginPaths } = require("./paths.cjs");
|
||||
const { PluginManager } = require("./pluginManager.cjs");
|
||||
const { PluginNetworkBroker } = require("./networkBroker.cjs");
|
||||
const { HOST_INTERACTION_FEATURE, PluginHostInteractionService } = require("./hostInteractionService.cjs");
|
||||
const { PluginPermissionEngine } = require("./permissionEngine.cjs");
|
||||
const { PluginProtocol } = require("./pluginProtocol.cjs");
|
||||
const { PluginViewHost } = require("./pluginViewHost.cjs");
|
||||
@@ -85,6 +90,7 @@ function createPluginHostService(options) {
|
||||
const appRoot = options.appRoot ?? options.app.getAppPath();
|
||||
const runtimeDirectory = options.runtimeDirectory ?? path.join(__dirname, "runtime");
|
||||
const database = new PluginDatabase(paths.database);
|
||||
let interactionService;
|
||||
try {
|
||||
const packageStore = new PackageStore({
|
||||
allowAdvancedPlugins,
|
||||
@@ -92,7 +98,7 @@ function createPluginHostService(options) {
|
||||
database,
|
||||
netcattyVersion: options.app.getVersion(),
|
||||
apiVersion: PLUGIN_API_VERSION,
|
||||
supportedFeatures: [...new Set(["metadata.brand-icon", ...(options.supportedFeatures ?? [])])],
|
||||
supportedFeatures: [...new Set(["metadata.brand-icon", NETWORK_CREDENTIAL_LEASE_FEATURE, HOST_INTERACTION_FEATURE, VAULT_PAGES_FEATURE, TERMINAL_SIDEBAR_FEATURE, INTERACTIVE_COMMAND_FEATURE, ...(options.supportedFeatures ?? [])])],
|
||||
});
|
||||
const contributionIconService = new PluginContributionIconService({
|
||||
database,
|
||||
@@ -164,6 +170,7 @@ function createPluginHostService(options) {
|
||||
runtimeSupervisor: runtimeAccess,
|
||||
secretStore,
|
||||
getLocale: options.getLocale,
|
||||
onCommandFinished: (runtimeId) => interactionService?.cancelRuntimeRequests(runtimeId),
|
||||
});
|
||||
contributionService.registerRpcCapabilities(rpcRegistry);
|
||||
registerSecurePluginCapabilities(rpcRegistry, {
|
||||
@@ -176,6 +183,14 @@ function createPluginHostService(options) {
|
||||
quotaManager,
|
||||
secretStore,
|
||||
});
|
||||
interactionService = new PluginHostInteractionService({
|
||||
database, permissionEngine, quotaManager,
|
||||
clipboard: options.electron.clipboard,
|
||||
getWindow: options.getInteractionWindow,
|
||||
isLocked: options.isAppLocked,
|
||||
subscribeLock: options.subscribeAppLock,
|
||||
});
|
||||
interactionService.registerRpcCapabilities(rpcRegistry);
|
||||
const configuredRegistry = options.configureRpcRegistry?.(rpcRegistry);
|
||||
if (configuredRegistry && typeof configuredRegistry.then === "function") {
|
||||
throw new TypeError("Plugin host RPC registry configuration must be synchronous");
|
||||
@@ -223,7 +238,7 @@ function createPluginHostService(options) {
|
||||
paths,
|
||||
netcattyVersion: options.app.getVersion(),
|
||||
apiVersion: PLUGIN_API_VERSION,
|
||||
supportedFeatures: [...new Set(["metadata.brand-icon", ...(options.supportedFeatures ?? [])])],
|
||||
supportedFeatures: [...new Set(["metadata.brand-icon", NETWORK_CREDENTIAL_LEASE_FEATURE, HOST_INTERACTION_FEATURE, VAULT_PAGES_FEATURE, TERMINAL_SIDEBAR_FEATURE, INTERACTIVE_COMMAND_FEATURE, ...(options.supportedFeatures ?? [])])],
|
||||
runtimeDirectory,
|
||||
appRoot,
|
||||
rpcRegistry,
|
||||
@@ -233,6 +248,7 @@ function createPluginHostService(options) {
|
||||
getInitialEnvironment: () => contributionService.getEnvironment(),
|
||||
runtimeResourceMonitor: quotaManager,
|
||||
runtimeCleanup: async (identity) => {
|
||||
interactionService?.releaseRuntime(identity.runtimeId);
|
||||
leaseStore.revokeRuntime(identity.runtimeId);
|
||||
await companionSupervisor.releaseRuntime(identity.runtimeId);
|
||||
},
|
||||
@@ -350,14 +366,18 @@ function createPluginHostService(options) {
|
||||
packageStore,
|
||||
database,
|
||||
contributionService,
|
||||
isLocked: options.isAppLocked,
|
||||
subscribeLock: options.subscribeAppLock,
|
||||
})
|
||||
: null;
|
||||
const manager = new PluginManager({
|
||||
permissionEngine,
|
||||
database,
|
||||
packageStore,
|
||||
runtimeSupervisor,
|
||||
contributionService,
|
||||
beforeClose: async () => {
|
||||
interactionService.shutdown();
|
||||
terminalDataPipelineService?.shutdown();
|
||||
extensionProviderService.shutdown();
|
||||
credentialResolver.shutdown?.();
|
||||
@@ -369,6 +389,7 @@ function createPluginHostService(options) {
|
||||
},
|
||||
});
|
||||
return {
|
||||
interactionService,
|
||||
catalogService: new RegistryCatalogService({ manager, fetch: options.electron.net?.fetch?.bind(options.electron.net) }),
|
||||
companionSupervisor,
|
||||
contributionIconService,
|
||||
@@ -396,6 +417,7 @@ function createPluginHostService(options) {
|
||||
viewHost,
|
||||
};
|
||||
} catch (error) {
|
||||
try { interactionService?.shutdown(); } catch {}
|
||||
database.close();
|
||||
throw error;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
"use strict";
|
||||
const assert = require("node:assert/strict");
|
||||
const test = require("node:test");
|
||||
const { MessageChannel } = require("node:worker_threads");
|
||||
const { PluginRpcRouter } = require("./rpcRouter.cjs");
|
||||
|
||||
test("browser lease example negotiates its feature and forwards only an opaque lease", async () => {
|
||||
const { startPluginRuntime } = await import("./runtime/runtimePeer.mjs");
|
||||
const fixture = await import("../../examples/plugins/lease-http/browser.js");
|
||||
const calls = [];
|
||||
const secret = { kind: "secret", id: "s".repeat(32), key: "synthetic-token" };
|
||||
const lease = { kind: "secret-lease", id: "l".repeat(32), operationId: "network:https://example.invalid", expiresAt: Date.now() + 30_000 };
|
||||
const { port1, port2 } = new MessageChannel();
|
||||
const host = new PluginRpcRouter({
|
||||
pluginId: "com.netcatty.lease-http",
|
||||
send: (message) => port1.postMessage(message),
|
||||
handlers: {
|
||||
"secrets.set": async (params) => { calls.push("secret"); assert.equal(params.key, secret.key); return { secret }; },
|
||||
"credentials.createLease": async (params) => {
|
||||
calls.push("lease"); assert.deepEqual(params.secret, secret); assert.equal(params.operationId, lease.operationId); return lease;
|
||||
},
|
||||
"network.request": async (params) => {
|
||||
calls.push("network");
|
||||
assert.deepEqual(params, { url: "https://example.invalid/synthetic", credentialLease: lease, authorization: { scheme: "Bearer" } });
|
||||
assert.equal(JSON.stringify(params).includes("synthetic-acceptance-token"), false);
|
||||
return { status: 200, url: params.url, headers: {}, body: { encoding: "base64", data: "" } };
|
||||
},
|
||||
},
|
||||
});
|
||||
port1.on("message", (message) => { void host.accept(message); });
|
||||
const peer = await startPluginRuntime({
|
||||
port: port2,
|
||||
config: { pluginId: "com.netcatty.lease-http", pluginVersion: "0.1.0", netcattyVersion: "0.0.0", apiVersion: "0.1.0-internal.1", enabledFeatures: ["network.credential-lease"] },
|
||||
loadPlugin: async () => fixture,
|
||||
});
|
||||
try {
|
||||
const initialized = await host.request("plugin.initialize", { netcattyVersion: "0.0.0", apiVersion: "0.1.0-internal.1", supportedFeatures: ["network.credential-lease"] });
|
||||
assert.deepEqual(initialized.enabledFeatures, ["network.credential-lease"]);
|
||||
await host.request("plugin.activate", {});
|
||||
assert.deepEqual(await host.request("plugin.command.execute", { command: "com.netcatty.lease-http.request", invocation: { source: "palette" } }), { status: 200 });
|
||||
assert.deepEqual(calls, ["secret", "lease", "network"]);
|
||||
} finally {
|
||||
await peer.dispose();
|
||||
host.close();
|
||||
port1.close();
|
||||
}
|
||||
});
|
||||
@@ -1,5 +1,9 @@
|
||||
"use strict";
|
||||
|
||||
const { createDefinitionValidator } = require("./contractValidator.cjs");
|
||||
const validateLease = createDefinitionValidator("SecretLeaseRef");
|
||||
const validateAuthorization = createDefinitionValidator("PluginNetworkAuthorization");
|
||||
|
||||
const { PluginRpcError, RPC_ERRORS } = require("./rpcRouter.cjs");
|
||||
const { PLUGIN_RPC_MAX_RAW_BYTES } = require("./constants.cjs");
|
||||
const { canonicalizeNetworkOrigin } = require("./permissionResources.cjs");
|
||||
@@ -180,15 +184,7 @@ class PluginNetworkBroker {
|
||||
// network.request lease injection would become dead code.
|
||||
if (params?.credentialLease !== undefined) {
|
||||
const lease = params.credentialLease;
|
||||
if (
|
||||
!lease
|
||||
|| typeof lease !== "object"
|
||||
|| Array.isArray(lease)
|
||||
|| lease.kind !== "secret-lease"
|
||||
|| typeof lease.id !== "string"
|
||||
|| typeof lease.operationId !== "string"
|
||||
|| !Number.isFinite(lease.expiresAt)
|
||||
) {
|
||||
if (!validateLease(lease)) {
|
||||
throw invalidArgument("Plugin network credential lease is invalid");
|
||||
}
|
||||
validated.credentialLease = Object.freeze({
|
||||
@@ -200,7 +196,7 @@ class PluginNetworkBroker {
|
||||
}
|
||||
if (params?.authorization !== undefined) {
|
||||
const authorization = params.authorization;
|
||||
if (!authorization || typeof authorization !== "object" || Array.isArray(authorization)) {
|
||||
if (!validateAuthorization(authorization)) {
|
||||
throw invalidArgument("Plugin network authorization is invalid");
|
||||
}
|
||||
const scheme = authorization.scheme === "Basic" ? "Basic" : "Bearer";
|
||||
|
||||
@@ -172,3 +172,14 @@ test("network broker reports its own timeout as a stable deadline error", async
|
||||
(error) => error.code === RPC_ERRORS.deadlineExceeded,
|
||||
);
|
||||
});
|
||||
|
||||
test("credential field validation follows the canonical contract and keeps Bearer default", () => {
|
||||
const broker = new PluginNetworkBroker({ permissionEngine: {} });
|
||||
const lease = { kind: "secret-lease", id: "l".repeat(32), operationId: "network:https://example.invalid", expiresAt: 1000 };
|
||||
const request = { url: "https://example.invalid", credentialLease: lease, authorization: {} };
|
||||
assert.deepEqual(broker.validate(request).authorization, { scheme: "Bearer" });
|
||||
for (const authorization of [{ scheme: "Digest" }, { scheme: "Basic", username: "name\n" }, { password: "synthetic" }]) {
|
||||
assert.throws(() => broker.validate({ ...request, authorization }), /authorization is invalid/);
|
||||
}
|
||||
assert.throws(() => broker.validate({ ...request, credentialLease: { ...lease, id: "short" } }), /lease is invalid/);
|
||||
});
|
||||
@@ -660,6 +660,22 @@ class PluginPermissionEngine {
|
||||
});
|
||||
}
|
||||
|
||||
describeGrants(pluginId, context) {
|
||||
const groups = this.listGrants(pluginId);
|
||||
const declarations = context ? normalizePermissionDeclarations(context.manifest) : new Map();
|
||||
return Object.fromEntries(Object.entries(groups).map(([scope, grants]) => [scope, grants.map((grant) => {
|
||||
const declaration = declarations.get(grant.permission);
|
||||
const matchesCurrentActivation = Boolean(
|
||||
context?.pluginId === pluginId
|
||||
&& context.securityPrincipal
|
||||
&& declaration
|
||||
&& declarationAllowsResource(declaration, grant.resource)
|
||||
&& grant.declarationHash === permissionDeclarationHash(declaration, context.securityPrincipal),
|
||||
);
|
||||
return Object.freeze({ ...grant, matchesCurrentActivation });
|
||||
})]));
|
||||
}
|
||||
|
||||
revokeAlways(pluginId, permission, resource) {
|
||||
assertPluginPermission(permission);
|
||||
const canonicalResource = canonicalizePermissionResource(permission, resource);
|
||||
|
||||
@@ -631,3 +631,25 @@ test("permission middleware rejects unclassified methods and permits explicit pu
|
||||
}, async () => "ok"), "ok");
|
||||
database.close();
|
||||
});
|
||||
|
||||
test("grant descriptions distinguish current activation from old package and changed declarations", async (context) => {
|
||||
const database = createDatabase(context);
|
||||
const engine = new PluginPermissionEngine({ database, requestDecision: async (request) => ({
|
||||
requestId: request.requestId, decision: "allow", scope: "always",
|
||||
}) });
|
||||
const pluginManifest = manifest({ optional: [{ permission: "network", resources: ["https://example.invalid"] }] });
|
||||
const first = runtimeContext(pluginManifest, { securityPrincipal: defaultSecurityPrincipal(pluginManifest, "a".repeat(64)) });
|
||||
const descriptor = { permission: "network", resources: ["https://example.invalid"], reason: "Synthetic request" };
|
||||
await engine.authorize(first, descriptor);
|
||||
assert.equal(engine.describeGrants(first.pluginId, first).always[0].matchesCurrentActivation, true);
|
||||
assert.equal(engine.describeGrants(first.pluginId, null).always[0].matchesCurrentActivation, false);
|
||||
const upgraded = { ...first, securityPrincipal: defaultSecurityPrincipal(pluginManifest, "b".repeat(64)) };
|
||||
assert.equal(engine.describeGrants(first.pluginId, upgraded).always[0].matchesCurrentActivation, false);
|
||||
await assert.rejects(engine.authorize(upgraded, { ...descriptor, interactive: false }), /permission/i);
|
||||
const changed = { ...first, manifest: { ...pluginManifest, permissions: { optional: [{ permission: "network", resources: ["https://example.invalid", "https://other.invalid"] }] } } };
|
||||
assert.equal(engine.describeGrants(first.pluginId, changed).always[0].matchesCurrentActivation, false);
|
||||
await assert.rejects(engine.authorize(changed, { ...descriptor, interactive: false }), /permission/i);
|
||||
assert.equal(engine.describeGrants(first.pluginId, { ...first, manifest: { ...pluginManifest, permissions: {} } }).always[0].matchesCurrentActivation, false);
|
||||
engine.shutdown();
|
||||
database.close();
|
||||
});
|
||||
@@ -3,6 +3,10 @@
|
||||
const fs = require("node:fs/promises");
|
||||
const { randomUUID } = require("node:crypto");
|
||||
|
||||
const { createDefinitionValidator } = require("./contractValidator.cjs");
|
||||
const validateManagedPluginId = createDefinitionValidator("PluginId");
|
||||
const validateViewFocus = createDefinitionValidator("PluginViewFocusRequest");
|
||||
|
||||
const { raceWithAbort } = require("./rpcRouter.cjs");
|
||||
const {
|
||||
INLINE_SYNC_OBJECT_SAFE_BYTES,
|
||||
@@ -13,7 +17,11 @@ const { assertSecretKey } = require("./secretStore.cjs");
|
||||
|
||||
const CHANNELS = Object.freeze({
|
||||
status: "netcatty:plugins:status",
|
||||
interactionReady: "netcatty:plugins:interaction-ready",
|
||||
interactionResponse: "netcatty:plugins:interaction-response",
|
||||
list: "netcatty:plugins:list",
|
||||
permissions: "netcatty:plugins:permissions",
|
||||
revokePermissions: "netcatty:plugins:revoke-permissions",
|
||||
catalog: "netcatty:plugins:catalog",
|
||||
catalogReadme: "netcatty:plugins:catalog-readme",
|
||||
catalogInstall: "netcatty:plugins:catalog-install",
|
||||
@@ -68,6 +76,7 @@ const CHANNELS = Object.freeze({
|
||||
importerProgress: "netcatty:plugins:importer-progress",
|
||||
openView: "netcatty:plugins:open-view",
|
||||
closeView: "netcatty:plugins:close-view",
|
||||
focusView: "netcatty:plugins:focus-view",
|
||||
setViewBounds: "netcatty:plugins:set-view-bounds",
|
||||
setViewVisibility: "netcatty:plugins:set-view-visibility",
|
||||
viewMessage: "netcatty:plugins:view-message",
|
||||
@@ -656,7 +665,54 @@ function registerPluginBridge(ipcMain, options) {
|
||||
if (!options.catalogService) throw new Error("Plugin catalog unavailable");
|
||||
return options.catalogService.install(payload?.id, payload?.selectionToken);
|
||||
});
|
||||
handle(CHANNELS.interactionReady, async (_manager, payload, event) => {
|
||||
if (!options.interactionService) throw new Error("Host interactions are unavailable");
|
||||
return options.interactionService.setReady(event.sender, payload?.ready);
|
||||
});
|
||||
handle(CHANNELS.interactionResponse, async (_manager, payload, event) => {
|
||||
if (!options.interactionService) throw new Error("Host interactions are unavailable");
|
||||
return options.interactionService.respond(event.sender, payload);
|
||||
});
|
||||
handle(CHANNELS.list, async (activeManager) => activeManager.list());
|
||||
const assertManagedPluginId = (payload) => {
|
||||
const id = payload?.pluginId;
|
||||
if (!validateManagedPluginId(id)) {
|
||||
throw new TypeError("Plugin ID is invalid");
|
||||
}
|
||||
return id;
|
||||
};
|
||||
handle(CHANNELS.permissions, async (_manager, payload) => {
|
||||
const pluginId = assertManagedPluginId(payload);
|
||||
if (!options.permissionEngine || !options.database) throw new Error("Plugin permissions are unavailable");
|
||||
const plugin = options.database.getActivePlugin(pluginId);
|
||||
const identity = options.runtimeSupervisor?.getRuntimeIdentity(pluginId);
|
||||
// A stored grant is effective only for its matching host-owned activation.
|
||||
// Disabled/lazy/stopped plugins retain saved grants without claiming they
|
||||
// currently apply. Never infer a principal from plugin-provided metadata.
|
||||
const context = plugin?.enabled && identity?.pluginVersion === plugin.activeVersion
|
||||
? { ...identity, manifest: plugin.manifest }
|
||||
: null;
|
||||
const groups = options.permissionEngine.describeGrants(pluginId, context);
|
||||
// Host-only summaries: never return arbitrary audit details, reasons,
|
||||
// operation IDs, credentials, terminal content or future database columns.
|
||||
const grants = ["always", "application", "session"].flatMap((scope) => (
|
||||
(groups[scope] ?? []).map((grant) => ({
|
||||
permission: grant.permission, resource: grant.resource,
|
||||
resourceKind: grant.resourceKind, scope,
|
||||
sessionId: grant.sessionId ?? null, grantedAt: grant.grantedAt,
|
||||
matchesCurrentActivation: grant.matchesCurrentActivation,
|
||||
}))
|
||||
));
|
||||
const audit = options.database.listSecurityAudit(pluginId, 100).map((entry) => ({
|
||||
event: entry.event, createdAt: entry.createdAt,
|
||||
...(typeof entry.details?.permission === "string" ? { permission: entry.details.permission } : {}),
|
||||
...(typeof entry.details?.scope === "string" ? { scope: entry.details.scope } : {}),
|
||||
}));
|
||||
return { grants, audit };
|
||||
});
|
||||
handle(CHANNELS.revokePermissions, async (activeManager, payload) => (
|
||||
activeManager.revokePermissions(assertManagedPluginId(payload))
|
||||
));
|
||||
handle(CHANNELS.install, async (activeManager, payload) => activeManager.install(
|
||||
payload?.archivePath,
|
||||
{ enable: typeof payload?.enable === "boolean" ? payload.enable : undefined },
|
||||
@@ -1551,6 +1607,12 @@ function registerPluginBridge(ipcMain, options) {
|
||||
await viewHost.close(payload?.instanceId, event.sender);
|
||||
return null;
|
||||
});
|
||||
handle(CHANNELS.focusView, async (_activeManager, payload, event) => {
|
||||
if (!viewHost) throw new Error("Plugin views are unavailable");
|
||||
if (!validateViewFocus(payload)) throw new TypeError("Plugin view focus request is invalid");
|
||||
viewHost.focus(payload.instanceId, event.sender);
|
||||
return null;
|
||||
});
|
||||
handle(CHANNELS.setViewBounds, async (_activeManager, payload, event) => {
|
||||
if (!viewHost) throw new Error("Plugin views are unavailable");
|
||||
viewHost.setBounds(payload?.instanceId, payload?.bounds, event.sender);
|
||||
|
||||
@@ -1635,3 +1635,82 @@ test('brand metadata bridge accepts only trusted UI and forwards only the plugin
|
||||
await ipcMain.handlers.get(CHANNELS.brandIcon)({ trusted: true }, { pluginId: 'com.test.brand', path: '/secret', url: 'https://attacker.invalid/' });
|
||||
assert.deepEqual(seen, [['com.test.brand']]);
|
||||
});
|
||||
|
||||
test("permission management is trusted-host-only and projects bounded audit summaries", async () => {
|
||||
const ipcMain = createIpcMain();
|
||||
let reads = 0;
|
||||
const revoked = [];
|
||||
registerPluginBridge(ipcMain, {
|
||||
manager: { async initialize() {}, async revokePermissions(id) { revoked.push(id); return { enabled: false }; } },
|
||||
isTrustedSender: (event) => event?.trusted === true,
|
||||
permissionEngine: { describeGrants(id, context) {
|
||||
assert.equal(context, null);
|
||||
reads++;
|
||||
assert.equal(id, "com.example.synthetic");
|
||||
return { always: [{ permission: "network", resource: "https://example.invalid", resourceKind: "exact", grantedAt: 12, matchesCurrentActivation: false, declarationHash: "private" }], application: [], session: [] };
|
||||
} },
|
||||
database: { getActivePlugin() { return null; }, listSecurityAudit(id, limit) {
|
||||
assert.equal(id, "com.example.synthetic");
|
||||
assert.equal(limit, 100);
|
||||
return [{ event: "permission.used", createdAt: 13, details: { permission: "network", scope: "always", operationId: "hidden", secret: "synthetic-private" } }];
|
||||
} },
|
||||
});
|
||||
const payload = { pluginId: "com.example.synthetic" };
|
||||
for (const channel of [CHANNELS.permissions, CHANNELS.revokePermissions]) {
|
||||
await assert.rejects(ipcMain.handlers.get(channel)({}, payload), /Untrusted/);
|
||||
await assert.rejects(ipcMain.handlers.get(channel)({ trusted: true }, { pluginId: "../invalid" }), /Plugin ID/);
|
||||
}
|
||||
assert.equal(reads, 0);
|
||||
assert.deepEqual(await ipcMain.handlers.get(CHANNELS.permissions)({ trusted: true }, payload), {
|
||||
grants: [{ permission: "network", resource: "https://example.invalid", resourceKind: "exact", scope: "always", sessionId: null, grantedAt: 12, matchesCurrentActivation: false }],
|
||||
audit: [{ event: "permission.used", createdAt: 13, permission: "network", scope: "always" }],
|
||||
});
|
||||
assert.deepEqual(await ipcMain.handlers.get(CHANNELS.revokePermissions)({ trusted: true }, payload), { enabled: false });
|
||||
assert.deepEqual(revoked, [payload.pluginId]);
|
||||
});
|
||||
|
||||
test("grant classification uses only the current host activation and stops matching after disable or upgrade", async () => {
|
||||
const ipcMain = createIpcMain();
|
||||
let plugin = { enabled: true, activeVersion: "1.0.0", manifest: { id: "com.example.synthetic" } };
|
||||
let identity = { pluginId: "com.example.synthetic", pluginVersion: "1.0.0", runtimeId: "runtime-host", securityPrincipal: "host-owned-principal" };
|
||||
const contexts = [];
|
||||
registerPluginBridge(ipcMain, {
|
||||
manager: { async initialize() {} }, isTrustedSender: () => true,
|
||||
runtimeSupervisor: { getRuntimeIdentity: () => identity },
|
||||
permissionEngine: { describeGrants(_id, context) { contexts.push(context); return { always: [], application: [], session: [] }; } },
|
||||
database: { getActivePlugin: () => plugin, listSecurityAudit: () => [] },
|
||||
});
|
||||
const payload = { pluginId: "com.example.synthetic", securityPrincipal: "plugin-forgery", manifest: {} };
|
||||
const read = ipcMain.handlers.get(CHANNELS.permissions);
|
||||
await read({}, payload);
|
||||
assert.deepEqual(contexts[0], { ...identity, manifest: plugin.manifest });
|
||||
plugin = { ...plugin, enabled: false };
|
||||
await read({}, payload);
|
||||
assert.equal(contexts[1], null);
|
||||
plugin = { ...plugin, enabled: true, activeVersion: "2.0.0" };
|
||||
await read({}, payload);
|
||||
assert.equal(contexts[2], null, "old activation cannot label upgraded package grants as current");
|
||||
identity = null;
|
||||
await read({}, payload);
|
||||
assert.equal(contexts[3], null, "stopped and lazy plugins show saved grants as inactive");
|
||||
});
|
||||
|
||||
test("host interaction ready and response channels reject untrusted senders before touching the adapter", async () => {
|
||||
const ipcMain = createIpcMain(), calls = [];
|
||||
const sender = { id: 7 };
|
||||
registerPluginBridge(ipcMain, {
|
||||
manager: { async initialize() {} }, isTrustedSender: (event) => event?.trusted === true,
|
||||
interactionService: {
|
||||
setReady(...args) { calls.push(args); return true; },
|
||||
respond(...args) { calls.push(args); return true; },
|
||||
},
|
||||
});
|
||||
for (const channel of [CHANNELS.interactionReady, CHANNELS.interactionResponse]) {
|
||||
await assert.rejects(ipcMain.handlers.get(channel)({ sender }, {}), /Untrusted/);
|
||||
}
|
||||
assert.deepEqual(calls, []);
|
||||
await ipcMain.handlers.get(CHANNELS.interactionReady)({ trusted: true, sender }, { ready: true });
|
||||
const response = { requestId: "opaque", actionToken: "host-token", action: "cancel" };
|
||||
await ipcMain.handlers.get(CHANNELS.interactionResponse)({ trusted: true, sender }, response);
|
||||
assert.deepEqual(calls, [[sender, true], [sender, response]]);
|
||||
});
|
||||
@@ -11,6 +11,7 @@ class PluginManager {
|
||||
onPluginEnabled: (pluginId) => this.runtimeSupervisor.start(pluginId),
|
||||
};
|
||||
this.beforeClose = options.beforeClose ?? null;
|
||||
this.permissionEngine = options.permissionEngine ?? null;
|
||||
this.initialized = false;
|
||||
this.initializePromise = null;
|
||||
this.mutationTail = Promise.resolve();
|
||||
@@ -128,6 +129,26 @@ class PluginManager {
|
||||
});
|
||||
}
|
||||
|
||||
revokePermissions(pluginId) {
|
||||
return this.#mutate(async () => {
|
||||
await this.#ready();
|
||||
if (!this.permissionEngine) throw new Error("Plugin permissions are unavailable");
|
||||
if (!this.database.getActivePlugin(pluginId)) throw new Error("Plugin is not installed");
|
||||
// Disable before revoking. Lazy activation cannot re-create authority while
|
||||
// runtime shutdown cancels pending RPC, streams, leases and provider work.
|
||||
const errors = [];
|
||||
// Every containment stage must be attempted even when persistence,
|
||||
// revocation audit, or contribution invalidation fails.
|
||||
try { this.database.setEnabled(pluginId, false); } catch (error) { errors.push(error); }
|
||||
try { this.permissionEngine.revokeAll(pluginId); } catch (error) { errors.push(error); }
|
||||
try { this.contributionService.onPluginDisabled(pluginId); } catch (error) { errors.push(error); }
|
||||
try { await this.runtimeSupervisor.stop(pluginId); } catch (error) { errors.push(error); }
|
||||
if (errors.length === 1) throw errors[0];
|
||||
if (errors.length > 1) throw new AggregateError(errors, "Plugin grant revocation or cleanup failed");
|
||||
return this.database.getActivePlugin(pluginId);
|
||||
});
|
||||
}
|
||||
|
||||
restart(pluginId) {
|
||||
return this.#mutate(async () => {
|
||||
await this.#ready();
|
||||
|
||||
@@ -511,3 +511,75 @@ test("explicit enable checks compatibility before clearing quarantine or exposin
|
||||
await assert.rejects(manager.setEnabled(plugin.id, true), /incompatible/);
|
||||
assert.deepEqual(calls, []);
|
||||
});
|
||||
|
||||
test("permission revocation disables before clearing grants and waits for runtime cleanup", async () => {
|
||||
const calls = [];
|
||||
let releaseStop;
|
||||
const stopped = new Promise((resolve) => { releaseStop = resolve; });
|
||||
let enabled = true;
|
||||
const manager = new PluginManager({
|
||||
database: {
|
||||
getActivePlugin: (id) => ({ id, enabled }),
|
||||
setEnabled(_id, value) { enabled = value; calls.push("disable"); },
|
||||
},
|
||||
packageStore: { async initialize() {} },
|
||||
contributionService: {
|
||||
async initialize() {},
|
||||
onPluginDisabled() { calls.push("contributions"); },
|
||||
},
|
||||
permissionEngine: { revokeAll() { assert.equal(enabled, false); calls.push("revoke"); } },
|
||||
runtimeSupervisor: { async stop() { calls.push("stop"); await stopped; calls.push("clean"); } },
|
||||
});
|
||||
let complete = false;
|
||||
const revoke = manager.revokePermissions("com.example.revoke").then((value) => { complete = true; return value; });
|
||||
const enable = manager.setEnabled("com.example.revoke", false);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.deepEqual(calls, ["disable", "revoke", "contributions", "stop"]);
|
||||
assert.equal(complete, false);
|
||||
releaseStop();
|
||||
assert.equal((await revoke).enabled, false);
|
||||
await enable;
|
||||
assert.deepEqual(calls.slice(4), ["clean", "disable", "contributions", "stop", "clean"]);
|
||||
});
|
||||
|
||||
test("failed runtime containment leaves the revoked plugin disabled and reports failure", async () => {
|
||||
let enabled = true;
|
||||
let revoked = false;
|
||||
const manager = new PluginManager({
|
||||
database: { getActivePlugin: () => ({ enabled }), setEnabled(_id, value) { enabled = value; } },
|
||||
packageStore: { async initialize() {} },
|
||||
contributionService: { async initialize() {}, onPluginDisabled() {} },
|
||||
permissionEngine: { revokeAll() { revoked = true; } },
|
||||
runtimeSupervisor: { async stop() { throw new Error("containment failed"); } },
|
||||
});
|
||||
await assert.rejects(manager.revokePermissions("com.example.revoke"), /containment failed/);
|
||||
assert.equal(enabled, false);
|
||||
assert.equal(revoked, true);
|
||||
});
|
||||
|
||||
test("revocation attempts every cleanup stage and aggregates independent failures", async () => {
|
||||
for (const failedStages of [["revoke"], ["contributions"], ["disable"], ["stop"], ["disable", "revoke", "contributions", "stop"]]) {
|
||||
const calls = [];
|
||||
const fail = (stage) => {
|
||||
calls.push(stage);
|
||||
if (failedStages.includes(stage)) throw new Error(`${stage} failed`);
|
||||
};
|
||||
const manager = new PluginManager({
|
||||
database: {
|
||||
getActivePlugin: () => ({ id: "com.example.revoke", enabled: false }),
|
||||
setEnabled() { fail("disable"); },
|
||||
},
|
||||
packageStore: { async initialize() {} },
|
||||
contributionService: { async initialize() {}, onPluginDisabled() { fail("contributions"); } },
|
||||
permissionEngine: { revokeAll() { fail("revoke"); } },
|
||||
runtimeSupervisor: { async stop() { fail("stop"); } },
|
||||
});
|
||||
await assert.rejects(manager.revokePermissions("com.example.revoke"), (error) => {
|
||||
if (failedStages.length === 1) return error.message === `${failedStages[0]} failed`;
|
||||
assert.ok(error instanceof AggregateError);
|
||||
assert.deepEqual(error.errors.map((entry) => entry.message), failedStages.map((stage) => `${stage} failed`));
|
||||
return true;
|
||||
});
|
||||
assert.deepEqual(calls, ["disable", "revoke", "contributions", "stop"]);
|
||||
}
|
||||
});
|
||||
@@ -36,10 +36,19 @@ class PluginViewHost {
|
||||
this.contributionService = options.contributionService;
|
||||
this.preloadPath = options.preloadPath ?? path.join(__dirname, "runtime", "viewPreload.cjs");
|
||||
this.instances = new Map();
|
||||
this.opening = new Map();
|
||||
this.closed = false;
|
||||
this.isLocked = options.isLocked ?? (() => false);
|
||||
this.subscriptions = [];
|
||||
const unsubscribeLock = options.subscribeLock?.((state) => {
|
||||
if (!state.locked) return;
|
||||
for (const opening of this.opening.values()) opening.cancelled = true;
|
||||
for (const instanceId of [...this.instances.keys()]) void this.close(instanceId, undefined, "app-locked");
|
||||
});
|
||||
this.subscriptions.push({ dispose: () => unsubscribeLock?.() });
|
||||
this.byWebContentsId = new Map();
|
||||
this.closeListeners = new Set();
|
||||
this.environment = { locale: "en", theme: "system", reducedMotion: false, highContrast: false };
|
||||
this.subscriptions = [];
|
||||
this.#registerIpc();
|
||||
this.subscriptions.push(this.contributionService.onDidPostViewMessage((event) => {
|
||||
for (const instance of this.instances.values()) {
|
||||
@@ -49,10 +58,14 @@ class PluginViewHost {
|
||||
}
|
||||
}));
|
||||
this.subscriptions.push(this.contributionService.onDidChange?.((event) => {
|
||||
if (!["plugin-disabled", "runtime-stopped", "runtime-error", "runtime-quarantined"].includes(event.reason)
|
||||
|| !event.pluginId) return;
|
||||
for (const instance of [...this.instances.values()]) {
|
||||
if (instance.pluginId === event.pluginId) void this.close(instance.instanceId, undefined, event.reason);
|
||||
if (["plugin-disabled", "runtime-stopped", "runtime-error", "runtime-quarantined"].includes(event.reason)
|
||||
&& instance.pluginId === event.pluginId) {
|
||||
void this.close(instance.instanceId, undefined, event.reason);
|
||||
} else if (["vault-page", "terminal-sidebar"].includes(instance.location)) {
|
||||
try { this.contributionService.assertViewActivationCurrent(instance.activation, { context: instance.context }); }
|
||||
catch { void this.close(instance.instanceId, undefined, "contribution-unavailable"); }
|
||||
}
|
||||
}
|
||||
}));
|
||||
}
|
||||
@@ -115,10 +128,45 @@ class PluginViewHost {
|
||||
|
||||
async open(payload, sender) {
|
||||
if (!payload || typeof payload !== "object") throw new TypeError("Plugin view request is invalid");
|
||||
const owner = this.electron.BrowserWindow.fromWebContents(sender);
|
||||
if (this.closed || this.isLocked() || !owner || owner.isDestroyed()) throw new Error("Plugin view owner is unavailable");
|
||||
const instanceId = typeof payload.instanceId === "string" && payload.instanceId.length <= 128
|
||||
? payload.instanceId : randomUUID();
|
||||
if (this.instances.has(instanceId) || this.opening.has(instanceId)) throw new Error(`Plugin view already exists: ${instanceId}`);
|
||||
const opening = { owner, cancelled: false };
|
||||
const cancel = () => { opening.cancelled = true; };
|
||||
const rendererGone = () => { void this.close(instanceId, undefined, "owner-renderer-gone"); };
|
||||
const navigate = (details, _url, isInPlace, isMainFrame) => {
|
||||
if ((details?.isMainFrame ?? isMainFrame) !== false && (details?.isSameDocument ?? isInPlace) !== true) rendererGone();
|
||||
};
|
||||
const cleanupOwner = () => {
|
||||
sender.removeListener?.("render-process-gone", rendererGone);
|
||||
sender.removeListener?.("did-start-navigation", navigate);
|
||||
};
|
||||
this.opening.set(instanceId, opening);
|
||||
owner.once?.("closed", cancel);
|
||||
sender.on?.("render-process-gone", rendererGone);
|
||||
sender.on?.("did-start-navigation", navigate);
|
||||
try { return await this.#open({ ...payload, instanceId }, sender, opening); }
|
||||
finally {
|
||||
owner.removeListener?.("closed", cancel);
|
||||
const instance = this.instances.get(instanceId);
|
||||
if (instance) instance.cleanupOwner = cleanupOwner;
|
||||
else cleanupOwner();
|
||||
if (this.opening.get(instanceId) === opening) this.opening.delete(instanceId);
|
||||
}
|
||||
}
|
||||
|
||||
#assertOpening(opening) {
|
||||
if (this.closed || this.isLocked() || opening.cancelled || opening.owner.isDestroyed()) throw new Error("Plugin view opening was cancelled");
|
||||
}
|
||||
|
||||
async #open(payload, sender, opening) {
|
||||
const { plugin, view, identity } = await this.contributionService.activateView(
|
||||
payload.viewId,
|
||||
{ context: payload.context },
|
||||
);
|
||||
this.#assertOpening(opening);
|
||||
const activation = Object.freeze({
|
||||
pluginId: plugin.id,
|
||||
pluginVersion: plugin.activeVersion,
|
||||
@@ -135,6 +183,7 @@ class PluginViewHost {
|
||||
: randomUUID();
|
||||
if (this.instances.has(instanceId)) throw new Error(`Plugin view already exists: ${instanceId}`);
|
||||
const packageRoot = await this.packageStore.preparePackageRoot(plugin);
|
||||
this.#assertOpening(opening);
|
||||
this.contributionService.assertViewActivationCurrent(activation, { context: payload.context });
|
||||
const partition = `netcatty-plugin-view-${randomUUID()}`;
|
||||
const pluginSession = this.electron.session.fromPartition(partition, { cache: false });
|
||||
@@ -183,12 +232,22 @@ class PluginViewHost {
|
||||
if (url !== registration.url) event.preventDefault();
|
||||
});
|
||||
contents.on("will-attach-webview", (event) => event.preventDefault());
|
||||
contents.once("render-process-gone", () => { void this.close(instanceId, undefined, "view-renderer-gone"); });
|
||||
if (["vault-page", "terminal-sidebar"].includes(view.location)) contents.on("before-input-event", (event, input) => {
|
||||
if (input.type === "keyDown" && input.key === "Escape" && !input.control && !input.meta && !input.alt && !input.shift) {
|
||||
event.preventDefault();
|
||||
void this.close(instanceId, undefined, "host-escape");
|
||||
}
|
||||
});
|
||||
const ownerClosed = () => { void this.close(instanceId, undefined, "owner-closed"); };
|
||||
const instance = {
|
||||
instanceId,
|
||||
pluginId: plugin.id,
|
||||
viewId: view.id,
|
||||
scopeId,
|
||||
location: view.location,
|
||||
activation,
|
||||
context: structuredClone(payload.context ?? {}),
|
||||
owner,
|
||||
ownerClosed,
|
||||
view: contentsView,
|
||||
@@ -197,8 +256,13 @@ class PluginViewHost {
|
||||
sessionRegistration,
|
||||
state: "opening",
|
||||
attached: false,
|
||||
visible: !["vault-page", "terminal-sidebar"].includes(view.location),
|
||||
};
|
||||
this.instances.set(instanceId, instance);
|
||||
contents.on("focus", () => {
|
||||
if (this.instances.get(instanceId) !== instance || instance.state !== "open") return;
|
||||
try { owner.webContents?.send("netcatty:plugins:view-focused", { instanceId }); } catch {}
|
||||
});
|
||||
this.byWebContentsId.set(contents.id, instance);
|
||||
owner.once?.("closed", ownerClosed);
|
||||
contents.once("destroyed", () => { void this.close(instanceId, undefined, "contents-destroyed"); });
|
||||
@@ -208,6 +272,10 @@ class PluginViewHost {
|
||||
|| owner.isDestroyed() || contents.isDestroyed()) {
|
||||
throw new Error("Plugin view ownership changed while opening");
|
||||
}
|
||||
this.#assertOpening(opening);
|
||||
// Embedded pages start hidden until the trusted renderer checks host
|
||||
// dialogs/lock/popovers. A late open must never cover a security surface.
|
||||
if (["vault-page", "terminal-sidebar"].includes(view.location)) contentsView.setVisible(false);
|
||||
owner.contentView.addChildView(contentsView);
|
||||
instance.attached = true;
|
||||
contentsView.setBounds(normalizeBounds(payload.bounds));
|
||||
@@ -232,7 +300,17 @@ class PluginViewHost {
|
||||
setVisible(instanceId, visible, sender) {
|
||||
if (typeof visible !== "boolean") throw new TypeError("Plugin view visibility must be a boolean");
|
||||
const instance = this.#ownedInstance(instanceId, sender);
|
||||
if (visible && this.isLocked()) throw new Error("Plugin view owner is unavailable");
|
||||
const rising = visible && !instance.visible;
|
||||
instance.visible = visible;
|
||||
instance.view.setVisible(visible);
|
||||
if (rising && instance.location === "vault-page" && instance.owner.isFocused?.()) instance.contents.focus?.();
|
||||
}
|
||||
|
||||
focus(instanceId, sender) {
|
||||
const instance = this.#ownedInstance(instanceId, sender);
|
||||
if (!instance.visible || this.isLocked() || instance.owner.isFocused?.() !== true) return;
|
||||
instance.contents.focus?.();
|
||||
}
|
||||
|
||||
async postMessage(instanceId, message, sender) {
|
||||
@@ -250,11 +328,17 @@ class PluginViewHost {
|
||||
}
|
||||
|
||||
async close(instanceId, sender, reason = sender ? "renderer-requested" : "host-requested") {
|
||||
const opening = this.opening.get(instanceId);
|
||||
if (opening) {
|
||||
if (sender && this.electron.BrowserWindow.fromWebContents(sender) !== opening.owner) throw new Error("Untrusted plugin view owner");
|
||||
opening.cancelled = true;
|
||||
}
|
||||
const instance = this.instances.get(instanceId);
|
||||
if (!instance) return;
|
||||
if (sender) this.#ownedInstance(instanceId, sender);
|
||||
this.instances.delete(instanceId);
|
||||
this.byWebContentsId.delete(instance.contents.id);
|
||||
try { instance.cleanupOwner?.(); } catch {}
|
||||
try { instance.owner.removeListener?.("closed", instance.ownerClosed); } catch {}
|
||||
if (instance.attached) {
|
||||
try { instance.owner.contentView.removeChildView(instance.view); } catch {}
|
||||
@@ -279,6 +363,8 @@ class PluginViewHost {
|
||||
}
|
||||
|
||||
async shutdown() {
|
||||
this.closed = true;
|
||||
for (const opening of this.opening.values()) opening.cancelled = true;
|
||||
await Promise.all([...this.instances.keys()].map((instanceId) => this.close(instanceId)));
|
||||
for (const subscription of this.subscriptions.splice(0)) subscription?.dispose?.();
|
||||
this.closeListeners.clear();
|
||||
|
||||
@@ -25,6 +25,7 @@ function fixture(options = {}) {
|
||||
if (options.loadURL) await options.loadURL(this);
|
||||
}
|
||||
send(channel, payload) { this.sent.push([channel, payload]); }
|
||||
focus() { this.emit("focus"); }
|
||||
isDestroyed() { return this.destroyed; }
|
||||
close() { this.destroyed = true; this.emit("destroyed"); }
|
||||
}
|
||||
@@ -87,9 +88,10 @@ function fixture(options = {}) {
|
||||
onDidPostViewMessage(listener) { viewListener = listener; return { dispose() {} }; },
|
||||
onDidChange(listener) { changeListener = listener; return { dispose() {} }; },
|
||||
async activateView() {
|
||||
await options.activateView?.();
|
||||
return {
|
||||
plugin: { id: "com.example.view", activeVersion: "1.0.0" },
|
||||
view: { id: "com.example.view.panel", entry: "view.html", location: "aside" },
|
||||
view: { id: "com.example.view.panel", entry: "view.html", location: options.location ?? "aside" },
|
||||
identity: { runtimeId: "runtime-view-1", pluginVersion: "1.0.0" },
|
||||
};
|
||||
},
|
||||
@@ -121,6 +123,8 @@ function fixture(options = {}) {
|
||||
setViewState(pluginId, viewId, scopeId, value) { state.set(`${pluginId}:${viewId}:${scopeId}`, value); },
|
||||
},
|
||||
contributionService,
|
||||
isLocked: options.isLocked,
|
||||
subscribeLock: options.subscribeLock,
|
||||
preloadPath: "/runtime/viewPreload.cjs",
|
||||
});
|
||||
function createOwner() {
|
||||
@@ -131,7 +135,7 @@ function fixture(options = {}) {
|
||||
addChildView(view) { owner.children.push(view); },
|
||||
removeChildView(view) { owner.children = owner.children.filter((entry) => entry !== view); },
|
||||
};
|
||||
const sender = {};
|
||||
const sender = new EventEmitter();
|
||||
senderOwners.set(sender, owner);
|
||||
return { owner, sender };
|
||||
}
|
||||
@@ -341,3 +345,132 @@ test("view cleanup failures cannot suppress owner removal or the renderer close
|
||||
reason: "renderer-requested",
|
||||
}]);
|
||||
});
|
||||
|
||||
test("Vault pages start hidden and are withdrawn when their current contribution is no longer available", async () => {
|
||||
const value = fixture({ location: "vault-page" });
|
||||
const { owner, sender } = value.createOwner();
|
||||
const result = await value.host.open({ viewId: "com.example.view.panel", scopeId: "window-1:vault-page" }, sender);
|
||||
assert.equal(owner.children.length, 1);
|
||||
assert.equal(value.views[0].visible, false, "host modal checks precede native visibility");
|
||||
value.host.setVisible(result.instanceId, true, sender);
|
||||
assert.equal(value.views[0].visible, true);
|
||||
value.setActivationCurrent(false);
|
||||
value.changeListener()({ reason: "context-key-changed" });
|
||||
assert.equal(owner.children.length, 0);
|
||||
assert.equal(value.views[0].webContents.destroyed, true);
|
||||
await value.host.shutdown();
|
||||
});
|
||||
|
||||
test("closing a reserved owner instance cancels activation before any native page is constructed", async () => {
|
||||
let release;
|
||||
const gate = new Promise((resolve) => { release = resolve; });
|
||||
const value = fixture({ location: "vault-page", activateView: () => gate });
|
||||
const { owner, sender } = value.createOwner();
|
||||
const other = value.createOwner();
|
||||
const opening = value.host.open({ viewId: "com.example.view.panel", scopeId: "window-1:vault-page", instanceId: "reserved-page" }, sender);
|
||||
await assert.rejects(value.host.close("reserved-page", other.sender), /Untrusted/);
|
||||
await value.host.close("reserved-page", sender);
|
||||
release();
|
||||
await assert.rejects(opening, /cancelled/);
|
||||
assert.equal(value.views.length, 0);
|
||||
assert.equal(owner.children.length, 0);
|
||||
assert.equal(value.host.opening.size, 0);
|
||||
await value.host.shutdown();
|
||||
});
|
||||
|
||||
test("app lock closes attached pages and cancels pending opens, with subscription cleanup", async () => {
|
||||
let listener, locked = false, disposed = 0;
|
||||
const value = fixture({ location: "vault-page", isLocked: () => locked,
|
||||
subscribeLock(fn) { listener = fn; return () => { disposed++; }; } });
|
||||
const { owner, sender } = value.createOwner();
|
||||
const result = await value.host.open({ viewId: "com.example.view.panel", scopeId: "window-1:vault-page" }, sender);
|
||||
locked = true; listener({ locked: true });
|
||||
assert.equal(owner.children.length, 0);
|
||||
assert.equal(value.views[0].webContents.destroyed, true);
|
||||
await assert.rejects(value.host.open({ viewId: "com.example.view.panel", scopeId: "window-1:vault-page" }, sender), /unavailable/);
|
||||
assert.throws(() => value.host.setVisible(result.instanceId, true, sender));
|
||||
await value.host.shutdown();
|
||||
assert.equal(disposed, 1);
|
||||
});
|
||||
|
||||
test("browser Scratchpad saves, reopens and clears only its own view state through the real host IPC", async () => {
|
||||
const { readFile } = require("node:fs/promises");
|
||||
const path = require("node:path");
|
||||
const { JSDOM } = require("jsdom");
|
||||
const { VIEW_CHANNELS } = require("./pluginViewHost.cjs");
|
||||
const html = await readFile(path.join(__dirname, "../../examples/plugins/vault-scratchpad/page.html"), "utf8");
|
||||
const script = await readFile(path.join(__dirname, "../../examples/plugins/vault-scratchpad/page.js"), "utf8");
|
||||
const value = fixture({ location: "vault-page" });
|
||||
const { sender } = value.createOwner();
|
||||
async function open(scopeId) {
|
||||
const result = await value.host.open({ viewId: "com.example.view.panel", scopeId }, sender);
|
||||
const contents = value.host.instances.get(result.instanceId).contents;
|
||||
const dom = new JSDOM(html, { runScripts: "outside-only" });
|
||||
dom.window.netcattyView = {
|
||||
getState: async () => value.ipcHandlers.get(VIEW_CHANNELS.getState)({ sender: contents }),
|
||||
setState: async (state) => value.ipcHandlers.get(VIEW_CHANNELS.setState)({ sender: contents }, structuredClone(state)),
|
||||
};
|
||||
dom.window.eval(script);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
return { dom, document: dom.window.document, id: result.instanceId, contents };
|
||||
}
|
||||
const first = await open("window-1:vault-page");
|
||||
first.document.getElementById("draft").value = "synthetic private draft";
|
||||
first.document.getElementById("save").click();
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.match(first.document.getElementById("status").textContent, /saved/);
|
||||
await value.host.close(first.id, sender); first.dom.window.close();
|
||||
const separate = await open("window-2:vault-page");
|
||||
assert.equal(separate.document.getElementById("draft").value, "", "a different host-owned state scope does not inherit this draft");
|
||||
const reopened = await open("window-1:vault-page");
|
||||
assert.equal(reopened.document.getElementById("draft").value, "synthetic private draft");
|
||||
reopened.document.getElementById("clear").click();
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(reopened.document.getElementById("draft").value, "");
|
||||
const cleared = await open("window-1:vault-page");
|
||||
assert.equal(cleared.document.getElementById("draft").value, "");
|
||||
await value.host.shutdown();
|
||||
assert.throws(() => value.ipcHandlers.get(VIEW_CHANNELS.getState)({ sender: reopened.contents }), /Untrusted/);
|
||||
for (const panel of [separate, reopened, cleared]) panel.dom.window.close();
|
||||
});
|
||||
|
||||
test("host reload, host/view renderer exit, and host Escape close embedded pages", async () => {
|
||||
for (const action of ["reload", "host-exit", "view-exit", "escape"]) {
|
||||
const value = fixture({ location: "vault-page" });
|
||||
const { sender, owner } = value.createOwner();
|
||||
const opened = await value.host.open({ viewId: "com.example.view.panel", scopeId: "window-1:vault-page" }, sender);
|
||||
const contents = value.views[0].webContents;
|
||||
sender.emit("did-start-navigation", { isMainFrame: true, isSameDocument: true });
|
||||
assert.equal(owner.children.length, 1);
|
||||
if (action === "reload") sender.emit("did-start-navigation", { isMainFrame: true, isSameDocument: false });
|
||||
if (action === "host-exit") sender.emit("render-process-gone");
|
||||
if (action === "view-exit") contents.emit("render-process-gone");
|
||||
if (action === "escape") {
|
||||
let prevented = false;
|
||||
contents.emit("before-input-event", { preventDefault() { prevented = true; } }, { type: "keyDown", key: "Escape" });
|
||||
assert.equal(prevented, true);
|
||||
}
|
||||
assert.equal(owner.children.length, 0, action);
|
||||
assert.equal(value.host.instances.has(opened.instanceId), false, action);
|
||||
assert.equal(sender.listenerCount("render-process-gone"), 0, action);
|
||||
await value.host.shutdown();
|
||||
}
|
||||
});
|
||||
|
||||
test("sidebar native focus is explicit, owner-bound and cannot raise a hidden or locked page", async () => {
|
||||
let locked = false;
|
||||
const value = fixture({ location: "terminal-sidebar", isLocked: () => locked });
|
||||
const { owner, sender } = value.createOwner(); const other = value.createOwner();
|
||||
const events = [];
|
||||
sender.send = (channel, event) => events.push({ channel, event }); owner.webContents = sender; owner.isFocused = () => true;
|
||||
const { instanceId } = await value.host.open({ viewId: "com.example.view.panel", scopeId: "window-1:terminal-sidebar" }, sender);
|
||||
assert.equal(value.views[0].visible, false);
|
||||
value.host.focus(instanceId, sender); assert.deepEqual(events, []);
|
||||
value.host.setVisible(instanceId, true, sender); assert.deepEqual(events, [], "revealing multiple panes does not focus each one");
|
||||
assert.throws(() => value.host.focus(instanceId, other.sender), /another window/i);
|
||||
value.host.focus(instanceId, sender);
|
||||
assert.deepEqual(events, [{ channel: "netcatty:plugins:view-focused", event: { instanceId } }]);
|
||||
locked = true; value.host.focus(instanceId, sender); assert.equal(events.length, 1);
|
||||
locked = false; value.setActivationCurrent(false); value.changeListener()({ reason: "context-key-changed" });
|
||||
assert.equal(owner.children.length, 0); await value.host.shutdown();
|
||||
});
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
PLUGIN_ERROR_WIRE_CODES,
|
||||
pluginErrorToRpcError,
|
||||
} from "@netcatty/plugin-sdk";
|
||||
import { createMessagePortStreamEnvelope } from "@netcatty/plugin-contract";
|
||||
import { createMessagePortStreamEnvelope, PLUGIN_HOST_INTERACTION_LIMITS } from "@netcatty/plugin-contract";
|
||||
import { createPluginStreamEndpoint } from "./pluginStreamEndpoint.mjs";
|
||||
|
||||
let terminalInterceptorTransportPromise;
|
||||
@@ -365,6 +365,16 @@ function createPluginContext(config, client, runtimeApi) {
|
||||
...(options?.ttlMs === undefined ? {} : { ttlMs: options.ttlMs }),
|
||||
}),
|
||||
};
|
||||
const interact = (params) => client.request("ui.interact", params, { deadlineMs: PLUGIN_HOST_INTERACTION_LIMITS.timeoutMs })
|
||||
.then((result) => result.cancelled ? undefined : result.value);
|
||||
const ui = {
|
||||
quickPick: (title, items) => interact({ kind: "quickPick", title, items }),
|
||||
input: (title, initialValue) => interact({ kind: "input", title, ...(initialValue === undefined ? {} : { initialValue }) }),
|
||||
};
|
||||
const clipboard = {
|
||||
readText: (title) => interact({ kind: "clipboard.read", title }),
|
||||
writeText: (text, title) => interact({ kind: "clipboard.write", title, text }).then((value) => value === true),
|
||||
};
|
||||
const network = {
|
||||
request: (request) => client.request("network.request", request, {
|
||||
deadlineMs: forwardedDeadline(request?.timeoutMs, 300_000),
|
||||
@@ -543,6 +553,8 @@ function createPluginContext(config, client, runtimeApi) {
|
||||
secrets,
|
||||
credentials,
|
||||
network,
|
||||
ui,
|
||||
clipboard,
|
||||
filesystem,
|
||||
companions,
|
||||
streams,
|
||||
|
||||
@@ -12,6 +12,7 @@ const { createPluginPaths } = require("./paths.cjs");
|
||||
const { RPC_ERRORS } = require("./rpcRouter.cjs");
|
||||
const { RuntimeSupervisor } = require("./runtimeSupervisor.cjs");
|
||||
const { createContainmentError } = require("./utilityPluginRuntime.cjs");
|
||||
const { PluginContributionService } = require("./contributionService.cjs");
|
||||
|
||||
function pluginManifest(overrides = {}) {
|
||||
return {
|
||||
@@ -66,6 +67,31 @@ function createFixture(context, runtimeFactory, supervisorOptions = {}) {
|
||||
return { database, manifest, runtimeOptions, supervisor };
|
||||
}
|
||||
|
||||
test("ordinary command activation A cannot dispatch on replacement runtime B", async (t) => {
|
||||
let executed = 0, activation;
|
||||
const fixture = createFixture(t, () => ({ async start(config) { return { pluginId: config.pluginId, pluginVersion: config.pluginVersion,
|
||||
apiVersion: config.apiVersion, enabledFeatures: config.enabledFeatures }; }, async stop() {}, async request() { executed++; return null; } }));
|
||||
t.after(() => fixture.supervisor.shutdown());
|
||||
const manifest = { ...fixture.manifest, contributes: { commands: [{ id: `${fixture.manifest.id}.ordinary`, title: "Ordinary" }] } };
|
||||
const finished = [];
|
||||
const service = new PluginContributionService({
|
||||
database: { listPlugins: () => [{ id: manifest.id, manifest, activeVersion: manifest.version, enabled: true, runtime: {} }] },
|
||||
onCommandFinished: (runtimeId) => finished.push(runtimeId),
|
||||
runtimeSupervisor: {
|
||||
async start(pluginId) {
|
||||
activation = await fixture.supervisor.start(pluginId);
|
||||
await fixture.supervisor.restart(pluginId);
|
||||
return activation;
|
||||
},
|
||||
request: (...args) => fixture.supervisor.request(...args),
|
||||
},
|
||||
});
|
||||
await assert.rejects(service.executeCommand(`${manifest.id}.ordinary`), /no longer matches the authorized activation/);
|
||||
assert.notEqual(fixture.supervisor.getRuntimeIdentity(manifest.id).runtimeId, activation.runtimeId);
|
||||
assert.equal(executed, 0, "replacement B receives no stale command");
|
||||
assert.deepEqual(finished, [activation.runtimeId]);
|
||||
});
|
||||
|
||||
test("supervisor prefers the ordinary browser runtime and enforces negotiated identity", async (context) => {
|
||||
const calls = [];
|
||||
const initialEnvironment = { locale: "en", theme: "light", reducedMotion: false, highContrast: false };
|
||||
|
||||
@@ -324,7 +324,7 @@ test("networkBroker.validate preserves credentialLease for registry dispatch", (
|
||||
url: "https://example.com/vault",
|
||||
credentialLease: {
|
||||
kind: "secret-lease",
|
||||
id: "lease-1",
|
||||
id: "l".repeat(32),
|
||||
operationId: "network:https://example.com",
|
||||
expiresAt: Date.now() + 60_000,
|
||||
},
|
||||
|
||||
@@ -102,7 +102,16 @@ function createPreloadApi(ctx) {
|
||||
listPluginCatalog: () => ipcRenderer.invoke("netcatty:plugins:catalog"),
|
||||
getPluginCatalogReadme: (id, selectionToken) => ipcRenderer.invoke("netcatty:plugins:catalog-readme", { id, selectionToken }),
|
||||
installCatalogPlugin: (id, selectionToken) => ipcRenderer.invoke("netcatty:plugins:catalog-install", { id, selectionToken }),
|
||||
setPluginInteractionReady: (ready) => ipcRenderer.invoke("netcatty:plugins:interaction-ready", { ready }),
|
||||
respondPluginInteraction: (response) => ipcRenderer.invoke("netcatty:plugins:interaction-response", response),
|
||||
onPluginInteraction: (callback) => {
|
||||
const listener = (_event, payload) => callback(payload);
|
||||
ipcRenderer.on("netcatty:plugins:host-interaction", listener);
|
||||
return () => ipcRenderer.removeListener("netcatty:plugins:host-interaction", listener);
|
||||
},
|
||||
listPlugins: () => ipcRenderer.invoke("netcatty:plugins:list"),
|
||||
getPluginPermissions: (pluginId) => ipcRenderer.invoke("netcatty:plugins:permissions", { pluginId }),
|
||||
revokePluginPermissions: (pluginId) => ipcRenderer.invoke("netcatty:plugins:revoke-permissions", { pluginId }),
|
||||
installPluginPackage: (archivePath, options) => ipcRenderer.invoke("netcatty:plugins:install", {
|
||||
archivePath,
|
||||
enable: options?.enable === true,
|
||||
@@ -261,6 +270,7 @@ function createPreloadApi(ctx) {
|
||||
},
|
||||
openPluginView: (payload) => ipcRenderer.invoke("netcatty:plugins:open-view", payload),
|
||||
closePluginView: (instanceId) => ipcRenderer.invoke("netcatty:plugins:close-view", { instanceId }),
|
||||
focusPluginView: (payload) => ipcRenderer.invoke("netcatty:plugins:focus-view", payload),
|
||||
setPluginViewBounds: (instanceId, bounds) => ipcRenderer.invoke("netcatty:plugins:set-view-bounds", { instanceId, bounds }),
|
||||
setPluginViewVisibility: (instanceId, visible) => ipcRenderer.invoke("netcatty:plugins:set-view-visibility", {
|
||||
instanceId,
|
||||
@@ -281,6 +291,11 @@ function createPreloadApi(ctx) {
|
||||
ipcRenderer.on("netcatty:plugins:view-closed", listener);
|
||||
return () => ipcRenderer.removeListener("netcatty:plugins:view-closed", listener);
|
||||
},
|
||||
onPluginViewFocused: (callback) => {
|
||||
const listener = (_event, payload) => callback(payload);
|
||||
ipcRenderer.on("netcatty:plugins:view-focused", listener);
|
||||
return () => ipcRenderer.removeListener("netcatty:plugins:view-focused", listener);
|
||||
},
|
||||
getPluginScopeCatalog: () => ipcRenderer.invoke("netcatty:plugins:get-scope-catalog"),
|
||||
setPluginScopeCatalog: (catalog) => ipcRenderer.invoke("netcatty:plugins:set-scope-catalog", catalog),
|
||||
onPluginScopeCatalogChanged: (callback) => {
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
# Host text workflow
|
||||
|
||||
This browser-only fixture uses host choice/input dialogs and explicit clipboard
|
||||
copy/share. It has no network, filesystem, host/session, credential, Node, or
|
||||
shell permission. Shared text stays in the current operation's memory and is
|
||||
not returned in the command result or written to plugin storage/logs.
|
||||
|
||||
Version 0.1.1 requires `host.interactions` and `commands.interactive`. Its one
|
||||
command explicitly declares the fixed 180-second interactive budget; ordinary
|
||||
commands keep 30 seconds and each dialog keeps 60 seconds. The host closes
|
||||
pending confirmations when the command fails or ends, without resetting quotas.
|
||||
These features add no new data permission. The permission list remains
|
||||
required `commands`/`menus`, optional `ui.interact`/`clipboard.read`/`clipboard.write`.
|
||||
|
||||
Pack the source after building the plugin packages:
|
||||
|
||||
```sh
|
||||
node packages/plugin-cli/dist/cli.js pack examples/plugins/host-interactions --out /tmp/host-interactions.ncpkg
|
||||
```
|
||||
|
||||
Import it disabled in the normal manager. Inspect permissions before enabling.
|
||||
Use one-use permissions for this check. With the unlocked main window focused,
|
||||
choose **Host text workflow** in the command palette.
|
||||
|
||||
- **Compose synthetic text** asks for text and then previews the exact clipboard
|
||||
replacement. Cancel leaves the clipboard unchanged.
|
||||
- **Share clipboard and uppercase it** first asks before reading the clipboard.
|
||||
The host previews the captured text and requires a second confirmation to
|
||||
share it with the plugin. The transformed output needs a separate copy
|
||||
confirmation. Clipboard changes after preview do not change the shared
|
||||
snapshot.
|
||||
|
||||
Use only synthetic clipboard text. Do not use account data or real secrets.
|
||||
The fixture does not require a persistent grant or any network destination.
|
||||
A different plugin that also has network permission could send shared text
|
||||
through its granted capabilities; reading text does not approve a new origin.
|
||||
|
||||
Blur/lock/close the owner window, cancel, revoke access, or disable the plugin
|
||||
to end a pending request. Late/duplicate replies cannot commit an old action.
|
||||
Only the focused unlocked main window is supported in this first slice. Other
|
||||
windows return unavailable instead of inheriting the request's authority.
|
||||
@@ -0,0 +1,21 @@
|
||||
import { definePlugin, PLUGIN_HOST_INTERACTION_FEATURE } from '@netcatty/plugin-sdk';
|
||||
|
||||
export default definePlugin({
|
||||
activate(context) {
|
||||
context.subscriptions.add(context.commands.registerCommand('com.netcatty.host-interactions.text', async () => {
|
||||
if (!context.enabledFeatures.has(PLUGIN_HOST_INTERACTION_FEATURE)) throw new Error('Host interactions are unavailable');
|
||||
const source = await context.ui.quickPick('Choose a text workflow', [
|
||||
{ id: 'compose', label: 'Compose synthetic text', description: 'Edit a draft, then explicitly confirm clipboard replacement.' },
|
||||
{ id: 'share', label: 'Share clipboard and uppercase it', description: 'Preview text, confirm sharing, then confirm the transformed copy.' },
|
||||
]);
|
||||
if (source === undefined) return { cancelled: true };
|
||||
const text = source === 'compose'
|
||||
? await context.ui.input('Edit synthetic text', 'synthetic text')
|
||||
: await context.clipboard.readText('Share clipboard text for uppercase conversion');
|
||||
if (text === undefined) return { cancelled: true };
|
||||
const output = source === 'share' ? text.toUpperCase() : text;
|
||||
const copied = await context.clipboard.writeText(output, 'Confirm the exact text to copy');
|
||||
return { copied, characters: output.length };
|
||||
}));
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"manifestVersion": 1,
|
||||
"id": "com.netcatty.host-interactions",
|
||||
"name": "host-interactions",
|
||||
"displayName": "Host text interactions",
|
||||
"description": "Synthetic host choice, input and explicit clipboard sharing workflow.",
|
||||
"version": "0.1.1",
|
||||
"publisher": "netcatty",
|
||||
"license": "GPL-3.0-or-later",
|
||||
"engines": {
|
||||
"netcatty": ">=0.0.0",
|
||||
"api": ">=0.1.0-internal <0.2.0"
|
||||
},
|
||||
"features": {
|
||||
"required": [
|
||||
"host.interactions",
|
||||
"commands.interactive"
|
||||
]
|
||||
},
|
||||
"main": {
|
||||
"browser": "browser.js"
|
||||
},
|
||||
"activationEvents": [
|
||||
"onCommand:com.netcatty.host-interactions.text"
|
||||
],
|
||||
"permissions": {
|
||||
"required": [
|
||||
"commands",
|
||||
"menus"
|
||||
],
|
||||
"optional": [
|
||||
"ui.interact",
|
||||
"clipboard.read",
|
||||
"clipboard.write"
|
||||
]
|
||||
},
|
||||
"contributes": {
|
||||
"commands": [
|
||||
{
|
||||
"id": "com.netcatty.host-interactions.text",
|
||||
"title": "Host text workflow",
|
||||
"interactive": true
|
||||
}
|
||||
],
|
||||
"menus": [
|
||||
{
|
||||
"command": "com.netcatty.host-interactions.text",
|
||||
"location": "commandPalette"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
# Synthetic credential lease fixture
|
||||
|
||||
This browser-only package exercises feature negotiation, plugin-owned secret
|
||||
references, a one-use host-consumed network lease, and the host grant panel.
|
||||
It contains only synthetic data and targets `https://example.invalid`.
|
||||
A synthetic host test supplies the HTTP response. The normal app reports a
|
||||
network failure at that deliberately non-routable address after permission.
|
||||
This fixture does not prove a live third-party integration.
|
||||
|
||||
Pack the source after building the plugin packages:
|
||||
|
||||
```sh
|
||||
node packages/plugin-cli/dist/cli.js pack examples/plugins/lease-http --out /tmp/lease-http.ncpkg
|
||||
```
|
||||
|
||||
In the normal plugin manager, import the package disabled, inspect its declared
|
||||
permissions, and enable it. The command palette contains **Synthetic credential
|
||||
lease request**. Reject a permission prompt to verify that the request stops.
|
||||
OS secret storage must be available to proceed past `secrets.set`.
|
||||
|
||||
The package detail's **Saved grants** panel shows retained decisions. One-use
|
||||
decisions are not retained. **Disable and revoke all grants** disables the
|
||||
plugin and waits for its runtime cleanup. Re-enable it to request access again.
|
||||
Recent events show event type and permission without terminal or secret data.
|
||||
Do not change the destination or substitute real credentials for this check.
|
||||
@@ -0,0 +1,24 @@
|
||||
import { definePlugin, PLUGIN_NETWORK_CREDENTIAL_LEASE_FEATURE } from '@netcatty/plugin-sdk';
|
||||
|
||||
// Isolated acceptance fixture: the target is deliberately non-routable.
|
||||
// Tests provide a synthetic HTTP transport. Never substitute a real credential.
|
||||
export default definePlugin({
|
||||
activate(context) {
|
||||
context.subscriptions.add(context.commands.registerCommand('com.netcatty.lease-http.request', async () => {
|
||||
if (!context.enabledFeatures.has(PLUGIN_NETWORK_CREDENTIAL_LEASE_FEATURE)) {
|
||||
throw new Error('Host does not advertise network credential leases');
|
||||
}
|
||||
const secret = await context.secrets.set('synthetic-token', 'synthetic-acceptance-token');
|
||||
const credentialLease = await context.credentials.createLease(secret, {
|
||||
operationId: 'network:https://example.invalid',
|
||||
purpose: 'Authenticate an isolated synthetic request',
|
||||
});
|
||||
const response = await context.network.request({
|
||||
url: 'https://example.invalid/synthetic',
|
||||
credentialLease,
|
||||
authorization: { scheme: 'Bearer' },
|
||||
});
|
||||
return { status: response.status };
|
||||
}));
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"manifestVersion": 1,
|
||||
"id": "com.netcatty.lease-http",
|
||||
"name": "lease-http",
|
||||
"displayName": "Synthetic credential lease",
|
||||
"description": "Isolated credential lease and grant revocation acceptance fixture.",
|
||||
"version": "0.1.0",
|
||||
"publisher": "netcatty",
|
||||
"license": "GPL-3.0-or-later",
|
||||
"engines": { "netcatty": ">=0.0.0", "api": ">=0.1.0-internal <0.2.0" },
|
||||
"features": { "required": ["network.credential-lease"] },
|
||||
"main": { "browser": "browser.js" },
|
||||
"activationEvents": ["onCommand:com.netcatty.lease-http.request"],
|
||||
"permissions": {
|
||||
"required": ["commands", "menus"],
|
||||
"optional": [
|
||||
{ "permission": "secrets", "resources": ["secret:synthetic-token"] },
|
||||
{ "permission": "network", "resources": ["https://example.invalid"] }
|
||||
]
|
||||
},
|
||||
"contributes": {
|
||||
"commands": [{ "id": "com.netcatty.lease-http.request", "title": "Synthetic credential lease request" }],
|
||||
"menus": [{ "command": "com.netcatty.lease-http.request", "location": "commandPalette" }]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
# Session Scratchpad
|
||||
|
||||
Fixture ID: `com.netcatty.session-scratchpad`, version `0.1.0`.
|
||||
Required permission: **views**, for this plugin's own pages only. Required
|
||||
feature: `views.terminal-sidebar`. No terminal/session read, clipboard, network,
|
||||
Vault, filesystem, credential or shell permission is requested.
|
||||
|
||||
```sh
|
||||
node packages/plugin-cli/dist/cli.js pack examples/plugins/session-scratchpad --out /tmp/session-scratchpad.ncpkg
|
||||
```
|
||||
|
||||
Import disabled through normal Settings, inspect and enable. Parent native QA
|
||||
must obtain a fixture-specific own-views approval; no previous plugin's grant
|
||||
applies. Use only an explicitly approved synthetic terminal target, without
|
||||
entering commands or connecting to real hosts for this page check.
|
||||
|
||||
In an open terminal, choose **Extension pages** in its toolbar, then **Session
|
||||
scratchpad**. This is a real existing side-panel pane. Save synthetic text,
|
||||
split beside a built-in tool, resize/move/magnify, return and reopen. The host
|
||||
keeps this page's state in a placement/session bucket; the page never receives
|
||||
the session ID, endpoint, cwd, title, history, selection or terminal output.
|
||||
This is plugin-owned text, not a Vault Note, and is not automatically synced.
|
||||
|
||||
For a separately approved second synthetic terminal/workspace focus target,
|
||||
verify that navigation closes the old native view and uses a separate draft
|
||||
bucket. Switch back to verify the saved first draft. Clear it to verify
|
||||
deletion. Close the pane, press Escape, disable/revoke, and verify cleanup.
|
||||
Settings/permission dialogs and overlapping menus must stay above native
|
||||
content. No new native target or persistent grant should be inferred from this
|
||||
README; parent coordinates the exact test and records source SHA/package hash.
|
||||
@@ -0,0 +1 @@
|
||||
export default { activate() {} };
|
||||
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"manifestVersion": 1,
|
||||
"id": "com.netcatty.session-scratchpad",
|
||||
"name": "session-scratchpad",
|
||||
"displayName": {
|
||||
"en": "Session Scratchpad",
|
||||
"zh-CN": "会话草稿板"
|
||||
},
|
||||
"version": "0.1.0",
|
||||
"publisher": "netcatty",
|
||||
"license": "GPL-3.0-or-later",
|
||||
"engines": {
|
||||
"netcatty": ">=0.0.0",
|
||||
"api": ">=0.1.0-internal.1 <0.2.0"
|
||||
},
|
||||
"features": {
|
||||
"required": [
|
||||
"views.terminal-sidebar"
|
||||
]
|
||||
},
|
||||
"main": {
|
||||
"browser": "browser.js"
|
||||
},
|
||||
"activationEvents": [
|
||||
"onView:com.netcatty.session-scratchpad.page"
|
||||
],
|
||||
"permissions": {
|
||||
"required": [
|
||||
"views"
|
||||
]
|
||||
},
|
||||
"contributes": {
|
||||
"views": [
|
||||
{
|
||||
"id": "com.netcatty.session-scratchpad.page",
|
||||
"title": {
|
||||
"en": "Session scratchpad",
|
||||
"zh-CN": "会话草稿板"
|
||||
},
|
||||
"location": "terminal-sidebar",
|
||||
"entry": "page.html",
|
||||
"order": 10
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
:root { color-scheme: light dark; font-family: system-ui, sans-serif; }
|
||||
body { margin: 0; padding: 24px; } main { max-width: 720px; margin: auto; }
|
||||
textarea { display: block; box-sizing: border-box; width: 100%; margin: 12px 0; padding: 10px; font: inherit; }
|
||||
button { padding: 8px 12px; margin: 0 8px 0 0; font: inherit; }
|
||||
#status { min-height: 1.5em; }
|
||||
@@ -0,0 +1,7 @@
|
||||
<!doctype html>
|
||||
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Session Scratchpad</title><link rel="stylesheet" href="page.css"></head>
|
||||
<body><main><h1>Session scratchpad</h1><p>This page stores only text you type here, in this plugin's own view state. It cannot read Vault hosts, notes, credentials, sessions or the clipboard.</p>
|
||||
<label for="draft">Synthetic draft</label><textarea id="draft" maxlength="4096" rows="8" disabled></textarea>
|
||||
<button type="button" id="save" disabled>Save draft</button><button type="button" id="clear" disabled>Clear draft</button>
|
||||
<p id="status" role="status">Loading your plugin draft…</p></main><script src="page.js"></script></body></html>
|
||||
@@ -0,0 +1,25 @@
|
||||
"use strict";
|
||||
/* global document, window */
|
||||
const draft = document.getElementById("draft");
|
||||
const status = document.getElementById("status");
|
||||
let busy = false;
|
||||
async function save(clear) {
|
||||
if (busy) return;
|
||||
busy = true;
|
||||
try {
|
||||
const text = clear ? "" : draft.value;
|
||||
await window.netcattyView.setState({ version: 1, text });
|
||||
if (clear) draft.value = "";
|
||||
status.textContent = clear ? "Draft cleared." : "Draft saved in this plugin only.";
|
||||
} catch { status.textContent = "Could not save this draft."; }
|
||||
finally { busy = false; }
|
||||
}
|
||||
document.getElementById("save").addEventListener("click", () => { void save(false); });
|
||||
document.getElementById("clear").addEventListener("click", () => { void save(true); });
|
||||
window.netcattyView.getState().then((state) => {
|
||||
if (state?.version === 1 && typeof state.text === "string" && state.text.length <= 4096) draft.value = state.text;
|
||||
status.textContent = "Ready. Use synthetic text for this example.";
|
||||
draft.disabled = false;
|
||||
document.getElementById("save").disabled = false;
|
||||
document.getElementById("clear").disabled = false;
|
||||
}).catch(() => { status.textContent = "Could not load this plugin draft."; });
|
||||
@@ -0,0 +1,27 @@
|
||||
# Vault Scratchpad
|
||||
|
||||
Browser-only example of an actual Vault extension page. It needs only `views`
|
||||
and the required `views.vault-pages` feature. No host/session/Vault data,
|
||||
clipboard, network, filesystem, credentials or shell permission is requested.
|
||||
Text explicitly typed here is stored in this plugin's own bounded view state;
|
||||
it is not a Netcatty Note and is not automatically cloud-synced.
|
||||
|
||||
```sh
|
||||
node packages/plugin-cli/dist/cli.js pack examples/plugins/vault-scratchpad --out /tmp/vault-scratchpad.ncpkg
|
||||
```
|
||||
|
||||
Import disabled through normal Settings, inspect and enable it. Under Vault's
|
||||
**Extensions** group choose **Scratchpad**. The sandbox must fill Vault's main
|
||||
content area, retaining the built-in sidebar and top tabs. Opening its detail
|
||||
entry must navigate to this same Vault page, without creating a top plugin tab.
|
||||
|
||||
For native QA, a separate explicit approval for this fixture's own `views`
|
||||
permission is required; earlier fixtures' grants do not cover this ID. With
|
||||
synthetic text, save, return to Hosts and reopen; verify the draft. Clear and
|
||||
reopen to verify deletion. Cancel the first permission prompt to verify that no
|
||||
page attaches. Switch away during opening, then disable/revoke and verify the
|
||||
page/entry disappear. Closing or withdrawing a page returns to Hosts. A host
|
||||
modal or overlapping menu must hide the native view while it is present.
|
||||
|
||||
This source example is not independent/native acceptance evidence. Parent QA
|
||||
records the precise tested commit and package hash separately.
|
||||
@@ -0,0 +1 @@
|
||||
export default { activate() {} };
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"manifestVersion": 1,
|
||||
"id": "com.netcatty.vault-scratchpad",
|
||||
"name": "vault-scratchpad",
|
||||
"displayName": { "en": "Vault Scratchpad", "zh-CN": "Vault 草稿板" },
|
||||
"version": "0.1.0",
|
||||
"publisher": "netcatty",
|
||||
"license": "GPL-3.0-or-later",
|
||||
"engines": { "netcatty": ">=0.0.0", "api": ">=0.1.0-internal.1 <0.2.0" },
|
||||
"features": { "required": ["views.vault-pages"] },
|
||||
"main": { "browser": "browser.js" },
|
||||
"activationEvents": ["onView:com.netcatty.vault-scratchpad.page"],
|
||||
"permissions": { "required": ["views"] },
|
||||
"contributes": {
|
||||
"views": [{
|
||||
"id": "com.netcatty.vault-scratchpad.page",
|
||||
"title": { "en": "Scratchpad", "zh-CN": "草稿板" },
|
||||
"location": "vault-page",
|
||||
"entry": "page.html",
|
||||
"order": 10
|
||||
}]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
:root { color-scheme: light dark; font-family: system-ui, sans-serif; }
|
||||
body { margin: 0; padding: 24px; } main { max-width: 720px; margin: auto; }
|
||||
textarea { display: block; box-sizing: border-box; width: 100%; margin: 12px 0; padding: 10px; font: inherit; }
|
||||
button { padding: 8px 12px; margin: 0 8px 0 0; font: inherit; }
|
||||
#status { min-height: 1.5em; }
|
||||
@@ -0,0 +1,7 @@
|
||||
<!doctype html>
|
||||
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Vault Scratchpad</title><link rel="stylesheet" href="page.css"></head>
|
||||
<body><main><h1>Scratchpad</h1><p>This page stores only text you type here, in this plugin's own view state. It cannot read Vault hosts, notes, credentials, sessions or the clipboard.</p>
|
||||
<label for="draft">Synthetic draft</label><textarea id="draft" maxlength="4096" rows="8" disabled></textarea>
|
||||
<button type="button" id="save" disabled>Save draft</button><button type="button" id="clear" disabled>Clear draft</button>
|
||||
<p id="status" role="status">Loading your plugin draft…</p></main><script src="page.js"></script></body></html>
|
||||
@@ -0,0 +1,25 @@
|
||||
"use strict";
|
||||
/* global document, window */
|
||||
const draft = document.getElementById("draft");
|
||||
const status = document.getElementById("status");
|
||||
let busy = false;
|
||||
async function save(clear) {
|
||||
if (busy) return;
|
||||
busy = true;
|
||||
try {
|
||||
const text = clear ? "" : draft.value;
|
||||
await window.netcattyView.setState({ version: 1, text });
|
||||
if (clear) draft.value = "";
|
||||
status.textContent = clear ? "Draft cleared." : "Draft saved in this plugin only.";
|
||||
} catch { status.textContent = "Could not save this draft."; }
|
||||
finally { busy = false; }
|
||||
}
|
||||
document.getElementById("save").addEventListener("click", () => { void save(false); });
|
||||
document.getElementById("clear").addEventListener("click", () => { void save(true); });
|
||||
window.netcattyView.getState().then((state) => {
|
||||
if (state?.version === 1 && typeof state.text === "string" && state.text.length <= 4096) draft.value = state.text;
|
||||
status.textContent = "Ready. Use synthetic text for this example.";
|
||||
draft.disabled = false;
|
||||
document.getElementById("save").disabled = false;
|
||||
document.getElementById("clear").disabled = false;
|
||||
}).catch(() => { status.textContent = "Could not load this plugin draft."; });
|
||||
Vendored
+16
@@ -355,6 +355,15 @@ declare global {
|
||||
artifact?: { url: string; sha256: string };
|
||||
}
|
||||
|
||||
interface NetcattyPluginPermissionSnapshot {
|
||||
grants: Array<{
|
||||
permission: string; resource: string; resourceKind: string;
|
||||
scope: 'always' | 'application' | 'session'; sessionId: string | null; grantedAt: number;
|
||||
matchesCurrentActivation: boolean;
|
||||
}>;
|
||||
audit: Array<{ event: string; createdAt: number; permission?: string; scope?: string }>;
|
||||
}
|
||||
|
||||
interface NetcattyInstalledPlugin {
|
||||
id: string;
|
||||
enabled: boolean;
|
||||
@@ -435,7 +444,12 @@ declare global {
|
||||
listPluginCatalog?(): Promise<NetcattyPluginCatalogSnapshot>;
|
||||
getPluginCatalogReadme?(id: string, selectionToken?: string): Promise<string>;
|
||||
installCatalogPlugin?(id: string, selectionToken?: string): Promise<NetcattyInstalledPlugin>;
|
||||
setPluginInteractionReady?(ready: boolean): Promise<boolean>;
|
||||
respondPluginInteraction?(response: import("@netcatty/plugin-contract").HostInteractionResponse): Promise<boolean>;
|
||||
onPluginInteraction?(callback: (event: import("@netcatty/plugin-contract").HostInteractionEvent) => void): () => void;
|
||||
listPlugins?(): Promise<NetcattyInstalledPlugin[]>;
|
||||
getPluginPermissions?(pluginId: string): Promise<NetcattyPluginPermissionSnapshot>;
|
||||
revokePluginPermissions?(pluginId: string): Promise<NetcattyInstalledPlugin>;
|
||||
installPluginPackage?(archivePath: string, options?: { enable?: boolean }): Promise<NetcattyInstalledPlugin>;
|
||||
setPluginEnabled?(pluginId: string, enabled: boolean): Promise<NetcattyInstalledPlugin>;
|
||||
restartPlugin?(pluginId: string): Promise<NetcattyInstalledPlugin>;
|
||||
@@ -567,10 +581,12 @@ declare global {
|
||||
closePluginView?(instanceId: string): Promise<void>;
|
||||
setPluginViewBounds?(instanceId: string, bounds: { x: number; y: number; width: number; height: number }): Promise<void>;
|
||||
setPluginViewVisibility?(instanceId: string, visible: boolean): Promise<void>;
|
||||
focusPluginView?(payload: import('@netcatty/plugin-contract').PluginViewFocusRequest): Promise<void>;
|
||||
postPluginViewMessage?(instanceId: string, message: unknown): Promise<void>;
|
||||
onPluginContributionsChanged?(callback: (event: { reason: string; pluginId: string | null; revision: number }) => void): () => void;
|
||||
onPluginViewMessage?(callback: (event: { pluginId: string; viewId: string; message: unknown }) => void): () => void;
|
||||
onPluginViewClosed?(callback: (event: NetcattyPluginViewClosedEvent) => void): () => void;
|
||||
onPluginViewFocused?(callback: (event: import('@netcatty/plugin-contract').PluginViewFocusEvent) => void): () => void;
|
||||
getPluginScopeCatalog?(): Promise<NetcattyPluginScopeCatalog>;
|
||||
setPluginScopeCatalog?(catalog: NetcattyPluginScopeCatalog): Promise<void>;
|
||||
onPluginScopeCatalogChanged?(callback: (catalog: NetcattyPluginScopeCatalog) => void): () => void;
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { validateManifestValue } from './manifest.js';
|
||||
import { checkPluginCompatibility } from './compatibility.js';
|
||||
|
||||
const manifest = JSON.parse(await readFile(new URL('../../../examples/plugins/host-interactions/netcatty.plugin.json', import.meta.url), 'utf8'));
|
||||
test('interactive command budget requires feature negotiation without changing ordinary command admission', () => {
|
||||
assert.equal(validateManifestValue(manifest).valid, true);
|
||||
assert.equal(validateManifestValue({ ...manifest, features: { required: ['host.interactions'] } }).valid, false);
|
||||
assert.equal(checkPluginCompatibility(manifest, { netcattyVersion: '1.0.0', features: ['host.interactions'] }).compatible, false);
|
||||
assert.equal(checkPluginCompatibility(manifest, { netcattyVersion: '1.0.0', features: ['host.interactions', 'commands.interactive'] }).compatible, true);
|
||||
const legacy = structuredClone(manifest); delete legacy.contributes.commands[0].interactive; legacy.features.required = ['host.interactions'];
|
||||
assert.equal(validateManifestValue(legacy).valid, true);
|
||||
});
|
||||
@@ -367,6 +367,14 @@ function validateSemantics(manifest: PluginManifest): string[] {
|
||||
}
|
||||
|
||||
const requiredFeatures = new Set(manifest.features?.required ?? []);
|
||||
if (manifest.contributes?.views?.some((view) => view.location === 'terminal-sidebar')
|
||||
&& !requiredFeatures.has('views.terminal-sidebar')) errors.push('Terminal sidebar pages require views.terminal-sidebar');
|
||||
if (manifest.contributes?.views?.some((view) => view.location === 'vault-page')
|
||||
&& !requiredFeatures.has('views.vault-pages')) {
|
||||
errors.push('Vault pages require views.vault-pages');
|
||||
}
|
||||
if (manifest.contributes?.commands?.some((command) => command.interactive)
|
||||
&& !requiredFeatures.has('commands.interactive')) errors.push('Interactive commands require commands.interactive');
|
||||
if (manifest.icon) {
|
||||
if (!requiredFeatures.has('metadata.brand-icon')) errors.push('Brand icons require metadata.brand-icon');
|
||||
if (!validRange(manifest.engines.api) || !satisfies('0.1.0-internal.1', manifest.engines.api) || satisfies('0.1.0-internal', manifest.engines.api)) errors.push('Brand icons require API >=0.1.0-internal.1 excluding the legacy revision');
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { validateManifestValue } from './manifest.js';
|
||||
import { checkPluginCompatibility } from './compatibility.js';
|
||||
const manifest = JSON.parse(await readFile(new URL('../../../examples/plugins/session-scratchpad/netcatty.plugin.json', import.meta.url), 'utf8'));
|
||||
test('terminal sidebar pages require views and their exact feature without session data permissions', () => {
|
||||
assert.equal(validateManifestValue(manifest).valid, true);
|
||||
assert.equal(validateManifestValue({ ...manifest, features: {} }).valid, false);
|
||||
assert.equal(validateManifestValue({ ...manifest, permissions: {} }).valid, false);
|
||||
assert.equal(checkPluginCompatibility(manifest, { netcattyVersion: '1.0.0', features: ['views.vault-pages'] }).compatible, false);
|
||||
assert.equal(checkPluginCompatibility(manifest, { netcattyVersion: '1.0.0', features: ['views.terminal-sidebar'] }).compatible, true);
|
||||
assert.deepEqual(manifest.permissions.required, ['views']);
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { validateManifestValue } from './manifest.js';
|
||||
import { checkPluginCompatibility } from './compatibility.js';
|
||||
|
||||
const manifest = JSON.parse(await readFile(new URL('../../../examples/plugins/vault-scratchpad/netcatty.plugin.json', import.meta.url), 'utf8'));
|
||||
test('Vault pages require exact feature negotiation and the existing views permission', () => {
|
||||
assert.equal(validateManifestValue(manifest).valid, true);
|
||||
assert.equal(validateManifestValue({ ...manifest, features: {} }).valid, false);
|
||||
assert.equal(validateManifestValue({ ...manifest, permissions: {} }).valid, false);
|
||||
assert.equal(checkPluginCompatibility(manifest, { netcattyVersion: '1.0.0', features: [] }).compatible, false);
|
||||
assert.equal(checkPluginCompatibility(manifest, { netcattyVersion: '1.0.0', features: ['views.vault-pages'] }).compatible, true);
|
||||
const legacy = { ...manifest, features: {}, contributes: { views: [{ ...manifest.contributes.views[0], location: 'tab' }] } };
|
||||
assert.equal(validateManifestValue(legacy).valid, true, 'legacy tab packages keep their contract');
|
||||
});
|
||||
@@ -178,6 +178,436 @@
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"HostInteractionLimits": {
|
||||
"const": {
|
||||
"textBytes": 4096,
|
||||
"requestBytes": 65536,
|
||||
"items": 100,
|
||||
"pendingPerRuntime": 1,
|
||||
"pendingTotal": 8,
|
||||
"requestsPerMinute": 10,
|
||||
"timeoutMs": 60000
|
||||
}
|
||||
},
|
||||
"HostInteractionFeature": {
|
||||
"const": "host.interactions"
|
||||
},
|
||||
"InteractiveCommandFeature": {
|
||||
"const": "commands.interactive"
|
||||
},
|
||||
"InteractiveCommandLimits": {
|
||||
"const": { "timeoutMs": 180000 }
|
||||
},
|
||||
"HostQuickPickItem": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"id",
|
||||
"label"
|
||||
],
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 128
|
||||
},
|
||||
"label": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 512
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"maxLength": 512
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"HostQuickPickRequest": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"kind",
|
||||
"title",
|
||||
"items"
|
||||
],
|
||||
"properties": {
|
||||
"kind": {
|
||||
"const": "quickPick"
|
||||
},
|
||||
"title": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 128,
|
||||
"pattern": "^[^\\u0000-\\u001f\\u007f]*$"
|
||||
},
|
||||
"items": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"maxItems": 100,
|
||||
"items": {
|
||||
"$ref": "#/$defs/HostQuickPickItem"
|
||||
}
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"HostSingleLineInputText": {
|
||||
"description": "Single-line host input. CR and LF are rejected before display and on submission; clipboard text remains multiline.",
|
||||
"type": "string",
|
||||
"maxLength": 4096,
|
||||
"pattern": "^[^\\u0000\\r\\n]*$"
|
||||
},
|
||||
"HostInputRequest": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"kind",
|
||||
"title"
|
||||
],
|
||||
"properties": {
|
||||
"kind": {
|
||||
"const": "input"
|
||||
},
|
||||
"title": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 128,
|
||||
"pattern": "^[^\\u0000-\\u001f\\u007f]*$"
|
||||
},
|
||||
"initialValue": { "$ref": "#/$defs/HostSingleLineInputText" }
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"HostClipboardReadRequest": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"kind",
|
||||
"title"
|
||||
],
|
||||
"properties": {
|
||||
"kind": {
|
||||
"const": "clipboard.read"
|
||||
},
|
||||
"title": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 128,
|
||||
"pattern": "^[^\\u0000-\\u001f\\u007f]*$"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"HostClipboardWriteRequest": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"kind",
|
||||
"title",
|
||||
"text"
|
||||
],
|
||||
"properties": {
|
||||
"kind": {
|
||||
"const": "clipboard.write"
|
||||
},
|
||||
"title": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 128,
|
||||
"pattern": "^[^\\u0000-\\u001f\\u007f]*$"
|
||||
},
|
||||
"text": {
|
||||
"type": "string",
|
||||
"maxLength": 4096,
|
||||
"pattern": "^[^\\u0000]*$"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"HostInteractionRequest": {
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/$defs/HostQuickPickRequest"
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/HostInputRequest"
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/HostClipboardReadRequest"
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/HostClipboardWriteRequest"
|
||||
}
|
||||
]
|
||||
},
|
||||
"HostInteractionResult": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"required": [
|
||||
"cancelled"
|
||||
],
|
||||
"properties": {
|
||||
"cancelled": {
|
||||
"const": true
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"required": [
|
||||
"cancelled",
|
||||
"value"
|
||||
],
|
||||
"properties": {
|
||||
"cancelled": {
|
||||
"const": false
|
||||
},
|
||||
"value": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"maxLength": 4096,
|
||||
"pattern": "^[^\\u0000]*$"
|
||||
},
|
||||
{
|
||||
"const": true
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
]
|
||||
},
|
||||
"HostInteractionResponse": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"requestId",
|
||||
"actionToken",
|
||||
"action"
|
||||
],
|
||||
"properties": {
|
||||
"requestId": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 128
|
||||
},
|
||||
"actionToken": {
|
||||
"type": "string",
|
||||
"minLength": 32,
|
||||
"maxLength": 256
|
||||
},
|
||||
"action": {
|
||||
"enum": [
|
||||
"cancel",
|
||||
"preview",
|
||||
"submit"
|
||||
]
|
||||
},
|
||||
"value": {
|
||||
"type": "string",
|
||||
"maxLength": 4096,
|
||||
"pattern": "^[^\\u0000]*$"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"HostInteractionClosed": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"requestId",
|
||||
"closed"
|
||||
],
|
||||
"properties": {
|
||||
"requestId": {
|
||||
"type": "string",
|
||||
"maxLength": 128
|
||||
},
|
||||
"closed": {
|
||||
"const": true
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"HostInteractionEvent": {
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/$defs/HostInteractionPrompt"
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/HostInteractionClosed"
|
||||
}
|
||||
]
|
||||
},
|
||||
"HostInteractionPrompt": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"requestId",
|
||||
"actionToken",
|
||||
"pluginId",
|
||||
"pluginVersion",
|
||||
"expiresAt",
|
||||
"phase",
|
||||
"request"
|
||||
],
|
||||
"properties": {
|
||||
"requestId": {
|
||||
"type": "string",
|
||||
"maxLength": 128
|
||||
},
|
||||
"actionToken": {
|
||||
"type": "string",
|
||||
"maxLength": 256
|
||||
},
|
||||
"pluginId": {
|
||||
"$ref": "#/$defs/PluginId"
|
||||
},
|
||||
"pluginVersion": {
|
||||
"$ref": "#/$defs/SemanticVersion"
|
||||
},
|
||||
"expiresAt": {
|
||||
"$ref": "#/$defs/SafePositiveInteger"
|
||||
},
|
||||
"phase": {
|
||||
"enum": [
|
||||
"request",
|
||||
"preview"
|
||||
]
|
||||
},
|
||||
"request": {
|
||||
"$ref": "#/$defs/HostInteractionRequest"
|
||||
},
|
||||
"previewText": {
|
||||
"type": "string",
|
||||
"maxLength": 4096,
|
||||
"pattern": "^[^\\u0000]*$"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"NetworkCredentialLeaseFeature": {
|
||||
"const": "network.credential-lease"
|
||||
},
|
||||
"PluginNetworkAuthorization": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"scheme": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"Basic",
|
||||
"Bearer"
|
||||
]
|
||||
},
|
||||
"username": {
|
||||
"type": "string",
|
||||
"maxLength": 8192,
|
||||
"pattern": "^[^\\u0000-\\u001f\\u007f]*$"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"PluginNetworkRequest": {
|
||||
"description": "Origin-authorized HTTP request. The host consumes a one-use credentialLease for network:<canonical origin>; plaintext credentials never return to the plugin.",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"url"
|
||||
],
|
||||
"properties": {
|
||||
"url": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 8192
|
||||
},
|
||||
"method": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"GET",
|
||||
"POST",
|
||||
"PUT",
|
||||
"PATCH",
|
||||
"DELETE",
|
||||
"HEAD"
|
||||
]
|
||||
},
|
||||
"headers": {
|
||||
"type": "object",
|
||||
"maxProperties": 64,
|
||||
"additionalProperties": {
|
||||
"type": "string",
|
||||
"maxLength": 8192
|
||||
}
|
||||
},
|
||||
"body": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"encoding",
|
||||
"data"
|
||||
],
|
||||
"properties": {
|
||||
"encoding": {
|
||||
"enum": [
|
||||
"utf8",
|
||||
"base64"
|
||||
]
|
||||
},
|
||||
"data": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"timeoutMs": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 300000
|
||||
},
|
||||
"credentialLease": {
|
||||
"$ref": "#/$defs/SecretLeaseRef"
|
||||
},
|
||||
"authorization": {
|
||||
"$ref": "#/$defs/PluginNetworkAuthorization"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"PluginNetworkResponse": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"url",
|
||||
"status",
|
||||
"headers",
|
||||
"body"
|
||||
],
|
||||
"properties": {
|
||||
"url": {
|
||||
"type": "string"
|
||||
},
|
||||
"status": {
|
||||
"type": "integer"
|
||||
},
|
||||
"headers": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"body": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"encoding",
|
||||
"data"
|
||||
],
|
||||
"properties": {
|
||||
"encoding": {
|
||||
"const": "base64"
|
||||
},
|
||||
"data": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"SemanticVersion": {
|
||||
"type": "string",
|
||||
"pattern": "^(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)(?:-(?:0|[1-9]\\d*|\\d*[A-Za-z-][0-9A-Za-z-]*)(?:\\.(?:0|[1-9]\\d*|\\d*[A-Za-z-][0-9A-Za-z-]*))*)?(?:\\+[0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*)?$"
|
||||
@@ -210,6 +640,7 @@
|
||||
"commands",
|
||||
"menus",
|
||||
"views",
|
||||
"ui.interact",
|
||||
"clipboard.read",
|
||||
"clipboard.write",
|
||||
"terminal.metadata",
|
||||
@@ -247,6 +678,7 @@
|
||||
"commands",
|
||||
"menus",
|
||||
"views",
|
||||
"ui.interact",
|
||||
"clipboard.read",
|
||||
"clipboard.write",
|
||||
"terminal.metadata",
|
||||
@@ -523,7 +955,8 @@
|
||||
"category": { "$ref": "#/$defs/LocalizedText" },
|
||||
"description": { "$ref": "#/$defs/LocalizedText" },
|
||||
"icon": { "$ref": "#/$defs/IconReference" },
|
||||
"enablement": { "$ref": "#/$defs/ContextKeyExpression" }
|
||||
"enablement": { "$ref": "#/$defs/ContextKeyExpression" },
|
||||
"interactive": { "type": "boolean", "default": false, "description": "Require commands.interactive for this command's fixed bounded host interaction budget. Does not grant UI or clipboard permissions." }
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
@@ -572,8 +1005,21 @@
|
||||
},
|
||||
"ViewLocation": {
|
||||
"type": "string",
|
||||
"enum": ["aside", "panel", "tab", "modal", "settings"]
|
||||
"enum": ["aside", "panel", "tab", "modal", "settings", "vault-page", "terminal-sidebar"]
|
||||
},
|
||||
"VaultPagesFeature": {
|
||||
"const": "views.vault-pages"
|
||||
},
|
||||
"TerminalSidebarFeature": {
|
||||
"const": "views.terminal-sidebar"
|
||||
},
|
||||
"PluginViewFocusEvent": {
|
||||
"type": "object",
|
||||
"required": ["instanceId"],
|
||||
"properties": { "instanceId": { "type": "string", "minLength": 1, "maxLength": 128 } },
|
||||
"additionalProperties": false
|
||||
},
|
||||
"PluginViewFocusRequest": { "$ref": "#/$defs/PluginViewFocusEvent" },
|
||||
"ViewContribution": {
|
||||
"type": "object",
|
||||
"required": ["id", "title", "location", "entry"],
|
||||
|
||||
@@ -6,6 +6,7 @@ export const PLUGIN_JSON_MAX_DEPTH = 128 as const;
|
||||
export const PLUGIN_JSON_MAX_NODES = 100000 as const;
|
||||
export const PLUGIN_WIRE_MAX_SAFE_INTEGER = 9007199254740991 as const;
|
||||
export const PLUGIN_RPC_MAX_JSON_BYTES = 1048576 as const;
|
||||
export const PLUGIN_HOST_INTERACTION_LIMITS = {"textBytes":4096,"requestBytes":65536,"items":100,"pendingPerRuntime":1,"pendingTotal":8,"requestsPerMinute":10,"timeoutMs":60000} as const;
|
||||
export const PLUGIN_RPC_ERROR_CODES = [-32700,-32600,-32601,-32602,-32603,-32001,-32002,-32003,-32004,-32005,-32006,-32007,-32008,-32009,-32010,-32011,-32012,-32013,-32014,-32015,-32016] as const;
|
||||
export const PLUGIN_STREAM_MAX_ID_LENGTH = 128 as const;
|
||||
export const PLUGIN_STREAM_MAX_CHUNK_BYTES = 16777216 as const;
|
||||
|
||||
@@ -80,6 +80,7 @@ export type CommandContribution = {
|
||||
description?: LocalizedText;
|
||||
icon?: IconReference;
|
||||
enablement?: ContextKeyExpression;
|
||||
interactive?: boolean;
|
||||
};
|
||||
|
||||
export type CompanionExecutable = {
|
||||
@@ -167,6 +168,75 @@ export type CredentialRef = {
|
||||
|
||||
export type FeatureId = string;
|
||||
|
||||
export type HostClipboardReadRequest = {
|
||||
kind: "clipboard.read";
|
||||
title: string;
|
||||
};
|
||||
|
||||
export type HostClipboardWriteRequest = {
|
||||
kind: "clipboard.write";
|
||||
title: string;
|
||||
text: string;
|
||||
};
|
||||
|
||||
export type HostInputRequest = {
|
||||
kind: "input";
|
||||
title: string;
|
||||
initialValue?: HostSingleLineInputText;
|
||||
};
|
||||
|
||||
export type HostInteractionClosed = {
|
||||
requestId: string;
|
||||
closed: true;
|
||||
};
|
||||
|
||||
export type HostInteractionEvent = (HostInteractionPrompt) | (HostInteractionClosed);
|
||||
|
||||
export type HostInteractionFeature = "host.interactions";
|
||||
|
||||
export type HostInteractionLimits = {"textBytes":4096,"requestBytes":65536,"items":100,"pendingPerRuntime":1,"pendingTotal":8,"requestsPerMinute":10,"timeoutMs":60000};
|
||||
|
||||
export type HostInteractionPrompt = {
|
||||
requestId: string;
|
||||
actionToken: string;
|
||||
pluginId: PluginId;
|
||||
pluginVersion: SemanticVersion;
|
||||
expiresAt: SafePositiveInteger;
|
||||
phase: "request" | "preview";
|
||||
request: HostInteractionRequest;
|
||||
previewText?: string;
|
||||
};
|
||||
|
||||
export type HostInteractionRequest = (HostQuickPickRequest) | (HostInputRequest) | (HostClipboardReadRequest) | (HostClipboardWriteRequest);
|
||||
|
||||
export type HostInteractionResponse = {
|
||||
requestId: string;
|
||||
actionToken: string;
|
||||
action: "cancel" | "preview" | "submit";
|
||||
value?: string;
|
||||
};
|
||||
|
||||
export type HostInteractionResult = ({
|
||||
cancelled: true;
|
||||
}) | ({
|
||||
cancelled: false;
|
||||
value: (string) | (true);
|
||||
});
|
||||
|
||||
export type HostQuickPickItem = {
|
||||
id: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
export type HostQuickPickRequest = {
|
||||
kind: "quickPick";
|
||||
title: string;
|
||||
items: Array<HostQuickPickItem>;
|
||||
};
|
||||
|
||||
export type HostSingleLineInputText = string;
|
||||
|
||||
export type IconReference = (ThemeIcon) | (PackageIcon);
|
||||
|
||||
export type ImporterDetectPayload = {
|
||||
@@ -319,6 +389,10 @@ export type ImporterSnippetDraft = {
|
||||
description?: string;
|
||||
};
|
||||
|
||||
export type InteractiveCommandFeature = "commands.interactive";
|
||||
|
||||
export type InteractiveCommandLimits = {"timeoutMs":180000};
|
||||
|
||||
export type JsonPrimitive = (string) | (number) | (boolean) | (null);
|
||||
|
||||
export type JsonRpcStandardErrorCode = -32700 | -32600 | -32601 | -32602 | -32603;
|
||||
@@ -355,7 +429,9 @@ export type MenuContribution = {
|
||||
|
||||
export type MenuLocation = "commandPalette" | "application" | "host/context" | "terminal/context" | "terminal/toolbar" | "statusBar";
|
||||
|
||||
export type NonResourceScopedPermission = "storage" | "runtime.advanced" | "settings.read" | "settings.write" | "commands" | "menus" | "views" | "clipboard.read" | "clipboard.write" | "terminal.metadata" | "terminal.output" | "terminal.input" | "terminal.decorate" | "terminal.complete" | "terminal.intercept.input" | "terminal.intercept.output" | "vault.metadata" | "vault.write" | "vault.credentials" | "sftp.read" | "sftp.write" | "secrets" | "provider.terminal" | "provider.connection" | "provider.authentication" | "provider.sync" | "provider.importer";
|
||||
export type NetworkCredentialLeaseFeature = "network.credential-lease";
|
||||
|
||||
export type NonResourceScopedPermission = "storage" | "runtime.advanced" | "settings.read" | "settings.write" | "commands" | "menus" | "views" | "ui.interact" | "clipboard.read" | "clipboard.write" | "terminal.metadata" | "terminal.output" | "terminal.input" | "terminal.decorate" | "terminal.complete" | "terminal.intercept.input" | "terminal.intercept.output" | "vault.metadata" | "vault.write" | "vault.credentials" | "sftp.read" | "sftp.write" | "secrets" | "provider.terminal" | "provider.connection" | "provider.authentication" | "provider.sync" | "provider.importer";
|
||||
|
||||
export type NullableRpcId = (RpcId) | (null);
|
||||
|
||||
@@ -481,7 +557,41 @@ export type PluginManifestHeader = ({
|
||||
engines: PluginEngineHeader;
|
||||
} & Record<string, unknown>);
|
||||
|
||||
export type PluginPermission = "storage" | "runtime.advanced" | "settings.read" | "settings.write" | "commands" | "menus" | "views" | "clipboard.read" | "clipboard.write" | "terminal.metadata" | "terminal.output" | "terminal.input" | "terminal.decorate" | "terminal.complete" | "terminal.intercept.input" | "terminal.intercept.output" | "vault.metadata" | "vault.write" | "vault.credentials" | "sftp.read" | "sftp.write" | "network" | "filesystem.read" | "filesystem.write" | "secrets" | "companion.execute" | "provider.terminal" | "provider.connection" | "provider.authentication" | "provider.sync" | "provider.importer";
|
||||
export type PluginNetworkAuthorization = {
|
||||
scheme?: "Basic" | "Bearer";
|
||||
username?: string;
|
||||
};
|
||||
|
||||
export type PluginNetworkRequest = {
|
||||
url: string;
|
||||
method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "HEAD";
|
||||
headers?: { [key: string]: string };
|
||||
body?: {
|
||||
encoding: "utf8" | "base64";
|
||||
data: string;
|
||||
};
|
||||
timeoutMs?: number;
|
||||
credentialLease?: SecretLeaseRef;
|
||||
authorization?: PluginNetworkAuthorization;
|
||||
};
|
||||
|
||||
export type PluginNetworkResponse = {
|
||||
url: string;
|
||||
status: number;
|
||||
headers: { [key: string]: string };
|
||||
body: {
|
||||
encoding: "base64";
|
||||
data: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type PluginPermission = "storage" | "runtime.advanced" | "settings.read" | "settings.write" | "commands" | "menus" | "views" | "ui.interact" | "clipboard.read" | "clipboard.write" | "terminal.metadata" | "terminal.output" | "terminal.input" | "terminal.decorate" | "terminal.complete" | "terminal.intercept.input" | "terminal.intercept.output" | "vault.metadata" | "vault.write" | "vault.credentials" | "sftp.read" | "sftp.write" | "network" | "filesystem.read" | "filesystem.write" | "secrets" | "companion.execute" | "provider.terminal" | "provider.connection" | "provider.authentication" | "provider.sync" | "provider.importer";
|
||||
|
||||
export type PluginViewFocusEvent = {
|
||||
instanceId: string;
|
||||
};
|
||||
|
||||
export type PluginViewFocusRequest = PluginViewFocusEvent;
|
||||
|
||||
export type PluginWireErrorCode = -32001 | -32002 | -32003 | -32004 | -32005 | -32006 | -32007 | -32008 | -32009 | -32010 | -32011 | -32012 | -32013 | -32014 | -32015 | -32016;
|
||||
|
||||
@@ -931,11 +1041,15 @@ export type TerminalSessionSnapshot = {
|
||||
alternateScreen?: boolean;
|
||||
};
|
||||
|
||||
export type TerminalSidebarFeature = "views.terminal-sidebar";
|
||||
|
||||
export type ThemeIcon = {
|
||||
kind: "theme";
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type VaultPagesFeature = "views.vault-pages";
|
||||
|
||||
export type ViewContribution = {
|
||||
id: ContributionId;
|
||||
title: LocalizedText;
|
||||
@@ -947,6 +1061,6 @@ export type ViewContribution = {
|
||||
retainContextWhenHidden?: boolean;
|
||||
};
|
||||
|
||||
export type ViewLocation = "aside" | "panel" | "tab" | "modal" | "settings";
|
||||
export type ViewLocation = "aside" | "panel" | "tab" | "modal" | "settings" | "vault-page" | "terminal-sidebar";
|
||||
|
||||
export type WireIntegerLimits = {"maxSafeInteger":9007199254740991};
|
||||
@@ -10,6 +10,7 @@ export {
|
||||
serializeJsonValue,
|
||||
} from "./jsonValue.js";
|
||||
export {
|
||||
PLUGIN_HOST_INTERACTION_LIMITS,
|
||||
PLUGIN_IMPORTER_MAX_INPUT_BYTES,
|
||||
PLUGIN_IMPORTER_MAX_OUTPUT_BYTES,
|
||||
PLUGIN_IMPORTER_MAX_RECORD_BYTES,
|
||||
@@ -49,3 +50,11 @@ export {
|
||||
} from "./streamTransport.js";
|
||||
|
||||
export { inspectPluginBrandIcon, PLUGIN_BRAND_ICON_FEATURE, PLUGIN_BRAND_ICON_MAX_BYTES, PLUGIN_BRAND_ICON_MAX_EDGE } from './brandIcon.js';
|
||||
|
||||
export const PLUGIN_NETWORK_CREDENTIAL_LEASE_FEATURE = "network.credential-lease" as const satisfies import("./generated/plugin-contract.js").NetworkCredentialLeaseFeature;
|
||||
|
||||
export const PLUGIN_HOST_INTERACTION_FEATURE = "host.interactions" as const satisfies import("./generated/plugin-contract.js").HostInteractionFeature;
|
||||
|
||||
export const PLUGIN_VAULT_PAGES_FEATURE = "views.vault-pages" as const satisfies import("./generated/plugin-contract.js").VaultPagesFeature;
|
||||
export const PLUGIN_TERMINAL_SIDEBAR_FEATURE = "views.terminal-sidebar" as const satisfies import("./generated/plugin-contract.js").TerminalSidebarFeature;
|
||||
export const PLUGIN_INTERACTIVE_COMMAND_FEATURE = "commands.interactive" as const satisfies import("./generated/plugin-contract.js").InteractiveCommandFeature;
|
||||
@@ -395,3 +395,37 @@ test("CancellationTokenSource finishes disposal when a cancellation listener fai
|
||||
assert.throws(() => source.dispose(true), AggregateError);
|
||||
assert.doesNotThrow(() => source.dispose(true));
|
||||
});
|
||||
|
||||
test("network requests expose canonical host-consumed credential leases without plaintext fields", () => {
|
||||
assertSdkTypeChecks(`
|
||||
import type { PluginNetworkRequest, PluginNetworkClient, SecretLeaseRef } from './index.ts';
|
||||
declare const network: PluginNetworkClient;
|
||||
declare const lease: SecretLeaseRef;
|
||||
const request: PluginNetworkRequest = {
|
||||
url: 'https://example.invalid', credentialLease: lease,
|
||||
authorization: { scheme: 'Basic', username: 'synthetic' },
|
||||
};
|
||||
void network.request(request);
|
||||
void network.request({ url: 'https://example.invalid' });
|
||||
// @ts-expect-error Plaintext credential handoff is not supported.
|
||||
void network.request({ url: 'https://example.invalid', authorization: { scheme: 'Bearer', password: 'secret' } });
|
||||
// @ts-expect-error Unsupported authentication schemes fail at compile time.
|
||||
void network.request({ url: 'https://example.invalid', authorization: { scheme: 'Digest' } });
|
||||
`);
|
||||
});
|
||||
|
||||
test("host interaction SDK exposes bounded choice/input and explicit clipboard methods", () => {
|
||||
assertSdkTypeChecks(`
|
||||
import type { PluginContext } from './index.ts';
|
||||
declare const context: PluginContext;
|
||||
const choice: Promise<string | undefined> = context.ui.quickPick('Pick', [{ id: 'one', label: 'One' }]);
|
||||
const input: Promise<string | undefined> = context.ui.input('Input', 'synthetic');
|
||||
const text: Promise<string | undefined> = context.clipboard.readText('Share');
|
||||
const copied: Promise<boolean> = context.clipboard.writeText('synthetic', 'Copy');
|
||||
void [choice, input, text, copied];
|
||||
// @ts-expect-error A plugin boolean cannot grant clipboard authority.
|
||||
context.clipboard.readText('Share', { userGesture: true });
|
||||
// @ts-expect-error No ambient clipboard watcher exists.
|
||||
context.clipboard.watch(() => {});
|
||||
`);
|
||||
});
|
||||
@@ -13,6 +13,8 @@ import type {
|
||||
ConnectionStatusResult,
|
||||
ConnectionValidateResult,
|
||||
CredentialRef,
|
||||
PluginNetworkRequest,
|
||||
PluginNetworkResponse,
|
||||
FeatureId,
|
||||
ImporterDetectPayload,
|
||||
ImporterDetectResult,
|
||||
@@ -107,6 +109,19 @@ export interface PluginCommands {
|
||||
executeCommand<T extends JsonValue = JsonValue>(commandId: string, args?: JsonValue): Promise<T>;
|
||||
}
|
||||
|
||||
export interface PluginHostUI {
|
||||
quickPick(title: string, items: readonly import("@netcatty/plugin-contract").HostQuickPickItem[]): Promise<string | undefined>;
|
||||
/** Single-line text. The host rejects CR/LF in initial values and submitted responses. */
|
||||
input(title: string, initialValue?: string): Promise<string | undefined>;
|
||||
}
|
||||
|
||||
export interface PluginClipboard {
|
||||
/** The host previews text only after the user asks, then separately confirms sharing it. */
|
||||
readText(title: string): Promise<string | undefined>;
|
||||
/** The host shows the exact text and requires explicit confirmation before replacement. */
|
||||
writeText(text: string, title: string): Promise<boolean>;
|
||||
}
|
||||
|
||||
export interface PluginContextKeys {
|
||||
set(key: string, value: JsonValue): Promise<void>;
|
||||
}
|
||||
@@ -636,20 +651,7 @@ export interface PluginCredentialBroker {
|
||||
createLease(credential: SecretRef | CredentialRef, options: PluginCredentialLeaseOptions): Promise<SecretLeaseRef>;
|
||||
}
|
||||
|
||||
export interface PluginNetworkRequest {
|
||||
readonly url: string;
|
||||
readonly method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "HEAD";
|
||||
readonly headers?: Readonly<Record<string, string>>;
|
||||
readonly body?: Readonly<{ encoding: "utf8" | "base64"; data: string }>;
|
||||
readonly timeoutMs?: number;
|
||||
}
|
||||
|
||||
export interface PluginNetworkResponse {
|
||||
readonly url: string;
|
||||
readonly status: number;
|
||||
readonly headers: Readonly<Record<string, string>>;
|
||||
readonly body: Readonly<{ encoding: "base64"; data: string }>;
|
||||
}
|
||||
export type { PluginNetworkAuthorization, PluginNetworkRequest, PluginNetworkResponse } from "@netcatty/plugin-contract";
|
||||
|
||||
export interface PluginNetworkClient {
|
||||
request(request: PluginNetworkRequest): Promise<PluginNetworkResponse>;
|
||||
@@ -734,6 +736,8 @@ export interface PluginContext {
|
||||
readonly storage: PluginKeyValueStore;
|
||||
readonly settings: PluginSettings;
|
||||
readonly commands: PluginCommands;
|
||||
readonly ui: PluginHostUI;
|
||||
readonly clipboard: PluginClipboard;
|
||||
readonly contextKeys: PluginContextKeys;
|
||||
readonly views: PluginViews;
|
||||
readonly providers: PluginProviders;
|
||||
@@ -922,3 +926,10 @@ export function definePlugin<T extends NetcattyPlugin>(plugin: T): T {
|
||||
export function throwIfCancellationRequested(token: CancellationToken): void {
|
||||
if (token.isCancellationRequested) throw new CancellationError();
|
||||
}
|
||||
|
||||
export { PLUGIN_NETWORK_CREDENTIAL_LEASE_FEATURE } from "@netcatty/plugin-contract";
|
||||
|
||||
export { PLUGIN_HOST_INTERACTION_FEATURE } from "@netcatty/plugin-contract";
|
||||
export { PLUGIN_VAULT_PAGES_FEATURE } from "@netcatty/plugin-contract";
|
||||
export { PLUGIN_TERMINAL_SIDEBAR_FEATURE } from "@netcatty/plugin-contract";
|
||||
export { PLUGIN_INTERACTIVE_COMMAND_FEATURE } from "@netcatty/plugin-contract";
|
||||
@@ -305,6 +305,11 @@ function schemaTypeToTs(node, level = 0) {
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
const hostInteractionLimits = schema.$defs.HostInteractionLimits?.const;
|
||||
if (!hostInteractionLimits || Object.values(hostInteractionLimits).some((value) => !Number.isSafeInteger(value) || value < 1)) {
|
||||
throw new Error("HostInteractionLimits must contain positive bounded integers");
|
||||
}
|
||||
|
||||
const definitionNames = Object.keys(schema.$defs).sort((left, right) =>
|
||||
left.localeCompare(right, "en"),
|
||||
);
|
||||
@@ -327,6 +332,7 @@ const generatedLimits = [
|
||||
`export const PLUGIN_JSON_MAX_NODES = ${jsonValueLimits.maxNodes} as const;`,
|
||||
`export const PLUGIN_WIRE_MAX_SAFE_INTEGER = ${wireIntegerLimits.maxSafeInteger} as const;`,
|
||||
`export const PLUGIN_RPC_MAX_JSON_BYTES = ${rpcLimits.maxJsonBytes} as const;`,
|
||||
`export const PLUGIN_HOST_INTERACTION_LIMITS = ${JSON.stringify(hostInteractionLimits)} as const;`,
|
||||
`export const PLUGIN_RPC_ERROR_CODES = ${JSON.stringify(rpcErrorCodes)} as const;`,
|
||||
`export const PLUGIN_STREAM_MAX_ID_LENGTH = ${streamLimits.maxStreamIdLength} as const;`,
|
||||
`export const PLUGIN_STREAM_MAX_CHUNK_BYTES = ${streamLimits.maxChunkBytes} as const;`,
|
||||
|
||||
Reference in new issue
Block a user