Compare commits

..
99 changed files with 1874 additions and 3253 deletions

No files matched your search

+1 -5
View File
@@ -582,8 +582,6 @@ Highlight the focused split pane:
'settings.appearance.uiFont.desc': 'Choose the font for the application interface',
'settings.appearance.windowOpacity': 'Window Opacity',
'settings.appearance.windowOpacity.desc': 'Adjust the transparency of the entire application window. Lower values also fade terminal text. Some Linux desktop environments may not support this.',
'settings.appearance.windowBackgroundBlur': 'Frosted glass background',
'settings.appearance.windowBackgroundBlur.desc': 'Blur the desktop behind the window. At 100% opacity the effect is hidden, but your choice is preserved.',
'settings.appearance.appIcon': 'App Icon',
'settings.appearance.appIcon.desc': 'Choose a dock/taskbar icon style to make Netcatty easier to spot when switching apps. On Windows, the notification area icon uses the same style. Applies at runtime; the installed app bundle icon is unchanged.',
'settings.appearance.appIcon.original': 'Original',
@@ -1264,8 +1262,6 @@ Highlight the focused split pane:
'deepLink.jms.unsupported': 'Unsupported JumpServer protocol: {{protocol}}',
'settings.tab.plugins': 'Plugins',
"settings.plugins.manager.installed": "Installed plugins",
"settings.plugins.author.official": "Official maintainer",
"settings.plugins.author.officialMeaning": "Maintained by the Netcatty project. This is not a safety certification.",
"settings.plugins.manager.import": "Import .ncpkg",
"settings.plugins.manager.preview": "Imported packages stay disabled until you review and enable them.",
"settings.plugins.manager.empty": "Import a local plugin package to get started.",
@@ -1423,7 +1419,7 @@ Highlight the focused split pane:
'plugins.authentication.responseFailedWithMessage': 'Could not send the authentication response: {message}. Try again or cancel the request.',
'plugins.authentication.continue': 'Continue',
'plugins.hostActions.select': 'Share selected host metadata',
'plugins.hostActions.selectionNote': 'Only the selected hosts\' saved labels, addresses, usernames, protocols, groups and tags are shared. Group selection includes only the hosts selected now. Later edit or connection requests follow this plugin\'s permission settings.',
'plugins.hostActions.selectionNote': 'Share saved labels, addresses, usernames, protocols, groups and tags with this plugin. Group selection includes only the hosts selected now. Connecting requires a separate confirmation.',
'plugins.hostActions.create': 'Review host draft',
'plugins.hostActions.edit': 'Review host changes',
'plugins.hostActions.connect': 'Confirm connection',
+1 -5
View File
@@ -582,8 +582,6 @@ Resalta el panel dividido enfocado:
'settings.appearance.uiFont.desc': 'Elige la fuente para la interfaz de la aplicación',
'settings.appearance.windowOpacity': 'Opacidad de la ventana',
'settings.appearance.windowOpacity.desc': 'Ajusta la transparencia de toda la ventana de la aplicación. Los valores más bajos también atenúan el texto de la terminal. Algunos entornos de escritorio de Linux pueden no admitir esto.',
'settings.appearance.windowBackgroundBlur': 'Fondo de cristal esmerilado',
'settings.appearance.windowBackgroundBlur.desc': 'Desenfoca el escritorio detrás de la ventana. Con una opacidad del 100 %, el efecto no es visible, pero se conserva tu elección.',
'settings.appearance.appIcon': 'Ícono de la aplicación',
'settings.appearance.appIcon.desc': 'Elige un estilo de ícono para el dock o la barra de tareas que haga más fácil distinguir Netcatty al cambiar de aplicación. En Windows, el icono del área de notificación usa el mismo estilo. Se aplica en tiempo de ejecución; el ícono del paquete instalado no cambia.',
'settings.appearance.appIcon.original': 'Original',
@@ -1263,8 +1261,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.author.official": "Mantenimiento oficial",
"settings.plugins.author.officialMeaning": "Mantenido por el proyecto Netcatty. No es una certificación de seguridad.",
"settings.plugins.manager.installed": "Plugins instalados",
"settings.plugins.manager.import": "Importar .ncpkg",
"settings.plugins.manager.preview": "Los paquetes importados permanecen desactivados hasta que los revises y actives.",
@@ -1423,7 +1419,7 @@ Resalta el panel dividido enfocado:
'plugins.authentication.responseFailedWithMessage': 'No se pudo enviar la respuesta de autenticación: {message}. Inténtalo de nuevo o cancela la solicitud.',
'plugins.authentication.continue': 'Continuar',
'plugins.hostActions.select': 'Compartir metadatos de los equipos seleccionados',
'plugins.hostActions.selectionNote': 'Solo se comparten los nombres, direcciones, usuarios, protocolos, grupos y etiquetas guardados de los equipos seleccionados. Los grupos solo incluyen los equipos seleccionados ahora. Las solicitudes posteriores de edición o conexión siguen los permisos de este complemento.',
'plugins.hostActions.selectionNote': 'Comparte nombres, direcciones, usuarios, protocolos, grupos y etiquetas guardados con este complemento. Los grupos solo incluyen los equipos seleccionados ahora. Conectar requiere otra confirmación.',
'plugins.hostActions.create': 'Revisar borrador de equipo',
'plugins.hostActions.edit': 'Revisar cambios del equipo',
'plugins.hostActions.connect': 'Confirmar conexión',
+1 -5
View File
@@ -525,8 +525,6 @@ export const ruCoreMessages: Messages = {
'settings.appearance.uiFont.desc': 'Выберите шрифт для интерфейса приложения',
'settings.appearance.windowOpacity': 'Прозрачность окна',
'settings.appearance.windowOpacity.desc': 'Настройте прозрачность всего окна приложения. При низких значениях текст терминала тоже бледнеет. В некоторых средах Linux это может не поддерживаться.',
'settings.appearance.windowBackgroundBlur': 'Матовое стекло',
'settings.appearance.windowBackgroundBlur.desc': 'Размывает рабочий стол за окном. При непрозрачности 100 % эффект не виден, но ваш выбор сохраняется.',
'settings.appearance.appIcon': 'Значок приложения',
'settings.appearance.appIcon.desc': 'Выберите стиль значка для Dock/панели задач, чтобы Netcatty было проще найти при переключении приложений. В Windows значок области уведомлений использует тот же стиль. Применяется во время работы; значок установленного пакета не меняется.',
'settings.appearance.appIcon.original': 'Оригинал',
@@ -1132,8 +1130,6 @@ export const ruCoreMessages: Messages = {
'deepLink.jms.unsupported': 'Неподдерживаемый протокол JumpServer: {{protocol}}',
'settings.tab.plugins': 'Плагины',
"settings.plugins.author.official": "Официальная поддержка",
"settings.plugins.author.officialMeaning": "Поддерживается проектом Netcatty. Это не сертификат безопасности.",
"settings.plugins.manager.installed": "Установленные плагины",
"settings.plugins.manager.import": "Импорт .ncpkg",
"settings.plugins.manager.preview": "Импортированные пакеты отключены до проверки и включения.",
@@ -1288,7 +1284,7 @@ export const ruCoreMessages: Messages = {
'plugins.authentication.responseFailedWithMessage': 'Не удалось отправить ответ аутентификации: {message}. Повторите попытку или отмените запрос.',
'plugins.authentication.continue': 'Продолжить',
'plugins.hostActions.select': 'Передать метаданные выбранных хостов',
'plugins.hostActions.selectionNote': 'Передаются только метаданные выбранных хостов: сохранённые имена, адреса, пользователи, протоколы, группы и теги. Группа включает только выбранные сейчас хосты. Дальнейшие запросы изменения или подключения зависят от настроек разрешений плагина.',
'plugins.hostActions.selectionNote': 'Плагину будут переданы сохранённые имена, адреса, пользователи, протоколы, группы и теги. Группа включает только выбранные сейчас хосты. Подключение требует отдельного подтверждения.',
'plugins.hostActions.create': 'Проверить черновик хоста',
'plugins.hostActions.edit': 'Проверить изменения хоста',
'plugins.hostActions.connect': 'Подтвердить подключение',
@@ -29,16 +29,6 @@ const ALL_SETTINGS_LOCALES = [
...LOCALIZED_SETTINGS_LOCALES,
];
test("Host selection disclosure describes later permission-controlled actions without promising another confirmation", () => {
const permissionWords: Record<string, RegExp> = { en: /permission settings/i, es: /permisos/i, ru: /разрешени/u, 'zh-CN': /权限设置/u, 'zh-TW': /權限設定/u };
const oldPromise = /requires a separate confirmation|requiere otra confirmación|требует отдельного подтверждения|需要单独确认|需要另外確認/iu;
for (const locale of ALL_SETTINGS_LOCALES) {
const note = locale.messages['plugins.hostActions.selectionNote'];
assert.match(note, permissionWords[locale.name], locale.name);
assert.doesNotMatch(note, oldPromise, locale.name);
}
});
test("localized settings include names for every default shortcut", () => {
for (const locale of ALL_SETTINGS_LOCALES) {
const missing = DEFAULT_KEY_BINDINGS
+1 -5
View File
@@ -586,8 +586,6 @@ Vaults 标签保持扁平,不使用这块背景。
'settings.appearance.uiFont.desc': '选择软件界面使用的字体',
'settings.appearance.windowOpacity': '窗口透明度',
'settings.appearance.windowOpacity.desc': '调节整个应用窗口的透明度,方便叠在其他内容上方。较低时终端文字也会变淡;部分 Linux 桌面环境可能不支持。',
'settings.appearance.windowBackgroundBlur': '毛玻璃背景',
'settings.appearance.windowBackgroundBlur.desc': '模糊窗口后方的桌面。100% 不透明时效果不可见,但会保留你的开关设置。',
'settings.appearance.appIcon': '应用图标',
'settings.appearance.appIcon.desc': '选择 Dock/任务栏图标风格,切换应用时更容易找到 Netcatty。Windows 通知区域图标会使用同一套风格。运行时生效,安装包内的图标不会改变。',
'settings.appearance.appIcon.original': '原始',
@@ -1179,8 +1177,6 @@ Vaults 标签保持扁平,不使用这块背景。
'qs.default': '默认',
'settings.tab.plugins': '插件',
"settings.plugins.manager.installed": "已安装插件",
"settings.plugins.author.official": "官方维护",
"settings.plugins.author.officialMeaning": "由 Netcatty 项目维护。这不是安全认证。",
"settings.plugins.manager.import": "导入 .ncpkg",
"settings.plugins.manager.preview": "导入的插件保持停用,请检查后再启用。",
"settings.plugins.manager.empty": "导入本地插件包以开始使用。",
@@ -1338,7 +1334,7 @@ Vaults 标签保持扁平,不使用这块背景。
'plugins.authentication.responseFailedWithMessage': '无法发送认证响应:{message}。请重试或取消此请求。',
'plugins.authentication.continue': '继续',
'plugins.hostActions.select': '分享所选主机的元数据',
'plugins.hostActions.selectionNote': '仅向此插件共享所选主机的元数据:名称、地址、用户名、协议、分组和标签。选择分组仅包含本次选中的成员。后续编辑或连接请求遵循此插件的权限设置。',
'plugins.hostActions.selectionNote': '向此插件分享已保存的名称、地址、用户名、协议、分组和标签。选择分组仅包含本次选中的成员。连接主机需要单独确认。',
'plugins.hostActions.create': '预览主机草稿',
'plugins.hostActions.edit': '预览主机修改',
'plugins.hostActions.connect': '确认连接',
+1 -5
View File
@@ -588,8 +588,6 @@ Vaults 分頁維持扁平,不使用這塊背景。
'settings.appearance.uiFont.desc': '選擇軟體介面使用的字型',
'settings.appearance.windowOpacity': '視窗透明度',
'settings.appearance.windowOpacity.desc': '調節整個應用程式視窗的透明度,方便疊在其他內容上方。較低時終端文字也會變淡;部分 Linux 桌面環境可能不支援。',
'settings.appearance.windowBackgroundBlur': '毛玻璃背景',
'settings.appearance.windowBackgroundBlur.desc': '模糊視窗後方的桌面。100% 不透明時效果不可見,但會保留你的開關設定。',
'settings.appearance.appIcon': '應用程式圖示',
'settings.appearance.appIcon.desc': '選擇 Dock/工作列圖示風格,切換應用程式時更容易找到 Netcatty。Windows 通知區域圖示會使用同一套風格。執行時生效,安裝檔內的圖示不會改變。',
'settings.appearance.appIcon.original': '原始',
@@ -1181,8 +1179,6 @@ Vaults 分頁維持扁平,不使用這塊背景。
'qs.default': '預設',
'settings.tab.plugins': '外掛程式',
"settings.plugins.author.official": "官方維護",
"settings.plugins.author.officialMeaning": "由 Netcatty 專案維護。這不是安全認證。",
"settings.plugins.manager.installed": "已安裝插件",
"settings.plugins.manager.import": "導入 .ncpkg",
"settings.plugins.manager.preview": "導入的插件保持停用,請檢查後再啟用。",
@@ -1341,7 +1337,7 @@ Vaults 分頁維持扁平,不使用這塊背景。
'plugins.authentication.responseFailedWithMessage': '無法送出驗證回應:{message}。請重試或取消此要求。',
'plugins.authentication.continue': '繼續',
'plugins.hostActions.select': '分享所選主機的中繼資料',
'plugins.hostActions.selectionNote': '僅向此外掛分享所選主機的中繼資料:名稱、位址、使用者名稱、協定、群組和標籤。選擇群組僅包含本次選取的成員。後續編輯或連線請求遵循此外掛的權限設定。',
'plugins.hostActions.selectionNote': '向此外掛分享已儲存的名稱、位址、使用者名稱、協定、群組和標籤。選擇群組僅包含本次選取的成員。連線需要另外確認。',
'plugins.hostActions.create': '預覽主機草稿',
'plugins.hostActions.edit': '預覽主機修改',
'plugins.hostActions.connect': '確認連線',
-7
View File
@@ -49,7 +49,6 @@ import {
STORAGE_KEY_LOCAL_SHELL_SIDE_PANEL_AUTO_OPEN,
STORAGE_KEY_LOCAL_SHELL_SIDE_PANEL_AUTO_OPEN_TAB,
STORAGE_KEY_WINDOW_OPACITY,
STORAGE_KEY_WINDOW_BACKGROUND_BLUR,
STORAGE_KEY_APP_ICON_VARIANT,
STORAGE_KEY_HTTP_NETWORK_PROXY,
STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE,
@@ -100,7 +99,6 @@ interface UseSettingsIpcSyncParams {
setIsHotkeyRecordingState: Dispatch<SetStateAction<boolean>>;
setGlobalHotkeyEnabled: Dispatch<SetStateAction<boolean>>;
setWindowOpacity: (raw: unknown) => void;
setWindowBackgroundBlur: Dispatch<SetStateAction<boolean>>;
setAppIconVariant: Dispatch<SetStateAction<AppIconVariant>>;
setAutoUpdateEnabled: Dispatch<SetStateAction<boolean>>;
setAutoLaunchEnabled: Dispatch<SetStateAction<boolean>>;
@@ -150,7 +148,6 @@ export function useSettingsIpcSync({
setIsHotkeyRecordingState,
setGlobalHotkeyEnabled,
setWindowOpacity,
setWindowBackgroundBlur,
setAppIconVariant,
setAutoUpdateEnabled,
setAutoLaunchEnabled,
@@ -282,9 +279,6 @@ export function useSettingsIpcSync({
if (key === STORAGE_KEY_WINDOW_OPACITY) {
setWindowOpacity(value);
}
if (key === STORAGE_KEY_WINDOW_BACKGROUND_BLUR && typeof value === 'boolean') {
setWindowBackgroundBlur((prev) => (prev === value ? prev : value));
}
if (key === STORAGE_KEY_APP_ICON_VARIANT) {
const nextVariant = resolveAppIconVariant(value);
setAppIconVariant((prev) => (prev === nextVariant ? prev : nextVariant));
@@ -377,7 +371,6 @@ export function useSettingsIpcSync({
setFollowAppTerminalThemeState,
setGlobalHotkeyEnabled,
setWindowOpacity,
setWindowBackgroundBlur,
setAppIconVariant,
setHotkeyScheme,
setIsHotkeyRecordingState,
+4 -14
View File
@@ -53,7 +53,6 @@ import {
STORAGE_KEY_UI_LANGUAGE,
STORAGE_KEY_WORKSPACE_FOCUS_STYLE,
STORAGE_KEY_WINDOW_OPACITY,
STORAGE_KEY_WINDOW_BACKGROUND_BLUR,
STORAGE_KEY_APP_ICON_VARIANT,
} from '../../infrastructure/config/storageKeys';
import { resolveAppIconVariant, type AppIconVariant } from '../../domain/appIconVariant';
@@ -117,7 +116,6 @@ interface UseSettingsStorageSyncParams {
globalHotkeyEnabled: boolean;
autoUpdateEnabled: boolean;
windowOpacity: number;
windowBackgroundBlur: boolean;
appIconVariant: AppIconVariant;
setTheme: Dispatch<SetStateAction<'dark' | 'light' | 'system'>>;
setLightUiThemeId: Dispatch<SetStateAction<string>>;
@@ -168,7 +166,6 @@ interface UseSettingsStorageSyncParams {
setExplorerContextMenuEnabledState: (enabled: boolean) => void;
setGlobalHotkeyEnabled: Dispatch<SetStateAction<boolean>>;
setWindowOpacity: (raw: unknown) => void;
setWindowBackgroundBlur: Dispatch<SetStateAction<boolean>>;
setAppIconVariant: Dispatch<SetStateAction<AppIconVariant>>;
setAutoUpdateEnabled: Dispatch<SetStateAction<boolean>>;
setWorkspaceFocusStyleState: Dispatch<SetStateAction<'dim' | 'border'>>;
@@ -187,7 +184,7 @@ export function useSettingsStorageSync({
sftpUseCompressedUpload, sftpSkipUnchanged, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, localShellSidePanelAutoOpen, localShellSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd, startupLanding,
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled, explorerContextMenuEnabled,
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, windowBackgroundBlur, appIconVariant,
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, appIconVariant,
setTheme, setLightUiThemeId, setDarkUiThemeId, setAccentMode, applyIncomingCustomAccent,
setCustomCSS, setUiFontFamilyId, setHotkeyScheme, setUiLanguage,
setTerminalThemeId, setTerminalThemeDarkId, setTerminalThemeLightId,
@@ -196,7 +193,7 @@ export function useSettingsStorageSync({
setSftpUseCompressedUpload, setSftpSkipUnchanged, setSftpAutoOpenSidebar, setSftpFollowTerminalCwd, setSftpDefaultViewMode,
setShowRecentHostsState, setHostClickBehaviorState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState, setShowHostTreeSidebarState, setTerminalSidePanelAutoOpenState, setTerminalSidePanelAutoOpenTabState, setLocalShellSidePanelAutoOpenState, setLocalShellSidePanelAutoOpenTabState, setShellOnlyTabNumberShortcutsState, setShowTabNumberBadgesState, setDisableTerminalFontZoomState, setRestorePreviousSessionState, setRestoreTerminalCwdState, setStartupLandingState,
setEditorWordWrapState, setSessionLogsEnabled, setSessionLogsDir, setSessionLogsFormat, setSessionLogsTimestampsEnabled, setSshDebugLogsEnabled, setSshDeepLinkEnabledState, setJmsDeepLinkEnabledState, setExplorerContextMenuEnabledState,
setGlobalHotkeyEnabled, setWindowOpacity, setWindowBackgroundBlur, setAppIconVariant, setAutoUpdateEnabled, setWorkspaceFocusStyleState,
setGlobalHotkeyEnabled, setWindowOpacity, setAppIconVariant, setAutoUpdateEnabled, setWorkspaceFocusStyleState,
setSftpTransferConcurrencyState, setSshTransportIdleTtlMsState,
applyIncomingCustomKeyBindings, mergeIncomingTerminalSettings,
}: UseSettingsStorageSyncParams) {
@@ -211,7 +208,7 @@ export function useSettingsStorageSync({
sftpUseCompressedUpload, sftpSkipUnchanged, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, localShellSidePanelAutoOpen, localShellSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd, startupLanding,
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled, explorerContextMenuEnabled,
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, windowBackgroundBlur, appIconVariant,
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, appIconVariant,
});
settingsSnapshotRef.current = {
theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent, customAccentVersion,
@@ -221,7 +218,7 @@ export function useSettingsStorageSync({
sftpUseCompressedUpload, sftpSkipUnchanged, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, localShellSidePanelAutoOpen, localShellSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd, startupLanding,
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled, explorerContextMenuEnabled,
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, windowBackgroundBlur, appIconVariant,
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, appIconVariant,
};
// Listen for storage changes from other windows (cross-window sync)
@@ -526,12 +523,6 @@ export function useSettingsStorageSync({
if (e.key === STORAGE_KEY_WINDOW_OPACITY && e.newValue !== null) {
setWindowOpacity(e.newValue);
}
if (e.key === STORAGE_KEY_WINDOW_BACKGROUND_BLUR && e.newValue !== null) {
const newValue = e.newValue === 'true';
if (newValue !== s.windowBackgroundBlur) {
setWindowBackgroundBlur(newValue);
}
}
if (e.key === STORAGE_KEY_APP_ICON_VARIANT && e.newValue !== null) {
const newValue = resolveAppIconVariant(e.newValue);
if (newValue !== s.appIconVariant) {
@@ -575,7 +566,6 @@ export function useSettingsStorageSync({
setGlobalHotkeyEnabled,
setHostClickBehaviorState,
setWindowOpacity,
setWindowBackgroundBlur,
setAppIconVariant,
setHotkeyScheme,
setLightUiThemeId,
@@ -7,7 +7,6 @@ import {
STORAGE_KEY_GLOBAL_HOTKEY_ENABLED,
STORAGE_KEY_TOGGLE_WINDOW_HOTKEY,
STORAGE_KEY_WINDOW_OPACITY,
STORAGE_KEY_WINDOW_BACKGROUND_BLUR,
STORAGE_KEY_APP_ICON_VARIANT,
STORAGE_KEY_HTTP_NETWORK_PROXY,
} from '../../infrastructure/config/storageKeys';
@@ -18,8 +17,6 @@ import {
} from '../../domain/httpNetworkProxy';
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
import { isMacPlatform } from '../../lib/utils';
import { isWindowBackgroundBlurActive } from '../../domain/windowBackgroundBlur';
import {
parseWindowOpacityRecord,
serializeWindowOpacityRecord,
@@ -50,7 +47,6 @@ interface UseSystemSettingsEffectsParams {
autoLaunchEnabled: boolean;
windowOpacityRecord: WindowOpacityRecord;
windowOpacityMutationSourceRef: MutableRefObject<WindowOpacityMutationSource>;
windowBackgroundBlur: boolean;
appIconVariant: AppIconVariant;
autoUpdateEnabled: boolean;
httpNetworkProxy: HttpNetworkProxySettings;
@@ -72,7 +68,6 @@ export function useSystemSettingsEffects({
autoLaunchEnabled,
windowOpacityRecord,
windowOpacityMutationSourceRef,
windowBackgroundBlur,
appIconVariant,
autoUpdateEnabled,
httpNetworkProxy,
@@ -305,49 +300,6 @@ export function useSystemSettingsEffects({
persistMountedRef,
]);
// Window chrome also applies in detached sessions, independently of the
// OS-level effects disabled there (tray, global shortcuts, autolaunch, ...).
// Only the windows that own settings persistence drive the shared native
// state; detached/editor windows consume it (see applySharedWindowSetting).
useEffect(() => {
if (!enabled) return;
localStorageAdapter.writeBoolean(STORAGE_KEY_WINDOW_BACKGROUND_BLUR, windowBackgroundBlur);
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_WINDOW_BACKGROUND_BLUR, windowBackgroundBlur);
}, [
enabled,
windowBackgroundBlur,
notifySettingsChanged,
persistMountedRef,
]);
const windowBackgroundBlurActive = isMacPlatform() && isWindowBackgroundBlurActive(
windowBackgroundBlur,
windowOpacityRecord.opacity,
);
// Applies in every app-content window, including detached sessions: the
// transparent backing only looks right when the renderer class matches the
// native vibrancy the main process put on that window.
useEffect(() => {
if (typeof document === 'undefined') return;
document.documentElement.classList.toggle('window-background-blur', windowBackgroundBlurActive);
document.documentElement.style.setProperty('--window-content-opacity', String(windowOpacityRecord.opacity));
return () => {
document.documentElement.classList.remove('window-background-blur');
document.documentElement.style.removeProperty('--window-content-opacity');
};
}, [windowBackgroundBlurActive, windowOpacityRecord.opacity]);
// setWindowBackgroundBlur is a main-process handler that retargets every
// app-content window, so a single owner must drive it.
useEffect(() => {
if (!enabled) return;
netcattyBridge.get()?.setWindowBackgroundBlur?.(windowBackgroundBlur).catch((err) => {
console.warn('[WindowBackgroundBlur] Failed to apply macOS vibrancy:', err);
});
}, [enabled, windowBackgroundBlur]);
// Persist and sync app icon variant
useEffect(() => {
if (!enabled) return;
@@ -1,139 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import React from 'react';
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
import { JSDOM } from 'jsdom';
import { STORAGE_KEY_WINDOW_BACKGROUND_BLUR } from '../../infrastructure/config/storageKeys';
import { DEFAULT_HTTP_NETWORK_PROXY } from '../../domain/httpNetworkProxy';
import { useSystemSettingsEffects } from './systemSettingsEffects';
type Harness = {
dom: JSDOM;
params: Parameters<typeof useSystemSettingsEffects>[0];
bridgeRequests: boolean[];
blurNotifications: unknown[];
restore: () => void;
};
function setUp(enabled: boolean, windowBackgroundBlur: boolean): Harness {
const dom = new JSDOM('<html><body><div id="root"></div></body></html>', { url: 'http://localhost' });
Object.defineProperty(dom.window.navigator, 'platform', { value: 'MacIntel' });
const previousGlobals = new Map<string, PropertyDescriptor | undefined>();
const replacements = {
window: dom.window,
document: dom.window.document,
navigator: dom.window.navigator,
localStorage: dom.window.localStorage,
IS_REACT_ACT_ENVIRONMENT: true,
};
for (const [key, value] of Object.entries(replacements)) {
previousGlobals.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
Object.defineProperty(globalThis, key, { value, configurable: true });
}
const bridgeRequests: boolean[] = [];
const blurNotifications: unknown[] = [];
Object.assign(dom.window, {
netcatty: {
setWindowBackgroundBlur: async (value: boolean) => { bridgeRequests.push(value); },
},
});
const noop = () => {};
const params: Parameters<typeof useSystemSettingsEffects>[0] = {
enabled,
toggleWindowHotkey: '',
globalHotkeyEnabled: false,
closeToTray: false,
showTrayIcon: false,
autoLaunchEnabled: false,
windowOpacityRecord: { opacity: 0.7, preferredOpacity: 0.7, version: 1 },
windowOpacityMutationSourceRef: { current: 'local' },
windowBackgroundBlur,
appIconVariant: 'original',
autoUpdateEnabled: false,
httpNetworkProxy: DEFAULT_HTTP_NETWORK_PROXY,
persistMountedRef: { current: true },
setHotkeyRegistrationError: noop,
setAutoUpdateEnabled: noop,
setAppIconVariant: noop,
setAutoLaunchEnabled: noop,
setAutoLaunchSupported: noop,
notifySettingsChanged: (key, value) => {
if (key === STORAGE_KEY_WINDOW_BACKGROUND_BLUR) blurNotifications.push(value);
},
};
return {
dom,
params,
bridgeRequests,
blurNotifications,
restore: () => {
for (const [key, descriptor] of previousGlobals) {
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
else Reflect.deleteProperty(globalThis, key);
}
dom.window.close();
},
};
}
const Probe = (props: Parameters<typeof useSystemSettingsEffects>[0]) => {
useSystemSettingsEffects(props);
return null;
};
test('opacity changes and remounts preserve the chosen blur preference', async () => {
const harness = setUp(true, true);
const { dom, params, bridgeRequests, blurNotifications, restore } = harness;
let renderer: ReactTestRenderer | undefined;
try {
await act(async () => { renderer = create(React.createElement(Probe, params)); });
assert.equal(document.documentElement.classList.contains('window-background-blur'), true);
const opaqueParams = { ...params, windowOpacityRecord: { ...params.windowOpacityRecord, opacity: 1, version: 2 } };
await act(async () => { renderer!.update(React.createElement(Probe, opaqueParams)); });
assert.equal(document.documentElement.classList.contains('window-background-blur'), false);
assert.equal(dom.window.localStorage.getItem(STORAGE_KEY_WINDOW_BACKGROUND_BLUR), 'true');
assert.deepEqual(bridgeRequests, [true]);
assert.deepEqual(blurNotifications, [true]);
await act(async () => { renderer!.unmount(); });
await act(async () => { renderer = create(React.createElement(Probe, opaqueParams)); });
assert.equal(dom.window.localStorage.getItem(STORAGE_KEY_WINDOW_BACKGROUND_BLUR), 'true');
await act(async () => { renderer!.update(React.createElement(Probe, params)); });
assert.equal(document.documentElement.classList.contains('window-background-blur'), true);
await act(async () => { renderer!.update(React.createElement(Probe, { ...params, windowBackgroundBlur: false })); });
assert.equal(dom.window.localStorage.getItem(STORAGE_KEY_WINDOW_BACKGROUND_BLUR), 'false');
assert.equal(document.documentElement.classList.contains('window-background-blur'), false);
assert.deepEqual(bridgeRequests, [true, true, false]);
} finally {
await act(async () => { renderer?.unmount(); });
restore();
}
});
test('detached session windows only apply the blur class, never own the setting', async () => {
// Peer session windows run with enableSystemEffects off. They must still
// paint the transparent backing, but persistence, cross-window broadcast and
// the (all-windows) native vibrancy IPC stay with the settings owners.
const harness = setUp(false, true);
const { dom, params, bridgeRequests, blurNotifications, restore } = harness;
let renderer: ReactTestRenderer | undefined;
try {
await act(async () => { renderer = create(React.createElement(Probe, params)); });
assert.equal(document.documentElement.classList.contains('window-background-blur'), true);
assert.equal(document.documentElement.style.getPropertyValue('--window-content-opacity'), '0.7');
assert.equal(dom.window.localStorage.getItem(STORAGE_KEY_WINDOW_BACKGROUND_BLUR), null);
assert.deepEqual(bridgeRequests, []);
assert.deepEqual(blurNotifications, []);
await act(async () => {
renderer!.update(React.createElement(Probe, { ...params, windowBackgroundBlur: false }));
});
assert.equal(document.documentElement.classList.contains('window-background-blur'), false);
assert.equal(dom.window.localStorage.getItem(STORAGE_KEY_WINDOW_BACKGROUND_BLUR), null);
assert.deepEqual(bridgeRequests, []);
assert.deepEqual(blurNotifications, []);
} finally {
await act(async () => { renderer?.unmount(); });
restore();
}
});
@@ -1,59 +0,0 @@
import { useEffect, useMemo, useState, useSyncExternalStore } from 'react';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
// All visible rows share one IPC subscription. No catalog fetch or polling here.
const generations = new Map<string, number>();
const listeners = new Set<() => void>();
let unsubscribe: (() => void) | undefined;
function subscribe(listener: () => void) {
listeners.add(listener);
if (listeners.size === 1) unsubscribe = netcattyBridge.get()?.onPluginCatalogChanged?.(({ sourceId }) => {
generations.set(sourceId, (generations.get(sourceId) ?? 0) + 1);
for (const notify of listeners) notify();
});
return () => {
listeners.delete(listener);
if (!listeners.size) { unsubscribe?.(); unsubscribe = undefined; generations.clear(); }
};
}
export function usePluginAuthorIdentity({ entry, plugin, sourceId = 'official' }: {
entry?: NetcattyPluginCatalogEntry; plugin?: NetcattyInstalledPlugin; sourceId?: string;
}) {
const [element, setElement] = useState<HTMLElement | null>(null);
const [loaded, setLoaded] = useState<{ key: string; value: NetcattyPluginAuthorPresentation } | null>(null);
const pluginId = plugin?.id;
const entryId = entry?.id;
const selectionToken = entry?.selectionToken;
const request = useMemo<NetcattyPluginAuthorRequest | null>(() => pluginId
? { kind: 'installed', pluginId }
: entryId && selectionToken ? { kind: 'catalog', pluginId: entryId, selectionToken, sourceId } : null,
[pluginId, entryId, selectionToken, sourceId]);
const identitySource = plugin?.source?.id ?? sourceId;
const generation = useSyncExternalStore(subscribe, () => generations.get(identitySource) ?? 0, () => 0);
const key = JSON.stringify([request, plugin?.activeVersion, plugin?.archiveSha256, plugin?.source?.id, plugin?.runtime?.quarantinedAt, generation]);
const installed = Boolean(plugin);
const version = plugin?.activeVersion;
const archiveSha256 = plugin?.archiveSha256 ?? null;
const installedSourceId = plugin?.source?.id ?? 'local';
useEffect(() => {
if (!element || !request) return;
let active = true;
let started = false;
const load = () => {
if (started) return;
started = true;
void netcattyBridge.get()?.getPluginAuthorPresentation?.(request).then((value) => {
if (!active || !value) return;
if (installed && (value.version !== version || value.archiveSha256 !== archiveSha256 || value.sourceId !== installedSourceId)) return;
setLoaded({ key, value });
}).catch(() => { /* The visible author placeholder remains usable offline. */ });
};
const observer = typeof IntersectionObserver === 'undefined' ? null : new IntersectionObserver((entries) => {
if (entries.some((entry) => entry.isIntersecting)) { load(); observer?.disconnect(); }
}, { rootMargin: '64px' });
if (observer) observer.observe(element); else load();
return () => { active = false; observer?.disconnect(); };
}, [element, request, key, installed, version, archiveSha256, installedSourceId]);
return { ref: setElement, identity: loaded?.key === key ? loaded.value : null };
}
+4 -18
View File
@@ -74,7 +74,6 @@ import {
STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE,
STORAGE_KEY_GLOBAL_HOTKEY_ENABLED,
STORAGE_KEY_WINDOW_OPACITY,
STORAGE_KEY_WINDOW_BACKGROUND_BLUR,
STORAGE_KEY_APP_ICON_VARIANT,
STORAGE_KEY_AUTO_UPDATE_ENABLED,
STORAGE_KEY_WORKSPACE_FOCUS_STYLE,
@@ -646,9 +645,6 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
windowOpacityMutationSourceRef.current = 'local';
setWindowOpacityRecord(toggleWindowOpacityRecord);
}, []);
const [windowBackgroundBlur, setWindowBackgroundBlur] = useState<boolean>(() => {
return readStoredString(STORAGE_KEY_WINDOW_BACKGROUND_BLUR) === 'true';
});
const [appIconVariant, setAppIconVariantState] = useState<AppIconVariant>(() => {
const stored = readStoredString(STORAGE_KEY_APP_ICON_VARIANT);
return resolveAppIconVariant(stored ?? DEFAULT_APP_ICON_VARIANT);
@@ -1280,7 +1276,6 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
setIsHotkeyRecordingState,
setGlobalHotkeyEnabled,
setWindowOpacity: applyIncomingWindowOpacity,
setWindowBackgroundBlur,
setAppIconVariant,
setAutoUpdateEnabled,
setAutoLaunchEnabled,
@@ -1303,18 +1298,12 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
setSshTransportIdleTtlMsState,
});
// Detached sessions share window chrome (opacity, background blur) and the
// shortcut configuration needed to operate it. Keep general settings
// listeners and OS effects disabled there.
// Detached sessions share opacity and the shortcut configuration needed to
// operate it. Keep general settings listeners and OS effects disabled there.
useEffect(() => {
if (enableSettingsSync) return;
const applySharedWindowSetting = (key: string, value: unknown) => {
if (key === STORAGE_KEY_WINDOW_OPACITY) applyIncomingWindowOpacity(value);
if (key === STORAGE_KEY_WINDOW_BACKGROUND_BLUR) {
// Storage events deliver the raw string; IPC delivers a boolean.
const incoming = typeof value === 'boolean' ? value : value === 'true';
setWindowBackgroundBlur(incoming);
}
if (key === STORAGE_KEY_HOTKEY_SCHEME && (value === 'disabled' || value === 'mac' || value === 'pc')) {
setHotkeyScheme(value);
}
@@ -1388,7 +1377,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
sftpUseCompressedUpload, sftpSkipUnchanged, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, localShellSidePanelAutoOpen, localShellSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd, startupLanding,
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled, explorerContextMenuEnabled,
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, windowBackgroundBlur, appIconVariant,
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, appIconVariant,
setTheme, setLightUiThemeId, setDarkUiThemeId, setAccentMode,
applyIncomingCustomAccent,
setCustomCSS, setUiFontFamilyId, setHotkeyScheme, setUiLanguage,
@@ -1398,7 +1387,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
setSftpUseCompressedUpload, setSftpSkipUnchanged, setSftpAutoOpenSidebar, setSftpFollowTerminalCwd, setSftpDefaultViewMode,
setShowRecentHostsState, setHostClickBehaviorState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState, setShowHostTreeSidebarState, setTerminalSidePanelAutoOpenState, setTerminalSidePanelAutoOpenTabState, setLocalShellSidePanelAutoOpenState, setLocalShellSidePanelAutoOpenTabState, setShellOnlyTabNumberShortcutsState, setShowTabNumberBadgesState, setDisableTerminalFontZoomState, setRestorePreviousSessionState, setRestoreTerminalCwdState, setStartupLandingState,
setEditorWordWrapState, setSessionLogsEnabled, setSessionLogsDir, setSessionLogsFormat, setSessionLogsTimestampsEnabled, setSshDebugLogsEnabled, setSshDeepLinkEnabledState: applyIncomingSshDeepLinkEnabled, setJmsDeepLinkEnabledState: applyIncomingJmsDeepLinkEnabled, setExplorerContextMenuEnabledState: applyIncomingExplorerContextMenuEnabled,
setGlobalHotkeyEnabled, setWindowOpacity: applyIncomingWindowOpacity, setWindowBackgroundBlur, setAppIconVariant, setAutoUpdateEnabled, setWorkspaceFocusStyleState,
setGlobalHotkeyEnabled, setWindowOpacity: applyIncomingWindowOpacity, setAppIconVariant, setAutoUpdateEnabled, setWorkspaceFocusStyleState,
setSftpTransferConcurrencyState, setSshTransportIdleTtlMsState,
applyIncomingCustomKeyBindings, mergeIncomingTerminalSettings,
});
@@ -1907,7 +1896,6 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
autoLaunchEnabled,
windowOpacityRecord,
windowOpacityMutationSourceRef,
windowBackgroundBlur,
appIconVariant,
autoUpdateEnabled,
httpNetworkProxy,
@@ -2288,8 +2276,6 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
windowOpacity,
setWindowOpacity,
toggleWindowOpacity,
windowBackgroundBlur,
setWindowBackgroundBlur,
appIconVariant,
setAppIconVariant,
rehydrateAllFromStorage,
-2
View File
@@ -576,8 +576,6 @@ const SettingsPageContent: React.FC<{ settings: SettingsState; appLock?: AppLock
setShowHostTreeSidebar={settings.setShowHostTreeSidebar}
windowOpacity={settings.windowOpacity}
setWindowOpacity={settings.setWindowOpacity}
windowBackgroundBlur={settings.windowBackgroundBlur}
setWindowBackgroundBlur={settings.setWindowBackgroundBlur}
appIconVariant={settings.appIconVariant}
setAppIconVariant={settings.setAppIconVariant}
/>
+3 -6
View File
@@ -64,8 +64,6 @@ interface TextEditorModalProps {
onClose: () => void;
fileName: string;
initialContent: string;
/** Full path for language detection; the title still uses fileName. */
filePath?: string;
onSave: (content: string) => Promise<void>;
editorWordWrap: boolean;
onToggleWordWrap: () => void;
@@ -81,7 +79,6 @@ const TextEditorModal: React.FC<TextEditorModalProps> = ({
open,
onClose,
fileName,
filePath,
initialContent,
onSave,
editorWordWrap,
@@ -97,7 +94,7 @@ const TextEditorModal: React.FC<TextEditorModalProps> = ({
const [baselineContent, setBaselineContent] = useState(initialContent);
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const [languageId, setLanguageId] = useState(() => getLanguageId(filePath ?? fileName));
const [languageId, setLanguageId] = useState(() => getLanguageId(fileName));
const contentRef = useRef(initialContent);
const baselineContentRef = useRef(initialContent);
const savingRef = useRef(false);
@@ -152,13 +149,13 @@ const TextEditorModal: React.FC<TextEditorModalProps> = ({
setBaselineContent(initialContent);
setSaveError(null);
setSaving(false);
setLanguageId(getLanguageId(filePath ?? fileName));
setLanguageId(getLanguageId(fileName));
contentRef.current = initialContent;
baselineContentRef.current = initialContent;
savingRef.current = false;
closePromptRef.current = null;
viewStateRef.current = null;
}, [initialContent, fileName, filePath]);
}, [initialContent, fileName]);
const saveContent = useCallback(async (contentToSave = contentRef.current): Promise<boolean> => {
return saveCoordinatorRef.current?.save(contentToSave) ?? false;
@@ -8,7 +8,6 @@ import {
getTextEditorContentStats,
isTextEditorCommandWEnabled,
isTextEditorReadOnly,
languageIdToMonaco,
TextEditorPromoteButton,
} from "./TextEditorPane.tsx";
import { TooltipProvider } from "../ui/tooltip.tsx";
@@ -17,11 +16,6 @@ import { DEFAULT_KEY_BINDINGS } from "../../domain/models/keyBindings.ts";
const wrap = (child: React.ReactElement) =>
React.createElement(TooltipProvider, null, child);
test("maps manual Nginx selection to the registered language", () => {
assert.equal(languageIdToMonaco('nginx'), 'nginx');
assert.equal(languageIdToMonaco('ini'), 'ini');
});
test("disables promoting a modal editor to a tab while a save is running", () => {
assert.equal(canPromoteTextEditor({ saving: true }), false);
assert.equal(canPromoteTextEditor({ saving: false }), true);
+1 -4
View File
@@ -30,7 +30,6 @@ import { useClipboardBackend } from '../../application/state/useClipboardBackend
import { isPrimaryModifierWBinding } from '../../application/state/windowCommandClose';
import { HotkeyScheme, KeyBinding, matchesKeyBinding } from '../../domain/models';
import { pasteForMonacoEditorCommand } from '../../infrastructure/monaco/monacoClipboardPaste';
import { registerNginxLanguage } from '../../infrastructure/monaco/nginxLanguage';
import { useNetcattyMonacoTheme } from '../../infrastructure/monaco/useNetcattyMonacoTheme';
import { getLanguageName, getSupportedLanguages } from '../../lib/sftpFileUtils';
import { Button } from '../ui/button';
@@ -38,7 +37,7 @@ import { Combobox } from '../ui/combobox';
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
// Map our language IDs to Monaco language IDs
export const languageIdToMonaco = (langId: string): string => {
const languageIdToMonaco = (langId: string): string => {
const mapping: Record<string, string> = {
'javascript': 'javascript',
'typescript': 'typescript',
@@ -74,7 +73,6 @@ export const languageIdToMonaco = (langId: string): string => {
'yaml': 'yaml',
'toml': 'ini',
'ini': 'ini',
'nginx': 'nginx',
'sql': 'sql',
'graphql': 'graphql',
'markdown': 'markdown',
@@ -514,7 +512,6 @@ const TextEditorPaneInner: React.FC<TextEditorPaneProps> = ({
language={monacoLanguage}
value={content}
onChange={handleEditorChange}
beforeMount={registerNginxLanguage}
onMount={handleEditorMount}
theme={customThemeName}
loading={
-26
View File
@@ -1,26 +0,0 @@
import React, { useState } from 'react';
import { UserRound } from 'lucide-react';
import { usePluginAuthorIdentity } from '../../application/state/usePluginAuthorIdentity';
import { useI18n } from '../../application/i18n/I18nProvider';
import { Badge } from '../ui/badge';
export function PluginAuthorRow({ entry, plugin, sourceId }: {
entry?: NetcattyPluginCatalogEntry; plugin?: NetcattyInstalledPlugin; sourceId?: string;
}) {
const { t } = useI18n();
const { ref, identity } = usePluginAuthorIdentity({ entry, plugin, sourceId });
const [failed, setFailed] = useState<string | null>(null);
const author = identity?.author ?? entry?.author ?? plugin?.manifest?.publisher ?? '';
const imageKey = JSON.stringify([entry?.id, entry?.selectionToken, plugin?.id, plugin?.activeVersion, plugin?.archiveSha256, sourceId, identity?.avatar]);
const avatar = identity?.avatar;
const showImage = avatar && avatar.length <= 700_000 && /^data:image\/png;base64,[A-Za-z0-9+/]+={0,2}$/.test(avatar) && failed !== imageKey;
return <div ref={ref} className="flex min-w-0 items-center gap-2 text-xs text-muted-foreground" data-plugin-author-row>
<span className="inline-flex h-5 w-5 shrink-0 items-center justify-center overflow-hidden rounded-full bg-muted/60" aria-hidden="true">
{showImage ? <img src={avatar} alt="" width={20} height={20} className="h-full w-full object-cover" onError={() => setFailed(imageKey)} />
: <UserRound size={13} data-plugin-author-placeholder />}
</span>
<span className="min-w-0 truncate" title={author}>{author}</span>
{identity?.officialMaintained === true && <Badge className="shrink-0 whitespace-nowrap border-0 bg-amber-500/15 px-2 py-0.5 text-[10px] font-medium text-amber-800 hover:bg-amber-500/15 dark:bg-amber-400/15 dark:text-amber-300 dark:hover:bg-amber-400/15"
title={t('settings.plugins.author.officialMeaning')} data-plugin-official-maintainer>{t('settings.plugins.author.official')}</Badge>}
</div>;
}
@@ -1,125 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import React from 'react';
import { JSDOM } from 'jsdom';
import { EventEmitter } from 'node:events';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { createRequire } from 'node:module';
import { usePluginAuthorIdentity } from '../../../application/state/usePluginAuthorIdentity';
import { checkPluginUpdates } from '../../../infrastructure/services/pluginUpdateCheck';
const require = createRequire(import.meta.url);
const { PluginDatabase } = require('../../../electron/plugins/database.cjs');
const { MarketSourcesService } = require('../../../electron/plugins/marketSourcesService.cjs');
const { OFFICIAL_SOURCE } = require('../../../electron/plugins/sourceIdentity.cjs');
const { registerPluginBridge, CHANNELS } = require('../../../electron/plugins/pluginBridge.cjs');
const { createPreloadApi } = require('../../../electron/preload/api.cjs');
const { catalog, delivery, makeFiles, read, digest } = require('../../../electron/plugins/fixtures/staticDistributionFixture.cjs');
test('mounted installed authors follow actual catalog/update/source entrypoints without changing package identity', async () => {
const rootPath = mkdtempSync(join(tmpdir(), 'netcatty-author-refresh-'));
const database = new PluginDatabase(join(rootPath, 'plugins.sqlite'));
const dom = new JSDOM('<!doctype html><div id="root"></div>', { pretendToBeVisual: true });
const previous = new Map<string, PropertyDescriptor | undefined>();
for (const [key, value] of Object.entries({ window: dom.window, document: dom.window.document,
navigator: dom.window.navigator, HTMLElement: dom.window.HTMLElement, Element: dom.window.Element,
Node: dom.window.Node, IntersectionObserver: undefined, IS_REACT_ACT_ENVIRONMENT: true })) {
previous.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
Object.defineProperty(globalThis, key, { configurable: true, writable: true, value });
}
const entry = structuredClone(catalog[0]);
entry.artifact = { sha256: 'a'.repeat(64), url: `${OFFICIAL_SOURCE.origin}/assets/${'a'.repeat(64)}.ncpkg` };
const bytes = read(delivery.plugins[0].iconLight.path.slice(1));
const avatar = { path: `/assets/${digest(bytes)}.png`, sha256: digest(bytes), size: bytes.length };
const filesFor = (entries = [entry], image = true) => {
const files = makeFiles(entries, (index: { plugins: Array<{ id: string; versions: Array<{ distribution: string }> }> }, sidecar: { schemaVersion: number; plugins: Array<{ id: string; avatar: typeof avatar | null; artifact: { path: string; sha256: string; size: number } }> }) => {
sidecar.schemaVersion = 2;
for (const item of sidecar.plugins) {
item.avatar = image ? avatar : null;
item.artifact = { path: `/assets/${entry.artifact.sha256}.ncpkg`, sha256: entry.artifact.sha256, size: 1 };
index.plugins.find((plugin) => plugin.id === item.id).versions[0].distribution = 'release';
}
});
files.set(avatar.path, bytes); return files;
};
let files = filesFor();
database.installVersion({ pluginId: entry.id, version: entry.version, runtimeKind: "browser", apiVersion: "0.1.0-internal.1", manifest: entry.manifest, archiveSha256: entry.artifact.sha256,
packageRelativePath: 'packages/synthetic', source: OFFICIAL_SOURCE }, { enable: true });
const manager = { initialize: async () => {}, list: async () => database.listPlugins(),
changeMarketSource: async (change: () => unknown) => change() };
let avatarReads = 0;
const service = new MarketSourcesService({ database, manager,
fetch: async (url: string) => { assert.equal(new URL(url).origin, OFFICIAL_SOURCE.origin); assert.ok(!new URL(url).pathname.endsWith('.ncpkg'), 'rejected selection never downloads a package'); return new Response(files.get(new URL(url).pathname)); },
createSourceFetch: (origin: string) => async (url: string) => { assert.equal(new URL(url).origin, origin); return new Response(files.get(new URL(url).pathname)); },
resolveAvatar: async () => { avatarReads++; return 'data:image/png;base64,eA=='; } });
const ipcMain = { handlers: new Map(), handle(channel: string, handler: unknown) { this.handlers.set(channel, handler); }, on() {} };
const events = new EventEmitter();
registerPluginBridge(ipcMain, { manager, catalogService: service, isTrustedSender: () => true,
broadcast: (channel: string, payload: unknown) => events.emit(channel, {}, payload) });
const api = createPreloadApi({ ipcRenderer: { on: events.on.bind(events), removeListener: events.removeListener.bind(events),
invoke: (channel: string, payload: unknown) => ipcMain.handlers.get(channel)({}, payload) } });
let authorCalls = 0;
const original = api.getPluginAuthorPresentation;
api.getPluginAuthorPresentation = (request: NetcattyPluginAuthorRequest) => { authorCalls++; return original(request); };
Object.assign(window, { netcatty: api });
let plugin = database.getActivePlugin(entry.id) as NetcattyInstalledPlugin;
function Row() {
const { ref, identity } = usePluginAuthorIdentity({ plugin });
return <div ref={ref} data-author={identity?.author} data-avatar={identity?.avatar} data-official={identity?.officialMaintained ?? false} />;
}
const { act } = await import('react');
const { createRoot } = await import('react-dom/client');
const root = createRoot(document.getElementById('root')!);
const row = () => document.querySelector<HTMLElement>('[data-official]')!;
try {
await act(async () => root.render(<><Row /><Row /></>));
assert.equal(row().dataset.author, entry.manifest.publisher); assert.equal(row().dataset.official, 'false');
assert.equal(avatarReads, 0, 'opening installed rows never fetches a catalog/avatar');
await act(async () => { await checkPluginUpdates(api); });
assert.equal(row().dataset.author, entry.author); assert.equal(row().dataset.official, 'true');
assert.match(row().dataset.avatar!, /^data:image\/png/);
assert.equal(events.listenerCount(CHANNELS.catalogChanged), 1, 'rows share one preload subscription');
const count = authorCalls;
await act(async () => { await api.listPluginCatalog('official'); root.render(<><Row /><Row /></>); });
assert.equal(authorCalls, count, 'equivalent catalog and installation retain request dedupe');
files = filesFor([{ ...entry, author: 'Updated project name' }], false);
await act(async () => { await api.listPluginCatalog('official'); });
assert.equal(row().dataset.author, 'Updated project name'); assert.equal(row().dataset.avatar, undefined);
const beforeWithdrawal = (await api.listPluginCatalog('official')).entries[0];
files = filesFor([]);
await act(async () => {
await assert.rejects(api.installCatalogPlugin(entry.id, beforeWithdrawal.selectionToken, 'official'), /not in the curated catalog|changed/);
});
assert.equal(row().dataset.author, entry.manifest.publisher); assert.equal(row().dataset.official, 'false');
assert.equal(row().dataset.avatar, undefined, 'rejected install refresh removes withdrawn enrichment without another list or remount');
const source = await api.addPluginSource({ name: 'Community', address: 'https://community.example.com' });
const community = structuredClone(entry);
community.id = community.manifest.id = 'com.community.identity';
community.artifact.url = `${source.origin}/assets/${community.artifact.sha256}.ncpkg`;
files = filesFor([community]);
database.installVersion({ pluginId: community.id, version: community.version, manifest: community.manifest,
archiveSha256: community.artifact.sha256, packageRelativePath: 'packages/community', source }, { enable: true });
await act(async () => { await api.listPluginCatalog(source.id); });
assert.equal(authorCalls, count + 4, 'unrelated source changes do not reload official rows');
plugin = database.getActivePlugin(community.id);
await act(async () => root.render(<><Row /><Row /></>));
assert.equal(row().dataset.author, community.author); assert.match(row().dataset.avatar!, /^data:image\/png/);
assert.equal(row().dataset.official, 'false', 'copied catalog never gains official maintenance authority');
await act(async () => { await api.updatePluginSource({ id: source.id, name: 'Renamed', enabled: false }); });
assert.equal(row().dataset.author, community.manifest.publisher); assert.equal(row().dataset.avatar, undefined);
await act(async () => { await api.updatePluginSource({ id: source.id, name: 'Renamed', enabled: true }); });
assert.equal(row().dataset.author, community.author);
await act(async () => { await api.removePluginSource(source.id, false); });
assert.equal(row().dataset.author, community.manifest.publisher); assert.equal(row().dataset.avatar, undefined);
await act(async () => root.unmount());
assert.equal(events.listenerCount(CHANNELS.catalogChanged), 0, 'last row releases the shared IPC listener');
} finally {
database.close(); dom.window.close(); rmSync(rootPath, { recursive: true, force: true });
for (const [key, descriptor] of previous) {
if (descriptor) Object.defineProperty(globalThis, key, descriptor); else Reflect.deleteProperty(globalThis, key);
}
}
});
@@ -1,116 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { JSDOM } from 'jsdom';
import { PluginAuthorRow } from '../../plugins/PluginAuthorRow';
import { I18nProvider } from '../../../application/i18n/I18nProvider';
const entry = { id: 'com.netcatty.starter', name: 'Starter', author: 'Netcatty', version: '0.1.2',
repository: 'binaricat/Netcatty', commit: 'a'.repeat(40), selectionToken: 'b'.repeat(64),
manifest: { publisher: 'netcatty' } } as NetcattyPluginCatalogEntry;
const identity = { author: 'Netcatty', avatar: 'data:image/png;base64,eA==', officialMaintained: true,
sourceId: 'official', version: entry.version, archiveSha256: null };
test('untrusted entry strings and absent imagery produce a compact placeholder without an official badge', () => {
const claimed = { ...entry, author: '<img src="https://avatars.githubusercontent.com/u/1">',
officialMaintained: true, avatar: 'https://avatars.githubusercontent.com/u/1' };
const html = renderToStaticMarkup(<I18nProvider locale="en"><PluginAuthorRow entry={claimed} /></I18nProvider>);
assert.match(html, /data-plugin-author-placeholder/);
assert.doesNotMatch(html, /<img|data-plugin-official-maintainer/);
assert.match(html, /&lt;img/);
assert.match(html, /items-center/);
});
test('mounted author rows load only when visible, retain fallbacks, cancel stale results and show a borderless themed maintainer pill', async () => {
const dom = new JSDOM('<!doctype html><html><body><div id="root"></div></body></html>', { pretendToBeVisual: true });
const previous = new Map<string, PropertyDescriptor | undefined>();
const install = (key: string, value: unknown) => {
previous.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
Object.defineProperty(globalThis, key, { configurable: true, writable: true, value });
};
const observers: Array<{ target: Element | null; callback: IntersectionObserverCallback }> = [];
class VisibilityObserver {
record: typeof observers[number];
constructor(callback: IntersectionObserverCallback) { this.record = { callback, target: null }; observers.push(this.record); }
observe(target: Element) { this.record.target = target; }
disconnect() { this.record.target = null; }
}
for (const key of ['window', 'document', 'navigator', 'HTMLElement', 'Element', 'Node', 'Event']) {
install(key, key === 'window' ? dom.window : dom.window[key as keyof typeof dom.window]);
}
install('IntersectionObserver', VisibilityObserver);
install('IS_REACT_ACT_ENVIRONMENT', true);
const { act } = await import('react');
const { createRoot } = await import('react-dom/client');
const root = createRoot(document.getElementById('root')!);
const calls: NetcattyPluginAuthorRequest[] = [];
const pending: Array<(value: NetcattyPluginAuthorPresentation | null) => void> = [];
Object.assign(window, { netcatty: { getPluginAuthorPresentation: (request: NetcattyPluginAuthorRequest) => {
calls.push(request); return new Promise((resolve) => pending.push(resolve));
} } });
const render = async (value = entry, sourceId = 'official') => {
await act(async () => root.render(<I18nProvider locale="en"><PluginAuthorRow entry={value} sourceId={sourceId} /></I18nProvider>));
};
const visible = async () => {
await act(async () => {
for (const observer of [...observers]) if (observer.target) observer.callback([{ target: observer.target, isIntersecting: true } as IntersectionObserverEntry], {} as IntersectionObserver);
});
};
try {
await render(); assert.equal(calls.length, 0); assert.ok(document.querySelector('[data-plugin-author-placeholder]'));
await visible(); assert.deepEqual(calls[0], { kind: 'catalog', pluginId: entry.id, selectionToken: entry.selectionToken, sourceId: 'official' });
assert.ok(document.querySelector('[data-plugin-author-placeholder]'), 'loading uses the default placeholder');
await act(async () => pending.shift()!(identity));
const image = document.querySelector('img')!;
assert.equal(image.getAttribute('src'), identity.avatar); assert.equal(image.width, 20); assert.equal(image.height, 20);
const badge = document.querySelector<HTMLElement>('[data-plugin-official-maintainer]')!;
assert.equal(badge.textContent, 'Official maintainer'); assert.match(badge.title, /not a safety certification/);
assert.ok(badge.classList.contains('border-0'));
assert.ok(badge.classList.contains('bg-amber-500/15')); assert.ok(badge.classList.contains('dark:bg-amber-400/15'));
assert.ok(badge.classList.contains('text-amber-800')); assert.ok(badge.classList.contains('dark:text-amber-300'));
document.documentElement.classList.add('dark');
assert.ok(document.querySelector('[data-plugin-author-row]')?.classList.contains('items-center'));
await act(async () => image.dispatchEvent(new Event('error')));
assert.equal(document.querySelector('img'), null); assert.ok(document.querySelector('[data-plugin-author-placeholder]'));
assert.ok(document.querySelector('[data-plugin-official-maintainer]'), 'image failure does not alter maintenance identity');
const next = { ...entry, selectionToken: 'c'.repeat(64) };
await render(next); assert.equal(document.querySelector('[data-plugin-official-maintainer]'), null);
await visible(); const stale = pending.shift()!;
const copied = { ...entry, author: 'Community author', selectionToken: 'd'.repeat(64) };
await render(copied, 'third-party:copy'); await visible();
await act(async () => stale(identity));
assert.equal(document.querySelector('[data-plugin-official-maintainer]'), null);
assert.match(document.querySelector('[data-plugin-author-row]')!.textContent!, /Community author/);
await act(async () => pending.shift()!({ ...identity, author: copied.author, sourceId: 'third-party:copy', officialMaintained: false, avatar: 'https://avatars.githubusercontent.com/u/1' }));
assert.equal(document.querySelector('img'), null, 'renderer never uses a remote avatar URL');
assert.equal(document.querySelector('[data-plugin-official-maintainer]'), null);
await render({ ...entry, selectionToken: 'f'.repeat(64) }); await visible();
await act(async () => pending.shift()!({ ...identity, avatar: null, officialMaintained: false }));
assert.ok(document.querySelector('[data-plugin-author-placeholder]'));
const installed = { id: entry.id, activeVersion: entry.version, archiveSha256: 'a'.repeat(64),
source: { id: 'official', kind: 'official', origin: 'https://plugins.netcatty.app', name: 'Official' },
manifest: entry.manifest, runtime: {} } as NetcattyInstalledPlugin;
await act(async () => root.render(<I18nProvider locale="en"><PluginAuthorRow plugin={installed} /></I18nProvider>));
await visible(); assert.deepEqual(calls.at(-1), { kind: 'installed', pluginId: installed.id });
await act(async () => pending.shift()!({ ...identity, version: '9.0.0', archiveSha256: installed.archiveSha256! }));
assert.equal(document.querySelector('[data-plugin-official-maintainer]'), null, 'a stale installed version cannot gain a badge');
const count = calls.length;
await act(async () => root.render(<I18nProvider locale="en"><PluginAuthorRow plugin={{ ...installed }} /></I18nProvider>));
await visible(); assert.equal(calls.length, count, 'unchanged installed identity does not reload on a new object reference');
await render({ ...entry, selectionToken: 'e'.repeat(64) }); await visible();
const late = pending.shift()!;
await act(async () => root.unmount());
await act(async () => late(identity));
assert.equal(document.querySelector('[data-plugin-author-row]'), null);
assert.ok(observers.every((observer) => observer.target === null));
} finally {
dom.window.close();
for (const [key, descriptor] of previous) {
if (descriptor) Object.defineProperty(globalThis, key, descriptor); else Reflect.deleteProperty(globalThis, key);
}
}
});
@@ -32,9 +32,7 @@ test('installed grid filters, opens one detail and restores search, scroll and c
assert.equal(cards().length, 2);
const alphaCard = renderer.root.findAllByType('article')[0];
assert.match(JSON.stringify(renderer.toJSON()), /Alpha safely formats synthetic greetings/);
const authorRow = alphaCard.findByProps({ 'data-plugin-author-row': true });
assert.ok(authorRow.findAllByType('span').some((node) => node.children.includes('Example')), 'author has its own compact row');
assert.ok(authorRow.findByProps({ 'data-plugin-author-placeholder': true }), 'an unverified installed publisher has a default avatar');
assert.ok(alphaCard.findAllByType('p').some((node) => node.children.includes('Example')), 'author has its own line');
assert.ok(alphaCard.findAllByType('p').some((node) => node.props.className?.includes('line-clamp-3')), 'card description is bounded');
assert.match(JSON.stringify(renderer.toJSON()), /MIT/);
assert.match(JSON.stringify(renderer.toJSON()), /Incompatible version/);
@@ -19,7 +19,7 @@ export function PluginLogSettings() {
const disclosure = zh
? '插件编写的日志文字可能包含敏感数据。截断和字段过滤不能保证任意文字安全。只为排查问题临时开启,导出前请检查。重启应用后自动关闭详细日志。'
: 'Plugin-authored text may contain sensitive data. Truncation and field filtering cannot make arbitrary text safe. Enable temporarily for troubleshooting and review files before sharing. Verbose diagnostics turn off after app restart.';
return <div>
return <div data-host-security-surface="">
<Dialog open={open} onOpenChange={setOpen}>
<SettingRow label={title} description={zh ? '选择是否在此设备保存审计记录和诊断日志。' : 'Choose whether to keep audit records and diagnostics on this device.'}>
<div className="flex items-center gap-3">
@@ -53,6 +53,6 @@ export function PluginLogSettings() {
</DialogFooter>
</DialogContent>
</Dialog>
<ConfirmDialog hostSecuritySurface open={confirmVerbose} title={zh ? '开启插件详细诊断?' : 'Enable verbose plugin diagnostics?'} message={disclosure} confirmLabel={zh ? '临时开启' : 'Enable temporarily'} onOpenChange={setConfirmVerbose} onConfirm={() => { setConfirmVerbose(false); void run('configure', { saveToDisk: true, verbose: true }); }} />
<ConfirmDialog open={confirmVerbose} title={zh ? '开启插件详细诊断?' : 'Enable verbose plugin diagnostics?'} message={disclosure} confirmLabel={zh ? '临时开启' : 'Enable temporarily'} onOpenChange={setConfirmVerbose} onConfirm={() => { setConfirmVerbose(false); void run('configure', { saveToDisk: true, verbose: true }); }} />
</div>;
}
@@ -68,6 +68,11 @@ test('plugin manager is single-instance, closes with Escape, restores focus and
})}>Open contributed settings view</button>}</PluginManagementDialog></I18nProvider>));
await click(trigger);
await click(document.querySelector<HTMLButtonElement>('[role="dialog"] button:not([data-dialog-close])')!);
// Radix schedules close-focus cleanup after the React close commit. The
// settle inside click's act() can precede that commit; observe the actual
// handoff instead of assuming the cleanup timer already ran.
const handoffDeadline = Date.now() + 2_000;
while (!handedOff && Date.now() < handoffDeadline) await act(async () => { await settle(); });
assert.equal(handedOff, true, 'external view opens only after modal ownership is released');
function ControlledEntry() {
const [open, setOpen] = useState(false);
@@ -4,7 +4,6 @@ import { Dialog, DialogContent, DialogDescription, DialogTitle } from '../../ui/
import { PluginInfo } from './PluginInfo';
import React, { useRef, useState } from 'react';
import { PluginBrandIcon } from '../../plugins/PluginBrandIcon';
import { PluginAuthorRow } from '../../plugins/PluginAuthorRow';
import { ArrowLeft } from 'lucide-react';
import { useI18n } from '../../../application/i18n/I18nProvider';
import { usePluginCatalog } from '../../../application/state/usePluginCatalog';
@@ -83,7 +82,7 @@ export function PluginMarketplaceContent({ catalog, installed, selected, onSelec
return <button key={item.id} type="button" data-plugin-id={item.id} aria-current={selected === item.id ? 'true' : undefined} onClick={() => choose(item.id)}
className="plugin-card min-w-0 flex min-h-[200px] flex-col gap-3 rounded-lg bg-card p-4 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
<div className="flex items-center gap-3"><span className="flex h-8 w-8 shrink-0 items-center justify-center"><PluginBrandIcon entry={item} size={32} className="text-muted-foreground" /></span><span className="min-w-0 break-words text-sm font-semibold leading-5">{item.name}</span></div>
<PluginAuthorRow entry={item} sourceId={sourceId} />
<p className="break-words text-xs text-muted-foreground">{item.author}</p>
<p className="line-clamp-3 break-words text-sm leading-5 text-muted-foreground">{item.description}</p>
<div className="mt-auto flex flex-wrap items-center justify-between gap-2 pt-1 text-xs text-muted-foreground"><span>{item.version} · {item.license}</span><span className="text-foreground">{t('settings.plugins.manager.details')}</span></div>
<div className="flex flex-wrap gap-2 text-xs text-muted-foreground">{isInstalled && <span className="rounded bg-muted px-2 py-0.5 text-foreground">{t('settings.plugins.market.installed')}</span>}{!item.artifact && <span className="rounded bg-muted/40 px-2 py-0.5">{t('settings.plugins.market.sourceBadge')}</span>}</div>
@@ -92,7 +91,7 @@ export function PluginMarketplaceContent({ catalog, installed, selected, onSelec
</div>}
{entry && <article key={entry.id} aria-label={entry.name} className="plugin-marketplace-detail mx-auto w-full max-w-[920px] min-w-0 space-y-6 focus:outline-none">
<Button variant="ghost" size="sm" onClick={back}><ArrowLeft size={14} className="mr-2" />{t('settings.plugins.market.back')}</Button>
<div className="space-y-2"><div className="flex items-center gap-3"><PluginBrandIcon entry={entry} size={48} /><h4 ref={detailRef} tabIndex={-1} className="break-words text-xl font-semibold focus:outline-none">{entry.name}</h4></div><PluginAuthorRow entry={entry} sourceId={sourceId} /><p className="text-xs text-muted-foreground">{entry.version} · {entry.license}</p>
<div className="space-y-2"><div className="flex items-center gap-3"><PluginBrandIcon entry={entry} size={48} /><h4 ref={detailRef} tabIndex={-1} className="break-words text-xl font-semibold focus:outline-none">{entry.name}</h4></div><p className="text-xs text-muted-foreground">{entry.author} · {entry.version} · {entry.license}</p>
{installedEntry && <p className="text-xs text-muted-foreground">{t('settings.plugins.market.installed')} · {installedEntry.activeVersion} · {t(installedEntry.enabled ? 'settings.plugins.manager.enabled' : 'settings.plugins.manager.disabled')}</p>}
</div>
{installedEntry?.runtime?.lastError && <p role="status" className="break-words text-xs text-destructive">{t('settings.plugins.manager.lastError')}: {installedEntry.runtime.lastError}</p>}
@@ -3,7 +3,6 @@ import { describePluginRuntime } from '@/domain/pluginRuntimePolicy.cjs';
import React, { useRef, useState } from 'react';
import { PluginPermissionPanel } from './PluginPermissionPanel';
import { PluginBrandIcon } from '../../plugins/PluginBrandIcon';
import { PluginAuthorRow } from '../../plugins/PluginAuthorRow';
import { ArrowLeft } from 'lucide-react';
import { usePluginManager } from '../../../application/state/usePluginManager';
import { useI18n } from '../../../application/i18n/I18nProvider';
@@ -104,7 +103,7 @@ export function PluginPackageManager({ manager, onSelectionChange, initialSelect
</Button>
</div>
<p className="break-all text-xs text-muted-foreground">{t('settings.plugins.sources.source')}: {t(plugin.source?.kind === 'official' ? 'settings.plugins.sources.official' : plugin.source?.kind === 'third-party' ? 'settings.plugins.sources.thirdparty' : 'settings.plugins.sources.local')}{plugin.source?.origin ? ` · ${plugin.source.origin}` : ''}</p>
{plugin.manifest?.publisher && <PluginAuthorRow plugin={plugin} />}
{plugin.manifest?.publisher && <p className="break-words text-xs text-muted-foreground">{plugin.manifest.publisher}</p>}
<p className={`break-words text-sm leading-5 text-muted-foreground ${selectedPlugin ? 'whitespace-pre-wrap' : 'line-clamp-3'}`}>{descriptionOf(plugin) || t('settings.plugins.manager.noDescription')}</p>
{!selectedPlugin ? <div className="mt-auto flex flex-wrap items-center justify-between gap-2 pt-1">
<p className="break-words text-xs text-muted-foreground">{plugin.activeVersion}{plugin.manifest?.license ? ` · ${plugin.manifest.license}` : ''}</p>
@@ -6,7 +6,6 @@ import type { usePluginManager } from '../../../application/state/usePluginManag
import { Button } from '../../ui/button';
import { Input } from '../../ui/input';
import { PluginBrandIcon } from '../../plugins/PluginBrandIcon';
import { PluginAuthorRow } from '../../plugins/PluginAuthorRow';
import { SettingCard, SettingRow, SettingsSection, Toggle } from '../settings-ui';
import { PluginLogSettings } from './PluginLogSettings';
@@ -73,8 +72,7 @@ export function PluginSettingsLanding({ manager, settingsAvailable, settingsErro
<span className="truncate" title={name}>{name}</span>
{plugin.activeVersion && <span className="shrink-0 text-xs font-normal text-muted-foreground">{plugin.activeVersion}</span>}
</span>} description={<>
{plugin.manifest?.publisher && <PluginAuthorRow plugin={plugin} />}
<span className="block break-words">{[sourceOf(plugin), statusOf(plugin)].filter(Boolean).join(' · ')}</span>
<span className="block break-words">{[plugin.manifest?.publisher, sourceOf(plugin), statusOf(plugin)].filter(Boolean).join(' · ')}</span>
{availableUpdate && <span className="block text-primary">{t('settings.plugins.landing.updateAvailable').replace('{version}', availableUpdate.availableVersion)}</span>}
{description && <span className="line-clamp-2 break-words">{description}</span>}
</>}>
@@ -1,88 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { EventEmitter } from 'node:events';
import { createRequire } from 'node:module';
import { JSDOM } from 'jsdom';
const require = createRequire(import.meta.url);
test('real Settings landing leaves views visible while idle; log and Ask dialogs suspend and restore them', async () => {
const dom = new JSDOM('<!doctype html><html><body><div id="root"></div><div id="native-view-mount"></div></body></html>', { pretendToBeVisual: true, url: 'http://localhost' });
const previous = new Map<string, PropertyDescriptor | undefined>();
const install = (key: string, value: unknown) => {
previous.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
Object.defineProperty(globalThis, key, { configurable: true, writable: true, value });
};
for (const key of ['window', 'document', 'navigator', 'HTMLElement', 'HTMLInputElement', 'Element', 'Node', 'NodeFilter', 'Event', 'MouseEvent', 'KeyboardEvent', 'MutationObserver', 'DocumentFragment', 'CustomEvent']) install(key, key === 'window' ? dom.window : dom.window[key as keyof typeof dom.window]);
install('getComputedStyle', dom.window.getComputedStyle.bind(dom.window));
install('requestAnimationFrame', dom.window.requestAnimationFrame.bind(dom.window));
install('cancelAnimationFrame', dom.window.cancelAnimationFrame.bind(dom.window));
install('IS_REACT_ACT_ENVIRONMENT', true);
const { default: React, act } = await import('react');
const { createRoot } = await import('react-dom/client');
const { I18nProvider } = await import('../../../application/i18n/I18nProvider');
const { PluginSettingsLanding } = await import('./PluginSettingsLanding');
const { PluginPermissionHost } = await import('../../plugins/PluginPermissionHost');
const { observePluginHostSecurityVisibility } = await import('../../../application/state/pluginHostSecurityVisibility');
const { PluginPermissionDecisionHost } = require('../../../electron/plugins/permissionDecisionHost.cjs');
type Prompt = NetcattyPluginPermissionPrompt | { requestId: string; closed: true };
const listeners = new Set<(event: Prompt) => void>();
const sender = Object.assign(new EventEmitter(), { isDestroyed: () => false, send: (_channel: string, event: Prompt) => { for (const listener of listeners) listener(event); } });
const owner = Object.assign(new EventEmitter(), { webContents: sender, isFocused: () => true, isVisible: () => true, isDestroyed: () => false });
const host = new PluginPermissionDecisionHost({ getWindows: () => [owner], isLocked: () => false });
let logStatus = { saveToDisk: false, verbose: false, droppedRecords: 0, writeErrors: 0, pendingBytes: 0, maxBytes: 3145728, retentionDays: 7 };
Object.assign(dom.window, { netcatty: {
async getPluginLogStatus() { return logStatus; },
async configurePluginLogs(value: { saveToDisk: boolean; verbose: boolean }) { logStatus = { ...logStatus, ...value }; return logStatus; },
onPluginPermission(listener: (event: Prompt) => void) { listeners.add(listener); return () => listeners.delete(listener); },
setPluginPermissionReady: async (ready: boolean) => host.setReady(sender, ready),
respondPluginPermission: async (response: unknown) => host.respond(sender, response),
} });
const manager = { plugins: [], available: true, statusKnown: true, busy: false, error: null, credentialStorageAvailable: true,
async refresh() {}, async setEnabled() {}, async importPackage() {}, async uninstall() {} };
let visible = true;
const transitions: boolean[] = [];
const stopVisibility = observePluginHostSecurityVisibility(document.getElementById('native-view-mount')!, (next) => { visible = next; transitions.push(next); });
const root = createRoot(document.getElementById('root')!);
const button = (label: string) => [...document.querySelectorAll<HTMLButtonElement>('button')].find((element) => element.textContent === label)!;
const flushVisibility = () => new Promise<void>((resolve) => dom.window.queueMicrotask(resolve));
try {
await act(async () => root.render(<I18nProvider locale="en"><PluginSettingsLanding manager={manager} settingsAvailable={new Set()} onManage={() => {}} /><PluginPermissionHost /></I18nProvider>));
await flushVisibility();
assert.equal(document.querySelector('[role="dialog"]'), null);
assert.ok(button('Configure'), 'real idle logging row is present');
assert.equal(visible, true, 'idle Settings landing must not hide the native child');
assert.deepEqual(transitions, []);
await act(async () => button('Configure').click());
await flushVisibility();
assert.ok(document.querySelector('[role="dialog"][data-host-security-surface]'));
assert.equal(visible, false, 'actual logging security dialog must hide the child');
await act(async () => document.querySelectorAll<HTMLButtonElement>('[role="switch"]')[0].click());
await act(async () => document.querySelectorAll<HTMLButtonElement>('[role="switch"]')[1].click());
await flushVisibility();
assert.equal(document.querySelectorAll('[role="dialog"]').length, 2);
assert.equal(visible, false, 'the verbose security confirmation must also keep the child hidden');
await act(async () => button('Cancel').click());
await flushVisibility();
assert.equal(visible, false, 'cancelling verbose returns to the protected logging dialog');
await act(async () => button('Close').click());
await flushVisibility();
assert.equal(document.querySelector('[role="dialog"]'), null);
assert.equal(visible, true, 'closing logs must restore the child while its idle row remains');
let pending!: Promise<{ decision: string }>;
await act(async () => {
pending = host.request({ requestId: 'settings-visibility-ask', pluginId: 'com.example.settings-visibility', pluginName: 'Settings visibility fixture', pluginVersion: '1.0.0',
permission: 'settings.read', resources: ['*'], reason: 'Read own synthetic setting' });
});
await flushVisibility();
assert.ok(document.querySelector('[role="dialog"][data-host-security-surface]'));
assert.equal(visible, false, 'Ask must still hide the child above the real landing');
await act(async () => { button('Deny').click(); await pending; });
await flushVisibility();
assert.equal((await pending).decision, 'deny');
assert.equal(visible, true, 'dismissing Ask must restore the same child');
assert.deepEqual(transitions, [false, true, false, true]);
} finally {
await act(async () => root.unmount()); stopVisibility(); host.shutdown(); dom.window.close();
for (const [key, descriptor] of previous) { if (descriptor) Object.defineProperty(globalThis, key, descriptor); else Reflect.deleteProperty(globalThis, key); }
}
});
@@ -23,7 +23,7 @@ import {
import { resolveAppIconVariant, type AppIconVariant } from "../../../domain/appIconVariant";
import { resolveNoteFontSelectionFamily, resolveNoteFontSelectionId } from "../../../domain/noteFonts";
import { DEFAULT_AUTO_IMPORT_SYSTEM_KNOWN_HOSTS } from "../../../domain/systemKnownHostsAutoImport";
import { cn, isMacPlatform } from "../../../lib/utils";
import { cn } from "../../../lib/utils";
import { SectionHeader, SettingsAnchor, SettingsTabContent, SettingRow, Toggle, Select } from "../settings-ui";
import { FontSelect } from "../FontSelect";
import { TerminalFontSelect } from "../TerminalFontSelect";
@@ -69,8 +69,6 @@ function SettingsAppearanceTab(props: {
setShowHostTreeSidebar: (enabled: boolean) => void;
windowOpacity: number;
setWindowOpacity: (opacity: number) => void;
windowBackgroundBlur: boolean;
setWindowBackgroundBlur: (enabled: boolean) => void;
appIconVariant: AppIconVariant;
setAppIconVariant: (variant: AppIconVariant) => void;
}) {
@@ -142,13 +140,10 @@ function SettingsAppearanceTab(props: {
setShowHostTreeSidebar,
windowOpacity,
setWindowOpacity,
windowBackgroundBlur,
setWindowBackgroundBlur,
appIconVariant,
setAppIconVariant,
} = props;
const resolvedAppIconVariant = resolveAppIconVariant(appIconVariant);
const isMac = isMacPlatform();
const WINDOW_OPACITY_PRESETS = [
{ label: '100%', value: 1 },
@@ -320,19 +315,6 @@ function SettingsAppearanceTab(props: {
</div>
</div>
</SettingRow>
{isMac && (
<SettingRow
anchorId="appearance-window-background-blur"
label={t("settings.appearance.windowBackgroundBlur")}
description={t("settings.appearance.windowBackgroundBlur.desc")}
>
<Toggle
checked={windowBackgroundBlur}
onChange={setWindowBackgroundBlur}
ariaLabel={t("settings.appearance.windowBackgroundBlur")}
/>
</SettingRow>
)}
</div>
<SectionHeader title={t("settings.appearance.uiTheme")} />
-1
View File
@@ -264,7 +264,6 @@ export const SftpOverlays: React.FC<SftpOverlaysProps> = React.memo(({
open={showTextEditor}
onClose={closeTextEditor}
fileName={textEditorFileName}
filePath={textEditorTarget?.fullPath}
initialContent={textEditorContent}
onSave={handleSaveTextFile}
editorWordWrap={editorWordWrap}
+2 -2
View File
@@ -248,7 +248,7 @@ export const useSftpViewFileOps = ({
hostId: target.hostId,
remotePath: target.fullPath,
fileName: target.file.name,
languageId: snapshot.languageId || getLanguageId(target.fullPath),
languageId: snapshot.languageId || getLanguageId(target.file.name),
content: snapshot.content,
baselineContent: snapshot.baselineContent,
wordWrap: snapshot.wordWrap,
@@ -284,7 +284,7 @@ export const useSftpViewFileOps = ({
hostId: target.hostId,
remotePath: target.fullPath,
fileName: target.file.name,
languageId: snapshot.languageId || getLanguageId(target.fullPath),
languageId: snapshot.languageId || getLanguageId(target.file.name),
content: snapshot.content,
baselineContent: snapshot.baselineContent,
wordWrap: snapshot.wordWrap,
-131
View File
@@ -1,131 +0,0 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { createRequire } from 'node:module';
import test from 'node:test';
import React from 'react';
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
import ts from 'typescript';
import type { TextEditorPaneProps } from '../editor/TextEditorPane';
import type { SftpStateApi } from '../../application/state/useSftpState';
import { editorTabStore } from '../../application/state/editorTabStore';
import { activeTabStore } from '../../application/state/activeTabStore';
import { useSftpViewFileOps } from './hooks/useSftpViewFileOps';
// Keep the real modal state/save flow; replace only Monaco and visual chrome.
function loadModal(pane: React.ComponentType<TextEditorPaneProps>) {
const url = new URL('../TextEditorModal.tsx', import.meta.url);
const compiled = ts.transpileModule(readFileSync(url, 'utf8'), { compilerOptions: {
module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022,
jsx: ts.JsxEmit.ReactJSX, esModuleInterop: true,
} }).outputText;
const require = createRequire(url);
const passthrough = ({ children }: { children: React.ReactNode }) => children;
const replacements: Record<string, unknown> = {
'./editor/TextEditorPane': { TextEditorPane: pane },
'./ui/dialog': { Dialog: passthrough, DialogContent: passthrough, DialogTitle: passthrough },
'./ui/toast': { toast: { success() {}, error() {} } },
'../application/state/windowInputFocus': { scheduleWindowInputFocus() {} },
};
const module = { exports: {} as { default: React.ComponentType<Record<string, unknown>> } };
new Function('require', 'module', 'exports', compiled)(
(id: string) => replacements[id] ?? require(id), module, module.exports,
);
return module.exports.default;
}
test('SFTP modal uses full paths, preserves manual selection and saves exact content', async () => {
const globals = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean };
const previousAct = globals.IS_REACT_ACT_ENVIRONMENT;
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, 'window');
const previousActiveTab = activeTabStore.getActiveTabId();
const detached: Array<{ languageId: string; content: string; remotePath: string }> = [];
Object.defineProperty(globalThis, 'window', { configurable: true, value: {
setTimeout, clearTimeout,
netcatty: { openEditorWindow: async (snapshot: typeof detached[number]) => {
detached.push(snapshot);
return { success: true };
} },
} });
globals.IS_REACT_ACT_ENVIRONMENT = true;
let paneProps: TextEditorPaneProps | undefined;
const Modal = loadModal((props) => { paneProps = props; return null; });
let ops: ReturnType<typeof useSftpViewFileOps> | undefined;
const original = '# fixture\r\nserver { proxy_pass http://$host; }\r\n';
const writes: unknown[][] = [];
const connection = { id: 'nginx-fixture', hostId: 'nginx-host', currentPath: '/etc/nginx/conf.d' };
const sftpRef = { current: {
leftPane: { id: 'nginx-pane', connection }, rightPane: { id: 'nginx-pane', connection },
joinPath: (parent: string, name: string) => `${parent}/${name}`,
readTextFile: async () => original,
writeTextFile: async (...args: unknown[]) => { writes.push(args); },
} as unknown as SftpStateApi };
function Probe() {
ops = useSftpViewFileOps({
sftpRef, behaviorRef: { current: '' }, autoSyncRef: { current: false },
getOpenerForFileRef: { current: () => null }, setOpenerForExtension() {}, t: (key) => key,
});
return ops.showTextEditor ? React.createElement(Modal, {
open: true, onClose() {}, fileName: ops.textEditorTarget!.file.name,
filePath: ops.textEditorTarget!.fullPath, initialContent: ops.textEditorContent,
onSave: ops.handleSaveTextFile, editorWordWrap: false, onToggleWordWrap() {},
hotkeyScheme: 'pc', keyBindings: [], onPromoteToTab: ops.onPromoteToTab,
}) : null;
}
let renderer: ReactTestRenderer | undefined;
try {
await act(async () => { renderer = create(React.createElement(Probe)); });
const file = { name: 'site.conf', size: original.length, type: 'file' as const, lastModified: 0, sizeFormatted: '', lastModifiedFormatted: '' };
await act(async () => { await ops!.onEditFileLeft(file); });
assert.equal(paneProps!.languageId, 'nginx');
assert.equal(paneProps!.fileName, 'site.conf');
// Keep the modal mounted: basename and content stay identical, only path changes.
await act(async () => { await ops!.onEditFileLeft(file, '/etc/other/site.conf'); });
assert.equal(paneProps!.languageId, 'ini');
await act(async () => { await ops!.onEditFileLeft(file, '/etc/nginx/conf.d/site.conf'); });
assert.equal(paneProps!.languageId, 'nginx');
await act(async () => { paneProps!.onLanguageChange('ini'); });
await act(async () => { paneProps!.onSave(); });
assert.deepEqual(writes, [['left', '/etc/nginx/conf.d/site.conf', original]]);
await act(async () => { paneProps!.onPromoteToTab!(); });
let tab = editorTabStore.getTabs().find(({ sessionId }) => sessionId === connection.id);
assert.equal(tab?.languageId, 'ini', 'promotion preserves a manual override');
assert.equal(tab?.content, original);
editorTabStore.close(tab!.id);
// A generic config can still be switched to Nginx manually.
await act(async () => { await ops!.onEditFileLeft(file, '/etc/other/site.conf'); });
assert.equal(paneProps!.languageId, 'ini');
await act(async () => { paneProps!.onLanguageChange('nginx'); });
assert.equal(paneProps!.languageId, 'nginx', 'arbitrary config supports manual Nginx selection');
await act(async () => { paneProps!.onPromoteToTab!(); });
tab = editorTabStore.getTabs().find(({ sessionId }) => sessionId === connection.id);
assert.equal(tab?.languageId, 'nginx');
// Legacy snapshots without language still infer from the full target path.
await act(async () => { await ops!.onEditFileLeft(file, '/etc/nginx/conf.d/fallback.conf'); });
await act(async () => { ops!.onPromoteToTab({ fileName: file.name, content: original,
baselineContent: original, languageId: '', wordWrap: false, viewState: null }); });
assert.equal(editorTabStore.getTabs().find(({ remotePath }) => remotePath.endsWith('/fallback.conf'))?.languageId, 'nginx');
await act(async () => { await ops!.onEditFileLeft(file, '/etc/nginx/sites-enabled/example.com'); });
await act(async () => { ops!.onPopOut({ fileName: file.name, content: original,
baselineContent: original, languageId: '', wordWrap: false, viewState: null }); });
assert.equal(detached.at(-1)?.languageId, 'nginx', 'pop-out fallback also uses the full path');
assert.equal(detached.at(-1)?.remotePath, '/etc/nginx/sites-enabled/example.com');
assert.equal(detached.at(-1)?.content, original);
} finally {
await act(async () => { renderer?.unmount(); });
for (const tab of editorTabStore.getTabs().filter(({ sessionId }) => sessionId === connection.id)) editorTabStore.close(tab.id);
activeTabStore.setActiveTabId(previousActiveTab);
await new Promise<void>((resolve) => setTimeout(resolve, 0));
globals.IS_REACT_ACT_ENVIRONMENT = previousAct;
if (previousWindow) Object.defineProperty(globalThis, 'window', previousWindow);
else Reflect.deleteProperty(globalThis, 'window');
}
});
test('the SFTP overlay forwards path context to the lazy modal', () => {
const overlay = readFileSync(new URL('./SftpOverlays.tsx', import.meta.url), 'utf8');
assert.match(overlay, /<LazyTextEditorModal[\s\S]*?filePath=\{textEditorTarget\?\.fullPath\}/);
const pane = readFileSync(new URL('../editor/TextEditorPane.tsx', import.meta.url), 'utf8');
assert.match(pane, /beforeMount=\{registerNginxLanguage\}/);
});
+1 -3
View File
@@ -17,7 +17,6 @@ interface ConfirmDialogProps {
confirmLabel: string;
busy?: boolean;
destructive?: boolean;
hostSecuritySurface?: boolean;
onOpenChange: (open: boolean) => void;
onConfirm: () => void;
}
@@ -29,7 +28,6 @@ export const ConfirmDialog = memo(function ConfirmDialog({
confirmLabel,
busy = false,
destructive = false,
hostSecuritySurface = false,
onOpenChange,
onConfirm,
}: ConfirmDialogProps) {
@@ -37,7 +35,7 @@ export const ConfirmDialog = memo(function ConfirmDialog({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent data-host-security-surface={hostSecuritySurface ? "" : undefined} className="max-w-[calc(100vw-2rem)] overflow-hidden sm:max-w-[380px]">
<DialogContent className="max-w-[calc(100vw-2rem)] overflow-hidden sm:max-w-[380px]">
<DialogHeader className="min-w-0 pr-6">
<DialogTitle className="truncate">{title}</DialogTitle>
</DialogHeader>
-58
View File
@@ -1,58 +0,0 @@
# Plugin author presentation
The marketplace displays the catalog's author next to a small avatar. Installed
views reuse the author row. Missing, loading, unavailable, or invalid imagery
uses a bundled placeholder. An avatar is presentation metadata, not proof of an
authenticated GitHub publisher.
The registry binds an image to its reviewed displayed-author subject. Repository
ownership alone does not establish that subject. The current first-party author
is the Netcatty project, not an assumed personal GitHub identity. Until an image
pin is reviewed, its avatar remains null.
## Avatar delivery
Delivery v2 extends the reserved `avatar` field to null or
`{path, sha256, size}`. The path is exactly `/assets/<sha256>.png` or `.webp`.
Size is a positive integer no greater than 256KiB. The delivery digest and existing
id/version/repository/commit tuple bind the reference. Catalog and index identity
fields do not change. Source-only delivery v1 remains supported.
Only the main process fetches the selected source's same-origin asset. Existing
URL, credential, redirect, timeout, byte-count, and SHA-256 checks apply. The host
rechecks the source and selection after asynchronous work. The existing isolated
icon rasterizer validates and converts PNG or WebP to a bounded PNG data URI,
using its current queue, pending-work map, and cache. No renderer URL, arbitrary
file path, GitHub API, or GitHub avatar URL is accepted. Visible rows load lazily.
Installed presentation uses already validated catalog metadata only when source,
version, package digest, and manifest match the active installation. It does not
fetch a catalog just because the installed list opens. Without an exact match,
the declared publisher and placeholder remain visible, without a maintenance
badge. No installed-data migration or persistent identity cache is added.
## Official maintenance
The borderless gold pill means that the Netcatty project maintains the plugin.
It does not certify the plugin's safety or grant permissions. It is separate
from the official marketplace source label.
The main process requires the built-in official source and one of these explicit
first-party ID and repository pairs:
- `com.netcatty.starter`, `binaricat/Netcatty`
- `com.netcatty.hello`, `binaricat/Netcatty`
A local import, a third-party copy, an ID prefix, author text, manifest publisher,
README, or membership in the official marketplace cannot create this badge.
The renderer consumes the host result through the existing trusted-app IPC
boundary. No new plugin permission or plugin RPC is introduced.
## Validation scope
Focused registered tests cover source spoofing, exact installed identity,
hash-bound images, invalid metadata and decoding, redirects, source removal,
selection changes, cache reuse, lazy loading, stale UI cleanup, placeholders,
and borderless light/dark theme classes. These tests do not establish native
layout, actual account identity, live CDN deployment, or successful network access.
Actual app screenshots and native acceptance remain separately scheduled.
-7
View File
@@ -450,13 +450,6 @@ new child to an opened parent directory without a path race.
`readDirectory()` likewise keeps its stable SDK/RPC method but fails closed
unless the main process supplies a native adapter whose inode checks and entry
enumeration are bound to the same directory handle.
The host advertises `filesystem.directory-enumeration` only when it supplies that
adapter. Declare the feature as required, or optional and check
`context.enabledFeatures.has(PLUGIN_FILESYSTEM_DIRECTORY_ENUMERATION_FEATURE)`.
The default host supplies no adapter and does not enable this feature. Both the
SDK and direct RPC return `unsupported` before a permission request or path probe.
An enabled feature still requires the declared `filesystem.read` directory
resource and the normal permission, root, symlink and same-handle checks.
Permission names already use the phase-3 enforcement boundaries: clipboard
read/write, terminal metadata/output/input and input/output interception, Vault
-121
View File
@@ -1,121 +0,0 @@
# Plugin foundation landing candidate
Draft #4011 is the single landing vehicle for the accumulated plugin foundation.
Earlier integration records describe frozen evidence, not the current acceptance
status. This candidate preserves their source history through ordinary merges.
The user cancelled the reference-driven row redesign. Installed grid and Settings
quick-toggle layout are restored exactly to b14, while author presentation and all
functional/security corrections remain. Cancelled visual commits stay in history
with attributable reverts; they are not effective changes in the landing tree.
It does not include global Agent/custom Chat #4006/#4012, model-picker #4002,
or terminal diagnostic #3999. No older source PR is closed by this preparation.
## Included commits
| Input | Exact input | Ordinary integration |
| --- | --- | --- |
| Prior #4011 | `d40b0d3c25ed00a33679e83be948fd1a5aa10bfa` | Starting point; all inherited contributions retained |
| Current main | `2796d1113a6310fae1779931f242e208a69b95a5` | `db1223d16`; Nginx #4010, macOS appearance #4013, closed-socket MCP reconnect #4016 |
| Author metadata and preserved visual history | `e4eb33856b335fe0b1d302827b1325ddfc23bdde` | `b3895a477`; preserves original avatar/badge, canonical v2 schema, b14 invalidation, visual-worker patch subsequently reverted |
| Directory availability | `a36a906292cbe8033bc19b596dcaf7f853a804c7` | `d035cbea3`; no native adapter or expanded permission |
| Two-profile sync composition | `14ed19d2ed7cd1ecc201c9c910b112e97cc098ff` | `fc37ac48e`; tests only |
| Restore pre-row layout | `7e7b05620ff800ae459d0bf007bbace6aaebbdbc` | Normal revert of e4 row commit; all six layout paths match parent `b14b65679ffd9c617bb0aa1215d8653c53430243` |
| Rejected-install catalog refresh | `50fda91424c915c1497e53a87a210e3943cb1e39` | Separate correction after bounded review; service and mounted-row regression only |
The separately published icon branch first reverted its outline correction
(`530ff0318`), then the e4 row commit (`6e4465f47`). Neither outline correction nor
those isolated reverts are imported here; foundation never included the outline.
The e4 and 1f renderer artifacts are historical pre-cancellation evidence, not
accepted final UI. No further cancelled-design build or screenshot is requested.
The only manual merge conflict was the end of `index.css`. Resolution retains
both the prior host-security theme block and main's window-background-blur block,
with their declarations unchanged. Automatic combinations retain main appearance
wiring alongside plugin settings/theme wiring, all five locales, preload methods,
the host services and SDK exports. No permission, source, endpoint, sandbox or
selected-resource restriction is relaxed. Package and lock inputs are unchanged.
`50fda9142` reuses the same selection-token comparison after an install-triggered
catalog refresh, including an install rejected for a changed/withdrawn selection.
The rejection is preserved; mounted authors no longer retain withdrawn enrichment.
The regression makes no package download or execution. It also preserves the
original synchronous unavailable-source rejection in `list()`: the first aggregate
runtime run caught that b14 changed it into a Promise rejection. The assertion was
not weakened. Corrected aggregate runtime: 711 pass, one native skip, zero failures
or cancellations. The original failed run and fail-before regression are retained.
## Checks and packages
Registered runtime, SDK/contract, Settings and actual connected host entrypoints,
changed-main MCP/appearance/editor regressions, example preparation, code generation,
lint and the normal production renderer build are run on this candidate. Exact
head, commands, counts and CI links are recorded in #4011. Package provenance is
in [the bounded sample manifest](foundation-packages.json): each archive is built
twice and whole-byte compared. Assistant is byte-identical to d40; Host changes
only README wording, with manifest/entrypoint unchanged. Database-owned, Rfam and
terminal archives change only `browser.js` minified bound identifier spelling.
Rebuilding the same sources with d40's generated SDK/contract artifacts reproduces
the exact old archive hashes. Parsing and normalizing lexical bindings yields
identical ASTs for all three bundles; manifests, protocol and permissions do not
change. Preparation does not connect, activate or query.
Local checks at pre-restoration1f: runtime 711 pass/one existing native skip; SDK91; Settings and
connected host/security78; changed-main MCP/appearance/editor71; examples five
pass/one Docker opt-in skip. Contract/capability/Codex generation has no drift;
lint zero errors/seven existing warnings; normal production build36.13s. These
checks do not claim native UI, packaging or encrypted transport acceptance.
Restoration-focused layout/author tests: six pass; changed-file lint and diff
checks clean. Two broader Settings runs each had38pass/one PluginLogSettings
file-entry failure, without a detailed assertion; that file alone passed. Their
cause remains unclassified and both failed runs are retained. No production log
change or baseline attribution is made. Every one of the six layout paths exactly matches b14; fourteen functional
paths (identity/provenance, permissions, directory availability and sync included)
remain byte-identical to1f. The five sample records remain unchanged. Pre-restoration1f exact CI completed:
[test40505644082](https://github.com/binaricat/Netcatty/actions/runs/40505644082)
records15,372pass/50skip/zero failures or cancellations on its successful retry;
[packages40505644200](https://github.com/binaricat/Netcatty/actions/runs/40505644200)
passed all four platforms with release skipped. Its original runner shutdown is
retained. These results do not certify the restored head.
An earlier coding-executor full suite timed out after 15 minutes: 31 failures and
482 cancellations. Seven failures reproduced on unchanged d40; 24 were not probed
on that baseline. They remain unresolved evidence, not assumed baseline failures.
No additional long local full suite is used to conceal them. Clean-run exact-head
CI is a separate aggregate check and does not diagnose that historical run.
## Acceptance and landing order
1. Foundation: keep #4011 Draft until integrated CI/review and affected native UI
acceptance settle. d40 Settings, Host/Vault policy, terminal A/B selection and
inert multiline draft isolation, and native chat split/drag ownership passes
are historical evidence at their stated heads. They do not certify this new
current-main merge tree. The e4/07b reference-driven row design is cancelled;
stop its native visual work. Restored layout retains author/avatar presentation.
Avatar/catalog behavior and the final install-refresh correction still require
their scoped review/acceptance; cancellation does not erase functional gates.
2. Marketplace distribution: retain static-source identity and v2 delivery checks,
but track reviewed registry data, actual hosting/CDN and install/update/source
replacement/withdrawal acceptance separately. Current live source-only viewing
is not installation or deployment acceptance. Never weaken admitted-source or
destination guards to manufacture a pass.
3. Global Agent: #4006/#4012 remain a separate later increment, with their own
scope, review, CI and actual sample smoke. #4002 is also separate.
Historical integration vehicles #3967/#3989/#3993/#3997/#4001/#4004 are superseded
as landing vehicles, not discarded contributions. Their ancestors and native
receipts stay available. Unique source PRs remain intact; no premature closures.
Directory enumeration is intentionally unavailable in the default production
composition until a same-handle adapter exists. The advertised feature now tells
SDK callers the truth; filesystem permission alone does not enable it. The new
sync composition test proves local collect/apply/hydrate with real databases, not
encrypted cloud transport or native cloud-account sync. A native compatibility
transition needs two properly versioned builds; two 0.0.0 checkouts do not qualify.
Public database acceptance remains blocked by the retained TCP refusal before
protocol payload; the separately owned local fixture is pending. Source-policy,
compatibility and database gates must be explicitly reconciled by the parent,
not silently removed from requirements. Nested log-dialog overlap has source
coverage but no normal native route in the tested UI. This is code-readiness work,
not a claim that deployment, every original requirement or the whole project is
complete. No main merge, Ready transition, release or live metadata publication.
@@ -1,177 +0,0 @@
[
{
"name": "assistant",
"id": "com.netcatty.assistant",
"version": "0.1.0",
"sha256": "7943d4c3800914fe0acd3d0f3a3d7f7c1910c212a5dc58a044871a6a82485274",
"bytes": 17214,
"fileCount": 6,
"permissions": {
"required": [
"commands",
"views"
],
"optional": [
"ai.invoke",
"context.selected.list",
"context.selected.read"
]
},
"features": {
"required": [
"ai.model-invocation",
"context.selected-read"
]
},
"repeatIdentical": true,
"previousD40Sha256": "7943d4c3800914fe0acd3d0f3a3d7f7c1910c212a5dc58a044871a6a82485274",
"preparedOnly": true,
"noConnectionOrActivation": true,
"changedEntriesFromD40": [],
"delta": "Archive byte-identical."
},
{
"name": "database-owned",
"id": "com.netcatty.database-minimal",
"version": "0.1.0",
"sha256": "3c67f529cd5d2843dbf74a62d237b7b7d50d24a466ade85cce76b3adfff919a2",
"bytes": 11660,
"fileCount": 5,
"permissions": {
"required": [
"commands",
"views"
],
"optional": [
{
"permission": "network.stream",
"resources": [
"tcp://10.77.0.20:3306"
],
"reason": "Connect only to the prepared disposable fixture."
}
]
},
"features": {
"required": [
"network.byte-streams"
]
},
"repeatIdentical": true,
"previousD40Sha256": "f01c6a24382897d4b0bf4130941274abacb9a9a963f9b56ecaceb2c020f36874",
"preparedOnly": true,
"noConnectionOrActivation": true,
"changedEntriesFromD40": [
"browser.js"
],
"delta": "Generated SDK export changes alter only minified bound identifier spelling; scope-aware normalized AST is identical.",
"oldGeneratedRebuildSha256": "f01c6a24382897d4b0bf4130941274abacb9a9a963f9b56ecaceb2c020f36874",
"boundIdentifierAlphaEquivalent": true
},
{
"name": "database-rfam-readonly",
"id": "com.netcatty.database-rfam-readonly",
"version": "0.1.0",
"sha256": "554407c067ced0a6634599b7eccc7c6dbb4f2ff7c523d0bacbbbd4fb78c04612",
"bytes": 11003,
"fileCount": 5,
"permissions": {
"required": [
"commands",
"views"
],
"optional": [
{
"permission": "network.stream",
"resources": [
"tcp://mysql-rfam-public.ebi.ac.uk:4497"
],
"reason": "Run the fixed public Rfam acceptance query only after explicit authorization."
}
]
},
"features": {
"required": [
"network.byte-streams"
]
},
"repeatIdentical": true,
"previousD40Sha256": "aaca586669db454e91d61487e576ed524d1b7190eed182bc2cd0035e59caa0d3",
"preparedOnly": true,
"noConnectionOrActivation": true,
"changedEntriesFromD40": [
"browser.js"
],
"delta": "Generated SDK export changes alter only minified bound identifier spelling; scope-aware normalized AST is identical.",
"oldGeneratedRebuildSha256": "aaca586669db454e91d61487e576ed524d1b7190eed182bc2cd0035e59caa0d3",
"boundIdentifierAlphaEquivalent": true
},
{
"name": "host-actions",
"id": "com.netcatty.host-actions",
"version": "0.1.0",
"sha256": "9713f001449b0c749f518d42f013fa72ead6dd43f8e7ddd7843cc6ecc03027f9",
"bytes": 4789,
"fileCount": 3,
"permissions": {
"required": [
"commands",
"menus"
],
"optional": [
"vault.metadata",
"vault.write",
"ui.interact"
]
},
"features": {
"required": [
"vault.host-actions",
"commands.interactive"
]
},
"repeatIdentical": true,
"previousD40Sha256": "cf04f82dfe25ab5cda6f5f3dad0a8fb06c37bedf9924f556bee2b8402e542ef2",
"preparedOnly": true,
"noConnectionOrActivation": true,
"changedEntriesFromD40": [
"README.md"
],
"delta": "README wording follows permission policy. Manifest and browser entrypoint are byte-identical."
},
{
"name": "terminal-selection-draft",
"id": "com.netcatty.terminal-selection-draft",
"version": "0.1.0",
"sha256": "aaadd8687b1af1d6e1f9a56c52760ac0d19e7ed4e8212fb7c8775ed052ed74f1",
"bytes": 2954,
"fileCount": 2,
"permissions": {
"required": [
"commands",
"menus"
],
"optional": [
"terminal.selection.read",
"terminal.input.propose"
]
},
"features": {
"required": [
"terminal.selection-sharing",
"terminal.input-drafts",
"commands.interactive"
]
},
"repeatIdentical": true,
"previousD40Sha256": "4c9c2388b7f891156192a0b5ab7b23291beba3555e7ec8cf24fbe2dfe1242d5e",
"preparedOnly": true,
"noConnectionOrActivation": true,
"changedEntriesFromD40": [
"browser.js"
],
"delta": "Generated SDK export changes alter only minified bound identifier spelling; scope-aware normalized AST is identical.",
"oldGeneratedRebuildSha256": "4c9c2388b7f891156192a0b5ab7b23291beba3555e7ec8cf24fbe2dfe1242d5e",
"boundIdentifierAlphaEquivalent": true
}
]
+161
View File
@@ -0,0 +1,161 @@
# Global Agent plugin bridge (work in progress)
This stack starts from `integration/plugin-ask-routing-acceptance` at
`2433cf0130d1c3274c56c39e5765349c2945de01`. The parent confirmed this dependency;
its native Ask acceptance and integrated CI are still pending. This branch does
not modify the frozen candidate, #3986, #3998, or #4002.
The user approved a global conversation independent of a terminal or workspace,
with a plugin-provided chat UI and selectable existing Agent backends. Core owns
Agent execution, history, target authority, native approvals, credentials and
audit. Global is not unrestricted inventory access or a background service.
## Stage 1: dispatch authority
`electron/plugins/agent/delegatedScope.cjs` is a host-only authority. It does not
register plugin APIs, start an Agent or implement an orchestration loop.
- `issue(context, hostSnapshot)` authenticates with the existing source binding.
Host snapshots contain backend identity/revision, target kind/id/owner/revision,
synchronous current-owner checks, and a supported capability subset. The public
result contains only an opaque handle and expiry. Plugin parameters must never
be used as `hostSnapshot`.
- `begin(handle, context)` returns an internal turn ticket and AbortController.
- `dispatch(ticket, context, resolvedAction, execute)` runs the common host policy
adapter once, checks current target revisions after any approval wait, invokes
the real dispatcher, and rejects late results. `resolvedAction` must come from
canonical host capability/argument resolution, not an unchecked plugin object.
- `assertTurn` guards outgoing events. `finish` and `stop` invalidate the exact
ticket. Runtime/plugin release, lock/hide invalidation and shutdown abort work.
- Handles expire in five minutes. There are at most eight per runtime owner and
64 overall, with at most 32 explicit resources per scope. Expiry never silently
refreshes a selection or revives an old turn.
The focused tests use the actual `PluginAISourceBinding` with synthetic host
registries, two terminal owners representing different workspaces, controlled
time, and deferred approval/native completion. They cover source/runtime/target
substitution, focus changes, revision changes across approval, policy denial,
Stop, release, lock, late completion and bounds. They do not yet prove actual MCP
or AgentRuntime dispatch; those remain integration gates.
Initial execution failed before test discovery because the new worktree has no
dependencies (`ajv/dist/2020` missing). Running with the existing local worktree's
read-only CommonJS dependency path passes all 12 cases. No provider, CLI Agent,
paid credentials or GUI was used.
The adjacent run then passed 49 tests and failed one installed-entrypoint test
because ESM could not resolve `@netcatty/plugin-cli` from the new worktree. Standard
`npm ci --ignore-scripts` and `build:plugin-runtime-deps` repaired the setup. These
failures are setup evidence, not failed-before product regressions.
The core conversation repository stores the existing global `AISession` document
shape in a host-only directory, namespaced by the hash of source/private-owner.
Native session identity and compaction remain private. Commits serialize a cloned
snapshot, revalidate provenance before atomic rename, and retain previous history
when that check fails. Files use mode 0600 and no-follow reads. The repository
does not register a plugin storage API or change the source database schema.
The canonical schema and SDK now declare the proposed session interface and
permissions. Runtime methods and the supported-feature advertisement are not yet
registered. This stage must not be described as an installable working Agent
bridge. Focused scope/history/wire and permission-copy tests pass 20 cases;
contract/SDK/CLI tests pass 91. Focused lint and generated drift checks pass.
## Independent stage-1 correction
The independent review of exact `056164157f34ea779ed6cf51199909082345eca4`
confirmed two helper defects before any runtime acceptance claim: source failure
during the initial owner check rejected late work without aborting the active
native signal; Stop could acquire a replacement turn after awaiting identity.
Both reproduce against that exact helper using the real `PluginAISourceBinding`:
the focused before run is 13 passed / 2 failed. The correction revokes the captured
scope when its own host-authenticated identity fails, and captures the intended
turn before Stop's first await. A foreign runtime cannot use a failed lookup to
revoke another caller's scope. Focused after: 15 passed / 0 failed. The complete
foundation/permission group passes 23 on Node 24.19.0 and Node 22.23.3; focused lint
and diff checks pass. Logs: `/tmp/netcatty-global-agent-review-before.log`,
`/tmp/netcatty-global-agent-review-after.log`,
`/tmp/netcatty-global-agent-review-node22.log`.
The first CI run at 0561641, test `40459522087`, Linux job `122040565759`, failed
after an explicit runner shutdown and operation cancellation, with no assertion
failure in its log and no complete repository test total. Package workflow
`40459521971` and both Windows jobs passed. The parent authorized one job rerun;
it was requested once, producing Linux job `122048349748`, still running at this
checkpoint. Original runner failure remains historical evidence. Neither that
rerun nor the helper fix establishes runtime, native or independent-review
acceptance; the correction still needs reviewer recheck.
### Follow-up: cancellation is not source revocation
The independent recheck confirmed the first two repairs and found a remaining
Stop race: an aborted Stop(A) RPC could enter the generic owner-failure cleanup
after B had started, revoking B's scope. The same failure was reproduced at both
owner-validation await paths using the actual source binding. Exact-18377 source
with the new 18-case scope suite yields 16 passed / 2 failed; evidence is retained
in `/tmp/netcatty-global-agent-stop-rpc-exact183-before.log`.
The minimal follow-up distinguishes this request's abort/deadline from verified
owner or resource failure, rethrows the access failure, and leaves the existing
turn-capture rule intact. Cancellation of Stop(A) cannot revoke B. A separate
regression confirms that a verified source mismatch still aborts native work
even when the RPC is concurrently cancelled. Foreign caller protections remain.
The full foundation/permission group passes 26 tests on Node 22.23.3 and Node
24.19.0; focused lint and diff checks pass. Logs:
`/tmp/netcatty-global-agent-stop-rpc-final.log` and
`/tmp/netcatty-global-agent-stop-rpc-node22.log`. Independent recheck is pending.
The authorized original-head Linux rerun `122048349748` ended cancelled, with an
operation-cancelled log and no assertion failure. No second rerun was requested.
At 18377, test workflow `40462595842`, Linux job `122050816182`, also received an
explicit runner shutdown; `PluginLogSettings.test.tsx` was SIGKILLed immediately
afterward. That interrupted stage reports 34 passed / 1 failed, not a complete
repository total or an assertion failure. Both Windows jobs passed. No blind
rerun of that job was made. Package workflow `40462595846` was still running when
checked. These records are not substituted for exact-head green CI.
## Planned public contract and adapters
The proposed feature is `ai.agent-sessions`, with `ai.agent.use` for conversation
operations and `ai.agent.actions` for actual Agent tool actions. Permission mode
comes from the existing source-bound Allow/Ask/Deny engine. These are additive
names, separate from the existing tools-disabled `ai.invoke` broker.
The API will provide backend listing, explicit target selection, owned chat
listing/opening, sending, bounded event reads, history, Stop and close. Backend
references are opaque host-issued configuration revisions. Conversation references
identify only histories owned by the calling source/private-owner; runtime handles
are separate and cannot substitute builtin chat IDs or native SDK resume IDs.
Messages cannot supply permissionMode, CLI/env/MCP configuration or owner fields.
Core will reuse `AISession`, `AgentRuntime`, both existing turn drivers, event and
compaction handling, SDK resume identity validation, and `stopAgentTurn`. The first
eligible backends are Catty and configured Claude Code in MCP mode. Other backend
entries must return explicit unsupported/unconfigured status until their native
tool and approval boundaries are verified. Codex SDK's current unrestricted native
configuration must not be treated as eligible merely because it has a driver.
The terminal worker already exposes a suitable main-owned resource binding:
`getPluginSessionBinding(sessionId, ownerWebContentsId)` returns worker epoch and
session generation. Use this for dispatch-time targets; do not use menu IDs,
focused tab, display labels, or a renderer-provided session ID as authority.
The MCP dispatcher and Catty direct execution route need the same delegated
session guard. A delegated call must not update process-global permission mode,
or request both an Agent approval and a plugin approval for the same action.
Policy changes, disable/restart/source replacement, lock and host disconnection
must cancel scoped native work and invalidate outgoing event tickets. Existing
Agent trace and plugin audit/log facilities will receive redacted correlation
metadata, not credentials, provider diagnostics or full unreviewed tool payloads.
## Remaining acceptance gates
- Canonical schema/SDK/runtime registration and the installed default host path.
- Host-owned durable history and backend references, including backend switching.
- Actual Catty and Claude MCP dispatch, policy and worker lifecycle regressions.
- Synthetic streaming/cancellation fixtures; these are not real inference or
actual authenticated Claude service acceptance.
- Installable minimal chat UI with backend and explicit target choice.
- Standard CI, independent review and parent-scheduled native evidence. No GUI
ownership, merge or release is granted to this executor.
@@ -1,8 +1,8 @@
# Host and Vault actions
Feature: `vault.host-actions`. The SDK registers three methods under
`context.vault`. Netcatty owns the picker, field-diff review, permission routing,
and normal connection dispatch. Plugin business logic stays in the offline browser sandbox.
`context.vault`. Netcatty owns the picker, field-diff review, and connection
confirmation. Plugin business logic stays in the offline browser sandbox.
Base: source integration #3981 at
`4213d8e6726c44e7a479418ece3adf16ea494782`.
@@ -36,12 +36,10 @@ remain behind the typed target reference; its revision is the expected revision.
Group metadata describes the explicitly selected current member handles. The
grant never expands when another host joins a group.
Selection shares the chosen metadata only. Later edit and connection requests
follow the plugin's Allow, Ask, or Deny settings. Draft saves retain the mandatory
host editor. Connection Ask displays the saved target and whether saved scripts
can run; Allow can dispatch without that permission-only dialog. Normal SSH
authentication remains unchanged. These actions grant no terminal input, output,
or session access.
Selection grants metadata access only. Each draft save and each connection
requires its own host-owned confirmation. A connection confirmation displays the
saved target and whether saved scripts can run. It does not grant terminal input,
terminal output, or session access.
All requests are bounded and cancel on runtime stop, source replacement, revoke,
Vault lock, owner window replacement, expiry, or user cancellation. The host
@@ -49,7 +47,7 @@ revalidates authority and expected revision after asynchronous work and before
the Vault write or connection dispatch.
Bounds reuse `HostInteractionLimits`: one pending request per runtime,
eight total, ten per minute, a 60-second host-interaction deadline, and a 64-KiB
eight total, ten per minute, a 60-second confirmation deadline, and a 64-KiB
request ceiling. Selection adds a maximum of 50 current hosts. Titles use the
existing 128-character single-line limit. Draft field lengths use
`ImporterHostDraft`. Unknown properties are rejected by the shared schema.
@@ -88,16 +86,15 @@ It revalidates after asynchronous authentication. AI selected-context handles ar
text-disclosure handles and cannot target these actions.
The permission engine gates selection with `vault.metadata`, drafts with
`vault.write`, and connection requests with `ui.interact`. Each committed save or
connection uses a signed one-use action claim. Permission settings control the
permission-only prompt; an Allow setting does not bypass the draft editor or
normal SSH authentication.
`vault.write`, and connection requests with `ui.interact`. Every save or connection
also requires a separate host-owned confirmation and a signed one-use action
claim. A stored permission grant cannot bypass this review.
After review, the renderer prepares the existing transaction or connection
request. It claims action authority after the final asynchronous preparation,
immediately before persistence or dispatch. This claim is the action's commit
point. Completion only acknowledges the committed action. Automation changes
during an Ask connection review require fresh confirmation.
during connection review require fresh confirmation.
The connection label uses the same `resolveEffectiveTerminalProtocol` helper as
the launcher, including Mosh/ET flags and implicit localhost shells. Local
execution displays an explicit on-device shell notice and omits the remote SSH
+7 -13
View File
@@ -3,10 +3,8 @@
The plugin manager has Installed, Official marketplace and Third-party marketplace
pages. Listing a plugin in the official market does not certify it or establish
that Netcatty maintains it. Remote manifests, publisher names, source names and
README content cannot create a host-issued official badge. The separate gold
maintainer badge requires the built-in official source and an exact host-curated
first-party ID/repository pair. It describes maintenance, not safety certification.
README text and images remain author content.
README content cannot create a host-issued official badge. This revision does not
issue maintainer badges. README text and images remain author content.
## Add a source
@@ -35,24 +33,20 @@ manifest, compatibility, browser-runtime admission and archive validation still
apply. Third-party source consent is not permission consent.
Delivery v1 is unchanged and source-only. Delivery **v2** has the same fields and
strict schema as v1, with `schemaVersion: 2`. Each plugin's `artifact` can be null
or a package reference:
strict schema as v1, with `schemaVersion: 2` and one extension: each plugin's
`artifact` can be null or a package reference:
```json
{"path":"/assets/<64-lowercase-hex-sha256>.ncpkg","sha256":"<same-sha256>","size":12345}
```
V2 delivery packages are at most 16 MiB. The catalog entry must contain
Packages are at most 32 MiB. The catalog entry must contain
`artifact: {url: "https://plugins.example.com/assets/<same-sha256>.ncpkg", sha256}`.
The index's current, non-withdrawn version must exactly equal the catalog entry
and use `distribution: "release"`. Source-only entries retain null delivery
artifacts and `distribution: "source-only"`. The reserved `avatar` can also be
null or a same-origin `/assets/<sha256>.png` or `.webp` reference, with a positive
size no greater than 256KiB. The host verifies and rasterizes its bytes through
the existing image infrastructure. Missing or invalid imagery uses a placeholder.
Remote README embeds are not loaded. README and PNG/WebP references retain the v1 bounds.
artifacts and `distribution: "source-only"`. Avatars remain null; remote README
embeds are not loaded. README and PNG/WebP references retain the v1 bounds.
Existing official bootstrap entries are not changed or made installable.
See [author presentation](author-identity.md) for subject and maintenance authority.
Before every install, refresh and validate the entire snapshot again. A changed
selection, withdrawal, unavailable source, altered hash or size prevents install;
@@ -85,32 +85,6 @@ initial-checkpoint evidence.
## Latest checks and remaining environment blockers
- The native candidate `2433cf0130d1c3274c56c39e5765349c2945de01` retains the
same idle logging-row marker as `89beeb9` (source blob
`4dd5a7cea4ae5a6addc5db5c162038d8fa44075c`). Actual Settings landing plus the
existing observer reproduces native-child suppression with no dialog open.
This source defect is separate from the worker's earlier GPU exit and does
not alone establish the cause of the recorded blank native body.
- The scoped correction removes only the idle-row marker. Actual logging and
Ask dialogs remain marked. A real nested verbose confirmation test also
reproduced premature restoration when its parent became accessibility-hidden;
that confirmation now opts into the existing marker on its own dialog content.
The observer, window trust, permissions and data access are unchanged. The real
landing regression verifies idle visible, each security dialog hidden, and
restored on dismissal. Focused group **six passed**; Settings boundary group
**36 passed**. Both pre-fix failures are retained outside the source tree.
Production build and full lint pass (zero lint errors/seven existing warnings).
Full TypeScript remains baseline-equivalent at 774 diagnostics with zero
added/removed. Exact-head CI and the next frozen native recheck are separate
gates; neither is inferred from these local tests.
- QA-only **Settings Ask QA 0.1.0** uses its supported `location: settings` entry
and public SDK to read only its synthetic own setting after an explicit click.
Exact archive SHA-256:
`9502a58a4804862e4b13de9fc0d1baf6ad8c2bc681c08b5c0664b552ef0aafc4`.
Its HTML/CSS/JS each return 200 with proper MIME/CSP through the actual plugin
protocol. It contains no raw IPC/test hook or external access. No GUI or running
app was changed by this correction; parent will build a new frozen candidate
after current cleanup and repeat native Settings acceptance.
- A registered regression reproduces `Plugin permission decision provider failed`
on exact `79a9bd6` when a Settings-owned view activates a real `settings.get`
API configured Ask while main is unfocused. The follow-up uses the existing
+1 -8
View File
@@ -29,13 +29,6 @@ for brand icons. This v1 publishing schema requires **avatar and artifact to
be null**. The client rejects unsupported delivery metadata rather than
inventing installable packages or deriving a GitHub avatar URL.
Delivery v2 retains those fields and admits hash-bound packages plus the reserved
avatar as null or a positive-size PNG/WebP asset reference of at most 256KiB.
The host fetches only the selected source's same-origin asset, verifies bytes,
and reuses its bounded icon rasterizer and cache. The renderer receives a PNG
data URI. Author imagery does not verify a GitHub account, and no GitHub avatar
URL is derived. See [author presentation](author-identity.md).
Index is validated with its pinned schema. Plugin/version identities must be
unique; each catalog entry must exactly equal the index's current, non-withdrawn,
source-only entry. Withdrawal does not disable, uninstall or delete any existing
@@ -80,7 +73,7 @@ exact SHA-256 bytes; it does not relax those future gates or auto-enable code.
| README details | Exact delivery `/assets/<hash>.md` | Visible error/Retry, no upstream fallback |
| Uninstalled brand icons | Existing reviewed bundled previews | Default icon; delivery icon metadata validated but not fetched by UI |
| Installed brand icons | Host-owned local package decoder | Default icon; no activation/network request |
| Author avatars | Visible-row host fetch of a validated v2 same-origin resource; v1 remains null | Default placeholder; no GitHub lookup or renderer URL fetch |
| Author avatars | Not implemented; delivery v1 requires null | Existing local presentation; no avatar lookup |
| Package artifacts | Not published or admitted by delivery v1 | Source-only; no download action fabricated |
| Source/README links | Explicit user-opened external HTTPS link | May open GitHub; no background dependency |
@@ -47,9 +47,6 @@ focus handoff is needed. Close, cancel, timeout, lock, hide/blur, reload, crash
and stale runtime/source fail closed. The dialog uses the existing host security
surface marker so attached native plugin views yield while it is visible and
restore through their existing lifecycle on dismissal.
Ordinary landing rows do not suspend native views. The logging dialog and its
verbose confirmation are marked security surfaces; the confirmation opts into
the existing marker because it temporarily accessibility-hides its parent dialog.
A plugin DOM click never becomes a host gesture. Choosers, drafts, terminal and
clipboard sharing use their existing host-owned action tokens. Ask integrates
with that workflow instead of opening a second capability dialog. Allow skips
@@ -1,140 +0,0 @@
# Settings visibility and current-main acceptance candidate
This isolated branch, `integration/plugin-settings-main-acceptance`, starts at
frozen #4004 `2433cf0130d1c3274c56c39e5765349c2945de01`. Its Draft PR and private
renderer provenance record the final exact head and CI. Frozen2433 source,
artifact, QA workspace/profile and earlier evidence remain intact.
## Inputs and production ownership
Both inputs were ordinarily merged, without conflicts or manual production edits:
| Input | Import | Production files and behavior |
| --- | --- | --- |
| Current main `a0cf2b487c9ff0b0472d622e66bc5ee20de32c71` | Merge `c4aedfa9ed8c9f2647ac26f0e6e144edab341c2d` | `domain/workspaceAiScopeHandoff.ts` keeps inherited AI chat on a surviving owner when a workspace dissolves (#4003/e3e2dcfe); `electron/bridges/ai/shellUtils.cjs` narrowly recognizes Oh My Zsh ys two-line idle prompts (#4008/a0cf2b48) |
| Policy `1551fa8355021b5a811d737d16f81e8070f99f5d` | Merge `714e2397a43dc6c083dce8afeeb7a0475de11384` | `PluginLogSettings.tsx` removes the idle-row security marker and opts the nested verbose confirmation into it; `confirm-dialog.tsx` adds the optional host-only marker with default false |
All four production files exactly match their respective owners. Main has only
those two new commits since common base f0897c3c. Neither changed file overlaps
the prior plugin changes. The new prompt utility is not imported by plugin
production modules; plugin sensitive-input, selection and source guards remain
unchanged. Existing prompt and live-PTY-byte suffix tests still reject bare
markers and pending input. These are synthetic tests, not a new native SSH pass.
The input also owns its real Settings landing regression and documentation.
The existing observer, Ask marker, IPC/window authority, sandbox, resource/source
controls, AI d33 wiring, schemas, sample bytes and package/lock inputs are
unchanged. `ModelSelector.tsx` equals2433: separate #4002 is not included. Global
Agent/custom Chat #4006 and terminal diagnostic #3999 are excluded. No main merge,
Ready transition, original-PR closure or release is part of this handoff.
The parent reports the1551-vs89 independent delta review scoped clean: idle
Settings no longer requests suspension, the real logging dialog remains marked,
its nested verbose portal protects itself while the parent is accessibility-
hidden, closing the nested dialog leaves the parent protected, and closing all
dialogs permits restoration. This source-only result is not native resolution
of the recorded Settings blank body.
## Frozen2433 native results
The parent supplied these results; this coding executor did not run the GUI:
| Case | Result on2433 |
| --- | --- |
| Main Ask visible | Passed |
| Cancel | Passed; zero provider POSTs |
| Accept | Passed; one provider POST |
| Stream chunks | Passed; no repeated permission prompts |
| Content Deny | Passed |
| Real five-minute chat expiry | Passed |
| Settings sample body | Blank; not accepted |
The idle-row suppression defect is source-confirmed. The correction requires a
new native Settings recheck; no native success is inferred from the DOM regression.
A later GPU exit9 and incomplete cleanup were reported separately. Its cause is
unclassified; no source hotpatch or sandbox/GPU workaround was used here.
Consumer verification of2433's Library renderer is complete: the parent checked
the matching full source/backend and all1001 renderer hashes, with native inputs
unchanged. The earlier producer download failure remains historical evidence,
not an outstanding consumer transfer blocker. That verified artifact still
certifies2433 only; it does not include1551 or the new main renderer behavior.
Earlier020 Default Allow cold Send/completion, Stop/Deny/restore and themes,
677 Host core, and corrected-fixture terminal solo/split results remain scoped
historical evidence. Terminal Focus is unreliable/unverified and QA sent one
Enter inA during a palette attempt: no global zero-input/full-terminal-pass claim.
The historical Rfam attempt failed socket transport before connect resolved;
authentication/SELECT were not reached and cause/auth/TLS compatibility remain
unverified. Final source-policy, DB, placement/lifecycle and actual hosting/CDN
gates remain separately reconciled by the parent.
## Local checks and sample provenance
| Check | Result |
| --- | --- |
| Registered plugin runtime | 695 passed, one native skip; zero failed/cancelled |
| Contract/SDK/CLI | 91 passed |
| Offline examples | Five passed, one Docker opt-in skip |
| Changed main owner/scope/prompt/suffix and terminal lifecycle group | 139 passed |
| Actual mounted AI/permission/selected-context/Host group | 21 passed |
| Settings boundary and security visibility group | 38 passed |
| Contract/capability/Codex generation and committed drift | Clean |
| Lint | Zero errors, seven existing warnings |
| Five sample archives | Validated, built twice and byte-identical to2433 |
The final exact-head normal production build, renderer inventory, bounded
credential check, CI and Codex review are recorded in the Draft PR/artifact
handoff. Local full-suite, native packaging and GUI success are not claimed.
Locked dependencies, standard patches and the official pinned Electron binary
were prepared normally; no header, HOME, sandbox or native shim workaround.
[Exact sample IDs, permissions/resources and hashes](settings-main-packages.json)
preserve all prior provenance, including Assistant7943d4c3, ownedDBf01c6a24,
Rfamaaca5866, Hostcf04f82d and terminal4c9c2388. Whole archive equality was
asserted, rather than inferred from unchanged source. The owned10.77.0.20 endpoint
is packaging-only, not a running fixture. No package was activated or service
queried by this executor.
The owner's separate QA-only Settings Ask sample has reported SHA-256
`9502a58a4804862e4b13de9fc0d1baf6ad8c2bc681c08b5c0664b552ef0aafc4`.
It is not one of these five repository examples and was not rebuilt here. The
native owner must retain its exact verified manifest/source/permissions and
fixture provenance; no package ID, permission expansion or hash equivalence is
invented by this integration.
## Native preparation and bounded recheck
1. After the parent's GUI/Nginx slot is released, use a NEW isolated checkout of
the final exact head and a separate synthetic QA profile. Preserve the old2433
workspace/profile and recordings. Materialize the new private Library artifact
in the consumer's own workspace and verify archive and every renderer hash.
2. Use the complete matching backend checkout: frontend-only dist does not carry
the CJS prompt helper or native dependencies. Match package/lock hashes to
independently normally rebuilt native inputs and prepare workspace packages
normally. Install dist as a whole; use the ordinary Electron launch without
Vite and with normal sandbox/helper settings. This is unpackaged native
production-renderer QA, not packaged-binary certification.
3. Open the verified Settings sample through its existing supported settings
entry. Check the idle body is visible/clickable with the logging row present.
Open logging and the nested verbose confirmation separately; native content
must yield to each, remain hidden after cancelling the nested confirmation,
and restore only after the parent closes. Do not enable real verbose logging
or collect real content for this check.
4. In the ready/focused/visible/unlocked Settings owner, exercise its synthetic
own-setting Ask operation. Record visible Ask, explicit cancel/accept, exact
current operation outcome and restoration. Recheck main Ask briefly. Preserve
the2433 main/content/expiry counters and any unrun matrix cases separately.
5. Use owned synthetic A/B AI chats: form a workspace, prefer B, dissolve while A
survives and confirm both histories stay with their owners. Remove A and
check bounded fallback to B. Confirm handoff alone does not invoke a model or
re-share content. Exercise selected-target invalidation across the transition
with inert drafts and no PTY input. Do not treat earlier Focus uncertainty as
resolved by this main domain fix.
6. Record exact source/artifact/sample identities, screenshots/video, request
counters, cleanup and unrun cases. No fallback endpoint/probe, real credentials,
paid API, security relaxation or unrecognized installation is authorized here.
If main advances after this pinned a0cf snapshot, compare the new delta and assess
its affected checks before landing. The current candidate deliberately does not
chase unrelated changes while native preparation is in progress.
@@ -1,217 +0,0 @@
[
{
"name": "assistant",
"id": "com.netcatty.assistant",
"version": "0.1.0",
"fileCount": 6,
"bytes": 17214,
"sha256": "7943d4c3800914fe0acd3d0f3a3d7f7c1910c212a5dc58a044871a6a82485274",
"contentSha256": "c3e31e9613422bf9a05ccaa5b6f234304208f1a8298b1c1ee19262ba88af6cd0",
"features": {
"required": [
"ai.model-invocation",
"context.selected-read"
]
},
"permissions": {
"required": [
"commands",
"views"
],
"optional": [
"ai.invoke",
"context.selected.list",
"context.selected.read"
]
},
"package": "assistant.ncpkg",
"repeatIdentical": true,
"preparedOnlyInThisExecutor": true,
"previousFrozen677Sha256": "c7d784d5c3bd2feb463afd58eae3a9594615395b168174cb8a3d0677584ae395",
"changedEntriesFromFrozen677": [
"README.md",
"panel.css",
"panel.js"
],
"previous7856Sha256": "7943d4c3800914fe0acd3d0f3a3d7f7c1910c212a5dc58a044871a6a82485274",
"changedEntriesFrom7856": [],
"rebuiltAgainstPolicy": "1551fa8355021b5a811d737d16f81e8070f99f5d",
"rebuiltAgainstAI": "d33c0193ccaedc1a10aed9da495f040250121bef",
"previous020Sha256": "7943d4c3800914fe0acd3d0f3a3d7f7c1910c212a5dc58a044871a6a82485274",
"changedEntriesFrom020": [],
"rebuiltAgainstMain": "a0cf2b487c9ff0b0472d622e66bc5ee20de32c71",
"productionMerge": "714e2397a43dc6c083dce8afeeb7a0475de11384",
"previous2433Sha256": "7943d4c3800914fe0acd3d0f3a3d7f7c1910c212a5dc58a044871a6a82485274",
"changedEntriesFrom2433": []
},
{
"name": "database-owned",
"id": "com.netcatty.database-minimal",
"version": "0.1.0",
"fileCount": 5,
"bytes": 11660,
"sha256": "f01c6a24382897d4b0bf4130941274abacb9a9a963f9b56ecaceb2c020f36874",
"contentSha256": "4d628c41c7a231e31cd56dacb822acaaa37f61e1e98b285b0086a4f783be199d",
"features": {
"required": [
"network.byte-streams"
]
},
"permissions": {
"required": [
"commands",
"views"
],
"optional": [
{
"permission": "network.stream",
"resources": [
"tcp://10.77.0.20:3306"
],
"reason": "Connect only to the prepared disposable fixture."
}
]
},
"package": "database-owned.ncpkg",
"repeatIdentical": true,
"preparedOnlyInThisExecutor": true,
"previousFrozen677Sha256": "f01c6a24382897d4b0bf4130941274abacb9a9a963f9b56ecaceb2c020f36874",
"changedEntriesFromFrozen677": [],
"previous7856Sha256": "f01c6a24382897d4b0bf4130941274abacb9a9a963f9b56ecaceb2c020f36874",
"changedEntriesFrom7856": [],
"rebuiltAgainstPolicy": "1551fa8355021b5a811d737d16f81e8070f99f5d",
"rebuiltAgainstAI": "d33c0193ccaedc1a10aed9da495f040250121bef",
"previous020Sha256": "f01c6a24382897d4b0bf4130941274abacb9a9a963f9b56ecaceb2c020f36874",
"changedEntriesFrom020": [],
"rebuiltAgainstMain": "a0cf2b487c9ff0b0472d622e66bc5ee20de32c71",
"productionMerge": "714e2397a43dc6c083dce8afeeb7a0475de11384",
"previous2433Sha256": "f01c6a24382897d4b0bf4130941274abacb9a9a963f9b56ecaceb2c020f36874",
"changedEntriesFrom2433": []
},
{
"name": "database-rfam-readonly",
"id": "com.netcatty.database-rfam-readonly",
"version": "0.1.0",
"fileCount": 5,
"bytes": 11003,
"sha256": "aaca586669db454e91d61487e576ed524d1b7190eed182bc2cd0035e59caa0d3",
"contentSha256": "f542ca7b8e821d684cf7f71f0930458bd3ad97bc94bf654c88b0f353111e9162",
"features": {
"required": [
"network.byte-streams"
]
},
"permissions": {
"required": [
"commands",
"views"
],
"optional": [
{
"permission": "network.stream",
"resources": [
"tcp://mysql-rfam-public.ebi.ac.uk:4497"
],
"reason": "Run the fixed public Rfam acceptance query only after explicit authorization."
}
]
},
"package": "database-rfam-readonly.ncpkg",
"repeatIdentical": true,
"preparedOnlyInThisExecutor": true,
"previousFrozen677Sha256": "aaca586669db454e91d61487e576ed524d1b7190eed182bc2cd0035e59caa0d3",
"changedEntriesFromFrozen677": [],
"previous7856Sha256": "aaca586669db454e91d61487e576ed524d1b7190eed182bc2cd0035e59caa0d3",
"changedEntriesFrom7856": [],
"rebuiltAgainstPolicy": "1551fa8355021b5a811d737d16f81e8070f99f5d",
"rebuiltAgainstAI": "d33c0193ccaedc1a10aed9da495f040250121bef",
"previous020Sha256": "aaca586669db454e91d61487e576ed524d1b7190eed182bc2cd0035e59caa0d3",
"changedEntriesFrom020": [],
"rebuiltAgainstMain": "a0cf2b487c9ff0b0472d622e66bc5ee20de32c71",
"productionMerge": "714e2397a43dc6c083dce8afeeb7a0475de11384",
"previous2433Sha256": "aaca586669db454e91d61487e576ed524d1b7190eed182bc2cd0035e59caa0d3",
"changedEntriesFrom2433": []
},
{
"name": "host-actions",
"id": "com.netcatty.host-actions",
"version": "0.1.0",
"fileCount": 3,
"bytes": 4633,
"sha256": "cf04f82dfe25ab5cda6f5f3dad0a8fb06c37bedf9924f556bee2b8402e542ef2",
"contentSha256": "d6f8bf3ae9dc417ea838f673966f2e1075fc8f9ef1d939ca85b476b1bb007e6c",
"features": {
"required": [
"vault.host-actions",
"commands.interactive"
]
},
"permissions": {
"required": [
"commands",
"menus"
],
"optional": [
"vault.metadata",
"vault.write",
"ui.interact"
]
},
"package": "host-actions.ncpkg",
"repeatIdentical": true,
"preparedOnlyInThisExecutor": true,
"previousFrozen677Sha256": "cf04f82dfe25ab5cda6f5f3dad0a8fb06c37bedf9924f556bee2b8402e542ef2",
"changedEntriesFromFrozen677": [],
"previous7856Sha256": "cf04f82dfe25ab5cda6f5f3dad0a8fb06c37bedf9924f556bee2b8402e542ef2",
"changedEntriesFrom7856": [],
"rebuiltAgainstPolicy": "1551fa8355021b5a811d737d16f81e8070f99f5d",
"rebuiltAgainstAI": "d33c0193ccaedc1a10aed9da495f040250121bef",
"previous020Sha256": "cf04f82dfe25ab5cda6f5f3dad0a8fb06c37bedf9924f556bee2b8402e542ef2",
"changedEntriesFrom020": [],
"rebuiltAgainstMain": "a0cf2b487c9ff0b0472d622e66bc5ee20de32c71",
"productionMerge": "714e2397a43dc6c083dce8afeeb7a0475de11384",
"previous2433Sha256": "cf04f82dfe25ab5cda6f5f3dad0a8fb06c37bedf9924f556bee2b8402e542ef2",
"changedEntriesFrom2433": []
},
{
"name": "terminal-selection-draft",
"id": "com.netcatty.terminal-selection-draft",
"version": "0.1.0",
"fileCount": 2,
"bytes": 2954,
"sha256": "4c9c2388b7f891156192a0b5ab7b23291beba3555e7ec8cf24fbe2dfe1242d5e",
"contentSha256": "99797d8679f34b96580076f922cbec067e511adc0f4ccf396ed129206acb6c4c",
"features": {
"required": [
"terminal.selection-sharing",
"terminal.input-drafts",
"commands.interactive"
]
},
"permissions": {
"required": [
"commands",
"menus"
],
"optional": [
"terminal.selection.read",
"terminal.input.propose"
]
},
"package": "terminal-selection-draft.ncpkg",
"repeatIdentical": true,
"preparedOnlyInThisExecutor": true,
"previousFrozen677Sha256": "4c9c2388b7f891156192a0b5ab7b23291beba3555e7ec8cf24fbe2dfe1242d5e",
"changedEntriesFromFrozen677": [],
"previous7856Sha256": "4c9c2388b7f891156192a0b5ab7b23291beba3555e7ec8cf24fbe2dfe1242d5e",
"changedEntriesFrom7856": [],
"rebuiltAgainstPolicy": "1551fa8355021b5a811d737d16f81e8070f99f5d",
"rebuiltAgainstAI": "d33c0193ccaedc1a10aed9da495f040250121bef",
"previous020Sha256": "4c9c2388b7f891156192a0b5ab7b23291beba3555e7ec8cf24fbe2dfe1242d5e",
"changedEntriesFrom020": [],
"rebuiltAgainstMain": "a0cf2b487c9ff0b0472d622e66bc5ee20de32c71",
"productionMerge": "714e2397a43dc6c083dce8afeeb7a0475de11384",
"previous2433Sha256": "4c9c2388b7f891156192a0b5ab7b23291beba3555e7ec8cf24fbe2dfe1242d5e",
"changedEntriesFrom2433": []
}
]
+2
View File
@@ -3,6 +3,8 @@
// Presentation only. Admission and grants remain owned by the canonical schema
// and permission engine. Tests require exact coverage of its permission enum.
const PERMISSION_DESCRIPTIONS = {
"ai.agent.use": [true, "Agent conversations", "Use configured host Agents and read this plugin's own chat history. Model or Agent service usage may incur costs.", "Agent 会话", "使用宿主已配置的 Agent,并读取此插件自己的聊天历史;模型或 Agent 服务可能产生费用。"],
"ai.agent.actions": [true, "Agent actions", "Authorize Agent tools against explicitly selected targets under this plugin's policy. Commands can change remote systems.", "Agent 操作", "根据插件策略授权 Agent 工具操作明确选中的目标;命令可能更改远程系统。"],
storage: [false, "Plugin storage", "Save and read this plugin's local data.", "插件存储", "保存和读取此插件自己的本地数据。"],
"runtime.advanced": [true, "Full device access", "Run Node code with filesystem and network access outside broker restrictions. Developer mode only.", "完整设备访问", "运行 Node 代码,可直接访问文件系统和网络,不受代理权限限制。仅限开发者模式。"],
"settings.read": [false, "Read plugin settings", "Read this plugin's configured preferences.", "读取插件设置", "读取此插件自己的设置。"],
-35
View File
@@ -5,7 +5,6 @@ import en from "../application/i18n/locales/en.ts";
import zhCN from "../application/i18n/locales/zh-CN.ts";
import zhTW from "../application/i18n/locales/zh-TW.ts";
import { filterSettingsSearchCatalog } from "./settingsSearch.ts";
import { resolveSettingsPlatform } from "./settingsSearchCatalog.ts";
import { SETTINGS_SEARCH_CATALOG } from "./settingsSearchCatalog.ts";
const EN: Record<string, string> = {
@@ -153,37 +152,3 @@ test("Sync status entries retain syncSubTab for navigation", () => {
assert.ok(backups);
assert.equal(backups?.entry.syncSubTab, "status");
});
test("macOS-only settings stay out of search on other platforms", () => {
const blurId = "appearance-window-background-blur";
// Both a targeted query and the default (empty query) list must skip it,
// otherwise selecting the hit jumps to a tab with no anchor to focus.
const macHits = filterSettingsSearchCatalog("", tEn, { platform: "mac", limit: 500 });
assert.ok(macHits.some((hit) => hit.entry.id === blurId));
for (const platform of ["win", "linux"] as const) {
const searched = filterSettingsSearchCatalog("frosted glass", tEn, { platform });
assert.equal(searched.some((hit) => hit.entry.id === blurId), false);
const defaults = filterSettingsSearchCatalog("", tEn, { platform, limit: 500 });
assert.equal(defaults.some((hit) => hit.entry.id === blurId), false);
}
});
test("platform-gated entries never hide cross-platform settings", () => {
for (const platform of ["mac", "win", "linux"] as const) {
const hits = filterSettingsSearchCatalog("opacity", tEn, { platform });
assert.ok(
hits.some((hit) => hit.entry.id === "appearance-window-opacity"),
`window opacity must stay searchable on ${platform}`,
);
}
});
test("resolveSettingsPlatform maps Electron-style platform strings", () => {
assert.equal(resolveSettingsPlatform("MacIntel"), "mac");
assert.equal(resolveSettingsPlatform("Win32"), "win");
assert.equal(resolveSettingsPlatform("Linux x86_64"), "linux");
assert.equal(resolveSettingsPlatform(""), "linux");
});
-9
View File
@@ -1,9 +1,6 @@
import { matchesSearchQuery } from "../lib/searchMatcher";
import {
SETTINGS_SEARCH_CATALOG,
isSettingsEntryAvailableOn,
resolveSettingsPlatform,
type SettingsPlatform,
type SettingsSearchEntry,
type SettingsTabId,
} from "./settingsSearchCatalog";
@@ -64,22 +61,16 @@ export function filterSettingsSearchCatalog(
catalog?: readonly SettingsSearchEntry[];
includePlugins?: boolean;
limit?: number;
/** Platform whose settings UI will receive the jump; defaults to the host. */
platform?: SettingsPlatform;
},
): SettingsSearchHit[] {
const catalog = options?.catalog ?? SETTINGS_SEARCH_CATALOG;
const includePlugins = options?.includePlugins ?? true;
const limit = options?.limit ?? 40;
const platform = options?.platform ?? resolveSettingsPlatform();
const trimmed = query.trim();
const hits: SettingsSearchHit[] = [];
for (const entry of catalog) {
if (!includePlugins && entry.tab === "plugins") continue;
// Platform-gated settings are hidden from search where their anchor is not
// rendered, so a hit always has a real control to focus.
if (!isSettingsEntryAvailableOn(entry, platform)) continue;
const hit = buildSettingsSearchHit(entry, t);
if (!trimmed) {
hits.push(hit);
-35
View File
@@ -31,8 +31,6 @@ export const SYNC_SETTINGS_SUB_TAB_IDS = ["providers", "status"] as const;
export type SyncSettingsSubTabId = (typeof SYNC_SETTINGS_SUB_TAB_IDS)[number];
export type SettingsPlatform = "mac" | "win" | "linux";
export type SettingsSearchEntry = {
/** Stable id; also used as `data-settings-anchor` on the target element. */
id: string;
@@ -49,33 +47,8 @@ export type SettingsSearchEntry = {
sectionKey?: string;
/** Extra English/Chinese aliases that are not full i18n strings. */
keywords?: readonly string[];
/**
* Platforms where the destination actually renders. Omit for cross-platform
* entries. Entries must not be offered on a platform whose settings UI hides
* the anchor, otherwise search jumps to a tab with nothing to highlight.
*/
platforms?: readonly SettingsPlatform[];
};
/** Resolve the host platform in the same terms the settings UI branches on. */
export function resolveSettingsPlatform(
platform?: string,
): SettingsPlatform {
const value = platform
?? (typeof navigator !== "undefined" ? navigator.platform : "");
if (/mac|iphone|ipad/i.test(value)) return "mac";
if (/win/i.test(value)) return "win";
return "linux";
}
export function isSettingsEntryAvailableOn(
entry: SettingsSearchEntry,
platform: SettingsPlatform,
): boolean {
if (!entry.platforms?.length) return true;
return entry.platforms.includes(platform);
}
export const SETTINGS_SEARCH_CATALOG: readonly SettingsSearchEntry[] = [
// Application
{
@@ -135,14 +108,6 @@ export const SETTINGS_SEARCH_CATALOG: readonly SettingsSearchEntry[] = [
descriptionKey: "settings.appearance.windowOpacity.desc",
keywords: ["transparency", "透明"],
},
{
id: "appearance-window-background-blur",
tab: "appearance",
labelKey: "settings.appearance.windowBackgroundBlur",
descriptionKey: "settings.appearance.windowBackgroundBlur.desc",
keywords: ["blur", "frosted glass", "vibrancy", "模糊", "毛玻璃"],
platforms: ["mac"],
},
{
id: "appearance-theme",
tab: "appearance",
-19
View File
@@ -1,19 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
isWindowBackgroundBlurActive,
} from './windowBackgroundBlur';
test('window background blur is visible only with a translucent surface', () => {
assert.equal(isWindowBackgroundBlurActive(true, 1), false);
assert.equal(isWindowBackgroundBlurActive(true, 0.99), true);
assert.equal(isWindowBackgroundBlurActive(true, 0.5), true);
assert.equal(isWindowBackgroundBlurActive(true, Number.NaN), false);
});
test('window background blur becomes inactive without losing the request state', () => {
assert.equal(isWindowBackgroundBlurActive(true, 0.85), true);
assert.equal(isWindowBackgroundBlurActive(true, 1), false);
assert.equal(isWindowBackgroundBlurActive(false, 0.85), false);
});
-6
View File
@@ -1,6 +0,0 @@
export function isWindowBackgroundBlurActive(
requested: boolean,
windowOpacity: number,
): boolean {
return requested && Number.isFinite(windowOpacity) && windowOpacity < 1;
}
+5 -45
View File
@@ -108,7 +108,7 @@ test('handoffDissolvedWorkspaceAIScope retargets inherited chat when its pane is
});
});
test('handoffDissolvedWorkspaceAIScope keeps inherited chat on its surviving owner pane', () => {
test('handoffDissolvedWorkspaceAIScope retargets inherited chat when original pane also survives', () => {
const result = handoffDissolvedWorkspaceAIScope({
activeSessionIdMap: {
'workspace:ws-1': 'chat-a',
@@ -132,52 +132,12 @@ test('handoffDissolvedWorkspaceAIScope keeps inherited chat on its surviving own
preferredTerminalId: 'term-b',
});
assert.equal(result.activeSessionIdMap['terminal:term-a'], 'chat-a');
assert.equal(result.activeSessionIdMap['terminal:term-b'], 'chat-b');
assert.equal(result.sessions[0]?.scope.type, 'terminal');
assert.equal(result.sessions[0]?.scope.targetId, 'term-a');
assert.equal(result.sessions[0]?.updatedAt, 1);
assert.equal(result.sessions[1]?.scope.targetId, 'term-b');
assert.deepEqual(result.panelViewByScope['terminal:term-a'], {
mode: 'session',
sessionId: 'chat-a',
});
assert.equal(result.panelViewByScope['terminal:term-b'], undefined);
});
test('handoffDissolvedWorkspaceAIScope keeps owner history when the other pane is preferred', () => {
const result = handoffDissolvedWorkspaceAIScope({
activeSessionIdMap: {
'workspace:ws-1': 'chat-a',
'terminal:term-b': 'chat-b',
},
sessions: [
{
id: 'chat-a',
scope: { type: 'terminal', targetId: 'term-a', hostIds: ['host-a'] },
updatedAt: 1,
},
{
id: 'chat-b',
scope: { type: 'terminal', targetId: 'term-b', hostIds: ['host-b'] },
updatedAt: 1,
},
],
workspaceId: 'ws-1',
terminalIds: ['term-a', 'term-b'],
preferredTerminalId: 'term-b',
panelViewByScope: {
'workspace:ws-1': { mode: 'session', sessionId: 'chat-a' },
},
});
assert.equal(result.changed, true);
assert.equal(result.activeSessionIdMap['terminal:term-a'], 'chat-a');
assert.equal(result.activeSessionIdMap['terminal:term-b'], 'chat-b');
assert.equal(result.sessions[0]?.scope.targetId, 'term-a');
assert.equal(result.sessions[1]?.id, 'chat-b');
assert.equal(result.activeSessionIdMap['terminal:term-b'], 'chat-a');
assert.equal(result.activeSessionIdMap['terminal:term-a'], null);
assert.equal(result.sessions[0]?.scope.targetId, 'term-b');
assert.equal(result.sessions[1]?.scope.targetId, 'term-b');
assert.deepEqual(result.panelViewByScope['terminal:term-a'], {
assert.deepEqual(result.panelViewByScope['terminal:term-b'], {
mode: 'session',
sessionId: 'chat-a',
});
+19 -31
View File
@@ -78,15 +78,13 @@ export function seedWorkspaceAIActiveSessionFromMembers(input: {
}
/**
* When a workspace tab dissolves, copy its active chat onto a surviving
* terminal before the workspace scope key is pruned. An inherited chat that
* is already terminal-scoped to a pane still in `terminalIds` stays on that
* owner, even when focus/preferred is a different survivor. Otherwise the
* target is the preferred survivor, or the first remaining terminal when the
* original pane is gone. Workspace-scoped chats are reminted onto that
* target, duplicate terminal map entries for the same chat are cleared, and
* a session panel view is seeded so terminal scopes do not fall back to a
* blank draft.
* When a workspace tab dissolves, copy its active chat onto the preferred
* surviving terminal before the workspace scope key is pruned. Also remint
* workspace-scoped chats and the handed-off active chat when it still lives
* under a different terminal (including one that also survives dissolve),
* clear that previous terminal selection so ownership filters do not hide the
* chat, and seed a session panel view so terminal scopes do not fall back to
* a blank draft.
*/
export function handoffDissolvedWorkspaceAIScope<T extends AISessionScopeHandoffLike>(input: {
activeSessionIdMap: AIActiveSessionIdMap;
@@ -101,7 +99,7 @@ export function handoffDissolvedWorkspaceAIScope<T extends AISessionScopeHandoff
panelViewByScope: Record<string, AIPanelViewHandoffLike>;
changed: boolean;
} {
const fallbackTerminalId = (
const preferredTerminalId = (
input.preferredTerminalId
&& input.terminalIds.includes(input.preferredTerminalId)
? input.preferredTerminalId
@@ -111,17 +109,6 @@ export function handoffDissolvedWorkspaceAIScope<T extends AISessionScopeHandoff
const workspaceKey = buildAIScopeKey('workspace', input.workspaceId);
const workspaceActive = input.activeSessionIdMap[workspaceKey];
const hasWorkspaceActive = typeof workspaceActive === 'string' && workspaceActive.length > 0;
const activeSession = hasWorkspaceActive
? input.sessions.find((session) => session.id === workspaceActive)
: undefined;
const survivingOwnerTerminalId = (
activeSession?.scope.type === 'terminal'
&& activeSession.scope.targetId
&& input.terminalIds.includes(activeSession.scope.targetId)
? activeSession.scope.targetId
: null
);
const handoffTargetTerminalId = survivingOwnerTerminalId ?? fallbackTerminalId;
const previousPanelViewByScope = (
input.panelViewByScope as Record<string, AIPanelViewHandoffLike> | undefined
) ?? {};
@@ -131,8 +118,8 @@ export function handoffDissolvedWorkspaceAIScope<T extends AISessionScopeHandoff
};
let mapChanged = false;
if (handoffTargetTerminalId && hasWorkspaceActive) {
const terminalKey = buildAIScopeKey('terminal', handoffTargetTerminalId);
if (preferredTerminalId && hasWorkspaceActive) {
const terminalKey = buildAIScopeKey('terminal', preferredTerminalId);
let map = nextMap;
let changed = false;
if (map[terminalKey] !== workspaceActive) {
@@ -158,23 +145,24 @@ export function handoffDissolvedWorkspaceAIScope<T extends AISessionScopeHandoff
let sessionsChanged = false;
const nextSessions = input.sessions.map((session) => {
if (!handoffTargetTerminalId) return session;
if (!preferredTerminalId) return session;
const isWorkspaceScoped = (
session.scope.type === 'workspace'
&& session.scope.targetId === input.workspaceId
);
// Keep an inherited chat on its owner when that pane survives. Remint
// only when the handoff target is a different terminal (original pane gone).
// Inherited member chats stay terminal-scoped under the original pane.
// Prefer the focused survivor even when that original pane also survives
// dissolve, otherwise B's history ownership filter excludes A's chat.
const isInheritedActiveChatOnOtherTerminal = (
hasWorkspaceActive
&& session.id === workspaceActive
&& session.scope.type === 'terminal'
&& Boolean(session.scope.targetId)
&& session.scope.targetId !== handoffTargetTerminalId
&& session.scope.targetId !== preferredTerminalId
);
if (!isWorkspaceScoped && !isInheritedActiveChatOnOtherTerminal) return session;
if (session.scope.type === 'terminal' && session.scope.targetId === handoffTargetTerminalId) {
if (session.scope.type === 'terminal' && session.scope.targetId === preferredTerminalId) {
return session;
}
@@ -184,7 +172,7 @@ export function handoffDissolvedWorkspaceAIScope<T extends AISessionScopeHandoff
scope: {
...session.scope,
type: 'terminal',
targetId: handoffTargetTerminalId,
targetId: preferredTerminalId,
},
updatedAt: Date.now(),
};
@@ -194,8 +182,8 @@ export function handoffDissolvedWorkspaceAIScope<T extends AISessionScopeHandoff
const nextPanelViewByScope: Record<string, AIPanelViewHandoffLike> = {
...previousPanelViewByScope,
};
if (handoffTargetTerminalId && hasWorkspaceActive) {
const terminalKey = buildAIScopeKey('terminal', handoffTargetTerminalId);
if (preferredTerminalId && hasWorkspaceActive) {
const terminalKey = buildAIScopeKey('terminal', preferredTerminalId);
const workspacePanelView = previousPanelViewByScope[workspaceKey];
const terminalPanelView = previousPanelViewByScope[terminalKey];
const nextView: AIPanelViewHandoffLike = (
+10 -28
View File
@@ -71,17 +71,9 @@ const POSIX_PROMPT_PATTERN = /^(?:[^\s@]+@[^\s:]+(?::[^\n\r]*)?|\[[^\s@\]]+@[^\s
// Host line of a two-line bash PS1 (`\u@\h: \w` then a following `$` or `#`).
// Same user@host[:path] class as POSIX_PROMPT_PATTERN's first alternative,
// without a prompt marker on this line. Oh My Zsh `ys` uses a different host
// line (`# user @ host ...`), matched separately below. A bare `$`/`#` alone
// is not a prompt.
// without a prompt marker on this line. A bare `$`/`#` alone is not a prompt.
const POSIX_PROMPT_HOST_LINE_PATTERN = /^[^\s@]+@[^\s:]+(?::[^\n\r]*)?$/;
// Oh My Zsh `ys` status line: a decorative `#`, then `user @ host` with
// spaces around `@`, then an optional remainder (directory, git, venv, time,
// exit code). Example: `# user @ host in ~ [12:34:56]`. Arbitrary text before
// a bare `$`/`#` still does not match.
const OH_MY_ZSH_YS_HOST_LINE_PATTERN = /^#\s+[^\s@]+\s+@\s+[^\s]+(?:\s+[^\n\r]+)?$/;
// Alpine's default ash PS1 omits the username: `\h:\w\$ ` -> `localhost:~# `.
// Require a hostname and a home/absolute path, excluding prompt delimiters
// within the path so partially entered commands do not count as idle prompts.
@@ -132,15 +124,12 @@ function extractTrailingIdlePrompt(output) {
return lastLine;
}
// Two-line PS1. Classic bash is `export PS1="\u@\h: \w\n$ "`. Oh My Zsh
// `ys` paints `# user @ host ...` and then a bare `$` or `#`. Bash emits
// the newline as CR and the PTY's ONLCR adds another, so the bytes are
// `\r\r\n`. The CR-to-LF normalize above turns that into blank lines
// between the host line and `$`/`#`. Keep those blanks in the cache:
// getFreshIdlePrompt uses the same normalize, and hasExpectedPromptSuffix
// compares this CR-as-LF form against live PTY bytes. A lone `$`/`#` must
// not satisfy endsWith; the previous non-empty line has to be a recognized
// host line (classic `user@host[:path]` or Oh My Zsh `ys`).
// 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+$/, "") === "") {
@@ -148,13 +137,7 @@ function extractTrailingIdlePrompt(output) {
}
const previousLine = hostIndex >= 0 ? lines[hostIndex] : "";
const previousRightTrimmed = previousLine.replace(/\s+$/, "");
// Same optional "(name) " env prefix as isDefaultPosixPromptLine (#3947).
// Recognition only; the returned prompt keeps the visible prefix.
const previousHostLine = previousRightTrimmed.replace(/^\([^()\n\r]+\)\s+/, "");
if (
POSIX_PROMPT_HOST_LINE_PATTERN.test(previousHostLine)
|| OH_MY_ZSH_YS_HOST_LINE_PATTERN.test(previousHostLine)
) {
if (POSIX_PROMPT_HOST_LINE_PATTERN.test(previousRightTrimmed)) {
return `${lines.slice(hostIndex).join("\n")}\n${lastLine}`;
}
}
@@ -219,9 +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][#$]`, two-line classic `user@host[:path]` or Oh My Zsh
// `ys` (`# user @ host ...`) + `$`/`#`, 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`
-77
View File
@@ -262,83 +262,6 @@ test("trackSessionIdlePrompt and getFreshIdlePrompt accept a two-line user@host
assert.equal(getFreshIdlePrompt(ptySession), "user@host: /home/foo\n\n\n$ ");
});
test("extractTrailingIdlePrompt recognizes an Oh My Zsh ys two-line idle prompt", () => {
// ys paints `# user @ host ...` then a bare `$` or `#`. Spaces around `@`
// and the leading decorative `#` are required; a lone marker is not a prompt.
assert.equal(
extractTrailingIdlePrompt("# user @ host in ~ [12:34:56]\n$ "),
"# user @ host in ~ [12:34:56]\n$ ",
);
assert.equal(
extractTrailingIdlePrompt("welcome\r\n# user @ host in ~ [12:34:56]\n# "),
"# user @ host in ~ [12:34:56]\n# ",
);
assert.equal(
extractTrailingIdlePrompt("# user @ host in ~/proj on git:main [12:34:56]\n$ "),
"# user @ host in ~/proj on git:main [12:34:56]\n$ ",
);
assert.equal(
extractTrailingIdlePrompt("welcome\r\n# ys @ ys-mbp in ~/.oh-my-zsh on git:master x [21:47:42] C:127\n$ "),
"# ys @ ys-mbp in ~/.oh-my-zsh on git:master x [21:47:42] C:127\n$ ",
);
// Same CR / CRLF blank lines as the classic two-line PS1.
assert.equal(
extractTrailingIdlePrompt("# user @ host in ~ [12:34:56]\r\r\n$ "),
"# user @ host in ~ [12:34:56]\n\n\n$ ",
);
assert.equal(
extractTrailingIdlePrompt("# user @ host in ~ [12:34:56]\r\n# "),
"# user @ host in ~ [12:34:56]\n\n# ",
);
// Optional "(name) " env prefix is stripped only for recognition (#3947).
assert.equal(
extractTrailingIdlePrompt("(venv) # user @ host in ~ [12:34:56]\n$ "),
"(venv) # user @ host in ~ [12:34:56]\n$ ",
);
assert.equal(
extractTrailingIdlePrompt("(venv) user@host: /home/foo\n$ "),
"(venv) user@host: /home/foo\n$ ",
);
assert.equal(extractTrailingIdlePrompt("$ "), "");
assert.equal(extractTrailingIdlePrompt("# "), "");
assert.equal(extractTrailingIdlePrompt("welcome\r\n$ "), "");
assert.equal(extractTrailingIdlePrompt("not a host line\n$ "), "");
assert.equal(extractTrailingIdlePrompt("user @ host in ~\n$ "), "");
assert.equal(extractTrailingIdlePrompt("# user host in ~ [12:34:56]\n$ "), "");
assert.equal(extractTrailingIdlePrompt("# user@host in ~ [12:34:56]\n$ "), "");
assert.equal(extractTrailingIdlePrompt("copied output\n# "), "");
assert.equal(extractTrailingIdlePrompt("# user @ host in ~ [12:34:56]\n$ ls"), "");
assert.equal(extractTrailingIdlePrompt("# user @ host in ~ [12:34:56] $"), "");
assert.equal(extractTrailingIdlePrompt("echo $"), "");
assert.equal(extractTrailingIdlePrompt("price is 5$"), "");
assert.equal(extractTrailingIdlePrompt("(venv) not a host line\n$ "), "");
});
test("trackSessionIdlePrompt and getFreshIdlePrompt accept an Oh My Zsh ys two-line prompt", () => {
const session = {};
const prompt = "# user @ host in ~ [12:34:56]\n$ ";
trackSessionIdlePrompt(session, `welcome\r\n${prompt}`);
assert.equal(getFreshIdlePrompt(session), prompt);
const extra = {};
trackSessionIdlePrompt(extra, "# user @ host in ~/proj on git:main [12:34:56]\r\r\n$ ");
assert.equal(
getFreshIdlePrompt(extra),
"# user @ host in ~/proj on git:main [12:34:56]\n\n\n$ ",
);
const root = {};
trackSessionIdlePrompt(root, "# user @ host in ~ [12:34:56]\n# ");
assert.equal(getFreshIdlePrompt(root), "# user @ host in ~ [12:34:56]\n# ");
const bare = {};
assert.equal(trackSessionIdlePrompt(bare, "$ "), "");
assert.equal(getFreshIdlePrompt(bare), "");
assert.equal(trackSessionIdlePrompt(bare, "not a host line\n$ "), "");
assert.equal(getFreshIdlePrompt(bare), "");
});
test("extractTrailingIdlePrompt still recognizes a classic single-line POSIX prompt", () => {
assert.equal(extractTrailingIdlePrompt("welcome\r\nuser@host:~$ "), "user@host:~$ ");
});
+14 -83
View File
@@ -39,10 +39,6 @@ let settingsWindow = null;
let currentTheme = "light";
let currentLanguage = "en";
let currentWindowOpacity = 1;
let currentWindowBackgroundBlurRequested = false;
let currentWindowBackgroundColor = null;
/** Windows this module switched to a transparent backing, so restore is safe. */
const transparentChromeWindows = new WeakSet();
let cachedNativeTheme = null;
let handlersRegistered = false; // Prevent duplicate IPC handler registration
@@ -367,7 +363,6 @@ function registerAppContentWindow(win, options = {}) {
if (!win || win.isDestroyed?.()) return;
appContentWindows.add(win);
if (options.queryDirtyEditors === true) dirtyEditorWindows.add(win);
applyWindowBackgroundChromeToWindow(win, { applyBackgroundColor: true });
}
function unregisterAppContentWindow(win) {
@@ -417,84 +412,21 @@ function clampWindowOpacity(opacity) {
function applyWindowOpacityToWindow(win) {
if (!win || win.isDestroyed?.()) return;
try {
win.setOpacity?.(isWindowBackgroundBlurActive() ? 1 : currentWindowOpacity);
win.setOpacity?.(currentWindowOpacity);
} catch {
// ignore
}
applyWindowBackgroundChromeToWindow(win, { applyBackgroundColor: true });
}
function applyWindowOpacity(opacity) {
currentWindowOpacity = clampWindowOpacity(opacity);
for (const win of getAppContentWindowList()) {
applyWindowOpacityToWindow(win);
}
for (const win of getAppContentWindowList()) applyWindowOpacityToWindow(win);
if (settingsWindow && !settingsWindow.isDestroyed()) {
applyWindowOpacityToWindow(settingsWindow);
}
return currentWindowOpacity;
}
function isWindowBackgroundBlurActive() {
return process.platform === "darwin"
&& currentWindowBackgroundBlurRequested
&& currentWindowOpacity < 1;
}
function applyWindowBackgroundChromeToWindow(win, options = {}) {
if (!win || win.isDestroyed?.()) return;
try {
if (isWindowBackgroundBlurActive()) {
win.setVibrancy?.("hud");
win.setBackgroundColor?.("#00000000");
transparentChromeWindows.add(win);
return;
}
if (process.platform === "darwin") win.setVibrancy?.(null);
if (options.applyBackgroundColor !== true) return;
// An explicit theme/background-color push must always repaint, otherwise
// the native backing goes stale after theme changes. Registration-time
// chrome setup is different: it must not clobber the color the window was
// created with before any theme color has been pushed.
const wasTransparent = transparentChromeWindows.delete(win);
if (options.forceBackgroundColor === true || wasTransparent) {
// A window can be asked to drop vibrancy before any theme color has been
// pushed (cold start with blur already saved). Falling back to the active
// theme keeps the window from staying transparent.
const restoreColor = currentWindowBackgroundColor
|| THEME_COLORS[currentTheme]?.background;
if (restoreColor) win.setBackgroundColor?.(restoreColor);
}
} catch {
// ignore platform-specific chrome failures
}
}
function applyWindowBackgroundColor(color) {
const normalized = normalizeBackgroundColor(color);
if (!normalized) return false;
currentWindowBackgroundColor = normalized;
for (const win of getAppContentWindowList()) {
applyWindowBackgroundChromeToWindow(win, { applyBackgroundColor: true, forceBackgroundColor: true });
}
if (settingsWindow && !settingsWindow.isDestroyed()) {
applyWindowBackgroundChromeToWindow(settingsWindow, { applyBackgroundColor: true, forceBackgroundColor: true });
}
return true;
}
function applyWindowBackgroundBlur(enabled) {
const supported = process.platform === "darwin";
currentWindowBackgroundBlurRequested = supported && enabled === true;
for (const win of getAppContentWindowList()) {
applyWindowOpacityToWindow(win);
}
if (settingsWindow && !settingsWindow.isDestroyed()) {
applyWindowOpacityToWindow(settingsWindow);
}
return supported;
}
function getMainWindowCount() {
return getMainWindowList().length;
}
@@ -1066,12 +998,7 @@ const { createWindow } = mainWindowApi;
const { createSettingsWindowApi } = require("./windowManager/settingsWindow.cjs");
const settingsWindowApi = createSettingsWindowApi({
get settingsWindow() { return settingsWindow; },
set settingsWindow(value) {
settingsWindow = value;
if (value) {
applyWindowBackgroundChromeToWindow(value, { applyBackgroundColor: true });
}
},
set settingsWindow(value) { settingsWindow = value; },
get mainWindow() { return mainWindow; },
get currentTheme() { return currentTheme; },
get isQuitting() { return isQuitting; },
@@ -1260,12 +1187,21 @@ function registerWindowHandlers(ipcMain, nativeTheme) {
? (nativeTheme?.shouldUseDarkColors ? "dark" : "light")
: theme;
const themeConfig = THEME_COLORS[effectiveTheme] || THEME_COLORS.light;
applyWindowBackgroundColor(themeConfig.background);
forEachMainWindow((win) => win.setBackgroundColor(themeConfig.background));
if (settingsWindow && !settingsWindow.isDestroyed()) {
settingsWindow.setBackgroundColor(themeConfig.background);
}
return true;
});
ipcMain.handle("netcatty:setBackgroundColor", (_event, color) => {
return applyWindowBackgroundColor(color);
const normalized = normalizeBackgroundColor(color);
if (!normalized) return false;
forEachMainWindow((win) => win.setBackgroundColor(normalized));
if (settingsWindow && !settingsWindow.isDestroyed()) {
settingsWindow.setBackgroundColor(normalized);
}
return true;
});
ipcMain.handle("netcatty:setWindowOpacity", (_event, opacity) => {
@@ -1273,10 +1209,6 @@ function registerWindowHandlers(ipcMain, nativeTheme) {
return true;
});
ipcMain.handle("netcatty:setWindowBackgroundBlur", (_event, enabled) => {
return applyWindowBackgroundBlur(enabled);
});
ipcMain.handle("netcatty:setAppIconVariant", (_event, variant) => {
const { app, BrowserWindow, nativeImage } = require("electron");
const appIconManager = require("./appIconManager.cjs");
@@ -1358,7 +1290,6 @@ function registerWindowHandlers(ipcMain, nativeTheme) {
try {
const sharedWindowSetting = [
"netcatty_window_opacity_v1",
"netcatty_window_background_blur_v1",
"netcatty_hotkey_scheme_v1",
"netcatty_custom_key_bindings_v1",
"netcatty_hotkey_recording_v1",
@@ -263,13 +263,11 @@ function createMainWindowApi(ctx) {
minWidth: windowMinimumSize.width,
minHeight: windowMinimumSize.height,
backgroundColor,
transparent: isMac,
icon: appIcon,
show: false,
frame: isMac,
titleBarStyle: isMac ? "hiddenInset" : undefined,
trafficLightPosition: isMac ? { x: 12, y: 12 } : undefined,
...(isMac ? { visualEffectState: "active" } : {}),
...windowsChrome,
webPreferences: {
preload,
@@ -139,7 +139,6 @@ function createSettingsWindowApi(ctx) {
minWidth: 820,
minHeight: 600,
backgroundColor,
transparent: isMac,
icon: appIcon,
fullscreenable: !isMac,
// NOTE: Do NOT set parent - on macOS this causes rendering issues when dragging
@@ -151,7 +150,6 @@ function createSettingsWindowApi(ctx) {
frame: isMac,
titleBarStyle: isMac ? "hiddenInset" : undefined,
trafficLightPosition: isMac ? { x: 12, y: 12 } : undefined,
...(isMac ? { visualEffectState: "active" } : {}),
...windowsChrome,
webPreferences: {
preload,
@@ -1,111 +0,0 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { createRequire } = require('node:module');
const vm = require('node:vm');
const test = require('node:test');
function loadWindowManager(platform) {
const filename = path.resolve(__dirname, '../windowManager.cjs');
const loadedModule = { exports: {} };
const context = vm.createContext({
process: { ...process, platform },
console,
URL,
setTimeout,
clearTimeout,
});
const load = vm.runInContext(
`(function(exports, require, module, __filename, __dirname) { ${fs.readFileSync(filename, 'utf8')}\n})`,
context,
);
load(loadedModule.exports, createRequire(filename), loadedModule, filename, path.dirname(filename));
const handlers = new Map();
loadedModule.exports.registerWindowHandlers({
handle: (channel, handler) => handlers.set(channel, handler),
on: (channel, handler) => handlers.set(channel, handler),
}, {});
const state = { opacity: 1, vibrancy: null, background: '#123456' };
const win = {
isDestroyed: () => false,
setOpacity: (value) => { state.opacity = value; },
setVibrancy: (value) => { state.vibrancy = value; },
setBackgroundColor: (value) => { state.background = value; },
webContents: { id: 1, isDestroyed: () => false },
};
loadedModule.exports.registerMainWindow(win);
const invoke = (name, value) => handlers.get(`netcatty:${name}`)({}, value);
return { invoke, state };
}
test('macOS blur keeps native opacity at one and restores normal opacity on disable', () => {
const { invoke, state } = loadWindowManager('darwin');
assert.equal(state.background, '#123456');
invoke('setBackgroundColor', '#123456');
invoke('setWindowBackgroundBlur', true);
assert.equal(state.vibrancy, null);
invoke('setWindowOpacity', 0.7);
assert.equal(state.opacity, 1);
assert.equal(state.vibrancy, 'hud');
assert.equal(state.background, '#00000000');
invoke('setWindowOpacity', 0.85);
assert.equal(state.opacity, 1);
invoke('setWindowBackgroundBlur', false);
assert.equal(state.opacity, 0.85);
assert.equal(state.vibrancy, null);
assert.equal(state.background, '#123456');
});
test('restoring 100% removes native vibrancy and restores the theme background', () => {
const { invoke, state } = loadWindowManager('darwin');
invoke('setWindowOpacity', 0.7);
invoke('setWindowBackgroundBlur', true);
invoke('setBackgroundColor', '#abcdef');
assert.equal(state.background, '#00000000');
invoke('setWindowOpacity', 1);
assert.equal(state.opacity, 1);
assert.equal(state.vibrancy, null);
assert.equal(state.background, '#abcdef');
invoke('setWindowOpacity', 0.7);
assert.equal(state.opacity, 1);
assert.equal(state.vibrancy, 'hud');
});
test('unsupported platforms retain the existing opacity behavior', () => {
for (const platform of ['win32', 'linux']) {
const { invoke, state } = loadWindowManager(platform);
invoke('setWindowOpacity', 0.7);
assert.equal(invoke('setWindowBackgroundBlur', true), false);
assert.equal(state.opacity, 0.7);
assert.equal(state.vibrancy, null);
}
});
test('disabling blur before any theme color push restores the current theme background', () => {
const { invoke, state } = loadWindowManager('darwin');
// No setBackgroundColor / setTheme call yet: currentWindowBackgroundColor is
// still null, so the fallback must come from the active theme.
invoke('setWindowOpacity', 0.7);
invoke('setWindowBackgroundBlur', true);
assert.equal(state.background, '#00000000');
invoke('setWindowBackgroundBlur', false);
assert.equal(state.vibrancy, null);
assert.notEqual(state.background, '#00000000');
assert.match(String(state.background), /^#[0-9a-fA-F]{6}$/);
});
test('explicit theme and background-color pushes repaint windows that never used blur', () => {
for (const platform of ['darwin', 'win32', 'linux']) {
const { invoke, state } = loadWindowManager(platform);
assert.equal(state.background, '#123456');
// Registration-time chrome setup must leave the creation-time color alone...
invoke('setWindowBackgroundBlur', false);
assert.equal(state.background, '#123456');
// ...but an explicit theme color must always land.
invoke('setBackgroundColor', '#abcdef');
assert.equal(state.background, '#abcdef');
invoke('setTheme', 'dark');
assert.match(String(state.background), /^#[0-9a-fA-F]{6}$/);
assert.notEqual(state.background, '#abcdef');
}
});
+5 -22
View File
@@ -37,12 +37,7 @@ function startBridge({ token, authDelayMs = 0, onGetContext = null }) {
sockets.add(socket);
socket.on("close", () => sockets.delete(socket));
let buffer = "";
let authenticated = false;
socket.setEncoding("utf8");
const writeLine = (payload) => {
if (socket.destroyed) return;
socket.write(`${JSON.stringify(payload)}\n`);
};
socket.on("data", (chunk) => {
buffer += chunk;
let newlineIdx;
@@ -57,29 +52,17 @@ function startBridge({ token, authDelayMs = 0, onGetContext = null }) {
token: msg.params?.token,
});
const respond = () => {
if (socket.destroyed) return;
if (msg.method === "auth/verify") {
if (msg.params?.token !== token) {
writeLine({
socket.write(`${JSON.stringify({
jsonrpc: "2.0",
id: msg.id,
error: { code: -32001, message: "Authentication required. Send auth/verify with valid token first." },
});
})}\n`);
socket.destroy();
return;
}
authenticated = true;
writeLine({ jsonrpc: "2.0", id: msg.id, result: { ok: true } });
return;
}
// Match the app bridge: a call before auth/verify closes the socket.
if (!authenticated) {
writeLine({
jsonrpc: "2.0",
id: msg.id,
error: { code: -32001, message: "Authentication required. Send auth/verify with valid token first." },
});
socket.destroy();
socket.write(`${JSON.stringify({ jsonrpc: "2.0", id: msg.id, result: { ok: true } })}\n`);
return;
}
if (msg.method === "netcatty/getContext" && onGetContext) {
@@ -89,11 +72,11 @@ function startBridge({ token, authDelayMs = 0, onGetContext = null }) {
return;
}
}
writeLine({
socket.write(`${JSON.stringify({
jsonrpc: "2.0",
id: msg.id,
result: { ok: true, marker: token },
});
})}\n`);
};
if (msg.method === "auth/verify" && authDelayMs > 0) {
setTimeout(respond, authDelayMs);
+10 -33
View File
@@ -122,33 +122,19 @@ function guardWriteOperation(command, { skipBlocklist = false } = {}) {
}
let tcpSocket = null;
let pendingRequests = new Map(); // id -> { resolve, reject, sock }
let pendingRequests = new Map(); // id -> { resolve, reject }
let nextRpcId = 1;
let tcpBuffer = "";
// `destroyed` stays false after the peer FIN has already moved readyState to
// "closed". Writing in that window hits writeAfterFIN and rejects the call
// with "TCP connection lost" instead of reconnecting.
function isLiveSocket(sock) {
return Boolean(
sock
&& !sock.destroyed
&& sock.readyState === "open"
&& sock.writable
&& !sock.readableEnded
&& !sock.writableEnded
);
}
function rejectPendingOnSocket(sock, externalMessage) {
if (tcpSocket && tcpSocket !== sock) return;
const managed = !externalDiscoveryPath;
if (managed && tcpSocket === sock) tcpSocket = null;
const message = managed ? "Not connected to Netcatty" : externalMessage;
for (const [id, pending] of pendingRequests) {
if (pending.sock !== sock) continue;
pendingRequests.delete(id);
pending.reject(new Error(message));
for (const { reject: rej } of pendingRequests.values()) {
rej(new Error(message));
}
pendingRequests.clear();
}
function connectTcp() {
@@ -212,16 +198,15 @@ function connectTcp() {
function sendRpc(method, params) {
return new Promise((resolve, reject) => {
if (!isLiveSocket(tcpSocket)) {
if (!tcpSocket || tcpSocket.destroyed) {
reject(new Error("Not connected to Netcatty"));
return;
}
const sock = tcpSocket;
const id = nextRpcId++;
pendingRequests.set(id, { resolve, reject, sock });
pendingRequests.set(id, { resolve, reject });
const msg = JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n";
debugLog("rpcCall", { id, method });
sock.write(msg);
tcpSocket.write(msg);
});
}
@@ -261,12 +246,7 @@ function dropActiveSocket() {
let connecting = null;
async function connectAndAuth() {
// Drop the reference only. In-flight requests stay bound to the old socket
// and are rejected when that socket closes; they are not replayed.
if (!isLiveSocket(tcpSocket)) {
tcpSocket = null;
tcpBuffer = "";
}
if (tcpSocket?.destroyed) tcpSocket = null;
refreshExternalEndpoint();
tcpBuffer = "";
try {
@@ -283,11 +263,8 @@ async function connectAndAuth() {
}
function ensureConnected() {
// Join an in-flight reconnect before trusting tcpSocket. connectTcp assigns
// the socket before auth/verify returns; a second caller must wait for that
// re-auth instead of sending on an unauthenticated socket.
if (tcpSocket && !tcpSocket.destroyed) return Promise.resolve();
if (connecting) return connecting;
if (isLiveSocket(tcpSocket)) return Promise.resolve();
connecting = connectAndAuth().finally(() => {
connecting = null;
});
+47
View File
@@ -0,0 +1,47 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const { randomUUID } = require("node:crypto");
const { createDefinitionValidator } = require("../contractValidator.cjs");
test("Agent wire accepts only opaque host configuration/scope refs, never raw backend authority", () => {
const open = createDefinitionValidator("PluginAgentOpenParams");
const params = { backendRef: randomUUID(), selectionHandle: randomUUID() };
assert.equal(open(params), true);
for (const [key, value] of Object.entries({ permissionMode: "auto", owner: "source:forged", privateOwner: "forged",
source: "official", model: "chosen", agentId: "claude", apiKey: "synthetic-not-a-key", cli: "node",
env: {}, mcpServers: {}, sessionIds: ["focused-session"], externalSessionId: "native-session" })) {
assert.equal(open({ ...params, [key]: value }), false, key);
}
for (const key of ["backendRef", "selectionHandle", "conversationRef"]) {
assert.equal(open({ ...params, [key]: "internal-or-path-id" }), false, key);
}
});
test("Agent messages and events are bounded and do not serialize raw tools/configuration", () => {
const send = createDefinitionValidator("PluginAgentSendParams");
const params = { chatHandle: randomUUID(), text: "Synthetic user instruction" };
assert.equal(send(params), true);
assert.equal(send({ ...params, text: "x".repeat(16_385) }), false);
assert.equal(send({ ...params, text: "a\0b" }), false);
assert.equal(send({ ...params, turnId: "builtin" }), false);
const event = createDefinitionValidator("PluginAgentEvent");
assert.equal(event({ kind: "activity", capabilityId: "terminal.execute", state: "started" }), true);
assert.equal(event({ kind: "activity", capabilityId: "terminal.execute", state: "started", args: { command: "raw" } }), false);
assert.equal(event({ kind: "usage", inputTokens: 2, outputTokens: 4 }), true);
assert.equal(event({ kind: "usage", inputTokens: -1, outputTokens: 4 }), false);
assert.equal(event({ kind: "text", text: "x".repeat(4097) }), false);
assert.equal(event({ kind: "provider-error", message: "private diagnostics" }), false);
});
test("public chat/history summaries do not contain backend/native identifiers, prompts or tool results", () => {
const chats = createDefinitionValidator("PluginAgentChatsResult");
const chat = { conversationRef: randomUUID(), title: "Synthetic chat", backend: "claude", updatedAt: 1 };
assert.equal(chats({ items: [chat] }), true);
assert.equal(chats({ items: [{ ...chat, externalSessionId: "native" }] }), false);
const history = createDefinitionValidator("PluginAgentHistoryResult");
assert.equal(history({ items: [{ role: "assistant", text: "Summary" }] }), true);
assert.equal(history({ items: [{ role: "tool", text: "Raw tool result" }] }), false);
assert.equal(history({ items: [{ role: "assistant", text: "Summary", apiKey: "synthetic" }] }), false);
});
@@ -0,0 +1,122 @@
"use strict";
const fs = require("node:fs/promises");
const path = require("node:path");
const { createHash, randomUUID } = require("node:crypto");
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u;
const MAX_BYTES = 1_048_576;
const MAX_CONVERSATIONS = 64;
// Core-owned AISession documents, outside plugin filesystem/storage roots.
// The caller must authenticate provenance before supplying the opaque owner.
// Native resume identity, compaction and tool history stay in this repository;
// only a separate public projection is returned through the plugin API.
class PluginAgentConversationStore {
constructor(directory) {
if (!path.isAbsolute(directory)) throw new TypeError("Absolute host history directory required");
this.directory = directory;
this.pending = new Map();
}
#ownerDirectory(owner) {
if (!owner || typeof owner.privateOwner !== "string" || !owner.privateOwner
|| typeof owner.sourceKey !== "string" || !owner.sourceKey) throw new TypeError("Host provenance required");
return path.join(this.directory, createHash("sha256").update(JSON.stringify([owner.sourceKey, owner.privateOwner])).digest("hex"));
}
#filename(owner, id) {
if (typeof id !== "string" || !UUID.test(id)) throw new TypeError("Invalid owned conversation reference");
return path.join(this.#ownerDirectory(owner), `${id}.json`);
}
async #prepare(owner) {
await fs.mkdir(this.directory, { recursive: true, mode: 0o700 });
if (!(await fs.lstat(this.directory)).isDirectory() || (await fs.lstat(this.directory)).isSymbolicLink()) throw Error("History root unavailable");
const directory = this.#ownerDirectory(owner);
await fs.mkdir(directory, { recursive: true, mode: 0o700 });
const stat = await fs.lstat(directory);
if (!stat.isDirectory() || stat.isSymbolicLink()) throw Error("History owner unavailable");
return directory;
}
#validate(record) {
if (!record || typeof record !== "object" || !UUID.test(record.conversationRef)
|| typeof record.id !== "string" || !record.id.startsWith("plugin-agent:")
|| record.scope?.type !== "global" || record.scope.targetId !== undefined
|| typeof record.agentId !== "string" || typeof record.title !== "string" || record.title.length > 160
|| !Array.isArray(record.messages) || record.messages.length > 200
|| !Number.isSafeInteger(record.createdAt) || !Number.isSafeInteger(record.updatedAt)) throw new TypeError("Invalid core Agent history");
const text = JSON.stringify(record);
if (Buffer.byteLength(text) > MAX_BYTES) throw new RangeError("Agent history budget exceeded");
return text;
}
async #serial(owner, operation) {
const key = this.#ownerDirectory(owner);
const previous = this.pending.get(key) ?? Promise.resolve();
const current = previous.catch(() => {}).then(operation);
this.pending.set(key, current);
try { return await current; }
finally { if (this.pending.get(key) === current) this.pending.delete(key); }
}
async list(owner, assertCurrent) {
await assertCurrent();
const directory = await this.#prepare(owner);
const files = await fs.readdir(directory);
const records = [];
for (const file of files) {
const id = file.slice(0, -5);
if (!file.endsWith(".json") || !UUID.test(id)) continue;
if (records.length >= MAX_CONVERSATIONS) break;
const record = await this.read(owner, id, assertCurrent);
if (record) records.push(record);
}
await assertCurrent();
return records.sort((a, b) => b.updatedAt - a.updatedAt);
}
async read(owner, id, assertCurrent) {
await assertCurrent();
const filename = this.#filename(owner, id);
await this.#prepare(owner);
let file;
try {
// O_NOFOLLOW avoids following a replaced final path. Core owns ancestors.
const { constants } = require("node:fs");
file = await fs.open(filename, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
const stat = await file.stat();
if (!stat.isFile() || stat.size > MAX_BYTES) throw Error("Agent history unavailable");
const record = JSON.parse(await file.readFile("utf8"));
this.#validate(record);
if (record.conversationRef !== id) throw Error("Agent history identity mismatch");
await assertCurrent();
return record;
} catch (error) {
if (error.code === "ENOENT") { await assertCurrent(); return null; }
throw error;
} finally { await file?.close(); }
}
async save(owner, record, assertCurrent) {
// Clone/serialize before any await so caller mutation cannot change commit.
const text = this.#validate(record);
const id = record.conversationRef;
return this.#serial(owner, async () => {
await assertCurrent();
const directory = await this.#prepare(owner);
const filename = this.#filename(owner, id);
const entries = (await fs.readdir(directory)).filter((name) => name.endsWith(".json"));
if (!entries.includes(`${id}.json`) && entries.length >= MAX_CONVERSATIONS) throw new RangeError("Agent conversation budget exceeded");
const temporary = path.join(directory, `.${randomUUID()}.tmp`);
try {
await fs.writeFile(temporary, text, { encoding: "utf8", mode: 0o600, flag: "wx" });
await assertCurrent();
await fs.rename(temporary, filename);
} finally { await fs.rm(temporary, { force: true }); }
});
}
}
module.exports = { PluginAgentConversationStore };
@@ -0,0 +1,68 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs/promises");
const os = require("node:os");
const path = require("node:path");
const { randomUUID } = require("node:crypto");
const { PluginAgentConversationStore } = require("./conversationStore.cjs");
const owner = (source) => ({ privateOwner: `opaque:${source}:same-plugin-id`, sourceKey: `source:${source}` });
const current = async () => {};
function record() {
const conversationRef = randomUUID();
return { conversationRef, id: `plugin-agent:${randomUUID()}`, title: "Synthetic chat", agentId: "catty",
scope: { type: "global" }, messages: [{ id: randomUUID(), role: "user", content: "Synthetic only", timestamp: 1 }], createdAt: 1, updatedAt: 1 };
}
async function fixture(t) {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "netcatty-agent-history-test-"));
t.after(() => fs.rm(root, { recursive: true, force: true }));
return { root, store: new PluginAgentConversationStore(path.join(root, "core-history")) };
}
test("core history survives reopening, uses existing AISession shape and isolates the same plugin ID across sources", async (t) => {
const { root, store } = await fixture(t); const chat = record();
await store.save(owner("a"), chat, current);
assert.deepEqual(await store.read(owner("a"), chat.conversationRef, current), chat);
const [directory] = await fs.readdir(path.join(root, "core-history"));
assert.doesNotMatch(directory, /opaque|same-plugin/);
const mode = (await fs.stat(path.join(root, "core-history", directory, `${chat.conversationRef}.json`))).mode;
if (process.platform !== "win32") assert.equal(mode & 0o777, 0o600);
assert.equal(await store.read(owner("b"), chat.conversationRef, current), null);
assert.deepEqual(await store.list(owner("b"), current), []);
const reopened = new PluginAgentConversationStore(path.join(root, "core-history"));
assert.deepEqual(await reopened.list(owner("a"), current), [chat]);
});
test("source revoked before async rename leaves previous history intact and cleans temporary files", async (t) => {
const { store, root } = await fixture(t); const chat = record(); await store.save(owner("a"), chat, current);
const next = { ...chat, title: "Must not commit" }; let checks = 0;
await assert.rejects(store.save(owner("a"), next, async () => { if (++checks === 2) throw Error("Revoked"); }));
assert.equal((await store.read(owner("a"), chat.conversationRef, current)).title, chat.title);
const [directory] = await fs.readdir(path.join(root, "core-history"));
assert.deepEqual(await fs.readdir(path.join(root, "core-history", directory)), [`${chat.conversationRef}.json`]);
});
test("history snapshots cannot mutate across await, and traversal/builtin IDs/oversized records are rejected", async (t) => {
const { store } = await fixture(t); const chat = record(); const savedTitle = chat.title;
const saved = store.save(owner("a"), chat, current); chat.title = "Changed after call"; await saved;
assert.equal((await store.read(owner("a"), chat.conversationRef, current)).title, savedTitle);
for (const id of ["../outside", "builtin-chat", "source:owner", ""]) await assert.rejects(store.read(owner("a"), id, current));
await assert.rejects(store.save({ privateOwner: "bare-plugin" }, record(), current));
await assert.rejects(store.save(owner("a"), { ...record(), id: "builtin-chat" }, current));
await assert.rejects(store.save(owner("a"), { ...record(), scope: { type: "workspace", targetId: "active" } }, current));
await assert.rejects(store.save(owner("a"), { ...record(), messages: Array(201).fill({}) }, current));
await assert.rejects(store.save(owner("a"), { ...record(), messages: [{ content: "x".repeat(1_048_576) }] }, current));
});
test("symlinked history roots and files do not become history authority", async (t) => {
const { root, store } = await fixture(t); const chat = record(); await store.save(owner("a"), chat, current);
const history = path.join(root, "core-history"); const [directory] = await fs.readdir(history);
const filename = path.join(history, directory, `${chat.conversationRef}.json`);
const unrelated = path.join(root, "unrelated.json"); await fs.writeFile(unrelated, JSON.stringify(chat));
await fs.rm(filename); await fs.symlink(unrelated, filename);
await assert.rejects(store.read(owner("a"), chat.conversationRef, current));
const linked = path.join(root, "linked"); await fs.symlink(history, linked, "dir");
await assert.rejects(new PluginAgentConversationStore(linked).list(owner("a"), current));
});
+180
View File
@@ -0,0 +1,180 @@
"use strict";
const { randomUUID } = require("node:crypto");
const { PluginRpcError, RPC_ERRORS } = require("../rpcRouter.cjs");
const fail = (code) => new PluginRpcError(RPC_ERRORS[code], "Delegated Agent scope is unavailable");
const key = (target) => JSON.stringify([target.kind, target.id]);
const isRequestCancellation = (error, context) => context.signal?.aborted === true
&& (error === context.signal.reason || error?.name === "AbortError" || error?.name === "TimeoutError"
|| error?.code === RPC_ERRORS.cancelled || error?.code === RPC_ERRORS.deadlineExceeded);
// Host-only authority, not a plugin RPC interface or an Agent engine. The host
// supplies snapshots after resolving configured backends and choosing targets.
// Plugins never supply owner, revision, capability policy or executable adapters.
class DelegatedAgentScopes {
constructor({ resolveOwner, assertHostActive, authorizeAction, clock = Date.now }) {
for (const fn of [resolveOwner, assertHostActive, authorizeAction, clock]) {
if (typeof fn !== "function") throw new TypeError("Host scope adapters are required");
}
this.resolveOwner = resolveOwner;
this.assertHostActive = assertHostActive;
this.authorizeAction = authorizeAction;
this.clock = clock;
this.scopes = new Map();
this.closed = false;
}
async #owner(context) {
if (this.closed) throw fail("unavailable");
const owner = await this.resolveOwner(context);
await this.assertHostActive(context);
context.signal?.throwIfAborted();
if (this.closed || await this.resolveOwner(context) !== owner) throw fail("permissionDenied");
return owner;
}
// The snapshot is host-owned. A public schema must only accept returned handles.
async issue(context, { backend, targets, capabilities }) {
const owner = await this.#owner(context);
if (!backend || typeof backend.key !== "string" || typeof backend.revision !== "string"
|| typeof backend.isCurrent !== "function" || !Array.isArray(targets) || targets.length > 32
|| !Array.isArray(capabilities) || capabilities.length > 128
|| capabilities.some((id) => typeof id !== "string" || !id || id.length > 160)) throw fail("invalidArgument");
const captured = new Map();
for (const target of targets) {
if (!target || !["terminal", "host", "note"].includes(target.kind)
|| typeof target.id !== "string" || !target.id || target.id.length > 256
|| typeof target.revision !== "string" || !target.revision
|| typeof target.owner !== "string" || !target.owner
|| typeof target.isCurrent !== "function" || captured.has(key(target))) throw fail("invalidArgument");
captured.set(key(target), Object.freeze({ ...target }));
}
const scope = { handle: randomUUID(), owner, backend: Object.freeze({ ...backend }), targets: captured,
capabilities: new Set(capabilities), expiresAt: this.clock() + 300_000, activeTurn: null };
this.#assertSnapshots(scope);
if (await this.#owner(context) !== owner) throw fail("permissionDenied");
this.#assertSnapshots(scope);
for (const entry of this.scopes.values()) if (entry.expiresAt <= this.clock()) this.#revoke(entry);
const existing = [...this.scopes.values()].filter((entry) => entry.owner === owner);
if (existing.length >= 8 || this.scopes.size >= 64) throw fail("resourceExhausted");
this.scopes.set(scope.handle, scope);
return Object.freeze({ handle: scope.handle, expiresAt: scope.expiresAt });
}
#assertSnapshots(scope) {
// Current-owner/revision checks are synchronous registry lookups, so the
// final dispatch check and native call have no asynchronous focus gap.
if (scope.backend.isCurrent(scope.backend) !== true) throw fail("failedPrecondition");
for (const target of scope.targets.values()) {
if (target.isCurrent(target) !== true) throw fail("failedPrecondition");
}
}
async #resolve(handle, context) {
const scope = this.scopes.get(handle);
let owner;
try { owner = await this.#owner(context); }
catch (error) {
// Context identity comes from the authenticated host transport. Revoke
// only its own captured scope, never a victim referenced by another caller.
if (!isRequestCancellation(error, context) && scope && scope.owner.runtimeId === context.runtimeId && scope.owner.pluginId === context.pluginId
&& scope.owner.pluginVersion === context.pluginVersion) this.#revoke(scope);
throw error;
}
if (!scope || scope.owner !== owner) throw fail("permissionDenied");
if (scope.expiresAt <= this.clock()) { this.#revoke(scope); throw fail("permissionDenied"); }
try {
if (await this.#owner(context) !== owner || this.scopes.get(handle) !== scope
|| scope.expiresAt <= this.clock()) throw fail("permissionDenied");
this.#assertSnapshots(scope);
} catch (error) {
// A failed individual RPC has no authority to revoke another turn. Real
// owner/target failure still invalidates the scope, even if a request also
// happens to be cancelled at that time.
if (!isRequestCancellation(error, context)) this.#revoke(scope);
throw error;
}
return scope;
}
async begin(handle, context) {
const scope = await this.#resolve(handle, context);
if (scope.activeTurn) throw fail("alreadyExists");
const ticket = Object.freeze({ scope, controller: new AbortController(), id: randomUUID() });
scope.activeTurn = ticket;
return ticket;
}
async assertTurn(ticket, context) {
if (!ticket?.scope || ticket.controller.signal.aborted) throw fail("cancelled");
const scope = await this.#resolve(ticket.scope.handle, context);
if (scope.activeTurn !== ticket) throw fail("cancelled");
ticket.controller.signal.throwIfAborted();
return scope;
}
// Called inside the actual tool dispatcher, after canonical argument/target
// resolution. It must not be used merely when displaying the model tool list.
async dispatch(ticket, context, action, execute) {
const scope = await this.assertTurn(ticket, context);
if (!action || !scope.capabilities.has(action.capabilityId) || !Array.isArray(action.targets)
|| action.targets.length > 32 || typeof execute !== "function") throw fail("permissionDenied");
const targets = action.targets.map((target) => {
const captured = target && scope.targets.get(key(target));
if (!captured || target.revision !== captured.revision || target.owner !== captured.owner) throw fail("permissionDenied");
return captured;
});
const request = Object.freeze({ capabilityId: action.capabilityId, targets: Object.freeze(targets) });
// The adapter combines plugin policy and native Agent restrictions once.
// No caller can select a process-global permission mode through this API.
await this.authorizeAction(context, scope.owner, request, ticket.controller.signal);
await this.assertTurn(ticket, context);
this.#assertSnapshots(scope);
ticket.controller.signal.throwIfAborted();
const result = await execute(Object.freeze({ signal: ticket.controller.signal,
assertCurrent: () => this.assertTurn(ticket, context) }));
await this.assertTurn(ticket, context);
return result;
}
finish(ticket) {
if (!ticket || ticket.scope?.activeTurn !== ticket) return false;
ticket.scope.activeTurn = null;
ticket.controller.abort();
return true;
}
async stop(handle, context) {
// Capture before the first await. A prior Stop cannot acquire authority over
// a turn that replaces the one the caller intended to stop.
const candidate = this.scopes.get(handle);
const ticket = candidate?.activeTurn;
const scope = await this.#resolve(handle, context);
return scope === candidate ? this.finish(ticket) : false;
}
#revoke(scope) {
this.finish(scope.activeTurn);
this.scopes.delete(scope.handle);
}
releaseRuntime(runtimeId) {
for (const scope of this.scopes.values()) if (scope.owner.runtimeId === runtimeId) this.#revoke(scope);
}
releasePlugin(pluginId) {
for (const scope of this.scopes.values()) if (scope.owner.pluginId === pluginId) this.#revoke(scope);
}
releaseAll() {
for (const scope of this.scopes.values()) this.#revoke(scope);
}
shutdown() {
this.closed = true;
this.releaseAll();
}
}
module.exports = { DelegatedAgentScopes };
@@ -0,0 +1,229 @@
"use strict";
const assert = require("node:assert/strict");
const test = require("node:test");
const { DelegatedAgentScopes } = require("./delegatedScope.cjs");
const { PluginAISourceBinding } = require("../ai/sourceBinding.cjs");
function deferred() {
let resolve;
const promise = new Promise((finish) => { resolve = finish; });
return { promise, resolve };
}
function fixture() {
let time = 1; let locked = false; let focus = "terminal-a";
let source = { id: "source-a", kind: "third-party", origin: "https://example.invalid" };
let privateOwner = "source:opaque-owner-a:com.example.assistant";
let identity = { pluginId: "com.example.assistant", pluginVersion: "1", runtimeId: "r1", runtimeKind: "browser" };
let authorize = async () => {};
const context = { ...identity, assertActive: async () => {} };
const database = {
getActivePlugin: () => ({ source, enabled: true, activeVersion: "1", runtime: { status: "running" } }),
getRetainedPluginSource: () => source,
privateOwner: () => privateOwner,
publicPluginId: (value) => value === privateOwner ? identity.pluginId : null,
};
const binding = new PluginAISourceBinding({ database, getRuntimeIdentity: () => identity });
let backendRevision = "configured-1";
const currentTargets = new Map([
["terminal-a", { owner: "window-1", revision: "session-a-v1" }],
["terminal-b", { owner: "window-2", revision: "session-b-v1" }],
]);
const backend = { key: "host-configured-claude", revision: backendRevision,
isCurrent: (snapshot) => snapshot.revision === backendRevision };
const target = (id) => ({ kind: "terminal", id, ...currentTargets.get(id),
isCurrent: (snapshot) => { const current = currentTargets.get(snapshot.id);
return current?.owner === snapshot.owner && current?.revision === snapshot.revision; } });
const scopes = new DelegatedAgentScopes({ resolveOwner: (ctx) => binding.resolve(ctx),
assertHostActive: async () => { if (locked) throw Error("Locked"); },
authorizeAction: (...args) => authorize(...args), clock: () => time });
return { scopes, context, target, targets: currentTargets, database,
issue: (targets = [target("terminal-a"), target("terminal-b")]) => scopes.issue(context,
{ backend, targets, capabilities: ["terminal.execute", "session.environment"] }),
action: (id = "terminal-a") => ({ capabilityId: "terminal.execute", targets: [target(id)] }),
tick: (ms) => { time += ms; }, lock: () => { locked = true; scopes.releaseAll(); },
focusTo: (id) => { focus = id; }, focus: () => focus,
authorize: (fn) => { authorize = fn; }, backendTo: (value) => { backendRevision = value; },
replaceSource: () => { source = { ...source, id: "source-b" }; privateOwner = "source:opaque-owner-b:com.example.assistant"; },
runtimeTo: (id) => { identity = { ...identity, runtimeId: id }; },
};
}
test("host-issued scope exposes only opaque identity and expiry; cross-workspace focus does not change explicit targets", async () => {
const f = fixture(); const ref = await f.issue();
assert.deepEqual(Object.keys(ref).sort(), ["expiresAt", "handle"]);
assert.doesNotMatch(JSON.stringify(ref), /window-|terminal-|source:|configured/);
const ticket = await f.scopes.begin(ref.handle, f.context);
f.focusTo("unselected-terminal");
let calls = 0;
assert.equal(await f.scopes.dispatch(ticket, f.context, f.action("terminal-b"), async () => ++calls), 1);
assert.equal(f.focus(), "unselected-terminal");
await assert.rejects(f.scopes.dispatch(ticket, f.context,
{ capabilityId: "terminal.execute", targets: [{ kind: "terminal", id: f.focus(), revision: "1", owner: "window-1" }] }, async () => ++calls));
assert.equal(calls, 1);
});
test("forged runtime, cross-runtime session and source replacement cannot use a scope", async () => {
const f = fixture(); const ref = await f.issue();
await assert.rejects(f.scopes.begin(ref.handle, { ...f.context, runtimeId: "forged" }));
f.runtimeTo("r2");
await assert.rejects(f.scopes.begin(ref.handle, { ...f.context, runtimeId: "r2" }));
f.runtimeTo("r1"); f.replaceSource();
await assert.rejects(f.scopes.begin(ref.handle, f.context));
});
test("one host policy decision; target owner/revision is revalidated after approval waits", async () => {
const f = fixture(); const ref = await f.issue(); const ticket = await f.scopes.begin(ref.handle, f.context);
const entered = deferred(); const release = deferred(); let decisions = 0; let effects = 0;
f.authorize(async (_context, owner, action) => {
decisions++; assert.equal(owner.privateOwner, "source:opaque-owner-a:com.example.assistant");
assert.equal(action.targets[0].id, "terminal-a"); entered.resolve(); await release.promise;
});
const result = f.scopes.dispatch(ticket, f.context, f.action(), async () => effects++);
await entered.promise;
f.targets.set("terminal-a", { owner: "new-window", revision: "session-a-v2" });
release.resolve(); await assert.rejects(result);
assert.equal(decisions, 1); assert.equal(effects, 0); assert.equal(ticket.controller.signal.aborted, true);
});
test("copied target ID with substituted owner or revision and undelegated capabilities fail before policy/execute", async () => {
const f = fixture(); const ref = await f.issue(); const ticket = await f.scopes.begin(ref.handle, f.context);
let decisions = 0; let effects = 0; f.authorize(async () => { decisions++; });
for (const patch of [{ owner: "attacker" }, { revision: "old" }]) {
await assert.rejects(f.scopes.dispatch(ticket, f.context,
{ capabilityId: "terminal.execute", targets: [{ ...f.target("terminal-a"), ...patch }] }, async () => effects++));
}
await assert.rejects(f.scopes.dispatch(ticket, f.context,
{ capabilityId: "vault.host.delete", targets: [] }, async () => effects++));
assert.equal(decisions, 0); assert.equal(effects, 0);
});
test("Deny and revoked source during authorization never dispatch a tool", async () => {
const f = fixture(); const ref = await f.issue(); const ticket = await f.scopes.begin(ref.handle, f.context);
let effects = 0;
f.authorize(async () => { throw Error("Denied"); });
await assert.rejects(f.scopes.dispatch(ticket, f.context, f.action(), async () => effects++));
f.authorize(async () => { f.replaceSource(); });
await assert.rejects(f.scopes.dispatch(ticket, f.context, f.action(), async () => effects++));
assert.equal(effects, 0);
});
test("Stop while approval waits cancels the exact turn and prevents invocation", async () => {
const f = fixture(); const ref = await f.issue(); const ticket = await f.scopes.begin(ref.handle, f.context);
const entered = deferred(); const release = deferred(); let effects = 0;
f.authorize(async (_context, _owner, _action, signal) => {
assert.equal(signal, ticket.controller.signal); entered.resolve(); await release.promise;
});
const result = f.scopes.dispatch(ticket, f.context, f.action(), async () => effects++);
await entered.promise; assert.equal(await f.scopes.stop(ref.handle, f.context), true);
release.resolve(); await assert.rejects(result); assert.equal(effects, 0);
const next = await f.scopes.begin(ref.handle, f.context);
assert.equal(f.scopes.finish(ticket), false); assert.equal(next.controller.signal.aborted, false);
});
for (const reason of ["runtime", "plugin", "lock", "stop"]) {
test(`${reason} cancellation drops late native results and invalidates the old event ticket`, async () => {
const f = fixture(); const ref = await f.issue(); const ticket = await f.scopes.begin(ref.handle, f.context);
const entered = deferred(); const release = deferred();
const result = f.scopes.dispatch(ticket, f.context, f.action(), async ({ signal }) => {
assert.equal(signal, ticket.controller.signal); entered.resolve(); await release.promise; return "late";
});
await entered.promise;
if (reason === "runtime") f.scopes.releaseRuntime(f.context.runtimeId);
else if (reason === "plugin") f.scopes.releasePlugin(f.context.pluginId);
else if (reason === "lock") f.lock();
else await f.scopes.stop(ref.handle, f.context);
release.resolve(); await assert.rejects(result); await assert.rejects(f.scopes.assertTurn(ticket, f.context));
assert.equal(ticket.controller.signal.aborted, true);
});
}
test("backend config changes and expiry invalidate scopes; host snapshots cannot silently refresh them", async () => {
const f = fixture(); const ref = await f.issue(); const ticket = await f.scopes.begin(ref.handle, f.context);
f.backendTo("new-config"); await assert.rejects(f.scopes.assertTurn(ticket, f.context));
assert.equal(ticket.controller.signal.aborted, true);
const g = fixture(); const next = await g.issue(); const active = await g.scopes.begin(next.handle, g.context);
g.tick(300_000); await assert.rejects(g.scopes.assertTurn(active, g.context));
assert.equal(active.controller.signal.aborted, true); assert.equal(g.scopes.scopes.size, 0);
});
test("source replacement detected during owner authentication aborts already-running native work", async () => {
const f = fixture(); const ref = await f.issue(); const ticket = await f.scopes.begin(ref.handle, f.context);
const entered = deferred(); const release = deferred();
const result = f.scopes.dispatch(ticket, f.context, f.action(), async ({ signal }) => {
entered.resolve(signal); await release.promise; return "late native result";
});
const signal = await entered.promise;
try {
f.replaceSource();
await assert.rejects(f.scopes.assertTurn(ticket, f.context));
assert.equal(signal.aborted, true, "source detection must abort native work, not only reject its result");
assert.equal(f.scopes.scopes.size, 0);
} finally { release.resolve(); await assert.rejects(result); }
});
test("Stop captures turn A before owner validation and cannot cancel replacement turn B", async () => {
const f = fixture(); const ref = await f.issue(); const first = await f.scopes.begin(ref.handle, f.context);
const entered = deferred(); const release = deferred(); let blocked = false;
const stopContext = { ...f.context, assertActive: async () => {
if (!blocked) { blocked = true; entered.resolve(); await release.promise; }
} };
const stopping = f.scopes.stop(ref.handle, stopContext);
await entered.promise; assert.equal(f.scopes.finish(first), true);
const second = await f.scopes.begin(ref.handle, f.context);
release.resolve();
assert.equal(await stopping, false);
assert.equal(second.controller.signal.aborted, false, "an earlier Stop must not cancel a later turn");
assert.equal(await f.scopes.assertTurn(second, f.context), second.scope);
});
for (const phase of [1, 5]) {
test(`cancelled Stop(A) during owner check ${phase} cannot revoke replacement turn B`, { timeout: 2_000 }, async () => {
const f = fixture(); const ref = await f.issue(); const first = await f.scopes.begin(ref.handle, f.context);
const entered = deferred(); const release = deferred(); const rpc = new AbortController(); let checks = 0;
const stopContext = { ...f.context, signal: rpc.signal, assertActive: async () => {
if (++checks === phase) { entered.resolve(); await release.promise; }
rpc.signal.throwIfAborted();
} };
const stopping = f.scopes.stop(ref.handle, stopContext);
await entered.promise; f.scopes.finish(first);
const second = await f.scopes.begin(ref.handle, f.context);
rpc.abort(phase === 1 ? new DOMException("Stop cancelled", "AbortError") : new DOMException("Stop deadline", "TimeoutError"));
release.resolve();
await assert.rejects(stopping, (error) => error === rpc.signal.reason);
assert.equal(second.controller.signal.aborted, false, "a cancelled RPC does not revoke source authority or stop a later turn");
assert.equal(f.scopes.scopes.size, 1);
assert.equal(await f.scopes.assertTurn(second, f.context), second.scope);
});
}
test("verified source failure still aborts native work when the RPC is concurrently cancelled", async () => {
const f = fixture(); const ref = await f.issue(); const ticket = await f.scopes.begin(ref.handle, f.context);
const rpc = new AbortController(); const retained = f.database.getRetainedPluginSource;
f.database.getRetainedPluginSource = () => {
rpc.abort(new DOMException("Concurrent RPC cancellation", "AbortError"));
return { ...retained(), id: "different-retained-source" };
};
await assert.rejects(f.scopes.assertTurn(ticket, { ...f.context, signal: rpc.signal }),
(error) => error !== rpc.signal.reason && error.code === -32007);
assert.equal(ticket.controller.signal.aborted, true);
assert.equal(f.scopes.scopes.size, 0);
});
test("a foreign runtime cannot revoke a victim scope by failing authentication with its handle", async () => {
const f = fixture(); const ref = await f.issue(); const ticket = await f.scopes.begin(ref.handle, f.context);
await assert.rejects(f.scopes.stop(ref.handle, { ...f.context, runtimeId: "foreign-runtime" }));
assert.equal(ticket.controller.signal.aborted, false);
assert.equal(f.scopes.scopes.size, 1);
assert.equal(await f.scopes.assertTurn(ticket, f.context), ticket.scope);
});
test("bounded host scopes reject malformed snapshots without registering partial targets", async () => {
const f = fixture(); await assert.rejects(f.issue([null])); assert.equal(f.scopes.scopes.size, 0);
await assert.rejects(f.issue([f.target("terminal-a"), f.target("terminal-a")]));
for (let i = 0; i < 8; i++) await f.issue();
await assert.rejects(f.issue());
f.tick(300_000); await f.issue(); assert.equal(f.scopes.scopes.size, 1);
f.scopes.shutdown(); await assert.rejects(f.issue());
});
@@ -1,162 +0,0 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const { RegistryCatalogService } = require("./registryCatalogService.cjs");
const { PluginContributionIconService } = require("./contributionIconService.cjs");
const { MarketSourcesService } = require("./marketSourcesService.cjs");
const { OFFICIAL_SOURCE, LOCAL_SOURCE, thirdPartySource } = require("./sourceIdentity.cjs");
const { catalog, delivery, digest, makeFiles, read } = require("./fixtures/staticDistributionFixture.cjs");
const sourcePng = read(delivery.plugins[0].iconLight.path.slice(1));
const avatar = { path: `/assets/${digest(sourcePng)}.png`, sha256: digest(sourcePng), size: sourcePng.length };
function filesFor(entries = catalog, change = () => {}) {
const files = makeFiles(entries, (index, sidecar) => {
sidecar.schemaVersion = 2;
for (const item of sidecar.plugins) {
item.avatar = structuredClone(avatar);
const entry = entries.find((entry) => entry.id === item.id);
if (entry.artifact) {
item.artifact = { path: `/assets/${entry.artifact.sha256}.ncpkg`, sha256: entry.artifact.sha256, size: 1 };
index.plugins.find((plugin) => plugin.id === item.id).versions[0].distribution = "release";
}
}
change(index, sidecar);
});
files.set(avatar.path, sourcePng);
return files;
}
function imageService(rasterizeIcon) {
return new PluginContributionIconService({ rasterizeIcon: rasterizeIcon ?? (async () => {
const png = Buffer.alloc(24); Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]).copy(png);
png.write("IHDR", 12); png.writeUInt32BE(32, 16); png.writeUInt32BE(32, 20); return png;
}) });
}
function registry(options = {}) {
const requests = [];
let files = options.files ?? filesFor();
const service = new RegistryCatalogService({ ...options,
fetch: options.fetch ?? (async (url, init) => {
requests.push(url);
assert.equal(new URL(url).origin, options.source?.origin ?? OFFICIAL_SOURCE.origin);
assert.equal(init.redirect, "manual"); assert.equal(init.credentials, "omit");
return files.has(new URL(url).pathname) ? new Response(files.get(new URL(url).pathname)) : new Response(null, { status: 404 });
}),
resolveAvatar: options.resolveAvatar ?? ((request) => imageServiceSingleton.resolveCatalogAvatar(request)),
});
const imageServiceSingleton = imageService();
return { service, requests, setFiles(value) { files = value; } };
}
test("v2 avatars use verified same-origin bytes and the shared image cache without GitHub", async () => {
const r = registry(); const snapshot = await r.service.list(); assert.equal(snapshot.error, null);
const entry = snapshot.entries[0];
const values = await Promise.all([r.service.author(entry.id, entry.selectionToken), r.service.author(entry.id, entry.selectionToken)]);
assert.deepEqual(values[0], values[1]); assert.match(values[0].avatar, /^data:image\/png;base64,/);
assert.equal(values[0].officialMaintained, true); assert.equal(values[0].author, "Netcatty");
assert.equal(r.requests.filter((url) => new URL(url).pathname === avatar.path).length, 1);
assert.ok(r.requests.every((url) => new URL(url).origin === OFFICIAL_SOURCE.origin));
await r.service.author(entry.id, entry.selectionToken);
assert.equal(r.requests.length, 5, "cached raster avoids another download");
});
test("third-party copies, community entries and local imports cannot acquire maintenance authority", async () => {
const copied = registry({ source: thirdPartySource("Netcatty official", "https://community.example.com") });
const entry = (await copied.service.list()).entries[0];
assert.equal((await copied.service.author(entry.id, entry.selectionToken)).officialMaintained, false);
const community = structuredClone(catalog[0]); community.id = "com.community.official";
community.manifest.id = community.id; community.author = "Netcatty official"; community.manifest.publisher = "netcatty";
const r = registry({ files: filesFor([community]) }); const listed = (await r.service.list()).entries[0];
assert.equal((await r.service.author(listed.id, listed.selectionToken)).officialMaintained, false);
const local = new MarketSourcesService({ database: { getActivePlugin: () => ({ id: catalog[0].id, activeVersion: catalog[0].version,
source: LOCAL_SOURCE, archiveSha256: "a".repeat(64), manifest: catalog[0].manifest, runtime: {} }) } });
assert.equal((await local.author({ kind: "installed", pluginId: catalog[0].id })).officialMaintained, false);
await assert.rejects(local.author({ kind: "installed", pluginId: catalog[0].id, officialMaintained: true }), /Invalid/);
});
test("avatar failure, redirects and mismatched bytes yield a placeholder without another origin", async () => {
for (const kind of ["missing", "hash", "format", "redirect"]) {
const files = filesFor(); const requests = [];
const r = registry({ files, fetch: async (url) => {
requests.push(url); const p = new URL(url).pathname;
if (p === avatar.path) {
if (kind === "redirect") return new Response(null, { status: 302, headers: { location: "https://avatars.githubusercontent.com/u/1" } });
if (kind === "missing") return new Response(null, { status: 404 });
if (kind === "hash") return new Response(Buffer.alloc(sourcePng.length));
}
return new Response(files.get(p));
}, ...(kind === "format" ? { resolveAvatar: async () => { throw new Error("Invalid PNG"); } } : {}) });
const entry = (await r.service.list()).entries[0]; const value = await r.service.author(entry.id, entry.selectionToken);
assert.equal(value.avatar, null, kind); assert.equal(value.officialMaintained, true);
assert.ok(requests.every((url) => new URL(url).origin === OFFICIAL_SOURCE.origin));
}
});
test("avatar schema rejects foreign paths, non-raster formats and invalid sizes before fetching", async () => {
for (const ref of [
{ ...avatar, path: "https://avatars.githubusercontent.com/u/1" },
{ ...avatar, path: `/assets/${avatar.sha256}.svg` },
{ ...avatar, path: `/assets/${"a".repeat(64)}.png` },
{ ...avatar, size: 0 }, { ...avatar, size: 256 * 1024 + 1 }, { ...avatar, size: 1.5 },
]) {
const r = registry({ files: filesFor(catalog, (_index, delivery) => { delivery.plugins[0].avatar = ref; }) });
const result = await r.service.list(); assert.ok(result.error, JSON.stringify(ref));
assert.equal(r.requests.length, 4, "invalid delivery cannot cause an avatar request");
}
});
test("v2 package references enforce the publisher's positive 16MiB bound without installing", async () => {
const entry = structuredClone(catalog[0]);
entry.artifact = { sha256: "a".repeat(64), url: `${OFFICIAL_SOURCE.origin}/assets/${"a".repeat(64)}.ncpkg` };
for (const size of [0, 16 * 1024 * 1024, 16 * 1024 * 1024 + 1]) {
const r = registry({ files: filesFor([entry], (_index, sidecar) => { sidecar.plugins[0].artifact.size = size; }) });
const snapshot = await r.service.list();
assert.equal(Boolean(snapshot.error), size !== 16 * 1024 * 1024);
assert.equal(r.requests.length, 4, "catalog validation never downloads or installs the package");
}
});
test("a changed selection or removed source cannot publish an in-flight avatar result", async () => {
for (const change of ["selection", "source"]) {
let release; let entered; let enabled = true;
const started = new Promise((resolve) => { entered = resolve; });
const gate = new Promise((resolve) => { release = resolve; });
const r = registry({ sourceGuard() { if (!enabled) throw new Error("Source removed"); }, resolveAvatar: async () => { entered(); await gate; return "data:image/png;base64,eA=="; } });
const entry = (await r.service.list()).entries[0]; const pending = r.service.author(entry.id, entry.selectionToken); await started;
if (change === "selection") { r.setFiles(filesFor(catalog, (_i, sidecar) => { sidecar.plugins[0].avatar = null; })); await r.service.refresh(); }
else enabled = false;
release(); await assert.rejects(pending, /changed|removed/);
}
});
test("installed presentation requires exact source/version/package/manifest and rejects replacement during load", async () => {
const entry = structuredClone(catalog[0]); entry.artifact = { sha256: "a".repeat(64), url: `${OFFICIAL_SOURCE.origin}/assets/${"a".repeat(64)}.ncpkg` };
let plugin = { id: entry.id, activeVersion: entry.version, archiveSha256: entry.artifact.sha256, source: OFFICIAL_SOURCE, manifest: entry.manifest, runtime: {} };
const requests = []; const files = filesFor([entry]);
const database = { getActivePlugin: () => plugin, db: { prepare: () => ({ get: () => undefined }) } };
const service = new MarketSourcesService({ database, fetch: async (url) => { requests.push(url); return new Response(files.get(new URL(url).pathname)); } });
const query = { kind: "installed", pluginId: entry.id };
assert.equal((await service.author(query)).officialMaintained, false); assert.equal(requests.length, 0);
await service.list(); assert.equal((await service.author(query)).officialMaintained, true);
for (const patch of [{ activeVersion: "9.0.0" }, { archiveSha256: "b".repeat(64) }, { manifest: { ...entry.manifest, publisher: "other" } }, { source: LOCAL_SOURCE }]) {
const saved = plugin; plugin = { ...plugin, ...patch };
assert.equal((await service.author(query)).officialMaintained, false); plugin = saved;
}
let release; let entered;
const started = new Promise((resolve) => { entered = resolve; });
service.catalogs.get("official").resolveAvatar = async () => { entered(); await new Promise((resolve) => { release = resolve; }); return null; };
const pending = service.author(query); await started; plugin = { ...plugin, archiveSha256: "b".repeat(64) }; release();
await assert.rejects(pending, /Installed plugin changed/);
});
test("avatar decoding validates bytes and uses the existing bounded rasterizer", async () => {
let calls = 0;
const service = imageService(async () => { calls++; throw new Error("Decode failed"); });
const request = { sourceId: "official", reference: avatar, read: async () => sourcePng };
await assert.rejects(service.resolveCatalogAvatar(request), /Decode failed/); assert.equal(calls, 1);
await assert.rejects(service.resolveCatalogAvatar({ ...request, read: async () => Buffer.alloc(sourcePng.length) }), /bytes/); assert.equal(calls, 1);
await assert.rejects(service.resolveCatalogAvatar({ ...request, reference: { ...avatar, path: `/assets/${avatar.sha256}.webp` } }), /format|signature/); assert.equal(calls, 1);
const cached = imageService();
await cached.resolveCatalogAvatar(request);
await assert.rejects(cached.resolveCatalogAvatar({ ...request, reference: { ...avatar, size: avatar.size + 1 } }), /bytes/);
await assert.rejects(cached.resolveCatalogAvatar({ ...request, reference: { ...avatar, path: `/assets/${avatar.sha256}.webp` } }), /format|signature/);
});
@@ -1,7 +1,6 @@
"use strict";
const path = require("node:path");
const { createHash } = require("node:crypto");
const { readContainedFile } = require("./pluginProtocol.cjs");
const { createIsolatedContributionIconRasterizer } = require("./contributionIconRasterizer.cjs");
@@ -215,35 +214,6 @@ class PluginContributionIconService {
return pending;
}
// Called only with a host-selected, hash-bound registry reference. It shares
// the existing decoder, queue, cache and pending work with package icons.
async resolveCatalogAvatar({ sourceId, reference, read }) {
const key = JSON.stringify(["catalog-avatar", sourceId, reference.path, reference.sha256, reference.size]);
if (this.cache.has(key)) return this.cache.get(key);
if (this.pending.has(key)) return this.pending.get(key);
if (this.pending.size >= MAX_QUEUED_ICON_RASTERIZATIONS + MAX_CONCURRENT_ICON_RASTERIZATIONS) {
throw new Error("Plugin image queue is full");
}
const pending = (async () => {
const body = await read();
if (!Buffer.isBuffer(body) || body.length !== reference.size || body.length > 256 * 1024
|| createHash("sha256").update(body).digest("hex") !== reference.sha256) throw new Error("Invalid plugin avatar bytes");
const extension = path.extname(reference.path);
if (![".png", ".webp"].includes(extension)) throw new Error("Invalid plugin avatar format");
const inspected = inspectContributionIconSource(body, extension);
if (inspected.mimeType !== (extension === ".png" ? "image/png" : "image/webp")) throw new Error("Plugin avatar format mismatch");
const png = await this.rasterizeIcon({ body, ...inspected, maxEdge: MAX_ICON_EDGE });
if (!png.length || png.length > MAX_ICON_PNG_BYTES) throw new Error("Plugin avatar output is too large");
inspectContributionIconSource(png, ".png", MAX_ICON_EDGE);
const value = `data:image/png;base64,${png.toString("base64")}`;
if (this.cache.size >= 256) this.cache.clear();
this.cache.set(key, value);
return value;
})().finally(() => this.pending.delete(key));
this.pending.set(key, pending);
return pending;
}
async #resolveUncached({ pluginId, plugin, icon, cacheKey }) {
const packageRoot = await this.packageStore.preparePackageRoot(plugin);
const result = Object.freeze({
-9
View File
@@ -14,12 +14,3 @@ The fixture includes no executable plugin package, avatar or credentials.
Schema revisions must be coordinated with the registry publisher. In particular,
artifact/avatar are deliberately null in v1; do not expand this consumer alone.
`delivery-v2.schema.json` is vendored without changes from
`binaricat/netcatty-plugins@2ffed31ad6a430e3711a63a902305063bd2504f8`,
`schema/delivery-v2.schema.json` (Git blob
`7a57504fbea272ca0e873725e050d1f15352cb5b`). The v2 consumer retains the pinned v1
schema and permits positive-size package references up to 16MiB and PNG/WebP avatar references.
Avatar references require positive size up to 256KiB and exact content-addressed
paths. No v3 path is added. The original bootstrap bytes remain unchanged, and
client support does not publish or activate live metadata.
@@ -1,222 +0,0 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "Netcatty static delivery v2 (reviewed avatars and packages)",
"type": "object",
"additionalProperties": false,
"required": [
"schemaVersion",
"registryCommit",
"plugins",
"catalogSha256",
"indexSha256",
"sourceLockSha256",
"builderSha256"
],
"properties": {
"schemaVersion": {
"const": 2
},
"registryCommit": {
"type": "string",
"pattern": "^[a-f0-9]{40}$"
},
"plugins": {
"type": "array",
"maxItems": 1000,
"items": {
"type": "object",
"additionalProperties": false,
"required": [
"id",
"version",
"repository",
"commit",
"readme",
"iconLight",
"iconDark",
"avatar",
"artifact"
],
"properties": {
"id": {
"type": "string"
},
"version": {
"type": "string"
},
"repository": {
"type": "string"
},
"commit": {
"type": "string",
"pattern": "^[a-f0-9]{40}$"
},
"readme": {
"type": "object",
"additionalProperties": false,
"required": [
"path",
"sha256",
"size"
],
"properties": {
"path": {
"type": "string",
"pattern": "^/assets/[a-f0-9]{64}\\.(md|png|webp|ncpkg)$"
},
"sha256": {
"type": "string",
"pattern": "^[a-f0-9]{64}$"
},
"size": {
"type": "integer",
"minimum": 0,
"maximum": 262144
}
}
},
"iconLight": {
"oneOf": [
{
"type": "null"
},
{
"type": "object",
"additionalProperties": false,
"required": [
"path",
"sha256",
"size"
],
"properties": {
"path": {
"type": "string",
"pattern": "^/assets/[a-f0-9]{64}\\.(md|png|webp|ncpkg)$"
},
"sha256": {
"type": "string",
"pattern": "^[a-f0-9]{64}$"
},
"size": {
"type": "integer",
"minimum": 0,
"maximum": 262144
}
}
}
]
},
"iconDark": {
"oneOf": [
{
"type": "null"
},
{
"type": "object",
"additionalProperties": false,
"required": [
"path",
"sha256",
"size"
],
"properties": {
"path": {
"type": "string",
"pattern": "^/assets/[a-f0-9]{64}\\.(md|png|webp|ncpkg)$"
},
"sha256": {
"type": "string",
"pattern": "^[a-f0-9]{64}$"
},
"size": {
"type": "integer",
"minimum": 0,
"maximum": 262144
}
}
}
]
},
"avatar": {
"oneOf": [
{
"type": "null"
},
{
"type": "object",
"additionalProperties": false,
"required": [
"path",
"sha256",
"size"
],
"properties": {
"path": {
"type": "string",
"pattern": "^/assets/[a-f0-9]{64}\\.(png|webp)$"
},
"sha256": {
"type": "string",
"pattern": "^[a-f0-9]{64}$"
},
"size": {
"type": "integer",
"minimum": 1,
"maximum": 262144
}
}
}
]
},
"artifact": {
"oneOf": [
{
"type": "null"
},
{
"type": "object",
"additionalProperties": false,
"required": [
"path",
"sha256",
"size"
],
"properties": {
"path": {
"type": "string",
"pattern": "^/assets/[a-f0-9]{64}\\.ncpkg$"
},
"sha256": {
"type": "string",
"pattern": "^[a-f0-9]{64}$"
},
"size": {
"type": "integer",
"minimum": 1,
"maximum": 16777216
}
}
}
]
}
}
}
},
"catalogSha256": {
"type": "string",
"pattern": "^[a-f0-9]{64}$"
},
"indexSha256": {
"type": "string",
"pattern": "^[a-f0-9]{64}$"
},
"sourceLockSha256": {
"type": "string",
"pattern": "^[a-f0-9]{64}$"
},
"builderSha256": {
"type": "string",
"pattern": "^[a-f0-9]{64}$"
}
}
}
+9 -3
View File
@@ -16,7 +16,14 @@ ajv.addSchema(require("./generated/plugin-contract.schema.json"));
const validateIndex = ajv.compile(require("./distribution/index.schema.json"));
const deliverySchema = require("./distribution/delivery.schema.json");
const validateDelivery = ajv.compile(deliverySchema);
const validateDeliveryV2 = ajv.compile(require("./distribution/delivery-v2.schema.json"));
const deliveryV2 = structuredClone(deliverySchema);
deliveryV2.title = "Netcatty static delivery v2 (hash-bound packages)";
deliveryV2.properties.schemaVersion.const = 2;
const packageRef = structuredClone(deliveryV2.properties.plugins.items.properties.readme);
packageRef.properties.path.pattern = "^/assets/[a-f0-9]{64}\\.ncpkg$";
packageRef.properties.size.maximum = 32 * 1024 * 1024;
deliveryV2.properties.plugins.items.properties.artifact = { oneOf: [{ type: "null" }, packageRef] };
const validateDeliveryV2 = ajv.compile(deliveryV2);
const digest = (bytes) => createHash("sha256").update(bytes).digest("hex");
const tuple = (entry) => JSON.stringify([entry.id, entry.version, entry.repository, entry.commit]);
@@ -94,10 +101,9 @@ function validateSet(pointer, entries, index, delivery, origin = DISTRIBUTION_OR
if (!catalogTuples.has(key) || resources.has(key)) throw new Error("Delivery identity does not match catalog tuple");
assertAsset(plugin.readme, ["md"]);
for (const icon of [plugin.iconLight, plugin.iconDark]) if (icon !== null) assertAsset(icon, ["png", "webp"]);
if (plugin.avatar !== null) assertAsset(plugin.avatar, ["png", "webp"]);
const entry = entries.find((item) => tuple(item) === key);
if (plugin.artifact !== null) {
assertReference(plugin.artifact, `/assets/${plugin.artifact.sha256}.ncpkg`, 16 * 1024 * 1024);
assertReference(plugin.artifact, `/assets/${plugin.artifact.sha256}.ncpkg`, 32 * 1024 * 1024);
if (!entry.artifact || entry.artifact.sha256 !== plugin.artifact.sha256 || entry.artifact.url !== `${origin}${plugin.artifact.path}`) throw new Error("Package delivery does not match catalog hash and source");
} else if (entry.artifact) throw new Error("Catalog package has no delivery resource");
resources.set(key, plugin);
+7 -7
View File
@@ -207,12 +207,6 @@ class PluginFilesystemBroker {
};
}
validatePath(params) { return assertPathParams(params); }
validateDirectory(params) {
if (typeof this.openDirectoryHandle !== "function") {
throw new PluginRpcError(RPC_ERRORS.unsupported, "Secure plugin directory enumeration is unavailable on this runtime");
}
return assertPathParams(params);
}
describeReadAuthorization(params, resourceKind = "exact") {
const value = assertPathParams(params);
@@ -335,11 +329,17 @@ class PluginFilesystemBroker {
}
async readDirectory(params, context) {
const value = this.validateDirectory(params);
const value = assertPathParams(params);
assertAuthorizedPath(context, "filesystem.read", value.path);
const { canonical, stats } = await resolveExistingPath(value.path, this.fileSystem);
assertAuthorizedPath(context, "filesystem.read", canonical);
if (!stats.isDirectory()) throw invalidArgument("Plugin filesystem directory target is not a directory");
if (!this.openDirectoryHandle) {
throw new PluginRpcError(
RPC_ERRORS.unsupported,
"Secure plugin directory enumeration is unavailable on this runtime",
);
}
const directory = await this.openDirectoryHandle(canonical, { signal: context.signal });
if (
!directory
@@ -1771,6 +1771,8 @@
"enum": [
"storage",
"ai.invoke",
"ai.agent.use",
"ai.agent.actions",
"context.selected.list",
"context.selected.read",
"runtime.advanced",
@@ -1815,6 +1817,8 @@
"enum": [
"storage",
"ai.invoke",
"ai.agent.use",
"ai.agent.actions",
"context.selected.list",
"context.selected.read",
"runtime.advanced",
@@ -7158,9 +7162,6 @@
"TerminalInputDraftFeature": {
"const": "terminal.input-drafts"
},
"FilesystemDirectoryEnumerationFeature": {
"const": "filesystem.directory-enumeration"
},
"HostTerminalInputProposalRequest": {
"type": "object",
"required": [
@@ -7189,6 +7190,402 @@
}
},
"additionalProperties": false
},
"PluginAgentFeature": {
"const": "ai.agent-sessions"
},
"PluginAgentBackendKind": {
"enum": [
"catty",
"claude",
"codex",
"copilot",
"cursor",
"codebuddy",
"opencode",
"grok",
"mimo"
]
},
"PluginAgentBackend": {
"type": "object",
"required": [
"backendRef",
"label",
"backend",
"status",
"reason"
],
"properties": {
"backendRef": {
"type": "string",
"format": "uuid"
},
"label": {
"type": "string",
"maxLength": 160
},
"backend": {
"$ref": "#/$defs/PluginAgentBackendKind"
},
"status": {
"enum": [
"supported",
"unsupported",
"unavailable"
]
},
"reason": {
"enum": [
"ready",
"not-configured",
"native-boundary-unverified",
"configuration-unavailable"
]
}
},
"additionalProperties": false
},
"PluginAgentBackendsResult": {
"type": "object",
"required": [
"items"
],
"properties": {
"items": {
"type": "array",
"maxItems": 32,
"items": {
"$ref": "#/$defs/PluginAgentBackend"
}
}
},
"additionalProperties": false
},
"PluginAgentTargetSummary": {
"type": "object",
"required": [
"title",
"kind"
],
"properties": {
"title": {
"type": "string",
"maxLength": 160
},
"kind": {
"const": "terminal"
}
},
"additionalProperties": false
},
"PluginAgentTargetsResult": {
"type": "object",
"required": [
"selectionHandle",
"expiresAt",
"items"
],
"properties": {
"selectionHandle": {
"type": "string",
"format": "uuid"
},
"expiresAt": {
"type": "integer",
"minimum": 0
},
"items": {
"type": "array",
"maxItems": 32,
"items": {
"$ref": "#/$defs/PluginAgentTargetSummary"
}
}
},
"additionalProperties": false
},
"PluginAgentOpenParams": {
"type": "object",
"required": [
"backendRef",
"selectionHandle"
],
"properties": {
"backendRef": {
"type": "string",
"format": "uuid"
},
"selectionHandle": {
"type": "string",
"format": "uuid"
},
"conversationRef": {
"type": "string",
"format": "uuid"
}
},
"additionalProperties": false
},
"PluginAgentChatRef": {
"type": "object",
"required": [
"chatHandle"
],
"properties": {
"chatHandle": {
"type": "string",
"format": "uuid"
}
},
"additionalProperties": false
},
"PluginAgentChatResult": {
"type": "object",
"required": [
"chatHandle",
"conversationRef",
"backendLabel",
"expiresAt"
],
"properties": {
"chatHandle": {
"type": "string",
"format": "uuid"
},
"conversationRef": {
"type": "string",
"format": "uuid"
},
"backendLabel": {
"type": "string",
"maxLength": 160
},
"expiresAt": {
"type": "integer",
"minimum": 0
}
},
"additionalProperties": false
},
"PluginAgentChatSummary": {
"type": "object",
"required": [
"conversationRef",
"title",
"backend",
"updatedAt"
],
"properties": {
"conversationRef": {
"type": "string",
"format": "uuid"
},
"title": {
"type": "string",
"maxLength": 160
},
"backend": {
"$ref": "#/$defs/PluginAgentBackendKind"
},
"updatedAt": {
"type": "integer",
"minimum": 0
}
},
"additionalProperties": false
},
"PluginAgentChatsResult": {
"type": "object",
"required": [
"items"
],
"properties": {
"items": {
"type": "array",
"maxItems": 64,
"items": {
"$ref": "#/$defs/PluginAgentChatSummary"
}
}
},
"additionalProperties": false
},
"PluginAgentSendParams": {
"type": "object",
"required": [
"chatHandle",
"text"
],
"properties": {
"chatHandle": {
"type": "string",
"format": "uuid"
},
"text": {
"type": "string",
"minLength": 1,
"maxLength": 16384,
"pattern": "^[^\\u0000]*$"
}
},
"additionalProperties": false
},
"PluginAgentTurnRef": {
"type": "object",
"required": [
"turnHandle"
],
"properties": {
"turnHandle": {
"type": "string",
"format": "uuid"
}
},
"additionalProperties": false
},
"PluginAgentTurnResult": {
"type": "object",
"required": [
"turnHandle",
"expiresAt"
],
"properties": {
"turnHandle": {
"type": "string",
"format": "uuid"
},
"expiresAt": {
"type": "integer",
"minimum": 0
}
},
"additionalProperties": false
},
"PluginAgentMessage": {
"type": "object",
"required": [
"role",
"text"
],
"properties": {
"role": {
"enum": [
"user",
"assistant"
]
},
"text": {
"type": "string",
"maxLength": 32768
}
},
"additionalProperties": false
},
"PluginAgentHistoryResult": {
"type": "object",
"required": [
"items"
],
"properties": {
"items": {
"type": "array",
"maxItems": 200,
"items": {
"$ref": "#/$defs/PluginAgentMessage"
}
}
},
"additionalProperties": false
},
"PluginAgentEvent": {
"oneOf": [
{
"type": "object",
"required": [
"kind",
"text"
],
"properties": {
"kind": {
"const": "text"
},
"text": {
"type": "string",
"maxLength": 4096
}
},
"additionalProperties": false
},
{
"type": "object",
"required": [
"kind",
"capabilityId",
"state"
],
"properties": {
"kind": {
"const": "activity"
},
"capabilityId": {
"type": "string",
"maxLength": 160
},
"state": {
"enum": [
"started",
"completed",
"denied"
]
}
},
"additionalProperties": false
},
{
"type": "object",
"required": [
"kind",
"inputTokens",
"outputTokens"
],
"properties": {
"kind": {
"const": "usage"
},
"inputTokens": {
"type": "integer",
"minimum": 0
},
"outputTokens": {
"type": "integer",
"minimum": 0
}
},
"additionalProperties": false
},
{
"type": "object",
"required": [
"kind",
"reason"
],
"properties": {
"kind": {
"const": "end"
},
"reason": {
"enum": [
"completed",
"cancelled",
"unavailable",
"budget-exceeded"
]
}
},
"additionalProperties": false
}
]
}
}
}
+4
View File
@@ -0,0 +1,4 @@
"use strict";
require("./agent/delegatedScope.test.cjs");
require("./agent/conversationStore.test.cjs");
require("./agent/contract.test.cjs");
+2 -6
View File
@@ -36,7 +36,6 @@ const { HOST_INTERACTION_FEATURE, PluginHostInteractionService } = require("./ho
const { PluginHostVaultService } = require("./hostVaultService.cjs");
const { PluginAISourceBinding } = require("./ai/sourceBinding.cjs");
const HOST_VAULT_FEATURE = require("./generated/plugin-contract.schema.json").$defs.HostVaultFeature.const;
const DIRECTORY_ENUMERATION_FEATURE = require("./generated/plugin-contract.schema.json").$defs.FilesystemDirectoryEnumerationFeature.const;
const { PluginPermissionEngine } = require("./permissionEngine.cjs");
const { PluginProtocol } = require("./pluginProtocol.cjs");
const { PluginViewHost } = require("./pluginViewHost.cjs");
@@ -100,9 +99,7 @@ function createPluginHostService(options) {
const paths = createPluginPaths(options.app.getPath("userData"));
const appRoot = options.appRoot ?? options.app.getAppPath();
const runtimeDirectory = options.runtimeDirectory ?? path.join(__dirname, "runtime");
const supportedFeatures = [...new Set(["terminal.input-drafts", "terminal.selection-sharing", "appearance.theme-packs", "metadata.brand-icon", BYTE_STREAM_FEATURE, ...PLUGIN_AI_CONTEXT_FEATURES, NETWORK_CREDENTIAL_LEASE_FEATURE, HOST_INTERACTION_FEATURE, HOST_VAULT_FEATURE, VAULT_PAGES_FEATURE, TERMINAL_SIDEBAR_FEATURE, INTERACTIVE_COMMAND_FEATURE, ...(options.supportedFeatures ?? [])])]
.filter((feature) => feature !== DIRECTORY_ENUMERATION_FEATURE);
if (typeof options.openPluginDirectoryHandle === "function") supportedFeatures.push(DIRECTORY_ENUMERATION_FEATURE);
const supportedFeatures = [...new Set(["terminal.input-drafts", "terminal.selection-sharing", "appearance.theme-packs", "metadata.brand-icon", BYTE_STREAM_FEATURE, ...PLUGIN_AI_CONTEXT_FEATURES, NETWORK_CREDENTIAL_LEASE_FEATURE, HOST_INTERACTION_FEATURE, HOST_VAULT_FEATURE, VAULT_PAGES_FEATURE, TERMINAL_SIDEBAR_FEATURE, INTERACTIVE_COMMAND_FEATURE, ...(options.supportedFeatures ?? [])])];
const database = new PluginDatabase(paths.database);
let interactionService;
let byteStreamService;
@@ -481,8 +478,7 @@ function createPluginHostService(options) {
themeService,
interactionService,
hostVaultService,
catalogService: new MarketSourcesService({ database, manager, fetch: options.electron.net?.fetch?.bind(options.electron.net),
resolveAvatar: (request) => contributionIconService.resolveCatalogAvatar(request) }),
catalogService: new MarketSourcesService({ database, manager, fetch: options.electron.net?.fetch?.bind(options.electron.net) }),
companionSupervisor,
contributionIconService,
contributionService,
-26
View File
@@ -335,8 +335,6 @@ test("host service wires a handle-bound directory adapter without changing the R
},
});
context.after(() => service.manager.shutdown());
assert.ok(service.runtimeSupervisor.supportedFeatures.includes("filesystem.directory-enumeration"));
assert.ok(service.packageStore.supportedFeatures.includes("filesystem.directory-enumeration"));
const pluginManifest = {
id: "com.example.directory",
name: "directory",
@@ -483,27 +481,3 @@ test("Deny for the privileged development runtime stops its activation through t
assert.equal(stops, 1); assert.equal(current, identity); assert.equal(service.database.getActivePlugin(manifest.id).enabled, true);
service.database.privateOwner = privateOwner;
});
test("production host composition does not advertise or prompt for unavailable directory enumeration", async (context) => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-plugin-directory-availability-"));
context.after(() => fs.rmSync(root, { recursive: true, force: true }));
let prompts = 0;
const feature = "filesystem.directory-enumeration";
// Same adapter-free composition as main/registerBridges; even an explicit extra
// supportedFeatures entry cannot claim an adapter that was never supplied.
const service = createPluginHostService({ ...createOptions(root), supportedFeatures: [feature],
requestPermissionDecision: async () => { prompts++; throw new Error("must not prompt"); } });
context.after(() => service.manager.shutdown());
assert.equal(service.runtimeSupervisor.supportedFeatures.includes(feature), false);
assert.equal(service.packageStore.supportedFeatures.includes(feature), false);
let authorizations = 0, probes = 0;
service.permissionEngine.authorize = async () => { authorizations++; throw new Error("must not authorize"); };
service.filesystemBroker.fileSystem = new Proxy({}, { get() { probes++; throw new Error("must not probe"); } });
const manifest = { id: "com.example.unavailable-directory", version: "1.0.0",
permissions: { optional: [{ permission: "filesystem.read", resources: [root] }] } };
const routes = service.rpcRegistry.createRoutes({ pluginId: manifest.id, pluginVersion: manifest.version,
runtimeId: "runtime-unavailable", runtimeKind: "browser", manifest });
await assert.rejects(routes.requestHandlers["filesystem.readDirectory"]({ path: root }, transportContext()),
(error) => error.code === -32012 && /unavailable/.test(error.message));
assert.equal(authorizations, 0); assert.equal(prompts, 0); assert.equal(probes, 0);
});
+3 -65
View File
@@ -2,9 +2,6 @@
const { PluginSourceRegistry } = require("./sourceRegistry.cjs");
const { RegistryCatalogService } = require("./registryCatalogService.cjs");
const { createSourceFetch } = require("./sourceFetch.cjs");
const { isDeepStrictEqual } = require("node:util");
const installedIdentity = (plugin) => JSON.stringify([plugin?.id, plugin?.activeVersion, plugin?.archiveSha256, plugin?.source, plugin?.manifest, plugin?.runtime?.quarantinedAt]);
class MarketSourcesService {
constructor(options) {
@@ -12,15 +9,6 @@ class MarketSourcesService {
this.manager = options.manager;
this.registry = new PluginSourceRegistry(options.database);
this.catalogs = new Map();
this.catalogIdentities = new Map();
this.changeListeners = new Set();
}
onDidChange(listener) {
this.changeListeners.add(listener);
return { dispose: () => this.changeListeners.delete(listener) };
}
#changed(sourceId) {
for (const listener of this.changeListeners) listener({ sourceId });
}
#catalog(sourceId = "official") {
const source = this.registry.get(sourceId);
@@ -34,71 +22,21 @@ class MarketSourcesService {
}
return catalog;
}
#publishCatalogIdentity(sourceId, entries) {
// Selection tokens bind the validated entry and its delivery resources.
const identity = JSON.stringify(entries.map((entry) => [entry.id, entry.selectionToken]));
if (this.catalogIdentities.get(sourceId) !== identity) {
this.catalogIdentities.set(sourceId, identity);
this.#changed(sourceId);
}
}
list(sourceId = "official") {
return this.#catalog(sourceId).list().then((snapshot) => {
this.#publishCatalogIdentity(sourceId, snapshot.entries);
return snapshot;
});
}
list(sourceId) { return this.#catalog(sourceId).list(); }
readme(id, token, sourceId) { return this.#catalog(sourceId).readme(id, token); }
async author(request) {
if (!request || typeof request !== "object" || Array.isArray(request)
|| typeof request.pluginId !== "string" || !/^[a-z0-9][a-z0-9.-]{1,127}$/u.test(request.pluginId)) throw new TypeError("Invalid plugin author request");
if (request.kind === "catalog") {
if (Object.keys(request).some((key) => !["kind", "pluginId", "selectionToken", "sourceId"].includes(key))) throw new TypeError("Invalid plugin author request");
return this.#catalog(request.sourceId).author(request.pluginId, request.selectionToken);
}
if (request.kind !== "installed" || Object.keys(request).some((key) => !["kind", "pluginId"].includes(key))) throw new TypeError("Invalid installed author request");
const plugin = this.options.database.getActivePlugin(request.pluginId);
if (!plugin?.activeVersion) return null;
const captured = installedIdentity(plugin);
let value = { author: plugin.manifest?.publisher ?? "", avatar: null, officialMaintained: false,
sourceId: plugin.source.id, version: plugin.activeVersion, archiveSha256: plugin.archiveSha256 ?? null };
if (plugin.runtime?.quarantinedAt == null) {
try {
// Presentation reuses an already validated catalog. It never refreshes
// a source merely because the installed list becomes visible.
this.registry.get(plugin.source.id);
const catalog = this.catalogs.get(plugin.source.id);
const entry = catalog?.publicEntries.find((item) => item.id === plugin.id && item.version === plugin.activeVersion
&& item.artifact?.sha256 === plugin.archiveSha256 && isDeepStrictEqual(item.manifest, plugin.manifest));
if (entry) value = await catalog.author(entry.id, entry.selectionToken);
} catch { /* No exact current source match confers no maintenance badge. */ }
}
if (captured !== installedIdentity(this.options.database.getActivePlugin(request.pluginId))) throw new Error("Installed plugin changed while loading its author");
return value;
}
install(id, token, sourceId = "official", acceptedSourceId) {
if (sourceId !== "official" && acceptedSourceId !== sourceId) throw new Error("Review and confirm this third-party source before installing");
const catalog = this.#catalog(sourceId);
return catalog.install(id, token).finally(() => {
// A rejected stale selection can still have published a newer snapshot.
this.#publishCatalogIdentity(sourceId, catalog.publicEntries);
});
return this.#catalog(sourceId).install(id, token);
}
listSources() { return this.registry.list(); }
addSource(payload) { return this.manager.changeMarketSource(() => this.registry.add(payload?.name, payload?.address)); }
async updateSource(payload) {
const source = await this.manager.changeMarketSource(() => this.registry.update(payload?.id, payload?.name, payload?.enabled));
this.#changed(source.id);
return source;
}
updateSource(payload) { return this.manager.changeMarketSource(() => this.registry.update(payload?.id, payload?.name, payload?.enabled)); }
async removeSource(payload) {
if (typeof payload?.disablePlugins !== "boolean") throw new Error("Choose whether to disable installed plugins");
await this.manager.changeMarketSource(() => {
this.registry.remove(payload.id);
this.catalogs.delete(payload.id);
this.catalogIdentities.delete(payload.id);
}, payload.disablePlugins ? payload.id : undefined);
this.#changed(payload.id);
}
}
module.exports = { MarketSourcesService };
-7
View File
@@ -41,9 +41,7 @@ const CHANNELS = Object.freeze({
updateSource: "netcatty:plugins:update-source",
removeSource: "netcatty:plugins:remove-source",
catalog: "netcatty:plugins:catalog",
catalogChanged: "netcatty:plugins:catalog-changed",
catalogReadme: "netcatty:plugins:catalog-readme",
author: "netcatty:plugins:author",
catalogInstall: "netcatty:plugins:catalog-install",
install: "netcatty:plugins:install",
setEnabled: "netcatty:plugins:set-enabled",
@@ -681,10 +679,6 @@ function registerPluginBridge(ipcMain, options) {
handle(CHANNELS.updateSource, async (_manager, payload) => options.catalogService.updateSource(payload));
handle(CHANNELS.removeSource, async (_manager, payload) => options.catalogService.removeSource(payload));
handle(CHANNELS.catalog, async (_manager, payload) => options.catalogService?.list(payload?.sourceId) ?? { entries: [], source: "builtin", error: "Plugin catalog unavailable" });
handle(CHANNELS.author, async (_manager, payload) => {
if (!options.catalogService) throw new Error("Plugin catalog unavailable");
return options.catalogService.author(payload);
});
handle(CHANNELS.catalogReadme, async (_manager, payload) => {
if (!options.catalogService) throw new Error("Plugin catalog unavailable");
return options.catalogService.readme(payload?.id, payload?.selectionToken, payload?.sourceId);
@@ -1709,7 +1703,6 @@ function registerPluginBridge(ipcMain, options) {
publishScopeCatalog(event);
return null;
});
options.catalogService?.onDidChange?.((event) => options.broadcast?.(CHANNELS.catalogChanged, event));
contributionService?.onDidChange?.((event) => options.broadcast?.(CHANNELS.contributionsChanged, event));
contributionService?.onDidPostViewMessage?.((event) => options.broadcast?.(CHANNELS.viewMessagePosted, event));
viewHost?.onDidClose?.((event) => options.broadcast?.(CHANNELS.viewClosed, event));
-13
View File
@@ -101,19 +101,6 @@ test("plugin management bridge checks sender ownership before invoking manager",
);
});
test("author presentation is a trusted-app-only route and forwards no invented authority", async () => {
const ipcMain = createIpcMain(); const calls = [];
registerPluginBridge(ipcMain, { manager: { initialize: async () => {} }, isTrustedSender: createTrustedPluginBridgeSender({ devServerUrl: "http://localhost:5173" }),
catalogService: { author: async (request) => { calls.push(request); return { author: "Netcatty", avatar: null, officialMaintained: false }; } } });
const payload = { kind: "catalog", pluginId: "com.netcatty.starter", selectionToken: "a".repeat(64), sourceId: "official" };
for (const url of ["netcatty-plugin://plugin-runtime/index.html", "https://attacker.invalid/"]) {
await assert.rejects(ipcMain.handlers.get(CHANNELS.author)({ senderFrame: { url } }, payload), /Untrusted/);
}
assert.deepEqual(calls, []);
const result = await ipcMain.handlers.get(CHANNELS.author)({ senderFrame: { url: "app://netcatty/index.html" } }, payload);
assert.equal(result.officialMaintained, false); assert.deepEqual(calls, [payload]);
});
test("plugin Vault credential catalog updates only through the trusted host bridge", async () => {
const ipcMain = createIpcMain();
const updates = [];
@@ -1,90 +0,0 @@
"use strict";
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const test = require("node:test");
const { PluginDatabase } = require("./database.cjs");
const { PluginContributionService } = require("./contributionService.cjs");
const { PluginSyncSidecarService } = require("./pluginSyncSidecarService.cjs");
const { registerPluginBridge, CHANNELS } = require("./pluginBridge.cjs");
const { OFFICIAL_SOURCE, thirdPartySource } = require("./sourceIdentity.cjs");
const id = "com.example.sync-composition";
const synced = `${id}.synced`, local = `${id}.local`;
const manifest = {
manifestVersion: 1, id, name: "Synthetic sync composition", version: "1.0.0", publisher: "example",
engines: { netcatty: ">=0.0.0", api: ">=0.1.0-internal <0.2.0" }, main: { browser: "index.js" },
contributes: { settings: [
{ id: synced, label: "Synced", control: "text", scope: "application", default: "default-sync", sync: true },
{ id: local, label: "Local", control: "text", scope: "application", default: "default-local", sync: false },
] },
};
function install(database, source = OFFICIAL_SOURCE, enabled = true) {
database.installVersion({ pluginId: id, version: manifest.version, manifest, source,
archiveSha256: "a".repeat(64), packageRelativePath: "synthetic/no-executable-package" }, { enable: enabled });
}
function profile(context, label, clock) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), `netcatty-sync-composition-${label}-`));
const database = new PluginDatabase(path.join(root, "plugins.sqlite"), { clock: () => clock });
context.after(() => { database.close(); fs.rmSync(root, { recursive: true, force: true }); });
const runtimeSupervisor = {
start: async () => { throw new Error("composition test must not execute a plugin"); },
request: async () => { throw new Error("composition test must not call a plugin"); },
notify: async () => {},
};
const contributions = new PluginContributionService({ database, runtimeSupervisor, getLocale: () => "en" });
const sidecars = new PluginSyncSidecarService({ database, contributionService: contributions });
const handlers = new Map();
registerPluginBridge({ handle: (name, handler) => handlers.set(name, handler), on() {} }, {
manager: { initialize: async () => {}, database }, contributionService: contributions,
syncSidecarService: sidecars, isTrustedSender: (event) => event?.trusted === true,
});
return { database, contributions, sidecars,
collect: () => handlers.get(CHANNELS.syncSidecarsCollect)({ trusted: true }),
apply: (bundle) => handlers.get(CHANNELS.syncSidecarsApply)({ trusted: true }, bundle),
untrustedApply: (bundle) => handlers.get(CHANNELS.syncSidecarsApply)({ trusted: false }, bundle) };
}
test("two real profiles collect/apply/hydrate only opted-in source-bound settings through registered host entries", async (context) => {
for (const state of ["enabled", "disabled", "missing", "other-source"]) {
await context.test(state, async (context) => {
const a = profile(context, "sender", 200), b = profile(context, "recipient", 100);
install(a.database);
await a.contributions.updateSetting(id, synced, "synthetic-shared-value");
await a.contributions.updateSetting(id, local, "synthetic-device-only-value");
const bundle = await a.collect();
assert.equal(bundle.entries.filter((entry) => entry.kind === "settings").length, 1);
assert.equal(bundle.entries.some((entry) => entry.value === "synthetic-device-only-value"), false);
const owner = a.database.privateOwner(id);
assert.equal(bundle.entries[0].pluginId, owner);
if (state !== "missing") install(b.database,
state === "other-source" ? thirdPartySource("Synthetic community", "https://community.example.com") : OFFICIAL_SOURCE,
state !== "disabled");
await assert.rejects(b.untrustedApply(bundle), /Untrusted/);
assert.equal(b.database.listAllSyncSidecars().length, 0, "untrusted sender never applies remote rows");
const result = await b.apply(bundle);
assert.equal(result.applied, true);
assert.equal(b.database.getSetting(id, local, "application", "application"), undefined);
if (state === "enabled") {
assert.equal(await b.contributions.getSetting(id, synced), "synthetic-shared-value");
assert.equal(await b.contributions.getSetting(id, local), "default-local");
} else {
assert.equal(b.database.getSetting(id, synced, "application", "application"), undefined,
"missing, disabled or different-source plugins cannot receive active settings");
assert.ok(b.database.listAllSyncSidecars().some((entry) => entry.pluginId === owner), "remote row is retained without execution");
await b.sidecars.hydrateInstalledPluginSettings(id);
assert.equal(b.database.getSetting(id, synced, "application", "application"), undefined);
if (state === "disabled") b.database.setEnabled(id, true);
if (state === "missing") install(b.database);
await b.sidecars.hydrateInstalledPluginSettings(id);
assert.equal(await b.contributions.getSetting(id, synced), state === "other-source" ? "default-sync" : "synthetic-shared-value");
if (state === "other-source") assert.notEqual(b.database.privateOwner(id), owner);
}
const collected = await b.collect();
assert.equal(collected.entries.some((entry) => entry.value === "synthetic-device-only-value"), false);
assert.equal(await a.contributions.getSetting(id, local), "synthetic-device-only-value", "sender's local-only setting stays local");
});
}
});
@@ -3,7 +3,6 @@
const { createHash } = require("node:crypto");
const { PluginCatalogService, validateCatalog, MAX_CATALOG_BYTES } = require("./catalogService.cjs");
const { REGISTRY_URL, fetchDistribution, fetchVerified, tuple } = require("./distributionSnapshot.cjs");
const { isOfficialMaintained } = require("./sourceIdentity.cjs");
const tokenFor = (entry) => createHash("sha256").update(JSON.stringify(entry)).digest("hex");
function prepareSnapshot(value, resources = new Map(), source) {
@@ -20,7 +19,6 @@ class RegistryCatalogService extends PluginCatalogService {
this.source = "builtin";
this.error = null;
this.refreshing = null;
this.resolveAvatar = options.resolveAvatar;
}
async refresh() {
if (this.refreshing) return this.refreshing;
@@ -60,22 +58,6 @@ class RegistryCatalogService extends PluginCatalogService {
return entry;
}
artifactSize(entry) { return this.resources.get(tuple(entry))?.artifact?.size; }
async author(id, token) {
this.sourceGuard();
const entry = this.selected(id, token);
const reference = this.resources.get(tuple(entry))?.avatar;
let avatar = null;
if (reference && this.resolveAvatar) {
try {
avatar = await this.resolveAvatar({ sourceId: this.marketSource.id, reference,
read: () => fetchVerified(reference, this.fetch, this.origin) });
} catch { /* Missing or invalid imagery uses the same author placeholder. */ }
}
this.sourceGuard();
this.selected(id, token);
return { author: entry.author, avatar, officialMaintained: isOfficialMaintained(entry, this.marketSource),
sourceId: this.marketSource.id, version: entry.version, archiveSha256: entry.artifact?.sha256 ?? null };
}
async readme(id, token) {
const entry = this.selected(id, token);
const resource = this.resources.get(tuple(entry))?.readme;
+3 -7
View File
@@ -5,7 +5,7 @@ import {
PLUGIN_ERROR_WIRE_CODES,
pluginErrorToRpcError,
} from "@netcatty/plugin-sdk";
import { createMessagePortStreamEnvelope, PLUGIN_HOST_INTERACTION_LIMITS, PLUGIN_FILESYSTEM_DIRECTORY_ENUMERATION_FEATURE } from "@netcatty/plugin-contract";
import { createMessagePortStreamEnvelope, PLUGIN_HOST_INTERACTION_LIMITS } from "@netcatty/plugin-contract";
import { createPluginStreamEndpoint } from "./pluginStreamEndpoint.mjs";
import { connectByteStream } from "./byteStreamClient.mjs";
@@ -412,12 +412,8 @@ function createPluginContext(config, client, runtimeApi) {
...options,
}).then(() => undefined),
stat: (filePath) => client.request("filesystem.stat", { path: filePath }),
readDirectory: async (directoryPath) => {
if (!config.enabledFeatures?.includes(PLUGIN_FILESYSTEM_DIRECTORY_ENUMERATION_FEATURE)) {
throw new PluginError("unsupported", "Secure plugin directory enumeration is unavailable on this runtime");
}
return (await client.request("filesystem.readDirectory", { path: directoryPath })).entries;
},
readDirectory: (directoryPath) => client.request("filesystem.readDirectory", { path: directoryPath })
.then((result) => result.entries),
};
const companions = {
start: async (companionId) => {
+2 -22
View File
@@ -78,7 +78,7 @@ test("runtime peer exposes secure host capabilities over the canonical RPC trans
pluginVersion: "1.0.0",
netcattyVersion: "1.0.0",
apiVersion: "0.1.0-internal",
enabledFeatures: ["filesystem.directory-enumeration"],
enabledFeatures: [],
},
async loadPlugin() {
return {
@@ -128,7 +128,7 @@ test("runtime peer exposes secure host capabilities over the canonical RPC trans
const initialized = await host.request("plugin.initialize", {
netcattyVersion: "1.0.0",
apiVersion: "0.1.0-internal",
supportedFeatures: ["filesystem.directory-enumeration"],
supportedFeatures: [],
});
assert.equal(initialized.pluginId, "com.example.peer");
const unhandledStream = await host.streams.openOutgoing("unhandled", 1024);
@@ -616,23 +616,3 @@ test("runtime peer routes connection and importer Provider operation maps", asyn
await peer.dispose();
host.close();
});
test("actual SDK directory call rejects an unnegotiated feature without sending host RPC", async (context) => {
const { startPluginRuntime } = await import("./runtime/runtimePeer.mjs");
const { port1, port2 } = new MessageChannel();
let requests = 0;
const host = new PluginRpcRouter({ pluginId: "com.example.no-directory", send: (message) => port1.postMessage(message),
handlers: { "filesystem.readDirectory": async () => { requests++; return { entries: [] }; }, "log.write": async () => null } });
port1.on("message", (message) => host.accept(message));
const peer = await startPluginRuntime({ port: port2,
config: { pluginId: "com.example.no-directory", pluginVersion: "1.0.0", netcattyVersion: "1.0.0", apiVersion: "0.1.0-internal", enabledFeatures: [] },
async loadPlugin() { return { default: { async activate(pluginContext) {
assert.equal(pluginContext.enabledFeatures.has("filesystem.directory-enumeration"), false);
await assert.rejects(pluginContext.filesystem.readDirectory("/not-probed"),
(error) => error.code === "unsupported" && /unavailable/.test(error.message));
} } }; } });
context.after(async () => { await peer.dispose(); host.close(); port1.close(); port2.close(); });
await host.request("plugin.initialize", { netcattyVersion: "1.0.0", apiVersion: "0.1.0-internal", supportedFeatures: [] });
await host.request("plugin.activate", {});
assert.equal(requests, 0);
});
+1 -1
View File
@@ -127,7 +127,7 @@ function registerSecurePluginCapabilities(registry, options) {
options.filesystemBroker.readDirectory(params, context)
), {
metadata: { capability: "filesystem", mutating: false, permission: "filesystem.read" },
validateParams: (params) => options.filesystemBroker.validateDirectory(params),
validateParams: (params) => options.filesystemBroker.validatePath(params),
authorization: (params) => options.filesystemBroker.describeReadAuthorization(params, "directory"),
});
registry.registerRequest("filesystem.writeFile", (params, context) => (
+1 -13
View File
@@ -6,11 +6,6 @@ const { isIP } = require("node:net");
const OFFICIAL_ORIGIN = "https://plugins.netcatty.app";
const OFFICIAL_SOURCE = Object.freeze({ id: "official", kind: "official", name: "Netcatty marketplace", origin: OFFICIAL_ORIGIN });
const LOCAL_SOURCE = Object.freeze({ id: "local", kind: "local", name: "Local import", origin: null });
// Curated first-party maintenance authority, independent of manifest publisher text.
const FIRST_PARTY_PLUGINS = Object.freeze([
Object.freeze({ id: "com.netcatty.starter", repository: "binaricat/Netcatty" }),
Object.freeze({ id: "com.netcatty.hello", repository: "binaricat/Netcatty" }),
]);
const hash = (value) => createHash("sha256").update(value).digest("hex");
// Source identity is host-owned. Neither a manifest nor a catalog supplies kind/id.
@@ -46,11 +41,4 @@ function privateOwner(source, pluginId) {
return `source:${hash(assertSource(source).id)}:${pluginId}`;
}
function isOfficialMaintained(entry, source) {
const trusted = assertSource(source);
return trusted.id === OFFICIAL_SOURCE.id && trusted.kind === OFFICIAL_SOURCE.kind
&& trusted.origin === OFFICIAL_SOURCE.origin
&& FIRST_PARTY_PLUGINS.some((pair) => pair.id === entry.id && pair.repository === entry.repository);
}
module.exports = { OFFICIAL_SOURCE, LOCAL_SOURCE, canonicalSourceOrigin, thirdPartySource, assertSource, privateOwner, isOfficialMaintained };
module.exports = { OFFICIAL_SOURCE, LOCAL_SOURCE, canonicalSourceOrigin, thirdPartySource, assertSource, privateOwner };
-9
View File
@@ -106,12 +106,6 @@ function createPreloadApi(ctx) {
updatePluginSource: (source) => ipcRenderer.invoke("netcatty:plugins:update-source", source),
removePluginSource: (id, disablePlugins) => ipcRenderer.invoke("netcatty:plugins:remove-source", { id, disablePlugins }),
listPluginCatalog: (sourceId) => ipcRenderer.invoke("netcatty:plugins:catalog", { sourceId }),
getPluginAuthorPresentation: (request) => ipcRenderer.invoke("netcatty:plugins:author", request),
onPluginCatalogChanged: (callback) => {
const listener = (_event, payload) => callback(payload);
ipcRenderer.on("netcatty:plugins:catalog-changed", listener);
return () => ipcRenderer.removeListener("netcatty:plugins:catalog-changed", listener);
},
getPluginCatalogReadme: (id, selectionToken, sourceId) => ipcRenderer.invoke("netcatty:plugins:catalog-readme", { id, selectionToken, sourceId }),
installCatalogPlugin: (id, selectionToken, sourceId, acceptedSourceId) => ipcRenderer.invoke("netcatty:plugins:catalog-install", { id, selectionToken, sourceId, acceptedSourceId }),
setPluginAIHostReady: (ready) => ipcRenderer.invoke("netcatty:plugins:ai-host-ready", ready),
@@ -1155,9 +1149,6 @@ function createPreloadApi(ctx) {
setWindowOpacity: async (opacity) => {
return ipcRenderer.invoke("netcatty:setWindowOpacity", opacity);
},
setWindowBackgroundBlur: async (enabled) => {
return ipcRenderer.invoke("netcatty:setWindowBackgroundBlur", enabled);
},
setAppIconVariant: async (variant) => {
return ipcRenderer.invoke("netcatty:setAppIconVariant", variant);
},
+3 -5
View File
@@ -11,11 +11,9 @@ Group results contain the chosen current members and never include future member
label change for the first chosen host. Netcatty displays editable fields and the
normalized field diff before either action saves.
`connect` requests a saved-host connection under the plugin's permission settings.
Ask displays the target and whether saved scripts can run; Allow can dispatch
without that permission-only dialog, and Deny blocks the request. Normal SSH
authentication still applies. The acknowledgement contains no session identifier
or terminal data. Selecting metadata does not change those permission settings.
`connect` asks Netcatty to confirm a saved-host connection. The confirmation shows
whether saved scripts can run. The acknowledgement contains no session identifier
or terminal data. Selecting metadata never authorizes a connection by itself.
All sample test resources use synthetic hostnames. Native acceptance must use
synthetic hosts and must not establish a real host connection.
Vendored
-14
View File
@@ -355,18 +355,6 @@ declare global {
artifact?: { url: string; sha256: string };
}
type NetcattyPluginAuthorRequest =
| { kind: 'catalog'; pluginId: string; selectionToken: string; sourceId: string }
| { kind: 'installed'; pluginId: string };
interface NetcattyPluginAuthorPresentation {
author: string;
avatar: string | null;
officialMaintained: boolean;
sourceId: string;
version: string;
archiveSha256: string | null;
}
type PluginPermissionMode = 'allow' | 'ask' | 'deny';
type NetcattyPluginPermissionPrompt = import('@netcatty/plugin-contract').PermissionRequest & {
actionToken: string; resources: string[];
@@ -489,8 +477,6 @@ declare global {
updatePluginSource?(source: { id: string; name: string; enabled: boolean }): Promise<NetcattyPluginSource>;
removePluginSource?(id: string, disablePlugins: boolean): Promise<void>;
listPluginCatalog?(sourceId?: string): Promise<NetcattyPluginCatalogSnapshot>;
getPluginAuthorPresentation?(request: NetcattyPluginAuthorRequest): Promise<NetcattyPluginAuthorPresentation | null>;
onPluginCatalogChanged?(callback: (event: { sourceId: string }) => void): () => void;
getPluginCatalogReadme?(id: string, selectionToken?: string, sourceId?: string): Promise<string>;
installCatalogPlugin?(id: string, selectionToken?: string, sourceId?: string, acceptedSourceId?: string): Promise<NetcattyInstalledPlugin>;
setPluginAIHostReady?(ready: boolean): Promise<boolean>;
-9
View File
@@ -2245,12 +2245,3 @@ html[data-theme-transition] *::after {
background-color: hsl(var(--background)) !important;
border-color: hsl(var(--border)) !important;
}
html.window-background-blur,
html.window-background-blur body {
background: transparent !important;
}
html.window-background-blur #root {
opacity: var(--window-content-opacity, 1);
}
@@ -1,112 +0,0 @@
import assert from 'node:assert/strict';
import test, { type TestContext } from 'node:test';
import { createRequire } from 'node:module';
import { EventEmitter } from 'node:events';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import type { PluginAIHostRequest, PluginSelectedContextContent, PluginSelectedContextListResult, PluginSelectedContextRef, PluginSelectedContextSummary } from '@netcatty/plugin-contract';
import type { TerminalSession, Workspace } from '../../../domain/models';
import { buildSelectedPluginSources, type SelectedSnapshot } from './selectedSources';
import { createTrustedPluginAIHostRuntime } from './trustedHostRuntime';
const require = createRequire(import.meta.url);
const { PluginDatabase } = require('../../../electron/plugins/database.cjs');
const { PluginPermissionEngine } = require('../../../electron/plugins/permissionEngine.cjs');
const { PluginAIContextService } = require('../../../electron/plugins/ai/aiContextService.cjs');
const { PluginAITrustedHostAdapter, PLUGIN_AI_HOST_CHANNELS: CHANNELS } = require('../../../electron/plugins/ai/trustedHostAdapter.cjs');
const { PluginHostRpcRegistry } = require('../../../electron/plugins/hostRpcRegistry.cjs');
const { RPC_ERRORS } = require('../../../electron/plugins/rpcRouter.cjs');
const staleContextError = { code: RPC_ERRORS.permissionDenied, message: /^Selected context (changed|is unavailable)$/ };
function fixture(t: TestContext) {
const a: TerminalSession = { id: 'a', hostId: 'host-a', hostLabel: 'Synthetic A', hostname: 'a.example.invalid', username: 'synthetic-a', status: 'connected' };
const b: TerminalSession = { id: 'b', hostId: 'host-b', hostLabel: 'Synthetic B', hostname: 'b.example.invalid', username: 'synthetic-b', status: 'connected' };
const workspace: Workspace = { id: 'workspace', title: 'Synthetic workspace', focusedSessionId: 'a',
root: { id: 'split', type: 'split', direction: 'horizontal', children: [{ id: 'pane-a', type: 'pane', sessionId: a.id }, { id: 'pane-b', type: 'pane', sessionId: b.id }] } };
let snapshot: SelectedSnapshot = { activeTabId: workspace.id, sessions: [a, b], workspaces: [workspace],
note: { id: 'note', title: 'Synthetic Note', content: 'Synthetic Note text', createdAt: 1, updatedAt: 1 } };
const renderer = createTrustedPluginAIHostRuntime({ getModel: () => null, prepareModel: async () => {}, isActive: () => true,
chooseSelected: async () => buildSelectedPluginSources(() => snapshot) });
const requests: string[] = [];
const sender = Object.assign(new EventEmitter(), { isDestroyed: () => false,
send: (channel: string, request: PluginAIHostRequest) => {
assert.equal(channel, CHANNELS.request); requests.push(request.kind);
void renderer.handle(request.kind, request.payload, request.runtimeId).then(
(result) => host.respond(sender, { requestId: request.requestId, actionToken: request.actionToken, result }),
() => host.respond(sender, { requestId: request.requestId, actionToken: request.actionToken, error: true }),
).catch(() => {});
} });
const window = Object.assign(new EventEmitter(), { webContents: sender, isDestroyed: () => false, isVisible: () => true, isFocused: () => true });
const host = new PluginAITrustedHostAdapter({ getWindow: () => window, isLocked: () => false, cancelProviderStreams: () => {}, timeoutMs: 1000 });
host.registerIpc({ handle() {}, removeHandler() {} }); host.setReady(sender, true);
let invoked = 0;
// Only inference is synthetic; selection, IPC nonce/canonical validation,
// source ownership, permission middleware and both public RPC routes are real.
host.selectModel = async () => ({ label: 'Synthetic model', isCurrent: () => true, invoke: async () => {
invoked++; return (async function* () { yield { kind: 'text', text: 'Synthetic response' }; })();
} });
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'netcatty-selected-focus-'));
const database = new PluginDatabase(path.join(root, 'plugins.sqlite'));
const manifest = { manifestVersion: 1, id: 'com.example.selected-focus', name: 'Selected focus fixture', version: '1.0.0', publisher: 'example',
engines: { netcatty: '>=0.0.0', api: '>=0.1.0-internal <0.2.0' }, main: { browser: 'no-plugin-is-executed.js' },
features: { required: ['ai.model-invocation', 'context.selected-read'] },
permissions: { optional: ['ai.invoke', 'context.selected.list', 'context.selected.read'] } };
database.installVersion({ pluginId: manifest.id, version: manifest.version, manifest, archiveSha256: 'a'.repeat(64), packageRelativePath: 'synthetic' }, { enable: true });
database.setRuntimeState(manifest.id, 'running', { runtimeKind: 'browser' });
const identity = { pluginId: manifest.id, pluginVersion: manifest.version, runtimeId: 'synthetic-runtime', runtimeKind: 'browser', manifest };
const permissions = new PluginPermissionEngine({ database });
const service = new PluginAIContextService({ database, permissionEngine: permissions, hostAdapter: host, getRuntimeIdentity: () => identity });
const registry = new PluginHostRpcRegistry(); registry.use(permissions.createMiddleware()); service.registerRpcCapabilities(registry);
const routes = registry.createRoutes(identity);
const call = (method: string, params: Record<string, unknown> = {}) => routes.requestHandlers[method](params, { signal: new AbortController().signal });
const reference = (item: PluginSelectedContextSummary): PluginSelectedContextRef => ({ handle: item.handle, version: item.version });
t.after(() => { service.shutdown(); host.shutdown(); renderer.shutdown(); permissions.shutdown(); database.close(); fs.rmSync(root, { recursive: true, force: true }); });
return { call, requests, invoked: () => invoked, snapshot: () => snapshot,
list: async () => (await call('context.selected.list') as PluginSelectedContextListResult).items,
read: async (item: PluginSelectedContextSummary) => await call('context.selected.read', reference(item)) as PluginSelectedContextContent,
start: async (item: PluginSelectedContextSummary) => { const chat = await call('ai.openChat'); return call('ai.startTurn', { chatHandle: chat.chatHandle, prompt: 'Synthetic summary', contexts: [reference(item)] }); },
focus: (id: string) => { snapshot = { ...snapshot, workspaces: snapshot.workspaces.map((item) => ({ ...item, focusedSessionId: id })) }; },
updateWorkspace: (change: Partial<Workspace>) => { snapshot = { ...snapshot, workspaces: snapshot.workspaces.map((item) => ({ ...item, ...change })) }; },
};
}
for (const scope of ['session', 'host']) for (const operation of ['read', 'start']) {
test(`same-workspace A-to-B focus invalidates selected ${scope} through registered ${operation}`, async (t) => {
const f = fixture(t); const item = (await f.list()).find((entry) => entry.scope === scope)!;
assert.equal(JSON.parse((await f.read(item)).text).label, 'Synthetic A');
const before = f.snapshot(); f.focus('b');
assert.equal(f.snapshot().activeTabId, before.activeTabId);
assert.equal(f.snapshot().sessions, before.sessions, 'real focus mutation leaves session objects unchanged');
await assert.rejects(operation === 'read' ? f.read(item) : f.start(item), staleContextError);
assert.equal(f.invoked(), 0, 'a stale metadata handle must not reach inference');
assert.ok(f.requests.includes('context.select') && f.requests.includes('context.current'));
});
}
test('focus validation remains kind-specific and host re-selection yields current B metadata', async (t) => {
const f = fixture(t); const items = await f.list();
const byScope = (scope: string) => items.find((item) => item.scope === scope)!;
f.updateWorkspace({ viewMode: 'focus' });
for (const scope of ['session', 'host']) assert.equal(JSON.parse((await f.read(byScope(scope))).text).label, 'Synthetic A');
await assert.rejects(f.read(byScope('workspace')), staleContextError, 'existing workspace revision semantics remain');
f.focus('b');
for (const scope of ['session', 'host']) await assert.rejects(f.read(byScope(scope)), staleContextError);
assert.equal(JSON.parse((await f.read(byScope('tab'))).text).kind, 'workspace', 'the workspace tab remains selected');
assert.equal((await f.read(byScope('note'))).text, 'Synthetic Note text', 'explicit Note choice is independent of terminal focus');
const fresh = await f.list();
for (const scope of ['session', 'host']) {
const item = fresh.find((entry) => entry.scope === scope)!;
assert.notEqual(item.handle, byScope(scope).handle);
assert.equal(JSON.parse((await f.read(item)).text).label, 'Synthetic B');
}
await f.start(fresh.find((entry) => entry.scope === 'session')!);
assert.equal(f.invoked(), 1, 'fresh current context still reaches the synthetic model through the registered route');
});
test('implicit first-pane fallback is re-resolved when the selected workspace pane changes', async (t) => {
const f = fixture(t); f.updateWorkspace({ focusedSessionId: undefined });
const item = (await f.list()).find((entry) => entry.scope === 'session')!;
assert.equal(JSON.parse((await f.read(item)).text).label, 'Synthetic A');
f.focus('b'); await assert.rejects(f.read(item), staleContextError);
});
+2 -11
View File
@@ -9,17 +9,12 @@ export interface SelectedSnapshot {
note: VaultNote | null;
}
function selectedSessionId(snapshot: SelectedSnapshot): string | undefined {
const workspace = snapshot.workspaces.find((item) => item.id === snapshot.activeTabId);
return workspace?.focusedSessionId ?? (workspace ? collectSessionIds(workspace.root)[0] : snapshot.activeTabId);
}
// Receives host-selected snapshots only. No Host/Vault DTO serialization,
// inventory reader, terminal scrollback, credential fields, or command access.
export function buildSelectedPluginSources(getSnapshot: () => SelectedSnapshot): HostSelectedSource[] {
const snapshot = getSnapshot();
const workspace = snapshot.workspaces.find((item) => item.id === snapshot.activeTabId);
const sessionId = selectedSessionId(snapshot);
const sessionId = workspace?.focusedSessionId ?? (workspace ? collectSessionIds(workspace.root)[0] : snapshot.activeTabId);
const session = snapshot.sessions.find((item) => item.id === sessionId);
const sources: HostSelectedSource[] = [];
const add = (scope: HostSelectedSource['scope'], key: string, title: string, value: unknown, current: () => boolean) => {
@@ -34,11 +29,7 @@ export function buildSelectedPluginSources(getSnapshot: () => SelectedSnapshot):
if (workspace || session) add('tab', snapshot.activeTabId, workspace?.title ?? session.hostLabel,
{ title: workspace?.title ?? session.hostLabel, kind: workspace ? 'workspace' : 'terminal' }, tabCurrent);
if (session) {
const current = () => {
const latest = getSnapshot();
return latest.activeTabId === snapshot.activeTabId && selectedSessionId(latest) === session.id
&& latest.sessions.find((item) => item.id === session.id) === session;
};
const current = () => tabCurrent() && getSnapshot().sessions.find((item) => item.id === session.id) === session;
add('session', session.id, session.hostLabel, { label: session.hostLabel, status: session.status, protocol: session.protocol ?? 'ssh' }, current);
add('host', session.hostId || session.id, session.hostLabel,
{ label: session.hostLabel, hostname: session.hostname, username: session.username, port: session.port, protocol: session.protocol ?? 'ssh' }, current);
-1
View File
@@ -199,7 +199,6 @@ export const STORAGE_KEY_HTTP_NETWORK_PROXY = 'netcatty_http_network_proxy_v1';
export const STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE = 'netcatty_global_default_proxy_profile_v1';
export const STORAGE_KEY_GLOBAL_HOTKEY_ENABLED = 'netcatty_global_hotkey_enabled_v1';
export const STORAGE_KEY_WINDOW_OPACITY = 'netcatty_window_opacity_v1';
export const STORAGE_KEY_WINDOW_BACKGROUND_BLUR = 'netcatty_window_background_blur_v1';
export const STORAGE_KEY_APP_ICON_VARIANT = 'netcatty_app_icon_variant_v1';
// Custom Terminal Themes
export const STORAGE_KEY_CUSTOM_THEMES = 'netcatty_custom_themes_v1';
@@ -1,84 +0,0 @@
import assert from 'node:assert/strict';
import { createRequire } from 'node:module';
import test from 'node:test';
import type { Monaco } from '@monaco-editor/react';
import { nginxLanguage, nginxLanguageConfiguration, registerNginxLanguage } from './nginxLanguage';
const require = createRequire(import.meta.url);
const { compile } = require('monaco-editor/esm/vs/editor/standalone/common/monarch/monarchCompile.js');
const { MonarchTokenizer } = require('monaco-editor/esm/vs/editor/standalone/common/monarch/monarchLexer.js');
// Exercise Monaco's real Monarch engine rather than approximating its regex/state rules.
function tokenize(content: string) {
const tokenizer = new MonarchTokenizer({}, {}, 'nginx', compile('nginx', nginxLanguage), {
getValue: () => 20000,
onDidChangeConfiguration: () => ({ dispose() {} }),
});
let state = tokenizer.getInitialState();
try {
return content.split('\n').map((line) => {
const result = tokenizer.tokenize(line, true, state);
state = result.endState;
return result.tokens.map((token: { offset: number; type: string }, index: number) => ({
text: line.slice(token.offset, result.tokens[index + 1]?.offset ?? line.length),
type: token.type,
}));
});
} finally {
tokenizer.dispose();
}
}
test('highlights directives, block structure, comments, strings and variables', () => {
const tokens = tokenize(`upstream backend { server 127.0.0.1:8080; }
server {
# location ignored;
location / { proxy_pass http://backend/$uri; }
set $message "hello \${host} #still string";
add_header X-Value 'literal; { # text }';
}`).flat();
for (const directive of ['upstream', 'server', 'location', 'proxy_pass', 'set', 'add_header']) {
assert.ok(tokens.some(({ text, type }) => text === directive && type === 'keyword.nginx'), directive);
}
for (const variable of ['$uri', '$message', '${host}']) {
assert.ok(tokens.some(({ text, type }) => text === variable && type === 'variable.nginx'), variable);
}
assert.ok(tokens.some(({ text, type }) => text === '# location ignored;' && type === 'comment.nginx'));
assert.ok(tokens.some(({ text, type }) => text.includes('#still string') && type === 'string.nginx'));
assert.ok(tokens.some(({ text, type }) => text.includes('literal; { # text }') && type === 'string.nginx'));
assert.ok(tokens.some(({ text, type }) => text === '{' && type === 'delimiter.curly.nginx'));
assert.ok(tokens.some(({ text, type }) => text === ';' && type === 'delimiter.nginx'));
});
test('retains argument and quote states across lines and respects escapes', () => {
const lines = tokenize(String.raw`proxy_set_header
Host $host; custom_module_directive on;
set $message "first
second \" #literal"; # comment
set $escaped value\#literal;`);
assert.ok(lines[1].some(({ text, type }) => text === 'custom_module_directive' && type === 'keyword.nginx'));
assert.ok(lines[1].every(({ text, type }) => text !== 'Host' || type !== 'keyword.nginx'));
assert.ok(lines[3].some(({ text, type }) => text.includes('#literal') && type === 'string.nginx'));
assert.ok(lines[3].some(({ text, type }) => text === '# comment' && type === 'comment.nginx'));
assert.ok(lines[4].some(({ text, type }) => text === '\\#' && type === 'string.escape.nginx'));
});
test('registers once per Monaco instance and exposes only highlighting/configuration', () => {
const calls: string[] = [];
const createMonaco = () => ({ languages: {
register: ({ id }: { id: string }) => calls.push(id),
setMonarchTokensProvider: (id: string, grammar: unknown) => {
assert.equal(grammar, nginxLanguage);
calls.push(`${id}:tokens`);
},
setLanguageConfiguration: (id: string, configuration: unknown) => {
assert.equal(configuration, nginxLanguageConfiguration);
calls.push(`${id}:configuration`);
},
} }) as unknown as Monaco;
const first = createMonaco();
registerNginxLanguage(first);
registerNginxLanguage(first);
registerNginxLanguage(createMonaco());
assert.deepEqual(calls, ['nginx', 'nginx:tokens', 'nginx:configuration', 'nginx', 'nginx:tokens', 'nginx:configuration']);
});
-66
View File
@@ -1,66 +0,0 @@
import type { Monaco } from '@monaco-editor/react';
import type { languages } from 'monaco-editor';
// Original Monarch grammar for Netcatty (same license as the project).
// Syntax reference: https://nginx.org/en/docs/beginners_guide.html#conf_structure
// Recognize directive positions rather than maintaining a list of module names.
export const nginxLanguage: languages.IMonarchLanguage = {
defaultToken: '',
tokenPostfix: '.nginx',
tokenizer: {
root: [
{ include: '@whitespace' },
[/[{}]/, 'delimiter.curly'],
[/;/, 'delimiter'],
[/[^\s{};#"'\\$]+/, 'keyword', '@arguments'],
{ include: '@values' },
],
arguments: [
{ include: '@whitespace' },
[/[{}]/, 'delimiter.curly', '@pop'],
[/;/, 'delimiter', '@pop'],
{ include: '@values' },
[/[^\s{};#"'\\$]+/, ''],
],
whitespace: [
[/\s+/, ''],
[/#.*$/, 'comment'],
],
values: [
[/\\./, 'string.escape'],
[/\$(?:\{[\w]+\}|[\w]+)/, 'variable'],
[/"/, 'string', '@doubleQuoted'],
[/'/, 'string', '@singleQuoted'],
],
doubleQuoted: [
[/\\./, 'string.escape'],
[/\$(?:\{[\w]+\}|[\w]+)/, 'variable'],
[/"/, 'string', '@pop'],
[/[^"\\$]+/, 'string'],
[/\$/, 'string'],
],
singleQuoted: [
[/\\./, 'string.escape'],
[/\$(?:\{[\w]+\}|[\w]+)/, 'variable'],
[/'/, 'string', '@pop'],
[/[^'\\$]+/, 'string'],
[/\$/, 'string'],
],
},
};
export const nginxLanguageConfiguration: languages.LanguageConfiguration = {
comments: { lineComment: '#' },
brackets: [['{', '}']],
};
const registeredInstances = new WeakSet<Monaco>();
/** Called before model creation, including when Monaco is loaded lazily. */
export function registerNginxLanguage(monaco: Monaco): void {
if (registeredInstances.has(monaco)) return;
monaco.languages.register({ id: 'nginx', aliases: ['Nginx', 'nginx'], filenames: ['nginx.conf'] });
monaco.languages.setMonarchTokensProvider('nginx', nginxLanguage);
monaco.languages.setLanguageConfiguration('nginx', nginxLanguageConfiguration);
registeredInstances.add(monaco);
}
-30
View File
@@ -1,30 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { getLanguageId, getLanguageName, getSupportedLanguages } from './sftpFileUtils';
test('recognizes Nginx by filename and recognizable config directories', () => {
for (const path of [
'nginx.conf', '/etc/nginx/nginx.conf', '/usr/local/nginx/conf/nginx.conf',
'/etc/nginx/conf.d/proxy.conf', '/usr/local/etc/nginx/conf.d/site.conf',
'/usr/local/nginx/conf/conf.d/site.conf', '/etc/nginx/snippets/tls.conf',
'/etc/nginx/sites-available/default', '/etc/nginx/sites-enabled/example.com',
'C:\\nginx\\conf.d\\site.conf',
]) assert.equal(getLanguageId(path), 'nginx', path);
});
test('keeps ambiguous configs and unrelated languages unchanged', () => {
for (const path of [
'site.conf', 'other.conf', '/etc/ssh/sshd.conf', '/etc/conf.d/site.conf',
'/etc/not-nginx/conf.d/site.conf', '/etc/nginx-not/conf.d/site.conf',
'/etc/nginx/conf.d/site.conf.bak', '/etc/nginx/conf.d/settings.ini',
]) assert.equal(getLanguageId(path), path.endsWith('.bak') ? 'plaintext' : 'ini', path);
assert.equal(getLanguageId('/folder.with.dots/default'), 'plaintext');
assert.equal(getLanguageId('/etc/nginx/readme.md'), 'markdown');
assert.equal(getLanguageId('settings.ini'), 'ini');
});
test('offers Nginx for manual selection without assigning all .conf files to it', () => {
assert.equal(getLanguageName('nginx'), 'Nginx');
assert.deepEqual(getSupportedLanguages().filter(({ id }) => id === 'nginx'), [{ id: 'nginx', name: 'Nginx' }]);
assert.equal(getLanguageId('arbitrary-site.conf'), 'ini');
});
+1 -11
View File
@@ -138,15 +138,7 @@ export function isKnownBinaryFile(fileName: string): boolean {
* Get language ID for syntax highlighting
*/
export function getLanguageId(fileName: string): string {
const path = fileName.replace(/\\/g, '/');
const baseName = path.slice(path.lastIndexOf('/') + 1);
const ext = getFileExtension(baseName);
// Keep generic .conf files as INI. Only infer Nginx with filename/path context.
if (baseName.toLowerCase() === 'nginx.conf'
|| /(?:^|\/)nginx\/(?:conf\/)?(?:conf\.d|snippets)\/.+\.conf$/i.test(path)
|| /(?:^|\/)nginx\/(?:conf\/)?sites-(?:available|enabled)\/[^/]+$/i.test(path)) {
return 'nginx';
}
const ext = getFileExtension(fileName);
return EXTENSION_TO_LANGUAGE[ext] || 'plaintext';
}
@@ -189,7 +181,6 @@ export function getLanguageName(languageId: string): string {
yaml: 'YAML',
toml: 'TOML',
ini: 'INI',
nginx: 'Nginx',
sql: 'SQL',
graphql: 'GraphQL',
markdown: 'Markdown',
@@ -233,7 +224,6 @@ export interface FileAssociation {
export function getSupportedLanguages(): { id: string; name: string }[] {
const languageIds = new Set(Object.values(EXTENSION_TO_LANGUAGE));
languageIds.add('plaintext');
languageIds.add('nginx');
return Array.from(languageIds)
.map(id => ({ id, name: getLanguageName(id) }))
@@ -1698,6 +1698,8 @@
"enum": [
"storage",
"ai.invoke",
"ai.agent.use",
"ai.agent.actions",
"context.selected.list",
"context.selected.read",
"runtime.advanced",
@@ -1742,6 +1744,8 @@
"enum": [
"storage",
"ai.invoke",
"ai.agent.use",
"ai.agent.actions",
"context.selected.list",
"context.selected.read",
"runtime.advanced",
@@ -4976,9 +4980,6 @@
"TerminalInputDraftFeature": {
"const": "terminal.input-drafts"
},
"FilesystemDirectoryEnumerationFeature": {
"const": "filesystem.directory-enumeration"
},
"HostTerminalInputProposalRequest": {
"type": "object",
"required": [
@@ -5007,6 +5008,402 @@
}
},
"additionalProperties": false
},
"PluginAgentFeature": {
"const": "ai.agent-sessions"
},
"PluginAgentBackendKind": {
"enum": [
"catty",
"claude",
"codex",
"copilot",
"cursor",
"codebuddy",
"opencode",
"grok",
"mimo"
]
},
"PluginAgentBackend": {
"type": "object",
"required": [
"backendRef",
"label",
"backend",
"status",
"reason"
],
"properties": {
"backendRef": {
"type": "string",
"format": "uuid"
},
"label": {
"type": "string",
"maxLength": 160
},
"backend": {
"$ref": "#/$defs/PluginAgentBackendKind"
},
"status": {
"enum": [
"supported",
"unsupported",
"unavailable"
]
},
"reason": {
"enum": [
"ready",
"not-configured",
"native-boundary-unverified",
"configuration-unavailable"
]
}
},
"additionalProperties": false
},
"PluginAgentBackendsResult": {
"type": "object",
"required": [
"items"
],
"properties": {
"items": {
"type": "array",
"maxItems": 32,
"items": {
"$ref": "#/$defs/PluginAgentBackend"
}
}
},
"additionalProperties": false
},
"PluginAgentTargetSummary": {
"type": "object",
"required": [
"title",
"kind"
],
"properties": {
"title": {
"type": "string",
"maxLength": 160
},
"kind": {
"const": "terminal"
}
},
"additionalProperties": false
},
"PluginAgentTargetsResult": {
"type": "object",
"required": [
"selectionHandle",
"expiresAt",
"items"
],
"properties": {
"selectionHandle": {
"type": "string",
"format": "uuid"
},
"expiresAt": {
"type": "integer",
"minimum": 0
},
"items": {
"type": "array",
"maxItems": 32,
"items": {
"$ref": "#/$defs/PluginAgentTargetSummary"
}
}
},
"additionalProperties": false
},
"PluginAgentOpenParams": {
"type": "object",
"required": [
"backendRef",
"selectionHandle"
],
"properties": {
"backendRef": {
"type": "string",
"format": "uuid"
},
"selectionHandle": {
"type": "string",
"format": "uuid"
},
"conversationRef": {
"type": "string",
"format": "uuid"
}
},
"additionalProperties": false
},
"PluginAgentChatRef": {
"type": "object",
"required": [
"chatHandle"
],
"properties": {
"chatHandle": {
"type": "string",
"format": "uuid"
}
},
"additionalProperties": false
},
"PluginAgentChatResult": {
"type": "object",
"required": [
"chatHandle",
"conversationRef",
"backendLabel",
"expiresAt"
],
"properties": {
"chatHandle": {
"type": "string",
"format": "uuid"
},
"conversationRef": {
"type": "string",
"format": "uuid"
},
"backendLabel": {
"type": "string",
"maxLength": 160
},
"expiresAt": {
"type": "integer",
"minimum": 0
}
},
"additionalProperties": false
},
"PluginAgentChatSummary": {
"type": "object",
"required": [
"conversationRef",
"title",
"backend",
"updatedAt"
],
"properties": {
"conversationRef": {
"type": "string",
"format": "uuid"
},
"title": {
"type": "string",
"maxLength": 160
},
"backend": {
"$ref": "#/$defs/PluginAgentBackendKind"
},
"updatedAt": {
"type": "integer",
"minimum": 0
}
},
"additionalProperties": false
},
"PluginAgentChatsResult": {
"type": "object",
"required": [
"items"
],
"properties": {
"items": {
"type": "array",
"maxItems": 64,
"items": {
"$ref": "#/$defs/PluginAgentChatSummary"
}
}
},
"additionalProperties": false
},
"PluginAgentSendParams": {
"type": "object",
"required": [
"chatHandle",
"text"
],
"properties": {
"chatHandle": {
"type": "string",
"format": "uuid"
},
"text": {
"type": "string",
"minLength": 1,
"maxLength": 16384,
"pattern": "^[^\\u0000]*$"
}
},
"additionalProperties": false
},
"PluginAgentTurnRef": {
"type": "object",
"required": [
"turnHandle"
],
"properties": {
"turnHandle": {
"type": "string",
"format": "uuid"
}
},
"additionalProperties": false
},
"PluginAgentTurnResult": {
"type": "object",
"required": [
"turnHandle",
"expiresAt"
],
"properties": {
"turnHandle": {
"type": "string",
"format": "uuid"
},
"expiresAt": {
"type": "integer",
"minimum": 0
}
},
"additionalProperties": false
},
"PluginAgentMessage": {
"type": "object",
"required": [
"role",
"text"
],
"properties": {
"role": {
"enum": [
"user",
"assistant"
]
},
"text": {
"type": "string",
"maxLength": 32768
}
},
"additionalProperties": false
},
"PluginAgentHistoryResult": {
"type": "object",
"required": [
"items"
],
"properties": {
"items": {
"type": "array",
"maxItems": 200,
"items": {
"$ref": "#/$defs/PluginAgentMessage"
}
}
},
"additionalProperties": false
},
"PluginAgentEvent": {
"oneOf": [
{
"type": "object",
"required": [
"kind",
"text"
],
"properties": {
"kind": {
"const": "text"
},
"text": {
"type": "string",
"maxLength": 4096
}
},
"additionalProperties": false
},
{
"type": "object",
"required": [
"kind",
"capabilityId",
"state"
],
"properties": {
"kind": {
"const": "activity"
},
"capabilityId": {
"type": "string",
"maxLength": 160
},
"state": {
"enum": [
"started",
"completed",
"denied"
]
}
},
"additionalProperties": false
},
{
"type": "object",
"required": [
"kind",
"inputTokens",
"outputTokens"
],
"properties": {
"kind": {
"const": "usage"
},
"inputTokens": {
"type": "integer",
"minimum": 0
},
"outputTokens": {
"type": "integer",
"minimum": 0
}
},
"additionalProperties": false
},
{
"type": "object",
"required": [
"kind",
"reason"
],
"properties": {
"kind": {
"const": "end"
},
"reason": {
"enum": [
"completed",
"cancelled",
"unavailable",
"budget-exceeded"
]
}
},
"additionalProperties": false
}
]
}
}
}
@@ -194,8 +194,6 @@ export type CredentialRef = {
export type FeatureId = string;
export type FilesystemDirectoryEnumerationFeature = "filesystem.directory-enumeration";
export type HostClipboardReadRequest = {
kind: "clipboard.read";
title: string;
@@ -488,7 +486,7 @@ export type MenuLocation = "commandPalette" | "application" | "host/context" | "
export type NetworkCredentialLeaseFeature = "network.credential-lease";
export type NonResourceScopedPermission = "storage" | "ai.invoke" | "context.selected.list" | "context.selected.read" | "runtime.advanced" | "settings.read" | "settings.write" | "commands" | "menus" | "views" | "ui.interact" | "clipboard.read" | "clipboard.write" | "terminal.metadata" | "terminal.output" | "terminal.input" | "terminal.decorate" | "terminal.complete" | "terminal.intercept.input" | "terminal.intercept.output" | "vault.metadata" | "vault.write" | "vault.credentials" | "sftp.read" | "sftp.write" | "secrets" | "provider.terminal" | "provider.connection" | "provider.authentication" | "provider.sync" | "provider.importer" | "terminal.selection.read" | "terminal.input.propose";
export type NonResourceScopedPermission = "storage" | "ai.invoke" | "ai.agent.use" | "ai.agent.actions" | "context.selected.list" | "context.selected.read" | "runtime.advanced" | "settings.read" | "settings.write" | "commands" | "menus" | "views" | "ui.interact" | "clipboard.read" | "clipboard.write" | "terminal.metadata" | "terminal.output" | "terminal.input" | "terminal.decorate" | "terminal.complete" | "terminal.intercept.input" | "terminal.intercept.output" | "vault.metadata" | "vault.write" | "vault.credentials" | "sftp.read" | "sftp.write" | "secrets" | "provider.terminal" | "provider.connection" | "provider.authentication" | "provider.sync" | "provider.importer" | "terminal.selection.read" | "terminal.input.propose";
export type NullableRpcId = (RpcId) | (null);
@@ -556,6 +554,100 @@ export type PermissionSet = {
optional?: Array<PermissionDeclaration>;
};
export type PluginAgentBackend = {
backendRef: string;
label: string;
backend: PluginAgentBackendKind;
status: "supported" | "unsupported" | "unavailable";
reason: "ready" | "not-configured" | "native-boundary-unverified" | "configuration-unavailable";
};
export type PluginAgentBackendKind = "catty" | "claude" | "codex" | "copilot" | "cursor" | "codebuddy" | "opencode" | "grok" | "mimo";
export type PluginAgentBackendsResult = {
items: Array<PluginAgentBackend>;
};
export type PluginAgentChatRef = {
chatHandle: string;
};
export type PluginAgentChatResult = {
chatHandle: string;
conversationRef: string;
backendLabel: string;
expiresAt: number;
};
export type PluginAgentChatsResult = {
items: Array<PluginAgentChatSummary>;
};
export type PluginAgentChatSummary = {
conversationRef: string;
title: string;
backend: PluginAgentBackendKind;
updatedAt: number;
};
export type PluginAgentEvent = ({
kind: "text";
text: string;
}) | ({
kind: "activity";
capabilityId: string;
state: "started" | "completed" | "denied";
}) | ({
kind: "usage";
inputTokens: number;
outputTokens: number;
}) | ({
kind: "end";
reason: "completed" | "cancelled" | "unavailable" | "budget-exceeded";
});
export type PluginAgentFeature = "ai.agent-sessions";
export type PluginAgentHistoryResult = {
items: Array<PluginAgentMessage>;
};
export type PluginAgentMessage = {
role: "user" | "assistant";
text: string;
};
export type PluginAgentOpenParams = {
backendRef: string;
selectionHandle: string;
conversationRef?: string;
};
export type PluginAgentSendParams = {
chatHandle: string;
text: string;
};
export type PluginAgentTargetsResult = {
selectionHandle: string;
expiresAt: number;
items: Array<PluginAgentTargetSummary>;
};
export type PluginAgentTargetSummary = {
title: string;
kind: "terminal";
};
export type PluginAgentTurnRef = {
turnHandle: string;
};
export type PluginAgentTurnResult = {
turnHandle: string;
expiresAt: number;
};
export type PluginAIBooleanResult = {
ok: boolean;
};
@@ -841,7 +933,7 @@ export type PluginNetworkResponse = {
};
};
export type PluginPermission = "storage" | "ai.invoke" | "context.selected.list" | "context.selected.read" | "runtime.advanced" | "settings.read" | "settings.write" | "commands" | "menus" | "views" | "ui.interact" | "clipboard.read" | "clipboard.write" | "terminal.metadata" | "terminal.output" | "terminal.input" | "terminal.decorate" | "terminal.complete" | "terminal.intercept.input" | "terminal.intercept.output" | "vault.metadata" | "vault.write" | "vault.credentials" | "sftp.read" | "sftp.write" | "network" | "network.stream" | "filesystem.read" | "filesystem.write" | "secrets" | "companion.execute" | "provider.terminal" | "provider.connection" | "provider.authentication" | "provider.sync" | "provider.importer" | "terminal.selection.read" | "terminal.input.propose";
export type PluginPermission = "storage" | "ai.invoke" | "ai.agent.use" | "ai.agent.actions" | "context.selected.list" | "context.selected.read" | "runtime.advanced" | "settings.read" | "settings.write" | "commands" | "menus" | "views" | "ui.interact" | "clipboard.read" | "clipboard.write" | "terminal.metadata" | "terminal.output" | "terminal.input" | "terminal.decorate" | "terminal.complete" | "terminal.intercept.input" | "terminal.intercept.output" | "vault.metadata" | "vault.write" | "vault.credentials" | "sftp.read" | "sftp.write" | "network" | "network.stream" | "filesystem.read" | "filesystem.write" | "secrets" | "companion.execute" | "provider.terminal" | "provider.connection" | "provider.authentication" | "provider.sync" | "provider.importer" | "terminal.selection.read" | "terminal.input.propose";
export type PluginSelectedContextContent = {
handle: string;
+1 -1
View File
@@ -55,6 +55,7 @@ export { inspectPluginBrandIcon, PLUGIN_BRAND_ICON_FEATURE, PLUGIN_BRAND_ICON_MA
export const PLUGIN_NETWORK_CREDENTIAL_LEASE_FEATURE = "network.credential-lease" as const satisfies import("./generated/plugin-contract.js").NetworkCredentialLeaseFeature;
export const PLUGIN_BYTE_STREAM_FEATURE = "network.byte-streams" as const satisfies import("./generated/plugin-contract.js").ByteStreamFeature;
export const PLUGIN_AI_MODEL_INVOCATION_FEATURE = "ai.model-invocation" as const satisfies import("./generated/plugin-contract.js").PluginAIModelInvocationFeature;
export const PLUGIN_AGENT_SESSIONS_FEATURE = "ai.agent-sessions" as const satisfies import("./generated/plugin-contract.js").PluginAgentFeature;
export const PLUGIN_SELECTED_CONTEXT_FEATURE = "context.selected-read" as const satisfies import("./generated/plugin-contract.js").PluginSelectedContextFeature;
export const PLUGIN_HOST_INTERACTION_FEATURE = "host.interactions" as const satisfies import("./generated/plugin-contract.js").HostInteractionFeature;
@@ -69,4 +70,3 @@ export const PLUGIN_THEME_PACK_FEATURE = "appearance.theme-packs" as const satis
export const PLUGIN_TERMINAL_SELECTION_SHARING_FEATURE = "terminal.selection-sharing" as const satisfies import("./generated/plugin-contract.js").TerminalSelectionSharingFeature;
export const PLUGIN_TERMINAL_INPUT_DRAFT_FEATURE = "terminal.input-drafts" as const satisfies import("./generated/plugin-contract.js").TerminalInputDraftFeature;
export const PLUGIN_FILESYSTEM_DIRECTORY_ENUMERATION_FEATURE = "filesystem.directory-enumeration" as const satisfies import("./generated/plugin-contract.js").FilesystemDirectoryEnumerationFeature;
+15 -4
View File
@@ -117,8 +117,7 @@ export interface PluginHostUI {
}
/** Saved-host selection does not grant editing, connecting or session access.
* Later actions follow the plugin's permission settings. Host drafts retain
* the host editor; connections retain normal authentication. */
* Each action uses the host's separate review and confirmation UI. */
export interface PluginVault {
selectHosts(params: import("@netcatty/plugin-contract").VaultSelectHostsParams): Promise<import("@netcatty/plugin-contract").VaultHostSelectionResult>;
/** Managed SSH-config drafts require the full Vault host editor. */
@@ -686,6 +685,19 @@ export interface PluginSelectedContextClient {
read(params: import("@netcatty/plugin-contract").PluginSelectedContextRef): Promise<import("@netcatty/plugin-contract").PluginSelectedContextContent>;
}
/** Configurations, targets and native resume IDs stay in the trusted host. */
export interface PluginAgentClient {
listBackends(params: import("@netcatty/plugin-contract").PluginAIEmptyParams): Promise<import("@netcatty/plugin-contract").PluginAgentBackendsResult>;
selectTargets(params: import("@netcatty/plugin-contract").PluginAIEmptyParams): Promise<import("@netcatty/plugin-contract").PluginAgentTargetsResult>;
listChats(params: import("@netcatty/plugin-contract").PluginAIEmptyParams): Promise<import("@netcatty/plugin-contract").PluginAgentChatsResult>;
openChat(params: import("@netcatty/plugin-contract").PluginAgentOpenParams): Promise<import("@netcatty/plugin-contract").PluginAgentChatResult>;
send(params: import("@netcatty/plugin-contract").PluginAgentSendParams): Promise<import("@netcatty/plugin-contract").PluginAgentTurnResult>;
read(params: import("@netcatty/plugin-contract").PluginAgentTurnRef): Promise<import("@netcatty/plugin-contract").PluginAgentEvent>;
history(params: import("@netcatty/plugin-contract").PluginAgentChatRef): Promise<import("@netcatty/plugin-contract").PluginAgentHistoryResult>;
stop(params: import("@netcatty/plugin-contract").PluginAgentTurnRef): Promise<import("@netcatty/plugin-contract").PluginAIBooleanResult>;
closeChat(params: import("@netcatty/plugin-contract").PluginAgentChatRef): Promise<import("@netcatty/plugin-contract").PluginAIBooleanResult>;
}
export interface PluginByteConnection extends Disposable {
readonly endpoint: string;
readonly readable: PluginReadableByteStream;
@@ -712,7 +724,6 @@ export interface PluginFilesystemClient {
overwrite: true;
}>): Promise<void>;
stat(path: string): Promise<PluginFilesystemStat>;
/** Requires filesystem.directory-enumeration and an authorized filesystem.read directory. */
readDirectory(path: string): Promise<readonly PluginFilesystemEntry[]>;
}
@@ -786,6 +797,7 @@ export interface PluginContext {
readonly credentials: PluginCredentialBroker;
readonly network: PluginNetworkClient;
readonly ai: PluginAIClient;
readonly agent: PluginAgentClient;
readonly selectedContext: PluginSelectedContextClient;
readonly filesystem: PluginFilesystemClient;
readonly companions: PluginCompanionService;
@@ -983,4 +995,3 @@ export { PLUGIN_THEME_PACK_FEATURE } from "@netcatty/plugin-contract";
export { PLUGIN_TERMINAL_SELECTION_SHARING_FEATURE } from "@netcatty/plugin-contract";
export { PLUGIN_TERMINAL_INPUT_DRAFT_FEATURE } from "@netcatty/plugin-contract";
export { PLUGIN_FILESYSTEM_DIRECTORY_ENUMERATION_FEATURE } from "@netcatty/plugin-contract";
-1
View File
@@ -47,7 +47,6 @@ declare global {
setTheme?(theme: 'light' | 'dark' | 'system'): Promise<boolean>;
setBackgroundColor?(color: string): Promise<boolean>;
setWindowOpacity?(opacity: number): Promise<boolean>;
setWindowBackgroundBlur?(enabled: boolean): Promise<boolean>;
setAppIconVariant?(variant: import('../../domain/appIconVariant').AppIconVariant): Promise<boolean>;
setLanguage?(language: string): Promise<boolean>;
// Window controls for custom title bar (Windows/Linux)