mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-10-29 18:11:40 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6390581d84 | ||
|
|
2671cf9f20 | ||
|
|
672a2bf406 | ||
|
|
9f09af3bda | ||
|
|
5931eb14f0 | ||
|
|
f5e2a64dfb | ||
|
|
ca2280ea8b | ||
|
|
7e7b05620f | ||
|
|
25e0c834da | ||
|
|
1f0741698d | ||
|
|
50fda91424 | ||
|
|
fc37ac48e1 | ||
|
|
d035cbea38 | ||
|
|
b3895a477d | ||
|
|
db1223d161 | ||
|
|
14ed19d2ed | ||
|
|
a36a906292 | ||
|
|
e4eb33856b | ||
|
|
b14b65679f | ||
|
|
f8616f5d3a | ||
|
|
0cab1046cf | ||
|
|
a56d1ab785 | ||
|
|
cbe89995e6 | ||
|
|
72383a50c9 | ||
|
|
0c05a96fa1 | ||
|
|
05a773b426 | ||
|
|
da16b90e9e | ||
|
|
2796d1113a | ||
|
|
8bcad28f9d | ||
|
|
59edc87ed4 | ||
|
|
5944a02238 | ||
|
|
c743456a0b | ||
|
|
b17713ef95 | ||
|
|
56fefdd03f | ||
|
|
b7e4abb30b | ||
|
|
f715a0764d | ||
|
|
5db0f90751 | ||
|
|
336ab5ab17 | ||
|
|
63f0572e0b | ||
|
|
ce9fcdfec6 | ||
|
|
de06a6aa4d | ||
|
|
ea619555ac | ||
|
|
dcf642cf39 | ||
|
|
d40b0d3c25 | ||
|
|
714e2397a4 | ||
|
|
c4aedfa9ed | ||
|
|
ba4d3fc223 | ||
|
|
18377a1fd9 | ||
|
|
1caf4a6479 | ||
|
|
a0cf2b487c | ||
|
|
056164157f | ||
|
|
e3e2dcfe02 | ||
|
|
2433cf0130 | ||
|
|
1b21c41fa2 | ||
|
|
02049102b6 | ||
|
|
cfa13680e6 | ||
|
|
7856c664c3 | ||
|
|
7d8a354404 | ||
|
|
5d2ff1cb86 | ||
|
|
9aa899059f | ||
|
|
d33c0193cc | ||
|
|
e325742abf | ||
|
|
9ca70f3290 | ||
|
|
092438803b |
No files matched your search
@@ -27,6 +27,7 @@ import { AppHostTreeLayer } from './AppHostTreeLayer';
|
||||
import { AppHostEditorLayer } from './AppHostEditorLayer';
|
||||
import { AppPluginKeybindingHost } from './AppPluginKeybindingHost';
|
||||
import { PluginAIContextHost } from '../../components/plugins/PluginAIContextHost';
|
||||
import { PluginAgentHost } from '../../components/plugins/PluginAgentHost';
|
||||
import { shouldOpenHostEditOnWorkSurface } from './workTabSurface';
|
||||
import { useConnectionLogsStore } from '../state/connectionLogsStore';
|
||||
import {
|
||||
@@ -858,6 +859,7 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
workspaces={workspaces}
|
||||
/>
|
||||
<PluginAIContextHost sessions={sessions} workspaces={workspaces} />
|
||||
<PluginAgentHost sessions={sessions} />
|
||||
</div>
|
||||
|
||||
{/* Global "quick add / edit snippet" modal, triggered by the
|
||||
|
||||
@@ -582,6 +582,8 @@ 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',
|
||||
@@ -1262,6 +1264,8 @@ 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.",
|
||||
@@ -1419,7 +1423,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': '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.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.create': 'Review host draft',
|
||||
'plugins.hostActions.edit': 'Review host changes',
|
||||
'plugins.hostActions.connect': 'Confirm connection',
|
||||
|
||||
@@ -582,6 +582,8 @@ 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',
|
||||
@@ -1261,6 +1263,8 @@ 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.",
|
||||
@@ -1419,7 +1423,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': '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.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.create': 'Revisar borrador de equipo',
|
||||
'plugins.hostActions.edit': 'Revisar cambios del equipo',
|
||||
'plugins.hostActions.connect': 'Confirmar conexión',
|
||||
|
||||
@@ -525,6 +525,8 @@ 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': 'Оригинал',
|
||||
@@ -1130,6 +1132,8 @@ 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": "Импортированные пакеты отключены до проверки и включения.",
|
||||
@@ -1284,7 +1288,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,6 +29,16 @@ 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
|
||||
|
||||
@@ -586,6 +586,8 @@ 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': '原始',
|
||||
@@ -1177,6 +1179,8 @@ 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": "导入本地插件包以开始使用。",
|
||||
@@ -1334,7 +1338,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': '确认连接',
|
||||
|
||||
@@ -588,6 +588,8 @@ 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,6 +1181,8 @@ 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": "導入的插件保持停用,請檢查後再啟用。",
|
||||
@@ -1337,7 +1341,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': '確認連線',
|
||||
|
||||
@@ -49,6 +49,7 @@ 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,
|
||||
@@ -99,6 +100,7 @@ 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>>;
|
||||
@@ -148,6 +150,7 @@ export function useSettingsIpcSync({
|
||||
setIsHotkeyRecordingState,
|
||||
setGlobalHotkeyEnabled,
|
||||
setWindowOpacity,
|
||||
setWindowBackgroundBlur,
|
||||
setAppIconVariant,
|
||||
setAutoUpdateEnabled,
|
||||
setAutoLaunchEnabled,
|
||||
@@ -279,6 +282,9 @@ 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));
|
||||
@@ -371,6 +377,7 @@ export function useSettingsIpcSync({
|
||||
setFollowAppTerminalThemeState,
|
||||
setGlobalHotkeyEnabled,
|
||||
setWindowOpacity,
|
||||
setWindowBackgroundBlur,
|
||||
setAppIconVariant,
|
||||
setHotkeyScheme,
|
||||
setIsHotkeyRecordingState,
|
||||
|
||||
@@ -53,6 +53,7 @@ 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';
|
||||
@@ -116,6 +117,7 @@ interface UseSettingsStorageSyncParams {
|
||||
globalHotkeyEnabled: boolean;
|
||||
autoUpdateEnabled: boolean;
|
||||
windowOpacity: number;
|
||||
windowBackgroundBlur: boolean;
|
||||
appIconVariant: AppIconVariant;
|
||||
setTheme: Dispatch<SetStateAction<'dark' | 'light' | 'system'>>;
|
||||
setLightUiThemeId: Dispatch<SetStateAction<string>>;
|
||||
@@ -166,6 +168,7 @@ 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'>>;
|
||||
@@ -184,7 +187,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, appIconVariant,
|
||||
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, windowBackgroundBlur, appIconVariant,
|
||||
setTheme, setLightUiThemeId, setDarkUiThemeId, setAccentMode, applyIncomingCustomAccent,
|
||||
setCustomCSS, setUiFontFamilyId, setHotkeyScheme, setUiLanguage,
|
||||
setTerminalThemeId, setTerminalThemeDarkId, setTerminalThemeLightId,
|
||||
@@ -193,7 +196,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, setAppIconVariant, setAutoUpdateEnabled, setWorkspaceFocusStyleState,
|
||||
setGlobalHotkeyEnabled, setWindowOpacity, setWindowBackgroundBlur, setAppIconVariant, setAutoUpdateEnabled, setWorkspaceFocusStyleState,
|
||||
setSftpTransferConcurrencyState, setSshTransportIdleTtlMsState,
|
||||
applyIncomingCustomKeyBindings, mergeIncomingTerminalSettings,
|
||||
}: UseSettingsStorageSyncParams) {
|
||||
@@ -208,7 +211,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, appIconVariant,
|
||||
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, windowBackgroundBlur, appIconVariant,
|
||||
});
|
||||
settingsSnapshotRef.current = {
|
||||
theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent, customAccentVersion,
|
||||
@@ -218,7 +221,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, appIconVariant,
|
||||
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, windowBackgroundBlur, appIconVariant,
|
||||
};
|
||||
|
||||
// Listen for storage changes from other windows (cross-window sync)
|
||||
@@ -523,6 +526,12 @@ 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) {
|
||||
@@ -566,6 +575,7 @@ export function useSettingsStorageSync({
|
||||
setGlobalHotkeyEnabled,
|
||||
setHostClickBehaviorState,
|
||||
setWindowOpacity,
|
||||
setWindowBackgroundBlur,
|
||||
setAppIconVariant,
|
||||
setHotkeyScheme,
|
||||
setLightUiThemeId,
|
||||
|
||||
@@ -7,6 +7,7 @@ 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';
|
||||
@@ -17,6 +18,8 @@ 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,
|
||||
@@ -47,6 +50,7 @@ interface UseSystemSettingsEffectsParams {
|
||||
autoLaunchEnabled: boolean;
|
||||
windowOpacityRecord: WindowOpacityRecord;
|
||||
windowOpacityMutationSourceRef: MutableRefObject<WindowOpacityMutationSource>;
|
||||
windowBackgroundBlur: boolean;
|
||||
appIconVariant: AppIconVariant;
|
||||
autoUpdateEnabled: boolean;
|
||||
httpNetworkProxy: HttpNetworkProxySettings;
|
||||
@@ -68,6 +72,7 @@ export function useSystemSettingsEffects({
|
||||
autoLaunchEnabled,
|
||||
windowOpacityRecord,
|
||||
windowOpacityMutationSourceRef,
|
||||
windowBackgroundBlur,
|
||||
appIconVariant,
|
||||
autoUpdateEnabled,
|
||||
httpNetworkProxy,
|
||||
@@ -300,6 +305,49 @@ 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;
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
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();
|
||||
}
|
||||
});
|
||||
@@ -4,7 +4,7 @@ import { getNotesSnapshot } from './notesStore';
|
||||
import { useActiveTabId } from './activeTabStore';
|
||||
import { getAppAppLockRuntime } from './appRuntimeBridge';
|
||||
import { createTrustedPluginAIHostRuntime } from '../../infrastructure/ai/plugin/trustedHostRuntime';
|
||||
import { getConfiguredPluginModel } from '../../infrastructure/ai/plugin/configuredModel';
|
||||
import { getConfiguredPluginModel, prepareConfiguredPluginModel } from '../../infrastructure/ai/plugin/configuredModel';
|
||||
import { buildSelectedPluginSources } from '../../infrastructure/ai/plugin/selectedSources';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
|
||||
@@ -29,6 +29,7 @@ export function usePluginAIContextHost({ sessions, workspaces }: { sessions: Ter
|
||||
const requests = new Map<string, { runtimeId: string; controller: AbortController }>();
|
||||
const runtime = createTrustedPluginAIHostRuntime({
|
||||
getModel: getConfiguredPluginModel,
|
||||
prepareModel: prepareConfiguredPluginModel,
|
||||
isActive: () => mounted && document.visibilityState === 'visible' && getAppAppLockRuntime()?.locked === false,
|
||||
chooseSelected: (signal, runtimeId) => new Promise((resolve, reject) => {
|
||||
if (chooser) { reject(Error('A host selection is already pending')); return; }
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import type { TerminalSession } from '../../domain/models';
|
||||
import type { PluginAgentHostTerminal } from '@netcatty/plugin-contract';
|
||||
import { createTrustedPluginAgentRuntime } from '../../infrastructure/ai/pluginAgent/trustedAgentRuntime';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
import { getAppAppLockRuntime } from './appRuntimeBridge';
|
||||
import { LOCAL_STORAGE_ADAPTER_CHANGED_EVENT } from '../../infrastructure/persistence/localStorageAdapter';
|
||||
import { STORAGE_KEY_AI_PROVIDERS, STORAGE_KEY_AI_ACTIVE_PROVIDER, STORAGE_KEY_AI_ACTIVE_MODEL, STORAGE_KEY_AI_AGENT_MODEL_MAP,
|
||||
STORAGE_KEY_AI_AGENT_PROVIDER_MAP, STORAGE_KEY_AI_EXTERNAL_AGENTS } from '../../infrastructure/config/storageKeys';
|
||||
|
||||
type Choice = { items: PluginAgentHostTerminal[]; confirm: (ids: string[]) => void; cancel: () => void };
|
||||
|
||||
// All inventory remains in trusted application UI. Only confirmed metadata is
|
||||
// passed to the main-owned worker bindings; focus never determines a target.
|
||||
export function usePluginAgentHost(sessions: TerminalSession[]) {
|
||||
const latest = useRef(sessions); latest.current = sessions;
|
||||
const [choice, setChoice] = useState<Choice | null>(null);
|
||||
useEffect(() => {
|
||||
const api = netcattyBridge.get();
|
||||
if (!api?.onPluginAgentHostRequest || !api.setPluginAgentHostReady || !api.respondPluginAgentHost) return;
|
||||
const bridge = { subscribe: api.onPluginAgentHostRequest.bind(api), ready: api.setPluginAgentHostReady.bind(api), respond: api.respondPluginAgentHost.bind(api) };
|
||||
let mounted = true;
|
||||
let chooser: { runtimeId: string; cancel: () => void } | null = null;
|
||||
const requests = new Map<string, { runtimeId: string; controller: AbortController }>();
|
||||
const runtime = createTrustedPluginAgentRuntime({
|
||||
isActive: () => mounted && document.visibilityState === 'visible' && getAppAppLockRuntime()?.locked === false,
|
||||
chooseTargets: (signal, runtimeId) => new Promise((resolve, reject) => {
|
||||
if (chooser) { reject(Error('A target selection is already pending')); return; }
|
||||
const items = latest.current.filter((item) => item.status === 'connected').map((item) => ({
|
||||
sessionId: item.id, hostId: item.hostId, hostname: (item.hostname || '').slice(0, 253),
|
||||
label: (item.customName || item.hostLabel || item.id).slice(0, 160),
|
||||
...(item.protocol ? { protocol: item.protocol } : {}), ...(item.username ? { username: item.username.slice(0, 160) } : {}), connected: true,
|
||||
}));
|
||||
const finish = (ids: string[] | null) => {
|
||||
if (chooser?.cancel !== cancel) return;
|
||||
chooser = null; signal.removeEventListener('abort', cancel); setChoice(null);
|
||||
if (ids === null || signal.aborted || ids.length > 32 || new Set(ids).size !== ids.length) { reject(Error('Target selection cancelled')); return; }
|
||||
const selected = ids.map((id) => items.find((item) => item.sessionId === id));
|
||||
if (selected.some((item) => !item || !latest.current.some((session) => session.id === item.sessionId && session.status === 'connected'))) {
|
||||
reject(Error('Selected terminal is no longer connected')); return;
|
||||
}
|
||||
resolve(selected as PluginAgentHostTerminal[]);
|
||||
};
|
||||
const cancel = () => finish(null);
|
||||
chooser = { runtimeId, cancel }; signal.addEventListener('abort', cancel, { once: true });
|
||||
setChoice({ items, confirm: finish, cancel });
|
||||
if (signal.aborted) cancel();
|
||||
}),
|
||||
});
|
||||
const unsubscribe = bridge.subscribe((request) => {
|
||||
if (request.kind === 'request.cancel') {
|
||||
const pending = requests.get(request.payload.requestId);
|
||||
if (pending?.runtimeId === request.runtimeId) pending.controller.abort();
|
||||
runtime.cancelRequest(request.payload.requestId, request.runtimeId); return;
|
||||
}
|
||||
if (request.kind === 'runtime.release') {
|
||||
for (const item of requests.values()) if (item.runtimeId === request.runtimeId) item.controller.abort();
|
||||
if (chooser?.runtimeId === request.runtimeId) chooser.cancel();
|
||||
runtime.releaseRuntime(request.runtimeId); return;
|
||||
}
|
||||
if (request.kind === 'agent.close') { runtime.releaseChat(request.payload.chatSessionId, request.runtimeId); return; }
|
||||
if (requests.has(request.requestId) || requests.size >= 64) return;
|
||||
const controller = new AbortController(); requests.set(request.requestId, { runtimeId: request.runtimeId, controller });
|
||||
void runtime.handle(request, controller.signal).then(
|
||||
(result) => bridge.respond({ requestId: request.requestId, actionToken: request.actionToken, result }).catch(() =>
|
||||
bridge.respond({ requestId: request.requestId, actionToken: request.actionToken, error: true })),
|
||||
() => bridge.respond({ requestId: request.requestId, actionToken: request.actionToken, error: true }),
|
||||
).catch(() => {}).finally(() => requests.delete(request.requestId));
|
||||
});
|
||||
const pageHide = () => { runtime.shutdown(); for (const item of requests.values()) item.controller.abort(); };
|
||||
const configurationKeys = new Set([STORAGE_KEY_AI_PROVIDERS, STORAGE_KEY_AI_ACTIVE_PROVIDER, STORAGE_KEY_AI_ACTIVE_MODEL,
|
||||
STORAGE_KEY_AI_AGENT_MODEL_MAP, STORAGE_KEY_AI_AGENT_PROVIDER_MAP, STORAGE_KEY_AI_EXTERNAL_AGENTS]);
|
||||
const configurationChanged = (event: Event) => {
|
||||
const key = event.type === 'storage' ? (event as StorageEvent).key : (event as CustomEvent<{ key: string }>).detail?.key;
|
||||
if (key === null || (key && configurationKeys.has(key))) {
|
||||
runtime.invalidateConfiguration();
|
||||
// Invalidate main-owned backend leases too, even with no event read in
|
||||
// flight. Re-advertising availability does not renew existing handles.
|
||||
void bridge.ready(false).then(() => { if (mounted) return bridge.ready(true); }).catch(() => {});
|
||||
}
|
||||
};
|
||||
window.addEventListener('pagehide', pageHide);
|
||||
window.addEventListener('storage', configurationChanged); window.addEventListener(LOCAL_STORAGE_ADAPTER_CHANGED_EVENT, configurationChanged);
|
||||
void bridge.ready(true).catch(() => {});
|
||||
return () => {
|
||||
mounted = false; chooser?.cancel(); unsubscribe(); window.removeEventListener('pagehide', pageHide);
|
||||
window.removeEventListener('storage', configurationChanged); window.removeEventListener(LOCAL_STORAGE_ADAPTER_CHANGED_EVENT, configurationChanged);
|
||||
for (const item of requests.values()) item.controller.abort(); requests.clear(); runtime.shutdown();
|
||||
void bridge.ready(false).catch(() => {});
|
||||
};
|
||||
}, []);
|
||||
return { choice };
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
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 };
|
||||
}
|
||||
@@ -74,6 +74,7 @@ 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,
|
||||
@@ -645,6 +646,9 @@ 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);
|
||||
@@ -1276,6 +1280,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
setIsHotkeyRecordingState,
|
||||
setGlobalHotkeyEnabled,
|
||||
setWindowOpacity: applyIncomingWindowOpacity,
|
||||
setWindowBackgroundBlur,
|
||||
setAppIconVariant,
|
||||
setAutoUpdateEnabled,
|
||||
setAutoLaunchEnabled,
|
||||
@@ -1298,12 +1303,18 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
setSshTransportIdleTtlMsState,
|
||||
});
|
||||
|
||||
// Detached sessions share opacity and the shortcut configuration needed to
|
||||
// operate it. Keep general settings listeners and OS effects disabled there.
|
||||
// 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.
|
||||
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);
|
||||
}
|
||||
@@ -1377,7 +1388,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, appIconVariant,
|
||||
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, windowBackgroundBlur, appIconVariant,
|
||||
setTheme, setLightUiThemeId, setDarkUiThemeId, setAccentMode,
|
||||
applyIncomingCustomAccent,
|
||||
setCustomCSS, setUiFontFamilyId, setHotkeyScheme, setUiLanguage,
|
||||
@@ -1387,7 +1398,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, setAppIconVariant, setAutoUpdateEnabled, setWorkspaceFocusStyleState,
|
||||
setGlobalHotkeyEnabled, setWindowOpacity: applyIncomingWindowOpacity, setWindowBackgroundBlur, setAppIconVariant, setAutoUpdateEnabled, setWorkspaceFocusStyleState,
|
||||
setSftpTransferConcurrencyState, setSshTransportIdleTtlMsState,
|
||||
applyIncomingCustomKeyBindings, mergeIncomingTerminalSettings,
|
||||
});
|
||||
@@ -1896,6 +1907,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
autoLaunchEnabled,
|
||||
windowOpacityRecord,
|
||||
windowOpacityMutationSourceRef,
|
||||
windowBackgroundBlur,
|
||||
appIconVariant,
|
||||
autoUpdateEnabled,
|
||||
httpNetworkProxy,
|
||||
@@ -2276,6 +2288,8 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
windowOpacity,
|
||||
setWindowOpacity,
|
||||
toggleWindowOpacity,
|
||||
windowBackgroundBlur,
|
||||
setWindowBackgroundBlur,
|
||||
appIconVariant,
|
||||
setAppIconVariant,
|
||||
rehydrateAllFromStorage,
|
||||
|
||||
@@ -10,7 +10,7 @@ import { fileURLToPath } from 'node:url';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
test('default host adapter, real preload, mounted host chooser and installed assistant reach only the local fixture', { timeout: 20_000 }, async () => {
|
||||
test('cold plugin host reaches the local fixture through the actual main provider allowlist without the AI sidebar', { timeout: 20_000 }, async (t) => {
|
||||
// JSDOM + the in-process runtime prove app wiring; they are not native sandbox
|
||||
// evidence. All state is synthetic, with no real provider, key, Note or host.
|
||||
const rootPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
@@ -42,6 +42,7 @@ test('default host adapter, real preload, mounted host chooser and installed ass
|
||||
const { inProcessBrowserRuntime } = require('../electron/plugins/fixtures/inProcessBrowserRuntime.cjs');
|
||||
const { createPreloadApi } = require('../electron/preload/api.cjs');
|
||||
const aiBridge = require('../electron/bridges/aiBridge.cjs');
|
||||
const { registerProviderHandlers } = require('../electron/bridges/aiBridge/providerHandlers.cjs');
|
||||
const { server, counts } = createAssistantProviderFixture({ delayMs: 25 });
|
||||
const oldNotes = getNotesSnapshot(); const oldLock = getAppAppLockRuntime();
|
||||
let service: ReturnType<typeof createPluginHostService> | undefined;
|
||||
@@ -51,6 +52,30 @@ test('default host adapter, real preload, mounted host chooser and installed ass
|
||||
const lockListeners = new Set<(state: { locked: boolean }) => void>();
|
||||
const reviews: Array<Record<string, unknown>> = [];
|
||||
const trustedPayloads: unknown[] = [];
|
||||
const streamResults: Array<{ ok: boolean; statusCode?: number; allowlistRejected: boolean }> = [];
|
||||
const providerSyncs: Array<{ count: number; selectedPresent: boolean }> = [];
|
||||
const sdkErrorNames: string[] = [];
|
||||
let diagnosticPhase = 'setup';
|
||||
const originalConsoleError = console.error;
|
||||
// node:sqlite was imported above. Node 22 delivers its ExperimentalWarning
|
||||
// asynchronously through console.error. Let module warnings reach normal
|
||||
// stderr before observing SDK/renderer errors; do not suppress that warning.
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
// The expected pre-fix SDK error contains request details. Keep diagnostics
|
||||
// categorical even on the failing-before path; never print payloads/headers.
|
||||
console.error = (value: unknown) => {
|
||||
let category = 'unknown';
|
||||
if (typeof value === 'string' && /not wrapped in act/.test(value)) category = 'react-act-warning';
|
||||
else if (typeof value === 'string' && /act.*environment|environment.*act/.test(value)) category = 'react-act-environment';
|
||||
else if (typeof value === 'string' && /ExperimentalWarning: SQLite is an experimental feature/.test(value)) category = 'node-sqlite-experimental';
|
||||
else if (typeof value === 'string') category = 'other-string';
|
||||
else if (value && typeof value === 'object') {
|
||||
const name = (value as { name?: unknown }).name;
|
||||
if (typeof name === 'string' && ['AbortError', 'AI_APICallError', 'AI_LoadAPIKeyError', 'TypeError', 'Error'].includes(name)) category = name;
|
||||
}
|
||||
sdkErrorNames.push(`${diagnosticPhase}:${category}`);
|
||||
};
|
||||
t.after(() => { console.error = originalConsoleError; });
|
||||
try {
|
||||
server.listen(0, '127.0.0.1'); await once(server, 'listening');
|
||||
const address = server.address(); assert.ok(address && typeof address !== 'string');
|
||||
@@ -62,25 +87,50 @@ test('default host adapter, real preload, mounted host chooser and installed ass
|
||||
ipcRenderer.emit(channel, {}, structuredClone(payload));
|
||||
} });
|
||||
const mainWindow = Object.assign(new EventEmitter(), { webContents: sender, isDestroyed: () => false, isVisible: () => true, isFocused: () => true });
|
||||
const providerContext = {
|
||||
ipcMain: { handle: (channel: string, handler: (...args: unknown[]) => unknown) => ipcHandlers.set(channel, handler) },
|
||||
providerConfigs: [] as Array<{ id: string; baseURL?: string; apiKey?: string }>, webSearchApiHost: null,
|
||||
validateSender: (event: { sender: unknown }) => event.sender === sender,
|
||||
validateSenderOrSettings: (event: { sender: unknown }) => event.sender === sender,
|
||||
// Only credential injection is synthetic. Admission, sync and the HTTP
|
||||
// transport are the production implementations, not a direct-fetch seam.
|
||||
injectApiKeyIntoRequest: (url: string, headers: Record<string, string>, providerId: string) => {
|
||||
const config = providerContext.providerConfigs.find((item) => item.id === providerId);
|
||||
return { url, headers: Object.fromEntries(Object.entries(headers).map(([key, value]) =>
|
||||
[key, config?.apiKey ? value.replace('__IPC_SECURED__', config.apiKey) : value])) };
|
||||
},
|
||||
shouldSkipTLSVerify: () => false,
|
||||
streamRequest: aiBridge._streamRequestForTests,
|
||||
};
|
||||
registerProviderHandlers(providerContext);
|
||||
assert.equal(providerContext.providerConfigs.length, 0);
|
||||
ipcRenderer.invoke = async (channel, payload) => {
|
||||
if (channel === 'netcatty:ai:chat:stream') {
|
||||
const request = payload as { url: string; headers: Record<string, string>; body: string; requestId: string };
|
||||
assert.equal(new URL(request.url).origin, new URL(endpoint).origin);
|
||||
assert.doesNotMatch(JSON.stringify(request), /fixture-only/);
|
||||
const status = await aiBridge._streamRequestForTests(request.url, { headers: request.headers, body: request.body }, { sender }, request.requestId, false);
|
||||
return { ok: status.statusCode === 200, ...status };
|
||||
}
|
||||
if (channel === 'netcatty:ai:chat:cancel') return aiBridge.cancelPluginAIStreamsForSender(sender) > 0;
|
||||
const handler = ipcHandlers.get(channel);
|
||||
assert.ok(handler, `Unexpected trusted IPC: ${channel}`);
|
||||
return handler({ sender }, structuredClone(payload));
|
||||
const result = await handler({ sender }, structuredClone(payload));
|
||||
if (channel === 'netcatty:ai:sync-providers') providerSyncs.push({ count: providerContext.providerConfigs.length,
|
||||
selectedPresent: providerContext.providerConfigs.some((item) => item.id === 'qa-provider') });
|
||||
if (channel === 'netcatty:ai:chat:stream') {
|
||||
const status = result as { ok: boolean; statusCode?: number; error?: string };
|
||||
streamResults.push({ ok: status.ok, statusCode: status.statusCode, allowlistRejected: status.error === 'URL host is not in the allowed list' });
|
||||
}
|
||||
return result;
|
||||
};
|
||||
const api = createPreloadApi({ ipcRenderer, webUtils: {} });
|
||||
Object.defineProperty(dom.window, 'netcatty', { value: api });
|
||||
const syntheticNote = { id: 'synthetic-note', title: 'Synthetic retry plan', content: 'Synthetic retry plan: add a retry button.', createdAt: 1, updatedAt: 1 };
|
||||
publishNotesSnapshot({ notes: [syntheticNote], noteGroups: [] });
|
||||
registerAppAppLockRuntime({ locked: false } as NonNullable<ReturnType<typeof getAppAppLockRuntime>>);
|
||||
dom.window.localStorage.setItem(STORAGE_KEY_AI_PROVIDERS, JSON.stringify([{ id: 'qa-provider', name: 'QA fixture', providerId: 'custom', enabled: true, baseURL: endpoint, defaultModel: 'deterministic-fixture', apiKey: 'fixture-only' }]));
|
||||
dom.window.localStorage.setItem(STORAGE_KEY_AI_PROVIDERS, JSON.stringify([
|
||||
{ id: 'qa-provider', name: 'QA fixture', providerId: 'custom', enabled: true, baseURL: endpoint, defaultModel: 'deterministic-fixture', apiKey: 'fixture-only' },
|
||||
{ id: 'qa-secondary', name: 'Synthetic secondary', providerId: 'custom', enabled: true, baseURL: 'https://secondary.invalid/v1', defaultModel: 'synthetic', apiKey: 'secondary-fixture-marker' },
|
||||
]));
|
||||
dom.window.localStorage.setItem(STORAGE_KEY_AI_ACTIVE_PROVIDER, 'qa-provider');
|
||||
dom.window.localStorage.setItem(STORAGE_KEY_AI_ACTIVE_MODEL, 'deterministic-fixture');
|
||||
service = createPluginHostService({
|
||||
@@ -116,6 +166,7 @@ test('default host adapter, real preload, mounted host chooser and installed ass
|
||||
executeCommand: async (command: string, args: unknown) => structuredClone(await service!.contributionService.executeCommand(command, structuredClone(args))),
|
||||
onDidReceiveMessage: () => ({ dispose() {} }),
|
||||
} });
|
||||
diagnosticPhase = 'initial-selection';
|
||||
await act(async () => panel!.window.eval(fs.readFileSync(path.join(sample, 'panel.js'), 'utf8')));
|
||||
await waitFor(() => document.getElementById('plugin-ai-note') !== null);
|
||||
const noteChoice = document.getElementById('plugin-ai-note') as HTMLSelectElement;
|
||||
@@ -129,24 +180,36 @@ test('default host adapter, real preload, mounted host chooser and installed ass
|
||||
assert.equal(reference.value, ''); assert.equal(reference.options.length, 2);
|
||||
assert.notEqual(reference.options[1].value, syntheticNote.id); reference.value = reference.options[1].value;
|
||||
const send = () => element('compose').dispatchEvent(new panel!.window.Event('submit', { cancelable: true }));
|
||||
diagnosticPhase = 'deny-content';
|
||||
send(); await waitFor(() => element('status').textContent!.startsWith('Request ended'));
|
||||
assert.equal(counts.requests, 0); assert.equal(reviews.length, 0);
|
||||
contentAllowed = true;
|
||||
send(); await waitFor(() => element('status').textContent === 'Complete');
|
||||
diagnosticPhase = 'complete-stream';
|
||||
send(); await waitFor(() => counts.completed === 1 || streamResults.some((result) => !result.ok));
|
||||
assert.deepEqual(streamResults.at(-1), { ok: true, statusCode: 200, allowlistRejected: false },
|
||||
'Cold plugin startup must synchronize host providers before the main allowlist admits transport');
|
||||
assert.ok(providerSyncs.some((sync) => sync.selectedPresent && sync.count === 2), 'Preserve other host-configured providers in the shared registry');
|
||||
await waitFor(() => element('status').textContent === 'Complete');
|
||||
assert.equal(reviews.length, 0, 'content policy must not open a second native sharing review');
|
||||
assert.equal(counts.completed, 1); assert.match(element('response').textContent!, /Synthetic plan/); assert.match(element('usage').textContent!, /12 input tokens/);
|
||||
diagnosticPhase = 'stop-stream';
|
||||
send(); await waitFor(() => element('response').textContent!.startsWith('Synthetic plan: '));
|
||||
element('stop').dispatchEvent(new panel.window.Event('click'));
|
||||
await waitFor(() => element('status').textContent === 'Stopped' && counts.aborted === 1);
|
||||
const stoppedText = element('response').textContent; await new Promise((resolve) => setTimeout(resolve, 50)); assert.equal(element('response').textContent, stoppedText);
|
||||
diagnosticPhase = 'lock-stream';
|
||||
send(); await waitFor(() => element('response').textContent!.startsWith('Synthetic plan: '));
|
||||
locked = true; registerAppAppLockRuntime({ locked: true } as NonNullable<ReturnType<typeof getAppAppLockRuntime>>);
|
||||
for (const listener of lockListeners) listener({ locked: true });
|
||||
await waitFor(() => counts.aborted === 2 && service!.aiContextService.broker.turns.size === 0);
|
||||
assert.equal(aiBridge._getActiveStreamCountForTests(), 0);
|
||||
assert.doesNotMatch(JSON.stringify(trustedPayloads), /fixture-only|apiKey|privateOwner|permissionMode|mcp/);
|
||||
assert.doesNotMatch(JSON.stringify(trustedPayloads), /fixture-only|secondary-fixture-marker|apiKey|privateOwner|permissionMode|mcp/);
|
||||
assert.deepEqual(sdkErrorNames, []);
|
||||
} finally {
|
||||
panel?.window.close();
|
||||
console.error = originalConsoleError;
|
||||
// JSDOM.close does not deliver the browser pagehide lifecycle. Invalidate
|
||||
// the sample's pending callbacks before teardown closes its document.
|
||||
if (panel) { panel.window.dispatchEvent(new panel.window.Event('pagehide')); panel.window.close(); }
|
||||
await act(async () => root.unmount());
|
||||
await service?.manager.shutdown(); aiBridge.cleanup();
|
||||
publishNotesSnapshot(oldNotes); registerAppAppLockRuntime(oldLock);
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import { createRequire } from 'node:module';
|
||||
import { EventEmitter, once } from 'node:events';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
test('installed global Chat UI reuses actual Catty runtime, configured HTTP transport and selected worker dispatch', { timeout: 30_000 }, async () => {
|
||||
// This Node/JSDOM harness proves installed app wiring, not a native sandbox or
|
||||
// real model/Claude acceptance. No real shell, remote host or credentials.
|
||||
const rootPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'netcatty-global-agent-ui-'));
|
||||
const oldTmp = process.env.TMPDIR; process.env.TMPDIR = directory;
|
||||
const dom = new JSDOM('<!doctype html><html><body><div id="root"></div></body></html>', { pretendToBeVisual: true, url: 'http://localhost' });
|
||||
const previous = new Map<string, PropertyDescriptor | undefined>();
|
||||
const install = (key: string, value: unknown) => { previous.set(key, Object.getOwnPropertyDescriptor(globalThis, key)); Object.defineProperty(globalThis, key, { configurable: true, writable: true, value }); };
|
||||
for (const key of ['window', 'document', 'navigator', 'HTMLElement', 'HTMLInputElement', 'HTMLSelectElement', 'Element', 'Node', 'NodeFilter', 'MutationObserver', 'CustomEvent', 'Event', 'KeyboardEvent', 'MouseEvent', 'DocumentFragment']) install(key, key === 'window' ? dom.window : dom.window[key as keyof typeof dom.window]);
|
||||
install('localStorage', dom.window.localStorage); install('getComputedStyle', dom.window.getComputedStyle.bind(dom.window));
|
||||
install('requestAnimationFrame', dom.window.requestAnimationFrame.bind(dom.window)); install('cancelAnimationFrame', dom.window.cancelAnimationFrame.bind(dom.window)); install('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
const { default: React, act } = await import('react'); const { createRoot } = await import('react-dom/client');
|
||||
const { PluginAgentHost } = await import('./plugins/PluginAgentHost');
|
||||
const { I18nProvider } = await import('../application/i18n/I18nProvider');
|
||||
const { registerAppAppLockRuntime, getAppAppLockRuntime } = await import('../application/state/appRuntimeBridge');
|
||||
const keys = await import('../infrastructure/config/storageKeys');
|
||||
const { createGlobalAgentProviderFixture } = await import('../scripts/plugin-global-agent-provider-fixture.mjs');
|
||||
const { createPluginHostService } = require('../electron/plugins/hostService.cjs');
|
||||
const { inProcessBrowserRuntime } = require('../electron/plugins/fixtures/inProcessBrowserRuntime.cjs');
|
||||
const { createPreloadApi } = require('../electron/preload/api.cjs');
|
||||
const { registerProviderHandlers } = require('../electron/bridges/aiBridge/providerHandlers.cjs');
|
||||
const { registerCattyExecHandlers } = require('../electron/bridges/aiBridge/cattyExecHandlers.cjs');
|
||||
const { registerAgentProcessHandlers } = require('../electron/bridges/aiBridge/agentProcessHandlers.cjs');
|
||||
const { registerPrivacyHandlers } = require('../electron/bridges/aiBridge/privacyHandlers.cjs');
|
||||
const aiBridge = require('../electron/bridges/aiBridge.cjs'); const mcp = require('../electron/bridges/mcpServerBridge.cjs');
|
||||
const privacy = require('../electron/shared/aiPrivacyService.cjs');
|
||||
const { server, counts } = createGlobalAgentProviderFixture({ delayMs: 30 });
|
||||
const oldLock = getAppAppLockRuntime(); const root = createRoot(document.getElementById('root')!);
|
||||
let service: ReturnType<typeof createPluginHostService> | undefined; let panel: JSDOM | undefined;
|
||||
const errors: string[] = []; const originalError = console.error;
|
||||
let phase = 'setup';
|
||||
// Flush the known Node sqlite import warning before observing safe categories.
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
console.error = (value: unknown) => { errors.push(`${phase}:${typeof value === 'string' && value === '[Plugin Agent] Turn unavailable' ? 'DELEGATED_TURN_UNAVAILABLE' : typeof value === 'string' && /not wrapped in act/.test(value) ? 'REACT_ACT_WARNING' : 'SDK_OR_RENDERER_ERROR'}`); };
|
||||
try {
|
||||
privacy.configure({ enabled: false }); server.listen(0, '127.0.0.1'); await once(server, 'listening');
|
||||
const address = server.address(); assert.ok(address && typeof address !== 'string'); const endpoint = `http://127.0.0.1:${address.port}/v1`;
|
||||
const handlers = new Map<string, (...args: unknown[]) => unknown>();
|
||||
const ipcMain = { handle: (name: string, handler: (...args: unknown[]) => unknown) => handlers.set(name, handler), removeHandler: (name: string) => handlers.delete(name) };
|
||||
const ipcRenderer = new EventEmitter() as EventEmitter & { invoke: (channel: string, payload: unknown) => Promise<unknown> };
|
||||
const trustedReplies: unknown[] = []; const pluginReplies: unknown[] = []; const workerCalls: string[] = [];
|
||||
const sender = Object.assign(new EventEmitter(), { id: 7, isDestroyed: () => false, send: (channel: string, payload: unknown) => ipcRenderer.emit(channel, {}, structuredClone(payload)) });
|
||||
const mainWindow = Object.assign(new EventEmitter(), { webContents: sender, isDestroyed: () => false, isVisible: () => true, isFocused: () => true });
|
||||
const owner = (id: string) => id === 'synthetic-a' ? 7 : id === 'synthetic-b' ? 8 : null;
|
||||
const worker = { getSessionOwnerWebContentsId: owner, getPluginSessionBinding: (id: string, actual: number) => actual === owner(id) ? { epoch: 'fixture-worker', generation: id === 'synthetic-a' ? 1 : 2 } : null,
|
||||
send: () => {}, request: async (method: string, payload: { sessionId: string }, options?: { beforeExecute?: () => void }) => {
|
||||
options?.beforeExecute?.(); if (method === 'netcatty:ai:exec') { workerCalls.push(payload.sessionId); return { ok: true, stdout: 'Synthetic worker output', stderr: '', exitCode: 0 }; } return [];
|
||||
} };
|
||||
mcp.init({ sessions: new Map(), terminalWorkerManager: worker, cliDiscoveryFilePath: path.join(directory, 'discovery.json') }); mcp.setPermissionMode('observer');
|
||||
const common = { ipcMain, validateSender: (event: { sender: unknown }) => event.sender === sender,
|
||||
validateSenderOrSettings: (event: { sender: unknown }) => event.sender === sender, mcpServerBridge: mcp, aiPrivacyService: privacy };
|
||||
const providerContext = { ...common, require: createRequire(path.join(rootPath, 'electron/bridges/aiBridge.cjs')),
|
||||
providerConfigs: [] as Array<{ id: string; baseURL?: string; apiKey?: string }>, webSearchApiHost: null,
|
||||
injectApiKeyIntoRequest: (url: string, headers: Record<string, string>, providerId: string) => {
|
||||
const provider = providerContext.providerConfigs.find((entry) => entry.id === providerId);
|
||||
return { url, headers: Object.fromEntries(Object.entries(headers).map(([key, value]) => [key, value.replace('__IPC_SECURED__', provider?.apiKey ?? '')])) };
|
||||
}, shouldSkipTLSVerify: () => false, streamRequest: aiBridge._streamRequestForTests,
|
||||
// The production stream function owns this map. Turn release below uses
|
||||
// its actual main lease; direct SDK abort still calls the ordinary route.
|
||||
activeStreams: new Map() };
|
||||
registerProviderHandlers(providerContext); registerCattyExecHandlers(common); registerAgentProcessHandlers(common); registerPrivacyHandlers(common);
|
||||
ipcRenderer.invoke = async (channel, payload) => {
|
||||
if (channel === 'netcatty:ai:chat:stream') {
|
||||
const request = payload as { requestId: string; url: string }; assert.equal(new URL(request.url).origin, new URL(endpoint).origin);
|
||||
assert.match(request.requestId, /^agent-model_/);
|
||||
}
|
||||
const handler = handlers.get(channel); assert.ok(handler, `Unexpected trusted IPC: ${channel}`);
|
||||
const result = await handler({ sender }, structuredClone(payload));
|
||||
if (channel === 'netcatty:plugins:agent-host-response') trustedReplies.push(payload);
|
||||
return result;
|
||||
};
|
||||
const api = createPreloadApi({ ipcRenderer, webUtils: {} }); Object.defineProperty(dom.window, 'netcatty', { value: api });
|
||||
registerAppAppLockRuntime({ locked: false } as NonNullable<ReturnType<typeof getAppAppLockRuntime>>);
|
||||
dom.window.localStorage.setItem(keys.STORAGE_KEY_AI_PROVIDERS, JSON.stringify([{ id: 'fixture-provider', name: 'Synthetic fixture', providerId: 'custom', enabled: true,
|
||||
baseURL: endpoint, defaultModel: 'global-agent-fixture', apiKey: 'FIXTURE_ONLY_API_MARKER' }]));
|
||||
dom.window.localStorage.setItem(keys.STORAGE_KEY_AI_ACTIVE_PROVIDER, 'fixture-provider');
|
||||
service = createPluginHostService({ app: { getAppPath: () => rootPath, getPath: () => directory, getVersion: () => '0.0.0' }, env: {},
|
||||
electron: { ipcMain }, getInteractionWindow: () => mainWindow, isAppLocked: () => false, getTerminalWorkerManager: () => worker });
|
||||
service.runtimeSupervisor.runtimeFactories.browser = inProcessBrowserRuntime; await service.manager.initialize();
|
||||
const sample = path.join(rootPath, 'examples/plugins/global-agent'); const staging = path.join(directory, 'package'); fs.mkdirSync(path.join(staging, 'dist'), { recursive: true });
|
||||
for (const file of ['netcatty.plugin.json', 'panel.html', 'panel.css', 'panel.js', 'README.md']) fs.copyFileSync(path.join(sample, file), path.join(staging, file));
|
||||
fs.copyFileSync(path.join(sample, 'src/index.js'), path.join(staging, 'dist/index.js'));
|
||||
const { buildPluginPackage } = await import('@netcatty/plugin-cli'); const archive = path.join(directory, 'global-agent.ncpkg'); await buildPluginPackage(staging, archive);
|
||||
await service.manager.install(archive, { enable: true });
|
||||
await act(async () => root.render(<I18nProvider locale="en"><PluginAgentHost sessions={[
|
||||
{ id: 'synthetic-a', hostId: 'host-a', hostLabel: 'Workspace A fixture', hostname: 'a.invalid', username: 'fixture', status: 'connected' },
|
||||
{ id: 'synthetic-b', hostId: 'host-b', hostLabel: 'Workspace B fixture', hostname: 'b.invalid', username: 'fixture', status: 'connected' },
|
||||
]} /></I18nProvider>));
|
||||
const waitFor = async (condition: () => boolean) => { const until = Date.now() + 8_000; while (!condition()) {
|
||||
if (Date.now() >= until) throw Error(`Global Agent UI fixture timed out; accepted=${counts.requests}, rejected=${counts.rejected}, effects=${workerCalls.length}, status=${panel?.window.document.getElementById('status')?.textContent}`);
|
||||
await act(async () => { await new Promise((resolve) => setTimeout(resolve, 5)); });
|
||||
} };
|
||||
let viewOpen = true;
|
||||
const mountPanel = async () => {
|
||||
panel = new JSDOM(fs.readFileSync(path.join(sample, 'panel.html'), 'utf8'), { runScripts: 'outside-only' });
|
||||
Object.defineProperty(panel.window, 'netcattyView', { value: { executeCommand: async (name: string, params: unknown) => {
|
||||
// PluginViewHost.close removes sender authority before WebContents
|
||||
// closes. A pagehide RPC cannot be relied on to reach this relay.
|
||||
if (!viewOpen) throw Error('Untrusted plugin view sender');
|
||||
const result = structuredClone(await service!.contributionService.executeCommand(name, structuredClone(params))); pluginReplies.push(result); return result;
|
||||
}, onDidReceiveMessage: () => ({ dispose() {} }), onDidChangeEnvironment: () => ({ dispose() {} }) } });
|
||||
await act(async () => panel!.window.eval(fs.readFileSync(path.join(sample, 'panel.js'), 'utf8')));
|
||||
};
|
||||
await mountPanel();
|
||||
const element = (id: string) => panel!.window.document.getElementById(id)!;
|
||||
await waitFor(() => element('status').textContent!.startsWith('Choose terminals'));
|
||||
await act(async () => element('targets').click()); await waitFor(() => document.querySelectorAll('input[type="checkbox"]').length === 2);
|
||||
await act(async () => { for (const checkbox of document.querySelectorAll<HTMLInputElement>('input[type="checkbox"]')) checkbox.click(); });
|
||||
await act(async () => { [...document.querySelectorAll('button')].find((button) => button.textContent === 'Use selected terminals')!.click(); });
|
||||
await waitFor(() => element('status').textContent!.startsWith('Targets selected'));
|
||||
await act(async () => element('open').click()); await waitFor(() => element('status').textContent!.startsWith('Ready'));
|
||||
let prompt = element('prompt') as HTMLTextAreaElement; prompt.value = 'Synthetic selected-target task';
|
||||
const send = () => element('compose').dispatchEvent(new panel!.window.Event('submit', { cancelable: true }));
|
||||
phase = 'complete'; await act(async () => { send(); }); await waitFor(() => element('status').textContent === 'Complete');
|
||||
assert.deepEqual(workerCalls, ['synthetic-b']); assert.match(element('transcript').textContent!, /Synthetic global Agent result/);
|
||||
assert.equal(mcp.getPermissionMode(), 'observer'); assert.equal(counts.requests, 2); assert.equal(counts.rejected, 0);
|
||||
assert.match(element('usage').textContent!, /input tokens/);
|
||||
phase = 'stop'; prompt.value = 'fixture:wait'; await act(async () => { send(); });
|
||||
await waitFor(() => element('transcript').textContent!.includes('Synthetic waiting output'));
|
||||
await act(async () => element('stop').click()); await waitFor(() => element('status').textContent === 'Stopped' && counts.aborted === 1);
|
||||
const stopped = element('transcript').textContent; await new Promise((resolve) => setTimeout(resolve, 75)); assert.equal(element('transcript').textContent, stopped);
|
||||
phase = 'modal-reopen-history';
|
||||
const reference = pluginReplies.find((value) => value && typeof value === 'object' && 'conversationRef' in value && 'chatHandle' in value) as { conversationRef: string };
|
||||
const beforeReopen = counts.requests;
|
||||
viewOpen = false;
|
||||
panel!.window.dispatchEvent(new panel!.window.Event('pagehide')); panel!.window.close();
|
||||
viewOpen = true; await mountPanel();
|
||||
await waitFor(() => element('status').textContent!.startsWith('Choose terminals'));
|
||||
(element('conversation') as HTMLSelectElement).value = reference.conversationRef;
|
||||
await act(async () => element('conversation').dispatchEvent(new panel!.window.Event('change')));
|
||||
await act(async () => element('targets').click()); await waitFor(() => document.querySelectorAll('input[type="checkbox"]').length === 2);
|
||||
await act(async () => { [...document.querySelectorAll('button')].find((button) => button.textContent === 'Use selected terminals')!.click(); });
|
||||
await waitFor(() => element('status').textContent!.startsWith('Targets selected'));
|
||||
await act(async () => element('open').click());
|
||||
await waitFor(() => /^(Ready|Conversation unavailable)/.test(element('status').textContent!));
|
||||
assert.match(element('status').textContent!, /^Ready/, 'ordinary modal reopen must release the prior exact chat even if pagehide cleanup never reaches the plugin');
|
||||
assert.match(element('transcript').textContent!, /Synthetic global Agent result/);
|
||||
assert.equal(service.agentService.chats.size, 1); assert.equal(counts.requests, beforeReopen, 'history reopen does not invoke a model');
|
||||
prompt = element('prompt') as HTMLTextAreaElement; prompt.value = 'Synthetic continuation';
|
||||
phase = 'deny'; const before = counts.requests; const plugin = service.database.getActivePlugin('com.netcatty.globalagent');
|
||||
service.permissionEngine.setMode(plugin, 'ai.agent.actions', 'deny', service.permissionEngine.getPolicyRevision(plugin));
|
||||
await act(async () => { send(); }); await waitFor(() => element('status').textContent!.startsWith('Request ended'));
|
||||
assert.equal(counts.requests, before); assert.deepEqual(workerCalls, ['synthetic-b']);
|
||||
phase = 'restart-history';
|
||||
await service.manager.setEnabled('com.netcatty.globalagent', false);
|
||||
await service.manager.setEnabled('com.netcatty.globalagent', true);
|
||||
await act(async () => element('refresh').click()); await waitFor(() => element('status').textContent!.startsWith('Choose terminals'));
|
||||
const conversation = element('conversation') as HTMLSelectElement; conversation.value = reference.conversationRef;
|
||||
await act(async () => conversation.dispatchEvent(new panel!.window.Event('change')));
|
||||
await act(async () => element('targets').click()); await waitFor(() => document.querySelectorAll('input[type="checkbox"]').length === 2);
|
||||
await act(async () => { [...document.querySelectorAll('button')].find((button) => button.textContent === 'Use selected terminals')!.click(); });
|
||||
await waitFor(() => element('status').textContent!.startsWith('Targets selected'));
|
||||
await act(async () => element('open').click()); await waitFor(() => element('status').textContent!.startsWith('Ready'));
|
||||
assert.match(element('transcript').textContent!, /Synthetic global Agent result/, 'owned core history survives runtime restart');
|
||||
prompt.value = 'Synthetic model-only continuation'; await act(async () => { send(); });
|
||||
await waitFor(() => element('status').textContent === 'Complete');
|
||||
assert.equal(counts.requests, before + 1, 'Actions Deny allows a tool-free configured model turn after reopening');
|
||||
assert.deepEqual(workerCalls, ['synthetic-b']);
|
||||
phase = 'backend-revision';
|
||||
const latestChat = [...pluginReplies].reverse().find((value) => value && typeof value === 'object' && 'chatHandle' in value) as { chatHandle: string };
|
||||
const generation = service.agentService.host.generation; const requestsBeforeChange = counts.requests;
|
||||
const { LOCAL_STORAGE_ADAPTER_CHANGED_EVENT } = await import('../infrastructure/persistence/localStorageAdapter');
|
||||
dom.window.localStorage.setItem(keys.STORAGE_KEY_AI_ACTIVE_MODEL, 'changed-synthetic-model');
|
||||
await act(async () => window.dispatchEvent(new CustomEvent(LOCAL_STORAGE_ADAPTER_CHANGED_EVENT, { detail: { key: keys.STORAGE_KEY_AI_ACTIVE_MODEL } })));
|
||||
await waitFor(() => service!.agentService.host.generation > generation && service!.agentService.chats.size === 0);
|
||||
await assert.rejects(service.contributionService.executeCommand('com.netcatty.globalagent.history', { chatHandle: latestChat.chatHandle }));
|
||||
assert.equal(counts.requests, requestsBeforeChange, 'configuration invalidation never retries model inference');
|
||||
assert.doesNotMatch(JSON.stringify(pluginReplies), /FIXTURE_ONLY_API_MARKER|apiKey|externalSessionId|plugin-agent:|opaque-owner|mcpServers|permissionMode/);
|
||||
assert.doesNotMatch(JSON.stringify(trustedReplies), /FIXTURE_ONLY_API_MARKER|apiKey/);
|
||||
assert.deepEqual(errors, []);
|
||||
} finally {
|
||||
console.error = originalError;
|
||||
if (panel) { panel.window.dispatchEvent(new panel.window.Event('pagehide')); panel.window.close(); }
|
||||
await act(async () => root.unmount()); await service?.manager.shutdown();
|
||||
mcp.setPermissionMode('confirm'); mcp.cleanup(); aiBridge.cleanup(); privacy.configure({ enabled: false }); registerAppAppLockRuntime(oldLock);
|
||||
server.closeAllConnections(); await new Promise<void>((resolve) => server.close(() => resolve())); dom.window.close();
|
||||
for (const [key, descriptor] of previous) { if (descriptor) Object.defineProperty(globalThis, key, descriptor); else Reflect.deleteProperty(globalThis, key); }
|
||||
if (oldTmp === undefined) delete process.env.TMPDIR; else process.env.TMPDIR = oldTmp;
|
||||
fs.rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -100,6 +100,21 @@ test('registered Ask uses one in-app dialog; policy controls apply Allow and Den
|
||||
await act(async () => { button('Deny').click(); await displayed; await settle(); });
|
||||
await new Promise<void>((resolve) => dom.window.queueMicrotask(resolve));
|
||||
assert.equal(nativeVisibility.at(-1), true, 'dismissing Ask restores the existing view without closing it');
|
||||
for (const selections of [[], [{ title: 'Synthetic terminal B', scope: 'session', version: 'opaque-worker-revision' }]]) {
|
||||
await act(async () => {
|
||||
displayed = host.request({ requestId: 'agent-empty-args', pluginId: plugin.id, permission: 'ai.agent.actions', resources: ['*'],
|
||||
displayContext: { modelLabel: 'Synthetic configured Catty', action: { capabilityId: 'session.environment', text: '{}', label: 'PLUGIN_SUPPLIED_OPERATION_NAME' }, selections },
|
||||
}); await settle();
|
||||
});
|
||||
const dialog = document.querySelector('[role="dialog"]')!;
|
||||
assert.match(dialog.textContent!, /Operation:.*get_environment/);
|
||||
assert.match(dialog.textContent!, /session\.environment/);
|
||||
assert.equal(dialog.querySelector('pre')!.textContent, '{}');
|
||||
if (selections.length) assert.match(dialog.textContent!, /Targets:.*Synthetic terminal B/);
|
||||
else assert.match(dialog.textContent!, /Targets:.*No targets selected/);
|
||||
assert.doesNotMatch(dialog.textContent!, /opaque-worker-revision|PLUGIN_SUPPLIED_OPERATION_NAME/);
|
||||
await act(async () => { button('Deny').click(); await displayed; await settle(); });
|
||||
}
|
||||
} finally {
|
||||
await act(async () => root.unmount()); stopVisibility(); host.shutdown(); engine.shutdown(); dom.window.close();
|
||||
for (const [key, descriptor] of previous) { if (descriptor) Object.defineProperty(globalThis, key, descriptor); else Reflect.deleteProperty(globalThis, key); }
|
||||
|
||||
@@ -576,6 +576,8 @@ 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}
|
||||
/>
|
||||
|
||||
@@ -64,6 +64,8 @@ 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;
|
||||
@@ -79,6 +81,7 @@ const TextEditorModal: React.FC<TextEditorModalProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
fileName,
|
||||
filePath,
|
||||
initialContent,
|
||||
onSave,
|
||||
editorWordWrap,
|
||||
@@ -94,7 +97,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(fileName));
|
||||
const [languageId, setLanguageId] = useState(() => getLanguageId(filePath ?? fileName));
|
||||
const contentRef = useRef(initialContent);
|
||||
const baselineContentRef = useRef(initialContent);
|
||||
const savingRef = useRef(false);
|
||||
@@ -149,13 +152,13 @@ const TextEditorModal: React.FC<TextEditorModalProps> = ({
|
||||
setBaselineContent(initialContent);
|
||||
setSaveError(null);
|
||||
setSaving(false);
|
||||
setLanguageId(getLanguageId(fileName));
|
||||
setLanguageId(getLanguageId(filePath ?? fileName));
|
||||
contentRef.current = initialContent;
|
||||
baselineContentRef.current = initialContent;
|
||||
savingRef.current = false;
|
||||
closePromptRef.current = null;
|
||||
viewStateRef.current = null;
|
||||
}, [initialContent, fileName]);
|
||||
}, [initialContent, fileName, filePath]);
|
||||
|
||||
const saveContent = useCallback(async (contentToSave = contentRef.current): Promise<boolean> => {
|
||||
return saveCoordinatorRef.current?.save(contentToSave) ?? false;
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
getTextEditorContentStats,
|
||||
isTextEditorCommandWEnabled,
|
||||
isTextEditorReadOnly,
|
||||
languageIdToMonaco,
|
||||
TextEditorPromoteButton,
|
||||
} from "./TextEditorPane.tsx";
|
||||
import { TooltipProvider } from "../ui/tooltip.tsx";
|
||||
@@ -16,6 +17,11 @@ 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);
|
||||
|
||||
@@ -30,6 +30,7 @@ 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';
|
||||
@@ -37,7 +38,7 @@ import { Combobox } from '../ui/combobox';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
|
||||
|
||||
// Map our language IDs to Monaco language IDs
|
||||
const languageIdToMonaco = (langId: string): string => {
|
||||
export const languageIdToMonaco = (langId: string): string => {
|
||||
const mapping: Record<string, string> = {
|
||||
'javascript': 'javascript',
|
||||
'typescript': 'typescript',
|
||||
@@ -73,6 +74,7 @@ const languageIdToMonaco = (langId: string): string => {
|
||||
'yaml': 'yaml',
|
||||
'toml': 'ini',
|
||||
'ini': 'ini',
|
||||
'nginx': 'nginx',
|
||||
'sql': 'sql',
|
||||
'graphql': 'graphql',
|
||||
'markdown': 'markdown',
|
||||
@@ -512,6 +514,7 @@ const TextEditorPaneInner: React.FC<TextEditorPaneProps> = ({
|
||||
language={monacoLanguage}
|
||||
value={content}
|
||||
onChange={handleEditorChange}
|
||||
beforeMount={registerNginxLanguage}
|
||||
onMount={handleEditorMount}
|
||||
theme={customThemeName}
|
||||
loading={
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import React, { useState } from 'react';
|
||||
import type { TerminalSession } from '../../domain/models';
|
||||
import { usePluginAgentHost } from '../../application/state/usePluginAgentHost';
|
||||
import { Button } from '../ui/button';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '../ui/dialog';
|
||||
|
||||
export function PluginAgentHost({ sessions }: { sessions: TerminalSession[] }) {
|
||||
const { choice } = usePluginAgentHost(sessions);
|
||||
const [selected, setSelected] = useState<string[]>([]);
|
||||
return <Dialog open={choice !== null} onOpenChange={(open) => { if (!open) choice?.cancel(); }}>
|
||||
<DialogContent data-host-security-surface="" onOpenAutoFocus={() => setSelected([])}>
|
||||
<DialogHeader><DialogTitle>Choose terminals for this Agent conversation</DialogTitle>
|
||||
<DialogDescription>The conversation is independent of the active tab or workspace. Only the terminals selected here are eligible targets. Tool actions follow this plugin's Allow / Ask / Deny setting.</DialogDescription></DialogHeader>
|
||||
<div className="max-h-72 space-y-2 overflow-y-auto">
|
||||
{choice?.items.map((item) => <label key={item.sessionId} className="flex gap-2 text-sm">
|
||||
<input type="checkbox" checked={selected.includes(item.sessionId)} disabled={selected.length >= 32 && !selected.includes(item.sessionId)}
|
||||
onChange={(event) => setSelected((ids) => event.target.checked ? [...ids, item.sessionId] : ids.filter((id) => id !== item.sessionId))} />
|
||||
<span>{item.label} <span className="text-muted-foreground">{item.hostname}</span></span>
|
||||
</label>)}
|
||||
{choice?.items.length === 0 && <p className="text-sm text-muted-foreground">No connected terminals. You can continue without targets.</p>}
|
||||
</div>
|
||||
<DialogFooter><Button variant="outline" onClick={() => choice?.cancel()}>Cancel</Button>
|
||||
<Button onClick={() => choice?.confirm(selected)}>Use selected terminals</Button></DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
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>;
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { describePluginPermission } from '@/domain/pluginPermissionDescriptions.cjs';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '../ui/dialog';
|
||||
import { Button } from '../ui/button';
|
||||
import globalAgentToolSpecs from '../../infrastructure/ai/harness/generated/globalAgentToolSpecs.json';
|
||||
|
||||
/** Rendered only by the trusted app, never inside a plugin view. */
|
||||
export function PluginPermissionHost() {
|
||||
@@ -12,6 +13,9 @@ export function PluginPermissionHost() {
|
||||
if (!prompt) return null;
|
||||
const description = describePluginPermission(prompt.permission, resolvedLocale);
|
||||
const resources = prompt.resources.filter((resource) => resource !== '*' && !resource.startsWith('selected:'));
|
||||
const action = prompt.displayContext?.action;
|
||||
const operation = action && (globalAgentToolSpecs.find((tool) => tool.capabilityId === action.capabilityId)?.toolName ?? action.capabilityId);
|
||||
const selections = prompt.displayContext?.selections ?? [];
|
||||
return <Dialog open onOpenChange={(open) => { if (!open) void respond('cancel'); }}>
|
||||
<DialogContent data-host-security-surface="" className="max-h-[85vh] overflow-y-auto sm:max-w-[480px]">
|
||||
<DialogHeader>
|
||||
@@ -20,9 +24,17 @@ export function PluginPermissionHost() {
|
||||
</DialogHeader>
|
||||
<p className="text-sm">{description.description}</p>
|
||||
{prompt.displayContext?.modelLabel && <p className="text-sm break-words">{zh ? '模型' : 'Model'}: {prompt.displayContext.modelLabel}</p>}
|
||||
{(prompt.displayContext?.selections ?? []).map((item, index) => <p key={index} className="text-sm break-words">
|
||||
{item.title} <span className="text-xs text-muted-foreground">· {item.scope}</span>
|
||||
</p>)}
|
||||
{action && <div className="space-y-1">
|
||||
<p className="text-sm break-words">{zh ? '操作' : 'Operation'}: <code>{operation}</code>{operation !== action.capabilityId && <> ({action.capabilityId})</>}</p>
|
||||
<p className="text-sm">{zh ? '请求内容(控制字符以转义形式显示)' : 'Requested action (control characters shown as escapes)'}</p>
|
||||
<pre className="max-h-48 overflow-auto whitespace-pre-wrap break-words rounded border border-border p-2 text-xs">{action.text}</pre>
|
||||
</div>}
|
||||
{(selections.length > 0 || prompt.permission === 'ai.agent.actions') && <div className="space-y-1">
|
||||
<p className="text-sm">{zh ? '目标' : 'Targets'}:{selections.length === 0 && (zh ? ' 未选择目标' : ' No targets selected')}</p>
|
||||
{selections.map((item, index) => <p key={index} className="text-sm break-words">
|
||||
{item.title} <span className="text-xs text-muted-foreground">· {item.scope}</span>
|
||||
</p>)}
|
||||
</div>}
|
||||
{(prompt.relatedPermissions ?? []).filter((item, index, items) => items.findIndex((other) => other.permission === item.permission) === index).map((item, index) => <div key={index} className="space-y-1 text-sm">
|
||||
<p>{describePluginPermission(item.permission, resolvedLocale).description}</p>
|
||||
<p className="text-xs text-muted-foreground break-all">{item.resources.filter((resource) => !resource.startsWith('selected:')).join(', ')}</p>
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
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, /<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,7 +32,9 @@ 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/);
|
||||
assert.ok(alphaCard.findAllByType('p').some((node) => node.children.includes('Example')), 'author has its own line');
|
||||
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.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/);
|
||||
|
||||
@@ -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,6 +4,7 @@ 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';
|
||||
@@ -82,7 +83,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>
|
||||
<p className="break-words text-xs text-muted-foreground">{item.author}</p>
|
||||
<PluginAuthorRow entry={item} sourceId={sourceId} />
|
||||
<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>
|
||||
@@ -91,7 +92,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><p className="text-xs text-muted-foreground">{entry.author} · {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><PluginAuthorRow entry={entry} sourceId={sourceId} /><p className="text-xs text-muted-foreground">{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,6 +3,7 @@ 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';
|
||||
@@ -103,7 +104,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 && <p className="break-words text-xs text-muted-foreground">{plugin.manifest.publisher}</p>}
|
||||
{plugin.manifest?.publisher && <PluginAuthorRow plugin={plugin} />}
|
||||
<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,6 +6,7 @@ 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';
|
||||
|
||||
@@ -72,7 +73,8 @@ 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={<>
|
||||
<span className="block break-words">{[plugin.manifest?.publisher, sourceOf(plugin), statusOf(plugin)].filter(Boolean).join(' · ')}</span>
|
||||
{plugin.manifest?.publisher && <PluginAuthorRow plugin={plugin} />}
|
||||
<span className="block break-words">{[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>}
|
||||
</>}>
|
||||
|
||||
@@ -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 } from "../../../lib/utils";
|
||||
import { cn, isMacPlatform } from "../../../lib/utils";
|
||||
import { SectionHeader, SettingsAnchor, SettingsTabContent, SettingRow, Toggle, Select } from "../settings-ui";
|
||||
import { FontSelect } from "../FontSelect";
|
||||
import { TerminalFontSelect } from "../TerminalFontSelect";
|
||||
@@ -69,6 +69,8 @@ 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;
|
||||
}) {
|
||||
@@ -140,10 +142,13 @@ 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 },
|
||||
@@ -315,6 +320,19 @@ 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")} />
|
||||
|
||||
@@ -264,6 +264,7 @@ export const SftpOverlays: React.FC<SftpOverlaysProps> = React.memo(({
|
||||
open={showTextEditor}
|
||||
onClose={closeTextEditor}
|
||||
fileName={textEditorFileName}
|
||||
filePath={textEditorTarget?.fullPath}
|
||||
initialContent={textEditorContent}
|
||||
onSave={handleSaveTextFile}
|
||||
editorWordWrap={editorWordWrap}
|
||||
|
||||
@@ -248,7 +248,7 @@ export const useSftpViewFileOps = ({
|
||||
hostId: target.hostId,
|
||||
remotePath: target.fullPath,
|
||||
fileName: target.file.name,
|
||||
languageId: snapshot.languageId || getLanguageId(target.file.name),
|
||||
languageId: snapshot.languageId || getLanguageId(target.fullPath),
|
||||
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.file.name),
|
||||
languageId: snapshot.languageId || getLanguageId(target.fullPath),
|
||||
content: snapshot.content,
|
||||
baselineContent: snapshot.baselineContent,
|
||||
wordWrap: snapshot.wordWrap,
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
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,5 +1,9 @@
|
||||
# Unified plugin acceptance candidate
|
||||
|
||||
This file records the frozen 677 native baseline. For the subsequent permission
|
||||
policy and AI candidate, use [the new integration record](policy-ai-integration.md)
|
||||
and its package manifest. Historical consent flows below are not the revised UX.
|
||||
|
||||
The dedicated branch is `integration/plugin-native-acceptance`. Its Draft PR
|
||||
records the exact current head and CI runs. Native AI, database, Host/Vault, and
|
||||
settings/logging checks use this candidate rather than separate feature trees.
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
# Plugin model transport cold start
|
||||
|
||||
Commit `092438803bed96753bfe68cb78da9ca1137dee25` removes an independent startup
|
||||
dependency on the internal AI sidebar. It does not establish the cause or outcome
|
||||
of the original native failure; that still requires an authorized native retest.
|
||||
|
||||
## Reproduced boundary
|
||||
|
||||
At native candidate `677aac33b1eec9dbd4a367417a49c964cdea63e7`, the plugin model
|
||||
selector reads the saved host provider, while main's provider registry starts
|
||||
empty. The existing sidebar synchronizes that registry when visible or sending.
|
||||
The plugin startup path did not. A nondefault configured loopback port can
|
||||
therefore fail main's provider allowlist before HTTP transport starts.
|
||||
|
||||
The revised `components/PluginAIContextHost.test.tsx` installs the browser sample,
|
||||
mounts the trusted host component, uses the real application preload, and routes
|
||||
model requests through `registerProviderHandlers` and its actual allowlist. It
|
||||
does not mount the internal sidebar or manually alter the allowlist. Credential
|
||||
injection uses synthetic fixture markers; the HTTP edge and deterministic local
|
||||
fixture use the existing transport. No paid service, real key, Note, or host is
|
||||
used. This remains DOM/in-process evidence, not native sandbox acceptance.
|
||||
|
||||
Failed-before evidence is `/tmp/netcatty-ai-cold-provider-before-final.log`:
|
||||
|
||||
```text
|
||||
ok: false
|
||||
allowlistRejected: true
|
||||
statusCode: undefined
|
||||
```
|
||||
|
||||
The corrected regression passes and preserves both the selected fixture provider
|
||||
and a second host-configured synthetic provider. It checks that provider markers
|
||||
and configuration do not enter plugin-facing model/context envelopes. Expected
|
||||
SDK error output is suppressed during this diagnostic so request payloads and
|
||||
headers are not logged.
|
||||
|
||||
## Correction
|
||||
|
||||
Before publishing a selected model handle, the trusted renderer synchronizes the
|
||||
current host-owned provider settings through the existing `aiSyncProviders`
|
||||
application API. It checks that the selected model revision matches the snapshot
|
||||
being synchronized. The runtime rechecks activity, cancellation, model revision,
|
||||
and pending-selection ownership after the await. Pending selections count toward
|
||||
the model-handle cap; release and shutdown invalidate late results.
|
||||
|
||||
Plugin requests supply no URL, credential, or provider configuration. The existing
|
||||
main sender validation, endpoint allowlist, source binding, and model invocation
|
||||
budgets are unchanged. No new schema feature, plugin permission, or consent
|
||||
dialog is added. The assistant package is unchanged and retains SHA-256
|
||||
`c7d784d5c3bd2feb463afd58eae3a9594615395b168174cb8a3d0677584ae395`.
|
||||
|
||||
Production integration touches `configuredModel.ts`, `trustedHostRuntime.ts`, and
|
||||
`usePluginAIContextHost.ts`. The hook's import and `prepareModel` option may
|
||||
overlap host-selection work; preserve both when integrating the separate policy
|
||||
task. Permission-engine, consent-dialog, and sample-UI changes are outside this
|
||||
correction.
|
||||
|
||||
## Native and policy status
|
||||
|
||||
The native open/close sidebar comparison was incomplete. Execution auto-review
|
||||
denied the Application-scope `context.selected.list` grant; that action did not
|
||||
execute. No post-sidebar inference was performed, the prompt expired, and the
|
||||
sample was disabled while the worker cleaned up. This is not native confirmation
|
||||
of the startup cause. The original `677aac33` app remains frozen for comparison;
|
||||
this fix does not authorize retrying the denied execution action.
|
||||
|
||||
In the earlier permission engine, **Application** grants are memory-only for the
|
||||
application lifetime; **Always** grants are persisted. An explicitly authorized
|
||||
Application grant can fit bounded temporary QA. A one-use prompt on every stream
|
||||
read is not an unavoidable target UX. The separate policy task implements the
|
||||
new direction: default Allow for declared supported capabilities, per-plugin
|
||||
Allow/Ask/Deny settings, audit, and in-app Ask dialogs. That product change is
|
||||
separate from execution-tool authorization and must not be used to evade a tool
|
||||
denial. This transport correction does not redesign that policy.
|
||||
|
||||
Native outcome, independent verification, and the sample's light-theme appearance
|
||||
follow-up remain separate acceptance work. No GUI launch, sandbox bypass, paid
|
||||
model invocation, main merge, or release was performed for this correction.
|
||||
|
||||
## Node 22 CI observer correction
|
||||
|
||||
The new cold-start test failed on exact head `9ca70f329` in test run
|
||||
`40436198585`, Linux job `121965743101`: 15,206 passed, 1 failed, 50 skipped.
|
||||
The sole failure was the empty SDK/renderer-error assertion receiving one
|
||||
`SDK_OR_RENDERER_ERROR` marker. This red result is retained as evidence; it is
|
||||
not an unrelated baseline failure.
|
||||
|
||||
The CI runtime was Node `22.23.3`; local validation had used Node `24.19.0`.
|
||||
Reproduction on the exact CI version identified the safe category
|
||||
`setup:node-sqlite-experimental`, before any model operation. The existing
|
||||
`node:sqlite` import schedules its ExperimentalWarning on the next tick via
|
||||
`console.error`. The newly added observer was installed before that warning
|
||||
drained. The same test passed on Node 24, which did not emit that warning.
|
||||
|
||||
The test now lets pending module warnings reach normal stderr before installing
|
||||
the SDK/renderer observer. The warning remains visible, and the zero-runtime-error
|
||||
assertion is unchanged. Safe phase/error categories make future failures
|
||||
diagnosable without request payloads or headers. No production behavior, timeout,
|
||||
permission, or cancellation assertion changed. Both Node versions pass the
|
||||
focused test; the Node 22 parallel fixture/UI/provider/preload group passes all
|
||||
16 tests with concurrency 4.
|
||||
|
||||
Diagnostic evidence: `/tmp/netcatty-ai-cold-ci-node22-sqlite-category.log` fails
|
||||
before correction, `/tmp/netcatty-ai-cold-ci-node22-after.log` passes with the
|
||||
SQLite warning still visible, and `/tmp/netcatty-ai-cold-ci-node22-parallel.log`
|
||||
records the parallel check. The appearance-only sample change is independently
|
||||
published as `e325742abf9fd329f1f7858eda66367e7a62357c`; its package hash is
|
||||
`7943d4c3800914fe0acd3d0f3a3d7f7c1910c212a5dc58a044871a6a82485274`.
|
||||
@@ -0,0 +1,125 @@
|
||||
# Managed-window Ask acceptance candidate
|
||||
|
||||
The dedicated branch is `integration/plugin-ask-routing-acceptance`. Its Draft
|
||||
PR records the exact frozen integration head, CI and private renderer artifact
|
||||
handoff. It starts from frozen #4001 at
|
||||
`02049102b6732778adcd0f33b0a0ade6b4752327`; the old source checkout, QA workspace,
|
||||
profile and renderer artifact remain unchanged.
|
||||
|
||||
## Exact inputs and ownership
|
||||
|
||||
The only new input is policy owner's reviewed Ask correction
|
||||
`89beeb93749c751f255beb6d0e0f95d23bdd9a7b`, imported by ordinary merge
|
||||
`1b21c41fa26c0173084c7ae75e640ef3274285dd`. There were no merge conflicts or
|
||||
manual production edits. These seven production files exactly match that input:
|
||||
|
||||
| File | Owned production delta |
|
||||
| --- | --- |
|
||||
| `electron/bridges/windowManager.cjs` | Enumerate managed app windows, including Settings |
|
||||
| `electron/main/registerBridges.cjs` | Supply the managed permission-window getter |
|
||||
| `electron/plugins/hostService.cjs` | Pass that getter to the existing decision host |
|
||||
| `electron/plugins/permissionDecisionHost.cjs` | Bind ready/focused/visible/unlocked managed renderer selection and request lifecycle to the chosen receiver |
|
||||
| `electron/plugins/pluginBridge.cjs` | Guard permission readiness/replies by managed app window and main frame; reject plugin/foreign/subframe senders |
|
||||
| `components/SettingsPage.tsx` | Mount the existing permission host for standalone Settings |
|
||||
| `components/plugins/PluginPermissionHost.tsx` | Mark Ask with the existing host-security surface so attached native views yield |
|
||||
|
||||
The input also owns the Settings-installed-entrypoint regression, renderer test
|
||||
updates and policy/security documentation. AI d33 startup wiring, sample assets,
|
||||
the combined cold-start assertions and all earlier policy/transport history are
|
||||
preserved from 020. `ModelSelector.tsx` is unchanged from 020; the separate
|
||||
model-selector failure-loop repair is not imported. Diagnostic #3999 remains
|
||||
excluded. No broker resource/source rules, stream restrictions, package/lock
|
||||
inputs, native dependencies or sandbox configuration change in this integration.
|
||||
|
||||
## Frozen 020 native evidence
|
||||
|
||||
The parent closed that QA session and released the GUI. Its partial outcome is:
|
||||
|
||||
| Native case on 020 | Result |
|
||||
| --- | --- |
|
||||
| Default Allow cold Send and completion | Passed |
|
||||
| Stop, AI Deny, restore without automatic replay | Passed |
|
||||
| Themes in new views | Passed |
|
||||
| Ask visible dialog | Failed: no visible dialog, timeout, zero new model requests |
|
||||
| Ask acceptance/cancel and content-read matrix | Not run; remain pending |
|
||||
|
||||
The input's independent source-only delta review is scoped clean. Its installed
|
||||
SDK/registered IPC test reproduces the old Settings-owned failure and passes with
|
||||
the correction using represented Electron objects. That is not native Ask
|
||||
success. No new native acceptance is claimed by this coding executor.
|
||||
|
||||
Earlier terminal solo/split core passes with corrected fixtures, unreliable/
|
||||
unverified Focus and one QA Enter in A during a palette attempt remain historical
|
||||
evidence. There is no global zero-input or full terminal native-pass claim. The
|
||||
old Rfam run failed socket transport before connect resolved, without reaching
|
||||
authentication/SELECT; its cause and auth/TLS compatibility remain unverified.
|
||||
The next native priority is the new Ask path, not more old-baseline loops.
|
||||
|
||||
## Checks and retained CI history
|
||||
|
||||
| Local check | Result |
|
||||
| --- | --- |
|
||||
| Registered runtime | 696 tests: 695 passed, one native skip, zero failed/cancelled |
|
||||
| Contract/SDK/CLI | 91 passed |
|
||||
| Offline examples | Five passed, one Docker opt-in skip |
|
||||
| Mounted cold-start AI/permission/selected-summary/Host group | 21 passed |
|
||||
| Settings and security-surface visibility group | 37 passed |
|
||||
| Contract/capability/Codex generation and drift | Clean |
|
||||
| Lint | Zero errors, seven existing warnings |
|
||||
| Five sample archives | Validated; two builds each are identical and match frozen 020 |
|
||||
|
||||
The exact integration production build, renderer inventory and final CI are
|
||||
recorded in the Draft PR and artifact provenance. Local native packaging and
|
||||
unpackaged GUI acceptance are not claimed. This environment uses locked
|
||||
dependencies and standard patches; the official pinned Electron binary uses its
|
||||
documented cache/proxy support. No header workaround, native shim, HOME change
|
||||
or sandbox change is introduced.
|
||||
|
||||
Input89 [test CI 40453446775](https://github.com/binaricat/Netcatty/actions/runs/40453446775)
|
||||
succeeded on the parent's single infrastructure-only rerun: 15,342 passed, 50
|
||||
skipped, zero failed/cancelled. The original runner interruption remains evidence;
|
||||
this worker did not duplicate its retry. Input89
|
||||
[four-platform packages](https://github.com/binaricat/Netcatty/actions/runs/40453446781)
|
||||
passed with release jobs skipped. Frozen 020's successful full/platform CI and
|
||||
all earlier diagnosed/unexplained failures remain separate predecessor evidence.
|
||||
|
||||
The [sample manifest](ask-routing-packages.json) records exact IDs, versions,
|
||||
features, permissions/resources and archive/content hashes. All five archives
|
||||
are byte-identical to 020, including Assistant
|
||||
`7943d4c3800914fe0acd3d0f3a3d7f7c1910c212a5dc58a044871a6a82485274`.
|
||||
No archive was activated by this executor. Its unchanged owner README retains
|
||||
old review wording; the current Allow/Ask/Deny procedure governs acceptance.
|
||||
|
||||
## Production-renderer preparation and native Ask recipe
|
||||
|
||||
1. Create a new isolated checkout at this Draft's exact head and a separate
|
||||
disposable synthetic QA profile. Keep the old 020 workspace/profile intact.
|
||||
2. Match `package.json` and `package-lock.json` hashes to the renderer artifact's
|
||||
provenance and the independently normally rebuilt native dependencies. They
|
||||
are unchanged from 020. Prepare matching host workspace packages normally.
|
||||
**The complete backend checkout must match the new head:** the frontend-only
|
||||
bundle does not include the new CJS window-routing code or native binaries.
|
||||
3. Materialize the exact private Library file/version in the consumer's own
|
||||
environment. Verify its archive SHA-256 and every dist file with the supplied
|
||||
inventory/verification script. Producer-local paths are not portable.
|
||||
4. Use those verified production assets as a whole `dist/` in the matching
|
||||
checkout, with the ordinary Electron entrypoint, no Vite environment and
|
||||
normal sandbox/helper settings. This is native **unpackaged production-
|
||||
renderer QA**, not packaged-binary certification. Process-loss causality
|
||||
remains unclassified; no native build/header workaround is supplied.
|
||||
5. Recreate only the synthetic provider/Note/sample setup. Verify Default Allow
|
||||
remains functional; then set the plugin operation to Ask in the active main
|
||||
or standalone Settings window. Keep the selected receiver visible, focused,
|
||||
ready and unlocked; opening Ask must not force focus back to main.
|
||||
6. Record visible readable Ask and native-view yielding. Check cancellation and
|
||||
acceptance as separate explicit operations: cancel must send no model request;
|
||||
accept must authorize only the current bounded operation. Complete the
|
||||
content-read Allow/Ask/Deny matrix and lifecycle invalidation cases. Do not
|
||||
treat the earlier zero-request timeout as an Ask pass.
|
||||
7. Record exact head, archive/sample hashes, request counters, prompt ownership,
|
||||
screenshots/video and any unrun cases. Report a model-selector failure
|
||||
separately rather than folding its other owner's repair into this candidate.
|
||||
|
||||
Hosting/distribution/deployment and remaining DB/terminal acceptance gates stay
|
||||
parent-coordinated. No main merge, Ready transition or release is part of this
|
||||
handoff.
|
||||
@@ -0,0 +1,197 @@
|
||||
[
|
||||
{
|
||||
"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": "89beeb93749c751f255beb6d0e0f95d23bdd9a7b",
|
||||
"rebuiltAgainstAI": "d33c0193ccaedc1a10aed9da495f040250121bef",
|
||||
"previous020Sha256": "7943d4c3800914fe0acd3d0f3a3d7f7c1910c212a5dc58a044871a6a82485274",
|
||||
"changedEntriesFrom020": []
|
||||
},
|
||||
{
|
||||
"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": "89beeb93749c751f255beb6d0e0f95d23bdd9a7b",
|
||||
"rebuiltAgainstAI": "d33c0193ccaedc1a10aed9da495f040250121bef",
|
||||
"previous020Sha256": "f01c6a24382897d4b0bf4130941274abacb9a9a963f9b56ecaceb2c020f36874",
|
||||
"changedEntriesFrom020": []
|
||||
},
|
||||
{
|
||||
"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": "89beeb93749c751f255beb6d0e0f95d23bdd9a7b",
|
||||
"rebuiltAgainstAI": "d33c0193ccaedc1a10aed9da495f040250121bef",
|
||||
"previous020Sha256": "aaca586669db454e91d61487e576ed524d1b7190eed182bc2cd0035e59caa0d3",
|
||||
"changedEntriesFrom020": []
|
||||
},
|
||||
{
|
||||
"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": "89beeb93749c751f255beb6d0e0f95d23bdd9a7b",
|
||||
"rebuiltAgainstAI": "d33c0193ccaedc1a10aed9da495f040250121bef",
|
||||
"previous020Sha256": "cf04f82dfe25ab5cda6f5f3dad0a8fb06c37bedf9924f556bee2b8402e542ef2",
|
||||
"changedEntriesFrom020": []
|
||||
},
|
||||
{
|
||||
"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": "89beeb93749c751f255beb6d0e0f95d23bdd9a7b",
|
||||
"rebuiltAgainstAI": "d33c0193ccaedc1a10aed9da495f040250121bef",
|
||||
"previous020Sha256": "4c9c2388b7f891156192a0b5ab7b23291beba3555e7ec8cf24fbe2dfe1242d5e",
|
||||
"changedEntriesFrom020": []
|
||||
}
|
||||
]
|
||||
@@ -1,5 +1,10 @@
|
||||
# Test the selected Note assistant on an isolated desktop
|
||||
|
||||
For the permission-policy/AI acceptance candidate, first use
|
||||
[its integration record](policy-ai-integration.md) and exact package manifest.
|
||||
The older permission-dialog sequence below is historical baseline evidence;
|
||||
apply the current Allow/Ask/Deny semantics when testing the new candidate.
|
||||
|
||||
This recipe tests a sandbox plugin against a deterministic loopback provider.
|
||||
The provider does not run a model or contact a paid service. The profile contains
|
||||
only synthetic data. The parent owns native screenshots and video on the working
|
||||
@@ -29,9 +34,9 @@ browser runtime entry. It has no Node entry, networking, filesystem, secrets,
|
||||
storage, terminal, or command-execution permission. Its `commands` permission
|
||||
registers its own view callbacks. It cannot execute terminal commands.
|
||||
The six-file artifact at this checkpoint has SHA-256
|
||||
`c7d784d5c3bd2feb463afd58eae3a9594615395b168174cb8a3d0677584ae395`.
|
||||
This replaces the earlier `c0f73369...` artifact after the selected-Note expiry
|
||||
recovery repair.
|
||||
`7943d4c3800914fe0acd3d0f3a3d7f7c1910c212a5dc58a044871a6a82485274`.
|
||||
This replaces the earlier `c7d784d5...` artifact with the sample-only appearance
|
||||
update. The host provider cold-start correction remains a separate app change.
|
||||
|
||||
## Start the deterministic fixture
|
||||
|
||||
@@ -119,6 +124,11 @@ Synthetic retry plan: add a retry button and show a connection status label.
|
||||
in the dropdown, and click Send yourself. Deny the fresh content permission
|
||||
once and cancel text review once before allowing a successful fixture run.
|
||||
The draft must survive all steps, and no request may be silently retried.
|
||||
14. With the sample idle, switch the host between light and dark and change its
|
||||
accent. The page, controls, muted surfaces, text, focus ring, and Send button
|
||||
must follow the existing view environment tokens without model requests or
|
||||
new permission prompts. Capture both appearances on the same host window.
|
||||
This check does not change or bypass the separately owned permission policy.
|
||||
|
||||
The expiry regression in `scripts/plugin-assistant-panel.test.cjs` controls the
|
||||
panel and actual host service/selected-context authority clocks and advances past
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
# 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.
|
||||
@@ -450,6 +450,13 @@ 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
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,177 @@
|
||||
[
|
||||
{
|
||||
"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
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,92 @@
|
||||
# Global Agent continuation: bounded QA recipe
|
||||
|
||||
This is a separate increment after the plugin platform foundation. The transport
|
||||
review checkpoint is #4012 at `ea619555`; its isolated correction is `63f0572e0`.
|
||||
The runtime continuation carries the correction through an ordinary feature-branch
|
||||
merge. It is not merged to main or accepted for release.
|
||||
|
||||
## Reproduce the installable sample
|
||||
|
||||
From the exact runtime head reported in the handoff:
|
||||
|
||||
```sh
|
||||
npm ci
|
||||
npm run build:plugin-runtime-deps
|
||||
node examples/plugins/global-agent/build.mjs
|
||||
node packages/plugin-cli/dist/cli.js pack examples/plugins/global-agent/.package --out /tmp/netcatty-global-agent.ncpkg
|
||||
sha256sum /tmp/netcatty-global-agent.ncpkg
|
||||
```
|
||||
|
||||
The reproducible sample SHA-256 is
|
||||
`f0edf43523845847d5dbb74b0c4d8cd3bfe8e0f89ca193f28140e6b47dc45ed0`.
|
||||
Two independent package builds matched. Build scripts and tests are excluded
|
||||
from the six-file installed package.
|
||||
|
||||
The sample reserves transcript space inside the actual 798-by-548-pixel plugin
|
||||
viewport measured during native QA. In a scheduled native test environment, run
|
||||
`npm run test:plugin-global-agent-layout` for the bounded render regression. It
|
||||
loads the real sample HTML/CSS with synthetic history, including the fixture
|
||||
notice, and checks transcript scrolling, reachable configuration, and visible
|
||||
mouse Stop. It does not load the plugin script or start an Agent/provider. This
|
||||
render check remains pending where the normal browser sandbox cannot start;
|
||||
do not disable the sandbox to run it. Also verify the installed sample visually
|
||||
at the 1188-by-848-pixel app window before accepting the viewport correction.
|
||||
|
||||
For the modal-history lifecycle regression, stop the synthetic stream, close
|
||||
the modal with the host X, and reopen it without disabling/restarting the
|
||||
plugin or waiting for five-minute lease expiry. Select the owned conversation,
|
||||
confirm fresh empty targets, and Open. History must load without another model
|
||||
request. Repeat with a new conversation. The sample command runtime retains
|
||||
only its last host-issued chat handle and releases that exact lease on explicit
|
||||
Open; it does not change core expiry, source ownership or duplicate-open guards.
|
||||
|
||||
## Native fixture acceptance (parent-scheduled GUI only)
|
||||
|
||||
1. Use an isolated synthetic profile, no real Notes, hosts, histories or keys.
|
||||
Start `node scripts/plugin-global-agent-provider-fixture.mjs --port 9218`.
|
||||
It performs no inference or outbound request and logs no bodies/headers.
|
||||
2. Through normal host settings, configure an OpenAI-compatible Chat Completions
|
||||
provider at `http://127.0.0.1:9218/v1`, model `global-agent-fixture`, with the
|
||||
fixture-only literal `FIXTURE_ONLY_API_MARKER`. Do not use real credentials,
|
||||
paid services, allowlist edits, debugger injection or the builtin AI sidebar.
|
||||
3. Install the `.ncpkg` in Settings → Plugins and open its contributed Global
|
||||
Agent Chat view. Verify light/dark/accent updates using existing theme tokens.
|
||||
4. Choose Catty. Choose no targets, or only owned synthetic terminal fixtures,
|
||||
then Open conversation. The fixture's default tool is `get_environment`; it
|
||||
never requests a terminal command. Record Send → streamed result/usage and
|
||||
`GET /fixture/status` counters. This proves a deterministic pipeline, not
|
||||
real model inference.
|
||||
5. Send `fixture:wait`, wait for output, press Stop. Verify aborted count rises
|
||||
once and no late text appears. Capture screenshot/video with fixture labels.
|
||||
6. Exercise Actions Ask using the existing in-app policy dialog: approve and
|
||||
deny the scoped environment tool separately. Actions Deny permits model-only
|
||||
chat with no tools. Agent-use Deny prevents dispatch. A policy change revokes
|
||||
existing handles; reselect/reopen explicitly before another Send.
|
||||
7. Close/reopen and disable/re-enable only this synthetic plugin. Reselect its
|
||||
targets and reopen its owned conversation. History should survive, while the
|
||||
private per-open actor ID is fresh. At five-minute expiry, the sample asks for
|
||||
explicit reselection and keeps the draft, without resending or renewing scope.
|
||||
8. Revoke/disable or lock while streaming and verify cleanup/no late events.
|
||||
Disconnect/re-own a selected synthetic worker during selection and a turn;
|
||||
stale selections must fail instead of binding a replacement connection.
|
||||
|
||||
Do not activate a configured real Claude backend in this fixture recipe. Claude
|
||||
MCP's code/handler boundary is covered with synthetic tests; authenticated service
|
||||
and genuine CLI acceptance remain unverified and require separately approved
|
||||
access. Other backends are explicitly unsupported/unavailable. No background
|
||||
service, blanket OS/Vault access, merge or release is implied.
|
||||
|
||||
## Evidence and limits
|
||||
|
||||
- Installed Catty path uses actual `AgentRuntime`, driver, main provider admission,
|
||||
source policy and worker dispatcher, with JSDOM/in-process plugin transport and
|
||||
synthetic workers. It proves selected B dispatch, stream, Stop, revocation and
|
||||
history after runtime restart. This is not a native BrowserWindow sandbox test.
|
||||
- Main/core tests cover source/session/resource substitution, scope expiry,
|
||||
approval waits, worker lifecycle, cancellation, late completion, stable source
|
||||
budgets, source-isolated history and no key serialization. Filesystem API reads
|
||||
cannot access the core history root. Explicitly granted terminal commands still
|
||||
have that terminal's native OS authority.
|
||||
- No GUI ran in this executor. Native screenshots/video and independent runtime
|
||||
review remain parent-owned gates. The exact-head CI result must be reported
|
||||
separately from focused local evidence.
|
||||
@@ -0,0 +1,309 @@
|
||||
# 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
|
||||
|
||||
### Independent transport correction
|
||||
|
||||
The exact `ea619555` internal-handler reproductions fail all four targeted cases:
|
||||
both no-session and empty-selection environment reads serialize global tool hints;
|
||||
stale chat-wide cleanup cancels replacement B; and caller-supplied builtin chat
|
||||
text can downgrade cancellation of a main-owned delegated request. The outer
|
||||
sender gate still accepts only the current trusted main window. These are trusted
|
||||
application lifecycle defects, not arbitrary plugin IPC exposure or installed
|
||||
runtime exploit evidence.
|
||||
|
||||
Cancellation now resolves ownership from the main request map, rejects a
|
||||
conflicting caller chat, checks the recorded sender and current turn lease, and
|
||||
cancels only that native request. The legacy chat-wide cleanup IPC is unavailable
|
||||
for reserved delegated conversations; main-owned exact-turn release remains the
|
||||
cleanup authority. All delegated environment return paths omit global tool hints.
|
||||
|
||||
An additional Node 22 regression is independently reproduced: inline arrow
|
||||
properties at the beginning of `module.exports` prevent namespace detection of
|
||||
existing exports such as `init`, breaking the real Catty attachment harness.
|
||||
Identifier exports preserve that existing interface. Correction tests pass 94 on
|
||||
Node 22.23.3, including the actual Catty attachment consumer; focused lint passes.
|
||||
Failed-before logs are `/tmp/netcatty-global-agent-transport-review-four-failed-before.log`
|
||||
and `/tmp/netcatty-global-agent-ea619-namespace-before.log`. The transport PR's
|
||||
review checkpoint remains `ea619555` until the parent integrates/rechecks this
|
||||
separate correction. No native/backend/full-CI acceptance is implied.
|
||||
|
||||
### Stage 2A: trusted native dispatch boundary
|
||||
|
||||
The independent bounded recheck of foundation `ba4d3fc` passed all 20 reviewer
|
||||
cases. This resolves its reported helper findings; it does not establish full CI,
|
||||
runtime, backend or native acceptance.
|
||||
|
||||
The additive transport slice gives managed subprocesses a separate per-turn MCP
|
||||
credential, permanently binds their TCP connection to that turn, filters their
|
||||
tool list, and routes actual tool execution through the source-bound host policy
|
||||
adapter. Catty's trusted-app IPC enters the same dispatcher. Branded approval
|
||||
contexts skip the legacy Agent approval only after that host adapter grants the
|
||||
action; process-global permission mode is unchanged. Selected environment reads
|
||||
return the scoped session metadata without querying global tunnel inventory.
|
||||
|
||||
Managed Claude is restricted to its existing MCP mode with builtin tools disabled,
|
||||
strict MCP configuration and no inherited settings sources. The main native entry
|
||||
rejects unverified backends and Skills mode for reserved delegated conversations.
|
||||
Each model stream and native SDK request carries a main-owned turn tag; releasing
|
||||
that lease aborts its HTTP/native work. Old credentials, cancellation requests and
|
||||
disposers cannot acquire or cancel the next turn.
|
||||
|
||||
The focused boundary suite passes 116 cases, including an actual local TCP/main
|
||||
handler with synthetic workers, plus two new actual-HTTP/renderer-transport lease
|
||||
tests. These are deterministic transport tests, not real model inference or
|
||||
authenticated Claude service/CLI acceptance. Public runtime registration, core
|
||||
conversation integration and the custom chat sample are still separate work in
|
||||
progress; this transport slice alone is not a usable plugin Agent feature.
|
||||
|
||||
At exact `ba4d3fc`, package CI `40464058910` passed; test workflow `40464058908`
|
||||
is red. Linux job `122055141575` failed a real assertion in
|
||||
`PluginManagementDialog.interaction.test.tsx:71`: the external view handoff had
|
||||
not run when asserted. That stage reports 34 passed / 1 failed; earlier runtime
|
||||
and contract groups passed 720 and 5. Both Windows jobs passed. No blind rerun was
|
||||
requested. This unresolved assertion must not be classified as runner shutdown.
|
||||
|
||||
The wider initial local transport run reports 697 passed / 10 failed / 3 skipped.
|
||||
All ten failures are Cursor discovery expectations after `npm ci --ignore-scripts`
|
||||
left SQLite's native binding absent, making the bundled Cursor SDK import fail.
|
||||
The standard rebuild first failed using unwritable default caches; rebuilding
|
||||
with task-local npm/node-gyp cache paths succeeded. The failed totals are retained
|
||||
in `/tmp/netcatty-global-agent-transport-expanded.log`; the after-setup run is
|
||||
recorded separately. No GUI or provider credentials were used.
|
||||
|
||||
After the dependency repair, the expanded transport/native-handler/preload/SDK
|
||||
suite passes 709 tests, 0 failures, 3 skips. Log:
|
||||
`/tmp/netcatty-global-agent-transport-expanded-after-setup.log`. Focused lint passes.
|
||||
|
||||
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
|
||||
|
||||
### Separate runtime/sample implementation checkpoint
|
||||
|
||||
The runtime continuation wires the nine `context.agent` methods through the
|
||||
registered default host, dedicated trusted-app preload/IPC and `PluginAgentHost`
|
||||
mounted in `AppView`. It reuses the existing `AgentRuntime`, Catty/external turn
|
||||
drivers, core `AISession` documents, compaction, model/provider transport, native
|
||||
cancellation, selected worker epoch/generation checks and permission/audit engine.
|
||||
No source database, Vault history or builtin conversation ownership change is
|
||||
included. Plugin input cannot supply internal session IDs, source/owner, CLI/env,
|
||||
MCP configuration or permission mode.
|
||||
|
||||
The first eligible profiles are configured Catty and Claude MCP. Other known
|
||||
backends report unsupported/unavailable. Claude has builtin tools disabled,
|
||||
strict MCP, no inherited settings sources, at most 12 native turns and no native
|
||||
session persistence/resume; its existing driver rehydrates from core-owned history.
|
||||
This code path has handler/driver/MCP fixture coverage. Authenticated Claude
|
||||
service/native CLI acceptance has not run and must not be inferred from mocks.
|
||||
|
||||
Actions use one source-bound Allow / Ask / Deny decision path. Actions Deny permits
|
||||
model-only conversation with an empty tool profile; Agent-use Deny blocks host
|
||||
dispatch. Explicit target choice is independent of focus/workspace. Worker
|
||||
lifecycle changes cancel a pending chooser and revoke stale target leases;
|
||||
source/runtime/policy/lock invalidation aborts native work and rejects late events.
|
||||
Core history is source/private-owner namespaced and read/write protected through
|
||||
the plugin filesystem broker. A user-granted terminal command still has that
|
||||
terminal's native OS authority; this is not an OS filesystem sandbox.
|
||||
|
||||
Bounds include six sends/source/minute, eight scopes/runtime and 64 total, 256
|
||||
retained turn handles, 32 tool actions and 32 Catty HTTP calls per turn, 12 Catty
|
||||
iterations, a 120-second turn lifetime, 60-second model HTTP/15-second streaming
|
||||
idle deadlines, 128 KiB HTTP responses, 256 KiB projected turn output and 4 KiB
|
||||
text events. Usage is projected; no hard total native-Claude token budget is
|
||||
claimed. Handles expire after five minutes without silent target reselection.
|
||||
|
||||
The installable `examples/plugins/global-agent` sample provides backend/owned
|
||||
conversation choice, explicit targets, history, Send/Stop and existing host theme
|
||||
tokens. Its deterministic local provider fixture performs no model inference,
|
||||
paid API calls, real terminal execution or request/body/header logging. The
|
||||
installed Node/JSDOM pipeline test uses actual Catty orchestration, configured
|
||||
HTTP admission and main dispatch to synthetic workers. It proves one selected-B
|
||||
tool cycle, streamed result, Stop and revoked-handle denial; it is not native
|
||||
browser sandbox or real model acceptance. A failed-before JSON-snapshot regression
|
||||
is retained at `/tmp/netcatty-global-agent-snapshot-failed-before.log`.
|
||||
|
||||
Recorded validation: plugin runtime 732 passed/0 failed/1 skipped; affected group
|
||||
105 passed/0 failed/3 skipped; examples 6 passed/0 failed/1 skipped; repaired setup
|
||||
paths 284 passed. Build, codegen and lint passed before the final review corrections.
|
||||
Typecheck is still red: 774 diagnostics on both unchanged foundation and this
|
||||
continuation, with equal per-file/error-code counts; it is not a clean typecheck.
|
||||
The repaired Node 22 full run reports 15,354 passed/9 failed/46 skipped. Eight are
|
||||
missing xterm install patches, subsequently applied; the ninth is the transport
|
||||
CJS namespace regression fixed separately. Exact final-head validation is pending.
|
||||
|
||||
The earlier pre-setup full run remains pending after its timed-out unpatched FIDO
|
||||
test worker kept a socket open. Reading `/proc/319596/cwd` to confirm that worker's
|
||||
identity was denied (EACCES); no signal was sent and no alternate route attempted.
|
||||
Its log is retained without invented final totals. `pack:dir` first failed using
|
||||
the unwritable default npm cache; a standard task-local cache repaired that step,
|
||||
then native rebuild failed because installed `@electron/rebuild` hardcodes
|
||||
`/home/agent/.electron-gyp` outside writable roots. No home override or sandbox
|
||||
bypass was attempted. Native screenshots/video and independent runtime review
|
||||
remain parent-owned gates; nothing is merged or released.
|
||||
|
||||
Final focused Node 22 validation passes 205 cases, including all nine failure
|
||||
paths from the broad run after the documented setup/correction changes. The
|
||||
installed pipeline additionally proves disable/re-enable, owned history reopening
|
||||
and a fresh model-only turn under Actions Deny. `conversationRef` is durable;
|
||||
each open receives a fresh private runtime actor so revoked privacy/native/tool
|
||||
state cannot be revived. A typed, host-only close control clears its owned
|
||||
runtime caches after that actor settles. Build, lint (seven existing warnings)
|
||||
and generated drift checks pass. Typecheck still has the same 774 per-file/code
|
||||
diagnostic counts as the unchanged foundation. Logs:
|
||||
`/tmp/netcatty-global-agent-lifecycle-final-tests.log`,
|
||||
`/tmp/netcatty-global-agent-lifecycle-final-build.log`, and
|
||||
`/tmp/netcatty-global-agent-lifecycle-final-typecheck.log`.
|
||||
|
||||
The native recipe and reproducible package hash are in
|
||||
[`global-agent-qa.md`](global-agent-qa.md). Remaining gates are independent
|
||||
transport/runtime review, CI on the final integrated head, parent-scheduled native
|
||||
screenshots/video, genuine Claude compatibility acceptance with separately
|
||||
approved access, and native packaging on a machine with supported writable
|
||||
rebuild caches. The initial full-run pending worker and previous red totals above
|
||||
remain recorded. 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, and connection
|
||||
confirmation. Plugin business logic stays in the offline browser sandbox.
|
||||
`context.vault`. Netcatty owns the picker, field-diff review, permission routing,
|
||||
and normal connection dispatch. Plugin business logic stays in the offline browser sandbox.
|
||||
|
||||
Base: source integration #3981 at
|
||||
`4213d8e6726c44e7a479418ece3adf16ea494782`.
|
||||
@@ -36,10 +36,12 @@ 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 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.
|
||||
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.
|
||||
|
||||
All requests are bounded and cancel on runtime stop, source replacement, revoke,
|
||||
Vault lock, owner window replacement, expiry, or user cancellation. The host
|
||||
@@ -47,7 +49,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 confirmation deadline, and a 64-KiB
|
||||
eight total, ten per minute, a 60-second host-interaction 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.
|
||||
@@ -86,15 +88,16 @@ 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`. 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.
|
||||
`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.
|
||||
|
||||
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 connection review require fresh confirmation.
|
||||
during an Ask 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
|
||||
|
||||
@@ -3,8 +3,10 @@
|
||||
The plugin manager has Installed, Official marketplace and Third-party marketplace
|
||||
pages. Listing a plugin in the official market does not certify it or establish
|
||||
that Netcatty maintains it. Remote manifests, publisher names, source names and
|
||||
README content cannot create a host-issued official badge. This revision does not
|
||||
issue maintainer badges. README text and images remain author content.
|
||||
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.
|
||||
|
||||
## Add a source
|
||||
|
||||
@@ -33,20 +35,24 @@ manifest, compatibility, browser-runtime admission and archive validation still
|
||||
apply. Third-party source consent is not permission consent.
|
||||
|
||||
Delivery v1 is unchanged and source-only. Delivery **v2** has the same fields and
|
||||
strict schema as v1, with `schemaVersion: 2` and one extension: each plugin's
|
||||
`artifact` can be null or a package reference:
|
||||
strict schema as v1, with `schemaVersion: 2`. Each plugin's `artifact` can be null
|
||||
or a package reference:
|
||||
|
||||
```json
|
||||
{"path":"/assets/<64-lowercase-hex-sha256>.ncpkg","sha256":"<same-sha256>","size":12345}
|
||||
```
|
||||
|
||||
Packages are at most 32 MiB. The catalog entry must contain
|
||||
V2 delivery packages are at most 16 MiB. The catalog entry must contain
|
||||
`artifact: {url: "https://plugins.example.com/assets/<same-sha256>.ncpkg", sha256}`.
|
||||
The index's current, non-withdrawn version must exactly equal the catalog entry
|
||||
and use `distribution: "release"`. Source-only entries retain null delivery
|
||||
artifacts and `distribution: "source-only"`. Avatars remain null; remote README
|
||||
embeds are not loaded. README and PNG/WebP references retain the v1 bounds.
|
||||
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.
|
||||
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;
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
# Permission-policy and AI acceptance candidate
|
||||
|
||||
This records frozen 020 and its predecessor evidence. For the later managed-
|
||||
window Ask correction, use [the isolated Ask integration record](ask-routing-integration.md).
|
||||
020 native Default Allow/Stop/Deny/restore/themes passed; Ask had no visible
|
||||
dialog and timed out with zero new model requests. Ask acceptance/cancel and
|
||||
the content-read matrix remain unrun until that new candidate is checked.
|
||||
|
||||
Use the dedicated `integration/plugin-policy-ai-acceptance` branch. Its Draft PR
|
||||
records the exact published head and aggregate CI. Frozen #3997 at
|
||||
`677aac33b1eec9dbd4a367417a49c964cdea63e7` remains the historical native baseline;
|
||||
neither that branch nor main is changed by this integration.
|
||||
|
||||
This is a candidate for parent-coordinated acceptance. It is not a claim of
|
||||
completed native acceptance, deployment readiness or permission activation.
|
||||
|
||||
## Preserved inputs
|
||||
|
||||
| Input | Exact checkpoint | Integration |
|
||||
| --- | --- | --- |
|
||||
| Frozen integration | `677aac33b1eec9dbd4a367417a49c964cdea63e7` | Starting point; includes stream/source/Host/settings and prior terminal history |
|
||||
| Permission policy #4000 | `79a9bd6df1c17fd8a5238aba31d7a8b4cda184b9` | Initial ordinary merge `9aa899059` preserved 2eee561; follow-up ordinary merge `cfa13680e` preserves the authoritative scope/sensitivity copy and tests |
|
||||
| AI #3986 | `d33c0193ccaedc1a10aed9da495f040250121bef` | Ordinary merge `5d2ff1cb8`; cold-start wiring, themed sample and Node 22 warning-drain test correction |
|
||||
| Terminal production | #3996 `8e7c28fe1bdbcdd76816d33ec926ba8e60eefe64` | Preserve the prior renderer eligibility/publication path; policy-owned interaction changes remain from #4000 |
|
||||
|
||||
Diagnostic #3999 and its tests/docs-only `3fdaa357` continuation are not merged.
|
||||
No terminal runtime fix is required by the fixture-classification finding.
|
||||
Database protocol and fixed SQL remain solely in the minimal examples.
|
||||
|
||||
## Direct integration decisions
|
||||
|
||||
The initial merge conflict was `components/PluginAIContextHost.test.tsx`. The
|
||||
combined test keeps policy's absence of a second native content-sharing review
|
||||
and AI's actual main allowlist, two-provider synchronization, streamed result,
|
||||
Stop/lock cleanup and empty SDK-error assertion. The Node 22 warning drain is
|
||||
retained. The obsolete forced-review cancellation branch is superseded by the
|
||||
current in-app Ask/cancellation tests; no transport assertion is dropped.
|
||||
|
||||
No production file needed a manual conflict resolution. Policy production files
|
||||
remain identical to the policy input; the three AI startup wiring files and
|
||||
sample assets remain identical to d33c0193. The stream service/router and prior
|
||||
terminal renderer eligibility/publication files are unchanged from frozen 677.
|
||||
|
||||
Integration commit `7d8a354404d68071d0924532ffe74c713ace6cb9` updates two stale
|
||||
disclosure assertions in `PluginPackageManager.test.tsx` and
|
||||
`PluginMarketplace.interaction.test.tsx`. They expected the old words
|
||||
`not a grant`; the new assertions require default Allow, Ask/Deny controls and
|
||||
retained host validation/resource scope. Existing escaping, advanced-authority,
|
||||
source and README-failure assertions remain intact. Production copy is unchanged.
|
||||
|
||||
The later authoritative 79a9bd6 policy merge overlapped those two tests. Their
|
||||
final contents are byte-identical to 79a9bd6: all three earlier policy assertions
|
||||
remain, together with the owner's additional unbounded-resource, sensitivity,
|
||||
escaping/source-order and advanced-authority checks. No assertion was dropped.
|
||||
This follow-up imports the owner's five-locale wording changes; it makes no
|
||||
manual production conflict edit and leaves the shared AI test intact.
|
||||
|
||||
## Checks and retained failures
|
||||
|
||||
| Local check | Result |
|
||||
| --- | --- |
|
||||
| Registered runtime | 695 tests: 694 passed, one native skip, zero failed/cancelled |
|
||||
| Contract/SDK/CLI | 91 passed |
|
||||
| Offline examples | Five passed, one explicit Docker opt-in skip |
|
||||
| Serial mounted AI/permission/selected-summary/Host/provider/panel group at 7856 | 31 passed |
|
||||
| Final settings, terminal interaction, cold-start host and locale group | 90 passed |
|
||||
| Contract/capability/Codex generation and drift | Passed |
|
||||
| Full lint | Zero errors, seven existing warnings |
|
||||
| Production build | Passed; Vite 35.50 seconds |
|
||||
| Five sample archives | Validated; repeat builds byte-identical |
|
||||
|
||||
The initial settings group records 34 passes and two failures on the stale copy
|
||||
assertions. That run is retained separately from the corrected 36/36 result.
|
||||
|
||||
Policy input [test CI 40443396430](https://github.com/binaricat/Netcatty/actions/runs/40443396430)
|
||||
first received an explicit runner shutdown. The parent requested one retry;
|
||||
this integration task did not duplicate it. Retry Linux job `121992430185`
|
||||
passed registered runtime (694/one skip) and examples (five/one skip), then
|
||||
failed those same two `not a grant` assertions: settings group 33 passed/two
|
||||
failed. It did not reach the full suite. The candidate includes the isolated
|
||||
test correction above; do not relabel the input CI as passing. Both input Windows
|
||||
jobs and [policy package CI](https://github.com/binaricat/Netcatty/actions/runs/40443396402)
|
||||
passed. Parent reports the independent policy correction reviews scoped clean,
|
||||
including both P2s and the three UI findings, with 71 focused/synthetic checks.
|
||||
|
||||
The policy owner subsequently corrected these tests in predecessor
|
||||
`da4e80946d2f3fb48f205aae2a732252b2f51fca`. Its
|
||||
[test CI](https://github.com/binaricat/Netcatty/actions/runs/40444934631) passed
|
||||
15,342 cases with 50 skipped and no failures/cancellations, and its
|
||||
[four-platform package CI](https://github.com/binaricat/Netcatty/actions/runs/40444934738)
|
||||
passed. The final 79a9bd6 wording head's
|
||||
[test CI](https://github.com/binaricat/Netcatty/actions/runs/40446529092) passed;
|
||||
its [package workflow](https://github.com/binaricat/Netcatty/actions/runs/40446529100)
|
||||
is tracked separately in the Draft PR. No input retry was duplicated here.
|
||||
|
||||
Integration predecessor `7856c664c3c2255a21badef2758bb29311b27a01` passed its
|
||||
[test CI](https://github.com/binaricat/Netcatty/actions/runs/40445809589): 15,344
|
||||
passed, 50 skipped, zero failed/cancelled, plus both Windows jobs and native
|
||||
automated checks. Its [four-platform packages](https://github.com/binaricat/Netcatty/actions/runs/40445809590)
|
||||
also passed. These are retained predecessor results; the new exact head needs
|
||||
its own aggregate CI.
|
||||
|
||||
AI input [test CI](https://github.com/binaricat/Netcatty/actions/runs/40439111241)
|
||||
and [package CI](https://github.com/binaricat/Netcatty/actions/runs/40439111215)
|
||||
passed. Parent reports its three isolated changes scoped clean. Its prior 9ca
|
||||
Node 22 warning-observer failure remains documented in `ai-provider-startup.md`;
|
||||
the warning drain does not ignore errors or remove the empty-error assertion.
|
||||
Frozen 677's earlier unexplained MCP assertion remains separate history.
|
||||
|
||||
This executor used locked dependencies with install scripts disabled, then the
|
||||
standard repository dependency patches. The official pinned Electron binary
|
||||
installed through its documented proxy support and executes in Node mode.
|
||||
No native-header download, alternate mirror, native shim, HOME change or
|
||||
sandbox change was used. Normal native postinstall/rebuild, local full-suite
|
||||
success and local desktop packaging are not claimed. Exact candidate CI is the
|
||||
aggregate full-suite and platform-packaging verification surface.
|
||||
|
||||
## Exact packages and provenance
|
||||
|
||||
The machine-readable [package record](policy-ai-packages.json) contains complete
|
||||
IDs, versions, required features, permission/resource declarations, archive and
|
||||
content hashes, and frozen-baseline comparisons. All five versions are 0.1.0.
|
||||
|
||||
| Sample | SHA-256 | Comparison with frozen 677 |
|
||||
| --- | --- | --- |
|
||||
| Assistant | `7943d4c3800914fe0acd3d0f3a3d7f7c1910c212a5dc58a044871a6a82485274` | Matches the AI owner's new themed archive; only README.md, panel.css and panel.js differ from 677 |
|
||||
| Owned database | `f01c6a24382897d4b0bf4130941274abacb9a9a963f9b56ecaceb2c020f36874` | Byte-identical |
|
||||
| Rfam | `aaca586669db454e91d61487e576ed524d1b7190eed182bc2cd0035e59caa0d3` | Byte-identical |
|
||||
| Host actions | `cf04f82dfe25ab5cda6f5f3dad0a8fb06c37bedf9924f556bee2b8402e542ef2` | Byte-identical |
|
||||
| Terminal draft | `4c9c2388b7f891156192a0b5ab7b23291beba3555e7ec8cf24fbe2dfe1242d5e` | Byte-identical |
|
||||
|
||||
After the authoritative 79 follow-up, all five archives were rebuilt twice,
|
||||
validated and compared byte for byte with the 7856 candidate: all five remain
|
||||
identical. No sample permission/resource declaration changed. The host's new policy changes
|
||||
effective authorization behavior; identical package bytes do not certify that
|
||||
new host behavior. The old Assistant c7d784d5 archive remains historical evidence,
|
||||
not the themed archive. No handoff archive was activated by this coding executor.
|
||||
The owner-identical Assistant README retains earlier permission-review wording;
|
||||
use this candidate's Allow/Ask/Deny procedure below for acceptance. That packaged
|
||||
copy is a documentation limitation, not a requirement for repeated dialogs.
|
||||
|
||||
## Native preparation and remaining gates
|
||||
|
||||
Use the exact candidate head from its Draft PR in a separate disposable native
|
||||
checkout/profile. Keep normal platform sandbox/helper settings. Build and hash:
|
||||
|
||||
```sh
|
||||
npm ci
|
||||
npm run build
|
||||
node examples/plugins/assistant/build.mjs
|
||||
node packages/plugin-cli/dist/cli.js pack examples/plugins/assistant/.package --out /tmp/assistant.ncpkg
|
||||
node examples/plugins/database-minimal/prepare.mjs tcp://10.77.0.20:3306 /tmp/database-owned.ncpkg
|
||||
node examples/plugins/database-rfam-readonly/prepare.mjs tcp://mysql-rfam-public.ebi.ac.uk:4497 /tmp/database-rfam-readonly.ncpkg
|
||||
node packages/plugin-cli/dist/cli.js pack examples/plugins/host-actions --out /tmp/host-actions.ncpkg
|
||||
node examples/plugins/terminal-selection-draft/prepare.mjs /tmp/terminal-selection-draft.ncpkg
|
||||
```
|
||||
|
||||
Validate each archive with the plugin CLI and compare against the machine record.
|
||||
The owned DB address is packaging-only. A different approved fixture endpoint
|
||||
requires a new archive and hash. Preparation does not dial either endpoint.
|
||||
|
||||
- Verify default Allow for declared supported capabilities without a mandatory
|
||||
first-install permission checklist or repeated permission-only dialogs. Verify
|
||||
per-plugin Ask/Deny, one scoped Ask receipt, readable host-selected labels,
|
||||
lifecycle invalidation and redacted audit. Preserve necessary target selection
|
||||
and inert draft editing, exact resources, source isolation and sandboxing.
|
||||
- Verify the assistant's cold startup through the normal host without opening
|
||||
the AI sidebar. Native inference on frozen 677 returned unavailable; the
|
||||
independent startup regression did not establish that native error's cause.
|
||||
Use only the deterministic synthetic provider and Note recipe, with current
|
||||
policy semantics. Native success is not claimed before that recheck.
|
||||
- Frozen 677 Host core passed per parent; old consent recordings describe the
|
||||
prior policy. They do not certify the new permission implementation.
|
||||
- Terminal's source-level finding is fixture-sensitive-input fail-closed:
|
||||
`fixture-A> ` is untrusted; `fixture@owned-A:~$ ` is confirmed shell. Parent
|
||||
reports corrected-fixture solo/split core passed on unmodified 677. Focus
|
||||
remained unreliable/unverified, and QA sent one Enter in A during a palette
|
||||
attempt. Do not claim global zero input or full terminal native acceptance.
|
||||
Keep the inert draft's no-PTY-dispatch property distinct from that QA action.
|
||||
Exclude diagnostic instrumentation. Prior-policy results do not certify the
|
||||
new policy; the next native priority is final-policy and cold-start AI QA.
|
||||
- Native Rfam's single baseline run failed with sanitized -32014 socket error
|
||||
before connect resolved. Auth/SELECT were not reached; transport causality and
|
||||
auth/TLS compatibility remain unverified. No probe/fallback or restriction
|
||||
change is authorized by this coding task. Owned DB fixture prerequisites and
|
||||
parent coordination remain separate.
|
||||
- Record exact head, hashes, ordered prompt/operation outcomes, screenshots and
|
||||
video for the new candidate. Hosting/distribution/deployment remain pending;
|
||||
no main merge, Ready transition or release is part of this handoff.
|
||||
@@ -0,0 +1,187 @@
|
||||
[
|
||||
{
|
||||
"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": "79a9bd6df1c17fd8a5238aba31d7a8b4cda184b9",
|
||||
"rebuiltAgainstAI": "d33c0193ccaedc1a10aed9da495f040250121bef"
|
||||
},
|
||||
{
|
||||
"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": "79a9bd6df1c17fd8a5238aba31d7a8b4cda184b9",
|
||||
"rebuiltAgainstAI": "d33c0193ccaedc1a10aed9da495f040250121bef"
|
||||
},
|
||||
{
|
||||
"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": "79a9bd6df1c17fd8a5238aba31d7a8b4cda184b9",
|
||||
"rebuiltAgainstAI": "d33c0193ccaedc1a10aed9da495f040250121bef"
|
||||
},
|
||||
{
|
||||
"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": "79a9bd6df1c17fd8a5238aba31d7a8b4cda184b9",
|
||||
"rebuiltAgainstAI": "d33c0193ccaedc1a10aed9da495f040250121bef"
|
||||
},
|
||||
{
|
||||
"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": "79a9bd6df1c17fd8a5238aba31d7a8b4cda184b9",
|
||||
"rebuiltAgainstAI": "d33c0193ccaedc1a10aed9da495f040250121bef"
|
||||
}
|
||||
]
|
||||
@@ -29,6 +29,13 @@ 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
|
||||
@@ -73,7 +80,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 | Not implemented; delivery v1 requires null | Existing local presentation; no avatar lookup |
|
||||
| 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 |
|
||||
| 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 |
|
||||
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,217 @@
|
||||
[
|
||||
{
|
||||
"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": []
|
||||
}
|
||||
]
|
||||
@@ -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.", "读取插件设置", "读取此插件自己的设置。"],
|
||||
|
||||
@@ -5,6 +5,7 @@ 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> = {
|
||||
@@ -152,3 +153,37 @@ 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");
|
||||
});
|
||||
@@ -1,6 +1,9 @@
|
||||
import { matchesSearchQuery } from "../lib/searchMatcher";
|
||||
import {
|
||||
SETTINGS_SEARCH_CATALOG,
|
||||
isSettingsEntryAvailableOn,
|
||||
resolveSettingsPlatform,
|
||||
type SettingsPlatform,
|
||||
type SettingsSearchEntry,
|
||||
type SettingsTabId,
|
||||
} from "./settingsSearchCatalog";
|
||||
@@ -61,16 +64,22 @@ 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);
|
||||
|
||||
@@ -31,6 +31,8 @@ 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;
|
||||
@@ -47,8 +49,33 @@ 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
|
||||
{
|
||||
@@ -108,6 +135,14 @@ 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",
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
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);
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
export function isWindowBackgroundBlurActive(
|
||||
requested: boolean,
|
||||
windowOpacity: number,
|
||||
): boolean {
|
||||
return requested && Number.isFinite(windowOpacity) && windowOpacity < 1;
|
||||
}
|
||||
@@ -108,7 +108,7 @@ test('handoffDissolvedWorkspaceAIScope retargets inherited chat when its pane is
|
||||
});
|
||||
});
|
||||
|
||||
test('handoffDissolvedWorkspaceAIScope retargets inherited chat when original pane also survives', () => {
|
||||
test('handoffDissolvedWorkspaceAIScope keeps inherited chat on its surviving owner pane', () => {
|
||||
const result = handoffDissolvedWorkspaceAIScope({
|
||||
activeSessionIdMap: {
|
||||
'workspace:ws-1': 'chat-a',
|
||||
@@ -132,12 +132,52 @@ test('handoffDissolvedWorkspaceAIScope retargets inherited chat when original pa
|
||||
preferredTerminalId: 'term-b',
|
||||
});
|
||||
|
||||
assert.equal(result.changed, true);
|
||||
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.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-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.sessions[1]?.scope.targetId, 'term-b');
|
||||
assert.deepEqual(result.panelViewByScope['terminal:term-a'], {
|
||||
mode: 'session',
|
||||
sessionId: 'chat-a',
|
||||
});
|
||||
|
||||
@@ -78,13 +78,15 @@ export function seedWorkspaceAIActiveSessionFromMembers(input: {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* 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.
|
||||
*/
|
||||
export function handoffDissolvedWorkspaceAIScope<T extends AISessionScopeHandoffLike>(input: {
|
||||
activeSessionIdMap: AIActiveSessionIdMap;
|
||||
@@ -99,7 +101,7 @@ export function handoffDissolvedWorkspaceAIScope<T extends AISessionScopeHandoff
|
||||
panelViewByScope: Record<string, AIPanelViewHandoffLike>;
|
||||
changed: boolean;
|
||||
} {
|
||||
const preferredTerminalId = (
|
||||
const fallbackTerminalId = (
|
||||
input.preferredTerminalId
|
||||
&& input.terminalIds.includes(input.preferredTerminalId)
|
||||
? input.preferredTerminalId
|
||||
@@ -109,6 +111,17 @@ 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
|
||||
) ?? {};
|
||||
@@ -118,8 +131,8 @@ export function handoffDissolvedWorkspaceAIScope<T extends AISessionScopeHandoff
|
||||
};
|
||||
let mapChanged = false;
|
||||
|
||||
if (preferredTerminalId && hasWorkspaceActive) {
|
||||
const terminalKey = buildAIScopeKey('terminal', preferredTerminalId);
|
||||
if (handoffTargetTerminalId && hasWorkspaceActive) {
|
||||
const terminalKey = buildAIScopeKey('terminal', handoffTargetTerminalId);
|
||||
let map = nextMap;
|
||||
let changed = false;
|
||||
if (map[terminalKey] !== workspaceActive) {
|
||||
@@ -145,24 +158,23 @@ export function handoffDissolvedWorkspaceAIScope<T extends AISessionScopeHandoff
|
||||
|
||||
let sessionsChanged = false;
|
||||
const nextSessions = input.sessions.map((session) => {
|
||||
if (!preferredTerminalId) return session;
|
||||
if (!handoffTargetTerminalId) return session;
|
||||
|
||||
const isWorkspaceScoped = (
|
||||
session.scope.type === 'workspace'
|
||||
&& session.scope.targetId === input.workspaceId
|
||||
);
|
||||
// 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.
|
||||
// Keep an inherited chat on its owner when that pane survives. Remint
|
||||
// only when the handoff target is a different terminal (original pane gone).
|
||||
const isInheritedActiveChatOnOtherTerminal = (
|
||||
hasWorkspaceActive
|
||||
&& session.id === workspaceActive
|
||||
&& session.scope.type === 'terminal'
|
||||
&& Boolean(session.scope.targetId)
|
||||
&& session.scope.targetId !== preferredTerminalId
|
||||
&& session.scope.targetId !== handoffTargetTerminalId
|
||||
);
|
||||
if (!isWorkspaceScoped && !isInheritedActiveChatOnOtherTerminal) return session;
|
||||
if (session.scope.type === 'terminal' && session.scope.targetId === preferredTerminalId) {
|
||||
if (session.scope.type === 'terminal' && session.scope.targetId === handoffTargetTerminalId) {
|
||||
return session;
|
||||
}
|
||||
|
||||
@@ -172,7 +184,7 @@ export function handoffDissolvedWorkspaceAIScope<T extends AISessionScopeHandoff
|
||||
scope: {
|
||||
...session.scope,
|
||||
type: 'terminal',
|
||||
targetId: preferredTerminalId,
|
||||
targetId: handoffTargetTerminalId,
|
||||
},
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
@@ -182,8 +194,8 @@ export function handoffDissolvedWorkspaceAIScope<T extends AISessionScopeHandoff
|
||||
const nextPanelViewByScope: Record<string, AIPanelViewHandoffLike> = {
|
||||
...previousPanelViewByScope,
|
||||
};
|
||||
if (preferredTerminalId && hasWorkspaceActive) {
|
||||
const terminalKey = buildAIScopeKey('terminal', preferredTerminalId);
|
||||
if (handoffTargetTerminalId && hasWorkspaceActive) {
|
||||
const terminalKey = buildAIScopeKey('terminal', handoffTargetTerminalId);
|
||||
const workspacePanelView = previousPanelViewByScope[workspaceKey];
|
||||
const terminalPanelView = previousPanelViewByScope[terminalKey];
|
||||
const nextView: AIPanelViewHandoffLike = (
|
||||
|
||||
@@ -71,9 +71,17 @@ 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. A bare `$`/`#` alone is not a prompt.
|
||||
// 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.
|
||||
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.
|
||||
@@ -124,12 +132,15 @@ function extractTrailingIdlePrompt(output) {
|
||||
return lastLine;
|
||||
}
|
||||
|
||||
// Two-line PS1 (`export PS1="\u@\h: \w\n$ "`). Bash emits the newline as
|
||||
// CR and the PTY's ONLCR adds another, so the bytes are `\r\r\n`. The
|
||||
// CR-to-LF normalize above turns that into blank lines between the host
|
||||
// line and `$`/`#`. Keep those blanks in the cache: getFreshIdlePrompt
|
||||
// uses the same normalize, and hasExpectedPromptSuffix compares this
|
||||
// CR-as-LF form against live PTY bytes. A lone `$` must not satisfy endsWith.
|
||||
// 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`).
|
||||
if (rightTrimmed === "$" || rightTrimmed === "#") {
|
||||
let hostIndex = lines.length - 1;
|
||||
while (hostIndex >= 0 && lines[hostIndex].replace(/\s+$/, "") === "") {
|
||||
@@ -137,7 +148,13 @@ function extractTrailingIdlePrompt(output) {
|
||||
}
|
||||
const previousLine = hostIndex >= 0 ? lines[hostIndex] : "";
|
||||
const previousRightTrimmed = previousLine.replace(/\s+$/, "");
|
||||
if (POSIX_PROMPT_HOST_LINE_PATTERN.test(previousRightTrimmed)) {
|
||||
// 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)
|
||||
) {
|
||||
return `${lines.slice(hostIndex).join("\n")}\n${lastLine}`;
|
||||
}
|
||||
}
|
||||
@@ -202,8 +219,9 @@ 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 `user@host[:path]` + `$`/`#`,
|
||||
// RHEL `[user@host path][#$]`, or Alpine `host:path[#$]`);
|
||||
// `user@host[:path][#$]`, two-line classic `user@host[:path]` or Oh My Zsh
|
||||
// `ys` (`# user @ host ...`) + `$`/`#`, 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`
|
||||
|
||||
@@ -262,6 +262,83 @@ 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:~$ ");
|
||||
});
|
||||
|
||||
@@ -611,11 +611,22 @@ function withContentLength(headers, body) {
|
||||
}
|
||||
|
||||
async function streamRequest(url, options, event, requestId, skipTLS) {
|
||||
if (requestId.startsWith("agent-model_") && !/^agent-model_[0-9a-f-]{36}_[0-9a-f-]{36}_sdk_/.test(requestId)) {
|
||||
throw new Error("Delegated model request identity invalid");
|
||||
}
|
||||
const parsedUrl = new URL(url);
|
||||
// Register cancellation before any await so Stop during PAC/proxy lookup works.
|
||||
const controller = new AbortController();
|
||||
const pluginRuntimeId = /^plugin-model_([A-Za-z0-9-]{1,128})_/.exec(requestId)?.[1];
|
||||
const { idleTimeoutMs, totalTimeoutMs } = pluginRuntimeId
|
||||
const delegatedMatch = /^agent-model_([0-9a-f-]{36})_([0-9a-f-]{36})_sdk_/.exec(requestId);
|
||||
const delegatedId = delegatedMatch?.[1];
|
||||
const delegationTag = delegatedMatch?.[2];
|
||||
const delegatedBridge = delegatedId ? require("./mcpServerBridge.cjs") : null;
|
||||
const assertDelegation = () => delegatedBridge?.assertPluginAgentCaller(`plugin-agent:${delegatedId}`, event.sender.id, delegationTag);
|
||||
assertDelegation();
|
||||
if (delegatedId) delegatedBridge.beginPluginAgentModelRequest(`plugin-agent:${delegatedId}`, event.sender.id, delegationTag);
|
||||
const detachDelegation = delegatedBridge?.onPluginAgentRelease(`plugin-agent:${delegatedId}`, delegationTag, () => controller.abort(createAbortError()));
|
||||
const { idleTimeoutMs, totalTimeoutMs } = pluginRuntimeId || delegatedId
|
||||
? { idleTimeoutMs: Math.min(15_000, Math.max(1, Number(options.idleTimeoutMs) || 15_000)),
|
||||
totalTimeoutMs: Math.min(60_000, Math.max(1, Number(options.totalTimeoutMs) || 60_000)) }
|
||||
: resolveAIStreamTimeouts(options);
|
||||
@@ -629,6 +640,7 @@ async function streamRequest(url, options, event, requestId, skipTLS) {
|
||||
clearTimeout(idleTimer);
|
||||
activeStreams.delete(requestId);
|
||||
pluginModelStreamOwners.delete(requestId);
|
||||
detachDelegation?.();
|
||||
};
|
||||
const noteStreamActivity = () => {
|
||||
if (lifecycleFinished) return;
|
||||
@@ -648,6 +660,9 @@ async function streamRequest(url, options, event, requestId, skipTLS) {
|
||||
throw controller.signal.reason instanceof Error ? controller.signal.reason : createAbortError();
|
||||
}
|
||||
|
||||
try { assertDelegation(); }
|
||||
catch (error) { finishLifecycle(); throw error; }
|
||||
|
||||
const { resolveOutboundHttpAgent } = require("./httpNetworkProxyAgent.cjs");
|
||||
let proxyAgent;
|
||||
try {
|
||||
@@ -731,6 +746,7 @@ async function streamRequest(url, options, event, requestId, skipTLS) {
|
||||
if (proxyAgent) reqOpts.agent = proxyAgent;
|
||||
|
||||
try {
|
||||
assertDelegation();
|
||||
req = lib.request(parsedUrl, reqOpts,
|
||||
(res) => {
|
||||
if (streamFinished) {
|
||||
@@ -795,7 +811,7 @@ async function streamRequest(url, options, event, requestId, skipTLS) {
|
||||
res.on("data", (chunk) => {
|
||||
if (streamFinished) return;
|
||||
noteStreamActivity();
|
||||
if (pluginRuntimeId) {
|
||||
if (pluginRuntimeId || delegatedId) {
|
||||
pluginResponseBytes += Buffer.byteLength(chunk);
|
||||
if (pluginResponseBytes > 131_072) {
|
||||
failStream(new Error("Plugin model stream byte budget exceeded"));
|
||||
|
||||
@@ -46,6 +46,7 @@ function loadBridgeWithMocks(options = {}) {
|
||||
clearPendingApprovals() {},
|
||||
cleanupScopedMetadata: async () => {},
|
||||
cleanup() {},
|
||||
...options.mcpServerBridge,
|
||||
},
|
||||
"../cli/discoveryPath.cjs": {
|
||||
getCliLauncherPath: () => "/tmp/netcatty-tool-cli",
|
||||
@@ -1725,6 +1726,61 @@ async function withCapturingServer(t, options, respond) {
|
||||
};
|
||||
}
|
||||
|
||||
test("delegated model stream release aborts its actual HTTP request and stale turns fail before HTTP", { timeout: 5_000 }, async (t) => {
|
||||
const { randomUUID } = require("node:crypto");
|
||||
const id = randomUUID(); const tag = randomUUID(); const chatId = `plugin-agent:${id}`;
|
||||
let live = true; let release; let detached = 0; let requests = 0;
|
||||
let resolveAccepted; const accepted = new Promise((resolve) => { resolveAccepted = resolve; });
|
||||
const ctx = await withCapturingServer(t, { mcpServerBridge: {
|
||||
beginPluginAgentModelRequest: () => {},
|
||||
assertPluginAgentCaller: (actual, sender, actualTag) => {
|
||||
assert.equal(actual, chatId); assert.equal(sender, 1);
|
||||
if (!live || actualTag !== tag) throw Error("Agent turn unavailable");
|
||||
},
|
||||
onPluginAgentRelease: (_id, _tag, cancel) => { release = cancel; return () => detached++; },
|
||||
} }, (res) => {
|
||||
requests++; res.writeHead(200, { "content-type": "text/event-stream" });
|
||||
res.write('data: {"synthetic":true}\n\n'); resolveAccepted();
|
||||
});
|
||||
try {
|
||||
const invoke = (turnTag) => ctx.ipcMain.handlers.get("netcatty:ai:chat:stream")({ sender: ctx.sender }, {
|
||||
requestId: `agent-model_${id}_${turnTag}_sdk_fixture`, url: `${ctx.baseURL}/v1/chat/completions`,
|
||||
headers: { "content-type": "application/json" }, body: '{"stream":true}', providerId: "content-length",
|
||||
});
|
||||
const ended = new Promise((resolve) => ctx.onStreamEvent((channel) => {
|
||||
if (channel === "netcatty:ai:stream:error" || channel === "netcatty:ai:stream:end") resolve();
|
||||
}));
|
||||
const pending = invoke(tag); await accepted; live = false; release();
|
||||
await pending; await ended;
|
||||
assert.equal(detached, 1); assert.equal(requests, 1);
|
||||
const stale = await invoke(randomUUID());
|
||||
assert.equal(stale.ok, false); assert.equal(requests, 1);
|
||||
} finally { ctx.restore(); }
|
||||
});
|
||||
|
||||
test("delegated compaction fetch uses the same exact-turn cancellation", { timeout: 5_000 }, async (t) => {
|
||||
const { randomUUID } = require("node:crypto"); const id = randomUUID(); const tag = randomUUID();
|
||||
let live = true; let release; let detached = 0; let requests = 0;
|
||||
let accepted; const started = new Promise((resolve) => { accepted = resolve; });
|
||||
const ctx = await withCapturingServer(t, { mcpServerBridge: {
|
||||
beginPluginAgentModelRequest: () => {},
|
||||
assertPluginAgentCaller: (chatId, sender, actualTag) => {
|
||||
assert.equal(chatId, `plugin-agent:${id}`); assert.equal(sender, 1);
|
||||
if (!live || actualTag !== tag) throw Error("Agent unavailable");
|
||||
},
|
||||
onPluginAgentRelease: (_id, _tag, cancel) => { release = cancel; return () => detached++; },
|
||||
} }, (res) => { requests++; res.writeHead(200, { "content-type": "application/json" }); res.write('{"synthetic":'); accepted(); });
|
||||
try {
|
||||
const invoke = () => ctx.ipcMain.handlers.get("netcatty:ai:fetch")({ sender: ctx.sender }, {
|
||||
requestId: `agent-model_${id}_${tag}_fetch_fixture`, url: `${ctx.baseURL}/v1/chat/completions`, method: "POST",
|
||||
headers: { "content-type": "application/json" }, body: '{"messages":[]}', providerId: "content-length",
|
||||
});
|
||||
const pending = invoke(); await started; assert.equal(ctx.received.body.toString(), '{"messages":[]}');
|
||||
live = false; release(); assert.equal((await pending).ok, false); assert.equal(detached, 1);
|
||||
assert.equal((await invoke()).ok, false); assert.equal(requests, 1); assert.equal(ctx.ipcMain.handlers.has("netcatty:ai:chat:cancel"), true);
|
||||
} finally { ctx.restore(); }
|
||||
});
|
||||
|
||||
test("streaming AI requests send Content-Length instead of chunked encoding", { timeout: 5_000 }, async (t) => {
|
||||
const ctx = await withCapturingServer(t, {}, (res) => {
|
||||
res.writeHead(200, { "content-type": "text/event-stream" });
|
||||
|
||||
@@ -4,8 +4,12 @@ function registerAgentProcessHandlers(ctx) {
|
||||
const maxCommandTimeoutSeconds = 24 * 60 * 60;
|
||||
// ── MCP Server session metadata ──
|
||||
|
||||
ipcMain.handle("netcatty:ai:mcp:update-sessions", async (event, { sessions: sessionList, chatSessionId }) => {
|
||||
ipcMain.handle("netcatty:ai:mcp:update-sessions", async (event, { sessions: sessionList, chatSessionId, delegationTag }) => {
|
||||
if (!validateSenderOrSettings(event)) return { ok: false, error: "Unauthorized IPC sender" };
|
||||
if (mcpServerBridge.isPluginAgentConversation?.(chatSessionId)) {
|
||||
try { mcpServerBridge.assertPluginAgentCaller(chatSessionId, event.sender.id, delegationTag); }
|
||||
catch { return { ok: false, error: "Agent scope update unavailable" }; }
|
||||
}
|
||||
const list = Array.isArray(sessionList) ? sessionList : [];
|
||||
const externalId = mcpServerBridge.EXTERNAL_MCP_CHAT_SESSION_ID;
|
||||
if (chatSessionId === externalId) {
|
||||
|
||||
@@ -87,11 +87,20 @@ async function proxyCattyExecToWorker({
|
||||
function registerCattyExecHandlers(ctx) {
|
||||
const privacyService = ctx.aiPrivacyService || defaultAiPrivacyService;
|
||||
with (ctx) {
|
||||
ipcMain.handle("netcatty:ai:exec", async (event, { sessionId, command, chatSessionId }) => {
|
||||
ipcMain.handle("netcatty:ai:exec", async (event, { sessionId, command, chatSessionId, delegationTag }) => {
|
||||
// Validate IPC sender (Issue #17)
|
||||
if (!validateSender(event)) {
|
||||
return { ok: false, error: "Unauthorized IPC sender" };
|
||||
}
|
||||
if (mcpServerBridge.isPluginAgentConversation?.(chatSessionId)) {
|
||||
try {
|
||||
return await mcpServerBridge.dispatchBuiltinRpc("netcatty/exec", { sessionId, command, chatSessionId }, {
|
||||
trustedAgentSenderId: event.sender.id,
|
||||
trustedAgentTurnTag: delegationTag,
|
||||
beforeExecute: () => assertCattyPrivacySession(privacyService, chatSessionId),
|
||||
});
|
||||
} catch { return { ok: false, error: "Delegated Agent execution unavailable" }; }
|
||||
}
|
||||
// Block execution in observer mode (Issue #11)
|
||||
if (mcpServerBridge.getPermissionMode() === "observer") {
|
||||
return { ok: false, error: "Execution blocked: permission mode is 'observer'" };
|
||||
@@ -293,10 +302,14 @@ function registerCattyExecHandlers(ctx) {
|
||||
});
|
||||
|
||||
// Cancel in-flight Catty Agent command executions for a chat session
|
||||
ipcMain.handle("netcatty:ai:catty:cancel", async (event, { chatSessionId }) => {
|
||||
ipcMain.handle("netcatty:ai:catty:cancel", async (event, { chatSessionId, delegationTag }) => {
|
||||
if (!validateSender(event)) {
|
||||
return { ok: false, error: "Unauthorized IPC sender" };
|
||||
}
|
||||
if (mcpServerBridge.isPluginAgentConversation?.(chatSessionId)) {
|
||||
try { mcpServerBridge.assertPluginAgentCaller(chatSessionId, event.sender.id, delegationTag); }
|
||||
catch { return { ok: false, error: "Agent cancellation unavailable" }; }
|
||||
}
|
||||
mcpServerBridge.cancelPtyExecsForSession(chatSessionId);
|
||||
void mcpServerBridge.cancelSftpOpsForSession?.(chatSessionId);
|
||||
if (typeof mcpServerBridge.cancelWorkerBackgroundJobsForSession === "function") {
|
||||
@@ -313,7 +326,7 @@ function registerCattyExecHandlers(ctx) {
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
ipcMain.handle("netcatty:ai:chat-session:set-cancelled", async (event, { chatSessionId, cancelled }) => {
|
||||
ipcMain.handle("netcatty:ai:chat-session:set-cancelled", async (event, { chatSessionId, cancelled, delegationTag }) => {
|
||||
if (!validateSender(event)) {
|
||||
return { ok: false, error: "Unauthorized IPC sender" };
|
||||
}
|
||||
@@ -321,13 +334,14 @@ function registerCattyExecHandlers(ctx) {
|
||||
return { ok: false, error: "chatSessionId is required" };
|
||||
}
|
||||
try {
|
||||
if (mcpServerBridge.isPluginAgentConversation?.(chatSessionId)) mcpServerBridge.assertPluginAgentCaller(chatSessionId, event.sender.id, delegationTag);
|
||||
return await mcpServerBridge.applyChatSessionCancelled(chatSessionId, cancelled !== false);
|
||||
} catch (err) {
|
||||
return { ok: false, error: err?.message || String(err) };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle("netcatty:ai:capability", async (event, { rpcMethod, params, chatSessionId }) => {
|
||||
ipcMain.handle("netcatty:ai:capability", async (event, { rpcMethod, params, chatSessionId, delegationTag }) => {
|
||||
if (!validateSender(event)) {
|
||||
return { ok: false, error: "Unauthorized IPC sender" };
|
||||
}
|
||||
@@ -340,7 +354,7 @@ function registerCattyExecHandlers(ctx) {
|
||||
return await mcpServerBridge.dispatchBuiltinRpc(rpcMethod, {
|
||||
...(params || {}),
|
||||
chatSessionId,
|
||||
}, { beforeExecute });
|
||||
}, { beforeExecute, trustedAgentSenderId: event.sender.id, trustedAgentTurnTag: delegationTag });
|
||||
} catch (err) {
|
||||
return { ok: false, error: err?.message || "AI privacy protection is unavailable." };
|
||||
}
|
||||
|
||||
@@ -400,7 +400,7 @@ function registerProviderHandlers(ctx) {
|
||||
});
|
||||
|
||||
// Non-streaming request (for model listing, validation, etc.)
|
||||
ipcMain.handle("netcatty:ai:fetch", async (event, { url, method, headers, body, providerId, skipHostCheck, followRedirects, skipTLSVerify }) => {
|
||||
ipcMain.handle("netcatty:ai:fetch", async (event, { url, method, headers, body, providerId, skipHostCheck, followRedirects, skipTLSVerify, requestId }) => {
|
||||
// Validate IPC sender — settings window needs this for model listing
|
||||
if (!validateSenderOrSettings(event)) {
|
||||
return { ok: false, status: 0, data: "", error: "Unauthorized IPC sender" };
|
||||
@@ -428,7 +428,25 @@ function registerProviderHandlers(ctx) {
|
||||
return { ok: false, status: 0, data: "", error: "URL host is not in the allowed list" };
|
||||
}
|
||||
|
||||
const MAX_RESPONSE_SIZE = 10 * 1024 * 1024; // 10MB safety limit
|
||||
const delegated = typeof requestId === "string" && /^agent-model_([0-9a-f-]{36})_([0-9a-f-]{36})_fetch_/.exec(requestId);
|
||||
if (requestId !== undefined && !delegated) return { ok: false, status: 0, data: "", error: "Delegated request identity invalid" };
|
||||
const controller = delegated ? new AbortController() : null;
|
||||
const delegatedBridge = delegated ? require("./mcpServerBridge.cjs") : null;
|
||||
const assertDelegation = () => {
|
||||
controller?.signal.throwIfAborted();
|
||||
if (delegated) delegatedBridge.assertPluginAgentCaller(`plugin-agent:${delegated[1]}`, event.sender.id, delegated[2]);
|
||||
};
|
||||
let detach; let totalTimer;
|
||||
if (delegated) {
|
||||
try {
|
||||
assertDelegation();
|
||||
delegatedBridge.beginPluginAgentModelRequest(`plugin-agent:${delegated[1]}`, event.sender.id, delegated[2]);
|
||||
detach = delegatedBridge.onPluginAgentRelease(`plugin-agent:${delegated[1]}`, delegated[2], () => controller.abort());
|
||||
activeStreams.set(requestId, controller);
|
||||
totalTimer = setTimeout(() => controller.abort(), 60_000);
|
||||
} catch { return { ok: false, status: 0, data: "", error: "Delegated Agent unavailable" }; }
|
||||
}
|
||||
const MAX_RESPONSE_SIZE = delegated ? 131_072 : 10 * 1024 * 1024;
|
||||
const MAX_REDIRECTS = followRedirects ? 5 : 0;
|
||||
|
||||
async function doFetch(fetchUrl, redirectsLeft) {
|
||||
@@ -437,14 +455,18 @@ function registerProviderHandlers(ctx) {
|
||||
const skipTLS = Boolean(skipTLSVerify || shouldSkipTLSVerify(providerId));
|
||||
let proxyAgent;
|
||||
try {
|
||||
assertDelegation();
|
||||
proxyAgent = await resolveOutboundHttpAgent(fetchUrl, {
|
||||
session: electronModule?.session?.defaultSession,
|
||||
rejectUnauthorized: skipTLS ? false : undefined,
|
||||
});
|
||||
} catch {
|
||||
} catch (error) {
|
||||
if (delegated) throw error;
|
||||
proxyAgent = undefined;
|
||||
}
|
||||
|
||||
assertDelegation();
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const parsedUrl = new URL(fetchUrl);
|
||||
const isHttps = parsedUrl.protocol === "https:";
|
||||
@@ -457,6 +479,8 @@ function registerProviderHandlers(ctx) {
|
||||
};
|
||||
if (skipTLS && isHttps) fetchOpts.rejectUnauthorized = false;
|
||||
if (proxyAgent) fetchOpts.agent = proxyAgent;
|
||||
if (controller) fetchOpts.signal = controller.signal;
|
||||
assertDelegation();
|
||||
const req = lib.request(parsedUrl, fetchOpts,
|
||||
(res) => {
|
||||
// Handle redirects
|
||||
@@ -505,7 +529,9 @@ function registerProviderHandlers(ctx) {
|
||||
});
|
||||
}
|
||||
|
||||
return doFetch(resolvedUrl, MAX_REDIRECTS);
|
||||
try { return await doFetch(resolvedUrl, MAX_REDIRECTS); }
|
||||
catch (error) { if (!delegated) throw error; return { ok: false, status: 0, data: "", error: "Delegated Agent unavailable" }; }
|
||||
finally { if (delegated) { clearTimeout(totalTimer); detach?.(); activeStreams.delete(requestId); } }
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
"use strict";
|
||||
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const vm = require("node:vm");
|
||||
const { createRequire, wrap } = require("node:module");
|
||||
|
||||
// Execute the real driver/registry source with only config repair and SDK
|
||||
// inference replaced. No user config is read and no SDK process is launched.
|
||||
function loadSource(name, replacements) {
|
||||
const filename = require.resolve(name); const local = createRequire(filename);
|
||||
const module = { exports: {} };
|
||||
const wrapper = vm.runInThisContext(wrap(fs.readFileSync(filename, "utf8")), { filename });
|
||||
wrapper(module.exports, (id) => replacements[id] ?? local(id), module, filename, path.dirname(filename));
|
||||
return module.exports;
|
||||
}
|
||||
|
||||
function fixture() {
|
||||
const repairs = []; const sends = []; const errors = [];
|
||||
const driver = loadSource("./claudeDriver.cjs", { "./claudeConfig.cjs": { ensureClaudeConfig: () => repairs.push("repair") } });
|
||||
const run = driver.runClaudeTurn;
|
||||
driver.runClaudeTurn = (args) => run({ ...args, queryFn: ({ options }) => {
|
||||
sends.push(options);
|
||||
return (async function* () { yield { type: "stream_event", event: { type: "content_block_delta", delta: { type: "text_delta", text: "Synthetic" } } }; })();
|
||||
} });
|
||||
const registry = loadSource("./index.cjs", { "./claudeDriver.cjs": driver });
|
||||
const emitter = new Proxy({ emitError: (error) => errors.push(error) }, { get: (target, key) => target[key] ?? (() => {}) });
|
||||
const env = Object.freeze({ CLAUDE_CONFIG_DIR: "/synthetic/selected-profile", ANTHROPIC_BASE_URL: "http://127.0.0.1:1", ANTHROPIC_API_KEY: "FIXTURE_ONLY_MARKER" });
|
||||
const ctx = { prompt: "Synthetic", env, emitter, toolIntegrationMode: "mcp", cwd: "/synthetic/turn", binPath: "/synthetic/official-claude",
|
||||
abortController: new AbortController(), assertSessionValid: () => {}, injectedMcpServers: [{ name: "selected-only", command: "host-managed", args: [], env: [] }] };
|
||||
return { repairs, sends, errors, env, ctx, run: (overrides) => registry.getDriver("claude").runTurn({ ...ctx, ...overrides }) };
|
||||
}
|
||||
|
||||
for (const explicitProfile of [true, false]) test(`delegated registry turn skips config repair with ${explicitProfile ? "explicit" : "default"} host profile`, async () => {
|
||||
const f = fixture(); const env = explicitProfile ? f.env : { ANTHROPIC_API_KEY: "FIXTURE_ONLY_MARKER" };
|
||||
await f.run({ delegatedAgent: true, env });
|
||||
assert.deepEqual(f.repairs, []); assert.equal(f.sends.length, 1); assert.deepEqual(f.errors, []);
|
||||
const options = f.sends[0]; assert.equal(options.env, env);
|
||||
assert.equal(options.cwd, f.ctx.cwd); assert.equal(options.pathToClaudeCodeExecutable, f.ctx.binPath);
|
||||
assert.deepEqual(options.tools, []); assert.deepEqual(options.settingSources, []);
|
||||
assert.equal(options.strictMcpConfig, true); assert.equal(options.persistSession, false);
|
||||
assert.deepEqual(Object.keys(options.mcpServers), ["selected-only"]);
|
||||
});
|
||||
|
||||
for (const delegatedAgent of [undefined, false]) test(`builtin registry turn retains repair when delegated flag is ${String(delegatedAgent)}`, async () => {
|
||||
const f = fixture(); await f.run({ delegatedAgent });
|
||||
assert.deepEqual(f.repairs, ["repair"]); assert.equal(f.sends.length, 1); assert.deepEqual(f.errors, []);
|
||||
assert.equal(f.sends[0].env, f.env); assert.equal(f.sends[0].settingSources, undefined);
|
||||
});
|
||||
@@ -5,7 +5,7 @@
|
||||
*
|
||||
* - Spawns the user's system `claude` binary via an ABSOLUTE pathToClaudeCodeExecutable
|
||||
* (SDK existsSync-checks it; PATH is not resolved — issue #205).
|
||||
* - Repairs ~/.claude.json before spawn (ensureClaudeConfig).
|
||||
* - Repairs ~/.claude.json before builtin turns only (ensureClaudeConfig).
|
||||
* - Bypasses the SDK's built-in permission system and BLOCKS built-in
|
||||
* side-effect tools so the agent can only act through the injected netcatty
|
||||
* MCP server (approval/scope/blocklist enforced there).
|
||||
@@ -101,7 +101,9 @@ function mergeClaudeEffortSettings(settings, effort) {
|
||||
function buildClaudeQueryOptions({
|
||||
cwd, model, env, pathToClaudeCodeExecutable, abortController, injectedMcpServers, settings, resume,
|
||||
toolIntegrationMode,
|
||||
delegatedAgent = false,
|
||||
}) {
|
||||
if (delegatedAgent && toolIntegrationMode !== "mcp") throw Error("Delegated Claude requires MCP mode");
|
||||
const { model: resolvedModel, effort } = splitClaudeModelSelection(model);
|
||||
const options = {
|
||||
cwd,
|
||||
@@ -130,8 +132,17 @@ function buildClaudeQueryOptions({
|
||||
options.pathToClaudeCodeExecutable = pathToClaudeCodeExecutable;
|
||||
}
|
||||
// Optional settings.json path / inline object — additive to CLAUDE_CONFIG_DIR.
|
||||
const parsedSettings = mergeClaudeEffortSettings(parseClaudeSettings(settings), effort);
|
||||
const parsedSettings = mergeClaudeEffortSettings(delegatedAgent ? undefined : parseClaudeSettings(settings), effort);
|
||||
if (parsedSettings !== undefined) options.settings = parsedSettings;
|
||||
if (delegatedAgent) {
|
||||
options.strictMcpConfig = true;
|
||||
options.settingSources = [];
|
||||
options.maxTurns = 12;
|
||||
// Global plugin history belongs to core's source namespace. Rehydrate from
|
||||
// that history each turn instead of creating a second native history store.
|
||||
options.persistSession = false;
|
||||
delete options.resume;
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
@@ -233,8 +244,10 @@ function buildClaudePromptInput(prompt, attachments) {
|
||||
* @param {object} args.emitter createStreamEmitter(...)
|
||||
* @param {Function} [args.queryFn] inject @anthropic-ai/claude-agent-sdk query (for tests)
|
||||
*/
|
||||
async function runClaudeTurn({ prompt, attachments, options, emitter, queryFn, assertSessionValid }) {
|
||||
ensureClaudeConfig();
|
||||
async function runClaudeTurn({ prompt, attachments, options, emitter, queryFn, assertSessionValid, delegatedAgent = false }) {
|
||||
// A delegated turn may use the selected host profile for authentication, but
|
||||
// must not repair or delete unrelated files in the main process's home.
|
||||
if (!delegatedAgent) ensureClaudeConfig();
|
||||
let query = queryFn;
|
||||
if (!query) {
|
||||
let sdk;
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
// SDK queries in this file are synthetic. Never repair real user config.
|
||||
const configPath = require.resolve("./claudeConfig.cjs");
|
||||
const priorConfigModule = require.cache[configPath];
|
||||
require.cache[configPath] = { id: configPath, filename: configPath, loaded: true, exports: { ensureClaudeConfig() {} } };
|
||||
const { translateClaudeMessage, buildClaudeQueryOptions, buildClaudePromptInput, classifyClaudeSpawnError, listClaudeModels, mapClaudeModels, parseClaudeSettings, splitClaudeModelSelection } = require("./claudeDriver.cjs");
|
||||
if (priorConfigModule) require.cache[configPath] = priorConfigModule;
|
||||
else delete require.cache[configPath];
|
||||
|
||||
function collector() {
|
||||
const events = [];
|
||||
@@ -333,3 +339,19 @@ test("listClaudeModels aborts a hung SDK query and returns it for cleanup", asyn
|
||||
assert.equal(queryAbortSignal.aborted, true);
|
||||
assert.equal(returnCount, 1);
|
||||
});
|
||||
|
||||
test("delegated Claude uses only explicit MCP configuration and cannot inherit local settings/tool mode", () => {
|
||||
const options = buildClaudeQueryOptions({
|
||||
delegatedAgent: true, toolIntegrationMode: "mcp", settings: { mcpServers: { unwanted: {} } },
|
||||
injectedMcpServers: [{ name: "scoped-netcatty", command: "host-managed", args: [], env: [] }],
|
||||
});
|
||||
assert.equal(options.strictMcpConfig, true);
|
||||
assert.equal(options.maxTurns, 12);
|
||||
assert.equal(options.persistSession, false);
|
||||
assert.equal(options.resume, undefined);
|
||||
assert.deepEqual(options.settingSources, []);
|
||||
assert.deepEqual(options.tools, []);
|
||||
assert.equal(options.settings, undefined);
|
||||
assert.deepEqual(Object.keys(options.mcpServers), ["scoped-netcatty"]);
|
||||
assert.throws(() => buildClaudeQueryOptions({ delegatedAgent: true, toolIntegrationMode: "skills" }), /requires MCP/);
|
||||
});
|
||||
@@ -44,8 +44,10 @@ const DRIVER_REGISTRY = {
|
||||
settings: ctx.claudeSettings,
|
||||
resume: ctx.resumeSessionId,
|
||||
toolIntegrationMode: ctx.toolIntegrationMode,
|
||||
delegatedAgent: ctx.delegatedAgent === true,
|
||||
});
|
||||
return claude.runClaudeTurn({ prompt: ctx.prompt, attachments: ctx.attachments, options, emitter: ctx.emitter, assertSessionValid: ctx.assertSessionValid });
|
||||
return claude.runClaudeTurn({ prompt: ctx.prompt, attachments: ctx.attachments, options, emitter: ctx.emitter,
|
||||
assertSessionValid: ctx.assertSessionValid, delegatedAgent: ctx.delegatedAgent === true });
|
||||
},
|
||||
async listModels(ctx) {
|
||||
return claude.listClaudeModels({
|
||||
|
||||
@@ -13,6 +13,7 @@ async function buildInjectedMcpServers({
|
||||
mcpServerBridge,
|
||||
chatSessionId,
|
||||
toolIntegrationMode,
|
||||
delegationTag,
|
||||
}) {
|
||||
try {
|
||||
// Start the netcatty control host for BOTH modes. getOrCreateHost binds the
|
||||
@@ -30,8 +31,22 @@ async function buildInjectedMcpServers({
|
||||
scopedIds,
|
||||
chatSessionId,
|
||||
);
|
||||
if (mcpServerBridge.isPluginAgentConversation?.(chatSessionId)) {
|
||||
const delegated = mcpServerBridge.getPluginAgentMcpConfiguration(chatSessionId, delegationTag);
|
||||
// Replace, never append alongside, the app-wide credential. These values
|
||||
// stay in the managed subprocess configuration and never reach the plugin.
|
||||
netcattyMcpConfig.env = netcattyMcpConfig.env.filter((entry) => ![
|
||||
"NETCATTY_MCP_TOKEN", "NETCATTY_MCP_DEBUG", "NETCATTY_MCP_PERMISSION_MODE",
|
||||
].includes(entry.name));
|
||||
netcattyMcpConfig.env.push(
|
||||
{ name: "NETCATTY_MCP_TOKEN", value: delegated.token },
|
||||
{ name: "NETCATTY_MCP_PERMISSION_MODE", value: "confirm" },
|
||||
{ name: "NETCATTY_MCP_DELEGATED_TOOLS", value: JSON.stringify(delegated.tools) },
|
||||
);
|
||||
}
|
||||
return [netcattyMcpConfig];
|
||||
} catch (err) {
|
||||
if (mcpServerBridge.isPluginAgentConversation?.(chatSessionId)) throw Error("Delegated Agent MCP is unavailable");
|
||||
console.error("[sdk] Failed to ensure netcatty host / inject MCP server:", err?.message || err);
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -3,6 +3,14 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const { createAiPrivacyService } = require("../../../shared/aiPrivacyService.cjs");
|
||||
// Load the query-only Claude fixture with config repair replaced. Its live
|
||||
// prompt guard tests must never read or repair the executor's actual profile.
|
||||
const configPath = require.resolve("./claudeConfig.cjs");
|
||||
const priorConfigModule = require.cache[configPath];
|
||||
require.cache[configPath] = { id: configPath, filename: configPath, loaded: true, exports: { ensureClaudeConfig() {} } };
|
||||
const claudeDriver = require("./claudeDriver.cjs");
|
||||
if (priorConfigModule) require.cache[configPath] = priorConfigModule;
|
||||
else delete require.cache[configPath];
|
||||
|
||||
function fixture() {
|
||||
const privacy = createAiPrivacyService();
|
||||
@@ -172,7 +180,7 @@ for (const pausedMethod of ["initialize", "session/new"]) {
|
||||
|
||||
test("query and argv drivers reject invalid sessions at their outbound handoff", async (t) => {
|
||||
const cases = [
|
||||
["claude", (f) => require("./claudeDriver.cjs").runClaudeTurn({ ...f, options: {}, queryFn: (input) => { f.sends.push(input); } })],
|
||||
["claude", (f) => claudeDriver.runClaudeTurn({ ...f, options: {}, queryFn: (input) => { f.sends.push(input); } })],
|
||||
["codebuddy", (f) => require("./codebuddyDriver.cjs").runCodebuddyTurn({ ...f, options: {}, queryFn: (input) => { f.sends.push(input); } })],
|
||||
["cursor-cli", (f) => require("./cursorCliDriver.cjs").runCursorCliTurn({ ...f, binPath: "/bin/cursor", workspaceCwd: "/tmp", spawnImpl: (...input) => { f.sends.push(input); } })],
|
||||
["grok-cli", (f) => require("./grokDriver.cjs").runGrokTurn({ ...f, binPath: "/bin/grok", spawnImpl: (...input) => { f.sends.push(input); } })],
|
||||
|
||||
@@ -744,6 +744,22 @@ function registerSdkStreamHandlers(ctx) {
|
||||
} = payload;
|
||||
|
||||
const backendKey = resolveBackendKey(sdkBackend);
|
||||
const delegatedAgent = mcpServerBridge.isPluginAgentConversation?.(chatSessionId) === true;
|
||||
const delegationTag = delegatedAgent ? /^plugin-agent_([0-9a-f-]{36})_/.exec(requestId)?.[1] : undefined;
|
||||
if (delegatedAgent && (backendKey !== "claude" || toolIntegrationMode !== "mcp")) {
|
||||
throw Error("This backend is not eligible for delegated Agent sessions");
|
||||
}
|
||||
if (delegatedAgent) {
|
||||
mcpServerBridge.assertPluginAgentCaller(chatSessionId, event.sender.id, delegationTag);
|
||||
mcpServerBridge.getPluginAgentMcpConfiguration(chatSessionId, delegationTag);
|
||||
}
|
||||
let delegatedCwd;
|
||||
if (delegatedAgent) {
|
||||
const nativePath = require("node:path");
|
||||
const name = require("node:crypto").createHash("sha256").update(chatSessionId).digest("hex");
|
||||
delegatedCwd = nativePath.join(tempDirBridge.getTempDir(), "plugin-agent", name);
|
||||
require("node:fs").mkdirSync(delegatedCwd, { recursive: true, mode: 0o700 });
|
||||
}
|
||||
if (!backendKey) {
|
||||
safeSend(event.sender, "netcatty:ai:sdk-agent:error", {
|
||||
requestId, error: `Unknown SDK backend: ${sdkBackend}`,
|
||||
@@ -752,8 +768,11 @@ function registerSdkStreamHandlers(ctx) {
|
||||
}
|
||||
|
||||
const abortController = new AbortController();
|
||||
const detachDelegatedCancellation = delegatedAgent
|
||||
? mcpServerBridge.onPluginAgentRelease(chatSessionId, delegationTag, () => abortController.abort()) : null;
|
||||
sdkActiveStreams.set(requestId, abortController);
|
||||
sdkRequestSessions.set(requestId, chatSessionId);
|
||||
if (delegatedAgent) sdkRequestRuntimes.set(requestId, { backendKey, delegationTag, senderId: event.sender.id });
|
||||
mcpServerBridge.setChatSessionCancelled?.(chatSessionId, false);
|
||||
|
||||
let privacyEnabled = false;
|
||||
@@ -776,6 +795,7 @@ function registerSdkStreamHandlers(ctx) {
|
||||
mcpServerBridge,
|
||||
chatSessionId,
|
||||
toolIntegrationMode: effectiveMode,
|
||||
delegationTag,
|
||||
});
|
||||
|
||||
// NETCATTY_CLAUDE_SETTINGS is a netcatty marker carrying the claude SDK
|
||||
@@ -850,6 +870,7 @@ function registerSdkStreamHandlers(ctx) {
|
||||
const sessionRuntime = backendKey === "grok" ? grokRuntime : codexRuntime;
|
||||
sdkRequestRuntimes.set(requestId, {
|
||||
backendKey,
|
||||
...(delegatedAgent ? { delegationTag, senderId: event.sender.id } : {}),
|
||||
codexRuntime,
|
||||
grokRuntime,
|
||||
sessionRuntime,
|
||||
@@ -1026,9 +1047,14 @@ function registerSdkStreamHandlers(ctx) {
|
||||
chatSessionId,
|
||||
// Drivers recheck after their own asynchronous startup, at the
|
||||
// final Netcatty-owned prompt handoff (including disabled turns).
|
||||
assertSessionValid: () => privacyService.checkSession(chatSessionId),
|
||||
assertSessionValid: () => {
|
||||
if (delegatedAgent) mcpServerBridge.assertPluginAgentCaller(chatSessionId, event.sender.id, delegationTag);
|
||||
abortController.signal.throwIfAborted();
|
||||
return privacyService.checkSession(chatSessionId);
|
||||
},
|
||||
delegatedAgent,
|
||||
...promptPlacement,
|
||||
cwd: cwd || process.cwd(),
|
||||
cwd: delegatedCwd || cwd || process.cwd(),
|
||||
model: model || undefined,
|
||||
permissionMode: permissionMode || "confirm",
|
||||
env,
|
||||
@@ -1086,6 +1112,7 @@ function registerSdkStreamHandlers(ctx) {
|
||||
emitter.emitError(err?.message || String(err));
|
||||
return { ok: false, error: err?.message || String(err) };
|
||||
} finally {
|
||||
detachDelegatedCancellation?.();
|
||||
sdkActiveStreams.delete(requestId);
|
||||
sdkRequestSessions.delete(requestId);
|
||||
sdkRequestRuntimes.delete(requestId);
|
||||
@@ -1388,7 +1415,19 @@ function registerSdkStreamHandlers(ctx) {
|
||||
|
||||
ipcMain.handle("netcatty:ai:sdk-agent:cancel", async (event, { requestId, chatSessionId }) => {
|
||||
if (!validateSender(event)) return { ok: false, error: "Unauthorized IPC sender" };
|
||||
const effectiveChatSessionId = chatSessionId || sdkRequestSessions.get(requestId);
|
||||
const ownedChatSessionId = sdkRequestSessions.get(requestId);
|
||||
// Caller text cannot select the legacy route for a delegated request.
|
||||
if (ownedChatSessionId && chatSessionId && ownedChatSessionId !== chatSessionId) return { ok: false, error: "Agent request ownership mismatch" };
|
||||
const effectiveChatSessionId = ownedChatSessionId || chatSessionId;
|
||||
if (mcpServerBridge.isPluginAgentConversation?.(effectiveChatSessionId)) {
|
||||
const native = sdkRequestRuntimes.get(requestId);
|
||||
if (!native?.delegationTag || native.senderId !== event.sender.id
|
||||
|| sdkRequestSessions.get(requestId) !== effectiveChatSessionId) return { ok: false, error: "Agent turn unavailable" };
|
||||
try { mcpServerBridge.assertPluginAgentCaller(effectiveChatSessionId, event.sender.id, native.delegationTag); }
|
||||
catch { return { ok: false, error: "Agent turn unavailable" }; }
|
||||
sdkActiveStreams.get(requestId)?.abort();
|
||||
return { ok: true };
|
||||
}
|
||||
mcpServerBridge.setChatSessionCancelled?.(effectiveChatSessionId, true);
|
||||
mcpServerBridge.cancelPtyExecsForSession(effectiveChatSessionId);
|
||||
mcpServerBridge.cancelWorkerBackgroundJobsForSession?.(effectiveChatSessionId);
|
||||
@@ -1405,6 +1444,9 @@ function registerSdkStreamHandlers(ctx) {
|
||||
|
||||
ipcMain.handle("netcatty:ai:sdk-agent:cleanup", async (event, { chatSessionId }) => {
|
||||
if (!validateSender(event)) return { ok: false, error: "Unauthorized IPC sender" };
|
||||
// This legacy route has no per-request identity. Delegated lifecycle is
|
||||
// owned by main's exact-turn lease, never a delayed chat-wide UI cleanup.
|
||||
if (mcpServerBridge.isPluginAgentConversation?.(chatSessionId)) return { ok: false, error: "Delegated Agent cleanup is main-owned" };
|
||||
mcpServerBridge.setChatSessionCancelled?.(chatSessionId, true);
|
||||
mcpServerBridge.cancelPtyExecsForSession(chatSessionId);
|
||||
mcpServerBridge.cancelWorkerBackgroundJobsForSession?.(chatSessionId);
|
||||
|
||||
@@ -2192,3 +2192,111 @@ test("SDK adapter passes a live privacy guard through asynchronous driver startu
|
||||
assert.equal(sent, false);
|
||||
assert.equal(h.ctx.sdkActiveStreams.size, 0);
|
||||
});
|
||||
|
||||
test("delegated Agent entry rejects unverified runtimes and Skills before native/environment work", async () => {
|
||||
let validated = 0;
|
||||
const { handlers } = registerWithStubbedCtx({
|
||||
mcpServerBridge: {
|
||||
isPluginAgentConversation: () => true,
|
||||
assertPluginAgentCaller: () => {},
|
||||
getPluginAgentMcpConfiguration: () => { validated++; throw Error("No current delegation"); },
|
||||
},
|
||||
});
|
||||
const handler = handlers.get("netcatty:ai:sdk-agent:stream");
|
||||
for (const sdkBackend of ["codex", "copilot", "cursor", "codebuddy", "opencode", "grok", "mimo", "arbitrary-cli"]) {
|
||||
await assert.rejects(handler({ sender: { id: 7 } }, {
|
||||
requestId: "synthetic", chatSessionId: "plugin-agent:synthetic", sdkBackend, toolIntegrationMode: "mcp",
|
||||
}), /not eligible/);
|
||||
}
|
||||
await assert.rejects(handler({ sender: { id: 7 } }, {
|
||||
requestId: "synthetic", chatSessionId: "plugin-agent:synthetic", sdkBackend: "claude", toolIntegrationMode: "skills",
|
||||
}), /not eligible/);
|
||||
assert.equal(validated, 0);
|
||||
await assert.rejects(handler({ sender: { id: 7 } }, {
|
||||
requestId: "synthetic", chatSessionId: "plugin-agent:synthetic", sdkBackend: "claude", toolIntegrationMode: "mcp",
|
||||
}), /No current delegation/);
|
||||
assert.equal(validated, 1);
|
||||
});
|
||||
|
||||
test("actual SDK entry derives delegated mode from main authority, ignoring caller flag spoofing", async (t) => {
|
||||
const { PluginAgentMcpDelegations } = require("../../../plugins/agent/mcpDelegation.cjs");
|
||||
const registry = new PluginAgentMcpDelegations(); t.after(() => registry.shutdown());
|
||||
const chat = "plugin-agent:11111111-1111-4111-8111-111111111111";
|
||||
const tag = "22222222-2222-4222-8222-222222222222";
|
||||
registry.register(chat, { turnTag: tag, senderId: 7, tools: [], assertCurrent() {}, dispatch() {} });
|
||||
const h = createPrivacyStreamHarness(createTestPrivacy(false));
|
||||
const directory = fs.mkdtempSync(nodePath.join(os.tmpdir(), "netcatty-claude-flag-"));
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
|
||||
h.ctx.tempDirBridge = { getTempDir: () => directory };
|
||||
Object.assign(h.ctx.mcpServerBridge, {
|
||||
isPluginAgentConversation: (id) => registry.isReserved(id),
|
||||
assertPluginAgentCaller: (...args) => registry.assertCaller(...args),
|
||||
getPluginAgentMcpConfiguration: (...args) => registry.configuration(...args),
|
||||
onPluginAgentRelease: (...args) => registry.onRelease(...args),
|
||||
});
|
||||
const handler = h.handlers.get("netcatty:ai:sdk-agent:stream");
|
||||
const payload = { requestId: `plugin-agent_${tag}_fixture`, chatSessionId: chat, sdkBackend: "claude", toolIntegrationMode: "mcp", prompt: "Synthetic", delegatedAgent: false };
|
||||
assert.equal((await handler({ sender: { id: 7 } }, payload)).ok, true);
|
||||
assert.equal(h.turns[0].delegatedAgent, true, "caller cannot turn off delegated protection");
|
||||
assert.equal((await h.stream({ delegatedAgent: true })).ok, true);
|
||||
assert.equal(h.turns[1].delegatedAgent, false, "builtin caller cannot opt into the delegated branch");
|
||||
await assert.rejects(handler({ sender: { id: 99 } }, { ...payload, requestId: `plugin-agent_${tag}_foreign`, delegatedAgent: true }));
|
||||
assert.equal(h.turns.length, 2, "a spoofed flag cannot replace current sender/turn authority");
|
||||
});
|
||||
|
||||
test("delegated native cancellation is exact-request and cannot mark replacement chat work cancelled", async () => {
|
||||
const broad = [];
|
||||
const { handlers, ctx } = registerWithStubbedCtx({ mcpServerBridge: {
|
||||
isPluginAgentConversation: () => true,
|
||||
assertPluginAgentCaller: () => {},
|
||||
setChatSessionCancelled: () => broad.push('chat'), cancelPtyExecsForSession: () => broad.push('pty'),
|
||||
clearPendingApprovals: () => broad.push('approval'), cancelWorkerBackgroundJobsForSession: () => broad.push('worker'),
|
||||
} });
|
||||
const first = new AbortController(); const second = new AbortController();
|
||||
const chat = 'plugin-agent:synthetic';
|
||||
ctx.sdkActiveStreams.set('native-a', first); ctx.sdkRequestSessions.set('native-a', chat);
|
||||
ctx.sdkRequestRuntimes.set('native-a', { delegationTag: 'tag-a', senderId: 7 });
|
||||
ctx.sdkActiveStreams.set('native-b', second); ctx.sdkRequestSessions.set('native-b', chat);
|
||||
ctx.sdkRequestRuntimes.set('native-b', { delegationTag: 'tag-b', senderId: 7 });
|
||||
const cancel = handlers.get('netcatty:ai:sdk-agent:cancel');
|
||||
assert.equal((await cancel({ sender: { id: 7 } }, { requestId: 'native-a', chatSessionId: chat })).ok, true);
|
||||
assert.equal(first.signal.aborted, true); assert.equal(second.signal.aborted, false);
|
||||
assert.equal((await cancel({ sender: { id: 7 } }, { requestId: '', chatSessionId: chat })).ok, false);
|
||||
assert.equal((await cancel({ sender: { id: 99 } }, { requestId: 'native-b', chatSessionId: chat })).ok, false);
|
||||
assert.equal(second.signal.aborted, false); assert.deepEqual(broad, []);
|
||||
});
|
||||
|
||||
test("legacy chat-wide cleanup cannot abort replacement delegated turn B or remove its metadata", async () => {
|
||||
const effects = [];
|
||||
const { handlers, ctx } = registerWithStubbedCtx({ mcpServerBridge: {
|
||||
isPluginAgentConversation: (id) => id?.startsWith('plugin-agent:'),
|
||||
setChatSessionCancelled: () => effects.push('cancelled'), cancelPtyExecsForSession: () => effects.push('pty'),
|
||||
cancelWorkerBackgroundJobsForSession: () => effects.push('worker'), cleanupScopedMetadata: async () => effects.push('metadata'),
|
||||
} });
|
||||
const chat = 'plugin-agent:11111111-1111-4111-8111-111111111111';
|
||||
const replacement = new AbortController();
|
||||
ctx.sdkActiveStreams.set('native-b', replacement); ctx.sdkRequestSessions.set('native-b', chat);
|
||||
ctx.sdkRequestRuntimes.set('native-b', { backendKey: 'claude', delegationTag: 'turn-b', senderId: 7 });
|
||||
const result = await handlers.get('netcatty:ai:sdk-agent:cleanup')({ sender: { id: 7 } }, { chatSessionId: chat, delegationTag: 'turn-a' });
|
||||
assert.equal(replacement.signal.aborted, false); assert.equal(ctx.sdkActiveStreams.has('native-b'), true);
|
||||
assert.equal(ctx.sdkRequestSessions.get('native-b'), chat); assert.equal(ctx.sdkRequestRuntimes.get('native-b').delegationTag, 'turn-b');
|
||||
assert.deepEqual(effects, []); assert.equal(result.ok, false);
|
||||
});
|
||||
|
||||
test("caller-supplied builtin chat cannot downgrade cancellation of a main-owned delegated request", async () => {
|
||||
const effects = []; const chat = 'plugin-agent:11111111-1111-4111-8111-111111111111';
|
||||
const { handlers, ctx } = registerWithStubbedCtx({ mcpServerBridge: {
|
||||
isPluginAgentConversation: (id) => id?.startsWith('plugin-agent:'),
|
||||
assertPluginAgentCaller: (id, sender, tag) => { if (id !== chat || sender !== 7 || tag !== 'turn-b') throw Error('Unavailable'); },
|
||||
setChatSessionCancelled: () => effects.push('cancelled'), cancelPtyExecsForSession: () => effects.push('pty'),
|
||||
clearPendingApprovals: () => effects.push('approval'), cancelWorkerBackgroundJobsForSession: () => effects.push('worker'),
|
||||
} });
|
||||
const replacement = new AbortController();
|
||||
ctx.sdkActiveStreams.set('native-b', replacement); ctx.sdkRequestSessions.set('native-b', chat);
|
||||
ctx.sdkRequestRuntimes.set('native-b', { backendKey: 'claude', delegationTag: 'turn-b', senderId: 7 });
|
||||
const cancel = handlers.get('netcatty:ai:sdk-agent:cancel');
|
||||
const result = await cancel({ sender: { id: 99 } }, { requestId: 'native-b', chatSessionId: 'builtin-chat' });
|
||||
assert.equal(replacement.signal.aborted, false); assert.equal(result.ok, false); assert.deepEqual(effects, []);
|
||||
assert.equal((await cancel({ sender: { id: 7 } }, { requestId: 'native-b', chatSessionId: chat })).ok, true);
|
||||
assert.equal(replacement.signal.aborted, true);
|
||||
});
|
||||
@@ -10,6 +10,8 @@ const net = require("node:net");
|
||||
const crypto = require("node:crypto");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { PluginAgentMcpDelegations } = require("../plugins/agent/mcpDelegation.cjs");
|
||||
const pluginAgentDelegations = new PluginAgentMcpDelegations();
|
||||
const { existsSync } = require("node:fs");
|
||||
|
||||
const { toUnpackedAsarPath, getFreshIdlePrompt, formatSyntheticEcho } = require("./ai/shellUtils.cjs");
|
||||
@@ -1423,7 +1425,7 @@ function handleConnection(socket) {
|
||||
consumedUntil = newlineIdx + 1;
|
||||
searchFrom = consumedUntil;
|
||||
if (!line.trim()) continue;
|
||||
debugLog("Incoming line", line);
|
||||
debugLog("Incoming message", { bytes: Buffer.byteLength(line) });
|
||||
handleMessage(socket, line);
|
||||
}
|
||||
if (consumedUntil > 0) buffer = buffer.slice(consumedUntil);
|
||||
@@ -1455,7 +1457,8 @@ async function handleMessage(socket, line) {
|
||||
const isExternalToken = Boolean(
|
||||
presentedToken && externalAuthToken && presentedToken === externalAuthToken,
|
||||
);
|
||||
if (method === "auth/verify" && (isCattyToken || isExternalToken)) {
|
||||
const isDelegatedToken = method === "auth/verify" && pluginAgentDelegations.authenticate(presentedToken, socket);
|
||||
if (method === "auth/verify" && (isCattyToken || isExternalToken || isDelegatedToken)) {
|
||||
if (isExternalToken && !externalMcpActivityHook?.isEnabled?.()) {
|
||||
const response = JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
@@ -1496,6 +1499,13 @@ async function handleMessage(socket, line) {
|
||||
|
||||
try {
|
||||
const callParams = { ...(params || {}) };
|
||||
const delegatedChat = pluginAgentDelegations.boundChat(socket);
|
||||
if (delegatedChat) {
|
||||
if (callParams.chatSessionId && callParams.chatSessionId !== delegatedChat) throw Error("Agent connection cannot change its session");
|
||||
callParams.chatSessionId = delegatedChat;
|
||||
} else if (pluginAgentDelegations.isReserved(callParams.chatSessionId)) {
|
||||
throw Error("Delegated Agent credentials required");
|
||||
}
|
||||
// External-token sockets always operate under the reserved app-wide scope so
|
||||
// callers cannot omit chatSessionId and widen access via scopedSessionIds.
|
||||
if (externalMcpSockets.has(socket)) {
|
||||
@@ -1531,7 +1541,7 @@ async function handleMessage(socket, line) {
|
||||
managedPrivacyScopes.set(socket, callParams.chatSessionId);
|
||||
}
|
||||
result = await createManagedPrivacyBoundary(
|
||||
(rpcMethod, rpcParams, context) => dispatch(rpcMethod, rpcParams, context),
|
||||
(rpcMethod, rpcParams, context) => dispatch(rpcMethod, rpcParams, { ...context, externalSocket: socket }),
|
||||
aiPrivacyService,
|
||||
)(method, callParams);
|
||||
}
|
||||
@@ -1653,6 +1663,7 @@ const dispatchCapabilityRpc = createCapabilityRpcDispatcher({
|
||||
return invokeVaultAgentFn(...args);
|
||||
},
|
||||
evaluatePermissionWithGrants,
|
||||
isDelegatedApproval: (context) => pluginAgentDelegations.isApproved(context),
|
||||
get permissionMode() {
|
||||
return permissionMode;
|
||||
},
|
||||
@@ -1967,7 +1978,7 @@ function getWorkerJob(jobId, chatSessionId) {
|
||||
|
||||
const WORKER_JOB_NOT_FOUND_ERROR = "Background job not found";
|
||||
|
||||
async function handleWorkerJobPoll(params = {}) {
|
||||
async function handleWorkerJobPoll(params = {}, beforeExecute) {
|
||||
const { jobId, chatSessionId, scopedSessionIds } = params || {};
|
||||
if (!jobId) throw new Error("jobId is required");
|
||||
const job = getWorkerJob(jobId, chatSessionId || null);
|
||||
@@ -1979,7 +1990,7 @@ async function handleWorkerJobPoll(params = {}) {
|
||||
const scopeErr = validateSessionScope(job.sessionId, chatSessionId || null, scopedSessionIds);
|
||||
if (scopeErr) return { ok: false, error: scopeErr };
|
||||
}
|
||||
const result = await terminalWorkerManager.request("netcatty:ai:jobPoll", params, {});
|
||||
const result = await terminalWorkerManager.request("netcatty:ai:jobPoll", params, typeof beforeExecute === "function" ? { beforeExecute } : {});
|
||||
if (result?.ok === false && result.error === WORKER_JOB_NOT_FOUND_ERROR) {
|
||||
// The route is still here but the worker no longer knows the job (pruned or restarted).
|
||||
emitJobDiag("job-stop-miss", { jobId, chatSessionId: chatSessionId || null, op: "poll", reason: "worker-job-missing" });
|
||||
@@ -1990,7 +2001,7 @@ async function handleWorkerJobPoll(params = {}) {
|
||||
return result;
|
||||
}
|
||||
|
||||
async function handleWorkerJobStop(params = {}) {
|
||||
async function handleWorkerJobStop(params = {}, beforeExecute) {
|
||||
const { jobId, chatSessionId, scopedSessionIds } = params || {};
|
||||
if (!jobId) throw new Error("jobId is required");
|
||||
const job = getWorkerJob(jobId, chatSessionId || null);
|
||||
@@ -2001,7 +2012,7 @@ async function handleWorkerJobStop(params = {}) {
|
||||
if (Array.isArray(scopedSessionIds) && job.sessionId && !scopedSessionIds.includes(job.sessionId)) {
|
||||
return { ok: false, error: `Session "${job.sessionId}" is not in the current scope.` };
|
||||
}
|
||||
const result = await terminalWorkerManager.request("netcatty:ai:jobStop", params, {});
|
||||
const result = await terminalWorkerManager.request("netcatty:ai:jobStop", params, typeof beforeExecute === "function" ? { beforeExecute } : {});
|
||||
if (result?.ok === false && result.error === WORKER_JOB_NOT_FOUND_ERROR) {
|
||||
// The route is still here but the worker no longer knows the job (pruned or restarted).
|
||||
emitJobDiag("job-stop-miss", { jobId, chatSessionId: chatSessionId || null, op: "stop", reason: "worker-job-missing" });
|
||||
@@ -2148,7 +2159,17 @@ function getBuiltinRpcHandlerRegistry() {
|
||||
}
|
||||
|
||||
async function dispatch(method, params, approvalContext = {}) {
|
||||
debugLog("dispatch", { method, params, permissionMode });
|
||||
if (pluginAgentDelegations.isReserved(params?.chatSessionId)) {
|
||||
return pluginAgentDelegations.dispatch(method, params, approvalContext,
|
||||
(approved) => dispatchCore(method, params, approved));
|
||||
}
|
||||
return dispatchCore(method, params, approvalContext);
|
||||
}
|
||||
|
||||
async function dispatchCore(method, params, approvalContext = {}) {
|
||||
const delegated = pluginAgentDelegations.isApproved(approvalContext);
|
||||
if (delegated) debugLog("Delegated dispatch", { method });
|
||||
else debugLog("dispatch", { method, params, permissionMode });
|
||||
|
||||
if (!method.startsWith("netcatty/")) {
|
||||
const capabilityResult = await dispatchCapabilityRpc(method, params || {}, approvalContext);
|
||||
@@ -2164,7 +2185,8 @@ async function dispatch(method, params, approvalContext = {}) {
|
||||
pruneCompletedBackgroundJobs();
|
||||
|
||||
const approvalTarget = getApprovalTarget(params);
|
||||
const permission = evaluatePermissionWithGrants({
|
||||
const permission = delegated ? { allowed: !isChatSessionCancelled(params?.chatSessionId), requiresApproval: false }
|
||||
: evaluatePermissionWithGrants({
|
||||
rpcMethod: method,
|
||||
surface: CAPABILITY_SURFACES.BUILTIN,
|
||||
permissionMode,
|
||||
@@ -2266,10 +2288,10 @@ async function dispatch(method, params, approvalContext = {}) {
|
||||
return await handleWorkerJobStart(params, approvalContext.beforeExecute);
|
||||
}
|
||||
if (capability?.id === "terminal.poll" && workerBackgroundJobs.has(params?.jobId)) {
|
||||
return await handleWorkerJobPoll(params);
|
||||
return await handleWorkerJobPoll(params, approvalContext.beforeExecute);
|
||||
}
|
||||
if (capability?.id === "terminal.stop" && workerBackgroundJobs.has(params?.jobId)) {
|
||||
return await handleWorkerJobStop(params);
|
||||
return await handleWorkerJobStop(params, approvalContext.beforeExecute);
|
||||
}
|
||||
return await handler(params, approvalContext);
|
||||
} finally {
|
||||
@@ -2284,10 +2306,12 @@ async function dispatch(method, params, approvalContext = {}) {
|
||||
|
||||
// ── Handler: getContext ──
|
||||
|
||||
async function handleGetContext(params) {
|
||||
debugLog("handleGetContext:start", { params, sessionCount: sessions?.size || 0 });
|
||||
const toolHints = buildMcpToolHints();
|
||||
async function handleGetContext(params, approvalContext) {
|
||||
const delegated = pluginAgentDelegations.isApproved(approvalContext);
|
||||
debugLog("handleGetContext:start", delegated ? { delegated: true } : { params, sessionCount: sessions?.size || 0 });
|
||||
const toolHints = delegated ? null : buildMcpToolHints();
|
||||
if (!sessions) {
|
||||
if (delegated) return { environment: "netcatty-terminal", description: "No host-selected terminal sessions are available.", hosts: [], hostCount: 0 };
|
||||
return {
|
||||
hosts: [],
|
||||
instructions: "No sessions available.",
|
||||
@@ -2333,7 +2357,7 @@ async function handleGetContext(params) {
|
||||
description: "No hosts are available in the current scope.",
|
||||
hosts: [],
|
||||
hostCount: 0,
|
||||
tools: toolHints,
|
||||
...(delegated ? {} : { tools: toolHints }),
|
||||
};
|
||||
}
|
||||
for (const [sessionId, session] of sessions.entries()) {
|
||||
@@ -2383,6 +2407,12 @@ async function handleGetContext(params) {
|
||||
}
|
||||
}
|
||||
|
||||
if (pluginAgentDelegations.isApproved(approvalContext)) {
|
||||
// Do not query or serialize app-wide tunnel/tool inventory for a delegated
|
||||
// conversation. Its core-owned scope already selected the session metadata.
|
||||
return { environment: "netcatty-terminal", description: "Use only these host-selected session IDs.",
|
||||
hosts, hostCount: hosts.length };
|
||||
}
|
||||
let activePortForwardTunnels = [];
|
||||
try {
|
||||
activePortForwardTunnels = await listActivePortForwards();
|
||||
@@ -2532,12 +2562,35 @@ const configAndCleanupApi = createConfigAndCleanupApi({
|
||||
const { resolveMcpServerRuntimeCommand, buildMcpServerConfig, cleanupScopedMetadata } = configAndCleanupApi;
|
||||
|
||||
function cleanup() {
|
||||
pluginAgentDelegations.shutdown();
|
||||
try { disposeWorkerSessionClosed?.dispose?.(); } catch { /* ignore */ }
|
||||
disposeWorkerSessionClosed = null;
|
||||
shutdownHost();
|
||||
}
|
||||
|
||||
// Keep identifier exports so Node 22's CJS namespace analysis also preserves the
|
||||
// existing named exports consumed by renderer-side harness tests.
|
||||
const registerPluginAgentDelegation = (...args) => pluginAgentDelegations.register(...args);
|
||||
const getPluginAgentMcpConfiguration = (id, tag) => pluginAgentDelegations.configuration(id, tag);
|
||||
const isPluginAgentConversation = (id) => pluginAgentDelegations.isReserved(id);
|
||||
const assertPluginAgentCaller = (id, senderId, tag) => pluginAgentDelegations.assertCaller(id, senderId, tag);
|
||||
const onPluginAgentRelease = (id, tag, cancel) => pluginAgentDelegations.onRelease(id, tag, cancel);
|
||||
|
||||
const beginPluginAgentModelRequest = (id, senderId, tag) => pluginAgentDelegations.beginModelRequest(id, senderId, tag);
|
||||
function getPluginAgentJobTarget(jobId, chatSessionId) {
|
||||
if (!pluginAgentDelegations.isReserved(chatSessionId)) return null;
|
||||
const job = workerBackgroundJobs.get(jobId) ?? backgroundJobs.get(jobId);
|
||||
return job?.chatSessionId === chatSessionId && typeof job.sessionId === "string" ? job.sessionId : null;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getPluginAgentJobTarget,
|
||||
beginPluginAgentModelRequest,
|
||||
registerPluginAgentDelegation,
|
||||
getPluginAgentMcpConfiguration,
|
||||
isPluginAgentConversation,
|
||||
assertPluginAgentCaller,
|
||||
onPluginAgentRelease,
|
||||
init,
|
||||
setCommandBlocklist,
|
||||
getCommandBlocklist,
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
"use strict";
|
||||
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const net = require("node:net");
|
||||
const { randomUUID } = require("node:crypto");
|
||||
const { createNdjsonRpcClient } = require("../capabilities/rpcTransport.cjs");
|
||||
const { DelegatedAgentScopes } = require("../plugins/agent/delegatedScope.cjs");
|
||||
const { buildInjectedMcpServers } = require("./aiBridge/sdk/injectMcp.cjs");
|
||||
const bridge = require("./mcpServerBridge.cjs");
|
||||
const privacy = require("../shared/aiPrivacyService.cjs");
|
||||
|
||||
test("actual main TCP gate binds delegated credentials, scope and policy at worker dispatch without global mode changes", { timeout: 10_000 }, async (t) => {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-agent-mcp-test-"));
|
||||
const clients = []; const registrations = [];
|
||||
let effects = 0; let inventoryRequests = 0; let decisions = 0; let allowed = true; let targetRevision = "v1";
|
||||
let beforeWorker = () => {}; let authorize = async () => {};
|
||||
t.after(() => {
|
||||
for (const registration of registrations) registration.dispose();
|
||||
for (const client of clients) client.close();
|
||||
bridge.setPermissionMode("confirm"); bridge.cleanup();
|
||||
privacy.configure({ enabled: false });
|
||||
fs.rmSync(directory, { recursive: true, force: true });
|
||||
});
|
||||
privacy.configure({ enabled: false });
|
||||
bridge.init({ sessions: new Map(), cliDiscoveryFilePath: path.join(directory, "discovery.json"),
|
||||
terminalWorkerManager: { request: async (method, _payload, options) => {
|
||||
if (method !== "netcatty:ai:exec") { inventoryRequests++; return []; }
|
||||
beforeWorker(); options?.beforeExecute?.(); effects++;
|
||||
return { ok: true, stdout: "synthetic output", stderr: "", exitCode: 0 };
|
||||
} } });
|
||||
bridge.setPermissionMode("observer");
|
||||
const port = await bridge.getOrCreateHost();
|
||||
const coreId = `plugin-agent:${randomUUID()}`;
|
||||
const turnTag = randomUUID();
|
||||
const owner = Object.freeze({ runtimeId: "r1", pluginId: "com.example.assistant", privateOwner: "opaque-source-a", sourceKey: "source-a" });
|
||||
const context = {};
|
||||
const scopes = new DelegatedAgentScopes({ resolveOwner: async () => owner, assertHostActive: async () => {},
|
||||
authorizeAction: async () => { decisions++; await authorize(); if (!allowed) throw Error("Denied by source policy"); } });
|
||||
const target = { kind: "terminal", id: "synthetic-a", owner: "window-7", revision: "v1",
|
||||
isCurrent: (snapshot) => snapshot.revision === targetRevision };
|
||||
const scope = await scopes.issue(context, { backend: { key: "configured-claude", revision: "1", isCurrent: () => true },
|
||||
targets: [target], capabilities: ["terminal.execute", "session.environment"] });
|
||||
let ticket = await scopes.begin(scope.handle, context);
|
||||
registrations.push(bridge.registerPluginAgentDelegation(coreId, {
|
||||
senderId: 7, turnTag, tools: ["get_environment", "terminal_execute"],
|
||||
assertCurrent: () => { if (ticket.controller.signal.aborted || targetRevision !== target.revision) throw Error("Stale delegation"); },
|
||||
dispatch: (method, params, execute) => {
|
||||
const capabilityId = method === "netcatty/exec" ? "terminal.execute" : method === "netcatty/getContext" ? "session.environment" : "unsupported";
|
||||
const targets = params.sessionId ? [{ ...target, id: params.sessionId }] : [];
|
||||
return scopes.dispatch(ticket, context, { capabilityId, targets }, execute);
|
||||
},
|
||||
}));
|
||||
bridge.updateSessionMetadata([{ sessionId: target.id, hostname: "fixture.invalid", label: "Synthetic A", protocol: "ssh" }], coreId);
|
||||
const appToken = bridge.buildMcpServerConfig(port, [], coreId).env.find((entry) => entry.name === "NETCATTY_MCP_TOKEN").value;
|
||||
const injected = await buildInjectedMcpServers({ mcpServerBridge: bridge, chatSessionId: coreId, toolIntegrationMode: "mcp", delegationTag: turnTag });
|
||||
const environment = Object.fromEntries(injected[0].env.map((entry) => [entry.name, entry.value]));
|
||||
assert.notEqual(environment.NETCATTY_MCP_TOKEN, appToken);
|
||||
assert.equal(environment.NETCATTY_MCP_PERMISSION_MODE, "confirm");
|
||||
assert.deepEqual(JSON.parse(environment.NETCATTY_MCP_DELEGATED_TOOLS), ["get_environment", "terminal_execute"]);
|
||||
async function connect(token) {
|
||||
const socket = net.createConnection({ host: "127.0.0.1", port });
|
||||
await new Promise((resolve, reject) => { socket.once("connect", resolve); socket.once("error", reject); });
|
||||
const client = createNdjsonRpcClient({ socket }); clients.push(client);
|
||||
await client.call("auth/verify", { token }); return client;
|
||||
}
|
||||
const app = await connect(appToken);
|
||||
await assert.rejects(app.call("netcatty/getContext", { chatSessionId: coreId }), /Delegated Agent credentials/);
|
||||
const client = await connect(environment.NETCATTY_MCP_TOKEN);
|
||||
await assert.rejects(client.call("netcatty/getContext", { chatSessionId: "builtin-chat" }), /cannot change/);
|
||||
const selected = await client.call("netcatty/getContext", {});
|
||||
assert.deepEqual(Object.keys(selected).sort(), ["description", "environment", "hostCount", "hosts"]);
|
||||
assert.deepEqual(selected.hosts.map((item) => item.sessionId), ["synthetic-a"]);
|
||||
assert.equal(inventoryRequests, 0, "delegated context must not query app-wide tunnel inventory");
|
||||
await assert.rejects(client.call("netcatty/getStatus", {}), /outside the Agent delegation/);
|
||||
const run = (sessionId = target.id) => client.call("netcatty/exec", { sessionId, command: "printf synthetic" });
|
||||
const beforeDecision = decisions;
|
||||
assert.equal((await run()).stdout, "synthetic output");
|
||||
assert.equal(effects, 1); assert.equal(decisions, beforeDecision + 1); assert.equal(bridge.getPermissionMode(), "observer");
|
||||
await assert.rejects(run("unselected-session")); assert.equal(effects, 1);
|
||||
allowed = false; await assert.rejects(run(), /Denied by source policy/); assert.equal(effects, 1);
|
||||
allowed = true;
|
||||
const entered = new Promise((resolve) => { authorize = async () => { resolve(); await new Promise((done) => { authorize = done; }); }; });
|
||||
const pending = run(); await entered;
|
||||
scopes.finish(ticket); authorize(); await assert.rejects(pending); assert.equal(effects, 1);
|
||||
ticket = await scopes.begin(scope.handle, context); authorize = async () => {};
|
||||
beforeWorker = () => { targetRevision = "v2"; };
|
||||
await assert.rejects(run()); assert.equal(effects, 1);
|
||||
registrations[0].dispose();
|
||||
await assert.rejects(app.call("netcatty/getContext", { chatSessionId: coreId }));
|
||||
assert.throws(() => bridge.getPluginAgentMcpConfiguration(coreId));
|
||||
});
|
||||
|
||||
for (const sessions of [null, new Map()]) test(`delegated empty environment omits global tool inventory (${sessions === null ? "uninitialized" : "empty scope"})`, async (t) => {
|
||||
bridge.init({ sessions });
|
||||
const id = `plugin-agent:${randomUUID()}`; const tag = randomUUID();
|
||||
const registration = bridge.registerPluginAgentDelegation(id, { senderId: 7, turnTag: tag, tools: ["get_environment"],
|
||||
assertCurrent: () => {}, dispatch: (_method, _params, execute) => execute() });
|
||||
t.after(() => { registration.dispose(); bridge.cleanup(); });
|
||||
const value = await bridge.dispatchBuiltinRpc("netcatty/getContext", { chatSessionId: id }, { trustedAgentSenderId: 7, trustedAgentTurnTag: tag });
|
||||
assert.equal(Object.hasOwn(value, "tools"), false); assert.deepEqual(value.hosts, []);
|
||||
});
|
||||
|
||||
test("MCP bridge retains existing CJS namespace exports for actual Catty harness consumers", async () => {
|
||||
const namespace = await import("./mcpServerBridge.cjs");
|
||||
for (const name of ["init", "cleanup", "dispatchBuiltinRpc", "registerPluginAgentDelegation"]) assert.equal(typeof namespace[name], "function", name);
|
||||
});
|
||||
@@ -157,7 +157,9 @@ function createCapabilityRpcDispatcher(deps) {
|
||||
hostname: host?.hostname || "",
|
||||
};
|
||||
}
|
||||
const permission = evaluatePermissionWithGrants({
|
||||
const permission = deps.isDelegatedApproval?.(approvalContext)
|
||||
? { allowed: !isChatSessionCancelled(params?.chatSessionId), requiresApproval: false }
|
||||
: evaluatePermissionWithGrants({
|
||||
rpcMethod,
|
||||
surface,
|
||||
permissionMode: deps.permissionMode,
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
"use strict";
|
||||
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const vm = require("node:vm");
|
||||
|
||||
test("actual plugin broadcast skips destroyed WebContents while its BrowserWindow is still alive", () => {
|
||||
// Evaluate the actual registrar callback with narrow window fixtures. This
|
||||
// models the destruction-event ordering, not an installed Electron crash.
|
||||
const source = fs.readFileSync(path.join(__dirname, "../main/registerBridges.cjs"), "utf8");
|
||||
const callbacks = [...source.matchAll(/broadcast: \(channel, payload\) => \{([\s\S]*?)\n \},/g)];
|
||||
assert.equal(callbacks.length, 1);
|
||||
const received = [];
|
||||
const deadWindow = { isDestroyed: () => true, get webContents() { throw Error("Destroyed BrowserWindow getter"); } };
|
||||
const closingWindow = { isDestroyed: () => false, webContents: {
|
||||
isDestroyed: () => true, send() { throw Error("Object has been destroyed"); },
|
||||
} };
|
||||
const liveWindow = { isDestroyed: () => false, webContents: {
|
||||
isDestroyed: () => false, send: (...args) => received.push(args),
|
||||
} };
|
||||
const broadcast = vm.runInNewContext(`(channel, payload) => {${callbacks[0][1]}\n}`, {
|
||||
electronModule: { BrowserWindow: { getAllWindows: () => [deadWindow, closingWindow, liveWindow] } },
|
||||
});
|
||||
const payload = { host: [], session: [] };
|
||||
assert.doesNotThrow(() => broadcast("netcatty:plugins:scope-catalog-changed", payload));
|
||||
assert.deepEqual(received, [["netcatty:plugins:scope-catalog-changed", payload]]);
|
||||
});
|
||||
@@ -39,6 +39,10 @@ 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
|
||||
@@ -363,6 +367,7 @@ 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) {
|
||||
@@ -412,21 +417,84 @@ function clampWindowOpacity(opacity) {
|
||||
function applyWindowOpacityToWindow(win) {
|
||||
if (!win || win.isDestroyed?.()) return;
|
||||
try {
|
||||
win.setOpacity?.(currentWindowOpacity);
|
||||
win.setOpacity?.(isWindowBackgroundBlurActive() ? 1 : 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;
|
||||
}
|
||||
@@ -998,7 +1066,12 @@ const { createWindow } = mainWindowApi;
|
||||
const { createSettingsWindowApi } = require("./windowManager/settingsWindow.cjs");
|
||||
const settingsWindowApi = createSettingsWindowApi({
|
||||
get settingsWindow() { return settingsWindow; },
|
||||
set settingsWindow(value) { settingsWindow = value; },
|
||||
set settingsWindow(value) {
|
||||
settingsWindow = value;
|
||||
if (value) {
|
||||
applyWindowBackgroundChromeToWindow(value, { applyBackgroundColor: true });
|
||||
}
|
||||
},
|
||||
get mainWindow() { return mainWindow; },
|
||||
get currentTheme() { return currentTheme; },
|
||||
get isQuitting() { return isQuitting; },
|
||||
@@ -1187,21 +1260,12 @@ function registerWindowHandlers(ipcMain, nativeTheme) {
|
||||
? (nativeTheme?.shouldUseDarkColors ? "dark" : "light")
|
||||
: theme;
|
||||
const themeConfig = THEME_COLORS[effectiveTheme] || THEME_COLORS.light;
|
||||
forEachMainWindow((win) => win.setBackgroundColor(themeConfig.background));
|
||||
if (settingsWindow && !settingsWindow.isDestroyed()) {
|
||||
settingsWindow.setBackgroundColor(themeConfig.background);
|
||||
}
|
||||
applyWindowBackgroundColor(themeConfig.background);
|
||||
return true;
|
||||
});
|
||||
|
||||
ipcMain.handle("netcatty:setBackgroundColor", (_event, color) => {
|
||||
const normalized = normalizeBackgroundColor(color);
|
||||
if (!normalized) return false;
|
||||
forEachMainWindow((win) => win.setBackgroundColor(normalized));
|
||||
if (settingsWindow && !settingsWindow.isDestroyed()) {
|
||||
settingsWindow.setBackgroundColor(normalized);
|
||||
}
|
||||
return true;
|
||||
return applyWindowBackgroundColor(color);
|
||||
});
|
||||
|
||||
ipcMain.handle("netcatty:setWindowOpacity", (_event, opacity) => {
|
||||
@@ -1209,6 +1273,10 @@ 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");
|
||||
@@ -1290,6 +1358,7 @@ 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,11 +263,13 @@ 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,6 +139,7 @@ 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
|
||||
@@ -150,6 +151,7 @@ function createSettingsWindowApi(ctx) {
|
||||
frame: isMac,
|
||||
titleBarStyle: isMac ? "hiddenInset" : undefined,
|
||||
trafficLightPosition: isMac ? { x: 12, y: 12 } : undefined,
|
||||
...(isMac ? { visualEffectState: "active" } : {}),
|
||||
...windowsChrome,
|
||||
webPreferences: {
|
||||
preload,
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
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');
|
||||
}
|
||||
});
|
||||
@@ -61,7 +61,7 @@ const HARNESS_CAPABILITIES = [
|
||||
},
|
||||
{
|
||||
id: "harness.terminal.read_context",
|
||||
agentKinds: ["sidebar"],
|
||||
agentKinds: ["sidebar", "global"],
|
||||
domain: "harness",
|
||||
status: CAPABILITY_STATUS.IMPLEMENTED,
|
||||
description: "Read a bounded slice of the current terminal screen or scrollback from the active AI scope.",
|
||||
|
||||
@@ -240,7 +240,7 @@ function createBridgeRegistrar(context) {
|
||||
isTrustedSender: createTrustedPluginBridgeSender({ devServerUrl: effectiveDevServerUrl }),
|
||||
broadcast: (channel, payload) => {
|
||||
for (const window of electronModule.BrowserWindow.getAllWindows()) {
|
||||
if (!window.isDestroyed()) window.webContents.send(channel, payload);
|
||||
if (!window.isDestroyed() && !window.webContents.isDestroyed()) window.webContents.send(channel, payload);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -37,7 +37,12 @@ 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;
|
||||
@@ -52,17 +57,29 @@ 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) {
|
||||
socket.write(`${JSON.stringify({
|
||||
writeLine({
|
||||
jsonrpc: "2.0",
|
||||
id: msg.id,
|
||||
error: { code: -32001, message: "Authentication required. Send auth/verify with valid token first." },
|
||||
})}\n`);
|
||||
});
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
socket.write(`${JSON.stringify({ jsonrpc: "2.0", id: msg.id, result: { ok: true } })}\n`);
|
||||
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();
|
||||
return;
|
||||
}
|
||||
if (msg.method === "netcatty/getContext" && onGetContext) {
|
||||
@@ -72,11 +89,11 @@ function startBridge({ token, authDelayMs = 0, onGetContext = null }) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
socket.write(`${JSON.stringify({
|
||||
writeLine({
|
||||
jsonrpc: "2.0",
|
||||
id: msg.id,
|
||||
result: { ok: true, marker: token },
|
||||
})}\n`);
|
||||
});
|
||||
};
|
||||
if (msg.method === "auth/verify" && authDelayMs > 0) {
|
||||
setTimeout(respond, authDelayMs);
|
||||
|
||||
@@ -69,6 +69,7 @@ const CHAT_SESSION_ID = process.env.NETCATTY_MCP_CHAT_SESSION_ID || null;
|
||||
// External MCP clients may keep a long-lived stdio process; re-read discovery so
|
||||
// Settings → AI → Safety changes apply without restarting the client.
|
||||
function readPermissionMode() {
|
||||
if (process.env.NETCATTY_MCP_DELEGATED_TOOLS !== undefined) return "confirm";
|
||||
const discoveryPath = process.env.NETCATTY_EXTERNAL_MCP_DISCOVERY_FILE;
|
||||
if (discoveryPath) {
|
||||
try {
|
||||
@@ -122,19 +123,33 @@ function guardWriteOperation(command, { skipBlocklist = false } = {}) {
|
||||
}
|
||||
|
||||
let tcpSocket = null;
|
||||
let pendingRequests = new Map(); // id -> { resolve, reject }
|
||||
let pendingRequests = new Map(); // id -> { resolve, reject, sock }
|
||||
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 { reject: rej } of pendingRequests.values()) {
|
||||
rej(new Error(message));
|
||||
for (const [id, pending] of pendingRequests) {
|
||||
if (pending.sock !== sock) continue;
|
||||
pendingRequests.delete(id);
|
||||
pending.reject(new Error(message));
|
||||
}
|
||||
pendingRequests.clear();
|
||||
}
|
||||
|
||||
function connectTcp() {
|
||||
@@ -198,15 +213,16 @@ function connectTcp() {
|
||||
|
||||
function sendRpc(method, params) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!tcpSocket || tcpSocket.destroyed) {
|
||||
if (!isLiveSocket(tcpSocket)) {
|
||||
reject(new Error("Not connected to Netcatty"));
|
||||
return;
|
||||
}
|
||||
const sock = tcpSocket;
|
||||
const id = nextRpcId++;
|
||||
pendingRequests.set(id, { resolve, reject });
|
||||
pendingRequests.set(id, { resolve, reject, sock });
|
||||
const msg = JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n";
|
||||
debugLog("rpcCall", { id, method });
|
||||
tcpSocket.write(msg);
|
||||
sock.write(msg);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -246,7 +262,12 @@ function dropActiveSocket() {
|
||||
let connecting = null;
|
||||
|
||||
async function connectAndAuth() {
|
||||
if (tcpSocket?.destroyed) tcpSocket = null;
|
||||
// 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 = "";
|
||||
}
|
||||
refreshExternalEndpoint();
|
||||
tcpBuffer = "";
|
||||
try {
|
||||
@@ -263,8 +284,11 @@ async function connectAndAuth() {
|
||||
}
|
||||
|
||||
function ensureConnected() {
|
||||
if (tcpSocket && !tcpSocket.destroyed) return Promise.resolve();
|
||||
// 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 (connecting) return connecting;
|
||||
if (isLiveSocket(tcpSocket)) return Promise.resolve();
|
||||
connecting = connectAndAuth().finally(() => {
|
||||
connecting = null;
|
||||
});
|
||||
@@ -319,12 +343,20 @@ server.resource(
|
||||
);
|
||||
|
||||
// Start with core tools; load specialized catalog groups through load_netcatty_tools.
|
||||
registerProgressiveMcpTools(server, {
|
||||
const toolDependencies = {
|
||||
rpcCall: exposedRpcCall,
|
||||
scopeParams,
|
||||
guardWriteOperation,
|
||||
catalogDescription,
|
||||
});
|
||||
};
|
||||
if (process.env.NETCATTY_MCP_DELEGATED_TOOLS !== undefined) {
|
||||
const { listMcpTools } = require("../capabilities/codegen/toolSurfaces.cjs");
|
||||
const { registerMcpTools } = require("../capabilities/codegen/mcpToolRegistry.cjs");
|
||||
const names = JSON.parse(process.env.NETCATTY_MCP_DELEGATED_TOOLS);
|
||||
if (!Array.isArray(names) || names.length > 128 || names.some((name) => typeof name !== "string")) throw Error("Invalid delegated tool profile");
|
||||
const allowed = new Set(names);
|
||||
registerMcpTools(server, toolDependencies, listMcpTools().filter((tool) => allowed.has(tool.mcpTool)));
|
||||
} else registerProgressiveMcpTools(server, toolDependencies);
|
||||
|
||||
// ── Start ──
|
||||
|
||||
|
||||
@@ -0,0 +1,412 @@
|
||||
"use strict";
|
||||
|
||||
const { randomUUID } = require("node:crypto");
|
||||
const { PluginRpcError, RPC_ERRORS } = require("../rpcRouter.cjs");
|
||||
const { createDefinitionValidator } = require("../contractValidator.cjs");
|
||||
const { normalizePermissionDeclarations } = require("../permissionResources.cjs");
|
||||
const { PluginAISourceBinding } = require("../ai/sourceBinding.cjs");
|
||||
const { DelegatedAgentScopes, isRequestCancellation } = require("./delegatedScope.cjs");
|
||||
const { listMcpTools } = require("../../capabilities/codegen/toolSurfaces.cjs");
|
||||
const fail = (code) => new PluginRpcError(RPC_ERRORS[code], "Plugin Agent operation unavailable");
|
||||
const FEATURE = require("../generated/plugin-contract.schema.json").$defs.PluginAgentFeature.const;
|
||||
const CAPABILITIES = Object.freeze(require("../generated/plugin-contract.schema.json").$defs.PluginAgentCapabilities.const);
|
||||
const TOOLS = listMcpTools().filter((tool) => CAPABILITIES.includes(tool.capabilityId));
|
||||
const ROUTES = [
|
||||
["listBackends", "PluginAIEmptyParams", "PluginAgentBackendsResult"],
|
||||
["selectTargets", "PluginAIEmptyParams", "PluginAgentTargetsResult"],
|
||||
["listChats", "PluginAIEmptyParams", "PluginAgentChatsResult"],
|
||||
["openChat", "PluginAgentOpenParams", "PluginAgentChatResult"],
|
||||
["send", "PluginAgentSendParams", "PluginAgentTurnResult"],
|
||||
["read", "PluginAgentTurnRef", "PluginAgentEvent"],
|
||||
["history", "PluginAgentChatRef", "PluginAgentHistoryResult"],
|
||||
["stop", "PluginAgentTurnRef", "PluginAIBooleanResult"],
|
||||
["closeChat", "PluginAgentChatRef", "PluginAIBooleanResult"],
|
||||
];
|
||||
|
||||
class PluginAgentService {
|
||||
constructor(options) {
|
||||
this.source = new PluginAISourceBinding(options);
|
||||
this.database = options.database;
|
||||
this.host = options.hostAdapter;
|
||||
this.permission = options.permissionEngine;
|
||||
this.store = options.conversationStore;
|
||||
this.getSessionBinding = options.getSessionBinding;
|
||||
this.getMcpBridge = options.getMcpBridge;
|
||||
this.getTargetManager = options.getTargetManager ?? (() => null);
|
||||
this.clock = options.clock ?? Date.now;
|
||||
this.backends = new Map(); this.selections = new Map(); this.chats = new Map(); this.turns = new Map();
|
||||
this.budgets = new Map(); this.pendingOpens = new Set(); this.registrations = []; this.closed = false;
|
||||
this.pendingSelections = new Map(); this.startupTurns = new Map(); this.targetManager = null; this.targetSubscriptions = [];
|
||||
this.eventValidator = createDefinitionValidator("PluginAgentEvent");
|
||||
this.sessionValidator = createDefinitionValidator("PluginAgentHostSession");
|
||||
this.scopes = new DelegatedAgentScopes({ resolveOwner: (ctx) => this.#guard(ctx),
|
||||
assertHostActive: (ctx) => this.host.assertActive(ctx), clock: this.clock,
|
||||
authorizeAction: async (ctx, owner, action, signal) => {
|
||||
const decisionContext = { ...ctx, signal, permissionOperation: Object.freeze({}), sourceOwner: owner.privateOwner };
|
||||
await this.permission.authorize(decisionContext, {
|
||||
permission: "ai.agent.actions", resources: action.targets.length
|
||||
? action.targets.map((target) => `agent-target:${target.kind}:${target.id}:${target.owner}:${target.revision}`) : ["*"],
|
||||
reason: "Use a delegated Agent tool on selected resources", sessionId: ctx.agentChatId,
|
||||
displayContext: { modelLabel: ctx.agentBackendLabel, selections: action.targets.map((target) => ({
|
||||
title: ctx.agentTargetLabels.get(target.id) ?? target.id, scope: "session", version: target.revision,
|
||||
})), action: { capabilityId: action.capabilityId, text: ctx.agentToolPreview } },
|
||||
});
|
||||
} });
|
||||
this.revocation = this.permission.onDidRevoke((event) => this.releasePlugin(event.pluginId));
|
||||
this.hostInvalidation = this.host.onDidInvalidate(() => this.releaseAll());
|
||||
}
|
||||
|
||||
async #guard(context) {
|
||||
try {
|
||||
if (this.closed) throw fail("unavailable");
|
||||
const owner = await this.source.resolve(context);
|
||||
await this.host.assertActive(context); this.source.assertCurrent(owner);
|
||||
return owner;
|
||||
} catch (error) {
|
||||
const owner = this.source.bindings.get(context.runtimeId);
|
||||
if (!isRequestCancellation(error, context) && owner?.pluginId === context.pluginId
|
||||
&& owner.pluginVersion === context.pluginVersion) this.releaseRuntime(context.runtimeId);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
#assertNow(turn) {
|
||||
if (this.closed || turn.done || this.turns.get(turn.handle) !== turn || turn.chat.activeTurn !== turn
|
||||
|| turn.controller.signal.aborted || turn.chat.expiresAt <= this.clock()
|
||||
|| turn.ticket?.controller.signal.aborted
|
||||
|| this.host.generation !== turn.chat.hostGeneration) throw fail("cancelled");
|
||||
this.source.assertCurrent(turn.owner);
|
||||
if (this.host.getSenderId() !== turn.senderId) throw fail("cancelled");
|
||||
if (this.permission.getPolicyRevision(this.database.getActivePlugin(turn.owner.pluginId)) !== turn.policyRevision) throw fail("cancelled");
|
||||
for (const target of turn.chat.selection.targets) if (!target.isCurrent(target)) throw fail("cancelled");
|
||||
}
|
||||
|
||||
async #chat(params, context) {
|
||||
const owner = await this.#guard(context); const chat = this.chats.get(params.chatHandle);
|
||||
if (!chat || chat.owner !== owner || chat.expiresAt <= this.clock() || chat.hostGeneration !== this.host.generation) throw fail("permissionDenied");
|
||||
return chat;
|
||||
}
|
||||
async #turn(params, context) {
|
||||
const owner = await this.#guard(context); const turn = this.turns.get(params.turnHandle);
|
||||
if (!turn || turn.owner !== owner || turn.expiresAt <= this.clock()) throw fail("permissionDenied");
|
||||
return turn;
|
||||
}
|
||||
async #backendCurrent(backend, context) {
|
||||
if (backend.expiresAt <= this.clock() || backend.hostGeneration !== this.host.generation
|
||||
|| !(await this.host.requestAgent("agent.current", { hostHandle: backend.hostHandle }, context)).current) throw fail("failedPrecondition");
|
||||
}
|
||||
#prune() {
|
||||
for (const map of [this.backends, this.selections]) for (const [key, entry] of map) if (entry.expiresAt <= this.clock()) map.delete(key);
|
||||
for (const [key, turn] of this.turns) if (turn.expiresAt <= this.clock()) { this.#end(turn); this.turns.delete(key); }
|
||||
for (const [key, chat] of this.chats) if (chat.expiresAt <= this.clock()) {
|
||||
this.#forgetChat(chat); this.chats.delete(key);
|
||||
}
|
||||
}
|
||||
#observeTargets() {
|
||||
const manager = this.getTargetManager();
|
||||
if (manager === this.targetManager) return;
|
||||
for (const entry of this.targetSubscriptions) entry.dispose(); this.targetSubscriptions = [];
|
||||
this.targetManager = manager;
|
||||
const invalidate = () => {
|
||||
for (const controller of this.pendingSelections.keys()) controller.abort();
|
||||
for (const [key, chat] of this.chats) if (chat.selection.targets.some((target) => !target.isCurrent(target))) {
|
||||
this.#forgetChat(chat); this.chats.delete(key);
|
||||
}
|
||||
};
|
||||
invalidate();
|
||||
for (const method of ["onSessionOwned", "onSessionClosed", "onWorkerExit"]) {
|
||||
if (typeof manager?.[method] === "function") this.targetSubscriptions.push(manager[method](invalidate));
|
||||
}
|
||||
}
|
||||
#charge(owner) {
|
||||
const key = JSON.stringify([owner.sourceKey, owner.privateOwner]); const now = this.clock();
|
||||
for (const [id, entry] of this.budgets) if (now - entry.start >= 60_000) this.budgets.delete(id);
|
||||
const budget = this.budgets.get(key) ?? { start: now, requests: 0 };
|
||||
if (budget.requests >= 6 || (!this.budgets.has(key) && this.budgets.size >= 256)) throw fail("resourceExhausted");
|
||||
budget.requests++; this.budgets.set(key, budget);
|
||||
}
|
||||
|
||||
registerRpcCapabilities(registry) {
|
||||
const startupLifetime = async (_context, next) => next();
|
||||
startupLifetime.prepareAuthorization = (context) => {
|
||||
if (context.method !== "agent.send") return undefined;
|
||||
// Registry cleanup runs after its final authority check. Until then the
|
||||
// caller still owns startup cancellation because no handle was delivered.
|
||||
return () => {
|
||||
const turn = this.startupTurns.get(context.permissionOperation);
|
||||
if (!turn) return;
|
||||
try {
|
||||
if (!turn.handoffReady || context.signal.aborted) throw fail("cancelled");
|
||||
this.#assertNow(turn);
|
||||
} catch { this.#end(turn); }
|
||||
turn.detachStartup();
|
||||
};
|
||||
};
|
||||
this.registrations.push(registry.use(startupLifetime));
|
||||
for (const [operation, paramsType, resultType] of ROUTES) {
|
||||
const input = createDefinitionValidator(paramsType); const output = createDefinitionValidator(resultType);
|
||||
this.registrations.push(registry.registerRequest(`agent.${operation}`, async (params, context) => {
|
||||
const result = await this[operation](params, context); await this.#guard(context);
|
||||
if (!output(result)) throw fail("internal");
|
||||
if (operation === "send") {
|
||||
const turn = this.startupTurns.get(context.permissionOperation);
|
||||
if (!turn || turn.handle !== result.turnHandle) throw fail("cancelled");
|
||||
turn.handoffReady = true;
|
||||
}
|
||||
return result;
|
||||
}, { metadata: { capability: "plugin-agent", mutating: !["listBackends", "listChats", "history", "read"].includes(operation), permission: "ai.agent.use" },
|
||||
validateParams: (params) => { if (!input(params)) throw fail("invalidArgument"); return Object.freeze(structuredClone(params)); },
|
||||
authorization: async (params, context) => {
|
||||
await this.#guard(context);
|
||||
const chat = operation === "send" ? await this.#chat(params, context) : null;
|
||||
return { permission: "ai.agent.use", resources: ["*"], reason: "Use this plugin's configured global Agent conversation",
|
||||
...(operation !== "send" ? { deferDecision: true } : {}),
|
||||
...(chat ? { displayContext: { modelLabel: chat.backend.label,
|
||||
selections: chat.selection.items.map((item) => ({ title: item.title, scope: "session", version: "selected" })),
|
||||
action: { capabilityId: "agent.send", text: params.text } } } : {}),
|
||||
};
|
||||
},
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
async listBackends(_params, context) {
|
||||
const owner = await this.#guard(context); this.#prune();
|
||||
const result = await this.host.requestAgent("agent.backends", {}, context); await this.#guard(context);
|
||||
const items = [];
|
||||
for (const entry of result.backends) {
|
||||
let lease = [...this.backends.values()].find((item) => item.owner === owner && item.hostHandle === entry.hostHandle);
|
||||
if (!lease) {
|
||||
if (this.backends.size >= 256) throw fail("resourceExhausted");
|
||||
lease = { ...entry, backendRef: randomUUID(), owner, hostGeneration: this.host.generation, expiresAt: this.clock() + 300_000 };
|
||||
this.backends.set(lease.backendRef, lease);
|
||||
}
|
||||
const eligible = ["catty", "claude"].includes(entry.backend);
|
||||
items.push({ backendRef: lease.backendRef, backend: entry.backend, label: entry.label,
|
||||
status: eligible ? entry.status : entry.status === "unavailable" ? "unavailable" : "unsupported",
|
||||
reason: eligible || entry.status === "unavailable" ? entry.reason : "native-boundary-unverified" });
|
||||
}
|
||||
return { items };
|
||||
}
|
||||
|
||||
async selectTargets(_params, context) {
|
||||
const owner = await this.#guard(context); this.#prune();
|
||||
this.#observeTargets();
|
||||
if (this.selections.size >= 64) throw fail("resourceExhausted");
|
||||
const controller = new AbortController(); this.pendingSelections.set(controller, owner);
|
||||
let result;
|
||||
try {
|
||||
result = await this.host.requestAgent("agent.targets", {}, { ...context,
|
||||
signal: context.signal ? AbortSignal.any([controller.signal, context.signal]) : controller.signal });
|
||||
await this.#guard(context); controller.signal.throwIfAborted();
|
||||
} finally { this.pendingSelections.delete(controller); }
|
||||
const targets = result.targets.map((item) => {
|
||||
const binding = this.getSessionBinding(item.sessionId);
|
||||
if (!binding || !binding.isCurrent(binding)) throw fail("failedPrecondition");
|
||||
return Object.freeze({ ...binding, kind: "terminal", id: item.sessionId });
|
||||
});
|
||||
if (new Set(targets.map((target) => target.id)).size !== targets.length) throw fail("invalidArgument");
|
||||
const selectionHandle = randomUUID(); const expiresAt = this.clock() + 300_000;
|
||||
const items = result.targets.map((item) => ({ kind: "terminal", title: item.label }));
|
||||
this.selections.set(selectionHandle, { owner, targets, terminalSessions: structuredClone(result.targets), items, expiresAt });
|
||||
return { selectionHandle, expiresAt, items };
|
||||
}
|
||||
|
||||
async listChats(_params, context) {
|
||||
const owner = await this.#guard(context);
|
||||
const rows = await this.store.list(owner, () => this.#guard(context));
|
||||
return { items: rows.map((row) => ({ conversationRef: row.conversationRef, title: row.title, backend: row.agentId, updatedAt: row.updatedAt })) };
|
||||
}
|
||||
|
||||
async openChat(params, context) {
|
||||
const owner = await this.#guard(context); this.#prune();
|
||||
const backend = this.backends.get(params.backendRef); const selection = this.selections.get(params.selectionHandle);
|
||||
if (!backend || backend.owner !== owner || !["catty", "claude"].includes(backend.backend) || backend.status !== "supported"
|
||||
|| !selection || selection.owner !== owner || selection.expiresAt <= this.clock()) throw fail("permissionDenied");
|
||||
if (this.chats.size + this.pendingOpens.size >= 64 || [...this.chats.values()].filter((chat) => chat.owner === owner).length >= 8) throw fail("resourceExhausted");
|
||||
if (params.conversationRef && [...this.pendingOpens].some((item) => item.owner === owner && item.conversationRef === params.conversationRef)) throw fail("alreadyExists");
|
||||
const opening = { owner, conversationRef: params.conversationRef, active: true }; this.pendingOpens.add(opening); let scope;
|
||||
try {
|
||||
await this.#backendCurrent(backend, context);
|
||||
let record = params.conversationRef ? await this.store.read(owner, params.conversationRef, () => this.#guard(context)) : null;
|
||||
if (params.conversationRef && !record) throw fail("notFound");
|
||||
if (record && [...this.chats.values()].some((chat) => chat.owner === owner && chat.record.conversationRef === record.conversationRef)) throw fail("alreadyExists");
|
||||
const now = this.clock();
|
||||
record ??= { conversationRef: randomUUID(), id: `plugin-agent:${randomUUID()}`, title: "Global Agent chat", agentId: backend.backend,
|
||||
scope: { type: "global" }, messages: [], createdAt: now, updatedAt: now };
|
||||
// Durable conversation identity is conversationRef. A fresh runtime actor
|
||||
// never reuses revoked privacy/native/tool state when history is reopened.
|
||||
record.id = `plugin-agent:${randomUUID()}`;
|
||||
record.agentId = backend.backend; delete record.externalSessionId;
|
||||
const declared = normalizePermissionDeclarations(context.manifest).has("ai.agent.actions");
|
||||
const capabilities = declared && this.permission.getMode(context, "ai.agent.actions") !== "deny" ? CAPABILITIES : [];
|
||||
scope = await this.scopes.issue(context, { backend: { key: backend.backendRef, revision: backend.hostHandle,
|
||||
isCurrent: () => backend.expiresAt > this.clock() && backend.hostGeneration === this.host.generation }, targets: selection.targets, capabilities });
|
||||
if (!opening.active || await this.#guard(context) !== owner) throw fail("cancelled");
|
||||
await this.store.save(owner, record, async () => { if (!opening.active) throw fail("cancelled"); await this.#guard(context); });
|
||||
if (!opening.active || await this.#guard(context) !== owner) throw fail("cancelled");
|
||||
const chatHandle = randomUUID();
|
||||
this.chats.set(chatHandle, { handle: chatHandle, owner, backend, selection, record, scope, capabilities,
|
||||
expiresAt: Math.min(scope.expiresAt, selection.expiresAt, backend.expiresAt), hostGeneration: this.host.generation, activeTurn: null, generation: 0 });
|
||||
return { chatHandle, conversationRef: record.conversationRef, backendLabel: backend.label,
|
||||
expiresAt: this.chats.get(chatHandle).expiresAt };
|
||||
} catch (error) { if (scope) this.scopes.discard(scope.handle, owner); throw error; }
|
||||
finally { this.pendingOpens.delete(opening); }
|
||||
}
|
||||
|
||||
async #dispatchTool(turn, method, params, execute) {
|
||||
this.#assertNow(turn);
|
||||
if (++turn.toolCalls > 32) { this.#end(turn, "budget-exceeded"); throw fail("resourceExhausted"); }
|
||||
const spec = TOOLS.find((item) => item.rpcMethod === method);
|
||||
if (!spec || !turn.chat.capabilities.includes(spec.capabilityId)) throw fail("permissionDenied");
|
||||
let ids = [];
|
||||
if (spec.capabilityId === "session.environment") ids = turn.chat.selection.targets.map((target) => target.id);
|
||||
else if (["terminal.poll", "terminal.stop"].includes(spec.capabilityId)) {
|
||||
const id = this.getMcpBridge().getPluginAgentJobTarget(params.jobId, turn.chat.record.id);
|
||||
if (!id) throw fail("permissionDenied"); ids = [id];
|
||||
} else { if (typeof params.sessionId !== "string") throw fail("invalidArgument"); ids = [params.sessionId]; }
|
||||
const targets = ids.map((id) => {
|
||||
const target = turn.chat.selection.targets.find((item) => item.id === id);
|
||||
if (!target) throw fail("permissionDenied"); return target;
|
||||
});
|
||||
const preview = JSON.stringify(Object.fromEntries(Object.entries(params).filter(([key]) => !["chatSessionId", "scopedSessionIds"].includes(key))), null, 2);
|
||||
if (Buffer.byteLength(preview) > 16_384) throw fail("resourceExhausted");
|
||||
const ctx = { ...turn.context, agentChatId: turn.chat.record.id, agentBackendLabel: turn.chat.backend.label,
|
||||
agentTargetLabels: new Map(turn.chat.selection.terminalSessions.map((item) => [item.sessionId, item.label])), agentToolPreview: preview };
|
||||
try { await this.#backendCurrent(turn.chat.backend, ctx); }
|
||||
catch (error) { this.#end(turn, "unavailable"); throw error; }
|
||||
return this.scopes.dispatch(turn.ticket, ctx, { capabilityId: spec.capabilityId, targets }, async (execution) => {
|
||||
this.#assertNow(turn);
|
||||
// Ask may have waited while the configured backend was edited. Check the
|
||||
// trusted renderer's current revision again before the selected effect.
|
||||
try { await this.#backendCurrent(turn.chat.backend, ctx); }
|
||||
catch (error) { this.#end(turn, "unavailable"); throw error; }
|
||||
this.#assertNow(turn); const result = await execute(execution); this.#assertNow(turn); return result;
|
||||
});
|
||||
}
|
||||
|
||||
async send(params, context) {
|
||||
const chat = await this.#chat(params, context); const owner = chat.owner;
|
||||
this.#prune();
|
||||
if (!params.text.trim()) throw fail("invalidArgument");
|
||||
if (this.turns.size >= 256 || chat.activeTurn || Buffer.byteLength(params.text) > 16_384 || chat.record.messages.length > 198) throw fail("resourceExhausted");
|
||||
const controller = new AbortController(); const turn = { handle: randomUUID(), owner, chat, controller, context: { ...context, signal: controller.signal },
|
||||
done: false, reading: false, bytes: 0, toolCalls: 0, generation: ++chat.generation, expiresAt: this.clock() + 300_000, hostHandle: null,
|
||||
policyRevision: this.permission.getPolicyRevision(this.database.getActivePlugin(owner.pluginId)), senderId: this.host.getSenderId() };
|
||||
chat.activeTurn = turn; this.turns.set(turn.handle, turn);
|
||||
turn.context = { ...context, signal: controller.signal, assertActive: async () => this.#assertNow(turn) };
|
||||
const cancelStartup = () => this.#end(turn);
|
||||
turn.detachStartup = () => {
|
||||
context.signal?.removeEventListener("abort", cancelStartup);
|
||||
if (this.startupTurns.get(context.permissionOperation) === turn) this.startupTurns.delete(context.permissionOperation);
|
||||
};
|
||||
this.startupTurns.set(context.permissionOperation, turn);
|
||||
context.signal?.addEventListener("abort", cancelStartup, { once: true });
|
||||
try {
|
||||
context.signal?.throwIfAborted();
|
||||
this.#charge(owner); await this.#backendCurrent(chat.backend, context);
|
||||
turn.ticket = await this.scopes.begin(chat.scope.handle, context);
|
||||
turn.ticket.controller.signal.addEventListener("abort", () => this.#end(turn), { once: true });
|
||||
this.#assertNow(turn);
|
||||
const mcp = this.getMcpBridge();
|
||||
turn.registration = mcp.registerPluginAgentDelegation(chat.record.id, { turnTag: turn.handle, senderId: turn.senderId,
|
||||
tools: TOOLS.filter((tool) => chat.capabilities.includes(tool.capabilityId)).map((tool) => tool.mcpTool),
|
||||
assertCurrent: () => this.#assertNow(turn), dispatch: (method, payload, execute) => this.#dispatchTool(turn, method, payload, execute) });
|
||||
mcp.setChatSessionCancelled(chat.record.id, false);
|
||||
turn.timer = setTimeout(() => this.#end(turn, "budget-exceeded"), Math.max(1, Math.min(120_000, chat.expiresAt - this.clock()))); turn.timer.unref?.();
|
||||
const prior = structuredClone(chat.record);
|
||||
chat.record.messages.push({ id: randomUUID(), role: "user", content: params.text, timestamp: this.clock() });
|
||||
chat.record.updatedAt = this.clock();
|
||||
if (chat.record.title === "Global Agent chat") chat.record.title = params.text.trim().split(/\r?\n/u)[0].slice(0, 80);
|
||||
prior.title = chat.record.title;
|
||||
await this.store.save(owner, chat.record, async () => { await this.#guard(context); this.#assertNow(turn); });
|
||||
const result = await this.host.requestAgent("agent.run", { backendHandle: chat.backend.hostHandle, delegationTag: turn.handle,
|
||||
session: prior, capabilities: chat.capabilities, terminalSessions: chat.selection.terminalSessions, text: params.text, maxIterations: 12 }, turn.context);
|
||||
this.#assertNow(turn); turn.hostHandle = result.hostHandle;
|
||||
return { turnHandle: turn.handle, expiresAt: turn.expiresAt };
|
||||
} catch (error) { this.#end(turn); throw error; }
|
||||
}
|
||||
|
||||
async read(params, context) {
|
||||
const turn = await this.#turn(params, context);
|
||||
if (turn.done) return turn.endEvent;
|
||||
if (turn.reading) throw fail("alreadyExists"); turn.reading = true;
|
||||
try {
|
||||
this.#assertNow(turn);
|
||||
await this.#backendCurrent(turn.chat.backend, context);
|
||||
const result = await this.host.requestAgent("agent.next", { hostHandle: turn.hostHandle },
|
||||
{ ...context, signal: AbortSignal.any([context.signal ?? new AbortController().signal, turn.controller.signal]) });
|
||||
this.#assertNow(turn);
|
||||
if (result.session) {
|
||||
if (!this.sessionValidator(result.session) || result.session.id !== turn.chat.record.id
|
||||
|| result.session.conversationRef !== turn.chat.record.conversationRef || result.session.agentId !== turn.chat.backend.backend) throw fail("internal");
|
||||
turn.chat.record = structuredClone(result.session);
|
||||
await this.store.save(turn.owner, turn.chat.record, async () => { await this.#guard(context); this.#assertNow(turn); });
|
||||
}
|
||||
const event = result.done ? { kind: "end", reason: "completed" } : result.event;
|
||||
if (!this.eventValidator(event)) throw fail("internal");
|
||||
turn.bytes += Buffer.byteLength(JSON.stringify(event));
|
||||
if (turn.bytes > 262_144) throw fail("resourceExhausted");
|
||||
if (event.kind === "end") this.#end(turn, event.reason);
|
||||
return event;
|
||||
} catch (error) { if (turn.done) return turn.endEvent; this.#end(turn, error.code === RPC_ERRORS.resourceExhausted ? "budget-exceeded" : "unavailable"); throw error; }
|
||||
finally { turn.reading = false; }
|
||||
}
|
||||
|
||||
async history(params, context) {
|
||||
const chat = await this.#chat(params, context); const items = []; let bytes = 0;
|
||||
for (const message of [...chat.record.messages].reverse()) {
|
||||
if (!["user", "assistant"].includes(message?.role) || typeof message.content !== "string") continue;
|
||||
const text = message.content.slice(0, 32_768); bytes += Buffer.byteLength(text);
|
||||
if (bytes > 131_072) break; items.unshift({ role: message.role, text });
|
||||
}
|
||||
return { items };
|
||||
}
|
||||
|
||||
#end(turn, reason = "cancelled") {
|
||||
if (turn.done) return;
|
||||
const wasCurrent = turn.chat.activeTurn === turn;
|
||||
turn.done = true; turn.detachStartup?.(); turn.endEvent = { kind: "end", reason }; clearTimeout(turn.timer); turn.controller.abort();
|
||||
if (turn.ticket) this.scopes.finish(turn.ticket);
|
||||
turn.registration?.dispose();
|
||||
if (wasCurrent) {
|
||||
turn.chat.activeTurn = null;
|
||||
try { this.getMcpBridge().applyChatSessionCancelled(turn.chat.record.id, true); } catch {}
|
||||
}
|
||||
if (turn.hostHandle) void this.host.requestAgent("agent.release", { hostHandle: turn.hostHandle },
|
||||
// Cleanup authenticates the owning source, independently of the ended
|
||||
// turn or its completed Send request. The host still checks app lifetime.
|
||||
{ ...turn.context, signal: undefined, assertActive: async () => this.source.assertCurrent(turn.owner) }).catch(() => {});
|
||||
}
|
||||
async stop(params, context) { const turn = await this.#turn(params, context); this.#end(turn); return { ok: true }; }
|
||||
async closeChat(params, context) {
|
||||
const chat = await this.#chat(params, context);
|
||||
this.#forgetChat(chat); this.chats.delete(chat.handle); return { ok: true };
|
||||
}
|
||||
#forgetChat(chat) {
|
||||
if (chat.activeTurn) this.#end(chat.activeTurn);
|
||||
this.scopes.discard(chat.scope.handle, chat.owner);
|
||||
try { this.host.releaseAgentChat?.(chat.record.id, chat.owner.runtimeId); } catch {}
|
||||
try { void this.getMcpBridge().cleanupScopedMetadata?.(chat.record.id)?.catch(() => {}); } catch {}
|
||||
}
|
||||
releaseRuntime(runtimeId) {
|
||||
for (const [controller, owner] of this.pendingSelections) if (owner.runtimeId === runtimeId) controller.abort();
|
||||
for (const turn of this.turns.values()) if (turn.owner.runtimeId === runtimeId) this.#end(turn);
|
||||
for (const chat of this.chats.values()) if (chat.owner.runtimeId === runtimeId) this.#forgetChat(chat);
|
||||
for (const opening of this.pendingOpens) if (opening.owner.runtimeId === runtimeId) opening.active = false;
|
||||
for (const map of [this.backends, this.selections, this.chats, this.turns]) for (const [id, entry] of map) if (entry.owner.runtimeId === runtimeId) map.delete(id);
|
||||
this.scopes.releaseRuntime(runtimeId); this.source.releaseRuntime(runtimeId); this.host.releaseRuntime(runtimeId);
|
||||
}
|
||||
releasePlugin(pluginId) {
|
||||
const ids = new Set([...this.source.bindings.values()].filter((owner) => owner.pluginId === pluginId).map((owner) => owner.runtimeId));
|
||||
for (const id of ids) this.releaseRuntime(id);
|
||||
}
|
||||
releaseAll() { for (const id of [...this.source.bindings.keys()]) this.releaseRuntime(id); }
|
||||
shutdown() {
|
||||
this.releaseAll(); this.closed = true; this.scopes.shutdown(); this.source.shutdown(); this.revocation.dispose(); this.hostInvalidation();
|
||||
for (const item of this.registrations) item.dispose(); this.host.shutdown();
|
||||
for (const item of this.targetSubscriptions) item.dispose(); this.targetSubscriptions = [];
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { PluginAgentService, PLUGIN_AGENT_FEATURE: FEATURE, PLUGIN_AGENT_CAPABILITIES: CAPABILITIES };
|
||||
@@ -0,0 +1,241 @@
|
||||
"use strict";
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { randomUUID } = require("node:crypto");
|
||||
const { PluginAgentService } = require("./agentService.cjs");
|
||||
const { PluginAgentConversationStore } = require("./conversationStore.cjs");
|
||||
const { PluginAgentMcpDelegations } = require("./mcpDelegation.cjs");
|
||||
const { PluginPermissionEngine } = require("../permissionEngine.cjs");
|
||||
const { PluginHostRpcRegistry } = require("../hostRpcRegistry.cjs");
|
||||
|
||||
function deferred() { let resolve; const promise = new Promise((done) => { resolve = done; }); return { promise, resolve }; }
|
||||
function fixture(t) {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-agent-service-"));
|
||||
const manifest = { id: "com.example.agent", publisher: "example", version: "1.0.0", permissions: { required: ["ai.agent.use", "ai.agent.actions"] } };
|
||||
let identity = { pluginId: manifest.id, pluginVersion: manifest.version, runtimeId: "r1", runtimeKind: "browser", manifest };
|
||||
let sourceId = "source-a"; let time = 1; let locked = false; const modes = new Map(); let focus = "a"; let effects = 0;
|
||||
const audits = []; const decisions = []; const hostRequests = []; const releasedHandles = []; const invalidations = new Set();
|
||||
const plugin = () => ({ id: manifest.id, enabled: true, activeVersion: manifest.version, manifest,
|
||||
source: { id: sourceId, kind: "third-party", origin: "https://synthetic.invalid" }, runtime: { status: "running" } });
|
||||
const database = { getActivePlugin: (id) => id === manifest.id ? plugin() : null,
|
||||
getRetainedPluginSource: () => plugin().source, privateOwner: () => `opaque-owner:${sourceId}`,
|
||||
publicPluginId: (owner) => owner === `opaque-owner:${sourceId}` ? manifest.id : null,
|
||||
getPermissionMode: (_id, permission) => modes.get(permission) ?? "allow",
|
||||
listPermissionGrants: () => [], recordSecurityAudit: (_id, event, value) => audits.push({ event, ...value }) };
|
||||
let approve = async (request) => ({ requestId: request.requestId, decision: "allow", scope: "once" });
|
||||
const permission = new PluginPermissionEngine({ database, requestDecision: (request) => { decisions.push(request); return approve(request); } });
|
||||
const targetState = new Map([["a", { owner: "7", revision: "worker-a:1" }], ["b", { owner: "8", revision: "worker-a:2" }]]);
|
||||
const backendHandle = randomUUID(); const externalHandle = randomUUID();
|
||||
const sessions = new Map(); const events = [];
|
||||
let run = async (payload) => { const handle = randomUUID(); sessions.set(handle, structuredClone(payload.session)); return { hostHandle: handle }; };
|
||||
let current = true;
|
||||
let choose = async () => ({ targets: [...targetState.keys()].map((id) => ({ sessionId: id, hostname: `${id}.invalid`, label: `Synthetic ${id}`, connected: true })) });
|
||||
const resourceListeners = new Set();
|
||||
const watch = (callback) => { resourceListeners.add(callback); return { dispose: () => resourceListeners.delete(callback) }; };
|
||||
const targetManager = { onSessionOwned: watch, onSessionClosed: watch, onWorkerExit: watch };
|
||||
const host = { generation: 1, getSenderId: () => 7,
|
||||
assertActive: async (context) => { await context.assertActive(); if (locked) throw Error("Locked"); },
|
||||
onDidInvalidate: (callback) => { invalidations.add(callback); return () => invalidations.delete(callback); },
|
||||
requestAgent: async (kind, payload, context) => {
|
||||
hostRequests.push({ kind, payload: structuredClone(payload) });
|
||||
if (kind === "agent.backends") return { backends: [
|
||||
{ hostHandle: backendHandle, backend: "catty", label: "Catty fixture", status: "supported", reason: "ready" },
|
||||
{ hostHandle: externalHandle, backend: "claude", label: "Claude fixture", status: "supported", reason: "ready" },
|
||||
{ hostHandle: randomUUID(), backend: "codex", label: "Codex fixture", status: "supported", reason: "ready" },
|
||||
] };
|
||||
if (kind === "agent.current") return { current };
|
||||
if (kind === "agent.targets") return choose();
|
||||
if (kind === "agent.run") return run(payload);
|
||||
if (kind === "agent.next") return { done: false, event: events.shift() ?? { kind: "end", reason: "completed" }, session: sessions.get(payload.hostHandle) };
|
||||
if (kind === "agent.release") { await context.assertActive(); releasedHandles.push(payload.hostHandle); }
|
||||
return { ok: true };
|
||||
}, releaseRuntime: () => {}, shutdown: () => {} };
|
||||
const delegation = new PluginAgentMcpDelegations();
|
||||
const mcp = { registerPluginAgentDelegation: (...args) => delegation.register(...args),
|
||||
setChatSessionCancelled: () => {}, applyChatSessionCancelled: () => {}, getPluginAgentJobTarget: () => null };
|
||||
const service = new PluginAgentService({ database, permissionEngine: permission, hostAdapter: host,
|
||||
conversationStore: new PluginAgentConversationStore(directory), getRuntimeIdentity: () => identity, getMcpBridge: () => mcp, clock: () => time,
|
||||
getTargetManager: () => targetManager,
|
||||
getSessionBinding: (id) => { const snapshot = targetState.get(id); return snapshot && { ...snapshot,
|
||||
isCurrent: (target) => targetState.get(id)?.owner === target.owner && targetState.get(id)?.revision === target.revision }; } });
|
||||
const registry = new PluginHostRpcRegistry(); registry.use(permission.createMiddleware()); service.registerRpcCapabilities(registry);
|
||||
const call = (method, params = {}, overrides = {}) => registry.createRoutes(identity).requestHandlers[`agent.${method}`](params, { signal: new AbortController().signal, ...overrides });
|
||||
const open = async (backend = "catty", conversationRef) => {
|
||||
const backends = await call("listBackends"); const selection = await call("selectTargets");
|
||||
return call("openChat", { backendRef: backends.items.find((item) => item.backend === backend).backendRef, selectionHandle: selection.selectionHandle, ...(conversationRef ? { conversationRef } : {}) });
|
||||
};
|
||||
const dispatch = async (turn, id = "a") => {
|
||||
const internal = service.turns.get(turn.turnHandle); if (!internal) throw Error("Turn unavailable");
|
||||
return delegation.dispatch("netcatty/exec", { chatSessionId: internal.chat.record.id, sessionId: id, command: "printf synthetic" },
|
||||
{ trustedAgentSenderId: 7, trustedAgentTurnTag: turn.turnHandle }, (context) => { context.beforeExecute(); effects++; return "synthetic"; });
|
||||
};
|
||||
t.after(() => { service.shutdown(); delegation.shutdown(); permission.shutdown(); fs.rmSync(directory, { recursive: true, force: true }); });
|
||||
return { service, call, open, dispatch, events, sessions, hostRequests, releasedHandles, decisions, audits, host, delegation, targetState,
|
||||
effects: () => effects, focusTo: (id) => { focus = id; }, focus: () => focus, modeTo: (value, permission = "ai.agent.actions") => { modes.set(permission, value); },
|
||||
approve: (fn) => { approve = fn; }, run: (fn) => { run = fn; }, tick: (ms) => { time += ms; },
|
||||
choose: (fn) => { choose = fn; }, resourceChanged: () => { for (const callback of resourceListeners) callback(); },
|
||||
replaceSource: () => { sourceId = "source-b"; }, currentTo: (value) => { current = value; },
|
||||
restart: () => { service.releaseRuntime(identity.runtimeId); identity = { ...identity, runtimeId: randomUUID() }; },
|
||||
lock: () => { locked = true; host.generation++; for (const callback of invalidations) callback(); } };
|
||||
}
|
||||
|
||||
test("registered Agent routes validate opaque authority, report unsupported modes and preserve core-only native history", async (t) => {
|
||||
const f = fixture(t); const backends = await f.call("listBackends");
|
||||
assert.deepEqual(f.hostRequests.map((request) => request.kind), ["agent.backends"], "public backend listing never starts native model discovery");
|
||||
await assert.rejects(f.call("listBackends", { delegatedAgent: true }));
|
||||
assert.equal(backends.items.find((entry) => entry.backend === "codex").status, "unsupported");
|
||||
await assert.rejects(f.call("openChat", { backendRef: randomUUID(), selectionHandle: randomUUID(), permissionMode: "auto" }));
|
||||
const chat = await f.open(); const turn = await f.call("send", { chatHandle: chat.chatHandle, text: "Synthetic conversation" });
|
||||
await assert.rejects(f.call("send", { chatHandle: chat.chatHandle, text: "Spoof delegated mode", delegatedAgent: false }));
|
||||
const [handle, session] = [...f.sessions][0];
|
||||
session.messages = [{ role: "user", content: "Synthetic conversation" }, { role: "assistant", content: "Synthetic answer" }];
|
||||
session.externalSessionId = "NATIVE_PRIVATE_SESSION"; f.events.push({ kind: "text", text: "Synthetic answer" });
|
||||
assert.deepEqual(await f.call("read", { turnHandle: turn.turnHandle }), { kind: "text", text: "Synthetic answer" });
|
||||
await f.call("read", { turnHandle: turn.turnHandle });
|
||||
assert.deepEqual(await f.call("history", { chatHandle: chat.chatHandle }), { items: [
|
||||
{ role: "user", text: "Synthetic conversation" }, { role: "assistant", text: "Synthetic answer" } ] });
|
||||
const summaries = await f.call("listChats"); assert.doesNotMatch(JSON.stringify({ chat, turn, summaries }), /NATIVE_|opaque-owner|source-a|plugin-agent:/);
|
||||
await f.call("closeChat", { chatHandle: chat.chatHandle });
|
||||
const reopened = await f.open("claude", chat.conversationRef);
|
||||
assert.equal(f.service.chats.get(reopened.chatHandle).record.externalSessionId, undefined);
|
||||
assert.equal((await f.call("history", { chatHandle: reopened.chatHandle })).items.length, 2);
|
||||
assert.ok(handle); f.restart(); f.replaceSource();
|
||||
assert.deepEqual(await f.call("listChats"), { items: [] });
|
||||
await assert.rejects(f.open("catty", chat.conversationRef));
|
||||
});
|
||||
|
||||
test("actual source policy decides once per tool and targets never follow focus across workspaces", async (t) => {
|
||||
const f = fixture(t); f.modeTo("ask"); const chat = await f.open();
|
||||
const turn = await f.call("send", { chatHandle: chat.chatHandle, text: "Synthetic action" }); f.focusTo("unselected");
|
||||
assert.equal(await f.dispatch(turn, "b"), "synthetic"); assert.equal(f.effects(), 1); assert.equal(f.decisions.length, 1);
|
||||
assert.equal(f.decisions[0].permission, "ai.agent.actions"); assert.equal(f.decisions[0].displayContext.action.capabilityId, "terminal.execute");
|
||||
await assert.rejects(f.dispatch(turn, f.focus())); assert.equal(f.effects(), 1); assert.equal(f.decisions.length, 1);
|
||||
f.approve(async (request) => ({ requestId: request.requestId, decision: "deny" }));
|
||||
await assert.rejects(f.dispatch(turn)); assert.equal(f.effects(), 1); assert.equal(f.decisions.length, 2);
|
||||
const entered = deferred(); const resume = deferred();
|
||||
f.approve(async (request) => { entered.resolve(); await resume.promise; return { requestId: request.requestId, decision: "allow", scope: "once" }; });
|
||||
const pending = f.dispatch(turn); const rejection = assert.rejects(pending); await entered.promise;
|
||||
f.targetState.set("a", { owner: "new-window", revision: "new-generation" }); resume.resolve(); await rejection;
|
||||
assert.equal(f.effects(), 1);
|
||||
});
|
||||
|
||||
test("a duplicate owned-history Open cannot replace or cancel an active core chat", async (t) => {
|
||||
const f = fixture(t); const chat = await f.open();
|
||||
const turn = await f.call("send", { chatHandle: chat.chatHandle, text: "Synthetic active chat" });
|
||||
const active = f.service.turns.get(turn.turnHandle);
|
||||
await assert.rejects(f.open("catty", chat.conversationRef), { code: -32006 });
|
||||
assert.equal(f.service.chats.size, 1); assert.equal(active.chat.activeTurn, active);
|
||||
assert.equal(active.controller.signal.aborted, false); assert.equal(await f.dispatch(turn), "synthetic");
|
||||
});
|
||||
|
||||
test("backend revision changed during Ask cannot execute the already selected target without any pending event read", async (t) => {
|
||||
const f = fixture(t); f.modeTo("ask"); const chat = await f.open();
|
||||
const turn = await f.call("send", { chatHandle: chat.chatHandle, text: "Synthetic pending approval" });
|
||||
const internal = f.service.turns.get(turn.turnHandle); const native = new AbortController();
|
||||
f.delegation.onRelease(internal.chat.record.id, turn.turnHandle, () => native.abort());
|
||||
const entered = deferred(); const resume = deferred();
|
||||
f.approve(async (request) => { entered.resolve(); await resume.promise; return { requestId: request.requestId, decision: "allow", scope: "once" }; });
|
||||
const pending = f.dispatch(turn); await entered.promise;
|
||||
const checksBeforeApproval = f.hostRequests.filter((item) => item.kind === "agent.current").length;
|
||||
f.currentTo(false); resume.resolve();
|
||||
const result = await pending.then(() => "executed", () => "rejected");
|
||||
assert.equal(f.effects(), 0); assert.equal(result, "rejected");
|
||||
assert.ok(f.hostRequests.filter((item) => item.kind === "agent.current").length > checksBeforeApproval);
|
||||
assert.equal(native.signal.aborted, true); assert.equal(internal.controller.signal.aborted, true);
|
||||
assert.equal(f.decisions.length, 1);
|
||||
});
|
||||
|
||||
test("empty-argument environment Ask carries the canonical host operation and selected-target disclosure", async (t) => {
|
||||
const f = fixture(t); f.choose(async () => ({ targets: [] })); f.modeTo("ask");
|
||||
const chat = await f.open(); const turn = await f.call("send", { chatHandle: chat.chatHandle, text: "Synthetic environment" });
|
||||
const internal = f.service.turns.get(turn.turnHandle);
|
||||
await f.delegation.dispatch("netcatty/getContext", { chatSessionId: internal.chat.record.id },
|
||||
{ trustedAgentSenderId: 7, trustedAgentTurnTag: turn.turnHandle }, () => ({ hosts: [], hostCount: 0 }));
|
||||
assert.equal(f.decisions.length, 1);
|
||||
assert.deepEqual(f.decisions[0].displayContext.action, { capabilityId: "session.environment", text: "{}" });
|
||||
assert.deepEqual(f.decisions[0].displayContext.selections, []);
|
||||
});
|
||||
|
||||
test("Actions Deny keeps model conversation available with no tools; Agent-use Deny prevents host dispatch", async (t) => {
|
||||
const f = fixture(t); f.modeTo("deny"); const chat = await f.open();
|
||||
const turn = await f.call("send", { chatHandle: chat.chatHandle, text: "Synthetic model-only intent" });
|
||||
const internal = f.service.turns.get(turn.turnHandle);
|
||||
assert.deepEqual(f.delegation.configuration(internal.chat.record.id, turn.turnHandle).tools, []);
|
||||
await assert.rejects(f.dispatch(turn)); assert.equal(f.effects(), 0); assert.equal(f.decisions.length, 0);
|
||||
await f.call("stop", { turnHandle: turn.turnHandle });
|
||||
f.modeTo("deny", "ai.agent.use"); const count = f.hostRequests.length;
|
||||
await assert.rejects(f.call("send", { chatHandle: chat.chatHandle, text: "Denied" })); assert.equal(f.hostRequests.length, count);
|
||||
});
|
||||
|
||||
test("source replacement, lock and backend changes revoke native work and reject late events", async (t) => {
|
||||
for (const kind of ["source", "lock", "backend"]) {
|
||||
const f = fixture(t); const chat = await f.open(); const turn = await f.call("send", { chatHandle: chat.chatHandle, text: "Synthetic" });
|
||||
const internal = f.service.turns.get(turn.turnHandle); const native = new AbortController();
|
||||
f.delegation.onRelease(internal.chat.record.id, turn.turnHandle, () => native.abort());
|
||||
if (kind === "source") f.replaceSource(); else if (kind === "lock") f.lock(); else f.currentTo(false);
|
||||
await assert.rejects(f.dispatch(turn)); assert.equal(native.signal.aborted, true); assert.equal(f.effects(), 0);
|
||||
if (kind !== "backend") await assert.rejects(f.call("read", { turnHandle: turn.turnHandle }));
|
||||
}
|
||||
});
|
||||
|
||||
test("per-source turn budget survives runtime restart and is isolated from a different source", async (t) => {
|
||||
const f = fixture(t); let chat = await f.open();
|
||||
for (let i = 0; i < 6; i++) { const turn = await f.call("send", { chatHandle: chat.chatHandle, text: "Synthetic" }); await f.call("stop", { turnHandle: turn.turnHandle }); }
|
||||
f.restart(); chat = await f.open(); await assert.rejects(f.call("send", { chatHandle: chat.chatHandle, text: "Over budget" }));
|
||||
f.restart(); f.replaceSource(); chat = await f.open();
|
||||
assert.ok((await f.call("send", { chatHandle: chat.chatHandle, text: "Isolated source" })).turnHandle);
|
||||
});
|
||||
|
||||
test("late start/open completion after release cannot publish handles or affect a replacement turn", async (t) => {
|
||||
const f = fixture(t); const chat = await f.open(); const entered = deferred(); const resume = deferred();
|
||||
f.run(async () => { entered.resolve(); await resume.promise; return { hostHandle: randomUUID() }; });
|
||||
const starting = f.call("send", { chatHandle: chat.chatHandle, text: "Delayed synthetic start" }); const rejected = assert.rejects(starting);
|
||||
await entered.promise; f.restart(); resume.resolve(); await rejected; assert.equal(f.service.chats.size, 0); assert.equal(f.service.turns.size, 0);
|
||||
const next = await f.open(); f.tick(300_001);
|
||||
await assert.rejects(f.call("send", { chatHandle: next.chatHandle, text: "Expired" }));
|
||||
assert.ok((await f.open()).chatHandle); assert.equal(f.service.chats.size, 1);
|
||||
});
|
||||
|
||||
test("registered send cancellation during trusted startup revokes the exact turn before a handle can be handed off", async (t) => {
|
||||
const f = fixture(t); const chat = await f.open(); const entered = deferred(); const resume = deferred();
|
||||
const caller = new AbortController();
|
||||
f.run(async () => { entered.resolve(); await resume.promise; return { hostHandle: randomUUID() }; });
|
||||
const pending = f.call("send", { chatHandle: chat.chatHandle, text: "Synthetic cancelled startup" }, { signal: caller.signal });
|
||||
const rejected = assert.rejects(pending); await entered.promise;
|
||||
const internal = [...f.service.turns.values()][0]; const native = new AbortController();
|
||||
f.delegation.onRelease(internal.chat.record.id, internal.handle, () => native.abort());
|
||||
caller.abort(); resume.resolve(); await rejected;
|
||||
assert.equal(internal.chat.activeTurn, null); assert.equal(internal.controller.signal.aborted, true);
|
||||
assert.equal(f.delegation.has(internal.chat.record.id), false); assert.equal(native.signal.aborted, true);
|
||||
assert.equal(internal.hostHandle, null, "late startup completion cannot publish an orphan handle");
|
||||
assert.equal(f.service.startupTurns.size, 0);
|
||||
});
|
||||
|
||||
test("completed send detaches request lifetime from its live turn and cannot cancel replacement B", async (t) => {
|
||||
const f = fixture(t); const chat = await f.open(); const caller = new AbortController();
|
||||
const first = await f.call("send", { chatHandle: chat.chatHandle, text: "Synthetic completed handoff" }, { signal: caller.signal });
|
||||
const a = f.service.turns.get(first.turnHandle); caller.abort();
|
||||
assert.equal(f.service.startupTurns.size, 0);
|
||||
assert.equal(a.controller.signal.aborted, false); assert.equal(await f.dispatch(first), "synthetic");
|
||||
await f.call("stop", { turnHandle: first.turnHandle });
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.ok(f.releasedHandles.includes(a.hostHandle), "completed request lifetime cannot block exact host-handle cleanup");
|
||||
const second = await f.call("send", { chatHandle: chat.chatHandle, text: "Synthetic replacement B" });
|
||||
const b = f.service.turns.get(second.turnHandle); caller.abort();
|
||||
assert.equal(b.controller.signal.aborted, false); assert.equal(await f.dispatch(second), "synthetic");
|
||||
});
|
||||
|
||||
test("worker lifecycle during target confirmation cannot silently bind a replacement connection or leave native work running", async (t) => {
|
||||
const f = fixture(t); const chat = await f.open(); const turn = await f.call("send", { chatHandle: chat.chatHandle, text: "Synthetic pending work" });
|
||||
const internal = f.service.turns.get(turn.turnHandle); const native = new AbortController();
|
||||
f.delegation.onRelease(internal.chat.record.id, turn.turnHandle, () => native.abort());
|
||||
const entered = deferred(); const resume = deferred();
|
||||
f.choose(async () => { entered.resolve(); await resume.promise; return { targets: [{ sessionId: "a", hostname: "a.invalid", label: "Synthetic a", connected: true }] }; });
|
||||
const pending = f.call("selectTargets"); const rejected = assert.rejects(pending); await entered.promise;
|
||||
f.targetState.set("a", { owner: "replacement-window", revision: "replacement-generation" }); f.resourceChanged();
|
||||
assert.equal(native.signal.aborted, true); assert.equal(f.service.chats.size, 0);
|
||||
const count = f.service.selections.size; resume.resolve(); await rejected; assert.equal(f.service.selections.size, count);
|
||||
});
|
||||
@@ -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));
|
||||
});
|
||||
@@ -0,0 +1,189 @@
|
||||
"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);
|
||||
}
|
||||
|
||||
// Main-owned cleanup after a pending operation loses its lease. Exact owner
|
||||
// object matching prevents old cleanup from deleting a replacement source.
|
||||
discard(handle, owner) {
|
||||
const scope = this.scopes.get(handle);
|
||||
if (!scope || scope.owner !== owner) return false;
|
||||
this.#revoke(scope);
|
||||
return true;
|
||||
}
|
||||
|
||||
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, isRequestCancellation };
|
||||
@@ -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());
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
"use strict";
|
||||
|
||||
const { randomBytes } = require("node:crypto");
|
||||
const PREFIX = "plugin-agent:";
|
||||
const { listMcpTools } = require("../../capabilities/codegen/toolSurfaces.cjs");
|
||||
const toolsByMethod = new Map(listMcpTools().map((tool) => [tool.rpcMethod, tool.mcpTool]));
|
||||
|
||||
// Main-only per-turn MCP credentials. A managed Agent subprocess receives this
|
||||
// credential instead of the app-wide token; no token crosses the plugin API.
|
||||
class PluginAgentMcpDelegations {
|
||||
constructor() {
|
||||
this.sessions = new Map();
|
||||
this.tokens = new Map();
|
||||
this.sockets = new WeakMap();
|
||||
this.approvedContexts = new WeakSet();
|
||||
}
|
||||
|
||||
register(chatSessionId, { dispatch, assertCurrent, senderId, tools, turnTag }) {
|
||||
if (typeof chatSessionId !== "string" || !chatSessionId.startsWith(PREFIX)
|
||||
|| typeof dispatch !== "function" || typeof assertCurrent !== "function"
|
||||
|| typeof turnTag !== "string" || !/^[0-9a-f-]{36}$/u.test(turnTag)
|
||||
|| !Number.isSafeInteger(senderId) || !Array.isArray(tools) || tools.length > 128
|
||||
|| tools.some((tool) => typeof tool !== "string" || !tool || tool.length > 128)
|
||||
|| this.sessions.has(chatSessionId) || this.sessions.size >= 64) throw Error("Invalid Agent delegation");
|
||||
const token = randomBytes(32).toString("hex");
|
||||
const entry = { chatSessionId, turnTag, token, dispatch, assertCurrent, senderId, modelRequests: 0, tools: Object.freeze([...tools]), sockets: new Set(), cancellations: new Set() };
|
||||
this.sessions.set(chatSessionId, entry); this.tokens.set(token, entry);
|
||||
return Object.freeze({ dispose: () => { if (this.sessions.get(chatSessionId) === entry) this.release(chatSessionId); } });
|
||||
}
|
||||
|
||||
#assert(entry) {
|
||||
if (!entry || this.sessions.get(entry.chatSessionId) !== entry) throw Error("Agent delegation unavailable");
|
||||
try { entry.assertCurrent(); }
|
||||
catch (error) {
|
||||
// This callback is a synchronous main-owned authority check, not an RPC
|
||||
// signal. Discovering revocation must abort already-running native work.
|
||||
if (this.sessions.get(entry.chatSessionId) === entry) this.release(entry.chatSessionId);
|
||||
throw error;
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
|
||||
authenticate(token, socket) {
|
||||
const entry = this.tokens.get(token);
|
||||
if (!entry) return false;
|
||||
try { this.#assert(entry); } catch { return false; }
|
||||
this.sockets.set(socket, entry); entry.sockets.add(socket);
|
||||
socket.once?.("close", () => entry.sockets.delete(socket));
|
||||
return true;
|
||||
}
|
||||
|
||||
boundChat(socket) {
|
||||
const entry = this.sockets.get(socket);
|
||||
return entry ? this.#assert(entry).chatSessionId : null;
|
||||
}
|
||||
|
||||
configuration(chatSessionId, turnTag) {
|
||||
const entry = this.#assert(this.sessions.get(chatSessionId));
|
||||
if (entry.turnTag !== turnTag) throw Error("Agent turn unavailable");
|
||||
return Object.freeze({ token: entry.token, tools: entry.tools });
|
||||
}
|
||||
|
||||
has(chatSessionId) { return this.sessions.has(chatSessionId); }
|
||||
assertCaller(chatSessionId, senderId, turnTag) {
|
||||
const entry = this.#assert(this.sessions.get(chatSessionId));
|
||||
if (entry.senderId !== senderId || entry.turnTag !== turnTag) throw Error("Agent caller unavailable");
|
||||
}
|
||||
onRelease(chatSessionId, turnTag, cancel) {
|
||||
const entry = this.#assert(this.sessions.get(chatSessionId));
|
||||
if (entry.turnTag !== turnTag) throw Error("Agent turn unavailable");
|
||||
if (typeof cancel !== "function") throw Error("Native cancellation callback required");
|
||||
entry.cancellations.add(cancel);
|
||||
return () => entry.cancellations.delete(cancel);
|
||||
}
|
||||
beginModelRequest(chatSessionId, senderId, turnTag) {
|
||||
this.assertCaller(chatSessionId, senderId, turnTag);
|
||||
const entry = this.sessions.get(chatSessionId);
|
||||
if (++entry.modelRequests > 32) { this.release(chatSessionId); throw Error("Agent model request budget exceeded"); }
|
||||
}
|
||||
isReserved(chatSessionId) { return typeof chatSessionId === "string" && chatSessionId.startsWith(PREFIX); }
|
||||
isApproved(context) { return this.approvedContexts.has(context); }
|
||||
|
||||
async dispatch(method, params, context, execute) {
|
||||
const entry = this.#assert(this.sessions.get(params.chatSessionId));
|
||||
if (!entry.tools.includes(toolsByMethod.get(method))) throw Error("Tool is outside the Agent delegation");
|
||||
const socketEntry = context.externalSocket && this.sockets.get(context.externalSocket);
|
||||
if (socketEntry !== entry && (context.trustedAgentSenderId !== entry.senderId || context.trustedAgentTurnTag !== entry.turnTag)) throw Error("Agent caller unavailable");
|
||||
if (socketEntry && socketEntry !== entry) throw Error("Agent connection cannot change its session");
|
||||
return entry.dispatch(method, params, async () => {
|
||||
this.#assert(entry);
|
||||
const approved = { ...context, beforeExecute: () => {
|
||||
this.#assert(entry); context.beforeExecute?.();
|
||||
} };
|
||||
this.approvedContexts.add(approved);
|
||||
try { return await execute(approved); }
|
||||
finally { this.approvedContexts.delete(approved); }
|
||||
});
|
||||
}
|
||||
|
||||
release(chatSessionId) {
|
||||
const entry = this.sessions.get(chatSessionId);
|
||||
if (!entry) return;
|
||||
this.sessions.delete(chatSessionId); this.tokens.delete(entry.token);
|
||||
for (const cancel of entry.cancellations) { try { cancel(); } catch {} }
|
||||
entry.cancellations.clear();
|
||||
for (const socket of entry.sockets) { try { socket.destroy(); } catch {} }
|
||||
entry.sockets.clear();
|
||||
}
|
||||
|
||||
shutdown() { for (const id of this.sessions.keys()) this.release(id); }
|
||||
}
|
||||
|
||||
module.exports = { PluginAgentMcpDelegations };
|
||||
@@ -0,0 +1,71 @@
|
||||
"use strict";
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const { EventEmitter } = require("node:events");
|
||||
const { randomUUID } = require("node:crypto");
|
||||
const { PluginAgentMcpDelegations } = require("./mcpDelegation.cjs");
|
||||
|
||||
const options = (turnTag, overrides = {}) => ({ turnTag, senderId: 7, tools: ["get_environment"],
|
||||
assertCurrent: () => {}, dispatch: (_method, _params, execute) => execute(), ...overrides });
|
||||
function socket() {
|
||||
const value = new EventEmitter(); value.destroyed = false;
|
||||
value.destroy = () => { value.destroyed = true; value.emit("close"); }; return value;
|
||||
}
|
||||
|
||||
test("old turn disposer, credentials and late cancellation registration cannot acquire replacement turn B", () => {
|
||||
const registry = new PluginAgentMcpDelegations(); const id = `plugin-agent:${randomUUID()}`;
|
||||
const a = randomUUID(); const b = randomUUID();
|
||||
const first = registry.register(id, options(a));
|
||||
const tokenA = registry.configuration(id, a).token; const connectionA = socket();
|
||||
assert.equal(registry.authenticate(tokenA, connectionA), true);
|
||||
let cancelledA = 0; let cancelledB = 0;
|
||||
registry.onRelease(id, a, () => cancelledA++); first.dispose();
|
||||
const second = registry.register(id, options(b)); registry.onRelease(id, b, () => cancelledB++);
|
||||
first.dispose(); assert.equal(cancelledB, 0); assert.equal(cancelledA, 1);
|
||||
assert.equal(connectionA.destroyed, true);
|
||||
assert.equal(registry.authenticate(tokenA, socket()), false);
|
||||
assert.throws(() => registry.configuration(id, a));
|
||||
assert.throws(() => registry.onRelease(id, a, () => cancelledB++));
|
||||
assert.throws(() => registry.assertCaller(id, 7, a));
|
||||
assert.throws(() => registry.assertCaller(id, 99, b));
|
||||
assert.doesNotThrow(() => registry.assertCaller(id, 7, b));
|
||||
assert.equal(cancelledB, 0); second.dispose(); assert.equal(cancelledB, 1);
|
||||
});
|
||||
|
||||
test("discovered owner revocation aborts native work and one faulty callback cannot interrupt cleanup", () => {
|
||||
const registry = new PluginAgentMcpDelegations(); const id = `plugin-agent:${randomUUID()}`; const tag = randomUUID();
|
||||
let current = true;
|
||||
registry.register(id, options(tag, { assertCurrent: () => { if (!current) throw Error("Source revoked"); } }));
|
||||
const token = registry.configuration(id, tag).token; const connection = socket(); registry.authenticate(token, connection);
|
||||
const native = new AbortController();
|
||||
registry.onRelease(id, tag, () => { throw Error("Faulty cleanup listener"); });
|
||||
registry.onRelease(id, tag, () => native.abort());
|
||||
current = false; assert.throws(() => registry.assertCaller(id, 7, tag), /Source revoked/);
|
||||
assert.equal(native.signal.aborted, true); assert.equal(connection.destroyed, true);
|
||||
assert.equal(registry.has(id), false); assert.equal(registry.authenticate(token, socket()), false);
|
||||
});
|
||||
|
||||
test("tool profile and trusted caller tag are enforced before invoking the host policy callback", async () => {
|
||||
const registry = new PluginAgentMcpDelegations(); const id = `plugin-agent:${randomUUID()}`; const tag = randomUUID();
|
||||
let decisions = 0; let effects = 0;
|
||||
registry.register(id, options(tag, { dispatch: (_method, _params, execute) => { decisions++; return execute(); } }));
|
||||
await assert.rejects(registry.dispatch("netcatty/getContext", { chatSessionId: id }, { trustedAgentSenderId: 7, trustedAgentTurnTag: randomUUID() }, () => effects++));
|
||||
await assert.rejects(registry.dispatch("netcatty/exec", { chatSessionId: id }, { trustedAgentSenderId: 7, trustedAgentTurnTag: tag }, () => effects++));
|
||||
assert.equal(decisions, 0); assert.equal(effects, 0);
|
||||
await registry.dispatch("netcatty/getContext", { chatSessionId: id }, { trustedAgentSenderId: 7, trustedAgentTurnTag: tag }, (context) => {
|
||||
assert.equal(registry.isApproved(context), true); context.beforeExecute(); effects++;
|
||||
});
|
||||
assert.equal(decisions, 1); assert.equal(effects, 1);
|
||||
registry.shutdown();
|
||||
});
|
||||
|
||||
test("model request limits cannot be reset or consumed by a different turn or caller", () => {
|
||||
const registry = new PluginAgentMcpDelegations(); const id = `plugin-agent:${randomUUID()}`; const tag = randomUUID();
|
||||
registry.register(id, options(tag)); let cancelled = 0; registry.onRelease(id, tag, () => cancelled++);
|
||||
for (let i = 0; i < 32; i++) {
|
||||
assert.throws(() => registry.beginModelRequest(id, 8, tag));
|
||||
assert.throws(() => registry.beginModelRequest(id, 7, randomUUID()));
|
||||
registry.beginModelRequest(id, 7, tag);
|
||||
}
|
||||
assert.throws(() => registry.beginModelRequest(id, 7, tag), /budget/); assert.equal(cancelled, 1); assert.equal(registry.has(id), false);
|
||||
});
|
||||
@@ -51,6 +51,15 @@ class PluginAISourceBinding {
|
||||
return binding;
|
||||
}
|
||||
|
||||
// Main-owned final dispatch guard. Only a previously authenticated binding
|
||||
// object can reach this path; it is never reconstructed from plugin fields.
|
||||
assertCurrent(binding) {
|
||||
if (!binding || this.bindings.get(binding.runtimeId) !== binding) throw denied();
|
||||
const current = this.#snapshot(binding);
|
||||
if (current.sourceKey !== binding.sourceKey || current.privateOwner !== binding.privateOwner
|
||||
|| current.pluginId !== binding.pluginId || current.pluginVersion !== binding.pluginVersion) throw denied();
|
||||
}
|
||||
|
||||
releaseRuntime(runtimeId) { this.bindings.delete(runtimeId); }
|
||||
releasePlugin(pluginId) {
|
||||
for (const [runtimeId, binding] of this.bindings) if (binding.pluginId === pluginId) this.bindings.delete(runtimeId);
|
||||
|
||||
@@ -39,3 +39,12 @@ test("source/owner changes during asynchronous authentication cannot commit a bi
|
||||
const context = { ...f.context, assertActive: async () => { if (++checks === 2) f.ownerTo("changed-owner"); } };
|
||||
await assert.rejects(f.binding.resolve(context)); assert.equal(f.binding.bindings.size, 0);
|
||||
});
|
||||
|
||||
test("final dispatch checks require the exact authenticated binding and current host provenance", async () => {
|
||||
const f = fixture(); const binding = await f.binding.resolve(f.context);
|
||||
assert.doesNotThrow(() => f.binding.assertCurrent(binding));
|
||||
assert.throws(() => f.binding.assertCurrent({ ...binding }));
|
||||
f.ownerTo("changed-owner"); assert.throws(() => f.binding.assertCurrent(binding));
|
||||
f.ownerTo("source:opaque-a:com.example.assistant");
|
||||
f.binding.releaseRuntime("r1"); assert.throws(() => f.binding.assertCurrent(binding));
|
||||
});
|
||||
Loaded 100 of 179 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user