mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-03 20:42:33 +00:00
Compare commits
41
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2dc3fc3cd1 | ||
|
|
bb02f5cbd0 | ||
|
|
a6dda1771c | ||
|
|
6620b85f2d | ||
|
|
942f106240 | ||
|
|
0c0d2ca558 | ||
|
|
e98e236d54 | ||
|
|
1e063dfa3c | ||
|
|
8b5b0c4416 | ||
|
|
afb0585016 | ||
|
|
6d65c89ecf | ||
|
|
2f214b8725 | ||
|
|
350917fa0e | ||
|
|
fb691318c4 | ||
|
|
9c1af5c73f | ||
|
|
555f193d23 | ||
|
|
88dd20a918 | ||
|
|
57e682dc83 | ||
|
|
2671cf9f20 | ||
|
|
672a2bf406 | ||
|
|
9f09af3bda | ||
|
|
f5e2a64dfb | ||
|
|
25e0c834da | ||
|
|
f8616f5d3a | ||
|
|
0cab1046cf | ||
|
|
a56d1ab785 | ||
|
|
cbe89995e6 | ||
|
|
72383a50c9 | ||
|
|
8bcad28f9d | ||
|
|
59edc87ed4 | ||
|
|
5944a02238 | ||
|
|
b17713ef95 | ||
|
|
56fefdd03f | ||
|
|
b7e4abb30b | ||
|
|
f715a0764d | ||
|
|
5db0f90751 | ||
|
|
63f0572e0b | ||
|
|
ea619555ac | ||
|
|
ba4d3fc223 | ||
|
|
18377a1fd9 | ||
|
|
056164157f |
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
|
||||
|
||||
@@ -525,6 +525,8 @@ export const enAiMessages: Messages = {
|
||||
'ai.chat.alwaysAllow': 'Always',
|
||||
'ai.chat.slashStopDesc': 'Stop the current AI turn and cancel in-flight tools',
|
||||
'ai.chat.slashCompactDesc': 'Summarize earlier conversation context',
|
||||
'ai.chat.slashRenameDesc': 'Rename the current chat (/rename <title>)',
|
||||
'ai.chat.slashChatsDesc': 'List the chats visible to this panel',
|
||||
'ai.chat.reject': 'Reject',
|
||||
'ai.chat.toolLabel': 'Tool',
|
||||
'ai.chat.targetLabel': 'Target',
|
||||
@@ -587,6 +589,28 @@ export const enAiMessages: Messages = {
|
||||
'ai.chat.recent': 'Recent',
|
||||
'ai.chat.viewAll': 'View All',
|
||||
'ai.chat.untitled': 'Untitled',
|
||||
'ai.chat.renameChat': 'Rename chat',
|
||||
'ai.chat.renameUsage': 'Usage: /rename <new title>',
|
||||
'ai.chat.renameNoActive': 'No active chat to rename.',
|
||||
'ai.chat.renameDone': 'Chat renamed to "{title}".',
|
||||
'ai.chat.exportSessions': 'Export AI chats',
|
||||
'ai.chat.importSessions': 'Import AI chats',
|
||||
'ai.chat.exportDone': 'AI chats exported to {path}.',
|
||||
'ai.chat.exportDoneNoPath': 'AI chats exported.',
|
||||
'ai.chat.exportUnavailable': 'AI chat export is unavailable in this environment.',
|
||||
'ai.chat.importDone': 'Imported {n} AI chat(s).',
|
||||
'ai.chat.importNone': 'No AI chats found in the selected file.',
|
||||
'ai.chat.chatsHeader': 'Chats visible in this panel:',
|
||||
'ai.chat.chatsEmpty': 'No chats visible in this panel.',
|
||||
'ai.chat.chatsScopeWorkspace': 'Workspace',
|
||||
'ai.chat.chatsScopeTerminal': 'Terminal',
|
||||
'ai.chat.chatsScopeGlobal': 'Global',
|
||||
'ai.chat.chatsScopeDangling': 'Dissociated',
|
||||
'ai.chat.slashRebindDesc': 'Rebind a disconnected chat to this panel (/rebind <n>)',
|
||||
'ai.chat.chatsRebindHint': 'Disconnected chats can be restored with /rebind <n>.',
|
||||
'ai.chat.rebindUsage': 'Usage: /rebind <number from /chats>',
|
||||
'ai.chat.rebindDone': 'Chat "{title}" rebound to this panel.',
|
||||
'ai.chat.rebindUnavailable': 'No rebind target in this panel scope.',
|
||||
'ai.chat.justNow': 'Just now',
|
||||
'ai.chat.minutesAgo': '{n}m ago',
|
||||
'ai.chat.hoursAgo': '{n}h ago',
|
||||
@@ -795,4 +819,27 @@ export const enAiMessages: Messages = {
|
||||
'zmodem.overwrite.skip': 'Skip',
|
||||
'zmodem.overwrite.cancel': 'Cancel',
|
||||
'settings.shortcuts.resetToDefault': 'Reset to default',
|
||||
"ai.toolSettings.immediate": "Changes take effect immediately. Closing this window does not undo them.",
|
||||
"ai.toolSettings.privacyOn": "Text redaction enabled",
|
||||
"ai.toolSettings.privacyOff": "Text redaction disabled",
|
||||
"ai.toolSettings.ruleSummary": "{cidrs} additional IP ranges · {patterns} custom patterns",
|
||||
"ai.toolSettings.configurePrivacy": "Configure privacy",
|
||||
"ai.toolSettings.unsaved": "Unsaved changes",
|
||||
"ai.toolSettings.viewSkills": "View CLI guidance",
|
||||
"ai.toolSettings.viewPrompt": "View setup prompt",
|
||||
"ai.toolSettings.behavior": "MCP behavior",
|
||||
"ai.toolSettings.configureBehavior": "Configure behavior",
|
||||
"ai.toolSettings.configureClients": "Configure clients",
|
||||
"ai.toolSettings.httpOn": "HTTP enabled",
|
||||
"ai.toolSettings.httpOff": "HTTP disabled",
|
||||
"ai.toolSettings.loopback": "Local connections only (127.0.0.1)",
|
||||
"ai.toolSettings.configureHttp": "Configure HTTP",
|
||||
"ai.toolSettings.hideSecret": "Hide secret",
|
||||
"ai.toolSettings.showSecret": "Show secret",
|
||||
"ai.toolSettings.manualSetup": "Manual setup",
|
||||
"ai.toolSettings.format": "Format",
|
||||
"ai.toolSettings.connectionDetails": "Connection details",
|
||||
"ai.toolSettings.conflict": "Settings changed in another window. Your draft is preserved. Cancel and reopen to load the saved settings.",
|
||||
"ai.toolSettings.retry": "Retry",
|
||||
"ai.toolSettings.applyingHttp": "Applying HTTP configuration...",
|
||||
};
|
||||
@@ -525,6 +525,8 @@ export const esAiMessages: Messages = {
|
||||
'ai.chat.alwaysAllow': 'Siempre',
|
||||
'ai.chat.slashStopDesc': 'Detener el turno actual de IA y cancelar las herramientas en curso',
|
||||
'ai.chat.slashCompactDesc': 'Resumir el contexto anterior de la conversación',
|
||||
'ai.chat.slashRenameDesc': 'Renombrar el chat actual (/rename <título>)',
|
||||
'ai.chat.slashChatsDesc': 'Listar los chats visibles en este panel',
|
||||
'ai.chat.reject': 'Rechazar',
|
||||
'ai.chat.toolLabel': 'Herramienta',
|
||||
'ai.chat.targetLabel': 'Destino',
|
||||
@@ -587,6 +589,28 @@ export const esAiMessages: Messages = {
|
||||
'ai.chat.recent': 'Recientes',
|
||||
'ai.chat.viewAll': 'Ver todo',
|
||||
'ai.chat.untitled': 'Sin título',
|
||||
'ai.chat.renameChat': 'Renombrar chat',
|
||||
'ai.chat.renameUsage': 'Uso: /rename <nuevo título>',
|
||||
'ai.chat.renameNoActive': 'No hay ningún chat activo para renombrar.',
|
||||
'ai.chat.renameDone': 'Chat renombrado a «{title}».',
|
||||
'ai.chat.exportSessions': 'Exportar chats de IA',
|
||||
'ai.chat.importSessions': 'Importar chats de IA',
|
||||
'ai.chat.exportDone': 'Chats de IA exportados a {path}.',
|
||||
'ai.chat.exportDoneNoPath': 'Chats de IA exportados.',
|
||||
'ai.chat.exportUnavailable': 'La exportación de chats de IA no está disponible en este entorno.',
|
||||
'ai.chat.importDone': 'Se importaron {n} chat(s) de IA.',
|
||||
'ai.chat.importNone': 'No se encontraron chats de IA en el archivo seleccionado.',
|
||||
'ai.chat.chatsHeader': 'Chats visibles en este panel:',
|
||||
'ai.chat.chatsEmpty': 'No hay chats visibles en este panel.',
|
||||
'ai.chat.chatsScopeWorkspace': 'Workspace',
|
||||
'ai.chat.chatsScopeTerminal': 'Terminal',
|
||||
'ai.chat.chatsScopeGlobal': 'Global',
|
||||
'ai.chat.chatsScopeDangling': 'Desasociado',
|
||||
'ai.chat.slashRebindDesc': 'Reasociar un chat desconectado a este panel (/rebind <n>)',
|
||||
'ai.chat.chatsRebindHint': 'Los chats desconectados se pueden restaurar con /rebind <n>.',
|
||||
'ai.chat.rebindUsage': 'Uso: /rebind <número de /chats>',
|
||||
'ai.chat.rebindDone': 'Chat «{title}» reasociado a este panel.',
|
||||
'ai.chat.rebindUnavailable': 'No hay destino de reasociación en el ámbito de este panel.',
|
||||
'ai.chat.justNow': 'Ahora mismo',
|
||||
'ai.chat.minutesAgo': 'hace {n} min',
|
||||
'ai.chat.hoursAgo': 'hace {n} h',
|
||||
@@ -795,4 +819,27 @@ export const esAiMessages: Messages = {
|
||||
'zmodem.overwrite.skip': 'Omitir',
|
||||
'zmodem.overwrite.cancel': 'Cancelar',
|
||||
'settings.shortcuts.resetToDefault': 'Restablecer al predeterminado',
|
||||
"ai.toolSettings.immediate": "Los cambios se aplican inmediatamente. Cerrar esta ventana no los deshace.",
|
||||
"ai.toolSettings.privacyOn": "Ocultación de texto activada",
|
||||
"ai.toolSettings.privacyOff": "Ocultación de texto desactivada",
|
||||
"ai.toolSettings.ruleSummary": "{cidrs} rangos IP adicionales · {patterns} patrones personalizados",
|
||||
"ai.toolSettings.configurePrivacy": "Configurar privacidad",
|
||||
"ai.toolSettings.unsaved": "Cambios sin guardar",
|
||||
"ai.toolSettings.viewSkills": "Ver instrucciones de CLI",
|
||||
"ai.toolSettings.viewPrompt": "Ver instrucciones de conexión",
|
||||
"ai.toolSettings.behavior": "Comportamiento de MCP",
|
||||
"ai.toolSettings.configureBehavior": "Configurar comportamiento",
|
||||
"ai.toolSettings.configureClients": "Configurar clientes",
|
||||
"ai.toolSettings.httpOn": "HTTP activado",
|
||||
"ai.toolSettings.httpOff": "HTTP desactivado",
|
||||
"ai.toolSettings.loopback": "Solo conexiones locales (127.0.0.1)",
|
||||
"ai.toolSettings.configureHttp": "Configurar HTTP",
|
||||
"ai.toolSettings.hideSecret": "Ocultar secreto",
|
||||
"ai.toolSettings.showSecret": "Mostrar secreto",
|
||||
"ai.toolSettings.manualSetup": "Configuración manual",
|
||||
"ai.toolSettings.format": "Formato",
|
||||
"ai.toolSettings.connectionDetails": "Detalles de conexión",
|
||||
"ai.toolSettings.conflict": "La configuración cambió en otra ventana. Se conserva el borrador. Cancela y vuelve a abrir para cargar la configuración guardada.",
|
||||
"ai.toolSettings.retry": "Reintentar",
|
||||
"ai.toolSettings.applyingHttp": "Aplicando la configuración HTTP...",
|
||||
};
|
||||
@@ -464,6 +464,8 @@ export const ruAiMessages: Messages = {
|
||||
'ai.chat.alwaysAllow': 'Всегда разрешать',
|
||||
'ai.chat.slashStopDesc': 'Остановить текущий ход AI и отменить выполняющиеся инструменты',
|
||||
'ai.chat.slashCompactDesc': 'Сжать ранний контекст разговора',
|
||||
'ai.chat.slashRenameDesc': 'Переименовать текущий чат (/rename <название>)',
|
||||
'ai.chat.slashChatsDesc': 'Показать чаты, видимые в этой панели',
|
||||
'ai.chat.reject': 'Отклонить',
|
||||
'ai.chat.toolLabel': 'Инструмент',
|
||||
'ai.chat.targetLabel': 'Цель',
|
||||
@@ -526,6 +528,28 @@ export const ruAiMessages: Messages = {
|
||||
'ai.chat.recent': 'Недавние',
|
||||
'ai.chat.viewAll': 'Показать всё',
|
||||
'ai.chat.untitled': 'Без названия',
|
||||
'ai.chat.renameChat': 'Переименовать чат',
|
||||
'ai.chat.renameUsage': 'Использование: /rename <новое название>',
|
||||
'ai.chat.renameNoActive': 'Нет активного чата для переименования.',
|
||||
'ai.chat.renameDone': 'Чат переименован в «{title}».',
|
||||
'ai.chat.exportSessions': 'Экспортировать чаты ИИ',
|
||||
'ai.chat.importSessions': 'Импортировать чаты ИИ',
|
||||
'ai.chat.exportDone': 'Чаты ИИ экспортированы в {path}.',
|
||||
'ai.chat.exportDoneNoPath': 'Чаты ИИ экспортированы.',
|
||||
'ai.chat.exportUnavailable': 'Экспорт чатов ИИ недоступен в этой среде.',
|
||||
'ai.chat.importDone': 'Импортировано чатов ИИ: {n}.',
|
||||
'ai.chat.importNone': 'В выбранном файле не найдено чатов ИИ.',
|
||||
'ai.chat.chatsHeader': 'Чаты, видимые в этой панели:',
|
||||
'ai.chat.chatsEmpty': 'В этой панели нет видимых чатов.',
|
||||
'ai.chat.chatsScopeWorkspace': 'Workspace',
|
||||
'ai.chat.chatsScopeTerminal': 'Терминал',
|
||||
'ai.chat.chatsScopeGlobal': 'Глобально',
|
||||
'ai.chat.chatsScopeDangling': 'Связь потеряна',
|
||||
'ai.chat.slashRebindDesc': 'Привязать потерянный чат к этой панели (/rebind <n>)',
|
||||
'ai.chat.chatsRebindHint': 'Потерянные чаты можно восстановить командой /rebind <n>.',
|
||||
'ai.chat.rebindUsage': 'Использование: /rebind <номер из /chats>',
|
||||
'ai.chat.rebindDone': 'Чат «{title}» привязан к этой панели.',
|
||||
'ai.chat.rebindUnavailable': 'В области этой панели нет цели для привязки.',
|
||||
'ai.chat.justNow': 'Только что',
|
||||
'ai.chat.minutesAgo': '{n}м назад',
|
||||
'ai.chat.hoursAgo': '{n}ч назад',
|
||||
@@ -724,4 +748,27 @@ export const ruAiMessages: Messages = {
|
||||
'zmodem.overwrite.skip': 'Пропустить',
|
||||
'zmodem.overwrite.cancel': 'Отмена',
|
||||
'settings.shortcuts.resetToDefault': 'Сбросить по умолчанию',
|
||||
"ai.toolSettings.immediate": "Изменения применяются сразу. Закрытие окна не отменяет их.",
|
||||
"ai.toolSettings.privacyOn": "Скрытие текста включено",
|
||||
"ai.toolSettings.privacyOff": "Скрытие текста выключено",
|
||||
"ai.toolSettings.ruleSummary": "Дополнительные диапазоны IP: {cidrs} · Пользовательские шаблоны: {patterns}",
|
||||
"ai.toolSettings.configurePrivacy": "Настроить приватность",
|
||||
"ai.toolSettings.unsaved": "Есть несохранённые изменения",
|
||||
"ai.toolSettings.viewSkills": "Инструкции CLI",
|
||||
"ai.toolSettings.viewPrompt": "Инструкции подключения",
|
||||
"ai.toolSettings.behavior": "Поведение MCP",
|
||||
"ai.toolSettings.configureBehavior": "Настроить поведение",
|
||||
"ai.toolSettings.configureClients": "Настроить клиенты",
|
||||
"ai.toolSettings.httpOn": "HTTP включён",
|
||||
"ai.toolSettings.httpOff": "HTTP выключен",
|
||||
"ai.toolSettings.loopback": "Только локальные подключения (127.0.0.1)",
|
||||
"ai.toolSettings.configureHttp": "Настроить HTTP",
|
||||
"ai.toolSettings.hideSecret": "Скрыть секрет",
|
||||
"ai.toolSettings.showSecret": "Показать секрет",
|
||||
"ai.toolSettings.manualSetup": "Ручная настройка",
|
||||
"ai.toolSettings.format": "Формат",
|
||||
"ai.toolSettings.connectionDetails": "Параметры подключения",
|
||||
"ai.toolSettings.conflict": "Настройки изменены в другом окне. Черновик сохранён. Отмените и откройте окно снова, чтобы загрузить сохранённые настройки.",
|
||||
"ai.toolSettings.retry": "Повторить",
|
||||
"ai.toolSettings.applyingHttp": "Применение настроек HTTP...",
|
||||
};
|
||||
@@ -525,6 +525,8 @@ export const zhCNAiMessages: Messages = {
|
||||
'ai.chat.alwaysAllow': '始终',
|
||||
'ai.chat.slashStopDesc': '停止当前 AI 回合并取消进行中的工具',
|
||||
'ai.chat.slashCompactDesc': '压缩较早的会话上下文',
|
||||
'ai.chat.slashRenameDesc': '重命名当前聊天(/rename <标题>)',
|
||||
'ai.chat.slashChatsDesc': '列出此面板可见的聊天',
|
||||
'ai.chat.reject': '拒绝',
|
||||
'ai.chat.toolLabel': '工具',
|
||||
'ai.chat.targetLabel': '目标',
|
||||
@@ -587,6 +589,28 @@ export const zhCNAiMessages: Messages = {
|
||||
'ai.chat.recent': '最近',
|
||||
'ai.chat.viewAll': '查看全部',
|
||||
'ai.chat.untitled': '无标题',
|
||||
'ai.chat.renameChat': '重命名聊天',
|
||||
'ai.chat.renameUsage': '用法:/rename <新标题>',
|
||||
'ai.chat.renameNoActive': '当前没有可重命名的聊天。',
|
||||
'ai.chat.renameDone': '聊天已重命名为「{title}」。',
|
||||
'ai.chat.exportSessions': '导出 AI 会话',
|
||||
'ai.chat.importSessions': '导入 AI 会话',
|
||||
'ai.chat.exportDone': 'AI 会话已导出到 {path}。',
|
||||
'ai.chat.exportDoneNoPath': 'AI 会话已导出。',
|
||||
'ai.chat.exportUnavailable': '当前环境不支持导出 AI 会话。',
|
||||
'ai.chat.importDone': '已导入 {n} 个 AI 会话。',
|
||||
'ai.chat.importNone': '所选文件中没有可导入的 AI 会话。',
|
||||
'ai.chat.chatsHeader': '此面板可见的聊天:',
|
||||
'ai.chat.chatsEmpty': '此面板没有可见的聊天。',
|
||||
'ai.chat.chatsScopeWorkspace': '工作区',
|
||||
'ai.chat.chatsScopeTerminal': '终端',
|
||||
'ai.chat.chatsScopeGlobal': '全局',
|
||||
'ai.chat.chatsScopeDangling': '已断开关联',
|
||||
'ai.chat.slashRebindDesc': '将断联的聊天重绑到此面板(/rebind <序号>)',
|
||||
'ai.chat.chatsRebindHint': '断联的聊天可用 /rebind <序号> 恢复。',
|
||||
'ai.chat.rebindUsage': '用法:/rebind <序号>',
|
||||
'ai.chat.rebindDone': '聊天“{title}”已重绑到此面板。',
|
||||
'ai.chat.rebindUnavailable': '当前面板范围内没有可用的重绑目标。',
|
||||
'ai.chat.justNow': '刚刚',
|
||||
'ai.chat.minutesAgo': '{n}分钟前',
|
||||
'ai.chat.hoursAgo': '{n}小时前',
|
||||
@@ -795,4 +819,27 @@ export const zhCNAiMessages: Messages = {
|
||||
'zmodem.overwrite.skip': '跳过',
|
||||
'zmodem.overwrite.cancel': '取消',
|
||||
'settings.shortcuts.resetToDefault': '重置为默认',
|
||||
"ai.toolSettings.immediate": "更改立即生效,关闭此窗口不会撤销。",
|
||||
"ai.toolSettings.privacyOn": "已启用文本脱敏",
|
||||
"ai.toolSettings.privacyOff": "未启用文本脱敏",
|
||||
"ai.toolSettings.ruleSummary": "{cidrs} 个附加 IP 范围 · {patterns} 条自定义规则",
|
||||
"ai.toolSettings.configurePrivacy": "配置隐私",
|
||||
"ai.toolSettings.unsaved": "有未保存的更改",
|
||||
"ai.toolSettings.viewSkills": "查看 CLI 指引",
|
||||
"ai.toolSettings.viewPrompt": "查看接入提示词",
|
||||
"ai.toolSettings.behavior": "MCP 行为",
|
||||
"ai.toolSettings.configureBehavior": "配置行为",
|
||||
"ai.toolSettings.configureClients": "配置客户端",
|
||||
"ai.toolSettings.httpOn": "HTTP 已启用",
|
||||
"ai.toolSettings.httpOff": "HTTP 未启用",
|
||||
"ai.toolSettings.loopback": "仅本地连接(127.0.0.1)",
|
||||
"ai.toolSettings.configureHttp": "配置 HTTP",
|
||||
"ai.toolSettings.hideSecret": "隐藏密钥",
|
||||
"ai.toolSettings.showSecret": "显示密钥",
|
||||
"ai.toolSettings.manualSetup": "手动配置",
|
||||
"ai.toolSettings.format": "格式",
|
||||
"ai.toolSettings.connectionDetails": "连接详情",
|
||||
"ai.toolSettings.conflict": "设置已在另一窗口更改,当前草稿已保留。请取消并重新打开以加载已保存的设置。",
|
||||
"ai.toolSettings.retry": "重试",
|
||||
"ai.toolSettings.applyingHttp": "正在应用 HTTP 配置…",
|
||||
};
|
||||
@@ -515,6 +515,8 @@ export const zhTWAiMessages: Messages = {
|
||||
'ai.chat.alwaysAllow': '始終',
|
||||
'ai.chat.slashStopDesc': '停止目前 AI 回合並取消進行中的工具',
|
||||
'ai.chat.slashCompactDesc': '壓縮較早的對話上下文',
|
||||
'ai.chat.slashRenameDesc': '重新命名目前聊天(/rename <標題>)',
|
||||
'ai.chat.slashChatsDesc': '列出此面板可見的聊天',
|
||||
'ai.chat.reject': '拒絕',
|
||||
'ai.chat.toolLabel': '工具',
|
||||
'ai.chat.targetLabel': '目標',
|
||||
@@ -577,6 +579,28 @@ export const zhTWAiMessages: Messages = {
|
||||
'ai.chat.recent': '最近',
|
||||
'ai.chat.viewAll': '檢視全部',
|
||||
'ai.chat.untitled': '無標題',
|
||||
'ai.chat.renameChat': '重新命名聊天',
|
||||
'ai.chat.renameUsage': '用法:/rename <新標題>',
|
||||
'ai.chat.renameNoActive': '目前沒有可重新命名的聊天。',
|
||||
'ai.chat.renameDone': '聊天已重新命名為「{title}」。',
|
||||
'ai.chat.exportSessions': '匯出 AI 會話',
|
||||
'ai.chat.importSessions': '匯入 AI 會話',
|
||||
'ai.chat.exportDone': 'AI 會話已匯出到 {path}。',
|
||||
'ai.chat.exportDoneNoPath': 'AI 會話已匯出。',
|
||||
'ai.chat.exportUnavailable': '目前環境不支援匯出 AI 會話。',
|
||||
'ai.chat.importDone': '已匯入 {n} 個 AI 會話。',
|
||||
'ai.chat.importNone': '所選檔案中沒有可匯入的 AI 會話。',
|
||||
'ai.chat.chatsHeader': '此面板可見的聊天:',
|
||||
'ai.chat.chatsEmpty': '此面板沒有可見的聊天。',
|
||||
'ai.chat.chatsScopeWorkspace': '工作區',
|
||||
'ai.chat.chatsScopeTerminal': '終端',
|
||||
'ai.chat.chatsScopeGlobal': '全域',
|
||||
'ai.chat.chatsScopeDangling': '已斷開關聯',
|
||||
'ai.chat.slashRebindDesc': '將斷開關聯的聊天重新綁定到此面板(/rebind <序號>)',
|
||||
'ai.chat.chatsRebindHint': '斷開關聯的聊天可用 /rebind <序號> 恢復。',
|
||||
'ai.chat.rebindUsage': '用法:/rebind <序號>',
|
||||
'ai.chat.rebindDone': '聊天「{title}」已重新綁定到此面板。',
|
||||
'ai.chat.rebindUnavailable': '目前面板範圍內沒有可用的重新綁定目標。',
|
||||
'ai.chat.justNow': '剛剛',
|
||||
'ai.chat.minutesAgo': '{n}分鐘前',
|
||||
'ai.chat.hoursAgo': '{n}小時前',
|
||||
@@ -785,4 +809,27 @@ export const zhTWAiMessages: Messages = {
|
||||
'zmodem.overwrite.skip': '跳過',
|
||||
'zmodem.overwrite.cancel': '取消',
|
||||
'settings.shortcuts.resetToDefault': '重設為預設值',
|
||||
"ai.toolSettings.immediate": "變更立即生效,關閉此視窗不會復原。",
|
||||
"ai.toolSettings.privacyOn": "已啟用文字遮蔽",
|
||||
"ai.toolSettings.privacyOff": "未啟用文字遮蔽",
|
||||
"ai.toolSettings.ruleSummary": "{cidrs} 個額外 IP 範圍 · {patterns} 條自訂規則",
|
||||
"ai.toolSettings.configurePrivacy": "設定隱私",
|
||||
"ai.toolSettings.unsaved": "有未儲存的變更",
|
||||
"ai.toolSettings.viewSkills": "查看 CLI 指引",
|
||||
"ai.toolSettings.viewPrompt": "查看接入提示詞",
|
||||
"ai.toolSettings.behavior": "MCP 行為",
|
||||
"ai.toolSettings.configureBehavior": "設定行為",
|
||||
"ai.toolSettings.configureClients": "設定用戶端",
|
||||
"ai.toolSettings.httpOn": "HTTP 已啟用",
|
||||
"ai.toolSettings.httpOff": "HTTP 未啟用",
|
||||
"ai.toolSettings.loopback": "僅本機連線(127.0.0.1)",
|
||||
"ai.toolSettings.configureHttp": "設定 HTTP",
|
||||
"ai.toolSettings.hideSecret": "隱藏金鑰",
|
||||
"ai.toolSettings.showSecret": "顯示金鑰",
|
||||
"ai.toolSettings.manualSetup": "手動設定",
|
||||
"ai.toolSettings.format": "格式",
|
||||
"ai.toolSettings.connectionDetails": "連線詳情",
|
||||
"ai.toolSettings.conflict": "設定已在其他視窗變更,目前草稿已保留。請取消並重新開啟以載入已儲存的設定。",
|
||||
"ai.toolSettings.retry": "重試",
|
||||
"ai.toolSettings.applyingHttp": "正在套用 HTTP 設定…",
|
||||
};
|
||||
@@ -2,14 +2,9 @@ import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import type {
|
||||
AIPanelView,
|
||||
AISession,
|
||||
} from "../../infrastructure/ai/types.ts";
|
||||
import { createEmptyDraft } from "./aiDraftState.ts";
|
||||
import {
|
||||
pruneInactiveScopedSessions,
|
||||
pruneInactiveScopedTransientState,
|
||||
} from "./aiScopeCleanup.ts";
|
||||
import { pruneInactiveScopedSessions } from "./aiScopeCleanup.ts";
|
||||
|
||||
function createSession(id: string, scope: AISession["scope"], externalSessionId?: string): AISession {
|
||||
return {
|
||||
@@ -24,47 +19,6 @@ function createSession(id: string, scope: AISession["scope"], externalSessionId?
|
||||
};
|
||||
}
|
||||
|
||||
test("pruneInactiveScopedTransientState removes closed workspace and terminal scope state", () => {
|
||||
const activeSessionIdMap = {
|
||||
"terminal:open-terminal": "session-open",
|
||||
"terminal:closed-terminal": "session-closed-terminal",
|
||||
"workspace:open-workspace": "session-open-workspace",
|
||||
"workspace:closed-workspace": "session-closed-workspace",
|
||||
};
|
||||
const draftsByScope = {
|
||||
"terminal:open-terminal": createEmptyDraft("catty"),
|
||||
"terminal:closed-terminal": createEmptyDraft("catty"),
|
||||
"workspace:open-workspace": createEmptyDraft("catty"),
|
||||
"workspace:closed-workspace": createEmptyDraft("catty"),
|
||||
};
|
||||
const panelViewByScope = {
|
||||
"terminal:open-terminal": { mode: "draft" },
|
||||
"terminal:closed-terminal": { mode: "session", sessionId: "session-closed-terminal" },
|
||||
"workspace:open-workspace": { mode: "draft" },
|
||||
"workspace:closed-workspace": { mode: "session", sessionId: "session-closed-workspace" },
|
||||
} satisfies Record<string, AIPanelView>;
|
||||
|
||||
const next = pruneInactiveScopedTransientState(
|
||||
activeSessionIdMap,
|
||||
draftsByScope,
|
||||
panelViewByScope,
|
||||
new Set(["open-terminal", "open-workspace"]),
|
||||
);
|
||||
|
||||
assert.deepEqual(next.activeSessionIdMap, {
|
||||
"terminal:open-terminal": "session-open",
|
||||
"workspace:open-workspace": "session-open-workspace",
|
||||
});
|
||||
assert.deepEqual(next.draftsByScope, {
|
||||
"terminal:open-terminal": draftsByScope["terminal:open-terminal"],
|
||||
"workspace:open-workspace": draftsByScope["workspace:open-workspace"],
|
||||
});
|
||||
assert.deepEqual(next.panelViewByScope, {
|
||||
"terminal:open-terminal": panelViewByScope["terminal:open-terminal"],
|
||||
"workspace:open-workspace": panelViewByScope["workspace:open-workspace"],
|
||||
});
|
||||
});
|
||||
|
||||
test("pruneInactiveScopedSessions reports inactive targets without deleting persisted history", () => {
|
||||
const sessions = [
|
||||
createSession("terminal-restorable", {
|
||||
|
||||
@@ -6,7 +6,6 @@ import type {
|
||||
|
||||
type DraftsByScope = Partial<Record<string, AIDraft>>;
|
||||
type PanelViewByScope = Partial<Record<string, AIPanelView>>;
|
||||
type ActiveSessionIdMap = Record<string, string | null>;
|
||||
|
||||
function isInactiveScopedTarget(
|
||||
scopeKey: string,
|
||||
@@ -55,41 +54,6 @@ export function pruneInactiveScopedState(
|
||||
};
|
||||
}
|
||||
|
||||
export function pruneInactiveScopedTransientState(
|
||||
activeSessionIdMap: ActiveSessionIdMap,
|
||||
draftsByScope: DraftsByScope,
|
||||
panelViewByScope: PanelViewByScope,
|
||||
activeTargetIds: Set<string>,
|
||||
): {
|
||||
activeSessionIdMap: ActiveSessionIdMap;
|
||||
draftsByScope: DraftsByScope;
|
||||
panelViewByScope: PanelViewByScope;
|
||||
} {
|
||||
let activeSessionMapChanged = false;
|
||||
const nextActiveSessionIdMap: ActiveSessionIdMap = {};
|
||||
|
||||
for (const [scopeKey, sessionId] of Object.entries(activeSessionIdMap)) {
|
||||
if (isInactiveScopedTarget(scopeKey, activeTargetIds)) {
|
||||
activeSessionMapChanged = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
nextActiveSessionIdMap[scopeKey] = sessionId;
|
||||
}
|
||||
|
||||
const nextScopedState = pruneInactiveScopedState(
|
||||
draftsByScope,
|
||||
panelViewByScope,
|
||||
activeTargetIds,
|
||||
);
|
||||
|
||||
return {
|
||||
activeSessionIdMap: activeSessionMapChanged ? nextActiveSessionIdMap : activeSessionIdMap,
|
||||
draftsByScope: nextScopedState.draftsByScope,
|
||||
panelViewByScope: nextScopedState.panelViewByScope,
|
||||
};
|
||||
}
|
||||
|
||||
export function pruneInactiveScopedSessions(
|
||||
sessions: AISession[],
|
||||
activeTargetIds: Set<string>,
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import type { AISession } from '../../infrastructure/ai/types';
|
||||
import {
|
||||
DebouncedBackupMirror,
|
||||
isMissingOrCorruptSessionsStore,
|
||||
mergeImportedSessions,
|
||||
parseBackupSessions,
|
||||
readLiveSessionsArray,
|
||||
shouldRestoreFromBackup,
|
||||
} from './aiSessionsBackup';
|
||||
import { prepareRestoredSessions, pruneSessionsForStorage } from './aiStateSnapshots';
|
||||
|
||||
function makeSession(id: string, updatedAt = 1_000): AISession {
|
||||
return {
|
||||
id,
|
||||
title: id,
|
||||
agentId: 'catty',
|
||||
scope: { type: 'workspace', targetId: 'ws-1' },
|
||||
messages: [],
|
||||
createdAt: updatedAt,
|
||||
updatedAt,
|
||||
} as never;
|
||||
}
|
||||
|
||||
test('isMissingOrCorruptSessionsStore distinguishes missing or corrupt stores from valid arrays', () => {
|
||||
assert.equal(isMissingOrCorruptSessionsStore(null), true);
|
||||
assert.equal(isMissingOrCorruptSessionsStore(''), true);
|
||||
assert.equal(isMissingOrCorruptSessionsStore('[]'), false);
|
||||
assert.equal(isMissingOrCorruptSessionsStore('{corrupt'), true);
|
||||
assert.equal(isMissingOrCorruptSessionsStore('{}'), true);
|
||||
assert.equal(isMissingOrCorruptSessionsStore('[{"id":"s1"}]'), false);
|
||||
});
|
||||
|
||||
test('readLiveSessionsArray normalizes missing and corrupt stores', () => {
|
||||
for (const raw of [null, undefined, '', '{}', 'not-json']) {
|
||||
assert.deepEqual(readLiveSessionsArray(raw), []);
|
||||
}
|
||||
const sessions = [makeSession('s1')];
|
||||
assert.deepEqual(readLiveSessionsArray(JSON.stringify(sessions)), sessions);
|
||||
});
|
||||
|
||||
test('parseBackupSessions ignores corrupt payloads and drops malformed entries', () => {
|
||||
assert.equal(parseBackupSessions(null), null);
|
||||
assert.equal(parseBackupSessions(''), null);
|
||||
assert.equal(parseBackupSessions('not json'), null);
|
||||
assert.equal(parseBackupSessions('{"id":"s1"}'), null);
|
||||
assert.equal(parseBackupSessions(JSON.stringify([{ id: 's1' }])), null);
|
||||
const sessions = parseBackupSessions(JSON.stringify([
|
||||
{ id: 's1' },
|
||||
{ nope: true },
|
||||
{ id: 'missing-scope', title: 'Missing scope', agentId: 'catty', messages: [], updatedAt: 1000 },
|
||||
{ ...makeSession('null-scope'), scope: null },
|
||||
{ ...makeSession('missing-title'), title: undefined },
|
||||
{ ...makeSession('missing-agent'), agentId: undefined },
|
||||
{ ...makeSession('null-message'), messages: [null] },
|
||||
{ ...makeSession('incomplete-message'), messages: [{ id: 'm' }] },
|
||||
{ ...makeSession('missing-content'), messages: [{ id: 'm', role: 'user', timestamp: 1000 }] },
|
||||
{ ...makeSession('invalid-content'), messages: [{ id: 'm', role: 'user', timestamp: 1000, content: null }] },
|
||||
{ ...makeSession('invalid-timestamp'), messages: [{ id: 'm', role: 'user', timestamp: null, content: 'hello' }] },
|
||||
null,
|
||||
{ ...makeSession('s2'), messages: [{ id: 'm', role: 'user', timestamp: 1000, content: 'hello' }] },
|
||||
]));
|
||||
assert.deepEqual(sessions?.map((session) => session.id), ['s2']);
|
||||
});
|
||||
|
||||
test('restore preserves all sessions when cloud sync is enabled', () => {
|
||||
const sessions = Array.from({ length: 51 }, (_, index) => makeSession(`s${index}`, index));
|
||||
assert.deepEqual(prepareRestoredSessions(sessions, true), sessions);
|
||||
const local = prepareRestoredSessions(sessions, false);
|
||||
assert.equal(local.length, 50);
|
||||
assert.equal(local.some(session => session.id === 's0'), false);
|
||||
});
|
||||
|
||||
test('malformed imports cannot reach pruning or inject broken sessions', () => {
|
||||
const current = [makeSession('live')];
|
||||
const malformed = parseBackupSessions(JSON.stringify([{ id: 's1' }]));
|
||||
assert.deepEqual(mergeImportedSessions(current, pruneSessionsForStorage(malformed ?? [])), current);
|
||||
const valid = parseBackupSessions(JSON.stringify([{ id: 's1' }, makeSession('valid')]));
|
||||
const merged = mergeImportedSessions(current, pruneSessionsForStorage(valid ?? []));
|
||||
assert.deepEqual(new Set(merged.map((session) => session.id)), new Set(['live', 'valid']));
|
||||
});
|
||||
|
||||
test('restore happens only on a missing or corrupt live store with a non-empty backup', () => {
|
||||
const backup = JSON.stringify([makeSession('s1')]);
|
||||
// Empty (or wiped) store -> restore.
|
||||
assert.equal(shouldRestoreFromBackup(null, backup), true);
|
||||
assert.equal(shouldRestoreFromBackup('', backup), true);
|
||||
assert.equal(shouldRestoreFromBackup('[]', backup), false);
|
||||
assert.equal(shouldRestoreFromBackup('{corrupt', backup), true);
|
||||
// Non-empty live store -> never restore (explicit deletions must win).
|
||||
assert.equal(shouldRestoreFromBackup('[{"id":"live"}]', backup), false);
|
||||
// Empty/corrupt backup -> nothing to restore.
|
||||
assert.equal(shouldRestoreFromBackup(null, null), false);
|
||||
assert.equal(shouldRestoreFromBackup(null, '[]'), false);
|
||||
assert.equal(shouldRestoreFromBackup(null, '{corrupt'), false);
|
||||
});
|
||||
|
||||
test('mergeImportedSessions keys by id with the newer updatedAt winning', () => {
|
||||
const current = [makeSession('keep', 100), makeSession('older', 100)];
|
||||
const imported = [makeSession('newer', 200), makeSession('older', 50), makeSession('added', 1)];
|
||||
const merged = mergeImportedSessions(current, imported);
|
||||
const byId = new Map(merged.map((session) => [session.id, session]));
|
||||
assert.equal(byId.get('older')?.updatedAt, 100);
|
||||
assert.equal(byId.get('newer')?.updatedAt, 200);
|
||||
assert.equal(byId.has('added'), true);
|
||||
assert.equal(byId.has('keep'), true);
|
||||
// Sorted newest-first for stable history ordering.
|
||||
assert.deepEqual(
|
||||
merged.map((session) => session.updatedAt),
|
||||
[...merged.map((session) => session.updatedAt)].sort((a, b) => b - a),
|
||||
);
|
||||
});
|
||||
|
||||
test('DebouncedBackupMirror writes once per batch with the last payload', async () => {
|
||||
const writes: string[] = [];
|
||||
const mirror = new DebouncedBackupMirror((json) => writes.push(json), 15);
|
||||
mirror.schedule('{"gen":1}');
|
||||
mirror.schedule('{"gen":2}');
|
||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||
mirror.schedule('{"gen":3}');
|
||||
mirror.schedule('{"gen":4}');
|
||||
await new Promise((resolve) => setTimeout(resolve, 40));
|
||||
assert.deepEqual(writes, ['{"gen":2}', '{"gen":4}']);
|
||||
});
|
||||
|
||||
test('DebouncedBackupMirror flushNow writes the pending payload immediately', () => {
|
||||
const writes: string[] = [];
|
||||
const mirror = new DebouncedBackupMirror((json) => writes.push(json), 60_000);
|
||||
mirror.schedule('{"gen":1}');
|
||||
mirror.flushNow();
|
||||
assert.deepEqual(writes, ['{"gen":1}']);
|
||||
// A second flush with nothing pending is a no-op.
|
||||
mirror.flushNow();
|
||||
assert.deepEqual(writes, ['{"gen":1}']);
|
||||
});
|
||||
|
||||
test('readLiveSessionsArray treats non-array JSON as empty (corrupt)', () => {
|
||||
assert.deepEqual(readLiveSessionsArray(null), []);
|
||||
assert.deepEqual(readLiveSessionsArray(''), []);
|
||||
assert.deepEqual(readLiveSessionsArray('{}'), []);
|
||||
assert.deepEqual(readLiveSessionsArray('{"sessions":[]}'), []);
|
||||
assert.deepEqual(readLiveSessionsArray('not-json'), []);
|
||||
assert.deepEqual(readLiveSessionsArray('[]'), []);
|
||||
assert.equal(readLiveSessionsArray('[{"id":"a"}]').length, 1);
|
||||
});
|
||||
@@ -0,0 +1,156 @@
|
||||
import type { AISession } from '../../infrastructure/ai/types';
|
||||
|
||||
/**
|
||||
* Renderer-side logic for the AI session durability layer (issue #4015 R2
|
||||
* follow-up): a disk mirror of the localStorage session store plus a
|
||||
* restore-on-empty-store boot path. The IPC plumbing lives in
|
||||
* `electron/bridges/aiSessionsBackupBridge.cjs`; this module keeps the
|
||||
* decisions pure so they can be tested without Electron.
|
||||
*/
|
||||
|
||||
/** Debounce window: at most one backup write per mutation burst (~2s). */
|
||||
export const AI_SESSIONS_BACKUP_DEBOUNCE_MS = 2000;
|
||||
|
||||
export interface AiSessionsBackupBridge {
|
||||
aiSessionsBackupWrite?: (payload: { json: string }) => Promise<{ ok: boolean }>;
|
||||
aiSessionsBackupReadLatest?: () => Promise<{ ok: boolean; json?: string }>;
|
||||
aiSessionsBackupExport?: (payload: { json: string }) => Promise<{ success: boolean; filePath?: string }>;
|
||||
aiSessionsBackupImport?: () => Promise<{ success: boolean; json?: string }>;
|
||||
}
|
||||
|
||||
/** Restore only when the live store is missing or malformed. A valid empty
|
||||
* array represents an intentional deletion and must remain authoritative. */
|
||||
export function isMissingOrCorruptSessionsStore(raw: string | null | undefined): boolean {
|
||||
if (raw == null || raw === '') return true;
|
||||
try {
|
||||
return !Array.isArray(JSON.parse(raw));
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/** Read the live sessions array from a raw localStorage string. Returns [] when
|
||||
* missing/corrupt (including parseable non-arrays such as `{}`), so callers never
|
||||
* treat a non-array as AISession[]. */
|
||||
export function readLiveSessionsArray(raw: string | null | undefined): AISession[] {
|
||||
if (isMissingOrCorruptSessionsStore(raw)) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(raw as string);
|
||||
return Array.isArray(parsed) ? parsed as AISession[] : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a backup payload into sessions. Returns null when the payload is
|
||||
* corrupt or malformed (ignored gracefully); also drops entries without a
|
||||
* usable required fields. Backup generations are written by the same serializer as the
|
||||
* live store, so entries are expected to be full AISession objects.
|
||||
*/
|
||||
|
||||
export function parseBackupSessions(json: string | null | undefined): AISession[] | null {
|
||||
if (typeof json !== 'string' || json === '') return null;
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(json);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!Array.isArray(parsed)) return null;
|
||||
const sessions = parsed.filter(
|
||||
(entry): entry is AISession => (
|
||||
typeof entry === 'object' && entry !== null
|
||||
&& typeof (entry as AISession).id === 'string'
|
||||
&& typeof (entry as AISession).title === 'string'
|
||||
&& typeof (entry as AISession).agentId === 'string'
|
||||
&& typeof (entry as AISession).scope === 'object'
|
||||
&& (entry as AISession).scope !== null
|
||||
&& typeof (entry as AISession).scope.type === 'string'
|
||||
&& Array.isArray((entry as AISession).messages)
|
||||
&& (entry as AISession).messages.every(message => (
|
||||
typeof message === 'object' && message !== null
|
||||
&& typeof message.id === 'string'
|
||||
&& typeof message.role === 'string'
|
||||
&& typeof message.content === 'string'
|
||||
&& typeof message.timestamp === 'number'
|
||||
&& Number.isFinite(message.timestamp)
|
||||
))
|
||||
&& typeof (entry as AISession).updatedAt === 'number'
|
||||
&& Number.isFinite((entry as AISession).updatedAt)
|
||||
),
|
||||
);
|
||||
return sessions.length > 0 ? sessions : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide whether to restore on boot: only when the live store is missing or corrupt AND
|
||||
* the backup actually contains at least one session.
|
||||
*/
|
||||
export function shouldRestoreFromBackup(
|
||||
liveRaw: string | null | undefined,
|
||||
backupJson: string | null | undefined,
|
||||
): boolean {
|
||||
if (!isMissingOrCorruptSessionsStore(liveRaw)) return false;
|
||||
const sessions = parseBackupSessions(backupJson);
|
||||
return sessions != null && sessions.length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge an imported backup into the current store: entries are keyed by id
|
||||
* and the newer `updatedAt` wins. Explicit user action, so it may overwrite
|
||||
* the live store (unlike the boot restore, which is empty-only).
|
||||
*/
|
||||
export function mergeImportedSessions(current: AISession[], imported: AISession[]): AISession[] {
|
||||
const byId = new Map<string, AISession>();
|
||||
for (const session of current) byId.set(session.id, session);
|
||||
for (const session of imported) {
|
||||
const existing = byId.get(session.id);
|
||||
if (!existing || (session.updatedAt ?? 0) >= (existing.updatedAt ?? 0)) {
|
||||
byId.set(session.id, session);
|
||||
}
|
||||
}
|
||||
return Array.from(byId.values()).sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0));
|
||||
}
|
||||
|
||||
/**
|
||||
* Debounced mirror scheduler. One rolling timer per process: the last
|
||||
* mutation batch wins, and the write happens `debounceMs` after the batch
|
||||
* settles (at most once per batch, per the durability spec).
|
||||
*/
|
||||
export class DebouncedBackupMirror {
|
||||
private timer: ReturnType<typeof setTimeout> | null = null;
|
||||
private pendingJson: string | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly write: (json: string) => void,
|
||||
private readonly debounceMs: number = AI_SESSIONS_BACKUP_DEBOUNCE_MS,
|
||||
) {}
|
||||
|
||||
schedule(json: string) {
|
||||
this.pendingJson = json;
|
||||
if (this.timer) clearTimeout(this.timer);
|
||||
this.timer = setTimeout(() => {
|
||||
this.timer = null;
|
||||
const json = this.pendingJson;
|
||||
this.pendingJson = null;
|
||||
if (json != null) this.write(json);
|
||||
}, this.debounceMs);
|
||||
}
|
||||
|
||||
flushNow() {
|
||||
if (this.timer) {
|
||||
clearTimeout(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
const json = this.pendingJson;
|
||||
this.pendingJson = null;
|
||||
if (json != null) this.write(json);
|
||||
}
|
||||
|
||||
reset() {
|
||||
if (this.timer) clearTimeout(this.timer);
|
||||
this.timer = null;
|
||||
this.pendingJson = null;
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
latestAISessionsSnapshot,
|
||||
setLatestAIActiveSessionMapSnapshot,
|
||||
setLatestAISessionsSnapshot,
|
||||
rebindActiveSessionMap,
|
||||
} from './aiStateSnapshots';
|
||||
|
||||
test('orphan cleanup keeps durable Catty output while explicit deletion removes it', async () => {
|
||||
@@ -90,7 +91,9 @@ test('orphan cleanup preserves resumable privacy maps until explicit chat deleti
|
||||
const previousWindow = globalThis.window;
|
||||
const previousSessions = latestAISessionsSnapshot;
|
||||
const previousActiveSessionMap = latestAIActiveSessionMapSnapshot;
|
||||
t.mock.method(localStorageAdapter, 'read', () => null);
|
||||
t.mock.method(localStorageAdapter, 'write', () => true);
|
||||
t.mock.method(localStorageAdapter, 'writeString', () => true);
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: {
|
||||
@@ -122,8 +125,24 @@ test('orphan cleanup preserves resumable privacy maps until explicit chat deleti
|
||||
try {
|
||||
cleanupOrphanedAISessions(new Set(['open', 'resumed-into']));
|
||||
|
||||
assert.equal(latestAISessionsSnapshot, sessions);
|
||||
// Issue #4015 R2: the dropped chat is marked dangling but its record and
|
||||
// its active pointer stay intact so /chats can offer a rebind.
|
||||
const persistedSessions = latestAISessionsSnapshot;
|
||||
assert.ok(persistedSessions);
|
||||
assert.equal(
|
||||
persistedSessions.find((session) => session.id === 'privacy-orphan')?.scopeDangling,
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
persistedSessions.find((session) => session.id === 'privacy-live')?.scopeDangling,
|
||||
undefined,
|
||||
);
|
||||
assert.equal(
|
||||
persistedSessions.find((session) => session.id === 'privacy-resumed')?.scopeDangling,
|
||||
undefined,
|
||||
);
|
||||
assert.deepEqual(latestAIActiveSessionMapSnapshot, {
|
||||
'terminal:closed': 'privacy-orphan',
|
||||
'terminal:open': 'privacy-live',
|
||||
'terminal:resumed-into': 'privacy-resumed',
|
||||
});
|
||||
@@ -158,6 +177,95 @@ test('orphan cleanup preserves resumable privacy maps until explicit chat deleti
|
||||
}
|
||||
});
|
||||
|
||||
test('orphan cleanup marks dangling chats instead of stripping them, and clears the flag when the target returns', (t) => {
|
||||
const previousSessions = latestAISessionsSnapshot;
|
||||
const previousActiveSessionMap = latestAIActiveSessionMapSnapshot;
|
||||
const read = t.mock.method(localStorageAdapter, 'read', () => null);
|
||||
t.mock.method(localStorageAdapter, 'write', () => true);
|
||||
const writeString = t.mock.method(localStorageAdapter, 'writeString', () => true);
|
||||
const previousWindow = globalThis.window;
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: { dispatchEvent: () => true, netcatty: {} },
|
||||
});
|
||||
const dangling = {
|
||||
...makeSession('dangling-chat', 1, []),
|
||||
scope: { type: 'terminal' as const, targetId: 'closed-ws' },
|
||||
};
|
||||
const live = {
|
||||
...makeSession('live-chat', 2, []),
|
||||
scope: { type: 'terminal' as const, targetId: 'open' },
|
||||
};
|
||||
setLatestAISessionsSnapshot([dangling, live]);
|
||||
setLatestAIActiveSessionMapSnapshot({
|
||||
'terminal:closed-ws': 'dangling-chat',
|
||||
'terminal:open': 'live-chat',
|
||||
});
|
||||
|
||||
try {
|
||||
cleanupOrphanedAISessions(new Set(['open']));
|
||||
|
||||
let persisted = latestAISessionsSnapshot;
|
||||
assert.ok(persisted);
|
||||
assert.equal(persisted.find((s) => s.id === 'dangling-chat')?.scopeDangling, true);
|
||||
assert.equal(persisted.find((s) => s.id === 'live-chat')?.scopeDangling, undefined);
|
||||
// The active pointer survives the dangling marking.
|
||||
assert.deepEqual(latestAIActiveSessionMapSnapshot, {
|
||||
'terminal:closed-ws': 'dangling-chat',
|
||||
'terminal:open': 'live-chat',
|
||||
});
|
||||
assert.ok(writeString.mock.callCount() > 0);
|
||||
|
||||
// The scope target becomes live again → the dangling flag clears.
|
||||
read.mock.mockImplementation(() => null);
|
||||
cleanupOrphanedAISessions(new Set(['open', 'closed-ws']));
|
||||
persisted = latestAISessionsSnapshot;
|
||||
assert.ok(persisted);
|
||||
assert.equal(persisted.find((s) => s.id === 'dangling-chat')?.scopeDangling, undefined);
|
||||
} finally {
|
||||
setLatestAISessionsSnapshot(previousSessions ?? []);
|
||||
setLatestAIActiveSessionMapSnapshot(previousActiveSessionMap ?? {});
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: previousWindow,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('orphan cleanup with an empty live-target set marks all scoped chats dangling', (t) => {
|
||||
const previousSessions = latestAISessionsSnapshot;
|
||||
const previousActiveSessionMap = latestAIActiveSessionMapSnapshot;
|
||||
t.mock.method(localStorageAdapter, 'read', () => null);
|
||||
t.mock.method(localStorageAdapter, 'write', () => true);
|
||||
t.mock.method(localStorageAdapter, 'writeString', () => true);
|
||||
const previousWindow = globalThis.window;
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: { dispatchEvent: () => true, netcatty: {} },
|
||||
});
|
||||
const orphan = {
|
||||
...makeSession('orphan-chat', 1, []),
|
||||
scope: { type: 'terminal' as const, targetId: 'gone' },
|
||||
};
|
||||
setLatestAISessionsSnapshot([orphan]);
|
||||
setLatestAIActiveSessionMapSnapshot({ 'terminal:gone': 'orphan-chat' });
|
||||
|
||||
try {
|
||||
// Empty is a valid observed live set (nothing mounted), not "unknown".
|
||||
cleanupOrphanedAISessions(new Set());
|
||||
const persisted = latestAISessionsSnapshot;
|
||||
assert.ok(persisted);
|
||||
assert.equal(persisted.find((s) => s.id === 'orphan-chat')?.scopeDangling, true);
|
||||
} finally {
|
||||
setLatestAISessionsSnapshot(previousSessions ?? []);
|
||||
setLatestAIActiveSessionMapSnapshot(previousActiveSessionMap ?? {});
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: previousWindow,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('serializeSessionsForStorage strips oldest ciphertext before dropping visible sessions', async () => {
|
||||
const { serializeSessionsForStorage } = await import('./aiStateSnapshots');
|
||||
const ciphertext = 'gAAAA'.repeat(50000); // ~250 KB of ciphertext per message
|
||||
@@ -817,3 +925,65 @@ for (const [budget, expectedCalls] of [[128 * 1024, 160], [0, 161]]) {
|
||||
assert.equal(result.sessions.length, budget === 0 ? 1 : 4);
|
||||
});
|
||||
}
|
||||
test('successful store writes schedule a throttled disk backup mirror', async (t) => {
|
||||
const { writeSessionsForStorage, flushBackupMirror, __resetBackupMirrorForTests } =
|
||||
await import('./aiStateSnapshots');
|
||||
__resetBackupMirrorForTests();
|
||||
const backupWrites: string[] = [];
|
||||
const previousWindow = globalThis.window;
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: {
|
||||
netcatty: {
|
||||
aiSessionsBackupWrite: async (payload: { json: string }) => {
|
||||
backupWrites.push(payload.json);
|
||||
return { ok: true };
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const sessions = freezeHistory(makePersistenceHistory(3, 10));
|
||||
const writeString = t.mock.method(localStorageAdapter, 'writeString', () => true);
|
||||
t.mock.method(localStorageAdapter, 'read', () => null);
|
||||
try {
|
||||
assert.equal(writeSessionsForStorage(sessions), true);
|
||||
// Throttled: nothing has been written to disk yet.
|
||||
assert.equal(backupWrites.length, 0);
|
||||
flushBackupMirror();
|
||||
assert.equal(backupWrites.length, 1);
|
||||
// The mirror carries exactly the JSON that landed in localStorage.
|
||||
assert.equal(backupWrites[0], writeString.mock.calls[0].arguments[1]);
|
||||
// Failed store writes never schedule a mirror.
|
||||
writeString.mock.mockImplementation(() => false);
|
||||
assert.equal(writeSessionsForStorage(sessions), false);
|
||||
flushBackupMirror();
|
||||
assert.equal(backupWrites.length, 1);
|
||||
} finally {
|
||||
__resetBackupMirrorForTests();
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: previousWindow,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('rebindActiveSessionMap drops stale pointers and sets the new scope', () => {
|
||||
const cleaned = rebindActiveSessionMap(
|
||||
{
|
||||
'terminal:old-ws': 'chat-1',
|
||||
'terminal:other': 'chat-2',
|
||||
'workspace:keep': 'chat-1',
|
||||
},
|
||||
'chat-1',
|
||||
'terminal:new-ws',
|
||||
);
|
||||
assert.deepEqual(cleaned, {
|
||||
'terminal:other': 'chat-2',
|
||||
'terminal:new-ws': 'chat-1',
|
||||
});
|
||||
// Idempotent when already bound to the target scope with no other pointers.
|
||||
assert.deepEqual(
|
||||
rebindActiveSessionMap({ 'terminal:new-ws': 'chat-1' }, 'chat-1', 'terminal:new-ws'),
|
||||
{ 'terminal:new-ws': 'chat-1' },
|
||||
);
|
||||
});
|
||||
@@ -20,10 +20,11 @@ import {
|
||||
} from './aiDraftState';
|
||||
import {
|
||||
pruneInactiveScopedSessions,
|
||||
pruneInactiveScopedTransientState,
|
||||
pruneInactiveScopedState,
|
||||
} from './aiScopeCleanup';
|
||||
import { emitAIStateChanged } from './aiStateEvents';
|
||||
import { getAgentRuntime } from '../../infrastructure/ai/harness/globalAgentRuntime';
|
||||
import { DebouncedBackupMirror, readLiveSessionsArray } from './aiSessionsBackup';
|
||||
|
||||
/** Typed accessor for the Electron IPC bridge exposed on `window.netcatty`. */
|
||||
export interface AIBridge {
|
||||
@@ -35,6 +36,11 @@ export interface AIBridge {
|
||||
aiMcpSetCommandBlocklist?: (blocklist: string[]) => Promise<unknown> | unknown;
|
||||
aiMcpSetCommandTimeout?: (timeout: number) => Promise<unknown> | unknown;
|
||||
aiMcpSetMaxIterations?: (maxIterations: number) => Promise<unknown> | unknown;
|
||||
/** Durability mirror: write one timestamped backup generation to disk. */
|
||||
aiSessionsBackupWrite?: (payload: { json: string }) => Promise<{ ok: boolean }>;
|
||||
aiSessionsBackupReadLatest?: () => Promise<{ ok: boolean; json?: string }>;
|
||||
aiSessionsBackupExport?: (payload: { json: string }) => Promise<{ success: boolean; filePath?: string }>;
|
||||
aiSessionsBackupImport?: () => Promise<{ success: boolean; json?: string }>;
|
||||
}
|
||||
|
||||
export function getAIBridge() {
|
||||
@@ -82,10 +88,13 @@ function isScopeKeyActive(scopeKey: string, activeTargetIds: Set<string>) {
|
||||
return activeTargetIds.has(targetId);
|
||||
}
|
||||
|
||||
/** Last live terminal/workspace target ids observed by orphan cleanup. */
|
||||
export let latestAIActiveTargetIdsSnapshot: Set<string> = new Set();
|
||||
|
||||
export function cleanupOrphanedAISessions(activeTargetIds: Set<string>) {
|
||||
latestAIActiveTargetIdsSnapshot = activeTargetIds;
|
||||
const currentSessions = latestAISessionsSnapshot
|
||||
?? localStorageAdapter.read<AISession[]>(STORAGE_KEY_AI_SESSIONS)
|
||||
?? [];
|
||||
?? readLiveSessionsArray(localStorageAdapter.readString(STORAGE_KEY_AI_SESSIONS));
|
||||
|
||||
// Sessions shown by a still-live scope must be protected from cleanup
|
||||
// even when their own `scope.targetId` points at a closed terminal —
|
||||
@@ -111,65 +120,62 @@ export function cleanupOrphanedAISessions(activeTargetIds: Set<string>) {
|
||||
cleanupSdkAgentSessions(nextSessionCleanup.orphanedSessionIds);
|
||||
}
|
||||
|
||||
if (nextSessionCleanup.sessions !== currentSessions) {
|
||||
setLatestAISessionsSnapshot(nextSessionCleanup.sessions);
|
||||
writeSessionsForStorage(nextSessionCleanup.sessions);
|
||||
// Issue #4015 R2: a dropped chat is recoverable now, so mark it dangling
|
||||
// instead of stripping its record or pointer. `/chats` lists dangling chats
|
||||
// with a rebind affordance; the flag clears when the chat is rebound or its
|
||||
// scope target becomes live again.
|
||||
const orphanedIds = new Set(nextSessionCleanup.orphanedSessionIds);
|
||||
let sessionsMarked = false;
|
||||
const markedSessions = currentSessions.map((session) => {
|
||||
if (orphanedIds.has(session.id)) {
|
||||
if (session.scopeDangling) return session;
|
||||
sessionsMarked = true;
|
||||
return { ...session, scopeDangling: true };
|
||||
}
|
||||
if (
|
||||
session.scopeDangling
|
||||
&& (!session.scope.targetId || activeTargetIds.has(session.scope.targetId))
|
||||
) {
|
||||
sessionsMarked = true;
|
||||
const { scopeDangling: _cleared, ...rest } = session;
|
||||
return rest as AISession;
|
||||
}
|
||||
return session;
|
||||
});
|
||||
|
||||
if (sessionsMarked) {
|
||||
setLatestAISessionsSnapshot(markedSessions);
|
||||
writeSessionsForStorage(markedSessions);
|
||||
emitAIStateChanged(STORAGE_KEY_AI_SESSIONS);
|
||||
}
|
||||
|
||||
const activeSessionIdMap = preCleanupActiveSessionMap;
|
||||
let activeSessionMapChanged = false;
|
||||
const nextActiveSessionIdMap = { ...activeSessionIdMap };
|
||||
|
||||
for (const scopeKey of Object.keys(activeSessionIdMap)) {
|
||||
if (isScopeKeyActive(scopeKey, activeTargetIds)) continue;
|
||||
delete nextActiveSessionIdMap[scopeKey];
|
||||
activeSessionMapChanged = true;
|
||||
}
|
||||
|
||||
if (activeSessionMapChanged) {
|
||||
setLatestAIActiveSessionMapSnapshot(nextActiveSessionIdMap);
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_ACTIVE_SESSION_MAP, nextActiveSessionIdMap);
|
||||
emitAIStateChanged(STORAGE_KEY_AI_ACTIVE_SESSION_MAP);
|
||||
}
|
||||
|
||||
const currentActiveSessionIdMap = activeSessionMapChanged
|
||||
? nextActiveSessionIdMap
|
||||
: activeSessionIdMap;
|
||||
// Issue #4015 R2: keep active-pointer entries for scopes whose target
|
||||
// closed. Clearing them used to make dangling chats invisible (rank 0) and
|
||||
// unrecoverable from the UI; the pointer is rewritten on rebind instead.
|
||||
const currentDraftsByScope = latestAIDraftsByScopeSnapshot ?? {};
|
||||
const currentPanelViewByScope = latestAIPanelViewByScopeSnapshot ?? {};
|
||||
const prunedScopedTransientState = pruneInactiveScopedTransientState(
|
||||
currentActiveSessionIdMap,
|
||||
const prunedScopedState = pruneInactiveScopedState(
|
||||
currentDraftsByScope,
|
||||
currentPanelViewByScope,
|
||||
activeTargetIds,
|
||||
);
|
||||
|
||||
if (prunedScopedTransientState.activeSessionIdMap !== currentActiveSessionIdMap) {
|
||||
setLatestAIActiveSessionMapSnapshot(prunedScopedTransientState.activeSessionIdMap);
|
||||
localStorageAdapter.write(
|
||||
STORAGE_KEY_AI_ACTIVE_SESSION_MAP,
|
||||
prunedScopedTransientState.activeSessionIdMap,
|
||||
);
|
||||
emitAIStateChanged(STORAGE_KEY_AI_ACTIVE_SESSION_MAP);
|
||||
}
|
||||
|
||||
if (prunedScopedTransientState.draftsByScope !== currentDraftsByScope) {
|
||||
if (prunedScopedState.draftsByScope !== currentDraftsByScope) {
|
||||
for (const scopeKey of Object.keys(currentDraftsByScope)) {
|
||||
if (scopeKey in prunedScopedTransientState.draftsByScope) continue;
|
||||
if (scopeKey in prunedScopedState.draftsByScope) continue;
|
||||
bumpDraftMutationVersion(scopeKey);
|
||||
bumpDraftUploadGeneration(scopeKey);
|
||||
}
|
||||
setLatestAIDraftsByScopeSnapshot(prunedScopedTransientState.draftsByScope);
|
||||
setLatestAIDraftsByScopeSnapshot(prunedScopedState.draftsByScope);
|
||||
emitAIStateChanged(AI_STATE_CHANGED_DRAFTS_BY_SCOPE);
|
||||
}
|
||||
|
||||
if (prunedScopedTransientState.panelViewByScope !== currentPanelViewByScope) {
|
||||
if (prunedScopedState.panelViewByScope !== currentPanelViewByScope) {
|
||||
for (const scopeKey of Object.keys(currentPanelViewByScope)) {
|
||||
if (scopeKey in prunedScopedTransientState.panelViewByScope) continue;
|
||||
if (scopeKey in prunedScopedState.panelViewByScope) continue;
|
||||
bumpDraftMutationVersion(scopeKey);
|
||||
}
|
||||
setLatestAIPanelViewByScopeSnapshot(prunedScopedTransientState.panelViewByScope);
|
||||
setLatestAIPanelViewByScopeSnapshot(prunedScopedState.panelViewByScope);
|
||||
emitAIStateChanged(AI_STATE_CHANGED_PANEL_VIEW_BY_SCOPE);
|
||||
}
|
||||
}
|
||||
@@ -349,6 +355,10 @@ export function pruneSessionsForStorage(sessions: AISession[]): AISession[] {
|
||||
});
|
||||
}
|
||||
|
||||
export function prepareRestoredSessions(sessions: AISession[], syncEnabled: boolean): AISession[] {
|
||||
return syncEnabled ? sessions : pruneSessionsForStorage(sessions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize sessions for localStorage under a byte budget, escalating pruning
|
||||
* as needed. Returns the JSON to persist plus the (possibly) further-pruned
|
||||
@@ -486,11 +496,47 @@ export function serializeSessionsForStorage(
|
||||
* Returns true when the write succeeded; a false result means the payload
|
||||
* could not be persisted even after escalation (it stays memory-only).
|
||||
*/
|
||||
|
||||
// Durability mirror (issue #4015 follow-up): every successful store write
|
||||
// also schedules a debounced disk backup so a wiped/recreated profile does
|
||||
// not take the chat history with it. The exact JSON that landed in
|
||||
// localStorage is mirrored (already byte-budgeted), throttled to at most one
|
||||
// write per mutation batch.
|
||||
const backupMirror = new DebouncedBackupMirror((json) => {
|
||||
try {
|
||||
void getAIBridge()?.aiSessionsBackupWrite?.({ json })?.catch(() => {});
|
||||
} catch {
|
||||
// Backup failures must never break chat persistence.
|
||||
}
|
||||
});
|
||||
|
||||
export function flushBackupMirror() {
|
||||
try {
|
||||
backupMirror.flushNow();
|
||||
} catch {
|
||||
// Unload and persistence must remain fail-safe.
|
||||
}
|
||||
}
|
||||
|
||||
export function __resetBackupMirrorForTests() {
|
||||
backupMirror.reset();
|
||||
}
|
||||
|
||||
export function scheduleBackupMirror(json: string) {
|
||||
try {
|
||||
backupMirror.schedule(json);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
export function writeSessionsForStorage(sessions: AISession[]): boolean {
|
||||
// Synced records are authoritative after restart: silently evicting a
|
||||
// session or turn here would turn local retention into cloud deletion.
|
||||
if (localStorageAdapter.read<{ enabled?: boolean }>(STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS)?.enabled) {
|
||||
return localStorageAdapter.write(STORAGE_KEY_AI_SESSIONS, sessions);
|
||||
const syncedOk = localStorageAdapter.write(STORAGE_KEY_AI_SESSIONS, sessions);
|
||||
if (syncedOk) scheduleBackupMirror(JSON.stringify(sessions));
|
||||
return syncedOk;
|
||||
}
|
||||
let previousLength = Number.POSITIVE_INFINITY;
|
||||
for (const configuredBudget of [MAX_SESSIONS_JSON_BYTES, ...RETRY_SESSIONS_JSON_BYTES]) {
|
||||
@@ -505,7 +551,10 @@ export function writeSessionsForStorage(sessions: AISession[]): boolean {
|
||||
if (budget < 2) break;
|
||||
const candidate = serializeSessionsForStorage(sessions, budget);
|
||||
if (candidate.json.length >= previousLength) continue;
|
||||
if (localStorageAdapter.writeString(STORAGE_KEY_AI_SESSIONS, candidate.json)) return true;
|
||||
if (localStorageAdapter.writeString(STORAGE_KEY_AI_SESSIONS, candidate.json)) {
|
||||
scheduleBackupMirror(candidate.json);
|
||||
return true;
|
||||
}
|
||||
previousLength = candidate.json.length;
|
||||
}
|
||||
// Last resort: attempt the smallest representation this serializer can
|
||||
@@ -516,6 +565,7 @@ export function writeSessionsForStorage(sessions: AISession[]): boolean {
|
||||
minimalCandidate.json.length < previousLength
|
||||
&& localStorageAdapter.writeString(STORAGE_KEY_AI_SESSIONS, minimalCandidate.json)
|
||||
) {
|
||||
scheduleBackupMirror(minimalCandidate.json);
|
||||
return true;
|
||||
}
|
||||
console.warn(
|
||||
@@ -531,6 +581,28 @@ export let latestAIPanelViewByScopeSnapshot: PanelViewByScope | null = null;
|
||||
let latestAIDraftMutationVersionByScopeSnapshot: Record<string, number> = {};
|
||||
let latestAIDraftUploadGenerationByScopeSnapshot: Record<string, number> = {};
|
||||
|
||||
|
||||
/**
|
||||
* Issue #4015 R2: rewrite the active-session map when rebinding a dangling
|
||||
* chat. Drops every pointer that still names `sessionId` under a different
|
||||
* scope key, then sets the new scope as active. Callers must persist and
|
||||
* publish the returned map (do not rebuild from a stale snapshot).
|
||||
*/
|
||||
export function rebindActiveSessionMap(
|
||||
activeSessionIdMap: Record<string, string | null>,
|
||||
sessionId: string,
|
||||
scopeKey: string,
|
||||
): Record<string, string | null> {
|
||||
let next = activeSessionIdMap;
|
||||
for (const [key, id] of Object.entries(activeSessionIdMap)) {
|
||||
if (id !== sessionId || key === scopeKey) continue;
|
||||
if (next === activeSessionIdMap) next = { ...activeSessionIdMap };
|
||||
delete next[key];
|
||||
}
|
||||
if (next[scopeKey] === sessionId) return next;
|
||||
return { ...next, [scopeKey]: sessionId };
|
||||
}
|
||||
|
||||
export function setLatestAISessionsSnapshot(sessions: AISession[]) {
|
||||
latestAISessionsSnapshot = sessions;
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
STORAGE_KEY_AI_AGENT_THINKING_MAP,
|
||||
STORAGE_KEY_AI_WEB_SEARCH,
|
||||
STORAGE_KEY_AI_QUICK_MESSAGES,
|
||||
STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS,
|
||||
} from '../../infrastructure/config/storageKeys';
|
||||
import type { AIQuickMessage } from '../../infrastructure/ai/quickMessages';
|
||||
import { sanitizeQuickMessages } from '../../infrastructure/ai/quickMessages';
|
||||
@@ -63,12 +64,17 @@ import {
|
||||
bumpDraftUploadGeneration,
|
||||
cleanupDeletedAIChatSessions,
|
||||
cleanupOrphanedAISessions,
|
||||
latestAIActiveTargetIdsSnapshot,
|
||||
getAIBridge,
|
||||
flushBackupMirror,
|
||||
getDraftUploadGeneration,
|
||||
latestAIActiveSessionMapSnapshot,
|
||||
latestAIDraftsByScopeSnapshot,
|
||||
latestAIPanelViewByScopeSnapshot,
|
||||
latestAISessionsSnapshot,
|
||||
prepareRestoredSessions,
|
||||
scheduleBackupMirror,
|
||||
rebindActiveSessionMap,
|
||||
writeSessionsForStorage,
|
||||
setLatestAIActiveSessionMapSnapshot,
|
||||
setLatestAIDraftsByScopeSnapshot,
|
||||
@@ -77,6 +83,12 @@ import {
|
||||
type DraftsByScope,
|
||||
type PanelViewByScope,
|
||||
} from './aiStateSnapshots';
|
||||
import {
|
||||
isMissingOrCorruptSessionsStore,
|
||||
mergeImportedSessions,
|
||||
parseBackupSessions,
|
||||
readLiveSessionsArray,
|
||||
} from './aiSessionsBackup';
|
||||
import { AI_STATE_CHANGED_EVENT, emitAIStateChanged } from './aiStateEvents';
|
||||
import {
|
||||
persistCommandBlocklistSetting,
|
||||
@@ -168,9 +180,76 @@ export function useAIState() {
|
||||
// ── Sessions ──
|
||||
const [sessions, setSessionsRaw] = useState<AISession[]>(() =>
|
||||
latestAISessionsSnapshot
|
||||
?? localStorageAdapter.read<AISession[]>(STORAGE_KEY_AI_SESSIONS)
|
||||
?? []
|
||||
?? readLiveSessionsArray(localStorageAdapter.readString(STORAGE_KEY_AI_SESSIONS))
|
||||
);
|
||||
// An explicit empty array is authoritative, even if the backup is stale.
|
||||
// Only a missing or corrupt store can be restored from disk.
|
||||
useEffect(() => {
|
||||
window.addEventListener('beforeunload', flushBackupMirror);
|
||||
return () => window.removeEventListener('beforeunload', flushBackupMirror);
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
const liveRaw = localStorageAdapter.readString(STORAGE_KEY_AI_SESSIONS);
|
||||
if (!isMissingOrCorruptSessionsStore(liveRaw)) {
|
||||
if (readLiveSessionsArray(liveRaw).length > 0 && liveRaw != null) scheduleBackupMirror(liveRaw);
|
||||
return;
|
||||
}
|
||||
if (latestAISessionsSnapshot && latestAISessionsSnapshot.length > 0) return;
|
||||
const bridge = getAIBridge();
|
||||
if (!bridge?.aiSessionsBackupReadLatest) return;
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
try {
|
||||
const liveRaw = localStorageAdapter.readString(STORAGE_KEY_AI_SESSIONS);
|
||||
if (!isMissingOrCorruptSessionsStore(liveRaw)) return;
|
||||
const backup = await bridge.aiSessionsBackupReadLatest?.();
|
||||
if (cancelled || !backup?.ok) return;
|
||||
// Re-check after the async gap: a session may have been created
|
||||
// while the backup was being read; never restore over live state.
|
||||
// Cover both persisted storage and in-memory snapshot/React state —
|
||||
// createSession updates the snapshot before the debounced disk write.
|
||||
if (!isMissingOrCorruptSessionsStore(localStorageAdapter.readString(STORAGE_KEY_AI_SESSIONS))) {
|
||||
return;
|
||||
}
|
||||
if ((latestAISessionsSnapshot?.length ?? 0) > 0) return;
|
||||
const syncEnabled = !!localStorageAdapter.read<{ enabled?: boolean }>(STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS)?.enabled;
|
||||
const restored = prepareRestoredSessions(parseBackupSessions(backup.json) ?? [], syncEnabled);
|
||||
if (restored.length === 0) return;
|
||||
// Apply restore synchronously (not inside a setState updater). A
|
||||
// deferred updater would leave didRestore false and skip dangling
|
||||
// marking even when the records later land in React state.
|
||||
if ((latestAISessionsSnapshot?.length ?? 0) > 0) return;
|
||||
if (sessionsRef.current.length > 0) return;
|
||||
if (!isMissingOrCorruptSessionsStore(localStorageAdapter.readString(STORAGE_KEY_AI_SESSIONS))) {
|
||||
return;
|
||||
}
|
||||
setLatestAISessionsSnapshot(restored);
|
||||
writeSessionsForStorage(restored);
|
||||
emitAIStateChanged(STORAGE_KEY_AI_SESSIONS);
|
||||
// Restore does not change live terminals/workspaces, so the orphan-
|
||||
// cleanup effect will not run. Reuse the last known live target set
|
||||
// (empty is valid: nothing live) so chats bound to missing scopes
|
||||
// become dangling and /chats can offer /rebind.
|
||||
cleanupOrphanedAISessions(latestAIActiveTargetIdsSnapshot);
|
||||
const nextSessions = latestAISessionsSnapshot ?? restored;
|
||||
sessionsRef.current = nextSessions;
|
||||
setSessionsRaw(nextSessions);
|
||||
setActiveSessionIdMapRaw(
|
||||
latestAIActiveSessionMapSnapshot
|
||||
?? localStorageAdapter.read<Record<string, string | null>>(STORAGE_KEY_AI_ACTIVE_SESSION_MAP)
|
||||
?? {},
|
||||
);
|
||||
console.info(
|
||||
`[AIState] Restored ${restored.length} AI session(s) from the disk backup.`,
|
||||
);
|
||||
} catch {
|
||||
// Backup restore must never break startup.
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
// Ref that always holds the latest sessions for use inside debounced callbacks
|
||||
const sessionsRef = useRef(sessions);
|
||||
useEffect(() => {
|
||||
@@ -866,6 +945,101 @@ export function useAIState() {
|
||||
});
|
||||
}, [persistSessions]);
|
||||
|
||||
// Issue #4015 R2: rebind a dangling chat to the panel's current scope.
|
||||
// Rewrites `scope`, clears the dangling marker and makes the chat the
|
||||
// active session of its new scope so it shows in the scoped history again.
|
||||
const rebindSessionScope = useCallback((
|
||||
sessionId: string,
|
||||
scope: { type: 'terminal' | 'workspace'; targetId: string; hostIds?: string[] },
|
||||
) => {
|
||||
const scopeKey = `${scope.type}:${scope.targetId}`;
|
||||
setSessionsRaw(prev => {
|
||||
const next = prev.map(s => {
|
||||
if (s.id !== sessionId) return s;
|
||||
const { scopeDangling: _cleared, ...rest } = s;
|
||||
// Replace scope entirely (including hostIds) so history matching follows
|
||||
// the destination terminal/workspace rather than the chat's old host.
|
||||
const nextScope: AISession['scope'] = {
|
||||
type: scope.type,
|
||||
targetId: scope.targetId,
|
||||
};
|
||||
if (scope.hostIds && scope.hostIds.length > 0) {
|
||||
nextScope.hostIds = [...scope.hostIds];
|
||||
}
|
||||
return {
|
||||
...rest,
|
||||
scope: nextScope,
|
||||
updatedAt: Date.now(),
|
||||
} as AISession;
|
||||
});
|
||||
setLatestAISessionsSnapshot(next);
|
||||
persistSessions(next);
|
||||
return next;
|
||||
});
|
||||
// Drop stale pointers that still reference this chat under its previous
|
||||
// scope key, then restore the active pointer for the new scope.
|
||||
// Compute + persist inside the updater so a deferred React setState still
|
||||
// writes the cleaned map (outer capture would stay null and skip persist).
|
||||
setActiveSessionIdMapRaw(prev => {
|
||||
const next = rebindActiveSessionMap(prev, sessionId, scopeKey);
|
||||
setLatestAIActiveSessionMapSnapshot(next);
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_ACTIVE_SESSION_MAP, next);
|
||||
emitAIStateChanged(STORAGE_KEY_AI_ACTIVE_SESSION_MAP);
|
||||
return next;
|
||||
});
|
||||
// Open the rebound chat in this panel; active-map alone loses to an
|
||||
// explicit draft/session panelView that still points elsewhere.
|
||||
setPanelViewByScope((prev) => setSessionView(prev, scopeKey, sessionId));
|
||||
}, [persistSessions, setPanelViewByScope]);
|
||||
|
||||
// Manual durability escape hatch (issue #4015 follow-up): export the
|
||||
// current store to a user-chosen JSON file, or import one back in.
|
||||
// Import is an explicit action, so it may overwrite the live store.
|
||||
const exportSessionsBackup = useCallback(async (): Promise<string | null> => {
|
||||
const bridge = getAIBridge();
|
||||
if (!bridge?.aiSessionsBackupExport) return null;
|
||||
const result = await bridge.aiSessionsBackupExport({
|
||||
json: JSON.stringify(sessionsRef.current),
|
||||
});
|
||||
return result?.success ? (result.filePath ?? '') : null;
|
||||
}, []);
|
||||
|
||||
const importSessionsBackup = useCallback(async (): Promise<number> => {
|
||||
const bridge = getAIBridge();
|
||||
if (!bridge?.aiSessionsBackupImport) return 0;
|
||||
const result = await bridge.aiSessionsBackupImport();
|
||||
if (!result?.success || !result.json) return 0;
|
||||
const imported = parseBackupSessions(result.json);
|
||||
if (!imported || imported.length === 0) return 0;
|
||||
// Merge synchronously against the live ref so orphan cleanup sees the
|
||||
// imported chats even if React would defer a setState updater.
|
||||
const syncEnabled = !!localStorageAdapter.read<{ enabled?: boolean }>(STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS)?.enabled;
|
||||
const next = prepareRestoredSessions(mergeImportedSessions(sessionsRef.current, imported), syncEnabled);
|
||||
if (!writeSessionsForStorage(next)) return 0;
|
||||
const persisted = readLiveSessionsArray(localStorageAdapter.readString(STORAGE_KEY_AI_SESSIONS));
|
||||
const importedIds = new Set(imported.map(session => session.id));
|
||||
sessionsRef.current = persisted;
|
||||
setLatestAISessionsSnapshot(persisted);
|
||||
setSessionsRaw(persisted);
|
||||
emitAIStateChanged(STORAGE_KEY_AI_SESSIONS);
|
||||
// Import does not change live terminals/workspaces, so the orphan-cleanup
|
||||
// effect will not run. Reuse the last known live target set (empty is
|
||||
// valid: nothing live) to mark chats bound to missing scopes as dangling
|
||||
// so /chats can offer /rebind.
|
||||
cleanupOrphanedAISessions(latestAIActiveTargetIdsSnapshot);
|
||||
const nextSessions =
|
||||
latestAISessionsSnapshot
|
||||
?? persisted;
|
||||
sessionsRef.current = nextSessions;
|
||||
setSessionsRaw(nextSessions);
|
||||
setActiveSessionIdMapRaw(
|
||||
latestAIActiveSessionMapSnapshot
|
||||
?? localStorageAdapter.read<Record<string, string | null>>(STORAGE_KEY_AI_ACTIVE_SESSION_MAP)
|
||||
?? {},
|
||||
);
|
||||
return persisted.filter(session => importedIds.has(session.id)).length;
|
||||
}, []);
|
||||
|
||||
const updateSessionExternalSessionId = useCallback((sessionId: string, externalSessionId: string | undefined) => {
|
||||
setSessionsRaw(prev => {
|
||||
const next = prev.map(s => (
|
||||
@@ -1370,6 +1544,9 @@ export function useAIState() {
|
||||
deleteSession,
|
||||
deleteSessionsByTarget,
|
||||
updateSessionTitle,
|
||||
rebindSessionScope,
|
||||
exportSessionsBackup,
|
||||
importSessionsBackup,
|
||||
updateSessionExternalSessionId,
|
||||
addMessageToSession,
|
||||
updateLastMessage,
|
||||
@@ -1432,6 +1609,9 @@ export function useAIState() {
|
||||
deleteSession,
|
||||
deleteSessionsByTarget,
|
||||
updateSessionTitle,
|
||||
rebindSessionScope,
|
||||
exportSessionsBackup,
|
||||
importSessionsBackup,
|
||||
updateSessionExternalSessionId,
|
||||
addMessageToSession,
|
||||
updateLastMessage,
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -24,6 +24,7 @@ reorderWorkspaceFocusSessionOrder,
|
||||
SplitDirection,
|
||||
SplitHint,
|
||||
updateWorkspaceSplitSizes,
|
||||
invertSplitPosition,
|
||||
} from '../../domain/workspace';
|
||||
import { clearSessionFontSizeOverride as clearSessionFontSizeOverrideFields } from '../../domain/terminalAppearance';
|
||||
import {
|
||||
@@ -795,12 +796,68 @@ export const useSessionState = ({
|
||||
return workspace.id;
|
||||
}, [setActiveTabId]);
|
||||
|
||||
const addSessionToWorkspace = useCallback((
|
||||
workspaceId: string,
|
||||
sessionId: string,
|
||||
hint: SplitHint
|
||||
) => {
|
||||
if (!hint) return;
|
||||
|
||||
setSessions(prevSessions => {
|
||||
const session = prevSessions.find(s => s.id === sessionId);
|
||||
if (!session || session.workspaceId) return prevSessions;
|
||||
|
||||
setWorkspaces(prevWorkspaces => {
|
||||
const targetWorkspace = prevWorkspaces.find(w => w.id === workspaceId);
|
||||
if (!targetWorkspace) return prevWorkspaces;
|
||||
|
||||
return insertWorkspacePaneIfMissing(prevWorkspaces, workspaceId, sessionId, hint);
|
||||
});
|
||||
|
||||
setActiveTabId(workspaceId);
|
||||
return prevSessions.map(s => s.id === sessionId ? { ...s, workspaceId } : s);
|
||||
});
|
||||
}, [setActiveTabId]);
|
||||
|
||||
// Merge two tabs into one workspace. When one of them already belongs to a
|
||||
// workspace, that workspace (and its id) is reused instead of dissolving
|
||||
// and recreating: the id is what workspace-scoped AI chats bind to, so
|
||||
// regenerating it used to strand every conversation anchored to it
|
||||
// (issue #4015 R3). Only two genuinely standalone tabs create a new id.
|
||||
const createWorkspaceFromSessions = useCallback((
|
||||
baseSessionId: string,
|
||||
joiningSessionId: string,
|
||||
hint: SplitHint
|
||||
) => {
|
||||
if (!hint || baseSessionId === joiningSessionId) return;
|
||||
const baseSession = sessionsRef.current.find((s) => s.id === baseSessionId);
|
||||
const joiningSession = sessionsRef.current.find((s) => s.id === joiningSessionId);
|
||||
const anchorWorkspaceId = baseSession?.workspaceId ?? joiningSession?.workspaceId;
|
||||
if (anchorWorkspaceId) {
|
||||
const anchorSessionId = baseSession?.workspaceId ? baseSessionId : joiningSessionId;
|
||||
const orphanSessionId = anchorSessionId === baseSessionId ? joiningSessionId : baseSessionId;
|
||||
const orphanSession = sessionsRef.current.find((s) => s.id === orphanSessionId);
|
||||
if (!orphanSession || orphanSession.workspaceId) return;
|
||||
// Split next to the anchor's existing pane: the raw drop hint targets
|
||||
// the orphan's pane, which is not part of this workspace tree yet.
|
||||
// When the dragged (joining) pane already owns the workspace, the orphan
|
||||
// is what gets inserted — invert the drop position so the previewed
|
||||
// side still matches (hint describes where the dragged pane should sit).
|
||||
const joinOwnsWorkspace = Boolean(joiningSession?.workspaceId) && !baseSession?.workspaceId;
|
||||
const insertHint = {
|
||||
...hint,
|
||||
targetSessionId: anchorSessionId,
|
||||
position: joinOwnsWorkspace ? invertSplitPosition(hint.position) : hint.position,
|
||||
};
|
||||
addSessionToWorkspace(
|
||||
anchorWorkspaceId,
|
||||
orphanSessionId,
|
||||
insertHint,
|
||||
);
|
||||
// The orphan's standalone tab slot is replaced by the workspace tab.
|
||||
setTabOrder((prevTabOrder) => prevTabOrder.filter((id) => id !== orphanSessionId));
|
||||
return;
|
||||
}
|
||||
// Name the merged workspace after both tabs' connection labels (e.g.
|
||||
// "01/02") so the window title and the rename dialog show the merged
|
||||
// identity instead of the generic "Workspace". Falls back to the default
|
||||
@@ -855,30 +912,7 @@ export const useSessionState = ({
|
||||
return s;
|
||||
});
|
||||
});
|
||||
}, [setActiveTabId]);
|
||||
|
||||
const addSessionToWorkspace = useCallback((
|
||||
workspaceId: string,
|
||||
sessionId: string,
|
||||
hint: SplitHint
|
||||
) => {
|
||||
if (!hint) return;
|
||||
|
||||
setSessions(prevSessions => {
|
||||
const session = prevSessions.find(s => s.id === sessionId);
|
||||
if (!session || session.workspaceId) return prevSessions;
|
||||
|
||||
setWorkspaces(prevWorkspaces => {
|
||||
const targetWorkspace = prevWorkspaces.find(w => w.id === workspaceId);
|
||||
if (!targetWorkspace) return prevWorkspaces;
|
||||
|
||||
return insertWorkspacePaneIfMissing(prevWorkspaces, workspaceId, sessionId, hint);
|
||||
});
|
||||
|
||||
setActiveTabId(workspaceId);
|
||||
return prevSessions.map(s => s.id === sessionId ? { ...s, workspaceId } : s);
|
||||
});
|
||||
}, [setActiveTabId]);
|
||||
}, [addSessionToWorkspace, setActiveTabId]);
|
||||
|
||||
// Add a host into an existing workspace by creating a new session for
|
||||
// that host and appending it as the last pane at the workspace root.
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
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 { collectSessionIds } from "../../domain/workspace.ts";
|
||||
import { useSessionState } from "./useSessionState.ts";
|
||||
|
||||
type SessionState = ReturnType<typeof useSessionState>;
|
||||
|
||||
test("merging a workspace tab with a standalone tab reuses the workspace id (issue #4015 R3)", async (t) => {
|
||||
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, "window");
|
||||
const eventTarget = new EventTarget() as EventTarget & Record<string, unknown>;
|
||||
Object.assign(eventTarget, {
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
});
|
||||
Object.defineProperty(globalThis, "window", {
|
||||
configurable: true,
|
||||
value: eventTarget,
|
||||
});
|
||||
t.after(() => {
|
||||
if (previousWindow) Object.defineProperty(globalThis, "window", previousWindow);
|
||||
else Reflect.deleteProperty(globalThis, "window");
|
||||
});
|
||||
|
||||
const actEnvironment = globalThis as typeof globalThis & {
|
||||
IS_REACT_ACT_ENVIRONMENT?: boolean;
|
||||
};
|
||||
const previousActEnvironment = actEnvironment.IS_REACT_ACT_ENVIRONMENT;
|
||||
actEnvironment.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
t.after(() => {
|
||||
actEnvironment.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
|
||||
});
|
||||
|
||||
let state: SessionState | null = null;
|
||||
let renderer: ReactTestRenderer | null = null;
|
||||
const current = (): SessionState => {
|
||||
assert.ok(state);
|
||||
return state;
|
||||
};
|
||||
function Probe() {
|
||||
state = useSessionState({ persistSessionRestore: false });
|
||||
return null;
|
||||
}
|
||||
|
||||
await act(async () => {
|
||||
renderer = create(React.createElement(Probe));
|
||||
});
|
||||
|
||||
let baseId = "";
|
||||
let memberId = "";
|
||||
let standaloneId = "";
|
||||
await act(async () => {
|
||||
baseId = current().createLocalTerminal({ shellName: "01" });
|
||||
memberId = current().createLocalTerminal({ shellName: "02" });
|
||||
standaloneId = current().createLocalTerminal({ shellName: "03" });
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
current().createWorkspaceFromSessions(baseId, memberId, {
|
||||
direction: "vertical",
|
||||
position: "right",
|
||||
targetSessionId: baseId,
|
||||
});
|
||||
});
|
||||
const workspaceId = current().workspaces[0]?.id;
|
||||
assert.ok(workspaceId);
|
||||
assert.equal(current().sessions.find((s) => s.id === standaloneId)?.workspaceId ?? "", "");
|
||||
|
||||
// Dropping the standalone tab onto a member of the workspace must join the
|
||||
// existing workspace — regenerating the id would strand AI chats bound to it.
|
||||
await act(async () => {
|
||||
current().createWorkspaceFromSessions(standaloneId, memberId, {
|
||||
direction: "vertical",
|
||||
position: "right",
|
||||
targetSessionId: standaloneId,
|
||||
});
|
||||
});
|
||||
|
||||
assert.equal(current().workspaces.length, 1);
|
||||
assert.equal(current().workspaces[0]?.id, workspaceId);
|
||||
assert.equal(
|
||||
current().sessions.find((s) => s.id === standaloneId)?.workspaceId,
|
||||
workspaceId,
|
||||
);
|
||||
const memberIds = collectSessionIds(current().workspaces[0]!.root);
|
||||
assert.ok(memberIds.includes(standaloneId));
|
||||
assert.ok(memberIds.includes(memberId));
|
||||
// The standalone tab slot is replaced by the workspace tab.
|
||||
assert.ok(!current().orderedTabs.includes(standaloneId));
|
||||
|
||||
|
||||
// Dropping a workspace member onto a standalone tab must insert the
|
||||
// standalone on the opposite side of the drop hint (hint describes where
|
||||
// the dragged member should appear relative to the standalone).
|
||||
let orientationStandaloneId = "";
|
||||
let orientationMemberId = "";
|
||||
await act(async () => {
|
||||
orientationStandaloneId = current().createLocalTerminal({ shellName: "orient-standalone" });
|
||||
});
|
||||
// memberId is already in workspaceId; drop member onto the new standalone with position "right"
|
||||
// (preview: member to the right of standalone ⇒ standalone should end up LEFT of member).
|
||||
await act(async () => {
|
||||
current().createWorkspaceFromSessions(orientationStandaloneId, memberId, {
|
||||
direction: "vertical",
|
||||
position: "right",
|
||||
targetSessionId: orientationStandaloneId,
|
||||
});
|
||||
});
|
||||
{
|
||||
const root = current().workspaces.find((ws) => ws.id === workspaceId)?.root;
|
||||
assert.ok(root);
|
||||
const ordered = collectSessionIds(root!);
|
||||
const standaloneIdx = ordered.indexOf(orientationStandaloneId);
|
||||
const memberIdx = ordered.indexOf(memberId);
|
||||
assert.ok(standaloneIdx >= 0 && memberIdx >= 0);
|
||||
assert.ok(
|
||||
standaloneIdx < memberIdx,
|
||||
`expected standalone left of member after inverted right-drop, got order ${ordered.join(",")}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Two genuinely standalone tabs still create a fresh workspace.
|
||||
let firstStandaloneId = "";
|
||||
let secondStandaloneId = "";
|
||||
await act(async () => {
|
||||
firstStandaloneId = current().createLocalTerminal({ shellName: "04" });
|
||||
secondStandaloneId = current().createLocalTerminal({ shellName: "05" });
|
||||
});
|
||||
await act(async () => {
|
||||
current().createWorkspaceFromSessions(firstStandaloneId, secondStandaloneId, {
|
||||
direction: "vertical",
|
||||
position: "right",
|
||||
targetSessionId: firstStandaloneId,
|
||||
});
|
||||
});
|
||||
|
||||
assert.equal(current().workspaces.length, 2);
|
||||
const freshWorkspace = current().workspaces.find((candidate) => candidate.id !== workspaceId);
|
||||
assert.ok(freshWorkspace);
|
||||
assert.notEqual(freshWorkspace.id, workspaceId);
|
||||
|
||||
await act(async () => {
|
||||
renderer?.unmount();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
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 { useToolAccessGuidanceState, type ToolAccessGuidanceState } from './useToolAccessGuidanceState';
|
||||
import { AI_STATE_CHANGED_EVENT } from './aiStateEvents';
|
||||
import { STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED } from '../../infrastructure/config/storageKeys';
|
||||
import type { AIToolIntegrationMode } from '../../infrastructure/ai/types';
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((done) => { resolve = done; });
|
||||
return { promise, resolve };
|
||||
}
|
||||
async function harness() {
|
||||
const requests: ReturnType<typeof deferred<unknown>>[] = [];
|
||||
const events = new EventTarget();
|
||||
const values = new Map([[STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED, 'true']]);
|
||||
const previous = new Map<string, PropertyDescriptor | undefined>();
|
||||
const globals = {
|
||||
IS_REACT_ACT_ENVIRONMENT: true,
|
||||
localStorage: { getItem: (key: string) => values.get(key) ?? null, setItem: (key: string, value: string) => values.set(key, value) },
|
||||
window: {
|
||||
netcatty: {
|
||||
externalMcpGetStatus: () => { const request = deferred<unknown>(); requests.push(request); return request.promise; },
|
||||
aiSkillsCliGetInvocation: async () => ({ ok: true, skillPath: '/fixture/skills/SKILL.md', commandPrefix: 'fixture-cli' }),
|
||||
},
|
||||
addEventListener: events.addEventListener.bind(events), removeEventListener: events.removeEventListener.bind(events),
|
||||
dispatchEvent: events.dispatchEvent.bind(events), setTimeout, clearTimeout,
|
||||
},
|
||||
};
|
||||
for (const [key, value] of Object.entries(globals)) {
|
||||
previous.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
|
||||
Object.defineProperty(globalThis, key, { value, configurable: true, writable: true });
|
||||
}
|
||||
let result!: ToolAccessGuidanceState;
|
||||
function Harness({ mode }: { mode: AIToolIntegrationMode }) { result = useToolAccessGuidanceState(mode); return null; }
|
||||
let renderer!: ReactTestRenderer;
|
||||
await act(async () => { renderer = create(<Harness mode="mcp" />); });
|
||||
return {
|
||||
requests, events, values, result: () => result,
|
||||
skills: async () => { await act(async () => renderer.update(<Harness mode="skills" />)); },
|
||||
close: async () => {
|
||||
await act(async () => renderer.unmount());
|
||||
for (const [key, value] of previous) { if (value) Object.defineProperty(globalThis, key, value); else Reflect.deleteProperty(globalThis, key); }
|
||||
},
|
||||
};
|
||||
}
|
||||
const status = (path: string) => ({ ok: true, enabled: true, state: 'running', launcherPath: path, discoveryPath: `${path}.json` });
|
||||
|
||||
test('older MCP guidance response cannot overwrite a newer enable-state refresh', async () => {
|
||||
const h = await harness();
|
||||
try {
|
||||
assert.equal(h.requests.length, 1);
|
||||
await act(async () => { h.events.dispatchEvent(new CustomEvent(AI_STATE_CHANGED_EVENT, { detail: { key: STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED } })); });
|
||||
assert.equal(h.requests.length, 2);
|
||||
await act(async () => h.requests[1].resolve(status('/fixture/new')));
|
||||
assert.equal(h.result().mcpLauncherPath, '/fixture/new');
|
||||
await act(async () => h.requests[0].resolve(status('/fixture/old')));
|
||||
assert.equal(h.result().mcpLauncherPath, '/fixture/new');
|
||||
} finally { await h.close(); }
|
||||
});
|
||||
|
||||
test('switching to Skills ignores pending MCP results and preserves resolved Skills guidance', async () => {
|
||||
const h = await harness();
|
||||
try {
|
||||
await h.skills();
|
||||
await act(async () => h.requests[0].resolve(status('/fixture/stale')));
|
||||
assert.equal(h.result().mcpLauncherPath, null);
|
||||
assert.equal(h.result().skillPath, '/fixture/skills/SKILL.md');
|
||||
assert.equal(h.result().commandPrefix, 'fixture-cli');
|
||||
} finally { await h.close(); }
|
||||
});
|
||||
|
||||
test('MCP guidance completion after unmount has no visible state or retry work', async () => {
|
||||
const h = await harness();
|
||||
const initial = h.result();
|
||||
await h.close();
|
||||
await act(async () => h.requests[0].resolve(status('/fixture/stale')));
|
||||
assert.equal(h.result(), initial);
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import type { AIToolIntegrationMode } from '../../infrastructure/ai/types';
|
||||
import { STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED } from '../../infrastructure/config/storageKeys';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
@@ -38,14 +38,15 @@ export type ToolAccessGuidanceState = {
|
||||
* runtime is still starting or after an idle shutdown).
|
||||
*/
|
||||
export function useToolAccessGuidanceState(mode: AIToolIntegrationMode): ToolAccessGuidanceState {
|
||||
const requestGeneration = useRef(0);
|
||||
const [skillPath, setSkillPath] = useState<string | null>(null);
|
||||
const [commandPrefix, setCommandPrefix] = useState('');
|
||||
const [mcpLauncherPath, setMcpLauncherPath] = useState<string | null>(null);
|
||||
const [mcpDiscoveryPath, setMcpDiscoveryPath] = useState<string | null>(null);
|
||||
|
||||
const refreshMcpStatus = useCallback(async (): Promise<string | null | undefined> => {
|
||||
const refreshMcpStatus = useCallback(async (isCurrent: () => boolean): Promise<string | null | undefined> => {
|
||||
const status = await netcattyBridge.get()?.externalMcpGetStatus?.();
|
||||
if (!status?.ok) return undefined;
|
||||
if (!isCurrent() || !status?.ok) return undefined;
|
||||
// Do not trust `enabled` alone: the main process flips `enabled` before
|
||||
// host credentials are resolved and the discovery file is written, and it
|
||||
// keeps reporting the configured discovery/launcher paths while starting.
|
||||
@@ -93,9 +94,11 @@ export function useToolAccessGuidanceState(mode: AIToolIntegrationMode): ToolAcc
|
||||
|
||||
const refetch = async () => {
|
||||
if (cancelled) return;
|
||||
const generation = ++requestGeneration.current;
|
||||
const isCurrent = () => !cancelled && generation === requestGeneration.current;
|
||||
const storedEnabled = readExternalMcpStoredEnabled();
|
||||
const readyLauncherPath = await refreshMcpStatus().catch(() => undefined);
|
||||
if (cancelled) return;
|
||||
const readyLauncherPath = await refreshMcpStatus(isCurrent).catch(() => undefined);
|
||||
if (!isCurrent()) return;
|
||||
// The persisted switch is off but the status check failed: drop any
|
||||
// cached launcher/discovery paths. The stored credentials were revoked
|
||||
// by the disable, and falling through to the comparison below would see
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
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 { installBrowserLockFixture } from "../testSupport/browserLockFixture";
|
||||
import { STORAGE_KEY_SHELL_HISTORY } from "../../infrastructure/config/storageKeys";
|
||||
|
||||
test("shell history append yields the input turn and persists merged entries", async () => {
|
||||
const dom = new JSDOM("", { url: "http://localhost" });
|
||||
const globals = ["window", "localStorage", "CustomEvent", "IS_REACT_ACT_ENVIRONMENT", "navigator"];
|
||||
const previous = new Map(globals.map(key => [key, Object.getOwnPropertyDescriptor(globalThis, key)]));
|
||||
Object.defineProperties(globalThis, {
|
||||
window: { value: dom.window, configurable: true },
|
||||
localStorage: { value: dom.window.localStorage, configurable: true },
|
||||
CustomEvent: { value: dom.window.CustomEvent, configurable: true },
|
||||
IS_REACT_ACT_ENVIRONMENT: { value: true, configurable: true },
|
||||
});
|
||||
installBrowserLockFixture();
|
||||
const { useVaultState } = await import("./useVaultState");
|
||||
let state: ReturnType<typeof useVaultState>;
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
const Probe = () => { state = useVaultState({ syncConnectionLogTerminalData: false }); return null; };
|
||||
try {
|
||||
await act(async () => { renderer = create(React.createElement(Probe)); });
|
||||
for (let attempt = 0; !state!.isInitialized && attempt < 50; attempt++) {
|
||||
await act(async () => { await new Promise(resolve => setTimeout(resolve, 10)); });
|
||||
}
|
||||
assert.equal(state!.isInitialized, true);
|
||||
const entry = { command: " echo hello ", hostId: "local", hostLabel: "Local", sessionId: "one" };
|
||||
act(() => {
|
||||
state!.addShellHistoryEntry(entry);
|
||||
state!.addShellHistoryEntry({ ...entry, sessionId: "two" });
|
||||
assert.equal(dom.window.localStorage.getItem(STORAGE_KEY_SHELL_HISTORY), null);
|
||||
assert.equal(state!.shellHistory.length, 0);
|
||||
});
|
||||
await act(async () => { await new Promise(resolve => setTimeout(resolve, 20)); });
|
||||
const saved = JSON.parse(dom.window.localStorage.getItem(STORAGE_KEY_SHELL_HISTORY)!);
|
||||
assert.equal(saved.length, 1);
|
||||
assert.equal(saved[0].command, "echo hello");
|
||||
assert.equal(saved[0].sessionId, "two");
|
||||
assert.deepEqual(state!.shellHistory, saved);
|
||||
|
||||
for (const clear of [() => state!.clearShellHistory(), () => state!.clearAllCommandHistory()]) {
|
||||
act(() => {
|
||||
state!.addShellHistoryEntry(entry);
|
||||
state!.addShellHistoryEntry({ ...entry, command: "another command" });
|
||||
clear();
|
||||
});
|
||||
await act(async () => { await new Promise(resolve => setTimeout(resolve, 20)); });
|
||||
assert.deepEqual(state!.shellHistory, []);
|
||||
assert.deepEqual(JSON.parse(dom.window.localStorage.getItem(STORAGE_KEY_SHELL_HISTORY)!), []);
|
||||
}
|
||||
} finally {
|
||||
await act(async () => { renderer?.unmount(); });
|
||||
dom.window.close();
|
||||
for (const key of globals) {
|
||||
const descriptor = previous.get(key);
|
||||
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
|
||||
else Reflect.deleteProperty(globalThis, key);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -317,6 +317,7 @@ export const useVaultState = (options?: UseVaultStateOptions) => {
|
||||
const [noteGroups, setNoteGroups] = useState<string[]>([]);
|
||||
const [knownHosts, setKnownHosts] = useState<KnownHost[]>([]);
|
||||
const [shellHistory, setShellHistory] = useState<ShellHistoryEntry[]>([]);
|
||||
const shellHistoryGenerationRef = useRef(0);
|
||||
const [connectionLogs, setConnectionLogs] = useState<ConnectionLog[]>([]);
|
||||
const [managedSources, setManagedSources] = useState<ManagedSource[]>([]);
|
||||
const [groupConfigs, setGroupConfigs] = useState<GroupConfig[]>([]);
|
||||
@@ -1445,19 +1446,25 @@ export const useVaultState = (options?: UseVaultStateOptions) => {
|
||||
|
||||
const addShellHistoryEntry = useCallback(
|
||||
(entry: Omit<ShellHistoryEntry, "id" | "timestamp">) => {
|
||||
setShellHistory((prev) => {
|
||||
const updated = mergeGlobalHistoryOnAppend(prev, entry);
|
||||
if (updated === prev) return prev;
|
||||
// Persist immediately so crash between commit and layout effect cannot drop history.
|
||||
// Store republish is also done in useLayoutEffect for load/storage-event paths.
|
||||
localStorageAdapter.write(STORAGE_KEY_SHELL_HISTORY, updated);
|
||||
return updated;
|
||||
});
|
||||
const generation = shellHistoryGenerationRef.current;
|
||||
// Yield the input turn before history rendering and synchronous storage work.
|
||||
// Persist in the next task to keep the crash-loss window small.
|
||||
setTimeout(() => {
|
||||
if (generation !== shellHistoryGenerationRef.current) return;
|
||||
setShellHistory((prev) => {
|
||||
if (generation !== shellHistoryGenerationRef.current) return prev;
|
||||
const updated = mergeGlobalHistoryOnAppend(prev, entry);
|
||||
if (updated === prev) return prev;
|
||||
localStorageAdapter.write(STORAGE_KEY_SHELL_HISTORY, updated);
|
||||
return updated;
|
||||
});
|
||||
}, 0);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const clearShellHistory = useCallback(() => {
|
||||
shellHistoryGenerationRef.current += 1;
|
||||
setShellHistory([]);
|
||||
localStorageAdapter.write(STORAGE_KEY_SHELL_HISTORY, []);
|
||||
publishShellHistorySnapshot([]);
|
||||
|
||||
@@ -40,3 +40,35 @@ test('terminal side panel tools share one primary toolbar height', () => {
|
||||
assert.match(source, /TERMINAL_SIDE_PANEL_INNER_HEADER_CLASS/);
|
||||
}
|
||||
});
|
||||
|
||||
test('/rename and /chats panel commands stay out of the agent message path', () => {
|
||||
const sidePanel = preparingSource;
|
||||
// Panel implements the commands with updateSessionTitle + a note state —
|
||||
// never addMessageToSession, so the commands cannot leak into chat history.
|
||||
assert.match(sidePanel, /handleRenameSession = useCallback/);
|
||||
assert.match(sidePanel, /handleListChats = useCallback/);
|
||||
assert.match(sidePanel, /updateSessionTitle\(sessionId, title\)/);
|
||||
assert.match(sidePanel, /setPanelCommandNote\(formatAiChatsListingNote\(entries, t\)\)/);
|
||||
const renameHandler = sidePanel.slice(
|
||||
sidePanel.indexOf('handleRenameSession = useCallback'),
|
||||
sidePanel.indexOf('const handleRenameSlashCommand'),
|
||||
);
|
||||
assert.doesNotMatch(renameHandler, /addMessageToSession/);
|
||||
const chatsHandler = sidePanel.slice(
|
||||
sidePanel.indexOf('handleListChats = useCallback'),
|
||||
sidePanel.indexOf('const handleSend'),
|
||||
);
|
||||
assert.doesNotMatch(chatsHandler, /addMessageToSession/);
|
||||
// Wiring reaches AIChatPanelContent.
|
||||
assert.match(sidePanel, /onRenameActiveChat=\{handleRenameSlashCommand\}/);
|
||||
assert.match(sidePanel, /onListChats=\{handleListChats\}/);
|
||||
});
|
||||
|
||||
test('AIChatPanelContent renders the panel command note and wires rename/list props', () => {
|
||||
assert.match(panelSource, /panelCommandNote \? \(/);
|
||||
assert.match(panelSource, /role="status"/);
|
||||
assert.match(panelSource, /onDismissPanelCommandNote/);
|
||||
assert.match(panelSource, /onRenameSession=\{onRenameActiveChat\}/);
|
||||
assert.match(panelSource, /onListChats=\{onListChats\}/);
|
||||
assert.match(panelSource, /onRename=\{onRenameSession\}/);
|
||||
});
|
||||
@@ -57,6 +57,20 @@ interface AIChatPanelContentProps {
|
||||
setInputValue: (value: string) => void;
|
||||
handleSend: () => void;
|
||||
handleCompact: () => void;
|
||||
/** Rename a chat by id (history drawer edit affordance). Panel action, never agent content. */
|
||||
onRenameSession?: (sessionId: string, title: string) => void;
|
||||
/** `/rename <title>` for the current chat. Panel action, never agent content. */
|
||||
onRenameActiveChat?: (title: string) => void;
|
||||
/** `/chats`: list the chats visible to this panel. Panel action, never agent content. */
|
||||
onListChats?: () => void;
|
||||
/** `/rebind <n>`: rebind a dangling chat to this panel's scope. Panel action, never agent content. */
|
||||
onRebindChat?: (arg: string) => void;
|
||||
/** `/chats` listing output, rendered as a system-style note above the composer. */
|
||||
panelCommandNote?: string | null;
|
||||
onDismissPanelCommandNote?: () => void;
|
||||
/** Manual durability escape hatch: export/import the whole session store. */
|
||||
onExportSessions?: () => void;
|
||||
onImportSessions?: () => void;
|
||||
/** Undo last turn is offered at a safe boundary (never mid-tool / mid-compaction). */
|
||||
canUndoLastTurn?: boolean;
|
||||
handleUndoLastTurn?: () => void;
|
||||
@@ -142,6 +156,14 @@ export const AIChatPanelContent: React.FC<AIChatPanelContentProps> = ({
|
||||
setInputValue,
|
||||
handleSend,
|
||||
handleCompact,
|
||||
onRenameSession,
|
||||
onRenameActiveChat,
|
||||
onListChats,
|
||||
onRebindChat,
|
||||
panelCommandNote,
|
||||
onDismissPanelCommandNote,
|
||||
onExportSessions,
|
||||
onImportSessions,
|
||||
canUndoLastTurn = false,
|
||||
handleUndoLastTurn,
|
||||
canCompact = false,
|
||||
@@ -309,6 +331,9 @@ export const AIChatPanelContent: React.FC<AIChatPanelContentProps> = ({
|
||||
activeSessionId={activeSessionId}
|
||||
onSelect={sending ? () => undefined : handleSelectSession}
|
||||
onDelete={handleDeleteSession}
|
||||
onRename={onRenameSession}
|
||||
onExportSessions={onExportSessions}
|
||||
onImportSessions={onImportSessions}
|
||||
onClose={() => setShowHistory(false)}
|
||||
/>
|
||||
</React.Profiler>
|
||||
@@ -369,6 +394,21 @@ export const AIChatPanelContent: React.FC<AIChatPanelContentProps> = ({
|
||||
{!hideInput && (
|
||||
<React.Profiler {...getAIPanelProfilerProps('AIChatPanel.Input')}>
|
||||
<div>
|
||||
{panelCommandNote ? (
|
||||
<div role="status" className="mx-3 mb-1.5 rounded-md border border-border/50 bg-muted/30 px-2.5 py-1.5 text-[11px] leading-4 text-muted-foreground">
|
||||
<span className="block max-h-48 overflow-y-auto whitespace-pre-wrap break-words">{panelCommandNote}</span>
|
||||
{onDismissPanelCommandNote && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('common.close')}
|
||||
onClick={onDismissPanelCommandNote}
|
||||
className="mt-1 text-[11px] text-muted-foreground/60 underline-offset-2 transition-colors hover:text-foreground hover:underline cursor-pointer"
|
||||
>
|
||||
{t('common.close')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
{steerWarning ? (
|
||||
<div role="status" className="mx-3 mb-1.5 rounded-md border border-amber-500/25 bg-amber-500/10 px-2.5 py-1.5 text-[11px] leading-4 text-amber-600 dark:text-amber-400">
|
||||
{steerWarning}
|
||||
@@ -396,6 +436,9 @@ export const AIChatPanelContent: React.FC<AIChatPanelContentProps> = ({
|
||||
onChange={setInputValue}
|
||||
onSend={handleSend}
|
||||
onCompact={handleCompact}
|
||||
onRenameSession={onRenameActiveChat}
|
||||
onListChats={onListChats}
|
||||
onRebindChat={onRebindChat}
|
||||
canCompact={canCompact}
|
||||
onSteer={handleSteer}
|
||||
onStop={handleStop}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useEffect, useId, useRef, useState } from 'react';
|
||||
import { Search, Trash2, X } from 'lucide-react';
|
||||
import { Download, Pencil, Search, Trash2, Upload, X } from 'lucide-react';
|
||||
import type { AISession } from '../infrastructure/ai/types';
|
||||
import { useI18n } from '../application/i18n/I18nProvider';
|
||||
import { cn } from '../lib/utils';
|
||||
@@ -19,6 +19,12 @@ interface SessionHistoryDrawerProps {
|
||||
activeSessionId: string | null;
|
||||
onSelect: (sessionId: string) => void;
|
||||
onDelete: (e: React.MouseEvent, sessionId: string) => void;
|
||||
/** Rename a chat inline (issue #4015 R5). Empty titles are ignored. */
|
||||
onRename?: (sessionId: string, title: string) => void;
|
||||
/** Manual durability escape hatch: export the whole store to a JSON file. */
|
||||
onExportSessions?: () => void;
|
||||
/** Import sessions from a JSON export; merges by id, newer wins. */
|
||||
onImportSessions?: () => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
@@ -30,11 +36,17 @@ export const SessionHistoryDrawer: React.FC<SessionHistoryDrawerProps> = ({
|
||||
activeSessionId,
|
||||
onSelect,
|
||||
onDelete,
|
||||
onRename,
|
||||
onExportSessions,
|
||||
onImportSessions,
|
||||
onClose,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [renderCount, setRenderCount] = useState(SESSION_RENDER_BATCH);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [renamingSessionId, setRenamingSessionId] = useState<string | null>(null);
|
||||
const [renameDraft, setRenameDraft] = useState('');
|
||||
const renameInputRef = useRef<HTMLInputElement>(null);
|
||||
const searchHintId = useId();
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
const { filteredSessions, isSearching } = useSessionHistorySearch(
|
||||
@@ -52,16 +64,69 @@ export const SessionHistoryDrawer: React.FC<SessionHistoryDrawerProps> = ({
|
||||
const displayedSessions = filteredSessions.slice(0, renderCount);
|
||||
const hiddenSessionCount = Math.max(0, filteredSessions.length - renderCount);
|
||||
|
||||
useEffect(() => {
|
||||
if (renamingSessionId) renameInputRef.current?.focus();
|
||||
}, [renamingSessionId]);
|
||||
|
||||
const beginRename = (sessionId: string, currentTitle: string) => {
|
||||
setRenamingSessionId(sessionId);
|
||||
setRenameDraft(currentTitle);
|
||||
};
|
||||
|
||||
const commitRename = () => {
|
||||
if (renamingSessionId && renameDraft.trim()) {
|
||||
onRename?.(renamingSessionId, renameDraft.trim());
|
||||
}
|
||||
setRenamingSessionId(null);
|
||||
setRenameDraft('');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col min-h-0">
|
||||
<div className="px-4 py-2.5 flex items-center justify-between shrink-0 border-b border-border/30">
|
||||
<span className="text-[13px] font-medium text-foreground/80">{t('ai.chat.allSessions')}</span>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-[12px] text-muted-foreground/60 hover:text-muted-foreground transition-colors cursor-pointer"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
{onImportSessions && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onImportSessions();
|
||||
}}
|
||||
className="text-[12px] text-muted-foreground/60 hover:text-muted-foreground transition-colors cursor-pointer"
|
||||
aria-label={t('ai.chat.importSessions')}
|
||||
>
|
||||
<Upload size={14} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('ai.chat.importSessions')}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{onExportSessions && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onExportSessions();
|
||||
}}
|
||||
className="text-[12px] text-muted-foreground/60 hover:text-muted-foreground transition-colors cursor-pointer"
|
||||
aria-label={t('ai.chat.exportSessions')}
|
||||
>
|
||||
<Download size={14} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('ai.chat.exportSessions')}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-[12px] text-muted-foreground/60 hover:text-muted-foreground transition-colors cursor-pointer"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-3 py-2 shrink-0 border-b border-border/30">
|
||||
<div className="relative">
|
||||
@@ -146,12 +211,52 @@ export const SessionHistoryDrawer: React.FC<SessionHistoryDrawerProps> = ({
|
||||
)}
|
||||
>
|
||||
<span className={SESSION_HISTORY_ROW_CLASSNAMES.title}>
|
||||
{session.title || t('ai.chat.untitled')}
|
||||
{renamingSessionId === session.id ? (
|
||||
<Input
|
||||
ref={renameInputRef}
|
||||
type="text"
|
||||
value={renameDraft}
|
||||
aria-label={t('ai.chat.renameChat')}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => {
|
||||
e.stopPropagation();
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
commitRename();
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
setRenamingSessionId(null);
|
||||
setRenameDraft('');
|
||||
}
|
||||
}}
|
||||
onChange={(e) => setRenameDraft(e.target.value)}
|
||||
onBlur={commitRename}
|
||||
className="h-6 text-[12px]"
|
||||
/>
|
||||
) : (
|
||||
session.title || t('ai.chat.untitled')
|
||||
)}
|
||||
</span>
|
||||
<div className={SESSION_HISTORY_ROW_CLASSNAMES.meta}>
|
||||
<span className={SESSION_HISTORY_ROW_CLASSNAMES.time}>
|
||||
{timeStr}
|
||||
</span>
|
||||
{onRename && renamingSessionId !== session.id && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
beginRename(session.id, session.title || '');
|
||||
}}
|
||||
className={SESSION_HISTORY_ROW_CLASSNAMES.deleteButton}
|
||||
>
|
||||
<Pencil size={12} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('ai.chat.renameChat')}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
|
||||
@@ -64,6 +64,7 @@ import {
|
||||
type DefaultTargetSessionHint,
|
||||
} from '../application/state/useAIChatStreaming';
|
||||
import { getScopedHistorySessions } from './ai/scopedHistorySessions';
|
||||
import { buildAiChatsListingEntries, canRebindChat, formatAiChatsListingNote, resolveRebindTargetFromArg, type AiChatsListingEntry } from './ai/aiChatsListing';
|
||||
import { resolveInheritedAIActiveSessionId } from '../domain/aiWorkspaceScopeInherit';
|
||||
import { resolveUndoLastTurnBoundary } from '../domain/aiUndoLastTurn';
|
||||
import { aiSessionIdSetEqual, exactScopeAISessionsEqual } from '../domain/aiSessionsForScope';
|
||||
@@ -279,6 +280,9 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
undoLastTurnInSession,
|
||||
deleteSession,
|
||||
updateSessionTitle,
|
||||
rebindSessionScope,
|
||||
exportSessionsBackup,
|
||||
importSessionsBackup,
|
||||
updateSessionExternalSessionId,
|
||||
addMessageToSession,
|
||||
updateLastMessage,
|
||||
@@ -312,7 +316,9 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
scopeHostIds,
|
||||
scopeLabel,
|
||||
focusedSessionId,
|
||||
workspaces = [],
|
||||
terminalSessions = [],
|
||||
liveScopeTargetIds,
|
||||
resolveExecutorContext,
|
||||
isVisible = true,
|
||||
notes = [],
|
||||
@@ -327,6 +333,11 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
const scopeKey = `${scopeType}:${scopeTargetId ?? ''}`;
|
||||
|
||||
const [showHistory, setShowHistory] = useState(false);
|
||||
// Inline system-style note above the composer (e.g. `/chats` listing).
|
||||
// Panel-command output, never agent content.
|
||||
const [panelCommandNote, setPanelCommandNote] = useState<string | null>(null);
|
||||
// Entries of the last `/chats` listing; `/rebind <n>` resolves against them.
|
||||
const lastChatsListingRef = useRef<AiChatsListingEntry[]>([]);
|
||||
const [isSending, setIsSending] = useState(false);
|
||||
// Undo-last-turn is still
|
||||
// running. Unlike the send flow this is not reflected in `isSending`, so it
|
||||
@@ -484,6 +495,119 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
const activeSessionRef = useRef(activeSession);
|
||||
activeSessionRef.current = activeSession;
|
||||
|
||||
/** Rename a chat (slash command + history drawer affordance). Panel action: nothing is sent to the agent. */
|
||||
const handleRenameSession = useCallback((sessionId: string, rawTitle: string) => {
|
||||
const title = rawTitle.trim();
|
||||
if (!title) {
|
||||
toast.warning(t('ai.chat.renameUsage'));
|
||||
return;
|
||||
}
|
||||
if (!sessionId) {
|
||||
toast.warning(t('ai.chat.renameNoActive'));
|
||||
return;
|
||||
}
|
||||
updateSessionTitle(sessionId, title);
|
||||
toast.success(t('ai.chat.renameDone').replace('{title}', title));
|
||||
}, [t, updateSessionTitle]);
|
||||
|
||||
const handleRenameSlashCommand = useCallback((title: string) => {
|
||||
handleRenameSession(activeSessionRef.current?.id ?? null, title);
|
||||
}, [handleRenameSession]);
|
||||
|
||||
const handleListChats = useCallback(() => {
|
||||
// Prefer the app-wide live target set so chats bound to other still-open
|
||||
// terminals are not mislabelled dangling / offered for /rebind.
|
||||
const liveTerminalIds = liveScopeTargetIds
|
||||
?? (scopeType === 'workspace' && workspaceMemberTerminalIds
|
||||
? workspaceMemberTerminalIds
|
||||
: new Set(scopeTargetId ? [scopeTargetId] : []));
|
||||
// Dangling chats no longer match any live scope (rank 0), so they must be
|
||||
// appended explicitly for the listing — that is where they become visible
|
||||
// and rebindable again (issue #4015 R2).
|
||||
const listedSessionIds = new Set(historySessions.map((session) => session.id));
|
||||
const danglingSessions = sessions.filter((session) => session.scopeDangling && !listedSessionIds.has(session.id));
|
||||
const entries = buildAiChatsListingEntries({
|
||||
sessions: [...historySessions, ...danglingSessions],
|
||||
activeSessionId,
|
||||
externalAgents,
|
||||
workspaces,
|
||||
liveTerminalIds,
|
||||
currentScopeLabel: scopeLabel,
|
||||
scopeTargetId,
|
||||
t,
|
||||
});
|
||||
lastChatsListingRef.current = entries;
|
||||
setPanelCommandNote(formatAiChatsListingNote(entries, t));
|
||||
}, [activeSessionId, externalAgents, historySessions, liveScopeTargetIds, scopeLabel, scopeTargetId, scopeType, sessions, t, workspaces, workspaceMemberTerminalIds]);
|
||||
|
||||
// `/rebind <n>`: rebind the nth dangling chat from the last /chats listing
|
||||
// to this panel's current scope. Panel action — no agent round-trip.
|
||||
const handleRebindChat = useCallback((arg: string) => {
|
||||
if (!scopeTargetId) {
|
||||
toast.warning(t('ai.chat.rebindUnavailable'));
|
||||
return;
|
||||
}
|
||||
const target = resolveRebindTargetFromArg(arg, lastChatsListingRef.current);
|
||||
if (!target) {
|
||||
toast.warning(t('ai.chat.rebindUsage'));
|
||||
return;
|
||||
}
|
||||
const session = sessions.find((item) => item.id === target.sessionId);
|
||||
const liveTargets = new Set(liveScopeTargetIds ?? workspaceMemberTerminalIds ?? []);
|
||||
for (const workspace of workspaces) liveTargets.add(workspace.id);
|
||||
liveTargets.add(scopeTargetId);
|
||||
if (canRebindChat(session, liveTargets)) {
|
||||
rebindSessionScope?.(target.sessionId, { type: scopeType, targetId: scopeTargetId, hostIds: scopeHostIds });
|
||||
toast.success(t('ai.chat.rebindDone').replace('{title}', target.title));
|
||||
} else {
|
||||
toast.warning(t('ai.chat.rebindUsage'));
|
||||
}
|
||||
// Do not rebuild /chats from this render's stale sessions (still dangling).
|
||||
// Drop the resolved entry from the stored listing so a repeat /rebind <n>
|
||||
// cannot target it; the next explicit /chats refreshes from live state.
|
||||
const remaining = lastChatsListingRef.current.filter(
|
||||
(entry) => entry.sessionId !== target.sessionId,
|
||||
);
|
||||
let danglingCounter = 0;
|
||||
for (const entry of remaining) {
|
||||
if (!entry.isDangling) {
|
||||
entry.rebindNumber = undefined;
|
||||
continue;
|
||||
}
|
||||
danglingCounter += 1;
|
||||
entry.rebindNumber = danglingCounter;
|
||||
}
|
||||
lastChatsListingRef.current = remaining;
|
||||
setPanelCommandNote(formatAiChatsListingNote(remaining, t));
|
||||
}, [liveScopeTargetIds, rebindSessionScope, scopeHostIds, scopeTargetId, scopeType, sessions, t, workspaces, workspaceMemberTerminalIds]);
|
||||
|
||||
// Manual durability actions (history drawer header). Export writes the
|
||||
// whole store to a user-chosen JSON file; import merges by id.
|
||||
const handleExportSessions = useCallback(() => {
|
||||
void exportSessionsBackup?.().then((filePath) => {
|
||||
if (filePath == null) {
|
||||
toast.warning(t('ai.chat.exportUnavailable'));
|
||||
return;
|
||||
}
|
||||
toast.success(
|
||||
filePath
|
||||
? t('ai.chat.exportDone').replace('{path}', filePath)
|
||||
: t('ai.chat.exportDoneNoPath'),
|
||||
);
|
||||
});
|
||||
}, [exportSessionsBackup, t]);
|
||||
|
||||
const handleImportSessions = useCallback(() => {
|
||||
void importSessionsBackup?.().then((count) => {
|
||||
if (count <= 0) {
|
||||
toast.warning(t('ai.chat.importNone'));
|
||||
return;
|
||||
}
|
||||
toast.success(t('ai.chat.importDone').replace('{n}', String(count)));
|
||||
});
|
||||
}, [importSessionsBackup, t]);
|
||||
|
||||
|
||||
const defaultTargetSession = useMemo<DefaultTargetSessionHint | undefined>(() => {
|
||||
const connectedSessions = terminalSessions.filter((session) => session.connected !== false);
|
||||
|
||||
@@ -2065,6 +2189,14 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
setInputValue={setInputValue}
|
||||
handleSend={handleSend}
|
||||
handleCompact={handleCompact}
|
||||
onRenameSession={handleRenameSession}
|
||||
onRenameActiveChat={handleRenameSlashCommand}
|
||||
onListChats={handleListChats}
|
||||
onRebindChat={handleRebindChat}
|
||||
panelCommandNote={panelCommandNote}
|
||||
onDismissPanelCommandNote={() => setPanelCommandNote(null)}
|
||||
onExportSessions={handleExportSessions}
|
||||
onImportSessions={handleImportSessions}
|
||||
canUndoLastTurn={canUndoLastTurn}
|
||||
handleUndoLastTurn={undoLastTurnInSession ? handleUndoLastTurn : undefined}
|
||||
handleSteer={handleSteer}
|
||||
@@ -2143,6 +2275,9 @@ const AI_CHAT_SIDE_PANEL_AI_STATE_KEYS = [
|
||||
'createSession',
|
||||
'deleteSession',
|
||||
'updateSessionTitle',
|
||||
'rebindSessionScope',
|
||||
'exportSessionsBackup',
|
||||
'importSessionsBackup',
|
||||
'updateSessionExternalSessionId',
|
||||
'addMessageToSession',
|
||||
'updateLastMessage',
|
||||
@@ -2193,6 +2328,8 @@ export function aiChatSidePanelPropsAreEqual(
|
||||
if ((prev.isVisible ?? true) !== (next.isVisible ?? true)) return false;
|
||||
if (prev.scopeHostIds !== next.scopeHostIds) return false;
|
||||
if (prev.terminalSessions !== next.terminalSessions) return false;
|
||||
if (prev.liveScopeTargetIds !== next.liveScopeTargetIds) return false;
|
||||
if (prev.workspaces !== next.workspaces) return false;
|
||||
if (prev.resolveExecutorContext !== next.resolveExecutorContext) return false;
|
||||
if (prev.notes !== next.notes) return false;
|
||||
if (prev.hosts !== next.hosts) return false;
|
||||
|
||||
@@ -44,6 +44,12 @@ export interface AIChatSidePanelProps {
|
||||
) => Promise<{ sessionId: string; restored: { text: string; attachments: import('../infrastructure/ai/types').UploadedFile[]; selectedUserSkillSlugs: string[] } } | null>;
|
||||
deleteSession: (sessionId: string, scopeKey?: string) => void;
|
||||
updateSessionTitle: (sessionId: string, title: string) => void;
|
||||
/** Issue #4015 R2: rebind a dangling chat to this panel's scope (panel action, never agent content). */
|
||||
rebindSessionScope?: (sessionId: string, scope: { type: 'terminal' | 'workspace'; targetId: string; hostIds?: string[] }) => void;
|
||||
/** Manual durability escape hatch: export the store to a JSON file (returns the path, '' when fixed-path). */
|
||||
exportSessionsBackup?: () => Promise<string | null>;
|
||||
/** Import sessions from a JSON export; merges by id. Returns the imported count. */
|
||||
importSessionsBackup?: () => Promise<number>;
|
||||
updateSessionExternalSessionId: (sessionId: string, externalSessionId: string | undefined) => void;
|
||||
addMessageToSession: (sessionId: string, message: ChatMessage) => void;
|
||||
updateLastMessage: (
|
||||
@@ -103,6 +109,10 @@ export interface AIChatSidePanelProps {
|
||||
scopeLabel?: string;
|
||||
/** Workspace focused pane; preferred when inheriting AI chat after merge. */
|
||||
focusedSessionId?: string;
|
||||
/** Live workspaces, used to resolve workspace titles for `/chats`. */
|
||||
workspaces?: Array<{ id: string; title: string }>;
|
||||
/** All currently live terminal (and workspace) scope target ids. */
|
||||
liveScopeTargetIds?: Set<string>;
|
||||
|
||||
// Terminal session context (from parent)
|
||||
terminalSessions?: Array<{
|
||||
|
||||
@@ -207,7 +207,9 @@ test('cold plugin host reaches the local fixture through the actual main provide
|
||||
assert.deepEqual(sdkErrorNames, []);
|
||||
} finally {
|
||||
console.error = originalConsoleError;
|
||||
panel?.window.close();
|
||||
// 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); }
|
||||
|
||||
@@ -4,7 +4,7 @@ import { readFileSync } from 'node:fs';
|
||||
import React from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
|
||||
import ChatInput from './ChatInput';
|
||||
import ChatInput, { replaceSlashQueryWithCommand, replaceSlashQueryWithRename } from './ChatInput';
|
||||
import {
|
||||
CHAT_INPUT_MAX_HEIGHT,
|
||||
CHAT_INPUT_MIN_HEIGHT,
|
||||
@@ -217,9 +217,46 @@ test('renders the Catty context usage ring after the model chip', () => {
|
||||
assert.match(html, /aria-valuenow="50"/);
|
||||
});
|
||||
|
||||
test('selecting rename completes a partial slash query without sending', () => {
|
||||
assert.equal(replaceSlashQueryWithRename('/ren', { start: 0, end: 4 }), '/rename ');
|
||||
assert.equal(replaceSlashQueryWithRename('/', { start: 0, end: 1 }), '/rename ');
|
||||
assert.equal(replaceSlashQueryWithRename('before /ren after', { start: 7, end: 11 }), 'before /rename after');
|
||||
assert.equal(replaceSlashQueryWithRename('old text', null), '/rename ');
|
||||
|
||||
const source = readFileSync(new URL('./ChatInput.tsx', import.meta.url), 'utf8');
|
||||
const branch = source.slice(source.indexOf("if (command === 'rename')"), source.indexOf("commitComposerText('');", source.indexOf("if (command === 'rename')")));
|
||||
assert.match(branch, /commitComposerText\(replaceSlashQueryWithRename\(readComposerText\(\), slashRange\)\)/);
|
||||
assert.match(branch, /closeAllMenus\(\);[\s\S]*?return;/);
|
||||
assert.doesNotMatch(branch, /onSend/);
|
||||
});
|
||||
|
||||
test('selecting rebind completes a partial slash query without sending', () => {
|
||||
assert.equal(replaceSlashQueryWithCommand('/re', { start: 0, end: 3 }, 'rebind'), '/rebind ');
|
||||
assert.equal(replaceSlashQueryWithCommand('/', { start: 0, end: 1 }, 'rebind'), '/rebind ');
|
||||
assert.equal(replaceSlashQueryWithCommand('before /re after', { start: 7, end: 10 }, 'rebind'), 'before /rebind after');
|
||||
assert.equal(replaceSlashQueryWithCommand('old text', null, 'rebind'), '/rebind ');
|
||||
|
||||
const source = readFileSync(new URL('./ChatInput.tsx', import.meta.url), 'utf8');
|
||||
const branch = source.slice(source.indexOf("if (command === 'rebind')"), source.indexOf("if (command === 'rename')"));
|
||||
assert.match(branch, /commitComposerText\(replaceSlashQueryWithCommand\(readComposerText\(\), slashRange, 'rebind'\)\)/);
|
||||
assert.match(branch, /closeAllMenus\(\);[\s\S]*?return;/);
|
||||
assert.doesNotMatch(branch, /onSend/);
|
||||
});
|
||||
|
||||
test('slash picker system commands clear the local composer', () => {
|
||||
const source = readFileSync(new URL('./ChatInput.tsx', import.meta.url), 'utf8');
|
||||
assert.match(source, /if \(command === 'stop'\) onStop\?\.\(\);\s*commitComposerText\(''\);/s);
|
||||
// /stop falls through to the shared clear; /rename, /chats and /rebind have
|
||||
// their own branches above it (rename/rebind keep the composer so the user
|
||||
// can type an argument).
|
||||
assert.match(source, /if \(command === 'stop'\) onStop\?\.\(\);[\s\S]*?commitComposerText\(''\);/);
|
||||
assert.match(source, /if \(command === 'chats'\) onListChats\?\.\(\);/);
|
||||
assert.match(source, /if \(command === 'rename'\) \{[\s\S]*?closeAllMenus\(\);[\s\S]*?return;/);
|
||||
assert.match(source, /if \(command === 'rebind'\) \{[\s\S]*?closeAllMenus\(\);[\s\S]*?return;/);
|
||||
});
|
||||
|
||||
test('/rebind routes its argument to onRebindChat as a panel action', () => {
|
||||
const source = readFileSync(new URL('./ChatInput.tsx', import.meta.url), 'utf8');
|
||||
assert.match(source, /if \(systemCommand === 'rebind'\) \{[\s\S]*?onRebindChat\?\.\(getRebindSlashCommandArg\(submittedText\)\);/);
|
||||
});
|
||||
|
||||
test('keeps thinking on a separate chip instead of mixing it into the model list', () => {
|
||||
@@ -333,6 +370,25 @@ test('ChatInput wires /compact through getSystemSlashCommand and canCompact', ()
|
||||
assert.match(source, /command\.slug !== 'compact' \|\| canCompact/);
|
||||
});
|
||||
|
||||
test('ChatInput intercepts /rename and /chats as panel actions before onSend', () => {
|
||||
const source = readFileSync(new URL('./ChatInput.tsx', import.meta.url), 'utf8');
|
||||
// The interception happens in handleSubmit's system-command branch, which
|
||||
// returns before onSend() — the commands never reach the agent.
|
||||
assert.match(source, /systemCommand === 'rename'/);
|
||||
assert.match(source, /systemCommand === 'chats'/);
|
||||
assert.match(source, /onRenameSession\?\.\(getRenameSlashCommandTitle\(submittedText\)\)/);
|
||||
assert.match(source, /onListChats\?\.\(\)/);
|
||||
// Both commands clear the composer instead of falling through to onSend.
|
||||
const renameBranchStart = source.indexOf("systemCommand === 'rename'");
|
||||
const renameBranchEnd = source.indexOf('if (isStreaming && canSteer)', renameBranchStart);
|
||||
const renameBranch = source.slice(renameBranchStart, renameBranchEnd);
|
||||
assert.match(renameBranch, /commitComposerText\(''\)/);
|
||||
assert.doesNotMatch(renameBranch, /onSend\(\)/);
|
||||
// The slash picker registers the built-ins and keeps /rename text editable.
|
||||
assert.match(source, /command === 'rename'/);
|
||||
assert.match(source, /command === 'chats'/);
|
||||
});
|
||||
|
||||
test('renders a Mention Note picker entry with a searchable note list', () => {
|
||||
const source = readFileSync(new URL('./ChatInput.tsx', import.meta.url), 'utf8');
|
||||
|
||||
|
||||
@@ -33,6 +33,8 @@ import {
|
||||
filterQuickMessages,
|
||||
filterSystemSlashCommands,
|
||||
filterUserSkillsForSlash,
|
||||
getRebindSlashCommandArg,
|
||||
getRenameSlashCommandTitle,
|
||||
getSlashCommandItemKey,
|
||||
getSystemSlashCommand,
|
||||
SYSTEM_BUILTIN_SLASH_COMMANDS,
|
||||
@@ -80,6 +82,19 @@ const MENU_VIEWPORT_GUTTER = 8;
|
||||
const CONTEXT_USAGE_RING_RADIUS = 10;
|
||||
const CONTEXT_USAGE_RING_CIRCUMFERENCE = 2 * Math.PI * CONTEXT_USAGE_RING_RADIUS;
|
||||
|
||||
export function replaceSlashQueryWithCommand(
|
||||
text: string,
|
||||
range: { start: number; end: number } | null,
|
||||
command: string,
|
||||
): string {
|
||||
const replacement = `/${command} `;
|
||||
return range ? `${text.slice(0, range.start)}${replacement}${text.slice(range.end)}` : replacement;
|
||||
}
|
||||
|
||||
export function replaceSlashQueryWithRename(text: string, range: { start: number; end: number } | null): string {
|
||||
return replaceSlashQueryWithCommand(text, range, 'rename');
|
||||
}
|
||||
|
||||
function formatContextTokens(tokens: number): string {
|
||||
if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(1)}M`;
|
||||
if (tokens >= 1_000) return `${(tokens / 1_000).toFixed(tokens >= 100_000 ? 0 : 1)}K`;
|
||||
@@ -266,6 +281,12 @@ interface ChatInputProps {
|
||||
onThinkingLevelChange?: (level: string) => void;
|
||||
/** Hidden retained panels must not leave body-portaled menus open. */
|
||||
parked?: boolean;
|
||||
/** `/rename <title>`: rename the current chat (panel action, never sent to the agent). */
|
||||
onRenameSession?: (title: string) => void;
|
||||
/** `/chats`: list the chats visible to this panel (panel action, never sent to the agent). */
|
||||
onListChats?: () => void;
|
||||
/** `/rebind <n>`: rebind a dangling chat to this panel's scope (panel action, never sent to the agent). */
|
||||
onRebindChat?: (arg: string) => void;
|
||||
}
|
||||
|
||||
const ChatInput: React.FC<ChatInputProps> = ({
|
||||
@@ -313,6 +334,9 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
thinkingLevel,
|
||||
onThinkingLevelChange,
|
||||
parked = false,
|
||||
onRenameSession,
|
||||
onListChats,
|
||||
onRebindChat,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const hasTerminalSelectionAttachment = files.some((file) => file.terminalSelection || file.vaultNoteId);
|
||||
@@ -750,6 +774,20 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
onCompact?.();
|
||||
}
|
||||
if (command === 'stop') onStop?.();
|
||||
if (command === 'chats') onListChats?.();
|
||||
if (command === 'rebind') {
|
||||
// Replace a partial slash query (e.g. `/re`) with `/rebind ` so the
|
||||
// user can finish the dangling-chat number; never leave `/re 1` to
|
||||
// be sent to the agent.
|
||||
commitComposerText(replaceSlashQueryWithCommand(readComposerText(), slashRange, 'rebind'));
|
||||
closeAllMenus();
|
||||
return;
|
||||
}
|
||||
if (command === 'rename') {
|
||||
commitComposerText(replaceSlashQueryWithRename(readComposerText(), slashRange));
|
||||
closeAllMenus();
|
||||
return;
|
||||
}
|
||||
commitComposerText('');
|
||||
closeAllMenus();
|
||||
return;
|
||||
@@ -759,7 +797,7 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
return;
|
||||
}
|
||||
insertUserSkillToken(item.skill);
|
||||
}, [canCompact, closeAllMenus, commitComposerText, insertQuickMessage, insertUserSkillToken, onCompact, onStop]);
|
||||
}, [canCompact, closeAllMenus, commitComposerText, insertQuickMessage, insertUserSkillToken, onCompact, onListChats, onStop, readComposerText, slashRange]);
|
||||
|
||||
// Reset active highlight when a menu opens or when the *identity* of the
|
||||
// visible items changes. Watching only `.length` misses cases where the
|
||||
@@ -962,6 +1000,21 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
commitComposerText('');
|
||||
return;
|
||||
}
|
||||
if (systemCommand === 'rename') {
|
||||
onRenameSession?.(getRenameSlashCommandTitle(submittedText));
|
||||
commitComposerText('');
|
||||
return;
|
||||
}
|
||||
if (systemCommand === 'chats') {
|
||||
onListChats?.();
|
||||
commitComposerText('');
|
||||
return;
|
||||
}
|
||||
if (systemCommand === 'rebind') {
|
||||
onRebindChat?.(getRebindSlashCommandArg(submittedText));
|
||||
commitComposerText('');
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (isStreaming && canSteer) {
|
||||
@@ -972,7 +1025,7 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
// abort; the parent clears value only after the user message is accepted.
|
||||
onSend();
|
||||
},
|
||||
[canCompact, canSteer, commitComposerText, isStreaming, onCompact, onSend, onSteer, onStop, readComposerText],
|
||||
[canCompact, canSteer, commitComposerText, isStreaming, onCompact, onListChats, onRebindChat, onRenameSession, onSend, onSteer, onStop, readComposerText],
|
||||
);
|
||||
|
||||
const status: PromptInputStatus = isStreaming ? 'streaming' : 'idle';
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import type { AISession, ExternalAgentConfig } from '../../infrastructure/ai/types.ts';
|
||||
import {
|
||||
buildAiChatsListingEntries,
|
||||
canRebindChat,
|
||||
formatAiChatsListingNote,
|
||||
resolveChatAgentLabel,
|
||||
resolveRebindTargetFromArg,
|
||||
truncateChatTitle,
|
||||
} from './aiChatsListing.ts';
|
||||
|
||||
test('rebind revalidates cached entries against current sessions and live targets', () => {
|
||||
const liveTargets = new Set(['ws-live', 'term-live']);
|
||||
assert.equal(canRebindChat(undefined, liveTargets), false);
|
||||
assert.equal(canRebindChat(createSession('restored', { scope: { type: 'workspace', targetId: 'ws-live' } }), liveTargets), false);
|
||||
assert.equal(canRebindChat(createSession('restored-terminal', { scope: { type: 'terminal', targetId: 'term-live' } }), liveTargets), false);
|
||||
assert.equal(canRebindChat(createSession('dangling', { scopeDangling: true, scope: { type: 'workspace', targetId: 'ws-old' } }), liveTargets), true);
|
||||
assert.equal(canRebindChat(createSession('unbound', { scope: { type: 'terminal', targetId: 'term-old' } }), liveTargets), true);
|
||||
});
|
||||
|
||||
function createSession(
|
||||
id: string,
|
||||
overrides: Partial<AISession> = {},
|
||||
): AISession {
|
||||
return {
|
||||
id,
|
||||
title: id,
|
||||
agentId: 'catty',
|
||||
scope: { type: 'workspace', targetId: 'ws-1' },
|
||||
messages: [],
|
||||
createdAt: 1_000,
|
||||
updatedAt: 1_000,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const t = (key: string) => key;
|
||||
const externalAgents: ExternalAgentConfig[] = [
|
||||
{ id: 'claude', name: 'Claude Code' } as ExternalAgentConfig,
|
||||
];
|
||||
|
||||
function buildEntries(sessions: AISession[], activeSessionId: string | null = 's-live') {
|
||||
return buildAiChatsListingEntries({
|
||||
sessions,
|
||||
activeSessionId,
|
||||
externalAgents,
|
||||
workspaces: [{ id: 'ws-1', title: 'Web farm' }],
|
||||
liveTerminalIds: new Set(['term-1']),
|
||||
scopeTargetId: 'ws-1',
|
||||
t,
|
||||
});
|
||||
}
|
||||
|
||||
test('/chats listing marks the live workspace chat, terminal chat and dangling chat', () => {
|
||||
const now = Date.now();
|
||||
const sessions = [
|
||||
createSession('s-live', { scope: { type: 'workspace', targetId: 'ws-1' }, updatedAt: now }),
|
||||
createSession('s-term', { scope: { type: 'terminal', targetId: 'term-1' }, agentId: 'claude', updatedAt: now }),
|
||||
createSession('s-dangling', { scope: { type: 'workspace', targetId: 'ws-gone' }, updatedAt: now }),
|
||||
];
|
||||
|
||||
const entries = buildEntries(sessions);
|
||||
assert.equal(entries.length, 3);
|
||||
|
||||
assert.equal(entries[0].isCurrent, true);
|
||||
assert.equal(entries[0].agentLabel, 'Catty Agent');
|
||||
assert.equal(entries[0].scopeLabel, 'ai.chat.chatsScopeWorkspace: Web farm');
|
||||
assert.equal(entries[0].isDangling, false);
|
||||
|
||||
assert.equal(entries[1].isCurrent, false);
|
||||
assert.equal(entries[1].agentLabel, 'Claude Code');
|
||||
assert.equal(entries[1].scopeLabel, 'ai.chat.chatsScopeTerminal');
|
||||
assert.equal(entries[1].isDangling, false);
|
||||
|
||||
assert.equal(entries[2].isDangling, true);
|
||||
assert.equal(entries[2].scopeLabel, 'ai.chat.chatsScopeDangling');
|
||||
});
|
||||
|
||||
test('/chats listing numbers dangling entries for /rebind and leaves live ones unnumbered', () => {
|
||||
const sessions = [
|
||||
createSession('s-dangling-a', { scope: { type: 'workspace', targetId: 'ws-gone-a' } }),
|
||||
createSession('s-live', { scope: { type: 'workspace', targetId: 'ws-1' } }),
|
||||
createSession('s-dangling-b', { scope: { type: 'terminal', targetId: 'term-gone' } }),
|
||||
createSession('s-dangling-c', { scope: { type: 'workspace', targetId: 'ws-gone-b' } }),
|
||||
];
|
||||
|
||||
const entries = buildEntries(sessions, null);
|
||||
assert.deepEqual(
|
||||
entries.map((entry) => entry.rebindNumber ?? 0),
|
||||
[1, 0, 2, 3],
|
||||
);
|
||||
});
|
||||
|
||||
test('/chats listing falls back to the panel scope label for the current workspace', () => {
|
||||
const sessions = [
|
||||
createSession('s-live', { scope: { type: 'workspace', targetId: 'ws-1' } }),
|
||||
];
|
||||
const entries = buildAiChatsListingEntries({
|
||||
sessions,
|
||||
activeSessionId: 's-live',
|
||||
externalAgents,
|
||||
workspaces: [],
|
||||
liveTerminalIds: new Set<string>(),
|
||||
currentScopeLabel: 'Web farm',
|
||||
scopeTargetId: 'ws-1',
|
||||
t,
|
||||
});
|
||||
assert.equal(entries[0].isDangling, false);
|
||||
assert.equal(entries[0].scopeLabel, 'ai.chat.chatsScopeWorkspace: Web farm');
|
||||
});
|
||||
|
||||
test('/chats note renders header, current marker and one line per chat', () => {
|
||||
const sessions = [
|
||||
createSession('s-live', { title: 'Fix deploy' }),
|
||||
createSession('s-term', { title: 'Logs', scope: { type: 'terminal', targetId: 'term-1' }, agentId: 'claude' }),
|
||||
createSession('s-dangling', { title: 'Old', scope: { type: 'workspace', targetId: 'ws-gone' } }),
|
||||
];
|
||||
const entries = buildEntries(sessions);
|
||||
const note = formatAiChatsListingNote(entries, t);
|
||||
const lines = note.split('\n');
|
||||
assert.equal(lines[0], 'ai.chat.chatsHeader');
|
||||
// Three chat lines plus the dangling rebind hint.
|
||||
assert.equal(lines.length, 5);
|
||||
assert.match(lines[1], /^• Fix deploy — Catty Agent · /);
|
||||
assert.match(lines[2], /^ {2}Logs — Claude Code · /);
|
||||
assert.match(lines[3], /ai\.chat\.chatsScopeDangling/);
|
||||
assert.ok(!lines[3].startsWith('•'));
|
||||
// The dangling line carries an inline rebind affordance, and the note
|
||||
// explains it.
|
||||
assert.match(lines[3], /\/rebind 1/);
|
||||
assert.equal(lines[4], 'ai.chat.chatsRebindHint');
|
||||
});
|
||||
|
||||
test('/chats note omits the rebind hint when nothing is dangling', () => {
|
||||
const sessions = [
|
||||
createSession('s-live', { title: 'Fix deploy' }),
|
||||
createSession('s-term', { title: 'Logs', scope: { type: 'terminal', targetId: 'term-1' }, agentId: 'claude' }),
|
||||
];
|
||||
const entries = buildEntries(sessions);
|
||||
const note = formatAiChatsListingNote(entries, t);
|
||||
assert.ok(!note.includes('rebind'));
|
||||
});
|
||||
|
||||
test('resolveRebindTargetFromArg picks only dangling entries by their number', () => {
|
||||
const sessions = [
|
||||
createSession('s-live', { title: 'Fix deploy' }),
|
||||
createSession('s-dangling', { title: 'Old', scope: { type: 'workspace', targetId: 'ws-gone' } }),
|
||||
createSession('s-dangling-2', { title: 'Older', scope: { type: 'terminal', targetId: 'term-gone' } }),
|
||||
];
|
||||
const entries = buildEntries(sessions);
|
||||
const dangling = entries.find((entry) => entry.isDangling);
|
||||
assert.ok(dangling);
|
||||
|
||||
assert.equal(resolveRebindTargetFromArg('1', entries)?.sessionId, 's-dangling');
|
||||
assert.equal(resolveRebindTargetFromArg(' 2 ', entries)?.sessionId, entries.filter((entry) => entry.isDangling)[1].sessionId);
|
||||
assert.equal(resolveRebindTargetFromArg('3', entries), null);
|
||||
assert.equal(resolveRebindTargetFromArg('1foo', entries), null);
|
||||
assert.equal(resolveRebindTargetFromArg('1.5', entries), null);
|
||||
assert.equal(resolveRebindTargetFromArg('0', entries), null);
|
||||
assert.equal(resolveRebindTargetFromArg('-1', entries), null);
|
||||
assert.equal(resolveRebindTargetFromArg('', entries), null);
|
||||
assert.equal(resolveRebindTargetFromArg('abc', entries), null);
|
||||
// A live entry is not rebindable even if its position would match.
|
||||
assert.equal(resolveRebindTargetFromArg('1', [entries[0]]), null);
|
||||
});
|
||||
|
||||
test('/chats note reports an empty panel', () => {
|
||||
assert.equal(formatAiChatsListingNote([], t), 'ai.chat.chatsEmpty');
|
||||
});
|
||||
|
||||
test('truncateChatTitle clamps long titles and keeps short ones intact', () => {
|
||||
assert.equal(truncateChatTitle('short'), 'short');
|
||||
assert.equal(truncateChatTitle(' '), '');
|
||||
const long = 'x'.repeat(80);
|
||||
const truncated = truncateChatTitle(long);
|
||||
assert.equal(truncated.length, 42);
|
||||
assert.ok(truncated.endsWith('…'));
|
||||
});
|
||||
|
||||
test('resolveChatAgentLabel resolves external agents and falls back to the raw id', () => {
|
||||
assert.equal(resolveChatAgentLabel('catty', externalAgents), 'Catty Agent');
|
||||
assert.equal(resolveChatAgentLabel('claude', externalAgents), 'Claude Code');
|
||||
assert.equal(resolveChatAgentLabel('mystery', externalAgents), 'mystery');
|
||||
});
|
||||
@@ -0,0 +1,161 @@
|
||||
import type { AISession, ExternalAgentConfig } from '../../infrastructure/ai/types';
|
||||
import { formatRelativeTime } from '../AIChatSessionHistoryDrawer';
|
||||
|
||||
/**
|
||||
* Pure builder for the `/chats` panel note. Kept separate from the component
|
||||
* so a future rebind action (issue #4015 R2) can attach per-entry actions
|
||||
* without reworking the text rendering.
|
||||
*/
|
||||
|
||||
export interface AiChatsListingInput {
|
||||
/** Rank-sorted sessions visible to the current panel scope. */
|
||||
sessions: AISession[];
|
||||
activeSessionId: string | null;
|
||||
externalAgents: ExternalAgentConfig[];
|
||||
/** Live workspaces, used to resolve workspace scope titles. */
|
||||
workspaces: Array<{ id: string; title: string }>;
|
||||
/** Live terminal session ids visible to this panel. */
|
||||
liveTerminalIds: Iterable<string>;
|
||||
/** Fallback scope label for the panel's own scope (e.g. current workspace title). */
|
||||
currentScopeLabel?: string;
|
||||
scopeTargetId?: string;
|
||||
t: (key: string) => string;
|
||||
}
|
||||
|
||||
export interface AiChatsListingEntry {
|
||||
sessionId: string;
|
||||
title: string;
|
||||
agentLabel: string;
|
||||
scopeLabel: string;
|
||||
/** Scope target no longer resolves to a live workspace/terminal. */
|
||||
isDangling: boolean;
|
||||
/** 1-based number among dangling entries, used by `/rebind <n>`. */
|
||||
rebindNumber?: number;
|
||||
isCurrent: boolean;
|
||||
relativeTime: string;
|
||||
}
|
||||
|
||||
const TITLE_TRUNCATE = 42;
|
||||
|
||||
export function truncateChatTitle(title: string): string {
|
||||
const trimmed = title.trim() || '';
|
||||
if (trimmed.length <= TITLE_TRUNCATE) return trimmed;
|
||||
return `${trimmed.slice(0, TITLE_TRUNCATE - 1)}…`;
|
||||
}
|
||||
|
||||
export function resolveChatAgentLabel(
|
||||
agentId: string,
|
||||
externalAgents: ExternalAgentConfig[],
|
||||
): string {
|
||||
if (agentId === 'catty') return 'Catty Agent';
|
||||
return externalAgents.find((agent) => agent.id === agentId)?.name ?? agentId;
|
||||
}
|
||||
|
||||
export function buildAiChatsListingEntries(input: AiChatsListingInput): AiChatsListingEntry[] {
|
||||
const {
|
||||
sessions,
|
||||
activeSessionId,
|
||||
externalAgents,
|
||||
workspaces,
|
||||
liveTerminalIds,
|
||||
scopeTargetId,
|
||||
t,
|
||||
} = input;
|
||||
const workspaceTitles = new Map(workspaces.map((workspace) => [workspace.id, workspace.title]));
|
||||
const liveTerminals = new Set(liveTerminalIds);
|
||||
|
||||
const entries: AiChatsListingEntry[] = sessions.map((session) => {
|
||||
const isCurrent = session.id === activeSessionId;
|
||||
let scopeLabel = '';
|
||||
let isDangling = false;
|
||||
if (session.scope.type === 'workspace') {
|
||||
const title = workspaceTitles.get(session.scope.targetId ?? '');
|
||||
if (title != null) {
|
||||
scopeLabel = `${t('ai.chat.chatsScopeWorkspace')}: ${title}`;
|
||||
} else {
|
||||
// The panel's own scope may be labelled by the parent even when the
|
||||
// workspace store has not been threaded through; treat the active
|
||||
// scope target as live.
|
||||
if (isCurrent && session.scope.targetId === scopeTargetId && input.currentScopeLabel) {
|
||||
scopeLabel = `${t('ai.chat.chatsScopeWorkspace')}: ${input.currentScopeLabel}`;
|
||||
} else {
|
||||
isDangling = true;
|
||||
scopeLabel = t('ai.chat.chatsScopeDangling');
|
||||
}
|
||||
}
|
||||
} else if (session.scope.type === 'terminal') {
|
||||
if (liveTerminals.has(session.scope.targetId ?? '')) {
|
||||
scopeLabel = t('ai.chat.chatsScopeTerminal');
|
||||
} else {
|
||||
isDangling = true;
|
||||
scopeLabel = t('ai.chat.chatsScopeDangling');
|
||||
}
|
||||
} else {
|
||||
scopeLabel = t('ai.chat.chatsScopeGlobal');
|
||||
}
|
||||
|
||||
return {
|
||||
sessionId: session.id,
|
||||
title: truncateChatTitle(session.title || t('ai.chat.untitled')),
|
||||
agentLabel: resolveChatAgentLabel(session.agentId, externalAgents),
|
||||
scopeLabel,
|
||||
isDangling,
|
||||
isCurrent,
|
||||
relativeTime: formatRelativeTime(new Date(session.updatedAt), t),
|
||||
};
|
||||
});
|
||||
|
||||
// Number the dangling entries so the note can advertise `/rebind <n>`.
|
||||
let danglingCounter = 0;
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDangling) continue;
|
||||
danglingCounter += 1;
|
||||
entry.rebindNumber = danglingCounter;
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a `/rebind <n>` argument against a `/chats` listing. Only dangling
|
||||
* entries are rebindable; a non-dangling target (or a bad index) yields null.
|
||||
*/
|
||||
export function resolveRebindTargetFromArg(
|
||||
arg: string,
|
||||
entries: AiChatsListingEntry[],
|
||||
): AiChatsListingEntry | null {
|
||||
const trimmed = arg.trim();
|
||||
if (!/^\d+$/.test(trimmed)) return null;
|
||||
const index = Number.parseInt(trimmed, 10);
|
||||
if (!Number.isInteger(index) || index < 1) return null;
|
||||
const dangling = entries.filter((entry) => entry.isDangling);
|
||||
return dangling[index - 1] ?? null;
|
||||
}
|
||||
|
||||
/** Revalidate a cached listing entry against the current session and live scopes. */
|
||||
export function canRebindChat(session: AISession | undefined, liveScopeTargetIds: ReadonlySet<string>): boolean {
|
||||
if (!session) return false;
|
||||
return !!session.scopeDangling || !session.scope.targetId || !liveScopeTargetIds.has(session.scope.targetId);
|
||||
}
|
||||
|
||||
export function formatAiChatsListingNote(
|
||||
entries: AiChatsListingEntry[],
|
||||
t: (key: string) => string,
|
||||
): string {
|
||||
if (entries.length === 0) {
|
||||
return t('ai.chat.chatsEmpty');
|
||||
}
|
||||
let hasDangling = false;
|
||||
const lines = entries.map((entry) => {
|
||||
const marker = entry.isCurrent ? '•' : ' ';
|
||||
const rebindHint = entry.isDangling && entry.rebindNumber != null
|
||||
? ` · /rebind ${entry.rebindNumber}`
|
||||
: '';
|
||||
if (entry.isDangling) hasDangling = true;
|
||||
return `${marker} ${entry.title} — ${entry.agentLabel} · ${entry.scopeLabel} · ${entry.relativeTime}${rebindHint}`;
|
||||
});
|
||||
if (hasDangling) {
|
||||
lines.push(t('ai.chat.chatsRebindHint'));
|
||||
}
|
||||
return [t('ai.chat.chatsHeader'), ...lines].join('\n');
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -203,6 +203,7 @@ export const SettingCard: React.FC<SettingCardProps> = ({
|
||||
interface SettingRowProps {
|
||||
leading?: React.ReactNode;
|
||||
label?: React.ReactNode;
|
||||
className?: string;
|
||||
description?: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
align?: "center" | "start";
|
||||
@@ -213,6 +214,7 @@ interface SettingRowProps {
|
||||
export const SettingRow: React.FC<SettingRowProps> = ({
|
||||
leading,
|
||||
label,
|
||||
className,
|
||||
description,
|
||||
children,
|
||||
align = "center",
|
||||
@@ -225,6 +227,7 @@ export const SettingRow: React.FC<SettingRowProps> = ({
|
||||
// Keep square corners: rounded rows bend divide-y separators at both ends.
|
||||
"flex justify-between py-3 gap-4",
|
||||
align === "start" ? "items-start" : "items-center",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{leading && <div className="shrink-0">{leading}</div>}
|
||||
|
||||
@@ -68,6 +68,11 @@ test('plugin manager is single-instance, closes with Escape, restores focus and
|
||||
})}>Open contributed settings view</button>}</PluginManagementDialog></I18nProvider>));
|
||||
await click(trigger);
|
||||
await click(document.querySelector<HTMLButtonElement>('[role="dialog"] button:not([data-dialog-close])')!);
|
||||
// Radix schedules close-focus cleanup after the React close commit. The
|
||||
// settle inside click's act() can precede that commit; observe the actual
|
||||
// handoff instead of assuming the cleanup timer already ran.
|
||||
const handoffDeadline = Date.now() + 2_000;
|
||||
while (!handedOff && Date.now() < handoffDeadline) await act(async () => { await settle(); });
|
||||
assert.equal(handedOff, true, 'external view opens only after modal ownership is released');
|
||||
function ControlledEntry() {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
@@ -1260,6 +1260,34 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="tools" className="m-0 space-y-6">
|
||||
|
||||
|
||||
<SettingsSection
|
||||
anchorId="ai-tool-access-mode"
|
||||
title={t('ai.toolAccess.title')}
|
||||
>
|
||||
<SettingCard>
|
||||
<SettingRow className="flex-col items-start md:flex-row md:items-center" description={t('ai.toolAccess.description')}>
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
<Select
|
||||
value={toolIntegrationMode}
|
||||
options={[
|
||||
{ value: 'mcp', label: t('ai.toolAccess.mode.mcp') },
|
||||
{ value: 'skills', label: t('ai.toolAccess.mode.skills') },
|
||||
]}
|
||||
onChange={(value) => setToolIntegrationMode(value as AIToolIntegrationMode)}
|
||||
className="w-48"
|
||||
/>
|
||||
<ToolAccessGuidance mode={toolIntegrationMode} />
|
||||
</div>
|
||||
</SettingRow>
|
||||
</SettingCard>
|
||||
</SettingsSection>
|
||||
|
||||
<ExternalMcpCard />
|
||||
|
||||
<AIPrivacySection />
|
||||
|
||||
<SettingsSection title={t('ai.chatShortcuts.title')}>
|
||||
<SettingCard divided>
|
||||
<SettingRow
|
||||
@@ -1276,34 +1304,6 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
|
||||
</SettingCard>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection
|
||||
anchorId="ai-tool-access-mode"
|
||||
title={t('ai.toolAccess.title')}
|
||||
>
|
||||
<SettingCard>
|
||||
<SettingRow description={t('ai.toolAccess.description')}>
|
||||
<Select
|
||||
value={toolIntegrationMode}
|
||||
options={[
|
||||
{ value: 'mcp', label: t('ai.toolAccess.mode.mcp') },
|
||||
{ value: 'skills', label: t('ai.toolAccess.mode.skills') },
|
||||
]}
|
||||
onChange={(value) => setToolIntegrationMode(value as AIToolIntegrationMode)}
|
||||
className="w-48"
|
||||
/>
|
||||
</SettingRow>
|
||||
<div className="pb-4">
|
||||
<ToolAccessGuidance mode={toolIntegrationMode} />
|
||||
</div>
|
||||
</SettingCard>
|
||||
</SettingsSection>
|
||||
|
||||
<AIPrivacySection />
|
||||
|
||||
<SettingsSection anchorId="ai-external-mcp" title={t('ai.externalMcp.title')}>
|
||||
<ExternalMcpCard />
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection
|
||||
anchorId="ai-user-skills"
|
||||
title={t('ai.userSkills.title')}
|
||||
|
||||
@@ -1,183 +1,154 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import test from 'node:test';
|
||||
import React from 'react';
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
|
||||
import { I18nProvider } from '../../../../application/i18n/I18nProvider';
|
||||
import React, { act } from 'react';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import { STORAGE_KEY_AI_PRIVACY } from '../../../../infrastructure/config/storageKeys';
|
||||
import { AIPrivacySection } from './AIPrivacySection';
|
||||
|
||||
function installEnvironment(setConfig: (config: unknown) => Promise<unknown>) {
|
||||
const values = new Map<string, string>();
|
||||
const events = new EventTarget();
|
||||
const globals = {
|
||||
IS_REACT_ACT_ENVIRONMENT: true,
|
||||
localStorage: {
|
||||
getItem: (key: string) => values.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => values.set(key, value),
|
||||
},
|
||||
window: {
|
||||
netcatty: { externalMcpSetConfig: setConfig },
|
||||
addEventListener: events.addEventListener.bind(events),
|
||||
removeEventListener: events.removeEventListener.bind(events),
|
||||
dispatchEvent: events.dispatchEvent.bind(events),
|
||||
},
|
||||
};
|
||||
const previous = new Map<string, PropertyDescriptor | undefined>();
|
||||
for (const [name, value] of Object.entries(globals)) {
|
||||
previous.set(name, Object.getOwnPropertyDescriptor(globalThis, name));
|
||||
Object.defineProperty(globalThis, name, { value, configurable: true, writable: true });
|
||||
}
|
||||
return {
|
||||
values,
|
||||
restore: () => {
|
||||
for (const [name, descriptor] of previous) {
|
||||
if (descriptor) Object.defineProperty(globalThis, name, descriptor);
|
||||
else Reflect.deleteProperty(globalThis, name);
|
||||
}
|
||||
},
|
||||
};
|
||||
const dom = new JSDOM('<div id="root"></div>', { url: 'http://localhost/#/settings' });
|
||||
for (const key of ['window', 'document', 'navigator', 'HTMLElement', 'HTMLInputElement', 'HTMLTextAreaElement', 'Element', 'Node', 'NodeFilter', 'MutationObserver', 'CustomEvent', 'Event', 'KeyboardEvent', 'MouseEvent', 'FocusEvent', 'getComputedStyle', 'localStorage'] as const) {
|
||||
Object.defineProperty(globalThis, key, { value: dom.window[key], configurable: true });
|
||||
}
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true, requestAnimationFrame: (fn: () => void) => setTimeout(fn, 0), cancelAnimationFrame: clearTimeout });
|
||||
const { createRoot } = await import('react-dom/client');
|
||||
const { I18nProvider } = await import('../../../../application/i18n/I18nProvider');
|
||||
const { AIPrivacySection } = await import('./AIPrivacySection');
|
||||
|
||||
async function mount(setConfig: (config: unknown) => Promise<unknown> = async () => ({ ok: true }), saved?: string) {
|
||||
dom.window.localStorage.clear();
|
||||
if (saved) dom.window.localStorage.setItem(STORAGE_KEY_AI_PRIVACY, saved);
|
||||
Object.assign(window, { netcatty: { externalMcpSetConfig: setConfig } });
|
||||
const root = createRoot(document.getElementById('root')!);
|
||||
await act(async () => root.render(<I18nProvider locale="en"><AIPrivacySection /></I18nProvider>));
|
||||
return async () => { await act(async () => root.unmount()); };
|
||||
}
|
||||
const button = (text: string) => [...document.querySelectorAll<HTMLButtonElement>('button')].find((el) => el.textContent === text)!;
|
||||
const click = async (text: string) => { await act(async () => button(text).click()); };
|
||||
async function input(index: number, value: string) {
|
||||
const el = document.querySelectorAll('textarea')[index];
|
||||
await act(async () => {
|
||||
Object.getOwnPropertyDescriptor(dom.window.HTMLTextAreaElement.prototype, 'value')!.set!.call(el, value);
|
||||
el.dispatchEvent(new dom.window.Event('input', { bubbles: true }));
|
||||
});
|
||||
}
|
||||
|
||||
function view() {
|
||||
return <I18nProvider locale="en"><AIPrivacySection /></I18nProvider>;
|
||||
}
|
||||
|
||||
test('privacy settings default off and explain their limits and connection scope', async () => {
|
||||
const env = installEnvironment(async () => ({ ok: true }));
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
test('privacy overview is persisted state; configuration preserves the full scope and atomic draft', async () => {
|
||||
const unmount = await mount();
|
||||
try {
|
||||
await act(async () => { renderer = create(view()); });
|
||||
const markup = JSON.stringify(renderer!.toJSON());
|
||||
assert.equal(renderer!.root.findAllByType('button').filter((node) => node.props['aria-checked'] === false).length, 2);
|
||||
assert.match(markup, /cannot detect every secret/);
|
||||
assert.match(markup, /AI privacy/);
|
||||
assert.match(markup, /Catty, integrated agents and external MCP clients/);
|
||||
assert.match(markup, /Only Netcatty-managed data and tools are covered/);
|
||||
assert.match(markup, /native tools, direct file access and other MCP servers/);
|
||||
assert.match(markup, /Binary tool responses are blocked/);
|
||||
assert.match(markup, /one managed conversation or external MCP connection/);
|
||||
assert.match(markup, /reconnect external MCP clients/);
|
||||
assert.match(markup, /start a new managed conversation/);
|
||||
assert.match(markup, /Additional IP ranges/);
|
||||
assert.match(markup, /Custom regular expressions/);
|
||||
} finally {
|
||||
if (renderer) await act(async () => { renderer!.unmount(); });
|
||||
env.restore();
|
||||
}
|
||||
assert.equal(document.querySelector('textarea'), null);
|
||||
assert.match(document.body.textContent!, /Text redaction disabled/);
|
||||
await click('Configure privacy');
|
||||
const dialog = document.querySelector('[role="dialog"]')!;
|
||||
assert.match(dialog.textContent!, /cannot detect every secret/);
|
||||
assert.match(dialog.textContent!, /Catty, integrated agents and external MCP clients/);
|
||||
assert.match(dialog.textContent!, /Only Netcatty-managed data and tools are covered/);
|
||||
assert.match(dialog.textContent!, /native tools, direct file access and other MCP servers/);
|
||||
assert.match(dialog.textContent!, /Binary tool responses are blocked/);
|
||||
assert.match(dialog.textContent!, /one managed conversation or external MCP connection/);
|
||||
assert.match(dialog.textContent!, /reconnect external MCP clients/);
|
||||
assert.equal(dialog.querySelectorAll('[role="switch"][aria-checked="false"]').length, 2);
|
||||
assert.equal(button('Apply').disabled, true);
|
||||
} finally { await unmount(); }
|
||||
});
|
||||
|
||||
test('AI privacy is shared Tool Access UI, not nested under External MCP', async () => {
|
||||
const settingsTab = await readFile(new URL('../SettingsAITab.tsx', import.meta.url), 'utf8');
|
||||
const externalCard = await readFile(new URL('./ExternalMcpCard.tsx', import.meta.url), 'utf8');
|
||||
const toolsTab = settingsTab.split('<TabsContent value="tools"')[1].split('</TabsContent>')[0];
|
||||
assert.match(toolsTab, /<\/SettingsSection>\s*<AIPrivacySection \/>\s*<SettingsSection anchorId="ai-external-mcp"/);
|
||||
assert.doesNotMatch(externalCard, /AIPrivacySection|ExternalMcpRedactionSection/);
|
||||
});
|
||||
|
||||
test('privacy drafts apply together, retain rejected input and persist only validated settings', async () => {
|
||||
test('rejected privacy draft stays open and accepted draft commits once and returns focus', async () => {
|
||||
const calls: unknown[] = [];
|
||||
let accepted = false;
|
||||
const env = installEnvironment(async (config) => {
|
||||
calls.push(config);
|
||||
return accepted ? { ok: true } : { ok: false, error: 'Invalid privacy rules.' };
|
||||
});
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
const unmount = await mount(async (config) => { calls.push(config); return accepted ? { ok: true } : { ok: false, error: 'Invalid privacy rules.' }; });
|
||||
try {
|
||||
await act(async () => { renderer = create(view()); });
|
||||
const root = renderer!.root;
|
||||
const privacySwitch = () => root.findAllByType('button').find((node) => (
|
||||
node.props['aria-label'] === 'Enable text redaction'
|
||||
))!;
|
||||
const apply = () => root.findAllByType('button').find((node) => node.props.children === 'Apply')!;
|
||||
await act(async () => { privacySwitch().props.onClick(); });
|
||||
await act(async () => {
|
||||
root.findAllByType('textarea')[0].props.onChange({ currentTarget: { value: '192.0.2.0/24\n\n2001:db8::/32' } });
|
||||
root.findAllByType('textarea')[1].props.onChange({ currentTarget: { value: '[' } });
|
||||
});
|
||||
assert.equal(calls.length, 0, 'draft edits must not change runtime or storage');
|
||||
assert.equal(env.values.get(STORAGE_KEY_AI_PRIVACY), undefined);
|
||||
await act(async () => { apply().props.onClick(); });
|
||||
assert.equal(root.findAllByType('textarea')[1].props.value, '[');
|
||||
assert.match(root.findByProps({ role: 'alert' }).children.join(''), /Invalid privacy rules/);
|
||||
assert.equal(env.values.get(STORAGE_KEY_AI_PRIVACY), undefined);
|
||||
|
||||
const trigger = button('Configure privacy');
|
||||
trigger.focus();
|
||||
await click('Configure privacy');
|
||||
await act(async () => (document.querySelector('[aria-label="Enable text redaction"]') as HTMLButtonElement).click());
|
||||
await input(0, '192.0.2.0/24\n\n2001:db8::/32');
|
||||
await input(1, '[');
|
||||
assert.equal(calls.length, 0);
|
||||
assert.equal(dom.window.localStorage.getItem(STORAGE_KEY_AI_PRIVACY), null);
|
||||
await click('Apply');
|
||||
assert.match(document.querySelector('[role="alert"]')!.textContent!, /Invalid privacy rules/);
|
||||
assert.equal(document.querySelectorAll('textarea')[1].value, '[');
|
||||
assert.equal(dom.window.localStorage.getItem(STORAGE_KEY_AI_PRIVACY), null);
|
||||
accepted = true;
|
||||
await act(async () => {
|
||||
root.findAllByType('textarea')[1].props.onChange({ currentTarget: { value: 'INTERNAL_[A-Z0-9]+' } });
|
||||
});
|
||||
await act(async () => { apply().props.onClick(); });
|
||||
const saved = JSON.parse(env.values.get(STORAGE_KEY_AI_PRIVACY)!);
|
||||
assert.deepEqual(saved, {
|
||||
enabled: true,
|
||||
redactPrivateIps: false,
|
||||
cidrs: ['192.0.2.0/24', '2001:db8::/32'],
|
||||
patterns: ['INTERNAL_[A-Z0-9]+'],
|
||||
});
|
||||
await input(1, 'INTERNAL_[A-Z0-9]+');
|
||||
await click('Apply');
|
||||
const saved = JSON.parse(dom.window.localStorage.getItem(STORAGE_KEY_AI_PRIVACY)!);
|
||||
assert.deepEqual(saved, { enabled: true, redactPrivateIps: false, cidrs: ['192.0.2.0/24', '2001:db8::/32'], patterns: ['INTERNAL_[A-Z0-9]+'] });
|
||||
assert.deepEqual(calls[1], { redaction: saved });
|
||||
assert.equal(root.findAllByProps({ role: 'alert' }).length, 0);
|
||||
assert.match(root.findByProps({ role: 'status' }).children.join(''), /new managed conversation/);
|
||||
} finally {
|
||||
if (renderer) await act(async () => { renderer!.unmount(); });
|
||||
env.restore();
|
||||
}
|
||||
assert.equal(document.querySelector('[role="dialog"]'), null);
|
||||
assert.equal(document.activeElement, trigger);
|
||||
assert.match(document.querySelector('[role="status"]')!.textContent!, /new managed conversation/);
|
||||
await click('Configure privacy');
|
||||
assert.equal(document.querySelectorAll('textarea')[1].value, 'INTERNAL_[A-Z0-9]+');
|
||||
} finally { await unmount(); }
|
||||
});
|
||||
|
||||
test('malformed saved privacy settings remain visible as a recoverable error', async () => {
|
||||
const env = installEnvironment(async () => ({ ok: true }));
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
test('Cancel, X and Escape discard edits and reopening reads saved values', async () => {
|
||||
let calls = 0;
|
||||
const unmount = await mount(async () => { calls += 1; return { ok: true }; });
|
||||
try {
|
||||
env.values.set(STORAGE_KEY_AI_PRIVACY, '{');
|
||||
await act(async () => { renderer = create(view()); });
|
||||
assert.match(renderer!.root.findByProps({ role: 'alert' }).children.join(''), /Invalid saved AI privacy settings/);
|
||||
assert.ok(renderer!.root.findAllByType('button').some((node) => node.props.children === 'Apply'));
|
||||
assert.equal(env.values.get(STORAGE_KEY_AI_PRIVACY), '{');
|
||||
} finally {
|
||||
if (renderer) await act(async () => { renderer!.unmount(); });
|
||||
env.restore();
|
||||
}
|
||||
for (const close of ['Cancel', 'X', 'Escape']) {
|
||||
await click('Configure privacy');
|
||||
await input(0, '192.0.2.0/24');
|
||||
assert.match(document.querySelector('[role="status"]')!.textContent!, /Unsaved changes/);
|
||||
if (close === 'Cancel') await click('Cancel');
|
||||
else if (close === 'X') await act(async () => (document.querySelector('[data-dialog-close]:not([aria-hidden])') as HTMLButtonElement).click());
|
||||
else await act(async () => { document.dispatchEvent(new dom.window.KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); });
|
||||
assert.equal(document.querySelector('[role="dialog"]'), null);
|
||||
await click('Configure privacy');
|
||||
assert.equal(document.querySelector('textarea')!.value, '');
|
||||
await click('Cancel');
|
||||
}
|
||||
assert.equal(calls, 0);
|
||||
assert.equal(dom.window.localStorage.getItem(STORAGE_KEY_AI_PRIVACY), null);
|
||||
} finally { await unmount(); }
|
||||
});
|
||||
|
||||
test('privacy apply disables draft controls until shared validation settles', async () => {
|
||||
let resolveConfig!: (result: unknown) => void;
|
||||
const env = installEnvironment(() => new Promise((resolve) => { resolveConfig = resolve; }));
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
test('busy Apply blocks duplicate submits, edits and Escape', async () => {
|
||||
let resolve!: (value: unknown) => void;
|
||||
let calls = 0;
|
||||
const unmount = await mount(() => { calls += 1; return new Promise((done) => { resolve = done; }); });
|
||||
try {
|
||||
await act(async () => { renderer = create(view()); });
|
||||
const root = renderer!.root;
|
||||
await act(async () => {
|
||||
root.findAllByType('button').find((node) => node.props['aria-label'] === 'Enable text redaction')!.props.onClick();
|
||||
});
|
||||
await act(async () => {
|
||||
root.findAllByType('button').find((node) => node.props.children === 'Apply')!.props.onClick();
|
||||
});
|
||||
assert.ok(root.findAllByType('button').every((node) => node.props.disabled));
|
||||
assert.ok(root.findAllByType('textarea').every((node) => node.props.disabled));
|
||||
assert.equal(env.values.get(STORAGE_KEY_AI_PRIVACY), undefined);
|
||||
await act(async () => { resolveConfig({ ok: true }); });
|
||||
assert.ok(root.findAllByType('button').every((node) => !node.props.disabled));
|
||||
assert.equal(JSON.parse(env.values.get(STORAGE_KEY_AI_PRIVACY)!).enabled, true);
|
||||
} finally {
|
||||
if (renderer) await act(async () => { renderer!.unmount(); });
|
||||
env.restore();
|
||||
}
|
||||
await click('Configure privacy');
|
||||
await input(0, '192.0.2.0/24');
|
||||
await click('Apply');
|
||||
assert.ok([...document.querySelectorAll('[role="dialog"] textarea, [role="dialog"] [role="switch"]')].every((el) => (el as HTMLInputElement).disabled));
|
||||
assert.equal(button('Cancel').disabled, true);
|
||||
await act(async () => { button('Applying...').click(); document.dispatchEvent(new dom.window.KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); });
|
||||
assert.ok(document.querySelector('[role="dialog"]'));
|
||||
assert.equal(calls, 1);
|
||||
await act(async () => { resolve({ ok: true }); });
|
||||
assert.equal(document.querySelector('[role="dialog"]'), null);
|
||||
} finally { await unmount(); }
|
||||
});
|
||||
|
||||
test('shared privacy controls refresh when another window changes the shared preference', async () => {
|
||||
const env = installEnvironment(async () => ({ ok: true }));
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
test('malformed saved settings remain visible and repairable without being rewritten on open', async () => {
|
||||
const unmount = await mount(undefined, '{');
|
||||
try {
|
||||
await act(async () => { renderer = create(view()); });
|
||||
const settings = { enabled: true, redactPrivateIps: true, cidrs: ['192.0.2.0/24'], patterns: ['INTERNAL_.*'] };
|
||||
env.values.set(STORAGE_KEY_AI_PRIVACY, JSON.stringify(settings));
|
||||
const event = new Event('storage');
|
||||
Object.defineProperty(event, 'key', { value: STORAGE_KEY_AI_PRIVACY });
|
||||
await act(async () => { window.dispatchEvent(event); });
|
||||
assert.equal(renderer!.root.findAllByType('button').filter((node) => node.props['aria-checked'] === true).length, 2);
|
||||
assert.equal(renderer!.root.findAllByType('textarea')[0].props.value, '192.0.2.0/24');
|
||||
assert.equal(renderer!.root.findAllByType('textarea')[1].props.value, 'INTERNAL_.*');
|
||||
} finally {
|
||||
if (renderer) await act(async () => { renderer!.unmount(); });
|
||||
env.restore();
|
||||
}
|
||||
assert.match(document.querySelector('[role="alert"]')!.textContent!, /Invalid saved AI privacy settings/);
|
||||
await click('Configure privacy');
|
||||
assert.equal(button('Apply').disabled, false);
|
||||
assert.equal(dom.window.localStorage.getItem(STORAGE_KEY_AI_PRIVACY), '{');
|
||||
} finally { await unmount(); }
|
||||
});
|
||||
|
||||
test('cross-window changes refresh clean drafts but preserve dirty drafts with a conflict notice', async () => {
|
||||
const unmount = await mount();
|
||||
const settings = { enabled: true, redactPrivateIps: true, cidrs: ['192.0.2.0/24'], patterns: ['INTERNAL_.*'] };
|
||||
const update = async () => {
|
||||
dom.window.localStorage.setItem(STORAGE_KEY_AI_PRIVACY, JSON.stringify(settings));
|
||||
await act(async () => { window.dispatchEvent(new dom.window.StorageEvent('storage', { key: STORAGE_KEY_AI_PRIVACY })); });
|
||||
};
|
||||
try {
|
||||
await click('Configure privacy');
|
||||
await update();
|
||||
assert.equal(document.querySelector('textarea')!.value, '192.0.2.0/24');
|
||||
await input(0, '198.51.100.0/24');
|
||||
settings.cidrs = ['203.0.113.0/24'];
|
||||
await update();
|
||||
assert.equal(document.querySelector('textarea')!.value, '198.51.100.0/24');
|
||||
assert.match(document.querySelector('[role="alert"]')!.textContent!, /another window/);
|
||||
assert.equal(button('Apply').disabled, true);
|
||||
await click('Cancel');
|
||||
await click('Configure privacy');
|
||||
assert.equal(document.querySelector('textarea')!.value, '203.0.113.0/24');
|
||||
} finally { await unmount(); }
|
||||
});
|
||||
@@ -1,7 +1,8 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { useI18n } from "../../../../application/i18n/I18nProvider";
|
||||
import { useAIPrivacyState } from "../../../../application/state/useExternalMcpToggleState";
|
||||
import { Button } from "../../../ui/button";
|
||||
import { ToolSettingsDialog } from "./ToolSettingsDialog";
|
||||
import { SettingCard, SettingRow, SettingsSection, Toggle } from "../../settings-ui";
|
||||
|
||||
export const AIPrivacySection: React.FC = () => {
|
||||
@@ -12,8 +13,32 @@ export const AIPrivacySection: React.FC = () => {
|
||||
const [cidrs, setCidrs] = useState(settings.cidrs.join("\n"));
|
||||
const [patterns, setPatterns] = useState(settings.patterns.join("\n"));
|
||||
const [applied, setApplied] = useState(false);
|
||||
const [open, setOpen] = useState(false);
|
||||
const errorRef = useRef<HTMLParagraphElement>(null);
|
||||
useEffect(() => { if (open && error) errorRef.current?.focus(); }, [open, error]);
|
||||
const [draftBase, setDraftBase] = useState(settings);
|
||||
const [conflict, setConflict] = useState(false);
|
||||
const dirty = enabled !== draftBase.enabled || privateIps !== draftBase.redactPrivateIps
|
||||
|| cidrs !== draftBase.cidrs.join("\n") || patterns !== draftBase.patterns.join("\n");
|
||||
const changeOpen = (next: boolean) => {
|
||||
if (applying) return;
|
||||
setDraftBase(settings);
|
||||
setEnabled(settings.enabled);
|
||||
setPrivateIps(settings.redactPrivateIps);
|
||||
setCidrs(settings.cidrs.join("\n"));
|
||||
setPatterns(settings.patterns.join("\n"));
|
||||
setConflict(false);
|
||||
setOpen(next);
|
||||
};
|
||||
|
||||
const draftState = useRef({ open, dirty, applying });
|
||||
draftState.current = { open, dirty, applying };
|
||||
useEffect(() => {
|
||||
if (draftState.current.open && draftState.current.dirty && !draftState.current.applying) {
|
||||
setConflict(true);
|
||||
return;
|
||||
}
|
||||
setDraftBase(settings);
|
||||
setEnabled(settings.enabled);
|
||||
setPrivateIps(settings.redactPrivateIps);
|
||||
setCidrs(settings.cidrs.join("\n"));
|
||||
@@ -21,6 +46,7 @@ export const AIPrivacySection: React.FC = () => {
|
||||
}, [settings]);
|
||||
|
||||
const onApply = async () => {
|
||||
if (applying || conflict) return;
|
||||
setApplied(false);
|
||||
const saved = await apply({
|
||||
enabled,
|
||||
@@ -29,13 +55,36 @@ export const AIPrivacySection: React.FC = () => {
|
||||
patterns: patterns.split(/\r?\n/).filter((line) => line.trim().length > 0),
|
||||
});
|
||||
setApplied(saved);
|
||||
if (saved) setOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsSection anchorId="ai-privacy" title={t("ai.privacy.title")}>
|
||||
<p className="text-xs text-muted-foreground leading-5">{t("ai.privacy.description")}</p>
|
||||
<p className="text-xs text-muted-foreground leading-5">{t("ai.privacy.boundary")}</p>
|
||||
<SettingCard divided className="rounded-md border-border/60 bg-background/70">
|
||||
<SettingCard>
|
||||
<SettingRow
|
||||
className="flex-col items-start md:flex-row md:items-center"
|
||||
label={t(settings.enabled ? "ai.toolSettings.privacyOn" : "ai.toolSettings.privacyOff")}
|
||||
description={`${t("ai.privacy.privateIps")}: ${t(settings.redactPrivateIps ? "common.enabled" : "common.disabled")} · ${t("ai.toolSettings.ruleSummary", { cidrs: String(settings.cidrs.length), patterns: String(settings.patterns.length) })}`}
|
||||
>
|
||||
<ToolSettingsDialog
|
||||
title={t("ai.privacy.title")}
|
||||
triggerLabel={t("ai.toolSettings.configurePrivacy")}
|
||||
open={open}
|
||||
onOpenChange={changeOpen}
|
||||
busy={applying}
|
||||
footer={<>
|
||||
{dirty ? <span role="status" className="mr-auto self-center text-xs text-muted-foreground">{t("ai.toolSettings.unsaved")}</span> : null}
|
||||
<Button type="button" variant="outline" onClick={() => changeOpen(false)} disabled={applying}>{t("common.cancel")}</Button>
|
||||
<Button type="button" onClick={() => { void onApply(); }} disabled={applying || conflict || (!dirty && !error)}>
|
||||
{t(applying ? "ai.privacy.applying" : "ai.privacy.apply")}
|
||||
</Button>
|
||||
</>}
|
||||
>
|
||||
<p className="text-xs text-muted-foreground leading-5">{t("ai.privacy.description")}</p>
|
||||
<p className="text-xs text-muted-foreground leading-5">{t("ai.privacy.boundary")}</p>
|
||||
<SettingCard divided className="border-0 rounded-none bg-transparent p-0">
|
||||
<SettingRow label={t("ai.privacy.enable")}>
|
||||
<Toggle
|
||||
checked={enabled}
|
||||
@@ -56,9 +105,9 @@ export const AIPrivacySection: React.FC = () => {
|
||||
/>
|
||||
</SettingRow>
|
||||
</SettingCard>
|
||||
<div className="space-y-3 rounded-md border border-border/60 bg-background/50 p-3">
|
||||
<div className="space-y-4">
|
||||
<label className="block space-y-1.5">
|
||||
<span className="text-xs font-medium">{t("ai.privacy.cidrs")}</span>
|
||||
<span className="text-sm font-medium">{t("ai.privacy.cidrs")}</span>
|
||||
<span className="block text-xs text-muted-foreground">{t("ai.privacy.cidrs.description")}</span>
|
||||
<textarea
|
||||
value={cidrs}
|
||||
@@ -72,7 +121,7 @@ export const AIPrivacySection: React.FC = () => {
|
||||
/>
|
||||
</label>
|
||||
<label className="block space-y-1.5">
|
||||
<span className="text-xs font-medium">{t("ai.privacy.patterns")}</span>
|
||||
<span className="text-sm font-medium">{t("ai.privacy.patterns")}</span>
|
||||
<span className="block text-xs text-muted-foreground">{t("ai.privacy.patterns.description")}</span>
|
||||
<textarea
|
||||
value={patterns}
|
||||
@@ -86,20 +135,15 @@ export const AIPrivacySection: React.FC = () => {
|
||||
/>
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground leading-5">{t("ai.privacy.connectionScope")}</p>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="text-xs text-amber-500">{t("ai.privacy.reconnect")}</p>
|
||||
<Button size="sm" variant="outline" onClick={() => { void onApply(); }} disabled={applying}>
|
||||
{t(applying ? "ai.privacy.applying" : "ai.privacy.apply")}
|
||||
</Button>
|
||||
</div>
|
||||
{error ? (
|
||||
<p role="alert" className="text-xs text-destructive">
|
||||
{t("ai.privacy.failed")} {error}
|
||||
</p>
|
||||
) : applied ? (
|
||||
<p role="status" className="text-xs text-emerald-500">{t("ai.privacy.applied")}</p>
|
||||
) : null}
|
||||
<p className="text-xs text-amber-500">{t("ai.privacy.reconnect")}</p>
|
||||
</div>
|
||||
{conflict ? <p role="alert" className="text-xs text-destructive">{t("ai.toolSettings.conflict")}</p> : null}
|
||||
{error ? <p ref={errorRef} tabIndex={-1} role="alert" className="text-xs text-destructive">{t("ai.privacy.failed")} {error}</p> : null}
|
||||
</ToolSettingsDialog>
|
||||
</SettingRow>
|
||||
</SettingCard>
|
||||
{error && !open ? <p role="alert" className="text-xs text-destructive">{t("ai.privacy.failed")} {error}</p>
|
||||
: applied ? <p role="status" className="text-xs text-emerald-500">{t("ai.privacy.applied")}</p> : null}
|
||||
</SettingsSection>
|
||||
);
|
||||
};
|
||||
@@ -1,10 +1,12 @@
|
||||
import React from "react";
|
||||
import { Check, Copy } from "lucide-react";
|
||||
import { Check, Copy, Eye, EyeOff } from "lucide-react";
|
||||
import { cn } from "../../../../lib/utils";
|
||||
import { useI18n } from "../../../../application/i18n/I18nProvider";
|
||||
import { Button } from "../../../ui/button";
|
||||
|
||||
type CopyableCodeBlockProps = {
|
||||
label?: string;
|
||||
sensitive?: boolean;
|
||||
value: string;
|
||||
copyKey: string;
|
||||
copied: string | null;
|
||||
@@ -17,6 +19,7 @@ type CopyableCodeBlockProps = {
|
||||
|
||||
export const CopyableCodeBlock: React.FC<CopyableCodeBlockProps> = ({
|
||||
label,
|
||||
sensitive = false,
|
||||
value,
|
||||
copyKey,
|
||||
copied,
|
||||
@@ -26,7 +29,9 @@ export const CopyableCodeBlock: React.FC<CopyableCodeBlockProps> = ({
|
||||
emptyLabel,
|
||||
className,
|
||||
}) => {
|
||||
const display = value || emptyLabel || "";
|
||||
const { t } = useI18n();
|
||||
const [revealed, setRevealed] = React.useState(false);
|
||||
const display = sensitive && value && !revealed ? "••••••••" : value || emptyLabel || "";
|
||||
const canCopy = Boolean(value);
|
||||
const isCopied = copied === copyKey;
|
||||
|
||||
@@ -38,12 +43,15 @@ export const CopyableCodeBlock: React.FC<CopyableCodeBlockProps> = ({
|
||||
<div className="group relative rounded-md border border-border/60 bg-muted/20">
|
||||
<pre
|
||||
className={cn(
|
||||
"max-h-40 overflow-auto whitespace-pre-wrap break-all px-3 py-2.5 pr-11 font-mono text-xs leading-5",
|
||||
"max-h-40 overflow-auto whitespace-pre-wrap break-all px-3 py-2.5 pr-20 font-mono text-xs leading-5",
|
||||
!value && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{display}
|
||||
</pre>
|
||||
{sensitive && value ? <Button type="button" variant="ghost" size="sm" className="absolute right-10 top-1.5 h-7 w-7 p-0" onClick={() => setRevealed(!revealed)} aria-label={t(revealed ? "ai.toolSettings.hideSecret" : "ai.toolSettings.showSecret")}>
|
||||
{revealed ? <EyeOff size={14} /> : <Eye size={14} />}
|
||||
</Button> : null}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import React, { act } from 'react';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import { installAiToolAccessFixture } from '../../../../scripts/fixtures/ai-tool-access/bridge.cjs';
|
||||
|
||||
const dom = new JSDOM('<div id="root"></div>', { url: 'http://localhost/#/settings' });
|
||||
for (const key of ['window', 'document', 'location', 'navigator', 'HTMLElement', 'HTMLInputElement', 'Element', 'Node', 'NodeFilter', 'MutationObserver', 'CustomEvent', 'Event', 'KeyboardEvent', 'MouseEvent', 'FocusEvent', 'getComputedStyle', 'localStorage'] as const) {
|
||||
Object.defineProperty(globalThis, key, { value: dom.window[key], configurable: true });
|
||||
}
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true, requestAnimationFrame: (fn: () => void) => setTimeout(fn, 0), cancelAnimationFrame: clearTimeout });
|
||||
const { createRoot } = await import('react-dom/client');
|
||||
const { I18nProvider } = await import('../../../../application/i18n/I18nProvider');
|
||||
const { TooltipProvider } = await import('../../../ui/tooltip');
|
||||
const { ExternalMcpCard } = await import('./ExternalMcpCard');
|
||||
|
||||
type FixtureControl = { rejectHttp: boolean; throwHttp: boolean; holdHttp: boolean; pendingHttp: (() => void)[]; pendingClients: (() => void)[]; rejectRotate: boolean; throwRotate: boolean; calls: { name: string; args: unknown[] }[] };
|
||||
async function mount(options: { enabled?: boolean; holdClients?: boolean } = {}, openHttp = true) {
|
||||
installAiToolAccessFixture({ locale: 'en', enabled: true, ...options });
|
||||
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText: async () => {} } });
|
||||
const qa = (window as unknown as { __aiToolQa: FixtureControl }).__aiToolQa;
|
||||
const root = createRoot(document.getElementById('root')!);
|
||||
await act(async () => root.render(<I18nProvider locale="en"><TooltipProvider><ExternalMcpCard /></TooltipProvider></I18nProvider>));
|
||||
if (openHttp) await click('Configure HTTP');
|
||||
return { qa, close: async () => { await act(async () => root.unmount()); } };
|
||||
}
|
||||
const dialog = () => document.querySelector<HTMLElement>('[role="dialog"]')!;
|
||||
const button = (text: string, scope: ParentNode = document) => [...scope.querySelectorAll<HTMLButtonElement>('button')].find((el) => el.textContent === text)!;
|
||||
const click = async (text: string) => { await act(async () => button(text).click()); };
|
||||
async function changePort(value: string) {
|
||||
const port = dialog().querySelector<HTMLInputElement>('[aria-label="Port"]')!;
|
||||
await act(async () => {
|
||||
port.focus();
|
||||
Object.getOwnPropertyDescriptor(dom.window.HTMLInputElement.prototype, 'value')!.set!.call(port, value);
|
||||
port.dispatchEvent(new dom.window.Event('input', { bubbles: true }));
|
||||
});
|
||||
await act(async () => port.blur());
|
||||
return port;
|
||||
}
|
||||
|
||||
for (const failure of ['returned', 'thrown'] as const) {
|
||||
test(`HTTP ${failure} configuration failure is visible inside the dialog, preserves input and can retry`, async () => {
|
||||
const h = await mount();
|
||||
try {
|
||||
h.qa.rejectHttp = failure === 'returned';
|
||||
h.qa.throwHttp = failure === 'thrown';
|
||||
const port = await changePort('47401');
|
||||
const message = failure === 'returned' ? 'Fixture: port unavailable.' : 'Fixture: HTTP configuration threw.';
|
||||
const alert = dialog().querySelector('[role="alert"]')!;
|
||||
assert.match(alert.textContent!, new RegExp(message.replaceAll('.', '\\.')));
|
||||
assert.equal(document.activeElement, alert, 'the error must be reachable without scrolling back manually');
|
||||
assert.equal(port.value, '47401');
|
||||
assert.ok(document.querySelector('[role="dialog"]'));
|
||||
assert.match(document.querySelector('[data-settings-anchor="ai-external-mcp-http"]')!.textContent!, /Fixture:/);
|
||||
const beforeRetry = h.qa.calls.filter((call) => call.name === 'config').length;
|
||||
h.qa.rejectHttp = false;
|
||||
h.qa.throwHttp = false;
|
||||
await click('Retry');
|
||||
assert.equal(h.qa.calls.filter((call) => call.name === 'config').length, beforeRetry + 1);
|
||||
assert.equal(dialog().querySelector('[role="alert"]'), null);
|
||||
assert.equal(port.value, '47401');
|
||||
} finally { await h.close(); }
|
||||
});
|
||||
}
|
||||
|
||||
for (const failure of ['returned', 'thrown'] as const) {
|
||||
test(`HTTP ${failure} token rotation failure stays in the active dialog and retries explicitly`, async () => {
|
||||
const h = await mount();
|
||||
try {
|
||||
await act(async () => (dialog().querySelector('[aria-label="Enable HTTP"]') as HTMLButtonElement).click());
|
||||
await act(async () => dialog().querySelector('summary')!.click());
|
||||
h.qa.rejectRotate = failure === 'returned';
|
||||
h.qa.throwRotate = failure === 'thrown';
|
||||
await click('Regenerate');
|
||||
assert.match(dialog().querySelector('[role="alert"]')!.textContent!, /Fixture: token rotation/);
|
||||
assert.equal(dialog().textContent!.includes('SYNTHETIC_FIXTURE_TOKEN_NEVER_REAL'), false);
|
||||
h.qa.rejectRotate = false;
|
||||
h.qa.throwRotate = false;
|
||||
await click('Retry');
|
||||
assert.equal(dialog().querySelector('[role="alert"]'), null);
|
||||
assert.equal(dialog().textContent!.includes('SYNTHETIC_ROTATED_FIXTURE_TOKEN'), false);
|
||||
} finally { await h.close(); }
|
||||
});
|
||||
}
|
||||
|
||||
test('HTTP clipboard failure is visible in the dialog and clears only after another copy attempt', async () => {
|
||||
const h = await mount();
|
||||
try {
|
||||
await act(async () => dialog().querySelector('summary')!.click());
|
||||
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText: async () => { throw new Error('Fixture clipboard failure'); } } });
|
||||
const copy = () => dialog().querySelector<HTMLButtonElement>('[aria-label="Copy"]:not([disabled])')!;
|
||||
await act(async () => copy().click());
|
||||
assert.match(dialog().querySelector('[role="alert"]')!.textContent!, /copy/i);
|
||||
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText: async () => {} } });
|
||||
await act(async () => copy().click());
|
||||
assert.equal(dialog().querySelector('[role="alert"]'), null);
|
||||
} finally { await h.close(); }
|
||||
});
|
||||
|
||||
for (const failure of ['returned', 'thrown'] as const) {
|
||||
test(`a completed runtime poll cannot suppress a pending HTTP ${failure} failure`, async () => {
|
||||
const h = await mount();
|
||||
try {
|
||||
h.qa.holdHttp = true;
|
||||
h.qa.rejectHttp = failure === 'returned';
|
||||
h.qa.throwHttp = failure === 'thrown';
|
||||
const beforePoll = h.qa.calls.filter((call) => call.name === 'status').length;
|
||||
const port = await changePort('47402');
|
||||
assert.match(dialog().querySelector('[role="status"]')!.textContent!, /Applying HTTP/);
|
||||
await act(async () => { await new Promise((resolve) => setTimeout(resolve, 3200)); });
|
||||
assert.ok(h.qa.calls.filter((call) => call.name === 'status').length > beforePoll);
|
||||
await act(async () => h.qa.pendingHttp.splice(0).forEach((resolve) => resolve()));
|
||||
assert.match(dialog().querySelector('[role="alert"]')!.textContent!, /Fixture:/);
|
||||
assert.equal(port.value, '47402');
|
||||
assert.equal(dialog().querySelector('[role="status"]'), null);
|
||||
} finally { await h.close(); }
|
||||
});
|
||||
}
|
||||
|
||||
test('completed MCP enable can be turned off while unrelated client discovery is still pending', async () => {
|
||||
const h = await mount({ enabled: false, holdClients: true }, false);
|
||||
try {
|
||||
const toggle = document.querySelector<HTMLButtonElement>('[role="switch"][aria-label="External MCP"]')!;
|
||||
await act(async () => toggle.click());
|
||||
assert.equal(toggle.getAttribute('aria-checked'), 'true');
|
||||
assert.ok(h.qa.pendingClients.length > 0, 'client discovery remains intentionally unresolved');
|
||||
assert.equal(toggle.disabled, false, 'successful enable must release the switch before client discovery finishes');
|
||||
await act(async () => toggle.click());
|
||||
assert.equal(toggle.getAttribute('aria-checked'), 'false');
|
||||
assert.equal(toggle.disabled, false);
|
||||
assert.deepEqual(h.qa.calls.filter((call) => call.name === 'enable').map((call) => call.args[0]), [true, false]);
|
||||
} finally { await h.close(); }
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { HelpCircle, RefreshCw } from "lucide-react";
|
||||
import { useI18n } from "../../../../application/i18n/I18nProvider";
|
||||
import {
|
||||
@@ -31,8 +31,13 @@ import {
|
||||
} from "../../../../application/state/useExternalMcpToggleState";
|
||||
import { cn } from "../../../../lib/utils";
|
||||
import { Button } from "../../../ui/button";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../../../ui/tabs";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "../../../ui/tooltip";
|
||||
import { Select, SettingCard, SettingRow, Toggle } from "../../../settings/settings-ui";
|
||||
import { Select, SettingCard, SettingRow, SettingsSection, Toggle } from "../../../settings/settings-ui";
|
||||
import { ToolStatusPill, type ToolStatusTone } from "./ToolStatusPill";
|
||||
import { ToolSettingsDialog } from "./ToolSettingsDialog";
|
||||
import { useOptionalSettingsFocus } from "../../SettingsFocusContext";
|
||||
import { SettingsAnchor } from "../../settings-ui";
|
||||
import { CopyableCodeBlock } from "./CopyableCodeBlock";
|
||||
import { ExternalMcpHttpSection, type ExternalMcpHttpViewStatus } from "./ExternalMcpHttpSection";
|
||||
import { getBridge } from "./types";
|
||||
@@ -72,23 +77,13 @@ type StatusView = {
|
||||
className: string;
|
||||
};
|
||||
|
||||
function getBridgeStatusView(status: ExternalMcpStatus | null, enabled: boolean): StatusView {
|
||||
if (!enabled) {
|
||||
return { labelKey: "ai.externalMcp.status.disabled", className: "text-muted-foreground" };
|
||||
}
|
||||
if (!status || !status.ok) {
|
||||
return { labelKey: "ai.externalMcp.status.unavailable", className: "text-amber-500" };
|
||||
}
|
||||
if (status.state === "running") {
|
||||
return { labelKey: "ai.externalMcp.status.running", className: "text-emerald-500" };
|
||||
}
|
||||
if (status.state === "starting") {
|
||||
return { labelKey: "ai.externalMcp.status.starting", className: "text-amber-500" };
|
||||
}
|
||||
if (status.state === "error") {
|
||||
return { labelKey: "ai.externalMcp.status.error", className: "text-destructive" };
|
||||
}
|
||||
return { labelKey: "ai.externalMcp.status.disabled", className: "text-muted-foreground" };
|
||||
function getBridgeStatusView(status: ExternalMcpStatus | null, enabled: boolean): { labelKey: string; tone: ToolStatusTone } {
|
||||
if (!enabled) return { labelKey: "ai.externalMcp.status.disabled", tone: "inactive" };
|
||||
if (!status || !status.ok) return { labelKey: "ai.externalMcp.status.unavailable", tone: status?.error ? "error" : "inactive" };
|
||||
if (status.state === "running") return { labelKey: "ai.externalMcp.status.running", tone: "active" };
|
||||
if (status.state === "starting") return { labelKey: "ai.externalMcp.status.starting", tone: "inactive" };
|
||||
if (status.state === "error") return { labelKey: "ai.externalMcp.status.error", tone: "error" };
|
||||
return { labelKey: "ai.externalMcp.status.disabled", tone: "inactive" };
|
||||
}
|
||||
|
||||
/** Map bridge permissionMode to a Safety i18n key for display. */
|
||||
@@ -252,6 +247,15 @@ export function buildCursorSnippet(launcherPath: string, discoveryPath?: string
|
||||
|
||||
export const ExternalMcpCard: React.FC = () => {
|
||||
const { t } = useI18n();
|
||||
const [behaviorOpen, setBehaviorOpen] = useState(false);
|
||||
const focus = useOptionalSettingsFocus();
|
||||
useEffect(() => {
|
||||
if (focus?.request?.anchorId === "ai-external-mcp-approval-alert") setBehaviorOpen(true);
|
||||
}, [focus?.request]);
|
||||
const statusGeneration = useRef(0);
|
||||
const clientStatusGeneration = useRef(0);
|
||||
const httpOperationGeneration = useRef(0);
|
||||
useEffect(() => () => { statusGeneration.current += 1; clientStatusGeneration.current += 1; httpOperationGeneration.current += 1; }, []);
|
||||
const { enabled, setEnabled, error: configError } = useExternalMcpToggleState();
|
||||
const [mode, setModeRaw] = useState<ExternalMcpMode>(() => readExternalMcpMode());
|
||||
const [idleTimeoutMinutes, setIdleTimeoutRaw] = useState<number>(() => readExternalMcpIdleTimeoutMinutes());
|
||||
@@ -268,8 +272,11 @@ export const ExternalMcpCard: React.FC = () => {
|
||||
listenAddress: readExternalMcpHttpListenAddress(),
|
||||
clientHost: readExternalMcpHttpClientHost(),
|
||||
}));
|
||||
const [isApplyingHttp, setIsApplyingHttp] = useState(false);
|
||||
const [isToggling, setIsToggling] = useState(false);
|
||||
const [isRotatingHttpToken, setIsRotatingHttpToken] = useState(false);
|
||||
const [status, setStatus] = useState<ExternalMcpStatus | null>(null);
|
||||
const [manualFormat, setManualFormat] = useState("command");
|
||||
const [selectedClient, setSelectedClient] = useState<ExternalMcpClient>("codex");
|
||||
const [codexStatus, setCodexStatus] = useState<ClientSetupStatus | null>(null);
|
||||
const [claudeStatus, setClaudeStatus] = useState<ClientSetupStatus | null>(null);
|
||||
@@ -280,6 +287,8 @@ export const ExternalMcpCard: React.FC = () => {
|
||||
const [isAddingClaude, setIsAddingClaude] = useState(false);
|
||||
const [isAddingGrok, setIsAddingGrok] = useState(false);
|
||||
const [copied, setCopied] = useState<string | null>(null);
|
||||
const [httpCopyError, setHttpCopyError] = useState<string | null>(null);
|
||||
const [httpActionError, setHttpActionError] = useState<{ kind: "config" | "rotate"; message: string } | null>(null);
|
||||
const [actionMessage, setActionMessage] = useState<{ tone: "error" | "warning" | "success"; text: string } | null>(null);
|
||||
const bridgeUnavailableMessage = t("ai.externalMcp.bridgeUnavailable");
|
||||
|
||||
@@ -324,6 +333,10 @@ export const ExternalMcpCard: React.FC = () => {
|
||||
}, []);
|
||||
|
||||
const applyHttpSettings = useCallback(async (next: ExternalMcpHttpSettings) => {
|
||||
const operation = ++httpOperationGeneration.current;
|
||||
const generation = ++statusGeneration.current;
|
||||
setHttpActionError(null);
|
||||
setIsApplyingHttp(true);
|
||||
const normalized: ExternalMcpHttpSettings = {
|
||||
enabled: writeExternalMcpHttpEnabled(next.enabled),
|
||||
port: writeExternalMcpHttpPort(next.port),
|
||||
@@ -332,36 +345,56 @@ export const ExternalMcpCard: React.FC = () => {
|
||||
clientHost: writeExternalMcpHttpClientHost(next.clientHost),
|
||||
};
|
||||
setHttpSettings(normalized);
|
||||
const nextStatus = await getBridge()?.externalMcpSetConfig?.({
|
||||
httpEnabled: normalized.enabled,
|
||||
httpPort: normalized.port,
|
||||
httpAllowCustomBind: normalized.allowCustomBind,
|
||||
httpListenAddress: normalized.listenAddress,
|
||||
httpClientHost: normalized.clientHost,
|
||||
});
|
||||
if (nextStatus && typeof nextStatus === "object") {
|
||||
setStatus(nextStatus as ExternalMcpStatus);
|
||||
try {
|
||||
const nextStatus = await getBridge()?.externalMcpSetConfig?.({
|
||||
httpEnabled: normalized.enabled,
|
||||
httpPort: normalized.port,
|
||||
httpAllowCustomBind: normalized.allowCustomBind,
|
||||
httpListenAddress: normalized.listenAddress,
|
||||
httpClientHost: normalized.clientHost,
|
||||
}) as ExternalMcpStatus | undefined;
|
||||
if (operation !== httpOperationGeneration.current) return;
|
||||
if (!nextStatus?.ok || nextStatus.error || nextStatus.http?.error) {
|
||||
setHttpActionError({ kind: "config", message: nextStatus?.error || nextStatus?.http?.error || bridgeUnavailableMessage });
|
||||
return;
|
||||
}
|
||||
if (generation === statusGeneration.current) setStatus(nextStatus);
|
||||
} catch (error) {
|
||||
if (operation === httpOperationGeneration.current) {
|
||||
setHttpActionError({ kind: "config", message: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
} finally {
|
||||
if (operation === httpOperationGeneration.current) setIsApplyingHttp(false);
|
||||
}
|
||||
}, []);
|
||||
}, [bridgeUnavailableMessage]);
|
||||
|
||||
const rotateHttpToken = useCallback(async () => {
|
||||
const bridge = getBridge();
|
||||
if (!bridge?.externalMcpSetConfig) return;
|
||||
const operation = ++httpOperationGeneration.current;
|
||||
const generation = ++statusGeneration.current;
|
||||
setIsRotatingHttpToken(true);
|
||||
setActionMessage(null);
|
||||
setHttpActionError(null);
|
||||
try {
|
||||
const nextStatus = await bridge.externalMcpSetConfig({ rotateHttpToken: true });
|
||||
if (nextStatus && typeof nextStatus === "object") {
|
||||
setStatus(nextStatus as ExternalMcpStatus);
|
||||
const nextStatus = await getBridge()?.externalMcpSetConfig?.({ rotateHttpToken: true }) as ExternalMcpStatus | undefined;
|
||||
if (operation !== httpOperationGeneration.current) return;
|
||||
if (!nextStatus?.ok || nextStatus.error || nextStatus.http?.error) {
|
||||
setHttpActionError({ kind: "rotate", message: nextStatus?.error || nextStatus?.http?.error || bridgeUnavailableMessage });
|
||||
return;
|
||||
}
|
||||
if (generation === statusGeneration.current) setStatus(nextStatus);
|
||||
} catch (error) {
|
||||
if (operation === httpOperationGeneration.current) {
|
||||
setHttpActionError({ kind: "rotate", message: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
} finally {
|
||||
setIsRotatingHttpToken(false);
|
||||
}
|
||||
}, []);
|
||||
}, [bridgeUnavailableMessage]);
|
||||
|
||||
const refreshStatus = useCallback(async (options?: { quiet?: boolean; clients?: boolean }) => {
|
||||
const generation = ++statusGeneration.current;
|
||||
const bridge = getBridge();
|
||||
const includeClients = options?.clients !== false;
|
||||
const clientGeneration = includeClients ? ++clientStatusGeneration.current : clientStatusGeneration.current;
|
||||
if (
|
||||
!bridge?.externalMcpGetStatus
|
||||
|| !bridge?.externalMcpCodexGetStatus
|
||||
@@ -411,10 +444,15 @@ export const ExternalMcpCard: React.FC = () => {
|
||||
bridge.externalMcpGetUniversalSetupPrompt?.()
|
||||
?? Promise.resolve({ ok: false, prompt: "" }),
|
||||
]);
|
||||
setStatus(nextStatus as ExternalMcpStatus);
|
||||
if (enabled && nextStatus?.ok && !nextStatus.enabled) {
|
||||
setEnabled(false);
|
||||
// Runtime polling and CLI/client discovery have separate lifetimes.
|
||||
// A fast quiet poll must not discard a slow, still-current client batch.
|
||||
if (generation === statusGeneration.current) {
|
||||
setStatus(nextStatus as ExternalMcpStatus);
|
||||
if (enabled && nextStatus?.ok && !nextStatus.enabled) {
|
||||
setEnabled(false);
|
||||
}
|
||||
}
|
||||
if (clientGeneration !== clientStatusGeneration.current) return;
|
||||
setCodexStatus(nextCodexStatus as ClientSetupStatus);
|
||||
setClaudeStatus(nextClaudeStatus as ClientSetupStatus);
|
||||
setGrokStatus(nextGrokStatus as ClientSetupStatus);
|
||||
@@ -425,13 +463,14 @@ export const ExternalMcpCard: React.FC = () => {
|
||||
);
|
||||
} else {
|
||||
const nextStatus = await bridge.externalMcpGetStatus();
|
||||
if (generation !== statusGeneration.current) return;
|
||||
setStatus(nextStatus as ExternalMcpStatus);
|
||||
if (enabled && nextStatus?.ok && !nextStatus.enabled) {
|
||||
setEnabled(false);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (!options?.quiet) setIsRefreshing(false);
|
||||
if (!options?.quiet && clientGeneration === clientStatusGeneration.current) setIsRefreshing(false);
|
||||
}
|
||||
}, [bridgeUnavailableMessage, enabled, setEnabled]);
|
||||
|
||||
@@ -481,8 +520,10 @@ export const ExternalMcpCard: React.FC = () => {
|
||||
const canAddToClaude = claudeStatus?.state === "not_configured";
|
||||
const canAddToGrok = grokStatus?.state === "not_configured";
|
||||
|
||||
const copyText = useCallback(async (key: string, text: string) => {
|
||||
const copyText = useCallback(async (key: string, text: string, scope: "client" | "http" = "client") => {
|
||||
if (!text) return;
|
||||
setCopied((current) => current === key ? null : current);
|
||||
if (scope === "http") setHttpCopyError(null);
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
setCopied(key);
|
||||
@@ -490,7 +531,8 @@ export const ExternalMcpCard: React.FC = () => {
|
||||
setCopied((current) => (current === key ? null : current));
|
||||
}, 1200);
|
||||
} catch {
|
||||
setActionMessage({ tone: "error", text: t("ai.externalMcp.copyFailed") });
|
||||
if (scope === "http") setHttpCopyError(t("ai.externalMcp.copyFailed"));
|
||||
else setActionMessage({ tone: "error", text: t("ai.externalMcp.copyFailed") });
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
@@ -634,8 +676,13 @@ export const ExternalMcpCard: React.FC = () => {
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border bg-card p-4 space-y-3">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<SettingsSection
|
||||
anchorId="ai-external-mcp"
|
||||
title={t("ai.externalMcp.title")}
|
||||
actions={<ToolStatusPill tone={bridgeStatusView.tone}>{t(bridgeStatusView.labelKey)}</ToolStatusPill>}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="min-w-0 flex items-start gap-1.5">
|
||||
<p className="min-w-0 text-xs text-muted-foreground leading-5">
|
||||
{t("ai.externalMcp.description")}
|
||||
@@ -665,12 +712,10 @@ export const ExternalMcpCard: React.FC = () => {
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className={cn("text-xs font-medium shrink-0", bridgeStatusView.className)}>
|
||||
{t(bridgeStatusView.labelKey)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-4 rounded-md border border-border/60 bg-background/70 px-3 py-2">
|
||||
<SettingCard divided>
|
||||
<div className="flex items-center justify-between gap-4 py-3">
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium">{t("ai.externalMcp.title")}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
@@ -679,17 +724,21 @@ export const ExternalMcpCard: React.FC = () => {
|
||||
</div>
|
||||
<Toggle
|
||||
checked={enabled}
|
||||
disabled={isToggling}
|
||||
ariaLabel={t("ai.externalMcp.title")}
|
||||
onChange={(nextEnabled) => {
|
||||
statusGeneration.current += 1;
|
||||
setActionMessage(null);
|
||||
void setEnabled(nextEnabled).then(() => refreshStatus());
|
||||
setIsToggling(true);
|
||||
void setEnabled(nextEnabled).then(() => { void refreshStatus(); }).finally(() => setIsToggling(false));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Permission mode is controlled in Safety settings; surface it here so External MCP
|
||||
users see why write tools may still prompt (confirm) or run freely (auto). */}
|
||||
<div className="flex items-start justify-between gap-4 rounded-md border border-border/60 bg-background/70 px-3 py-2">
|
||||
<div className="min-w-0 space-y-1">
|
||||
<div className="flex items-start justify-between gap-4 py-3">
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<div className="text-sm font-medium">{t("ai.externalMcp.permissionMode.label")}</div>
|
||||
<div className="text-xs text-muted-foreground leading-5">
|
||||
{t("ai.externalMcp.permissionMode.hint")}
|
||||
@@ -706,9 +755,11 @@ export const ExternalMcpCard: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SettingCard divided className="rounded-md border-border/60 bg-background/70">
|
||||
<SettingsAnchor anchorId="ai-external-mcp-approval-alert">
|
||||
<SettingRow className="flex-col items-start md:flex-row md:items-center" label={t("ai.toolSettings.behavior")} description={t(mode === "persistent" ? "ai.externalMcp.mode.persistent" : "ai.externalMcp.mode.temporary")}>
|
||||
<ToolSettingsDialog title={t("ai.toolSettings.behavior")} triggerLabel={t("ai.toolSettings.configureBehavior")} immediate open={behaviorOpen} onOpenChange={setBehaviorOpen}>
|
||||
<SettingCard divided className="border-0 bg-transparent p-0">
|
||||
<SettingRow
|
||||
anchorId="ai-external-mcp-approval-alert"
|
||||
label={t("ai.externalMcp.approvalAlert")}
|
||||
description={t("ai.externalMcp.approvalAlert.description")}
|
||||
>
|
||||
@@ -803,6 +854,10 @@ export const ExternalMcpCard: React.FC = () => {
|
||||
<span className="text-xs text-muted-foreground">{t("ai.externalMcp.idleTimeout.minutes")}</span>
|
||||
</div>
|
||||
</SettingRow>
|
||||
</SettingCard>
|
||||
</ToolSettingsDialog>
|
||||
</SettingRow>
|
||||
</SettingsAnchor>
|
||||
</SettingCard>
|
||||
|
||||
<ExternalMcpHttpSection
|
||||
@@ -811,93 +866,36 @@ export const ExternalMcpCard: React.FC = () => {
|
||||
status={status?.http || null}
|
||||
copied={copied}
|
||||
rotating={isRotatingHttpToken}
|
||||
onCopy={copyText}
|
||||
onCopy={(key, text) => { void copyText(key, text, "http"); }}
|
||||
applying={isApplyingHttp}
|
||||
operationError={httpActionError?.message ?? null}
|
||||
copyError={httpCopyError}
|
||||
onRetry={httpActionError ? () => {
|
||||
if (httpActionError.kind === "rotate") void rotateHttpToken();
|
||||
else void applyHttpSettings(httpSettings);
|
||||
} : undefined}
|
||||
onChange={(next) => { void applyHttpSettings(next); }}
|
||||
onRotateToken={() => { void rotateHttpToken(); }}
|
||||
/>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex min-h-8 items-center justify-between gap-2">
|
||||
<div className="text-sm font-semibold text-foreground">{t("ai.externalMcp.discovery")}</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void refreshStatus()}
|
||||
disabled={isRefreshing}
|
||||
>
|
||||
<RefreshCw size={14} className={cn("mr-1.5", isRefreshing && "animate-spin")} />
|
||||
{t("ai.externalMcp.refresh")}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-2.5 rounded-md border border-border/60 bg-background/50 p-3">
|
||||
<CopyableCodeBlock
|
||||
label={t("ai.externalMcp.launcher")}
|
||||
value={launcherPath || ""}
|
||||
copyKey="launcher"
|
||||
copied={copied}
|
||||
onCopy={copyText}
|
||||
copyLabel={t("ai.externalMcp.copy")}
|
||||
copiedLabel={t("ai.externalMcp.copied")}
|
||||
emptyLabel={t("ai.externalMcp.unavailable")}
|
||||
/>
|
||||
<CopyableCodeBlock
|
||||
label={t("ai.externalMcp.discovery")}
|
||||
value={status?.discoveryPath || ""}
|
||||
copyKey="discovery"
|
||||
copied={copied}
|
||||
onCopy={copyText}
|
||||
copyLabel={t("ai.externalMcp.copy")}
|
||||
copiedLabel={t("ai.externalMcp.copied")}
|
||||
emptyLabel={t("ai.externalMcp.unavailable")}
|
||||
/>
|
||||
{!enabled ? (
|
||||
<p className="text-xs text-amber-500">{t("ai.externalMcp.enableForLauncher")}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<SettingCard>
|
||||
<SettingRow className="flex-col items-start md:flex-row md:items-center" label={t("ai.externalMcp.clientConfiguration")} description={t("ai.externalMcp.clientConfiguration.description")}>
|
||||
<ToolSettingsDialog title={t("ai.externalMcp.clientConfiguration")} triggerLabel={t("ai.toolSettings.configureClients")}>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-1">
|
||||
<div className="text-sm font-semibold text-foreground">
|
||||
{t("ai.externalMcp.clientConfiguration")}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground leading-5">
|
||||
{t("ai.externalMcp.clientConfiguration.description")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
role="tablist"
|
||||
aria-label={t("ai.externalMcp.clientConfiguration")}
|
||||
className="grid grid-cols-5 gap-1 rounded-md bg-muted p-1"
|
||||
>
|
||||
{CLIENT_TABS.map((client) => {
|
||||
const active = selectedClient === client;
|
||||
return (
|
||||
<button
|
||||
key={client}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={active}
|
||||
onClick={() => {
|
||||
setSelectedClient(client);
|
||||
setActionMessage(null);
|
||||
}}
|
||||
className={cn(
|
||||
"inline-flex h-8 items-center justify-center rounded-sm px-2 text-xs font-medium transition-colors",
|
||||
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
|
||||
active
|
||||
? "bg-background text-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{t(`ai.externalMcp.client.${client}`)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<Tabs value={selectedClient} onValueChange={(value) => { setSelectedClient(value as ExternalMcpClient); setManualFormat(value === "other" || value === "cursor" ? "snippet" : "command"); setActionMessage(null); }}>
|
||||
<TabsList aria-label={t("ai.externalMcp.clientConfiguration")} className="flex h-auto flex-wrap justify-start">
|
||||
{CLIENT_TABS.map((client) => <TabsTrigger key={client} value={client}>{t(`ai.externalMcp.client.${client}`)}</TabsTrigger>)}
|
||||
</TabsList>
|
||||
<TabsContent value={selectedClient}>
|
||||
|
||||
<div className="space-y-3 rounded-md border border-border/60 bg-background/50 p-3">
|
||||
<div className="space-y-4">
|
||||
{selectedClientMeta.kind === "installable" && selectedClientMeta.statusView ? (
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className={cn("text-xs font-medium", selectedClientMeta.statusView.className)}>
|
||||
@@ -924,57 +922,76 @@ export const ExternalMcpCard: React.FC = () => {
|
||||
</p>
|
||||
)}
|
||||
|
||||
{selectedClientMeta.kind === "installable" ? (
|
||||
<>
|
||||
<CopyableCodeBlock
|
||||
label={t("ai.externalMcp.cliCommand")}
|
||||
value={selectedClientMeta.command}
|
||||
copyKey="command"
|
||||
copied={copied}
|
||||
onCopy={copyText}
|
||||
copyLabel={t("ai.externalMcp.copy")}
|
||||
copiedLabel={t("ai.externalMcp.copied")}
|
||||
emptyLabel={t("ai.externalMcp.unavailable")}
|
||||
/>
|
||||
<CopyableCodeBlock
|
||||
label={t("ai.externalMcp.configSnippet")}
|
||||
value={selectedClientMeta.snippet}
|
||||
copyKey="snippet"
|
||||
copied={copied}
|
||||
onCopy={copyText}
|
||||
copyLabel={t("ai.externalMcp.copy")}
|
||||
copiedLabel={t("ai.externalMcp.copied")}
|
||||
emptyLabel={t("ai.externalMcp.unavailable")}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CopyableCodeBlock
|
||||
label={t("ai.externalMcp.configSnippet")}
|
||||
value={selectedClientMeta.snippet}
|
||||
copyKey="cursor"
|
||||
copied={copied}
|
||||
onCopy={copyText}
|
||||
copyLabel={t("ai.externalMcp.copy")}
|
||||
copiedLabel={t("ai.externalMcp.copied")}
|
||||
emptyLabel={t("ai.externalMcp.unavailable")}
|
||||
/>
|
||||
{selectedClient === "other" ? (
|
||||
<CopyableCodeBlock
|
||||
label={t("ai.externalMcp.universalSetupPrompt")}
|
||||
value={universalSetupPrompt}
|
||||
copyKey="universal-setup-prompt"
|
||||
copied={copied}
|
||||
onCopy={copyText}
|
||||
copyLabel={t("ai.externalMcp.copy")}
|
||||
copiedLabel={t("ai.externalMcp.copied")}
|
||||
emptyLabel={t("ai.externalMcp.unavailable")}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
<details className="space-y-3">
|
||||
<summary className="cursor-pointer text-sm font-medium">{t("ai.toolSettings.manualSetup")}</summary>
|
||||
<label className="flex flex-wrap items-center justify-between gap-2 text-sm">
|
||||
{t("ai.toolSettings.format")}
|
||||
<select aria-label={t("ai.toolSettings.format")} className="h-9 max-w-full rounded-md border border-input bg-background px-3 text-sm" value={manualFormat} onChange={(event) => setManualFormat(event.currentTarget.value)}>
|
||||
{selectedClientMeta.kind === "installable" ? <option value="command">{t("ai.externalMcp.cliCommand")}</option> : null}
|
||||
<option value="snippet">{t("ai.externalMcp.configSnippet")}</option>
|
||||
{selectedClient === "other" ? <option value="prompt">{t("ai.externalMcp.universalSetupPrompt")}</option> : null}
|
||||
</select>
|
||||
</label>
|
||||
<CopyableCodeBlock
|
||||
label={t(manualFormat === "command" ? "ai.externalMcp.cliCommand" : manualFormat === "prompt" ? "ai.externalMcp.universalSetupPrompt" : "ai.externalMcp.configSnippet")}
|
||||
value={manualFormat === "command" ? selectedClientMeta.command : manualFormat === "prompt" ? universalSetupPrompt : selectedClientMeta.snippet}
|
||||
copyKey={manualFormat}
|
||||
copied={copied}
|
||||
onCopy={copyText}
|
||||
copyLabel={t("ai.externalMcp.copy")}
|
||||
copiedLabel={t("ai.externalMcp.copied")}
|
||||
emptyLabel={t("ai.externalMcp.unavailable")}
|
||||
/>
|
||||
</details>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
<details className="space-y-3">
|
||||
<summary className="cursor-pointer text-sm font-medium">{t("ai.toolSettings.connectionDetails")}</summary>
|
||||
<div className="flex min-h-8 items-center justify-between gap-2">
|
||||
<div className="text-sm font-semibold text-foreground">{t("ai.externalMcp.discovery")}</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void refreshStatus()}
|
||||
disabled={isRefreshing}
|
||||
>
|
||||
<RefreshCw size={14} className={cn("mr-1.5", isRefreshing && "animate-spin")} />
|
||||
{t("ai.externalMcp.refresh")}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<CopyableCodeBlock
|
||||
label={t("ai.externalMcp.launcher")}
|
||||
value={launcherPath || ""}
|
||||
copyKey="launcher"
|
||||
copied={copied}
|
||||
onCopy={copyText}
|
||||
copyLabel={t("ai.externalMcp.copy")}
|
||||
copiedLabel={t("ai.externalMcp.copied")}
|
||||
emptyLabel={t("ai.externalMcp.unavailable")}
|
||||
/>
|
||||
<CopyableCodeBlock
|
||||
label={t("ai.externalMcp.discovery")}
|
||||
value={status?.discoveryPath || ""}
|
||||
copyKey="discovery"
|
||||
copied={copied}
|
||||
onCopy={copyText}
|
||||
copyLabel={t("ai.externalMcp.copy")}
|
||||
copiedLabel={t("ai.externalMcp.copied")}
|
||||
emptyLabel={t("ai.externalMcp.unavailable")}
|
||||
/>
|
||||
{!enabled ? (
|
||||
<p className="text-xs text-amber-500">{t("ai.externalMcp.enableForLauncher")}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</details>
|
||||
|
||||
{actionMessage ? <p role={actionMessage.tone === "error" ? "alert" : "status"} className={cn("text-xs", actionMessage.tone === "error" ? "text-destructive" : "text-muted-foreground")}>{actionMessage.text}</p> : null}
|
||||
</ToolSettingsDialog>
|
||||
</SettingRow>
|
||||
</SettingCard>
|
||||
|
||||
{actionMessage ? (
|
||||
<div
|
||||
@@ -991,6 +1008,7 @@ export const ExternalMcpCard: React.FC = () => {
|
||||
{configError || status?.error ? (
|
||||
<div role="alert" className="text-xs text-destructive">{configError || status?.error}</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
);
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState } from "react";
|
||||
import React, { useEffect, useId, useRef, useState } from "react";
|
||||
import {
|
||||
buildClaudeHttpAddCommand,
|
||||
buildCodexHttpEnvLine,
|
||||
@@ -8,9 +8,12 @@ import {
|
||||
DEFAULT_EXTERNAL_MCP_HTTP_PORT,
|
||||
} from "../../../../domain/externalMcpHttp";
|
||||
import { useI18n } from "../../../../application/i18n/I18nProvider";
|
||||
import { cn } from "../../../../lib/utils";
|
||||
import { Button } from "../../../ui/button";
|
||||
import { SettingCard, SettingRow, Toggle } from "../../settings-ui";
|
||||
import { ToolStatusPill } from "./ToolStatusPill";
|
||||
import { ToolSettingsDialog } from "./ToolSettingsDialog";
|
||||
import { useOptionalSettingsFocus } from "../../SettingsFocusContext";
|
||||
import { SettingsAnchor } from "../../settings-ui";
|
||||
import { CopyableCodeBlock } from "./CopyableCodeBlock";
|
||||
|
||||
export type ExternalMcpHttpViewStatus = {
|
||||
@@ -41,6 +44,10 @@ type ExternalMcpHttpSectionProps = {
|
||||
status: ExternalMcpHttpViewStatus | null;
|
||||
copied: string | null;
|
||||
rotating: boolean;
|
||||
applying?: boolean;
|
||||
operationError?: string | null;
|
||||
copyError?: string | null;
|
||||
onRetry?: () => void;
|
||||
onCopy: (key: string, text: string) => void;
|
||||
onChange: (next: ExternalMcpHttpViewSettings) => void;
|
||||
onRotateToken: () => void;
|
||||
@@ -73,11 +80,25 @@ export const ExternalMcpHttpSection: React.FC<ExternalMcpHttpSectionProps> = ({
|
||||
status,
|
||||
copied,
|
||||
rotating,
|
||||
applying,
|
||||
operationError,
|
||||
copyError,
|
||||
onRetry,
|
||||
onCopy,
|
||||
onChange,
|
||||
onRotateToken,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const operationErrorRef = useRef<HTMLDivElement>(null);
|
||||
const errorId = useId();
|
||||
const [open, setOpen] = useState(false);
|
||||
useEffect(() => {
|
||||
if (open && (operationError || copyError)) operationErrorRef.current?.focus();
|
||||
}, [open, operationError, copyError]);
|
||||
const focus = useOptionalSettingsFocus();
|
||||
useEffect(() => {
|
||||
if (focus?.request?.anchorId === "ai-external-mcp-http") setOpen(true);
|
||||
}, [focus?.request]);
|
||||
const [portDraft, setPortDraft] = useState(String(settings.port || DEFAULT_EXTERNAL_MCP_HTTP_PORT));
|
||||
const [addressDraft, setAddressDraft] = useState(settings.listenAddress || "0.0.0.0");
|
||||
const [clientHostDraft, setClientHostDraft] = useState(settings.clientHost || "");
|
||||
@@ -85,6 +106,16 @@ export const ExternalMcpHttpSection: React.FC<ExternalMcpHttpSectionProps> = ({
|
||||
const [addressError, setAddressError] = useState(false);
|
||||
const [clientHostError, setClientHostError] = useState(false);
|
||||
|
||||
const changeOpen = (next: boolean) => {
|
||||
setPortDraft(String(settings.port || DEFAULT_EXTERNAL_MCP_HTTP_PORT));
|
||||
setAddressDraft(settings.listenAddress || "0.0.0.0");
|
||||
setClientHostDraft(settings.clientHost || "");
|
||||
setPortError(false);
|
||||
setAddressError(false);
|
||||
setClientHostError(false);
|
||||
setOpen(next);
|
||||
};
|
||||
|
||||
const listenAddress = settings.allowCustomBind
|
||||
? (status?.listenAddress || settings.listenAddress)
|
||||
: "127.0.0.1";
|
||||
@@ -97,14 +128,9 @@ export const ExternalMcpHttpSection: React.FC<ExternalMcpHttpSectionProps> = ({
|
||||
);
|
||||
const token = status?.bearerToken || "";
|
||||
const listening = Boolean(status?.listening);
|
||||
const statusLabel = listening
|
||||
? t("ai.externalMcp.http.listening")
|
||||
: t("ai.externalMcp.http.notListening");
|
||||
const statusClass = status?.errorCode
|
||||
? "text-destructive"
|
||||
: listening
|
||||
? "text-emerald-500"
|
||||
: "text-muted-foreground";
|
||||
const hasRuntimeError = Boolean(operationError || status?.errorCode || status?.error);
|
||||
const statusLabel = hasRuntimeError ? t("ai.externalMcp.status.error")
|
||||
: t(listening ? "ai.externalMcp.http.listening" : "ai.externalMcp.http.notListening");
|
||||
|
||||
let errorText = "";
|
||||
if (portError) errorText = t("ai.externalMcp.http.invalidPort");
|
||||
@@ -116,6 +142,7 @@ export const ExternalMcpHttpSection: React.FC<ExternalMcpHttpSectionProps> = ({
|
||||
} else if (status?.errorCode === "invalid_address") errorText = t("ai.externalMcp.http.invalidAddress");
|
||||
else if (status?.errorCode === "invalid_port") errorText = t("ai.externalMcp.http.invalidPort");
|
||||
else if (status?.errorCode === "listen_failed") errorText = status.error || t("ai.externalMcp.http.listenFailed");
|
||||
else if (status?.error) errorText = status.error;
|
||||
|
||||
const commitPort = () => {
|
||||
const port = parsePort(portDraft);
|
||||
@@ -151,16 +178,25 @@ export const ExternalMcpHttpSection: React.FC<ExternalMcpHttpSectionProps> = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<SettingsAnchor anchorId="ai-external-mcp-http" className="space-y-2">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="text-sm font-semibold text-foreground">{t("ai.externalMcp.http.title")}</div>
|
||||
<div className={cn("text-xs font-medium", statusClass)}>{statusLabel}</div>
|
||||
<ToolStatusPill tone={hasRuntimeError ? "error" : listening ? "active" : "inactive"}>{statusLabel}</ToolStatusPill>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground leading-5">{t("ai.externalMcp.http.description")}</p>
|
||||
<p className="text-xs text-muted-foreground leading-5">{t("ai.externalMcp.http.legacySseNote")}</p>
|
||||
<SettingCard divided className="rounded-md border-border/60 bg-background/70">
|
||||
<SettingCard>
|
||||
<SettingRow className="flex-col items-start md:flex-row md:items-center" label={t(settings.enabled ? "ai.toolSettings.httpOn" : "ai.toolSettings.httpOff")} description={settings.allowCustomBind ? t("ai.externalMcp.http.warning") : t("ai.toolSettings.loopback")}>
|
||||
<ToolSettingsDialog title={t("ai.externalMcp.http.title")} triggerLabel={t("ai.toolSettings.configureHttp")} immediate open={open} onOpenChange={changeOpen}>
|
||||
{applying ? <p role="status" className="text-xs text-muted-foreground">{t("ai.toolSettings.applyingHttp")}</p> : null}
|
||||
{operationError || copyError ? <div ref={operationErrorRef} role="alert" tabIndex={-1} className="space-y-2 text-xs text-destructive">
|
||||
{operationError ? <p>{operationError}</p> : null}
|
||||
{copyError ? <p>{copyError}</p> : null}
|
||||
{operationError && onRetry ? <Button type="button" variant="outline" size="sm" disabled={rotating || applying} onClick={onRetry}>{t("ai.toolSettings.retry")}</Button> : null}
|
||||
</div> : null}
|
||||
<p className="text-xs text-muted-foreground leading-5">{t("ai.externalMcp.http.description")}</p>
|
||||
<SettingCard divided className="border-0 bg-transparent p-0">
|
||||
<SettingRow
|
||||
anchorId="ai-external-mcp-http"
|
||||
label={t("ai.externalMcp.http.enable")}
|
||||
>
|
||||
<Toggle
|
||||
@@ -176,6 +212,8 @@ export const ExternalMcpHttpSection: React.FC<ExternalMcpHttpSectionProps> = ({
|
||||
<input
|
||||
type="number"
|
||||
aria-label={t("ai.externalMcp.http.port")}
|
||||
aria-invalid={portError}
|
||||
aria-describedby={portError ? errorId : undefined}
|
||||
min={1024}
|
||||
max={65535}
|
||||
value={portDraft}
|
||||
@@ -207,6 +245,8 @@ export const ExternalMcpHttpSection: React.FC<ExternalMcpHttpSectionProps> = ({
|
||||
<input
|
||||
type="text"
|
||||
aria-label={t("ai.externalMcp.http.listenAddress")}
|
||||
aria-invalid={addressError}
|
||||
aria-describedby={addressError ? errorId : undefined}
|
||||
placeholder={t("ai.externalMcp.http.listenAddress.placeholder")}
|
||||
value={addressDraft}
|
||||
onChange={(event) => {
|
||||
@@ -226,6 +266,8 @@ export const ExternalMcpHttpSection: React.FC<ExternalMcpHttpSectionProps> = ({
|
||||
<input
|
||||
type="text"
|
||||
aria-label={t("ai.externalMcp.http.clientHost")}
|
||||
aria-invalid={clientHostError}
|
||||
aria-describedby={clientHostError ? errorId : undefined}
|
||||
placeholder={t("ai.externalMcp.http.clientHost.placeholder")}
|
||||
value={clientHostDraft}
|
||||
onChange={(event) => {
|
||||
@@ -245,8 +287,9 @@ export const ExternalMcpHttpSection: React.FC<ExternalMcpHttpSectionProps> = ({
|
||||
{settings.enabled && !externalMcpEnabled ? (
|
||||
<p className="text-xs text-amber-500">{t("ai.externalMcp.http.requiresExternalMcp")}</p>
|
||||
) : null}
|
||||
{errorText ? <p className="text-xs text-destructive">{errorText}</p> : null}
|
||||
<div className="space-y-2.5 rounded-md border border-border/60 bg-background/50 p-3">
|
||||
{errorText ? <p id={errorId} role="alert" className="text-xs text-destructive">{errorText}</p> : null}
|
||||
<details className="space-y-4">
|
||||
<summary className="cursor-pointer text-sm font-medium">{t("ai.toolSettings.connectionDetails")}</summary>
|
||||
<CopyableCodeBlock
|
||||
label={t("ai.externalMcp.http.url")}
|
||||
value={url}
|
||||
@@ -259,6 +302,7 @@ export const ExternalMcpHttpSection: React.FC<ExternalMcpHttpSectionProps> = ({
|
||||
<CopyableCodeBlock
|
||||
label={t("ai.externalMcp.http.token")}
|
||||
value={token}
|
||||
sensitive
|
||||
copyKey="http-token"
|
||||
copied={copied}
|
||||
onCopy={onCopy}
|
||||
@@ -272,7 +316,7 @@ export const ExternalMcpHttpSection: React.FC<ExternalMcpHttpSectionProps> = ({
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!externalMcpEnabled || !settings.enabled || rotating}
|
||||
disabled={!externalMcpEnabled || !settings.enabled || rotating || applying}
|
||||
onClick={onRotateToken}
|
||||
>
|
||||
{t("ai.externalMcp.http.regenerate")}
|
||||
@@ -290,6 +334,7 @@ export const ExternalMcpHttpSection: React.FC<ExternalMcpHttpSectionProps> = ({
|
||||
<CopyableCodeBlock
|
||||
label={t("ai.externalMcp.http.envLine")}
|
||||
value={token ? buildCodexHttpEnvLine(token) : ""}
|
||||
sensitive
|
||||
copyKey="http-env"
|
||||
copied={copied}
|
||||
onCopy={onCopy}
|
||||
@@ -300,6 +345,7 @@ export const ExternalMcpHttpSection: React.FC<ExternalMcpHttpSectionProps> = ({
|
||||
<CopyableCodeBlock
|
||||
label={t("ai.externalMcp.http.claudeCommand")}
|
||||
value={token ? buildClaudeHttpAddCommand(url, token) : ""}
|
||||
sensitive
|
||||
copyKey="http-claude"
|
||||
copied={copied}
|
||||
onCopy={onCopy}
|
||||
@@ -310,6 +356,7 @@ export const ExternalMcpHttpSection: React.FC<ExternalMcpHttpSectionProps> = ({
|
||||
<CopyableCodeBlock
|
||||
label={t("ai.externalMcp.http.genericSnippet")}
|
||||
value={token ? buildGenericHttpMcpJson(url, token) : ""}
|
||||
sensitive
|
||||
copyKey="http-json"
|
||||
copied={copied}
|
||||
onCopy={onCopy}
|
||||
@@ -317,7 +364,14 @@ export const ExternalMcpHttpSection: React.FC<ExternalMcpHttpSectionProps> = ({
|
||||
copiedLabel={t("ai.externalMcp.copied")}
|
||||
emptyLabel={t("ai.externalMcp.unavailable")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</ToolSettingsDialog>
|
||||
</SettingRow>
|
||||
</SettingCard>
|
||||
{settings.enabled && !externalMcpEnabled ? <p className="text-xs text-amber-500">{t("ai.externalMcp.http.requiresExternalMcp")}</p> : null}
|
||||
{applying ? <p role="status" className="text-xs text-muted-foreground">{t("ai.toolSettings.applyingHttp")}</p> : null}
|
||||
{copyError ? <p role="alert" className="text-xs text-destructive">{copyError}</p> : null}
|
||||
{operationError || status?.error ? <p role="alert" className="text-xs text-destructive">{operationError || status?.error}</p> : null}
|
||||
</SettingsAnchor>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,177 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test, { after } from 'node:test';
|
||||
import React from 'react';
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
|
||||
import { JSDOM } from 'jsdom';
|
||||
|
||||
const dom = new JSDOM('<!doctype html><html><body></body></html>', { url: 'http://localhost/' });
|
||||
const previous = new Map<string, PropertyDescriptor | undefined>();
|
||||
for (const name of ['window', 'document', 'navigator', 'HTMLElement', 'Element', 'Node', 'MutationObserver', 'CustomEvent', 'Event'] as const) {
|
||||
previous.set(name, Object.getOwnPropertyDescriptor(globalThis, name));
|
||||
Object.defineProperty(globalThis, name, { value: dom.window[name], configurable: true });
|
||||
}
|
||||
for (const [name, value] of Object.entries({ IS_REACT_ACT_ENVIRONMENT: true, fetch: () => { throw new Error('Network is forbidden in model discovery tests'); } })) {
|
||||
previous.set(name, Object.getOwnPropertyDescriptor(globalThis, name));
|
||||
Object.defineProperty(globalThis, name, { value, configurable: true });
|
||||
}
|
||||
after(() => {
|
||||
dom.window.close();
|
||||
for (const [name, descriptor] of previous) {
|
||||
if (descriptor) Object.defineProperty(globalThis, name, descriptor);
|
||||
else Reflect.deleteProperty(globalThis, name);
|
||||
}
|
||||
});
|
||||
const { I18nProvider } = await import('../../../../application/i18n/I18nProvider');
|
||||
const { TooltipProvider } = await import('../../../ui/tooltip');
|
||||
const { ModelSelector } = await import('./ModelSelector');
|
||||
|
||||
type Response = { ok: boolean; status?: number; data: string; error?: string };
|
||||
function deferred() {
|
||||
let resolve!: (value: Response) => void;
|
||||
let reject!: (error: Error) => void;
|
||||
const promise = new Promise<Response>((yes, no) => { resolve = yes; reject = no; });
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
type Props = React.ComponentProps<typeof ModelSelector>;
|
||||
const baseProps: Props = {
|
||||
value: '', onChange: () => {}, baseURL: 'http://127.0.0.1:9217/v1',
|
||||
providerId: 'custom', style: 'openai', apiKey: 'SYNTHETIC_FIXTURE_KEY', customHeaders: {},
|
||||
};
|
||||
async function mount() {
|
||||
const requests: ({ url: string; method: string; body: string | undefined } & ReturnType<typeof deferred>)[] = [];
|
||||
const allowlistCalls: string[] = [];
|
||||
let overflow = false;
|
||||
Object.defineProperty(window, 'netcatty', { configurable: true, value: {
|
||||
aiAllowlistAddHost: async (baseURL: string) => { allowlistCalls.push(baseURL); return { ok: true }; },
|
||||
aiFetch: (url: string, method: string, _headers: unknown, body: string | undefined) => {
|
||||
// Hold instead of rejecting if a regression exceeds the budget, so even a
|
||||
// broken auto-fetch effect cannot create an uncontrolled test loop.
|
||||
if (requests.length >= 4) { overflow = true; return new Promise<Response>(() => {}); }
|
||||
const pending = deferred();
|
||||
requests.push({ url, method, body, ...pending });
|
||||
return pending.promise;
|
||||
},
|
||||
} });
|
||||
let renderer!: ReactTestRenderer;
|
||||
const view = (updates: Partial<Props> = {}) => <I18nProvider locale="en"><TooltipProvider>
|
||||
<ModelSelector {...baseProps} {...updates} />
|
||||
</TooltipProvider></I18nProvider>;
|
||||
await act(async () => { renderer = create(view()); });
|
||||
const input = () => renderer.root.findByType('input');
|
||||
const open = async () => { await act(async () => input().props.onFocus()); };
|
||||
const close = async () => { await act(async () => input().props.onBlur()); };
|
||||
const refresh = async () => {
|
||||
const button = renderer.root.findAllByType('button').find((node) => typeof node.props.onClick === 'function')!;
|
||||
await act(async () => button.props.onClick({ defaultPrevented: false }));
|
||||
};
|
||||
const markup = () => JSON.stringify(renderer.toJSON());
|
||||
return {
|
||||
requests, allowlistCalls, input, open, close, refresh, markup,
|
||||
update: async (updates: Partial<Props>) => { await act(async () => renderer.update(view(updates))); },
|
||||
assertBounded: () => assert.equal(overflow, false, 'mock request budget was not exceeded'),
|
||||
unmount: async () => { await act(async () => renderer.unmount()); },
|
||||
};
|
||||
}
|
||||
const notFound: Response = { ok: false, status: 404, data: '{"error":"fixture has no model-list route"}' };
|
||||
const models = (id: string): Response => ({ ok: true, status: 200, data: JSON.stringify({ data: [{ id }] }) });
|
||||
|
||||
for (const failure of ['404', 'transport', 'invalid-json'] as const) {
|
||||
test(`model discovery ${failure} failure stays visible without auto-retry; Refresh retries once`, async () => {
|
||||
const h = await mount();
|
||||
const reject = (index: number) => {
|
||||
if (failure === 'transport') h.requests[index].reject(new Error('Synthetic model-list transport failure'));
|
||||
else h.requests[index].resolve(failure === '404' ? notFound : { ok: true, status: 200, data: '{not json' });
|
||||
};
|
||||
try {
|
||||
assert.equal(h.requests.length, 0);
|
||||
await h.open();
|
||||
assert.equal(h.requests.length, 1);
|
||||
assert.equal(h.requests[0].url, 'http://127.0.0.1:9217/v1/models');
|
||||
assert.equal(h.requests[0].method, 'GET');
|
||||
assert.equal(h.requests[0].body, undefined);
|
||||
await act(async () => reject(0));
|
||||
assert.equal(h.requests.length, 1, 'failure must settle instead of triggering the loading effect again');
|
||||
assert.match(h.markup(), /text-destructive/);
|
||||
assert.doesNotMatch(h.markup(), /Loading models/);
|
||||
await h.close();
|
||||
await h.open();
|
||||
await h.update({ customHeaders: {} });
|
||||
assert.equal(h.requests.length, 1, 'reopen or an equal-config render must not retry a failure');
|
||||
assert.match(h.markup(), /text-destructive/);
|
||||
await h.refresh();
|
||||
assert.equal(h.requests.length, 2);
|
||||
await act(async () => reject(1));
|
||||
assert.equal(h.requests.length, 2);
|
||||
assert.match(h.markup(), /text-destructive/);
|
||||
assert.equal(h.allowlistCalls.length, 2);
|
||||
h.assertBounded();
|
||||
} finally { await h.unmount(); }
|
||||
});
|
||||
}
|
||||
|
||||
test('changing provider configuration clears a settled error and permits one fresh discovery', async () => {
|
||||
const h = await mount();
|
||||
try {
|
||||
await h.open();
|
||||
await act(async () => h.requests[0].resolve(notFound));
|
||||
assert.equal(h.requests.length, 1);
|
||||
await h.update({ baseURL: 'http://127.0.0.1:9217/second-provider/v1', providerId: 'openai' });
|
||||
assert.equal(h.requests.length, 2);
|
||||
assert.equal(h.requests[1].url, 'http://127.0.0.1:9217/second-provider/v1/models');
|
||||
await act(async () => h.requests[1].resolve(models('second-model')));
|
||||
assert.equal(h.requests.length, 2);
|
||||
assert.match(h.markup(), /second-model/);
|
||||
assert.doesNotMatch(h.markup(), /Failed to fetch models/);
|
||||
h.assertBounded();
|
||||
} finally { await h.unmount(); }
|
||||
});
|
||||
|
||||
test('a provider switch ignores an older pending discovery failure', async () => {
|
||||
const h = await mount();
|
||||
try {
|
||||
await h.open();
|
||||
await h.update({ baseURL: 'http://127.0.0.1:9217/second-provider/v1' });
|
||||
assert.equal(h.requests.length, 2);
|
||||
await act(async () => h.requests[1].resolve(models('current-model')));
|
||||
await act(async () => h.requests[0].resolve(notFound));
|
||||
assert.equal(h.requests.length, 2);
|
||||
assert.match(h.markup(), /current-model/);
|
||||
assert.doesNotMatch(h.markup(), /Failed to fetch models/);
|
||||
h.assertBounded();
|
||||
} finally { await h.unmount(); }
|
||||
});
|
||||
|
||||
test('successful discovery remains cached across reopen and manual model entry remains controlled', async () => {
|
||||
const h = await mount();
|
||||
try {
|
||||
await h.open();
|
||||
await act(async () => h.requests[0].resolve(models('listed-model')));
|
||||
await h.close();
|
||||
await h.open();
|
||||
assert.equal(h.requests.length, 1);
|
||||
assert.match(h.markup(), /listed-model/);
|
||||
const changed: string[] = [];
|
||||
await h.update({ onChange: (value) => { changed.push(value); } });
|
||||
await act(async () => h.input().props.onChange({ target: { value: 'manual-model' } }));
|
||||
await h.update({ value: changed[0] });
|
||||
assert.deepEqual(changed, ['manual-model']);
|
||||
assert.equal(h.input().props.value, 'manual-model');
|
||||
assert.equal(h.requests.length, 1);
|
||||
h.assertBounded();
|
||||
} finally { await h.unmount(); }
|
||||
});
|
||||
|
||||
test('closing and unmounting with a pending discovery do not trigger another request', async () => {
|
||||
const h = await mount();
|
||||
try {
|
||||
await h.open();
|
||||
await h.close();
|
||||
await act(async () => h.requests[0].resolve(notFound));
|
||||
assert.equal(h.requests.length, 1);
|
||||
await h.refresh();
|
||||
assert.equal(h.requests.length, 2);
|
||||
h.assertBounded();
|
||||
} finally { await h.unmount(); }
|
||||
await act(async () => h.requests[1].resolve(notFound));
|
||||
assert.equal(h.requests.length, 2);
|
||||
});
|
||||
@@ -175,12 +175,12 @@ export const ModelSelector: React.FC<{
|
||||
}
|
||||
}, [baseURL, effectiveModelsEndpoint, apiKey, customHeaders, resolvedStyle, skipTLSVerify, discoveryKey]);
|
||||
|
||||
// Auto-fetch when dropdown first opens
|
||||
// Failed discovery stays settled until explicit refresh or a new discovery key.
|
||||
useEffect(() => {
|
||||
if (isOpen && canFetch && !hasFetched && !isLoading) {
|
||||
if (isOpen && canFetch && !hasFetched && !isLoading && error === null) {
|
||||
void fetchModels();
|
||||
}
|
||||
}, [isOpen, canFetch, hasFetched, isLoading, fetchModels]);
|
||||
}, [isOpen, canFetch, hasFetched, isLoading, error, fetchModels]);
|
||||
|
||||
// Filter preset and discovered models by current input value (inline autocomplete).
|
||||
const suggestions = useMemo(() => {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useI18n } from "../../../../application/i18n/I18nProvider";
|
||||
import type { AIToolIntegrationMode } from "../../../../infrastructure/ai/types";
|
||||
import { cn } from "../../../../lib/utils";
|
||||
import { useToolAccessGuidanceState } from "../../../../application/state/useToolAccessGuidanceState";
|
||||
import { ToolSettingsDialog } from "./ToolSettingsDialog";
|
||||
import { EXTERNAL_MCP_DISCOVERY_ENV_VAR } from "./ExternalMcpCard";
|
||||
|
||||
/** Build a ready-to-paste prompt so an external AI client can register Netcatty MCP itself. */
|
||||
@@ -45,16 +46,20 @@ type CopyRowProps = {
|
||||
|
||||
const CopyRow: React.FC<CopyRowProps> = ({ value, label, copyLabel, copiedLabel, testId }) => {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [copyError, setCopyError] = useState(false);
|
||||
const { t } = useI18n();
|
||||
const canCopy = Boolean(value);
|
||||
|
||||
const handleCopy = async () => {
|
||||
if (!value) return;
|
||||
setCopyError(false);
|
||||
setCopied(false);
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
setCopied(true);
|
||||
window.setTimeout(() => setCopied(false), 1200);
|
||||
} catch {
|
||||
// Clipboard may be unavailable; the text stays selectable in the block.
|
||||
setCopyError(true);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -82,6 +87,7 @@ const CopyRow: React.FC<CopyRowProps> = ({ value, label, copyLabel, copiedLabel,
|
||||
{copied ? <Check size={14} className="text-emerald-500" /> : <Copy size={14} />}
|
||||
</button>
|
||||
</div>
|
||||
{copyError ? <p role="alert" className="text-xs text-destructive">{t("ai.externalMcp.copyFailed")}</p> : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -93,7 +99,7 @@ export const ToolAccessGuidance: React.FC<{ mode: AIToolIntegrationMode }> = ({
|
||||
|
||||
if (mode === "skills") {
|
||||
return (
|
||||
<div className="rounded-md border border-border/60 bg-background/50 p-3 space-y-2">
|
||||
<ToolSettingsDialog title={t("ai.toolAccess.skills.file")} triggerLabel={t("ai.toolSettings.viewSkills")}>
|
||||
<p className="text-xs text-muted-foreground leading-5">
|
||||
{t("ai.toolAccess.skills.description")}
|
||||
</p>
|
||||
@@ -110,12 +116,12 @@ export const ToolAccessGuidance: React.FC<{ mode: AIToolIntegrationMode }> = ({
|
||||
{commandPrefix ? (
|
||||
<p className="text-xs text-muted-foreground/80 font-mono break-all">{commandPrefix}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</ToolSettingsDialog>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-md border border-border/60 bg-background/50 p-3 space-y-2">
|
||||
<ToolSettingsDialog title={t("ai.toolAccess.mcpPrompt.title")} triggerLabel={t("ai.toolSettings.viewPrompt")}>
|
||||
<p className="text-xs text-muted-foreground leading-5">
|
||||
{t("ai.toolAccess.mcpPrompt.description")}
|
||||
</p>
|
||||
@@ -129,6 +135,6 @@ export const ToolAccessGuidance: React.FC<{ mode: AIToolIntegrationMode }> = ({
|
||||
{!mcpLauncherPath ? (
|
||||
<p className="text-xs text-amber-500">{t("ai.toolAccess.mcpPrompt.enableHint")}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</ToolSettingsDialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,47 @@
|
||||
import React from "react";
|
||||
import { useI18n } from "../../../../application/i18n/I18nProvider";
|
||||
import { Button } from "../../../ui/button";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "../../../ui/dialog";
|
||||
|
||||
/** Shared, bounded settings dialog. Callers retain their original commit semantics. */
|
||||
export const ToolSettingsDialog: React.FC<{
|
||||
title: string;
|
||||
triggerLabel: string;
|
||||
children: React.ReactNode;
|
||||
immediate?: boolean;
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
footer?: React.ReactNode;
|
||||
busy?: boolean;
|
||||
}> = ({ title, triggerLabel, children, immediate, open, onOpenChange, footer, busy }) => {
|
||||
const { t } = useI18n();
|
||||
const [localOpen, setLocalOpen] = React.useState(false);
|
||||
const changeOpen = (next: boolean) => {
|
||||
if (busy) return;
|
||||
setLocalOpen(next);
|
||||
onOpenChange?.(next);
|
||||
};
|
||||
return (
|
||||
<Dialog open={open ?? localOpen} onOpenChange={changeOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button type="button" variant="outline" size="sm">{triggerLabel}</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent
|
||||
className="flex max-h-[calc(100dvh-2rem)] w-[calc(100vw-2rem)] max-w-2xl flex-col gap-0 overflow-hidden p-0"
|
||||
aria-busy={busy || undefined}
|
||||
hideCloseButton={busy}
|
||||
onEscapeKeyDown={(event) => { if (busy) event.preventDefault(); }}
|
||||
onInteractOutside={(event) => { if (busy) event.preventDefault(); }}
|
||||
>
|
||||
<DialogHeader className="shrink-0 px-6 pb-4 pt-6 pr-12 text-left">
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
{immediate ? <p className="text-xs text-muted-foreground">{t("ai.toolSettings.immediate")}</p> : null}
|
||||
</DialogHeader>
|
||||
<div className="min-h-0 overflow-y-auto px-6 pb-6 space-y-4">{children}</div>
|
||||
<DialogFooter className="shrink-0 gap-2 border-t px-6 py-4">
|
||||
{footer ?? <Button type="button" variant="outline" onClick={() => changeOpen(false)}>{t("common.close")}</Button>}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
import React from "react";
|
||||
import { cn } from "../../../../lib/utils";
|
||||
|
||||
export type ToolStatusTone = "active" | "inactive" | "error";
|
||||
|
||||
/** Non-interactive runtime status, shared by the MCP and HTTP section headers. */
|
||||
export const ToolStatusPill: React.FC<{ tone: ToolStatusTone; children: React.ReactNode }> = ({ tone, children }) => (
|
||||
<span role="status" className={cn(
|
||||
"inline-flex shrink-0 items-center rounded-full px-2.5 py-0.5 text-xs font-medium leading-5",
|
||||
tone === "active" ? "bg-primary/10 text-primary"
|
||||
: tone === "error" ? "bg-destructive/10 text-destructive"
|
||||
: "bg-muted text-muted-foreground",
|
||||
)}>{children}</span>
|
||||
);
|
||||
@@ -283,6 +283,8 @@ interface AIChatPanelsHostProps {
|
||||
mountedTabIds: string[];
|
||||
activeTabId: string | null;
|
||||
activeSidePanelTab: SidePanelTab | null;
|
||||
/** All live terminal+workspace target ids (for /chats dangling detection). */
|
||||
liveScopeTargetIds: Set<string>;
|
||||
contextsByTabId: Map<string, AIPanelContext>;
|
||||
resolveExecutorContext: (scope: {
|
||||
type: 'terminal' | 'workspace';
|
||||
@@ -298,6 +300,8 @@ interface AIChatPanelsHostProps {
|
||||
onOpenVaultHostFromChat?: (hostId: string) => void;
|
||||
onOpenVaultSectionFromChat?: (section: 'notes' | 'hosts' | 'snippets') => void;
|
||||
onOpenVaultSnippetFromChat?: (snippetId: string) => void;
|
||||
/** Live workspaces, threaded to AI chats for `/chats` scope titles. */
|
||||
workspaces?: Workspace[] | null;
|
||||
}
|
||||
|
||||
const EMPTY_WORKSPACES: Workspace[] = [];
|
||||
@@ -450,6 +454,7 @@ function aiChatPanelsHostAreEqual(
|
||||
next: AIChatPanelsHostProps,
|
||||
): boolean {
|
||||
if (prev.mountedTabIds !== next.mountedTabIds) return false;
|
||||
if (prev.liveScopeTargetIds !== next.liveScopeTargetIds) return false;
|
||||
if (prev.contextsByTabId !== next.contextsByTabId) return false;
|
||||
if (prev.activeSidePanelTab !== next.activeSidePanelTab) return false;
|
||||
if (prev.pendingTerminalSelection !== next.pendingTerminalSelection) return false;
|
||||
@@ -462,6 +467,7 @@ function aiChatPanelsHostAreEqual(
|
||||
if (prev.onOpenVaultHostFromChat !== next.onOpenVaultHostFromChat) return false;
|
||||
if (prev.onOpenVaultSectionFromChat !== next.onOpenVaultSectionFromChat) return false;
|
||||
if (prev.onOpenVaultSnippetFromChat !== next.onOpenVaultSnippetFromChat) return false;
|
||||
if (prev.workspaces !== next.workspaces) return false;
|
||||
if (prev.activeTabId === next.activeTabId) return true;
|
||||
|
||||
for (let i = 0; i < prev.mountedTabIds.length; i += 1) {
|
||||
@@ -489,6 +495,7 @@ const AIChatPanelsHostInner: React.FC<AIChatPanelsHostProps> = ({
|
||||
mountedTabIds,
|
||||
activeTabId,
|
||||
activeSidePanelTab,
|
||||
liveScopeTargetIds,
|
||||
contextsByTabId,
|
||||
resolveExecutorContext,
|
||||
pendingTerminalSelection,
|
||||
@@ -500,6 +507,7 @@ const AIChatPanelsHostInner: React.FC<AIChatPanelsHostProps> = ({
|
||||
onOpenVaultHostFromChat,
|
||||
onOpenVaultSectionFromChat,
|
||||
onOpenVaultSnippetFromChat,
|
||||
workspaces,
|
||||
}) => {
|
||||
const aiConfig = useContext(AIConfigContext);
|
||||
|
||||
@@ -517,6 +525,10 @@ const AIChatPanelsHostInner: React.FC<AIChatPanelsHostProps> = ({
|
||||
showDraftView,
|
||||
updateDraft,
|
||||
} = aiConfig;
|
||||
const aiPanelWorkspaces = useMemo(
|
||||
() => (workspaces ?? []).map((workspace) => ({ id: workspace.id, title: workspace.title })),
|
||||
[workspaces],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pendingTerminalSelection) return;
|
||||
@@ -588,6 +600,9 @@ const AIChatPanelsHostInner: React.FC<AIChatPanelsHostProps> = ({
|
||||
undoLastTurnInSession={aiConfig.undoLastTurnInSession}
|
||||
deleteSession={aiConfig.deleteSession}
|
||||
updateSessionTitle={aiConfig.updateSessionTitle}
|
||||
rebindSessionScope={aiConfig.rebindSessionScope}
|
||||
exportSessionsBackup={aiConfig.exportSessionsBackup}
|
||||
importSessionsBackup={aiConfig.importSessionsBackup}
|
||||
updateSessionExternalSessionId={aiConfig.updateSessionExternalSessionId}
|
||||
addMessageToSession={aiConfig.addMessageToSession}
|
||||
updateLastMessage={aiConfig.updateLastMessage}
|
||||
@@ -621,7 +636,9 @@ const AIChatPanelsHostInner: React.FC<AIChatPanelsHostProps> = ({
|
||||
scopeHostIds={context.scopeHostIds}
|
||||
scopeLabel={context.scopeLabel}
|
||||
focusedSessionId={context.focusedSessionId}
|
||||
workspaces={aiPanelWorkspaces}
|
||||
terminalSessions={context.terminalSessions}
|
||||
liveScopeTargetIds={liveScopeTargetIds}
|
||||
resolveExecutorContext={resolveExecutorContext}
|
||||
isVisible={isVisible}
|
||||
notes={notes}
|
||||
|
||||
@@ -654,6 +654,7 @@ function SidePanelAiSlotInner({
|
||||
mountedTabIds={mountedAiTabIds}
|
||||
activeTabId={activeTabId}
|
||||
activeSidePanelTab={isVisible ? 'ai' : null}
|
||||
liveScopeTargetIds={validAIScopeTargetIds}
|
||||
contextsByTabId={aiContextsByTabId}
|
||||
resolveExecutorContext={resolveAIExecutorContext}
|
||||
pendingTerminalSelection={pendingTerminalSelectionForAI}
|
||||
@@ -665,6 +666,7 @@ function SidePanelAiSlotInner({
|
||||
onOpenVaultHostFromChat={onOpenVaultHostFromChat}
|
||||
onOpenVaultSectionFromChat={onOpenVaultSectionFromChat}
|
||||
onOpenVaultSnippetFromChat={onOpenVaultSnippetFromChat}
|
||||
workspaces={workspaces}
|
||||
/>
|
||||
</AISidePanelStateRoot>
|
||||
);
|
||||
|
||||
@@ -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.
|
||||
@@ -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.", "读取插件设置", "读取此插件自己的设置。"],
|
||||
|
||||
@@ -10,6 +10,16 @@ export type SplitHint = {
|
||||
targetSessionId?: string;
|
||||
};
|
||||
|
||||
export const invertSplitPosition = (position: SplitPosition): SplitPosition => {
|
||||
switch (position) {
|
||||
case 'left': return 'right';
|
||||
case 'right': return 'left';
|
||||
case 'top': return 'bottom';
|
||||
case 'bottom': return 'top';
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
export const pruneWorkspaceNode = (node: WorkspaceNode, targetSessionId: string): WorkspaceNode | null => {
|
||||
if (node.type === 'pane') {
|
||||
return node.sessionId === targetSessionId ? null : node;
|
||||
|
||||
@@ -36,6 +36,79 @@ test('PTY sends no bytes while a slow shell is starting, then runs once ready',
|
||||
assert.equal(pty.writes.length, 1);
|
||||
});
|
||||
|
||||
test('macOS default zsh prompt is recognized as an idle prompt', async () => {
|
||||
const pty = new TestPty();
|
||||
const session = { _promptTrackTail: '', lastIdlePrompt: '' };
|
||||
const prompt = 'hexiao@JosephdeMacBook-Pro-2 ~ % ';
|
||||
trackSessionIdlePrompt(session, `Last login: Mon Oct 26\r\n${prompt}`);
|
||||
assert.equal(session.lastIdlePrompt, prompt);
|
||||
assert.equal(readyPrompt(session), prompt);
|
||||
|
||||
const job = startPtyJob(pty, 'echo OK', {
|
||||
shellKind: 'posix',
|
||||
getReadyPrompt: () => readyPrompt(session),
|
||||
promptWaitTimeoutMs: 200,
|
||||
timeoutMs: 1000,
|
||||
});
|
||||
const result = await job.resultPromise;
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(pty.writes.length, 1);
|
||||
});
|
||||
|
||||
test('zsh prompt with pending input does not become ready', async (t) => {
|
||||
t.mock.timers.enable({ apis: ['Date', 'setTimeout'] });
|
||||
const pty = new TestPty();
|
||||
const session = { _promptTrackTail: '', lastIdlePrompt: '' };
|
||||
trackSessionIdlePrompt(session, 'hexiao@JosephdeMacBook-Pro-2 ~ % echo half-typed');
|
||||
assert.equal(readyPrompt(session), '');
|
||||
|
||||
const job = startPtyJob(pty, 'echo OK', {
|
||||
shellKind: 'posix',
|
||||
getReadyPrompt: () => readyPrompt(session),
|
||||
promptWaitTimeoutMs: 50,
|
||||
timeoutMs: 1000,
|
||||
});
|
||||
t.mock.timers.tick(50);
|
||||
const result = await job.resultPromise;
|
||||
assert.equal(result.code, 'SHELL_NOT_READY');
|
||||
assert.deepEqual(pty.writes, []);
|
||||
});
|
||||
|
||||
test('zsh default prompts allow directory spaces and the privileged marker', async () => {
|
||||
for (const prompt of ['alice@MacBook-Pro My Project % ', 'root@MacBook-Pro ~ # ', 'root@MacBook-Pro My Project # ']) {
|
||||
const pty = new TestPty();
|
||||
const session = { _promptTrackTail: '', lastIdlePrompt: '' };
|
||||
trackSessionIdlePrompt(session, `Last login\r\n${prompt}`);
|
||||
assert.equal(getFreshIdlePrompt(session), prompt);
|
||||
assert.equal(getVisibleIdlePrompt(session), prompt);
|
||||
|
||||
const result = await startPtyJob(pty, 'echo OK', {
|
||||
shellKind: 'posix',
|
||||
getReadyPrompt: () => readyPrompt(session),
|
||||
promptWaitTimeoutMs: 200,
|
||||
timeoutMs: 1000,
|
||||
}).resultPromise;
|
||||
assert.equal(result.ok, true, prompt);
|
||||
assert.equal(pty.writes.length, 1, prompt);
|
||||
}
|
||||
});
|
||||
|
||||
test('zsh spaced-directory prompts with pending input stay unready', async () => {
|
||||
for (const line of ['alice@MacBook-Pro My Project % echo %', 'root@MacBook-Pro My Project # echo #']) {
|
||||
const pty = new TestPty();
|
||||
const session = { _promptTrackTail: '', lastIdlePrompt: '' };
|
||||
trackSessionIdlePrompt(session, line);
|
||||
assert.equal(readyPrompt(session), '', line);
|
||||
const result = await startPtyJob(pty, 'echo forbidden', {
|
||||
getReadyPrompt: () => readyPrompt(session),
|
||||
promptWaitTimeoutMs: 25,
|
||||
timeoutMs: 1000,
|
||||
}).resultPromise;
|
||||
assert.equal(result.code, 'SHELL_NOT_READY', line);
|
||||
assert.deepEqual(pty.writes, [], line);
|
||||
}
|
||||
});
|
||||
|
||||
test('PTY readiness timeout does not send command or interrupt to a busy program', async () => {
|
||||
const pty = new TestPty();
|
||||
const job = startPtyJob(pty, 'echo OK', { getReadyPrompt:()=>'', promptWaitTimeoutMs:25, timeoutMs:1000 });
|
||||
|
||||
@@ -312,6 +312,21 @@ test("recognizes PowerShell on Linux/macOS prompts (`PS /home/alice>`)", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("recognizes the macOS zsh default prompt without changing PowerShell/cmd detection", () => {
|
||||
assert.equal(
|
||||
resolveEffectiveShellKind(undefined, "hexiao@JosephdeMacBook-Pro-2 ~ % "),
|
||||
"posix",
|
||||
);
|
||||
assert.equal(
|
||||
resolveEffectiveShellKind(undefined, "hexiao@MacBook-Pro ~/git %", { loginShellHint: "powershell" }),
|
||||
"posix",
|
||||
);
|
||||
assert.equal(
|
||||
resolveEffectiveShellKind("posix", "hexiao@MacBook-Pro ~ % echo half-typed"),
|
||||
"posix",
|
||||
);
|
||||
});
|
||||
|
||||
test("ignores ANSI-coloured PowerShell prompts when detecting the shell", () => {
|
||||
assert.equal(
|
||||
resolveEffectiveShellKind(undefined, "[32mPS C:\\Users\\alice>[0m"),
|
||||
|
||||
@@ -41,6 +41,16 @@ test("prompt suffix is found after a very large flood of CRLF output", () => {
|
||||
assert.equal(hasExpectedPromptSuffix(`${flood}${COLORED_PROMPT}`, "root@3160T:~# "), true);
|
||||
});
|
||||
|
||||
test("macOS zsh default prompt suffix matches only when the prompt is idle", () => {
|
||||
const prompt = "hexiao@JosephdeMacBook-Pro-2 ~ % ";
|
||||
assert.equal(hasExpectedPromptSuffix(`Last login\r\n${prompt}`, prompt), true);
|
||||
assert.equal(hasExpectedPromptSuffix(`Last login\r\n${prompt}echo hello`, prompt), false);
|
||||
assert.equal(
|
||||
hasExpectedPromptSuffix(`Last login\r\n${prompt}`, "hexiao@JosephdeMacBook-Pro-2 ~ %"),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("live PTY CR bytes match a cached two-line idle prompt", () => {
|
||||
// Bash writes the PS1 newline as CR and ONLCR adds another, so the bytes
|
||||
// are `\r\r\n`. The idle-prompt cache keeps those as blank lines. Suffix
|
||||
|
||||
@@ -69,6 +69,13 @@ const CMD_PROMPT_PATTERN = /^[A-Za-z]:(?:\\[^<>"|]*)?>$/;
|
||||
// the final `]` before `$`/`#` is the delimiter.
|
||||
const POSIX_PROMPT_PATTERN = /^(?:[^\s@]+@[^\s:]+(?::[^\n\r]*)?|\[[^\s@\]]+@[^\s\]:]+(?:\s+[^\n\r]+)?\])[#$]$/;
|
||||
|
||||
// macOS ships zsh with `%n@%m %1~ %#`, which renders as
|
||||
// `user@host ~ %` / `user@host My Project %`; privileged shells end in `#`.
|
||||
// Allow spaces in the path, but keep prompt delimiters excluded so a prompt
|
||||
// followed by input (`user@host ~ % echo %`) cannot count as idle. Requiring
|
||||
// whitespace before the marker also rejects output such as `user@host 100%`.
|
||||
const ZSH_PROMPT_PATTERN = /^[^\s@]+@[^\s:]+ [^\s%#$][^%#$\r\n]* [%#]$/;
|
||||
|
||||
// Host line of a two-line bash PS1 (`\u@\h: \w` then a following `$` or `#`).
|
||||
// Same user@host[:path] class as POSIX_PROMPT_PATTERN's first alternative,
|
||||
// without a prompt marker on this line. Oh My Zsh `ys` uses a different host
|
||||
@@ -103,7 +110,8 @@ function isDefaultPosixPromptLine(line) {
|
||||
const withoutEnvPrefix = normalized.replace(/^\([^()\n\r]+\)\s+/, "");
|
||||
return (
|
||||
POSIX_PROMPT_PATTERN.test(withoutEnvPrefix) ||
|
||||
ALPINE_PROMPT_PATTERN.test(withoutEnvPrefix)
|
||||
ALPINE_PROMPT_PATTERN.test(withoutEnvPrefix) ||
|
||||
ZSH_PROMPT_PATTERN.test(withoutEnvPrefix)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -151,6 +151,64 @@ test("isDefaultPosixPromptLine matches classic user@host prompts", () => {
|
||||
assert.equal(isDefaultPosixPromptLine(""), false);
|
||||
});
|
||||
|
||||
test("isDefaultPosixPromptLine matches the macOS zsh default prompt", () => {
|
||||
for (const prompt of [
|
||||
"hexiao@JosephdeMacBook-Pro-2 ~ %",
|
||||
"hexiao@JosephdeMacBook-Pro-2 ~ % ",
|
||||
"alice@MacBook-Pro ~/src % ",
|
||||
"alice@MacBook-Pro /tmp %",
|
||||
"alice@MacBook-Pro My Project % ",
|
||||
"root@MacBook-Pro ~ # ",
|
||||
"root@MacBook-Pro My Project # ",
|
||||
"(venv) alice@MacBook-Pro ~/src % ",
|
||||
]) {
|
||||
assert.equal(isDefaultPosixPromptLine(prompt), true, prompt);
|
||||
}
|
||||
});
|
||||
|
||||
test("zsh default prompt recognition rejects pending input and non-prompt output", () => {
|
||||
for (const line of [
|
||||
"user@host ~ % echo",
|
||||
"user@host ~ %echo",
|
||||
"user@host ~ % printf busy%",
|
||||
"user@host My Project % echo %",
|
||||
"root@host My Project # echo #",
|
||||
"user@host My Project % echo #",
|
||||
"user@host My Project % echo $",
|
||||
"user@host 100%",
|
||||
"user@host 100#",
|
||||
"progress 50 %",
|
||||
"user@host %",
|
||||
"#",
|
||||
"%",
|
||||
"progress@build 50%",
|
||||
]) {
|
||||
assert.equal(isDefaultPosixPromptLine(line), false, line);
|
||||
assert.equal(extractTrailingIdlePrompt(`welcome\r\n${line}`), "", line);
|
||||
}
|
||||
});
|
||||
|
||||
test("extracts the macOS zsh default prompt and preserves trailing whitespace", () => {
|
||||
assert.equal(
|
||||
extractTrailingIdlePrompt("Last login: Mon Oct 26\r\nhexiao@MacBook-Pro ~ % "),
|
||||
"hexiao@MacBook-Pro ~ % ",
|
||||
);
|
||||
assert.equal(
|
||||
extractTrailingIdlePrompt("welcome\r\nhexiao@MacBook-Pro ~/git %"),
|
||||
"hexiao@MacBook-Pro ~/git %",
|
||||
);
|
||||
assert.equal(
|
||||
extractTrailingIdlePrompt("welcome\r\nalice@MacBook-Pro My Project % "),
|
||||
"alice@MacBook-Pro My Project % ",
|
||||
);
|
||||
assert.equal(
|
||||
extractTrailingIdlePrompt("welcome\r\nroot@MacBook-Pro My Project # "),
|
||||
"root@MacBook-Pro My Project # ",
|
||||
);
|
||||
assert.equal(extractTrailingIdlePrompt("welcome\r\nhexiao@MacBook-Pro ~ % echo"), "");
|
||||
assert.equal(extractTrailingIdlePrompt("welcome\r\nhexiao@MacBook-Pro ~ %\r\n"), "");
|
||||
});
|
||||
|
||||
test("isDefaultPosixPromptLine matches Alpine host:path prompts without a username", () => {
|
||||
for (const prompt of [
|
||||
"localhost:~#",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -0,0 +1,278 @@
|
||||
/**
|
||||
* AI Sessions Backup Bridge - durable disk mirror of the renderer's AI chat
|
||||
* session store (`netcatty_ai_sessions_v1` in localStorage).
|
||||
*
|
||||
* localStorage lives inside the Electron profile directory, so reinstalling,
|
||||
* moving or recreating the app takes the chat history with it, and in-app
|
||||
* cleanup sweeps / cap eviction can also shrink the live store. This bridge
|
||||
* keeps timestamped JSON generations under
|
||||
* `{userData}/ai-sessions-backup/` so the newest store can be restored on
|
||||
* boot when the live store is missing or empty (never on top of a non-empty
|
||||
* store — see useAIState).
|
||||
*
|
||||
* Generations are capped (default 5) and each write is atomic
|
||||
* (temp file + rename). All handlers never throw: a backup failure must not
|
||||
* break chat persistence.
|
||||
*/
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
let backupDir = null;
|
||||
let electronDialog = null;
|
||||
|
||||
const BACKUP_DIR_NAME = "ai-sessions-backup";
|
||||
const FILE_PREFIX = "ai-sessions-";
|
||||
const EXPORT_PREFIX = "export-";
|
||||
const FILE_SUFFIX = ".json";
|
||||
/** Keep at most this many rolling generations (plus dated export files). */
|
||||
const MAX_GENERATIONS = 5;
|
||||
/**
|
||||
* Hard cap for a mirrored payload. localStorage itself targets ~2 MB, but when
|
||||
* cloud AI-session sync is enabled the renderer persists the full unpruned
|
||||
* history for the disk mirror — that can exceed 4 MB. Keep a generous disk
|
||||
* ceiling so synced histories still get rolling backups, while still rejecting
|
||||
* runaway payloads.
|
||||
*/
|
||||
const MAX_BACKUP_JSON_BYTES = 64 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* The automatic mirror recovers from in-profile localStorage loss or corruption
|
||||
* (quota, empty or corrupt JSON, or accidental clear). Profile deletion or
|
||||
* recreation is out of scope; use the manual export/import buttons for that.
|
||||
*/
|
||||
function ensureBackupDir() {
|
||||
if (backupDir) return backupDir;
|
||||
try {
|
||||
let userDataPath = null;
|
||||
try {
|
||||
const { app } = require("electron");
|
||||
userDataPath = app?.getPath?.("userData") ?? null;
|
||||
} catch {
|
||||
userDataPath = null;
|
||||
}
|
||||
if (!userDataPath) return null;
|
||||
backupDir = path.join(userDataPath, BACKUP_DIR_NAME);
|
||||
if (!fs.existsSync(backupDir)) {
|
||||
fs.mkdirSync(backupDir, { recursive: true });
|
||||
}
|
||||
return backupDir;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function listGenerationFiles(dir) {
|
||||
try {
|
||||
return fs
|
||||
.readdirSync(dir)
|
||||
.filter((file) => file.startsWith(FILE_PREFIX) && file.endsWith(FILE_SUFFIX))
|
||||
.sort()
|
||||
.reverse(); // timestamped names sort lexicographically newest-first
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function listExportFiles(dir) {
|
||||
try {
|
||||
return fs
|
||||
.readdirSync(dir)
|
||||
.filter((file) => file.startsWith(EXPORT_PREFIX) && file.endsWith(FILE_SUFFIX))
|
||||
.sort()
|
||||
.reverse();
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function timestampToken(now = new Date()) {
|
||||
return `${now.toISOString().replace(/[-:]/g, "").replace(".", "-")}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write one generation atomically (temp + rename) and prune older ones.
|
||||
* Returns { ok, file } and never throws.
|
||||
*/
|
||||
function writeGeneration(json) {
|
||||
const dir = ensureBackupDir();
|
||||
if (!dir) return { ok: false, error: "no-backup-dir" };
|
||||
if (typeof json !== "string" || json.length === 0) {
|
||||
return { ok: false, error: "invalid-json" };
|
||||
}
|
||||
if (Buffer.byteLength(json, "utf-8") > MAX_BACKUP_JSON_BYTES) {
|
||||
return { ok: false, error: "payload-too-large" };
|
||||
}
|
||||
|
||||
const file = `${FILE_PREFIX}${timestampToken()}${FILE_SUFFIX}`;
|
||||
const filePath = path.join(dir, file);
|
||||
const tmpPath = `${filePath}.tmp`;
|
||||
try {
|
||||
fs.writeFileSync(tmpPath, json, "utf-8");
|
||||
fs.renameSync(tmpPath, filePath);
|
||||
} catch (error) {
|
||||
try {
|
||||
fs.unlinkSync(tmpPath);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return { ok: false, error: String(error) };
|
||||
}
|
||||
|
||||
// Keep only the newest MAX_GENERATIONS generations.
|
||||
const generations = listGenerationFiles(dir);
|
||||
for (const stale of generations.slice(MAX_GENERATIONS)) {
|
||||
try {
|
||||
fs.unlinkSync(path.join(dir, stale));
|
||||
} catch {
|
||||
// skip
|
||||
}
|
||||
}
|
||||
return { ok: true, file };
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the newest usable session array. Invalid generations are skipped and
|
||||
* older ones are tried, so one bad write never blocks a restore.
|
||||
*/
|
||||
function readLatestGeneration() {
|
||||
const dir = ensureBackupDir();
|
||||
if (!dir) return { ok: false };
|
||||
for (const file of listGenerationFiles(dir)) {
|
||||
try {
|
||||
const json = fs.readFileSync(path.join(dir, file), "utf-8");
|
||||
const parsed = JSON.parse(json);
|
||||
// Structurally unusable payloads can fall back to an older session array.
|
||||
if (!Array.isArray(parsed)) continue;
|
||||
// An empty array is an intentional wipe and must not resurrect older chats.
|
||||
if (parsed.length === 0) return { ok: true, json, file };
|
||||
if (!parsed.some((entry) => (
|
||||
entry !== null && typeof entry === "object"
|
||||
&& typeof entry.id === "string"
|
||||
&& typeof entry.title === "string"
|
||||
&& typeof entry.agentId === "string"
|
||||
&& entry.scope !== null && typeof entry.scope === "object"
|
||||
&& typeof entry.scope.type === "string"
|
||||
&& Array.isArray(entry.messages)
|
||||
&& entry.messages.every((message) => (
|
||||
message !== null && typeof message === "object"
|
||||
&& typeof message.id === "string"
|
||||
&& typeof message.role === "string"
|
||||
&& typeof message.content === "string"
|
||||
&& typeof message.timestamp === "number" && Number.isFinite(message.timestamp)
|
||||
))
|
||||
&& typeof entry.updatedAt === "number" && Number.isFinite(entry.updatedAt)
|
||||
))) continue;
|
||||
return { ok: true, json, file };
|
||||
} catch {
|
||||
// try the next older generation
|
||||
}
|
||||
}
|
||||
return { ok: false };
|
||||
}
|
||||
|
||||
async function exportToFile(json) {
|
||||
const dir = ensureBackupDir();
|
||||
if (!dir) return { success: false, error: "no-backup-dir" };
|
||||
if (typeof json !== "string" || json.length === 0) {
|
||||
return { success: false, error: "invalid-json" };
|
||||
}
|
||||
try {
|
||||
let result;
|
||||
if (electronDialog?.showSaveDialog) {
|
||||
result = await electronDialog.showSaveDialog({
|
||||
defaultPath: `${EXPORT_PREFIX}${timestampToken()}${FILE_SUFFIX}`,
|
||||
filters: [
|
||||
{ name: "JSON", extensions: ["json"] },
|
||||
{ name: "All Files", extensions: ["*"] },
|
||||
],
|
||||
});
|
||||
if (result.canceled || !result.filePath) {
|
||||
return { success: false, canceled: true };
|
||||
}
|
||||
} else {
|
||||
// Fallback when no dialog is available (tests / unusual hosts): fixed
|
||||
// path next to the rolling generations.
|
||||
return await writeExportFixed(dir, json);
|
||||
}
|
||||
await fs.promises.writeFile(result.filePath, json, "utf-8");
|
||||
return { success: true, filePath: result.filePath };
|
||||
} catch (error) {
|
||||
return { success: false, error: String(error) };
|
||||
}
|
||||
}
|
||||
|
||||
async function writeExportFixed(dir, json) {
|
||||
const filePath = path.join(dir, `${EXPORT_PREFIX}${timestampToken()}${FILE_SUFFIX}`);
|
||||
try {
|
||||
await fs.promises.writeFile(filePath, json, "utf-8");
|
||||
return { success: true, filePath };
|
||||
} catch (error) {
|
||||
return { success: false, error: String(error) };
|
||||
}
|
||||
}
|
||||
|
||||
async function importFromFile() {
|
||||
try {
|
||||
if (electronDialog?.showOpenDialog) {
|
||||
const result = await electronDialog.showOpenDialog({
|
||||
properties: ["openFile"],
|
||||
filters: [
|
||||
{ name: "JSON", extensions: ["json"] },
|
||||
{ name: "All Files", extensions: ["*"] },
|
||||
],
|
||||
});
|
||||
if (result.canceled || !result.filePaths || result.filePaths.length === 0) {
|
||||
return { success: false, canceled: true };
|
||||
}
|
||||
const json = await fs.promises.readFile(result.filePaths[0], "utf-8");
|
||||
return { success: true, json };
|
||||
}
|
||||
// No dialog available: import the newest fixed-path export instead of
|
||||
// failing outright.
|
||||
const dir = ensureBackupDir();
|
||||
if (!dir) return { success: false, error: "no-backup-dir" };
|
||||
const exports = listExportFiles(dir);
|
||||
if (exports.length === 0) return { success: false, error: "no-export-file" };
|
||||
const json = await fs.promises.readFile(path.join(dir, exports[0]), "utf-8");
|
||||
return { success: true, json };
|
||||
} catch (error) {
|
||||
return { success: false, error: String(error) };
|
||||
}
|
||||
}
|
||||
|
||||
function registerHandlers(ipcMain) {
|
||||
ipcMain.handle("netcatty:aiSessionsBackup:write", (_event, payload) =>
|
||||
writeGeneration(payload?.json));
|
||||
ipcMain.handle("netcatty:aiSessionsBackup:readLatest", () => readLatestGeneration());
|
||||
ipcMain.handle("netcatty:aiSessionsBackup:export", (_event, payload) =>
|
||||
exportToFile(payload?.json));
|
||||
ipcMain.handle("netcatty:aiSessionsBackup:import", () => importFromFile());
|
||||
}
|
||||
|
||||
const __internals = {
|
||||
BACKUP_DIR_NAME,
|
||||
FILE_PREFIX,
|
||||
EXPORT_PREFIX,
|
||||
MAX_GENERATIONS,
|
||||
MAX_BACKUP_JSON_BYTES,
|
||||
listGenerationFiles,
|
||||
listExportFiles,
|
||||
timestampToken,
|
||||
writeGeneration,
|
||||
readLatestGeneration,
|
||||
writeExportFixed,
|
||||
// Tests point the bridge at a temp directory without Electron.
|
||||
_setBackupDir(dir) {
|
||||
backupDir = dir;
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
init(deps) {
|
||||
electronDialog = deps?.electronModule?.dialog ?? null;
|
||||
ensureBackupDir();
|
||||
},
|
||||
registerHandlers,
|
||||
__internals,
|
||||
};
|
||||
@@ -0,0 +1,172 @@
|
||||
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 bridge = require("./aiSessionsBackupBridge.cjs");
|
||||
const session = { id: "s1", title: "Session", agentId: "agent", scope: { type: "global" }, messages: [], updatedAt: 1 };
|
||||
|
||||
function makeTempDir() {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-ai-backup-"));
|
||||
}
|
||||
|
||||
test("writeGeneration writes atomically and rotates generations to the cap", () => {
|
||||
const dir = makeTempDir();
|
||||
bridge.__internals._setBackupDir(dir);
|
||||
try {
|
||||
const { MAX_GENERATIONS } = bridge.__internals;
|
||||
for (let i = 0; i < MAX_GENERATIONS + 2; i++) {
|
||||
const result = bridge.__internals.writeGeneration(JSON.stringify({ gen: i }));
|
||||
assert.equal(result.ok, true);
|
||||
// Small delay so timestamped names strictly increase.
|
||||
const stamp = bridge.__internals.timestampToken();
|
||||
while (bridge.__internals.timestampToken() === stamp) {
|
||||
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 5);
|
||||
}
|
||||
}
|
||||
const generations = bridge.__internals.listGenerationFiles(dir);
|
||||
assert.equal(generations.length, MAX_GENERATIONS);
|
||||
// Newest generation wins the lexicographic sort.
|
||||
assert.deepEqual(JSON.parse(fs.readFileSync(path.join(dir, generations[0]), "utf-8")), {
|
||||
gen: MAX_GENERATIONS + 1,
|
||||
});
|
||||
// No temp files left behind by the atomic write.
|
||||
assert.deepEqual(fs.readdirSync(dir).filter((f) => f.endsWith(".tmp")), []);
|
||||
} finally {
|
||||
bridge.__internals._setBackupDir(null);
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("readLatestGeneration skips corrupt generations and reads the newest parseable one", () => {
|
||||
const dir = makeTempDir();
|
||||
bridge.__internals._setBackupDir(dir);
|
||||
try {
|
||||
fs.writeFileSync(path.join(dir, "ai-sessions-20260101T000000-000Z.json"), "not json");
|
||||
fs.writeFileSync(
|
||||
path.join(dir, "ai-sessions-20260102T000000-000Z.json"),
|
||||
JSON.stringify([session]),
|
||||
);
|
||||
const latest = bridge.__internals.readLatestGeneration();
|
||||
assert.equal(latest.ok, true);
|
||||
assert.deepEqual(JSON.parse(latest.json), [session]);
|
||||
// A corrupt-only directory reports failure instead of throwing.
|
||||
const corruptOnly = makeTempDir();
|
||||
bridge.__internals._setBackupDir(corruptOnly);
|
||||
try {
|
||||
fs.writeFileSync(path.join(corruptOnly, "ai-sessions-20260103T000000-000Z.json"), "{oops");
|
||||
assert.equal(bridge.__internals.readLatestGeneration().ok, false);
|
||||
} finally {
|
||||
bridge.__internals._setBackupDir(dir);
|
||||
fs.rmSync(corruptOnly, { recursive: true, force: true });
|
||||
}
|
||||
} finally {
|
||||
bridge.__internals._setBackupDir(null);
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("readLatestGeneration skips non-array generations", () => {
|
||||
const dir = makeTempDir();
|
||||
bridge.__internals._setBackupDir(dir);
|
||||
try {
|
||||
const olderFile = "ai-sessions-20260101T000000-000Z.json";
|
||||
const sessions = [session];
|
||||
fs.writeFileSync(path.join(dir, olderFile), JSON.stringify(sessions));
|
||||
fs.writeFileSync(path.join(dir, "ai-sessions-20260102T000000-000Z.json"), JSON.stringify({ sessions }));
|
||||
|
||||
const latest = bridge.__internals.readLatestGeneration();
|
||||
assert.equal(latest.ok, true);
|
||||
assert.equal(latest.file, olderFile);
|
||||
assert.deepEqual(JSON.parse(latest.json), sessions);
|
||||
} finally {
|
||||
bridge.__internals._setBackupDir(null);
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("readLatestGeneration skips arrays without usable sessions", () => {
|
||||
const dir = makeTempDir();
|
||||
bridge.__internals._setBackupDir(dir);
|
||||
try {
|
||||
const olderFile = "ai-sessions-20260101T000000-000Z.json";
|
||||
fs.writeFileSync(path.join(dir, olderFile), JSON.stringify([session]));
|
||||
fs.writeFileSync(path.join(dir, "ai-sessions-20260102T000000-000Z.json"), JSON.stringify([{ id: "bad" }]));
|
||||
|
||||
const latest = bridge.__internals.readLatestGeneration();
|
||||
assert.equal(latest.ok, true);
|
||||
assert.equal(latest.file, olderFile);
|
||||
assert.deepEqual(JSON.parse(latest.json), [session]);
|
||||
} finally {
|
||||
bridge.__internals._setBackupDir(null);
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("readLatestGeneration treats an empty generation as an authoritative wipe", () => {
|
||||
const dir = makeTempDir();
|
||||
bridge.__internals._setBackupDir(dir);
|
||||
try {
|
||||
const newestFile = "ai-sessions-20260102T000000-000Z.json";
|
||||
fs.writeFileSync(path.join(dir, "ai-sessions-20260101T000000-000Z.json"), JSON.stringify([session]));
|
||||
fs.writeFileSync(path.join(dir, newestFile), "[]");
|
||||
|
||||
const latest = bridge.__internals.readLatestGeneration();
|
||||
assert.equal(latest.ok, true);
|
||||
assert.equal(latest.file, newestFile);
|
||||
assert.deepEqual(JSON.parse(latest.json), []);
|
||||
} finally {
|
||||
bridge.__internals._setBackupDir(null);
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("readLatestGeneration skips sessions with malformed messages", () => {
|
||||
const dir = makeTempDir();
|
||||
bridge.__internals._setBackupDir(dir);
|
||||
try {
|
||||
const olderFile = "ai-sessions-20260101T000000-000Z.json";
|
||||
const newestFile = "ai-sessions-20260102T000000-000Z.json";
|
||||
fs.writeFileSync(path.join(dir, olderFile), JSON.stringify([session]));
|
||||
for (const message of [null, {}, { id: "m", role: "user", content: "hello", timestamp: null }]) {
|
||||
fs.writeFileSync(path.join(dir, newestFile), JSON.stringify([{ ...session, messages: [message] }]));
|
||||
const latest = bridge.__internals.readLatestGeneration();
|
||||
assert.equal(latest.ok, true);
|
||||
assert.equal(latest.file, olderFile);
|
||||
assert.deepEqual(JSON.parse(latest.json), [session]);
|
||||
}
|
||||
} finally {
|
||||
bridge.__internals._setBackupDir(null);
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("writeGeneration rejects invalid or oversized payloads", () => {
|
||||
const dir = makeTempDir();
|
||||
bridge.__internals._setBackupDir(dir);
|
||||
try {
|
||||
assert.equal(bridge.__internals.writeGeneration("").ok, false);
|
||||
assert.equal(bridge.__internals.writeGeneration(null).ok, false);
|
||||
const oversized = "x".repeat(bridge.__internals.MAX_BACKUP_JSON_BYTES + 1);
|
||||
assert.equal(bridge.__internals.writeGeneration(oversized).ok, false);
|
||||
assert.deepEqual(bridge.__internals.listGenerationFiles(dir), []);
|
||||
} finally {
|
||||
bridge.__internals._setBackupDir(null);
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("export falls back to a fixed path when no dialog is available", async () => {
|
||||
const dir = makeTempDir();
|
||||
bridge.__internals._setBackupDir(dir);
|
||||
try {
|
||||
const result = await bridge.__internals.writeExportFixed(dir, JSON.stringify([{ id: "s1" }]));
|
||||
assert.equal(result.success, true);
|
||||
assert.ok(path.basename(result.filePath).startsWith("export-"));
|
||||
assert.equal(fs.existsSync(result.filePath), true);
|
||||
} finally {
|
||||
bridge.__internals._setBackupDir(null);
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -439,6 +439,7 @@ function ensureTrayPanelWindow() {
|
||||
// see-through. On Windows, disable OS rounding so it does not stack under
|
||||
// the CSS radius (#2505 / Electron #46468).
|
||||
...windowsCssRoundedOverlayChromeOptions(),
|
||||
...(process.platform === "darwin" ? { visualEffectState: "active" } : {}),
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, "../preload.cjs"),
|
||||
contextIsolation: true,
|
||||
@@ -450,6 +451,8 @@ function ensureTrayPanelWindow() {
|
||||
},
|
||||
});
|
||||
|
||||
require("./windowManager.cjs").applyTrayPanelBackgroundBlur(trayPanelWindow);
|
||||
|
||||
trayPanelWindow.webContents.on("console-message", (_event, level, message) => {
|
||||
// Forward renderer logs to main process output for easy debugging.
|
||||
console.log(`[TrayPanel:renderer:${level}] ${message}`);
|
||||
|
||||
@@ -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]]);
|
||||
});
|
||||
@@ -432,6 +432,7 @@ function applyWindowOpacity(opacity) {
|
||||
if (settingsWindow && !settingsWindow.isDestroyed()) {
|
||||
applyWindowOpacityToWindow(settingsWindow);
|
||||
}
|
||||
applyTrayPanelBackgroundBlur(getGlobalShortcutBridge().getTrayPanelWindow?.());
|
||||
return currentWindowOpacity;
|
||||
}
|
||||
|
||||
@@ -441,6 +442,15 @@ function isWindowBackgroundBlurActive() {
|
||||
&& currentWindowOpacity < 1;
|
||||
}
|
||||
|
||||
function applyTrayPanelBackgroundBlur(win) {
|
||||
if (process.platform !== "darwin" || !win || win.isDestroyed?.()) return;
|
||||
try {
|
||||
win.setVibrancy?.(isWindowBackgroundBlurActive() ? "hud" : null);
|
||||
} catch {
|
||||
// ignore platform-specific chrome failures
|
||||
}
|
||||
}
|
||||
|
||||
function applyWindowBackgroundChromeToWindow(win, options = {}) {
|
||||
if (!win || win.isDestroyed?.()) return;
|
||||
try {
|
||||
@@ -492,6 +502,7 @@ function applyWindowBackgroundBlur(enabled) {
|
||||
if (settingsWindow && !settingsWindow.isDestroyed()) {
|
||||
applyWindowOpacityToWindow(settingsWindow);
|
||||
}
|
||||
applyTrayPanelBackgroundBlur(getGlobalShortcutBridge().getTrayPanelWindow?.());
|
||||
return supported;
|
||||
}
|
||||
|
||||
@@ -1662,4 +1673,5 @@ module.exports = {
|
||||
clampWindowOpacity,
|
||||
applyWindowOpacity,
|
||||
applyWindowOpacityToWindow,
|
||||
applyTrayPanelBackgroundBlur,
|
||||
};
|
||||
@@ -5,7 +5,7 @@ const { createRequire } = require('node:module');
|
||||
const vm = require('node:vm');
|
||||
const test = require('node:test');
|
||||
|
||||
function loadWindowManager(platform) {
|
||||
function loadWindowManager(platform, trayWindow = null) {
|
||||
const filename = path.resolve(__dirname, '../windowManager.cjs');
|
||||
const loadedModule = { exports: {} };
|
||||
const context = vm.createContext({
|
||||
@@ -19,7 +19,11 @@ function loadWindowManager(platform) {
|
||||
`(function(exports, require, module, __filename, __dirname) { ${fs.readFileSync(filename, 'utf8')}\n})`,
|
||||
context,
|
||||
);
|
||||
load(loadedModule.exports, createRequire(filename), loadedModule, filename, path.dirname(filename));
|
||||
const originalRequire = createRequire(filename);
|
||||
const requireWithTray = (name) => name === './globalShortcutBridge.cjs'
|
||||
? { getTrayPanelWindow: () => trayWindow }
|
||||
: originalRequire(name);
|
||||
load(loadedModule.exports, requireWithTray, loadedModule, filename, path.dirname(filename));
|
||||
const handlers = new Map();
|
||||
loadedModule.exports.registerWindowHandlers({
|
||||
handle: (channel, handler) => handlers.set(channel, handler),
|
||||
@@ -35,9 +39,61 @@ function loadWindowManager(platform) {
|
||||
};
|
||||
loadedModule.exports.registerMainWindow(win);
|
||||
const invoke = (name, value) => handlers.get(`netcatty:${name}`)({}, value);
|
||||
return { invoke, state };
|
||||
return { invoke, state, windowManager: loadedModule.exports };
|
||||
}
|
||||
|
||||
test('macOS tray blur follows the saved preference and opacity without repainting its clear backing', () => {
|
||||
const materials = [];
|
||||
const backgrounds = [];
|
||||
const opacities = [];
|
||||
const trayWindow = {
|
||||
isDestroyed: () => false,
|
||||
setVibrancy: (value) => materials.push(value),
|
||||
setBackgroundColor: (value) => backgrounds.push(value),
|
||||
setOpacity: (value) => opacities.push(value),
|
||||
};
|
||||
const { invoke, windowManager } = loadWindowManager('darwin', trayWindow);
|
||||
invoke('setWindowOpacity', 0.7);
|
||||
invoke('setWindowBackgroundBlur', true);
|
||||
assert.equal(materials.at(-1), 'hud');
|
||||
invoke('setWindowOpacity', 1);
|
||||
assert.equal(materials.at(-1), null);
|
||||
invoke('setWindowOpacity', 0.85);
|
||||
assert.equal(materials.at(-1), 'hud');
|
||||
invoke('setWindowBackgroundBlur', false);
|
||||
assert.equal(materials.at(-1), null);
|
||||
assert.deepEqual(backgrounds, []);
|
||||
assert.deepEqual(opacities, []);
|
||||
assert.equal(windowManager.getAppContentWindows().includes(trayWindow), false);
|
||||
});
|
||||
|
||||
test('a tray created after blur is enabled receives the current native material', () => {
|
||||
const { invoke, windowManager } = loadWindowManager('darwin');
|
||||
invoke('setWindowOpacity', 0.7);
|
||||
invoke('setWindowBackgroundBlur', true);
|
||||
const materials = [];
|
||||
windowManager.applyTrayPanelBackgroundBlur({
|
||||
isDestroyed: () => false,
|
||||
setVibrancy: (value) => materials.push(value),
|
||||
});
|
||||
assert.deepEqual(materials, ['hud']);
|
||||
windowManager.applyTrayPanelBackgroundBlur({
|
||||
isDestroyed: () => true,
|
||||
setVibrancy: () => assert.fail('destroyed tray must not be updated'),
|
||||
});
|
||||
});
|
||||
|
||||
test('tray vibrancy is untouched on Windows and Linux', () => {
|
||||
for (const platform of ['win32', 'linux']) {
|
||||
const { invoke } = loadWindowManager(platform, {
|
||||
isDestroyed: () => false,
|
||||
setVibrancy: () => assert.fail('non-macOS tray must not use vibrancy'),
|
||||
});
|
||||
invoke('setWindowOpacity', 0.7);
|
||||
invoke('setWindowBackgroundBlur', true);
|
||||
}
|
||||
});
|
||||
|
||||
test('macOS blur keeps native opacity at one and restores normal opacity on disable', () => {
|
||||
const { invoke, state } = loadWindowManager('darwin');
|
||||
assert.equal(state.background, '#123456');
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -545,6 +545,9 @@ function createBridgeRegistrar(context) {
|
||||
aiBridge.registerHandlers(ipcMain);
|
||||
httpNetworkProxyBridge.registerHandlers(ipcMain, electronModule);
|
||||
crashLogBridge.registerHandlers(ipcMain);
|
||||
const aiSessionsBackupBridge = require("../bridges/aiSessionsBackupBridge.cjs");
|
||||
aiSessionsBackupBridge.init({ electronModule });
|
||||
aiSessionsBackupBridge.registerHandlers(ipcMain);
|
||||
vaultBackupBridge.registerHandlers(ipcMain, electronModule);
|
||||
appLockController?.registerHandlers?.(ipcMain);
|
||||
|
||||
|
||||
@@ -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 {
|
||||
@@ -342,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));
|
||||
});
|
||||
@@ -11,6 +11,12 @@ const CHANNELS = Object.freeze({ request: "netcatty:plugins:ai-host-request", re
|
||||
// supplied by the shared contract task; no provider config or key crosses it.
|
||||
class PluginAITrustedHostAdapter {
|
||||
constructor(options) {
|
||||
// Separate trusted Agent transport instances reuse the same sender/nonce
|
||||
// guards. These construction options never come from a plugin request.
|
||||
this.channels = options.channels ?? CHANNELS;
|
||||
this.requestDefinition = options.requestDefinition ?? "PluginAIHostRequest";
|
||||
this.responseDefinition = options.responseDefinition ?? "PluginAIHostResponse";
|
||||
this.maxEnvelopeBytes = Math.min(options.maxEnvelopeBytes ?? 24_576, 1_048_576);
|
||||
this.getWindow = options.getWindow;
|
||||
this.isLocked = options.isLocked ?? (() => true);
|
||||
this.dialog = options.dialog;
|
||||
@@ -25,11 +31,11 @@ class PluginAITrustedHostAdapter {
|
||||
|
||||
registerIpc(ipcMain) {
|
||||
if (this.ipcRegistration) throw new Error("Trusted AI host IPC is already registered");
|
||||
this.validateRequest ??= createDefinitionValidator("PluginAIHostRequest");
|
||||
this.validateResponse ??= createDefinitionValidator("PluginAIHostResponse");
|
||||
ipcMain.handle(CHANNELS.ready, (event, ready) => this.setReady(event.sender, ready));
|
||||
ipcMain.handle(CHANNELS.response, (event, response) => this.respond(event.sender, response));
|
||||
this.ipcRegistration = { dispose: () => { ipcMain.removeHandler(CHANNELS.ready); ipcMain.removeHandler(CHANNELS.response); } };
|
||||
this.validateRequest ??= createDefinitionValidator(this.requestDefinition);
|
||||
this.validateResponse ??= createDefinitionValidator(this.responseDefinition);
|
||||
ipcMain.handle(this.channels.ready, (event, ready) => this.setReady(event.sender, ready));
|
||||
ipcMain.handle(this.channels.response, (event, response) => this.respond(event.sender, response));
|
||||
this.ipcRegistration = { dispose: () => { ipcMain.removeHandler(this.channels.ready); ipcMain.removeHandler(this.channels.response); } };
|
||||
return this.ipcRegistration;
|
||||
}
|
||||
|
||||
@@ -68,7 +74,7 @@ class PluginAITrustedHostAdapter {
|
||||
|
||||
#envelope(kind, payload, runtimeId) {
|
||||
const request = { requestId: randomUUID(), actionToken: randomBytes(24).toString("base64url"), kind, payload, runtimeId };
|
||||
assertPluginJsonValue(request, { maxBytes: 24_576 });
|
||||
assertPluginJsonValue(request, { maxBytes: this.maxEnvelopeBytes });
|
||||
if (!this.validateRequest?.(request)) throw fail("internal", "Trusted AI host request is invalid");
|
||||
return request;
|
||||
}
|
||||
@@ -80,7 +86,7 @@ class PluginAITrustedHostAdapter {
|
||||
|
||||
#cleanup(kind, payload, runtimeId, sender = this.owner) {
|
||||
if (!sender || sender !== this.getWindow?.()?.webContents || sender.isDestroyed?.()) return;
|
||||
try { sender.send(CHANNELS.request, this.#envelope(kind, payload, runtimeId)); } catch {}
|
||||
try { sender.send(this.channels.request, this.#envelope(kind, payload, runtimeId)); } catch {}
|
||||
}
|
||||
|
||||
async #request(kind, payload, context) {
|
||||
@@ -102,14 +108,14 @@ class PluginAITrustedHostAdapter {
|
||||
this.pending.set(request.requestId, entry);
|
||||
context.signal?.addEventListener("abort", entry.abort, { once: true });
|
||||
if (context.signal?.aborted) { entry.abort(); return; }
|
||||
try { sender.send(CHANNELS.request, request); }
|
||||
try { sender.send(this.channels.request, request); }
|
||||
catch { entry.finish(null, fail("unavailable", "Trusted AI host disconnected")); }
|
||||
});
|
||||
}
|
||||
|
||||
async respond(sender, response) {
|
||||
if (sender !== this.#owner()) throw fail("permissionDenied", "AI host response sender is invalid");
|
||||
assertPluginJsonValue(response, { maxBytes: 24_576 });
|
||||
assertPluginJsonValue(response, { maxBytes: this.maxEnvelopeBytes });
|
||||
if (!this.validateResponse?.(response)) throw fail("invalidArgument", "AI host response is invalid");
|
||||
const entry = this.pending.get(response.requestId);
|
||||
if (!entry || entry.sender !== sender || entry.generation !== this.generation) return false;
|
||||
@@ -126,6 +132,18 @@ class PluginAITrustedHostAdapter {
|
||||
} catch { entry.finish(null, fail("cancelled", "Trusted AI host response became stale")); return false; }
|
||||
}
|
||||
|
||||
// Host-only, canonical typed request path for a separate Agent instance.
|
||||
requestAgent(kind, payload, context) {
|
||||
if (this.requestDefinition !== "PluginAgentHostRequest" || !kind.startsWith("agent.")) throw fail("unsupported", "Agent host route unavailable");
|
||||
return this.#request(kind, payload, context);
|
||||
}
|
||||
|
||||
getSenderId() { return this.#owner().id; }
|
||||
releaseAgentChat(chatSessionId, runtimeId) {
|
||||
if (this.requestDefinition !== "PluginAgentHostRequest" || !/^plugin-agent:[0-9a-f-]{36}$/u.test(chatSessionId)) throw fail("invalidArgument", "Agent conversation unavailable");
|
||||
this.#cleanup("agent.close", { chatSessionId }, runtimeId);
|
||||
}
|
||||
|
||||
async selectModel(context) {
|
||||
const selected = await this.#request("model.select", {}, context);
|
||||
if (typeof selected?.hostHandle !== "string" || typeof selected.label !== "string" || selected.label.length > 160) throw fail("internal", "Selected model is invalid");
|
||||
|
||||
@@ -188,6 +188,7 @@ class PluginFilesystemBroker {
|
||||
constructor(options = {}) {
|
||||
this.quotaManager = options.quotaManager ?? null;
|
||||
this.protectedDirectories = options.protectedDirectories ?? [];
|
||||
this.protectedReadDirectories = options.protectedReadDirectories ?? [];
|
||||
this.protectedFiles = options.protectedFiles ?? [];
|
||||
this.fileSystem = options.fileSystem ?? fsp;
|
||||
this.openDirectoryHandle = options.openDirectoryHandle ?? null;
|
||||
@@ -241,8 +242,10 @@ class PluginFilesystemBroker {
|
||||
|
||||
async readFile(params, context) {
|
||||
const value = assertReadParams(params);
|
||||
await this.#assertReadable(value.path);
|
||||
assertAuthorizedPath(context, "filesystem.read", value.path);
|
||||
const { canonical, stats } = await resolveExistingPath(value.path, this.fileSystem);
|
||||
await this.#assertReadable(canonical);
|
||||
assertAuthorizedPath(context, "filesystem.read", canonical);
|
||||
if (!stats.isFile()) throw invalidArgument("Plugin filesystem read target is not a file");
|
||||
if (stats.size > value.maxBytes) {
|
||||
@@ -259,7 +262,9 @@ class PluginFilesystemBroker {
|
||||
if (openedStats.size > value.maxBytes) {
|
||||
throw new PluginRpcError(RPC_ERRORS.resourceExhausted, "Plugin filesystem file changed or is too large");
|
||||
}
|
||||
assertAuthorizedPath(context, "filesystem.read", await this.fileSystem.realpath(canonical));
|
||||
const current = await this.fileSystem.realpath(canonical);
|
||||
await this.#assertReadable(current);
|
||||
assertAuthorizedPath(context, "filesystem.read", current);
|
||||
const bytes = await readBoundedFileHandle(handle, value.maxBytes);
|
||||
this.quotaManager?.chargeBytes(context.runtimeId, "filesystem", bytes.byteLength);
|
||||
await context.assertActive();
|
||||
@@ -290,6 +295,20 @@ class PluginFilesystemBroker {
|
||||
}
|
||||
}
|
||||
|
||||
async #assertReadable(candidate) {
|
||||
for (const directory of this.protectedReadDirectories) {
|
||||
const variants = [path.resolve(directory)];
|
||||
try { variants.push(await this.fileSystem.realpath(directory)); }
|
||||
catch (error) { if (error?.code !== "ENOENT") throw error; }
|
||||
for (const parent of variants) {
|
||||
const relative = path.relative(parent, candidate);
|
||||
if (relative === "" || (!path.isAbsolute(relative) && relative !== ".." && !relative.startsWith(`..${path.sep}`))) {
|
||||
throw new PluginRpcError(RPC_ERRORS.permissionDenied, "Private host conversation storage is protected");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async writeFile(params, context) {
|
||||
const value = assertSecureWriteMode(assertWriteParams(params));
|
||||
await this.#assertWritable(path.resolve(value.path));
|
||||
@@ -323,8 +342,10 @@ class PluginFilesystemBroker {
|
||||
|
||||
async stat(params, context) {
|
||||
const value = assertPathParams(params);
|
||||
await this.#assertReadable(value.path);
|
||||
assertAuthorizedPath(context, "filesystem.read", value.path);
|
||||
const { canonical, stats } = await resolveExistingPath(value.path, this.fileSystem);
|
||||
await this.#assertReadable(canonical);
|
||||
assertAuthorizedPath(context, "filesystem.read", canonical);
|
||||
await context.assertActive();
|
||||
return {
|
||||
@@ -336,8 +357,10 @@ class PluginFilesystemBroker {
|
||||
|
||||
async readDirectory(params, context) {
|
||||
const value = this.validateDirectory(params);
|
||||
await this.#assertReadable(value.path);
|
||||
assertAuthorizedPath(context, "filesystem.read", value.path);
|
||||
const { canonical, stats } = await resolveExistingPath(value.path, this.fileSystem);
|
||||
await this.#assertReadable(canonical);
|
||||
assertAuthorizedPath(context, "filesystem.read", canonical);
|
||||
if (!stats.isDirectory()) throw invalidArgument("Plugin filesystem directory target is not a directory");
|
||||
const directory = await this.openDirectoryHandle(canonical, { signal: context.signal });
|
||||
|
||||
@@ -93,6 +93,24 @@ test("filesystem broker uses canonical authorization for bounded read, write, st
|
||||
]);
|
||||
});
|
||||
|
||||
test("host conversation roots are unreadable through explicit filesystem grants, including symlink aliases", async (context) => {
|
||||
const root = createRoot(context); const privateRoot = path.join(root, "agent-chats");
|
||||
await fsp.mkdir(privateRoot); const file = path.join(privateRoot, "synthetic.json"); await fsp.writeFile(file, "PRIVATE_OTHER_SOURCE_HISTORY");
|
||||
const broker = new PluginFilesystemBroker({ protectedReadDirectories: [privateRoot], openDirectoryHandle: openTestDirectoryHandle });
|
||||
const authorize = (candidate, kind = "exact") => runtimeContext(broker.describeReadAuthorization({ path: candidate }, kind));
|
||||
await assert.rejects(broker.readFile({ path: file }, authorize(file)), (error) => error.code === RPC_ERRORS.permissionDenied);
|
||||
await assert.rejects(broker.stat({ path: privateRoot }, authorize(privateRoot)), (error) => error.code === RPC_ERRORS.permissionDenied);
|
||||
await assert.rejects(broker.readDirectory({ path: privateRoot }, authorize(privateRoot, "directory")), (error) => error.code === RPC_ERRORS.permissionDenied);
|
||||
if (process.platform !== "win32") {
|
||||
const alias = path.join(root, "alias.json"); await fsp.symlink(file, alias);
|
||||
await assert.rejects(broker.readFile({ path: alias }, authorize(root, "directory")), (error) => error.code === RPC_ERRORS.permissionDenied);
|
||||
const directoryAlias = path.join(root, "directory-alias"); await fsp.symlink(privateRoot, directoryAlias);
|
||||
await assert.rejects(broker.readDirectory({ path: directoryAlias }, authorize(root, "directory")), (error) => error.code === RPC_ERRORS.permissionDenied);
|
||||
}
|
||||
const ordinary = path.join(root, "ordinary.txt"); await fsp.writeFile(ordinary, "Synthetic public file");
|
||||
assert.deepEqual(await broker.readFile({ path: ordinary }, authorize(ordinary)), { data: "Synthetic public file" });
|
||||
});
|
||||
|
||||
test("filesystem authorization removes trailing directory separators before permission", async (context) => {
|
||||
const root = createRoot(context);
|
||||
const requestedPath = `${root}${path.sep}`;
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,6 @@
|
||||
"use strict";
|
||||
require("./agent/delegatedScope.test.cjs");
|
||||
require("./agent/conversationStore.test.cjs");
|
||||
require("./agent/contract.test.cjs");
|
||||
require("./agent/mcpDelegation.test.cjs");
|
||||
require("./agent/agentService.test.cjs");
|
||||
Loaded 100 of 152 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user