mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-10-30 18:32:39 +00:00
Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
730fa643ba | ||
|
|
267bcbfb8b | ||
|
|
17c48b4d79 | ||
|
|
739cab2965 | ||
|
|
cecfa3a47e | ||
|
|
0a590b4ebc | ||
|
|
69c2cc8d1f | ||
|
|
ccfe41f39a | ||
|
|
d5cb8cfd3b | ||
|
|
c4433d4961 | ||
|
|
264f1d983c | ||
|
|
35e3f9f99f | ||
|
|
4882e0501c | ||
|
|
c3a5f810ce | ||
|
|
dd41f9d8d0 | ||
|
|
6392ac9532 | ||
|
|
afe831bfc5 |
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,32 +1,6 @@
|
||||
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',
|
||||
@@ -1406,5 +1380,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,32 +1,6 @@
|
||||
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',
|
||||
@@ -1406,5 +1380,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.',
|
||||
|
||||
};
|
||||
@@ -1275,4 +1275,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,32 +1,6 @@
|
||||
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': '取消',
|
||||
@@ -1321,5 +1295,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,32 +1,6 @@
|
||||
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': '取消',
|
||||
@@ -1324,4 +1298,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,13 +1,14 @@
|
||||
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 { 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();
|
||||
@@ -17,7 +18,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 ?? '' : '');
|
||||
}
|
||||
pending.current = false; setBusy(false); setError(false);
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
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,8 +10,7 @@ export function usePluginManager() {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const generation = useRef(0);
|
||||
const mutation = useRef(false);
|
||||
const refresh = useCallback(async (options?: { clearError?: boolean }) => {
|
||||
if (options?.clearError) setError(null);
|
||||
const refresh = useCallback(async () => {
|
||||
const current = ++generation.current;
|
||||
try {
|
||||
const bridge = netcattyBridge.get();
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
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
|
||||
@@ -2519,6 +2531,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;
|
||||
let onLock: ((state: { locked: boolean }) => void) | undefined;
|
||||
const service = createPluginHostService({
|
||||
app: { getAppPath: () => process.cwd(), getPath: () => directory, getVersion: () => '0.0.0' }, electron: {}, env: {},
|
||||
getInteractionWindow: () => owner, isAppLocked: () => locked,
|
||||
subscribeAppLock: (callback: typeof onLock) => { onLock = callback; return () => {}; },
|
||||
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(); onLock?.({ 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,9 +201,8 @@ export const SettingCard: React.FC<SettingCardProps> = ({
|
||||
);
|
||||
|
||||
interface SettingRowProps {
|
||||
leading?: React.ReactNode;
|
||||
label?: React.ReactNode;
|
||||
description?: React.ReactNode;
|
||||
label?: string;
|
||||
description?: string;
|
||||
children: React.ReactNode;
|
||||
align?: "center" | "start";
|
||||
/** Stable catalog id for settings search jump targets. */
|
||||
@@ -211,7 +210,6 @@ interface SettingRowProps {
|
||||
}
|
||||
|
||||
export const SettingRow: React.FC<SettingRowProps> = ({
|
||||
leading,
|
||||
label,
|
||||
description,
|
||||
children,
|
||||
@@ -227,7 +225,6 @@ 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 && (
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -1,58 +0,0 @@
|
||||
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, useRef, useState } = await import('react');
|
||||
const { default: React, act, useEffect } = await import('react');
|
||||
const { createRoot } = await import('react-dom/client');
|
||||
const { I18nProvider } = await import('../../../application/i18n/I18nProvider');
|
||||
const { PluginManagementDialog } = await import('./PluginManagementDialog');
|
||||
@@ -69,28 +69,6 @@ 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,25 +4,18 @@ 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, 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>;
|
||||
export function PluginManagementDialog({ children }: {
|
||||
children: React.ReactNode | ((handoff: (action: () => void) => void) => React.ReactNode);
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const [internalOpen, setInternalOpen] = useState(false);
|
||||
const open = controlledOpen ?? internalOpen;
|
||||
const setOpen = (value: boolean) => { setInternalOpen(value); onOpenChange?.(value); };
|
||||
const [open, setOpen] = useState(false);
|
||||
const pendingHandoff = useRef<(() => void) | null>(null);
|
||||
const handoff = (action: () => void) => { pendingHandoff.current = action; setOpen(false); };
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
{trigger !== null && <DialogTrigger asChild>
|
||||
{trigger ?? <Button variant="outline">{t('settings.plugins.openManager')}</Button>}
|
||||
</DialogTrigger>}
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline">{t('settings.plugins.openManager')}</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent onCloseAutoFocus={(event) => {
|
||||
const action = pendingHandoff.current;
|
||||
pendingHandoff.current = null;
|
||||
@@ -31,13 +24,6 @@ export function PluginManagementDialog({ children, trigger, open: controlledOpen
|
||||
// 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, initialSelectedId }: { initialSelectedId?: string | null; manager: ReturnType<typeof usePluginManager>; onSelectionChange?: (id: string | null) => void }) {
|
||||
export function PluginPackageManager({ manager, onSelectionChange }: { manager: ReturnType<typeof usePluginManager>; onSelectionChange?: (id: string | null) => void }) {
|
||||
const { t, resolvedLocale } = useI18n();
|
||||
const [selected, setSelected] = useState<string | null>(initialSelectedId ?? null);
|
||||
const [selected, setSelected] = useState<string | null>(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. */
|
||||
|
||||
@@ -1,149 +0,0 @@
|
||||
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(); }
|
||||
});
|
||||
@@ -1,79 +0,0 @@
|
||||
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); }
|
||||
}
|
||||
});
|
||||
@@ -1,89 +0,0 @@
|
||||
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>
|
||||
</>;
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
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); }
|
||||
}
|
||||
});
|
||||
@@ -1,74 +0,0 @@
|
||||
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,13 +1,11 @@
|
||||
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 { PluginSettingsLanding } from './PluginSettingsLanding';
|
||||
import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { normalizePluginKeyboardEvent } from '../../../application/state/pluginKeybindings';
|
||||
import { usePluginContributions } from '../../../application/state/usePluginContributions';
|
||||
@@ -287,15 +285,11 @@ export function PluginSettingField({
|
||||
);
|
||||
}
|
||||
|
||||
function PluginManagementContent({ handoff, manager, initialSelectedId, active }: {
|
||||
handoff: (action: () => void) => void;
|
||||
manager: ReturnType<typeof usePluginManager>;
|
||||
initialSelectedId: string | null;
|
||||
active: boolean;
|
||||
}) {
|
||||
function PluginManagementContent({ handoff }: { handoff: (action: () => void) => void }) {
|
||||
const { t } = useI18n();
|
||||
const manager = usePluginManager();
|
||||
const [catalogToolbar, setCatalogToolbar] = useState<HTMLDivElement | null>(null);
|
||||
const [selectedInstalled, setSelectedInstalled] = useState<string | null>(initialSelectedId);
|
||||
const [selectedInstalled, setSelectedInstalled] = useState<string | null>(null);
|
||||
const [pluginView, setPluginView] = useState<'installed' | 'marketplace' | 'thirdparty'>('installed');
|
||||
const availableFonts = useAvailableFonts();
|
||||
const scopeCatalog = usePluginSettingScopeCatalog();
|
||||
@@ -308,31 +302,6 @@ function PluginManagementContent({ handoff, manager, initialSelectedId, active }
|
||||
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))
|
||||
@@ -342,7 +311,7 @@ function PluginManagementContent({ handoff, manager, initialSelectedId, active }
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<Tabs value={pluginView} onValueChange={(value) => { gearEntry.current = null; setSelectedInstalled(null); setPluginView(value as 'installed' | 'marketplace' | 'thirdparty'); }} className="space-y-5">
|
||||
<Tabs value={pluginView} onValueChange={(value) => { 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>
|
||||
@@ -358,7 +327,7 @@ function PluginManagementContent({ handoff, manager, initialSelectedId, active }
|
||||
<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} initialSelectedId={selectedInstalled} onSelectionChange={(id) => { gearEntry.current = null; setSelectedInstalled(id); }} />
|
||||
<PluginPackageManager manager={manager} onSelectionChange={setSelectedInstalled} />
|
||||
{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) => (
|
||||
@@ -383,7 +352,7 @@ function PluginManagementContent({ handoff, manager, initialSelectedId, active }
|
||||
const views = plugin.views.filter((view) => view.visible && view.location === 'settings');
|
||||
if (!settings.length && !views.length) return null;
|
||||
return (
|
||||
<section key={plugin.id} ref={settingsSection} tabIndex={-1} className="space-y-3 scroll-mt-4 focus:outline-none" aria-labelledby={`plugin-settings-${plugin.id}`}>
|
||||
<section key={plugin.id} className="space-y-3" 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>}
|
||||
@@ -425,27 +394,15 @@ function PluginManagementContent({ handoff, manager, initialSelectedId, active }
|
||||
|
||||
|
||||
export default function SettingsPluginsTab() {
|
||||
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]);
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<SettingsTabContent value="plugins">
|
||||
<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 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>
|
||||
</SettingsTabContent>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
# Plugin AI and selected context: implementation checkpoint
|
||||
|
||||
Base: ThemePack #3978, `91b8927c56314e68c06e2c008e84559ddfb96d78`.
|
||||
Branch: `feat/plugin-ai-context`.
|
||||
Status: isolated host internals and fixture-tested transport reuse. Public contract,
|
||||
production adapters, installable example, independent review, and native acceptance
|
||||
are pending. This source is not ready to merge or release.
|
||||
|
||||
## Proposed public boundaries
|
||||
|
||||
The proposed optional features are `ai.model-invocation` and
|
||||
`context.selected-read`. The parent has not confirmed these names. No canonical
|
||||
schema, generated contract, SDK, runtime peer, hostService, or installer changes
|
||||
are included in this checkpoint.
|
||||
|
||||
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.
|
||||
Plugins do not supply provider IDs, URLs, credentials, tools, or Agent policies.
|
||||
The internal operations are `open`, `start`, `read`, `stop`, and `close`.
|
||||
`read` pulls one chunk with one pending read per turn. The parent can map this
|
||||
iterator onto the existing stream router after the canonical contract is agreed.
|
||||
|
||||
Context summary enumeration returns only references explicitly shared with the
|
||||
current runtime and source owner. Content reads require an opaque handle and an
|
||||
expected version. Summary enumeration never calls the content reader. Supported
|
||||
selected scopes are workspace, tab, session, host, and Note. There is no inventory
|
||||
or arbitrary entity-ID lookup.
|
||||
|
||||
## File ownership
|
||||
|
||||
| Owner | Files or responsibility |
|
||||
| --- | --- |
|
||||
| This task | `electron/plugins/ai/`, `electron/plugins/context/`, `infrastructure/ai/plugin/`, `examples/plugins/assistant/`, this document |
|
||||
| This task | Opt-in `requireBridge` and `maxStreamBytes` request context in `infrastructure/ai/sdk/providers.ts` |
|
||||
| Parent coordination | Canonical schema, generated files, SDK, runtime forwarding, hostService registration, trusted renderer adapters |
|
||||
| Market task | Source identity, private data-owner namespace, installer database, marketplace UI |
|
||||
| Transport task | Generic TCP and TLS streams |
|
||||
| Logging task | Optional local plugin logs |
|
||||
|
||||
The existing `feat/plugin-terminal-sharing` commit
|
||||
`3cdc97782ef3262969eee9d540e03ebe6fdee210` is preserved. Its
|
||||
`terminal-sharing-wip.md` describes incomplete selection publication and review.
|
||||
This branch does not claim that workflow is complete.
|
||||
|
||||
## Implemented authority
|
||||
|
||||
`ModelInvocationBroker` requires host-provided `resolveDataOwner` and
|
||||
`selectModel` adapters. 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`.
|
||||
|
||||
Each handle is bound to a data owner, a runtime ID, and a plugin version.
|
||||
Every read rechecks runtime authority, the current model, handle lifetime, and
|
||||
selected context versions. Revocation aborts pending turns. Late provider results
|
||||
are discarded. Another source with the same plugin ID cannot read, cancel, or
|
||||
close the original source's handles.
|
||||
|
||||
`SelectedContextAuthority.share` is a trusted host operation. A plugin must never
|
||||
call it directly. Its descriptor accepts only a scope, a title, a version, a text
|
||||
reader, and a current-selection guard. Unknown Host fields are rejected. Content
|
||||
has a 16 KiB cap, excludes NUL, and rejects PEM private keys. The production host
|
||||
reader must project safe fields explicitly and exclude passwords, private keys,
|
||||
TOTP values, identity credentials, startup commands, and environment variables.
|
||||
Free-form Notes can contain secrets that field projection cannot detect. The
|
||||
trusted sharing UI must preview the selected text before sharing it.
|
||||
|
||||
`createPluginModelAdapter` calls the existing `createModelFromConfig` and
|
||||
`streamText`, through the existing host provider transport. Credential injection
|
||||
stays in Netcatty. Direct-fetch fallback is disabled for this adapter. Incoming
|
||||
SSE payloads have an aggregate 128 KiB cap before SDK buffering. SDK retries are
|
||||
disabled. Only text and allowlisted token totals reach the broker. Provider
|
||||
errors, raw chunks, reasoning, headers, and tool payloads do not cross it.
|
||||
|
||||
No AgentRuntime, internal prompt, full MCP catalog, tool executor, Node runtime,
|
||||
raw host IPC, or global renderer store is exposed to plugins. A model tool request
|
||||
ends the turn without executing an action. Notes edits and terminal input are
|
||||
outside this checkpoint. No command is written to a terminal or followed by Enter.
|
||||
|
||||
The default broker limits are defined in `MODEL_INVOCATION_LIMITS` and
|
||||
`SELECTED_CONTEXT_LIMITS`. They include concurrent handles, turn and chat lifetime,
|
||||
input and output bytes, output token caps, event count, requests per minute, and
|
||||
reserved tokens per minute. Input token reservation uses UTF-8 bytes plus 1,024
|
||||
bytes for framing and the output token cap. This is a conservative estimate, not
|
||||
a tokenizer or a monetary billing cap. Provider usage is reported separately.
|
||||
Reservations are not refunded after cancellation. Rate limits survive runtime
|
||||
restart within the broker's lifetime. Host shutdown clears this in-memory state.
|
||||
|
||||
## Required host adapters
|
||||
|
||||
Production integration is blocked on these parent-owned contracts:
|
||||
|
||||
1. `resolveDataOwner(context)` must return the current private source owner from
|
||||
host provenance. A plugin-supplied source value or plugin ID is insufficient.
|
||||
2. `selectModel(context)` must return a trusted invocation adapter for the model
|
||||
explicitly selected in the main UI. No provider configuration crosses RPC.
|
||||
3. The renderer adapter must validate the current window, selected resource,
|
||||
lifecycle generation, version, and owner before and after asynchronous reads.
|
||||
Reconnect, tab or session close, navigation, lock, disable, and permission
|
||||
revocation must invalidate handles and abort turns.
|
||||
4. Summary enumeration, content reads, and model invocation need separate
|
||||
permissions. Only the canonical schema can define their wire format.
|
||||
Model invocation permission does not grant context or action permissions.
|
||||
5. Host review must show selected content before it becomes model input.
|
||||
A command-palette or menu ID is never resource authority.
|
||||
6. The shared hostService adapter must register teardown and permission-revoke
|
||||
hooks, and discard results if its renderer owner changes.
|
||||
|
||||
## QA evidence
|
||||
|
||||
The domain suites and transport suite use synthetic data only. The transport
|
||||
suite starts a deterministic OpenAI-compatible HTTP server on loopback and
|
||||
checks this path:
|
||||
|
||||
`ModelInvocationBroker` -> `createPluginModelAdapter` -> `createModelFromConfig`
|
||||
-> `createBridgeFetchForSDK` -> existing `_streamRequestForTests` main transport
|
||||
-> local fixture -> text and usage.
|
||||
|
||||
This is not real model inference or paid API testing. It uses no paid keys, real
|
||||
Notes, hosts, or chat history. The credential value is a fixed synthetic marker;
|
||||
the existing model factory replaces it with its IPC placeholder. The test does
|
||||
not exercise native credential storage or production credential injection.
|
||||
|
||||
The tests cover source, runtime, and version denial; separate summaries and
|
||||
content reads; context invalidation; provider cancellation and late results;
|
||||
deadlines; request, token, event, input, output, and stream-byte limits; and
|
||||
allowlisted response serialization. Raw provider diagnostics remain inside the
|
||||
host.
|
||||
|
||||
Commands:
|
||||
|
||||
```sh
|
||||
node --test electron/plugins/modelInvocation.test.cjs
|
||||
node --test --import tsx infrastructure/ai/plugin/modelInvocationAdapter.test.ts
|
||||
npx eslint infrastructure/ai/plugin/*.ts infrastructure/ai/sdk/providers.ts examples/plugins/assistant/panel.js
|
||||
```
|
||||
|
||||
The final focused domain run passes 16 tests. The transport suite passes three
|
||||
tests, including the actual assistant panel's Send, streaming, usage, and Stop
|
||||
handlers in JSDOM. The test bridge uses structured clones to model the existing
|
||||
view command boundary. It does not replace installed-plugin or native coverage.
|
||||
The wider plugin runtime run passes 556 tests with one skipped. The existing
|
||||
provider regression suites pass 34 tests.
|
||||
|
||||
The full repository typecheck fails in existing code, including provider tool-call
|
||||
inference and plugin CLI test manifest literals. No new adapter type errors were
|
||||
reported. The focused lint passes.
|
||||
|
||||
Native Electron launch aborts because the bundled `chrome-sandbox` is not owned
|
||||
by root with mode 4755. No sandbox bypass was attempted. Native screenshots,
|
||||
video, installed-plugin UI tests, packaged builds, and independent review are
|
||||
pending on the parent's working desktop after shared integration. The panel
|
||||
source alone is not evidence of native acceptance.
|
||||
@@ -0,0 +1,111 @@
|
||||
# Byte stream integration checkpoint
|
||||
|
||||
The branch starts at #3978 `91b8927c56314e68c06e2c008e84559ddfb96d78`.
|
||||
It integrates source APIs from #3981 `ab8764a31752fd2d226c9d029c4d65498ba7b063`
|
||||
and AI service/adapters from #3986 `264f1d983cd1ed83fe4873974a614fff2f47f989`.
|
||||
No source database or sync implementation is changed by the transport delta.
|
||||
|
||||
## Registered transport
|
||||
|
||||
Canonical definitions are in `packages/plugin-contract/schema/plugin-contract.schema.json`.
|
||||
`network.byte-streams` negotiates the capability. The separate `network.stream`
|
||||
permission requires explicit `tcp://host:port` or `tls://host:port` resources.
|
||||
Wildcard declarations and grants cannot authorize raw streams. HTTP grants and
|
||||
payload limits are unchanged.
|
||||
|
||||
The SDK exposes `context.network.connect({ endpoint }, cancellationToken?)`.
|
||||
The returned connection has `endpoint`, `readable`, `writable`, `close()`, and
|
||||
`dispose()`. The control methods are `network.stream.connect`,
|
||||
`network.stream.attach`, and `network.stream.close`. Descriptors, request
|
||||
shapes, feature names, limits, generated SDK types, and host validators share
|
||||
the canonical schema. Unknown request properties are rejected.
|
||||
|
||||
The registered service authenticates the host runtime before source lookup.
|
||||
Runtime startup captures the existing opaque source owner. Every asynchronous
|
||||
commit revalidates the runtime, active and retained source, and opaque owner
|
||||
through the source task's APIs. Caller-supplied owner/source parameters are
|
||||
rejected. Audit commits also verify the source binding.
|
||||
|
||||
Connection and stream IDs are host-issued and bound to that runtime and owner.
|
||||
The SDK prepares its incoming waiter before attach starts the host producer.
|
||||
Both directions reuse the existing binary stream router and credit windows.
|
||||
Input credit returns after the socket write callback. Output backpressure
|
||||
bounds the producer. The router has one optional producer `onClose` hook;
|
||||
peer cancellation releases the socket even while it is idle.
|
||||
|
||||
The host validates all DNS answers and pins a literal IP. Production rejects
|
||||
loopback, link-local, unspecified, multicast, mapped local addresses, selected
|
||||
IPv6 tunnel forms, and the machine's own interface addresses. The production
|
||||
host constructor never forwards the core's explicit local-test override.
|
||||
There is no proxy discovery, reconnect, UDP, listen, generic IPC, shell, Node
|
||||
plugin runtime, or filesystem escape.
|
||||
|
||||
Limits are four connections per runtime, 64 globally, 64 KiB chunks and socket
|
||||
buffer thresholds, and 8 MiB combined bytes per connection, plus the existing
|
||||
runtime byte quota. Connect, idle, and lifetime deadlines are 10, 30, and 120
|
||||
seconds. Close tombstones are capped at 256 small identity records. Revocation,
|
||||
runtime stop, disable, shutdown, and stream cancellation release resources.
|
||||
|
||||
TLS requires trust and original-hostname validation with TLS 1.2 or later.
|
||||
Plugins cannot set custom CAs, SNI, or validation exceptions. STARTTLS is not
|
||||
implemented. Generic audit records contain endpoint, runtime, handle, and byte
|
||||
counts. The host does not inspect SQL or record payloads or credentials.
|
||||
|
||||
## Additive AI interface for the parallel owner
|
||||
|
||||
The same canonical schema defines the relayed `AI_CONTEXT_ROUTES` types.
|
||||
The SDK/runtime expose `context.ai.openChat/startTurn/readTurn/stopTurn/closeChat`
|
||||
and `context.selectedContext.list/read`. Host registration advertises
|
||||
`ai.model-invocation` and `context.selected-read`, and wires runtime cleanup,
|
||||
plugin disable, revocation through the service, and shutdown.
|
||||
|
||||
The trusted host adapter constructor option is `pluginAIHostAdapter`. It has
|
||||
the service owner's `assertActive`, `selectModel`, `listSelected`, `reviewText`,
|
||||
and optional lifecycle callbacks. These callbacks are never exposed to plugin
|
||||
views. The default adapter rejects model invocation, returns no selected
|
||||
resources, and refuses text review. The AI owner still owns real renderer
|
||||
wiring and model/context UX. A registered synthetic adapter test is not proof
|
||||
of that remaining production wiring.
|
||||
|
||||
## Database example
|
||||
|
||||
`examples/plugins/database-minimal` is an ordinary browser plugin. Its developer
|
||||
preparation tool creates an `.ncpkg` for one exact disposable private IPv4
|
||||
endpoint. The output contains a browser bundle, a normal manifest, and view
|
||||
assets. The preparation tool and its Node dependencies are not in the plugin.
|
||||
|
||||
Only the example contains the database protocol: common protocol 4.1,
|
||||
`mysql_native_password`, and
|
||||
`SELECT value FROM plugin_demo.example_result LIMIT 1`.
|
||||
The view accepts session-only fixture credentials and clears its password
|
||||
field before dispatch. It does not save view state, settings, logs, or secrets.
|
||||
The host never exposes SSH or AI credentials. JavaScript does not promise
|
||||
memory zeroization. The account's server-side SELECT grant enforces read-only
|
||||
access. The fixed query is not a SQL security classifier.
|
||||
|
||||
The example has no query editor, schema browser, pool, arbitrary SQL, or host
|
||||
connection-management subsystem. See the example README for native setup.
|
||||
|
||||
## Evidence and limits
|
||||
|
||||
- Both source-reviewed races have executable real-loopback regressions here.
|
||||
Delayed final-byte consumption previously lost EOF after the socket closed.
|
||||
Double-microtask cancellation previously created an untracked late socket.
|
||||
Each regression failed before its respective fix and passed afterward.
|
||||
- The broker tests use real TCP/TLS sockets, including trusted fixture CA,
|
||||
untrusted certificate and wrong-hostname denial. Connect and idle deadline
|
||||
cases use mock clocks around real sockets or delayed DNS.
|
||||
- The installed entrypoint test builds and installs a normal package, executes
|
||||
its actual browser entry module through SDK/RPC/binary streams, and exercises
|
||||
grant denial, exact resources, unknown-field rejection, close, revoke, and
|
||||
disable. It also exercises registered AI/context SDK routes with a synthetic
|
||||
trusted adapter. Its runtime harness is Node and is explicitly not a browser
|
||||
sandbox or OS sandbox test.
|
||||
- The disposable MariaDB test checks a synthetic SELECT result and server-side
|
||||
INSERT denial. It creates an internal network, publishes no host ports, and
|
||||
removes the container and network. Database transport is plain TCP inside
|
||||
that disposable network, not database TLS.
|
||||
|
||||
Normal native BrowserWindow, full app/package checks, exact-head independent
|
||||
review, and screenshot/video acceptance remain separate required evidence.
|
||||
Do not disable the Chromium sandbox to obtain that evidence.
|
||||
@@ -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.
|
||||
@@ -1,87 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,181 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,46 +0,0 @@
|
||||
# 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.
|
||||
@@ -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);
|
||||
}
|
||||
@@ -11,6 +11,9 @@ const PERMISSION_DESCRIPTIONS = {
|
||||
menus: [false, "Menu entries", "Add this plugin's actions to host menus.", "菜单入口", "将此插件的操作加入应用菜单。"],
|
||||
"ui.interact": [false, "Host prompts", "Ask the host to show bounded input and choice dialogs.", "宿主交互", "请求宿主显示有界的输入和选择对话框。"],
|
||||
views: [false, "Plugin panels", "Display plugin-owned panels inside the app.", "插件面板", "在应用中显示插件自己的面板。"],
|
||||
"ai.invoke": [true, "Use configured AI model", "Send plugin prompts and separately approved selected text to the configured model. Provider charges may apply. Does not grant tools or host actions.", "使用已配置的 AI 模型", "向已配置的模型发送插件提示和另行批准的所选文本。模型提供商可能收费。此权限不授予工具或宿主操作权限。"],
|
||||
"context.selected.list": [true, "Selected context summaries", "Ask you to share summaries of selected workspace, tab, session, host, or Note references. Does not read their text.", "所选上下文摘要", "请求你分享所选工作区、标签页、会话、主机或笔记引用的摘要。此权限不会读取其文本。"],
|
||||
"context.selected.read": [true, "Read selected context", "Read text from references you explicitly select and approve. It may contain private data.", "读取所选上下文", "读取你明确选择并批准的引用中的文本,其中可能包含隐私信息。"],
|
||||
"clipboard.read": [true, "Read clipboard", "Read copied text, which may contain passwords or other sensitive data.", "读取剪贴板", "读取复制的文本,其中可能包含密码或其他敏感数据。"],
|
||||
"clipboard.write": [true, "Write clipboard", "Replace clipboard content that you may paste elsewhere.", "写入剪贴板", "替换剪贴板内容,可能影响你随后粘贴的数据。"],
|
||||
"terminal.metadata": [true, "Terminal metadata", "Read terminal/session details that may identify hosts and activity.", "终端元数据", "读取终端和会话信息,可能暴露主机及操作活动。"],
|
||||
@@ -26,6 +29,7 @@ const PERMISSION_DESCRIPTIONS = {
|
||||
"sftp.read": [true, "Read remote files", "Read remote file content through supported host APIs.", "读取远程文件", "通过支持的宿主 API 读取远程文件内容。"],
|
||||
"sftp.write": [true, "Change remote files", "Write or change remote files through supported host APIs.", "修改远程文件", "通过支持的宿主 API 写入或修改远程文件。"],
|
||||
network: [true, "Network requests", "Contact approved origins. Data sent to them leaves this device.", "网络请求", "访问已批准的网络来源。发送的数据会离开此设备。"],
|
||||
"network.stream": [true, "TCP and TLS connections", "Send and receive data over TCP or TLS at approved endpoints. Data may leave this device.", "TCP 和 TLS 连接", "通过 TCP 或 TLS 与已批准的端点收发数据。数据可能离开此设备。"],
|
||||
"filesystem.read": [true, "Read local files", "Read approved local paths, which may contain sensitive files.", "读取本地文件", "读取已批准的本地路径,其中可能包含敏感文件。"],
|
||||
"filesystem.write": [true, "Change local files", "Write to approved local paths; existing content may be changed.", "修改本地文件", "写入已批准的本地路径,可能改变现有文件内容。"],
|
||||
secrets: [true, "Plugin secrets", "Store and use this plugin's secrets through OS-backed storage and controlled leases.", "插件秘密", "通过系统密钥存储与受控租约保存和使用此插件的秘密。"],
|
||||
|
||||
@@ -245,7 +245,6 @@ 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 }),
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
"use strict";
|
||||
|
||||
const { PluginRpcError, RPC_ERRORS } = require("../rpcRouter.cjs");
|
||||
const { createDefinitionValidator } = require("../contractValidator.cjs");
|
||||
const { ModelInvocationBroker } = require("./modelInvocationBroker.cjs");
|
||||
const { SelectedContextAuthority } = require("../context/selectedContextAuthority.cjs");
|
||||
const { PluginAISourceBinding } = require("./sourceBinding.cjs");
|
||||
const fail = (code, message) => new PluginRpcError(RPC_ERRORS[code], message);
|
||||
const FEATURES = Object.freeze(["ai.model-invocation", "context.selected-read"]);
|
||||
const ROUTES = Object.freeze([
|
||||
["ai.openChat", "PluginAIEmptyParams", "PluginAIChatResult", "ai.invoke", "open"],
|
||||
["ai.startTurn", "PluginAIStartTurnParams", "PluginAITurnResult", "ai.invoke", "start"],
|
||||
["ai.readTurn", "PluginAITurnRef", "PluginAIStreamEvent", "ai.invoke", "read"],
|
||||
["ai.stopTurn", "PluginAITurnRef", "PluginAIBooleanResult", "ai.invoke", "stop"],
|
||||
["ai.closeChat", "PluginAIChatRef", "PluginAIBooleanResult", "ai.invoke", "close"],
|
||||
["context.selected.list", "PluginAIEmptyParams", "PluginSelectedContextListResult", "context.selected.list", "list"],
|
||||
["context.selected.read", "PluginSelectedContextRef", "PluginSelectedContextContent", "context.selected.read", "readSelected"],
|
||||
]);
|
||||
|
||||
class PluginAIContextService {
|
||||
constructor(options) {
|
||||
this.host = options.hostAdapter;
|
||||
for (const method of ["selectModel", "listSelected", "reviewText", "assertActive"]) {
|
||||
if (typeof this.host?.[method] !== "function") throw new TypeError(`AI host adapter ${method} required`);
|
||||
}
|
||||
this.source = new PluginAISourceBinding(options);
|
||||
this.permissionEngine = options.permissionEngine;
|
||||
this.resolveOwner = (context) => this.source.resolve(context);
|
||||
this.selected = new SelectedContextAuthority({ resolveDataOwner: this.resolveOwner });
|
||||
this.runtimeContexts = new Map();
|
||||
this.registrations = [];
|
||||
this.broker = new ModelInvocationBroker({ resolveDataOwner: this.resolveOwner,
|
||||
selectModel: async (context) => {
|
||||
await this.#guard(context);
|
||||
const model = await this.host.selectModel(context);
|
||||
await this.#guard(context);
|
||||
return model;
|
||||
},
|
||||
contexts: {
|
||||
read: (reference, context) => this.readSelected(reference, context),
|
||||
assertCurrent: (reference, context) => this.selected.assertCurrent(reference, context),
|
||||
onDidInvalidate: (listener) => this.selected.onDidInvalidate(listener),
|
||||
releaseRuntime: (runtimeId) => this.selected.releaseRuntime(runtimeId),
|
||||
releaseOwner: (owner) => this.selected.releaseOwner(owner),
|
||||
},
|
||||
});
|
||||
this.revocation = this.permissionEngine.onDidRevoke((event) => this.releasePlugin(event.pluginId));
|
||||
this.unsubscribeHost = this.host.onDidInvalidate?.(() => this.releaseAll());
|
||||
}
|
||||
|
||||
async #guard(context) {
|
||||
const owner = await this.resolveOwner(context);
|
||||
await this.host.assertActive(context);
|
||||
if (await this.resolveOwner(context) !== owner) throw fail("permissionDenied", "Plugin source changed");
|
||||
return owner;
|
||||
}
|
||||
|
||||
#authorization(permission, params) {
|
||||
const resource = permission === "context.selected.read" ? `selected:${params.handle}:${params.version}` : "*";
|
||||
return { permission, resources: [resource],
|
||||
reason: permission === "ai.invoke" ? "Use the model selected in Netcatty without tools"
|
||||
: permission === "context.selected.list" ? "List summaries of the current selected context"
|
||||
: "Review and share this selected context version",
|
||||
...(permission === "context.selected.read" ? { allowedScopes: ["once"] } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
registerRpcCapabilities(registry) {
|
||||
if (this.registrations.length) throw new Error("AI/context routes are already registered");
|
||||
// Canonical validators are created only when transport integration registers
|
||||
// this service. There is no second plugin wire schema in these modules.
|
||||
const prepared = ROUTES.map(([method, paramsType, resultType, permission, operation]) => ({
|
||||
method, permission, operation,
|
||||
validateParams: createDefinitionValidator(paramsType),
|
||||
validateResult: createDefinitionValidator(resultType),
|
||||
}));
|
||||
for (const route of prepared) {
|
||||
this.registrations.push(registry.registerRequest(route.method, async (params, context) => {
|
||||
await this.#guard(context);
|
||||
const result = await this[route.operation](params, context);
|
||||
await this.#guard(context);
|
||||
if (!route.validateResult(result)) throw fail("internal", "AI/context response is invalid");
|
||||
return result;
|
||||
}, {
|
||||
metadata: { capability: "plugin-ai-context", mutating: false, permission: route.permission },
|
||||
validateParams: (params) => {
|
||||
if (!route.validateParams(params)) throw fail("invalidArgument", "AI/context request is invalid");
|
||||
return Object.freeze(structuredClone(params));
|
||||
},
|
||||
authorization: async (params, context) => {
|
||||
await this.#guard(context);
|
||||
if (route.operation === "readSelected") await this.selected.assertCurrent(params, context);
|
||||
return this.#authorization(route.permission, params);
|
||||
},
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
open(_params, context) { return this.broker.open(context); }
|
||||
start(params, context) { return this.broker.start(params, context); }
|
||||
read({ turnHandle }, context) { return this.broker.read(turnHandle, context); }
|
||||
async stop({ turnHandle }, context) { return { ok: await this.broker.stop(turnHandle, context) }; }
|
||||
async close({ chatHandle }, context) { return { ok: await this.broker.close(chatHandle, context) }; }
|
||||
|
||||
async list(_params, context) {
|
||||
const owner = await this.#guard(context);
|
||||
const selections = await this.host.listSelected(context);
|
||||
await this.#guard(context);
|
||||
if (!Array.isArray(selections) || selections.length > 5 || new Set(selections.map((item) => item.scope)).size !== selections.length) {
|
||||
throw fail("resourceExhausted", "Selected scope limit reached");
|
||||
}
|
||||
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) };
|
||||
}
|
||||
this.selected.releaseRuntime(context.runtimeId);
|
||||
const created = [];
|
||||
try {
|
||||
for (const { key, scope, title, version, readText, isCurrent } of selections) {
|
||||
if (typeof key !== "string" || !key || key.length > 256) throw fail("invalidArgument", "Host selection key is invalid");
|
||||
const summary = await this.selected.share({ scope, title, version, readText, isCurrent }, context);
|
||||
created.push(summary.handle);
|
||||
}
|
||||
await this.#guard(context);
|
||||
this.runtimeContexts.set(context.runtimeId, { owner, keys: JSON.stringify(selections.map(({ key, version }) => [key, version])) });
|
||||
return { items: await this.selected.summaries(context) };
|
||||
} catch (error) { for (const handle of created) this.selected.release(handle); throw error; }
|
||||
}
|
||||
|
||||
async readSelected(reference, context) {
|
||||
await this.#guard(context);
|
||||
await this.selected.assertCurrent(reference, context);
|
||||
const authorization = this.#authorization("context.selected.read", reference);
|
||||
// Direct RPC reads have already passed the registry permission middleware.
|
||||
// A model turn must obtain its separate content grant here.
|
||||
if (context.method !== "context.selected.read" || context.authorization?.permission !== authorization.permission
|
||||
|| context.authorization.resources?.[0] !== authorization.resources[0]) {
|
||||
await this.permissionEngine.authorize(context, authorization);
|
||||
}
|
||||
await this.#guard(context);
|
||||
const selected = await this.selected.read(reference, context);
|
||||
await this.#guard(context);
|
||||
if (!await this.host.reviewText({ scope: selected.scope, title: selected.title, version: selected.version,
|
||||
text: selected.text, pluginId: context.pluginId }, context)) throw fail("cancelled", "Selected context sharing was cancelled");
|
||||
await this.#guard(context);
|
||||
await this.selected.assertCurrent(reference, context);
|
||||
return selected;
|
||||
}
|
||||
|
||||
releaseRuntime(runtimeId) {
|
||||
this.broker.releaseRuntime(runtimeId);
|
||||
this.runtimeContexts.delete(runtimeId);
|
||||
this.source.releaseRuntime(runtimeId);
|
||||
this.host.releaseRuntime?.(runtimeId);
|
||||
}
|
||||
releasePlugin(pluginId) {
|
||||
for (const [runtimeId, { owner }] of [...this.runtimeContexts]) if (owner.pluginId === pluginId) this.releaseRuntime(runtimeId);
|
||||
for (const binding of [...this.source.bindings.values()]) if (binding.pluginId === pluginId) this.releaseRuntime(binding.runtimeId);
|
||||
this.source.releasePlugin(pluginId);
|
||||
}
|
||||
releaseAll() { for (const binding of [...this.source.bindings.values()]) this.releaseRuntime(binding.runtimeId); }
|
||||
shutdown() {
|
||||
for (const registration of this.registrations.splice(0)) registration.dispose();
|
||||
this.releaseAll(); this.broker.shutdown(); this.selected.shutdown(); this.source.shutdown();
|
||||
this.revocation.dispose(); this.unsubscribeHost?.(); this.host.shutdown?.();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { PluginAIContextService, PLUGIN_AI_CONTEXT_FEATURES: FEATURES, AI_CONTEXT_ROUTES: ROUTES };
|
||||
@@ -0,0 +1,263 @@
|
||||
"use strict";
|
||||
|
||||
const { randomUUID } = require("node:crypto");
|
||||
const { PluginRpcError, RPC_ERRORS } = require("../rpcRouter.cjs");
|
||||
const { assertPluginJsonValue } = require("../jsonBoundary.cjs");
|
||||
const fail = (code, message) => new PluginRpcError(RPC_ERRORS[code], message);
|
||||
const LIMITS = Object.freeze({ chats: 32, perRuntime: 2, turnMs: 60_000, chatMs: 300_000,
|
||||
inputBytes: 16_384, outputBytes: 32_768, outputTokens: 2_048, requestsPerMinute: 8,
|
||||
reservedTokensPerMinute: 65_536, contexts: 4, chunkBytes: 4_096, events: 8_192, rateOwners: 128 });
|
||||
|
||||
function boundedLimits(overrides) {
|
||||
return Object.freeze(Object.fromEntries(Object.entries(LIMITS).map(([key, max]) => {
|
||||
const value = overrides[key] ?? max;
|
||||
if (!Number.isSafeInteger(value) || value < 1 || value > max) throw new TypeError(`Invalid ${key} limit`);
|
||||
return [key, value];
|
||||
})));
|
||||
}
|
||||
|
||||
function abortable(promise, signal) {
|
||||
signal.throwIfAborted();
|
||||
return new Promise((resolve, reject) => {
|
||||
const abort = () => reject(signal.reason);
|
||||
signal.addEventListener("abort", abort, { once: true });
|
||||
Promise.resolve(promise).then(resolve, reject).finally(() => signal.removeEventListener("abort", abort));
|
||||
});
|
||||
}
|
||||
|
||||
// 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");
|
||||
this.resolveDataOwner = resolveDataOwner;
|
||||
this.selectModel = selectModel;
|
||||
this.contexts = contexts;
|
||||
this.clock = clock;
|
||||
this.limits = boundedLimits(limits);
|
||||
this.chats = new Map();
|
||||
this.turns = new Map();
|
||||
this.rates = new Map();
|
||||
this.closed = false;
|
||||
this.contextSubscription = contexts?.onDidInvalidate((handle) => {
|
||||
for (const turn of [...this.turns.values()]) {
|
||||
if (turn.references.some((reference) => reference.handle === handle)) {
|
||||
this.#end(turn, fail("cancelled", "Selected context was revoked"));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async #binding(context) {
|
||||
await context.assertActive();
|
||||
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 };
|
||||
}
|
||||
|
||||
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) {
|
||||
throw fail("permissionDenied", "Plugin chat is unavailable");
|
||||
}
|
||||
}
|
||||
|
||||
async #check(chat, context) {
|
||||
await this.#ownedChat(chat, context);
|
||||
if (!await chat.model.isCurrent()) throw fail("permissionDenied", "Selected model changed");
|
||||
const currentOwner = await this.resolveDataOwner(context);
|
||||
await context.assertActive();
|
||||
if (currentOwner !== chat.owner || this.closed || this.chats.get(chat.handle) !== chat || chat.expiresAt <= this.clock()) {
|
||||
throw fail("permissionDenied", "Plugin chat is unavailable");
|
||||
}
|
||||
}
|
||||
|
||||
#prune() {
|
||||
for (const chat of [...this.chats.values()]) if (chat.expiresAt <= this.clock()) this.#close(chat);
|
||||
for (const [owner, rate] of this.rates) if (rate.startedAt + 60_000 <= this.clock()) this.rates.delete(owner);
|
||||
}
|
||||
|
||||
async open(context) {
|
||||
const binding = await this.#binding(context);
|
||||
// Host chooses from configured models. Plugin parameters cannot override it.
|
||||
const model = await this.selectModel(context);
|
||||
await context.assertActive();
|
||||
if (!model || typeof model.invoke !== "function" || typeof model.isCurrent !== "function"
|
||||
|| typeof model.label !== "string" || model.label.length > 160 || !await model.isCurrent()) {
|
||||
throw fail("unavailable", "Choose a configured model in the host first");
|
||||
}
|
||||
await context.assertActive();
|
||||
this.#prune();
|
||||
if (this.closed || this.chats.size >= this.limits.chats
|
||||
|| [...this.chats.values()].filter((chat) => chat.runtimeId === context.runtimeId).length >= this.limits.perRuntime) {
|
||||
throw fail("resourceExhausted", "Plugin chat limit reached");
|
||||
}
|
||||
const chat = { ...binding, handle: randomUUID(), model, expiresAt: this.clock() + this.limits.chatMs, turn: null };
|
||||
this.chats.set(chat.handle, chat);
|
||||
return { chatHandle: chat.handle, model: model.label, expiresAt: chat.expiresAt };
|
||||
}
|
||||
|
||||
async start({ chatHandle, prompt, contexts = [] }, context) {
|
||||
assertPluginJsonValue({ chatHandle, prompt, contexts }, { maxBytes: this.limits.inputBytes + 2048 });
|
||||
if (typeof prompt !== "string" || !prompt.trim() || prompt.includes("\0") || !Array.isArray(contexts)
|
||||
|| contexts.length > this.limits.contexts || Buffer.byteLength(prompt) > this.limits.inputBytes) {
|
||||
throw fail("invalidArgument", "Invalid model input");
|
||||
}
|
||||
const references = contexts.map((reference) => {
|
||||
if (!reference || Array.isArray(reference) || Object.keys(reference).some((key) => !["handle", "version"].includes(key))
|
||||
|| typeof reference.handle !== "string" || !reference.handle || reference.handle.length > 128
|
||||
|| typeof reference.version !== "string" || !reference.version || reference.version.length > 128) {
|
||||
throw fail("invalidArgument", "Invalid context reference");
|
||||
}
|
||||
return Object.freeze({ handle: reference.handle, version: reference.version });
|
||||
});
|
||||
const chat = this.chats.get(chatHandle);
|
||||
if (!chat) throw fail("permissionDenied", "Plugin chat is unavailable");
|
||||
await this.#check(chat, context);
|
||||
if (chat.turn) throw fail("resourceExhausted", "A turn is already active");
|
||||
const controller = new AbortController();
|
||||
const turn = { handle: randomUUID(), chat, controller, reading: false, bytes: 0, sequence: 0, iterator: null, references: Object.freeze(references) };
|
||||
chat.turn = turn;
|
||||
this.turns.set(turn.handle, turn);
|
||||
const abort = () => this.#end(turn, fail("cancelled", "Model turn cancelled"));
|
||||
context.signal?.addEventListener("abort", abort, { once: true });
|
||||
turn.cleanup = () => context.signal?.removeEventListener("abort", abort);
|
||||
turn.timer = setTimeout(() => this.#end(turn, fail("deadlineExceeded", "Model turn timed out")), this.limits.turnMs);
|
||||
try {
|
||||
await this.#guard(turn, context);
|
||||
const selected = [];
|
||||
let bytes = Buffer.byteLength(prompt);
|
||||
for (const reference of references) {
|
||||
const result = await abortable(this.contexts.read(reference, { ...context, signal: controller.signal }), controller.signal);
|
||||
bytes += Buffer.byteLength(result.text);
|
||||
if (bytes > this.limits.inputBytes) throw fail("resourceExhausted", "Model input byte budget exceeded");
|
||||
selected.push(result.text);
|
||||
}
|
||||
await this.#guard(turn, context);
|
||||
this.#prune();
|
||||
const rate = this.rates.get(chat.owner) ?? { 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) {
|
||||
throw fail("resourceExhausted", "Model request/token budget reached");
|
||||
}
|
||||
rate.count++; rate.tokens += reserved; this.rates.set(chat.owner, 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);
|
||||
await this.#guard(turn, context);
|
||||
if (!iterator || typeof iterator.next !== "function") throw fail("internal", "Model adapter failed");
|
||||
turn.iterator = iterator;
|
||||
return { turnHandle: turn.handle, maxOutputTokens: this.limits.outputTokens, reservedTokens: reserved };
|
||||
} catch (error) {
|
||||
this.#end(turn, error);
|
||||
throw this.#safe(error);
|
||||
}
|
||||
}
|
||||
|
||||
async #guard(turn, context) {
|
||||
turn.controller.signal.throwIfAborted();
|
||||
await abortable(this.#check(turn.chat, context), turn.controller.signal);
|
||||
for (const reference of turn.references) {
|
||||
await abortable(this.contexts.assertCurrent(reference, { ...context, signal: turn.controller.signal }), turn.controller.signal);
|
||||
}
|
||||
turn.controller.signal.throwIfAborted();
|
||||
if (this.turns.get(turn.handle) !== turn || turn.chat.turn !== turn) throw fail("cancelled", "Model turn ended");
|
||||
}
|
||||
|
||||
#safe(error) {
|
||||
// SDK/provider errors may contain headers, URLs or echoed request content.
|
||||
return error instanceof PluginRpcError ? error : fail("unavailable", "Model invocation failed");
|
||||
}
|
||||
|
||||
async read(turnHandle, context) {
|
||||
const turn = this.turns.get(turnHandle);
|
||||
if (!turn) throw fail("permissionDenied", "Model turn is unavailable");
|
||||
// Validate owner before mutating/aborting another caller's turn.
|
||||
await this.#ownedChat(turn.chat, context);
|
||||
if (turn.reading || !turn.iterator) throw fail("resourceExhausted", "A model read is already pending");
|
||||
turn.reading = true;
|
||||
const abort = () => this.#end(turn, fail("cancelled", "Model read cancelled"));
|
||||
context.signal?.addEventListener("abort", abort, { once: true });
|
||||
try {
|
||||
if (context.signal?.aborted) abort();
|
||||
await this.#guard(turn, context);
|
||||
if (turn.sequence >= this.limits.events) throw fail("resourceExhausted", "Model event budget exceeded");
|
||||
const next = await abortable(turn.iterator.next(), turn.controller.signal);
|
||||
await this.#guard(turn, context);
|
||||
if (next.done) {
|
||||
this.#end(turn);
|
||||
return { kind: "end", sequence: turn.sequence++ };
|
||||
}
|
||||
const event = next.value;
|
||||
if (event?.kind === "text" && typeof event.text === "string") {
|
||||
const bytes = Buffer.byteLength(event.text);
|
||||
if (bytes > this.limits.chunkBytes || turn.bytes + bytes > this.limits.outputBytes) {
|
||||
throw fail("resourceExhausted", "Model output byte budget exceeded");
|
||||
}
|
||||
turn.bytes += bytes;
|
||||
return { kind: "text", sequence: turn.sequence++, text: event.text };
|
||||
}
|
||||
if (event?.kind === "usage") {
|
||||
const usage = {};
|
||||
for (const key of ["inputTokens", "outputTokens", "totalTokens"]) {
|
||||
if (event[key] !== undefined) {
|
||||
if (!Number.isSafeInteger(event[key]) || event[key] < 0) throw fail("internal", "Invalid model usage");
|
||||
usage[key] = event[key];
|
||||
}
|
||||
}
|
||||
if ((usage.outputTokens ?? 0) > this.limits.outputTokens) throw fail("resourceExhausted", "Model output token budget exceeded");
|
||||
return { kind: "usage", sequence: turn.sequence++, ...usage };
|
||||
}
|
||||
// A model's tool request is data, never host authorization. This phase
|
||||
// exposes no execution path, and drops raw tool payloads/reasoning/errors.
|
||||
throw fail("permissionDenied", "Model tools and unsupported events are disabled");
|
||||
} catch (error) {
|
||||
this.#end(turn, error);
|
||||
throw this.#safe(error);
|
||||
} finally { turn.reading = false; context.signal?.removeEventListener("abort", abort); }
|
||||
}
|
||||
|
||||
async stop(turnHandle, context) {
|
||||
const turn = this.turns.get(turnHandle);
|
||||
if (!turn) return false;
|
||||
await this.#ownedChat(turn.chat, context);
|
||||
this.#end(turn, fail("cancelled", "Model turn cancelled"));
|
||||
return true;
|
||||
}
|
||||
|
||||
#end(turn, reason = fail("cancelled", "Model turn ended")) {
|
||||
if (this.turns.get(turn.handle) !== turn) return;
|
||||
this.turns.delete(turn.handle);
|
||||
if (turn.chat.turn === turn) turn.chat.turn = null;
|
||||
clearTimeout(turn.timer); turn.cleanup?.(); turn.controller.abort(reason);
|
||||
// Never wait for a provider which ignores cancellation. Its late next()
|
||||
// result is observed by abortable() and cannot re-enter the output path.
|
||||
try { Promise.resolve(turn.iterator?.return?.()).catch(() => {}); } catch {}
|
||||
turn.iterator = null;
|
||||
}
|
||||
|
||||
#close(chat) {
|
||||
if (chat.turn) this.#end(chat.turn);
|
||||
this.chats.delete(chat.handle);
|
||||
}
|
||||
async close(chatHandle, context) {
|
||||
const chat = this.chats.get(chatHandle);
|
||||
if (!chat) return false;
|
||||
await this.#ownedChat(chat, context); this.#close(chat); return true;
|
||||
}
|
||||
releaseRuntime(runtimeId) {
|
||||
for (const chat of [...this.chats.values()]) if (chat.runtimeId === runtimeId) this.#close(chat);
|
||||
this.contexts?.releaseRuntime(runtimeId);
|
||||
}
|
||||
releaseOwner(owner) {
|
||||
for (const chat of [...this.chats.values()]) if (chat.owner === owner) this.#close(chat);
|
||||
this.contexts?.releaseOwner(owner);
|
||||
}
|
||||
shutdown() { this.closed = true; for (const chat of [...this.chats.values()]) this.#close(chat); this.rates.clear(); this.contextSubscription?.dispose(); }
|
||||
}
|
||||
|
||||
module.exports = { ModelInvocationBroker, MODEL_INVOCATION_LIMITS: LIMITS };
|
||||
@@ -0,0 +1,211 @@
|
||||
"use strict";
|
||||
const assert = require("node:assert/strict");
|
||||
const test = require("node:test");
|
||||
const { ModelInvocationBroker } = require("./modelInvocationBroker.cjs");
|
||||
const { SelectedContextAuthority } = require("../context/selectedContextAuthority.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 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"); } };
|
||||
const calls = [];
|
||||
const model = { label: "Host selected model", isCurrent: () => current,
|
||||
invoke: (input) => { calls.push(input); return (async function* () { yield { kind: "text", text: "Summary" };
|
||||
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,
|
||||
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; } };
|
||||
}
|
||||
|
||||
test("selected synthetic Note traverses actual broker; text/usage are projected without configuration or secrets", async (t) => {
|
||||
const f = fixture(); t.after(() => f.broker.shutdown());
|
||||
const note = await f.contexts.share({ scope: "note", title: "Synthetic note", version: "v1",
|
||||
readText: () => "Synthetic release: add retry button", isCurrent: () => true }, f.context);
|
||||
assert.equal(f.calls.length, 0);
|
||||
const chat = await f.broker.open(f.context);
|
||||
const turn = await f.broker.start({ chatHandle: chat.chatHandle, prompt: "Summarize", contexts: [{ handle: note.handle, version: note.version }] }, f.context);
|
||||
assert.deepEqual(f.calls[0].contexts, ["Synthetic release: add retry button"]);
|
||||
assert.equal(f.calls[0].tools, undefined);
|
||||
const events = [await f.broker.read(turn.turnHandle, f.context), await f.broker.read(turn.turnHandle, f.context), await f.broker.read(turn.turnHandle, f.context)];
|
||||
assert.equal(events[0].text, "Summary");
|
||||
assert.deepEqual(events[1], { kind: "usage", sequence: 1, inputTokens: 7, outputTokens: 2, totalTokens: 9 });
|
||||
assert.equal(events[2].kind, "end");
|
||||
assert.doesNotMatch(JSON.stringify({ chat, turn, events, note }), /apiKey|config|DO_NOT_SERIALIZE/);
|
||||
});
|
||||
|
||||
test("same pluginId with another source owner, runtime or version cannot use handles or stop victim turns", async (t) => {
|
||||
const f = fixture(); t.after(() => f.broker.shutdown());
|
||||
const chat = await f.broker.open(f.context);
|
||||
const turn = await f.broker.start({ chatHandle: chat.chatHandle, prompt: "Hi" }, f.context);
|
||||
for (const changed of [{ runtimeId: "r2" }, { pluginVersion: "2" }]) {
|
||||
await assert.rejects(f.broker.read(turn.turnHandle, { ...f.context, ...changed }), denied);
|
||||
await assert.rejects(f.broker.stop(turn.turnHandle, { ...f.context, ...changed }), denied);
|
||||
}
|
||||
f.setOwner({});
|
||||
await assert.rejects(f.broker.read(turn.turnHandle, f.context), denied);
|
||||
await assert.rejects(f.broker.close(chat.chatHandle, f.context), denied);
|
||||
f.setOwner(f.owner);
|
||||
assert.equal((await f.broker.read(turn.turnHandle, f.context)).text, "Summary");
|
||||
});
|
||||
|
||||
test("pending read cancels promptly and a provider's late result never escapes", async (t) => {
|
||||
const pending = deferred(); let signal;
|
||||
const f = fixture({ model: { invoke: (input) => { signal = input.signal; return { next: () => pending.promise }; } } });
|
||||
t.after(() => f.broker.shutdown());
|
||||
const chat = await f.broker.open(f.context);
|
||||
const turn = await f.broker.start({ chatHandle: chat.chatHandle, prompt: "Hi" }, f.context);
|
||||
const reading = f.broker.read(turn.turnHandle, f.context);
|
||||
await new Promise(setImmediate);
|
||||
assert.equal(await f.broker.stop(turn.turnHandle, f.context), true);
|
||||
await assert.rejects(reading, (e) => e.code === RPC_ERRORS.cancelled);
|
||||
assert.equal(signal.aborted, true);
|
||||
pending.resolve({ value: { kind: "text", text: "Late output" } });
|
||||
await assert.rejects(f.broker.read(turn.turnHandle, f.context), denied);
|
||||
});
|
||||
|
||||
test("runtime teardown, owner revoke and request abort discard pending results", async () => {
|
||||
for (const action of ["runtime", "owner", "abort"]) {
|
||||
const pending = deferred();
|
||||
const f = fixture({ model: { invoke: () => ({ next: () => pending.promise }) } });
|
||||
const chat = await f.broker.open(f.context);
|
||||
const turn = await f.broker.start({ chatHandle: chat.chatHandle, prompt: "Hi" }, f.context);
|
||||
const reading = f.broker.read(turn.turnHandle, f.context);
|
||||
await new Promise(setImmediate);
|
||||
if (action === "runtime") f.broker.releaseRuntime("r1");
|
||||
if (action === "owner") f.broker.releaseOwner(f.owner);
|
||||
if (action === "abort") f.request.abort();
|
||||
await assert.rejects(reading);
|
||||
pending.resolve({ value: { kind: "text", text: "Late" } });
|
||||
assert.equal(f.broker.turns.size, 0); f.broker.shutdown();
|
||||
}
|
||||
});
|
||||
|
||||
test("deadline also aborts providers that ignore AbortSignal", async () => {
|
||||
const f = fixture({ model: { invoke: () => ({ next: () => new Promise(() => {}) }) }, limits: { turnMs: 20 } });
|
||||
const chat = await f.broker.open(f.context);
|
||||
const turn = await f.broker.start({ chatHandle: chat.chatHandle, prompt: "Hi" }, f.context);
|
||||
await assert.rejects(f.broker.read(turn.turnHandle, f.context), (e) => e.code === RPC_ERRORS.deadlineExceeded);
|
||||
assert.equal(f.broker.turns.size, 0); f.broker.shutdown();
|
||||
});
|
||||
|
||||
test("model switch and chat expiration reject results", async () => {
|
||||
for (const action of ["model", "expiry"]) {
|
||||
const pending = deferred(); const f = fixture({ model: { invoke: () => ({ next: () => pending.promise }) } });
|
||||
const chat = await f.broker.open(f.context);
|
||||
const turn = await f.broker.start({ chatHandle: chat.chatHandle, prompt: "Hi" }, f.context);
|
||||
const reading = f.broker.read(turn.turnHandle, f.context); await new Promise(setImmediate);
|
||||
if (action === "model") f.setCurrent(false); else f.setTime(300_001);
|
||||
pending.resolve({ value: { kind: "text", text: "Late" } });
|
||||
await assert.rejects(reading, denied); f.broker.shutdown();
|
||||
}
|
||||
});
|
||||
|
||||
test("bounds cover request rate, output bytes/tokens, concurrent read and raw error serialization", async () => {
|
||||
const f = fixture({ limits: { requestsPerMinute: 1 } });
|
||||
const chat = await f.broker.open(f.context);
|
||||
const turn = await f.broker.start({ chatHandle: chat.chatHandle, prompt: "Hi" }, f.context);
|
||||
await f.broker.stop(turn.turnHandle, f.context);
|
||||
await assert.rejects(f.broker.start({ chatHandle: chat.chatHandle, prompt: "Hi" }, f.context), (e) => e.code === RPC_ERRORS.resourceExhausted);
|
||||
f.broker.releaseRuntime("r1");
|
||||
const again = await f.broker.open(f.context);
|
||||
await assert.rejects(f.broker.start({ chatHandle: again.chatHandle, prompt: "Hi" }, f.context), (e) => e.code === RPC_ERRORS.resourceExhausted);
|
||||
f.broker.shutdown();
|
||||
for (const event of [{ kind: "text", text: "X".repeat(4097) }, { kind: "usage", outputTokens: 2049 }, { kind: "tool-call", command: "rm -rf /" }]) {
|
||||
const instance = fixture({ model: { invoke: () => (async function* () { yield event; })() } });
|
||||
const selected = await instance.broker.open(instance.context);
|
||||
const started = await instance.broker.start({ chatHandle: selected.chatHandle, prompt: "Hi" }, instance.context);
|
||||
await assert.rejects(instance.broker.read(started.turnHandle, instance.context));
|
||||
assert.equal(instance.broker.turns.size, 0); instance.broker.shutdown();
|
||||
}
|
||||
const broken = fixture({ model: { invoke: () => { throw Error("api-key secret request body"); } } });
|
||||
const selected = await broken.broker.open(broken.context);
|
||||
await assert.rejects(broken.broker.start({ chatHandle: selected.chatHandle, prompt: "Hi" }, broken.context), (e) => !/secret|api-key/.test(e.message));
|
||||
broken.broker.shutdown();
|
||||
});
|
||||
|
||||
test("revoking selected context aborts an in-flight turn; changing its version suppresses late output", async () => {
|
||||
for (const action of ["revoke", "version"]) {
|
||||
let current = true; const pending = deferred();
|
||||
const f = fixture({ model: { invoke: () => ({ next: () => pending.promise }) } });
|
||||
const note = await f.contexts.share({ scope: "note", title: "Synthetic", version: "1",
|
||||
readText: () => "Synthetic", isCurrent: () => current }, f.context);
|
||||
const chat = await f.broker.open(f.context);
|
||||
const turn = await f.broker.start({ chatHandle: chat.chatHandle, prompt: "Hi", contexts: [{ handle: note.handle, version: note.version }] }, f.context);
|
||||
const reading = f.broker.read(turn.turnHandle, f.context); await new Promise(setImmediate);
|
||||
if (action === "revoke") f.contexts.release(note.handle); else current = false;
|
||||
pending.resolve({ value: { kind: "text", text: "Late" } });
|
||||
await assert.rejects(reading); f.broker.shutdown();
|
||||
}
|
||||
});
|
||||
|
||||
test("input, aggregate output, reserved token and concurrent read bounds", async () => {
|
||||
const cases = [
|
||||
{ limits: { inputBytes: 2 }, prompt: "too big", start: true },
|
||||
{ limits: { reservedTokensPerMinute: 1 }, prompt: "Hi", start: true },
|
||||
{ limits: { outputBytes: 2 }, prompt: "Hi", start: false },
|
||||
];
|
||||
for (const scenario of cases) {
|
||||
const f = fixture({ limits: scenario.limits }); const chat = await f.broker.open(f.context);
|
||||
const start = () => f.broker.start({ chatHandle: chat.chatHandle, prompt: scenario.prompt }, f.context);
|
||||
if (scenario.start) await assert.rejects(start());
|
||||
else { const turn = await start(); await assert.rejects(f.broker.read(turn.turnHandle, f.context)); }
|
||||
assert.equal(f.broker.turns.size, 0); f.broker.shutdown();
|
||||
}
|
||||
const pending = deferred(); const f = fixture({ model: { invoke: () => ({ next: () => pending.promise }) } });
|
||||
const chat = await f.broker.open(f.context); const turn = await f.broker.start({ chatHandle: chat.chatHandle, prompt: "Hi" }, f.context);
|
||||
const reading = f.broker.read(turn.turnHandle, f.context); await new Promise(setImmediate);
|
||||
await assert.rejects(f.broker.read(turn.turnHandle, f.context), (e) => e.code === RPC_ERRORS.resourceExhausted);
|
||||
pending.resolve({ value: { kind: "text", text: "OK" } }); assert.equal((await reading).text, "OK"); f.broker.shutdown();
|
||||
});
|
||||
|
||||
test("the owner can stop or close after a model switch; read request cancellation aborts the provider", async () => {
|
||||
const f = fixture(); const chat = await f.broker.open(f.context);
|
||||
const turn = await f.broker.start({ chatHandle: chat.chatHandle, prompt: "Hi" }, f.context);
|
||||
f.setCurrent(false); assert.equal(await f.broker.stop(turn.turnHandle, f.context), true);
|
||||
assert.equal(await f.broker.close(chat.chatHandle, f.context), true); f.broker.shutdown();
|
||||
const pending = deferred(); const instance = fixture({ model: { invoke: () => ({ next: () => pending.promise }) } });
|
||||
const selected = await instance.broker.open(instance.context);
|
||||
const started = await instance.broker.start({ chatHandle: selected.chatHandle, prompt: "Hi" }, instance.context);
|
||||
const controller = new AbortController();
|
||||
const reading = instance.broker.read(started.turnHandle, { ...instance.context, signal: controller.signal });
|
||||
await new Promise(setImmediate); controller.abort(); await assert.rejects(reading);
|
||||
assert.equal(instance.broker.turns.size, 0); pending.resolve({ done: true }); instance.broker.shutdown();
|
||||
});
|
||||
|
||||
test("event and handle caps prevent cheap empty streams and unbounded allocation", async () => {
|
||||
const f = fixture({ limits: { perRuntime: 1, events: 1 }, model: { invoke: () => (async function* () {
|
||||
yield { kind: "text", text: "" }; yield { kind: "text", text: "" };
|
||||
})() } });
|
||||
const chat = await f.broker.open(f.context); await assert.rejects(f.broker.open(f.context));
|
||||
const turn = await f.broker.start({ chatHandle: chat.chatHandle, prompt: "Hi" }, f.context);
|
||||
assert.equal((await f.broker.read(turn.turnHandle, f.context)).text, "");
|
||||
await assert.rejects(f.broker.read(turn.turnHandle, f.context), (e) => e.code === RPC_ERRORS.resourceExhausted); f.broker.shutdown();
|
||||
});
|
||||
|
||||
test("malformed context cannot publish a turn or interrupt two-handle runtime revocation", async (t) => {
|
||||
const entered = deferred(); const resume = deferred(); let checks = 0;
|
||||
const f = fixture({ model: { isCurrent: async () => { if (++checks === 3) { entered.resolve(); await resume.promise; } return true; } } });
|
||||
t.after(() => { resume.resolve(); f.broker.shutdown(); });
|
||||
const selection = { scope: "note", title: "Synthetic", version: "1", readText: () => "Synthetic", isCurrent: () => true };
|
||||
await f.contexts.share(selection, f.context); await f.contexts.share(selection, f.context);
|
||||
const chat = await f.broker.open(f.context);
|
||||
const start = f.broker.start({ chatHandle: chat.chatHandle, prompt: "Hi", contexts: [null] }, f.context);
|
||||
const outcome = start.then(() => "accepted", (error) => error);
|
||||
// On the old implementation, reach the reported post-publication model guard.
|
||||
// On the fixed implementation, validation rejects before that await exists.
|
||||
await Promise.race([entered.promise, outcome]);
|
||||
assert.doesNotThrow(() => f.contexts.releaseRuntime("r1"));
|
||||
assert.equal(f.contexts.handles.size, 0);
|
||||
resume.resolve();
|
||||
const error = await outcome;
|
||||
assert.equal(error.code, RPC_ERRORS.invalidArgument);
|
||||
assert.equal(f.broker.turns.size, 0);
|
||||
assert.equal(f.calls.length, 0);
|
||||
f.broker.shutdown();
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
"use strict";
|
||||
|
||||
const { PluginRpcError, RPC_ERRORS } = require("../rpcRouter.cjs");
|
||||
const denied = () => new PluginRpcError(RPC_ERRORS.permissionDenied, "Plugin source binding is unavailable");
|
||||
|
||||
// Consume the market task's host-owned API. No source or owner is read from RPC
|
||||
// parameters, and a missing API/retained source fails closed.
|
||||
class PluginAISourceBinding {
|
||||
constructor({ database, getRuntimeIdentity }) {
|
||||
for (const method of ["getActivePlugin", "getRetainedPluginSource", "privateOwner", "publicPluginId"]) {
|
||||
if (typeof database?.[method] !== "function") throw new TypeError(`Source database ${method} is required`);
|
||||
}
|
||||
if (typeof getRuntimeIdentity !== "function") throw new TypeError("Runtime identity resolver required");
|
||||
this.database = database;
|
||||
this.getRuntimeIdentity = getRuntimeIdentity;
|
||||
this.bindings = new Map();
|
||||
}
|
||||
|
||||
#snapshot(context) {
|
||||
const identity = this.getRuntimeIdentity(context.pluginId);
|
||||
if (!identity || identity.pluginId !== context.pluginId || identity.runtimeId !== context.runtimeId
|
||||
|| identity.pluginVersion !== context.pluginVersion || identity.runtimeKind !== "browser") throw denied();
|
||||
const plugin = this.database.getActivePlugin(identity.pluginId);
|
||||
const source = plugin?.source;
|
||||
const retained = this.database.getRetainedPluginSource(identity.pluginId);
|
||||
if (!plugin?.enabled || plugin.activeVersion !== identity.pluginVersion || !source || !retained
|
||||
|| !["starting", "running"].includes(plugin.runtime?.status)
|
||||
|| typeof source.id !== "string" || typeof source.kind !== "string"
|
||||
|| source.id !== retained.id || source.kind !== retained.kind || source.origin !== retained.origin) throw denied();
|
||||
const privateOwner = this.database.privateOwner(identity.pluginId);
|
||||
if (typeof privateOwner !== "string" || !privateOwner || this.database.publicPluginId(privateOwner) !== identity.pluginId) throw denied();
|
||||
return { runtimeId: identity.runtimeId, pluginId: identity.pluginId, pluginVersion: identity.pluginVersion,
|
||||
sourceKey: JSON.stringify([source.id, source.kind, source.origin ?? null]), privateOwner };
|
||||
}
|
||||
|
||||
async resolve(context) {
|
||||
// Authenticate first. Even the database source lookup follows this guard.
|
||||
await context.assertActive();
|
||||
context.signal?.throwIfAborted();
|
||||
const snapshot = this.#snapshot(context);
|
||||
const previous = this.bindings.get(snapshot.runtimeId);
|
||||
if (previous && (previous.sourceKey !== snapshot.sourceKey || previous.privateOwner !== snapshot.privateOwner
|
||||
|| previous.pluginId !== snapshot.pluginId || previous.pluginVersion !== snapshot.pluginVersion)) throw denied();
|
||||
await context.assertActive();
|
||||
context.signal?.throwIfAborted();
|
||||
const current = this.#snapshot(context);
|
||||
if (current.sourceKey !== snapshot.sourceKey || current.privateOwner !== snapshot.privateOwner) throw denied();
|
||||
if (previous) return previous;
|
||||
const binding = Object.freeze(snapshot);
|
||||
this.bindings.set(binding.runtimeId, binding);
|
||||
return binding;
|
||||
}
|
||||
|
||||
releaseRuntime(runtimeId) { this.bindings.delete(runtimeId); }
|
||||
releasePlugin(pluginId) {
|
||||
for (const [runtimeId, binding] of this.bindings) if (binding.pluginId === pluginId) this.bindings.delete(runtimeId);
|
||||
}
|
||||
shutdown() { this.bindings.clear(); }
|
||||
}
|
||||
|
||||
module.exports = { PluginAISourceBinding };
|
||||
@@ -0,0 +1,41 @@
|
||||
"use strict";
|
||||
const assert = require("node:assert/strict");
|
||||
const test = require("node:test");
|
||||
const { PluginAISourceBinding } = require("./sourceBinding.cjs");
|
||||
function fixture() {
|
||||
let source = { id: "source-a", kind: "third-party", origin: "https://registry.example" };
|
||||
let owner = "source:opaque-a:com.example.assistant"; let active = true; let runtimeId = "r1"; let reads = 0;
|
||||
const context = { pluginId: "com.example.assistant", pluginVersion: "1", runtimeId: "r1", assertActive: async () => { if (!active) throw Error("Inactive"); } };
|
||||
const database = {
|
||||
getActivePlugin: () => { reads++; return { source, enabled: true, activeVersion: "1", runtime: { status: "running" } }; },
|
||||
getRetainedPluginSource: () => source,
|
||||
privateOwner: () => owner,
|
||||
publicPluginId: (value) => value === owner ? context.pluginId : null,
|
||||
};
|
||||
const binding = new PluginAISourceBinding({ database, getRuntimeIdentity: () => ({ ...context, runtimeId, runtimeKind: "browser" }) });
|
||||
return { binding, context, database, reads: () => reads, activeTo: (v) => { active = v; }, runtimeTo: (v) => { runtimeId = v; },
|
||||
sourceTo: (v) => { source = v; }, ownerTo: (v) => { owner = v; } };
|
||||
}
|
||||
|
||||
test("host authentication precedes database access; forged runtime and missing provenance fail closed", async () => {
|
||||
const f = fixture(); f.activeTo(false); await assert.rejects(f.binding.resolve(f.context)); assert.equal(f.reads(), 0);
|
||||
f.activeTo(true); f.runtimeTo("different"); await assert.rejects(f.binding.resolve(f.context)); assert.equal(f.reads(), 0);
|
||||
f.runtimeTo("r1"); f.database.getRetainedPluginSource = () => null; await assert.rejects(f.binding.resolve(f.context));
|
||||
assert.throws(() => new PluginAISourceBinding({ database: {}, getRuntimeIdentity: () => null }));
|
||||
});
|
||||
|
||||
test("opaque host owner remains stable and source changes cannot resume the old runtime", async () => {
|
||||
const f = fixture(); const owner = await f.binding.resolve({ ...f.context, source: "attacker", privateOwner: "attacker" });
|
||||
assert.equal(owner.privateOwner, "source:opaque-a:com.example.assistant");
|
||||
assert.equal(await f.binding.resolve(f.context), owner);
|
||||
f.sourceTo({ id: "source-b", kind: "third-party", origin: "https://other.example" });
|
||||
f.ownerTo("source:opaque-b:com.example.assistant"); await assert.rejects(f.binding.resolve(f.context));
|
||||
// A fresh authenticated runtime may acquire its own distinct binding.
|
||||
f.binding.releaseRuntime("r1"); const next = await f.binding.resolve(f.context); assert.notEqual(next, owner);
|
||||
});
|
||||
|
||||
test("source/owner changes during asynchronous authentication cannot commit a binding", async () => {
|
||||
const f = fixture(); let checks = 0;
|
||||
const context = { ...f.context, assertActive: async () => { if (++checks === 2) f.ownerTo("changed-owner"); } };
|
||||
await assert.rejects(f.binding.resolve(context)); assert.equal(f.binding.bindings.size, 0);
|
||||
});
|
||||
@@ -288,9 +288,9 @@ class BrowserPluginRuntime {
|
||||
this.router.notify(method, params);
|
||||
}
|
||||
|
||||
openStream(streamId, windowBytes) {
|
||||
openStream(streamId, windowBytes, options) {
|
||||
if (!this.router) return Promise.reject(new Error("Plugin browser runtime is not connected"));
|
||||
return this.router.streams.openOutgoing(streamId, windowBytes);
|
||||
return this.router.streams.openOutgoing(streamId, windowBytes, options);
|
||||
}
|
||||
|
||||
getProcessId() {
|
||||
|
||||
@@ -0,0 +1,297 @@
|
||||
"use strict";
|
||||
|
||||
// Host-only transport core. No protocol, credential lookup, or plugin-supplied
|
||||
// Node runs here. The registered service authenticates source-bound runtimes.
|
||||
const net = require("node:net");
|
||||
const tls = require("node:tls");
|
||||
const dns = require("node:dns/promises");
|
||||
const { randomUUID } = require("node:crypto");
|
||||
const { PluginRpcError, RPC_ERRORS, raceWithAbort } = require("./rpcRouter.cjs");
|
||||
|
||||
const LIMITS = Object.freeze(require("./generated/plugin-contract.schema.json").$defs.ByteStreamLimits.const);
|
||||
const { canonicalizeStreamEndpoint, isLocalControlAddress } = require("./byteStreamEndpoint.cjs");
|
||||
|
||||
function failure(code, message) { return new PluginRpcError(RPC_ERRORS[code], message); }
|
||||
|
||||
class PluginByteStreamBroker {
|
||||
constructor(options) {
|
||||
this.permissionEngine = options.permissionEngine;
|
||||
this.quotaManager = options.quotaManager;
|
||||
this.audit = options.audit ?? (() => {});
|
||||
this.lookup = options.lookup ?? dns.lookup;
|
||||
this.connectSocket = options.connectSocket ?? ((params) => net.createConnection(params));
|
||||
this.connectTls = options.connectTls ?? ((params) => tls.connect(params));
|
||||
// Only a host test harness can opt a specific loopback endpoint in. The
|
||||
// production default excludes all loopback/link-local control surfaces.
|
||||
this.testLocalEndpoints = new Set(options.testLocalEndpoints ?? []);
|
||||
this.isControlEndpoint = options.isControlEndpoint ?? (() => false);
|
||||
this.connections = new Map();
|
||||
this.closeListeners = new Set();
|
||||
this.closed = false;
|
||||
this.revocation = this.permissionEngine.onDidRevoke((event) => {
|
||||
for (const state of this.connections.values()) {
|
||||
if (state.pluginId === event.pluginId
|
||||
&& (event.permission === undefined || event.permission === "network.stream")
|
||||
&& (event.resource === undefined || event.resource === state.endpoint)) {
|
||||
this.finish(state, failure("permissionDenied", "Byte stream permission was revoked"));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
record(state, operation) {
|
||||
// Audit only host-observed generic operations, never bytes or peer messages.
|
||||
try { this.audit(state.pluginId, `network.stream.${operation}`, {
|
||||
runtimeId: state.runtimeId, endpoint: state.endpoint,
|
||||
handleId: state.id, bytesRead: state.bytesRead, bytesWritten: state.bytesWritten,
|
||||
}, state); } catch { /* An audit sink cannot keep a revoked socket alive. */ }
|
||||
}
|
||||
|
||||
finish(state, reason) {
|
||||
if (state.closed) return;
|
||||
state.closed = true;
|
||||
clearTimeout(state.connectTimer);
|
||||
clearTimeout(state.idleTimer);
|
||||
clearTimeout(state.lifetimeTimer);
|
||||
state.error = reason;
|
||||
this.connections.delete(state.id);
|
||||
state.controller.abort(reason ?? failure("cancelled", "Byte stream closed"));
|
||||
state.socket?.destroy();
|
||||
this.record(state, "close");
|
||||
for (const listener of this.closeListeners) {
|
||||
try { listener(state, reason); } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
onDidClose(listener) {
|
||||
this.closeListeners.add(listener);
|
||||
return { dispose: () => this.closeListeners.delete(listener) };
|
||||
}
|
||||
|
||||
touch(state) {
|
||||
clearTimeout(state.idleTimer);
|
||||
state.idleTimer = setTimeout(() => this.finish(state,
|
||||
failure("deadlineExceeded", "Byte stream idle timeout")), LIMITS.idleMs);
|
||||
state.idleTimer.unref?.();
|
||||
}
|
||||
|
||||
async active(state, context) {
|
||||
this.assertCommit(state, context);
|
||||
await context.assertActive();
|
||||
this.assertCommit(state, context);
|
||||
}
|
||||
|
||||
assertCommit(state, context) {
|
||||
if (this.closed || state.closed || this.connections.get(state.id) !== state) {
|
||||
throw state.error ?? failure("unavailable", "Byte stream is closed");
|
||||
}
|
||||
state.controller.signal.throwIfAborted();
|
||||
context.signal?.throwIfAborted();
|
||||
context.assertCommit?.();
|
||||
}
|
||||
|
||||
async connect(endpoint, context) {
|
||||
let target;
|
||||
try { target = canonicalizeStreamEndpoint(endpoint); }
|
||||
catch { throw failure("invalidArgument", "Byte stream endpoint is invalid"); }
|
||||
if (this.closed) throw failure("unavailable", "Byte stream broker stopped");
|
||||
if (!context.runtimeId || !context.pluginId || typeof context.assertActive !== "function") {
|
||||
throw failure("unauthenticated", "Byte streams require a host runtime identity");
|
||||
}
|
||||
const count = [...this.connections.values()].filter((state) => state.runtimeId === context.runtimeId).length;
|
||||
if (count >= LIMITS.connectionsPerRuntime || this.connections.size >= LIMITS.connectionsTotal) {
|
||||
throw failure("resourceExhausted", "Byte stream connection limit exceeded");
|
||||
}
|
||||
const state = {
|
||||
...target, id: randomUUID(), pluginId: context.pluginId, runtimeId: context.runtimeId,
|
||||
controller: new AbortController(), bytesRead: 0, bytesWritten: 0,
|
||||
sourceOwner: context.sourceOwner, sourceId: context.sourceId,
|
||||
closed: false, reading: false, writing: false, socket: null,
|
||||
};
|
||||
this.connections.set(state.id, state);
|
||||
const onAbort = () => this.finish(state, failure("cancelled", "Byte stream connection cancelled"));
|
||||
context.signal?.addEventListener("abort", onAbort, { once: true });
|
||||
try {
|
||||
await this.active(state, context);
|
||||
this.assertCommit(state, context);
|
||||
await raceWithAbort(Promise.resolve(this.permissionEngine.authorize({ ...context, signal: state.controller.signal }, {
|
||||
permission: "network.stream", resources: [target.endpoint],
|
||||
reason: `Open byte stream to ${target.endpoint}`,
|
||||
operationId: `network.stream:${target.endpoint}`,
|
||||
validateBeforeGrant: () => this.assertCommit(state, context),
|
||||
})), state.controller.signal);
|
||||
await this.active(state, context);
|
||||
state.connectTimer = setTimeout(() => this.finish(state,
|
||||
failure("deadlineExceeded", "Byte stream connect timeout")), LIMITS.connectMs);
|
||||
const addresses = net.isIP(target.host)
|
||||
? [{ address: target.host, family: net.isIP(target.host) }]
|
||||
: await raceWithAbort(Promise.resolve(this.lookup(target.host, { all: true, verbatim: true })), state.controller.signal);
|
||||
await this.active(state, context);
|
||||
if (!Array.isArray(addresses) || addresses.length === 0 || addresses.length > 64) {
|
||||
throw failure("unavailable", "Byte stream hostname could not be resolved");
|
||||
}
|
||||
// Validate the whole DNS answer and pin one literal address. No second
|
||||
// lookup, redirect, proxy discovery, or reconnect can change the peer.
|
||||
for (const peer of addresses) {
|
||||
if (![4, 6].includes(peer.family) || net.isIP(peer.address) !== peer.family) {
|
||||
throw failure("unavailable", "Byte stream resolver returned an invalid address");
|
||||
}
|
||||
const local = isLocalControlAddress(peer.address);
|
||||
const allowedTestPeer = this.testLocalEndpoints.has(target.endpoint)
|
||||
&& !this.isControlEndpoint(peer.address, target.port);
|
||||
if ((local && !allowedTestPeer) || (!local && this.isControlEndpoint(peer.address, target.port))) {
|
||||
throw failure("permissionDenied", "Byte streams cannot reach local or application control endpoints");
|
||||
}
|
||||
}
|
||||
const peer = addresses[0];
|
||||
const params = {
|
||||
host: peer.address, port: target.port, family: peer.family,
|
||||
highWaterMark: LIMITS.bufferBytes,
|
||||
};
|
||||
if (target.transport === "tls") {
|
||||
Object.assign(params, {
|
||||
rejectUnauthorized: true, minVersion: "TLSv1.2",
|
||||
...(net.isIP(target.host) ? {} : { servername: target.host }),
|
||||
checkServerIdentity: (_name, certificate) => tls.checkServerIdentity(target.host, certificate),
|
||||
});
|
||||
}
|
||||
this.assertCommit(state, context);
|
||||
const socket = target.transport === "tls" ? this.connectTls(params) : this.connectSocket(params);
|
||||
state.socket = socket;
|
||||
if (state.closed) { socket.destroy(); throw state.error ?? failure("cancelled", "Byte stream connection cancelled"); }
|
||||
socket.on("error", () => this.finish(state, failure("unavailable", "Byte stream transport failed")));
|
||||
// Preserve one EOF observation for a delayed consumer. This reservation
|
||||
// still counts toward quotas and expires on the existing idle/life timer.
|
||||
socket.on("close", () => {
|
||||
if (!state.eof) this.finish(state, failure("unavailable", "Byte stream transport closed"));
|
||||
});
|
||||
socket.on("end", () => { state.eof = true; });
|
||||
socket.pause();
|
||||
await raceWithAbort(new Promise((resolve) => socket.once(
|
||||
target.transport === "tls" ? "secureConnect" : "connect", resolve,
|
||||
)), state.controller.signal);
|
||||
await this.active(state, context);
|
||||
if (target.transport === "tls" && !socket.authorized) {
|
||||
throw failure("permissionDenied", "Byte stream TLS validation failed");
|
||||
}
|
||||
clearTimeout(state.connectTimer);
|
||||
state.lifetimeTimer = setTimeout(() => this.finish(state,
|
||||
failure("deadlineExceeded", "Byte stream lifetime exceeded")), LIMITS.lifetimeMs);
|
||||
state.lifetimeTimer.unref?.();
|
||||
this.touch(state);
|
||||
this.assertCommit(state, context);
|
||||
this.record(state, "open");
|
||||
return Object.freeze({ handleId: state.id, endpoint: state.endpoint });
|
||||
} catch (error) {
|
||||
this.finish(state, error);
|
||||
throw error;
|
||||
} finally {
|
||||
context.signal?.removeEventListener("abort", onAbort);
|
||||
}
|
||||
}
|
||||
|
||||
get(id, context) {
|
||||
const state = this.connections.get(id);
|
||||
if (!state || state.pluginId !== context.pluginId || state.runtimeId !== context.runtimeId
|
||||
|| state.sourceOwner !== context.sourceOwner || state.sourceId !== context.sourceId) {
|
||||
throw failure("permissionDenied", "Byte stream handle does not belong to this runtime");
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
charge(state, direction, size) {
|
||||
if (state.bytesRead + state.bytesWritten + size > LIMITS.connectionBytes) {
|
||||
throw failure("resourceExhausted", "Byte stream byte limit exceeded");
|
||||
}
|
||||
this.quotaManager?.chargeBytes(state.runtimeId, "network.stream", size);
|
||||
state[direction] += size;
|
||||
this.touch(state);
|
||||
}
|
||||
|
||||
async read(id, context) {
|
||||
const state = this.get(id, context);
|
||||
if (state.reading) throw failure("failedPrecondition", "Byte stream has a pending read");
|
||||
state.reading = true;
|
||||
const onAbort = () => this.finish(state, failure("cancelled", "Byte stream read cancelled"));
|
||||
context.signal?.addEventListener("abort", onAbort, { once: true });
|
||||
try {
|
||||
await this.active(state, context);
|
||||
while (true) {
|
||||
this.assertCommit(state, context);
|
||||
const available = state.socket.readableLength;
|
||||
const bytes = state.socket.read(Math.min(available || LIMITS.chunkBytes, LIMITS.chunkBytes));
|
||||
if (bytes !== null) {
|
||||
this.charge(state, "bytesRead", bytes.byteLength);
|
||||
await this.active(state, context);
|
||||
this.assertCommit(state, context);
|
||||
return new Uint8Array(bytes);
|
||||
}
|
||||
if (state.eof || state.socket.readableEnded) { this.finish(state); return null; }
|
||||
await raceWithAbort(new Promise((resolve) => {
|
||||
const readable = () => {
|
||||
state.socket.off("end", readable);
|
||||
state.socket.off("readable", readable);
|
||||
state.controller.signal.removeEventListener("abort", readable);
|
||||
resolve();
|
||||
};
|
||||
state.socket.once("readable", readable);
|
||||
state.socket.once("end", readable);
|
||||
// Remove listeners even when cancellation wins the race.
|
||||
state.controller.signal.addEventListener("abort", readable, { once: true });
|
||||
}), state.controller.signal);
|
||||
await this.active(state, context);
|
||||
}
|
||||
} catch (error) {
|
||||
this.finish(state, error);
|
||||
throw error;
|
||||
} finally {
|
||||
context.signal?.removeEventListener("abort", onAbort);
|
||||
state.reading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async write(id, bytes, context) {
|
||||
const state = this.get(id, context);
|
||||
if (!(bytes instanceof Uint8Array) || bytes.byteLength < 1 || bytes.byteLength > LIMITS.chunkBytes) {
|
||||
throw failure("invalidArgument", "Byte stream chunk size is invalid");
|
||||
}
|
||||
if (state.writing) throw failure("resourceExhausted", "Byte stream has a pending write");
|
||||
state.writing = true;
|
||||
const onAbort = () => this.finish(state, failure("cancelled", "Byte stream write cancelled"));
|
||||
context.signal?.addEventListener("abort", onAbort, { once: true });
|
||||
try {
|
||||
await this.active(state, context);
|
||||
this.assertCommit(state, context);
|
||||
this.charge(state, "bytesWritten", bytes.byteLength);
|
||||
const owned = Buffer.from(bytes);
|
||||
await raceWithAbort(new Promise((resolve, reject) => {
|
||||
state.socket.write(owned, (error) => error ? reject(error) : resolve());
|
||||
}), state.controller.signal);
|
||||
await this.active(state, context);
|
||||
} catch (error) {
|
||||
this.finish(state, error);
|
||||
throw error;
|
||||
} finally {
|
||||
context.signal?.removeEventListener("abort", onAbort);
|
||||
state.writing = false;
|
||||
}
|
||||
}
|
||||
|
||||
close(id, context) { this.finish(this.get(id, context)); }
|
||||
|
||||
releaseRuntime(runtimeId) {
|
||||
for (const state of this.connections.values()) {
|
||||
if (state.runtimeId === runtimeId) this.finish(state,
|
||||
failure("unavailable", "Byte stream runtime stopped"));
|
||||
}
|
||||
}
|
||||
|
||||
shutdown() {
|
||||
this.closed = true;
|
||||
for (const state of this.connections.values()) this.finish(state,
|
||||
failure("unavailable", "Byte stream broker stopped"));
|
||||
this.revocation.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { LIMITS, PluginByteStreamBroker, canonicalizeStreamEndpoint, isLocalControlAddress };
|
||||
@@ -0,0 +1,330 @@
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert/strict");
|
||||
const { test } = require("node:test");
|
||||
const net = require("node:net");
|
||||
const tls = require("node:tls");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { execFileSync } = require("node:child_process");
|
||||
const { once } = require("node:events");
|
||||
const { LIMITS, PluginByteStreamBroker, canonicalizeStreamEndpoint, isLocalControlAddress } = require("./byteStreamBroker.cjs");
|
||||
const { RPC_ERRORS } = require("./rpcRouter.cjs");
|
||||
|
||||
function context(runtimeId = "runtime-a", controller = new AbortController()) {
|
||||
return { pluginId: "test.stream", runtimeId, signal: controller.signal,
|
||||
assertActive: async () => controller.signal.throwIfAborted() };
|
||||
}
|
||||
|
||||
async function server(t, listener, options) {
|
||||
const sockets = new Set();
|
||||
const instance = options ? tls.createServer(options, listener) : net.createServer(listener);
|
||||
instance.on("connection", (socket) => {
|
||||
sockets.add(socket);
|
||||
socket.on("error", () => {});
|
||||
socket.on("close", () => sockets.delete(socket));
|
||||
});
|
||||
instance.on("tlsClientError", () => {});
|
||||
instance.listen(0, "127.0.0.1");
|
||||
await once(instance, "listening");
|
||||
t.after(async () => {
|
||||
for (const socket of sockets) socket.destroy();
|
||||
await new Promise((resolve) => instance.close(resolve));
|
||||
});
|
||||
return instance;
|
||||
}
|
||||
|
||||
function broker(t, endpoint, options = {}) {
|
||||
const audit = [];
|
||||
const requests = [];
|
||||
let revoke;
|
||||
const instance = new PluginByteStreamBroker({
|
||||
permissionEngine: {
|
||||
authorize: async (_ctx, request) => requests.push(request),
|
||||
onDidRevoke(listener) { revoke = listener; return { dispose() {} }; },
|
||||
},
|
||||
audit: (...args) => audit.push(args),
|
||||
testLocalEndpoints: endpoint ? [endpoint] : [],
|
||||
...options,
|
||||
});
|
||||
t.after(() => instance.shutdown());
|
||||
return { instance, requests, audit, revoke: (event) => revoke(event) };
|
||||
}
|
||||
|
||||
test("stream endpoint canonicalization requires one exact host, transport and port", () => {
|
||||
assert.equal(canonicalizeStreamEndpoint("tls://EXAMPLE.test:443").endpoint, "tls://example.test:443");
|
||||
assert.equal(canonicalizeStreamEndpoint("tcp://[0:0:0:0:0:0:0:1]:3306").endpoint, "tcp://[::1]:3306");
|
||||
for (const value of ["*", "tcp://*:3306", "tcp://x:0", "tcp://x:01", "tcp://x:65536", "udp://x:22",
|
||||
"tcp://x", "tcp://x:22/", "tcp://user@x:22", "tcp://127.1:22", "tcp://x:22?x", "tcp://x.:22",
|
||||
"tcp://[::1%25lo]:22", "tcp://x:22#x", "tcp://-x:22"]) {
|
||||
assert.throws(() => canonicalizeStreamEndpoint(value), TypeError, value);
|
||||
}
|
||||
});
|
||||
|
||||
test("local/control address classification handles mapped IPv6 and alternate literals", () => {
|
||||
for (const address of ["127.0.0.1", "127.9.8.7", "0.0.0.0", "169.254.169.254", "224.0.0.1",
|
||||
"::1", "::", "::ffff:127.0.0.1", "::ffff:7f00:1", "fe80::1", "ff02::1", "2002:7f00:1::"]) {
|
||||
assert.equal(isLocalControlAddress(address), true, address);
|
||||
}
|
||||
for (const address of ["192.168.1.2", "10.0.0.8", "203.0.113.1", "2001:db8::1", "fd00::1"]) {
|
||||
assert.equal(isLocalControlAddress(address), false, address);
|
||||
}
|
||||
});
|
||||
|
||||
test("real TCP transport exchanges binary bytes and records no payloads", async (t) => {
|
||||
const peer = await server(t, (socket) => socket.on("data", (bytes) => socket.write(bytes)));
|
||||
const endpoint = `tcp://127.0.0.1:${peer.address().port}`;
|
||||
const { instance, requests, audit } = broker(t, endpoint);
|
||||
const ctx = context();
|
||||
const handle = await instance.connect(endpoint, ctx);
|
||||
const bytes = Uint8Array.from([0, 255, 1, 254, 19]);
|
||||
await instance.write(handle.handleId, bytes, ctx);
|
||||
assert.deepEqual(await instance.read(handle.handleId, ctx), bytes);
|
||||
instance.close(handle.handleId, ctx);
|
||||
assert.equal(instance.connections.size, 0);
|
||||
assert.deepEqual(requests[0].resources, [endpoint]);
|
||||
assert.equal(requests[0].permission, "network.stream");
|
||||
assert.deepEqual(audit.map((entry) => entry[1]), ["network.stream.open", "network.stream.close"]);
|
||||
assert.equal(audit[1][2].bytesRead, bytes.byteLength);
|
||||
assert.equal(audit[1][2].bytesWritten, bytes.byteLength);
|
||||
assert.equal(JSON.stringify(audit).includes("data"), false);
|
||||
});
|
||||
|
||||
test("remote EOF returns the final bytes and releases the connection", async (t) => {
|
||||
const peer = await server(t, (socket) => socket.end("synthetic final bytes"));
|
||||
const endpoint = `tcp://127.0.0.1:${peer.address().port}`;
|
||||
const { instance } = broker(t, endpoint);
|
||||
const ctx = context();
|
||||
const { handleId } = await instance.connect(endpoint, ctx);
|
||||
const chunks = [];
|
||||
while (true) {
|
||||
const chunk = await instance.read(handleId, ctx);
|
||||
if (chunk === null) break;
|
||||
chunks.push(Buffer.from(chunk));
|
||||
}
|
||||
assert.equal(Buffer.concat(chunks).toString(), "synthetic final bytes");
|
||||
assert.equal(instance.connections.size, 0);
|
||||
});
|
||||
|
||||
test("a delayed consumer can observe EOF after the real socket has closed", async (t) => {
|
||||
const peer = await server(t, (socket) => socket.end("final"));
|
||||
const endpoint = `tcp://127.0.0.1:${peer.address().port}`;
|
||||
const { instance, revoke } = broker(t, endpoint);
|
||||
const ctx = context();
|
||||
const { handleId } = await instance.connect(endpoint, ctx);
|
||||
const socket = instance.connections.get(handleId).socket;
|
||||
const closed = once(socket, "close");
|
||||
assert.equal(Buffer.from(await instance.read(handleId, ctx)).toString(), "final");
|
||||
await closed;
|
||||
await assert.rejects(instance.read(handleId, context("other-runtime")), { code: RPC_ERRORS.permissionDenied });
|
||||
assert.equal(await instance.read(handleId, ctx), null);
|
||||
assert.equal(instance.connections.size, 0);
|
||||
const second = await instance.connect(endpoint, ctx);
|
||||
const secondClosed = once(instance.connections.get(second.handleId).socket, "close");
|
||||
await instance.read(second.handleId, ctx);
|
||||
await secondClosed;
|
||||
revoke({ pluginId: ctx.pluginId, permission: "network.stream", resource: endpoint });
|
||||
await assert.rejects(instance.read(second.handleId, ctx), { code: RPC_ERRORS.permissionDenied });
|
||||
});
|
||||
|
||||
test("denial happens before DNS or a socket is created", async (t) => {
|
||||
let lookedUp = false;
|
||||
const { instance } = broker(t, null, {
|
||||
permissionEngine: { authorize: async () => { throw new Error("denied"); }, onDidRevoke: () => ({ dispose() {} }) },
|
||||
lookup: () => { lookedUp = true; },
|
||||
});
|
||||
await assert.rejects(instance.connect("tcp://example.test:22", context()), /denied/u);
|
||||
assert.equal(lookedUp, false);
|
||||
assert.equal(instance.connections.size, 0);
|
||||
});
|
||||
|
||||
test("production rejects loopback, mixed DNS answers, and app control peers", async (t) => {
|
||||
const endpoint = "tcp://example.test:22";
|
||||
const { instance } = broker(t, null, {
|
||||
lookup: async () => [{ address: "203.0.113.1", family: 4 }, { address: "127.0.0.1", family: 4 }],
|
||||
});
|
||||
await assert.rejects(instance.connect(endpoint, context()), { code: RPC_ERRORS.permissionDenied });
|
||||
await assert.rejects(instance.connect("tcp://127.0.0.1:22", context()), { code: RPC_ERRORS.permissionDenied });
|
||||
const protectedBroker = broker(t, "tcp://127.0.0.1:22", { isControlEndpoint: () => true }).instance;
|
||||
await assert.rejects(protectedBroker.connect("tcp://127.0.0.1:22", context()), { code: RPC_ERRORS.permissionDenied });
|
||||
});
|
||||
|
||||
test("runtime handles cannot cross runtime or plugin boundaries", async (t) => {
|
||||
const peer = await server(t, () => {});
|
||||
const endpoint = `tcp://127.0.0.1:${peer.address().port}`;
|
||||
const { instance } = broker(t, endpoint);
|
||||
const ctx = context();
|
||||
const { handleId } = await instance.connect(endpoint, ctx);
|
||||
for (const other of [context("runtime-b"), { ...ctx, pluginId: "test.other" }]) {
|
||||
await assert.rejects(instance.read(handleId, other), { code: RPC_ERRORS.permissionDenied });
|
||||
assert.throws(() => instance.close(handleId, other), { code: RPC_ERRORS.permissionDenied });
|
||||
}
|
||||
instance.close(handleId, ctx);
|
||||
await assert.rejects(instance.write(handleId, Uint8Array.of(1), ctx), { code: RPC_ERRORS.permissionDenied });
|
||||
});
|
||||
|
||||
test("revocation and runtime release cancel pending real reads", async (t) => {
|
||||
const peer = await server(t, () => {});
|
||||
const endpoint = `tcp://127.0.0.1:${peer.address().port}`;
|
||||
const { instance, revoke } = broker(t, endpoint);
|
||||
const ctx = context();
|
||||
let handle = await instance.connect(endpoint, ctx);
|
||||
const reading = instance.read(handle.handleId, ctx);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
revoke({ pluginId: ctx.pluginId, permission: "network.stream", resource: endpoint });
|
||||
await assert.rejects(reading, { code: RPC_ERRORS.permissionDenied });
|
||||
assert.equal(instance.connections.size, 0);
|
||||
handle = await instance.connect(endpoint, ctx);
|
||||
const secondRead = instance.read(handle.handleId, ctx);
|
||||
instance.releaseRuntime(ctx.runtimeId);
|
||||
await assert.rejects(secondRead, { code: RPC_ERRORS.unavailable });
|
||||
assert.equal(instance.connections.size, 0);
|
||||
});
|
||||
|
||||
test("cancellation during DNS rejects promptly and ignores late resolution", async (t) => {
|
||||
let resolveDns;
|
||||
let dialed = false;
|
||||
const { instance } = broker(t, null, {
|
||||
lookup: () => new Promise((resolve) => { resolveDns = resolve; }),
|
||||
connectSocket: () => { dialed = true; },
|
||||
});
|
||||
const controller = new AbortController();
|
||||
const pending = instance.connect("tcp://example.test:22", context("runtime-a", controller));
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
controller.abort();
|
||||
await assert.rejects(pending, { code: RPC_ERRORS.cancelled });
|
||||
resolveDns([{ address: "203.0.113.1", family: 4 }]);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(dialed, false);
|
||||
assert.equal(instance.connections.size, 0);
|
||||
});
|
||||
|
||||
test("cancellation after the last async guard cannot create a late real socket", async (t) => {
|
||||
const peer = await server(t, () => {});
|
||||
const endpoint = `tcp://127.0.0.1:${peer.address().port}`;
|
||||
let dialed = false;
|
||||
const { instance } = broker(t, endpoint, { connectSocket(options) {
|
||||
dialed = true;
|
||||
const socket = net.createConnection(options);
|
||||
t.after(() => socket.destroy());
|
||||
return socket;
|
||||
} });
|
||||
const controller = new AbortController();
|
||||
let guards = 0;
|
||||
const ctx = context("runtime-a", controller);
|
||||
ctx.assertActive = async () => {
|
||||
controller.signal.throwIfAborted();
|
||||
if (++guards === 3) queueMicrotask(() => queueMicrotask(() => controller.abort()));
|
||||
};
|
||||
await assert.rejects(instance.connect(endpoint, ctx), { code: RPC_ERRORS.cancelled });
|
||||
assert.equal(dialed, false);
|
||||
assert.equal(instance.connections.size, 0);
|
||||
});
|
||||
|
||||
test("connect and idle deadlines close resources and reject pending operations", async (t) => {
|
||||
const peer = await server(t, () => {});
|
||||
const endpoint = `tcp://127.0.0.1:${peer.address().port}`;
|
||||
const { instance } = broker(t, endpoint);
|
||||
t.mock.timers.enable({ apis: ["setTimeout"] });
|
||||
const ctx = context();
|
||||
const { handleId } = await instance.connect(endpoint, ctx);
|
||||
const reading = instance.read(handleId, ctx);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
t.mock.timers.tick(LIMITS.idleMs);
|
||||
await assert.rejects(reading, { code: RPC_ERRORS.deadlineExceeded });
|
||||
assert.equal(instance.connections.size, 0);
|
||||
let resolveDns;
|
||||
let dialed = false;
|
||||
const slow = broker(t, null, {
|
||||
lookup: () => new Promise((resolve) => { resolveDns = resolve; }),
|
||||
connectSocket: () => { dialed = true; },
|
||||
}).instance;
|
||||
const pending = slow.connect("tcp://example.test:22", ctx);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
t.mock.timers.tick(LIMITS.connectMs);
|
||||
await assert.rejects(pending, { code: RPC_ERRORS.deadlineExceeded });
|
||||
resolveDns([{ address: "203.0.113.1", family: 4 }]);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(dialed, false);
|
||||
assert.equal(slow.connections.size, 0);
|
||||
});
|
||||
|
||||
test("concurrent operations and oversized writes cannot grow an unbounded queue", async (t) => {
|
||||
const peer = await server(t, () => {});
|
||||
const endpoint = `tcp://127.0.0.1:${peer.address().port}`;
|
||||
const { instance } = broker(t, endpoint);
|
||||
const ctx = context();
|
||||
const { handleId } = await instance.connect(endpoint, ctx);
|
||||
const pending = instance.read(handleId, ctx);
|
||||
await assert.rejects(instance.read(handleId, ctx), { code: RPC_ERRORS.failedPrecondition });
|
||||
await assert.rejects(instance.write(handleId, new Uint8Array(LIMITS.chunkBytes + 1), ctx), { code: RPC_ERRORS.invalidArgument });
|
||||
const writing = instance.write(handleId, Uint8Array.of(1), ctx);
|
||||
await assert.rejects(instance.write(handleId, Uint8Array.of(2), ctx), { code: RPC_ERRORS.resourceExhausted });
|
||||
await writing;
|
||||
instance.close(handleId, ctx);
|
||||
await assert.rejects(pending, { code: RPC_ERRORS.cancelled });
|
||||
});
|
||||
|
||||
test("connection reservations bound concurrent DNS operations", async (t) => {
|
||||
const { instance } = broker(t, null, { lookup: () => new Promise(() => {}) });
|
||||
const ctx = context();
|
||||
const pending = Array.from({ length: LIMITS.connectionsPerRuntime }, () => instance.connect("tcp://example.test:22", ctx));
|
||||
const results = Promise.allSettled(pending);
|
||||
await assert.rejects(instance.connect("tcp://example.test:22", ctx), { code: RPC_ERRORS.resourceExhausted });
|
||||
instance.releaseRuntime(ctx.runtimeId);
|
||||
assert.ok((await results).every((result) => result.status === "rejected"));
|
||||
assert.equal(instance.connections.size, 0);
|
||||
});
|
||||
|
||||
test("real unread TCP output stays bounded and byte quota closes the socket", async (t) => {
|
||||
const peer = await server(t, (socket) => {
|
||||
const flood = () => { while (!socket.destroyed && socket.write(Buffer.alloc(LIMITS.chunkBytes))) {} };
|
||||
socket.on("drain", flood);
|
||||
flood();
|
||||
});
|
||||
const endpoint = `tcp://127.0.0.1:${peer.address().port}`;
|
||||
const { instance } = broker(t, endpoint, { quotaManager: {
|
||||
chargeBytes() { throw new Error("quota reached"); },
|
||||
} });
|
||||
const ctx = context();
|
||||
const { handleId } = await instance.connect(endpoint, ctx);
|
||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||
const state = instance.connections.get(handleId);
|
||||
assert.ok(state.socket.readableLength <= LIMITS.bufferBytes + LIMITS.chunkBytes);
|
||||
await assert.rejects(instance.read(handleId, ctx), /quota reached/u);
|
||||
assert.equal(instance.connections.size, 0);
|
||||
});
|
||||
|
||||
test("real TLS validates trust and the original hostname against a pinned IP", async (t) => {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-stream-tls-test-"));
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
|
||||
const keyPath = path.join(directory, "test.key");
|
||||
const certPath = path.join(directory, "test.crt");
|
||||
execFileSync("openssl", ["req", "-x509", "-newkey", "rsa:2048", "-nodes", "-keyout", keyPath,
|
||||
"-out", certPath, "-days", "1", "-subj", "/CN=stream-fixture.test", "-addext", "subjectAltName=DNS:stream-fixture.test"],
|
||||
{ stdio: "ignore" });
|
||||
const cert = fs.readFileSync(certPath);
|
||||
const peer = await server(t, (socket) => socket.on("data", (data) => socket.write(data)), {
|
||||
key: fs.readFileSync(keyPath), cert,
|
||||
});
|
||||
const endpoint = `tls://stream-fixture.test:${peer.address().port}`;
|
||||
const lookup = async () => [{ address: "127.0.0.1", family: 4 }];
|
||||
const untrusted = broker(t, endpoint, { lookup }).instance;
|
||||
await assert.rejects(untrusted.connect(endpoint, context()), { code: RPC_ERRORS.unavailable });
|
||||
let dialOptions;
|
||||
const trusted = broker(t, endpoint, { lookup,
|
||||
connectTls: (options) => { dialOptions = options; return tls.connect({ ...options, ca: cert }); },
|
||||
}).instance;
|
||||
const ctx = context();
|
||||
const { handleId } = await trusted.connect(endpoint, ctx);
|
||||
assert.equal(dialOptions.host, "127.0.0.1");
|
||||
assert.equal(dialOptions.servername, "stream-fixture.test");
|
||||
assert.equal(dialOptions.rejectUnauthorized, true);
|
||||
await trusted.write(handleId, Uint8Array.of(42), ctx);
|
||||
assert.deepEqual(await trusted.read(handleId, ctx), Uint8Array.of(42));
|
||||
trusted.close(handleId, ctx);
|
||||
const wrongEndpoint = `tls://wrong-fixture.test:${peer.address().port}`;
|
||||
const wrong = broker(t, wrongEndpoint, { lookup, connectTls: (options) => tls.connect({ ...options, ca: cert }) }).instance;
|
||||
await assert.rejects(wrong.connect(wrongEndpoint, context()), { code: RPC_ERRORS.unavailable });
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
"use strict";
|
||||
|
||||
// Creates only an isolated disposable fixture. Never accepts a user host,
|
||||
// existing database, database URL, or real credentials from the environment.
|
||||
const assert = require("node:assert/strict");
|
||||
const { test } = require("node:test");
|
||||
const { execFileSync } = require("node:child_process");
|
||||
const { randomUUID, randomBytes } = require("node:crypto");
|
||||
const { pathToFileURL } = require("node:url");
|
||||
const path = require("node:path");
|
||||
const { PluginByteStreamBroker } = require("./byteStreamBroker.cjs");
|
||||
const FIXTURE_IMAGE = "mariadb:11.4@sha256:250e1609e93c0e6aecf64bfbab2d84d5e89d2481540fe77447ff6c855c3b1873";
|
||||
|
||||
function docker(args, input) {
|
||||
return execFileSync("docker", args, { input, encoding: "utf8", stdio: ["pipe", "pipe", "pipe"], timeout: 60_000 }).trim();
|
||||
}
|
||||
|
||||
test("minimal browser protocol queries a disposable MariaDB through the real byte broker", {
|
||||
skip: process.env.NETCATTY_BYTE_STREAM_DATABASE_TEST !== "1",
|
||||
timeout: 120_000,
|
||||
}, async (t) => {
|
||||
const suffix = randomUUID().slice(0, 8);
|
||||
const network = `netcatty-stream-test-${suffix}`;
|
||||
const container = `netcatty-stream-test-${suffix}`;
|
||||
const sessionPassword = randomBytes(24).toString("hex");
|
||||
docker(["network", "create", "--internal", network]);
|
||||
let created = false;
|
||||
t.after(() => {
|
||||
if (created) docker(["rm", "--force", container]);
|
||||
docker(["network", "rm", network]);
|
||||
});
|
||||
docker(["run", "--detach", "--rm", "--name", container, "--network", network,
|
||||
"--env", "MARIADB_ALLOW_EMPTY_ROOT_PASSWORD=1", FIXTURE_IMAGE]);
|
||||
created = true;
|
||||
let ready = false;
|
||||
for (let attempt = 0; attempt < 60; attempt++) {
|
||||
try { docker(["exec", container, "mariadb-admin", "--host=127.0.0.1", "ping", "--silent"]); ready = true; break; }
|
||||
catch { await new Promise((resolve) => setTimeout(resolve, 500)); }
|
||||
}
|
||||
assert.equal(ready, true, "isolated MariaDB fixture starts");
|
||||
docker(["exec", "--interactive", container, "mariadb", "--protocol=socket", "--user=root"], `
|
||||
CREATE DATABASE plugin_demo;
|
||||
CREATE TABLE plugin_demo.example_result (value VARCHAR(64));
|
||||
INSERT INTO plugin_demo.example_result VALUES ('synthetic stream result');
|
||||
CREATE USER 'plugin_demo'@'%' IDENTIFIED BY '${sessionPassword}';
|
||||
GRANT SELECT ON plugin_demo.example_result TO 'plugin_demo'@'%';
|
||||
`);
|
||||
assert.equal(docker(["port", container]), "", "the isolated fixture publishes no host ports");
|
||||
const address = docker(["inspect", "--format", "{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}", container]);
|
||||
assert.match(address, /^172\.[0-9]+\.[0-9]+\.[0-9]+$/u);
|
||||
const endpoint = `tcp://${address}:3306`;
|
||||
const broker = new PluginByteStreamBroker({
|
||||
permissionEngine: {
|
||||
authorize: async (_context, request) => assert.deepEqual(request.resources, [endpoint]),
|
||||
onDidRevoke: () => ({ dispose() {} }),
|
||||
},
|
||||
});
|
||||
t.after(() => broker.shutdown());
|
||||
const context = { pluginId: "com.netcatty.database-minimal", runtimeId: `fixture-${suffix}`, assertActive: async () => {} };
|
||||
const { handleId } = await broker.connect(endpoint, context);
|
||||
const { queryExample, READ_QUERY } = await import(pathToFileURL(path.resolve(
|
||||
__dirname, "../../examples/plugins/database-minimal/protocol.mjs",
|
||||
)).href);
|
||||
assert.equal(READ_QUERY, "SELECT value FROM plugin_demo.example_result LIMIT 1");
|
||||
const result = await queryExample({
|
||||
read: () => broker.read(handleId, context),
|
||||
write: (bytes) => broker.write(handleId, bytes, context),
|
||||
close: () => { if (broker.connections.has(handleId)) broker.close(handleId, context); },
|
||||
}, "plugin_demo", sessionPassword);
|
||||
assert.deepEqual(result, { columns: ["value"], rows: [["synthetic stream result"]] });
|
||||
assert.equal(broker.connections.size, 0);
|
||||
// Server-side grants, not a SQL-prefix check, enforce the read-only fixture.
|
||||
// MYSQL_PWD exists only inside this disposable test exec and is never logged.
|
||||
assert.throws(() => execFileSync("docker", ["exec", "--interactive", container, "sh", "-c",
|
||||
'read -r MYSQL_PWD; export MYSQL_PWD; exec mariadb --host=127.0.0.1 --user=plugin_demo --execute="INSERT INTO plugin_demo.example_result VALUES (1)"'],
|
||||
{ input: `${sessionPassword}\n`, encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] }),
|
||||
(error) => error.status === 1 && /INSERT command denied/u.test(String(error.stderr)));
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
"use strict";
|
||||
|
||||
const net = require("node:net");
|
||||
|
||||
function failure(_code, message) { return new TypeError(message); }
|
||||
|
||||
function canonicalizeStreamEndpoint(value) {
|
||||
if (typeof value !== "string" || value.length > 512) {
|
||||
throw failure("invalidArgument", "Byte stream endpoint is invalid");
|
||||
}
|
||||
// URL's permissive non-special scheme parser accepts paths, userinfo, and
|
||||
// alternate IPv4 spellings. Require a complete unambiguous authority instead.
|
||||
const match = /^(tcp|tls):\/\/(\[[0-9a-fA-F:.]+\]|[A-Za-z0-9.-]+):([0-9]{1,5})$/u.exec(value);
|
||||
if (!match) throw failure("invalidArgument", "Byte streams require an exact TCP or TLS host and port");
|
||||
let host = match[2].toLowerCase();
|
||||
const port = Number(match[3]);
|
||||
if (port < 1 || port > 65535 || String(port) !== match[3]) {
|
||||
throw failure("invalidArgument", "Byte stream port is invalid");
|
||||
}
|
||||
if (host.startsWith("[")) {
|
||||
host = host.slice(1, -1);
|
||||
if (net.isIP(host) !== 6) throw failure("invalidArgument", "Byte stream IPv6 address is invalid");
|
||||
host = new URL(`http://[${host}]`).hostname.slice(1, -1);
|
||||
} else if (!net.isIP(host)) {
|
||||
if (host.length > 253 || /^[0-9.]+$/u.test(host) || host.split(".").some((label) => (
|
||||
!/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/u.test(label)
|
||||
))) throw failure("invalidArgument", "Byte stream hostname is invalid");
|
||||
}
|
||||
return Object.freeze({
|
||||
endpoint: `${match[1]}://${net.isIP(host) === 6 ? `[${host}]` : host}:${port}`,
|
||||
transport: match[1], host, port,
|
||||
});
|
||||
}
|
||||
|
||||
function isLocalControlAddress(address) {
|
||||
if (net.isIP(address) === 4) {
|
||||
const [a, b] = address.split(".").map(Number);
|
||||
return a === 0 || a === 127 || a >= 224 || (a === 169 && b === 254);
|
||||
}
|
||||
if (net.isIP(address) !== 6) return true;
|
||||
const canonical = new URL(`http://[${address}]`).hostname.slice(1, -1);
|
||||
if (canonical.startsWith("::ffff:")) {
|
||||
const words = canonical.slice(7).split(":");
|
||||
const n = (parseInt(words[0], 16) * 65536) + parseInt(words[1], 16);
|
||||
return isLocalControlAddress([n >>> 24, (n >>> 16) & 255, (n >>> 8) & 255, n & 255].join("."));
|
||||
}
|
||||
// Also exclude compatible/tunnel addresses that can obscure a local IPv4 peer.
|
||||
return canonical.startsWith("::") || canonical.startsWith("ff")
|
||||
|| /^fe[89ab]/u.test(canonical) || canonical.startsWith("2002:")
|
||||
|| canonical.startsWith("2001:0:");
|
||||
}
|
||||
|
||||
module.exports = { canonicalizeStreamEndpoint, isLocalControlAddress };
|
||||
@@ -0,0 +1,126 @@
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert/strict");
|
||||
const test = require("node:test");
|
||||
const fs = require("node:fs/promises");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const net = require("node:net");
|
||||
const { once } = require("node:events");
|
||||
const { createPluginHostService } = require("./hostService.cjs");
|
||||
const { inProcessBrowserRuntime } = require("./fixtures/inProcessBrowserRuntime.cjs");
|
||||
|
||||
test("installed browser entrypoint uses registered SDK/RPC streams, denial and revoke", { timeout: 15_000 }, async (t) => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "netcatty-byte-entry-"));
|
||||
let service;
|
||||
const sockets = new Set();
|
||||
const server = net.createServer((socket) => {
|
||||
sockets.add(socket);
|
||||
socket.on("close", () => sockets.delete(socket));
|
||||
socket.on("error", () => {});
|
||||
socket.on("data", (bytes) => socket.write(bytes));
|
||||
});
|
||||
server.listen(0, "127.0.0.1");
|
||||
await once(server, "listening");
|
||||
t.after(async () => {
|
||||
await service?.manager.shutdown();
|
||||
for (const socket of sockets) socket.destroy();
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
});
|
||||
const endpoint = `tcp://127.0.0.1:${server.address().port}`;
|
||||
const packageRoot = path.join(root, "source");
|
||||
await fs.mkdir(packageRoot);
|
||||
const id = "com.netcatty.byte-entry-test";
|
||||
const manifest = {
|
||||
manifestVersion: 1, id, name: "byte-entry-test", version: "0.1.0", publisher: "netcatty",
|
||||
engines: { netcatty: ">=0.0.0", api: ">=0.1.0-internal.1 <0.2.0" },
|
||||
main: { browser: "browser.js" }, features: { required: ["network.byte-streams", "ai.model-invocation", "context.selected-read"] },
|
||||
activationEvents: [`onCommand:${id}.run`],
|
||||
permissions: { required: ["commands"], optional: [{ permission: "network.stream", resources: [endpoint] }, "ai.invoke", "context.selected.list", "context.selected.read"] },
|
||||
contributes: { commands: ["run", "pending", "cancel", "spoof", "ai", "selected", "aiSpoof"].map((name) => ({ id: `${id}.${name}`, title: name })) },
|
||||
};
|
||||
await fs.writeFile(path.join(packageRoot, "netcatty.plugin.json"), JSON.stringify(manifest));
|
||||
await fs.writeFile(path.join(packageRoot, "browser.js"), `
|
||||
export default { activate(context) {
|
||||
let connection;
|
||||
for (const name of ["run", "pending", "cancel", "spoof", "ai", "selected", "aiSpoof"]) context.subscriptions.add(context.commands.registerCommand(${JSON.stringify(id)} + "." + name, async () => {
|
||||
if (name === "aiSpoof") return context.ai.openChat({ sourceOwner: "spoofed" });
|
||||
if (name === "selected") {
|
||||
const selected = await context.selectedContext.list({});
|
||||
return context.selectedContext.read({ handle: selected.items[0].handle, version: selected.items[0].version });
|
||||
}
|
||||
if (name === "ai") {
|
||||
const chat = await context.ai.openChat({});
|
||||
try {
|
||||
const turn = await context.ai.startTurn({ chatHandle: chat.chatHandle, prompt: "synthetic prompt" });
|
||||
const events = [];
|
||||
while (true) { const event = await context.ai.readTurn({ turnHandle: turn.turnHandle }); events.push(event); if (event.kind === "end") return events; }
|
||||
} finally { await context.ai.closeChat({ chatHandle: chat.chatHandle }); }
|
||||
}
|
||||
if (name === "cancel") { await connection?.close(); return true; }
|
||||
if (name === "spoof") return context.network.connect({ endpoint: ${JSON.stringify(endpoint)}, sourceOwner: "spoofed" });
|
||||
connection = await context.network.connect({ endpoint: ${JSON.stringify(endpoint)} });
|
||||
if (name === "pending") return { connected: true };
|
||||
try {
|
||||
await connection.writable.write(Uint8Array.of(0, 255, 42));
|
||||
return Array.from(await connection.readable.read());
|
||||
} finally { await connection.close(); await connection.close(); }
|
||||
}));
|
||||
} };`);
|
||||
let allow = false;
|
||||
const decisions = [];
|
||||
service = createPluginHostService({
|
||||
app: { getAppPath: () => process.cwd(), getPath: () => root, getVersion: () => "0.0.0" },
|
||||
electron: {}, env: {},
|
||||
pluginAIHostAdapter: {
|
||||
assertActive: (context) => context.assertActive(),
|
||||
selectModel: async () => ({ label: "Synthetic fixture", isCurrent: async () => true,
|
||||
invoke: async () => (async function* () { yield { kind: "text", text: "synthetic model result" }; yield { kind: "usage", inputTokens: 2, outputTokens: 3, totalTokens: 5 }; })(),
|
||||
}),
|
||||
listSelected: async () => [{ key: "fixture", scope: "tab", title: "Synthetic tab", version: "v1",
|
||||
readText: async () => "synthetic selected text", isCurrent: async () => true }],
|
||||
reviewText: async () => true,
|
||||
},
|
||||
requestPermissionDecision: async (request) => {
|
||||
decisions.push(request);
|
||||
return request.permission === "network.stream" && !allow
|
||||
? { requestId: request.requestId, decision: "deny" }
|
||||
: { requestId: request.requestId, decision: "allow", scope: request.permission === "context.selected.read" ? "once" : "application" };
|
||||
},
|
||||
});
|
||||
// Explicit test-only loopback fixture injection. The production constructor
|
||||
// exposes no option that forwards these overrides to the broker.
|
||||
service.byteStreamService.broker.testLocalEndpoints.add(endpoint);
|
||||
service.byteStreamService.broker.isControlEndpoint = () => false;
|
||||
service.runtimeSupervisor.runtimeFactories.browser = inProcessBrowserRuntime;
|
||||
await service.manager.initialize();
|
||||
const { buildPluginPackage } = await import("@netcatty/plugin-cli");
|
||||
const archive = path.join(root, "fixture.ncpkg");
|
||||
await buildPluginPackage(packageRoot, archive);
|
||||
await service.manager.install(archive, { enable: true });
|
||||
const run = (name) => service.contributionService.executeCommand(`${id}.${name}`);
|
||||
await assert.rejects(run("run"), /denied/u);
|
||||
assert.equal(service.byteStreamService.broker.connections.size, 0);
|
||||
allow = true;
|
||||
assert.deepEqual(await run("run"), [0, 255, 42]);
|
||||
assert.equal(service.byteStreamService.records.size, 0);
|
||||
assert.deepEqual(decisions.find((request) => request.permission === "network.stream").resources, [endpoint]);
|
||||
await assert.rejects(run("spoof"), /contract/u);
|
||||
assert.deepEqual(await run("ai"), [
|
||||
{ kind: "text", sequence: 0, text: "synthetic model result" },
|
||||
{ kind: "usage", sequence: 1, inputTokens: 2, outputTokens: 3, totalTokens: 5 },
|
||||
{ kind: "end", sequence: 2 },
|
||||
]);
|
||||
assert.equal((await run("selected")).text, "synthetic selected text");
|
||||
await assert.rejects(run("aiSpoof"), /invalid/u);
|
||||
assert.deepEqual(await run("pending"), { connected: true });
|
||||
service.permissionEngine.revokeApplication(id, "network.stream", endpoint);
|
||||
assert.equal(service.byteStreamService.records.size, 0);
|
||||
assert.equal(service.byteStreamService.broker.connections.size, 0);
|
||||
await run("cancel");
|
||||
await run("pending");
|
||||
await service.manager.setEnabled(id, false);
|
||||
assert.equal(service.byteStreamService.broker.connections.size, 0);
|
||||
assert.equal(service.byteStreamService.closedHandles.size, 0);
|
||||
});
|
||||
@@ -0,0 +1,206 @@
|
||||
"use strict";
|
||||
|
||||
const { randomUUID } = require("node:crypto");
|
||||
const os = require("node:os");
|
||||
const { PluginByteStreamBroker, LIMITS } = require("./byteStreamBroker.cjs");
|
||||
const { PluginRpcError, RPC_ERRORS } = require("./rpcRouter.cjs");
|
||||
const { createDefinitionValidator } = require("./contractValidator.cjs");
|
||||
|
||||
const FEATURE = require("./generated/plugin-contract.schema.json").$defs.ByteStreamFeature.const;
|
||||
function denied() { return new PluginRpcError(RPC_ERRORS.permissionDenied, "Byte stream runtime or source binding is invalid"); }
|
||||
function validator(name) {
|
||||
const validate = createDefinitionValidator(name);
|
||||
return (params) => {
|
||||
if (!validate(params)) throw new PluginRpcError(RPC_ERRORS.invalidArgument, "Byte stream parameters violate the contract");
|
||||
return params;
|
||||
};
|
||||
}
|
||||
|
||||
class PluginByteStreamService {
|
||||
constructor(options) {
|
||||
this.database = options.database;
|
||||
this.runtimeSupervisor = options.runtimeSupervisor;
|
||||
this.records = new Map();
|
||||
this.closedHandles = new Map();
|
||||
this.closed = false;
|
||||
this.broker = new PluginByteStreamBroker({
|
||||
permissionEngine: options.permissionEngine,
|
||||
quotaManager: options.quotaManager,
|
||||
// The public host service never forwards transport fixture overrides.
|
||||
isControlEndpoint: (address) => Object.values(os.networkInterfaces()).flat().some((item) => item?.address === address),
|
||||
audit: (pluginId, operation, fields, state) => {
|
||||
if (this.sourceMatches(state)) this.database.recordSecurityAudit(pluginId, operation, fields);
|
||||
},
|
||||
});
|
||||
this.brokerClose = this.broker.onDidClose((state, reason) => {
|
||||
if (reason) this.closeRecord(this.records.get(state.id), reason);
|
||||
});
|
||||
}
|
||||
|
||||
sourceMatches(identity) {
|
||||
const active = this.database.getActivePlugin(identity.pluginId);
|
||||
const retained = this.database.getRetainedPluginSource(identity.pluginId);
|
||||
return Boolean(active?.enabled && identity.sourceOwner && identity.sourceId
|
||||
&& active.source?.id === identity.sourceId && retained?.id === identity.sourceId
|
||||
&& this.database.privateOwner(identity.pluginId) === identity.sourceOwner
|
||||
&& this.database.publicPluginId(identity.sourceOwner) === identity.pluginId);
|
||||
}
|
||||
|
||||
async authenticate(context) {
|
||||
await context.assertActive();
|
||||
const assertCommit = () => {
|
||||
context.signal?.throwIfAborted();
|
||||
const current = this.runtimeSupervisor.getRuntimeIdentity(context.pluginId);
|
||||
if (this.closed || !current || current.runtimeId !== context.runtimeId
|
||||
|| current.pluginVersion !== context.pluginVersion
|
||||
|| current.securityPrincipal !== context.securityPrincipal
|
||||
|| current.sourceOwner !== context.sourceOwner || current.sourceId !== context.sourceId
|
||||
|| !this.sourceMatches(context)) throw denied();
|
||||
};
|
||||
assertCommit();
|
||||
if (!context.manifest?.features?.required?.includes(FEATURE)) {
|
||||
throw new PluginRpcError(RPC_ERRORS.unsupported, "Byte stream feature was not negotiated");
|
||||
}
|
||||
return Object.freeze({ ...context, assertCommit,
|
||||
assertActive: async () => { await context.assertActive(); assertCommit(); },
|
||||
});
|
||||
}
|
||||
|
||||
register(registry) {
|
||||
// These routes own authorization: connect authenticates before the broker
|
||||
// obtains its exact endpoint grant; attach/close prove handle ownership.
|
||||
// The registry public flag skips only the generic permission middleware.
|
||||
// Runtime authentication and quota middleware still apply to every call.
|
||||
registry.registerRequest("network.stream.connect", async ({ endpoint }, caller) => {
|
||||
const context = await this.authenticate(caller);
|
||||
const handle = await this.broker.connect(endpoint, context);
|
||||
try {
|
||||
context.assertCommit();
|
||||
this.broker.get(handle.handleId, context);
|
||||
const record = {
|
||||
...handle, context,
|
||||
inputStreamId: `byte-in-${randomUUID()}`, outputStreamId: `byte-out-${randomUUID()}`,
|
||||
attached: false, closing: false, input: null, output: null,
|
||||
};
|
||||
this.records.set(handle.handleId, record);
|
||||
record.abort = () => this.closeRecord(record);
|
||||
context.signal?.addEventListener("abort", record.abort, { once: true });
|
||||
return { ...handle, inputStreamId: record.inputStreamId,
|
||||
outputStreamId: record.outputStreamId, windowBytes: LIMITS.bufferBytes };
|
||||
} catch (error) {
|
||||
const state = this.broker.connections.get(handle.handleId);
|
||||
if (state) this.broker.finish(state, error);
|
||||
throw error;
|
||||
}
|
||||
}, { validateParams: validator("ByteStreamConnectRequest"), metadata: { capability: "network.stream", mutating: true, public: true } });
|
||||
registry.registerRequest("network.stream.attach", async ({ handleId }, caller) => {
|
||||
const context = await this.authenticate(caller);
|
||||
const record = this.get(handleId, context);
|
||||
if (record.attached) throw new PluginRpcError(RPC_ERRORS.failedPrecondition, "Byte stream is already attached");
|
||||
record.attached = true;
|
||||
try {
|
||||
record.output = await this.runtimeSupervisor.openStream(context.pluginId, record.outputStreamId, LIMITS.bufferBytes, {
|
||||
expectedIdentity: context,
|
||||
onClose: (reason) => { if (reason !== "end") this.closeRecord(record); },
|
||||
});
|
||||
context.assertCommit();
|
||||
if (record.closing) { record.output.cancel(); throw new PluginRpcError(RPC_ERRORS.cancelled, "Byte stream attachment cancelled"); }
|
||||
context.signal?.addEventListener("abort", record.abort, { once: true });
|
||||
// A cancelled attachment cannot leave a producer running after its RPC.
|
||||
void this.pump(record).finally(() => context.signal?.removeEventListener("abort", record.abort));
|
||||
return { attached: true };
|
||||
} catch (error) {
|
||||
try { record.output?.cancel(); } catch {}
|
||||
this.closeRecord(record, error);
|
||||
throw error;
|
||||
}
|
||||
}, { validateParams: validator("ByteStreamHandleRequest"), metadata: { capability: "network.stream", mutating: true, public: true } });
|
||||
registry.registerRequest("network.stream.close", async ({ handleId }, caller) => {
|
||||
const context = await this.authenticate(caller);
|
||||
if (!this.wasClosed(handleId, context)) this.closeRecord(this.get(handleId, context));
|
||||
return null;
|
||||
}, { validateParams: validator("ByteStreamHandleRequest"), metadata: { capability: "network.stream", mutating: true, public: true } });
|
||||
this.streamRegistration = registry.registerIncomingStream(async (stream, caller) => {
|
||||
const record = [...this.records.values()].find((item) => item.inputStreamId === stream.streamId);
|
||||
if (!record) return false;
|
||||
const context = await this.authenticate(caller);
|
||||
this.get(record.handleId, context);
|
||||
if (record.input || stream.windowBytes !== LIMITS.bufferBytes) throw denied();
|
||||
record.input = stream;
|
||||
stream.bind({
|
||||
onChunk: async (chunk, release) => {
|
||||
try {
|
||||
if (chunk.encoding !== "binary" || !(chunk.bytes instanceof Uint8Array)) throw denied();
|
||||
await this.broker.write(record.handleId, chunk.bytes, record.context);
|
||||
record.context.assertCommit();
|
||||
release();
|
||||
} catch (error) { this.closeRecord(record, error); }
|
||||
},
|
||||
onClose: () => this.closeRecord(record),
|
||||
});
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
get(handleId, context) {
|
||||
const record = this.records.get(handleId);
|
||||
if (!record || record.closing || record.context.runtimeId !== context.runtimeId
|
||||
|| record.context.pluginId !== context.pluginId || record.context.sourceOwner !== context.sourceOwner
|
||||
|| record.context.sourceId !== context.sourceId) throw denied();
|
||||
return record;
|
||||
}
|
||||
|
||||
wasClosed(handleId, context) {
|
||||
const owner = this.closedHandles.get(handleId);
|
||||
return owner && owner.runtimeId === context.runtimeId && owner.pluginId === context.pluginId
|
||||
&& owner.sourceOwner === context.sourceOwner && owner.sourceId === context.sourceId;
|
||||
}
|
||||
|
||||
async pump(record) {
|
||||
try {
|
||||
while (!record.closing) {
|
||||
const bytes = await this.broker.read(record.handleId, record.context);
|
||||
if (record.closing) return;
|
||||
record.context.assertCommit();
|
||||
if (bytes === null) { record.output.end(); break; }
|
||||
await record.output.write(bytes);
|
||||
}
|
||||
this.closeRecord(record);
|
||||
} catch (error) { this.closeRecord(record, error); }
|
||||
}
|
||||
|
||||
closeRecord(record, reason) {
|
||||
if (!record || record.closing) return;
|
||||
record.closing = true;
|
||||
record.context.signal?.removeEventListener("abort", record.abort);
|
||||
this.records.delete(record.handleId);
|
||||
const { pluginId, runtimeId, sourceOwner, sourceId } = record.context;
|
||||
this.closedHandles.set(record.handleId, { pluginId, runtimeId, sourceOwner, sourceId });
|
||||
while (this.closedHandles.size > 256) this.closedHandles.delete(this.closedHandles.keys().next().value);
|
||||
const state = this.broker.connections.get(record.handleId);
|
||||
if (state) this.broker.finish(state, reason);
|
||||
try { record.output?.cancel(); } catch {}
|
||||
try { void Promise.resolve(record.input?.cancel()).catch(() => {}); } catch {}
|
||||
}
|
||||
|
||||
releaseRuntime(runtimeId) {
|
||||
for (const record of this.records.values()) {
|
||||
if (record.context.runtimeId === runtimeId) this.closeRecord(record);
|
||||
}
|
||||
this.broker.releaseRuntime(runtimeId);
|
||||
for (const [id, owner] of this.closedHandles) {
|
||||
if (owner.runtimeId === runtimeId) this.closedHandles.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
shutdown() {
|
||||
this.closed = true;
|
||||
for (const record of this.records.values()) this.closeRecord(record);
|
||||
this.broker.shutdown();
|
||||
this.brokerClose.dispose();
|
||||
this.streamRegistration?.dispose();
|
||||
this.closedHandles.clear();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { PluginByteStreamService, FEATURE };
|
||||
@@ -0,0 +1,165 @@
|
||||
"use strict";
|
||||
|
||||
const { randomUUID } = require("node:crypto");
|
||||
const { PluginRpcError, RPC_ERRORS } = require("../rpcRouter.cjs");
|
||||
const { assertPluginJsonValue } = require("../jsonBoundary.cjs");
|
||||
const fail = (code, message) => new PluginRpcError(RPC_ERRORS[code], message);
|
||||
const SCOPES = new Set(["workspace", "tab", "session", "host", "note"]);
|
||||
const LIMITS = Object.freeze({ handles: 128, perRuntime: 8, contentBytes: 16_384, ttlMs: 300_000, readMs: 5_000 });
|
||||
|
||||
// Internal host authority. Only trusted selection/review UI may call share().
|
||||
// A menu id, plugin supplied entity id or global inventory is never accepted.
|
||||
class SelectedContextAuthority {
|
||||
constructor({ resolveDataOwner, clock = Date.now, limits = {} }) {
|
||||
if (typeof resolveDataOwner !== "function") throw new TypeError("Host data-owner resolver required");
|
||||
this.resolveDataOwner = resolveDataOwner;
|
||||
this.clock = clock;
|
||||
this.limits = Object.freeze(Object.fromEntries(Object.entries(LIMITS).map(([key, max]) => {
|
||||
const value = limits[key] ?? max;
|
||||
if (!Number.isSafeInteger(value) || value < 1 || value > max) throw new TypeError(`Invalid ${key} limit`);
|
||||
return [key, value];
|
||||
})));
|
||||
this.handles = new Map();
|
||||
this.closed = false;
|
||||
this.listeners = new Set();
|
||||
}
|
||||
|
||||
async binding(context) {
|
||||
context.signal?.throwIfAborted();
|
||||
await context.assertActive();
|
||||
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");
|
||||
}
|
||||
context.signal?.throwIfAborted();
|
||||
return { owner, runtimeId: context.runtimeId, pluginVersion: context.pluginVersion };
|
||||
}
|
||||
|
||||
async #check(entry, context) {
|
||||
const binding = await this.binding(context);
|
||||
if (this.closed || this.handles.get(entry.handle) !== entry || entry.expiresAt <= this.clock()
|
||||
|| binding.owner !== entry.owner || binding.runtimeId !== entry.runtimeId
|
||||
|| binding.pluginVersion !== entry.pluginVersion) throw fail("permissionDenied", "Selected context is unavailable");
|
||||
context.signal?.throwIfAborted();
|
||||
// The trusted reader checks the current window/resource generation and owner.
|
||||
// Recheck our map after awaiting it: close/revoke/version changes can race.
|
||||
const current = await entry.isCurrent();
|
||||
const currentOwner = await this.resolveDataOwner(context);
|
||||
await context.assertActive();
|
||||
context.signal?.throwIfAborted();
|
||||
if (!current || currentOwner !== entry.owner || this.handles.get(entry.handle) !== entry || this.closed || entry.expiresAt <= this.clock()) {
|
||||
throw fail("permissionDenied", "Selected context changed");
|
||||
}
|
||||
}
|
||||
|
||||
#prune() {
|
||||
for (const [handle, entry] of this.handles) if (entry.expiresAt <= this.clock()) this.release(handle);
|
||||
}
|
||||
|
||||
async share(selection, context) {
|
||||
if (this.closed) throw fail("unavailable", "Selected context is closed");
|
||||
assertPluginJsonValue({ scope: selection.scope, title: selection.title, version: selection.version }, { maxBytes: 1024 });
|
||||
if (!SCOPES.has(selection.scope) || typeof selection.title !== "string" || selection.title.length > 160
|
||||
|| typeof selection.version !== "string" || !selection.version || selection.version.length > 128
|
||||
|| typeof selection.readText !== "function" || typeof selection.isCurrent !== "function") {
|
||||
throw fail("invalidArgument", "Invalid selected context");
|
||||
}
|
||||
// This API accepts a curated text reader, never a Host/Vault DTO. Passwords,
|
||||
// private keys and arbitrary entity fields have no place in the descriptor.
|
||||
if (Object.keys(selection).some((key) => !["scope", "title", "version", "readText", "isCurrent"].includes(key))) {
|
||||
throw fail("invalidArgument", "Selected context contains unsupported fields");
|
||||
}
|
||||
const binding = await this.binding(context);
|
||||
if (!await selection.isCurrent()) throw fail("permissionDenied", "Selection is no longer current");
|
||||
await context.assertActive();
|
||||
this.#prune();
|
||||
if (this.closed || this.handles.size >= this.limits.handles
|
||||
|| [...this.handles.values()].filter((entry) => entry.runtimeId === context.runtimeId).length >= this.limits.perRuntime) {
|
||||
throw fail("resourceExhausted", "Selected context handle limit reached");
|
||||
}
|
||||
const entry = { ...binding, ...selection, handle: randomUUID(), expiresAt: this.clock() + this.limits.ttlMs };
|
||||
this.handles.set(entry.handle, entry);
|
||||
return this.#summary(entry);
|
||||
}
|
||||
|
||||
#summary(entry) {
|
||||
return { handle: entry.handle, scope: entry.scope, title: entry.title, version: entry.version, expiresAt: entry.expiresAt };
|
||||
}
|
||||
|
||||
async summaries(context) {
|
||||
const binding = await this.binding(context);
|
||||
this.#prune();
|
||||
const summaries = [];
|
||||
for (const entry of [...this.handles.values()]) {
|
||||
if (entry.owner !== binding.owner || entry.runtimeId !== binding.runtimeId || entry.pluginVersion !== binding.pluginVersion) continue;
|
||||
try { await this.#check(entry, context); summaries.push(this.#summary(entry)); }
|
||||
catch (error) { if (context.signal?.aborted) throw error; this.release(entry.handle); }
|
||||
}
|
||||
await context.assertActive();
|
||||
return summaries;
|
||||
}
|
||||
|
||||
async #read({ handle, version }, context) {
|
||||
const entry = this.handles.get(handle);
|
||||
if (!entry || entry.version !== version) throw fail("permissionDenied", "Selected context is unavailable");
|
||||
await this.#check(entry, context);
|
||||
let text;
|
||||
try { text = await entry.readText({ signal: context.signal, maxBytes: this.limits.contentBytes }); }
|
||||
catch { throw fail("unavailable", "Selected context reader failed"); }
|
||||
await this.#check(entry, context);
|
||||
if (typeof text !== "string" || text.includes("\0") || Buffer.byteLength(text) > this.limits.contentBytes) {
|
||||
throw fail("resourceExhausted", "Selected context exceeds its text limit");
|
||||
}
|
||||
// Do not disclose private key material even if a curated text reader errs.
|
||||
if (/-----BEGIN (?:[A-Z0-9]+ )*PRIVATE KEY-----/.test(text)) {
|
||||
throw fail("permissionDenied", "Private key material cannot be shared");
|
||||
}
|
||||
return { ...this.#summary(entry), text };
|
||||
}
|
||||
|
||||
async read(reference, context) {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(fail("deadlineExceeded", "Selected context read timed out")), this.limits.readMs);
|
||||
const signal = context.signal ? AbortSignal.any([context.signal, controller.signal]) : controller.signal;
|
||||
let abort;
|
||||
try {
|
||||
signal.throwIfAborted();
|
||||
return await new Promise((resolve, reject) => {
|
||||
abort = () => reject(signal.reason);
|
||||
signal.addEventListener("abort", abort, { once: true });
|
||||
this.#read(reference, { ...context, signal }).then(resolve, reject);
|
||||
});
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
if (abort) signal.removeEventListener("abort", abort);
|
||||
}
|
||||
}
|
||||
|
||||
async assertCurrent({ handle, version }, context) {
|
||||
const entry = this.handles.get(handle);
|
||||
if (!entry || entry.version !== version) throw fail("permissionDenied", "Selected context is unavailable");
|
||||
await this.#check(entry, context);
|
||||
}
|
||||
onDidInvalidate(listener) {
|
||||
this.listeners.add(listener);
|
||||
return { dispose: () => this.listeners.delete(listener) };
|
||||
}
|
||||
release(handle) {
|
||||
if (!this.handles.delete(handle)) return;
|
||||
for (const listener of [...this.listeners]) {
|
||||
// Revocation must finish even when an observer fails. Never log selected
|
||||
// text or let an observer exception retain another runtime handle.
|
||||
try { listener(handle); } catch {}
|
||||
}
|
||||
}
|
||||
releaseRuntime(runtimeId) {
|
||||
for (const [handle, entry] of this.handles) if (entry.runtimeId === runtimeId) this.release(handle);
|
||||
}
|
||||
releaseOwner(owner) {
|
||||
for (const [handle, entry] of this.handles) if (entry.owner === owner) this.release(handle);
|
||||
}
|
||||
shutdown() { this.closed = true; for (const handle of [...this.handles.keys()]) this.release(handle); this.listeners.clear(); }
|
||||
}
|
||||
|
||||
module.exports = { SelectedContextAuthority, SELECTED_CONTEXT_LIMITS: LIMITS };
|
||||
@@ -0,0 +1,87 @@
|
||||
"use strict";
|
||||
const assert = require("node:assert/strict");
|
||||
const test = require("node:test");
|
||||
const { SelectedContextAuthority } = require("./selectedContextAuthority.cjs");
|
||||
const deferred = () => { let resolve; const promise = new Promise((r) => { resolve = r; }); return { promise, resolve }; };
|
||||
function fixture() {
|
||||
const owner = {}; let currentOwner = owner; let current = true; let version = "1"; let reads = 0; let now = 0;
|
||||
const context = { pluginId: "same", pluginVersion: "1", runtimeId: "r1", assertActive: async () => {} };
|
||||
const authority = new SelectedContextAuthority({ resolveDataOwner: async () => currentOwner, clock: () => now });
|
||||
const selection = { scope: "note", title: "Synthetic Note", version: "1", isCurrent: () => current && version === "1",
|
||||
readText: () => { reads++; return "synthetic content"; } };
|
||||
return { owner, context, authority, selection, reads: () => reads, ownerTo: (v) => { currentOwner = v; },
|
||||
versionTo: (v) => { version = v; }, currentTo: (v) => { current = v; }, timeTo: (v) => { now = v; } };
|
||||
}
|
||||
|
||||
test("summaries enumerate only explicitly shared selection and never read content", async () => {
|
||||
const f = fixture(); assert.deepEqual(await f.authority.summaries(f.context), []);
|
||||
const note = await f.authority.share(f.selection, f.context);
|
||||
assert.deepEqual(await f.authority.summaries(f.context), [note]); assert.equal(f.reads(), 0);
|
||||
assert.equal((await f.authority.read(note, f.context)).text, "synthetic content");
|
||||
assert.equal(f.reads(), 1);
|
||||
assert.deepEqual(await f.authority.summaries({ ...f.context, runtimeId: "r2" }), []);
|
||||
f.ownerTo({}); assert.deepEqual(await f.authority.summaries(f.context), []);
|
||||
await assert.rejects(f.authority.read(note, f.context));
|
||||
f.ownerTo(f.owner); assert.equal((await f.authority.read(note, f.context)).text, "synthetic content");
|
||||
});
|
||||
|
||||
test("scope, owner, expected version and resource lifetime are enforced before read", async () => {
|
||||
for (const action of ["version", "owner", "closed", "expiry", "release", "runtime"]) {
|
||||
const f = fixture(); const note = await f.authority.share(f.selection, f.context);
|
||||
if (action === "version") f.versionTo("2");
|
||||
if (action === "owner") f.currentTo(false);
|
||||
if (action === "closed") f.authority.shutdown();
|
||||
if (action === "expiry") f.timeTo(300_001);
|
||||
if (action === "release") f.authority.release(note.handle);
|
||||
if (action === "runtime") f.authority.releaseRuntime("r1");
|
||||
await assert.rejects(f.authority.read(note, f.context)); assert.equal(f.reads(), 0);
|
||||
}
|
||||
const f = fixture(); const note = await f.authority.share(f.selection, f.context);
|
||||
await assert.rejects(f.authority.read({ ...note, version: "2" }, f.context));
|
||||
await assert.rejects(f.authority.read({ handle: "menu-id", version: "1" }, f.context));
|
||||
await assert.rejects(f.authority.share({ ...f.selection, scope: "all" }, f.context));
|
||||
});
|
||||
|
||||
test("revocation/version/owner replacement while reader awaits suppresses late content", async () => {
|
||||
for (const action of ["release", "version", "owner", "source"]) {
|
||||
const f = fixture(); const pending = deferred(); let started;
|
||||
const entered = new Promise((r) => { started = r; });
|
||||
const note = await f.authority.share({ ...f.selection, readText: () => { started(); return pending.promise; } }, f.context);
|
||||
const reading = f.authority.read(note, f.context); await entered;
|
||||
if (action === "release") f.authority.release(note.handle);
|
||||
if (action === "version") f.versionTo("2");
|
||||
if (action === "owner") f.currentTo(false);
|
||||
if (action === "source") f.ownerTo({});
|
||||
pending.resolve("late secret text"); await assert.rejects(reading);
|
||||
}
|
||||
});
|
||||
|
||||
test("unknown Host fields, private keys and oversized content are excluded", async () => {
|
||||
const f = fixture();
|
||||
await assert.rejects(f.authority.share({ ...f.selection, password: "forbidden" }, f.context));
|
||||
await assert.rejects(f.authority.share({ ...f.selection, privateKey: "forbidden" }, f.context));
|
||||
for (const text of ["-----BEGIN OPENSSH PRIVATE KEY-----\nforbidden", "X".repeat(16_385)]) {
|
||||
const note = await f.authority.share({ ...f.selection, readText: () => text }, f.context);
|
||||
await assert.rejects(f.authority.read(note, f.context));
|
||||
}
|
||||
});
|
||||
|
||||
test("content reader has a deadline and respects request cancellation", async () => {
|
||||
const owner = {}; const context = { runtimeId: "r", pluginVersion: "1", assertActive: async () => {} };
|
||||
const authority = new SelectedContextAuthority({ resolveDataOwner: () => owner, limits: { readMs: 20 } });
|
||||
const note = await authority.share({ scope: "note", title: "Synthetic", version: "1", isCurrent: () => true,
|
||||
readText: () => new Promise(() => {}) }, context);
|
||||
await assert.rejects(authority.read(note, context), /timed out/);
|
||||
const controller = new AbortController(); const reading = authority.read(note, { ...context, signal: controller.signal });
|
||||
controller.abort(); await assert.rejects(reading); authority.shutdown();
|
||||
});
|
||||
|
||||
test("one failing invalidation listener cannot interrupt other listeners or handle cleanup", async () => {
|
||||
const f = fixture(); const first = await f.authority.share(f.selection, f.context); const second = await f.authority.share(f.selection, f.context);
|
||||
const notified = [];
|
||||
f.authority.onDidInvalidate(() => { throw Error("listener failed"); });
|
||||
f.authority.onDidInvalidate((handle) => notified.push(handle));
|
||||
assert.doesNotThrow(() => f.authority.releaseRuntime("r1"));
|
||||
assert.equal(f.authority.handles.size, 0);
|
||||
assert.deepEqual(notified, [first.handle, second.handle]);
|
||||
});
|
||||
@@ -187,8 +187,6 @@ 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") {
|
||||
@@ -263,33 +261,10 @@ 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();
|
||||
@@ -303,7 +278,6 @@ 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,61 +390,3 @@ 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,45 @@
|
||||
"use strict";
|
||||
|
||||
// Test transport harness only. This is Node, not an OS/browser sandbox test.
|
||||
// Production continues to use the sandboxed BrowserPluginRuntime unchanged.
|
||||
const { MessageChannel } = require("node:worker_threads");
|
||||
const { pathToFileURL } = require("node:url");
|
||||
const path = require("node:path");
|
||||
const { PluginRpcRouter } = require("../rpcRouter.cjs");
|
||||
|
||||
function inProcessBrowserRuntime(options) {
|
||||
let host;
|
||||
let peer;
|
||||
let port;
|
||||
return {
|
||||
async start(config) {
|
||||
const { startPluginRuntime } = await import("../runtime/runtimePeer.mjs");
|
||||
const { port1, port2 } = new MessageChannel();
|
||||
port = port1;
|
||||
host = new PluginRpcRouter({ pluginId: config.pluginId,
|
||||
send: (message, transfer) => port1.postMessage(message, transfer),
|
||||
requestHandlers: options.requestHandlers, notificationHandlers: options.notificationHandlers,
|
||||
onIncomingStream: options.onIncomingStream, onBeforeMessage: options.onBeforeMessage,
|
||||
onProtocolError: options.onProtocolError,
|
||||
});
|
||||
port1.on("message", (message) => { void host.accept(message); });
|
||||
peer = await startPluginRuntime({ port: port2, config,
|
||||
loadPlugin: () => import(pathToFileURL(path.join(options.packageRoot, options.plugin.manifest.main.browser)).href),
|
||||
});
|
||||
const initialized = await host.request("plugin.initialize", {
|
||||
netcattyVersion: config.netcattyVersion, apiVersion: config.apiVersion, supportedFeatures: config.supportedFeatures,
|
||||
});
|
||||
await host.request("plugin.activate", {});
|
||||
return initialized;
|
||||
},
|
||||
request: (...args) => host.request(...args),
|
||||
notify: (...args) => host.notify(...args),
|
||||
openStream: (...args) => host.streams.openOutgoing(...args),
|
||||
async stop() {
|
||||
try { if (host) await host.request("plugin.deactivate", {}); }
|
||||
finally { await peer?.dispose(); host?.close(); port?.close(); }
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { inProcessBrowserRuntime };
|
||||
@@ -1,38 +0,0 @@
|
||||
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 };
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -1,29 +0,0 @@
|
||||
{
|
||||
"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" }
|
||||
]
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -61,6 +61,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));
|
||||
@@ -72,15 +73,19 @@ 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); }
|
||||
this.admissions.set(params, { sender, runtimeId: context.runtimeId, revision: this.admissionRevision, guard });
|
||||
this.admitRequest(params, context);
|
||||
return interactionAuthorization(params);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
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); }
|
||||
this.admissions.set(params, { sender, runtimeId: context.runtimeId, revision: this.admissionRevision, guard });
|
||||
}
|
||||
|
||||
setReady(sender, ready) {
|
||||
const window = this.getWindow();
|
||||
if (!sender || window?.webContents !== sender || sender.isDestroyed?.()) {
|
||||
@@ -92,6 +97,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
|
||||
@@ -144,6 +150,8 @@ class PluginHostInteractionService {
|
||||
pluginId: entry.context.pluginId, pluginVersion: entry.context.pluginVersion,
|
||||
expiresAt: entry.expiresAt, phase: entry.phase, request: entry.params,
|
||||
...(entry.previewText === undefined ? {} : { previewText: entry.previewText }),
|
||||
...(this.vaultService?.isRequest(entry.params) ? this.vaultService.describePrompt(entry) : {}),
|
||||
...(entry.reviewedDraft === undefined ? {} : { reviewedDraft: entry.reviewedDraft }),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -258,6 +266,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 === "clipboard.read" && entry.phase === "request") {
|
||||
if (response.action !== "preview") throw fail("invalidArgument", "Clipboard sharing requires a preview action");
|
||||
this.#audit(entry, "preview-confirmed");
|
||||
@@ -298,6 +325,8 @@ class PluginHostInteractionService {
|
||||
releaseWindow(sender) {
|
||||
if (this.owner !== sender) return;
|
||||
this.admissionRevision++;
|
||||
this.ownerGeneration++;
|
||||
this.vaultService?.clearSelections();
|
||||
this.owner = null;
|
||||
this.ownerCleanup?.(); this.ownerCleanup = null;
|
||||
for (const entry of [...this.pending.values()]) {
|
||||
@@ -306,6 +335,7 @@ class PluginHostInteractionService {
|
||||
}
|
||||
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"));
|
||||
}
|
||||
@@ -313,6 +343,7 @@ class PluginHostInteractionService {
|
||||
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);
|
||||
@@ -324,6 +355,7 @@ class PluginHostInteractionService {
|
||||
}
|
||||
cancelAll(invalidateAdmission = true) {
|
||||
if (invalidateAdmission) this.admissionRevision++;
|
||||
if (invalidateAdmission) this.vaultService?.clearSelections();
|
||||
for (const entry of [...this.pending.values()]) this.#finish(entry, null, fail("cancelled", "Host interaction was cancelled"));
|
||||
}
|
||||
shutdown() {
|
||||
@@ -333,6 +365,7 @@ class PluginHostInteractionService {
|
||||
this.cancelAll(); this.rates.clear(); this.admissionGuards.clear();
|
||||
this.revocation.dispose(); this.unsubscribeLock?.();
|
||||
this.key.fill(0);
|
||||
this.vaultService?.shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -79,6 +79,7 @@ function normalizeRuntimeIdentity(identity) {
|
||||
runtimeKind: identity.runtimeKind,
|
||||
...(identity.manifest === undefined ? {} : { manifest: identity.manifest }),
|
||||
...(identity.securityPrincipal === undefined ? {} : { securityPrincipal: identity.securityPrincipal }),
|
||||
...(identity.sourceOwner === undefined ? {} : { sourceOwner: identity.sourceOwner, sourceId: identity.sourceId }),
|
||||
...(identity.packageRoot === undefined ? {} : { packageRoot: identity.packageRoot }),
|
||||
...(identity.logger === undefined ? {} : { logger: identity.logger }),
|
||||
});
|
||||
@@ -175,73 +176,45 @@ class PluginHostRpcRegistry {
|
||||
await assertCurrent?.();
|
||||
transportContext.signal?.throwIfAborted();
|
||||
};
|
||||
const startedAt = Date.now();
|
||||
let authorizationOutcome = "not-reached";
|
||||
let auditResources;
|
||||
let auditResult;
|
||||
let auditError;
|
||||
try {
|
||||
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,
|
||||
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 {}
|
||||
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));
|
||||
await assertActive();
|
||||
return registration.handler(validatedParams, context);
|
||||
};
|
||||
const result = await dispatch(0);
|
||||
await assertActive();
|
||||
return result;
|
||||
};
|
||||
const requestHandlers = Object.freeze(Object.fromEntries(
|
||||
[...this.requests].map(([method, registration]) => [
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
"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;
|
||||
@@ -29,7 +27,13 @@ const { PackageStore } = require("./packageStore.cjs");
|
||||
const { createPluginPaths } = require("./paths.cjs");
|
||||
const { PluginManager } = require("./pluginManager.cjs");
|
||||
const { PluginNetworkBroker } = require("./networkBroker.cjs");
|
||||
const { PluginByteStreamService, FEATURE: BYTE_STREAM_FEATURE } = require("./byteStreamService.cjs");
|
||||
const { PluginAIContextService, PLUGIN_AI_CONTEXT_FEATURES } = require("./ai/aiContextService.cjs");
|
||||
const { PluginRpcError, RPC_ERRORS } = require("./rpcRouter.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");
|
||||
@@ -39,7 +43,6 @@ 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,9 +96,11 @@ 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(["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;
|
||||
const logService = new PluginLogService({ paths, electron: options.electron, resolveProvenance: options.resolvePluginProvenance ?? createPluginLogProvenanceResolver(database) });
|
||||
let byteStreamService;
|
||||
let aiContextService;
|
||||
try {
|
||||
const packageStore = new PackageStore({
|
||||
allowAdvancedPlugins,
|
||||
@@ -103,7 +108,7 @@ function createPluginHostService(options) {
|
||||
database,
|
||||
netcattyVersion: options.app.getVersion(),
|
||||
apiVersion: PLUGIN_API_VERSION,
|
||||
supportedFeatures: [...new Set(["appearance.theme-packs", "metadata.brand-icon", NETWORK_CREDENTIAL_LEASE_FEATURE, HOST_INTERACTION_FEATURE, VAULT_PAGES_FEATURE, TERMINAL_SIDEBAR_FEATURE, INTERACTIVE_COMMAND_FEATURE, ...(options.supportedFeatures ?? [])])],
|
||||
supportedFeatures,
|
||||
});
|
||||
const contributionIconService = new PluginContributionIconService({
|
||||
database,
|
||||
@@ -143,8 +148,6 @@ 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
|
||||
@@ -198,6 +201,11 @@ function createPluginHostService(options) {
|
||||
subscribeLock: options.subscribeAppLock,
|
||||
});
|
||||
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");
|
||||
@@ -211,36 +219,25 @@ 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");
|
||||
try {
|
||||
await permissionEngine.authorizeRequired(context.plugin, {
|
||||
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,
|
||||
securityPrincipal: context.securityPrincipal,
|
||||
signal: context.signal,
|
||||
skipPermissions: ["runtime.advanced"],
|
||||
}, {
|
||||
permission: "runtime.advanced",
|
||||
resources: ["*"],
|
||||
reason: "Start an advanced runtime with full Node, filesystem, and network authority",
|
||||
operationId: `runtime.advanced:${context.plugin.activeVersion}`,
|
||||
});
|
||||
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;
|
||||
};
|
||||
@@ -250,14 +247,13 @@ function createPluginHostService(options) {
|
||||
};
|
||||
runtimeSupervisor = new RuntimeSupervisor({
|
||||
electron: options.electron,
|
||||
logService,
|
||||
database,
|
||||
packageStore,
|
||||
protocol,
|
||||
paths,
|
||||
netcattyVersion: options.app.getVersion(),
|
||||
apiVersion: PLUGIN_API_VERSION,
|
||||
supportedFeatures: [...new Set(["appearance.theme-packs", "metadata.brand-icon", NETWORK_CREDENTIAL_LEASE_FEATURE, HOST_INTERACTION_FEATURE, VAULT_PAGES_FEATURE, TERMINAL_SIDEBAR_FEATURE, INTERACTIVE_COMMAND_FEATURE, ...(options.supportedFeatures ?? [])])],
|
||||
supportedFeatures,
|
||||
runtimeDirectory,
|
||||
appRoot,
|
||||
rpcRegistry,
|
||||
@@ -267,23 +263,32 @@ function createPluginHostService(options) {
|
||||
getInitialEnvironment: () => contributionService.getEnvironment(),
|
||||
runtimeResourceMonitor: quotaManager,
|
||||
runtimeCleanup: async (identity) => {
|
||||
byteStreamService?.releaseRuntime(identity.runtimeId);
|
||||
aiContextService?.releaseRuntime(identity.runtimeId);
|
||||
interactionService?.releaseRuntime(identity.runtimeId);
|
||||
leaseStore.revokeRuntime(identity.runtimeId);
|
||||
await companionSupervisor.releaseRuntime(identity.runtimeId);
|
||||
},
|
||||
utilityModuleMappings: options.utilityModuleMappings ?? createUtilityModuleMappings(moduleResources),
|
||||
});
|
||||
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] : [],
|
||||
});
|
||||
byteStreamService = new PluginByteStreamService({ database, permissionEngine, quotaManager, runtimeSupervisor });
|
||||
byteStreamService.register(rpcRegistry);
|
||||
aiContextService = new PluginAIContextService({ database, permissionEngine,
|
||||
getRuntimeIdentity: (pluginId) => runtimeSupervisor.getRuntimeIdentity(pluginId),
|
||||
hostAdapter: options.pluginAIHostAdapter ?? {
|
||||
assertActive: (context) => context.assertActive(),
|
||||
selectModel: () => { throw new PluginRpcError(RPC_ERRORS.unavailable, "Plugin model adapter is unavailable"); },
|
||||
listSelected: () => [],
|
||||
reviewText: () => false,
|
||||
},
|
||||
});
|
||||
aiContextService.registerRpcCapabilities(rpcRegistry);
|
||||
const originalOnPluginDisabled = contributionService.onPluginDisabled.bind(contributionService);
|
||||
contributionService.onPluginDisabled = (pluginId) => {
|
||||
aiContextService.releasePlugin(pluginId);
|
||||
return originalOnPluginDisabled(pluginId);
|
||||
};
|
||||
runtimeSupervisor.onDidChangeRuntime((event) => contributionService.onRuntimeStateChanged(event));
|
||||
const terminalProviderService = new PluginTerminalProviderService({
|
||||
contributionService,
|
||||
permissionEngine,
|
||||
@@ -408,26 +413,24 @@ function createPluginHostService(options) {
|
||||
runtimeSupervisor,
|
||||
contributionService,
|
||||
beforeClose: async () => {
|
||||
try {
|
||||
themeService.shutdown();
|
||||
interactionService.shutdown();
|
||||
terminalDataPipelineService?.shutdown();
|
||||
extensionProviderService.shutdown();
|
||||
credentialResolver.shutdown?.();
|
||||
await viewHost?.shutdown();
|
||||
await companionSupervisor.shutdown();
|
||||
leaseStore.shutdown();
|
||||
permissionEngine.shutdown();
|
||||
quotaManager.shutdown();
|
||||
} finally {
|
||||
await logService.close();
|
||||
}
|
||||
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();
|
||||
},
|
||||
});
|
||||
return {
|
||||
logService,
|
||||
themeService,
|
||||
interactionService,
|
||||
hostVaultService,
|
||||
catalogService: new MarketSourcesService({ database, manager, fetch: options.electron.net?.fetch?.bind(options.electron.net) }),
|
||||
companionSupervisor,
|
||||
contributionIconService,
|
||||
@@ -445,6 +448,8 @@ function createPluginHostService(options) {
|
||||
paths,
|
||||
protocol,
|
||||
networkBroker,
|
||||
byteStreamService,
|
||||
aiContextService,
|
||||
permissionEngine,
|
||||
quotaManager,
|
||||
rpcRegistry,
|
||||
@@ -456,7 +461,8 @@ function createPluginHostService(options) {
|
||||
};
|
||||
} catch (error) {
|
||||
try { interactionService?.shutdown(); } catch {}
|
||||
void logService.close();
|
||||
try { byteStreamService?.shutdown(); } catch {}
|
||||
try { aiContextService?.shutdown(); } catch {}
|
||||
database.close();
|
||||
throw error;
|
||||
}
|
||||
|
||||
@@ -401,83 +401,3 @@ 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 };
|
||||
@@ -1,34 +0,0 @@
|
||||
"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);
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
"use strict";
|
||||
// Include the isolated domain suites in npm run test:plugin-runtime.
|
||||
require("./ai/modelInvocationBroker.test.cjs");
|
||||
require("./context/selectedContextAuthority.test.cjs");
|
||||
@@ -639,21 +639,11 @@ class PluginPermissionEngine {
|
||||
|
||||
createMiddleware() {
|
||||
return async (context, next) => {
|
||||
if (context.metadata.public === true) {
|
||||
context.recordAuthorization?.("not-required");
|
||||
return next();
|
||||
}
|
||||
if (context.metadata.public === true) return next();
|
||||
if (!context.authorization) {
|
||||
context.recordAuthorization?.("denied");
|
||||
throw permissionDenied(`Plugin host method has no authorization policy: ${context.method}`);
|
||||
}
|
||||
try {
|
||||
await this.authorize(context, context.authorization);
|
||||
} catch (error) {
|
||||
if (error?.code === RPC_ERRORS.permissionDenied) context.recordAuthorization?.("denied");
|
||||
throw error;
|
||||
}
|
||||
context.recordAuthorization?.("allowed");
|
||||
await this.authorize(context, context.authorization);
|
||||
return next();
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
const { createHash } = require("node:crypto");
|
||||
const path = require("node:path");
|
||||
const { canonicalizeStreamEndpoint } = require("./byteStreamEndpoint.cjs");
|
||||
|
||||
const contractSchema = require("./generated/plugin-contract.schema.json");
|
||||
|
||||
@@ -62,6 +63,7 @@ function canonicalizeFilesystemResource(value) {
|
||||
|
||||
function canonicalizePermissionResource(permission, resource) {
|
||||
assertPluginPermission(permission);
|
||||
if (permission === "network.stream") return canonicalizeStreamEndpoint(resource).endpoint;
|
||||
if (resource === "*") return resource;
|
||||
if (permission === "network") return canonicalizeNetworkOrigin(resource);
|
||||
if (permission === "companion.execute") return canonicalizeCompanionResource(resource);
|
||||
@@ -86,6 +88,7 @@ function permissionResourceCovers(
|
||||
resourceKind = "exact",
|
||||
requestedResourceKind = "exact",
|
||||
) {
|
||||
if (permission === "network.stream") return grantResource !== "*" && grantResource === requestedResource;
|
||||
if (grantResource === "*") return true;
|
||||
if (permission === "filesystem.read" || permission === "filesystem.write") {
|
||||
if (requestedResourceKind === "directory" && resourceKind !== "directory") return false;
|
||||
@@ -112,7 +115,7 @@ function normalizePermissionDeclarations(manifest) {
|
||||
: [...new Set((value.resources ?? []).map((resource) => (
|
||||
canonicalizePermissionResource(permission, resource)
|
||||
)))].sort();
|
||||
if (required && RESOURCE_SCOPED_PERMISSIONS.has(permission)) {
|
||||
if ((required && RESOURCE_SCOPED_PERMISSIONS.has(permission)) || permission === "network.stream") {
|
||||
if (resources.length === 0) {
|
||||
throw new TypeError(`Required permission ${permission} must declare resources`);
|
||||
}
|
||||
|
||||
@@ -20,11 +20,6 @@ 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",
|
||||
interactionReady: "netcatty:plugins:interaction-ready",
|
||||
interactionResponse: "netcatty:plugins:interaction-response",
|
||||
list: "netcatty:plugins:list",
|
||||
@@ -701,14 +696,6 @@ 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,17 +1721,3 @@ 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"]);
|
||||
});
|
||||
@@ -1,36 +0,0 @@
|
||||
"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 };
|
||||
@@ -1,70 +0,0 @@
|
||||
"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");
|
||||
});
|
||||
@@ -1,149 +0,0 @@
|
||||
"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);
|
||||
});
|
||||
@@ -1,150 +0,0 @@
|
||||
"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,186 +1,96 @@
|
||||
"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 { TokenBucket, DEFAULT_QUOTAS } = require("./quotaManager.cjs");
|
||||
const { assertPluginStorageSegment } = require("./paths.cjs");
|
||||
|
||||
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;
|
||||
const SENSITIVE_KEY = /(?:password|passwd|token|secret|private.?key|credential|authorization|cookie|totp|otpauth)/iu;
|
||||
|
||||
// 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 }) {
|
||||
function redactLogValue(value, key = "", depth = 0) {
|
||||
if (SENSITIVE_KEY.test(key)) return "[REDACTED]";
|
||||
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") 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);
|
||||
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;
|
||||
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;
|
||||
} 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;
|
||||
}
|
||||
|
||||
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 (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 (!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; }
|
||||
const descriptor = Object.getOwnPropertyDescriptor(value, entryKey);
|
||||
output[entryKey.slice(0, 128)] = descriptor && "value" in descriptor
|
||||
? redactLogValue(descriptor.value, entryKey, depth + 1)
|
||||
: "[UNAVAILABLE]";
|
||||
}
|
||||
return output;
|
||||
}
|
||||
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 }; }
|
||||
return "[UNSUPPORTED]";
|
||||
}
|
||||
|
||||
class PluginLogger {
|
||||
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);
|
||||
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();
|
||||
}
|
||||
|
||||
write(level, message, fields) {
|
||||
if (this.closed || !this.sink?.enabled || !this.verbose()) return Promise.resolve();
|
||||
if (!this.bucket.consume()) { this.sink.dropped++; return Promise.resolve(); }
|
||||
this.sink.enqueue({
|
||||
const record = JSON.stringify({
|
||||
timestamp: new Date().toISOString(),
|
||||
recordType: "plugin-diagnostic", trust: "plugin-authored-unverified",
|
||||
...this.identity,
|
||||
level: ["debug", "info", "warn", "error"].includes(level) ? level : "info",
|
||||
diagnostic: {
|
||||
message: redactLogValue(typeof message === "string" ? message : "[INVALID MESSAGE]"),
|
||||
fields: redactLogValue(fields),
|
||||
},
|
||||
}, this.isCurrent);
|
||||
return Promise.resolve();
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
close() { this.closed = true; }
|
||||
}
|
||||
|
||||
module.exports = { PluginLogger, PluginLogSink, redactLogValue };
|
||||
module.exports = { PluginLogger, redactLogValue };
|
||||
@@ -1,258 +1,43 @@
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs/promises");
|
||||
const fs = require("node:fs");
|
||||
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");
|
||||
|
||||
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; } };
|
||||
}
|
||||
const { PluginLogger, redactLogValue } = require("./pluginLogger.cjs");
|
||||
|
||||
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);
|
||||
});
|
||||
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("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();
|
||||
for (let index = 0; index < 8; index += 1) {
|
||||
await logger.write("info", `record-${index}`, {
|
||||
nested: { apiToken: "must-not-appear", visible: index },
|
||||
});
|
||||
}
|
||||
let files = await fs.readdir(f.service.directory);
|
||||
const files = fs.readdirSync(path.join(root, "com.example.logger"));
|
||||
assert.ok(files.length <= 2);
|
||||
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), []);
|
||||
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\]/);
|
||||
});
|
||||
|
||||
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");
|
||||
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]" });
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import { PluginError } from "@netcatty/plugin-sdk";
|
||||
import { PLUGIN_BYTE_STREAM_FEATURE } from "@netcatty/plugin-contract";
|
||||
|
||||
export async function connectByteStream({ client, streams, subscriptions, enabledFeatures }, request, cancellationToken) {
|
||||
if (!enabledFeatures.includes(PLUGIN_BYTE_STREAM_FEATURE)) throw new PluginError("unsupported", "Byte stream feature is unavailable");
|
||||
const descriptor = await client.request("network.stream.connect", request, { cancellationToken });
|
||||
let readable;
|
||||
let writable;
|
||||
let readablePromise;
|
||||
let closing;
|
||||
let cancellation;
|
||||
let connection;
|
||||
const close = () => {
|
||||
closing ??= client.request("network.stream.close", { handleId: descriptor.handleId })
|
||||
.finally(() => {
|
||||
cancellation?.dispose();
|
||||
streams.rejectReadable(descriptor.outputStreamId);
|
||||
if (connection) subscriptions.delete(connection);
|
||||
});
|
||||
return closing;
|
||||
};
|
||||
const cancel = () => { void close().catch(() => {}); };
|
||||
const checkCancelled = async () => {
|
||||
if (!cancellationToken?.isCancellationRequested) return;
|
||||
await close().catch(() => {});
|
||||
throw new PluginError("cancelled", "Byte stream connection cancelled");
|
||||
};
|
||||
try {
|
||||
await checkCancelled();
|
||||
readablePromise = streams.acceptReadable(descriptor.outputStreamId);
|
||||
void readablePromise.catch(() => {});
|
||||
writable = await streams.openWritable(descriptor.inputStreamId, descriptor.windowBytes);
|
||||
const attaching = client.request("network.stream.attach", { handleId: descriptor.handleId }, { cancellationToken });
|
||||
[readable] = await Promise.all([readablePromise, attaching]);
|
||||
await checkCancelled();
|
||||
connection = Object.freeze({
|
||||
endpoint: descriptor.endpoint,
|
||||
readable: Object.freeze({
|
||||
id: readable.id, read: () => readable.read(), cancel, dispose: cancel,
|
||||
}),
|
||||
writable: Object.freeze({
|
||||
id: writable.id, write: (bytes) => writable.write(bytes), end: () => writable.end(),
|
||||
fail: cancel, cancel, dispose: cancel,
|
||||
}),
|
||||
close, dispose: cancel,
|
||||
});
|
||||
cancellation = cancellationToken?.onCancellationRequested(cancel);
|
||||
subscriptions.add(connection);
|
||||
return connection;
|
||||
} catch (error) {
|
||||
await close().catch(() => {});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
} from "@netcatty/plugin-sdk";
|
||||
import { createMessagePortStreamEnvelope, PLUGIN_HOST_INTERACTION_LIMITS } from "@netcatty/plugin-contract";
|
||||
import { createPluginStreamEndpoint } from "./pluginStreamEndpoint.mjs";
|
||||
import { connectByteStream } from "./byteStreamClient.mjs";
|
||||
|
||||
let terminalInterceptorTransportPromise;
|
||||
|
||||
@@ -164,6 +165,7 @@ function createHostClient(transport) {
|
||||
const request = pending.get(`${typeof message.id}:${String(message.id)}`);
|
||||
if (!request) return false;
|
||||
pending.delete(`${typeof message.id}:${String(message.id)}`);
|
||||
request.cancellation?.dispose();
|
||||
if (message.error) {
|
||||
request.reject(new PluginError(
|
||||
pluginErrorNameFromRpcError(message.error),
|
||||
@@ -174,17 +176,24 @@ function createHostClient(transport) {
|
||||
return true;
|
||||
}
|
||||
function request(method, params, options = {}) {
|
||||
if (options.cancellationToken?.isCancellationRequested) return Promise.reject(new PluginError("cancelled", "Plugin request cancelled"));
|
||||
const id = nextId;
|
||||
nextId = nextId === Number.MAX_SAFE_INTEGER ? 0 : nextId + 1;
|
||||
return new Promise((resolve, reject) => {
|
||||
pending.set(`${typeof id}:${String(id)}`, { resolve, reject });
|
||||
const cancellationId = options.cancellationToken ? `browser-${id}` : undefined;
|
||||
const pendingRequest = { resolve, reject };
|
||||
pending.set(`${typeof id}:${String(id)}`, pendingRequest);
|
||||
transport.post({
|
||||
jsonrpc: "2.0",
|
||||
id,
|
||||
method,
|
||||
params,
|
||||
...(cancellationId === undefined ? {} : { cancellationId }),
|
||||
...(options.deadlineMs === undefined ? {} : { deadlineMs: options.deadlineMs }),
|
||||
});
|
||||
pendingRequest.cancellation = options.cancellationToken?.onCancellationRequested(() => {
|
||||
notify("$/cancelRequest", { cancellationId });
|
||||
});
|
||||
});
|
||||
}
|
||||
function notify(method, params) {
|
||||
@@ -192,7 +201,7 @@ function createHostClient(transport) {
|
||||
}
|
||||
function close() {
|
||||
const error = new PluginError("unavailable", "Plugin host disconnected");
|
||||
for (const request of pending.values()) request.reject(error);
|
||||
for (const request of pending.values()) { request.cancellation?.dispose(); request.reject(error); }
|
||||
pending.clear();
|
||||
}
|
||||
return { accept, request, notify, close };
|
||||
@@ -371,15 +380,27 @@ 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),
|
||||
};
|
||||
const network = {
|
||||
connect: (request, cancellationToken) => connectByteStream({ client, streams: runtimeApi.streams, subscriptions,
|
||||
enabledFeatures: config.enabledFeatures }, request, cancellationToken),
|
||||
request: (request) => client.request("network.request", request, {
|
||||
deadlineMs: forwardedDeadline(request?.timeoutMs, 300_000),
|
||||
}),
|
||||
};
|
||||
const ai = Object.fromEntries(["openChat", "startTurn", "readTurn", "stopTurn", "closeChat"].map((operation) => [
|
||||
operation, (params) => client.request(`ai.${operation}`, params, operation === "readTurn" ? { deadlineMs: 65_000 } : {}),
|
||||
]));
|
||||
const selectedContext = {
|
||||
list: (params) => client.request("context.selected.list", params),
|
||||
read: (params) => client.request("context.selected.read", params),
|
||||
};
|
||||
const filesystem = {
|
||||
readFile: (filePath, options = {}) => client.request("filesystem.readFile", {
|
||||
path: filePath,
|
||||
@@ -553,7 +574,10 @@ function createPluginContext(config, client, runtimeApi) {
|
||||
secrets,
|
||||
credentials,
|
||||
network,
|
||||
ai,
|
||||
selectedContext,
|
||||
ui,
|
||||
vault,
|
||||
clipboard,
|
||||
filesystem,
|
||||
companions,
|
||||
|
||||
@@ -51,7 +51,6 @@ 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;
|
||||
@@ -203,7 +202,6 @@ 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);
|
||||
}
|
||||
|
||||
@@ -241,6 +239,10 @@ class RuntimeSupervisor {
|
||||
signal.throwIfAborted();
|
||||
const plugin = this.database.getActivePlugin(pluginId);
|
||||
if (!plugin?.manifest || !plugin.packageRelativePath) throw new Error(`Plugin is not installed: ${pluginId}`);
|
||||
// Capture the host's opaque source owner before async package/placement work.
|
||||
// Test doubles without provenance APIs cannot use source-bound capabilities.
|
||||
const sourceOwner = plugin.source && typeof this.database.privateOwner === "function"
|
||||
? this.database.privateOwner(pluginId) : null;
|
||||
if (!plugin.enabled) throw new Error(`Plugin is disabled: ${pluginId}`);
|
||||
if (plugin.manifest.main.data) throw new Error("Static theme packs have no executable runtime");
|
||||
if (plugin.runtime.quarantinedAt != null) throw new Error(`Plugin is quarantined: ${pluginId}`);
|
||||
@@ -275,12 +277,14 @@ class RuntimeSupervisor {
|
||||
const currentPlugin = this.database.getActivePlugin(pluginId);
|
||||
if (
|
||||
!currentPlugin?.enabled
|
||||
|| (sourceOwner !== null && this.database.privateOwner(pluginId) !== sourceOwner)
|
||||
|| (plugin.source && currentPlugin.source?.id !== plugin.source.id)
|
||||
|| currentPlugin.activeVersion !== plugin.activeVersion
|
||||
|| currentPlugin.runtime.quarantinedAt != null
|
||||
) {
|
||||
throw new PluginRpcError(RPC_ERRORS.unavailable, "Plugin changed while runtime placement was resolved");
|
||||
}
|
||||
let logger;
|
||||
const logger = new PluginLogger({ pluginId, logsDirectory: this.paths.logs });
|
||||
let runtime;
|
||||
const identity = Object.freeze({
|
||||
pluginId,
|
||||
@@ -288,14 +292,17 @@ class RuntimeSupervisor {
|
||||
runtimeId: randomUUID(),
|
||||
runtimeKind: kind,
|
||||
securityPrincipal,
|
||||
...(sourceOwner === null ? {} : { sourceOwner, sourceId: plugin.source.id }),
|
||||
manifest: freezeJson(structuredClone(plugin.manifest)),
|
||||
packageRoot,
|
||||
get logger() { return logger; },
|
||||
logger,
|
||||
assertCurrent: () => {
|
||||
const active = this.database.getActivePlugin(pluginId);
|
||||
if (
|
||||
this.runtimes.get(pluginId) !== runtime
|
||||
|| !active?.enabled
|
||||
|| (sourceOwner !== null && this.database.privateOwner(pluginId) !== sourceOwner)
|
||||
|| (plugin.source && active.source?.id !== plugin.source.id)
|
||||
|| active.activeVersion !== plugin.activeVersion
|
||||
|| (active.runtime.status !== "starting" && active.runtime.status !== "running")
|
||||
) {
|
||||
@@ -303,13 +310,11 @@ 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.audit({ operation: "runtime.protocol", authorization: "not-reached", result: "error", errorCode: error?.code });
|
||||
return logger.write("error", "Plugin protocol violation", { error: error?.message });
|
||||
};
|
||||
const onProtocolError = (error) => logger.write("error", "Plugin protocol violation", {
|
||||
error: error?.message ?? String(error),
|
||||
});
|
||||
runtime = kind === "browser"
|
||||
? this.runtimeFactories.browser({
|
||||
electron: this.electron,
|
||||
@@ -545,9 +550,9 @@ 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 } : {}),
|
||||
pluginId: identity.pluginId,
|
||||
pluginVersion: identity.pluginVersion,
|
||||
runtimeId: identity.runtimeId,
|
||||
@@ -580,6 +585,7 @@ class RuntimeSupervisor {
|
||||
runtimeId: identity.runtimeId,
|
||||
runtimeKind: identity.runtimeKind,
|
||||
securityPrincipal: identity.securityPrincipal,
|
||||
...(identity.sourceOwner ? { sourceOwner: identity.sourceOwner, sourceId: identity.sourceId } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -656,7 +662,7 @@ class RuntimeSupervisor {
|
||||
if (typeof runtime.openStream !== "function") {
|
||||
throw new PluginRpcError(RPC_ERRORS.unavailable, `Plugin runtime cannot receive streams: ${pluginId}`);
|
||||
}
|
||||
const stream = await runtime.openStream(streamId, windowBytes);
|
||||
const stream = await runtime.openStream(streamId, windowBytes, { onClose: options?.onClose });
|
||||
try {
|
||||
const current = await this.#getRunningRuntime(pluginId);
|
||||
if (current.runtime !== runtime || current.identity.runtimeId !== identity.runtimeId) {
|
||||
|
||||
@@ -1002,30 +1002,3 @@ 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);
|
||||
});
|
||||
@@ -158,6 +158,8 @@ class PluginStreamRouter {
|
||||
this.outgoing.delete(frame.streamId);
|
||||
for (const pending of outgoing.queue) pending.reject(new Error(`Plugin stream cancelled: ${frame.streamId}`));
|
||||
outgoing.queue.length = 0;
|
||||
outgoing.queuedBytes = 0;
|
||||
this.#notifyOutgoingClosed(outgoing, "cancel");
|
||||
return;
|
||||
}
|
||||
const state = this.incoming.get(frame.streamId);
|
||||
@@ -233,7 +235,8 @@ class PluginStreamRouter {
|
||||
state.onClose = handlers.onClose;
|
||||
}
|
||||
|
||||
async openOutgoing(streamId, windowBytes) {
|
||||
async openOutgoing(streamId, windowBytes, options = {}) {
|
||||
if (options.onClose !== undefined && typeof options.onClose !== "function") throw new TypeError("Stream onClose must be a function");
|
||||
if (
|
||||
this.closed
|
||||
|| this.outgoing.has(streamId)
|
||||
@@ -268,6 +271,7 @@ class PluginStreamRouter {
|
||||
queuedBytes: 0,
|
||||
closed: false,
|
||||
terminalKind: null,
|
||||
onClose: options.onClose,
|
||||
};
|
||||
this.outgoing.set(streamId, state);
|
||||
try {
|
||||
@@ -347,6 +351,13 @@ class PluginStreamRouter {
|
||||
for (const pending of state.queue) pending.reject(error);
|
||||
state.queue.length = 0;
|
||||
state.queuedBytes = 0;
|
||||
this.#notifyOutgoingClosed(state, error);
|
||||
}
|
||||
|
||||
#notifyOutgoingClosed(state, reason) {
|
||||
if (state.closeNotified) return;
|
||||
state.closeNotified = true;
|
||||
try { state.onClose?.(reason); } catch {}
|
||||
}
|
||||
|
||||
#finishOutgoing(state, kind) {
|
||||
@@ -373,6 +384,7 @@ class PluginStreamRouter {
|
||||
) {
|
||||
this.outgoing.delete(state.streamId);
|
||||
}
|
||||
this.#notifyOutgoingClosed(state, kind);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -413,6 +425,7 @@ class PluginStreamRouter {
|
||||
for (const state of this.outgoing.values()) {
|
||||
state.closed = true;
|
||||
for (const pending of state.queue) pending.reject(error);
|
||||
this.#notifyOutgoingClosed(state, error);
|
||||
}
|
||||
this.outgoing.clear();
|
||||
for (const state of this.incoming.values()) {
|
||||
|
||||
@@ -208,9 +208,9 @@ class UtilityPluginRuntime {
|
||||
this.router.notify(method, params);
|
||||
}
|
||||
|
||||
openStream(streamId, windowBytes) {
|
||||
openStream(streamId, windowBytes, options) {
|
||||
if (!this.router) return Promise.reject(new Error("Plugin utility runtime is not connected"));
|
||||
return this.router.streams.openOutgoing(streamId, windowBytes);
|
||||
return this.router.streams.openOutgoing(streamId, windowBytes, options);
|
||||
}
|
||||
|
||||
attachTerminalInterceptor(descriptor, port) {
|
||||
|
||||
@@ -128,11 +128,6 @@ 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"),
|
||||
getPluginThemes: () => ipcRenderer.invoke("netcatty:plugins:theme-catalog"),
|
||||
applyPluginTheme: (request) => ipcRenderer.invoke("netcatty:plugins:theme-apply", request),
|
||||
onPluginThemesChanged: (callback) => {
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
# Assistant example source
|
||||
|
||||
This panel contains the minimal example UI: a host-selected model, one explicitly
|
||||
selected Note, Send, streamed text, Stop, and token usage. It uses the existing
|
||||
`netcattyView.executeCommand` surface. It has no direct network access or Node API.
|
||||
|
||||
The runtime entry and package manifest are pending the parent's shared contract
|
||||
and SDK decision. This directory is not yet an installable plugin.
|
||||
The command names in `panel.js` are example-local command IDs, not host RPC methods.
|
||||
A menu or command ID grants no authority to read a resource.
|
||||
|
||||
The QA harness binds these commands to `ModelInvocationBroker` and
|
||||
`SelectedContextAuthority`. The production runtime must bind them through the
|
||||
approved canonical SDK methods, with host permission checks and provenance.
|
||||
The local deterministic provider proves the pipeline. It does not perform model
|
||||
inference, use paid credentials, or read a real user's data.
|
||||
@@ -0,0 +1,4 @@
|
||||
import { mkdir, copyFile } from 'node:fs/promises';
|
||||
await mkdir(new URL('./dist/', import.meta.url), { recursive: true });
|
||||
await copyFile(new URL('./src/index.js', import.meta.url), new URL('./dist/index.js', import.meta.url));
|
||||
import { URL } from 'node:url';
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"manifestVersion": 1,
|
||||
"id": "com.netcatty.assistant",
|
||||
"name": "assistant-example",
|
||||
"displayName": "Selected Note Assistant",
|
||||
"description": "Minimal sandbox assistant using a host-selected model and one explicitly selected reference. No tools or commands are executed.",
|
||||
"version": "0.1.0",
|
||||
"publisher": "netcatty",
|
||||
"license": "GPL-3.0-or-later",
|
||||
"engines": { "netcatty": ">=0.0.0", "api": ">=0.1.0-internal.1 <0.2.0" },
|
||||
"main": { "browser": "dist/index.js" },
|
||||
"activationEvents": [ "onView:com.netcatty.assistant.panel" ],
|
||||
"permissions": {
|
||||
"required": [ "commands", "views" ],
|
||||
"optional": [ "ai.invoke", "context.selected.list", "context.selected.read" ]
|
||||
},
|
||||
"features": { "required": [ "ai.model-invocation", "context.selected-read" ] },
|
||||
"contributes": {
|
||||
"commands": [
|
||||
{ "id": "com.netcatty.assistant.open", "title": "Assistant: Open Chat" },
|
||||
{ "id": "com.netcatty.assistant.contexts", "title": "Assistant: Selected References" },
|
||||
{ "id": "com.netcatty.assistant.send", "title": "Assistant: Send" },
|
||||
{ "id": "com.netcatty.assistant.read", "title": "Assistant: Read Stream" },
|
||||
{ "id": "com.netcatty.assistant.stop", "title": "Assistant: Stop" },
|
||||
{ "id": "com.netcatty.assistant.close", "title": "Assistant: Close Chat" }
|
||||
],
|
||||
"views": [ { "id": "com.netcatty.assistant.panel", "title": "Selected Note Assistant", "location": "modal", "entry": "panel.html" } ]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"name": "@netcatty/example-assistant-plugin",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "node build.mjs"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
:root { color-scheme: dark; font: 15px/1.5 system-ui,sans-serif; color:#e9edf6; background:#10141c; }
|
||||
* { box-sizing:border-box; }
|
||||
body { margin:0; }
|
||||
main { max-width:760px; padding:36px; margin:auto; }
|
||||
header { margin-bottom:26px; }
|
||||
.badge { color:#8bb9ff; font-size:11px; font-weight:700; letter-spacing:1.6px; }
|
||||
h1 { font-size:27px; font-weight:650; letter-spacing:-.6px; margin:8px 0; }
|
||||
p { color:#a9b4c7; margin:4px 0; }
|
||||
.card { border:1px solid #303c4f; border-radius:10px; padding:16px 18px; margin:14px 0; background:#181f2a; }
|
||||
label { display:block; font-size:12px; font-weight:600; color:#aabbd5; margin-bottom:8px; }
|
||||
#model { font-weight:600; }
|
||||
select, textarea { width:100%; background:#10161f; color:#e9edf6; border:1px solid #455570; padding:10px 12px; border-radius:7px; font:inherit; }
|
||||
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; }
|
||||
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; }
|
||||
#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; }
|
||||
.hint,footer { font-size:12px; color:#9aabc3; margin-top:8px; }
|
||||
footer { padding-top:8px; }
|
||||
@media(max-width:520px) { main { padding:22px; } h1 { font-size:23px; } }
|
||||
@@ -0,0 +1,21 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'none'; img-src 'none'; base-uri 'none'; form-action 'none'">
|
||||
<title>Assistant example</title>
|
||||
<link rel="stylesheet" href="panel.css">
|
||||
</head>
|
||||
<body>
|
||||
<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 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>
|
||||
<script src="panel.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,77 @@
|
||||
/* global document, window */
|
||||
'use strict';
|
||||
const element = (id) => document.getElementById(id);
|
||||
const invoke = (operation, params = {}) => window.netcattyView.executeCommand(`com.netcatty.assistant.${operation}`, params);
|
||||
let chat;
|
||||
let turn;
|
||||
let generation = 0;
|
||||
const references = new Map();
|
||||
function busy(value) {
|
||||
element('send').disabled = value || !chat;
|
||||
element('stop').disabled = !value;
|
||||
element('context').disabled = value;
|
||||
element('prompt').disabled = value;
|
||||
}
|
||||
async function initialize() {
|
||||
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);
|
||||
}
|
||||
element('status').textContent = 'Ready';
|
||||
busy(false);
|
||||
} catch {
|
||||
element('status').textContent = 'Choose a model and grant access in Netcatty.';
|
||||
}
|
||||
}
|
||||
element('compose').addEventListener('submit', async (event) => {
|
||||
event.preventDefault();
|
||||
if (!chat || element('send').disabled) return;
|
||||
const run = ++generation;
|
||||
busy(true); element('status').textContent = 'Streaming...';
|
||||
element('response').textContent = ''; element('usage').textContent = '';
|
||||
try {
|
||||
const reference = references.get(element('context').value);
|
||||
const started = await invoke('send', { chatHandle: chat.chatHandle, prompt: element('prompt').value,
|
||||
contexts: reference ? [reference] : [] });
|
||||
if (run !== generation) { await invoke('stop', { turnHandle: started.turnHandle }); return; }
|
||||
turn = started.turnHandle;
|
||||
for (;;) {
|
||||
const chunk = await invoke('read', { turnHandle: turn });
|
||||
if (run !== generation) return;
|
||||
if (chunk.kind === 'end') break;
|
||||
if (chunk.kind === 'text') element('response').textContent += chunk.text;
|
||||
if (chunk.kind === 'usage') element('usage').textContent = `${chunk.inputTokens ?? '?'} input tokens · ${chunk.outputTokens ?? '?'} output tokens`;
|
||||
}
|
||||
element('status').textContent = 'Complete';
|
||||
} catch {
|
||||
if (run === generation) element('status').textContent = 'Request ended. Check access or try again.';
|
||||
} finally {
|
||||
if (run === generation) { turn = undefined; busy(false); }
|
||||
}
|
||||
});
|
||||
element('stop').addEventListener('click', async () => {
|
||||
++generation;
|
||||
const stoppedTurn = turn; turn = undefined;
|
||||
element('status').textContent = 'Stopping...';
|
||||
try {
|
||||
if (stoppedTurn) await invoke('stop', { turnHandle: stoppedTurn });
|
||||
else if (chat) {
|
||||
await invoke('close', { chatHandle: chat.chatHandle });
|
||||
chat = await invoke('open');
|
||||
}
|
||||
}
|
||||
finally { 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', () => { if (turn) invoke('stop', { turnHandle: turn }).catch(() => {}); });
|
||||
void initialize();
|
||||
@@ -0,0 +1,14 @@
|
||||
// Ordinary browser runtime. The host SDK supplies the only privileged surface.
|
||||
export default {
|
||||
activate(context) {
|
||||
const register = (name, handler) => context.subscriptions.add(context.commands.registerCommand(
|
||||
`com.netcatty.assistant.${name}`, handler,
|
||||
));
|
||||
register('open', () => context.ai.openChat({}));
|
||||
register('contexts', async () => (await context.selectedContext.list({})).items);
|
||||
register('send', (params) => context.ai.startTurn(params));
|
||||
register('read', (params) => context.ai.readTurn(params));
|
||||
register('stop', (params) => context.ai.stopTurn(params));
|
||||
register('close', (params) => context.ai.closeChat(params));
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
import { CancellationTokenSource } from "@netcatty/plugin-sdk";
|
||||
import { queryExample } from "./protocol.mjs";
|
||||
import { endpoint } from "./fixture-config.mjs";
|
||||
|
||||
export default {
|
||||
activate(context) {
|
||||
let pending;
|
||||
const register = (suffix, handler) => context.subscriptions.add(context.commands.registerCommand(
|
||||
`com.netcatty.database-minimal.${suffix}`, handler,
|
||||
));
|
||||
const cancel = () => { pending?.cancel(); return { cancelled: true }; };
|
||||
register("cancel", cancel);
|
||||
register("endpoint", () => ({ endpoint }));
|
||||
context.subscriptions.add({ dispose: cancel });
|
||||
register("query", async (args) => {
|
||||
if (pending) throw new Error("An example connection is already active");
|
||||
if (!args || Array.isArray(args) || Object.keys(args).some((key) => !["username", "password"].includes(key))
|
||||
|| typeof args.username !== "string" || typeof args.password !== "string") throw new Error("Enter disposable fixture credentials");
|
||||
const cancellation = new CancellationTokenSource();
|
||||
pending = cancellation;
|
||||
let connection;
|
||||
try {
|
||||
connection = await context.network.connect({ endpoint }, cancellation.token);
|
||||
return await queryExample({ read: () => connection.readable.read(), write: (bytes) => connection.writable.write(bytes),
|
||||
close: () => connection.close() }, args.username, args.password);
|
||||
} finally {
|
||||
args.password = "";
|
||||
await connection?.close().catch(() => {});
|
||||
cancellation.dispose();
|
||||
pending = undefined;
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export const endpoint = "tcp://database-fixture.invalid:3306";
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"manifestVersion": 1,
|
||||
"id": "com.netcatty.database-minimal",
|
||||
"name": "database-minimal",
|
||||
"displayName": "Disposable database example",
|
||||
"description": "Connect to one prepared synthetic fixture and read one row.",
|
||||
"version": "0.1.0",
|
||||
"publisher": "netcatty",
|
||||
"license": "GPL-3.0-or-later",
|
||||
"engines": { "netcatty": ">=0.0.0", "api": ">=0.1.0-internal.1 <0.2.0" },
|
||||
"features": { "required": ["network.byte-streams"] },
|
||||
"main": { "browser": "browser.js" },
|
||||
"activationEvents": ["onCommand:com.netcatty.database-minimal.query", "onView:com.netcatty.database-minimal.panel"],
|
||||
"permissions": {
|
||||
"required": ["commands", "views"],
|
||||
"optional": [{ "permission": "network.stream", "resources": ["tcp://database-fixture.invalid:3306"], "reason": "Connect only to the prepared disposable fixture." }]
|
||||
},
|
||||
"contributes": {
|
||||
"commands": [
|
||||
{ "id": "com.netcatty.database-minimal.query", "title": "Read one synthetic database row" },
|
||||
{ "id": "com.netcatty.database-minimal.cancel", "title": "Cancel example connection" },
|
||||
{ "id": "com.netcatty.database-minimal.endpoint", "title": "Show example endpoint" }
|
||||
],
|
||||
"views": [{ "id": "com.netcatty.database-minimal.panel", "title": "Disposable database example", "location": "modal", "entry": "panel.html" }]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
:root{color-scheme:dark light;font:14px system-ui,sans-serif;color:hsl(var(--foreground,210 20% 90%));background:hsl(var(--background,220 15% 12%))}*{box-sizing:border-box}body{margin:0}main{max-width:640px;padding:24px;margin:auto}h1{font-size:24px}.eyebrow{font-size:11px;letter-spacing:.1em;opacity:.7}p{line-height:1.5}#endpoint,pre{overflow-wrap:anywhere;white-space:pre-wrap}label{display:block;margin:16px 0 6px}input{width:100%;padding:10px;border:1px solid hsl(var(--border,220 10% 35%));border-radius:6px;background:hsl(var(--background,220 15% 12%));color:inherit}.actions{display:flex;flex-wrap:wrap;gap:10px;margin:20px 0}button{padding:10px 16px;border:1px solid hsl(var(--border,220 10% 35%));border-radius:6px;color:inherit;background:hsl(var(--secondary,220 12% 22%));cursor:pointer}button:disabled{opacity:.5;cursor:default}input:focus-visible,button:focus-visible{outline:2px solid hsl(var(--primary,210 90% 60%));outline-offset:2px}pre{padding:14px;border:1px solid hsl(var(--border,220 10% 35%));border-radius:6px;min-height:80px}
|
||||
Loaded 100 of 124 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user