mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-03 12:32:49 +00:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b6b45fc11a | ||
|
|
d601d3d238 | ||
|
|
e20f2a9986 |
No files matched your search
@@ -1235,6 +1235,24 @@ Highlight the focused split pane:
|
||||
'deepLink.jms.invalid': 'Invalid JumpServer link',
|
||||
'deepLink.jms.unsupported': 'Unsupported JumpServer protocol: {{protocol}}',
|
||||
'settings.tab.plugins': 'Plugins',
|
||||
"settings.plugins.manager.installed": "Installed plugins",
|
||||
"settings.plugins.manager.import": "Import .ncpkg",
|
||||
"settings.plugins.manager.preview": "Developer preview. Imported packages remain disabled until you review and enable them.",
|
||||
"settings.plugins.manager.empty": "Import a local plugin package to get started.",
|
||||
"settings.plugins.manager.enabled": "Enabled",
|
||||
"settings.plugins.manager.disabled": "Disabled",
|
||||
"settings.plugins.manager.enable": "Enable",
|
||||
"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.manager.permissions": "Declared permissions",
|
||||
"settings.plugins.manager.permissionNote": "Read from the installed manifest. Declarations are not grants; sensitive operations still require host approval.",
|
||||
"settings.plugins.manager.noPermissions": "No privileged permissions declared.",
|
||||
"settings.plugins.manager.required": "Required",
|
||||
"settings.plugins.manager.optional": "Optional",
|
||||
"settings.plugins.manager.browser": "Browser runtime: privileged access goes through host permission brokers.",
|
||||
"settings.plugins.manager.advanced": "Advanced runtime: full Node filesystem and network authority. Broker permissions cannot restrict this access. Development preview only.",
|
||||
"settings.plugins.manager.data": "Local KV stays on this device. Only non-secret settings explicitly marked for sync participate in encrypted cloud sidecars. Packages and permission grants do not sync.",
|
||||
'settings.plugins.title': 'Plugin settings',
|
||||
'settings.plugins.description': 'Native, validated settings contributed by development plugins.',
|
||||
'settings.plugins.loading': 'Loading plugin settings…',
|
||||
|
||||
@@ -1235,6 +1235,24 @@ Resalta el panel dividido enfocado:
|
||||
'deepLink.jms.invalid': 'Enlace JumpServer inválido',
|
||||
'deepLink.jms.unsupported': 'Protocolo JumpServer no compatible: {{protocol}}',
|
||||
'settings.tab.plugins': 'Plugins',
|
||||
"settings.plugins.manager.installed": "Plugins instalados",
|
||||
"settings.plugins.manager.import": "Importar .ncpkg",
|
||||
"settings.plugins.manager.preview": "Vista previa para desarrolladores. Los paquetes importados permanecen desactivados hasta que los revises y actives.",
|
||||
"settings.plugins.manager.empty": "Importa un paquete local para empezar.",
|
||||
"settings.plugins.manager.enabled": "Activado",
|
||||
"settings.plugins.manager.disabled": "Desactivado",
|
||||
"settings.plugins.manager.enable": "Activar",
|
||||
"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.manager.permissions": "Permisos declarados",
|
||||
"settings.plugins.manager.permissionNote": "Obtenidos del manifiesto instalado. Las declaraciones no son autorizaciones; las operaciones sensibles requieren aprobación del anfitrión.",
|
||||
"settings.plugins.manager.noPermissions": "No se han declarado permisos privilegiados.",
|
||||
"settings.plugins.manager.required": "Obligatorio",
|
||||
"settings.plugins.manager.optional": "Opcional",
|
||||
"settings.plugins.manager.browser": "Entorno de navegador: el acceso privilegiado pasa por los controles de permisos del anfitrión.",
|
||||
"settings.plugins.manager.advanced": "Entorno avanzado: acceso completo de Node al sistema de archivos y a la red. Los permisos del intermediario no restringen este acceso. Solo para desarrollo.",
|
||||
"settings.plugins.manager.data": "Los datos KV permanecen en este dispositivo. Solo los ajustes no secretos marcados para sincronización participan en los datos auxiliares cifrados. Los paquetes y las autorizaciones no se sincronizan.",
|
||||
'settings.plugins.title': 'Configuración de plugins',
|
||||
'settings.plugins.description': 'Ajustes nativos y validados que aportan los plugins de desarrollo.',
|
||||
'settings.plugins.loading': 'Cargando ajustes del plugin…',
|
||||
|
||||
@@ -1130,6 +1130,24 @@ export const ruCoreMessages: Messages = {
|
||||
'deepLink.jms.unsupported': 'Неподдерживаемый протокол JumpServer: {{protocol}}',
|
||||
|
||||
'settings.tab.plugins': 'Плагины',
|
||||
"settings.plugins.manager.installed": "Установленные плагины",
|
||||
"settings.plugins.manager.import": "Импорт .ncpkg",
|
||||
"settings.plugins.manager.preview": "Предварительная версия для разработчиков. Импортированные пакеты отключены до проверки и включения.",
|
||||
"settings.plugins.manager.empty": "Импортируйте локальный пакет плагина.",
|
||||
"settings.plugins.manager.enabled": "Включён",
|
||||
"settings.plugins.manager.disabled": "Отключён",
|
||||
"settings.plugins.manager.enable": "Включить",
|
||||
"settings.plugins.manager.disable": "Отключить",
|
||||
"settings.plugins.manager.remove": "Удалить",
|
||||
"settings.plugins.manager.removeNote": "Удаление очищает код пакета и локальные данные KV. Настройки пользователя, секреты и данные синхронизации сохраняются.",
|
||||
"settings.plugins.manager.permissions": "Заявленные разрешения",
|
||||
"settings.plugins.manager.permissionNote": "Из установленного манифеста. Объявление не является разрешением; чувствительные операции требуют одобрения приложения.",
|
||||
"settings.plugins.manager.noPermissions": "Привилегированные разрешения не заявлены.",
|
||||
"settings.plugins.manager.required": "Обязательно",
|
||||
"settings.plugins.manager.optional": "Необязательно",
|
||||
"settings.plugins.manager.browser": "Браузерная среда: привилегированный доступ проходит через проверку разрешений приложения.",
|
||||
"settings.plugins.manager.advanced": "Расширенная среда: полный доступ Node к файловой системе и сети. Разрешения посредника не ограничивают этот доступ. Только для разработки.",
|
||||
"settings.plugins.manager.data": "Данные KV остаются на устройстве. Синхронизируются только явно отмеченные несекретные настройки в зашифрованных данных. Пакеты и разрешения не синхронизируются.",
|
||||
'settings.plugins.title': 'Настройки плагинов',
|
||||
'settings.plugins.description': 'Проверенные нативные настройки от плагинов разработки.',
|
||||
'settings.plugins.loading': 'Загрузка настроек плагинов…',
|
||||
|
||||
@@ -1150,6 +1150,24 @@ Vaults 标签保持扁平,不使用这块背景。
|
||||
'qs.localShells': '本地 Shell',
|
||||
'qs.default': '默认',
|
||||
'settings.tab.plugins': '插件',
|
||||
"settings.plugins.manager.installed": "已安装插件",
|
||||
"settings.plugins.manager.import": "导入 .ncpkg",
|
||||
"settings.plugins.manager.preview": "开发者预览。导入的插件保持停用,请检查后再启用。",
|
||||
"settings.plugins.manager.empty": "导入本地插件包以开始使用。",
|
||||
"settings.plugins.manager.enabled": "已启用",
|
||||
"settings.plugins.manager.disabled": "已停用",
|
||||
"settings.plugins.manager.enable": "启用",
|
||||
"settings.plugins.manager.disable": "停用",
|
||||
"settings.plugins.manager.remove": "卸载",
|
||||
"settings.plugins.manager.removeNote": "卸载会移除插件代码和本地 KV 数据,保留用户设置、秘密存储和同步 sidecar。",
|
||||
"settings.plugins.manager.permissions": "声明的权限",
|
||||
"settings.plugins.manager.permissionNote": "来自已安装的 manifest。声明不等于授权,敏感操作仍须通过宿主批准。",
|
||||
"settings.plugins.manager.noPermissions": "未声明特权权限。",
|
||||
"settings.plugins.manager.required": "必需",
|
||||
"settings.plugins.manager.optional": "可选",
|
||||
"settings.plugins.manager.browser": "浏览器运行时:特权访问通过宿主权限代理。",
|
||||
"settings.plugins.manager.advanced": "高级运行时:拥有完整 Node 文件系统和网络能力,代理权限无法限制这些访问。仅供开发预览。",
|
||||
"settings.plugins.manager.data": "本地 KV 仅保留在此设备。只有明确标记为同步的非秘密设置进入加密云 sidecar。插件包和权限授权不参与同步。",
|
||||
'settings.plugins.title': '插件设置',
|
||||
'settings.plugins.description': '由开发插件提供、经 Netcatty 原生组件验证的设置。',
|
||||
'settings.plugins.loading': '正在加载插件设置…',
|
||||
|
||||
@@ -1153,6 +1153,24 @@ Vaults 分頁維持扁平,不使用這塊背景。
|
||||
'qs.default': '預設',
|
||||
|
||||
'settings.tab.plugins': '外掛程式',
|
||||
"settings.plugins.manager.installed": "已安裝插件",
|
||||
"settings.plugins.manager.import": "導入 .ncpkg",
|
||||
"settings.plugins.manager.preview": "開發者預覽。導入的插件保持停用,請檢查後再啟用。",
|
||||
"settings.plugins.manager.empty": "導入本地插件包以開始使用。",
|
||||
"settings.plugins.manager.enabled": "已啟用",
|
||||
"settings.plugins.manager.disabled": "已停用",
|
||||
"settings.plugins.manager.enable": "啟用",
|
||||
"settings.plugins.manager.disable": "停用",
|
||||
"settings.plugins.manager.remove": "卸載",
|
||||
"settings.plugins.manager.removeNote": "卸載會移除插件代碼和本地 KV 數據,保留用戶設置、秘密存儲和同步 sidecar。",
|
||||
"settings.plugins.manager.permissions": "聲明的權限",
|
||||
"settings.plugins.manager.permissionNote": "來自已安裝的 manifest。聲明不等於授權,敏感操作仍須通過宿主批准。",
|
||||
"settings.plugins.manager.noPermissions": "未聲明特權權限。",
|
||||
"settings.plugins.manager.required": "必需",
|
||||
"settings.plugins.manager.optional": "可選",
|
||||
"settings.plugins.manager.browser": "瀏覽器運行時:特權訪問通過宿主權限代理。",
|
||||
"settings.plugins.manager.advanced": "高級運行時:擁有完整 Node 文件系統和網絡能力,代理權限無法限制這些訪問。僅供開發預覽。",
|
||||
"settings.plugins.manager.data": "本地 KV 僅保留在此設備。只有明確標記為同步的非秘密設置進入加密雲 sidecar。插件包和權限授權不參與同步。",
|
||||
'settings.plugins.title': '外掛程式設定',
|
||||
'settings.plugins.description': '由開發外掛程式提供、經 Netcatty 原生元件驗證的設定。',
|
||||
'settings.plugins.loading': '正在載入外掛程式設定…',
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
|
||||
export function usePluginManager() {
|
||||
const [plugins, setPlugins] = useState<NetcattyInstalledPlugin[]>([]);
|
||||
const [available, setAvailable] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const generation = useRef(0);
|
||||
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);
|
||||
setPlugins(list);
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
const update = () => { void refresh().catch((cause) => setError(String(cause))); };
|
||||
update();
|
||||
const unsubscribe = netcattyBridge.get()?.onPluginContributionsChanged?.(update);
|
||||
const refreshGeneration = generation;
|
||||
return () => { refreshGeneration.current++; unsubscribe?.(); };
|
||||
}, [refresh]);
|
||||
const run = useCallback(async (action: (bridge: NetcattyBridge) => Promise<unknown>) => {
|
||||
if (mutation.current) return;
|
||||
mutation.current = true;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try { await action(netcattyBridge.require()); }
|
||||
catch (cause) { setError(cause instanceof Error ? cause.message : String(cause)); }
|
||||
finally {
|
||||
try { await refresh(); } catch (cause) { setError(String(cause)); }
|
||||
mutation.current = false;
|
||||
setBusy(false);
|
||||
}
|
||||
}, [refresh]);
|
||||
return {
|
||||
plugins, available, busy, error,
|
||||
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'] }]);
|
||||
if (path) await bridge.installPluginPackage(path, { enable: false });
|
||||
}),
|
||||
setEnabled: (id: string, enabled: boolean) => run(async (bridge) => {
|
||||
if (!bridge.setPluginEnabled) throw new Error('Plugin management unavailable');
|
||||
return bridge.setPluginEnabled(id, enabled);
|
||||
}),
|
||||
uninstall: (id: string) => run(async (bridge) => {
|
||||
if (!bridge.uninstallPlugin) throw new Error('Plugin management unavailable');
|
||||
return bridge.uninstallPlugin(id);
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import React from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { I18nProvider } from '../../../application/i18n/I18nProvider';
|
||||
import { PluginPackageDetails } from './PluginPackageManager';
|
||||
|
||||
test('package detail renders manifest resources as text and explains advanced authority', () => {
|
||||
const plugin = {
|
||||
id: 'com.example.synthetic', enabled: false, activeVersion: '1.0.0',
|
||||
archiveSha256: 'a'.repeat(64),
|
||||
manifest: {
|
||||
manifestVersion: 1, id: 'com.example.synthetic', name: 'synthetic', version: '1.0.0', publisher: 'example',
|
||||
engines: { netcatty: '>=1.0.0 <2.0.0', api: '0.1.0-internal' },
|
||||
main: { node: 'index.js' },
|
||||
permissions: { required: [{ permission: 'network', resources: ['https://example.com'], reason: '<script>attack()</script>' }] },
|
||||
},
|
||||
runtime: { status: 'stopped', kind: null, lastError: null, quarantinedAt: null },
|
||||
} as NetcattyInstalledPlugin;
|
||||
const html = renderToStaticMarkup(<I18nProvider locale="en"><PluginPackageDetails plugin={plugin} /></I18nProvider>);
|
||||
assert.match(html, /full Node filesystem and network authority/);
|
||||
assert.match(html, /https:\/\/example.com/);
|
||||
assert.match(html, /<script>/);
|
||||
assert.doesNotMatch(html, /<script>/);
|
||||
assert.match(html, /Declarations are not grants/);
|
||||
assert.match(html, /aaaaaaaaaaaaaaaa/);
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Package, Upload } from 'lucide-react';
|
||||
import { usePluginManager } from '../../../application/state/usePluginManager';
|
||||
import { useI18n } from '../../../application/i18n/I18nProvider';
|
||||
import { Button } from '../../ui/button';
|
||||
|
||||
export function PluginPackageDetails({ plugin }: { plugin: NetcattyInstalledPlugin }) {
|
||||
const { t } = useI18n();
|
||||
const manifest = plugin.manifest;
|
||||
if (!manifest) return null;
|
||||
const permissions = [
|
||||
...(manifest.permissions?.required ?? []).map((value) => ({ value, required: true })),
|
||||
...(manifest.permissions?.optional ?? []).map((value) => ({ value, required: false })),
|
||||
];
|
||||
const advanced = !manifest.main.browser || Boolean(manifest.companionExecutables?.length)
|
||||
|| manifest.contributes?.providers?.some((p) => p.kind === 'terminal.interceptor.input' || p.kind === 'terminal.interceptor.output');
|
||||
return <div className="space-y-4 border-t border-border/60 pt-4 text-sm">
|
||||
<dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-2 text-xs">
|
||||
<dt className="text-muted-foreground">ID</dt><dd className="break-all font-mono">{plugin.id}</dd>
|
||||
<dt className="text-muted-foreground">Netcatty</dt><dd>{manifest.engines.netcatty}</dd>
|
||||
<dt className="text-muted-foreground">API</dt><dd>{manifest.engines.api}</dd>
|
||||
<dt className="text-muted-foreground">SHA-256</dt><dd className="break-all font-mono">{plugin.archiveSha256 ?? '-'}</dd>
|
||||
</dl>
|
||||
<p className={advanced ? 'text-destructive' : 'text-muted-foreground'}>{t(advanced ? 'settings.plugins.manager.advanced' : 'settings.plugins.manager.browser')}</p>
|
||||
<div className="space-y-2">
|
||||
<h4 className="font-medium">{t('settings.plugins.manager.permissions')}</h4>
|
||||
<p className="text-xs text-muted-foreground">{t('settings.plugins.manager.permissionNote')}</p>
|
||||
{permissions.length === 0 && <p className="text-xs text-muted-foreground">{t('settings.plugins.manager.noPermissions')}</p>}
|
||||
{permissions.map(({ value, required }, index) => <div key={index} className="rounded-md bg-muted/40 p-3">
|
||||
<div className="flex justify-between gap-3"><code className="break-all text-xs">{typeof value === 'string' ? value : value.permission}</code><span className="text-xs text-muted-foreground">{t(required ? 'settings.plugins.manager.required' : 'settings.plugins.manager.optional')}</span></div>
|
||||
{typeof value !== 'string' && <><p className="mt-1 whitespace-pre-wrap break-all text-xs">{value.resources.join('\n')}</p>{value.reason && <p className="mt-1 text-xs text-muted-foreground">{value.reason}</p>}</>}
|
||||
</div>)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{t('settings.plugins.manager.data')}</p>
|
||||
</div>;
|
||||
}
|
||||
|
||||
export function PluginPackageManager() {
|
||||
const { t, resolvedLocale } = useI18n();
|
||||
const manager = usePluginManager();
|
||||
const [selected, setSelected] = useState<string | null>(null);
|
||||
const [removing, setRemoving] = useState<string | null>(null);
|
||||
if (!manager.available) return null;
|
||||
return <section className="space-y-4" aria-label={t('settings.plugins.manager.installed')}>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h3 className="font-medium">{t('settings.plugins.manager.installed')} <span className="text-muted-foreground">{manager.plugins.length}</span></h3>
|
||||
<Button variant="outline" size="sm" disabled={manager.busy} onClick={() => void manager.importPackage()}><Upload size={14} className="mr-2" />{t('settings.plugins.manager.import')}</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{t('settings.plugins.manager.preview')}</p>
|
||||
{manager.error && <p role="alert" className="text-sm text-destructive">{manager.error}</p>}
|
||||
{!manager.plugins.length && <div className="rounded-xl border border-dashed border-border p-8 text-center text-sm text-muted-foreground">{t('settings.plugins.manager.empty')}</div>}
|
||||
{manager.plugins.map((plugin) => <article key={plugin.id} className="space-y-4 rounded-xl border border-border/70 bg-background p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="rounded-lg bg-muted p-2"><Package size={20} /></div>
|
||||
<button type="button" className="min-w-0 flex-1 text-left" aria-expanded={selected === plugin.id} onClick={() => setSelected(selected === plugin.id ? null : plugin.id)}>
|
||||
<div className="truncate text-sm font-medium">{typeof plugin.manifest?.displayName === 'string' ? plugin.manifest.displayName : plugin.manifest?.displayName?.[resolvedLocale] ?? plugin.manifest?.displayName?.en ?? plugin.manifest?.name ?? plugin.id}</div>
|
||||
<div className="text-xs text-muted-foreground">{plugin.activeVersion} · {t(plugin.enabled ? 'settings.plugins.manager.enabled' : 'settings.plugins.manager.disabled')}</div>
|
||||
</button>
|
||||
<Button variant="outline" size="sm" disabled={manager.busy} onClick={() => void manager.setEnabled(plugin.id, !plugin.enabled)}>{t(plugin.enabled ? 'settings.plugins.manager.disable' : 'settings.plugins.manager.enable')}</Button>
|
||||
</div>
|
||||
{plugin.runtime.lastError && <p role="status" className="break-words text-xs text-destructive">{plugin.runtime.lastError}</p>}
|
||||
{selected === plugin.id && <>
|
||||
<PluginPackageDetails plugin={plugin} />
|
||||
{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>
|
||||
<Button variant="ghost" size="sm" onClick={() => setRemoving(null)}>{t('common.cancel')}</Button>
|
||||
</div> : <Button variant="ghost" size="sm" disabled={manager.busy} onClick={() => setRemoving(plugin.id)}>{t('settings.plugins.manager.remove')}</Button>}
|
||||
</>}
|
||||
</article>)}
|
||||
</section>;
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { PluginPackageManager } from './PluginPackageManager';
|
||||
import { FolderOpen } from 'lucide-react';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
@@ -307,6 +308,7 @@ export default function SettingsPluginsTab() {
|
||||
<h2 className="text-xl font-semibold">{t('settings.plugins.title')}</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{t('settings.plugins.description')}</p>
|
||||
</div>
|
||||
<PluginPackageManager />
|
||||
{contextualScopes.size > 0 && (
|
||||
<section className="grid gap-3 rounded-lg border border-border/70 bg-muted/10 p-4 sm:grid-cols-2" aria-label={t('settings.plugins.scopeTargets')}>
|
||||
{[...contextualScopes].map((kind) => (
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
# Plugin platform implementation review
|
||||
|
||||
Baseline: main `0256e7471d66bbbca077c361ef379d7b11ce8465` (2026-10-26).
|
||||
|
||||
## Existing capabilities and gaps
|
||||
|
||||
| Area | Implemented source | Remaining boundary |
|
||||
| --- | --- | --- |
|
||||
| Contract, SDK, package CLI | `packages/plugin-contract/schema`, `packages/plugin-sdk`, `packages/plugin-cli` | Internal API `0.1.0-internal`; no public API stability promise |
|
||||
| Runtime | `electron/plugins/browserPluginRuntime.cjs`, `utilityPluginRuntime.cjs`, `hostBootstrap.cjs` | Entire host remains development opt-in; advanced utility is ambient Node authority, not a fine-grained sandbox |
|
||||
| Permissions | `permissionEngine.cjs`, `hostRpcRegistry.cjs`, `secureCapabilities.cjs` | Broker checks at privileged boundary; unsigned principal bound to package digest; verified publisher/distribution not implemented |
|
||||
| UI contributions | `contributionService.cjs`, `components/plugins`, `SettingsPluginsTab.tsx` | Commands, menus, keybindings, settings and views exist; current Settings page is contributed settings, not a package manager/marketplace |
|
||||
| Terminal | `terminalProviderService.cjs`, `terminalDataPipelineService.cjs` | Completion/link/visual providers and lifecycle; interceptors require privileged utility placement; not arbitrary terminal object access |
|
||||
| Connection, authentication, importer | `extensionProviderService.cjs`, `application/state/pluginExtensionBridge.ts` | Existing operation-bound provider integration; not general vault/SFTP/clipboard CRUD |
|
||||
| Sync | `pluginSyncSidecarService.cjs`, `application/pluginSyncSidecarBridge.ts` | Opt-in non-secret settings and account/CRDT sidecars; ordinary KV is local, package code/permissions/secrets are not cloud authority |
|
||||
| Compatibility | `plugin-cli/src/compatibility.ts`, `packageStore.cjs`, `runtimeSupervisor.cjs` | Install/start checks existed; startup reconciliation and lazy re-enable checks missing |
|
||||
| Local package | `packageStore.cjs`, CLI `pack`, first-party `examples/plugins/hello-netcatty` | Immutable snapshot, hash/integrity validation and safe extraction exist; no ordinary import management UI |
|
||||
| Distribution | No public catalog implementation | Version/hash/pinned README, review workflow, manual update UI still needed |
|
||||
|
||||
Historical merged steps: #2276 contract/SDK; #2303 runtime; #2315 permission/security;
|
||||
#2324 UI contributions; #2338 terminal providers; #2402 privileged terminal pipeline;
|
||||
#2413 connection/auth/importers; #2713 sync; #2748 and #2752 sync follow-up fixes.
|
||||
The repository AGENTS.md runtime section describes an earlier phase and must not
|
||||
be mistaken for a complete current capability inventory. No repository `.agents/skills`
|
||||
directory was present at this baseline.
|
||||
|
||||
## Small implementation slices
|
||||
|
||||
1. Reconcile host/API/features compatibility before contribution enumeration;
|
||||
disable incompatible plugins and retain package/data, record the reason;
|
||||
block lazy re-enable. Do not auto-enable after a host downgrade.
|
||||
2. Reuse the existing Settings Plugins tab and management IPC for installed
|
||||
packages, manifest-derived permissions, version/hash and lifecycle actions;
|
||||
local import starts disabled for review. Keep native permission mediation.
|
||||
3. Add a lightweight first-party catalog and pinned safe README detail renderer,
|
||||
shared package verification, digest-bound manual update flow and author docs.
|
||||
Do not introduce remote accounts, reviews, payments or a new repository.
|
||||
4. Reproduce the actual Electron flow with synthetic data and the first-party
|
||||
sample; transfer source/build instructions if GUI is unavailable here.
|
||||
|
||||
## Decisions and explicit release gates
|
||||
|
||||
User approved public source plus a clear license for catalog entries, and manual
|
||||
confirmation of plugin updates. Private local packages remain separate.
|
||||
|
||||
Recommended public v1 eligibility: browser runtime only. Utility Node, native
|
||||
companions and raw terminal interceptors retain the development preview path.
|
||||
This excludes advanced provider implementations requiring Node/native binaries;
|
||||
ordinary browser contributions, broker-backed networking and providers remain
|
||||
possible. This policy needs product confirmation; no existing advanced path is
|
||||
removed by this review.
|
||||
|
||||
Keep `NETCATTY_PLUGIN_DEV=1` until distribution trust, permission UI, compatibility
|
||||
migration and native acceptance are reviewed. Passing tests is not proof of
|
||||
security isolation. Do not describe utility processes as broker-constrained.
|
||||
|
||||
A future KV sync feature needs an explicit manifest opt-in schema, size limits,
|
||||
namespace and schema-version migrations, deterministic conflict/deletion rules,
|
||||
account switching semantics and secret exclusion. It is deliberately not silently
|
||||
enabled in this iteration. Reuse existing settings sidecars for small preferences.
|
||||
|
||||
Catalog entries should bind ID, repository, license, author, version, commit,
|
||||
artifact SHA-256 and URL, platform/engine ranges, permissions and README revision.
|
||||
A hash mismatch must fail rather than fall back to latest. README must not execute
|
||||
HTML or automatically fetch remote media. Directory PR checks are a bounded
|
||||
review process, not a security certification or a guarantee that maintainers will
|
||||
repair third-party plugins.
|
||||
@@ -151,6 +151,35 @@ class PackageStore {
|
||||
.filter((value) => typeof value === "string" && value !== this.paths.database)
|
||||
.map((directory) => mkdir(directory, { recursive: true, mode: 0o700 })));
|
||||
await this.recover();
|
||||
// Reconcile every installed manifest before lazy contributions are exposed.
|
||||
// An application/API upgrade must not leave incompatible providers enabled.
|
||||
for (const plugin of this.database.listPlugins()) {
|
||||
if (!plugin.enabled) continue;
|
||||
await this.assertCompatible(plugin).catch((error) => {
|
||||
if (error.code !== "PLUGIN_INCOMPATIBLE") throw error;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async assertCompatible(plugin) {
|
||||
const pluginCli = await loadPluginCli();
|
||||
const result = pluginCli.checkPluginCompatibility(plugin.manifest, {
|
||||
netcattyVersion: this.netcattyVersion,
|
||||
apiVersion: this.apiVersion,
|
||||
features: this.supportedFeatures,
|
||||
});
|
||||
if (result.compatible) return result;
|
||||
const error = new Error(`Plugin is incompatible: ${result.errors.join("; ")}`);
|
||||
error.code = "PLUGIN_INCOMPATIBLE";
|
||||
const current = this.database.getActivePlugin(plugin.id);
|
||||
if (current?.activeVersion === plugin.activeVersion) {
|
||||
this.database.setEnabled(plugin.id, false);
|
||||
this.database.setRuntimeState(plugin.id, "error", {
|
||||
pluginVersion: plugin.activeVersion,
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
async install(archivePath, options = {}) {
|
||||
@@ -187,7 +216,9 @@ class PackageStore {
|
||||
const version = assertPluginStorageSegment(manifest.version, "version");
|
||||
const targetDirectory = resolveInstalledVersionDirectory(this.paths, pluginId, version);
|
||||
const previousPlugin = this.database.getActivePlugin(pluginId);
|
||||
const enableAfterActivation = options.enable === true || previousPlugin?.enabled === true;
|
||||
const enableAfterActivation = options.enable === false
|
||||
? false
|
||||
: options.enable === true || previousPlugin?.enabled === true;
|
||||
let activationPrepared = false;
|
||||
const prepareActivation = async (reason) => {
|
||||
if (activationPrepared) return;
|
||||
@@ -214,7 +245,7 @@ class PackageStore {
|
||||
await rm(targetDirectory, { recursive: true, force: true });
|
||||
}
|
||||
if (existingValidation) {
|
||||
if (previousPlugin?.activeVersion !== version) await prepareActivation("switch-active-version");
|
||||
if (previousPlugin?.activeVersion !== version || options.enable === false) await prepareActivation("switch-active-version");
|
||||
const existingPackage = path.join(targetDirectory, PACKAGE_DIRECTORY);
|
||||
this.database.installVersion({
|
||||
pluginId,
|
||||
@@ -222,7 +253,7 @@ class PackageStore {
|
||||
manifest: existingValidation.manifest,
|
||||
archiveSha256: existing.archiveSha256,
|
||||
packageRelativePath: path.relative(this.paths.packages, existingPackage),
|
||||
}, { enable: enableAfterActivation });
|
||||
}, { enable: enableAfterActivation, forceDisabled: options.enable === false });
|
||||
return this.database.getActivePlugin(pluginId);
|
||||
}
|
||||
}
|
||||
@@ -241,7 +272,7 @@ class PackageStore {
|
||||
await writeDurableMetadata(path.join(stagingDirectory, INSTALL_METADATA_FILE), metadata);
|
||||
await this.syncDirectory(stagingDirectory);
|
||||
await mkdir(path.dirname(targetDirectory), { recursive: true, mode: 0o700 });
|
||||
if (previousPlugin?.activeVersion !== version) await prepareActivation("switch-active-version");
|
||||
if (previousPlugin?.activeVersion !== version || options.enable === false) await prepareActivation("switch-active-version");
|
||||
await rename(stagingDirectory, targetDirectory);
|
||||
await this.syncDirectory(path.dirname(targetDirectory));
|
||||
const packageRelativePath = path.relative(
|
||||
@@ -255,7 +286,7 @@ class PackageStore {
|
||||
manifest,
|
||||
archiveSha256: snapshot.sha256,
|
||||
packageRelativePath,
|
||||
}, { enable: enableAfterActivation });
|
||||
}, { enable: enableAfterActivation, forceDisabled: options.enable === false });
|
||||
} catch (error) {
|
||||
this.verifiedArchives.delete(this.#versionKey(pluginId, version));
|
||||
await rm(targetDirectory, { recursive: true, force: true });
|
||||
|
||||
@@ -370,3 +370,54 @@ test("failed package validation removes every extracted staging file", async (co
|
||||
assert.deepEqual(await fs.promises.readdir(fixture.paths.staging), []);
|
||||
assert.deepEqual(fixture.database.listPlugins(), []);
|
||||
});
|
||||
|
||||
test("host upgrade disables incompatible lazy plugins before contributions and retains data", async (context) => {
|
||||
const fixture = createStore(context);
|
||||
await fixture.store.initialize();
|
||||
const archive = await createPackage(fixture.root, {
|
||||
manifest: { engines: { netcatty: "<1.0.0", api: ">=0.1.0-internal <0.2.0" } },
|
||||
});
|
||||
const installed = await fixture.store.install(archive.archive, { enable: true });
|
||||
fixture.database.setValue(installed.id, "synthetic", { retained: true });
|
||||
fixture.store.netcattyVersion = "1.0.0";
|
||||
await fixture.store.initialize();
|
||||
const disabled = fixture.database.getActivePlugin(installed.id);
|
||||
assert.equal(disabled.enabled, false);
|
||||
assert.match(disabled.runtime.lastError, /incompatible.*Netcatty/);
|
||||
assert.equal(disabled.activeVersion, installed.activeVersion);
|
||||
assert.deepEqual(fixture.database.getValue(installed.id, "synthetic"), { retained: true });
|
||||
assert.equal(fs.existsSync(fixture.store.resolvePackageRoot(disabled)), true);
|
||||
await assert.rejects(fixture.store.assertCompatible(disabled), { code: "PLUGIN_INCOMPATIBLE" });
|
||||
fixture.store.netcattyVersion = "0.0.0";
|
||||
await fixture.store.initialize();
|
||||
assert.equal(fixture.database.getActivePlugin(installed.id).enabled, false,
|
||||
"returning to a compatible host must not silently re-enable code");
|
||||
});
|
||||
|
||||
for (const target of [
|
||||
{ apiVersion: "0.2.0" },
|
||||
{ supportedFeatures: [] },
|
||||
]) {
|
||||
test(`startup checks API and required feature compatibility: ${JSON.stringify(target)}`, async (context) => {
|
||||
const fixture = createStore(context, { supportedFeatures: ["example.feature"] });
|
||||
await fixture.store.initialize();
|
||||
const archive = await createPackage(fixture.root, {
|
||||
manifest: { features: { required: ["example.feature"] } },
|
||||
});
|
||||
const installed = await fixture.store.install(archive.archive, { enable: true });
|
||||
Object.assign(fixture.store, target);
|
||||
await fixture.store.initialize();
|
||||
assert.equal(fixture.database.getActivePlugin(installed.id).enabled, false);
|
||||
});
|
||||
}
|
||||
|
||||
test("explicit disabled import never auto-enables an update of an enabled plugin", async (context) => {
|
||||
const fixture = createStore(context);
|
||||
await fixture.store.initialize();
|
||||
const first = await createPackage(fixture.root);
|
||||
await fixture.store.install(first.archive, { enable: true });
|
||||
const update = await createPackage(fixture.root, { manifest: { version: "1.1.0" } });
|
||||
const installed = await fixture.store.install(update.archive, { enable: false });
|
||||
assert.equal(installed.activeVersion, "1.1.0");
|
||||
assert.equal(installed.enabled, false);
|
||||
});
|
||||
@@ -646,7 +646,7 @@ function registerPluginBridge(ipcMain, options) {
|
||||
handle(CHANNELS.list, async (activeManager) => activeManager.list());
|
||||
handle(CHANNELS.install, async (activeManager, payload) => activeManager.install(
|
||||
payload?.archivePath,
|
||||
{ enable: payload?.enable === true },
|
||||
{ enable: typeof payload?.enable === "boolean" ? payload.enable : undefined },
|
||||
));
|
||||
handle(CHANNELS.setEnabled, async (activeManager, payload) => activeManager.setEnabled(
|
||||
payload?.pluginId,
|
||||
|
||||
@@ -52,7 +52,7 @@ class PluginManager {
|
||||
let plugin;
|
||||
try {
|
||||
plugin = await this.packageStore.install(archivePath, {
|
||||
enable: options?.enable === true,
|
||||
enable: options?.enable,
|
||||
beforeActivate: async ({ pluginId, previousPlugin }) => {
|
||||
if (!previousPlugin?.enabled) return;
|
||||
this.database.setEnabled(pluginId, false);
|
||||
@@ -107,6 +107,7 @@ class PluginManager {
|
||||
await this.#ready();
|
||||
if (enabled) {
|
||||
const plugin = this.database.getActivePlugin(pluginId);
|
||||
if (plugin) await this.packageStore.assertCompatible?.(plugin);
|
||||
if (plugin?.runtime?.quarantinedAt != null) {
|
||||
this.database.clearQuarantine(pluginId, plugin.activeVersion);
|
||||
}
|
||||
|
||||
@@ -488,3 +488,26 @@ test("shutdown cancels an in-flight runtime mutation before waiting for its queu
|
||||
"database:close",
|
||||
]);
|
||||
});
|
||||
|
||||
test("explicit enable checks compatibility before clearing quarantine or exposing lazy contributions", async () => {
|
||||
const calls = [];
|
||||
const plugin = { id: "com.example.incompatible", enabled: false, runtime: { quarantinedAt: 1 } };
|
||||
const manager = new PluginManager({
|
||||
database: {
|
||||
getActivePlugin: () => plugin,
|
||||
clearQuarantine: () => calls.push("clear"),
|
||||
setEnabled: () => calls.push("enable"),
|
||||
},
|
||||
packageStore: {
|
||||
async initialize() {},
|
||||
async assertCompatible() { throw new Error("Plugin is incompatible"); },
|
||||
},
|
||||
runtimeSupervisor: {},
|
||||
contributionService: {
|
||||
async initialize() {},
|
||||
onPluginEnabled: () => calls.push("contribute"),
|
||||
},
|
||||
});
|
||||
await assert.rejects(manager.setEnabled(plugin.id, true), /incompatible/);
|
||||
assert.deepEqual(calls, []);
|
||||
});
|
||||
@@ -9,9 +9,9 @@ From the repository root:
|
||||
|
||||
```bash
|
||||
npm run build:plugin-packages
|
||||
npm exec -- netcatty-plugin validate examples/plugins/hello-netcatty
|
||||
npm exec -- netcatty-plugin compatibility examples/plugins/hello-netcatty --netcatty 0.0.0
|
||||
npm exec -- netcatty-plugin pack examples/plugins/hello-netcatty --out /tmp/hello-netcatty.ncpkg
|
||||
node packages/plugin-cli/dist/cli.js validate examples/plugins/hello-netcatty
|
||||
node packages/plugin-cli/dist/cli.js compatibility examples/plugins/hello-netcatty --netcatty 0.0.0
|
||||
node packages/plugin-cli/dist/cli.js pack examples/plugins/hello-netcatty --out /tmp/hello-netcatty.ncpkg
|
||||
```
|
||||
|
||||
After installation, change **Greeting** under **Settings → Plugins**, then run
|
||||
@@ -21,3 +21,29 @@ sandboxed plugin runtime. Type `netc` at a shell prompt to exercise the bounded
|
||||
completion Provider, and print `Hello from Netcatty` to exercise the declarative
|
||||
decoration Provider. Neither Provider receives raw xterm objects or sensitive
|
||||
terminal input/output streams.
|
||||
|
||||
## Import and inspect
|
||||
|
||||
Launch a development build with `NETCATTY_PLUGIN_DEV=1`, using a fresh Electron
|
||||
user-data directory for demonstrations. In **Settings > Plugins**, choose
|
||||
**Import .ncpkg**, select the generated archive, and open its details. Review the
|
||||
manifest permissions, engine ranges and SHA-256, then choose **Enable**. Native
|
||||
permission prompts still mediate activation and sensitive operations.
|
||||
|
||||
Importing an update is a manual action and leaves it disabled, including when
|
||||
the previous version was enabled. Review the new package before enabling it.
|
||||
Changing unsigned package contents changes its security principal; previous
|
||||
persistent grants are not transferred to different bytes.
|
||||
|
||||
An incompatible application/API upgrade disables installed plugins before
|
||||
publishing their contributions. The reason remains in the installed list, and
|
||||
package files and local data remain available. Returning to a compatible host
|
||||
requires explicit re-enable. Uninstall is different: package code and local KV
|
||||
are deleted; user-owned settings, secret storage and sync sidecars are retained.
|
||||
|
||||
For a synthetic demonstration, use only this first-party example, change the
|
||||
Greeting, invoke **Examples: Say Hello**, disable/re-enable, restart the app and
|
||||
inspect the package again. Do not connect real hosts or a cloud account. The
|
||||
existing greeting setting is local; declaring `sync: true` on a non-secret
|
||||
setting opts it into the existing encrypted settings sidecar. Generic SDK KV
|
||||
storage does not sync.
|
||||
Vendored
+2
-1
@@ -337,7 +337,8 @@ declare global {
|
||||
id: string;
|
||||
enabled: boolean;
|
||||
activeVersion: string | null;
|
||||
manifest: unknown;
|
||||
manifest: import("@netcatty/plugin-contract").PluginManifest | null;
|
||||
archiveSha256?: string | null;
|
||||
runtime: {
|
||||
status: string;
|
||||
kind: 'browser' | 'utility' | null;
|
||||
|
||||
@@ -581,11 +581,11 @@ test("example README commands use repository-root CLI paths", async () => {
|
||||
assert.match(readme, /npm run build:plugin-packages/);
|
||||
assert.match(
|
||||
readme,
|
||||
/npm exec -- netcatty-plugin validate examples\/plugins\/hello-netcatty/,
|
||||
/node packages\/plugin-cli\/dist\/cli\.js validate examples\/plugins\/hello-netcatty/,
|
||||
);
|
||||
assert.match(
|
||||
readme,
|
||||
/npm exec -- netcatty-plugin compatibility examples\/plugins\/hello-netcatty --netcatty 0\.0\.0/,
|
||||
/node packages\/plugin-cli\/dist\/cli\.js compatibility examples\/plugins\/hello-netcatty --netcatty 0\.0\.0/,
|
||||
);
|
||||
assert.doesNotMatch(readme, /npm exec --workspace @netcatty\/plugin-cli/);
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user