mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-03 12:32:49 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b92177ec42 | ||
|
|
54481b6306 | ||
|
|
7e81bece9e | ||
|
|
04ee9afa7f | ||
|
|
efe5d4530d | ||
|
|
32e75b6ace | ||
|
|
4a831f41c2 | ||
|
|
141148ae0d | ||
|
|
8d15a29bc6 | ||
|
|
fcb6723c3d | ||
|
|
519c24cc4c |
No files matched your search
@@ -1235,24 +1235,6 @@ 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,24 +1235,6 @@ 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,24 +1130,6 @@ 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,24 +1150,6 @@ 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,24 +1153,6 @@ 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': '正在載入外掛程式設定…',
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
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);
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -1,3 +1,14 @@
|
||||
!macro customInit
|
||||
; #3946: external MCP hosts launch Netcatty.exe with ELECTRON_RUN_AS_NODE=1
|
||||
; via netcatty-external-mcp.cmd. Those processes can outlive the host UI as
|
||||
; orphans and keep $INSTDIR locked, so the in-app updater hangs (AppHangB1).
|
||||
; Force-kill leftover Netcatty.exe before files are replaced. Do not pass /T:
|
||||
; electron-updater spawns this installer as a child of the UI Netcatty.exe,
|
||||
; and /T would terminate the installer itself. Ignore a missing process.
|
||||
nsExec::ExecToLog "taskkill /F /IM Netcatty.exe"
|
||||
Pop $0
|
||||
!macroend
|
||||
|
||||
!macro customInstall
|
||||
; Open a selected folder in a new Netcatty local terminal.
|
||||
; Args go after `--` so Electron/Chromium does not swallow them. Use
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
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/);
|
||||
});
|
||||
@@ -1,72 +0,0 @@
|
||||
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,4 +1,3 @@
|
||||
import { PluginPackageManager } from './PluginPackageManager';
|
||||
import { FolderOpen } from 'lucide-react';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
@@ -308,7 +307,6 @@ 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) => (
|
||||
|
||||
@@ -5,6 +5,7 @@ import { createRequire } from "node:module";
|
||||
import type { SerializeAddon as SerializeAddonType } from "@xterm/addon-serialize";
|
||||
import type { Terminal as XTermType } from "@xterm/xterm";
|
||||
|
||||
import { DEFAULT_KEYWORD_HIGHLIGHT_RULES } from "../../domain/models/terminal.ts";
|
||||
import { writeTerminalDataWithLineTimestamps } from "./runtime/terminalLineTimestamps.ts";
|
||||
import {
|
||||
noteTerminalOutputPressureData,
|
||||
@@ -81,6 +82,76 @@ test("new output is colored on the written cells without decorations", async ()
|
||||
term.dispose();
|
||||
});
|
||||
|
||||
test("keyword highlight skips cells whose PTY foreground is already colored", async () => {
|
||||
// Starship directory segments set a truecolor foreground (#3940). Those
|
||||
// cells must keep the PTY color; only default-foreground matches are recolored.
|
||||
const unixPath = DEFAULT_KEYWORD_HIGHLIGHT_RULES.find((entry) => entry.id === "unix-path");
|
||||
assert.ok(unixPath);
|
||||
const keywordRgb = Number.parseInt(unixPath.color.slice(1), 16);
|
||||
const cream = 0xfbf1c7;
|
||||
const yellow = 0xfabd2f;
|
||||
const term = new XTerm({ allowProposedApi: true, cols: 80, rows: 5, scrollback: 20 });
|
||||
const highlighter = new KeywordHighlighter(term);
|
||||
highlighter.setRules([unixPath], true);
|
||||
await write(
|
||||
term,
|
||||
"\x1b[38;2;251;241;199m\x1b[48;2;250;189;47m/opt\x1b[0m"
|
||||
+ " \x1b[38;5;220m/mnt\x1b[0m"
|
||||
+ " \x1b[31m/bin\x1b[0m"
|
||||
+ " /var",
|
||||
);
|
||||
|
||||
const tokenCells = (text: string) => {
|
||||
const line = term.buffer.active.getLine(0);
|
||||
const index = (line?.translateToString(true) ?? "").indexOf(text);
|
||||
assert.ok(index >= 0, text);
|
||||
return Array.from({ length: text.length }, (_, offset) => {
|
||||
const cell = line?.getCell(index + offset);
|
||||
assert.ok(cell, `${text}[${offset}]`);
|
||||
return cell;
|
||||
});
|
||||
};
|
||||
|
||||
for (const cell of tokenCells("/opt")) {
|
||||
assert.equal(cell.isFgRGB(), true);
|
||||
assert.equal(cell.getFgColorMode(), 0x3000000);
|
||||
assert.equal(cell.getFgColor(), cream);
|
||||
assert.equal(cell.isBgRGB(), true);
|
||||
assert.equal(cell.getBgColor(), yellow);
|
||||
}
|
||||
for (const cell of tokenCells("/mnt")) {
|
||||
assert.equal(cell.isFgPalette(), true);
|
||||
assert.equal(cell.getFgColorMode(), 0x2000000);
|
||||
assert.equal(cell.getFgColor(), 220);
|
||||
}
|
||||
for (const cell of tokenCells("/bin")) {
|
||||
assert.equal(cell.isFgPalette(), true);
|
||||
assert.equal(cell.getFgColorMode(), 0x1000000);
|
||||
assert.equal(cell.getFgColor(), 1);
|
||||
}
|
||||
for (const cell of tokenCells("/var")) {
|
||||
assert.equal(cell.isFgRGB(), true);
|
||||
assert.equal(cell.getFgColor(), keywordRgb);
|
||||
}
|
||||
|
||||
highlighter.setRules([{ ...unixPath, color: "#60A5FA" }], true);
|
||||
await highlighter.whenSettled();
|
||||
assert.equal(tokenCells("/opt")[0]?.getFgColor(), cream);
|
||||
assert.equal(tokenCells("/mnt")[0]?.getFgColor(), 220);
|
||||
assert.equal(tokenCells("/bin")[0]?.getFgColor(), 1);
|
||||
assert.equal(tokenCells("/var")[0]?.getFgColor(), BLUE);
|
||||
|
||||
highlighter.setRules([{ ...unixPath, color: "#60A5FA" }], false);
|
||||
await highlighter.whenSettled();
|
||||
assert.equal(tokenCells("/opt")[0]?.getFgColor(), cream);
|
||||
assert.equal(tokenCells("/mnt")[0]?.getFgColor(), 220);
|
||||
assert.equal(tokenCells("/bin")[0]?.getFgColor(), 1);
|
||||
assert.equal(tokenCells("/var")[0]?.getFgColor(), -1);
|
||||
|
||||
highlighter.dispose();
|
||||
term.dispose();
|
||||
});
|
||||
|
||||
test("ordinary Enter output does not revisit existing history", async () => {
|
||||
const term = new XTerm({ allowProposedApi: true, cols: 80, rows: 5, scrollback: 20 });
|
||||
const highlighter = new KeywordHighlighter(term);
|
||||
|
||||
@@ -67,6 +67,7 @@ const CELL_INDICES = 3;
|
||||
const CELL_CONTENT = 0;
|
||||
const CELL_FG = 1;
|
||||
const STYLE_MASK = 0xfc000000;
|
||||
const CM_MASK = 0x3000000;
|
||||
const CM_RGB = 0x3000000;
|
||||
const MAX_PLUGIN_HIGHLIGHT_SCAN_CHARS = 4_096;
|
||||
const MAX_PLUGIN_HIGHLIGHT_MATCHES_PER_WRITE = 256;
|
||||
@@ -1061,7 +1062,13 @@ export class KeywordHighlighter implements IDisposable {
|
||||
const dataIndex = x * CELL_INDICES;
|
||||
const content = internal._data[dataIndex + CELL_CONTENT];
|
||||
const currentFg = internal._data[dataIndex + CELL_FG];
|
||||
if (!originals.mask[x] || originals.content[x] !== content) {
|
||||
// A cell already carrying a PTY palette/truecolor foreground (starship
|
||||
// directory segments, ANSI logs) must keep that color. Keyword RGB is
|
||||
// only for the default foreground.
|
||||
const maskedForContent = originals.mask[x] === 1 && originals.content[x] === content;
|
||||
const ptyFg = maskedForContent ? originals.fg[x] : currentFg;
|
||||
if ((ptyFg & CM_MASK) !== 0) continue;
|
||||
if (!maskedForContent) {
|
||||
originals.fg[x] = currentFg;
|
||||
originals.content[x] = content;
|
||||
originals.mask[x] = 1;
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
# 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.
|
||||
@@ -68,7 +68,8 @@ const PROMPT_SUFFIX_WINDOW_PER_PROMPT_CHAR = 8;
|
||||
|
||||
function hasExpectedPromptSuffix(text, expectedPrompt) {
|
||||
if (!expectedPrompt) return false;
|
||||
const normalizedPrompt = stripAnsi(String(expectedPrompt || "")).replace(/\r/g, "");
|
||||
const ansiFreePrompt = stripAnsi(String(expectedPrompt || ""));
|
||||
const normalizedPrompt = ansiFreePrompt.replace(/\r/g, "");
|
||||
if (!normalizedPrompt) return false;
|
||||
const fullText = String(text || "");
|
||||
const windowChars = Math.max(
|
||||
@@ -76,7 +77,17 @@ function hasExpectedPromptSuffix(text, expectedPrompt) {
|
||||
String(expectedPrompt).length * PROMPT_SUFFIX_WINDOW_PER_PROMPT_CHAR,
|
||||
);
|
||||
const tail = fullText.length > windowChars ? fullText.slice(-windowChars) : fullText;
|
||||
return stripAnsi(tail).replace(/\r/g, "").endsWith(normalizedPrompt);
|
||||
const strippedTail = stripAnsi(tail);
|
||||
// Drop CR so a redraw inside one line (`root@host:~\r# `) still matches
|
||||
// the cached prompt. That collapses a two-line PS1's `\r\r\n` to a single
|
||||
// LF, which no longer ends with the cache (`user@host: /root\n\n\n$ `).
|
||||
if (strippedTail.replace(/\r/g, "").endsWith(normalizedPrompt)) return true;
|
||||
// The idle-prompt cache turns those CRs into LFs and keeps the blank
|
||||
// lines. Compare that form too, only when the cache itself spans lines,
|
||||
// so a bare `$`/`#` is unchanged.
|
||||
if (!ansiFreePrompt.includes("\n")) return false;
|
||||
const crAsLfPrompt = ansiFreePrompt.replace(/\r/g, "\n");
|
||||
return strippedTail.replace(/\r/g, "\n").endsWith(crAsLfPrompt);
|
||||
}
|
||||
|
||||
function escapePosixSingleQuoted(text) {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const { hasExpectedPromptSuffix } = require("./ptyExecHelpers.cjs");
|
||||
const { stripAnsi } = require("./shellUtils.cjs");
|
||||
const { extractTrailingIdlePrompt, stripAnsi } = require("./shellUtils.cjs");
|
||||
|
||||
// The behaviour before the tail window was introduced: normalise the whole
|
||||
// text, then compare its end.
|
||||
@@ -41,6 +41,38 @@ test("prompt suffix is found after a very large flood of CRLF output", () => {
|
||||
assert.equal(hasExpectedPromptSuffix(`${flood}${COLORED_PROMPT}`, "root@3160T:~# "), true);
|
||||
});
|
||||
|
||||
test("live PTY CR bytes match a cached two-line idle prompt", () => {
|
||||
// Bash writes the PS1 newline as CR and ONLCR adds another, so the bytes
|
||||
// are `\r\r\n`. The idle-prompt cache keeps those as blank lines. Suffix
|
||||
// matching has to accept that form; stripping CR down to one LF does not.
|
||||
const live = "user@host: /workspace\r\r\n$ ";
|
||||
const cached = extractTrailingIdlePrompt(`welcome\r\n${live}`);
|
||||
assert.equal(cached, "user@host: /workspace\n\n\n$ ");
|
||||
const matchLive = hasExpectedPromptSuffix(live, cached);
|
||||
assert.equal(matchLive, true);
|
||||
assert.equal(hasExpectedPromptSuffix(`hostname\r\nbox\r\n${live}`, cached), true);
|
||||
assert.equal(
|
||||
hasExpectedPromptSuffix("\x1b[?2004huser@host: /workspace\r\r\n$ ", cached),
|
||||
true,
|
||||
);
|
||||
|
||||
const rootLive = "user@host: /root\r\r\n# ";
|
||||
const rootCached = extractTrailingIdlePrompt(rootLive);
|
||||
assert.equal(rootCached, "user@host: /root\n\n\n# ");
|
||||
assert.equal(hasExpectedPromptSuffix(rootLive, rootCached), true);
|
||||
|
||||
// Readiness for a real LF two-line prompt still matches, and a bare
|
||||
// `$`/`#` is neither this prompt nor a POSIX prompt on its own.
|
||||
const lfCached = extractTrailingIdlePrompt("welcome\r\nuser@host: /workspace\n$ ");
|
||||
assert.equal(lfCached, "user@host: /workspace\n$ ");
|
||||
assert.equal(hasExpectedPromptSuffix("user@host: /workspace\n$ ", lfCached), true);
|
||||
assert.equal(hasExpectedPromptSuffix("output\r\n$ ", cached), false);
|
||||
assert.equal(hasExpectedPromptSuffix("$ ", cached), false);
|
||||
assert.equal(hasExpectedPromptSuffix("# ", rootCached), false);
|
||||
assert.equal(extractTrailingIdlePrompt("$ "), "");
|
||||
assert.equal(extractTrailingIdlePrompt("# "), "");
|
||||
});
|
||||
|
||||
test("prompt suffix check does not scan the whole buffer on every call", () => {
|
||||
// 2M characters of CRLF output used to cost seconds per call because every
|
||||
// call stripped ANSI and carriage returns over the entire text.
|
||||
|
||||
@@ -69,6 +69,11 @@ const CMD_PROMPT_PATTERN = /^[A-Za-z]:(?:\\[^<>"|]*)?>$/;
|
||||
// the final `]` before `$`/`#` is the delimiter.
|
||||
const POSIX_PROMPT_PATTERN = /^(?:[^\s@]+@[^\s:]+(?::[^\n\r]*)?|\[[^\s@\]]+@[^\s\]:]+(?:\s+[^\n\r]+)?\])[#$]$/;
|
||||
|
||||
// Host line of a two-line bash PS1 (`\u@\h: \w` then a following `$` or `#`).
|
||||
// Same user@host[:path] class as POSIX_PROMPT_PATTERN's first alternative,
|
||||
// without a prompt marker on this line. A bare `$`/`#` alone is not a prompt.
|
||||
const POSIX_PROMPT_HOST_LINE_PATTERN = /^[^\s@]+@[^\s:]+(?::[^\n\r]*)?$/;
|
||||
|
||||
// Alpine's default ash PS1 omits the username: `\h:\w\$ ` -> `localhost:~# `.
|
||||
// Require a hostname and a home/absolute path, excluding prompt delimiters
|
||||
// within the path so partially entered commands do not count as idle prompts.
|
||||
@@ -84,7 +89,14 @@ function isDefaultCmdPromptLine(line) {
|
||||
|
||||
function isDefaultPosixPromptLine(line) {
|
||||
const normalized = String(line || "").replace(/\s+$/, "");
|
||||
return POSIX_PROMPT_PATTERN.test(normalized) || ALPINE_PROMPT_PATTERN.test(normalized);
|
||||
// Optional virtualenv / conda / BT-panel style env name prefix: "(name) "
|
||||
// before the shell prompt. Recognition only; callers still return the full
|
||||
// prompt line so suffix matching sees the visible prefix (#3947).
|
||||
const withoutEnvPrefix = normalized.replace(/^\([^()\n\r]+\)\s+/, "");
|
||||
return (
|
||||
POSIX_PROMPT_PATTERN.test(withoutEnvPrefix) ||
|
||||
ALPINE_PROMPT_PATTERN.test(withoutEnvPrefix)
|
||||
);
|
||||
}
|
||||
|
||||
function extractTrailingIdlePrompt(output) {
|
||||
@@ -95,7 +107,8 @@ function extractTrailingIdlePrompt(output) {
|
||||
const normalized = stripAnsi(output).replace(/\r/g, "\n");
|
||||
if (!normalized || normalized.endsWith("\n")) return "";
|
||||
|
||||
const lastLine = normalized.split("\n").pop() || "";
|
||||
const lines = normalized.split("\n");
|
||||
const lastLine = lines.pop() || "";
|
||||
const rightTrimmed = lastLine.replace(/\s+$/, "");
|
||||
if (!rightTrimmed) return "";
|
||||
|
||||
@@ -111,6 +124,24 @@ function extractTrailingIdlePrompt(output) {
|
||||
return lastLine;
|
||||
}
|
||||
|
||||
// Two-line PS1 (`export PS1="\u@\h: \w\n$ "`). Bash emits the newline as
|
||||
// CR and the PTY's ONLCR adds another, so the bytes are `\r\r\n`. The
|
||||
// CR-to-LF normalize above turns that into blank lines between the host
|
||||
// line and `$`/`#`. Keep those blanks in the cache: getFreshIdlePrompt
|
||||
// uses the same normalize, and hasExpectedPromptSuffix compares this
|
||||
// CR-as-LF form against live PTY bytes. A lone `$` must not satisfy endsWith.
|
||||
if (rightTrimmed === "$" || rightTrimmed === "#") {
|
||||
let hostIndex = lines.length - 1;
|
||||
while (hostIndex >= 0 && lines[hostIndex].replace(/\s+$/, "") === "") {
|
||||
hostIndex -= 1;
|
||||
}
|
||||
const previousLine = hostIndex >= 0 ? lines[hostIndex] : "";
|
||||
const previousRightTrimmed = previousLine.replace(/\s+$/, "");
|
||||
if (POSIX_PROMPT_HOST_LINE_PATTERN.test(previousRightTrimmed)) {
|
||||
return `${lines.slice(hostIndex).join("\n")}\n${lastLine}`;
|
||||
}
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
@@ -171,7 +202,8 @@ function trackSessionIdlePrompt(session, chunk) {
|
||||
// Return `session.lastIdlePrompt` only if the PTY's recent rolling tail
|
||||
// still ends with it. The cached prompt is updated only when
|
||||
// extractTrailingIdlePrompt recognizes a known shape (PowerShell, cmd.exe,
|
||||
// `user@host[:path][#$]`, RHEL `[user@host path][#$]`, or Alpine `host:path[#$]`);
|
||||
// `user@host[:path][#$]`, two-line `user@host[:path]` + `$`/`#`,
|
||||
// RHEL `[user@host path][#$]`, or Alpine `host:path[#$]`);
|
||||
// a remote shell switch into another shell, an oh-my-posh / starship / custom
|
||||
// PS1, or any unrecognized prompt would
|
||||
// otherwise leave a stale value behind, which `resolveEffectiveShellKind`
|
||||
|
||||
@@ -219,6 +219,97 @@ test("extracts a trailing RHEL-family bracketed idle prompt", () => {
|
||||
assert.equal(extractTrailingIdlePrompt("welcome\r\nroot@host ~#"), "");
|
||||
});
|
||||
|
||||
test("extractTrailingIdlePrompt recognizes a two-line user@host then bare $/# PS1", () => {
|
||||
// export PS1="\u@\h: \w\n$ " paints the host line, then a lone "$ ".
|
||||
// The cached prompt must keep both lines so a lone "$" cannot satisfy endsWith.
|
||||
assert.equal(
|
||||
extractTrailingIdlePrompt("welcome\r\nuser@host: /home/foo\n$ "),
|
||||
"user@host: /home/foo\n$ ",
|
||||
);
|
||||
assert.equal(
|
||||
extractTrailingIdlePrompt("welcome\r\nroot@host: /root\n# "),
|
||||
"root@host: /root\n# ",
|
||||
);
|
||||
// Live bash PTY: prompt CR plus ONLCR is `\r\r\n`, which this normalizer
|
||||
// stores as blank lines. The cache must keep them so endsWith still matches.
|
||||
assert.equal(
|
||||
extractTrailingIdlePrompt("\x1b[?2004huser@host: /home/foo\r\r\n$ "),
|
||||
"user@host: /home/foo\n\n\n$ ",
|
||||
);
|
||||
assert.equal(
|
||||
extractTrailingIdlePrompt("\x1b[?2004hroot@host: /root\r\r\n# "),
|
||||
"root@host: /root\n\n\n# ",
|
||||
);
|
||||
assert.equal(extractTrailingIdlePrompt("$ "), "");
|
||||
assert.equal(extractTrailingIdlePrompt("# "), "");
|
||||
assert.equal(extractTrailingIdlePrompt("welcome\r\n$ "), "");
|
||||
assert.equal(extractTrailingIdlePrompt("not a host line\n$ "), "");
|
||||
assert.equal(extractTrailingIdlePrompt("copied output\n# "), "");
|
||||
assert.equal(isDefaultPosixPromptLine("$"), false);
|
||||
assert.equal(isDefaultPosixPromptLine("#"), false);
|
||||
assert.equal(isDefaultPosixPromptLine("$ "), false);
|
||||
assert.equal(isDefaultPosixPromptLine("# "), false);
|
||||
});
|
||||
|
||||
test("trackSessionIdlePrompt and getFreshIdlePrompt accept a two-line user@host PS1", () => {
|
||||
const session = {};
|
||||
const prompt = "user@host: /home/foo\n$ ";
|
||||
trackSessionIdlePrompt(session, `welcome\r\n${prompt}`);
|
||||
assert.equal(getFreshIdlePrompt(session), prompt);
|
||||
|
||||
const ptySession = {};
|
||||
trackSessionIdlePrompt(ptySession, "\x1b[?2004huser@host: /home/foo\r\r\n$ ");
|
||||
assert.equal(getFreshIdlePrompt(ptySession), "user@host: /home/foo\n\n\n$ ");
|
||||
});
|
||||
|
||||
test("extractTrailingIdlePrompt still recognizes a classic single-line POSIX prompt", () => {
|
||||
assert.equal(extractTrailingIdlePrompt("welcome\r\nuser@host:~$ "), "user@host:~$ ");
|
||||
});
|
||||
|
||||
test("isDefaultPosixPromptLine accepts virtualenv, conda, and BT-panel env prefixes", () => {
|
||||
// Python venv / conda / BT-panel prepend "(name) " before the real prompt.
|
||||
// Recognition strips that prefix; a missing space after ")" is already
|
||||
// accepted because the username class absorbs the parens (#3947).
|
||||
for (const prompt of [
|
||||
"(3.12.12) root@example:~# ",
|
||||
"(3.12.12) root@example:~#",
|
||||
"(venv) alice@host:~$ ",
|
||||
"(base) [opc@host ~]$ ",
|
||||
"(my-env) localhost:~# ",
|
||||
"(3.12.12)root@example:~# ",
|
||||
]) {
|
||||
assert.equal(isDefaultPosixPromptLine(prompt), true, prompt);
|
||||
}
|
||||
});
|
||||
|
||||
test("extractTrailingIdlePrompt keeps the full env-prefixed POSIX prompt", () => {
|
||||
// Suffix matching compares the visible PTY line, so the captured prompt
|
||||
// must still include the "(name) " prefix rather than the stripped form.
|
||||
for (const prompt of [
|
||||
"(3.12.12) root@example:~# ",
|
||||
"(3.12.12) root@example:~#",
|
||||
"(venv) alice@host:~$ ",
|
||||
"(base) [opc@host ~]$ ",
|
||||
"(my-env) localhost:~# ",
|
||||
]) {
|
||||
assert.equal(extractTrailingIdlePrompt(`Last login\r\n${prompt}`), prompt, prompt);
|
||||
}
|
||||
});
|
||||
|
||||
test("env-prefixed POSIX recognition rejects incomplete prefixes and non-prompts", () => {
|
||||
for (const line of [
|
||||
"(3.12.12 root@example:~# ",
|
||||
"(3.12.12) root@example:~# printf busy",
|
||||
"(venv) not a prompt",
|
||||
"root@host ~#",
|
||||
"(base) [opc@host ~]$ printf busy",
|
||||
"(my-env) localhost:~# printf busy",
|
||||
]) {
|
||||
assert.equal(isDefaultPosixPromptLine(line), false, line);
|
||||
assert.equal(extractTrailingIdlePrompt(`welcome\r\n${line}`), "", line);
|
||||
}
|
||||
});
|
||||
|
||||
test("isPlausibleCliVersionOutput rejects stack traces and file URLs", () => {
|
||||
assert.equal(isPlausibleCliVersionOutput("2.1.123 (Claude Code)"), true);
|
||||
assert.equal(isPlausibleCliVersionOutput("codex-cli 0.125.0"), true);
|
||||
|
||||
@@ -836,6 +836,105 @@ test("sudo SFTP-page transfer reuses the elevated channel instead of dialing aga
|
||||
assert.equal(sftpClients.has("browse-sudo"), true);
|
||||
});
|
||||
|
||||
for (const [ident, sequential] of [
|
||||
["OpenSSH_9.6", false],
|
||||
["CLOUDBILITY-4.14", true],
|
||||
["TERM-SSHD", true],
|
||||
]) {
|
||||
test(`sudo transfer aliases preserve ${ident} upload concurrency`, { timeout: 15000 }, async (t) => {
|
||||
const server = await startOtpOnlyServer({ ident });
|
||||
pool.resetSshTransportRegistryForTests({ defaultIdleTtlMs: 0 });
|
||||
const sftpClients = new Map();
|
||||
sftpBridge.init({ sftpClients, sessions: new Map(), electronModule: {} });
|
||||
transferBridge.init({ sftpClients });
|
||||
t.after(async () => {
|
||||
for (const sftpId of [...sftpClients.keys()].reverse()) {
|
||||
await sftpBridge.closeSftp(null, { sftpId }).catch(() => {});
|
||||
}
|
||||
pool.discardAllTransports("test-end");
|
||||
pool.resetSshTransportRegistryForTests({ defaultIdleTtlMs: 0 });
|
||||
await server.close();
|
||||
});
|
||||
|
||||
const options = { ...BASE_OPTIONS(server.port), reuseTransport: false };
|
||||
const sender = createSender();
|
||||
const opening = sftpBridge.openSftp({ sender }, { ...options, sessionId: "sudo-owner" });
|
||||
opening.catch(() => {});
|
||||
await waitFor(() => answerOtpPrompts(sender));
|
||||
const opened = await opening;
|
||||
const owner = sftpClients.get(opened.sftpId);
|
||||
// Model an already-elevated channel. The real SSH/SFTP peer remains the
|
||||
// ordinary server or bastion above; no second sudo/authentication is needed.
|
||||
const sudoOptions = { ...BASE_OPTIONS(server.port), sudo: true };
|
||||
const endpoint = pool.buildConnectionReuseEndpoint(sudoOptions, { sftpSudo: true });
|
||||
owner.__netcattySudoMode = true;
|
||||
owner.__netcattyEndpointKey = pool.buildEndpointKey(endpoint);
|
||||
owner.__netcattyChannelAdoptionKey = pool.buildChannelAdoptionKey(endpoint);
|
||||
const firstAlias = await sftpBridge.openSftp({ sender: createSender() }, sudoOptions);
|
||||
const secondAlias = await sftpBridge.openSftp({ sender: createSender() }, sudoOptions);
|
||||
for (const alias of [firstAlias, secondAlias]) {
|
||||
const client = sftpClients.get(alias.sftpId);
|
||||
assert.equal(client.__netcattySharedChannelAlias, true);
|
||||
assert.equal(client.__netcattySudoMode, true);
|
||||
assert.equal(client.sftp, owner.sftp, "both uploads must retain the elevated channel");
|
||||
}
|
||||
|
||||
const root = fs.mkdtempSync(`${tempDirBridge.getTempFilePath("sudo-concurrency")}-`);
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const source = path.join(root, "source.bin");
|
||||
const payload = Buffer.alloc(3 * 32768 + 17, 0x5a);
|
||||
fs.writeFileSync(source, payload);
|
||||
const firstPath = "/home/otpuser/first.bin";
|
||||
const secondPath = "/home/otpuser/second.bin";
|
||||
server.files.set(firstPath, Buffer.from("original file"));
|
||||
const channel = owner.sftp;
|
||||
const originalLstat = channel.lstat.bind(channel);
|
||||
let firstTargetChecks = 0;
|
||||
let promotionStarted;
|
||||
const promotionStartedPromise = new Promise((resolve) => { promotionStarted = resolve; });
|
||||
let releasePromotion;
|
||||
const promotionGate = new Promise((resolve) => { releasePromotion = resolve; });
|
||||
channel.lstat = (remotePath, ...args) => {
|
||||
if (String(remotePath) === firstPath && ++firstTargetChecks === 2) {
|
||||
promotionStarted();
|
||||
void promotionGate.then(() => originalLstat(remotePath, ...args));
|
||||
return;
|
||||
}
|
||||
originalLstat(remotePath, ...args);
|
||||
};
|
||||
const upload = (alias, targetPath) => transferBridge.startTransfer({ sender: createSender() }, {
|
||||
transferId: `sudo-concurrency-${ident}-${path.basename(targetPath)}`,
|
||||
sourcePath: source, targetPath,
|
||||
sourceType: "local", targetType: "sftp", targetSftpId: alias.sftpId,
|
||||
totalBytes: payload.length, resumable: true,
|
||||
});
|
||||
let firstFinished = false;
|
||||
let secondFinished = false;
|
||||
const first = upload(firstAlias, firstPath).then((result) => { firstFinished = true; return result; });
|
||||
let second;
|
||||
let results;
|
||||
try {
|
||||
await promotionStartedPromise;
|
||||
second = upload(secondAlias, secondPath).then((result) => { secondFinished = true; return result; });
|
||||
await waitFor(() => secondFinished
|
||||
|| transferBridge._getSessionFastDownloadWaiterCountForTests(channel) > 0);
|
||||
assert.equal(firstFinished, false, "the first upload must still be held before publication");
|
||||
assert.equal(secondFinished, !sequential,
|
||||
sequential ? "a bastion must wait for the first promotion" : "ordinary sudo uploads must remain concurrent");
|
||||
assert.equal(transferBridge._getSessionFastDownloadWaiterCountForTests(channel), sequential ? 1 : 0);
|
||||
} finally {
|
||||
releasePromotion();
|
||||
results = await Promise.all(second ? [first, second] : [first]);
|
||||
channel.lstat = originalLstat;
|
||||
}
|
||||
for (const result of results) assert.equal(result.error, undefined, result.error);
|
||||
assert.deepEqual(server.files.get(firstPath), payload);
|
||||
assert.deepEqual(server.files.get(secondPath), payload);
|
||||
assert.equal(server.getConnectionCount(), 1, "sudo uploads must not open an unelevated connection");
|
||||
assert.equal(server.getKeyboardInteractiveRounds(), 1, "uploads must not ask for another OTP");
|
||||
});
|
||||
}
|
||||
|
||||
test("reconnecting the soft-closed owner id does not replace it with a self-alias (#3310)", async (t) => {
|
||||
const { sftpTransferSessionLeaseStore, channelAliasLeaseId } = require("./sftpTransferSessionLease.cjs");
|
||||
pool.resetSshTransportRegistryForTests({ defaultIdleTtlMs: 0 });
|
||||
|
||||
@@ -2474,16 +2474,68 @@ async function acquireSessionFastDownloadSlot(client, transfer) {
|
||||
}
|
||||
}
|
||||
|
||||
// TERM-SSHD compatibility requires one file handle per channel. Per-file request
|
||||
// concurrency is not enough: pooled SFTP aliases share that physical channel,
|
||||
// and a second body or verification OPEN would fail even for tiny files.
|
||||
// Reuse the cancelable one-slot queue, keyed by the stream rather than its
|
||||
// wrapper client, across both directions and the complete verification phase.
|
||||
function isSingleChannelSftpClient(client) {
|
||||
return Boolean(
|
||||
client?.__netcattySingleChannelSsh
|
||||
|| client?.client?.__netcattySingleChannelSsh,
|
||||
);
|
||||
}
|
||||
|
||||
function requiresExclusiveSftpTransferChannel(client, channel) {
|
||||
// Aliases stay on one authenticated/elevated channel, even on ordinary SSH
|
||||
// servers. Only the owning peer's restriction requires serial transfers.
|
||||
const peer = client?.__netcattySharedChannelAlias
|
||||
? sftpClients?.get(client.__netcattySharedChannelOwnerId) || client
|
||||
: client;
|
||||
return isSingleChannelSftpClient(peer)
|
||||
|| getSftpTransferConcurrency(channel, DOWNLOAD_TRANSFER_CONCURRENCY) === 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Some single-channel gateways cannot safely interleave SFTP metadata and
|
||||
* write requests from different files. Serialize the complete upload
|
||||
* transaction per raw SFTP stream while leaving independent connections free.
|
||||
*/
|
||||
async function acquireExclusiveSftpTransferTransaction(client, transfer) {
|
||||
if (isScpModeClient(client)) return null;
|
||||
// Preserve the normal lazy SFTP-open path. Only known single-channel peers
|
||||
// acquire the transaction slot before the upload itself opens a handle.
|
||||
const knownChannel = client?.sftp || { _client: client?.client };
|
||||
if (!requiresExclusiveSftpTransferChannel(client, knownChannel)) return null;
|
||||
await requireSftpChannel(client);
|
||||
const channel = client.sftp;
|
||||
if (transfer?._exclusiveSftpTransactionChannel === channel) return null;
|
||||
|
||||
await acquireSessionFastDownloadSlot(channel, transfer);
|
||||
transfer._exclusiveSftpTransactionChannel = channel;
|
||||
let released = false;
|
||||
return {
|
||||
client,
|
||||
channel,
|
||||
release() {
|
||||
if (released) return;
|
||||
released = true;
|
||||
if (transfer._exclusiveSftpTransactionChannel === channel) {
|
||||
transfer._exclusiveSftpTransactionChannel = null;
|
||||
}
|
||||
releaseSessionFastDownloadSlot(channel);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Single-channel compatibility permits only one transfer operation per raw
|
||||
// SFTP stream. A per-file request limit is insufficient because pooled aliases
|
||||
// share the physical channel, and even a tiny file's second OPEN or verification
|
||||
// read can fail. Reuse the cancelable one-slot queue keyed by stream rather
|
||||
// than wrapper client across both directions and complete verification.
|
||||
async function withSequentialSftpTransferSlot(client, transfer, operation) {
|
||||
if (isScpModeClient(client)) return operation();
|
||||
await requireSftpChannel(client);
|
||||
const channel = client.sftp;
|
||||
if (getSftpTransferConcurrency(channel, DOWNLOAD_TRANSFER_CONCURRENCY) !== 1) {
|
||||
if (transfer?._exclusiveSftpTransactionChannel === channel) {
|
||||
return operation();
|
||||
}
|
||||
if (!requiresExclusiveSftpTransferChannel(client, channel)) {
|
||||
return operation();
|
||||
}
|
||||
await acquireSessionFastDownloadSlot(channel, transfer);
|
||||
@@ -6625,6 +6677,7 @@ async function startTransferNow(event, payload, onProgress) {
|
||||
});
|
||||
};
|
||||
|
||||
let exclusiveSftpUploadTransaction = null;
|
||||
try {
|
||||
// Explicit 0 is a valid empty-snapshot plan (e.g. download of an empty log
|
||||
// that may grow later). Do not treat it as "size unknown" and re-stat into
|
||||
@@ -6752,6 +6805,16 @@ async function startTransferNow(event, payload, onProgress) {
|
||||
else await verify();
|
||||
}
|
||||
|
||||
// A single-channel upload holds the raw SFTP stream from directory setup
|
||||
// through stage verification, destination safety checks, promotion, and
|
||||
// timestamp restoration so directory children cannot interleave requests.
|
||||
if (sourceType === "local" && targetType === "sftp" && targetClient) {
|
||||
exclusiveSftpUploadTransaction = await acquireExclusiveSftpTransferTransaction(
|
||||
targetClient,
|
||||
transfer,
|
||||
);
|
||||
}
|
||||
|
||||
sendProgress(transfer.checkpointBytes, fileSize, { force: true });
|
||||
|
||||
if (sourceType === 'local' && targetType === 'sftp') {
|
||||
@@ -7380,6 +7443,13 @@ async function startTransferNow(event, payload, onProgress) {
|
||||
|
||||
return { transferId, totalBytes: fileSize, ...(transfer.warning ? { warning: transfer.warning } : {}) };
|
||||
} catch (err) {
|
||||
if (
|
||||
exclusiveSftpUploadTransaction
|
||||
&& err?.uncleanSftpChannel
|
||||
&& exclusiveSftpUploadTransaction.client.sftp === exclusiveSftpUploadTransaction.channel
|
||||
) {
|
||||
abandonWedgedVerificationSftpChannel(exclusiveSftpUploadTransaction.client);
|
||||
}
|
||||
const failureMessage = err?.message || String(err);
|
||||
if (
|
||||
sourceType === "local"
|
||||
@@ -7465,6 +7535,8 @@ async function startTransferNow(event, payload, onProgress) {
|
||||
sendError(err);
|
||||
}
|
||||
return { transferId, error: err.message };
|
||||
} finally {
|
||||
exclusiveSftpUploadTransaction?.release();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -233,6 +233,7 @@ async function createSequentialTransferFixture(t) {
|
||||
return {
|
||||
payload, files, sftp, handles, clients,
|
||||
rejectedOpens: () => rejectedOpens,
|
||||
openCount: () => opens,
|
||||
async start(direction, sftpId = "first", options = {}) {
|
||||
const transferId = options.transferId || crypto.randomUUID();
|
||||
const localPath = tempDirBridge.getTempFilePath(`term-concurrent-${transferId}.bin`);
|
||||
@@ -263,6 +264,61 @@ async function createSequentialTransferFixture(t) {
|
||||
};
|
||||
}
|
||||
|
||||
test("single-channel upload waits for the prior promotion before starting the next file", { timeout: 5_000 }, async (t) => {
|
||||
const fixture = await createSequentialTransferFixture(t);
|
||||
const originalLstat = fixture.sftp.lstat;
|
||||
let lstatCalls = 0;
|
||||
let promotionStarted;
|
||||
const promotionStartedPromise = new Promise((resolve) => { promotionStarted = resolve; });
|
||||
let releasePromotion;
|
||||
const promotionGate = new Promise((resolve) => { releasePromotion = resolve; });
|
||||
|
||||
fixture.sftp.lstat = (remotePath, callback) => {
|
||||
lstatCalls += 1;
|
||||
// The first two calls inspect the recovery backup and initial destination.
|
||||
// The third is the final safety check after the .part write; hold it to
|
||||
// prove that the next file cannot acquire the channel yet.
|
||||
if (lstatCalls === 3) {
|
||||
promotionStarted();
|
||||
void promotionGate.then(() => originalLstat(remotePath, callback));
|
||||
return;
|
||||
}
|
||||
originalLstat(remotePath, callback);
|
||||
};
|
||||
|
||||
const first = fixture.start("upload", "first");
|
||||
await promotionStartedPromise;
|
||||
const openCountAtPromotion = fixture.openCount();
|
||||
const second = fixture.start("upload", "alias");
|
||||
let firstResult;
|
||||
let secondResult;
|
||||
try {
|
||||
// start() first writes its local source. Wait until the second upload
|
||||
// actually queues, or incorrectly starts remote work, before asserting.
|
||||
const deadline = Date.now() + 1_000;
|
||||
while (
|
||||
transferBridge._getSessionFastDownloadWaiterCountForTests(fixture.sftp) === 0
|
||||
&& lstatCalls === 3
|
||||
&& fixture.openCount() === openCountAtPromotion
|
||||
&& Date.now() < deadline
|
||||
) {
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
}
|
||||
assert.equal(lstatCalls, 3, "the next file must not inspect its target during prior promotion");
|
||||
assert.equal(fixture.openCount(), openCountAtPromotion, "the next file must not open a remote handle during prior promotion");
|
||||
assert.equal(transferBridge._getSessionFastDownloadWaiterCountForTests(fixture.sftp), 1,
|
||||
"the second upload must be waiting for the shared channel");
|
||||
} finally {
|
||||
releasePromotion();
|
||||
[firstResult, secondResult] = await Promise.all([first, second]);
|
||||
}
|
||||
assert.equal(firstResult.result.error, undefined);
|
||||
assert.equal(secondResult.result.error, undefined);
|
||||
assert.deepEqual(fixture.files.get(firstResult.targetPath), fixture.payload);
|
||||
assert.deepEqual(fixture.files.get(secondResult.targetPath), fixture.payload);
|
||||
assert.equal(fixture.rejectedOpens(), 0);
|
||||
});
|
||||
|
||||
for (const secondId of ["first", "alias"]) {
|
||||
test(`TERM-SSHD small downloads serialize through body and verification (${secondId})`, async (t) => {
|
||||
const fixture = await createSequentialTransferFixture(t);
|
||||
|
||||
@@ -151,35 +151,6 @@ 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 = {}) {
|
||||
@@ -216,9 +187,7 @@ 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 === false
|
||||
? false
|
||||
: options.enable === true || previousPlugin?.enabled === true;
|
||||
const enableAfterActivation = options.enable === true || previousPlugin?.enabled === true;
|
||||
let activationPrepared = false;
|
||||
const prepareActivation = async (reason) => {
|
||||
if (activationPrepared) return;
|
||||
@@ -245,7 +214,7 @@ class PackageStore {
|
||||
await rm(targetDirectory, { recursive: true, force: true });
|
||||
}
|
||||
if (existingValidation) {
|
||||
if (previousPlugin?.activeVersion !== version || options.enable === false) await prepareActivation("switch-active-version");
|
||||
if (previousPlugin?.activeVersion !== version) await prepareActivation("switch-active-version");
|
||||
const existingPackage = path.join(targetDirectory, PACKAGE_DIRECTORY);
|
||||
this.database.installVersion({
|
||||
pluginId,
|
||||
@@ -253,7 +222,7 @@ class PackageStore {
|
||||
manifest: existingValidation.manifest,
|
||||
archiveSha256: existing.archiveSha256,
|
||||
packageRelativePath: path.relative(this.paths.packages, existingPackage),
|
||||
}, { enable: enableAfterActivation, forceDisabled: options.enable === false });
|
||||
}, { enable: enableAfterActivation });
|
||||
return this.database.getActivePlugin(pluginId);
|
||||
}
|
||||
}
|
||||
@@ -272,7 +241,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 || options.enable === false) await prepareActivation("switch-active-version");
|
||||
if (previousPlugin?.activeVersion !== version) await prepareActivation("switch-active-version");
|
||||
await rename(stagingDirectory, targetDirectory);
|
||||
await this.syncDirectory(path.dirname(targetDirectory));
|
||||
const packageRelativePath = path.relative(
|
||||
@@ -286,7 +255,7 @@ class PackageStore {
|
||||
manifest,
|
||||
archiveSha256: snapshot.sha256,
|
||||
packageRelativePath,
|
||||
}, { enable: enableAfterActivation, forceDisabled: options.enable === false });
|
||||
}, { enable: enableAfterActivation });
|
||||
} catch (error) {
|
||||
this.verifiedArchives.delete(this.#versionKey(pluginId, version));
|
||||
await rm(targetDirectory, { recursive: true, force: true });
|
||||
|
||||
@@ -370,54 +370,3 @@ 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: typeof payload?.enable === "boolean" ? payload.enable : undefined },
|
||||
{ enable: payload?.enable === true },
|
||||
));
|
||||
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,
|
||||
enable: options?.enable === true,
|
||||
beforeActivate: async ({ pluginId, previousPlugin }) => {
|
||||
if (!previousPlugin?.enabled) return;
|
||||
this.database.setEnabled(pluginId, false);
|
||||
@@ -107,7 +107,6 @@ 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,26 +488,3 @@ 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
|
||||
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
|
||||
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
|
||||
```
|
||||
|
||||
After installation, change **Greeting** under **Settings → Plugins**, then run
|
||||
@@ -21,29 +21,3 @@ 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
+1
-2
@@ -337,8 +337,7 @@ declare global {
|
||||
id: string;
|
||||
enabled: boolean;
|
||||
activeVersion: string | null;
|
||||
manifest: import("@netcatty/plugin-contract").PluginManifest | null;
|
||||
archiveSha256?: string | null;
|
||||
manifest: unknown;
|
||||
runtime: {
|
||||
status: string;
|
||||
kind: 'browser' | 'utility' | null;
|
||||
|
||||
+3
-3
@@ -1,14 +1,14 @@
|
||||
{
|
||||
version = "1.1.89";
|
||||
version = "1.1.90";
|
||||
|
||||
sources = {
|
||||
x86_64-linux = {
|
||||
appImageArch = "x86_64";
|
||||
hash = "sha256-Ne5TalZ3G9cHPl0xnQpPq/0GNRe1ErUg1DzE+/nnseM=";
|
||||
hash = "sha256-oa/T3Zey0FHsH+SOt4C1H4CT6aZRJta+x4x/11EAOJE=";
|
||||
};
|
||||
aarch64-linux = {
|
||||
appImageArch = "arm64";
|
||||
hash = "sha256-L1aiJ/CroFwLSLH5aDnakogxjP8k5gLt8NJC+IGfXIw=";
|
||||
hash = "sha256-r5hr3+gpIGrv9XJ7PQkHCdhOd2COkhELMHlnr3xIG7A=";
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
/node packages\/plugin-cli\/dist\/cli\.js validate examples\/plugins\/hello-netcatty/,
|
||||
/npm exec -- netcatty-plugin validate examples\/plugins\/hello-netcatty/,
|
||||
);
|
||||
assert.match(
|
||||
readme,
|
||||
/node packages\/plugin-cli\/dist\/cli\.js compatibility examples\/plugins\/hello-netcatty --netcatty 0\.0\.0/,
|
||||
/npm exec -- netcatty-plugin compatibility examples\/plugins\/hello-netcatty --netcatty 0\.0\.0/,
|
||||
);
|
||||
assert.doesNotMatch(readme, /npm exec --workspace @netcatty\/plugin-cli/);
|
||||
});
|
||||
|
||||
@@ -345,6 +345,26 @@ test("windows packaging includes a zip archive target", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("windows installer kills leftover Netcatty.exe before file replacement", () => {
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const installerScript = fs.readFileSync(
|
||||
path.join(__dirname, "..", config.nsis.include),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
assert.match(installerScript, /!macro customInit\b/);
|
||||
assert.match(installerScript, /#3946/);
|
||||
assert.match(installerScript, /ELECTRON_RUN_AS_NODE/);
|
||||
assert.match(
|
||||
installerScript,
|
||||
/nsExec::ExecToLog "taskkill \/F \/IM Netcatty\.exe"/,
|
||||
);
|
||||
assert.match(installerScript, /Pop \$0/);
|
||||
// /T would kill this installer while it is still a child of the UI Netcatty.exe.
|
||||
assert.doesNotMatch(installerScript, /taskkill [^\r\n]*\/T/);
|
||||
});
|
||||
|
||||
test("windows installer registers and removes Explorer folder context menu entries", () => {
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
Reference in new issue
Block a user