mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-10-31 10:52:38 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
677aac33b1 | ||
|
|
24254ed737 | ||
|
|
bbcf9c01c3 | ||
|
|
ba09461676 | ||
|
|
54b6cd401f | ||
|
|
9e7c9df58e | ||
|
|
824c3838e9 | ||
|
|
35b65ef1d7 | ||
|
|
07eb68c5e7 | ||
|
|
d81896b0d3 | ||
|
|
e1cb95ee87 | ||
|
|
ee5a4a4067 | ||
|
|
823db94cf3 | ||
|
|
174c525cd4 | ||
|
|
2278681787 | ||
|
|
31d61e3040 | ||
|
|
52d4af2a86 | ||
|
|
dc6e645fab | ||
|
|
41313293e1 | ||
|
|
730fa643ba | ||
|
|
d16e21c848 | ||
|
|
e6dd68b209 | ||
|
|
eb6772355e | ||
|
|
267bcbfb8b | ||
|
|
17c48b4d79 | ||
|
|
8eefd9de96 | ||
|
|
739cab2965 | ||
|
|
cecfa3a47e | ||
|
|
0a590b4ebc | ||
|
|
69c2cc8d1f | ||
|
|
ccfe41f39a | ||
|
|
d5cb8cfd3b | ||
|
|
2a9c310498 | ||
|
|
9b853130ca | ||
|
|
e3cc23bca8 |
No files matched your search
@@ -13,6 +13,7 @@ import { TooltipProvider } from './components/ui/tooltip';
|
||||
import { ScriptAutomationRoot } from './components/scripts/ScriptAutomationRoot';
|
||||
import { ExternalMcpApprovalsHost } from './components/ai/ExternalMcpApprovalsHost';
|
||||
import { PluginHostInteractionHost } from './components/plugins/PluginHostInteractionHost';
|
||||
import { PluginHostActionHost } from './components/plugins/PluginHostActionHost';
|
||||
import { PluginAuthenticationHost } from './components/plugins/PluginAuthenticationHost';
|
||||
import { MultilinePasteConfirmHost } from './components/terminal/MultilinePasteConfirmDialog';
|
||||
import { useExternalMcpGrantPersister } from './components/ai/useExternalMcpGrantPersister';
|
||||
@@ -92,6 +93,7 @@ function AppWithProviders({ settings, appLock }: { settings: SettingsState; appL
|
||||
<VaultPublisher>
|
||||
<SessionPublisher persistSessionRestore={!isPeerSessionWindow}>
|
||||
<App />
|
||||
<PluginHostActionHost />
|
||||
</SessionPublisher>
|
||||
</VaultPublisher>
|
||||
</TooltipProvider>
|
||||
|
||||
@@ -1,6 +1,32 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const enCoreMessages: Messages = {
|
||||
"settings.plugins.landing.checkUpdates": "Check for updates",
|
||||
"settings.plugins.landing.checkingUpdates": "Checking…",
|
||||
"settings.plugins.landing.updateSummary": "Checked: {checked} · Published updates: {updates} · Not checked: {skipped}.",
|
||||
"settings.plugins.landing.updateSourcesFailed": "Unavailable sources: {count}.",
|
||||
"settings.plugins.landing.updateReview": "Review updates in the plugin manager before installing.",
|
||||
"settings.plugins.landing.updateAvailable": "Version {version} available",
|
||||
"settings.plugins.landing.loading": "Loading installed plugins…",
|
||||
"settings.plugins.landing.settingsUnavailable": "No plugin settings are available in this context.",
|
||||
"settings.plugins.landing.options": "Plugin options",
|
||||
"settings.plugins.landing.manager": "Plugin manager",
|
||||
"settings.plugins.landing.managerDescription": "Browse plugins and manage installed packages and sources.",
|
||||
"settings.plugins.landing.browse": "Browse",
|
||||
"settings.plugins.landing.refresh": "Refresh installed plugins",
|
||||
"settings.plugins.landing.unavailable": "Plugin management is unavailable in this session.",
|
||||
"settings.plugins.landing.empty": "No plugins installed. Browse the plugin manager to get started.",
|
||||
"settings.plugins.landing.search": "Search installed plugins",
|
||||
"settings.plugins.landing.settingsFor": "Settings for {name}",
|
||||
"settings.plugins.landing.enableSettings": "Enable this plugin to open its available settings.",
|
||||
"settings.plugins.landing.sourceUnknown": "Source unknown",
|
||||
"settings.plugins.landing.status.running": "Running",
|
||||
"settings.plugins.landing.status.starting": "Starting",
|
||||
"settings.plugins.landing.status.stopping": "Stopping",
|
||||
"settings.plugins.landing.status.error": "Error",
|
||||
"settings.plugins.landing.status.quarantined": "Quarantined",
|
||||
"settings.plugins.landing.status.unknown": "Status unknown",
|
||||
"settings.plugins.landing.status.idle": "Enabled, idle",
|
||||
// Common
|
||||
'common.save': 'Save',
|
||||
'common.cancel': 'Cancel',
|
||||
@@ -1388,5 +1414,20 @@ Highlight the focused split pane:
|
||||
'plugins.authentication.responseFailed': 'Could not send the authentication response. Try again or cancel the request.',
|
||||
'plugins.authentication.responseFailedWithMessage': 'Could not send the authentication response: {message}. Try again or cancel the request.',
|
||||
'plugins.authentication.continue': 'Continue',
|
||||
'plugins.hostActions.select': 'Share selected host metadata',
|
||||
'plugins.hostActions.selectionNote': 'Share saved labels, addresses, usernames, protocols, groups and tags with this plugin. Group selection includes only the hosts selected now. Connecting requires a separate confirmation.',
|
||||
'plugins.hostActions.create': 'Review host draft',
|
||||
'plugins.hostActions.edit': 'Review host changes',
|
||||
'plugins.hostActions.connect': 'Confirm connection',
|
||||
'plugins.hostActions.fieldDiff': 'Changes to save',
|
||||
'plugins.hostActions.defaultsWarning': 'Saved connection defaults apply. Changing a group can change inherited authentication, proxy settings and scripts.',
|
||||
'plugins.hostActions.invalidDraft': 'Enter a valid host draft.',
|
||||
'plugins.hostActions.scriptsRun': 'Saved startup, connect or output scripts may run with this connection.',
|
||||
'plugins.hostActions.noScripts': 'No saved startup, connect or output scripts are configured for this host.',
|
||||
'plugins.hostActions.connectionNote': 'Netcatty will open this connection using the saved settings. This does not share session content with the plugin.',
|
||||
'plugins.hostActions.localExecution': 'Local execution: this opens a shell on this device.',
|
||||
'plugins.hostActions.managedUnsupported': 'Managed SSH config changes require the Vault host editor.',
|
||||
'plugins.hostActions.failed': 'The request expired, was revoked, or the host changed. Select the host again.',
|
||||
'plugins.hostActions.selectionLimit': 'Select up to 50 current hosts.',
|
||||
|
||||
};
|
||||
@@ -1,6 +1,32 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const esCoreMessages: Messages = {
|
||||
"settings.plugins.landing.checkUpdates": "Buscar actualizaciones",
|
||||
"settings.plugins.landing.checkingUpdates": "Buscando…",
|
||||
"settings.plugins.landing.updateSummary": "Comprobados: {checked} · Actualizaciones publicadas: {updates} · Sin comprobar: {skipped}.",
|
||||
"settings.plugins.landing.updateSourcesFailed": "Fuentes no disponibles: {count}.",
|
||||
"settings.plugins.landing.updateReview": "Revisa las actualizaciones en el gestor de plugins antes de instalarlas.",
|
||||
"settings.plugins.landing.updateAvailable": "Versión {version} disponible",
|
||||
"settings.plugins.landing.loading": "Cargando plugins instalados…",
|
||||
"settings.plugins.landing.settingsUnavailable": "No hay ajustes del plugin disponibles en este contexto.",
|
||||
"settings.plugins.landing.options": "Opciones de plugins",
|
||||
"settings.plugins.landing.manager": "Gestor de plugins",
|
||||
"settings.plugins.landing.managerDescription": "Explora plugins y gestiona los paquetes instalados y sus fuentes.",
|
||||
"settings.plugins.landing.browse": "Explorar",
|
||||
"settings.plugins.landing.refresh": "Actualizar la lista de plugins instalados",
|
||||
"settings.plugins.landing.unavailable": "La gestión de plugins no está disponible en esta sesión.",
|
||||
"settings.plugins.landing.empty": "No hay plugins instalados. Abre el gestor de plugins para empezar.",
|
||||
"settings.plugins.landing.search": "Buscar plugins instalados",
|
||||
"settings.plugins.landing.settingsFor": "Ajustes de {name}",
|
||||
"settings.plugins.landing.enableSettings": "Activa este plugin para abrir sus ajustes disponibles.",
|
||||
"settings.plugins.landing.sourceUnknown": "Fuente desconocida",
|
||||
"settings.plugins.landing.status.running": "En ejecución",
|
||||
"settings.plugins.landing.status.starting": "Iniciando",
|
||||
"settings.plugins.landing.status.stopping": "Deteniendo",
|
||||
"settings.plugins.landing.status.error": "Error",
|
||||
"settings.plugins.landing.status.quarantined": "En cuarentena",
|
||||
"settings.plugins.landing.status.unknown": "Estado desconocido",
|
||||
"settings.plugins.landing.status.idle": "Activado, en espera",
|
||||
// Common
|
||||
'common.save': 'Guardar',
|
||||
'common.cancel': 'Cancelar',
|
||||
@@ -1388,5 +1414,20 @@ Resalta el panel dividido enfocado:
|
||||
'plugins.authentication.responseFailed': 'No se pudo enviar la respuesta de autenticación. Inténtalo de nuevo o cancela la solicitud.',
|
||||
'plugins.authentication.responseFailedWithMessage': 'No se pudo enviar la respuesta de autenticación: {message}. Inténtalo de nuevo o cancela la solicitud.',
|
||||
'plugins.authentication.continue': 'Continuar',
|
||||
'plugins.hostActions.select': 'Compartir metadatos de los equipos seleccionados',
|
||||
'plugins.hostActions.selectionNote': 'Comparte nombres, direcciones, usuarios, protocolos, grupos y etiquetas guardados con este complemento. Los grupos solo incluyen los equipos seleccionados ahora. Conectar requiere otra confirmación.',
|
||||
'plugins.hostActions.create': 'Revisar borrador de equipo',
|
||||
'plugins.hostActions.edit': 'Revisar cambios del equipo',
|
||||
'plugins.hostActions.connect': 'Confirmar conexión',
|
||||
'plugins.hostActions.fieldDiff': 'Cambios que se guardarán',
|
||||
'plugins.hostActions.defaultsWarning': 'Se aplican los ajustes de conexión guardados. Cambiar el grupo puede cambiar la autenticación, el proxy y los scripts heredados.',
|
||||
'plugins.hostActions.invalidDraft': 'Introduce un borrador de equipo válido.',
|
||||
'plugins.hostActions.scriptsRun': 'Esta conexión puede ejecutar scripts guardados de inicio, conexión o salida.',
|
||||
'plugins.hostActions.noScripts': 'Este equipo no tiene scripts guardados de inicio, conexión o salida.',
|
||||
'plugins.hostActions.connectionNote': 'Netcatty abrirá la conexión con los ajustes guardados. Esto no comparte el contenido de la sesión con el complemento.',
|
||||
'plugins.hostActions.localExecution': 'Ejecución local: esto abre una shell en este dispositivo.',
|
||||
'plugins.hostActions.managedUnsupported': 'Los cambios de configuración SSH administrada requieren el editor de equipos de Vault.',
|
||||
'plugins.hostActions.failed': 'La solicitud caducó, se revocó o el equipo cambió. Selecciona el equipo de nuevo.',
|
||||
'plugins.hostActions.selectionLimit': 'Selecciona hasta 50 equipos actuales.',
|
||||
|
||||
};
|
||||
@@ -1283,4 +1283,19 @@ export const ruCoreMessages: Messages = {
|
||||
'plugins.authentication.responseFailed': 'Не удалось отправить ответ аутентификации. Повторите попытку или отмените запрос.',
|
||||
'plugins.authentication.responseFailedWithMessage': 'Не удалось отправить ответ аутентификации: {message}. Повторите попытку или отмените запрос.',
|
||||
'plugins.authentication.continue': 'Продолжить',
|
||||
'plugins.hostActions.select': 'Передать метаданные выбранных хостов',
|
||||
'plugins.hostActions.selectionNote': 'Плагину будут переданы сохранённые имена, адреса, пользователи, протоколы, группы и теги. Группа включает только выбранные сейчас хосты. Подключение требует отдельного подтверждения.',
|
||||
'plugins.hostActions.create': 'Проверить черновик хоста',
|
||||
'plugins.hostActions.edit': 'Проверить изменения хоста',
|
||||
'plugins.hostActions.connect': 'Подтвердить подключение',
|
||||
'plugins.hostActions.fieldDiff': 'Изменения для сохранения',
|
||||
'plugins.hostActions.defaultsWarning': 'Применяются сохранённые настройки подключения. Смена группы может изменить унаследованные настройки входа, прокси и скрипты.',
|
||||
'plugins.hostActions.invalidDraft': 'Введите корректные данные хоста.',
|
||||
'plugins.hostActions.scriptsRun': 'При подключении могут выполняться сохранённые скрипты запуска, подключения или вывода.',
|
||||
'plugins.hostActions.noScripts': 'Для этого хоста не настроены сохранённые скрипты запуска, подключения или вывода.',
|
||||
'plugins.hostActions.connectionNote': 'Netcatty откроет подключение с сохранёнными настройками. Содержимое сеанса не передаётся плагину.',
|
||||
'plugins.hostActions.localExecution': 'Локальное выполнение: откроется оболочка на этом устройстве.',
|
||||
'plugins.hostActions.managedUnsupported': 'Изменяйте управляемые настройки SSH в редакторе хостов Vault.',
|
||||
'plugins.hostActions.failed': 'Запрос истёк, был отозван или хост изменился. Выберите хост заново.',
|
||||
'plugins.hostActions.selectionLimit': 'Выберите не более 50 текущих хостов.',
|
||||
};
|
||||
@@ -1,6 +1,32 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const zhCNCoreMessages: Messages = {
|
||||
"settings.plugins.landing.checkUpdates": "检查更新",
|
||||
"settings.plugins.landing.checkingUpdates": "正在检查…",
|
||||
"settings.plugins.landing.updateSummary": "已检查:{checked} · 已发布更新:{updates} · 未检查:{skipped}。",
|
||||
"settings.plugins.landing.updateSourcesFailed": "不可用来源:{count}。",
|
||||
"settings.plugins.landing.updateReview": "安装前请在插件管理器中查看更新详情。",
|
||||
"settings.plugins.landing.updateAvailable": "可更新至 {version}",
|
||||
"settings.plugins.landing.loading": "正在载入已安装插件…",
|
||||
"settings.plugins.landing.settingsUnavailable": "当前上下文中没有可用的插件设置。",
|
||||
"settings.plugins.landing.options": "插件选项",
|
||||
"settings.plugins.landing.manager": "插件管理器",
|
||||
"settings.plugins.landing.managerDescription": "浏览插件,管理已安装的插件包及来源。",
|
||||
"settings.plugins.landing.browse": "浏览",
|
||||
"settings.plugins.landing.refresh": "刷新已安装插件",
|
||||
"settings.plugins.landing.unavailable": "当前会话无法使用插件管理。",
|
||||
"settings.plugins.landing.empty": "尚未安装插件。可在插件管理器中浏览或导入插件。",
|
||||
"settings.plugins.landing.search": "搜索已安装插件",
|
||||
"settings.plugins.landing.settingsFor": "{name} 的设置",
|
||||
"settings.plugins.landing.enableSettings": "启用此插件后可打开其可用设置。",
|
||||
"settings.plugins.landing.sourceUnknown": "来源未知",
|
||||
"settings.plugins.landing.status.running": "运行中",
|
||||
"settings.plugins.landing.status.starting": "启动中",
|
||||
"settings.plugins.landing.status.stopping": "停止中",
|
||||
"settings.plugins.landing.status.error": "错误",
|
||||
"settings.plugins.landing.status.quarantined": "已隔离",
|
||||
"settings.plugins.landing.status.unknown": "状态未知",
|
||||
"settings.plugins.landing.status.idle": "已启用,空闲",
|
||||
// Common
|
||||
'common.save': '保存',
|
||||
'common.cancel': '取消',
|
||||
@@ -1303,5 +1329,20 @@ Vaults 标签保持扁平,不使用这块背景。
|
||||
'plugins.authentication.responseFailed': '无法发送认证响应。请重试或取消此请求。',
|
||||
'plugins.authentication.responseFailedWithMessage': '无法发送认证响应:{message}。请重试或取消此请求。',
|
||||
'plugins.authentication.continue': '继续',
|
||||
'plugins.hostActions.select': '分享所选主机的元数据',
|
||||
'plugins.hostActions.selectionNote': '向此插件分享已保存的名称、地址、用户名、协议、分组和标签。选择分组仅包含本次选中的成员。连接主机需要单独确认。',
|
||||
'plugins.hostActions.create': '预览主机草稿',
|
||||
'plugins.hostActions.edit': '预览主机修改',
|
||||
'plugins.hostActions.connect': '确认连接',
|
||||
'plugins.hostActions.fieldDiff': '即将保存的修改',
|
||||
'plugins.hostActions.defaultsWarning': '将应用已保存的连接默认设置。修改分组可能改变继承的认证、代理和脚本设置。',
|
||||
'plugins.hostActions.invalidDraft': '请输入有效的主机草稿。',
|
||||
'plugins.hostActions.scriptsRun': '此连接可能运行已保存的启动、连接或输出触发脚本。',
|
||||
'plugins.hostActions.noScripts': '此主机未配置已保存的启动、连接或输出触发脚本。',
|
||||
'plugins.hostActions.connectionNote': 'Netcatty 将使用已保存的设置打开连接。这不会向插件分享会话内容。',
|
||||
'plugins.hostActions.localExecution': '本地执行:这将在此设备上打开一个 Shell。',
|
||||
'plugins.hostActions.managedUnsupported': '受管理的 SSH 配置变更需在 Vault 主机编辑器中完成。',
|
||||
'plugins.hostActions.failed': '请求已过期、被撤销或主机已改变。请重新选择主机。',
|
||||
'plugins.hostActions.selectionLimit': '最多选择 50 个当前主机。',
|
||||
|
||||
};
|
||||
@@ -1,6 +1,32 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const zhTWCoreMessages: Messages = {
|
||||
"settings.plugins.landing.checkUpdates": "檢查更新",
|
||||
"settings.plugins.landing.checkingUpdates": "正在檢查…",
|
||||
"settings.plugins.landing.updateSummary": "已檢查:{checked} · 已發佈更新:{updates} · 未檢查:{skipped}。",
|
||||
"settings.plugins.landing.updateSourcesFailed": "無法使用的來源:{count}。",
|
||||
"settings.plugins.landing.updateReview": "安裝前請在外掛管理器中查看更新詳情。",
|
||||
"settings.plugins.landing.updateAvailable": "可更新至 {version}",
|
||||
"settings.plugins.landing.loading": "正在載入已安裝外掛…",
|
||||
"settings.plugins.landing.settingsUnavailable": "目前內容中沒有可用的外掛設定。",
|
||||
"settings.plugins.landing.options": "外掛選項",
|
||||
"settings.plugins.landing.manager": "外掛管理器",
|
||||
"settings.plugins.landing.managerDescription": "瀏覽外掛,管理已安裝的外掛套件及來源。",
|
||||
"settings.plugins.landing.browse": "瀏覽",
|
||||
"settings.plugins.landing.refresh": "重新整理已安裝外掛",
|
||||
"settings.plugins.landing.unavailable": "目前工作階段無法使用外掛管理。",
|
||||
"settings.plugins.landing.empty": "尚未安裝外掛。可在外掛管理器中瀏覽或匯入外掛。",
|
||||
"settings.plugins.landing.search": "搜尋已安裝外掛",
|
||||
"settings.plugins.landing.settingsFor": "{name} 的設定",
|
||||
"settings.plugins.landing.enableSettings": "啟用此外掛後可開啟其可用設定。",
|
||||
"settings.plugins.landing.sourceUnknown": "來源不明",
|
||||
"settings.plugins.landing.status.running": "執行中",
|
||||
"settings.plugins.landing.status.starting": "啟動中",
|
||||
"settings.plugins.landing.status.stopping": "停止中",
|
||||
"settings.plugins.landing.status.error": "錯誤",
|
||||
"settings.plugins.landing.status.quarantined": "已隔離",
|
||||
"settings.plugins.landing.status.unknown": "狀態不明",
|
||||
"settings.plugins.landing.status.idle": "已啟用,閒置",
|
||||
// Common
|
||||
'common.save': '儲存',
|
||||
'common.cancel': '取消',
|
||||
@@ -1306,4 +1332,19 @@ Vaults 分頁維持扁平,不使用這塊背景。
|
||||
'plugins.authentication.responseFailed': '無法送出驗證回應。請重試或取消此要求。',
|
||||
'plugins.authentication.responseFailedWithMessage': '無法送出驗證回應:{message}。請重試或取消此要求。',
|
||||
'plugins.authentication.continue': '繼續',
|
||||
'plugins.hostActions.select': '分享所選主機的中繼資料',
|
||||
'plugins.hostActions.selectionNote': '向此外掛分享已儲存的名稱、位址、使用者名稱、協定、群組和標籤。選擇群組僅包含本次選取的成員。連線需要另外確認。',
|
||||
'plugins.hostActions.create': '預覽主機草稿',
|
||||
'plugins.hostActions.edit': '預覽主機修改',
|
||||
'plugins.hostActions.connect': '確認連線',
|
||||
'plugins.hostActions.fieldDiff': '即將儲存的修改',
|
||||
'plugins.hostActions.defaultsWarning': '將套用已儲存的連線預設設定。修改群組可能改變繼承的驗證、代理和指令碼設定。',
|
||||
'plugins.hostActions.invalidDraft': '請輸入有效的主機草稿。',
|
||||
'plugins.hostActions.scriptsRun': '此連線可能執行已儲存的啟動、連線或輸出觸發指令碼。',
|
||||
'plugins.hostActions.noScripts': '此主機未設定已儲存的啟動、連線或輸出觸發指令碼。',
|
||||
'plugins.hostActions.connectionNote': 'Netcatty 將使用已儲存的設定開啟連線。這不會向外掛分享工作階段內容。',
|
||||
'plugins.hostActions.localExecution': '本機執行:這會在此裝置上開啟 Shell。',
|
||||
'plugins.hostActions.managedUnsupported': '受管理的 SSH 設定變更需在 Vault 主機編輯器中完成。',
|
||||
'plugins.hostActions.failed': '請求已過期、被撤銷或主機已變更。請重新選擇主機。',
|
||||
'plugins.hostActions.selectionLimit': '最多選擇 50 部目前的主機。',
|
||||
};
|
||||
@@ -0,0 +1,128 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { executePluginHostAction, type PluginHostAction, type PluginHostActionVault } from './pluginHostActionExecution';
|
||||
import { PluginHostActionResources } from './pluginHostActionResources';
|
||||
import type { Host, ManagedSource } from '../../domain/models';
|
||||
|
||||
const synthetic: Host = { id: 'synthetic', label: 'Synthetic', hostname: 'synthetic.invalid', username: 'test', tags: [], os: 'linux', password: 'synthetic-only', startupCommand: 'synthetic-script' };
|
||||
|
||||
function fixture() {
|
||||
const resources = new PluginHostActionResources();
|
||||
let active = true, authorized = 0, connections = 0, writes = 0;
|
||||
let beforeRead: (() => void) | undefined;
|
||||
let beforeCommit: (() => void) | undefined;
|
||||
let duringAuthorize: (() => void) | undefined;
|
||||
let storedSources: ManagedSource[] | undefined;
|
||||
const vault: PluginHostActionVault = {
|
||||
hosts: [synthetic], customGroups: [], groupConfigs: [], snippets: [], managedSources: [], isInitialized: true,
|
||||
async readPersistedHosts() { beforeRead?.(); return [structuredClone(synthetic)]; },
|
||||
async commitVaultImportTransaction(next, groups, sources, _configs, _expected, _lock, authorize) {
|
||||
const nextSources = sources(storedSources ?? vault.managedSources);
|
||||
beforeCommit?.();
|
||||
await authorize?.();
|
||||
writes++; vault.hosts = next;
|
||||
return { status: 'persisted', groups: groups([]), sources: nextSources, groupConfigs: [] };
|
||||
},
|
||||
};
|
||||
resources.observe(vault.hosts);
|
||||
const target = resources.metadata(synthetic.id);
|
||||
const options = {
|
||||
getVault: () => vault, resources,
|
||||
assertCurrent() { if (!active) throw new Error('Action cancelled'); },
|
||||
async authorizeCommit() { duringAuthorize?.(); if (!active) throw new Error('Action revoked'); authorized++; },
|
||||
connect(host: Host) { assert.equal(host, synthetic); connections++; return 'private-session-id'; },
|
||||
};
|
||||
return { vault, target, options, get authorized() { return authorized; }, get connections() { return connections; }, get writes() { return writes; },
|
||||
cancel() { active = false; }, beforeRead(fn: () => void) { beforeRead = fn; }, beforeCommit(fn: () => void) { beforeCommit = fn; }, duringAuthorize(fn: () => void) { duringAuthorize = fn; },
|
||||
storedSources(value: ManagedSource[]) { storedSources = value; },
|
||||
};
|
||||
}
|
||||
|
||||
test('reviewed connection dispatches through existing handler and returns no session data', async () => {
|
||||
const f = fixture();
|
||||
assert.equal(await executePluginHostAction({ kind: 'connect', target: f.target }, f.options), 'requested');
|
||||
assert.equal(f.authorized, 1); assert.equal(f.connections, 1); assert.equal(f.writes, 0);
|
||||
});
|
||||
|
||||
test('host edit preserves credentials and scripts and claims action authority after preparation', async () => {
|
||||
const f = fixture();
|
||||
assert.equal(await executePluginHostAction({ kind: 'edit', target: f.target, draft: { label: 'Reviewed' } }, f.options), 'saved');
|
||||
assert.equal(f.vault.hosts[0].password, synthetic.password);
|
||||
assert.equal(f.vault.hosts[0].startupCommand, synthetic.startupCommand);
|
||||
assert.equal(f.vault.hosts[0].label, 'Reviewed');
|
||||
assert.equal(f.authorized, 1); assert.equal(f.writes, 1); assert.equal(f.connections, 0);
|
||||
});
|
||||
|
||||
for (const point of ['beforeRead', 'beforeCommit', 'duringAuthorize'] as const) test(`cancel/revoke at ${point} prevents the Vault write`, async () => {
|
||||
const f = fixture(); f[point](() => f.cancel());
|
||||
await assert.rejects(executePluginHostAction({ kind: 'edit', target: f.target, draft: { label: 'Never saved' } }, f.options), /cancelled|revoked/);
|
||||
assert.equal(f.writes, 0); assert.equal(f.connections, 0);
|
||||
});
|
||||
|
||||
for (const change of ['remove', 'replace', 'scripts', 'locked'] as const) test(`${change} during awaited connection preparation prevents dispatch`, async () => {
|
||||
const f = fixture();
|
||||
f.beforeRead(() => {
|
||||
if (change === 'remove') f.vault.hosts = [];
|
||||
if (change === 'replace') f.vault.hosts = [{ ...synthetic }];
|
||||
if (change === 'scripts') f.vault.snippets = [];
|
||||
if (change === 'locked') f.cancel();
|
||||
});
|
||||
await assert.rejects(executePluginHostAction({ kind: 'connect', target: f.target }, f.options));
|
||||
assert.equal(f.authorized, 0); assert.equal(f.connections, 0);
|
||||
});
|
||||
|
||||
test('create produces a saved SSH draft without executable or credential injection', async () => {
|
||||
const f = fixture();
|
||||
const action: PluginHostAction = { kind: 'create', draft: { hostname: 'new.invalid', label: 'New', group: 'Synthetic', password: 'untrusted', startupCommand: 'untrusted' } as never };
|
||||
assert.equal(await executePluginHostAction(action, f.options), 'saved');
|
||||
const created = f.vault.hosts[1];
|
||||
assert.notEqual(created.id, synthetic.id);
|
||||
assert.equal(created.password, undefined); assert.equal(created.startupCommand, undefined);
|
||||
});
|
||||
|
||||
const managedSources: ManagedSource[] = [
|
||||
{ id: 'synthetic-source-a', type: 'ssh_config', filePath: '/synthetic/a.conf', groupName: 'A', lastSyncedAt: 0 },
|
||||
{ id: 'synthetic-source-b', type: 'ssh_config', filePath: '/synthetic/b.conf', groupName: 'B', lastSyncedAt: 0 },
|
||||
];
|
||||
|
||||
for (const destination of ['B', 'Unmanaged', ''] as const) test(`managed A to ${destination || 'no group'} is refused before action authority or persistence`, async () => {
|
||||
const f = fixture();
|
||||
const host = { ...synthetic, group: 'A', managedSourceId: managedSources[0].id };
|
||||
f.vault.hosts = [host]; f.vault.managedSources = managedSources;
|
||||
f.vault.readPersistedHosts = async () => [structuredClone(host)];
|
||||
f.options.resources.observe(f.vault.hosts);
|
||||
const target = f.options.resources.metadata(host.id);
|
||||
await assert.rejects(executePluginHostAction({ kind: 'edit', target, draft: { group: destination } }, f.options), /Managed.*Vault/);
|
||||
assert.equal(f.writes, 0); assert.equal(f.authorized, 0);
|
||||
assert.equal(f.vault.hosts[0].managedSourceId, managedSources[0].id);
|
||||
});
|
||||
|
||||
for (const kind of ['create', 'edit'] as const) test(`${kind} into a managed destination is refused`, async () => {
|
||||
const f = fixture(); f.vault.managedSources = managedSources;
|
||||
const action: PluginHostAction = kind === 'create'
|
||||
? { kind, draft: { hostname: 'new.invalid', group: 'B/Child' } }
|
||||
: { kind, target: f.target, draft: { group: 'B/Child' } };
|
||||
await assert.rejects(executePluginHostAction(action, f.options), /Managed.*Vault/);
|
||||
assert.equal(f.writes, 0); assert.equal(f.authorized, 0);
|
||||
});
|
||||
|
||||
test('ordinary unmanaged moves preserve the managed-source catalog without taking ownership', async () => {
|
||||
const f = fixture(); f.vault.managedSources = managedSources;
|
||||
assert.equal(await executePluginHostAction({ kind: 'edit', target: f.target, draft: { group: 'Unmanaged' } }, f.options), 'saved');
|
||||
assert.equal(f.vault.hosts[0].managedSourceId, undefined);
|
||||
assert.equal(f.vault.hosts[0].group, 'Unmanaged');
|
||||
assert.equal(f.vault.managedSources, managedSources);
|
||||
});
|
||||
|
||||
test('a managed-source catalog replacement during preparation prevents the write', async () => {
|
||||
const f = fixture();
|
||||
f.beforeCommit(() => { f.vault.managedSources = managedSources; });
|
||||
await assert.rejects(executePluginHostAction({ kind: 'create', draft: { hostname: 'new.invalid', group: 'B' } }, f.options), /Managed sources changed/);
|
||||
assert.equal(f.writes, 0); assert.equal(f.authorized, 0);
|
||||
});
|
||||
|
||||
test('a newer persisted managed-source catalog rejects a stale unmanaged preview before the claim', async () => {
|
||||
const f = fixture(); f.storedSources(managedSources);
|
||||
await assert.rejects(executePluginHostAction({ kind: 'create', draft: { hostname: 'new.invalid', group: 'B' } }, f.options), /Managed sources changed/);
|
||||
assert.equal(f.writes, 0); assert.equal(f.authorized, 0);
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
import type { Host, GroupConfig, Snippet, ManagedSource } from '../../domain/models';
|
||||
import { canonicalJsonString, normalizeJsonValue } from '../../domain/convergentSync/json';
|
||||
import { applyPluginHostEdit, normalizePluginHostDraft, pluginHostDraftTouchesManagedSource, type PluginHostDraftFields } from '../../domain/pluginHostActions';
|
||||
import { applyVaultHostCreates } from '../../domain/vaultHostCreate';
|
||||
import type { PluginHostActionResources } from './pluginHostActionResources';
|
||||
import type { AppVaultRuntime } from './appRuntimeBridge';
|
||||
|
||||
// Application action model. Public RPC types live only in plugin-contract.
|
||||
export type PluginHostAction =
|
||||
| { kind: 'create'; draft: PluginHostDraftFields }
|
||||
| { kind: 'edit'; target: { resourceId: string; revision: string }; draft: PluginHostDraftFields }
|
||||
| { kind: 'connect'; target: { resourceId: string; revision: string } };
|
||||
|
||||
export interface PluginHostActionVault {
|
||||
hosts: Host[];
|
||||
customGroups: string[];
|
||||
groupConfigs: GroupConfig[];
|
||||
snippets: Snippet[];
|
||||
managedSources: ManagedSource[];
|
||||
isInitialized: boolean;
|
||||
readPersistedHosts: () => Promise<Host[]>;
|
||||
commitVaultImportTransaction: AppVaultRuntime['commitVaultImportTransaction'];
|
||||
}
|
||||
|
||||
export interface PluginHostActionExecution {
|
||||
getVault: () => PluginHostActionVault;
|
||||
resources: PluginHostActionResources;
|
||||
assertCurrent: () => void;
|
||||
authorizeCommit: () => Promise<void>;
|
||||
connect: (host: Host) => unknown;
|
||||
}
|
||||
|
||||
const sameHost = (left: Host, right: Host) => (
|
||||
canonicalJsonString(normalizeJsonValue(left)) === canonicalJsonString(normalizeJsonValue(right))
|
||||
);
|
||||
|
||||
/** Execute only a host-reviewed action. authorizeCommit claims separate,
|
||||
* one-use main-process action authority after the final asynchronous work. */
|
||||
export async function executePluginHostAction(action: PluginHostAction, options: PluginHostActionExecution): Promise<'saved' | 'requested'> {
|
||||
const current = () => {
|
||||
options.assertCurrent();
|
||||
const vault = options.getVault();
|
||||
if (!vault.isInitialized) throw new Error('Vault is unavailable');
|
||||
options.resources.observe(vault.hosts);
|
||||
return vault;
|
||||
};
|
||||
const initialVault = current();
|
||||
const reviewedGroups = initialVault.groupConfigs;
|
||||
const reviewedSnippets = initialVault.snippets;
|
||||
const reviewedSources = initialVault.managedSources;
|
||||
const selected = action.kind === 'create' ? undefined : options.resources.resolve(action.target.resourceId, action.target.revision);
|
||||
const assertSelected = () => {
|
||||
const vault = current();
|
||||
if (action.kind !== 'create' && options.resources.resolve(action.target.resourceId, action.target.revision) !== selected) {
|
||||
throw new Error('Selected host changed');
|
||||
}
|
||||
// A connection preview includes inherited and global automation. Any
|
||||
// settings change while the preview is open requires fresh confirmation.
|
||||
if (action.kind === 'connect' && (vault.groupConfigs !== reviewedGroups || vault.snippets !== reviewedSnippets)) {
|
||||
throw new Error('Saved connection settings changed');
|
||||
}
|
||||
if (action.kind !== 'connect' && vault.managedSources !== reviewedSources) throw new Error('Managed sources changed during draft review');
|
||||
return vault;
|
||||
};
|
||||
const persisted = await initialVault.readPersistedHosts();
|
||||
assertSelected();
|
||||
if (selected) {
|
||||
const persistedHost = persisted.find((host) => host.id === selected.id);
|
||||
if (!persistedHost || !sameHost(selected, persistedHost)) throw new Error('Selected host changed');
|
||||
}
|
||||
if (action.kind === 'connect') {
|
||||
await options.authorizeCommit();
|
||||
assertSelected();
|
||||
// The existing host connection handler owns authentication, saved scripts,
|
||||
// and sessions. Its return value never reaches the sandboxed plugin.
|
||||
options.connect(selected!);
|
||||
return 'requested';
|
||||
}
|
||||
const nextHost = action.kind === 'create'
|
||||
? normalizePluginHostDraft(action.draft, crypto.randomUUID())
|
||||
: applyPluginHostEdit(selected!, action.draft);
|
||||
if (pluginHostDraftTouchesManagedSource(nextHost, reviewedSources)) {
|
||||
throw new Error('Managed SSH config changes require the Vault host editor');
|
||||
}
|
||||
const nextHosts = action.kind === 'create'
|
||||
? applyVaultHostCreates(persisted, [], [nextHost]).hosts
|
||||
: persisted.map((host) => host.id === nextHost.id ? nextHost : host);
|
||||
if (action.kind === 'create' && nextHosts.length !== persisted.length + 1) throw new Error('Host already exists');
|
||||
const updateGroups = (groups: string[]) => nextHost.group && !groups.includes(nextHost.group) ? [...groups, nextHost.group] : groups;
|
||||
const sourceSnapshot = canonicalJsonString(normalizeJsonValue(reviewedSources));
|
||||
const keepSources = (sources: ManagedSource[]) => {
|
||||
if (canonicalJsonString(normalizeJsonValue(sources)) !== sourceSnapshot) throw new Error('Managed sources changed during draft review');
|
||||
return sources;
|
||||
};
|
||||
const result = await initialVault.commitVaultImportTransaction(
|
||||
nextHosts, updateGroups, keepSources,
|
||||
undefined, persisted, undefined,
|
||||
async () => { assertSelected(); await options.authorizeCommit(); assertSelected(); },
|
||||
);
|
||||
if (result.status !== 'persisted') throw new Error('Vault changed before the draft was saved');
|
||||
return 'saved';
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { PluginHostActionResources } from './pluginHostActionResources';
|
||||
import type { Host } from '../../domain/models';
|
||||
|
||||
const first: Host = { id: 'synthetic-1', label: 'First', hostname: 'first.invalid', username: 'test', tags: [], os: 'linux', group: 'Synthetic', password: 'synthetic-password', startupCommand: 'synthetic-script', totp: { uri: 'synthetic-totp' } as never };
|
||||
|
||||
test('selected metadata projects only the allowlist and replacement invalidates revision', () => {
|
||||
const resources = new PluginHostActionResources();
|
||||
resources.observe([first]);
|
||||
const metadata = resources.metadata(first.id);
|
||||
assert.deepEqual(Object.keys(metadata), ['resourceId', 'revision', 'label', 'hostname', 'username', 'protocol', 'group', 'tags']);
|
||||
assert.equal(JSON.stringify(metadata).includes('synthetic-password'), false);
|
||||
resources.observe([first]);
|
||||
assert.equal(resources.resolve(first.id, metadata.revision), first);
|
||||
resources.observe([{ ...first }]);
|
||||
assert.throws(() => resources.resolve(first.id, metadata.revision), /changed/);
|
||||
const replaced = resources.metadata(first.id);
|
||||
resources.observe([]);
|
||||
assert.throws(() => resources.resolve(first.id, replaced.revision), /changed/);
|
||||
resources.observe([first]);
|
||||
assert.throws(() => resources.resolve(first.id, replaced.revision), /changed/);
|
||||
});
|
||||
|
||||
test('group choices are explicit current members and never expand to later members', () => {
|
||||
const resources = new PluginHostActionResources();
|
||||
resources.observe([first]);
|
||||
const selection = [resources.metadata(first.id)];
|
||||
resources.observe([first, { ...first, id: 'synthetic-2' }]);
|
||||
assert.deepEqual(selection.map((item) => item.resourceId), [first.id]);
|
||||
assert.equal(resources.resolve(first.id, selection[0].revision), first);
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { Host } from '../../domain/models';
|
||||
|
||||
/** Renderer-local revisions track replacement as well as visible edits. They
|
||||
* are never authorization handles; only the main-process authority issues one. */
|
||||
export class PluginHostActionResources {
|
||||
private entries = new Map<string, { host: Host; revision: string }>();
|
||||
|
||||
observe(hosts: readonly Host[]) {
|
||||
const next = new Map<string, { host: Host; revision: string }>();
|
||||
for (const host of hosts) {
|
||||
const existing = this.entries.get(host.id);
|
||||
next.set(host.id, existing?.host === host ? existing : { host, revision: crypto.randomUUID() });
|
||||
}
|
||||
this.entries = next;
|
||||
}
|
||||
|
||||
resolve(id: string, expectedRevision: string): Host {
|
||||
const entry = this.entries.get(id);
|
||||
if (!entry || entry.revision !== expectedRevision) throw new Error('Selected host changed; select it again');
|
||||
return entry.host;
|
||||
}
|
||||
|
||||
metadata(id: string) {
|
||||
const entry = this.entries.get(id);
|
||||
if (!entry) throw new Error('Selected host is unavailable');
|
||||
const host = entry.host;
|
||||
return {
|
||||
resourceId: host.id, revision: entry.revision,
|
||||
label: host.label, hostname: host.hostname, username: host.username,
|
||||
...(host.port === undefined ? {} : { port: host.port }), protocol: host.protocol ?? 'ssh',
|
||||
group: host.group ?? '', tags: [...host.tags],
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import type { HostInteractionPrompt, VaultHostActionRequest } from '@netcatty/plugin-contract';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
import { getAppHandlers } from '../app/appHandlersBridge';
|
||||
import { getAppAppLockRuntime, getAppVaultRuntime } from './appRuntimeBridge';
|
||||
import { PluginHostActionResources } from './pluginHostActionResources';
|
||||
import { executePluginHostAction, type PluginHostAction } from './pluginHostActionExecution';
|
||||
import { pluginHostRunsSavedScripts, type PluginHostDraftFields } from '../../domain/pluginHostActions';
|
||||
import type { Host, GroupConfig, ManagedSource, Snippet } from '../../domain/models';
|
||||
|
||||
export type VaultHostPrompt = Omit<HostInteractionPrompt, 'request'> & { request: VaultHostActionRequest };
|
||||
type Review = {
|
||||
prompt: VaultHostPrompt;
|
||||
candidates: Host[];
|
||||
candidateRevisions: Map<string, string>;
|
||||
customGroups: string[];
|
||||
host?: Host;
|
||||
groups: GroupConfig[];
|
||||
snippets: Snippet[];
|
||||
managedSources: ManagedSource[];
|
||||
scriptsRun: boolean;
|
||||
claimed: boolean;
|
||||
completionToken?: string;
|
||||
};
|
||||
|
||||
export function usePluginHostActions() {
|
||||
const [review, setReview] = useState<Review | null>(null);
|
||||
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState(false);
|
||||
const current = useRef<Review | null>(null);
|
||||
const resources = useRef(new PluginHostActionResources());
|
||||
const responding = useRef(false);
|
||||
|
||||
const getVault = useCallback(() => {
|
||||
const vault = getAppVaultRuntime();
|
||||
const lock = getAppAppLockRuntime();
|
||||
if (!vault?.isInitialized || !lock?.initialized || lock.locked) throw new Error('Vault is locked or unavailable');
|
||||
const actionState = vault.readCurrentHostActionState();
|
||||
resources.current.observe(actionState.hosts);
|
||||
return { ...vault, ...actionState };
|
||||
}, []);
|
||||
|
||||
const assertCurrent = useCallback((entry: Review) => {
|
||||
if (current.current !== entry || Date.now() >= entry.prompt.expiresAt) throw new Error('Host action expired');
|
||||
const vault = getVault();
|
||||
const target = entry.prompt.vaultTarget;
|
||||
if (target && resources.current.resolve(target.resourceId, target.revision) !== entry.host) throw new Error('Selected host changed');
|
||||
if (entry.prompt.request.kind === 'vault.requestConnect' && (vault.groupConfigs !== entry.groups || vault.snippets !== entry.snippets)) {
|
||||
throw new Error('Saved connection settings changed');
|
||||
}
|
||||
if (entry.prompt.request.kind === 'vault.requestHostDraft' && vault.managedSources !== entry.managedSources) throw new Error('Managed sources changed during draft review');
|
||||
}, [getVault]);
|
||||
|
||||
const fail = useCallback(async (entry: Review) => {
|
||||
if (current.current !== entry) return;
|
||||
setError(true);
|
||||
try {
|
||||
await netcattyBridge.get()?.respondPluginInteraction?.({
|
||||
requestId: entry.prompt.requestId, actionToken: entry.completionToken ?? entry.prompt.actionToken, action: 'fail',
|
||||
});
|
||||
} catch { /* A stopped runtime or closed window already invalidates it. */ }
|
||||
}, []);
|
||||
|
||||
const execute = useCallback(async (entry: Review) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.respondPluginInteraction) return fail(entry);
|
||||
setBusy(true);
|
||||
try {
|
||||
assertCurrent(entry);
|
||||
const request = entry.prompt.request;
|
||||
const target = entry.prompt.vaultTarget;
|
||||
let action: PluginHostAction;
|
||||
if (request.kind === 'vault.requestConnect' && target) action = { kind: 'connect', target };
|
||||
else if (request.kind === 'vault.requestHostDraft' && entry.prompt.reviewedDraft) {
|
||||
action = target ? { kind: 'edit', target, draft: entry.prompt.reviewedDraft } : { kind: 'create', draft: entry.prompt.reviewedDraft };
|
||||
} else throw new Error('Host action is invalid');
|
||||
await executePluginHostAction(action, {
|
||||
getVault, resources: resources.current,
|
||||
assertCurrent: () => assertCurrent(entry),
|
||||
authorizeCommit: async () => {
|
||||
assertCurrent(entry);
|
||||
const receipt = await bridge.respondPluginInteraction!({ requestId: entry.prompt.requestId, actionToken: entry.prompt.actionToken, action: 'commit' });
|
||||
if (!receipt || typeof receipt !== 'object' || !receipt.completionToken) throw new Error('Host action was revoked');
|
||||
entry.completionToken = receipt.completionToken;
|
||||
entry.claimed = true;
|
||||
assertCurrent(entry);
|
||||
},
|
||||
connect: (host) => {
|
||||
const connect = getAppHandlers()?.handleConnectToHost;
|
||||
if (typeof connect !== 'function') throw new Error('Host connection flow unavailable');
|
||||
return connect(host);
|
||||
},
|
||||
});
|
||||
await bridge.respondPluginInteraction({ requestId: entry.prompt.requestId, actionToken: entry.completionToken!, action: 'complete' });
|
||||
} catch { await fail(entry); }
|
||||
}, [assertCurrent, fail, getVault]);
|
||||
|
||||
useEffect(() => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.onPluginInteraction) return;
|
||||
const unsubscribe = bridge.onPluginInteraction((event) => {
|
||||
if ('closed' in event) {
|
||||
if (current.current?.prompt.requestId !== event.requestId) return;
|
||||
current.current = null; setReview(null); setBusy(false); responding.current = false;
|
||||
return;
|
||||
}
|
||||
if (!event.request.kind.startsWith('vault.')) return;
|
||||
const prompt = event as VaultHostPrompt;
|
||||
try {
|
||||
if (prompt.phase === 'commit') {
|
||||
const entry = current.current;
|
||||
if (!entry || entry.prompt.requestId !== prompt.requestId || entry.prompt.request.kind !== prompt.request.kind) throw new Error('Host review is unavailable');
|
||||
entry.prompt = prompt;
|
||||
setReview({ ...entry });
|
||||
void execute(entry);
|
||||
return;
|
||||
}
|
||||
const vault = getVault();
|
||||
const candidates = [...vault.hosts];
|
||||
const host = prompt.vaultTarget ? resources.current.resolve(prompt.vaultTarget.resourceId, prompt.vaultTarget.revision) : undefined;
|
||||
const entry: Review = {
|
||||
prompt, candidates, candidateRevisions: new Map(candidates.map((candidate) => [candidate.id, resources.current.metadata(candidate.id).revision])),
|
||||
customGroups: vault.customGroups, host, groups: vault.groupConfigs, snippets: vault.snippets,
|
||||
managedSources: vault.managedSources,
|
||||
scriptsRun: host ? pluginHostRunsSavedScripts(host, vault.groupConfigs, vault.snippets) : false,
|
||||
claimed: false,
|
||||
};
|
||||
current.current = entry; setReview(entry); setSelectedIds([]); setBusy(false); setError(false); responding.current = false;
|
||||
} catch {
|
||||
void bridge.respondPluginInteraction?.({ requestId: prompt.requestId, actionToken: prompt.actionToken, action: 'fail' }).catch(() => {});
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
const entry = current.current;
|
||||
current.current = null;
|
||||
unsubscribe();
|
||||
if (entry && !entry.claimed) void bridge.respondPluginInteraction?.({ requestId: entry.prompt.requestId, actionToken: entry.prompt.actionToken, action: 'cancel' }).catch(() => {});
|
||||
};
|
||||
}, [execute, getVault]);
|
||||
|
||||
const respond = useCallback(async (action: 'cancel' | 'submit', draft?: PluginHostDraftFields) => {
|
||||
const entry = current.current;
|
||||
if (!entry || responding.current || (action === 'cancel' && entry.claimed)) return;
|
||||
responding.current = true; setBusy(true); setError(false);
|
||||
try {
|
||||
assertCurrent(entry);
|
||||
const request = entry.prompt.request;
|
||||
const selected = request.kind === 'vault.selectHosts' && action === 'submit' ? selectedIds.map((id) => {
|
||||
const revision = entry.candidateRevisions.get(id);
|
||||
if (!revision) throw new Error('Host was not part of the current selection');
|
||||
resources.current.resolve(id, revision);
|
||||
return resources.current.metadata(id);
|
||||
}) : undefined;
|
||||
await netcattyBridge.require().respondPluginInteraction?.({
|
||||
requestId: entry.prompt.requestId, actionToken: entry.prompt.actionToken, action,
|
||||
...(selected === undefined ? {} : { resources: selected }),
|
||||
...(draft === undefined ? {} : { draft }),
|
||||
...(request.kind === 'vault.requestConnect' && action === 'submit' ? { scriptsRun: entry.scriptsRun } : {}),
|
||||
});
|
||||
} catch { await fail(entry); }
|
||||
finally {
|
||||
if (current.current === entry && entry.prompt.phase === 'request') { responding.current = false; setBusy(false); }
|
||||
}
|
||||
}, [assertCurrent, fail, selectedIds]);
|
||||
|
||||
return { review, selectedIds, setSelectedIds, busy, error, respond };
|
||||
}
|
||||
@@ -1,14 +1,15 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import type { HostInteractionPrompt, HostInteractionResponse } from '@netcatty/plugin-contract';
|
||||
import type { HostInteractionPrompt, HostInteractionRequest, HostInteractionResponse } from '@netcatty/plugin-contract';
|
||||
import { readPluginTerminalSelection } from '../../infrastructure/plugins/terminalSelectionRegistry';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
|
||||
export function usePluginHostInteractions() {
|
||||
const [prompt, setPrompt] = useState<HostInteractionPrompt | null>(null);
|
||||
type UiPrompt = Omit<HostInteractionPrompt, 'request'> & { request: HostInteractionRequest };
|
||||
const [prompt, setPrompt] = useState<UiPrompt | null>(null);
|
||||
const [value, setValue] = useState('');
|
||||
const [error, setError] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const current = useRef<HostInteractionPrompt | null>(null);
|
||||
const current = useRef<UiPrompt | null>(null);
|
||||
const pending = useRef(false);
|
||||
useEffect(() => {
|
||||
const bridge = netcattyBridge.get();
|
||||
@@ -18,7 +19,8 @@ export function usePluginHostInteractions() {
|
||||
if (current.current?.requestId !== event.requestId) return;
|
||||
current.current = null; setPrompt(null);
|
||||
} else {
|
||||
current.current = event; setPrompt(event);
|
||||
if (event.request.kind.startsWith('vault.')) return;
|
||||
current.current = event as UiPrompt; setPrompt(event as UiPrompt);
|
||||
setValue(event.request.kind === 'input' ? event.request.initialValue ?? '' : event.request.kind === 'terminal.input.propose' ? event.request.text : '');
|
||||
}
|
||||
pending.current = false; setBusy(false); setError(false);
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
|
||||
export function usePluginLogs() {
|
||||
const [status, setStatus] = useState<NetcattyPluginLogStatus | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const pending = useRef(false);
|
||||
const mounted = useRef(true);
|
||||
const run = useCallback(async (action: 'status' | 'configure' | 'open' | 'export' | 'clear', settings?: { saveToDisk: boolean; verbose: boolean }) => {
|
||||
if (pending.current) return;
|
||||
pending.current = true;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const bridge = netcattyBridge.require();
|
||||
const result = await (action === 'configure' ? bridge.configurePluginLogs?.(settings!)
|
||||
: action === 'open' ? bridge.openPluginLogFolder?.()
|
||||
: action === 'export' ? bridge.exportPluginLogs?.()
|
||||
: action === 'clear' ? bridge.clearPluginLogs?.() : bridge.getPluginLogStatus?.());
|
||||
if (!result) throw new Error('Local plugin logging is unavailable');
|
||||
if (mounted.current) setStatus(result);
|
||||
} catch (cause) {
|
||||
let message = cause instanceof Error ? cause.message : String(cause);
|
||||
if (action === 'configure') {
|
||||
// Configuration fails closed before disk I/O. Show actual host state,
|
||||
// even when persisting a setting failed, rather than keeping stale ON.
|
||||
try {
|
||||
const actual = await netcattyBridge.require().getPluginLogStatus?.();
|
||||
if (!actual) throw new Error('Local plugin log status is unavailable');
|
||||
if (mounted.current) setStatus(actual);
|
||||
} catch (refreshError) {
|
||||
if (mounted.current) setStatus(null);
|
||||
message += `\n${refreshError instanceof Error ? refreshError.message : String(refreshError)}`;
|
||||
}
|
||||
}
|
||||
if (mounted.current) setError(message);
|
||||
} finally {
|
||||
pending.current = false;
|
||||
if (mounted.current) setBusy(false);
|
||||
}
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
mounted.current = true;
|
||||
void run('status');
|
||||
return () => { mounted.current = false; };
|
||||
}, [run]);
|
||||
return { status, error, busy, run };
|
||||
}
|
||||
@@ -10,7 +10,8 @@ export function usePluginManager() {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const generation = useRef(0);
|
||||
const mutation = useRef(false);
|
||||
const refresh = useCallback(async () => {
|
||||
const refresh = useCallback(async (options?: { clearError?: boolean }) => {
|
||||
if (options?.clearError) setError(null);
|
||||
const current = ++generation.current;
|
||||
try {
|
||||
const bridge = netcattyBridge.get();
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { checkPluginUpdates, type PluginUpdateCheckResult } from '../../infrastructure/services/pluginUpdateCheck';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
|
||||
/** Manual catalog checks only; no scheduler, install or permission mutation. */
|
||||
export function usePluginUpdateCheck(plugins: readonly NetcattyInstalledPlugin[]) {
|
||||
const [result, setResult] = useState<PluginUpdateCheckResult | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const pending = useRef(false);
|
||||
const mounted = useRef(false);
|
||||
useEffect(() => { mounted.current = true; return () => { mounted.current = false; }; }, []);
|
||||
const generation = useRef(0);
|
||||
const identityKey = useMemo(() => JSON.stringify(plugins.map((plugin) => [
|
||||
plugin.id, plugin.activeVersion, plugin.archiveSha256, plugin.source?.id,
|
||||
plugin.source?.kind, plugin.source?.origin,
|
||||
])), [plugins]);
|
||||
useEffect(() => {
|
||||
generation.current++;
|
||||
setResult(null);
|
||||
setError(null);
|
||||
const requests = generation;
|
||||
return () => { requests.current++; };
|
||||
}, [identityKey]);
|
||||
useEffect(() => {
|
||||
const invalidate = () => {
|
||||
generation.current++;
|
||||
setResult(null);
|
||||
setError(null);
|
||||
};
|
||||
const unsubscribe = netcattyBridge.get()?.onPluginContributionsChanged?.(invalidate);
|
||||
window.addEventListener('netcatty:plugin-packages-changed', invalidate);
|
||||
return () => { unsubscribe?.(); window.removeEventListener('netcatty:plugin-packages-changed', invalidate); };
|
||||
}, []);
|
||||
const check = useCallback(async () => {
|
||||
if (pending.current) return;
|
||||
pending.current = true;
|
||||
const current = generation.current;
|
||||
setBusy(true); setError(null); setResult(null);
|
||||
try {
|
||||
const value = await checkPluginUpdates(netcattyBridge.require());
|
||||
if (mounted.current && current === generation.current) setResult(value);
|
||||
} catch (cause) {
|
||||
if (mounted.current && current === generation.current) setError(cause instanceof Error ? cause.message : String(cause));
|
||||
} finally {
|
||||
pending.current = false;
|
||||
// This component may still be mounted with a refreshed package list.
|
||||
if (mounted.current) setBusy(false);
|
||||
}
|
||||
}, []);
|
||||
return { result, error, busy, check };
|
||||
}
|
||||
@@ -776,6 +776,13 @@ export const useVaultState = (options?: UseVaultStateOptions) => {
|
||||
}
|
||||
}, [waitForPendingVaultWrites]);
|
||||
|
||||
// Host-owned action guards need the synchronous mutation snapshot, including
|
||||
// edits that React has not published yet. This accessor never crosses RPC.
|
||||
const readCurrentHostActionState = useCallback(() => ({
|
||||
hosts: hostsRef.current, groupConfigs: groupConfigsRef.current, snippets: snippetsRef.current,
|
||||
managedSources: managedSourcesRef.current,
|
||||
}), []);
|
||||
|
||||
const updateKeys = useCallback((data: SSHKey[]) => {
|
||||
const cleaned = normalizeVaultOrder(data);
|
||||
setKeys(cleaned);
|
||||
@@ -1248,6 +1255,7 @@ export const useVaultState = (options?: UseVaultStateOptions) => {
|
||||
updateGroupConfigs?: (current: GroupConfig[]) => GroupConfig[],
|
||||
expectedHosts?: Host[],
|
||||
lock?: VaultLockHandle | null,
|
||||
assertAuthorized?: () => void | Promise<void>,
|
||||
): Promise<
|
||||
| {
|
||||
status: "persisted";
|
||||
@@ -1322,6 +1330,10 @@ export const useVaultState = (options?: UseVaultStateOptions) => {
|
||||
encryptHosts(cleanedHosts),
|
||||
encryptGroupConfigs(nextGroupConfigs),
|
||||
]);
|
||||
// Optional plugin draft guard: runtime/source/revoke/lock authority must
|
||||
// still hold after encryption, immediately before the existing atomic
|
||||
// persistence boundary. Ordinary imports retain their existing behavior.
|
||||
if (assertAuthorized) await assertAuthorized();
|
||||
if (
|
||||
version !== hostsWriteVersion.current
|
||||
|| groupConfigsVersion !== groupConfigsWriteVersion.current
|
||||
@@ -2520,6 +2532,7 @@ export const useVaultState = (options?: UseVaultStateOptions) => {
|
||||
groupConfigs,
|
||||
updateHosts,
|
||||
readPersistedHosts,
|
||||
readCurrentHostActionState,
|
||||
updateKeys,
|
||||
importOrReuseKey,
|
||||
updateIdentities,
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { createRequire } from 'node:module';
|
||||
import { mkdtemp, cp, rm } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import { sanitizeHost } from '../domain/host';
|
||||
import type { ConnectionLog, Host, ManagedSource } from '../domain/models';
|
||||
import { applyGroupDefaults, resolveGroupDefaults } from '../domain/groupConfig';
|
||||
import type { HostInteractionEvent, HostInteractionPrompt, VaultHostSelectionResult, VaultHostActionResult, VaultHostRef } from '@netcatty/plugin-contract';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
test('installed sample entrypoint uses real SDK, source authority, signed host dialogs and renderer action guards', { timeout: 45_000 }, async (t) => {
|
||||
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', 'HTMLButtonElement', 'Element', 'SVGElement', 'Node', 'NodeFilter', 'MutationObserver', 'CustomEvent', 'Event', 'KeyboardEvent', 'MouseEvent']) {
|
||||
install(key, key === 'window' ? dom.window : dom.window[key as keyof typeof dom.window]);
|
||||
}
|
||||
install('getComputedStyle', dom.window.getComputedStyle.bind(dom.window));
|
||||
install('requestAnimationFrame', dom.window.requestAnimationFrame.bind(dom.window));
|
||||
install('cancelAnimationFrame', dom.window.cancelAnimationFrame.bind(dom.window));
|
||||
install('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
install('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} });
|
||||
const { default: React, act } = await import('react');
|
||||
const { createRoot } = await import('react-dom/client');
|
||||
const { I18nProvider } = await import('../application/i18n/I18nProvider');
|
||||
const { PluginHostActionHost } = await import('./plugins/PluginHostActionHost');
|
||||
const { handleConnectToHostImpl } = await import('../application/app/AppHandlers');
|
||||
const runtime = await import('../application/state/appRuntimeBridge');
|
||||
const { registerAppHandlers } = await import('../application/app/appHandlersBridge');
|
||||
const { createPluginHostService } = require('../electron/plugins/hostService.cjs');
|
||||
const { inProcessBrowserRuntime } = require('../electron/plugins/fixtures/inProcessBrowserRuntime.cjs');
|
||||
const directory = await mkdtemp(path.join(tmpdir(), 'netcatty-host-entry-'));
|
||||
const listeners = new Set<(event: unknown) => void>();
|
||||
const prompts: HostInteractionPrompt[] = [];
|
||||
const sender = Object.assign(new EventEmitter(), { id: 77, isDestroyed: () => false, send(_channel: string, event: HostInteractionEvent) { if (!('closed' in event)) prompts.push(event); for (const listener of listeners) listener(event); } });
|
||||
const owner = Object.assign(new EventEmitter(), { webContents: sender, isFocused: () => true, isVisible: () => true, isDestroyed: () => false });
|
||||
let locked = false;
|
||||
const lockListeners = new Set<(state: { locked: boolean }) => void>();
|
||||
const service = createPluginHostService({
|
||||
app: { getAppPath: () => process.cwd(), getPath: () => directory, getVersion: () => '0.0.0' }, electron: {}, env: {},
|
||||
getInteractionWindow: () => owner, isAppLocked: () => locked,
|
||||
subscribeAppLock: (callback: (state: { locked: boolean }) => void) => { lockListeners.add(callback); return () => lockListeners.delete(callback); },
|
||||
requestPermissionDecision: async (request: { requestId: string; permission: string }) => ({ requestId: request.requestId, decision: 'allow', scope: ['vault.metadata', 'vault.write', 'ui.interact'].includes(request.permission) ? 'once' : 'application' }),
|
||||
});
|
||||
let requestClock = Date.now();
|
||||
service.interactionService.clock = () => requestClock;
|
||||
service.runtimeSupervisor.runtimeFactories.browser = inProcessBrowserRuntime;
|
||||
Object.assign(dom.window, { netcatty: {
|
||||
onPluginInteraction(callback: (event: unknown) => void) { listeners.add(callback); return () => listeners.delete(callback); },
|
||||
respondPluginInteraction: (response: unknown) => service.interactionService.respond(sender, response),
|
||||
} });
|
||||
const first = sanitizeHost({ id: 'synthetic-first', label: 'Synthetic first', hostname: 'first.invalid', username: 'test', port: 22, protocol: 'ssh', os: 'linux', tags: ['synthetic'], group: 'Synthetic', password: 'synthetic-password', startupCommand: 'synthetic-script' });
|
||||
const second = sanitizeHost({ ...first, id: 'synthetic-second', label: 'Synthetic second', hostname: 'second.invalid', password: undefined, startupCommand: undefined });
|
||||
let hosts: Host[] = [first, second];
|
||||
let persisted = hosts.map((host) => structuredClone(host));
|
||||
let beforeCommit: (() => Promise<void>) | undefined;
|
||||
let saves = 0;
|
||||
const connections: Host[] = [];
|
||||
const vault = {
|
||||
hosts, customGroups: ['Synthetic'], groupConfigs: [], snippets: [], managedSources: [] as ManagedSource[], isInitialized: true,
|
||||
readCurrentHostActionState: () => ({ hosts, groupConfigs: vault.groupConfigs, snippets: vault.snippets, managedSources: vault.managedSources }),
|
||||
async readPersistedHosts() { return persisted.map((host) => structuredClone(host)); },
|
||||
async commitVaultImportTransaction(next: Host[], groups: (groups: string[]) => string[], sources: (sources: ManagedSource[]) => ManagedSource[], _configs: unknown, _expected: unknown, _lock: unknown, authorize: () => Promise<void>) {
|
||||
const nextSources = sources(vault.managedSources);
|
||||
await beforeCommit?.();
|
||||
await authorize();
|
||||
saves++; hosts = next; persisted = next.map((host) => structuredClone(host));
|
||||
return { status: 'persisted', groups: groups(vault.customGroups), sources: nextSources, groupConfigs: [] };
|
||||
},
|
||||
};
|
||||
const publish = () => {
|
||||
runtime.registerAppVaultRuntime(vault as never);
|
||||
runtime.registerAppAppLockRuntime({ initialized: true, locked } as never);
|
||||
};
|
||||
publish();
|
||||
registerAppHandlers({ handleConnectToHost: (host: Host) => { connections.push(host); return 'private-session-id'; } });
|
||||
const root = createRoot(document.getElementById('root')!);
|
||||
const settle = () => new Promise((resolve) => setTimeout(resolve, 20));
|
||||
const waitFor = async (predicate: () => boolean) => {
|
||||
for (let count = 0; count < 80; count++) { await act(async () => { await settle(); }); if (predicate()) return; }
|
||||
assert.fail(`Expected host UI state; current text: ${document.body.textContent}`);
|
||||
};
|
||||
const button = (text: string) => Array.from(document.querySelectorAll<HTMLButtonElement>('button')).find((item) => item.textContent === text)!;
|
||||
const click = async (element: HTMLElement) => { assert.ok(element); await act(async () => { element.click(); await settle(); }); };
|
||||
const begin = async (name: string) => {
|
||||
requestClock += 60_001;
|
||||
let promise!: Promise<VaultHostSelectionResult | VaultHostActionResult>;
|
||||
await act(async () => { promise = service.contributionService.executeCommand(`com.netcatty.host-actions.${name}`); void promise.catch(() => {}); await settle(); });
|
||||
await waitFor(() => document.querySelector('[role="dialog"]') !== null);
|
||||
return { promise };
|
||||
};
|
||||
const chooseGroup = async () => {
|
||||
await click(document.querySelector<HTMLElement>('[role="option"] button[aria-pressed]')!);
|
||||
await click(Array.from(document.querySelectorAll<HTMLButtonElement>('button')).find((item) => item.textContent?.startsWith('Continue ('))!);
|
||||
};
|
||||
try {
|
||||
await service.manager.initialize();
|
||||
const packageRoot = path.join(directory, 'source');
|
||||
await cp(new URL('../examples/plugins/host-actions/', import.meta.url), packageRoot, { recursive: true });
|
||||
const { buildPluginPackage } = await import('@netcatty/plugin-cli');
|
||||
const archive = path.join(directory, 'fixture.ncpkg');
|
||||
await buildPluginPackage(packageRoot, archive);
|
||||
await service.manager.install(archive, { enable: true });
|
||||
service.interactionService.setReady(sender, true);
|
||||
await act(async () => root.render(<I18nProvider locale="en"><PluginHostActionHost /></I18nProvider>));
|
||||
|
||||
let selectedTarget: VaultHostRef;
|
||||
await t.test('group selection shares only explicit current members and grants no connection', async () => {
|
||||
const run = await begin('select');
|
||||
await click(document.querySelector<HTMLElement>('[role="option"] button[aria-pressed]')!);
|
||||
hosts = [...hosts, sanitizeHost({ ...second, id: 'synthetic-future', hostname: 'future.invalid' })];
|
||||
persisted = hosts.map((host) => structuredClone(host));
|
||||
await click(Array.from(document.querySelectorAll<HTMLButtonElement>('button')).find((item) => item.textContent?.startsWith('Continue ('))!);
|
||||
const result = await run.promise;
|
||||
assert.ok('hosts' in result);
|
||||
assert.equal(result.hosts.length, 2); assert.equal(result.groups[0].members.length, 2);
|
||||
assert.equal(result.hosts.some((host) => host.metadata.hostname === 'future.invalid'), false);
|
||||
assert.equal(result.hosts[0].target.scope, 'metadata');
|
||||
selectedTarget = result.hosts[0].target;
|
||||
for (const text of ['synthetic-password', 'synthetic-script', 'resourceId', 'private-session-id']) assert.equal(JSON.stringify(result).includes(text), false);
|
||||
assert.equal(connections.length, 0); assert.equal(saves, 0);
|
||||
});
|
||||
|
||||
await t.test('registered boundaries reject AI text, caller owners, tampered targets and stale expected revisions', async () => {
|
||||
const identity = service.runtimeSupervisor.getRuntimeIdentity('com.netcatty.host-actions');
|
||||
const routes = service.rpcRegistry.createRoutes({ ...identity, manifest: service.database.getActivePlugin(identity.pluginId).manifest });
|
||||
const transport = { signal: new AbortController().signal };
|
||||
for (const target of [
|
||||
{ handle: 'text-only', version: 'v1', scope: 'host' },
|
||||
{ ...selectedTarget, handle: 'synthetic-tampered-selection-handle-0000' },
|
||||
]) await assert.rejects(routes.requestHandlers['vault.requestConnect']({ target, expectedRevision: selectedTarget.revision }, transport), /invalid|expired|changed/);
|
||||
await assert.rejects(routes.requestHandlers['vault.requestConnect']({ target: selectedTarget, expectedRevision: 'stale' }, transport), /expired|changed/);
|
||||
await assert.rejects(routes.requestHandlers['vault.requestHostDraft']({ privateOwner: 'spoof', draft: { hostname: 'synthetic.invalid' } }, transport), /invalid/);
|
||||
assert.equal(service.interactionService.pending.size, 0); assert.equal(connections.length, 0);
|
||||
});
|
||||
|
||||
await t.test('cancel creates no draft or connection', async () => {
|
||||
const run = await begin('create'); await click(button('Cancel'));
|
||||
assert.deepEqual(await run.promise, { cancelled: true }); assert.equal(saves, 0); assert.equal(connections.length, 0);
|
||||
});
|
||||
|
||||
await t.test('reviewed create saves only the synthetic SSH draft', async () => {
|
||||
const run = await begin('create');
|
||||
assert.match(document.body.textContent ?? '', /Changes to save/);
|
||||
await click(button('Save Host'));
|
||||
assert.deepEqual(await run.promise, { cancelled: false, status: 'saved' });
|
||||
assert.equal(saves, 1);
|
||||
const saved = hosts.find((host) => host.hostname === 'synthetic.invalid')!;
|
||||
assert.equal(saved.protocol, 'ssh'); assert.equal(saved.password, undefined); assert.equal(saved.startupCommand, undefined);
|
||||
});
|
||||
|
||||
for (const change of ['remove', 'replace'] as const) await t.test(`${change} after selection prevents a stale edit`, async () => {
|
||||
hosts = [first, second]; persisted = hosts.map((host) => structuredClone(host));
|
||||
const run = await begin('edit'); await chooseGroup();
|
||||
await waitFor(() => document.body.textContent?.includes('Changes to save') === true);
|
||||
hosts = change === 'remove' ? [second] : [{ ...first }, second];
|
||||
await click(button('Save Host'));
|
||||
await assert.rejects(run.promise, /changed|cancelled|complete/);
|
||||
assert.equal(saves, 1); assert.equal(connections.length, 0);
|
||||
});
|
||||
|
||||
await t.test('connection confirmation discloses saved scripts before dispatch and returns no session', async () => {
|
||||
hosts = [first, second]; persisted = hosts.map((host) => structuredClone(host));
|
||||
const run = await begin('connect'); await chooseGroup();
|
||||
await waitFor(() => document.body.textContent?.includes('Saved startup, connect or output scripts may run') === true);
|
||||
assert.equal(connections.length, 0); assert.equal(document.body.textContent?.includes('synthetic-script'), false);
|
||||
await click(button('Connect'));
|
||||
assert.deepEqual(await run.promise, { cancelled: false, status: 'requested' });
|
||||
assert.equal(connections.length, 1); assert.equal(connections[0], first);
|
||||
});
|
||||
|
||||
await t.test('Vault lock cancels a pending connection', async () => {
|
||||
const run = await begin('connect'); await chooseGroup();
|
||||
await waitFor(() => button('Connect') !== undefined);
|
||||
await act(async () => { locked = true; publish(); for (const listener of lockListeners) listener({ locked: true }); await settle(); });
|
||||
await assert.rejects(run.promise, /cancelled/); assert.equal(connections.length, 1);
|
||||
locked = false; publish();
|
||||
});
|
||||
|
||||
await t.test('revoke during asynchronous preparation prevents the write', async () => {
|
||||
hosts = [first, second]; persisted = hosts.map((host) => structuredClone(host));
|
||||
let release!: () => void; let preparing = false;
|
||||
beforeCommit = async () => { preparing = true; await new Promise<void>((resolve) => { release = resolve; }); };
|
||||
const run = await begin('edit'); await chooseGroup();
|
||||
await waitFor(() => button('Save Host') !== undefined); await click(button('Save Host'));
|
||||
await waitFor(() => preparing);
|
||||
await act(async () => { service.permissionEngine.revokeApplication('com.netcatty.host-actions', 'vault.write'); release(); await settle(); });
|
||||
await assert.rejects(run.promise, /revoked|cancelled/); assert.equal(saves, 1); beforeCommit = undefined;
|
||||
});
|
||||
|
||||
await t.test('source replacement during review cannot use the old action authority', async () => {
|
||||
const run = await begin('create');
|
||||
const original = service.database.getRetainedPluginSource;
|
||||
service.database.getRetainedPluginSource = () => ({ id: 'changed-source', kind: 'local', origin: null });
|
||||
try { await click(button('Save Host')); await assert.rejects(run.promise, /source binding/); }
|
||||
finally { service.database.getRetainedPluginSource = original; }
|
||||
assert.equal(saves, 1); assert.equal(connections.length, 1);
|
||||
});
|
||||
|
||||
await t.test('managed A to B stays in the Vault editor and cannot acquire file-write effects', async () => {
|
||||
const candidate = sanitizeHost({ ...first, group: 'A', managedSourceId: 'synthetic-source-a' });
|
||||
hosts = [candidate]; persisted = hosts.map((host) => structuredClone(host));
|
||||
vault.managedSources = [
|
||||
{ id: 'synthetic-source-a', type: 'ssh_config', filePath: '/synthetic/a.conf', groupName: 'A', lastSyncedAt: 0 },
|
||||
{ id: 'synthetic-source-b', type: 'ssh_config', filePath: '/synthetic/b.conf', groupName: 'B', lastSyncedAt: 0 },
|
||||
];
|
||||
const run = await begin('edit'); await chooseGroup();
|
||||
await waitFor(() => button('Save Host') !== undefined);
|
||||
const group = Array.from(document.querySelectorAll('label')).find((label) => label.querySelector('span')?.textContent === 'Group')?.querySelector('input');
|
||||
assert.ok(group);
|
||||
const setValue = Object.getOwnPropertyDescriptor(dom.window.HTMLInputElement.prototype, 'value')!.set!;
|
||||
await act(async () => { setValue.call(group, 'B'); group.dispatchEvent(new dom.window.Event('input', { bubbles: true })); await settle(); });
|
||||
assert.match(document.querySelector('[role="dialog"]')?.textContent ?? '', /"A"\s*\u2192\s*"B"/u);
|
||||
assert.match(document.querySelector('[role="alert"]')?.textContent ?? '', /Managed SSH config changes require the Vault host editor/);
|
||||
assert.equal(button('Save Host').disabled, true);
|
||||
await click(button('Save Host')); assert.equal(saves, 1);
|
||||
await click(button('Cancel')); assert.deepEqual(await run.promise, { cancelled: true });
|
||||
assert.equal(hosts[0].group, 'A'); assert.equal(hosts[0].managedSourceId, 'synthetic-source-a');
|
||||
vault.managedSources = [];
|
||||
});
|
||||
|
||||
await t.test('creating into a managed group is explicitly unavailable in the small draft UI', async () => {
|
||||
vault.managedSources = [{ id: 'synthetic-source-b', type: 'ssh_config', filePath: '/synthetic/b.conf', groupName: 'Samples', lastSyncedAt: 0 }];
|
||||
const run = await begin('create');
|
||||
assert.match(document.querySelector('[role="alert"]')?.textContent ?? '', /Managed SSH config changes require the Vault host editor/);
|
||||
assert.equal(button('Save Host').disabled, true);
|
||||
await click(button('Cancel')); assert.deepEqual(await run.promise, { cancelled: true });
|
||||
assert.equal(saves, 1); vault.managedSources = [];
|
||||
});
|
||||
|
||||
for (const transport of [
|
||||
{ name: 'Mosh', expected: 'mosh', overrides: { moshEnabled: true } },
|
||||
{ name: 'ET', expected: 'et', overrides: { etEnabled: true } },
|
||||
{ name: 'implicit localhost shell', expected: 'local', overrides: { hostname: 'localhost' } },
|
||||
] as const) await t.test(`${transport.name} confirmation matches the real connection handler`, async () => {
|
||||
const candidate = sanitizeHost({ ...second, id: `synthetic-${transport.expected}`, label: `Synthetic ${transport.expected}`, ...transport.overrides });
|
||||
hosts = [candidate]; persisted = hosts.map((host) => structuredClone(host));
|
||||
const dispatched: Host[] = [];
|
||||
const logs: Array<Pick<ConnectionLog, 'protocol'>> = [];
|
||||
registerAppHandlers({ handleConnectToHost: (host: Host) => handleConnectToHostImpl(() => ({
|
||||
resolveEffectiveHost: (saved: Host) => applyGroupDefaults(saved, saved.group ? resolveGroupDefaults(saved.group, vault.groupConfigs) : {}),
|
||||
discoveredShells: [], terminalSettings: {}, resolveShellSetting: () => null,
|
||||
systemInfoRef: { current: { username: 'synthetic-local-user', hostname: 'synthetic-device' } },
|
||||
keys: [], identities: [], resolveHostAuth: () => ({ username: 'synthetic-user' }),
|
||||
connectToHost: (resolved: Host) => { dispatched.push(resolved); return 'synthetic-transport-session'; },
|
||||
addConnectionLog: (entry: Pick<ConnectionLog, 'protocol'>) => logs.push(entry),
|
||||
}), host) });
|
||||
const run = await begin('connect');
|
||||
try {
|
||||
await chooseGroup(); await waitFor(() => button('Connect') !== undefined);
|
||||
const confirmation = document.querySelector('[role="dialog"]')?.textContent ?? '';
|
||||
assert.match(confirmation, new RegExp(`\\(${transport.expected}\\)`));
|
||||
assert.equal(confirmation.includes('Local execution: this opens a shell on this device.'), transport.expected === 'local');
|
||||
if (transport.expected === 'local') assert.doesNotMatch(confirmation, /test@localhost:22/u);
|
||||
assert.equal(dispatched.length, 0);
|
||||
await click(button('Connect'));
|
||||
assert.deepEqual(await run.promise, { cancelled: false, status: 'requested' });
|
||||
assert.equal(dispatched.length, 1);
|
||||
assert.equal(logs[0].protocol, transport.expected);
|
||||
} finally {
|
||||
if (button('Cancel')) await click(button('Cancel'));
|
||||
await run.promise.catch(() => {});
|
||||
registerAppHandlers({ handleConnectToHost: (host: Host) => { connections.push(host); return 'private-session-id'; } });
|
||||
}
|
||||
});
|
||||
|
||||
await t.test('runtime removal rejects old signed replies', async () => {
|
||||
const run = await begin('create');
|
||||
const old = prompts.at(-1)!;
|
||||
await act(async () => { await service.manager.setEnabled('com.netcatty.host-actions', false); await settle(); });
|
||||
await assert.rejects(run.promise);
|
||||
assert.equal(await service.interactionService.respond(sender, { requestId: old.requestId, actionToken: old.actionToken, action: 'submit', draft: { hostname: 'late.invalid' } }), false);
|
||||
assert.equal(saves, 1); assert.equal(connections.length, 1);
|
||||
});
|
||||
} finally {
|
||||
await act(async () => { root.unmount(); await settle(); });
|
||||
await service.manager.shutdown();
|
||||
runtime.registerAppVaultRuntime(null); runtime.registerAppAppLockRuntime(null); registerAppHandlers(null);
|
||||
dom.window.close(); await rm(directory, { recursive: true, force: true });
|
||||
for (const [key, descriptor] of previous) { if (descriptor) Object.defineProperty(globalThis, key, descriptor); else Reflect.deleteProperty(globalThis, key); }
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import React from 'react';
|
||||
import { I18nProvider } from '../application/i18n/I18nProvider';
|
||||
import { PluginHostDraftPreview } from './plugins/PluginHostDraftPreview';
|
||||
import { createDomRenderer, dispatchDomEvent, installDomEnvironment } from './test-support/renderReactDom';
|
||||
import type { Host } from '../domain/models';
|
||||
import type { PluginHostDraftFields } from '../domain/pluginHostActions';
|
||||
|
||||
test('host-owned draft preview shows a field diff and submits only reviewed editable fields', async () => {
|
||||
const dom = installDomEnvironment();
|
||||
const renderer = await createDomRenderer(dom.document);
|
||||
const host: Host = { id: 'synthetic', label: 'Before', hostname: 'host.invalid', username: 'test', tags: [], os: 'linux', password: 'synthetic-password', startupCommand: 'synthetic-script' };
|
||||
const submitted: PluginHostDraftFields[] = [];
|
||||
try {
|
||||
await renderer.render(<I18nProvider locale="en"><PluginHostDraftPreview before={host} draft={{ label: 'After' }} busy={false} onConfirm={(draft) => submitted.push(draft)} /></I18nProvider>);
|
||||
assert.equal(dom.document.querySelectorAll('input').length, 3);
|
||||
assert.match(renderer.container.textContent ?? '', /Before.*After/u);
|
||||
assert.equal(renderer.container.innerHTML.includes('synthetic-password'), false);
|
||||
assert.equal(renderer.container.innerHTML.includes('synthetic-script'), false);
|
||||
const save = dom.document.querySelector('button');
|
||||
assert.ok(save);
|
||||
await dispatchDomEvent(save, new dom.window.MouseEvent('click', { bubbles: true }));
|
||||
assert.deepEqual(submitted, [{ label: 'After', group: '', tags: [] }]);
|
||||
} finally { await renderer.unmount(); dom.cleanup(); }
|
||||
});
|
||||
|
||||
test('invalid create draft cannot be confirmed', async () => {
|
||||
const dom = installDomEnvironment();
|
||||
const renderer = await createDomRenderer(dom.document);
|
||||
try {
|
||||
await renderer.render(<I18nProvider locale="en"><PluginHostDraftPreview draft={{ hostname: 'invalid host' }} busy={false} onConfirm={() => { throw new Error('Invalid draft was submitted'); }} /></I18nProvider>);
|
||||
assert.ok(dom.document.querySelector('[role="alert"]'));
|
||||
const save = dom.document.querySelector('button');
|
||||
assert.ok(save?.disabled);
|
||||
await dispatchDomEvent(save, new dom.window.MouseEvent('click', { bubbles: true }));
|
||||
} finally { await renderer.unmount(); dom.cleanup(); }
|
||||
});
|
||||
@@ -50,6 +50,7 @@ export interface SelectHostPanelContentProps {
|
||||
/** Preferred multi-select path for host/group toggles (selection resolves to host ids). */
|
||||
onSelectionChange?: (selectedHostIds: string[]) => void;
|
||||
onConfirm: () => void;
|
||||
confirmDisabled?: boolean;
|
||||
onNewHost?: () => void;
|
||||
availableKeys?: SSHKey[];
|
||||
identities?: import('../domain/models').Identity[];
|
||||
@@ -79,6 +80,7 @@ export const SelectHostPanelContent: React.FC<SelectHostPanelContentProps> = ({
|
||||
onSelect,
|
||||
onSelectionChange,
|
||||
onConfirm,
|
||||
confirmDisabled = false,
|
||||
onNewHost,
|
||||
availableKeys = [],
|
||||
identities = [],
|
||||
@@ -643,7 +645,7 @@ export const SelectHostPanelContent: React.FC<SelectHostPanelContentProps> = ({
|
||||
<div className="px-4 py-3 border-t border-border/60 shrink-0">
|
||||
<Button
|
||||
className="w-full"
|
||||
disabled={selectedHostIds.length === 0}
|
||||
disabled={confirmDisabled || selectedHostIds.length === 0}
|
||||
onClick={onConfirm}
|
||||
>
|
||||
{multiSelect
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { usePluginHostActions } from '../../application/state/usePluginHostActions';
|
||||
import { applyGroupDefaults, resolveGroupDefaults } from '../../domain/groupConfig';
|
||||
import { resolveEffectiveTerminalProtocol } from '../../domain/terminalProtocol';
|
||||
import { SelectHostPanelContent } from '../SelectHostPanelContent';
|
||||
import { Button } from '../ui/button';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '../ui/dialog';
|
||||
import { PluginHostDraftPreview } from './PluginHostDraftPreview';
|
||||
|
||||
export function PluginHostActionHost() {
|
||||
const { t } = useI18n();
|
||||
const state = usePluginHostActions();
|
||||
const entry = state.review;
|
||||
if (!entry) return null;
|
||||
const request = entry.prompt.request;
|
||||
const selection = request.kind === 'vault.selectHosts';
|
||||
const connection = request.kind === 'vault.requestConnect';
|
||||
const effective = entry.host ? applyGroupDefaults(entry.host, entry.host.group ? resolveGroupDefaults(entry.host.group, entry.groups) : {}) : undefined;
|
||||
const protocol = effective ? resolveEffectiveTerminalProtocol(effective) : undefined;
|
||||
return <Dialog open onOpenChange={(open) => { if (!open) void state.respond('cancel'); }}>
|
||||
<DialogContent className={selection ? 'max-w-md h-[min(85vh,640px)] flex flex-col p-0 gap-0 overflow-hidden' : 'sm:max-w-[560px] max-h-[85vh] overflow-y-auto'} hideCloseButton>
|
||||
<DialogHeader className={selection ? 'px-4 pt-4 pb-2 shrink-0 border-b border-border/60' : undefined}>
|
||||
<DialogTitle>{t(selection ? 'plugins.hostActions.select' : connection ? 'plugins.hostActions.connect' : entry.host ? 'plugins.hostActions.edit' : 'plugins.hostActions.create')}</DialogTitle>
|
||||
<DialogDescription className="break-all">{entry.prompt.pluginId} · {entry.prompt.pluginVersion}</DialogDescription>
|
||||
</DialogHeader>
|
||||
{selection ? <>
|
||||
<p className="px-4 pt-3 text-xs text-muted-foreground">{t('plugins.hostActions.selectionNote')}</p>
|
||||
<p className="px-4 pt-2 text-sm font-medium break-words">{request.title}</p>
|
||||
{state.selectedIds.length > 50 && <p role="alert" className="px-4 text-sm text-destructive">{t('plugins.hostActions.selectionLimit')}</p>}
|
||||
<SelectHostPanelContent hosts={entry.candidates} customGroups={entry.customGroups} selectedHostIds={state.selectedIds} multiSelect className="flex-1 min-h-0" confirmDisabled={state.busy || state.selectedIds.length > 50} onSelect={(host) => state.setSelectedIds((ids) => ids.includes(host.id) ? ids.filter((id) => id !== host.id) : [...ids, host.id])} onSelectionChange={state.setSelectedIds} onConfirm={() => { if (!state.busy && state.selectedIds.length <= 50) void state.respond('submit'); }} />
|
||||
</> : connection && effective ? <>
|
||||
<p className="break-all text-sm font-medium">{effective.label}</p>
|
||||
<p className="break-all text-sm">{protocol !== 'local' && effective.username ? `${effective.username}@` : ''}{effective.hostname}{protocol !== 'local' && effective.port ? `:${effective.port}` : ''} ({protocol})</p>
|
||||
{protocol === 'local' && <p className="text-sm font-medium" role="note">{t('plugins.hostActions.localExecution')}</p>}
|
||||
<p className="text-sm" role="note">{t(entry.scriptsRun ? 'plugins.hostActions.scriptsRun' : 'plugins.hostActions.noScripts')}</p>
|
||||
<p className="text-xs text-muted-foreground">{t('plugins.hostActions.connectionNote')}</p>
|
||||
</> : request.kind === 'vault.requestHostDraft' ? <PluginHostDraftPreview key={entry.prompt.requestId} before={entry.host} draft={request.draft} managedSources={entry.managedSources} busy={state.busy} onConfirm={(draft) => void state.respond('submit', draft)} /> : null}
|
||||
{state.error && <p role="alert" className="text-sm text-destructive">{t('plugins.hostActions.failed')}</p>}
|
||||
<DialogFooter className={selection ? 'p-3 border-t' : undefined}>
|
||||
<Button variant="outline" disabled={state.busy} onClick={() => void state.respond('cancel')}>{t('common.cancel')}</Button>
|
||||
{connection && <Button disabled={state.busy} onClick={() => void state.respond('submit')}>{t('common.connect')}</Button>}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { applyPluginHostEdit, normalizePluginHostDraft, pluginHostDraftTouchesManagedSource, pluginHostFieldDiff, PLUGIN_HOST_CREATE_FIELDS, PLUGIN_HOST_EDIT_FIELDS, type PluginHostDraftFields } from '../../domain/pluginHostActions';
|
||||
import type { Host, ManagedSource } from '../../domain/models';
|
||||
import { Button } from '../ui/button';
|
||||
import { Input } from '../ui/input';
|
||||
|
||||
/** Host-owned editable draft and exact normalized field diff. The containing
|
||||
* dialog owns confirmation, expiry and cancellation; this body has no IPC. */
|
||||
export function PluginHostDraftPreview({ before, draft, busy, onConfirm, managedSources = [] }: {
|
||||
before?: Host;
|
||||
draft: PluginHostDraftFields;
|
||||
busy: boolean;
|
||||
onConfirm: (draft: PluginHostDraftFields) => void;
|
||||
managedSources?: ManagedSource[];
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const [fields, setFields] = useState<Record<string, string>>(() => ({
|
||||
label: draft.label ?? before?.label ?? '',
|
||||
hostname: draft.hostname ?? '', username: draft.username ?? '',
|
||||
port: String(draft.port ?? 22), group: draft.group ?? before?.group ?? '',
|
||||
tags: (draft.tags ?? before?.tags ?? []).join(', '),
|
||||
}));
|
||||
const preview = useMemo(() => {
|
||||
const value = {
|
||||
label: fields.label, group: fields.group,
|
||||
tags: fields.tags.split(',').map((tag) => tag.trim()).filter(Boolean),
|
||||
...(!before ? { hostname: fields.hostname, username: fields.username, port: Number(fields.port) } : {}),
|
||||
};
|
||||
try {
|
||||
const after = before ? applyPluginHostEdit(before, value) : normalizePluginHostDraft(value, 'preview');
|
||||
return { value: {
|
||||
label: after.label, group: after.group ?? '', tags: after.tags,
|
||||
...(!before ? { hostname: after.hostname, username: after.username, port: after.port } : {}),
|
||||
}, diff: pluginHostFieldDiff(before, after), managed: pluginHostDraftTouchesManagedSource(after, managedSources) };
|
||||
} catch { return null; }
|
||||
}, [before, fields, managedSources]);
|
||||
return <div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{(before ? PLUGIN_HOST_EDIT_FIELDS : PLUGIN_HOST_CREATE_FIELDS).map((field) => <label key={field} className="space-y-1 text-sm">
|
||||
<span>{t(`hostForm.field.${field}`)}</span>
|
||||
<Input value={fields[field]} type={field === 'port' ? 'number' : 'text'} min={field === 'port' ? 1 : undefined} max={field === 'port' ? 65535 : undefined} maxLength={field === 'tags' ? 32_896 : 1024} autoComplete="off" disabled={busy} onChange={(event) => setFields((previous) => ({ ...previous, [field]: event.target.value }))} />
|
||||
</label>)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{t('plugins.hostActions.defaultsWarning')}</p>
|
||||
{preview?.managed && <p role="alert" className="text-sm text-destructive">{t('plugins.hostActions.managedUnsupported')}</p>}
|
||||
{preview ? <div className="max-h-48 overflow-auto rounded-md border p-3">
|
||||
<p className="mb-2 text-sm font-medium">{t('plugins.hostActions.fieldDiff')}</p>
|
||||
<dl className="space-y-2 text-xs">
|
||||
{preview.diff.map((change) => <div key={change.field} className="break-all">
|
||||
<dt className="font-medium">{t(`hostForm.field.${change.field}`)}</dt>
|
||||
<dd>{JSON.stringify(change.before)} → {JSON.stringify(change.after)}</dd>
|
||||
</div>)}
|
||||
</dl>
|
||||
</div> : <p role="alert" className="text-sm text-destructive">{t('plugins.hostActions.invalidDraft')}</p>}
|
||||
<Button className="w-full" disabled={busy || !preview || preview.managed || preview.diff.length === 0} onClick={() => { if (preview && !preview.managed) onConfirm(preview.value); }}>{t('hostForm.saveHost')}</Button>
|
||||
</div>;
|
||||
}
|
||||
@@ -201,8 +201,9 @@ export const SettingCard: React.FC<SettingCardProps> = ({
|
||||
);
|
||||
|
||||
interface SettingRowProps {
|
||||
label?: string;
|
||||
description?: string;
|
||||
leading?: React.ReactNode;
|
||||
label?: React.ReactNode;
|
||||
description?: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
align?: "center" | "start";
|
||||
/** Stable catalog id for settings search jump targets. */
|
||||
@@ -210,6 +211,7 @@ interface SettingRowProps {
|
||||
}
|
||||
|
||||
export const SettingRow: React.FC<SettingRowProps> = ({
|
||||
leading,
|
||||
label,
|
||||
description,
|
||||
children,
|
||||
@@ -225,6 +227,7 @@ export const SettingRow: React.FC<SettingRowProps> = ({
|
||||
align === "start" ? "items-start" : "items-center",
|
||||
)}
|
||||
>
|
||||
{leading && <div className="shrink-0">{leading}</div>}
|
||||
<div className="flex-1 min-w-0">
|
||||
{label && <div className="text-sm font-medium">{label}</div>}
|
||||
{description && (
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { JSDOM } from 'jsdom';
|
||||
|
||||
test('local logging controls default off, disclose verbose risk, and call host-only management actions', async () => {
|
||||
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', 'Element', 'Node', 'Event', 'MouseEvent', 'KeyboardEvent', 'MutationObserver', 'NodeFilter', 'DocumentFragment', 'CustomEvent', 'getComputedStyle']) {
|
||||
install(key, key === 'window' ? dom.window : dom.window[key as keyof typeof dom.window]);
|
||||
}
|
||||
install('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
const { default: React, act } = await import('react');
|
||||
const { createRoot } = await import('react-dom/client');
|
||||
const { I18nProvider } = await import('../../../application/i18n/I18nProvider');
|
||||
const { PluginLogSettings } = await import('./PluginLogSettings');
|
||||
const root = createRoot(document.getElementById('root')!);
|
||||
let status = { saveToDisk: false, verbose: false, droppedRecords: 2, writeErrors: 0, pendingBytes: 0, maxBytes: 3145728, retentionDays: 7 };
|
||||
const settings: Array<{ saveToDisk: boolean; verbose: boolean }> = [];
|
||||
const actions: string[] = [];
|
||||
let failConfigure = false;
|
||||
Object.assign(dom.window, { netcatty: {
|
||||
async getPluginLogStatus() { return status; },
|
||||
async configurePluginLogs(value: { saveToDisk: boolean; verbose: boolean }) { settings.push(value); status = { ...status, ...value }; if (failConfigure) throw new Error('synthetic preferences write failed'); return status; },
|
||||
async openPluginLogFolder() { actions.push('open'); return status; },
|
||||
async exportPluginLogs() { actions.push('export'); return status; },
|
||||
async clearPluginLogs() { actions.push('clear-confirmed-by-native-host'); return status; },
|
||||
} });
|
||||
const button = (label: string) => [...document.querySelectorAll<HTMLButtonElement>('button')].find((item) => item.textContent === label)!;
|
||||
const switches = () => [...document.querySelectorAll<HTMLButtonElement>('[role="switch"]')];
|
||||
try {
|
||||
await act(async () => root.render(<React.StrictMode><I18nProvider locale="en"><PluginLogSettings /></I18nProvider></React.StrictMode>));
|
||||
assert.equal(document.querySelector('[role=dialog]'), null);
|
||||
assert.equal(document.querySelector('input[type=checkbox]'), null);
|
||||
const configure = button('Configure');
|
||||
configure.focus();
|
||||
await act(async () => configure.click());
|
||||
assert.equal(document.querySelectorAll('[role=dialog]').length, 1);
|
||||
assert.equal(switches()[0].getAttribute('aria-checked'), 'false');
|
||||
assert.equal(switches()[0].disabled, false, 'StrictMode initialization completes');
|
||||
assert.equal(switches()[1].disabled, true);
|
||||
assert.match(document.body.textContent!, /no upload or sync/);
|
||||
assert.match(document.body.textContent!, /Dropped: 2/);
|
||||
await act(async () => switches()[0].click());
|
||||
assert.deepEqual(settings, [{ saveToDisk: true, verbose: false }]);
|
||||
await act(async () => switches()[1].click());
|
||||
assert.equal(settings.length, 1, 'opening disclosure does not enable verbose logs');
|
||||
assert.match(document.body.textContent!, /cannot make arbitrary text safe/);
|
||||
await act(async () => button('Cancel').click());
|
||||
assert.equal(settings.length, 1, 'cancelling risk consent leaves verbose off');
|
||||
await act(async () => switches()[1].click());
|
||||
await act(async () => button('Enable temporarily').click());
|
||||
assert.deepEqual(settings[1], { saveToDisk: true, verbose: true });
|
||||
await act(async () => button('Open folder').click());
|
||||
await act(async () => button('Export').click());
|
||||
await act(async () => button('Clear logs…').click());
|
||||
assert.deepEqual(actions, ['open', 'export', 'clear-confirmed-by-native-host']);
|
||||
failConfigure = true;
|
||||
await act(async () => switches()[0].click());
|
||||
assert.deepEqual(settings.at(-1), { saveToDisk: false, verbose: false });
|
||||
assert.equal(switches()[0].getAttribute('aria-checked'), 'false', 'failed configuration refreshes actual OFF host state');
|
||||
assert.equal(switches()[1].getAttribute('aria-checked'), 'false');
|
||||
assert.match(document.querySelector('[role=alert]')!.textContent!, /synthetic preferences write failed/);
|
||||
await act(async () => button('Close').click());
|
||||
assert.equal(document.querySelector('[role=dialog]'), null);
|
||||
assert.equal(document.activeElement, configure);
|
||||
} finally {
|
||||
await act(async () => root.unmount());
|
||||
dom.window.close();
|
||||
for (const [key, descriptor] of previous) {
|
||||
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
|
||||
else Reflect.deleteProperty(globalThis, key);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import React, { useState } from 'react';
|
||||
import { usePluginLogs } from '../../../application/state/usePluginLogs';
|
||||
import { useI18n } from '../../../application/i18n/I18nProvider';
|
||||
import { Button } from '../../ui/button';
|
||||
import { ConfirmDialog } from '../../ui/confirm-dialog';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from '../../ui/dialog';
|
||||
import { SettingRow, Toggle } from '../settings-ui';
|
||||
|
||||
/** Host settings row; detailed controls stay inside the standard host dialog. */
|
||||
export function PluginLogSettings() {
|
||||
const { t, resolvedLocale } = useI18n();
|
||||
const zh = resolvedLocale.startsWith('zh');
|
||||
const { status, error, busy, run } = usePluginLogs();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [confirmVerbose, setConfirmVerbose] = useState(false);
|
||||
const title = zh ? '本地插件日志' : 'Local plugin logs';
|
||||
const saveLabel = zh ? '将宿主审计日志保存到本地' : 'Save host audit logs locally';
|
||||
const verboseLabel = zh ? '包含插件详细诊断文字' : 'Include verbose plugin text';
|
||||
const disclosure = zh
|
||||
? '插件编写的日志文字可能包含敏感数据。截断和字段过滤不能保证任意文字安全。只为排查问题临时开启,导出前请检查。重启应用后自动关闭详细日志。'
|
||||
: 'Plugin-authored text may contain sensitive data. Truncation and field filtering cannot make arbitrary text safe. Enable temporarily for troubleshooting and review files before sharing. Verbose diagnostics turn off after app restart.';
|
||||
return <div data-host-security-surface="">
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<SettingRow label={title} description={zh ? '选择是否在此设备保存审计记录和诊断日志。' : 'Choose whether to keep audit records and diagnostics on this device.'}>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs text-muted-foreground" role="status">{status ? (status.saveToDisk ? t('common.enabled') : t('common.disabled')) : error ? (zh ? '不可用' : 'Unavailable') : (zh ? '载入中' : 'Loading')}</span>
|
||||
<DialogTrigger asChild><Button className="focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2" variant="outline" size="sm">{zh ? '配置' : 'Configure'}</Button></DialogTrigger>
|
||||
</div>
|
||||
</SettingRow>
|
||||
<DialogContent data-host-security-surface="" className="max-h-[85vh] overflow-y-auto sm:max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>{zh ? '默认不保存。仅保存在此设备,不上传或同步。权限面板中的有限安全历史独立保留。' : 'Saving is off by default. Files stay on this device with no upload or sync. The bounded security history in plugin permissions is separate.'}</DialogDescription>
|
||||
</DialogHeader>
|
||||
{error && <p role="alert" className="text-sm text-destructive">{error}</p>}
|
||||
<div className="divide-y divide-border">
|
||||
<SettingRow label={saveLabel} description={zh ? '保存宿主确认的操作和授权结果,不保存敏感正文。' : 'Save host-observed operations and authorization outcomes without sensitive bodies.'}>
|
||||
<Toggle ariaLabel={saveLabel} checked={status?.saveToDisk ?? false} disabled={busy || !status} onChange={(saveToDisk) => void run('configure', { saveToDisk, verbose: false })} />
|
||||
</SettingRow>
|
||||
<SettingRow label={verboseLabel} description={zh ? '插件文字可能包含敏感数据;只为排查问题临时开启。' : 'Plugin text may contain sensitive data. Enable temporarily for troubleshooting.'}>
|
||||
<Toggle ariaLabel={verboseLabel} checked={status?.verbose ?? false} disabled={busy || !status?.saveToDisk} onChange={(verbose) => verbose ? setConfirmVerbose(true) : void run('configure', { saveToDisk: true, verbose: false })} />
|
||||
</SettingRow>
|
||||
</div>
|
||||
{status && <p className="text-xs text-muted-foreground">{zh ? `最多 ${status.maxBytes / 1048576} MiB,最长保留 ${status.retentionDays} 天。丢弃 ${status.droppedRecords} 条,写入错误 ${status.writeErrors} 次。` : `Up to ${status.maxBytes / 1048576} MiB, up to ${status.retentionDays} days. Dropped: ${status.droppedRecords}. Write errors: ${status.writeErrors}.`}</p>}
|
||||
<DialogFooter className="gap-2 sm:justify-between">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button className="focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2" variant="outline" size="sm" disabled={busy || !status} onClick={() => void run('open')}>{zh ? '打开文件夹' : 'Open folder'}</Button>
|
||||
<Button className="focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2" variant="outline" size="sm" disabled={busy || !status} onClick={() => void run('export')}>{zh ? '导出' : 'Export'}</Button>
|
||||
<Button className="focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2" variant="ghost" size="sm" disabled={busy || !status} onClick={() => void run('clear')}>{zh ? '清除日志…' : 'Clear logs…'}</Button>
|
||||
<Button className="focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2" variant="ghost" size="sm" disabled={busy} onClick={() => void run('status')}>{zh ? '刷新' : 'Refresh'}</Button>
|
||||
</div>
|
||||
<Button className="focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2" variant="outline" size="sm" onClick={() => setOpen(false)}>{t('common.close')}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<ConfirmDialog open={confirmVerbose} title={zh ? '开启插件详细诊断?' : 'Enable verbose plugin diagnostics?'} message={disclosure} confirmLabel={zh ? '临时开启' : 'Enable temporarily'} onOpenChange={setConfirmVerbose} onConfirm={() => { setConfirmVerbose(false); void run('configure', { saveToDisk: true, verbose: true }); }} />
|
||||
</div>;
|
||||
}
|
||||
@@ -17,7 +17,7 @@ test('plugin manager is single-instance, closes with Escape, restores focus and
|
||||
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, useEffect } = await import('react');
|
||||
const { default: React, act, useEffect, useRef, useState } = await import('react');
|
||||
const { createRoot } = await import('react-dom/client');
|
||||
const { I18nProvider } = await import('../../../application/i18n/I18nProvider');
|
||||
const { PluginManagementDialog } = await import('./PluginManagementDialog');
|
||||
@@ -69,6 +69,28 @@ test('plugin manager is single-instance, closes with Escape, restores focus and
|
||||
await click(trigger);
|
||||
await click(document.querySelector<HTMLButtonElement>('[role="dialog"] button:not([data-dialog-close])')!);
|
||||
assert.equal(handedOff, true, 'external view opens only after modal ownership is released');
|
||||
function ControlledEntry() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const browse = useRef<HTMLButtonElement | null>(null);
|
||||
const opener = useRef<HTMLElement | null>(null);
|
||||
return <>
|
||||
<button ref={browse}>Fallback browse</button>
|
||||
<button onClick={(event) => { opener.current = event.currentTarget; setOpen(true); }}>External plugin settings</button>
|
||||
<PluginManagementDialog open={open} onOpenChange={setOpen} trigger={null} restoreFocusTo={opener} fallbackFocusTo={browse}><input aria-label="Controlled search" /></PluginManagementDialog>
|
||||
</>;
|
||||
}
|
||||
await act(async () => root.render(<I18nProvider locale="en"><ControlledEntry /></I18nProvider>));
|
||||
const external = [...document.querySelectorAll<HTMLButtonElement>('button')].find((item) => item.textContent === 'External plugin settings')!;
|
||||
external.focus();
|
||||
await click(external);
|
||||
assert.equal(document.querySelectorAll('[role=dialog]').length, 1);
|
||||
await act(async () => { document.activeElement!.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); await settle(); });
|
||||
assert.equal(document.activeElement, external, 'controlled entry restores the actual row opener');
|
||||
await click(external);
|
||||
external.disabled = true;
|
||||
await act(async () => { document.activeElement!.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); await settle(); });
|
||||
assert.equal(document.activeElement?.textContent, 'Fallback browse', 'a disabled or removed opener uses the stable browse action');
|
||||
|
||||
} finally {
|
||||
await act(async () => root.unmount());
|
||||
dom.window.close();
|
||||
|
||||
@@ -4,18 +4,25 @@ import { Button } from '../../ui/button';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '../../ui/dialog';
|
||||
|
||||
/** Uses the existing renderer and host bridge; opening the manager never starts a plugin. */
|
||||
export function PluginManagementDialog({ children }: {
|
||||
export function PluginManagementDialog({ children, trigger, open: controlledOpen, onOpenChange, restoreFocusTo, fallbackFocusTo }: {
|
||||
trigger?: React.ReactNode | null;
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
restoreFocusTo?: React.RefObject<HTMLElement | null>;
|
||||
fallbackFocusTo?: React.RefObject<HTMLElement | null>;
|
||||
children: React.ReactNode | ((handoff: (action: () => void) => void) => React.ReactNode);
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [internalOpen, setInternalOpen] = useState(false);
|
||||
const open = controlledOpen ?? internalOpen;
|
||||
const setOpen = (value: boolean) => { setInternalOpen(value); onOpenChange?.(value); };
|
||||
const pendingHandoff = useRef<(() => void) | null>(null);
|
||||
const handoff = (action: () => void) => { pendingHandoff.current = action; setOpen(false); };
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline">{t('settings.plugins.openManager')}</Button>
|
||||
</DialogTrigger>
|
||||
{trigger !== null && <DialogTrigger asChild>
|
||||
{trigger ?? <Button variant="outline">{t('settings.plugins.openManager')}</Button>}
|
||||
</DialogTrigger>}
|
||||
<DialogContent onCloseAutoFocus={(event) => {
|
||||
const action = pendingHandoff.current;
|
||||
pendingHandoff.current = null;
|
||||
@@ -24,6 +31,13 @@ export function PluginManagementDialog({ children }: {
|
||||
// Wait for Radix to release modal pointer/focus ownership before opening
|
||||
// the existing external plugin view host (including exit animations).
|
||||
queueMicrotask(action);
|
||||
} else {
|
||||
const previous = restoreFocusTo?.current;
|
||||
const target = previous?.isConnected && !previous.matches(':disabled') ? previous : fallbackFocusTo?.current;
|
||||
if (target?.isConnected) {
|
||||
event.preventDefault();
|
||||
target.focus({ preventScroll: true });
|
||||
}
|
||||
}
|
||||
}} className="flex h-[min(88dvh,900px)] w-[calc(100vw-2rem)] max-w-[1180px] flex-col gap-0 overflow-hidden p-0">
|
||||
<DialogHeader className="shrink-0 border-b px-6 py-4 pr-12 text-left">
|
||||
|
||||
@@ -49,9 +49,9 @@ export function PluginPackageDetails({ plugin }: { plugin: NetcattyInstalledPlug
|
||||
</div>;
|
||||
}
|
||||
|
||||
export function PluginPackageManager({ manager, onSelectionChange }: { manager: ReturnType<typeof usePluginManager>; onSelectionChange?: (id: string | null) => void }) {
|
||||
export function PluginPackageManager({ manager, onSelectionChange, initialSelectedId }: { initialSelectedId?: string | null; manager: ReturnType<typeof usePluginManager>; onSelectionChange?: (id: string | null) => void }) {
|
||||
const { t, resolvedLocale } = useI18n();
|
||||
const [selected, setSelected] = useState<string | null>(null);
|
||||
const [selected, setSelected] = useState<string | null>(initialSelectedId ?? null);
|
||||
const [search, setSearch] = useState('');
|
||||
const [sourceFilter, setSourceFilter] = useState('');
|
||||
const rootRef = useRef<HTMLElement>(null);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { netcattyBridge } from '../../../infrastructure/services/netcattyBridge';
|
||||
import { useI18n } from '../../../application/i18n/I18nProvider';
|
||||
import { describePluginPermission } from '../../../domain/pluginPermissionDescriptions.cjs';
|
||||
import { describePluginPermission } from '@/domain/pluginPermissionDescriptions.cjs';
|
||||
import { Button } from '../../ui/button';
|
||||
|
||||
/** Host UI only. Plugin views cannot query or mutate this security state. */
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { JSDOM } from 'jsdom';
|
||||
|
||||
async function fixture() {
|
||||
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', 'Element', 'Node', 'NodeFilter', 'MutationObserver', 'DocumentFragment', 'CustomEvent', 'Event', 'KeyboardEvent', 'MouseEvent']) install(key, key === 'window' ? dom.window : dom.window[key as keyof typeof dom.window]);
|
||||
install('getComputedStyle', dom.window.getComputedStyle.bind(dom.window));
|
||||
install('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
const frames = new Map<number, FrameRequestCallback>();
|
||||
let frameId = 0;
|
||||
install('requestAnimationFrame', (callback: FrameRequestCallback) => { frames.set(++frameId, callback); return frameId; });
|
||||
install('cancelAnimationFrame', (id: number) => frames.delete(id));
|
||||
const scrolled: HTMLElement[] = [];
|
||||
dom.window.HTMLElement.prototype.scrollIntoView = function () { scrolled.push(this); };
|
||||
const { default: React, act } = await import('react');
|
||||
const { createRoot } = await import('react-dom/client');
|
||||
const { I18nProvider } = await import('../../../application/i18n/I18nProvider');
|
||||
const { Tabs } = await import('../../ui/tabs');
|
||||
const { default: SettingsPluginsTab } = await import('./SettingsPluginsTab');
|
||||
const plugin = { id: 'com.example.alpha', enabled: true, activeVersion: '1.0.0', archiveSha256: 'a'.repeat(64),
|
||||
source: { id: 'local', kind: 'local', name: 'Local import', origin: null },
|
||||
manifest: { manifestVersion: 1, id: 'com.example.alpha', name: 'Alpha', version: '1.0.0', publisher: 'example', engines: { netcatty: '>=0.0.0', api: '>=0.1.0-internal <0.2.0' }, main: { browser: 'index.js' }, contributes: { settings: [{ id: 'com.example.alpha.setting', label: 'Alpha setting', control: 'text', scope: 'application' }] } },
|
||||
runtime: { status: 'stopped', kind: null, lastError: null, quarantinedAt: null } };
|
||||
let installed = plugin;
|
||||
let delayed = false;
|
||||
const pending: (() => void)[] = [];
|
||||
const listeners = new Set<() => void>();
|
||||
Object.assign(dom.window, { netcatty: {
|
||||
async getPluginRuntimeStatus() { return { available: true, experimental: true, credentialStorageAvailable: true }; },
|
||||
async listPlugins() { return [installed]; },
|
||||
onPluginContributionsChanged(callback: () => void) { listeners.add(callback); return () => listeners.delete(callback); },
|
||||
async getPluginLogStatus() { return { saveToDisk: false, verbose: false, droppedRecords: 0, writeErrors: 0, pendingBytes: 0, maxBytes: 3145728, retentionDays: 7 }; },
|
||||
async getPluginContributions(query: NetcattyPluginContributionQuery) {
|
||||
if (delayed && query.scopeIds) await new Promise<void>((resolve) => pending.push(resolve));
|
||||
return { locale: 'en', plugins: [{ id: plugin.id, version: '1.0.0', displayName: 'Alpha', commands: [], keybindings: [], menus: [], views: [], settings: [{ id: 'com.example.alpha.setting', label: 'Alpha setting', control: 'text', scope: 'application', scopeId: 'application', value: 'synthetic', visible: true, configured: true }] }] };
|
||||
},
|
||||
async getPluginScopeCatalog() { return { workspace: [], host: [], session: [], device: [] }; },
|
||||
async listPluginSources() { return []; },
|
||||
async listPluginCatalog() { return { entries: [], source: 'builtin', error: null }; },
|
||||
async getPluginPermissions() { return { grants: [], audit: [] }; },
|
||||
} });
|
||||
const root = createRoot(document.getElementById('root')!);
|
||||
const settle = () => new Promise((resolve) => setTimeout(resolve, 25));
|
||||
const perform = async (action: () => void) => { await act(async () => { action(); await settle(); }); };
|
||||
await perform(() => root.render(<I18nProvider locale="en"><Tabs defaultValue="plugins"><SettingsPluginsTab /></Tabs></I18nProvider>));
|
||||
const gear = document.querySelector<HTMLButtonElement>('button[aria-label="Settings for Alpha"]')!;
|
||||
assert.ok(gear && !gear.disabled);
|
||||
const flushFrames = async (count = 3) => {
|
||||
for (let i = 0; i < count; i++) await act(async () => {
|
||||
const current = [...frames.values()]; frames.clear();
|
||||
for (const callback of current) callback(i * 16);
|
||||
});
|
||||
};
|
||||
return {
|
||||
gear, scrolled, perform, flushFrames,
|
||||
section: () => document.querySelector<HTMLElement>('section[aria-labelledby="plugin-settings-com.example.alpha"]'),
|
||||
open: async (slow = false) => { delayed = slow; await perform(() => { gear.focus(); gear.click(); }); },
|
||||
release: async () => { delayed = false; await perform(() => { for (const resolve of pending.splice(0)) resolve(); }); },
|
||||
selectTab: async (name: string) => {
|
||||
const tab = [...document.querySelectorAll<HTMLElement>('[role=dialog] [role=tab]')].find((element) => element.textContent?.startsWith(name))!;
|
||||
await perform(() => { tab.focus(); tab.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); });
|
||||
},
|
||||
close: async () => { await perform(() => document.activeElement!.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))); },
|
||||
replaceOwner: async () => { installed = { ...plugin, source: { ...plugin.source, id: 'third-party:replacement', kind: 'third-party' } }; await perform(() => window.dispatchEvent(new Event('netcatty:plugin-packages-changed'))); },
|
||||
refresh: async () => { await perform(() => { for (const callback of [...listeners]) callback(); }); },
|
||||
dispose: async () => {
|
||||
await act(async () => root.unmount()); dom.window.close();
|
||||
for (const [key, descriptor] of previous) { if (descriptor) Object.defineProperty(globalThis, key, descriptor); else Reflect.deleteProperty(globalThis, key); }
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('gear entry scrolls and focuses its settings once, after dialog autofocus', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
await f.open();
|
||||
assert.notEqual(document.activeElement, f.section());
|
||||
await f.flushFrames();
|
||||
assert.deepEqual(f.scrolled, [f.section()]);
|
||||
assert.equal(document.activeElement, f.section());
|
||||
const input = document.querySelector<HTMLInputElement>('input[aria-label="Alpha setting"]')!;
|
||||
input.focus();
|
||||
await f.refresh(); await f.flushFrames();
|
||||
assert.equal(document.activeElement, input, 'later contribution refresh cannot steal focus');
|
||||
assert.equal(f.scrolled.length, 1);
|
||||
await f.close();
|
||||
assert.equal(document.activeElement, f.gear);
|
||||
} finally { await f.dispose(); }
|
||||
});
|
||||
|
||||
test('gear entry waits for slow contributions before targeting the existing settings section', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
await f.open(true); await f.flushFrames();
|
||||
assert.equal(f.section(), null); assert.equal(f.scrolled.length, 0);
|
||||
await f.release(); await f.flushFrames();
|
||||
assert.equal(f.scrolled.length, 1); assert.equal(document.activeElement, f.section());
|
||||
} finally { await f.dispose(); }
|
||||
});
|
||||
|
||||
test('closing between animation frames cancels an already scheduled gear focus', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
await f.open(); await f.flushFrames(1);
|
||||
await f.close(); await f.flushFrames();
|
||||
assert.equal(f.scrolled.length, 0);
|
||||
assert.equal(document.querySelector('[role=dialog]'), null);
|
||||
assert.equal(document.activeElement, f.gear);
|
||||
} finally { await f.dispose(); }
|
||||
});
|
||||
|
||||
for (const cancellation of ['tab', 'plugin', 'owner', 'close']) test(`pending gear entry cancels on ${cancellation} change`, async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
await f.open(true);
|
||||
if (cancellation === 'tab') await f.selectTab('Official');
|
||||
if (cancellation === 'plugin') await f.perform(() => document.querySelector<HTMLButtonElement>('[data-plugin-card="com.example.alpha"]')!.click());
|
||||
if (cancellation === 'owner') await f.replaceOwner();
|
||||
if (cancellation === 'close') await f.close();
|
||||
await f.release();
|
||||
if (cancellation === 'tab') await f.selectTab('Installed');
|
||||
if (cancellation === 'tab' || cancellation === 'plugin') await f.perform(() => document.querySelector<HTMLButtonElement>('[data-plugin-card="com.example.alpha"]')!.click());
|
||||
await f.flushFrames();
|
||||
assert.equal(f.scrolled.length, 0, 'late data or returning to the plugin cannot revive the cancelled intent');
|
||||
if (cancellation === 'close') {
|
||||
assert.equal(document.querySelector('[role=dialog]'), null);
|
||||
assert.equal(document.activeElement, f.gear);
|
||||
}
|
||||
} finally { await f.dispose(); }
|
||||
});
|
||||
|
||||
test('normal Browse and detail entry retain their existing detail focus', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
const browse = [...document.querySelectorAll<HTMLButtonElement>('button')].find((button) => button.textContent === 'Browse')!;
|
||||
await f.perform(() => { browse.focus(); browse.click(); });
|
||||
await f.perform(() => document.querySelector<HTMLButtonElement>('[data-plugin-card="com.example.alpha"]')!.click());
|
||||
await f.flushFrames();
|
||||
assert.ok(f.section()); assert.equal(f.scrolled.length, 0);
|
||||
assert.equal(document.activeElement, document.querySelector('[data-plugin-detail]'));
|
||||
await f.close(); assert.equal(document.activeElement, browse);
|
||||
} finally { await f.dispose(); }
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { JSDOM } from 'jsdom';
|
||||
|
||||
// Uses the real settings primitives and verifies that the host manager owns state.
|
||||
test('landing rows expose real sources/status/settings and quick toggles preserve host decisions', async () => {
|
||||
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', 'Element', 'Node', 'Event', 'MouseEvent']) install(key, key === 'window' ? dom.window : dom.window[key as keyof typeof dom.window]);
|
||||
install('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
const { default: React, act } = await import('react');
|
||||
const { createRoot } = await import('react-dom/client');
|
||||
const { I18nProvider } = await import('../../../application/i18n/I18nProvider');
|
||||
const { PluginSettingsLanding } = await import('./PluginSettingsLanding');
|
||||
const root = createRoot(document.getElementById('root')!);
|
||||
const plugins = [{
|
||||
id: 'com.example.alpha', enabled: true, activeVersion: '1.0.0',
|
||||
source: { id: 'official', kind: 'official', name: 'Official source', origin: 'https://plugins.example.com' },
|
||||
manifest: { manifestVersion: 1, id: 'com.example.alpha', name: 'Alpha', version: '1.0.0', publisher: 'example', engines: { netcatty: '>=0.0.0', api: '>=0.1.0-internal <0.2.0' }, main: { browser: 'index.js' }, description: 'Synthetic description', contributes: { settings: [{ id: 'com.example.alpha.value', label: 'Value', control: 'text', scope: 'application' }] } },
|
||||
runtime: { status: 'running', kind: 'browser', lastError: null, quarantinedAt: null },
|
||||
}, {
|
||||
id: 'com.example.beta', enabled: false, activeVersion: '2.0.0',
|
||||
source: { id: 'third-party:synthetic', kind: 'third-party', name: 'Synthetic source', origin: 'https://synthetic.example.com' },
|
||||
manifest: { manifestVersion: 1, id: 'com.example.beta', name: 'Beta', version: '2.0.0', publisher: 'example', engines: { netcatty: '>=0.0.0', api: '>=0.1.0-internal <0.2.0' }, main: { browser: 'index.js' } },
|
||||
runtime: { status: 'stopped', kind: null, lastError: null, quarantinedAt: null },
|
||||
}] as NetcattyInstalledPlugin[];
|
||||
const changes: Array<[string, boolean]> = [];
|
||||
const opened: Array<string | null> = [];
|
||||
let refreshes = 0;
|
||||
const manager = { plugins, available: true, statusKnown: true, busy: false, error: null as string | null, credentialStorageAvailable: true,
|
||||
async refresh() { refreshes++; }, async setEnabled(id: string, enabled: boolean) { changes.push([id, enabled]); }, async importPackage() {}, async uninstall() {} };
|
||||
Object.assign(dom.window, { netcatty: { async getPluginLogStatus() { return { saveToDisk: false, verbose: false, droppedRecords: 0, writeErrors: 0, pendingBytes: 0, maxBytes: 3145728, retentionDays: 7 }; } } });
|
||||
const render = async (value = manager, settingsAvailable = new Set(['com.example.alpha'])) => act(async () => root.render(<I18nProvider locale="en"><PluginSettingsLanding manager={value} settingsAvailable={settingsAvailable} onManage={(id) => opened.push(id)} /></I18nProvider>));
|
||||
const button = (label: string) => [...document.querySelectorAll<HTMLButtonElement>('button')].find((item) => item.textContent === label)!;
|
||||
const byLabel = (label: string) => document.querySelector<HTMLButtonElement>(`button[aria-label="${label}"]`)!;
|
||||
try {
|
||||
await render();
|
||||
assert.equal(document.querySelector('[role=dialog]'), null);
|
||||
assert.equal(document.querySelector('input[type=checkbox]'), null);
|
||||
assert.equal(document.querySelectorAll('[role=switch]').length, 2, 'only real plugin toggles appear on landing');
|
||||
assert.match(document.body.textContent!, /Running/);
|
||||
assert.match(document.body.textContent!, /Source: Official/);
|
||||
assert.match(document.body.textContent!, /Synthetic source/);
|
||||
assert.doesNotMatch(document.body.textContent!, /Save host audit logs locally|trust all|safe mode|telemetry/i);
|
||||
await act(async () => button('Browse').click());
|
||||
await act(async () => byLabel('Settings for Alpha').click());
|
||||
assert.deepEqual(opened, [null, 'com.example.alpha']);
|
||||
await act(async () => byLabel('Disable Alpha').click());
|
||||
assert.deepEqual(changes, [['com.example.alpha', false]]);
|
||||
assert.equal(byLabel('Disable Alpha').getAttribute('aria-checked'), 'true', 'no optimistic flip before host approval');
|
||||
await render({ ...manager, error: 'Synthetic permission denied' });
|
||||
assert.match(document.querySelector('[role=alert]')!.textContent!, /Synthetic permission denied/);
|
||||
assert.equal(byLabel('Disable Alpha').getAttribute('aria-checked'), 'true');
|
||||
await act(async () => byLabel('Refresh installed plugins').click());
|
||||
assert.equal(refreshes, 1);
|
||||
await render({ ...manager, statusKnown: false });
|
||||
assert.equal(byLabel('Disable Alpha').disabled, true);
|
||||
await act(async () => byLabel('Disable Alpha').click());
|
||||
assert.equal(changes.length, 1);
|
||||
await render(manager, new Set());
|
||||
assert.equal(byLabel('Settings for Alpha').disabled, true, 'unavailable settings cannot open an empty destination');
|
||||
await render({ ...manager, plugins: [] });
|
||||
assert.match(document.body.textContent!, /No plugins installed/);
|
||||
await render({ ...manager, plugins: [], statusKnown: false });
|
||||
assert.match(document.body.textContent!, /Loading/);
|
||||
await render({ ...manager, plugins: [], available: false });
|
||||
assert.match(document.body.textContent!, /unavailable in this session/);
|
||||
await render({ ...manager, plugins: [{ ...plugins[0], source: undefined }] });
|
||||
assert.match(document.body.textContent!, /Source unknown/);
|
||||
assert.doesNotMatch(document.body.textContent!, /Source: Local/);
|
||||
} finally {
|
||||
await act(async () => root.unmount()); dom.window.close();
|
||||
for (const [key, descriptor] of previous) { if (descriptor) Object.defineProperty(globalThis, key, descriptor); else Reflect.deleteProperty(globalThis, key); }
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import { RefreshCw, Settings2 } from 'lucide-react';
|
||||
import { useI18n } from '../../../application/i18n/I18nProvider';
|
||||
import type { usePluginUpdateCheck } from '../../../application/state/usePluginUpdateCheck';
|
||||
import type { usePluginManager } from '../../../application/state/usePluginManager';
|
||||
import { Button } from '../../ui/button';
|
||||
import { Input } from '../../ui/input';
|
||||
import { PluginBrandIcon } from '../../plugins/PluginBrandIcon';
|
||||
import { SettingCard, SettingRow, SettingsSection, Toggle } from '../settings-ui';
|
||||
import { PluginLogSettings } from './PluginLogSettings';
|
||||
|
||||
type Manager = ReturnType<typeof usePluginManager>;
|
||||
|
||||
export function PluginSettingsLanding({ manager, settingsAvailable, settingsError, managerButtonRef, updates, onManage }: {
|
||||
manager: Manager;
|
||||
settingsAvailable: ReadonlySet<string>;
|
||||
settingsError?: string;
|
||||
managerButtonRef?: React.Ref<HTMLButtonElement>;
|
||||
updates?: ReturnType<typeof usePluginUpdateCheck>;
|
||||
onManage: (pluginId: string | null, opener: HTMLElement) => void;
|
||||
}) {
|
||||
const { t, resolvedLocale } = useI18n();
|
||||
const [search, setSearch] = useState('');
|
||||
const localized = useCallback((value: string | Record<string, string> | undefined) => typeof value === 'string' ? value : value?.[resolvedLocale] ?? value?.en ?? '', [resolvedLocale]);
|
||||
const nameOf = (plugin: NetcattyInstalledPlugin) => localized(plugin.manifest?.displayName) || plugin.manifest?.name || plugin.id;
|
||||
const visible = useMemo(() => {
|
||||
const term = search.trim().toLocaleLowerCase();
|
||||
return manager.plugins.filter((plugin) => [localized(plugin.manifest?.displayName), plugin.manifest?.name, plugin.id, plugin.manifest?.publisher, localized(plugin.manifest?.description), plugin.source?.name].some((value) => String(value ?? '').toLocaleLowerCase().includes(term)));
|
||||
}, [manager.plugins, search, localized]);
|
||||
const statusOf = (plugin: NetcattyInstalledPlugin) => {
|
||||
if (!manager.statusKnown) return t('settings.plugins.permissions.statusUnknown');
|
||||
const state = plugin.runtime.status;
|
||||
if (['running', 'starting', 'stopping', 'error', 'quarantined'].includes(state)) return t(`settings.plugins.landing.status.${state}`);
|
||||
if (state !== 'stopped') return t('settings.plugins.landing.status.unknown');
|
||||
return t(plugin.enabled ? 'settings.plugins.landing.status.idle' : 'settings.plugins.manager.disabled');
|
||||
};
|
||||
const sourceOf = (plugin: NetcattyInstalledPlugin) => {
|
||||
const source = plugin.source;
|
||||
if (!source) return t('settings.plugins.landing.sourceUnknown');
|
||||
const kind = t(source.kind === 'official' ? 'settings.plugins.sources.official' : source.kind === 'third-party' ? 'settings.plugins.sources.thirdparty' : 'settings.plugins.sources.local');
|
||||
return `${t('settings.plugins.sources.source')}: ${kind}${source.kind === 'third-party' ? ` · ${source.name}` : ''}`;
|
||||
};
|
||||
return <>
|
||||
<SettingsSection title={t('settings.plugins.landing.options')}>
|
||||
<SettingCard divided>
|
||||
<SettingRow label={t('settings.plugins.landing.manager')} description={t('settings.plugins.landing.managerDescription')}>
|
||||
<Button ref={managerButtonRef} className="focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2" variant="outline" size="sm" onClick={(event) => onManage(null, event.currentTarget)}>{t('settings.plugins.landing.browse')}</Button>
|
||||
</SettingRow>
|
||||
<PluginLogSettings />
|
||||
</SettingCard>
|
||||
</SettingsSection>
|
||||
<SettingsSection title={`${t('settings.plugins.manager.installed')} · ${manager.plugins.length}`} actions={<>
|
||||
{updates && <Button className="focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2" variant="outline" size="sm" disabled={updates.busy || manager.busy || !manager.available || !manager.statusKnown || manager.plugins.length === 0} onClick={() => void updates.check()}>{t(updates.busy ? 'settings.plugins.landing.checkingUpdates' : 'settings.plugins.landing.checkUpdates')}</Button>}
|
||||
<Button className="focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2" variant="ghost" size="icon" disabled={manager.busy} aria-label={t('settings.plugins.landing.refresh')} onClick={() => void manager.refresh({ clearError: true }).catch(() => {})}><RefreshCw size={16} /></Button>
|
||||
</>}>
|
||||
{updates?.error && <p role="alert" className="text-sm text-destructive">{updates.error}</p>}
|
||||
{updates?.result && <p role="status" className="text-xs text-muted-foreground">{t('settings.plugins.landing.updateSummary').replace('{checked}', String(updates.result.checked)).replace('{updates}', String(updates.result.updates.length)).replace('{skipped}', String(updates.result.skipped))}{updates.result.failedSources > 0 && ` ${t('settings.plugins.landing.updateSourcesFailed').replace('{count}', String(updates.result.failedSources))}`} {t('settings.plugins.landing.updateReview')}</p>}
|
||||
{settingsError && <p role="alert" className="text-sm text-destructive">{settingsError}</p>}
|
||||
{manager.error && <p role="alert" className="text-sm text-destructive">{manager.error}</p>}
|
||||
{!manager.statusKnown && !manager.error && <p role="status" className="text-sm text-muted-foreground">{t('settings.plugins.landing.loading')}</p>}
|
||||
{manager.statusKnown && !manager.available && <p role="status" className="text-sm text-muted-foreground">{t('settings.plugins.landing.unavailable')}</p>}
|
||||
{manager.statusKnown && manager.available && manager.plugins.length === 0 && <p className="text-sm text-muted-foreground">{t('settings.plugins.landing.empty')}</p>}
|
||||
{manager.plugins.length > 0 && <>
|
||||
<Input aria-label={t('settings.plugins.landing.search')} placeholder={t('settings.plugins.landing.search')} value={search} onChange={(event) => setSearch(event.target.value)} />
|
||||
{visible.length === 0 ? <p role="status" className="text-sm text-muted-foreground">{t('settings.plugins.market.noResults')}</p> : <SettingCard divided>
|
||||
{visible.map((plugin) => {
|
||||
const name = nameOf(plugin);
|
||||
const description = localized(plugin.manifest?.description);
|
||||
const availableUpdate = updates?.result?.updates.find((item) => item.pluginId === plugin.id && item.sourceId === plugin.source?.id);
|
||||
const declaredSettings = Boolean(plugin.manifest?.contributes?.settings?.length || plugin.manifest?.contributes?.views?.some((view) => view.location === 'settings'));
|
||||
return <SettingRow key={plugin.id} leading={<PluginBrandIcon plugin={plugin} size={24} />} label={<span className="flex min-w-0 items-baseline gap-2">
|
||||
<span className="truncate" title={name}>{name}</span>
|
||||
{plugin.activeVersion && <span className="shrink-0 text-xs font-normal text-muted-foreground">{plugin.activeVersion}</span>}
|
||||
</span>} description={<>
|
||||
<span className="block break-words">{[plugin.manifest?.publisher, sourceOf(plugin), statusOf(plugin)].filter(Boolean).join(' · ')}</span>
|
||||
{availableUpdate && <span className="block text-primary">{t('settings.plugins.landing.updateAvailable').replace('{version}', availableUpdate.availableVersion)}</span>}
|
||||
{description && <span className="line-clamp-2 break-words">{description}</span>}
|
||||
</>}>
|
||||
<div className="flex items-center gap-3">
|
||||
{declaredSettings && <Button className="focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2" variant="ghost" size="icon" disabled={manager.busy || !manager.statusKnown || !settingsAvailable.has(plugin.id)} aria-label={t('settings.plugins.landing.settingsFor').replace('{name}', name)} title={t(settingsAvailable.has(plugin.id) ? 'common.settings' : !plugin.enabled ? 'settings.plugins.landing.enableSettings' : 'settings.plugins.landing.settingsUnavailable')} onClick={(event) => onManage(plugin.id, event.currentTarget)}><Settings2 size={16} /></Button>}
|
||||
<Toggle checked={plugin.enabled} disabled={manager.busy || !manager.available || !manager.statusKnown} ariaLabel={`${t(plugin.enabled ? 'settings.plugins.manager.disable' : 'settings.plugins.manager.enable')} ${name}`} onChange={(enabled) => void manager.setEnabled(plugin.id, enabled)} />
|
||||
</div>
|
||||
</SettingRow>;
|
||||
})}
|
||||
</SettingCard>}
|
||||
</>}
|
||||
</SettingsSection>
|
||||
</>;
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { JSDOM } from 'jsdom';
|
||||
|
||||
test('manual update action coalesces clicks, never runs on mount, and reports incomplete source checks honestly', async () => {
|
||||
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', 'Element', 'Node', 'Event', 'MouseEvent']) install(key, key === 'window' ? dom.window : dom.window[key as keyof typeof dom.window]);
|
||||
install('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
const { default: React, act } = await import('react');
|
||||
const { createRoot } = await import('react-dom/client');
|
||||
const { I18nProvider } = await import('../../../application/i18n/I18nProvider');
|
||||
const { PluginSettingsLanding } = await import('./PluginSettingsLanding');
|
||||
const { usePluginUpdateCheck } = await import('../../../application/state/usePluginUpdateCheck');
|
||||
const root = createRoot(document.getElementById('root')!);
|
||||
const source: NetcattyPluginSource = { id: 'official', kind: 'official', name: 'Official source', origin: 'https://plugins.example.com', enabled: true };
|
||||
const plugin = { id: 'com.example.alpha', enabled: false, activeVersion: '1.0.0', archiveSha256: 'a'.repeat(64), source,
|
||||
manifest: { manifestVersion: 1, id: 'com.example.alpha', name: 'Alpha', version: '1.0.0', publisher: 'example', engines: { netcatty: '>=0.0.0', api: '>=0.1.0-internal <0.2.0' }, main: { browser: 'index.js' } },
|
||||
runtime: { status: 'stopped', kind: null, lastError: null, quarantinedAt: null } } as NetcattyInstalledPlugin;
|
||||
const plugins = [plugin];
|
||||
const manager = { plugins, available: true, statusKnown: true, busy: false, error: null, credentialStorageAvailable: true, async refresh() {}, async setEnabled() {}, async importPackage() {}, async uninstall() {} };
|
||||
let catalogReads = 0; let mutations = 0; let offline = false;
|
||||
let release: () => void;
|
||||
const gate = new Promise<void>((resolve) => { release = resolve; });
|
||||
Object.assign(dom.window, { netcatty: {
|
||||
async listPlugins() { return plugins; }, async listPluginSources() { return [source]; },
|
||||
async listPluginCatalog(id: string) { assert.equal(id, source.id); catalogReads++; await gate; return { entries: [{ id: plugin.id, version: '1.1.0', artifact: { url: 'https://plugins.example.com/synthetic.ncpkg', sha256: 'b'.repeat(64) } }], source: offline ? 'cached' : 'registry', error: offline ? 'synthetic offline' : null }; },
|
||||
async getPluginLogStatus() { return { saveToDisk: false, verbose: false, droppedRecords: 0, writeErrors: 0, pendingBytes: 0, maxBytes: 3145728, retentionDays: 7 }; },
|
||||
async installCatalogPlugin() { mutations++; }, async setPluginEnabled() { mutations++; }, async checkForUpdates() { mutations++; },
|
||||
} });
|
||||
function Page() { const updates = usePluginUpdateCheck(plugins); return <PluginSettingsLanding manager={manager} updates={updates} settingsAvailable={new Set()} onManage={() => {}} />; }
|
||||
const check = () => [...document.querySelectorAll<HTMLButtonElement>('button')].find((button) => button.textContent === 'Check for updates')!;
|
||||
try {
|
||||
await act(async () => root.render(<I18nProvider locale="en"><Page /></I18nProvider>));
|
||||
assert.equal(catalogReads, 0, 'rendering settings does not schedule checks');
|
||||
await act(async () => { const button = check(); button.click(); button.click(); });
|
||||
assert.equal(catalogReads, 1);
|
||||
assert.equal([...document.querySelectorAll<HTMLButtonElement>('button')].find((button) => button.textContent === 'Checking…')!.disabled, true);
|
||||
await act(async () => release());
|
||||
assert.match(document.body.textContent!, /Published updates: 1/);
|
||||
assert.match(document.body.textContent!, /Version 1.1.0 available/);
|
||||
assert.equal(mutations, 0);
|
||||
await act(async () => window.dispatchEvent(new Event('netcatty:plugin-packages-changed')));
|
||||
assert.doesNotMatch(document.body.textContent!, /Version 1.1.0 available/, 'source/package changes invalidate prior results');
|
||||
offline = true;
|
||||
await act(async () => check().click());
|
||||
assert.match(document.body.textContent!, /Checked: 0/);
|
||||
assert.match(document.body.textContent!, /Not checked: 1/);
|
||||
assert.match(document.body.textContent!, /Unavailable sources: 1/);
|
||||
assert.doesNotMatch(document.body.textContent!, /Version 1.1.0 available/);
|
||||
assert.equal(mutations, 0);
|
||||
} finally {
|
||||
await act(async () => root.unmount()); dom.window.close();
|
||||
for (const [key, descriptor] of previous) { if (descriptor) Object.defineProperty(globalThis, key, descriptor); else Reflect.deleteProperty(globalThis, key); }
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { JSDOM } from 'jsdom';
|
||||
|
||||
test('landing settings entry targets the declared plugin and a manager tab roundtrip clears selection consistently', async () => {
|
||||
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', 'Element', 'Node', 'NodeFilter', 'MutationObserver', 'DocumentFragment', 'CustomEvent', 'Event', 'KeyboardEvent', 'MouseEvent']) install(key, key === 'window' ? dom.window : dom.window[key as keyof typeof dom.window]);
|
||||
install('getComputedStyle', dom.window.getComputedStyle.bind(dom.window));
|
||||
install('requestAnimationFrame', dom.window.requestAnimationFrame.bind(dom.window));
|
||||
install('cancelAnimationFrame', dom.window.cancelAnimationFrame.bind(dom.window));
|
||||
install('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
dom.window.HTMLElement.prototype.scrollIntoView = () => {};
|
||||
const { default: React, act } = await import('react');
|
||||
const { createRoot } = await import('react-dom/client');
|
||||
const { I18nProvider } = await import('../../../application/i18n/I18nProvider');
|
||||
const { Tabs } = await import('../../ui/tabs');
|
||||
const { default: SettingsPluginsTab } = await import('./SettingsPluginsTab');
|
||||
const root = createRoot(document.getElementById('root')!);
|
||||
const plugin = { id: 'com.example.alpha', enabled: true, activeVersion: '1.0.0', archiveSha256: 'a'.repeat(64),
|
||||
source: { id: 'local', kind: 'local', name: 'Local import', origin: null },
|
||||
manifest: { manifestVersion: 1, id: 'com.example.alpha', name: 'Alpha', version: '1.0.0', publisher: 'example', engines: { netcatty: '>=0.0.0', api: '>=0.1.0-internal <0.2.0' }, main: { browser: 'index.js' }, contributes: { settings: [{ id: 'com.example.alpha.setting', label: 'Alpha setting', control: 'text', scope: 'application' }] } },
|
||||
runtime: { status: 'stopped', kind: null, lastError: null, quarantinedAt: null } };
|
||||
Object.assign(dom.window, { netcatty: {
|
||||
async getPluginRuntimeStatus() { return { available: true, experimental: true, credentialStorageAvailable: true }; },
|
||||
async listPlugins() { return [plugin]; },
|
||||
async setPluginEnabled() { throw new Error('Synthetic management failure'); },
|
||||
async getPluginLogStatus() { return { saveToDisk: false, verbose: false, droppedRecords: 0, writeErrors: 0, pendingBytes: 0, maxBytes: 3145728, retentionDays: 7 }; },
|
||||
async getPluginContributions() { return { locale: 'en', plugins: [{ id: plugin.id, version: '1.0.0', displayName: 'Alpha', description: 'Synthetic plugin', commands: [], keybindings: [], menus: [], views: [], settings: [{ id: 'com.example.alpha.setting', label: 'Alpha setting', control: 'text', scope: 'application', scopeId: 'application', value: 'synthetic', visible: true, configured: true }] }] }; },
|
||||
async getPluginScopeCatalog() { return { workspace: [], host: [], session: [], device: [] }; },
|
||||
async listPluginSources() { return []; },
|
||||
async listPluginCatalog() { return { entries: [], source: 'builtin', error: null }; },
|
||||
async getPluginPermissions() { return { grants: [], audit: [] }; },
|
||||
} });
|
||||
const settle = () => new Promise((resolve) => setTimeout(resolve, 25));
|
||||
const selectTab = async (name: string) => {
|
||||
const tab = [...document.querySelectorAll<HTMLElement>('[role=dialog] [role=tab]')].find((element) => element.textContent?.startsWith(name))!;
|
||||
assert.ok(tab, name);
|
||||
await act(async () => { tab.focus(); tab.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); await settle(); });
|
||||
};
|
||||
try {
|
||||
await act(async () => { root.render(<I18nProvider locale="en"><Tabs defaultValue="plugins"><SettingsPluginsTab /></Tabs></I18nProvider>); await settle(); });
|
||||
const settings = document.querySelector<HTMLButtonElement>('button[aria-label="Settings for Alpha"]')!;
|
||||
assert.ok(settings);
|
||||
assert.equal(settings.disabled, false);
|
||||
await act(async () => { settings.focus(); settings.click(); await settle(); });
|
||||
assert.ok(document.querySelector('[data-plugin-detail]'));
|
||||
assert.ok(document.querySelector('input[aria-label="Alpha setting"]'));
|
||||
await selectTab('Official');
|
||||
await selectTab('Installed');
|
||||
assert.equal(document.querySelector('[data-plugin-detail]'), null, 'returning to Installed shows the grid, not a stale deep-link detail');
|
||||
assert.equal(document.querySelector('input[aria-label="Alpha setting"]'), null);
|
||||
const card = document.querySelector<HTMLButtonElement>('[data-plugin-card="com.example.alpha"]')!;
|
||||
await act(async () => { card.click(); await settle(); });
|
||||
assert.ok(document.querySelector('input[aria-label="Alpha setting"]'), 'choosing the plugin again restores its real settings');
|
||||
await act(async () => { document.activeElement!.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); await settle(); });
|
||||
assert.equal(document.querySelector('[role=dialog]'), null);
|
||||
assert.equal(document.activeElement, settings);
|
||||
const toggle = document.querySelector<HTMLButtonElement>('button[aria-label="Disable Alpha"]')!;
|
||||
await act(async () => { toggle.click(); await settle(); });
|
||||
assert.match(document.querySelector('[role=alert]')!.textContent!, /Synthetic management failure/);
|
||||
assert.equal(toggle.getAttribute('aria-checked'), 'true', 'failed mutation retains actual host state');
|
||||
const refresh = document.querySelector<HTMLButtonElement>('button[aria-label="Refresh installed plugins"]')!;
|
||||
await act(async () => { refresh.click(); await settle(); });
|
||||
assert.equal(document.querySelector('[role=alert]'), null, 'explicit successful refresh clears an acknowledged manager error');
|
||||
} finally {
|
||||
await act(async () => root.unmount()); dom.window.close();
|
||||
for (const [key, descriptor] of previous) { if (descriptor) Object.defineProperty(globalThis, key, descriptor); else Reflect.deleteProperty(globalThis, key); }
|
||||
}
|
||||
});
|
||||
@@ -1,11 +1,13 @@
|
||||
import { PluginThirdPartyMarket } from './PluginThirdPartyMarket';
|
||||
import { usePluginUpdateCheck } from '../../../application/state/usePluginUpdateCheck';
|
||||
import { usePluginManager } from '../../../application/state/usePluginManager';
|
||||
import { PluginManagementDialog } from './PluginManagementDialog';
|
||||
import { PluginMarketplace } from './PluginMarketplace';
|
||||
import { PluginPackageManager } from './PluginPackageManager';
|
||||
import { FolderOpen } from 'lucide-react';
|
||||
import { PluginStatus } from './PluginStatus';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { PluginSettingsLanding } from './PluginSettingsLanding';
|
||||
import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { normalizePluginKeyboardEvent } from '../../../application/state/pluginKeybindings';
|
||||
import { usePluginContributions } from '../../../application/state/usePluginContributions';
|
||||
@@ -285,11 +287,15 @@ export function PluginSettingField({
|
||||
);
|
||||
}
|
||||
|
||||
function PluginManagementContent({ handoff }: { handoff: (action: () => void) => void }) {
|
||||
function PluginManagementContent({ handoff, manager, initialSelectedId, active }: {
|
||||
handoff: (action: () => void) => void;
|
||||
manager: ReturnType<typeof usePluginManager>;
|
||||
initialSelectedId: string | null;
|
||||
active: boolean;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const manager = usePluginManager();
|
||||
const [catalogToolbar, setCatalogToolbar] = useState<HTMLDivElement | null>(null);
|
||||
const [selectedInstalled, setSelectedInstalled] = useState<string | null>(null);
|
||||
const [selectedInstalled, setSelectedInstalled] = useState<string | null>(initialSelectedId);
|
||||
const [pluginView, setPluginView] = useState<'installed' | 'marketplace' | 'thirdparty'>('installed');
|
||||
const availableFonts = useAvailableFonts();
|
||||
const scopeCatalog = usePluginSettingScopeCatalog();
|
||||
@@ -302,6 +308,31 @@ function PluginManagementContent({ handoff }: { handoff: (action: () => void) =>
|
||||
scopeIds,
|
||||
}), [scopeIds]);
|
||||
const contributions = usePluginContributions(query);
|
||||
const settingsSection = useRef<HTMLElement | null>(null);
|
||||
const installed = manager.plugins.find((plugin) => plugin.id === selectedInstalled);
|
||||
const ownerKey = JSON.stringify(installed && [installed.id, installed.activeVersion, installed.archiveSha256,
|
||||
installed.source?.id, installed.source?.kind, installed.source?.origin]);
|
||||
const gearEntry = useRef(initialSelectedId ? { pluginId: initialSelectedId, ownerKey } : null);
|
||||
useLayoutEffect(() => {
|
||||
const intent = gearEntry.current;
|
||||
if (!intent) return;
|
||||
if (!active || pluginView !== 'installed' || selectedInstalled !== intent.pluginId || ownerKey !== intent.ownerKey) {
|
||||
gearEntry.current = null;
|
||||
return;
|
||||
}
|
||||
const section = settingsSection.current;
|
||||
if (contributions.loading || contributions.error || !contributions.available || !section) return;
|
||||
// Let dialog autofocus settle first, including when contributions arrive late.
|
||||
let frame = requestAnimationFrame(() => {
|
||||
frame = requestAnimationFrame(() => {
|
||||
if (gearEntry.current !== intent || !section.isConnected) return;
|
||||
gearEntry.current = null;
|
||||
section.scrollIntoView({ block: 'start' });
|
||||
section.focus({ preventScroll: true });
|
||||
});
|
||||
});
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}, [active, pluginView, selectedInstalled, ownerKey, contributions.loading, contributions.error, contributions.available, contributions.snapshot]);
|
||||
const contextualScopes = useMemo(() => new Set(contributions.snapshot.plugins
|
||||
.filter((plugin) => plugin.id === selectedInstalled)
|
||||
.flatMap((plugin) => plugin.settings.map((setting) => setting.scope))
|
||||
@@ -311,7 +342,7 @@ function PluginManagementContent({ handoff }: { handoff: (action: () => void) =>
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<Tabs value={pluginView} onValueChange={(value) => { setSelectedInstalled(null); setPluginView(value as 'installed' | 'marketplace' | 'thirdparty'); }} className="space-y-5">
|
||||
<Tabs value={pluginView} onValueChange={(value) => { gearEntry.current = null; setSelectedInstalled(null); setPluginView(value as 'installed' | 'marketplace' | 'thirdparty'); }} className="space-y-5">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<TabsList className="h-auto flex-wrap justify-start bg-muted/50">
|
||||
<TabsTrigger value="installed">{t('settings.plugins.manager.installed')} <span className="ml-2 text-muted-foreground">{manager.plugins.length}</span></TabsTrigger>
|
||||
@@ -327,7 +358,7 @@ function PluginManagementContent({ handoff }: { handoff: (action: () => void) =>
|
||||
<TabsContent forceMount hidden={pluginView !== 'marketplace'} value="marketplace" className="m-0 space-y-6"><PluginMarketplace installed={manager.plugins} toolbarContainer={pluginView === 'marketplace' ? catalogToolbar : undefined} noVault={manager.credentialStorageAvailable === false} /></TabsContent>
|
||||
<TabsContent forceMount hidden={pluginView !== 'thirdparty'} value="thirdparty" className="m-0 space-y-6"><PluginThirdPartyMarket installed={manager.plugins} toolbarContainer={pluginView === 'thirdparty' ? catalogToolbar : undefined} noVault={manager.credentialStorageAvailable === false} /></TabsContent>
|
||||
<TabsContent value="installed" className="m-0 space-y-6">
|
||||
<PluginPackageManager manager={manager} onSelectionChange={setSelectedInstalled} />
|
||||
<PluginPackageManager manager={manager} initialSelectedId={selectedInstalled} onSelectionChange={(id) => { gearEntry.current = null; setSelectedInstalled(id); }} />
|
||||
{contextualScopes.size > 0 && (
|
||||
<section className="grid gap-3 rounded-lg border border-border/70 bg-muted/10 p-4 sm:grid-cols-2" aria-label={t('settings.plugins.scopeTargets')}>
|
||||
{[...contextualScopes].map((kind) => (
|
||||
@@ -352,7 +383,7 @@ function PluginManagementContent({ handoff }: { handoff: (action: () => void) =>
|
||||
const views = plugin.views.filter((view) => view.visible && view.location === 'settings');
|
||||
if (!settings.length && !views.length) return null;
|
||||
return (
|
||||
<section key={plugin.id} className="space-y-3" aria-labelledby={`plugin-settings-${plugin.id}`}>
|
||||
<section key={plugin.id} ref={settingsSection} tabIndex={-1} className="space-y-3 scroll-mt-4 focus:outline-none" aria-labelledby={`plugin-settings-${plugin.id}`}>
|
||||
<div>
|
||||
<h3 id={`plugin-settings-${plugin.id}`} className="text-base font-semibold">{plugin.displayName}</h3>
|
||||
{plugin.description && <p className="text-xs text-muted-foreground">{plugin.description}</p>}
|
||||
@@ -394,15 +425,27 @@ function PluginManagementContent({ handoff }: { handoff: (action: () => void) =>
|
||||
|
||||
|
||||
export default function SettingsPluginsTab() {
|
||||
const { t } = useI18n();
|
||||
const manager = usePluginManager();
|
||||
const updates = usePluginUpdateCheck(manager.plugins);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [initialSelectedId, setInitialSelectedId] = useState<string | null>(null);
|
||||
const opener = useRef<HTMLElement | null>(null);
|
||||
const managerButton = useRef<HTMLButtonElement | null>(null);
|
||||
const contributions = usePluginContributions({ context: { 'netcatty.surface': 'settings' } });
|
||||
const settingsAvailable = useMemo(() => new Set(contributions.snapshot.plugins
|
||||
.filter((plugin) => plugin.settings.some((setting) => setting.visible) || plugin.views.some((view) => view.location === 'settings' && view.visible))
|
||||
.map((plugin) => plugin.id)), [contributions.snapshot]);
|
||||
return (
|
||||
<SettingsTabContent value="plugins">
|
||||
<SettingsAnchor anchorId="plugins-root" className="space-y-5">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold">{t('settings.plugins.title')}</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{t('settings.plugins.description')}</p>
|
||||
</div>
|
||||
<PluginManagementDialog>{(handoff) => <PluginManagementContent handoff={handoff} />}</PluginManagementDialog>
|
||||
<SettingsAnchor anchorId="plugins-root" className="space-y-6">
|
||||
<PluginSettingsLanding manager={manager} updates={updates} managerButtonRef={managerButton} settingsAvailable={settingsAvailable} settingsError={contributions.error?.message} onManage={(pluginId, element) => {
|
||||
opener.current = element;
|
||||
setInitialSelectedId(pluginId);
|
||||
setOpen(true);
|
||||
}} />
|
||||
<PluginManagementDialog open={open} onOpenChange={setOpen} trigger={null} restoreFocusTo={opener} fallbackFocusTo={managerButton}>
|
||||
{(handoff) => <PluginManagementContent handoff={handoff} manager={manager} initialSelectedId={initialSelectedId} active={open} />}
|
||||
</PluginManagementDialog>
|
||||
</SettingsAnchor>
|
||||
</SettingsTabContent>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
# Unified plugin acceptance candidate
|
||||
|
||||
The dedicated branch is `integration/plugin-native-acceptance`. Its Draft PR
|
||||
records the exact current head and CI runs. Native AI, database, Host/Vault, and
|
||||
settings/logging checks use this candidate rather than separate feature trees.
|
||||
Only the dedicated acceptance branch is published by this integration task;
|
||||
the reported feature checkpoints are preserved through ordinary merges.
|
||||
|
||||
The previous candidate `174c525cd45bdc1152e9feb21839a288706d3bd1` passed
|
||||
[test CI](https://github.com/binaricat/Netcatty/actions/runs/40381685282),
|
||||
[four-platform packages](https://github.com/binaricat/Netcatty/actions/runs/40381685946),
|
||||
and [ET CI](https://github.com/binaricat/Netcatty/actions/runs/40381684810).
|
||||
No actionable CI regression was found. Its full test suite records 15,375 tests:
|
||||
15,325 passed, 50 skipped, zero failed or cancelled.
|
||||
|
||||
The next candidate includes the published settings focus, AI budget/session/
|
||||
selected-Note recovery, and terminal Solo/Focus ownership corrections. The parent
|
||||
reports both correction reviews scoped clean at their exact source heads. Those
|
||||
reviews do not establish aggregate package verification or native acceptance.
|
||||
No uncommitted owner work is imported, and native activation is not performed
|
||||
by this coding task.
|
||||
|
||||
## Included source checkpoints
|
||||
|
||||
| Slice | Frozen checkpoint | Integration evidence |
|
||||
| --- | --- | --- |
|
||||
| Main | `f0897c3c1711f60350f8d613696d387bcac464ed` | Includes #3991, #3983, #3992, and #3995 |
|
||||
| Byte streams, source identity, connected AI, examples | #3993 `37df67256e7eedc1050f186bcf401dc376cb5e71` | Ordinary merge; both transport P2 fixes retained |
|
||||
| Host/Vault actions | #3990 `730fa643ba0589b8919368657dabc83428f577be` | Ordinary merge; metadata/action authority stays separate |
|
||||
| Settings and logging UI | #3994 `823db94cf34503417e3b7c19c6daab379f60530f` | Ordinary merge; retains dc6 alias repair and adds the natively verified gear-to-settings focus path |
|
||||
| AI corrections | #3986 `ba09461676f52b6b1853385ba61845b48cf5f5ac` | Ordinary merges preserve budget/model recovery and final `9e7c9df58` explicit Note re-selection; same reviewer reports the bounded correction clean |
|
||||
| Selected-terminal continuation | #3996 `8e7c28fe1bdbcdd76816d33ec926ba8e60eefe64` | Ordinary merge; independent correction review reports Solo/Focus ownership resolved; native acceptance pending |
|
||||
|
||||
#3995 was squash-merged into main as `f0897c3c`. Its PR tip was rechecked at
|
||||
`921f51756eca564cef3d30152807a4649c2a0d3b`; both contain the same TERM-SSHD test
|
||||
blob `cdfc067e6ca3b4f6b7b3d6e78834072ed84837b4`. The candidate keeps main's
|
||||
deterministic first/alias OPEN-order cases instead of the older plugin-branch
|
||||
test-only repair. No production transfer cancellation change is introduced.
|
||||
|
||||
## Resolve the shared integration files
|
||||
|
||||
- Root package and lockfile retain both the plugin workspace versions and
|
||||
main's pinned `@noble/ciphers` dependency.
|
||||
- The assistant retains its six-file staging build. The Host/Vault branch's
|
||||
older assistant preparation does not replace it.
|
||||
- `hostService.cjs` retains byte-stream, AI/context, Host/Vault, and logging
|
||||
construction and registration. Shutdown cleans up the existing services and
|
||||
closes logs in `finally`. Constructor-failure cleanup retains each service.
|
||||
- The AI host remains mounted in `application/app/AppView.tsx`. The Host action
|
||||
host remains inside the Vault/session providers in `App.tsx`. Trusted preload
|
||||
methods, source-bound authority, and canonical schema definitions are retained.
|
||||
- Canonical contract generation produces no drift after merging the shared
|
||||
schema. This worktree's `@netcatty/plugin-contract` and `@netcatty/plugin-sdk`
|
||||
resolve to its own `packages/` directories, not an older worktree.
|
||||
- The terminal merge unions canonical terminal and Vault prompt fields, trusted
|
||||
preload methods, feature registration, and lifecycle cleanup. Terminal target
|
||||
capture reuses the existing admission method and authorization guard. The
|
||||
generic interaction UI continues to ignore Vault-only requests; Vault retains
|
||||
its own handler. Inert terminal draft review returns a boolean receipt, never
|
||||
edited text or terminal input.
|
||||
- The installed Host test fixture now supports multiple lock listeners, matching
|
||||
the production app-lock bridge's `Set` subscription contract. The old fixture
|
||||
stored one callback and timed out after both Host and AI services subscribed.
|
||||
Production lock behavior is unchanged.
|
||||
|
||||
## Local evidence
|
||||
|
||||
Checks use Node 24.19.0 and a private task temp root through the existing temp
|
||||
bridge. HOME, endpoint policy, and sandbox settings are unchanged.
|
||||
|
||||
| Check | Local result |
|
||||
| --- | --- |
|
||||
| Registered plugin runtime | 676 tests: 674 passed, one missing-Electron failure, one native skip |
|
||||
| Final AI broker/context and real-handle sample panel | 27 passed, including explicit Note re-selection |
|
||||
| Contract/SDK/CLI | 91 passed, zero failures |
|
||||
| Host installed entrypoint/action isolation after terminal merge | 40 passed; earlier baseline Host/action/lock group passed 82 after correcting the subscription fixture |
|
||||
| Terminal selection, pane ownership/lifecycle, draft UI, palette failure | 19 passed |
|
||||
| Plugin settings/market/logging UI after gear correction | 36 passed, zero failures |
|
||||
| Registered database/terminal examples | Five passed, one opt-in Docker skip |
|
||||
| Vite lazy-settings HTTP/native-ESM probe | Passed after settings focus correction; dc6 alias repair retained |
|
||||
| Main TERM-SSHD file | All 32 cases passed, including both controlled arrival orders |
|
||||
| Contract/capability generation and drift, Codex schema | Passed |
|
||||
| Full lint | Zero errors, seven existing warnings |
|
||||
| Production build | Passed on the assembled source tree |
|
||||
| Five sample packages | Validated; two builds of each match byte for byte |
|
||||
|
||||
The initial connected group is retained as a failed run: 39 passed, nine failed,
|
||||
and two cancelled. The mounted AI chooser test failed because this local
|
||||
`--ignore-scripts` dependency installation lacks the Electron binary. The Host
|
||||
lock case timed out because of the single-listener test fixture; later subtests
|
||||
were cancelled. The corrected Host group passes as recorded above.
|
||||
|
||||
The combined Vite/main regression run records 33 passed and seven failed. Its
|
||||
Vite probe and all TERM-SSHD cases passed. The SSH algorithm import and six
|
||||
ChaCha/Electron cases could not load the same missing Electron binary. The
|
||||
normal pinned Electron installer was attempted with a writable task-local cache
|
||||
and failed with `TypeError: fetch failed`. No alternate download route, native
|
||||
rebuild bypass, Electron shim, or sandbox change was used. These local failures
|
||||
remain separate from the exact-head CI results recorded in the Draft PR.
|
||||
|
||||
The final registered runtime failure is the installed terminal reference's
|
||||
worker-reconnect case: the real worker imports the unavailable Electron binary.
|
||||
The same case failed for this prerequisite before the final AI merge. The other
|
||||
674 registered cases pass. The final focused AI suite passes all 27 cases; the
|
||||
earlier connected adapter run retains two passes and one missing-Electron
|
||||
failure. These failures are not relabeled as passing or suppressed.
|
||||
|
||||
## Reproducible packages and exact permissions
|
||||
|
||||
All package versions below are `0.1.0`. These hashes are for this candidate's
|
||||
SDK and source tree. Older #3993 database archives have different hashes and
|
||||
must not be substituted. The assistant and Host samples contain their READMEs;
|
||||
the database packages contain only manifest, browser bundle, and three panel
|
||||
assets. These handoff archives were not installed or activated in the native
|
||||
GUI. The automated installed-entrypoint tests use temporary synthetic fixtures;
|
||||
the Rfam candidate was not installed or granted permissions in any runtime.
|
||||
|
||||
| Package ID | Files | SHA-256 |
|
||||
| --- | --- | --- |
|
||||
| `com.netcatty.assistant` | 6 | `c7d784d5c3bd2feb463afd58eae3a9594615395b168174cb8a3d0677584ae395` |
|
||||
| `com.netcatty.database-minimal` | 5 | `f01c6a24382897d4b0bf4130941274abacb9a9a963f9b56ecaceb2c020f36874` |
|
||||
| `com.netcatty.database-rfam-readonly` | 5 | `aaca586669db454e91d61487e576ed524d1b7190eed182bc2cd0035e59caa0d3` |
|
||||
| `com.netcatty.host-actions` | 3 | `cf04f82dfe25ab5cda6f5f3dad0a8fb06c37bedf9924f556bee2b8402e542ef2` |
|
||||
| `com.netcatty.terminal-selection-draft` | 2 | `4c9c2388b7f891156192a0b5ab7b23291beba3555e7ec8cf24fbe2dfe1242d5e` |
|
||||
|
||||
The assistant matches the final owner's reported archive hash. Both database
|
||||
archives and the terminal sample changed after rebuilding against the combined
|
||||
SDK. The terminal source and CLI are unchanged from `8e7c28fe`; the integrated
|
||||
SDK has additional exports. The measured terminal content hash is
|
||||
`99797d8679f34b96580076f922cbec067e511adc0f4ccf396ed129206acb6c4c`.
|
||||
These archives replace earlier acceptance artifacts. Repeat-build equality and
|
||||
CLI validation do not grant native permissions or establish independent
|
||||
aggregate/package security verification. Host activation remains blocked by
|
||||
the separate automatic-review denial.
|
||||
|
||||
| Package | Required feature | Required permissions | Optional permissions/resources |
|
||||
| --- | --- | --- | --- |
|
||||
| Assistant | `ai.model-invocation`, `context.selected-read` | `commands`, `views` | `ai.invoke`, `context.selected.list`, `context.selected.read` |
|
||||
| Owned database | `network.byte-streams` | `commands`, `views` | `network.stream`: exactly `tcp://10.77.0.20:3306` |
|
||||
| Public Rfam | `network.byte-streams` | `commands`, `views` | `network.stream`: exactly `tcp://mysql-rfam-public.ebi.ac.uk:4497` |
|
||||
| Host actions | `vault.host-actions`, `commands.interactive` | `commands`, `menus` | `vault.metadata`, `vault.write`, `ui.interact` |
|
||||
| Terminal draft | `terminal.selection-sharing`, `terminal.input-drafts`, `commands.interactive` | `commands`, `menus` | `terminal.selection.read`, `terminal.input.propose`, each for one explicit operation |
|
||||
|
||||
The owned database address is packaging-only. A different approved fixture
|
||||
endpoint requires a new package and hash. No route from the GUI to the coding
|
||||
container's private MariaDB is assumed. The GUI's missing Docker runtime remains
|
||||
a fixture prerequisite, not a reason to change loopback/own-interface checks.
|
||||
|
||||
Reproduction commands after building the plugin packages:
|
||||
|
||||
```sh
|
||||
node examples/plugins/assistant/build.mjs
|
||||
node packages/plugin-cli/dist/cli.js pack examples/plugins/assistant/.package --out /tmp/assistant.ncpkg
|
||||
node examples/plugins/database-minimal/prepare.mjs tcp://10.77.0.20:3306 /tmp/database-owned.ncpkg
|
||||
node examples/plugins/database-rfam-readonly/prepare.mjs tcp://mysql-rfam-public.ebi.ac.uk:4497 /tmp/database-rfam-readonly.ncpkg
|
||||
node packages/plugin-cli/dist/cli.js pack examples/plugins/host-actions --out /tmp/host-actions.ncpkg
|
||||
node examples/plugins/terminal-selection-draft/prepare.mjs /tmp/terminal-selection-draft.ncpkg
|
||||
```
|
||||
|
||||
## Native, review, and deployment gates
|
||||
|
||||
| Gate | Status and next evidence |
|
||||
| --- | --- |
|
||||
| Exact-head test/build CI | Recorded in the dedicated Draft PR; earlier feature-head CI is historical evidence |
|
||||
| Native settings UI | Parent reports dc6 layout/controls and 823 gear-to-settings focus passed; those source-head results do not certify every flow on the integrated head |
|
||||
| Native AI sample | Final source correction review is scoped clean at `ba094616`; integrated native acceptance remains pending and activation is not performed here. Expiry requires fresh host selection, explicit dropdown choice, content grant and text review; no silent re-share or inference retry |
|
||||
| Native owned database | Pending a supported disposable fixture path; owned live regression remains opt-in and was not run here |
|
||||
| Public Rfam | Prepared only; no service DNS lookup, connection, query, installation, or permission activation. Parent coordinates exact hash/endpoint authorization. Only protocol 4.1/native-password is supported; auth/TLS compatibility is unverified and no fallback is added |
|
||||
| Native Host/Vault | Parent reports installation/details passed. Activation consent was denied by automatic review. Functional Host flows remain unrun pending specific user approval. Do not retry that activation or change permissions to bypass it |
|
||||
| Selected-terminal continuation | Correction review scoped clean at `8e7c28fe`; integrated checks recorded above; native acceptance pending. Input review remains inert and returns only a boolean receipt |
|
||||
| AI independent security review | The same worker reconsidered the earlier restriction once with newer maintainer evidence and completed review without recurrence. Parent reports the final bounded correction review clean at `ba094616`, including generation guards and old-handle invalidity. This task does not repeat that review or claim independent aggregate/package verification |
|
||||
| Main merge/release/deployment | Not authorized by this task; candidate remains Draft |
|
||||
|
||||
## Native handoff
|
||||
|
||||
Use the exact head from the Draft PR and the current five-package manifest/hash
|
||||
record. Source-head review and native settings evidence remain separately
|
||||
attributed. Do not substitute earlier assistant or database archives. No native
|
||||
Host activation retry, automatic re-sharing, or inference retry is part of this
|
||||
integration work.
|
||||
|
||||
Native evidence records the exact candidate commit, package hashes, source and
|
||||
permission prompts, denial/retry/cancel/revoke behavior, screenshots, and video.
|
||||
The coding executor's Node/DOM harnesses and Vite probe are not native sandbox
|
||||
certification. Database protocol and fixed query code remain in the examples.
|
||||
@@ -0,0 +1,153 @@
|
||||
[
|
||||
{
|
||||
"name": "assistant",
|
||||
"id": "com.netcatty.assistant",
|
||||
"version": "0.1.0",
|
||||
"fileCount": 6,
|
||||
"bytes": 14638,
|
||||
"sha256": "c7d784d5c3bd2feb463afd58eae3a9594615395b168174cb8a3d0677584ae395",
|
||||
"features": {
|
||||
"required": [
|
||||
"ai.model-invocation",
|
||||
"context.selected-read"
|
||||
]
|
||||
},
|
||||
"permissions": {
|
||||
"required": [
|
||||
"commands",
|
||||
"views"
|
||||
],
|
||||
"optional": [
|
||||
"ai.invoke",
|
||||
"context.selected.list",
|
||||
"context.selected.read"
|
||||
]
|
||||
},
|
||||
"package": "assistant.ncpkg",
|
||||
"repeatIdentical": true,
|
||||
"preparedOnly": true,
|
||||
"contentSha256": "9a4efe442cd1c35ec8f61e3e2e4ccbb4fa97addf168b0b8b6ac85bac79c39ca9"
|
||||
},
|
||||
{
|
||||
"name": "database-owned",
|
||||
"id": "com.netcatty.database-minimal",
|
||||
"version": "0.1.0",
|
||||
"fileCount": 5,
|
||||
"bytes": 11660,
|
||||
"sha256": "f01c6a24382897d4b0bf4130941274abacb9a9a963f9b56ecaceb2c020f36874",
|
||||
"features": {
|
||||
"required": [
|
||||
"network.byte-streams"
|
||||
]
|
||||
},
|
||||
"permissions": {
|
||||
"required": [
|
||||
"commands",
|
||||
"views"
|
||||
],
|
||||
"optional": [
|
||||
{
|
||||
"permission": "network.stream",
|
||||
"resources": [
|
||||
"tcp://10.77.0.20:3306"
|
||||
],
|
||||
"reason": "Connect only to the prepared disposable fixture."
|
||||
}
|
||||
]
|
||||
},
|
||||
"package": "database-owned.ncpkg",
|
||||
"repeatIdentical": true,
|
||||
"preparedOnly": true,
|
||||
"contentSha256": "4d628c41c7a231e31cd56dacb822acaaa37f61e1e98b285b0086a4f783be199d"
|
||||
},
|
||||
{
|
||||
"name": "database-rfam-readonly",
|
||||
"id": "com.netcatty.database-rfam-readonly",
|
||||
"version": "0.1.0",
|
||||
"fileCount": 5,
|
||||
"bytes": 11003,
|
||||
"sha256": "aaca586669db454e91d61487e576ed524d1b7190eed182bc2cd0035e59caa0d3",
|
||||
"features": {
|
||||
"required": [
|
||||
"network.byte-streams"
|
||||
]
|
||||
},
|
||||
"permissions": {
|
||||
"required": [
|
||||
"commands",
|
||||
"views"
|
||||
],
|
||||
"optional": [
|
||||
{
|
||||
"permission": "network.stream",
|
||||
"resources": [
|
||||
"tcp://mysql-rfam-public.ebi.ac.uk:4497"
|
||||
],
|
||||
"reason": "Run the fixed public Rfam acceptance query only after explicit authorization."
|
||||
}
|
||||
]
|
||||
},
|
||||
"package": "database-rfam-readonly.ncpkg",
|
||||
"repeatIdentical": true,
|
||||
"preparedOnly": true,
|
||||
"contentSha256": "f542ca7b8e821d684cf7f71f0930458bd3ad97bc94bf654c88b0f353111e9162"
|
||||
},
|
||||
{
|
||||
"name": "host-actions",
|
||||
"id": "com.netcatty.host-actions",
|
||||
"version": "0.1.0",
|
||||
"fileCount": 3,
|
||||
"bytes": 4633,
|
||||
"sha256": "cf04f82dfe25ab5cda6f5f3dad0a8fb06c37bedf9924f556bee2b8402e542ef2",
|
||||
"features": {
|
||||
"required": [
|
||||
"vault.host-actions",
|
||||
"commands.interactive"
|
||||
]
|
||||
},
|
||||
"permissions": {
|
||||
"required": [
|
||||
"commands",
|
||||
"menus"
|
||||
],
|
||||
"optional": [
|
||||
"vault.metadata",
|
||||
"vault.write",
|
||||
"ui.interact"
|
||||
]
|
||||
},
|
||||
"package": "host-actions.ncpkg",
|
||||
"repeatIdentical": true,
|
||||
"preparedOnly": true,
|
||||
"contentSha256": "d6f8bf3ae9dc417ea838f673966f2e1075fc8f9ef1d939ca85b476b1bb007e6c"
|
||||
},
|
||||
{
|
||||
"name": "terminal-selection-draft",
|
||||
"id": "com.netcatty.terminal-selection-draft",
|
||||
"version": "0.1.0",
|
||||
"fileCount": 2,
|
||||
"bytes": 2954,
|
||||
"sha256": "4c9c2388b7f891156192a0b5ab7b23291beba3555e7ec8cf24fbe2dfe1242d5e",
|
||||
"features": {
|
||||
"required": [
|
||||
"terminal.selection-sharing",
|
||||
"terminal.input-drafts",
|
||||
"commands.interactive"
|
||||
]
|
||||
},
|
||||
"permissions": {
|
||||
"required": [
|
||||
"commands",
|
||||
"menus"
|
||||
],
|
||||
"optional": [
|
||||
"terminal.selection.read",
|
||||
"terminal.input.propose"
|
||||
]
|
||||
},
|
||||
"package": "terminal-selection-draft.ncpkg",
|
||||
"repeatIdentical": true,
|
||||
"preparedOnly": true,
|
||||
"contentSha256": "99797d8679f34b96580076f922cbec067e511adc0f4ccf396ed129206acb6c4c"
|
||||
}
|
||||
]
|
||||
@@ -14,6 +14,10 @@ transport task owns the public schema, SDK, and runtime peer. Its
|
||||
`c4433d4961ecf4a81bea92b0a14f47b4ec12f679` head contains the public AI/context
|
||||
routes and is merged into this branch. The parent assigned this task the additive
|
||||
trusted-host IPC schema, preload, AppView mount, and AI-only hostService wiring.
|
||||
The base later advanced to `f1410f8bb`; its source/stream changes are incorporated
|
||||
by ordinary merge. Three overlaps retain this branch's permission explanations,
|
||||
bounded-stream assertions, and six-file assistant staging. The source database
|
||||
and sidecar changes come from their owning task, without local modifications.
|
||||
|
||||
The model API uses a host-selected configured model. Plugins receive a chat
|
||||
handle, a display label, an expiry, turn handles, text chunks, and usage totals.
|
||||
@@ -55,8 +59,12 @@ This branch does not claim that workflow is complete.
|
||||
database and sync implementation are unchanged. Source origin does not imply
|
||||
that a package is officially maintained.
|
||||
|
||||
`ModelInvocationBroker` requires host-provided `resolveDataOwner` and
|
||||
`selectModel` adapters. It keeps chats and turns in memory. It stores no chat
|
||||
`ModelInvocationBroker` requires host-provided `resolveDataOwner`,
|
||||
`resolveBudgetOwner`, and `selectModel` adapters. The service derives the stable
|
||||
budget owner from the authenticated source key and opaque private owner. It never
|
||||
accepts a plugin-supplied budget key or falls back to a bare plugin ID. Strict
|
||||
runtime binding objects still govern handle access; their replacement does not
|
||||
reset request or token budgets. It keeps chats and turns in memory. It stores no chat
|
||||
history and makes no database changes. Data owners are opaque host identities;
|
||||
no private storage is keyed by `pluginId`.
|
||||
|
||||
@@ -191,6 +199,11 @@ Failed aggregate runs remain evidence, not passing acceptance records:
|
||||
| `netcatty-ai-connected-final-runtime.log` | Connected code: 610 tests, 607 passed, 1 failed, 0 cancelled, 2 skipped |
|
||||
| `netcatty-ai-fixed-runtime.log` | After `0ba3ea606`: 610 tests, 608 passed, 0 failed, 0 cancelled, 2 skipped |
|
||||
| `netcatty-ai-fixed-contract-suite.log` | After `0ba3ea606`: all 88 contract/SDK/CLI tests passed |
|
||||
| `netcatty-ai-fixed-final-tests.log` | Before the later review corrections, `173ef5abb`: 15,248 tests, 15,205 passed, 0 failed, 0 cancelled, 43 skipped |
|
||||
| `netcatty-ai-review-final-runtime.log` | After both review corrections: 614 tests, 612 passed, 0 failed, 0 cancelled, 2 skipped |
|
||||
| `netcatty-ai-review-final-tests.log` | Review-corrected head `d81896b0d`: 15,252 tests, 15,209 passed, 0 failed, 0 cancelled, 43 skipped, before the later base merge |
|
||||
| `netcatty-ai-review-merged-tests.log` | Integrated head `07eb68c5e`: 15,255 tests, 15,212 passed, 0 failed, 0 cancelled, 43 skipped, before the selected-Note expiry correction |
|
||||
| `netcatty-ai-note-expiry-runtime.log` | Selected-Note expiry correction: 625 tests, 624 passed, 0 failed, 0 cancelled, 1 skipped |
|
||||
|
||||
The ten Cursor failures in the second full run came from its missing SQLite
|
||||
native dependency. Rebuilding the existing dependency with the standard
|
||||
@@ -229,9 +242,66 @@ or terminal permission.
|
||||
|
||||
Independent review confirmed the malformed-reference revocation defect on
|
||||
`afe831bfc`. The two regression tests failed before the repair and pass afterward.
|
||||
The repair is retained in #3985 head `4882e0501`. Further independent review was
|
||||
blocked by the review tool/model restriction. That review is incomplete and has
|
||||
not been retried through another route. CI is not a substitute for that review.
|
||||
The repair is retained in #3985 head `4882e0501`. Further review was initially
|
||||
blocked. After receiving newer exact maintainer security-task evidence, the same
|
||||
worker completed its bounded read-only review of `173ef5abb` without a repeated
|
||||
restriction or alternate route/model. It confirmed the earlier repair and found
|
||||
two additional P2 defects: runtime-bound budget keys and the sample's expired
|
||||
chat recovery.
|
||||
|
||||
Commit `ee5a4a406` separates stable source budgets from strict runtime handle
|
||||
identity. Its fake-clock regression uses the actual source-binding class,
|
||||
replaces runtime binding objects, tests request and reserved-token budgets,
|
||||
isolates another source with the same public plugin ID, and checks the minute
|
||||
boundary. Both budget subtests fail on the reviewed source and pass after repair.
|
||||
Missing or invalid host budget identities fail closed.
|
||||
|
||||
Commit `e1cb95ee8` reopens an expired sample chat before an explicit Send and adds
|
||||
**Refresh model** for changed-model or failed sessions. Refresh preserves the
|
||||
draft and does not send inference. A failed send/read is never automatically
|
||||
retried. Stop or page teardown invalidates pending opens and closes late sessions.
|
||||
Four controlled-time DOM regressions fail before the repair and pass afterward,
|
||||
including an uncertain inference outcome and Stop during reopening. They use no
|
||||
network or paid provider. Failed-before logs are
|
||||
`/tmp/netcatty-ai-budget-review-before.log` and
|
||||
`/tmp/netcatty-ai-panel-review-before.log`; passing logs use the corresponding
|
||||
`-after.log` names. Correction review independently confirmed the stable budget
|
||||
fix, but found the remaining selected-Note expiry path at `07eb68c5e`.
|
||||
|
||||
The corrected broker suite passes all 17 tests, the sample's controlled-time
|
||||
panel suite passes all 4, and the connected provider/UI suite passes all 4. Full
|
||||
lint still reports zero errors and seven existing warnings; codegen drift and
|
||||
the production build pass. Two independent package builds reproduce SHA-256
|
||||
`c0f733697b8f43a8591f39c02aeea5d8b7b4bd544b642ed87e758e62d98696a5`
|
||||
for that earlier sample checkpoint.
|
||||
|
||||
Commit `9e7c9df58` adds explicit **Choose reference** recovery. The panel retains
|
||||
summary expiry locally, blocks an expired selection before Send, and never sends
|
||||
that local metadata as an authorization field. Refresh model never re-shares a
|
||||
Note. Re-selection opens the existing host chooser and resets the dropdown to
|
||||
No reference; the user selects the fresh handle and clicks Send explicitly.
|
||||
|
||||
The service's identical-selection cache now reuses only complete live summaries.
|
||||
After an explicit host re-selection, expired/revoked handles remain invalid and
|
||||
fresh eligible handles are issued. No authority lifetime or revocation rule is
|
||||
changed. Summary selection does not read text. Every new content handle still
|
||||
requires the existing per-context grant and exact-text review.
|
||||
|
||||
The new fake-clock panel regression uses the actual host service and selected
|
||||
context authority with a real synthetic Note handle. It covers expiry, model
|
||||
refresh without re-sharing, explicit re-selection, denial of the fresh content
|
||||
grant, cancellation of text review, and one successful explicit send with the
|
||||
draft preserved. It repeats the expiry/re-selection with both chat and Note
|
||||
lifetimes expired, then succeeds without a manual model refresh. A separate
|
||||
service regression reproduces the formerly empty
|
||||
same-Note cache after expiry. Both fail on `07eb68c5e`; failed-before evidence is
|
||||
`/tmp/netcatty-ai-note-expiry-panel-before.log` and
|
||||
`/tmp/netcatty-ai-note-expiry-service-before.log`. The combined panel/service
|
||||
suite now passes all 10 tests; the connected installed UI/provider suite passes
|
||||
all 4. The replacement six-file artifact has SHA-256
|
||||
`c7d784d5c3bd2feb463afd58eae3a9594615395b168174cb8a3d0677584ae395`.
|
||||
Independent verification of the latest correction and native acceptance remain
|
||||
pending. CI is not a substitute for either.
|
||||
|
||||
Native Electron launch on this executor aborts because `chrome-sandbox` is not
|
||||
root-owned mode 4755. No sandbox bypass or native retry was attempted. Native
|
||||
|
||||
@@ -29,7 +29,9 @@ browser runtime entry. It has no Node entry, networking, filesystem, secrets,
|
||||
storage, terminal, or command-execution permission. Its `commands` permission
|
||||
registers its own view callbacks. It cannot execute terminal commands.
|
||||
The six-file artifact at this checkpoint has SHA-256
|
||||
`8ef162d13d0a16878fb817462759c9221352d0565dcefebeb102ea3575a918e8`.
|
||||
`c7d784d5c3bd2feb463afd58eae3a9594615395b168174cb8a3d0677584ae395`.
|
||||
This replaces the earlier `c0f73369...` artifact after the selected-Note expiry
|
||||
recovery repair.
|
||||
|
||||
## Start the deterministic fixture
|
||||
|
||||
@@ -105,6 +107,27 @@ Synthetic retry plan: add a retry button and show a connection status label.
|
||||
11. Keep the fixture request counter and aborted-connection counter in the
|
||||
evidence. Do not include prompt bodies, headers, credentials, or Note content
|
||||
other than the synthetic text above in logs.
|
||||
12. Type a synthetic draft and click **Refresh model** while idle. The model
|
||||
session must reopen, the draft must remain, and the fixture request count
|
||||
must not increase. Change the selected configured model in host Settings,
|
||||
refresh again, and check the displayed model before explicitly sending.
|
||||
Refresh and failed requests must never automatically resend inference.
|
||||
13. Keep a selected synthetic Note until its five-minute handle expires. Send
|
||||
must explain that the reference expired without reading text or calling the
|
||||
fixture. Refresh model must not share the Note again. Click **Choose
|
||||
reference**, confirm the Note in the host chooser, select its fresh reference
|
||||
in the dropdown, and click Send yourself. Deny the fresh content permission
|
||||
once and cancel text review once before allowing a successful fixture run.
|
||||
The draft must survive all steps, and no request may be silently retried.
|
||||
|
||||
The expiry regression in `scripts/plugin-assistant-panel.test.cjs` controls the
|
||||
panel and actual host service/selected-context authority clocks and advances past
|
||||
300,000 ms using a real selected Note handle, without network calls. It checks
|
||||
fresh host selection, old-handle rejection, independent content permission and
|
||||
exact-text review, and a successful explicit send. The broker budget
|
||||
regression controls its clock across runtime replacement and the minute boundary.
|
||||
Do not change the host clock or production limits to reproduce those cases in
|
||||
native QA.
|
||||
|
||||
The automated suites cover source substitution, runtime identity, two-handle
|
||||
revocation after malformed input, late replies, byte/token/time bounds, and
|
||||
@@ -114,7 +137,14 @@ installed runtime, configured provider transport, and UI lifecycle.
|
||||
## Record remaining limits
|
||||
|
||||
Independent review found the malformed-context revocation bug. The regression
|
||||
reproduced and the repair passed focused tests. Further independent review was
|
||||
blocked by the review tool/model restriction. That review remains incomplete and
|
||||
has not been retried through another route. CI and native evidence do not replace
|
||||
that missing review.
|
||||
reproduced and the repair passed focused tests. Further review was initially
|
||||
blocked; after newer exact maintainer security-task evidence, the same worker
|
||||
completed its bounded read-only review at `173ef5abb`, with no repeated restriction
|
||||
or alternate route/model. Two additional P2 findings were reproduced and repaired
|
||||
in `ee5a4a406` (stable source budgets) and `e1cb95ee8` (sample session recovery).
|
||||
Correction review independently confirmed the stable source-budget fix, then
|
||||
identified the remaining selected-Note expiry path at `07eb68c5e`. Commit
|
||||
`9e7c9df58` repairs that path with explicit reference re-selection and fresh
|
||||
handles after host confirmation. The real-Note panel and service regressions
|
||||
failed before repair and pass afterward. Independent verification of this latest
|
||||
correction remains pending. CI and native evidence do not replace it.
|
||||
@@ -69,9 +69,12 @@ plugin disable, revocation through the service, and shutdown.
|
||||
The connected host renderer and shared permission descriptions from #3986 are
|
||||
preserved. Its owner documents the adapter and existing configured model path in
|
||||
[the AI checkpoint](ai-context-wip.md), with a
|
||||
[synthetic native assistant recipe](assistant-native-qa.md). No further
|
||||
independent review of AI internals was performed. The prior review denial remains
|
||||
unresolved and has not been retried through another route.
|
||||
[synthetic native assistant recipe](assistant-native-qa.md). The original AI
|
||||
worker later reconsidered the earlier review restriction once with newer
|
||||
maintainer evidence and completed the review. Published fixes and remaining
|
||||
verification/native holds are tracked in the current
|
||||
[acceptance matrix](acceptance-candidate.md). This integration task does not
|
||||
repeat that review.
|
||||
|
||||
## Database example
|
||||
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
# Test integrated byte streams on the native desktop
|
||||
|
||||
This record describes #3993 and its earlier package hashes. For the unified
|
||||
main, transport/AI, Host/Vault, and settings/logging candidate, use
|
||||
[the dedicated acceptance matrix](acceptance-candidate.md). Rebuild and verify
|
||||
that candidate's package hashes before native testing.
|
||||
|
||||
Use `integration/plugin-byte-stream-handoff` from its integration PR. Record the
|
||||
exact commit with `git rev-parse HEAD`. The branch preserves transport #3988
|
||||
(`c4433d4961ecf4a81bea92b0a14f47b4ec12f679`), AI integration #3986
|
||||
@@ -102,8 +107,12 @@ and record that package's new hash. Preparation does not dial the endpoint.
|
||||
Build the assistant and follow its existing
|
||||
[native QA recipe](assistant-native-qa.md). Use its deterministic local provider,
|
||||
synthetic credential marker, and synthetic Note. No paid model call is required.
|
||||
The AI internals were integrated without another independent review. The earlier
|
||||
review denial remains in effect and is not replaced by these tests.
|
||||
The original AI worker subsequently completed the bounded review after a single
|
||||
reconsideration using newer maintainer evidence. The parent reports its final
|
||||
bounded correction review clean at `ba094616`: source budgets and explicit
|
||||
selected-Note re-selection are repaired. The [acceptance matrix](acceptance-candidate.md)
|
||||
records aggregate checks and remaining native gates. These transport tests do
|
||||
not substitute for native consent or acceptance.
|
||||
|
||||
## Record the installed browser path
|
||||
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
# Host and Vault actions
|
||||
|
||||
Feature: `vault.host-actions`. The SDK registers three methods under
|
||||
`context.vault`. Netcatty owns the picker, field-diff review, and connection
|
||||
confirmation. Plugin business logic stays in the offline browser sandbox.
|
||||
|
||||
Base: source integration #3981 at
|
||||
`4213d8e6726c44e7a479418ece3adf16ea494782`.
|
||||
The published stream/AI branch point `c4433d496` is preserved through an ordinary
|
||||
merge. Its owner was still completing integration checks when it was published.
|
||||
|
||||
## Reusable boundaries
|
||||
|
||||
| Concern | Existing contract | Reuse |
|
||||
| --- | --- | --- |
|
||||
| Host picker | `components/SelectHostPanelContent.tsx` | Controlled `selectedHostIds` and `onSelectionChange`; group toggles resolve to explicit host IDs. |
|
||||
| Typed drafts | `ImporterHostDraft` in `packages/plugin-contract/schema/plugin-contract.schema.json` | Create allowlist: label, hostname, username, port, group, tags. Edit allowlist: label, group, tags. |
|
||||
| Validation and preview | `normalizePluginImporterRecords`, `buildVaultHostFromDraft` | Validate the raw address, normalize the importer draft, validate the normalized result, and show the normalized field diff. |
|
||||
| Vault writes | `useVaultState.commitVaultImportTransaction` | Existing shared Vault lock, expected host snapshot, encryption, stale-write checks, and journaled persistence. Optional authorization guard runs after encryption. |
|
||||
| Connection | `AppSideEffects.handleConnectToHost` | Existing saved-settings, group defaults, authentication, logging, and session creation flow. |
|
||||
| Script disclosure | `applyGroupDefaults`, `hasHostConnectAutomation`, `snippetAppliesToOutputTrigger` | Boolean disclosure covers inherited startup commands, global/group/host connect scripts, unresolved bindings, host output triggers, and applicable global output scripts. Script contents remain host-owned. |
|
||||
| Lifecycle | `PluginHostInteractionService`, `PluginPermissionEngine`, runtime route `assertActive` | Existing window ownership, focus, lock, signed replies, cancellation, and revocation patterns. |
|
||||
| Source identity | `PluginDatabase` | `getActivePlugin`, `getRetainedPluginSource`, `privateOwner`, and `publicPluginId`; no caller-supplied owner or bare-ID fallback. |
|
||||
|
||||
## Public methods
|
||||
|
||||
| Method | Parameters | Result |
|
||||
| --- | --- | --- |
|
||||
| `vault.selectHosts` | `{ title: string }` | `{ cancelled: true }`, or `{ cancelled: false, hosts, groups }`. |
|
||||
| `vault.requestHostDraft` | Create: `{ draft: { label?, hostname, username?, port?, group?, tags? } }`. Edit: `{ target, expectedRevision, draft: { label?, group?, tags? } }`. | Cancelled, or `{ cancelled: false, status: "saved" }`. |
|
||||
| `vault.requestConnect` | `{ target, expectedRevision }` | Cancelled, or `{ cancelled: false, status: "requested" }`; no session identifier or content. |
|
||||
|
||||
Selected metadata contains label, hostname, username, optional port, protocol,
|
||||
group path, and tags. Each host result contains `{ target, metadata }`. Host IDs
|
||||
remain behind the typed target reference; its revision is the expected revision.
|
||||
Group metadata describes the explicitly selected current member handles. The
|
||||
grant never expands when another host joins a group.
|
||||
|
||||
Selection grants metadata access only. Each draft save and each connection
|
||||
requires its own host-owned confirmation. A connection confirmation displays the
|
||||
saved target and whether saved scripts can run. It does not grant terminal input,
|
||||
terminal output, or session access.
|
||||
|
||||
All requests are bounded and cancel on runtime stop, source replacement, revoke,
|
||||
Vault lock, owner window replacement, expiry, or user cancellation. The host
|
||||
revalidates authority and expected revision after asynchronous work and before
|
||||
the Vault write or connection dispatch.
|
||||
|
||||
Bounds reuse `HostInteractionLimits`: one pending request per runtime,
|
||||
eight total, ten per minute, a 60-second confirmation deadline, and a 64-KiB
|
||||
request ceiling. Selection adds a maximum of 50 current hosts. Titles use the
|
||||
existing 128-character single-line limit. Draft field lengths use
|
||||
`ImporterHostDraft`. Unknown properties are rejected by the shared schema.
|
||||
|
||||
Creates produce saved SSH drafts. An empty group clears the saved group. Edits
|
||||
preserve connection endpoints and credentials. Changing a group can change
|
||||
inherited authentication, proxies, and scripts; the draft review states this.
|
||||
Drafts involving managed SSH-config hosts or SSH destinations inside managed
|
||||
groups are explicitly unavailable in this small API. The review disables saving
|
||||
and directs the user to the Vault host editor. The execution adapter enforces the
|
||||
same restriction before claiming action authority, using the existing
|
||||
`applyVaultHostUpdate` ownership rules. It also checks the live and persisted
|
||||
managed-source catalog for changes before writing. This API does not initiate
|
||||
managed-file sync or expose plugin file access.
|
||||
|
||||
The API excludes Vault and renderer stores, the internal MCP catalog, plaintext
|
||||
credentials, private keys, TOTP configuration, startup scripts, and arbitrary
|
||||
execution. Host selection and action UI live in Netcatty. Plugin business logic
|
||||
stays in the sandboxed plugin.
|
||||
|
||||
## Typed target and action authority
|
||||
|
||||
`VaultHostRef` is a signed `host.selection` reference with `scope: "metadata"`,
|
||||
an opaque handle, a revision, and an expiry. It identifies an exact chosen host.
|
||||
It does not authorize editing, connecting, terminal input, or terminal output.
|
||||
|
||||
References bind source, private owner, plugin version, runtime, window owner and
|
||||
generation, exact host ID, revision, and expiry. They live for five minutes at
|
||||
most, with at most 500 retained references. Another selection replaces that
|
||||
runtime's previous references. Lock, revoke, window replacement, and runtime stop
|
||||
invalidate the affected references. Removal or replacement of a host makes its
|
||||
renderer revision stale, even if its visible fields are identical.
|
||||
|
||||
`PluginAISourceBinding` supplies the existing host-only source identity primitive.
|
||||
It revalidates after asynchronous authentication. AI selected-context handles are
|
||||
text-disclosure handles and cannot target these actions.
|
||||
|
||||
The permission engine gates selection with `vault.metadata`, drafts with
|
||||
`vault.write`, and connection requests with `ui.interact`. Every save or connection
|
||||
also requires a separate host-owned confirmation and a signed one-use action
|
||||
claim. A stored permission grant cannot bypass this review.
|
||||
|
||||
After review, the renderer prepares the existing transaction or connection
|
||||
request. It claims action authority after the final asynchronous preparation,
|
||||
immediately before persistence or dispatch. This claim is the action's commit
|
||||
point. Completion only acknowledges the committed action. Automation changes
|
||||
during connection review require fresh confirmation.
|
||||
The connection label uses the same `resolveEffectiveTerminalProtocol` helper as
|
||||
the launcher, including Mosh/ET flags and implicit localhost shells. Local
|
||||
execution displays an explicit on-device shell notice and omits the remote SSH
|
||||
username and port.
|
||||
|
||||
## Sample and validation
|
||||
|
||||
`examples/plugins/host-actions` adds four commands through the existing command
|
||||
palette slot. Its create draft uses `synthetic.invalid`; it requests no network,
|
||||
filesystem, terminal-content, or credential permissions.
|
||||
|
||||
`components/PluginHostActions.entrypoint.test.tsx` installs that sample and drives
|
||||
its actual entrypoint, SDK, RPC registry, source binding, signed replies,
|
||||
production React dialog, and renderer execution adapter. It covers explicit
|
||||
selection, cancel, stale revisions, locked Vault, host removal and replacement,
|
||||
revoke, source replacement, runtime removal, and connection confirmation. Registry
|
||||
checks reject text handles, tampered references, caller owners, and mismatched
|
||||
expected revisions. Only synthetic resources are used. The Node transport fixture
|
||||
does not certify the operating-system sandbox.
|
||||
Focused action tests compare Mosh, ET, and localhost confirmations with the real
|
||||
`handleConnectToHostImpl` dispatcher while replacing terminal creation with a
|
||||
synthetic recorder. Managed A-to-B/outside moves, managed destinations, ordinary
|
||||
unmanaged saves, and catalog changes use synthetic source records; no SSH config
|
||||
files are read or written.
|
||||
|
||||
Native screenshots and video run on the parent's existing cloud GUI. This branch
|
||||
does not change Electron sandbox or helper permissions. Tests use synthetic hosts
|
||||
and credentials. No real host connection or credential access is authorized.
|
||||
@@ -0,0 +1,87 @@
|
||||
# Local logging validation
|
||||
|
||||
Base: PR #3978 at `91b8927c56314e68c06e2c008e84559ddfb96d78`.
|
||||
All data used in these checks is synthetic and each persistence test creates its
|
||||
own temporary profile. No real logs or credentials were opened.
|
||||
|
||||
| Check | Result |
|
||||
| --- | --- |
|
||||
| `node --test electron/plugins/*.test.cjs` | 561 passed, 2 expected skips (Electron live test and the #3981 database integration test on this older base) |
|
||||
| New logging settings, existing settings and permission-panel interaction tests | 4 passed |
|
||||
| `npm run build` | Passed; existing large-chunk warning |
|
||||
| `npm run lint` | No errors, 7 existing warnings; final changed-source lint also passed |
|
||||
| `npm run check:plugin-contract` | Passed, no generated drift |
|
||||
| `npx tsc --noEmit` against this change and the exact base | 773 diagnostics in each, zero added or removed after normalizing worktree paths |
|
||||
| `git diff --check` | Passed |
|
||||
|
||||
Coverage includes disabled and enabled persistence, opt-in plugin text, sensitive
|
||||
field exclusion, host source attribution, unknown-source fallback, URL rejection,
|
||||
spoofed metadata, cyclic/accessor/proxy input, noisy writers, shared queue and disk
|
||||
limits, rotation, expiration, drop/error counts, export, clear confirmation,
|
||||
disable/revoke, stale runtime callbacks, and manager shutdown. The filesystem
|
||||
regressions cover direct paths, symlink aliases, a symlinked profile directory,
|
||||
and revocation during the final protected-storage check.
|
||||
|
||||
## Remaining native checks
|
||||
|
||||
Electron launch failed before application/profile startup. The executable at
|
||||
`node_modules/electron/dist/electron --version` aborted with:
|
||||
|
||||
> The SUID sandbox helper binary was found, but is not configured correctly.
|
||||
|
||||
Its message requires `chrome-sandbox` to be root-owned with mode 4755. No sandbox
|
||||
switch, privilege escalation, permission repair, or native retry followed. Native
|
||||
smoke execution, screenshots, and a short recording remain pending. The acceptance
|
||||
flow is in [Local plugin logging](local-logging.md#native-qa-with-synthetic-data).
|
||||
|
||||
`npm run pack:dir` completed its production build, then failed during serialport
|
||||
native rebuilding: node-gyp returned ENOENT for mkdir
|
||||
`/home/agent/.electron-gyp`. No system-directory change or packaging retry followed.
|
||||
A native package was not produced.
|
||||
|
||||
## Parent integration
|
||||
|
||||
The only shared settings-page change is one import and one `<PluginLogSettings />`
|
||||
element. Preserve that element when integrating the marketplace task's page.
|
||||
There are no Installed, Official, Third-party, source-database, or unrelated host
|
||||
data changes.
|
||||
|
||||
The default host-owned resolver is wired through `createPluginHostService`. It
|
||||
consumes #3981's confirmed API without editing its database or sync implementation.
|
||||
On the older #3987 base, missing source APIs remain explicitly unknown. No shared
|
||||
transport-registration change is needed beyond the existing logging registration.
|
||||
|
||||
## Source and review follow-up
|
||||
|
||||
A separate worktree at `59721f97172acffffe960a6268a2916a90a6c554` received only
|
||||
the logging backend overlay. Its logging/source/host regression command passed
|
||||
33 tests with no skips, including the real database replacement, encoded local-owner roundtrip and
|
||||
shutdown-drain case. The product branch does not import marketplace changes or bump its database
|
||||
schema.
|
||||
|
||||
Independent review of the implementation follow-up found no remaining blocking
|
||||
findings. Independent runs passed 50 focused regressions with one expected source
|
||||
skip, 20 real-source integration regressions with no skips, and the logging UI
|
||||
interaction test. The reviewer made no edits and did not launch Electron. A further independent
|
||||
review of the newer encoded-local-owner contract found no issues; all six
|
||||
provenance tests passed on exact `59721f97172acffffe960a6268a2916a90a6c554`,
|
||||
with an additional probe confirming the local encoded owner.
|
||||
|
||||
Fixed review findings have deterministic regressions: 31 days of low-volume
|
||||
appends and export cannot extend segment retention via mtime; stale sources cannot
|
||||
rotate away existing evidence; settings-write failure shows actual host state;
|
||||
log draining still completes when another shutdown stage rejects; and activation
|
||||
permission denial produces a fixed host record before runtime creation.
|
||||
|
||||
Exact prior head `e3cc23bca81a4187ccc13b90d3882889d524ca3b` passed remote
|
||||
[test 40339839263](https://github.com/binaricat/Netcatty/actions/runs/40339839263)
|
||||
and [build-packages 40339839660](https://github.com/binaricat/Netcatty/actions/runs/40339839660).
|
||||
Those runs do not validate the follow-up head. Its exact-head CI is reported in
|
||||
the PR after pushing.
|
||||
|
||||
The native fixture build is reproducible: 3,270 bytes, SHA-256
|
||||
`c3770a55676ed708f01ec1e0e0b9968ffb5e03c02303ee7cb9f739bde27c9de0`.
|
||||
The concise flow and commands are in [Local plugin logging](local-logging.md#native-qa-with-synthetic-data).
|
||||
Native screenshots and recording remain assigned to the parent's working cloud
|
||||
GUI machine. No local sandbox bypass or packaging retry is authorized by this
|
||||
handoff.
|
||||
@@ -0,0 +1,181 @@
|
||||
# Local plugin logging
|
||||
|
||||
Local file persistence defaults to off. The existing permission panel keeps its
|
||||
bounded security history independently. Changing or clearing local log files does
|
||||
not clear that history, grant permissions, or change a plugin's source.
|
||||
|
||||
## Saved records
|
||||
|
||||
`PluginLogService` extends the existing `PluginLogger`. It owns one shared sink at
|
||||
`<userData>/plugins/logs/host-v1`, outside plugin storage. Files contain JSON Lines.
|
||||
They stay on the device. No cloud upload or sync path includes these files or the
|
||||
local preference at `<userData>/plugins/local-logging.json`.
|
||||
|
||||
Host audit records contain these facts:
|
||||
|
||||
- Host-assigned plugin ID, installed version, archive SHA-256, runtime ID and kind.
|
||||
- Source ID, source kind, and namespace from an injected host provenance resolver.
|
||||
Missing provenance is `null`. Manifest claims never imply an official source.
|
||||
- Registered host RPC operation, authorization outcome, result, numeric error code,
|
||||
elapsed milliseconds, and available input/output `data` field byte counts.
|
||||
- Target identifiers from host authorization descriptors, replaced with a keyed
|
||||
SHA-256 digest. The random key stays in memory, so target correlation lasts only
|
||||
for the current app process.
|
||||
- Cumulative dropped-record and write-error counts at enqueue time.
|
||||
|
||||
Runtime start, running, stop, failure, protocol rejection, and permission revocation
|
||||
produce separate host facts. Required and advanced activation permission failures
|
||||
produce a fixed `runtime.authorization` event even before a runtime ID exists. The registry records known RPC attempts even when
|
||||
validation or authorization fails. Authorization has separate `allowed`, `denied`,
|
||||
`not-required`, and `not-reached` states. A handler error after approval preserves
|
||||
`allowed` and records the failed result. Unknown values remain explicit.
|
||||
|
||||
The byte counts measure UTF-8 bytes in a string `data` field, or byte length for a
|
||||
binary `data` field. They are not total wire traffic or decoded base64 size.
|
||||
Missing measurements are `null`. Streaming traffic outside these calls does not
|
||||
become a complete per-byte trace. Host facts describe what the host observed;
|
||||
plugin-returned success cannot prove an external side effect occurred.
|
||||
|
||||
Host audit records never copy request bodies, response bodies, error messages,
|
||||
credentials, API keys, passwords, prompts, Notes, SQL, query results, or terminal
|
||||
content. Error codes come from the host RPC error set. No audit field accepts
|
||||
plugin-supplied source identity.
|
||||
|
||||
## Optional plugin text
|
||||
|
||||
Verbose diagnostics require a separate confirmation and turn off after app
|
||||
restart. Console messages, utility stdout/stderr, and `logger.write` text use
|
||||
`recordType: plugin-diagnostic` and `trust: plugin-authored-unverified`. The host
|
||||
adds identity outside the nested `diagnostic` object. Plugin fields named
|
||||
`pluginId`, `sourceId`, or `recordType` cannot replace reserved metadata.
|
||||
|
||||
Text and structured fields have traversal, length, depth and total-record limits.
|
||||
Control characters are removed and known sensitive field names are replaced.
|
||||
These bounds do **not** make arbitrary plugin text safe. A plugin can put a secret
|
||||
under an unexpected key or inside a sentence. Review files before sharing them.
|
||||
No plugin text is persisted when verbose diagnostics are off.
|
||||
|
||||
## Limits and lifecycle
|
||||
|
||||
The shared sink retains at most three files of 1 MiB each, for at most seven days.
|
||||
Expiry uses the first host timestamp in each segment, read within the existing
|
||||
16 KiB record bound. Appending cannot extend a segment's lifetime. Export uses
|
||||
the same cutoff. A segment is removed when its first record expires; capacity
|
||||
rotation or segment expiry can remove later records sooner. Retention runs on
|
||||
startup, writes and hourly while the app runs, including after saving is disabled. The app cannot delete expired files while it is closed.
|
||||
The asynchronous queue accepts at most 256 KiB and records drops when full.
|
||||
Plugin text can occupy only a quarter of that queue, leaving capacity for host
|
||||
facts. Diagnostics also reuse the existing per-runtime log token bucket (240
|
||||
records per minute, with a 240-record initial burst), including console output. Each
|
||||
record is limited to 16 KiB. Oversized records are dropped. Write failures increase
|
||||
both drop and error counts and never fail the plugin operation.
|
||||
|
||||
Turning saving off stops ingress, discards queued records that have not started,
|
||||
and waits for any current write. Runtime cleanup closes its diagnostic logger.
|
||||
App shutdown drains accepted writes through the existing manager shutdown path,
|
||||
including when another cleanup stage rejects. A failed settings write refreshes
|
||||
the checkbox from actual host state while retaining the original error.
|
||||
Clearing pauses ingress, discards queued records, waits for a current write and
|
||||
removes only the sink's known files. Cancellation leaves them intact. Export takes
|
||||
a bounded snapshot on the write queue and uses the native Save dialog.
|
||||
|
||||
Files use mode 0600 and the directory uses 0700 on systems that support them.
|
||||
The host filesystem broker rejects writes to the audit directory and preferences,
|
||||
including canonical paths reached through symlinks. Plugin views cannot invoke
|
||||
log management IPC. No plugin API exposes audit mutation. These local files are
|
||||
not a tamper-evident ledger against the OS account or the development-only utility
|
||||
runtime, which already has full Node and filesystem authority.
|
||||
|
||||
## Integration boundaries
|
||||
|
||||
`PluginLogSettings` presents a compact settings row on `PluginSettingsLanding`.
|
||||
Its standard host dialog contains the save and verbose switches plus local file
|
||||
actions. The landing page also shows installed-plugin quick controls using the
|
||||
existing host manager. The Installed, Official and Third-party manager pages keep
|
||||
their existing layout; declared settings links open the relevant installed detail.
|
||||
The logging backend and source database are unchanged by this UI restructuring.
|
||||
|
||||
`createPluginHostService` now injects the host-owned
|
||||
`createPluginLogProvenanceResolver(database)` by default. It consumes the source
|
||||
API confirmed on #3981 at `59721f97172acffffe960a6268a2916a90a6c554`:
|
||||
|
||||
- `getActivePlugin(pluginId)` supplies the installed version, hash and source.
|
||||
- `getRetainedPluginSource(pluginId)` must agree with the installed source.
|
||||
- `privateOwner(pluginId)` supplies the opaque namespace.
|
||||
- `publicPluginId(owner)` must round-trip to the host-assigned public ID.
|
||||
|
||||
The adapter receives host activation identity only, after the existing caller
|
||||
guards. It never accepts source or owner fields from plugin request parameters.
|
||||
Missing APIs, an absent installation, changed version/hash, a retained-source
|
||||
mismatch or an orphaned owner leave provenance explicitly unknown. There is no
|
||||
bare-plugin-ID fallback. The current host API encodes every owner, including
|
||||
local imports; a bare ID returned as an owner remains unknown.
|
||||
|
||||
Each logger snapshots the source binding and supplies a host-only `isCurrent`
|
||||
check. The sink checks it at enqueue, after asynchronous preflight, before
|
||||
rotation mutations and immediately before appending. Records from a replaced or
|
||||
uninstalled source are dropped and counted. They are never relabelled as the new
|
||||
source. Already saved records keep their original identity.
|
||||
|
||||
The optional `resolvePluginProvenance` override remains host-owned. Its result
|
||||
contains `sourceId`, `sourceKind`, `namespace`, and an optional synchronous
|
||||
`isCurrent` function. Source IDs and kinds accept 1-128 ASCII letters, digits,
|
||||
periods, underscores, colons and hyphens. Opaque namespaces accept the same
|
||||
characters up to 256 bytes. URLs, display names and plugin claims are not source
|
||||
identity. `official` describes the host-bound source, not official maintenance.
|
||||
|
||||
The source database, marketplace page and sync implementation are unchanged by
|
||||
this adapter. It also needs no new shared host-registration change for transport.
|
||||
|
||||
## Native QA with synthetic data
|
||||
|
||||
Use the parent's working cloud GUI machine, a fresh isolated profile, and the
|
||||
synthetic fixture below. Do not retry the blocked local Electron launch.
|
||||
Do not open real logs or credentials. Capture screenshots of the settings card,
|
||||
verbose disclosure, and native Clear confirmation. Record a short flow:
|
||||
|
||||
1. Open Plugins settings and choose **Configure** in the local logging row.
|
||||
Confirm both options are off and no host-v1 files exist.
|
||||
2. Enable local saving. Invoke a synthetic plugin host operation and deny another.
|
||||
3. Open the folder and export to a synthetic destination. Inspect parsed JSONL for
|
||||
host identity, outcomes, target digests, and absence of synthetic secret bodies.
|
||||
4. Open verbose disclosure and cancel. Confirm no plugin text is saved. Confirm
|
||||
the disclosure, write synthetic text, and verify the unverified record type.
|
||||
5. Disable/revoke the plugin. Confirm its later text cannot append. Restart the
|
||||
app and verify that local saving remains selected but verbose text is off.
|
||||
6. Cancel Clear, then confirm it. Confirm the files are removed and the permission
|
||||
panel's separate security history remains. Turn local saving off.
|
||||
|
||||
The fixture is `electron/plugins/fixtures/local-logging-plugin`. Build it with:
|
||||
|
||||
```sh
|
||||
node --input-type=module -e 'import { buildPluginPackage } from "@netcatty/plugin-cli"; await buildPluginPackage("electron/plugins/fixtures/local-logging-plugin", "/tmp/netcatty-local-logging-fixture.ncpkg");'
|
||||
```
|
||||
|
||||
Package: 3,270 bytes. SHA-256:
|
||||
`c3770a55676ed708f01ec1e0e0b9968ffb5e03c02303ee7cb9f739bde27c9de0`.
|
||||
It declares only `commands` and `menus`; its command-palette entries are under
|
||||
**Logging QA**. **Emit synthetic diagnostic** tests redaction and nested spoofed
|
||||
identity fields. **Emit synthetic diagnostic burst** emits 400 console messages
|
||||
to exercise drop accounting. **Test undeclared network denial** must return denial
|
||||
without a prompt or network I/O because the manifest declares no network access.
|
||||
No request body or credential is supplied. Do not grant additional permissions.
|
||||
|
||||
Capture the compact settings card, verbose disclosure and native Clear dialog,
|
||||
plus a short recording of the enable/emit/export/clear flow. On a #3981 integration
|
||||
build, a normal local import must log the source and opaque owner returned by its
|
||||
host database. On the older base, both stay explicitly unknown. Do not forge an
|
||||
Official source through fixture metadata. The real-source replacement case is
|
||||
covered by `pluginLogProvenance.integration.test.cjs` on the documented source
|
||||
integration worktree; it uses only temporary synthetic database state.
|
||||
|
||||
The execution environment for this change could not launch Electron: its SUID
|
||||
sandbox helper is not root-owned with mode 4755. Electron aborted before opening
|
||||
any profile. No sandbox-disable retry or system-permission change was attempted.
|
||||
`npm run pack:dir` also failed while rebuilding the serialport native dependency:
|
||||
node-gyp tried to create `/home/agent/.electron-gyp` and received ENOENT. No
|
||||
system-directory change or packaging retry was attempted.
|
||||
|
||||
Native screenshots and recording therefore remain pending in the parent desktop
|
||||
QA queue. Component interaction tests are separate evidence and do not replace
|
||||
that acceptance.
|
||||
@@ -0,0 +1,46 @@
|
||||
# Plugin settings
|
||||
|
||||
Settings > Plugins uses the same sections, divided cards, rows and toggles as
|
||||
other Netcatty settings. The first two rows open the plugin manager and configure
|
||||
[local logging](local-logging.md). Logging details live in a standard host dialog;
|
||||
opening it does not enable saving or verbose diagnostics.
|
||||
|
||||
The installed list shows each plugin's icon, name, version, publisher, description,
|
||||
host-bound source and runtime status. Search filters this metadata. Quick toggles
|
||||
call the existing host manager, which retains compatibility checks, permission
|
||||
prompts and runtime lifecycle rules. Failed mutations leave the actual enabled
|
||||
state visible. A settings button appears only for declared settings or settings
|
||||
views, and is enabled only when the host exposes those contributions.
|
||||
|
||||
## Manual update checks
|
||||
|
||||
**Check for updates** reads installed packages, registered sources and each bound
|
||||
source's catalog through the existing host bridge. It never installs code or
|
||||
changes permissions. Open **Browse** to review and install an update through the
|
||||
existing manager flow.
|
||||
|
||||
Only a fresh registry response with a published artifact and valid semantic
|
||||
version counts as checked. Cached, built-in, failed and incomplete responses do
|
||||
not imply that installed plugins are current. Local, unknown, removed and disabled
|
||||
sources are not checked. The summary reports checked packages, available updates,
|
||||
unchecked packages and unavailable sources separately.
|
||||
|
||||
The checker matches source ID, kind and origin, then revalidates source, installed
|
||||
version and archive hash after asynchronous reads. Source or package changes
|
||||
invalidate displayed results. Checks run only when requested; there is no automatic
|
||||
checking preference, automatic installation or global pause switch.
|
||||
|
||||
## Native acceptance
|
||||
|
||||
Use a fresh isolated profile and synthetic plugins. Capture the compact landing
|
||||
page and logging dialog at normal and narrow window sizes, including keyboard
|
||||
focus, scrolling, loading, empty and error states. Verify a denied quick-enable
|
||||
request leaves the switch off, and a successful request shows the real host state.
|
||||
Open declared plugin settings, switch manager tabs, return to Installed and reopen
|
||||
the settings. Close the dialog and verify focus returns to its opener.
|
||||
|
||||
Run a manual update check against a synthetic source, then an unavailable source;
|
||||
verify the summary distinguishes unchecked packages and never installs anything.
|
||||
Complete the separate local-logging acceptance flow, including verbose consent,
|
||||
native Clear cancellation/confirmation and export. Do not use real logs or
|
||||
credentials.
|
||||
@@ -80,8 +80,12 @@ HTTP 403 for the configured Electron header download under
|
||||
The download was not retried through a mirror. Exact-head platform CI remains the
|
||||
packaging verification surface; its result is recorded on the Draft PR.
|
||||
|
||||
The sample archive is
|
||||
The original owner-head sample archive is
|
||||
`f3b8ac268edbf616effe76110f372d2249e414675d877020a074c89f03c65950`.
|
||||
The unified candidate rebuild has a different SDK and archive hash; its current
|
||||
hash, source checkpoints and review/native gates are in the
|
||||
[acceptance record](acceptance-candidate.md). Do not substitute the owner archive
|
||||
for that candidate's package.
|
||||
The parent-coordinated [native recipe](terminal-sharing-native-qa.md) remains
|
||||
pending on the reserved GUI machine. No GUI launch, sandbox bypass, helper
|
||||
permission change, paid provider call, or real-user resource was used.
|
||||
@@ -0,0 +1,40 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyPluginHostEdit, normalizePluginHostDraft, pluginHostFieldDiff, pluginHostRunsSavedScripts } from './pluginHostActions';
|
||||
import type { Host, Snippet } from './models';
|
||||
|
||||
const host: Host = { id: 'synthetic-host', label: 'Synthetic', hostname: 'host.invalid', username: 'test', os: 'linux', tags: [], password: 'synthetic-secret', startupCommand: 'synthetic-script' };
|
||||
|
||||
test('host action drafts reuse importer normalization and discard credential/executable fields', () => {
|
||||
const created = normalizePluginHostDraft({ ...host, password: 'untrusted', protocol: 'local' } as never, 'host-owned-id');
|
||||
assert.equal(created.id, 'host-owned-id');
|
||||
assert.equal(created.protocol, 'ssh');
|
||||
assert.equal(created.password, undefined);
|
||||
assert.equal(created.startupCommand, undefined);
|
||||
assert.throws(() => normalizePluginHostDraft({ hostname: 'host.invalid', port: 0 }, 'id'));
|
||||
assert.throws(() => normalizePluginHostDraft({ hostname: 'host.invalid\ninvalid' }, 'id'));
|
||||
assert.throws(() => normalizePluginHostDraft({ hostname: 'host.invalid other.invalid' }, 'id'));
|
||||
});
|
||||
|
||||
test('edit draft preserves saved connection and credentials and diff includes only approved metadata', () => {
|
||||
const edited = applyPluginHostEdit(host, { label: ' Renamed ', tags: ['new'], hostname: 'attacker.invalid', password: 'untrusted' } as never);
|
||||
assert.equal(edited.password, host.password);
|
||||
assert.equal(edited.hostname, host.hostname);
|
||||
assert.equal(edited.startupCommand, host.startupCommand);
|
||||
assert.throws(() => applyPluginHostEdit(host, { label: ' ' }), /required/);
|
||||
assert.deepEqual(pluginHostFieldDiff(host, edited), [
|
||||
{ field: 'label', before: 'Synthetic', after: 'Renamed' },
|
||||
{ field: 'tags', before: [], after: ['new'] },
|
||||
]);
|
||||
assert.equal(JSON.stringify(pluginHostFieldDiff(undefined, normalizePluginHostDraft(host, 'id'))).includes('synthetic-secret'), false);
|
||||
});
|
||||
|
||||
test('connection warning covers host, inherited, global and output automation without returning script content', () => {
|
||||
const clean = { ...host, startupCommand: undefined };
|
||||
assert.equal(pluginHostRunsSavedScripts(clean, [], []), false);
|
||||
assert.equal(pluginHostRunsSavedScripts(host, [], []), true);
|
||||
assert.equal(pluginHostRunsSavedScripts({ ...clean, group: 'Synthetic' }, [{ path: 'Synthetic', startupCommand: 'synthetic-group-script' }], []), true);
|
||||
assert.equal(pluginHostRunsSavedScripts(clean, [], [{ id: 'global', label: 'Global', command: 'synthetic', kind: 'script', trigger: 'onConnect', targetsAllHosts: true } as Snippet]), true);
|
||||
assert.equal(pluginHostRunsSavedScripts(clean, [], [{ id: 'output', label: 'Output', command: 'synthetic', kind: 'script', trigger: 'onOutput', triggerPattern: 'ready' } as Snippet]), true);
|
||||
assert.equal(pluginHostRunsSavedScripts({ ...clean, outputTriggers: [{ id: 't', pattern: 'ready', scriptId: 'synthetic' }] }, [], []), true);
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import type { ImporterHostDraft } from '@netcatty/plugin-contract';
|
||||
import type { GroupConfig, Host, ManagedSource, Snippet } from './models';
|
||||
import { applyGroupDefaults, resolveGroupDefaults } from './groupConfig';
|
||||
import { hasHostConnectAutomation } from './hostConnectScripts';
|
||||
import { normalizePluginImporterRecords } from './pluginImporter';
|
||||
import { applyVaultHostUpdate, buildVaultHostFromDraft } from './vaultHostCreate';
|
||||
import { isScriptSnippet } from './snippetScript';
|
||||
import { snippetAppliesToOutputTrigger } from './snippetTargets';
|
||||
|
||||
// Keep the public host draft a subset of the existing typed importer. In
|
||||
// particular, references to credentials, plugin configuration and executable
|
||||
// settings are never accepted by this surface.
|
||||
export type PluginHostDraftFields = Pick<ImporterHostDraft,
|
||||
'label' | 'hostname' | 'username' | 'port' | 'group' | 'tags'>;
|
||||
export type PluginHostEditFields = Pick<PluginHostDraftFields, 'label' | 'group' | 'tags'>;
|
||||
|
||||
export const PLUGIN_HOST_CREATE_FIELDS = ['label', 'hostname', 'username', 'port', 'group', 'tags'] as const;
|
||||
export const PLUGIN_HOST_EDIT_FIELDS = ['label', 'group', 'tags'] as const;
|
||||
|
||||
export function normalizePluginHostDraft(draft: PluginHostDraftFields, id: string): Host {
|
||||
const fields = Object.fromEntries(PLUGIN_HOST_CREATE_FIELDS.flatMap((field) => (
|
||||
draft[field] === undefined ? [] : [[field, draft[field]]]
|
||||
)));
|
||||
// Validate before sanitization as well: an invalid address must not become
|
||||
// a different, valid endpoint when the importer removes control characters.
|
||||
const raw = { ...fields, hostname: draft.hostname ?? '', protocol: 'ssh' as const };
|
||||
if (!buildVaultHostFromDraft(raw).ok) throw new Error('Host draft is invalid');
|
||||
const normalized = normalizePluginImporterRecords([{
|
||||
type: 'draft', draft: { kind: 'host', value: raw },
|
||||
}]);
|
||||
if (normalized.errors.length || normalized.hosts.length !== 1) {
|
||||
throw new Error('Host draft is invalid');
|
||||
}
|
||||
const validated = buildVaultHostFromDraft(normalized.hosts[0]);
|
||||
if (!validated.ok) throw new Error('Host draft is invalid');
|
||||
return { ...validated.host, id };
|
||||
}
|
||||
|
||||
export function applyPluginHostEdit(host: Host, draft: PluginHostEditFields): Host {
|
||||
if (draft.label !== undefined && !draft.label.trim()) throw new Error('Host label is required');
|
||||
const normalized = normalizePluginHostDraft({
|
||||
label: draft.label ?? host.label,
|
||||
hostname: 'draft.invalid', username: '',
|
||||
group: draft.group ?? host.group, tags: draft.tags ?? host.tags,
|
||||
}, host.id);
|
||||
return { ...host, label: normalized.label, group: normalized.group, tags: normalized.tags };
|
||||
}
|
||||
|
||||
export function pluginHostFieldDiff(before: Host | undefined, after: Host) {
|
||||
const fields = before ? PLUGIN_HOST_EDIT_FIELDS : PLUGIN_HOST_CREATE_FIELDS;
|
||||
return fields.flatMap((field) => {
|
||||
const previous = before?.[field];
|
||||
const value = after[field];
|
||||
return JSON.stringify(previous) === JSON.stringify(value) ? [] : [{
|
||||
field, before: previous ?? null, after: value ?? null,
|
||||
}];
|
||||
});
|
||||
}
|
||||
|
||||
/** Managed SSH-config writes stay in the full Vault editor. Reuse its group
|
||||
* ownership rules without opening or syncing any managed file here. */
|
||||
export function pluginHostDraftTouchesManagedSource(host: Host, managedSources: ManagedSource[]): boolean {
|
||||
if (host.managedSourceId) return true;
|
||||
if (managedSources.length === 0) return false;
|
||||
const reconciled = applyVaultHostUpdate([host], [], host.id, { group: host.group ?? '' }, { managedSources });
|
||||
if (!reconciled.ok) throw new Error('Host draft is invalid');
|
||||
return Boolean(reconciled.updatedHost.managedSourceId);
|
||||
}
|
||||
|
||||
/** Only the host UI consumes this disclosure; script text never crosses RPC. */
|
||||
export function pluginHostRunsSavedScripts(host: Host, groups: GroupConfig[], snippets: Snippet[]): boolean {
|
||||
const effective = applyGroupDefaults(host, host.group ? resolveGroupDefaults(host.group, groups) : {});
|
||||
return Boolean(effective.startupCommand?.trim())
|
||||
|| hasHostConnectAutomation(effective, snippets)
|
||||
|| snippets.some((snippet) => isScriptSnippet(snippet) && snippetAppliesToOutputTrigger(snippet, effective))
|
||||
|| Boolean(effective.outputTriggers?.length);
|
||||
}
|
||||
@@ -246,6 +246,7 @@ function createBridgeRegistrar(context) {
|
||||
resolveBrandIcon: (pluginId) => pluginHostService?.contributionIconService?.resolveBrand(pluginId),
|
||||
resolveContributionIcon: (payload) => pluginHostService?.contributionIconService?.resolve(payload),
|
||||
themeService: pluginHostService?.themeService,
|
||||
logService: pluginHostService?.logService,
|
||||
viewHost: pluginHostService?.viewHost,
|
||||
env: process.env,
|
||||
isTrustedSender: createTrustedPluginBridgeSender({ devServerUrl: effectiveDevServerUrl }),
|
||||
|
||||
@@ -30,6 +30,7 @@ class PluginAIContextService {
|
||||
this.runtimeContexts = new Map();
|
||||
this.registrations = [];
|
||||
this.broker = new ModelInvocationBroker({ resolveDataOwner: this.resolveOwner,
|
||||
resolveBudgetOwner: (binding) => JSON.stringify([binding.sourceKey, binding.privateOwner]),
|
||||
selectModel: async (context) => {
|
||||
await this.#guard(context);
|
||||
const model = await this.host.selectModel(context);
|
||||
@@ -117,7 +118,10 @@ class PluginAIContextService {
|
||||
}
|
||||
const previous = this.runtimeContexts.get(context.runtimeId);
|
||||
if (previous?.owner === owner && previous.keys === JSON.stringify(selections.map(({ key, version }) => [key, version]))) {
|
||||
return { items: await this.selected.summaries(context) };
|
||||
const items = await this.selected.summaries(context);
|
||||
if (items.length === selections.length) return { items };
|
||||
// The host has just explicitly selected these resources again. Expired
|
||||
// or revoked handles stay invalid; issue fresh handles for this selection.
|
||||
}
|
||||
this.selected.releaseRuntime(context.runtimeId);
|
||||
const created = [];
|
||||
|
||||
@@ -64,3 +64,16 @@ test("cancelling one host request does not revoke another chat in the same runti
|
||||
await new Promise(setImmediate); controller.abort(); resolve([]); await cancelled;
|
||||
assert.equal(f.service.broker.chats.has(chat.chatHandle), true);
|
||||
});
|
||||
|
||||
test("an explicit host re-selection issues fresh handles after identical selected Note summaries expire", async (t) => {
|
||||
const f = fixture(); t.after(() => f.service.shutdown());
|
||||
let now = 0; f.service.selected.clock = () => now;
|
||||
const { items: [old] } = await f.service.list({}, f.context);
|
||||
now = 300_001;
|
||||
await assert.rejects(f.service.selected.assertCurrent({ handle: old.handle, version: old.version }, f.context));
|
||||
const { items } = await f.service.list({}, f.context);
|
||||
assert.equal(items.length, 1);
|
||||
assert.notEqual(items[0].handle, old.handle); assert.equal(items[0].expiresAt, 600_001);
|
||||
await assert.rejects(f.service.selected.assertCurrent({ handle: old.handle, version: old.version }, f.context));
|
||||
assert.equal(f.reads(), 0); assert.equal(f.authorizations.length, 0); assert.equal(f.invoked(), 0);
|
||||
});
|
||||
@@ -28,9 +28,10 @@ function abortable(promise, signal) {
|
||||
// In-memory, source-isolated handles. No AgentRuntime, tools, chat persistence,
|
||||
// provider configuration, URLs, credentials or arbitrary system prompts cross it.
|
||||
class ModelInvocationBroker {
|
||||
constructor({ resolveDataOwner, selectModel, contexts, clock = Date.now, limits = {} }) {
|
||||
if (typeof resolveDataOwner !== "function" || typeof selectModel !== "function") throw new TypeError("Host adapters required");
|
||||
constructor({ resolveDataOwner, resolveBudgetOwner, selectModel, contexts, clock = Date.now, limits = {} }) {
|
||||
if (typeof resolveDataOwner !== "function" || typeof resolveBudgetOwner !== "function" || typeof selectModel !== "function") throw new TypeError("Host adapters required");
|
||||
this.resolveDataOwner = resolveDataOwner;
|
||||
this.resolveBudgetOwner = resolveBudgetOwner;
|
||||
this.selectModel = selectModel;
|
||||
this.contexts = contexts;
|
||||
this.clock = clock;
|
||||
@@ -54,13 +55,21 @@ class ModelInvocationBroker {
|
||||
const owner = await this.resolveDataOwner(context);
|
||||
await context.assertActive();
|
||||
if (!owner || (typeof owner !== "object" && typeof owner !== "string")) throw fail("permissionDenied", "Host data owner is unavailable");
|
||||
return { owner, runtimeId: context.runtimeId, pluginVersion: context.pluginVersion };
|
||||
// Handle identity remains runtime-specific. Budget identity is separately
|
||||
// supplied by the host and must survive runtime replacement/regrant.
|
||||
const budgetOwner = await this.resolveBudgetOwner(owner, context);
|
||||
await context.assertActive();
|
||||
if (typeof budgetOwner !== "string" || !budgetOwner || Buffer.byteLength(budgetOwner) > 8_192) {
|
||||
throw fail("permissionDenied", "Host budget owner is unavailable");
|
||||
}
|
||||
return { owner, budgetOwner, runtimeId: context.runtimeId, pluginVersion: context.pluginVersion };
|
||||
}
|
||||
|
||||
async #ownedChat(chat, context) {
|
||||
const binding = await this.#binding(context);
|
||||
if (this.closed || this.chats.get(chat.handle) !== chat
|
||||
|| chat.owner !== binding.owner || chat.runtimeId !== binding.runtimeId || chat.pluginVersion !== binding.pluginVersion) {
|
||||
|| chat.owner !== binding.owner || chat.budgetOwner !== binding.budgetOwner
|
||||
|| chat.runtimeId !== binding.runtimeId || chat.pluginVersion !== binding.pluginVersion) {
|
||||
throw fail("permissionDenied", "Plugin chat is unavailable");
|
||||
}
|
||||
}
|
||||
@@ -149,14 +158,14 @@ class ModelInvocationBroker {
|
||||
}
|
||||
await this.#guard(turn, context);
|
||||
this.#prune();
|
||||
const rate = this.rates.get(chat.owner) ?? { startedAt: this.clock(), count: 0, tokens: 0 };
|
||||
const rate = this.rates.get(chat.budgetOwner) ?? { startedAt: this.clock(), count: 0, tokens: 0 };
|
||||
// Reserve conservatively using UTF-8 input bytes plus the output cap. Do
|
||||
// not refund unknown provider usage after cancellation or disconnect.
|
||||
const reserved = bytes + 1_024 + this.limits.outputTokens;
|
||||
if ((!this.rates.has(chat.owner) && this.rates.size >= this.limits.rateOwners) || rate.count >= this.limits.requestsPerMinute || rate.tokens + reserved > this.limits.reservedTokensPerMinute) {
|
||||
if ((!this.rates.has(chat.budgetOwner) && this.rates.size >= this.limits.rateOwners) || rate.count >= this.limits.requestsPerMinute || rate.tokens + reserved > this.limits.reservedTokensPerMinute) {
|
||||
throw fail("resourceExhausted", "Model request/token budget reached");
|
||||
}
|
||||
rate.count++; rate.tokens += reserved; this.rates.set(chat.owner, rate);
|
||||
rate.count++; rate.tokens += reserved; this.rates.set(chat.budgetOwner, rate);
|
||||
await this.#guard(turn, context);
|
||||
const iterator = await abortable(chat.model.invoke({ prompt, contexts: selected, signal: controller.signal,
|
||||
maxOutputTokens: this.limits.outputTokens, maxChunkBytes: this.limits.chunkBytes }), controller.signal);
|
||||
|
||||
@@ -3,11 +3,12 @@ const assert = require("node:assert/strict");
|
||||
const test = require("node:test");
|
||||
const { ModelInvocationBroker } = require("./modelInvocationBroker.cjs");
|
||||
const { SelectedContextAuthority } = require("../context/selectedContextAuthority.cjs");
|
||||
const { PluginAISourceBinding } = require("./sourceBinding.cjs");
|
||||
const { RPC_ERRORS } = require("../rpcRouter.cjs");
|
||||
const deferred = () => { let resolve; const promise = new Promise((r) => { resolve = r; }); return { promise, resolve }; };
|
||||
const denied = (error) => error.code === RPC_ERRORS.permissionDenied;
|
||||
function fixture(options = {}) {
|
||||
const owner = {}; let currentOwner = owner; let current = true; let active = true; let now = 0;
|
||||
const owner = { budgetOwner: "synthetic-source-a" }; let currentOwner = owner; let current = true; let active = true; let now = 0;
|
||||
const request = new AbortController();
|
||||
const context = { pluginId: "same-id", pluginVersion: "1", runtimeId: "r1", signal: request.signal,
|
||||
assertActive: async () => { request.signal.throwIfAborted(); if (!active) throw Error("stopped"); } };
|
||||
@@ -17,7 +18,8 @@ function fixture(options = {}) {
|
||||
yield { kind: "usage", inputTokens: 7, outputTokens: 2, totalTokens: 9, secret: "DO_NOT_SERIALIZE" }; })(); }, ...options.model };
|
||||
const resolveDataOwner = async () => currentOwner;
|
||||
const contexts = new SelectedContextAuthority({ resolveDataOwner, clock: () => now });
|
||||
const broker = new ModelInvocationBroker({ resolveDataOwner, selectModel: async () => model, contexts,
|
||||
const broker = new ModelInvocationBroker({ resolveDataOwner, resolveBudgetOwner: (binding) => binding.budgetOwner,
|
||||
selectModel: async () => model, contexts,
|
||||
clock: () => now, limits: options.limits });
|
||||
return { broker, context, contexts, calls, request, owner, model, setOwner: (value) => { currentOwner = value; },
|
||||
setCurrent: (value) => { current = value; }, setActive: (value) => { active = value; }, setTime: (value) => { now = value; } };
|
||||
@@ -220,3 +222,65 @@ test("pending model selections reserve capacity and cannot publish after revocat
|
||||
pending.resolve({ ...f.model, dispose: () => { disposed++; } });
|
||||
await rejected; assert.equal(f.broker.chats.size, 0); assert.equal(f.broker.openings.size, 0); assert.equal(disposed, 1); f.broker.shutdown();
|
||||
});
|
||||
|
||||
test("same-source runtime replacement preserves minute budgets while distinct sources stay isolated", async (t) => {
|
||||
for (const limits of [{ requestsPerMinute: 1 }, { reservedTokensPerMinute: 3_074 }]) {
|
||||
await t.test(Object.keys(limits)[0], async (t) => {
|
||||
let now = 1_000; let sourceId = "source-a"; let runtimeId = "runtime-1"; let invocations = 0;
|
||||
const pluginId = "com.example.assistant";
|
||||
const context = () => ({ pluginId, pluginVersion: "1", runtimeId, assertActive: async () => {} });
|
||||
const source = () => ({ id: sourceId, kind: "third-party", origin: `https://${sourceId}.example` });
|
||||
const binding = new PluginAISourceBinding({
|
||||
database: {
|
||||
getActivePlugin: () => ({ enabled: true, activeVersion: "1", runtime: { status: "running" }, source: source() }),
|
||||
getRetainedPluginSource: source,
|
||||
privateOwner: () => `opaque:${sourceId}:assistant`,
|
||||
publicPluginId: (owner) => owner === `opaque:${sourceId}:assistant` ? pluginId : null,
|
||||
},
|
||||
getRuntimeIdentity: () => ({ ...context(), runtimeKind: "browser" }),
|
||||
});
|
||||
const broker = new ModelInvocationBroker({
|
||||
resolveDataOwner: (caller) => binding.resolve(caller),
|
||||
resolveBudgetOwner: (owner) => JSON.stringify([owner.sourceKey, owner.privateOwner]),
|
||||
selectModel: () => ({ label: "Synthetic model", isCurrent: () => true, invoke: () => {
|
||||
invocations++; return (async function* () {})();
|
||||
} }), clock: () => now, limits,
|
||||
});
|
||||
t.after(() => { broker.shutdown(); binding.shutdown(); });
|
||||
const start = (chat) => broker.start({ chatHandle: chat.chatHandle, prompt: "Hi" }, context());
|
||||
const replace = (nextSource) => {
|
||||
broker.releaseRuntime(runtimeId); binding.releaseRuntime(runtimeId);
|
||||
sourceId = nextSource; runtimeId = `${runtimeId}-next`;
|
||||
};
|
||||
const firstOwner = await binding.resolve(context());
|
||||
const first = await broker.open(context()); const firstTurn = await start(first);
|
||||
await broker.stop(firstTurn.turnHandle, context());
|
||||
replace("source-a");
|
||||
assert.notEqual(await binding.resolve(context()), firstOwner);
|
||||
const restarted = await broker.open(context());
|
||||
await assert.rejects(start(restarted), (error) => error.code === RPC_ERRORS.resourceExhausted);
|
||||
assert.equal(invocations, 1);
|
||||
assert.equal(await broker.close(first.chatHandle, context()), false);
|
||||
replace("source-b");
|
||||
const other = await broker.open(context()); const otherTurn = await start(other);
|
||||
await broker.stop(otherTurn.turnHandle, context()); assert.equal(invocations, 2);
|
||||
replace("source-a");
|
||||
const returned = await broker.open(context());
|
||||
now = 60_999;
|
||||
await assert.rejects(start(returned), (error) => error.code === RPC_ERRORS.resourceExhausted);
|
||||
now = 61_000;
|
||||
const afterWindow = await start(returned); assert.equal(invocations, 3);
|
||||
await broker.stop(afterWindow.turnHandle, context());
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("budget ownership requires an explicit host adapter and never falls back to the plugin ID", async (t) => {
|
||||
assert.throws(() => new ModelInvocationBroker({ resolveDataOwner: () => ({}), selectModel: () => ({}) }), /Host adapters required/);
|
||||
for (const budgetOwner of [undefined, "", {}, "x".repeat(8_193)]) {
|
||||
const f = fixture(); t.after(() => f.broker.shutdown());
|
||||
f.broker.resolveBudgetOwner = () => budgetOwner;
|
||||
await assert.rejects(f.broker.open(f.context), denied);
|
||||
assert.equal(f.calls.length, 0); assert.equal(f.broker.chats.size, 0);
|
||||
}
|
||||
});
|
||||
@@ -187,6 +187,8 @@ function assertSameOpenedDirectory(expectedStats, pathStats) {
|
||||
class PluginFilesystemBroker {
|
||||
constructor(options = {}) {
|
||||
this.quotaManager = options.quotaManager ?? null;
|
||||
this.protectedDirectories = options.protectedDirectories ?? [];
|
||||
this.protectedFiles = options.protectedFiles ?? [];
|
||||
this.fileSystem = options.fileSystem ?? fsp;
|
||||
this.openDirectoryHandle = options.openDirectoryHandle ?? null;
|
||||
if (this.openDirectoryHandle != null && typeof this.openDirectoryHandle !== "function") {
|
||||
@@ -261,10 +263,33 @@ class PluginFilesystemBroker {
|
||||
}
|
||||
}
|
||||
|
||||
async #assertWritable(canonical) {
|
||||
const variants = async (entry) => {
|
||||
const lexical = path.resolve(entry);
|
||||
try { return [lexical, await this.fileSystem.realpath(lexical)]; }
|
||||
catch (error) { if (error?.code !== "ENOENT") throw error; return [lexical]; }
|
||||
};
|
||||
for (const file of this.protectedFiles) {
|
||||
if ((await variants(file)).includes(canonical)) {
|
||||
throw new PluginRpcError(RPC_ERRORS.permissionDenied, "Host audit storage is protected");
|
||||
}
|
||||
}
|
||||
for (const directory of this.protectedDirectories) {
|
||||
for (const parent of await variants(directory)) {
|
||||
const relative = path.relative(parent, canonical);
|
||||
if (relative === "" || (!path.isAbsolute(relative) && relative !== ".." && !relative.startsWith(`..${path.sep}`))) {
|
||||
throw new PluginRpcError(RPC_ERRORS.permissionDenied, "Host audit storage is protected");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async writeFile(params, context) {
|
||||
const value = assertSecureWriteMode(assertWriteParams(params));
|
||||
await this.#assertWritable(path.resolve(value.path));
|
||||
assertAuthorizedPath(context, "filesystem.write", value.path);
|
||||
const { canonical, stats } = await resolveWritePath(value.path, this.fileSystem);
|
||||
await this.#assertWritable(canonical);
|
||||
assertAuthorizedPath(context, "filesystem.write", canonical);
|
||||
this.quotaManager?.chargeBytes(context.runtimeId, "filesystem", value.bytes.byteLength);
|
||||
await context.assertActive();
|
||||
@@ -278,6 +303,7 @@ class PluginFilesystemBroker {
|
||||
]);
|
||||
assertSameOpenedFile(openedStats, stats);
|
||||
assertSameOpenedFile(openedStats, pathStats);
|
||||
await this.#assertWritable(canonical);
|
||||
await context.assertActive();
|
||||
if (value.overwrite) await handle.truncate(0);
|
||||
await handle.writeFile(value.bytes);
|
||||
|
||||
@@ -390,3 +390,61 @@ test("filesystem broker rejects oversized and non-canonical payloads", async (co
|
||||
(error) => error.code === RPC_ERRORS.resourceExhausted,
|
||||
);
|
||||
});
|
||||
|
||||
test("plugin writes cannot modify host audit files or preferences, including symlink aliases", async (t) => {
|
||||
const root = createRoot(t);
|
||||
const directory = path.join(root, "host-logs");
|
||||
await fsp.mkdir(directory);
|
||||
const audit = path.join(directory, "runtime.jsonl");
|
||||
const preferences = path.join(root, "local-logging.json");
|
||||
await fsp.writeFile(audit, "host audit");
|
||||
await fsp.writeFile(preferences, "host preferences");
|
||||
const alias = path.join(root, "alias");
|
||||
await fsp.symlink(audit, alias);
|
||||
const broker = new PluginFilesystemBroker({ protectedDirectories: [directory], protectedFiles: [preferences] });
|
||||
for (const target of [audit, preferences, alias]) {
|
||||
const authorization = broker.describeWriteAuthorization({ path: target, data: "overwrite", overwrite: true });
|
||||
await assert.rejects(broker.writeFile({ path: target, data: "overwrite", overwrite: true }, runtimeContext(authorization)), { code: RPC_ERRORS.permissionDenied });
|
||||
}
|
||||
assert.equal(await fsp.readFile(audit, "utf8"), "host audit");
|
||||
assert.equal(await fsp.readFile(preferences, "utf8"), "host preferences");
|
||||
});
|
||||
|
||||
test("host audit protection resolves a symlinked userData directory", async (t) => {
|
||||
const root = createRoot(t);
|
||||
const actual = path.join(root, "actual-profile");
|
||||
await fsp.mkdir(path.join(actual, "logs"), { recursive: true });
|
||||
const profileAlias = path.join(root, "profile-alias");
|
||||
await fsp.symlink(actual, profileAlias);
|
||||
const target = path.join(actual, "logs", "runtime.jsonl");
|
||||
await fsp.writeFile(target, "host audit");
|
||||
const broker = new PluginFilesystemBroker({ protectedDirectories: [path.join(profileAlias, "logs")] });
|
||||
const params = { path: target, data: "overwrite", overwrite: true };
|
||||
const authorization = broker.describeWriteAuthorization(params);
|
||||
await assert.rejects(broker.writeFile(params, runtimeContext(authorization)), { code: RPC_ERRORS.permissionDenied });
|
||||
assert.equal(await fsp.readFile(target, "utf8"), "host audit");
|
||||
});
|
||||
|
||||
test("revocation during the protected-storage check cannot write an unrelated granted file", async (t) => {
|
||||
const root = createRoot(t);
|
||||
const logs = path.join(root, "host-logs");
|
||||
const target = path.join(root, "synthetic.txt");
|
||||
await fsp.mkdir(logs);
|
||||
await fsp.writeFile(target, "prior");
|
||||
let checks = 0;
|
||||
let active = true;
|
||||
const broker = new PluginFilesystemBroker({ protectedDirectories: [logs], fileSystem: {
|
||||
...fsp, async realpath(requested) {
|
||||
const canonical = await fsp.realpath(requested);
|
||||
if (requested === logs && ++checks === 3) active = false;
|
||||
return canonical;
|
||||
},
|
||||
} });
|
||||
const params = { path: target, data: "overwrite", overwrite: true };
|
||||
const authorization = broker.describeWriteAuthorization(params);
|
||||
const context = { ...runtimeContext(authorization), async assertActive() {
|
||||
if (!active) throw Object.assign(new Error("synthetic revocation"), { code: RPC_ERRORS.permissionDenied });
|
||||
} };
|
||||
await assert.rejects(broker.writeFile(params, context), { code: RPC_ERRORS.permissionDenied });
|
||||
assert.equal(await fsp.readFile(target, "utf8"), "prior");
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import { definePlugin } from "@netcatty/plugin-sdk";
|
||||
|
||||
export default definePlugin({
|
||||
activate(context) {
|
||||
const register = (suffix, handler) => context.subscriptions.add(context.commands.registerCommand(
|
||||
`com.example.local-logging-fixture.${suffix}`, handler,
|
||||
));
|
||||
register("emit", () => {
|
||||
context.logger.info("Synthetic diagnostic for local logging QA", {
|
||||
apiKey: "SYNTHETIC-API-KEY-NOT-A-CREDENTIAL",
|
||||
prompt: "SYNTHETIC-PROMPT-BODY",
|
||||
Notes: "SYNTHETIC-NOTE-BODY",
|
||||
SQL: "SYNTHETIC-SQL-BODY",
|
||||
results: "SYNTHETIC-RESULT-BODY",
|
||||
terminalContent: "SYNTHETIC-TERMINAL-BODY",
|
||||
pluginId: "synthetic-spoofed-plugin",
|
||||
sourceId: "official",
|
||||
runtimeId: "synthetic-spoofed-runtime",
|
||||
});
|
||||
return { ok: true, synthetic: true };
|
||||
});
|
||||
register("noise", () => {
|
||||
for (let index = 0; index < 400; index++) console.info(`Synthetic diagnostic burst ${index}`);
|
||||
return { ok: true, synthetic: true, emitted: 400 };
|
||||
});
|
||||
register("denied", async () => {
|
||||
// No network permission is declared. The host must reject before any
|
||||
// network I/O or approval prompt; no body or credential is ever supplied.
|
||||
try {
|
||||
await context.network.request({ method: "GET", url: "https://logging-fixture.example.com/should-never-be-sent" });
|
||||
throw new Error("Synthetic undeclared request unexpectedly succeeded");
|
||||
} catch (error) {
|
||||
if (error?.code !== "permission_denied" && error?.code !== -32007) throw error;
|
||||
return { denied: true, synthetic: true };
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"manifestVersion": 1,
|
||||
"id": "com.example.local-logging-fixture",
|
||||
"name": "local-logging-fixture",
|
||||
"displayName": "Synthetic local logging fixture",
|
||||
"description": "Synthetic diagnostics and denied-operation checks. No real data or credentials.",
|
||||
"version": "0.1.0",
|
||||
"publisher": "example",
|
||||
"engines": { "netcatty": ">=0.0.0", "api": ">=0.1.0-internal <0.2.0" },
|
||||
"main": { "browser": "index.js" },
|
||||
"activationEvents": [
|
||||
"onCommand:com.example.local-logging-fixture.emit",
|
||||
"onCommand:com.example.local-logging-fixture.noise",
|
||||
"onCommand:com.example.local-logging-fixture.denied"
|
||||
],
|
||||
"permissions": { "required": ["commands", "menus"], "optional": [] },
|
||||
"contributes": {
|
||||
"commands": [
|
||||
{ "id": "com.example.local-logging-fixture.emit", "title": "Emit synthetic diagnostic", "category": "Logging QA" },
|
||||
{ "id": "com.example.local-logging-fixture.noise", "title": "Emit synthetic diagnostic burst", "category": "Logging QA" },
|
||||
{ "id": "com.example.local-logging-fixture.denied", "title": "Test undeclared network denial", "category": "Logging QA" }
|
||||
],
|
||||
"menus": [
|
||||
{ "command": "com.example.local-logging-fixture.emit", "location": "commandPalette" },
|
||||
{ "command": "com.example.local-logging-fixture.noise", "location": "commandPalette" },
|
||||
{ "command": "com.example.local-logging-fixture.denied", "location": "commandPalette" }
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -231,6 +231,565 @@
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"HostVaultFeature": {
|
||||
"const": "vault.host-actions"
|
||||
},
|
||||
"VaultHostRef": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"kind",
|
||||
"scope",
|
||||
"handle",
|
||||
"revision",
|
||||
"expiresAt"
|
||||
],
|
||||
"properties": {
|
||||
"kind": {
|
||||
"const": "host.selection"
|
||||
},
|
||||
"scope": {
|
||||
"const": "metadata"
|
||||
},
|
||||
"handle": {
|
||||
"type": "string",
|
||||
"minLength": 32,
|
||||
"maxLength": 256
|
||||
},
|
||||
"revision": {
|
||||
"type": "string",
|
||||
"maxLength": 128,
|
||||
"pattern": "^[^\\u0000-\\u001f\\u007f]*$",
|
||||
"minLength": 1
|
||||
},
|
||||
"expiresAt": {
|
||||
"$ref": "#/$defs/SafePositiveInteger"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"description": "Host-signed source/runtime/window-bound metadata selection. Identifies an exact saved host and revision; edit and connect require separate host confirmation and one-use action authority. AI text handles are not Host targets."
|
||||
},
|
||||
"VaultHostMetadata": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"label",
|
||||
"hostname",
|
||||
"username",
|
||||
"group",
|
||||
"tags",
|
||||
"protocol"
|
||||
],
|
||||
"properties": {
|
||||
"label": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 512
|
||||
},
|
||||
"hostname": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 1024
|
||||
},
|
||||
"username": {
|
||||
"type": "string",
|
||||
"maxLength": 512
|
||||
},
|
||||
"port": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 65535
|
||||
},
|
||||
"group": {
|
||||
"type": "string",
|
||||
"minLength": 0,
|
||||
"maxLength": 512
|
||||
},
|
||||
"tags": {
|
||||
"type": "array",
|
||||
"maxItems": 128,
|
||||
"uniqueItems": true,
|
||||
"items": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 256
|
||||
}
|
||||
},
|
||||
"protocol": {
|
||||
"type": "string",
|
||||
"maxLength": 199,
|
||||
"pattern": "^[^\\u0000-\\u001f\\u007f]*$",
|
||||
"minLength": 1
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"VaultHostCreateFields": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"hostname"
|
||||
],
|
||||
"properties": {
|
||||
"label": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 512
|
||||
},
|
||||
"hostname": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 1024
|
||||
},
|
||||
"username": {
|
||||
"type": "string",
|
||||
"maxLength": 512
|
||||
},
|
||||
"port": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 65535
|
||||
},
|
||||
"group": {
|
||||
"type": "string",
|
||||
"minLength": 0,
|
||||
"maxLength": 512
|
||||
},
|
||||
"tags": {
|
||||
"type": "array",
|
||||
"maxItems": 128,
|
||||
"uniqueItems": true,
|
||||
"items": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 256
|
||||
}
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"VaultHostEditFields": {
|
||||
"type": "object",
|
||||
"required": [],
|
||||
"properties": {
|
||||
"label": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 512
|
||||
},
|
||||
"group": {
|
||||
"type": "string",
|
||||
"minLength": 0,
|
||||
"maxLength": 512
|
||||
},
|
||||
"tags": {
|
||||
"type": "array",
|
||||
"maxItems": 128,
|
||||
"uniqueItems": true,
|
||||
"items": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 256
|
||||
}
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"minProperties": 1
|
||||
},
|
||||
"VaultSelectHostsParams": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"title"
|
||||
],
|
||||
"properties": {
|
||||
"title": {
|
||||
"type": "string",
|
||||
"maxLength": 128,
|
||||
"pattern": "^[^\\u0000-\\u001f\\u007f]*$",
|
||||
"minLength": 1
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"VaultHostDraftParams": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"required": [
|
||||
"draft"
|
||||
],
|
||||
"properties": {
|
||||
"draft": {
|
||||
"$ref": "#/$defs/VaultHostCreateFields"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"required": [
|
||||
"target",
|
||||
"expectedRevision",
|
||||
"draft"
|
||||
],
|
||||
"properties": {
|
||||
"target": {
|
||||
"$ref": "#/$defs/VaultHostRef"
|
||||
},
|
||||
"expectedRevision": {
|
||||
"type": "string",
|
||||
"maxLength": 128,
|
||||
"pattern": "^[^\\u0000-\\u001f\\u007f]*$",
|
||||
"minLength": 1
|
||||
},
|
||||
"draft": {
|
||||
"$ref": "#/$defs/VaultHostEditFields"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
]
|
||||
},
|
||||
"VaultHostConnectParams": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"target",
|
||||
"expectedRevision"
|
||||
],
|
||||
"properties": {
|
||||
"target": {
|
||||
"$ref": "#/$defs/VaultHostRef"
|
||||
},
|
||||
"expectedRevision": {
|
||||
"type": "string",
|
||||
"maxLength": 128,
|
||||
"pattern": "^[^\\u0000-\\u001f\\u007f]*$",
|
||||
"minLength": 1
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"VaultHostActionRequest": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"required": [
|
||||
"kind",
|
||||
"title"
|
||||
],
|
||||
"properties": {
|
||||
"kind": {
|
||||
"const": "vault.selectHosts"
|
||||
},
|
||||
"title": {
|
||||
"type": "string",
|
||||
"maxLength": 128,
|
||||
"pattern": "^[^\\u0000-\\u001f\\u007f]*$",
|
||||
"minLength": 1
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"required": [
|
||||
"kind",
|
||||
"draft"
|
||||
],
|
||||
"properties": {
|
||||
"kind": {
|
||||
"const": "vault.requestHostDraft"
|
||||
},
|
||||
"draft": {
|
||||
"$ref": "#/$defs/VaultHostCreateFields"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"required": [
|
||||
"kind",
|
||||
"target",
|
||||
"expectedRevision",
|
||||
"draft"
|
||||
],
|
||||
"properties": {
|
||||
"kind": {
|
||||
"const": "vault.requestHostDraft"
|
||||
},
|
||||
"target": {
|
||||
"$ref": "#/$defs/VaultHostRef"
|
||||
},
|
||||
"expectedRevision": {
|
||||
"type": "string",
|
||||
"maxLength": 128,
|
||||
"pattern": "^[^\\u0000-\\u001f\\u007f]*$",
|
||||
"minLength": 1
|
||||
},
|
||||
"draft": {
|
||||
"$ref": "#/$defs/VaultHostEditFields"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"required": [
|
||||
"kind",
|
||||
"target",
|
||||
"expectedRevision"
|
||||
],
|
||||
"properties": {
|
||||
"kind": {
|
||||
"const": "vault.requestConnect"
|
||||
},
|
||||
"target": {
|
||||
"$ref": "#/$defs/VaultHostRef"
|
||||
},
|
||||
"expectedRevision": {
|
||||
"type": "string",
|
||||
"maxLength": 128,
|
||||
"pattern": "^[^\\u0000-\\u001f\\u007f]*$",
|
||||
"minLength": 1
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
]
|
||||
},
|
||||
"VaultHostSelectedResources": {
|
||||
"description": "Trusted host-renderer reply only. Never accepted from a plugin request.",
|
||||
"type": "array",
|
||||
"maxItems": 50,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"resourceId",
|
||||
"revision",
|
||||
"label",
|
||||
"hostname",
|
||||
"username",
|
||||
"group",
|
||||
"tags",
|
||||
"protocol"
|
||||
],
|
||||
"properties": {
|
||||
"resourceId": {
|
||||
"type": "string",
|
||||
"maxLength": 256,
|
||||
"pattern": "^[^\\u0000-\\u001f\\u007f]*$",
|
||||
"minLength": 1
|
||||
},
|
||||
"revision": {
|
||||
"type": "string",
|
||||
"maxLength": 128,
|
||||
"pattern": "^[^\\u0000-\\u001f\\u007f]*$",
|
||||
"minLength": 1
|
||||
},
|
||||
"label": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 512
|
||||
},
|
||||
"hostname": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 1024
|
||||
},
|
||||
"username": {
|
||||
"type": "string",
|
||||
"maxLength": 512
|
||||
},
|
||||
"port": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 65535
|
||||
},
|
||||
"group": {
|
||||
"type": "string",
|
||||
"minLength": 0,
|
||||
"maxLength": 512
|
||||
},
|
||||
"tags": {
|
||||
"type": "array",
|
||||
"maxItems": 128,
|
||||
"uniqueItems": true,
|
||||
"items": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 256
|
||||
}
|
||||
},
|
||||
"protocol": {
|
||||
"type": "string",
|
||||
"maxLength": 199,
|
||||
"pattern": "^[^\\u0000-\\u001f\\u007f]*$",
|
||||
"minLength": 1
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"VaultHostSelectedResult": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"cancelled",
|
||||
"hosts",
|
||||
"groups"
|
||||
],
|
||||
"properties": {
|
||||
"cancelled": {
|
||||
"const": false
|
||||
},
|
||||
"hosts": {
|
||||
"type": "array",
|
||||
"maxItems": 50,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"target",
|
||||
"metadata"
|
||||
],
|
||||
"properties": {
|
||||
"target": {
|
||||
"$ref": "#/$defs/VaultHostRef"
|
||||
},
|
||||
"metadata": {
|
||||
"$ref": "#/$defs/VaultHostMetadata"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"groups": {
|
||||
"type": "array",
|
||||
"maxItems": 50,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"path",
|
||||
"members"
|
||||
],
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string",
|
||||
"maxLength": 512,
|
||||
"pattern": "^[^\\u0000-\\u001f\\u007f]*$",
|
||||
"minLength": 1
|
||||
},
|
||||
"members": {
|
||||
"type": "array",
|
||||
"maxItems": 50,
|
||||
"items": {
|
||||
"$ref": "#/$defs/VaultHostRef"
|
||||
}
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"VaultHostSelectionResult": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"required": [
|
||||
"cancelled"
|
||||
],
|
||||
"properties": {
|
||||
"cancelled": {
|
||||
"const": true
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/VaultHostSelectedResult"
|
||||
}
|
||||
]
|
||||
},
|
||||
"VaultHostActionAccepted": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"cancelled",
|
||||
"status"
|
||||
],
|
||||
"properties": {
|
||||
"cancelled": {
|
||||
"const": false
|
||||
},
|
||||
"status": {
|
||||
"enum": [
|
||||
"saved",
|
||||
"requested"
|
||||
]
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"VaultHostActionResult": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"required": [
|
||||
"cancelled"
|
||||
],
|
||||
"properties": {
|
||||
"cancelled": {
|
||||
"const": true
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/VaultHostActionAccepted"
|
||||
}
|
||||
]
|
||||
},
|
||||
"VaultHostLocalTarget": {
|
||||
"description": "Trusted host renderer target only; resource IDs never cross the plugin API.",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"resourceId",
|
||||
"revision"
|
||||
],
|
||||
"properties": {
|
||||
"resourceId": {
|
||||
"type": "string",
|
||||
"maxLength": 256,
|
||||
"pattern": "^[^\\u0000-\\u001f\\u007f]*$",
|
||||
"minLength": 1
|
||||
},
|
||||
"revision": {
|
||||
"type": "string",
|
||||
"maxLength": 128,
|
||||
"pattern": "^[^\\u0000-\\u001f\\u007f]*$",
|
||||
"minLength": 1
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"HostInteractionReceipt": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "boolean"
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"required": [
|
||||
"completionToken"
|
||||
],
|
||||
"properties": {
|
||||
"completionToken": {
|
||||
"type": "string",
|
||||
"minLength": 32,
|
||||
"maxLength": 256
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
]
|
||||
},
|
||||
"HostInteractionLimits": {
|
||||
"const": {
|
||||
"textBytes": 4096,
|
||||
@@ -440,6 +999,12 @@
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/VaultHostSelectedResult"
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/VaultHostActionAccepted"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -465,13 +1030,32 @@
|
||||
"enum": [
|
||||
"cancel",
|
||||
"preview",
|
||||
"submit"
|
||||
"submit",
|
||||
"commit",
|
||||
"complete",
|
||||
"fail"
|
||||
]
|
||||
},
|
||||
"value": {
|
||||
"type": "string",
|
||||
"maxLength": 4096,
|
||||
"pattern": "^[^\\u0000]*$"
|
||||
},
|
||||
"resources": {
|
||||
"$ref": "#/$defs/VaultHostSelectedResources"
|
||||
},
|
||||
"draft": {
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/$defs/VaultHostCreateFields"
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/VaultHostEditFields"
|
||||
}
|
||||
]
|
||||
},
|
||||
"scriptsRun": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
@@ -535,11 +1119,19 @@
|
||||
"phase": {
|
||||
"enum": [
|
||||
"request",
|
||||
"preview"
|
||||
"preview",
|
||||
"commit"
|
||||
]
|
||||
},
|
||||
"request": {
|
||||
"$ref": "#/$defs/HostInteractionRequest"
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/$defs/HostInteractionRequest"
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/VaultHostActionRequest"
|
||||
}
|
||||
]
|
||||
},
|
||||
"previewText": {
|
||||
"type": "string",
|
||||
@@ -560,6 +1152,19 @@
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 160
|
||||
},
|
||||
"vaultTarget": {
|
||||
"$ref": "#/$defs/VaultHostLocalTarget"
|
||||
},
|
||||
"reviewedDraft": {
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/$defs/VaultHostCreateFields"
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/VaultHostEditFields"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
|
||||
@@ -74,6 +74,7 @@ class PluginHostInteractionService {
|
||||
this.admissionRevision = 0;
|
||||
this.focusRestoreMs = Math.min(options.focusRestoreMs ?? 1_000, 1_000);
|
||||
this.owner = null;
|
||||
this.ownerGeneration = 0;
|
||||
this.ownerCleanup = null;
|
||||
this.closed = false;
|
||||
this.revocation = this.permissionEngine.onDidRevoke((event) => this.releasePlugin(event.pluginId));
|
||||
@@ -93,17 +94,23 @@ class PluginHostInteractionService {
|
||||
metadata: { capability: "ui", mutating: true },
|
||||
validateParams: assertInteractionRequest,
|
||||
authorization: (params, context) => {
|
||||
const sender = this.#admit(params, context);
|
||||
let guard = this.admissionGuards.get(context.runtimeId);
|
||||
if (!guard) { guard = { active: true }; this.admissionGuards.set(context.runtimeId, guard); }
|
||||
const terminalTarget = params.kind === "terminal.selection.read" ? this.terminalSelection.capture(sender, context)
|
||||
: params.kind === "terminal.input.propose" ? this.terminalSelection.resolve(params.handle, context) : undefined;
|
||||
this.admissions.set(params, { sender, runtimeId: context.runtimeId, revision: this.admissionRevision, guard, terminalTarget });
|
||||
return interactionAuthorization(params, terminalTarget);
|
||||
const admission = this.admitRequest(params, context);
|
||||
return interactionAuthorization(params, admission.terminalTarget);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
admitRequest(params, context) {
|
||||
const sender = this.#admit(params, context);
|
||||
let guard = this.admissionGuards.get(context.runtimeId);
|
||||
if (!guard) { guard = { active: true }; this.admissionGuards.set(context.runtimeId, guard); }
|
||||
const terminalTarget = params.kind === "terminal.selection.read" ? this.terminalSelection.capture(sender, context)
|
||||
: params.kind === "terminal.input.propose" ? this.terminalSelection.resolve(params.handle, context) : undefined;
|
||||
const admission = { sender, runtimeId: context.runtimeId, revision: this.admissionRevision, guard, terminalTarget };
|
||||
this.admissions.set(params, admission);
|
||||
return admission;
|
||||
}
|
||||
|
||||
setReady(sender, ready) {
|
||||
const window = this.getWindow();
|
||||
if (!sender || window?.webContents !== sender || sender.isDestroyed?.()) {
|
||||
@@ -115,6 +122,7 @@ class PluginHostInteractionService {
|
||||
if (this.owner === sender) return true;
|
||||
if (this.owner) this.releaseWindow(this.owner);
|
||||
this.owner = sender;
|
||||
this.ownerGeneration++;
|
||||
const close = () => this.releaseWindow(sender);
|
||||
// A native permission dialog can temporarily blur its parent. Already
|
||||
// displayed interactions still cancel immediately; pre-prompt admission
|
||||
@@ -168,6 +176,8 @@ class PluginHostInteractionService {
|
||||
expiresAt: entry.expiresAt, phase: entry.phase, request: entry.params,
|
||||
...(entry.terminalTarget ? { terminalSessionId: entry.terminalTarget.sessionId, terminalRendererId: entry.terminalTarget.rendererId, terminalLabel: entry.terminalTarget.label } : {}),
|
||||
...(entry.previewText === undefined ? {} : { previewText: entry.previewText }),
|
||||
...(this.vaultService?.isRequest(entry.params) ? this.vaultService.describePrompt(entry) : {}),
|
||||
...(entry.reviewedDraft === undefined ? {} : { reviewedDraft: entry.reviewedDraft }),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -289,6 +299,25 @@ class PluginHostInteractionService {
|
||||
this.#audit(entry, "cancelled");
|
||||
return this.#finish(entry, { cancelled: true });
|
||||
}
|
||||
if (this.vaultService?.isRequest(entry.params)) {
|
||||
const outcome = await this.vaultService.respond(entry, response);
|
||||
await entry.context.assertActive();
|
||||
this.#assertWindow(sender);
|
||||
if (this.pending.get(entry.id) !== entry) return false;
|
||||
if (outcome.result) {
|
||||
this.#audit(entry, "completed");
|
||||
return this.#finish(entry, outcome.result);
|
||||
}
|
||||
this.#audit(entry, outcome.phase === "commit" ? "action-confirmed" : "action-claimed");
|
||||
entry.phase = outcome.phase;
|
||||
if (outcome.reviewedDraft !== undefined) entry.reviewedDraft = outcome.reviewedDraft;
|
||||
if (entry.phase === "executing") {
|
||||
entry.actionToken = this.#token(entry);
|
||||
return { completionToken: entry.actionToken };
|
||||
}
|
||||
this.#present(entry);
|
||||
return true;
|
||||
}
|
||||
if (entry.params.kind === "terminal.selection.read" && entry.phase === "request") {
|
||||
if (response.action !== "preview") throw fail("invalidArgument", "Terminal sharing requires a preview action");
|
||||
const text = assertTextBytes(response.value);
|
||||
@@ -345,6 +374,8 @@ class PluginHostInteractionService {
|
||||
if (this.owner !== sender) return;
|
||||
this.terminalSelection.releaseWindow(sender);
|
||||
this.admissionRevision++;
|
||||
this.ownerGeneration++;
|
||||
this.vaultService?.clearSelections();
|
||||
this.owner = null;
|
||||
this.ownerCleanup?.(); this.ownerCleanup = null;
|
||||
for (const entry of [...this.pending.values()]) {
|
||||
@@ -354,6 +385,7 @@ class PluginHostInteractionService {
|
||||
releasePlugin(pluginId) {
|
||||
this.terminalSelection.releasePlugin(pluginId);
|
||||
this.admissionRevision++;
|
||||
this.vaultService?.releasePlugin(pluginId);
|
||||
for (const entry of [...this.pending.values()]) {
|
||||
if (entry.context.pluginId === pluginId) this.#finish(entry, null, fail("cancelled", "Plugin access was revoked"));
|
||||
}
|
||||
@@ -362,6 +394,7 @@ class PluginHostInteractionService {
|
||||
this.terminalSelection.releaseRuntime(runtimeId);
|
||||
this.cancelRuntimeRequests(runtimeId, "Plugin runtime stopped");
|
||||
this.rates.delete(runtimeId);
|
||||
this.vaultService?.releaseRuntime(runtimeId);
|
||||
}
|
||||
cancelRuntimeRequests(runtimeId, reason = "Host command finished") {
|
||||
const guard = this.admissionGuards.get(runtimeId);
|
||||
@@ -372,7 +405,11 @@ class PluginHostInteractionService {
|
||||
}
|
||||
}
|
||||
cancelAll(invalidateAdmission = true) {
|
||||
if (invalidateAdmission) { this.admissionRevision++; this.terminalSelection.clearHandles(); }
|
||||
if (invalidateAdmission) {
|
||||
this.admissionRevision++;
|
||||
this.terminalSelection.clearHandles();
|
||||
this.vaultService?.clearSelections();
|
||||
}
|
||||
for (const entry of [...this.pending.values()]) this.#finish(entry, null, fail("cancelled", "Host interaction was cancelled"));
|
||||
}
|
||||
shutdown() {
|
||||
@@ -383,6 +420,7 @@ class PluginHostInteractionService {
|
||||
this.revocation.dispose(); this.unsubscribeLock?.();
|
||||
this.terminalInvalidation.dispose(); this.terminalSelection.shutdown();
|
||||
this.key.fill(0);
|
||||
this.vaultService?.shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -176,45 +176,73 @@ class PluginHostRpcRegistry {
|
||||
await assertCurrent?.();
|
||||
transportContext.signal?.throwIfAborted();
|
||||
};
|
||||
await assertActive();
|
||||
const validatedParams = registration.validateParams
|
||||
? registration.validateParams(params)
|
||||
: params;
|
||||
if (validatedParams && typeof validatedParams.then === "function") {
|
||||
throw new TypeError("Plugin host RPC parameter validators must be synchronous");
|
||||
}
|
||||
const authorizationContext = Object.freeze({
|
||||
...transportContext,
|
||||
...identity,
|
||||
assertActive,
|
||||
kind,
|
||||
method,
|
||||
metadata: registration.metadata,
|
||||
params: validatedParams,
|
||||
});
|
||||
const resolvedAuthorization = typeof registration.authorization === "function"
|
||||
? await registration.authorization(validatedParams, authorizationContext)
|
||||
: registration.authorization;
|
||||
const authorization = resolvedAuthorization == null
|
||||
? null
|
||||
: cloneAndFreezeMetadata(resolvedAuthorization);
|
||||
await assertActive();
|
||||
const context = Object.freeze({
|
||||
...authorizationContext,
|
||||
authorization,
|
||||
});
|
||||
let index = -1;
|
||||
const dispatch = async (nextIndex) => {
|
||||
if (nextIndex <= index) throw new Error("Plugin host RPC middleware called next() more than once");
|
||||
index = nextIndex;
|
||||
const current = middleware[nextIndex];
|
||||
if (current) return current(context, () => dispatch(nextIndex + 1));
|
||||
const startedAt = Date.now();
|
||||
let authorizationOutcome = "not-reached";
|
||||
let auditResources;
|
||||
let auditResult;
|
||||
let auditError;
|
||||
try {
|
||||
await assertActive();
|
||||
return registration.handler(validatedParams, context);
|
||||
};
|
||||
const result = await dispatch(0);
|
||||
await assertActive();
|
||||
return result;
|
||||
const validatedParams = registration.validateParams
|
||||
? registration.validateParams(params)
|
||||
: params;
|
||||
if (validatedParams && typeof validatedParams.then === "function") {
|
||||
throw new TypeError("Plugin host RPC parameter validators must be synchronous");
|
||||
}
|
||||
const authorizationContext = Object.freeze({
|
||||
...transportContext,
|
||||
...identity,
|
||||
assertActive,
|
||||
kind,
|
||||
method,
|
||||
metadata: registration.metadata,
|
||||
recordAuthorization: (outcome) => {
|
||||
if (["allowed", "denied", "not-required"].includes(outcome)) authorizationOutcome = outcome;
|
||||
},
|
||||
params: validatedParams,
|
||||
});
|
||||
const resolvedAuthorization = typeof registration.authorization === "function"
|
||||
? await registration.authorization(validatedParams, authorizationContext)
|
||||
: registration.authorization;
|
||||
const authorization = resolvedAuthorization == null
|
||||
? null
|
||||
: cloneAndFreezeMetadata(resolvedAuthorization);
|
||||
auditResources = authorization?.resources;
|
||||
await assertActive();
|
||||
const context = Object.freeze({
|
||||
...authorizationContext,
|
||||
authorization,
|
||||
});
|
||||
let index = -1;
|
||||
const dispatch = async (nextIndex) => {
|
||||
if (nextIndex <= index) throw new Error("Plugin host RPC middleware called next() more than once");
|
||||
index = nextIndex;
|
||||
const current = middleware[nextIndex];
|
||||
if (current) return current(context, () => dispatch(nextIndex + 1));
|
||||
await assertActive();
|
||||
return registration.handler(validatedParams, context);
|
||||
};
|
||||
const result = await dispatch(0);
|
||||
await assertActive();
|
||||
auditResult = result;
|
||||
return result;
|
||||
} catch (error) {
|
||||
auditError = error;
|
||||
throw error;
|
||||
} finally {
|
||||
// Never record params, handler results, error messages or plugin text.
|
||||
const dataBytes = (value) => {
|
||||
if (typeof value === "string") return Buffer.byteLength(value);
|
||||
if (Buffer.isBuffer(value) || value instanceof Uint8Array) return value.byteLength;
|
||||
return null;
|
||||
};
|
||||
const field = (value, key) => value && typeof value === "object"
|
||||
? Object.getOwnPropertyDescriptor(value, key)?.value : undefined;
|
||||
try { identity.logger?.audit?.({ operation: method, resources: auditResources,
|
||||
authorization: authorizationOutcome, result: auditError ? "error" : "ok",
|
||||
errorCode: auditError?.code, durationMs: Date.now() - startedAt,
|
||||
inputBytes: dataBytes(field(params, "data")), outputBytes: dataBytes(field(auditResult, "data")) }); } catch {}
|
||||
}
|
||||
};
|
||||
const requestHandlers = Object.freeze(Object.fromEntries(
|
||||
[...this.requests].map(([method, registration]) => [
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
"use strict";
|
||||
|
||||
const path = require("node:path");
|
||||
const { PluginLogService } = require("./pluginLogService.cjs");
|
||||
const { createPluginLogProvenanceResolver } = require("./pluginLogProvenance.cjs");
|
||||
const NETWORK_CREDENTIAL_LEASE_FEATURE = require("./generated/plugin-contract.schema.json").$defs.NetworkCredentialLeaseFeature.const;
|
||||
const VAULT_PAGES_FEATURE = require("./generated/plugin-contract.schema.json").$defs.VaultPagesFeature.const;
|
||||
const TERMINAL_SIDEBAR_FEATURE = require("./generated/plugin-contract.schema.json").$defs.TerminalSidebarFeature.const;
|
||||
@@ -31,6 +33,9 @@ const { PluginByteStreamService, FEATURE: BYTE_STREAM_FEATURE } = require("./byt
|
||||
const { PluginAIContextService, PLUGIN_AI_CONTEXT_FEATURES } = require("./ai/aiContextService.cjs");
|
||||
const { PluginAITrustedHostAdapter } = require("./ai/trustedHostAdapter.cjs");
|
||||
const { HOST_INTERACTION_FEATURE, PluginHostInteractionService } = require("./hostInteractionService.cjs");
|
||||
const { PluginHostVaultService } = require("./hostVaultService.cjs");
|
||||
const { PluginAISourceBinding } = require("./ai/sourceBinding.cjs");
|
||||
const HOST_VAULT_FEATURE = require("./generated/plugin-contract.schema.json").$defs.HostVaultFeature.const;
|
||||
const { PluginPermissionEngine } = require("./permissionEngine.cjs");
|
||||
const { PluginProtocol } = require("./pluginProtocol.cjs");
|
||||
const { PluginViewHost } = require("./pluginViewHost.cjs");
|
||||
@@ -40,6 +45,7 @@ const {
|
||||
resolveDefaultRuntimeKind,
|
||||
} = require("./runtimeSupervisor.cjs");
|
||||
const { PluginQuotaManager } = require("./quotaManager.cjs");
|
||||
const { RPC_ERRORS } = require("./rpcRouter.cjs");
|
||||
const { registerSecurePluginCapabilities } = require("./secureCapabilities.cjs");
|
||||
const { PluginSecretStore } = require("./secretStore.cjs");
|
||||
const { SecretLeaseStore } = require("./secretLease.cjs");
|
||||
@@ -93,12 +99,13 @@ function createPluginHostService(options) {
|
||||
const paths = createPluginPaths(options.app.getPath("userData"));
|
||||
const appRoot = options.appRoot ?? options.app.getAppPath();
|
||||
const runtimeDirectory = options.runtimeDirectory ?? path.join(__dirname, "runtime");
|
||||
const supportedFeatures = [...new Set(["terminal.input-drafts", "terminal.selection-sharing", "appearance.theme-packs", "metadata.brand-icon", BYTE_STREAM_FEATURE, ...PLUGIN_AI_CONTEXT_FEATURES, NETWORK_CREDENTIAL_LEASE_FEATURE, HOST_INTERACTION_FEATURE, VAULT_PAGES_FEATURE, TERMINAL_SIDEBAR_FEATURE, INTERACTIVE_COMMAND_FEATURE, ...(options.supportedFeatures ?? [])])];
|
||||
const supportedFeatures = [...new Set(["terminal.input-drafts", "terminal.selection-sharing", "appearance.theme-packs", "metadata.brand-icon", BYTE_STREAM_FEATURE, ...PLUGIN_AI_CONTEXT_FEATURES, NETWORK_CREDENTIAL_LEASE_FEATURE, HOST_INTERACTION_FEATURE, HOST_VAULT_FEATURE, VAULT_PAGES_FEATURE, TERMINAL_SIDEBAR_FEATURE, INTERACTIVE_COMMAND_FEATURE, ...(options.supportedFeatures ?? [])])];
|
||||
const database = new PluginDatabase(paths.database);
|
||||
let interactionService;
|
||||
let byteStreamService;
|
||||
let aiContextService;
|
||||
let aiHostAdapter;
|
||||
const logService = new PluginLogService({ paths, electron: options.electron, resolveProvenance: options.resolvePluginProvenance ?? createPluginLogProvenanceResolver(database) });
|
||||
try {
|
||||
const packageStore = new PackageStore({
|
||||
allowAdvancedPlugins,
|
||||
@@ -146,6 +153,8 @@ function createPluginHostService(options) {
|
||||
const filesystemBroker = new PluginFilesystemBroker({
|
||||
quotaManager,
|
||||
openDirectoryHandle: options.openPluginDirectoryHandle,
|
||||
protectedDirectories: [paths.logs],
|
||||
protectedFiles: [logService.preferencesPath, `${logService.preferencesPath}.tmp`],
|
||||
});
|
||||
const networkBroker = new PluginNetworkBroker({
|
||||
fetch: options.fetch ?? (options.electron.net?.fetch
|
||||
@@ -200,6 +209,11 @@ function createPluginHostService(options) {
|
||||
getTerminalWorkerManager: options.getTerminalWorkerManager,
|
||||
});
|
||||
interactionService.registerRpcCapabilities(rpcRegistry);
|
||||
const hostVaultService = new PluginHostVaultService({
|
||||
interactionService,
|
||||
sourceBinding: new PluginAISourceBinding({ database, getRuntimeIdentity: (pluginId) => runtimeSupervisor.getRuntimeIdentity(pluginId) }),
|
||||
});
|
||||
hostVaultService.registerRpcCapabilities(rpcRegistry);
|
||||
const configuredRegistry = options.configureRpcRegistry?.(rpcRegistry);
|
||||
if (configuredRegistry && typeof configuredRegistry.then === "function") {
|
||||
throw new TypeError("Plugin host RPC registry configuration must be synchronous");
|
||||
@@ -213,25 +227,36 @@ function createPluginHostService(options) {
|
||||
|| !context.availableKinds.includes(kind)
|
||||
) throw new Error(`Plugin runtime selection is unavailable: ${String(kind)}`);
|
||||
if (!allowAdvancedPlugins && kind !== "browser") throw new Error("Advanced plugin runtime requires developer mode");
|
||||
await permissionEngine.authorizeRequired(context.plugin, {
|
||||
securityPrincipal: context.securityPrincipal,
|
||||
signal: context.signal,
|
||||
skipPermissions: ["runtime.advanced"],
|
||||
});
|
||||
if (kind === "utility") {
|
||||
await permissionEngine.authorize({
|
||||
pluginId: context.plugin.id,
|
||||
pluginVersion: context.plugin.activeVersion,
|
||||
runtimeId: null,
|
||||
manifest: context.plugin.manifest,
|
||||
try {
|
||||
await permissionEngine.authorizeRequired(context.plugin, {
|
||||
securityPrincipal: context.securityPrincipal,
|
||||
signal: context.signal,
|
||||
}, {
|
||||
permission: "runtime.advanced",
|
||||
resources: ["*"],
|
||||
reason: "Start an advanced runtime with full Node, filesystem, and network authority",
|
||||
operationId: `runtime.advanced:${context.plugin.activeVersion}`,
|
||||
skipPermissions: ["runtime.advanced"],
|
||||
});
|
||||
if (kind === "utility") {
|
||||
await permissionEngine.authorize({
|
||||
pluginId: context.plugin.id,
|
||||
pluginVersion: context.plugin.activeVersion,
|
||||
runtimeId: null,
|
||||
manifest: context.plugin.manifest,
|
||||
securityPrincipal: context.securityPrincipal,
|
||||
signal: context.signal,
|
||||
}, {
|
||||
permission: "runtime.advanced",
|
||||
resources: ["*"],
|
||||
reason: "Start an advanced runtime with full Node, filesystem, and network authority",
|
||||
operationId: `runtime.advanced:${context.plugin.activeVersion}`,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
const identity = logService.identity({
|
||||
pluginId: context.plugin.id, pluginVersion: context.plugin.activeVersion,
|
||||
runtimeId: null, runtimeKind: kind, securityPrincipal: context.securityPrincipal,
|
||||
}, context.plugin);
|
||||
logService.audit(identity, { operation: "runtime.authorization", result: "error",
|
||||
authorization: error?.code === RPC_ERRORS.permissionDenied ? "denied" : "unknown",
|
||||
errorCode: error?.code });
|
||||
throw error;
|
||||
}
|
||||
return kind;
|
||||
};
|
||||
@@ -241,6 +266,7 @@ function createPluginHostService(options) {
|
||||
};
|
||||
runtimeSupervisor = new RuntimeSupervisor({
|
||||
electron: options.electron,
|
||||
logService,
|
||||
database,
|
||||
packageStore,
|
||||
protocol,
|
||||
@@ -285,6 +311,16 @@ function createPluginHostService(options) {
|
||||
return originalOnPluginDisabled(pluginId);
|
||||
};
|
||||
runtimeSupervisor.onDidChangeRuntime((event) => contributionService.onRuntimeStateChanged(event));
|
||||
permissionEngine.onDidRevoke((event) => {
|
||||
const plugin = database.getActivePlugin(event.pluginId);
|
||||
const identity = runtimeSupervisor.getRuntimeIdentity(event.pluginId) ?? {
|
||||
pluginId: event.pluginId, pluginVersion: plugin?.activeVersion,
|
||||
};
|
||||
logService.audit(logService.identity(identity, plugin), {
|
||||
operation: "permission.revoked", authorization: "not-required", result: "ok",
|
||||
resources: event.resource ? [event.resource] : [],
|
||||
});
|
||||
});
|
||||
const terminalProviderService = new PluginTerminalProviderService({
|
||||
contributionService,
|
||||
permissionEngine,
|
||||
@@ -409,23 +445,29 @@ function createPluginHostService(options) {
|
||||
runtimeSupervisor,
|
||||
contributionService,
|
||||
beforeClose: async () => {
|
||||
themeService.shutdown();
|
||||
interactionService.shutdown();
|
||||
byteStreamService.shutdown();
|
||||
aiContextService.shutdown();
|
||||
terminalDataPipelineService?.shutdown();
|
||||
extensionProviderService.shutdown();
|
||||
credentialResolver.shutdown?.();
|
||||
await viewHost?.shutdown();
|
||||
await companionSupervisor.shutdown();
|
||||
leaseStore.shutdown();
|
||||
permissionEngine.shutdown();
|
||||
quotaManager.shutdown();
|
||||
try {
|
||||
themeService.shutdown();
|
||||
interactionService.shutdown();
|
||||
byteStreamService.shutdown();
|
||||
aiContextService.shutdown();
|
||||
terminalDataPipelineService?.shutdown();
|
||||
extensionProviderService.shutdown();
|
||||
credentialResolver.shutdown?.();
|
||||
await viewHost?.shutdown();
|
||||
await companionSupervisor.shutdown();
|
||||
leaseStore.shutdown();
|
||||
permissionEngine.shutdown();
|
||||
quotaManager.shutdown();
|
||||
} finally {
|
||||
await logService.close();
|
||||
}
|
||||
},
|
||||
});
|
||||
return {
|
||||
logService,
|
||||
themeService,
|
||||
interactionService,
|
||||
hostVaultService,
|
||||
catalogService: new MarketSourcesService({ database, manager, fetch: options.electron.net?.fetch?.bind(options.electron.net) }),
|
||||
companionSupervisor,
|
||||
contributionIconService,
|
||||
@@ -459,6 +501,7 @@ function createPluginHostService(options) {
|
||||
try { byteStreamService?.shutdown(); } catch {}
|
||||
try { aiContextService?.shutdown(); } catch {}
|
||||
if (!aiContextService) { try { aiHostAdapter?.shutdown?.(); } catch {} }
|
||||
void logService.close();
|
||||
database.close();
|
||||
throw error;
|
||||
}
|
||||
|
||||
@@ -401,3 +401,83 @@ test("normal release blocks a custom utility placement before permission prompts
|
||||
}), /requires developer mode/);
|
||||
assert.equal(prompts, 0);
|
||||
});
|
||||
|
||||
test("host service logs revocation with injected provenance and drains through manager shutdown", async (t) => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-synthetic-log-host-"));
|
||||
let seen;
|
||||
const service = createPluginHostService({ ...createOptions(root), resolvePluginProvenance(input) {
|
||||
seen = input;
|
||||
return { sourceId: "synthetic-host-source", namespace: "test", sourceKind: "local" };
|
||||
} });
|
||||
t.after(async () => { await service.manager.shutdown(); fs.rmSync(root, { recursive: true, force: true }); });
|
||||
const manifest = { manifestVersion: 1, id: "com.example.synthetic-audit", version: "1.0.0", name: "Synthetic", publisher: "example", engines: { netcatty: ">=0.0.0", api: ">=0.1.0-internal <0.2.0" }, main: { browser: "index.js" } };
|
||||
service.database.installVersion({ pluginId: manifest.id, version: manifest.version, manifest, archiveSha256: "b".repeat(64), packageRelativePath: `${manifest.id}/${manifest.version}/package` }, { enable: true });
|
||||
await service.logService.ready;
|
||||
assert.equal(service.logService.status().saveToDisk, false);
|
||||
await service.logService.configure({ saveToDisk: true, verbose: false });
|
||||
service.permissionEngine.revokeAll(manifest.id);
|
||||
await service.logService.sink.flush();
|
||||
const record = JSON.parse(await fsp.readFile(service.logService.sink.file(0), "utf8"));
|
||||
assert.equal(record.operation, "permission.revoked");
|
||||
assert.equal(record.sourceId, "synthetic-host-source");
|
||||
assert.equal(record.archiveSha256, "b".repeat(64));
|
||||
assert.equal(record.pluginVersion, "1.0.0");
|
||||
assert.equal(record.runtimeId, null);
|
||||
assert.equal(seen.pluginId, manifest.id);
|
||||
assert.equal(seen.archiveSha256, "b".repeat(64));
|
||||
await service.manager.shutdown();
|
||||
assert.equal(service.logService.sink.closed, true);
|
||||
});
|
||||
|
||||
test("logging always drains before database close when another shutdown stage rejects", async (t) => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-synthetic-log-shutdown-failure-"));
|
||||
const host = createPluginHostService(createOptions(root));
|
||||
t.after(async () => { await host.manager.shutdown().catch(() => {}); fs.rmSync(root, { recursive: true, force: true }); });
|
||||
await host.logService.ready;
|
||||
await host.logService.configure({ saveToDisk: true, verbose: false });
|
||||
let resume;
|
||||
const gate = new Promise((resolve) => { resume = resolve; });
|
||||
host.logService.sink.queue = host.logService.sink.queue.then(() => gate);
|
||||
const logger = host.logService.createLogger({ pluginId: "com.example.synthetic", pluginVersion: "1.0.0", runtimeId: "host-shutdown", runtimeKind: "browser" }, {});
|
||||
logger.audit({ operation: "synthetic.before-shutdown", result: "ok" });
|
||||
host.companionSupervisor.shutdown = async () => { throw new Error("synthetic companion shutdown failure"); };
|
||||
let settled = false;
|
||||
const shutdown = host.manager.shutdown().finally(() => { settled = true; });
|
||||
// Attach the rejection assertion before yielding so the expected failure is handled.
|
||||
const rejected = assert.rejects(shutdown, /synthetic companion shutdown failure/);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(settled, false);
|
||||
assert.doesNotThrow(() => host.database.db.prepare("SELECT 1").get());
|
||||
resume();
|
||||
await rejected;
|
||||
assert.equal(host.logService.sink.closed, true);
|
||||
assert.equal(host.logService.retentionTimer._destroyed, true);
|
||||
const record = JSON.parse(await fsp.readFile(host.logService.sink.file(0), "utf8"));
|
||||
assert.equal(record.operation, "synthetic.before-shutdown");
|
||||
});
|
||||
|
||||
test("activation permission denial is audited before a runtime logger exists", async (t) => {
|
||||
const { defaultSecurityPrincipal } = require("./permissionResources.cjs");
|
||||
const { RPC_ERRORS } = require("./rpcRouter.cjs");
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-synthetic-activation-log-"));
|
||||
const host = createPluginHostService(createOptions(root));
|
||||
t.after(async () => { await host.manager.shutdown(); fs.rmSync(root, { recursive: true, force: true }); });
|
||||
const manifest = { manifestVersion: 1, id: "com.example.synthetic-activation", version: "1.0.0", name: "Synthetic", publisher: "example", engines: { netcatty: ">=0.0.0", api: ">=0.1.0-internal <0.2.0" }, main: { browser: "index.js" }, permissions: { required: ["commands"] } };
|
||||
const archiveSha256 = "a".repeat(64);
|
||||
host.database.installVersion({ pluginId: manifest.id, version: manifest.version, manifest, archiveSha256, packageRelativePath: `${manifest.id}/${manifest.version}/package` }, { enable: true });
|
||||
await host.logService.ready;
|
||||
await host.logService.configure({ saveToDisk: true, verbose: false });
|
||||
await assert.rejects(host.runtimeSupervisor.resolveRuntimeKind({
|
||||
plugin: host.database.getActivePlugin(manifest.id), availableKinds: ["browser"], signal: new AbortController().signal,
|
||||
securityPrincipal: defaultSecurityPrincipal(manifest, archiveSha256),
|
||||
}), { code: RPC_ERRORS.permissionDenied });
|
||||
await host.logService.sink.flush();
|
||||
const record = JSON.parse(await fsp.readFile(host.logService.sink.file(0), "utf8"));
|
||||
assert.equal(record.operation, "runtime.authorization");
|
||||
assert.equal(record.authorization, "denied");
|
||||
assert.equal(record.runtimeId, null);
|
||||
assert.equal(record.errorCode, RPC_ERRORS.permissionDenied);
|
||||
assert.equal(record.archiveSha256, archiveSha256);
|
||||
assert.equal(Object.hasOwn(record, "message"), false);
|
||||
assert.equal(host.runtimeSupervisor.getRuntimeIdentity(manifest.id), null);
|
||||
});
|
||||
@@ -0,0 +1,153 @@
|
||||
"use strict";
|
||||
|
||||
const { createHmac, randomBytes } = require("node:crypto");
|
||||
const { createDefinitionValidator } = require("./contractValidator.cjs");
|
||||
const { PluginRpcError, RPC_ERRORS } = require("./rpcRouter.cjs");
|
||||
const fail = (code, message) => new PluginRpcError(RPC_ERRORS[code], message);
|
||||
const METHODS = ["vault.selectHosts", "vault.requestHostDraft", "vault.requestConnect"];
|
||||
const MAX_SELECTED = 50;
|
||||
const MAX_REFERENCES = 500;
|
||||
const TARGET_LIFETIME_MS = 5 * 60_000;
|
||||
|
||||
/** Typed host selection only. Selection references identify a reviewed target;
|
||||
* the interaction service owns separate operation-specific commit authority. */
|
||||
class PluginHostVaultService {
|
||||
constructor({ sourceBinding, interactionService, clock = Date.now }) {
|
||||
this.sourceBinding = sourceBinding;
|
||||
this.interactionService = interactionService;
|
||||
this.clock = clock;
|
||||
this.key = randomBytes(32);
|
||||
this.targets = new Map();
|
||||
this.admissions = new WeakMap();
|
||||
this.validators = Object.fromEntries([
|
||||
"VaultSelectHostsParams", "VaultHostDraftParams", "VaultHostConnectParams",
|
||||
"VaultHostSelectedResources", "VaultHostCreateFields", "VaultHostEditFields",
|
||||
].map((name) => [name, createDefinitionValidator(name)]));
|
||||
interactionService.vaultService = this;
|
||||
}
|
||||
|
||||
isRequest(params) { return METHODS.includes(params?.kind); }
|
||||
|
||||
#validate(name, value) {
|
||||
if (!this.validators[name](value)) throw fail("invalidArgument", "Host action parameters are invalid");
|
||||
if (Buffer.byteLength(JSON.stringify(value)) > 65_536) throw fail("resourceExhausted", "Host action exceeds its size limit");
|
||||
return structuredClone(value);
|
||||
}
|
||||
|
||||
registerRpcCapabilities(registry) {
|
||||
const definitions = ["VaultSelectHostsParams", "VaultHostDraftParams", "VaultHostConnectParams"];
|
||||
for (const [index, method] of METHODS.entries()) {
|
||||
registry.registerRequest(method, (params, context) => this.request(params, context), {
|
||||
metadata: { capability: "vault", mutating: method !== "vault.selectHosts" },
|
||||
validateParams: (params) => Object.freeze({ ...this.#validate(definitions[index], params), kind: method }),
|
||||
authorization: async (params, context) => {
|
||||
const binding = await this.sourceBinding.resolve(context);
|
||||
if (params.target) this.#resolveTarget(params.target, params.expectedRevision, binding);
|
||||
this.interactionService.admitRequest(params, context);
|
||||
this.admissions.set(params, binding);
|
||||
return {
|
||||
permission: method === "vault.requestHostDraft" ? "vault.write" : method === "vault.selectHosts" ? "vault.metadata" : "ui.interact",
|
||||
resources: ["*"], allowedScopes: ["once"],
|
||||
reason: method === "vault.selectHosts" ? "Choose the current host metadata to share" : method === "vault.requestHostDraft" ? "Review a host draft before saving" : "Review a saved host connection and its script disclosure",
|
||||
operationId: method,
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async request(params, originalContext) {
|
||||
const binding = await this.sourceBinding.resolve(originalContext);
|
||||
if (this.admissions.get(params) !== binding) throw fail("permissionDenied", "Host action admission is unavailable");
|
||||
this.admissions.delete(params);
|
||||
const assertActive = async () => {
|
||||
if (await this.sourceBinding.resolve(originalContext) !== binding) throw fail("permissionDenied", "Host action source changed");
|
||||
if (params.target) this.#resolveTarget(params.target, params.expectedRevision, binding);
|
||||
};
|
||||
await assertActive();
|
||||
return this.interactionService.request(params, Object.freeze({ ...originalContext, assertActive, vaultBinding: binding }));
|
||||
}
|
||||
|
||||
#resolveTarget(ref, expectedRevision, binding) {
|
||||
const entry = this.targets.get(ref?.handle);
|
||||
if (!entry || ref.kind !== "host.selection" || ref.scope !== "metadata" || entry.binding !== binding
|
||||
|| ref.revision !== entry.revision || expectedRevision !== entry.revision || ref.expiresAt !== entry.expiresAt
|
||||
|| this.clock() >= entry.expiresAt || entry.owner !== this.interactionService.owner
|
||||
|| entry.generation !== this.interactionService.ownerGeneration) {
|
||||
throw fail("permissionDenied", "Host selection expired or changed; select the host again");
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
|
||||
#issueTarget(resource, context) {
|
||||
const entry = {
|
||||
binding: context.vaultBinding, resourceId: resource.resourceId, revision: resource.revision,
|
||||
owner: this.interactionService.owner, generation: this.interactionService.ownerGeneration,
|
||||
expiresAt: this.clock() + TARGET_LIFETIME_MS,
|
||||
};
|
||||
const nonce = randomBytes(24).toString("base64url");
|
||||
const signature = createHmac("sha256", this.key).update(JSON.stringify([
|
||||
nonce, entry.binding, entry.owner.id, entry.generation, entry.resourceId,
|
||||
entry.revision, entry.expiresAt, "host.selection", "metadata",
|
||||
])).digest("base64url");
|
||||
const handle = `${nonce}.${signature}`;
|
||||
this.targets.set(handle, entry);
|
||||
return { kind: "host.selection", scope: "metadata", handle, revision: entry.revision, expiresAt: entry.expiresAt };
|
||||
}
|
||||
|
||||
describePrompt(entry) {
|
||||
if (!entry.params.target) return {};
|
||||
const target = this.#resolveTarget(entry.params.target, entry.params.expectedRevision, entry.context.vaultBinding);
|
||||
return { vaultTarget: { resourceId: target.resourceId, revision: target.revision } };
|
||||
}
|
||||
|
||||
async respond(entry, response) {
|
||||
await entry.context.assertActive();
|
||||
if (response.action === "fail") throw fail("cancelled", "Host resource changed or the action could not complete");
|
||||
if (entry.phase === "request" && response.action === "submit") {
|
||||
if (entry.params.kind === "vault.selectHosts") {
|
||||
const resources = this.#validate("VaultHostSelectedResources", response.resources);
|
||||
if (resources.length > MAX_SELECTED || new Set(resources.map((resource) => resource.resourceId)).size !== resources.length) {
|
||||
throw fail("invalidArgument", "Host selection is invalid");
|
||||
}
|
||||
this.releaseSelections(entry.context.vaultBinding);
|
||||
this.pruneExpired();
|
||||
if (this.targets.size + resources.length > MAX_REFERENCES) throw fail("resourceExhausted", "Too many host selections");
|
||||
const hosts = resources.map(({ resourceId, revision, ...metadata }) => ({
|
||||
target: this.#issueTarget({ resourceId, revision }, entry.context), metadata,
|
||||
}));
|
||||
const groups = [...new Set(hosts.map((host) => host.metadata.group).filter(Boolean))].map((path) => ({
|
||||
path, members: hosts.filter((host) => host.metadata.group === path).map((host) => host.target),
|
||||
}));
|
||||
return { result: { cancelled: false, hosts, groups } };
|
||||
}
|
||||
if (entry.params.kind === "vault.requestHostDraft") {
|
||||
const draft = this.#validate(entry.params.target ? "VaultHostEditFields" : "VaultHostCreateFields", response.draft);
|
||||
return { phase: "commit", reviewedDraft: draft };
|
||||
}
|
||||
if (typeof response.scriptsRun !== "boolean") throw fail("invalidArgument", "Connection script disclosure is required");
|
||||
return { phase: "commit" };
|
||||
}
|
||||
if (entry.phase === "commit" && response.action === "commit") return { phase: "executing" };
|
||||
if (entry.phase === "executing" && response.action === "complete") {
|
||||
return { result: { cancelled: false, status: entry.params.kind === "vault.requestConnect" ? "requested" : "saved" } };
|
||||
}
|
||||
throw fail("invalidArgument", "Host action phase is invalid");
|
||||
}
|
||||
|
||||
pruneExpired() { for (const [handle, entry] of this.targets) if (this.clock() >= entry.expiresAt) this.targets.delete(handle); }
|
||||
releaseSelections(binding) { for (const [handle, entry] of this.targets) if (entry.binding === binding) this.targets.delete(handle); }
|
||||
releaseRuntime(runtimeId) {
|
||||
for (const [handle, entry] of this.targets) if (entry.binding.runtimeId === runtimeId) this.targets.delete(handle);
|
||||
this.sourceBinding.releaseRuntime(runtimeId);
|
||||
}
|
||||
releasePlugin(pluginId) {
|
||||
for (const [handle, entry] of this.targets) if (entry.binding.pluginId === pluginId) this.targets.delete(handle);
|
||||
// Keep the source helper's pinned runtime binding until runtime stop. A
|
||||
// revoked old runtime must never rebind itself to a replacement source.
|
||||
}
|
||||
clearSelections() { this.targets.clear(); }
|
||||
shutdown() { this.targets.clear(); this.sourceBinding.shutdown(); this.key.fill(0); }
|
||||
}
|
||||
|
||||
module.exports = { PluginHostVaultService, METHODS, MAX_SELECTED, TARGET_LIFETIME_MS };
|
||||
@@ -0,0 +1,34 @@
|
||||
"use strict";
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs/promises");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { pathToFileURL } = require("node:url");
|
||||
const test = require("node:test");
|
||||
|
||||
test("native logging fixture packs reproducibly and exposes only synthetic commands", async (t) => {
|
||||
const root = path.join(__dirname, "fixtures", "local-logging-plugin");
|
||||
const output = await fs.mkdtemp(path.join(os.tmpdir(), "netcatty-synthetic-logging-package-"));
|
||||
t.after(() => fs.rm(output, { recursive: true, force: true }));
|
||||
const { buildPluginPackage } = await import("@netcatty/plugin-cli");
|
||||
await buildPluginPackage(root, path.join(output, "one.ncpkg"));
|
||||
await buildPluginPackage(root, path.join(output, "two.ncpkg"));
|
||||
assert.deepEqual(await fs.readFile(path.join(output, "one.ncpkg")), await fs.readFile(path.join(output, "two.ncpkg")));
|
||||
const manifest = JSON.parse(await fs.readFile(path.join(root, "netcatty.plugin.json"), "utf8"));
|
||||
assert.deepEqual(manifest.permissions, { required: ["commands", "menus"], optional: [] });
|
||||
const plugin = (await import(pathToFileURL(path.join(root, "index.js")).href)).default;
|
||||
const commands = new Map();
|
||||
const logs = [];
|
||||
let deniedRequests = 0;
|
||||
plugin.activate({
|
||||
subscriptions: { add() {} },
|
||||
commands: { registerCommand(id, handler) { commands.set(id, handler); } },
|
||||
logger: { info(...args) { logs.push(args); } },
|
||||
network: { async request(request) { assert.equal(request.method, "GET"); assert.equal(request.body, undefined); deniedRequests++; throw Object.assign(new Error("synthetic permission denial"), { code: "permission_denied" }); } },
|
||||
});
|
||||
assert.deepEqual(await commands.get(`${manifest.id}.emit`)(), { ok: true, synthetic: true });
|
||||
assert.match(logs[0][0], /Synthetic/);
|
||||
assert.equal(logs[0][1].sourceId, "official", "spoofed field is deliberately nested plugin text, never host provenance");
|
||||
assert.deepEqual(await commands.get(`${manifest.id}.denied`)(), { denied: true, synthetic: true });
|
||||
assert.equal(deniedRequests, 1);
|
||||
});
|
||||
@@ -639,11 +639,21 @@ class PluginPermissionEngine {
|
||||
|
||||
createMiddleware() {
|
||||
return async (context, next) => {
|
||||
if (context.metadata.public === true) return next();
|
||||
if (context.metadata.public === true) {
|
||||
context.recordAuthorization?.("not-required");
|
||||
return next();
|
||||
}
|
||||
if (!context.authorization) {
|
||||
context.recordAuthorization?.("denied");
|
||||
throw permissionDenied(`Plugin host method has no authorization policy: ${context.method}`);
|
||||
}
|
||||
await this.authorize(context, context.authorization);
|
||||
try {
|
||||
await this.authorize(context, context.authorization);
|
||||
} catch (error) {
|
||||
if (error?.code === RPC_ERRORS.permissionDenied) context.recordAuthorization?.("denied");
|
||||
throw error;
|
||||
}
|
||||
context.recordAuthorization?.("allowed");
|
||||
return next();
|
||||
};
|
||||
}
|
||||
|
||||
@@ -20,6 +20,11 @@ const CHANNELS = Object.freeze({
|
||||
themeApply: "netcatty:plugins:theme-apply",
|
||||
themesChanged: "netcatty:plugins:themes-changed",
|
||||
status: "netcatty:plugins:status",
|
||||
logStatus: "netcatty:plugins:log-status",
|
||||
logConfigure: "netcatty:plugins:log-configure",
|
||||
logOpen: "netcatty:plugins:log-open",
|
||||
logExport: "netcatty:plugins:log-export",
|
||||
logClear: "netcatty:plugins:log-clear",
|
||||
terminalTarget: "netcatty:plugins:terminal-target",
|
||||
interactionReady: "netcatty:plugins:interaction-ready",
|
||||
interactionResponse: "netcatty:plugins:interaction-response",
|
||||
@@ -697,6 +702,14 @@ function registerPluginBridge(ipcMain, options) {
|
||||
}
|
||||
return id;
|
||||
};
|
||||
for (const [channel, action] of [
|
||||
[CHANNELS.logStatus, "status"], [CHANNELS.logConfigure, "configure"],
|
||||
[CHANNELS.logOpen, "openFolder"], [CHANNELS.logExport, "exportLogs"], [CHANNELS.logClear, "clear"],
|
||||
]) handle(channel, async (_manager, payload) => {
|
||||
if (!options.logService) throw new Error("Local plugin logging is unavailable");
|
||||
await options.logService.ready;
|
||||
return options.logService[action](payload);
|
||||
});
|
||||
handle(CHANNELS.permissions, async (_manager, payload) => {
|
||||
const pluginId = assertManagedPluginId(payload);
|
||||
if (!options.permissionEngine || !options.database) throw new Error("Plugin permissions are unavailable");
|
||||
|
||||
@@ -1721,3 +1721,17 @@ test('theme catalog and selection remain trusted app management channels',async(
|
||||
for(const channel of [CHANNELS.themeCatalog,CHANNELS.themeApply])await assert.rejects(ipc.handlers.get(channel)({trusted:false},{}),/Untrusted/);
|
||||
assert.equal(calls,0);await ipc.handlers.get(CHANNELS.themeCatalog)({trusted:true});await ipc.handlers.get(CHANNELS.themeApply)({trusted:true},{});assert.equal(calls,2);
|
||||
});
|
||||
|
||||
test("local log management rejects plugin/untrusted senders before touching the sink", async () => {
|
||||
const ipcMain = createIpcMain();
|
||||
const actions = [];
|
||||
const logService = { ready: Promise.resolve() };
|
||||
for (const action of ["status", "configure", "openFolder", "exportLogs", "clear"]) logService[action] = async () => { actions.push(action); return { saveToDisk: false }; };
|
||||
registerPluginBridge(ipcMain, { manager: { async initialize() {} }, logService, isTrustedSender: (event) => event?.trusted === true });
|
||||
for (const channel of [CHANNELS.logStatus, CHANNELS.logConfigure, CHANNELS.logOpen, CHANNELS.logExport, CHANNELS.logClear]) {
|
||||
await assert.rejects(ipcMain.handlers.get(channel)({}, {}), /Untrusted/);
|
||||
assert.equal(actions.length, 0);
|
||||
}
|
||||
for (const channel of [CHANNELS.logStatus, CHANNELS.logConfigure, CHANNELS.logOpen, CHANNELS.logExport, CHANNELS.logClear]) await ipcMain.handlers.get(channel)({ trusted: true }, {});
|
||||
assert.deepEqual(actions, ["status", "configure", "openFolder", "exportLogs", "clear"]);
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
"use strict";
|
||||
|
||||
/**
|
||||
* Host-only adapter. Call with the host-assigned activation identity after the
|
||||
* caller guard; never pass plugin request params, manifest source claims or an
|
||||
* owner supplied by a plugin. Missing source APIs mean unknown provenance.
|
||||
*/
|
||||
function createPluginLogProvenanceResolver(database) {
|
||||
const methods = ["getActivePlugin", "getRetainedPluginSource", "privateOwner", "publicPluginId"];
|
||||
const resolve = (identity) => {
|
||||
if (!methods.every((method) => typeof database?.[method] === "function")) return null;
|
||||
const plugin = database.getActivePlugin(identity.pluginId);
|
||||
if (!plugin || plugin.id !== identity.pluginId
|
||||
|| plugin.activeVersion !== identity.pluginVersion
|
||||
|| plugin.archiveSha256 !== identity.archiveSha256) return null;
|
||||
const source = plugin.source;
|
||||
const retained = database.getRetainedPluginSource(identity.pluginId);
|
||||
if (!source || !retained || source.id !== retained.id || source.kind !== retained.kind) return null;
|
||||
const owner = database.privateOwner(identity.pluginId);
|
||||
if (typeof owner !== "string" || !owner || owner === identity.pluginId || database.publicPluginId(owner) !== identity.pluginId) return null;
|
||||
return { sourceId: source.id, sourceKind: source.kind, namespace: owner };
|
||||
};
|
||||
return (identity) => {
|
||||
const initial = resolve(identity);
|
||||
if (!initial) return null;
|
||||
return Object.freeze({ ...initial, isCurrent: () => {
|
||||
try {
|
||||
const current = resolve(identity);
|
||||
return current !== null && current.sourceId === initial.sourceId
|
||||
&& current.sourceKind === initial.sourceKind && current.namespace === initial.namespace;
|
||||
} catch { return false; }
|
||||
} });
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { createPluginLogProvenanceResolver };
|
||||
@@ -0,0 +1,70 @@
|
||||
"use strict";
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs/promises");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const test = require("node:test");
|
||||
const { PluginDatabase } = require("./database.cjs");
|
||||
const { createPluginHostService } = require("./hostService.cjs");
|
||||
|
||||
test("real source database replacement never reattributes old runtime log records", {
|
||||
skip: typeof PluginDatabase.prototype.getRetainedPluginSource !== "function" && "requires the source API from PR #3981; run on the documented integration worktree",
|
||||
}, async (t) => {
|
||||
const { thirdPartySource, OFFICIAL_SOURCE } = require("./sourceIdentity.cjs");
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "netcatty-synthetic-source-integration-"));
|
||||
const host = createPluginHostService({
|
||||
app: { getPath: () => root, getAppPath: () => process.cwd(), getVersion: () => "0.0.0" },
|
||||
electron: {}, env: {},
|
||||
});
|
||||
const database = host.database;
|
||||
const pluginId = "com.example.source-log-fixture";
|
||||
// The current source contract encodes every namespace, including local.
|
||||
const { LOCAL_SOURCE } = require("./sourceIdentity.cjs");
|
||||
database.installVersion({ pluginId: "com.example.local-source-fixture", version: "1.0.0", manifest: {}, archiveSha256: "b".repeat(64), packageRelativePath: "com.example.local-source-fixture/1.0.0", source: LOCAL_SOURCE });
|
||||
const localOwner = database.privateOwner("com.example.local-source-fixture");
|
||||
assert.notEqual(localOwner, "com.example.local-source-fixture");
|
||||
assert.equal(database.publicPluginId(localOwner), "com.example.local-source-fixture");
|
||||
|
||||
const first = thirdPartySource("Synthetic first source", "https://synthetic-first.example.com");
|
||||
const install = (source) => database.installVersion({ pluginId, version: "1.0.0", manifest: {}, archiveSha256: "a".repeat(64), packageRelativePath: `${pluginId}/1.0.0`, source }, { enable: true });
|
||||
install(first);
|
||||
const service = host.logService;
|
||||
t.after(async () => { await host.manager.shutdown().catch(() => {}); await fs.rm(root, { recursive: true, force: true }); });
|
||||
await service.ready;
|
||||
await service.configure({ saveToDisk: true, verbose: true });
|
||||
const activation = { pluginId, pluginVersion: "1.0.0", runtimeId: "source-fixture-A", runtimeKind: "browser" };
|
||||
const firstLogger = service.createLogger(activation, database.getActivePlugin(pluginId));
|
||||
firstLogger.audit({ operation: "runtime.running", result: "ok", authorization: "not-required" });
|
||||
await service.sink.flush();
|
||||
const oldOwner = database.privateOwner(pluginId);
|
||||
let resume;
|
||||
const queueGate = new Promise((resolve) => { resume = resolve; });
|
||||
service.sink.queue = service.sink.queue.then(() => queueGate);
|
||||
await firstLogger.write("info", "synthetic queued before replacement");
|
||||
database.removePlugin(pluginId);
|
||||
assert.equal(database.getRetainedPluginSource(pluginId).id, first.id);
|
||||
database.clearUninstalledSourceData(pluginId, first.id);
|
||||
install(OFFICIAL_SOURCE);
|
||||
assert.equal(database.publicPluginId(oldOwner), null);
|
||||
resume();
|
||||
await service.sink.flush();
|
||||
assert.equal(service.status().droppedRecords, 1);
|
||||
const replacement = service.createLogger({ ...activation, runtimeId: "source-fixture-B" }, database.getActivePlugin(pluginId));
|
||||
replacement.audit({ operation: "runtime.running", result: "ok", authorization: "not-required" });
|
||||
await service.sink.flush();
|
||||
const records = (await fs.readFile(service.sink.file(0), "utf8")).trim().split("\n").map(JSON.parse);
|
||||
assert.equal(records.length, 2);
|
||||
assert.equal(records[0].sourceId, first.id);
|
||||
assert.equal(records[0].namespace, oldOwner);
|
||||
assert.equal(records[1].sourceId, "official");
|
||||
assert.equal(records[1].namespace, database.privateOwner(pluginId));
|
||||
assert.equal(Object.hasOwn(records[1], "officiallyMaintained"), false);
|
||||
assert.doesNotMatch(JSON.stringify(records), /queued before replacement|synthetic-first.example.com/);
|
||||
replacement.audit({ operation: "synthetic.before-shutdown", result: "ok" });
|
||||
host.companionSupervisor.shutdown = async () => { throw new Error("synthetic companion shutdown failure"); };
|
||||
await assert.rejects(host.manager.shutdown(), /synthetic companion shutdown failure/);
|
||||
assert.equal(service.sink.closed, true);
|
||||
const drained = (await fs.readFile(service.sink.file(0), "utf8")).trim().split("\n").map(JSON.parse);
|
||||
assert.equal(drained.at(-1).operation, "synthetic.before-shutdown");
|
||||
assert.equal(drained.at(-1).sourceId, "official");
|
||||
});
|
||||
@@ -0,0 +1,149 @@
|
||||
"use strict";
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs/promises");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const test = require("node:test");
|
||||
const { createPluginLogProvenanceResolver } = require("./pluginLogProvenance.cjs");
|
||||
const { PluginLogService } = require("./pluginLogService.cjs");
|
||||
|
||||
function sourceFixture(pluginId = "com.example.synthetic-source-logging") {
|
||||
const identity = { pluginId, pluginVersion: "1.0.0", archiveSha256: "a".repeat(64), runtimeId: "host-runtime-A", runtimeKind: "browser" };
|
||||
let source = { id: `third-party:${"b".repeat(64)}`, kind: "third-party", name: "Synthetic source", origin: "https://synthetic.example.com" };
|
||||
let active = { id: pluginId, activeVersion: identity.pluginVersion, archiveSha256: identity.archiveSha256, source };
|
||||
let retained = source;
|
||||
const owner = () => source ? `source:${source.id === "official" ? "c".repeat(64) : "d".repeat(64)}:${pluginId}` : null;
|
||||
const database = {
|
||||
getActivePlugin(id) { assert.equal(id, pluginId); return active; },
|
||||
getRetainedPluginSource(id) { assert.equal(id, pluginId); return retained; },
|
||||
privateOwner(id) { assert.equal(id, pluginId); return owner(); },
|
||||
publicPluginId(encoded) { return encoded === owner() ? pluginId : null; },
|
||||
};
|
||||
return { identity, database, plugin: () => active, setActive(value) { active = value; },
|
||||
setRetained(value) { retained = value; },
|
||||
replace() { source = { id: "official", kind: "official", name: "Not a maintenance claim", origin: "https://plugins.netcatty.app" }; active = { ...active, source }; retained = source; },
|
||||
uninstall() { active = null; },
|
||||
};
|
||||
}
|
||||
|
||||
test("host provenance uses the bound owner and ignores plugin-supplied source metadata", () => {
|
||||
const f = sourceFixture();
|
||||
const resolver = createPluginLogProvenanceResolver(f.database);
|
||||
const result = resolver({ ...f.identity, sourceId: "official", source: { id: "official" }, owner: "spoof" });
|
||||
assert.equal(result.sourceId, f.plugin().source.id);
|
||||
assert.equal(result.sourceKind, "third-party");
|
||||
assert.equal(result.namespace, f.database.privateOwner(f.identity.pluginId));
|
||||
assert.equal(result.isCurrent(), true);
|
||||
assert.equal(Object.hasOwn(result, "officiallyMaintained"), false);
|
||||
assert.equal(Object.hasOwn(result, "origin"), false);
|
||||
f.replace();
|
||||
assert.equal(result.isCurrent(), false);
|
||||
assert.equal(resolver(f.identity).sourceId, "official");
|
||||
});
|
||||
|
||||
test("missing source APIs, retained mismatch, orphaned owner and changed package remain unknown", () => {
|
||||
const f = sourceFixture();
|
||||
const resolve = createPluginLogProvenanceResolver(f.database);
|
||||
assert.equal(createPluginLogProvenanceResolver({ getActivePlugin: f.database.getActivePlugin })(f.identity), null);
|
||||
assert.equal(resolve({ ...f.identity, pluginVersion: "2.0.0" }), null);
|
||||
assert.equal(resolve({ ...f.identity, archiveSha256: "c".repeat(64) }), null);
|
||||
f.database.privateOwner = () => null;
|
||||
assert.equal(resolve(f.identity), null, "no bare pluginId fallback");
|
||||
f.database.privateOwner = () => f.identity.pluginId;
|
||||
f.database.publicPluginId = (owner) => owner;
|
||||
assert.equal(resolve(f.identity), null, "a bare owner is not an authenticated namespace");
|
||||
f.database.privateOwner = () => "source:orphan";
|
||||
assert.equal(resolve(f.identity), null);
|
||||
const g = sourceFixture();
|
||||
g.setRetained({ id: "different", kind: "third-party" });
|
||||
assert.equal(createPluginLogProvenanceResolver(g.database)(g.identity), null);
|
||||
const h = sourceFixture();
|
||||
const snapshot = createPluginLogProvenanceResolver(h.database)(h.identity);
|
||||
h.uninstall();
|
||||
assert.equal(snapshot.isCurrent(), false);
|
||||
assert.equal(createPluginLogProvenanceResolver(h.database)(h.identity), null, "retained source alone cannot authenticate an installed activation");
|
||||
});
|
||||
|
||||
test("queued diagnostics and host facts are revalidated after async I/O before commit", async (t) => {
|
||||
const f = sourceFixture(`com.${"a".repeat(120)}`);
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "netcatty-synthetic-source-log-"));
|
||||
const service = new PluginLogService({ paths: { root, logs: path.join(root, "logs") }, resolveProvenance: createPluginLogProvenanceResolver(f.database) });
|
||||
t.after(async () => { await service.close(); await fs.rm(root, { recursive: true, force: true }); });
|
||||
await service.ready;
|
||||
assert.equal(service.status().saveToDisk, false);
|
||||
await service.configure({ saveToDisk: true, verbose: true });
|
||||
const loggerA = service.createLogger(f.identity, f.plugin());
|
||||
loggerA.audit({ operation: "runtime.running", authorization: "not-required", result: "ok" });
|
||||
await service.sink.flush();
|
||||
let release;
|
||||
let entered;
|
||||
const paused = new Promise((resolve) => { entered = resolve; });
|
||||
const gate = new Promise((resolve) => { release = resolve; });
|
||||
const prune = service.sink.prune.bind(service.sink);
|
||||
service.sink.prune = async () => { entered(); await gate; return prune(); };
|
||||
loggerA.audit({ operation: "synthetic.pending-source-A", authorization: "allowed", result: "ok" });
|
||||
await loggerA.write("info", "synthetic pending diagnostic A");
|
||||
await paused;
|
||||
f.replace();
|
||||
release();
|
||||
await service.sink.flush();
|
||||
assert.equal(service.status().droppedRecords, 2);
|
||||
const loggerB = service.createLogger({ ...f.identity, runtimeId: "host-runtime-B" }, f.plugin());
|
||||
loggerB.audit({ operation: "runtime.running", authorization: "not-required", result: "ok" });
|
||||
await service.sink.flush();
|
||||
const records = (await fs.readFile(service.sink.file(0), "utf8")).trim().split("\n").map(JSON.parse);
|
||||
assert.equal(records.length, 2);
|
||||
assert.match(records[0].sourceId, /^third-party:/u);
|
||||
assert.equal(records[1].sourceId, "official");
|
||||
assert.notEqual(records[0].namespace, records[1].namespace);
|
||||
assert.equal(records[0].runtimeId, "host-runtime-A");
|
||||
assert.equal(records[1].runtimeId, "host-runtime-B");
|
||||
assert.doesNotMatch(JSON.stringify(records), /synthetic.pending-source-A|pending diagnostic|example.com|maintenance/i);
|
||||
f.uninstall();
|
||||
await loggerB.write("info", "synthetic stale callback");
|
||||
loggerB.audit({ operation: "synthetic.after-uninstall", result: "ok" });
|
||||
assert.equal(service.status().droppedRecords, 4);
|
||||
});
|
||||
|
||||
test("a thrown source revalidation fails closed without breaking plugin execution", async (t) => {
|
||||
const f = sourceFixture();
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "netcatty-synthetic-source-failure-"));
|
||||
const service = new PluginLogService({ paths: { root, logs: path.join(root, "logs") }, resolveProvenance: createPluginLogProvenanceResolver(f.database) });
|
||||
t.after(async () => { await service.close(); await fs.rm(root, { recursive: true, force: true }); });
|
||||
await service.ready;
|
||||
await service.configure({ saveToDisk: true, verbose: true });
|
||||
const logger = service.createLogger(f.identity, f.plugin());
|
||||
f.database.getActivePlugin = () => { throw new Error("synthetic unavailable source database"); };
|
||||
await logger.write("info", "synthetic ignored");
|
||||
logger.audit({ operation: "storage.get", result: "ok" });
|
||||
await service.sink.flush();
|
||||
assert.equal(service.status().droppedRecords, 2);
|
||||
assert.equal(service.status().writeErrors, 0);
|
||||
});
|
||||
|
||||
test("a stale queued source cannot rotate away existing audit evidence", async (t) => {
|
||||
const f = sourceFixture();
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "netcatty-synthetic-source-rotation-"));
|
||||
const service = new PluginLogService({ paths: { root, logs: path.join(root, "logs") }, limits: { maxFiles: 1 }, resolveProvenance: createPluginLogProvenanceResolver(f.database) });
|
||||
t.after(async () => { await service.close(); await fs.rm(root, { recursive: true, force: true }); });
|
||||
await service.ready;
|
||||
await service.configure({ saveToDisk: true, verbose: false });
|
||||
const logger = service.createLogger(f.identity, f.plugin());
|
||||
logger.audit({ operation: "runtime.running", authorization: "not-required", result: "ok" });
|
||||
await service.sink.flush();
|
||||
const before = await fs.readFile(service.sink.file(0), "utf8");
|
||||
service.sink.maxBytes = Buffer.byteLength(before) + 16;
|
||||
let resume;
|
||||
let entered;
|
||||
const paused = new Promise((resolve) => { entered = resolve; });
|
||||
const gate = new Promise((resolve) => { resume = resolve; });
|
||||
const prune = service.sink.prune.bind(service.sink);
|
||||
service.sink.prune = async () => { entered(); await gate; return prune(); };
|
||||
logger.audit({ operation: "runtime.running", authorization: "not-required", result: "ok" });
|
||||
await paused;
|
||||
f.replace();
|
||||
resume();
|
||||
await service.sink.flush();
|
||||
assert.equal(service.status().droppedRecords, 1);
|
||||
assert.equal(await fs.readFile(service.sink.file(0), "utf8"), before);
|
||||
});
|
||||
@@ -0,0 +1,150 @@
|
||||
"use strict";
|
||||
|
||||
const fs = require("node:fs");
|
||||
const fsp = require("node:fs/promises");
|
||||
const path = require("node:path");
|
||||
const { createHmac, randomBytes } = require("node:crypto");
|
||||
const { PluginLogger, PluginLogSink } = require("./pluginLogger.cjs");
|
||||
const { RPC_ERRORS } = require("./rpcRouter.cjs");
|
||||
|
||||
const bounded = (value, length = 128) => typeof value === "string"
|
||||
? value.slice(0, length).replace(/[^a-zA-Z0-9._:@/-]/gu, "_") : null;
|
||||
const sourceKey = (value) => typeof value === "string" && /^[a-zA-Z0-9._:-]{1,128}$/u.test(value) ? value : null;
|
||||
const ownerKey = (value) => typeof value === "string" && /^[a-zA-Z0-9._:-]{1,256}$/u.test(value) ? value : null;
|
||||
const count = (value) => Number.isSafeInteger(value) && value >= 0 ? value : null;
|
||||
|
||||
class PluginLogService {
|
||||
constructor(options) {
|
||||
this.directory = path.join(options.paths.logs, "host-v1");
|
||||
this.preferencesPath = path.join(options.paths.root, "local-logging.json");
|
||||
this.resolveProvenance = options.resolveProvenance ?? (() => null);
|
||||
this.electron = options.electron;
|
||||
this.provenanceChecks = new WeakMap();
|
||||
this.salt = randomBytes(32);
|
||||
this.sink = new PluginLogSink({ directory: this.directory, ...options.limits });
|
||||
this.settings = { saveToDisk: false, verbose: false };
|
||||
this.management = Promise.resolve();
|
||||
try {
|
||||
if (fs.statSync(this.preferencesPath).size <= 1024) {
|
||||
const saved = JSON.parse(fs.readFileSync(this.preferencesPath, "utf8"));
|
||||
this.settings.saveToDisk = saved.saveToDisk === true;
|
||||
// Free-form diagnostics always require fresh consent after app restart.
|
||||
}
|
||||
} catch {}
|
||||
this.ready = this.sink.setEnabled(this.settings.saveToDisk).then(async () => {
|
||||
if (!this.sink.enabled && fs.existsSync(this.directory)) await this.sink.prune();
|
||||
}).catch(() => {
|
||||
this.sink.writeErrors++;
|
||||
this.settings.saveToDisk = false;
|
||||
this.sink.enabled = false;
|
||||
});
|
||||
this.retentionTimer = setInterval(() => {
|
||||
if (!fs.existsSync(this.directory) || this.sink.closed) return;
|
||||
this.sink.queue = this.sink.queue.then(() => this.sink.prune()).catch(() => { this.sink.writeErrors++; });
|
||||
}, 60 * 60 * 1000);
|
||||
this.retentionTimer.unref?.();
|
||||
}
|
||||
|
||||
status() {
|
||||
return { ...this.settings, ...this.sink.status(), maxBytes: this.sink.maxBytes * this.sink.maxFiles,
|
||||
retentionDays: this.sink.retentionMs / 86400000 };
|
||||
}
|
||||
async configure(request) {
|
||||
if (!request || typeof request.saveToDisk !== "boolean" || typeof request.verbose !== "boolean") throw new TypeError("Invalid plugin log settings");
|
||||
return this.#manage(async () => {
|
||||
if (this.sink.closed) throw new Error("Plugin logging is closed");
|
||||
const settings = { saveToDisk: request.saveToDisk, verbose: request.saveToDisk && request.verbose };
|
||||
// Stop ingress before changing preferences. Failure leaves persistence off.
|
||||
this.settings = { saveToDisk: false, verbose: false };
|
||||
await this.sink.setEnabled(false);
|
||||
await fsp.mkdir(path.dirname(this.preferencesPath), { recursive: true, mode: 0o700 });
|
||||
const temporary = `${this.preferencesPath}.tmp`;
|
||||
await fsp.writeFile(temporary, JSON.stringify({ saveToDisk: settings.saveToDisk }), { mode: 0o600 });
|
||||
await fsp.rename(temporary, this.preferencesPath);
|
||||
await this.sink.setEnabled(settings.saveToDisk);
|
||||
this.settings = settings;
|
||||
return this.status();
|
||||
});
|
||||
}
|
||||
#manage(action) {
|
||||
const result = this.management.then(() => this.ready).then(action);
|
||||
this.management = result.catch(() => {});
|
||||
return result;
|
||||
}
|
||||
identity(identity, plugin) {
|
||||
// Only the injected HOST resolver may assign source and namespace identity.
|
||||
let source = null;
|
||||
try { source = this.resolveProvenance(Object.freeze({ pluginId: identity.pluginId, pluginVersion: identity.pluginVersion,
|
||||
archiveSha256: plugin?.archiveSha256 ?? null, securityPrincipal: identity.securityPrincipal })); } catch {}
|
||||
const metadata = Object.freeze({ pluginId: bounded(identity.pluginId), pluginVersion: bounded(identity.pluginVersion),
|
||||
runtimeId: bounded(identity.runtimeId), runtimeKind: bounded(identity.runtimeKind),
|
||||
archiveSha256: /^[a-f0-9]{64}$/u.test(plugin?.archiveSha256 ?? "") ? plugin.archiveSha256 : null,
|
||||
sourceId: sourceKey(source?.sourceId), sourceKind: sourceKey(source?.sourceKind), namespace: ownerKey(source?.namespace) });
|
||||
if (source?.isCurrent && typeof source.isCurrent === "function") {
|
||||
this.provenanceChecks.set(metadata, () => source.isCurrent() === true);
|
||||
}
|
||||
return metadata;
|
||||
}
|
||||
createLogger(identity, plugin) {
|
||||
const provenance = this.identity(identity, plugin);
|
||||
return new PluginLogger({ sink: this.sink, identity: provenance, verbose: () => this.settings.verbose, isCurrent: this.provenanceChecks.get(provenance), audit: (event) => this.audit(provenance, event) });
|
||||
}
|
||||
audit(identity, event) {
|
||||
if (!this.sink.enabled) return;
|
||||
// An allowlist, never spread request/response/error objects into an audit.
|
||||
const resources = Array.isArray(event.resources) ? event.resources.slice(0, 16) : [];
|
||||
this.sink.enqueue({ timestamp: new Date().toISOString(), recordType: "host-audit", trust: "host-observed",
|
||||
...identity, operation: bounded(event.operation, 256),
|
||||
targetIds: resources.filter((item) => typeof item === "string").map((item) =>
|
||||
createHmac("sha256", this.salt).update(item.slice(0, 8192)).digest("hex")),
|
||||
authorization: ["allowed", "denied", "not-required", "not-reached", "unknown"].includes(event.authorization) ? event.authorization : "unknown",
|
||||
result: event.result === "ok" ? "ok" : "error",
|
||||
errorCode: Object.values(RPC_ERRORS).includes(event.errorCode) ? event.errorCode : event.result === "ok" ? null : RPC_ERRORS.internal,
|
||||
durationMs: count(event.durationMs), inputBytes: count(event.inputBytes), outputBytes: count(event.outputBytes) }, this.provenanceChecks.get(identity));
|
||||
}
|
||||
async openFolder() {
|
||||
return this.#manage(async () => {
|
||||
await fsp.mkdir(this.directory, { recursive: true, mode: 0o700 });
|
||||
const error = await this.electron.shell.openPath(this.directory);
|
||||
if (error) throw new Error("Could not open plugin log folder");
|
||||
return this.status();
|
||||
});
|
||||
}
|
||||
async exportLogs() {
|
||||
return this.#manage(async () => {
|
||||
const result = await this.electron.dialog.showSaveDialog({ title: "Export local plugin logs", defaultPath: "netcatty-plugin-logs.jsonl" });
|
||||
if (result.canceled || !result.filePath) return this.status();
|
||||
if (path.resolve(result.filePath).startsWith(path.resolve(this.directory) + path.sep)) throw new Error("Choose a destination outside the log folder");
|
||||
const task = this.sink.queue.then(async () => {
|
||||
const chunks = [];
|
||||
for (let index = this.sink.maxFiles - 1; index >= 0; index--) {
|
||||
try {
|
||||
const stats = await fsp.lstat(this.sink.file(index));
|
||||
if (!stats.isFile() || stats.isSymbolicLink() || stats.size > this.sink.maxBytes) throw new Error("Unsafe plugin log file");
|
||||
if (await this.sink.retained(this.sink.file(index))) chunks.push(await fsp.readFile(this.sink.file(index)));
|
||||
} catch (error) { if (error.code !== "ENOENT") throw error; }
|
||||
}
|
||||
await fsp.writeFile(result.filePath, Buffer.concat(chunks), { mode: 0o600 });
|
||||
});
|
||||
this.sink.queue = task.catch(() => {});
|
||||
await task;
|
||||
return this.status();
|
||||
});
|
||||
}
|
||||
async clear() {
|
||||
return this.#manage(async () => {
|
||||
const result = await this.electron.dialog.showMessageBox({ type: "warning", title: "Clear local plugin logs?",
|
||||
message: "Delete the saved local audit and diagnostic files? The bounded security history in plugin permissions is kept.",
|
||||
buttons: ["Cancel", "Clear logs"], defaultId: 0, cancelId: 0, noLink: true });
|
||||
if (result.response === 1) await this.sink.clear();
|
||||
return this.status();
|
||||
});
|
||||
}
|
||||
async close() {
|
||||
clearInterval(this.retentionTimer);
|
||||
await this.management;
|
||||
await this.ready;
|
||||
await this.sink.close();
|
||||
}
|
||||
}
|
||||
module.exports = { PluginLogService };
|
||||
@@ -1,96 +1,186 @@
|
||||
"use strict";
|
||||
|
||||
const fs = require("node:fs/promises");
|
||||
const { constants } = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const { PLUGIN_LOG_MAX_BYTES, PLUGIN_LOG_MAX_FILES } = require("./constants.cjs");
|
||||
const { assertPluginStorageSegment } = require("./paths.cjs");
|
||||
const { TokenBucket, DEFAULT_QUOTAS } = require("./quotaManager.cjs");
|
||||
|
||||
const SENSITIVE_KEY = /(?:password|passwd|token|secret|private.?key|credential|authorization|cookie|totp|otpauth)/iu;
|
||||
const SENSITIVE_KEY = /(?:password|passwd|token|secret|api.?key|private.?key|credential|authorization|cookie|totp|otpauth|prompt|notes?|sql|results?|terminal|content|body|payload)/iu;
|
||||
|
||||
function redactLogValue(value, key = "", depth = 0) {
|
||||
// Bounds apply to the entire traversal, including hostile, cyclic or wide input.
|
||||
// This is damage limitation for opt-in diagnostics, not a guarantee of secrecy.
|
||||
function redactLogValue(value, key = "", depth = 0, budget = { remaining: 4096 }) {
|
||||
if (SENSITIVE_KEY.test(key)) return "[REDACTED]";
|
||||
if (depth >= 8) return "[TRUNCATED]";
|
||||
if (value == null || typeof value === "boolean" || typeof value === "number") return value;
|
||||
if (typeof value === "string") return value.slice(0, 2_048);
|
||||
if (Array.isArray(value)) {
|
||||
const output = [];
|
||||
for (let index = 0; index < Math.min(value.length, 64); index += 1) {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(value, String(index));
|
||||
output.push(descriptor && "value" in descriptor
|
||||
? redactLogValue(descriptor.value, "", depth + 1)
|
||||
: "[UNAVAILABLE]");
|
||||
}
|
||||
return output;
|
||||
if (depth >= 6 || budget.remaining <= 0) return "[TRUNCATED]";
|
||||
budget.remaining -= 16;
|
||||
if (value == null || typeof value === "boolean") return value;
|
||||
if (typeof value === "number") return Number.isFinite(value) ? value : null;
|
||||
if (typeof value === "string") {
|
||||
const text = value.slice(0, Math.min(2048, Math.max(0, budget.remaining))).replace(/[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/gu, " ");
|
||||
budget.remaining -= text.length;
|
||||
return text;
|
||||
}
|
||||
if (typeof value === "object") {
|
||||
const output = {};
|
||||
for (const entryKey of Object.keys(value).slice(0, 64)) {
|
||||
if (SENSITIVE_KEY.test(entryKey)) {
|
||||
output[entryKey.slice(0, 128)] = "[REDACTED]";
|
||||
continue;
|
||||
}
|
||||
if (typeof value !== "object") return "[UNSUPPORTED]";
|
||||
try {
|
||||
const output = Array.isArray(value) ? [] : Object.create(null);
|
||||
// Stop enumeration as soon as the budget is exhausted.
|
||||
let count = 0;
|
||||
for (const entryKey in value) {
|
||||
if (budget.remaining <= 0 || count++ >= 32) break;
|
||||
const descriptor = Object.getOwnPropertyDescriptor(value, entryKey);
|
||||
output[entryKey.slice(0, 128)] = descriptor && "value" in descriptor
|
||||
? redactLogValue(descriptor.value, entryKey, depth + 1)
|
||||
: "[UNAVAILABLE]";
|
||||
if (!descriptor?.enumerable) continue;
|
||||
const safeKey = entryKey.slice(0, 64).replace(/[\u0000-\u001f\u007f]/gu, " ");
|
||||
budget.remaining -= safeKey.length;
|
||||
const item = "value" in descriptor ? redactLogValue(descriptor.value, entryKey, depth + 1, budget) : "[UNAVAILABLE]";
|
||||
if (Array.isArray(output)) output.push(item);
|
||||
else output[safeKey] = item;
|
||||
}
|
||||
return output;
|
||||
} catch { return "[UNAVAILABLE]"; }
|
||||
}
|
||||
|
||||
/** One app-owned sink gives every plugin a shared disk and queue budget. */
|
||||
class PluginLogSink {
|
||||
constructor(options) {
|
||||
this.directory = options.directory;
|
||||
this.maxBytes = options.maxBytes ?? PLUGIN_LOG_MAX_BYTES;
|
||||
this.maxFiles = options.maxFiles ?? PLUGIN_LOG_MAX_FILES;
|
||||
this.maxPendingBytes = options.maxPendingBytes ?? 256 * 1024;
|
||||
this.retentionMs = options.retentionMs ?? 7 * 24 * 60 * 60 * 1000;
|
||||
for (const value of [this.maxBytes, this.maxFiles, this.maxPendingBytes, this.retentionMs]) {
|
||||
if (!Number.isSafeInteger(value) || value <= 0) throw new TypeError("Invalid plugin log limit");
|
||||
}
|
||||
this.clock = options.clock ?? Date.now;
|
||||
this.enabled = false;
|
||||
this.closed = false;
|
||||
this.pendingBytes = 0;
|
||||
this.diagnosticPendingBytes = 0;
|
||||
this.dropped = 0;
|
||||
this.writeErrors = 0;
|
||||
this.queue = Promise.resolve();
|
||||
this.generation = 0;
|
||||
}
|
||||
return "[UNSUPPORTED]";
|
||||
|
||||
enqueue(record, isCurrent = () => true) {
|
||||
if (!this.enabled || this.closed) return;
|
||||
if (this.clearing) { this.dropped++; return; }
|
||||
const current = () => { try { return isCurrent() === true; } catch { return false; } };
|
||||
if (!current()) { this.dropped++; return; }
|
||||
let line;
|
||||
try { line = `${JSON.stringify({ ...record, timestamp: new Date(this.clock()).toISOString(), droppedRecords: this.dropped, writeErrors: this.writeErrors })}\n`; }
|
||||
catch { this.dropped++; return; }
|
||||
const bytes = Buffer.byteLength(line);
|
||||
const diagnostic = record.recordType === "plugin-diagnostic";
|
||||
if ((diagnostic && this.diagnosticPendingBytes + bytes > this.maxPendingBytes / 4) || bytes > Math.min(this.maxBytes, 16 * 1024) || this.pendingBytes + bytes > this.maxPendingBytes) {
|
||||
this.dropped++; return;
|
||||
}
|
||||
const generation = this.generation;
|
||||
this.pendingBytes += bytes;
|
||||
if (diagnostic) this.diagnosticPendingBytes += bytes;
|
||||
this.queue = this.queue.then(async () => {
|
||||
if (generation !== this.generation || !current()) { this.dropped++; return; }
|
||||
await this.prune();
|
||||
const active = this.file(0);
|
||||
let size = 0;
|
||||
try { size = (await fs.stat(active)).size; } catch (error) { if (error.code !== "ENOENT") throw error; }
|
||||
if (!current()) { this.dropped++; return; }
|
||||
if (size + bytes > this.maxBytes) {
|
||||
await fs.rm(this.file(this.maxFiles - 1), { force: true });
|
||||
for (let index = this.maxFiles - 2; index >= 0; index--) {
|
||||
if (!current()) { this.dropped++; return; }
|
||||
try { await fs.rename(this.file(index), this.file(index + 1)); }
|
||||
catch (error) { if (error.code !== "ENOENT") throw error; }
|
||||
}
|
||||
}
|
||||
if (!current()) { this.dropped++; return; }
|
||||
const handle = await fs.open(active, constants.O_WRONLY | constants.O_CREAT | constants.O_APPEND | (constants.O_NOFOLLOW ?? 0), 0o600);
|
||||
try {
|
||||
if (!(await handle.stat()).isFile()) throw new Error("Unsafe plugin log file");
|
||||
if (!current()) { this.dropped++; return; }
|
||||
await handle.writeFile(line);
|
||||
} finally { await handle.close(); }
|
||||
}).catch(() => { this.dropped++; this.writeErrors++; }).finally(() => {
|
||||
this.pendingBytes -= bytes;
|
||||
if (diagnostic) this.diagnosticPendingBytes -= bytes;
|
||||
});
|
||||
}
|
||||
|
||||
file(index) { return path.join(this.directory, index === 0 ? "runtime.jsonl" : `runtime.${index}.jsonl`); }
|
||||
async retained(file) {
|
||||
// Every host record is capped at 16 KiB. Read only the first line: append
|
||||
// updates mtime, so mtime cannot enforce record retention for active files.
|
||||
const handle = await fs.open(file, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
|
||||
try {
|
||||
const buffer = Buffer.alloc(16 * 1024);
|
||||
const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
|
||||
const newline = buffer.indexOf(10, 0);
|
||||
if (newline < 0 || newline >= bytesRead) return false;
|
||||
let first;
|
||||
try { first = JSON.parse(buffer.subarray(0, newline).toString("utf8")); } catch { return false; }
|
||||
const start = typeof first.timestamp === "string" ? Date.parse(first.timestamp) : NaN;
|
||||
return Number.isFinite(start) && this.clock() - start < this.retentionMs;
|
||||
} finally { await handle.close(); }
|
||||
}
|
||||
async prune() {
|
||||
await fs.mkdir(this.directory, { recursive: true, mode: 0o700 });
|
||||
for (let index = 0; index < this.maxFiles; index++) {
|
||||
try {
|
||||
const file = this.file(index);
|
||||
const stats = await fs.lstat(file);
|
||||
if (!stats.isFile() || stats.isSymbolicLink()) throw new Error("Unsafe plugin log file");
|
||||
if (stats.size > this.maxBytes || !await this.retained(file)) await fs.rm(file);
|
||||
} catch (error) { if (error.code !== "ENOENT") throw error; }
|
||||
}
|
||||
}
|
||||
flush() { return this.queue; }
|
||||
async setEnabled(enabled) {
|
||||
this.enabled = false;
|
||||
this.generation++;
|
||||
await this.flush();
|
||||
if (enabled === true && !this.closed) {
|
||||
await this.prune();
|
||||
this.enabled = true;
|
||||
}
|
||||
}
|
||||
async clear() {
|
||||
this.clearing = true;
|
||||
this.generation++;
|
||||
try {
|
||||
await this.flush();
|
||||
for (let index = 0; index < this.maxFiles; index++) await fs.rm(this.file(index), { force: true });
|
||||
} finally { this.clearing = false; }
|
||||
}
|
||||
async close() { this.closed = true; this.enabled = false; await this.flush(); }
|
||||
status() { return { droppedRecords: this.dropped, writeErrors: this.writeErrors, pendingBytes: this.pendingBytes }; }
|
||||
}
|
||||
|
||||
class PluginLogger {
|
||||
constructor(options) {
|
||||
this.pluginId = assertPluginStorageSegment(options.pluginId, "ID");
|
||||
this.directory = path.join(options.logsDirectory, this.pluginId);
|
||||
this.maxBytes = options.maxBytes ?? PLUGIN_LOG_MAX_BYTES;
|
||||
this.maxFiles = options.maxFiles ?? PLUGIN_LOG_MAX_FILES;
|
||||
this.maxPendingBytes = options.maxPendingBytes ?? Math.min(this.maxBytes, 256 * 1024);
|
||||
this.pendingBytes = 0;
|
||||
this.queue = Promise.resolve();
|
||||
constructor(options = {}) {
|
||||
this.sink = options.sink;
|
||||
this.identity = options.identity;
|
||||
this.verbose = options.verbose ?? (() => false);
|
||||
this.isCurrent = options.isCurrent;
|
||||
this.closed = false;
|
||||
this.audit = options.audit ?? (() => {});
|
||||
this.bucket = new TokenBucket(DEFAULT_QUOTAS.logWritesPerMinute, DEFAULT_QUOTAS.logWritesPerMinute / 60000, options.clock ?? Date.now);
|
||||
}
|
||||
|
||||
write(level, message, fields) {
|
||||
const record = JSON.stringify({
|
||||
if (this.closed || !this.sink?.enabled || !this.verbose()) return Promise.resolve();
|
||||
if (!this.bucket.consume()) { this.sink.dropped++; return Promise.resolve(); }
|
||||
this.sink.enqueue({
|
||||
timestamp: new Date().toISOString(),
|
||||
recordType: "plugin-diagnostic", trust: "plugin-authored-unverified",
|
||||
...this.identity,
|
||||
level: ["debug", "info", "warn", "error"].includes(level) ? level : "info",
|
||||
message: String(message ?? "").slice(0, 2_048),
|
||||
...(fields === undefined ? {} : { fields: redactLogValue(fields) }),
|
||||
});
|
||||
const line = `${record}\n`;
|
||||
const bytes = Buffer.byteLength(line);
|
||||
if (this.pendingBytes + bytes > this.maxPendingBytes) return Promise.resolve();
|
||||
this.pendingBytes += bytes;
|
||||
this.queue = this.queue
|
||||
.then(() => this.#append(line))
|
||||
.catch(() => {})
|
||||
.finally(() => { this.pendingBytes -= bytes; });
|
||||
return this.queue;
|
||||
}
|
||||
|
||||
async #append(line) {
|
||||
await fs.mkdir(this.directory, { recursive: true, mode: 0o700 });
|
||||
const activePath = path.join(this.directory, "runtime.log");
|
||||
let size = 0;
|
||||
try { size = (await fs.stat(activePath)).size; } catch {}
|
||||
if (size + Buffer.byteLength(line) > this.maxBytes) await this.#rotate();
|
||||
await fs.appendFile(activePath, line, { encoding: "utf8", mode: 0o600 });
|
||||
}
|
||||
|
||||
async #rotate() {
|
||||
await fs.rm(path.join(this.directory, `runtime.${this.maxFiles}.log`), { force: true });
|
||||
for (let index = this.maxFiles - 1; index >= 1; index -= 1) {
|
||||
try {
|
||||
await fs.rename(
|
||||
path.join(this.directory, index === 1 ? "runtime.log" : `runtime.${index - 1}.log`),
|
||||
path.join(this.directory, `runtime.${index}.log`),
|
||||
);
|
||||
} catch (error) {
|
||||
if (error?.code !== "ENOENT") throw error;
|
||||
}
|
||||
}
|
||||
diagnostic: {
|
||||
message: redactLogValue(typeof message === "string" ? message : "[INVALID MESSAGE]"),
|
||||
fields: redactLogValue(fields),
|
||||
},
|
||||
}, this.isCurrent);
|
||||
return Promise.resolve();
|
||||
}
|
||||
close() { this.closed = true; }
|
||||
}
|
||||
|
||||
module.exports = { PluginLogger, redactLogValue };
|
||||
module.exports = { PluginLogger, PluginLogSink, redactLogValue };
|
||||
@@ -1,43 +1,258 @@
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const fs = require("node:fs/promises");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const test = require("node:test");
|
||||
const { PluginLogger, PluginLogSink, redactLogValue } = require("./pluginLogger.cjs");
|
||||
const { PluginLogService } = require("./pluginLogService.cjs");
|
||||
const { PluginHostRpcRegistry } = require("./hostRpcRegistry.cjs");
|
||||
const { PluginPermissionEngine } = require("./permissionEngine.cjs");
|
||||
const { RPC_ERRORS } = require("./rpcRouter.cjs");
|
||||
|
||||
const { PluginLogger, redactLogValue } = require("./pluginLogger.cjs");
|
||||
async function fixture(t, limits = {}) {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "netcatty-synthetic-plugin-logs-"));
|
||||
const paths = { root, logs: path.join(root, "logs") };
|
||||
let response = 0;
|
||||
const service = new PluginLogService({ paths, limits, resolveProvenance: () => ({ sourceId: "synthetic-source", sourceKind: "local", namespace: "test" }),
|
||||
electron: { shell: { openPath: async () => "" }, dialog: { showMessageBox: async () => ({ response }), showSaveDialog: async () => ({ filePath: path.join(root, "export.jsonl") }) } } });
|
||||
await service.ready;
|
||||
t.after(async () => { await service.close(); await fs.rm(root, { recursive: true, force: true }); });
|
||||
const identity = { pluginId: "com.example.synthetic", pluginVersion: "1.2.3", runtimeId: "host-runtime-1", runtimeKind: "browser" };
|
||||
const logger = service.createLogger(identity, { archiveSha256: "a".repeat(64) });
|
||||
const records = async () => {
|
||||
await service.sink.flush();
|
||||
const files = await fs.readdir(service.directory).catch(() => []);
|
||||
const lines = (await Promise.all(files.map((name) => fs.readFile(path.join(service.directory, name), "utf8")))).join("");
|
||||
return lines.trim() ? lines.trim().split("\n").map(JSON.parse) : [];
|
||||
};
|
||||
return { service, logger, identity, root, paths, records, confirm: () => { response = 1; } };
|
||||
}
|
||||
|
||||
test("plugin logs redact structured secrets and remain bounded on disk", async (context) => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-plugin-logs-"));
|
||||
context.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const logger = new PluginLogger({
|
||||
pluginId: "com.example.logger",
|
||||
logsDirectory: root,
|
||||
maxBytes: 240,
|
||||
maxFiles: 2,
|
||||
});
|
||||
test("persistence defaults off and disabled logger does not coerce or inspect text", async (t) => {
|
||||
const f = await fixture(t);
|
||||
await f.logger.write("error", { toString() { throw Error("must not execute"); } });
|
||||
f.logger.audit({ operation: "storage.get", result: "ok" });
|
||||
assert.deepEqual(await f.records(), []);
|
||||
await assert.rejects(fs.stat(f.service.directory), { code: "ENOENT" });
|
||||
assert.equal(f.service.status().saveToDisk, false);
|
||||
});
|
||||
|
||||
for (let index = 0; index < 8; index += 1) {
|
||||
await logger.write("info", `record-${index}`, {
|
||||
nested: { apiToken: "must-not-appear", visible: index },
|
||||
});
|
||||
test("enabled host audit excludes bodies and binds host source, version, hash and runtime", async (t) => {
|
||||
const f = await fixture(t);
|
||||
await f.service.configure({ saveToDisk: true, verbose: false });
|
||||
await f.logger.write("info", "SYNTHETIC_SECRET_PROMPT");
|
||||
f.logger.audit({ operation: "network.request", authorization: "allowed", result: "ok", resources: ["https://user:password@example.test/private?api_key=SYNTHETIC_SECRET"],
|
||||
body: "SYNTHETIC_SECRET_BODY", error: "SYNTHETIC_SECRET_ERROR", inputBytes: 24, outputBytes: 64, durationMs: 3, pluginId: "spoof" });
|
||||
const records = await f.records();
|
||||
assert.equal(records.length, 1);
|
||||
assert.equal(records[0].pluginId, f.identity.pluginId);
|
||||
assert.equal(records[0].sourceId, "synthetic-source");
|
||||
assert.equal(records[0].archiveSha256, "a".repeat(64));
|
||||
assert.equal(records[0].runtimeId, "host-runtime-1");
|
||||
assert.equal(records[0].authorization, "allowed");
|
||||
assert.equal(records[0].inputBytes, 24);
|
||||
assert.match(records[0].targetIds[0], /^[a-f0-9]{64}$/u);
|
||||
assert.doesNotMatch(JSON.stringify(records), /SYNTHETIC_SECRET|example.test|password|spoof/);
|
||||
});
|
||||
|
||||
test("verbose diagnostics are opt-in, bounded and cannot impersonate host records", async (t) => {
|
||||
const f = await fixture(t);
|
||||
await f.service.configure({ saveToDisk: true, verbose: true });
|
||||
await f.logger.write("bad", "message\u001b\n", { sourceId: "spoof", recordType: "host-audit", apiKey: "SYNTHETIC_SECRET", prompt: "SYNTHETIC_SECRET", Notes: "SYNTHETIC_SECRET", SQL: "SYNTHETIC_SECRET", results: "SYNTHETIC_SECRET", terminalContent: "SYNTHETIC_SECRET", credentialPayload: "SYNTHETIC_SECRET" });
|
||||
const records = await f.records();
|
||||
assert.equal(records[0].sourceId, "synthetic-source");
|
||||
assert.equal(records[0].recordType, "plugin-diagnostic");
|
||||
assert.equal(records[0].trust, "plugin-authored-unverified");
|
||||
assert.equal(records[0].diagnostic.fields.sourceId, "spoof");
|
||||
assert.doesNotMatch(JSON.stringify(records), /SYNTHETIC_SECRET|\\u001b/);
|
||||
const cyclic = {}; cyclic.self = cyclic;
|
||||
await f.logger.write("error", { toString() { throw Error("coercion"); } }, cyclic);
|
||||
await f.logger.write("info", "proxy", new Proxy({}, { ownKeys() { throw Error("proxy"); } }));
|
||||
const getter = {};
|
||||
Object.defineProperty(getter, "value", { enumerable: true, get() { throw Error("accessor"); } });
|
||||
assert.equal(redactLogValue(getter).value, "[UNAVAILABLE]");
|
||||
assert.equal((await f.records()).length, 3);
|
||||
const restored = new PluginLogService({ paths: f.paths });
|
||||
await restored.ready;
|
||||
assert.equal(restored.status().saveToDisk, true);
|
||||
assert.equal(restored.status().verbose, false);
|
||||
await restored.close();
|
||||
});
|
||||
|
||||
test("shared disk cap, queue cap, retention and drop accounting", async (t) => {
|
||||
const f = await fixture(t, { maxBytes: 800, maxFiles: 2, maxPendingBytes: 1600 });
|
||||
await f.service.configure({ saveToDisk: true, verbose: true });
|
||||
for (let i = 0; i < 10; i++) {
|
||||
f.logger.audit({ operation: "storage.get", result: "ok" });
|
||||
await f.service.sink.flush();
|
||||
}
|
||||
const files = fs.readdirSync(path.join(root, "com.example.logger"));
|
||||
let files = await fs.readdir(f.service.directory);
|
||||
assert.ok(files.length <= 2);
|
||||
const contents = files.map((file) => fs.readFileSync(
|
||||
path.join(root, "com.example.logger", file),
|
||||
"utf8",
|
||||
)).join("\n");
|
||||
assert.doesNotMatch(contents, /must-not-appear/);
|
||||
assert.match(contents, /\[REDACTED\]/);
|
||||
for (const name of files) assert.ok((await fs.stat(path.join(f.service.directory, name))).size <= 800);
|
||||
for (let i = 0; i < 1000; i++) f.logger.audit({ operation: "storage.get", result: "ok" });
|
||||
assert.ok(f.service.sink.pendingBytes <= 1600);
|
||||
assert.ok(f.service.status().droppedRecords > 0);
|
||||
await f.service.sink.flush();
|
||||
f.logger.audit({ operation: "storage.get", result: "ok" });
|
||||
assert.ok((await f.records()).some((record) => record.droppedRecords > 0));
|
||||
files = await fs.readdir(f.service.directory);
|
||||
const future = Date.now() + f.service.sink.retentionMs;
|
||||
f.service.sink.clock = () => future;
|
||||
await f.service.sink.prune();
|
||||
assert.deepEqual(await fs.readdir(f.service.directory), []);
|
||||
});
|
||||
|
||||
test("log redaction does not invoke accessors", () => {
|
||||
const value = {};
|
||||
Object.defineProperty(value, "secret", {
|
||||
enumerable: true,
|
||||
get() { throw new Error("accessor invoked"); },
|
||||
});
|
||||
assert.deepEqual(redactLogValue(value), { secret: "[REDACTED]" });
|
||||
test("disable, revoke, clear confirmation and shutdown stop future diagnostic writes", async (t) => {
|
||||
const f = await fixture(t);
|
||||
await f.service.configure({ saveToDisk: true, verbose: true });
|
||||
await f.logger.write("info", "before revoke");
|
||||
await f.service.sink.flush();
|
||||
f.logger.close();
|
||||
await f.logger.write("info", "after revoke");
|
||||
assert.equal((await f.records()).length, 1);
|
||||
await f.service.clear();
|
||||
assert.equal((await f.records()).length, 1);
|
||||
f.confirm(); await f.service.clear();
|
||||
assert.equal((await f.records()).length, 0);
|
||||
const active = f.service.createLogger(f.identity, {});
|
||||
await f.service.configure({ saveToDisk: false, verbose: false });
|
||||
await active.write("info", "disabled");
|
||||
assert.equal((await f.records()).length, 0);
|
||||
await f.service.configure({ saveToDisk: true, verbose: true });
|
||||
await active.write("info", "before shutdown");
|
||||
await f.service.close();
|
||||
await active.write("info", "after shutdown");
|
||||
assert.equal((await f.records()).length, 1);
|
||||
});
|
||||
|
||||
test("export is local and does not alter retained files", async (t) => {
|
||||
const f = await fixture(t);
|
||||
await f.service.configure({ saveToDisk: true, verbose: false });
|
||||
f.logger.audit({ operation: "filesystem.stat", result: "ok", authorization: "allowed" });
|
||||
await f.service.exportLogs();
|
||||
const exported = await fs.readFile(path.join(f.root, "export.jsonl"), "utf8");
|
||||
assert.equal(JSON.parse(exported).operation, "filesystem.stat");
|
||||
assert.equal((await f.records()).length, 1);
|
||||
});
|
||||
|
||||
test("RPC audit includes denied, malformed and public operations without error messages or bodies", async (t) => {
|
||||
const f = await fixture(t);
|
||||
await f.service.configure({ saveToDisk: true, verbose: false });
|
||||
const registry = new PluginHostRpcRegistry();
|
||||
const engine = new PluginPermissionEngine({ database: { listPermissionGrants: () => [] } });
|
||||
t.after(() => engine.shutdown());
|
||||
registry.use(engine.createMiddleware());
|
||||
registry.registerRequest("synthetic.public", (params) => ({ data: params.data }), { metadata: { public: true } });
|
||||
registry.registerRequest("synthetic.denied", () => { throw Error("not reached"); });
|
||||
registry.registerRequest("synthetic.invalid", () => null, { validateParams() { throw new Error("SYNTHETIC_SECRET_ERROR"); } });
|
||||
const routes = registry.createRoutes({ ...f.identity, logger: f.logger });
|
||||
await routes.requestHandlers["synthetic.public"]({ data: "SYNTHETIC_SECRET_BODY" }, {});
|
||||
await assert.rejects(routes.requestHandlers["synthetic.denied"]({}, {}), { code: RPC_ERRORS.permissionDenied });
|
||||
await assert.rejects(routes.requestHandlers["synthetic.invalid"]({}, {}));
|
||||
const records = await f.records();
|
||||
assert.deepEqual(records.map((r) => r.authorization), ["not-required", "denied", "not-reached"]);
|
||||
assert.equal(records[0].inputBytes, 21);
|
||||
assert.doesNotMatch(JSON.stringify(records), /SYNTHETIC_SECRET/);
|
||||
});
|
||||
|
||||
test("I/O failure is counted and never fails plugin execution", async (t) => {
|
||||
const f = await fixture(t);
|
||||
await f.service.configure({ saveToDisk: true, verbose: true });
|
||||
await fs.writeFile(f.service.sink.file(0), "");
|
||||
await fs.rm(f.service.sink.file(0));
|
||||
await fs.symlink(path.join(f.root, "outside"), f.service.sink.file(0));
|
||||
await f.logger.write("info", "synthetic");
|
||||
await f.service.sink.flush();
|
||||
assert.equal(f.service.status().writeErrors, 1);
|
||||
assert.equal(f.service.status().droppedRecords, 1);
|
||||
});
|
||||
|
||||
test("invalid sink limits fail early", () => {
|
||||
assert.throws(() => new PluginLogSink({ directory: "/unused", maxFiles: -1 }));
|
||||
const logger = new PluginLogger();
|
||||
assert.doesNotThrow(() => logger.write("info", "disabled"));
|
||||
});
|
||||
|
||||
test("unknown provenance stays unknown and a disabled restart prunes expired files", async (t) => {
|
||||
const f = await fixture(t);
|
||||
f.service.resolveProvenance = () => null;
|
||||
const logger = f.service.createLogger({ ...f.identity, sourceId: "manifest-official-claim" }, { sourceId: "manifest-official-claim", archiveSha256: "c".repeat(64) });
|
||||
await f.service.configure({ saveToDisk: true, verbose: false });
|
||||
logger.audit({ operation: "storage.get", result: "ok" });
|
||||
const records = await f.records();
|
||||
assert.equal(records[0].sourceId, null);
|
||||
assert.equal(records[0].namespace, null);
|
||||
assert.doesNotMatch(JSON.stringify(records), /manifest-official-claim/);
|
||||
await f.service.configure({ saveToDisk: false, verbose: false });
|
||||
const expired = await fs.readFile(f.service.sink.file(0), "utf8");
|
||||
await fs.writeFile(f.service.sink.file(0), expired.replace(/"timestamp":"[^"]+"/u, '"timestamp":"2000-01-01T00:00:00.000Z"'));
|
||||
const restored = new PluginLogService({ paths: f.paths });
|
||||
await restored.ready;
|
||||
assert.equal(restored.status().saveToDisk, false);
|
||||
assert.deepEqual(await fs.readdir(restored.directory), []);
|
||||
await restored.close();
|
||||
});
|
||||
|
||||
test("noisy and malformed plugin diagnostics remain bounded with visible drops", async (t) => {
|
||||
const f = await fixture(t, { maxPendingBytes: 4096 });
|
||||
await f.service.configure({ saveToDisk: true, verbose: true });
|
||||
const wide = {};
|
||||
for (let index = 0; index < 100; index++) wide[`key-${index}`] = Array(100).fill("synthetic ".repeat(100));
|
||||
await f.logger.write(Symbol("invalid"), { toString() { throw Error("must not coerce"); } }, wide);
|
||||
for (let index = 0; index < 1000; index++) void f.logger.write("info", "synthetic noise", { pluginId: "spoof", index });
|
||||
assert.ok(f.service.sink.pendingBytes <= 4096);
|
||||
assert.ok(f.service.status().droppedRecords > 0);
|
||||
for (const record of await f.records()) {
|
||||
assert.equal(record.pluginId, f.identity.pluginId);
|
||||
assert.ok(Buffer.byteLength(JSON.stringify(record)) < 16384);
|
||||
}
|
||||
});
|
||||
|
||||
test("source resolver URLs and userinfo are not persisted as source identity", async (t) => {
|
||||
const f = await fixture(t);
|
||||
f.service.resolveProvenance = () => ({ sourceId: "https://user:SYNTHETIC_SECRET@example.test/source?key=SYNTHETIC_SECRET", sourceKind: "local", namespace: "user@SYNTHETIC_SECRET" });
|
||||
const logger = f.service.createLogger(f.identity, {});
|
||||
await f.service.configure({ saveToDisk: true, verbose: false });
|
||||
logger.audit({ operation: "storage.get", result: "ok" });
|
||||
const records = await f.records();
|
||||
assert.equal(records[0].sourceId, null);
|
||||
assert.equal(records[0].namespace, null);
|
||||
assert.doesNotMatch(JSON.stringify(records), /SYNTHETIC_SECRET/);
|
||||
});
|
||||
|
||||
test("31 days of low-volume appends never retain an active record for seven days", async (t) => {
|
||||
let now = Date.UTC(2030, 0, 1);
|
||||
const f = await fixture(t, { clock: () => now });
|
||||
await f.service.configure({ saveToDisk: true, verbose: false });
|
||||
for (let day = 0; day < 31; day++) {
|
||||
now = Date.UTC(2030, 0, 1) + day * 86400000;
|
||||
f.logger.audit({ operation: `synthetic.day-${day}`, authorization: "not-required", result: "ok" });
|
||||
const records = await f.records();
|
||||
assert.ok(records.length > 0);
|
||||
for (const record of records) assert.ok(now - Date.parse(record.timestamp) < f.service.sink.retentionMs);
|
||||
}
|
||||
assert.doesNotMatch(JSON.stringify(await f.records()), /synthetic.day-0"/u);
|
||||
});
|
||||
|
||||
test("export excludes an expired active segment even when its mtime is recent", async (t) => {
|
||||
let now = Date.UTC(2030, 0, 1);
|
||||
const f = await fixture(t, { clock: () => now });
|
||||
await f.service.configure({ saveToDisk: true, verbose: false });
|
||||
f.logger.audit({ operation: "synthetic.expired", result: "ok" });
|
||||
await f.service.sink.flush();
|
||||
now += 86400000;
|
||||
f.logger.audit({ operation: "synthetic.day-one", result: "ok" });
|
||||
await f.service.sink.flush();
|
||||
// Appending refreshed mtime; export must use the first host record's age.
|
||||
await fs.utimes(f.service.sink.file(0), new Date(now), new Date(now));
|
||||
now += f.service.sink.retentionMs;
|
||||
await f.service.exportLogs();
|
||||
assert.equal(await fs.readFile(path.join(f.root, "export.jsonl"), "utf8"), "");
|
||||
f.logger.audit({ operation: "synthetic.new-segment", result: "ok" });
|
||||
await f.service.sink.flush();
|
||||
await f.service.exportLogs();
|
||||
const records = (await fs.readFile(path.join(f.root, "export.jsonl"), "utf8")).trim().split("\n").map(JSON.parse);
|
||||
assert.equal(records.length, 1);
|
||||
assert.equal(records[0].operation, "synthetic.new-segment");
|
||||
});
|
||||
@@ -380,6 +380,9 @@ function createPluginContext(config, client, runtimeApi) {
|
||||
quickPick: (title, items) => interact({ kind: "quickPick", title, items }),
|
||||
input: (title, initialValue) => interact({ kind: "input", title, ...(initialValue === undefined ? {} : { initialValue }) }),
|
||||
};
|
||||
const vault = Object.fromEntries(["selectHosts", "requestHostDraft", "requestConnect"].map((method) => [
|
||||
method, (params) => client.request(`vault.${method}`, params, { deadlineMs: PLUGIN_HOST_INTERACTION_LIMITS.timeoutMs }),
|
||||
]));
|
||||
const clipboard = {
|
||||
readText: (title) => interact({ kind: "clipboard.read", title }),
|
||||
writeText: (text, title) => interact({ kind: "clipboard.write", title, text }).then((value) => value === true),
|
||||
@@ -582,6 +585,7 @@ function createPluginContext(config, client, runtimeApi) {
|
||||
ai,
|
||||
selectedContext,
|
||||
ui,
|
||||
vault,
|
||||
clipboard,
|
||||
filesystem,
|
||||
companions,
|
||||
|
||||
@@ -51,6 +51,7 @@ function resolveDefaultRuntimeKind({ plugin }) {
|
||||
class RuntimeSupervisor {
|
||||
constructor(options) {
|
||||
this.electron = options.electron;
|
||||
this.logService = options.logService;
|
||||
this.database = options.database;
|
||||
this.packageStore = options.packageStore;
|
||||
this.protocol = options.protocol;
|
||||
@@ -202,6 +203,7 @@ class RuntimeSupervisor {
|
||||
quarantinedAt: details.quarantinedAt,
|
||||
});
|
||||
}
|
||||
if (identity) this.logService?.audit(identity.logger.identity, { operation: `runtime.${status}`, authorization: "not-required", result: ["error", "quarantined"].includes(status) ? "error" : "ok" });
|
||||
this.#emitRuntimeState(identity, status, details);
|
||||
}
|
||||
|
||||
@@ -284,7 +286,7 @@ class RuntimeSupervisor {
|
||||
) {
|
||||
throw new PluginRpcError(RPC_ERRORS.unavailable, "Plugin changed while runtime placement was resolved");
|
||||
}
|
||||
const logger = new PluginLogger({ pluginId, logsDirectory: this.paths.logs });
|
||||
let logger;
|
||||
let runtime;
|
||||
const identity = Object.freeze({
|
||||
pluginId,
|
||||
@@ -295,7 +297,7 @@ class RuntimeSupervisor {
|
||||
...(sourceOwner === null ? {} : { sourceOwner, sourceId: plugin.source.id }),
|
||||
manifest: freezeJson(structuredClone(plugin.manifest)),
|
||||
packageRoot,
|
||||
logger,
|
||||
get logger() { return logger; },
|
||||
assertCurrent: () => {
|
||||
const active = this.database.getActivePlugin(pluginId);
|
||||
if (
|
||||
@@ -310,11 +312,13 @@ class RuntimeSupervisor {
|
||||
}
|
||||
},
|
||||
});
|
||||
logger = this.logService?.createLogger(identity, plugin) ?? new PluginLogger();
|
||||
const routes = this.rpcRegistry.createRoutes(identity);
|
||||
const onExit = (details) => { void this.#handleExit(pluginId, runtime, identity, details); };
|
||||
const onProtocolError = (error) => logger.write("error", "Plugin protocol violation", {
|
||||
error: error?.message ?? String(error),
|
||||
});
|
||||
const onProtocolError = (error) => {
|
||||
logger.audit({ operation: "runtime.protocol", authorization: "not-reached", result: "error", errorCode: error?.code });
|
||||
return logger.write("error", "Plugin protocol violation", { error: error?.message });
|
||||
};
|
||||
runtime = kind === "browser"
|
||||
? this.runtimeFactories.browser({
|
||||
electron: this.electron,
|
||||
@@ -550,6 +554,7 @@ class RuntimeSupervisor {
|
||||
}
|
||||
|
||||
async #cleanupRuntime(identity) {
|
||||
identity?.logger?.close();
|
||||
if (!identity || !this.runtimeCleanup) return;
|
||||
await this.runtimeCleanup(Object.freeze({
|
||||
...(identity.sourceOwner ? { sourceOwner: identity.sourceOwner, sourceId: identity.sourceId } : {}),
|
||||
|
||||
@@ -1002,3 +1002,30 @@ test('data-only themes never create a runtime on startup or explicit start',asyn
|
||||
await assert.rejects(fixture.supervisor.start(manifest.id),/no executable runtime/);assert.equal(fixture.runtimeOptions.length,0);
|
||||
await fixture.supervisor.shutdown();
|
||||
});
|
||||
|
||||
test("runtime cleanup closes diagnostic loggers before stale runtime callbacks can write", async (t) => {
|
||||
const { PluginLogger } = require("./pluginLogger.cjs");
|
||||
const loggers = [];
|
||||
const events = [];
|
||||
const fixture = createFixture(t, () => ({
|
||||
async start(config) { return { pluginId: config.pluginId, pluginVersion: config.pluginVersion, apiVersion: config.apiVersion, enabledFeatures: config.enabledFeatures }; },
|
||||
async stop() {},
|
||||
}), { logService: {
|
||||
createLogger(identity) {
|
||||
const logger = new PluginLogger({ identity });
|
||||
loggers.push(logger);
|
||||
return logger;
|
||||
},
|
||||
audit(_identity, event) { events.push(event.operation); },
|
||||
} });
|
||||
t.after(() => fixture.supervisor.shutdown());
|
||||
await fixture.supervisor.start(fixture.manifest.id);
|
||||
assert.equal(loggers[0].closed, false);
|
||||
await fixture.supervisor.stop(fixture.manifest.id);
|
||||
assert.equal(loggers[0].closed, true);
|
||||
assert.deepEqual(events.slice(0, 3), ["runtime.starting", "runtime.running", "runtime.stopped"]);
|
||||
await fixture.runtimeOptions[0].logger.write("info", "synthetic stale callback");
|
||||
await fixture.supervisor.start(fixture.manifest.id);
|
||||
await fixture.supervisor.shutdown();
|
||||
assert.equal(loggers[1].closed, true);
|
||||
});
|
||||
@@ -135,6 +135,11 @@ function createPreloadApi(ctx) {
|
||||
}),
|
||||
restartPlugin: (pluginId) => ipcRenderer.invoke("netcatty:plugins:restart", { pluginId }),
|
||||
uninstallPlugin: (pluginId) => ipcRenderer.invoke("netcatty:plugins:uninstall", { pluginId }),
|
||||
getPluginLogStatus: () => ipcRenderer.invoke("netcatty:plugins:log-status"),
|
||||
configurePluginLogs: (settings) => ipcRenderer.invoke("netcatty:plugins:log-configure", settings),
|
||||
openPluginLogFolder: () => ipcRenderer.invoke("netcatty:plugins:log-open"),
|
||||
exportPluginLogs: () => ipcRenderer.invoke("netcatty:plugins:log-export"),
|
||||
clearPluginLogs: () => ipcRenderer.invoke("netcatty:plugins:log-clear"),
|
||||
setPluginTerminalTarget: (target) => ipcRenderer.invoke("netcatty:plugins:terminal-target", target),
|
||||
getPluginThemes: () => ipcRenderer.invoke("netcatty:plugins:theme-catalog"),
|
||||
applyPluginTheme: (request) => ipcRenderer.invoke("netcatty:plugins:theme-apply", request),
|
||||
|
||||
@@ -4,6 +4,18 @@ A minimal browser plugin that uses Netcatty's selected configured model and one
|
||||
explicitly selected reference. It provides Send, streamed text, Stop, and usage.
|
||||
It has no tools, terminal commands, direct network access, Node runtime, or storage.
|
||||
|
||||
An expired chat is reopened before the next explicit Send. If the configured
|
||||
model changes or a request fails, use **Refresh model** to reopen the session,
|
||||
then click **Send** yourself. Refresh keeps your draft and never retries model
|
||||
inference. If a request failed after reaching the provider, check its outcome
|
||||
before choosing to send it again.
|
||||
|
||||
Selected references expire too. If a selected Note expires or changes, click
|
||||
**Choose reference**, confirm the Note in Netcatty, and select its fresh reference
|
||||
in the dropdown before clicking **Send**. Refresh model does not share a Note
|
||||
again. Re-selection keeps your draft and does not read content or run inference;
|
||||
the new reference still requires its own content permission and text review.
|
||||
|
||||
The host must support `ai.model-invocation` and `context.selected-read`, with the
|
||||
trusted renderer adapter connected. AI invocation, selected-summary enumeration,
|
||||
and selected-content reading have separate optional permissions. The host shows
|
||||
|
||||
@@ -13,10 +13,11 @@ select, textarea { width:100%; background:#10161f; color:#e9edf6; border:1px sol
|
||||
select:focus,textarea:focus,button:focus-visible { outline:2px solid #8bb9ff; outline-offset:2px; }
|
||||
textarea { resize:vertical; min-height:84px; }
|
||||
form { margin:22px 0; }
|
||||
.actions { display:flex; align-items:center; gap:10px; margin-top:12px; }
|
||||
.actions { display:flex; flex-wrap:wrap; align-items:center; gap:10px; margin-top:12px; }
|
||||
button { border:1px solid #4e8bdf; border-radius:7px; background:#3274c6; color:white; padding:9px 24px; font:inherit; font-weight:600; cursor:pointer; }
|
||||
button:disabled { opacity:.42; cursor:default; }
|
||||
#stop { background:transparent; border-color:#56657b; }
|
||||
#stop,#refresh,#choose-reference { background:transparent; border-color:#56657b; }
|
||||
#choose-reference { margin-top:10px; }
|
||||
#status { margin-left:4px; color:#b6c6dc; font-size:12px; }
|
||||
.response { min-height:160px; }
|
||||
pre { white-space:pre-wrap; overflow-wrap:anywhere; font:16px/1.7 system-ui,sans-serif; margin:0 0 12px; }
|
||||
|
||||
@@ -11,8 +11,8 @@
|
||||
<main>
|
||||
<header><span class="badge">PLUGIN EXAMPLE</span><h1>Summarize a selected Note</h1><p>Use the model and reference shared by Netcatty.</p></header>
|
||||
<section class="card"><label>Model selected in Netcatty</label><div id="model">Connecting...</div></section>
|
||||
<section class="card"><label for="context">Selected reference</label><select id="context"><option value="">No reference</option></select><p class="hint">Only references explicitly shared with this plugin appear here.</p></section>
|
||||
<form id="compose"><label for="prompt">Message</label><textarea id="prompt" rows="3" maxlength="4000">Summarize this Note in one sentence.</textarea><div class="actions"><button id="send" type="submit" disabled>Send</button><button id="stop" type="button" disabled>Stop</button><span id="status" role="status" aria-live="polite">Connecting...</span></div></form>
|
||||
<section class="card"><label for="context">Selected reference</label><select id="context"><option value="">No reference</option></select><p class="hint">Only references explicitly shared with this plugin appear here.</p><button id="choose-reference" type="button" disabled>Choose reference</button></section>
|
||||
<form id="compose"><label for="prompt">Message</label><textarea id="prompt" rows="3" maxlength="4000">Summarize this Note in one sentence.</textarea><div class="actions"><button id="send" type="submit" disabled>Send</button><button id="stop" type="button" disabled>Stop</button><button id="refresh" type="button" disabled>Refresh model</button><span id="status" role="status" aria-live="polite">Connecting...</span></div></form>
|
||||
<section class="card response"><label>Response</label><pre id="response" aria-live="polite">Your response appears here.</pre><p id="usage" class="hint"></p></section>
|
||||
<footer id="qa"></footer>
|
||||
</main>
|
||||
|
||||
@@ -5,43 +5,96 @@ const invoke = (operation, params = {}) => window.netcattyView.executeCommand(`c
|
||||
let chat;
|
||||
let turn;
|
||||
let generation = 0;
|
||||
let closed = false;
|
||||
const references = new Map();
|
||||
function busy(value) {
|
||||
element('send').disabled = value || !chat;
|
||||
element('stop').disabled = !value;
|
||||
element('refresh').disabled = value;
|
||||
element('choose-reference').disabled = value;
|
||||
element('context').disabled = value;
|
||||
element('prompt').disabled = value;
|
||||
}
|
||||
async function replaceChat(run) {
|
||||
const previous = chat; chat = undefined;
|
||||
if (previous) await invoke('close', { chatHandle: previous.chatHandle }).catch(() => {});
|
||||
if (closed || run !== generation) return false;
|
||||
const opened = await invoke('open');
|
||||
if (closed || run !== generation) {
|
||||
await invoke('close', { chatHandle: opened.chatHandle }).catch(() => {});
|
||||
return false;
|
||||
}
|
||||
chat = opened;
|
||||
element('model').textContent = chat.model;
|
||||
return true;
|
||||
}
|
||||
async function selectReferences(run) {
|
||||
const summaries = await invoke('contexts');
|
||||
if (closed || run !== generation) return false;
|
||||
references.clear();
|
||||
const placeholder = document.createElement('option');
|
||||
placeholder.value = ''; placeholder.textContent = 'No reference';
|
||||
element('context').replaceChildren(placeholder);
|
||||
for (const reference of summaries) {
|
||||
if (reference.scope !== 'note') continue;
|
||||
references.set(reference.handle, { handle: reference.handle, version: reference.version, expiresAt: reference.expiresAt });
|
||||
const option = document.createElement('option');
|
||||
option.value = reference.handle; option.textContent = reference.title;
|
||||
element('context').append(option);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
async function initialize() {
|
||||
const run = ++generation;
|
||||
busy(true);
|
||||
try {
|
||||
chat = await invoke('open');
|
||||
element('model').textContent = chat.model;
|
||||
const summaries = await invoke('contexts');
|
||||
for (const reference of summaries) {
|
||||
if (reference.scope !== 'note') continue;
|
||||
references.set(reference.handle, { handle: reference.handle, version: reference.version });
|
||||
const option = document.createElement('option');
|
||||
option.value = reference.handle; option.textContent = reference.title;
|
||||
element('context').append(option);
|
||||
}
|
||||
if (!await replaceChat(run)) return;
|
||||
if (!await selectReferences(run)) return;
|
||||
element('status').textContent = 'Ready';
|
||||
busy(false);
|
||||
} catch {
|
||||
if (run !== generation) return;
|
||||
const failedChat = chat; chat = undefined;
|
||||
if (failedChat) await invoke('close', { chatHandle: failedChat.chatHandle }).catch(() => {});
|
||||
element('status').textContent = 'Choose a model and grant access in Netcatty.';
|
||||
}
|
||||
if (run === generation) element('status').textContent = 'Choose a model and grant access in Netcatty, then refresh the model.';
|
||||
} finally { if (run === generation) busy(false); }
|
||||
}
|
||||
element('refresh').addEventListener('click', async () => {
|
||||
if (closed || element('refresh').disabled) return;
|
||||
const run = ++generation;
|
||||
busy(true); element('status').textContent = 'Refreshing model...';
|
||||
try {
|
||||
if (await replaceChat(run)) element('status').textContent = 'Ready';
|
||||
} catch {
|
||||
if (run === generation) element('status').textContent = 'Choose a model and grant access in Netcatty, then refresh the model.';
|
||||
} finally { if (run === generation) busy(false); }
|
||||
});
|
||||
element('choose-reference').addEventListener('click', async () => {
|
||||
if (closed || element('choose-reference').disabled) return;
|
||||
const run = ++generation;
|
||||
busy(true); element('status').textContent = 'Choosing reference...';
|
||||
try {
|
||||
if (await selectReferences(run)) element('status').textContent = 'References updated. Select a reference, then Send.';
|
||||
} catch {
|
||||
if (run === generation) element('status').textContent = 'Reference selection ended. Choose reference again when access is available.';
|
||||
} finally { if (run === generation) busy(false); }
|
||||
});
|
||||
element('compose').addEventListener('submit', async (event) => {
|
||||
event.preventDefault();
|
||||
if (!chat || element('send').disabled) return;
|
||||
const reference = references.get(element('context').value);
|
||||
if (reference && reference.expiresAt <= Date.now()) {
|
||||
element('status').textContent = 'Selected reference expired. Choose reference again, then Send.';
|
||||
return;
|
||||
}
|
||||
const run = ++generation;
|
||||
busy(true); element('status').textContent = 'Streaming...';
|
||||
element('response').textContent = ''; element('usage').textContent = '';
|
||||
try {
|
||||
const reference = references.get(element('context').value);
|
||||
// No inference has been requested yet, so an expired session can be
|
||||
// replaced before this explicit Send. Never retry a failed send/read.
|
||||
if (chat.expiresAt <= Date.now() && !await replaceChat(run)) return;
|
||||
const started = await invoke('send', { chatHandle: chat.chatHandle, prompt: element('prompt').value,
|
||||
contexts: reference ? [reference] : [] });
|
||||
contexts: reference ? [{ handle: reference.handle, version: reference.version }] : [] });
|
||||
if (run !== generation) { await invoke('stop', { turnHandle: started.turnHandle }); return; }
|
||||
turn = started.turnHandle;
|
||||
for (;;) {
|
||||
@@ -53,29 +106,31 @@ element('compose').addEventListener('submit', async (event) => {
|
||||
}
|
||||
element('status').textContent = 'Complete';
|
||||
} catch {
|
||||
if (run === generation) element('status').textContent = 'Request ended. Check access or try again.';
|
||||
if (run === generation) element('status').textContent = 'Request ended. Check access, choose reference again, or refresh the model before sending again.';
|
||||
} finally {
|
||||
if (run === generation) { turn = undefined; busy(false); }
|
||||
}
|
||||
});
|
||||
element('stop').addEventListener('click', async () => {
|
||||
++generation;
|
||||
if (element('stop').disabled) return;
|
||||
const run = ++generation;
|
||||
const stoppedTurn = turn; turn = undefined;
|
||||
element('status').textContent = 'Stopping...';
|
||||
element('status').textContent = 'Stopping...'; element('stop').disabled = true;
|
||||
try {
|
||||
if (stoppedTurn) await invoke('stop', { turnHandle: stoppedTurn });
|
||||
else if (chat) {
|
||||
await invoke('close', { chatHandle: chat.chatHandle });
|
||||
chat = await invoke('open');
|
||||
const stoppedChat = chat; chat = undefined;
|
||||
await invoke('close', { chatHandle: stoppedChat.chatHandle });
|
||||
}
|
||||
}
|
||||
finally { element('status').textContent = 'Stopped'; busy(false); }
|
||||
} catch { /* Closing a revoked session may already have stopped the turn. */ }
|
||||
finally { if (run === generation) { element('status').textContent = 'Stopped'; busy(false); } }
|
||||
});
|
||||
// QA supplies this text through the regular view message surface.
|
||||
window.netcattyView.onDidReceiveMessage((message) => {
|
||||
if (message?.qaFixture === true) element('qa').textContent = 'Isolated QA · deterministic local provider · no model inference or paid API';
|
||||
});
|
||||
window.addEventListener('pagehide', () => {
|
||||
closed = true; ++generation;
|
||||
if (turn) invoke('stop', { turnHandle: turn }).catch(() => {});
|
||||
if (chat) invoke('close', { chatHandle: chat.chatHandle }).catch(() => {});
|
||||
});
|
||||
|
||||
@@ -36,7 +36,10 @@ Package identity and permission scope:
|
||||
- Optional permission: `network.stream`, resource exactly
|
||||
`tcp://mysql-rfam-public.ebi.ac.uk:4497`.
|
||||
- Five-file package: 11,003 bytes. Two builds matched byte for byte with SHA-256
|
||||
`a9d56b69a4d2c587a4712d1b2fe395c381bcf8b15e34d476c4ba8b5ca967c35a`.
|
||||
`aaca586669db454e91d61487e576ed524d1b7190eed182bc2cd0035e59caa0d3`.
|
||||
This is the dedicated acceptance candidate's package, rebuilt with its SDK.
|
||||
The earlier #3993 archive had a different hash; do not reuse an earlier
|
||||
artifact's authorization for this package.
|
||||
- Expected result after a separately authorized compatible run:
|
||||
`{"columns":["value"],"rows":[["1"]]}`.
|
||||
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
# Host action sample
|
||||
|
||||
The sample adds four commands to the command palette. It runs as an offline,
|
||||
sandboxed browser plugin and requests no network, filesystem, or credential access.
|
||||
|
||||
`select` asks Netcatty to share explicitly chosen current hosts. The result contains
|
||||
saved labels, addresses, usernames, groups, tags, and metadata-only target references.
|
||||
Group results contain the chosen current members and never include future members.
|
||||
|
||||
`create` opens a host-owned SSH draft for `synthetic.invalid`. `edit` proposes a
|
||||
label change for the first chosen host. Netcatty displays editable fields and the
|
||||
normalized field diff before either action saves.
|
||||
|
||||
`connect` asks Netcatty to confirm a saved-host connection. The confirmation shows
|
||||
whether saved scripts can run. The acknowledgement contains no session identifier
|
||||
or terminal data. Selecting metadata never authorizes a connection by itself.
|
||||
|
||||
All sample test resources use synthetic hostnames. Native acceptance must use
|
||||
synthetic hosts and must not establish a real host connection.
|
||||
@@ -0,0 +1,31 @@
|
||||
// This offline BrowserWindow plugin has no filesystem, network or credential
|
||||
// permissions. Netcatty owns every picker, field diff and connection prompt.
|
||||
export default {
|
||||
activate(context) {
|
||||
const select = () => context.vault.selectHosts({ title: 'Share saved host names, addresses, usernames, groups and tags' });
|
||||
const commands = {
|
||||
select,
|
||||
create: () => context.vault.requestHostDraft({ draft: {
|
||||
label: 'Synthetic SSH host', hostname: 'synthetic.invalid', username: 'example', group: 'Samples', tags: ['synthetic'],
|
||||
} }),
|
||||
edit: async () => {
|
||||
const selection = await select();
|
||||
if (selection.cancelled || selection.hosts.length === 0) return { cancelled: true };
|
||||
const host = selection.hosts[0];
|
||||
return context.vault.requestHostDraft({
|
||||
target: host.target, expectedRevision: host.target.revision,
|
||||
draft: { label: `${host.metadata.label} (reviewed)` },
|
||||
});
|
||||
},
|
||||
connect: async () => {
|
||||
const selection = await select();
|
||||
if (selection.cancelled || selection.hosts.length === 0) return { cancelled: true };
|
||||
const target = selection.hosts[0].target;
|
||||
return context.vault.requestConnect({ target, expectedRevision: target.revision });
|
||||
},
|
||||
};
|
||||
for (const [name, execute] of Object.entries(commands)) {
|
||||
context.subscriptions.add(context.commands.registerCommand(`com.netcatty.host-actions.${name}`, execute));
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"manifestVersion": 1,
|
||||
"id": "com.netcatty.host-actions",
|
||||
"name": "host-actions",
|
||||
"displayName": "Host action sample",
|
||||
"description": "Explicit host metadata sharing, reviewed SSH drafts, and confirmed saved-host connections.",
|
||||
"version": "0.1.0",
|
||||
"publisher": "netcatty",
|
||||
"license": "GPL-3.0-or-later",
|
||||
"engines": { "netcatty": ">=0.0.0", "api": ">=0.1.0-internal <0.2.0" },
|
||||
"features": { "required": ["vault.host-actions", "commands.interactive"] },
|
||||
"main": { "browser": "browser.js" },
|
||||
"activationEvents": [
|
||||
"onCommand:com.netcatty.host-actions.select",
|
||||
"onCommand:com.netcatty.host-actions.create",
|
||||
"onCommand:com.netcatty.host-actions.edit",
|
||||
"onCommand:com.netcatty.host-actions.connect"
|
||||
],
|
||||
"permissions": {
|
||||
"required": ["commands", "menus"],
|
||||
"optional": ["vault.metadata", "vault.write", "ui.interact"]
|
||||
},
|
||||
"contributes": {
|
||||
"commands": [
|
||||
{ "id": "com.netcatty.host-actions.select", "title": "Host sample: share selected metadata", "interactive": true },
|
||||
{ "id": "com.netcatty.host-actions.create", "title": "Host sample: review a synthetic SSH draft", "interactive": true },
|
||||
{ "id": "com.netcatty.host-actions.edit", "title": "Host sample: review a host label change", "interactive": true },
|
||||
{ "id": "com.netcatty.host-actions.connect", "title": "Host sample: confirm a saved-host connection", "interactive": true }
|
||||
],
|
||||
"menus": [
|
||||
{ "command": "com.netcatty.host-actions.select", "location": "commandPalette" },
|
||||
{ "command": "com.netcatty.host-actions.create", "location": "commandPalette" },
|
||||
{ "command": "com.netcatty.host-actions.edit", "location": "commandPalette" },
|
||||
{ "command": "com.netcatty.host-actions.connect", "location": "commandPalette" }
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -18,9 +18,14 @@ sha256sum /tmp/terminal-selection-draft.ncpkg
|
||||
|
||||
The two-file package is `com.netcatty.terminal-selection-draft@0.1.0`.
|
||||
Its SHA-256 is
|
||||
`f3b8ac268edbf616effe76110f372d2249e414675d877020a074c89f03c65950`.
|
||||
`4c9c2388b7f891156192a0b5ab7b23291beba3555e7ec8cf24fbe2dfe1242d5e`.
|
||||
Two independent builds match. Its content SHA-256 is
|
||||
`ede2b623a235771f7b4897155bcfd02e4b289295bea201bca14b6046476b41bf`.
|
||||
`99797d8679f34b96580076f922cbec067e511adc0f4ccf396ed129206acb6c4c`.
|
||||
These are the unified acceptance candidate's hashes after rebuilding with its
|
||||
combined SDK. The owner-head `8e7c28fe` archive had a different hash; use the
|
||||
current [acceptance record](../../../docs/plugin-platform/acceptance-candidate.md)
|
||||
and exact candidate commit for native preparation. Package validation is not
|
||||
permission activation or native acceptance.
|
||||
|
||||
Required permissions are `commands` and `menus`. Optional permissions are
|
||||
`terminal.selection.read` and `terminal.input.propose`, each limited to one
|
||||
|
||||
Vendored
+16
-1
@@ -447,7 +447,22 @@ declare global {
|
||||
| NetcattyPluginAuthenticationChallengeOpenEvent
|
||||
| NetcattyPluginAuthenticationChallengeCancelEvent;
|
||||
|
||||
interface NetcattyPluginLogStatus {
|
||||
saveToDisk: boolean;
|
||||
verbose: boolean;
|
||||
droppedRecords: number;
|
||||
writeErrors: number;
|
||||
pendingBytes: number;
|
||||
maxBytes: number;
|
||||
retentionDays: number;
|
||||
}
|
||||
|
||||
interface NetcattyBridge {
|
||||
getPluginLogStatus?(): Promise<NetcattyPluginLogStatus>;
|
||||
configurePluginLogs?(settings: { saveToDisk: boolean; verbose: boolean }): Promise<NetcattyPluginLogStatus>;
|
||||
openPluginLogFolder?(): Promise<NetcattyPluginLogStatus>;
|
||||
exportPluginLogs?(): Promise<NetcattyPluginLogStatus>;
|
||||
clearPluginLogs?(): Promise<NetcattyPluginLogStatus>;
|
||||
getPluginRuntimeStatus?(): Promise<NetcattyPluginRuntimeStatus>;
|
||||
getPluginSourceBinding?(pluginId: string): Promise<NetcattyPluginSource | null>;
|
||||
resetPluginSource?(pluginId: string, expectedSourceId: string): Promise<void>;
|
||||
@@ -462,7 +477,7 @@ declare global {
|
||||
respondPluginAIHost?(response: import("@netcatty/plugin-contract").PluginAIHostResponse): Promise<boolean>;
|
||||
onPluginAIHostRequest?(callback: (request: import("@netcatty/plugin-contract").PluginAIHostRequest) => void): () => void;
|
||||
setPluginInteractionReady?(ready: boolean): Promise<boolean>;
|
||||
respondPluginInteraction?(response: import("@netcatty/plugin-contract").HostInteractionResponse): Promise<boolean>;
|
||||
respondPluginInteraction?(response: import("@netcatty/plugin-contract").HostInteractionResponse): Promise<import("@netcatty/plugin-contract").HostInteractionReceipt>;
|
||||
onPluginInteraction?(callback: (event: import("@netcatty/plugin-contract").HostInteractionEvent) => void): () => void;
|
||||
listPlugins?(): Promise<NetcattyInstalledPlugin[]>;
|
||||
getPluginPermissions?(pluginId: string): Promise<NetcattyPluginPermissionSnapshot>;
|
||||
|
||||
@@ -101,7 +101,7 @@ test('broker -> existing model factory -> existing host HTTP transport -> loopba
|
||||
const contexts = new SelectedContextAuthority({ resolveDataOwner });
|
||||
const note = await contexts.share({ scope: 'note', version: '1', title: 'Synthetic Note', isCurrent: () => true,
|
||||
readText: () => 'Synthetic retry plan: add retry button.' }, context);
|
||||
const broker = new ModelInvocationBroker({ resolveDataOwner, contexts, selectModel: () => model });
|
||||
const broker = new ModelInvocationBroker({ resolveDataOwner, resolveBudgetOwner: () => 'synthetic-source-owner', contexts, selectModel: () => model });
|
||||
t.after(() => broker.shutdown());
|
||||
const chat = await broker.open(context);
|
||||
const turn = await broker.start({ chatHandle: chat.chatHandle, prompt: 'Summarize this Note.',
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { checkPluginUpdates } from './pluginUpdateCheck';
|
||||
|
||||
const official: NetcattyPluginSource = { id: 'official', kind: 'official', name: 'Official source', origin: 'https://plugins.example.com', enabled: true };
|
||||
const third: NetcattyPluginSource = { id: 'third-party:synthetic', kind: 'third-party', name: 'Synthetic source', origin: 'https://synthetic.example.com', enabled: true };
|
||||
const local: NetcattyPluginSource = { id: 'local', kind: 'local', name: 'Local import', origin: null };
|
||||
const plugin = (id: string, version: string, source?: NetcattyPluginSource): NetcattyInstalledPlugin => ({ id, activeVersion: version, source, archiveSha256: 'a'.repeat(64), enabled: false, manifest: null, runtime: { status: 'stopped', kind: null, lastError: null, quarantinedAt: null } });
|
||||
const entry = (id: string, version: string) => ({ id, version, artifact: { url: 'https://plugins.example.com/synthetic.ncpkg', sha256: 'b'.repeat(64) } }) as NetcattyPluginCatalogEntry;
|
||||
const catalog = (entries: NetcattyPluginCatalogEntry[]): NetcattyPluginCatalogSnapshot => ({ source: 'registry', error: null, entries });
|
||||
|
||||
test('update checks compare semantic versions within each host-bound source and never mutate or fetch directly', async () => {
|
||||
const installed = [plugin('com.example.alpha', '1.9.0', official), plugin('com.example.beta', '2.0.0-beta.1', third), plugin('com.example.local', '1.0.0', local), plugin('com.example.unknown', '1.0.0')];
|
||||
const requested: string[] = [];
|
||||
let mutations = 0;
|
||||
const bridge = {
|
||||
async listPlugins() { return installed; }, async listPluginSources() { return [official, third]; },
|
||||
async listPluginCatalog(sourceId?: string) {
|
||||
requested.push(sourceId!);
|
||||
return sourceId === official.id ? catalog([entry(installed[0].id, '1.10.0'), entry(installed[1].id, '99.0.0')]) : catalog([entry(installed[1].id, '2.0.0')]);
|
||||
},
|
||||
async installCatalogPlugin() { mutations++; }, async setPluginEnabled() { mutations++; }, async checkForUpdates() { mutations++; },
|
||||
};
|
||||
const previous = globalThis.fetch;
|
||||
globalThis.fetch = async () => { throw new Error('Direct fetching is forbidden'); };
|
||||
try {
|
||||
const result = await checkPluginUpdates(bridge);
|
||||
assert.deepEqual(requested, [official.id, third.id]);
|
||||
assert.equal(result.checked, 2);
|
||||
assert.equal(result.skipped, 2);
|
||||
assert.equal(result.failedSources, 0);
|
||||
assert.deepEqual(result.updates, [
|
||||
{ pluginId: installed[0].id, sourceId: official.id, installedVersion: '1.9.0', availableVersion: '1.10.0' },
|
||||
{ pluginId: installed[1].id, sourceId: third.id, installedVersion: '2.0.0-beta.1', availableVersion: '2.0.0' },
|
||||
]);
|
||||
assert.equal(mutations, 0);
|
||||
} finally { globalThis.fetch = previous; }
|
||||
});
|
||||
|
||||
test('cached, failed, disabled and unpublished sources do not claim an update check succeeded', async () => {
|
||||
const disabled = { ...third, enabled: false };
|
||||
const installed = [plugin('com.example.alpha', '1.0.0', official), plugin('com.example.beta', '1.0.0', disabled)];
|
||||
const calls: string[] = [];
|
||||
const result = await checkPluginUpdates({ async listPlugins() { return installed; }, async listPluginSources() { return [official, disabled]; },
|
||||
async listPluginCatalog(sourceId) { calls.push(sourceId!); return { source: 'cached', error: 'synthetic offline', entries: [entry(installed[0].id, '2.0.0')] }; } });
|
||||
assert.deepEqual(calls, [official.id]);
|
||||
assert.equal(result.checked, 0); assert.equal(result.skipped, 2); assert.equal(result.failedSources, 1); assert.deepEqual(result.updates, []);
|
||||
const unpublished = await checkPluginUpdates({ async listPlugins() { return [installed[0]]; }, async listPluginSources() { return [official]; }, async listPluginCatalog() { return catalog([{ ...entry(installed[0].id, '2.0.0'), artifact: undefined }]); } });
|
||||
assert.equal(unpublished.checked, 0); assert.equal(unpublished.skipped, 1);
|
||||
});
|
||||
|
||||
test('source or installed package replacement during an asynchronous catalog read invalidates results', async () => {
|
||||
for (const change of ['source', 'hash', 'disabled']) {
|
||||
const before = plugin('com.example.alpha', '1.0.0', official);
|
||||
let installed = [before]; let sources = [official];
|
||||
const result = await checkPluginUpdates({ async listPlugins() { return installed; }, async listPluginSources() { return sources; }, async listPluginCatalog() {
|
||||
if (change === 'source') { installed = [{ ...before, source: third }]; sources = [third]; }
|
||||
if (change === 'hash') installed = [{ ...before, archiveSha256: 'c'.repeat(64) }];
|
||||
if (change === 'disabled') sources = [{ ...official, enabled: false }];
|
||||
return catalog([entry(before.id, '2.0.0')]);
|
||||
} });
|
||||
assert.deepEqual(result.updates, [], change); assert.equal(result.checked, 0, change); assert.equal(result.skipped, 1, change);
|
||||
}
|
||||
});
|
||||
|
||||
test('a failing source leaves other fresh source results intact and missing APIs fail explicitly', async () => {
|
||||
const installed = [plugin('com.example.alpha', '1.0.0', official), plugin('com.example.beta', '1.0.0', third)];
|
||||
const result = await checkPluginUpdates({ async listPlugins() { return installed; }, async listPluginSources() { return [official, third]; }, async listPluginCatalog(id) { if (id === third.id) throw new Error('synthetic source unavailable'); return catalog([entry(installed[0].id, '1.1.0')]); } });
|
||||
assert.equal(result.failedSources, 1); assert.equal(result.checked, 1); assert.equal(result.skipped, 1); assert.equal(result.updates[0].sourceId, official.id);
|
||||
await assert.rejects(checkPluginUpdates({}), /unavailable/);
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import { gt, valid } from 'semver';
|
||||
|
||||
export interface PluginUpdateCandidate {
|
||||
pluginId: string;
|
||||
sourceId: string;
|
||||
installedVersion: string;
|
||||
availableVersion: string;
|
||||
}
|
||||
export interface PluginUpdateCheckResult {
|
||||
checkedAt: number;
|
||||
checked: number;
|
||||
updates: PluginUpdateCandidate[];
|
||||
skipped: number;
|
||||
failedSources: number;
|
||||
}
|
||||
|
||||
type UpdateBridge = Pick<NetcattyBridge, 'listPlugins' | 'listPluginSources' | 'listPluginCatalog'>;
|
||||
const sameSource = (left?: NetcattyPluginSource, right?: NetcattyPluginSource) => Boolean(left && right
|
||||
&& left.id === right.id && left.kind === right.kind && left.origin === right.origin);
|
||||
const sameInstallation = (left: NetcattyInstalledPlugin, right?: NetcattyInstalledPlugin) => Boolean(right
|
||||
&& left.activeVersion === right.activeVersion && left.archiveSha256 === right.archiveSha256
|
||||
&& sameSource(left.source, right.source));
|
||||
|
||||
/** Read-only: host catalogs own fetching, source consent, bounds and cache status. */
|
||||
export async function checkPluginUpdates(bridge: UpdateBridge): Promise<PluginUpdateCheckResult> {
|
||||
if (!bridge.listPlugins || !bridge.listPluginSources || !bridge.listPluginCatalog) throw new Error('Plugin update checks are unavailable');
|
||||
const installed = await bridge.listPlugins();
|
||||
const sources = await bridge.listPluginSources();
|
||||
const groups = new Map<string, NetcattyInstalledPlugin[]>();
|
||||
for (const plugin of installed) {
|
||||
const source = sources.find((item) => sameSource(plugin.source, item));
|
||||
// Local/unknown/removed/disabled bindings never fall back to another market.
|
||||
if (!plugin.activeVersion || !valid(plugin.activeVersion) || !source || source.kind === 'local' || source.enabled === false) continue;
|
||||
groups.set(source.id, [...(groups.get(source.id) ?? []), plugin]);
|
||||
}
|
||||
const checked = new Set<string>();
|
||||
const candidates: PluginUpdateCandidate[] = [];
|
||||
let failedSources = 0;
|
||||
// At most one catalog read at a time; the host supports at most 32 custom sources.
|
||||
for (const [sourceId, plugins] of groups) {
|
||||
try {
|
||||
const catalog = await bridge.listPluginCatalog(sourceId);
|
||||
if (catalog.error || catalog.source !== 'registry') { failedSources++; continue; }
|
||||
for (const plugin of plugins) {
|
||||
const entry = catalog.entries.find((item) => item.id === plugin.id && item.artifact);
|
||||
if (!entry || !valid(entry.version)) continue;
|
||||
checked.add(plugin.id);
|
||||
if (gt(entry.version, plugin.activeVersion!)) candidates.push({
|
||||
pluginId: plugin.id, sourceId, installedVersion: plugin.activeVersion!, availableVersion: entry.version,
|
||||
});
|
||||
}
|
||||
} catch { failedSources++; }
|
||||
}
|
||||
// Revalidate host-owned source/package binding after every asynchronous read.
|
||||
// This publishes metadata only; installation stays in the existing manager flow.
|
||||
const current = await bridge.listPlugins();
|
||||
const currentSources = await bridge.listPluginSources();
|
||||
for (const plugin of installed) {
|
||||
const source = currentSources.find((item) => item.id === plugin.source?.id);
|
||||
if (!sameInstallation(plugin, current.find((item) => item.id === plugin.id)) || !sameSource(plugin.source, source) || source?.enabled === false) checked.delete(plugin.id);
|
||||
}
|
||||
const updates = candidates.filter((candidate) => checked.has(candidate.pluginId));
|
||||
return { checkedAt: Date.now(), checked: checked.size, updates, skipped: current.length - checked.size, failedSources };
|
||||
}
|
||||
Generated
+1
@@ -33,6 +33,7 @@
|
||||
"jsqr": "^1.4.0",
|
||||
"node-pty": "1.1.0",
|
||||
"re2js": "2.8.6",
|
||||
"semver": "^7.7.3",
|
||||
"serialport": "^13.0.0",
|
||||
"socks-proxy-agent": "^8.0.5",
|
||||
"ssh2-sftp-client": "^12.0.1",
|
||||
|
||||
@@ -98,6 +98,7 @@
|
||||
"jsqr": "^1.4.0",
|
||||
"node-pty": "1.1.0",
|
||||
"re2js": "2.8.6",
|
||||
"semver": "^7.7.3",
|
||||
"serialport": "^13.0.0",
|
||||
"socks-proxy-agent": "^8.0.5",
|
||||
"ssh2-sftp-client": "^12.0.1",
|
||||
|
||||
@@ -178,6 +178,565 @@
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"HostVaultFeature": {
|
||||
"const": "vault.host-actions"
|
||||
},
|
||||
"VaultHostRef": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"kind",
|
||||
"scope",
|
||||
"handle",
|
||||
"revision",
|
||||
"expiresAt"
|
||||
],
|
||||
"properties": {
|
||||
"kind": {
|
||||
"const": "host.selection"
|
||||
},
|
||||
"scope": {
|
||||
"const": "metadata"
|
||||
},
|
||||
"handle": {
|
||||
"type": "string",
|
||||
"minLength": 32,
|
||||
"maxLength": 256
|
||||
},
|
||||
"revision": {
|
||||
"type": "string",
|
||||
"maxLength": 128,
|
||||
"pattern": "^[^\\u0000-\\u001f\\u007f]*$",
|
||||
"minLength": 1
|
||||
},
|
||||
"expiresAt": {
|
||||
"$ref": "#/$defs/SafePositiveInteger"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"description": "Host-signed source/runtime/window-bound metadata selection. Identifies an exact saved host and revision; edit and connect require separate host confirmation and one-use action authority. AI text handles are not Host targets."
|
||||
},
|
||||
"VaultHostMetadata": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"label",
|
||||
"hostname",
|
||||
"username",
|
||||
"group",
|
||||
"tags",
|
||||
"protocol"
|
||||
],
|
||||
"properties": {
|
||||
"label": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 512
|
||||
},
|
||||
"hostname": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 1024
|
||||
},
|
||||
"username": {
|
||||
"type": "string",
|
||||
"maxLength": 512
|
||||
},
|
||||
"port": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 65535
|
||||
},
|
||||
"group": {
|
||||
"type": "string",
|
||||
"minLength": 0,
|
||||
"maxLength": 512
|
||||
},
|
||||
"tags": {
|
||||
"type": "array",
|
||||
"maxItems": 128,
|
||||
"uniqueItems": true,
|
||||
"items": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 256
|
||||
}
|
||||
},
|
||||
"protocol": {
|
||||
"type": "string",
|
||||
"maxLength": 199,
|
||||
"pattern": "^[^\\u0000-\\u001f\\u007f]*$",
|
||||
"minLength": 1
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"VaultHostCreateFields": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"hostname"
|
||||
],
|
||||
"properties": {
|
||||
"label": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 512
|
||||
},
|
||||
"hostname": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 1024
|
||||
},
|
||||
"username": {
|
||||
"type": "string",
|
||||
"maxLength": 512
|
||||
},
|
||||
"port": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 65535
|
||||
},
|
||||
"group": {
|
||||
"type": "string",
|
||||
"minLength": 0,
|
||||
"maxLength": 512
|
||||
},
|
||||
"tags": {
|
||||
"type": "array",
|
||||
"maxItems": 128,
|
||||
"uniqueItems": true,
|
||||
"items": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 256
|
||||
}
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"VaultHostEditFields": {
|
||||
"type": "object",
|
||||
"required": [],
|
||||
"properties": {
|
||||
"label": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 512
|
||||
},
|
||||
"group": {
|
||||
"type": "string",
|
||||
"minLength": 0,
|
||||
"maxLength": 512
|
||||
},
|
||||
"tags": {
|
||||
"type": "array",
|
||||
"maxItems": 128,
|
||||
"uniqueItems": true,
|
||||
"items": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 256
|
||||
}
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"minProperties": 1
|
||||
},
|
||||
"VaultSelectHostsParams": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"title"
|
||||
],
|
||||
"properties": {
|
||||
"title": {
|
||||
"type": "string",
|
||||
"maxLength": 128,
|
||||
"pattern": "^[^\\u0000-\\u001f\\u007f]*$",
|
||||
"minLength": 1
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"VaultHostDraftParams": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"required": [
|
||||
"draft"
|
||||
],
|
||||
"properties": {
|
||||
"draft": {
|
||||
"$ref": "#/$defs/VaultHostCreateFields"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"required": [
|
||||
"target",
|
||||
"expectedRevision",
|
||||
"draft"
|
||||
],
|
||||
"properties": {
|
||||
"target": {
|
||||
"$ref": "#/$defs/VaultHostRef"
|
||||
},
|
||||
"expectedRevision": {
|
||||
"type": "string",
|
||||
"maxLength": 128,
|
||||
"pattern": "^[^\\u0000-\\u001f\\u007f]*$",
|
||||
"minLength": 1
|
||||
},
|
||||
"draft": {
|
||||
"$ref": "#/$defs/VaultHostEditFields"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
]
|
||||
},
|
||||
"VaultHostConnectParams": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"target",
|
||||
"expectedRevision"
|
||||
],
|
||||
"properties": {
|
||||
"target": {
|
||||
"$ref": "#/$defs/VaultHostRef"
|
||||
},
|
||||
"expectedRevision": {
|
||||
"type": "string",
|
||||
"maxLength": 128,
|
||||
"pattern": "^[^\\u0000-\\u001f\\u007f]*$",
|
||||
"minLength": 1
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"VaultHostActionRequest": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"required": [
|
||||
"kind",
|
||||
"title"
|
||||
],
|
||||
"properties": {
|
||||
"kind": {
|
||||
"const": "vault.selectHosts"
|
||||
},
|
||||
"title": {
|
||||
"type": "string",
|
||||
"maxLength": 128,
|
||||
"pattern": "^[^\\u0000-\\u001f\\u007f]*$",
|
||||
"minLength": 1
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"required": [
|
||||
"kind",
|
||||
"draft"
|
||||
],
|
||||
"properties": {
|
||||
"kind": {
|
||||
"const": "vault.requestHostDraft"
|
||||
},
|
||||
"draft": {
|
||||
"$ref": "#/$defs/VaultHostCreateFields"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"required": [
|
||||
"kind",
|
||||
"target",
|
||||
"expectedRevision",
|
||||
"draft"
|
||||
],
|
||||
"properties": {
|
||||
"kind": {
|
||||
"const": "vault.requestHostDraft"
|
||||
},
|
||||
"target": {
|
||||
"$ref": "#/$defs/VaultHostRef"
|
||||
},
|
||||
"expectedRevision": {
|
||||
"type": "string",
|
||||
"maxLength": 128,
|
||||
"pattern": "^[^\\u0000-\\u001f\\u007f]*$",
|
||||
"minLength": 1
|
||||
},
|
||||
"draft": {
|
||||
"$ref": "#/$defs/VaultHostEditFields"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"required": [
|
||||
"kind",
|
||||
"target",
|
||||
"expectedRevision"
|
||||
],
|
||||
"properties": {
|
||||
"kind": {
|
||||
"const": "vault.requestConnect"
|
||||
},
|
||||
"target": {
|
||||
"$ref": "#/$defs/VaultHostRef"
|
||||
},
|
||||
"expectedRevision": {
|
||||
"type": "string",
|
||||
"maxLength": 128,
|
||||
"pattern": "^[^\\u0000-\\u001f\\u007f]*$",
|
||||
"minLength": 1
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
]
|
||||
},
|
||||
"VaultHostSelectedResources": {
|
||||
"description": "Trusted host-renderer reply only. Never accepted from a plugin request.",
|
||||
"type": "array",
|
||||
"maxItems": 50,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"resourceId",
|
||||
"revision",
|
||||
"label",
|
||||
"hostname",
|
||||
"username",
|
||||
"group",
|
||||
"tags",
|
||||
"protocol"
|
||||
],
|
||||
"properties": {
|
||||
"resourceId": {
|
||||
"type": "string",
|
||||
"maxLength": 256,
|
||||
"pattern": "^[^\\u0000-\\u001f\\u007f]*$",
|
||||
"minLength": 1
|
||||
},
|
||||
"revision": {
|
||||
"type": "string",
|
||||
"maxLength": 128,
|
||||
"pattern": "^[^\\u0000-\\u001f\\u007f]*$",
|
||||
"minLength": 1
|
||||
},
|
||||
"label": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 512
|
||||
},
|
||||
"hostname": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 1024
|
||||
},
|
||||
"username": {
|
||||
"type": "string",
|
||||
"maxLength": 512
|
||||
},
|
||||
"port": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 65535
|
||||
},
|
||||
"group": {
|
||||
"type": "string",
|
||||
"minLength": 0,
|
||||
"maxLength": 512
|
||||
},
|
||||
"tags": {
|
||||
"type": "array",
|
||||
"maxItems": 128,
|
||||
"uniqueItems": true,
|
||||
"items": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 256
|
||||
}
|
||||
},
|
||||
"protocol": {
|
||||
"type": "string",
|
||||
"maxLength": 199,
|
||||
"pattern": "^[^\\u0000-\\u001f\\u007f]*$",
|
||||
"minLength": 1
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"VaultHostSelectedResult": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"cancelled",
|
||||
"hosts",
|
||||
"groups"
|
||||
],
|
||||
"properties": {
|
||||
"cancelled": {
|
||||
"const": false
|
||||
},
|
||||
"hosts": {
|
||||
"type": "array",
|
||||
"maxItems": 50,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"target",
|
||||
"metadata"
|
||||
],
|
||||
"properties": {
|
||||
"target": {
|
||||
"$ref": "#/$defs/VaultHostRef"
|
||||
},
|
||||
"metadata": {
|
||||
"$ref": "#/$defs/VaultHostMetadata"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"groups": {
|
||||
"type": "array",
|
||||
"maxItems": 50,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"path",
|
||||
"members"
|
||||
],
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string",
|
||||
"maxLength": 512,
|
||||
"pattern": "^[^\\u0000-\\u001f\\u007f]*$",
|
||||
"minLength": 1
|
||||
},
|
||||
"members": {
|
||||
"type": "array",
|
||||
"maxItems": 50,
|
||||
"items": {
|
||||
"$ref": "#/$defs/VaultHostRef"
|
||||
}
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"VaultHostSelectionResult": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"required": [
|
||||
"cancelled"
|
||||
],
|
||||
"properties": {
|
||||
"cancelled": {
|
||||
"const": true
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/VaultHostSelectedResult"
|
||||
}
|
||||
]
|
||||
},
|
||||
"VaultHostActionAccepted": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"cancelled",
|
||||
"status"
|
||||
],
|
||||
"properties": {
|
||||
"cancelled": {
|
||||
"const": false
|
||||
},
|
||||
"status": {
|
||||
"enum": [
|
||||
"saved",
|
||||
"requested"
|
||||
]
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"VaultHostActionResult": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"required": [
|
||||
"cancelled"
|
||||
],
|
||||
"properties": {
|
||||
"cancelled": {
|
||||
"const": true
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/VaultHostActionAccepted"
|
||||
}
|
||||
]
|
||||
},
|
||||
"VaultHostLocalTarget": {
|
||||
"description": "Trusted host renderer target only; resource IDs never cross the plugin API.",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"resourceId",
|
||||
"revision"
|
||||
],
|
||||
"properties": {
|
||||
"resourceId": {
|
||||
"type": "string",
|
||||
"maxLength": 256,
|
||||
"pattern": "^[^\\u0000-\\u001f\\u007f]*$",
|
||||
"minLength": 1
|
||||
},
|
||||
"revision": {
|
||||
"type": "string",
|
||||
"maxLength": 128,
|
||||
"pattern": "^[^\\u0000-\\u001f\\u007f]*$",
|
||||
"minLength": 1
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"HostInteractionReceipt": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "boolean"
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"required": [
|
||||
"completionToken"
|
||||
],
|
||||
"properties": {
|
||||
"completionToken": {
|
||||
"type": "string",
|
||||
"minLength": 32,
|
||||
"maxLength": 256
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
]
|
||||
},
|
||||
"HostInteractionLimits": {
|
||||
"const": {
|
||||
"textBytes": 4096,
|
||||
@@ -383,6 +942,12 @@
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/VaultHostSelectedResult"
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/VaultHostActionAccepted"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -408,13 +973,32 @@
|
||||
"enum": [
|
||||
"cancel",
|
||||
"preview",
|
||||
"submit"
|
||||
"submit",
|
||||
"commit",
|
||||
"complete",
|
||||
"fail"
|
||||
]
|
||||
},
|
||||
"value": {
|
||||
"type": "string",
|
||||
"maxLength": 4096,
|
||||
"pattern": "^[^\\u0000]*$"
|
||||
},
|
||||
"resources": {
|
||||
"$ref": "#/$defs/VaultHostSelectedResources"
|
||||
},
|
||||
"draft": {
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/$defs/VaultHostCreateFields"
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/VaultHostEditFields"
|
||||
}
|
||||
]
|
||||
},
|
||||
"scriptsRun": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
@@ -478,11 +1062,19 @@
|
||||
"phase": {
|
||||
"enum": [
|
||||
"request",
|
||||
"preview"
|
||||
"preview",
|
||||
"commit"
|
||||
]
|
||||
},
|
||||
"request": {
|
||||
"$ref": "#/$defs/HostInteractionRequest"
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/$defs/HostInteractionRequest"
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/VaultHostActionRequest"
|
||||
}
|
||||
]
|
||||
},
|
||||
"previewText": {
|
||||
"type": "string",
|
||||
@@ -503,6 +1095,19 @@
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 160
|
||||
},
|
||||
"vaultTarget": {
|
||||
"$ref": "#/$defs/VaultHostLocalTarget"
|
||||
},
|
||||
"reviewedDraft": {
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/$defs/VaultHostCreateFields"
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/VaultHostEditFields"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
|
||||
@@ -228,21 +228,30 @@ export type HostInteractionPrompt = {
|
||||
pluginId: PluginId;
|
||||
pluginVersion: SemanticVersion;
|
||||
expiresAt: SafePositiveInteger;
|
||||
phase: "request" | "preview";
|
||||
request: HostInteractionRequest;
|
||||
phase: "request" | "preview" | "commit";
|
||||
request: (HostInteractionRequest) | (VaultHostActionRequest);
|
||||
previewText?: string;
|
||||
terminalSessionId?: string;
|
||||
terminalRendererId?: string;
|
||||
terminalLabel?: string;
|
||||
vaultTarget?: VaultHostLocalTarget;
|
||||
reviewedDraft?: (VaultHostCreateFields) | (VaultHostEditFields);
|
||||
};
|
||||
|
||||
export type HostInteractionReceipt = (boolean) | ({
|
||||
completionToken: string;
|
||||
});
|
||||
|
||||
export type HostInteractionRequest = (HostQuickPickRequest) | (HostInputRequest) | (HostClipboardReadRequest) | (HostClipboardWriteRequest) | (HostTerminalSelectionReadRequest) | (HostTerminalInputProposalRequest);
|
||||
|
||||
export type HostInteractionResponse = {
|
||||
requestId: string;
|
||||
actionToken: string;
|
||||
action: "cancel" | "preview" | "submit";
|
||||
action: "cancel" | "preview" | "submit" | "commit" | "complete" | "fail";
|
||||
value?: string;
|
||||
resources?: VaultHostSelectedResources;
|
||||
draft?: (VaultHostCreateFields) | (VaultHostEditFields);
|
||||
scriptsRun?: boolean;
|
||||
};
|
||||
|
||||
export type HostInteractionResult = ({
|
||||
@@ -250,7 +259,7 @@ export type HostInteractionResult = ({
|
||||
}) | ({
|
||||
cancelled: false;
|
||||
value: (string) | (true) | (SharedTerminalSelection);
|
||||
});
|
||||
}) | (VaultHostSelectedResult) | (VaultHostActionAccepted);
|
||||
|
||||
export type HostQuickPickItem = {
|
||||
id: string;
|
||||
@@ -280,6 +289,8 @@ export type HostTerminalSelectionReadRequest = {
|
||||
|
||||
export type HostTerminalTarget = (unknown);
|
||||
|
||||
export type HostVaultFeature = "vault.host-actions";
|
||||
|
||||
export type IconReference = (ThemeIcon) | (PackageIcon);
|
||||
|
||||
export type ImporterDetectPayload = {
|
||||
@@ -1398,8 +1409,117 @@ export type ThemePackSelection = {
|
||||
|
||||
export type ThemePackVariant = "light" | "dark";
|
||||
|
||||
export type VaultHostActionAccepted = {
|
||||
cancelled: false;
|
||||
status: "saved" | "requested";
|
||||
};
|
||||
|
||||
export type VaultHostActionRequest = ({
|
||||
kind: "vault.selectHosts";
|
||||
title: string;
|
||||
}) | ({
|
||||
kind: "vault.requestHostDraft";
|
||||
draft: VaultHostCreateFields;
|
||||
}) | ({
|
||||
kind: "vault.requestHostDraft";
|
||||
target: VaultHostRef;
|
||||
expectedRevision: string;
|
||||
draft: VaultHostEditFields;
|
||||
}) | ({
|
||||
kind: "vault.requestConnect";
|
||||
target: VaultHostRef;
|
||||
expectedRevision: string;
|
||||
});
|
||||
|
||||
export type VaultHostActionResult = ({
|
||||
cancelled: true;
|
||||
}) | (VaultHostActionAccepted);
|
||||
|
||||
export type VaultHostConnectParams = {
|
||||
target: VaultHostRef;
|
||||
expectedRevision: string;
|
||||
};
|
||||
|
||||
export type VaultHostCreateFields = {
|
||||
label?: string;
|
||||
hostname: string;
|
||||
username?: string;
|
||||
port?: number;
|
||||
group?: string;
|
||||
tags?: Array<string>;
|
||||
};
|
||||
|
||||
export type VaultHostDraftParams = ({
|
||||
draft: VaultHostCreateFields;
|
||||
}) | ({
|
||||
target: VaultHostRef;
|
||||
expectedRevision: string;
|
||||
draft: VaultHostEditFields;
|
||||
});
|
||||
|
||||
export type VaultHostEditFields = {
|
||||
label?: string;
|
||||
group?: string;
|
||||
tags?: Array<string>;
|
||||
};
|
||||
|
||||
export type VaultHostLocalTarget = {
|
||||
resourceId: string;
|
||||
revision: string;
|
||||
};
|
||||
|
||||
export type VaultHostMetadata = {
|
||||
label: string;
|
||||
hostname: string;
|
||||
username: string;
|
||||
port?: number;
|
||||
group: string;
|
||||
tags: Array<string>;
|
||||
protocol: string;
|
||||
};
|
||||
|
||||
export type VaultHostRef = {
|
||||
kind: "host.selection";
|
||||
scope: "metadata";
|
||||
handle: string;
|
||||
revision: string;
|
||||
expiresAt: SafePositiveInteger;
|
||||
};
|
||||
|
||||
export type VaultHostSelectedResources = Array<{
|
||||
resourceId: string;
|
||||
revision: string;
|
||||
label: string;
|
||||
hostname: string;
|
||||
username: string;
|
||||
port?: number;
|
||||
group: string;
|
||||
tags: Array<string>;
|
||||
protocol: string;
|
||||
}>;
|
||||
|
||||
export type VaultHostSelectedResult = {
|
||||
cancelled: false;
|
||||
hosts: Array<{
|
||||
target: VaultHostRef;
|
||||
metadata: VaultHostMetadata;
|
||||
}>;
|
||||
groups: Array<{
|
||||
path: string;
|
||||
members: Array<VaultHostRef>;
|
||||
}>;
|
||||
};
|
||||
|
||||
export type VaultHostSelectionResult = ({
|
||||
cancelled: true;
|
||||
}) | (VaultHostSelectedResult);
|
||||
|
||||
export type VaultPagesFeature = "views.vault-pages";
|
||||
|
||||
export type VaultSelectHostsParams = {
|
||||
title: string;
|
||||
};
|
||||
|
||||
export type ViewContribution = {
|
||||
id: ContributionId;
|
||||
title: LocalizedText;
|
||||
|
||||
@@ -58,6 +58,7 @@ export const PLUGIN_AI_MODEL_INVOCATION_FEATURE = "ai.model-invocation" as const
|
||||
export const PLUGIN_SELECTED_CONTEXT_FEATURE = "context.selected-read" as const satisfies import("./generated/plugin-contract.js").PluginSelectedContextFeature;
|
||||
|
||||
export const PLUGIN_HOST_INTERACTION_FEATURE = "host.interactions" as const satisfies import("./generated/plugin-contract.js").HostInteractionFeature;
|
||||
export const PLUGIN_HOST_VAULT_FEATURE = "vault.host-actions" as const satisfies import("./generated/plugin-contract.js").HostVaultFeature;
|
||||
|
||||
export const PLUGIN_VAULT_PAGES_FEATURE = "views.vault-pages" as const satisfies import("./generated/plugin-contract.js").VaultPagesFeature;
|
||||
export const PLUGIN_TERMINAL_SIDEBAR_FEATURE = "views.terminal-sidebar" as const satisfies import("./generated/plugin-contract.js").TerminalSidebarFeature;
|
||||
|
||||
@@ -116,6 +116,15 @@ export interface PluginHostUI {
|
||||
input(title: string, initialValue?: string): Promise<string | undefined>;
|
||||
}
|
||||
|
||||
/** Saved-host selection does not grant editing, connecting or session access.
|
||||
* Each action uses the host's separate review and confirmation UI. */
|
||||
export interface PluginVault {
|
||||
selectHosts(params: import("@netcatty/plugin-contract").VaultSelectHostsParams): Promise<import("@netcatty/plugin-contract").VaultHostSelectionResult>;
|
||||
/** Managed SSH-config drafts require the full Vault host editor. */
|
||||
requestHostDraft(params: import("@netcatty/plugin-contract").VaultHostDraftParams): Promise<import("@netcatty/plugin-contract").VaultHostActionResult>;
|
||||
requestConnect(params: import("@netcatty/plugin-contract").VaultHostConnectParams): Promise<import("@netcatty/plugin-contract").VaultHostActionResult>;
|
||||
}
|
||||
|
||||
export interface PluginClipboard {
|
||||
/** The host previews text only after the user asks, then separately confirms sharing it. */
|
||||
readText(title: string): Promise<string | undefined>;
|
||||
@@ -764,6 +773,7 @@ export interface PluginContext {
|
||||
readonly settings: PluginSettings;
|
||||
readonly commands: PluginCommands;
|
||||
readonly ui: PluginHostUI;
|
||||
readonly vault: PluginVault;
|
||||
readonly clipboard: PluginClipboard;
|
||||
readonly contextKeys: PluginContextKeys;
|
||||
readonly views: PluginViews;
|
||||
@@ -961,6 +971,7 @@ export { PLUGIN_BYTE_STREAM_FEATURE, PLUGIN_BYTE_STREAM_LIMITS } from "@netcatty
|
||||
export { PLUGIN_AI_MODEL_INVOCATION_FEATURE, PLUGIN_SELECTED_CONTEXT_FEATURE } from "@netcatty/plugin-contract";
|
||||
|
||||
export { PLUGIN_HOST_INTERACTION_FEATURE } from "@netcatty/plugin-contract";
|
||||
export { PLUGIN_HOST_VAULT_FEATURE } from "@netcatty/plugin-contract";
|
||||
export { PLUGIN_VAULT_PAGES_FEATURE } from "@netcatty/plugin-contract";
|
||||
export { PLUGIN_TERMINAL_SIDEBAR_FEATURE } from "@netcatty/plugin-contract";
|
||||
export { PLUGIN_INTERACTIVE_COMMAND_FEATURE } from "@netcatty/plugin-contract";
|
||||
|
||||
@@ -43,11 +43,27 @@ async function checkDevImport() {
|
||||
const themeResponse = await fetch(new URL("/infrastructure/config/pluginThemeRegistry.ts", origin));
|
||||
assert.equal(themeResponse.status, 200);
|
||||
const managerSource = await managerResponse.text() + "\n" + await themeResponse.text();
|
||||
// The lazy settings entry also links the permission panel before it renders.
|
||||
// Probe its actual Vite-served import: Node's CJS interop and production
|
||||
// CommonJS conversion otherwise hide missing browser ESM exports.
|
||||
const settingsResponse = await fetch(new URL("/components/settings/tabs/SettingsPluginsTab.tsx", origin));
|
||||
assert.equal(settingsResponse.status, 200);
|
||||
assert.match(await settingsResponse.text(), /\/components\/settings\/tabs\/PluginPackageManager\.tsx/);
|
||||
assert.match(managerSource, /\/components\/settings\/tabs\/PluginPermissionPanel\.tsx/);
|
||||
const panelResponse = await fetch(new URL("/components/settings/tabs/PluginPermissionPanel.tsx", origin));
|
||||
assert.equal(panelResponse.status, 200);
|
||||
const panelSource = await panelResponse.text();
|
||||
const permissionImport = panelSource.match(/^import\s+.*from "[^"\n]*pluginPermissionDescriptions[^"\n]*";[^\n]*$/m);
|
||||
assert.ok(permissionImport, "Missing Vite-served permission helper import");
|
||||
const panelProbe = new SourceTextModule(`${permissionImport[0]}\nexport { describePluginPermission };`, { context, identifier: new URL("/permission-panel-probe", origin).href });
|
||||
await panelProbe.link((specifier, importer) => load(new URL(specifier, importer.identifier).href));
|
||||
await panelProbe.evaluate();
|
||||
assert.equal(panelProbe.namespace.describePluginPermission("network").label, "Network requests");
|
||||
for (const [helper, exported] of [["pluginRuntimePolicy", "describePluginRuntime"], ["pluginPermissionDescriptions", "describePluginPermission"], ["pluginThemePack", "resolveThemePalette"]]) {
|
||||
const match = managerSource.match(new RegExp('from "([^"\\n]*' + helper + '[^"\\n]*)"'));
|
||||
assert.ok(match, `Missing prebundled ${helper} import`);
|
||||
const module = await load(new URL(match[1], origin).href);
|
||||
await module.link((specifier, importer) => load(new URL(specifier, importer.identifier).href));
|
||||
if (module.status === "unlinked") await module.link((specifier, importer) => load(new URL(specifier, importer.identifier).href));
|
||||
await module.evaluate();
|
||||
assert.equal(typeof module.namespace.default[exported], "function");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
"use strict";
|
||||
const assert = require("node:assert/strict");
|
||||
const test = require("node:test");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { JSDOM } = require("jsdom");
|
||||
|
||||
async function fixture(t, options = {}) {
|
||||
const root = path.resolve(__dirname, "../examples/plugins/assistant");
|
||||
const dom = new JSDOM(fs.readFileSync(path.join(root, "panel.html"), "utf8"), { runScripts: "outside-only" });
|
||||
t.after(() => dom.window.close());
|
||||
let now = 0; let opens = 0; let modelCalls = 0; let changed = false;
|
||||
const calls = [];
|
||||
const element = (id) => dom.window.document.getElementById(id);
|
||||
dom.window.Date.now = options.clock ?? (() => now);
|
||||
Object.defineProperty(dom.window, "netcattyView", { value: {
|
||||
onDidReceiveMessage: () => () => {},
|
||||
executeCommand: async (id, params) => {
|
||||
const operation = id.replace("com.netcatty.assistant.", ""); calls.push({ operation, params });
|
||||
if (options.commands) return options.commands(operation, structuredClone(params));
|
||||
if (operation === "open") {
|
||||
const count = ++opens;
|
||||
if (count > 1 && options.opening) await options.opening;
|
||||
return { chatHandle: `chat-${count}`, model: `Synthetic model ${count}`, expiresAt: now + 300_000 };
|
||||
}
|
||||
if (operation === "contexts") return [];
|
||||
if (operation === "close" || operation === "stop") return { ok: true };
|
||||
if (operation === "send") {
|
||||
if (params.chatHandle === "chat-1" && (changed || now >= 300_000)) throw Error("Session unavailable");
|
||||
modelCalls++;
|
||||
if (options.uncertain) throw Error("Reply lost after invocation");
|
||||
return { turnHandle: "synthetic-turn" };
|
||||
}
|
||||
if (operation === "read") return { kind: "end", sequence: 0 };
|
||||
throw Error(`Unexpected operation ${operation}`);
|
||||
},
|
||||
} });
|
||||
const waitFor = async (condition) => {
|
||||
const deadline = Date.now() + 2_000;
|
||||
while (!condition()) {
|
||||
if (Date.now() >= deadline) throw Error(`Panel state timed out: ${element("status").textContent}`);
|
||||
await new Promise(setImmediate);
|
||||
}
|
||||
};
|
||||
dom.window.eval(fs.readFileSync(path.join(root, "panel.js"), "utf8"));
|
||||
await waitFor(() => element("status").textContent === "Ready");
|
||||
element("prompt").value = "Synthetic draft must survive session recovery";
|
||||
return { element, calls, waitFor, opens: () => opens, modelCalls: () => modelCalls,
|
||||
timeTo: (value) => { now = value; }, changeModel: () => { changed = true; },
|
||||
send: () => element("compose").dispatchEvent(new dom.window.Event("submit", { cancelable: true })) };
|
||||
}
|
||||
|
||||
test("expired sample chat reopens before one explicit send and preserves the draft", async (t) => {
|
||||
const f = await fixture(t); f.timeTo(300_001); f.send();
|
||||
await f.waitFor(() => !f.element("prompt").disabled);
|
||||
assert.equal(f.opens(), 2);
|
||||
assert.deepEqual(f.calls.filter((call) => call.operation === "send").map((call) => call.params.chatHandle), ["chat-2"]);
|
||||
assert.equal(f.modelCalls(), 1);
|
||||
assert.equal(f.element("status").textContent, "Complete");
|
||||
assert.match(f.element("prompt").value, /must survive/);
|
||||
});
|
||||
|
||||
test("changed-model rejection offers explicit reopen without automatically resending", async (t) => {
|
||||
const f = await fixture(t); f.changeModel(); f.send();
|
||||
await f.waitFor(() => !f.element("prompt").disabled);
|
||||
assert.equal(f.opens(), 1); assert.equal(f.modelCalls(), 0);
|
||||
assert.ok(f.element("refresh"), "A changed model needs an explicit session refresh control");
|
||||
f.element("refresh").click(); await f.waitFor(() => f.element("status").textContent === "Ready");
|
||||
assert.equal(f.opens(), 2); assert.equal(f.modelCalls(), 0);
|
||||
assert.match(f.element("model").textContent, /model 2/);
|
||||
assert.match(f.element("prompt").value, /must survive/);
|
||||
f.send(); await f.waitFor(() => f.element("status").textContent === "Complete");
|
||||
assert.equal(f.modelCalls(), 1);
|
||||
});
|
||||
|
||||
test("uncertain inference outcome is never automatically retried by session refresh", async (t) => {
|
||||
const f = await fixture(t, { uncertain: true }); f.send();
|
||||
await f.waitFor(() => !f.element("prompt").disabled);
|
||||
assert.equal(f.modelCalls(), 1); assert.equal(f.opens(), 1);
|
||||
assert.ok(f.element("refresh"), "Recovery must reopen only, never retry inference");
|
||||
f.element("refresh").click(); await f.waitFor(() => f.element("status").textContent === "Ready");
|
||||
assert.equal(f.opens(), 2); assert.equal(f.modelCalls(), 1);
|
||||
assert.match(f.element("prompt").value, /must survive/);
|
||||
});
|
||||
|
||||
test("Stop during expired-session reopening discards the late session without sending", async (t) => {
|
||||
let resume; const opening = new Promise((resolve) => { resume = resolve; });
|
||||
t.after(() => resume());
|
||||
const f = await fixture(t, { opening }); f.timeTo(300_001); f.send();
|
||||
await f.waitFor(() => f.opens() === 2);
|
||||
f.element("stop").click(); resume();
|
||||
await f.waitFor(() => f.calls.some((call) => call.operation === "close" && call.params.chatHandle === "chat-2"));
|
||||
assert.equal(f.modelCalls(), 0); assert.equal(f.element("status").textContent, "Stopped");
|
||||
assert.match(f.element("prompt").value, /must survive/);
|
||||
});
|
||||
|
||||
test("expired real selected Note requires explicit re-selection, then fresh content permission and review", async (t) => {
|
||||
const { PluginAIContextService } = require("../electron/plugins/ai/aiContextService.cjs");
|
||||
let now = 0; let selections = 0; let reads = 0; let modelCalls = 0;
|
||||
let contentAllowed = false; let reviewAllowed = false;
|
||||
const grants = []; const reviews = [];
|
||||
const caller = { pluginId: "com.netcatty.assistant", runtimeId: "selected-note-panel", pluginVersion: "0.1.0", assertActive: async () => {} };
|
||||
const source = { id: "synthetic-source", kind: "third-party", origin: "https://synthetic.example" };
|
||||
const service = new PluginAIContextService({
|
||||
database: {
|
||||
getActivePlugin: () => ({ source, enabled: true, activeVersion: caller.pluginVersion, runtime: { status: "running" } }),
|
||||
getRetainedPluginSource: () => source, privateOwner: () => "synthetic-private-owner", publicPluginId: () => caller.pluginId,
|
||||
},
|
||||
getRuntimeIdentity: () => ({ ...caller, runtimeKind: "browser" }),
|
||||
permissionEngine: { onDidRevoke: () => ({ dispose() {} }), authorize: async (_caller, grant) => {
|
||||
grants.push(grant); if (!contentAllowed) throw Error("Synthetic content grant denied");
|
||||
} },
|
||||
hostAdapter: {
|
||||
assertActive: async () => {},
|
||||
selectModel: async () => ({ label: "Synthetic model", isCurrent: () => true, invoke: () => {
|
||||
modelCalls++; return (async function* () { yield { kind: "text", text: "Synthetic summary" }; })();
|
||||
} }),
|
||||
listSelected: async () => {
|
||||
selections++;
|
||||
return [{ key: "synthetic-note", scope: "note", title: "Synthetic selected Note", version: "v1", isCurrent: () => true,
|
||||
readText: () => { reads++; return "Synthetic selected Note body"; } }];
|
||||
},
|
||||
reviewText: async (text) => { reviews.push(text); return reviewAllowed; },
|
||||
},
|
||||
});
|
||||
service.selected.clock = () => now; service.broker.clock = () => now;
|
||||
t.after(() => service.shutdown());
|
||||
const commands = {
|
||||
open: () => service.open({}, caller), contexts: async () => (await service.list({}, caller)).items,
|
||||
send: (params) => service.start(params, caller), read: (params) => service.read(params, caller),
|
||||
stop: (params) => service.stop(params, caller), close: (params) => service.close(params, caller),
|
||||
};
|
||||
const f = await fixture(t, { clock: () => now, commands: (operation, params) => commands[operation](params) });
|
||||
const selector = f.element("context"); const oldHandle = selector.options[1].value;
|
||||
selector.value = oldHandle;
|
||||
now = 300_001;
|
||||
f.send(); await f.waitFor(() => !f.element("prompt").disabled);
|
||||
assert.match(f.element("status").textContent, /reference expired/i);
|
||||
assert.equal(f.calls.filter((call) => call.operation === "send").length, 0);
|
||||
assert.equal(selections, 1); assert.equal(modelCalls, 0); assert.equal(reads, 0);
|
||||
await assert.rejects(service.selected.assertCurrent({ handle: oldHandle, version: "v1" }, caller));
|
||||
f.element("refresh").click(); await f.waitFor(() => f.element("status").textContent === "Ready");
|
||||
assert.equal(selections, 1); // Refresh model must not silently share the Note again.
|
||||
f.send(); await f.waitFor(() => !f.element("prompt").disabled);
|
||||
assert.match(f.element("status").textContent, /reference expired/i);
|
||||
assert.ok(f.element("choose-reference"));
|
||||
f.element("choose-reference").click(); await f.waitFor(() => f.element("status").textContent.startsWith("References updated"));
|
||||
assert.equal(selections, 2); assert.equal(selector.value, "");
|
||||
const newHandle = selector.options[1].value;
|
||||
assert.notEqual(newHandle, oldHandle);
|
||||
const [fresh] = await service.selected.summaries(caller);
|
||||
assert.equal(fresh.handle, newHandle); assert.equal(fresh.expiresAt, 600_001);
|
||||
await assert.rejects(service.selected.assertCurrent({ handle: oldHandle, version: "v1" }, caller));
|
||||
assert.equal(reads, 0); assert.equal(grants.length, 0); assert.equal(modelCalls, 0);
|
||||
selector.value = newHandle;
|
||||
f.send(); await f.waitFor(() => f.element("status").textContent.startsWith("Request ended"));
|
||||
assert.equal(grants.length, 1); assert.equal(reads, 0); assert.equal(modelCalls, 0);
|
||||
contentAllowed = true;
|
||||
f.send(); await f.waitFor(() => f.element("status").textContent.startsWith("Request ended"));
|
||||
assert.equal(reviews.length, 1); assert.equal(reviews[0].text, "Synthetic selected Note body"); assert.equal(modelCalls, 0);
|
||||
reviewAllowed = true;
|
||||
f.send(); await f.waitFor(() => f.element("status").textContent === "Complete");
|
||||
assert.equal(modelCalls, 1); assert.equal(reviews.length, 2);
|
||||
assert.ok(grants.every((grant) => grant.permission === "context.selected.read" && grant.allowedScopes[0] === "once"
|
||||
&& grant.resources[0] === `selected:${newHandle}:v1`));
|
||||
assert.match(f.element("prompt").value, /must survive/);
|
||||
// Repeat with both lifetimes expired, without manually refreshing the model.
|
||||
now = 600_002;
|
||||
f.send(); await f.waitFor(() => !f.element("prompt").disabled);
|
||||
assert.match(f.element("status").textContent, /reference expired/i);
|
||||
assert.equal(modelCalls, 1); assert.equal(selections, 2);
|
||||
f.element("choose-reference").click(); await f.waitFor(() => f.element("status").textContent.startsWith("References updated"));
|
||||
const repeatedHandle = selector.options[1].value;
|
||||
assert.notEqual(repeatedHandle, newHandle); assert.equal(selector.value, "");
|
||||
selector.value = repeatedHandle;
|
||||
f.send(); await f.waitFor(() => f.element("status").textContent === "Complete");
|
||||
assert.equal(modelCalls, 2); assert.equal(grants.at(-1).resources[0], `selected:${repeatedHandle}:v1`);
|
||||
await assert.rejects(service.selected.assertCurrent({ handle: newHandle, version: "v1" }, caller));
|
||||
assert.match(f.element("prompt").value, /must survive/);
|
||||
});
|
||||
Reference in new issue
Block a user